Skip to content

Latest commit

 

History

History
809 lines (588 loc) · 42.1 KB

File metadata and controls

809 lines (588 loc) · 42.1 KB

裸机生态的四项未完成事项:方案

2026-08-21。对象是 2026-08-20 那一批实施之后仍然敞开的四件事,记录于 2026-08-20-freestanding-ecosystem-positioning.md 第 12 节。每一项都已定位到具体位置,本文给出修法、判据与代价。

四项之间没有依赖关系,可以并行,也可以只做其中任意一项。它们的价值差别很 大,第 5 节给出排序与理由。


0. 一处必须先纠正的记录

前一份文档与若干提交信息里写着「Windows 的 LLVM 载荷不带 libc++ 头,已由 CI 测量」。该测量不成立,纠正如下。

std-freestanding 的 Windows 作业里那一步打印的是:

payload: /c/Users/runneradmin/.mcpp/registry/data/xpkgs/xim-x-llvm/*/
  (no include/)
  include/c++ : absent
  share/libc++: absent

⚠️ 第一行的 glob 没有展开。那说明该路径下不存在任何目录,也就是说这一步 检查的位置本身就是错的——Windows 上 mcpp 的 home 与我在脚本里拼的 $HOME/.mcpp/registry 不是同一处。它测到的是「我猜的路径不对」,而不是 「载荷里没有那些头」。

已经成立的事实只有一条:该行的构建死于 src/std_freestanding.cppm:19:10: fatal error: 'algorithm' file not found。 这说明 clang 被解析到了,而 libc++ 的头不在 mcpp 交给它的 include 路径上。 至于载荷里究竟有没有那些文件,以及 mcpp 指向了哪里,尚未被观察过。

⇒ 第 2 节的第一步是补上这个测量,而不是直接动载荷。


1. mcpp testmcpp build 对 feature 源的判断不一致

已修复(mcpp 2026.8.21.1)。 ⚠️ 而修法既不是路线 A 也不是路线 B —— 是第三次 尝试的判据,它当初被以一个错误的理由放弃了。见 1.3.1。

1.1 现象与位置

同一个包、同一份清单,两条命令编译的源文件集合不同:

命令 未激活 feature 的源
mcpp build 不编译(正确)
mcpp test 编译

实测于 mcpplibs/riscv-virt-rt:src/kal/** 只由 openkal feature 命名, mcpp build --target riscv64-none-elf 正确略过它,而 mcpp test --target riscv64-none-elf 编译它并死于 'openkal/abort.h' file not found——那个头由该 feature 的 [feature-deps] 带来,feature 没激活自然不在。同一缺陷经 path 依赖传到了 mcpplibs/std-freestanding

位置在 src/build/prepare.cppm,feature glob 的处理分两半:

if (!bc.featureSources.empty()) {
    if (!includeDevDeps) {          // ← DROP 只在非 test 模式跑
        ...把未激活 feature 的 glob 关掉,并追加 `!` 排除...
    }
    ...ADD 在两种模式下都跑...
}

includeDevDeps 表示的是「这次构建包含 dev 依赖」,也就是 test 模式。

1.2 为什么那个条件当初是对的

引擎里的注释写明了:gtest 把 gtest_main.cc 同时列在 main feature 与 base sources 里,而 dev 依赖那条轨道的「逐测试 main 探测」需要看得见 它才能逐个测试地把它剪掉。! 排除会让它消失。

所以这不是有人忘了,而是一个真实约束被用一个过宽的条件表达了。

1.3 ⚠️ 三次修法,全部被测量证伪

尝试 结果
DROP 与 ! 排除在两种模式下都跑 弄坏 gtest:mcpp 自己的单测链接失败,ld returned 1 exit status
test 模式只删 glob 字符串、不加 ! 排除 无效:src/kal/** 从来不在 bc.sources 里,删字符串什么都没删,文件仍由 base glob src/** 匹配
按「该 glob 是否也出现在 base sources」判别 记录为无效,理由是「gtest 的 base 是一条能匹配到那个文件的 glob,不是同一个字符串」。这条记录是错的 —— 见 1.3.1

三次都已撤销。不留半个修复:把一个只在部分情形下正确的门放进已发布的 引擎,比一个有文档的缺陷更糟。

1.3.1 ⚠️ 第三次尝试的判据是对的,被放弃的理由是错的

去读索引真正携带的描述符 pkgs/c/compat.gtest.lua:

sources  = { "*/googletest/src/gtest-all.cc",
             "*/googletest/src/gtest_main.cc" },   -- 第 71–73 行
features = { ["main"] = { sources = { "*/googletest/src/gtest_main.cc" } } },  -- 第 90 行

两处逐字节相同。所以「字符串相等判别不到」这句话与事实不符,而当初把它写进 文档时没有去读那个文件 —— 判据被一个凭印象写下的理由否掉了。

真正让第三次尝试失效的是判据被施加的时机:membership 是拿 bc.sources 去比 的,而 drop() 已经先一步把那条 glob 从里面删掉了,于是这个测试只可能为假。

修法:在 drop() 之前把 base 的 glob 集合快照下来,并且只在 test 模式使用 它 ——

  • glob 同时出现在 base sources 与 feature 里 ⇒ 这是一个(gtest 那一族), test 模式下保持可见,逐测试 main 探测仍然剪得掉它;
  • glob 只出现在 feature 里 ⇒ 这是一个提供者(riscv-virt-rt 那一族), test 模式下也要 ! 排除。

