Skip to content

feat(freestanding): bare-metal targets, and a BSP that supplies the whole target world - #455

Merged
Sunrisepeak merged 9 commits into
mainfrom
feat/freestanding-baremetal-targets
Aug 18, 2026
Merged

feat(freestanding): bare-metal targets, and a BSP that supplies the whole target world#455
Sunrisepeak merged 9 commits into
mainfrom
feat/freestanding-baremetal-targets

Conversation

@Sunrisepeak

@Sunrisepeak Sunrisepeak commented Aug 18, 2026

Copy link
Copy Markdown
Member

裸机链路的两半:引擎能构建能跑;板级支持包(BSP)把目标世界的其余部分全部供上,消费者的 manifest 只写一条依赖。

所有 freestanding 专属的东西在新的 src/freestanding/ 模块目录(target / linkline / runner)。hosted 路径一行没动。


第一半:引擎能构建、能跑

$ mcpp build --target riscv64-none-elf     # entry 0x80200000 · 0 PT_INTERP · 0 未定义符号
$ mcpp run   --target-triple riscv64-none-elf
MCPP-FREESTANDING-OK

⚠️ 关掉的缺陷

只把三元组解析加上之后,失败形态比构建报错更糟:

$ mcpp build --target riscv64-none-elf
    Resolved llvm@22.1.8 → riscv64-none-elf → …/bin/clang++
    Finished dev [unoptimized + debuginfo] in 0.47s
$ ls target/
    x86_64-linux-gnu/          ← 宿主的 ELF,却报成了 riscv64

真因,且不能从已能用的交叉 target 推广:此前每个交叉 target 都用独立的编译器二进制(x86_64-w64-mingw32-g++),它自己的 -dumpmachine 就是交叉三元组。而 clang 一个二进制服务所有 target,-dumpmachine 永远回答宿主。现在 freestanding 的 tc.targetTriple请求决定 —— 产物目录、指纹、缓存键、flag 层读的都是这一个字段

部件 在解决什么
none 的歧义 既是 vendor 段也是 OS 段:riscv64-none-elf 裸机、x86_64-none-linux-gnu hosted。预扫描判定,两侧都钉了测试 —— 判反是静默的。
链接线整条替换 每条 hosted 决策在这里都是错的(crt、动态链接器、C++ 运行时、loader 路径)。追加 -nostdlib 会让结果取决于驱动的 flag 顺序。
⚠️ --no-default-config 带进去 载荷的 clang++.cfg 无条件注入 -Wl,--dynamic-linker=…/ld-linux-x86-64.so.2。丢掉它 ⇒ RISC-V 镜像烙进 x86-64 PT_INTERP,链接干净、报告成功。本次改动过程中实测到。
⚠️ 链接器用绝对路径 -fuse-ld=lld 走 PATH,binutils 排前面就找到 GNU ld,死于 unrecognised emulation mode: elf64lriscv。编 picolibc 时复现过。
C++ 运行时表短路 它找到的归档是宿主的,把 x86-64 libc++.a 放上了 riscv64 链接线。
import std 关断 std 是覆盖整库的一个模块,没有 OS 就没有子集。留着报 '__config_site' file not found,读起来像载荷坏了。诊断现在点名替代包和那行 manifest
runner 无默认值 用哪个模拟器/机器型号/固件模式是板级事实(-bios default vs -bios none -semihosting),引擎猜一个,另一块板就得跟它打架。

第二半:BSP 供上整个目标世界

[dependencies]
board = { path = "../board" }
import board;
extern "C" int main() { board::printf_f("float %.4f\n", 3.14159); … }
$ mcpp run --target-triple riscv64-none-elf
BSP-CHAIN-OK 42
float 3.1416
MALLOC-OK

⭐ 这份工程里没有 picolibc、compiler-rt、crt0、链接脚本、加载地址、-nostdlib-mcmodel

⚠️ 接缝(探针 Z1 实测)

link-search / link-lib / link-script   LinkGlobal      → 到达消费者
include-dir / cflag / cfg              PackagePrivate  → 不到达

这个不对称是刻意的(构建期程序不得静默拓宽包的公开编译接口),也正是 BSP 私有 include 目标 libc 头、对外 export 一个 C++ 模块的原因。tests/e2e/131 两侧都钉:模块化消费者跑通,而试图 #include <stdio.h> 的消费者必须构建失败

