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标准中定义的核间中断控制器。它负责两件事:
- 生成定时器中断:每个核都有一个mtime和mtimecmp寄存器,当mtime >= mtimecmp时,触发定时器中断。
- 生成软件中断:通过写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的工作流程是这样的:
- 外设产生中断请求,PLIC收到后根据优先级排序。
- PLIC找到当前优先级最高的待处理中断。
- PLIC把这个中断分配给某个目标核(可以配置是哪个核)。
- 目标核的MEIP(机器外部中断挂起位)被置位,CPU跳转到中断向量表。
- CPU读PLIC的claim寄存器,拿到中断源ID,然后处理。
- 处理完后写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中。返回值通过a0和a1返回。
举个例子,发送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做桥梁。把这四样东西搞明白,多核移植就成功了一半。