状态:设计,未实施。本文不新增里程碑,它给出五项决策的形状与判据, 以及下一个真实案例的选择依据。
配套文档:
2026-08-19-freestanding-baremetal-design.md—— 裸机方向的原始方案,§11 提出 openkal / openhal / openarch 三层。2026-08-19-freestanding-baremetal-implementation-plan.md—— §7 是 D 档四阶段路线图与每阶段的继续/停止判据。2026-08-20-openkal-design.md—— 内核 ABI 规范的推导,§6.2 给出裸机 UART 后端。2026-08-20-pr455-459-freestanding-review.md—— 引擎侧五个 PR 的 review。docs/13-baremetal.md(PR#466)—— 面向用户的章节。
证据来源分两类:标注为「实测」的结论在本机验证过,环境为 mcpp 2026.8.20.1
(由本仓库构建)· x86_64-linux-gnu · xim:llvm 22.1.8 · xim:picolibc-riscv 1.8.12 ·
xim:qemu-riscv 9.2.4-1 · mcpplibs:riscv-virt-rt 0.3.0 · mcpplibs:std-freestanding 0.2.0,
日期 2026-08-20;其余为设计主张,或转引自上列文档并注明出处。
| 维度 | 当前状态 | 依据 |
|---|---|---|
| 构建轴 | 完整。ISA 档位、目标 libc、启动对象、链接脚本、执行方式各有归属,且各只有一个读取点 | §2.1 |
| 设备轴 | 空。无 PAC、无 HAL、无中断模型、无设备树 | §2.2 |
| 可移植性表达 | §4 | |
| 目标可扩展性 | §5 | |
| OS 服务抽象 | 已实现(openkal 0.5,八个接口,五个后端); |
§6.4 |
| 分配器接线 | std::vector 在裸机上要用户手写 12 个 operator new/delete 重载 |
§3.2、§6.6 |
| 设备服务抽象 | 已设计未实现(openhal,D2),停止判据已写明 | §2.2、§10 |
| 内核方向 | 构建侧可用; |
§7 |
| 案例覆盖 | §8 |
实测:src/riscv_virt_rt.cppm 共 46 行,导出
board::print/println/printf/alloc/release/copy/fill/poweroff。除 poweroff 外
全部是 picolibc 的直接转发。poweroff 是全包唯一一次寄存器访问,向 virt
的 syscon 0x100000 写 0x5555。
包真正承重的部分在 build.mcpp(约 30 行),而其中三件事都是选择而非实现:
选 crt0-semihost 等四个库、指名 picolibc 的 picolibcpp.ld、拼 qemu 的 argv。
它不是 HAL:没有 GPIO、定时器、中断、DMA,没有任何外设类型。 它也不是 PAC:没有寄存器定义。
它是一个板级支持包,粒度是每台机器(QEMU virt),不是每个 ISA。它同时服务
rv64 与 rv32,因为 virt 两个宽度都存在且 picolibc 两个 multilib 档位都有。
名字沿用 cortex-m-rt / riscv-rt 的 -rt 约定,但比它们薄一层:
Rust riscv-rt |
riscv-virt-rt |
|
|---|---|---|
| 复位向量 | 自己实现 | picolibc 的 crt0-semihost |
.bss / .data 初始化 |
自己实现 | 同上 |
| 链接脚本 | 自带 link.x |
指名 picolibc 的 picolibcpp.ld |
| 入口宏 | #[entry] |
普通 int main() |
⇒ 它的贡献是接线而不是实现。这在 virt 上是优点(代码少、不重复造轮子),
但也意味着它没有验证过「板级包能否实现启动逻辑」这件事。
| 层 | 归属 | 载体 | 状态 |
|---|---|---|---|
ISA 档位(-march/-mabi/-mcmodel) |
引擎 | src/freestanding/target.cppm,每目标一行 |
✅ 2 行 |
| 编译器 + 目标 libc | 目标表行 | triple.cppm 的 pin + sysroot |
✅ |
| 启动对象 / 链接脚本 / 执行方式 | 板级支持包 | build.mcpp 三条指令 |
✅ |
| 可移植语言设施 | 普通依赖 | std-freestanding |
✅ 103/110 头(实测) |
| OS 服务抽象 | 普通依赖 | openkal + 后端 |
✅ 规范与两个后端; |
| 设备服务抽象 | 普通依赖 | openhal |
|
| arch 机制 | 普通依赖 | openarch |
riscv-virt-rt 能到达「hello world + 堆 + 退出码」,是因为 semihosting 提供了
一个长得像宿主的控制台。而 semihosting 是调试器功能:qemu -semihosting 与
JTAG 探针提供它,量产板子不提供。
⇒ 该路径不能原样迁到真实硬件。 真实板子上 board::println 需要一个
picolibc 后端,而该包不提供。
沿用方案 §11.6 / §14.0 的结论,本文不修改它:引擎只有 L1–L3 三条缝, openkal / openhal / openarch 都是包。后端选择是条件依赖,零新增轴 (openkal 设计 §6.3 已按此实现)。
本文的五项决策全部遵守该约束:§4 复用既有的 provides,§5 只动目标表的输入
来源而不动其读取点,§6/§7 是包与载荷,§8 是案例选择。
Rust 中一个库写 #![no_std],同一份 crate 宿主与 MCU 都能编。
mcpp 中 freestanding 住在目标表,引擎对图中每个 TU强制
-ffreestanding -nostdinc++ -fno-exceptions -fno-rtti。
⇒ 包无法声明自己 freestanding-safe,索引也无法据此筛选。这是 §4 要解决的。
std-freestanding 需要堆,没有办法说不要堆」,实测推翻了它。
实测三态(rv64):
| 用什么 | 结果 |
|---|---|
array span optional atomic ranges to_chars |
✅ 链接通过,完全不碰堆 |
std::vector |
undefined symbol: operator new(unsigned long) |
std::vector + 自写 12 个 new/delete 重载(转发 board::alloc) |
✅ 跑通,text 13492 |
⇒ std-freestanding 是分配中立的:既不提供堆也不要求堆。picolibc 的 malloc
之所以存在,是因为板级包链了 -lc;而 C++ 的 operator new 谁都没接上——
libc++ 把它放在编译版库里,目标版没有。
⭐ 更进一步的实测:一个 tier-0 程序的全部未定义符号是
_ZNSt3__122__libcpp_verbose_abortEPKcz ← 包自带 verbose_abort.cpp 提供
_ZNSt3__16__sortIRNS_6__lessIiiEEPiEE… ← 标量 __sort 的 extern template(T2 边界)
memmove
strlen
⇒ tier-0 的整个 libc 运行期面是 memmove + strlen 两个纯计算函数,
不需要 OS,也不需要 openkal。与 Rust core 的差距远小于本节初稿的判断。
真正的耦合在头文件而非服务:libc++ 的头 #include <string.h> / <math.h> /
<stdio.h>,这是结构性的,换不掉。见 §6.5。
rustc 内置约 40 个裸机目标,mcpp 有 2 个。数量差距本身不重要——重要的是 rustc 的自定义目标 JSON 早于 tier-3 目标很多年,生态先跑起来才谈内置。 mcpp 今天没有等价物。这是 §5。
Rust:riscv(ISA)→ riscv-rt(运行时)→ PAC(每芯片,SVD 生成)→
HAL(embedded-hal trait)→ BSP(每板)。共五层。
mcpp 把这些压进一个板级包。对 virt 够用,撑不过同一厂商的第二颗芯片——
PAC / HAL 的切分正是让 50 块板共用一份 HAL 的东西。
一个驱动 crate 写给 embedded-hal::spi::SpiDevice,在每颗芯片上都能用。
openhal 的 D2 判据(同一个驱动包既跑裸机 MCU 又跑 Linux)正是对标这一形状,
且其停止信号写得很清楚:驱动作者不来就停。
「没有 OS 能用,有 OS 更能用」成立,但要说准:它成立在源码层而非产物层。 freestanding 构建被强制四个 flag,所以「能 freestanding 编」等价于 「这份源码不用 OS、不用异常、不用 RTTI、不用 hosted std」。这样的源码在有 OS 时 当然也能编——但会被按 hosted 重新编一遍,不是复用同一份产物。
provides 已经是包级键,不需要新增任何段:
[package]
provides = ["freestanding"]与 blas / lapack 同一套词汇(docs/05 §2.8.1)。
openkal 设计 §5 为此把整套能力位机制删掉,理由是
「无法在不定义的前提下声称」。同一条纪律必须套在这里,否则重演
caps::seek = true 而 seek() 永远失败。
本处的判据近乎免费:
⭐ 包能为
riscv64-none-elf编过,它就是 freestanding-safe,by construction。 因为引擎已经把那四个 flag 强制到全图,没有第二件事需要查。
⇒ 形状是证据推导而非作者声称:包 CI 里加一条 freestanding 构建,索引记录
该事实。这与 PR#451 的 [[runtime.artifacts]] 承载证据同形。
| 今天 | 决策 A 之后 | |
|---|---|---|
裸机工程 mcpp add 一个 hosted-only 库 |
解析成功、构建到链接期报一批未定义符号 | 解析期即可警告或拒绝 |
| 索引检索「哪些库能裸机用」 | 不可能 | 可枚举 |
| 库作者知道自己是否还兼容 | 不知道 | CI 变红 |
实测:cfg(os = "none") 与 cfg(arch = "riscv64") 都能匹配,库因此可以按
freestanding 走另一套源码或 flag:
[target.'cfg(os = "none")'.build]
defines = ["PROBE_OS_NONE=1"]cfg(os = "none") matched
cfg(arch = "riscv64") matched
| 形态 | 代价 | 兼容风险 | |
|---|---|---|---|
| (a) 工程内定义目标 | [target.<triple>] 今天只能覆盖已知 triple 的旋钮,扩成能定义未知 triple(march / mabi / mcmodel / sysroot / pin) |
最小 | 零:工程本地,老 mcpp 不认识该 triple 时干净失败 |
| (b) 目标定义包 | 目标行随包走,板级支持包自带它的 target | 中 | 低 |
| (c) 索引化 | 已定为阶段二 | 大 |
理由是前一条产出后一条所需的证据。(a) 立即解除生态作者的阻塞,并且在设计 (c) 的索引 schema 之前,先积累「真实目标行长什么样」的样本。Rust 的自定义目标 JSON 就是这个位置。
src/freestanding/target.cppm 的注释写明它是单一读取点,存在理由是不让同一
决策在 N 处推导(#233 / #240 / #242 / #344 的教训)。
⇒ 清单定义的目标必须喂进同一张表,由 resolve() 统一读出,不得开第二条解析
路径。决策 B 改的是表的输入来源,不是它的读取方式。
| 问题 | 归属 | 类比 |
|---|---|---|
语言设施(容器、算法、optional、span) |
std-freestanding |
Rust core |
| OS 服务(读写、时钟、内存、进程) | openkal |
Rust std 减去 core;或 WASI |
库写 kal:: 而不是 libc / POSIX,后端由条件依赖选中。转引 openkal 设计 §1:
裸机侧后端直接写 MMIO,两侧 app.o 的外部符号集合完全相同,裸机侧无未定义符号,
体积 157 字节(该数字转引自设计文档,本文未复测)。
一整个 interface 不存在时,import openkal.task; 在编译期即找不到模块;
未提供的符号确实不导出,该性质用 nm 静态可查。
std::format、内建标量类型的 std::sort、完整的 std::string 在 libc++ 的
编译版库里(标量 __sort 的实例化是 extern template,没有可关闭它们的宏)。
这要的是为目标编出的 libc++.a,是载荷问题而不是抽象层问题。
⇒ openkal 不会让这三样出现,docs/13 的「当前边界」那一行不因 openkal 而改变。
实测索引里 openkal 家族有六个包:openkal · -linux · -macos · -musl ·
-windows · -libc。没有裸机后端,而 D0 的交付物字面就是
「两个后端(linux / bare)」——它是已命名但缺失的那一个。
[target.'cfg(all(arch = "riscv64", os = "none"))'.dependencies]
openkal-uart = "0.1"cfg 是 ISA 级的,而 §6.2 那个后端里的 UART 地址 0x10000000 是 板级的。
换一块 RISC-V 板,它会写到一个不存在的地址上——类型正确、能编、能链、
跑起来无输出。这与 §1.2 的分层结论直接冲突:位置是目标的事实,选择是板级的事实,
而 UART 基址显然属于后者。
正确形状:后端由板级支持包提供,选择键是那条板级依赖本身。
# 应用:不写任何 cfg —— 板级依赖已经是 target 特定的
[dependencies]
openkal = "0.5" # 契约
riscv-virt-rt = "0.3" # 板级支持,同时定义 kal_* 符号| 方案 | 形态 | 评价 |
|---|---|---|
| (A) 板级包兼任后端 | riscv-virt-rt 自己定义 kal_* |
⭐ 推荐。粒度精确,消费者零 cfg。不违反 openkal §21——该条禁止的是实现导出模块,而定义 kal_* 正是实现该做的事 |
(B) 每板一个 openkal-<board> |
后端与板级包分开 | 板级包不想强绑 openkal 时用;代价是每板两个包 |
| (C) 参数化的通用 MMIO 后端 | 从板级包取基址 | ❌ 后端要反向依赖板级包的模块名,形成耦合环 |
| (D) ISA 级 cfg(设计 §6.3 现状) | cfg(arch, os) |
❌ 上述的静默失败 |
provides = ["openkal-backend"],应用侧 requires 它——复用 §4 同一套能力词汇。
不加也可用:openkal 设计 §21.3 已定「缺少实现时链接期报错并点名未定义的函数」。
自然的猜想是 openkal → openkal-musl/-libc → std-freestanding,把 picolibc 替掉。
实测把这条链拆成了两半,结论与猜想不同:
| 能否绕过 libc | 依据 | |
|---|---|---|
| 头文件 | ❌ 结构性不可绕过 | libc++ 的头 #include <string.h> / <math.h> / <stdio.h>。这不是策略选择,是 libc++ 的实现方式 |
| tier-0 运行期符号 | ⭐ 几乎已经绕过了 | 实测只有 memmove + strlen,两个纯计算函数,不需要 OS 也不需要 openkal |
tier-1(operator new) |
需要一个分配器 | 这里才是 kal_alloc 的位置 |
tier-2(format / 标量 sort / 完整 string) |
需要目标版 libc++.a |
openkal 管不着(§6.3) |
⇒ 正确表述:std-freestanding 与 openkal 的接触面只有 tier-1 的分配器一处。
「基于 openkal 的 libc」(openkal-musl / openkal-libc)对程序有价值——它让
一份 C 库跨所有 openkal 实现——但它不是 std-freestanding 的前置。
std-freestanding 坐到 openkal-libc 的头文件上,仍然要改目标的
sysroot,因为目标 libc 由目标表行的 sysroot 解析、不走依赖图。见 §7.2 的瓶颈。
分档的直觉正确,但不能按头文件拆。理由写在包自己的注释里:
GENERATED by tools/regenerate.sh. The list is not a curated opinion: it is every libc++ header that compiles for a freestanding target, measured by compiling each one.⚠️ The export table is NOT here and must never be written here.
按头文件拆成 freestanding.core / freestanding.<其它>,等于把一份生成的清单
改回手工策展的清单——正好撤销该纪律,而且清单会随 libc++ 版本漂移。
⇒ 该拆的是**「程序必须自己提供什么」**。而 alloc 不是一个头文件开关:
std::vector 现在就编得过,失败发生在链接(§3.2 实测)。
不需要 alloc |
需要 alloc |
|---|---|
array span optional expected atomic string_view ranges bit charconv tuple |
vector string deque list map set unordered_* function any make_unique;operator new) |
上表只有 vector 一行直接测过,其余由「缺 operator new」这一机制推得。
⭐ 这不是新发明,是本仓库已文档化的形状(docs/05 §2.8.2 的 backend-openblas:
一个 feature 同时拉 provider 并打开消费者开关),照搬到分配器:
# mcpplibs/std-freestanding
[features]
default = []
alloc = { requires = ["freestanding-allocator"] } # 消费者开关
alloc-kal = { implies = ["alloc"] } # 内置默认:开它就够
[feature-deps.alloc-kal]
std-freestanding-alloc-kal = "0.1.x" # 仅在 alloc-kal 激活时解析| 场景 | 工程里写什么 |
|---|---|
| tier-0 固件 | std-freestanding = "0.2.0" — 不碰分配器 |
| ⭐ 默认路径 | features = ["alloc-kal"] — 一个 feature,实现自动进图,不必知道 provider 包名 |
| 自定义 / 第三方 | features = ["alloc"] + 自己那个 provides = ["freestanding-allocator"] 的包 |
它同时拿到四件事:实现分离(本体零依赖,openkal 只出现在 feature-deps 下)·
默认一键可得 · 可换 · 并集风险被转化(依赖擅自开 alloc-kal 而应用又自带
provider 时,得到的是解析期两个 provider 报错,不是链接期一句
duplicate symbol: operator new)。
| 形态 | 否决理由 |
|---|---|
按头文件拆 freestanding.core / freestanding.<其它> |
把生成的清单改回手工策展的清单,撤销包自己写明的纪律,且清单随 libc++ 版本漂移 |
nolibc 做成 feature |
-lc 时 memmove/strlen 重复。且加性 feature 无法被消费者关掉。它是目标的属性(sysroot 有没有 libc),做成包级开关就是同一决策的第二处推导 |
| provider 用弱符号提供「可被顶掉的默认」 |
build.ninja 的链接边(零 libc 工程,实测):
build bin/solo | bin/solo.map : cxx_link obj/mcpplibs_std-freestanding/src/std_freestanding.m.o \
obj/main.o obj/mcpplibs_std-freestanding/src/verbose_abort.o
整份文件零个 .a。 StaticLibrary 只在根清单声明 kind = "lib" 的目标上产生
(plan.cppm:1637),不是依赖包被消费的形态。
⇒ libc++ 那套「库里常驻一份默认、程序定义了就顶掉」依赖归档语义(成员仅在符号 仍未定义时才拉入);目标文件的定义无条件进入链接,所以那条路今天走不通。 这正是 §6.6.1 用「开关控制是否存在」而非「默认存在可覆盖」的原因——两份定义从不共存。
两个包都 provides = ["alloc-cap"] 且都定义同名符号:
error: capability 'alloc-cap' has multiple providers in the graph: [pa, pb];
select one with [capabilities] alloc-cap = "<provider>" or --cap alloc-cap=<provider>
按提示指定 [capabilities] alloc-cap = "pa" 之后:
ld: obj/mcpplibs_pa/src/impl.o: in function `cap_probe':
multiple definition of `cap_probe'; obj/mcpplibs_pb/src/impl.o: first defined here
链接边上两份 .o 都在。
⇒ capability 绑定的是「谁满足这条要求」,不是「哪些目标文件参与链接」。
对单例符号类的 provider(operator new 即是),[capabilities] 消歧会把一个
点名两个 provider 的好错误,换成一句 multiple definition 的坏错误。
blas 严一档:两个 provider 同时在图里是要修的错误,
不是可消歧的状态。 这一条已补进 docs/05 §2.8.1。
freestanding 目标 · 自带链接脚本(link-script)· 自定义 runner ·
.bin / .map / size 摘要 · mcpp test 在模拟器里读退出码。
⇒ 写内核最繁琐的构建部分基本齐备。实测:一个零依赖的 freestanding 工程可以构建,
产物 text 12 data 0 bss 0 total 12——这证明仅凭 ISA 表行就足以产出正确目标文件。
⭐ 更强的一条实测:零 libc 链接已经成立。 一个只依赖 std-freestanding、
没有板级包、没有 -lc、没有 crt0 的工程,自带 memmove + strlen + _start
之后链接通过,text 15866。
0x10000,
qemu virt 需要 0x80000000。
⇒ ⭐ 零 libc 档缺的不是 libc,是链接脚本——而链接脚本是板级事实,不是 libc 事实(§1.2)。这一条反而加强了分层结论:去掉 libc 之后剩下的那件事,恰好落在 分层里已经有归属的那一格。
std-freestanding:它们是 libc 符号,板级包一旦链了
-lc 就会重复(§6.6.3 已证依赖以裸 .o 参与链接,重复即硬错误)。而且它们本不同层——
memcpy/memmove/memset/memcmp 是编译器要求的(freestanding 实现也必须提供,
因为编译器会自行发出对它们的调用),属 ABI 层;只有 strlen 真的是 libc。
⇒ 应为独立小包,由零 libc 档拉入。
| 缺口 | 为何挡路 |
|---|---|
⭐ [target.X].sysroot 覆盖 |
三条线的共同瓶颈,见下 |
| 零 libc 档 | 目标行今天硬绑 sysroot = xim:picolibc-riscv@1.8.12。内核作者要的是什么都不要。还需要「main 指向携带 _start 的文件」这条路径的真实示例 |
x86_64-none-elf 目标行 |
内核开发的主要战场之一,今天一行都没有。 |
| openarch(D3) | Context / Trap / AddressSpace / PerCpu。2026-08-20-openarch-implementation-design.md |
⭐ [target.X].sysroot 是三条独立需求的同一个瓶颈:
| 需求 | 要把 sysroot 改成 |
|---|---|
| 内核 / bootloader 的零 libc 档 | 空 |
| 换一份 C 库实现(newlib 等) | 另一个 xim 包 |
让 std-freestanding 坐到 openkal 的 C 库头上(§6.5) |
openkal-libc / openkal-musl |
⇒ 一个旋钮解锁三条线。这比把它列成一条普通边界重要得多,因此它在 §10 的实施顺序里 排在零 libc 档之前。
openkal 的接口划分与 UEFI 的服务几乎一一对应,因此 openkal-uefi 是本层最自然的
一个后端:
| openkal interface | UEFI 对应 |
|---|---|
stream |
SIMPLE_TEXT_OUTPUT_PROTOCOL / SIMPLE_TEXT_INPUT_PROTOCOL |
memory |
AllocatePool / FreePool |
abort |
Exit / ExitBootServices 之前的 ResetSystem |
fs |
SIMPLE_FILE_SYSTEM_PROTOCOL / EFI_FILE_PROTOCOL |
time |
GetTime / Stall |
process / task |
不提供——UEFI 无进程模型,整组缺席即可 |
⇒ 一个 bootloader 可以用 openkal 写业务(读文件、打印、分配),用
std-freestanding 的 tier-0 写数据结构,而完全不碰 UEFI 的具体 API。
-
UEFI 需要一个今天不存在的 PE 形态裸机目标行。⭐ 实测:不需要。x86_64-windows-gnu加三个链接 flag(-nostdlib、-Wl,--subsystem,10、-Wl,-e,efi_main)产出的正是IMAGE_SUBSYSTEM_EFI_APPLICATION (0xA), 且不依赖任何 DLL;该目标默认就是 Microsoft x64 调用约定,固件函数指针可 直接调用。已实现为mcpplibs/openkal-uefi0.1.0,在 OVMF 下作为EFI/BOOT/BOOTX64.EFI启动验证。⇒ 原判断是从「裸机链接行是 ELF 形状」推出来的,错在没有考虑已有的 PE 目标 能否被降到 freestanding。这是本轮第二次「结构上可能 ≠ 运行时确实」在我自己 身上生效。
-
⚠️ legacy BIOS 基本不在射程内:512 字节 MBR 是 16 位实模式,clang 无法 有意义地生成。现实的「BIOS 路径」是 multiboot2 ELF(由 GRUB 加载), 而那正好就是x86_64-none-elf,不需要新形态。
mcpplibs/openkal-opensbi 0.1.0:控制台是对已经知道机器是什么的固件的一次
ecall,因此同一个镜像在 QEMU virt 的 OpenSBI 下与真实板子上都能跑,不必重新
构建 —— 而板级后端往固定地址写,换板即静默失效(§6.4)。
⇒ 两种后端并列而非替代:SBI 要求底下有固件,板级后端不要求。
给内核开发者的最小示例应当是一个零 libc 的 riscv64-none-elf 工程:
自带 link.ld、自带 _start、自己写 UART、main 指向汇编,启动到打印一行并停机。
它同时是三样东西:
- 内核作者的起点;
- 零 libc 档(
sysroot为空)的判据——没有它,那一档没有任何测试; - openkal 裸机后端(§6.2 那 15 行)的宿主。
xim:llvm 22.1.8 的 clang -print-targets:
已注册:aarch64 · arm · thumb · avr · msp430 · riscv32 · riscv64 ·
loongarch32/64 · mips · ppc · sparc · systemz · wasm32/64 · x86 ·
x86-64 · xcore 等。
未注册:xtensa。
⇒ 两条结论:
- Cortex-M 不需要新的工具链载荷,
arm/thumb已在其中。 - ESP32 / S2 / S3(Xtensa)在钉住的载荷上不成立,除非另做一份 Espressif 分支的 LLVM 载荷。
pkgs/ 下与裸机 C 库相关的包只有
picolibc-riscv.lua 一个,没有 arm 版 picolibc,没有 newlib,没有任何
arm-none-eabi 包。
⇒ Cortex-M 一步的真实成本是四件而不是三件:
| 内容 | 性质 | |
|---|---|---|
| 1 | xim:picolibc-arm 载荷 |
picolibc-riscv 是自建的(见 review §1 生态侧),因此这是重复一次已走通的流程,不是未知领域 |
| 2 | 目标表加行(thumbv7em-none-eabi 一类) |
一行 |
| 3 | 板级支持包 | 与 riscv-virt-rt 同形 |
| 4 | xim:qemu-arm 载荷 |
qemu-riscv.lua 一个 qemu 包,没有 qemu-arm。上游同为 xPack(qemu-arm-xpack 与 qemu-riscv-xpack 同一发布方),因此描述符可照 qemu-riscv.lua 改写 |
⇒ 修正判断:Cortex-M 一步不是「加一行」,而是两个新载荷 + 一行 + 一个包。 两个载荷都是已走通流程的重复,风险低但工作量真实存在。这一点必须在排期前说清楚, 否则会重演「计划里凡是带具体数字的句子都是必须先测的探针」那条教训。
| 型号 | ISA | 结论 |
|---|---|---|
| ESP32 / S2 / S3 | Xtensa | ❌ 载荷未注册该后端 |
| ESP32-C3 / C6 | RISC-V rv32imc |
C3 的三个新问题:ROM bootloader 的镜像头(esptool 格式)· 没有 semihosting 需自行 初始化 UART · 外设实际依赖 ESP-IDF(FreeRTOS + CMake + Kconfig 一整套)。
⇒ 它测的是厂商 SDK 集成,不是裸机模型。
| 步 | 案例 | 它验证什么 | 为什么是这个位置 |
|---|---|---|---|
| 1 | Cortex-M + QEMU(mps2-an385 或 lm3s6965evb) |
「ISA 表是数据」在真正不同的架构上是否成立 | 不需硬件,CI 保持封闭;载荷已有 arm/thumb |
| 2 | RP2040(树莓派 Pico) | 真烧录 + 真 UART 控制台(无 semihosting) | 4 美元、开源 SDK 成熟、UF2 烧录路径清楚 |
| 3 | ESP32-C3(可选) | 厂商 SDK 集成 | 三个新问题应当逐个引入 |
先做真实硬件会把「新 ISA」与「没有 semihosting」两个变量混在一起,失败时分不清 是哪一个。这与本轮已经付过学费的形状一致:判据一次只应引入一个变量。
真实案例会逼出 PAC / HAL / BSP 的切分,把「我们的分层是对的」从断言变成被检验 过的结论。
今天这套分层只在一台几乎没有外设的虚拟机上验证过,这是本文全部结论里证据最弱 的一环。
裸机不存在「通用 x86_64 / arm64」。 x86_64 裸机意味着在写 bootloader 或内核 (UEFI / multiboot);arm64 裸机永远在某颗具体 SoC 上。
| 可跨板共享 | 不可跨板共享 |
|---|---|
ISA 级运行时、目标 libc、std-freestanding、openkal 契约 |
内存映射、外设、启动协议、链接脚本 |
⇒ 正确粒度是三段:每 ISA 一份薄 -rt + 每芯片家族一份 HAL + 每板一份 BSP。
riscv-virt-rt 现在把三段熔在一起,因为 virt 是虚拟机、几乎没有外设(§1.2)。
第二块板会强制这次切分,而这正是 §8 的目的。
| 序 | 决策 | 判据(达成即继续) | 停止信号 |
|---|---|---|---|
| 1 | A freestanding 包能力 | 一个既有库为 riscv64-none-elf 构建通过并被索引记录;裸机工程 mcpp add 一个 hosted-only 库时在解析期被告知 |
—— (代价极低,无停止条件) |
| 2 | C′ std-freestanding-alloc(§6.6) |
std::vector 在裸机上不需要用户写一行 operator new 即可跑通 |
—— (约 30 行,无停止条件) |
| 3 | C″ openkal 裸机后端(§6.4) | D0 的「两个后端」补齐;⭐ 后端由板级包提供,应用侧零 cfg |
若发现必须回到 ISA 级 cfg ⇒ 分层结论有误,回到 §1.2 |
| 4 | ⭐ D′ [target.X].sysroot 覆盖 |
一个工程仅凭 mcpp.toml 把 sysroot 换成空 / 另一个包 |
—— (三条线的共同瓶颈,应早做) |
| 5 | B(a) 工程内定义目标 | 一个未进引擎表的 triple 仅凭 mcpp.toml 即可构建;resolve() 仍是唯一读取点 |
若发现必须开第二条解析路径 ⇒ 停,回到 §5.3 重设计 |
| 6 | D 零 libc 档 + 最小示例 | 把 picolibc 排除后仍能构建并在 qemu 打印一行 | —— |
| 7 | E 步 1 Cortex-M QEMU | 两个新载荷(picolibc-arm / qemu-arm)+ 目标表一行 + 一个板级包,源码零改地跑通;⭐ 引擎零改动 |
若引擎必须改 ⇒ 「ISA 表是数据」不成立,回炉 |
| 8 | E 步 2 RP2040 | 真烧录 + 真 UART | —— |
| 9 | B(b)/(c) 目标定义包 / 索引化 | 老客户端降级正确 | —— |
| 边界 | 状态 |
|---|---|
| 本文全部分层结论 | 只在 QEMU virt 一台虚拟机上验证过 |
| openkal 裸机后端的 157 字节 | 转引自 openkal 设计 §6.2,本文未复测 |
| Cortex-M 可行性 | 依据是载荷注册了 arm/thumb,未实际构建过任何 Cortex-M 产物 |
picolibc-arm / qemu-arm |
实测 xim 索引中均不存在,两者都要新建;上游可用性(picolibc 的 ARM 支持、xPack 的 qemu-arm-xpack)未逐一核实 |
| RP2040 / ESP32-C3 | 均未尝试 |
provides = ["freestanding"] 的索引侧表示 |
形状已定(证据推导),schema 未设计 |
openkal-uefi / PE 形态的裸机目标 |
形状已论证(§7.4),未实现,也未验证 lld-link 能产出 EFI 应用 |
std-freestanding-alloc 的默认策略 |
选定 kal_alloc(§6.6),但该包依赖 openkal 后端存在,而裸机后端尚未实现 |
| macOS / Windows 宿主上的裸机链 | 无持续验证(docs/13 已记) |
| 决策 | 一句话 | |
|---|---|---|
| A | freestanding 成为包能力 | 复用 provides,但由构建证据推导而非作者声称 |
| B | 目标表可扩展 | 先工程内定义,再随包走,最后索引化;单一读取点不可破 |
| C | std-freestanding 与 openkal 互补 |
⭐ 实测:接触面只有 tier-1 的分配器一处;tier-0 只需 memmove+strlen。openkal 解决不了目标版 libc++.a |
| C′ | 不拆 std-freestanding,另出 -alloc |
按头文件拆会把生成的清单改回手工清单;该拆的是**「程序必须提供什么」**,默认转 kal_alloc |
| C″ | 裸机后端由板级包提供 | cfg 会让换板后写到不存在的地址、静默无输出 |
| D | 零 libc 档 | 最小示例不是一个内核,是一个零 libc 的启动工程——它同时是该档的唯一判据 |
| D′ | [target.X].sysroot 覆盖 |
⭐ 三条线(零 libc / 换 libc / openkal 栈)的同一个瓶颈 |
| E | 下一个案例 | Cortex-M + QEMU 先行(编译器载荷已有 arm/thumb,但 C 库与模拟器两个载荷都要新建);ESP32 分型号,Xtensa 不成立 |
provides,B 只改目标表的输入来源,C/D 是包与载荷,E 是案例选择。
本部分记录上述方案的落地结果。它与方案分开,因为十七项假设中有十四项被 实测推翻或修正,而被推翻的过程本身是这份文档最有价值的部分:一个只记录 结论的方案,读者无法判断哪些结论是被检验过的。
| 维度 | 落点 | 判据 |
|---|---|---|
| 架构 | C 库成为目标的属性([target.X].sysroot),而不是包的;板级包不再指名 picolibc、compiler-rt 或 multilib 目录 |
riscv-virt-rt 的 build.mcpp 里三个硬编码名字全部换成查询;e2e/134 两侧钉住 |
| 稳定性 | 每一条新诊断都有机器校验,不靠注释维持 | e2e/135 把诊断里给出的依赖行粘回 manifest 再构建;riscv-virt-rt 的 CI 断言拒绝消息的内容而非退出码 |
| 优雅 | 新增能力零新引擎轴:sysroot 是既有目标表的一列,三个查询走既有的 build.mcpp 契约,分配器走既有的 capability 机制 |
引擎侧改动集中在 triple.cppm、freestanding/target.cppm 与 prepare.cppm 的填值处 |
| 用户体验 | mcpp new k --template riscv-virt-rt:nolibc-bsp 产出两个文件的可运行内核工程 |
实测 294 字节镜像;对照 :nolibc 模板的五个文件 |
| 兼容性 | 目标表新增行不改变既有两行;nolibc 是新 feature 而非默认;版本校验从硬错误降级为 schemaWarnings |
钉住 std-freestanding 0.3.0 的工程在校验加入后仍能加载 |
| 跨平台 | 八个仓库补宿主平台轴(macos-14 + windows-2022) | 五个仓库三宿主全绿;std-freestanding 的 Windows 缺口测量并记录而非删除 |
| 一致性 | 同一份探针源码在两台指令集不同的机器上运行,输出逐字相同 | openarch 的门槛判定,riscv64 与 aarch64 退出码均为 0 |
| 无感升级 | 索引 deps 边补回后,消费者装板级包即得到目标 C 库;引擎发布走既有闭环 |
「拿走再装回来」实测:包与 qemu 一起藏起来后重新构建,两者都被装回 |
E1 引擎:[target.X].sysroot 覆盖与零 libc 档 ──┬─→ P1 std-freestanding nolibc feature
├─→ P2 riscv-virt-rt 0.5.0 nolibc feature
└─→ E4 aarch64-none-elf 目标行(零 libc)
E2 引擎:三个目标查询接口 ────────────────────→ P2
E3 引擎:operator new 缺失的具名诊断 ─────────→ P3 两个 alloc 实现包
X1 索引:xim:qemu-arm(含 xlings-res 双端镜像)─┐
E4 ───────────────────────────────────────────┴─→ A1 openarch aarch64 上下文后端
└─→ A2 门槛判定(一份探针,两台机器)
└─→ A3 openarch.pte 两个后端
└─→ A4 CI 三腿
X2 索引:riscv-virt-rt 的 picolibc 安装边补回 ─→ 全部生态仓库 CI
X2。方案里没有它,因为方案假定
「mcpp 从目标行解析 C 库」等同于「mcpp 安装它」。实测:引擎只在磁盘上查找,
从不安装,而 0.3.0 删掉索引里的安装边时把这两件事混为了一谈。
| 仓库 | 本次交付 | 版本 |
|---|---|---|
mcpp-community/mcpp |
引擎五项能力 + 目标表第三行 + 章节 13 | 2026.8.20.2 → 2026.8.20.3 |
mcpplibs/std-freestanding |
alloc / alloc-kal / alloc-libc / nolibc 四个 feature;C++26 特性宏守卫;标准强制清单与合规断言 |
0.2.0 → 0.4.0 |
mcpplibs/std-freestanding-nolibc |
五个 C 函数 + 四份 C 头,供零 libc 档 | 0.2.0 |
mcpplibs/std-freestanding-alloc-kal |
十二个 operator new/delete,经 openkal | 0.1.0 |
mcpplibs/std-freestanding-alloc-libc |
同上,经目标 C 库 | 0.1.0 |
mcpplibs/riscv-virt-rt |
解耦三个硬编码名字;openkal 与 nolibc 两个 feature;两个零 libc 模板 |
0.3.0 → 0.5.0 |
mcpplibs/openkal-opensbi |
SBI 控制台 + SRST + bump 分配器 | 0.1.0 |
mcpplibs/openkal-uefi |
Boot Services 后端,偏移由 static_assert 钉住 | 0.1.0 |
mcpplibs/openarch |
第二个架构 + 页表项接口,门槛通过 | 0.1.0 → 0.2.0 |
外加两个索引仓库:mcpplibs/mcpp-index(包描述符)与
openxlings/xim-pkgindex(xim:qemu-arm),以及新建的 xlings-res/qemu-arm
双端镜像。
| 模板 | 生成 | 用途 |
|---|---|---|
riscv-virt-rt |
普通 int main() 工程 |
picolibc 之上的固件 |
riscv-virt-rt:nolibc |
五个文件,不依赖任何包 | 内核起点,展示到底需要什么 |
riscv-virt-rt:nolibc-bsp |
两个文件,依赖板级包的 nolibc feature |
内核起点,展示BSP 省掉了什么 |
按对方案的杀伤力排序。
- 「mcpp 从目标行解析 C 库」被读成「mcpp 会安装它」。 引擎只查找不安装;
冷 runner 的整份日志里
picolibc一次都没出现。索引的deps边是补法, 而 0.3.0 把它连同构建期耦合一起删了。 std-freestanding与std-freestanding-nolibc「互斥」。 0.4.0 的nolibcfeature 让子集在零 libc 档可用,实测 94/103。写着互斥的那段文档 与推翻它的 feature 是同一个 PR 合入的。- 板级包在零 libc 档「无事可做」。 不用 C 库的开发者需要的是更多板级 包而不是更少:UART 在哪、RAM 从哪开始、哪个模拟器启动它,没有一条是 C 库 事实。0.5.0 把 C 库变成消费方式上的一个 feature。
libc.empty()意味着「工程拒绝了 C 库」。 它同样意味着「还没装」。本机 picolibc 早就在,所以每次本地测试都过,CI 在冷机上失败。- UEFI 需要新的 PE freestanding 目标。 不需要:
x86_64-windows-gnu加-nostdlib -Wl,--subsystem,10就产出IMAGE_SUBSYSTEM_EFI_APPLICATION。 -mabi=lp64是 aarch64 的 ABI 名。 clang 直接拒绝;正确的是aapcs。 按 RISC-V 类推填表,恰好会在类推最像的地方错。- feature 可以控制 ISA 后端的宿主可测面。 一个文件只要被任何 feature
命名就归它独占——feature 未激活时不编译它,即便 cfg 块显式列出,而库
构建仍报成功。头文件
inline才是解法。 std-freestanding的 Windows 失败是「包绑死在 libc++」。 更深一层: libc++ 的__config_site只为宿主 triple 出厂,所以合成它是用 libc++ 做任何交叉的唯一办法(不合成时标准强制的 34 个头 0/34)。Windows 缺的是载荷没打包include/c++/v1,而 libc++ 的头是与宿主无关的纯文本。- C++26 的 freestanding 特性宏可以当门。 libc++ 22.1.8 一个都不定义, libstdc++ 16.1.0 定义七个,两者都不定义那个元宏。拿在场当门会把这个包在 libc++ 上清空。规则必须不对称:在场且不足才排除,缺席不排除。
- 这个包提供的是「libc++ 恰好能编的东西」。 它同时提供了标准强制的
全部 34 个,而它宣传的
<array>/<span>/<optional>/<vector>都不在强制列表里。说法比实际低。 mcpp build与mcpp test对 feature 源的判断一致。 不一致:ADD 两边 都跑而 DROP 被!includeDevDeps挡住,于是mcpp test编译未激活的 feature 源。这是为 gtest 的mainfeature 做的选择被推广到了每个包。- 上下文接口预留的 128 字节是个余量。 它已经假定了一条契约:保存的上下文
只含整数状态。aarch64 连
d8–d15一起保存需要 168 字节。 - 页表项自带其含义。 aarch64 写的是指向
MAIR_EL1的索引,所以同样的位 在两台 CPU 上描述不同的内存。本层因此接管那个寄存器。 - QEMU 一个包支持很多指令集。 xPack 按目标族分包:
qemu-riscv的bin/里只有两个程序。
| 状态 | |
|---|---|
mcpp test 与 mcpp build 的 feature 源不一致 |
已定位到 prepare.cppm 的 DROP 条件,未修——正确的修法要把「对测试规划器可见」与「编进库里」分开,并连 gtest 路径一起验证 |
Windows LLVM 载荷缺 include/c++/v1 |
已定位。修法是往载荷里加一份资源,不动 Windows 宿主默认的 MSVC STL |
| openarch 的 trap / per-CPU / 屏障 / 时钟 | 刻意未开始:形状取决于已完成的两条原语 |
| x86_64 裸机目标 | 卡在模拟器载荷:没有上游为本索引服务的五个宿主目标发布 qemu-system-x86_64 |