0%

0. 引子:按下电源到内核接管,中间这段最神秘

按下电源键,到屏幕上滚出第一行内核日志,中间有一段几乎没有人看得见的旅程。这段旅程里没有操作系统,没有 printf,没有调试器,没有文件系统,甚至最开始连内存都不能用。CPU 从一个写死在芯片里的地址取出第一条指令,在一片荒地上一砖一瓦地把自己能用的世界搭起来——初始化时钟、训练内存、建立串口、找到下一段代码、校验它、跳过去——如此接力四五棒,直到把控制权郑重交给内核的第一条指令。

这段旅程的主角,是固件(firmware)与引导(boot)——要拆解的,正是它从上电到内核入口之间的每一棒,收束点精确落在内核拿到 CPU 的第一条指令之前

为什么单独花一篇大笔记讲”之前”?因为这段最容易被跳过,也最容易被误解。大多数人对启动的认识停在”BIOS 然后 GRUB 然后开机”,而真实的启动链是一条有五六棒的接力,每一棒都有它存在的硬件理由:有的因为复位时 DRAM 还不能用,有的因为需要一个比内核更高的特权级来管硬件,有的因为要在不可信的世界外面再套一层可信的世界。把这条链拆开,每一棒”为什么出现、解决了什么、被谁取代”,才算真的看懂了一台机器是怎么活过来的。

贯穿全篇会反复出现四种指令集架构,约定缩写:RISC-V(rv)x86/x64(x86)aarch64/arm(aa)LoongArch(la)。其中 rv 特权架构干净、从 reset vector 到内核每条指令全程开源,是把启动链看到底的最佳样本。

目标不只是”看懂”,而是”能写”:理解到能自己动手实现一个最小的 SBI 固件、最小的 SPL、最小的 UEFI 引导程序、最小的裸机 booter,并在 QEMU 上跑通。


1. 总纲:从上电到内核的六级接力(L0 → L5)

无论哪种架构,从上电到内核都遵循同一个骨架:一段接一段地”加载下一段、移交控制权”,每一段比上一段能力更强、运行环境更完整。我们把它抽象成六级接力,记作 L0 到 L5。

1
2
3
4
5
6
7
8
flowchart TD
P[上电 / 复位] --> L0
L0["L0 BootROM / ZSBL / Reset Vector<br/>芯片内不可改 ROM,出厂烧死<br/>选启动设备、加载下一段到片上 SRAM"] --> L1
L1["L1 FSBL / SPL<br/>第一段可换固件<br/>核心 = DDR training,初始化主存"] --> L2
L2["L2 机器态固件 / 安全监控<br/>rv SBI · aa TF-A BL31+OP-TEE · x86 SMM<br/>最高特权级常驻,向下提供服务"] --> L3
L3["L3 SSBL 第二级引导器<br/>U-Boot proper · UEFI BDS · GRUB · barebox<br/>有命令行、驱动、脚本,能读文件系统"] --> L4
L4["L4 OS Loader<br/>按 boot protocol 把内核镜像搬进内存<br/>准备 cmdline / initrd / 设备树"] --> L5
L5["L5 内核第一条指令<br/>固件与引导世界的终点"]

逐级说明它”是什么、为什么存在、做完什么就移交”:

级别 角色 为什么需要这一棒 典型产物
L0 BootROM / ZSBL / Reset Vector 复位瞬间 CPU 一无所有:无 RAM、无外设、不知从哪取码。必须有一段出厂固化、永不失效的代码托底 选定启动设备,把 L1 搬进片上 SRAM 并跳入
L1 FSBL / SPL 主存 DRAM 上电后是一片乱码,必须先做 DDR training 才能用;这段代码因此只能挤在几十 KB 的片上 SRAM 里 主存可用,加载更大的 L2/L3 到 DRAM
L2 机器态固件 / 安全监控 需要一个比内核更高的特权级,常驻于此,统一管理底层硬件、提供运行时服务、隔离可信与不可信世界 设置特权委托与内存保护,降级跳入 L3
L3 SSBL(第二级引导器) 需要一个”功能丰富”的引导环境:能读文件系统、跑命令行、走网络、解析脚本、按需选择启动项 找到并加载内核镜像与配套数据
L4 OS Loader 内核镜像有固定的交接格式(boot protocol),必须按规矩把它放到正确地址、对齐 ABI、备好参数 内核镜像在内存就位,寄存器按约定填好
L5 内核入口 —— 内核第一条指令开始执行

几点要点,贯穿全篇:

  • 同构异形:六级骨架三大架构通用,但每一棒的”肉”长得很不一样。比如 L2 这一棒,rv 是 SBI,aa 是 TF-A 加 OP-TEE,x86 是藏在固件里的 SMM——它们解决的是同一类问题(最高特权级的常驻服务与隔离),实现却天差地别。
  • 棒数会伸缩:六级是”最完整”的形态。开发与虚拟机里常常合并或省略某几棒(例如 QEMU 上直接 -bios 加载 SBI,跳过 L0/L1;或 OpenSBI 内嵌内核当 payload,省掉 L3/L4)。真硬件量产则往往六棒齐全。
  • 每一棒都在回答两个问题:上一棒把什么交给我(入口地址、参数寄存器、机器状态)?我做完之后把什么交给下一棒?把这两个”接口”摸清楚,就摸清了整条链。

2. 一眼看全:rv · x86 · aa · la

在钻进任何一棒的细节之前,先把几条线整体摊开,让”它们都长什么样”一眼可见。先看一张总览表(六级 × 各架构),再并置几段简史,最后用一段话把它们贯起来。

2.1 启动链总览表

级别 rv x86 aa la
L0 BootROM 复位向量 + ZSBL(片内 mask ROM);QEMU virt 是 0x1000 处十余条指令的 stub 复位向量 0xFFFFFFF0(16 位实模式),芯片组 + ME/PSP 早期初始化 片内 mask ROM(Boot ROM),选启动设备,支持救砖下载模式 片内 BootROM(同 aa 路线)
L1 FSBL/SPL U-Boot SPL / HSS(PolarFire),DDR training 早期无独立 FSBL,由 BIOS/coreboot 的 romstage 兼任(cache-as-RAM 做 DDR) TF-A BL1 + BL2 / Xilinx FSBL,DDR training UEFI 固件早期 init(含 DDR)
L2 机器态/安全 SBI(M-mode):OpenSBI / RustSBI / BBL / 自研 SMM(系统管理模式)/ SGX / TDX / SEV TF-A BL31(EL3 安全监控)+ OP-TEE BL32(安全 EL1);PSCI UEFI 运行时(无独立机器态规范层)
L3 SSBL U-Boot proper / UEFI(EDK2 或 U-Boot 内置) 传统 BIOS(INT 调用 + MBR)/ UEFI BDS / GRUB U-Boot proper / UEFI / barebox UEFI + GRUB(早期 PMON)
L4 OS Loader U-Boot booti / GRUB / EFI stub GRUB / Windows Boot Manager / systemd-boot U-Boot booti / GRUB / EFI stub GRUB / EFI stub
L5 交接 ABI mret 进 S-mode,a0=hartid,a1=dtb 物理地址 bzImage 实模式/保护模式/长模式分段交接,boot_params 降到 EL1/EL2,x0=dtb 物理地址 UEFI 交接进内核
硬件描述 Device Tree(FDT/dtb) ACPI 为主(DT 极少) Device Tree 为主,服务器走 ACPI ACPI(服务器)/ DT
中断控制器 CLINT/ACLINT + PLIC/APLIC+IMSIC LAPIC + IOAPIC / MSI GIC(v2/v3/v4) 自有(extioi 等)

这张表是全篇的地图。后续每一章都在放大其中一格,而 rv 那一列是放得最大的一列。

2.2 三线简史并置:同一道难题的三种演化

三条线各自的固件史,本质都在回答同一个问题——“复位之后怎么一步步把机器带起来、又怎么把硬件细节对内核屏蔽掉”。但它们出发的年代、背负的兼容包袱不同,于是走出了三条形状各异的路。

x86:从 BIOS 到 UEFI,一部兼容性史。 1981 年 IBM PC 的 BIOS(Basic Input/Output System)把”开机自检 + 从磁盘第一个扇区加载引导代码”固化下来,16 位实模式、INT 13h 读盘、512 字节 MBR——这套接口一用就是二十多年,靠的是死磕向后兼容。问题是它太老:实模式只有 1MB 地址空间,MBR 只能管 2TB 盘,没有可扩展的驱动模型。1998 年 Intel 为安腾另起炉灶做 EFI,2005 年交给 UEFI Forum 演化成 UEFI,2007 年随 Tianocore/EDK2 开源。UEFI 用 32/64 位、GPT 分区、FAT32 的 EFI 系统分区(ESP)、Protocol/GUID 的驱动模型、Boot Manager 的启动项管理,系统地取代了 BIOS。今天 PC/服务器固件几乎全是 UEFI,但仍保留 CSM(兼容支持模块)模拟老 BIOS——又是兼容性。

aa:从各家乱战到 TF-A 统一。 早期 aa 没有统一固件标准,每家 SoC 的 BootROM、第一级加载器各搞各的。随着 ARMv8 引入 EL0–EL3 四个异常级,需要一个跑在最高 EL3、负责安全世界与非安全世界切换的”安全监控”。aa 阵营于是推出 Trusted Firmware-A(TF-A),把启动切成 BL1(BootROM 之后第一段)、BL2(加载器)、BL31(EL3 常驻安全监控,实现 PSCI 电源接口)、BL32(安全世界 OS,常用 OP-TEE)、BL33(非安全引导器,常用 U-Boot 或 UEFI)。TrustZone 把世界一分为二,TF-A + OP-TEE 是这套分裂的看门人。

rv:后发者的干净起步。 rv 2010 年才在伯克利起步,没有历史包袱,从一开始就把”机器态固件接口”标准化。最早是 BBL(Berkeley Boot Loader,源于 riscv-pk 项目),在 M-mode 提供一层薄薄的环境给上面的内核;随着生态成熟,这层被抽象成 SBI(Supervisor Binary Interface)规范——S-mode 内核通过 ecall 调用 M-mode 固件的标准服务(定时器、核间中断、远程 fence、热插拔、关机等)。OpenSBI(C,Western Digital 主导)成为参考实现,RustSBI(Rust)是另一套主流实现。SBI 之于 rv,约等于 PSCI + 一部分固件运行时服务之于 aa,又约等于被规范化、被开源、被简化的那部分 BIOS/UEFI 运行时之于 x86。这条线最年轻,却也最清爽。

把三段史叠在一起看,会发现一条共同的暗线:早期是”实现即标准”(BIOS、各家 aa 固件),后来都走向”规范与实现分离”(UEFI 规范 + EDK2 实现、SBI 规范 + OpenSBI/RustSBI 实现、PSCI 规范 + TF-A 实现)。 规范化让内核不必关心固件是谁家的——这正是固件层存在的根本意义:向上屏蔽硬件与厂商差异。

2.3 一段贯通:同一件事,三种长相

把镜头拉到最高,三条线做的是同一件事,分四步走:

  1. 托底(L0)。复位后从一个固定地址取第一条指令,这段代码出厂烧死、永不可改。rv 是复位向量加 ZSBL,x86 是 0xFFFFFFF0 的复位向量,aa 是片内 mask ROM。它们都只做最低限度的事:选好从哪个设备继续,把下一段搬到能跑的地方。

  2. 唤醒主存(L1)。复位时 DRAM 不可用,必须有一段小代码挤在片上 SRAM 里完成 DDR training,把主存激活。rv/aa 是 SPL/FSBL,x86 早期把这步藏在 BIOS/coreboot 的 romstage 里用 cache-as-RAM 顶着。这一步做完,机器才第一次有了”大内存”。

  3. 架起常驻服务与可信边界(L2)。需要一个最高特权级的常驻固件:对内核屏蔽底层差异、提供运行时服务、隔离可信世界。rv 是 SBI(M-mode),aa 是 TF-A BL31 加 OP-TEE(EL3/安全 EL1),x86 是 SMM 加 SGX/TDX/SEV 这类机密计算扩展。

  4. 挑选并加载内核(L3–L5)。一个功能丰富的引导器(U-Boot/UEFI/GRUB/barebox)读文件系统、走网络、解析配置、选出内核,按 boot protocol 搬进内存、对齐寄存器约定,最后一跳交给内核第一条指令。

四步之中,第 3 步(L2)是三条线差异最大、也最值得花笔墨的一棒,而 rv 的 SBI 又是其中结构最清晰的样本。

2.4 la 一瞥

la 是龙芯自研指令集,启动思路与 aa/x86 同源而非另立门户:桌面/服务器平台走 UEFI(基于 EDK2 的龙芯移植)加 GRUB;早期与嵌入式平台有 PMON(一个历史悠久的类 BIOS 引导器,本仓 boot/pmon 即是)。它有自己的特权级(PLV0–PLV3)与异常入口,但在”六级接力”的框架里完全对得上号:BootROM → 固件 → 引导器 → 内核。


3. L0:上电第一条指令——Reset Vector / BootROM / ZSBL

复位(reset)是一切的起点。复位信号拉低再拉高,CPU 内部寄存器回到确定的初值,程序计数器 PC 被置成一个架构规定或芯片固定的地址,然后取出第一条指令。这个地址叫复位向量(reset vector),它指向的那段出厂固化代码,就是 L0。

L0 的处境是整条链里最艰难的:没有 DRAM(只有寄存器和极少量片上 SRAM/cache 可用),没有初始化好的外设,不知道从哪个设备继续启动,甚至时钟可能还是最慢的初始频率。它能做的事极有限,目标也极明确:做最低限度的初始化,选好启动设备,把下一段(L1)搬到能执行的地方,然后跳过去。

下面先看 rv,再对照 x86 与 aa,最后归纳 L0 的共性职责。

3.1 rv:复位向量 + ZSBL

rv 特权规范并未把复位向量的具体数值钉死,而是交给平台实现。真实 SoC 上,复位向量指向片内的一块 mask ROM(也叫 BootROM、BROM,或在 SBI 语境里称 ZSBL,Zeroth Stage Boot Loader——“第零级引导器”,强调它在 SBI 之前)。这块 ROM 几 KB 到几十 KB,出厂时光刻进芯片,无法更改。

真机 ZSBL 的职责相当实在:

  • 初始化最基础的时钟(PLL),让 CPU 跑到可用频率;
  • 根据 GPIO/eFuse/拨码开关选择启动设备(SD 卡 / SPI Flash / eMMC / USB 下载模式);
  • 把第一段可换固件(SPL)从启动设备读进片上 SRAM(此时 DRAM 还不能用);
  • 可选地校验 SPL 的签名(这是 Verified Boot 信任链的根,详见 §9.2);
  • 提供救砖入口:当正常启动失败或被强制时,进入串口/USB 下载模式(全志的 FEL、瑞芯微的 MaskROM 模式等),让主机灌入固件。

几个代表性平台的 ZSBL 形态:

平台 L0 形态 大致体量 行为概要
SiFive FU540/FU740(HiFive Unmatched) 片内 mask ROM ~32 KB 读 GPIO 选启动设备,把 SPL 加载进 L2 cache-as-RAM
StarFive JH7110(VisionFive 2) 片内 BootROM(BROM) 几十 KB 选 SD/Flash,加载含 OpenSBI 的 SPL
Allwinner D1 brom ~32 KB 加载 SPL;失败则进 FEL 下载模式
SpacemiT K1 片内 BootROM 几十 KB 加载 SPL 到片上 SRAM
Microchip PolarFire SoC HSS(Hart Software Services) MB 级 比典型 ZSBL 复杂得多,是用户可编译的固件而非 mask ROM
QEMU virt 0x1000 处的 stub ~10 条指令 a0=hartid、a1=dtb,跳 0x80000000

QEMU 把 L0 简化到了极致,正好用来看清 L0 的最小本质。QEMU virt 复位后 PC = 0x1000,那里是 QEMU 预置的一小段复位代码(等价于真机 ZSBL,逆向自 QEMU 源码):

1
2
3
4
5
6
7
# QEMU virt 复位向量 @ 0x1000
0x1000: auipc t0, 0x0 # t0 = 0x1000(取当前 PC)
0x1004: addi a2, t0, 40 # a2 = 0x1028
0x1008: csrr a0, mhartid # a0 = 当前 hart 的硬件 ID
0x100c: ld a1, 32(t0) # a1 = dtb 物理地址(QEMU 自动放好)
0x1010: ld t0, 24(t0) # t0 = 0x80000000(下一段入口)
0x1014: jr t0 # 跳到 0x80000000

这十几条指令,就是 QEMU 版 L0 的全部:读出 hartid 放进 a0,读出设备树地址放进 a1,跳到 0x80000000 这里已经埋下了贯穿全链的一条 ABI 暗线——a0=hartid、a1=dtb,从 L0 一路传到内核(§8 详述)。

关于 0x80000000 这个地址:它不是规范强制,而是事实标准。SiFive 早期 SoC 把 DRAM 映射到 0x80000000,QEMU virt 抄了 SiFive 的布局,后续 rv 板(VisionFive、SpacemiT K1、Allwinner D1 等)几乎全部沿用。对比之下 aa 板的 DRAM 起点五花八门(0x00x400000000x80000000 都有),全靠设备树描述;rv 这一点要齐整得多。

3.2 x86:复位向量 0xFFFFFFF0 与实模式

x86 的 L0 背着最重的历史包袱。CPU 上电复位后进入 16 位实模式,PC(准确说是 CS:IP)被硬件置为 0xFFFFFFF0——注意这是接近 4GB 顶端的地址,而不是低地址。芯片组把这个地址映射到主板上的固件 Flash(SPI Flash)的顶部,那里放着固件的复位入口。0xFFFFFFF0 距 4GB 只有 16 字节,刚够放一条跳转指令,跳到固件的真正初始化代码。

为什么是实模式、为什么在 4GB 顶端?这是从 8086 一路继承下来的兼容性约定:早期 BIOS 固件就映射在地址空间高端,复位向量自然落在那里;为了让几十年的固件和引导代码继续能跑,现代 x86 CPU 复位时仍旧装成一颗 16 位实模式的 8086,然后由固件一步步把它”升级”成 32 位保护模式、再到 64 位长模式。

现代 x86 的 L0 远比一条跳转复杂:在 x86 主核取第一条指令之前,往往已有一颗独立的管理核先跑过一轮——Intel 的 ME(Management Engine)或 AMD 的 PSP(Platform Security Processor)。它们负责最早期的硬件门禁与固件验证,是 x86 信任链真正的根。主核复位后,固件(传统 BIOS 或 UEFI 的 SEC 阶段)接手,逐步初始化芯片组、内存控制器、把 CPU 切到保护模式。x86 没有独立的”SPL”概念,DDR 初始化这一步被吸收进固件早期阶段(见 §4.4)。

3.3 aa:片内 mask ROM 与救砖模式

aa 的 L0 与 rv 形态最接近:片内一块出厂固化的 Boot ROM(mask ROM)。复位后 CPU 从复位向量进入 Boot ROM,由它根据启动引脚/eFuse 选择启动设备,把第一级加载器(TF-A 的 BL1 或厂商 FSBL)读进片上 SRAM 并跳入。

aa 阵营把”救砖模式”做得尤其成熟,因为嵌入式设备一旦固件刷坏就需要从外部重新灌入:

  • NXP i.MX 的串口/USB 下载模式(Serial Download Protocol);
  • 全志的 FEL 模式(USB);
  • 高通的 EDL(Emergency Download,9008 模式);
  • 苹果设备的 DFU(Device Firmware Update)。

这些模式本质都一样:当 Boot ROM 发现正常启动设备无效、或检测到强制信号时,转而从 USB/串口等待主机推送一段固件到 SRAM 执行,从而绕过损坏的存储。这是 Boot ROM 作为”永不失效托底层”的价值所在——只要芯片还活着,就有一条进得去的后门(受签名校验约束)。

3.4 共性归纳:L0 的职责清单

抛开三家的外形差异,L0 这一棒做的事高度一致,可归纳为八项(不是每个平台都全做,但都从这张清单里取):

  1. 把 CPU 从复位的混沌态带到一个确定、可执行代码的最小状态;
  2. 初始化最基础的时钟(让 CPU 跑到可用频率);
  3. 选择启动设备(GPIO/eFuse/拨码/优先级表);
  4. 把下一段固件(L1)从启动设备读进片上 SRAM(因为 DRAM 还不能用);
  5. 建立信任链的根:校验下一段的签名(Verified/Secure Boot 起点);
  6. 提供救砖/恢复入口(串口/USB 下载模式);
  7. 传递最基本的交接信息(如 rv 的 hartid、dtb 地址);
  8. 跳入 L1,移交控制权。

L0 永远是不可改的(mask ROM 或芯片组写死),这既是它的可靠性来源(永远托底),也是它的局限(有 bug 也改不了,只能靠后续固件绕开)。理解了 L0 的”一无所有”,才能理解下一棒 L1 为什么必须先做一件大事——唤醒主存。


4. L1:第一段可换固件——FSBL / SPL,灵魂是 DDR training

L0 把控制权交出来时,机器仍然只有寄存器和片上 SRAM 可用,主存 DRAM 还是一片不能读写的乱码。L1 这一棒的核心使命,就是把 DRAM 唤醒,然后把更大的后续固件加载进这片刚激活的主存。它在不同体系里有不同名字——rv 与 U-Boot 称 SPL(Secondary Program Loader),aa 的 TF-A 称 BL1/BL2,Xilinx 称 FSBL(First Stage Boot Loader),x86 没有独立的 L1,把这步吸收进了固件早期阶段——但它们做的是同一件事。

4.1 为什么必须有这一棒:DRAM 的”鸡生蛋”困局

DRAM(动态随机存取存储器)不像 SRAM 那样上电即用。它由电容存储电荷表示比特,需要一个内存控制器按精确时序去刷新、寻址、读写;而高速 DDR 接口(DDR3/4/5,数据率达每秒数十亿次传输)的物理信号还必须经过一轮校准才能可靠工作。这套初始化与校准,统称 DDR 初始化与 DDR training

这就形成了一个”鸡生蛋”的困局:

  • 要用 DRAM,得先运行一段初始化它的代码;
  • 但这段代码不能放在 DRAM 里(它还没初始化);
  • 只能放在容量极小的片上 SRAM(几十 KB 到几百 KB)里运行。

于是 L1 的两条铁律就被钉死了:第一,它必须极小(要塞进 SRAM);第二,它必须可换(DDR 参数随板卡、随内存颗粒、随走线而变,写死在 mask ROM 里行不通,必须做成可重新编译、可烧录的固件)。这正是 L0(不可改、托底)与 L1(可改、专门唤醒主存)分成两棒的根本原因。

4.2 DDR training 深讲:为什么非得运行时校准

DDR training 是 L1 里技术含量最高、也最容易被一笔带过的部分。它之所以无法在编译期算好、必须上电实测,根源在物理:DDR 总线上,时钟/选通信号(DQS)与数据信号(DQ)从控制器到内存颗粒,经过不同长度的走线、不同的负载、随温度与电压漂移,到达时间各不相同。在每秒数十亿次传输的速率下,这点时间差足以让采样采到错误的电平。training 就是逐根信号去寻找正确的采样延迟与参考电压,把采样窗口对准数据眼图的中心。

典型的 training 步骤(DDR4 为例):

阶段 校准目标 解决的问题
Write Leveling DQS 与 CK 时钟对齐 补偿 fly-by 走线导致各颗粒时钟到达不同步
Read Gate Training 读选通门控窗口 找到读数据何时到达,开正确的接收门
Read/Write Leveling 每根 DQ 相对 DQS 的延迟 把每比特采样点对准数据眼中心
VREF Training 参考电压阈值 把判 0/1 的电压门限调到最佳

这些参数只能上电后在目标硬件上实测得出——工艺偏差、板级走线、内存颗粒批次、当前温度,编译期一概无从得知。这就是 DDR training 必须运行时做、且必须由一段”贴着硬件”的固件来做的原因。为了缩短后续启动时间,训练得到的参数常被缓存进 eMMC/Flash,下次启动直接载入、跳过部分重训。

理解了这一点,”L1 为什么必须在片上 SRAM 里跑、为什么必须极小”就顺理成章了:它运行时,DRAM 尚不可用。x86 没有大块片上 SRAM,于是用了另一招——Cache-as-RAM(CAR):把 CPU 的 L2/L3 cache 临时锁成一块可读写的 RAM,让早期代码(包括 DRAM 初始化)有地方运行栈和变量,等 DRAM 训练好再切换过去。三种体系,殊途同归。

4.3 rv:U-Boot SPL

rv 平台上 L1 通常就是 U-Boot 的 SPL。它的主控制流分两大段,对应”DRAM 之前”与”DRAM 之后”:

1
2
3
4
5
6
7
8
// 简化的 SPL 主链(U-Boot, arch/riscv + common/spl)
start.S // SPL 入口汇编:设栈(在 SRAM 内)、清 BSS、跳 C
└─ board_init_f // "f" = before relocation,DRAM 尚不可用
├─ 时钟/IO 最小初始化
└─ DRAM 控制器初始化 + DDR training // 核心!主存在此被唤醒
└─ board_init_r // "r" = after,DRAM 已可用,可加载大镜像
└─ spl_load_image / spl_load_simple_fit // 从启动设备读下一段
└─ jump_to_image_no_args / spl jump // 跳入下一棒

几个关键源码锚点(U-Boot 源码树):

  • common/spl/spl.c —— SPL 主流程 board_init_r(),决定从哪个 backend(MMC/SPI/NOR/NET/RAM…)加载下一段;
  • common/spl/spl_fit.c —— 解析 FIT/ITB 镜像(多组件打包格式,详见 §6/§7);
  • arch/riscv/lib/spl.c —— rv 专用的跳转逻辑;
  • drivers/ram/ —— 各家 SoC 的 DRAM 控制器与 training 驱动。

一个 rv 特有的细节:SPL 运行在 S-mode,它本身不提供 M-mode 的机器态服务,因此依赖一个已经就位的 SBI 固件(L2)。在 QEMU 教程链里,U-Boot 构建系统通过 OPENSBI= 环境变量把 RustSBI 或 OpenSBI 打包进来,由 SPL 解析 FIT 后把 SBI 拷到 0x80000000、U-Boot proper 拷到 0x80200000,再跳进 SBI——这条搬运链在 §5、§7 会逐字拆开。更复杂的形态如 Microchip PolarFire 的 HSS(Hart Software Services),是一个 MB 级、多 hart 协同的可编译 FSBL,承担比典型 SPL 重得多的职责。

4.4 x86:coreboot 的 bootblock / romstage(Cache-as-RAM)

x86 没有独立的”SPL”概念,DRAM 初始化被吸收进固件的早期阶段。以开源固件 coreboot 为例,它的启动分四段:

阶段 运行环境 主要职责
bootblock CAR(cache 当 RAM) 最早期 CPU/芯片组设置,进入 romstage
romstage CAR DRAM 初始化(原生 raminit 或调用 Intel FSP 的 MemoryInit),完成后切到 DRAM
ramstage DRAM 枚举并初始化设备、建立 ACPI 表、装载 payload
payload DRAM SeaBIOS / GRUB / UEFI(TianoCore)/ 直接 Linux 等

其中 romstage 在 CAR 里做 DRAM 初始化,功能上等价于 rv/aa 的 SPL/BL2 那一棒。值得一提的是 FSP(Firmware Support Package):Intel 把内存初始化、硅初始化这些与具体芯片强绑定的代码做成闭源二进制 blob,coreboot 调用它完成 raminit——这也是 x86 固件至今难以完全开源的症结之一。传统 BIOS 同样用 CAR 顶过 DRAM 就绪前的窗口,只是整个过程封装在厂商固件内、不对外暴露。

