xv6-k210/doc/构建调试-外部中断v2.md

13 KiB
Raw Permalink Blame History

外部中断的解决方案尝试

AtomHeartCoder

DMA 控制器

Artyom Liu 同学的努力下SD 卡驱动可以正常工作了。数据传输通过 SPI 协议进行, 直接由 CPU 控制,也就是 CPU 死等传输的方式。随后,我们又注意到 K210 是有 DMA 控制器的Direct Memory Access 可以控制总线上数据的传输,从而释放 CPU 资源。而且 Kendryte 也提供了 DMA 控制器的驱动代码,稍加理解便可以直接将 我们需要的部分移植过来。这个过程很顺利编译通过后DMA 方式也能正常读取 SD 上的数据,成功打开 shell。
但是,此时仍然是 CPU 轮询 DMA 控制器的相关寄存器位来判断传输时候完成,因为 S 态下不能接收到外部中断。不过, M 态下是可以收到外部中断的,但我更倾向于在 S 态进行中断响应。

尝试解决外部中断(一)

S 态下虽然没有外部中断但是有软件中断。我一开始的设想是M 态收到外部中断后,从 PLIC 得到中断源编号并通过 stval 传递, 然后写 mip.SSIP 将 S 态的软中断置位。虽然我不会 Rust但还是不得不去修改 SBI 的代码。随后我发现,在 RustSBI 的代码里, 其实已经有同样的操作,但是被注释掉了,说明这种方案可能行不通。我仍然尝试了一下,并观察到以下现象:

  • 确实能够收到 DMA 控制器发来的中断,键盘输入也可以;
  • 外部中断源源不绝,期间只偶尔进了几次 S 态软中断处理,无论是 DMA 的中断还是接收键盘输入的串口中断;
  • 进一步地,我观察到 mip.MEIP 位(外部中断等待位)总是为 1。

根据我所学习到的 PLIC 的相关知识,在进行 PLIC claim/complete 操作时PLIC 中对应中断源的 IP 寄存器和 mip.xEIP 位 都应该被清零 0。这很奇怪于是我在每次 PLIC claim/complete 后多一次输出 mip.MEIP,并观察到有时它确实可以被清零! 我们知道,当 mstatus.MIEmie.MEIE 以及 mip.MEIP 都为 1 时,外部中断会触发。暂且不深究为何外部中断总是不断地来, 先设想一下这样的情形:

在运行时 mstatus.MIEmie.MEIE 都为 1即允许外部中断。某时刻中断源 A 向 PLIC 发送中断请求,通过仲裁后, mip.MEIP 被置位,中断条件到达,中断信息被发送至中断目标(这里是某一个 hart 的 M 态)。某一 hart 的 M 态外部中断被触发, mstatus.MIE 被硬件自动置 0随后 PLIC claim/complete 清除 mip.MEIP 位,并设置 S 态软中断。倘若这一时刻,中断源 B 也向 PLIC 发送了一个中断请求,其优先级高于 A那么仲裁后 mip.MEIP 又会被重新置位。当 M 态通过 mret 指令从 A 的中断 处理中返回时,mstatus.MIE 会重新设置为原先的值,中断条件再次到达。那么此时,这个 hart 是先处理先前设置的 S 态软中断, 还是刚刚到来的 B 外部中断呢?当然是 M 态外部中断优先,处理时又会再次设置 S 态软中断,从而将前一个 A 中断的设置覆盖掉, 导致我们丢失 S 态下对 A 中断应有的处理!

有没有办法解决呢?在 SBI 中的时钟中断处理中,可以找到答案:关闭 mie.MEIE。这使得外部中断被暂时屏蔽,从而使得 S 态软中断 可以紧接着进行。但是这也要求 S 态处理完毕后,向 SBI 发送通知,重新打开 mie.MEIE。这与目前我们对时钟中断的处理方式一致。 但再仔细想想,万一 S 态软中断被 M 态时钟中断插队,在时钟中断处理流程中,需要进行额外判断,才能决定是否重新置位 mie.MEIE。 这些都使得中断处理显得格外繁琐。

尝试解决外部中断(二)

还有第二种方法,就是吴一凡学长的方案:将 S 态中断处理函数的指针通过 SBI call 传入 M 态,外部中断到来时,直接以 M 态的身份 执行 S 态的代码。前不久我们也尝试了这个方法,但是出现了访存异常。我研究了一下 SBI 中的相关代码,如下:

unsafe {
    let mut mstatus: usize;
    llvm_asm!("csrr $0, mstatus" : "=r"(mstatus) ::: "volatile");
    // set mstatus.mprv
    mstatus |= 1 << 17;
    // it may trap from U/S Mode
    // save mpp and set mstatus.mpp to S Mode
    let mpp = (mstatus >> 11) & 3;
    mstatus = mstatus & !(3 << 11);
    mstatus |= 1 << 11;
    // drop mstatus.mprv protection
    llvm_asm!("csrw mstatus, $0" :: "r"(mstatus) :: "volatile");
    fn devintr() {
        unsafe {
            // call devintr defined in application
            // we have to ask compiler save ra explicitly
            llvm_asm!("jalr 0($0)" :: "r"(DEVINTRENTRY) : "ra" : "volatile");
        }
    }
    // compiler helps us save/restore caller-saved registers
    devintr();
    // restore mstatus
    mstatus = mstatus &!(3 << 11);
    mstatus |= mpp << 11;
    mstatus -= 1 << 17;
    llvm_asm!("csrw mstatus, $0" :: "r"(mstatus) :: "volatile");
}

其中DEVINTRENTRY 就是我们传入的 S 态处理函数的地址。代码还置了 mstatus.MPRV 位,这会使得 M 态下以 mstatus.MPP 中 对应特权态的方式(即可以使用页表)进行访存,这样就可以执行 S 态的代码了,执行完毕后再恢复 mstatus 寄存器即可。我觉得很妙, 这不就能访问 S 态的数据和函数了吗?设想是好的,但是在 xv6 的情况下就有问题了:

  • 据我了解到的资料,mstatus.MPRV 位的设置只对访存指令有效,对 pc 指令寻址没有影响;
  • xv6 的代码段和数据段是直接映射的(即虚拟地址映射到相等的物理地址),有没有页表其实没有很大区别;
  • xv6 的栈段不是直接映射。

我在上述代码中没有注意到对 sp 的操作,也就是说,使用 S 态页表时,sp 是 M 态下的栈顶指针!这样映射就会出错了!更何况, SBI 的地址是低于内核地址的S 态页表中根本没有对 SBI 的映射。那么交换一下栈顶指针是不是就好了呢?还不见得,也是同样的问题。 mstatus 的原值保存在 M 态的栈上,一旦使用了内核页表,就无法回到 M 态的栈上了。我们可以使用 mscratch 寄存器保存一些值, 但我还是觉得繁琐。即使可以写出代码,我们也不能很明确 S 态的栈是什么情况,贸然让 M 态在上面执行,有可能会产生数据覆盖的风险。 那么,根据 xv6 直接映射的特点,我索性舍弃这一繁琐操作,直接让 M 态在自己的栈上执行 S 态的代码。代码就很简单了:

fn devintr() {
    unsafe {
        llvm_asm!("jalr 0($0)" :: "r"(DEVINTRENTRY) : "ra" : "volatile");
    }
}
devintr();

结果就是能运行了!只不过又产生了新问题,虽然中断处理代码是从 S 态角度写的,但实际上是 M 态在执行,那么在控制台 IO 的处理上, 我们就不便使用 SBI call 进行操作了,而应该直接操控串口。好在 Kendryte 也有相关的代码,直接搬过来适配一下就好了。

外部中断能顺利处理后,我也发现了之前 DMA 中断源源不断的原因:在 SD 卡的操作中,需要向 SD 卡发送命令,并等待 SD 卡的回复。 由于命令较短不太耗时(但不一定能一次就读到回复,毕竟 CPU 比 SD 快太多),这个操作采用了轮询方式:

...
int timeout = 0xfff;
while (--timeout) {
    sd_read_data(&result, 1);
    if (0xfe == result) break;
}
...

我最初把 SD 卡调用 SPI 的接口都改用了 DMA 方式,这么循环多次,才导致了很多次外部中断。我仅将数据传输改用 DMA 方式,而命令收发 保留 CPU 轮询方式,这样外部中断次数就正常了。至于原本的串口中断为何也很多,我就不得而知了。

这期间我和 Artyom Liu 同学多次探讨了外部中断的处理方式,考虑过各种方式的优点与不足, 但都不能找到一个十分完美的做法。在这里我也要感谢 Artyom Liu 给予的建议和帮助!就目前来看, 暂时先使用这种方式处理外部中断吧,我们也只有对各种方案都进行一些尝试,才可能摸索出一套最合适的解决方案吧。

一个很奇怪的问题