⚠️! 排除才是整个机制,删 glob 字符串不是:src/kal/** 从来就不在 bc.sources 里(该包根本没声明 sources),它的文件由推导出的 src/** 匹配。

1.4 正确的修法

三次失败指向同一个结论:判据不是「这条 glob 字符串在不在 base 里」,而是 「base 的任何一条 glob 是否匹配这条 feature glob 所匹配的文件」。前者是 字符串比较,后者需要 glob 展开。

两条可行路线:

路线 A(引擎内展开)。 在 feature 门这一处把两侧都展开成文件集合,按 文件而不是按 glob 决定:

featureFiles = expand(feature glob)
baseFiles    = expand(base globs, 不含 feature glob)
对每个 f ∈ featureFiles:
    若 feature 未激活:
        若 f ∈ baseFiles → 保留(gtest 那一族:包无条件提供、feature 只是门)
        否则             → 排除(riscv-virt-rt 那一族:包只经 feature 提供)

代价:这一处目前只做字符串操作,引入文件系统展开会把它与扫描阶段耦合起来, 并且要考虑展开的缓存与顺序稳定性。

路线 B(让包表达意图)。 在 manifest 里区分两种 feature 源:

[features]
main    = { sources = ["src/gtest_main.cc"], gate-only = true }   # 包无条件提供,feature 只是门
openkal = { sources = ["src/kal/**"] }                             # 只经 feature 提供

代价:新增一个清单键,而这正是本次实施一直在避免的事(见定位文档第 7 节 「优雅」那一行)。但它把一个引擎猜不出来的区别交给知道答案的人来写。

建议先做路线 A,因为路线 B 要求每个既有包回头补一个键,而在补之前 行为不变——也就是说 B 不修复任何现存的包,A 修复。若 A 的展开代价被证明 过高,再退回 B。

1.5 判据

必须同时满足,任缺其一即视为未修复:

  1. mcpplibs/riscv-virt-rt 不带 feature 时 mcpp test 不再编译 src/kal/**,两条命令都以 0 退出(此前 test 死于 'openkal/abort.h' file not found);
  2. ✅ mcpp 自己的 92 个单测仍然链接并通过(gtest 那一族);
  3. tests/e2e/138_feature_sources_gate_vs_provider.sh,两个最小包分别表达 上面两族,不依赖任何生态包。

⚠️ 第 3 条是重点。这个缺陷之所以活到今天,是因为没有任何测试同时覆盖两族; 只修不测,下一次同样的改动会再次在两者之间摇摆。

⭐ 那条 e2e 已按「回归测试必须先失败」验证过:拿修复前的引擎跑它,它以正是本节 开头那句症状失败;拿修复后的引擎跑它,它通过。

1.6 副作用

修好之后,mcpplibs/riscv-virt-rt CI 里那句 mcpp test --features openkal--features 可以去掉,但不建议去掉: 一个包测自己发布的 feature 本来就是对的,与这个缺陷无关。


2. Windows 的 LLVM 载荷与 libc++ 头

2.1 先补测量

在动任何东西之前,先回答两个尚未被观察过的问题:

  1. Windows 的 LLVM 载荷里到底有没有 include/c++/v1share/libc++/v1?
  2. mcpp 在 Windows 上把 toolchain_dir() 指向了哪里?

判据不能再用猜出来的 home 路径。正确的问法是问 mcpp 自己:

- name: What this payload carries, asked rather than guessed
  run: |
    mcpp self env                      # 打印 mcpp 的 home 与工具链路径
    tc="$(mcpp self env | sed -n 's/^toolchain: *//p')"
    ls "$tc/include/c++/v1" 2>/dev/null | head -5 || echo "include/c++/v1 absent"
    ls "$tc/share/libc++/v1" 2>/dev/null | head -5 || echo "share/libc++/v1 absent"

⚠️mcpp self env 不输出工具链目录,则先补这一条输出——一个构建程序能 问到的东西,诊断也应当能问到。

2.2 ⚠️ 一处需要收回的论证

本文第一版在这里写道:独立成包会引入「索引今天没有表达机制」的跨包版本约束, 所以应当把头加进现有载荷。

那个前提是错的。 一条带版本要求的依赖边就是那个机制,而 std-freestanding 自己已经在用它——[feature-deps.nolibc] 里写着 std-freestanding-nolibc = "^0.2.0"。我把「描述符里的注释」与「依赖边」混成 了一件事,而这两者恰恰是本次实施反复区分过的东西:诊断里写死版本字面量那次, 教训正是注释维持不住跨仓库不变量,而依赖边可以。

收回该论证之后,取舍要重新做,而重新做的结果与原结论相反。

2.3 重新提问:freestanding 的支持应当与标准库无关

标准把 freestanding 子集定义成一张表,任何符合的实现都提供它。因此一个 名为「C++ 标准库的 freestanding 子集」的包,其接口理应与具体实现无关, 而今天它不是:它合成 libc++ 的 __config_site、读 libc++ 的头。

把这件事拆成三层,归属就清楚了:

内容 与实现有关吗
接口 包含哪些标准头、导出哪些标准名 无关,标准定义
配置 如何让一个实现进入 freestanding 状态 有关,而且比原先写的大:见 2.3.1
头从哪来 那些文件本身 与宿主无关,但今天从宿主的编译器载荷里借

2.3.1 ⚠️ 「libstdc++ 一个 -D_GLIBCXX_HOSTED=0 就行(29/34)」是错的

本文原先把配置层写成「有关,但很小」,并给了 libstdc++ 29/34 这个数字。重测之后 那个数字站不住,而站不住的方式说明了真正的问题在哪。

