ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RISC-V SBI 从规范到落地:扩展体系、调用约定与 Linux 内核驱动实现

RISC-V SBI 从规范到落地:扩展体系、调用约定与 Linux 内核驱动实现 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这些核间协作功能再往上有系统复位、关机、性能监控、调试控制台等。近几年的扩展体系越来越丰富比如 HSMHart 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 扩展是 0x48534DSRST 是 0x53525354PMU 是 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 传 IDa0/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 设为扩展 IDa6 设为函数 IDa0 到 a5 设为参数然后执行 ecall最后从 a0 和 a1 里取出错误码和返回值。在sbi_ecall之上内核为每个扩展提供了更友好的封装函数。比如 TIME 扩展对应sbi_set_timerHSM 扩展对应sbi_hart_start、sbi_hart_stop、sbi_hart_get_status、sbi_hart_suspendSRST 扩展对应sbi_system_resetPMU 扩展对应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 结果来决定是否初始化。这种模块化的设计在扩展数量越来越多的时候会体现出明显的优势。
返回列表