从汇编角度看待函数调用

引言

函数调用对于程序员而言,就像每天吃饭睡觉一样普通寻常。几乎每种编程语言都会提供函数定义、函数调用的功能。但是,在看起来寻常不过的函数调用背后,系统内核帮助我们做了很多事情。下面,我打算通过反汇编的方法,从汇编语言的层次来阐释函数调用的实现。

基础知识

先回顾几个概念,这样可以帮助我们顺利地理解后面实验的结果。

调用函数(caller)和被调函数(callee)

调用函数(caller)向被调函数(callee)传入参数,被调函数(callee)返回结果。首先要明确这两个名词,免得被下文的表述弄混淆。

高地址和低地址

每个进程都有自己的虚拟地址空间。高地址和低地址是相对的,我们通常用16进制数来表示一个内存地址。例如,相比于0x000x04数值上比0x00大,所以0x04称为高地址, 0x00 称为低地址。

进程内存布局

如图,一个进程的内存布局从低地址到高地址分别是

  1. 代码段
  2. 数据段,包括初始化区和未初始化区(bss)
  3. 堆段
  4. 栈段
  5. 内核地址空间

《从汇编角度看待函数调用》

栈段(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)。

以上输出每行指示一条汇编指令,除程序源码外共有四列,各列含义为:

  1. 0x00000000004004ba: 该指令对应的虚拟内存地址
  2. <+0>: 该指令的虚拟内存地址偏移量
  3. 55: 该指令对应的计算机指令
  4. push %rbp: 汇编指令

回忆一下我们用汇编语言写调用函数的代码时,第一步是“保护现场”,也就是:

  1. 将调用函数的栈帧栈底地址入栈,即将bp寄存器的值压入调用栈中
  2. 建立新的栈帧,将被调函数的栈帧栈底地址放入bp寄存器中,其值为调用函数的栈顶地址sp

以下两条指令即完成上面动作:

push %rbp
mov  %rsp, %rbp

通过objdumpgdb的结果,我们发现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指令,完成了两个任务:

  1. 将调用函数(main)中的下一条指令(这里为0x00000000004004cd)入栈,被调函数返回后将取这条指令继续执行
  2. 修改指令指针寄存器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

参考:

https://web.stanford.edu/clas…

    原文作者:附中食堂厨师长
    原文地址: https://segmentfault.com/a/1190000016661251
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