过程是软件中一种很重要的抽象,它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。

假设过程 P 调用过程 Q,Q 执行后返回到 P。那么这些动作包括下面一个或多个机制。

  • 传递控制
  • 传递数据
  • 分配和释放内存

一、操作数指示符

各种不同的操作数可以分为三种类型:

  • 立即数(immediate):例如,$3 或 $0x1F。
  • 寄存器(register):表示某个寄存器的内容,用符号 ra 表示任意寄存器 a,用引用 R[ra] 表示它的值,这将寄存器集合看成一个数组 R,用寄存器标识符作为索引。
  • 内存引用是第三类操作数:它会根据计算出来的地址(有效地址)访问某个内存位置。
  • 寻址模式:Imm(rb, ri, s)由一个立即数偏移Imm,一个基址寄存器 rb,一个变址寄存器 ri 和一个比例因子 s 组成,这里 s 必须是 1、2、4 或者 8。基址和变址寄存器必须是 64 位寄存器。有效地址被计算为 Imm+R[rb]+R[ri]·s。

二、压入和弹出栈操作

在聊压栈和弹栈操作之前,我们有必要先了解一下什么是栈?顺便把堆也一块复习了。

(1) 相同点:

  1. 栈和堆都是用来从底层操作系统中获取内存的。
  2. 在多线程环境下每个线程都可以有他自己完全的独立的栈,但是他们共享堆。并行存取是堆来控制,而不是栈。

(2) 不同点:
栈:

  1. 栈是为执行线程留出的内存空间,一般提前分配好了,栈必须是连续的内存块。
  2. 栈是一种数据结构,可以添加或者删除值,遵循“后进先出”(LIFO)的原则;从栈中释放块(free block)只不过是指针的偏移而已。
  3. 每一个线程都有一个栈,但是每一个应用程序通常只有一个堆。
  4. 栈经常与 sp 寄存器(stack pointer)一起工作,最初 sp 指向栈顶(栈的高地址)。
  5. CPU 用 push 指令来讲数据压栈,用 pop 指令来弹栈。当用 push 压栈时,sp 值减少(向低地址扩展)。当用 pop 弹栈时,sp 值增大。存储和获取的都是 CPU 寄存器的值。
  6. 当函数被调用时,CPU 使用特定的指令把当前的 IP(instruction pointer,指令地址寄存器,用来记录 CPU 指令的位置)压栈。即执行代码的地址。CPU 接下来把将要调用的函数地址赋给 IP,进行调用。当函数返回时,旧的 IP 被弹栈,CPU 继续去调用之前的代码。

堆:

  1. 堆是动态分配预留的内存空间。堆包含一个链表来维护已用和空闲的内存块。在堆上新分配(用 new 或者 malloc)内存是从空闲的内存块中找到一些满足要求的合适块。这个操作会更新堆中的块链表,元信息被存储在每个块头部的区域。
  2. 堆通常从低地址向高地址扩展。如果申请的堆内存很小,操作系统可能给它分配的内存为最小的堆内存单元(比申请的大)。
  3. 申请和释放许多小的块会产生“堆碎片”,导致申请大块内存失败。
  4. 当空闲块旁边的已用块释放时,新的空闲块可能会与相邻的空闲块合并为一个大的空闲块,这样可以减少“堆碎片”的产生。

push 和 pop 是汇编中压栈和出栈的指令。这里引用一张图来说明:

%rsp0x108%rax0x123

  • pushq %rax:做了两件事

    • subq $8, %rsp:内存地址 0x108 向下扩展到 0x100
    • movq %rax, (%rsp):然后将 0x123 存放到内存地址 0x100 处。
  • popq %rdx:做了两件事,这是弹出一个四字的操作。

    • movq (%rsp), %rdx:从栈顶位置读出 0x123 赋值给 %rdx
    • addq $8, %rsp:然后将栈顶指针加 8,变为原来的 0x108

注:%rax 是直接寻址操作;(%rsp)是间接寻址操作,也就是先取出 %rsp 里的值作为地址,再根据这个地址到内存中找到相应的位置并取出其中的值。

