你正在给自己的LLVM后端加函数调用支持,跑出来第一条指令就是Segfault。GDB一看,栈指针不知道歪到哪里去了。寄存器存的返回值被覆盖了。你开始怀疑人生:到底是谁在函数入口和出口搞的鬼?
这就是Prologue(序言)和Epilogue(结语)的领地。本文基于LLVM Cpu0后端教程的第八讲,把Prologue/Epilogue的实现逻辑从头到尾拆一遍。
一、先搞清楚:Prologue和Epilogue干什么的
每个函数编译成汇编后,开头和结尾都会多出几行代码,这些不是你自己写的,是编译器自动塞进去的:
asm
# Prologue ------ 函数入口的"开场白"
addiu $sp, $sp, -32 # 栈指针往下挪,分配栈帧
sw $ra, 28($sp) # 保存返回地址
sw $fp, 24($sp) # 保存帧指针
# ... 你的函数体在这里 ...
# Epilogue ------ 函数出口的"谢幕词"
lw $ra, 28($sp) # 恢复返回地址
lw $fp, 24($sp) # 恢复帧指针
addiu $sp, $sp, 32 # 栈指针归位
jr $ra # 返回
Prologue负责"进场":保存调用者的上下文、分配自己的地盘(栈帧)。Epilogue负责"退场":把地盘还回去、恢复现场、跳回调用者。
听起来简单,但在LLVM后端真正实现过的人都知道,坑不在概念上,在细节里。
二、PEI Pass:谁在幕后操纵这一切?
LLVM不是等到最后才一股脑插入Prologue/Epilogue的。它有一个专门的Pass叫PrologueEpilogueInserter(简称PEI),在CodeGen流水线的后期执行。

PEI的触发时机非常靠后:指令选择做完了,寄存器分配做完了,指令调度也差不多搞定了。这时候MachineFunction里已经是一堆接近真实汇编的MachineInstr,PEI的任务就是在这之上插入栈帧操作。
PEI的入口在 lib/CodeGen/PrologueEpilogueInserter.cpp,它是一个MachineFunctionPass。每遇到一个函数,它就:
- 调用
TargetFrameLowering::determineCalleeSaves()------ 决定哪些寄存器必须存到栈上 - 给每个需要保存的寄存器分配固定栈槽(Fixed Stack Object)
- 在Entry BB最前面调用
emitPrologue() - 在所有Return BB的终止指令前调用
emitEpilogue()
核心代码路径可以用一张图概括:

从上图可以看到调用链:PEI → emitPrologue → RISCVFrameLowering → RISCVRegisterInfo。你写的后端里 XXXFrameLowering::emitPrologue 就是在这一步被调用的。
什么时候PEI不起作用? 如果你的后端根本没实现
TargetFrameLowering的子类,PEI就跳过你的函数 ------ 这就是为什么刚搭好后端时函数调用不出错但也跑不对,因为栈帧根本没人管。
三、栈帧布局:一个函数在栈上到底占多少空间
在深入了解代码之前,先把栈帧的物理布局搞清楚。MIPS/Cpu0的栈是向下增长的(从高地址到低地址):
高地址
+------------------+
| 调用者参数区 | ← 调用者的栈帧
+------------------+
| 返回地址 ($ra) |
+------------------+
| 帧指针 ($fp) |
+------------------+
| Callee-Saved寄存器| ← 被调用者保存的寄存器
+------------------+
| 局部变量 |
+------------------+
| 溢出槽 (spills) |
+------------------+
| 对齐填充 (padding) |
低地址 ← $sp

视频中用MIPS汇编展示了完整的栈分配过程。关键点:LLVM把这一切抽象成 MachineFrameInfo 里的 Stack Objects。
每个Stack Object代表栈上一块有固定偏移的内存。LLVM用 Frame Index (一个负数)来引用它们,在寄存器分配时还不知道真实地址,等到PEI完了才通过 eliminateFrameIndex 把虚拟Frame Index替换成 $sp + offset 或 $fp + offset。
四、Callee-Saved寄存器:最难搞的一环
这是PEI中最复杂的部分。哪些寄存器需要在Prologue里存起来、Epilogue里恢复?
答案取决于你后端的 Calling Convention(调用约定) 。在 XXXRegisterInfo.cpp 里,你要实现:
cpp
const MCPhysReg *
Cpu0RegisterInfo::getCalleeSavedRegs(const MachineFunction *MF) const {
static const MCPhysReg CalleeSavedRegs[] = {
Cpu0::LR, // 返回地址寄存器
Cpu0::FP, // 帧指针
Cpu0::S0, Cpu0::S1, Cpu0::S2, // 通用保存寄存器
0 // 哨兵
};
return CalleeSavedRegs;
}
这个数组告诉PEI:"函数入口要把这些寄存器压栈,出口要把它们弹回来"。

