凌云的博客

行胜于言

程序的机器级表示

分类:os| 发布时间:2025-04-21 12:04:00

数据格式

由于是从 16 位体系结构扩展成 32 位的,Intel 用术语 “字” (word)表示 16 位数据类型。 因此,称 32 位数为 “双字” (double words),称 64 位数为 “四字” (quad words)。

下表为 C 语言数据类型在 IA32 中的大小。

C声明 Intel 数据类型 汇编代码后缀 大小(字节)
char 字节 b 1
short w 2
int 双字 l 4
long int 双字 l 4
long long int - - 4
char * 双字 l 4
float 单精度 s 4
double 双精度 l 8
long double 扩展精度 t 10/12

表1

访问信息

一个 IA32 中央处理单元(CPU)包含一组 8 个存储 32 位值的寄存器。 这些寄存器用来存储整数数据和指针。

在最初的 8086 中,寄存器是 16 位的,每个都有特殊的用途。 名字的选择就是用来反映这些不同的用途。 在平坦寻址中,对特殊寄存器的需求已经极大降低。

图 1

操作数指示符

大多数指令有一个或多个操作数,指示出执行一个操作中要引用的源数据值,以及放置结果的目标位置。 IA32 支持多种操作数格式(见下表)。 源数据值可以以常数形式给出,或是从寄存器或存储器中读出。结果可以存放在寄存器或存储器中。 各种不同的操作数的可能性被分为三种类型。

  • 第一种类型是立即数(immediate),也就是常数值。 在 ATT 格式的汇编代码中,立即数的书写方式是 '$' 后面跟一个用标准 C 表示法表示的整数,比如,$-577 或 $0x1F. 任何能放进一个 32 位的字里的数值都可以用做立即数,不过汇编器在可能时会使用一个或两个字节的编码。
  • 第二种类型是寄存器(register),它表示某个寄存器的内容,对于双字操作来说,可以是 8 个 32 位寄存器中的一个(例如,%eax), 对字操作来说,可以是 8 个 16 位寄存器中的一个(例如, %ax),或者对字节操作来说,可以是 8 个单字节寄存器元素中的一个(如 %al)。 在下表中,我们用符号 Ea 来表示任意寄存器 a,用引用 R[Ea] 来表示它的值
  • 第三种操作数是存储器(memory)引用,它会根据计算出来的地址(通常称为有效地址)访问某个存储器的位置。 用符号 Mb[Addr] 表示对存储在存储器中从地址 Addr 开始的 b 个字节值的引用。 为了简便,通常省去下方的 b。

如下表所示,有多种不同的寻址模式,允许不同形式的存储器引用。

类型 格式 操作数值 名称
立即数 $Imm Imm 立即数寻址
寄存器 Ea R[Ea] 寄存器寻址
存储器 Imm M[Imm] 绝对寻址
存储器 (Ea) M[R[Ea]] 间接寻址
存储器 Imm(Eb) M[Imm+R[Eb]] (基址+偏移量)寻址
存储器 (Eb, Ei) M[R[Eb]+R[Ei]] 变址寻址
存储器 Imm(Eb, Ei) M[Imm+R[Eb]+R[Ei]] 变址寻址
存储器 (,Ei,s) M[R[Ei]*s] 比例变址寻址
存储器 Imm(,Ei,s) M[R[Imm+Ei]*s] 比例变址寻址
存储器 (Eb, Ei,s) M[R[Eb]+R[Ei]*s] 比例变址寻址
存储器 Imm(Eb, Ei,s) M[Imm+R[Eb]+R[Ei]*s] 比例变址寻址

表2

数据传送指令

将数据从一个位置复制到另一个位置的指令是最频繁使用的指令。

下表列出了一些重要的数据传送指令。 我们把许多不同的指令分成了 指令类,一类中的指令执行一样的操作,只不过操作数的大小不同。 例如,MOV 类由三条指令组成:movb、movw 和 movl。

指令 效果 描述
MOV S, D D ← S 传送
movb
movw
movl
传送字节
传送字
传送双字
MOVS S, D D ← 符号扩展(S) 传送符号扩展的字节
movsbw
movsbl
movswl
将做了符号扩展的字节传送到字
将做了符号扩展的字节传送到双字
将做了符号扩展的字节传送到双字
MOVZ S, D D ← 零扩展(S) 传送零扩展的字节
movzbw
movzbl
movzwl
将做了零扩展的字节传送到字
将做了零扩展的字节传送到双字
将做了零扩展的字节传送到双字
pushl S R[%esp] ← R[%esp] - 4
M[R[%esp]] ← S
将双字压栈
popl D D ← M[R[%esp]]
R[%esp] ← R[%esp] + 4
将双字出栈

表3

栈可以实现为一个数组,总是从数组的一端插入和删除元素。 这一端称为栈顶。 如下图所示,栈向下增长,栈顶元素的地址是所有栈中元素地址最低的。 (根据惯例,栈是倒过来画的,栈顶在图的底部。)

图 2 栈示例

算术和逻辑操作

下表列出一些整数和逻辑操作。 大多数操作都分成了指令类,这些指令类有各种带不同大小操作数的变种。(只有 leal 没有其他大小的变种。)

例如,指令类 ADD 由三条加法指令组成:addb、addw 和 addl,分别是字节加法、字加法和双字加法。

给出的每个指令类都有字节、字和双字数据进行操作的指令。

这些操作被分为四组:加载有效地址、一元操作、二元操作和移位。

leal 实际上是 movl 指令的变形。它的指令形式是从存储器读数据到寄存器,但实际上它根本就没有引用存储器。

指令 效果 描述
leal S, D D ← &S 加载有效地址
- - -
INC D D ← D + 1 加 1
DEC D D ← D - 1 减 1
NEG D D ← -D 取负
NOT D D ← ~D 取补
- - -
ADD S, D D ← D + S
SUB S, D D ← D - S
IMUL S, D D ← D * S
XOR S, D D ← D ^ S 异或
OR S, D D ← D S
AND S, D D ← D & S
- - -
SAL k, D D ← D << k 左移
SHL k, D D ← D << k 左移(等同于 SAL)
SAR k, D D ← D >>A k 算术右移
SHR k, D D ← D >>L k 逻辑右移

表4

第二组中的操作是一元操作,它只有一个操作数,既是源又是目的。 这个操作数可以是一个寄存器,也可以是一个存储器位置。 这种语法让人想起 C 语言中的加 1 运算符(++)和减 1 运算符。

第三组是二元操作,其中,第二个操作数既是源又是目的。 这种语法让人想起 C 语言中的赋值运算符,例如 x+= y。

最后一组是移位操作,先给出移位量,然后第二项给出的是要移位的位数。

特殊的算术操作

下表描述的指令支持产生两个 32 位数字的全 64 位乘积以及整数除法。

指令 效果 描述
imull S R[%edx]:R[%eax] ← S × R[%eax] 有符号全64位乘法
mull S R[%edx]:R[%eax] ← S × R[%eax] 无符号全64位乘法
cltd R[%edx]:R[%eax] ← SignExtend(R[%eax]) 转为四字
idivl S R[%edx] ← R[%edx]:R[%eax] mod S;
R[%eax] ← R[%edx]:R[%eax] ÷ S
有符号除法
divl S R[%edx] ← R[%edx]:R[%eax] mod S;
R[%eax] ← R[%edx]:R[%eax] ÷ S
无符号除法

表5

表4列出的 imull 指令称为“双操作数”乘法指令。 它从两个 32 位操作数产生一个 32 位乘积。

IA32 还提供了两个不同的“单操作数”乘法指令,以计算两个 32 位值的全 64 位乘积。 这两调指令都要求一个参数必须在寄存器 %eax 中,而另一个作为指令的源操作数给出。

控制

目前为止,我们只考虑了直线代码的行为,也就是指令一条接着一条顺序执行。

C 语言中的某些结构,比如条件语句、循环语句和分支语句,要求有条件执行,根据数据测试的结果来决定操作执行的顺序。

机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。

条件码

除了整数寄存器,CPU 还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。 可以检测这些寄存器来执行条件分支指令。

最常用的条件码有:

  • CF:进位标志。最近的操作使最高位产生了进位。可以用来检查无符号操作数的溢出。
  • ZF:零标志。最近的操作得出的结果为 0。
  • SF:符号标志。最近的操作得到的结果是负数。
  • OF:溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出。

leal 指令不改变任何条件码,因为它是用来进行地址计算的。

除了表4中的指令会设置条件码,有两类指令(有 8、16和32位形式),它们只设置条件吗而不改变任何其他寄存器。

CMP 指令根据它们两个操作数之差来设置条件码。 除了只设置条件码而不更新目标寄存器之外,CMP 指令与 SUB 指令的行为是一样的。

TEST 指令的行为与 AND 指令一样,除了它只设置条件码而不改变目的寄存器的值。

指令 基于 描述
CMP S2, S1 S1-S2 比较
- - -
cmpb Compare byte
cmpw Compare word
cmpl Compare double world
- - -
TEST S2, S1 S1 & S2 测试
- - -
testb Test byte
testw Test word
testl Test double word

表6:比较和测试指令

访问条件码

条件码通常不会直接读取,常用的使用方法有三种:

  1. 可以根据条件码的某个组合,将一个字节设置为 0 或者 1
  2. 可以条件跳转到程序的某个其他的部分
  3. 可以有条件地传送数据

表7描述的指令根据条件码的某个组合,将一个字节设置为 0 或者 1。

指令 同义名 效果 设置条件
sete D setz D ← ZF 相等/零
setne D setnz D ← ~ZF 不等/非零
- - - -
sets D D ← SF 负数
setns D D ← ~SF 非负数
- - - -
setg D setnle D ← ~(SF ^ OF) & ~ZF 大于(有符号>)
setge D setnl D ← ~(SF ^ OF) 大于等于(有符号>=)
setl D setnge D ← SF ^ OF 小于(有符号<)
setle D setng D ← (SF ^ OF) | ZF 小于等于(有符号<=)
- - - -
seta D setnbe D ← ~CF & ~ZF 超过(无符号>)
setae D setnb D ← ~CF 超过或相等(无符号>=)
setb D setnae D ← CF 低于(无符号<)
setbe D setna D ← CF | ZF 低于或相等(无符号<=)

表7:SET 指令,根据条件码的某个组合,将一个字节设置为 0 或者 1

一条 SET 指令的目的操作数是 8 个单字节寄存器元素(图 1)之一,或是存储一个字节的存储器位置,将这个字节设置成 0 或者 1。

跳转指令及其编码

正常执行的情况下,指令按照它们出现的顺序一条一条地执行。 跳转(jump)指令会导致执行切换到程序中一个全新的位置。 在汇编代码中,这些跳转目的地通常用一个标号(label)执行。例如:

1       movl    $0,%eax         Set %eax to 0
2       jmp     .L1             Goto .L1
3       movl    (%eax), %edx    Null pointer deference
4   .L1:
5       popl %edx

指令 jmp .L1 会导致程序跳过 movl 指令,从 popl 指令开始继续执行。 在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标(目的指令的地址)编码位跳转指令的一部分。

jmp 指令是无条件跳转,它可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或存储器位置中读出的。

汇编语言中,直接跳转是给出一个标号作为跳转目标的,例如上面所示代码中的标号 “.L1”。 间接跳转的写法是 “*” 后面跟一个操作数指示符,可以使用 表 2 中描述的格式中的一种。例如:

jmp *%eax

用寄存器 %eax 中的值作为跳转目标,而指令

jmp *(%eax)

以 %eax 中的值作为读地址,从存储器中读出跳转目标。

下表中除 jmp 外的其他跳转指令都是有条件的——它们根据条件码的某个组合,或者跳转,或者继续执行代码序列中下一条指令。

这些指令的名字和它们的跳转条件与 SET 指令是相匹配的(参考表 7)。 条件跳转只能是直接跳转。

跳转指令有几种不同的编码,但是最常用的都是 PC 相关的(PC = Program Counter,程序计数器)。 它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。 这些地址偏移量可以编码位 1、2 或 4 个字节。 第二种编码方法是给出“绝对”地址,用 4 个字节值指定目标。 汇编器和链接器会选择适当的跳转目的编码。

指令 同义名 跳转条件 描述
jmp Label 1 直接跳转
jmp *Operand 1 间接跳转
je Label jz ZF 相等/零
jne Label jnz ~ZF 不相等/非零
js Label SF 负数
jns Label ~SF 非负数
jg Label jnle ~(SF ^ OF) & ~ZF 大于(有符号>)
jge Label jnl ~(SF ^ OF) 大于或等于(有符号>=)
jl Label jnge SF ^ OF 小于(有符号<)
jle Label jng (SF ^ OF) | ZF 小于或等于(有符号<=)
ja Label jnbe ~CF & ~ZF 超过(无符号>)
jae Label jnb ~CF 超过或相等(无符号 >=)
jb Label jnae CF 低于(无符号 <)
jbe Label jna CF | ZF 低于或相等(无符号 <=)

表8:jump指令

翻译条件分支

将条件表达式和语句从 C 语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。

C 语言中 if-else 语言的通用形式模板是这样的:

if (text-expr)
    then-statement
else
    else-statement

对于这种通用形式,汇编实现通常会使用下面这种形式,这里,我们用 C 语法来描述控制流:

t = test-expr;
if (!t)
    goto false;
then-statement
goto done;
false:
    else-statement
done:

也就是,汇编器为 then-statement 和 else-statement 产生各自的代码块。 它会插入条件和无条件分支,以保证能执行正确的代码块。

循环

C 语言提供了多种循环结构,即 do-while、while 和 for。 汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。

大多数汇编器根据一个循环的 do-while 形式来产生循环代码,即使在实际程序中这种形式用的相对较少。 其他的循环会首先转换成 do-while 形式,然后再编译成机器代码。