三个新部件

mcpp:link-script= directive 表里一行,Scope::LinkGlobal。别的通道要么包私有(cxxflag),要么表达不了这个 flag(link-lib-llink-search-L)。⚠️不认领 declared-output:那个契约假设"值就是路径",而这里的值是 -T <路径>,检查会拒绝一个明明在那儿的脚本;lld 自己的报错本来就精确。
mcpp::xpkg_dir(ns, name) "我在 [xlings] deps 里声明的包落在哪"的接口dep_dir 只答 mcpp 依赖。没有它,BSP 就得把 <home>/data/xpkgs/<ns>-x-<name>/<version> 写进代码 —— 那是 mcpp 可以随时改的 store 内部结构。解析放在 xpkgs_base 旁边(xlings 模块本来就拥有这套布局),避免同一决策两处推导。⚠️ 带版本固定只解析到那个版本,否则什么都不返回。
⚠️ freestanding 下跳过宿主 include 重建 那块发的是手工重建的宿主世界(libc++ 头、glibc 头、Linux UAPI 头),因为 cfg 被 bypass 了。裸机上它们不只是没用:picolibc 自己的 <stdio.h>#include <stddef.h>,落到 libc++ 那份,打开一个为宿主生成、这里根本不存在的 __config_site。报错点名 __config_site,读起来像载荷坏了。-nostdinc++ 同理进了 freestanding 编译前缀。

测试

  • 单测 +18:三个 freestanding 模块 11 条 · triple 的 none 歧义两侧 6 条 · xpkg 接口 4 条(每种 manifest 写法 / 固定版本要么精确要么没有 / 版本按数字段比较,因为字符串序把 0.4.11 排在 0.4.9 前 / 通道两侧共用一个 sanitizer)· link-script 3 条
  • tests/e2e/130:引擎链路 —— 模块 + 汇编 → UCB RISC-V 镜像、无 PT_INTERP、零未定义符号、入口 0x80200000 → 启动并断言模块输出。runner 两侧钉。
  • tests/e2e/131:生态链路 —— BSP 供 sysroot + 链接脚本 + 运行时,消费者只声明依赖;并反向验证 include-dir 不泄漏到消费者。
  • e2e harness 新增 # requires-hard:(能力缺失 FAIL 而非 SKIP)。

⚠️ 两条裸机测试刻意不用 requires-hard:qemu-riscv 在 macOS/Windows runner 上本来就没有,硬 token 会让那些 job 结构性首红。真正要防的事由 ci-linux-e2e.yml 新增的 baremetal job 守:装 qemu + sysroot(装进 MCPP 用的那个 home,否则 131 会 SKIP)→ 跑这两条 → 断言两条 PASS 行都出现了run_all.sh 跳过时退出码是 0,回答不了这个问题。

本地结果

mcpp test                       91 passed; 0 failed
tests/e2e/130                   PASS: freestanding riscv64 build + run
tests/e2e/131                   PASS: BSP supplies the sysroot, linker script and runtime
check_docs_style.sh             OK

依赖

