24、SMP在RISC-V上的实现:RISC-V特权级、CLINT与PLIC、SBI接口

好,我们进入RISC-V多核实现的核心环节。说实话,这部分内容我当年啃了很久才真正吃透。RISC-V的SMP实现,说白了就是三件事:搞清楚谁有权限做什么(特权级)、怎么让核之间互相打断(CLINT/PLIC)、以及怎么跟底层固件打交道(SBI)。这三件事搞明白了,SMP的骨架就搭起来了。

24.1 RISC-V特权级:谁说了算?

RISC-V定义了三个特权级,从低到高分别是:U-Mode(用户模式)、S-Mode(监管模式)、M-Mode(机器模式)。嗯,这里要注意,我们做SMP内核,主要打交道的是S-Mode和M-Mode。

  • U-Mode:跑应用程序的地方。权限最低,不能碰CSR,不能关中断。
  • S-Mode:跑操作系统内核的地方。可以操作大部分CSR,处理异常和中断。
  • M-Mode:跑BootROM或SBI固件的地方。最高权限,能操作所有CSR,包括那些敏感的物理内存属性寄存器。

我个人习惯把M-Mode想象成“硬件管家”,S-Mode是“软件经理”,U-Mode是“干活的小弟”。多核启动时,所有核都从M-Mode开始,然后主核把从核“踢”到S-Mode去跑调度器。

关键点:RISC-V的SMP实现中,每个核都有自己的CSR副本。比如mhartid(硬件线程ID),每个核读到的值都不一样。这在多核启动时特别有用——主核ID通常是0,其他核ID依次递增。

我在项目中遇到过一个问题:某个从核启动后一直卡在WFI(等待中断)状态,死活不响应IPI。查了半天,发现是M-Mode下没正确设置mideleg寄存器,导致S-Mode收不到核间中断。说白了,就是权限没委派好。

24.2 CLINT:核间中断的“传令兵”

CLINT(Core Local Interruptor)是RISC-V标准中定义的核间中断控制器。它负责两件事:

  1. 生成定时器中断:每个核都有一个mtime和mtimecmp寄存器,当mtime >= mtimecmp时,触发定时器中断。
  2. 生成软件中断:通过写msip寄存器,可以向指定核发送核间中断(IPI)。

你想想看,多核调度最核心的操作是什么?是让一个核去打断另一个核正在执行的任务。CLINT就是干这个的。在RT-Thread的SMP实现中,当主核需要从核执行某个任务时,就会写从核对应的msip寄存器,触发一个IPI。

我的经验:CLINT的地址空间通常是内存映射的。比如在QEMU的virt平台上,CLINT基地址是0x02000000。msip寄存器偏移是0x0000,mtimecmp偏移是0x4000,mtime偏移是0xBFF8。写代码时一定要确认好这些地址,我曾经因为地址算错一位,导致IPI发到了隔壁核上,调试了一整天。

CLINT的寄存器布局很简单,我列个表给你看:

偏移地址 寄存器 描述
0x0000 + 4 * hart_id msip 写1触发软件中断,写0清除
0x4000 + 8 * hart_id mtimecmp 比较值,当mtime >= 此值时触发定时器中断
0xBFF8 mtime 实时计数器,每个时钟周期递增

24.3 PLIC:外部中断的“调度员”

CLINT管的是核间中断和定时器中断,那外部设备的中断(比如UART、网卡)谁来管?答案是PLIC(Platform Level Interrupt Controller)。

PLIC的作用说白了就是:多个外设可以同时产生中断,但CPU核有限,PLIC负责决定哪个中断先被处理,以及分配给哪个核去处理。

PLIC的工作流程是这样的:

  1. 外设产生中断请求,PLIC收到后根据优先级排序。
  2. PLIC找到当前优先级最高的待处理中断。
  3. PLIC把这个中断分配给某个目标核(可以配置是哪个核)。
  4. 目标核的MEIP(机器外部中断挂起位)被置位,CPU跳转到中断向量表。
  5. CPU读PLIC的claim寄存器,拿到中断源ID,然后处理。
  6. 处理完后写complete寄存器,通知PLIC可以处理下一个中断了。