4.5 aa:TF-A BL1/BL2 与 Xilinx FSBL

aa 把 L1 拆得更细。在 TF-A 的分级模型里:

  • BL1:BootROM 之后的第一段可信固件,运行在片上 SRAM(可信区),做最小初始化,加载并校验 BL2;
  • BL2:可信引导固件,在这里完成 DDR 初始化,随后把后续各级(BL31 安全监控、BL32 安全 OS、BL33 非安全引导器)加载到合适位置;
  • 之后才进入 L2 的 BL31(§5 详述)。

厂商也有自己的 FSBL,例如 Xilinx Zynq 的 FSBL:除 DDR 初始化外,还负责加载 FPGA 比特流(PL 部分)、再加载 BL31/U-Boot——这是 SoC FPGA 平台的特色。

把三家放在一起,L1 的共性一目了然:一段必须极小、必须可换的固件,在片上 SRAM(或 CAR)里把主存唤醒,再把更大的后续固件搬进主存。 名字不同、源码不同,骨架完全一致。

维度 rv(U-Boot SPL) x86(coreboot romstage) aa(TF-A BL2 / FSBL)
运行环境 片上 SRAM Cache-as-RAM 片上 SRAM(可信区)
核心职责 DDR training + 加载下一段 DRAM init(FSP/native)+ 加载 payload DDR init + 加载 BL31/32/33
主存初始化代码 drivers/ram/ FSP blob / native raminit 平台 DDR 驱动
下一棒 SBI(M-mode) ramstage → payload BL31(EL3)

5. L2:机器态固件与安全世界——SBI

DRAM 唤醒、后续固件搬进主存之后,到了整条链上差异最大、也最值得细看的一棒:一个跑在最高特权级、常驻不退的固件,向下屏蔽硬件、提供运行时服务、隔离可信世界。rv 这一棒叫 SBI,aa 是 TF-A 的 BL31 加 OP-TEE,x86 是藏在固件里的 SMM 与机密计算扩展。三者里 rv 的 SBI 结构最清晰、最开放——版本史、v0.1 最小实现、v3.0 全部扩展的每个 EID/FID、哪些必备、Linux 启动需要哪些、各自对应什么硬件,逐一来看;x86 与 aa 随后对照。

5.1 SBI 是什么,为什么 rv 需要它

rv 特权架构定义三个模式:U-mode(用户态)、S-mode(操作系统内核)、M-mode(机器模式,最高特权)。复位后 CPU 进入 M-mode。

问题在于:每块板子的 CLINT(定时器/核间中断)、UART、复位寄存器地址都不同。如果 S-mode 内核直接去读写这些寄存器,就得为每块板各写一份内核。解法是在 M-mode 放一层薄固件,对 S-mode 暴露一套统一的 ecall 接口;内核只发 ecall,由固件去碰真实硬件。 这套接口的规范,就是 SBI(Supervisor Binary Interface),规范文档由 riscv-non-isa/riscv-sbi-doc 维护。

1
2
U-mode 程序  ──syscall(ecall)──→  S-mode 内核  ──SBI(ecall)──→  M-mode 固件  ──→  CLINT/UART/...
←──── mret ─────

类比很整齐:U→S 是系统调用,S→M 是 SBI 调用,两者都用 ecall 指令陷入更高特权级,区别只在谁来处理、处理完用什么指令返回(S-mode 用 sret,M-mode 用 mret)。

调用约定(S-mode → M-mode):

1
2
3
4
a7 = EID   扩展 ID(Extension ID,哪一类服务)
a6 = FID 函数 ID(Function ID,该扩展里的第几个函数)
a0–a5 参数(最多 6 个)
执行 ecall

返回约定(M-mode → S-mode,经 mret):

1
2
a0 = error  错误码(SbiError)
a1 = value 返回值(仅 error == 0 时有意义)

错误码是一套统一枚举,随版本扩充:

名称 含义
0 SUCCESS 成功
-1 ERR_FAILED 通用失败
-2 ERR_NOT_SUPPORTED 扩展/函数不存在
-3 ERR_INVALID_PARAM 参数非法
-4 ERR_DENIED 权限不足
-5 ERR_INVALID_ADDRESS 地址无效/不可访问
-6 ~ -8 ALREADY_* 资源已存在/已启动/已停止
-9 ERR_NO_SHMEM 共享内存未设置(v2.0+)
-10 ~ -13 INVALID_STATE / BAD_RANGE / TIMEOUT / IO v3.0 新增

多核广播类调用(IPI/RFNC/HSM)用 HartMask 寻址一组 hart:a0 是位图、a1 是位图起始偏移(-1 表示所有 hart)。

5.2 版本史与 v0.1 的最小实现

SBI 规范从 2019 走到 2025,分四个大版本,只增不改——新版从不删除或修改旧扩展,这是它能让内核与固件解耦演进的根基。

1
2
3
4
5
v0.1 (2019)        9 个 Legacy 函数,无 EID 概念,a7 直接是函数号
v0.2 / v0.3 Legacy 小修订与定稿
v1.0 (2022) EID/FID 体系确立;一次引入 BASE/TIME/IPI/RFNC/HSM/SRST 六大主力;rc3 加 PMU
v2.0 (2024-02) +DBCN +SUSP +CPPC +NACL +STA(调试台、挂起、电源、虚拟化加速、被偷时间)
v3.0 (2025) +SSE +MPXY +DBTR +FWFT(软件事件、消息代理、调试触发器、固件特性开关)

v0.1 Legacy——最小可用的 SBI。 在 EID 体系之前,SBI 只有 9 个函数,a7 直接是函数编号 0–8,返回值只用 a0(没有 a1,也没有统一错误码,更没有”探测某函数是否存在”的机制):

a7 函数 功能 对应硬件
0 set_timer 设定时器(写 mtimecmp);不清 STIP,由调用方管 CLINT mtimecmp
1 console_putchar 向调试串口输出一个字节(阻塞) UART THR
2 console_getchar 从调试串口读一个字节,无数据返回 -1 UART RBR
3 clear_ipi 清本 hart 的软件中断(清 SSIP) CLINT
4 send_ipi 向位图指定 hart 发 IPI(写 MSIP) CLINT MSIP
5 remote_fence_i 通知目标 hart 执行 fence.i (经 IPI)
6 remote_sfence_vma 通知目标 hart 刷 TLB (经 IPI)
7 remote_sfence_vma_asid 带 ASID 的远程 TLB 刷新 (经 IPI)
8 shutdown 关机 复位设备

这 9 个函数,就是”一个能把早期内核引导起来的最小 M-mode 固件”需要实现的全部:设个定时器让调度有心跳、有串口能打字、能发核间中断和远程 fence 撑起多核、能关机。后文 §11 自己写 minimal SBI,起点正是这一张表。

v1.0 的 EID 体系——一道分水岭。 v1.0 把”函数号直接放 a7”换成了”EID(扩展号)放 a7、FID(函数号)放 a6”,带来四个关键改进:

  1. EID 命名空间,扩展之间不再冲突;
  2. 返回值分 error 与 value 两个字段;
  3. probe_extension,内核可以先探测、再调用;
  4. EID 取值是 ASCII 串,可读性好——0x54494D45 = “TIME”、0x53525354 = “SRST”、0x735049 = “sPI”(IPI)。

版本号的 wire 格式(major << 24) | minor:v1.0 = 0x01000000,v2.0 = 0x02000000,v3.0 = 0x03000000,而 v0.1 = 0x00000001。Linux 启动时先调 sbi_get_spec_version,若结果 < 0x01000000 就判定为 Legacy 固件、退回兼容路径;否则走 EID 体系。

5.3 v3.0 全部扩展:EID / FID / 功能 / 必备性 / Linux 前置 / 对应外设

SBI 规范定义的扩展就是下面这 16 个(加 Legacy 共 17 项),再无其他。先看一张总表,再逐个展开,最后归纳”哪些必备、Linux 启动到底要哪些、各对应什么硬件”。

EID 名称 简称 FID 数 引入版本 强制 一句话功能
Legacy 9 v0.1 v0.x 中是 最早的 9 个函数
0x10 Base BASE 9 v1.0 必须 版本信息 + 扩展探测 + CPU ID
0x54494D45 Timer TIME 1 v1.0 否† 设定时器(自动清 STIP)
0x735049 IPI IPI 1 v1.0 核间中断
0x52464E43 Remote Fence RFNC 7 v1.0 远程 TLB/缓存刷新
0x48534D Hart State Mgmt HSM 4 v1.0 hart 启停/挂起/查状态
0x53525354 System Reset SRST 1 v1.0 关机/重启
0x504D55 Perf Monitor PMU 8 v1.0-rc3 硬件性能计数器
0x4442434E Debug Console DBCN 3 v2.0 调试串口(批量读写)
0x53555350 Suspend SUSP 1 v2.0 系统挂起到内存
0x43505043 CPPC CPPC 4 v2.0 协作电源管理(DVFS)
0x4E41434C Nested Accel NACL 5 v2.0 嵌套虚拟化加速
0x535441 Steal-time Acct STA 1 v2.0 虚拟化被偷时间记账
0x535345 Supervisor SW Events SSE 11 v3.0 固件向 S-mode 注入事件
0x4D505859 Message Proxy MPXY 6 v3.0 核间/服务消息通道
0x44425452 Debug Triggers DBTR 8 v3.0 硬件调试触发器接口
0x46574654 FW Features FWFT 2 v3.0 运行时开关固件特性

† TIME 名义上非强制,但实际是 Linux 调度心跳的来源,几乎必备;硬件支持 Sstc 时内核可绕过它直接写 stimecmp。

逐个展开(重点是”做什么 + 关键 FID + 落到哪个硬件”):

  • BASE(0x10,唯一强制)。9 个函数:get_spec_version(版本号)、get_impl_id(实现者:0=BBL、1=OpenSBI、4=RustSBI…)、get_impl_versionprobe_extension(eid)(探测扩展是否存在)、get_mvendorid/marchid/mimpid(CPU 三个识别 CSR)、get_mhartid(v2.0 加)、get_features(v3.0 加)。Linux 第一条 SBI 调用就是 get_spec_version,随后用 probe_extension 逐个探 TIME/IPI/HSM 决定走哪条路径。BASE 永远不返回 NOT_SUPPORTED。

  • TIME(0x54494D45,1 个 FID)set_timer(stime_value):设定本 hart 下次定时器中断的时刻。硬件行为:当自增计数器 mtime ≥ mtimecmp 时硬件置 MTIP,固件转成 STIP 交给 S-mode。对应硬件 = CLINT 的 mtimecmp;若 CPU 有 Sstc 扩展,S-mode 可直接写 stimecmp CSR、完全跳过 ecall(固件在初始化时把 menvcfg.STCE 置 1 放行)。与 Legacy set_timer 的关键差异:TIME 版会自动清 STIP。

  • IPI(0x735049,1 个 FID)send_ipi(mask, base):向一组 hart 发软件中断。对应硬件 = CLINT 的 MSIP 寄存器——固件写目标 hart 的 MSIP,目标 hart 进 M-mode、固件清 MSIP 并置 SSIP,S-mode 看到 SSIP 处理。用于调度迁移、TLB shootdown、唤醒待启动的 hart。

  • RFNC(0x52464E43,7 个 FID)。远程 fence:通知其他 hart 执行 fence.i / sfence.vma,维持多核指令缓存与 TLB 一致性。FID 0–2 是普通 OS 用的(remote_fence_i / remote_sfence_vma / remote_sfence_vma_asid),FID 3–6 是 H 扩展(虚拟化)才用的 G-stage / VS-stage fence。底层依赖 IPI——本地先 fence,再发 IPI 让远端 fence。

  • HSM(0x48534D,4 个 FID)。Hart 生命周期:hart_start(id, addr, opaque)(让某 hart 从 addr 开始跑 S-mode 代码)、hart_stop(自停)、hart_get_statushart_suspend。这是 Linux SMP 启动的基础——boot hart 起来后,逐个 hart_start 把其他核拉进内核。hart 有一套状态机(Stopped / Start_pending / Started / Suspended…)。底层用 IPI 唤醒等在 wfi 里的从核

  • SRST(0x53525354,1 个 FID)system_reset(type, reason):type = 关机 / 冷重启 / 温重启。对应硬件 = 平台复位设备(QEMU virt 上是 SiFive test device,写特定值即退出/重启)。替代 Legacy 的 shutdown,多了重启与原因码。

  • PMU(0x504D55,8 个 FID)。性能监控单元接口:枚举/配置/启停硬件性能计数器(hpmcounter),读固件计数器。perf 工具的底座。对应硬件 = CPU 的性能计数 CSR

  • DBCN(0x4442434E,3 个 FID)。调试控制台:console_write / console_read(批量,传物理地址 + 长度,支持 >4GB)/ console_write_byte(单字节最简)。替代 Legacy 的 putchar/getchar。对应硬件 = 调试串口(QEMU virt 上是 NS16550A 兼容 UART)。

  • SUSP(0x53555350,1 个 FID)system_suspend(type, resume_addr, opaque):整系统挂起到内存(S2RAM),唤醒后 boot hart 从 resume_addr 继续。

  • CPPC(0x43505043,4 个 FID)。协作式处理器性能控制,ACPI CPPC 寄存器的 SBI 包装,服务器调频用;嵌入式一般不需要。

  • NACL(0x4E41434C,5 个 FID)。嵌套虚拟化加速:用共享内存批量同步 CSR / hfence / sret,减少 Guest↔Hypervisor 的 VM-exit 次数。

  • STA(0x535441,1 个 FID)steal_time_set_shmem:虚拟化下,固件周期性把”本 vCPU 被宿主偷走多少 CPU 时间”写进共享内存,Guest 读取后修正调度与负载统计。

  • SSE(0x535345,11 个 FID)。Supervisor Software Events:M-mode(或硬件)向 S-mode 注入事件,直接跳到 S-mode 注册的处理函数(不经中断控制器),类似从固件发起的信号。用于 RAS 错误上报、PMU 溢出、双重陷阱等。是 v3.0 里最复杂的扩展。

  • MPXY(0x4D505859,6 个 FID)。Message Proxy:固件开”通道”,S-mode 通过共享内存与 M-mode 侧服务(TEE、SCMI、PLDM 等)收发消息,统一替代零散 ecall,近似一套 IPC。

  • DBTR(0x44425452,8 个 FID)。Debug Triggers:让 S-mode 经 SBI 管理 RISC-V Debug 规范的硬件触发器(执行断点 / 数据监视点 / 触发链),而不必直接碰 M-mode 调试寄存器。

  • FWFT(0x46574654,2 个 FID)。Firmware Features:运行时开关固件行为,如”非对齐访问异常是否委托给 S-mode”、控制流完整性(landing pad / shadow stack)、PTE A/D 位是否硬件更新、指针屏蔽等,可加锁不可再改。

必备性与 Linux 启动前置——这是最实用的一张地图:

层级 扩展 没有会怎样
必须 BASE Linux 第一条 SBI 就是 get_spec_version,没有 BASE 起不来
强烈需要 TIME 调度心跳没了,无法分时调度
强烈需要 IPI SMP 核间通信没了,多核失效
强烈需要 RFNC 进程切换/缺页时无法刷远端 TLB,内存管理出错
强烈需要 HSM 无法拉起非 boot hart,退化成单核
推荐 SRST 无法正常关机/重启(panic 后悬停)
推荐 DBCN 早期 earlycon 失效(可用 Legacy putchar 顶替)
可选 PMU/SUSP/CPPC/NACL/STA 分别失去 perf / 休眠 / 调频 / 虚拟化优化
前沿 SSE/MPXY/DBTR/FWFT v3.0 高级特性,缺则相应高级功能不可用

换言之,一个能把多核 Linux 跑到登录提示符的 SBI 固件,实现 BASE + TIME + IPI + RFNC + HSM + SRST + DBCN 七个扩展即可;单核、无 earlycon 的最小场景还能更省。

扩展 → 外设的对应一览

扩展 落到的硬件
TIME CLINT mtimecmp(或 Sstc 的 stimecmp CSR)
IPI CLINT MSIP
RFNC 经 IPI 触发,目标 hart 执行 fence.i/sfence.vma
HSM 经 IPI 唤醒 wfi 中的从核
SRST 平台复位设备(QEMU:SiFive test device)
DBCN 调试串口(QEMU:NS16550A UART)
PMU 性能计数 CSR(hpmcounter)

换个角度——从实现者出发:实现哪些扩展,解锁哪些能力。 上面是”消费者视角”(某外设/某功能需要哪个扩展、Linux 至少要哪些)。把它翻过来从”实现者视角”看同一件事会更立体:固件每多实现一组扩展,就多解锁一层能力,由简到全是一道阶梯。

累加实现的扩展 解锁的能力 适用场景
Legacy putchar(或 BASE + DBCN) 能往串口打字 最早的 bring-up,连调度都还没有
+ TIME 定时器心跳,可分时调度 单核内核跑起来
+ IPI + RFNC + HSM 多核唤醒 + 核间中断 + 远程 TLB 一致 多核 SMP
+ SRST 能正常关机/重启 完整生命周期
以上 7 个(BASE/TIME/IPI/RFNC/HSM/SRST/DBCN) 把多核 Linux 引到登录提示符的全部所需 通用 OS 启动基线
+ PMU perf 性能分析 调优
+ SUSP 挂起到内存(S2RAM) 低功耗
+ CPPC 动态调频 服务器/移动
+ NACL + STA 嵌套虚拟化加速 + steal-time 记账 虚拟化宿主
+ SSE/MPXY/DBTR/FWFT 软件事件/消息代理/硬件调试/特性开关 v3.0 前沿全特性

这张表有个直觉:“启动一个内核”只卡在中间那一行(7 个扩展)——往下是 bring-up 的过渡态,往上是锦上添花。这也解释了为什么各家最小 SBI 实现都不约而同先把这 7 个做齐:它既是”消费者”眼里 Linux 的启动底线,也是”实现者”眼里性价比最高的一档。

5.4 M-mode 固件启动八步:从 _start 到 mret 进 S-mode

把上面的接口落到代码,一个 SBI 固件从复位到交棒给内核,主干就八步。这里用与具体语言无关的方式呈现(OpenSBI 的 fw_base.S、RustSBI 的 _start、其它实现都是这套骨架),它也正是 §11 自己动手写 minimal SBI 的模板。

入口 _start(裸函数,无序言尾声——此刻栈还没建好):

1
2
3
4
5
6
7
8
9
10
11
12
13
_start:
csrr t0, mhartid # 1. 读当前 hart ID
la sp, __stack_top # 2. 每 hart 一段独立栈:sp = top - hartid*STACK_SIZE
li t1, STACK_SIZE
mul t1, t0, t1
sub sp, sp, t1
csrw mscratch, sp # 3. 关键:把 M-mode sp 存进 mscratch,
# 供 trap 入口用 csrrw 原子换栈
bnez t0, secondary # 4. boot hart(0) 去清 BSS,从核去等 IPI
# boot hart:清零 BSS(全局变量初始化)→ 调 C 初始化
call fw_init # (传入 a1 = dtb 物理地址)
secondary:
call fw_secondary # 从核:设好栈与 trap 后 wfi 等 HSM hart_start

C 初始化 fw_init(M-mode 配置完成后 mret 降到 S-mode),关键八步:

操作 作用
1 mtvec = trap_entry 设 M-mode 异常/中断入口(Direct 模式)
2 mstatus:MPP=01, MPIE=1, FS=01 mret 后进 S-mode、开中断、放行 FPU
3 menvcfg:STCE=1(如有 Sstc)等 放行 S-mode 直接写 stimecmp、cbo 指令
4 pmpaddr0 = 全地址pmpcfg0 = TOR|R|W|X PMP 直通,让 S-mode 能访问全部物理内存(默认拒绝一切)
5 medeleg 委托异常给 S-mode,但不委托 S-mode ecall 缺页/断点/U-ecall 交 S-mode;S-ecall 必须留 M-mode(这正是 SBI 入口)
6 mideleg 委托 SSIP/STIP/SEIP 给 S-mode S-mode 自己收软件/定时器/外部中断
7 mepc = os_entry(如 0x80200000);a0=hartida1=dtb 设好返回地址与交接寄存器
8 mret CPU:PC=mepc、特权级=MPP=S-mode、MIE=MPIE——控制权交给内核

第 5 步是整个 SBI 机制的命门:所有异常里唯独 S-mode 的 ecall 不委托,于是内核每次 ecall 都精确陷回 M-mode 固件,这才有了 SBI。第 4 步的 PMP 直通同样不能省——默认 PMP 拒绝一切,不放行的话 S-mode 连内存都读不了。

运行时的 trap 循环(S-mode 每次 ecall 走这里):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
S-mode 执行 ecall
→ 硬件:mepc=ecall地址, mcause=9(S-ecall), 跳 mtvec(trap_entry)
trap_entry(裸函数):
csrrw sp, mscratch, sp # 原子换栈:拿到 M-mode 栈,同时把 S-mode sp 存回 mscratch
保存 31 个通用寄存器到 TrapFrame
call trap_handler(frame, mepc, mcause)
若中断: MTIP→置 STIP 转交; MSIP→清 MSIP 置 SSIP 转交
若 S-ecall:
eid=a7, fid=a6, args=a0..a5
ret = dispatch(eid, fid, args) # 按 §5.3 的大 switch 分发
frame.a0 = ret.error; frame.a1 = ret.value
frame.mepc += 4 # 跳过 ecall 指令,避免死循环
恢复寄存器
csrrw sp, mscratch, sp # 换回 S-mode 栈
mret # 回 S-mode

dispatch 内部就是 §5.3 那张扩展表的代码化——一层 switch(eid) 套一层 switch(fid)。值得一提的是,有的实现把”支持哪些扩展”做成编译期可裁剪:按选定的 SBI 规范版本,在编译期就决定启用哪些 case、未选的折叠成 NOT_SUPPORTED,固件因此能做到只包含用到的扩展、零运行时分支开销(KuSBI 即以此为卖点,可在 v0.1 到 v3.0 之间按版本变体编译)。

5.5 OpenSBI 源码走读(C,事实参考实现)

OpenSBI 是 SBI 的参考实现,C 写成,结构清晰,是读 SBI 源码的首选样本。目录骨架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
opensbi/
├── firmware/ ← 固件入口汇编与三种固件形态
│ ├── fw_base.S M-mode 第一条指令
│ ├── fw_jump.S 跳转固件(编译期固定下一阶段地址)
│ ├── fw_dynamic.S 动态固件(运行时从上一级取入口,QEMU 默认)
│ └── fw_payload.S 内嵌载荷固件(把 OS 打包进来)
├── lib/sbi/ ← 与硬件无关的 SBI 核心
│ ├── sbi_init.c C 入口 sbi_init()
│ ├── sbi_ecall.c ecall 分发器
│ ├── sbi_trap.c trap 总入口
│ └── sbi_ecall_*.c 各扩展实现(timer/ipi/hsm/...)
├── lib/utils/ ← 通用驱动库(clint/aclint/uart8250/...)
├── include/sbi/sbi_ecall_interface.h 所有 EID/FID 常量
└── platform/generic/ 通用平台:启动扫 FDT 自动匹配驱动

fw_base.S_start 就是 §5.4 那套骨架的 C 实现版前奏:关中断(csrw mie, zero)、为每个 hart 设独立栈、清 BSS、把 mtvec 指向 _trap_handler、然后 call sbi_init

sbi_init()lib/sbi/sbi_init.c)分两段:每个 hart 都执行的初始化(堆、domain 内存保护域、hart 的 PMP、串口、平台、定时器、IPI),随后只有 boot hart 执行 sbi_ecall_init() 注册全部扩展 handler;最后调 sbi_hart_switch_mode() 执行 mret 降到 S-mode,正常不返回。

sbi_ecall_handler()lib/sbi/sbi_ecall.c)是运行时核心:从陷阱帧取 a7(EID)、a6(FID),sbi_ecall_find_extension(eid) 找到扩展,调它的 handle(),把结果写回 a0(error)、a1(value),再把 mepc += 4 跳过 ecall 指令避免死循环。

三种固件形态是 OpenSBI 的实用设计,对应不同部署:

