搞C/C++的朋友,早晚会在某个深夜面对这样一个灵魂拷问:局部变量到底存在哪里?为什么函数一返回,里面的变量就“没了”?如果你只用“栈上分配、栈上释放”这种答案应付过去,那离真正理解计算机运行机制还差着一大截。
我当年第一次在gdb里用info registers看到rsp和rbp跳来跳去的时候,才真正明白什么叫“函数栈帧的创建和销毁”。这东西不是考试应付一下就行的冷门考点,它是理解局部变量生命周期、栈溢出崩溃、调试器backtrace工作原理、甚至安全攻防的基础。这篇就把函数栈帧这件事从头到尾拆开讲透,用实际反汇编和调试记录说话,看完你再去读那些讲缓冲区溢出的文章,会轻松非常多。
1. 栈帧到底是个什么东西
1.1 先搞清楚“栈”和“堆”的区别
很多初学者分不清“栈”和“堆”,典型表现就是背概念:“局部变量在栈上,动态分配在堆上”。概念没错,但没建立起画面感。
你想象一块连续的内存,从高地址向低地址增长,这块区域就是程序运行时的调用栈(call stack)。每次调用一个函数,系统就在当前栈顶划分出一块区域,专门给这个函数用,存它的局部变量、参数、返回地址等。这块区域就是“函数栈帧”(stack frame)。
栈和堆最大的区别在于生命周期和分配方式:
- 栈的分配和释放是自动的,函数调用时“划地”,函数返回时“回收”,不需要你手动管理。
- 栈是从高地址往低地址长,堆是从低地址往高地址长,两个方向正好相反,中间空着的地方供两者自由生长。
- 栈空间有限,一般几MB级别,堆空间大得多,所以大数组、递归过深往往会栈溢出,而
malloc大块内存通常不会立刻崩。
你不需要关心操作系统具体怎么管理物理页,只要记住一点:函数栈帧就是函数在栈上的“私人办公区”,函数一旦返回,这块办公区就“作废”了,里面的数据逻辑上不存在了,但物理上内存里的值还在,只是没人管了。这也是为什么返回局部变量的指针是危险的——指针指向的地址还在,但内容随时可能被下一个函数调用覆盖。
1.2 关键寄存器:rsp、rbp和指令指针
在x86-64体系下,有三个寄存器跟栈帧息息相关:
rsp(栈指针寄存器):始终指向当前栈顶,也就是“下一个可用位置”。所有栈操作(push、pop、call、ret)都会改动它。rbp(基址指针寄存器,也叫帧指针):指向当前函数栈帧的底部,用于方便地访问局部变量和参数。rip(指令指针寄存器):指向当前正在执行的指令地址,函数调用和返回本质上是改变它的值。
32位时代对应的是esp和ebp,原理完全一样,只是寄存器宽度不同。网上很多老的博客讲的是32位的ebp,你在64位机器上看反汇编,看到的可能是rbp,其他逻辑差不多。
这里有个容易绕晕的点:栈向下增长,所以“栈底”其实是高地址,“栈顶”是低地址。push操作会让rsp减小,pop操作会让rsp增大。画栈图的时候,建议把地址从下往上递增画——也就是栈底在高地址在上方,栈顶在低地址在下方,看反汇编代码时对着这个图就不会乱。
注意:现代编译器在开优化的情况下经常会省略
rbp,直接用rsp加偏移来访问局部变量,这种叫帧指针省略(-fomit-frame-pointer)。但不影响理解原理,反而更能看出栈帧的本质——它只是一个“约定”,不是硬件强制的。后面我会专门讲这个。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 函数调用之前,调用者都干了些什么
2.1 参数传递:寄存器优先,栈上兜底
写一个最简单的函数:
c复制int add(int a, int b)
{
int c = a + b;
return c;
}
然后gcc -S生成汇编(不开优化),你会看到主调方调用add之前做了这些事:
assembly复制movl $3, %esi
movl $2, %edi
call add
就三句。edi和esi是x86-64 SysV调用约定里传递前两个整数参数的寄存器。如果你的函数参数超过6个(整数/指针类型),第7个及以后的参数才会压到栈上传递。
这块有个值得展开的细节:
- 参数从右往左压栈是cdecl调用约定的习惯,但是x86-64下多数参数走寄存器,所以压栈顺序只在参数多的时候才体现出来。
- 浮点参数走
xmm0到xmm7,和整数参数分开。 - Windows x64调用约定和System V不一样,参数传递用的寄存器也不同,跨平台写汇编或者逆向分析时得先搞清楚目标平台的约定,否则看反汇编会一头雾水。
call add这一条指令其实是两步操作的合体:
- 把返回地址(也就是
call指令的下一条指令的地址)压入栈中。 - 把
rip改为add函数的入口地址,程序跳转过去执行。
所以进入add函数那一刻,栈顶(rsp指向的位置)压着的就是返回地址,占8个字节(64位下)。
2.2 call指令做了哪些不显眼的事
很多人不理解为什么call和ret能自动配对。其实call的本质就是“先存好回来的路,再跳走”,ret的本质就是“按着存好的路走回来”。
具体流程:
code复制call add 等价于:
sub rsp, 8 ; 栈顶向下挪8字节,腾位置
mov [rsp], 返回地址 ; 把返回地址写入栈顶
jmp add ; 跳转到add函数入口
返回地