上图展示了 initCalleeSavedValues 的调用流程,这一步在PEI内部执行,遍历你声明的CalleeSaved寄存器列表,给每个分配栈槽。
但是,PEI有个智能优化 :如果寄存器分配后发现某个Callee-Saved寄存器在函数体内根本没被改写(没被分配出去),PEI就不保存它。这就是 determineCalleeSaves() 做的事 ------ 它用寄存器分配的liveness信息来裁剪不必要的保存/恢复指令。
视频中展示了 PrologInserter.cpp 里 initCalleeSavedValues 的实际代码:

红色箭头直接指向核心实现:遍历、判断、分配栈槽编号。理解这段代码后你对整个PEI的寄存器管理就不会再有陌生感了。
五、emitPrologue:函数入口的三板斧
重头戏来了。你的 XXXFrameLowering::emitPrologue 要干三件事:
5.1 拿到栈大小
cpp
const MachineFrameInfo &MFI = MF.getFrameInfo();
uint64_t StackSize = MFI.getStackSize();
注意 :此时PEI已经把所有栈对象(局部变量、溢出槽、Callee-Saved寄存器槽、对齐填充)都算好了,getStackSize() 返回的是最终值。千万别自己在emitPrologue里重新算一遍。
5.2 调整栈指针
cpp
// SP = SP - StackSize
BuildMI(MBB, MBBI, dl, TII.get(Cpu0::ADDiu), Cpu0::SP)
.addReg(Cpu0::SP)
.addImm(-StackSize);
这里有一个细节经常被忽略:如果 StackSize 太大,一条 addiu 的立即数范围装不下(MIPS的立即数是16位有符号),你需要分多条指令加载常数。
视频中花了大量时间讲这个:先用 LUI 加载高16位,再用 ORI 合并低16位,最后才做 addiu:

上面截图展示了实际的 emitPrologue 代码,包括如何通过 BuildMI 构建 addiu $sp, $sp, $1 指令。其中 $1 寄存器的值是由前面 LUI + ORI 两条指令拼出来的大常数。
5.3 保存Callee-Saved寄存器
cpp
const std::vector<CalleeSavedInfo> &CSI = MFI.getCalleeSavedInfo();
for (const CalleeSavedInfo &CS : CSI) {
Register Reg = CS.getReg();
int FrameIdx = CS.getFrameIdx();
// emit SW Reg, FrameIdx($sp)
}
每个 CalleeSavedInfo 包含寄存器编号和PEI分配的栈槽编号(FrameIdx),你只需遍历它,生成对应的 SW(Store Word)指令。
六、栈对齐:16字节的坑,多少后端开发者被绊倒过
栈对齐是Prologue实现里最容易被忽略、后果也最严重的细节。
为什么必须对齐? 不是编译器强迫症,是ABI(应用二进制接口)的硬性要求。以x86-64 System V ABI为例:
在执行任何
call指令之前,栈指针%rsp必须是16字节对齐的。
原因有三:
- SIMD指令要求 :SSE/AVX指令(
movaps、movdqa)要求内存操作数16字节对齐,不对齐直接触发#GP异常 - 参数传递 :
__m128类型的参数如果走栈传递,必须对齐,否则值就读错了 - 不同编译器互通:GCC生成的代码假设栈是16字节对齐的,LLVM生成的也得对齐,不然ABI层面就不兼容

视频中详细展示了MIPS下实现栈对齐的方法:用 shl(逻辑左移)和 addiu 组合,确保最终的栈大小是16的倍数。
在LLVM里,栈对齐通过 TargetFrameLowering 的 getStackAlignment() 声明:
cpp
// 返回栈对齐要求(字节数)
uint64_t getStackAlignment() const { return 16; }
PEI在计算栈大小时会主动考虑对齐要求,最终的 StackSize 已经是对齐过的值。但如果你在 emitPrologue 里又手动加了一次对齐逻辑,反而会搞出偏移Bug。
一个真实的踩坑场景 :你在Prologue里手动 AND $sp, -16,但PEI以为已经对齐好了,后续的Frame Index消除全按对齐后的偏移算------结果你的局部变量访问全部偏移了8个字节,数据全乱。
七、emitEpilogue:优雅的退场
Epilogue是Prologue的镜像操作,但有一个关键区别:执行顺序是Prologue的逆序。
Prologue:分配栈空间 → 保存寄存器
Epilogue:恢复寄存器 → 释放栈空间 → ret
不能反过来。如果你先释放栈空间再恢复寄存器,LW 指令的地址就飞到栈外去了。