形态 下一阶段入口怎么来 典型场景
fw_jump 编译期固定地址(FW_JUMP_ADDR QEMU 测试、布局固定的板
fw_dynamic 运行时由上一级(如 SPL)经 a2 传入 next_addr 生产环境(QEMU -bios default 即此)
fw_payload 把 OS 镜像打包进固件本身 单文件部署,无需 bootloader

generic 平台是 OpenSBI 通用性的关键:启动时遍历 FDT,按 compatible 字符串自动绑定驱动——riscv,clint0 → CLINT 驱动、ns16550a → uart8250、sifive,test → 复位驱动,地址从 FDT 的 reg 属性读取,无需编译期硬编码。代价是所有驱动都编进固件、二进制偏大。适配新板,往往只要给出正确的 FDT。

5.6 RustSBI 与前身 BBL / riscv-pk

RustSBI(Rust)走的是另一条路:用 trait 表达扩展、用类型系统保证安全。平台适配 = 实现 trait:

1
2
3
4
5
// 板级适配:为某板实现 Timer trait
impl rustsbi::Timer for MyBoardClint {
fn set_timer(&self, stime_value: u64) { /* 写该板 CLINT mtimecmp */ }
}
let sbi = RustSBI::builder().timer(MyBoardClint).console(MyBoardUart).build();

Prototyper 是对标 OpenSBI fw_dynamic 的通用固件,同样 FDT 动态发现。入口 _start 是裸函数,rust_main(hartid, opaque)opaque 即 dtb 地址,随后设委托、PMP,mret 降到 S-mode。与 OpenSBI 的核心差异:

维度 OpenSBI RustSBI
语言 C Rust
扩展实现 静态编译进固件 trait 对象注入
平台适配 platform/ + 编译期选择 trait dispatch / 独立 BSP crate
安全保证 依赖 C 编程规范 所有权 + 借用检查
入口 fw_base.S 汇编 #[naked] _start

BBL / riscv-pk——SBI 的前身。 在 SBI 规范成形之前,伯克利的 riscv-pk 项目提供了 M-mode 环境:bbl(Berkeley Boot Loader)在 M-mode 给上层内核搭一层薄环境,pk(proxy kernel)则能在 M-mode 代理运行用户程序、把系统调用转发给宿主,常用于 Spike 模拟器上跑单个程序、教学。SBI 的”S-mode 通过 ecall 请求 M-mode 服务”这一核心思想,正是从 BBL 提炼、标准化而来。现代生产已由 OpenSBI / RustSBI 取代 BBL,但理解 SBI 的来历绕不开它——它是这条线的起点。

5.7 SBI 实现横向

同一套 SBI 规范,对上层(U-Boot / Linux)完全透明,换实现只需改 -bios 参数或 SPL 的打包配置:

实现 语言 扩展实现方式 平台适配 定位
BBL / riscv-pk C 固定少量 编译期 历史前身,Spike/教学
OpenSBI C 静态编译 FDT generic + platform/ 事实参考实现,生态最广
RustSBI Rust trait 注入 trait / BSP crate Rust 生态主流实现
tg-rcore SBI Rust 内置精简 教学内核自带 随教学内核一体
KuSBI Zig comptime 裁剪 BSP / Kconfig / FDT 三路 按 spec 版本变体编译的实验实现(彩蛋一枚)

5.8 x86 配角:SMM / SGX / TDX / SEV

x86 没有像 SBI 这样”开放规范 + 开源实现”的机器态服务层,但功能上有对应物,只是大多藏在闭源固件里。

  • SMM(System Management Mode):一个比 ring 0 还隐蔽、优先级更高的执行模式,由 SMI(系统管理中断)触发,进入固件预置的 SMM handler,处理电源管理、风扇、固件级功能,对操作系统完全透明。它在”最高特权、常驻、对 OS 屏蔽底层”这点上与 M-mode 固件神似,区别是它由 BIOS/UEFI 厂商提供、闭源、不可换。
  • SGX / TDX(Intel)、SEV(AMD):机密计算扩展,分别提供 enclave 飞地、机密虚拟机、加密内存,隔离出可信执行环境。它们对应的是 SBI/安全监控的”可信边界”一面。

一句话对照:rv 把机器态服务做成了开放、开源、可替换的 SBI;x86 这一层是厂商私有、随固件出货的。

5.9 aa 配角:TF-A BL31 + PSCI + OP-TEE

aa 把这一棒拆成”安全监控 + 电源接口 + 安全世界 OS”三件套,与 SBI 的对应相当工整。

  • TF-A BL31:常驻 EL3(最高异常级)的安全监控,负责非安全世界与安全世界的切换,是 aa 版的”M-mode 固件”。
  • PSCI(Power State Coordination Interface):aa 的”电源 SBI”。S-mode/EL1 通过 SMC 指令(功能上等同 rv 的 ecall)陷入 BL31,请求 CPU 上电/下电/挂起、系统关机重启。它对应 SBI 的 HSM + SRST + SUSP 三者之和——多核 bring-up、关机、休眠都走它。
  • OP-TEE(BL32):跑在安全 EL1 的 TEE 操作系统,承载可信应用(TA),提供 GlobalPlatform API。它实现了 TrustZone 切出来的那个”安全世界”。

把三条线的 L2 并起来看:

维度 rv aa x86
最高常驻固件 SBI(M-mode) TF-A BL31(EL3) SMM(隐藏模式)
调用指令 ecall SMC SMI 触发
电源/多核接口 HSM + SRST + SUSP PSCI ACPI + 固件
安全世界 Keystone / CoVE(较新) TrustZone + OP-TEE SGX / TDX / SEV
开放程度 开放规范 + 开源实现 开放规范(PSCI)+ 多为开源 厂商私有为主

到这里,L2 这一棒就讲完了。有了机器态服务托底,下一棒是一个功能丰富的引导器,去文件系统和网络里把内核找出来。


6. L3:第二级引导器(SSBL)——U-Boot / UEFI / GRUB / barebox

L1/L2 把硬件铺平之后,需要一个”功能丰富”的引导环境:能读文件系统、跑命令行、走网络、解析配置、按需选启动项,最终找到内核并加载。这一棒叫 SSBL(Second-Stage Boot Loader)。它有几个主角——U-Boot、UEFI(实现如 EDK2)、GRUB、barebox——彼此定位不同,先厘清”它们各自算哪种意义上的标准”,再逐个深入。

6.1 三种”标准”的本质不同

初学时容易把 U-Boot、UEFI、GRUB 混为一谈,其实它们处在不同层面:

  • U-Boot 是一个实现,没有独立的规范文档。 它的”标准性”来自事实地位:嵌入式世界几乎人人用它,于是它的命令、环境变量、FIT 镜像格式就成了约定俗成。换言之,U-Boot 即标准本身,靠源码定义。
  • UEFI 是一份开放规范。 UEFI Forum 出规范文档,定义 Boot Services、Runtime Services、Protocol/GUID、启动项管理等接口;EDK2(TianoCore)是参考实现,但任何人都能实现这套规范——U-Boot 自己就内置了一个 UEFI 实现(lib/efi_loader/),能加载 grub.efi、Linux EFI stub、systemd-boot。规范与实现分离,是 UEFI 与 U-Boot 最大的不同。
  • GRUB 是一个实现,同时推动了一个协议(Multiboot)。 GRUB 本身是引导器实现,但它定义的 Multiboot/Multiboot2 协议让”引导器”与”被引导的内核”解耦:任何遵守 Multiboot 的内核都能被任何遵守 Multiboot 的引导器加载。所以 GRUB 兼具”实现”与”协议倡导者”两重身份。

记住这条分层,看它们的源码与行为就不会串味。

6.2 U-Boot:proper 与 SPL 为什么是两个二进制

U-Boot 全称 Universal Boot Loader,是嵌入式世界的”BIOS + Bootloader 二合一”。理解它,第一件事就是搞清它为什么被切成 SPLU-Boot proper 两个独立编译的二进制。

为什么分成两个:容量、时序、特权级三层理由。

  1. 容量:完整的 U-Boot proper 有几 MB(Driver Model + 命令行 + 网络 + 文件系统 + UEFI 实现),而上电瞬间只有片内 SRAM 可用(几十 KB 到几 MB),proper 根本装不进去。官方文档的说法是 “xPL images normally have a different text base”——不同链接基址,注定是两个二进制。
  2. 时序:DRAM 必须先训练才能用,而训练代码本身得先驻留在 SRAM。于是需要一个极小的先行者,先把 DRAM 唤醒,再去加载大块头。
  3. 特权级(rv 特有):SPL 跑在 M-mode(最早期),proper 则由 SBI(OpenSBI/RustSBI 等)mret 切到 S-mode 才运行——proper 不再碰硬件初始化,只负责”找 OS、构造启动参数、跳转”。

于是分工清晰:SPL = 极小、跑在 SRAM、训练 DRAM 并把 proper 搬进 DRAM;proper = 全功能、跑在 DRAM、交互式地把内核找出来加载走。 二者是同一份仓库源码、一次 make 同时编出(构建跑两轮,带 CONFIG_XPL_BUILD 宏的那轮编 SPL),连入口汇编 arch/riscv/cpu/start.S 都共用,靠 #ifdef CONFIG_XPL_BUILD 分裂出两条路径。

维度 SPL U-Boot proper
大小 ≤ 64 KB(典型) 几 MB
跑在哪 片上 SRAM DRAM
时机 DRAM 未就绪 DRAM 已就绪
特权级(rv) M-mode S-mode(被 SBI 切下来)
职责 DRAM 训练 + 加载 proper + 跳转 命令行 + env + 文件系统 + 网络 + UEFI + 加载 OS
可选性 可省(SRAM 够大/Falcon 直跳 OS) 不可省(除非 SPL 链式加载 OS)

派生层级也是同一思路的延伸:TPL(Tertiary PL,比 SPL 更早、更小,专门去加载 SPL)、VPL(Verification PL,A/B 验证启动时决定加载哪个 SPL)。

24 年连续演化的史。 U-Boot 不是某次重写出来的,而是从 1999 年连续长到今天(本地样本 Makefile 显示 VERSION=2026 PATCHLEVEL=04,文件数从最早的 1401 长到 37780,支持板从 71 到 722):

时间 里程碑 解决什么
1999→2002 PPCBoot + ARMboot 合并为 U-Boot 跨架构通用引导器(双 copyright DNA 为证)
v2008.10 nand_spl/ → SPL 概念主线化 SoC 变大、SRAM 没变大,逼出两阶段启动
v2008.10 版本号转 YYYY.MM 季度/双月发布节奏
~v2014.07 Kconfig 取代 #define 大杂烩 上千块板的配置不再失控
~v2014.04 Driver Model(drivers/core/ 统一驱动模型 + DT 自动 probe
v2014.04 arch/ 重构、dts/ 与 Linux 共享 目录现代化、设备树复用
~v2018.05 rv 完整移植 大批 rv SBC 进入
v2017.09+ EFI loader(lib/efi_loader/ U-Boot 反向实现 UEFI,生态汇合
v2020.10+ bootstd/Bootflow,2024 弃 distro_bootcmd 用可测试的 C 状态机取代 shell 脚本

SPL 的核心动作 = 让 DRAM 可用 + 把 proper 搬进 DRAM。 SPL 的 C 主链是 board_init_farch/riscv/lib/spl.c,DRAM 之前)到 board_init_rcommon/spl/spl.c,主驱动循环,选 backend 加载、按镜像类型选跳转器)。DRAM 训练完全藏在 Driver Model 里——spl_dram_init() 实质就是 uclass_get_device(UCLASS_RAM, 0, &dev),触发对应 SoC 的 DDR 驱动 probe;具体形式三选一:厂商闭源 blob 前置(K230/D1/RK35xx)、U-Boot 内的 UCLASS_RAM 驱动(FU540/FU740/JH7110)、或 BootROM 已训完 SPL 只读容量寄存器(SpacemiT K1)。

proper 的核心动作 = 自我重定位 + 全功能引导。 proper 启动后先 relocate_codestart.S)把自己搬到高端 RAM——它用 -pie -fpie 编译,搬完手动修补 .rela.dyn 里的 R_RISCV_RELATIVE 重定位项,因此同一个二进制能落到任意 RAM 地址(”一个 binary 多 SoC 部署”)。随后是 24 年不变的二段式:board_init_f(重定位前,initcall_run_f() 跑 50+ 步)到 board_init_r(重定位后,initcall_run_r() 跑 80+ 步),最后进 for(;;) main_loop()board_init_f(f = before relocation)与 board_init_r(r = after,in RAM)的二段切分,是 rv/aa/x86 通用的金科玉律。

Driver Model:uclass / driver / udevice 三件套。 这是 U-Boot 现代驱动的核心抽象(drivers/core/):

  • udevice:设备实例,组织成树(root → bus → device → child);
  • driver:驱动定义,关键字段有 id(归属哪个 uclass)、of_match(compatible 字符串数组)、probe/bind 回调、ops(接口函数指针表);
  • uclass:接口分类——所有 UART 归一个 uclass,所有 MMC 归另一个,给上层统一接口。

注册不靠运行时,而靠 linker sectionU_BOOT_DRIVER(name) 宏在编译期把驱动塞进链接段 __u_boot_list_2_driver_*KEEP+SORT 防 gc、按名排序),运行时取数组。这与 Linux 的 module_init/动态加载不同——U-Boot 是纯静态、编译期决定全集,适合裸机、省代码。DT 驱动 probe 的流程:lists_bind_fdt 拿驱动的 of_match 去匹配设备树节点的 compatible,匹配上就 bind(登记),需要时 probe(激活、真写寄存器);分 pre-reloc 与 post-reloc 两次扫描,pre 阶段只 bind 标了 u-boot,dm-pre-reloc/u-boot,dm-spl 的少数节点(此时 malloc 池才 64K)。

启动流程:main_loop → autoboot → bootcmd → booti/bootm。 proper 进 main_loop() 后,倒数若干秒没人按键就执行环境变量 bootcmd(这个”倒数自动启动”机制 24 年没变)。老式路径是字符串膨胀的 distro_bootcmdrun distro_bootcmd → 各设备的 bootcmd_<dev> → 扫 extlinux.conf/boot.scr/EFI app → booti $kernel - $fdt。最终落到两个命令:

  • booti(rv/aa64 的 Image 直跳,cmd/booti.c):探测压缩、解析 Image 头、relocate、找 ramdisk/fdt,跑 bootm 状态机到 OS_GO
  • bootmboot/bootm.c 的状态机:START/FINDOS/LOADOS/RAMDISK/FDT/…/OS_GO):rv 最终落到 arch/riscv/lib/bootm.cboot_jump_linux——kernel(gd->arch.boot_hart, images->ft_addr),即 rv Linux 入口 ABI =(hartid, dtb)。rv 的 do_bootm_linux 只有不到 100 行,因为特权级早被 SBI 处理好了,不像 aa64 还要做 EL 切换与 cache flush。

SPL → SBI 的接力 ABI。 双二进制中间还夹着 SBI。SPL 通过 common/spl/spl_opensbi.c 填一个 fw_dynamic_info 结构(magic = “OSBI”、next_addr = proper 入口、next_mode = 1 即 S-mode、boot_hart),然后以 a0=hartid、a1=dtb、a2=&fw_dynamic_info 跳进 OpenSBI;OpenSBI 看到 a2 的 magic 走 fw_dynamic 路径,初始化完 mret 把 proper 拉到 S-mode。这正是 §5 讲的 SBI 接力,在 SPL 这一侧的对接代码。

env 系统。 环境变量是 proper 的专属功能(SPL 太小一般不带)。它有一条 backend 优先级链(MMC/SPI flash/ext4/FAT/NAND/…),env_load 逐个 backend 尝试加载,支持多 backend 冗余与 A/B。env_get/env_set/env_save 三件套从 2002 年至今核心未变。

现代化:bootstd/Bootflow 与 Falcon mode。 老式 distro_bootcmd 是 shell 脚本,难懂、难扩展、没测试,2020 年起被 C 实现的 bootstd/Bootflow 取代(boot/ 目录:bootdev 设备层 / bootmeth 扫描方法 / bootflow 一次启动尝试),2024 年正式弃用 distro 脚本。另一条优化路线是 Falcon mode:让 SPL 跳过 proper、直接启动 Linux——SPL 预先用 spl export 把内核启动参数存进持久区,启动时直接加载内核并复制参数,省掉 proper 那一棒以加速;代价是需要自定义机制选择”加载 proper 还是 kernel”,常规 Falcon 失败可回退标准流程。

6.3 UEFI 与 EDK2:一个开机前的微型操作系统

如果说 U-Boot 是”一个实现即标准”,UEFI 则是”先有规范、再有实现”。UEFI(Unified Extensible Firmware Interface)由 UEFI Forum 维护规范,当前版本是 2.11 + PI 1.9(2024-12-17 发布);EDK2(TianoCore)是它的开源参考实现。

为什么会有 UEFI。 1981 年的 BIOS 是 16 位实模式、汇编写成、只能寻址 1MB、没有标准镜像格式、没有模块化——到了 64 位服务器时代彻底力不从心。1998 年 Intel 为 Itanium 起 EFI 项目,2005 年成立 UEFI Forum 并把 EFI 改名 UEFI、规范权威从 Intel 移交论坛,2007 年 UEFI 2.0 发布、同年 Tianocore EDK 开源。此后是一串能力补齐:2011 年 UEFI 2.3.1 引入 Secure Boot(随 Windows 8 强制普及),2014 年 HTTP Boot,2016 年 AArch64 完整化(aa 服务器走 UEFI),2019 年 UEFI 2.8 引入 rv binding,2024 年 UEFI 2.11 把 la 纳入规范。一句话:UEFI 把 BIOS 那点固件升级成了”开机前的一个微型操作系统”——64 位原生、C 开发、阶段化、标准 ABI、PE/COFF 镜像、GUID+Protocol 模块化、NVRAM 变量。

要分清几个常被混用的名字:UEFI Specification 是文档;Tianocore 是开源社区;EDK / EDK II 是社区的产品(EDK II 自 2010 用 C 重写至今);UDK 是 EDK II 的定期稳定发布。产业链是:UEFI Forum 写规范 → EDK II 是参考实现 → AMI(Aptio,约占 PC 一半)、Insyde(H2O,笔记本)、Phoenix、百敖(国产)在 EDK II 上二次开发卖给 OEM。

启动阶段:常说五段,PI 规范实为七段。 平台初始化(PI)规范把 UEFI 启动切成七个阶段,日常多简称前五个:

阶段 全称 做什么
SEC Security 复位后第一段代码,系统信任根;x86 reset vector = 0xFFFFFFF0,无 DRAM 时用 Cache-As-RAM 顶着
PEI Pre-EFI Init 核心是 DRAM 训练(最难,几千行),产出 HOB 链传给 DXE
DXE Driver Execution Env 加载所有驱动,建立 Handle/Protocol/GUID 数据库与 Dispatcher
BDS Boot Device Selection 读 NVRAM Boot####/BootOrder 选启动项,开机 F12 菜单在此
TSL Transient System Load OS 厂商的 boot loader 运行阶段(rboot / grub.efi / EFI stub 都在这里)
RT Runtime OS 接管后仍可调用的少量 Runtime Services
AL After Life OS 还给固件的过渡(关机/重启路径)

核心机制四件套。 第一,System Table 是唯一入口:任何 UEFI 应用的入口签名是 EfiMain(ImageHandle, SystemTable),从 SystemTable 能拿到 BootServices、RuntimeServices、控制台、ConfigurationTable(ACPI/DT/SMBIOS 指针)。第二,Boot Services(BS,仅启动前)vs Runtime Services(RS,启动前后均可):BS 提供内存分配、事件、Protocol、镜像加载等 30+ API,其中 ExitBootServices 是权限交接的瞬间——调用后 BS 全部失效、硬件交给 OS;RS 提供变量读写、时间、ResetSystem 等 14 个 API,Linux 的 efivarfs 就是访问 RS 变量的桥。第三,Protocol / Handle / GUID 是模块化基石:UEFI 不是函数库,而是 COM 风格的”面向对象 + 服务发现”运行时——Handle 是匿名对象、可挂多个 Protocol,Protocol 是一组函数指针、由 128 位 GUID 唯一标识,全局 Database 是服务目录。第四,镜像格式统一为 PE/COFF(后缀 .efi,与 Windows .exe 同格式),这相对”BIOS 毫无标准镜像格式”是革命性的。

ESP——EFI 系统分区。 老 BIOS 的引导代码只有 MBR 里 446 字节;UEFI 把固件内置了 FAT 驱动,于是引导器升级成”独立分区里的 PE/COFF 文件”。这个分区就是 ESP,规范强制用 FAT32(GPT 类型 GUID C12A7328-...,MBR 类型 0xEF)。选 FAT32 是因为它是无专利费的公开标准、跨 OS 通用、简单到能塞进固件 ROM。目录固定为 EFI/

1
2
3
4
5
ESP (FAT32,Linux 常挂 /boot/efi)
└── EFI/
├── BOOT/BOOTX64.EFI / BOOTAA64.EFI / BOOTRISCV64.EFI ← fallback 默认引导器
├── ubuntu/{shimx64.efi, grubx64.efi, grub.cfg}
└── Microsoft/Boot/bootmgfw.efi

固件选哪个 .efi 有两条路径:正式路径读 NVRAM 的 BootOrder + Boot#### 变量(每个是一个 EFI_LOAD_OPTION,含描述与设备路径),Linux 装系统时用 efibootmgr 写一项指向 \EFI\ubuntu\shimx64.efifallback 路径在找不到启动项或选”从 U 盘启动”时,自动找 \EFI\BOOT\BOOT<arch>.EFI

EDK2 在 rv 上的关键形态:PEI-less 的 S-mode payload。 这一点与 rv 的整体思路高度契合。EDK2 的 OvmfPkg/RiscVVirt/README 写得很清楚:rv 上的 EDK2 是”前一级 M-mode 固件(如 OpenSBI)之后的一个 S-mode payload,采用 PEI-less 设计”。也就是说——M-mode 的 SBI 先把内存训练好,EDK2 跳过传统的 SEC/PEI/CAR,直接从 DXE 起步,整条链是 SBI(M-mode) → EDK2(S-mode, PEI-less) → OS。这与 x86 上 EDK2 自己从 reset vector 一路 SEC/PEI 训内存的形态截然不同,正是因为 rv 把”机器态固件”独立成了开放的 SBI。EDK2 本体体量惊人(约 200 万行 C、27 个顶层 Pkg,核心是 MdePkg 接口定义 + MdeModulePkg 通用驱动 + OvmfPkg 平台),是工业固件的事实底座。

6.4 rboot:527 行的极小 UEFI 引导器

与 EDK2 的庞大相对,rboot 是个绝佳的反例——一个 527 行 Rust、4 个文件的 UEFI 引导器(rcore-os 王润基所作,x86_64)。关键在于它不实现 UEFI,而是 UEFI 的”客户”:它编译成一个 PE/COFF 的 .efi 应用,跑在前述 TSL 阶段,把文件系统解析、物理内存管理、串口、显存、ACPI 查找、PE/COFF 加载这些约一万七千行的脏活全部交给底下的固件(EDK2/OVMF),自己只专注”把内核加载好、跳进去”。这正是”规范与实现分离”带来的红利:写引导器不必从零造固件。

rboot 的 efi_main 主流程(按真实源码,八步):

  1. 初始化 uefi-rs 运行时(装上分配器、日志、panic handler,都靠固件的 BS);
  2. 从 ESP 读配置文件 \EFI\Boot\rboot.conf
  3. 经 GOP(Graphics Output Protocol)初始化显存,拿到 framebuffer 信息;
  4. 从 ConfigurationTable 取 ACPI RSDP 与 SMBIOS 地址(要传给内核);
  5. 读 ELF 格式的内核文件,记下入口地址;
  6. 读 initramfs(可选);
  7. 建页表——注意必须在 ExitBootServices 之前:因为页表的帧分配器底层调用的是 allocate_pages,那是 Boot Service,一旦 ExitBootServices 就失效了。rboot 在这里有个精到的细节:它不新建 PML4、不切 CR3,而是拿固件已经在用的页表(从 Cr3::read() 读出)、临时去掉写保护、在其上追加高半区映射(映射内核段、栈、以及把物理内存线性映射到一个偏移),映射完恢复写保护;
  8. 预分配好 BootInfo(exit 之后连内存分配都没了),调 ExitBootServices 交接,把最终内存图填进 BootInfo,最后跳进内核——跳转用的是 call(不是 jmp)、通过 rdi&BootInfo(System V AMD64 第一参数寄存器)、且不重载 CR3,内核就跑在”固件页表 + rboot 追加映射”之上。

这一步步正是”穿越系统之前”最后一跳的精确机制:ExitBootServices 就是 UEFI 世界的”权限交接瞬间”,与 rv 里 SBI mret 从 M-mode 降到 S-mode 是同一性质的动作。

值得并提的是 EFI stub:Linux 内核可以自己伪装成一个 PE/COFF 的 .efi(x86 在 arch/x86/boot/header.S 加 PE 头、libstub 提供 EFI 入口),从 Linux 3.3(2012)起,UEFI 能直接把内核当应用启动,连独立引导器都省了。rboot 与 EFI stub 是 TSL 阶段的两种风格——前者是”外部引导器加载 ELF 内核”,后者是”内核自带 PE 头、自己当引导器”。

6.5 GRUB2:OS 选择器与 Multiboot 协议

GRUB2(约 30 万行 C,GPL-3.0+)的生态位与 U-Boot/barebox 不同:它是OS 选择器 / boot manager,跑在 BIOS 或 UEFI 固件之上,不做硬件初始化(DDR、时钟都依赖固件已就绪),职责是”找内核 + 出菜单 + 按协议加载 + 跳转”。同位的是 systemd-boot、rEFInd。它同时是”实现”与”协议倡导者”——Multiboot 协议就是它定的。

