深入浅出地讲解rvbacktrace原理
前情提要:栈
要理解栈回溯,就要首先理解栈是如何运行的
下面看一段非常简单的代码
uint32_t funB(uint32_t b)
{
return b-1;
}
void funA(uint32_t a)
{
uint32_t b = funB(a);
}
int main(void)
{
board_init();
funA(3);
while (1) {
}
return 0;
}
这段代码的逻辑非常简单,调用链也很清晰,在main函数中调用funA,funA中调用funB,main函数的汇编代码如下所示
int main(void)
{
8000648c: 1141 add sp,sp,-16
8000648e: c606 sw ra,12(sp)
80006490: c422 sw s0,8(sp)
80006492: 0800 add s0,sp,16
board_init();
80006494: b16fe0ef jal 800047aa <board_init>
funA(3);
80006498: 450d li a0,3
8000649a: b7dfd0ef jal 80004016 <funA>
可以看到,当main函数执行时,首先向上开辟了一块栈空间,然后将ra,s0压栈,随后才是用户所写的代码逻辑,对这里用到的两个寄存器进行说明:
-ra:保存了返回地址,当执行jal时,会将下一条指令的地址保存至ra
-s0:又叫fp,frame point,栈帧指针,是回溯需要用到的重要寄存器
这里需要注意一个细节,在调用main函数之前,把fp压栈了,随后将fp指向了当前函数栈底;这样意味着,当运行某个函数时,risc-v的fp寄存器,始终指向该函数的栈底,而该函数的栈中,保留了运行上一个函数时的fp寄存器的值
这么说或许有些抽象,用一个图进行演示
这么一看就清晰多了,也就是在FunB中,可以看到自己的caller FunA的栈底是0x2200110,而caller的栈顶,也就是callee的栈底,也就是0x2200120;
这么一来,对于被调用者来说,可以知道调用者的栈地址,这也是通过FP进行栈回溯的基础
以上基础是理解栈回溯的基础,务必搞清楚后才能理解栈回溯的本质
栈回溯实现
思路
有了以上的铺垫之后,我们可以想想如果是要自己写一个栈回溯的函数traceback,该怎么做
- 首先traceback一定是在某处被调用,我们假设FunB调用了traceback
- 在funB运行时,我们可以从寄存器SP得到FunB的栈顶,从寄存器FP得到栈底
- 我们可以把栈底和栈顶传递给traceback,这样trackback就能得到栈中的所有内容
- 栈内有什么呢,有FunB的返回地址ra,还有之前保存的fp(这里要注意,栈中的fp和当前寄存器FP内的值是两回事,栈中的fp是调用FunB的函数FunA的栈底)
- 接着,我们可以根据栈中的fp跳转至FunA的栈,里面又可以得到FunA的返回地址,以及FunA栈中的fp,也就是main函数的栈底
- 这样一层一层套娃,也就完成了函数的调用链回溯
- 最后需要注意退出条件,fp指向整个栈底的时候意味着调用链结束,应当退出
演示代码
void backtrace() {
uintptr_t *fp;
// 内联汇编获取当前FP(s0寄存器)
asm volatile ("mv %0, s0" : "=r"(fp));
printf("Backtrace:\n");
//如果FP>=栈底,则意味着函数调用链已经结束
while (uintptr_t)FP < (uintptr_t)estack) {
// 获取返回地址(RA = fp - 8)
uintptr_t ra = *(fp - 1);
if (ra == 0) break;
// 获取前一个FP(prev_fp = fp - 16)
uintptr_t *prev_fp = (uintptr_t *)*(fp - 2);
// 打印RA(实际应用中可解析为函数名)
printf("#%d: [FP=0x%lx] RA = 0x%lx\n", i, (uintptr_t)fp, ra);
// 检查prev_fp有效性(如对齐、地址范围)
if ((uintptr_t)prev_fp <= (uintptr_t)fp || prev_fp == NULL) break;
fp = prev_fp;
}
}
细节剖析
uintptr_t ra = *(fp - 1);
// 获取前一个FP(prev_fp = fp - 16)
uintptr_t *prev_fp = (uintptr_t *)*(fp - 2);
// 检查prev_fp有效性(如对齐、地址范围)
if ((uintptr_t)prev_fp <= (uintptr_t)fp || prev_fp == NULL) break;
fp = prev_fp;
1.为什么ra 和prev_fp是 *(fp -1) 和*(fp -2)
对于普通的函数调用(这点很重要,因为ISR,Excpetion可能是另外的情况,下文会展开)来说,程序运行时首先开辟一块栈,将sp指向栈顶,然后将返回地址ra入栈,fp入栈;最后将fp指向栈底,栈底的第一个元素就是ra,第二个元素则是fp,至于为什么不是*fp和*(fp -1),那是因为这是一个自减栈,高地址是栈底,低地址则是栈顶。
2.检查prev_fp有效性
代码的演示中只是简单判断了prev_fp是否非NULL,实际使用中还可以根据栈的地址对齐进行额外校验,例如你的栈如果是16字节对齐,则可以判断prev_fp是否整除16
.stack (NOLOAD) : {
. = ALIGN(16);
__stack_base__ = .;
. += STACK_SIZE;
. = ALIGN(16);
PROVIDE (_stack = .);
PROVIDE (_stack_safe = .);
} > DLM
Trap的特殊处理
概述
RISC-V的trap发生时,会进入trap_handler,然后根据发生trap的原因(中断还是异常),进入不同的处理函数,笔者所使用的RISC-V芯片开启了中断向量,因此中断并不会进入trap_handler,而会直接进入中断处理函数,因此对两种情况分开阐述。
中断
当开启中断向量且中断发生后,芯片根据中断id号跳转至中断处理函数。由于中断的退出是通过mret跳转至mepc所指向的地址继续运行,因此在进入中断时并不会保存ra的地址。我们看一段中断的汇编代码如下
可以看到,由于中断是随时发生的,系统并不会保存ra,只保存了s0(也就是fp),对ra的压栈发生在中断对上下文进行保存的过程中,是我们手动实现的,而绿色方框外的代码则是自动生成的。比起常规的函数调用,自动生成的代码中少了对ra的压栈,因此prev_fp是 *(fp -1) 和而非*(fp -2)
异常
和中断类似,异常发生时会进入trap_handler,汇编代码如下
同样的,s0和常规函数调用的压栈顺序发生的变化,prev_fp是 *(fp -5) 和而非*(fp -2)
演示代码修改
在上文我们提供了backtrac的演示代码,他能很好地处理函数调用的栈回溯,但知道了trap需要的特殊处理后,我们需要对该代码进行修改,根据不同情况对prev_fp取值进行修改,代码如下所示:
void backtrace() {
uintptr_t *fp;
// 内联汇编获取当前FP(s0寄存器)
asm volatile ("mv %0, s0" : "=r"(fp));
printf("Backtrace:\n");
//如果FP>=栈底,则意味着函数调用链已经结束
while (uintptr_t)FP < (uintptr_t)estack) {
uint32_index = 0;
long *rs0;
uint32_t pos[3] = {1,2,5};
//根据不同情况找到prev_fp所在的位置
for (uint32_t i = 0; i < sizeof(pos) / sizeof(pos[0]); i++) {
rs0 = (long *) *(b - pos[i]);
if (rs0 > (long *) *estack)
continue;
if (rs0 <= b)
continue;
if (((char *)rs0 - (char *)b) % 16 == 0) {
index = pos[i];
}
}
//isr并没有对ra自动压栈,在保存上下文时保存了ra
if (index == 1) {
ra = (unsigned long) *SP;
} else {
ra = (unsigned long) *(FP - 1);
}
if (ra == 0) break;
// 获取前一个FP(prev_fp = fp - index)
uintptr_t *prev_fp = (uintptr_t *)*(fp - index);
// 打印RA(实际应用中可解析为函数名)
printf("#%d: [FP=0x%lx] RA = 0x%lx\n", i, (uintptr_t)fp, ra);
// 检查prev_fp有效性(如对齐、地址范围)
if ((uintptr_t)prev_fp <= (uintptr_t)fp || prev_fp == NULL) break;
fp = prev_fp;
}
}