PAYATHON 2026

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 时,也不能让不同层直接复用同一片栈空间。

建议按这个顺序验证

  1. 暂时关闭任务切换,只验证 timer interrupt 进入和返回,先隔离 trap 路径自身的问题。
  2. 用独立汇编测试给通用寄存器设置可辨识的值,在受控 trap 返回后逐一比较。对 sp、gp、tp 保留其运行所需的有效值。
  3. 在完成最早期保存后记录 mepc、原始 sp 和 frame 地址;在恢复前检查它们,避免调试打印再次覆盖待保存状态。
  4. 检查任务栈和 trap 栈的边界及栈顶对齐,核对汇编访问方式是否符合 Rust 字段的实际类型。
  5. 最后追踪 panic 的调用链,查看实际传入 is_aligned_to 的 align 值及其来源。

备注:内容仅供参考。