do-while 语句的通用形式如下:

do
    body-statement
    while (test-expr);

可以翻译成如下所示的条件和 goto 语句:

loop:
    body-statement
    t = test-expr;
    if (t)
        goto loop;

也就是说,每次循环,程序会执行循环体里的语句,然后执行测试表达式。 如果测试为真,则回去再执行一次循环。

while 语句的通用形式如下:

while (test-expr)
    body-statement

将 while 循环翻译成机器代码有很多种方法。 一种常见的方法,也是 GCC 采用的方法,是使用条件分支,在需要时省略循环体的第一次执行,从而将代码转换成 do-while 循环,如下:

if (!test-expr)
    goto done;

do
    body-statement
    while (test-expr);
done:

接下来,这个代码可以直接翻译成 goto 代码,如下:

t = test-expr;
if (!t)
    goto done;
loop:
    body-statement
    t = test-expr;
    if (t)
        goto loop;
done:

for 循环的通用形式如下:

for (init-expr; test-expr; update-expr)
    body-statement;

这样一个循环的行为与下面这段使用 while 循环代码的行为一样:

init-expr;
while (test-expr) {
    body-statement
    update-expr;
}

这段代码编译后的形式,基于前面讲过的从 while 到 do-while 的转换,首先给出 do-while 形式:

init-expr;
if (!test-expr)
    goto done;
do {
    body-statement
    update-expr;
} while (test-expr);
done:

然后,将它转换成 goto 代码:

    init-expr;
    t = test-expr;
    if (!t)
        goto done;
loop:
    body-statement
    update-expr;
    t = test-expr;
    if (t)
        goto loop;
done:

条件传送指令

实现条件操作的传统方法是利用控制的条件转移。 当条件满足时,程序沿着一条执行路径进行,而当条件不满足时,就走另一条路径。 这种机制简单而通用,但是在现代处理器上,它可能会非常的低效率。

数据的条件转移是一种替代的策略。 这种方法先计算一个条件操作的两种结果,然后再根据条件是否满足从而选取一个。 只有在一些受限制的情况下,这种策略才可行,但是如果可行,就可以用一条简单的条件传送指令来实现它。 条件传送指令更好地匹配了现代处理器的性能特性。

下表列举了一些条件传送指令,i686 开始支持。 其中,每一条都有两个操作数:源寄存器或者存储地址 S,和目的寄存器 R。 源值可以从存储器或者源寄存器中读取,但是只有在满足指定的条件时,才被复制到目的寄存器中。

对于 IA32 来说,源值和目的值可以是 16 位或 32 位长,不支持单字节的条件传送。 汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个指令名字。

与条件跳转不同,处理器可以执行条件传送,而无需预测测试的结果。

指令 同义名 传送条件 描述
cmove S, R cmovz ZF 相等/零
cmovne S, R cmovnz ~ZF 不相等/非零
- - - -
cmovs S, R SF 负数
cmovns S, R ~SF 非负数
- - - -
cmovg S, R cmovnle ~(SF ^ OF) & ~ZF 大于(有符号>)
cmovge S, R cmovnl ~(SF ^ OF) 大于或等于(有符号>=)
cmovl S, R cmovnge SF ^ OF 小于(有符号<)
cmovle S, R cmovng (SF ^ OF) | ZF 小于或等于(有符号<=)
- - - -
cmova S, R cmovnbe ~CF & ~ZF 超过(无符号>)
cmovae S, R cmovnb ~CF 超过或相等(无符号>=)
cmovb S, R cmovnae CF 低于(无符号<)
cmovbe S, R cmovna CF | ZF 低于或相等(无符号<=)

表9:条件传送指令。当传送条件满足时,将 S 值复制到 R 中

为了理解如何通过天剑输入传送来实现条件操作,考虑下面的条件表达式和赋值的通用形式:

v = test-expr ? then-expr : else-expr;

对于传统的 IA32,编译器产生的代码具有以下抽象代码所示的形式:

    if (!test-expr)
        goto false;
    v = true-expr;
    goto done;
false:
    v = else-expr;
done:

基于条件传送的代码,会对 then-expr 和 else-expr 都求值,最终值的选择基于对 test-expr 的求值。 可以用下面的抽象代码描述:

vt = then-expr;
v  = else-expr;
t  = test-expr;
if (t) v = vt;

这个序列的最后一条语句是用条件传送实现的,只有当测试条件 t 满足时,vt 的值才会被复制到 v 中。

不是所有的条件表达式都可以用条件传送来编译。 我们给出的抽象代码会对 then-expr 和 else-expr 都求值,无论测试结果如何。 如果这两个表达式中任意一个可能产生错误条件或者副作用,就会导致非法行为。

例如:

int cread(int *xp) {
    return (xp ? * xp : 0);
}

乍一看,这段代码似乎很适合编译,使用条件传送读指针 xp 所指向的值,汇编代码如下所示:

    Invalid implementation of function cread
    xp in register %edx
1       movl    $0, %eax            Set 0 as return value
2       testl   %edx, %edx          Test xp
3       cmmovne (%edx), %eax        if !0, dereference xp to get return value

不过,这个实现是非法的,因此即使当测试为假时,cmovne 指令(第 3 行)对 xp 的间接引用还是发生了,导致一个间接引用空指针的错误。

条件数据传送提供了一种用条件控制转移来实现条件操作的替代策略。 它们只能用于很受限制的情况,但是这些情况还是相当常见的,而且充分利用到了现代处理器的运行方式。

switch 语句

switch 语句可以根据一个整数索引值进行多重分支(multi-way branching)。 处理具有多种可能结果的测试时,这种语句特别有用。 它们不仅提高了 C 代码的可读性,而且通过使用跳转表(jump table)这种数据结构使得实现更加高效。 跳转表是一个数组,表项 i 是一个代码段的地址,这个代码段实现当开关索引值等于 i 时程序应该采取的动作。 程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。

过程

一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。 另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。 大多数机器,包括 IA32,只提供转移控制到过程中转移出控制这种简单的指令。 数据传递、局部变量的分配和释放通过操纵程序栈来实现。

栈帧结构

IA32 程序用程序栈来支持过程调用。 机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。 为单个过程分配的那部分栈称为栈帧(stack frame)。

下图 3 描绘了栈帧的通用结构。栈帧的最顶端以两个指针界定,寄存器 %ebp 为帧指针,而寄存器 %esp 为栈指针。 当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于帧指针的。

图 3 栈帧结构(栈用来传递参数、存储返回信息、保存寄存器,以及本地存储)

转移控制

下表示支持过程调用和返回的指令:

指令 描述
call Label 过程调用
call *Operand 过程调用
leave 为返回准备栈
ret 从过程调用中返回

表 10

call 指令有一个目标,即表明被调用过程其实的指令地址。 同跳转一样,调用可以是直接的,也可以是间接的。 在汇编代码中,直接调用的目标是一个标号,而间接调用的目标是 * 后面跟一个操作数指示符(使用 表 2 中的格式之一)。