三、函数调用的栈帧结构

其实,从某种意义上说,C 语言就是个函数嵌套语言,从一个主函数开始,内部生出几个子函数,每个子函数下面又有更多的子函数,有时候父子函数之间还会出现递归嵌套调用,再加上循环和条件判断,如此复杂的操作,编辑器是怎么翻译成汇编来实现的呢?这依赖于简单实用的栈帧结构,引用《深入理解计算机系统》中的一张图:

我们先不看栈里面具体的内容,我们带着几个问题去研究栈帧结构,系统中这么多的函数调用,无论函数嵌套有多复杂,肯定有个先后关系。那么这么多的栈,就是根据调用的先后排列顺序入栈,先调用的函数,其栈帧结构就整体先入栈,后调用的函数就后入栈,栈顶所代表的就是当前栈,就是当前正在调用的函数

栈帧结构最难理解的就是那句 “被保存的%rbp”。这句话难的背后,是对 rbp 在栈帧中的作用的理解,可以这么说,只有把 %rbp 理解了,才能真正理解栈帧结构。那么 %rbp 是什么呢?%rbp 被称为基址指针寄存器(base pointer)或者帧指针(frame pointer),它保存着当前栈帧的基地址。图中函数 Q 的第一个地址——被保存的%rbp,我们要注意它(在栈中的值)这里保存的可不是函数 Q 的基地址,而是它的调用者函数 P 的基地址。这里大家可能会奇怪,为什么是函数 P 的基地址呢?后文我会讲到这个,这里大家先知道栈中的「被保存的%rbp」保存的是调用者的栈帧首地址。

首先看右侧以帧划分的三个部分,函数 P 要调用函数 Q,较早的帧调用函数 P,可以看出,函数的调用地址的方向从高地址向低地址扩展的

我们接着来看看调用函数 P 的参数部分,参数7…参数n,为什么是从参数 7 开始呢?其实,C 语言的过程 P 调用 Q,通过寄存器,P 最多可以传送 6 个整数值(指针和整数)给 Q。假设 Q 需要的参数 n > 6,那么在调用 Q 之前,P 必须在自己的栈帧里为这多出来的参数分配空间,并且分配的帧栈必须能够容纳下 7 到 n 号参数。参数的压栈顺序是从右向左的,也就是说参数 7 的地址一定比参数 n 的地址小。

设置好调用参数之后,开始执行 call 指令。call 指令做了两件事,

  • 第一件事是 pushl %eip,将返回地址(紧跟在 call 指令的下一条指令)压入栈,用于函数返回继续执行。我们把这个返回地址当做 P 的栈帧的一部分,因为它存放的是与 P 相关的状态。这步执行完后,栈指针 %esp 的值会减少一定大小,用来存放返回地址,即 (%esp)。无论如何,%esp 指向的地址总是在栈顶。
  • 第二件事是 jmp Q,跳转到函数 Q,会改变程序计数器(PC) %eip 寄存器的值,具体为 call 指令后面跟的地址,在这里为 Q 的第一条指令地址。

接下来,进入到了函数 Q 的执行过程,首先我们看到汇编代码的最上面有两句:

1
2
pushl   %ebp 		# 把父调用者的帧首地址 %ebp 入栈
movl %esp, %ebp # 更新 %ebp 为当前 Q 函数的帧首

关键的地方来了,还记到上面提到过的栈中的「被保存的%rbp」保存的是调用者的栈帧首地址吧。根据之前复习过的 push 操作的用法:

  • 第一句 pushl %ebp 的意思是 %ebp 寄存器的值压入栈中,我们知道到目前为止,%ebp 的值一直是函数 P 的帧首,所以压入栈帧中的值是函数 P 的帧首。这时候 %rbp 指向的依旧是函数 P 的帧首。
  • 第二句 movl %esp, %ebp 的意思是将当前栈指针寄存器 %esp 的地址赋值给帧指针寄存器 %ebp,这时候 %ebp 指向的是函数 Q 的帧首。对于函数 Q 的栈帧来说,此时栈帧顶和栈帧底指向同一位置。

