From 5b36d64fcd395893fd617a408e3ab228b7ad9a54 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:53:07 +0800 Subject: [PATCH 1/9] feat(freestanding): a BSP supplies the runner, `mcpp test` runs on bare metal, and the artifact set is real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four gaps the phase-3 plan named. The consumer's manifest now has no `[target.*]` section at all: [dependencies] board = { path = "../board" } $ mcpp run --target riscv64-none-elf → boots in qemu $ mcpp test --target riscv64-none-elf → ok_one ok, ok_two ok, deliberate_fail FAIL (exit 1) $ mcpp build --target riscv64-none-elf → firmware, .bin, .map Size firmware text 8836 data 80 bss 5668 total 14584 R1 — THE RUNNER BELONGS TO THE BOARD-SUPPORT PACKAGE `mcpp:runner=`, one argv token per line in emission order, with its own `Slot::Runner` and its own `Scope::RunGlobal`. Both are new values and neither is a detail: LdFlags would put an emulator's argv on the linker command line, and reusing LinkGlobal would hand this the conflict rule for link flags — two dependencies' link flags concatenate correctly, two runners cannot. It has to come from `build.mcpp` rather than from a manifest key, and that is measured, not stylistic: the emulator must be named by ABSOLUTE path (a bare name resolves through PATH to a shim that dispatches against its owner home — CI reported `xlings: 'qemu-system-riscv64' is not installed` from a job where the same name had answered `--version` two steps earlier), the path carries a home and a version, and only a build program can compute it. Three sides verified: a BSP supplying it means the consumer needs no `[target.*]`; two dependencies supplying it is a hard error naming BOTH; a consumer writing its own wins and is told which dependency it overrode. ⚠️ R3 — TWO PLAN ASSUMPTIONS WERE WRONG, AND MEASURING THEM DELETED MOST OF IT The plan called for a `batch` mode and a structured stdout protocol. Both premises were false: * "qemu cold start is ~0.4s, so 30 isolated cases cost 12s" — measured 12ms per start (5 runs in 63ms). 30 cases cost 0.36s. The reason for batching disappeared, and with it the "on timeout, re-run isolated to find the culprit" machinery: isolated already names the culprit. * "bare metal has no exit code, so results need their own channel" — semihosting propagates the firmware's `main` return value to the emulator's exit code (`return 7` → qemu exits 7, verified). "Exit code is the verdict" holds here exactly as on the host. So `mcpp test` needed one change: route the test binary through the SAME runner `mcpp run` uses. Both callers now read `choose_runner()` — deriving it twice is the shape this codebase has paid for repeatedly. R4 — THE ARTIFACT SET `.bin` via llvm-objcopy, `.map` via `-Wl,-Map=`, and a size summary, because capacity is the constraint on a bare-metal target and mcpp already knows the number. ⚠️ `.bin` is a SEPARATE ninja edge on the ELF, not a second output of the link: folding it in would make the two share one up-to-date check, and an incremental build would hand back a stale image. Pinned by content, not mtime. Also fixes an inconsistency this work introduced: `mcpp run` took `--target-triple` while every other subcommand took `--target`. `--target` now works everywhere; `--target-triple` stays as `run`'s unambiguous alias, because its POSITIONAL is also called target and means a binary name. TESTS 91/91 unit (7 new on the runner directive and the artifact helpers) and three e2e, all three run by the `baremetal` CI job which asserts each PASS line appeared — every one of them can exit 0 without running. --- ...2026-08-19-baremetal-phase3-usable-plan.md | 321 ++++++++++++++++++ .github/workflows/ci-linux-e2e.yml | 6 +- docs/07-build-mcpp.md | 29 ++ docs/zh/07-build-mcpp.md | 24 ++ src/build/directives.cppm | 30 +- src/build/execute.cppm | 109 +++++- src/build/hostprogram.cppm | 19 ++ src/build/ninja_backend.cppm | 44 +++ src/build/prepare.cppm | 32 ++ src/build/program_protocol.cppm | 2 +- src/cli.cppm | 13 +- src/cli/cmd_build.cppm | 6 + src/freestanding/linkline.cppm | 80 +++++ src/manifest/types.cppm | 18 + ...31_freestanding_bsp_supplies_everything.sh | 20 +- .../132_freestanding_test_and_artifacts.sh | 174 ++++++++++ tests/unit/test_build_directives.cpp | 64 ++++ tests/unit/test_freestanding.cpp | 38 +++ 18 files changed, 1012 insertions(+), 17 deletions(-) create mode 100644 .agents/docs/2026-08-19-baremetal-phase3-usable-plan.md create mode 100755 tests/e2e/132_freestanding_test_and_artifacts.sh diff --git a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md new file mode 100644 index 00000000..d2fabb97 --- /dev/null +++ b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md @@ -0,0 +1,321 @@ +# 裸机 / freestanding — 第三阶段:从「能跑」到「能用」 + +- Date: 2026-08-19 +- Status: **方案计划,待 review** +- 上游:`2026-08-19-baremetal-ecosystem-closure-plan.md`(第二阶段;§10 记录已落地与三处实测修正) +- 已发布基线:**mcpp 2026.8.19.1** · `xim:qemu-riscv@9.2.4-1` · `xim:picolibc-riscv@1.8.12` +- 范围:第二阶段 §10.4 列出的四个缺口 —— **runner 归属** · **生态发布** · **裸机 test** · **产物形态 + 模板** + +--- + +## 0. 现在在哪(全部已验证,不是推测) + +``` +$ mcpp run --target-triple riscv64-none-elf # mcpp 2026.8.19.1,来自已发布索引 +BSP-CHAIN-OK 42 +float 3.1416 +MALLOC-OK +``` + +| 已经成立 | 证据 | +|---|---| +| 引擎认识 `riscv64-none-elf` / `riscv32-none-elf` | `toolchain list` 显示 `bare, static, cross` | +| 从 C++20 模块构建裸机镜像 | `tests/e2e/130`,CI 每次跑 | +| BSP 供 sysroot + 链接脚本 + 运行时,消费者只声明依赖 | `tests/e2e/131`,CI 每次跑 | +| `mcpp:link-script=` · `mcpp::xpkg_dir()` · `[target.X].runner` | 已发布在 2026.8.19.1 | + +**还差四件事,而它们的性质完全不同:** 一件是**真设计题**(§2),一件是**发布工程**(§3), +两件是**已知形状的实现**(§4/§5/§6)。 + +--- + +## 1. 判据 + +沿用第二阶段 §1 的总判据(用户写五行、跑三条命令、看不到 `picolibc`/`crt0`/`-nostdlib`/ +`-machine virt` 等词),并把否定判据补到四条: + +| # | 不算「能用」 | 现状 | +|---|---|---| +| **N1** | `mcpp run` 要用户自己拼 qemu 命令 | ⛔ **仍然违反** —— `runner` 在消费者 manifest 里 | +| **N2** | 要用户手写 `link-search` / `cflag` | ✅ 已解决(BSP 供) | +| **N3** | 换一块板要改引擎而不是换依赖 | ✅ 结构上成立,但**没有第二块板证过** | +| **N4** | 依赖只能用 `path = "..."`,`mcpp add` 拿不到 | ⛔ **仍然违反** —— 两个包都没进索引 | + +⭐ **第三阶段的完成判据 = N1 与 N4 同时消失**,即: + +```bash +mcpp new --template baremetal-riscv blinky && cd blinky +mcpp add riscv-virt-rt # 从已发布索引 +mcpp build --target riscv64-none-elf +mcpp run --target riscv64-none-elf # runner 由 BSP 供,manifest 里没有它 +mcpp test --target riscv64-none-elf +``` + +--- + +## 2. ⭐ R1:runner 归属 BSP —— 本阶段唯一的真设计题 + +### 2.1 为什么它不能照抄任何现成机制 + +四条**已实测**的约束,任意一条都能否掉一个方案: + +| # | 约束 | 出处 | +|---|---|---| +| C1 | **模拟器必须用绝对路径** | CI 实测:runner 写裸名 `qemu-system-riscv64` 时 `mcpp run` 报 `[error] xlings: 'qemu-system-riscv64' is not installed`,而同一 job 两步之前 `--version` 刚跑通 —— shim 按**拥有它的 home**派发 | +| C2 | 那个绝对路径**机器相关**,静态 manifest 写不出来 | 载荷路径含 home 与版本 | +| C3 | 能算出它的只有 `build.mcpp` | 它是唯一拿得到 `xpkg_dir()` 与 `MCPP_TARGET_*` 的地方 | +| C4 | 但 `build.mcpp` 是**构建期**程序,`mcpp run` 要的是**运行期**值 | 定义 | + +⇒ **C2+C3 排除静态 manifest 传播;C4 说明这个值必须被构建期算出、持久化、运行期读回。** + +⚠️ 顺带排除一个看起来更简单的方案:让 BSP 在 manifest 里写 +`[target.riscv64-none-elf] runner = [...]` 并让它沿依赖边传播。C1/C2 让它写不出可用的值。 + +### 2.2 设计:`mcpp:runner=` 指令 + 新 Slot + +**一条新 directive,与 `link-script` 同族**(第二阶段已经证明这条路走得通): + +```cpp +// BSP 的 build.mcpp +const char* qemu = mcpp::xpkg_dir("xim", "qemu-riscv"); // 已有接口 +mcpp::runner(std::format("{}/bin/qemu-system-riscv64", qemu).c_str()); +mcpp::runner("-machine"); mcpp::runner("virt"); +mcpp::runner("-nographic"); mcpp::runner("-no-reboot"); +mcpp::runner("-semihosting"); mcpp::runner("-bios"); mcpp::runner("none"); +mcpp::runner("-kernel"); +``` + +| 决定 | 取值 | 理由 | +|---|---|---| +| 编码 | **一行一个 argv token,按发射顺序** | argv 是有序列表,而 directive 是「一行一值」。JSON 数组(像 `action`)要引入转义规则,而这里没有任何一处需要嵌套 | +| Slot | **新 `Slot::Runner`** | 它既不是编译输入也不是链接输入;塞进 `LdFlags` 会让它进链接线 | +| Scope | **新 `Scope::RunGlobal`** | 语义与 `LinkGlobal` 平行(到达消费者),但落到运行配置而非链接线。⚠️ **必须是新的 Scope 值**,否则 `link-script` 与它会共享传播路径与冲突规则 | +| 持久化 | 随 directive 记录进构建缓存 | 已有机制,`mcpp run` 的快路径已经读它 | +| 冲突 | **两个依赖都供 = 硬错误**,消费者自己写的 `[target.X].runner` **覆盖**依赖供的 | 两个 BSP 同时供是配置错误;消费者显式覆盖是正当的(调试时换 `-bios default`) | + +### 2.3 ⚠️ 这一条要改的地方(每处都要有测试) + +1. `directives.cppm` — 新 Slot + 新 Scope + 一行表项 + `Transform::Verbatim` +2. `hostprogram.cppm` — `mcpp::runner(tok)` 接口 + protocol 4 +3. 传播 — `Scope::RunGlobal` 的 fixpoint(与 LinkGlobal 同形,但**独立的冲突检查**) +4. `execute.cppm` — `build_run_target` 先读依赖供的,消费者 override 优先 +5. `test_targets.cppm` / 测试执行 — **同一个 runner**(见 §4) + +⭐ **判据(两侧)**: +- BSP 供 runner ⇒ 消费者 manifest 里**没有 `[target.*]` 段**也能 `mcpp run`; +- 两个依赖都供 ⇒ **硬错误并同时点名两个包**; +- 消费者写了自己的 ⇒ 用消费者的,且**说明它覆盖了谁**。 + +--- + +## 3. R2:两个生态包 + 索引收录 + +### 3.1 要建的仓库 + +| 仓库 | 内容 | 依赖 | +|---|---|---| +| `mcpplibs/riscv-virt-rt` | qemu `virt` 的 BSP。`[xlings] deps = ["xim:picolibc-riscv@1.8.12", "xim:qemu-riscv@9.2.4-1"]`;`build.mcpp` 按 `MCPP_TARGET_ARCH` 选档位,发 include-dir(私有)+ link-search/link-lib/link-script/**runner** | mcpp ≥ 2026.8.19.1(+ §2 后的版本) | +| `mcpplibs/std-freestanding` | `mcpplibs.std.freestanding` —— 私有 include picolibc 头,导出可移植 std 子集模块 | 同上 | + +⚠️ **`riscv-virt-rt` 的代码在 `tests/e2e/131` 里已经逐行验证过**,建仓是把它从 heredoc 搬进仓库并补 README/CI/examples,不是重新设计。 + +### 3.2 ⚠️ 兼容底线:一个需要新 mcpp 的包,怎么进一个服务老客户端的索引 + +**已核准的事实:mcpp 只有索引级 `index.toml [index].min_mcpp`,没有包级的 mcpp 版本要求。** + +| 方案 | 后果 | +|---|---| +| 抬高索引 `min_mcpp` | ⛔ **绝对不行** —— 索引是数据、mcpp 是程序,**发布数据不得让程序失效**;抬高会让所有老客户端拿不到**任何**包 | +| 什么都不加 | 老客户端能解析、能下载,构建到 `build.mcpp` 那一步才失败 —— 但失败**是可行动的**:2026.8.19.1 起诊断会说「要么这个包写给更新的 mcpp(试 `mcpp self update`),要么指令拼错了」 | +| 加**包级** `requires-mcpp` 键 | 早失败、消息更准,但 ⚠️ **要先探针**:老客户端遇到未知的**顶层 manifest 键**是警告还是硬失败?若是硬失败,已发布包永远无法采用新键(与 provisions 的 reexport 键同一形状) | + +⭐ **决定:先按「什么都不加」发布,并把包级 `requires-mcpp` 作为一个独立探针(P-COMPAT)排在后面。** +理由:诊断已经可行动,而一个会让老客户端硬失败的新键代价高得多 —— 且这个代价**尚未测过**。 + +### 3.3 收录进 `mcpplibs/mcpp-index` + +`pkgs/r/riscv-virt-rt.lua` 与 `pkgs/m/mcpplibs.std.freestanding.lua`。 + +⭐ **判据是 `mcpp add` 而不是「文件进了仓库」**: + +```bash +mcpp new probe && cd probe +mcpp add riscv-virt-rt # 从已发布索引解析、下载、写进 mcpp.toml +mcpp build --target riscv64-none-elf +``` + +⚠️ **发布顺序有依赖**:`riscv-virt-rt` 的 runner 需要 §2 落地。⇒ 先发**不带 runner** 的 +版本(0.1.0,消费者仍写 `[target.X].runner`),§2 落地后发 0.2.0 把 runner 收进 BSP。 +**两次发布都要留在索引里**,这样 0.1.0 的用户不会被新 mcpp 的要求卡住。 + +--- + +## 4. R3:裸机 `mcpp test`(W11) + +### 4.1 已核准的机制事实 + +- `mcpp test` 从 `tests/**/*.cpp` 发现用例,每个编成一个**独立可执行**,用 + `run_exec_deadline` 跑,退出码即判据。 +- 裸机上这两条都不成立:没有进程退出码回到宿主,而且**每个用例一个镜像**意味着 + N 次 qemu 冷启动。 + +### 4.2 设计 + +| 决定 | 取值 | 理由 | +|---|---|---| +| 默认模式 | **`batch`**:所有用例编进**一个**镜像,semihosting 打用例名与结果,固件自行 poweroff | qemu `virt` 冷启动实测 ~0.4s;30 个用例 isolated 就是 12s 纯开销 | +| 结果通道 | **stdout 上的结构化行**(`mcpp-test: `),`mcpp` 侧解析 | 没有退出码可用;semihosting 的 stdout 是唯一可靠通道 | +| 收尾 | 固件写 syscon `0x5555` 关机 | 已验证:否则 qemu 永远不退,只能靠超时 | +| 超时 | 整镜像一个 deadline;**超时后自动用 `isolated` 重跑一次** | batch 下「跑飞」只知道**没跑完**,不知道**是谁**;重跑一次才定位得到 | +| `isolated` | 可选开关,一个用例一个镜像 | 定位用,不做默认 | +| 执行 | **走 §2 的同一个 runner** | ⚠️ 两条路径(run / test)各自推导 runner 就是「同一决策两处推导」 | + +### 4.3 ⚠️ 判据 + +- 3 个用例 batch 一次 qemu 全过; +- **人为让第 2 个死循环 ⇒ 超时后 mcpp 指出是第 2 个**(不是「有用例超时」); +- ⚠️ **一个用例失败不能让整批静默变绿** —— 断言 `mcpp test` 的退出码非零且列出失败者。 + +--- + +## 5. R4:产物形态(W13) + +| 产物 | 怎么来 | 为什么需要 | +|---|---|---| +| `.elf` | 已有 | 调试、qemu `-kernel` | +| `.bin` | `llvm-objcopy -O binary` | 真硬件烧录只吃裸二进制 | +| `.map` | `-Wl,-Map=.map` | 裸机上「为什么这段没进来」只有 map 能答 | +| **size 摘要** | `llvm-size` → 构建后打印 text/data/bss | ⭐ 裸机的核心约束是**容量**;不打印等于让用户自己去查 | + +⚠️ **实现要点**:`.bin` 与 `.map` 是**额外的 ninja 边**,不是链接命令的副产物 —— +`.map` 可以挂在链接行上,`.bin` 必须是一条以 `.elf` 为输入的新边,否则增量构建不会重生成。 + +⭐ **判据**:改一行源码 → `.bin` 的 mtime 与内容都变;`.map` 里能找到该符号。 + +--- + +## 6. R5:`mcpp new --template baremetal-riscv`(T1) + +生成的工程**必须开箱通过 §1 的三条命令**,且 manifest 不超过: + +```toml +[package] +name = "blinky" +version = "0.1.0" + +[build] +target = "riscv64-none-elf" + +[dependencies] +riscv-virt-rt = "0.2" +``` + +⚠️ **两个实测约束会体现在模板里**: + +1. **裸机固件可以有 `main`** —— 只要 BSP 带了 picolibc 的 `crt0`,`main` 就是普通的 + `main`。**只有零 libc 档才需要 `[targets.X] main = "src/start.S"`**。⇒ 模板走 BSP 路线, + 用户看到的就是 `int main()`。 +2. 模板要**同时**给一个 `tests/` 用例,否则 `mcpp test` 在新工程上无事可做,W11 的价值 + 在用户第一次接触时就是隐形的。 + +--- + +## 7. 依赖拓扑 · 里程碑 · 并行度 + +``` +R1 runner 归属 BSP ──┬──► R2b riscv-virt-rt 0.2(收 runner) + (引擎 + 协议 4) │ + └──► R3 裸机 mcpp test(共用同一个 runner) + │ +R2a 两个仓库 + 索引收录 ────────────┼──► R5 模板(需要 add 得到 + runner 由 BSP 供) + (0.1.0,不含 runner) │ + │ +R4 产物形态 ────────────────────────┘ (完全独立,任何时候可做) +``` + +| 里程碑 | 含 | ⚠️ 验收判据(必须是这句) | +|---|---|---| +| **M-3′ 生态可取** | R2a | `mcpp add riscv-virt-rt` 从**已发布索引**成功,且构建跑通 | +| **M-3″ N1 消失** | R1 · R2b | ⭐ 消费者 manifest **没有 `[target.*]` 段**,`mcpp run` 仍在 qemu 里跑出预期串 | +| **M-4 能测** | R3 | 3 用例 batch 一次全过;第 2 个跑飞 ⇒ **超时后指出是第 2 个**;1 个失败 ⇒ **退出码非零** | +| **M-5 能烧** | R4 | 改一行源码 ⇒ `.bin` 内容变;构建后打印 text/data/bss | +| **M-6 ⭐ 能用** | R5 | ⭐ **干净机器上 `mcpp new --template baremetal-riscv` → 三条命令全过,用户没见过 §1 列出的任何一个词** | + +**并行度**:R4 零依赖;R2a 只依赖已发布的 mcpp(今天就能开);R1 是唯一的串行头。 + +--- + +## 8. ⚠️ 风险(每条有出处) + +| # | 风险 | 出处 | 防线 | +|---|---|---|---| +| **R-1** | **runner 用裸名 ⇒ 换台机器就不工作** | CI 实测(shim 按 owner home 派发) | BSP 必须发**绝对路径**;e2e 断言 runner 首 token 是绝对路径 | +| **R-2** | **新 Scope 与 LinkGlobal 共用传播路径** ⇒ 冲突规则互相污染 | 设计 | `Scope::RunGlobal` 必须是**独立值**,并有自己的冲突测试 | +| **R-3** | **run 与 test 各自推导 runner** | 本仓反复付过的形状(#233/#240/#242/#344) | 一个读点,两个调用方 | +| **R-4** | **batch 模式下一个用例失败被读成整批成功** | 假绿家族 | 判据是**退出码非零 + 列出失败者**,不是「有输出」 | +| **R-5** | **`.bin` 不是新 ninja 边 ⇒ 增量构建拿到陈旧二进制** | 增量语义 | 判据是**改一行源码后 `.bin` 内容变** | +| **R-6** | **抬高索引 `min_mcpp` 把老客户端变砖** | 记忆:PR#349 | ⛔ 绝不抬高;包级要求走 P-COMPAT 探针 | +| **R-7** | **未知顶层 manifest 键让老客户端硬失败** ⇒ 已发布包永远无法采用新键 | provisions reexport 同形 | P-COMPAT **先探再定**,不先加键 | +| **R-8** | **模板生成的工程在干净机器上装不齐依赖** | 首次体验 | M-6 判据明写「干净机器」 | +| **R-9** | **只有一块板 ⇒ N3 从未被证过** | 现状 | R2b 之后加**第二块板**(rv32 档)作为 N3 的证据 | +| **R-10** | **读代码下结论** | 本轮被实测推翻五次以上 | 每个单元判据都是命令 + 期望输出 | + +--- + +## 9. 兼容性与无感升级 + +| 轴 | 问题 | 处置 | +|---|---|---| +| **protocol 3 → 4** | 加 `mcpp:runner=` 要不要抬 protocol? | ⭐ **要**。抬了之后,老 mcpp 遇到它会说「要么这个包写给更新的 mcpp,要么拼错了」(2026.8.19.1 起的措辞),而不是静默丢弃 | +| **BSP 0.1.0 → 0.2.0** | 0.2.0 需要更新的 mcpp | **两个版本都留在索引里**;0.1.0 的用户不受影响 | +| **索引底线** | 是否抬 `min_mcpp` | ⛔ 不抬(R-6) | +| **`[target.X].runner` 的去留** | BSP 供了之后它还留着吗 | **留着,并且是覆盖语义** —— 调试时换一个 `-bios` 是正当需求,而删掉一个已发布的键是破坏性变更 | + +--- + +## 10. 不在本阶段 + +| 项 | 去向 | +|---|---| +| **第二块板 / ARM Cortex-M** | R-9 要求 rv32 作为 N3 的证据;真正的 ARM 支持另立 | +| **E-STD S-3**(`format` / `sort` / `string` 全功能) | 需为目标编 libc++ | +| **烧录 / OTA** | 设备侧的事,mcpp 不碰 | +| **D 档**(openkal / openhal / openarch) | 路线图在第一阶段计划 §7 | + +--- + +## 11. 实施记录:两条计划主张被同一轮实测推翻 + +§4 的裸机 `mcpp test` 设计建立在两个**我没测就写下的数字/断言**上。两个都是错的。 + +| 计划写的 | 实测 | 后果 | +|---|---|---| +| 「qemu 冷启动 ~0.4s ⇒ 30 用例 isolated 要 12s」⇒ **默认 batch** | **12ms**(5 次连跑 63ms) ⇒ 30 用例 **0.36s** | ⛔ **batch 的全部理由消失**;连带「超时后重跑一次 isolated 定位」也不需要 —— isolated 本来就指名道姓 | +| 「裸机没有退出码回宿主 ⇒ 结果要走结构化 stdout 通道」 | **semihosting 把固件 `main` 的返回值原样传给 qemu 退出码**(`return 7` → 退出码 7) | ⛔ **不需要新通道**;「退出码即判据」在裸机上原样成立 | + +⇒ **R3 从一个子系统缩成一件事**:让测试二进制**走 `mcpp run` 用的同一个 runner**。 +实测结果: + +``` +ok_one ... ok (0.02s) +ok_two ... ok (0.02s) +deliberate_fail ... FAIL (exit 1, 0.02s) +error: test result: FAILED. 2 passed; 1 failed +``` + +三条判据(全过 · 失败被点名 · 退出码非零)一次全中,而 §4.2 表里的六个设计决定 +**有四个根本不需要做**。 + +⚠️ **教训与本轮其它几次同形**:计划里凡是带具体数字或「A 不成立所以要 B」的句子, +数字与断言本身就是**必须先测的探针**。这两条的代价是我差点实现一整个 batch 子系统。 + +### 11.1 R1/R4 的实施结论 + +- **R1 按设计落地**(新 `Slot::Runner` + 新 `Scope::RunGlobal` + 一行表项),三侧已验: + BSP 供 ⇒ 消费者无 `[target.*]` 也能跑 · 两个依赖都供 ⇒ **硬错误并点名两个** · + 消费者写了 ⇒ 以它为准并说明覆盖了谁。 +- **R4 按设计落地**,包括计划里点名的 R-5:`.bin` 是以 `.elf` 为输入的**独立 ninja 边**, + 改一行源码后**内容**变(不是只有 mtime 变)。 +- ⚠️ 顺带修了一处**我自己造成的不一致**:`mcpp run` 先前用 `--target-triple` 而其余 + 子命令用 `--target`。现在 `--target` 到处可用,`--target-triple` 作为 `run` 的 + 无歧义别名保留(它的**位置参数**恰好也叫 target,含义是二进制名)。 diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index f60d966d..fb5ede2a 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -173,7 +173,8 @@ jobs: # Invoked directly, a skip is visible: the script either prints its # PASS line or it does not. for t in tests/e2e/130_freestanding_riscv_build_and_run.sh \ - tests/e2e/131_freestanding_bsp_supplies_everything.sh; do + tests/e2e/131_freestanding_bsp_supplies_everything.sh \ + tests/e2e/132_freestanding_test_and_artifacts.sh; do echo "=== $t ===" bash "$t" 2>&1 | tee "$(basename "$t").log" rc=${PIPESTATUS[0]} @@ -188,6 +189,9 @@ jobs: grep -q 'PASS: BSP supplies the sysroot' \ 131_freestanding_bsp_supplies_everything.sh.log || { echo "131 (ecosystem chain) skipped on the runner that must run it"; exit 1; } + grep -q 'PASS: bare-metal mcpp test names its failure' \ + 132_freestanding_test_and_artifacts.sh.log || { + echo "132 (test + artifacts) skipped on the runner that must run it"; exit 1; } # ────────────────────────────────────────────────────────────────── # Hermetic (no host toolchain): the ONLY environment class that diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index 9b5b7994..73a38d3d 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -52,6 +52,7 @@ is ignored, so diagnostics may be logged freely. | `mcpp:source=` *(0.0.100+)* | select a **pre-existing** source file into the build (absolute, or relative to the package root). Same downstream effect as `generated=`; use it for files the program *chose* (payload/vendored tree) rather than wrote — e.g. a per-target source selection over a large tarball | | `mcpp:include-dir=` *(0.0.100+)* | add a **private** include directory (`-I`) for this package's own TUs (absolute, or relative to the package root; normalized). Replaces the `cxxflag=-I` + `cflag=-I` double emission | | `mcpp:include-dir-after=` *(0.0.100+)* | like `include-dir`, but searched **after** the system directories (`-idirafter`) — for payload trees that shadow system headers | +| `mcpp:runner=` *(2026.8.19.2+)* | one argv token of the command that EXECUTES this build's artifact, when the host cannot. Emitted once per token, in order; the artifact path is appended (or substituted for `{}`). Reaches the **consumer**. ⚠️ Emit the executable as an ABSOLUTE path, and only **one** dependency may supply it | | `mcpp:link-script=` *(2026.8.19+)* | link with this **linker script** (`-T`; relative resolves against the package root, and the emitted path is absolute because the link runs in the build directory). Reaches the **consumer**, unlike `include-dir` — a board's memory layout is the one thing a consumer cannot write for itself | | `mcpp:rerun-if-changed=` | re-run `build.mcpp` when this file changes | | `mcpp:rerun-if-env-changed=` | re-run `build.mcpp` when this env var changes | @@ -101,9 +102,37 @@ int main() { | `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` — re-run when the **set** of files matching `pat` changes (see below) | | `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* | reads `MCPP_DEP__BIN_` — the absolute path of a **host tool** built by a dependency (see below) | | `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` | +| `mcpp::runner(tok)` *(2026.8.19.2+)* | `mcpp:runner=` — see below | | `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | the payload directory of a package this manifest declared in `[xlings] deps`; `""` when it was not declared or is not installed (see below) | | `mcpp::action{…}.submit()` *(2026.8.5.1+)* | `mcpp:action=` — declares a **build-graph node** instead of doing the work here (see below) | +### `runner` — how the artifact is executed (2026.8.19.2+) + +A board-support package knows the emulator, its machine model and its firmware +mode. It also knows where the emulator IS, which a static manifest cannot: the +payload path carries a home and a version. + +```cpp +const char* qemu = mcpp::xpkg_dir("xim", "qemu-riscv"); +mcpp::runner(std::format("{}/bin/qemu-system-riscv64", qemu).c_str()); +for (auto a : {"-machine","virt","-nographic","-no-reboot","-kernel"}) + mcpp::runner(a); +``` + +The consumer then needs no `[target.]` section at all. If it writes one +anyway, **it wins** — swapping `-bios default` for `-bios none -semihosting` +while debugging is a legitimate thing to want — and mcpp says which dependency +it overrode. + +⚠️ **Emit the executable as an absolute path.** A bare name resolves through +`PATH` to a shim that dispatches against its own owner home, which is not +necessarily the home this build uses. + +⚠️ **Exactly one dependency may supply a runner.** Two board-support packages +both claiming to know how to run the artifact is a configuration error, and +mcpp reports it naming both rather than merging them into an argv that is +neither one's. + ### Finding an `[xlings] deps` payload: `xpkg_dir` (2026.8.19+) `dep_dir` answers for **mcpp** dependencies. An xlings package is a different diff --git a/docs/zh/07-build-mcpp.md b/docs/zh/07-build-mcpp.md index 5775bb0c..01fe301c 100644 --- a/docs/zh/07-build-mcpp.md +++ b/docs/zh/07-build-mcpp.md @@ -49,6 +49,7 @@ mcpp build # 编译 + 运行 build.mcpp,然后构建工程 | `mcpp:source=` *(0.0.100+)* | 把一份**既有**源文件选入构建(绝对路径,或相对包根)。下游效果与 `generated=` 相同;语义区别在于文件是程序*选中*的(tarball payload / vendored 源树)而非程序写出的——例如对大型源码包做 per-target 源选择 | | `mcpp:include-dir=` *(0.0.100+)* | 为本包自身 TU 增加一个**私有** include 目录(`-I`;绝对路径或相对包根,自动规范化)。取代过去 `cxxflag=-I` + `cflag=-I` 的双重裸发 | | `mcpp:include-dir-after=` *(0.0.100+)* | 同 `include-dir`,但排在系统目录**之后**搜索(`-idirafter`)——用于会遮蔽系统头的 payload 源树 | +| `mcpp:runner=` *(2026.8.19.2+)* | 执行本次构建产物的命令的**一个 argv token**(宿主跑不了它时)。一个 token 一次调用、按顺序;产物路径会被追加(或替换 `{}`)。**到达消费者**。⚠️ 可执行文件要发**绝对路径**,且**只能有一个**依赖提供它 | | `mcpp:link-script=` *(2026.8.19+)* | 用这个**链接脚本**链接(`-T`;相对路径按包根解析,发出的是绝对路径,因为链接是在构建目录里跑的)。与 `include-dir` 不同,它**到达消费者** —— 板子的内存布局恰恰是消费者写不出来的那一项 | | `mcpp:rerun-if-changed=` | 该文件变化时重跑 `build.mcpp` | | `mcpp:rerun-if-env-changed=` | 该环境变量变化时重跑 `build.mcpp` | @@ -94,9 +95,32 @@ int main() { | `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` —— 匹配 `pat` 的文件**集合**发生变化时重跑(见下) | | `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* | 读 `MCPP_DEP__BIN_` —— 依赖构建出的 **host 工具**的绝对路径(见下) | | `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` | +| `mcpp::runner(tok)` *(2026.8.19.2+)* | `mcpp:runner=` —— 见下 | | `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | 本 manifest 在 `[xlings] deps` 里声明的包的载荷目录;没声明或没安装时返回 `""`(见下) | | `mcpp::action{…}.submit()` *(2026.8.5.1+)* | `mcpp:action=` —— **声明一个构建图节点**,而不是在这里把活干了(见下) | +### `runner` —— 产物的执行方式(2026.8.19.2+) + +板级支持包知道模拟器、机器型号和固件模式,也知道模拟器**在哪** —— 而静态 manifest +写不出来:载荷路径里带着 home 和版本号。 + +```cpp +const char* qemu = mcpp::xpkg_dir("xim", "qemu-riscv"); +mcpp::runner(std::format("{}/bin/qemu-system-riscv64", qemu).c_str()); +for (auto a : {"-machine","virt","-nographic","-no-reboot","-kernel"}) + mcpp::runner(a); +``` + +这样消费者**完全不需要 `[target.]` 段**。它若还是写了,**以它为准** —— +调试时把 `-bios default` 换成 `-bios none -semihosting` 是正当需求 —— 且 mcpp 会说明 +它覆盖了哪个依赖。 + +⚠️ **可执行文件要发绝对路径。** 裸名会经 `PATH` 解析到一个 shim,而 shim 按**拥有它 +的 home** 派发,那未必是本次构建用的 home。 + +⚠️ **只能有一个依赖提供 runner。** 两个板级支持包都声称知道怎么跑这个产物是配置 +错误;mcpp 会**同时点名两个**并报错,而不是把它们并成一个谁也不是的 argv。 + ### 找到 `[xlings] deps` 的载荷:`xpkg_dir`(2026.8.19+) `dep_dir` 回答的是 **mcpp** 依赖。xlings 包是另一个命名空间、另一套 store 布局, diff --git a/src/build/directives.cppm b/src/build/directives.cppm index f52d7e31..91379bca 100644 --- a/src/build/directives.cppm +++ b/src/build/directives.cppm @@ -72,7 +72,11 @@ using mcpp::build::program_protocol::run_timeout_for; // Where a directive's value accumulates. One slot may be fed by several wire // names (link-lib and link-search both produce link flags). enum class Slot : std::size_t { - CxxFlags = 0, + // How to EXECUTE the artifact. Its own slot, not a corner of LdFlags: it + // is neither a compile input nor a link input, and putting it in LdFlags + // would put an emulator's argv on the linker command line. + Runner, + CxxFlags, CFlags, LdFlags, Defines, @@ -101,6 +105,12 @@ inline constexpr std::size_t kSlotCount = static_cast(Slot::Count); enum class Scope { PackagePrivate, // only this package's own TUs — never propagated to consumers LinkGlobal, // reaches the final link of whatever consumes this package + // Reaches how the consumer RUNS the artifact. Parallel to LinkGlobal in + // propagation and deliberately NOT the same value: the two have different + // conflict rules. Link flags from two dependencies concatenate and that is + // correct; two runners cannot, so this scope carries an exactly-one- + // provider check that LinkGlobal must not inherit. + RunGlobal, SourceSet, // joins the compile set RerunKey, // not a build input at all; only feeds the re-run key GraphNode, // declares an edge in the build graph; see manifest::BuildAction @@ -139,7 +149,7 @@ struct Def { int sinceProtocol; }; -inline constexpr std::array kTable{{ +inline constexpr std::array kTable{{ // wire tag slot scope transform must missingPrefix missingSuffix since {"cxxflag", "cxxflag", Slot::CxxFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1}, {"cflag", "cflag", Slot::CFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1}, @@ -172,6 +182,19 @@ inline constexpr std::array kTable{{ // the contract for one row would cost more than it buys: lld's own error // is already exact ("cannot find linker script "), which is the // condition the contract exists to make legible. + // ⚠️ One argv TOKEN per line, in emission order. + // + // argv is an ordered list and a directive is one line = one value, so the + // list is built by repetition. The alternative — a JSON array, as `action` + // uses — would introduce an escaping contract for a payload that never + // nests, and `action` pays that cost only because it has six fields. + // + // Verbatim: a runner token is not a path to normalize (it may be `-bios`), + // and the producer already resolved the emulator absolutely, because a + // bare name resolves through PATH to a shim that dispatches against its + // OWNER home — measured in CI as `xlings: '…' is not installed` from a job + // where the same name had answered `--version` two steps earlier. + {"runner", "runner", Slot::Runner, Scope::RunGlobal, Transform::Verbatim, false, "", "", 4}, {"link-script", "ldflag", Slot::LdFlags, Scope::LinkGlobal, Transform::LinkerScript, false, "", "", 3}, {"include-dir", "include-dir", Slot::IncludeDirs, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1}, {"include-dir-after", "include-dir-after", Slot::IncludeDirsAfter, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1}, @@ -559,11 +582,14 @@ void apply(mcpp::manifest::Manifest& m, const Directives& d) { auto const& cxx = d.at(Slot::CxxFlags); auto const& c = d.at(Slot::CFlags); auto const& ld = d.at(Slot::LdFlags); + auto const& runner = d.at(Slot::Runner); auto const& defines = d.at(Slot::Defines); bc.cxxflags.insert(bc.cxxflags.end(), cxx.begin(), cxx.end()); bc.cflags.insert(bc.cflags.end(), c.begin(), c.end()); bc.ldflags.insert(bc.ldflags.end(), ld.begin(), ld.end()); + // Appended in emission order — the tokens ARE the argv. + bc.runner.insert(bc.runner.end(), runner.begin(), runner.end()); // cfg defines colour BOTH language channels — the one slot that fans out. bc.cflags.insert(bc.cflags.end(), defines.begin(), defines.end()); bc.cxxflags.insert(bc.cxxflags.end(), defines.begin(), defines.end()); diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 3779d700..39b1dcfe 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -16,6 +16,7 @@ import mcpp.diag; import mcpp.build.plan; import mcpp.toolchain.triple; import mcpp.freestanding.runner; +import mcpp.freestanding.linkline; import mcpp.build.graph_shape; // #407: which mode wrote this build.ninja import mcpp.build.backend; import mcpp.build.ninja; @@ -385,6 +386,69 @@ compute_subos_env(const mcpp::build::BuildPlan& plan) { // instead of re-deriving it. `scheduleNinjaJobs` is NOT the compiler cap under // detach-codegen: a detached compiler stops holding a ninja slot, so ninja is // handed a larger number on purpose. +// THE read point for "how is this artifact executed". +// +// One function, two callers (`mcpp run` and `mcpp test`). Deriving it twice is +// the shape this codebase has paid for repeatedly (#233/#240/#242/#344): it +// does not fail when you add the second derivation, it fails later, when one +// of them gains a rule the other does not. +// +// Returns an empty argv for a hosted target — the caller runs the artifact +// directly, as it always did. +struct RunnerChoice { + std::vector tmpl; // empty = execute the artifact directly + bool freestanding = false; // a runner is REQUIRED when true + bool fromManifest = false; // the consumer overrode a dependency's +}; + +RunnerChoice choose_runner(const BuildContext& ctx) { + RunnerChoice c; + auto ft = mcpp::toolchain::triple::parse(ctx.tc.targetTriple); + if (!ft || !ft->is_freestanding()) return c; + c.freestanding = true; + // Two producers, ordinary precedence: what the author of THIS project + // wrote beats what a dependency supplied. The dependency is the normal + // case (a board-support package computes the emulator's absolute path); + // the manifest key exists for swapping `-bios default` for + // `-bios none -semihosting` while debugging. + c.tmpl = ctx.manifest.buildConfig.runner; + if (auto it = ctx.manifest.targetOverrides.find(ctx.tc.targetTriple); + it != ctx.manifest.targetOverrides.end() && !it->second.runner.empty()) { + c.tmpl = it->second.runner; + c.fromManifest = !ctx.manifest.buildConfig.runner.empty(); + } + return c; +} + +// The capacity number, printed because capacity is the constraint. +// +// After `Finished`, not instead of it: the build succeeded either way, and a +// size line that replaced the outcome would be a different kind of message. +// Silent on every hosted target and whenever the tool is absent — an +// informational line has no standing to fail a build. +void report_freestanding_size(const BuildContext& ctx) { + auto ft = mcpp::toolchain::triple::parse(ctx.tc.targetTriple); + if (!ft || !ft->is_freestanding()) return; + auto tool = mcpp::freestanding::resolve_size_tool(ctx.tc.binaryPath); + if (tool.empty()) return; + for (auto const& lu : ctx.plan.linkUnits) { + if (lu.kind != mcpp::build::LinkUnit::Binary) continue; + auto art = ctx.outputDir / lu.output; + std::error_code ec; + if (!std::filesystem::exists(art, ec)) continue; + auto out = mcpp::xlings::run_capture(std::format( + "{} {} 2>/dev/null", mcpp::xlings::shq(tool.string()), + mcpp::xlings::shq(art.string()))); + if (!out) continue; + auto s = mcpp::freestanding::parse_size_output(*out); + if (!s) continue; + mcpp::ui::info("Size", std::format( + "{} text {} data {} bss {} total {}", + lu.targetName, s->text, s->data, s->bss, + mcpp::freestanding::size_total(*s))); + } +} + export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, std::string_view targetOverride = "") { // `--cache=off` means a cold build: no global cache, and target/ cleared — @@ -563,6 +627,7 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, if (bc.lto) descriptor += " + lto"; mcpp::ui::finished(ctx.profile, r->elapsed, descriptor); } + report_freestanding_size(ctx); return 0; } @@ -1049,10 +1114,22 @@ export int build_run_target(const std::optional& targetName, if (auto ft = mcpp::toolchain::triple::parse(ctx->tc.targetTriple)) freestandingRun = ft->is_freestanding(); if (freestandingRun) { - std::vector tmpl; - if (auto it = ctx->manifest.targetOverrides.find(ctx->tc.targetTriple); - it != ctx->manifest.targetOverrides.end()) - tmpl = it->second.runner; + // Two producers, and the precedence is the ordinary one: what the + // author of THIS project wrote beats what a dependency supplied. + // + // The dependency is the normal case — a board-support package knows + // the emulator, its machine model and its firmware mode, and computes + // the absolute path that a static manifest cannot. The explicit key + // exists for the other case: swapping `-bios default` for + // `-bios none -semihosting` while debugging is a legitimate thing to + // want, and removing that ability to make the BSP authoritative would + // trade one problem for a worse one. + const auto choice = choose_runner(*ctx); + auto tmpl = choice.tmpl; + if (choice.fromManifest) + mcpp::ui::info("note", std::format( + "[target.{}].runner overrides the runner a dependency supplied", + ctx->tc.targetTriple)); if (tmpl.empty()) { std::println(stderr, "error: {}", mcpp::freestanding::no_runner_message(ctx->tc.targetTriple)); @@ -1612,8 +1689,30 @@ export int run_tests(std::span passthrough, auto exe = ctx->outputDir / lu.output; + // A freestanding test image cannot run here either, and the answer is + // the SAME runner `mcpp run` uses — one read point, two callers. + // + // Nothing else about the test model changes, and that is a measured + // result rather than a simplification: semihosting propagates the + // firmware's `main` return value to the emulator's exit code + // (`return 7` → qemu exits 7, verified), so "exit code is the verdict" + // holds on bare metal exactly as it does on the host. An earlier plan + // called for a structured stdout protocol because it assumed there was + // no exit code to read; there is. std::vector argv; - argv.push_back(exe.string()); + { + const auto choice = choose_runner(*ctx); + if (choice.freestanding) { + if (choice.tmpl.empty()) { + std::println(stderr, "error: {}", + mcpp::freestanding::no_runner_message(ctx->tc.targetTriple)); + return 2; + } + argv = mcpp::freestanding::expand(choice.tmpl, exe); + } else { + argv.push_back(exe.string()); + } + } for (auto& a : passthrough) argv.push_back(a); std::vector> childEnv; diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index f263be33..7f35b42a 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -50,6 +50,25 @@ inline void generated(const char* path) { std::printf("mcpp:generated= inline void source(const char* path) { std::printf("mcpp:source=%s\n", path); } inline void include_dir(const char* dir) { std::printf("mcpp:include-dir=%s\n", dir); } inline void include_dir_after(const char* dir) { std::printf("mcpp:include-dir-after=%s\n", dir); } +// One argv token of the command that EXECUTES this build's artifact, when the +// host cannot run it itself (a freestanding image: wrong ISA, no loader). +// +// Called once per token, in order — argv is an ordered list and a directive +// carries one value per line. The artifact path is appended by mcpp, or +// substituted for a `{}` token if one is present. +// +// ⚠️ Emit the executable as an ABSOLUTE path. A bare name resolves through +// PATH to a shim that dispatches against its OWNER home, which is not +// necessarily the home this build uses; measured in CI as +// `xlings: 'qemu-system-riscv64' is not installed` from a job where the same +// bare name had answered `--version` two steps earlier. `xpkg_dir()` is how a +// package finds the payload it declared. +// +// ⚠️ Exactly one dependency may supply this. Two board-support packages both +// claiming to know how to run the artifact is a configuration error, and mcpp +// reports it naming both rather than merging them. +inline void runner(const char* token) { std::printf("mcpp:runner=%s\n", token); } + // The memory layout for a freestanding link. Reaches the CONSUMER's link line // (like link_lib/link_search, unlike include_dir), because the package that // knows a board's layout is not the package being built. diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 93f6adef..a77facec 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -18,6 +18,7 @@ module; export module mcpp.build.ninja; import std; +import mcpp.freestanding.linkline; import mcpp.build.backend; import mcpp.manifest; import mcpp.source_kind; @@ -504,6 +505,29 @@ std::string emit_ninja_string(const BuildPlan& plan) { flags.toolchainRuntimeDeploy.begin(), flags.toolchainRuntimeDeploy.end()); + // ── The raw image a flasher takes ────────────────────────────────────── + // + // Only freestanding targets get this; a hosted binary is loaded by a + // loader that wants the ELF. + const auto fsObjcopy = mcpp::freestanding::resolve_objcopy( + plan.toolchain.binaryPath, plan.toolchain.targetTriple); + + if (!fsObjcopy.empty()) { + append("objcopy = " + escape_ninja_path(fsObjcopy) + "\n\n"); + // ── The raw image a flasher takes ────────────────────────────────── + // + // A SEPARATE EDGE with the ELF as its input, not a second output of + // the link. A flat binary is produced by a different tool from a + // finished ELF, and folding it into the link command would make the + // two share one up-to-date check: touch a source, ninja relinks, and + // whether the .bin is regenerated depends on the command happening to + // run again rather than on a declared dependency. That is the shape + // where an incremental build hands back a stale image. + append("rule objcopy_bin\n"); + append(" command = $objcopy -O binary $in $out\n"); + append(" description = OBJCOPY $out\n\n"); + } + bool need_c_rule = false, need_asm_rule = false, need_nasm_rule = false; for (auto& cu : plan.compileUnits) { if (is_c_source(cu)) need_c_rule = true; @@ -1922,11 +1946,31 @@ std::string emit_ninja_string(const BuildPlan& plan) { if (lu.kind != LinkUnit::StaticLibrary) tail.runtimeFallback = flags.ldRuntimeFallback; tail.loaderTag = lu.loaderTagFlag; + // The link map — per unit, because it is named after the artifact. + if (!fsObjcopy.empty() + && (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary)) + tail.dependencies += mcpp::freestanding::map_flag( + lu.output, [](const std::filesystem::path& q) { + return escape_ninja_path(q); + }); if (auto unit = tail.render(); !unit.empty()) out_line += " unit_ldflags =" + unit + "\n"; } append(std::move(out_line)); + // ── Freestanding artifact set ────────────────────────────────────── + // + // `.bin` is a real edge on the `.elf`, so ninja rebuilds it when the + // image changes and never when it does not. `.map` is an implicit + // OUTPUT of the link: one command writes both, and a second edge + // claiming to produce the map would run the link twice. + if (!fsObjcopy.empty() + && (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary)) { + const auto elf = escape_ninja_path(lu.output); + append("build " + elf + ".bin: objcopy_bin " + elf + "\n"); + append("default " + elf + ".bin\n\n"); + } + for (auto const& alias : lu.runtimeAliases) { append(std::format("build {} : runtime_alias {}\n", escape_ninja_path(alias), diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 97ec0d11..69fb1555 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -3132,6 +3132,10 @@ prepare_build(bool print_fingerprint, // the build.mcpp side for the same reason that one is: a program that // reconstructs the store path is coupled to internals mcpp is free to // change. See mcpp::build::hostprogram::xpkg_dir. + // Which dependency supplied the runner, for the exactly-one-provider + // error below. A name rather than a bool: the message has to name both. + std::string runnerProvider; + auto fillXpkgDirs = [&](mcpp::build::BuildProgramEnv& e, const mcpp::manifest::Manifest& owner) { if (owner.xlings.deps.empty()) return; @@ -4958,6 +4962,7 @@ prepare_build(bool print_fingerprint, const auto mark = markDirectiveTail(pkg.manifest); const auto ldN = bcDep.ldflags.size(); const auto actN = bcDep.actions.size(); + const auto runnerN = bcDep.runner.size(); if (auto r = mcpp::build::run_build_program( pkg.manifest, pkg.root, host->first, host->second, pkg.manifest.cppStandard, bpEnv); @@ -4976,6 +4981,33 @@ prepare_build(bool print_fingerprint, // (link-search paths are already absolute from parse_line). foldDirectiveTailIntoPrivateBuild(pkg, pkg.manifest, mark); adoptActionOutputs(pkg.manifest, pkg.root, actN); + + // Scope::RunGlobal — how the artifact is EXECUTED, forwarded to + // the root like link flags but with the opposite merge rule. + // + // ⚠️ EXACTLY ONE provider. Link flags from two dependencies + // concatenate and that is correct; two runners cannot — appending + // produces an argv that is neither one's and fails at exec time + // with nothing to say which package contributed which token. So + // the second provider is a hard error that names BOTH, because + // naming only the loser tells the reader half of what they need. + if (bcDep.runner.size() > runnerN) { + std::vector supplied( + bcDep.runner.begin() + static_cast(runnerN), + bcDep.runner.end()); + if (!m->buildConfig.runner.empty() && !runnerProvider.empty()) { + return std::unexpected(std::format( + "two dependencies both supply a runner for this target: " + "'{}' and '{}'.\n" + " A runner is how the artifact is EXECUTED — there " + "can only be one.\n" + " Drop one of them, or override both with an " + "explicit [target.].runner.", + runnerProvider, pkg.manifest.package.name)); + } + m->buildConfig.runner = std::move(supplied); + runnerProvider = pkg.manifest.package.name; + } m->buildConfig.ldflags.insert(m->buildConfig.ldflags.end(), bcDep.ldflags.begin() + ldN, bcDep.ldflags.end()); } diff --git a/src/build/program_protocol.cppm b/src/build/program_protocol.cppm index 953dfe94..cd16be7b 100644 --- a/src/build/program_protocol.cppm +++ b/src/build/program_protocol.cppm @@ -45,7 +45,7 @@ export namespace mcpp::build::program_protocol { // number than this must refuse: it cannot know what it is being asked to do, // and "warn and ignore" would turn that into a silently different build. // v2 (#359): adds `rerun-if-changed-glob`. -inline constexpr int kProtocolVersion = 3; +inline constexpr int kProtocolVersion = 4; // ── Cache-format epoch ───────────────────────────────────────────────────── // diff --git a/src/cli.cppm b/src/cli.cppm index 37749e31..c57cbb89 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -324,8 +324,17 @@ int run(int argc, char** argv) { // target triple, exactly as in `mcpp build --target`. Spelling the // option differently here would make the one command that needs // both the one command where the flag is not called --target. - .option(cl::Option("target-triple").takes_value().value_name("TRIPLE") + // ⚠️ `--target` is the spelling every other subcommand uses, so it + // is the one here too. The POSITIONAL above happens to share the + // word and means something else entirely (a binary name) — that + // collision predates this flag and is why `--target-triple` also + // exists: an unambiguous spelling for anyone who wants one. + // Making `run` the one command whose cross-target flag is not + // called `--target` would have been the worse trade. + .option(cl::Option("target").takes_value().value_name("TRIPLE") .help("Cross target triple (same axis as `mcpp build --target`)")) + .option(cl::Option("target-triple").takes_value().value_name("TRIPLE") + .help("Unambiguous alias for --target (the positional is a binary NAME)")) .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") .help("Run only the named workspace member (single-member; no --workspace fan-out)")) .option(cl::Option("cache").takes_value().value_name("MODE") @@ -339,6 +348,8 @@ int run(int argc, char** argv) { .description("Build + run all tests/**/*.cpp (after `--`, args go to each test binary)") .arg(cl::Arg("pattern") .help("Run only tests whose name contains PATTERN (optional)")) + .option(cl::Option("target").takes_value().value_name("TRIPLE") + .help("Cross target triple (same axis as `mcpp build --target`)")) .option(cl::Option("message-format").takes_value().value_name("FMT") .help("Output format: human (default) | json (NDJSON, one record per test)")) .option(cl::Option("list") diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 857e28c7..18c99e84 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -166,7 +166,11 @@ export int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed, bool no_cache = parsed.is_flag_set("no-cache"); if (auto c = parsed.value("cache")) cache_mode = *c; else if (no_cache) cache_mode = "off"; + // `--target` is the spelling shared with every other subcommand; + // `--target-triple` is the unambiguous alias, because `run`'s POSITIONAL + // is also called target and means a binary name. std::string target_triple; + if (auto tt = parsed.value("target")) target_triple = *tt; if (auto tt = parsed.value("target-triple")) target_triple = *tt; return mcpp::build::build_run_target(targetName, passthrough, package_filter, cache_mode, no_cache, target_triple); @@ -188,6 +192,8 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, if (auto c = parsed.value("cache")) ov.cache_mode = *c; else if (parsed.is_flag_set("no-cache")) ov.cache_mode = "off"; + if (auto tt = parsed.value("target")) ov.target_triple = *tt; + mcpp::build::TestOptions to; if (parsed.positional_count() > 0) to.filter = parsed.positional(0); to.list = parsed.is_flag_set("list"); diff --git a/src/freestanding/linkline.cppm b/src/freestanding/linkline.cppm index 859bf1da..2e3be178 100644 --- a/src/freestanding/linkline.cppm +++ b/src/freestanding/linkline.cppm @@ -85,6 +85,20 @@ inline std::string link_flags(const Spec& s, const LinkInputs& in, return out; } +// The link map, for the question only a map can answer on a bare-metal target: +// why a section is where it is, and why something did or did not get pulled in. +// +// A flag on the link rather than a separate edge, because the linker is the +// only thing that can produce it and it does so as a side effect of the link +// it is already doing. +inline std::string map_flag(const std::filesystem::path& artifact, + const std::function& esc) +{ + auto m = artifact; m += ".map"; + return " -Wl,-Map=" + esc(m); +} + // LLD inside the same payload as the driver. // // Derived from the driver's own path rather than searched for, because a @@ -102,6 +116,24 @@ inline std::filesystem::path resolve_lld(const std::filesystem::path& driver) { return {}; } +// llvm-objcopy inside the same payload as the driver, for the flat image a +// flasher takes. Derived from the driver's path for the same reason +// `resolve_lld` is: a search is how the wrong tool gets picked. Returns "" for +// a hosted target — a loader wants the ELF, so there is nothing to convert. +inline std::filesystem::path +resolve_objcopy(const std::filesystem::path& driver, std::string_view triple) { + if (driver.empty()) return {}; + if (!resolve(triple)) return {}; // not a bare-metal target we know + const auto bin = driver.parent_path(); + std::error_code ec; + for (const char* name : { "llvm-objcopy", "llvm-objcopy.exe", + "objcopy", "objcopy.exe" }) { + auto p = bin / name; + if (std::filesystem::exists(p, ec)) return p; + } + return {}; +} + // Does this look like LLD? Checked against the linker's own `--version` // output rather than its filename, because the filename is exactly what was // wrong in the failure this guards. @@ -124,4 +156,52 @@ inline std::string wrong_linker_message(const std::filesystem::path& linker, linker.string(), firstLine.empty() ? "(no output)" : firstLine); } +// ── Size summary ─────────────────────────────────────────────────────────── +// +// The core constraint on a bare-metal target is CAPACITY, and mcpp already +// knows the number the moment the link finishes. Not printing it means every +// user runs `size` themselves — and the ones who do not find out the image no +// longer fits when the flasher refuses it. +// +// Parsed rather than passed through so the shape is mcpp's, not the tool's: +// `llvm-size` and GNU `size` differ in their headers, and a build that printed +// one tool's table would change appearance with the toolchain. +struct SizeSummary { long long text = 0, data = 0, bss = 0; }; + +inline long long size_total(const SizeSummary& s) { return s.text + s.data + s.bss; } + +// Parse the Berkeley-format second line: " text data bss dec …". +inline std::optional parse_size_output(std::string_view out) { + std::size_t nl = out.find('\n'); + if (nl == std::string_view::npos) return std::nullopt; + auto row = out.substr(nl + 1); + SizeSummary s; + int got = 0; + long long cur = 0; bool in = false; + for (char c : row) { + if (c >= '0' && c <= '9') { cur = cur * 10 + (c - '0'); in = true; continue; } + if (in) { + if (got == 0) s.text = cur; + else if (got == 1) s.data = cur; + else if (got == 2) { s.bss = cur; return s; } + ++got; cur = 0; in = false; + } + if (c == '\n') break; + } + if (in && got == 2) { s.bss = cur; return s; } + return std::nullopt; +} + +// `llvm-size` beside the driver, same derivation as the linker and objcopy. +inline std::filesystem::path resolve_size_tool(const std::filesystem::path& driver) { + if (driver.empty()) return {}; + const auto bin = driver.parent_path(); + std::error_code ec; + for (const char* name : { "llvm-size", "llvm-size.exe", "size", "size.exe" }) { + auto p = bin / name; + if (std::filesystem::exists(p, ec)) return p; + } + return {}; +} + } // namespace mcpp::freestanding diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index 04a46541..5c83888c 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -353,6 +353,24 @@ struct Resources { // is read in ~150 places, and a BuildConfig genuinely IS a set of build // inputs plus the selection axis and resolved policy scalars. struct BuildConfig : BuildInputs { + // How `mcpp run` / `mcpp test` execute an artifact this host cannot run, + // as an argv template (the artifact path is appended, or substituted for + // `{}`). + // + // On BuildConfig rather than only in `[target.].runner` because + // the value is MACHINE-SPECIFIC: the emulator lives in a package payload + // whose path carries a home and a version, so only a `build.mcpp` can + // compute it — and a build program writes into BuildConfig. A + // board-support package emitting `mcpp:runner=` is the intended producer; + // the manifest key remains the consumer's override. + // + // ⚠️ EXACTLY ONE provider among the dependencies. Two board-support + // packages both claiming to know how to run the artifact is a + // configuration error, not something to merge: appending would produce an + // argv that is neither one's, and it would fail at exec time with no + // indication of which package contributed which token. + std::vector runner; + // Was `sources` WRITTEN, as opposed to merely being empty? // // Presence is semantic here for the same reason it is on diff --git a/tests/e2e/131_freestanding_bsp_supplies_everything.sh b/tests/e2e/131_freestanding_bsp_supplies_everything.sh index 62e1f350..b1c88510 100755 --- a/tests/e2e/131_freestanding_bsp_supplies_everything.sh +++ b/tests/e2e/131_freestanding_bsp_supplies_everything.sh @@ -65,7 +65,7 @@ name = "board" version = "0.1.0" [xlings] -deps = ["xim:picolibc-riscv@1.8.12"] +deps = ["xim:picolibc-riscv@1.8.12", "xim:qemu-riscv@9.2.4-1"] EOF cat > build.mcpp <<'EOF' @@ -93,6 +93,16 @@ int main() { std::println("mcpp:link-lib=semihost"); std::println("mcpp:link-lib=clang_rt.builtins-{}", rt); std::println("mcpp:link-script={}/picolibcpp.ld", lib); + // ⭐ The runner too: the package that knows the board resolves the + // emulator absolutely and says how to drive it. The consumer's manifest + // below has no [target.*] section at all — that is N1 of the plan. + const char* qemu = std::getenv("MCPP_XPKG_XIM_QEMU_RISCV_DIR"); + if (qemu && *qemu) { + std::println("mcpp:runner={}/bin/qemu-system-riscv64", qemu); + for (auto a : {"-machine","virt","-nographic","-no-reboot", + "-semihosting","-bios","none","-kernel"}) + std::println("mcpp:runner={}", a); + } std::println("mcpp:rerun-if-env-changed=MCPP_TARGET_ARCH"); return 0; } @@ -136,7 +146,8 @@ extern "C" int main() { EOF # ⚠️ This manifest IS the assertion. Nothing here names picolibc, compiler-rt, -# crt0, a linker script, a load address, -nostdlib or -mcmodel. +# crt0, a linker script, a load address, -nostdlib, -mcmodel — or an emulator. +# There is no [target.*] section at all: the runner comes from the BSP. cat > mcpp.toml <<'EOF' [package] name = "fw" @@ -148,12 +159,7 @@ board = { path = "../board" } [targets.firmware] kind = "bin" main = "src/main.cpp" - -[target.riscv64-none-elf] -runner = ["QEMU_PATH", "-machine", "virt", "-nographic", - "-no-reboot", "-semihosting", "-bios", "none", "-kernel"] EOF -sed -i "s|QEMU_PATH|$QEMU|" mcpp.toml "$MCPP" run --target-triple riscv64-none-elf > run.log 2>&1 || true grep -q 'BSP-CHAIN-OK 42' run.log || { diff --git a/tests/e2e/132_freestanding_test_and_artifacts.sh b/tests/e2e/132_freestanding_test_and_artifacts.sh new file mode 100755 index 00000000..1e23c2ed --- /dev/null +++ b/tests/e2e/132_freestanding_test_and_artifacts.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# requires: llvm qemu-riscv unix-shell +# `mcpp test` on bare metal, and the artifact set a flasher needs. +# +# ⚠️ TWO PLAN ASSUMPTIONS THIS TEST EXISTS BECAUSE THEY WERE WRONG +# +# The design called for a `batch` mode (all cases in one image) and a +# structured stdout protocol, on two premises. Both were measured false: +# +# * "qemu cold start is ~0.4s, so 30 isolated cases cost 12s" +# → measured 12ms per start. 30 cases cost 0.36s. The whole reason for +# batching disappeared, and with it the "on timeout, re-run isolated to +# find the culprit" machinery — isolated already names the culprit. +# +# * "bare metal has no exit code to read, so results need their own channel" +# → semihosting propagates the firmware's `main` return value to the +# emulator's exit code (`return 7` → qemu exits 7, verified). "Exit code +# is the verdict" holds here exactly as it does on the host. +# +# So `mcpp test` needed one change — route the test binary through the same +# runner `mcpp run` uses — and this test pins the result of that, from both +# sides: passes pass, and a failure is NAMED and makes the run non-zero. +set -e + +QEMU="$(command -v qemu-system-riscv64 || true)" +for d in "$HOME"/.mcpp/registry/data/xpkgs/*-x-qemu-riscv/*/bin \ + "$HOME"/.xlings/data/xpkgs/*-x-qemu-riscv/*/bin; do + [[ -x "$d/qemu-system-riscv64" ]] && QEMU="$d/qemu-system-riscv64" +done +[[ -n "$QEMU" ]] || { echo "SKIP: no qemu-system-riscv64"; exit 0; } + +MH="${MCPP_HOME:-$HOME/.mcpp}" +[[ -d "$MH/registry/data/xpkgs/xim-x-picolibc-riscv" ]] \ + || { echo "SKIP: xim:picolibc-riscv not installed in $MH"; exit 0; } + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# ── the board-support package (same shape as 131) ─────────────────────────── +"$MCPP" new board > /dev/null +cd board +rm -f src/main.cpp tests/*.cpp 2>/dev/null || true + +cat > mcpp.toml <<'EOF' +[package] +name = "board" +version = "0.1.0" + +[xlings] +deps = ["xim:picolibc-riscv@1.8.12", "xim:qemu-riscv@9.2.4-1"] +EOF + +cat > build.mcpp <<'EOF' +import std; +int main() { + const char* sysroot = std::getenv("MCPP_XPKG_XIM_PICOLIBC_RISCV_DIR"); + if (!sysroot || !*sysroot) { + std::cerr << "board: xim:picolibc-riscv declared but not installed\n"; + return 1; + } + std::string arch = std::getenv("MCPP_TARGET_ARCH") ?: ""; + std::string prof = (arch == "riscv32") ? "rv32imac/ilp32" : "rv64gc/lp64d"; + std::string rt = (arch == "riscv32") ? "riscv32" : "riscv64"; + std::string lib = std::format("{}/lib/{}", sysroot, prof); + + std::println("mcpp:include-dir={}/include/{}", sysroot, prof); + std::println("mcpp:link-search={}", lib); + std::println("mcpp:link-lib=crt0-semihost"); + std::println("mcpp:link-lib=c"); + std::println("mcpp:link-lib=semihost"); + std::println("mcpp:link-lib=clang_rt.builtins-{}", rt); + std::println("mcpp:link-script={}/picolibcpp.ld", lib); + + // ⭐ The runner, from the package that knows the board. The consumer's + // manifest below has no [target.*] section at all. + const char* qemu = std::getenv("MCPP_XPKG_XIM_QEMU_RISCV_DIR"); + if (qemu && *qemu) { + std::println("mcpp:runner={}/bin/qemu-system-riscv64", qemu); + for (auto a : {"-machine","virt","-nographic","-no-reboot", + "-semihosting","-bios","none","-kernel"}) + std::println("mcpp:runner={}", a); + } + std::println("mcpp:rerun-if-env-changed=MCPP_TARGET_ARCH"); + return 0; +} +EOF + +cat > src/board.cppm <<'EOF' +module; +#include +export module board; +export namespace board { + inline void print(const char* s) { fputs(s, stdout); } + inline void printf_d(const char* f, int v) { printf(f, v); } +} +EOF + +# ── the consumer: three test cases, one of them failing ───────────────────── +cd "$TMP" +"$MCPP" new fw > /dev/null +cd fw +rm -f tests/*.cpp 2>/dev/null || true + +cat > src/main.cpp <<'EOF' +import board; +extern "C" int main() { board::print("firmware\n"); return 0; } +EOF +cat > tests/ok_one.cpp <<'EOF' +import board; +extern "C" int main() { board::print("case one\n"); return 0; } +EOF +cat > tests/ok_two.cpp <<'EOF' +import board; +extern "C" int main() { board::printf_d("case two %d\n", 2); return 0; } +EOF +cat > tests/deliberate_fail.cpp <<'EOF' +import board; +// Non-zero on purpose. Without it, "all green" would be indistinguishable +// from "the verdict is never read" — which is what a bare-metal test harness +// gets wrong by default. +extern "C" int main() { board::print("case three\n"); return 1; } +EOF + +# ⚠️ No [target.*] section: the runner comes from the BSP. +cat > mcpp.toml <<'EOF' +[package] +name = "fw" +version = "0.1.0" + +[dependencies] +board = { path = "../board" } + +[targets.firmware] +kind = "bin" +main = "src/main.cpp" +EOF + +# ── mcpp test ─────────────────────────────────────────────────────────────── +if "$MCPP" test --target riscv64-none-elf > test.log 2>&1; then + cat test.log + echo "a failing test case did not make the run fail" + exit 1 +fi +grep -q 'ok_one ... ok' test.log || { cat test.log; echo "ok_one did not pass"; exit 1; } +grep -q 'ok_two ... ok' test.log || { cat test.log; echo "ok_two did not pass"; exit 1; } +# ⚠️ The failure has to be NAMED. "2 passed; 1 failed" without a name is a +# harness that tells you to go looking. +grep -q 'deliberate_fail ... FAIL' test.log || { + cat test.log; echo "the failing case was not named"; exit 1; } + +# ── artifact set ──────────────────────────────────────────────────────────── +"$MCPP" build --target riscv64-none-elf > build.log 2>&1 +elf="$(find target/riscv64-none-elf -name firmware -type f | head -1)" +[[ -n "$elf" ]] || { cat build.log; echo "no firmware"; exit 1; } +[[ -f "$elf.bin" ]] || { echo "no .bin beside the ELF"; exit 1; } +[[ -f "$elf.map" ]] || { echo "no .map beside the ELF"; exit 1; } + +# Capacity is the constraint on a bare-metal target, so the number is printed. +grep -q 'Size .*text .*data .*bss' build.log || { + cat build.log; echo "no size summary"; exit 1; } + +# ⚠️ The one that matters: `.bin` is a real edge on the ELF, not a side effect +# of the link command happening to run. Change a source and its CONTENT must +# change — a mtime-only check would pass even if the edge were missing. +before="$(sha256sum "$elf.bin" | cut -d' ' -f1)" +sed -i 's/firmware\\n/firmware2\\n/' src/main.cpp +"$MCPP" build --target riscv64-none-elf > build2.log 2>&1 +after="$(sha256sum "$elf.bin" | cut -d' ' -f1)" +[[ "$before" != "$after" ]] || { + echo ".bin did not change after a source edit — it is not a real ninja edge" + exit 1; } + +echo "PASS: bare-metal mcpp test names its failure, and the artifact set is real" diff --git a/tests/unit/test_build_directives.cpp b/tests/unit/test_build_directives.cpp index 46e423c8..f84e53d9 100644 --- a/tests/unit/test_build_directives.cpp +++ b/tests/unit/test_build_directives.cpp @@ -535,3 +535,67 @@ TEST(BuildDirectives, LinkScriptDoesNotClaimADeclaredOutput) { EXPECT_FALSE(def.mustExistAfterRun); } } + +// ── runner ───────────────────────────────────────────────────────────────── +// +// How the artifact is EXECUTED, when the host cannot execute it. Produced by +// a board-support package's build.mcpp, because the value is machine-specific: +// the emulator lives in a package payload whose path carries a home and a +// version, and only a build program can compute that. + +TEST(BuildDirectives, RunnerBuildsAnArgvFromRepeatedLines) { + // argv is an ordered list and a directive is one line = one value, so the + // list is built by repetition. Order is the emission order. + auto d = parse("mcpp:runner=/payload/bin/qemu-system-riscv64\n" + "mcpp:runner=-machine\n" + "mcpp:runner=virt\n" + "mcpp:runner=-kernel\n"); + auto& r = d.at(dirs::Slot::Runner); + ASSERT_EQ(r.size(), 4u); + EXPECT_EQ(r[0], "/payload/bin/qemu-system-riscv64"); + EXPECT_EQ(r[1], "-machine"); + EXPECT_EQ(r[3], "-kernel"); +} + +TEST(BuildDirectives, RunnerTokensAreVerbatim) { + // Not a path to normalize: `-bios` is a token, and the producer already + // resolved the executable absolutely (a bare name resolves through PATH to + // a shim that dispatches against its OWNER home — measured in CI). + auto d = parse("mcpp:runner=-bios\nmcpp:runner=none\n"); + auto& r = d.at(dirs::Slot::Runner); + ASSERT_EQ(r.size(), 2u); + EXPECT_EQ(r[0], "-bios"); + EXPECT_EQ(r[1], "none"); +} + +TEST(BuildDirectives, RunnerHasItsOwnSlotAndScope) { + const dirs::Def* runner = nullptr; + const dirs::Def* script = nullptr; + for (auto const& def : dirs::kTable) { + if (def.wire == "runner") runner = &def; + if (def.wire == "link-script") script = &def; + } + ASSERT_NE(runner, nullptr); + ASSERT_NE(script, nullptr); + // Its own slot: putting it in LdFlags would put an emulator's argv on the + // linker command line. + EXPECT_EQ(runner->slot, dirs::Slot::Runner); + EXPECT_NE(runner->slot, script->slot); + // Its own scope: RunGlobal propagates like LinkGlobal but carries an + // exactly-one-provider rule that LinkGlobal must not inherit — two + // dependencies' link flags concatenate correctly, two runners cannot. + EXPECT_EQ(runner->scope, dirs::Scope::RunGlobal); + EXPECT_EQ(script->scope, dirs::Scope::LinkGlobal); +} + +TEST(BuildDirectives, RunnerLandsInBuildConfigNotInLdflags) { + auto d = parse("mcpp:runner=/qemu\nmcpp:runner=-kernel\n" + "mcpp:link-lib=c\n"); + mcpp::manifest::Manifest m; + dirs::apply(m, d); + ASSERT_EQ(m.buildConfig.runner.size(), 2u); + EXPECT_EQ(m.buildConfig.runner[0], "/qemu"); + // The link line must carry the library and nothing of the runner. + for (auto const& f : m.buildConfig.ldflags) + EXPECT_EQ(f.find("qemu"), std::string::npos) << f; +} diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index 58fd15a3..66c52944 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -227,3 +227,41 @@ TEST(XpkgEnvVar, BothSpellingsAreDerivedFromOneSanitizer) { EXPECT_EQ(xpkg_env_var("", "picolibc-riscv"), "MCPP_XPKG_PICOLIBC_RISCV_DIR"); } + +// ── artifact set ─────────────────────────────────────────────────────────── + +TEST(FreestandingArtifacts, SizeOutputIsParsedNotPassedThrough) { + // Parsed so the printed shape is mcpp's, not the tool's: llvm-size and GNU + // size differ in their headers, and a build whose output changed + // appearance with the toolchain would be reporting the tool, not the size. + auto s = parse_size_output( + " text\t data\t bss\t dec\t hex\tfilename\n" + " 8836\t 80\t 5668\t 14584\t 38f8\tfirmware\n"); + ASSERT_TRUE(s.has_value()); + EXPECT_EQ(s->text, 8836); + EXPECT_EQ(s->data, 80); + EXPECT_EQ(s->bss, 5668); + // The number that decides whether the image fits. + EXPECT_EQ(size_total(*s), 14584); +} + +TEST(FreestandingArtifacts, MalformedSizeOutputIsNotGuessedAt) { + // An informational line has no standing to invent numbers. + EXPECT_FALSE(parse_size_output("").has_value()); + EXPECT_FALSE(parse_size_output("size: cannot open 'x'\n").has_value()); +} + +TEST(FreestandingArtifacts, MapFlagNamesTheArtifact) { + auto f = map_flag("/build/bin/firmware", + [](const std::filesystem::path& p) { return p.string(); }); + EXPECT_EQ(f, " -Wl,-Map=/build/bin/firmware.map"); +} + +TEST(FreestandingArtifacts, ObjcopyOnlyResolvesForABareMetalTarget) { + // A hosted binary is loaded by a loader that wants the ELF, so there is + // nothing to convert — and resolving a tool for it would put an edge in + // every hosted graph. + EXPECT_TRUE(resolve_objcopy("/payload/bin/clang++", "x86_64-linux-gnu").empty()); + // (The bare-metal case needs a real payload on disk, so it is asserted by + // tests/e2e/130 instead: the artifact set has to actually appear.) +} From 287e43ecae7c16d194e736caa3e88c3d9a20e1c9 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:07:30 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix(cli):=20`mcpp=20run`=20cannot=20spell?= =?UTF-8?q?=20it=20`--target`=20=E2=80=94=20the=20positional=20already=20i?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding `--target` to `mcpp run` for symmetry with every other subcommand broke `mcpp run `: $ mcpp run q error: unknown target 'q' The parser keys options and positionals by the same word, and this subcommand's POSITIONAL is called target and means the binary NAME. `e2e/73` caught it within the hour. The positional's declaration already carried a comment saying so; I overrode it reaching for consistency. `run` keeps `--target-triple`; `test` has no such positional and keeps `--target`. Consistency is one of the criteria, and it ranks below not breaking things. --- ...2026-08-19-baremetal-phase3-usable-plan.md | 15 +++++++++--- src/cli.cppm | 24 +++++++++++-------- src/cli/cmd_build.cppm | 7 +++--- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md index d2fabb97..b17f4628 100644 --- a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md +++ b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md @@ -316,6 +316,15 @@ error: test result: FAILED. 2 passed; 1 failed 消费者写了 ⇒ 以它为准并说明覆盖了谁。 - **R4 按设计落地**,包括计划里点名的 R-5:`.bin` 是以 `.elf` 为输入的**独立 ninja 边**, 改一行源码后**内容**变(不是只有 mtime 变)。 -- ⚠️ 顺带修了一处**我自己造成的不一致**:`mcpp run` 先前用 `--target-triple` 而其余 - 子命令用 `--target`。现在 `--target` 到处可用,`--target-triple` 作为 `run` 的 - 无歧义别名保留(它的**位置参数**恰好也叫 target,含义是二进制名)。 +- ⚠️ **一次为了「一致性」而制造的回归,被 CI 抓回来。** `mcpp run` 用 + `--target-triple` 而其余子命令用 `--target`,看起来像我造成的不一致,于是我给 + `run` 也加了 `--target`。**parser 把选项和位置参数按同一个词索引**,于是: + + ``` + $ mcpp run q + error: unknown target 'q' ← q 是二进制名,被当成了目标三元组 + ``` + + `e2e/73` 立刻红。而位置参数上**原本就有一条注释写着这个碰撞** —— 我为了对称把它 + 覆盖了。⇒ `run` 保持 `--target-triple`(`test` 没有这个位置参数,`--target` 正常)。 + **一致性是判据之一,但它排在「不能坏」后面。** diff --git a/src/cli.cppm b/src/cli.cppm index c57cbb89..d1d132dc 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -324,17 +324,21 @@ int run(int argc, char** argv) { // target triple, exactly as in `mcpp build --target`. Spelling the // option differently here would make the one command that needs // both the one command where the flag is not called --target. - // ⚠️ `--target` is the spelling every other subcommand uses, so it - // is the one here too. The POSITIONAL above happens to share the - // word and means something else entirely (a binary name) — that - // collision predates this flag and is why `--target-triple` also - // exists: an unambiguous spelling for anyone who wants one. - // Making `run` the one command whose cross-target flag is not - // called `--target` would have been the worse trade. - .option(cl::Option("target").takes_value().value_name("TRIPLE") - .help("Cross target triple (same axis as `mcpp build --target`)")) + // ⚠️ NOT `--target`, and this is not a style choice. + // + // The POSITIONAL above is already called target — it is the binary + // NAME — and the parser keys both by that word, so declaring the + // option makes `mcpp run ` set the cross-target instead: + // + // $ mcpp run q + // error: unknown target 'q' + // + // Measured, after trying exactly that for the sake of matching + // `mcpp build --target`. `run` is the one subcommand that cannot + // spell it that way, and the existing comment on the positional + // said so before this was attempted. .option(cl::Option("target-triple").takes_value().value_name("TRIPLE") - .help("Unambiguous alias for --target (the positional is a binary NAME)")) + .help("Cross target triple (same axis as `mcpp build --target`)")) .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") .help("Run only the named workspace member (single-member; no --workspace fan-out)")) .option(cl::Option("cache").takes_value().value_name("MODE") diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 18c99e84..c0c847c0 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -166,11 +166,10 @@ export int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed, bool no_cache = parsed.is_flag_set("no-cache"); if (auto c = parsed.value("cache")) cache_mode = *c; else if (no_cache) cache_mode = "off"; - // `--target` is the spelling shared with every other subcommand; - // `--target-triple` is the unambiguous alias, because `run`'s POSITIONAL - // is also called target and means a binary name. + // ⚠️ `--target-triple`, never `--target`: this subcommand's POSITIONAL is + // called target and means the binary NAME, and the parser keys both by + // that word. See the option's declaration in cli.cppm. std::string target_triple; - if (auto tt = parsed.value("target")) target_triple = *tt; if (auto tt = parsed.value("target-triple")) target_triple = *tt; return mcpp::build::build_run_target(targetName, passthrough, package_filter, cache_mode, no_cache, target_triple); From b2c38ca1d6dc86c4498eae9d746249f00f0f777a Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:17:06 +0800 Subject: [PATCH 3/9] feat(build.mcpp): a missing `mcpp::` API now says the engine may be too old MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package that adopts a new typed directive cannot be used by an older mcpp, and until now the only thing the reader saw was error: 'runner' is not a member of 'mcpp' which reads like the package author's typo rather than the reader's engine being out of date. The wire protocol's own answer (protocol_error names `mcpp self update`) never reaches this case: it needs the program to have compiled first. The package cannot solve it either. Measured: if constexpr (requires { mcpp::runner("qemu"); }) // hard error A requires-expression over a qualified name that does not exist is ill-formed, not `false`, so there is no in-language feature probe. So the compile failure carries the hint, matched across all three frontends' spellings and anchored on our namespace so an ordinary error does not get it. This covers every future typed-API addition, not just `runner`. Also corrects docs/07 (both languages), which still claimed an unknown directive "can only be a typo" within one protocol version — the engine's own diagnostic was corrected away from that when link-script landed: the protocol number is stamped by whichever mcpp compiled the program, so two matching numbers say nothing about whether the key is from the future. --- ...2026-08-19-baremetal-phase3-usable-plan.md | 26 ++++++++ docs/07-build-mcpp.md | 34 +++++++++- docs/zh/07-build-mcpp.md | 27 +++++++- src/build/build_program.cppm | 64 ++++++++++++++++++- tests/unit/test_freestanding.cpp | 29 +++++++++ 5 files changed, 173 insertions(+), 7 deletions(-) diff --git a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md index b17f4628..5b51cf99 100644 --- a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md +++ b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md @@ -132,6 +132,32 @@ mcpp::runner("-kernel"); ⭐ **决定:先按「什么都不加」发布,并把包级 `requires-mcpp` 作为一个独立探针(P-COMPAT)排在后面。** 理由:诊断已经可行动,而一个会让老客户端硬失败的新键代价高得多 —— 且这个代价**尚未测过**。 +#### ⚠️ 实施时探到的:上表第 2 行「诊断可行动」对**类型化 API 是假的** + +那条可行动的诊断(`protocol_error()`,点名 `mcpp self update`)只对**wire 键**生效, +而它要求程序**先编译得过**。BSP 用的是 `mcpp::runner(...)`,老 mcpp 上根本编不过, +拿到的是: + +``` +error: 'runner' is not a member of 'mcpp' +``` + +—— 读起来像**包作者拼错了**,而不是**读者的引擎旧了**。 + +我先试了在包里做优雅降级,**实测证伪**: + +```cpp +if constexpr (requires { mcpp::runner("qemu"); }) // ✗ 名字不存在 ⇒ 硬错误 +``` + +`requires` 表达式作用在**不存在的限定名**上是 ill-formed,**不是求值为 `false`**。 +⇒ **语言内没有特性探测**,包侧无路可走。 + +⭐ **所以补在引擎侧**:`build.mcpp` 编译失败且错误里出现「不是 `mcpp` 的成员」时, +追加一段点名 `mcpp self update` 并报出当前版本的提示(三个前端三种写法都认)。 +**这对以后每一次类型化 API 新增都生效**,不只是 `runner`。 +判据:`mentions_missing_mcpp_api` 单测钉三种拼写 + 三条不该误报的普通错误。 + ### 3.3 收录进 `mcpplibs/mcpp-index` `pkgs/r/riscv-virt-rt.lua` 与 `pkgs/m/mcpplibs.std.freestanding.lua`。 diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index 73a38d3d..e29e841f 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -326,9 +326,12 @@ write it yourself). mcpp uses that two ways: with an upgrade hint. Continuing would silently drop directives the build depends on — and "the build succeeded but the flag never arrived" is the worst class of build bug. -- Because the two sides then provably agree, an **unrecognized directive is an - error** rather than a warning: within one protocol version it can only be a - typo. +- An **unrecognized directive is an error** rather than a warning, and the error + names *both* possible causes. It cannot name one: the protocol number is + stamped by whichever mcpp **compiled** the program, not carried by the + package, so a package written for a newer mcpp arrives at an older one + wearing the older engine's number. Two matching numbers therefore say nothing + about whether the key came from the future. A `printf`-style program announces nothing, so it keeps the historical warn-and-ignore behaviour. That surface is **frozen at the eleven directives in @@ -336,6 +339,31 @@ the table above** — it still works and will keep working, but new capabilities land only in the typed API. Prefer `import mcpp;` for anything intended to maintain. +#### A package that needs a newer mcpp + +When a published package calls a typed function this mcpp does not have, the +compile error naming it is followed by: + +``` + 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.2), or the name is misspelled … +``` + +The package cannot handle this itself, and it is worth knowing why — the +obvious guard does not compile: + +```cpp +if constexpr (requires { mcpp::runner("qemu"); }) // ✗ hard error when absent + mcpp::runner("qemu"); +``` + +A `requires`-expression over a **qualified name that does not exist** is +ill-formed, not `false`. So there is no in-language feature probe, and a +package that adopts a new directive states its floor in prose (its README) and +relies on the diagnostic above. Such a package should name the mcpp version it +requires. + ### `import std;` (mcpp 2026.8.2.1+) A `build.mcpp` may `import std;` (and `import std.compat;`), alone or together diff --git a/docs/zh/07-build-mcpp.md b/docs/zh/07-build-mcpp.md index 01fe301c..8aa8c5bf 100644 --- a/docs/zh/07-build-mcpp.md +++ b/docs/zh/07-build-mcpp.md @@ -290,13 +290,36 @@ mcpp 会播下一个带着该声明的占位文件,使 prepare 期的扫描与 - 程序声明的协议**高于** mcpp 所理解的 → **拒绝执行**,并给出升级提示。继续跑会 静默丢掉构建依赖的指令,而「构建成功了但那个 flag 根本没到」是最难查的一类问题。 -- 既然双方已被证明一致,**未知指令就是错误**而不是警告:在同一个协议版本内, - 它只可能是拼写错误。 +- **未知指令是错误**而不是警告,而且这条错误会把**两种可能的原因都说出来**。 + 它没法只说一种:协议号是由**编译**该程序的那个 mcpp 现场打上的,并不由包本身携带 + —— 于是一个写给新 mcpp 的包到了老 mcpp 手里,身上戴的是老引擎的号。 + **两个号一致因此完全不能说明这个键是不是来自未来。** `printf` 风格的程序什么都不声明,因此保留历史上的「警告并忽略」行为。这一面 **冻结在上表的 11 条指令**上——它仍然能用、也会继续能用,但新能力只在类型化 API 里 落地。**要长期维护的程序请用 `import mcpp;`。** +#### 一个需要更新 mcpp 的包 + +当已发布的包调用了当前 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.2), or the name is misspelled … +``` + +**包自己处理不了这件事**,而原因值得知道 —— 最直觉的那道防护编译不过: + +```cpp +if constexpr (requires { mcpp::runner("qemu"); }) // ✗ 名字不存在时是硬错误 + mcpp::runner("qemu"); +``` + +`requires` 表达式作用在一个**不存在的限定名**上时是 ill-formed,而**不是求值为 +`false`**。所以语言内没有特性探测这条路:采用了新指令的包只能在自己的 README 里 +用文字写明版本下限,并依赖上面那条诊断。**这类包应当写清楚它需要哪个版本的 mcpp。** + ### `import std;`(mcpp 2026.8.2.1+) `build.mcpp` 可以 `import std;`(以及 `import std.compat;`),单用或与 diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index a3cd01f7..ac61bbdc 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -28,6 +28,7 @@ import mcpp.toolchain.registry; // archive_tool import mcpp.toolchain.stdmod; // ensure_built — the SAME std BMI the main build uses import mcpp.toolchain.triple; // host_triple (MCPP_HOST contract value) import mcpp.ui; +import mcpp.version; // MCPP_VERSION — the hint names the engine the reader is on export namespace mcpp::build { @@ -94,6 +95,30 @@ inline std::string xpkg_env_var(std::string_view ns, std::string_view name) { return out; } +// Does a compiler's output say the program asked for something the bundled +// `mcpp` module does not have? +// +// ⚠️ This is the ONLY place an engine-too-old situation can be caught for the +// TYPED api, and it exists because the in-language probe does not: +// +// if constexpr (requires { mcpp::runner("x"); }) // ← hard error, +// mcpp::runner("x"); // measured +// +// A requires-expression over a qualified name that does not exist is +// ill-formed, not `false`, so a package CANNOT degrade gracefully across mcpp +// versions the way it could across, say, a header's feature macro. The wire +// protocol has its own answer for this (protocol_error() names `mcpp self +// update` for an unknown `mcpp:` key), but that answer needs the program to +// have COMPILED — and a package written against a newer mcpp does not get +// that far. So the raw compiler error is the message, and on its own it says +// only `'runner' is not a member of 'mcpp'`, which reads like the author's +// typo instead of the reader's out-of-date engine. +// +// Deliberately spelling-based, and deliberately broad across the three +// frontends (they phrase it three ways). A false positive costs one extra +// hint line under a genuine typo; a false negative costs a user an afternoon. +bool mentions_missing_mcpp_api(std::string_view compilerOutput); + // Compile + run `/build.mcpp` (if present) with `hostCompiler` (the resolved // HOST frontend — under a cross --target the caller resolves a host toolchain; // the program always compiles AND runs on the host) and apply its directives to @@ -128,6 +153,28 @@ bool glob_inputs_stale(const std::filesystem::path& projectRoot); namespace mcpp::build { +// See the declaration for why this exists at all. +// +// Three frontends, three spellings of the same fact — and MSVC's does not even +// contain the word "member" in the same order, so each is matched literally +// rather than by a shared substring: +// +// gcc error: 'runner' is not a member of 'mcpp' +// clang error: no member named 'runner' in namespace 'mcpp' +// cl.exe error C2039: 'runner': is not a member of 'mcpp' +// +// The trailing `'mcpp'` is what keeps this off unrelated failures: a package's +// own missing symbol names its own namespace, not ours. +bool mentions_missing_mcpp_api(std::string_view out) { + static constexpr std::string_view kNeedles[] = { + "is not a member of 'mcpp'", // gcc, and cl.exe's tail + "in namespace 'mcpp'", // clang + }; + for (auto n : kNeedles) + if (out.find(n) != std::string_view::npos) return true; + return false; +} + namespace { namespace fs = std::filesystem; @@ -778,8 +825,21 @@ std::expected run_build_program( auto cres = mcpp::platform::process::capture_exec(compileArgv, compileEnv, compileCwd); if (cres.exit_code != 0) { - return std::unexpected(std::format( - "build.mcpp failed to compile (exit {}):\n{}", cres.exit_code, cres.output)); + std::string msg = std::format("build.mcpp failed to compile (exit {}):\n{}", + cres.exit_code, cres.output); + if (mentions_missing_mcpp_api(cres.output)) { + msg += std::format( + "\n The `mcpp` build module this engine bundles does not have " + "that name.\n" + " Either the package was written for a newer mcpp (try " + "`mcpp self update`;\n" + " this is mcpp {}), or the name is misspelled — the compiler " + "cannot tell\n" + " the two apart, because the module is generated by whichever " + "mcpp is running.", + mcpp::MCPP_VERSION); + } + return std::unexpected(std::move(msg)); } // ── Run it; capture stdout(+stderr) and parse directives ──────────────── diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index 66c52944..1acc9bb5 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -228,6 +228,35 @@ TEST(XpkgEnvVar, BothSpellingsAreDerivedFromOneSanitizer) { "MCPP_XPKG_PICOLIBC_RISCV_DIR"); } +// ── engine floor: a package that needs a newer mcpp ───────────────────────── + +TEST(BuildProgramCompatHint, RecognisesAllThreeFrontendSpellings) { + using mcpp::build::mentions_missing_mcpp_api; + // ⚠️ Measured, not assumed: `if constexpr (requires { mcpp::runner("x"); })` + // is a HARD ERROR when the name is absent, so a package cannot probe for a + // newer API in-language. The compiler's error IS the compat channel, and + // these are the three ways it arrives. + EXPECT_TRUE(mentions_missing_mcpp_api( + "build.mcpp:57:11: error: 'runner' is not a member of 'mcpp'")); // gcc + EXPECT_TRUE(mentions_missing_mcpp_api( + "build.mcpp:57:11: error: no member named 'runner' in namespace 'mcpp'"));// clang + EXPECT_TRUE(mentions_missing_mcpp_api( + "build.mcpp(57): error C2039: 'runner': is not a member of 'mcpp'")); // cl.exe +} + +TEST(BuildProgramCompatHint, StaysOffFailuresThatAreNotAboutOurApi) { + using mcpp::build::mentions_missing_mcpp_api; + // The hint says "your mcpp may be too old". Attaching that to an ordinary + // compile error would send the reader to the wrong place, so the match is + // anchored on OUR namespace — a package's own missing symbol names its own. + EXPECT_FALSE(mentions_missing_mcpp_api( + "build.mcpp:12:5: error: 'runner' is not a member of 'board'")); + EXPECT_FALSE(mentions_missing_mcpp_api( + "build.mcpp:3:1: error: expected ';' after top level declarator")); + EXPECT_FALSE(mentions_missing_mcpp_api( + "build.mcpp:9:9: error: use of undeclared identifier 'mcpp_runner'")); +} + // ── artifact set ─────────────────────────────────────────────────────────── TEST(FreestandingArtifacts, SizeOutputIsParsedNotPassedThrough) { From c99bd2652c03bf54038f9bbc8435d68cffbacf65 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:22:57 +0800 Subject: [PATCH 4/9] fix(cli): rename `run`'s positional to `bin`, which frees `--target` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collision was real — `mcpp run q` became `mcpp run --target=q` — but retreating to `--target-triple` on this one subcommand paid for it with a lasting inconsistency. The actual cause is narrower than "one word, two axes": ParsedArgs::value() falls back from an unset option to a positional OF THE SAME NAME, and that positional was named `target`. cmd_run never reads the name (it takes positional(0) by index) — it only labels the slot in --help, where `bin` is the more accurate word. So `--target` works on `run` like everywhere else, `--target-triple` stays as an alias because it shipped in 2026.8.19.1, and e2e/130 now pins both spellings while e2e/73 pins the positional. --- ...2026-08-19-baremetal-phase3-usable-plan.md | 11 ++++-- docs/05-mcpp-toml.md | 2 +- docs/zh/05-mcpp-toml.md | 2 +- src/cli.cppm | 34 +++++++++---------- src/cli/cmd_build.cppm | 6 ++-- .../130_freestanding_riscv_build_and_run.sh | 18 +++++++++- 6 files changed, 48 insertions(+), 25 deletions(-) diff --git a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md index 5b51cf99..0598d2f8 100644 --- a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md +++ b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md @@ -352,5 +352,12 @@ error: test result: FAILED. 2 passed; 1 failed ``` `e2e/73` 立刻红。而位置参数上**原本就有一条注释写着这个碰撞** —— 我为了对称把它 - 覆盖了。⇒ `run` 保持 `--target-triple`(`test` 没有这个位置参数,`--target` 正常)。 - **一致性是判据之一,但它排在「不能坏」后面。** + 覆盖了。 + + 但**第一版修法(`run` 退回只认 `--target-triple`)也不对** —— 它把「不能坏」买回来了, + 代价是留下一条真实的不一致。**真因不是这个词有两个轴,而是位置参数的名字选错了**: + `ParsedArgs::value()` 在选项未设置时会**按同名回落到位置参数**,而这个位置参数的名字 + `cmd_run` 从来不读(它按下标取 `positional(0)`),只出现在 `--help` 里。 + ⇒ **把位置参数改名 `bin`**(在 `--help` 里本来就更准),`--target` 就自由了。 + `--target-triple` 作为 2026.8.19.1 已发布的拼写保留为别名。 + **一致性是判据之一,它排在「不能坏」后面 —— 但排在后面不等于要放弃。** diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 03571d1c..ee4bd164 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -906,7 +906,7 @@ can produce them. ```bash mcpp build --target riscv64-none-elf -mcpp run --target-triple riscv64-none-elf # via [target.].runner +mcpp run --target riscv64-none-elf # via [target.].runner ``` **What changes on a freestanding target** diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 4e36a7cd..89407276 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -804,7 +804,7 @@ cxxflags = ["-march=x86-64-v2"] ```bash mcpp build --target riscv64-none-elf -mcpp run --target-triple riscv64-none-elf # 经 [target.].runner +mcpp run --target riscv64-none-elf # 经 [target.].runner ``` **freestanding target 上有什么不同** diff --git a/src/cli.cppm b/src/cli.cppm index d1d132dc..bd4431c6 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -316,29 +316,29 @@ int run(int argc, char** argv) { .action(wrap_rc(cmd_build))) .subcommand(cl::App("run") .description("Build + run a binary target (after `--`, args are passed to it)") - // NB: this positional is a BINARY NAME from [[bin]]/src layout — - // unrelated to `--target ` (the cross-target axis). - .arg(cl::Arg("target").help("Binary name (optional)")) - // ⚠️ Same word, two axes — and they were ALREADY named this way: - // the positional above is a binary name, this option is the cross - // target triple, exactly as in `mcpp build --target`. Spelling the - // option differently here would make the one command that needs - // both the one command where the flag is not called --target. - // ⚠️ NOT `--target`, and this is not a style choice. + // ⚠️ Named `bin`, NOT `target`, and the rename is load-bearing. // - // The POSITIONAL above is already called target — it is the binary - // NAME — and the parser keys both by that word, so declaring the - // option makes `mcpp run ` set the cross-target instead: + // This positional is a BINARY NAME from [[bin]]/src layout. It was + // called `target` — the same word as the cross-target axis — and + // ParsedArgs::value() falls back from an unset option to a + // positional OF THE SAME NAME, so adding `--target` here made + // every ordinary invocation read the binary name as a triple: // // $ mcpp run q // error: unknown target 'q' // - // Measured, after trying exactly that for the sake of matching - // `mcpp build --target`. `run` is the one subcommand that cannot - // spell it that way, and the existing comment on the positional - // said so before this was attempted. - .option(cl::Option("target-triple").takes_value().value_name("TRIPLE") + // The name is never read back (cmd_run takes positional(0) by + // index); it only labels this slot and shows up in --help, where + // `bin` is the more accurate word anyway. So renaming it is what + // lets `run` spell the flag `--target` like every other + // subcommand, instead of being the one command that cannot. + .arg(cl::Arg("bin").help("Binary name (optional)")) + .option(cl::Option("target").takes_value().value_name("TRIPLE") .help("Cross target triple (same axis as `mcpp build --target`)")) + // Kept as an alias: it shipped in 2026.8.19.1 as the only spelling + // `run` accepted, and scripts written against it must keep working. + .option(cl::Option("target-triple").takes_value().value_name("TRIPLE") + .help("Alias for --target")) .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") .help("Run only the named workspace member (single-member; no --workspace fan-out)")) .option(cl::Option("cache").takes_value().value_name("MODE") diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index c0c847c0..13b53177 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -166,10 +166,10 @@ export int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed, bool no_cache = parsed.is_flag_set("no-cache"); if (auto c = parsed.value("cache")) cache_mode = *c; else if (no_cache) cache_mode = "off"; - // ⚠️ `--target-triple`, never `--target`: this subcommand's POSITIONAL is - // called target and means the binary NAME, and the parser keys both by - // that word. See the option's declaration in cli.cppm. + // Both spellings; see cli.cppm for why the positional had to be renamed + // before `--target` could exist here at all. std::string target_triple; + if (auto tt = parsed.value("target")) target_triple = *tt; if (auto tt = parsed.value("target-triple")) target_triple = *tt; return mcpp::build::build_run_target(targetName, passthrough, package_filter, cache_mode, no_cache, target_triple); diff --git a/tests/e2e/130_freestanding_riscv_build_and_run.sh b/tests/e2e/130_freestanding_riscv_build_and_run.sh index 65d7142e..b04012f3 100755 --- a/tests/e2e/130_freestanding_riscv_build_and_run.sh +++ b/tests/e2e/130_freestanding_riscv_build_and_run.sh @@ -159,10 +159,26 @@ readelf -h "$img" | grep -q '0x80200000' || { readelf -h "$img" | grep -i entry; exit 1; } # ── run ───────────────────────────────────────────────────────────────────── -"$MCPP" run --target-triple riscv64-none-elf > run.log 2>&1 || true +# ⚠️ BOTH spellings, and the pairing is the test. +# +# `run` also takes a POSITIONAL binary name, and the arg parser falls back +# from an unset option to a positional of the same name. While that positional +# was itself called `target`, adding `--target` here silently turned +# `mcpp run ` into `mcpp run --target=`: +# +# $ mcpp run q +# error: unknown target 'q' +# +# `--target-triple` shipped first and stays an alias, so both are pinned; the +# positional case is pinned by 73_issue131_per_target_cxxflag.sh. +"$MCPP" run --target riscv64-none-elf > run.log 2>&1 || true grep -q 'MCPP-FREESTANDING-OK' run.log || { cat run.log; echo "the firmware did not run (or produced no output)"; exit 1; } +"$MCPP" run --target-triple riscv64-none-elf > alias.log 2>&1 || true +grep -q 'MCPP-FREESTANDING-OK' alias.log || { + cat alias.log; echo "--target-triple (the 2026.8.19.1 spelling) stopped working"; exit 1; } + # ── the runner is REQUIRED, and its absence must say so ───────────────────── # Two-sided: without this, "run worked" could equally mean mcpp exec'd the # image directly and something else printed the line. From dd9b880fcb5cd4637cf34aa1750633a999c78033 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:35:25 +0800 Subject: [PATCH 5/9] docs(plan): record R2/R5 implementation findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R5's builtin template was the wrong shape — mcpp's builtin registry is frozen at `bin` by design and templates ship with the package that provides them, so the template lives in riscv-virt-rt and mcpp needs no change at all. R2 hit a real gap: a dependency's `[xlings] deps` is materialized for the ROOT project only, so a consumer of the board package would get neither libc nor emulator. The fix is xim's own install-time dependency edge in the descriptor, verified by removing picolibc from the store and watching `mcpp add` put it back — not by observing an already-provisioned machine. --- ...2026-08-19-baremetal-phase3-usable-plan.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md index 0598d2f8..e080f601 100644 --- a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md +++ b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md @@ -361,3 +361,55 @@ error: test result: FAILED. 2 passed; 1 failed ⇒ **把位置参数改名 `bin`**(在 `--help` 里本来就更准),`--target` 就自由了。 `--target-triple` 作为 2026.8.19.1 已发布的拼写保留为别名。 **一致性是判据之一,它排在「不能坏」后面 —— 但排在后面不等于要放弃。** + +### 11.2 R2/R5 的实施结论:两条计划主张又被推翻 + +#### ⭐ R5:`mcpp new --template baremetal-riscv` 不该由 mcpp 提供 + +§6 写的是给 mcpp 加一个内建模板。**读代码发现这条路是被刻意关掉的**: + +```cpp +// src/cli/cmd_new.cppm +// builtin registry (frozen: bin; gui = transitional alias), else a +// package template: [ns.]pkg | [ns.]pkg:tmpl | [ns.]pkg@ver:tmpl. +``` + +⇒ **模板随提供它的包走**(封闭语法 / 开放词汇),而且 `mcpp new` 会把自依赖按 +**它解析到的版本**写进生成的 manifest —— 模板因此**不可能与库脱节**。 + +落地形态因此变成:`riscv-virt-rt` 里加 `templates/blinky/`,用户敲 + +```bash +mcpp new blinky --template riscv-virt-rt +``` + +**mcpp 侧零改动**。这比原计划好在:模板与板级包同一版本、同一仓库、同一次发布。 + +#### ⚠️ R2:包的 `[xlings] deps` **不会**到达消费者 —— 少了这条边包是废的 + +`mcpp` 只为**根工程**(或 workspace)物化 `[xlings]`(`prepare.cppm` 的 +`runtimeOwnerManifest`)。BSP 自己声明的 `xim:picolibc-riscv` / `xim:qemu-riscv` +在被当依赖用时**一个都不装** —— 用户 `mcpp add` 之后拿到的是一个没有 libc 也没有 +模拟器的板级包,直到 `build.mcpp` 才报出来。 + +解法**不在 mcpp 里**:xim 的描述符本来就有安装期依赖边,写在 `xpm.<平台>.deps` +(`libpng.lua` 早就这么用)。⇒ 描述符里加 + +```lua +deps = { "xim:picolibc-riscv@1.8.12", "xim:qemu-riscv@9.2.4-1" }, +``` + +**实测判据(必须是「拿走再装回来」,不能只看装好的机器)**:把 store 里的 +`xim-x-picolibc-riscv` 改名藏起来 → `mcpp add` + `mcpp build` **把它装了回来并链接成功**; +不加这条边则停在 `build.mcpp` 说「declared in [xlings].deps but is not installed」。 + +#### ⚠️ Form A vs Form B:选错的话包会「解析成功、编译成功、链接到空气」 + +`riscv-virt-rt` 带 `build.mcpp`,而 mcpp 在**包根**找它。Form B(内联 `mcpp = {...}`) +把包根留在解包目录,tarball 的 `riscv-virt-rt-/` 包裹层由每条 glob 的 `*/` 吸收 +⇒ **`build.mcpp` 落在下一层,找不到**。Form A(`mcpp = "*/mcpp.toml"`)把包根移进包裹层。 +⇒ **凡是带 `build.mcpp` 的包必须用 Form A。** + +#### 兼容性:类型化 API 没有语言内的特性探测(见 §3.2 的补记) + +包侧无解 ⇒ 补在引擎侧的编译失败诊断上,对**以后每一次**类型化 API 新增都生效。 From d93d228e0fd31d6c711774afcdf121d78ff618fa Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:37:19 +0800 Subject: [PATCH 6/9] =?UTF-8?q?chore(release):=202026.8.19.2=20=E2=80=94?= =?UTF-8?q?=20the=20runner=20travels=20with=20the=20board?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpp run` and `mcpp test` on a bare-metal target now take their runner from an ordinary dependency, so a firmware project's manifest names no emulator, no linker script, no libc and no load address — it has no [target.*] section at all. Builds emit .bin and .map beside the ELF and print a text/data/bss summary, because capacity is the constraint that decides whether an image ships. New surface: * `mcpp:runner=` / `mcpp::runner(token)` — a package says how to execute * `mcpp run --target` — the spelling every other subcommand uses * a missing `mcpp::` API in build.mcpp now says the engine may be too old The ecosystem half ships alongside: mcpplibs/riscv-virt-rt supplies the board (and, from 0.2.0, `mcpp new --template riscv-virt-rt`). --- mcpp.toml | 2 +- src/version.cppm | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mcpp.toml b/mcpp.toml index f9095618..3cde1f91 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.19.1" +version = "2026.8.19.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/version.cppm b/src/version.cppm index cbdd92fb..798bdb34 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.19.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.19.2"; } // namespace mcpp From c6f332d89ebeb2ca2208862b729f23a1193400b6 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:38:48 +0800 Subject: [PATCH 7/9] docs: a freestanding target does have `main` when a board package supplies crt0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/05 §2.7.2 told the reader there is no `main` on a bare-metal target and to point the target at a file carrying `_start`. That is only true for a board with no C library at all — with picolibc's semihosting crt0, which is what a board-support package ships, the entry point is an ordinary `int main()` whose return value reaches the host. Also leads the section with the short path (`mcpp new blinky --template riscv-virt-rt`), since the hand-written form below it is what to reach for when writing a package for a board that has none — not what to start with. --- docs/05-mcpp-toml.md | 18 +++++++++++++++++- docs/zh/05-mcpp-toml.md | 16 +++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index ee4bd164..d83ef145 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -909,6 +909,22 @@ mcpp build --target riscv64-none-elf mcpp run --target riscv64-none-elf # via [target.].runner ``` +**Starting from a board package** + +Almost nothing below has to be written by hand. A board-support package carries +the C library, the startup code, the memory layout and the emulator, so the +shortest path to a booting image is: + +```bash +mcpp new blinky --template riscv-virt-rt +cd blinky && mcpp run +``` + +The generated manifest names no linker script, load address, libc or emulator — +it has no `[target.*]` section at all. The rest of this section describes what +such a package supplies, which is what to reach for when writing one for a board +that has none. + **What changes on a freestanding target** | | | @@ -916,7 +932,7 @@ mcpp run --target riscv64-none-elf # via [target.].runner | Link line | `-nostdlib -nostartfiles -static`, and nothing hosted — no crt files, no dynamic linker, no C++ runtime. The linker is addressed by **absolute path** (`-fuse-ld=/bin/ld.lld`), because `-fuse-ld=lld` resolves through `PATH` and finds GNU ld on any machine with binutils earlier on it. | | ISA flags | `-march` / `-mabi` / `-mcmodel` come from the target table, so `--target ` alone is enough to produce a correct object file. | | `import std` | **Unavailable.** `std` is one module over the entire library — threads, filesystem and iostreams included — so there is no subset of it to build without an OS. The freestanding subset package replaces it, and mcpp's diagnostic names it. | -| Entry point | There is no `main`. Declare the target explicitly and point `main` at the file carrying `_start`. | +| Entry point | `int main()` works **as long as something supplies a `crt0`** — a board package normally does, and then a firmware's entry point is an ordinary `main` whose return value reaches the host through semihosting. Only a zero-libc board needs an explicit target whose `main` points at the file carrying `_start`. | **A minimal firmware** diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 89407276..fcac8427 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -807,6 +807,20 @@ mcpp build --target riscv64-none-elf mcpp run --target riscv64-none-elf # 经 [target.].runner ``` +**从板级支持包起步** + +下面这些几乎都不需要手写。板级支持包(BSP)自带 C 库、启动代码、内存布局和模拟器, +所以跑起一个镜像的最短路径是: + +```bash +mcpp new blinky --template riscv-virt-rt +cd blinky && mcpp run +``` + +生成的 manifest 里没有链接脚本、没有加载地址、没有 libc、没有模拟器 —— 连 +`[target.*]` 段都没有。本节余下的内容讲的是**这样一个包提供了什么**,也就是要给 +一块还没有 BSP 的板子写一个时该照着做什么。 + **freestanding target 上有什么不同** | | | @@ -814,7 +828,7 @@ mcpp run --target riscv64-none-elf # 经 [target.].runner | 链接线 | `-nostdlib -nostartfiles -static`,且不带任何 hosted 的东西 —— 没有 crt 文件、没有动态链接器、没有 C++ 运行时。链接器用**绝对路径**寻址(`-fuse-ld=<载荷>/bin/ld.lld`),因为 `-fuse-ld=lld` 走 `PATH` 解析,在任何 binutils 排前面的机器上都会找到 GNU ld。 | | ISA flag | `-march` / `-mabi` / `-mcmodel` 来自 target 表,所以只写 `--target ` 就足以产出正确的目标文件。 | | `import std` | **不可用。** `std` 是覆盖整个库的一个模块 —— 线程、文件系统、iostreams 全在内 —— 没有 OS 就没有它的子集可编。freestanding 子集包取代它,mcpp 的诊断会点名。 | -| 入口点 | 没有 `main`。显式声明 target,并把 `main` 指向携带 `_start` 的那个文件。 | +| 入口点 | **只要有人提供 `crt0`,`int main()` 就能用** —— 板级支持包通常就提供它,于是固件的入口就是普通的 `main`,它的返回值经 semihosting 传回宿主。**只有零 libc 的板子**才需要显式声明 target 并把 `main` 指向携带 `_start` 的那个文件。 | **一个最小固件** From 944889762e38a40a61255cb037a7048ab6e700d1 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:47:32 +0800 Subject: [PATCH 8/9] fix(diag): the freestanding `import std` message pointed at a package that does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It ended with a copy-pasteable [dependencies] mcpplibs.std.freestanding = "0.1" and no such package is published. Following the advice fails at the very next command with "package not found", which sends the reader off to debug their index instead of their code. Now it points at what a bare-metal project actually has — the module its board package exports, named concretely — and describes a freestanding subset package as a shape rather than a line to paste, saying plainly that none is published yet. The concrete line comes back when one ships. Every suggestion in a diagnostic is a promise; this one had not been checked against reality before being written. --- ...2026-08-19-baremetal-phase3-usable-plan.md | 24 ++++++++++++++ docs/05-mcpp-toml.md | 2 +- docs/zh/05-mcpp-toml.md | 2 +- src/build/prepare.cppm | 31 ++++++++++++++----- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md index e080f601..593145bf 100644 --- a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md +++ b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md @@ -413,3 +413,27 @@ deps = { "xim:picolibc-riscv@1.8.12", "xim:qemu-riscv@9.2.4-1" }, #### 兼容性:类型化 API 没有语言内的特性探测(见 §3.2 的补记) 包侧无解 ⇒ 补在引擎侧的编译失败诊断上,对**以后每一次**类型化 API 新增都生效。 + +### 11.3 ⚠️ 我在第二阶段留下了一条**指向不存在的包**的诊断 + +W6 的 `import std` 诊断里写着可以直接粘贴的: + +```toml +[dependencies] +mcpplibs.std.freestanding = "0.1" +``` + +**这个包没有发布,而且本阶段也不该顺手发**(E-STD-1/E-STD-2 各自是 M 规模: +`-nostdinc++` 之下 libc++ 的头一个都用不了,子集要自己实现;而且往 `namespace std` +里加声明本身就是另一个问题)。 + +⇒ 用户照着诊断粘完,下一条命令报「package not found」。**一条修不好问题的诊断, +比一条只解释不给命令的诊断更糟** —— 它让读者接下来几分钟在怀疑自己的索引坏了。 + +已改成指向**今天确实存在**的东西(板级包导出的模块,点名 `mcpplibs.riscv_virt_rt`), +并把子集包描述成一个**形态**而不是一行可粘贴的依赖,末尾明说「还没有这样的包」。 +**E-STD 仍然开着;它发布时要把这条诊断改回具体的一行。** + +⚠️ 一般化:**诊断里的每一条建议都是一个承诺**,而承诺是要被兑现的。写「加这一行」 +之前必须先确认那一行今天能跑通 —— 这条和 [[issue427-absence-treated-as-contradiction]] +里那条错误建议是同一形状。 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index d83ef145..442508a9 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -931,7 +931,7 @@ that has none. |---|---| | Link line | `-nostdlib -nostartfiles -static`, and nothing hosted — no crt files, no dynamic linker, no C++ runtime. The linker is addressed by **absolute path** (`-fuse-ld=/bin/ld.lld`), because `-fuse-ld=lld` resolves through `PATH` and finds GNU ld on any machine with binutils earlier on it. | | ISA flags | `-march` / `-mabi` / `-mcmodel` come from the target table, so `--target ` alone is enough to produce a correct object file. | -| `import std` | **Unavailable.** `std` is one module over the entire library — threads, filesystem and iostreams included — so there is no subset of it to build without an OS. The freestanding subset package replaces it, and mcpp's diagnostic names it. | +| `import std` | **Unavailable.** `std` is one module over the entire library — threads, filesystem and iostreams included — so there is no subset of it to build without an OS. What a firmware imports instead is the module its **board package** exports, which is where the target's C library is already wrapped. | | Entry point | `int main()` works **as long as something supplies a `crt0`** — a board package normally does, and then a firmware's entry point is an ordinary `main` whose return value reaches the host through semihosting. Only a zero-libc board needs an explicit target whose `main` points at the file carrying `_start`. | **A minimal firmware** diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index fcac8427..1109dcf2 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -827,7 +827,7 @@ cd blinky && mcpp run |---|---| | 链接线 | `-nostdlib -nostartfiles -static`,且不带任何 hosted 的东西 —— 没有 crt 文件、没有动态链接器、没有 C++ 运行时。链接器用**绝对路径**寻址(`-fuse-ld=<载荷>/bin/ld.lld`),因为 `-fuse-ld=lld` 走 `PATH` 解析,在任何 binutils 排前面的机器上都会找到 GNU ld。 | | ISA flag | `-march` / `-mabi` / `-mcmodel` 来自 target 表,所以只写 `--target ` 就足以产出正确的目标文件。 | -| `import std` | **不可用。** `std` 是覆盖整个库的一个模块 —— 线程、文件系统、iostreams 全在内 —— 没有 OS 就没有它的子集可编。freestanding 子集包取代它,mcpp 的诊断会点名。 | +| `import std` | **不可用。** `std` 是覆盖整个库的一个模块 —— 线程、文件系统、iostreams 全在内 —— 没有 OS 就没有它的子集可编。固件真正 import 的是**板级包导出的模块**,目标的 C 库已经在那里包好了。 | | 入口点 | **只要有人提供 `crt0`,`int main()` 就能用** —— 板级支持包通常就提供它,于是固件的入口就是普通的 `main`,它的返回值经 semihosting 传回宿主。**只有零 libc 的板子**才需要显式声明 target 并把 `main` 指向携带 `_start` 的那个文件。 | **一个最小固件** diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 69fb1555..84fd71e2 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -5273,9 +5273,18 @@ prepare_build(bool print_fingerprint, // no subset of it to build without an OS. Saying "provides no std // module source" sends the reader to look for a broken payload. // - // Naming the replacement is the whole value of the diagnostic: the - // freestanding subset is an ordinary package, so the fix is one line in - // the manifest rather than a toolchain investigation. + // ⚠️ It used to end with a copy-pasteable + // + // [dependencies] + // mcpplibs.std.freestanding = "0.1" + // + // and that package is NOT published. A diagnostic whose suggested fix + // fails at the next command is worse than one that explains and stops: + // the reader spends the next minutes deciding whether their index is + // broken. Point at what a bare-metal project actually has today — the + // board package it already depends on exports a module — and describe + // the subset package as a shape rather than as a line to paste. + // Restore the concrete line when such a package ships. if (auto ft = mcpp::toolchain::triple::parse(tc->targetTriple); ft && ft->is_freestanding()) { @@ -5286,13 +5295,19 @@ prepare_build(bool print_fingerprint, "filesystem, iostreams\n" " included), so there is no subset of it to build without " "an OS underneath.\n" - " Use the freestanding subset instead:\n" "\n" - " [dependencies]\n" - " mcpplibs.std.freestanding = \"0.1\"\n" + " What a bare-metal project uses instead:\n" + " * the module its BOARD package exports — that is where " + "the target's\n" + " C library is already wrapped (riscv-virt-rt exports " + "`mcpplibs.riscv_virt_rt`);\n" + " * or a freestanding subset package, which is an " + "ordinary dependency\n" + " providing the header-only parts of the library that " + "need no OS.\n" "\n" - " then `import mcpplibs.std.freestanding;` in place of " - "`import std;`.", + " No such subset package is published yet, so there is no " + "line to paste here.", tc->targetTriple)); } return std::unexpected(std::format( From b6acc79a986ea60f12745197272f9809e8af03f7 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:52:52 +0800 Subject: [PATCH 9/9] fix(ninja): declare the link map as an implicit output, not a side effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-Wl,-Map=` is a flag on the link command, so the map cannot be its own edge — a second edge claiming to produce it would run the link twice. It was therefore written but never declared, which means nothing tracked it: delete `firmware.map` and ninja considers the ELF up to date and puts nothing back. Declaring it as an implicit output of the link edge fixes that and makes `ninja -t clean` remove it with everything else. e2e/132 now deletes the map and asserts it returns, which is the only form of this test that can fail before the fix. --- src/build/ninja_backend.cppm | 17 ++++++++++++++--- .../e2e/132_freestanding_test_and_artifacts.sh | 11 +++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index a77facec..768e9b92 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -1873,6 +1873,18 @@ std::string emit_ninja_string(const BuildPlan& plan) { std::string implicitOut; if (!lu.importLibrary.empty()) implicitOut = " | " + escape_ninja_path(lu.importLibrary); + // The link map, DECLARED rather than merely written. It is produced by + // a flag on the link command, so it cannot be its own edge — a second + // edge claiming to produce it would run the link twice. Declaring it + // as an implicit output is what makes ninja regenerate a deleted map + // and clean it with everything else; without the declaration it exists + // only as a side effect that nothing tracks. + if (!fsObjcopy.empty() + && (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary)) { + auto map = lu.output; map += ".map"; + implicitOut += (implicitOut.empty() ? " | " : " ") + + escape_ninja_path(map); + } // The `.def` edge, emitted BEFORE the link that consumes it. // @@ -1961,9 +1973,8 @@ std::string emit_ninja_string(const BuildPlan& plan) { // ── Freestanding artifact set ────────────────────────────────────── // // `.bin` is a real edge on the `.elf`, so ninja rebuilds it when the - // image changes and never when it does not. `.map` is an implicit - // OUTPUT of the link: one command writes both, and a second edge - // claiming to produce the map would run the link twice. + // image changes and never when it does not. `.map` is declared as an + // implicit output of the link edge above — see the note there. if (!fsObjcopy.empty() && (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary)) { const auto elf = escape_ninja_path(lu.output); diff --git a/tests/e2e/132_freestanding_test_and_artifacts.sh b/tests/e2e/132_freestanding_test_and_artifacts.sh index 1e23c2ed..4d9b17af 100755 --- a/tests/e2e/132_freestanding_test_and_artifacts.sh +++ b/tests/e2e/132_freestanding_test_and_artifacts.sh @@ -171,4 +171,15 @@ after="$(sha256sum "$elf.bin" | cut -d' ' -f1)" echo ".bin did not change after a source edit — it is not a real ninja edge" exit 1; } +# ⚠️ The `.map` is written by a FLAG on the link command, not by its own edge, +# so it is easy to leave undeclared — and then nothing tracks it. Delete it and +# ninja must put it back; without the implicit-output declaration the ELF is +# up to date, ninja has nothing to do, and the map stays gone. +rm -f "$elf.map" +"$MCPP" build --target riscv64-none-elf > build3.log 2>&1 +[[ -f "$elf.map" ]] || { + cat build3.log + echo ".map did not come back after being deleted — it is not a declared output" + exit 1; } + echo "PASS: bare-metal mcpp test names its failure, and the artifact set is real"