feat(freestanding): exceptions off graph-wide, and the std subset becomes usable - #459
Merged
Conversation
…omes usable
The freestanding std subset was described in the phase-3 wrap-up as "a separate
piece of work". That was wrong, and the research it contradicted had already
measured it. Reproduced here on the shipped payloads: with a synthesised
`__config_site`, 103 of libc++'s 110 headers compile for riscv64-none-elf, and
all 7 that fail also fail on an x86_64 host with full libc++ and glibc — they
are headers libc++ has not implemented. The freestanding compile-time loss is
zero. `-nostdinc++` is the MECHANISM of that route, not an obstacle: it keeps
libc++'s headers private to the subset package, which exports a module — the
same shape the board package already uses for the target's C library.
What actually blocked it was exceptions, and that is a whole-graph property:
* `std::optional::value()` alone pulls in `__cxa_throw`, two vtables and
`std::bad_optional_access::~bad_optional_access`, none of which can exist
without an unwinder or libc++abi.
* A project's own `[build] cxxflags` cannot fix it: they do not reach a
dependency's module compile, so the BMI and the importer disagree and clang
reports a `.pcm` "configuration mismatch" — naming a module file rather
than the setting that split the graph.
* `is_dialect_flag` deliberately does NOT propagate `-fno-exceptions`, on the
grounds that "dependencies may assume exceptions are available". Right for
a hosted target, exactly backwards here.
So `-fno-exceptions -fno-rtti` join `-ffreestanding` and `-nostdinc++` on the
target spec, which is the one place that reaches every unit in the graph.
⚠️ That change alone would have broken every existing bare-metal user on
upgrade. The dependency cache key carries the target TRIPLE but not the flags
the triple implies — and which flags those are is mcpp's decision, so it moves
between versions while the triple string does not. A project that had built
once got its old BMI back and a hard failure naming a .pcm. Fixed by adding a
`targetImpliedFlags` axis, empty for hosted targets so no existing key moves.
e2e/133 pins the whole chain in the emulator, from both sides: the subset
builds, runs `ranges::sort` with a projection plus optional/atomic/span/
string_view on the target, and `std::mutex` fails to COMPILE — capabilities
turned off in `__config_site` vanish rather than leaving a run-time stub.
The `import std` diagnostic names `std-freestanding` again, now that the
package is published.
…e one
Both bare-metal packages carried `[xlings] deps = ["xim:picolibc-riscv@1.8.12"]`
and the standard-library subset also carried `xim:llvm`. That pinned a board
package and an implementation-neutral library alike to one libc, one ISA, one
toolchain and one version of each — none of which is a property of either.
The cause was a gap in the target model rather than sloppy packaging. A hosted
target has always had its C library resolved for it: musl rides inside its gcc
payload and glibc arrives through PayloadPaths, which is why nobody writes
`xim:glibc` in a manifest. A freestanding target pins a generic clang, which
carries no target libc, and there was no axis for one — so the requirement
leaked outward into every package.
kKnownTargets now names the target's C library beside its compiler pin. It is
installed through the same channel `[xlings] deps` already use, its headers go
on every compile line and its directory on the link search path. Location is a
target fact; selection stays a board fact — a board still chooses
`-lcrt0-semihost` over a UART crt0, and still names its linker script.
Two interfaces let a package ask instead of declare: `mcpp::toolchain_dir()`
for headers that ship with the toolchain (libc++'s, for the freestanding
subset) and `mcpp::sysroot_dir()` for a file inside the target's C library (a
linker script). Both follow whatever `[toolchain]` and `--target` resolved.
Three things this turned up:
* `-L` appended to the ordinary ldflags is discarded — a freestanding link
line is replaced wholesale, so it has to go through LinkInputs. Measured:
the flag was built and then simply was not there.
* `link-script` resolves a bare name against the PACKAGE root, so a board
cannot rely on the linker's search path for it; it asks for the sysroot.
* e2e/131's privacy assertion was testing the wrong thing. It proved a
dependency's `include-dir` stays private by showing `#include <stdio.h>`
fails — but with the libc owned by the target that now correctly SUCCEEDS,
exactly as on a hosted build. Split into two: the target's C headers must
reach the consumer, and a header the BOARD ships must not.
Sunrisepeak
pushed a commit
that referenced
this pull request
Aug 19, 2026
…hip 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.
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.
Sunrisepeak
pushed a commit
that referenced
this pull request
Aug 20, 2026
⚠️ 这次解除的耦合**在任何 manifest 里都看不见**。`riscv-virt-rt` 自 #459 起既不 声明 LLVM 也不声明 picolibc,却依然服务不了第二种工具链或第二份 C 库 —— 因为它把 `clang_rt.builtins-riscv64`(compiler-rt 的事实,GCC 下是 `libgcc`)与 `rv64gc/lp64d`(picolibc 的 multilib 约定)写进了自己的 build.mcpp。 **声明出来的依赖可见可评审;写死的名字不可见,而且只在换东西时才失败** —— 恰好是 没人在看的时候。 ## 判据用的是既有的那条 位置是目标的事实,选择是板级的事实。据此: * 哪个 builtins 库存在,由**编译器**决定,而且没有板子会选择不要它 (rv64 上的触发者是 picolibc printf 的 128 位移位,ISA 无对应指令); * 某个 ISA 档位的库放在哪个子目录,是 **C 库的约定**,零板级输入。 两者引擎本来就知道 ⇒ `mcpp::target_builtins_lib()` / `mcpp::target_libc_profile()`。 第三个 `mcpp::target_libc()` **不消除耦合,而是让它显形**:crt0 的对象名在 picolibc 与 newlib 之间确实不同,而那确实是板级选择。显式分支可读可扩展, 藏在字面量里的假设两者都不是。 ## 单一读取点(又一次:它原本要变成两处) `bpEnv` 在两处构造(根工程 / 每个依赖)。四个值各推导两遍,会得到「作为根工程拿到 对的、作为依赖拿到旧的」的板级包 —— 而那只在**消费方**的构建里失败,是更难查的方向。 ⇒ 收敛为 `fill_target_build_env()`,两处同一次调用。 ##⚠️ 一处契约不一致,由实测暴露 零 libc 档上 `profile` 仍返回 `rv64gc/lp64d`,而它按名字是 **C 库的**子目录。 没有 C 库时它不是任何东西的约定,发出去等于把一条不存在的路径交给内核,访问器的 名字就成了假话。⇒ 已收紧:**三个 libc 面的答案一起为空**,而 builtins 是编译器 事实,保留。 ## 验证 新增 e2e/134,七步全部**两侧钉**:目标行的 libc 可用 ↔ `sysroot = ""` 后 `<stdio.h>` 确实找不到;零 libc 仍产出镜像;三个查询在 rv64/rv32/零 libc 三种 配置下的值;裸名被解析期拒绝。 ⭐ **做了 revert-A 探针**:把 `effective_sysroot` 的覆盖分支注释掉重建后,e2e 精确 地死在第 4 步那条承重断言(`sysroot = "" did not remove the C library`)。不做这一步 不算写完 —— 这两个特性都属于「在已配置好的机器上,在与不在长得一模一样」那一类。
Sunrisepeak
added a commit
that referenced
this pull request
Aug 20, 2026
* docs: a bare-metal chapter, and the diagnostic promise it found broken 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. * docs: 裸机/嵌入式/内核方向的生态定位与缺口 上一轮 review 提了七个问题,其中两个更正了我的判断:freestanding 应当是包属性 (对),以及 mcpp 已有 openkal(已实现)与 openhal(已设计)——我此前把 「没有 embedded-hal 那套词汇」写成最大缺口,那是错的,真实状态是已设计未实现。 本文给出五项决策的形状与判据: * A freestanding 成为包能力,复用既有 `provides`,但**由构建证据推导而非作者 声称** —— 判据近乎免费:包能为 riscv64-none-elf 编过就是 by construction, 因为引擎已把那四个 flag 强制到全图。这与 openkal 设计 §5 删掉能力位机制是 同一条纪律。 * B 目标表三步可扩展:先工程内定义、再随包走、最后索引化;⚠️ 单一读取点不可破。 * C std-freestanding 与 openkal 互补而非替代 —— openkal 解决 OS 服务可移植, **解决不了目标版 libc++.a**,docs/13 的边界不因它而改变。 * D 零 libc 档:最小示例不是一个内核,是一个零 libc 的启动工程,它同时是该档 的唯一判据。 * E 下一个案例:Cortex-M + QEMU 先行,ESP32 分型号。 四条本机实测,其中两条改变了结论: * `clang -print-targets` 未注册 `xtensa` ⇒ ESP32/S2/S3 在钉住的载荷上不成立; 已注册 `arm`/`thumb`/`riscv32`/`riscv64`。 *⚠️ xim 索引里裸机 C 库只有 `picolibc-riscv` 一个,qemu 只有 `qemu-riscv` 一个 ⇒ Cortex-M **不是「加一行」**,而是两个新载荷 + 一行 + 一个包。写进文档前 我差点把它写成「只缺一行目标表」。 * `cfg(os = "none")` 与 `cfg(arch = ...)` 实测可匹配 ⇒ 今天的兜底存在,但索引 检索不到,不能替代决策 A。 * 零依赖 freestanding 工程可构建(text 12)⇒ 仅凭 ISA 表行足以产出正确目标文件。⚠️ 全文最弱的一环写在 §8.5 与 §11 第一行:这套分层只在 QEMU virt 一台几乎没有 外设的虚拟机上验证过。 五项决策全部不要求引擎认识 KAL/HAL/ARCH,也全部不新增引擎轴。 * docs(openarch): 实现方案;并把四条实测折回定位文档 ## openarch 实现方案(新) 它是三层里唯一实现者集合**有界**的一层(x86_64/aarch64/riscv64,基本只有自己), 因此仓库形态、里程碑顺序、判据都应与 openkal/openhal 相反: * 单仓库 —— 因为**接口与实现必须共同演化**:D3 的门是「加第二个 arch 时抽象 不碎」,而只有加第二个 arch 才知道接口错在哪,拆仓会拖慢决定这一层生死的循环。 * ⭐ **先做最硬的两条**(上下文切换、页表项),与常规的先易后难相反 —— 容易的 部分(percpu/barrier)全部做完也不能证明这一层成立,而最硬的一碎,前面全是 沉没成本。方案 §7.1 的原话是「碎在这里,后面全是幻觉」。 *⚠️ `context_switch` 必须是纯汇编符号,换栈后跑的 prologue 访问的是错误的栈 ⇒ 它的「零成本」不来自 inline,而来自「它本来就是一次 call」。照搬方案 §16.2 的「显式 inline 优先」会得到一个崩溃的设计。 * 内存属性是 A 类裂缝的教科书例子:x86 走 PAT 索引、aarch64 走 MAIR 索引、 riscv 是 PTE 位直给 ⇒ 判据设计成能证伪的,碎了就把 addrspace 缩到「结构而非属性」。 * x86_64 是**第二道门**而不是「再加一个 arch」:rv64 与 aarch64 可能偶然相似, x86_64 是最不像的那个,它检验接口有没有被前两个过拟合。⚠️ 全文零实测 —— openarch 还没有一行代码,第一条实测只能由 A0 探针产生。这一点 写在 §7 第一行。 ## 四条实测折回定位文档 1. ⭐ **推翻了我自己写的一句话。** 原文说 std-freestanding 需要堆、「没有办法说 不要堆」。实测三态:tier-0 完全不碰堆;`std::vector` 编得过链不上(缺 `operator new`);补齐 12 个重载后跑通(text 13492)。它是**分配中立**的。 2. ⭐ tier-0 程序的全部未定义符号是 `memmove` + `strlen` 两个纯计算函数 ⇒ 「std-freestanding 应基于 openkal」的猜想被拆成两半:**头文件结构性绕不过, 而运行期面几乎已经绕过了**。接触面只有 tier-1 的分配器一处。 3.⚠️ openkal 设计 §6.3 的选择键是 ISA 级 cfg,而 UART 基址是板级事实 ⇒ 换板后**类型正确、能编能链、写到不存在的地址、静默无输出**。更正为 「后端由板级包提供,选择键是那条板级依赖」。 4. ⭐ `[target.X].sysroot` 从一条普通边界升格为**三条线的共同瓶颈**(零 libc 档 / 换 libc 实现 / 让 std-freestanding 坐到 openkal 的 C 库头上),实施顺序因此提前。 另新增:std-freestanding-alloc(约 30 行,默认转 kal_alloc)—— 按头文件拆 `freestanding.core` 会把一份**生成的**清单改回**手工策展的**清单,正好撤销包自己 写明的纪律;该拆的是「程序必须提供什么」。以及 UEFI 作为 bootloader 方向的好位置 (接口与 UEFI 服务几乎一一对应),而 legacy BIOS 不在射程内(16 位实模式)。 * docs: 分配器的 feature 形状,以及两条把它定死的实测 ## 最终形状(方案文档 §6.6.1) 照搬 docs/05 §2.8.2 已文档化的 backend-openblas:一个 feature 同时拉 provider 并打开消费者开关。 [features] alloc = { requires = ["freestanding-allocator"] } alloc-kal = { implies = ["alloc"] } [feature-deps.alloc-kal] std-freestanding-alloc-kal = "0.1.x" ⇒ 实现分离(本体零依赖)· 默认一键可得(不必知道 provider 包名)· 可换 · 并集风险被转化成解析期报错。 ## 两条实测把三个备选形态判死了 1. ⭐ **依赖以裸 `.o` 参与链接,整份 build.ninja 零个 `.a`**(StaticLibrary 只在根 清单 kind="lib" 时产生,plan.cppm:1637)⇒ libc++ 那套「库常驻默认、程序顶掉」 靠的是归档语义,对包依赖不适用。这正是要用「开关控制是否存在」而不是「默认存在 可覆盖」的原因 —— 两份定义从不共存。 2. ⭐ **capability 消歧不裁剪链接行。** 两个包都 provides 同一能力且都定义同名符号: 未 pin 时解析期报错并点名两者;按提示 pin 一个之后,构建走到链接器才死于 `multiple definition`,两份 .o 都在链接边上。 ⇒ 绑定的是「谁满足要求」,不是「哪些目标文件参与链接」。对单例符号类 provider, pin 会把好错误换成坏错误 ⇒ 两个 provider 同时在图里是**待修的缺陷**而非可 pin 的歧义。这条已补进 docs/05 §2.8.1(中英)。 被否决的三个形态及理由也记了:按头文件拆 core/其它(把生成的清单改回手工策展)、 nolibc 做成 feature(非加性,且它是目标的属性)、弱符号默认(两份实现同时在图里 不报错,按链接顺序静默选)。 ## 零 libc 的两条(§7.1) * ⭐ 零 libc 链接已成立:只依赖 std-freestanding、无板级包、无 -lc、无 crt0, 自带 memmove+strlen+_start 后链接通过(text 15866)。 *⚠️ 但**未验证能否启动,且已知不能**:加载地址 0x10000,qemu virt 要 0x80000000。 ⇒ 零 libc 档缺的不是 libc,是**链接脚本** —— 而那是板级事实,恰好落在分层里 已有归属的那一格。 ## mcpp 用户文档 docs/05(中英)§2.8.1 补「绑定不裁剪链接行」及其对单例符号能力的后果; §2.8.2 补「保持可替换的默认实现」小节 —— 这是一个通用形状(operator new、 日志 sink、panic handler),不只分配器。 check_docs_style.sh 通过(双语标题层级一致)。 * feat(target): [target.X].sysroot —— 目标 C 库可按工程覆盖,并引入零 libc 档 目标表把一份 C 库绑在每个 triple 上,而三条互不相干的需求都想和它分歧:内核要 一份都不要、厂商 SDK 上的工程要那份 newlib、std-freestanding 坐到 openkal 的 C 库上也要换。⇒ 一个旋钮解锁三条线。 ## 形状 `[target.<triple>].sysroot`,与 `toolchain` 覆盖 `pin` 同轴:一个指名编译器, 另一个指名 C 库,两者本来都只有引擎能决定。⚠️ 字段是 `std::optional<std::string>` 而不是 `std::string`,因为**缺席与空是 两个不同的答案**:缺席继承目标表行,`sysroot = ""` 是**零 libc 档**。用普通字符串 两者不可区分,而空串正是没有 sysroot 的目标行本来的样子 —— 内核工程会静默地把 picolibc 拿回去。 ## 单一读取点(它原本是两处) `prepare_build` 原先在两个地方各自推导「这个目标用哪份 sysroot」——一处算 include/ library 路径,一处物化 xim 包。只给其中一处加覆盖,会得到**装了一份 libc、编译时用 另一份**的构建。⇒ 收敛到 `triple::effective_sysroot()`,并把 `[target.X]` 的 拼写无关查找也factor 出来(否则会出现「对 toolchain 生效、对 sysroot 不生效」的段)。 零 libc 档返回空串是刻意的:hosted 目标行本来就是空串,每个下游消费者早已把空串 当作「不加目标 sysroot 路径」⇒ **新档位在下游不需要任何新分支**。 ## 实测(两侧都钉) * 不覆盖:picolibc 头在,`text 12`; * `sysroot = ""`:`fatal error: 'stdio.h' file not found` —— C 库确实没了; * 零 libc 自包含镜像:**`text 108`,qemu 里打印 `zero-libc ok`** —— 无 libc、无 crt0、无板级包。这是零 libc 档的判据,不是「能编过」。 9 个新单测:effective_sysroot 四态(继承/覆盖/空串/hosted)+ 解析五态 (覆盖/空串present/缺席nullopt/裸名拒绝/不被误报为 unsupported)。⚠️ 过程记录:验证时我用 `find | head -1` 取测试二进制,取到陈旧目录,一度得到 「新测试 0 个」的假结论。按内容选(`--gtest_list_tests | grep`)才对。这是同一个 坑的第四次。 * feat(build): 三个目标查询,解除板级包对编译器与 C 库的隐式耦合⚠️ 这次解除的耦合**在任何 manifest 里都看不见**。`riscv-virt-rt` 自 #459 起既不 声明 LLVM 也不声明 picolibc,却依然服务不了第二种工具链或第二份 C 库 —— 因为它把 `clang_rt.builtins-riscv64`(compiler-rt 的事实,GCC 下是 `libgcc`)与 `rv64gc/lp64d`(picolibc 的 multilib 约定)写进了自己的 build.mcpp。 **声明出来的依赖可见可评审;写死的名字不可见,而且只在换东西时才失败** —— 恰好是 没人在看的时候。 ## 判据用的是既有的那条 位置是目标的事实,选择是板级的事实。据此: * 哪个 builtins 库存在,由**编译器**决定,而且没有板子会选择不要它 (rv64 上的触发者是 picolibc printf 的 128 位移位,ISA 无对应指令); * 某个 ISA 档位的库放在哪个子目录,是 **C 库的约定**,零板级输入。 两者引擎本来就知道 ⇒ `mcpp::target_builtins_lib()` / `mcpp::target_libc_profile()`。 第三个 `mcpp::target_libc()` **不消除耦合,而是让它显形**:crt0 的对象名在 picolibc 与 newlib 之间确实不同,而那确实是板级选择。显式分支可读可扩展, 藏在字面量里的假设两者都不是。 ## 单一读取点(又一次:它原本要变成两处) `bpEnv` 在两处构造(根工程 / 每个依赖)。四个值各推导两遍,会得到「作为根工程拿到 对的、作为依赖拿到旧的」的板级包 —— 而那只在**消费方**的构建里失败,是更难查的方向。 ⇒ 收敛为 `fill_target_build_env()`,两处同一次调用。 ##⚠️ 一处契约不一致,由实测暴露 零 libc 档上 `profile` 仍返回 `rv64gc/lp64d`,而它按名字是 **C 库的**子目录。 没有 C 库时它不是任何东西的约定,发出去等于把一条不存在的路径交给内核,访问器的 名字就成了假话。⇒ 已收紧:**三个 libc 面的答案一起为空**,而 builtins 是编译器 事实,保留。 ## 验证 新增 e2e/134,七步全部**两侧钉**:目标行的 libc 可用 ↔ `sysroot = ""` 后 `<stdio.h>` 确实找不到;零 libc 仍产出镜像;三个查询在 rv64/rv32/零 libc 三种 配置下的值;裸名被解析期拒绝。 ⭐ **做了 revert-A 探针**:把 `effective_sysroot` 的覆盖分支注释掉重建后,e2e 精确 地死在第 4 步那条承重断言(`sysroot = "" did not remove the C library`)。不做这一步 不算写完 —— 这两个特性都属于「在已配置好的机器上,在与不在长得一模一样」那一类。 * test(e2e): 机器校验诊断里那条可粘贴的依赖行⚠️ **同一个缺陷发过两次,而两次的修法都是「改字面量 + 加注释」。** freestanding 的 `import std;` 诊断末尾给一个 `[dependencies]` 块让读者直接粘贴。 它错过两次,形态不同: 1. 指向 `mcpplibs.std.freestanding`,而当时**没有这个包** —— 粘完下一条命令就是 `package not found`; 2. 包名对了,**版本过期**:索引里只有 `0.2.0`,而它印 `0.1.0` ⇒ `E_NOT_FOUND: package 'compat.std-freestanding@0.1.0' not found in the synced index`。 第二次发生时,**第一次修复留下的那条注释就在断掉的那一行正上方**。⇒ 注释强制不了 跨仓库不变量 —— 本仓库早就为版本 pin 学过这一课,`check_version_pins.sh` 就是那次的 产物。 ## 判据的形状⚠️ **测试不能写出版本号。** 断言 `std-freestanding = "0.3.0"` 只是把同一个字面量 抄到第二个地方,再检查两份抄件一致 —— 两份都错时它同样通过。 改为检查那条真正重要的性质:**诊断印什么,那个东西就能解析**。 触发诊断 → 从输出里 `grep -oE` 抠出那一行 → 原样写进 manifest → 构建 → 断言没有 not-found 类错误,且该依赖**确实进了构建图** (Downloading/Compiling/Cached 三个动词之一点名它)。 链接失败是允许的(这个工程没有板级包,没有 crt0),那不是本测试的对象;**解析失败** 才是,而两次历史事故都正是解析失败。 ## revert-A 探针 把字面量改回 `0.1.0`(第二次复发的原样)重建后,该 e2e **变红**,并打印: the diagnostic's copy-pasteable line does not resolve. advice was: std-freestanding = "0.1.0" update the literal in prepare.cppm's freestanding import-std message to a version the index actually carries, in the same change that publishes it. ⇒ 它对历史缺陷本身变红,而不只是对假想的缺陷变红。 * feat(diag): 裸机上缺 operator new 时点名分配器 feature 裸机工程一用 `std::vector`,链接就死在标准库深处某个头文件里的 mangled 符号上: ld.lld: error: undefined symbol: operator new(unsigned long) >>> referenced by allocate.h:58 (…/include/c++/v1/__new/allocate.h:58) 这条消息读起来像工具链坏了,而它不是:freestanding 目标没有编译版 libc++, `operator new` 就是不存在。消息里没有任何一处说明哪个包提供它,也没说答案只是 一行清单。 ## 内容 新增 `link_failure_advice()`,在构建失败时按链接器输出追加:说明「会分配的那部分 需要程序提供分配器,不会分配的那部分什么都不需要」,并给出激活 feature 的写法, 以及自己实现时那 **12 个重载**(特别点名带 `align_val_t` 的四个 —— 漏掉它们会在 修好第一个错误之后再撞第二次,这是实测出来的)。 ## 三处刻意的设计 * ⭐ **不带版本字面量。** 它点名的是包与 **feature**,而 feature 负责拉实现 ⇒ 该行跨该包的每个版本都成立。`import std` 那条带版本的建议需要 e2e/135 才能 保持诚实;这一条**在构造上就不可能以同样方式过期**,并有单测钉住(出现形如 `0.` 的版本串即判失败)。 * **两条失败路径都接。** 快路径(execute.cppm)与完整路径(ninja_backend.cppm) 经不同渠道报错;只接一条,建议就会随 build.ninja 是否最新而出现或消失。 * **从原始输出而非过滤后的输出里判定。** 过滤器会丢掉命令行,而将来改过滤器 不应该能顺手把建议一起丢掉。 ## 判据 两种链接器的两种拼写都认(lld 的 `undefined symbol:` 与 GNU ld 的 `undefined reference to \``)—— 只认一种会让建议在部分工具链上不出现。 四个单测,其中一个是**反向**的:板级符号缺失(`undefined symbol: board_uart_init`) 时必须保持沉默 —— 建议会追加到每一次失败的构建上,匹配太松就会把分配器建议贴到 一个缺 crt0 的错误上。 * chore(release): 2026.8.20.2 —— 生态实施计划、文档与版本 ## 实施计划(.agents/docs) 八维度评估 + 任务依赖图 + 每步的可证伪判据。⭐ 最有价值的是 §3:**实施中被实测 推翻的八条设计主张**,其中七条若不实测就会写进文档 —— 包括「std-freestanding 需要 堆」「默认实现可被程序覆盖」「`[capabilities]` 能消歧单例符号」「Cortex-M 只缺一行 目标表」。 §5 如实记录未完成项:⚠️ **openarch 的 A0 门要求两个真实不同的 arch,而 aarch64 缺 目标行与模拟器。只做一个 arch 不能证明抽象不碎 —— 这正是该门存在的理由,伪造它比 不做更糟。** ## 用户文档 * `docs/13`(中英)新增两节:「会分配的那半边」与「没有 C 库的目标」,含 capability 两种失败的原文、零 libc 的 108 字节与模板的 369 字节; * `docs/05`(中英)§2.7.1 新增 `sysroot` 键的参考,写明**缺席与空是两个不同的 答案**; * `docs/13` 的「当前边界」按实现结果更新 ——⚠️ 并如实收窄:换 C 库这条**只有空值 一侧经过验证**,生态里没有第二份裸机 C 库,指向另一份的路径未经测试。 check_docs_style.sh 通过(双语标题层级一致)。 * docs: 更正一条被实测推翻的断言 —— nolibc 与 C 库并存不报错,而是静默替换⚠️ **本轮唯一一次「已经写进已发布文本才被推翻」的断言。** `docs/13`(中英)、`std-freestanding-nolibc` 的 mcpp.toml 与 README、以及索引描述符 都写着:板级包链了 `-lc` 时该包会造成 `memcpy` 重复定义而失败。 **实测:冷构建成功,`nm` 只找到一处 `memcpy` 定义。** 真因是我只看了推理链的一半。依赖确实以裸 `.o` 参与链接(这一半是对的,并且有 build.ninja 为证),但 **C 库是归档**,归档成员只在符号仍未定义时才被拉入 —— 包的 `.o` 先定义了它,C 库那份就永远不进来。 ⭐ **危害因此比预测的更糟**:不是响亮的链接错误,而是**静默地**用逐字节实现替换掉 C 库经过优化的字长实现,没有任何东西报告这次替换。 已更正四处文本 + 包发 0.1.1(0.1.0 内容不变保留)。实施计划 §3 记下了这一条, 以及它为什么发生:「结构上可能 ≠ 运行时确实」这次是在我自己身上生效的。 * docs: openkal 的两个新后端,以及被它们推翻的两条判断 ## 两个新后端(生态侧已发布并进索引) * `mcpplibs/openkal-opensbi` 0.1.0 —— ⭐ **可移植的那个 RISC-V 后端**:控制台是对 已经知道机器是什么的固件的一次 ecall,同一镜像在 QEMU virt 的 OpenSBI 下与真实 板子上都能跑;而板级后端往固定地址写,换板即静默失效。两者并列而非替代 —— SBI 要求底下有固件,板级后端不要求。 * `mcpplibs/openkal-uefi` 0.1.0 —— UEFI Boot Services,OVMF 下作为 EFI/BOOT/BOOTX64.EFI 启动验证。 ##⚠️ 两条被实测推翻的判断 **1. 「UEFI 受阻于一个不存在的 PE 形态裸机目标」——错的。** `x86_64-windows-gnu` + `-nostdlib -Wl,--subsystem,10 -Wl,-e,efi_main` 产出的正是 `IMAGE_SUBSYSTEM_EFI_APPLICATION (0xA)` 且**零 DLL 依赖**,该目标默认就是 MS x64 调用约定。原判断从「裸机链接行是 ELF 形状」推出,错在**没有考虑已有的 PE 目标能否 被降到 freestanding**。 **2. 「`kind = "lib"` 能让依赖以归档参与链接」——错的。** 两个变体产出的镜像**逐字节相同**(`text 1027`),`build.ninja` 里零个 `.a`。 ⇒ 因此不拆分 `std-freestanding-nolibc` 的编译器档与库档:**能让那条边界产生收益的 机制并不存在**,拆开只多一个包名。 至此本轮被实测推翻的设计主张达 **11 条中的 10 条**,实施计划 §3 逐条记录。 * fix(docs): `[feature-deps]` 的示例版本形式解析不了⚠️ `docs/05` §2.8.2 的示例写着 `compat.openblas = "0.3.x"`。**该形式不解析。** 结尾的 `.x` 不是本解析器拥有的选择器,字面量原样送到安装器即 E_NOT_FOUND。 以索引中确定存在的包作对照: | 写法 | 结果 | |-------------------|--------------| | cmdline = "0.0.1" | 解析通过 | | cmdline = "0.0" | 解析通过 | | cmdline = "0.0.x" | **解析失败** | ⇒ 中英两版示例改为两段前缀,并补上这张对照表与它为何在此处更要紧:**实现取不回来的 feature 等于不存在的 feature**,而开发期用 path 依赖的工程根本不查索引,该失败只在 发布之后才出现。 ## 它已经造成了一次真实事故 我照抄该示例写进 `std-freestanding` 0.3.0 的 `[feature-deps]`,于是 **0.3.0 的 alloc-kal / alloc-libc 两个 feature 实际不可用**,而本地测试全绿 —— 因为它们全都用 path 依赖。已发 0.3.1 修复,0.3.0 保留(它不会分配的那半边不受影响)。 ## 另一条被实测推翻的 「feature 是消费者控制的,不开就不包含」——**中间库能替根工程打开**。实测:根不请求 `loud`,中间库请求了,根自己的翻译单元里就得到 `LOUD`。这是 `std-freestanding-nolibc` 必须独立成包而不是做成 feature 的决定性证据。 至此本轮 13 条设计主张被实测推翻 12 条,实施计划 §3 逐条记录。 * fix(manifest): 依赖版本用已有的解析器校验,而这条路径原本没用它⚠️ **解析器一直都在,而这条路径没有调用它。** `version_req::parse_req` 是决定「哪个已发布版本满足这条要求」的东西。依赖读取器却把 字符串直接交给安装器,于是一条**匹配器永远无法满足**的要求穿过网络,回来是: E_NOT_FOUND: package 'compat.std-freestanding-alloc-libc@0.1.x' not found in the synced index 它点名的是**包**,而那个包存在;不能解析的是要求。 ## 这个坏形式有三层来源 1. **文档教它** —— `docs/05` §2.8.2 的示例写着 `compat.openblas = "0.3.x"`; 2. **测试套件用它** —— `test_manifest.cpp` 的 fixture 写着 `zlib = "1.3.x"`; 3. **生态照抄它** —— 我据此写进 `std-freestanding` 0.3.0,使它的两个 alloc feature **实际不可用**。 三处没有一处会去解析它:fixture 只需要**能解析清单**,从不问索引;而开发期用 path 依赖的工程同样不查索引。⇒ **能用**在这三处证明的都是别的东西。 ## 校验用既有解析器,并钉住不许收窄 新增的检查不发明规则,它调用 `parse_req`。单测**先**钉住五种实测可解析的形式 (`0.0.1` / `0.0` / `^0.0.1` / `>=0.0.1, <0.1.0` / `*`)必须仍被接受,再钉两种坏形式 被拒 —— 这样将来任何收紧都会先在这里变红。 诊断也说清楚**什么是可接受的**,因为读者是写下一个看起来合理的东西才走到这里的。 ## 生态侧 `std-freestanding` 0.3.1 已发布并进索引(0.3.0 保留,其不分配的那半边不受影响); 三个包的 README 里可粘贴的版本行已指向可用版本。 * fix(docs): 版本形式的推荐值也是错的,判据换成「构建成功」⚠️ 上一次提交把 `docs/05` §2.8.2 的示例从 `"0.3.x"` 改成 `"0.3"`,而**两段前缀同样 不可用**:它解析通过,然后死在 `install path missing after fetch`。 我当时的判据是「没有 E_NOT_FOUND」—— 它看不见这一层。 ## 三个判据强度递增,前两个各放行一种不可用写法 本地能编过 < 没有 E_NOT_FOUND < 装得上 < 对着真实索引构建成功 中英两版的表格重写成四行,按**构建**判定: | 写法 | 结果 | |-------------------|---------------------------------------------| | cmdline = "0.0.1" | 构建通过 | | cmdline = "^0.0.1"| 构建通过 | | cmdline = "0.0" | 解析通过,随后 install path missing | | cmdline = "0.0.x" | E_NOT_FOUND,点名的是包 —— 而该包存在 | ## 单测的名字也在说谎 `DependencyVersionAcceptsEveryFormThatResolves` 里含 `"0.0"`,而它并不 resolve 到底。 改名为 `...ParserAcceptsEveryEstablishedForm`,并写明它钉的是**解析器不许收窄**, 与「能否取回」无关 —— 清单检查没有资格裁定安装器的路径推导。 生态侧:`std-freestanding` 0.3.2 已发布并进索引(0.3.0/0.3.1 保留,其不分配的那半边 不受影响)。 * fix(manifest): 版本要求改为报告而非拒绝,以及两条被实测定案的设计问题 ## 1.⚠️ 我上一次提交引入了一个回归,并且它正是我一直在防的那一类 把依赖版本校验做成**错误**,会让钉在**已发布包**上的工程在升级 mcpp 后彻底加载失败 —— 即使那条坏 entry 属于一个**没人开启的 feature**。 实测:钉 `std-freestanding = "0.3.0"` 的工程,升级后不再加载,而它用的那半边包 (不分配的部分)本来完全正常。 ⇒ 这是索引那条纪律的**镜像方向**:已发布的数据不得让运行中的程序失效,而**新程序 同样不得让已发布的数据失效**。清单检查没有资格因为一条可能从未被到达的 entry 而 否决整个包。 改为 schema warning。诊断价值完整保留 —— 它说出真实原因(「不能解析的是这条要求, 而不是那个包」),而破坏面归零。回归验证:钉 0.3.0 的工程恢复可用。 ## 2. ⭐ 「把 nolibc 也做成 std-freestanding 的 feature」—— 提法对,但状态不可达 该提法本身成立(默认关的 feature 与主动加依赖等价,且让包的表面一致),而且**我原来 反对它的理由确实站不住**:中间库无论经 feature 还是经直接依赖,都能把它注入全图。 否掉它的是一个第三方事实,实测得到: * 零 libc 档 + `std-freestanding` ⇒ **编译期**死于 `'inttypes.h' file not found` * 有 C 库 + `std-freestanding` ⇒ 编译通过,只死在已知的 T2 边界(标量 `__sort`) ⇒ **子集需要 C 库的头文件**,而 `sysroot = ""` 同时拿走头与库,没有「有头无库」 这一档。因此「用子集且缺 C 库」这个状态不可达,该 feature 会是**构造上恒不生效**的。 判据因此不是「这个设计好不好」,而是**「有没有一个用户能处在需要它的状态」**。 两个包服务于互斥的安排,已写入 `docs/13` 中英两版。 * docs: 零 libc 档上的标准库子集 —— 一个问题测了四遍才对⚠️ **前三次测量全部无效,而每一次我都以为已经有答案了。** | 次 | 做法 | 为什么无效 | |----|------|-----------| | 1 | 手搓 clang++ -nostdinc++ | 回落到**宿主 glibc** 的头 | | 2 | 加 -nostdlibinc | 载荷 clang.cfg **无条件注入** -isystem <宿主 glibc>,仍穿透 | | 3 | 由此得出「32/103」 | 该数字量的是载荷配置,不是零 libc | | 4 | ⭐ **用 mcpp 本身构建**(它发 --no-default-config) | 有效 | ⇒ **判据必须走被测系统本身。** 手搓的命令行不是 mcpp 的编译行,而两者的差别恰好就是 这个问题的答案所在。 ## 正确测量的结果 * 零 libc 档上**编译器自带的** freestanding C 头都在(stdint/stddef/stdarg/ limits/float),缺的只是真正的 libc 头(string.h/stdio.h); * 21 个 tier-0 头里 **15 个直接可编**(array/span/expected/bit/charconv/ concepts/type_traits/tuple/utility/compare/limits/numbers…); * 余下 6 个的真因是 **libc++ 自带的 C 头包装头**靠 #include_next 到 C 库取 size_t,没有 C 库时断链; * 一份最小 string.h shim(⚠️ 必须放在 libc++ **之后**,否则 #include_next 跳过它) 解锁 optional 与 coroutine;string_view 还差 mbstate_t,atomic 还差 time_t。 ## 因此上一次的「不可达」判断也是错的 我据一次测错的探针写下「用子集且缺 C 库的状态不可达」,并据此否掉了把 nolibc 做成 feature 的提法。**正确测量后它可达** —— 但代价是让 std-freestanding-nolibc 从 「五个函数」长成「五个函数 + libc++ 真正需要的那一小组 C 头」。⚠️ 那是一个独立项目而不是一个 feature:**一份写错的 mbstate_t 不会报错,只会让类型 静默不匹配。** 本轮不做,记为后续项。 * fix(manifest): 把版本校验助手移出模块接口 —— 它毒化了下游的 BMI⚠️ **两个 Windows job 因此稳定变红,而报错点名的是我从未碰过的测试文件。** 助手原本位于模块 purview 的命名空间作用域,因此它的声明属于本模块接口记录的内容。 它的返回类型是 `std::optional<std::string>`,而在 clang + MSVC 标准库下,这一点足以 让**每一个构造该类型的下游翻译单元**编不过: MSVC\include\optional:307: error: no matching constructor for initialization of '_SMF_control<_Optional_construct_base<basic_string… * 报错点名 `test_scaffold.cpp` / `test_modgraph.cpp` —— 本次改动**从未触碰**它们; * Linux 侧全程绿。 这正是本仓库记过的形状:**新导出的模块接口里出现 std 类型,会毒化导入者的模块文件, 而不是在写下它的地方失败。** 文件外没有任何东西调用它,因此文件外也不应该看得见它 —— 移入匿名命名空间。⚠️ 我最初把这两个 job 读成 flake:同一个 SHA 上多数 run 显示 success。那是**不同的 workflow**,不是同一个 job。判据必须按 job 名对齐,不能按 SHA 数颜色。 * feat(scaffold): 模板可以拒绝自依赖,因为有一个模板必须拒绝⚠️ **`mcpp new k --template riscv-virt-rt:nolibc` 之后 `mcpp run` 直接失败**, 而那个模板的全部意义就是不依赖任何东西。 自依赖注入的存在是为了让模板不与它所属的库脱节,这对几乎所有模板都对。它对 「主题就是不依赖任何东西」的模板是错的:板级包自己的模块 `#include <stdio.h>`, 于是在一个**清单里没有任何依赖**的工程上,报错是 riscv_virt_rt.cppm:9: fatal error: 'stdio.h' file not found ⇒ `[template.inject] self = false`。布尔形式拒绝注入,既有的表形式(携带 features) 不受影响,默认仍然是开 —— 三个单测分别钉住这三种情形,其中「默认为开」那条是防止 将来悄悄翻转:每个没有声明的模板都依赖它。 ## 生态侧同批修复(riscv-virt-rt 0.4.1) 板级包此前把「没有 C 库」当成错误。它不是:处在零 libc 档的工程没有在向这块板子要 任何东西。现在该路径不再报错。⚠️ 但**模拟器照常发** —— 哪个模拟器能启动这台机器是板级事实,与哪份 C 库编出的镜像 无关。我第一版把它和其余部分一起早退掉了,那会让这类工程没有 runner。 实测:生成后 **362 字节**,qemu 中打印 `k: running with no C library` / `answer 42`。 ##⚠️ 这是本轮唯一一次发布之后才发现的缺陷 0.4.0 的模板在索引里可见、可生成,而生成出来的工程跑不起来。发现它的方式是**用发布后 的包从零走一遍新用户流程** —— 与上一轮 `.3` 那次同一个方法,也同一个教训: **「我这儿能跑」用的从来不是新用户的路径。** * fix(manifest): 导出接口里的 optional<string> 会毒化导入者的模块文件 Windows 上四个单测编译失败,而它们指名的文件这次改动一个都没碰: test_modgraph、test_object_address、test_scaffold、test_xpkg_host_tools。 报错落在标准库里: MSVC\include\optional:307: error: no matching constructor for initialization of '_SMF_control<_Optional_construct_base<basic_string… 真因是 `TargetEntry::sysroot` 的类型。ABSENT 与 EMPTY 是两个不同的 答案——缺席继承目标行,`sysroot = ""` 是零 libc 档——所以单一 string 承载不了,第一版写成了 `std::optional<std::string>`。 `std::optional<std::string>` 作为导出结构体的**数据成员**,会强制这个 模块的接口实例化该特化的特殊成员函数机制;clang 配 MSVC 标准库时, 这份机制毒化了每一个构造它的下游翻译单元。同一个类型此前已经作为 **返回类型**出现在这个模块里且相安无事——是成员位置逼出了实例化。 两个普通成员承载同样的信息,且不实例化任何东西。同理, `effective_sysroot` 的覆盖参数改为指针:三态语义不变,导出接口里 不再有该特化。 上一次把嫌疑判给了 `version_req_problem`(已移入匿名命名空间,那一步 本身是对的),但换成匿名命名空间后 Windows 仍以同样的报错失败——排除 了那条,才落到成员上。 本机是 GCC 配 libstdc++,复现不了这条路径;六个受影响的单测本机全绿, MSVC 侧由 CI 判定。 * fix(manifest): 消掉最后一处 optional<string>,不再赌一轮 CI 上一次把嫌疑判给 `version_req_problem` 并把它移进匿名命名空间, Windows 仍以同样的报错失败;那证明它不是唯一的因,却没有证明它无辜。 成员那一处已经修好,但两个候选一次只排除一个,每轮要四十分钟。 `version_req_problem` 现在返回 `std::string`,空串表示没有问题。调用侧 不需要区分「没有问题」与「问题是空串」,所以不损失任何东西,而这个模块 的新代码里再没有 `std::optional<std::string>`——下一轮 CI 的结论因此 不含歧义。 --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
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.
The freestanding std subset was described in the phase-3 wrap-up as "a separate
piece of work". That was wrong, and the research it contradicted had already
measured it. Reproduced here on the shipped payloads: with a synthesised
__config_site, 103 of libc++'s 110 headers compile for riscv64-none-elf, andall 7 that fail also fail on an x86_64 host with full libc++ and glibc — they
are headers libc++ has not implemented. The freestanding compile-time loss is
zero.
-nostdinc++is the MECHANISM of that route, not an obstacle: it keepslibc++'s headers private to the subset package, which exports a module — the
same shape the board package already uses for the target's C library.
What actually blocked it was exceptions, and that is a whole-graph property:
std::optional::value()alone pulls in__cxa_throw, two vtables andstd::bad_optional_access::~bad_optional_access, none of which can existwithout an unwinder or libc++abi.
[build] cxxflagscannot fix it: they do not reach adependency's module compile, so the BMI and the importer disagree and clang
reports a
.pcm"configuration mismatch" — naming a module file ratherthan the setting that split the graph.
is_dialect_flagdeliberately does NOT propagate-fno-exceptions, on thegrounds that "dependencies may assume exceptions are available". Right for
a hosted target, exactly backwards here.
So
-fno-exceptions -fno-rttijoin-ffreestandingand-nostdinc++on thetarget spec, which is the one place that reaches every unit in the graph.
upgrade. The dependency cache key carries the target TRIPLE but not the flags
the triple implies — and which flags those are is mcpp's decision, so it moves
between versions while the triple string does not. A project that had built
once got its old BMI back and a hard failure naming a .pcm. Fixed by adding a
targetImpliedFlagsaxis, empty for hosted targets so no existing key moves.e2e/133 pins the whole chain in the emulator, from both sides: the subset
builds, runs
ranges::sortwith a projection plus optional/atomic/span/string_view on the target, and
std::mutexfails to COMPILE — capabilitiesturned off in
__config_sitevanish rather than leaving a run-time stub.The
import stddiagnostic namesstd-freestandingagain, now that thepackage is published.
Ships as 2026.8.19.4, alongside
mcpplibs/std-freestanding0.1.0.