概述

add_procedure_prologues 函数是编译器后端中一个关键步骤,它负责根据 RISC-V ABI 规范。

在函数调用点插入调用者保存寄存器(caller-saved)的保存与恢复代码,并在函数的入口和出口处插入被调用者保存寄存器(callee-saved)的保存与恢复代码。

这样做的目的是保证寄存器在跨越函数调用时的值能够被正确保留,同时为后续的栈帧布局提供准确的栈偏移信息。

代码示例


/// `add_procedure_prologues` 函数:为函数调用添加序言和尾声代码
///
/// 参数:
/// - cfg: &mut CFG<Operator> - 控制流图的可变引用
/// - allocs: &HashMap<VReg, RV64Reg> - 虚拟寄存器到物理寄存器的分配映射
///
/// 功能:根据RISC-V ABI规范,在函数调用点插入调用者保存寄存器的保存/恢复代码,
///       在函数入口/出口插入被调用者保存寄存器的保存/恢复代码
///
/// 约束:调用点必须被隔离(不包括溢出寄存器)
/// 注释:callsites must be isolated (excluding spills);
/// callsites must be isolated (excluding spills);
fn add_procedure_prologues(cfg: &mut CFG<Operator>, allocs: &HashMap<VReg, RV64Reg>) {
    // 步骤1:获取当前已分配的栈帧偏移量(Activation Record Size)
    // ar 表示当前栈帧中已分配的8字节槽位数量
    let ar = *cfg.get_allocated_ars_mut();

    // 步骤2:初始化最大栈偏移量,用于跟踪整个函数中需要的最大栈空间
    let mut ar_max = ar;

    // 步骤3:计算每个基本块的活跃出集合(live-out)
    // live_out[i] 表示基本块i出口处活跃的寄存器集合
    let live_out = cfg.live_out();

    // 步骤4:获取所有被调用者保存寄存器(Callee-Saved Registers)
    // 这些寄存器在函数调用前后必须保持值不变
    let callee_saved = allocs.values().filter(|reg| reg.callee_saved());
    // 步骤5:遍历所有基本块,处理包含函数调用的基本块
    for (i, block) in cfg.get_blocks_mut().iter_mut().enumerate() {
        // 步骤5.1:检查当前基本块是否包含函数调用指令
        if block.body.iter().any(|op| matches!(op, Operator::Call(..))) {
            // 步骤5.2:初始化当前调用点的栈偏移量
            let mut ar_ = ar;

            // 步骤5.3:验证调用点被隔离(只有一个前驱)
            // 这是函数的前提条件,确保调用点分析的正确性
            debug_assert_eq!(block.preds.len(), 1);

            // 步骤5.4:计算需要保存的寄存器集合
            // 规则:寄存器在调用点活跃,且不是被调用者保存寄存器
            let to_save: Vec<_> = live_out[i]
                .intersection(&live_out[block.preds[0]])  // 在调用点和前驱块都活跃
                .filter(|reg| !allocs[reg].callee_saved()) // 不是被调用者保存寄存器
                .collect();

            // 步骤5.5:生成序言代码(调用前保存寄存器)
            let mut prologue: Vec<_> = to_save
                .iter()
                .map(|&&reg| {
                    // 为每个需要保存的寄存器生成存储指令
                    let res = Operator::StoreLocal(reg, ar_);
                    ar_ += 1;  // 递增栈偏移量
                    res
                })
                .collect();

            // 步骤5.6:重置栈偏移量,用于尾声代码
            let mut ar_ = ar;

            // 步骤5.7:生成尾声代码(调用后恢复寄存器)
            let mut epilogue: Vec<_> = to_save
                .iter()
                .map(|&&reg| {
                    // 为每个需要恢复的寄存器生成加载指令
                    let res = Operator::LoadLocal(reg, ar_);
                    ar_ += 1;  // 递增栈偏移量
                    res
                })
                .collect();

            // 步骤5.8:构建新的指令序列
            // 顺序:序言代码 + 原始指令 + 尾声代码
            prologue.append(&mut block.body);

            // 步骤5.9:保存并移除跳转指令(如果有的话)
            let jump = prologue.pop();

            // 步骤5.10:将尾声代码添加到序言之后
            prologue.append(&mut epilogue);

            // 步骤5.11:恢复跳转指令(放在尾声代码之后)
            if let Some(op) = jump {
                prologue.push(op)
            }

            // 步骤5.12:用新的指令序列替换原始指令序列
            block.body = prologue;

            // 步骤5.13:更新最大栈偏移量
            ar_max = std::cmp::max(ar_max, ar_);
        }
    }
    // 步骤6:设置当前栈偏移量为最大值
    let mut ar = ar_max;
    *cfg.get_allocated_ars_mut() = ar_max;
    // 步骤7:处理被调用者保存寄存器(Callee-Saved Registers)
    for &saved in callee_saved {
        // 步骤7.1:在函数入口保存被调用者保存寄存器
        cfg.get_block_mut(cfg.get_entry())
            .body
            .insert(0, Operator::StoreLocal(usize::from(saved) as VReg, ar));

        // 步骤7.2:在函数出口恢复被调用者保存寄存器
        cfg.get_block_mut(cfg.get_exit())
            .body
            .push(Operator::LoadLocal(usize::from(saved) as VReg, ar));

        // 步骤7.3:递增栈偏移量
        ar += 1;
    }

    // 步骤8:更新最终的栈帧大小
    *cfg.get_allocated_ars_mut() = ar;
}