整理一下思路,当一个函数调用另一个函数的时候,需要对栈帧信息进行修改和维护,如何在 Q 函数执行完后让 CPU 顺利地找到 P 函数的帧首地址并成功返回呢?这就要在调用 Q 之前做好充分的准备,我们知道,P 函数有自己的帧首,在 P 函数执行的时候,%ebp 就保存了这个帧首的地址值,或者说 %ebp 指向 P 函数的帧首。当调用 Q 函数的时候,%ebp 的值会更新为 Q 的栈帧首地址,为了让 Q 在返回时能够顺利更新 %ebp 的值,使得帧指针顺利指回到 P 函数的帧首,有必要在 %ebp 指向 Q 函数(被调用函数)帧首之前,更改 Q 函数帧首空间内所保存的值,为 P 函数(调用函数)的帧首地址值,也就是 Q 栈帧中的「被保存的%rbp」的地址值。这样一来,每一个当前调用的函数都保存着父函数的帧首,函数执行完后都能顺利更新为父函数的帧首,以此类推,一直到 main 函数的帧首。通过 %ebp 的修改和被保存,就能确保栈帧结构的访问顺利进行,是不是很奇妙?

四、一个例子

下面用一个例子来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int g(int x)
{
return x+5;
}

int f(int x)
{
return g(x);
}

int main(void)
{
return f(10)+1;
}

根据汇编代码画了一个图,我们先看看完整的栈结构信息图,这里假设进入 main 函数时栈顶 %rsp 的地址是 0x7fffffffd9e8

使用 disassemble 命令显示 main 函数汇编指令代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
0000000000400513 <main>:

int main(void)
{
400513: 55 push %rbp
400514: 48 89 e5 mov %rsp,%rbp
return f(10)+1;
400517: bf 0a 00 00 00 mov $0xa,%edi
40051c: e8 db ff ff ff callq 4004fc <f>
400521: 83 c0 01 add $0x1,%eax
}
400524: 5d pop %rbp
400525: c3 retq

(1)创建新的栈帧

一个函数被调用,首先默认要完成以下动作:

  • 建立新的栈帧,将调用函数的帧首地址入栈,即将 bp 寄存器的值压入调用栈中
  • 将被调函数的帧首地址放入 bp 寄存器中

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

1
2
400513:       55                      push   %rbp
400514: 48 89 e5 mov %rsp,%rbp

执行完之后,%rsp 将减少 8,变为 0x7fffffffd9e0%rbp 将和 %rsp 一样,指向同一个地址。

有人要问了,不是说 main 函数是程序的入口吗,难道还会有其他函数来调 main 函数?是的,皆因 main 并不是程序拉起后第一个被执行的函数,main 函数是被 _start 函数拉起的,更详细的解释参看这里

(2)准备调用函数需要的参数

言归正传,一个函数调用另一个函数,需先将参数准备好。main 函数调用 f 函数,一个参数传入通用寄存器中:

1
400517:       bf 0a 00 00 00          mov    $0xa,%edi

(3)调用函数

万事具备,是时候将执行控制权交给 f 函数了,call 指令完成交接任务:

1
2
3
4
return f(10)+1;
400517: bf 0a 00 00 00 mov $0xa,%edi
40051c: e8 db ff ff ff callq 4004fc <f>
400521: 83 c0 01 add $0x1,%eax

