ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

RISC-V SBI 从规范到落地:扩展体系、调用约定与 Linux 映射全解析

RISC-V SBI 从规范到落地:扩展体系、调用约定与 Linux 映射全解析 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、关机。所有功能挤在一个命名空间里靠EIDExtension ID区分。这就像一家公司只有一个总机号码所有部门都靠分机号找分机号还经常冲突。到了 0.2 和 0.3社区发现这样不行新硬件功能越来越多如果每个功能都往主规范里塞规范会变得又臭又长而且厂商的私有功能没法标准化。于是从SBI 1.0开始正式引入了“扩展”概念把功能按类别拆成独立的扩展每个扩展有自己的 EID 和版本号。这个思路跟 Linux 内核的子系统划分、跟 USB 的设备类规范是一个逻辑核心保持精简功能按需扩展。2.2 扩展的三大分类基础、通用、厂商按 SBI 2.0 规范扩展大致分三类我整理成表格方便对照分类代表扩展EID 范围作用是否必须实现基础扩展Base、TIME、IPI、RFENCE0x00-0x0F定时器、核间中断、内存屏障、关机重启Base 必须其余强烈建议通用扩展HSM、SRST、PMU、CPPC、DBTR0x10-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获取固件实现 IDOpenSBI 是 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多核世界的交通指挥IPIInter-Processor Interrupt扩展负责核间中断主要函数是sbi_send_ipi参数是一个位图hmask每一位对应一个 hart。比如你要给 hart 0 和 hart 2 发中断就传0b101。RFENCERemote 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核的启停和系统复位HSMHart State Management扩展是 SBI 1.0 引入的重头戏它定义了核的生命周期状态机STOPPED、STARTED、SUSPENDED、SUSPENDED_RETURN。对应的函数有sbi_hart_start启动一个核参数是 hartid、起始地址、私有数据。sbi_hart_stop停止当前核。sbi_hart_get_status查询核状态。sbi_hart_suspend让核进入低功耗状态。SRSTSystem Reset扩展负责系统级复位sbi_system_reset可以执行关机、冷启动、热启动。参数里有个reset_type和reset_reasonLinux 的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寄存器FIDFunction ID放到a6其余参数按顺序放到a0到a5。执行 ecall在 S 模式执行ecallCPU 陷入 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_SUCCESS0成功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 搞混其实两者层次完全不同对比项SBIsyscall发起方S 模式内核U 模式应用目标M 模式固件S 模式内核指令ecallecall参数寄存器a0-a7a0-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_ecallstruct 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 到 SBILinux 的定时器子系统在 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 看 ecallIPI 发不出去多核调度异常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_DEBUG1可以打开详细日志每次 SBI 调用都会打印 EID、FID、参数和返回值。我在调试一个 HSM 问题时就是靠日志发现内核传的 hartid 是 0但实际 S 模式核从 1 开始导致sbi_hart_start返回SBI_ERR_INVALID_PARAM。编译命令make PLATFORMgeneric FW_DEBUG1然后把生成的fw_jump.bin替换到启动镜像里。日志会从串口输出格式大概是SBI call: ext0x54494D45 fid0x0 arg00x12345678 SBI return: error0x0 value0x05.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 consolettyS0 \ -drive filerootfs.ext4,formatraw,idhd0 \ -device virtio-blk-device,drivehd0 \ -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 时很有用能快速告诉别人你的固件能力边界。
返回列表