⭐ 全部实施完毕(2026-08-21)。 每一节末尾新增「已实施」小节,记录实测结果
与本文写错的地方。
2026-08-21。本文为 2026-08-21-baremetal-ecosystem-assessment.md 列出的四项优先事项给出
可执行方案。每项包含:成因(读码得出)、设计、实施步骤、判据、依赖与刻意不做的事。
先前记录中有一条:「build.mcpp 新增一条指令要改 9 处」。这条已经过时。
读 src/build/directives.cppm 的当前形态:
- 指令词表是
inline constexpr std::array<Def, 15> kTable,唯一真源; - 引用
Slot::的只有两个文件(directives.cppm26 处、build_program.cppm4 处); - 缓存记录的
d <tag>词表在注释里明确写着「由 kTable 拥有,不在此处拼写 —— 这份 列表曾被复制到四个地方并且漂移过」; serialize按表序遍历,accept_cache_record对未知 tag 返回 false(视为陈旧条目)。
⇒ 新增一条指令的成本比记录里低得多,而且缓存的写入与重放是表驱动的、免费的。 本文 §1 的方案依赖这个事实;若不先读码,会按九处改动去规划,得出错误的代价评估。
src/build/build_program.cppm 的运行段落:
auto rres = capture_exec_deadline({bin.string()}, childEnv, deadline, &timedOut, root.string());
if (timedOut) { return std::unexpected(... "Output so far:\n{}" ... rres.output); }
if (rres.exit_code != 0) { return std::unexpected(... "build.mcpp exited with {}:\n{}" ...); }
Directives d;
dirs::accept_output(d, dial, root, rres.output); // 只挑出 `mcpp:` 行⇒ rres.output 只在超时或非零退出时被打印。 构建程序成功时,它写的任何非指令输出被
静默丢弃。而 kTable 十五行里没有一行承担「向用户说一句话」。
实测后果:清单的 [xlings] deps 是声明而非安装触发器,干净机器上 mcpp::xpkg_dir 返回
空,构建程序静默地不配 runner,mcpp run 给出一条一般情况下正确、在此处不正确的
建议。为此写过一条 std::cerr 提示,实测后删除:它在最需要它的那些构建上一个字都不
打印,留着比不留更糟,因为它看起来像个修复。
候选 A:新增 mcpp:warning= 指令 + 类型化 API mcpp::warning(...)。
候选 B:构建程序成功时,把非指令输出打印出来。
B 的诱人之处是零兼容性成本:不需要新指令、不需要协议号、不需要新 API,老引擎只是 不打印而已。
⇒ 采纳 A。
协议门的注释给出了决定性事实:
一个针对新 mcpp 写的包到达老 mcpp 时,身上盖的是老引擎的协议号 —— 那个声明是在 build.mcpp 编译期由正在运行的引擎替换进去的,不是由包携带的。
⇒ 所以用了 mcpp::warning(...) 的构建程序在老引擎上根本编译不过(类型化 API 不
存在),失败形态是 clang 的「no member named 'warning'」而不是 mcpp 的诊断。
⭐ 这不是新问题,而是既有的代价模型。 link-script 在协议 3 加入时具有完全相同的
性质,注释里也记着那次「adding link-script in protocol 3 proved it wrong」。
⇒ 结论:使用警告通道会抬高该包的最低 mcpp 版本,这与使用任何新指令一样。对 motivating case(openarch 模板)不构成问题,它本来就 pin 了近期版本。
| 项 | 取值 | 理由 |
|---|---|---|
| wire | warning |
与 mcpp: 前缀连读为 mcpp:warning= |
| tag | warning(非空) |
见 §1.5,这是整个设计里最容易漏的一条 |
| slot | Slot::Warnings(新增) |
不能塞进任何既有槽:它既不是编译输入也不是链接输入 |
| scope | Scope::PackagePrivate |
一个包的警告不传播给消费者。传播会让一个深层依赖的提示出现在与它无关的构建上 |
| transform | Verbatim |
值是给人读的句子,不是路径也不是 flag |
| mustExistAfterRun | false |
值不是路径 |
| sinceProtocol | 当前 kProtocolVersion + 1 |
与 link-script 同法 |
打印形态:经 mcpp::ui::warning,前缀为包名。引擎知道是哪个包在说话,而构建程序
自己拼包名会在 workspace 里拼错。
构建程序的结果是被缓存的(write_cache / d <tag> <value> 行),命中时程序不再运行。
std::cerr 提示的失败形态的镜像:一条时有时无的提示看起来像「问题已解决」。
好消息是重放是免费的:serialize 按表序遍历、accept_cache_record 按 tag 派发,
所以只要 tag 非空,警告就随缓存一起写入并读回。
kCacheEpoch 必须递增。旧缓存条目没有 d warning 行,而语义变了。
Slot::Warnings加入枚举(在Count之前);kTable增加一行,数组长度 15 → 16;kProtocolVersion+1,kCacheEpoch+1;- 打印点:
build_program.cppm中dirs::apply(m, d)之前,且在缓存命中的早退路径上 同样经过; - 类型化 API
mcpp::warning(std::string_view)加入随附mcpp模块; - 文档:
docs/07-build-mcpp.md的指令表 +docs/13-baremetal.md板级包一节; - openarch / riscv-virt-rt 的构建程序改用它,把「一次性
xlings install」这句话从 README 移回它该在的地方。
必须同时满足,任缺其一视为未完成:
- 一个发
mcpp:warning=的构建程序,其文字出现在mcpp build的输出里,带包名前缀; - 同一个工程连续构建两次,第二次(缓存命中)仍然打印同一条警告 —— 这条是 §1.5 的 直接判据,且必须先看到它失败再看到它通过,否则无法区分「重放正确」与「缓存根本 没命中」;
- 一个 workspace 里两个成员各发一条警告,两条都出现且包名不同;
- 警告不使构建失败,
mcpp build退出码仍为 0; - openarch 模板在干净机器上
mcpp build后,用户看到的是「模拟器未安装,运行xlings install qemu-riscv qemu-arm qemu-x86 -y」,而不是mcpp run之后一条泛泛 建议。
build.ninja 图混叠的回归测试
先删产物或 touch 源码都会把缺陷盖住;一个从未被观察到失败过的测试,不能证明它在测
它声称的东西。
- 不做
mcpp:error=。 构建程序要报错,非零退出即可,而那条路径已经打印输出。 - 不做严重级别分层(note/warn)。 一个级别不够用之前不加第二个。
- 不做「警告即失败」开关。 那是消费者的策略,不是引擎的。
三个设计判断都成立,而兼容性一节写得比实际更悲观。
⭐ §1.3 说用了 mcpp::warning 的包在老引擎上「失败形态是 clang 的
『no member named』而不是 mcpp 的诊断」。错了 —— 引擎早有那个机制。
mentions_missing_mcpp_api 按命名空间 'mcpp' 匹配,所以新 API 自动被它
覆盖。实测(旧引擎 2026.8.20.3):
error: 'warning' is not a member of 'mcpp'
The `mcpp` build module this engine bundles does not have that name.
Either the package was written for a newer mcpp (try `mcpp self update`;
this is mcpp 2026.8.20.3), or the name is misspelled …
§0 的教训在本节内部又应验一次:凭读码不足以下结论,要去跑。
Finished dev in 0.00s),
根本到不了 build.mcpp 阶段 —— 断言它等于测了 fast path。回归测试必须先 touch
一个源文件。由此定下一条边界并写进 docs/07:全工程 no-op 构建什么都不打印,
包括这一条。
⭐ 判据 2 的「先看到它失败」真做了:临时移除缓存命中处的发射,第二条断言变 红而第一条仍绿。
采用侧:openarch 0.6.0 与 riscv-virt-rt 0.5.2。干净机器实测,而
riscv-virt-rt 那条归属到依赖而不是根 —— 包名由引擎传入,不由构建程序自己拼。
目标表四行裸机里,后两行的 C 库列为空。这一半是设计(零 libc 层),一半是索引里没有 这两个架构的 picolibc 构建。
xim-pkgindex/.agents/tools/build-baremetal-sysroot.sh(216 行)的形状:
PROFILES=( "rv64gc lp64d" "rv32imac ilp32" ) # march/mabi 对
… meson cross file: -target $triple -march=$march -mabi=$mabi -mcmodel=medany
布局:include/<march>/<mabi>/ # picolibc 自己的 multilib 约定
而且脚本注释里写明了两个 profile 的用意:「都需要,以证明布局可以推广 —— 单 profile 的树会把 profile 意外地烤进路径里」。
⇒ 这条路已经为「加第三、第四个 profile」铺好了。riscv 专属的只有两处:-mcmodel=medany
与 triple。
picolibc 对 aarch64-none-elf 与 x86_64-none-elf 的支持程度未经本项目测量。
aarch64 有上游端口;x86_64 的端口存在但受关注远少于 arm/riscv。
⇒ 第一步是一次最小测量而不是直接建包:用现有脚本的 cross file 形状,对两个 triple
各跑一次 meson setup + ninja,记录第一条错误(若有)。在这次测量之前,本节余下的
步骤都是条件性的。
std-freestanding 的 libstdc++ 一栏同法:判据是那份实现有没有为这个目标 configure 过。
这是本方案里唯一一个两种答案都站得住、且后果不同的地方。
| 填(与 riscv 一致) | 不填(保持零 libc) | |
|---|---|---|
| 默认行为 | 针对这两个 triple 的工程默认拿到 C 库 | 默认落在零 libc 层,要 C 库须声明 |
| 与 riscv 的一致性 | 一致 | 不一致,而不一致需要被解释 |
| 对 openarch 的影响 | 它引用不到任何 C 库符号,静态链接下未用对象不贡献任何东西 ⇒ 无功能影响,但它所处的层变了 | 无影响 |
| 「零 libc 层」的存在证据 | 四行里零行默认在该层 ⇒ 该层变成一个只在文档里存在的概念 | 两行默认在该层 |
我的建议:不填。 理由:
⭐ 零 libc 不是一个过渡状态,而是这一层的性质本身 —— openarch 引用不到任何 C 库符号
这件事,是它能同时服务三台机器的原因之一。如果四行里没有一行默认在该层,那么「零 libc
层」就没有任何东西在证明它可用。
与 riscv 的不一致有一个可陈述的理由:riscv 两行是 verified,且有一个写在 picolibc 上
的板级包;后两行是 preview,其第一个消费者在零 libc 层。⇒ 不一致反映的是成熟度
差异,不是随意。
- 测量 —— 两个 triple 各一次最小构建,记录结果;
- 把
-mcmodel与 triple 从脚本里的字面量提为 profile 的字段(riscv 用medany, aarch64/x86_64 用各自的默认或small); - 构建产物,按
picolibc-riscv的形状发布:xlings-res/<pkg>双端 + 每资产.sha256侧文件;⚠️ 判定上传成功只能靠回探下载并逐字节核验; - 写
xim:picolibc-aarch64/xim:picolibc-x86描述符,形状照picolibc-riscv(载荷是目标侧的,archs是宿主架构,一份归档服务所有宿主); - 文档:
docs/13-baremetal.md的四行表更新 C 库列的说明。
- 一个声明了该 C 库的工程,在对应 triple 上编出并运行一个用到
printf的镜像; - 不声明该 C 库的工程,其编译行与本方案落地前逐字节相同 —— 这是「不填行」这个
决策的判据,与
libcxx-headers的判据 4 同法(包已安装而载荷仍应赢); - 描述符的资产两端逐字节一致。
- 不追 newlib / musl 的裸机变体。 一个实现先跑通,第二个由需求驱动。
- 不为 arm32(
arm-none-eabi)开新行。 那是新目标而非本项范围。
§2.3 要求先测量。测量结果推翻了「可能只有 aarch64 成立」的预期:两个都成立
(libc.a 7,070,160 / 6,458,288)。
构建器泛化为一个脚本三个族(而不是加第二份拷贝 —— 那正是 §4/R2 要防的)。 加两个族只暴露了三条 riscv 两年没暴露的缺陷:
| 缺陷 | 症状 | riscv 为什么没暴露 |
|---|---|---|
稀疏检出少 third-party |
'siphash/SipHash.h' file not found |
emupac.cpp 是 aarch64 专属 |
没设 CMAKE_CXX_COMPILER_TARGET |
unknown register name 'x30' in asm |
riscv 的 builtins 全是 C 与汇编,没有 C++ 源 |
| 链接脚本默认加载地址 | 链接成功,一跑就挂 | 0x80000000 是 riscv virt 的 RAM 基址 |
⭐ 第三条最值得记:它链接成功,只表现为机器不动。给 qemu 4GB 内存就跑起来 —— 假设由此被证实。一个只在配置慷慨的机器上才工作的加载地址,是最坏的一种错: 它通过。
-fuse-ld=lld 看起来解决了;
把 PATH 收到只剩 coreutils 后裸名与 -B<llvm>/bin 两种都倒在
collect2 ... [cannot find ld] —— 之前的通过是环境里恰好有 lld。真解与
mcpp 引擎对这个 triple 的做法逐字相同:把链接从驱动手里拿走。
判据:aarch64 的 printf 镜像在 qemu virt 默认 128MB 下链接并运行;
x86_64 在 0x100000 链接成功(undefined symbol: stdout 是正确答案)。
.sha256 侧文件,表现为
HTTP 404。
§2.4 的决策按建议执行:目标表的行没有填。 这两个包是可声明的,不是默认的。
上面那句「可声明」在写下时还不是真的,而我报告 §2 完成时没有验证它。
验证用的是手工 -isystem / -L,它跳过了整条解析链。补验受支持的路径:
[target.aarch64-none-elf]
sysroot = "xim:picolibc-aarch64@1.8.12"'stdio.h' file not found
⭐ 成因:picolibc 是 multilib 布局(include/<march>/<mabi>/),而引擎从
src/freestanding/target.cppm 的 libdir 列推那个子目录 —— 那两行的 libdir
是空的。 而在索引里没有对应 picolibc 之前,空是对的:它命名的是一个当时
不存在的东西。
A/B:
| 引擎 | 结果 |
|---|---|
| 2026.8.21.3 | -isystem …/include/armv8-a/aapcs,头解析成功 |
| 2026.8.21.2 | 'stdio.h' file not found |
(链接仍缺 printf,因为裸工程没有 crt0 —— riscv 在同样条件下同样如此,那是板级
包的活。)
libdir 只在 sysroot 已被解析后才被读,而目标表对这
两行仍不绑定任何 C 库 ⇒ §2.4 的决策不受影响,零 libc 仍是默认,包仍是可选加入。
单元测试把这一点也断言了。
libdir 为空,而它当时是对的。事实变了,测试跟着改
并在注释里写明它为什么曾经正确 —— 结论会被复查,理由不会。
xlings install picolibc-aarch64 报「✓ 1 package(s)
installed」而安装目录为空。成因是我自己先前用 local-index 测过同名包,
xim: 安装撞上了(它的提示写着 still resolves to local:1.8.12)。清掉重装即
正确 —— 是测试顺序的产物,不是包的缺陷。
⇒ 判据教训写在这里,因为它不属于任何一节而属于全部四节:「资产已发布并双端 核验」与「使用者能通过受支持的路径用上它」是两件事,而只验证前者会把整条解析链 跳过去。
板级包模型目前只被 一块板验证过(riscv-virt-rt,qemu virt)。
⭐ 这与 openarch 在两台 RISC 机器上的处境完全同形:一个只服务过一块板的抽象,分不清 它是对的还是它只适配了那块板。
riscv-virt-rt 同时固定了三件事:ISA(riscv64)、有 C 库(picolibc)、
模拟器板(qemu virt)。
按 openarch 的方法论,第二块板应当变的是前两件:
⇒ 建议:aarch64-virt-rt —— 不同 ISA,且写在零 libc 层上。
理由:
- 不同 ISA 才能让「板级包」与「机器机制」的边界被检验。同 ISA 的第二块板(如
sifive_u)只变第三件,得到的证据最弱; - 零 libc 检验的是一个此前从未被问过的问题:「板级包」与「C 库提供者」是不是两个
可分离的角色?
riscv-virt-rt的注释里写着它「曾经负责定位 picolibc 并交出它的 include 与 library」,后来不再 —— 这说明这两个角色已经在被分开的路上,而一块零 libc 的板会给出它是否真的分开了的答案; - 它不依赖 §2。若依赖,两项就串行了。
riscv-virt-rt 的构建程序发出的指令:runner(绝对路径的模拟器 + argv)、
link_script(virt.ld)、以及 crt0 的选择。crt0-semihost 经调试通道到达宿主,没有该通道的板选 UART crt0。
⇒ 零 libc 的板需要自带最小 crt0(设栈、清 bss、调 main),而这正好是它要证明的东西。
mcpp new x --template aarch64-virt-rt生成的工程mcpp run打印预期行;- ⭐ 两块板的构建程序发出的指令集合相同,只有值不同 —— 若第二块板需要一条第一块板 没有的指令,那说明板级包的接口不完整,而那正是这一项要发现的东西;
- 该板不声明任何 C 库,且工程仍能构建并运行。
- 不上真实硬件。 CI 里没有硬件,而一个只能在作者机器上验证的板级包,其判据无法被 他人复核。真实硬件是第三块板的事。
⭐ 判据 2 达成,而且比预测的更强。 §3.4 预期「两块板的指令集合相同」,并预留
了它可能失败。由两个构建程序自己的 build.mcpp.cache 的 d <tag> 行实测 ——
那是引擎记下的「发出过什么」,不是读源码:
riscv-virt-rt ldflag runner
aarch64-virt-rt ldflag runner
同样两条。 C 库体现为 ldflag 的值不同:
riscv-virt-rt -lcrt0-semihost -lc -lsemihost
-lclang_rt.builtins-riscv64 -T …/picolibcpp.ld
aarch64-virt-rt -T …/virt.ld
⇒ 「板级包」与「C 库提供者」可分离,板级包接口完整。 CI 把它写成硬断言对着 字面量比,所以任何一侧变了都必须来说明。
§3.3 预判「零 libc 的板需要自带最小 crt0」是对的,而它的性质比预判更准确:
那是内容的差别,不是接口的差别 —— 包多了一个 boot.S,指令一条没多。
.bss 不被任何别的东西清零(-kernel 只加载 PT_LOAD 段),而那种失败
数据相关、不会在写代码的机器上复现 ⇒ 例子断言 bss zeroed 而不是假设它。
| 事故 | 为什么看不见 | |
|---|---|---|
| 0.4.0 | 模板断言放在不装模拟器的作业里 | 物理上只可能断言 mcpp build;一个「三台都编得过、一台都跑不起来」的工程通过了 |
| 0.5.0 | 修好后放在 gate 的一行上 |
每行只装自己那台机器的模拟器 ⇒ 能支持的主张只有「模板在 riscv64 上能跑」 |
| qemu-x86 | 闭包检查 ldd … 2>/dev/null | grep -v <载荷> |
ldd 崩在 stderr ⇒ 空输出与「没有越界」不可区分 |
⭐ 共同形状:断言必须放在能观察到所要主张之现象的环境里,而矩阵的每一行是一个独立的 观察环境。 前两次把这条规则执行到了作业一级,没执行到行一级。
R1 —— 矩阵作业里,步骤名含字面架构/目标名的,是嫌疑。
name: The probe runs on x86_64 # 嫌疑:矩阵里为什么只提一个?
name: The probe runs on ${{ matrix.arch }} # 由构造保证逐行
可 grep:在含 strategy.matrix 的 job 内,步骤 name 或 run 里出现矩阵取值集合中的
字面量而未使用 ${{ matrix.* }}。
R2 —— 一处逻辑的两份拷贝必须逐字节相同,并由 diff 断言。
0.5.1 已对 examples/switch/build.mcpp 与 templates/three-machines/build.mcpp.in
落地此法。规则可推广:仓内声明一张「必须一致的文件对」清单,CI 逐对 diff。
R3 —— 空输出不得被当作通过。
任何形如「列出违规项,期望为空」的检查,必须同时断言被检查的集合非空:
n=$(LD_TRACE_LOADED_OBJECTS=1 "$loader" "$bin" | wc -l)
[ "$n" -ge 5 ] || { echo "闭包检查只看到 $n 个对象,工具没有工作"; exit 1; }⭐ 这条是三条里最一般的:「没有发现问题」与「没有去看」在输出上完全相同。
一个 tools/lint-ci-assertions.sh,在 mcpp 仓内实现并作为可复用工作流导出,生态各仓
引用。
- 把 0.4.0、0.5.0 的两份历史工作流喂给 linter,两份都必须被标出;
- 把修好后的当前工作流喂给它,不得报警;
- R3 的检查对一个故意坏掉的工具(把
ldd换成一个只写 stderr 的脚本)必须失败。
tools/lint-ci-assertions.sh 已落地并接进 mcpp 的 CI(打印,不失败)。
0.5.0 的缺陷是可 lint 的:if: matrix.arch == 'riscv64' 把一个步骤门到矩阵的
一行上,一行文本就说明白了。0.4.0 的缺陷不是:那一步只断言了 mcpp build,
也只声称了这么多 —— 文件里没有任何东西是错的,它只是比仓库在别处做出的主张更
弱。⭐ linter 检查写下来的东西,不检查没写的东西。
新增 R4 抓的是同一个形状的未来版本(把执行断言放在跑不了东西的作业里), 这是这个机制能给的全部。该限制写在工具头部第一段,以免下一个读者去找一条从不 运行的路径。
两条规则在跑过之后被收窄,两次都是误报率决定的:
- R3 原本对任何
2>/dev/null | grep报警。而cmd 2>/dev/null | grep -q .在机器看来一模一样,做的却是相反的事 —— 它断言输出非空,正是本规则要求的。 收窄到只对-v/-c/ 取反管道(按缺席判定)报警。 - R4 原本对任何
mcpp run报警,在六个完全正确的作业上误报:宿主目标的mcpp run不需要模拟器。收窄到只对 freestanding 目标。
对 mcpp 自身跑,剩一条真的:nm "$BIN" 2>/dev/null | grep -c 在 nm 失败
时打印「0 (good)」。已修成可区分两种情况。
⭐ R2 已在 openarch 上落地并被反向验证过:.ci-identical-files 声明「示例与
模板的构建程序自 // The emulator. 起逐字节相同」,故意弄坏一侧,它报了。
/ —— sed 把它当模式结尾,报 unknown command: '/',两边都空,
diff 通过。改用 grep -F + tail,并断言两侧非空。
结构已支持:backend-riscv64 命名的是后端而不是架构。需求尚未出现 —— 现有后端陷入
M 态,而 SBI 之下的内核陷入 S 态。
⇒ 在有一个真实的 S 态消费者之前不做。 没有消费者的第二个后端,与没有第二块板的板级 包模型同病:分不清它是对的还是它凑合了。
§1 warning 通道 ──────────────────┐ (独立,改 mcpp)
│
§2 目标 C 库 ── §2.3 先测量 ──────┤ (独立,改 xim-pkgindex)
├──> §3 第二块板(不依赖 §2,因为它在零 libc 层)
§4 CI linter ─────────────────────┘ (独立,改 mcpp + 各仓引用)
四项互不阻塞,可并行。唯一的内部串行是 §2.3 的测量必须先于 §2.5。
- 不重构指令表。 §0 的读码显示它已经是唯一真源,加一行就够;
- 不改协议门的语义。 「未知指令是硬错误」是对的:warn-and-ignore 会把缺失的指令变成 一个静默不同的构建;
- 不为兼容性发明新机制。 §1.3 的代价模型由
link-script在协议 3 建立,沿用即可; - 不把 CI 规则做成硬门。 见 §4.3。