会用它(使用者角度)。 桌面/服务器上,GRUB 的配置是 grub.cfg,但一般不手写,而是用 grub-mkconfig/etc/default/grub(约 30 个变量)+ /etc/grub.d/*(一组脚本:10_linux 探测内核、30_os-prober 探测其它 OS、40_custom 自定义项)生成。安装到磁盘用 grub-install。一个启动项是一段 menuentry,里头是 linux/initrd/multiboot 等命令。启动失败掉进 grub rescue> 还能手动 set root=linuxboot

会写它(实现者角度,源码级)。 GRUB 的可移植性来自几个干净抽象:

  • 加载器统一抽象:所有协议(multiboot/linux/chainloader/xnu…)都实现一组 cmd_xxx + xxx_boot + xxx_unload,注册进唯一的全局加载器槽(grub_loader_set);boot 命令或超时触发 grub_loader_boot() 调用当前 boot 钩。
  • 文件系统抽象:42 个 fs 驱动(ext2/btrfs/fat/xfs/zfs/erofs…),统一 struct grub_fs vtable;没有固定 magic 表grub_fs_probe 顺序对每个 fs 试探性 fs_dir(device,"/"),第一个不报 BAD_FS 的中标。
  • 分区表抽象:11 种 partmap(gpt/msdos/apple/bsd…),各是一个 iterate vtable;GPT 解析就是读 protective MBR 验 0xAA55 → 读 LBA1 验 “EFI PART” → 遍历分区项。
  • 脚本引擎grub.cfg 是简化版 shell,由 Flex 词法 + Bison 语法(约 350 行文法)解析成 AST,再由树解释器执行;menuentry 本身是个内置命令,body 当”延迟求值的字符串块”,选中才执行。
  • Secure Boot = verifiers 框架:核心是一个通用校验过滤器——每次 grub_file_open 都过一遍注册的 grub_file_verifier。其中 shim_lock verifier 有两条路径:现代 shim 暴露 image-loader 协议、旧 shim 用 EFI_SHIM_LOCK_PROTOCOLverify(buf,size);UEFI 变量 MokSBState==1 可关闭校验。另有一条并列的 PGP verifier——把 GPG 公钥编进 core.img,对内核/模块/ACPI/设备树做 detached 签名校验,是独立于 shim 的第二条信任根。

Multiboot 协议——让内核与引导器解耦。 这是 GRUB 对生态的最大贡献:任何遵守 Multiboot 的内核都能被任何遵守 Multiboot 的引导器加载。机制是内核在文件开头嵌一个 magic 头,引导器扫描确认后按约定加载并在跳转时把信息表地址放进寄存器:

常量 Multiboot 1 Multiboot 2
内核内嵌 header magic 0x1BADB002 0xE85250D6
引导器跳转时寄存器 magic 0x2BADB002(EAX) 0x36D76289(EAX)
header 必须在文件前 N 字节 8192 32768
header 对齐 4 8

跳转 ABI(BIOS 下):EAX = 引导器 magic、EBX = 信息表(mbi)物理地址、32 位保护模式、关中断。MB2 用 tag 链表传递命令行、内存图、framebuffer 等,比 MB1 灵活得多。自己写一个 Multiboot 内核或引导器,照着这几个常量与 ABI 即可对接。

6.6 barebox:Linux 风格重写的 bootloader

barebox(约 10 万行 C,是 U-Boot 的七分之一,GPL-2.0)的生态位与 U-Boot 相同——一个独立 bootloader,自己做 DDR 后期、时钟、驱动,提供 shell/fs/net,加载内核。它的设计哲学是”用 Linux kernel 的风格重写 U-Boot”:POSIX 文件系统接口、devfs、Linux 式驱动模型、与 Linux 同款的 Kconfig。主战场是工业、汽车、医疗。

会用它(使用者角度)。 交互是 hush(Bourne 语法的 shell),有 if/for/while/function。启动策略可以一行 boot bootchooser 走 A/B 回退;启动项可走 blspec(BootLoaderSpec,与 systemd-boot 同规范,读 loader/entries/*.conf)。配置在 defaultenv 与全局变量。调试体验是亮点:sandbox 架构能直接在 PC 上把 barebox 当普通程序跑,配 pytest 做自动化测试。

会写它(实现者角度,源码级)。 barebox 也分两段,但命名和组织更像内核:

  • PBL(Pre-Bootloader)→ proper:PBL 相当于 SPL,它自重定位(PIC)、解压前就开 MMU 加速 D-cache、解压(lz4/lzo/gz/xz)——而且解压出来的是 ELF 不是裸二进制,按段权限 PF_R/W/X 推页表权限(PBL 阶段就实施 W^X),再通过 type-tag 的 handoff-data 把 FDT 传给 proper(取代 r0/r1/r2 寄存器约定)。
  • proper = initcall for 循环start_barebox 顺序跑 17 级 initcall(pure/core/postcore/console/mem/mmu/…/late),然后进 autoboot 倒计时、run_shell(hush)。这与 Linux 的 initcall 机制几乎同构。
  • 驱动模型仿 Linuxdrivers/ 下 52 个子目录与 Linux drivers/ 几乎一一对应(clk/i2c/spi/mtd/net/mci/pci/usb…)。
  • POSIX VFS + devfsfs/fs.c(3654 行)提供 open/read/write/lseek/mount,18 个 fs;devfs 把分区做成 cdev(子 cdev 自动带 offset),任意嵌套。学完 Linux VFS 基本就懂了 barebox VFS——i_fop 同名同语义。
  • bootchooser A/B 回退状态机:每个 target 有 priority 和 remaining_attempts;选 target 时先把 attempts 减一并立即写回非易失存储,再跳转(防 panic 循环耗尽),OS 跑到用户态调 last_boot_successful 才重置计数。天然对接 RAUC OTA。

关于 multi_v8 的一个常见误解(顺带澄清)。 常说”barebox 一个二进制跑数十块板”,更精确的说法是:multi_v8_defconfig 一次构建出数十个镜像,它们共享同一个 barebox proper,但每块板有各自的 PBL(含该板设备树,prepend 到公共 proper 前)。唯一真正”一个二进制通用”的是 barebox-dt-2nd.img——它作为第二级被上层(U-Boot/UEFI)加载,靠运行时传入的 FDT 判断板型,因此 aa64 任意板可跑同一份。

把 L3 的四位主角放一起:U-Boot 与 barebox 是嵌入式独立引导器(自己初始化硬件),GRUB 是桌面/服务器的 OS 选择器(依赖固件),UEFI 是规范化的固件引导环境。它们的共同点是”把内核加载进内存、按 ABI 跳转”;差异在做不做硬件初始化、是实现还是规范、面向嵌入式还是桌面。


7. L4:OS Loader 与 boot protocol——把内核搬进内存、对齐交接契约

引导器找到内核之后,要做两件事:把内核镜像放到正确的内存位置,并按内核期望的”交接契约”(boot protocol)准备好寄存器、机器状态与配套数据。这一棒看似只是”加载 + 跳转”,但每个架构的契约细节不同,错一个寄存器就启动失败。

7.1 内核镜像格式速览

内核从编译产物到可引导镜像,经过一条加工链:

  • vmlinux:ELF 格式、带符号的完整内核,用于调试,不直接启动
  • Image(rv/aa64):objcopy 从 vmlinux 抽出的裸二进制,前 64 字节是约定的头(见下),可加载到任意 2MB 对齐地址自重定位;常配 Image.gz
  • bzImage(x86,”big zImage”):自带实模式 setup 头 + 自解压的内核,是 x86 的标准可引导格式;
  • uImage(U-Boot 传统格式):在内核前加 64 字节 mkimage 头(魔数 0x27051956,记录加载/入口地址、类型、CRC);
  • FIT(.itb):复用 DTB 二进制格式,把内核 + 设备树 + initramfs 多个 payload 打成一个文件,支持逐镜像哈希/签名与多 configuration(见 §7 末)。

7.2 三线 boot protocol

维度 rv aa64 x86(bzImage)
传参方式 寄存器 寄存器 结构体(setup header)
硬件描述指针 a1 = dtb 物理地址 x0 = dtb 物理地址 无(x86 走 ACPI)
其它入口寄存器 a0 = hartid x1x3 = 0(保留) ——
镜像头魔数 "RSC\x05" @0x38 "ARM\x64" @0x38 HdrS(0x53726448) @0x202
进入时 MMU 由 SBI 设好,内核自建页表 必须 off 实模式起步,内核自切
进入时 cache —— data cache 必须 off ——
进入特权级 S-mode EL2(首选)或 EL1 实模式→保护→长模式

三线细看:

  • rv:入口时 a0 = 当前 hart 的 ID、a1 = dtb 物理地址(这正是从 L0 一路透传下来的那两个寄存器)。64 字节 Image 头里,code0/code1 是可执行跳转码(EFI 启动时 code0 被换成 “MZ”)、text_offsetimage_sizeflagsmagic2 = "RSC\x05" 在偏移 0x38——这个偏移与 aa64 刻意对齐。进内核时 MMU 还是关的,内核自己建页表。
  • aa64:只传 x0 = dtb 物理地址(注意 aa64 不传 hartid),x1x3 保留为 0。机器状态约束很硬:MMU 必须关、内核镜像区的 data cache 必须关、异步中断 masked、进入 EL2(首选)或 EL1;dtb 要 8 字节对齐且不超过 2MB。镜像头魔数 "ARM\x64" 在 0x38。
  • x86:不靠寄存器,而是一整个实模式 setup header(HdrS 魔数在偏移 0x202,不在则按老协议处理)。引导器填 boot_params/setup_header 里的 cmd_line_ptr(命令行线性地址)、ramdisk_image + ramdisk_size(initrd)、code32_start 等,保护模式内核放在 0x100000(high)。

7.3 Multiboot:OS 无关的引导契约

Multiboot 是 GRUB 推动的协议,特点是与具体 OS 无关——任何遵守它的内核都能被任何遵守它的引导器加载,常见于教学 OS(如 xv6)。它不绑 UEFI,硬件信息靠引导器构造的信息表传递,而非 DTB/ACPI。两代的关键常量在 §6.5 已列:MB1 内核头魔数 0x1BADB002、跳转时 EAX = 0x2BADB002 + EBX = 信息表指针;MB2 头魔数 0xE85250D6EAX = 0x36D76289 + tag 链表。

7.4 EFI stub:内核自己当引导器

现代 UEFI 平台上有一条统一三线的捷径:内核镜像本身同时是一个合法的 PE/COFF(.efi——code0 处是 “MZ”、偏移 0x3C 指向 PE 头。于是 UEFI 固件能像运行普通 .efi 一样直接 LoadImage/StartImage 内核,内核里的一段 stub 代码先作为 EFI 应用运行(此时 Boot Services 还在),自己取齐信息、调 ExitBootServices,再跳进真正的内核入口。这让 rv/aa64/x86 的内核”既是裸 Image,又是 .efi“。

stub 与内核主体怎么通信,是这条暗线的收口:stub 用一棵设备树(哪怕在 ACPI 系统上也造一棵极小的 “tiny DT”)承载 EFI system table 地址与 EFI 内存图,其余信息(RSDP、cmdline、initrd、KASLR 种子)走 Linux 私有的 EFI configuration table 或写进 DT 的 /chosen。设备发现的最终分叉就在这里:ACPI 启用时,RSDP 经 EFI Configuration Table 的 ACPI_20_TABLE_GUID 暴露给内核(UEFI 规范强制);ACPI 禁用时,内核用经 DEVICE_TREE_GUID 转发的设备树。

7.5 cmdline 与 initrd 怎么带过去

内核命令行与初始内存盘的传递,对应三条 boot protocol 各有通道:

数据 DT 线 x86 线 EFI stub 线
cmdline /chosen/bootargs(引导器 fixup 写入) setup_header.cmd_line_ptr .efi LoadOptions 转写进 /chosen 或 boot_params
initrd /chosen/linux,initrd-start + -end ramdisk_image + ramdisk_size LINUX_EFI_INITRD_MEDIA_GUID 设备路径加载

FIT 镜像则把内核、设备树、ramdisk 一起打包,U-Boot 解析后填 /chosenbooti/bootm——mkimage -f kernel.its kernel.itb 生成,bootm 0x...#conf-name# 选 configuration,配合把公钥嵌进 u-boot.dtb 可做验证启动。


8. L5:内核第一条指令之前的最后一跳

所有铺垫到此收束于一个动作:把 CPU 的控制权交给内核的第一条指令。这一跳的精确性是整条链的成败所在——入口地址、参数寄存器、机器状态,一个错位就是 panic。

8.1 三线交接 ABI 对照

维度 rv aa64 x86
交接指令 mret(M→S)或 sret ERET(降到 EL1/EL2) 跳转到保护/长模式入口
入口寄存器 a0=hartid,a1=dtb x0=dtb RSI/boot_params 指针
硬件描述 dtb(a1 dtb(x0)或 ACPI(EFI table) ACPI(RSDP)
进入特权级 S-mode EL1/EL2 长模式 ring 0

8.2 最后一跳的精确动作

  • rv:固件(SBI)设 mepc = 内核入口、mstatus.MPP = S-mode、a0 = hartid、a1 = dtb,执行 mret——CPU 一步切到 S-mode、PC 落在内核第一条指令。这正是 §5.4 那八步的终点。
  • aa64:BL31 或 U-Boot 把状态降到 EL1/EL2(ERET),x0 = dtb,且确保 MMU、data cache 已关——内核 head 代码不能踩到陈旧缓存。
  • x86:引导器填好 boot_params,按协议跳进保护/长模式内核入口;走 UEFI 时则是内核 EFI stub 或外部引导器在 ExitBootServices 之后完成跳转。
  • UEFI 通路(以 rboot 为例)ExitBootServices 是这条路的”交接瞬间”——之后引导器用 call 跳进内核、用 RDI&BootInfo,内核就跑在固件页表加引导器追加的映射之上(§6.4 已逐步拆过)。

8.3 一个寄存器都不能错

把这一跳单独成节,是因为它最不容差错也最能体现整条链的契约本质:每一棒都在”上一棒交给我什么、我交给下一棒什么”的寄存器约定上严丝合缝。 rv 的 a1、aa64 的 x0 一旦不是 dtb 物理地址,内核要么找不到内存、要么解析到垃圾,瞬间崩在第一条指令附近;x86 的 boot_params 字段错位同理。这套从 L0 的复位向量一路传到 L5 的寄存器契约,就是”穿越系统之前”这趟旅程的脊梁。

走完这一跳,固件与引导的世界就结束了——内核的第一条指令开始执行,机器从此进入操作系统的纪元。


9. 贯穿全链的两条暗线:设备发现 与 安全启动

前面按 L0–L5 纵向走了一遍,还有两条暗线横向贯穿每一棒:硬件长什么样怎么告诉内核(设备发现),以及怎么保证每一棒都没被篡改(安全启动)。它们不属于某一级,而是从 BootROM 一路渗到内核。

9.1 设备发现:Device Tree 与 ACPI

操作系统启动后不能把”机器上有哪些硬件、在什么地址”写死在代码里——否则换块板就得改内核。业界有两套答案:Device Tree(DT/FDT),嵌入式、rv、aa SoC 的主流;ACPI,x86 PC/服务器、aa 服务器、以及未来 rv 服务器的选择。

两套体系的由来。 DT 源自 1990 年代 Sun 的 OpenFirmware(OpenBoot),2005 年 Ben Herrenschmidt 把内存里的设备树扁平化序列化成 Flattened Device Tree(FDT),随 Linux PowerPC 移植引入——这是今天 DTB 二进制格式的起点;2011–2013 年 Linus 痛斥 ARM 子树满地板级 hack,DT 借机在 ARM 全面铺开;2016 年 devicetree.org 独立维护规范,v0.4(2020)是当前稳定版。ACPI 则诞生于 1996 年(Intel + Microsoft + Toshiba),为 PC + Windows + 即插即用 + 电源管理设计,取代了更早的 APM/PnP BIOS/MPS;它的核心创新是用 AML 字节码描述硬件与”控制方法”,让 OS 不必为每台机器写死代码;2013 年 ACPI 5.0 引入 Hardware-Reduced 模式 + ARM 支持,走出 x86 进了 aa 服务器;参考实现是 Intel 维护的 ACPICA(本仓 boot/acpica),2023 年起 rv 的 ACPI 支持也进了 Linux 主线。

要点是:DT 没”取代”ACPI,ACPI 也没”取代”DT,二者按生态分流——嵌入式/移动用 DT,PC/服务器用 ACPI;被真正取代的是 BIOS 里的 APM/PnP 和 ARM 早期的 mach-* C 代码。

DTB 的二进制结构(实现者要懂的)。 DTB 是一坨连续字节(装进内存后叫 FDT,同一坨),big-endian,由四段组成:Header(魔数 0xD00DFEED、各段偏移、版本)、内存保留块、Structure Block(token 流:BEGIN_NODE/END_NODE/PROP/NOP/END)、Strings Block(属性名去重池)。属性名不存在树里、而存在 Strings Block,PROP token 用偏移引用,所以 reg/compatible 出现 N 次只存一份。关键概念:compatible(字符串列表,按”最具体→最通用”排,驱动据此匹配)、reg(地址+大小,cell 宽度由父节点的 #address-cells/#size-cells 决定)、phandle(节点引用 ID)、/chosen(放 bootargs、initrd 地址等 boot 元数据,引导器常往这写)、/memory(标 RAM)、interrupt-parent + interrupts(树状中断模型)。

DTB 在全链怎么流转。 这正是暗线:dtb 物理地址从 L0 起就放在 a1(rv)/x0(aa),一路透传到内核,途中只有四处真正修改它——U-Boot SPL 打板级补丁(内存大小/MAC)、U-Boot proper 在 bootcmd 期间写 /chosen、SBI 在 mret 前加 memreserve 保护自己的 M-mode 内存区、Linux 运行时的 overlay;其余各棒只读透传。其中 SBI 加 memreserve 这步不可省:Linux unflatten 后建页分配器,没被声明保留的物理内存全发给用户,SBI 自己的 .text/.stack 若不在 memreserve 里就会被占用,下次 ecall 跳进垃圾代码而 panic——这是 OS 友好告知固件占用区的唯一通道。

ACPI 的体系(对照)。 ACPI 是一组表,信任根是 RSDP(签名 "RSD PTR "),指向 RSDT/XSDT(表目录),再指向各功能表:FADT(电源管理寄存器 + 指向 DSDT)、DSDT(主 AML 字节码块)、MADT(中断控制器描述,= DT 里中断控制器节点的等价物)、rv 专用的 RINTC/IMSIC/APLIC/PLIC/RHCT 等。与 DT 最大的不同是 ACPI 有解释器_CRS(取资源)、_STA(状态)这些是可执行的 AML 控制方法,OS 跑 ACPICA 解释器去执行,而 DT 是纯静态数据、直接读。RSDP 怎么找:x86 legacy 扫 EBDA 与 0xE0000 段(16 字节步进 + checksum),现代/aa/rv 一律走 UEFI——从 EFI Configuration Table 按 ACPI_20_TABLE_GUID 取(规范强制)。

维度 Device Tree ACPI
生态 嵌入式 / rv / aa SoC x86 PC/服务器 / aa 服务器 / rv 服务器
语法 DTS → DTB ASL → AML 字节码
解释器 (纯静态读 blob) (AML 解释器,控制方法可执行)
大小 几 KB ~ 几十 KB 几十 KB ~ 几 MB
字节序 big-endian little-endian
信任根 a1/x0 指针 RSDP(扫描或 EFI table)
资源获取 reg/interrupts(静态字段) _CRS(AML 控制方法,动态)
维护方 devicetree.org UEFI Forum / Intel ACPICA

核心取舍:DT 简单、静态、无解释器,适合不变的硬件和极小固件(SBI 单趟 walk 就能解析);ACPI 复杂、动态、自带字节码解释器,适合多变配置与高级电源管理(厂商把逻辑封进 AML,OS 不必知道板子细节),代价是庞大、需要解释器、攻击面大。rv 嵌入式默认 DT,是因为 DT 出生于嵌入式、结构简单;rv 服务器走 ACPI,是为了对齐 aa 服务器生态(统一发行版镜像与 OEM 工具链)。

自己实现一个最小的设备发现(实现者角度)。 写一个极小的固件,不必上完整 libfdt:一个 FDT 单趟 walk parser 约两百行就够——校验魔数 0xD00DFEED、读 off_dt_struct/off_dt_strings、主循环逐 token(BEGIN_NODE 深度加一并读节点名、PROP 读长度与名偏移、END_NODE 深度减一、END 结束),关心的字段(/cpustimebase-frequency、CPU 的 riscv,isa/soc 下设备的 compatible 首串与 reg 首地址)拷进自己的 .bss 缓冲(别留 FDT 内指针,那块内存可能被 OS 覆盖),单趟扫完即扔。ACPI 侧同理:要做 CPU/中断发现,不必实现 AML——拿到 RSDP、走 XSDT、按签名找 MADT,逐子表解析(rv 的 RINTC 取 HartId/ImsicAddr、PLIC 取 BaseAddr)即可;只有电源管理(_CRS/_PSx)才需要 AML 解释器,这是裁剪的关键点。

9.2 安全启动:Secure / Verified / Measured Boot 三线

启动链的每一棒都可能被篡改,于是有了三种保护机制——名字相近,机理不同,常被混用,这里厘清。

机制 动作 是否拦截 信任根 代表
Secure Boot 执行点门卫:拒绝运行未签名/不可信代码 是(在执行点拦) UEFI PK/KEK/db/dbx UEFI Secure Boot
Verified Boot 每段被上一段用 OEM 密钥验签后才加载 是(在加载点拦) SoC eFuse 固化的 OEM 公钥 hash + 不可改 ROM Android AVB / ChromeOS
Measured Boot 度量每段 hash,extend 进 TPM PCR,不拦不拒 否(只记录) TPM EK(出厂注入) TPM + 远程证明

核心分歧:Secure/Verified 是”强制”——签名不对就不让跑;Measured 是”记录”——照跑,但把”跑了什么”刻进 TPM,事后由远程证明(用 AIK 签 PCR 交给远端裁决)。三者常叠加:先 Verified/Secure 保证只跑可信码,再 Measured 留下可证明的运行证据。TPM 的累积机制是 PCR_new = H(PCR_old ‖ measurement)——哈希链上任一段偏离,PCR 终值就错。信任根分两类:度量根是 TPM 的 EK(出厂可信),验证根是 SoC eFuse 里烧死的 OEM 公钥 hash + 上电第一条指令所在的不可改 mask ROM(物理不可篡改);私钥永不落到设备上。

把它落到本篇的 L0–L5 链上,就是一条逐级验签的信任链

1
2
3
4
5
6
硬件信任根(SoC ROM 内固化 OEM 公钥 hash)
→ 验 SPL 签名 → SPL
→ 验 FIT 签名 → FIT(SBI + U-Boot proper + dtb)
→ 验 kernel 签名 → Linux
→ 验 rootfs 哈希树(dm-verity)→ rootfs
→ 验 app 签名 → 应用

每一段都用上一段提供的公钥验下一段,断在哪一环就停在哪一环。各家生产实现各有侧重:ChromeOS 是 RO firmware → RW firmware → kernel → dm-verity rootfs 全链;Android AVB 2.0 用 vbmeta 分区集中存哈希与签名、链式验各分区、并有绿/黄/橙/红启动状态分级;iOS 是 BootROM → LLB → iBoot → kernel 的 Apple 私钥链;Windows/UEFI 走微软签 shim → 发行版签 grub → 签 kernel;Intel TXT 则是 Measured Boot 的典型,度量固件→bootloader→kernel 进 TPM PCR0–7。

跨架构的”安全世界”。 安全启动之外,运行时的可信隔离三线差异很大:x86 是 SMM(ring -2)/ME(ring -3)/SGX/TDX/SEV,比较碎片化;aa 的 TrustZone 最成熟——EL3 的安全监控就是 TF-A BL31,安全 EL1 跑 OP-TEE 这类 TEE OS,非安全位由硬件强制,入口指令是 smc;rv 基础 ISA 没有 TEE,靠 PMP/ePMP 打底,社区有 Keystone/Penglai 等方案在竞争(多为研究态),入口是 ecall 进 M-mode 的安全监控;la 没有独立安全世界,靠 UEFI Secure Boot + 外挂 TPM 2.0 + 国密 SM2/3/4 + 厂商签名链。这一段正好与 §5 的 L2 呼应——机器态固件那一棒,本身就是安全边界的看门人。


10. 跨架构抽象:用一套 HAL 收住四条线

前面把四条线的差异铺开了,自然会问:内核能不能用一套代码同时支持它们?答案是硬件抽象层(HAL)。这里用一个真实的跨架构 HAL——polyhal(Byte-OS 出品,MIT,支持 rv/x86/aa/la 四架构,与 arceos 的 axhal 属同一”Rust 跨架构内核基座”潮流)——把全篇的四架构分歧在代码级收口,看看抽象能做到什么程度、又有哪些做不到。

10.1 一个抽象名,四份实现

polyhal 的核心机械是一个过程宏 define_arch_mods!:任何子系统目录只写一行,它就按当前目标架构把 riscv64.rs/x86_64.rs/aarch64.rs/loongarch64.rs 里对应那份接进来。于是上层只看到统一的抽象名,底下是四份独立实现。最能体现差异的是启动入口——四份 _start 都是裸函数、职责一致(建栈 → 建地址翻译 → 开 MMU/分页 → 跳进 Rust),但指令毫不相同:

架构 _start 收到什么 怎么建地址翻译 跳入
rv a0=hartid,a1=dtb Sv39 临时页表(1G 大页恒等 + 高半区)→ satp rust_main
aa x0=dtb,mpidr→cpuid 配 MAIR/TCR/TTBR0+TTBR1 临时页表 → SCTLR 开 M rust_tmp_main
x86 Multiboot:eax=magic,ebx=info 汇编设 CR0(PG)/CR4(PAE)/EFER(LME) 切长模式 rust_tmp_main
la csrrd 读 cpuid 写 DMW 直接映射窗口(无需页表即可线性访问物理)→ CRMD 开分页 rust_tmp_main

这张表是四架构启动差异最浓缩的一处:rv/aa 走”临时页表 + satp/TTBR”,la 走”DMW 直接映射窗口”(连页表都不必先建),x86 走”Multiboot 头 + 长模式切换”,polyhal 用四份独立裸汇编各自吃掉。

四个入口随后收口到同一套 ctor 多阶段框架:按固定优先级 Primary → Cpu → Platform → HALDriver → KernelService → Normal 跑构造器,驱动与中断控制器用一个 ph_ctor! 宏把自己的 init 函数注册进一个链接段、由框架统一扫描调用——这取代了”一长串手写 init() 调用”,和 §6.2 讲的 U-Boot linker-list 注册是同一思路。

10.2 同一个 boot_core,背后四种固件协议

把这套抽象放到”上电后谁来拉第二个核”这个问题上,最能看出 HAL 之下的硬件分歧。polyhal 的 boot_core(hartid, entry, sp) 是统一抽象,四份实现却调了四种完全不同的固件协议:

架构 拉核机制
rv SBI HSM hart_start(§5 讲的那个)
aa PSCI cpu_on(经 smc/hvc 进 EL3 的 TF-A BL31)
la IPI mailbox:写 mail buffer + 发 IPI,从核从邮箱取栈指针
x86 INIT-SIPI-SIPI:经 LAPIC 发,AP 从实模式 trampoline 起

这正是前面各章铺垫的收束——L2 那一棒(SBI / PSCI / 固件)不只是”启动时降一次特权级”,还在运行期持续提供”拉核、关机、定时器”这些服务,而它们在四条线上是四套协议。trap 抽象同理:polyhal 用统一的 TrapType 枚举,但装中断入口与译码各异(rv 写 stvecscause、la 写 eentryestat、aa 用 VBAR 向量表、x86 用 IDT)。

10.3 HAL 能屏蔽到什么程度(要诚实)

抽象不是万能的。polyhal 的中断抽象只在 aa 的 GICv2 与 x86 的 APIC 上是完整的;rv 的 PLIC 与 la 的 EIOINTC 它并不驱动——per-IRQ 的开关、claim/complete 都是”未实现”占位,外部中断留给上层内核处理,HAL 只统一了”全局中断开关 + 时钟”。原因在 §9.1 的取舍里能找到根:rv 早期 PLIC 各家变体多、难统一抽象。此外 polyhal 的板级地址多是按 QEMU virt 硬编码的(GICD、IOAPIC、la 的 dtb 地址等),真实多板仍要靠 FDT/ACPI 运行时发现。说”HAL 屏蔽硬件差异”时,要清楚它的边界在哪。

10.4 la 启动一瞥:从 PMON 到 UEFI

la 的启动路径正好是一个”私有固件拥抱标准”的活案例,值得对照一句。老的 MIPS 龙芯(2E/2F、3A1000–4000)用 PMON2000——一个 MIPS 平台的单体固件,BIOS + bootloader + monitor + debugger 四合一(谱系可追到 Phil Bunce 的 PMON、再由瑞典 Opsycon 大改成 PMON2000;本仓 boot/pmon 即此):复位向量 0xbfc00000start.S(开头十六条指令内配 CP0、建栈、PIC 重定位),做 DDR 训练(arch/mips/mm/ 下几十个 leveling 汇编)与 cache 初始化,进 C 后跑监控器主循环,最后用 argc/argv/envpa0/a1/a2)把内核命令行交给 Linux——这套交接约定与 rv(a0=hartid/a1=dtb)、aa(x0=dtb)、x86(Multiboot)又都不同。新的 LoongArch(3A5000 起,2021 后)则改用 UEFI 固件(基于 EDK2 的龙芯移植) + GRUB 或 EFI stub,内核是 EFI PE 镜像。也就是说,PMON 在 LoongArch 上被 UEFI 取代了,路径与 x86 从 BIOS 走向 UEFI、U-Boot 反向内置 UEFI 是同一股标准化潮流。需要分清的是:本仓 PMON 的 arch/ 只有 mips/i386/powerpc、没有 loongarch——“龙芯用 PMON”严格说指的是老 MIPS 龙芯,不是今天的 LoongArch。

10.5 四架构分歧总表

把全篇的横向差异收成一张表,作为这一章的落点:

环节 rv aa x86 la
上电交接 ABI a0=hartid,a1=dtb x0=dtb Multiboot eax/ebx 新:UEFI;老:argc/argv/envp
地址翻译开启 satp(Sv39 临时页表) TTBR0/1 + TCR CR0.PG + CR4.PAE + EFER.LME DMW 直接映射窗口
拉第二个核 SBI hart_start PSCI cpu_on(smc/hvc) INIT-SIPI-SIPI(LAPIC) IPI mailbox
时钟 SBI set_timer / Sstc generic timer(CNTP) APIC timer tcfg CSR
外部中断控制器 PLIC / APLIC+IMSIC GIC v2/v3/v4 8259→APIC→MSI EIOINTC / HT 向量
最高常驻固件 SBI(M-mode) TF-A BL31(EL3) SMM UEFI 运行时

11. 动手:自己写一个最小的 SBI / SPL / UEFI app / booter

前面每一棒都既讲了”怎么用”也讲了”怎么实现”,这一章把它们落到能照着敲出来的最小骨架。目标是:读完能在 QEMU 上跑通一个自己写的 M-mode SBI 固件、明白最小 SPL 的思路、写出一个最小 UEFI 应用与一个站在 SBI 上的裸机 booter,并知道一条最短可跑链。rv 为主,其余对照。

11.1 最小 SBI 固件(M-mode)

任何最小 SBI 固件(无论 OpenSBI 的 C、RustSBI 的 Rust 还是其它)都走同一条五步不变骨架,差别只在用汇编还是 naked Rust、支持几个 hart:

  1. 关中断 + 选 boot hartcsrw mie, zero;多 hart 用原子指令(amoswap.w/amoadd.w)抢一个 boot hart,其余自旋等待,避免多核同时清 bss 竞争;
  2. 设栈:每个 hart 一段独立 stack,sp 指向某段 RAM 顶;
  3. 清 .bss:boot hart 把 _bss_start.._bss_end 写零(清之前不能用全局变量;PIC 固件还要先处理 .rela.dyn 里的 R_RISCV_RELATIVE 重定位);
  4. 设 trap + 委托 + PMPmtvec 指向 trap handler,mideleg/medeleg 把中断与异常委托给 S-mode,配 PMP 给 S-mode 开内存窗口;
  5. mret 跳 S-mode:填 mepc = 内核入口、mstatus.MPP = S、a0 = hartid、a1 = dtb,执行 mret

入口汇编(综合 OpenSBI fw_base.S 与 RustSBI 的 naked _start,去掉平台细节):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.section .text.entry
_start:
csrw mie, zero # 关中断
# 选 boot hart:第一个抢到 lottery 的继续,其余去自旋
la t0, _boot_lottery
li t1, 1
amoswap.w t1, t1, (t0)
bnez t1, _wait_for_boot_hart
# boot hart:设栈 + 清 bss
la sp, _stack_top
la t0, _bss_start
la t1, _bss_end
1: bgeu t0, t1, 2f
sd zero, (t0)
addi t0, t0, 8
j 1b
2: call fw_init # 进 C/Rust 世界,不返回

第四、五步的核心在 C/Rust 侧。委托最容易写错的是:把 mideleg/medeleg 几乎全开,但必须收回 S-mode 的 ecall(否则 SBI 调用被 S-mode 自己吃掉,固件根本收不到),通常还收回非对齐访问与非法指令交给 M 侧软件模拟:

1
2
3
4
5
// 委托给 S-mode,但 SBI 入口(S-ecall)必须留在 M
csrw!(mideleg, !0);
csrw!(medeleg, !0);
medeleg::clear_supervisor_env_call(); // 关键:否则 SBI ecall 进不来
medeleg::clear_illegal_instruction(); // M 侧软件处理

PMP绝不能省——默认 PMP 拒绝一切,不开窗 S-mode 第一条访存就 access-fault。教学级最小写法是一区全开:

1
2
pmpaddr0 = usize::MAX >> 2;   // PMP 地址按 4 字节粒度,故右移 2
pmpcfg0 = 0x1f; // A=NAPOT(0x18) | R|W|X(0x7) → 全地址空间对 S/U 开放

OpenSBI/RustSBI 之所以分七八个 PMP 区(TOR 模式逐段设),是为了把固件自身的 .text/.rodata 设成 S-mode 不可写,防内核改坏固件;最小实现先用一区全开即可。

mret 三件套mepc = 入口地址、mstatus.MPP = 01(S-mode)、摆好 a0/a1,然后 mret,CPU 一步落到 S-mode 的内核第一条指令。

ecall 最小集——要把内核引到登录提示符,需要服务这几类(a7=EID、a6=FID、a0a5=参数;返回 a0=error、a1=value,收尾必须 mepc += 4 跳过 ecall 指令,否则返回后死循环):

服务 没它会怎样
console 输出(DBCN console_write_byte 或 legacy 0x01 看不到任何输出
set_timer(TIME 或 legacy 0x00 调度器/时钟卡死
BASE 探测(probe_extension 等) 内核启动早期问扩展时拿不到答复
send_ipi + RFENCE 多 hart 才需要,单 hart 可省
HSM hart_start 多核启动从核才需要
SRST system_reset 关机/重启(可选)

“下一阶段去哪”三选一:编译期写死地址(最适合 minimal,对应 OpenSBI fw_jump)、把内核打包进固件镜像(fw_payload)、运行时从 a2 指向的结构读(fw_dynamic,SPL 用这条传参)。链接脚本骨架也简单:. = 0x80000000ENTRY(_start).text.entry 放最前、payload 段固定 0x80200000。把这套写出来,就是一个能跑的 SBI——KuSBI 正是在这套骨架上用 comptime 按 spec 版本裁剪扩展。

11.2 最小 SPL 思路

SPL 比 SBI 更早一级(L1 FSBL),骨架与 SBI 极像但职责不同:它跑在 SRAM,核心是 DDR 训练 + 时钟配置,然后从存储(eMMC/SD/SPI-NOR/网络)把下一级(打包成 FIT 的 SBI + U-Boot proper)读进 DDR、跳过去。流程是 start.S 设栈(在 SRAM)→ 清 bss → board_init_f(早期 init,此时还不能用全局变量)→ DDR 训练 → spl_load_image(读 FIT)→ jump_to_image(传 dtb)。它不做 PMP/委托/mret(那是 SBI 的活),加载完直接跳 SBI。唯一不可省的硬骨头是 DDR 训练——每块板的 DDR PHY 寄存器序列都不同。好消息是 QEMU virt 的 RAM 上电即在,SPL 在 QEMU 上可以空跑、重点只剩”读 FIT + 跳转”,纯学习时甚至可整级跳过(QEMU 直接 -bios/-kernel)。

11.3 最小 UEFI app

UEFI 应用不实现 UEFI,而是固件的”客户”。入口是 efi_main(ImageHandle, SystemTable),不需要链接脚本(它是 PE/COFF,固件按 PE 入口加载),编译目标 x86_64-unknown-uefi(或 rv 的 riscv64gc-unknown-uefi)。最小的 Hello:

1
2
3
4
5
6
#[entry]
fn efi_main() -> Status {
uefi::helpers::init().unwrap();
system::with_stdout(|out| out.output_string(cstr16!("Hello UEFI!\r\n")));
Status::SUCCESS
}

要让它真正当引导器干活,就是 rboot 的五步:读配置 → 读 ELF 内核 → 映射内核段/栈/物理内存 → exit_boot_services(此后不能再用分配器/日志,所以 BootInfo 必须在这之前分配好)→ 跳内核(call 入口、RDI&BootInfo)。文件访问遵循 UEFI 的”按 Protocol 找服务”模式:拿 SimpleFileSystem 句柄 → open_protocolopen_volumeopen(path)read;ACPI/SMBIOS 则遍历 ConfigurationTable 按 GUID 找。写这些不必碰 EDK2 源码,只要 OVMF.fd 当运行环境 + uefi-rs(或 C 的 GNU-EFI)当 SDK 即可——EDK2 是”造固件”,这里是”在固件上跑 app”。

11.4 最小裸机 booter(站在 SBI 上)

最小的”裸机内核/booter” = 一个 _start + 串口输出 + 死循环。rv 上它站在 SBI 之上:链接到 0x80200000(QEMU 默认 OpenSBI 把 S-payload 放这),_start 设栈后 call rust_mainrust_main 用 SBI ecall 打印(a7=0x01 legacy putchar,或 DBCN 的 console_write_byte),再 panic handler + wfi 死循环。这样就和 §11.1 的 SBI 凑成完整的两层——下面 M-mode 固件提供服务,上面 S-mode 内核通过 ecall 用它打出 Hello。x86 上的等价物是 rboot 的 example-kernel:_start 往 COM1 端口(out 0x3F8)写字符、用 isa-debug-exit 退出 QEMU,编译目标 x86_64-unknown-none + 高半区 image-base + 静态重定位(no_std 必须自带 panic handler)。

11.5 一条最短可跑链

1
2
# 绝对最短:QEMU 内置 OpenSBI + 你的 S-mode 内核
qemu-system-riscv64 -machine virt -nographic -kernel kernel.bin

不写 -bios 时 QEMU 用内置默认 OpenSBI(fw_dynamic)当固件、load 到 0x80000000-kernel 放到 0x80200000 当 S-mode payload,进内核时 a0=hartid、a1=自动生成的 dtb。这就是”内核入口前”最短的一跳:上电 → OpenSBI(M) → mret → 你的内核(S)。要测自己写的 M-mode 固件,用 -bios none 让 QEMU 复位后直接把 -kernel 当成你的 M-mode 程序:

1
2
qemu-system-riscv64 -machine virt -nographic -bios none -kernel my_mmode_firmware.bin
qemu-system-riscv64 -machine virt -nographic -bios fw_jump.bin -kernel kernel.bin -s -S # 挂 GDB 调试

完整的 SBI + U-Boot 链则是:make qemu-riscv64_smode_defconfig && make 出 S-mode 的 u-boot.bin,再用 OpenSBI 的 fw_dynamic.bin-biosu-boot.bin-kernel;换 RustSBI 就用 cargo prototyper --payload u-boot.bin 出一体镜像作 -bios(rustsbi-qemu 仓已归档,现以 Prototyper 为准)。这条链把 §4–§8 的 SPL/SBI/U-Boot/内核接力完整跑了一遍。

11.6 易踩坑速记

  • ecall 返回前 mepc += 4,否则死循环再 ecall;
  • S-mode 的 env_call 不能 medeleg,否则 SBI 调用被 S 自己吃掉;
  • 不配 PMP,S-mode 第一条访存就 access-fault(最小 pmpaddr0 = -1>>2pmpcfg0 = 0x1f 全开);
  • mret 前摆好 mepc/mstatus.MPP=S/a0/a1;PMP 地址是 addr >> 2
  • 清 bss 前别用全局变量,PIC 固件清 bss 前先做 R_RISCV_RELATIVE 重定位;
  • 多 hart 用原子指令选 boot hart,避免并发 init 竞争;
  • UEFI:exit_boot_services 之后不能再 alloc/log,BootInfo 要提前分配;字符串是 UCS-2 宽字符;
  • 裸机 no_std 必须自带 panic handler;QEMU -kernel 不带 -bios 会自动塞默认 OpenSBI,测自己的 M-mode 固件要 -bios none

12. 想自己读源码:去哪读、按什么顺序

这一棒一棒讲下来,真正”会写”还得自己读源码。下面是各组件的上游仓库与入门文件,按从易到难排:

组件 上游仓库 入门读哪里 体量
极小 UEFI loader rcore-os/rboot src/main.rs(efi_main 全流程) 527 行,最易读
RustSBI rustsbi/rustsbi prototyper/(naked _start + rust_main
OpenSBI riscv-software-src/opensbi firmware/fw_base.Slib/sbi/sbi_init.csbi_ecall.c 中,工业基准
SBI 前身 BBL riscv-software-src/riscv-pk bbl/ + machine/mentry.S 小,读历史
U-Boot SPL/proper u-boot/u-boot common/spl/spl.c + arch/riscv/
EDK2 tianocore/edk2 MdePkg + OvmfPkg/RiscVVirt/ 极大(200 万行)
GRUB2 gnu/grub(Savannah) grub-core/loader/multiboot.c
barebox barebox/barebox arch/arm/cpu/uncompress.c(PBL)
OP-TEE OP-TEE/optee_os core/arch/arm/kernel/
跨架构 HAL Byte-OS/polyhal polyhal-boot/src/arch/
ACPI 参考实现 Intel ACPICA source/components/tables/tbxfroot.c

推荐顺序:先读 rboot(527 行,一眼看清”UEFI 应用怎么加载内核”),再读 RustSBI prototyper(Rust naked 入口最直观地展示委托/PMP/mret),再啃 OpenSBI fw_base.S(工业基准的汇编范式),然后是 U-Boot SPL(理解 FIT 加载与两阶段),最后才碰 EDK2(量级最大,挑 OvmfPkg/RiscVVirt 看 rv 的 PEI-less 路径)。规范文档配套看:SBI 规范(riscv-non-isa/riscv-sbi-doc)、UEFI/PI 规范(uefi.org)、Devicetree Specification(devicetree.org)、Multiboot/Multiboot2(GNU)。


13. 词典

  • BootROM / ZSBL / Mask ROM:芯片内出厂固化、不可改的第一段代码(L0),上电复位后第一条指令所在。rv 语境也叫 ZSBL(Zeroth Stage Boot Loader)。
  • Reset Vector(复位向量):复位后 PC 被硬件置到的固定地址。x86 = 0xFFFFFFF0,la = 0x1C000000,rv 实现定义(QEMU virt = 0x1000),aa 由 RVBAR_EL3 配置。
  • FSBL / SPL:第一段可换固件(L1),核心是 DDR 训练。U-Boot 叫 SPL,TF-A 叫 BL1/BL2,Xilinx 叫 FSBL。
  • SSBL:第二级引导器(L3),功能丰富(命令行/文件系统/网络),如 U-Boot proper、UEFI、GRUB、barebox。
  • DDR training:上电后对高速内存接口逐根信号校准采样延迟与参考电压的过程,必须运行时实测,是 L1 的灵魂。
  • Cache-as-RAM(CAR):x86 把 CPU cache 临时锁成可读写 RAM,供 DRAM 就绪前的早期代码使用。
  • SBI(Supervisor Binary Interface):rv 上 S-mode 内核通过 ecall 调用 M-mode 固件标准服务的规范;实现有 OpenSBI/RustSBI/BBL/KuSBI。
  • EID / FID:SBI 调用里的扩展 ID(a7)与功能 ID(a6)。
  • HSM:SBI 的 Hart State Management 扩展,管 hart 启停挂起,多核启动靠它。
  • PMP(Physical Memory Protection):rv M-mode 配置的物理内存访问权限;不配则 S-mode 无法访存。
  • medeleg / mideleg:把异常/中断委托给 S-mode 处理的 CSR;唯独 S-mode 的 ecall 不委托,才有 SBI。
  • mret / sret / smc / ecall:特权级返回/陷入指令。mret 从 M 降到 S(rv),smc 进 EL3(aa),ecall 陷入更高特权级。
  • Device Tree(DT/FDT/DTB):描述硬件的树状数据。DTS 是源码、DTB 是二进制(魔数 0xD00DFEED,big-endian)、装进内存后叫 FDT。
  • ACPI:x86/服务器的硬件描述与电源管理体系,用 AML 字节码 + 解释器;信任根是 RSDP,关键表有 FADT/DSDT/MADT。
  • RSDP / MADT / RHCT:ACPI 的根指针 / 中断控制器表 / rv 能力表。
  • UEFI:开机前的微型操作系统规范,当前 2.11 + PI 1.9;实现如 EDK2。
  • PI 七阶段:SEC → PEI → DXE → BDS → TSL → RT → AL;常简称前五个(SEC/PEI/DXE/BDS/RT)。
  • BS / RS(Boot/Runtime Services):UEFI 的启动期服务与运行期服务;ExitBootServices 是二者的交接点。
  • Protocol / Handle / GUID:UEFI 的 COM 风格服务发现机制。
  • ESP(EFI System Partition):放 .efi 引导器的 FAT32 分区,目录 EFI/
  • PE/COFF:UEFI 应用的镜像格式(.efi),与 Windows .exe 同源。
  • EFI stub:内核自带 PE 头,能被 UEFI 当应用直接启动,免独立引导器。
  • TF-A / BL31 / PSCI / OP-TEE / TrustZone:aa 的安全监控固件 / EL3 常驻段 / 电源协调接口 / 安全世界 OS / 安全隔离机制。
  • SMM / SGX / TDX / SEV:x86 的系统管理模式与机密计算扩展。
  • Multiboot:GRUB 推动的 OS 无关引导协议(MB1 魔数 0x1BADB002,MB2 0xE85250D6)。
  • Image / bzImage / uImage / FIT:rv/aa 的裸内核镜像(64 字节头)/ x86 自解压镜像 / U-Boot 加头镜像 / 多 payload 打包镜像(.itb)。
  • Secure / Verified / Measured Boot:执行点拒未签名 / 加载点验签 / 度量 hash 进 TPM 供远程证明。
  • Falcon mode:U-Boot SPL 跳过 proper 直接启动 Linux 的加速模式。
  • Driver Model(DM):U-Boot 的 uclass/driver/udevice 驱动框架,DT 自动 probe。
  • fw_jump / fw_dynamic / fw_payload:OpenSBI 三种固件形态——固定地址跳转 / 运行时取入口 / 内嵌内核。

14. 思考题

Q1. 为什么 L0(BootROM)和 L1(SPL)要分成两棒,不能合并?

因为二者的硬性约束相反。L0 必须不可改——它是上电托底,写死在 mask ROM 里永不失效,有 bug 也只能靠后续绕开;而 L1 的核心是 DDR 训练,训练参数随板卡、内存颗粒、走线、温度而变,必须可重新编译烧录。再加上”鸡生蛋”困局:要用 DRAM 得先运行初始化它的代码,但这段代码不能放在还没初始化的 DRAM 里,只能挤进几十 KB 的片上 SRAM,因此 L1 还必须极小。一个不可改、一个必须可改且极小,自然分成两棒。

Q2. rv 的 SBI、aa 的 TF-A BL31+PSCI、x86 的 SMM 都属 L2,它们解决同一类什么问题?三者最大的不同是什么?

它们都提供一个比内核更高特权级、常驻不退的固件层,对内核屏蔽底层硬件差异、提供运行时服务(定时器、多核启停、电源、关机),并充当可信与不可信世界的边界。最大不同在开放程度:rv 的 SBI 是开放规范 + 开源实现(OpenSBI/RustSBI),内核完全不必关心固件是谁家的;aa 的 PSCI 是开放规范、TF-A 多为开源;x86 的 SMM 则是厂商私有、随固件出货、闭源。这也是为什么本篇能把 SBI 讲到源码级,而 SMM 只能讲到机制。

Q3. 一个能把多核 Linux 引到登录提示符的最小 SBI 固件,要实现哪几个扩展?为什么是这几个?

BASE、TIME、IPI、RFNC、HSM、SRST、DBCN 共七个。BASE 是强制(内核第一条 SBI 就是问版本);TIME 提供调度心跳(没它无法分时);IPI 是核间通信(没它 SMP 失效);RFNC 做远程 TLB 刷新(没它进程切换/缺页时内存管理出错);HSM 拉起非 boot hart(没它退化成单核);SRST 负责正常关机/重启;DBCN 提供早期控制台。再往上(PMU/SUSP/CPPC/…)都是锦上添花,再往下(单核、无 earlycon)还能更省。

Q4. dtb 的物理地址是怎么从最早的固件一路传到内核的?全链里哪几处会真正修改 dtb?

通过一个寄存器一路透传:rv 是 a1、aa 是 x0,从 L0 的复位代码设好后,SPL、SBI、U-Boot 各棒只透传不重载,直到内核从该寄存器读出。全链里只有四处真正修改 dtb:U-Boot SPL 打板级补丁(内存大小/MAC 等)、U-Boot proper 在 bootcmd 期间写 /chosen(bootargs/initrd)、SBI 在 mret 前加 memreserve 声明自己占用的 M-mode 内存、Linux 运行时的 overlay;其余各棒只读。SBI 加 memreserve 这步不可省,否则内核会把固件的内存当空闲发给用户,下次 ecall 跳进被覆盖的代码而 panic。

Q5. UEFI 的 ExitBootServices 和 rv 的 mret 有什么共性?rboot 为什么必须在 ExitBootServices 之前建好页表?

二者都是权限交接的瞬间——之前是固件的世界(UEFI 的 Boot Services / rv 的 M-mode),之后控制权交给下一层(OS / S-mode),交接后某些能力立即失效。rboot 必须在 ExitBootServices 之前建页表,是因为它的页表帧分配器底层调用的是 allocate_pages,那是一个 Boot Service,一旦 ExitBootServices 就不可用了;所以页表、BootInfo 这些需要分配内存的工作都得赶在交接前做完,交接之后只剩”填最终内存图 + 跳内核”。

Q6. 写最小 SBI 固件时,为什么 medeleg 几乎全开、却必须单独收回 S-mode 的 ecall?不配 PMP 会怎样?

把异常尽量委托给 S-mode 是为了让内核自己处理缺页、断点等,减少 M 侧往返;但S-mode 的 ecall 正是 SBI 调用的入口——如果把它也委托给 S-mode,那么内核每次 ecall 就被自己接住了,固件永远收不到,SBI 形同虚设。所以必须 clear_supervisor_env_call 把这一项收回 M-mode。至于 PMP:rv 的 PMP 默认拒绝一切,不配置的话 S-mode 内核第一条访存指令就会 access-fault,连内存都读不了;最小可用写法是一区全开(pmpaddr0 = -1>>2pmpcfg0 = 0x1f)。

Q7. Device Tree 和 ACPI 为什么是”分生态”而不是”互相取代”?rv 的嵌入式与服务器分别用哪个,为什么?

因为二者的设计取向适配不同场景:DT 简单、静态、无解释器,适合硬件相对固定、固件要极小的嵌入式(SBI 单趟 walk 就能解析);ACPI 复杂、自带 AML 字节码解释器,适合配置多变、需要高级电源管理、希望”OS 不必知道板子细节”(厂商把逻辑封进 AML)的 PC 与服务器。所以它们按生态分流,谁也没取代谁。rv 的嵌入式默认 DT(出生于嵌入式、结构简单);rv 服务器走 ACPI,是为了对齐 aa 服务器生态——统一发行版镜像、统一 OEM 工具链。


Zig / Rust 对于 C/C++:两门都自称 “更好的 C/C++”(C/C++ plus),都不要 GC、都讲究”安全 × 可控”,但走了两条岔路:Rust 用编译期的”强制”换安全;Zig 用”显式 + 简单”换可控。本文全维度横扫,每条都给 Rust 写法 + Zig 写法 + 取舍(基于 Zig 0.17.0-dev、Rust 1.94)。C 只在该出现时点一句。

我(Leo)当初被 Zig 吸引,是因为它对”函数染色(function coloring)”问题的态度——这条单开一节(§9)讲。


1. 设计哲学:两种”更好的 C”

Rust 和 Zig 都想取代 C/C++,但信条不同:

\ Rust Zig
一句话 安全优先,零成本抽象 简单 × 显式,无隐藏
安全模型 编译期强制(借用检查器当警察) 运行期可选检查 + 程序员自律
抽象 trait / 泛型 / 生命周期,层层零成本 几乎不抽象,”所见即所得”
心智 “按我的规则写,我保证你安全” “给你工具,炸了算你的”
成熟度 1.0 稳定(2015),承诺不破坏 0.x,频繁破坏式更新(见 §16)

Zig 的设计写死了五条”无隐藏”原则(这是理解 Zig 一切语法的钥匙):

Zig 五原则 含义
无隐藏控制流 没有运算符重载、没有析构函数、没有异常——a + b 就是加法,不会偷偷调函数
无隐藏内存分配 标准库绝不偷偷分配堆;要分配?把 allocator 显式传进来
无隐藏的”编译期 vs 运行期” comptime 是第一公民,编译期和运行期用同一套语法
错误是值 error.Foo 是枚举值,!T 是”错误联合”类型,不是异常
C 互操作一等公民 @cImport 直接吃 C 头,零成本 ABI 兼容

对 Rust 老用户:Zig 几乎是”把 Rust 的安全保证拿掉、把元编程统一成一个 comptime、把 FFI 做到极致”的另一种取舍。下面逐条看。


2. 第一印象:Hello 与语法风格

1
2
3
4
5
// Zig
const std = @import("std");
pub fn main() !void { // !void = 可能返回错误
std.debug.print("Hello, {s}!\n", .{"Zig"}); // .{...} 是匿名元组(参数列表)
}
1
2
3
4
// Rust
fn main() {
println!("Hello, {}!", "Rust"); // println! 是宏
}

几个一眼可见的差异:@import/@xxx 是 Zig 的内置函数(编译器保留,用户不能新增);.{...} 既是匿名结构体也是元组;Rust 的 println!,Zig 没有宏(用 comptime + 格式串)。


3. 类型系统

3.1 基础类型——Zig 的”任意位宽整数”

1
2
3
4
const a: u8 = 0xFF;
const b: u7 = 127; // 任意位宽:u1..u65535 都行
const c: u3 = 0b101; // 寄存器位段神器
const w = a +% 1; // +% 回绕 +| 饱和(溢出运算符)

Rust 只有固定 u8/u16/u32/u64/u128;要 3 位字段得手动位运算或用 bitflags crate。Zig 的 u3/packed struct(u8) 让寄存器/协议位段类型安全且零开销

3.2 Optional 与 错误联合——两个”一等公民”

Rust Zig
可空 Option<T> = Some/None 枚举 ?T?*T 用空指针表示,零开销)
解包 match / if let / ? / unwrap() if (x) |v| / orelse 默认 / .?
错误 Result<T,E> 枚举 E!T 错误联合(见 §5)
1
2
3
4
var opt: ?u32 = null;
const v = opt orelse 0; // 默认值
const f = opt.?; // 断言非 null(null 则 panic)
if (opt) |val| { _ = val; } // 解包
1
2
3
4
let opt: Option<u32> = None;
let v = opt.unwrap_or(0);
let f = opt.unwrap(); // None 则 panic
if let Some(val) = opt { let _ = val; }

3.3 struct / enum / union——Zig 的 tagged union 是内建语法

1
2
3
4
5
6
7
8
9
10
const Value = union(enum) {    // tagged union:tag 自动生成
int: i64,
text: []const u8,
empty,
};
switch (v) { // switch 必须穷尽
.int => |n| use(n),
.text => |s| use(s),
.empty => {},
}

对位 Rust 的 enum Value { Int(i64), Text(String), Empty } + match——几乎一一对应,这是两门语言最像的地方(代数数据类型 + 穷尽匹配)。差别:Zig 的 union(enum) 还能写裸 union(C 风格、无 tag、@bitCast 重解释),Rust 没有安全的裸 union(要 unsafe)。

3.4 切片 / 指针 / 字符串——比 Rust 多了”指针种类”

类型 Zig Rust 对位
切片 []T / []const u8 &mut [T] / &[T] / &str
单值指针 *T / *const T &mut T / &T / *mut T
多值指针 [*]T(无长度) *mut T(裸)
C 指针 [*c]T(可空、可 0) *mut T + FFI
哨兵终止 [*:0]const u8(C 字符串) CStr

Zig 没有 String 类型,字符串就是 []const u8(UTF-8 字节切片)+ 字符串字面量 [N:0]u8。Rust 区分 String(拥有)/ &str(借用)。Zig 的指针种类更细(*/[*]/[*c]/[:0]),因为它要直接对接 C 和硬件——没有借用检查器,靠类型把”这个指针能不能空、知不知道长度”标清楚