call 指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。 返回地址是程序中紧跟在 call 后面的那条指令的地址,这样当被调用过程返回时,执行会从此处继续。

ret 指令从栈中弹出地址,并跳转到这个位置。

下面是一个过程调用的反汇编代码节选:

    Begining of function sum
1   08048394 <sum>:
2    8048394:   55                      push %ebp
     ...
    Return from function sum
3    80483a4:   c3                      ret
     ...
    Call to sum from main
4    80483dc:   e8 b3 ff ff ff          call 8048394 <sum>
5    80483e1:   83 c4 14                add  $0x14,%esp

下图说明了上述汇编代码 call 和 ret 指令的执行情况:

图 4 call 和 ret 函数的说明。

用 leave 指令可以使栈做好返回的准备。 它等价于下面的代码序列:

1   movl %ebp, %esp     Set stack pointer to begining of fram
2   popl %ebp           Restore saved %ebp and set stack ptr to end of caller's frame

如果函数要返回整数或指针的话,寄存器 %eax 可以用了返回值。

寄存器使用惯例

程序寄存器组是唯一能被所有过程共享的资源。 虽然在给定时刻只能有一个过程是活动的,但是我们必须保证当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖某个调用者稍后会使用的寄存器的值。 为此,IA32 采用了一组统一的寄存器使用惯例,所有的过程都必须遵守,包括程序库中的过程。

根据惯例,寄存器 %eax、%edx 和 %ecx 被划分为调用者保存寄存器。 当过程 P 调用 Q 时,Q 可以覆盖这些寄存器,而不会破坏任何 P 所需要的数据。

另一方面,寄存器 %ebx、%esi 和 %edi 被划分为被调用者保存寄存器。 这意味着 Q 必须在覆盖这些寄存器的值之前,先把它们保存在栈中,并在返回前恢复它们。

此外,根据这里描述的惯例,必须保持寄存器 %ebp 和 %esp。

过程示例

假设有如下 C 过程,其中函数 caller 包括一个对函数 swap_add 的调用。

1   int swap_add(int *xp, int *yp)
2   {
3       int x = *xp;
4       int y = *yp;
5
6       *xp = y;
7       *yp = x;
8       return x + y;
9   }
10
11  int caller()
12  {
13      int arg1 = 534;
14      int arg2 = 1057;
15      int sum = swap_add(&arg1, &arg2);
16      int diff = arg1 - arg2;
17
18      return sum * diff;
19  }

下图给出了 caller 调用函数 swap_add 之前和 swap_add 正在运行时的栈帧结构。 有些指令访问的栈位置是相对于栈指针 %esp 的,而另一些访问的栈位置是相对于基地址指针 %ebp 的。

图 5 caller 和 swap_add 的栈帧。

caller 的栈帧包括局部变量 arg1 和 arg2 的存储,其位置相对于帧指针是 -4 和 -8。 这些变量必须存在栈中,因为我们必须为它们生成地址。接下来的这段汇编代码来自 caller 编译过的编译版本,说明它如何调用 swap_add:

1   caller:
2       pushl       %ebp            Save old %ebp
3       movl        %esp, %ebp      Set %ebp as frame pointer
4       subl        $24, %esp       Allocate 24 bytes on stack
5       movl        $534, -4(%ebp)  Set arg1 to 534
6       movl        $1057, -8(%ebp) Set arg2 to 1057
7       leal        -8(%ebp), %eax  Compute &arg2
8       movl        %eax, 4(%esp)   Store on stack
9       leal        -4(%ebp), %eax  Compute &arg1
10      movl        %eax, (%esp)    Store on  stack
11      call        swap_add        Call the swap_add function

12      movl        -4(%ebp), %edx
13      subl        -8(%ebp), %edx
14      imull       %edx, %eax
15      leave
16      ret

这段代码保存了 %ebp 的一个副本,将 %ebp 设置为栈帧的开始位置(第 2~3 行)。 然后将栈指针减去 24,从而在栈上分配 24 个字节。 将 arg1 和 arg2 分别初始化为 534 和 1057(第 5~6 行),计算 $arg2 和 &arg1 的值并存储到栈上,形成函数 swap_add 的参数(第 7~10 行)。 将这些参数存储到相对于栈指针偏移量为 0 和 +4 的地方,留待稍后 swap_add 访问。 然后调用 swap_add。分配给栈帧的 24 个字节中 ,8个用于局部变量,8个用于向 swap_add 传递参数,还有 8 个未使用。

为什么 GCC 分配从不使用的空间
GCC 为 caller 参数的代码在栈上分配了 24 个字节,但是只使用了其中的 16 个。 GCC 坚持一个 x86 编程指导方针,也就是一个函数使用的所有栈空间必须是 16 字节的整数倍。 包括保存 %ebp 值的 4 个字节和返回值的 4 个字节,caller 一共使用了 32 个字节。 采用这个规则是为了保证访问数据的严格对其。

swap_add 编译过的代码有三个部分:“建立”部分,初始化栈帧;“主体”部分,执行过程的实际计算;“结束”部分,恢复栈的状态,以及过程返回。

1   swap_add:
2       pushl   %ebp                Save old %ebp
3       movl    %esp, %ebp          Set %ebp as frame pointer
4       pushl   %ebx                Save %ebx

5       movl    8(%ebp), %edx       Get xp
6       movl    12(%ebp), %ecx      Get yp
7       movl    (%edx), %ebx        Get x
8       movl    (%ecx), %eax        Get y
9       movl    %eax, (%edx)        Store y at xp
10      movl    %ebx, (%ecx)        Store x at yp
11      addl    %edx, %eax          Return value = x + y

12      popl    %ebx                Restore %ebx
13      popl    %ebp                Restore %ebp
14      ret                         Return

数组分配和访问

C 语言中的数组是一种将标量数据聚集成更大数据类型的方式。 C 语言实现数据的方式非常简单,因此很容易翻译成机器代码。 C 语言一个不同寻常的特点是可以产生指向数组中元素的指针,并对这些指针进行运算。 在机器代码中,这些指针会被翻译成地址运算。

对于数据类型 T 和整型常数 N,声明如下:

T A[N];

它有两个效果。首先,它在存储器中分配一个 L * N 的连续区域;这里 L 是数据类型 T 的带下(单位为字节),用 xA 来表示起始的位置。 其次,它引入了标识符 A;可以用 A 作为指向数组开头的指针,这个指针的值就是 xA

IA32 的存储器引用指令可以用来简化数组访问。 例如,假设 E 是一个 int 型的数组,并且我们想计算 E[i],在此,E 的地址存放在寄存器 %edx 中,而 i 存放在寄存器 %ecx 中,然后,指令

movl (%edx, %ecx, 4), %eax

会执行地址运算 xE+4i,读这个存储器位置的值,并将结果存放到寄存器 %eax 中。