xim:qemu-riscv@9.2.4-1xim:picolibc-riscv@1.8.12,均已进 xlings 生态(openxlings/xim-pkgindex#651、#653)。方案与计划在 .agents/docs/ 下。

`mcpp build --target riscv64-none-elf` now produces a RISC-V firmware image
from a C++20 module interface unit, and `mcpp run --target-triple` boots it in
an emulator. Two targets are registered: riscv64-none-elf and riscv32-none-elf.

Everything freestanding-specific lives in a new src/freestanding/ module
directory (target / linkline / runner), so the ISA table, the link line and the
runner each have one home and one read point. The hosted paths are untouched:
a target that is not freestanding takes exactly the code it took before.

⚠️ THE DEFECT THIS CLOSES

Before this, `--target riscv64-none-elf` did not parse, and the documented
escape hatch left the build on the host target. With the triple parsing added
but nothing else, the failure was worse than a build error:

    $ mcpp build --target riscv64-none-elf
        Resolved llvm@22.1.8 → riscv64-none-elf → …/bin/clang++
        Finished dev [unoptimized + debuginfo] in 0.47s
    $ ls target/
        x86_64-linux-gnu/          ← an ELF for the host, reported as riscv64

Root cause, and it does not generalise from the working cross targets: every
cross target that worked before uses a DISTINCT compiler binary
(`x86_64-w64-mingw32-g++`), whose own `-dumpmachine` reports the cross triple.
Clang is ONE binary that emits every target it was built with, so
`-dumpmachine` always answers with the host and nothing downstream ever learns
otherwise. `tc.targetTriple` is now set from the request for a freestanding
target — the output directory, the fingerprint, the cache key and the flag
layer all read that one field, so correcting it corrects all of them.

WHAT EACH PIECE IS FOR

* `none` is both a vendor segment and an OS segment, and which one it is
  depends on the rest of the triple: `riscv64-none-elf` is bare metal,
  `x86_64-none-linux-gnu` is hosted. Decided by a pre-scan, and pinned from
  both sides in the tests, because getting it backwards is silent.
* The link line is REPLACED, not extended. Every hosted decision is actively
  wrong here — crt files, a dynamic linker, the C++ runtime, loader search
  paths — and appending `-nostdlib` to a line that carries them leaves the
  outcome depending on the driver's flag ordering.
* ⚠️ `--no-default-config` is carried into that replacement, and it is not
  hygiene. The llvm payload's clang++.cfg injects an unconditional
  `-Wl,--dynamic-linker=…/ld-linux-x86-64.so.2`. Dropping the bypass produced
  a RISC-V image with an x86-64 PT_INTERP baked in, which links clean and
  reports success. Measured on this very change, before the line existed.
* ⚠️ The linker is addressed by ABSOLUTE PATH. `-fuse-ld=lld` resolves through
  PATH and finds GNU ld on any machine with binutils earlier on it, which then
  dies with `unrecognised emulation mode: elf64lriscv` — reproduced on this
  toolchain while building picolibc.
* The C++ runtime table short-circuits: its archives are the HOST's, and one
  of its ELF cells put x86-64 libc++.a on a riscv64 link.
* `import std` is turned off, because `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. Left on, the failure was `'__config_site' file
  not found`, which reads as a broken payload and says nothing about the
  target. The diagnostic now names the replacement package and the manifest
  line to add.
* `[target.<triple>].runner` is an argv template and there is deliberately no
  default. Which emulator, which machine model and which firmware mode are
  BOARD facts — `-bios default` for an OpenSBI boot, `-bios none -semihosting`
  for a picolibc image — and an engine that guesses one is an engine the other
  board has to fight.

TESTS

* 11 unit tests over the three new modules; 6 more on the triple, both sides
  of the `none` disambiguation.
* tests/e2e/130: builds a firmware from a module + assembly and asserts it is
  a UCB RISC-V image with no PT_INTERP, no undefined symbols and entry
  0x80200000, then boots it and asserts the module's own output. Two-sided on
  the runner: deleting `[target.…].runner` must fail and must name the key.
* `# requires-hard:` added to the e2e harness (missing capability FAILS rather
  than SKIPs). ⚠️ Test 130 deliberately does NOT use it — qemu-riscv is
  legitimately absent on the macOS and Windows runners, so a hard token would
  make those jobs structurally red. The guard that matters lives in
  ci-linux-e2e.yml's new `baremetal` job, which installs qemu and then asserts
  the test's PASS line actually appeared. run_all.sh exits 0 on a skip, so its
  exit code cannot answer that question.

91/91 unit tests pass. Design and plans in .agents/docs/.
The reference docs carry a bilingual-parity check and a style check; the
first pass added the English section only and used "if you try" in a
reference table. Both are what .github/tools/check_docs_style.sh exists to
catch — run it before pushing, not after.
Second half of the bare-metal chain: the engine could build and boot an image,
but a project still had to write its own linker script and could call no libc.
Now a board-support package supplies all of it and the consumer's manifest says
only "depend on it" — measured end to end:

    [dependencies]
    board = { path = "../board" }

    import board;
    extern "C" int main() { board::printf_f("float %.4f\n", 3.14159); … }

    $ mcpp run --target-triple riscv64-none-elf
    BSP-CHAIN-OK 42
    float 3.1416
    MALLOC-OK

Nothing in that project names picolibc, compiler-rt, crt0, a linker script, a
load address, -nostdlib or -mcmodel.

THREE PIECES, AND WHY EACH IS SHAPED THIS WAY

