Rust 自研 RISC-V 32 位内核上下文切换后触发对齐 panic,如何排查?
结论
这段代码存在明确的上下文保存和恢复错误。应先修复寄存器状态损坏,再判断是否还存在独立的栈对齐故障。即使所有结构体 offsets 都正确,当前实现也无法完整恢复被中断的任务。
is_aligned_to: align is not a power-of-two 表示传入对齐检查的 align 参数不是有效的非零 2 的幂,不能直接证明 sp 不对齐。寄存器或内存损坏可能使后续代码收到错误的 align,但仅凭现有信息,还无法确定触发 panic 的具体调用点。
保存发生得太晚,原始寄存器值已经丢失
在 trap_entry 中,保存上下文之前已经执行了:
la t0, TASK_HANDLER
lw t1, 0(t0)
mv a0, t1
csrr a1, mepc
mv a2, sp
call trap_save_context
这些指令会覆盖任务原来的 t0、t1、a0、a1 和 a2,而 call trap_save_context 还会覆盖 ra。
进入 trap_save_context 后,又有两次覆盖:
li t0, ((3 << 11) | (1 << 3))
mv t6, a0
因此,宏最终保存的部分寄存器值已经被 trap 处理代码改写,丢失了任务被中断时的原值。
异步中断可以发生在任意指令之间。普通函数调用约定允许被调用函数修改 caller-saved 寄存器,但被中断的任务并没有执行函数调用,不能承担寄存器被修改的后果。trap 入口必须先保存原值,再使用对应的寄存器。
ra、t6 遗漏恢复,t0 恢复后又被覆盖
两个宏都跳过了 x1、x2 和 x31,分别对应 ra、sp 和 t6。
sp 通过 OFFSET_SP 单独处理了,但所示代码没有保存和恢复任务原来的 ra、t6。
这里需要区分两个地址:
mepc:mret返回时继续执行的位置。ra:任务执行函数返回时使用的返回地址寄存器。
sw a1, OFFSET_PC(t6) 保存的是从 mepc 读出的值,不能替代对 ra 的保存。即使 mret 返回位置正确,任务稍后执行 ret 时仍可能跳转到错误地址。
另外,恢复宏恢复了 t0 之后,又执行了:
li t0, ((3 << 11) | (1 << 7))
csrrs x0, mstatus, t0
mret
这使任务最终得到的 t0 成为状态位掩码。CSR 设置必须安排在临时寄存器的最终恢复之前。
如何调整保存和恢复顺序
建议先实现一个不调度、只进入并返回的最小 trap 路径。确认所有通用寄存器都能还原后,再接入调度器。
以下是 RV32 的示意骨架,采用任务栈保存 trap frame。它定义了新的布局和 trap_dispatch 调用契约,不能直接替换现有函数:
xN保存在偏移N * 4,其中x2保存进入 trap 时的sp。mepc位于偏移128,mstatus位于偏移132。- frame 分配
144字节,以保持原有的 16 字节栈对齐。 trap_dispatch接收当前 frame 指针,并返回待恢复的 frame 指针;没有切换任务时返回原指针。
.equ FRAME_SIZE, 144
.equ FRAME_PC, 128
.equ FRAME_STATUS, 132
trap_entry_example:
addi sp, sp, -FRAME_SIZE
# 在使用任何通用寄存器前保存其原值;sp 单独处理。
.irp reg,1,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31
sw x\reg, (\reg * 4)(sp)
.endr
# t0 的原值已经保存,现在可以把它用作临时寄存器。
addi t0, sp, FRAME_SIZE
sw t0, 8(sp)
csrr t0, mepc
sw t0, FRAME_PC(sp)
csrr t0, mstatus
sw t0, FRAME_STATUS(sp)
mv a0, sp
call trap_dispatch
# a0 必须指向一个有效、可恢复的 trap frame。
mv sp, a0
# 先恢复 CSR,再恢复用于这些操作的临时寄存器。
lw t0, FRAME_PC(sp)
csrw mepc, t0
lw t0, FRAME_STATUS(sp)
csrw mstatus, t0
.irp reg,1,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31
lw x\reg, (\reg * 4)(sp)
.endr
# 最后切换 sp,避免提前丢失 frame 基址。
lw sp, 8(sp)
mret
这个骨架假定任务栈可写且有足够空间,入口 sp 已满足所用 ABI 的对齐要求,调用 Rust 时 gp、tp 等运行环境有效,并且恢复的 mstatus.MIE 保持为零,直到 mret。新任务的初始 frame 也必须满足相同约定。若使用独立 trap 栈,需要另外设计入口暂存区和栈切换流程,保证切栈前不会破坏任务寄存器。
mstatus 和 trap 栈还要检查什么
mstatus.MPP 表示返回时使用的特权级,清除它不是屏蔽中断的操作。普通 M-mode trap 进入时,硬件已经清除 MIE,并更新 MPIE、MPP。需要恢复的状态应在软件改写前保存。
当前恢复代码把 MPP 设置为 3,意味着选择返回 M-mode。如果任务需要运行在其他特权级,这种固定设置就不适用。应根据任务的执行模式和保存的状态构造返回值。
还需要核对这条访问 trap 栈的指令:
lw t1, OFFSET_TRAP_STACK(t6)
它读取字段中的一个 word。若该字段保存的是栈顶指针,这种访问方式才有相应含义;若字段实际上是内嵌数组,它读到的就是数组开头的数据。原始注释称其为静态数组,但没有提供 Rust 结构体,无法据此确定布局。
还要确认栈顶指向可用区域的高地址端,并满足调用 Rust 所需的对齐。通常使用标准 RV32I ABI 时,函数调用边界要求 sp 按 16 字节对齐;具体仍应以实际 target 和 ABI 为准。允许嵌套 trap 时,也不能让不同层直接复用同一片栈空间。
建议按这个顺序验证
- 暂时关闭任务切换,只验证 timer interrupt 进入和返回,先隔离 trap 路径自身的问题。
- 用独立汇编测试给通用寄存器设置可辨识的值,在受控 trap 返回后逐一比较。对
sp、gp、tp保留其运行所需的有效值。 - 在完成最早期保存后记录
mepc、原始sp和 frame 地址;在恢复前检查它们,避免调试打印再次覆盖待保存状态。 - 检查任务栈和 trap 栈的边界及栈顶对齐,核对汇编访问方式是否符合 Rust 字段的实际类型。
- 最后追踪 panic 的调用链,查看实际传入
is_aligned_to的align值及其来源。
备注:内容仅供参考。