视频进入Epilogue部分时展示的汇编序列,箭头清晰地标出了恢复指令与释放栈空间指令的顺序。
伪代码结构如下:
cpp
void Cpu0FrameLowering::emitEpilogue(MachineFunction &MF,
MachineBasicBlock &MBB) const {
// 1. 恢复Callee-Saved寄存器(逆序遍历!)
const std::vector<CalleeSavedInfo> &CSI = MFI.getCalleeSavedInfo();
for (int i = CSI.size() - 1; i >= 0; --i) {
// emit LW CSI[i].getReg(), CSI[i].getFrameIdx()($sp)
}
// 2. 释放栈空间
// SP = SP + StackSize
// 3. RET(通常由terminator指令处理,不用你在epilogue里生成)
}

注意上图右侧源码中的异常处理逻辑 ------ Epilogue不仅要处理正常返回,还要处理异常展开(Unwinding)的场景。如果你的后端要支持C++异常,Epilogue必须与 .cfi_* 伪指令配合,保证异常栈回溯能正确还原寄存器状态。
八、Cpu0后端的完整调用链(对照视频梳理)
视频花了大量篇幅用红色箭头串联调用链,这是理解整个体系最直观的方式。我把关键路径整理如下:
PrologueEpilogueInserter::runOnMachineFunction()
│
├── determineCalleeSaves()
│ └── Cpu0RegisterInfo::getCalleeSavedRegs()
│
├── emitPrologue()
│ └── Cpu0FrameLowering::emitPrologue()
│ ├── MF.getFrameInfo().getStackSize()
│ ├── BuildMI(ADDiu SP, SP, -StackSize)
│ └── for each CalleeSavedInfo: BuildMI(SW Reg, FI($sp))
│
└── emitEpilogue()
└── Cpu0FrameLowering::emitEpilogue()
├── for each CalleeSavedInfo (逆序): BuildMI(LW Reg, FI($sp))
└── BuildMI(ADDiu SP, SP, +StackSize)
三个类各司其职:
| 类 | 职责 |
|---|---|
| PrologueEpilogueInserter | 通用Pass,驱动整个流程,计算栈大小 |
| Cpu0FrameLowering | 目标架构特化的Prologue/Epilogue指令生成 |
| Cpu0RegisterInfo | 声明哪些寄存器需要保存、栈指针/帧指针是哪几个 |
Cpu0FrameLowering 像包工头,从 MachineFrameInfo 拿图纸(栈大小、CalleeSaved列表),从 Cpu0RegisterInfo 拿物料清单(哪些寄存器、怎么访问栈),然后用 Cpu0InstrInfo 工具箱里的 BuildMI 敲出最终指令。
九、常见问题速查
Q1:eliminateFrameIndex 和 PEI 的关系?
PEI先把虚拟的Frame Index分配到栈上(确定偏移量),然后 eliminateFrameIndex 在另一个Pass里把所有 FrameIndex 引用替换成 $sp + offset 或 $fp + offset。两者先后执行,PEI在前期算偏移,eliminateFrameIndex在后期做替换。
Q2:什么时候需要Frame Pointer($fp)?
- 函数里用了
alloca(动态栈分配) - 函数调用了其他函数(
hasCalls()为true) - 调试信息需要栈回溯
不需要FP时,LLVM会启用 Frame Pointer Elimination,把FP寄存器释放给通用计算用。
Q3:动态栈分配(alloca)和固定栈帧的区别?
固定栈帧:编译时栈大小已知,Prologue一条 sub $sp, N 搞定。
动态栈分配:alloca 的大小运行时才知道。这时候必须用Frame Pointer,因为 $sp 会在运行时变化,局部变量不能用 $sp + const 访问,只能用 $fp + const。
Q4:Shrink-wrapping是什么?
默认情况下PEI把所有Callee-Saved寄存器在函数入口全部保存。但有些寄存器只在函数的某条路径上被用到。Shrink-wrapping技术会把保存/恢复指令挪到真正需要的那条路径上,减少不必要开销。LLVM的PEI支持部分Shrink-wrapping优化。
十、写在最后
Prologue和Epilogue看起来只是几行汇编,但背后是一整套ABI、栈管理、寄存器分配的协作机制。PEI Pass是这套机制的调度中心,FrameLowering 是你和后端对话的接口,RegisterInfo 定义了游戏规则。
踩过栈对齐的坑、被Callee-Saved的逆序恢复绊倒、搞混Frame Index消除的时机------这些几乎每个后端开发者都经历过。希望这篇文章能帮你少走弯路。
如果你正在基于Cpu0教程写自己的LLVM后端,建议对照本视频的第八章代码(Prologue/Epilogue部分),把 Cpu0FrameLowering.cpp 的 emitPrologue 和 emitEpilogue 从头读一遍,再回头看这篇文章的调用链图,整个体系就通了。