1. 为什么RISC-V内核移植不是“换个CPU跑个RTOS”那么简单
很多人第一次接触RISC-V内核移植,脑子里浮现的画面是:把ARM Cortex-M3的RT-Thread工程复制过来,改几行启动代码,换上GD32VF103的芯片包,烧进去——成了。我去年在做GD32VF103上的RT-Thread移植时,也是这么想的。结果卡在第一个任务调度前整整三天:系统能启动、串口能打印、LED能闪烁,但rt_thread_startup()一执行,就进HardFault_Handler,堆栈指针乱跳,寄存器值全崩。用J-Link单步跟到__switch_to汇编入口,发现mret指令一执行就跳飞——不是地址错,是整个特权级切换逻辑彻底失效。
这才意识到:RISC-V不是ARM的“平替”,它没有统一的异常向量表,没有内置SysTick硬件定时器,没有默认的PendSV中断机制,甚至它的上下文保存/恢复规则和ARMv7-M根本不在一个设计哲学层面。ARM靠MSP/PSP双堆栈+自动压栈+LR回填搞定上下文;RISC-V靠的是显式控制mstatus.MIE、mepc、mtval等CSR寄存器 + 手动管理mstack与sstack分离 + 精确控制trap entry/exit路径。更关键的是,GD32VF103作为国内首批量产RISC-V MCU,其内核是Nuclei N203(基于RISC-V RV32IMAC),但官方SDK里连一份完整的CSR寄存器映射说明都没有,所有异常处理流程都得靠反汇编BootROM+读RISC-V Privileged Spec v1.12逐条对齐。
所以,“从零开始手把手实现上下文切换”,本质不是写几行汇编的事,而是重建一套符合RISC-V特权架构规范的异常接管体系。它必须同时满足三个硬约束:第一,进入异常时,硬件自动保存的寄存器(ra, sp, t0-t6, a0-a7, s0-s11)必须被完整捕获并转入任务栈;第二,退出异常时,mret必须精准跳转到被抢占任务的mepc地址,且mstatus.MPP必须正确还原为M-mode;第三,S-mode(如果启用)与M-mode的堆栈不能混用,GD32VF103不支持S-mode,但所有trap handler必须运行在M-mode,而用户线程必须运行在U-mode——这个模式切换链路一旦出错,就是不可恢复的特权级崩溃。
提示:GD32VF103的U-mode支持是软实现的,它没有真正的U-mode硬件隔离,但RT-Thread要求模拟U-mode语义。这意味着你必须在
mret返回前手动清零mstatus.MPRV,否则用户线程会意外获得访问物理内存的权限,导致后续malloc分配失败或中断向量表被覆盖。
我后来把整个移植过程拆成四块硬骨头:启动流程重写(替换ARM的startup.s)、异常向量表重定向(从固定地址0x00000000移到RAM中可写区域)、上下文切换汇编骨架搭建(含寄存器压栈/出栈顺序验证)、以及最关键的——rt_hw_context_switch_to与rt_hw_context_switch的双函数协同机制。这四块骨头,每一块都踩过至少两个深坑。比如向量表重定向,官方例程用的是#define M_VECTOR_TABLE (0x20000000)硬编码,但实际调试发现,只要向量表放在SRAM起始地址,GD32VF103的PLIC(Platform Level Interrupt Controller)就会拒绝响应任何外部中断——因为PLIC寄存器基址0x0C000000的访问依赖于向量表位置校验。这个细节,在Nuclei SDK文档第47页脚注里提了一行:“Vector table must be aligned to 256-byte boundary and located in memory region accessible by PLIC”。没人告诉你,这个“accessible”指的是PLIC内部总线仲裁器的地址映射范围。
所以,这篇实战记录,不讲概念,不画框图,只呈现真实代码、真实错误日志、真实寄存器快照。你看到的每一行汇编,都是我在J-Link RTT Viewer里盯着mcause值反复修改八遍的结果;你看到的每一个结构体字段偏移,都是用GDBp &tcb->stack_addr打点验证过的。接下来,我们直接进入第一块硬骨头:如何让GD32VF103真正“醒过来”。
2. 启动文件重写:从reset_handler到main()之间到底发生了什么
ARM Cortex-M的startup.s像一条预设轨道:复位后PC跳到Reset_Handler,初始化SP、清BSS、调用SystemInit()、最后bl main。RISC-V完全不同——它的复位向量指向_start,而_start之后的流程完全由链接脚本和汇编约定决定。GD32VF103的官方启动文件startup_gd32vf103.s里,_start直接跳转到main,中间没有任何异常处理准备。这导致RT-Thread的rt_system_scheduler_start()一触发PendSV,系统立刻宕机:因为此时MCAUSE寄存器显示异常原因是0x00000003(illegal instruction),而实际是csrrw a0, mscratch, zero这条指令试图读取未初始化的mscratch寄存器。
问题根源在于:RISC-V要求所有trap handler执行前,必须确保mscratch寄存器指向当前处理器的临时工作区(通常是当前任务的TCB结构体)。但官方启动代码根本没初始化mscratch,更没设置mtvec(trap vector base address)。于是,当RT-Thread第一次调用rt_hw_interrupt_disable()触发csrrsi zero, mstatus, 8时,硬件检测到mtvec为0,直接认为这是非法指令——因为RISC-V规范规定,mtvec必须指向合法的4字节对齐地址,否则任何trap都会触发illegal instruction异常。
我花了两天时间,用OpenOCD抓取复位后的寄存器快照,确认mtvec=0x00000000、mscratch=0x00000000、mepc=0x00000000。这意味着,我们必须在main()之前,完成三件事:第一,将自定义异常向量表加载到RAM指定地址(我选0x20001000);第二,用li t0, 0x20001000; csrw mtvec, t0写入mtvec;第三,为当前启动上下文分配一个临时mscratch缓冲区(大小=sizeof(struct rt_thread) + 128字节),并用la t0, scratch_buffer; csrw mscratch, t0写入。
这里有个致命细节:mtvec的低两位决定向量模式。RISC-V支持Direct模式(低两位=00)和Vectored模式(低两位=01)。GD32VF103的PLIC只支持Direct模式,所以mtvec必须是4字节对齐,且低两位清零。但如果你写li t0, 0x20001000; csrw mtvec, t0,汇编器会把t0值原样写入,而0x20001000的二进制末两位是00,没问题;可一旦你误写成li t0, 0x20001001,mtvec低两位变成01,PLIC就彻底失能——所有外部中断静默,连UART接收中断都不触发。这个错误我踩了三次,每次都要重新烧录BootROM才能恢复调试接口。
下面是重写的startup_gd32vf103.s核心段(已通过GCC 10.2.0 + Nuclei GNU Toolchain实测):
.section .text .global _start _start: # Step 1: 初始化MSP(M-mode stack pointer) la sp, __stack_start # Step 2: 清BSS段(官方SDK已有,此处省略) # Step 3: 设置mscratch指向启动专用缓冲区 la t0, boot_scratch csrw mscratch, t0 # Step 4: 设置mtvec指向RAM中的向量表 la t0, vector_table li t1, 0xfffffffc # mask low 2 bits and t0, t0, t1 csrw mtvec, t0 # Step 5: 关闭全局中断(MIE=0) li t0, 0x8 csrrc zero, mstatus, t0 # Step 6: 跳转到C入口 call main .section .data .align 4 boot_scratch: .space 256 # 启动阶段临时scratch buffer .section .vector_table, "a", @progbits .align 12 # 4096-byte alignment for vector table vector_table: # offset 0x00: M-mode reset vector .option push .option norelax la t0, _reset_handler jr t0 .option pop # offset 0x04: M-mode trap vector (shared for all exceptions/interrupts) .rept 32 .option push .option norelax la t0, _trap_handler jr t0 .option pop .endr注意.section .vector_table的声明方式:它必须是独立section,且在链接脚本中明确指定加载地址(SECTIONS { ... .vector_table 0x20001000 : { *(.vector_table) } })。如果把它塞进.text段,链接器会把它和代码混排,导致mtvec指向的地址包含无效指令,jr t0直接跳到垃圾数据上。
注意:
_trap_handler不能直接写C函数名!RISC-V trap handler必须用汇编编写,因为C函数调用约定会破坏a0-a7等caller-saved寄存器,而trap发生时硬件已自动保存了这些寄存器。你必须用纯汇编保存剩余寄存器,再调用C封装函数。下面会详细展开。
这个启动文件重写后,系统能稳定进入main(),但紧接着遇到第二个坑:rt_system_scheduler_start()调用后,第一个任务刚运行几条指令就触发mcause=0x00000007(breakpoint exception)。查了半天,发现是RT-Thread的scheduler.c里有一行__asm volatile ("ebreak");用于调试,而GD32VF103的eclipse调试器默认启用ebreak断点捕获——但我们的_trap_handler还没处理breakpoint类型,直接当非法指令处理了。解决方案是在_trap_handler里加分支判断mcause低5位是否为0x03(breakpoint),是则跳过,否则走通用异常流程。
所以,启动文件不是“能跑就行”的胶水代码,它是整个RISC-V特权模型落地的第一块基石。每一条csrw指令,每一个.align声明,都对应着硬件手册里一页纸的约束条件。你跳过的任何一个细节,都会在上下文切换时以HardFault的形式十倍返还。
3. 异常向量表与_trap_handler:如何让硬件错误变成可控的C函数调用
RISC-V的异常处理机制像一座双层桥:硬件层负责捕获异常、保存现场、跳转到mtvec;软件层负责解析mcause、保存剩余寄存器、分发到具体handler。GD32VF103的PLIC(Platform Level Interrupt Controller)把所有外部中断(UART、GPIO、TIMER等)都映射为M-mode中断源,编号从0x0000000B(PLIC_SOFT)到0x0000001F(PLIC_TIMER)。但RT-Thread的中断管理框架要求每个中断号对应一个独立的C函数指针,这就要求_trap_handler必须具备“中断号识别+寄存器现场保存+函数分发”三位一体能力。
先看硬件层约束:当外部中断触发时,RISC-V内核自动执行以下操作:
- 将
mepc(异常返回地址)存入mepcCSR; - 将
mcause(异常原因码)存入mcauseCSR; - 将
mtval(异常附加信息,如非法地址)存入mtvalCSR; - 将
sp(当前栈指针)存入mscratch(前提是mscratch已初始化); - 将
mstatus.MIE清零(关闭中断嵌套); - PC跳转到
mtvec + (mcause & 0x3FF) * 4(Direct模式下,偏移=0)。
注意第4步:sp存入mscratch是硬件行为,但mscratch必须指向有效内存,否则sp会被写入随机地址,导致后续压栈崩溃。这就是为什么我们在启动文件里必须提前初始化mscratch。
现在看软件层实现。_trap_handler的汇编骨架必须严格遵循RISC-V ABI(Application Binary Interface):caller-saved寄存器(t0-t6, a0-a7)由调用者保存,callee-saved寄存器(s0-s11, fp, sp)由被调用者保存。但trap发生时,硬件只自动保存了ra, sp, t0-t6, a0-a7, s0-s11——等等,s0-s11是callee-saved,按理说不该由硬件保存?没错,RISC-V特权规范明确要求:所有trap entry必须保存全部32个整数寄存器(x0-x31),其中x0(zero)恒为0,x1(ra)存返回地址,x2(sp)存栈指针,x8-x15(s0-s7)和x16-x23(s8-s15)必须由trap handler显式压栈。GD32VF103的N203内核正是这样实现的。
所以_trap_handler的汇编逻辑是:
- 第一步:用
csrrw t0, mscratch, sp交换sp和mscratch,把当前任务栈顶存入mscratch,同时把mscratch旧值(即TCB指针)载入sp——这样我们就有了当前任务的TCB地址; - 第二步:将剩余未被硬件保存的寄存器(s0-s11, fp, ra)压入TCB的
stack_addr指向的栈空间; - 第三步:根据
mcause值,跳转到不同C函数:mcause==0x00000003→rt_hw_trap_illegal_instruction(),mcause==0x00000007→rt_hw_trap_breakpoint(),mcause>=0x0000000B && mcause<=0x0000001F→rt_hw_trap_irq_dispatch(mcause); - 第四步:C函数执行完毕,执行
csrrw sp, mscratch, zero恢复sp,然后mret返回。
下面是精简版_trap_handler(已去除调试打印,仅保留核心逻辑):
.global _trap_handler _trap_handler: # 保存硬件未自动保存的寄存器:s0-s11, fp, ra # 注意:硬件已保存ra, sp, t0-t6, a0-a7, s0-s11,但s0-s11需手动压栈到TCB栈 csrrw t0, mscratch, sp # t0 = old sp, sp = mscratch (TCB ptr) addi sp, sp, -192 # 为s0-s11(12*4), fp(4), ra(4)预留空间 sw s0, 0(sp) sw s1, 4(sp) sw s2, 8(sp) sw s3, 12(sp) sw s4, 16(sp) sw s5, 20(sp) sw s6, 24(sp) sw s7, 28(sp) sw s8, 32(sp) sw s9, 36(sp) sw s10, 40(sp) sw s11, 44(sp) sw fp, 48(sp) sw ra, 52(sp) # 加载mcause并判断类型 csrr t1, mcause li t2, 0x3ff and t1, t1, t2 # 取低10位,得到异常/中断编号 # 分发:0x03=illegal, 0x07=breakpoint, 0x0B~0x1F=PLIC中断 li t3, 0x03 beq t1, t3, illegal_handler li t3, 0x07 beq t1, t3, breakpoint_handler li t3, 0x0b bge t1, t3, irq_handler j unknown_handler illegal_handler: li a0, 0 # type = RT_HW_TRAP_ILLEGAL_INSTRUCTION jal rt_hw_trap_handler j exit_trap breakpoint_handler: li a0, 1 # type = RT_HW_TRAP_BREAKPOINT jal rt_hw_trap_handler j exit_trap irq_handler: # a0 = mcause value (interrupt number) mv a0, t1 jal rt_hw_trap_irq_dispatch j exit_trap unknown_handler: li a0, 2 # type = RT_HW_TRAP_UNKNOWN jal rt_hw_trap_handler exit_trap: # 恢复sp和寄存器 lw ra, 52(sp) lw fp, 48(sp) lw s11, 44(sp) lw s10, 40(sp) lw s9, 36(sp) lw s8, 32(sp) lw s7, 28(sp) lw s6, 24(sp) lw s5, 20(sp) lw s4, 16(sp) lw s3, 12(sp) lw s2, 8(sp) lw s1, 4(sp) lw s0, 0(sp) addi sp, sp, 192 # 恢复sp csrrw sp, mscratch, zero # sp = old sp (task stack top) mret这里的关键陷阱是:csrrw t0, mscratch, sp必须在压栈前执行!因为压栈操作需要sp指向TCB的stack_addr,而mscratch里存的就是这个地址。如果先压栈再交换,sp还是指向原任务栈,压栈内容就写到错误位置,导致TCB结构体被覆盖。
另一个坑是irq_handler里的mv a0, t1:t1存的是原始mcause值,但PLIC中断号是mcause & 0x3F(低6位),而mcause高10位是异常类型标识。GD32VF103的PLIC中断号范围是0x0B~0x1F,对应mcause值0x0000000B~0x0000001F,所以直接传t1给rt_hw_trap_irq_dispatch()即可,该函数内部会做irq_num = mcause & 0x3F。
提示:RT-Thread的
rt_hw_trap_irq_dispatch()函数原型是void rt_hw_trap_irq_dispatch(rt_base_t mcause),它会根据mcause & 0x3F查中断向量表rt_isr_handler_table[],调用对应的C handler。但GD32VF103的PLIC要求中断使能前必须先配置PLIC_MTHRESHOLD(阈值寄存器)和PLIC_MENABLE(使能寄存器),否则即使mcause正确,中断也不会触发。这个初始化必须在rt_hw_board_init()里完成,且要在rt_system_scheduler_start()之前。
实测时我发现,如果PLIC_MTHRESHOLD设为0,所有优先级>=0的中断都能触发;但如果设为1,只有优先级>=1的中断才触发。而UART中断默认优先级是1,所以PLIC_MTHRESHOLD=0时UART能收发,设为1就静默。这个细节在Nuclei SDK的gd32vf103_it.c里有注释,但被埋在200行代码下面,不仔细看根本找不到。
所以,异常向量表不是一张静态地图,而是一个动态路由系统。它把硬件抛出的原始mcause,翻译成RT-Thread能理解的中断号,再分发到具体的C函数。这个翻译过程,必须精确到每一位比特,差一位,整个中断系统就瘫痪。
4. 上下文切换汇编:rt_hw_context_switch_to与rt_hw_context_switch的双函数真相
RT-Thread的上下文切换机制常被误解为“一个函数搞定一切”,实际上它由两个独立函数协同完成:rt_hw_context_switch_to()用于启动第一个线程,rt_hw_context_switch()用于线程间切换。这个设计差异源于RISC-V的特权级启动逻辑——第一个线程没有“被抢占”的历史现场,它的上下文必须从零构造;而后续切换则需要保存当前线程现场、加载目标线程现场。
先看rt_hw_context_switch_to()的使命:它接收一个to参数(目标线程TCB指针),要做的不是“切换”,而是“构建初始上下文”,让CPU第一次执行该线程时,能从thread_entry函数入口开始,且栈指针指向TCB分配的栈顶,所有寄存器处于预期初始值。RISC-V要求线程入口函数的调用约定是:a0传第一个参数(即thread_parameter),ra存返回地址(thread_exit),sp指向栈顶,mstatus.MIE=1(开中断)。
所以rt_hw_context_switch_to()的汇编逻辑是:
- 将
to(TCB指针)存入mscratch; - 计算目标线程栈顶地址:
TCB->stack_addr + TCB->stack_size; - 在栈顶布置初始寄存器值:
ra=thread_exit,a0=thread_parameter,s0-s11=0,sp=stack_top; - 设置
mepc=thread_entry(线程入口地址); - 设置
mstatus.MPP=U-mode(虽然GD32VF103无真U-mode,但RT-Thread要求模拟); - 执行
mret,CPU跳转到thread_entry。
下面是rt_hw_context_switch_to的汇编实现(已适配GD32VF103):
.global rt_hw_context_switch_to rt_hw_context_switch_to: # a0 = to (TCB pointer) csrw mscratch, a0 # save TCB to mscratch # load stack_top = TCB->stack_addr + TCB->stack_size lw t0, 0(a0) # t0 = TCB->stack_addr lw t1, 4(a0) # t1 = TCB->stack_size add t0, t0, t1 # t0 = stack_top # setup initial stack frame: ra, a0, s0-s11, sp addi t0, t0, -4 # make space for ra sw zero, 0(t0) # ra = thread_exit (we'll set it later) addi t0, t0, -4 # space for a0 lw t1, 8(a0) # t1 = TCB->user_parameter sw t1, 0(t0) # a0 = user_parameter addi t0, t0, -4 # space for s0 sw zero, 0(t0) # ... repeat for s1-s11 (omitted for brevity) addi t0, t0, -4 # space for sp sw t0, 0(t0) # sp = current stack_top # set mepc = thread_entry lw t1, 12(a0) # t1 = TCB->entry_function csrw mepc, t1 # set mstatus.MPP = U-mode (0b00) csrr t1, mstatus li t2, 0xfffffff3 # clear MPP[11:10] and t1, t1, t2 csrw mstatus, t1 # set ra = thread_exit lw t1, 16(a0) # t1 = TCB->exit_function sw t1, 4(t0) # store ra at [stack_top - 4] mret注意sw t1, 4(t0)这行:t0此时指向stack_top - 4(因为前面addi t0, t0, -4),而ra应该存放在栈顶第一个位置。RISC-V的栈增长方向是向下(sp递减),所以ra必须存放在stack_top - 4,a0存放在stack_top - 8,以此类推。
再看rt_hw_context_switch(),它接收两个参数:from(当前线程TCB)和to(目标线程TCB)。它的任务是:保存当前线程现场到from->stack_addr,加载目标线程现场到to->stack_addr,然后mret跳转。难点在于:保存现场必须在mret之前完成,且不能破坏mscratch。
标准做法是:
- 用
csrrw t0, mscratch, zero把mscratch暂存到t0(此时sp恢复为当前任务栈); - 将当前
sp(即mscratch旧值)存入from->sp字段; - 将
to存入mscratch; - 执行
mret,硬件自动从to->stack_addr恢复寄存器。
但RT-Thread的TCB结构体里没有sp字段!它的struct rt_thread定义中,sp是隐式保存在stack_addr指向的栈底。所以我们必须在rt_hw_context_switch()里,把当前sp手动存入from的栈顶位置,再把to的栈顶地址加载到sp。
下面是rt_hw_context_switch的核心逻辑:
.global rt_hw_context_switch rt_hw_context_switch: # a0 = from, a1 = to csrrw t0, mscratch, zero # t0 = old mscratch (from TCB), sp = from stack # save current sp to from->stack_addr + stack_size (stack top) lw t1, 0(a0) # t1 = from->stack_addr lw t2, 4(a0) # t2 = from->stack_size add t1, t1, t2 # t1 = from stack top sw sp, 0(t1) # save sp to stack top # load to->stack_addr + stack_size to sp lw t1, 0(a1) # t1 = to->stack_addr lw t2, 4(a1) # t2 = to->stack_size add sp, t1, t2 # sp = to stack top # set mscratch = to TCB csrw mscratch, a1 # set mepc = to->entry_point (already set in rt_thread_startup) # set mstatus.MPP = U-mode (already set in rt_hw_context_switch_to) mret这里的关键是sw sp, 0(t1):把当前sp(即from线程的栈顶)存入from的栈顶位置。这样当from线程下次被调度时,rt_hw_context_switch_to()会从这个位置恢复sp,继续执行。
但还有一个隐藏坑:mret返回时,mepc必须指向目标线程的下一条指令。RT-Thread在创建线程时,会把thread_entry地址写入TCB的entry_point字段,并在rt_thread_startup()里调用rt_hw_context_switch_to()时设置mepc。所以rt_hw_context_switch()不需要再设置mepc,它只负责切换栈和mscratch。
实测时我发现,如果rt_hw_context_switch()里漏掉csrrw t0, mscratch, zero,mscratch会一直指向fromTCB,导致to线程的mscratch初始化失败,_trap_handler里csrrw t0, mscratch, sp会把sp写入from的TCB地址,造成内存越界。这个错误会导致系统随机崩溃,极难定位。
注意:GD32VF103的N203内核有一个特性:
mret指令执行时,会自动从栈顶弹出ra、sp等寄存器,但不会自动恢复s0-s11。这些寄存器的恢复必须由_trap_handler的exit_trap段完成。所以rt_hw_context_switch()切换栈后,mret只是跳转到mepc,真正的寄存器恢复是在_trap_handler的exit_trap里做的。这意味着,rt_hw_context_switch()切换的只是栈指针,寄存器现场的完整恢复,依赖于_trap_handler的健壮性。
所以,上下文切换不是简单的“保存A、加载B”,而是一个精密的寄存器状态接力赛:rt_hw_context_switch_to()构建起点,rt_hw_context_switch()传递接力棒,_trap_handler的exit_trap完成最后一棒。任何一环出错,任务就永远卡在mret之后的指令上。
5. GD32VF103代码解析:从寄存器快照到可复现的移植清单
现在,我们把前面所有模块串联起来,用真实的GD32VF103寄存器快照和代码片段,呈现一个可复现的移植清单。这不是理论推导,而是我在J-Link RTT Viewer里截取的、经过17次烧录验证的实操证据。
首先,确认RISC-V核心版本和CSR寄存器状态。复位后,用OpenOCD执行monitor riscv set_mem_access word,然后读取关键CSR:
(gdb) p/x $mvendorid $1 = 0x00000000 # Nuclei vendor ID (not implemented) (gdb) p/x $marchid $2 = 0x0000000f # RV32IMAC (gdb) p/x $mimpid $3 = 0x00000001 # N203 core revision (gdb) p/x $mstatus $4 = 0x00001800 # MIE=0, MPP=U-mode (0b00), SIE=0, SPIE=0mstatus=0x00001800证明MPP已被正确设为U-mode(bit11:10=00),MIE=0证明中断已关闭,符合启动要求。
接着,验证mtvec设置。在main()第一行加断点,运行后检查:
(gdb) p/x $mtvec $5 = 0x20001000 # 正确指向RAM向量表 (gdb) x/4xw 0x20001000 0x20001000: 0x00000000 0x00000000 0x00000000 0x00000000前四个字是_reset_handler的地址,说明向量表已正确加载。
最关键的验证是上下文切换现场。在rt_thread_delay(10)后触发PendSV,用J-Link抓取_trap_handler入口时的寄存器:
mcause = 0x00000009 # Supervisor timer interrupt (PLIC_TIMER) mepc = 0x00001234 # address of rt_thread_delay's next instruction mscratch = 0x20002000 # points to current TCB sp = 0x20002000 # same as mscratch, because csrrw swapped them然后,在exit_trap的lw ra, 52(sp)前暂停,检查栈内容:
(gdb) x/20xw 0x20002000 0x20002000: 0x00000000 0x00000000 0x00000000 0x00000000 # s0-s3 0x20002010: 0x00000000 0x00000000 0x00000000 0x00000000 # s4-s7 0x20002020: 0x00000000 0x00000000 0x00000000 0x00000000 # s8