4. 内存管理:Zig 最分裂于 Rust 的地方

4.1 所有权 vs 显式 allocator

Rust:所有权 + 借用检查器,编译期决定每块内存何时释放,drop 自动调用。你几乎不”看见”分配器(全局 alloc)。

Zig没有所有权、没有借用检查器、没有自动析构。谁要堆内存,就把 allocator 当参数显式传进去

1
2
3
4
5
fn process(a: std.mem.Allocator, data: []const u8) ![]u8 {
const out = try a.alloc(u8, data.len * 2); // 显式分配
// ... 调用者负责 a.free(out)
return out;
}

这强迫你在架构设计时就想清”谁负责分配/释放”,而不是像 C 那样到处隐式 malloc。标准库容器也一样——ArrayList 的每个 append 都要传 allocator(0.17):

1
2
3
4
5
6
7
var dbg = std.heap.DebugAllocator(.{}){};        // 0.17:GPA 改名 DebugAllocator
defer _ = dbg.deinit(); // 报告内存泄漏!
const a = dbg.allocator();
var list: std.ArrayList(u8) = .empty; // 0.17:ArrayList 默认 unmanaged
defer list.deinit(a);
try list.append(a, 7); // 每次显式传 a
try list.appendSlice(a, &.{ 8, 9 }); // → { 7, 8, 9 }

Zig 的显式 allocator = “no hidden allocations” 哲学的落地。好处:测试时换个 FixedBufferAllocator 就能裸机跑、ArenaAllocator 一次性释放、DebugAllocator 自动查泄漏——分配策略是参数而非全局。代价:啰嗦,且没有借用检查器兜底,use-after-free 要靠下面的机制防。

4.2 defer / errdefer——Zig 最香的语法糖

