RISC-V 编程之 Frame Pointer和 backtrace

时间:2022-11-20 22:59:54


生成的代码

一段简单的代码:

int main(void)
{
blink1(10);

return 0;
}

未设置任何优化选项得到的编译结果,函数头部的​​sw s0,8(sp)​​​和​​addi s0,sp,16​​​和函数尾部的​​lw s0,8(sp)​​就是对 frame pointer 的存储和恢复。s0 寄存器即 fp 寄存器:

00000118 <main>:
118: 1141 addi sp,sp,-16
11a: c606 sw ra,12(sp)
11c: c422 sw s0,8(sp)
11e: 0800 addi s0,sp,16
120: 4529 li a0,10
122: 00000097 auipc ra,0x0
126: 000080e7 jalr ra # 122 <main+0xa>
12a: 4781 li a5,0
12c: 853e mv a0,a5
12e: 40b2 lw ra,12(sp)
130: 4422 lw s0,8(sp)
132: 0141 addi sp,sp,16
134: 8082 ret

使用 ​​-fomit-frame-pointer​​ 优化选项得到的编译结果,没有对 s0/fp 寄存器的操作代码:

000000d6 <main>:
d6: 1141 addi sp,sp,-16
d8: c606 sw ra,12(sp)
da: 4529 li a0,10
dc: 00000097 auipc ra,0x0
e0: 000080e7 jalr ra # dc <main+0x6>
e4: 4781 li a5,0
e6: 853e mv a0,a5
e8: 40b2 lw ra,12(sp)
ea: 0141 addi sp,sp,16
ec: 8082 ret

使用 ​​-O2​​​ 优化选项得到的编译结果,也没有对 s0/fp 寄存器的操作代码,因为 ​​-O2​​​ 选项包含了 ​​-fomit-frame-pointer​​ 选项:

00000000 <main>:
0: 1141 addi sp,sp,-16
2: 4529 li a0,10
4: c606 sw ra,12(sp)
6: 00000097 auipc ra,0x0
a: 000080e7 jalr ra # 6 <main+0x6>
e: 40b2 lw ra,12(sp)
10: 4501 li a0,0
12: 0141 addi sp,sp,16
14: 8082 ret

使用 ​​-O2 -fno-omit-frame-pointer​​​ 优化选项得到的编译结果,函数头部有​​sw s0,8(sp)​​​和​​addi s0,sp,16​​​,函数尾部有​​lw s0,8(sp)​​,但是次序和前面的不一样了,这是编译优化导致的编译乱序:

00000000 <main>:
0: 1141 addi sp,sp,-16
2: c422 sw s0,8(sp)
4: c606 sw ra,12(sp)
6: 0800 addi s0,sp,16
8: 4529 li a0,10
a: 00000097 auipc ra,0x0
e: 000080e7 jalr ra # a <main+0xa>
12: 40b2 lw ra,12(sp)
14: 4422 lw s0,8(sp)
16: 4501 li a0,0
18: 0141 addi sp,sp,16
1a: 8082 ret

对调试的影响

有没有 frame pointer 对调试时函数调用栈的查看没有影响,至少对 RISC-V 而言是这样的。调试器的调用栈是 ​​CFI – Call Frame Information​​ 提供的,不是通过 frame pointer 获取的。CFI 会告诉调试器返回地址存储在堆栈的哪个位置,调试器依次去获取每级函数调用的返回地址即可获得调用关系,除了调用关系外,调试器还得知道局部变量的存储位置等信息,这些信息都由 CFI 来实现。

当给编译器添加 ​​-g​​​ 选项后,编译器的汇编输出会多出很多 ​​.cfi_*​​​ 的伪指令,这就是 CFI 有关的内容了,还有就是 ​​.loc​​​ 的伪指令,这是将指令和源代码行号对应起来的伪指令。例如某文件加 ​​-g​​ 编译的汇编输出:

.file "frame-pointer.c"
.option nopic
.text
.Ltext0:
.cfi_sections .debug_frame
.align 1
.globl blink4
.type blink4, @function
blink4:
.LFB0:
.file 1 "frame-pointer.c"
.loc 1 14 1
.cfi_startproc
.LVL0:
.loc 1 15 5
.loc 1 14 1 is_stmt 0
addi sp,sp,-16
.cfi_def_cfa_offset 16
sw s0,8(sp)
sw ra,12(sp)
.cfi_offset 8, -8
.cfi_offset 1, -4
.loc 1 14 1
mv s0,a0
.LVL1:
.loc 1 16 5 is_stmt 1
call backtrace
.LVL2:
.L2:
.loc 1 17 5 discriminator 1
.loc 1 18 9 discriminator 1
call hal_led_blue_toggle
.LVL3:
.loc 1 19 9 discriminator 1
.loc 1 20 10 is_stmt 0 discriminator 1
addi s0,s0,-1
.LVL4:
.loc 1 19 9 discriminator 1
li a0,1
call hal_tick_delay_lf
.LVL5:
.loc 1 20 9 is_stmt 1 discriminator 1
.loc 1 21 5 is_stmt 0 discriminator 1
bnez s0,.L2
.loc 1 22 1
lw ra,12(sp)
.cfi_restore 1
lw s0,8(sp)
.cfi_restore 8
.LVL6:
addi sp,sp,16
.cfi_def_cfa_offset 0
jr ra
.cfi_endproc
.LFE0:
.size blink4, .-blink4

