资讯动态

函数栈帧深度解析:从调用约定到缓冲区溢出排查

发布时间:2026/10/20 22:02:56 来源:尧图企业网站定制
上个月排查一个偶发崩溃崩在一个看起来人畜无害的字符串处理函数里。用调试器打开 core 文件bt给的调用栈只有两帧第三帧直接显示?? ??一瞬间脑子就空白了。后来才发现根本不是逻辑写错而是某个函数过早的ret破坏了栈导致恢复到上一层时找不到返回地址。这件事让我重新把函数栈帧从头捋了一遍——如果你也想真正理解调试器看到的那些反汇编、调用栈和崩溃现场这篇文章就是为你准备的。我会从最基础的栈帧布局讲起一直讲到优化参数、调试器原理和缓冲区溢出这些实际排查里躲不开的话题。1. 从一次崩溃说起调用栈是怎么被记录的先说那次崩溃本身。程序跑在 x86-64 Linux 上崩溃位置在第三方库的字符串函数里。正常来说bt应该能告诉我是哪个函数调用进来的但那次只显示被调函数那一帧上一层直接是?? ??。我盯着屏幕想了半天最后把反汇编打开才明白问题出在栈帧上。所谓“调用栈”本质上就是栈帧串起来的一条链。每次函数调用发生时CPU 都会把当前指令的下一跳地址也就是返回地址压到栈里被调函数再把自己栈帧的基址压栈、建立新帧。等到函数返回时再从栈里弹出返回地址继续执行调用处之后的指令。1.1 栈回溯的第一性原理返回地址压栈很多人初学汇编时都有个疑问函数调用完怎么知道该回哪去答案就是call指令。call做了两件事把rip下一条指令地址压栈然后跳转到目标函数入口。ret则相反从栈顶弹出一个值到rip于是程序又回到调用点的下一条指令。这样一来栈上天然保存着每一层调用者的返回地址。而函数内部的局部变量、临时数据要想不和这些返回地址混在一起就得划分出独立的“区域”——这就是栈帧的由来。可以这么理解调用栈是一条纵深的走廊每个函数占据走廊里的一段段与段之间靠返回地址和帧指针分隔。1.2 一个最小 C 函数的反汇编观察先看一个最简单的例子体会一下栈帧是怎么被“搭”起来的。int add_two(int a, int b) { int c a b; return c; }用gcc -O0 -fno-omit-frame-pointer编译后核心反汇编差不多长这样add_two: push rbp ; 保存调用者的帧指针 mov rbp, rsp ; 建立新帧rbp 指向当前栈顶 sub rsp, 16 ; 给局部变量让出空间 mov DWORD PTR [rbp-4], edi ; a 存入栈帧 mov DWORD PTR [rbp-8], esi ; b 存入栈帧 mov edx, DWORD PTR [rbp-4] mov eax, DWORD PTR [rbp-8] add eax, edx mov DWORD PTR [rbp-12], eax ; c 存入栈帧 mov eax, DWORD PTR [rbp-12] leave ; mov rsp, rbp; pop rbp ret第一眼可能会觉得奇怪x86-64 下前两个整数参数明明是通过edi、esi传进来的为什么汇编里还要把它们写到[rbp-4]、[rbp-8]因为编译器为了让调试器能在任何指令位置查看参数和局部变量选择把寄存器参数“存回”栈帧。这也是栈帧的重要作用之一给调试器提供一个稳定、可查找的数据存放区寄存器内容在函数执行过程中随时会被改写栈内存不会。2. 栈帧的标准布局参数、返回地址、局部变量的相对位置理解了“压返回地址 建新帧”之后栈帧内部的布局就有规律可循了。下面这张表是我在 x86-64 下运行add_two到sub rsp, 16之后的内存布局地址从高到低排列。这张表值得记在脑子里因为排查崩溃时绝大多数手工分析都建立在它上面。地址区间内容访问方式高地址调用者栈区调用者自身局部数据—[rbp16]调用者压栈的第 7 个及以后的参数或调用者临时数据[rbp16]起[rbp8]返回地址由call压入[rbp8][rbp]保存的旧rbp调用者帧指针[rbp][rbp-4]参数a的保存副本[rbp-4][rbp-8]参数b的保存副本[rbp-8][rbp-12]局部变量c[rbp-12][rsp]栈顶当前函数临时区[rsp]2.1 基址指针和栈指针各管一头rbp是基址指针frame base pointerrsp是栈指针。栈帧的本质就是rbp和rsp之间这块“围起来”的区域。rbp是“固定参考点”局部变量和参数都用rbp加偏移量访问rsp则随着函数内的压栈、出栈不断变化指向当前栈顶。为什么要固定rbp因为rsp太“活”了。函数里随时可能再有新的push、pop、或者调用别的函数栈顶一变化如果所有局部变量都用rsp的相对偏移表示那每一次栈顶变化都得重新计算偏移编译器会疯掉。rbp把基准点锁死偏移量在整个函数内都不变。2.2 函数调用全过程的栈变化把主调函数、被调函数的动作串起来流程就很清晰了主调函数把超出寄存器容量的参数从右往左压栈x86-32 更是全部压栈。call压入返回地址CPU 跳入被调函数。被调函数push rbp保存主调函数的帧指针mov rbp, rsp把当前栈顶作为新帧底部。sub rsp, N为局部变量和临时区预留空间。执行函数体期间通过rbp 偏移访问参数rbp - 偏移访问局部变量。准备返回把返回值写入eax。leavemov rsp, rbp把栈指针拉回帧底pop rbp恢复主调函数的帧指针。ret弹出返回地址到rip回到主调函数。理解这个流程有个立竿见影的好处看到一个崩溃现场立刻就知道是不是rbp或返回地址被破坏了。比如leave; ret执行时如果[rbp]存的是垃圾值那pop rbp后rbp就乱了ret也会跳到完全不相干的地址——这往往就是栈溢出的结果后面第 5 章详细说。3. 调用约定才是栈帧的“宪法”光知道栈帧长什么样还不够还得知道“谁来打扫战场”。不同平台、不同编译器默认的调用约定不同栈帧的构建和销毁方式也跟着变。我见过不少跨平台项目Windows 上好好的代码移植到 Linux 就崩其中有一部分就是调用约定不一致导致的。3.1 常见调用约定对比下面这张表整理了 4 种最常见的函数调用约定。这里的核心差异其实是三个问题参数放哪参数按什么顺序放谁来清理栈调用约定参数传递方式参数清理责任方典型场景cdecl32位全部压栈从右往左调用者add esp, NC/C 默认x86-32stdcall32位全部压栈从右往左被调者ret NWin32 APIfastcall32位前两个参数用ecx/edx其余压栈被调者Windows 某些系统调用System V AMD6464位前 6 个参数放rdi/rsi/rdx/rcx/r8/r9第 7 个起压栈调用者Linux/macOS 64位的 C/C 默认以 32 位下int add(int a, int b)为例; cdecl 下主调函数这样调用 push b push a call add add esp, 8 ; 调用者负责清理 ; stdcall 下调用一样但是被调函数内部用 ret 8 ; 被调函数自己清理如果代码里声明了一个函数是stdcall实际定义却是cdecl或者反过来就会出现经典毛病要么栈不平衡要么返回时rsp指错位置最终在ret之后直接跑飞。这种问题在反汇编里非常显眼——你会在函数末尾看到ret 8或者看到主调函数在call之后跟了一条多余的add esp, N那就要警惕约定是不是对不上。3.2 x86-64 下栈帧的“变与不变”到了 x86-64因为寄存器数量充足前 6 个参数不再压栈但这绝不意味着栈帧消失。编译器仍然可能把寄存器参数“溢出”spill到栈上就像add_two例子中那样局部变量也照样占栈空间。更微妙的是System V AMD64 还定义了一个红区Red Zonersp往下 128 字节的区域在当前函数内可以放心使用不会被信号处理器或异步事件覆盖但call之后的被调函数会用到所以一般只在叶函数里用。红区的实际意义是某些简单函数可以完全不用sub rsp直接访问[rsp-8]、[rsp-16]这些低地址区域。这在反汇编里看着很反直觉明明栈顶没动却“凭空”出现了局部变量。有一次我在照着一个老教程调试时看到函数开头没调整rsp就直接mov DWORD PTR [rsp-8], edi误以为栈被破坏了查了半天。后来才意识到这其实就是红区的正常用法——反汇编里见到低于rsp的栈访问先别急着怀疑看看是不是红区。Windows x64 上的情况又不太一样它规定了 Home Space前 4 个参数在栈上预留固定位置又是另一套逻辑。所以如果你在跨平台项目里调试一定先确认当前平台的 ABI别用一套栈帧模型套到底。4. 帧指针省略FPO与优化后的栈帧很多人在 Debug 版下跑得好好的一开优化就发现调试信息“失真”了。这里最常见的原因就是帧指针省略FPOFrame Pointer Omission。gcc 的-fomit-frame-pointerMSVC 的/Oy都会把“标准 prologue 搭建”这步砍掉。4.1 优化到底省了什么未优化版本里函数入口有三条指令push rbp mov rbp, rsp sub rsp, 16而 FPO 开启后变成sub rsp, 24push rbp; mov rbp, rsp直接被删掉局部变量改用rsp加偏移量访问。好处很直观少了两条指令rbp这个通用寄存器也被腾出来了。代价则是栈帧失去了“指针链”——调试器没法通过遍历rbp链来恢复调用栈了。4.2 没有帧指针调试器凭什么回溯那 Release 版的bt是怎么工作的答案是依赖调试信息里的 CFI调用帧信息在 ELF 文件中通常存在.eh_frame或.debug_frame节。CFI 记录了每个指令地址处如何从堆栈或寄存器恢复出调用者的rbp、rsp、rip。调试器正是靠这些信息一步步“逆向”还原出调用链。这也是为什么 Release 二进制如果被strip过或者某些汇编函数没有生成 CFIbt就会断链——出现开头我遇到的那种?? ??。我自己总结的排查经验是对需要长期维护、崩溃频发的模块编译时尽量保留-fno-omit-frame-pointer。虽然会有一点点性能开销每函数多两条指令但换来的是在任何符号丢失、CFI 损坏的情况下还能靠帧指针链硬回溯这个稳定性价值远大于那点 CPU 开销。4.3 一个被优化坑掉真实崩溃栈的排查片段那次排查让我印象很深。函数process_record内部调用了一个第三方库接口崩溃发生在接口内部。Debug 版一拿走就能定位Release 版只要开 FPObt输出就变成(gdb) bt #0 0x00007ffff7a9e4c0 in __memmove_avx_unaligned_erms () from libc.so.6 #1 0x00007ffff7a58384 in ?? () from libexample.so #2 0x0000000000000000 in ?? ()那一瞬间你会觉得无从下手。后来怎么解决的把libexample.so用没有 FPO 的选项重新编译一个专门用于复现的版本再跑同样的输入立刻就看到完整调用链了(gdb) bt #0 __memmove_avx_unaligned_erms () #1 process_record (input0x7ffc0011, len4096) at process.c:78 #2 parse_stream () at parser.c:210 #3 main () at main.c:45两相对照就很清楚了崩溃点在memmove调用者是process_record而且process_record是从parse_stream里去的。这个分析不靠任何魔法就是“多给调试器一条路”——用帧指针链。5. 栈帧与缓冲区溢出返回地址为什么容易被改写栈帧知识不只是做题或调试时用的它对安全分析和稳定性排查也是核心。缓冲区溢出之所以危险本质上就是写越界的数据碰巧覆盖了栈帧里的关键字段。最常见的受害者是局部变量、保存的rbp再往下就是返回地址。5.1 一个直观的越界覆盖路径看一段很有年代感但极经典的示例void vuln() { char buf[16]; gets(buf); // 可越界输入 }x86-64 下未优化时的栈帧布局从高地址到低地址大致是位置内容高地址返回地址[rbp]保存的旧rbp[rbp-16]到[rbp-1]buf[16]低地址可能的填充区如果输入超过 16 字节首先改写的是buf末尾之后紧挨的内存——正好是保存的rbp再继续写就轮到返回地址。调试时最典型的“症状”是函数体内运行一切正常但一执行leave; ret就崩rip跳到0x4141414141414141这种“A”的 ASCII 填充值。看到0x41...这种十六进制基本可以断定返回地址被攻击/越界覆盖了。5.2 编译器怎么拦截canary 与 /GS现代编译器不会坐视不管。gcc 的-fstack-protector、MSVC 的/GS会在局部变量和保存的rbp之间放一个随机数叫canary哨兵值。函数返回前检查 canary 是否被改写一旦发现异常立即终止程序而不是让崩溃发生在不可控的地方。canary 的值通常从线程局部存储里取每个进程启动时随机化。之所以放在[rbp-8]这类夹在局部变量和rbp之间的位置目的就是要让越界写入“先碰到 canary 再碰到返回地址”。这样攻击者或 bug 就无法在不惊动检查的情况下直接改写返回地址。千万不要因为有了 canary 就放松警惕。编译时如果局部数组和指针在同一函数里且数组索引未经过严格边界检查仍然可能通过其他路径比如先覆盖指针再通过指针写任意地址绕过保护。栈帧分析在这些场景里同样管用——它让你知道数据从哪写到哪哪些字节可能碰坏关键字段。5.3 崩溃现场最值得看的几个寄存器真遇到疑似栈破坏的崩溃我会依次确认三件事rip的值是否落在合法代码区域模块的.text范围或系统库范围内。若是一个奇怪的地址如0x41414141、0xdeadbeef基本就是栈/堆被写烂了。rsp和rbp是否仍然指向同一片可读的内存区域。若两者差距极大或者x/gx $rsp看栈顶内容全是垃圾字符串大概率是栈帧被破坏。栈上是否残留着可读的函数名字符串。因为栈顶附近可能正好保存着被覆盖的局部变量里面如果是一串可见 ASCII能帮我们反推出是谁写坏了栈。有一次排查我看到bt里帧间地址连续但顺序错乱再用x/20gx $rsp一看栈上全是同一个字符串的重复填充立刻判断是某个strcpy把目标缓冲区写穿了。后来在源码里 grep 那个字符串一次就定位到了问题行。这就是栈帧分析在实战里最朴素也最有效的一次使用。6. 在 gdb 中真正“看见”栈帧常用命令与手算偏移前面几章解释了原理这一章直接上手说说我在 gdb 里怎么把栈帧“解剖”开。很多人用 gdb 只会bt和frame真遇到调用链断裂或想确认某个局部变量的物理位置时就不知道怎么办了。其实把栈帧模型带入 gdb问题就迎刃而解。6.1 每个命令到底看的是栈帧的哪部分下面是我最常用的一组命令以及它们对应的栈帧语义gdb 命令等价于查看栈帧的哪部分bt/backtrace遍历所有栈帧列出调用链frame N切换到第 N 层栈帧info args读取当前帧的参数区可能在寄存器、保存区或栈上info locals读取当前帧的局部变量区p $rbpp $rsp看当前帧的上下边界x/8gx $rsp从栈顶向低地址或向高地址取决于方向看原始内存x/4gx $rbp看当前帧的保存rbp、返回地址和调用者数据举个例子一个典型的bt输出长这样(gdb) bt #0 vuln () at vuln.c:12 #1 0x00005555555551f0 in main () at main.c:18 #2 0x00007ffff7dd70b3 in __libc_start_main () from /lib/x86_64-linux-gnu/libc.so.6 #3 0x00005555555550de in _start ()这串信息的含义是当前正在vuln内执行最内层vuln的返回地址是0x00005555555551f0它属于mainmain的返回地址在 libc 的__libc_start_main里。这条链的每一环都对应栈上一个真实的[rbp8]位置保存的返回地址。6.2 手算一段真实栈帧的偏移如果你想验证一下自己的理解可以在 gdb 里这样操作(gdb) p $rbp $1 (void *) 0x7fffffffe3a0 (gdb) x/gx $rbp 0x7fffffffe3a0: 0x00007fffffffe3c0 ; 保存的旧 rbp (gdb) x/gx $rbp8 0x7fffffffe3a8: 0x00005555555551f0 ; 返回地址即 main 中的下一条指令看到[rbp]是上一个帧的rbp[rbp8]是返回地址整个栈帧就“通了”。如果这里存的值不是可读代码地址而是0x41414141...那你已经知道发生了什么——栈帧的灵魂被冲掉了。6.3 判断“栈是否健康”的经验标准我把这些年排查崩溃时总结的判断标准写在这里遇到类似问题时可以直接对照rbp指向的地址和rsp指向的地址应该都在当前线程栈范围内info proc mappings或/proc/pid/maps里能看到线程栈的起止地址。若超出范围栈帧通常已被破坏。x/gx $rbp8取出的返回地址应该落在某个已加载模块的可执行区域内。可以用info symbol 0x...反查若提示无符号至少也要落在一个合理的模块范围内。bt各帧地址应该严格递减往低地址走如果出现回溯地址忽然“往上跳”的情况说明某层栈帧的保存数据被覆盖了这时候我通常直接去查x/Ngx $rsp找找栈里残留的字符串或明显标记。我个人现在调试的时候已经习惯性开着 gdb 的 TUI 模式一边看反汇编一边单步。但比工具更重要的是脑子里的那张栈帧布局图知道call压了什么知道push rbp之后返回地址在哪知道sub rsp是给谁腾地方。遇到再乱的崩溃现场只要栈上还有一两个可读的地址就能顺着这张图把真相挖出来。框架可以升级指令集可以变化但“栈帧记录函数调用历史”这件事几十年了都没变过。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