1
2
3
4
5
6
fn init(a: std.mem.Allocator) !*Res {
const r = try a.create(Res);
errdefer a.destroy(r); // 仅当本函数"以错误返回"时执行
try r.setup(); // 若这里出错 → errdefer 清理 r → 不泄漏
return r; // 成功返回 → errdefer 不执行
}
  • defer:作用域退出时执行(LIFO)。资源获取后紧跟 defer x.deinit()释放逻辑紧挨分配逻辑,很难忘。
  • errdefer:只在错误返回路径执行——这是 Rust 的 ? + Drop 才能做到的”出错自动清理”,Zig 用一个关键字显式表达,而且看得见

defer 跟 Go 不一样:Zig 的 defer块级作用域(离开 enclosing block 就跑),Go 是函数级(return 才跑)。在循环里区别巨大——Zig 循环体内的 defer 每次迭代结束就执行,Go 是攒到函数末尾 LIFO 一起跑。

对位 Rust:Rust 用 RAII + Drop trait 自动析构(更隐式、零关键字);Zig 用 defer 显式(更可见、可控)。一个”自动但隐藏”,一个”手动但透明”——正是两门语言的缩影。


5. 错误处理:!T vs Result<T,E>

1
2
3
4
5
6
7
8
9
const MyError = error{ TooHot, TooCold };   // 错误集 = 一组扁平标签
fn readTemp() MyError!i32 { // !T = 错误联合
return error.TooHot; // 只能返回"信号",不带数据
}
const t = readTemp() catch |e| blk: { // catch 捕获
std.debug.print("err: {}\n", .{e});
break :blk 0;
};
const t2 = try readTemp(); // try = 出错就向上传播
1
2
3
enum ReadError { TooHot(i32), TooCold(i32) }  // E 可带任意 payload
fn read_temp() -> Result<i32, ReadError> { Err(ReadError::TooHot(42)) }
let t = read_temp()?; // ? 向上传播

最大区别

Rust Result<T,E> Zig E!T
错误能否带数据 E 是任意类型(带上下文) 不能,error 只是全局扁平标签
体积 取决于 E,可能很大(带 padding/布局开销) 极小:tag + T
传播 ? try
出错清理 Drop(隐式) errdefer(显式)

体积@sizeOf(anyerror)=2(一个 u16 错误号)、@sizeOf(MyError!i32)=8(i32 的 4 + tag 2 + padding)。底层就是整数比较 + 一个 if (err) return err,无 Box、无 match 嵌套、无堆分配。

Zig 的 error 更像类型安全版 errno——适合底层系统编程,错误只需”知道发生了什么”。需要带上下文?自己设计一个 struct 当返回值,别硬塞进 error union。这是”简单 × 零成本”对”表达力”的取舍。


6. comptime:Zig 的灵魂——一个机制替掉「宏 + 泛型 + const fn + 反射」

这是 Zig 最独特的地方。

Rust 的元编程是四套互不打通的机制:声明宏(token 流转换)、过程宏(编译期跑 Rust 但隔离)、泛型(单态化)、const fn(常量求值)。

Zig 只有一个 comptime:它不是”模板”也不是”宏”,而是 在编译期直接执行普通 Zig 代码,执行结果作为 AST 插入当前位置。编译器解析到 comptime 表达式,就用内置解释器跑它,结果替换原节点。所以同一套语法、同一门语言,既写运行期也写编译期。

1
2
3
4
5
6
// 编译期算查找表,运行期直接查(零运行时开销)
const upper = comptime blk: {
var t: [256]u8 = undefined;
for (&t, 0..) |*c, i| c.* = if (i >= 'a' and i <= 'z') @intCast(i - 32) else @intCast(i);
break :blk t;
};

一个顶四个

你在 Rust 里用 在 Zig 里就是
泛型 fn f<T>() comptime T: type 参数(见 §7)
声明/过程宏 comptime 函数返回类型/代码
const fn comptime 块/函数
反射(serde/proc-macro) @typeInfo(T) 编译期类型反射

编译期反射(Rust 要靠过程宏 + serde 才能做的,Zig 内建):

1
2
3
4
5
fn dumpFields(comptime T: type) void {
inline for (@typeInfo(T).@"struct".fields) |f| { // 0.17:字段全小写 .@"struct"
std.debug.print("{s}: {s}\n", .{ f.name, @typeName(f.type) });
}
}

版本坑(0.17-dev):类型反射字段名 全小写.int / .@"struct" / .@"fn",不是旧的 .Int);动态构造类型用 @Int(.unsigned, 32)——旧的 @Type(...) 在 0.17 已删(报 invalid builtin function: '@Type')。这正是 Zig 破坏式更新的缩影(§16)。

comptime 看起来比 Rust 啰嗦(要写工厂函数),但极其透明——你清清楚楚知道编译期发生了什么,没有宏的”token 黑魔法”。这是 “simple × explicit” 哲学最闪光的体现。


7. 泛型 / 多态:工厂函数 + 鸭子类型 vs trait + 生命周期

Zig 没有 trait、没有生命周期标注、没有泛型尖括号。它用 comptime 实现泛型,两条路:

① 类型工厂(显式造一个类型):

1
2
3
4
5
6
7
8
9
fn Stack(comptime T: type) type {        // 传类型进去,返回一个新 struct 类型
return struct {
items: std.ArrayList(T) = .empty,
pub fn push(s: *@This(), a: std.mem.Allocator, v: T) !void {
try s.items.append(a, v);
}
};
}
const IntStack = Stack(i32); // 显式实例化
1
2
struct Stack<T> { items: Vec<T> }        // Rust:隐式单态化,编译器推 T
impl<T> Stack<T> { fn push(&mut self, v: T) { self.items.push(v) } }

anytype 鸭子类型(编译期推参数类型):

1
fn add(a: anytype, b: anytype) @TypeOf(a) { return a + b; }  // 谁传进来算谁

两条路都单态化(每个具体类型生成一份代码,和 Rust 泛型一样零成本)。

关键差异

Rust Zig
泛型 fn f<T: Trait>,编译器隐式推导 + trait 约束 Foo(comptime T: type) 显式工厂 / anytype
约束 trait bound(编译期接口契约) @hasDecl/@compileError 手动检查(鸭子)
生命周期 <'a> 标注,借用检查器验证 没有——指针有效性靠程序员
报错 约束不满足 → 清晰的 trait 错误 鸭子类型 → 报错可能在模板深处

Rust 的 trait 是”先声明契约、再实现”;Zig 是”直接用,编译期发现缺方法才报错”。Rust 更结构化/可读,Zig 更灵活/啰嗦——又一次”强制 vs 自由”。


8. 安全:编译期警察 vs 运行期可关检查

Rust:借用检查器 + Send/Sync编译期强制内存安全和数据竞争自由——违规直接编译不过。unsafe 块才能逃逸。

Zig没有借用检查器。内存安全靠运行期检查,且按编译模式可关(4 个 build mode):

模式 命令 安全检查 速度/体积
Debug 默认 全开(越界/溢出/UAF/null)+ undefined 填 0xAA 最慢
ReleaseSafe -Doptimize=ReleaseSafe 仍开安全检查 优化但带检查
ReleaseFast -Doptimize=ReleaseFast 全关 最快(裸奔,和 C 一样)
ReleaseSmall -Doptimize=ReleaseSmall 全关 最小(裸机首选)

外加 DebugAllocator(旧名 GPA)在 Debug 下自动抓内存泄漏 / double-free / use-after-free

Rust 的 borrow checker 是”警察”,编译期强行拦下所有违规;Zig 是”交通规则写在纸上,但车可以随时飙到 300 码”ReleaseFast 关掉检查)。Zig 承认”我管不了编译期那么多,让你在 Debug 抓到它,生产要性能就关检查”。对系统编程,有时“我知道我在干什么,别拦我” 比 “绝对安全” 更重要——这就是两门语言最根本的价值观分歧。


9. 并发 / 异步 / 函数染色(我的初心)

这是当初把我(Leo)领进 Zig 的那扇门。

9.1 什么是函数染色(Function Coloring)

Bob Nystrom 2015《What Color is Your Function?》:一门语言引入 async/await 后,函数被隐式分成两色——普通函数(红)async 函数(蓝)。蓝函数只能被蓝函数调用,传染整条调用链:底层一个 async,上面全得 async

语言 方案 染色?
Python / JS / Rust async/await 关键字 ,传染
Go / Java 虚拟线程 goroutine / 阻塞自动挂起
老 Zig(≤0.14) async fn 关键字 (同 Rust)
新 Zig(0.15+) 砍掉关键字 + Io 参数注入 设计上无

9.2 Zig 的答案:釜底抽薪——把 async 关键字砍了

0.17-dev

1
2
pub fn main() void { var fr = async foo(); }
// → error: expected ';' after statement ← async 根本不是关键字了!

Zig 在 0.15 移除了 async/await/suspend/resume 全部关键字(旧实现编译器复杂度爆炸、无法真零成本、与 comptime 交互困难)。没有颜色关键字 = 没有染色。取而代之(0.16+ 设计中):协程能力通过 Io 接口当参数注入,函数本身是中性的:

1
2
3
4
fn fetch(io: std.Io, url: []const u8) ![]u8 {   // 普通函数,只是多收个 io 参数
return io.http.get(url); // io 决定同步还是异步,函数不知道
}
// 同一个 fetch:单线程 Io → 顺序; 线程池 Io → 并发; io_uring Io → 异步 I/O

9.3 犀利吐槽

本质还是没摆脱染色问题,只是把染色问题”推迟到调用方”——决定用哪种 Io 后端的那一刻,颜色就定了。

旧设计把”是否异步”编码在函数类型里(染色传染);新设计把”如何调度”编码在参数里(注入)。函数中性了,但调用链仍要一路把 io 传下去——这何尝不是另一种形式的”传染”?只是从”类型传染”变成”参数传染”,从编译期强制变成约定。Zig 没有”消灭”染色,而是换了个更可控、更显式的形态

截至 0.17.0-dev,语言里没有 async/await;新的 Io 协程模型仍在落地,API 会变。想在 Zig 里写并发,当下用:手工状态机(裸机/no_std 永远有效)、std.Thread(有 OS)、或社区库 libxev(事件循环)/ zigcoro(有栈协程,汇编 context_switch,支持 RISC-V)。

9.4 原子操作(两门都直面内存序)

1
2
3
var ctr = std.atomic.Value(u64).init(0);
_ = ctr.fetchAdd(1, .monotonic); // .monotonic/.acquire/.release/.acq_rel/.seq_cst
const ok = ctr.cmpxchgWeak(0, 1, .acq_rel, .monotonic) == null; // CAS
1
2
3
use std::sync::atomic::{AtomicU64, Ordering};
let ctr = AtomicU64::new(0);
ctr.fetch_add(1, Ordering::Relaxed); // Relaxed/Acquire/Release/AcqRel/SeqCst

内存序模型几乎一致(都是 C++11 那套):Zig .monotonic = Rust Relaxed,其余同名。这层两门语言高度趋同。


10. 标准库:显式、不偷偷分配

Zig std 的设计跟它的哲学一脉相承:不默认分配堆错误是返回值无全局状态

能力 Rust Zig(0.17)
动态数组 Vec<T>(自带全局分配) std.ArrayList(T) = .empty + append(a, x)
哈希表 HashMap<K,V> std.AutoHashMap / std.StringHashMap.init(a)
通用分配器 全局 alloc(隐式) DebugAllocator(查泄漏)/ Arena / FixedBuffer / smp_allocator
格式化 format! / println!(宏) std.fmt + std.debug.print(comptime 格式串)
排序 slice.sort() std.mem.sort(T, s, ctx, less)

I/O 正在大改(Writergate):旧的 std.io.getStdOut().writer() 在 0.15+ 被重构为新的 Writer 接口模型,0.17-dev 仍在流变(连”标准输出怎么写”在 0.17-dev 都还会报错)。例子里安全的输出用 std.debug.print(稳定)。这又是 Zig 不稳定的一个活样本。

对位 Rust:Vec/HashMap/String 自带全局分配器、用起来”无脑”;Zig 把分配器摊开给你,省心 vs 可控的又一次取舍。

10.1 常用标准库对照(速查)

用途 Rust std Zig std(0.17)
动态数组 Vec<T>push/pop/len std.ArrayList(T).empty):append(a, x)/pop/items.len
哈希表 HashMap<K,V> / BTreeMap std.AutoHashMap(K,V) / std.StringHashMap(V).init(a)
集合 HashSet / BTreeSet 无独立 Set,用 AutoHashMap(K, void)
字符串 String(拥有)/ &str(借用),保证 UTF-8 []const u8(裸字节切片,编码自负);拼接用 ArrayList(u8)std.fmt.allocPrint
可空 Option<T>unwrap_or/map/? ?Torelse/.?/if (x) |v|
错误 Result<T,E>? E!Ttry/catch
格式化成串 format!("{}", x) std.fmt.allocPrint(a, "{}", .{x})(要 allocator)
打印 println! / print! std.debug.print("{}\n", .{x})
排序 / 二分 slice.sort() / binary_search std.mem.sort(T, s, ctx, less) / std.sort.binarySearch
遍历 Iterator trait + map/filter/collect 适配器链 无 Iterator trait:手写 for/while,或容器自带 iterator()
随机 rand(crate) std.Random(std 内建)
JSON serde_json(crate) std.json(std 内建)
文件 / IO std::fs / std::io std.fs / std.Io(0.17-dev 正重构:std.fs 部分搬进 std.IoWriter 接口重做)
时间 std::time::{Instant, Duration} std.time(0.17-dev 在变)
命令行 / env std::env::{args, var} std.process(args/env API 0.17-dev 在变)

两条总纲:① “要分配就传 allocator”贯穿整个 Zig stdArrayList.append(a, x)allocPrint(a, …)HashMap.init(a)),Rust 用全局分配器把这层藏起来。② Zig 没有 Iterator trait——没有 map/filter/collect 惰性链,遍历靠 for/while 或容器自带的 iterator()

表里最后三行(fs/io、time、process)在 0.17-dev 正经历重构(std.fsstd.Io 搬、Writer 接口重做、args/env 改形)——今天的精确函数名下个版本未必还在。稳的是”显式分配 + 错误是值”这套哲学,变的是具体签名。


11. C / C++ 互操作:Zig 甩 Rust 一个街区

Rust 调 C:写 bindgen 构建脚本 → 生成一堆 extern "C" + unsafe fn → 处理 #[link]/库路径 → C 宏调不了只能硬编码常量 → size_tusize 来回转、指针满天飞用 std::ptr两套类型世界观int* vs *mut i32)来回搬。

Zig 调 C

1
2
3
4
5
6
const c = @cImport({
@cInclude("stdio.h");
@cInclude("SDL2/SDL.h");
});
c.printf("Hello from Zig!\n"); // 直接用,无 unsafe 块
c.SDL_Init(c.SDL_INIT_VIDEO);

这背后是质变:

Rust Zig
绑定生成 bindgen(独立工具 + build.rs) @cImport 内建 Clang,编译期解析头文件
C 宏 调不了 @cImport 部分翻译宏;@cDefine 还能反向传宏
unsafe FFI 全要 unsafe unsafe 关键字,直接调
指针 *mut i32int* 两套 @cImport 直接生成 [*c]i32,无缝
交叉编译 C 要装目标工具链 zig cc -target riscv64-linux 自带 libc/sysroot,零工具链

写 Zig 有 C 的自由感,但又多受一部分约束(传入分配器参数)、多了 defer 和编译期计算、少了宏的繁杂。它不是 Rust 的替代品,是 C 的进化版。 渐进式替换很爽:先把 Zig 当”增强版 C 预处理器 + 构建系统”用(zig cc 是个能跨平台的 drop-in C 编译器),再慢慢用 Zig 重写模块。唯一代价:@cImport 编译期要起 Clang 解析头文件,略慢——但比 Rust 的 bindgen + 两步编译,体验好太多。


12. 构建系统 / 包管理:zig build vs Cargo

Rust(Cargo) Zig
构建脚本 Cargo.toml(声明式 TOML)+ build.rs build.zig(用 Zig 写的命令式构建图)
包清单 Cargo.toml build.zig.zon(ZON = Zig 对象记法)
注册中心 crates.io(中心化) 无官方中心,靠 URL + hash 或本地路径
加依赖 cargo add foo zig fetch --save <url>
跨平台构建 需对应 target 工具链 -Dtarget=riscv64-freestanding-none 内建跨编译

Zig 没有 crates.io 那样的中央仓库,”收录”全靠社区索引站:在 GitHub 及其他各大代码平台上,公开仓库打上 zig-package 的 topic 标签就能被自动识别、聚合进”Zig 包”列表——门槛极低,质量也因此良莠不齐。

1
2
3
4
5
6
// build.zig:构建脚本就是普通 Zig 代码,你能用 if/for/comptime 编排
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const exe = b.addExecutable(.{ .name = "app", .root_source_file = b.path("src/main.zig"), .target = target });
b.installArtifact(exe);
}

取舍:Cargo 生态成熟(crates.io 几十万包、版本解析强)、声明式好读;zig build图灵完备的命令式构建(能跨编译、能编 C、能跑任意步骤),但没有中心化注册表、生态小。Rust 生态赢,Zig 构建灵活性 + 跨编译赢


13. 裸机 / 嵌入式 / RISC-V:两门都能 no-runtime

这是 RISC-V 全栈最关心的一层。Rust #![no_std],Zig freestanding target——都能脱离 OS/libc 跑在裸机。

能力 Rust(no_std) Zig(freestanding)
脱 OS #![no_std] + #![no_main] -target riscv64-freestanding-none
入口 #[no_mangle] extern "C" fn _start export fn _start() callconv(.naked)
裸函数 #[naked](需 nightly/外部 crate) callconv(.naked)(内建)
内联汇编 core::arch::asm! asm volatile (...)
MMIO read_volatile/write_volatile *volatile T + @ptrFromInt
panic #[panic_handler] pub fn panic(...)
链接脚本 build.rs + .cargo/config exe.setLinkerScriptPath(...)(build.zig 内建)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Zig 裸机 RISC-V 入口 + CSR + MMIO,全内建,无外部 crate
export fn _start() callconv(.naked) noreturn {
asm volatile (
\\csrr t0, mhartid
\\bnez t0, .Lwait
\\la sp, _stack_top
\\call zigStart
\\.Lwait: wfi
\\ j .Lwait
::: "t0", "sp", "ra"
);
}
const uart: *volatile u8 = @ptrFromInt(0x10000000); // MMIO 寄存器
fn putc(c: u8) void { uart.* = c; }

Rust 裸机生态强(embedded-hal/cortex-m/svd2rust 一整套 trait + PAC 自动生成),但 #[naked] 等一些底层能力长期要 nightly/外部 crate;Zig 把 naked 函数、内联汇编、链接脚本、任意位宽整数 u3packed struct(u8) 寄存器位段、comptime 编译期算页表常量全做进语言核心,写裸机 SBI/bootloader 更顺手、更少魔法。两门都远胜 C 的“全靠宏 + 链接器脚本手搓”。


14. 工具链 / 编译模型

Rust Zig
一站式 rustup + cargo + clippy + rustfmt 一个 zig 二进制build/test/fmt/cc 全包
LSP rust-analyzer(强) zls(够用,社区)
编译后端 LLVM(唯一) LLVM + 自研后端(debug 构建已可绕开 LLVM,更快)
编译速度 慢(借用检查 + 单态化 + LLVM) 快得多(尤其 debug 自研后端)
格式化 cargo fmt zig fmt(内建,无配置项——强制统一风格)

Zig “一个二进制搞定一切” + 自研后端追求快编译,是对 Rust”编译慢”的直接回应。Rust 工具链更成熟(clippy 静态检查、rust-analyzer 体验顶级)。


15. 稳定性 / 成熟度

Rust Zig
版本 1.0(2015) 0.16 stable(2026-04),未到 1.0
兼容承诺 有(edition 机制,老代码永远能编) ——官方下载页都没有破坏式更新政策
更新风格 加功能不破坏 频繁破坏式重构

例如:照 0.16 官方文档写的 Zig 笔记,到 0.17.0-dev 已经多处编不过

之前的笔记内的写法(v0.16) 0.17-dev 表现
@Type(.{.int=...}) 构造类型 删了 → 必须 @Int(.unsigned, N)
std.heap.GeneralPurposeAllocator 删了DebugAllocator
ArrayList(T).init(a) / ArrayListUnmanaged 合并 → ArrayList 默认 unmanaged,.empty
async fn / await 关键字早已移除
@typeInfo 字段 .Int(部分旧章节) 全小写 .int

这就是 Zig 当下最大的”坑”:官方文档教你的写法,下个 dev 版本可能就变了。Rust 老用户对此要有心理准备——你买的是”自由 + 简单 + 快”,付的是”稳定性”。等 Zig 1.0,这条会大幅改善。


16. 适用场景 + 总评

你要做的 更推荐 为什么
Web 服务 / 分布式 / 后端 Rust 生态成熟(tokio/axum)、并发安全编译期保证、大团队协作稳
安全关键 / 大型长期项目 Rust 借用检查器 + 1.0 稳定承诺,重构有底气
内核 / 驱动 / Bootloader / SBI Zig naked/内联汇编/位段/comptime 全内建,少魔法、编译快
游戏引擎 / 高性能 / 手动内存 Zig 显式 allocator、ReleaseFast 裸奔、无借用检查掣肘
接手/混编/渐进替换 C 项目 Zig @cImport + zig cc 无缝吃 C,甩 Rust 一条街
多版本结构体/编译期定制(如协议/固件) Zig comptime 工厂选类型,比 Rust 宏+泛型体验好

一句话总评
Rust 是”给你安全,代价是必须按我的规则写”;Zig 是”给你工具,你爱怎么造怎么造,炸了算你的”。
写 Zig 有 C 的自由感,但又多受一部分约束(传入分配器参数)、多了 defer 和编译期计算、少了宏的繁杂——它不是 Rust 的替代品,是 C 的进化版。要稳、要大、要安全 → Rust;要爽、要底层、要可控、要混 C → Zig。而在异步(colorless 哲学)、泛型、多版本结构体构建上,Zig 的自由度和体验,我个人觉得比 Rust 更顺手。


17. 全维度速查总表

维度 Rust Zig
哲学 安全优先·零成本抽象 简单·显式·无隐藏
内存 所有权 + 借用检查器 显式 allocator + defer/errdefer
安全 编译期强制 运行期可选检查(ReleaseFast 裸奔)
错误 Result<T,E> 带 payload !T 仅标签,零成本
元编程 宏 + 泛型 + const fn(四套) comptime 一套通吃
多态 trait + 生命周期 comptime 工厂 + anytype 鸭子
异步 async/await + 染色 砍 async 关键字 + Io 参数(colorless-ish)
整数 固定位宽 任意位宽 u3
C 互操作 bindgen + unsafe @cImport + zig cc 无缝
构建 Cargo + crates.io build.zig + zon,内建跨编译
工具链 rustup 全家桶(成熟) 一个 zig(快)
编译速度
稳定性 1.0 稳定 0.x 破坏式更新
生态 小但增长
强项 Web/分布式/安全关键 内核/驱动/游戏/嵌入/混 C

18. 思考题(附参考答案)

Q1. Zig 砍掉 async 关键字”解决”了函数染色——但为什么说”只是推迟到调用方”?Io 参数模型和 async 关键字模型在”传染性”上到底是不是一回事?

半是半不是。 async 关键字 = 类型传染:蓝函数类型与红不兼容,编译期钉死,无法绕过。Io 参数 = 参数传染:要异步能力的函数得收 io 参数,调用链一路往下传。相同点:都得改一整条链。不同点:io普通参数、函数类型不变——不需要异步的中间函数可以不收 io、可默认单线程后端、可运行时换后端;颜色从”编译期钉在类型上”变成”运行时由传入的 io 决定”。所以传染性弱化了(可选/可换/不改类型)但没消失(io 仍要传)。Leo 的”推迟到调用方”精准:染色决策从函数定义处挪到了注入处

Q2. 为什么 E!i32 体积是 8(tag+T),而 Rust 的 Result<i32, E> 可能更大?根因?

Zig 的 error 是全局扁平整数标识符anyerror=u16=2 字节),不带数据,故 E!i32 = 4(i32) + 2(tag) + 对齐 = 8。Rust Result<T,E> 是 enum,Err 携带任意 E,布局 ≈ max(sizeof T, sizeof E) + 判别(可能被 niche 优化省掉),E 大则 Result 大。根因:Zig 把”错误带上下文”踢出 error union(要上下文自己设计 struct)换零成本;Rust 让错误本身就是完整数据换表达力。

Q3. comptime 凭什么”一个机制”替掉 Rust 的宏+泛型+const fn?它和 Rust 过程宏的本质区别?

因为 Zig 没有”宏”这个独立阶段——编译器解析 AST 时遇到 comptime 就用内置解释器直接跑那段普通 Zig 代码,结果(值或类型)替换原节点。于是”编译期算值(const fn)””按类型生成代码(泛型/宏)””反射类型”全是同一件事:编译期执行 Zig。和 Rust 过程宏的本质区别:过程宏是隔离的、操作 TokenStream(语法层面拼 token,看不到类型语义、要自己 parse)、且是独立 crate 编译;comptime 直接在类型系统内、能 @typeInfo 看到完整类型语义、和普通代码同语言同语法、零隔离。

Q4.(开放)Rust 老用户转 Zig,最该警惕的”舒适区陷阱”是什么?

没有 borrow checker 兜底——“编译过了”不等于安全,UAF/泄漏要靠 defer/errdefer 自律 + DebugAllocator 运行时抓,ReleaseFast 还会关检查。② 0.x 破坏式更新——今天的代码下个版本可能编不过(我半年前的笔记到这个版本已失效)。③ error 无 payload——别指望像 Rust 那样把上下文塞进 E。④ 没有 trait/RAII 自动析构——接口靠鸭子检查、释放靠手动 defer


小结:Rust 和 Zig 都是”更好的 C”,但一个用编译期强制买安全(borrow checker + 1.0 稳定),一个用显式 + 简单买可控(comptime 统一元编程、@cImport 无缝吃 C、defer 显式清理、ReleaseFast 裸奔)。对 RISC-V 全栈这种”既要底层可控、又要混 C、还要编译期定制”的场景,Zig 的自由度很迷人;对要长期维护、要安全保证、要成熟生态的场景,Rust 更稳。而 Zig 当下最大的代价是不稳定(0.x 破坏式更新)——等 1.0,天平会再调一次。

StarryOS SD/MMC 驱动 3 个月开发技术总结

摘要

本报告记录了我在泉城实验室为期3个月的实习期间,围绕 StarryOS SD 卡驱动展开的主要开发与优化工作。实习期间,我深入分析了原有的 simple-sdmmc 驱动,针对其在功能、性能和兼容性方面的不足,进行了系统性的优化和扩展,最终形成了功能更完善、兼容性更强的 simple-sdmmc-extended 版本。该版本补全和优化了硬件初始化流程与时钟与电源管理,还引入了 DMA(直接内存访问)支持,显著提升了大容量数据传输的效率和系统资源利用率。在驱动开发的同时,我还设计并实现了一个自动化测试框架,用于系统性地验证 SD/MMC 驱动在不同平台和多种极端场景下的功能正确性、性能表现以及平台兼容性。测试框架涵盖了基本功能、性能基准、并发一致性、平台专用等多类测试,能够自动统计吞吐量、IOPS、错误数等关键指标,并输出详细日志,极大提升了驱动开发和回归验证的效率。


1. 项目背景