假设整型数组 E 的起始地址和整数索引 i 分别存放在寄存器 %edx 和 %ecx 中。 下面是一些与 E 有关的表达式。我们还给出了每个表达式的汇编代码实现,结果存放在寄存器 %eax 中。

表达式 类型 汇编代码
E int* xE movl %edx,%eax
E[0] int M[xE] movl (%edx),%eax
E[i] int M[xE+4i] movl (%edx,%ecx,4),%eax
&E[2] int* xE+8 leal 8(%edx),%eax
E+i-1 int* xE+4i-4 leal -4(%edx,$ecx,4),%eax
*(E+i-3) int* M[xE+4i-12] movl -12(%edx,%ecx,4),%eax
&E[i]-E int i movl %ecx,%eax

在这些例子中,leal指令用来产生地址,而 movl 用来引用存储器。

嵌套的数组

当我们创建数组的数组时,数组分配和引用的一般原则也是成立的。 例如声明:

int A[5][3];

等价于下面的声明

typedef int row3_t[3];
row3_t A[5];

通常来说,对于一个数组声明如下:

T D[R][C];

它的数组元素 D[i][j] 的存储器地址为:

&D[i][j] = xD+L(C·i+j)

公式 1

这里,L 是数据类型 T 以字节为单位的大小。

异质的数据结构

C 语言提供了两种结合不同类型的对象来创建数据类型的机制:结构(structure),用关键字 struct 声明,将多个对象集合到一个单位中;联合(union),用关键字 union 声明,允许用几种不同的类型来引用一个对象。

结构

C 语言的 struct 声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。 结构的各个组成部分用名字来引用。 类似于数组的实现,结构的所有组成部分都存放在存储器中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。 编译器维护关于每个结构类型的信息,指示每个字段的字节偏移。 它以这些偏移作为存储器引用指令中的位移,从而产生对结构元素的引用。

联合

联合提供了一种方式,能够规避 C 语言的类型系统,允许以多种类型来引用一个对象。 联合声明的语法与结构的语法一样,只不过语义相差比较大。 它们是用不同的字段来引用相同的存储器块。

考虑下面的声明:

struct S3 {
    char c;
    int i[2];
    double v;
};

union U3 {
    char c;
    int i[2];
    double v;
}

在一台 IA32 Linux 机器上编译时,字段的偏移量、数据类型 S3 和 U3 的完整大小如下:

类型 c i v 大小
S3 0 4 12 20
U3 0 0 0 8

S3 中的 i 偏移量是 4 而不是 1 是因为要进行数据对齐。

数据对齐

许多计算机系统对基本数据类型合法地址做出了一些限制,要求某种类型对象的地址必须是某个值 K(通常是 2、4和8)的倍数。 这种对齐限制简化了形成处理器和存储器系统之间接口的硬件设计。 例如,假设一个处理器总是从存储器中取出 8 个字节,则地址必须为 8 的倍数。 如果我们能保证所有的 double 类型数据的地址对齐 8 的倍数,那么就可以用一个存储器操作来读或者写值了。 否则,我们可能需要执行两次存储器访问,因为对象可能被分放在两个 8 字节存储器块中。

无论数据是否对齐,IA32 硬件都能正确工作。 不过,Intel 还是建议要对齐数据以提供存储器系统的性能。 Linux 沿用的对齐策略是,2字节数据类型的地址必须是2的倍数,而较大的数据类型的地址必须是4的倍数。 Windows 对齐的要求更严格,任何 K 字节基本对象的地址必须是 K 的倍数。K =2、4或者 8。 特别地,它要求一个 double 或者 long long 类型数据的地址应该是 8 的倍数。

编译器在汇编代码中放入命令,指明全局数据所需的对齐。例如:

.align 4

这就保证了它后面的数据的起始地址是 4 的倍数。

理解指针

  • 每个指针都有一个值
  • 指针用 & 运算符创建
  • 操作符用于指针的间接引用
  • 数组与指针紧密联系
  • 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值
  • 指针也可以指向函数

使用 GDB 调试器

命令 效果
开始和停止
quit 退出GDB
run 运行程序(在此给出命令行参数)
kill 停止程序
断点
break sum 在函数 sum 入口处设置断点
break *0x8048394 在地址 0x8048394 处设置断点
delete 1 删除断点 1
delete 删除所有断点
执行
stepi 执行1条指令
stepi 4 执行4条指令
nexti 类似于 stepi,但是以函数调用为单位的
continue 继续执行
finish 运行直到当前函数返回
检查代码
disas 反汇编当前函数
disas sum 反汇编函数 sum
disas 0x8048397 反汇编位于地址 0x8048397 附近的函数
disas 0x8048394 0x80483a4 反汇编指定地址范围内的代码
print /x $eip 以十六进制输出程序计数器的值
检查数据
print $eax 以十进制输出 %eax 的内容
print /x $eax 以十六进制输出 %eax 的内容
print /t $eax 以二进制输出 %eax 的内容
print 0x100 输出 0x100 的十进制表示
print /x 555 输出 555 的十六进制表示
print /x ($ebp+8) 以十六进制输出 %ebp 的内容加上 8
print *(int*) 0xfff076b0 输出位于地址 0xfff076b0 的整数
print *(int*) ($ebp+8) 输出位于地址 %ebp + 8 处的整数
x/2w 0xfff076b0 检查从地址 0xfff076b0 开始的双(4字节)字
x/20b sum 检查函数 sum 的前 20 个字节
有用的信息
info frame 有关当前栈帧的信息
info registers 所有寄存器的值
help 获取有关 GDB 的信息

表10:GDB 命令示例

存储器的越界引用和缓冲区溢出

C 对于数组引用不进行任何边界检查,而且局部变量和状态信息,都存放在栈中。 这两种情况结合到一起就可能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。

缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。 这是一种最常见的通过计算机网络攻击系统安全的方法。

对抗缓冲区溢出攻击的方法

  1. 栈随机化
    程序开始时,在栈上分配一段 0~n 字节之间的随机大小的空间。在 Linux 系统中,栈随机化已经变成标准行为。它是更大一类技术中的一种,这类技术成为地址空间布局随机化(Address-Space Layout Randomization),或者简称 ASLR。采用 ASLR,每次运行程序的不同部分,包括程序代码、库代码、栈、全局变量和堆数据,都会被加载到存储器的不同区域。
  2. 栈破坏检测
    GCC 在产生代码中加入了一种栈保护着(stack protector)机制,用来检测缓冲区越界。 其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀(canary)值。这个金丝雀值,也称为哨兵值(guard value),是在程序每次运行时随机产生的,因此,攻击者没有简单的办法能够知道它是什么。
  3. 限制可执行代码区域
    限制那些能够存放执行代码的存储器区域,在典型程序中,只有保存编译器产生的代码的那部分存储器才需要是可执行的。 其他部分可以被限制为只允许读和写。

x86-64:将 IA32 扩展到 64 位

