Skip to content

Commit b4da84d

Browse files
feat(freestanding): bare-metal targets, and a BSP that supplies the whole target world (#455)
* feat(freestanding): bare-metal targets — build and run riscv64-none-elf `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/. * docs: mirror the bare-metal section into docs/zh, and drop second person 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. * feat(freestanding): a BSP can supply the whole target world 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. * docs(plan): record what landed, and the three things the probes changed 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. * fix(build.mcpp): an unknown directive is not necessarily a typo 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. * fix(e2e): drop `requires-hard` — a token cannot know which runner it 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. * docs(plan): W12 was wrong, and the measurement says why 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. * fix: `runner` was reported as an ignored key while being honoured, and 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. * fix(e2e): name the emulator by path, not by a PATH lookup 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. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
1 parent f0de3ef commit b4da84d

34 files changed

Lines changed: 6355 additions & 18 deletions

.agents/docs/2026-08-18-freestanding-baremetal-analysis.md

Lines changed: 1177 additions & 0 deletions
Large diffs are not rendered by default.

.agents/docs/2026-08-19-baremetal-ecosystem-closure-plan.md

Lines changed: 358 additions & 0 deletions
Large diffs are not rendered by default.

.agents/docs/2026-08-19-freestanding-baremetal-design.md

Lines changed: 2576 additions & 0 deletions
Large diffs are not rendered by default.

.agents/docs/2026-08-19-freestanding-baremetal-implementation-plan.md

Lines changed: 308 additions & 0 deletions
Large diffs are not rendered by default.

.github/workflows/ci-linux-e2e.yml

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,97 @@ jobs:
9898
"$MCPP" toolchain install gcc 16.1.0-musl
9999
bash tests/e2e/run_all.sh
100100
101+
# ──────────────────────────────────────────────────────────────────
102+
# Bare metal: the one chain the sharded suite above cannot be trusted
103+
# to exercise.
104+
#
105+
# tests/e2e/130_freestanding_riscv_build_and_run.sh declares
106+
# `# requires: qemu-riscv`, which is legitimately absent on the macOS and
107+
# Windows runners — so it must be a SOFT token, and a soft token means the
108+
# test skips in silence on a Linux runner that lost qemu too. That is the
109+
# exact shape this repository has been burned by twice (65_* never ran at
110+
# all; ten pack e2e skipped on two platforms), and no token can tell the two
111+
# cases apart.
112+
#
113+
# So the guard lives here, where it can be exact: install qemu, run the one
114+
# test, and assert its PASS line appeared. A skip fails this job.
115+
# ──────────────────────────────────────────────────────────────────
116+
baremetal:
117+
name: bare-metal e2e (riscv64-none-elf, qemu)
118+
runs-on: ubuntu-24.04
119+
timeout-minutes: 40
120+
env:
121+
MCPP_HOME: /home/runner/.mcpp
122+
steps:
123+
- uses: actions/checkout@v4
124+
- uses: ./.github/actions/bootstrap-mcpp
125+
126+
- name: Build mcpp from source (self-host)
127+
run: |
128+
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
129+
"$XLINGS_BIN" config --mirror GLOBAL 2>/dev/null || true
130+
"$MCPP" self config --mirror GLOBAL 2>/dev/null || true
131+
"$MCPP" build
132+
133+
- name: Install the emulator (xim:qemu-riscv)
134+
run: |
135+
# ⚠️ BOTH homes. The shim on PATH dispatches against whichever home
136+
# owns it, and `mcpp run` runs the runner through that shim — so an
137+
# emulator installed only in the ambient xlings home answers
138+
# "xlings: 'qemu-system-riscv64' is not installed" when mcpp asks.
139+
# Measured: the job installed it once, the shim resolved, and the
140+
# run still failed.
141+
"$XLINGS_BIN" install xim:qemu-riscv -y
142+
XLINGS_HOME="${MCPP_HOME:-$HOME/.mcpp}/registry" \
143+
"$XLINGS_BIN" install xim:qemu-riscv -y
144+
# Assert it is reachable AND runnable BEFORE the tests. Without this
145+
# the capability probe simply would not add `qemu-riscv` and the
146+
# tests would skip — which is what this job exists to prevent.
147+
command -v qemu-system-riscv64
148+
qemu-system-riscv64 --version | head -1
149+
# The target sysroot, into the home MCPP uses. Test 131's BSP
150+
# declares it as an `[xlings] deps` entry and finds it through
151+
# `xpkg_dir`; installed into the ambient xlings home instead, the
152+
# test would SKIP and the seam would go unexercised.
153+
XLINGS_HOME="${MCPP_HOME:-$HOME/.mcpp}/registry" \
154+
"$XLINGS_BIN" install xim:picolibc-riscv -y
155+
test -d "${MCPP_HOME:-$HOME/.mcpp}/registry/data/xpkgs/xim-x-picolibc-riscv"
156+
157+
- name: Bare-metal e2e
158+
timeout-minutes: 25
159+
run: |
160+
MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)")
161+
test -x "$MCPP"
162+
export MCPP
163+
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
164+
export MCPP_E2E_TOOLCHAIN_MIRROR=GLOBAL
165+
"$MCPP" self config --mirror "$MCPP_E2E_TOOLCHAIN_MIRROR"
166+
# llvm is the toolchain a freestanding target pins; install it
167+
# explicitly rather than relying on whatever the sandbox cache holds.
168+
"$MCPP" toolchain install llvm 22.1.8
169+
# Run the two scripts DIRECTLY rather than through run_all.sh.
170+
# They are standalone (they take $MCPP and nothing else), run_all.sh
171+
# accepts no filter — it would run the whole 250-test suite here for
172+
# two tests — and, more to the point, run_all.sh exits 0 on a skip.
173+
# Invoked directly, a skip is visible: the script either prints its
174+
# PASS line or it does not.
175+
for t in tests/e2e/130_freestanding_riscv_build_and_run.sh \
176+
tests/e2e/131_freestanding_bsp_supplies_everything.sh; do
177+
echo "=== $t ==="
178+
bash "$t" 2>&1 | tee "$(basename "$t").log"
179+
rc=${PIPESTATUS[0]}
180+
[ "$rc" = "0" ] || { echo "$t failed (exit $rc)"; exit 1; }
181+
done
182+
# The assertion this job exists for: both tests RAN. Each has an
183+
# early `exit 0` for a missing capability, so a zero exit code alone
184+
# does not distinguish "passed" from "skipped".
185+
grep -q 'PASS: freestanding riscv64 build + run' \
186+
130_freestanding_riscv_build_and_run.sh.log || {
187+
echo "130 (engine chain) skipped on the runner that must run it"; exit 1; }
188+
grep -q 'PASS: BSP supplies the sysroot' \
189+
131_freestanding_bsp_supplies_everything.sh.log || {
190+
echo "131 (ecosystem chain) skipped on the runner that must run it"; exit 1; }
191+
101192
# ──────────────────────────────────────────────────────────────────
102193
# Hermetic (no host toolchain): the ONLY environment class that
103194
# faithfully reproduces issue #195. Standard runners ship gcc +