在 StarryOS 项目早期,为实现对 SD/MMC 存储设备的基本支持,设计并实现了 simple-sdmmc 驱动。该驱动采用了轮询(Polling)方式进行控制器状态检测和数据传输:

  • 驱动通过不断轮询硬件寄存器,判断命令是否完成、FIFO 是否可读写、数据传输是否结束等,无需依赖中断。
  • 轮询方式实现简单,便于在无操作系统或早期启动阶段使用,适合嵌入式、裸机等资源受限场景。
  • 该设计保证了驱动的确定性和可控性,但在高性能或多任务环境下,CPU 占用较高,后续版本逐步引入了 DMA、并发等优化。

2. simple-sdmmc 的优化

2.2 关键优化点

2.2.1 硬件初始化流程完善

具体的工作包括了:

  • 时钟管理
    • 明确分步关闭/配置/开启时钟,确保卡片处于稳定状态
    • 多次发送 ResetClock 命令,保证控制器与卡片同步
  • 电源管理
    • 显式设置 PWREN,确保卡片上电
  • 延时与稳定性
    • 延长初始化等待周期,确保卡片在低频下充分稳定

硬件初始化的优化如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
优化前:
[关闭时钟]
→ [发送 ResetClock 命令]
→ [设置低速分频]
→ [开启时钟]
→ [再次发送 ResetClock]
→ [设置数据宽度为1bit]
→ [重置DMA]
→ [发送 GoIdleState 命令]
→ [发送 SendIfCond,检查卡片电压/版本]
→ [循环发送 AppCmd + SdSendOpCond,等待卡片上电完成]
→ [发送 AllSendCid,读取卡片CID]
→ [发送 SetRca,分配/获取RCA地址]
→ [发送 SendCsd,读取卡片CSD,解析容量/特性]
→ [选择卡片 SelectCard]
→ [设置块长度/计数]
→ [卡片初始化完成,可进行数据读写]

优化后:
[清理U-Boot遗留中断标志]
→ [关闭时钟]
→ [多次发送 ResetClock,确保同步]
→ [设置/开启时钟]
→ [显式上电 PWREN]
→ [延长等待,保证低频下稳定]
→ [设置数据宽度/分频/其他参数]
→ [发送 GoIdleState 命令]
→ [发送 SendIfCond,检查卡片电压/版本]
→ [循环发送 AppCmd + SdSendOpCond,等待卡片上电完成]
→ [发送 AllSendCid,读取卡片CID]
→ [发送 SetRca,分配/获取RCA地址]
→ [发送 SendCsd,读取卡片CSD,解析容量/特性]
→ [选择卡片 SelectCard]
→ [设置总线宽度/高速模式(如支持)]
→ [设置块长度/计数]
→ [(可选)切换到高速/4bit模式]
→ [卡片初始化完成,可进行数据读写]
2.2.2 DMA 支持与寄存器抽象

具体的工作包括了:

  • 新增描述符链数据结构 IdmacDescriptor 为 CPU 与 IDMAC 间的数据交换作支持。
  • 新增 dma_enabled 字段,初始化 IDMAC 控制器。
  • 优化寄存器访问抽象,提升代码可维护性与可移植性。
2.2.3 命令与数据传输流程增强
  • 将原有 send_cmd 仅用于控制类/非数据命令,数据传输相关命令全部优化为 send_cmd_idmac,实现命令与数据路径分离。
  • send_cmd_idmac 针对 DMA/IDMAC 场景,自动完成物理地址转换、描述符配置、DMA 启动、状态与错误检查等,极大提升了大块数据传输的效率与健壮性。
  • 两者均增加了详细的超时与状态检查、错误日志,便于定位问题。
  • 兼容部分命令(如 GoIdleState)超时非致命的实际情况。

命令与数据传输流程优化前后对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
优化前:
[设置块大小/计数]
→ [配置命令参数]
→ [发送读命令]
→ [等待命令完成]
→ [轮询FIFO,逐字节读取数据]
→ [检查/清理中断状态]
→ [返回数据]

优化后:
[设置块大小/计数]
→ [配置命令参数]
→ [选择数据传输模式(CPU轮询或IDMAC DMA)]
→ [发送读命令]
→ [等待命令/数据完成,超时与错误检查]
→ [CPU模式:轮询FIFO读取]
→ [DMA模式:配置描述符,启动IDMAC,等待完成]
→ [检查/清理中断与DMA状态]
→ [返回数据]

2.3 典型问题与解决方案

2.3.1 U-Boot 遗留状态导致初始化失败
  • 现象:U-Boot 预设时钟/宽度,驱动未重置,导致卡片未能正常初始化
  • 解决:驱动启动时强制重配时钟、电源、数据宽度,确保硬件状态一致
2.3.2 GoIdleState 超时
  • 现象:部分卡片在 CMD0 (GoIdleState) 阶段超时,但后续流程可继续
  • 解决:将 CMD0 超时视为可接受,后续命令继续执行
2.3.3 DMA/CPU 模式性能对比
  • 通过测试框架对比 DMA 与 CPU 直传模式,量化性能提升

2.4 优化效果

  • 驱动在 VisionFive2 等平台下可稳定初始化并识别大容量 SD 卡
  • 性能测试显示 DMA 模式下吞吐量显著提升
  • 代码结构更清晰,便于后续维护与扩展

3. 测试框架设计与实现

3.1 测试框架描述

整个测试框架存储在 sdmmc-tests 项目中,通过分模块自动化验证 SD/MMC 驱动的各项能力,涵盖基本读写、边界与异常、性能基准、并发一致性、以及平台专用(如 VisionFive2)等多类测试。每类测试包含单块/多块读写、边界块访问、错误注入、顺序与随机性能、DMA/CPU对比、缓存一致性、多线程压力、时钟切换、中断延迟和大容量寻址等典型场景。测试框架会自动统计吞吐量、IOPS、错误数等指标,并输出详细日志,便于定位驱动在不同平台和极端条件下的功能与性能问题。所有测试均可一键批量运行,极大提升了驱动开发和回归验证的效率与可靠性。

3.2 测试分类与功能说明

当前 sdmmc-tests 测试框架将测试分为以下几大类,每类覆盖不同的驱动能力:

  • 基本功能与正确性测试(basic/)

    • 单块/多块读写:验证驱动对单个和多个数据块的读写正确性。
    • 边界条件:测试首块、末块、分区边界等特殊地址的访问,确保无越界和数据一致性。
    • 异常与错误处理:如非法块号、错误缓冲区长度、超时等,验证驱动能否正确检测和报告错误。
  • 性能基准测试(benchmark/)

    • 顺序吞吐量:测量大块连续读写的最大带宽。
    • 随机访问性能:评估随机读写的 IOPS 和延迟。
    • DMA/CPU 模式对比:对比 DMA 直传与 CPU 轮询两种模式下的性能差异,量化 DMA 优势。
  • 并发与一致性测试(concurrency/)

    • 缓存一致性:验证 DMA 传输下 CPU 缓存与物理内存的数据一致性,确保写后读、读后写无异常。
    • 并发压力:模拟多线程/多任务下的交错读写,检验驱动在高并发场景下的稳定性和正确性。
  • 平台专用测试(visionfive2/)

    • SDIO 时钟切换:动态切换多种时钟频率,验证驱动对不同速率的适配能力。
    • 中断延迟:测量中断响应时间,评估驱动实时性。
    • LBA48 支持:测试大容量卡 (>2TB) 的 48 位寻址能力。

每类测试均配有详细日志和统计信息,便于定位问题和量化性能。

3.3 测试框架设计要点

  • 统一的 TestResult/TestError 类型,便于错误归类与统计
  • TestStats 支持吞吐量、IOPS 等性能指标自动统计
  • 详细日志输出,便于定位问题
  • 支持特性开关(如 vf2)灵活适配不同硬件

3.4 运行方式

  • 可集成于 StarryOS 镜像,在真实硬件或 QEMU 上运行
  • 支持独立 cargo 运行,便于 API 层测试

相关仓库

  1. 优化后的 StarryOS 代码与日志仓库
  2. 优化后的 simple-sdmmc 代码仓库
  3. 测试集框架代码仓库

操作系统训练营复盘

序言

我参加过不止一次操作系统训练营,但只有这次真正进入了专业阶段。回头看,这段学习给我最大的提升不是“又多懂了一些概念”,而是理解方式变了:操作系统不再是各种模块的拼装,而更像是对 CPU、内存、设备等物理资源的重新组织与分配——向上管理应用,向下管理资源。

这次训练营的跨度也很大:从 OS 理论与 Rust 基础,到搭建纯粹的 no_std 环境;再到特权级切换、调度、页表;最后在专业阶段落到并发控制、VFS、系统调用等更复杂的系统能力上。


导学阶段

导学阶段我主要跟着课程录像把操作系统体系重新过了一遍,并结合训练营官方指导与 OSTEP 做补充。这个阶段我最重要的收获是:很多章节并不是在讲“技巧”,而是在解释 OS 如何把有限的物理资源做成一种“多路复用的错觉”,让每个进程都觉得自己独占资源。理解到这一层之后,再看内核架构为什么这么演进,会更顺。

第一次在 no_std/裸机环境里做实验时,一些在标准库里理所当然的操作突然变得“不显然”。我印象很深的是终端彩色输出:我原本习惯用 {:?} 快速打印结构体/变量状态,但当我尝试直接写字节序列输出 ANSI 颜色控制码时,屏幕却是一堆乱码。

后来定位到原因:Debug 格式化会把 ANSI 转义字符 \x1b 强制转义成字面量文本(真实字符 \x1b),控制码语义被破坏,终端自然无法解析颜色指令。解决方式很朴素:不要用 {:?},改用 {}(Display),保证原始 0x1B 字节无损进入串口发送寄存器。


基础阶段

基础阶段的任务密度明显上来了:总共需要实现 6 个模块、23 个测试,涉及 no_std 基础设施、并发、特权级切换与调度、页表等多个节点。我这里挑几个对我影响最大的点写一下。

1)no_std 基础设施:内存原语、ecall、堆分配

在没有标准库的情况下,一些基础能力需要自己补齐。我用汇编与内联 Rust 代码手动实现了 memcpymemset,并封装了 ecall 系统调用接口。

动态内存分配方面,我实现了基于 CAS 无锁算法与侵入式链表的 Free-List 分配器,为内核态提供稳定的堆内存支持。

2)特权级切换与调度:上下文切换变得“可触摸”

在特权级切换与进程调度模块里,我更深刻地理解了上下文切换的本质:我利用 RISC‑V 的裸函数属性,手动保存与恢复 32 个通用寄存器以及核心 CSR。在此基础上,我不仅实现了传统线程上下文切换,还进一步实现了运行在用户态的“绿色线程”,用协作式调度与状态机探索轻量级协程的性能边界。

3)和同期同学对照后的一个结论:底层问题放大效应很强

我会把自己的 Debug 记录和同期同学的PR对照着看,感受很明显:应用层的“坏习惯”到了 OS 底层会被成倍放大。比如只顾设计模式而忽略数据流正确性、缺少错误路径测试、试图用大量 unsafe 绕过 Rust 检查器等——在 OS 里往往会直接演变成系统级灾难。

我印象比较深的几类问题与我的处理方式大致是这样:

  • 内存分配与回收 :早期简单 Bump 分配器缺乏回收机制,遇到 Vec 扩容或动态字符串处理很容易 OOM。针对早期内核初始化,我做过一种折中:设计“双端内存分配”,前端分配小块字节、后端分配整页,并引入一个基于引用计数的全局追踪器;当活动对象计数归零时,直接把字节分配指针重置回基址(b_pos = start)。
  • 并发与生命周期 :我踩过一个很隐蔽的“临时值生命周期”坑:链式调用 current().task_ext().aspace.lock() 会让 current() 生成的临时任务引用在语句结束时被 Drop,但 lock() 得到的 Guard 仍试图借用它,导致编译错误。最终我回到所有权本质:用显式变量绑定 let curr = current(); 延长引用存活期,理清锁与借用对象的层次关系。
  • 中断与特权级相关陷阱 :在异常处理里,若不推进 sepc,可能会反复回到同一条触发异常的指令。我的处理里会在必要时手动 sepc += 4 跳过当前 32 位指令。在 simple_hv 虚拟机管理程序中,我还遇到过一个规范相关的细节:从 VS 态访问某些 M 级 CSR(如 mhartid)触发的并非预期的 VirtualInstruction(异常码 22),而是 IllegalInstruction(异常码 2)。最后我重写了非法指令处理器:软件解码指令、模拟返回伪造的 CSR 值,并精细管理 sepc 的演进流。
  • no_std 生态缺失 :为了支持更复杂的数据结构,我把 hashbrown 引入到自己的 axstd 模块中,重建哈希集合支持。过程中还触发过 nightly-2024-09-03 的编译器 ICE:常量泛型参数 SIZE 与 Trait 关联常量 PAGE_SIZE 底层符号解析冲突。我通过重命名泛型变量(如改为 PAGESZ)从侧面规避了前端缺陷。

SV39 分页 与 TLB“幽灵状态”

如果要选一个最能体现“软硬件协同”且最折磨人的点,我会选虚拟内存分页系统及其与 TLB 的交互。

RISC‑V 的 SV39 使用三级 Radix Tree:虚拟地址拆成 VPN2/VPN1/VPN0+ 12 位页内偏移,MMU 会遍历三级页表完成映射。静态结构很好理解,但真正难的是运行时的硬件状态同步。

我在处理动态内存映射时,逻辑上已经“看起来完美”:分配物理页帧、更新 PTE、设置 V/R/W/X 权限位,然后 sret 返回继续执行。按常理应该成功,但 CPU 会再次抛出同样的缺页异常。

后来我意识到这是典型的 TLB staleness :页表在内存里更新了,但 CPU 内部的 TLB 仍缓存着旧的“该地址无效”记录。解决必须落到指令层:修改页表后,显式向处理器发出同步屏障。在我的逻辑里,只要重写了 hgatp 或更新了关键页表条目,就需要紧接着执行 hfence.gvma,强制刷新相关地址空间的 TLB 项。


专业阶段

专业阶段的变化很明显:复杂度与状态空间开始爆炸。文件系统架构、复杂并发控制、IPC 等问题,逼着我从“写功能”转向“做工程治理与架构取舍”。

