从汇编角度看待函数调用

引言

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

基础知识

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

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

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

高地址和低地址

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

进程内存布局

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

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

memoryLayoutC.jpg

栈段(stack segment)

栈是最常用的数据结构之一,可以进行push/pop,且只允许在一端进行操作,后进先出(LIFO)。但就是这个最简单的数据结构,构成了计算机中程序执行的基础,用于内核中程序执行的栈具有以下特点:

  • 每一个进程在用户态对应一个调用栈结构(call stack)
  • 程序中每一个未完成运行的函数对应一个栈帧(stack frame),栈帧中保存函数局部变量、传递给被调函数的参数等信息
  • 栈底对应高地址,栈顶对应低地址,栈由内存高地址向低地址生长

一个进程的调用栈图示如下:

callstack

寄存器(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...

函数调用过程是程序中常见的一种操作,它通常涉及到参数传递、栈帧的建立与销毁、返回值的传递等多个方面。从汇编角度来看,函数调用过程可以分为以下几个步骤: 1. 将函数的参数压入栈中。在调用函数时,需要将函数所需的参数传递给它。这些参数通常以一定的顺序压入栈中,以便在函数内部使用。在 x86 架构中,参数的传递是通过将参数压入栈顶实现的。 2. 调用函数函数调用的指令通常是 CALL 指令。在调用函数前,需要将函数的入口地址压入栈中,以便在函数执行完毕后返回到调用位置。CALL 指令会将当前的程序计数器(PC)压入栈中,并将函数的入口地址作为新的 PC。 3. 建立栈帧。在函数被调用时,需要为函数建立一个独立的栈帧,以便在函数内部使用局部变量和临时变量。栈帧通常包括以下几个部分:返回地址、旧的基址指针、局部变量和临时变量。在 x86 架构中,栈帧的建立是通过将 ESP 寄存器减去一个固定的值实现的。 4. 执行函数。在函数被调用后,CPU 会跳转到函数的入口地址并开始执行函数。函数内部可以通过栈中的参数和局部变量完成相应的计算和操作。 5. 返回值传递。在函数执行完毕后,需要将函数的返回值传递给调用者。在 x86 架构中,函数的返回值通常通过 EAX 寄存器传递。 6. 销毁栈帧。在函数执行完毕后,需要将栈帧销毁,以便释放栈空间。栈帧的销毁通常是通过将 ESP 寄存器还原到旧的基址指针处实现的。 7. 返回到调用位置。在函数执行完毕后,需要返回到函数被调用的位置。在 x86 架构中,返回指令通常是 RET 指令。RET 指令会将栈顶的返回地址弹出,并将其作为新的 PC。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值