Skip to content

feat(freestanding): a BSP supplies the runner, mcpp test runs on bare metal, and the artifact set is real - #457

Merged
Sunrisepeak merged 9 commits into
mainfrom
feat/freestanding-runner-from-bsp
Aug 19, 2026
Merged

feat(freestanding): a BSP supplies the runner, mcpp test runs on bare metal, and the artifact set is real#457
Sunrisepeak merged 9 commits into
mainfrom
feat/freestanding-runner-from-bsp

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

第三阶段四个缺口里的三个。消费者的 manifest 现在完全没有 [target.*]:

[dependencies]
board = { path = "../board" }
$ mcpp run  --target riscv64-none-elf     → 在 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 ⭐ runner 归属板级支持包

mcpp:runner=,一行一个 argv token、按发射顺序,带自己的 Slot::Runner自己的 Scope::RunGlobal。两个都是新值,且都不是细节:

  • 塞进 LdFlags 会把模拟器的 argv 放到链接器命令行上;
  • 复用 LinkGlobal 会让它继承链接 flag 的冲突规则 —— 两个依赖的链接 flag 拼接是对的,两个 runner 不能拼

它必须来自 build.mcpp 而不是 manifest 键,这是实测结论:模拟器必须用绝对路径(裸名会经 PATH 解析到一个按拥有它的 home 派发的 shim —— CI 报过 xlings: 'qemu-system-riscv64' is not installed,而同一 job 两步之前 --version 刚跑通),而那个路径含 home 与版本,只有构建程序算得出来

三侧已验:

情形 结果
BSP 供 消费者[target.*]也能 mcpp run
两个依赖都供 硬错误,同时点名两个包
消费者自己写 以它为准,并说明覆盖了哪个依赖

⚠️ R3:两条计划假设是错的,量完之后它几乎不用做

计划要 batch 模式 + 结构化 stdout 协议。两个前提都被实测否掉:

计划写的 实测
「qemu 冷启动 ~0.4s ⇒ 30 用例 isolated 要 12s」 12ms(5 次 63ms)⇒ 30 用例 0.36s。batch 的理由消失,连带「超时后重跑 isolated 定位」也不需要 —— isolated 本来就点名
「裸机没有退出码 ⇒ 结果要走新通道」 semihosting 把固件 main 的返回值传给 qemu 退出码(return 7 → 退出码 7)。「退出码即判据」在裸机上原样成立

mcpp test 只需要一件事:让测试二进制走 mcpp run 用的同一个 runner。两个调用方现在都读 choose_runner() —— 同一决策两处推导是本仓反复付过的形状。


R4:产物形态

.bin(llvm-objcopy)· .map(-Wl,-Map=size 摘要 —— 裸机的核心约束是容量,而 mcpp 在链接结束的那一刻就知道这个数。

⚠️ .bin 是以 ELF 为输入的独立 ninja 边,不是链接的第二输出:合进去会让两者共用一次 up-to-date 检查,增量构建会拿到陈旧镜像。判据按内容钉,不是 mtime。


顺带修掉一处我自己造成的不一致

mcpp run 先前用 --target-triple,而其余子命令用 --target。现在 --target 到处可用;--target-triple 作为 run 的无歧义别名保留 —— 它的位置参数恰好也叫 target,含义是二进制名。


测试

  • 单测 91/91(新增 7 条:runner directive 的 slot/scope/argv/落点;size 解析、map flag、objcopy 只对裸机解析)
  • 三条 e2e:130 引擎链路 · 131 生态链路(现已含 BSP 供 runner,消费者无 [target.*]132 裸机 test + 产物集(含故意失败的用例必须被点名且让退出码非零,以及改一行源码后 .bin 内容必须变)
  • baremetal CI job 跑这三条并断言各自的 PASS 行出现过 —— 三个脚本都可能 exit 0 而没跑

…re metal, and the artifact set is real

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.
…ady is

Adding `--target` to `mcpp run` for symmetry with every other subcommand broke
`mcpp run <binary>`:

    $ 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.
…oo old

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.
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.
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.
`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 <name> --template riscv-virt-rt`).
…plies crt0

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.
… that does not exist

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.
…fect

`-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.
@Sunrisepeak
Sunrisepeak merged commit b066b3b into main Aug 19, 2026
21 checks passed
@Sunrisepeak
Sunrisepeak deleted the feat/freestanding-runner-from-bsp branch August 19, 2026 02:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants