1. 计算机是如何工作的?——存储程序
计算机工作的基本方式:取指执行。即,从内存中指定位置取出指令,然后在 CPU 中执行该指令。
IP 寄存器: IP (Instruction Pointer) 总指向计算机即将执行的指令。计算机在工作的时候,首先到 IP 所指向的内存地址获取将要执行的指令,然后 IP 自加 1(条指令)指向下一条指令。注意;每条指令所占据的内存空间可能是不同的。
在 x86 架构中,实际由 EIP 寄存器作为指令指针(32位寄存器)。EIP 寄存器可被 CALL, RET, JMP 以及条件跳转语句修改。
2. x86 汇编基础
- x86 CPU 寄存器
通用寄存器 | 低16位 | 低8位 | 全名 |
Accumulator | AX | AL | EAX |
Base Register | BX | BL | EBX |
Count Register | CX | CL | ECX |
Data Register | DX | DL | EDX |
特殊寄存器 | 对应 16-bit 寄存器 | 32-bit 寄存器 |
Base Pointer | BP | EBP |
Stack Pointer | SP | ESP |
Source Index | SI | ESI |
Destination Index | DI | EDI |
段寄存器: CS (Code Segment), SS (Stack Segment) 等。
CPU 在取指令时,根据 CS 和 EIP 查找指令。
在 32 位汇编中,寄存器缩写一般以 E 开头,意为 Extended;64 位汇编中,寄存器缩写以 R 开头。
- 常用命令
MOV, PUSH, POP, CALL, RET
MOV - 赋值命令。用法:movl a, b 将 a 的值赋给 b
PUSH - 压栈。用法: pushl a,将 a 的值压栈。
等价命令: addl 4, %esp
movl a, (%esp)
POP - 出栈。用法:popl a,将栈中弹出的值赋给 a
等价命令: movl (%esp), a
subl 4, %esp
CALL - 调用函数。用法:call func
实际操作:pushl %eip
movl func, %eip
RET - 返回。
实际操作:popl %eip
[注]CALL 和 RET 不能用对应实际操作的语句来替换,原因:eip 寄存器只能通过指定语句赋值。
[注]MOV, PUSH, POP 后可以加 b(8-bit), w(16-bit), l(32-bit), g(64-bit)。
- 寻址方式
寻址方式 | 实例 | 说明 |
Register mode | movl %eax, %ebx | 直接将 eax 的内容赋值给 ebx |
Immediate | movl $0x123, %eax | 将数 0x123 赋给 eax |
Direct | movl 0x123, %eax | 将地址 0x123 所存放内容赋给 eax |
Indirect | movl (%ebx), %eax | 将地址为 ebx 的值赋给 eax |
Displace | movl 4(%ebx), %eax | 将地址为 ebx + 4 的值赋给 eax |
3. 实际例程分析
实验截图
int g(int x) { return x + 7;}int f(int x){ return g(x);}int main(void){ return f(11) + 9;}上面是一段简单的 C 语言代码,使用 gcc -S test.c -o test.s -m32 将 C 语言编译成为汇编语言,得到如下的汇编代码(只保留指令内容)
g: pushl %ebp movl %esp, %ebp movl 8(%ebp), %eax addl $7, %eax popl %ebp retf: pushl %ebp movl %esp, %ebp subl $4, %esp movl 8(%ebp), %eax movl %eax, (%esp) call g leave retmain: pushl %ebp movl %esp, %ebp subl $4, %esp movl $11, (%esp) call f addl $9, %eax leave ret
由于 main 函数是整个程序的入口,我们从 main 标签开始分析整个汇编程序。
main: 18, 19行
pushl %ebp - 将栈底指针压栈movl %esp, %ebp - 将 esp 赋值给 ebp
以上两个语句实质是将函数运行栈置空,两个指令完成之后,ebp 和 esp 相等,表现为空栈。而 ebp 上一个位置存放的即是之前 ebp 的值。这个过程也被定义为宏指令 enter。对应的逆过程指令为 leave(main: 24行),可以展开写作:
movl %ebp, %esppopl %ebp
通过 enter 和 leave 的配合,保证了栈状态在函数调用前后的一致性。
main: 20, 21行
subl $4, %espmovl $11, (%esp)
可以看到这实质上就是 pushl $11 指令的展开,即将函数调用参数压栈。将立即数 11 而是赋给了 esp 作为地址所指向的内存单元。
call f
调用 f 函数,此指令实际由两个指令组成:首先是将当前 eip 压栈(注意!!),然后将 f 函数起始指令地址赋给 eip,从而实现指令执行的跳转。回顾一下,到目前为止执行了三次压栈操作,依次压入了 ebp (main 函数调用前状态),立即数11,以及 eip (指向程序第 23 行)。
现在,程序执行跳转到 f 部分的第一条指令(f: 9行)
pushl %ebp接下来就是在 main 部分已经解释的函数调用栈的初始化动作,首先将 ebp (f 函数调用前状态)压栈,接着第 10 行指令将当前 esp 赋值给 ebp。然后接下来三行,
subl $4, %espmovl 8(%ebp), %eaxmovl %eax, (%esp)如果排列成这样的顺序则更容易理解,
movl 8(%ebp), %eaxsubl $4, %espmovl %eax, (%esp)观察代码,我们可以很安全地交换第一二行。交换后,第二、三行执行的调用函数时参数的压栈动作。那么第一行呢?可以看到是从 ebp + 8 这个地址取数并复制给 eax,那么这里 ebp + 8 这个地址存放的是什么呢? 我们回顾一下程序执行过程,可以发现,在第10行,ebp 被赋值成为了 f 函数的栈底指针;第9行 处,压入了 main 这个函数的栈底指针,此刻 ebp 指向的地址存放的正是这个指针的值。再往上存在跳转,在 main 函数调用 f 函数的的22行压入了当时的 eip 指针,这里的位置就是此刻的 4(%ebp)。再往上,在 main 函数第20,21行,程序将立即数 11 压栈,因此 8(%ebp) 存放的内容正是立即数 11。
在 32 位汇编中,函数调用参数通过栈进行传递,按照参数列表逆序压栈;紧接着压入 eip 指针和 ebp 栈底指针,并将 ebp 的值置为 esp。所以要访问第一个参数,需访问地址 ebp + 8。
之后的代码和上面大同小异,就不逐个分析了。
总结:
总体说来,程序在执行的时候,如果不存在函数调用或者其他跳转语句时,CPU 将依照指令顺序线性地进行取指执行,这个过程中扮演者关键地位的是 EIP 寄存器,EIP 总指向即将执行的语句,并且取指后自动指向下一条指令。如果存在函数调用,如果存在函数调用,则需要在栈内维护以下信息;1. 函数调用参数;2. 函数返回位置;3. 函数运行栈指针。
具体说来,在函数调用前,将参数压入栈内;接下来调用函数时将 EIP 寄存器的值压栈(正是函数返回地址);然后在被调函数中将主调函数的栈底指针压栈,并更新 EBP 为此刻栈顶指针的值(亦即置为空栈)。
在函数调用完毕后,进行逆操作:首先,在被调用函数中将 EBP 赋给 ESP(栈顶指针还原),弹出主调函数的栈底指针赋给 EBP(栈底指针还原);然后,弹出函数返回地址赋给 EIP,让程序可以从调用处接着往下执行。
=========================================
真实姓名:姚思远 原创作品转载请注明出处
《Linux内核分析》MOOC课程