x86-64 代码与为 IA32 机器生成的代码有极大的不同。 主要特性如下:

  • 指针和长整型是 64 位长。整数算数运算支持 8、16、32 和 64 位数据类型。
  • 通用目的寄存器从 8 个扩展到 16 个。
  • 许多程序状态都保存在寄存器中,而不是栈上。整型和指针类型的过程参数(最多 6 个)通过寄存器传递。有些过程根本不需要访问栈。
  • 如果可能,条件操作用条件传送指令实现,会得到比传统分支代码更好的性能。
  • 浮点操作用面向寄存器的指令集(SSE 版本 2 引入的)来实现,而不用 IA32 支持的基于栈的方法来实现。

下表给出了 x86-64 各种 C 语言数据类型的大小,以及与 IA32 的比较。

C声明 Intel数据类型 汇编代码后缀 x86-64大小(字节) IA32大小
char 字节 b 1 1
short w 2 2
int 双字 l 4 4
long int 四字 q 8 4
long long int 四字 q 8 8
char* 四字 q 8 4
float 单精度 s 4 4
double 双精度 d 8 8
long double 扩展精度 t 10/16 10/12

表11:x86-64的标准数据类型大小

汇编代码示例

long int simple_l(long int *xp, long int y)
{
    long int t = *xp + y;
    *xp = t;
    return t;
}

在 x86-64 Linux 机器上一如下命令行运行 GCC

unix> gcc -O1 -S -m32 code.c

它产生与任意 IA32 机器兼容的代码如下:

    IA32 implementation of function simple_l
    xp at %ebp+8, y at %ebp+12

1   simple_l:
2       pushl   %ebp            Save frame pointer      (W)
3       movl    %esp, %ebp      Create new frame pointer
4       movl    8(%ebp), %edx   Retrieve xp             (R)
5       movl    12(%ebp), %eax  Retrieve y              (R)
6       addl    (%edx), %eax    Add *xp to get t        (R)
7       movl    %eax, (%edx)    Store t at xp           (W)
8       popl    %ebp            Restore frame pointer   (R)
9       ret                     Return                  (R)

当我们告诉 GCC 产生 x86-64 代码时

unix> gcc -O1 -S -m64 code.c

我们会得到非常不同的代码:

    x86-64 version of function simple_l.
    xp in %rdi, y in %rsi

1   simple_l:
2       movq    %rsi, %rax      Copy y
3       addq    (%rdi), %rax    Add *xp to get t        (R)
4       movq    %rax, (%rdi)    Store t at xp           (W)
5       ret                     Return                  (R)

一些关键的区别如下:

  • 没有看到 movl 和 addl 指令,只看到 movq 和 addq。指针和声明为长整形的变量都是 64 位(四字)而不是 32 位(长字)。
  • 我们看到 64 位的寄存器(例如 %rsi 和 %rdi,而不是 %esi 和 %edi)。过程将返回值存放在寄存器 %rax 中。
  • 在 x86-64 的版本中没有生成栈帧。这消除了 IA32 代码中建立指令(第 2~3 行)和取出栈帧的指令(第 8 行)。
  • 参数 xp 和 y 是通过寄存器(分别是 %rdi 和 %rsi)传递而不是栈传递。这就不需要从存储器中读取参数。

这些改变导致的结果是:IA32代码由 8 条指令构成,要做 7 次存储器引用(5 次读和 2 次写)。而 x86-64 代码由 4 条指令构成,要做 3 次存储器引用(2 次读和 1 次写)。

访问信息

下图是 x86-64 下的通用寄存器组。与 IA32 的寄存器(图 1)相比,我们看到以下区别:

  • 寄存器的数量翻倍至 16 个。
  • 所有的寄存器都是 64 个。IA32 寄存器的 64 位扩展分别命名为 %rax、%rcx、%rdx、%rbx、%rsi、%rsp 和 %rbp。新增加寄存器命名为 %r8~%r15。
  • 可以直接访问每个寄存器的低 32 位。这就给了我们 IA32 中熟悉的那些寄存器:%eax、%ecx、%edx、%ebx、%esi、%edi、%esp 和 %ebp,以及 8 个新 32 位寄存器:%r8d~%r15d。
  • 同 IA32 中的情况一样,可以直接访问每个寄存器的低 16 位。新寄存器的字大小版本命名为 %r8w~%r15w。
  • 可以直接访问每个寄存器的低 8 位。在 IA32 中,只有对前 4 个寄存器(%al、%cl、%dl 和 %bl)才可以这样。其他 IA32 寄存器的字节大小版本命名为 %sil、%dil、%spl 和 %bpl。新寄存器的字节大小版本命名为:%r8b~%15b。
  • 为了向后兼容性,具有单字节操作数的指令可以直接访问 %rax、%rcx、%rdx 和 %rbx 的第二个字节。

图 6 x86-64 整数寄存器

同 IA32 一样,大多数寄存器可以互换使用,但是有一些特殊情况。寄存器 %rsp 有特殊的状态,它会保存执行栈顶指针。 与 IA32 不同的是,没有帧指针寄存器;可以用寄存器 %rbp 作为通用寄存器。

大部分 x86-64 操作数指示符与 IA32 中的一样(参见 表2),不同之处是基址寄存器和变址寄存器指示符必须使用寄存器的 'r' 版本,而不是它的 'e' 版本。 除了 IA32 的寻址方式外,它还支持一些 PC 相对操作数寻址方式。 在 IA32 中,只有对跳转和其他控制转移指令才能支持这种寻址方式(参见 跳转指令及其编码 )。 提供这种模式为了弥补偏移量(表2中的 Imm)只有 32 位长度的情况。 将这个字段看作 32 位不码数,指令可以访问窗口为相对于程序计数器大约 ±2.15×109 范围内的数据。 x86-64 中程序计数器命名为 %rip。

下面的过程时一个 PC 相对数据寻址的示例,它调用前面看到过的 simple_l 函数:

long int gval1 = 567;
long int gval2 = 763;

long int call_simple_l()
{
    long int z = simple_l(&gval1, 12L);
    return z + gval2;
}

当编译、汇编和链接这个函数时,我们得到下面的可执行代码(由反汇编器 objdump 产生的):

1   0000000000400541<call_simple_1>:
2       400541: be 0c 00 00 00          mov     $0x,%esi            Load 12 as 2nd argument
3       400546: bf 20 10 60 00          mov     $0x601020,%edi      Load &gval1 as 1st argument
4       40054b: e8 c3 ff ff ff          callq   400513 <simple_l>   Call simple_l
5       400550: 48 03 05 d1 0a 20 00    add     0x200ad1(%rip),%rax Add gval2 to result
6       400557: c3                      retq                        Return

这里我们看到 PC 相对寻址——立即数值 0x200ad1 加上下面一条指令的地址得到 0x200ad1+0x400557=0x601028。