frame pointer 的作用

frame pointer 被程序用来跟踪函数调用关系(backtrace功能),特别是在发生异常时,输出函数调用关系可以更容易跟踪问题所在,特别是异常发生现场是被多处调用的公共函数时。

为了产生有 frame pointer 的栈帧,如果有优化选项,那么要加上 ​​-fno-omit-frame-pointer​​​ 告诉编译器不要把 frame pointer 优化掉,还要加上 ​​-fno-optimize-sibling-calls​​​ 选项,该选项让编译器不要优化尾部调用,如果执行了尾部调用,那么本来应该编译成 ​​jal, ret​​​ 两条指令的操作被优化成 ​​j​​​ 一条指令,而且 frame pointer 在 ​​j​​ 指令前被恢复了,这就没法获得完整的调用关系了。

C代码,blink2 函数的末尾调用 blink3 函数:

void blink2(uint32_t n)
{
……
blink3(n);
}

加了 ​​-fno-optimize-sibling-calls​​ 选项的编译结果,尾调用未被优化:

20400070 <blink2>:
20400070: 1141 addi sp,sp,-16
20400072: c422 sw s0,8(sp)
20400074: c04a sw s2,0(sp)
20400076: c606 sw ra,12(sp)
20400078: c226 sw s1,4(sp)
2040007a: 0800 addi s0,sp,16
……
2040008e: 854a mv a0,s2
20400090: 3775 jal 2040003c <blink3>
20400092: 40b2 lw ra,12(sp)
20400094: 4422 lw s0,8(sp)
20400096: 4492 lw s1,4(sp)
20400098: 4902 lw s2,0(sp)
2040009a: 0141 addi sp,sp,16
2040009c: 8082 ret

没有加 ​​-fno-optimize-sibling-calls​​ 选项编译结果,尾调用被优化:

20400070 <blink2>:
20400070: 1141 addi sp,sp,-16
20400072: c422 sw s0,8(sp)
20400074: c04a sw s2,0(sp)
20400076: c606 sw ra,12(sp)
20400078: c226 sw s1,4(sp)
2040007a: 0800 addi s0,sp,16
……
2040008e: 4422 lw s0,8(sp)
20400090: 40b2 lw ra,12(sp)
20400092: 4492 lw s1,4(sp)
20400094: 854a mv a0,s2
20400096: 4902 lw s2,0(sp)
20400098: 0141 addi sp,sp,16
2040009a: b74d j 2040003c <blink3>

上述两次编译都添加了选项 ​​-O2 -fno-omit-frame-pointer​​。从上述两次编译结果看,尾调用优化只减少了一条指令,但是可以减少堆栈空间的使用,因为执行尾调用时,caller 的堆栈空间已经释放了。

与 ​​-fno-optimize-sibling-calls​​​ 选项类似可能还有 ​​-ftree-tail-merge​​ 选项等。

backtrace 的实现

gcc 有提供内建函数 ​​__builtin_frame_address​​​ 获取栈帧地址,还提供内建函数 ​​__builtin_return_address​​ 获取函数返回地址。但是 gcc 的这两个函数只保证正确获取末级的帧地址和返回地址,上级的帧地址和返回地址是不保证正确的,实际使用情况是,RISC-V gcc 的这两个函数无法得到上级的帧地址和返回地址,那么就只能自己写汇编代码来实现 backtrace 了。

示例代码如下:

.text
.align 2
.global backtrace
.type backtrace, @function
backtrace:
la a0, backtrace_buffer
sw ra, 0(a0) // 保存末级函数返回地址
mv a1, s0 // 取fp寄存器,s0即fp

lw a2, -4(a1) // fp寄存器所指向的位置偏移-4就是上一级返回地址的存储地址
sw a2, 4(a0)
lw a1, -8(a1) // fp 寄存器所指向的位置偏移-8就是上一级fp的存储地址

lw a2, -4(a1) // 依此类推
sw a2, 8(a0)
lw a1, -8(a1)

lw a2, -4(a1)
sw a2, 12(a0)
lw a1, -8(a1)

lw a2, -4(a1)
sw a2, 16(a0)
lw a1, -8(a1)

lw a2, -4(a1)
sw a2, 20(a0)
lw a1, -8(a1)

ret
.size backtrace, .-backtrace

.data
.align 2
backtrace_buffer:
.word 0, 0, 0, 0, 0, 0, 0, 0

这段代码将各级函数调用的返回地址写入到 ​​backtrace_buffer​​ 中,这里只是个示例,并不实用,除了末级函数外,上级函数调用的返回地址获得可以通过循环来实现,这里的代码是全部展开的,而且也没有判断是不是*函数。

参考

-​​gcc优化选项解析​​ -​​Tackling C++ Tail Calls​