同一批 40 个 C++23/26 freestanding 头,同一份 libstdc++ 16.1.0,同样加 -D_GLIBCXX_HOSTED=0,只换目标:

目标 结果
x86_64-linux-gnu(宿主) 40 / 40 编过
riscv64-none-elf(裸机) 0 / 40,全部倒在同一处
bits/c++config.h:733:
bits/os_defines.h:39:10: fatal error: 'features.h' file not found

⇒ 原来那个 29/34 是在宿主目标上量的。它证明的是「libstdc++ 支持 freestanding 模式」,而不是「libstdc++ 能服务一个裸机目标」—— 而后者才是这个包的用途。

于是问题的提法要改:重点不是「用哪个标准库」,而是「那份标准库有没有为这个目标 configure 过」。

实现 逐目标配置是什么 能不能合成
libc++ __config_site —— 一个扁平的宏列表 。本仓库的 regenerate.sh 就是用 sed 改十行得到交叉目标的配置
libstdc++ bits/c++config.h —— configure 的产物,且 #include <bits/os_defines.h><features.h>,把宿主 C 库拖进来 不能。要给裸机目标用,需要一份为该目标 configure 出来的 libstdc++,即一个 riscv64 的 gcc
MSVC STL UCRT / vcruntime 头 ⚠️ 未测量。 本机没有 MSVC 载荷,不能声称验证过。它属于同一类问题,而这只是分类不是结论

⇒ 选 libc++ 不是因为它「更 freestanding」,而是因为它的逐目标配置是唯一一个可以 被合成出来的。这条理由比原先写的「配置层很小」更准确,也更有用:它说明换实现解决不了 Windows 的问题。

第三层才是 Windows 问题的所在,而它与「用哪个标准库」无关。

在 Windows 宿主上交叉编译到 riscv64-none-elf 时,宿主的标准库是什么不重要: MSVC STL 对 riscv 裸机不可用,不是因为它是 MSVC 的,而是因为它下面需要 Windows CRT 而那个目标上没有。能用的只能是头与宿主无关的实现——libc++ 或 libstdc++——而两者在那份载荷里都不在。

2.4 ⭐ 结论:目标的 C++ 标准库应当像目标的 C 库一样解析

本次实施已经把同一个教训学过一遍。板级包曾经硬编码 picolibc,修法不是让 板级包更聪明,而是把 C 库变成目标的属性:

[target.riscv64-none-elf]
sysroot = "xim:picolibc-riscv@1.8.12"     # 目标的 C 库,与宿主无关

std-freestanding 今天从 mcpp::toolchain_dir() 借 C++ 标准库的头,是同一类 错误:它把「目标需要一份 C++ 标准库」写成了「宿主的编译器恰好带着一份」。

⇒ 修法与当年一致:让它成为可解析的东西,而不是借来的东西。 三步,每步 可独立落地:

步骤 1:std-freestanding 增加「头从哪来」的解析顺序。

1. 工具链载荷带了就用          ← 今天 Linux 与 macOS 的路径,不变
2. 否则用声明的包

第 2 条是一条普通依赖边,版本约束由它承载:

[dependencies]
libcxx-headers = "22.1.8"     # 与产出导出表的那个 libc++ 同版本

⚠️ 只有第 1 条落空时才走第 2 条,所以 Linux 与 macOS 的解析路径一字不变—— 这是「无感升级」那一栏的要求。

步骤 2:新建 xim:libcxx-headers

内容是 include/c++/v1share/libc++。复测于 llvm 22.1.8 的载荷:

include/c++/v1 16 MB
share/libc++ 624 KB
打包后(tar + xz -9) 943,180 字节

它与宿主无关,因此一个包服务全部五个宿主,而「加进载荷」要给每个缺失的宿主 各加一次。

⚠️ 这个包尚未上传,而不上传是有意的。 它今天可以打出来,但没有任何东西验证过 它在 Windows 上确实让 std-freestanding 工作 —— 步骤 1 还没做。把一个未经验证的 产物先镜像出去,只是让「未经验证的东西」传播得更快。这与 mcpplibs/qemu-x86 那边 定下的顺序是同一条:先跑通,再发布

步骤 3:实现层的选择成为可声明的。

接口层不变,配置层按检测到的实现分支:

有 include/c++/v1        → libc++    :合成 __config_site
有 bits/c++config.h      → libstdc++ :-D_GLIBCXX_HOSTED=0
两者皆无                  → 具名诊断

⚠️ libstdc++ 那条实测只到 29/34(缺的五个是 GCC 16 未实现的 C++26 新增项), 所以它是一条真实但更窄的路径,应当照实说明而不是宣称等价。

2.5 两条路的对比

加进 Windows 载荷 独立成包
体积 每个缺失宿主 +0.9 MB 一个包服务全部宿主
版本约束 天然同版本 由依赖边承载(收回的那条论证以为不行)
谁能改 ⚠️ 打包脚本不在我们仓库,是外部依赖 我们自己能做完
概念 「宿主编译器顺带带一份」 「目标需要一份」,与 C 库同构
对 libstdc++ 路径 无帮助 同一套机制可以再加一个头包

改选独立成包。 决定性的不是体积,而是后两行:它是我们能自己做完的, 并且它把这件事放回了本次实施已经证明正确的那个位置——目标需要什么,由目标 解析,而不由宿主的编译器顺带提供。

⚠️ 「加进载荷」仍值得作为补充提给上游:一份带 libc++ 的 Windows 载荷会让 第 1 条解析路径直接命中,连依赖边都不需要。但它不该是主路径,因为它把一个 我们能解决的问题变成了一个要等别人的问题。