先把 0xa 的值存入 %edi 寄存器。然后,一条 call 指令,完成了两个任务:

  • 被调函数执行完之后要返回紧跟在 call 的下一条指令继续执行,所以把 call 的下一条指令的地址(称为返回地址0x400521 入栈,同时 %rsp 的值将减去 8%rsp 的值现在是 0x7fffffffd9d8,如下调用栈图所示。
  • jmp f,跳转到函数 f,这将会改变 %rip 的值,变为 0x4004fc,即程序将跳转到该地址继续执行。

si 命令执行到 call 指令之后,使用 info registers 指令查看寄存器 %rbp 和 %rsp 的值:

1
(gdb) info registers rbp rsp
rbp            0x7fffffffd9e0	0x7fffffffd9e0
rsp            0x7fffffffd9d8	0x7fffffffd9d8
(gdb)

调用栈:

(4)执行控制权交接给被调函数

程序进入 f 函数,其汇编指令:

1
(gdb) disas /rm
Dump of assembler code for function f:
7	{
   0x00000000004004fc <+0>:	55		push   %rbp
   0x00000000004004fd <+1>:	48 89 e5	mov    %rsp,%rbp
   0x0000000000400500 <+4>:	48 83 ec 08	sub    $0x8,%rsp
   0x0000000000400504 <+8>:	89 7d fc	mov    %edi,-0x4(%rbp)

8	    return g(x);
=> 0x0000000000400507 <+11>:	8b 45 fc	mov    -0x4(%rbp),%eax
   0x000000000040050a <+14>:	89 c7		mov    %eax,%edi
   0x000000000040050c <+16>:	e8 dc ff ff ff	callq  0x4004ed <g>

9	}
   0x0000000000400511 <+21>:	c9		leaveq 
   0x0000000000400512 <+22>:	c3		retq   

End of assembler dump.
(gdb)

前面两条指令跟 main 函数里的一样:建立 f 函数的栈帧,将 main 函数的帧首地址入栈,更新 %rbpf 函数的帧首地址

这时的 %rsp%rbp 均为 0x7fffffffd9d0,栈信息如图:

接下来两条指令,将 %rsp 减少 8(向下扩展 8 个字节),变为 0x7fffffffd9c8。接着, %edi 寄存器里的值被设置为相对于 %rbp (0x7fffffffd9d0)向下偏移 4 个字节的地址 0x7fffffffd9cc

继续执行 si 指令,让程序走完 call 指令:这个过程同 main 函数里的一样,准备调用参数,调用函数,压入返回地址。这时候寄存器 %rbp 为 0x7fffffffd9d0,%rsp 为 0x7fffffffd9c0

1
(gdb) si
g (x=32767) at father.c:2
2	{
(gdb) info registers rbp rsp
rbp            0x7fffffffd9d0	0x7fffffffd9d0
rsp            0x7fffffffd9c0	0x7fffffffd9c0

此时的调用栈:

(5)继续调用其他函数

接下来调用函数 g,和调用函数 f 一样,压入紧跟在 call 函数之后的指令地址,程序跳转到函数 g 起始处。这时,%rsp 保存的地址值为 0x7fffffffd9c0

函数 g 的汇编指令如下:

1
(gdb) disas /rm
Dump of assembler code for function g:
2	{
=> 0x00000000004004ed <+0>:	55		push   %rbp
   0x00000000004004ee <+1>:	48 89 e5	mov    %rsp,%rbp
   0x00000000004004f1 <+4>:	89 7d fc	mov    %edi,-0x4(%rbp)

3	    return x+5;
   0x00000000004004f4 <+7>:	8b 45 fc	mov    -0x4(%rbp),%eax
   0x00000000004004f7 <+10>:	83 c0 05	add    $0x5,%eax

4	}
   0x00000000004004fa <+13>:	5d		pop    %rbp
   0x00000000004004fb <+14>:	c3		retq   

End of assembler dump.
(gdb)

对于最前面两条指令,我们已经很熟悉了:建立 g 函数的栈帧,将 f 函数的帧首地址入栈,更新 %rbp 为 g 函数的帧首地址。接着一条指令把 %edi 寄存器里的值被设置为相对于 %rbp 向下偏移 4 个字节的地址 0x7fffffffd9b4。注意,这里并没有将 %rsp 继续向下扩展 8 个字节,因为 g 函数之后没有需要额外栈空间的操作了,只需做一个加法运算然后返回,这样,仅使用 %rax 寄存器就可以完成这个操作。因此,-0x4(%rbp) 这个四字节栈空间只用来临时存储一下 %edi里的值。编译器优化了指令.

接着两条指令将刚才存入到偏移位置的值存入 %rax 寄存器,加上 0x5 之后再赋值给 %rax 作为结果返回。

到目前为止,栈指针和帧指针的值指向同一位置:

1
(gdb) i registers rbp rsp
rbp            0x7fffffffd9b8	0x7fffffffd9b8
rsp            0x7fffffffd9b8	0x7fffffffd9b8
(gdb)

调用栈:

这时,用 x 指令查看内存信息:

1
(gdb) x/16x $rsp
0x7fffffffd9b8:	0xffffd9d0	0x00007fff	0x00400511	0x00000000
0x7fffffffd9c8:	0x00400400	0x0000000a	0xffffd9e0	0x00007fff
0x7fffffffd9d8:	0x00400521	0x00000000	0x00000000	0x00000000
0x7fffffffd9e8:	0xf7a35ec5	0x00007fff	0x00000000	0x00000000
(gdb)

以上显示的 16 个 4 bytes 内存地址指示的值,以十六进制显示。比较下,是否和上面的调用栈信息一致?

函数返回过程

函数调用对应着调用栈的建立,而函数返回则是进行调用栈的销毁,返回比调用过程简单多了。继续上面 g 函数的执行,g 函数还剩两条语句:

1
0x00000000004004fa <+13>:	5d		pop    %rbp
0x00000000004004fb <+14>:	c3		retq

可以看出,g 函数的返回过程中,第一条指令 pop %rbp 先把当前栈顶 %rsp 的值出栈并赋给 %rbp,这时候栈顶指针指向的是 f 函数的栈帧顶 0x7fffffffd9c0,里面存的就是继续执行 f 函数接下来执行语句的地址 0x400511

第二条指令 retq,它是 call 指令的逆操作,它将改变 ip 寄存器的值,值就是当前 %rsp 里存的值 0x400511,同时将 %rsp 的值加 8。

两条指令执行完之后的调用栈:

这时候程序就已经从 g 函数中返回,返回到 f 函数继续执行了。接下来的返回流程就比较类似了,不过注意到 f 函数有一个 leave 指令,那么这个指令有什么作用呢?

leave 指令相当于是每个函数开头两条指令的逆操作,等价于以下两条指令:

1
2
movq %rbp, %rsp
popq %rbp

这两条指令将 bp 和 sp 寄存器中的值还原为函数调用前的值。接着就是 ret 指令,与 g 函数过程一样的,就不再赘述了。然后返回到 main 函数,main 函数再返回,调用栈被销毁。

五、小结

由于 C 语言学艺不精,欲从基础学起,查资料,看书,看博客,从计算机底层基础学起,逐步了解 C 语言,学习 gdb 单步调试和反汇编工具,梳理了对函数调用的底层实现过程。

1、%rsp 保存的是栈顶的地址,栈顶所代表的就是当前栈,就是当前正在调用的函数。
2、%rbp 被称为基址指针寄存器(base pointer)或者帧指针(frame pointer),它保存着当前栈帧的基地址。
3、%rip 被称为指令地址寄存器(instruction pointer),用来记录 CPU 指令的位置。每次 CPU 执行都要先读取 %rip 寄存器的值,然后定位到 %rip 指向的内存地址,并且读取汇编指令,最后执行。读完之后,相应的 %rip 寄存器的值就会增加,增加大小就是读取指令的字节大小。
3、函数的调用地址的方向从高地址向低地址扩展的。
4、函数调用最多可以传送 6 个整数值(指针和整数)。
5、函数调用前如果需要传递参数,参数的压栈顺序是从右向左的。
6、栈中的「被保存的%rbp」保存的是调用者的栈帧首地址。
7、圧栈和出栈、call、leave、ret等指令完成的实际工作。
8、gdb 工具的学习使用。
9、函数的调用与返回过程。

栈这种简单的数据结构优雅地完成了支撑计算机程序执行的任务。引用自这里

六、参考

函数调用过程探究
函数调用
main函数和启动例程
gdb 使用
栈指针和帧指针
call stack
堆栈中的EIP EBP ESP
一段C语言和汇编的对应分析,揭示函数调用的本质