分类: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 支持多种操作数格式(见下表)。 源数据值可以以常数形式给出,或是从寄存器或存储器中读出。结果可以存放在寄存器或存储器中。 各种不同的操作数的可能性被分为三种类型。
如下表所示,有多种不同的寻址模式,允许不同形式的存储器引用。
类型 | 格式 | 操作数值 | 名称 |
---|---|---|---|
立即数 | $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 | 传送 |
movbmovwmovl | 传送字节传送字传送双字 | |
MOVS S, D | D ← 符号扩展(S) | 传送符号扩展的字节 |
movsbwmovsblmovswl | 将做了符号扩展的字节传送到字将做了符号扩展的字节传送到双字将做了符号扩展的字节传送到双字 | |
MOVZ S, D | D ← 零扩展(S) | 传送零扩展的字节 |
movzbwmovzblmovzwl | 将做了零扩展的字节传送到字将做了零扩展的字节传送到双字将做了零扩展的字节传送到双字 | |
pushl S | R[%esp] ← R[%esp] - 4M[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)寄存器,它们描述了最近的算术或逻辑操作的属性。 可以检测这些寄存器来执行条件分支指令。
最常用的条件码有:
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:比较和测试指令
条件码通常不会直接读取,常用的使用方法有三种:
表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 语句可以根据一个整数索引值进行多重分支(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 的倍数。
命令 | 效果 |
---|---|
开始和停止 | |
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 对于数组引用不进行任何边界检查,而且局部变量和状态信息,都存放在栈中。 这两种情况结合到一起就可能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。 这是一种最常见的通过计算机网络攻击系统安全的方法。
对抗缓冲区溢出攻击的方法
x86-64 代码与为 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)
一些关键的区别如下:
这些改变导致的结果是:IA32代码由 8 条指令构成,要做 7 次存储器引用(5 次读和 2 次写)。而 x86-64 代码由 4 条指令构成,要做 3 次存储器引用(2 次读和 1 次写)。
下图是 x86-64 下的通用寄存器组。与 IA32 的寄存器(图 1)相比,我们看到以下区别:
图 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] - 8M[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 | 2 | 3 | 4 | 5 | 6 | |
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 |
与 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
下图
call 指令将返回值压入栈中,所有在执行 call_proc 时,栈指针相对于它的位置向下移动 8。 因此,在 proc 的代码中,用距离栈指针偏移量为 8 和 16 来访问参数 7 和 8。
图 7 call_proc 的栈帧结构
可以观察到 call_proc 如何在执行过程中只改变了一次栈指针。 GCC 认为用 32 字节来保存所有的局部变量和 proc 多出来的参数就足够了。 尽量减少栈指针的移动次数,简化了编译器用相对于栈指针的偏移量产生对栈元素的引用的任务。
在 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 处理器有很长的发展演变历史,它提供了多种浮点体系结构,目前有两种还在使用。