ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

函数栈帧详解:从内存四分区到函数调用全过程

函数栈帧详解:从内存四分区到函数调用全过程 1. 程序跑起来以后内存到底是怎么分的1.1 内存四分区为什么偏偏栈最容易翻车很多人学C语言学到指针、数组、函数之后就卡住了觉得自己代码写对了编译器也不报错可程序一跑就出奇怪的问题函数返回的字符串打印出来是一堆乱码、递归几次就“爆栈”、局部变量莫名被篡改。这些问题几乎都指向同一个根源——没搞懂函数调用时内存是怎么分配的。程序编译成一个可执行文件之后在操作系统里运行起来虚拟内存地址空间通常分成几个区域。不同教材叫法略有差别但本质是同一个东西我习惯这么划分内存区主要存放内容分配方式生命周期代码段Text编译后的机器指令编译期确定程序运行期间一直存在数据段Data/BSS全局变量、静态变量编译期确定程序运行期间一直存在堆区Heapmalloc/new 动态分配的内存运行时手动申请、释放由程序员控制栈区Stack函数参数、局部变量、返回地址函数调用时自动分配函数返回时自动释放你问一个C程序“这段数据存哪”大方向就是这四个地方。刚开始入门的同学觉得堆和栈长得差不多反正都是运行时的内存但两者的分配机制差别太大了。堆是程序员手动要、手动还你说“帮我申请100个字节”malloc就去堆里找一块给你什么时候还由你说了算不还就内存泄漏。栈不用你申请也不用你释放函数一调一返系统自动给你安排好。这里必须提一个关键概念栈是一种后进先出LIFO的数据结构。就像食堂里一摞餐盘你拿走的永远是最上面那个洗好的盘子也永远摞在最上面。函数调用的嵌套关系跟这个完全一样——main调用func1func1调用func2func2执行完先返回func1再返回最后才回到main。后调用的先返回天然就是栈的行为。提示这句话值得多看两遍。理解了“函数调用天然是后进先出”这句话就能理解为什么局部变量分配在栈上、为什么函数返回后局部变量就失效、为什么递归能工作、为什么递归太深会崩。整个栈帧机制就是这句话的展开。1.2 栈区是怎么被分配出去的一次一段“临时工位”如果说堆是“零散租房”想租几平米、租多久你自己跟中介谈那栈更像是“工位”每次进入一个函数系统就给你临时分配一小段空间供你放参数、放局部变量、记录“我该回哪去”。这个函数退出工位直接回收下一层函数接着用同一片区域。注意这句话栈的工位是复用的。你调用func函数时在栈上分配的局部变量区域func返回之后就作废了等下一层函数调用进来它可能会占用同一块物理内存地址。这也是为什么“返回局部变量地址”是C语言里最经典的错误——那块地址确实还在内存也没被清零但里面内容可能被下面任何一层函数覆盖。栈区不像堆那样需要你主动malloc它由CPU里的两个寄存器配合维护RSP栈指针寄存器始终指向栈顶。分配局部变量就是让RSP往下挪一段距离释放就是让RSP挪回来。用一个寄存器变量维护整个栈的位置。RBP栈基址寄存器指向当前函数的栈帧底部。用来定位参数和局部变量的参照物编译器用RBP加偏移量来访问每个局部变量。栈指针和基址寄存器就像一个账本的“当前行”和“章节起始行”。函数一进来新开一章记录起始行函数一退出账本回到上一章这一整章的记录全部作废。后面我会把整个过程一步步拆开看。2. 什么是函数栈帧为什么每个函数都有自己的“隔间”2.1 栈帧的本质一次函数调用专属的“工作台”函数栈帧Stack Frame也叫活动记录Activation Record就是一次函数调用在栈上占用的一段连续内存区域。每个正在执行、还没返回的函数栈上都有属于它自己的一个小隔间。想象一下流水线上的工位每个工人都有一块自己的操作台台子上放着图纸参数、工具局部变量、一张便签写着“做完这批货回哪个工位报到”返回地址。工人A开工时铺开自己的台子干到一半让工人B帮忙工人B在自己的台子上干活干完收拾台子走人工人A接着在自己的台子上继续。这样互不干扰非常清晰。栈帧这个概念解决了几个核心问题参数怎么传递调用者把实参放到特定位置被调用者从约定好的位置取。局部变量放哪函数内部声明的变量就放在当前栈帧的一段空间里用偏移量访问。怎么回去调用结束时程序得知道返回到哪里调用前的状态是什么。嵌套调用怎么不乱每一层函数都有独立栈帧互不干扰哪怕递归调用自己也不例外。没有这套机制函数之间传递数据、嵌套调用、递归全都无从谈起。C语言的函数机制能正常工作完全依赖栈帧这条隐形生产线。2.2 栈帧里到底装了什么逐一拆解经典的一个函数栈帧通常包含以下几块内容从高地址到低地址排列调用者传入的实参如果参数不多在64位环境下优先用寄存器传参参数多了或者为方便统一访问会压到栈上。返回地址调用者用了CALL指令跳进函数时CALL指令自动把下一条指令的地址压入栈。这样函数执行完RETCPU就知道该回到哪继续跑。调用者的RBP值进入函数后先把旧RBP压栈保存退出时恢复保证返回后调用者能找到自己的栈帧。当前函数的局部变量RSP向下移动一段距离这块空间就是局部变量区。编译器通过RBP减偏移量或RSP加偏移量来访问它们。要保存的寄存器值如果函数里用了某些寄存器且调用者还在用、希望在函数返回后值不被改那就得临时压栈保存返回前恢复。打个通俗的比方返回地址是“我来时的路”旧RBP是“上一个工位的坐标”局部变量是“我这道工序的物料”保存的寄存器是“借了工具用完要归还原位”。2.3 x86-32和x86-64的差异别混着看很多教材和网课讲的栈帧是基于x86-32模型也就是所有参数都压栈通过RBP加偏移量访问。现在大家用的基本都是x86-64环境差异要清楚x86-64下前6个整数或指针参数用寄存器传递依次是RDI、RSI、RDX、RCX、R8、R9不需要全压栈。只有参数多于6个时多的部分才放到栈上。局部变量访问方式更灵活编译器在O0优化下会建立栈帧使用RBP访问在O2等优化下很可能会省略RBP直接用RSP加偏移量访问变量。栈上会有对齐要求x86-64要求函数调用时栈按16字节对齐编译器会在栈帧里留出填充字节。不管细节怎么变核心思想一致一次函数调用就在栈上划一片区域用来放参数、返回地址、局部变量和寄存器现场。3. 一次函数调用的完整过程把每一步都放慢看3.1 一个简单的例子跟着汇编走一遍光说不练假把式。来看一个最简单的函数调用程序int add(int a, int b) { int sum a b; return sum; } int main(void) { int x 3; int y 4; int result add(x, y); return 0; }如果你用gcc默认编译并关掉优化生成的汇编代码里能清楚地看到栈帧的完整创建和销毁过程。整个过程大概五步第一步调用者压栈参数。main函数准备调用add之前先把参数准备好。x86-64下编译器的做法是movl $3, -4(%rbp) ; x 3 movl $4, -8(%rbp) ; y 4 movl -8(%rbp), %edx ; 把 y 放入 edx movl -4(%rbp), %eax ; 把 x 放入 eax movl %edx, %esi ; 第二个参数放入 esi movl %eax, %edi ; 第一个参数放入 edi call add参数放在寄存器里进函数前准备好。第二步CALL指令自动压入返回地址。call add这条指令干了两件事把call下一条指令的地址压栈这就是返回地址然后跳转到add函数的起始地址。这一步由硬件自动完成不需要编译器显式写push指令。第三步被调用者建立栈帧序言。add函数第一段代码称为“序言”pushq %rbp ; 保存调用者的栈基址 movq %rsp, %rbp ; 设置当前栈基址 subq $16, %rsp ; 为自己的局部变量分配空间先保存旧RBP再把RSP的值赋给RBP这样当前函数就有了自己的栈帧基准地址。然后RSP向下移动16字节注意对齐要求这块空间就是存放局部变量用的。第四步函数主体执行。movl %edi, -4(%rbp) ; 把参数a存到局部变量区 movl %esi, -8(%rbp) ; 把参数b存到局部变量区 movl -4(%rbp), %edx movl -8(%rbp), %eax addl %edx, %eax ; a b参数是从寄存器里取出来的即使原本在寄存器为了统一管理和调试方便编译器也会先把它们保存到当前栈帧的局部变量区然后再计算。第五步销毁栈帧恢复现场尾声。popq %rbp ; 恢复调用者的栈基址 ret ; 弹出返回地址跳回去add把计算结果放在EAX寄存器函数返回值默认的传递通道然后popq %rbp把旧RBP恢复出来此时RSP自动上移到返回地址处ret指令把返回地址弹出并跳转回main。这一套流程干净利落进函数时把现场存好出函数时把现场恢复中间的所有局部变量全部作废。3.2 我画的那些示意图为什么实际布局可能不一样我知道你肯定在各种教程里看过栈帧布局图比如“参数在栈顶、返回地址在中间、局部变量在下”。这种图没错但它画的是一个典型情况实际布局受编译器优化、CPU架构、调用约定影响很大。比如在x86-32传统模式下参数确实压栈传递栈帧从上到下通常是参数区 → 返回地址 → 旧RBP → 局部变量区。但在x86-64寄存器传参模式下参数一开始并不在栈上是函数自己把它们保存到局部变量区的。所以有时候你看代码时觉得“怎么变量地址跟我想的不一样”不要慌。栈帧的精确布局是编译器决定的不是C语言标准规定的。C标准只规定局部变量的生命周期、作用域等语义并不强制规定它在栈上怎么排、哪个地址高哪个地址低。3.3 递归为什么能工作答案就在栈帧隔离上理解了栈帧递归就特别好解释了。每次调用自己就是在栈上又分配了一个全新的、独立的栈帧里面的局部变量跟上一层函数栈帧里的变量虽然同名但物理内存地址完全不同。int factorial(int n) { if (n 1) return 1; return n * factorial(n - 1); }factorial(4)调用factorial(3)是在栈上又压入一个新栈帧这个新栈帧里n3跟上一层n4毫无关系。等最底层的factorial(1)返回一个接一个销毁栈帧逐层把结果传上来。但递归的代价也在这里每一层递归都要消耗栈空间。如果递归深度太大栈区内存被消耗完就会触发栈溢出Stack Overflow程序直接崩溃。默认栈大小通常只有8MB左右具体看系统所以无限制的深层递归非常危险。4. 动手打印地址实测验证栈的关键特征4.1 验证栈的生长方向从大地址到小地址理论知识再多不如真机跑一遍。第一个实验验证栈的生长方向。写一个小程序#include stdio.h void inner(int *addr_from_outer) { int local; printf(外层变量地址: %p\n, addr_from_outer); printf(内层变量地址: %p\n, local); if (addr_from_outer local) { printf(内层地址更小栈向下生长\n); } else { printf(内层地址更大栈向上生长\n); } } int main(void) { int outer; inner(outer); return 0; }在我机器上运行结果类似这样外层变量地址: 0x7ffd8a4f2a4c 内层变量地址: 0x7ffd8a4f2a1c外层main函数的变量地址比内层inner函数的变量地址大差了大概0x3048字节说明调用越深分配的栈地址越小。这就验证了x86-64栈向下生长地址从高往低用每调用一层函数就往低地址方向压。这个实验建议所有人都跑一遍比背十遍“栈向下生长”都管用。而且你还能观察到48字节这个差它告诉我们inner的整个栈帧大约占了48字节的区域包括旧RBP、返回地址、局部变量和可能的对齐填充。4.2 验证局部变量的地址连续声明不一定地址相邻第二个实验更有意思在同一个函数里连续声明多个变量看看它们的地址布局#include stdio.h int main(void) { int a 1; int b 2; int c 3; char ch x; int arr[3] {10, 20, 30}; printf(a %p\n, a); printf(b %p\n, b); printf(c %p\n, c); printf(ch %p\n, ch); printf(arr %p\n, arr); printf(arr[0] %p\n, arr[0]); printf(arr[1] %p\n, arr[1]); printf(arr[2] %p\n, arr[2]); return 0; }很多新手以为a、b、c三个int一定按声明顺序紧挨着排实际跑起来大概率不是。我这边的输出长这样a 0x7ffc6c4d3a5c b 0x7ffc6c4d3a58 c 0x7ffc6c4d3a54 ch 0x7ffc6c4d3a53 arr 0x7ffc6c4d3a3ca、b、c是递减排列的每个差4字节这是符合直觉的。但你把arr算进去就会发现arr的地址是0x...3c跟ch的0x...53差了23字节远大于ch本身的1字节。编译器会考虑对齐、寄存器分配、优化策略自行安排变量位置并不存在“声明顺序等于内存顺序”的保证。所以有个吃了不少亏的经验永远不要依赖局部变量的相对地址关系来写代码。这种代码换个编译器版本、换个优化级别行为就可能完全变掉。4.3 验证返回地址往栈帧里看一眼想亲眼看看返回地址可以通过一个技巧在函数里打印“栈上越过局部变量区域再往上”的内容。下面这段代码用gcc的__builtin_return_address比较方便这也是平时调试栈回溯很有用的内置函数#include stdio.h void func(void) { void *ret __builtin_return_address(0); printf(返回地址: %p\n, ret); } int main(void) { printf(main 函数地址: %p\n, main); func(); return 0; }__builtin_return_address(0)返回当前函数返回地址也就是调用者下一条指令的地址。编译运行你会发现这个地址跟main函数地址比较接近因为它确实落在main的代码区域内——这正是从栈上取回的“我来时的路”。提示这个内置函数是GCC和Clang都支持的在栈回溯、嵌入式异常处理、调试工具里非常常见。但注意它是编译器相关的不是C标准库功能跨编译器不可移植。5. 三大经典坑踩过一次就再也不想踩第二次5.1 坑一返回局部变量的地址这段代码是经典中的经典#include stdio.h int *get_value(void) { int local 42; return local; } int main(void) { int *p get_value(); printf(%d\n, *p); return 0; }虽然打印42可能成功但这是未定义行为。局部变量local分配在get_value的栈帧里函数返回后栈帧销毁回收但你手里还攥着那块地址。此刻那块内存是什么状态——什么状态都有可能。它可能还存着42可能被下一层函数调用覆盖可能被中断处理程序改写不能做任何可靠假设。正确做法用static int local 42;让变量生命周期延长到程序结束。用malloc在堆上分配返回堆地址注意用完free。把地址作为参数传入函数比如void get_value(int *out) { *out 42; }。我的原则很简单函数永远不要返回指向自己局部变量的指针。这是区分新手和熟练工的一条分水岭。5.2 坑二递归太深瞬间栈溢出递归确实优雅但千万别高估栈的容量。写一个无终止条件的递归#include stdio.h void boom(void) { char buf[1024]; printf(stack...\n); boom(); } int main(void) { boom(); return 0; }每层递归先在栈上分配1KB的buf再压入返回地址和旧RBP。假设默认栈大小8MB大概几千层就能把栈耗尽程序以分段错误Segmentation Fault结束。就算有终止条件深层递归也要小心。例如递归深度100万次的遍历算法在默认栈大小下很危险。这种情况下建议把递归改为显式栈的循环实现。增大线程栈大小比如在Linux下用ulimit -s查看和调整。用尾递归优化但C编译器不一定帮你优化别依赖。重新设计算法用迭代替代递归。判断“这个递归能不能用”有个简单估算单层栈帧占用约等于参数局部变量返回地址对齐用栈总大小除以单层占用就知道极限深度。比如单层1KB8MB栈就是8192层超过这个数就随时可能崩。5.3 坑三缓冲区溢出栈帧被越界覆盖C语言的缓冲区溢出是安全领域的著名问题它的根源也跟栈帧布局密切相关#include string.h #include stdio.h void vulnerable(void) { char buf[16]; gets(buf); // 经典危险函数不检查长度 printf(buf %s\n, buf); } int main(void) { vulnerable(); return 0; }gets把输入写到buf里如果输入超过16字节数据就往上越界写。而根据栈帧布局buf的高地址方向正好是旧RBP和返回地址。超出的输入覆盖了它们函数返回时就会跳到非法地址运气好程序崩溃运气不好正好被精心构造的输入控制程序流程——这就是栈溢出攻击的由来。现代系统有很多缓解手段比如栈保护值stack canary、地址随机化ASLR、禁止栈上执行代码NX但根本解决方法是写出不越界的代码使用fgets、snprintf等带长度限制的安全函数。在写数组前手动检查索引范围。开启编译器的加固选项gcc可以加-fstack-protector-strong -D_FORTIFY_SOURCE2。注意缓冲区溢出不是只有在黑客攻防里才需要关心。平时写业务代码数据越界写数组、字符串拼接不检查长度都会悄悄破坏相邻栈帧造成难以排查的“灵异Bug”——比如局部变量值莫名改变、返回地址损坏导致程序跑飞。多数神秘崩溃根源都在这。6. 用GDB亲眼看看栈帧再给你几条一线经验6.1 GDB里那几个最实用的栈帧命令如果你真的想彻底搞懂栈帧别光靠看博客打开GDB亲自看一下。拿之前的add函数程序编译时加-ggcc -g -o add add.c gdb ./add在GDB里执行几个关键命令break add在add函数入口设置断点。run运行程序。bt或者backtrace查看当前函数调用栈它会列出从main到当前函数的所有栈帧。frame 0/frame 1切换到指定栈帧。info frame显示当前栈帧的详细信息包括栈帧地址、返回地址、保存的寄存器等。x/20x $rsp查看栈顶附近20个内存单元的内容你能直接看到局部变量的十六进制表示。info locals查看当前函数所有局部变量的值。info args查看当前函数参数。我强烈建议你在断点处执行一次info frame它会直接显示类似这样的信息Stack frame at 0x7fffffffe2d0: rip 0x400514 in add (add.c:3); saved rip 0x400548 called by frame at 0x7fffffffe300 Arglist at 0x7fffffffe2c0, args: a3, b4 Locals at 0x7fffffffe2b0, Previous frames sp is 0x7fffffffe2d0“saved rip”就是返回地址会告诉你add执行完要回哪里“called by frame”指出调用者栈帧的位置。这一条命令抵得上十张示意图。6.2 长时间调试C语言我沉淀的几个重要习惯第一个习惯不要忽略编译警告尤其是-Wall -Wextra。很多栈相关的问题编译器其实有能力给出警告线索。比如返回局部变量地址加上警告选项后gcc通常能识别并给出提示。把警告当成错误来看很多坑根本不用踩。第二个习惯区分“能跑”和“对了”。C语言里大量栈相关Bug是概率性的比如返回局部变量地址的例子可能在你机器上跑100次都正常换个环境立刻崩。这种程序不是“能用”只是“还没出问题”。学习阶段要养成用-fsanitizeaddress编译的习惯它能在运行时帮你检测栈缓冲区溢出、越界访问、释放后使用等一类问题实测能抓出大批隐藏Bug。第三个习惯养成查看反汇编的习惯。遇到栈帧相关的奇怪问题直接objdump -d或者gdb里disassemble看汇编。我看过无数新手卡在“为什么变量地址跟我预期不一样”上一查汇编编译器安排得明明白白只不过跟你脑补的布局不一样。汇编和栈帧是不可分割的一对学会读基本汇编C语言的理解深度直接上一个台阶。第四个习惯做实验验证而不是背结论。栈向下生长、栈帧包含返回地址、局部变量生命周期等于函数执行期——这些结论你都可以亲手打印地址、用GDB查看验证。验证过一遍的知识才是真正长在身上的知识遇到怪问题的时候才拿得出来用。我在实际工作和带新人的过程中反复看到同一个现象对栈帧理解得越扎实的人写C代码越稳出问题后判断越准。很多让人挠头的内存Bug最后追根溯源都是“栈帧布局没想清楚”这五个字。花一个下午把栈帧搞透往后的调试时间能省回几周。这份功夫绝对值得下。
返回列表