程序运行的过程本质上是CPU与内存的共同作用,程序指令和数据被加载内存中,由 CPU 解释执行 。程序运行过程中需要使用内存来存储数据和程序状态信息,因此,必须对的内存进行严格地、精确地管理,才能使得程序高效稳定的运行。
概览
本文通过对C程序编译得到的汇编代码进行分析,来讨论C语言中的栈内存管理问题。 本文汇编语法采用 Intel 风格,编译成32位平台汇编(64位寄存器增多,会优先使用寄存器而不是栈,汇编代码会有少许区别)。为使例子尽量简洁,使用下面的的C代码作为实例。值得注意的是,这段代码并没有使用标准库,因此不需要使用头文件,是合法的C语言程序。
1 2 3 4 5 6 7 8 9 |
int sum(int a, int b) { return a + b; } int main() { int s = sum(1, 2); return 0; } |
接下来我将主要从两个角度分析栈内存的管理过程,即调用者与执行者,调用者(main)调用函数,执行者(sum)执行具体函数功能。
调用者
本例中调用者是 main 函数,它调用了 sum ,即 sum(1,2)
。从内存上来看,这一行 C 代码需要将参数(1和2)入栈,调用完成后将栈空间还原到调用之前的状态。为了更好的理解代码,下面有些基础知识需要了解。
- esp(stack pointer)永远指向栈的栈顶,即栈生长方向上的最新的位置
- ebp(base pointer)指向栈基,作为栈的固定端
一个程序载入启动时,能够获得属于它的独有虚拟内存空间,通常这个值是4G,同时栈顶和栈基地址被初始化。因此,在程序载入时,其栈为空,即栈顶等于栈基,当分配栈空间时,栈向下生长,即 esp 随着栈长度的增大而减小,ebp 不变。
回归正题,main 中调用 sum(1,2) 的实际汇编代码如下。
1 2 3 4 5 6 7 |
;; sum(1, 2); push 0x2 push 0x1 call 0xffe07c76<sum> add esp, 0x8 |
我们可以看到,函数的参数入栈是倒序的,这只是编译器的具体实现而已,传入参数时倒序入栈,取出时就要顺序出站。实际上如果我们偏要颠倒他们也是可行的,只要保证前后的逻辑正确,但这个问题似乎经常成为一道考题,例如 sum(printf("a"), printf("b"))
的输出是什么样的?我们只能说在现在的编译器中,这段代码的输出是 ba
, 因为参数从右到左入栈,但我们明白,它实际上只是编译器的一个共同的约定而已。
程序先将参数 1 和 2 入栈,然后调用 call, call 是一个汇编关键字,如果你对汇编有所了解,它实际上相当于一个 jmp 指令,即跳转到某处执行。
现在流行的操作系统都是冯·诺伊曼结构,即将程序代码和数据都装入内存,然后跳转到某处代码执行,子函数的调用过程也是如此,但调用函数不是简单的跳转执行,它还需要在执行完后回到调用处,也就是说需要保存调用代码的内存地址,并在执行完后,通过 jmp 回到调用处。因此,用汇编表达这个过程应该是这样的
1 2 3 4 5 6 7 8 |
push caller_address to stack jmp sub_function sub_function: ;; do some code pop call_address to reg jmp call_address |
在此过程中,caller_address 被暂存到栈中,然后跳转到子函数,汇编将跳转和存储调用地址简化为一条指令,即 call。当前程序将要执行的下一条指令的地址存放在 eip 寄存器,于是实际上, call = { push eip; jmp sub_fun_addr }, 其逆过程用 ret 指令简化,即 ret = { pop eip, jmp eip }。
我可能花了太多的篇幅来介绍 call 了,实际上,你只需要记得 call 调用时,存了一个地址到栈就可以了,而这个地址会在调用 ret 时弹出。让我们继续探索,如果你已经忘了我们的初心,那么我重新把它列出来。
1 2 3 4 5 6 7 |
;; sum(1, 2); push 0x2 push 0x1 call 0xffe07c76<sum> add esp, 0x8 |
参数入栈后,开始调用子函数,call 后面的0xffe07c76
就是 sum 函数的地址,为了方便程序员调试,编译器把这个函数的名字也打印了出来,但它实际上只是一个便于观察的符号,没有具体作用。我们在这一行调用了 sum 函数,之后 CPU 已经不在此处执行,直到它完成了 sum 函数的工作,这里面的内容我会在本文的后面一节来介绍。
我们假设 sum 函数已经执行完毕了,它不再需要了,程序必须清理使用过的栈空间,以保证栈空间不会被耗尽,栈空间的回收并不需要真正的置零,而是修改栈顶指针,即 esp+8。
在汇编中,地址和空间的基本单位都是字节,例如,C语言的 int 类型占用4个字节,CPU 的寄存器 eax,esp等也占用4字节,以e
开头的寄存器都是4个字节,它实际上是32位二进制表示的整数,范围是 0~2^32-1, 但是汇编中更习惯于使用16进制来表示数,即 esp 的表示范围是 0~0xffffffff。而r
开头的寄存器都是8字节,它们通常在64位程序中出现,这些只是为了说明为什么 esp 被增加了8, 是因为之前我们入栈了两个 int 类型的整数。
调用者的内存管理到此就结束了,也许你会思考,sum 函数的返回值去哪儿了?实际上它被保存在 eax 中,后面我会介绍它。
执行者
本例中的执行者是 sum 函数,它要做的是接收两个参数,求和,返回结果。在此过程中,它使用 eax 和 edx 两个通用寄存器完成求和计算。
1 2 3 4 5 6 7 8 9 10 11 12 |
;; int sum(int a, int b) { ;; return s; ;; } push ebp mov ebp, esp mov edx, DWORD PTR [ebp+0x8] ; 读取栈中的参数 1 mov eax, DWORD PTR [ebp+0xc] ; 读取栈中的参数 2 add eax, edx ; 1 + 2 pop ebp ret |
DWORD PTR [address] 是指从内存 address 处读取4字节,PTR 代表指针,DWORD 表示4字节(常用的长度单位还有 WORD 是 2字节,BYTE 是 1字节)。
ret 指令结束子程序,回到调用处,函数返回的值存储在 eax 中,这是一个通用规约,外部程序调用后从 eax 读取返回值。
如果让我评价这段代码,我会说有些冗余。引入的 ebp 仅仅是 esp 的一个拷贝,它的实际作用是定义一个局部的栈基,但是这段代码中,栈顶始终没有变化,因此它没有发挥到作用。但是多数情况下,函数内部会定义一些局部变量,即 esp 会发生改变,这时,ebp 的作用才会展现出来,我们后面再来讨论它。
程序的第一个参数在距离栈顶8个字节的空间中,因为在它后面,栈中又加入了 eip(call 时加入)和 ebp。因此,上面的代码不难理解,程序的第二个参数依次类推再向前找4个字节。
1 2 3 4 5 6 7 8 |
栈内存布局 | address | value | | ------- | ----- | | ebp+0xc | 2 | | ebp+0x8 | 1 | | ebp+0x4 | *eip | | ebp | *ebp | |
程序使用了两个通用寄存器来进行运算,add 指令的结果会存入第一个参数,即 eax 中,这是汇编的语法决定的。也许你也发现,这样的代码还不是最简的,edx 实际上可以不需要,只需要 add eax, [esp+0x8] 即可,确实,在这段程序中,edx 显得多余。但是多数程序中,读取的参数可能在多行代码中使用,下一次使用时,需要再次使用 [esp+0x8] 的方式读取,CPU 读取内存的速度明显比不上读取寄存器。因此,放入寄存器多数情况下是有用的,遗憾的是,本例中,它是冗余的。
sum 函数结束时,ebp 和 eip 会依次出栈,回到 main 函数后,栈顶指针上移,栈恢复之前的样子。
尽管一次函数调用涉及了很多的操作,甚至有一些看起来巧妙或笨拙的代码,但调用完成,这一切都会消失,就像是 CPU 一次奇特的旅程,它将继续在 main 函数执行后续的代码,对了,它收获了一个值,就在 eax 中!
函数的调用到这里就结束了,栈空间经历了从分配到回收,前后的操作是对称的、严谨的。但我们好像还有一些小问题没有讨论,比如局部变量,虽然文章有些长了,但我还是打算接下来讨论一下。
局部变量
这一部分可以作为一个补充,它使用和前面的例子相关的代码,但它也是完整的,读完后对 ebp 的作用可能会有更好的理解。
我们对之前的 sum 函数稍加修改,加上一个局部变量 s, 它保存 a+b 的和。
1 2 3 4 5 |
int sum(int a, int b) { int s = a + b; return s; } |
汇编代码如下:
1 2 3 4 5 6 7 8 9 10 11 |
push ebp mov ebp, esp sub esp, 0x10 ; 申请局部栈空间 mov edx, DWORD PTR [ebp+0x8] mov eax, DWORD PTR [ebp+0xc] add eax, edx mov DWORD PTR [ebp-0x4], eax ; 将和存入局部变量 mov eax, DWORD PTR [ebp-0x4] leave ; 释放局部栈空间,还原栈基址 ret |
由于加入了局部变量,程序需要为它分配栈空间,分配栈空间与回收使用了相同的做法,即修改栈顶指针,我们能够看到编译器选择为局部变量划分 16 字节的空间,即 sub esp, 0x10
。实际上,它只需要4字节空间,也只占用了前4字节的空间,可以看到 eax 的值被赋值给了 [ebp-0x4], 即局部变量 s 的地址。分配16字节是编译器的策略问题,不过它没有造成空间浪费,因为栈上的空间实际上占用的时间很短,同时也没多执行代码,我们暂且放过它。
leave 指令的作用是回收局部栈空间,它实际上执行了mov esp, ebp
和pop ebp
两条指令,其它的逻辑之前已经介绍过了。当然,由于局部变量 s 本来就是不需要的,使得汇编存在多余的代码,即 eax 的存入和读取,但这应该算是程序员的问题吧~
实际上,开启优化的编译器能够自动发现这些冗余,并自动优化。如果我们在编译选项中加入 -O1
,编译出的汇编代码就简洁多了:
1 2 3 4 5 6 7 8 |
add: mov eax, DWORD PTR [esp+8] add eax, DWORD PTR [esp+4] ret main: mov eax, 0 ret |
因此,通常,不必过于在意这些细微的问题,而把更多的精力投入逻辑思考。
总结
本文介绍了C语言中栈的内存管理,涉及了调用者、被调用者以及局部变量的栈布局,它们的合作最终使得栈的分配和回收高效、精确、完整。
本文有用的tip:
- 汇编中对栈的分配和回收是通过对栈顶指针 esp 的加减实现的
- C 语言中函数的参数从右到左入栈
- call={ push eip; jmp sub_func_addr }, ret={ pop eip }, leave={ mov esp, ebp; pop ebp } 用于还原栈顶
- 编译器的编译结果并不是最优的,多数情况不是,因此,汇编比C高效
- 编译器能够对部分代码进行优化,不必太在意细节
完。
× 如转载请注明出处,谢谢 ×
私认为,文章写的极好,甚赞
谢谢!