函数的主要工作流程

  1. 获取当前栈帧已分配的8字节槽位数量(ar):这个值表示在溢出(spill)过程中已经占用的栈空间(以8字节为单位)。
  2. 遍历所有基本块,找出包含函数调用指令(Operator::Call)的基本块。
    1. 对于每个这样的调用点,计算需要保存的寄存器集合:这些寄存器在调用点处是活跃的(即在调用后还会被使用),并且不属于被调用者保存寄存器。
    2. 在调用之前插入 序言代码:将每个需要保存的寄存器存储到栈上(StoreLocal),栈偏移依次递增。
    3. 在调用之后插入 尾声代码:从栈上加载回这些寄存器的值(LoadLocal)。
    4. 更新当前基本块的最大栈偏移量,最终记录整个函数所需的栈帧最大值(ar_max)。
  3. 处理被调用者保存寄存器:在函数的入口处(cfg.get_entry())插入保存代码,在出口处(cfg.get_exit())插入恢复代码,同样使用栈帧空间。
  4. 更新最终栈帧大小:将 allocated_ars 更新为最大值,供后续代码生成使用。

例子:一个包含函数调用的简单函数

假设我们有如下的中间代码(已经过寄存器分配,虚拟寄存器被替换为物理寄存器)

; 虚拟寄存器 → 物理寄存器分配映射:
;   r1 → R1 (调用者保存)
;   r2 → R2 (调用者保存)
;   r3 → R3 (被调用者保存)
;   r4 → R4 (调用者保存)

BB0 (入口):
  r1 = li 10
  r2 = li 20
  r3 = add r1, r2       ; r3 是被调用者保存寄存器,需跨调用保留
  call foo, (r1, r2)    ; 调用函数 foo,传递 r1, r2 作为参数
  r4 = add r1, r3       ; 调用后使用 r1 和 r3
  ret r4

调用点: 位于 BB0

活跃分析结果(假设):

  • 在调用指令 call foo 处,活跃的寄存器有 r1r3, r4(因为 r1 和 r3 在后面被使用,r4 是结果寄存器)
  • 根据ABI,r1r2r4 是调用者保存寄存器,r3 是被调用者保存寄存器

当前栈帧已分配槽位 ar = 0(假设之前没有溢出)

执行过程

1. 处理调用点 call foo

计算需要保存的寄存器:live_out 在调用点处的活跃集合 = {r1, r3, r4}

过滤掉被调用者保存寄存器(r3),剩余 {r1, r4}

 r1 生成 StoreLocal r1, 0ar_ 变为 1;为 r4 生成 StoreLocal r4, 1ar_ 变为 2。

生成序言代码(保存):

StoreLocal r1, 0
StoreLocal r4, 1

生成尾声代码(恢复):

LoadLocal r1, 0
LoadLocal r4, 1

将原始指令序列 [r1=li, r2=li, r3=add, call foo, r4=add, ret] 拆开,在 call 前后插入序言和尾声。最终 BB0 变为

; 序言
StoreLocal r1, 0
StoreLocal r4, 1
; 原始指令
r1 = li 10
r2 = li 20
r3 = add r1, r2
call foo, (r1, r2)
; 尾声
LoadLocal r1, 0
LoadLocal r4, 1
; 原始剩余指令
r4 = add r1, r3
ret r4

注意:StoreLocal 和 LoadLocal 使用的是栈偏移 0 和 1(每个占8字节)。调用点更新后 ar_max = 2

2. 处理被调用者保存寄存器

被调用者保存寄存器集合:{r3}r3.callee_saved() 为 true)。

当前 ar_max = 2,从 ar = 2 开始。

在入口块(BB0)的开头插入 StoreLocal r3, 2ar 变为 3。

在出口块(也是 BB0,因为只有一个块)的末尾插入 LoadLocal r3, 2(在 ret 之前)。

最终 BB0 变为

; 入口保存被调用者保存寄存器
StoreLocal r3, 2
; 序言(调用点)
StoreLocal r1, 0
StoreLocal r4, 1
; 原始指令
r1 = li 10
r2 = li 20
r3 = add r1, r2
call foo, (r1, r2)
; 尾声(调用点)
LoadLocal r1, 0
LoadLocal r4, 1
; 原始剩余指令
r4 = add r1, r3
; 出口恢复被调用者保存寄存器
LoadLocal r3, 2
ret r4

3. 更新栈帧大小

最终 ar = 3,表示整个函数需要 3 * 8 = 24 字节的栈空间用于保存寄存器(不包括其他局部变量)

为什么这样设计?

调用者保存寄存器:在函数调用前后,调用者负责保存和恢复它们。因为被调用的函数可能会任意使用这些寄存器,调用者必须假设它们的值会被破坏,因此需要在调用前保存,调用后恢复。

被调用者保存寄存器:被调用的函数负责在入口处保存它们的原始值,并在出口处恢复,以保证调用者能看到调用前的值。因此我们在函数的入口和出口处插入保存/恢复代码。

栈偏移管理:通过顺序分配栈槽位,确保保存的寄存器不会互相覆盖,同时记录下函数所需的最大栈空间,为后续代码生成(如栈帧分配)提供依据。总结

总结

add_procedure_prologues 函数将RISC-V ABI的寄存器保存约定具体化到中间代码中,通过插入显式的存储和加载指令,确保函数调用的正确性,并计算出栈帧所需的总空间。

这是编译器后端生成可执行代码前的重要步骤。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