原文来自耶鲁大学 CS421 的 x86 Assembly Guide,以下是译文
这是源自 David Evans 旧文档 的改编版本。语法从 Intel 改为了 UNIX 系统的标准语法 AT&T,并且网页上给出的代码也是纯 AT&T 语法
这个教程描述了 32 位 x86 汇编编程的基础,包含一个小型但有用的汇编指令以及汇编器伪指令的子集。需要先明确的一点是,生成 x86 机器码可以使用多种不同的汇编语言。我们在 CS421 使用的是 GNU Assembler(gas)汇编器,以及使用标准 AT&T 语法编写 x86 汇编代码
x86 全部指令集不仅庞大而且复杂(Intel x86 指令集手册由超过 2900 页组成),所以我们不会在教程里覆盖所有指令。比如 x86 指令集里 16 位指令这个子集,使用 16 位编程模式可谓相当复杂。不仅要使用分段内存模型,而且在寄存器的使用上也存在诸多限制,还有其他更多的复杂性。在这个教程里,我们更多地集中在现代 x86 编程上,只探索那些足够我们领略 x86 编程的基础细节
寄存器组
现代 x86 处理器(即 386 及以后)拥有如下面图所示的 8 个 32 位通用寄存器。寄存器名字大部分是历史遗留问题,如 EAX 被称为累加器(Accumulator)因为它被一个算术运算的数值使用;又如 ECX 被称为计数器(Counter)因为它用来保存循环的索引。除此之外大多数寄存器在现代指令集里已经与其原来名称的目的不相符了,只保留了两个专用的寄存器——栈指针寄存器(ESP)和基指针寄存器(BSP)
对于 EAX,EBX,ECX 和 EDX 寄存器来说,其子寄存器是可以使用的。举个例子,EAX 最低两个字节是用作 16 位寄存器的 AX;而 AX 最低一个字节又是用作 8 位寄存器的 AL,对应地其最高一个字节是 AH,也可用作 8 位寄存器。虽然具有这么多名称,但其实都是同一个物理寄存器。当一个两字节的数值存放于 DX 时,会影响 DH,DL 和 EDX 的更新。这些子寄存器主要是用来兼容老版本的 16 位指令集。但是,我们用来处理小于 32 位的数据(如一个字节的 ASCII 字符)也是很方便的
内存和寻址模式
声明静态数据区
在 x86 汇编里你可以使用特定的伪指令来声明静态数据区(类似于全局变量),.data
伪指令用来处理数据声明。随着这个指令而来的还有 .byte
,.short
和 .long
,分别用以声明一个,两个和四个字节的数据区域。我们使用标号(Label)来指向被创建出来的数据的地址。标号在汇编里非常有用,给予了稍后被汇编器和链接器计算地址的内存区一个名称。这和用名字声明一个变量是相似的,但更接近底层。例如,按顺序的数据声明会存放在内存上彼此相邻的地方
数据声明的例子:
1 | .data |
和高级语言不同,高级语言中,数组有许多维度,并且通过下标访问。而 x86 汇编里的数组可以简单地想象成,在内存里连续的小房间。可以只通过列举数值来声明数组,就像下面所示的第一个例子那样。逐字节的字符串也是字节数组的一个特殊情况。另外,.zero
伪指令可以为大量的内存区填充零
一些例子
1 | s: |
寻址内存
兼容 x86 的现代处理器能够寻址内存最多 232 字节:内存地址是 32 位宽。在上面那个例子中,我们使用标号来指示内存区,标号实质是汇编器用 32 位数值指示一个内存地址。x86 除了支持用标号(即一个常数)指向内存区外,还提供了一个灵活的策略来计算内存地址:最多使用两个 32 位寄存器和一个 32 位带符号的常数相加,从而得到一个内存地址。也可以选择将这些寄存器其中一个先乘上 2,4 或 8
译者注:这里说的灵活的策略指的是 比例变址寻址,AT&T 语法是
imm(base, index, scale)
,scale 称为 比例因子,只能取 1,2,4 和 8。该寻址含义是base + index * scale
许多 x86 指令都会用到寻址模式(我们会在下一节看到)。我们在这里展示一些使用 mov
指令来在寄存器和内存之间移动数据的例子。该指令包含两个操作数:第一个(从左往右)是源操作数,第二个是目的操作数
以下是需要计算内存地址的 mov
指令的例子:
1 | mov (%ebx), %eax /* 从 EBX 给出的内存地址上加载 4 个字节到 EAX */ |
以下是无效的地址计算:
1 | mov (%ebx, %ecx, -1), %eax /* 只能增加寄存器的运算结果 */ |
译者注:第一行指令注释原文是 Can only add register values,结合上面提及的 比例变址寻址 的含义来理解,若此处为负数,将使得 EBX 减去了某个值,使得运算结果减少。这才不允许
操作前缀
通常,给定地址处所需数据项的大小是可以从所引用的汇编指令处推断出来的。例如,上面所有指令中,内存区大小可以从寄存器操作数尺寸中推断出来。当我们加载一个 32 位寄存器时,汇编器会推断出我们使用的内存区是 4 字节宽。当我们保存寄存器一个字节的值到内存时,汇编器会推断出我们是使用内存里该地址上的一个字节
但是,引用内存区时,在很多情况下其尺寸是不明确的。考虑该指令:mov $2, (%ebx)
是移动数值 2 到 EBX 指示地址处一个字节吗?但可能它期望的是移动 32 位整型值 2 到一个开始于地址 EBX 处的四字。由于任何一个都是有效的解释,因此必须明确指示汇编器哪个是正确的。可以使用大小前缀 b
,w
和 l
分别指示 1,2 和 4 个字节的大小
例如:
1 | movb $2, (%ebx) /* 移动 2 到 EBX 指示地址开头一个字节处 */ |
指令
机器指令通常分为三个类别:数据移动、算术/逻辑运算和控制流。这一章里,我们会看到每个类别的 x86 指令的重要例子。这一章不会考虑全部 x86 指令,只取其中最有用的一部分。你可以从因特尔指令集中找到全部指令然后自己学习
我们使用以下的概念:
1 | <reg32> 任何 32 位寄存器(%eax, %ebx, %ecx, %edx, %esi, %edi, %esp 或 %ebp) |
在汇编语言里,所有标号和用作立即数操作的数字常量(即不像 3(%eax, %ebx, 8)
里面用作地址计算的)都是用美元符号($)作为前缀。十六进制需要使用 0x
前缀(如 $0xABC
);如果没有这个前缀,数值会被解释成十进制
译者注:标号加不加美元符号作为前缀,含义是不同的:
1
2
3
4
5 val:
.long 0x5a5a5a5a
movl val, %eax # 将标号 val 地址上的值赋值给 EAX
movl $val, %ebx # 将标号 val 这个地址赋值给 EBX即,加上美元符号前缀,含义为使用标号这个地址;不加则为使用标号地址上的内容
数据移动指令
mov —— 移动
mov
指令拷贝第一个操作数指向的数据项(即寄存器内容,内存内容或一个常数值),送入第二个操作数指向的位置(即一个寄存器或内存)。可以将数据从寄存器移动到寄存器,但不能从内存移动到内存。在需要进行内存数据转移的地方,必须首先将源数据加载到寄存器,然后才能保存到目标内存地址
语法
1 | mov <reg>, <reg> |
例子
1 | mov %ebx, %eax —— 从 EBX 拷贝数值到 EAX |
push —— 入栈
push
指令将它的操作数放置在支持栈的硬件的内存顶部。具体来说,push
先将 ESP 减去 4,然后放置它的操作数到 (%esp)
指示地址开头 4 个字节处。ESP(栈指针)使用减法是因为 x86 的栈向下生长 —— 即栈从高地址往低地址方向生长
语法
1 | push <reg32> |
例子
1 | push %eax —— 入栈 EAX |
pop —— 出栈
pop
指令从硬件支持的栈的顶部移除 4 字节数据元素,移出的元素保存到指定的操作数上(即寄存器或内存)。该指令首先在 (%esp)
指示的内存处,移动 4 字节到指定的寄存器或内存,然后将 ESP 增加 4
语法
1 | pop <reg32> |
例子
1 | pop %di —— 出栈最顶部的元素到 EDI |
lea —— 加载有效地址
lea
指令放置由第一个操作数指定的地址,到由第二个操作数指定的寄存器中。
注意,内存上的内容不会被加载,只会计算它上面的有效地址,然后放置到寄存器。用来获取指向内存的指针,或执行简单的算术操作是非常有用的
译者注:真实的 AT&T 汇编里会使用大量的
lea
作算术运算,理由可以参见 知乎:为什么 lea 会被用来计算?
语法
1 | lea <mem>, <reg32> |
例子
1 | lea (%ebx, %esi, 8) %edi —— EBX + 8 * ESI 会被放置到 EDI |
算术和逻辑运算指令
add —— 整数加法
add
指令让两个操作数相加,保存结果到第二个操作数。注意,虽然两个操作数可以都是寄存器,但最多只允许一个操作数是内存
语法
1 | add <reg>, <reg> |
例子
1 | add $10, %eax —— EAX 被设置为 EAX + 10 |
sub —— 整数减法
sub
指令将第二个操作数减去第一个操作数,结果又储存回第二个操作数。像 add
那样可以两个操作数都是寄存器,但至多一个操作数是内存
语法
1 | sub <reg>, <reg> |
例子
1 | sub %ah, %al —— AL 被设置成 AL - AH |
inc,dec —— 自增,自减
inc
指令将它操作数的值自增 1;dec
指令将它操作数的值自减 1
语法
1 | inc <reg> |
例子
1 | dec %eax —— 从 EAX 的值里减去 1 |
imul —— 整数乘法
imul
指令有两种基本的格式:双操作数格式(下面 语法 列举的前两个)和三操作数格式(下面 语法 列举的后两个)
双操作数格式将它的两个操作数相乘,然后储存结果到第二个操作数上。结果操作数(即第二个)必须是一个寄存器
三操作数格式将它的第一个和第二个操作数相乘,然后储存结果到最后一个操作数上。同样,结果操作数必须是一个寄存器,另外,第一个操作数需要是一个常数值
语法
1 | imul <reg32>, <reg32> |
例子
1 | imul (%ebx), %eax —— EBX 指示地址上的 32 位数据,乘上 EAX,结果存回 EAX |
idiv —— 整数除法
idiv
指令将 EDX:EAX
构成的 64 位整数(EDX 组成最高 4 字节,EAX 组成最低 4 字节),除以它操作数指定的值。除法的商储存在 EAX,余数储存在 EDX
语法
1 | idiv <reg32> |
例子
1 | idiv %ebx —— EDX:EAX 组成的值除以 EBX,商储存于 EAX,余数储存在 EDX |
and,or,xor —— 按位逻辑与,或,异或
这些指令在它们操作数上执行特殊的逻辑运算,放置结果于第一个操作数指示的内存上
语法
1 | and <reg>, <reg> |
例子
1 | and $0x0f, %eax —— EAX 中除了最后 4 位其余位均清位 |
译者注:清零一般使用
xor
指令效率更高
not —— 按位逻辑非
将操作数的内容逻辑取反(即操作数每一位反转)
语法
1 | not <reg> |
例子
1 | not %eax —— 反转 EAX 所有位 |
neg —— 取反
对操作数内容执行二进制补码求反
语法
1 | neg <reg> |
例子
1 | neg %eax —— EAX 被设置为(-EAX) |
shl,shr —— 左移和右移
这些指令向左和向右移动它们第一个操作数的内容,用零填充得到的空位位置。移动操作最多 31 次。由第二个操作数指定要移动的位的数量,要么是一个 8 位常数要么由寄存器 CL 指定。这两种情况下大于 31 次移动将会模除 32
语法
1 | shl <con8>, <reg> |
例子
1 | shl $1, eax —— 将 EAX 的值乘上 2(需要最高有效位为 0) |
控制流指令
x86 处理器维持一个指示当前指令的内存位置的 32 位寄存器,EIP(指令指针寄存器)。通常,EIP 需要自增以指向内存上当前指令的下一条。EIP 的值不能直接改变,但可以通过控制流指令隐式更新
我们使用 <label> 指向程序代码被标记的位置。标号可以在 x86 代码中通过给出一个标号名称,以冒号(“:”)结尾,以插入任意地方。例如:
1 | mov 8(%ebp), %esi |
代码块里第二条指令被 begin
标记。在该代码的任何地方,我们可以使用更方便的符号名称 begin
来访问存放这条指令的内存地址。使用标号可以很方便地表示一个内存地址,而不需要给出内存地址对应的 32 位数值
jmp —— 跳转
转移程序控制流到操作数给定的内存中的指令处
语法
1 | jmp <label> |
例子
1 | jmp begin —— 跳转到 begin 标记的指令处 |
jcondition —— 条件跳转
这些指令基于一系列条件码的状态而跳转,这些条件码储存在一个叫做机器状态字(machine status word)的专用寄存器上,其内容包括最后一次执行的算术运算的信息。举个例子,该状态字其中一个位可以指出最后一次运算结果是否为零,另一个位指出最后一次运算结果是否为负数等等。因此,许多条件跳转可以基于条件码实现跳转,如 jz
指令在最后一次运算结果为零时跳转到操作数指定的标号处,否则按顺序执行下一条指令
许多条件跳转指令的名称都是非常直观的,取决于最后一次执行的比较指令 cmp
(见下面 例子),如 jle
和 jne
都需要在期望的操作数上先执行 cmp
操作
语法
1 | je <label>(相等才跳转) |
例子
1 | cmp %ebx, %eax |
如果 EAX 的值小于或等于 EBX 的值,就跳转至标号 done
处,否则继续执行下一条指令
cmp —— 比较
比较两个指定操作数的值,然后再机器状态字上设置合适的条件码。该指令等效于 sub
指令,只是 sub
会将减法结果存回第一个操作数上,而该指令是丢弃
语法
1 | cmp <reg>, <reg> |
例子
1 | cmpb $10, (%ebx) |
如果储存在 EBX 指向的内存上的一个字节等于整数常量 10,就跳转到 loop
标记的内存上
call,ret —— 子进程调用和返回
这些指令实现了一个子进程的调用和返回。call
指令首先将当前代码的地址入栈(详见上文 push
指令),然后执行无条件跳转,跳转至由标号操作数指示的代码地址处。和普通跳转指令不同,call
指令会保存子程序完成后的返回地址
ret
指令实现了一个子进程返回机制。该指令首先出栈(详见上文 pop
指令),然后执行无条件跳转至取出的代码位置处
语法
1 | call <label> |
调用约定
为了允许独立的程序共享代码和开发其他程序可以使用的库,也为了简化平常子程序的使用,程序员们通常采用一种通用的称为 调用约定 的标准。调用约定是一种关于怎样调用例程、怎样从例程返回的标准。例如,给定一系列调用约定的规则,一个程序员就不需要测试子例程的定义以决定怎样向该例程传递参数。而且,高级语言的编译器就可以被设计为遵守这些规则,从而允许汇编语言例程和高级语言例程相互调用
译者注:原文此处使用 hand-coded 来形容汇编语言(原文为 hand-coded assembly language)。译者理解 hand-coded 为不借助现代 IDE 工具的提示功能纯手工书写的代码。参考 Definition of hand coding
事实上存在多种调用约定,我们将描述 C 语言版本被广泛使用的一种。这种调用约定允许我们书写供 C/C++ 安全调用的汇编语言子例程,也允许你从你的汇编语言代码中调用 C 库函数
C 调用约定很大程度上基于栈的使用,基于 push
,pop
,call
和 ret
指令。子例程参数会传递到栈上。寄存器和子例程使用的局部变量都保存到栈上。大多数处理器实现的高级语言,都使用了类似的调用约定
译者注:上一段粗体个人感觉翻译不出来,原文是:Registers are saved on the stack, and local variables used by subroutines are placed in memory on the stack,这两个栈不同?这一句仅供参考
调用约定被划分为两组规则集合。第一组由 caller 使用,第二组由子进程的编写者(callee)使用。需要特别强调的是,遵守调用约定的这些规则时若产生错误,将会导致严重的程序错误(fatal),因为此时的栈处于一个不连续的状态。因此当你在你的子例程中实现调用约定的时候必须异常小心
译者注:caller 和 callee 可简单理解为
main()
调用printf()
则当执行流来到printf()
时,此时main()
为 caller 而printf()
为 callee
将调用约定的执行过程可视化的一个可采取的办法是,在子例程调用期间画出栈附件区域的内容。上面的图片描绘了一个传递三个参数和三个局部变量的子例程,在其调用期间栈的内容。图片里栈的小格子是 32 位宽的内存区域,因此这些小格子的内存地址以 4 个字节划分开来。第一个参数保存在基指针(EBP)偏移 8 个字节处。在参数上面(基指针下面)是 call
指令设置的返回地址,因此基指针到第一个参数的偏移需要额外加上 4 个字节。当调用 ret
指令从子例程中返回时,执行流会跳转到栈里保存的返回地址处
caller 规则
为了完成一个子例程的调用,caller 应该:
- 调用前应该保存某些寄存器的内容,这些寄存器由 caller-saved 规则指定,包括 EAX,ECX,EDX。因为被调用的子例程可以修改这些寄存器,所以如果 caller 现需要在子例程返回后使用这些寄存器的值,则必须将这些寄存器入栈(可以在子例程返回后恢复)
- 为了向子例程传参,在调用子例程之前就需要将参数入栈。参数入栈遵从反向顺序(即最后一个参数第一个入栈)。因为栈往低处长,所以第一个参数会保存在最低的地址处(这种参数的倒转在历史上被函数用来传递可变数量的参数)
- 为了调用子例程,使用
call
指令。这条指令在栈上所传递参数之后入栈其返回地址,然后跳入子例程,之后遵守的是接下来要介绍的 callee 规则
译者注:第三点说的 “跟着参数之后入栈返回地址”,是如下所示的含义:
1
2
3
4
5
6
7
8
9
10
11 ┌──────────────┐<--old ebp
│caller's local│
├──────────────┤
│ params passed│
├──────────────┤
│ return addr │
├──────────────┤<--new ebp
│ old ebp │
├──────────────┤
│callee's local│
... ... <--esp
在子例程返回后(跟在 call
指令之后),caller 可以在寄存器 EAX 中找到自己期望的子例程的返回值。为了恢复原来的硬件状态,caller 应该:
- 从栈中移除参数,这会将堆栈恢复到调用之前的状态
- 通过出栈 caller-saved 寄存器(EAX,ECX,EDX)以恢复它们的值。caller 可以假设子例程中没有其他寄存器被修改
例子
下面的代码展示了一个遵守 caller 规则的函数调用。caller 正在调用 myFunc(),函数需要三个整型参数。第一个参数保存在 EAX,第二个参数是常数 216,第三个参数保存在 EBX 指向的内存上
1 | push (%ebx) /* 首先先入栈最后一个参数 */ |
注意,在调用返回之后,caller 需要使用 add
指令清理栈。我们在栈上使用了 12 字节(3 个参数 * 每个参数 4 字节),并且要考虑栈是往低处长的,所以为了清理这些参数,我们可以直接向栈指针上增加 12
myFunc() 产生的结果现在已经保存在 EAX 供我们使用了。caller-saved 寄存器(ECX 和 EDX)的值可能会被修改,所以如果 caller 在调用之后要使用,就需要在调用前保存到栈上,然后调用后再恢复过来
callee 规则
子例程的定义应该在开头处就应坚持遵守以下规则:
- 将 EBP 入栈,然后使用以下指令将 ESP 的值拷贝到 EBP
1 | push %ebp |
这种初始化行为保存了基指针,EBP。它用以被调用约定在栈上,寻找参数和局部变量。当子例程刚开始执行时,EBP 保存了栈指针(ESP)的副本。参数和局部变量总是位于基于 EBP 的一个已知的、恒定的偏移处。我们在子例程最开始处入栈原 EBP 的值是为了在子例程返回时能恢复回来。请记住,caller 不希望子例程修改 EBP 的值。之后我们拷贝 ESP 到 EBP,就可以访问参数和局部变量了
- 之后在栈上开辟空间以分配给局部变量。由于栈往低处长,所以为了在栈顶获取空间,ESP 应该执行减法。这个减数取决于所需局部变量的数量和大小。例如,如果需要三个整型值(每个 4 字节),ESP 需要减去 12 才能为它的局部变量开辟空间(即
sub $12, %esp
)。与参数一样,局部变量也位于距基指针已知的偏移量处 - 然后保存函数将要使用的那些 callee-saved 寄存器的值。也是通过入栈来保存。这些寄存器是 EBX,EDI 和 ESI(ESP 和 EBP 仍要遵守调用约定,只是这个阶段暂时不需要入栈)
上面三步执行完后,就开始执行子例程。当子例程返回时,需要遵守以下步骤:
- 在 EAX 中留下返回值
- 恢复被修改的 callee-saved 寄存器(EDI 和 ESI)原来的值。通过出栈恢复这些寄存器的值时,出栈顺序应该和入栈顺序相反
- 回收局部变量的空间。直接的办法是 ESP 加上某个合适的值(因为这些空间是 ESP 减去某个值而实现分配的)。事实上,更不容易出错的办法是拷贝 EBP 的值到 ESP(
mov %ebp, %esp
)。这里的原理是 EBP 的值,总是与 ESP 分配空间前的值相等 - 在返回的那一刻,通过出栈 EBP 而恢复 caller 的 EBP。回想我们在子例程入口做的第一件事就是将 ESP 原来的值入栈
- 最后,调用
ret
指令返回 caller。该指令会从栈中找到合适的返回地址,并删掉这个地址
译者注:第三点最后一句可简单理解为 x86 的 EBP 相当于栈底,而 ESP 相当于栈顶
注意,callee 规则分为上面两大块内容,它们像镜像一样彼此相反。第一块规则适用于函数开头,通常被称为函数定义的前言(the prologue to the function);第二块规则适用于函数结尾,通常被称为函数定义的后语(the epilogue to the function)
例子
这里是一个遵从 callee 规则的函数定义例子:
1 | /* 代码段的开始 */ |
子例程前言遵守标准,在 EBP 里保存了 ESP 的快照,通过减去 ESP 分配了局部变量的空间,也在栈上保存了寄存器的值
在子例程主体里,我们可以看到 EBP 的使用。参数和局部变量在子例程执行期间都位于 EBP 一个固定的偏移处。事实上,我们也可以这么理解,由于参数在调用子程序之前被放入栈,所以它们总是位于栈上 EBP 下方(即位于更高的地址处)。子例程的第一个参数总是可以通过 EBP + 8 访问到,第二个通过 EBP + 12 访问,第三个通过 EBP + 16 访问。同样,因为局部变量在 EBP 设置之后才分配空间,所以它们总是位于栈上 EBP 上方(即位于更低的地址处)。所以第一个局部变量总是可以通过 EBP - 4 访问到,第二个通过 EBP - 8 访问到,以此类推。这种 EBP 的约定俗成的使用,允许我们可以在函数中很快地访问到参数和局部变量
函数后语基本上可以视为前言的镜像。从栈中恢复 caller 的寄存器,然后重置 ESP 以回收局部变量的空间。之后恢复 caller EBP,执行 ret
返回到 caller 代码内某个合适的位置