2.6 判据

  • 步骤 1 落地后,Linux 与 macOS 的构建一字不变;
  • Windows 行从「测量缺口」翻转为真实交叉构建,其自退役断言主动失败;
  • 两侧判据:把工具链载荷里的 include/c++/v1 藏起来,构建仍应通过(走 第 2 条);把包也拿掉,应给出具名诊断而不是 'algorithm' file not found

⚠️ 第三条是重点。这个能力若只在 Windows 上被用到,就只有一台机器测过; 「拿走再装回来」是唯一能在任何机器上验证它的判据,与 picolibc 那条安装边的 教训一致。


3. openarch:三台机器,一个包两个门面,后端由 feature 选择

本节已由 0.4.0 落地。 下面记录做成了什么、以及两处本方案原先判断错了的地方。

3.1 已完成(0.4.0)

四个接口 —— contexts、页表项、trap、per-CPU 与屏障 —— 覆盖三个指令集: riscv64、aarch64、x86_64。一份探针源码在三台机器上构建并运行,输出逐字节相同。

第三台机器是把门槛变成证据的那一步,而本方案原先低估了它。 §4 把 x86_64 当作「模拟器载荷问题」,并把它排在最后;实际上 riscv64 与 aarch64 都是弱内存序、 定长指令的 load/store RISC 机器,一个同时适配两者的接口可能是因为它对,也可能 是因为它们像,而在这两台机器上再怎么测也分不开这两种情况。x86_64 两样都不是: 变长指令;total store order(四条屏障里三条不需要任何指令);中断机制是 256 个门 的表;控制台由 out 到达,没有任何指针能命名它。

它挖出了三条,而每一条都是两台 RISC 机器合起来也看不见的:

  1. MAIR_EL1 那个决定不再是 aarch64 的例外。 两台机器时是一比一,「本层拥有 属性寄存器」还可以被称作 aarch64 的权宜。x86_64 的 PWT/PCD/PAT 三个分散 的位同样构成 IA32_PAT 的索引 —— 现在是二比一,方向反了过来。 ⚠️ 而且它的规则更严。 未编程的 MAIR_EL1 字段读作最严格的类型,过早的 aarch64 映射只是慢而正确;IA32_PAT 的复位值在索引 1 上是 write-through, 过早的设备映射是被缓存的 —— 写在程序没有选择的时刻到达设备,不触发任何异常。
  2. pc 在每台机器上并不指同一件事。 两台 RISC 机器都报告出错指令的地址; x86_64 把异常分为 fault(如此)与 trap(报告下一条的地址),而 int3 ——instr_len 正是为跨过它而存在的断点 —— 是 trap。后端做归一化,于是 f->pc += f->instr_len 在三台机器上都恢复到同一处。
  3. 接口的一条承诺在这台机器的页表项里无法表达。 riscv 用 U 限定 X, aarch64 有独立的 PXN/UXN;x86_64 只有一个覆盖全部特权级的 NX,该规则改由 CR4.SMEP 提供。

0.3.x 的第三条发现仍然成立:trap_frame 必须带 instr_len 实测 rv64gc:

cause:3 desc=breakpoint          epc:0x800001f2
cause:2 desc=illegal_instruction epc:0x800001f6   ← 无限重复

0x800001f2 不是四字节对齐 —— C 扩展是 rv64gc 的一部分,汇编器发的是两字节的 c.ebreak。aarch64 只有一种指令宽度,永远暴露不了这一条。

3.2 目录树:混合式的根,以及它顺带修好的一条

openarch/
├── mcpp.toml              [package] openarch  兼  [workspace]
├── src/                   C++ 门面 —— 模块 mcpplibs.openarch 再导出四个
├── tests/                 两个门面必须一致的地方
├── abi/                   契约 —— 只有头文件,不依赖任何东西
│   ├── mcpp.toml          openarch-abi
│   └── include/
│       ├── mcpplibs/openarch.h   C 门面,全部
│       └── openarch/
│           ├── types.h    宽度,写一次、断言一次
│           ├── abi.h      后端要实现的东西
│           └── pte_encode.h   纯编码器,宿主可调用
├── backends/              每个指令集一个包,都 provides "openarch-backend"
│   ├── riscv64/  aarch64/  x86_64/
└── examples/switch/       一份探针源码

⚠️ 本方案原先写的是「根是 [workspace],接口在 spec/ 成员里」,那个形状是错 的,而错在一条它自己没有预见的地方。 portability 作业在仓库根跑 mcpp build --target riscv64-none-elf,虚拟 workspace 会对所有成员扇出 —— 于是 aarch64 汇编被喂给 riscv 汇编器:

unrecognized instruction mnemonic, did you mean: sra, srl?

混合式的根(同时是 [package][workspace])修好了它:根现在是接口包,构建 它只拉入该 target 的后端。这个形状同时也是消费者只写一行依赖的原因 —— 虚拟 workspace 会让 openarch = "0.4.0" 不得不指名成员目录。

3.2.1 两个门面

消费者写一行依赖,然后二选一:

#include <mcpplibs/openarch.h>   /* C,以及想要 C 名字的 C++ */
import mcpplibs.openarch;        // 四个模块,再导出

⭐ 两者是一个库的两种拼写,不是两份互相对齐的声明:模块的 trap_frame 就是 ::arch_trap_frame(using,不是同形体),枚举由契约的枚举量定义而来 —— illegal = ARCH_TRAP_ILLEGALtests/faces.cpp 检查的是推导而不是一致性, 后者是更弱的东西:「两边都是 2」今天成立、明天可能不成立,唯一维持它的是有人同时 改两处。