docs/05-mcpp-toml.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,59 @@ for arch/env conditions and combinators.
897897
cross target, so put them under `[target.<triple>]` (above), not under a bare
898898
alias or `cfg(...)`.
899899

900+
### 2.7.2 Bare metal (`os = none`) — freestanding targets
901+
902+
`riscv64-none-elf` and `riscv32-none-elf` are targets with no operating system
903+
underneath. They need no per-host cross toolchain: clang and lld are
904+
cross-compilers by construction, so any host that can install the llvm payload
905+
can produce them.
906+
907+
```bash
908+
mcpp build --target riscv64-none-elf
909+
mcpp run --target-triple riscv64-none-elf # via [target.<triple>].runner
910+
```
911+
912+
**What changes on a freestanding target**
913+
914+
| | |
915+
|---|---|
916+
| 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=<payload>/bin/ld.lld`), because `-fuse-ld=lld` resolves through `PATH` and finds GNU ld on any machine with binutils earlier on it. |
917+
| ISA flags | `-march` / `-mabi` / `-mcmodel` come from the target table, so `--target <triple>` alone is enough to produce a correct object file. |
918+
| `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. |
919+
| Entry point | There is no `main`. Declare the target explicitly and point `main` at the file carrying `_start`. |
920+
921+
**A minimal firmware**
922+
923+
```toml
924+
[package]
925+
name = "fw"
926+
version = "0.1.0"
927+
928+
[build]
929+
ldflags = ["-T", "/abs/path/to/link.ld"]
930+
931+
[targets.firmware]
932+
kind = "bin"
933+
main = "src/start.S" # the entry lives in assembly, not in main()
934+
935+
[target.riscv64-none-elf]
936+
runner = ["qemu-system-riscv64", "-machine", "virt", "-nographic",
937+
"-no-reboot", "-bios", "default", "-kernel"]
938+
```
939+
940+
**`runner` — how `mcpp run` executes something this machine cannot run**
941+
942+
A bare-metal image has the wrong ISA, no loader, and expects to own the address
943+
space; exec'ing it directly gives "Exec format error". `runner` is the argv
944+
template that stands in front of it. The artifact path is **appended**, or
945+
substituted for `{}` when the template contains it.
946+
947+
mcpp ships **no default runner**, deliberately. Which emulator, which machine
948+
model and which firmware mode are board facts — two boards on the same ISA need
949+
different argv (`-bios default` for an OpenSBI boot, `-bios none -semihosting`
950+
for a picolibc image) — and an engine that guesses one is an engine the other
951+
board has to fight. A board-support package normally supplies it.
952+
900953
### 2.8 `[features]` — Features (Cargo-style, additive)
901954

