1. 从一次固件调试翻车说起:RISC-V SBI 到底在管什么
第一次在 RISC-V 开发板上跑 Linux 的时候,我遇到一个很典型的问题:内核启动到一半卡死,串口只打印了几行 earlycon 信息就没了下文。当时我以为是设备树写错了,查了半天,最后发现是固件里的 SBI 实现有问题——时钟中断的委托配置没做对,导致内核在初始化定时器的时候拿不到正确的返回值。那次之后我才认真把 SBI 规范从头到尾读了一遍,也才真正理解为什么 RISC-V 要把固件接口单独抽出来做一层标准。
SBI,全称 Supervisor Binary Interface,中文一般叫“监管者二进制接口”。它是 RISC-V 特权架构里一个非常关键的抽象层:运行在 S 模式(Supervisor Mode)的操作系统内核,通过 SBI 调用向运行在 M 模式(Machine Mode)的固件请求服务。你可以把它理解成 RISC-V 世界里的“系统调用”,只不过普通系统调用是用户态向内核请求服务,而 SBI 是内核向固件请求服务。这个设计的好处是,操作系统不需要关心底层硬件具体是怎么实现的,只要固件按照 SBI 规范提供统一的接口,同一份内核镜像就能跑在不同厂商的芯片上。
这套接口覆盖的东西比很多人想象的要多。最基础的是定时器设置、IPI(处理器间中断)发送、远程栅栏(remote fence)这些核间协作功能;再往上有系统复位、关机、性能监控、调试控制台等。近几年的扩展体系越来越丰富,比如 HSM(Hart State Management)扩展负责核心的启停管理,SRST 扩展负责系统复位,PMU 扩展负责性能计数器,CPPC 扩展负责协作处理器性能控制,还有像 DBCN 这样的调试控制台扩展。每一个扩展都有自己的扩展 ID、函数 ID 和返回码约定,内核侧则通过对应的驱动去匹配和调用。
这篇文章我打算把 SBI 这套东西从规范到落地完整梳理一遍:扩展体系是怎么组织的,调用约定在寄存器层面长什么样,Linux 内核里又是怎么把这套接口映射成驱动和子系统的。中间会穿插我自己在调试固件和内核时踩过的坑,以及一些看规范文档不容易注意到但实际很要命的细节。不管你是做固件开发的、做内核移植的,还是单纯想搞明白 RISC-V 启动流程的,应该都能从里面找到有用的东西。
2. SBI 扩展体系与调用约定拆解
2.1 扩展 ID、函数 ID 与返回码的三层结构
SBI 的接口组织方式其实很规整,核心就是三个概念:扩展(Extension)、函数(Function)和返回码(Error Code)。每一次 SBI 调用,本质上就是“我要调用某个扩展下的某个函数,参数放在指定寄存器里,返回值也放在指定寄存器里”。
扩展 ID 是一个 32 位无符号整数,用来标识一组功能相关的接口。比如 TIME 扩展的 ID 是 0x54494D45,这个值其实是 ASCII 码“TIME”拼出来的,规范里很多扩展 ID 都用了这种可读性很强的编码方式。HSM 扩展是 0x48534D,SRST 是 0x53525354,PMU 是 0x504D55。这种设计的好处是调试的时候看寄存器值就能大概猜出是哪个扩展,比纯数字好记太多。
函数 ID 是在某个扩展内部区分具体功能的编号。比如 TIME 扩展只有一个函数 set_timer,函数 ID 就是 0。HSM 扩展下面有 hart_start、hart_stop、hart_get_status、hart_suspend 等好几个函数,各自有独立的编号。调用的时候,扩展 ID 放在 a7 寄存器,函数 ID 放在 a6 寄存器,这是 RISC-V SBI 规范里固定的约定。
返回码方面,SBI 定义了一套标准的错误码体系。成功返回 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,已经停止过了。这套错误码在内核侧会被转换成对应的 errno,比如 -2 会变成 -ENOTSUPP,-3 会变成 -EINVAL。
这里有个容易踩的坑:不同版本的 SBI 规范对某些错误码的定义可能有细微差别,而且有些固件实现会返回规范里没定义的错误码。我在调试的时候就遇到过某个国产芯片的固件在 hart_start 失败时返回了一个非标准的负值,内核侧因为没有对应的转换规则,直接把它当成了未知错误。所以如果你在做固件开发,尽量严格按规范返回标准错误码;如果你在做内核移植,遇到奇怪的返回值时不妨先查一下固件那边的实现。
2.2 寄存器调用约定:a7/a6 传 ID,a0/a1 传参数与返回
SBI 的调用约定在寄存器层面定义得非常明确,这也是它能做到“二进制接口”这个级别的关键。调用方(也就是 S 模式的内核)需要把扩展 ID 放到 a7,函数 ID 放到 a6,然后最多六个参数依次放到 a0 到 a5。发起调用的时候,执行ecall指令,CPU 会陷入 M 模式,固件接管处理。
固件处理完之后,返回值放在 a0 和 a1 里。a0 是错误码,0 表示成功,负值表示失败;a1 是函数的返回值,具体含义取决于函数本身。比如 TIME 扩展的 set_timer 函数,a0 返回错误码,a1 没有用到;而 HSM 扩展的 hart_get_status 函数,a0 返回错误码,a1 返回目标 hart 的当前状态。
这套约定看起来简单,但实际写代码的时候有几个细节特别容易出错。第一个是寄存器保存问题:ecall 指令本身不会自动保存寄存器,调用方需要自己保证 a0 到 a7 这些寄存器在调用前后的状态是可控的。内核里的 SBI 调用封装通常会把这些寄存器作为输入输出约束写在 inline assembly 里,让编译器去处理。
第二个是参数宽度问题。RISC-V 有 RV32 和 RV64 两种位宽,SBI 规范对这两种情况都有定义。在 RV32 上,有些参数需要拆成两个寄存器传递,比如 64 位的地址或长度。规范里对这种情况有明确的约定,但实际实现的时候如果没注意,很容易出现高低位搞反的问题。我在一个 RV32 的项目里就遇到过因为地址传递错误导致固件访问非法内存的情况,排查了很久才发现是参数拆分的问题。
第三个是 ecall 的返回路径。在 RISC-V 里,ecall 从 S 模式陷入 M 模式后,固件处理完需要通过 mret 指令返回 S 模式。这个过程中,mepc 寄存器保存的是 ecall 指令的下一条指令地址,所以返回后会继续执行 ecall 之后的代码。但如果固件在处理过程中修改了 mepc 或者 mstatus,返回后的行为就可能不符合预期。这也是为什么固件开发里对上下文保存和恢复的要求特别严格。
2.3 扩展的发现机制:probe 扩展与版本协商
SBI 规范里有一个很聪明的设计:扩展不是硬编码在规范里的,而是通过一个专门的 probe 机制来发现。这个机制本身也是一个扩展,叫 BASE 扩展,扩展 ID 是 0x10。BASE 扩展下面有几个函数,其中 get_spec_version 用来获取 SBI 规范的版本号,probe_extension 用来查询某个扩展是否可用,get_mvendorid、get_marchid、get_mimpid 用来获取厂商 ID、架构 ID 和实现 ID。
probe_extension 的用法很简单:把要查询的扩展 ID 放到 a0,调用 BASE 扩展的 probe_extension 函数,如果返回 0 表示该扩展可用,返回非 0 表示不可用。内核在启动的时候会遍历自己支持的扩展列表,逐个 probe,然后根据结果决定启用哪些功能。这种设计让内核可以在不同版本的固件上运行,有更好的向前兼容性。
版本协商方面,SBI 规范从 0.2 版本演进到 0.3,再到现在的 1.0 和 2.0,接口有一些变化。比如 0.2 时代的 legacy 调用(扩展 ID 为 0 到 0x0F 的那些)在 0.3 之后被标记为 deprecated,但为了兼容性仍然保留。内核里对 legacy 调用和扩展调用都有处理,启动时会根据 get_spec_version 的返回值来决定用哪套接口。
这里有个实际经验:有些老固件只实现了 legacy 调用,没有实现 BASE 扩展的 probe 机制。这种情况下内核会回退到 legacy 模式,但功能会受限。如果你在移植内核到一个新平台时发现某些功能不可用,可以先检查一下固件是否支持扩展 probe。我遇到过一块开发板的固件版本比较老,probe_extension 直接返回不支持,导致 HSM 扩展用不了,CPU 热插拔功能也就没法用。后来升级固件之后问题就解决了。
3. Linux 内核侧的 SBI 映射与驱动实现
3.1 从 ecall 到 sbi_ecall:内核里的调用封装
Linux 内核里对 SBI 调用的封装集中在arch/riscv/kernel/sbi.c这个文件里。最底层的函数是sbi_ecall,它负责把扩展 ID、函数 ID 和参数组装到寄存器里,执行 ecall,然后解析返回值。这个函数的实现用了 inline assembly,核心逻辑大概是这样:把 a7 设为扩展 ID,a6 设为函数 ID,a0 到 a5 设为参数,然后执行 ecall,最后从 a0 和 a1 里取出错误码和返回值。
在sbi_ecall之上,内核为每个扩展提供了更友好的封装函数。比如 TIME 扩展对应sbi_set_timer,HSM 扩展对应sbi_hart_start、sbi_hart_stop、sbi_hart_get_status、sbi_hart_suspend,SRST 扩展对应sbi_system_reset,PMU 扩展对应sbi_pmu_*系列函数。这些封装函数会处理错误码转换、参数检查等细节,让上层的驱动代码不用直接和寄存器打交道。
这里有一个设计上的细节值得注意:sbi_ecall在调用前后会做一些额外的检查。比如它会检查扩展 ID 是否在合法范围内,会处理 RV32 上的参数拆分,还会在调用失败时打印调试信息。这些检查在正常运行时开销很小,但在调试阶段非常有用。我在排查一个 IPI 发送失败的问题时,就是靠sbi_ecall里的调试打印定位到是目标 hart 的 ID 传错了。
另外,内核里对 SBI 调用的返回值处理有一套统一的规则。sbi_ecall返回的是固件给的原始错误码,上层的封装函数会把它转换成 Linux 的 errno。比如sbi_hart_start在遇到 SBI_ERR_ALREADY_STARTED 时会返回 -EALREADY,在遇到 SBI_ERR_INVALID_PARAM 时会返回 -EINVAL。这种转换让 SBI 的错误码能够融入到 Linux 的错误处理体系里,上层驱动可以用标准的方式处理错误。
3.2 HSM 扩展与 CPU 热插拔的对接
HSM 扩展是 Linux 内核里用得比较多的一个 SBI 扩展,它主要负责 hart 的状态管理。所谓 hart,就是 RISC-V 里的硬件线程,可以理解成其他架构里的 CPU 核心。HSM 扩展提供了 hart_start、hart_stop、hart_get_status、hart_suspend 四个函数,分别用来启动 hart、停止 hart、查询 hart 状态和挂起 hart。
在 Linux 里,HSM 扩展主要和 CPU 热插拔(CPU hotplug)以及 CPU 空闲(cpuidle)两个子系统对接。CPU 热插拔的场景下,当用户通过 sysfs 接口下线一个 CPU 时,内核会调用sbi_hart_stop让目标 hart 进入停止状态;上线时则调用sbi_hart_start让目标 hart 重新开始执行。这个过程涉及到复杂的核间同步,因为停止一个 hart 之前需要确保它已经完成了所有正在处理的工作,并且不会再被调度。
CPU 空闲的场景下,当某个 hart 没有任务可跑时,内核会调用sbi_hart_suspend让它进入低功耗状态。HSM 规范里定义了几种不同的挂起类型,比如 retentive 和 non-retentive,区别在于挂起期间 hart 的上下文是否保留。retentive 挂起唤醒后可以从挂起点继续执行,non-retentive 挂起唤醒后需要从指定的入口重新开始。内核会根据平台的支持情况选择合适的挂起类型。
这里有一个实际调试中经常遇到的问题:hart_start 的入口地址和参数传递。HSM 规范里 hart_start 接受三个参数:目标 hart 的 ID、启动入口的物理地址、以及一个传递给入口的不透明参数。固件在启动目标 hart 后,会让它从指定的入口地址开始执行,并且把不透明参数放到 a0 寄存器里。如果入口地址或者参数传错了,目标 hart 启动后就会跑飞到未知的地方,表现为系统挂死或者随机崩溃。我在调试 CPU 热插拔的时候就遇到过因为入口地址没有正确映射导致的问题,后来在固件里加了地址检查才定位到。
3.3 SRST 扩展与系统复位路径
SRST 扩展负责系统复位,扩展 ID 是 0x53525354。它只有一个函数 system_reset,接受两个参数:复位类型和复位原因。复位类型分为关机(shutdown)和冷复位(cold reboot)两种,复位原因则是一个自定义的数值,用来告诉固件这次复位是为什么发起的。
在 Linux 里,SRST 扩展主要和 reboot 子系统对接。当用户执行reboot命令或者通过 sysfs 触发重启时,内核最终会调用sbi_system_reset,把复位类型设为 cold reboot,然后固件执行实际的复位操作。关机流程类似,只是复位类型设为 shutdown。
这里有一个细节:SRST 规范里说,如果固件不支持某种复位类型,应该返回 SBI_ERR_NOT_SUPPORTED,而不是直接忽略。内核在收到这个错误码后,会尝试其他的复位方式,比如通过电源管理芯片或者看门狗。这种分层处理让系统在不同平台上都能找到合适的复位路径。
我在一个项目里遇到过 SRST 扩展返回 SBI_ERR_FAILED 的情况,排查后发现是固件里的复位原因参数没有正确初始化,导致固件认为这是一个非法请求。后来在固件里把复位原因默认值设成 0 就正常了。这个经验说明,虽然 SRST 的接口很简单,但固件实现里的细节还是不能马虎。
3.4 PMU 扩展与性能监控的映射
PMU 扩展是近几年才加入 SBI 规范的一个扩展,扩展 ID 是 0x504D55。它提供了一组接口用来配置和读取性能监控计数器,包括获取计数器数量、配置计数器、启动计数器、停止计数器、读取计数器值等功能。这个扩展的意义在于,RISC-V 的性能监控单元在不同厂商的实现里差异很大,通过 SBI 抽象之后,操作系统可以用统一的方式访问。
在 Linux 里,PMU 扩展主要和 perf 子系统对接。perf 是 Linux 下的性能分析工具,可以统计 CPU 周期数、指令数、缓存命中率等指标。当用户在 perf 里指定要监控某个事件时,内核会通过 PMU 扩展去配置硬件计数器,然后在需要的时候读取计数值。这个过程涉及到计数器的分配、复用、溢出处理等复杂逻辑。
PMU 扩展的一个难点是计数器数量的不确定性。不同平台的硬件计数器数量不一样,有的只有几个,有的有几十个。内核在初始化的时候会通过 PMU 扩展查询可用计数器的数量,然后根据这个数量来分配资源。如果计数器不够用,perf 会采用时间复用的方式,让多个事件轮流使用同一个计数器。这种复用会引入一定的测量误差,但在计数器资源有限的情况下是必要的妥协。
4. 固件实现与内核移植中的典型问题排查
4.1 扩展 probe 失败导致功能缺失的排查思路
扩展 probe 失败是移植过程中最常见的问题之一。表现是内核启动日志里出现类似 “SBI extension 0x48534d not available” 的提示,然后对应的功能就用不了。排查这个问题,我一般按下面的顺序来。
先确认固件是否真的实现了这个扩展。有些固件的代码里可能只实现了一部分扩展,或者实现了一个扩展但 probe 函数没有正确返回。可以在固件侧加打印,看看 probe_extension 被调用时返回了什么。如果固件返回 0 但内核仍然认为不可用,那可能是内核侧的解析逻辑有问题,比如扩展 ID 的大小端搞反了。
再确认 SBI 规范的版本。有些扩展是在较新的规范版本里才加入的,如果固件基于老版本规范实现,自然不会有这些扩展。可以通过 BASE 扩展的 get_spec_version 函数查询固件支持的规范版本,然后对照规范文档确认目标扩展是否在该版本里。
最后确认内核配置。有些 SBI 扩展的支持是可以通过内核配置项开关的,如果配置项没打开,即使固件支持,内核也不会去 probe。比如 PMU 扩展的支持就和 CONFIG_RISCV_PMU_SBI 这个配置项相关。
4.2 调用返回非法错误码的定位方法
固件返回非标准错误码的情况在实际项目里并不少见。内核侧收到这些错误码后,可能会打印警告或者直接当成未知错误处理。定位这类问题,关键是找到错误码的来源。
一种方法是在内核的sbi_ecall函数里加打印,把每次调用的扩展 ID、函数 ID、参数和返回值都打出来。这样可以看到是哪个调用返回了异常值。另一种方法是在固件侧加打印,记录每次 ecall 的处理过程和返回值。两边对照,基本就能定位到问题。
如果错误码是间歇性出现的,那可能是并发或者时序问题。比如多个 hart 同时调用同一个 SBI 函数,固件侧如果没有做好并发保护,就可能返回不一致的结果。这种情况下需要在固件侧加锁或者用原子操作来保护共享状态。
4.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 内核启动卡死,无后续输出 | 定时器设置失败或时钟中断未委托 | 检查 TIME 扩展调用返回值,确认 mcounteren 和 mip 配置 | 修正固件里的定时器实现和中断委托配置 |
| CPU 热插拔失败 | HSM 扩展不可用或 hart_start 参数错误 | probe HSM 扩展,检查入口地址和参数传递 | 升级固件支持 HSM,修正入口地址映射 |
| 系统无法重启 | SRST 扩展返回错误或未实现 | 检查 sbi_system_reset 返回值,确认复位类型 | 实现 SRST 扩展或回退到其他复位方式 |
| perf 无法统计事件 | PMU 扩展不可用或计数器数量不足 | probe PMU 扩展,查询计数器数量 | 启用 PMU 扩展支持,调整 perf 事件配置 |
| SBI 调用返回未知错误码 | 固件返回非标准错误码 | 在内核和固件两侧加打印,对照调用记录 | 固件侧修正为标准错误码,内核侧增加容错处理 |
| RV32 上地址传递错误 | 64 位参数拆分不当 | 检查参数拆分逻辑,确认高低位顺序 | 按规范正确拆分参数,增加参数校验 |
4.4 几个容易忽略的实操细节
第一个细节是 ecall 指令的编码。在 RISC-V 里,ecall 的编码是固定的,但不同特权级别的 ecall 有不同的行为。从 S 模式发起的 ecall 会陷入 M 模式,从 U 模式发起的 ecall 会陷入 S 模式。SBI 调用用的是前者。如果在固件里错误地处理了 ecall 的来源,可能会导致调用被路由到错误的地方。
第二个细节是中断使能状态。SBI 调用期间,M 模式的中断使能状态会影响固件的行为。如果固件在处理 SBI 调用时没有正确管理中断,可能会出现嵌套中断或者中断丢失的问题。规范里对这一点有建议,但具体实现还是取决于固件开发者。
第三个细节是缓存一致性。SBI 调用涉及到 S 模式和 M 模式之间的数据传递,如果参数里包含指针,需要确保两个模式看到的内存是一致的。在有些平台上,M 模式和 S 模式的缓存视图可能不同,需要通过 fence 指令或者非缓存内存来保证一致性。这个问题在调试 DMA 相关的 SBI 调用时特别容易遇到。
5. 从规范到落地:一些个人经验
SBI 这套东西刚接触的时候会觉得有点绕,毕竟多了一层固件抽象,调试的时候不像直接操作硬件那么直观。但用久了就会发现,这层抽象带来的好处远大于麻烦。同一份内核镜像能跑在不同芯片上,靠的就是 SBI 这层标准接口。而且随着扩展体系越来越丰富,SBI 能做的事情也越来越多,从最基本的定时器和 IPI,到性能监控和调试控制台,覆盖面已经很广了。
我在实际项目里最大的体会是:固件和内核的版本匹配非常重要。SBI 规范本身在演进,不同版本的固件和内核之间可能存在兼容性问题。比如老固件不支持扩展 probe,新内核可能就找不到某些扩展;或者新固件返回了新的错误码,老内核不认识。所以在做产品开发的时候,最好把固件和内核的版本对应关系固定下来,避免因为版本错配导致奇怪的问题。
另一个体会是调试手段要提前准备好。SBI 调用发生在 M 模式和 S 模式的边界上,普通的调试工具不一定能直接看到调用过程。我的做法是在固件侧加一个可配置的调试开关,打开后把每次 ecall 的扩展 ID、函数 ID、参数和返回值都通过串口打出来。这个调试通道在排查问题时非常有用,比在内核侧猜要高效得多。
最后分享一个小技巧:如果你在写固件,建议把每个扩展的实现做成独立的模块,通过一个注册表来管理。这样 probe 函数只需要查表就能知道某个扩展是否可用,添加新扩展的时候也不用改动核心逻辑。内核侧的驱动也是类似的思路,每个扩展对应一个独立的驱动文件,通过 probe 结果来决定是否初始化。这种模块化的设计在扩展数量越来越多的时候会体现出明显的优势。