3.2.2 后端由 feature 选择

三个曾由一个机制回答的问题被分开了:

消费者要什么 清单里写什么
本 target 的后端 openarch = "0.4.0"
指定某一个 default-features = false, features = ["backend-riscv64"]
自己实现 default-features = false, features = ["backend-external"] + 一个 provides = ["openarch-backend"] 的包

backend-external 不指名任何包,而是 require 能力;图里没有提供者时构建在 configure 阶段停下并说明,而不是在链接期报出一个改过名的符号。这与 std-freestanding 的分配器同形,于是生态里「一个可被替换的默认实现」只有一种 写法而不是两种。

⚠️ backend-auto 刻意不 require 该能力,而第一版让它 require 了。 feature 是 可加的,而 requires 是无条件的 —— 哪怕满足它的 feature-deps 是 target 条件化 的。于是本包自己的宿主测试无法构建:

error: no package provides capability 'openarch-backend' required by 'openarch'

宿主目标没有后端是关于目标的事实,不是消费者能处理的错误。

3.2.3 类型集中到一处

openarch/types.h 定义 arch_u32/arch_u64/arch_uptr断言它们的宽度。 此前每处用点各自拼出 unsigned long long,顶上一段注释解释为什么不是 unsigned long —— 一条被描述而从未被检查的规则。它唯一一次被违反(1UL << 53) 是靠运气发现的:那个移位恰好在 constexpr 里,编译器被迫求值。

⚠️ arch_uptr 断言为八字节。页表项在每台机器上都是 64 位(包括 32 位机器), 指针不是,而 riscv32-none-elf 是本仓库打算到达的目标。断言指针是八字节会在今天 测过的每台机器上通过,而那正是 openkal 在 fs.h 里犯过的错。

3.2.4 ⚠️ CI 从 0.3.0 起一直是红的,而我此前报告过它是绿的

两处,都是我写的断言把「意图」和「它实际匹配的模式」搞混了:

  1. 「探针不按架构分支」这条太宽。 它 grep __riscv|__aarch64__,而探针必须 在恰好一处指名架构 —— 陷入指令,ebreak / brk #0 / int3 是同一个想法的三种 拼写,没有可移植的第四种。trap 接口在 0.3.0 落地时这条断言就开始失败,按它自己 的字面是对的、按它的意图是错的,而它一直红到 0.3.1 因为没有人去读那些 run。 收窄为:一个条件块,块内除指令外别无他物。
  2. portability 作业的扇出问题,见 §3.2。

3.3 仍未做:时钟,以及它是否属于这一层

⭐ 建议先作为调研而不是实现排期。

riscv 的 mtime/mtimecmp 是内存映射的,且属于平台而非 ISA;可移植的那 一层是 SBI 的 set_timer。aarch64 的通用定时器是 CSR。若 riscv 上可移植的 时钟必须经过 SBI,那它属于 openkal 的实现方而不是 openarch,而这会改变 分层。

调研的判据:在 -bios none(M 模式)与 -bios default(S 模式 + OpenSBI) 两种启动方式下各写一个最小时钟探针,看 M 模式那份能否不经 SBI 完成,以及 S 模式那份是否只能经 SBI。两个答案决定接口归属,而调研比实现便宜得多。

3.4 不做的事

  • 不加第三个架构来「验证」这些接口:门槛的定义是第二台机器;
  • 不实现页表走查:构造表项是机制,决定表项放在哪里是策略。

4. x86_64 裸机目标

本节的阶段 4 已由 mcpp 2026.8.21.1 提前落地,而落地过程推翻了本节的一个前提。 阶段 1–3(xim:qemu-x86 的构建与收录)仍然未做,分析依旧成立。

4.0 ⚠️ 「不是代码,是模拟器载荷」这句话是错的

本节原先断言目标行本身没有工作量,只差一个模拟器。实测下来目标行需要引擎 代码,而原因是 clang 的属性、不是指令集的属性。

clang 由 triple 选工具链。它为 arm / aarch64 / riscv 备有 BareMetal 工具链, 直接以 ld.lld 链接;它没有 x86_64 的,于是裸 x86_64 triple 的每一种写法都 落到通用 GCC 工具链上 —— 而后者的链接器是宿主的 g++:

g++: error: unrecognized command-line option '-fuse-ld=/…/llvm/22.1.8/bin/ld.lld'

x86_64-none-elfx86_64-unknown-none-elfx86_64-unknown-nonex86_64-elfx86_64-none-nonex86_64-unknown-unknown 逐一实测,结果一致; -fuse-ld=lld / --ld-path= / --gcc-toolchain= / -B 逐一实测,均不改变结果。 唯一能改变它的是把 linux 放进 OS 位 —— 那会给一次裸机链接带来八条宿主 -L

两种结果都不可接受:经宿主 g++ 会让这一行只在 Linux 宿主上成立(而 macOS 与 Windows 宿主根本没有能产 ELF 的 g++);宿主搜索路径出现在 freestanding 链接 上,正是引擎要守住的封闭性。

解法:ISA 档表新增 lldEmulation 列;置位时引擎直接用 ld.lld 驱动链接。 标志的词汇随工具一起改变 —— -Map= 而非 -Wl,-Map=⚠️ 仅属于驱动的标志是 丢弃而非翻译,而第一次尝试漏了两个:

