1. 从一个“看不见的中间层”说起:RISC-V SBI 到底在干什么
如果你最近在折腾 RISC-V 开发板,比如 VisionFive 2、荔枝派 4A、K230 或者 QEMU 里的 virt 机器,大概率会遇到一个绕不开的词:SBI。全称是 Supervisor Binary Interface,翻译过来叫“监管者二进制接口”。名字听着很学术,但你可以把它理解成 RISC-V 世界里操作系统和底层固件之间的“电话总机”——Linux 内核想关机、想发核间中断、想读写定时器,都得先拨这个总机,由固件那边帮忙转接。
为什么 RISC-V 需要这么一层?这跟它的设计哲学有关。RISC-V 把特权级分得很清楚:M 模式(Machine)管最底层的硬件,S 模式(Supervisor)跑操作系统内核,U 模式(User)跑应用程序。Linux 跑在 S 模式,但很多硬件操作只有 M 模式才有权限。于是 SBI 就诞生了,它定义了一套标准调用约定,让 S 模式的软件通过ecall指令陷入 M 模式,由固件(通常是 OpenSBI)来执行具体操作,再把结果返回。
这套机制的价值在于解耦。同一份 Linux 内核镜像,可以跑在 QEMU 上,也可以跑在真实的 StarFive JH7110 上,只要两边的固件都实现了同一套 SBI 规范。厂商换硬件、换固件,内核不用大改。这就是为什么 SBI 被称为 RISC-V 生态的“地基接口”。
这篇文章我打算把 SBI 从规范到落地讲透:扩展体系怎么分类、调用约定有哪些坑、Linux 侧是怎么映射的、实际调试时怎么排查问题。适合正在做 RISC-V 移植、写驱动、或者准备面试被问到“SBI 和 syscall 有什么区别”的朋友。内容会偏底层,但我会尽量用生活化的类比把原理讲明白,代码和参数都会给到可以直接抄的程度。
2. SBI 扩展体系全拆解:从 0.1 到 2.0 的演进逻辑
2.1 为什么 SBI 要分“扩展”:一个总机不够用
早期的 SBI 规范(0.1 版本)非常简陋,就几个固定功能号:设置定时器、发 IPI、远程 fence、关机。所有功能挤在一个命名空间里,靠EID(Extension ID)区分。这就像一家公司只有一个总机号码,所有部门都靠分机号找,分机号还经常冲突。
到了 0.2 和 0.3,社区发现这样不行:新硬件功能越来越多,如果每个功能都往主规范里塞,规范会变得又臭又长,而且厂商的私有功能没法标准化。于是从SBI 1.0开始,正式引入了“扩展”概念,把功能按类别拆成独立的扩展,每个扩展有自己的 EID 和版本号。这个思路跟 Linux 内核的子系统划分、跟 USB 的设备类规范是一个逻辑:核心保持精简,功能按需扩展。
2.2 扩展的三大分类:基础、通用、厂商
按 SBI 2.0 规范,扩展大致分三类,我整理成表格方便对照:
| 分类 | 代表扩展 | EID 范围 | 作用 | 是否必须实现 |
|---|---|---|---|---|
| 基础扩展 | Base、TIME、IPI、RFENCE | 0x00-0x0F | 定时器、核间中断、内存屏障、关机重启 | Base 必须,其余强烈建议 |
| 通用扩展 | HSM、SRST、PMU、CPPC、DBTR | 0x10-0x1F | 核的启停、系统复位、性能计数、CPU 电源管理 | 按平台能力选做 |
| 厂商扩展 | 各厂商自定义 | 0x08000000+ | 私有硬件控制、调试、安全功能 | 厂商自定 |
这里有个关键点:EID 的编码规则。基础扩展用 0x00 到 0x0F,通用扩展用 0x10 到 0x1F,实验性扩展用 0x20 到 0x3F,厂商扩展从 0x08000000 开始。这个分段不是随便定的,是为了给未来留空间,同时让固件能快速判断一个 EID 是不是自己认识的。
2.3 Base 扩展:一切的地基
Base 扩展(EID 0x10,注意这里规范里 Base 的 EID 是 0x10,跟上面表格的通用范围有重叠,实际以规范为准)提供了最核心的几个功能:
sbi_get_spec_version:获取 SBI 规范版本,返回一个 32 位整数,高 16 位是主版本,低 16 位是次版本。比如返回 0x00020000 就是 2.0。sbi_get_impl_id:获取固件实现 ID,OpenSBI 是 1,其他固件有各自的编号。sbi_get_impl_version:固件版本号。sbi_probe_extension:探测某个扩展是否存在,返回 0 表示不存在,非 0 表示存在且返回值是扩展的版本。sbi_get_mvendorid/sbi_get_marchid/sbi_get_mimpid:获取厂商 ID、架构 ID、实现 ID,用于内核做硬件适配。
这几个函数看着简单,但它们是内核启动时最先调用的 SBI 接口。Linux 在setup_arch阶段就会调sbi_probe_extension来确认 TIME、IPI、RFENCE 这些扩展在不在,不在的话就得走降级路径。我见过有人自己写了个极简固件,只实现了 Base,结果 Linux 启动到定时器初始化就卡死了,就是因为 TIME 扩展没实现。
2.4 TIME 扩展:定时器不是你想设就能设
TIME 扩展(EID 0x54494D45,就是 “TIME” 的 ASCII)只有一个函数:sbi_set_timer。参数是uint64_t stime_value,表示下一次定时器触发的绝对时间,单位是 RISC-V 的time计数器周期。
这里有个大坑:stime_value 是绝对时间,不是相对时间。很多新手写驱动时习惯传“1000 个周期后触发”,结果定时器要么立刻触发要么永远不触发。正确做法是先读timeCSR 拿到当前值,再加上你想要的延迟周期数。
// 正确用法:设置 1 秒后触发(假设 time 频率是 10MHz) uint64_t now = csr_read(CSR_TIME); uint64_t freq = 10000000; // 从设备树或 FDT 获取 sbi_set_timer(now + freq);还有个细节:sbi_set_timer在 SBI 0.1 里是直接调用,在 0.2+ 里变成了通过ecall的标准化调用。OpenSBI 内部会把绝对时间转换成mtimecmp寄存器的值。如果你的平台mtime频率跟内核假设的不一致,定时器就会跑偏,表现为系统时间走得忽快忽慢。这个频率通常从设备树的timebase-frequency属性读,读错了就是灾难。
2.5 IPI 与 RFENCE:多核世界的交通指挥
IPI(Inter-Processor Interrupt)扩展负责核间中断,主要函数是sbi_send_ipi,参数是一个位图hmask,每一位对应一个 hart。比如你要给 hart 0 和 hart 2 发中断,就传0b101。
RFENCE(Remote Fence)扩展负责远程内存屏障,函数包括sbi_remote_fence_i(指令缓存同步)、sbi_remote_sfence_vma(虚拟地址映射同步)等。这些在 Linux 的 TLB shootdown、代码修改后同步 icache 时都会用到。
注意:IPI 和 RFENCE 的位图参数是“hart mask”,但 hart 的编号不一定连续。有些平台 hart 0 是 M 模式专用,S 模式从 hart 1 开始。传位图时一定要用
cpumask转换后的值,别自己硬编码。
2.6 HSM 与 SRST:核的启停和系统复位
HSM(Hart State Management)扩展是 SBI 1.0 引入的重头戏,它定义了核的生命周期状态机:STOPPED、STARTED、SUSPENDED、SUSPENDED_RETURN。对应的函数有:
sbi_hart_start:启动一个核,参数是 hartid、起始地址、私有数据。sbi_hart_stop:停止当前核。sbi_hart_get_status:查询核状态。sbi_hart_suspend:让核进入低功耗状态。
SRST(System Reset)扩展负责系统级复位,sbi_system_reset可以执行关机、冷启动、热启动。参数里有个reset_type和reset_reason,Linux 的reboot和poweroff最终就是走到这里。
我踩过的一个坑:在 QEMU 上sbi_system_reset关机很干脆,但在某块真实开发板上,固件把关机实现成了“死循环等看门狗”,结果系统卡住不重启。后来查规范才发现,reset_type应该用SBI_SRST_RESET_TYPE_COLD_REBOOT,而厂商固件只认WARM_REBOOT。这种兼容性问题只能靠sbi_probe_extension先探测再决定调用方式。
3. 调用约定:ecall 背后的寄存器游戏
3.1 一次 SBI 调用的完整流程
SBI 调用本质上是一条ecall指令,但参数怎么传、返回值怎么拿,规范定义得很死。我把它拆成五步:
- 准备参数:把 EID 放到
a7寄存器,FID(Function ID)放到a6,其余参数按顺序放到a0到a5。 - 执行 ecall:在 S 模式执行
ecall,CPU 陷入 M 模式。 - 固件处理:M 模式的 trap handler 根据
a7和a6找到对应函数,执行。 - 返回结果:错误码放
a0,返回值放a1。 - 返回 S 模式:
mret回到 ecall 的下一条指令。
用汇编表示大概是这样:
# 调用 sbi_set_timer(0x1000000) li a7, 0x54494D45 # EID: TIME li a6, 0 # FID: set_timer li a0, 0x1000000 # 参数: stime_value ecall # 返回后 a0 是错误码,a1 是返回值3.2 错误码:0 是成功,负数才是失败
SBI 规范定义了一套错误码,全部是负数:
| 错误码 | 值 | 含义 |
|---|---|---|
| SBI_SUCCESS | 0 | 成功 |
| SBI_ERR_FAILED | -1 | 通用失败 |
| SBI_ERR_NOT_SUPPORTED | -2 | 功能不支持 |
| SBI_ERR_INVALID_PARAM | -3 | 参数非法 |
| SBI_ERR_DENIED | -4 | 权限不足 |
| SBI_ERR_INVALID_ADDRESS | -5 | 地址非法 |
| SBI_ERR_ALREADY_AVAILABLE | -6 | 已经可用 |
| SBI_ERR_ALREADY_STARTED | -7 | 已经启动 |
| SBI_ERR_ALREADY_STOPPED | -8 | 已经停止 |
这里有个容易搞混的地方:返回值在 a1,错误码在 a0。有些固件实现不规范,把返回值也放 a0,导致内核读到的数据是错的。判断调用是否成功,一定要先看 a0 是不是 0。
3.3 调用约定里的“隐藏规则”
规范里没明说但实际很重要的几点:
- 寄存器保存:SBI 调用会破坏
a0到a7,但s0到s11、sp、ra这些必须保持不变。如果你在汇编里调 SBI,记得保存现场。 - 栈的使用:SBI 调用不应该依赖 S 模式的栈,固件有自己的 M 模式栈。但有些实现会临时借用,所以调用前最好保证栈指针合法。
- 中断状态:
ecall进入 M 模式后,M 模式的中断使能由固件控制。如果固件没关中断,可能在处理 SBI 时被 M 模式定时器打断,导致嵌套。OpenSBI 默认会关掉 M 模式中断,但自研固件要注意。
实操心得:调试 SBI 问题时,可以在 QEMU 里加
-d int,cpu_reset参数,把每次 ecall 的 EID、FID、参数都打出来。我排查一个定时器不触发的问题时,就是靠这个发现内核传的 stime_value 是 0,原因是timeCSR 读出来是 0,根子在设备树频率没配。
3.4 与 syscall 的本质区别
很多人会把 SBI 和 syscall 搞混,其实两者层次完全不同:
| 对比项 | SBI | syscall |
|---|---|---|
| 发起方 | S 模式内核 | U 模式应用 |
| 目标 | M 模式固件 | S 模式内核 |
| 指令 | ecall | ecall |
| 参数寄存器 | a0-a7 | a0-a7 |
| 返回 | a0 错误码,a1 返回值 | a0 返回值 |
| 典型用途 | 定时器、IPI、复位 | 文件、进程、网络 |
有意思的是,两者都用ecall指令,但 CPU 根据当前特权级决定陷入到哪一层。U 模式 ecall 进 S 模式,S 模式 ecall 进 M 模式。这个设计很优雅,但也意味着如果你在 M 模式执行 ecall,会直接 trap 到 M 模式自己,形成死循环。
4. Linux 侧映射:从 sbi_ecall 到驱动调用
4.1 内核里的 SBI 封装层
Linux 内核在arch/riscv/kernel/sbi.c里封装了一套 C 函数,把汇编的 ecall 包装成可读的接口。核心函数是sbi_ecall:
struct sbiret sbi_ecall(int ext, int fid, unsigned long arg0, unsigned long arg1, unsigned long arg2, unsigned long arg3, unsigned long arg4, unsigned long arg5) { struct sbiret ret; register unsigned long a0 asm("a0") = arg0; register unsigned long a1 asm("a1") = arg1; // ... a2 到 a5 register unsigned long a6 asm("a6") = fid; register unsigned long a7 asm("a7") = ext; __asm__ __volatile__("ecall" : "+r"(a0), "+r"(a1) : "r"(a2), "r"(a3), "r"(a4), "r"(a5), "r"(a6), "r"(a7) : "memory"); ret.error = a0; ret.value = a1; return ret; }这个函数返回一个sbiret结构体,包含error和value。所有具体的 SBI 功能都是基于它封装的,比如:
static inline long sbi_set_timer(uint64_t stime_value) { struct sbiret ret = sbi_ecall(SBI_EXT_TIME, SBI_EXT_TIME_SET_TIMER, stime_value, 0, 0, 0, 0, 0); return ret.error; }4.2 扩展探测与能力协商
内核启动时不会盲目调用所有 SBI 功能,而是先探测。sbi_probe_extension的封装在sbi.c里:
long sbi_probe_extension(int extid) { struct sbiret ret = sbi_ecall(SBI_EXT_BASE, SBI_EXT_BASE_PROBE_EXT, extid, 0, 0, 0, 0, 0); if (ret.error) return 0; return ret.value; }然后在sbi_init里,内核会检查 TIME、IPI、RFENCE、HSM 等扩展是否存在,把结果存到全局变量里。比如riscv_ipi_ops会根据 IPI 扩展是否存在,决定用 SBI 发 IPI 还是用 CLINT/ACLINT 直接写寄存器。
这个“能力协商”机制很关键。我移植内核到一块新板子时,发现 IPI 发不出去,查了半天发现固件没实现 IPI 扩展,但内核默认走了 SBI 路径。后来在设备树里加了riscv,ipi节点指向 CLINT,内核就自动切换到直接写寄存器的方式了。
4.3 定时器:从 clocksource 到 SBI
Linux 的定时器子系统在 RISC-V 上分两层:clocksource负责读时间,clockevent负责产生中断。clocksource直接读timeCSR,不需要 SBI;但clockevent设置下一次中断时,就要调sbi_set_timer。
在drivers/clocksource/timer-riscv.c里:
static int riscv_timer_next_event(unsigned long delta, struct clock_event_device *evt) { uint64_t next = riscv_csr_read(CSR_TIME) + delta; sbi_set_timer(next); return 0; }这里delta是相对周期数,函数内部转成绝对时间再传给 SBI。如果固件的 TIME 扩展实现有问题,比如把绝对时间当相对时间处理,系统就会频繁触发定时器中断,表现为 CPU 占用率飙升、系统卡顿。
4.4 IPI 与 TLB shootdown 的映射
当内核需要让其他核刷新 TLB 时,会调用sbi_remote_sfence_vma。这个函数在flush_tlb_mm路径里被调用,参数是要刷新的 hart 位图和地址范围。
void flush_tlb_mm(struct mm_struct *mm) { unsigned long asid = atomic_long_read(&mm->context.id); if (asid >= 0) { sbi_remote_sfence_vma(mm_cpumask(mm)->bits, 0, 0); } }注意mm_cpumask(mm)->bits就是 hart 位图。如果这个位图算错了,比如把当前核也包含进去,会导致自己给自己发 IPI,虽然不会死锁,但浪费性能。内核在smp_call_function里会先排除当前核。
4.5 HSM 与 CPU 热插拔
CPU 热插拔在 RISC-V 上依赖 HSM 扩展。arch/riscv/kernel/cpu_ops_sbi.c实现了sbi_cpu_start、sbi_cpu_stop等回调:
static int sbi_cpu_start(unsigned int cpuid, struct task_struct *tidle) { unsigned long boot_addr = __pa_symbol(secondary_start_sbi); unsigned long hartid = cpuid_to_hartid_map(cpuid); return sbi_hart_start(hartid, boot_addr, 0); }boot_addr是次核启动后跳转的物理地址,通常是secondary_start_sbi的物理地址。这里有个坑:地址必须是物理地址,不能是虚拟地址,因为次核刚启动时 MMU 还没开。用__pa_symbol转换是标准做法。
5. 实操排查:SBI 问题怎么定位
5.1 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 系统启动卡在定时器初始化 | TIME 扩展未实现或 stime_value 错误 | 检查设备树 timebase-frequency,用 QEMU -d int 看 ecall |
| IPI 发不出去,多核调度异常 | IPI 扩展缺失或位图错误 | sbi_probe_extension(SBI_EXT_IPI)返回值,检查 cpumask |
| 关机变成重启 | SRST reset_type 不匹配 | 打印sbi_system_reset的参数,对比固件支持的类型 |
| 次核启动失败 | HSM 未实现或 boot_addr 错误 | 确认sbi_hart_start返回值,检查地址是否物理地址 |
| 系统时间走得不准 | timebase-frequency 配置错误 | 对比timeCSR 和实际秒表,调整设备树 |
| ecall 后系统崩溃 | 固件没保存寄存器或栈越界 | 用 OpenSBI 的 debug 版本,打开 M 模式 trap 日志 |
5.2 用 OpenSBI 的调试功能
OpenSBI 编译时加FW_DEBUG=1可以打开详细日志,每次 SBI 调用都会打印 EID、FID、参数和返回值。我在调试一个 HSM 问题时,就是靠日志发现内核传的 hartid 是 0,但实际 S 模式核从 1 开始,导致sbi_hart_start返回SBI_ERR_INVALID_PARAM。
编译命令:
make PLATFORM=generic FW_DEBUG=1然后把生成的fw_jump.bin替换到启动镜像里。日志会从串口输出,格式大概是:
SBI call: ext=0x54494D45 fid=0x0 arg0=0x12345678 SBI return: error=0x0 value=0x05.3 自己写一个 SBI 测试模块
如果想验证固件的 SBI 实现,可以写个内核模块,在加载时调用各个扩展并打印结果:
#include <linux/module.h> #include <asm/sbi.h> static int __init sbi_test_init(void) { struct sbiret ret; pr_info("SBI spec version: %lx\n", sbi_get_spec_version()); pr_info("SBI impl id: %lx\n", sbi_get_impl_id()); pr_info("TIME ext: %ld\n", sbi_probe_extension(SBI_EXT_TIME)); pr_info("IPI ext: %ld\n", sbi_probe_extension(SBI_EXT_IPI)); pr_info("HSM ext: %ld\n", sbi_probe_extension(SBI_EXT_HSM)); ret = sbi_ecall(SBI_EXT_BASE, SBI_EXT_BASE_GET_MVENDORID, 0, 0, 0, 0, 0, 0); pr_info("MVENDORID: error=%ld value=%lx\n", ret.error, ret.value); return 0; } static void __exit sbi_test_exit(void) { pr_info("sbi_test unloaded\n"); } module_init(sbi_test_init); module_exit(sbi_test_exit); MODULE_LICENSE("GPL");编译加载后dmesg就能看到所有扩展的探测结果。这个模块我放在每块新板子上跑一遍,五分钟就能摸清固件的能力边界。
5.4 QEMU 里的快速验证
没有真实硬件时,QEMU 是最好的试验场。启动命令:
qemu-system-riscv64 -M virt -m 2G -smp 4 \ -bios opensbi/build/platform/generic/firmware/fw_jump.bin \ -kernel Image \ -append "root=/dev/vda rw console=ttyS0" \ -drive file=rootfs.ext4,format=raw,id=hd0 \ -device virtio-blk-device,drive=hd0 \ -nographicQEMU 的 virt 机器默认实现了完整的 SBI 2.0 扩展,可以用来验证内核的 SBI 调用路径。如果 QEMU 上正常、真实硬件上异常,基本可以锁定是固件实现差异。
避坑技巧:QEMU 的
-smp参数会影响 hart 编号。-smp 4时 hart 0 到 3 都是 S 模式可用的,但有些真实平台 hart 0 被 M 模式独占。移植时一定要用cpuid_to_hartid_map做转换,别假设 cpuid 等于 hartid。
6. 扩展体系的设计哲学与未来演进
6.1 为什么厂商扩展要放在高地址
厂商扩展的 EID 从 0x08000000 开始,这个设计不是随意选的。0x08000000 是 2 的 27 次方,留出了 0x00 到 0x07FFFFFF 共 1.34 亿个编号给标准和实验性扩展。这样固件在收到一个 EID 时,可以先判断高位:如果小于 0x08000000,走标准处理流程;如果大于等于,查厂商扩展表。这种“分段路由”的思路在网络协议里很常见,比如 IP 地址的分类。
6.2 扩展版本号的兼容策略
每个扩展都有自己的版本号,通过sbi_probe_extension返回。比如 TIME 扩展返回 1 表示版本 1,返回 2 表示版本 2。内核在调用时应该根据版本号决定用哪些功能。SBI 规范要求高版本必须兼容低版本,也就是说版本 2 的扩展必须支持版本 1 的所有功能。这个原则保证了老内核能在新固件上跑。
但实际中我遇到过反例:某厂商的 SRST 扩展返回版本 2,但sbi_system_reset只认WARM_REBOOT,传COLD_REBOOT就返回SBI_ERR_NOT_SUPPORTED。所以探测到版本号只是第一步,具体功能还得实际调用验证。
6.3 从 SBI 看 RISC-V 的生态策略
SBI 的设计体现了 RISC-V 生态的一个核心策略:接口标准化,实现自由化。规范只定义“怎么调用”,不规定“怎么实现”。OpenSBI 是一个参考实现,但厂商可以写自己的固件,只要符合 SBI 规范,Linux 就能跑。这种模式降低了准入门槛,但也带来了碎片化风险——不同固件的 bug 和兼容性差异,最终都要内核来兜底。
我个人的体会是,做 RISC-V 移植时,先跑 OpenSBI,再换厂商固件。OpenSBI 的实现最规范,用它验证内核没问题后,再换厂商固件,如果出问题就能快速定位是固件的锅。这个“控制变量法”帮我省了很多时间。
6.4 后续可以扩展的方向
如果你已经搞定了基本的 SBI 调用,可以往这几个方向深入:一是研究 OpenSBI 的源码,看它怎么实现 ecall 的 trap handler;二是尝试自己写一个最小固件,只实现 Base 和 TIME,跑一个极简的 bare-metal 程序;三是研究 SBI 在虚拟化场景下的扩展,比如 HS 模式的 SBI 代理。这些内容每一个都能单独写一篇,后面有机会再展开。
最后分享一个我常用的调试命令,在 Linux 里直接读 SBI 规范版本:
cat /proc/cpuinfo | grep -i sbi有些内核版本会输出sbi: 2.0这样的信息。如果没有,就用前面那个测试模块。这个信息在报 bug 时很有用,能快速告诉别人你的固件能力边界。