在 rCore-Tutorial 的 Chapter 6,我围绕 easy-fs 的文件系统实现做了较多工作,并对其分层有了更清晰的认识:

  1. 块设备抽象层(easy-fs/efs.rs :磁盘布局、inode 位置计算、位图分配与回收等底层逻辑
  2. VFS 层(easy-fs/vfs.rs :目录树遍历、链接、inode 生命周期管理等核心抽象
  3. 系统调用边界(os/src/fs/ :将内核对象包装成用户态可用的文件抽象(如文件描述符)

我主要补全了 sys_fstatsys_linkatsys_unlinkat。以 linkat 为例,其控制流非常“手术式”:路径遍历 → 校验目标实体/链接名→ 锁定源 inode,对 nlink 原子 +1 并写回磁盘 → 在目标目录追加新的 DirEntry 并强刷缓存。

unlinkat 的反向流程同样复杂,尤其是“删除目录项不产生空间碎片”的问题。我采用的策略是:找到待删除目录项后,将其与目录中最后一个有效目录项交换,然后缩减目录文件 size,保证磁盘空间紧凑;随后递减目标 inode 引用计数,只有当 nlink 降至 0 且系统内无文件描述符占用时,才触发底层数据簇回收。

另外我也处理了一些边界:例如 Stdin/Stdout 属于流式字符设备,缺乏普通磁盘文件的块状元数据;为了避免生成“虚假的 Stat”,我的实现会直接拦截这类请求并 panic!,拒绝伪造元数据。


反思

经历导学、基础、专业三个阶段之后,我最明显的变化是:对“底层原理”的认知从抽象概念变成了可推导的具体原理,我会去思考寄存器怎么变、异常为什么回到同一条指令、TLB 为什么缓存旧结论、并发原语为何在不同调度模型下表现截然不同。

我对 AI 辅助编码的用法

随着AI的迅猛发展,在本次训练营中我也结合AI辅助OS编码,但是我也发现了OS 底层容错率太低,使得我对 AI 的用法也变得更审慎:它可以当手术刀处理局部实现与重复劳动,但不该在宏观架构上自由发挥。我的做法是把它严格约束在可控范围内:

  1. 全局上下文对齐 :先规定技术边界与可改文件/不可碰结构(唯一事实来源)
  2. 原子化拆解 :把大需求拆成 3–5 个闭环步骤,每一步都是 MVP,爆炸半径小
  3. 确定性验证标准 :每一步必须给出明确可验证的测试/日志/行为变化,通过后再做下一步

这套策略在专业阶段帮我避免了“AI 幻觉导致底层数据结构被悄悄破坏”的风险,也提高了推进效率,这也是我在本次训练营中领悟出来的使用技巧。从代码的写作转到审查代码,把控架构,也许我感觉这是能否在Vibe Coding的冲击中站稳脚跟的关键,以操作系统的设计为例,后者更考验对整个复杂系统的掌握,这就驱使着我们要加速脚步增加对知识的汲取。AI Coding 的效率不取决于模型的能力,而是取决于所使用的架构是否支持多工作树低冲突合并;AI 的能力边界也不取决于模型的能力,而是取决于工程过程可被 token 化的范围边界在哪里。

RKNPU驱动3个月开发技术总结

摘要

本报告记录我在泉城实验室三个月实习期间针对Rockchip RK3588芯片,为其中的NPU构建一套可运行于StarryOS和其它OS上的驱动,覆盖从寄存器映射、DMA缓冲管理、任务描述符组装到多核调度与中断回收的完整链路。核心工作包括三项:三核并行执行路径的设计与实现,使同一批任务可按 lane 分发到不同 NPU core;统一排队调度器,支持多线程共享 NPU 并通过 per-submit waiter 保持阻塞式 ioctl 语义;以及基于 SVD/svd2rust 生成的 rknpu-regs 寄存器访问库,以类型化接口替代裸地址编程。在此基础上,通过四个 benchmark 场景验证了三核算力收益,所有场景均实现正加速

一、项目内容与用途

这个项目的对象是 RK3588 NPU 驱动及其在 StarryOS 上的系统集成。它的作用:让上层推理程序、benchmark 或后续 runtime 能稳定地把任务提交给 NPU 执行,而不是只能依赖 Linux 或闭源用户态库环境。

当前驱动已经覆盖了几条基础链路:

  1. 寄存器/MMIO 访问:能够映射 RK3588 RKNPU 三个 core 的寄存器窗口,并通过寄存器接口完成 PC、CNA、DPU 等硬件模块的配置。
  2. GEM/DMA 缓冲管理:支持用户态创建、映射、同步和销毁 NPU 可访问的 DMA buffer。
  3. 任务描述符组织:支持 RknpuTask、regcmd、输入/权重/输出 buffer 的组合,并把这些描述符作为 submit 的基本执行单元。
  4. ioctl 服务:支持 SubmitMemCreateMemMapMemDestroyMemSyncAction 等 RKNPU 专用入口。
  5. 中断与 completion 回收:IRQ handler 负责读取硬件完成状态,调度器再根据 core 绑定关系把 completion 还原到具体任务。
  6. 多核调度:在一个 submit 中按 lane 把任务切到不同 core,同时支持多个 submit 进入队列等待。

二、本轮二次开发重点

2.1 支持 RK3588 三核 NPU 执行

RK3588 的 RKNPU 不是单个执行核心,而是三个可以并行工作的 NPU core。早期单核路径只证明了“任务能跑”,但没有把硬件并行能力发挥出来。本轮二次开发首先把提交路径改成能够识别 core_masksubcore_task,让同一批任务可以被拆成多个 lane 分发到不同 core。

当前的三核执行模型可以概括为:

  1. 用户态仍然通过一个 RknpuSubmit 提交任务批次。
  2. subcore_task[] 描述每个 lane 的任务范围;如果用户态没有显式填写 lane,队列层会把任务归一化到默认 lane。
  3. core_mask 决定这次 submit 允许使用哪些物理 core,例如 0x1 表示只用 core0,0x7 表示三个 core 都可用。
  4. 调度器为每个空闲 core 挑选一个可派发 lane,然后调用底层 driver 对该 core 编程。
  5. core 完成后,IRQ 路径发布 raw completion,调度器再回收并推进对应 lane 的 cursor。

这部分工作的重点不是简单把同一条命令发三遍,而是要保证每个 core 跑的是正确的 task slice,同一条 lane 不会被重复派发,completion 也能回到正确的 submit 和 task index。只有这些状态对齐,多核数据才有意义。

2.2 任务调度器与多线程共享 NPU

第二个是任务调度器。NPU 是共享硬件资源,不能让多个线程各自直接碰寄存器,否则很容易出现 core 状态、任务进度和 completion 归属混乱。当前实现保留外部的 blocking submit 语义,但内部引入了调度队列和 worker 线程:

  1. 调用线程进入 Submit ioctl 后,不直接独占 NPU 跑完整批任务,而是把 submit 放进 scheduler。
  2. 每个 submit 有自己的 waiter。调用线程只等待“自己的 submit 是否完成”。
  3. 全局 worker 负责真正的 dispatch 和 harvest。它被 kick 唤醒后,先回收已完成 core,再给空闲 core 下发新任务。
  4. scheduler 维护 ready、running、complete 这些状态。ready 表示还没开始跑的 submit,running 表示已有 lane 在跑或还有 lane 可继续派发,complete 表示终态结果等待 ioctl 路径取回。
  5. 如果 running submit 还有可派发 lane,会优先继续推进 running,而不是马上切到一个全新的 ready submit。这样可以减少同一 submit 内部的等待空洞。

service 层的 blocking submit 通过 per-submit waiter 阻塞:调用线程在 wait_for_submit() 上等待,直到 worker 将该 submit 移入 complete 并调用 waiter.complete() 唤醒它,再通过 take_terminal_submit() 取回终态结果。多个用户线程同时提交 NPU 任务时,线程可以各自阻塞在自己的 waiter 上,NPU 由全局 worker 串行管理硬件状态并并行利用多个 core。换句话说,NPU 对外看起来仍是阻塞式设备,对内已经具备”多线程共享、队列化提交、三核流式执行”的基础形态。

这版调度器的价值就在这里:它没有把用户态 ABI 改复杂,但把内核侧的执行模型从”谁提交谁跑”推进到了”统一排队、统一分发、统一回收”。

2.3 引入 rknpu-regs 寄存器库

寄存器访问层也做了工程化整理,引入了基于 SVD / svd2rust 生成的 drivers/rknpu-regs。驱动开发里手写寄存器偏移和位域非常容易出错,尤其是 NPU 这种寄存器块多、字段分散、不同 core 地址重复的硬件。

rknpu-regs

  1. 用类型化寄存器接口替代裸地址和 magic number。
  2. 减少手工抄写偏移、位宽、mask 时的错误。
  3. 让寄存器访问代码更接近硬件文档,后续查错更方便。
  4. 把”访问寄存器”和”调度策略”拆开,避免调度器里混入大量底层地址细节。

三、benchmark 测试内容

3.1 测量范围与方法

本次性能数据来自 log.txt 中运行的 ./core_scaling_benchmark,对应测试程序是 core_scaling_benchmark.c

测量窗口:仅覆盖 blocking DRM_IOCTL_RKNPU_SUBMIT 的完整往返时间,即 submit ioctl 从进入到返回。operand packing 和 regcmd generation 单独打印,不计入对比窗口。因此下面的数据反映的是 driver submit、scheduler dispatch/harvest、硬件执行和 completion 回收的综合耗时

指标定义

  • speedup = T_1core / T_3core(同一批任务 1-core 与 3-core 的平均 submit 时间之比)
  • parallel efficiency = speedup / 3(理想三核加速为 3x,efficiency 衡量实际利用率)
  • GFLOP/s = 2 × GMAC/s(1 MAC = 1 乘 + 1 加 = 2 FLOP)
  • jitter span = (T_max - T_min) / T_avg(衡量单次 submit 时间的波动幅度)

正确性校验:第一轮 measured round 会对输出抽样与 CPU reference 比较,并检查每个 task 的 int_status == 0x300。这是抽样校验,不是全量验证。

3.2 测试环境

  • 硬件平台:RK3588 SoC(三核 NPU)
  • 操作系统:StarryOS比赛版本
  • Benchmark 程序core_scaling_benchmark(4 场景 × 2 operand 模式)
  • 测量轮次:每场景 warmup 2 轮 + measured 5-12 轮(场景相关)
  • 频率/电源控制:未控制(默认 governor)
  • 热稳态控制:未控制
  • IRQ 亲和性:未设置

3.3 测试场景

每个场景一般又分两种 operand 模式:

  1. shared-operands:所有任务复用同一份 input/weight,只有 output slice 私有。这更偏向测试调度和计算本身,DMA footprint 较小。
  2. unique-operands:每个 task 有自己的 input/weight/output slice。这更接近多任务独立数据的情况,内存占用和准备成本更高。

测试程序里固定了四个场景。它们不是随便取的矩阵形状,而是分别压不同瓶颈:

场景 矩阵形状 shared tasks unique tasks warmup measured 主要验证点
tiny_dispatch M=4 K=32 N=16 96 96 2 12 小矩阵,submit/scheduler 固定开销占比最高,用来观察多核调度在短任务下是否会被开销吞掉。
mid_balanced M=64 K=512 N=512 48 12 2 8 中等矩阵,调度开销和计算吞吐都会影响结果,用来判断调度器是否进入稳定可用区间。
throughput_heavy M=128 K=1024 N=1024 24 4 2 5 大矩阵,目标是把瓶颈推向 NPU 计算吞吐;unique 任务数较少,是为了控制 DMA footprint。
llama_decode_like M=1 K=4096 N=4096 48 0 2 8 低 M、高 K、高 N,接近 LLM decode 阶段线性层投影形状,更关注长任务延迟和尾部波动。

tiny_dispatch 主要看调度器的“底噪”。如果这个场景三核退化,说明每次 dispatch、IRQ 回收、worker yield、waiter 唤醒的成本已经压过了硬件并行收益。它使用 96 个 task,任务数足够多,可以持续给三个 core 喂任务,但单 task 计算量非常小。

mid_balanced 是更接近日常 benchmark 的中间档。shared 模式有 48 个 task,unique 模式只有 12 个 task,因为 unique 模式每个 task 都有独立 input/weight/output,内存占用增长更快。这个场景用来观察调度器在“既有计算量、又有一定任务数量”的情况下是否稳定。

throughput_heavy 则故意把矩阵放大。shared 模式有 24 个 task,unique 模式只有 4 个 task。这里不是为了追求任务数,而是为了让每个 task 本身足够重,看三核并行能否把 GFLOP/s 拉上去。unique 任务数少也会暴露另一个问题:当任务批次太短时,三核并行窗口会变窄。

llama_decode_like 只跑 shared 模式,unique tasks 在测试程序里设为 0,所以日志里会跳过 unique。它模拟的是 decode 阶段常见的投影类 workload:M 很低,但 K 和 N 很大。这个场景不一定追求最高吞吐,更关心一次 submit 的阻塞时间能不能被多核明显压下来。

输入和权重不是随机数,而是由 deterministic_input_value()deterministic_weight_value() 生成的整数模式;输出会抽样和 CPU reference 比较。每个 task 的 int_status 也会检查,期望值是 0x300。任务分发时,distribute_tasks_to_cores() 会按 core 数把 task range 平均切到 subcore_task[],1 core 时只填 core0,3 cores 时填 core0/core1/core2 并设置对应 core_mask。因此这个 benchmark 测到的不是单纯的 C 程序循环,而是完整覆盖了 submit ABI、调度器 lane 切分、三核 dispatch、IRQ completion 和 copy-back 这些路径。

四、benchmark 结果与性能分析

4.1 总体结论

所有有效场景里,三核都比单核快,没有出现最终退化。最好的场景是 llama_decode_like/shared-operands,从 344.354 ms 降到 137.000 ms,speedup 达到 2.514x,parallel efficiency 为 83.78%。这说明调度器已经能把 RK3588 三个 NPU core 的并行能力用起来。

三核都有正收益,但大多数场景的 parallel efficiency 仍在 57%68% 左右

4.2 结果汇总

场景 模式 任务数 1-core avg submit 3-core avg submit speedup parallel efficiency
tiny_dispatch shared 96 2.533 ms 1.423 ms 1.780x 59.32%
tiny_dispatch unique 96 3.478 ms 1.739 ms 2.000x 66.67%
mid_balanced shared 48 32.496 ms 18.746 ms 1.733x 57.78%
mid_balanced unique 12 10.459 ms 6.065 ms 1.724x 57.48%
throughput_heavy shared 24 42.656 ms 20.864 ms 2.044x 68.15%
throughput_heavy unique 4 6.781 ms 3.939 ms 1.721x 57.38%
llama_decode_like shared 48 344.354 ms 137.000 ms 2.514x 83.78%

4.3 tiny_dispatch:小任务也有明显收益

tiny_dispatch 的矩阵规模很小,单 task 的计算量低。当前结果里,shared 模式从 2.533 ms 降到 1.423 ms,unique 模式从 3.478 ms 降到 1.739 ms。这说明当前调度器的固定开销没有大到完全吞掉三核收益,小任务也能加速。

但是这个场景的性能不能过度解读。shared 模式 parallel efficiency 是 59.32%,unique 模式是 66.67%,距离理想三核加速2x以上仍有明显差距。小任务下,每次 dispatch 和 completion 的成本占比很高,真正花在矩阵计算上的时间太短。后续如果要优化小任务,需要减少每 task 的下发/回收成本,或者把更多小 task 合并成更粗粒度的 batch。

4.4 mid_balanced:中等任务证明调度器进入显著加速区间

mid_balanced 是这次比较有代表性的场景。shared 模式中,1 core 平均 submit 为 32.496 ms,3 core 为 18.746 ms,speedup 为 1.733x。unique 模式中,1 core 为 10.459 ms,3 core 为 6.065 ms,speedup 为 1.724x

这个结果说明,调度器在中等任务规模下已经进入稳定可用区间。两种 operand 模式都接近 1.7x,没有只在某一种特殊数据复用方式下才有效。它也说明当前瓶颈不只是数据准备,因为 benchmark 的 measured window 没把 operand packing 算进去;submit 阶段本身确实因为多核并行缩短了。

4.5 throughput_heavy:吞吐型任务收益更明显

throughput_heavy 的 shared 模式从 42.656 ms 降到 20.864 ms,speedup 达到 2.044x,parallel efficiency 为 68.15%。同时 GFLOP/s 从 151.033 提升到 308.786,基本也是 2.044x 的增益。这个场景最能说明三核调度的直接价值:任务足够重之后,固定调度开销被摊薄,硬件并行执行开始成为主导因素。

unique 模式任务数只有 4 个,这是为了控制 DMA footprint。它仍然从 6.781 ms 降到 3.939 ms,speedup 为 1.721x。任务数少会限制三核利用率,因为 worker 能同时派发的 lane 数量和后续补发机会都变少。这个结果没有 shared 模式好,但仍然是稳定正收益。

4.6 llama_decode_like:长任务场景收益最好,但 jitter 较大

llama_decode_like 是本次最强的结果。它的 shared 模式从 344.354 ms 降到 137.000 ms,speedup 达到 2.514x,parallel efficiency 为 83.78%,GFLOP/s 从 4.677 提升到 11.756。这说明在低 M、高 K、高 N 的投影类 workload 下,当前三核调度能显著降低 submit 阻塞时间。

但这个场景也反应出在3-core 的 min/max 为 126.027 / 139.504 ms时,jitter span 达到 9.84%,明显高于 1-core 的 0.03%。也就是说,平均值很好,但三核路径下仍有一定波动。可能原因包括 worker harvest 时机、core completion 到达顺序、yield 后重新调度的时间差,以及更长任务下不同 core 之间的尾部等待。

4.7 这轮结果说明

  1. 三核支持已经有效。所有有效 benchmark 场景都显示 3-core submit 时间低于 1-core。
  2. 调度器设计已在真实 benchmark 中转化为性能收益。ready/running 队列推进、core binding 和 completion 回收这套机制能够有效利用三核并行。需要注意的是,当前 benchmark 验证的是单次 blocking submit 内的多核扩展收益,而非多线程并发提交的竞争场景——后者是调度器设计支持的能力,但尚未专项测试。
  3. 软件开销仍然存在。大多数场景没有接近理想 3x,说明 dispatch、harvest、同步和调度唤醒成本仍然需要优化。

五、功能模块分层与实现思想

当前实现可以按从上到下的链路理解。

最上层是用户态 benchmark 或 runtime。它负责准备输入、权重、输出、regcmd 和 task array,然后通过 ioctl 提交任务。benchmark 还负责构造不同矩阵规模和不同 operand 共享模式,用来观察调度器在不同负载下的行为。

再往下是 ioctl / service 边界层。它负责把用户态传进来的 RknpuSubmitRknpuTask[] 和内存管理请求拷入内核,转换成驱动内部可以处理的数据。这里不应该保存太多调度状态,否则 ioctl 层会变成第二个 scheduler。

scheduler 层负责 submit 生命周期。它维护 ready、running、complete 三个 bucket,管理每个 submit 的 waiter,也负责唤醒全局 worker。waiter 和 kick 的职责要分清:waiter 是 per-submit 的,回答”这个 submit 完成了吗”;kick 是全局 worker 信号,回答”现在有没有新活需要 worker 醒来处理”。这两个东西混在一起,调度器会很难读,也很容易漏唤醒。

driver / dispatch / IRQ 层负责硬件事实。driver 给某个 core 编程,IRQ handler 读取和清除硬件中断,completion 只表示”某个 core 有原始完成状态”。至于这个 completion 属于哪个 submit、哪个 lane、哪个 task index,应由 scheduler 根据 core_binding 还原,而不是让 driver 反向保存一堆队列语义。

最底层是寄存器访问层,也就是 rknpu-regs。它提供类型化 register API。调度器不应该知道太多寄存器细节,寄存器层也不应该知道队列策略。这个分层让后续工作可以分开推进:寄存器访问继续补全,调度策略继续优化,ioctl ABI 保持稳定。

六、当前限制与问题分析

  1. 三核收益仍然低于理想值。除了 llama_decode_like 达到 83.78% efficiency,多数场景仍在 57%68% 左右。说明当前并行并不是完全线性扩展,软件路径还有明显成本。
  2. 小任务场景仍然敏感。tiny_dispatch 虽然这次有正收益,但它的绝对 submit 时间只有毫秒级,任何一次额外调度、yield、IRQ 回收或锁竞争都会改变结果。后续如果要跑大量小算子,必须继续压低固定开销。
  3. 3-core 路径存在尾延迟波动。llama_decode_like 的平均 speedup 很好,但 3-core jitter span 到了 9.84%。这提示我们不能只盯 avg submit,还要看 min/max 和尾延迟。

七、调度器的执行时序

本节描述调度器的内部机制和执行流程,内容基于 drivers/rknpu/src/service/scheduler.rsdrivers/rknpu/src/task/taskqueen.rs 的实现。

7.1 核心数据结构

NpuSchedulerState(调度器全局状态,受单一 mutex 保护):

  • tasks: BTreeMap<RknpuQueueTaskId, RknpuQueueTask> — 所有活跃 submit 的唯一所有者
  • ready: BTreeMap<i32, VecDeque<RknpuQueueTaskId>> — 按优先级分桶,尚未开始执行的 submit
  • running: BTreeMap<i32, VecDeque<RknpuQueueTaskId>> — 按优先级分桶,已有 lane 在执行或还有 lane 可派发的 submit
  • complete: BTreeMap<RknpuQueueTaskId, RknpuQueueTask> — 已终态,等待 ioctl 路径取回
  • core_binding: BTreeMap<usize, CoreRunBinding> — 物理 core → {task_id, lane_slot, task_index} 的归属映射
  • waiters: BTreeMap<RknpuQueueTaskId, Arc<W>> — per-submit 的阻塞等待器

RknpuQueueTask(单个 submit 的运行时状态):

  • lane_isrun: [bool; 5] — 每条 lane 是否有任务在飞
  • subcore_cursors: [u32; 5] — 每条 lane 已完成的 task 数量
  • last_error: Option<RknpuError> — 错误状态

submit 的生命周期状态(ready/running/complete)不是单独维护的枚举,而是由 lane_isrunsubcore_cursorslast_error 的组合派生,并通过 reclassify_task() 决定 submit 应归入哪个 bucket。

7.2 调度策略

running-before-readydispatch_idle_cores() 为每个空闲 core 选择候选时,优先从 running bucket 中找可继续派发的 submit(prepare_dispatch_from_running),只有当没有 running submit 可用时,才从 ready 提升新 submit(promote_ready_and_prepare_dispatch)。这减少了同一 submit 内部的等待空洞,但对等待中的 ready submit 不是严格公平的。

内存同步边界

  • comfirm_write_all():在一次 dispatch_idle_cores() 调用中,对某个 submit 首次派发前调用,确保 DMA 写入对硬件可见
  • prepare_read_all():在 terminal submit 唤醒 waiter 前调用,确保硬件写回对 CPU 可见

7.3 图一:Submit 生命周期

注意:函数签名可能随着更新而改变实际函数签名以代码仓库中为准,此处只做流程解读

下图展示单个 submit 从 ioctl 入队到 copy-back 返回的完整路径,以及多个并发 submit 线程如何通过独立 waiter 共享同一个 worker。

7.4 图二:Worker 主循环与多核派发

注意:函数签名可能随着更新而改变实际函数签名以代码仓库中为准,此处只做流程解读

下图展示 worker 线程的主循环结构,以及 harvest 和 dispatch 如何交替推进多个 submit 的执行。

相关仓库

驱动主仓库

RKNPU

StarryOS与驱动的桥接层

rknpu-starry-adapter

效果视频,使用请看readme

Video

PPT

PPT

开发日志

Log

本次实习工作总结

摘要

本次实习围绕 x86 自动化测试体系构建、starry-vdso 完善、以及基于自定义 vDSO 接口的 fast path 设计与实现 三条主线展开。完成了从 PXE 自动化部署验证、CI 集成、多组织资源锁适配,到多架构 vDSO/getcpu 支持、vvar 映射优化、自定义 fast path 加载与调用、eBPF→vDSO 数据通路打通等一系列工作。整体实现了 vDSO 基础设施、以及低延迟 fast path 机制上的重要增强,为后续可插拔调度器、动态 eBPF 策略与用户态零系统调用访问奠定了基础。

x86 自动化测试支持

  1. 验证了 x86 平台使用 pxe 进行自动测试部署的流程,并编写验证报告

  2. 拆分 pxe 部署脚本,并与已有 github-runners 结合,使其能够在 CI 中自动触发测试

  3. 验证了部署流程与多组织资源锁 runner-wrapper 的适配,在 ubuntu+qemu 环境成功触发资源锁并执行完整测试

  4. 由于本地环境限制(缺少物理开发板),将已完成部分交付给柏乔森老师负责真实硬件上的验证

starry-vdso 的完善

  1. starry-vdso 接入 starryOS 验证时间相关函数已经能够走 vdso 路径 具体验证结果见笔记
  2. 修改了 x86 架构 getcpu 的初始化实现 为每个 cpu 分配独立 GDT 空间 解决多核场景下 CPU ID 获取不准确的问题
  3. 实现了 loongarch64 架构的 vdso_getcpu

基于自定义 vDSO 接口的 fast path 设计与实现

  1. 使用 vdso-helper 编译包含自定义 fast path 的 .so 文件,并完成在 StarryOS 中的加载与调用

  2. 优化 starry-vdso 的 vvar 映射逻辑 使用户态可以正常通过 vdso基地址+偏移量 访问自定义 vdso 接口

  3. 在已有可加载模块 modules/kebpf 中增加 vdso 数据页的更新逻辑。当 eBPF 程序执行时,将结果从 map 写入 vvar,使用户态可以零系统调用读取结果,显著降低延迟

  4. api/src/lib.rs 处增加 vdso 数据页的更新逻辑,在 register_timer_callback 时同步更新 vvar 中的任务快照,确保 fast path 始终读取到最新状态

相关链接

项目工程仓库

周报和工作总结

参考

Embassy Preempt

摘要

Embassy Preempt是一个嵌入式异步实时操作系统调度模块,结合Rust协程机制与传统RTOS的抢占式调度,在内存效率和实时性之间取得平衡。系统支持Rust原生async/await语法定义实时任务,兼容uCOSII API接口,并实现了创新的动态栈空间管理——主动让权时共享栈以节省内存,被抢占时按需分配私有栈保存上下文,在64任务场景下相比固定栈方案节省约53%内存。

项目已适配RISC-V平台,在VisionFive2(JH7110)开发板上实现了完整的AMP方案:S7核心运行Embassy Preempt处理实时任务,U74核心运行StarryOS处理通用计算,通过ov_channel共享内存库实现Notification和RPC双向通信。在无数据缓存的S7核心上,上下文切换性能与U74核心上的RT-Thread相当,核间通信延迟比官方AMP方案低约5~6倍。

汇报仓库

系统简介

Embassy Preempt是一个嵌入式异步实时操作系统的调度模块,它通过Rust提供的协程机制,结合embassy的异步执行器的实现方式,并借鉴传统嵌入式实时操作系统uCOSII的任务切换机制,在任务调度时实现了一套创新的内存管理和实时性保证机制。

在传统嵌入式实时操作系统(如uCOSII)中,每个任务都占有一个私有的栈空间,但在实际应用中,大部分任务释放CPU都是由于主动让权,而非被高优先级的任务抢占,这使得栈空间存在一定的浪费。而在embassy中,虽然通过引入Rust的协程机制使得栈空间的利用率得到了极大的提升,但是由于协程之间无法进行抢占,并且没有优先级裁决机制,导致在多任务场景下实时性较差。

Embassy Preempt在这两者之间进行了”折衷”,实现了一个既可以满足实时应用环境下的实时性要求,又可以尽可能缩小内存使用的嵌入式异步实时操作系统调度模块。其核心在于:

  • 混合任务调度:支持Rust原生协程作为任务,兼容传统uCOSII API接口
  • 动态栈空间管理:主动让权时进行栈复用,被抢占时进行栈分配和现场保存
  • 抢占式内核:支持高优先级任务抢占低优先级任务=

2025年11月以来,项目重点进行了RISC-V平台支持的开发,特别是在VisionFive2(JH7110)开发板上实现了完整的AMP方案:S7核心运行Embassy Preempt处理实时任务,U74核心运行StarryOS处理通用计算,两系统间实现了Notification和RPC的双向通信机制。

系统架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
graph LR
A[平台配置库] --> B[platform crate]
K[架构特定runtime库] --> B
B --> C[embassy-preempt-executor]
B --> D[embassy-preempt-mem]
B --> E[embassy-preempt-event]
B --> F[embassy-preempt-log]
B --> G[embassy-preempt-macros]
B --> H[embassy-preempt-structs]
B --> I[embassy-preempt-cfg]

C --> J[用户编写app]
D --> J
E --> J
F --> J
G --> J
H --> J
I --> J

style A fill:#e1f5ff
style K fill:#e1f5ff
style B fill:#fff4e1
style J fill:#e1ffe1

系统特性

支持rust原生协程作为task

Embassy Preempt完全支持Rust原生的async/await语法,开发者可以使用标准的异步函数作为实时任务。这种支持基于Rust的Future trait,通过执行器的poll机制驱动异步任务执行。

核心特性:

  • 原生异步语法:直接使用async fn定义任务,配合.await进行异步等待
  • 零成本抽象:协程在编译期转换为状态机,无运行时开销
  • 统一的任务模型:异步任务与同步任务在同一调度器中管理
  • 优先级支持:协程任务可以设置优先级,参与抢占式调度

任务创建示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use embassy_preempt_executor::AsyncOSTaskCreate;

// 创建异步任务 ([源代码](https://github.com/Oveln/embassy_preempt/blob/a708bb1ea723da795e79d672916791790e841dd5/modules/embassy-preempt-executor/src/os_task.rs#L79-L97))
AsyncOSTaskCreate(
my_async_task, // 异步函数
ptr::null_mut(),
ptr::null_mut(),
priority, // 优先级
);

// 异步任务定义
async fn my_async_task() {
loop {
// 执行实时任务
embassy_preempt_log::info!("Async task running");
// 主动让权,等待定时器
Timer::after_micros(100000000).await;
}
}

执行机制:

协程任务的执行通过执行器的poll函数驱动。当协程遇到.await点时,会将控制权交还给调度器,调度器可以选择:

  • 继续poll当前协程(如果未完成)
  • 切换到其他就绪任务
  • 进入低功耗状态(如果无就绪任务)

这种机制使得协程在主动让权时不需要保存完整的栈上下文,因为所有的局部变量都存储在Future的状态机中,而非栈上。

ucosii api兼容

Embassy Preempt提供了与传统uCOSII RTOS兼容的API接口,使得现有的嵌入式代码可以轻松迁移。

主要兼容接口:

  • OSTaskCreate/OSTaskCreateExt - 任务创建 (源代码)
  • OSTimeDly - 任务延迟 (源代码)
  • OSTimeDlyHMSM - 按时分秒延迟
  • OSSemCreate/OSSemPend/OSSemPost - 信号量操作 (源代码)

使用示例:

1
2
3
4
5
6
7
8
9
10
// C风格任务创建 ([源代码](https://github.com/Oveln/embassy_preempt/blob/a708bb1ea723da795e79d672916791790e841dd5/modules/embassy-preempt-executor/src/os_task.rs#L43-L76))
extern "C" void my_task(void* arg) {
while (1) {
// 任务逻辑
OSTimeDly(100);
}
}

// 创建任务
SyncOSTaskCreate(my_task, NULL, NULL, 10);

统一调度机制:

在系统内部将任意sync函数视作单个Future,使得所有task能被统一调度

动态栈空间使用

Embassy Preempt实现了创新的动态栈空间管理机制,根据任务调度方式智能选择栈分配策略,最大化内存利用率。

内存设计:

  • 栈复用:主动让权时共享栈空间,避免内存浪费
  • 按需分配:仅在发生抢占时分配私有栈

两种调度模式:

  1. 让权模式(主动让权)

    • 任务通过.await主动让出CPU
    • 所有协程共享同一个程序栈
    • 无需保存完整栈上下文(局部变量在Future状态机中)
    • 栈空间利用率最高
  2. 抢占模式(被动切换)

    • 高优先级任务抢占低优先级任务
    • 当前程序栈分配给被抢占任务保存上下文
    • 立即分配新栈供系统继续运行
    • 恢复执行时可能回收私有栈(如果任务再次让权或完成)

栈分配器实现:

采用固定大小块分配算法 (源代码),支持8种栈大小:

1
2
3
const STACK_SIZES: [usize; 8] = [
128, 256, 512, 1024, 2048, 4096, 8192, 16384 // 字节
];

根据Qemu平台的测试数据(64任务场景):

  • 动态栈开销:120KB(峰值时约7个任务同时拥有私有栈)
  • uCOSII固定栈:256KB(每任务4KB)
  • 内存节省:约53%

这种设计既保持了抢占式RTOS的实时性,又最大程度地降低了内存占用,特别适合资源受限的嵌入式系统。

上下文切换性能

基于QEMU RISC-V平台的精确指令计数测试,Embassy Preempt展现了高效的上下文切换性能。

测试环境:

项目 参数
测试平台 QEMU RISC-V
架构 RISC-V 64位
测试方法 指令计数
测试流程 从发起上下文切换请求到处理完成
测试项 指令数 说明
完整上下文切换 378条指令 从请求到完成的完整流程

在实际平台上运行会因为cache miss等原因大于378时钟周期,具体表现在visionfive2的部分会有比较

Embassy Preempt On VisionFive2

S7核心适配

VisionFive2开发板上的JH7110处理器有5个核心,其中S7核心(hart0)专门运行Embassy Preempt。

S7核心特点:

特性 S7核心
指令集 RV64IMAC(整数+乘法+压缩)
浮点
原子指令 不支持CAS,需要软件模拟
缓存 仅有I cache,无D cache
运行位置 L2 Cache Lim区域

S7核心不支持S态,只能运行在M态,这意味着不需要OpenSBI层,可以直接访问硬件,减少了中断延迟。

L2 Cache Lim配置:

L2 Cache Lim是JH7110中L2 Cache的特殊功能,disabled的cache ways可以直接作为内存寻址,提供确定的访问时间。

配置原理:

  • 总共2MB = 16 ways × 128KB
  • 复位状态:只有way 0是cache,其他15个ways都是disabled(可作为L2 LIM)
  • Enable规则:通过WayEnable寄存器enable ways,从最低编号way开始
  • 地址映射:最高编号way映射到最低L2 LIM地址
  • 一旦enable就不可disable,除非系统复位

启动流程与SPL自搬运:

初始状态下,这块低内存区域(0x800_0000)被SPL使用。启动流程如下:

  1. SPL阶段:SPL搬运opensbi、uboot、embassy_preempt到对应内存位置
  2. U-Boot阶段:需要初始化cache2驱动

为了给Embassy Preempt腾出L2 LIM空间,做了以下修改:

1. SPL自搬运

  • 新增arch/riscv/cpu/spl_relocate.S(适用于多核的自搬运算法)
  • 将SPL自身从0x800_0000搬运到0x808_0000
  • 修改u-boot-spl.lds链接脚本
  • 这样让出了低内存的512KB空间

2. Cache2驱动修改

  • 添加sifive,max-enabled-ways设备树选项
  • 修改cache-sifive-ccache.c驱动
  • 只enable way 0~11作为cache(12个ways,1.5MB)
  • 保持way 12~15为disabled状态(4个ways,512KB)

最终内存布局: (源代码 - memory.x)

1
2
3
4
5
6
7
8
9
10
11
0x800_0000 ┌───────────────────────┐
│ L2 LIM (4 ways) │ ← 512KB,disabled ways
│ way 15 → way 12 │ embassy_preempt运行区
│ - embassy_preempt. │
├───────────────────────┤
0x808_0000 │ SPL (搬运后位置) │ ← SPL自搬运到这里
│在uboot阶段初始化为Cache │
│ Cache (12 ways) │ ← 1.5MB,enabled ways
│ way 11 → way 0 │
└──────────────────────┘
0x820_0000

Embassy Preempt的代码段通过链接脚本放置在L2 LIM区域(0x800_0000),享受确定的访问延迟,不会有cache miss。

StarryOS兼容

VisionFive2的另外4个U74核心(hart1-4)运行StarryOS操作系统。

U74核心特点:

特性 U74核心
指令集 RV64GC(完整指令集)
浮点 支持双精度浮点
原子指令 完整支持
缓存 完整L1 + 共享L2

U74核心性能比S7核心强,有浮点单元和完整的原子指令,适合运行通用计算任务。

StarryOS基于ArceOS,支持多核SMP模式。hart1-4核心通过OpenSBI启动,然后uboot加载StarryOS内核,走的是标准的RISC-V启动流程,使用SBI接口访问硬件。

这与hart0直接运行Embassy Preempt不同:hart0在M态直接运行,而U74核心在S态运行,保持了与传统RISC-V Linux系统的兼容性。

双系统通信

S7核心的Embassy Preempt与U74核心的StarryOS之间通过ov_channel库实现双向通信。

ov_channel库简介:

ov_channel是一个为该场景特殊设计的双系统共享内存通信库,专门设计用于裸机环境下的高效通信。这个库基于环形缓冲区实现无锁通信。

主要特性:

  • no_std
  • 基于环形缓冲区的无锁通信
  • 四种消息类型:Notification(通知)、Data(数据)、RPC Request/Response(远程过程调用)
  • 每个消息256字节,包含1字节类型标识和255字节负载数据
  • 支持类型安全的RPC调用,使用postcard二进制序列化

测试验证:

该库已经在x86和RISC-V 64位平台上进行了充分测试,验证了跨平台兼容性和正确性。

MSIP中断通知机制:

两系统间通过MSIP(Machine Software Interrupt)寄存器实现中断通知:

  1. StarryOS → Embassy Preempt

    • StarryOS写入共享内存消息
    • 通过OpenSBI扩展SBI接口触发MSIP0中断
    • Embassy Preempt的MSI中断处理函数被调用
    • 唤醒wait_for_ipi().await等待的任务
    • 处理共享内存中的消息
  2. Embassy Preempt → StarryOS

    • Embassy Preempt写入共享内存消息
    • 直接写MSIP1寄存器触发hart1的SSI中断
    • StarryOS的IPI设备驱动处理中断
    • 读取并处理共享内存中的消息

对于StarryOS来说,ov_channal所在的内存区域是一块mmio设备

RPC通信流程:

1
2
3
4
5
6
StarryOS 发起RPC调用:
1. 写入RPC Request到Channel 0
2. 触发MSIP中断通知Embassy Preempt
3. Embassy Preempt处理请求并写入RPC Response到Channel 1
4. 触发MSIP中断通知StarryOS
5. StarryOS读取响应

实际应用示例:

在embassy_preempt_app_Visionfive2中实现了RPC服务器,支持的方法包括:

  • HELLO_WORLD: 测试方法
  • ADD: 加法运算

任务可以通过wait_for_ipi().await (源代码) 异步等待来自StarryOS的RPC调用或Notification。

性能比较

与官方在VisionFive2大核心(U74)上移植的RT-Thread AMP方案进行性能对比。

对比项 Embassy Preempt RT-Thread
运行核心 S7 (hart0) U74
指令集 RV64IMAC RV64GC
浮点单元 双精度浮点
数据缓存 完整L1+L2
上下文切换 (源代码) 1.25~2.48 μs 平均1μs,最大2μs
核间通信 RPC: 3.585~4.669 μs IPI: ~25μs,最大70μs

核心优势

  1. 上下文切换:在无数据缓存的S7核心上,实现了与U74核心上RT-Thread相当的调度性能

  2. 核间通信:通过软件中断提醒,通过ov_channal的延迟比官方AMP方案的延迟低约5~6倍,主要得益于共享内存通信和协程异步模型

  3. 资源利用:动态栈管理机制在64任务场景下相比传统固定栈方案节省约53%内存

相关链接

AxVisor 中统一 eBPF Tracing 系统的设计与实现

摘要

本文总结了在 AxVisor 中构建统一 eBPF tracing 系统的阶段性工作。系统面向 VMM、Guest Kernel 和 Guest Userspace 三个执行层次,目标是把分散的事件源接入同一条程序装载、命中处理、运行时执行和事件输出链路中。实现过程中,先建立了 tracepoint、runtime、map、helper 和统一事件流等基础设施,再逐步扩展到 hprobe/hretprobeguest-kprobe/kretprobeguest-uprobe/uretprobe。其中,userspace tracing 重点解决了对象元数据装载、运行期激活、实例管理和生命周期回收等问题。最终形成了一套可以继续扩展、并已具备联调与验收路径的 tracing 基础框架。

Read more »