902955
```toml

docs/07-build-mcpp.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ is ignored, so diagnostics may be logged freely.
5252
| `mcpp:source=<path>` *(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 |
5353
| `mcpp:include-dir=<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 |
5454
| `mcpp:include-dir-after=<dir>` *(0.0.100+)* | like `include-dir`, but searched **after** the system directories (`-idirafter`) — for payload trees that shadow system headers |
55+
| `mcpp:link-script=<path>` *(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 |
5556
| `mcpp:rerun-if-changed=<path>` | re-run `build.mcpp` when this file changes |
5657
| `mcpp:rerun-if-env-changed=<VAR>` | re-run `build.mcpp` when this env var changes |
5758

@@ -99,8 +100,39 @@ int main() {
99100
| `mcpp::rerun_if_changed(p)` / `mcpp::rerun_if_env_changed(v)` | the matching `rerun-*` directives |
100101
| `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) |
101102
| `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* | reads `MCPP_DEP_<PKG>_BIN_<TOOL>` — the absolute path of a **host tool** built by a dependency (see below) |
103+
| `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` |
104+
| `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) |
102105
| `mcpp::action{…}.submit()` *(2026.8.5.1+)* | `mcpp:action=` — declares a **build-graph node** instead of doing the work here (see below) |
103106

107+
### Finding an `[xlings] deps` payload: `xpkg_dir` (2026.8.19+)
108+
109+
`dep_dir` answers for **mcpp** dependencies. An xlings package is a different
110+
namespace with a different store layout, and `xpkg_dir` is the interface for it:
111+
112+
```cpp
113+
// mcpp.toml
114+
// [xlings]
115+
// deps = ["xim:picolibc-riscv@1.8.12"]
116+
117+
const char* sysroot = mcpp::xpkg_dir("xim", "picolibc-riscv"); // exact
118+
const char* same = mcpp::xpkg_dir("picolibc-riscv"); // bare name
119+
```
120+
121+
The namespaced form answers only for a package declared under that namespace
122+
and is the one to prefer; the bare form is a convenience for the common single
123+
declaration, and when two namespaces claim one name it answers for the first
124+
**declared**. Both return `""` when the package was not declared or is not
125+
installed — a program that needs it should say so itself, because only it knows
126+
whether the absence is fatal.
127+
128+
It is an interface rather than a documented path because the alternative is a
129+
build program encoding `<home>/data/xpkgs/<ns>-x-<name>/<version>`, which is
130+
store internals mcpp is free to change — the same reason `dep_dir` exists.
131+
132+
⚠️ A **pinned** reference resolves to exactly that version or to nothing. A
133+
build that asked for `1.8.12` and silently got `1.9.0` is an answer only
134+
discovered later, in the artifact.
135+
104136
### Host tools from a dependency (2026.8.5.1+)
105137

106138
Declare the need in `mcpp.toml`, then call it:

docs/zh/05-mcpp-toml.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,56 @@ cxxflags = ["-march=x86-64-v2"]
796796
- **`toolchain` / `linkage` 仅限精确三元组** —— 它们描述某一个具体的交叉目标,
797797
因此写在 `[target.<triple>]` 下(见上),而不是裸别名或 `cfg(...)` 下。
798798

799+
### 2.7.2 裸机(`os = none`)—— freestanding target
800+
801+
`riscv64-none-elf``riscv32-none-elf` 是底下没有操作系统的 target。它们不需要
802+
逐宿主的交叉工具链:clang 与 lld 天生是交叉编译器,任何能装 llvm 载荷的宿主都能
803+
产出它们。
804+
805+
```bash
806+
mcpp build --target riscv64-none-elf
807+
mcpp run --target-triple riscv64-none-elf # 经 [target.<triple>].runner
808+
```
809+
810+
**freestanding target 上有什么不同**
811+
812+
| | |
813+
|---|---|
814+
| 链接线 | `-nostdlib -nostartfiles -static`,且不带任何 hosted 的东西 —— 没有 crt 文件、没有动态链接器、没有 C++ 运行时。链接器用**绝对路径**寻址(`-fuse-ld=<载荷>/bin/ld.lld`),因为 `-fuse-ld=lld``PATH` 解析,在任何 binutils 排前面的机器上都会找到 GNU ld。 |
815+
| ISA flag | `-march` / `-mabi` / `-mcmodel` 来自 target 表,所以只写 `--target <triple>` 就足以产出正确的目标文件。 |
816+
| `import std` | **不可用。** `std` 是覆盖整个库的一个模块 —— 线程、文件系统、iostreams 全在内 —— 没有 OS 就没有它的子集可编。freestanding 子集包取代它,mcpp 的诊断会点名。 |
817+
| 入口点 | 没有 `main`。显式声明 target,并把 `main` 指向携带 `_start` 的那个文件。 |
818+
819+
**一个最小固件**
820+
821+
```toml
822+
[package]
823+
name = "fw"
824+
version = "0.1.0"
825+
826+
[build]
827+
ldflags = ["-T", "/abs/path/to/link.ld"]
828+
829+
[targets.firmware]
830+
kind = "bin"
831+
main = "src/start.S" # 入口在汇编里,不在 main()
832+
833+
[target.riscv64-none-elf]
834+
runner = ["qemu-system-riscv64", "-machine", "virt", "-nographic",
835+
"-no-reboot", "-bios", "default", "-kernel"]
836+
```
837+
838+
**`runner` —— `mcpp run` 如何执行本机跑不了的东西**
839+
840+
裸机镜像的 ISA 不对、没有 loader、且期望独占整个地址空间;直接 exec 它得到的是
841+
"Exec format error"。`runner` 就是挡在它前面的 argv 模板。产物路径会被**追加**,
842+
或者在模板含 `{}` 时替换进去。
843+
844+
mcpp **刻意不提供默认 runner**。用哪个模拟器、哪个机器型号、哪种固件模式都是板级
845+
事实 —— 同一 ISA 的两块板需要不同 argv(OpenSBI 启动用 `-bios default`,picolibc
846+
镜像用 `-bios none -semihosting`)—— 引擎一旦猜一个,另一块板就得跟它打架。板级
847+
支持包通常会提供它。
848+
799849
### 2.8 `[features]` —— Feature(Cargo 风格,可加性)
800850

801851
#### 表形式 —— 让 feature 贡献的不止是隐含 feature

docs/zh/07-build-mcpp.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ mcpp build # 编译 + 运行 build.mcpp,然后构建工程
4949
| `mcpp:source=<path>` *(0.0.100+)* | 把一份**既有**源文件选入构建(绝对路径,或相对包根)。下游效果与 `generated=` 相同;语义区别在于文件是程序*选中*的(tarball payload / vendored 源树)而非程序写出的——例如对大型源码包做 per-target 源选择 |
5050
| `mcpp:include-dir=<dir>` *(0.0.100+)* | 为本包自身 TU 增加一个**私有** include 目录(`-I`;绝对路径或相对包根,自动规范化)。取代过去 `cxxflag=-I` + `cflag=-I` 的双重裸发 |
5151
| `mcpp:include-dir-after=<dir>` *(0.0.100+)* |`include-dir`,但排在系统目录**之后**搜索(`-idirafter`)——用于会遮蔽系统头的 payload 源树 |
52+
| `mcpp:link-script=<path>` *(2026.8.19+)* | 用这个**链接脚本**链接(`-T`;相对路径按包根解析,发出的是绝对路径,因为链接是在构建目录里跑的)。与 `include-dir` 不同,它**到达消费者** —— 板子的内存布局恰恰是消费者写不出来的那一项 |
5253
| `mcpp:rerun-if-changed=<path>` | 该文件变化时重跑 `build.mcpp` |
5354
| `mcpp:rerun-if-env-changed=<VAR>` | 该环境变量变化时重跑 `build.mcpp` |
5455

@@ -92,8 +93,35 @@ int main() {
9293
| `mcpp::rerun_if_changed(p)` / `mcpp::rerun_if_env_changed(v)` | 对应的 `rerun-*` 指令 |
9394
| `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` —— 匹配 `pat` 的文件**集合**发生变化时重跑(见下) |
9495
| `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* |`MCPP_DEP_<PKG>_BIN_<TOOL>` —— 依赖构建出的 **host 工具**的绝对路径(见下) |
96+
| `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` |
97+
| `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | 本 manifest 在 `[xlings] deps` 里声明的包的载荷目录;没声明或没安装时返回 `""`(见下) |
9598
| `mcpp::action{…}.submit()` *(2026.8.5.1+)* | `mcpp:action=` —— **声明一个构建图节点**,而不是在这里把活干了(见下) |
9699

100+
### 找到 `[xlings] deps` 的载荷:`xpkg_dir`(2026.8.19+)
101+
102+
`dep_dir` 回答的是 **mcpp** 依赖。xlings 包是另一个命名空间、另一套 store 布局,
103+
`xpkg_dir` 是它的接口:
104+
105+
```cpp
106+
// mcpp.toml
107+
// [xlings]
108+
// deps = ["xim:picolibc-riscv@1.8.12"]
109+
110+
const char* sysroot = mcpp::xpkg_dir("xim", "picolibc-riscv"); // 精确
111+
const char* same = mcpp::xpkg_dir("picolibc-riscv"); // 裸名
112+
```
113+
114+
带命名空间的形式只对该命名空间下声明的包作答,应当优先使用;裸名形式是常见的单条
115+
声明的便利写法,两个命名空间都声明同一个名字时,它回答**先声明**的那个。两者在包
116+
未声明或未安装时都返回 `""` —— 缺失是否致命只有调用方知道,所以由它自己说。
117+
118+
做成接口而不是给一条路径约定,是因为另一种做法是让构建程序把
119+
`<home>/data/xpkgs/<ns>-x-<name>/<version>` 写进代码,而那是 mcpp 可以随时改的
120+
store 内部结构 —— 与 `dep_dir` 存在的理由相同。
121+
122+
⚠️ **带版本固定**的引用只解析到那个版本,否则什么都不返回。请求 `1.8.12` 却静默拿
123+
`1.9.0`,是那种要到产物里才被发现的答案。
124+
97125
### 依赖产出的 host 工具(2026.8.5.1+)
98126

99127
`mcpp.toml` 里声明需求,然后调用它:

0 commit comments

Comments
 (0)