下表记录了一些 x86-64 中可用而 IA32 中没有的数据传送指令(参见 表3)。 一些指令要求目标是寄存器,用 R 表示。其他的一些目标可以是寄存器或者存储器地址,用 D 表示。 movabsq 指令没有对应的 IA32 指令,它可以将一个完全的 64 位立即数值复制到它的目的寄存器。 当 movq 指令有一个立即数值作为源操作数时,如果这个立即数是一个 32 位的值,会被符号扩展至 64 位。

从较小的数据大小传送到较大的数据大小可以用符号扩张(MOVS)或者零扩展(MOVZ)。 传送或者产生 32 位寄存器值的指令也会将寄存器的高 32 位设置为 0。 结果就不需要指令 movzlq。 类似地,当目的是寄存器是,指令 movzbq 与 movzbl 有完全一样的行为——将目的寄存器的高 56 位设置为 0。 这条指令与那些产生 8 位或者 16 位值的指令(例如 movb)不同,那些指令不会改变寄存器中的其他位。

指令 效果 描述
movabsq I, R R ← I 传送绝对四字
MOV S, D D ← S 传送
movq 传送四字
MOVS S, D D ← SignExtend(S) 符号扩展传送
movsbq 符号扩展字节传送四字
movswq 符号扩展字传送四字
movslq 符号扩展双字传送四字
MOVZ S, D D ← ZeroExtend(S) 零扩展传送
movzbq 零扩展字节传送四字
movzwq 零扩展字传送四字
pushq S R[%rsp] ← R[%rsp] - 8
M[R[%rsp]] ← S
将四字压栈
popq D D ← M[R[%rsp]]
R[%rsp] ← R[%rsp] + 8
将四字出栈

表 12 数据传送指令,这些指令是对 IA32 的传送指令的补充

算术指令

表 4 中,我们列出了许多算术和逻辑指令,用类名(例如 “ADD”)来表示针对不同操作数大小的指令,例如 addb(字节)、addw(字)和 addl(长字)。 现在,我们为每一类指令增加了在四字上进行运算的指令,后缀为 'q'。 这些四字指令有它们对应的较短操作数的指令一样的参数类型。 正如签名提到的,产生 32 寄存器结果的指令,例如 addl,也会将寄存器的高 32 位设置为 0。 产生 16 位结果的指令,例如 addw,只会影响它们的 16 位目的寄存器,产生 8 位结果的指令也是类似。 对于 movq 指令,立即数操作数被限制为 32 位,所以会做符号扩展到 64 位。

下表是用两个 64 位字来产生全 128 位乘积的指令,类似于对应的 32 位指令(表 5)。 下表还给出一条指令 cltq,将寄存器 %eax 符号扩展到 %rax。这条指令只是指令 movslq %eax, %rax 的缩写。

指令 效果 描述
imulq S R[%rdx]:R[%rax] ← S × R[%rax] 有符号全乘法
mulq S R[%rdx]:R[%rax] ← S × R[%rax] 无符号全乘法
ctlq R[%rax] ← SignExtend(R[%eax]) 将 %eax 转换成四字
cqto R[%rdx]:R[%rax] ← SignExtend(R[%rax]) 转换成八字
idivq S R[%rdx] ← R[%rdx]:R[%rax] mod S;
R[%rax] ← R[%rdx]:R[%rax] ÷ S
有符号除法
divq S R[%rdx] ← R[%rdx]:R[%rax] mod S;
R[%rax] ← R[%rdx]:R[%rax] ÷ S
无符号除法

表 13 特殊的算术运算

控制

x86-64 中实现控制转移的控制指令和方法与 IA32(上面的 控制)一样。 如下表所示,增加的两条新指令 cmpq 和 testq,用来比较和测试四字,是对字节、字和双字大小的指令(表 6)的扩充。

指令 根据 描述
CMP S2, S1 S1 - S2 比较
cmpq 比较四字
TEST S2, S1 S1 & S2 测试
testq 测试四字

表 14 64 位的比较和测试指令

为了说明 IA32 和 x86-64 代码的相似性,考虑用 while 循环实现的整数阶乘函数编译后产生的汇编代码,如下面所示:

int fact_while(int n)
{
    int result = 1;
    while (n > 1) {
        result *= n;
        n = n -1;
    }
    return result;
}

while循环阶乘C代码

1   fact_while:
  n at %ebp+8
2       pushl   %ebp                Save frame pointer
3       movl    %esp, %ebp          Create new frame pointer
4       movl    8(%ebp), %edx       Get n
5       movl    $1, %eax            Set result = 1
6       cmpl    $1, %edx            Compare n:1
7       jle     .L7                 If <=, goto done
8   .L10:                       loop:
9       imull   %edx, %eax          Compute result *= n
10      subl    $1, %edx            Decrement n
11      cmpl    $1, %edx            Compare n:1
12      jg      .L10                If >, goto loop
13  .L7:                        done:
14      popl    %ebp                Restore frame pointer
15      ret                         Return result

IA32 版本

1   fact_while:
  n in %rdi
2       movl    $1, %eax            Set result = 1
3       cmpl    $1, %edi            Compare n:1
4       jle     .L7                 If <=, goto done
5   .L10:                       loop:
6       imull   %edi, %eax          Compute result *= n
7       subl    $1, %edi            Decrement n
8       cmpl    $1, %edi            Compare n:1
9       jg      .L10                If >, goto loop
10  .L7:                        done:
11      rep                         (See explation)
12      ret                         Return Result

x86-64版本

正如能够看到的那样,这两个版本非常相似。 区别之处在于如何传递参数(分别是在栈上和寄存器中),并且 x86-64 代码中没有栈帧和帧指针。

为什么代码中有一条 rep 指令

我们看到 x86-64 代码中的第 11 行,在返回指令 ret 之前有指令 rep。 rep 指令用来实现重复字符串操作。 这里出现这条指令看上去完全不合时宜。 对于这个疑惑的解答可以在 AMD 给编译器作者的指导书(Advanced Macro Devices, Inc. Software Optimization Guide for AND64 Processors, 2005. Publication Number 25112.)里找到。 他们建议使用 rep 后面跟 ret 的组合,可以避免使 ret 指令作为条件跳转指令的目的地。 如果没有 rep 指令,那么当不跳转到指令分支的时候,条件跳转 jg 指令的目的就是 ret 指令。 根据 AMD 的说法,当从一条跳转指令跳到 ret 指令时,处理器不能适当地预测 ret 指令的目的。 由于这条 rep 指令作为空操作使用,所以插入至此作为跳转的目的地,除了代码在 AMD 处理器上运行得更快之外,不会改变代码的其他行为。

  1. 过程
    过程调用的 x86-64 实现与其 IA32 实现有很大的不同。 通过将寄存器组翻倍,程序不再需要依赖于栈来存储和获取过程信息。 这极大地减少了过程调用和返回的开销。
    • 参数(最多是前六个)通过寄存器传递到过程,而不是在栈上。这消除了在栈上存储和检索值的开销。
    • callq 指令将一个 64 位返回地址存储在栈上。
    • 许多函数不需要栈帧。只有那些不能将所有的局部变量都放在寄存器中的函数才需要在栈上分配空间。
    • 函数最多可以访问超过当前栈指针值 128 个字节的栈上存储空间(地址低于当前栈指针的值)。这允许一些函数将信息存储在栈上而无需修改栈指针。
    • 没有帧指针。作为替代,对栈位置的引用相对于栈指针。大多数函数在调用开始时开始分配所需要的整个栈存储,并保持栈指针执行固定位置。
    • 同 IA32 一样,有些寄存器被指定为被调用者保存寄存器。任何需要修改这些寄存器的过程都必须保存并恢复它们。
  2. 参数传递
    最多可以有 6 个整型(整数和指针)参数可以通过寄存器传递。寄存器按照指定的顺序来使用,使用的寄存器名对应于所传递的数据的大小。参数按照它们在参数列表中的顺序依次分配到这些寄存器中。 小于 64 位的参数可以用 64 位寄存器响应的部分来访问。
