学习、分享

站在巨人的肩膀上

C语言的栈内存管理

程序运行的过程本质上是CPU与内存的共同作用,程序指令和数据被加载内存中,由 CPU 解释执行 。程序运行过程中需要使用内存来存储数据和程序状态信息,因此,必须对的内存进行严格地、精确地管理,才能使得程序高效稳定的运行。

概览

本文通过对C程序编译得到的汇编代码进行分析,来讨论C语言中的栈内存管理问题。 本文汇编语法采用 Intel 风格,编译成32位平台汇编(64位寄存器增多,会优先使用寄存器而不是栈,汇编代码会有少许区别)。为使例子尽量简洁,使用下面的的C代码作为实例。值得注意的是,这段代码并没有使用标准库,因此不需要使用头文件,是合法的C语言程序。

接下来我将主要从两个角度分析栈内存的管理过程,即调用者执行者,调用者(main)调用函数,执行者(sum)执行具体函数功能。

调用者

本例中调用者是 main 函数,它调用了 sum ,即 sum(1,2)。从内存上来看,这一行 C 代码需要将参数(1和2)入栈,调用完成后将栈空间还原到调用之前的状态。为了更好的理解代码,下面有些基础知识需要了解。

  • esp(stack pointer)永远指向栈的栈顶,即栈生长方向上的最新的位置
  • ebp(base pointer)指向栈基,作为栈的固定端

一个程序载入启动时,能够获得属于它的独有虚拟内存空间,通常这个值是4G,同时栈顶和栈基地址被初始化。因此,在程序载入时,其栈为空,即栈顶等于栈基,当分配栈空间时,栈向下生长,即 esp 随着栈长度的增大而减小,ebp 不变。

回归正题,main 中调用 sum(1,2) 的实际汇编代码如下。

我们可以看到,函数的参数入栈是倒序的,这只是编译器的具体实现而已,传入参数时倒序入栈,取出时就要顺序出站。实际上如果我们偏要颠倒他们也是可行的,只要保证前后的逻辑正确,但这个问题似乎经常成为一道考题,例如 sum(printf("a"), printf("b")) 的输出是什么样的?我们只能说在现在的编译器中,这段代码的输出是 ba, 因为参数从右到左入栈,但我们明白,它实际上只是编译器的一个共同的约定而已。

程序先将参数 1 和 2 入栈,然后调用 call, call 是一个汇编关键字,如果你对汇编有所了解,它实际上相当于一个 jmp 指令,即跳转到某处执行。

现在流行的操作系统都是冯·诺伊曼结构,即将程序代码和数据都装入内存,然后跳转到某处代码执行,子函数的调用过程也是如此,但调用函数不是简单的跳转执行,它还需要在执行完后回到调用处,也就是说需要保存调用代码的内存地址,并在执行完后,通过 jmp 回到调用处。因此,用汇编表达这个过程应该是这样的

在此过程中,caller_address 被暂存到栈中,然后跳转到子函数,汇编将跳转和存储调用地址简化为一条指令,即 call。当前程序将要执行的下一条指令的地址存放在 eip 寄存器,于是实际上, call = { push eip; jmp sub_fun_addr }, 其逆过程用 ret 指令简化,即 ret = { pop eip, jmp eip }。

我可能花了太多的篇幅来介绍 call 了,实际上,你只需要记得 call 调用时,存了一个地址到栈就可以了,而这个地址会在调用 ret 时弹出。让我们继续探索,如果你已经忘了我们的初心,那么我重新把它列出来。

参数入栈后,开始调用子函数,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 两个通用寄存器完成求和计算。

DWORD PTR [address] 是指从内存 address 处读取4字节,PTR 代表指针,DWORD 表示4字节(常用的长度单位还有 WORD 是 2字节,BYTE 是 1字节)。

ret 指令结束子程序,回到调用处,函数返回的值存储在 eax 中,这是一个通用规约,外部程序调用后从 eax 读取返回值。