* `mcpp:link-script=` — one row in the directive table, Scope::LinkGlobal.
  Everything else that could carry a linker script is package-private
  (`cxxflag`) or cannot express the flag (`link-lib` emits `-l`, `link-search`
  emits `-L`), so before this a BSP could supply the C library and the startup
  code and still not supply the layout — leaving the one thing a consumer
  cannot write for itself as the one thing it had to. ⚠️ It does NOT claim a
  declared output: that contract assumes the value IS a path, and this one's
  transformed value is `-T <path>`, so the check would reject a script that is
  right there. lld's own error is already exact.

* `mcpp::xpkg_dir(ns, name)` — an INTERFACE for "where did the package I
  declared in `[xlings] deps` land". `dep_dir` answers for mcpp dependencies
  and cannot answer for xlings ones. Without it a BSP would encode
  `<home>/data/xpkgs/<ns>-x-<name>/<version>`, which is store internals mcpp is
  free to change — the same reason `dep_dir` exists rather than a documented
  path. Resolution lives beside `xpkgs_base` in the xlings module, which
  already owns that layout; a second place deriving it is the shape this
  codebase has paid for repeatedly. ⚠️ A pinned ref resolves to exactly that
  version or to nothing: asking for 1.8.12 and silently getting 1.9.0 is an
  answer only discovered later, in the artifact.

* ⚠️ The hosted include reconstruction is SKIPPED for a freestanding target,
  not filtered. What that block emits is the host's world rebuilt by hand
  (libc++ headers, glibc headers, Linux UAPI headers) because the cfg that
  normally supplies them is bypassed. On a bare-metal target they do not merely
  go unused: picolibc's own <stdio.h> includes <stddef.h>, which then resolves
  to libc++'s copy, which opens a `__config_site` generated for the host and
  absent here. The error names __config_site, so it reads as a broken payload
  rather than as the wrong include path. `-nostdinc++` is now part of the
  freestanding compile prefix for the same reason.

THE SEAM, MEASURED (probe Z1, 2026-08-19)

    link-search / link-lib / link-script   LinkGlobal      → reach the consumer
    include-dir / cflag / cfg              PackagePrivate  → do not

That asymmetry is deliberate — a build-time program must not silently widen a
package's public compile interface — and it is WHY a BSP includes the target's
libc headers privately and exports a C++ module instead. tests/e2e/131 pins
both sides: the module-based consumer runs, and a consumer that tries to
`#include <stdio.h>` must fail to build.

TESTS

* 4 more unit tests on the xpkg interface (every spelling a manifest may
  write; pinned-or-nothing; numeric version ordering, because a string sort
  puts 0.4.11 before 0.4.9; one sanitizer shared by both sides of the channel).