也是 SD 卡驱动刚做好的那会,我想测试 SD 卡和文件系统在 K210 上的运行情况。那时键盘的输入还是在时钟中断过程中直接从串口取回的。 在板子上跑,看起来也能命令 shell执行用户程序。但运行没几分钟RustSBI 就会在某次敲下回车后,报了panic,是非法指令异常。 我起初怀疑这与串口有关,但当时键盘输入也只是临时方案,说不定改成通常的中断方式就可以解决这个问题。但是目前来看,并没有解决, 问题依然存在。当时外部中断还没起来,问题就已经存在,所以外部中断处理不太可能是问题产生的根本原因。

我决定定位这个 BUG。根据 RustSBI 的报错,mepc 的值符合用户程序的特征,mstatus.MPP 也指明是 U 态下发生的异常。按理说, 正常编写的用户程序应该不会导致如此严重的异常,况且同样的用户程序在 QEMU 上就没出现过问题。用户态出现非法指令,而我们知道实际上 并没有非法指令,只能说明,pc 可能指向了非代码段。我怀疑在内核中是不是覆盖了 trapframe 中的现场,包括 epcpcra等, 于是我在 usertrapusertrapret 中检查了这些值,都很正常。从 ra 出发,对照用户程序对应的 asm 文件,也未发现会将 pc 导向至一个不正确地址的迹象。同时,还有一些其他问题。我总结如下:

  1. 某次输入命令后RustSBI 会 panic大多数情况是非法指令异常偶尔有访存不对齐
    • mepc 中的值指向了错误位置(在用户程序的汇编文件中不存在对应指令)
    • 用户程序多是 sh,出错前的最后一次系统调用不固定,writeexecwait等都有,但寻指相关的寄存器未见出错
    • 有时 mepc 指向的指令看起来很正常,是否是 K210 不支持的指令?
  2. 有时 S 态也会捕捉到 U 态的访存异常/取指异常
    • 对于访存异常,sepc 对应的值在汇编文件中可以看到是正常的访存指令,但 stval 的值不太正常
    • 对于取指异常,sepc 出现了类似 0x0000000505050504 这样的值
  3. 我用 Artyom Liu 同学的板子试了几次,也存在问题,但表现有所差别
    • RustSBI 报 panic 的概率小了很多,可以运行更久
    • S 态也会捕捉到用户进程的访存异常
    • 除了板子新一些其他条件相同包括板子型号、SD 卡、宿主机等

我们怀疑有以下原因,但都不能明确:

  1. K210 板子自身的问题

    理由:

    • 用户程序在 QEMU 上没有问题
    • 内陷 S 态前后,取指相关的寄存器没有问题,可能不是内核问题

    疑点:

    • 如果是硬件问题,为何只会在 U 态下出错?
  2. K210 版 RustSBI 的问题

    理由:

    • 如果板子硬件没问题,是否是硬件参数配置不合适?
    • 外部中断处理时M 态下执行了 S 态代码,有不稳定因素

    疑点:

    • 这个报错在先前就已经存在
  3. K210 版内核的问题

    理由:

    • 内核直接支撑着用户程序的进行,是离用户程序最近的一层,最可能出错

    疑点:

    • 我跟踪过内陷前后的情况,用户现场的保存应该没问题。
  4. 用户栈空间的问题

    理由:

    • 栈出错可能导致 pcrasp 等寄存器出错

    疑点

    • 用户程序在 QEMU 上没有问题
  5. 用户页表的问题

    理由:

    • mepc 指向的指令地址可能未正确映射,才导致取到非法或错误指令

    疑点:

    • RustSBI 通过页表和 mepc 取到的指令与用户汇编文件中的指令一致,表明页表应当正常

凡事先从自己身上找原因。最近的移植工作涉及较多改动,这个过程中我们还没有能很好地排查内核代码中可能存在的问题,现在也只能慢慢 DEBUG 了。

更新:内核代码确实存在问题。内核的中断开关操作置的是 sstatus 寄存器的位,而实际上外部中断由 M 态截获,这会导致死锁。 开关中断可能要改成 SBI call 的形式,让 M 态打开/关闭外部中断。但是M 态也会经过这一执行路径,遇到 SBI call 的调用,这就有些麻烦了。 M 态在处理外部中断时,本就处于中断关闭状态,是否可以另写 M 态的锁操作,从而绕过中断开关过程呢?经过讨论,我们认为这样做会造成代码冗杂, 遂放弃。

尝试解决外部中断(三)

这个想法其实是方法(一)的扩展。如果收到中断,无论是时钟中断或是外部中断,都将 mieMEIEMTIE 关闭,再对应置 S 态的时钟中断、 软中断,是否就可以保证 M 态收到时钟/外部中断后,接着进入 S 态时钟/软中断了呢?当然,如果这么做,还需要 S 态处理结束后,通过 SBI call 将中断重新打开。