feat(freestanding): bare-metal targets, and a BSP that supplies the whole target world - #455
Merged
Merged
Conversation
`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.
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
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>
Sunrisepeak
pushed a commit
that referenced
this pull request
Aug 19, 2026
Written from the discussion that followed the #455-#459 review, and grounded in what is measurable today rather than sketched: every claim marked with a source was verified against the shipped payloads (llvm 22.1.8, gcc 16.1.0, picolibc 1.8.12) while writing. The load-bearing decisions: * Partitioning by RESOURCE KIND, not by which standard-library facility it lights up. The latter couples a kernel ABI to C++ and to today's library, and inverts the dependency — it is the same mistake POSIX made for C and WASIp1 made for POSIX, one generation further on. 'Lights up std::X' is demoted to the upward admission criterion, which is where it belongs. * An opaque one-word handle, because that is what makes openkal indifferent to sitting above or below libc. An int fd forces it below (Windows needs a table); a FILE* forces it above. Measured: four backends store their native thing with zero bridging. * fs and net dissolve. Naming goes to openkal.namespace, and what it hands back is the same stream resource a file, a socket or a UART gives you. Cleaner than 'everything is a file', because naming failure and I/O failure end up in different interfaces. * core is abort + stream + memory. Memory is core because a bump allocator over a static arena is an IMPLEMENTATION, not an emulation — the test being whether a fake would make callers silently wrong, which is true of a clock that does not advance but not of an allocator that can fail. Also records the caps-can-lie problem with four defences ordered by strength, led by making unsupported operations unrepresentable in the type system rather than false in a bool — the same conclusion K1/K2 reached for the MMU.
Sunrisepeak
added a commit
that referenced
this pull request
Aug 19, 2026
…plementation plan (#461) * docs: deep review of #455-#459 and the bare-metal ecosystem Covers what the five PRs did, the four releases they produced, and the ecosystem work alongside them (xim-pkgindex #651/#652/#653, mcpp-index #219/#220, two new mcpplibs repos), assessed on architecture, compatibility, simplicity, stability and cross-platform. Every claim carries its source — a PR number, a file, or the command that measured it — because the point of the document is to be checkable rather than summarised. Three of its assertions were re-verified against the tree while writing it. The uncomfortable half is deliberate: * five defects in this round were self-inflicted, two of them found only AFTER a release; * three test criteria were themselves wrong — green tests that were not testing the thing they named; * the largest architectural error (packages declaring the target's C library) was found by review, not by me, and I had written 'this cannot be done' about the std subset while my own research document had measured that it could. * docs: user-facing bare-metal scenarios, and the package-naming/ownership answers Adds a scenario document — what a user types, what they see, and what they no longer have to write — with the commands and outputs taken from a real run of the released binary rather than sketched. Folds two review questions into the analysis: * `picolibc-riscv` / `qemu-riscv` carrying the target in the name follows xim's existing rule, which is visible across the index: aarch64-linux-musl-gcc, riscv64-linux-musl-gcc, mingw-cross-gcc, musl-gcc. The NAME carries the target; the `archs` axis carries the host, which is why `llvm` has no target in its name at all. * picolibc belongs on the xim side, resolved at build time from the target's row. It is not importable, it is chosen by the target rather than by a dependency graph, and every other C library in the ecosystem — glibc, musl, musl-cross-make — is already there; mcpp-index carries zero libc packages. Moving it would put the libc back into the package graph, undoing #459. Both answers came with a gap worth recording: `[target.X]` has toolchain, linkage, runner and cxx_runtime but no `sysroot`, so a project cannot swap picolibc for newlib today. * docs: openkal design — a two-sided kernel ABI specification Written from the discussion that followed the #455-#459 review, and grounded in what is measurable today rather than sketched: every claim marked with a source was verified against the shipped payloads (llvm 22.1.8, gcc 16.1.0, picolibc 1.8.12) while writing. The load-bearing decisions: * Partitioning by RESOURCE KIND, not by which standard-library facility it lights up. The latter couples a kernel ABI to C++ and to today's library, and inverts the dependency — it is the same mistake POSIX made for C and WASIp1 made for POSIX, one generation further on. 'Lights up std::X' is demoted to the upward admission criterion, which is where it belongs. * An opaque one-word handle, because that is what makes openkal indifferent to sitting above or below libc. An int fd forces it below (Windows needs a table); a FILE* forces it above. Measured: four backends store their native thing with zero bridging. * fs and net dissolve. Naming goes to openkal.namespace, and what it hands back is the same stream resource a file, a socket or a UART gives you. Cleaner than 'everything is a file', because naming failure and I/O failure end up in different interfaces. * core is abort + stream + memory. Memory is core because a bump allocator over a static arena is an IMPLEMENTATION, not an emulation — the test being whether a fake would make callers silently wrong, which is true of a clock that does not advance but not of an allocator that can fail. Also records the caps-can-lie problem with four defences ordered by strength, led by making unsupported operations unrepresentable in the type system rather than false in a bool — the same conclusion K1/K2 reached for the MMU. * docs(openkal): retract two decisions after review, and record six open questions Two things the design got wrong, both retracted with the reasoning that made them look right at the time: * `openkal.namespace` replacing fs and net. It violated this document's own §5.1 rule (a stream whose caps are the union of file and socket operations is precisely the 'present but useless' antipattern), it required every backend to carry a URI parser — which is an emulation layer by the downward admission criterion — and the WASIp2 precedent it cited was a misreading: WASIp2 separates resource KINDS and shares only the stream type. * Extending cfg() with capability predicates. That conclusion was about openarch's AddressSpace; going through openkal interface by interface, core has no semantic axis at all, and the triple already carries most of what cfg(mmu) would have. The design is now a zero-engine-change proposal. Also corrects the module wiring: `reexport` propagates DOWNSTREAM, so an interface package cannot use it to reach a backend the consumer chose. The backend reexports the interface instead, which is what that mechanism is for. Six open questions the draft did not take a position on, led by one that is measured rather than theoretical: picolibc's vfprintf references free, so routing operator new to kal_alloc while printf keeps picolibc's malloc puts two allocators on the same RAM. The spec has to require that kal_alloc be built over a libc allocator where one exists, not beside it. * docs(openkal): capabilities are ADL-probeable — the caps struct and its config file are gone The design's §4 rested on one measurement: `requires { mcpp::runner("x") }` is a hard error when the name is absent. The measurement was right; the quantifier in the conclusion was not. It is QUALIFIED names that cannot be probed — unqualified lookup through ADL is dependent inside a template and evaluates to false, exactly as wanted. Verified on llvm 22.1.8 against real C++20 modules, not headers, with all three behaviours holding at once: * backend present → the concept is true and the call resolves to it * backend absent → the concept is FALSE, so `if constexpr` degrades * backend absent, called anyway → a compile error carrying the spec's own wording, which is what 'build the diagnostic in' was asking for⚠️ One trap worth the record: if the fallback overload returns the same type as the real one, the concept is true even with no backend — a requires-expression does not instantiate the body, so the static_assert never fires. The fallback must return a distinct type. Measured, after writing it the other way first. Consequences: the caps struct, the generated caps module and capabilities.toml are all deleted. A backend's module interface IS its capability declaration, so claim and implementation become the same artifact by construction and the whole 'caps can lie' problem shrinks from structural+behavioural to behavioural only. Nothing leaves mcpp.toml — backend selection stays a conditional dependency. * docs(openkal): the backend owns the interface module name, and consumers declare both Answers two review questions that turned out to be the same question. The draft had the application write `import openkal.uart;`. That is wrong — it pins the source to a backend, which is the one thing openkal exists to avoid. But it papered over a real constraint, now measured on mcpp 2026.8.19.4 with gcc 16.1.0: * a transitive dependency's module IS importable *⚠️ but ADL does NOT reach a module the translation unit did not import ('seek' was not declared in this scope) So the backend's declarations must live in the module the app imports, which forces the backend to own the well-known name `openkal.stream` while the interface package provides `openkal.abi.stream`. Verified end to end: the app writes one import, names no backend, and ADL resolves to the backend's seek.⚠️ And a trap worth the record: the interface module cannot be called `openkal.stream.abi` — the module graph reads the dots as hierarchy and ninja reports a self-cycle on openkal.stream.gcm. `openkal.abi.stream` is fine. On dependencies: two, not one. The backend alone would work, but declaring the contract is what lets the APPLICATION pin the contract version, turning a mismatch into a resolution error instead of a pile of signature errors at compile time. Same shape as embedded-hal plus a board crate. * docs(openkal): openkal IS the ABI, and the fragmentation risk is mechanically bounded Naming: the interface package is `openkal`, not `openkal-abi` — openkal is the specification, so saying it twice is noise. Two module names are still forced by the language (§4.3), but the qualifier now lands only where implementers see it: applications write `import openkal.stream;`, backend authors write `export import openkal.decl.stream;`. The backend owning the application-visible module name is the one real cost of that shape, and it is a fragmentation risk: a backend could put non-standard names into the standard module and applications would not notice. What bounds it is that most of the surface is not the backend's to touch — ⓘ measured, a backend redefining the interface's types is rejected outright: error: redeclaring 'struct kal::io_result@openkal.decl.stream' in module 'openkal.stream' conflicts with import so the only freedom left is adding overloads, and THAT is statically checkable: conformance diffs the module's exported name set — and signatures, since an `unsigned long` offset would still win ADL through a conversion — against the spec list. Vendor extensions must live under a different module name, which makes 'I used an extension' visible in the source. Second review pass adds two findings: the cardinality that matters is one implementation per INTERFACE rather than one backend per program (a program may take stream from one provider and memory from another), and the fallback overload is too greedy — unconstrained, it catches every kal type and tells a socket it is not a seekable stream. * docs(openkal): add a complete Linux reference implementation Two identities: a backend that works today, and the thing other implementers copy. It does not move the D0 gate — that gate is whether a THIRD PARTY writes a third backend — but it turns 'guess the shape and write the implementation' into 'write the implementation'. Writing it out surfaced three things the design document had not: * core operations need no ADL at all. They are declared `extern "C"` by the interface and defined by the backend; missing means a link error. The ADL mechanism serves optional capabilities only, which makes the common path simpler than the draft implied. * short writes are a spec question nobody had asked. ::write(2) may write less than requested, so openkal must choose: write-all-or-error (the loop lives once, in the backend) or allow short writes (every caller writes the loop — which is exactly where POSIX has tripped programs up for decades). * ⭐ it independently confirms the fs/net decomposition. On Linux, seekability is a property of the HANDLE, not of the backend — lseek succeeds on a file and returns ESPIPE on a pipe. If openkal.stream had seek, the Linux backend could not answer honestly: claiming it means always failing on pipes, which is precisely the 'present but useless' antipattern. Because §2.3 puts seek on openkal.fs's descriptor instead, the question does not arise. That last point is the strongest argument for writing a complete reference at all: it is the only way to find a decomposition error, and it finds it earlier than a conformance suite would. * docs(openkal): module naming is normative, and decl is not interchangeable with impl `openkal.impl.*` would be semantically backwards: that module belongs to the interface package and holds declarations — types, the extern "C" surface, the fallback overloads, the concepts. What an implementation provides is `openkal.<interface>` itself. The name has to be in the spec rather than left to taste, for three reasons that are all load-bearing: every backend must `export import` that exact name, so it is part of the contract; the guarantee that a backend cannot redefine the interface's types only holds while all backends import the SAME module; and conformance's exported-name diff needs to know which names came from the shared module.⚠️ The rationale has to ship with the rule. A spec reader will naturally reach for `openkal.stream.decl` — the dotted extension — and that one was measured to produce a ninja self-cycle on openkal.stream.gcm. A rule without its reason sends the first implementer straight into it. Also records a simplification that was considered and rejected: one `openkal` module holding every interface's declarations. It costs a naming level but breaks per-interface independent versioning, and drags task/fs declarations into a backend that only provides streams. * docs: openkal 0.1 implementation plan, and the design document now points at the shipped packages openkal 0.1 exists as two published packages: mcpplibs/openkal carries the specification and the modules that declare it, and mcpplibs/openkal-linux is the reference implementation, maintained as the worked example other implementations follow. Both are mirrored, and the mirrored archives were verified byte-identical. The plan document records the task dependencies, the criteria applied to each decision, and what verification established. Two results are worth separating from the rest. Writing a complete reference implementation confirmed the decomposition independently of the reasoning that produced it: on Linux, whether a stream can be repositioned is a property of the individual descriptor rather than of the implementation, so an openkal.stream that offered positioning could have been neither claimed honestly nor withheld usefully. A decomposition error of that kind is invisible in specification text and would have surfaced later. The exported-surface checker required by clause 9.3 was verified in both directions, and the negative direction mattered: an earlier version of it was vacuous, comparing a set of C++ symbols that inline functions never emit. The design document is now marked as the record of derivation, including withdrawn proposals and their reasons, while the specification records only conclusions. Where they disagree the specification governs. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
Sunrisepeak
pushed a commit
that referenced
this pull request
Aug 20, 2026
Bare-metal support landed across #455-#459 but had no user-facing page. What existed was docs/05 §2.7.2 — the manifest reference for `[target.*]` — and an outlook section in docs/08 written before the work. Adds docs/13 (en + zh): the two commands that produce a booting image, what a freestanding target changes, the engine/target/board layering, worked examples (ISA width, the freestanding std subset, `mcpp test` on the target, the flashing artifact set, runner override), the two diagnostics, and how to write a board support package. ## The measurements are re-taken, not copied Every transcript was measured on 2026-08-20 with mcpp 2026.8.20.1 built from this tree, on x86_64-linux-gnu. That mattered: the recorded scenario notes say `text 8844` and this build measures `text 8572`, so a copied number would have been wrong on arrival. The chapter says so, and gives its component versions. Two claims that could have been repeated on trust were checked instead: * 103 of 110 headers — counted in both trees (`103` distinct `.inc` in the package, `110` `std/*.inc` in the llvm 22.1.8 payload); * five host targets for `xim:qemu-riscv` — read out of the descriptor (linux x64/arm64, darwin x64/arm64, win32 x64), which also carries the comment explaining why win32-arm64 is absent. The one claim not verified here is labelled as such: the 7 omitted headers are reported by the package to fail on a hosted x86_64 too. ## The defect writing it exposed The freestanding `import std` diagnostic ends in a copy-pasteable dependency line, and prepare.cppm carries a comment saying that line is a PROMISE which has to resolve today — because it once named a package that did not exist. It was broken again, in a second form. The line said `"0.1.0"` after `0.2.0` superseded it in the index, and 0.1.0 is not published: E_NOT_FOUND: package 'compat.std-freestanding@0.1.0' not found in the synced index So the version is part of the promise, not decoration. Fixed to `"0.2.0"` and verified end to end: trigger the diagnostic, paste its line, `mcpp run` prints `value 42`. The comment now records this recurrence, since the first note was not enough to prevent it. ## Adjacent documents * README target table gained `riscv64-none-elf` / `riscv32-none-elf` — both tier `verified` in triple.cppm, both executed under qemu by the `baremetal` CI job, and neither was listed. * docs/08 §7.3 stops being an outlook. Three of its predictions held; one was wrong in the way that matters — the C library is NOT inside the toolchain payload, it is a separate payload named by the target's own row, and that is what keeps a bare-metal package from having to name a libc. * docs/05 §2.7.2 stays as the manifest reference and now points at docs/13. `bash .github/tools/check_docs_style.sh` passes; the bilingual heading structure is identical by construction. `mcpp build` succeeds with the prepare.cppm change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
裸机链路的两半:引擎能构建能跑;板级支持包(BSP)把目标世界的其余部分全部供上,消费者的 manifest 只写一条依赖。
所有 freestanding 专属的东西在新的
src/freestanding/模块目录(target/linkline/runner)。hosted 路径一行没动。第一半:引擎能构建、能跑
只把三元组解析加上之后,失败形态比构建报错更糟:
真因,且不能从已能用的交叉 target 推广:此前每个交叉 target 都用独立的编译器二进制(
x86_64-w64-mingw32-g++),它自己的-dumpmachine就是交叉三元组。而 clang 一个二进制服务所有 target,-dumpmachine永远回答宿主。现在 freestanding 的tc.targetTriple由请求决定 —— 产物目录、指纹、缓存键、flag 层读的都是这一个字段。none的歧义riscv64-none-elf裸机、x86_64-none-linux-gnuhosted。预扫描判定,两侧都钉了测试 —— 判反是静默的。-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 时复现过。libc++.a放上了 riscv64 链接线。import std关断std是覆盖整库的一个模块,没有 OS 就没有子集。留着报'__config_site' file not found,读起来像载荷坏了。诊断现在点名替代包和那行 manifest。runner无默认值-bios defaultvs-bios none -semihosting),引擎猜一个,另一块板就得跟它打架。第二半:BSP 供上整个目标世界
⭐ 这份工程里没有 picolibc、compiler-rt、crt0、链接脚本、加载地址、
-nostdlib、-mcmodel。这个不对称是刻意的(构建期程序不得静默拓宽包的公开编译接口),也正是 BSP 私有 include 目标 libc 头、对外 export 一个 C++ 模块的原因。
tests/e2e/131两侧都钉:模块化消费者跑通,而试图#include <stdio.h>的消费者必须构建失败。三个新部件
mcpp:link-script=Scope::LinkGlobal。别的通道要么包私有(cxxflag),要么表达不了这个 flag(link-lib发-l、link-search发-L)。-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 模块本来就拥有这套布局),避免同一决策两处推导。<stdio.h>会#include <stddef.h>,落到 libc++ 那份,打开一个为宿主生成、这里根本不存在的__config_site。报错点名__config_site,读起来像载荷坏了。-nostdinc++同理进了 freestanding 编译前缀。测试
none歧义两侧 6 条 · xpkg 接口 4 条(每种 manifest 写法 / 固定版本要么精确要么没有 / 版本按数字段比较,因为字符串序把 0.4.11 排在 0.4.9 前 / 通道两侧共用一个 sanitizer)·link-script3 条tests/e2e/130:引擎链路 —— 模块 + 汇编 → UCB RISC-V 镜像、无 PT_INTERP、零未定义符号、入口 0x80200000 → 启动并断言模块输出。runner 两侧钉。tests/e2e/131:生态链路 —— BSP 供 sysroot + 链接脚本 + 运行时,消费者只声明依赖;并反向验证include-dir不泄漏到消费者。# requires-hard:(能力缺失 FAIL 而非 SKIP)。requires-hard:qemu-riscv 在 macOS/Windows runner 上本来就没有,硬 token 会让那些 job 结构性首红。真正要防的事由ci-linux-e2e.yml新增的baremetaljob 守:装 qemu + sysroot(装进 MCPP 用的那个 home,否则 131 会 SKIP)→ 跑这两条 → 断言两条 PASS 行都出现了。run_all.sh跳过时退出码是 0,回答不了这个问题。本地结果
依赖
xim:qemu-riscv@9.2.4-1与xim:picolibc-riscv@1.8.12,均已进 xlings 生态(openxlings/xim-pkgindex#651、#653)。方案与计划在.agents/docs/下。