目录
RISC-V栈帧结构分析
开启编译优化后FP当成普通寄存器
实现自己的异常处理栈回溯函数
异常处理打印上下文和任务栈验证
前面两篇文章分析了RISC-V上FreeRTOS的启动以及OS任务切换的处理流程,本文基于之前的分析,实现自己的栈回溯函数,程序出错陷入异常处理中,打印处函数调用关系及其上下文数据,方便以后问题调试。还是基于qemu模拟器,使用FreeRTOS官方标准发布包的FreeRTOS/Demo/RISC-V-Qemu-virt_GCC工程调试。
写一个简单的test.c
long test_fun_b(long x, long y)
{
printf("%s x:%ld y:%ld\r\n", __func__, x, y);
return x+y;
}
long test_fun_a(long m, long n, long x, long y)
{
long b = 10;
long c = 11;
b = b + c + m;
c = b + c + n;
test_fun_b(b, c);
printf("%s b:%ld c:%ld\r\n", __func__, b, c);
return b+c;
}
long test_fun_c(long x0, int x1, short x2, char x3, const char* x4, int x5, int x6, int x7, char x8)
{
long b;
long c;
b = x0 + x1 + x2 + x3;
c = x5 + x6 + x7 + x8;
printf("%s b:%ld c:%ld %s x4:%d\r\n", __func__, b, c, x4, x8);
b = test_fun_a(b, c, 0, 2);
return b+c;
}
int main(void)
{
test_fun_c(1, 2, 3, 4, "test", 6, 7, 8, 9);
return 0;
}
使用RISC-V Linux编译器编译:riscv32-unknown-linux-gnu-gcc test.c -o rv_test,
之后反汇编:riscv32-unknown-linux-gnu-objdump -S -d rv_test > rv_test.dsi
截取部分反汇编代码如下:
从进入函数test_fun_a开始分析,如下图所示,假设此时sp处在1位置,
执行完115行
103f8: 7179 addi sp,sp,-48
sp移动到了上图2位置,也就是在栈中留出了test_fun_a的栈帧,继续执完接下来两行代码,
103fa: d606 sw ra,44(sp)
103fc: d422 sw s0,40(sp)
则,ra和s0(s0也就是fp)都保存到当前函数栈帧中,如上图所示的位置。接下来执行
103fe: 1800 addi s0,sp,48
s0的值更新了,指向了函数栈帧的开始位置,也就是上图sp位置1。继续执行,保存函数局部变量、参数a0,a1...等,直到141行调用函数test_fun_b时,test_fun_a的栈帧大致如下:
继续执行,进入函数test_fun_b,执行完test_fun_b前面3行代码,
103be: 1101 addi sp,sp,-32
103c0: ce06 sw ra,28(sp)
103c2: cc22 sw s0,24(sp)
此时s0的值还未修改,sw s0,24(sp)压栈保存的是test_fun_a栈帧的起始位置,栈帧如下图所示。
继续执行完
103c4: 1000 addi s0,sp,32
之后,s0又指向test_fun_b的栈帧起始位置,如下图所示:
如此循环,相当于一个链表把该任务的栈帧给串起来。发生错误时,我们直接可以找到任务函数栈帧的起始位置s0和任务栈的栈顶指针sp,也可以找到该函数的返回地址ra,通过ra的值也就找到了该函数的调用者,就可以分析出函数调用关系。
下面是一张RV64和ARM64栈帧对比图:
以上分析用的是基于riscv32-unknown-linux-gnu-gcc编译器。FreeRTOS/Demo/RISC-V-Qemu-virt_GCC工程用的是riscv64-unknown-elf-gcc编译器,把上面的代码放入main_blinky.c中prvQueueSendTask中,
可以看到栈帧保存方式也是一样的,也就是符合上面的规则栈帧保存规则。
后来发现,CFLAGS += -O2 开启O2优化编译后反汇编,s0/fp被作为普通寄存器使用了,反汇编代码如下:
可以看到s0被当作普通寄存器使用了,也就是没有了fp指针,但是ra还是符合上面的规则,放在函数栈帧的起始位置,而且反汇编中找不到test_fun_a和test_fun_b,实际上这两个函数被展开到test_fun_c里面了。这样任务栈如下图所示(s0作为普通寄存器,没有了fp指针):
虽然riscv64-unknown-elf-gcc开启O2优化后没有fp指针,但是通过sp指针,结合反汇编代码还是可以找ra(反汇编代码函数入口第一行addi sp,sp,-x,x即为函数栈帧的大小,sp还是指向栈顶),也就可以找到函数的Caller,可以手动完成栈回溯。
关于fp指针,可以参考文章[GCC入坑指南] -fomit-frame-pointer 和 -fno-omit-frame-pointer 编译标识 - 简书
基于以上的分析,我们就是可以实现异常处理栈回溯函数。在前一篇文章RISC-V FreeRTOS异常处理及任务切换分析(基于qemu+gdb跟踪调试)_Dingjun798077632的博客-CSDN博客中分析了,同步异常处理的代码如下:
synchronous_exception -> handle_exception,如果不是ecall触发的异常则调用application_exception_handler -> freertos_risc_v_application_exception_handler。
函数freertos_risc_v_application_exception_handler是一个.weak 函数,最终进入死循环。
所以,我们实现自己的freertos_risc_v_application_exception_handler函数,打印异常上下文信息与栈回溯。
函数实现如下:参数mcause异常原因、mepc异常返回地址、mtval异常值寄存器(比如非法内存地址访问时,保存该内存地址值)、mstatus机器状态、pxTopOfStack异常发生时任务栈栈顶指针。
void freertos_risc_v_application_exception_handler(UBaseType_t mcause, UBaseType_t mepc,
UBaseType_t mtval, UBaseType_t mstatus, StackType_t * pxTopOfStack)
{
#if __riscv_xlen == 64
#define portWORD_SIZE 8
#define store_x sd
#define load_x ld
#elif __riscv_xlen == 32
#define store_x sw
#define load_x lw
#define portWORD_SIZE 4
#else
#error Assembler did not define __riscv_xlen
#endif
#define portCONTEXT_SIZE ( 31 * portWORD_SIZE )
#define portCONTEXT_COUNT ( portCONTEXT_SIZE/portWORD_SIZE )
int i;
TaskStatus_t TaskStatus;
UBaseType_t reg;
StackType_t* pStack;
UBaseType_t* fp;
xprintf("\033[0m\033[1;31m%s\033[0m","Panic: Freertos risc-v application exception handler\n");
//xprintf("Panic: Freertos risc-v application exception handler\n");
xprintf("mcause: 0x%lx\nmepc: 0x%lx\nmtval: 0x%lx\nmstatus:0x%lx\npxTopOfStack: %p\n", mcause, mepc - portWORD_SIZE,
mtval, mstatus, pxTopOfStack);
/*
参考portcontextRESTORE_CONTEXT中恢复context寄存器的顺序,打印异常发生前的各通用寄存器值
load_x x1, 1 * portWORD_SIZE( sp )
load_x x5, 2 * portWORD_SIZE( sp )
load_x x6, 3 * portWORD_SIZE( sp )
load_x x7, 4 * portWORD_SIZE( sp )
load_x x8, 5 * portWORD_SIZE( sp )
load_x x9, 6 * portWORD_SIZE( sp )
...
...
*/
xprintf("The exception context regs:\n");
for (i = 1; i <= 28; i++){
reg = *((UBaseType_t*)pxTopOfStack + i);
if (i >= 2) {
xprintf("x%d: 0x%lx\n", i+3, reg);
if (i+3 == 8)
fp = (UBaseType_t*)reg; //Risc-V寄存器x8即是s0/FP
} else {
xprintf("x%d: 0x%lx\n", i, reg);
}
}
if (xTaskGetSchedulerState() != taskSCHEDULER_NOT_STARTED )
{
vTaskGetInfo(NULL, &TaskStatus, pdTRUE, eInvalid);
xprintf("Ther current task is %s, StackBase:%p, StackEnd:%p, StackTop:%p, StackHighWaterMark:%d\n",
TaskStatus.pcTaskName, TaskStatus.pxStackBase, TaskStatus.pxEndOfStack, pxTopOfStack, TaskStatus.usStackHighWaterMark);
if (TaskStatus.pxEndOfStack < pxTopOfStack + portCONTEXT_COUNT) {
xprintf("Error: he current task stack error pxEndOfStack:%p pxTopOfStack:%p\n", TaskStatus.pxEndOfStack, pxTopOfStack);
goto loop;
}
i = 0;
//循环打印出函数的fp和ra返回地址, TaskStatus.pxEndOfStack为task栈底(最高地址)
/*
这部分通过fp查找ra开启O1或者O2优化后有问题,可以去掉(刚开始写文章时没发现开启优化fp被作为普指针)
*/
xprintf("Ther current task bactrace:\n");
while (fp < TaskStatus.pxEndOfStack)
{
xprintf("fun%d: FramePointer:0x%lx\tReturnAddr:0x%lx\n", i++, fp, *(fp-1));
fp = (UBaseType_t*)*(fp-2);
}
//打印出当前栈数据,任务栈贞起始位置为pxTopOfStack + portCONTEXT_COUNT
xprintf("Ther current task Stack:");
i = 0;
for (pStack = pxTopOfStack + portCONTEXT_COUNT; pStack < TaskStatus.pxEndOfStack; pStack++)
{
reg = *(UBaseType_t*)pStack;
if (i++ % 8 == 0) {
xprintf("\n%p: 0x%lx ", pStack, reg);
} else{
xprintf("0x%lx ", reg);
}
}
xprintf("\n");
//任务栈打印完成后,pStack必然指向pxEndOfStack
if (pStack != TaskStatus.pxEndOfStack)
xprintf("Error: the pStack not pointer to the EndOfStack\n");
} else {
}
loop:
xprintf("Freertos risc-v application exception handler end\n");
while(1);
}
为了显眼,把freertos_risc_v_application_exception_handler的第一个打印Pannic用红色显示xprintf("\033[0m\033[1;31m%s\033[0m","Panic: Freertos risc-v application exception handler\n");
上面代码中xprintf是自己写的printf函数,在中断处理中用xprintf代替printf。上面的宏定义portCONTEXT_SIZE、 portWORD_SIZE是从FreeRTOS\Source\portable\GCC\RISC-V\portContext.h中copy过来的(和架构相关)。
portContext.h中的portCONTEXT_SIZE宏定义如下:
修改FreeRTOS\Source\portable\GCC\RISC-V\portASM.S的代码,在调用函数freertos_risc_v_application_exception_handler之前,通过a0-a4传入函数的参数(这里application_exception_handler之前a0已经保存为了mcause,a1保存的mepc + 4,之前的文章已经分析过了),portASM.S修改如下:
同时freertos_risc_v_application_exception_handler中打印了当前任务的状态信息,需要开启部分FreeRTOS的部分配置:
在FreeRTOS\Source\include\task.h增加代码
#elif ( configRECORD_STACK_HIGH_ADDRESS == 1 )
StackType_t * pxEndOfStack; /* Points to the end address of the task's stack area. */
修改后如下图(新增了57-58行):
打印任务栈时,需要通过vTaskGetInfo获取任务栈的栈底(栈起始位置)。
该函数代码FreeRTOS\FreeRTOS\Source\tasks.c
在之前测试函数test_fun_b中加入一条非法的指令,编译反汇编(这里需要在Makefile中将CFLAGS += -O2改为CFLAGS += -O0,关闭O2优化,否则test_fun_b和test_fun_a编译时会直接展开到test_fun_c,栈回溯没有了函数多重调用关系)。
下面的测试还是基于CFLAGS += -O0编译的,一开始没发现CFLAGS += -O2会把fp当作普通寄存器使用,下面演示略过fp栈回溯部分,通过sp对照反汇编找到ra,手动完成栈回溯,开启O2依然起效,文章都写好了,就懒得改了。
程序编译运行后,进入异常处理freertos_risc_v_application_exception_handler函数中打印如下(对照反汇编代码):
对照反汇编代码,可以看到触发异常的pc地址为0x800008b8,该地址在函数test_fun_b中,而且刚好是增加的错误代码asm volatile(".word 0x1234567")。上面打印的最后一个函数func0的地址ReturnAddr为0x80000948,0x80000948对应的代码恰好是test_fun_a中调用test_fun_b函数的下一条指令的地址。
对应的反汇编代码也可以看出,test_fun_b函数的栈帧大小为32byte, ra保存在sp偏移28的位置,从栈帧起始0x80082dd0 + 28byte的地方也是0x80000948。
同样的方法也可以找到test_fun_a的ReturnAddr为0x80000a34,依此类推,最终找到任务的入口函数,如下图。
当我们修改test_fun_b函数,改为非法地址访问时
重新编译运行,可以看到mcause为0x00000007,mtval为0x00000034即为上面非法访问的地址
mcause 0x00000007对应Store sccess faut,之前mcause 0x00000002对应Illegal instruction。
需要说明的是,一般Load和Store指令都是非精确异常,也就是mepc并不一定指向触发异常的指令地址,但mtval是准确的(指向访问出错的内存地址)。现代cpu为了提高效率,一般都支持超标量流水线,乱序执行,(比如当前乘法指令,下一条加法指令而且操作数不相干,乘法指令需要多个周期,加法指令就可能先执行完),加上Cach影响(比如当前指令Catch,下一条指令Catch命中)等...,cpu并不保证严格的遵守指令流水线。硬件外设驱动,有很多时候需要保证前一条指令先于后一条指令执行完,就需要用的Fence、Fence.i指令(对应arm也有dsb、dmb、isb指令),这里就不展开讨论了。
下面时平头哥玄铁E907(RISC-V架构,arm等其它处理器也有类似的特性)用户手册对精确或者非精确异常的描述: