引言
函数调用对于程序员而言,就像每天吃饭睡觉一样普通寻常。几乎每种编程语言都会提供函数定义、函数调用的功能。但是,在看起来寻常不过的函数调用背后,系统内核帮助我们做了很多事情。下面,我打算通过反汇编的方法,从汇编语言的层次来阐释函数调用的实现。
基础知识
先回顾几个概念,这样可以帮助我们顺利地理解后面实验的结果。
调用函数(caller)和被调函数(callee)
调用函数(caller)向被调函数(callee)传入参数,被调函数(callee)返回结果。首先要明确这两个名词,免得被下文的表述弄混淆。
高地址和低地址
每个进程都有自己的虚拟地址空间。高地址和低地址是相对的,我们通常用16进制数来表示一个内存地址。例如,相比于0x00
,0x04
数值上比0x00
大,所以0x04
称为高地址, 0x00
称为低地址。
进程内存布局
如图,一个进程的内存布局从低地址到高地址分别是
- 代码段
- 数据段,包括初始化区和未初始化区(bss)
- 堆段
- 栈段
- 内核地址空间
栈段(stack segment)
栈是最常用的数据结构之一,可以进行push/pop,且只允许在一端进行操作,后进先出(LIFO)。但就是这个最简单的数据结构,构成了计算机中程序执行的基础,用于内核中程序执行的栈具有以下特点:
- 每一个进程在用户态对应一个调用栈结构(call stack)
- 程序中每一个未完成运行的函数对应一个栈帧(stack frame),栈帧中保存函数局部变量、传递给被调函数的参数等信息
- 栈底对应高地址,栈顶对应低地址,栈由内存高地址向低地址生长
一个进程的调用栈图示如下:
寄存器(register)
寄存器位于CPU内部,用于存放程序执行中用到的数据和指令,CPU从寄存器中取数据,相比从内存中取快得多。
寄存器又分通用寄存器和特殊寄存器。
通用寄存器有ax/bx/cx/dx/di/si,尽管这些寄存器在大多数指令中可以任意选用,但也有一些规定某些指令只能用某个特定“通用”寄存器,例如函数返回时需将返回值mov到ax寄存器中;特殊寄存器有bp/sp/ip等,特殊寄存器均有特定用途,例如sp寄存器用于存放以上提到的栈帧的栈顶地址,除此之外,不用于存放局部变量,或其他用途。
对于有特定用途的几个寄存器,简要介绍如下:
- ax(accumulator): 可用于存放函数返回值
- bp(base pointer): 用于存放执行中的函数对应的栈帧的栈底地址
- sp(stack poinger): 用于存放执行中的函数对应的栈帧的栈顶地址
- ip(instruction pointer): 指向当前执行指令的下一条指令
不同架构的CPU,寄存器名称被添以不同前缀以指示寄存器的大小。例如对于x86架构,字母“e”用作名称前缀,指示各寄存器大小为32位;对于x86_64寄存器,字母“r”用作名称前缀,指示各寄存器大小为64位。
大学课程(例如微机原理、汇编语言)里应该都会介绍Intel 8086汇编或类似知识,相信应该可以触类旁通,很多时候只是寄存器的名字发生了变化,大体的思想还是共通的。
函数调用样例
在掌握了基础知识之后,我们选取下面这个简单的例子进行分析。
//call_example.c
int add(int a, int b) { return a + b; }
int main(void) {
add(2, 5);
return 0;
}
通过gcc call_example.c -g -o call_example
命令得到可执行文件call_example
。
加上参数-g是为了让目标文件call_example
包含符号表等调试信息。
我们可以用objdump -D -M att ./call_example
命令先来对call_example
进行反汇编看看结果。截取了部分结果如下:
00000000004004a6 <add>:
4004a6: 55 push %rbp
4004a7: 48 89 e5 mov %rsp,%rbp
4004aa: 89 7d fc mov %edi,-0x4(%rbp)
4004ad: 89 75 f8 mov %esi,-0x8(%rbp)
4004b0: 8b 55 fc mov -0x4(%rbp),%edx
4004b3: 8b 45 f8 mov -0x8(%rbp),%eax
4004b6: 01 d0 add %edx,%eax
4004b8: 5d pop %rbp
4004b9: c3 retq
00000000004004ba <main>:
4004ba: 55 push %rbp
4004bb: 48 89 e5 mov %rsp,%rbp
4004be: be 05 00 00 00 mov $0x5,%esi
4004c3: bf 02 00 00 00 mov $0x2,%edi
4004c8: e8 d9 ff ff ff callq 4004a6 <add>
4004cd: b8 00 00 00 00 mov $0x0,%eax
4004d2: 5d pop %rbp
4004d3: c3 retq
4004d4: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
4004db: 00 00 00
4004de: 66 90 xchg %ax,%ax
objdump
固然是一个好工具,但是有时候看起来不是那么直观,下面我着重介绍用gdb进行分析反汇编分析。
利用gdb进行反汇编分析
我们利用gdb
跟踪main->add的过程。
启动
利用gdb
载入可执行程序call_example
$ gdb ./call_example
GNU gdb (GDB) 7.12.1
Reading symbols from ./call_example...done.
(gdb) start
Temporary breakpoint 1 at 0x4004be: file call_example.c, line 3.
Starting program: /tmp/call_example
Temporary breakpoint 1, main () at call_example.c:3
3 add(2, 5);
(gdb)
start命令用于拉起被调试程序,并执行至main函数的开始位置,程序被执行之后与一个用户态的调用栈关联。
main函数
现在程序停止在main函数,用disassemble命令显示当前函数的汇编信息:
(gdb) disassemble /mr
Dump of assembler code for function main:
2 int main(void) {
0x00000000004004ba <+0>: 55 push %rbp
0x00000000004004bb <+1>: 48 89 e5 mov %rsp,%rbp
3 add(2, 5);
=> 0x00000000004004be <+4>: be 05 00 00 00 mov $0x5,%esi
0x00000000004004c3 <+9>: bf 02 00 00 00 mov $0x2,%edi
0x00000000004004c8 <+14>: e8 d9 ff ff ff callq 0x4004a6 <add>
4 return 0;
0x00000000004004cd <+19>: b8 00 00 00 00 mov $0x0,%eax
5 }
0x00000000004004d2 <+24>: 5d pop %rbp
0x00000000004004d3 <+25>: c3 retq
End of assembler dump.
(gdb)
disassemble命令的/m指示显示汇编指令的同时,显示相应的程序源码;/r指示显示十六进制的计算机指令(raw instruction)。
以上输出每行指示一条汇编指令,除程序源码外共有四列,各列含义为:
- 0x00000000004004ba: 该指令对应的虚拟内存地址
- <+0>: 该指令的虚拟内存地址偏移量
- 55: 该指令对应的计算机指令
- push %rbp: 汇编指令
回忆一下我们用汇编语言写调用函数的代码时,第一步是“保护现场”,也就是:
- 将调用函数的栈帧栈底地址入栈,即将bp寄存器的值压入调用栈中
- 建立新的栈帧,将被调函数的栈帧栈底地址放入bp寄存器中,其值为调用函数的栈顶地址sp
以下两条指令即完成上面动作:
push %rbp
mov %rsp, %rbp
通过objdump
和gdb
的结果,我们发现main函数也包含了这两条指令,这是因为main函数也会被__libc_start_main
所调用,这里不多加赘述。
main调用add函数,两个参数传入通用寄存器中:
mov $0x5,%esi
mov $0x2,%edi
咦?汇编语言课上老师不是教过传递的参数会被压入栈中么?
其实,x86和x86_64定义了不同的函数调用规约(calling convention)。x86_64采用将参数传入通用寄存器的方式,x86则将参数压入调用栈中。我们利用gcc -S -m32 call_example.c
来直接生成x86平台的汇编代码,找到传递参数那段代码:
pushl $5
pushl $2
call add
原来如此!
准备完参数之后,就可以放心大胆的将控制权交给add函数了,callq指令完成这里的交接任务:
0x00000000004004c8 <+14>: e8 d9 ff ff ff callq 0x4004a6 <add>
callq指令会在调用函数的时候将下一条指令的地址push到stack上,当本次调用结束后,retq指令会跳转到被保存的返回地址处使程序继续执行。
本次callq指令,完成了两个任务:
- 将调用函数(main)中的下一条指令(这里为0x00000000004004cd)入栈,被调函数返回后将取这条指令继续执行
- 修改指令指针寄存器rip的值,使其指向被调函数(add)的执行位置,这里为0x00000000004004a6
我们可以用stepi
指令进行指令级别的操作,相比于一般调试时候按行调试的粒度会更精细。
(gdb) stepi 3
add (a=0, b=4195248) at call_example.c:1
1 int add(int a, int b) { return a + b; }
(gdb) disassemble /mr
Dump of assembler code for function add:
1 int add(int a, int b) { return a + b; }
=> 0x00000000004004a6 <+0>: 55 push %rbp
0x00000000004004a7 <+1>: 48 89 e5 mov %rsp,%rbp
0x00000000004004aa <+4>: 89 7d fc mov %edi,-0x4(%rbp)
0x00000000004004ad <+7>: 89 75 f8 mov %esi,-0x8(%rbp)
0x00000000004004b0 <+10>: 8b 55 fc mov -0x4(%rbp),%edx
0x00000000004004b3 <+13>: 8b 45 f8 mov -0x8(%rbp),%eax
0x00000000004004b6 <+16>: 01 d0 add %edx,%eax
0x00000000004004b8 <+18>: 5d pop %rbp
0x00000000004004b9 <+19>: c3 retq
End of assembler dump.
(gdb)
至此,main函数的执行到此就暂时告一段落了,我们进入了add函数的新篇章。
add函数
add函数也是一样的套路,头两条指令先建立自己的栈帧,然后调用add指令计算结果,结果存放在eax寄存器中。计算完之后,需要“恢复现场”:
0x00000000004004b8 <+18>: 5d pop %rbp
因为此例比较特殊,add函数没有包含局部变量,main和add函数的栈顶恰好相同,所以忽略了对栈顶rsp的恢复。
通常,完整的“恢复现场”需要以下两条指令:
mov %rbp, %rsp
pop %rbp
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。