ld.lld: error: unknown argument '-nostdlib++'
ld.lld: error: unknown argument '-Wl,--disable-new-dtags'

⚠️ riscv 与 aarch64 两行该列留空。它们的驱动本就到得了 lld,为了让三行看起来 一致而改动一条可用的链接,正是引入回归的方式。

第二列 extra 承载 -mno-red-zone,也不是偏好:System V 的 128 字节红区在有 OS 的机器上安全,是因为内核为中断切了栈;裸机上处理器把中断帧压进红区,被中断的叶 函数恢复后局部变量已被覆盖 —— 不触发异常、没有诊断,而且只在中断恰好落在叶函数 内部时发生。

4.0.1 ⭐ 探针能跑起来,靠的是 multiboot 的 a.out kludge

QEMU 的 multiboot 装载器只接受 32 位 ELF:

qemu-system-x86_64: Cannot load x86-64 image, give a 32bit one.

而 ELF 的 class 是整个文件的属性,x86-64 代码产不出 ELF32。走的是 multiboot 的 另一条路 —— flag 位 16 的 a.out kludge,头里自带装载地址,装载器根本不解析 ELF。

⭐ 让这条路的算术成立的是 SIZEOF_HEADERS:装载器算的起始文件偏移是 header_addr - load_addr,所以这个差必须等于 multiboot 头在文件里的真实偏移。 把镜像起点写成 0x100000 + SIZEOF_HEADERS,ELF 头恰好占满 load_addrheader_addr 之间的字节,偏移与地址保持同余、链接器不插填充,差值按构造就是 偏移。按平常写法(. = 0x100000).multiboot 落在文件偏移 0x1000 而地址 0x100000,load_addr 就得是 0xFF000 —— 落在写入会被丢弃的 legacy BIOS 窗口里。

4.1 阶段 1–3 卡在哪里

不是代码,是模拟器载荷。qemu-riscv 描述符里写明了本索引的收录门槛:为它 服务的五个宿主目标(linux x64/arm64、darwin x64/arm64、win32 x64)从同一个 版本化发布提供预编译件,且每个资产带侧文件校验。

xPack 一共只发布两个 QEMU 包(arm 与 riscv),没有 x86。qemu.org 只出 Windows 安装器,macOS 与 Linux 由发行版包服务。因此没有上游过得了这条线。

4.2 从源码构建:可行,代价明确

QEMU 可以从源码构建,而且只构建需要的目标能把代价压下来:

./configure --target-list=x86_64-softmmu --disable-docs --disable-guest-agent \
            --disable-tools --disable-vnc --disable-sdl --disable-gtk

单目标构建远小于全量。需要的依赖是 meson、ninja、glib、pixman、zlib。

⚠️ 难点不在构建,在五个宿主目标。

宿主 途径 难度
linux x64 GitHub 托管 runner
linux arm64 GitHub 现已提供 arm64 runner
darwin arm64 macOS runner 中(依赖走 Homebrew,需处理 @rpath 与最低版本)
darwin x64 macOS runner(x64 镜像) 中,同上
win32 x64 MSYS2 / mingw :QEMU 在 Windows 上的依赖链与 DLL 布局是这条路上最麻烦的一段

⭐ 更省的一条路:照 xPack 自己的方式做。 xpack-dev-tools 的构建脚本是 公开的,且已经解决了上面五个宿主的全部问题——它只是没有为 x86 建一个包。 沿用它们的构建容器与脚本、把 --target-list 换成 x86_64-softmmu,比从零 搭五条流水线现实得多。

4.3 阶段划分

阶段 1:只做 linux x64,先用起来。

openkal-uefi 今天在 CI 里用 apt 装 qemu-system-x86。先把 linux x64 一个 宿主的载荷构建出来并直接在 CI 里用,它本身就是一次真实验证——载荷能不能启动 一个 UEFI 镜像,是可以立刻回答的。

⚠️ 不要在只有一个宿主时就收录进索引:描述符的收录门槛是五个宿主,破例 一次就等于把门槛降成注释。

阶段 2:在一个临时 PR 的 CI 上,用 Actions 构建其余四个宿主。 ⇒ 已开始:mcpplibs/qemu-x86

仓库只含跨宿主构建工作流,不含描述符、不含二进制、不做镜像上传。 ⭐ 五条腿全绿。 linux-x64 真正引导了 multiboot 探针(SeaBIOS 起来、镜像打印 qemu-x86 probe ok);另外四条按设计回落成只核 --version,并各自发出一条 ##[notice] 说明这台宿主的链接器发不出 x86 ELF。这是收录门槛所要求的那件事, 而它是在任何东西被发布之前拿到的。

这一步的价值已经兑现:六条只有真跑才会出现的发现,而每一条都是「已发布却装不上」 的成因。 它们在这里只是红叉。

# 宿主 发现
1 darwin found no usable distlib —— QEMU 的 mkvenv 建非隔离虚拟环境要 distlib,而现代 Python 都不自带。⚠️ 第一轮我只在 macOS 修了它并归因于「Homebrew 比较特别」;Windows 走到同一行,说明它是 QEMU 9.2.4 的属性
2 win32 解压创建符号链接失败。⚠️ 我先断言「MSYS2 的 tar 会改成复制」——错的:setup-msys2 默认 winsymlinks:nativestrict。⭐ 报的是 No such file or directory 而不是 Permission denied,所以是顺序不是权限,冲权限去的修法不会奏效
3 darwin x64 ⚠️ 两轮都没跑过 —— 不是失败,是一直排队。 macos-13 镜像已退役,而退役标签不报错、只等。汇总页上「没跑」与「慢」长得一样。换 macos-15-intel 后立刻开始
4 win32 vendored wheel 够不着:file://D:/…/python/wheels —— file:// URL 在盘符前需要三个斜杠,只有两个时 D: 被解析成主机名。而 mkvenv 离线,没有回落。⭐ 修法不是补斜杠,是预装 pycotap:非隔离虚拟环境看得见系统的包,mkvenv 的判据就是「能不能 import」
5 win32 MSYS2 的 Python 不自带 pip(单独的包)。我默认了它在场
6 win32 ninja 会连单元测试一起构建,而 test-vmstate.exe 在 MinGW 下链接不了(undefined reference to qemu_ftruncate64)—— 2032 个目标里已经编好 1897 个。⭐ 只构建模拟器目标而不是给测试打补丁,是更小的主张

4.3.0 ⚠️ 第七条与第八条:失败的是探针,不是模拟器

# 宿主 发现
7 win32 只构建模拟器目标之后,meson install --no-rebuild 停在 ERROR: File 'trace/trace-events-all' could not be found —— 那是 install 要拷的生成文件。点名它只会停在下一个;去掉 --no-rebuild 会重建全部、把链接不了的单元测试带回来。⭐ 改为显式组装载荷(模拟器 + pc-bios + 用 ldd 实测出的 13 个 MinGW 运行时 DLL),这也正是 316MB 那条发现说载荷该做的事
8 全部 ⚠️ 引导探针的门槛问错了问题。 它检查有没有 32 位编译器,然后放链接过去 —— Windows 上前者通过(cc -m32 照样产出对象),链接倒下:ld.exe: unrecognised emulation mode: elf_i386 / Supported emulations: i386pep i386pe。改成问链接器认不认这个 emulation,一条检查覆盖全部宿主

⚠️ 而我差点把这条修复读成一次「假绿」。 检查各腿是否真的引导时,我 grep 了 ::notice::this host's linker emits no x86 ELF —— 那串字同时出现在被回显的脚本里, 于是五条腿全被读成「跳过」,包括确实引导了的 linux-x64。判据换成 GitHub 真正发出的 ##[notice] 注解(脚本回显里不会有)之后,真相是:一条引导、四条按设计回落。 同一个形状:grep 匹配到的是脚本而不是结果。

4.3.1 ⚠️ 体积:strip 回答的比预期少得多

实测于 linux-x64:

qemu-system-x86_64 78,681,192 → 26,994,336 字节(strip 后)
整个安装前缀 392 MB → 343 MB

也就是说约 316 MB 根本不是模拟器。 一个把这个前缀直接打包的载荷会把它一起 发出去,而索引的资产预算是实测 0.012 MB/s 的单连接上传、无分片、无断点续传 —— 所以载荷选什么比它 strip 掉什么更重要。这一条要在写描述符之前回答,而不是靠 「删到出问题为止」。

⚠️ macOS 那两条腿不 strip,并且说明了原因:strip Mach-O 会让 ad-hoc 签名失效,这 正是本生态发布流水线自己的规则。

4.3.2 ⭐ 选定载荷之后的数字:392 MB → 54 MB

把五条腿都从 meson install 改成显式组装(模拟器 + pc-bios + Windows 上用 ldd 实测出的 13 个 MinGW 运行时 DLL)之后:

宿主 meson install 显式组装 strip 后
linux-x64 392 MB 103 MB 54 MB
linux-arm64 389 MB 100 MB 53 MB
darwin-arm64 341 MB 53 MB(不 strip)
darwin-x64 342 MB 53 MB(不 strip)
win32-x64 (装不出来) 110 MB 58 MB

七倍。 而这不是收尾优化:索引的资产预算是实测 0.012 MB/s 的单连接上传、 无分片、无断点续传,343 MB 的载荷在那条链路上是不可发布的,54 MB 的可以。也就是说 「载荷选什么」不是打包细节,而是收录能否成立的前提

⚠️ pc-bios 一个文件都没删。哪些 blob 会在运行时被加载是描述符要回答的问题,靠 「删到出问题为止」来回答,就是一个载荷在别人机器上少一个 blob 的由来 —— 本次实施在 这里犯过一次(顺手删了 keymaps 与固件描述符),当场撤回。

4.3.3 收录 xim:qemu-x86 还差什么

五条腿全绿这个门槛已经达到,而收录仍是一个独立的决定,它现在有了做决定所需的 全部数字。剩下的三步与 qemu-riscv 的做法同形:

  1. 把五个资产发布成一个版本化 release,每个带 .sha256 侧文件;
  2. 镜像到 xlings-res/qemu-x86 双端,并按「回探下载 URL」而不是退出码判定成功;
  3. xim:qemu-x86 描述符,deps 是否为空按 qemu-arm 的做法实测 DT_NEEDED / otool 闭包后决定,而不是从兄弟描述符抄结论。⚠️ Windows 一侧的答案已经量出来了: 13 个 MinGW 运行时 DLL 必须与 exe 同目录,因为 PE 没有 rpath。

⭐ 这一步的价值在于先把五条流水线跑通,再谈收录。GitHub 托管的 runner 覆盖 linux x64/arm64、macOS arm64/x64 与 windows x64,恰好就是本索引服务的 五个宿主——所以这五条腿可以在同一个 PR 的矩阵里一次跑出来,产物作为 artifact 留存。

顺序上这比先建仓、先写描述符要省:构建失败在这里只是一个红叉,而不是一个 已发布却装不上的包。⚠️ 而且它把「Windows 那条最难的腿」的风险前置——那一腿 若过不去,五宿主的门槛就不成立,后面的收录也就不该开始。

沿用 xpack-dev-tools 公开的构建脚本,只把 --target-list 换成 x86_64-softmmu,比从零搭五条流水线现实得多:那些脚本已经解决了这五个宿主的 依赖与打包问题,缺的只是没有为 x86 建过包。

阶段 3:五条腿都绿之后,再补进 xlings 生态。

镜像到 xlings-res/qemu-x86 双端,写 xim:qemu-x86 描述符,进 openxlings/xim-pkgindex。DT_NEEDED 闭包按 qemu-arm 的做法实测后决定 deps 是否为空,而不是从兄弟描述符抄结论。

阶段 4:目标表加 x86_64-none-elf 行。✅ 已完成(mcpp 2026.8.21.1)。

aarch64-none-elf 同样是零 libc 档:索引里没有 x86 的裸机 C 库,而第一批 消费者 —— UEFI 应用与 openarch 的第三个后端 —— 都不需要。

⚠️ 这一阶段本来排在最后,理由是「等模拟器」。实际顺序反了过来:目标行先落地, openarch 的第三个后端因此写得出来,而第三台机器正是把门槛从「适配」变成 「抽象」的那一步(见 §3.1)。模拟器仍然缺,CI 的那一行用 apt 装并注明了原因。

4.4 判据

  • 阶段 1:openkal-uefi 的 CI 不再出现 apt-get install,而 OVMF 启动断言 一字不改地通过;
  • 阶段 2:五个资产两端镜像逐字节一致,DT_NEEDED 闭包按 qemu-arm 的做法 实测后决定 deps 是否为空;
  • 阶段 3:一条 e2e,断言产物而不是退出码——.text 的加载地址、零未定义符号、 以及命令行上的 ISA 参数,与 tests/e2e/136 同形。

4.5 ⚠️ 顺带暴露的一个引擎缺口:构建程序无法在成功时说话

openarch 的三机器模板发布后,用发布出去的包走完整条 mcpp new --template openarch:生成的工程三个目标都构建得出,而 mcpp run 在两台 有模拟器的机器上都失败。

成因是清单里的 [xlings] deps 是声明而不是安装触发器 —— 它让 mcpp::xpkg_dir 能问「那个包落在哪」,自己不装任何东西。干净 registry 上它返回空, build.mcpp 静默地不配置 runner,而 mcpp 给出的建议是「写一个 runner 键」:一般 情况下对,在这里不对(这个工程有 runner,只是条件性的)。

⭐ 普通裸机工程遇不到:板级包把模拟器写在索引描述符的平台 deps 里,那些是随包 安装的。只有没有板级包的工程才会掉进来。

⚠️ 而构建程序没有办法说出这件事。 一条 std::cerr 提示被写出来、实测、然后删掉: mcpp 用 capture_exec 抓构建程序,只在非零退出时打印抓到的东西,于是那条提示在 最需要它的那些构建上一个字都不打印。留着比不留更糟,因为它看起来像个修复。让构建失败 也不对 —— mcpp build 不需要模拟器。

mcpp: 指令表里没有「成功但有话说」的通道。 现有的是 action / cfg / cflag / cxxflag / generated / graph / include / link / protocol / rerun / runner / source。 一个 mcpp:warning <text> 指令会让这一类情形可被诊断 —— 包不在时、可选特性被跳过时、 构建程序做了一个消费者应当知道的回退时。

判据:一个最小的构建程序发出 mcpp:warning,mcpp build 成功并把那行文字显示 给用户;而 e2e 断言那行确实出现在输出里,而不只是断言构建成功。


5. 排序与建议

状态 建议
1 Windows libc++(第 2 节) 未做。⭐ 结论已从「加进载荷」改为独立成包,理由见 2.5 先做 2.1 的测量,再按三步落地。我们自己能做完
2 feature 源不一致(第 1 节) 已修复(2026.8.21.1)。⚠️ 修法就是第三次尝试的判据 —— 它当初被一个没有去读文件就写下的理由否掉了
3 openarch(第 3 节) 0.4.0 已完成:混合式的根、两个门面、feature 选后端、三个指令集
4 openarch 的时钟归属(3.3) 未做 ⭐ 先作为调研:两种启动方式各一个最小探针,答案决定接口归属
5 x86_64 裸机(第 4 节) 阶段 4 已完成(目标行 + 引擎的直连链接);阶段 1–3 未做 xim:qemu-x86 仍缺。阶段 1 先做 linux x64 供 openkal-uefi 用;阶段 2 用临时 PR 的 CI 矩阵跑通五条腿再谈收录

⚠️ 本方案排序里有一处判断错了,值得记下来。 第 5 项原先排在最后,理由是它 「卡在模拟器载荷」。实际做下来,它是唯一一项改变了对已完成工作之信心的: 第三台机器挖出了三条两台 RISC 机器合起来也看不见的东西(§3.1)。一个「被外部 依赖卡住」的条目和一个「价值低」的条目在列表上看起来一样,而它们不是一回事。

⚠️ 排期时要当外部依赖的只剩两处,比本文第一版少了一处:xim:qemu-x86 需要 进 openxlings/xim-pkgindex;QEMU 的五宿主构建若沿用 xPack 的脚本,需要与那个 项目协调。

Windows libc++ 从外部依赖变成了我们能自己做完的事,这正是 2.2 收回那条 论证之后取舍改变的直接结果:独立成包不需要动别人的打包脚本。