2026-08-20。对象是 mcpplibs/openarch 0.1.0,以及为使其可被验证所需的引擎与
索引改动。
生态的三层各自回答一个不同的问句:
| 问句 | |
|---|---|
| openkal | 一个程序向内核要求什么 |
| openarch | 一个内核向机器要求什么 |
| openhal | 一个程序向设备要求什么 |
openarch 承载机制而非策略:它能切换上下文,但不做调度;它能装载陷入向量、 构造页表项,但不判定一次缺页意味着什么、也不规定内存怎么排布。
这一层与另外两层有一处根本差别,而它决定了本方案的形状:openkal 与 openhal 的实现者集合是开放的——谁都可以为一个新内核或一块新设备写后端,而 openarch 的实现者集合是有界且很小的,并且没有人会把 aarch64 的上下文切换 写成第二种样子。因此接口与实现必须共同演化:判定一个接口是否写错了,唯一的 办法是加入第二个架构。
仓库当前共 82 行接口 加 一份 riscv64 的裸汇编:
src/context.cppm openarch.context —— context / switch / init
src/arch/riscv64/context.S 裸汇编的切换
src/arch/riscv64/context_init.cpp
examples/switch/ 两个上下文之间来回切换的探针
仓库自己的 README 写明了判据,而本方案接受它:
这一层的可行性由两条原语决定,而不由它最终承载多少个接口决定: ① 上下文切换;② 页表项。
以及那句最要紧的自述:
⚠️ 门槛尚未通过,而一个架构无法通过它。 判据是同一个接口在第二台、 且确实不同的机器上仍然成立。只有 riscv64 时,抽象没有任何可供其失败的对象 ——围绕一套指令集塑形的接口,总是恰好合于它。
⭐ 所以本方案的第一优先级不是"多写几个接口",而是让门槛可被判定。在第二个 架构存在之前增加接口数量,产出的是"没有任何测试能运行的大量代码",而这正是 这个项目的设计记录最直接警告的失败形态。
src/toolchain/triple.cppm 的裸机行只有两条:
{ "riscv64-none-elf", "verified", "bare", "llvm@22.1.8", "xim:picolibc-riscv@1.8.12", true },
{ "riscv32-none-elf", "verified", "bare", "llvm@22.1.8", "xim:picolibc-riscv@1.8.12", true },
⭐ 解法不需要新的 C 库包。 openarch 是纯机制层,不引用任何 C 库符号;它要的
是编译器与指令集,不是 <stdio.h>。因此 aarch64-none-elf 可以作为零 libc
档的一行加入——sysroot 为空,不解析任何 C 库,不添加任何 include 或 library
路径。这一档的能力本身是 2026.8.20.2 才具备的([target.<triple>].sysroot = ""),
而 openarch 恰好是它的第一个真实用例。
一条需要 C 库的 aarch64 裸机行是另一件事,需要 picolibc-aarch64 之类的载荷;
它不在本方案范围内,也不是通过门槛所必需的。
xlings search qemu 只有 xim:qemu-riscv。而实测其载荷的 bin/ 只有两个程序:
qemu-system-riscv32
qemu-system-riscv64
xPack 是按架构分包构建的,不是全量 QEMU。
xPack 发布的 QEMU 只有两个:qemu-arm-xpack 与 qemu-riscv-xpack。前者 v9.2.4-1
的载荷经实测含 qemu-system-aarch64 与 qemu-system-arm,五个宿主目标齐全
(linux x64/arm64、darwin x64/arm64、win32 x64),每个资产带 .sha 侧文件——与
qemu-riscv 描述符所述的收录门槛完全一致。
⇒ 新增 xim:qemu-arm,照 qemu-riscv 的式样:上游 xPack 为 GLOBAL,
xlings-res/qemu-arm 双端镜像为 CN。
x86 一档暂缓且有据:qemu-riscv 描述符写明 xPack 是"唯一一个为本索引服务的
全部五个宿主目标、从同一个版本化发布、且带每资产侧文件发布预编译件的上游"。
qemu.org 只出 Windows 安装器,macOS 与 Linux 只有发行版包,都不满足这条线。
与其凑合,不如记为缺口。
顺序由门槛决定,不由完整性决定。
| 接口 | 内容 | 为何在这个位置 | |
|---|---|---|---|
| I1 | openarch.context |
上下文的表示、切换、构造 | 已有 riscv64;门槛的第一条原语,加入 aarch64 后才第一次可被证伪 |
| I2 | openarch.pte |
页表项的构造与解读 | 门槛的第二条原语。README 已指出预期的失败点:内存属性在 x86 走 PAT、aarch64 走 MAIR、riscv 直接写进项内——三种机器把同一个概念放在三处 |
| I3 | openarch.trap |
陷入向量的装载与最小陷入帧 | 前两条通过之后才有意义:它的形状取决于上下文的形状 |
| I4 | openarch.cpu |
per-CPU 基址、屏障 | 架构间差异最小,先做说明不了任何问题 |
| I5 | openarch.timer |
时钟节拍源 | 同上 |
本方案实现 I1 与 I2,并把 I3–I5 明确留在门槛之后。这不是保守,而是这个项目 自己写下的判据:先证明抽象成立,再扩展它覆盖的面。
这是本方案里唯一需要论证的设计决定。
指令集不是一个选择。 它由目标 triple 决定,所以后端里带汇编的那部分由
cfg(arch = ...) 选中。用 feature 强行选入一个与目标不符的后端,产出的是编不过
或链不上的代码,而一个能表达"错"的开关不如没有这个开关。
但每个后端里有一半不是汇编。 页表项的构造是纯位运算:给定物理地址、权限与 内存属性,产出一个 64 位的项。这一半:
- 在任何宿主上都能编译;
- 在任何宿主上都能单元测试——判据是产出的位模式,不是它能否运行;
- 恰恰是最可能在第二个架构上暴露接口错误的一半(见 I2 那一行)。
⇒ feature 的作用是把某个后端与架构无关的那一半强制编入当前构建,使其可在 宿主上被测试:
[features]
default = []
# 接口子集:一个只要上下文的内核不必为页表付出任何东西
context = {}
pte = {}
# 可测面:把该后端的纯计算部分编入当前构建(不含汇编),
# 用于在宿主上对位模式做断言。
probe-riscv64 = {}
probe-aarch64 = {}⭐ 这样,"同一个接口在两台机器上是否成立"这个问题,有一半可以在没有模拟器的 情况下、在任意宿主上、由普通单元测试回答;另一半由模拟器上的真实切换回答。
三个作业,每个断言一件不同的事:
| 作业 | 断言 |
|---|---|
riscv64 |
探针在 qemu-system-riscv64 上切换、返回,且被调用者保存的寄存器不变 |
aarch64 |
同一份探针源码,在 qemu-system-aarch64 上,同样的断言 |
host |
两个后端的页表项编码在宿主上编译并通过位模式断言 |
xim:qemu-riscv、xim:qemu-arm),不用发行版的
包。openkal-uefi 目前用 apt 装 qemu 与 OVMF,那是在没有对应载荷时的权宜;本层
不复制它,因为 qemu-arm 已经补上。
E1 引擎:aarch64-none-elf 目标行(零 libc 档)
└─ 需要:2026.8.20.2 的 sysroot 覆盖能力(已发布)
X1 索引:xim:qemu-arm 描述符 + xlings-res/qemu-arm 双端镜像
└─ 与 E1 无依赖,可并行
A1 openarch:I1 的 aarch64 后端(context.S + context_init)
└─ 依赖 E1(否则无法为该目标编译)
A2 openarch:门槛判定 —— 同一份探针在两台机器上运行
└─ 依赖 A1 与 X1
A3 openarch:I2 页表项接口 + 两个后端的编码器
└─ 依赖 A2(接口在第二个架构上成立之后,再加第二条原语)
A4 openarch:feature 拆分(接口子集 + 宿主可测面)与宿主单元测试
└─ 依赖 A3
A5 openarch:CI 三个作业
└─ 依赖 A2(riscv64/aarch64 两腿)与 A4(host 一腿)
A6 发布 0.2.0 + 索引描述符
└─ 依赖 A5 全绿
- 不实现 I3–I5。它们的形状取决于 I1 与 I2 的结论;在门槛通过之前写下它们, 等于用一个未经验证的抽象去生成更多代码。
- 不加 x86_64 后端。缺的是模拟器载荷这一个环节(见 3.2),而不是代码;等到 有满足收录门槛的上游再补。
- 不为 aarch64 引入 C 库。openarch 不需要,而引入它会把一条"因为需要所以存在" 的目标行,变成一条"因为对称所以存在"的目标行。
- 不把 feature 用于选择指令集。理由见第 5 节:那样的开关只能表达错误。
方案第 4–6 节的安排在实施中被三条实测修改,记录如下,因为每一条都推翻了写 方案时的一个假设。
examples/switch 是一份探针源码,为两个目标构建、在两个模拟器里运行,输出
逐字相同,两边退出码均为 0:
main: switching to task
task: arg=42
main: back, witness=7 before=1234
switch ok
保存的上下文只含整数状态。 riscv64 不保存 fs0–fs11,这在一个后端时
看起来像该后端的做法。AAPCS64 同样把 d8–d15 列为被调用者保存,而算术给出
了结论:连它们一起保存需要 168 字节,接口只预留了 128。预留的大小早已假定了
一条契约,而没有任何地方写着它。
页表项不总是自带其含义。 riscv64 把内存类型写进项内(Svpbmt);aarch64
写的是指向 MAIR_EL1 的三位索引,所以同样的位在两台 CPU 上描述不同的内存。
memory_type::device 因此无法仅靠编码满足,本层接管了那个寄存器。
这正是方案第 2 节所引 README 预言的失败点,而它以「接口欠定义」而非「接口 错误」的形式出现。
方案第 5 节提出用 probe-riscv64 / probe-aarch64 把后端与架构无关的那一半
编入宿主构建。实施中测出:
- 一个文件只要被任何 feature 命名,就归该 feature 独占。 feature 未激活
时它不被编译,即便
cfg块显式列出了它;而库构建仍然报告成功,缺失 只在消费方链接时以undefined reference出现。 - 改为两个编码器都无条件编入之后,外来那个往 riscv64 镜像里放了 450 字节
(
nm --size-sort实测),而整个探针的.text约 2000 字节。
头文件 inline 同时满足两侧且不需要开关:目标构建只实例化它调用的那个(实测
两个镜像中的外来符号数均为 0),宿主构建调用两个便得到两个。
⇒ 本包没有 feature 轴。 指令集由 cfg 决定(能选错的开关不如没有),接口
子集无法用加性 feature 表达,而宿主可测性由 inline 达成。
mcpp::xpkg_dir 解析的是工程声明过的包,而不是任何已安装的包。实测:载荷
在磁盘上而工程未声明时它返回空串,build.mcpp 因此不发出 runner,mcpp run 报
「no runner is configured」——而它的构建程序刚刚试图配置一个。
板级支持包在索引描述符的平台 deps 里声明模拟器,所以普通裸机工程从不写
[xlings]。这个探针没有板级包(也不可能有,因为没有板级包同时服务它的两台
机器),所以它自己声明。