资讯详情

资讯详情

函数序言与尾声生成:编译器后端的细节与陷阱

大概半年前我在调试一个莫名其妙挂在返回地址上的崩溃。函数开头明明有push rbp、mov rbp, rsp这类代码栈回溯却指向了完全错误的调用者。排查到最后问题出在编译器后端一个负责生成函数入口和出口代码的模块——那个模块少给某个 nonvolatile 寄存器生成保存和恢复指令。这件事之后我把add_procedure_prologues这个功能从头梳理了一遍发现很多坑都在细节里值得单独写一篇。如果你在写编译器后端、做二进制分析或者只是好奇函数汇编入口那一堆指令为什么长这样这篇文章应该能帮你理清思路。本文要讲的就是如何为一个函数调用生成正确的序言prologue和尾声epilogue代码以及实现过程中那些文档里不会写的东西。1. 序言尾声到底在干什么从一次寄存器污染事故说起很多人第一次看汇编时会问为什么函数入口总要先push rbp、mov rbp, rsp返回前又原封不动地pop rbp这不浪费吗要回答这个问题得先理解调用者caller和被调用者callee之间的“契约”。一个函数被调用后它会使用栈空间、临时寄存器还会改变程序计数器。调用者调用它之前可能正在用r12保存循环计数可能已经在栈上存放了一些临时值。如果被调用者肆无忌惮地覆盖r12或者往栈指针下方写数据返回后调用者的计算就被破坏了。序言和尾声就是用来“隔离”这种破坏的序言在被调者入口处保存现场、划分私有栈空间尾声在返回前恢复现场、销毁栈帧然后把控制权交还给调用者。1.1 从调用者视角看一次函数调用中发生了什么假设有一个调用者函数准备调用另一个函数callee(int x)。在 x86-64 的视角下调用者的call指令做了两件事把下一条指令的地址压入栈顶然后跳转到callee的入口。所以被调者一进来栈顶就是返回地址。接下来被调者需要保存那些调用者希望保持不变、但被调者打算使用的寄存器给局部变量、溢出临时值分配一段栈空间如果需要还要把当前函数的这一帧记录下来供调试器和异常处理程序做栈回溯。尾声则相反释放栈空间恢复寄存器让栈指针回到返回地址的位置然后ret弹出返回地址继续执行。寄存器分为两类调用者保存寄存器caller-saved又称 volatile和被调用者保存寄存器callee-saved又称 nonvolatile。调用者保存寄存器允许被调者随意修改修改后由调用者自己负责恢复被调者保存寄存器则要求被调者在返回时必须保持原值如果内部要借用就必须先保存再恢复。这个区分是调用约定calling convention的核心内容。add_procedure_prologues要做的第一件事就是根据调用约定判断哪些寄存器必须进保存清单。1.2 没有正确序言尾声的典型症状缺了序言或尾声最直接的后果是程序行为不可预测。常见症状有局部变量互相覆盖。函数 A 在栈上放了local1调用函数 B 时B 认为自己可以从栈指针往下随意写局部变量。如果 B 不分配自己的栈帧直接把数据写到 A 的local1区域A 返回后读到的就是被改过的值。返回后寄存器值错乱。循环里调用了一个会修改rbx的函数如果该函数不保存rbx循环计数就会变成垃圾值程序会陷入死循环或越界。栈回溯断裂。调试器看不到调用链backtrace只显示当前函数。原因往往是rbp没有被按约定压栈或者保存顺序不对导致帧指针链断开。ret跳飞到莫名地址。栈指针没有在返回前准确指向返回地址ret就把错误数据当成了地址跳走。这种问题最难查因为崩溃现场往往离真正的 bug 很远。这些症状我都实际遇到过而且大部分并不是逻辑错误而是“少了一条指令”或“多了一条指令”造成的。2. 写 add_procedure_prologues 前必须拍板的三个设计决策在动手写代码之前有三个设计决策会影响 Genesis 整个后端的代码质量。我之前也见过有人图省事固定给每个函数都生成“通用序言”结果不是浪费性能就是产生错误栈帧。2.1 调用约定哪些寄存器必须由被调用者保存调用约定不是编译器后端可以随便定死的它要和系统 ABI 对齐否则编译出来的函数无法和库函数互操作。实现序言之前必须把目标平台的 callee-saved 寄存器清单弄准确。以常见桌面平台为例寄存器通常分成两组类别常见例子序言是否需要保存调用者保存临时寄存器、参数传递寄存器不需要改动自己负责恢复被调用者保存跨调用保留寄存器、帧指针、返回地址寄存器需要压栈保存尾声恢复这张表格很粗糙不同平台差别很大。比如 ARM64 的x19~x28是 callee-savedx30链接寄存器保存返回地址RISC-V 的s0~s11是 callee-savedx86-64 的rbx、rbp、r12~r15都需要保存。一个更隐蔽的问题是某些平台还有额外的条件状态标志寄存器、向量寄存器它们是否归 callee-saved必须查 ABI 文档不能想当然。我的建议是把一整套“寄存器机器描述”写好。不要手写一堆规则判断“如果寄存器名字是 r12 就保存”而是统一维护三个集合参数寄存器、调用者保存寄存器、被调用者保存寄存器。后面寄存器分配器会告诉你哪些被实际修改序言只需遍历“被调者保存且被修改”的寄存器即可。2.2 帧指针一个影响调试和性能的选择帧指针frame pointer专门用来指向当前栈帧的起始位置。经典 x86 序言push rbp; mov rbp, rsp就是在建立帧指针。有了帧指针调试器可以通过链式指针一路回溯调用栈编译器里做栈回溯、异常展开也简单很多。但帧指针的代价也很直接占用一个宝贵的通用寄存器序言和尾声都多几条指令现代编译器的寄存器分配器会少一个可用的物理寄存器。所以很多优化等级较高的编译会启用“省略帧指针”omit frame pointer。此时函数只用sp加偏移量来寻址栈帧调试信息则需要通过更复杂的方式记录 CFA当前帧地址。add_procedure_prologues的设计中必须允许按编译选项动态决定是否生成帧指针。我的做法是调试模式默认生成帧指针性能优化模式默认省略。但省略帧指针会带来一个问题——某些依赖帧指针回溯的老调试器会失效所以还要确保调试信息或 CFI 指令足够完善。2.3 栈对齐十六字节规则背后的 CPU 性能陷阱栈对齐看起来是“洁癖”实际上和 CPU 的访存性能强相关。很多 ABI 要求在执行call指令之前栈指针按 16 字节对齐。例如 x86-64 的 System V ABI 规定函数入口处rsp % 16 8是因为调用者执行call前rsp对齐到 16而call会压入 8 字节返回地址进入被调者后自然变成 8。于是被调者分配栈帧时不能只考虑局部变量大小。假设函数需要 16 字节局部变量后面还要调用其他函数那么序言至少要让rsp回到 16 字节对齐。入口时rsp余 8要减去一个 N满足(rsp - N) % 16 0且 N 必须大于等于局部变量和保存寄存器占用的总空间。最小 N 是 24因为 24 mod 16 等于 8入口减 24 后刚好对齐。这就是为什么很多函数会分配出比实际需要更大的栈帧多出来的就是对齐填充padding。实现时千万别在“每个分支”单独做对齐判断。最佳做法是在栈帧大小计算阶段统一处理totalSize alignTo(savedRegsSize localSpaceSize, 16) - savedRegsSize这种公式很容易算错。我的习惯是先计算保存寄存器后的栈偏移再算对齐 pad最后让所有下游代码都使用这个统一结果。3. 核心实现在函数入口插入序言、在返回点插入尾声现在到了最关键的部分如何在机器指令层插入序言和尾声。我们假设已经有一个类似于中间代码的后端流程函数已经完成了指令选择、寄存器分配当前正在生成最终的机器指令。这个阶段通常叫目标指令调整或汇编流输出。3.1 输入信息栈帧大小、保存寄存器清单、是否需要帧指针要生成序言尾声需要以下几项输入保存寄存器清单来自寄存器分配器它算出了函数里实际使用了哪些被调用者保存寄存器。如果函数从头到尾没碰rbx就没必要保存它。栈帧大小包括局部变量、溢出槽、临时槽的大小。如果使用帧指针优化可能还要加上一个0偏移量。是否需要帧指针由编译选项决定。如果函数里涉及动态栈分配比如alloca强制需要帧指针否则无法恢复栈指针。是否有子函数调用影响对齐填充也影响叶函数是否可以简化。栈探测参数某些平台对超大栈帧有额外要求这会在后面小节展开。我之前见过一个很典型的 bug不管函数是否需要寄存器分配器把某个寄存器从清单里删了但序言生成代码仍然按旧的固定集合保存。结果运行到函数中间时寄存器被覆盖函数结束时却弹出没保存过的脏数据。所以务必把保存清单作为唯一真相从寄存器分配器一路传到序言生成器。3.2 序言的指令顺序为什么不能乱序言的顺序并非随意。经典的 x86-64 序言顺序是保存帧指针如果使用push rbp建立帧指针mov rbp, rsp按顺序保存 callee-saved 寄存器push rbx,push r12……分配局部空间sub rsp, frame_size。为什么先push rbp再push rbx因为栈回溯时帧指针链依赖于这样的顺序栈顶附近依次是返回地址、旧rbp。后面保存的其他寄存器都位于rbp下方地址更低。调试器从rbp读取调用者的rbp形成链表。如果把rbx保存放在rbp保存之前链表顺序就乱了回溯会失败。还有一个容易被忽略的细节分配局部空间和保存寄存器的先后。如果先sub rsp再push rbx那么局部变量区的起始地址就变得不好计算。更常规的做法是先压 pc 有关的内容再一次性 sub。但有些 ABI 为了栈回溯方便会规定先 sub 再保存寄存器。因此生成器需要按目标的约定顺序来不要照搬别的平台。一个简化的 C 风格伪代码void emitPrologue(const MachineFunction mf, CodeEmitter out) { bool useFP mf.hasFramePointer(); Register fp target.getFramePointer(); Register sp target.getStackPointer(); if (useFP) { out.push(fp); out.mov(fp, sp); } for (Register reg : mf.calleeSavedRegs) { out.push(reg); } if (mf.stackFrameSize 0) { out.sub(sp, mf.stackFrameSize); } }注意这里我把保存寄存器放到了“建立帧指针”之后如果同时有 FP 且没有实际局部空间这个顺序是常见做法。如果你使用的目标是 AArch64通常会先stp x29, x30, [sp, #-16]!再mov x29, sp这是一次性保存两个寄存器并分配空间最后再分配其余帧空间。尾声自然也要按相反顺序void emitEpilogue(const MachineFunction mf, CodeEmitter out) { Register sp target.getStackPointer(); if (mf.stackFrameSize 0) { out.add(sp, mf.stackFrameSize); } for (auto it mf.calleeSavedRegs.rbegin(); it ! mf.calleeSavedRegs.rend(); it) { out.pop(*it); } if (mf.hasFramePointer()) { Register fp target.getFramePointer(); // 使用 pop 恢复旧帧指针 out.pop(fp); } out.ret(); }如果整个序言尾声恰好是push rbp; mov rbp, rsp; pop rbp; ret这种形状某些目标还可以用leave指令缩写。但这类优化最好放到指令选择器的“模式匹配”阶段做不要塞进序言生成器里否则会让控制流分析变得复杂。3.3 处理多条返回路径统一尾声基本块一个函数可能有多个return点。如果每个return前都完整地生成一份尾声指令代码体积会变大而且在优化时容易产生重复的 CFI 信息。更干净的做法是在函数中建立一个统一尾声基本块。把所有ret之前的指令替换成一个跳到该基本块的指令尾声块位于函数末尾包含恢复现场和ret。这样全函数只有一份尾声修改尾声逻辑时不需要到处同步后续优化可以对该块做合并、调整。但要注意如果函数里有栈帧指针跳转到统一尾声块时sp的值必须一致。否则就会出现“不同返回路径带着不同栈指针跳到一个尾声”的严重问题。因此凡是函数中需要动态调整sp的操作比如alloca或在内部进行多次栈切换不能直接跳到统一块而要先恢复sp。我实现时会把“恢复栈指针到标准位置”的代码也放到统一块之前所有路径必须经过修复 reg 的副本。4. 主流指令集架构下的序言尾声变体不同指令集差异巨大。同一个功能模块要做跨平台移植必须把栈帧和保存机制抽象出来。我这里拿三种常见架构做对比。4.1 x86-64push/pop 和 leave/ret 的简史x86-64 的栈操作指令丰富push、pop可以灵活压栈/弹栈。带帧指针的典型序言push rbp mov rbp, rsp push rbx push r12 sub rsp, 0x20尾声add rsp, 0x20 pop r12 pop rbx pop rbp ret也可以用leave指令缩写leave等价于mov rsp, rbp; pop rbp但要注意它必须搭配帧指针使用。x86-64 的返回地址在栈顶ret从栈顶弹出。实现时我会提醒自己不要在 x86-64 上用“先 sub 再保存”的统一模板。因为push是 8 字节对齐操作会影响后续栈指针偏移量计算。我见过一个后端为了少写几条指令在同一个函数里既用push又直接用rsp访问局部变量结果rsp变化后局部变量位置全错。调试这种问题特别费时间。4.2 ARM64stp/ldp 和链接寄存器ARM64 没有push/pop指令它使用stpstore pair/ldpload pair加上前后变更操作来批量保存寄存器。返回地址保存在x30链接寄存器LR中而不是压栈。所以函数需要调用其他函数时必须保存 LR如果是个叶函数且不需要 LR就可以不保存。典型的 ARM64 带帧指针序言stp x29, x30, [sp, #-0x20]! mov x29, sp stp x19, x20, [sp, #0x10]第一行stp x29, x30, [sp, #-0x20]!的含义是先把sp减 0x20再往新栈顶地址同时保存x29和x30。注意它一次就完成了“分配空间 保存帧指针 保存返回地址”三件事这比 x86 简洁很多。尾声则对应ldp x19, x20, [sp, #0x10] ldp x29, x30, [sp], #0x20 retARM64 的栈对齐要求同样是 16 字节并且 SP 必须始终对齐。编译器在处理stp指令的偏移时往往需要保证保存对的总大小是 16 的倍数。另外ARM64 的调试信息要求 CFAcanonical frame address通常通过sp或者fp来定义。add_procedure_prologues在这个目标上要同时输出.cfi_def_cfa_offset和相应的.cfi_offset否则栈回溯就是断的。4.3 RISC-Vsp 加减与压缩指令扩展RISC-V 的返回地址在ra寄存器帧指针一般用s0也叫fp。它的序言风格类似 ARM64但指令更精简addi sp, sp, -0x20 sd ra, 0x18(sp) sd s0, 0x10(sp) addi s0, sp, 0x20返回前ld ra, 0x18(sp) ld s0, 0x10(sp) addi sp, sp, 0x20 ret如果启用了 RVC压缩指令扩展还有专门的c.push、c.pop指令可以一次性保存多个寄存器并分配/释放栈帧。例如c.push {ra, s0, s1}会根据扩展规则自动生成合适的栈调整和保存序列非常适合代码大小敏感的场景。RISC-V 的 ABI 里s0和fp是同一个寄存器但如果没有使用帧指针s0可以当普通 callee-saved 寄存器用。我的建议是在一个 target 的描述文件里把“帧指针寄存器”和“第一个 callee-saved 寄存器”分开建模否则优化模式切换时会很难处理。下表给出三种架构的序言尾声关键差异维度x86-64ARM64RISC-V返回地址位置栈顶call 压入x30 寄存器ra 寄存器批量保存机制push/popstp/ldp成对sd/ld、c.push/c.pop帧指针寄存器rbpx29s0/fp栈对齐要求16 字节16 字节16 字节叶函数是否需要保存返回地址不需要ret 直接用栈中的地址不需要但 ra 被覆盖才要保存不需要但 ra 被覆盖才要保存5. 容易被忽略的边界情况序言尾声的常规路径实现后真正让人头疼的是各种边界情况。这些情况在测试里不常暴露但生产环境一跑就崩。5.1 叶函数可以省略一半的代码叶函数leaf function是指不调用其他函数的函数。由于它不会修改返回地址寄存器也不需要为子调用做栈对齐准备所以可以省掉很多序言尾声如果不需要保存任何 callee-saved 寄存器也不需要局部栈空间那么序言和尾声可以完全为空函数体直接执行ret或用jmp返回。如果叶函数恰好要使用“返回地址寄存器”来临时存值那就又变成必须保存该寄存器不能无脑省。在 x86-64 上叶函数入口的栈顶已经是返回地址所以如果函数内部不修改栈指针尾声直接ret即可。在 ARM64/RISC-V 上如果叶函数要使用x30/ra必须先把它保存否则返回地址就丢了。我在做某个模拟项目时就是因为漏了这个细节导致一个纯计算函数在特定情况下把ra覆盖成垃圾值程序跳进黑洞。5.2 大栈帧与栈探测如果一个函数的栈帧非常大例如超过了一个内存页大小常见的 4KB在 Windows 内核模式或某些安全环境下需要生成“栈探测”stack probing代码。原因是现代操作系统通常按需给栈分配页如果一次性修改sp跨越多个页而中间某些页面尚未被映射第一次访问时就可能触发缺页异常。为了避免这种情况序言需要逐个页“触碰”栈底地址强制系统按顺序分配物理页。栈探测的做法一般是用循环或者一系列test指令来确保每个页都访问过。一个简化的 x86-64 探测序言cmp rsp, probe_zone_base jl call_stack_probe或者直接生成多个test [rsp - offset], eax的段。这部分逻辑如果做错最典型的症状是“大数组分配后马上崩溃”或“栈溢出异常不可恢复”。在实现add_procedure_prologues时我一开始偷懒没做探测直到跑一个递归深度很大的测试程序才被系统教做人。5.3 调试信息和异常展开CFI 不是可选项好多人以为序言尾声生成完就大功告成了其实还差最后一步和栈相关的调试信息必须同步正确。无论调试器要查看局部变量还是 C 异常处理要展开栈都需要知道“当前指令位置对应的栈偏移”以及“被恢复寄存器相对于当前栈的位置”。以 DWARF CFI 为例序言中每一条修改rsp或保存寄存器的指令都要用.cfi_def_cfa_offset、.cfi_offset等伪指令记录状态变化。例如.cfi_startproc push rbp .cfi_def_cfa_offset 16 .cfi_offset rbp, -16 mov rbp, rsp .cfi_def_cfa_register rbp sub rsp, 0x20 .cfi_def_cfa_offset 32如果在实现时跳过这些信息后果是gdb的bt指令只能显示当前函数往上全是error reading variable异常处理在 unwinding 时无法恢复原来的寄存器直接调用 terminate性能分析工具无法正确采样调用栈。我个人的经验是给每条会改变栈状态的机器指令都打一个操作码标签并让汇编流发射器统一决定“这条指令需要附加哪些 CFI 记录”。不要把.cfi_*当成单独漏洞去补否则很容易漏掉某个优化分支里的压栈。5.4 尾调用优化让尾声消失尾调用优化tail call optimization是一种把“调用后立刻返回”的代码转换为跳转的优化。例如int wrapper(int x) { return helper(x 1); }优化后wrapper不需要把helper的返回地址再压栈再弹出而是清掉自己的栈帧恢复好寄存器后直接jmp helper。对应到add_procedure_prologues就是识别出那些“尾声紧跟着一条调用且调用后直接 ret”的基本块不生成普通尾声而是生成一个“恢复现场并按跳转替代调用”的序列。这个优化给序言尾声生成器带来一个棘手问题原本应该在尾声块恢复的寄存器现在必须都恢复到调用点前。而且由于这是优化可能会把原来安全的表现搞坏。实现方法一般是在机器指令层定义一个TailCall伪指令让它继承尾声所需的寄存器恢复信息然后由汇编流发射器把它展开成一组恢复指令 jmp。测试这种场景时我会专门构造一个整数重载递归函数确保所有可能的尾递归路径都被优化且不改变栈深度。6. 怎么验证生成结果是对的写功能容易验证功能难。序言尾声这种东西一旦出错可能不是“立刻崩溃”而是运行几百毫秒后才爆炸。我总结了三层验证手段从静态到动态逐级加码。6.1 汇编级断言查看序言的形态最简单的测试是做汇编文本断言。针对不同边界情况写一个编译输入函数然后拿到生成的目标汇编字符串检查是否包含预期指令序列。例如asm compile_asm(int foo(int x) { return x 1; }, optO0) assert push rbp in asm assert mov rbp, rsp in asm asm compile_asm(int foo(int x) { return x 1; }, optO2) assert push rbp not in asm # 如果这里没有修改任何 callee-saved 寄存器这类测试能在代码生成阶段就揪出大量问题但过于依赖目标平台的字符串格式适合用作“回归冒烟”。真正严格一些的方式是在编译结果的基础上遍历机器指令对象检查序言末端是否设置了正确的栈偏移、帧指针是否存在、保存寄存器集合是否和寄存器分配器的输出一致。我会在单元测试里做这种结构性断言。6.2 运行期测试寄存器恢复与栈指针对齐静态断言还不够必须实际运行。我经常使用一个小型的测试框架或指令集模拟器构造这样的用例调用者把r12、rbx等 callee-saved 寄存器设置成固定值调用一个会修改这些寄存器的被测试函数返回后判断这些寄存器是否仍是原始值再写一个内部调用其他函数的触发函数在子调用前检查sp是否按 ABI 对齐。如果展开失败模拟器通常能给出精确的指令位置和寄存器快照。做运行期测试时我会把“栈溢出探针”也纳入覆盖用一个需要分配 2MB 栈帧的大数组函数在高优化级别下反复调用观察是否在序言阶段崩溃。6.3 调试器里的栈回溯测试最后还要动真格的在调试器里打断点。对整个程序编译出的可执行文件在函数入口、函数中间、返回指令前各下一个断点执行backtrace检查调用链是否完整。测试叶函数时应该没有额外帧指针但栈回溯依然要准确。测试带异常处理的 C 程序时在抛出异常前故意在多个嵌套函数里写局部变量让异常展开时能逐层恢复rbp、rsp最终正常结束。根据我的经验一个后端如果能让下面这个程序在不同优化级别下都稳定跑过序言尾声就基本过关了volatile int sink; int recurse(int n) { int buf[64]; if (n 0) return 0; buf[n % 64] n; sink buf[n % 64]; return recurse(n - 1) buf[n % 64]; }这个用例同时覆盖了栈帧分配、数组访问、递归调用、叶函数路径、返回值传播。如果还能支持调试器里完整回溯那这个实现算是能拿得出手了。最后再分享一个实际工作中让我受益的细节不要把所有和栈相关的硬编码写死在同一个函数里。我曾经在一个后端里把“保存寄存器”和“栈帧大小计算”耦合在一起结果一改帧指针策略就要动七八处。后来把栈帧布局拆成独立的描述结构序言尾声生成器只消费这个结构改起来轻松很多。遇到目标是硬件模拟器时还能直接复用同一套栈帧描述生成调试快照把排查时间缩短一大半。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →