LLVM后端Prologue与Epilogue实现详解 —— 从Cpu0看栈帧管理完整流程

你正在给自己的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。每遇到一个函数,它就:

  1. 调用 TargetFrameLowering::determineCalleeSaves() ------ 决定哪些寄存器必须存到栈上
  2. 给每个需要保存的寄存器分配固定栈槽(Fixed Stack Object)
  3. 在Entry BB最前面调用 emitPrologue()
  4. 在所有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.cppinitCalleeSavedValues 的实际代码:

红色箭头直接指向核心实现:遍历、判断、分配栈槽编号。理解这段代码后你对整个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字节对齐的。

原因有三:

  1. SIMD指令要求 :SSE/AVX指令(movapsmovdqa)要求内存操作数16字节对齐,不对齐直接触发 #GP 异常
  2. 参数传递__m128 类型的参数如果走栈传递,必须对齐,否则值就读错了
  3. 不同编译器互通:GCC生成的代码假设栈是16字节对齐的,LLVM生成的也得对齐,不然ABI层面就不兼容

视频中详细展示了MIPS下实现栈对齐的方法:用 shl(逻辑左移)和 addiu 组合,确保最终的栈大小是16的倍数。

在LLVM里,栈对齐通过 TargetFrameLoweringgetStackAlignment() 声明:

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.cppemitPrologueemitEpilogue 从头读一遍,再回头看这篇文章的调用链图,整个体系就通了。


相关推荐
liangshanbo12154 小时前
手写 Function.prototype.call 与 Function.prototype.apply 面试题满分答案
开发语言·javascript·原型模式
Zenova EdgeOS4 小时前
C++ 工业边缘 libcurl HTTP 客户端实战
开发语言·c++·网络协议·边缘计算
Java小白笔记5 小时前
Java 单元测试怎么写:JUnit 5、Mockito 与 Spring Boot 方法级实战
java·junit·单元测试
Jackson_GJH5 小时前
Qt 右键自定义菜单的实现
开发语言·c++·qt
CoderYanger5 小时前
前端基础——JavaScript(WebAPI)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展
lisin-lee-cooper5 小时前
简单聊聊 Spring 框架源码
java·后端·spring
一木 之林6 小时前
七、一-AI 工程实践、插件化调试与软件交付
java·linux·c++
边境悍匪6 小时前
蜗牛学苑 Java 智能体学习 Day38|Spring AI Alibaba2 思维导图复盘
java·学习·spring
Co_Hui6 小时前
Java 静态内部类、非静态内部类、匿名内部类的区别?
java
Wang's Blog6 小时前
Java框架快速入门: Spring Security+OAuth2之自定义认证过滤器实现JSON登录
java·spring·json