* 3 on `link-script` (the `-T` transform and its absolute path; LinkGlobal vs
  include-dir's PackagePrivate; no declared-output claim).
* tests/e2e/131 — the whole ecosystem chain, two-sided.
* The `baremetal` CI job installs the sysroot into the home MCPP uses and
  asserts BOTH tests' PASS lines appeared. Installed into the ambient xlings
  home instead, 131 would SKIP and the seam would go unexercised.

91/91 unit tests pass; both e2e pass locally.
@Sunrisepeak Sunrisepeak changed the title feat(freestanding): bare-metal targets — build and run riscv64-none-elf feat(freestanding): bare-metal targets, and a BSP that supplies the whole target world Aug 18, 2026
Phase 0's probes were the point of the plan, and two of them overturned
design decisions it had already made:

* the compile/link asymmetry (include-dir is PackagePrivate, link-* is
  LinkGlobal) removes the 'does the engine need a sysroot concept' question
  entirely — target headers reach a consumer as a MODULE;
* W8 collapses from an ordered two-slot provision to one directive row,
  because `-lcrt0-semihost` pulls the startup code out of an archive and
  the linker script already orders the sections;
* and a gap the plan never named: build.mcpp could not locate an
  `[xlings] deps` payload at all.

Also records a self-correction: `requires-hard` was the wrong tool for the
two bare-metal e2e, and why the guard belongs in the job instead.
Adding `link-script` in protocol 3 proved the old wording wrong. It said:

    The program announced protocol 2, which this mcpp also speaks, so an
    unrecognized directive is a typo rather than newer syntax.

The premise does not hold. A build.mcpp's protocol number is substituted at
COMPILE time by whichever mcpp is running — it is not carried by the package —
so a package written against a newer mcpp arrives at an older one wearing the
OLDER engine's number. The two agreeing therefore says nothing about whether
the KEY is from the future, and this is exactly the case a board-support
package using `mcpp:link-script=` hits on an mcpp that predates it: told its
directive is misspelled, when the real answer is `mcpp self update`.

An old engine genuinely cannot tell the two apart. Naming both is the only
honest thing it can do, and the upgrade is the cheaper one to try first.
…is on

Shipped it, used it once, and CI proved it wrong within the hour:

    FAIL: 130_freestanding_riscv_build_and_run.sh
          (REQUIRED capability missing: llvm)   ← the macOS e2e suite

`llvm` and `qemu-riscv` are absent on the macOS and Windows runners BY DESIGN,
so a token whose absence fails makes those jobs structurally red — a worse
outcome than the silent skip it was meant to prevent. The same word has to mean
both "this platform legitimately lacks it" and "this runner is misconfigured",
and nothing in the token can tell them apart.

The guard that works has to know WHICH runner it is talking about, so it lives
in the job. ci-linux-e2e.yml's `baremetal` job installs qemu and the sysroot
(into the home MCPP uses, or 131 skips), runs the two scripts DIRECTLY — they
are standalone, run_all.sh takes no filter and would run all 250 tests for two
— and then asserts each script's PASS line appeared. Both scripts can exit 0
without running, so the exit code alone cannot answer the question.

run_all.sh keeps the qemu-riscv capability probe and gains a comment saying why
the hard form is not there, so the next person does not re-derive it.
The plan listed `requires-hard` as a prerequisite. It shipped, was used once,
and the macOS e2e suite falsified it within the hour. The conclusion is
stronger than 'used in the wrong place': one token has to mean both 'this
platform legitimately lacks it' and 'this runner is misconfigured', and
nothing in a token can separate those.
…d CI installed the emulator into one home

Two things CI found that local runs could not.

* `[target.<triple>].runner` drew "unsupported key 'runner' (ignored)". The
  unknown-key sweep is about SCALARS — "a scalar that does nothing" — and it
  skipped tables but not arrays, so an array key the parser reads a few lines
  earlier was announced as ignored. Saying a working key does nothing is worse
  than either statement being true on its own. Two tests pin it: the key parses
  and warns about nothing, and the two shapes that would run nothing (an empty
  array, a bare string) are still errors.

* The bare-metal job installed the emulator into the ambient xlings home only,
  and `mcpp run` answered

      [error] xlings: 'qemu-system-riscv64' is not installed

  even though the shim was on PATH. A shim dispatches against whichever home
  owns it, and `mcpp run` goes through that shim — so the emulator has to be in
  the home MCPP uses, exactly like the sysroot two steps below it. Installed
  into both now, with the `--version` probe kept as the before-the-fact check.
CI failed with `[error] xlings: 'qemu-system-riscv64' is not installed` from a
`mcpp run` whose runner named the emulator bare — in a job where
`qemu-system-riscv64 --version` had succeeded two steps earlier. A shim on PATH
dispatches against whichever home owns it, and installing into both homes did
not settle it either.

That topology is not what these tests are about. They test mcpp's runner
MECHANISM — that a template is expanded, the artifact appended, and the child
executed — and a bare name makes them also test shim ownership, which has its
own tests elsewhere. Both scripts now locate the emulator in the payload store
(either home) and put an absolute path in the runner. A real board-support
package has the same information and would do the same.

Both pass locally against the final binary.
@Sunrisepeak
Sunrisepeak merged commit b4da84d into main Aug 18, 2026
21 checks passed
@Sunrisepeak
Sunrisepeak deleted the feat/freestanding-baremetal-targets branch August 18, 2026 23:39
Sunrisepeak added a commit that referenced this pull request Aug 19, 2026
…get worlds (#456)

Ships `--target riscv64-none-elf` / `riscv32-none-elf`: mcpp builds a
freestanding image from C++20 modules and `mcpp run --target-triple` boots it
through a per-target `runner` template, with the C library, startup code,
memory layout and ISA profile all supplied by an ordinary dependency package
(#455).

New surface a package can use:
  * `mcpp:link-script=` / `mcpp::link_script(p)`  — reaches the consumer's link
  * `mcpp::xpkg_dir(ns, name)`                     — where an [xlings] deps payload landed
  * `[target.<triple>].runner`                     — how to execute what this host cannot

Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
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