目录
一、x86汇编指令基础
1.相关寄存器
(1)通用寄存器
x86 架构的 CPU 中会设置一组 通用寄存器,用来存储整数数据和指针(地址)。
最初的 8086 有 8 个 16 位的寄存器,分别叫做 ax、bx、cx、dx、si、di、bp、sp,每个寄存器都有各自特殊的用途,这都体现在它们的名字中。当扩展到 32 位架构(标准名称为 IA32)时,这些寄存器也都扩展为 32 位,名称前加上了 ’‘e“ 表示扩展(extended)。
每个 32 位的通用寄存器,都可以将低 16 位当作一个 16 位寄存器独立使用,最低 8 位当作一个 8 位寄存器使用;而 ax、bx、cx、dx 的高低字节都可以分别作为两个 8 位寄存器,称为 ah、bh、ch、dh 和 al、bl、cl、dl。
这里用途最为特殊的,就是堆栈指针 ebp 和 esp,它们配合可以很容易地实现子过程的调用和返回;另外,一般也经常用 ebp + 偏移量 的形式来定位存放在栈中的局部变量。
扩展为 64 位的 x86-64 架构后,原先的 8 个 32 位寄存器全部扩展到 64 位,标号以 r 开头;此外还新增了 8 个通用寄存器,标号为 r8 ~ r15。
(2)指令指针寄存器
除通用寄存器外,x86 架构的 CPU 还会设置一系列特殊功能的寄存器。其中最为重要的就是 指令指针寄存器 IP(Instruction Pointer),它存放了下一条要执行的指令的地址;很明显,这其实就是我们之前介绍的 程序计数器 PC。
最初的 ip 也是 16 位的;到了 IA32 架构下,指令指针寄存器也扩展为 32 位,称为 eip;而到了 x86-64 时代,对应也扩展成了 64 位的指令寄存器,称为 rip。
(3)标志寄存器
标志寄存器 flags 里面有众多标志位,记录了 CPU 执行指令过程中的一系列状态,大都由 CPU 自动设置和修改。
-
ZF 零标志
-
CF 进位标志
-
SF 符号标志
-
OF 溢出标志
-
PF 奇偶标志
-
TF 跟踪标志
-
IF 中断标志
-
...
很显然,标志寄存器其实就是之前提到的 程序状态字 PSW。IA32 架构下标志寄存器为 32 位,称为 efl(eflags),除去一些不使用的保留位外,每一位都对应着一个状态标志;x86-64 架构下扩展为 64 位,不过扩展的高位都没有使用,相当于还是 32 位。
2.汇编指令格式
对于 x86 指令集的汇编代码,也有两种不同的指令格式。
-
ATT 格式:由 AT&T 公司而得名,这是 GCC 等常用工具的默认格式。
-
Intel 格式:Intel 文档中和 Microsoft 编程工具采用的汇编格式。
这两种格式整体风格相似,但也有很多不同:
x86 是复杂指令集架构,支持多种寻址方式,两种格式的各种寻址方式如下:
下面是 mov 指令的一些示例。mov 指令用于移动数据,可以将立即数、寄存器和内存中的操作数,移动到寄存器或者内存中。需要注意,ATT 格式的数据传输方向是从左向右,而 Intel 格式恰好相反。
mov 指令不能将一个内存中的操作数,移动到另一个内存地址。
下面我们主要以 Intel 格式为例,来详细介绍 C 语言和汇编指令的对应关系。
3.常用汇编指令
(1)数据传输指令
-
mov:在寄存器和内存之间移动数据;
-
lea:load effective address,加载有效地址,将一个内存地址加载到目的寄存器;
-
push:将数据压入栈,同时 esp 减去数据长度;
-
pop:将栈顶数据弹出栈,同时 esp 加上数据长度;
(2)算术和逻辑运算指令
主要可以按照操作数的个数分为两类:
-
双操作数 —— add(加)、sub(减)、mul(无符号乘)、imul(有符号乘)、and(逻辑与)、or(或)、xor(异或)、sal/shl(左移)、sar(算术右移)、shl(逻辑右移)
格式为: op D, S
表示计算 D (op) S 的值,结果存入 D 中。
-
单操作数 —— inc(自增)、dec(自减)、neg(取负)、not(取反)
计算的结果仍然存入操作数所在位置。
比较特别的是除法指令,它们都只有一个操作数,表示除数,被除数则放在 edx : eax 中;得到的结果商放在 eax 中,余数放在 edx 中:
-
div:无符号除
-
idiv:有符号除
(3)转移指令
-
无条件转移:jmp
jmp 指令后面一般跟一个 “标签”(label),用来指明可以直接跳转到的目的地。它的底层编码一般都是 PC 相对的,也就是相对寻址。
-
有条件转移
标志寄存器 efl 中有很多标志状态,也称为 “条件码”,它们记录了最近的算术逻辑操作的结果属性。通过检测这些寄存器中的条件码,就可以执行条件转移指令了。最常用的条件码有:ZF(零标志)、CF(进位标志)、SF(符号标志)、OF(溢出标志);一般会对它们进行组合,用来表示更加容易理解的控制条件。
上面的条件跳转指令,需要先做一个算术或逻辑运算、更改条件码;而很多时候我们只需要做一个简单比较即可,并不需要将运算结果保存。有两类特殊指令可以只改变条件码、而不改变其它任何的寄存器:
这两种指令,特别是 cmp 经常和条件转移指令配合使用,用来实现条件分支(选择)和循环结构的程序。
-
调用和返回
进行子过程(函数)调用时,使用 call 指令;返回原函数时使用 ret 指令。
二、从 C 语言程序到汇编程序
1.编译器、汇编器、链接器
用高级语言编写好一段程序之后,需要经过一系列“翻译“过程,才能得到计算机能够执行的机器代码。比如,我们用 C 语言写了一个简单的 hello world 程序,源程序文件命名为 hello.c,用 GCC 编译器可以将它翻译成一个可执行目标程序 hello。具体的过程如下图所示:
第二步编译的结果,生成了汇编程序 hello.s,这就是汇编语言描述的机器指令;汇编程序再经过汇编就可以得到二进制的机器语言程序。
在一些集成开发环境(比如 Visual Studio)中,可以在调试(Debug)模式下对机器码进行 ”反汇编“,得到相应的汇编语言代码。
2.进程的地址空间
C 语言程序运行之后,对应的进程都会有自己独立的地址空间;这就是操作系统为每个进程提供的 虚拟地址空间。对于 32 位系统,进程虚拟地址空间的大小就是 2^32^ B = 4 GB。
整个虚拟空间需要操作系统统一管理,因此进程的虚拟地址空间中必须保留一部分给操作系统内核使用。对于 Linux 系统(32 位),内核区大小为 1GB,地址从 0xc0000000 ~ 0xffffffff;而 Windows 系统默认情况下内核区大小为 2GB,地址从 0x80000000 ~ 0xffffffff。其余低地址部分则为用户区。
下面是一个 x86 Linux 进程的虚拟地址空间。
用户区主要包括这样几部分:
-
程序代码和数据:主要包括 只读代码段 和 读写段(.data 和 .bss)。对所有进程来说,代码都是从固定地址开始,紧接着就是 C 语言中的全局和静态数据。其中 .data 中是已初始化的全局和静态 C 变量,而 .bss 中是未初始化的全局和静态变量。
-
堆(Heap):用于运行时的动态内存分配,向上(高地址)生长。代码和数据区,在进程开始运行时就被指定了大小;而通过调用 malloc 和 free 这样的 C 标准库函数,可以让堆区动态地扩展和收缩。
-
共享库的内存映射区:用户区的中间部分是一块内存映射区域,用来存放像 C 标准库这样的共享库。
-
用户栈(Stack):位于虚拟地址空间用户区顶部,向下(低地址)生长。一般用来存储局部变量和函数参数,结合堆栈指针可以方便地实现函数的调用和返回。
3.利用栈实现函数原理
C 语言中的函数是一种重要的抽象,它将代码按功能封装起来,让程序结构更加清晰、可重用性更高。
每个函数内部可以定义局部变量,这些变量只具有局部作用域。所以,嵌套函数调用时(例如,在函数 P 中调用函数 Q),就可以利用栈数据结构 ” 后进先出 “(LIFO)的特点,在栈内依次保存函数 P 和 Q 的相关内容。
这样,进程中的每一个函数,都会在栈上有一块自己的空间,就叫做 ” 栈帧 “(stack frame);当前正在执行的函数的栈帧总是在栈顶。这样 esp 的内容就是栈顶地址,而 ebp 的内容就保存当前栈顶栈帧的 “底部” 地址。
于是,在调用一个函数 Q(子过程)时,可以在栈上继续给 Q 中的局部变量分配内存空间(入栈)。当 Q 调用结束,就将 Q 的所有局部变量释放(出栈)。
想要用机器级代码实现函数调用,还需要考虑下面几个问题:
① 参数传递:函数 Q 应该能获取到 P 传入的参数;这可以通过指定参数存放的位置(写入寄存器或者入栈)来实现。
② 转移控制:调用 Q 时,需要跳转到函数 Q 入口处执行指令,这可以用 call 指令实现;调用结束,还应返回到 P 中的调用点继续执行,这需要保存之前调用点的信息,将下一条指令地址入栈。
等到调用结束时,执行 ret 指令返回,就执行出栈操作,将栈中保存的地址交给 eip,继续执行 P 中的下一条指令。
③ 保存上下文:原函数 P 使用的寄存器的内容,应该进行保存;调用结束后,还应该进行恢复。
调用 Q 后,可以先将原函数 P 的上下文(寄存器值)做一个入栈保存;然后再分配内存给 Q 的局部变量。待 Q 调用结束后,先释放 Q 的局部变量,然后继续弹栈恢复 P 的上下文。
比较特殊的是栈基指针 ebp,在调用 Q 之后,它应该指向 Q 的栈帧的底部;所以应该先将之前的 ebp(P 的栈帧底部)入栈,然后将 ebp 移向 esp 的位置。之后再保存 P 其它寄存器的值、分配空间给 Q 的局部变量。
push ebp
mov ebp, esp
当 Q 调用结束返回时,只要反向执行,让 esp 移向 ebp 的位置;再将原先保存的 ebp 的值弹出,并放入 ebp 中就可以了:
mov esp, ebp
pop ebp
对于函数调用开始时(call 之后)两条对 ebp 的处理指令,可以用一条 enter 指令来代替;函数结束时(ret 之前)的两条指令,则可以用 leave 来代替。
④ 返回值传递:函数 Q 调用结束,执行 ret 指令,此时应该能将返回值传回原函数 P;可以通过指定某个寄存器(eax)接收返回值来实现。
这样,我们可以将完整的栈帧结果表示如下:
栈帧中主要由 4 部分构成:上一层函数的上下文(主要是寄存器的值)、当前函数的局部变量、调用下一层函数所需的参数,以及返回地址。对于当前执行的函数 Q,没有参数构造区和返回地址两部分。
三、选择结构语句的机器级表示
除顺序结构外,高级语言程序中一般还会有选择结构和循环结构。
选择结构 又称为 分支(branch)结构。C 语言中的选择语句主要有 if ... else 和 switch ... case,此外三目运算符 ? : 也可以实现选择结构。
很显然,通过设置条件码(标志位)、结合各类转移指令,就可以很容易地实现程序中的选择语句。
下面一段 C 语言代码使用 if - else 语句实现了选择结构:
int a = 23;
int b = 31;
if (a > b)
{
a++;
}
else {
a--;
}
printf(" a = %d\n", a);
可以发现它等效于使用 2 个 goto,分别跳过 if 后面的分支和 else 后面的分支:
if (a <= b)
goto L1;
a++;
goto L2;
L1:
a--;
L2:
printf(" a = %d\n", a);
这样,很容易得到对应的汇编代码:
四、循环结构语句的机器级表示
C 语言中的循环语句有 do - while、while 和 for 三种。汇编语言中,同样可以用条件测试和跳转指令的组合来实现循环的效果。在循环结构中,通常使用条件转移指令来判断循环的结束。
1.do-while
下面是一段使用了 do - while 循环的 C 语言代码:
int a = 0;
do {
a++;
} while (a < 5);
printf(" a = %d \n ", a);
很明显,循环部分可以利用 if 判断和 goto 语言来实现:
int a = 0;
L1:
a++;
if (a < 5)
goto L1;
printf(" a = %d \n ", a);
这样,类比之前选择结构,可以得到对应的汇编表示如下:
2.while
while 循环与 do - while 类似,区别在于第一次执行循环体之前就要先做条件判断。下面是一段 while 循环的 C 语言代码:
int a = 0;
while (a < 5) {
a++;
}
printf(" a = %d \n ", a);
如果用 goto 进行改写,可以参考 if - else 的实现,使用两个 goto 分别进行满足、不满足循环条件时的跳转:
int a = 0;
L1:
if (a >= 5)
goto L2;
a++;
goto L1;
L2:
printf(" a = %d \n ", a);
于是对应的汇编程序如下:
3.for循环
for 循环可以将循环变量和循环条件统一列出,因此对程序员来说是最友好的;但它直接转换成汇编语言会有一定的难度。for 循环的基本形式如下:
for( 初始化循环变量; 判断循环条件; 更新循环变量)
循环体
可以把它先改写成 while 循环的形式:
初始化循环变量;
while( 判断循环条件 )
{
循环体;
更新循环变量;
}
这样,就可以用 goto 改写如下:
初始化循环变量;
L1:
if(循环条件不成立)
goto L2;
循环体;
更新循环变量;
goto L1;
L2:
循环外语句;
当然,如果不改写成 while、完全按照 for 循环的执行顺序来处理,就会麻烦很多。比如下面是一段 for 循环的 C 语言代码:
int a = 10;
for (int i = 0; i < 5; i++)
{
a--;
}
printf(" a = %d \n ", a);
对应的汇编代码如下:
很明显,本质上三种循环都是等效的,而比较之下 do-while 循环的汇编指令最简洁。因此,大多数编译器会进行优化,将另两种循环语句转换为 do-while 语句形式来生成机器代码。
另外,x86 架构指令集还提供了一个 loop 指令专门用于循环的实现。它默认使用 ecx 寄存器作为循环计数器,每次执行到 loop 指令都会先对 ecx 做减 1 操作;然后判断 ecx 是否为 0,如果不为 0 则跳转到 loop 后面标号对应的位置,如果为 0 则循环结束继续向下执行。
mov ecx, 10
.L1:
mov eax, dword ptr [a]
sub eax, 1
mov dword ptr [a], eax
loop .L1
上面的 loop 指令相当于:
dec ecx
cmp ecx, 0
jne .L1
这样减少了指令数,汇编代码的可读性更高了。除 loop 外,还有类似的 loopz 和 loopnz 指令,它们判断循环继续的条件除了 ecx != 0 外,还有对 ZF 的要求。
五、过程(函数)调用对应的机器级表示
下面是一段 C 语言的函数调用过程。我们在 main 函数中调用了 add 函数,进行两个整数的求和。
int add(int x, int y);
int main() {
int a = 23;
int b = 31;
int sum = add(a, b);
printf(" sum = %d\n", sum);
}
int add(int x, int y) {
int sum = x + y;
return sum;
}
函数调用时,首先应该跳转之前,将需要的参数进行保存;然后执行 call 指令,同时将下一条指令地址入栈。主程序 main 对应的汇编代码如下:
调用函数 add 后,首先应该将原来的 ebp 入栈,并将 ebp 指向当前 esp 的位置(add 函数栈帧的底部);然后保存之前其它寄存器的值(入栈)。之后分配局部变量的空间,执行 add 内部指令。执行完毕后,做反向操作,弹栈恢复所有寄存器的值,返回 main 函数的调用处继续执行。
add 函数对应的汇编指令如下: