diff --git a/.agents/docs/2026-08-20-baremetal-user-facing-scenarios.md b/.agents/docs/2026-08-20-baremetal-user-facing-scenarios.md new file mode 100644 index 00000000..d06c4a11 --- /dev/null +++ b/.agents/docs/2026-08-20-baremetal-user-facing-scenarios.md @@ -0,0 +1,337 @@ +# 裸机:用户面能感受到的变化(场景 + 伪代码) + +配套 [PR #455–#459 review](2026-08-20-pr455-459-freestanding-review.md)。 +本文只讲**用户敲什么、看到什么、不用写什么**;机制在 review 里。 + +以下命令与输出都是**实测**的(mcpp 2026.8.19.4 + `riscv-virt-rt 0.3.0`), +不是设想的接口。 + +--- + +## 场景 0:先看「不用写什么」 + +这是本轮最大的用户面变化。裸机工程的**整个** manifest: + +```toml +[package] +name = "blinky" +version = "0.1.0" + +[build] +target = "riscv64-none-elf" + +[dependencies] +riscv-virt-rt = "0.3.0" +``` + +⭐ 里面**没有**:链接脚本路径 · 加载地址 · `-nostdlib` · `-march`/`-mabi`/`-mcmodel` · +crt0 · libc 名字 · 模拟器命令行 · `[target.*]` 段 —— **一个都没有**。 + +对照:同样的工程在裸机 C/C++ 的常规做法里,通常需要一份 `link.ld`、一份 `start.S`、 +一段 Makefile 里的 `qemu-system-riscv64 -machine virt …`,以及一个手工维护的 sysroot 路径。 + +--- + +## 场景 1:从零到一个会跑的固件 + +```bash +mcpp new blinky --template riscv-virt-rt +cd blinky +mcpp run +``` + +实际输出: + +``` + Downloading mcpplibs.riscv-virt-rt v0.3.0 + Compiling blinky v0.1.0 (.) + Finished dev [unoptimized + debuginfo] in 0.08s + Size blinky text 8844 data 80 bss 5668 total 14592 + Running `…/qemu-system-riscv64 … target/riscv64-none-elf/…/bin/blinky` + +hello from blinky +float 3.1416 +heap ok +``` + +生成的 `src/main.cpp` 是**普通的 `int main()`**: + +```cpp +import mcpplibs.riscv_virt_rt; + +extern "C" int main() { + board::println("hello from blinky"); + board::printf("float %.4f\n", 3.14159); // 浮点 printf 也能用 + void* p = board::alloc(64); // 堆也在 + board::println(p ? "heap ok" : "heap FAILED"); + board::release(p); + return p ? 0 : 1; // 返回值经 semihosting 回到宿主 +} +``` + +⚠️ **不需要 `_start`,不需要汇编入口**。板级包带了 picolibc 的 semihosting `crt0`, +所以 C 运行时在 `main` 之前就已经起来了。只有**零 libc** 的板子才需要自己写入口。 + +--- + +## 场景 2:在目标上跑测试 + +```bash +mcpp test +``` + +``` + Compiling boots (test) + Running bin/boots +boots: console +boots ... ok (0.04s) + + test result ok. 1 passed; 0 failed; finished in 0.34s +``` + +⭐ **每个 `tests/*.cpp` 是一个独立镜像,在模拟器里跑,退出码即判据** —— +和宿主上的 `mcpp test` 完全一样的心智模型。 + +失败会**点名**: + +``` +ok_one ... ok +ok_two ... ok +deliberate_fail ... FAIL (exit 1, 0.02s) +error: test result: FAILED. 2 passed; 1 failed +``` + +⚠️ 这条不是设计出来的,是**实测**出来的:semihosting 把固件 `main` 的返回值原样传给 +qemu 退出码(`return 7` → qemu 退出 7)。原计划要为裸机造一套结构化 stdout 协议,不需要。 + +--- + +## 场景 3:换 ISA 宽度 —— 改一个 flag + +```bash +mcpp build --target riscv32-none-elf +mcpp run --target riscv32-none-elf +``` + +**源码一个字不改,板级包一个字不改。** + +``` + Size blinky text 10680 data 44 bss 5400 total 16124 +hello from blinky +float 3.1416 +heap ok +``` + +同一个板级包用一份描述服务两个宽度:它从 `MCPP_TARGET_ARCH` 选档位, +而 ISA 参数(`-march`/`-mabi`/`-mcmodel`)来自引擎的目标表 —— **是数据不是代码**。 + +--- + +## 场景 4:烧到真硬件要的东西 + +```bash +mcpp build +``` + +``` + Size blinky text 8844 data 80 bss 5668 total 14592 +``` + +产物旁边直接就有: + +``` +target/riscv64-none-elf//bin/blinky # ELF(调试 / qemu -kernel) +target/riscv64-none-elf//bin/blinky.bin # 裸二进制(烧录器只吃这个) +target/riscv64-none-elf//bin/blinky.map # 链接映射 +``` + +⭐ **size 摘要是每次构建都打印的**,因为裸机的核心约束是**容量** —— +不打印等于让用户自己去查一个每次都想知道的数。 + +`.map` 是「为什么这段没进来 / 为什么这段这么大」唯一能回答的东西。 + +--- + +## 场景 5:在已有工程里加板级支持 + +```bash +mcpp add riscv-virt-rt@0.3.0 +``` + +```toml +[build] +target = "riscv64-none-elf" + +[dependencies] +riscv-virt-rt = "0.3.0" +``` + +⭐ **模拟器和目标 C 库会被自动装上** —— 用户不需要事先 `xlings install` 任何东西。 +(实测判据:把 store 里的 picolibc 藏起来,`mcpp add` + `mcpp build` 把它装了回来。) + +--- + +## 场景 6:用标准库的可移植子集 + +```toml +[dependencies] +riscv-virt-rt = "0.3.0" +std-freestanding = "0.2.0" +``` + +```cpp +import mcpplibs.riscv_virt_rt; +import mcpplibs.std.freestanding; // 不是 `import std;` + +struct Task { int prio; const char* name; }; + +extern "C" int main() { + std::array t{{ {3,"c"}, {1,"a"}, {4,"d"}, {2,"b"} }}; + std::ranges::sort(t, {}, &Task::prio); // 带投影,裸机上跑 + std::optional o = 41; + std::atomic a{0}; + a.fetch_add(o.value() + 1); + std::span s{t}; + std::string_view sv{"ok"}; + board::printf("atomic %d\n", a.load()); + return 0; +} +``` + +实测输出 `abcd` / `atomic 42` / `span 4`。 + +**可用**:`array` `span` `optional` `expected` `atomic` `string_view` `ranges` `algorithm` +`bit` `charconv` `concepts` `type_traits` `tuple` `utility` coroutines … +(libc++ 110 个头里的 **103** 个) + +**干净地不可用**(编译期报错,不是跑起来才错): + +```cpp +std::mutex m; // error: no type named 'mutex' in namespace 'std' +``` + +⚠️ **需要目标版 `libc++.a` 才有的**(今天会在**链接期**失败并点名符号): +`std::format` · 内建标量类型的 `std::sort` · 完整的 `std::string`。 + +### 如果直接写 `import std;` 会怎样 + +``` +error: `import std;` is not available on 'riscv64-none-elf' — a freestanding + target has no hosted standard library. + `std` is one module over the entire library (threads, filesystem, + iostreams included), so there is no subset of it to build without an OS. + Use the freestanding subset instead — an ordinary dependency carrying + the parts of the library that need no OS (array, span, optional, atomic, + string_view, ranges, expected, charconv, coroutines): + + [dependencies] + std-freestanding = "0.2.0" + + then `import mcpplibs.std.freestanding;` in place of `import std;`. +``` + +⭐ **诊断给的那一行是能直接粘贴并跑通的** —— 这是一条硬规矩: +诊断里的每条建议都是承诺(本轮曾违反过一次,见 review §3.4)。 + +--- + +## 场景 7:调试时换掉板级包给的 runner + +板级包供 runner 是常态,但工程可以覆盖: + +```toml +[target.riscv64-none-elf] +runner = ["qemu-system-riscv64", "-machine", "virt", "-nographic", + "-bios", "default", # 换成 OpenSBI 启动 + "-s", "-S", # 挂 gdb,停在第一条指令 + "-kernel"] +``` + +``` +note: [target.riscv64-none-elf].runner overrides the runner a dependency supplied +``` + +⭐ 工程写的**赢过**依赖供的,而且 mcpp 会**说出来** —— 覆盖生效时不沉默。 + +--- + +## 场景 8:给一块新板子写 BSP(伪代码) + +这是生态作者面。**整个 `build.mcpp` 大约 30 行**: + +```cpp +import mcpp; +import std; + +int main() { + const bool rv32 = std::string_view{mcpp::target_arch() ?: ""} == "riscv32"; + + // 1. 从目标的 C 库里「选」—— 裸名即可,搜索路径引擎已经给了 + mcpp::link_lib("crt0-semihost"); // 换 UART 板就换成别的 crt0 + mcpp::link_lib("c"); + mcpp::link_lib("semihost"); + mcpp::link_lib(rv32 ? "clang_rt.builtins-riscv32" + : "clang_rt.builtins-riscv64"); + + // 2. 这块板子的内存布局 + if (const char* sr = mcpp::sysroot_dir(); sr && *sr) + mcpp::link_script(std::format("{}/lib/{}/picolibcpp.ld", sr, + rv32 ? "rv32imac/ilp32" : "rv64gc/lp64d").c_str()); + + // 3. 怎么把镜像跑起来 + if (const char* q = mcpp::xpkg_dir("xim", "qemu-riscv"); q && *q) { + mcpp::runner(std::format("{}/bin/qemu-system-{}", q, + rv32 ? "riscv32" : "riscv64").c_str()); + for (auto a : {"-machine","virt","-nographic","-no-reboot", + "-semihosting","-bios","none","-kernel"}) + mcpp::runner(a); + } + return 0; +} +``` + +⚠️ **注意它不做什么**:不找 libc、不声明 libc、不知道 libc 叫什么。 +它只知道**要哪个 crt0、要哪份链接脚本、怎么起模拟器** —— +**位置是目标的事实,选择是板级的事实。** + +换一块同 ISA 的板子 = 换这三段里的具体取值,**引擎零改动**。 + +--- + +## 场景 9:老版本 mcpp 上会看到什么 + +``` +error: 'runner' 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.19.1), or the name is misspelled — the compiler + cannot tell the two apart, because the module is generated by whichever + mcpp is running. +``` + +⚠️ 这条提示是**补出来的**,因为包侧**无法**优雅降级: +`if constexpr (requires { mcpp::runner("x"); })` 在名字不存在时是**硬错误**,不是 `false` +—— 语言内没有特性探测这条路。所以引擎在编译失败时把「可能是引擎旧了」说出来。 + +--- + +## 一句话:用户感受到的四件事 + +| | 之前 | 现在 | +|---|---|---| +| **起步** | 自己攒 linker script + start.S + qemu 命令行 | `mcpp new --template riscv-virt-rt` → `mcpp run` | +| **测试** | 裸机基本不测,或自造一套协议 | `mcpp test`,退出码即判据,失败点名 | +| **换宽度** | 改一堆 flag 和路径 | `--target riscv32-none-elf`,源码零改 | +| **烧录** | 自己 objcopy、自己看 size | `.bin`/`.map` 自动产出,size 每次构建都打印 | + +--- + +## 已知边界(不要许诺给用户) + +| 边界 | 表现 | +|---|---| +| `std::format` / 标量 `std::sort` / 完整 `std::string` | **链接期**失败并点名符号 —— 需要为目标编 `libc++.a`(未发布) | +| 异常与 RTTI | 整图关闭(裸机没有 unwinder);`try/catch` 编译期就不可用 | +| 第二块板 / ARM Cortex-M | 未做;rv32 只是「ISA 表是数据」的证据 | +| Windows arm64 宿主 | 装不上 `qemu-riscv`(上游无该资产),行为正确但会失败 | +| 目标 C 库不可按工程覆盖 | `[target.X]` 有 `toolchain`/`linkage`/`runner`/`cxx_runtime`,**没有 `sysroot`** —— 想换 newlib today 做不到 | diff --git a/.agents/docs/2026-08-20-openkal-design.md b/.agents/docs/2026-08-20-openkal-design.md new file mode 100644 index 00000000..0b7063e1 --- /dev/null +++ b/.agents/docs/2026-08-20-openkal-design.md @@ -0,0 +1,1198 @@ +# openkal 设计方案:通用内核 ABI 规范 + +**状态**:**0.1 已实施并发布**。规范与声明模块在 +[`mcpplibs/openkal`](https://github.com/mcpplibs/openkal),Linux 参考实现在 +[`mcpplibs/openkal-linux`](https://github.com/mcpplibs/openkal-linux),两者均为 `0.1.0`。 +实施计划与结果见 +[`2026-08-20-openkal-implementation-plan.md`](2026-08-20-openkal-implementation-plan.md)。 + +对应第一阶段计划 §7 的 **D0**;D0 的门不是技术判据 —— +是**「有第三方实现了第三个后端」**,参考实现降低门槛而不移动门。 + +⚠️ **本文与规范正文的关系**:本文记录**推导过程**,包含被撤回的方案与其理由; +规范正文(`openkal/SPEC.md`)只记录**结论**。两者冲突时以规范正文为准。 + +**证据来源**:标注 ⓘ 的是本机实测(载荷版本写在旁边),其余是设计主张。 +调研出处见 [`2026-08-18-freestanding-baremetal-analysis.md`](2026-08-18-freestanding-baremetal-analysis.md)。 + +--- + +## 1. 定位:一份规范,两个方向 + +openkal **不是一个库,是一份内核 ABI 规范**。它有两类使用者,方向相反: + +``` + 应用 / libc / libc++ / Rust / ... + │ 对上:消费者「可以依赖什么」 + ════════════╪════════════ openkal SPEC + │ 对下:实现者「必须提供什么」 + Linux / Windows / 你写的内核 / 裸机 BSP +``` + +⭐ **关键性质:openkal 不知道自己在 libc 之上还是之下。** +hosted 后端把 `kal_stream_write` 转发到 `::write(2)`(在 libc 之上); +裸机后端直接打 MMIO(取代 libc);picolibc 后端把 `FILE::put` 接到它上面(在 libc 之下)。 +**三种都成立,而且是同一份契约。** + +ⓘ **实测(调研 KA1/KA3)**:同一份 `app.cppm`,零 `#if`,hosted 转 `::write(2)`、 +裸机转 MMIO UART;**两侧 `app.o` 的外部符号集完全相同**(都只有 `kal_write`); +裸机侧零未定义符号,157 字节。 + +### 1.1 这个性质由什么承载 + +**句柄的不透明性。** 一旦句柄有类型,openkal 就被钉在 libc 的某一侧: + +| 句柄形状 | 强迫的位置 | 后果 | +|---|---|---| +| `int fd` | libc 之下(要有 fd 表) | ⛔ Windows 后端要维护 fd→HANDLE 表 = **模拟层** | +| `FILE*` / stream 对象 | libc 之上 | ⛔ 裸机零 libc 档、Windows 都得造 FILE | +| **不透明一个机器字** | **不强迫** | ✅ 每个后端塞自己原生的东西 | + +--- + +## 2. 划分依据:资源种类,不是「点亮标准库」 + +⚠️ **一条被否掉的划分依据**:按「点亮标准库的哪一块」切接口。 +它耦合到 C++、耦合到今天的标准库,而且**依赖方向反了** —— 内核 ABI 应由内核提供什么定义。 + +而且那正是**同一个错误的第三代**:POSIX 由 C 的 stdio/unistd 塑形,WASIp1 由 POSIX 塑形。 + +⭐ **划分依据 = 交给你的是哪一种资源。** 语言无关,也是 WASIp2 / seL4 / Fuchsia 收敛到的形状。 + +「点亮标准库」退回它该在的位置:**D1 的准入判据**(见 §11.2),不是划分依据。 + +### 2.1 接口清单 + +| interface | 资源 | core? | 判据:无该设施的后端能实现吗 | +|---|---|---|---| +| **`openkal.abort`** | (终止) | ✅ | `for(;;) wfi` 是一个**实现** | +| **`openkal.stream`** | 一条字节流 | ✅ | null sink 是一个**实现** | +| **`openkal.memory`** | 一块内存区域 | ✅ | 静态 arena 上的 bump allocator 是一个**实现** | +| `openkal.time` | 一个时间源 | ❌ | ⚠️ 不走的计数器**不是**时钟 —— 会让超时静默失效 = **模拟** | +| `openkal.task` | 一个执行上下文 | ❌ | 需要调度 + 上下文切换(那是 openarch) | +| `openkal.fs` | 一个 **descriptor**(自有句柄类型) | ❌ | 需要一个命名权威 | +| `openkal.net` | 一个 **socket**(自有句柄类型) | ❌ | 同上 | +| `openkal.channel` | 一条消息通道 | ❌ | — | + +⭐ **`stream` 是共享货币,不是统一入口**:`fs` 的 descriptor 与 `net` 的 socket 各有 +自己的句柄类型和自己的操作,但**都能产出 `openkal.stream`**。往哪写这件事对文件 / +socket / UART 是同一套代码;打开它们不是。 + +### 2.2 ⭐ core 的判别式:实现 vs 模拟 + +> **假实现会让上层「静默地错」的 ⇒ 模拟;只是「容量小 / 会失败」的 ⇒ 实现。** + +`operator new` 失败在任何平台上都是**有定义的结果**,所以静态 arena 是实现。 +而一个不前进的时钟会让 `wait_for` 永远返回、熵不随机会让密钥可预测 —— 那是模拟。 + +⇒ **「这块 MCU 没有堆」是错的命题**:只要有 RAM,堆就是实现出来的; +上层不关心 openkal 底层怎么做到。 + +### 2.3 ⚠️ 一个被起草后撤回的分解:`openkal.namespace` + +草案曾把 `fs` 和 `net` 消掉,换成一个通用的 `openkal.namespace`(名字 → 资源), +理由是「文件 / TCP 连接 / 管道 / 串口给你的都是 stream,只是命名方式不同」。 + +**重估后撤回。三条攻击全部成立:** + +**① 它触犯本文自己的 §5.1 规矩。** 「一个操作若能『存在但永远失败』,说明它被错误地 +合并了」—— 而把文件和 socket 都塞进一个 `stream`,`stream` 的 caps 就成了 +**互不相干能力的并集**(seek/size/truncate/sync 对上 shutdown/peer/nodelay), +每个后端对其中大多数说 `false`。**正是那个反模式,换了个地方出现。** + +**② `namespace` 需要一个 URI 解析器,那是模拟层。** `kal_namespace_open("tcp://…")` +要求**每个后端都能解析 scheme**:只有 UART 的后端也得解析并拒绝 `tcp://`; +合法 scheme 集合无界、不可发现;错误是字符串形状的。 +⚠️ **直接违反对下判据**(四后端自然实现、不需模拟层),而且比 POSIX 还差 —— +POSIX 至少 `open()` 与 `socket()+connect()` 是分开的类型化调用。 + +**③ 引用的先例是错的。** 草案称「这是 WASIp2 收敛到的形状」。**不是。** WASIp2 是: + +``` +wasi:io/streams input-stream / output-stream ← 共享的传输资源 +wasi:filesystem/types descriptor ← 自有资源类型,能产出 stream +wasi:sockets/tcp tcp-socket ← 自有资源类型,能产出 stream +``` + +它把**资源种类分开**,共享的是 **stream 这个传输类型**。草案把「共享 stream」 +误读成了「统一命名」。 + +⇒ **保留对的那半(流统一了传输),丢掉错的那半(统一命名)。** + +| | namespace 草案 | 撤回后 | 单体 fs+net | +|---|---|---|---| +| 实现者 | ⛔ 人人要 URI 解析器 | ✅ 没有就**不提供**该 interface | ✅ 同 | +| 消费者 | ⛔ 错误是字符串;**编译期不知道支不支持** | ⭐ `import openkal.net;` 缺了就**编译期报错** | ✅ 同 | +| 规范负担 | ⛔ 要标准化 **scheme 注册表** = 巨大隐藏面 | ✅ 每 interface 独立版本,面有界 | ⚠️ 接口大但有界 | +| 类型安全 | ⛔ caps 成为不相干能力并集 | ✅ 文件操作在文件句柄上 | ✅ 同 | + +⚠️ **划分原则(按资源种类)没错,错的是塌缩过头** —— `descriptor` / `socket` / +`stream` 本来就是三种资源。 + +**net 不是设备,但网卡是** —— 按基数分(§12):网卡 N 个 → openhal;协议栈 1 个 → openkal。 + +--- + +## 3. 核心 ABI 形状 + +```c +/* openkal.core —— C ABI(H1:跨包提供实现只有这一条路)*/ + +typedef struct { uintptr_t h; } kal_stream; /* 不透明,1 个机器字 */ + +/* 标准流:借用,不拥有 —— 裸机上「关闭控制台」没有意义 */ +kal_stream kal_stdout(void); +kal_stream kal_stderr(void); +kal_stream kal_stdin (void); + +/* 2 字返回;T ≤ 1 个机器字 */ +typedef struct { uintptr_t n; int32_t err; } kal_io_result; + +kal_io_result kal_stream_write(kal_stream, const void* buf, uintptr_t len); +kal_io_result kal_stream_read (kal_stream, void* buf, uintptr_t len); +int32_t kal_stream_flush(kal_stream); + +/* memory */ +void* kal_alloc (uintptr_t size, uintptr_t align); +void kal_free (void* p, uintptr_t size, uintptr_t align); + +/* abort */ +_Noreturn void kal_abort(const char* msg, uintptr_t len); +_Noreturn void kal_exit (int32_t code); +``` + +四个后端往 `h` 里塞什么 —— **桥接代码全部为 0 行**: + +| 后端 | `h` | +|---|---| +| linux | `fd` | +| windows | `HANDLE` | +| bare + picolibc | ⓘ **`FILE*`(就是 `__stdio`)** | +| bare 零 libc | 驱动结构指针 / 小索引 | +| 真内核 | **能力索引** | + +### 3.1 为什么是这些形状 + +| 决定 | 理由 | +|---|---| +| **C ABI** | ⓘ H1 实测:跨包提供实现只有这一条路;且 KAL 一次 call 的成本**可忽略**(本来就要陷入) | +| **2 字结构返回** | ⓘ CABI 实测:两个 arch 上结构返回都更便宜;RISC-V 上就是 `a0/a1`,陷入桩天然能回。⚠️ **`T` 必须 ≤ 一个机器字**,否则退化成隐藏指针 | +| **不透明句柄** | §1.1;且真内核不能假设进程模型 / 全局命名空间,句柄必须是**调用方上下文相关**的 | +| **`enum class` 错误码,不透传 errno** | errno 是 POSIX 的。⚠️ 但**映射 ≠ 模拟**:查表翻译不是模拟层,造 fd 命名空间才是 | +| **自由函数,不要 vtable** | 虚表 = 把结构体布局写进 ABI,而「只增不改」保护不了它(加一个方法就改布局) | +| **没有 `open(path)`** | WASIp1 的教训:照抄 fd + 路径命名空间 ⇒ 每个非 POSIX 宿主都要模拟 preopen / `openat` | + +--- + +## 4. 能力组件化:ADL 探测 + 兜底重载 + +⚠️ **草案在这里错了一整节。** 它说: + +> `requires` 作用在不存在的限定名上是硬错误 ⇒ 「能力 = 符号在不在」这条路 C++ 内测不出来 +> ⇒ 声明必须永远齐全,能力要放进一个 `caps` 结构体。 + +**第一句对,后面全错。** 硬错误只发生在**限定名**上;**非限定名 + ADL** 在模板里是 +dependent 的,`requires` 会老实求值为 `false`。 + +ⓘ **实测(llvm 22.1.8,真 C++20 模块,不是头文件)**: + +```cpp +// ⛔ 限定名:名字不存在 ⇒ 硬错误 +if constexpr (requires(S s) { kal::seek(s, 0); }) + +// ✅ 非限定 + ADL:名字不存在 ⇒ false +template concept Seekable = requires(S s, long o) { seek(s, o); }; +``` + +⇒ **能力不需要另一个可命名的东西。后端的模块接口本身就是能力声明。** + +### 4.1 三件事同时成立(ⓘ 全部实测) + +```cpp +export module openkal.stream; +export namespace kal { + +struct stream { unsigned long h; }; +struct io_result { unsigned long n; int e; }; + +// ⭐ 兜底重载的返回类型与真实现不同 —— 这是让「探测」与「兜底」共存的关键 +struct unsupported_t {}; +template inline constexpr bool always_false = false; + +template +unsupported_t seek(S, long) { + static_assert(always_false, + "this openkal backend provides no seekable streams. " + "openkal.fs hands out descriptors that do."); + return {}; +} + +// 探测:要求返回真类型,兜底自动落选 +template concept HasSeek = + requires(S s, long o) { requires __is_same(decltype(seek(s, o)), io_result); }; + +} +``` + +后端只需要**声明并定义真实现**,不需要任何配置: + +```cpp +export module openkal.uart; +export import openkal.stream; +export namespace kal { io_result seek(stream, long) { /* … */ } } +``` + +| 场景 | 结果 | ⓘ | +|---|---|---| +| 有后端,调用 `seek(s, 0)` | ✅ 编过,真实现赢重载 | 实测 | +| 有后端,`HasSeek` | ✅ **真** | 实测 | +| 无后端,`HasSeek` | ✅ **假**(不是硬错误)⇒ `if constexpr` 可优雅降级 | 实测 | +| 无后端,**强行调用** | ⭐ **编译期报错,文案是规范自己写的那句** | 实测 | + +⚠️ **这里有一个必须踩过才知道的坑**:如果兜底重载的返回类型**和真实现一样**, +`HasSeek` 在没有后端时也是**真** —— 因为 ADL 找到了兜底,而 `requires` 不实例化函数体, +`static_assert` 不会触发。**两个机制互相干扰,靠返回类型区分才能共存。** +ⓘ 我第一版就是这么写的,测出来是真才发现。 + +### 4.2 ⇒ 草案里三样东西被删掉了 + +| 删掉的 | 为什么不再需要 | +|---|---| +| `caps` 结构体 | 后端的模块接口就是声明 | +| `openkal.stream.caps` 模块 | 同上 | +| `capabilities.toml` | ⭐ **生态整洁性**:mcpp 的一切都在 `mcpp.toml` 里,不该为这个引入第二份配置 | + +**后端选择仍然只是条件依赖**(§4.3),整条链路**零新增配置、零新增引擎轴**。 + +### 4.3 ⭐ 模块名的归属:后端提供「接口名」 + +这是全套设计里最容易写错的一处,而且草案写错过 —— 它让应用 `import openkal.uart;`, +**那等于把源码钉死在后端上**,正好推翻 openkal 的立身之本。 + +⚠️ 但它掩盖了一个真实约束。ⓘ **三条实测(mcpp 2026.8.19.4 / gcc 16.1.0)**: + +| | 结果 | +|---|---| +| 传递依赖的模块能不能 import | ✅ **能** —— app 只依赖 backend,可以 `import` iface 的模块 | +| **ADL 能不能到达未 import 的模块** | ⛔ **不能** —— `error: 'seek' was not declared in this scope` | +| ⇒ 所以后端的声明**必须在应用 import 的那个模块里** | — | + +⭐ **解法:`openkal.stream` 这个模块名由「后端」提供,接口包用另一个名字。** + +``` +openkal.decl.stream ← 接口包:类型、兜底重载、concepts +openkal.stream ← 由「后端」提供:export import openkal.decl.stream; + 真实现 +``` + +```cpp +// 接口包 +export module openkal.decl.stream; +export namespace kal { struct stream{…}; struct io_result{…}; + template unsupported_t seek(S, long) {…} + template concept HasSeek = …; } + +// 后端包 —— 它提供「接口名」 +export module openkal.stream; +export import openkal.decl.stream; +export namespace kal { io_result seek(stream, long) { … } } +``` + +```cpp +// 应用:后端在源码里无名 +import openkal.stream; +int main() { + kal::stream s{1}; + static_assert(kal::HasSeek); + return seek(s, 0).n; // ADL 找到后端的实现 +} +``` + +ⓘ **端到端跑通**(app 只写 `import openkal.stream;`,concept 为真,返回后端的值)。 + +⚠️ ⚠️ **一个必须踩过才知道的坑**:接口模块**不能**叫 `openkal.stream.decl`。 +ⓘ 实测直接 ninja 自环: + +``` +ninja: error: dependency cycle: gcm.cache/openkal.stream.gcm -> gcm.cache/openkal.stream.gcm +``` + +模块图把点号读成了层级关系。⇒ **ABI 模块名不能是接口名的点号延伸**, +`openkal.decl.stream` 可以,`openkal.stream.decl` 不行。 + +#### ⭐ 模块命名是**规范条款**,不是风格 + +| 名字 | 谁提供 | 谁写 | +|---|---|---| +| `openkal.` | **后端** | 应用 `import` 它 | +| `openkal.decl.` | **接口包** | **只有后端作者** `export import` 它 | + +**必须入规范**,三条理由都是硬的: + +1. ⭐ 每个后端都要 `export import` **这个确切的名字** —— 名字本身就是契约的一部分; + 名字自由 = 后端没法照着规范写。 +2. ⓘ 「后端不能重定义接口类型」那条保证(实测 `conflicts with import`) + **只在所有后端 import 同一个模块时成立**。 +3. conformance 要 diff 导出名集合,得知道哪些来自共享模块、哪些是后端自己加的。 + +**为什么是 `decl`**(排除掉的): + +| | | +|---|---| +| `openkal.impl.*` | ⛔ **语义反了** —— 那是接口包,不是实现;实现提供的是 `openkal.*` | +| `openkal.abi.*` | ⛔ openkal 本身就是 ABI,冗余 | +| `openkal.core.*` | ⛔ 与 §2.1 的「core 接口集」撞词 | +| `openkal.spec.*` | ⚠️ 可用,但读者会以为里面是规范**正文** | +| ⭐ `openkal.decl.*` | 无歧义:里面就是声明 | + +⚠️ **规范必须连同理由一起写**:读者会很自然地选 `openkal.stream.decl`(点号延伸), +而那个 ⓘ 实测直接自环(§4.3)。只写「必须这么叫」不写为什么,第一个实现者就会卡住。 + +⚠️ **一个被否掉的简化**:把所有声明合并成单个 `openkal` 模块,后端各自 +`export import openkal;`。少一层命名,但**破坏「每个 interface 独立版本」** —— +只提供 stream 的后端会把 task/fs 的声明一起拖进来,大模块对裸机的编译代价也不友好。 +⇒ **每个 interface 一个 `openkal.decl.`。** + +### 4.4 后端怎么被选中:只用 `mcpp.toml` + +```toml +# 后端包 openkal-uart +[dependencies] +openkal = { version = "0.1" } # 它 export import 的那个 +``` + +```toml +# 消费者:只写后端,按 target 选 +[target.'cfg(os = "linux")'.dependencies] +openkal-linux = "0.1" +[target.'cfg(os = "none")'.dependencies] +openkal-uart = "0.1" +``` + +源码 `import openkal.stream;` 两个 target 一字不改 —— ⓘ 这正是 KA1 测到的 +「同一份 `app.cppm`,零 `#if`,两个后端」。 + +⚠️ 两个后端同时进图 ⇒ 两个包都导出 `openkal.stream` ⇒ 模块名冲突。 +基数为 1(§12)使这成为用户错误,而且是**编译期**被发现的。 + +#### ⭐ 消费者要声明几个依赖:两个 + +```toml +[dependencies] +openkal = "0.1" # ① 契约:openkal 本身就是那份规范 + +[target.'cfg(os = "linux")'.dependencies] +openkal-linux = "0.1" # ② 实现:可替换的那一半 +[target.'cfg(os = "none")'.dependencies] +openkal-uart = "0.1" +``` + +技术上 ① **可以省略**(后端已经把它拉进来了,模块也 `export import` 了)。 +**但不该省**,理由只有一条,而且是硬的: + +> ⭐ **① 是应用真正耦合的东西,而且它让「契约版本不匹配」变成解析期错误。** + +应用写 `openkal = "0.2"`、后端只支持 `"0.1"` ⇒ **依赖解析当场失败**; +省掉 ① 的话,同一个问题要等到**编译期**才以一堆签名不匹配的形式冒出来。 + +而且这与生态里已被验证的形状一致:Rust 的 `embedded-hal`(trait 包)+ 板级包, +应用同时依赖两者;`log` + `env_logger` 也是同一个形状。 + +| | 只写后端 | ⭐ 契约 + 后端 | +|---|---|---| +| 行数 | 1 | 2 | +| 契约版本由谁定 | ⚠️ 后端 | **应用** | +| 版本不匹配何时暴露 | ⚠️ 编译期,一堆签名错误 | **解析期,一条消息** | +| 换后端要改几行 | 1 | 1(① 不动) | + +⚠️ ① 看起来「声明了却没 import」—— 那是表象:应用 `import openkal.stream;` 时, +后端 `export import openkal.decl.stream;` 把它带了进来。**①的作用是钉版本,不是给 import 用。** + +#### ⚠️ 命名:丑的那一半要落在实现者身上 + +**openkal 本身就是接口/ABI,包名不该再带 `-abi`。** + +| 谁写 | 名字 | 谁看得到 | +|---|---|---| +| 应用 | `import openkal.stream;` · `openkal = "0.1"` | **所有人** —— 干净 | +| 后端作者 | `export import openkal.decl.stream;` | **只有实现者** | + +⇒ 两个模块名是语言逼出来的(§4.3),但**限定词只出现在实现者那一侧**。 + +#### ⚠️ 后端拥有应用可见的名字 ⇒ 碎片化风险,靠规范 + conformance 机械地封住 + +这是这套形状唯一的真代价:`openkal.stream` 由后端提供,理论上后端可以往里塞 +非标准的东西,而应用**察觉不到自己用了厂商扩展**。 + +⭐ 但**后端能塞的东西是有界的**,而且边界是语言给的: + +| 后端**不能** | 因为 | +|---|---| +| 重定义 `stream` / `io_result` / `unsupported_t` | ⓘ **实测被编译器拒**:`redeclaring 'struct kal::io_result@openkal.decl.stream' in module 'openkal.stream' conflicts with import` | +| 改 concepts 的语义 | 同上 | +| 改兜底重载 | 同上 | +| **只能**:加 spec 列出的那些函数的实现,**或额外的重载** | ← 唯一的自由度 | + +⇒ **规范只需要封住最后一行**,而它是**静态可查**的: + +> **SPEC**:`openkal.stream` 导出的名字集合**必须等于** spec 列表 ∩ 该后端实现的能力。 +> 厂商扩展必须放在**另一个模块名**里(`vendor.foo.stream`),应用要用就得显式 import 它 —— +> **于是「我用了扩展」在源码里是可见的。** +> +> **conformance**:dump 后端模块的导出名集合,与 spec 列表**逐条 diff**。多一个名字 = 不通过。 + +⚠️ 还要 diff **签名**,不只是名字:后端把 `seek(stream, long)` 写成 +`seek(stream, unsigned long)` 时 ADL 仍会经隐式转换选中它,而语义可能不同。 + +⭐ 这条比 §5 的其它防御更强,因为它**不是行为测试** —— 名字集合是制品的静态属性。 + +### 4.5 接口层面的缺失(①) + +一整个 interface 不存在时,`import openkal.task;` **编译期就找不到模块**。 +这一条不变,而且和 §4.1 是同一套失败语义:**编译期,点名,不是运行期返回值。** + +--- + +## 5. ⭐「声称 ≠ 事实」问题:新机制消掉了大半 + +草案担心的是:后端写 `caps::seek = true` 而 `seek()` 永远失败。 +**§4 换成 ADL 之后,结构性的谎话已经说不出来了:** + +> **你不能「声称有 seek」而不真的声明一个返回 `io_result` 的 `seek`。** +> 而声明了不定义,是链接错误。 + +⇒ **声称与实现是同一个制品**,不再需要「同源生成」那套纪律,也不需要 `capabilities.toml`。 + +### 5.1 ① 让「不支持」在类型系统里**不可表达**(仍然最强) + +剩下的问题是**分类错误**,不是撒谎。判据不变: + +> **一个操作若能「存在但永远失败」,通常说明它被错误地合并了。** + +ⓘ MMU 就是这个的实例:`NoMmu::map()` 对非恒等映射返回 `false` —— 那**不是撒谎, +是这个抽象本来就不该把两族东西装进一个 concept**。当时的结论是把它挪到 `cfg` 轴。 + +推广:socket 不能 seek,但那**不是后端撒谎,是名词错了** —— +所以 `fs` 的 descriptor 与 `net` 的 socket 各持自己的句柄类型(§2.3), +`kal::seek(socket, 0)` 因为**没有那个重载**而编译失败,不是因为返回错误。 + +### 5.2 ② 双向 conformance:`false` 也要验 + +> 后端若不提供 `seek`,**必须真的不导出这个符号** —— `nm` 可查。 + +⭐ 价值在于**它不是行为测试,是对制品的静态检查**:又快又不可能漏测。 +在新机制下这条更强了:导出了符号 ⇒ ADL 就会找到 ⇒ 探测为真 ⇒ 与声称自动一致。 +**这条变成了「验证机制本身没被绕过」,而不是「验证后端没撒谎」。** + +### 5.3 ③ 过程兜底 + +conformance 结果进索引元数据:没过 seek 那组的后端,描述符里不允许声称。 +这是唯一能约束「实现者根本不跑 conformance」的东西。 + +### 5.4 ⚠️ 诚实的残余风险 + +**以上都不证明行为。** 后端可以导出 `seek`、通过 happy path、在某个输入上错。 +这是**每一份规范都有的残余风险**(POSIX 也一样),答案只能是 conformance 的覆盖度, +不存在语言层的解法。 + +⇒ 但和草案相比,**残余从「结构 + 行为」缩小到只剩「行为」** —— 这正是把机制从 +「声明一个 bool」换成「声明一个函数」买到的东西。 + +--- + +## 6. 实现者视角(对下) + +### 6.1 要交付什么 + +| | | +|---|---| +| 一组 `extern "C"` 定义 | §3 的清单,按你实现的 interface | +| 一个导出真实现的模块 | ⭐ **它就是能力声明**,不需要额外的 caps(§4) | +| conformance 通过记录 | 双向:声称有的能用,没提供的**符号不存在** | + +### 6.2 最小实现:一个裸机 UART 后端 + +```cpp +// openkal-uart/src/stream.cpp +extern "C" { +kal_stream kal_stdout(void) { return kal_stream{1}; } // h = 一个小索引 +kal_stream kal_stderr(void) { return kal_stream{1}; } +kal_stream kal_stdin (void) { return kal_stream{0}; } + +kal_io_result kal_stream_write(kal_stream s, const void* buf, uintptr_t n) { + if (s.h != 1) return kal_io_result{0, KAL_EBADF}; + auto* p = static_cast(buf); + for (uintptr_t i = 0; i < n; ++i) + *reinterpret_cast(0x10000000) = p[i]; + return kal_io_result{n, 0}; +} +} +``` + +⚠️ **没有第二份配置。** 这个后端不提供 `seek`,做法就是**不声明它** —— +`mcpp.toml` 里只有普通的包信息,`seek.cpp` 不存在,符号也不存在, +而消费者侧 `kal::HasSeek` 因此为假(§4.1)。 + +### 6.3 后端怎么被选中 + +**条件依赖,零新增引擎轴**: + +```toml +[target.'cfg(all(arch = "riscv64", os = "none"))'.dependencies] +openkal-uart = "0.1" + +[target.'cfg(os = "linux")'.dependencies] +openkal-linux = "0.1" +``` + +### 6.4 ⚠️ 真内核实现者的额外约束 + +1. **不能假设进程模型 / 用户内核分界 / 全局命名空间** —— 单地址空间内核里没有「进程」 +2. **句柄必须是调用方上下文相关的**,不能是全局整数表下标 +3. **返回形状要能过陷入边界** —— 2 字结构在 RISC-V 上就是 `a0/a1` + +⇒ 三条**全部指向同一个结论**:不透明一个机器字的句柄。 + +--- + +## 7. 上层视角(对上) + +### 7.1 应用直接用 + +```cpp +import openkal.stream; // ⭐ 只写接口名;提供它的是后端(§4.3) + +int main() { + kal::write(kal::stdout(), "hello\n"); + + // 有就用,没有就走别的路 —— 探测是 ADL,不是查表 + if constexpr (kal::HasVectored) { /* writev 形状 */ } + else { /* 逐段写 */ } + + // 要求必须有:文案是规范自己写的 + static_assert(kal::HasSeek, + "this program needs seekable streams"); +} +``` + +⭐ **而且不写 `if constexpr` 也不会错**:直接调 `kal::seek(s, 0)` 在没有后端支持时 +就是**编译期报错并给出规范的原话**(§4.1 实测)—— 这是 §7 里问「能不能内置」的答案: +**能,而且是默认行为,不需要消费者做任何事。** + +ⓘ **KA2 实测:换后端不重编应用** —— 同一个 `app.o` 换掉后端目标文件重链即成。 + +### 7.2 类型化封装(C ABI 之上的零成本层) + +```cpp +export module openkal.stream; +import openkal.stream.caps; + +export namespace kal { + +enum class error : std::int32_t { ok = 0, badf, again, io, nospace, /*…*/ }; + +struct [[nodiscard]] io_result { + std::uintptr_t n; + error e; + constexpr explicit operator bool() const { return e == error::ok; } +}; + +inline io_result write(stream s, std::span b) { + auto r = kal_stream_write(s, b.data(), b.size()); + return { r.n, static_cast(r.err) }; +} + +// 便利重载:字符串 +inline io_result write(stream s, std::string_view sv) { + return write(s, std::as_bytes(std::span{sv.data(), sv.size()})); +} + +} +``` + +⚠️ `std::span` / `std::byte` / `std::string_view` **全都在 freestanding 可用的 103 个头里** +(ⓘ 实测,见 review §4)—— 这一层在裸机上是可以存在的。 + +--- + +## 8. 对接下层硬件:BSP 接线 + +⭐ **openhal 的设备实例可以成为 openkal 的后端** —— 这是接线,不是层级: + +```cpp +// BSP:把这块板的 openhal Serial 接成 openkal 的 stdout +import openhal.serial; + +namespace { + auto& uart = board::uart0(); // openhal 实例(concept,零成本) +} + +extern "C" kal_io_result kal_stream_write(kal_stream s, const void* buf, uintptr_t n) { + if (s.h != 1) return { 0, KAL_EBADF }; + uart.write({static_cast(buf), n}); // 内联进来 + return { n, 0 }; +} +``` + +⚠️ **注意成本**:openhal 是 concept,`uart.write` **可内联**;openkal 是 C ABI, +`kal_stream_write` 是一次真调用。这正是两者契约形态不同的原因(§10)。 + +--- + +## 9. 对接 libc:picolibc(⚠️ 缝是实测出来的) + +ⓘ **实测(picolibc 1.8.12,rv64gc/lp64d)** —— `libsemihost.a` 的 `common_iob.c.o`: + +``` +d __stdio ← FILE 实体 +R stdout / stdin / stderr ← 三个都指向它(同址) +U sys_semihost_putc ← 未定义 +U sys_semihost_getc ← 未定义 +``` + +而 **`libc.a` 根本不定义 `stdout`** —— picolibc 早就把「C 库」和「控制台在哪」分开了。 + +⇒ 移植到 openkal = **一个小 .o**: + +```cpp +// openkal-picolibc/src/console.cpp +#include +import openkal.stream; + +static int kal_putc(char c, FILE*) { + auto b = static_cast(c); + return kal::write(kal::stdout(), {&b, 1}) ? c : EOF; +} +static FILE __kal_stdio = FDEV_SETUP_STREAM(kal_putc, nullptr, nullptr, + _FDEV_SETUP_WRITE); +extern "C" FILE* const stdout = &__kal_stdio; +extern "C" FILE* const stderr = &__kal_stdio; +``` + +⭐ **成本论据**:`struct __file` 里 `put` **本来就是函数指针** +(`int (*put)(char, struct __file*)`)。接到 `kal` 上**不新增任何一层间接**。 + +⇒ **`printf` 和 `kal::write` 汇到同一个底,不产生第二条 I/O 路径。** + +⚠️ 反向也成立(openkal 在 libc **之上**):hosted 后端的 `kal_stream_write` 可以就是 +`fwrite(buf, 1, n, stdout)`。**同一份契约,两个方向。** + +--- + +## 10. 对接 libc++ / gcc 工具链 + +### 10.1 libc++:36 个名字,ⓘ **实测清单** + +ⓘ llvm 22.1.8 的 `__thread/support/` 有 **四个后端**:`pthread.h` `windows.h` `c11.h` +**`external.h`** —— 可插拔线程后端这个形状**在标准库里已经存在**。 + +`_LIBCPP_HAS_THREAD_API_EXTERNAL = 1` 后,要提供 `<__external_threading>`, +面是 ⓘ **36 个名字**(从 `pthread.h` 数出来的,与调研一致): + +``` +mutex×4 __libcpp_mutex_t / _lock / _trylock / _unlock / _destroy +recursive_mutex×5 __libcpp_recursive_mutex_t / _init / _lock / _trylock / _unlock / _destroy +condvar×5 __libcpp_condvar_t / _signal / _broadcast / _wait / _timedwait / _destroy +thread×9 __libcpp_thread_t / _id / _create / _join / _detach / _yield / _sleep_for … +once×1 __libcpp_execute_once (+ __libcpp_exec_once_flag) +tls×3 __libcpp_tls_key / _create / _get / _set +``` + +映射到 `openkal.task` 应当是**一一对应**,不需要适配层: + +```cpp +// openkal 侧 <__external_threading> 的实现骨架 +using __libcpp_mutex_t = kal_mutex; // 不透明,1 字 +inline int __libcpp_mutex_lock(__libcpp_mutex_t* m) { + return static_cast(kal_mutex_lock(*m)); // 直调,零桥接 +} +``` + +⚠️ **这听起来像「又在照抄」,区别是实质的**:`__external_threading` 本来就是给 +**非 POSIX 系统**用的插点,它已经过了「非 POSIX 宿主能不能实现」这道筛;POSIX 没有。 +**照抄一个可移植性插点 ≠ 照抄一个 OS。** + +### 10.2 gcc / libstdc++:gthreads + +ⓘ **实测(gcc 16.1.0)**:`bits/gthr-default.h` 里 `__gthread_*` 共 **71** 个名字, +其中 **20** 个是 `__gthread_objc_*` 遗留 ⇒ **真实面 51**,与 libc++ 的 36 高度重合。 + +⭐ ⓘ 更重要的先例:gcc 自带 **`gthr-single.h`** —— **「没有线程」是一个编译期后端选择**, +不是运行期 `ENOSYS`。**这正是本方案 §4 主张的形状,而且 gcc 已经这么做了三十年。** + +⇒ openkal 的 gcc 侧对接 = 提供一份 `gthr-openkal.h`,和 `gthr-posix.h` 平级。 + +### 10.3 `operator new` 与 `__libcpp_verbose_abort` + +这两个是**标准扩展点**,不需要发明契约: + +```cpp +void* operator new(std::size_t n) { + if (void* p = kal_alloc(n, __STDCPP_DEFAULT_NEW_ALIGNMENT__)) return p; + kal_abort("operator new failed", 20); +} +_LIBCPP_BEGIN_NAMESPACE_STD +[[noreturn]] void __libcpp_verbose_abort(const char* f, ...) _NOEXCEPT { + kal_abort(f, __builtin_strlen(f)); +} +_LIBCPP_END_NAMESPACE_STD +``` + +ⓘ ⚠️ **`__libcpp_verbose_abort` 必须通过 libc++ 自己的头声明** —— 真符号在 ABI 内联 +命名空间 `std::__1::` 里,手写 `namespace std { }` **编得过、链不上、报错一字不变** +(本轮实测踩过)。 + +### 10.4 ⚠️ openkal 明确**不认领** `thread_local` + +ⓘ 实测的静默失败: + +``` +thread_local int counter; → 编译 ✅ 链接 ✅ 零未定义符号 ✅ 零诊断 ✅ + → 运行期通过未设置的 tp 读写垃圾地址 +``` + +`thread_local` **不属于线程契约**,属于 **openarch(TLS 寄存器约定)+ BSP +(`start.S` 设 `tp` + 链接脚本 `.tdata/.tbss`)**。 +而 `__external_threading` 里的 `tls×3` 是 **pthread_key 那种动态 TLS**,不是它。 + +⇒ **规范里必须写死这条边**,否则「我实现了 openkal 的线程」会被读成「`thread_local` 能用了」。 +⚠️ 且 libc++/libstdc++ **自己内部就用 `thread_local`**,不是「用户不写就没事」。 + +--- + +## 11. C++ 特性 → 解决什么问题 + +| 特性 | 解决什么 | ⚠️ 限制 | +|---|---|---| +| **modules** | 接口/caps 分离;**模块找不到 = 能力检查**,把链接期错误提前到编译期 | 需要 mcpp 的 `reexport`/provisions 承载 | +| ⭐ **ADL + `requires`** | ⓘ **非限定名**在模板里是 dependent 的 ⇒ 缺失时求值为 `false` 而不是硬错误。**能力探测不需要额外的数据结构,后端的模块接口就是声明** | ⚠️ **限定名**(`kal::seek`)仍是硬错误 —— 两者的差别是这套设计成立的全部基础 | +| ⭐ **兜底重载 + `static_assert`** | 没有后端时直接调用 ⇒ **编译期报错,文案是规范写的** | ⚠️ 兜底的**返回类型必须与真实现不同**,否则探测恒为真(ⓘ 实测踩过) | +| **`if constexpr`** | 消费者按能力降级,未选中的分支**不实例化** | — | +| **concepts** | 组合 caps;openhal 的多提供者共存 | ⚠️ ⓘ **只检查语法不检查语义**(K1/K2),③ 类能力不能用它 | +| **非类型模板参数(能力位)** | 让「不支持」**在类型系统里不可表达**(§5.1) | — | +| **`static_assert` + 文案** | 把缺能力变成**人能读**的编译错,而不是链接器的符号名 | — | +| **`enum class`** | 封闭错误集合,不透传 errno | — | +| **`[[nodiscard]]`** | `io_result` 不能被静默丢弃 | — | +| **`std::span` / `std::byte` / `string_view`** | C ABI 之上的类型化层 | ⓘ 三者都在 freestanding 可用的 103 头里 | +| **`_Noreturn` / `[[noreturn]]`** | `kal_abort` 的控制流事实进类型 | — | +| ~~virtual / vtable~~ | — | ⛔ 把结构体布局写进 ABI,「只增不改」保护不了 | +| ~~exceptions / RTTI~~ | — | ⛔ 裸机整图关闭(mcpp 2026.8.19.4 起) | +| ~~`requires` 探测**限定名**~~ | — | ⛔ ⓘ 实测:硬错误。**改用非限定 + ADL** | +| ~~`caps` 结构体 / `capabilities.toml`~~ | — | ⛔ 被 ADL 机制整个取代;且第二份配置文件违背生态整洁性 | + +--- + +## 12. 与 openhal / openarch 的关系:**契约形态由基数决定** + +| | 一个程序里几个实现 | 契约 | 为什么 | +|---|---|---|---| +| **openarch** | **1**(一颗 CPU) | concept **+ LTO 必需** | ⓘ K3:`local_irq_disable()` 本该一条 `csrci`;跨模块 `-O2` 退化成真 `jal`,`-flto` 才恢复 | +| **openkal** | **1**(一个内核) | **C ABI** | ⓘ 一次 call 可忽略(本来就陷入);H1 | +| **openhal** | **N**(每条总线/设备) | **concept** | ⓘ H3:多提供者共存 + 零成本可内联 | + +⭐ **openarch 与 openhal 都是 concept,但理由相反**:一个因为调用成本灾难性, +一个因为要多实现共存。openkal 两种压力都没有 ⇒ 用最简单也最稳定的 C ABI。 + +**openkal.stream 与 openhal.Console 的边界**: + +- openkal 的流 = **程序的标准流**:一个、环境给的、借用的 ——「我的诊断往哪去」 +- openhal 的 `Console`/`Serial` = **你打开的一个设备**:多个、拥有的、有类型 + +**是两个名词,不是一个东西的两层。** 自然组合见 §8。 + +--- + +## 13. 判据与门 + +### 13.1 双向准入判据(⚠️ 文档今天只有对下那一半) + +| 方向 | 判据 | 怎么数 | +|---|---|---| +| **对下** | 四个后端自然实现,**没有模拟层** | 写出四个后端的 `kal_stream_write`,数「只为迁就形状而存在」的行数 —— 任何后端需要**表/注册中心/路径解析器**,形状就错了 | +| **对上** | libc / libc++ / gcc 的**既有插点**直接落上,**没有适配层** | `stdout->put` · `operator new` · `__libcpp_verbose_abort` · 36 个 `__libcpp_*` · `gthr-*.h` —— 同样数桥接行数 | + +⇒ **对上判据不满足 ⇒ 说明资源分解切错了,回去改分解** —— 而不是改边界迁就标准库。 + +### 13.2 D0–D3 的门(来自第一阶段计划 §7,不改) + +| 阶段 | ⭐ 继续的判据 | ⚠️ 停止的信号 | +|---|---|---| +| **D0** | **有第三方实现了第三个后端** —— 这才是 D 能否成立的真变量 | 半年内无人实现 ⇒ 停在 D0,当 mcpp 内部设施 | +| **D1** | 三个官方后端全过 conformance;⚠️ windows 后端**不经 MSVC CRT 的 POSIX 兼容层** | windows 做不出来而不模拟 ⇒ **SPEC 形状错了**,回炉 | +| **D2**(openhal) | 同一个驱动包既跑裸机 MCU 又跑 Linux | 驱动作者不来 ⇒ 停;不影响 openkal | +| **D3**(openarch) | ⚠️ 两个最硬原语不碎(上下文切换 / 页表项) | 碎掉 ⇒ **停**;⚠️ 碎在这里后面全是幻觉 | + +--- + +## 14. 明确不做的 + +| | 为什么 | +|---|---| +| **`open(path)` 进 core** | WASIp1 的教训;开文件在 `openkal.fs`(自有句柄类型),不在 core,也不经一个通用命名接口(§2.3) | +| **运行期 `ENOSYS`** | 部分性用「链接哪些 interface」表达;⭐ gcc 的 `gthr-single.h` 已是三十年的先例 | +| **errno 透传** | 封闭 `enum class`;映射不是模拟 | +| **虚接口 / vtable** | ABI 脆弱,与「只增不改」冲突 | +| **`thread_local` 的保证** | 属于 openarch + BSP(§10.4) | +| **引擎认识 openkal** | ⚠️ 计划 §7.2 第一行:**引擎永不认识这三层**;后端选择 = 条件依赖,零新增轴 | +| **现在就实现** | D0 的门是「第三方来了没有」,不是「能不能写出来」 | + +--- + +## 15. 撤回记录 + +⚠️ 草案里被 review 推翻的两条,连同它们**为什么当时看起来对**: + +| 撤回的 | 当时的理由 | 为什么错 | +|---|---|---| +| **`openkal.namespace`**(取代 fs/net) | 「文件 / socket / UART 给你的都是 stream,只是命名方式不同」 | ① 触犯本文自己的 §5.1 规矩(caps 成为不相干能力并集);② URI 解析器**就是**模拟层,违反对下判据;③ 引用的 WASIp2 先例是**误读**(它分开资源种类,只共享 stream 类型) | +| **扩 `cfg()` 文法支持 `cfg(mmu)`** | K1/K2 说「MMU 是能力轴不是契约」 | 那条结论是关于 **openarch** 的;openkal core 逐个接口查下来**一条 ③ 类语义轴都没有**,而且 triple 本身已承载大部分 | +| **`caps` 结构体 + `capabilities.toml`** | 以为「`requires` 测不了缺失的名字」⇒ 能力必须放进另一个可命名的东西 | ⓘ **只对限定名成立**。非限定 + ADL 在模板里是 dependent 的,缺失时求值为 `false`。⇒ 后端的模块接口本身就是能力声明,**两样东西整个删掉**,也不需要第二份配置 | +| **应用 `import openkal.uart;`** | 想让「接口随后端而来」 | ⛔ **把源码钉死在后端上**,推翻 openkal 的立身之本。ⓘ 而且掩盖了真约束:**ADL 到不了未 import 的模块**(实测)。正解是**后端提供接口名**(§4.3) | + +⭐ 三条的共同形状:**都是「我有一个漂亮的统一」,而漂亮的统一把两件本来不同的事合并了。** +这与 §5.1 给出的判据是同一条 —— 只是那一节我用它去检查别人的后端,没有用它检查自己的分解。 + +--- + +## 16. 综合 review 发现的开放问题 + +撤回两条之后重新通读,又找出六条 —— 都是**规范必须表态、但草案没表态**的。 + +### 16.1 ⚠️ 两个堆(ⓘ 实测,不是理论) + +ⓘ picolibc `libc.a` 的 `vfprintf.c.o` **引用 `free`** —— **printf 与分配器是耦合的**。 +而固件里已有一整套 `malloc`/`free`/`__malloc_sbrk_aligned`/`__fallback_sbrk`。 + +⇒ 若 `operator new` 走 `kal_alloc`(openkal 的 arena)而 `printf` 走 picolibc 的 +`malloc`,**同一块 RAM 上会有两个分配器**,而且都想长 sbrk。 + +**规范必须写死一条**: + +> **后端上若已存在 libc 分配器,`kal_alloc` 必须实现在它之上,而不是与它并列。** + +三种配置: + +| 后端 | 做法 | | +|---|---|---| +| picolibc | `kal_alloc` → `malloc`(openkal 在 libc **之上**) | ✅ 一个堆 | +| 零 libc | `kal_alloc` → 自带 arena;没有 libc malloc | ✅ 一个堆 | +| ⛔ 自带 arena **且** libc malloc 也在 | — | **禁止** | + +⚠️ 这条部分可 conformance 化:检查固件里 `sbrk` 的消费者是不是只有一个。 + +### 16.1b ⚠️ 短写:`kal_stream_write` 写不全时怎么办(§20.3 逼出来的) + +写参考实现时立刻撞上:`::write(2)` 可以短写。SPEC 必须选一边: + +| | 后果 | +|---|---| +| **写全或报错**(推荐) | 循环在**后端**里写一次 | +| 允许短写 | ⚠️ **每个调用方**都要自己写循环 —— 这正是 POSIX 让无数程序出错的地方 | + +⇒ 建议规定「写全或报错」,短写只在 `kal_stream_write_some`(若真需要)里出现。 + +### 16.2 ⚠️ 错误集合的封闭性 + +草案说「封闭 `enum class`,不透传 errno」。但 POSIX 有约 130 个 errno, +一个 ~15 项的封闭集合**必然丢信息**。规范要表态: + +- 丢掉细节(简单,但诊断质量下降),还是 +- 留一个 `other` + **后端私有的细节通道**(`kal_last_error_detail()`), + ⚠️ 但那是全局状态,和 errno 一样的毛病 + +**倾向**:封闭集合 + 细节通道**只用于日志**,不进控制流。需要写进 SPEC。 + +### 16.3 ⚠️ C ABI 没有版本,而结构体布局会被永久冻结 + +「每个 interface 独立版本 + 只增不改」保护得了**函数**(加新函数是可加的), +保护不了**结构体**:`kal_io_result` 的布局一旦发布就永远不能动。 + +选项:符号带版本后缀(`kal_stream_write_v1`,难看但诚实),或**明确声明这些布局永久冻结**。 +草案默认了后者却没写出来。 + +### 16.4 ⚠️ 拥有 vs 借用,C ABI 强制不了 + +标准流是**借用**(不配 `close`),`openkal.fs` 的 descriptor 是**拥有**(必须 close)。 +C ABI 里没有 RAII,谁来保证? + +⇒ 类型化 C++ 层可以包 RAII,但**那不是规范的一部分**,Rust/C 消费者拿不到。 +规范至少要把「哪些句柄是拥有的」写清楚,并规定重复 close 的行为。 + +### 16.5 ⚠️ 线程安全未规定 + +同一个 `kal_stream` 被两个 task 同时 `write`,是什么行为? +POSIX 至少规定了 `PIPE_BUF` 以内的原子性。**草案一个字没提。** +这条在有 `openkal.task` 之后立刻变成必须回答的。 + +### 16.6 ⚠️ sized-free 的方向性成本 + +`kal_free(p, size, align)` 对 arena 友好(Rust 的做法),但: + +- hosted 后端 `kal_free` → `free(p)`,**丢掉 size**:无成本 ✅ +- 反向:一个建在 `kal_alloc` 之上的 libc `malloc` **必须自己存 size** ⇒ 每次分配多一个字 + +⚠️ 与 §16.1 的规则合看,反向配置本来就该避免,所以成本可控 —— 但要写明。 + +--- + +## 17. 这一轮 review 的元结论 + +三条撤回(§15)+ 六条开放问题(§16)里,有一个共同形状值得单独记: + +草案的错误分两族。 + +**族一:一个漂亮的统一,把两件本来不同的事合并了。** + +- `openkal.namespace` 合并了「命名」与「资源种类」 +- `cfg(mmu)` 把 openarch 的结论搬进 openkal +- 「两个堆」是**没有**合并该合并的(分配器) + +而 §5.1 给出的判据 —— **「一个操作若能『存在但永远失败』,说明它被错误地合并了」** —— +本来就能抓住前两条。⚠️ **我只用它去检查别人的后端,没有用它检查自己的分解。** + +**族二:测了一种写法,把结论推广到了全部。** + +⚠️ `caps` 结构体 + `capabilities.toml` 那整套,建立在**一次实测**上: +「`requires { mcpp::runner("x") }` 是硬错误」。那次实测本身没错, +错在**结论的量词** —— 我写的是「C++ 语言内测不出来」,而真实情况是 +**「限定名测不出来」**。非限定 + ADL 一直是可以的。 + +⇒ 代价是**一整节设计 + 一份多余的配置文件格式**,而验证它只需要三行代码。 + +⭐ **两族合起来的规律**:一条实测能否定一个做法,**但否定不了一整类做法** —— +写下「X 做不到」之前,要先问「我测的是 X,还是 X 的某一种写法」。 + +--- + +## 18. 第二轮综合 review 新增的两条 + +### 18.1 ⭐ 基数是「每个 interface 一个实现」,不是「每个程序一个后端」 + +草案 §12 说 openkal 的基数是 1,含糊在于**1 个什么**。澄清: + +```toml +[target.'cfg(os = "none")'.dependencies] +openkal-uart = "0.1" # 提供 openkal.stream +openkal-arena = "0.1" # 提供 openkal.memory +``` + +**一个程序可以从不同提供者拿不同的 interface** —— 这是好事,而且是 §2 资源分解的 +自然结果。冲突只发生在**同一个 interface 有两个提供者**时(两个包都导出 +`openkal.stream`),那是模块名冲突,**编译期报错**。 + +⇒ 「基数 1」应当读作:**每个 interface 的实现是 1 个**。契约形态(C ABI)的论据不变。 + +### 18.2 ⚠️ 兜底重载太贪心,文案会张冠李戴 + +```cpp +template unsupported_t seek(S, long) { + static_assert(always_false, "…no seekable streams…"); +} +``` + +`S` 无约束 ⇒ `kal` 里**任何**类型调 `seek` 都落到这里。 +`seek(some_socket, 0)` 会得到「no seekable **streams**」—— 名词错了。 + +**修法**:兜底要约束到它该管的类型,或者文案改成不带具体名词的。 + +```cpp +template requires std::same_as +unsupported_t seek(S, long) { … } +``` + +⚠️ 这条小,但它是 §5.1 那条判据的又一次应用:**兜底重载的适用范围也是一种「分类」**, +分类过宽,诊断就会指向错误的地方。 + +--- + +## 19. 设计现状小结(第二轮 review 后) + +| 维度 | 状态 | +|---|---| +| **位置无关**(上/下 libc) | ✅ 由不透明句柄承载,ⓘ 三种后端实测 | +| **划分依据** | ✅ 资源种类;⚠️ 曾塌缩过头(namespace),已撤回 | +| **core 边界** | ✅ abort + stream + memory,判别式是「实现 vs 模拟」 | +| **能力探测** | ✅ ADL + 兜底重载,ⓘ 真模块上三行为同时成立;**零额外配置** | +| **模块名归属** | ✅ 后端提供接口名(语言逼出来的),⚠️ 碎片化靠 spec + 名字集合 diff 封住(静态可查) | +| **依赖形状** | ✅ 契约 + 后端两条,契约那条用来**钉版本** | +| **引擎改动** | ✅ **零** | +| **对下/对上判据** | ✅ 双向且可数(数桥接行数) | +| ⚠️ **开放问题** | §16 六条 + §18 两条,**全部需要 SPEC 表态** | +| ⚠️ **最大风险** | 不是技术 —— 是 **D0 的门:有没有第三方来实现第三个后端**。⭐ 缓解手段是 §20 的官方 `openkal-linux` 完整参考实现:**降低门槛,但不移动门** | + +--- + +## 20. 官方参考实现:`openkal-linux`(完整) + +⭐ **它有两个身份**:一个**当天可用**的后端,和**其它实现者要抄的那份样板**。 + +⚠️ 它**不能移动 D0 的门**(门是「有第三方实现了第三个后端」),但它把门**变得够得着** —— +没有可抄的样板时,第三方要同时猜形状和写实现。 + +### 20.1 ⭐ 写这份实现时才发现的一条:core 操作根本不需要 ADL + +| | 声明在哪 | 后端提供什么 | 探测机制 | +|---|---|---|---| +| **core 操作**(write/read/alloc/abort) | 接口包 `extern "C"` + C++ 封装 | **只有 C 函数的定义** | **不需要** —— core 的定义就是「一定在」 | +| **可选能力**(seek/vectored/…) | 后端声明 C++ 重载 | 声明 + 定义 | **ADL**(§4.1) | + +⇒ ADL 那套只服务**可选能力**。core 走最简单的路:接口声明,后端定义,缺了就是链接错误。 + +### 20.2 包结构 + +``` +openkal-linux/ +├── mcpp.toml +└── src/ + ├── stream.cppm export module openkal.stream; ← 提供接口名 + ├── stream.cpp extern "C" 定义 + ├── memory.cppm export module openkal.memory; + ├── memory.cpp + ├── abort.cppm export module openkal.abort; + └── abort.cpp +``` + +```toml +[package] +name = "openkal-linux" +version = "0.1.0" + +[dependencies] +openkal = "0.1" # 契约 + +[target.'cfg(not(linux))'.build] +# 这个后端只在 linux 上有意义;别的 target 上它不该被选中 +``` + +### 20.3 `openkal.stream` + +```cpp +// src/stream.cppm —— 提供应用可见的名字,自己不加任何非标准的东西 +export module openkal.stream; +export import openkal.decl.stream; +// core 操作无需在此声明:它们是 openkal.decl.stream 里的 extern "C" + 封装。 +// 这个后端也不提供 seek —— 见 20.6,那不是疏漏。 +``` + +```cpp +// src/stream.cpp +#include +#include +import openkal.decl.stream; + +extern "C" { + +kal_stream kal_stdin (void) { return kal_stream{0}; } +kal_stream kal_stdout(void) { return kal_stream{1}; } +kal_stream kal_stderr(void) { return kal_stream{2}; } + +kal_io_result kal_stream_write(kal_stream s, const void* buf, uintptr_t n) { + auto* p = static_cast(buf); + uintptr_t done = 0; + while (done < n) { + ssize_t r = ::write(static_cast(s.h), p + done, n - done); + if (r < 0) { + // ⚠️ EINTR 必须重试。漏掉它的后端在有信号的系统上会随机短写, + // 而这类 bug 在测试里几乎不出现。 + if (errno == EINTR) continue; + return { done, kal_from_errno(errno) }; + } + if (r == 0) break; + done += static_cast(r); + } + return { done, 0 }; +} + +kal_io_result kal_stream_read(kal_stream s, void* buf, uintptr_t n) { + for (;;) { + ssize_t r = ::read(static_cast(s.h), buf, n); + if (r < 0) { if (errno == EINTR) continue; return { 0, kal_from_errno(errno) }; } + return { static_cast(r), 0 }; // 短读是正常的,不重试 + } +} + +int32_t kal_stream_flush(kal_stream) { return 0; } // 裸 fd 无用户态缓冲 + +} +``` + +⚠️ **写这段逼出了一个 SPEC 必须回答的问题**(§16 没覆盖): + +> `kal_stream_write` 返回**短写**,还是**写全或报错**? + +上面选了「循环到写全」。若 SPEC 选另一边,**每个调用方都要自己写这个循环** —— +这正是 POSIX 让无数程序出错的地方。⇒ **建议 SPEC 规定「写全或报错」,短写只在 +`kal_stream_write_some`(如果需要)里出现。** + +### 20.4 `openkal.memory` —— 演示 §16.1 的规则 + +```cpp +// src/memory.cpp +#include + +extern "C" { +// ⭐ 建在 libc 分配器之上,不是与它并列 —— §16.1 的规则,这里是它的正面示例。 +void* kal_alloc(uintptr_t size, uintptr_t align) { + if (align <= alignof(max_align_t)) return ::malloc(size); + return ::aligned_alloc(align, (size + align - 1) / align * align); +} +// sized-free:这个方向丢掉 size 是零成本的(§16.6) +void kal_free(void* p, uintptr_t, uintptr_t) { ::free(p); } +} +``` + +### 20.5 `openkal.abort` + +```cpp +// src/abort.cpp +#include +#include + +extern "C" { +[[noreturn]] void kal_abort(const char* msg, uintptr_t len) { + if (msg && len) { ssize_t r = ::write(2, msg, len); (void)r; } + ::abort(); +} +[[noreturn]] void kal_exit(int32_t code) { ::_exit(code); } +} +``` + +⚠️ `_exit` 而不是 `exit`:`exit` 会跑 atexit 与静态析构,而 `kal_exit` 的契约是 +「立刻结束」。这类差别**必须写进 SPEC**,否则两个后端的语义会悄悄分叉。 + +### 20.6 ⭐ 参考实现验证了 fs/net 的分解(§2.3) + +写 Linux 后端时会立刻撞上一件事: + +> **Linux 上「能不能 seek」是每个句柄的属性,不是后端的属性。** +> `lseek(fd)` 对普通文件成功,对管道 `ESPIPE`。 + +⇒ 如果 `openkal.stream` 有 `seek`,Linux 后端**无法诚实回答** —— +声称有,则对管道永远失败(**正是 §5.1 的「存在但永远失败」反模式**); +声称没有,则文件用不了。 + +⭐ **而 §2.3 的分解让这个问题不存在**:seek 属于 `openkal.fs` 的 descriptor 类型, +`openkal.stream` 压根没有它。**参考实现独立地证实了那次撤回是对的。** + +⇒ **这是「写一份完整实现」最大的价值:它是唯一能发现分解错误的方法, +而且比 conformance 更早。** + +### 20.7 它作为样板教什么 + +| 样板里的模式 | 其它实现者照抄什么 | +|---|---| +| `stream.cppm` 只有 `export import`,不加任何东西 | **不要往标准模块名里塞私货**(§4.4) | +| EINTR 循环 | 每个后端都要处理自己平台的「被打断」 | +| `kal_alloc` 走 `malloc` | §16.1:有 libc 分配器就建在它之上 | +| `kal_from_errno` 是一张**表** | **映射 ≠ 模拟**(§3.1) | +| 没有 `seek` | 能力缺失就是**不声明**,不是声明后返回错误 | +| `_exit` 而非 `exit` | 语义细节要向 SPEC 对齐,不要凭直觉 | + +### 20.8 对 D0 的影响:降低门槛,不移动门 + +| | | +|---|---| +| D0 的门 | **有第三方实现了第三个后端** —— 不变 | +| 官方 linux 后端做的事 | 把「猜形状 + 写实现」减成**只写实现** | +| ⚠️ 不做的事 | 它**不算**第三方后端,也不算第三个后端(linux/bare 是官方的两个) | + +⇒ 判据仍然是**别人来不来**,而这份实现让「来」这件事从一个季度变成一个周末。 diff --git a/.agents/docs/2026-08-20-openkal-implementation-plan.md b/.agents/docs/2026-08-20-openkal-implementation-plan.md new file mode 100644 index 00000000..3cbdc90b --- /dev/null +++ b/.agents/docs/2026-08-20-openkal-implementation-plan.md @@ -0,0 +1,186 @@ +# openkal: implementation plan and outcome + +This document records the plan by which openkal 0.1 was implemented, the +dependencies among its tasks, the criteria applied to each, and the result. It +accompanies the design analysis in +[`2026-08-20-openkal-design.md`](2026-08-20-openkal-design.md) and the +specification itself, which is maintained in the `mcpplibs/openkal` repository. + +## 1. Deliverables + +| Repository | Contents | Version | +| --- | --- | --- | +| `mcpplibs/openkal` | the specification, the modules that declare it, the surface checker, and a substitution example | 0.1.0 | +| `mcpplibs/openkal-linux` | the reference implementation for Linux, its conformance suite, and an example | 0.1.0 | +| `mcpplibs/mcpp-index` | descriptors for both packages | pull request 220 | + +Both packages are mirrored to GitCode, and the mirrored archives were verified +to be byte-identical to those served by GitHub. + +## 2. Task dependencies + +The work divides into five groups. Groups A and B are independent of each +other; C depends on both; D and E follow C. + +``` +A. specification text ────┐ + ├──► C. reference implementation ──► D. conformance +B. declaration modules ───┘ │ + └► E. distribution +``` + +The order is not arbitrary. Writing the reference implementation before +completing the specification would have produced a specification describing one +implementation, which clause 7.1 of the specification exists to prevent. +Deferring the reference implementation until after distribution would have +deferred the discovery reported in section 4.3. + +| Group | Tasks | Depends upon | +| --- | --- | --- | +| A | scope, interface inventory, capability model, conformance procedure | the design analysis | +| B | `openkal.decl.types`, `.abort`, `.stream`, `.memory` | A | +| C | `openkal.abort`, `.stream`, `.memory` for Linux | A, B | +| D | behavioural suite, absence assertions, exported-surface comparison | C | +| E | repositories, tags, mirrors, index descriptors, verification | D | + +## 3. Criteria applied + +The following criteria were fixed before implementation and applied to each +decision. Where a decision satisfied one criterion at the expense of another, +the resolution is recorded. + +### 3.1 Architecture + +An interface is the unit of provision and of versioning. An implementation +provides an interface in whole or not at all, and an interface that is not +provided is absent as a module rather than present and refusing. + +The consequence for the module layout is stated in clause 4 of the +specification: the implementation owns the name a consumer imports, because +argument-dependent lookup does not reach a module the translation unit has not +imported. The alternative arrangement, in which the specification package owns +that name, was rejected because it would have made optional capabilities +undetectable. + +### 3.2 Stability + +The layout of every structure is frozen at 0.1, and the evolution rule admits +new declarations while excluding changes to existing ones. The C surface carries +no version in its symbol names; the specification records this as an unsettled +matter rather than concealing it. + +The reference implementation retries interrupted operations rather than +reporting them. The alternative produces short transfers on any system that +delivers asynchronous notifications, and such a defect is unlikely to be +reproduced by a test suite. + +### 3.3 Simplicity + +The capability mechanism uses argument-dependent lookup and a fallback overload. +An earlier design used a record of capability flags together with a separate +configuration file, and both were removed. A record can disagree with the code +it describes; a declaration cannot. The removal also eliminated a second +configuration format from the ecosystem, in which every other fact about a +package resides in `mcpp.toml`. + +### 3.4 User experience + +A consumer that calls an operation the implementation does not provide is +rejected during compilation, and the diagnostic carries the wording the +specification supplies. This is the default behaviour and requires nothing of +the consumer: no capability test, no configuration, and no annotation. + +A consumer that wishes to adapt rather than fail uses the concept the interface +provides, which evaluates to false when the operation is absent. + +### 3.5 Compatibility + +A consumer declares two dependencies. The second selects an implementation; the +first fixes the version of the contract, and converts a mismatch between +consumer and implementation into a failure of dependency resolution rather than +a collection of signature errors at compile time. + +Neither package raises the index floor, and neither requires a version of mcpp +beyond one already published. + +### 3.6 Portability + +The handle is one machine word and opaque. An implementation stores a +descriptor, an operating-system handle, a pointer to a driver structure, or a +capability index, and none is required to maintain a translation table. This is +the property that permits an implementation to be placed above a C library, +beneath one, or without one. + +The specification does not assume a process model, a division between +privileged and unprivileged execution, or a namespace shared by all callers. + +### 3.7 Consistency + +The specification adopts the vocabulary already established in this ecosystem: +capability absence is expressed at compile time, backend selection is a +conditional dependency, and no new axis is introduced into the build system. The +implementation of openkal required no change to mcpp. + +### 3.8 Upgrade without disruption + +Version 0.1 introduces two packages and modifies none. No existing project is +affected, and the criterion is satisfied trivially. It is recorded so that +subsequent versions, which will not satisfy it trivially, are measured against +it. + +## 4. Verification performed + +### 4.1 Language constructs + +Exporting `extern "C"` declarations from a module, and calling them from a +consumer that imports the module, was verified before the interface was written. + +### 4.2 Substitution + +One application source was compiled against two implementations, one writing to +descriptors and one discarding every transfer. The observable behaviour differed +and the source did not: its checksum was taken before the first build and +compared after the second. The example is retained in the specification +repository and is exercised by its continuous integration. + +### 4.3 A decomposition confirmed by implementation + +Writing the Linux implementation confirmed clause 6.3 independently of the +reasoning that produced it. On Linux, whether a stream can be repositioned is a +property of the individual descriptor: the same implementation succeeds for a +regular file and fails for a pipe. Had `openkal.stream` offered positioning, the +implementation could have neither claimed it honestly nor withheld it usefully. + +This is the argument for writing a complete reference implementation rather than +a sketch. A decomposition error of this kind is not visible in the +specification text, and a conformance suite would find it later. + +### 4.4 The surface checker + +The exported-surface comparison required by clause 9.3 was verified in both +directions: it accepts the reference implementation, and it rejects the same +implementation after an unspecified name is added. The negative direction is the +one that establishes the check is not vacuous, and an earlier version of it was +vacuous, comparing a set of C++ symbols that inline functions never emit. + +### 4.5 Conformance + +The suite verifies that the operations provided behave as specified and that the +operation not provided is absent, the latter as a compile-time assertion. + +## 5. Matters deferred + +| Matter | Reason | +| --- | --- | +| `openkal.time`, `.task`, `.fs`, `.net`, `.channel` | 0.1 specifies the core set; the remaining names are reserved | +| Concurrency upon one handle | unavoidable once `openkal.task` is specified, and not before | +| Symbol versioning | the evolution rule protects the interface by prohibiting change; an ecosystem that outgrows the prohibition will need a mechanism this version does not define | +| A shared conformance package | the suite presently resides with the implementation; a reusable package requires a mechanism for a test package to be compiled against an implementation chosen by a third project | + +## 6. The gate that governs what follows + +The design analysis records that the criterion for continuing beyond D0 is not +technical: it is whether a third party implements a third backend. The reference +implementation does not satisfy that criterion and does not alter it. Its effect +is to reduce the work such a party must perform from inferring a shape and +writing an implementation to writing an implementation. diff --git a/.agents/docs/2026-08-20-pr455-459-freestanding-review.md b/.agents/docs/2026-08-20-pr455-459-freestanding-review.md new file mode 100644 index 00000000..cea4e917 --- /dev/null +++ b/.agents/docs/2026-08-20-pr455-459-freestanding-review.md @@ -0,0 +1,357 @@ +# PR #455–#459 深度 review:裸机 freestanding 从「能编」到「能用」 + +**范围**:`mcpp-community/mcpp` #455 · #456 · #457 · #458 · #459,以及同期的 +`openxlings/xim-pkgindex` #651/#652/#653(+ 三条版本 bump)、`mcpplibs/mcpp-index` +#219/#220、两个新建仓库 `mcpplibs/riscv-virt-rt` 与 `mcpplibs/std-freestanding`。 + +**时间**:2026-08-19 一日之内(#455 合入 → #459 合入),发布 `2026.8.19.1` … `2026.8.19.4`。 + +**配套**:用户面场景与伪代码见 [裸机使用场景](2026-08-20-baremetal-user-facing-scenarios.md)。 + +⚠️ **本文的定位是 review 而不是总结**:凡是「做了什么」都给出可复核的出处(PR 号、 +文件、命令),凡是「做错了什么」都写明**发现方式**与**当时为什么没看见**。 +一日四个补丁版本本身就是一个信号,第 6 节专门分析它。 + +--- + +## 0. 一句话结论 + +引擎侧的形状是对的,而且**收敛得比计划更小**;真正的成本几乎全在 +**「我把属于 target 的东西写成了包的依赖」** 这一类边界判断上 —— +五个已修缺陷里有三个是同一个根因的不同表现。 + +判据(用**已发布**二进制、从零验证): + +```bash +mcpp new blinky --template riscv-virt-rt +cd blinky && mcpp run # qemu 里打印;manifest 里没有 [target.*] 段 +``` + +```toml +[package] name = "blinky" / version = "0.1.0" +[build] target = "riscv64-none-elf" +[dependencies] riscv-virt-rt = "0.3.0" +``` + +--- + +## 1. 五个 PR 各做了什么 + +| PR | 规模 | 内容 | 版本 | +|---|---|---|---| +| **#455** | +6355/−18,34 文件 | 裸机 target 成立:`riscv64/32-none-elf` 进目标表、新建 `src/freestanding/`(`target`/`linkline`/`runner`)、`mcpp:link-script=` 指令(protocol 3)、`mcpp::xpkg_dir` | 2026.8.19.1 | +| **#456** | +2/−2 | 发布 | — | +| **#457** | +1388/−49,24 文件 | **runner 归 BSP**(`mcpp:runner=`,新 `Slot::Runner` + `Scope::RunGlobal`,protocol 4)、裸机 `mcpp test`、产物集(`.bin`/`.map`/size 摘要) | 2026.8.19.2 | +| **#458** | +109/−3,5 文件 | 修 `mcpp build` 之后 `mcpp run` 在宿主上直接 exec 裸机 ELF | 2026.8.19.3 | +| **#459** | +753/−45,21 文件 | 异常/RTTI 整图关闭、**目标自带 sysroot 轴**、`toolchain_dir`/`sysroot_dir`、依赖缓存键补 target flags、std 子集打通 | 2026.8.19.4 | + +累计(从 #455 之前的 `9c5cc8d` 算起,含同期其它工作):**87 文件 / +10330 / −161**。 +其中 `src/freestanding/` 三个模块共 **432 行**,裸机 e2e 四个脚本共 **983 行**, +`test_freestanding.cpp` **25 个单测**。 + +**指令表 13 → 15 行,protocol 2 → 4。** 两条新指令(`link-script`、`runner`)各自 +只占表里一行 —— 这是 #455/#457 最值得记的一点,见 §2.1。 + +### 生态侧 + +| 仓库 | 内容 | +|---|---| +| `openxlings/xim-pkgindex` | #651 `qemu-riscv 9.2.4-1`(五个宿主目标)· #653 `picolibc-riscv 1.8.12`(自建,`rv64gc/lp64d` + `rv32imac/ilp32`)· #652 修镜像工具两个缺陷 · #654/#655/#657 版本 bump | +| `mcpplibs/mcpp-index` | #219 收录 `riscv-virt-rt`(已合)· #220 `std-freestanding 0.2.0` + `riscv-virt-rt 0.3.0`(待 2026.8.19.4 生效) | +| `mcpplibs/riscv-virt-rt` | 板级支持包,0.1.0 → **0.3.0**;含 `templates/blinky`(`mcpp new --template`)、examples、仓库 CI(rv64+rv32 真跑 qemu) | +| `mcpplibs/std-freestanding` | freestanding std 子集,0.1.0 → **0.2.0**;`tools/regenerate.sh` 实测生成 | + +两个包 GitHub + GitCode 双镜像,tarball **逐字节校验一致**。 + +--- + +## 2. 架构评估 + +### 2.1 ⭐ 最好的一条:能力增长没有换来概念增长 + +裸机需要三样东西是引擎原本表达不了的:**链接脚本**、**执行方式**、**目标 C 库位置**。 +三样落地为: + +- `link-script` —— 指令表**一行**(`Scope::LinkGlobal`,`Transform::LinkerScript`); +- `runner` —— 指令表**一行** + 一个新 `Slot` + 一个新 `Scope`; +- 目标 sysroot —— `TargetInfo` **一个字段**,与 `pin` 并列。 + +⚠️ **计划里原本有更大的方案,是实测把它们砍掉的**: + +| 计划写的 | 实测 | 结果 | +|---|---|---| +| W8「带序 startup-objects 具名槽」 | `-lcrt0-semihost` 从**归档**里拉得进来,顺序由链接脚本 section 序决定 | ⛔ 整个槽位机制不需要,真正缺的只有 `-T` | +| §4「裸机 `mcpp test` 要 batch 模式 + 结构化 stdout 协议」 | qemu 冷启动 **12ms**(不是计划写的 0.4s);semihosting **把固件 `main` 返回值原样传给 qemu 退出码** | ⛔ 六个设计决定里四个不必做,R3 缩成「走 `mcpp run` 用的同一个 runner」 | +| W12 `requires-hard` 能力硬门 | 同一个 token 要同时表达「平台本来没有」与「runner 配错了」 | ⛔ 实现后用了一次,macOS e2e **一小时内证伪**,整个机制撤销 | + +⇒ **这三条是本轮质量的主要来源**:计划里凡是带具体数字或「A 不成立所以要 B」的句子, +数字与断言本身就是必须先测的探针。 + +### 2.2 ⭐ 作用域的不对称是刻意的,而且被两侧钉住 + +`directives.cppm` 里 `link-search`/`link-lib`/`link-script` 是 **LinkGlobal**(到达消费者), +`include-dir`/`cflag`/`cfg` 是 **PackagePrivate**(不到达)。这条供应链纪律让 +**引擎完全不需要 sysroot 概念**就能让 BSP 工作:目标头由包私有 include、对外只 export 一个模块。 + +e2e/131 从**两侧**钉它:目标 C 头**必须**到达消费者,板级包**自己的**头**必须不**到达。 + +### 2.3 ⚠️ 最大的架构错误:把 target 的属性写成了包的依赖 + +`riscv-virt-rt` 与 `std-freestanding` 都曾声明 +`[xlings] deps = ["xim:picolibc-riscv@1.8.12"]`,后者还声明了 `xim:llvm`。 +**这是用户 review 时指出的**,三条都对:不该绑 libc、不该绑架构、不该绑编译器实现。 + +真因是**引擎的结构性缺口**,不是包写得随意: + +| | 编译器 | 目标 libc | +|---|---|---| +| hosted(`x86_64-linux-musl`) | 目标表 `pin` **自动** | musl 在 gcc 载荷里 / glibc 走 `PayloadPaths` —— **自动**,从没人写过 `xim:glibc` | +| freestanding | 目标表 `pin = llvm@22.1.8` **自动** | **一条轴都没有** ⇒ 外溢到每个包 | + +⇒ #459 给 `TargetInfo` 加 `sysroot` 轴,复用现有 `[xlings] deps` 物化通道安装, +引擎发 `-isystem /include/<档位>` 与 `-L /lib/<档位>`。 +再补两个「问引擎」的接口 `mcpp::toolchain_dir()` / `mcpp::sysroot_dir()`。 + +**结果**:`std-freestanding` 变成**零依赖**,`riscv-virt-rt` 只剩模拟器(那确实是板级事实)。 +分界线也随之清楚了:**位置是目标的事实,选择是板级的事实。** + +⚠️ **这个错误能存在两个版本,是因为它在装好的机器上完全不可见** —— 见 §5.2。 + +### 2.4 包命名与归属:两条都符合既有先例(review 时被问到) + +**`picolibc-riscv` / `qemu-riscv` 把 target 写进包名,对吗?** —— 对,而且是 xim 的既有规则: + +``` +aarch64-linux-musl-gcc riscv64-linux-musl-gcc x86_64-linux-musl-gcc +mingw-cross-gcc mingw-w64 musl-gcc +picolibc-riscv qemu-riscv +``` + +⭐ **名字承载 TARGET,`archs` 轴承载 HOST**(`llvm` 的 `archs = {x86_64, arm64}` 是宿主)。 +`llvm` 名字里没有 target,因为它一份载荷服务所有 target —— 规则是一致的,不是巧合。 + +粒度落在 **ISA 家族**而不是 triple:`riscv64-none-elf` 与 `riscv32-none-elf` 共用一个 +`picolibc-riscv`(内含两个 multilib 档位),这正是 picolibc 上游一次构建的产物单位。 + +**picolibc 该在 mcpp-index 侧还是 xim 侧?** —— **xim 侧,构建期由目标行解析**,也就是 #459 的做法。 + +| 判据 | picolibc | +|---|---| +| 可 `import` 吗 | ❌ 没有 C++ 模块,是头 + `libc.a` + crt0 + 链接脚本 | +| 进依赖图吗 | ❌ 由 **target** 选定,不由用户的依赖图选定 | +| 生态里同类的东西在哪 | `glibc` `musl` `musl-cross-make` **全在 xim**;`mcpp-index` 里 libc 包 **0 个** | + +⇒ 放进 mcpp-index 会把 libc 重新塞回包依赖图,**正好撤销 #459**。 + +⚠️ **但确实缺一个旋钮**:`TargetEntry` 今天有 `toolchain`/`linkage`/`runner`/`cxxRuntime`, +**没有 `sysroot`** ⇒ 工程无法把 picolibc 换成 newlib。 +自然形态是 `[target.X] sysroot = "xim:newlib-riscv@…"`,与 `[toolchain]` 覆盖编译器同构。 +**未实现,列入 §8。** + +### 2.5 单一读取点 + +`choose_runner(ctx)` 是「产物怎么执行」的**唯一**读取点,`mcpp run` 与 `mcpp test` 共用。 +这是本仓库反复付过学费的形状(#233/#240/#242/#344:同一决策两处推导)。 +覆盖语义也留了:`[target.X].runner` 覆盖 BSP 供的,调试时换 `-bios` 是正当需求。 + +--- + +## 3. 兼容性评估 + +### 3.1 ⭐ 已发布数据不得让已发布程序失效 + +索引 `min_mcpp` **没有抬**(memory:`index-floor-must-degrade`)。 +新包进索引不会让老客户端拿不到任何包。`riscv-virt-rt` 0.1.0/0.2.0 **留在索引里**, +因为「已经发布过的版本就是可能有人 pin 过的版本」。 + +### 3.2 ⚠️ 类型化 API 没有语言内的特性探测(实测) + +```cpp +if constexpr (requires { mcpp::runner("x"); }) // 名字不存在 ⇒ 硬错误,不是 false +``` + +`requires` 表达式作用在**不存在的限定名**上是 ill-formed。⇒ 包**无法**优雅降级; +而 wire 协议那条点名 `mcpp self update` 的诊断也够不着(它要求程序**先编译得过**)。 + +⇒ 补在引擎侧:`build.mcpp` 编译失败且错误含「不是 `mcpp` 的成员」时追加升级提示, +三个前端三种拼写都认。**对以后每一次类型化 API 新增都生效**,不只是 `runner`。 + +### 3.3 ⚠️ 升级即坏的缓存缺陷(#459 修) + +依赖缓存键有 `targetTriple`,**没有 triple 隐含的那组 flag**。而**哪些 flag 由 triple 隐含 +是 mcpp 的决定**,会随版本变、triple 字符串不变。 + +⇒ 加 `-fno-exceptions` 那一刻,所有已建过的裸机工程**复用升级前的 BMI**,硬失败, +而错误只点名一个 `.pcm` 文件: + +``` +error: exception handling was enabled in precompiled file +'mcpplibs.riscv_virt_rt.pcm' but is currently disabled +``` + +已加 `targetImpliedFlags` 轴(hosted 为空,不动任何现有键),单测钉「加一个 flag 键必须变」。 + +### 3.4 ⚠️ 一条指向不存在的包的诊断(自造,已修) + +第二阶段的 `import std` 诊断末尾给了可直接粘贴的 +`mcpplibs.std.freestanding = "0.1"` —— **这个包当时没发布**。用户粘完下一条命令报 +`package not found`。⇒ **诊断里的每条建议都是承诺**;现在包发布了,那一行才放回去。 + +### 3.5 破坏性变更盘点 + +| 变更 | 影响面 | 处置 | +|---|---|---| +| `mcpp run` 位置参数改名 `target` → `bin` | **零**(只出现在 `--help`,`cmd_run` 按下标读) | — | +| `mcpp run --target-triple` | 保留为别名(2026.8.19.1 已发布的拼写),e2e/130 两个拼写都钉 | — | +| freestanding 加 `-fno-exceptions -fno-rtti` | 裸机工程全体 | 由 §3.3 的缓存键保证重编;**异常在裸机上本来就不可用**(无 unwinder) | +| 目标自带 sysroot | 老包的 `[xlings] deps` 仍可留(去重),**不破坏** | 新包不必写 | + +--- + +## 4. 简洁与优雅 + +**好的**: + +- `src/freestanding/` 三个模块 **432 行**承载全部裸机知识,且 `linkline` 是**纯字符串构造** + (不依赖 `CompileFlags`,避免模块环)。 +- ISA 表是**数据**:`riscv32` 支持靠表里加一行,引擎与 BSP 零改动 —— 这条被 N3 判据实测过。 +- 模板**随包走**:`cmd_new` 的内建注册表刻意冻结在 `bin`,其余是包模板; + `mcpp new` 把自依赖按**解析到的版本**写进 manifest ⇒ **模板不可能与库脱节**。 + ⚠️ 计划里原本要给 mcpp 加内建 `--template baremetal-riscv`,**是错的形状**。 +- std 子集**不手写导出表**:libc++ 自带 110 个 `std/
.inc`,子集是机械挑选, + 生成物 217 行全是 `#include`;`tools/regenerate.sh` 每次运行都打印宿主对照组。 + +**不够好的**: + +- ⚠️ `src/build/prepare.cppm` 与 `execute.cppm` 各 +136/+137 行。这两个文件已经很大, + 本轮又往里加了 sysroot 解析、runner 选择、size 报告、快路径前置条件。 + **它们正在变成「什么都知道的地方」**,下一轮应当先拆再加。 +- `build.mcpp` 里合成 `__config_site` 是 40 行字符串拼接。可用但不优雅; + 若第二个包也要合成配置,应当抽成 `mcpp::` 的一个接口。 + +--- + +## 5. 稳定性评估 + +### 5.1 已修缺陷清单(全部为本轮自造) + +| # | 缺陷 | 发现方式 | ⚠️ 当时为什么没看见 | +|---|---|---|---| +| 1 | `mcpp run ` 被 `--target` 吃掉 | **CI(e2e/73)** | 位置参数上**原本就有注释写着这个碰撞**,我为了「一致性」覆盖了它 | +| 2 | `mcpp build` 后 `mcpp run` 在宿主 exec 裸机 ELF | **发布后**用发布二进制走新用户流程 | 130/131/132 三个 e2e **都恰好先 `run` 后 `build`**,顺序本身是被测对象 | +| 3 | 诊断指向不存在的包 | 自查 | 写诊断时没验证那一行今天能跑通 | +| 4 | 包声明了 target 的 libc/编译器 | **用户 review** | 在已装好的机器上,这条边在与不在**完全一样** | +| 5 | 缓存键缺 target 隐含 flags | 实施 #459 时的升级路径自测 | 只有跨版本才暴露 | + +### 5.2 ⚠️ 三条「判据本身是错的」 + +这是本轮最值得记的一类问题 —— 测试是绿的,但它测的不是那件事。 + +1. **第一版 build-then-run 回归测试是假绿**:加进 e2e/131 的既有工程后, + **关掉修复它照样过**。给 `try_fast_run` 的 16 个 `return nullopt` 各打编号探针后定位到: + `mcpp.toml` 比 `build.ninja` 新,**快路径本来就没被走到**。 + 真因:**重建不会重写内容未变的 `build.ninja`** ⇒ 在原地编辑过 manifest 的目录里, + manifest 永远最新,快路径**永久关闭**。⇒ 这类测试必须**新建干净工程**,并先确认它**变红**。 +2. **e2e/131 的私有性断言判据失效**:它用「`#include ` 编不过」证明依赖的 + `include-dir` 不到达消费者。libc 归目标之后 `` **本来就该编得过**(和宿主一样)。 + **原来那条测的其实是「libc 从哪来」,不是「作用域对不对」。** +3. **「拿走再装回来」**:xim 安装期依赖边在**已装好的机器**上不可验证。 + 判据必须是把 store 里的包改名藏起来 → `mcpp add` + `mcpp build` 把它装回来。 + +### 5.3 ⚠️ `.map` 曾是「写了但没人跟踪」 + +`-Wl,-Map=` 是链接命令上的 flag,不是独立边,所以很容易只写不声明 —— +删掉 `firmware.map`,ninja 认为 ELF 是新的、什么都不做,map 就永远不回来。 +已声明为链接边的 implicit output,e2e/132 用「删掉必须回来」钉住(**这是唯一能在修复前失败的形式**)。 + +### 5.4 测试面 + +- 单测:`test_freestanding.cpp` 25 个 + `test_build_directives` 33 个 + cache_key 19 个。 +- e2e:130(引擎链)/131(生态链)/132(test + 产物)/133(std 子集),共 983 行。 +- CI:新增 `baremetal` job,**两个 home 都装 qemu**(shim 按拥有它的 home 派发), + 并且**断言每条 PASS 行真的出现** —— `run_all.sh` 跳过时退出码是 0。 +- 包侧 CI:`riscv-virt-rt` 与 `std-freestanding` 各自仓库 CI 真跑 qemu,rv64+rv32 双档。 + +--- + +## 6. ⚠️ 一日四个补丁版本:这个节奏说明什么 + +`2026.8.19.1` → `.2` → `.3` → `.4`,其中 `.3` 与 `.4` 都是在**发布之后**发现问题才发的。 + +**不是坏事的部分**:`.3` 的缺陷是靠「用发布二进制从零走一遍新用户流程」发现的 —— +这一步如果省掉,它会留在生态里直到有用户撞上。**这一步的价值被证明了。** + +**是问题的部分**: + +- `.2` 发布时,`mcpp build && mcpp run` 这条**新用户最可能敲的两条命令**没有被任何测试覆盖。 + 四个裸机 e2e 都先 `run`,是巧合而非设计。 +- `.4` 的架构问题(§2.3)是**用户 review 发现的**,不是我自查发现的。我在 `.3` 的收尾报告里 + 甚至把 std 子集写成「要从头实现、不该在收尾时半做」——**而调研文档 X1–X10 早就把这条路测到了底**。 + ⇒ **写「这条做不了」之前必须回查自己的调研文档。** + +**改进项**(下一轮的硬约束): + +1. 发布前必须跑一遍**命令序列矩阵**(`build→run`、`run→build`、`build→test`),而不是单条命令。 +2. 任何「某某做不了 / 代价很大」的结论,若同一课题有调研文档,**必须引用文档中的具体测量**才能下。 +3. 回归测试必须做 **revert-A 探针**(关掉修复看它是否变红),否则不算写完。 + +--- + +## 7. 跨平台评估 + +⭐ **裸机 target 反而是最省跨平台成本的一类**:clang/lld 是 cross-compiler by construction, +**任何能装 llvm 载荷的宿主都能产出 RISC-V 镜像**,不需要 per-host 交叉载荷。 +`host_can_serve` 对 freestanding 直接返回 `true`,这条在目标表注释里写明了。 + +| 轴 | 状态 | +|---|---| +| 宿主 | 引擎侧 21 个 CI job 全绿(linux/macOS/Windows/aarch64/hermetic/mingw-cross) | +| 模拟器 | `xim:qemu-riscv` 覆盖 **五个宿主目标**(linux x64/arm64 · darwin x64/arm64 · win32 x64) | +| 目标 sysroot | `picolibc-riscv` 与宿主无关(目标侧产物),三平台同一份 | +| ⚠️ 缺口 | `qemu-riscv` **win32-arm64 上游无资产** ⇒ 该宿主装不上,行为正确但会失败 | +| ⚠️ 缺口 | 两个包的仓库 CI **只有 ubuntu-24.04**;macOS/Windows 上的裸机链**没有持续验证** | + +⚠️ **mcpp-index 的 `tests/examples/` workspace 成员在三个平台上无条件跑,没有能力门** +⇒ 需要模拟器/目标 sysroot 的包**不能加成员**,验证只能放包自己的仓库 CI。 +这是一个**已知的覆盖缺口**,不是遗漏。 + +--- + +## 8. 仍然开着的 + +| 项 | 性质 | 备注 | +|---|---|---| +| **T3 档**(`std::format`、标量 `std::sort`、完整 `std::string`) | 真实边界 | libc++ 把这些实体放在编译版库里(标量 `__sort` 是 `extern template`,**无宏可关**)⇒ 需为目标编 `libc++.a`,是新载荷 | +| `mcpp-index` #220 | 待发布生效 | 依赖 2026.8.19.4 | +| **`[target.X] sysroot` 覆盖** | 缺口 | 见 §2.4:今天换不了 libc 实现 | +| 目标表**索引化** | 已定为阶段二 | 把 `pin` + `sysroot` 搬到索引,含老客户端降级路径;阶段一不会让它更难做 | +| 第二块板 / ARM Cortex-M | 未开始 | rv32 已作为「ISA 表是数据」的证据 | +| `prepare.cppm` / `execute.cppm` 体量 | 技术债 | 见 §4 | +| 包侧 CI 单平台 | 覆盖缺口 | 见 §7 | + +--- + +## 9. 分项评分 + +| 维度 | 评价 | 依据 | +|---|---|---| +| **架构** | 好 | 能力增长未换来概念增长(2 条指令 + 1 个字段);三个计划中的大机制被实测砍掉;单一读取点 | +| **兼容性** | 好,但有一次真实风险 | 索引底线未抬、别名保留、破坏面为零;⚠️ 但 `-fno-exceptions` 差点造成升级即坏,靠缓存键轴补上 | +| **简洁优雅** | 好 | 432 行承载全部裸机知识;ISA 表是数据;模板随包走;子集不手写导出表 | +| **稳定性** | ⚠️ 中 | 五个自造缺陷,两个是**发布后**才发现;三条判据本身是错的 | +| **跨平台** | 好,有已知缺口 | 引擎侧 21 job 全绿、模拟器五宿主;⚠️ 包侧 CI 单平台 | +| **一致性** | 好 | `--target` 在所有子命令上拼写一致(靠位置参数改名换来,而非放弃一致性) | +| **无感升级** | ⚠️ 中 | 缓存键轴与诊断升级提示都是**补出来的**,不是设计时就有的 | + +--- + +## 10. 三条要带进下一轮的 + +1. ⭐ **计划里的数字就是探针。** 本轮被实测推翻的计划主张:qemu 冷启动(0.4s→12ms)、 + 「裸机没有退出码」(semihosting 有)、builtins 缺口触发者(不是 64 位除法,是 128 位移位)、 + 「libc++ 头一个都用不了」(103/110)。**四条全是我没测就写下的。** +2. ⭐ **在「已经配置好的机器」上,很多边验不出来。** 目标 libc 的依赖边、xim 安装期依赖边、 + 快路径前置条件 —— 三者的共同点是**存在与不存在长得一模一样**。 + 判据必须是**拿走**(stash 后重装)或**新建**(干净工程),不能是「在我这儿是好的」。 +3. ⚠️ **顺序本身是被测对象。** `mcpp run` 对、`mcpp build && mcpp run` 错, + 而四个裸机 e2e 恰好都先 `run`。**命令序列矩阵要进发布前检查表。**