注意:PLIC的配置寄存器也是内存映射的。而且不同硬件平台的PLIC实现可能不一样。比如有些平台支持优先级抢占,有些不支持。我在移植RT-Thread到某个RISC-V开发板时,就发现PLIC的优先级位数只有3位(0-7),而不是标准的7位(0-127)。这种细节很容易踩坑。

在SMP环境下,PLIC的每个核都有独立的上下文(context)。每个上下文可以单独设置中断阈值和使能位。这样,不同核可以处理不同的中断集合,实现中断负载均衡。

24.4 SBI接口:操作系统和固件的“翻译官”

SBI(Supervisor Binary Interface)是RISC-V定义的一套标准接口,运行在S-Mode的操作系统通过它来调用M-Mode的服务。为什么要搞这么一层?因为有些操作在S-Mode下不能直接做,比如:

  • 读写mtime和mtimecmp(定时器相关)
  • 发送IPI(核间中断)
  • 系统关机或重启
  • 获取硬件信息(比如内存大小、CPU核数)

SBI调用通过ecall指令实现。调用时,a7寄存器存放功能ID(FID),a6寄存器存放扩展ID(EID),参数放在a0-a5中。返回值通过a0a1返回。

举个例子,发送IPI的SBI调用:

// 发送IPI到hart_mask指定的核
// a7 = SBI_EXT_IPI (0x735049)
// a6 = SBI_EXT_IPI_SEND_IPI (0)
// a0 = hart_mask的低32位
// a1 = hart_mask的高32位
// 返回值: a0 = 错误码, a1 = 保留

static inline void sbi_send_ipi(const unsigned long *hart_mask)
{
    register uintptr_t a0 asm("a0") = hart_mask[0];
    register uintptr_t a1 asm("a1") = hart_mask[1];
    register uintptr_t a6 asm("a6") = 0;
    register uintptr_t a7 asm("a7") = 0x735049;

    asm volatile("ecall"
                 : "+r"(a0), "+r"(a1)
                 : "r"(a6), "r"(a7)
                 : "memory");
}

我的建议:在RT-Thread的SMP实现中,尽量把SBI调用封装成统一的接口。比如rt_hw_ipi_send()rt_hw_timer_set()。这样如果将来换平台(比如从QEMU换到真实芯片),只需要修改底层SBI封装,上层调度代码完全不用动。我在一个项目里就是这么做的,后来换芯片时只改了两个文件,省了不少事。

SBI规范目前有v0.2和v1.0两个主要版本。v1.0引入了更多功能,比如系统复位、RTC时间等。我个人建议新项目直接用v1.0,因为它的接口更完善,而且向后兼容。

24.5 三者的协作关系

最后,我总结一下这三者在SMP实现中的协作关系:

  • 特权级决定了代码运行在哪个层级。RT-Thread内核跑在S-Mode,SBI固件跑在M-Mode。
  • CLINT负责核间通信和定时器。当调度器需要触发上下文切换时,通过CLINT发送IPI。
  • PLIC负责外部设备中断。每个核可以独立配置中断亲和性,实现中断负载均衡。
  • SBI是S-Mode和M-Mode之间的桥梁。内核通过SBI调用间接操作CLINT和PLIC中那些M-Mode才能访问的寄存器。

嗯,说到这里,你应该对RISC-V的SMP底层机制有了清晰的认识。下一节我们会深入代码,看看RT-Thread是如何利用这些硬件特性实现多核调度器的。到时候我会带着你一行一行地分析启动代码和IPI处理流程。

一句话总结:RISC-V的SMP实现,就是通过特权级划分权限,CLINT管核间通信,PLIC管外部中断,SBI做桥梁。把这四样东西搞明白,多核移植就成功了一半。