如果让我评价这段代码,我会说有些冗余。引入的 ebp 仅仅是 esp 的一个拷贝,它的实际作用是定义一个局部的栈基,但是这段代码中,栈顶始终没有变化,因此它没有发挥到作用。但是多数情况下,函数内部会定义一些局部变量,即 esp 会发生改变,这时,ebp 的作用才会展现出来,我们后面再来讨论它。

程序的第一个参数在距离栈顶8个字节的空间中,因为在它后面,栈中又加入了 eip(call 时加入)和 ebp。因此,上面的代码不难理解,程序的第二个参数依次类推再向前找4个字节。

程序使用了两个通用寄存器来进行运算,add 指令的结果会存入第一个参数,即 eax 中,这是汇编的语法决定的。也许你也发现,这样的代码还不是最简的,edx 实际上可以不需要,只需要 add eax, [esp+0x8] 即可,确实,在这段程序中,edx 显得多余。但是多数程序中,读取的参数可能在多行代码中使用,下一次使用时,需要再次使用 [esp+0x8] 的方式读取,CPU 读取内存的速度明显比不上读取寄存器。因此,放入寄存器多数情况下是有用的,遗憾的是,本例中,它是冗余的。

sum 函数结束时,ebp 和 eip 会依次出栈,回到 main 函数后,栈顶指针上移,栈恢复之前的样子。

尽管一次函数调用涉及了很多的操作,甚至有一些看起来巧妙或笨拙的代码,但调用完成,这一切都会消失,就像是 CPU 一次奇特的旅程,它将继续在 main 函数执行后续的代码,对了,它收获了一个值,就在 eax 中!

函数的调用到这里就结束了,栈空间经历了从分配到回收,前后的操作是对称的、严谨的。但我们好像还有一些小问题没有讨论,比如局部变量,虽然文章有些长了,但我还是打算接下来讨论一下。

局部变量

这一部分可以作为一个补充,它使用和前面的例子相关的代码,但它也是完整的,读完后对 ebp 的作用可能会有更好的理解。

我们对之前的 sum 函数稍加修改,加上一个局部变量 s, 它保存 a+b 的和。

汇编代码如下:

由于加入了局部变量,程序需要为它分配栈空间,分配栈空间与回收使用了相同的做法,即修改栈顶指针,我们能够看到编译器选择为局部变量划分 16 字节的空间,即 sub esp, 0x10 。实际上,它只需要4字节空间,也只占用了前4字节的空间,可以看到 eax 的值被赋值给了 [ebp-0x4], 即局部变量 s 的地址。分配16字节是编译器的策略问题,不过它没有造成空间浪费,因为栈上的空间实际上占用的时间很短,同时也没多执行代码,我们暂且放过它。

leave 指令的作用是回收局部栈空间,它实际上执行了mov esp, ebppop ebp两条指令,其它的逻辑之前已经介绍过了。当然,由于局部变量 s 本来就是不需要的,使得汇编存在多余的代码,即 eax 的存入和读取,但这应该算是程序员的问题吧~

实际上,开启优化的编译器能够自动发现这些冗余,并自动优化。如果我们在编译选项中加入 -O1,编译出的汇编代码就简洁多了:

因此,通常,不必过于在意这些细微的问题,而把更多的精力投入逻辑思考。

总结

本文介绍了C语言中栈的内存管理,涉及了调用者、被调用者以及局部变量的栈布局,它们的合作最终使得栈的分配和回收高效、精确、完整。

本文有用的tip:

  • 汇编中对栈的分配和回收是通过对栈顶指针 esp 的加减实现的
  • C 语言中函数的参数从右到左入栈
  • call={ push eip; jmp sub_func_addr }, ret={ pop eip }, leave={ mov esp, ebp; pop ebp } 用于还原栈顶
  • 编译器的编译结果并不是最优的,多数情况不是,因此,汇编比C高效
  • 编译器能够对部分代码进行优化,不必太在意细节

完。

× 如转载请注明出处,谢谢 ×

点赞
  1. successli说道:

    私认为,文章写的极好,甚赞

    1. chiyiw说道:

      谢谢!

发表评论

电子邮件地址不会被公开。 必填项已用*标注