操作数大小参数数量
123456
64%rdi%rsi%rdx%rcx%r8%r9
32%edi%esi%edx%ecx%r8d%r9d
16%di%si%dx%cx%r8w%r9w
8%dil%sil%dl%cl%r8b%r9b
  1. 栈帧
    我们已经看到许多编译后的函数并不需要栈帧。 如果所有的局部变量都能保存在寄存器中,而且这个函数也不会调用其他函数,那么唯一需要栈的唯一原因就是用来保存返回地址。 另一方面,使得函数可能需要栈帧的原因如下:
    • 局部变量太多,不能都放在寄存器中。
    • 有些局部变量是数组或者结构。
    • 函数有取地址操作符(&)来计算一个局部变量的地址。
    • 函数必须将栈上的某些参数传递到另一个函数。
    • 在修改一个被调用者保存寄存器之前,函数需要保存它的状态。

与 IA32 的代码不同,那里的栈指针会随着值的压入和弹出不断前后移动,但是 x86-64 过程的栈帧通常有固定的大小,在过程开始时通过减小栈指针(寄存器 %rsp)来设置。 在调用过程中,栈指针保持在固定的位置,使得可以用相对于栈指针的偏移量来访问数据。 因此,就不再需要 IA32 代码中可见的帧指针(寄存器 %ebp)了。

下面举例说明:

long int call_proc()
{
    long  x1 = 1; int  x2 = 2;
    short x3 = 3; char x4 = 4;
    proc(x1, &x1, x2, &x2, x3, &x3, x4, &x4);
    return (x1+x2)*(x3-x4);
}

GCC 产生下面的 x86-64 代码:

  x86-64 implementation of call_proc
1   call_proc:
2       subq        $32, %rsp                   Allocate 32-byte stack frame
3       movq        $1, 16(%rsp)                Store 1 in &x1
4       movl        $2, 24(%rsp)                Store 2 in &x2
5       movw        $3, 28(%rsp)                Store 3 in &x3
6       movb        $4, 31(%rsp)                Store 4 in &x4
7       leaq        24(%rsp), %rcx              Pass &x2 as argument 4
8       leaq        16(%rsp), %rsi              Pass &x1 as argument 2
9       leaq        31(%rsp), %rax              Compute &x4
10      movq        %rax, 8(%rsp)               Pass &x4 as argument 8
11      movl        $4, (%rsp)                  Pass 4 as argument 7
12      leaq        28(%rsp), %r9               Pass &x3 as argument 6
13      movl        $3, %r8d                    Pass 3 as argument 5
14      movl        $2, %edx                    Pass 2 as argument 3
15      movl        $1, %edi                    Pass 1 as argument 1
16      call        proc                        Call
17      movswl      28(%rsp), %eax              Get x3 and convert to int
18      movsbl      31(%rsp), %edx              Get x4 and convert to int
19      subl        %edx, %eax                  Compute x3-x4
20      ctlq                                    Sign extend to long int
21      movslq      24(%rsp), %rdx              Get x2
22      addq        16(%rsp), %rdx              Compute x1+x2
23      imulq       %rdx, %rax                  Compute (x1+x2)*(x3-x4)
24      addq        $32, %rsp                   Deallocate stack frame
25      ret                                     Return

下图

  • a) 说明了在执行 call_proc 时,栈帧的建立。
  • b)说明了 proc 执行时的栈。

call 指令将返回值压入栈中,所有在执行 call_proc 时,栈指针相对于它的位置向下移动 8。 因此,在 proc 的代码中,用距离栈指针偏移量为 8 和 16 来访问参数 7 和 8。

图 7 call_proc 的栈帧结构

可以观察到 call_proc 如何在执行过程中只改变了一次栈指针。 GCC 认为用 32 字节来保存所有的局部变量和 proc 多出来的参数就足够了。 尽量减少栈指针的移动次数,简化了编译器用相对于栈指针的偏移量产生对栈元素的引用的任务。

  1. 寄存器保存惯例

在 IA32 中,有些用来保存临时值的寄存器被指定为调用者保存,函数可以自由地覆盖这些寄存器的值; 而另外一些是被调用者保存,函数在写这些寄存器之前,必须在栈上保存它们的值。 在 x86-64 中,指定为被调用者保存的寄存器有:%rbx、%rbp 和 %r12~%r15。

x86-64 的一个不同寻常的特性是能够访问栈指针之外的存储器。 它要求虚拟存储器管理系统为这段区域分配存储器。 x86-64 ABI 指明程序可以使用当前栈指针之外 128 字节的范围(即低于当前栈指针的值)。 ABI 将这个区域成为红色地带(red zone)。 必须保持当栈指针移动时,红色地带可读可写。

数据结构

x86-64 中数据结构遵循的原则与 IA32 的一样:数组是作为同样大小的块的序列来分配,这些块中保存着数组元素; 结构则作为变长的块来分配,块中保存着结构元素;联合是作为一个单独的块来分配,这个块足够大,能够装下联合中最大的元素。

区别是 x86-64 遵循一组更严格的对齐要求。 对于任何需要 K 字节的标量数据类型来说,它的起始地址必须是 K 的倍数。 因此,数据类型 long 和 double 以及指针,都必须在 8 字节边界上对齐。 此外,数据类型 long double 使用 16 字节对齐(分配也是 16 个字节大小),虽然实际表示只需要 10 个字节。

浮点程序的机器级表示

为了实现浮点数据的程序,我们必须用一些方法来存储浮点数据,同时还要有额外的指令对浮点值进行操作、在浮点和整数值之间进行转换以,以及在浮点数之间进行比较。 还需要规则定义如何传递作为函数参数的浮点值,以及如何传递作为函数结果的浮点值。 我们把存储模型、指令和传递规则的组合成为机器的浮点体系结构。

由于 x86 处理器有很长的发展演变历史,它提供了多种浮点体系结构,目前有两种还在使用。

  • 第一种,称为“x87”,可以追溯到早期的 Intel 微处理器,直到现在都还是标准的实现。
  • 第二种,称为“SSE”,是基于较新的对 x86 处理增加多媒体应用的支持。