From 968377c9f41e9fea2161671fb0dccdb64cfc3016 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:17:28 +0800 Subject: [PATCH 01/20] docs: a bare-metal chapter, and the diagnostic promise it found broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 8 + docs/05-mcpp-toml.md | 5 + docs/08-toolchain-internals.md | 53 ++-- docs/13-baremetal.md | 493 ++++++++++++++++++++++++++++++ docs/README.md | 1 + docs/zh/05-mcpp-toml.md | 4 + docs/zh/08-toolchain-internals.md | 45 +-- docs/zh/13-baremetal.md | 456 +++++++++++++++++++++++++++ docs/zh/README.md | 1 + src/build/prepare.cppm | 15 +- 10 files changed, 1041 insertions(+), 40 deletions(-) create mode 100644 docs/13-baremetal.md create mode 100644 docs/zh/13-baremetal.md diff --git a/README.md b/README.md index 54d1b59f..2c660390 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,8 @@ the right toolchain payload is resolved and installed automatically. | `x86_64-windows-gnu` | gcc 16 MinGW-w64 — native on Windows, cross from Linux (wine-verified) *(Windows default without Visual Studio)* | ✅ | | `x86_64-windows-msvc` | `msvc@system` (detected VS/BuildTools) or llvm ¹ *(Windows default with Visual Studio)* | ✅ | | `aarch64-macos` | llvm *(macOS default)* | ✅ | +| `riscv64-none-elf` | llvm 22 — bare metal, no OS; needs no per-host cross payload ² | ✅ | +| `riscv32-none-elf` | llvm 22 — bare metal, no OS; needs no per-host cross payload ² | ✅ | | `riscv64-linux-musl` | — | 🔄 | | `aarch64-linux-gnu` | — | 🔄 | | `x86_64-macos` | — | 🔄 | @@ -400,6 +402,12 @@ the right toolchain payload is resolved and installed automatically. > or configure; `mcpp new && mcpp build` just works on a stock Windows box. > An explicit `[toolchain]` in `mcpp.toml` is always respected as written — > mcpp revises its own default, never yours. +> +> ² The bare-metal rows carry no operating system: clang and lld are +> cross-compilers by construction, so any host that can install the LLVM +> payload produces these targets. The C library, startup code, memory layout +> and emulator travel with a board-support package rather than with mcpp — see +> [docs/13 — Bare-Metal and Freestanding Targets](docs/13-baremetal.md). ## Documentation diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 925c6a26..f5f329f2 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -904,6 +904,11 @@ underneath. They need no per-host cross toolchain: clang and lld are cross-compilers by construction, so any host that can install the llvm payload can produce them. +This section is the manifest reference. The worked examples — scaffolding, +running, testing on the target, the freestanding standard-library subset and +writing a board-support package — are in +[13 — Bare-Metal and Freestanding Targets](13-baremetal.md). + ```bash mcpp build --target riscv64-none-elf mcpp run --target riscv64-none-elf # via [target.].runner diff --git a/docs/08-toolchain-internals.md b/docs/08-toolchain-internals.md index c6d1d17d..44b648d6 100644 --- a/docs/08-toolchain-internals.md +++ b/docs/08-toolchain-internals.md @@ -528,27 +528,38 @@ The machinery is already arch-parameterized; the work is data: 3. nothing else: `-B`/`-L`/loader emission, the fixup pipeline, and the hermetic check are all name-agnostic. -### 7.3 Embedded / bare-metal toolchains (outlook) - -The model extends naturally to `arm-none-eabi`-class toolchains because the -hard parts of the hosted world *disappear* rather than multiply: - -- **No dynamic linker**: `loader` stays empty — already legal everywhere - (renderers omit `--dynamic-linker`; the pack/deploy story is flashing, not - ELF interp). -- **No glibc payload**: newlib/picolibc live inside the toolchain's own - sysroot ⇒ `CLibMode::Sysroot`, the exact mode self-contained musl uses - today. `is_musl_target`-style self-containment detection generalizes to a - capability flag ("ships own C library"). -- **Fixup kind = none or gcc-like** depending on how the payload is built - (a cross gcc payload still wants PT_INTERP/RUNPATH alignment for the - *host-run* compiler binaries — that part is identical to today's gcc kind; - the *target* side needs nothing). -- **Hermetic check** generalizes: assert crt0/semihosting stubs resolve - inside the toolchain payload instead of Scrt1.o/loader. -- What genuinely needs new design: per-target `[target.'cfg(...)']` specs - for MCU flags (`-mcpu`, `--specs=nosys.specs`), linker-script handling, - and a run/flash story — build-graph concerns above this document's layer. +### 7.3 Embedded and bare-metal toolchains + +`riscv64-none-elf` and `riscv32-none-elf` are implemented, and the user-facing +account is [13 — Bare-Metal and Freestanding Targets](13-baremetal.md). This +section records how the resulting shape relates to the hosted model above. + +Three of this section's earlier predictions held: + +- **No dynamic linker.** `loader` stays empty, which every renderer already + permitted; the deployment story is flashing rather than ELF interp. +- **The target side needs no fixup.** Host-run compiler binaries still want + PT_INTERP/RUNPATH alignment, identical to today's gcc kind. +- **MCU flags, linker-script handling and a run story genuinely needed new + design.** All three landed, and above this document's layer as predicted: + ISA flags come from a one-row-per-target table in + `src/freestanding/target.cppm`, the linker script arrives through the + `link-script` build directive, and execution through `runner`. + +One prediction was wrong, and the correction is the load-bearing part of the +design. The C library does **not** live inside the toolchain payload, so this +is not `CLibMode::Sysroot` with a different sysroot. picolibc is a separate +payload named by the target's own table row +(`sysroot = xim:picolibc-riscv@1.8.12` in `src/toolchain/triple.cppm`), on the +same footing as that row's compiler `pin`. Resolving it from the target rather +than from the toolchain is what keeps a bare-metal *package* from having to +name a libc, exactly as a hosted package never names glibc. + +The freestanding link line is also **replaced** rather than extended — see +`src/freestanding/linkline.cppm` — because every hosted link flag is wrong +there rather than merely unnecessary. Anything appended to the ordinary ldflags +earlier in the pipeline is discarded, which is why the target sysroot's `-L` +is emitted on that line and not with the generic flags. ### 7.4 Non-ELF platforms diff --git a/docs/13-baremetal.md b/docs/13-baremetal.md new file mode 100644 index 00000000..6e3e49d3 --- /dev/null +++ b/docs/13-baremetal.md @@ -0,0 +1,493 @@ +# 13 — Bare-Metal and Freestanding Targets + +This document describes how mcpp builds, runs and tests software for targets +with no operating system underneath, and how a board-support package supplies +the parts of such a target that the engine deliberately does not know. + +Related documents: [05 — mcpp.toml Manifest Guide](05-mcpp-toml.md) §2.7.2 is +the reference for the `[target.]` keys used here; [07 — build.mcpp +Build Program](07-build-mcpp.md) is the reference for the directive protocol a +board-support package speaks; [08 — Toolchain Internals](08-toolchain-internals.md) +covers the hosted link model this chapter departs from. + +## Overview + +A freestanding target is a target whose `os` field is `none`. mcpp treats two +of them as verified: `riscv64-none-elf` and `riscv32-none-elf`, both listed in +the target table at `src/toolchain/triple.cppm`. + +Such a target needs no per-host cross toolchain. clang and lld are +cross-compilers by construction — one binary emits every target it was built +with — so the target table pins `llvm@22.1.8` on every host, and any machine +that can install the LLVM payload can produce a RISC-V image. + +Three things a bare-metal build requires are not properties of the ISA, and +mcpp does not attempt to derive them: which startup object and libraries to +select, which linker script describes the machine's memory, and how to execute +the resulting image. These travel with a **board-support package**, an ordinary +dependency. The consequence is that changing boards is a dependency change +rather than a build-system change. + +## The shortest path to a running image + +Two commands produce a booting image, with no linker script, load address or +emulator invocation written by hand: + +```bash +mcpp new blinky --template riscv-virt-rt +cd blinky +mcpp run +``` + +Measured output: + +``` + Resolving toolchain + Resolved llvm@22.1.8 → riscv64-none-elf → @mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++ + Resolved host toolchain for build.mcpp: clang 22.1.8 (x86_64-unknown-linux-gnu) + build.mcpp compiling + build.mcpp running + Inferred sources [src/**/*.{cppm,cpp,cc,c,S,s,asm}] + Inferred target blinky (bin from src/main.cpp) + Compiling blinky v0.1.0 (.) + Cached riscv-virt-rt v0.3.0 (1 unit) + Finished dev [unoptimized + debuginfo] in 0.05s + Size blinky text 8572 data 80 bss 5668 total 14320 + Running `…/xim-x-qemu-riscv/9.2.4-1/bin/qemu-system-riscv64 … target/riscv64-none-elf/…/bin/blinky` + +hello from blinky +float 3.1416 +heap ok +``` + +The `Size` line is printed after every freestanding link. Capacity is the +governing constraint on a bare-metal target, and the number is already known +the moment the link finishes; omitting it would require every project to run +`size` separately. It is silent on hosted targets and whenever the tool is +absent, because an informational line has no standing to fail a build. + +### The generated project + +The entire manifest is four declarations: + +```toml +[package] +name = "blinky" +version = "0.1.0" + +[build] +target = "riscv64-none-elf" + +[dependencies] +riscv-virt-rt = "0.3.0" +``` + +There is no `[target.*]` section, no linker script path, no load address, no +`-nostdlib`, no `-march`/`-mabi`/`-mcmodel`, no crt0, no C library name and no +emulator command line. The ISA flags come from the engine's target table; the +remainder comes from the board-support package named in `[dependencies]`. + +The generated `src/main.cpp` is an ordinary `main`: + +```cpp +import mcpplibs.riscv_virt_rt; + +extern "C" int main() { + board::println("hello from blinky"); + board::printf("float %.4f\n", 3.14159); + void* p = board::alloc(64); + board::println(p ? "heap ok" : "heap FAILED"); + board::release(p); + return p ? 0 : 1; +} +``` + +No `_start` and no assembly entry point are required, because the board-support +package selects picolibc's semihosting `crt0` — the C runtime is initialised +before `main` runs, and the return value reaches the host through semihosting. +Only a board with no C library at all needs an explicit entry point, declared +by pointing `main` at the source file that carries `_start`. + +## What a freestanding target changes + +| Aspect | Behaviour on a freestanding target | +|---|---| +| Link line | Built from nothing rather than extended: `-nostdlib -nostartfiles -static`, with no crt files, no dynamic linker and no C++ runtime. Appending `-nostdlib` to a hosted line would rely on the driver discarding the earlier flags in the right order. | +| Linker selection | `ld.lld` is addressed by **absolute path**, derived from the driver's own directory. `-fuse-ld=lld` resolves by name and finds GNU ld on any machine with binutils earlier on `PATH`, which then fails with `unrecognised emulation mode: elf64lriscv`. | +| ISA flags | `-march`, `-mabi` and `-mcmodel` come from one row per target in `src/freestanding/target.cppm`, so `--target ` alone is sufficient to produce a correct object file. | +| C library | The **target's**, resolved by mcpp from the target's own table row exactly as the compiler is. A bare-metal project declares no libc, just as a hosted project declares no glibc. The engine places the sysroot's library directory on the link search path, so a board-support package selects out of it by bare name (`-lc`, `-lcrt0-semihost`). | +| Exceptions and RTTI | Off on every translation unit in the graph, including a dependency's. There is no unwinder and no `libc++abi`, so nothing can throw; `std::optional::value()` alone would otherwise reference `__cxa_throw` and three further undefined symbols. The setting belongs to the target rather than to a project's `cxxflags` because a BMI records it, and a dependency compiled with exceptions cannot be imported by a unit without them. | +| `import std` | Unavailable, and rejected at configure time with a diagnostic rather than at link time. | +| Entry point | `int main()` is available whenever something supplies a `crt0`. A board-support package normally does. | +| Default linkage | Static, and not as a preference: there is no loader, so there is no other option. | + +A project with no dependencies at all still builds, which is the evidence that +the ISA row alone is sufficient: + +``` + Size norunner text 12 data 0 bss 0 total 12 +``` + +## The layering: engine, target and board-support package + +The division of responsibility is a single sentence: **location is a target +fact, selection is a board fact.** + +| Layer | Owns | Example | +|---|---|---| +| Engine | The ISA profile, the freestanding link line, the artifact set, the single read point for how an artifact is executed | `-march=rv64gc -mabi=lp64d -mcmodel=medany -ffreestanding` | +| Target | Which compiler and which C library, both resolved from the target's row and installed on demand | `pin = llvm@22.1.8`, `sysroot = xim:picolibc-riscv@1.8.12` | +| Board-support package | Which startup object and libraries to select, which linker script, which emulator invocation | `-lcrt0-semihost`, `picolibcpp.ld`, `qemu-system-riscv64 -machine virt …` | + +The middle row is what keeps a package from having to name a C library. +Earlier versions of both ecosystem packages declared +`[xlings] deps = ["xim:picolibc-riscv@1.8.12"]`, which bound a package to one +libc, one architecture and one compiler implementation. That declaration is no +longer required, and the target's sysroot column replaced it. + +A second board on the same ISA is a change of the three values in the bottom +row. It requires no change to the engine. + +## Worked examples + +Every transcript in this section was measured with mcpp 2026.8.20.1 on +`x86_64-linux-gnu`; see [Verification scope](#verification-scope). + +### Switching ISA width + +The same sources and the same board-support package serve both widths: + +```bash +mcpp run --target riscv32-none-elf +``` + +``` + Size blinky text 10412 data 48 bss 5400 total 15860 +hello from blinky +float 3.1416 +heap ok +``` + +Neither the project's sources nor the board-support package changed. The +package selects its profile from `MCPP_TARGET_ARCH`, and the ISA parameters +come from the engine's table, which is data rather than code — supporting a +further width is one row. + +### The freestanding standard-library subset + +`import std` is one module over the entire library, threads, filesystem and +iostreams included, so there is no subset of it to build without an operating +system. An ordinary dependency carries the parts that need no OS: + +```toml +[dependencies] +riscv-virt-rt = "0.3.0" +std-freestanding = "0.2.0" +``` + +```cpp +import mcpplibs.riscv_virt_rt; +import mcpplibs.std.freestanding; // not `import std;` + +struct Task { int prio; const char* name; }; + +extern "C" int main() { + std::array tasks{{ {3,"c"}, {1,"a"}, {4,"d"}, {2,"b"} }}; + std::ranges::sort(tasks, {}, &Task::prio); + for (const auto& t : tasks) board::printf("%s", t.name); + board::println(""); + + std::optional o = 41; + std::atomic a{0}; + a.fetch_add(o.value() + 1); + board::printf("atomic %d\n", a.load()); + + std::span s{tasks}; + std::string_view sv{"ok"}; + board::printf("span %zu %s\n", s.size(), sv.data()); + return 0; +} +``` + +Measured output and size: + +``` + Size blinky text 19564 data 72 bss 5632 total 25268 + +abcd +atomic 42 +span 4 ok +``` + +The subset covers 103 of the 110 `std/*.inc` headers the LLVM 22.1.8 payload +ships — counted in both trees on 2026-08-20 — and it is generated by mechanical +selection rather than written as an export list. The 7 it omits are reported by +the package to fail on a hosted `x86_64` as well; that report was not +re-measured here. Available entities include `array`, `span`, `optional`, `expected`, +`atomic`, `string_view`, `ranges`, `algorithm`, `bit`, `charconv`, `concepts`, +`type_traits`, `tuple`, `utility` and coroutines. + +What the subset excludes is excluded at compile time rather than at run time: + +```cpp +std::mutex m; +``` + +``` +error: no type named 'mutex' in namespace 'std' +``` + +### Running tests on the target + +`mcpp test` builds one image per `tests/*.cpp`, runs each under the emulator +the board-support package supplies, and reads the exit code as the verdict. +Semihosting propagates the firmware's `main` return value to the emulator's +exit code, so the model is identical to a hosted test run. + +```bash +mcpp test +``` + +``` + Compiling boots (test) + Running bin/boots +boots: console +boots ... ok (0.02s) + + test result ok. 1 passed; 0 failed; finished in 0.58s (build 0.05s + run 0.02s) +``` + +A failing case is named, and the command's exit status is non-zero: + +``` +deliberate_fail ... FAIL (exit 1, 0.02s) +about to fail +boots ... ok (0.02s) +boots: console + +error: test result: FAILED. 1 passed; 1 failed; finished in 0.24s (build 0.04s + run 0.02s) + +failures: + deliberate_fail +``` + +### The artifact set for flashing + +A freestanding link produces three files rather than one: + +``` +target/riscv64-none-elf//bin/blinky 91640 bytes ELF, for a debugger or `qemu -kernel` +target/riscv64-none-elf//bin/blinky.bin 8664 bytes flat image, what a flasher accepts +target/riscv64-none-elf//bin/blinky.map 253369 bytes link map +``` + +The flat image is produced by an `objcopy -O binary` edge derived from the same +payload as the driver. The map is an implicit output of the link edge rather +than a bare `-Wl,-Map=` flag, so deleting it causes it to be regenerated; it is +the only artifact that answers why a section is where it is, and why something +was or was not pulled in from an archive. + +### Overriding the runner a board-support package supplies + +A board-support package normally supplies the runner. A project may override +it, which is the ordinary precedence — what the author of the project wrote +beats what a dependency supplied: + +```toml +[target.riscv64-none-elf] +runner = ["qemu-system-riscv64", "-machine", "virt", "-nographic", + "-no-reboot", "-bios", "default", + "-s", "-S", # wait for a debugger on the first instruction + "-kernel"] +``` + +The override is reported rather than applied silently: + +``` + note [target.riscv64-none-elf].runner overrides the runner a dependency supplied +``` + +The artifact path is appended to the template, or substituted for `{}` when the +template contains that token. Appending is the common shape, because `-kernel +` ends the line. + +mcpp ships no default runner. Which emulator, which machine model and which +firmware mode are board facts — two boards on the same ISA need different argv, +`-bios default` for an OpenSBI boot against `-bios none -semihosting` for a +picolibc image — so an engine that guessed one would have to be fought by the +other. + +## Diagnostics + +### `import std` on a freestanding target + +``` +error: `import std;` is not available on 'riscv64-none-elf' — a freestanding target has no hosted standard library. + `std` is one module over the entire library (threads, filesystem, iostreams + included), so there is no subset of it to build without an OS underneath. + Use the freestanding subset instead — an ordinary dependency carrying + the parts of the library that need no OS (array, span, optional, atomic, + string_view, ranges, expected, charconv, coroutines): + + [dependencies] + std-freestanding = "0.2.0" + + then `import mcpplibs.std.freestanding;` in place of `import std;`. + The target's C library itself comes from the BOARD package (riscv-virt-rt + exports `mcpplibs.riscv_virt_rt`). +``` + +The message is emitted at configure time. Reporting a missing `std` module +source instead would send the reader to look for a broken payload, when nothing +is missing from the toolchain. + +### An absent runner + +A freestanding artifact cannot execute on the build machine: it has the wrong +ISA, no loader, and expects to own the address space. When no runner is +configured, `mcpp run` reports the gap between a successful build and an +impossible execution: + +``` +error: no runner is configured for 'riscv64-none-elf' — a freestanding artifact cannot execute on this machine. + Declare how to run it: + + [target.riscv64-none-elf] + runner = ["qemu-system-riscv64", "-machine", "virt", + "-nographic", "-no-reboot", "-bios", "default", "-kernel"] + + The artifact path is appended, or substituted for `{}` if the template contains it. + A board-support package normally supplies this so you do not have to. +``` + +## Writing a board-support package + +A board-support package is an ordinary mcpp package. It declares the emulator +it needs under `[xlings] deps`, exports one C++ module for consumers, and emits +its board facts from `build.mcpp`. + +### The directives a board-support package emits + +| Directive | Effect | +|---|---| +| `mcpp:link-lib=` | Adds `-l` to the consumer's link line. Bare names suffice: the target sysroot's library directory is already on the search path. | +| `mcpp:link-search=` | Adds `-L`, for a library the package carries itself. | +| `mcpp:link-script=` | Adds `-T `. A relative path resolves against the package root, so a script belonging to the target's C library must be named absolutely. | +| `mcpp:runner=` | Appends one argv token to the run template. argv is ordered, so the template is built by repetition — one directive line per token. | +| `mcpp:include-dir=` | Adds an include directory **for this package only**. | + +Two engine queries supply the paths a board-support package must not hardcode: +`mcpp::sysroot_dir()` returns the target's C library root, and +`mcpp::xpkg_dir(ns, name)` returns an installed payload's directory. +`mcpp::target_arch()` reports the architecture being built for. + +### Directive scope and what reaches a consumer + +The scopes are deliberately asymmetric, and the asymmetry is what allows the +engine to work without a sysroot concept in the directive layer at all: + +| Scope | Directives | Reaches the consumer | +|---|---|---| +| `LinkGlobal` | `link-lib`, `link-search`, `link-script` | Yes | +| `RunGlobal` | `runner` | Yes | +| `PackagePrivate` | `include-dir`, `include-dir-after` | No | + +A board-support package therefore includes the target's C headers privately and +exports what it wants visible as a C++ module. Consumers import that module; +they do not inherit an include path. + +### A complete build.mcpp + +The board-support package for QEMU's RISC-V `virt` machine is reproduced below +with its comments removed. It is the whole of the board's build logic: + +```cpp +import mcpp; +import std; + +int main() { + const std::string arch = mcpp::target_arch() ? mcpp::target_arch() : ""; + const bool rv32 = (arch == "riscv32"); + + // Selected out of the target's C library, by bare name. + mcpp::link_lib("crt0-semihost"); + mcpp::link_lib("c"); + mcpp::link_lib("semihost"); + mcpp::link_lib(std::format("clang_rt.builtins-{}", + rv32 ? "riscv32" : "riscv64").c_str()); + + // This machine's memory layout, asked for rather than declared. + if (const char* sysroot = mcpp::sysroot_dir(); sysroot && *sysroot) + mcpp::link_script(std::format("{}/lib/{}/picolibcpp.ld", sysroot, + rv32 ? "rv32imac/ilp32" : "rv64gc/lp64d").c_str()); + + // How to run an image. The emulator is named by absolute path, because a + // bare name resolves through PATH to a shim that dispatches against its + // own owner home rather than the home this build uses. + if (const char* qemu = mcpp::xpkg_dir("xim", "qemu-riscv"); qemu && *qemu) { + mcpp::runner(std::format("{}/bin/qemu-system-{}", qemu, + rv32 ? "riscv32" : "riscv64").c_str()); + for (auto a : {"-machine", "virt", "-nographic", "-no-reboot", + "-semihosting", "-bios", "none", "-kernel"}) + mcpp::runner(a); + } + + mcpp::rerun_if_env_changed("MCPP_TARGET_ARCH"); + return 0; +} +``` + +The package's manifest declares the emulator and nothing else: + +```toml +[xlings] +deps = ["xim:qemu-riscv@9.2.4-1"] +``` + +Linking `clang_rt.builtins` is not optional on this board. picolibc formats +floating-point values through ryu, which performs 128-bit shifts, and rv64 has +no instruction for them; without the builtins the link fails on `__ashlti3` and +`__lshrti3`. A check for 64-bit division does not reach this case, because +rv64gc has a hardware `divu`. + +An mcpp older than the one a package was written for cannot be detected from +within the package: `if constexpr (requires { mcpp::runner("x"); })` is a hard +error on an unknown qualified name rather than `false`, so the language offers +no feature test here. The engine compensates by appending an upgrade note when +a `build.mcpp` fails to compile with an error naming a non-member of `mcpp`. + +## Verification scope + +The commands and outputs in this document were measured on 2026-08-20 with: + +| Component | Version | +|---|---| +| mcpp | 2026.8.20.1, built from this repository | +| Host | `x86_64-linux-gnu` | +| Toolchain | `xim:llvm` 22.1.8 | +| Target C library | `xim:picolibc-riscv` 1.8.12 | +| Emulator | `xim:qemu-riscv` 9.2.4-1 | +| Board-support package | `mcpplibs:riscv-virt-rt` 0.3.0 | +| Standard-library subset | `mcpplibs:std-freestanding` 0.2.0 | + +Sizes vary between mcpp versions: the same project measured `text 8844` under +2026.8.19.4 and `text 8572` under 2026.8.20.1. The numbers above are therefore +illustrative of magnitude rather than fixed values. + +The bare-metal chain has continuous verification on Linux only. Engine-side CI +runs a `baremetal` job covering four end-to-end scripts, and the two ecosystem +packages run RISC-V 64 and 32 under QEMU in their own repositories — all on +`ubuntu-24.04`. macOS and Windows hosts are expected to work, because the +payload is a cross-compiler and `xim:qemu-riscv` publishes assets for five host +targets, but that expectation is **not** covered by a test. + +## Current limitations + +| Limitation | Observed behaviour | +|---|---| +| `std::format`, `std::sort` over builtin scalar types, and a complete `std::string` | Fail at **link** time naming the undefined symbol. libc++ places these entities in the compiled library — the scalar `__sort` instantiations are `extern template`, with no macro that disables them — so a target-built `libc++.a` is required. No such payload is published. | +| Exceptions and RTTI | Disabled across the whole graph. `try`/`catch` is unavailable at compile time. A board shipping a target-built `libc++abi` and unwinder has a genuine case for re-enabling them; that is the point at which this becomes a manifest key. | +| Board coverage | One board family. `riscv32-none-elf` demonstrates that the ISA table is data, not that a second machine has been ported. ARM Cortex-M has not been attempted. | +| C library substitution | `[target.]` accepts `toolchain`, `linkage`, `runner` and `cxx_runtime`, but has **no `sysroot` key**. Replacing picolibc with newlib is therefore not expressible in a project's manifest today. | +| `qemu-riscv` on `win32-arm64` | The upstream package publishes no asset for that host, so installation fails on it. The failure is correct rather than silent, but the host cannot run a bare-metal image. | +| Ecosystem CI breadth | The two ecosystem packages run their own CI on `ubuntu-24.04` only. mcpp-index's `tests/examples/` workspace members run unconditionally on three platforms with no capability gate, so a package requiring an emulator and a target sysroot cannot be added there. This is a known coverage gap. | diff --git a/docs/README.md b/docs/README.md index 369af822..a6739ef9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ - [10 - Publishing a Library to mcpp-index](10-publishing-a-library.md) - [11 - Machine-Readable Output](11-machine-output.md) - [12 - Distributing a Prebuilt Library](12-binary-distribution.md) +- [13 - Bare-Metal and Freestanding Targets](13-baremetal.md) ## Specifications diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 31bae2da..0e0c8706 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -802,6 +802,10 @@ cxxflags = ["-march=x86-64-v2"] 逐宿主的交叉工具链:clang 与 lld 天生是交叉编译器,任何能装 llvm 载荷的宿主都能 产出它们。 +本节是清单参考。示例部分 —— 生成工程、运行、在目标上测试、freestanding 标准库 +子集,以及编写板级支持包 —— 在 +[13 — 裸机与 freestanding 目标](13-baremetal.md)。 + ```bash mcpp build --target riscv64-none-elf mcpp run --target riscv64-none-elf # 经 [target.].runner diff --git a/docs/zh/08-toolchain-internals.md b/docs/zh/08-toolchain-internals.md index 3f20ae8f..dd329c52 100644 --- a/docs/zh/08-toolchain-internals.md +++ b/docs/zh/08-toolchain-internals.md @@ -429,24 +429,33 @@ loader;`/bin/sh` 有事:它的 `PT_INTERP` 指向**宿主** loader,而且任何 3. 其余什么都不用做:`-B`/`-L`/loader 的发射、fixup 管线、hermetic 校验全部 与名字无关。 -### 7.3 嵌入式 / 裸机工具链(展望) - -模型可以自然延伸到 `arm-none-eabi` 一类工具链,因为 hosted 世界的难点在这里 -是**消失**而不是加倍: - -- **无动态链接器**:`loader` 保持为空——全链路本来就允许(渲染器自动省略 - `--dynamic-linker`;部署故事是烧录,不是 ELF interp); -- **无 glibc payload**:newlib/picolibc 在工具链自己的 sysroot 里 ⇒ - `CLibMode::Sysroot`,与今天自包含 musl 完全同一模式。`is_musl_target` 式的 - 自包含判定应泛化为能力标志("自带 C 库"); -- **fixup kind = 无或 gcc 式**,取决于 payload 怎么打包(交叉 gcc 的 - **宿主运行**的编译器二进制仍需要 PT_INTERP/RUNPATH 对齐——与今天的 gcc kind - 完全相同;**目标侧**什么都不需要); -- **hermetic 校验**可泛化:断言 crt0/semihosting stub 解析在工具链 payload 内, - 替代 Scrt1.o/loader; -- 真正需要新设计的:MCU flags 的 per-target 规格(`-mcpu`、 - `--specs=nosys.specs`)、链接脚本处理、运行/烧录故事——那些是本档之上的 - 构建图层面。 +### 7.3 嵌入式与裸机工具链 + +`riscv64-none-elf` 与 `riscv32-none-elf` 已实现,面向用户的说明见 +[13 — 裸机与 freestanding 目标](13-baremetal.md)。本节记录由此得到的形态与 +上文 hosted 模型之间的关系。 + +本节早先的三条预测成立: + +- **无动态链接器。** `loader` 保持为空,这一点每个渲染器本来就允许;部署路径是 + 烧录而不是 ELF interp。 +- **目标侧不需要 fixup。** 宿主运行的编译器二进制仍需要 PT_INTERP/RUNPATH 对齐, + 与今天的 gcc kind 完全相同。 +- **MCU flags、链接脚本处理与运行路径确实需要新设计。** 三者都已落地,且如预测 + 那样位于本文档之上的层次:ISA 参数来自 `src/freestanding/target.cppm` 中每个 + 目标一行的表,链接脚本经 `link-script` 构建指令到达,执行方式经 `runner` 到达。 + +有一条预测是错的,而这条更正正是整个设计的承重部分。C 库**不在**工具链载荷内, +因此这并不是换了一个 sysroot 的 `CLibMode::Sysroot`。picolibc 是一份独立载荷, +由目标自己的表行指名(`src/toolchain/triple.cppm` 中的 +`sysroot = xim:picolibc-riscv@1.8.12`),与该行的编译器 `pin` 处于同一地位。 +从目标而非从工具链解析它,正是裸机**包**不必指名一份 libc 的原因,如同 hosted +包从不指名 glibc。 + +freestanding 链接行同样是被**替换**而不是被追加 —— 见 +`src/freestanding/linkline.cppm` —— 因为在那里每一条 hosted 链接 flag 都是错的, +而不只是多余的。管线更早处追加到普通 ldflags 上的内容会被丢弃,这也是目标 sysroot +的 `-L` 发在该行上而不是与通用 flag 一起发出的原因。 ### 7.4 非 ELF 平台 diff --git a/docs/zh/13-baremetal.md b/docs/zh/13-baremetal.md new file mode 100644 index 00000000..57527109 --- /dev/null +++ b/docs/zh/13-baremetal.md @@ -0,0 +1,456 @@ +# 13 — 裸机与 freestanding 目标 + +本文说明 mcpp 如何为没有操作系统的目标构建、运行与测试软件,以及板级支持包如何 +提供引擎刻意不去掌握的那部分目标事实。 + +相关文档:[05 — mcpp.toml 清单指南](05-mcpp-toml.md) §2.7.2 是本文使用的 +`[target.]` 各键的参考;[07 — build.mcpp 构建程序](07-build-mcpp.md) +是板级支持包所用指令协议的参考;[08 — 工具链内部机制](08-toolchain-internals.md) +描述了本文所偏离的宿主链接模型。 + +## 概述 + +freestanding 目标是 `os` 字段为 `none` 的目标。mcpp 将其中两个列为 verified: +`riscv64-none-elf` 与 `riscv32-none-elf`,两行都在 `src/toolchain/triple.cppm` +的目标表中。 + +这类目标不需要逐宿主的交叉工具链。clang 与 lld 在构造上就是交叉编译器 —— +一个二进制发射它构建时支持的全部目标 —— 因此目标表在每个宿主上都钉 +`llvm@22.1.8`,任何能安装 LLVM 载荷的机器都能产出 RISC-V 镜像。 + +裸机构建需要的三样东西并不是 ISA 的属性,mcpp 也不试图推导它们:选哪个启动对象 +与哪些库、哪份链接脚本描述这台机器的内存、以及如何执行产出的镜像。这三样随 +**板级支持包**一起提供,而板级支持包是一个普通依赖。由此得到的结果是:换板子是 +一次依赖变更,而不是一次构建系统变更。 + +## 从零到一个可运行的镜像 + +两条命令即可产出一个会启动的镜像,其中链接脚本、加载地址与模拟器命令行都不必手写: + +```bash +mcpp new blinky --template riscv-virt-rt +cd blinky +mcpp run +``` + +实测输出: + +``` + Resolving toolchain + Resolved llvm@22.1.8 → riscv64-none-elf → @mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++ + Resolved host toolchain for build.mcpp: clang 22.1.8 (x86_64-unknown-linux-gnu) + build.mcpp compiling + build.mcpp running + Inferred sources [src/**/*.{cppm,cpp,cc,c,S,s,asm}] + Inferred target blinky (bin from src/main.cpp) + Compiling blinky v0.1.0 (.) + Cached riscv-virt-rt v0.3.0 (1 unit) + Finished dev [unoptimized + debuginfo] in 0.05s + Size blinky text 8572 data 80 bss 5668 total 14320 + Running `…/xim-x-qemu-riscv/9.2.4-1/bin/qemu-system-riscv64 … target/riscv64-none-elf/…/bin/blinky` + +hello from blinky +float 3.1416 +heap ok +``` + +`Size` 行在每次 freestanding 链接后打印。容量是裸机目标上的支配性约束,而链接完成 +的那一刻引擎已经知道这个数;不打印就意味着每个工程都要另跑一次 `size`。在宿主目标 +上以及工具缺失时它保持静默 —— 一行信息性输出没有让构建失败的资格。 + +### 生成的工程 + +整份清单只有四处声明: + +```toml +[package] +name = "blinky" +version = "0.1.0" + +[build] +target = "riscv64-none-elf" + +[dependencies] +riscv-virt-rt = "0.3.0" +``` + +其中没有 `[target.*]` 段,没有链接脚本路径,没有加载地址,没有 `-nostdlib`, +没有 `-march`/`-mabi`/`-mcmodel`,没有 crt0,没有 C 库名字,也没有模拟器命令行。 +ISA 参数来自引擎的目标表;其余来自 `[dependencies]` 中列出的板级支持包。 + +生成的 `src/main.cpp` 是一个普通的 `main`: + +```cpp +import mcpplibs.riscv_virt_rt; + +extern "C" int main() { + board::println("hello from blinky"); + board::printf("float %.4f\n", 3.14159); + void* p = board::alloc(64); + board::println(p ? "heap ok" : "heap FAILED"); + board::release(p); + return p ? 0 : 1; +} +``` + +不需要 `_start`,也不需要汇编入口,因为板级支持包选取了 picolibc 的 semihosting +`crt0` —— C 运行时在 `main` 之前就已就绪,返回值经 semihosting 回到宿主。只有完全 +没有 C 库的板子才需要显式入口,做法是让 `main` 指向携带 `_start` 的那份源文件。 + +## freestanding 目标带来的变化 + +| 方面 | 在 freestanding 目标上的行为 | +|---|---| +| 链接行 | 从零构造而非在原有基础上追加:`-nostdlib -nostartfiles -static`,没有 crt 文件,没有动态链接器,没有 C++ 运行时。在宿主链接行后追加 `-nostdlib` 将依赖驱动以正确顺序丢弃先前的 flag。 | +| 链接器选择 | `ld.lld` 按**绝对路径**寻址,由驱动自身所在目录推导。`-fuse-ld=lld` 按名字解析,在任何 binutils 位于 `PATH` 更靠前位置的机器上都会找到 GNU ld,随后以 `unrecognised emulation mode: elf64lriscv` 失败。 | +| ISA 参数 | `-march`、`-mabi` 与 `-mcmodel` 来自 `src/freestanding/target.cppm` 中每个目标一行的表,因此仅凭 `--target ` 就足以产出正确的目标文件。 | +| C 库 | 属于**目标**,由 mcpp 从目标自己的表行解析,与解析编译器的方式完全一致。裸机工程不声明 libc,正如宿主工程不声明 glibc。引擎把 sysroot 的库目录放入链接搜索路径,板级支持包因此可以按裸名从中选取(`-lc`、`-lcrt0-semihost`)。 | +| 异常与 RTTI | 在图中每个翻译单元上都关闭,包括依赖的翻译单元。没有 unwinder 也没有 `libc++abi`,因此任何东西都无法抛出;仅 `std::optional::value()` 一处就会引用 `__cxa_throw` 以及另外三个未定义符号。该设定属于目标而非工程的 `cxxflags`,因为 BMI 会记录它,而带异常编出的依赖无法被不带异常的单元导入。 | +| `import std` | 不可用,并且在配置期以诊断拒绝,而不是在链接期失败。 | +| 入口点 | 只要有东西提供 `crt0`,`int main()` 就可用。板级支持包通常提供它。 | +| 默认链接方式 | 静态,而且这不是偏好:没有加载器,因此没有别的选项。 | + +一个完全没有依赖的工程仍然构建得出来,这正是「仅凭 ISA 表行就已足够」的证据: + +``` + Size norunner text 12 data 0 bss 0 total 12 +``` + +## 分层:引擎、目标与板级支持包 + +职责划分只有一句话:**位置是目标的事实,选择是板级的事实。** + +| 层 | 拥有的内容 | 例子 | +|---|---|---| +| 引擎 | ISA 档位、freestanding 链接行、产物集、以及「产物如何执行」的单一读取点 | `-march=rv64gc -mabi=lp64d -mcmodel=medany -ffreestanding` | +| 目标 | 用哪个编译器与哪份 C 库,两者都从目标的表行解析并按需安装 | `pin = llvm@22.1.8`、`sysroot = xim:picolibc-riscv@1.8.12` | +| 板级支持包 | 选哪个启动对象与哪些库、哪份链接脚本、哪条模拟器命令行 | `-lcrt0-semihost`、`picolibcpp.ld`、`qemu-system-riscv64 -machine virt …` | + +中间那一行正是让包不必指名 C 库的原因。两个生态包早先都声明过 +`[xlings] deps = ["xim:picolibc-riscv@1.8.12"]`,这把包绑死在一份 libc、一种架构 +与一种编译器实现上。该声明已不再需要,目标的 sysroot 列取代了它。 + +同一 ISA 上的第二块板子,是把最后一行里的三个取值换掉。它不需要引擎作任何改动。 + +## 示例 + +本节每一段实录都在 `x86_64-linux-gnu` 上用 mcpp 2026.8.20.1 实测得到, +参见[验证范围](#验证范围)。 + +### 切换 ISA 宽度 + +同一份源码与同一个板级支持包同时服务两种宽度: + +```bash +mcpp run --target riscv32-none-elf +``` + +``` + Size blinky text 10412 data 48 bss 5400 total 15860 +hello from blinky +float 3.1416 +heap ok +``` + +工程的源码与板级支持包都未改动。板级支持包从 `MCPP_TARGET_ARCH` 选取档位,而 ISA +参数来自引擎的表 —— 那是数据而不是代码,再支持一种宽度是加一行。 + +### freestanding 标准库子集 + +`import std` 是覆盖整个库的一个模块,线程、文件系统与 iostreams 全在其中,因此在 +没有操作系统的前提下不存在它的子集可编。承载其中不需要 OS 的那部分的是一个普通依赖: + +```toml +[dependencies] +riscv-virt-rt = "0.3.0" +std-freestanding = "0.2.0" +``` + +```cpp +import mcpplibs.riscv_virt_rt; +import mcpplibs.std.freestanding; // not `import std;` + +struct Task { int prio; const char* name; }; + +extern "C" int main() { + std::array tasks{{ {3,"c"}, {1,"a"}, {4,"d"}, {2,"b"} }}; + std::ranges::sort(tasks, {}, &Task::prio); + for (const auto& t : tasks) board::printf("%s", t.name); + board::println(""); + + std::optional o = 41; + std::atomic a{0}; + a.fetch_add(o.value() + 1); + board::printf("atomic %d\n", a.load()); + + std::span s{tasks}; + std::string_view sv{"ok"}; + board::printf("span %zu %s\n", s.size(), sv.data()); + return 0; +} +``` + +实测输出与体积: + +``` + Size blinky text 19564 data 72 bss 5632 total 25268 + +abcd +atomic 42 +span 4 ok +``` + +该子集覆盖 LLVM 22.1.8 载荷所带 110 个 `std/*.inc` 头中的 103 个 —— 2026-08-20 在 +两侧目录分别计数得到 —— 且它由机械挑选生成,而不是手写的导出表。被略去的 7 个, +按包一侧的说明在宿主 `x86_64` 上同样失败;该说明未在此重新实测。可用的实体包括 +`array`、`span`、`optional`、 +`expected`、`atomic`、`string_view`、`ranges`、`algorithm`、`bit`、`charconv`、 +`concepts`、`type_traits`、`tuple`、`utility` 以及协程。 + +子集排除掉的部分在编译期被排除,而不是运行期: + +```cpp +std::mutex m; +``` + +``` +error: no type named 'mutex' in namespace 'std' +``` + +### 在目标上运行测试 + +`mcpp test` 为每个 `tests/*.cpp` 构建一个独立镜像,在板级支持包提供的模拟器里运行, +并以退出码为判据。semihosting 把固件 `main` 的返回值传递到模拟器的退出码,因此其 +心智模型与宿主上的测试运行完全一致。 + +```bash +mcpp test +``` + +``` + Compiling boots (test) + Running bin/boots +boots: console +boots ... ok (0.02s) + + test result ok. 1 passed; 0 failed; finished in 0.58s (build 0.05s + run 0.02s) +``` + +失败的用例会被点名,命令的退出状态非零: + +``` +deliberate_fail ... FAIL (exit 1, 0.02s) +about to fail +boots ... ok (0.02s) +boots: console + +error: test result: FAILED. 1 passed; 1 failed; finished in 0.24s (build 0.04s + run 0.02s) + +failures: + deliberate_fail +``` + +### 烧录所需的产物集 + +freestanding 链接产出三个文件而不是一个: + +``` +target/riscv64-none-elf//bin/blinky 91640 bytes ELF, for a debugger or `qemu -kernel` +target/riscv64-none-elf//bin/blinky.bin 8664 bytes flat image, what a flasher accepts +target/riscv64-none-elf//bin/blinky.map 253369 bytes link map +``` + +裸二进制由一条 `objcopy -O binary` 边产出,该工具与驱动取自同一份载荷。映射文件是 +链接边的隐式输出而非一个孤立的 `-Wl,-Map=` flag,因此删掉它会被重新生成;它是唯一 +能回答「某个段为何在此处」以及「某段内容为何被或未被从归档中拉入」的产物。 + +### 覆盖板级支持包提供的 runner + +板级支持包通常提供 runner。工程可以覆盖它,这是通常的优先级 —— 工程作者写下的 +胜过依赖提供的: + +```toml +[target.riscv64-none-elf] +runner = ["qemu-system-riscv64", "-machine", "virt", "-nographic", + "-no-reboot", "-bios", "default", + "-s", "-S", # wait for a debugger on the first instruction + "-kernel"] +``` + +覆盖生效时会被报告,而不是静默应用: + +``` + note [target.riscv64-none-elf].runner overrides the runner a dependency supplied +``` + +产物路径追加在模板末尾,或者在模板包含 `{}` 时替换该记号。追加是常见形态,因为 +`-kernel ` 处在命令行末尾。 + +mcpp 不附带任何默认 runner。用哪个模拟器、哪种机器模型、哪种固件模式都是板级事实 +—— 同一 ISA 上的两块板子需要不同的 argv,OpenSBI 启动用 `-bios default`,picolibc +镜像用 `-bios none -semihosting` —— 因此一个猜中其中之一的引擎,会被另一块板子反复 +对抗。 + +## 诊断 + +### freestanding 目标上的 `import std` + +``` +error: `import std;` is not available on 'riscv64-none-elf' — a freestanding target has no hosted standard library. + `std` is one module over the entire library (threads, filesystem, iostreams + included), so there is no subset of it to build without an OS underneath. + Use the freestanding subset instead — an ordinary dependency carrying + the parts of the library that need no OS (array, span, optional, atomic, + string_view, ranges, expected, charconv, coroutines): + + [dependencies] + std-freestanding = "0.2.0" + + then `import mcpplibs.std.freestanding;` in place of `import std;`. + The target's C library itself comes from the BOARD package (riscv-virt-rt + exports `mcpplibs.riscv_virt_rt`). +``` + +该消息在配置期发出。若改为报告缺少 `std` 模块源,会把读者引向排查一个损坏的载荷, +而工具链其实什么都不缺。 + +### 缺少 runner + +freestanding 产物无法在构建机器上执行:ISA 不对,没有加载器,而且它预期独占整个 +地址空间。当没有配置 runner 时,`mcpp run` 会报告「构建成功」与「无法执行」之间的 +这段落差: + +``` +error: no runner is configured for 'riscv64-none-elf' — a freestanding artifact cannot execute on this machine. + Declare how to run it: + + [target.riscv64-none-elf] + runner = ["qemu-system-riscv64", "-machine", "virt", + "-nographic", "-no-reboot", "-bios", "default", "-kernel"] + + The artifact path is appended, or substituted for `{}` if the template contains it. + A board-support package normally supplies this so you do not have to. +``` + +## 编写板级支持包 + +板级支持包是一个普通的 mcpp 包。它在 `[xlings] deps` 下声明所需的模拟器,为消费者 +导出一个 C++ 模块,并从 `build.mcpp` 发出它的板级事实。 + +### 板级支持包发出的指令 + +| 指令 | 作用 | +|---|---| +| `mcpp:link-lib=` | 向消费者的链接行加入 `-l`。裸名即可:目标 sysroot 的库目录已在搜索路径上。 | +| `mcpp:link-search=` | 加入 `-L`,用于包自身携带的库。 | +| `mcpp:link-script=` | 加入 `-T `。相对路径按包根解析,因此属于目标 C 库的脚本必须按绝对路径指名。 | +| `mcpp:runner=` | 向运行模板追加一个 argv 记号。argv 是有序列表,模板由重复构建 —— 一个记号一行指令。 | +| `mcpp:include-dir=` | 加入一个**仅对本包生效**的头文件目录。 | + +两个「向引擎提问」的接口提供了板级支持包不应硬编码的路径:`mcpp::sysroot_dir()` +返回目标 C 库的根,`mcpp::xpkg_dir(ns, name)` 返回一个已安装载荷的目录。 +`mcpp::target_arch()` 报告正在构建的架构。 + +### 指令作用域与到达消费者的内容 + +作用域是刻意不对称的,而这份不对称正是指令层完全不需要 sysroot 概念的原因: + +| 作用域 | 指令 | 到达消费者 | +|---|---|---| +| `LinkGlobal` | `link-lib`、`link-search`、`link-script` | 是 | +| `RunGlobal` | `runner` | 是 | +| `PackagePrivate` | `include-dir`、`include-dir-after` | 否 | + +因此板级支持包以私有方式包含目标的 C 头文件,并把希望被看见的部分导出为一个 C++ +模块。消费者导入该模块,而不继承一条头文件搜索路径。 + +### 一份完整的 build.mcpp + +下面是 QEMU RISC-V `virt` 机器的板级支持包,去掉注释后的全文。这就是该板子构建 +逻辑的全部: + +```cpp +import mcpp; +import std; + +int main() { + const std::string arch = mcpp::target_arch() ? mcpp::target_arch() : ""; + const bool rv32 = (arch == "riscv32"); + + // Selected out of the target's C library, by bare name. + mcpp::link_lib("crt0-semihost"); + mcpp::link_lib("c"); + mcpp::link_lib("semihost"); + mcpp::link_lib(std::format("clang_rt.builtins-{}", + rv32 ? "riscv32" : "riscv64").c_str()); + + // This machine's memory layout, asked for rather than declared. + if (const char* sysroot = mcpp::sysroot_dir(); sysroot && *sysroot) + mcpp::link_script(std::format("{}/lib/{}/picolibcpp.ld", sysroot, + rv32 ? "rv32imac/ilp32" : "rv64gc/lp64d").c_str()); + + // How to run an image. The emulator is named by absolute path, because a + // bare name resolves through PATH to a shim that dispatches against its + // own owner home rather than the home this build uses. + if (const char* qemu = mcpp::xpkg_dir("xim", "qemu-riscv"); qemu && *qemu) { + mcpp::runner(std::format("{}/bin/qemu-system-{}", qemu, + rv32 ? "riscv32" : "riscv64").c_str()); + for (auto a : {"-machine", "virt", "-nographic", "-no-reboot", + "-semihosting", "-bios", "none", "-kernel"}) + mcpp::runner(a); + } + + mcpp::rerun_if_env_changed("MCPP_TARGET_ARCH"); + return 0; +} +``` + +该包的清单只声明模拟器,别无其他: + +```toml +[xlings] +deps = ["xim:qemu-riscv@9.2.4-1"] +``` + +在这块板子上链接 `clang_rt.builtins` 不是可选项。picolibc 通过 ryu 格式化浮点值, +而 ryu 需要 128 位移位,rv64 没有对应指令;缺少 builtins 时链接会在 `__ashlti3` +与 `__lshrti3` 上失败。一个「64 位除法是否可用」的检查到不了这条判据,因为 rv64gc +有硬件 `divu`。 + +包一侧无法探测出当前 mcpp 是否比它所面向的版本更旧: +`if constexpr (requires { mcpp::runner("x"); })` 作用在未知的限定名上是硬错误而不是 +`false`,语言在此不提供特性探测。引擎为此作了补偿:当 `build.mcpp` 编译失败且错误 +指出某个名字不是 `mcpp` 的成员时,追加一条升级提示。 + +## 验证范围 + +本文的命令与输出于 2026-08-20 实测,环境如下: + +| 组件 | 版本 | +|---|---| +| mcpp | 2026.8.20.1,由本仓库构建 | +| 宿主 | `x86_64-linux-gnu` | +| 工具链 | `xim:llvm` 22.1.8 | +| 目标 C 库 | `xim:picolibc-riscv` 1.8.12 | +| 模拟器 | `xim:qemu-riscv` 9.2.4-1 | +| 板级支持包 | `mcpplibs:riscv-virt-rt` 0.3.0 | +| 标准库子集 | `mcpplibs:std-freestanding` 0.2.0 | + +体积随 mcpp 版本变化:同一个工程在 2026.8.19.4 下实测为 `text 8844`,在 2026.8.20.1 +下为 `text 8572`。因此上文的数字表示量级,而不是固定值。 + +裸机链路只在 Linux 上有持续验证。引擎侧 CI 有一个 `baremetal` job,覆盖四个端到端 +脚本;两个生态包在各自仓库中以 QEMU 真跑 RISC-V 64 与 32 —— 全部在 `ubuntu-24.04` +上。macOS 与 Windows 宿主预期可用,依据是载荷为交叉编译器且 `xim:qemu-riscv` 发布了 +五个宿主目标的资产,但该预期**没有**测试覆盖。 + +## 当前边界 + +| 边界 | 观察到的行为 | +|---|---| +| `std::format`、内建标量类型上的 `std::sort`、以及完整的 `std::string` | 在**链接**期失败并点名未定义符号。libc++ 把这些实体放在编译版库中 —— 标量 `__sort` 的实例化是 `extern template`,没有可用于关闭它们的宏 —— 因此需要为目标编出的 `libc++.a`。该载荷尚未发布。 | +| 异常与 RTTI | 在整张图上关闭。`try`/`catch` 在编译期即不可用。一块随包提供目标版 `libc++abi` 与 unwinder 的板子有重新开启它们的正当理由;那也正是这一项应当成为一个清单键的时刻。 | +| 板子覆盖面 | 只有一个板级家族。`riscv32-none-elf` 证明的是 ISA 表为数据,而不是已移植第二台机器。ARM Cortex-M 尚未尝试。 | +| 替换 C 库 | `[target.]` 接受 `toolchain`、`linkage`、`runner` 与 `cxx_runtime`,但**没有 `sysroot` 键**。因此把 picolibc 换成 newlib 在今天的工程清单里无法表达。 | +| `win32-arm64` 上的 `qemu-riscv` | 上游包未为该宿主发布资产,因此在其上安装会失败。该失败是正确的而非静默的,但该宿主无法运行裸机镜像。 | +| 生态侧 CI 广度 | 两个生态包各自的 CI 只跑 `ubuntu-24.04`。mcpp-index 的 `tests/examples/` workspace 成员在三个平台上无条件运行且没有能力门,因此需要模拟器与目标 sysroot 的包无法加入其中。这是一个已知的覆盖缺口。 | diff --git a/docs/zh/README.md b/docs/zh/README.md index 8fd1580b..15533efd 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -15,6 +15,7 @@ - [10 - 发布一个库到 mcpp-index](10-publishing-a-library.md) - [11 - 机器可读输出](11-machine-output.md) - [12 - 分发预编译库](12-binary-distribution.md) +- [13 - 裸机与 freestanding 目标](13-baremetal.md) ## 规范文档 diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 781e9bfa..89968287 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -5372,6 +5372,19 @@ prepare_build(bool print_fingerprint, // measured; the 7 that fail fail on a hosted x86_64 too), so the line // is back. If it is ever removed from the index, this must change with // it. + // + // ⚠️ And the VERSION is part of the promise, not decoration — which is + // how the same defect recurred 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, + // so pasting it produced + // + // E_NOT_FOUND: package 'compat.std-freestanding@0.1.0' not found + // in the synced index + // + // measured 2026-08-20 while documenting this message. A floor would + // not fix it either: the request has to name a version the index + // actually carries. Publishing a new std-freestanding means updating + // this literal in the same change. if (auto ft = mcpp::toolchain::triple::parse(tc->targetTriple); ft && ft->is_freestanding()) { @@ -5389,7 +5402,7 @@ prepare_build(bool print_fingerprint, " string_view, ranges, expected, charconv, coroutines):\n" "\n" " [dependencies]\n" - " std-freestanding = \"0.1.0\"\n" + " std-freestanding = \"0.2.0\"\n" "\n" " then `import mcpplibs.std.freestanding;` in place of " "`import std;`.\n" From 56d9f7391c2b393882fcd5773c512198e5299dc8 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:25:44 +0800 Subject: [PATCH 02/20] =?UTF-8?q?docs:=20=E8=A3=B8=E6=9C=BA/=E5=B5=8C?= =?UTF-8?q?=E5=85=A5=E5=BC=8F/=E5=86=85=E6=A0=B8=E6=96=B9=E5=90=91?= =?UTF-8?q?=E7=9A=84=E7=94=9F=E6=80=81=E5=AE=9A=E4=BD=8D=E4=B8=8E=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮 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,也全部不新增引擎轴。 --- ...8-20-freestanding-ecosystem-positioning.md | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 .agents/docs/2026-08-20-freestanding-ecosystem-positioning.md diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md b/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md new file mode 100644 index 00000000..d52f4a7b --- /dev/null +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md @@ -0,0 +1,443 @@ +# 裸机 / 嵌入式 / 内核方向的生态定位与缺口(设计方案) + +**状态**:设计,未实施。本文不新增里程碑,它给出五项决策的形状与判据, +以及下一个真实案例的选择依据。 + +配套文档: + +- [`2026-08-19-freestanding-baremetal-design.md`](2026-08-19-freestanding-baremetal-design.md) + —— 裸机方向的原始方案,§11 提出 openkal / openhal / openarch 三层。 +- [`2026-08-19-freestanding-baremetal-implementation-plan.md`](2026-08-19-freestanding-baremetal-implementation-plan.md) + —— §7 是 D 档四阶段路线图与每阶段的继续/停止判据。 +- [`2026-08-20-openkal-design.md`](2026-08-20-openkal-design.md) + —— 内核 ABI 规范的推导,§6.2 给出裸机 UART 后端。 +- [`2026-08-20-pr455-459-freestanding-review.md`](2026-08-20-pr455-459-freestanding-review.md) + —— 引擎侧五个 PR 的 review。 +- `docs/13-baremetal.md`(PR#466)—— 面向用户的章节。 + +证据来源分两类:标注为「实测」的结论在本机验证过,环境为 mcpp 2026.8.20.1 +(由本仓库构建)· `x86_64-linux-gnu` · `xim:llvm 22.1.8` · `xim:picolibc-riscv 1.8.12` · +`xim:qemu-riscv 9.2.4-1` · `mcpplibs:riscv-virt-rt 0.3.0` · `mcpplibs:std-freestanding 0.2.0`, +日期 2026-08-20;其余为设计主张,或转引自上列文档并注明出处。 + +--- + +## 0. 多维度评估摘要 + +| 维度 | 当前状态 | 依据 | +|---|---|---| +| **构建轴** | 完整。ISA 档位、目标 libc、启动对象、链接脚本、执行方式各有归属,且各只有一个读取点 | §2.1 | +| **设备轴** | 空。无 PAC、无 HAL、无中断模型、无设备树 | §2.2 | +| **可移植性表达** | ⚠️ 缺口。库无法声明自己 freestanding-safe,消费者靠链接失败发现 | §4 | +| **目标可扩展性** | ⚠️ 缺口。加一个目标是引擎代码变更,生态作者做不到 | §5 | +| **OS 服务抽象** | 已实现(openkal 0.5,八个接口),裸机后端未实现 | §6 | +| **设备服务抽象** | 已设计未实现(openhal,D2),停止判据已写明 | §2.2、§10 | +| **内核方向** | 构建侧可用;⚠️ 零 libc 档缺失,x86_64 裸机目标缺失 | §7 | +| **案例覆盖** | ⚠️ 一台几乎没有外设的虚拟机。分层的证据强度是所有结论里最弱的一环 | §8 | + +--- + +## 1. 现状核定:`riscv-virt-rt` 是什么 + +### 1.1 实测构成 + +实测:`src/riscv_virt_rt.cppm` 共 **46 行**,导出 +`board::print/println/printf/alloc/release/copy/fill/poweroff`。除 `poweroff` 外 +全部是 picolibc 的直接转发。`poweroff` 是**全包唯一一次寄存器访问**,向 `virt` +的 syscon `0x100000` 写 `0x5555`。 + +包真正承重的部分在 `build.mcpp`(约 30 行),而其中三件事都是**选择**而非实现: +选 `crt0-semihost` 等四个库、指名 picolibc 的 `picolibcpp.ld`、拼 qemu 的 argv。 + +### 1.2 它在分层中的位置 + +它**不是 HAL**:没有 GPIO、定时器、中断、DMA,没有任何外设类型。 +它也**不是 PAC**:没有寄存器定义。 + +它是一个板级支持包,粒度是**每台机器**(QEMU `virt`),不是每个 ISA。它同时服务 +rv64 与 rv32,因为 `virt` 两个宽度都存在且 picolibc 两个 multilib 档位都有。 + +### 1.3 与 Rust `-rt` 约定的对应 + +名字沿用 `cortex-m-rt` / `riscv-rt` 的 `-rt` 约定,但**比它们薄一层**: + +| | Rust `riscv-rt` | `riscv-virt-rt` | +|---|---|---| +| 复位向量 | 自己实现 | picolibc 的 `crt0-semihost` | +| `.bss` / `.data` 初始化 | 自己实现 | 同上 | +| 链接脚本 | 自带 `link.x` | 指名 picolibc 的 `picolibcpp.ld` | +| 入口宏 | `#[entry]` | 普通 `int main()` | + +⇒ 它的贡献是**接线**而不是实现。这在 `virt` 上是优点(代码少、不重复造轮子), +但也意味着**它没有验证过「板级包能否实现启动逻辑」这件事**。 + +--- + +## 2. 分层与归属 + +### 2.1 现有各层与归属 + +| 层 | 归属 | 载体 | 状态 | +|---|---|---|---| +| ISA 档位(`-march`/`-mabi`/`-mcmodel`) | 引擎 | `src/freestanding/target.cppm`,每目标一行 | ✅ 2 行 | +| 编译器 + 目标 libc | 目标表行 | `triple.cppm` 的 `pin` + `sysroot` | ✅ | +| 启动对象 / 链接脚本 / 执行方式 | 板级支持包 | `build.mcpp` 三条指令 | ✅ | +| 可移植语言设施 | 普通依赖 | `std-freestanding` | ✅ 103/110 头(实测) | +| OS 服务抽象 | 普通依赖 | `openkal` + 后端 | ✅ 规范与两个后端;⚠️ 无裸机后端 | +| 设备服务抽象 | 普通依赖 | `openhal` | ⚠️ 已设计未实现(D2) | +| arch 机制 | 普通依赖 | `openarch` | ⚠️ 已设计未实现(D3) | + +### 2.2 构建轴完整,设备轴为空 + +`riscv-virt-rt` 能到达「hello world + 堆 + 退出码」,是因为 **semihosting 提供了 +一个长得像宿主的控制台**。而 semihosting 是调试器功能:`qemu -semihosting` 与 +JTAG 探针提供它,量产板子不提供。 + +⇒ **该路径不能原样迁到真实硬件。** 真实板子上 `board::println` 需要一个 +picolibc 后端,而该包不提供。 + +### 2.3 ⚠️ 引擎永不认识 KAL / HAL / ARCH + +沿用方案 §11.6 / §14.0 的结论,本文不修改它:引擎只有 L1–L3 三条缝, +openkal / openhal / openarch 都是**包**。后端选择是**条件依赖,零新增轴** +(openkal 设计 §6.3 已按此实现)。 + +本文的五项决策全部遵守该约束:§4 复用既有的 `provides`,§5 只动目标表的**输入 +来源**而不动其读取点,§6/§7 是包与载荷,§8 是案例选择。 + +--- + +## 3. 对比 Rust `no_std`:五处结构差异 + +### 3.1 ⭐ `no_std` 是 crate 属性,freestanding 是 target 属性 + +Rust 中一个库写 `#![no_std]`,**同一份 crate** 宿主与 MCU 都能编。 +mcpp 中 freestanding 住在目标表,引擎对**图中每个 TU**强制 +`-ffreestanding -nostdinc++ -fno-exceptions -fno-rtti`。 + +⇒ 包**无法声明**自己 freestanding-safe,索引也无法据此筛选。这是 §4 要解决的。 + +### 3.2 `core` 不需要 libc,`std-freestanding` 需要 + +`core` 自包含,`alloc` 需显式 `#[global_allocator]`。 +`std-freestanding` 是 libc++ 的头压在目标 C 库之上;`malloc` 由 picolibc 提供, +**今天没有办法说「不要堆」**。这是 §7 零 libc 档的动机之一。 + +### 3.3 目标数量与可扩展性 + +rustc 内置约 40 个裸机目标,mcpp 有 2 个。数量差距本身不重要——重要的是 +**rustc 的自定义目标 JSON 早于 tier-3 目标很多年**,生态先跑起来才谈内置。 +mcpp 今天没有等价物。这是 §5。 + +### 3.4 分层深度 + +Rust:`riscv`(ISA)→ `riscv-rt`(运行时)→ PAC(每芯片,SVD 生成)→ +HAL(`embedded-hal` trait)→ BSP(每板)。共五层。 + +mcpp 把这些压进一个板级包。对 `virt` 够用,**撑不过同一厂商的第二颗芯片**—— +PAC / HAL 的切分正是让 50 块板共用一份 HAL 的东西。 + +### 3.5 网络效应在 trait 不在目标数 + +一个驱动 crate 写给 `embedded-hal::spi::SpiDevice`,在每颗芯片上都能用。 +openhal 的 D2 判据(**同一个驱动包既跑裸机 MCU 又跑 Linux**)正是对标这一形状, +且其停止信号写得很清楚:**驱动作者不来就停**。 + +--- + +## 4. 决策 A:freestanding 成为包能力,且由证据推导 + +### 4.1 包含关系成立于源码层 + +「没有 OS 能用,有 OS 更能用」成立,但要说准:它成立在**源码层**而非产物层。 +freestanding 构建被强制四个 flag,所以「能 freestanding 编」等价于 +「这份源码不用 OS、不用异常、不用 RTTI、不用 hosted std」。这样的源码在有 OS 时 +当然也能编——但会被**按 hosted 重新编一遍**,不是复用同一份产物。 + +### 4.2 机制已存在 + +`provides` 已经是包级键,不需要新增任何段: + +```toml +[package] +provides = ["freestanding"] +``` + +与 `blas` / `lapack` 同一套词汇(`docs/05` §2.8.1)。 + +### 4.3 ⚠️ 声称不足以成立,判据必须是构建 + +openkal 设计 §5 为此把整套能力位机制删掉,理由是 +「无法在不定义的前提下声称」。同一条纪律必须套在这里,否则重演 +`caps::seek = true` 而 `seek()` 永远失败。 + +本处的判据近乎免费: + +> ⭐ **包能为 `riscv64-none-elf` 编过,它就是 freestanding-safe,by construction。** +> 因为引擎已经把那四个 flag 强制到全图,没有第二件事需要查。 + +⇒ 形状是**证据推导而非作者声称**:包 CI 里加一条 freestanding 构建,索引记录 +该事实。这与 PR#451 的 `[[runtime.artifacts]]` 承载证据同形。 + +### 4.4 消费端收益 + +| | 今天 | 决策 A 之后 | +|---|---|---| +| 裸机工程 `mcpp add` 一个 hosted-only 库 | 解析成功、构建到链接期报一批未定义符号 | 解析期即可警告或拒绝 | +| 索引检索「哪些库能裸机用」 | 不可能 | 可枚举 | +| 库作者知道自己是否还兼容 | 不知道 | CI 变红 | + +### 4.5 今天的兜底 + +实测:`cfg(os = "none")` 与 `cfg(arch = "riscv64")` 都能匹配,库因此可以按 +freestanding 走另一套源码或 flag: + +```toml +[target.'cfg(os = "none")'.build] +defines = ["PROBE_OS_NONE=1"] +``` + +``` +cfg(os = "none") matched +cfg(arch = "riscv64") matched +``` + +⚠️ 但那是逐包自觉,**索引检索不到**,因此不能替代决策 A。 + +--- + +## 5. 决策 B:目标表的三步可扩展路径 + +### 5.1 三条路径 + +| | 形态 | 代价 | 兼容风险 | +|---|---|---|---| +| **(a) 工程内定义目标** | `[target.]` 今天只能**覆盖**已知 triple 的旋钮,扩成能**定义**未知 triple(march / mabi / mcmodel / sysroot / pin) | 最小 | **零**:工程本地,老 mcpp 不认识该 triple 时干净失败 | +| **(b) 目标定义包** | 目标行随包走,板级支持包自带它的 target | 中 | 低 | +| **(c) 索引化** | 已定为阶段二 | 大 | ⚠️ 必须降级 | + +### 5.2 推荐顺序:(a) → (b) → (c) + +理由是**前一条产出后一条所需的证据**。(a) 立即解除生态作者的阻塞,并且在设计 +(c) 的索引 schema 之前,先积累「真实目标行长什么样」的样本。Rust 的自定义目标 +JSON 就是这个位置。 + +⚠️ (c) 必须遵守既有结论:**索引是数据,mcpp 是程序,发布数据不得让程序失效**。 +新目标行在老客户端上必须降级为「不认识这个 triple」,而不是让整份索引加载失败。 + +### 5.3 ⚠️ 单一读取点不可破 + +`src/freestanding/target.cppm` 的注释写明它是**单一读取点**,存在理由是不让同一 +决策在 N 处推导(#233 / #240 / #242 / #344 的教训)。 + +⇒ 清单定义的目标必须**喂进同一张表**,由 `resolve()` 统一读出,不得开第二条解析 +路径。决策 B 改的是表的**输入来源**,不是它的**读取方式**。 + +--- + +## 6. 决策 C:`std-freestanding` 与 openkal 互补,不替代 + +### 6.1 两个不同的问题 + +| 问题 | 归属 | 类比 | +|---|---|---| +| 语言设施(容器、算法、`optional`、`span`) | `std-freestanding` | Rust `core` | +| OS 服务(读写、时钟、内存、进程) | `openkal` | Rust `std` 减去 `core`;或 WASI | + +### 6.2 openkal 解决的那个 + +库写 `kal::` 而不是 libc / POSIX,后端由条件依赖选中。转引 openkal 设计 §1: +裸机侧后端直接写 MMIO,两侧 `app.o` 的外部符号集合完全相同,裸机侧**无未定义符号, +体积 157 字节**(该数字转引自设计文档,本文未复测)。 + +一整个 interface 不存在时,`import openkal.task;` 在**编译期**即找不到模块; +未提供的符号确实不导出,该性质用 `nm` 静态可查。 + +### 6.3 ⚠️ openkal 解决不了的那个 + +`std::format`、内建标量类型的 `std::sort`、完整的 `std::string` 在 libc++ 的 +**编译版库**里(标量 `__sort` 的实例化是 `extern template`,没有可关闭它们的宏)。 +这要的是**为目标编出的 `libc++.a`**,是载荷问题而不是抽象层问题。 + +⇒ openkal 不会让这三样出现,`docs/13` 的「当前边界」那一行不因 openkal 而改变。 + +### 6.4 裸机后端的形状 + +openkal 设计 §6.2 已给出最小裸机 UART 后端,约 15 行,选中方式是条件依赖: + +```toml +[target.'cfg(all(arch = "riscv64", os = "none"))'.dependencies] +openkal-uart = "0.1" +``` + +⇒ **这是 D0 的「两个后端(linux / bare)」里缺的那一个**,也是 §7 零 libc 档的 +天然第一个消费者。 + +--- + +## 7. 决策 D:零 libc 档与内核开发者支持 + +### 7.1 今天已经能用的 + +freestanding 目标 · 自带链接脚本(`link-script`)· 自定义 runner · +`.bin` / `.map` / size 摘要 · `mcpp test` 在模拟器里读退出码。 + +⇒ 写内核最繁琐的构建部分基本齐备。实测:一个零依赖的 freestanding 工程可以构建, +产物 `text 12 data 0 bss 0 total 12`——这证明仅凭 ISA 表行就足以产出正确目标文件。 + +### 7.2 缺口 + +| 缺口 | 为何挡路 | +|---|---| +| **零 libc 档** | 目标行今天硬绑 `sysroot = xim:picolibc-riscv@1.8.12`。内核作者要的是**什么都不要**。需要 `sysroot` 可为空这一档,以及「`main` 指向携带 `_start` 的文件」这条路径的真实示例 | +| **`[target.X].sysroot` 覆盖** | 今天工程换不掉 libc 实现(`docs/13` 已记为边界)。零 libc 档与换 newlib 是同一个旋钮的两种取值 | +| **`x86_64-none-elf` 目标行** | 内核开发的主要战场之一,今天一行都没有 | +| **openarch(D3)** | Context / Trap / AddressSpace / PerCpu。⚠️ 其停止判据最硬:上下文切换或页表项抽象一碎,**后面全是幻觉** | + +### 7.3 ⭐ 最小示例不是一个内核 + +给内核开发者的最小示例应当是**一个零 libc 的 `riscv64-none-elf` 工程**: +自带 `link.ld`、自带 `_start`、自己写 UART、`main` 指向汇编,启动到打印一行并停机。 + +它同时是三样东西: + +1. 内核作者的起点; +2. 零 libc 档(`sysroot` 为空)的判据——**没有它,那一档没有任何测试**; +3. openkal 裸机后端(§6.2 那 15 行)的宿主。 + +⚠️ 判据必须是「拿走」而不是「在我这儿是好的」:把 picolibc 从解析中排除后仍能 +构建并运行,才证明零 libc 档真的成立。 + +--- + +## 8. 决策 E:下一个真实案例 + +### 8.1 ⭐ 实测:载荷已注册的目标 + +`xim:llvm 22.1.8` 的 `clang -print-targets`: + +**已注册**:`aarch64` · `arm` · `thumb` · `avr` · `msp430` · `riscv32` · `riscv64` · +`loongarch32/64` · `mips` · `ppc` · `sparc` · `systemz` · `wasm32/64` · `x86` · +`x86-64` · `xcore` 等。 + +**未注册**:`xtensa`。 + +⇒ 两条结论: + +- **Cortex-M 不需要新的工具链载荷**,`arm` / `thumb` 已在其中。 +- **ESP32 / S2 / S3(Xtensa)在钉住的载荷上不成立**,除非另做一份 Espressif 分支的 + LLVM 载荷。 + +⚠️ **但目标 C 库要新建。** 实测 xim 索引 `pkgs/` 下与裸机 C 库相关的包只有 +**`picolibc-riscv.lua` 一个**,没有 arm 版 picolibc,没有 newlib,没有任何 +`arm-none-eabi` 包。 + +⇒ Cortex-M 一步的真实成本是**四件**而不是三件: + +| | 内容 | 性质 | +|---|---|---| +| 1 | `xim:picolibc-arm` 载荷 | ⚠️ **新建**。picolibc 上游支持 ARM,且 `picolibc-riscv` 是自建的(见 review §1 生态侧),因此这是**重复一次已走通的流程**,不是未知领域 | +| 2 | 目标表加行(`thumbv7em-none-eabi` 一类) | 一行 | +| 3 | 板级支持包 | 与 `riscv-virt-rt` 同形 | +| 4 | `xim:qemu-arm` 载荷 | ⚠️ **新建**。实测 xim 索引下只有 `qemu-riscv.lua` 一个 qemu 包,没有 `qemu-arm`。上游同为 xPack(`qemu-arm-xpack` 与 `qemu-riscv-xpack` 同一发布方),因此描述符可照 `qemu-riscv.lua` 改写 | + +⇒ 修正判断:Cortex-M 一步**不是「加一行」**,而是**两个新载荷 + 一行 + 一个包**。 +两个载荷都是已走通流程的重复,风险低但工作量真实存在。这一点必须在排期前说清楚, +否则会重演「计划里凡是带具体数字的句子都是必须先测的探针」那条教训。 + +### 8.2 ESP32 分型号 + +| 型号 | ISA | 结论 | +|---|---|---| +| ESP32 / S2 / S3 | Xtensa | ❌ 载荷未注册该后端 | +| ESP32-C3 / C6 | RISC-V rv32imc | ⚠️ ISA 上成立,但一次带进三个新问题 | + +C3 的三个新问题:ROM bootloader 的镜像头(esptool 格式)· 没有 semihosting 需自行 +初始化 UART · 外设实际依赖 ESP-IDF(FreeRTOS + CMake + Kconfig 一整套)。 + +⇒ **它测的是厂商 SDK 集成,不是裸机模型。** + +### 8.3 推荐顺序 + +| 步 | 案例 | 它验证什么 | 为什么是这个位置 | +|---|---|---|---| +| **1** | Cortex-M + QEMU(`mps2-an385` 或 `lm3s6965evb`) | 「ISA 表是数据」在**真正不同的架构**上是否成立 | 不需硬件,CI 保持封闭;载荷已有 `arm`/`thumb` | +| **2** | RP2040(树莓派 Pico) | 真烧录 + 真 UART 控制台(无 semihosting) | 4 美元、开源 SDK 成熟、UF2 烧录路径清楚 | +| **3** | ESP32-C3(可选) | 厂商 SDK 集成 | 三个新问题应当逐个引入 | + +### 8.4 ⚠️ 为什么顺序不能反 + +先做真实硬件会把「新 ISA」与「没有 semihosting」两个变量混在一起,失败时分不清 +是哪一个。这与本轮已经付过学费的形状一致:**判据一次只应引入一个变量。** + +### 8.5 ⭐ 真正的收益 + +真实案例会**逼出 PAC / HAL / BSP 的切分**,把「我们的分层是对的」从断言变成被检验 +过的结论。 + +今天这套分层只在**一台几乎没有外设的虚拟机**上验证过,这是本文全部结论里证据最弱 +的一环。 + +--- + +## 9. 板级粒度 + +**裸机不存在「通用 x86_64 / arm64」。** x86_64 裸机意味着在写 bootloader 或内核 +(UEFI / multiboot);arm64 裸机永远在某颗具体 SoC 上。 + +| 可跨板共享 | 不可跨板共享 | +|---|---| +| ISA 级运行时、目标 libc、`std-freestanding`、openkal 契约 | 内存映射、外设、启动协议、链接脚本 | + +⇒ 正确粒度是三段:**每 ISA 一份薄 `-rt` + 每芯片家族一份 HAL + 每板一份 BSP**。 + +`riscv-virt-rt` 现在把三段熔在一起,因为 `virt` 是虚拟机、几乎没有外设(§1.2)。 +第二块板会强制这次切分,而这正是 §8 的目的。 + +--- + +## 10. 实施顺序与判据 + +⚠️ 本节不排期。顺序依据是「解除阻塞的程度 × 判据的可检验性」。 + +| 序 | 决策 | 判据(达成即继续) | 停止信号 | +|---|---|---|---| +| 1 | **A** freestanding 包能力 | 一个既有库为 `riscv64-none-elf` 构建通过并被索引记录;裸机工程 `mcpp add` 一个 hosted-only 库时在**解析期**被告知 | —— (代价极低,无停止条件) | +| 2 | **B(a)** 工程内定义目标 | 一个未进引擎表的 triple 仅凭 `mcpp.toml` 即可构建;`resolve()` 仍是唯一读取点 | 若发现必须开第二条解析路径 ⇒ 停,回到 §5.3 重设计 | +| 3 | **D** 零 libc 档 + 最小示例 | 把 picolibc 排除后仍能构建并在 qemu 打印一行 | —— | +| 4 | **E 步 1** Cortex-M QEMU | 两个新载荷(`picolibc-arm` / `qemu-arm`)+ 目标表一行 + 一个板级包,源码零改地跑通;⭐ **引擎零改动** | 若引擎必须改 ⇒ 「ISA 表是数据」不成立,回炉 | +| 5 | **C** openkal 裸机后端 | D0 的「两个后端」补齐;conformance 双向通过 | —— | +| 6 | **E 步 2** RP2040 | 真烧录 + 真 UART | —— | +| 7 | **B(b)/(c)** 目标定义包 / 索引化 | 老客户端降级正确 | —— | + +⚠️ **openhal(D2)与 openarch(D3)不进本表。** 它们的门在 D 档路线图里, +且 D2 的停止信号是「驱动作者不来就停」——那不是技术判据,不能靠实施推进。 + +--- + +## 11. 当前边界 + +| 边界 | 状态 | +|---|---| +| 本文全部分层结论 | 只在 QEMU `virt` 一台虚拟机上验证过 | +| openkal 裸机后端的 157 字节 | 转引自 openkal 设计 §6.2,**本文未复测** | +| Cortex-M 可行性 | 依据是载荷注册了 `arm`/`thumb`,**未实际构建过任何 Cortex-M 产物** | +| `picolibc-arm` / `qemu-arm` | 实测 xim 索引中**均不存在**,两者都要新建;上游可用性(picolibc 的 ARM 支持、xPack 的 `qemu-arm-xpack`)**未逐一核实** | +| RP2040 / ESP32-C3 | 均未尝试 | +| `provides = ["freestanding"]` 的索引侧表示 | 形状已定(证据推导),**schema 未设计** | +| macOS / Windows 宿主上的裸机链 | 无持续验证(`docs/13` 已记) | + +--- + +## 12. 决策回执 + +| | 决策 | 一句话 | +|---|---|---| +| **A** | freestanding 成为包能力 | 复用 `provides`,但**由构建证据推导而非作者声称** | +| **B** | 目标表可扩展 | **先工程内定义,再随包走,最后索引化**;单一读取点不可破 | +| **C** | `std-freestanding` 与 openkal 互补 | openkal 解决 OS 服务可移植;**解决不了目标版 `libc++.a`** | +| **D** | 零 libc 档 | 最小示例不是一个内核,是**一个零 libc 的启动工程**——它同时是该档的唯一判据 | +| **E** | 下一个案例 | **Cortex-M + QEMU 先行**(编译器载荷已有 `arm`/`thumb`,但 **C 库与模拟器两个载荷都要新建**);ESP32 分型号,Xtensa 不成立 | + +⚠️ 五项决策全部不要求引擎认识 KAL / HAL / ARCH,也全部不新增引擎轴—— +A 复用 `provides`,B 只改目标表的输入来源,C/D 是包与载荷,E 是案例选择。 From 2d25af0c79000ea7380c3956371fedaa7a8cc70f Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:57:24 +0800 Subject: [PATCH 03/20] =?UTF-8?q?docs(openarch):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=96=B9=E6=A1=88;=E5=B9=B6=E6=8A=8A=E5=9B=9B=E6=9D=A1?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E6=8A=98=E5=9B=9E=E5=AE=9A=E4=BD=8D=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 位实模式)。 --- ...8-20-freestanding-ecosystem-positioning.md | 192 +++++++++++-- ...26-08-20-openarch-implementation-design.md | 267 ++++++++++++++++++ 2 files changed, 439 insertions(+), 20 deletions(-) create mode 100644 .agents/docs/2026-08-20-openarch-implementation-design.md diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md b/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md index d52f4a7b..7fa0ce44 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md @@ -30,7 +30,8 @@ | **设备轴** | 空。无 PAC、无 HAL、无中断模型、无设备树 | §2.2 | | **可移植性表达** | ⚠️ 缺口。库无法声明自己 freestanding-safe,消费者靠链接失败发现 | §4 | | **目标可扩展性** | ⚠️ 缺口。加一个目标是引擎代码变更,生态作者做不到 | §5 | -| **OS 服务抽象** | 已实现(openkal 0.5,八个接口),裸机后端未实现 | §6 | +| **OS 服务抽象** | 已实现(openkal 0.5,八个接口,五个后端);⚠️ **裸机后端缺失,且设计 §6.3 的选择键是 ISA 级的** | §6.4 | +| **分配器接线** | ⚠️ 缺口。`std::vector` 在裸机上要用户手写 12 个 `operator new`/`delete` 重载 | §3.2、§6.6 | | **设备服务抽象** | 已设计未实现(openhal,D2),停止判据已写明 | §2.2、§10 | | **内核方向** | 构建侧可用;⚠️ 零 libc 档缺失,x86_64 裸机目标缺失 | §7 | | **案例覆盖** | ⚠️ 一台几乎没有外设的虚拟机。分层的证据强度是所有结论里最弱的一环 | §8 | @@ -117,11 +118,36 @@ mcpp 中 freestanding 住在目标表,引擎对**图中每个 TU**强制 ⇒ 包**无法声明**自己 freestanding-safe,索引也无法据此筛选。这是 §4 要解决的。 -### 3.2 `core` 不需要 libc,`std-freestanding` 需要 +### 3.2 `core` 与 `std-freestanding` 的耦合都比预期小 -`core` 自包含,`alloc` 需显式 `#[global_allocator]`。 -`std-freestanding` 是 libc++ 的头压在目标 C 库之上;`malloc` 由 picolibc 提供, -**今天没有办法说「不要堆」**。这是 §7 零 libc 档的动机之一。 +⚠️ **本节最初写成「`std-freestanding` 需要堆,没有办法说不要堆」,实测推翻了它。** + +实测三态(rv64): + +| 用什么 | 结果 | +|---|---| +| `array` `span` `optional` `atomic` `ranges` `to_chars` | ✅ 链接通过,**完全不碰堆** | +| `std::vector` | ⚠️ 编得过、**链不上**:`undefined symbol: operator new(unsigned long)` | +| `std::vector` + 自写 12 个 `new`/`delete` 重载(转发 `board::alloc`) | ✅ 跑通,`text 13492` | + +⇒ **`std-freestanding` 是分配中立的**:既不提供堆也不要求堆。picolibc 的 `malloc` +之所以存在,是因为**板级包链了 `-lc`**;而 C++ 的 `operator new` 谁都没接上—— +libc++ 把它放在编译版库里,目标版没有。 + +⭐ 更进一步的实测:一个 tier-0 程序的**全部**未定义符号是 + +``` +_ZNSt3__122__libcpp_verbose_abortEPKcz ← 包自带 verbose_abort.cpp 提供 +_ZNSt3__16__sortIRNS_6__lessIiiEEPiEE… ← 标量 __sort 的 extern template(T2 边界) +memmove +strlen +``` + +⇒ **tier-0 的整个 libc 运行期面是 `memmove` + `strlen` 两个纯计算函数**, +不需要 OS,也不需要 openkal。与 Rust `core` 的差距远小于本节初稿的判断。 + +真正的耦合在**头文件**而非服务:libc++ 的头 `#include ` / `` / +``,这是结构性的,换不掉。见 §6.5。 ### 3.3 目标数量与可扩展性 @@ -261,17 +287,95 @@ JSON 就是这个位置。 ⇒ openkal 不会让这三样出现,`docs/13` 的「当前边界」那一行不因 openkal 而改变。 -### 6.4 裸机后端的形状 +### 6.4 裸机后端的形状,以及对设计 §6.3 选择键的更正 + +实测索引里 openkal 家族有六个包:`openkal` · `-linux` · `-macos` · `-musl` · +`-windows` · `-libc`。**没有裸机后端**,而 D0 的交付物字面就是 +「两个后端(linux / **bare**)」——它是**已命名但缺失**的那一个。 -openkal 设计 §6.2 已给出最小裸机 UART 后端,约 15 行,选中方式是条件依赖: +⚠️ **openkal 设计 §6.3 给出的选择键是错的**: ```toml [target.'cfg(all(arch = "riscv64", os = "none"))'.dependencies] openkal-uart = "0.1" ``` -⇒ **这是 D0 的「两个后端(linux / bare)」里缺的那一个**,也是 §7 零 libc 档的 -天然第一个消费者。 +`cfg` 是 **ISA 级**的,而 §6.2 那个后端里的 UART 地址 `0x10000000` 是 **板级**的。 +换一块 RISC-V 板,它会写到一个不存在的地址上——**类型正确、能编、能链、 +跑起来无输出**。这与 §1.2 的分层结论直接冲突:**位置是目标的事实,选择是板级的事实**, +而 UART 基址显然属于后者。 + +**正确形状:后端由板级支持包提供,选择键是那条板级依赖本身。** + +```toml +# 应用:不写任何 cfg —— 板级依赖已经是 target 特定的 +[dependencies] +openkal = "0.5" # 契约 +riscv-virt-rt = "0.3" # 板级支持,同时定义 kal_* 符号 +``` + +| 方案 | 形态 | 评价 | +|---|---|---| +| **(A) 板级包兼任后端** | `riscv-virt-rt` 自己定义 `kal_*` | ⭐ **推荐**。粒度精确,消费者零 cfg。不违反 openkal §21——该条禁止的是实现导出**模块**,而定义 `kal_*` 正是实现该做的事 | +| (B) 每板一个 `openkal-` | 后端与板级包分开 | 板级包不想强绑 openkal 时用;代价是每板两个包 | +| (C) 参数化的通用 MMIO 后端 | 从板级包取基址 | ❌ 后端要反向依赖板级包的模块名,形成耦合环 | +| (D) ISA 级 cfg(设计 §6.3 现状) | `cfg(arch, os)` | ❌ 上述的静默失败 | + +⚠️ 若希望「缺后端」在**解析期**而非链接期报错,可让板级包声明 +`provides = ["openkal-backend"]`,应用侧 `requires` 它——复用 §4 同一套能力词汇。 +不加也可用:openkal 设计 §21.3 已定「缺少实现时链接期报错并点名未定义的函数」。 + +### 6.5 ⭐ `std-freestanding` 不必、也无法「基于 openkal」 + +自然的猜想是 `openkal → openkal-musl/-libc → std-freestanding`,把 picolibc 替掉。 +实测把这条链拆成了两半,结论与猜想不同: + +| | 能否绕过 libc | 依据 | +|---|---|---| +| **头文件** | ❌ **结构性不可绕过** | libc++ 的头 `#include ` / `` / ``。这不是策略选择,是 libc++ 的实现方式 | +| **tier-0 运行期符号** | ⭐ **几乎已经绕过了** | 实测只有 `memmove` + `strlen`,两个纯计算函数,不需要 OS 也不需要 openkal | +| **tier-1(`operator new`)** | 需要一个分配器 | 这里才是 `kal_alloc` 的位置 | +| **tier-2(`format` / 标量 `sort` / 完整 `string`)** | 需要目标版 `libc++.a` | openkal 管不着(§6.3) | + +⇒ **正确表述:`std-freestanding` 与 openkal 的接触面只有 tier-1 的分配器一处。** +「基于 openkal 的 libc」(`openkal-musl` / `openkal-libc`)对**程序**有价值——它让 +一份 C 库跨所有 openkal 实现——但它**不是 `std-freestanding` 的前置**。 + +⚠️ 但要让 `std-freestanding` 坐到 `openkal-libc` 的**头文件**上,仍然要改目标的 +sysroot,因为目标 libc 由目标表行的 `sysroot` 解析、不走依赖图。见 §7.2 的瓶颈。 + +### 6.6 ⚠️ 不拆 `std-freestanding`,另出一个 `std-freestanding-alloc` + +分档的直觉正确,但**不能按头文件拆**。理由写在包自己的注释里: + +> `GENERATED by tools/regenerate.sh. The list is not a curated opinion: it is every +> libc++ header that compiles for a freestanding target, measured by compiling each one.` +> `⚠️ The export table is NOT here and must never be written here.` + +按头文件拆成 `freestanding.core` / `freestanding.<其它>`,等于把一份**生成的**清单 +改回**手工策展的**清单——正好撤销该纪律,而且清单会随 libc++ 版本漂移。 + +⇒ 该拆的是**「程序必须自己提供什么」**,而那不是模块切分,是一个独立的小包: + +``` +mcpplibs/std-freestanding-alloc 约 30 行 +``` + +它提供 §3.2 实测缺失的那 **12 个** `operator new` / `operator delete` 重载 +(含 `align_val_t` 的四个——不写就链不上),转发到一个由工程选定的策略: + +| 策略 | 转发到 | 何时用 | +|---|---|---| +| ⭐ **默认** | `kal_alloc` / `kal_free` | 生态内优先:openkal 是 mcpp 自己的抽象,且它在裸机与宿主上都成立 | +| `libc` | `malloc` / `free` | 板级包已经链了 `-lc`,不想引入 openkal | +| `none` | 不定义,留给工程 | 内核/bootloader 自带分配器 | + +⇒ 默认转 `kal_alloc` 的代价是该包依赖 `openkal`;当目标上没有 openkal 后端时, +链接期报缺 `kal_alloc` 并点名——**与直接报缺 `operator new` 相比,它点的是一个 +有文档、有实现路径的名字**。 + +配套:把 `undefined symbol: operator new` 变成**具名诊断**,点名该包。 +档位是文档与诊断的事,不是模块切分的事。 --- @@ -289,10 +393,48 @@ freestanding 目标 · 自带链接脚本(`link-script`)· 自定义 runner · | 缺口 | 为何挡路 | |---|---| -| **零 libc 档** | 目标行今天硬绑 `sysroot = xim:picolibc-riscv@1.8.12`。内核作者要的是**什么都不要**。需要 `sysroot` 可为空这一档,以及「`main` 指向携带 `_start` 的文件」这条路径的真实示例 | -| **`[target.X].sysroot` 覆盖** | 今天工程换不掉 libc 实现(`docs/13` 已记为边界)。零 libc 档与换 newlib 是同一个旋钮的两种取值 | -| **`x86_64-none-elf` 目标行** | 内核开发的主要战场之一,今天一行都没有 | -| **openarch(D3)** | Context / Trap / AddressSpace / PerCpu。⚠️ 其停止判据最硬:上下文切换或页表项抽象一碎,**后面全是幻觉** | +| ⭐ **`[target.X].sysroot` 覆盖** | **三条线的共同瓶颈**,见下 | +| **零 libc 档** | 目标行今天硬绑 `sysroot = xim:picolibc-riscv@1.8.12`。内核作者要的是**什么都不要**。还需要「`main` 指向携带 `_start` 的文件」这条路径的真实示例 | +| **`x86_64-none-elf` 目标行** | 内核开发的主要战场之一,今天一行都没有。⚠️ UEFI 方向另需一个 **PE 目标**,见 §7.4 | +| **openarch(D3)** | Context / Trap / AddressSpace / PerCpu。⚠️ 其停止判据最硬:上下文切换或页表项抽象一碎,**后面全是幻觉**。实施方案见 [`2026-08-20-openarch-implementation-design.md`](2026-08-20-openarch-implementation-design.md) | + +⭐ **`[target.X].sysroot` 是三条独立需求的同一个瓶颈**: + +| 需求 | 要把 sysroot 改成 | +|---|---| +| 内核 / bootloader 的零 libc 档 | 空 | +| 换一份 C 库实现(newlib 等) | 另一个 xim 包 | +| 让 `std-freestanding` 坐到 openkal 的 C 库头上(§6.5) | `openkal-libc` / `openkal-musl` | + +⇒ 一个旋钮解锁三条线。这比把它列成一条普通边界重要得多,因此它在 §10 的实施顺序里 +排在零 libc 档之前。 + +### 7.4 bootloader 方向:UEFI 是好位置,legacy BIOS 不是 + +openkal 的接口划分与 UEFI 的服务几乎一一对应,因此 `openkal-uefi` 是本层最自然的 +一个后端: + +| openkal interface | UEFI 对应 | +|---|---| +| `stream` | `SIMPLE_TEXT_OUTPUT_PROTOCOL` / `SIMPLE_TEXT_INPUT_PROTOCOL` | +| `memory` | `AllocatePool` / `FreePool` | +| `abort` | `Exit` / `ExitBootServices` 之前的 `ResetSystem` | +| `fs` | `SIMPLE_FILE_SYSTEM_PROTOCOL` / `EFI_FILE_PROTOCOL` | +| `time` | `GetTime` / `Stall` | +| `process` / `task` | **不提供**——UEFI 无进程模型,整组缺席即可 | + +⇒ 一个 bootloader 可以用 `openkal` 写业务(读文件、打印、分配),用 +`std-freestanding` 的 tier-0 写数据结构,而**完全不碰 UEFI 的具体 API**。 + +⚠️ 两条约束: + +1. **UEFI 应用是 PE/COFF(subsystem `EFI_APPLICATION`),不是 ELF。** 目标不是 + `x86_64-none-elf`,需要一个 PE 形态的裸机目标行 + `lld-link`。载荷里有 + `lld-link`,但**该目标行今天不存在**,且它与现有裸机行的形状不同(§5 的 + 决策 B 应当先落地)。 +2. ⚠️ **legacy BIOS 基本不在射程内**:512 字节 MBR 是 16 位实模式,clang 无法 + 有意义地生成。现实的「BIOS 路径」是 **multiboot2 ELF**(由 GRUB 加载), + 而那正好就是 `x86_64-none-elf`,不需要新形态。 ### 7.3 ⭐ 最小示例不是一个内核 @@ -403,12 +545,17 @@ C3 的三个新问题:ROM bootloader 的镜像头(esptool 格式)· 没有 semih | 序 | 决策 | 判据(达成即继续) | 停止信号 | |---|---|---|---| | 1 | **A** freestanding 包能力 | 一个既有库为 `riscv64-none-elf` 构建通过并被索引记录;裸机工程 `mcpp add` 一个 hosted-only 库时在**解析期**被告知 | —— (代价极低,无停止条件) | -| 2 | **B(a)** 工程内定义目标 | 一个未进引擎表的 triple 仅凭 `mcpp.toml` 即可构建;`resolve()` 仍是唯一读取点 | 若发现必须开第二条解析路径 ⇒ 停,回到 §5.3 重设计 | -| 3 | **D** 零 libc 档 + 最小示例 | 把 picolibc 排除后仍能构建并在 qemu 打印一行 | —— | -| 4 | **E 步 1** Cortex-M QEMU | 两个新载荷(`picolibc-arm` / `qemu-arm`)+ 目标表一行 + 一个板级包,源码零改地跑通;⭐ **引擎零改动** | 若引擎必须改 ⇒ 「ISA 表是数据」不成立,回炉 | -| 5 | **C** openkal 裸机后端 | D0 的「两个后端」补齐;conformance 双向通过 | —— | -| 6 | **E 步 2** RP2040 | 真烧录 + 真 UART | —— | -| 7 | **B(b)/(c)** 目标定义包 / 索引化 | 老客户端降级正确 | —— | +| 2 | **C′** `std-freestanding-alloc`(§6.6) | `std::vector` 在裸机上**不需要用户写一行 `operator new`** 即可跑通 | —— (约 30 行,无停止条件) | +| 3 | **C″** openkal 裸机后端(§6.4) | D0 的「两个后端」补齐;⭐ 后端**由板级包提供**,应用侧零 `cfg` | 若发现必须回到 ISA 级 cfg ⇒ 分层结论有误,回到 §1.2 | +| 4 | ⭐ **D′** `[target.X].sysroot` 覆盖 | 一个工程仅凭 `mcpp.toml` 把 sysroot 换成空 / 另一个包 | —— (**三条线的共同瓶颈**,应早做) | +| 5 | **B(a)** 工程内定义目标 | 一个未进引擎表的 triple 仅凭 `mcpp.toml` 即可构建;`resolve()` 仍是唯一读取点 | 若发现必须开第二条解析路径 ⇒ 停,回到 §5.3 重设计 | +| 6 | **D** 零 libc 档 + 最小示例 | 把 picolibc 排除后仍能构建并在 qemu 打印一行 | —— | +| 7 | **E 步 1** Cortex-M QEMU | 两个新载荷(`picolibc-arm` / `qemu-arm`)+ 目标表一行 + 一个板级包,源码零改地跑通;⭐ **引擎零改动** | 若引擎必须改 ⇒ 「ISA 表是数据」不成立,回炉 | +| 8 | **E 步 2** RP2040 | 真烧录 + 真 UART | —— | +| 9 | **B(b)/(c)** 目标定义包 / 索引化 | 老客户端降级正确 | —— | + +⚠️ 序 1–4 全部是**小改动且互不依赖**,它们合起来解除的阻塞比序 7 那个真实案例更多。 +序 4 之所以提前,是因为它一个旋钮解锁三条线(§7.2)。 ⚠️ **openhal(D2)与 openarch(D3)不进本表。** 它们的门在 D 档路线图里, 且 D2 的停止信号是「驱动作者不来就停」——那不是技术判据,不能靠实施推进。 @@ -425,6 +572,8 @@ C3 的三个新问题:ROM bootloader 的镜像头(esptool 格式)· 没有 semih | `picolibc-arm` / `qemu-arm` | 实测 xim 索引中**均不存在**,两者都要新建;上游可用性(picolibc 的 ARM 支持、xPack 的 `qemu-arm-xpack`)**未逐一核实** | | RP2040 / ESP32-C3 | 均未尝试 | | `provides = ["freestanding"]` 的索引侧表示 | 形状已定(证据推导),**schema 未设计** | +| `openkal-uefi` / PE 形态的裸机目标 | 形状已论证(§7.4),**未实现,也未验证 `lld-link` 能产出 EFI 应用** | +| `std-freestanding-alloc` 的默认策略 | 选定 `kal_alloc`(§6.6),但该包**依赖 openkal 后端存在**,而裸机后端尚未实现 | | macOS / Windows 宿主上的裸机链 | 无持续验证(`docs/13` 已记) | --- @@ -435,8 +584,11 @@ C3 的三个新问题:ROM bootloader 的镜像头(esptool 格式)· 没有 semih |---|---|---| | **A** | freestanding 成为包能力 | 复用 `provides`,但**由构建证据推导而非作者声称** | | **B** | 目标表可扩展 | **先工程内定义,再随包走,最后索引化**;单一读取点不可破 | -| **C** | `std-freestanding` 与 openkal 互补 | openkal 解决 OS 服务可移植;**解决不了目标版 `libc++.a`** | +| **C** | `std-freestanding` 与 openkal 互补 | ⭐ 实测:**接触面只有 tier-1 的分配器一处**;tier-0 只需 `memmove`+`strlen`。openkal 解决不了目标版 `libc++.a` | +| **C′** | 不拆 `std-freestanding`,另出 `-alloc` | 按头文件拆会把生成的清单改回手工清单;该拆的是**「程序必须提供什么」**,默认转 `kal_alloc` | +| **C″** | 裸机后端由**板级包**提供 | ⚠️ 设计 §6.3 的 ISA 级 `cfg` 会让换板后**写到不存在的地址、静默无输出** | | **D** | 零 libc 档 | 最小示例不是一个内核,是**一个零 libc 的启动工程**——它同时是该档的唯一判据 | +| **D′** | `[target.X].sysroot` 覆盖 | ⭐ **三条线(零 libc / 换 libc / openkal 栈)的同一个瓶颈** | | **E** | 下一个案例 | **Cortex-M + QEMU 先行**(编译器载荷已有 `arm`/`thumb`,但 **C 库与模拟器两个载荷都要新建**);ESP32 分型号,Xtensa 不成立 | ⚠️ 五项决策全部不要求引擎认识 KAL / HAL / ARCH,也全部不新增引擎轴—— diff --git a/.agents/docs/2026-08-20-openarch-implementation-design.md b/.agents/docs/2026-08-20-openarch-implementation-design.md new file mode 100644 index 00000000..d9114381 --- /dev/null +++ b/.agents/docs/2026-08-20-openarch-implementation-design.md @@ -0,0 +1,267 @@ +# openarch 实现方案:arch 机制层 + +**状态**:设计,未实施。本文给出仓库形态、接口划分、里程碑与**可证伪的判据**。 + +配套文档: + +- [`2026-08-19-freestanding-baremetal-design.md`](2026-08-19-freestanding-baremetal-design.md) + —— §11 提出三层,§11.7 给出 D3 的门。 +- [`2026-08-19-freestanding-baremetal-implementation-plan.md`](2026-08-19-freestanding-baremetal-implementation-plan.md) + —— §7 的 D3 行:交付物、前置、继续判据、停止信号。 +- [`2026-08-20-openkal-design.md`](2026-08-20-openkal-design.md) + —— 契约机制的先例;§21 的模块归属规则在本文沿用。 +- [`2026-08-20-freestanding-ecosystem-positioning.md`](2026-08-20-freestanding-ecosystem-positioning.md) + —— 本层在生态中的位置。 + +证据来源:本文**没有实测**。openarch 尚未有任何代码,所有关于每个 arch 的机制差异 +均来自体系结构手册的公开知识,标注为「设计主张」。⚠️ 这一点很重要: +D3 的性质决定了**只有 A0 探针能产生第一条实测**,在那之前本文全部内容都是待验证的。 + +--- + +## 0. 一句话 + +openarch 是**唯一一个实现者集合有界的层**,因此它的仓库形态、里程碑顺序与判据 +都应当与 openkal / openhal 相反:**单仓、先做最硬的、以证伪为目标**。 + +--- + +## 1. 与 openkal / openhal 的根本差异 + +| | openkal | openhal | **openarch** | +|---|---|---|---| +| 实现者集合 | **开放**:任何 OS / 内核 | **开放**:任何芯片 / 板 | ⭐ **有界且小**:x86_64 · aarch64 · riscv64 · 或许 arm32 / loongarch | +| 谁会去实现 | 第三方 | 第三方(D0 的真变量) | **基本只有自己** | +| 契约机制 | C ABI | concept | concept + `inline` / 汇编符号 | +| 成败取决于 | 第三方来不来 | 驱动作者来不来 | ⭐ **抽象碎不碎** | + +⇒ 前两层是**生态问题**,openarch 是**技术问题**。生态问题靠等待和推广,技术问题 +靠尽早证伪。本文全部设计围绕这一条。 + +--- + +## 2. 决策:单仓库 + +### 2.1 结论与理由 + +**`mcpplibs/openarch` 一个仓库,内含接口模块与全部 arch 后端。** + +| 理由 | 说明 | +|---|---| +| 实现者是自己 | 没有第三方需要独立发版节奏 | +| ⭐ **接口与实现必须共同演化** | D3 的门是「加第二个 arch 时抽象不碎」——**只有加第二个 arch 才知道接口错在哪**。拆仓会把决定这一层生死的那个循环拖慢 | +| 选择机制现成 | `cfg(arch = ...)` 条件源码,引擎零新增轴 | +| 数量有界 | 四到五个 arch,单仓的维护面不会失控 | + +### 2.2 ⚠️ 单仓不等于放弃边界 + +仓内仍**必须**保持模块边界,沿用 openkal §21: + +- 接口模块 `openarch.context` / `openarch.trap` / … 由**规范侧**拥有; +- 后端**不导出任何模块**,只提供定义(concept 的 model、以及汇编符号); +- 后端源码按 `cfg(arch = ...)` 条件编入,**不参与模块名**。 + +⇒ 将来若某个 arch 需要独立发版,拆分是机械操作而不是重构。 + +### 2.3 ⚠️ 单仓的 CI 代价 + +接口仓天然 target 中立,而 openarch 的仓 **必须按 arch 做矩阵**:每个 arch 至少 +「交叉编译通过 + 在模拟器里跑通探针」。只跑宿主那一个 arch 的 CI **等于没有 CI**, +因为本层的全部风险都在 arch 之间的差异上。 + +### 2.4 目录形态 + +``` +openarch/ + mcpp.toml + src/ + context.cppm 接口:concept + 类型 ← 规范侧 + trap.cppm + addrspace.cppm + percpu.cppm + tick.cppm + barrier.cppm + boot.cppm + arch/ + riscv64/ context.S trap.S boot.S addrspace.cpp ← 后端,cfg 条件编入 + aarch64/ context.S trap.S boot.S addrspace.cpp + x86_64/ context.S trap.S boot.S addrspace.cpp + tests/ + probe_context_zero_cost.cpp ← A0 + probe_addrspace_attrs.cpp ← A0 +``` + +```toml +[target.'cfg(arch = "riscv64")'.build] +sources = ["src/arch/riscv64/**"] +[target.'cfg(arch = "aarch64")'.build] +sources = ["src/arch/aarch64/**"] +``` + +--- + +## 3. 接口划分:只含机制,不含策略 + +判别式沿用 openkal §2.2 的形式: + +> 一个东西若在不同 arch 上**只是实现方式不同**,它是机制; +> 若在不同 arch 上**可以有不同的合理选择**,它是策略,不进本层。 + +| interface | 机制 | 明确不含的策略 | 变异程度 | +|---|---|---|---| +| `boot` | 从固件/前一级交接到 C++:建栈、清 BSS、取 CPU id | 用什么引导协议、设备树怎么解析 | ⚠️ **极高** | +| `context` | 上下文表示 + 切换 | 调度 | ⭐ **高(最硬之一)** | +| `trap` | 向量表安装 + 最小分发 | 每个 cause 做什么 | ⭐ 高 | +| `addrspace` | 页表项构造 + 安装 + TLB 失效 | VMA 管理、缺页策略 | ⭐⭐ **最高(最硬之一)** | +| `percpu` | 每 CPU 基址的读写 | 里面放什么 | 低 | +| `tick` | 设定下一次定时中断、应答 | 时间片长度 | 中 | +| `barrier` | `std::atomic` 覆盖不到的屏障(指令屏障、TLB 屏障) | — | 低 | + +### 3.1 `boot` 为什么不可能统一签名 + +交接状态三者完全不同:riscv64 由 SBI 从 M 态进入,`a0 = hartid`;aarch64 从 +EL2/EL1 进入,`x0 = DTB` 指针;x86_64 由 multiboot2(`ebx = info`)或 UEFI +(image handle + system table)进入。 + +⇒ **本层不统一入口签名,只统一「交接完成后的后置条件」**:栈可用、BSS 已清零、 +`percpu::base()` 可用、`cpu_id()` 可读。入口本身是每 arch 一段 `.S`,由**上层** +(内核或 bootloader)决定用哪个引导协议。 + +--- + +## 4. ⭐ 两个最硬的原语 + +D3 的门只认这两个。本文其余部分都可以推迟,这两个不能。 + +### 4.1 上下文切换 + +**共通的洞察**:切换发生在一次普通函数调用点上,因此**只需保存 callee-saved 寄存器**。 +调用者已经替我们保存了 caller-saved 的部分。这条对三个 arch 都成立,是抽象成立的基础。 + +| arch | 需保存 | +|---|---| +| riscv64 | `ra` `sp` `s0`–`s11`(13 个整数寄存器);若用浮点则加 `fs0`–`fs11` | +| aarch64 | `x19`–`x30` `sp`;`d8`–`d15` | +| x86_64 (SysV) | `rbx` `rbp` `r12`–`r15` `rsp` | + +接口形状: + +```cpp +export module openarch.context; + +export namespace arch { + +// 不透明存储。大小与对齐由后端给出,上层只持有它。 +struct context; // 后端提供完整定义 + +// 把一个新上下文初始化成「一旦被切入就从 entry(arg) 开始执行」。 +void context_init(context&, void (*entry)(void*), void* arg, void* stack_top); + +// 保存当前到 from,恢复 to。返回时,当前上下文是 from。 +[[gnu::returns_twice]] void context_switch(context& from, context& to); + +} +``` + +⚠️ **`context_switch` 必须是纯汇编符号,不能是 C++ 函数。** 编译器插入的 +prologue/epilogue 会在换栈之后运行,访问的是错误的栈。 + +⇒ **这意味着本原语的「零成本」不来自 `inline`,而来自「它本来就是一次 call」。** +concept 在这里的作用是给一个汇编符号一个类型化的门面,不是消除调用开销。 +本文把这一点写明,因为方案 §16.2 的「显式 `inline` 是首选,LTO 是兜底」在这条上 +**不适用**,照搬会得到一个编译不过或运行即崩的设计。 + +**零成本判据(可证伪)**: + +> 用 concept 门面写的切换,与直接 `call` 汇编符号,**LTO 后反汇编逐指令相同**。 +> 多出任何一条指令即判定不通过。 + +### 4.2 页表项与地址空间 + +变异是全层最大的: + +| arch | 级数 | 页大小 | 内存属性机制 | +|---|---|---|---| +| x86_64 | 4 级 / 5 级 | 4K / 2M / 1G | **PAT 索引**(PTE 三位选 8 个 PAT 项) | +| aarch64 | 4 级(granule 相关) | 4K / 16K / 64K | **MAIR 索引**(PTE 三位选 8 个 MAIR 项),另有 TTBR0/TTBR1 分半 | +| riscv64 | Sv39 / Sv48 / Sv57 | 4K / 2M / 1G | ⭐ **PTE 位直给**,无间接表(Svpbmt 才有 PBMT 位) | + +⭐ **内存属性是 A 类裂缝(能编但语义不同)的教科书例子**:三者都能表达 +「device memory」与「normal cacheable」,但 x86/aarch64 需要**先配置一张间接表**, +riscv 不需要。一个只写 `map(va, pa, Cache::Device)` 的通用内核,在 riscv 上正确, +在 aarch64 上**取决于 MAIR 是否已被正确初始化**——而初始化 MAIR 是策略还是机制, +本身就是要在 A0 里回答的问题。 + +**判据(可证伪)**: + +> 一段**通用**的映射代码 `map(va, pa, Perm::RW | Cache::Device)`,在 riscv64 与 +> aarch64 上产生的映射,**用同一段通用测试代码验证语义相同**:写入后读回、 +> 且该页在两边都确实是非缓存的(以 arch 特定手段观察)。 +> +> 若必须在通用代码里出现 `#if arch` 或必须暴露「先初始化属性表」这一步给上层, +> ⇒ **该抽象碎了**,`addrspace` 应缩小到只做页表**结构**(级数、遍历、失效), +> 把属性移到能力轴。 + +--- + +## 5. 里程碑:先做最硬的 + +⚠️ **顺序与常规相反,而且这是刻意的。** 常规做法是先做容易的(`percpu`、`barrier`) +把仓库跑起来。在本层这是错的:容易的部分**全部做完也不能证明这一层成立**, +而一旦最硬的碎了,前面做的全部是沉没成本。 + +方案 §7.1 的停止信号已经写明:「⚠️ 碎在这里,后面全是幻觉」。 + +| 阶段 | 交付 | ⭐ 继续的判据 | ⚠️ 停止信号 | +|---|---|---|---| +| **A0 探针** | **只做 §4 两条**,riscv64 + aarch64,**不建完整仓库结构**,不做其余五个 interface | ① 上下文切换的反汇编逐指令判据通过;② 通用 `map()` 的语义判据通过 | 任一不通过 ⇒ **停**。缩小 openarch 范围至「结构而非属性」后重新评估,或整层放弃 | +| **A1 骨架** | 仓库形态(§2.4)· 接口模块 · `cfg` 条件源码 · **CI 按 arch 矩阵** | 两个 arch 都能交叉编译并在模拟器里跑通 A0 的两个探针 | CI 只能跑宿主 arch ⇒ 先解决 CI,不要继续加接口 | +| **A2 中变异** | `boot` · `trap` · `percpu` | trap 的 cause **不被统一成一个 enum**,而是 arch 特定类型 + 少数共通谓词 | 若为统一 cause 而丢失信息 ⇒ 退回 arch 特定 | +| **A3 低变异** | `tick` · `barrier` | tick 的**频率来源**被明确放在能力轴而非接口里 | — | +| **A4 第三个 arch** | x86_64 | ⭐ **接口零改动**地接纳 x86_64 | 若必须改接口 ⇒ 前两个 arch 把接口**过拟合**了,回到 A0 的判据重做 | + +### 5.1 为什么第三个 arch 是独立的一关 + +两个 arch 可以偶然相似。riscv64 与 aarch64 都是 RISC、都有独立的页表根寄存器、 +都用 load/store 架构。**x86_64 是最不像的那一个**(段、PAT、IDT 的 16 字节项、 +`swapgs`),因此它才是接口是否被过拟合的检验。 + +⇒ A4 不是「再加一个 arch」,它是**A0 判据的第二次执行**。 + +--- + +## 6. 与其它层的接线 + +| | 关系 | +|---|---| +| **openkal** | 无直接依赖。openkal 是**内核对上**的 ABI,openarch 是**内核对下**的机制。一个内核同时用两者:用 openarch 建起地址空间与陷入,用 openkal 向应用提供服务 | +| **openhal** | 无直接依赖。⚠️ 边界已定:**寄存器 / ISR / DMA 进不了 openhal**,它们属本层与驱动层 | +| **`std-freestanding`** | 单向:openarch 可以用它的 tier-0 部分(`array` `span` `optional` `bit`),实测这些只需 `memmove` + `strlen` 两个纯函数 | +| **引擎** | ⭐ **零关系。** 引擎永不认识 openarch,后端选择是 `cfg(arch = ...)` 条件源码 | + +--- + +## 7. ⚠️ 当前边界 + +| 边界 | 状态 | +|---|---| +| 本文全部 arch 差异描述 | **来自手册的公开知识,未实测**。第一条实测只能由 A0 产生 | +| 「只保存 callee-saved」 | 设计主张。中断上下文(与函数调用点不同)需要保存全部寄存器,本文**未覆盖**该情形 | +| 反汇编逐指令判据 | 形状已定,**工具链侧如何自动化未设计** | +| `aarch64` / `x86_64` 的模拟器与目标行 | 均不存在。A1 的 CI 需要 `qemu-system-aarch64`,而 xim 索引里**只有 `qemu-riscv`** | +| 中断上下文、SMP 启动、IPI | **本文未涉及**,应在 A2 之后单独评估 | +| 浮点 / 向量上下文 | 未涉及。它会让 `context` 的大小成为 cfg 的函数 | + +--- + +## 8. 决策回执 + +| | 决策 | 一句话 | +|---|---|---| +| **1** | 单仓库 | 实现者是自己,且**接口与实现必须共同演化** | +| **2** | 仓内保持模块边界 | 后端不导出模块,将来拆分是机械操作 | +| **3** | CI 必须按 arch 矩阵 | 只跑宿主 arch 等于没有 CI | +| **4** | ⭐ **先做最硬的两条** | 容易的部分做完也不能证明这一层成立 | +| **5** | `context_switch` 是汇编符号 | 零成本不来自 `inline`,照搬 §16.2 会得到崩溃的设计 | +| **6** | 内存属性是 A 类裂缝 | 碎了就把 `addrspace` 缩到「结构而非属性」 | +| **7** | x86_64 是第二道门 | 它是过拟合检验,不是「再加一个 arch」 | From 259e1b602cf15f24c70600fcc1353e7a94899886 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:42:29 +0800 Subject: [PATCH 04/20] =?UTF-8?q?docs:=20=E5=88=86=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E7=9A=84=20feature=20=E5=BD=A2=E7=8A=B6,=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E6=8A=8A=E5=AE=83=E5=AE=9A=E6=AD=BB=E7=9A=84?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 最终形状(方案文档 §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 通过(双语标题层级一致)。 --- ...8-20-freestanding-ecosystem-positioning.md | 110 +++++++++++++++--- docs/05-mcpp-toml.md | 57 +++++++++ docs/zh/05-mcpp-toml.md | 46 ++++++++ 3 files changed, 199 insertions(+), 14 deletions(-) diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md b/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md index 7fa0ce44..1d2d8ede 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md @@ -355,27 +355,92 @@ sysroot,因为目标 libc 由目标表行的 `sysroot` 解析、不走依赖图 按头文件拆成 `freestanding.core` / `freestanding.<其它>`,等于把一份**生成的**清单 改回**手工策展的**清单——正好撤销该纪律,而且清单会随 libc++ 版本漂移。 -⇒ 该拆的是**「程序必须自己提供什么」**,而那不是模块切分,是一个独立的小包: +⇒ 该拆的是**「程序必须自己提供什么」**。而 `alloc` 不是一个头文件开关: + +⚠️ **它不改变哪些东西能编,只改变哪些东西能链上。** 103 个头今天已经无条件包含, +`std::vector` 现在就编得过,失败发生在链接(§3.2 实测)。 + +| 不需要 `alloc` | 需要 `alloc` | +|---|---| +| `array` `span` `optional` `expected` `atomic` `string_view` `ranges` `bit` `charconv` `tuple` | `vector` `string` `deque` `list` `map` `set` `unordered_*` `function` `any` `make_unique`;⚠️ **协程**(帧默认经 `operator new`) | + +上表只有 `vector` 一行直接测过,其余由「缺 `operator new`」这一机制推得。 + +### 6.6.1 最终形状:feature 开关 + `feature-deps` 拉默认 + capability 仲裁 + +⭐ **这不是新发明,是本仓库已文档化的形状**(`docs/05` §2.8.2 的 `backend-openblas`: +一个 feature 同时**拉 provider** 并**打开消费者开关**),照搬到分配器: + +```toml +# mcpplibs/std-freestanding +[features] +default = [] +alloc = { requires = ["freestanding-allocator"] } # 消费者开关 +alloc-kal = { implies = ["alloc"] } # 内置默认:开它就够 + +[feature-deps.alloc-kal] +std-freestanding-alloc-kal = "0.1.x" # 仅在 alloc-kal 激活时解析 +``` + +| 场景 | 工程里写什么 | +|---|---| +| tier-0 固件 | `std-freestanding = "0.2.0"` — 不碰分配器 | +| ⭐ **默认路径** | `features = ["alloc-kal"]` — 一个 feature,实现自动进图,**不必知道 provider 包名** | +| 自定义 / 第三方 | `features = ["alloc"]` + 自己那个 `provides = ["freestanding-allocator"]` 的包 | + +它同时拿到四件事:**实现分离**(本体零依赖,openkal 只出现在 `feature-deps` 下)· +**默认一键可得** · **可换** · **并集风险被转化**(依赖擅自开 `alloc-kal` 而应用又自带 +provider 时,得到的是解析期两个 provider 报错,不是链接期一句 +`duplicate symbol: operator new`)。 + +### 6.6.2 ⚠️ 三条被否决的形态,以及否决它们的实测 + +| 形态 | 否决理由 | +|---|---| +| 按头文件拆 `freestanding.core` / `freestanding.<其它>` | 把**生成的**清单改回**手工策展的**清单,撤销包自己写明的纪律,且清单随 libc++ 版本漂移 | +| `nolibc` 做成 feature | ⚠️ **非加性**:feature 在图上并集,任一包打开则全图打开,而板级包链了 `-lc` 时 `memmove`/`strlen` 重复。且加性 feature **无法被消费者关掉**。它是**目标**的属性(sysroot 有没有 libc),做成包级开关就是同一决策的第二处推导 | +| provider 用弱符号提供「可被顶掉的默认」 | ⚠️ 两份实现同时在图里**不报错**,按链接顺序静默选一个——把解析期能报的错换成运行期的静默错。libc++ 自己不这么做,它靠归档 | + +### 6.6.3 ⭐ 实测:依赖以裸 `.o` 参与链接,因此「库给默认、程序顶掉」不成立 + +`build.ninja` 的链接边(零 libc 工程,实测): ``` -mcpplibs/std-freestanding-alloc 约 30 行 +build bin/solo | bin/solo.map : cxx_link obj/mcpplibs_std-freestanding/src/std_freestanding.m.o \ + obj/main.o obj/mcpplibs_std-freestanding/src/verbose_abort.o ``` -它提供 §3.2 实测缺失的那 **12 个** `operator new` / `operator delete` 重载 -(含 `align_val_t` 的四个——不写就链不上),转发到一个由工程选定的策略: +**整份文件零个 `.a`。** `StaticLibrary` 只在**根清单**声明 `kind = "lib"` 的目标上产生 +(`plan.cppm:1637`),不是依赖包被消费的形态。 -| 策略 | 转发到 | 何时用 | -|---|---|---| -| ⭐ **默认** | `kal_alloc` / `kal_free` | 生态内优先:openkal 是 mcpp 自己的抽象,且它在裸机与宿主上都成立 | -| `libc` | `malloc` / `free` | 板级包已经链了 `-lc`,不想引入 openkal | -| `none` | 不定义,留给工程 | 内核/bootloader 自带分配器 | +⇒ libc++ 那套「库里常驻一份默认、程序定义了就顶掉」依赖**归档语义**(成员仅在符号 +仍未定义时才拉入);目标文件的定义**无条件进入链接**,所以那条路今天走不通。 +这正是 §6.6.1 用「开关控制是否存在」而非「默认存在可覆盖」的原因——两份定义**从不共存**。 + +### 6.6.4 ⭐ 实测:capability 消歧**不裁剪链接行** + +两个包都 `provides = ["alloc-cap"]` 且都定义同名符号: + +``` +error: capability 'alloc-cap' has multiple providers in the graph: [pa, pb]; + select one with [capabilities] alloc-cap = "" or --cap alloc-cap= +``` + +按提示指定 `[capabilities] alloc-cap = "pa"` 之后: -⇒ 默认转 `kal_alloc` 的代价是该包依赖 `openkal`;当目标上没有 openkal 后端时, -链接期报缺 `kal_alloc` 并点名——**与直接报缺 `operator new` 相比,它点的是一个 -有文档、有实现路径的名字**。 +``` +ld: obj/mcpplibs_pa/src/impl.o: in function `cap_probe': + multiple definition of `cap_probe'; obj/mcpplibs_pb/src/impl.o: first defined here +``` + +链接边上**两份 `.o` 都在**。 + +⇒ **capability 绑定的是「谁满足这条要求」,不是「哪些目标文件参与链接」。** +对**单例符号**类的 provider(`operator new` 即是),`[capabilities]` 消歧会把一个 +点名两个 provider 的好错误,换成一句 `multiple definition` 的坏错误。 -配套:把 `undefined symbol: operator new` 变成**具名诊断**,点名该包。 -档位是文档与诊断的事,不是模块切分的事。 +⚠️ 因此分配器能力的规则要比 `blas` 严一档:**两个 provider 同时在图里是要修的错误, +不是可消歧的状态。** 这一条已补进 `docs/05` §2.8.1。 --- @@ -389,6 +454,23 @@ freestanding 目标 · 自带链接脚本(`link-script`)· 自定义 runner · ⇒ 写内核最繁琐的构建部分基本齐备。实测:一个零依赖的 freestanding 工程可以构建, 产物 `text 12 data 0 bss 0 total 12`——这证明仅凭 ISA 表行就足以产出正确目标文件。 +⭐ **更强的一条实测:零 libc 链接已经成立。** 一个只依赖 `std-freestanding`、 +**没有板级包、没有 `-lc`、没有 crt0** 的工程,自带 `memmove` + `strlen` + `_start` +之后**链接通过**,`text 15866`。 + +⚠️ **但未验证它能否启动**,而且已知不能:实测 ELF 的加载地址是 `0x10000`, +qemu `virt` 需要 `0x80000000`。 + +⇒ ⭐ **零 libc 档缺的不是 libc,是链接脚本**——而链接脚本是**板级事实**,不是 libc +事实(§1.2)。这一条反而加强了分层结论:去掉 libc 之后剩下的那件事,恰好落在 +分层里已经有归属的那一格。 + +⚠️ 那两个函数**不应无条件放进 `std-freestanding`**:它们是 libc 符号,板级包一旦链了 +`-lc` 就会重复(§6.6.3 已证依赖以裸 `.o` 参与链接,重复即硬错误)。而且它们本不同层—— +`memcpy`/`memmove`/`memset`/`memcmp` 是**编译器要求的**(freestanding 实现也必须提供, +因为编译器会自行发出对它们的调用),属 ABI 层;只有 `strlen` 真的是 libc。 +⇒ 应为独立小包,由零 libc 档拉入。 + ### 7.2 缺口 | 缺口 | 为何挡路 | diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 925c6a26..74ba7aa1 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1076,6 +1076,25 @@ The bound provider's link/include flags reach the consumer through normal dependency mechanics; the capability layer is the *selection-and-validation* step that turns a silently-wrong or missing backend into a loud configure-time error. +**Binding selects a provider; it does not prune the link line.** A dependency +package contributes its object files to the consumer's link regardless of whether +its capability was the one bound. Measured with two packages that both provide +one capability and both define `cap_probe`: unpinned, resolution fails as the +table says; after pinning one with `[capabilities]`, the build reaches the linker +and fails there instead — + +``` +ld: obj/mcpplibs_pa/src/impl.o: in function `cap_probe': + multiple definition of `cap_probe'; obj/mcpplibs_pb/src/impl.o: first defined here +``` + +This matters for a capability whose providers define the **same symbols** — a +whole-program singleton such as `operator new`, or a C API with one fixed name +set. For those, two providers in the graph is a defect to fix rather than an +ambiguity to pin: pinning replaces an error that names both candidates with one +that names a mangled symbol. Interchangeable *libraries* (BLAS implementations, +which export distinct symbol sets and are selected per link) are unaffected. + ### 2.8.2 `[feature-deps.]` — dependencies a feature pulls in A dependency declared under `[feature-deps.]` is **optional**: it is @@ -1112,6 +1131,44 @@ features = { } ``` +#### A default implementation that stays replaceable + +The same three pieces cover the case where a library wants to *offer* an +implementation without *imposing* one — a whole-program singleton such as +`operator new`, a logging sink, or a panic handler: + +```toml +[features] +default = [] +# The consumer-side switch: "I use the part of this library that needs an allocator". +alloc = { requires = ["freestanding-allocator"] } +# The built-in default: activating this one is enough. +alloc-kal = { implies = ["alloc"] } + +# Resolved only when `alloc-kal` is active, so the library itself carries no +# dependency on the implementation. +[feature-deps.alloc-kal] +std-freestanding-alloc-kal = "0.1.x" +``` + +Three usages, one line each: + +| Consumer needs | What the manifest says | +|---|---| +| none of the allocating parts | `std-freestanding = "0.2.0"` — no allocator enters the graph | +| the default | `features = ["alloc-kal"]` — the implementation arrives with it, and its package name never has to be known | +| its own or a third party's | `features = ["alloc"]` plus a package that `provides = ["freestanding-allocator"]` | + +Two properties make this preferable to shipping the implementation +unconditionally. A library that ships one takes a decision belonging to the +program, and it cannot be undone: features are **additive**, so there is no way +for a consumer to switch a default *off*. And because a dependency package's +objects link unconditionally (§2.8.1), a shipped default plus a program-supplied +one is a duplicate definition rather than a replacement — the archive semantics +that let a C++ standard library offer a replaceable `operator new` do not apply +to a package dependency. Keeping the implementation behind a switch means the +two never coexist. + ### 2.8.3 `[scan_overrides.""]` — Author-Asserted Scan Results The default module scanner is a text-level pass that (deliberately) rejects diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 31bae2da..f6632dc6 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -943,6 +943,20 @@ compat.openblas = "0.3.0" # provider 必须是图中真实存在的依赖 被绑定 provider 的链接/头文件旗标经由常规依赖机制流到消费方;capability 层是那道 *选择与校验* 步骤,把"静默选错后端 / 缺后端"变成构建期的显式报错。 +**绑定选中的是 provider,它不裁剪链接行。** 依赖包的目标文件一律进入消费方的链接, +与它的能力是否被绑定无关。实测:两个包都提供同一能力且都定义 `cap_probe`,未 pin 时 +解析按上表报错;按提示用 `[capabilities]` pin 其中一个之后,构建走到链接器才失败—— + +``` +ld: obj/mcpplibs_pa/src/impl.o: in function `cap_probe': + multiple definition of `cap_probe'; obj/mcpplibs_pb/src/impl.o: first defined here +``` + +这一点对**多个 provider 定义同一批符号**的能力有影响 —— 全程序单例(例如 +`operator new`),或名字集合固定的 C 接口。对这类能力,图中出现两个 provider 是**待修的 +缺陷**而非可 pin 的歧义:pin 会把一个点名两个候选的报错,换成一个点名 mangled 符号的报错。 +可互换的**库**(各 BLAS 实现导出不同的符号集合,按链接选其一)不受此影响。 + ### 2.8.2 `[feature-deps.]` —— 由 feature 拉取的依赖 在 `[feature-deps.]` 下声明的依赖是**可选的**:仅当该 feature 激活时(根 `--features`, @@ -977,6 +991,38 @@ features = { } ``` +#### 保持可替换的默认实现 + +同样这三件东西,也覆盖"库希望**提供**一份实现但不**强加**一份"的情形 —— 全程序单例, +例如 `operator new`、日志 sink、panic handler: + +```toml +[features] +default = [] +# 消费方开关:"我用到了本库中需要分配器的那部分"。 +alloc = { requires = ["freestanding-allocator"] } +# 内置默认:激活它就够了。 +alloc-kal = { implies = ["alloc"] } + +# 仅在 `alloc-kal` 激活时解析,因此库本体不携带对该实现的依赖。 +[feature-deps.alloc-kal] +std-freestanding-alloc-kal = "0.1.x" +``` + +三种用法各一行: + +| 消费方需要 | 清单里怎么写 | +|---|---| +| 不用会分配的那部分 | `std-freestanding = "0.2.0"` —— 分配器不进图 | +| 默认实现 | `features = ["alloc-kal"]` —— 实现随之进图,**无需知道其包名** | +| 自己的或第三方的 | `features = ["alloc"]` 加一个 `provides = ["freestanding-allocator"]` 的包 | + +有两条性质使该形状优于无条件随包提供实现。随包提供实现的库替程序做了本属程序的决定, +而且**撤销不掉**:feature 是**加性**的,消费方没有把某个默认**关掉**的手段。以及,由于 +依赖包的目标文件无条件参与链接(§2.8.1),随包的默认加上程序自备的那份是**重复定义** +而非替换 —— 让 C++ 标准库能提供可替换 `operator new` 的那套归档语义,对包依赖并不适用。 +把实现放在开关之后,意味着两者**从不共存**。 + ### 2.8.3 `[scan_overrides.""]` —— 作者断言的扫描结果 默认的模块扫描器是文本级的一遍扫描,它(刻意地)拒绝条件预处理块内部的 `import` From 32f5e537a835bc2d5adf5d1588bfc8e23eb8d094 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:05:29 +0800 Subject: [PATCH 05/20] =?UTF-8?q?feat(target):=20[target.X].sysroot=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E7=9B=AE=E6=A0=87=20C=20=E5=BA=93?= =?UTF-8?q?=E5=8F=AF=E6=8C=89=E5=B7=A5=E7=A8=8B=E8=A6=86=E7=9B=96,?= =?UTF-8?q?=E5=B9=B6=E5=BC=95=E5=85=A5=E9=9B=B6=20libc=20=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目标表把一份 C 库绑在每个 triple 上,而三条互不相干的需求都想和它分歧:内核要 一份都不要、厂商 SDK 上的工程要那份 newlib、std-freestanding 坐到 openkal 的 C 库上也要换。⇒ 一个旋钮解锁三条线。 ## 形状 `[target.].sysroot`,与 `toolchain` 覆盖 `pin` 同轴:一个指名编译器, 另一个指名 C 库,两者本来都只有引擎能决定。 ⚠️ 字段是 `std::optional` 而不是 `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`)才对。这是同一个 坑的第四次。 --- src/build/prepare.cppm | 44 +++++++++++--- src/manifest/toml.cppm | 24 +++++++- src/manifest/types.cppm | 13 ++++ src/toolchain/triple.cppm | 29 +++++++++ tests/unit/test_manifest.cpp | 89 ++++++++++++++++++++++++++++ tests/unit/test_toolchain_triple.cpp | 35 +++++++++++ 6 files changed, 226 insertions(+), 8 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 89968287..2582a413 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -718,6 +718,36 @@ namespace { // index is first opened, which can be hundreds of lines earlier; the message // that STOPS the build has to carry the cause, because that is the one a user // reads. See mcpp::pm::unusable_index_hint. +// Spelling-independent `[target.]` lookup. +// +// A section keyed `x86_64-w64-mingw32` matches a resolved `x86_64-windows-gnu`, +// and unparseable keys compare exactly (the escape hatch for custom triples). +// Factored out of the toolchain-override path because the sysroot override must +// use the SAME matching: two lookups that disagreed about spelling would give a +// section that applies to `toolchain` and not to `sysroot`, which is a defect +// nobody would think to look for. +const mcpp::manifest::TargetEntry* +find_target_entry(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::triple::Triple& t) +{ + if (auto it = m.targetOverrides.find(t.str()); it != m.targetOverrides.end()) + return &it->second; + for (auto const& [key, entry] : m.targetOverrides) { + if (auto k = mcpp::toolchain::triple::parse(key); k && k->str() == t.str()) + return &entry; + } + return nullptr; +} + +// The project's `[target.].sysroot`, or nullopt when it declared none. +std::optional +sysroot_override(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::triple::Triple& t) +{ + auto* e = find_target_entry(m, t); + return e ? e->sysroot : std::nullopt; +} + std::string with_index_cause(std::string msg) { if (auto hint = mcpp::pm::unusable_index_hint(); !hint.empty()) msg += "\n" + hint; @@ -1796,11 +1826,12 @@ prepare_build(bool print_fingerprint, // on a first pass. What follows would then simply not add the // paths, and the link fails naming the missing libc — which is the // truthful message either way. - if (auto* known = mcpp::toolchain::triple::find_known_target(*want); - known && !known->sysroot.empty()) { + if (const std::string want_sysroot = + mcpp::toolchain::triple::effective_sysroot( + *want, sysroot_override(*m, *want)); + !want_sysroot.empty()) { if (auto cfg3 = get_cfg(); cfg3) { - auto ref = mcpp::xlings::paths::parse_xpkg_ref( - std::string(known->sysroot)); + auto ref = mcpp::xlings::paths::parse_xpkg_ref(want_sysroot); auto xl = mcpp::config::make_xlings_env(**cfg3); if (auto dir = mcpp::xlings::paths::xpkg_payload(xl, ref)) { if (auto spec = mcpp::freestanding::resolve(*want)) { @@ -2116,9 +2147,8 @@ prepare_build(bool print_fingerprint, std::string targetSysroot; if (tc) { if (auto tt = mcpp::toolchain::triple::parse(tc->targetTriple)) - if (auto* known = mcpp::toolchain::triple::find_known_target(*tt); - known && !known->sysroot.empty()) - targetSysroot = std::string(known->sysroot); + targetSysroot = mcpp::toolchain::triple::effective_sysroot( + *tt, sysroot_override(*m, *tt)); } const bool materializeRootRuntime = !overrides.inherited_runtime_binding diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index 26131662..1fd3bafd 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -1414,6 +1414,28 @@ std::expected parse_string(std::string_view content, triple, e.cxxRuntime))); } } + // The target's C library, overriding the target table's `sysroot` + // column. Accepted forms are an xpkg reference (`xim:newlib-arm@4.4`) + // and the empty string. + // + // ⚠️ The empty string is MEANINGFUL and must not be normalised + // away: it selects the zero-libc tier. Written into an + // `std::optional`, so "the key is absent" (inherit the target row) + // stays distinguishable from "the key is present and empty" (no C + // library at all). Collapsing the two is how a kernel project would + // silently get picolibc back. + if (auto it = body.find("sysroot"); it != body.end() && it->second.is_string()) { + std::string s = it->second.as_string(); + if (!s.empty() && s.find(':') == std::string::npos) { + return std::unexpected(error(origin, std::format( + "[target.{}].sysroot = '{}' is not an xpkg reference; " + "expected `:[@]` (e.g. " + "\"xim:picolibc-riscv@1.8.12\"), or \"\" for a target " + "with no C library.", triple, s))); + } + e.sysroot = std::move(s); + } + // `runner` — the argv template `mcpp run` uses for a target whose // artifact cannot execute here. An ARRAY, so it is neither a // scalar (the unknown-key sweep below skips it by type) nor part @@ -1460,7 +1482,7 @@ std::expected parse_string(std::string_view content, // new conditional sections need no change here, and the reported // case — a scalar that does nothing — is still caught. static constexpr std::string_view kKnownTargetScalars[] = { - "cxx_runtime", "linkage", "toolchain", + "cxx_runtime", "linkage", "sysroot", "toolchain", }; for (auto& [key, value] : body) { if (value.is_table()) continue; // the conditional channel diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index ececfd44..33dbde7b 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -694,6 +694,19 @@ struct TargetEntry { // channel deliberately carries build INPUTS and nothing else // (ConditionalConfig). One axis, one scoping rule. std::string cxxRuntime; + // The target's C library, overriding the `sysroot` column of the target + // table for this triple. Same axis as `toolchain` overriding `pin`: one + // names the compiler the target resolves, the other names the C library, + // and both were engine-only until a project had a reason to disagree. + // + // ⚠️ `std::optional`, not `std::string`, because ABSENT and EMPTY are + // different answers. Absent inherits the target row. `sysroot = ""` is the + // ZERO-LIBC tier: no C library is resolved, no include or library path is + // added, and the link carries only what the project and its dependencies + // supply. A kernel or a bootloader wants exactly that, and with a plain + // string the two cases would be indistinguishable — the empty string is + // what a target row without a sysroot already looks like. + std::optional sysroot; // ⚠️ NO per-role field here. There used to be a `cxxRuntimeTests`, and it was // parsed nowhere and applied nowhere — a configuration key that looked // available and did nothing (#418). The per-target channel carries the diff --git a/src/toolchain/triple.cppm b/src/toolchain/triple.cppm index 862b27e2..d8194b98 100644 --- a/src/toolchain/triple.cppm +++ b/src/toolchain/triple.cppm @@ -151,6 +151,35 @@ inline const TargetInfo* find_known_target(const Triple& t) { inline bool is_known_target(const Triple& t) { return find_known_target(t) != nullptr; } +// The effective target C library for one build. +// +// SINGLE READ POINT, and it is one because it was two. `prepare_build` derived +// "which sysroot does this target use" in two places — once to compute the +// include/library paths and once to materialize the xim package — and adding a +// project-level override to only one of them would have produced a build that +// installs one C library and compiles against another. This codebase has paid +// for that shape repeatedly (#233/#240/#242/#344). +// +// `override_` is the project's `[target.].sysroot`, and its optionality +// is load-bearing: +// +// nullopt -> the project said nothing; the target table's column applies +// "xim:..." -> the project named a different C library +// "" -> the project asked for NO C library (the zero-libc tier) +// +// Returning "" for the last case is deliberate: it is what a hosted target row +// already carries, and every consumer of this function already treats empty as +// "add no target sysroot paths". The tier therefore needs no new branch +// anywhere downstream — it reuses the answer the engine already knew how to +// handle. +inline std::string effective_sysroot(const Triple& t, + const std::optional& override_) +{ + if (override_) return *override_; + if (auto* k = find_known_target(t)) return std::string(k->sysroot); + return {}; +} + // Closest known-target canonical name for a mistyped `--target` (checked // against canonical names AND common alias spellings). nullopt when nothing // is plausibly close. diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index b1ea39fa..b843c2d1 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -3639,3 +3639,92 @@ runner = "qemu-system-riscv64 -kernel" )"; EXPECT_FALSE(mcpp::manifest::parse_string(scalar).has_value()); } + +// ── [target.].sysroot — the target's C library, per project ────────── +// +// The axis exists because the target table binds one C library per triple, and +// three independent needs want to disagree with it: a kernel wants none, a +// project on a vendor SDK wants that SDK's newlib, and a `std-freestanding` +// stack over openkal wants openkal's C library. + +TEST(Manifest, TargetSysrootOverrideIsParsed) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[target.riscv64-none-elf] +sysroot = "xim:newlib-riscv@4.4" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + auto it = m->targetOverrides.find("riscv64-none-elf"); + ASSERT_NE(it, m->targetOverrides.end()); + ASSERT_TRUE(it->second.sysroot.has_value()); + EXPECT_EQ(*it->second.sysroot, "xim:newlib-riscv@4.4"); + EXPECT_TRUE(m->schemaWarnings.empty()) + << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); +} + +// ⚠️ The distinction this test pins is the reason the field is an optional. +// An empty string is a REQUEST (no C library), not an absence. +TEST(Manifest, TargetSysrootEmptyStringIsRecordedAsPresent) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[target.riscv64-none-elf] +sysroot = "" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + auto it = m->targetOverrides.find("riscv64-none-elf"); + ASSERT_NE(it, m->targetOverrides.end()); + ASSERT_TRUE(it->second.sysroot.has_value()); + EXPECT_TRUE(it->second.sysroot->empty()); +} + +TEST(Manifest, TargetSysrootAbsentStaysNullopt) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[target.riscv64-none-elf] +linkage = "static" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + auto it = m->targetOverrides.find("riscv64-none-elf"); + ASSERT_NE(it, m->targetOverrides.end()); + EXPECT_FALSE(it->second.sysroot.has_value()); +} + +// A bare name is the plausible typo, and accepting it would install nothing +// and then fail much later naming a missing libc. +TEST(Manifest, TargetSysrootRejectsANonXpkgReference) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[target.riscv64-none-elf] +sysroot = "newlib" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()); + EXPECT_NE(m.error().format().find("xpkg reference"), std::string::npos); +} + +// The unknown-scalar sweep must not report a key it now honours — the exact +// shape of #418, where a key was reported as ignored while being applied. +TEST(Manifest, TargetSysrootIsNotReportedAsUnsupported) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[target.riscv64-none-elf] +sysroot = "" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + for (auto const& w : m->schemaWarnings) + EXPECT_EQ(w.find("sysroot"), std::string::npos) << w; +} diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index a0c43937..8dadc2cd 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -214,3 +214,38 @@ TEST(Triple, BareMetalEabiSpellings) { // vocabulary table yet, and the target gate is what says so. EXPECT_FALSE(is_known_target(*hf)); } + +// ── effective_sysroot: the project's override, the target row otherwise ────── +// +// ⚠️ The absent/empty distinction is the whole point of these three tests. A +// plain `std::string` would make "the project said nothing" and "the project +// asked for no C library" the same value, and a kernel project would silently +// get picolibc back. + +TEST(Triple, EffectiveSysrootFallsBackToTheTargetRow) { + auto t = parse("riscv64-none-elf"); + ASSERT_TRUE(t.has_value()); + EXPECT_EQ(effective_sysroot(*t, std::nullopt), "xim:picolibc-riscv@1.8.12"); +} + +TEST(Triple, EffectiveSysrootHonoursAnOverride) { + auto t = parse("riscv64-none-elf"); + ASSERT_TRUE(t.has_value()); + EXPECT_EQ(effective_sysroot(*t, std::optional{"xim:newlib-riscv@4.4"}), + "xim:newlib-riscv@4.4"); +} + +TEST(Triple, EffectiveSysrootEmptyStringIsTheZeroLibcTier) { + // Present-and-empty must NOT fall through to the target row. Measured + // end-to-end alongside this: with `sysroot = ""`, `#include ` + // stops resolving and a self-contained image links at 108 bytes. + auto t = parse("riscv64-none-elf"); + ASSERT_TRUE(t.has_value()); + EXPECT_EQ(effective_sysroot(*t, std::optional{""}), ""); +} + +TEST(Triple, EffectiveSysrootIsEmptyForHostedTargets) { + auto t = parse("x86_64-linux-musl"); + ASSERT_TRUE(t.has_value()); + EXPECT_EQ(effective_sysroot(*t, std::nullopt), ""); +} From 80ceb82e75aea72091e3eea1e3ce87dafb57464f Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:12:27 +0800 Subject: [PATCH 06/20] =?UTF-8?q?feat(build):=20=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E6=9F=A5=E8=AF=A2,=E8=A7=A3=E9=99=A4?= =?UTF-8?q?=E6=9D=BF=E7=BA=A7=E5=8C=85=E5=AF=B9=E7=BC=96=E8=AF=91=E5=99=A8?= =?UTF-8?q?=E4=B8=8E=20C=20=E5=BA=93=E7=9A=84=E9=9A=90=E5=BC=8F=E8=80=A6?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ 这次解除的耦合**在任何 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 = ""` 后 `` 确实找不到;零 libc 仍产出镜像;三个查询在 rv64/rv32/零 libc 三种 配置下的值;裸名被解析期拒绝。 ⭐ **做了 revert-A 探针**:把 `effective_sysroot` 的覆盖分支注释掉重建后,e2e 精确 地死在第 4 步那条承重断言(`sysroot = "" did not remove the C library`)。不做这一步 不算写完 —— 这两个特性都属于「在已配置好的机器上,在与不在长得一模一样」那一类。 --- src/build/build_program.cppm | 20 +++ src/build/hostprogram.cppm | 42 +++++ src/build/prepare.cppm | 62 +++++-- src/toolchain/model.cppm | 5 + tests/e2e/134_target_sysroot_and_queries.sh | 174 ++++++++++++++++++++ 5 files changed, 289 insertions(+), 14 deletions(-) create mode 100755 tests/e2e/134_target_sysroot_and_queries.sh diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 31c715e6..55431153 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -43,6 +43,23 @@ struct BuildProgramEnv { // hostprogram::toolchain_dir / sysroot_dir for why declaring was wrong. std::string toolchainDir; std::string targetSysroot; + // Three more answers a board-support package would otherwise hardcode. + // + // ⚠️ THE COUPLING THESE REMOVE IS INVISIBLE IN A MANIFEST. `riscv-virt-rt` + // declares no dependency on LLVM or on picolibc — #459 removed those — and + // yet it named `clang_rt.builtins-riscv64` (a compiler-rt fact, `libgcc` + // under GCC) and `rv64gc/lp64d` (picolibc's multilib convention). A + // declared dependency is visible; a hardcoded name is not, and it fails + // only when something is swapped. + // + // The division of labour is the same one the layering already uses: + // location is a target fact, selection is a board fact. Which builtins + // library exists is decided by the compiler, and no board chooses to go + // without one; where a profile's libraries live is the C library's + // convention. Both belong to the engine, which knows them already. + std::string targetBuiltinsLib; // "clang_rt.builtins-riscv64" | "gcc" | "" + std::string targetLibcProfile; // "rv64gc/lp64d" | "" + std::string targetLibc; // "picolibc-riscv" | "" (zero-libc tier) std::string profile; // effective profile name (dev/release/…) std::vector features; // active feature closure of the package // Artifact home (bin/cache/out). Empty → /target/.build-mcpp (the @@ -306,6 +323,9 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv // process happened to export. e.emplace_back("MCPP_TOOLCHAIN_DIR", env.toolchainDir); e.emplace_back("MCPP_TARGET_SYSROOT", env.targetSysroot); + e.emplace_back("MCPP_TARGET_BUILTINS_LIB", env.targetBuiltinsLib); + e.emplace_back("MCPP_TARGET_LIBC_PROFILE", env.targetLibcProfile); + e.emplace_back("MCPP_TARGET_LIBC", env.targetLibc); e.emplace_back("MCPP_PROFILE", env.profile); e.emplace_back("MCPP_OUT_DIR", outDir.string()); e.emplace_back("MCPP_MANIFEST_DIR", root.string()); diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 7f8a69a9..8593dc1f 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -203,6 +203,48 @@ inline const char* toolchain_dir() { return env_or("MCPP_TOOLCHAI // Empty on a hosted target — there the libc comes with the compiler payload or // through the runtime binding, and nothing has to look for it. inline const char* sysroot_dir() { return env_or("MCPP_TARGET_SYSROOT"); } + +// ── Three answers a board-support package would otherwise hardcode ─────────── +// +// ⚠️ The coupling these remove does not appear in any manifest. A board package +// can declare no dependency on LLVM and none on picolibc — and still be unable +// to serve a second toolchain or a second C library, because it wrote their +// names into its `build.mcpp`. A declared dependency is visible and reviewable; +// a hardcoded name fails only when something is swapped, which is exactly when +// nobody is looking for it. +// +// The rule that decides what belongs here is the one the layering already +// uses: LOCATION IS A TARGET FACT, SELECTION IS A BOARD FACT. + +// The compiler's builtins library, by bare name: `clang_rt.builtins-riscv64` +// for an LLVM payload, `gcc` for a GCC one. +// +// A board does not choose whether to have builtins — every freestanding link +// needs them, and on rv64 the trigger is picolibc's printf doing 128-bit +// shifts, which the ISA has no instruction for. What varies is only which +// implementation the resolved toolchain ships, and that is not a board fact. +// Empty on a hosted target, where the driver links them without being asked. +inline const char* target_builtins_lib() { return env_or("MCPP_TARGET_BUILTINS_LIB"); } + +// The C library's sub-directory for this target's ISA profile, e.g. +// `rv64gc/lp64d`. It is the multilib convention of whichever C library the +// target resolved, with no board input at all — a board that wanted a +// different layout would be using a different C library. +// +// Empty when the target has no C library of its own (the zero-libc tier, or a +// hosted target). +inline const char* target_libc_profile() { return env_or("MCPP_TARGET_LIBC_PROFILE"); } + +// The C library's package name, e.g. `picolibc-riscv`; empty on the zero-libc +// tier and on hosted targets. +// +// This one does NOT remove a coupling — it makes one visible. A board package +// that genuinely must differ between picolibc and newlib (the crt0 object is +// named differently, and that IS a board choice) can branch on this instead of +// assuming. An explicit branch can be read and can be extended; an assumption +// baked into a string literal can be neither. +inline const char* target_libc() { return env_or("MCPP_TARGET_LIBC"); } + inline const char* manifest_dir() { return env_or("MCPP_MANIFEST_DIR"); } inline bool has_feature(const char* name) { char buf[256] = "MCPP_FEATURE_"; diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 2582a413..be4eced0 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -748,6 +748,45 @@ sysroot_override(const mcpp::manifest::Manifest& m, return e ? e->sysroot : std::nullopt; } +// The target-facing answers a `build.mcpp` may ask the engine for. +// +// ONE function because there are TWO call sites — the root project and each +// dependency — and four values derived independently in two places is the +// shape this codebase keeps paying for. A board package that got the right +// answer as a root project and a stale one as a dependency would fail only in +// the consuming build, which is the harder direction to debug. +void fill_target_build_env(mcpp::build::BuildProgramEnv& e, + const mcpp::toolchain::Toolchain* tc) +{ + e.toolchainDir = (tc && !tc->binaryPath.empty()) + ? tc->binaryPath.parent_path().parent_path().string() : std::string{}; + e.targetSysroot = tc ? tc->targetSysrootRoot.string() : std::string{}; + e.targetLibc = tc ? tc->targetSysrootPkg : std::string{}; + if (!tc) return; + + // The C LIBRARY's sub-directory for this ISA profile, from the freestanding + // table — the same single read point the compile flags use. + // + // ⚠️ Gated on there being a C library at all, and the gate is the point: the + // value is a multilib convention, so on the zero-libc tier there is nothing + // for it to be a convention OF. Emitting `rv64gc/lp64d` there would hand a + // kernel a path into a directory that does not exist, and the name of the + // accessor would be a lie. All three libc-facing answers are empty together. + if (!e.targetSysroot.empty()) + if (auto spec = mcpp::freestanding::resolve(tc->targetTriple)) + e.targetLibcProfile = std::string(spec->libdir); + + // Which builtins library the RESOLVED toolchain ships. Freestanding only: + // on a hosted target the driver links them without being asked, and + // handing a package a name it must not use would invite it to. + if (auto t = mcpp::toolchain::triple::parse(tc->targetTriple); + t && t->is_freestanding()) { + e.targetBuiltinsLib = mcpp::toolchain::is_clang(*tc) + ? "clang_rt.builtins-" + t->arch + : std::string("gcc"); + } +} + std::string with_index_cause(std::string msg) { if (auto hint = mcpp::pm::unusable_index_hint(); !hint.empty()) msg += "\n" + hint; @@ -1841,6 +1880,7 @@ prepare_build(bool print_fingerprint, *dir / "lib" / std::string(spec->libdir); std::error_code ec2; tc->targetSysrootRoot = *dir; + tc->targetSysrootPkg = ref.name; if (std::filesystem::is_directory(inc, ec2)) tc->targetSysrootInclude = inc; if (std::filesystem::is_directory(lib, ec2)) @@ -5043,13 +5083,10 @@ prepare_build(bool print_fingerprint, }; mcpp::build::BuildProgramEnv bpEnv; bpEnv.targetTriple = resolvedTargetCanonical; - // The payload ROOT, not the driver: `/bin/clang++` → ``. - // A build program wants `/include/c++/v1`, and deriving that - // from the driver path in every program would be the same expression - // copied into every package. - bpEnv.toolchainDir = (tc && !tc->binaryPath.empty()) - ? tc->binaryPath.parent_path().parent_path().string() : std::string{}; - bpEnv.targetSysroot = tc ? tc->targetSysrootRoot.string() : std::string{}; + // The payload ROOT (not the driver), the target's C library, and the + // three answers that keep a board package from hardcoding a toolchain + // or a libc. All four in one call — see fill_target_build_env. + fill_target_build_env(bpEnv, tc ? &*tc : nullptr); bpEnv.profile = effectiveProfile; bpEnv.features = feature_closure(pkg.manifest, req, depDefaultFeatures); bpEnv.artifactsDir = workRoot / "target" / ".build-mcpp" / "deps" @@ -5210,13 +5247,10 @@ prepare_build(bool print_fingerprint, if (!host) return std::unexpected(host.error()); mcpp::build::BuildProgramEnv bpEnv; bpEnv.targetTriple = resolvedTargetCanonical; - // The payload ROOT, not the driver: `/bin/clang++` → ``. - // A build program wants `/include/c++/v1`, and deriving that - // from the driver path in every program would be the same expression - // copied into every package. - bpEnv.toolchainDir = (tc && !tc->binaryPath.empty()) - ? tc->binaryPath.parent_path().parent_path().string() : std::string{}; - bpEnv.targetSysroot = tc ? tc->targetSysrootRoot.string() : std::string{}; + // The payload ROOT (not the driver), the target's C library, and the + // three answers that keep a board package from hardcoding a toolchain + // or a libc. All four in one call — see fill_target_build_env. + fill_target_build_env(bpEnv, tc ? &*tc : nullptr); bpEnv.profile = effectiveProfile; // Set explicitly rather than relying on build_dir()'s root-relative // default: under BuildOverrides::work_dir the package root is shared diff --git a/src/toolchain/model.cppm b/src/toolchain/model.cppm index 7600ac03..d9b0711f 100644 --- a/src/toolchain/model.cppm +++ b/src/toolchain/model.cppm @@ -94,6 +94,11 @@ struct Toolchain { // down for them. std::filesystem::path targetSysrootRoot; std::filesystem::path targetSysrootInclude; + // The C library's package NAME (`picolibc-riscv`), recorded beside its + // paths so a build program can be told which C library it is without + // reverse-engineering the store layout from the path above. Empty on the + // zero-libc tier and on hosted targets. + std::string targetSysrootPkg; std::filesystem::path targetSysrootLib; std::vector compilerRuntimeDirs; // LD_LIBRARY_PATH for private tools std::vector linkRuntimeDirs; // -L/-rpath dirs for produced binaries diff --git a/tests/e2e/134_target_sysroot_and_queries.sh b/tests/e2e/134_target_sysroot_and_queries.sh new file mode 100755 index 00000000..60894ffd --- /dev/null +++ b/tests/e2e/134_target_sysroot_and_queries.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# `[target.].sysroot` and the three target queries a board package asks. +# +# ⚠️ WHY THIS TEST IS TWO-SIDED THROUGHOUT +# +# Both features here are of the kind whose presence and absence look identical +# on a machine that is already configured. A sysroot override that silently did +# nothing would still build, because the target row's picolibc is what would be +# used either way; a query that returned a stale value would still compile, +# because there is only one toolchain and one C library installed. So every +# assertion below pins BOTH states — with the override and without it, with a C +# library and without one. +# +# The queries exist to remove a coupling that no manifest shows. `riscv-virt-rt` +# declares no dependency on LLVM and none on picolibc, and still could not serve +# a second toolchain or a second C library, because it had written +# `clang_rt.builtins-riscv64` and `rv64gc/lp64d` into its build program. +# +# No emulator is needed: everything here is decided at configure and link time, +# which is why `requires:` asks only for llvm. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +"$MCPP" new probe > /dev/null +cd probe +rm -f tests/*.cpp 2>/dev/null || true + +# A build program that reports what the engine told it. It returns non-zero so +# that mcpp surfaces the output: a build program's stderr is otherwise only +# shown when it fails, and a probe nobody can read proves nothing. +cat > build.mcpp <<'EOF' +import mcpp; +import std; +int main() { + std::cerr << "builtins=[" << (mcpp::target_builtins_lib() ?: "") << "]\n" + << "profile=[" << (mcpp::target_libc_profile() ?: "") << "]\n" + << "libc=[" << (mcpp::target_libc() ?: "") << "]\n"; + return 1; +} +EOF +cat > src/main.cpp <<'EOF' +extern "C" void _start() { for (;;) {} } +EOF + +manifest() { + cat > mcpp.toml < q64.log 2>&1 || true +grep -q 'builtins=\[clang_rt.builtins-riscv64\]' q64.log || { + cat q64.log; echo "rv64 builtins query wrong"; exit 1; } +grep -q 'profile=\[rv64gc/lp64d\]' q64.log || { + cat q64.log; echo "rv64 libc profile query wrong"; exit 1; } +grep -q 'libc=\[picolibc-riscv\]' q64.log || { + cat q64.log; echo "libc name query wrong"; exit 1; } + +# ── 2. The SAME manifest at the other width ───────────────────────────────── +# This is what makes the queries worth having: one board description, two ISA +# profiles, and the varying parts come from the engine rather than from a +# branch in the package. +"$MCPP" build --target riscv32-none-elf > q32.log 2>&1 || true +grep -q 'builtins=\[clang_rt.builtins-riscv32\]' q32.log || { + cat q32.log; echo "rv32 builtins query did not follow the target"; exit 1; } +grep -q 'profile=\[rv32imac/ilp32\]' q32.log || { + cat q32.log; echo "rv32 libc profile query did not follow the target"; exit 1; } + +# ── 3. The C library is really reachable without the override ─────────────── +# Establishes the control for step 4: `` resolves here. +cat > src/main.cpp <<'EOF' +#include +extern "C" void _start() { (void)sizeof(FILE); for (;;) {} } +EOF +cat > build.mcpp <<'EOF' +import mcpp; +int main() { return 0; } +EOF +manifest "" +"$MCPP" build > with_libc.log 2>&1 || { + cat with_libc.log; echo "the target row's C library should have been usable"; exit 1; } + +# ── 4. `sysroot = ""` really removes it ───────────────────────────────────── +# ⚠️ THE LOAD-BEARING ASSERTION. Without it, an override that parsed and did +# nothing would pass every other check in this file. +manifest ' +[target.riscv64-none-elf] +sysroot = ""' +if "$MCPP" build > no_libc.log 2>&1; then + cat no_libc.log + echo "sysroot = \"\" did not remove the C library — still resolved" + exit 1 +fi +grep -q "stdio.h" no_libc.log || { + cat no_libc.log; echo "build failed, but not because the C library was gone"; exit 1; } + +# ── 5. The zero-libc tier still produces an image ─────────────────────────── +# Removing the C library must leave a usable target, not a broken one. +cat > src/main.cpp <<'EOF' +static volatile unsigned char* const UART = + reinterpret_cast(0x10000000); +extern "C" void kmain() { + for (const char* p = "zero-libc\n"; *p; ++p) + *UART = static_cast(*p); + *reinterpret_cast(0x100000) = 0x5555; + for (;;) {} +} +asm(".section .text.entry\n.globl _start\n_start:\n" + " la sp, __stack_top\n call kmain\n1: j 1b\n"); +EOF +cat > link.ld <<'EOF' +ENTRY(_start) +SECTIONS { + . = 0x80000000; + .text : { *(.text.entry) *(.text*) } + .rodata : { *(.rodata*) } + .data : { *(.data*) } + .bss : { *(.bss*) *(COMMON) } + . = ALIGN(16); . = . + 0x1000; __stack_top = .; +} +EOF +manifest "ldflags = [\"-T\", \"$PWD/link.ld\"] + +[target.riscv64-none-elf] +sysroot = \"\"" +"$MCPP" build > zero.log 2>&1 || { + cat zero.log; echo "a self-contained zero-libc image should still build"; exit 1; } +grep -q 'Size probe' zero.log || { + cat zero.log; echo "no size summary — the freestanding link path was not taken"; exit 1; } + +# ── 6. On the zero-libc tier the libc-facing queries are empty ────────────── +# All three answers move together: a kernel must not be handed a path into a +# C library that is not there, while `builtins` is a COMPILER fact and stays. +cat > build.mcpp <<'EOF' +import mcpp; +import std; +int main() { + std::cerr << "builtins=[" << (mcpp::target_builtins_lib() ?: "") << "]\n" + << "profile=[" << (mcpp::target_libc_profile() ?: "") << "]\n" + << "libc=[" << (mcpp::target_libc() ?: "") << "]\n"; + return 1; +} +EOF +"$MCPP" build > zeroq.log 2>&1 || true +grep -q 'profile=\[\]' zeroq.log || { + cat zeroq.log; echo "libc profile should be empty with no C library"; exit 1; } +grep -q 'libc=\[\]' zeroq.log || { + cat zeroq.log; echo "libc name should be empty with no C library"; exit 1; } +grep -q 'builtins=\[clang_rt.builtins-riscv64\]' zeroq.log || { + cat zeroq.log; echo "builtins is a compiler fact and must survive"; exit 1; } + +# ── 7. A bare name is rejected at parse time ──────────────────────────────── +# Accepting it would install nothing and fail much later naming a missing libc. +manifest ' +[target.riscv64-none-elf] +sysroot = "newlib"' +if "$MCPP" build > badref.log 2>&1; then + cat badref.log; echo "a bare sysroot name should have been rejected"; exit 1; fi +grep -q 'xpkg reference' badref.log || { + cat badref.log; echo "rejection did not explain the expected form"; exit 1; } + +echo "PASS: [target.X].sysroot override, zero-libc tier, and the three target queries" From 293c146931d4ad1bdd2235d8b5aac1e58cd15962 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:15:09 +0800 Subject: [PATCH 07/20] =?UTF-8?q?test(e2e):=20=E6=9C=BA=E5=99=A8=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E8=AF=8A=E6=96=AD=E9=87=8C=E9=82=A3=E6=9D=A1=E5=8F=AF?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=E7=9A=84=E4=BE=9D=E8=B5=96=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ **同一个缺陷发过两次,而两次的修法都是「改字面量 + 加注释」。** 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. ⇒ 它对历史缺陷本身变红,而不只是对假想的缺陷变红。 --- tests/e2e/135_diagnostic_promise_resolves.sh | 115 +++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100755 tests/e2e/135_diagnostic_promise_resolves.sh diff --git a/tests/e2e/135_diagnostic_promise_resolves.sh b/tests/e2e/135_diagnostic_promise_resolves.sh new file mode 100755 index 00000000..7c440a2f --- /dev/null +++ b/tests/e2e/135_diagnostic_promise_resolves.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# A diagnostic that prints a copy-pasteable dependency line must print one that +# resolves. +# +# ⚠️ THIS TEST EXISTS BECAUSE THE SAME DEFECT SHIPPED TWICE. +# +# The freestanding `import std;` message ends in a `[dependencies]` block the +# reader is meant to paste. It has been wrong twice, in two different ways: +# +# 1. it named `mcpplibs.std.freestanding` before any such package existed — +# pasting it failed at the very next command with "package not found"; +# 2. it named version `0.1.0` after `0.2.0` superseded it in the index — +# pasting it produced `E_NOT_FOUND: package 'compat.std-freestanding@0.1.0' +# not found in the synced index`. +# +# Both times the repair was to edit the literal and add a comment saying the +# literal must be kept current. A comment cannot enforce a cross-repository +# invariant — this repository already learned that for its version pins, which +# is why `check_version_pins.sh` exists — and the second occurrence is the +# proof: the comment from the first repair was sitting right above the line +# that broke. +# +# ⚠️ THE TEST MUST NOT SPELL THE VERSION. Asserting `std-freestanding = "0.3.0"` +# would copy the literal into a second place and check that the two copies +# agree, which is true even when both are wrong. What is checked instead is the +# property that matters: whatever the diagnostic prints, RESOLVES. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +"$MCPP" new promise > /dev/null +cd promise +rm -f tests/*.cpp 2>/dev/null || true + +cat > mcpp.toml <<'EOF' +[package] +name = "promise" +version = "0.1.0" + +[build] +target = "riscv64-none-elf" +EOF + +# `import std;` on a freestanding target is the trigger. +cat > src/main.cpp <<'EOF' +import std; +extern "C" int main() { return 0; } +EOF + +if "$MCPP" build > diag.log 2>&1; then + cat diag.log + echo "\`import std;\` on a freestanding target should have been rejected" + exit 1 +fi +grep -q 'is not available on' diag.log || { + cat diag.log; echo "the freestanding import-std diagnostic did not appear"; exit 1; } + +# ── Extract the advice, rather than restating it ───────────────────────────── +# The line is indented inside the message; take it verbatim, minus the padding. +ADVICE=$(grep -oE '[a-z0-9.-]+[[:space:]]*=[[:space:]]*"[^"]+"' diag.log | head -1) +[[ -n "$ADVICE" ]] || { + cat diag.log + echo "the diagnostic printed no dependency line to paste — if the advice was" + echo "removed on purpose, remove this test with it" + exit 1; } +echo "advice: $ADVICE" + +# ── Paste it, exactly as a reader would ───────────────────────────────────── +cat > mcpp.toml < src/main.cpp <<'EOF' +import mcpplibs.std.freestanding; +extern "C" int main() { return static_cast(std::array{}.size()); } +EOF + +# The build is expected to get as far as the LINK. It may well fail there — this +# project has no board package, so there is no crt0 and nothing to start it — +# and that failure is not what this test is about. What must not happen is a +# RESOLUTION failure: that is what a stale name or a stale version produces, and +# it is what both historical occurrences looked like. +"$MCPP" build > paste.log 2>&1 || true + +if grep -qE 'not found in the synced index|package not found|E_NOT_FOUND' paste.log; then + cat paste.log + echo + echo "the diagnostic's copy-pasteable line does not resolve." + echo "advice was: $ADVICE" + echo "update the literal in prepare.cppm's freestanding import-std message" + echo "to a version the index actually carries, in the same change that" + echo "publishes it." + exit 1 +fi + +# ...and the package must actually have entered the graph, so that a build which +# silently ignored the dependency cannot pass. One of the three verbs mcpp uses +# for a dependency it resolved has to name it. +grep -qE '(Downloading|Compiling|Cached).*std-freestanding' paste.log || { + cat paste.log + echo "the pasted dependency never entered the build graph" + exit 1; } + +echo "PASS: the import-std diagnostic's advice resolves and enters the graph" From 12b99d12acd962174d5bcc7bb1aa156f3812a4a7 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:19:30 +0800 Subject: [PATCH 08/20] =?UTF-8?q?feat(diag):=20=E8=A3=B8=E6=9C=BA=E4=B8=8A?= =?UTF-8?q?=E7=BC=BA=20operator=20new=20=E6=97=B6=E7=82=B9=E5=90=8D?= =?UTF-8?q?=E5=88=86=E9=85=8D=E5=99=A8=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 裸机工程一用 `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 的错误上。 --- src/build/execute.cppm | 5 +++ src/build/ninja_backend.cppm | 57 +++++++++++++++++++++++++++++++ tests/unit/test_ninja_backend.cpp | 46 +++++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/src/build/execute.cppm b/src/build/execute.cppm index a9a625eb..70670699 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -767,6 +767,11 @@ std::optional run_ninja_fast(const std::string& ninjaProgram, if (diagnostics.back() != '\n') std::fputc('\n', stderr); } + // Read from the RAW output, not from `diagnostics`: the filter drops + // command lines, and a future filter change must not be able to + // silently remove the advice along with them. + if (auto advice = mcpp::build::link_failure_advice(out); !advice.empty()) + std::fputs(advice.c_str(), stderr); return 1; } if (verbose && !out.empty()) diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 768e9b92..c755f865 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -65,6 +65,18 @@ std::string emit_ninja_string(const BuildPlan& plan); std::string filter_ninja_output(std::string_view output, std::span commandPrefixes); +// Advice appended to a failed build whose linker output names a replaceable +// function nothing in the graph defines. Empty when there is nothing to add. +// +// ⚠️ The case this exists for reads as a toolchain fault and is not one. A +// freestanding target has no compiled `libc++`, so `operator new` is simply +// absent; the moment a project uses `std::vector` the link fails naming a +// mangled symbol from a header deep inside the standard library, and nothing +// in that message says which package supplies it or that the answer is one +// manifest line. Measured: `undefined symbol: operator new(unsigned long)` +// referenced from `__libcpp_allocate` in `__new/allocate.h`. +std::string link_failure_advice(std::string_view output); + } // namespace mcpp::build namespace mcpp::build { @@ -410,6 +422,45 @@ runtime_env_for_dirs(const std::vector& dirs) { } // namespace +std::string link_failure_advice(std::string_view output) { + // Both linkers, both spellings. lld says "undefined symbol: X"; GNU ld says + // "undefined reference to `X'". Matched on the operator's own name rather + // than on a mangled form, because the mangling differs by ABI and the + // demangled text is what both emit. + static constexpr std::string_view kNew[] = { + "undefined symbol: operator new", + "undefined reference to `operator new", + }; + bool missingNew = false; + for (auto n : kNew) + if (output.find(n) != std::string_view::npos) { missingNew = true; break; } + if (!missingNew) return {}; + + // ⚠️ NOT a version literal. The advice names a package and a feature, and + // the feature is what pulls the implementation — so this line stays correct + // across every release of that package. The version-bearing advice in the + // `import std` message needed an e2e to keep it honest (tests/e2e/135); + // this one is built so that it cannot go stale in the same way. + return + "\n" + "note: nothing in this build defines `operator new`.\n" + " A freestanding target has no compiled libc++, so the allocating\n" + " parts of the standard library (vector, string, map, and the\n" + " default coroutine frame) link only once the program supplies an\n" + " allocator. The non-allocating parts — array, span, optional,\n" + " atomic, string_view, ranges — need nothing.\n" + "\n" + " Activate the subset's allocator feature, which brings an\n" + " implementation with it:\n" + "\n" + " [dependencies]\n" + " std-freestanding = { version = \"…\", features = [\"alloc-kal\"] }\n" + "\n" + " To supply an allocator instead, define the twelve `operator new`\n" + " and `operator delete` overloads — including the four taking\n" + " `std::align_val_t`, which are the ones most often forgotten.\n"; +} + std::string filter_ninja_output(std::string_view output, std::span commandPrefixes) { std::string filtered; @@ -2426,6 +2477,12 @@ std::expected NinjaBackend::build(const BuildPlan& plan } else { auto prefixes = command_prefixes(flags, plan); auto diagnostics = opts.verbose ? out : filter_ninja_output(out, prefixes); + // Appended here as well as on the fast path (execute.cppm): the two + // paths report failure through different channels, and advice attached + // to only one of them would appear or not depending on whether + // build.ninja happened to be up to date — which is exactly the kind of + // "same decision in two places" this codebase keeps paying for. + diagnostics += link_failure_advice(out); return std::unexpected(BuildError{"build failed", plan.outputDir / "build.ninja", std::move(diagnostics)}); } diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index d7614274..76a07180 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -1566,3 +1566,49 @@ TEST(NinjaBackendPeRuntime, AnElfToolchainNeverStagesItsRuntimeDirs) { plan.toolchain.targetTriple = "x86_64-linux-gnu"; EXPECT_TRUE(compute_flags(plan).toolchainRuntimeDeploy.empty()); } + +// ── link_failure_advice ────────────────────────────────────────────────────── +// +// A freestanding link that reaches for `operator new` fails naming a mangled +// symbol from a header inside the standard library. Nothing in that message +// says which package supplies it, or that the answer is one manifest line. + +TEST(LinkFailureAdvice, NamesTheAllocatorFeatureForLldsSpelling) { + const std::string out = + "ld.lld: error: undefined symbol: operator new(unsigned long)\n" + ">>> referenced by allocate.h:58\n"; + auto advice = mcpp::build::link_failure_advice(out); + EXPECT_NE(advice.find("alloc-kal"), std::string::npos); + EXPECT_NE(advice.find("std-freestanding"), std::string::npos); + // The four that are most often forgotten are named, because omitting them + // produces a SECOND link failure after the first is fixed. + EXPECT_NE(advice.find("align_val_t"), std::string::npos); +} + +TEST(LinkFailureAdvice, MatchesGnuLdsSpellingToo) { + // Two linkers, two spellings of the same fact. Matching only lld's would + // make the advice appear on some toolchains and not others. + const std::string out = + "/usr/bin/ld: main.o: undefined reference to `operator new(unsigned long)'\n"; + EXPECT_FALSE(mcpp::build::link_failure_advice(out).empty()); +} + +TEST(LinkFailureAdvice, StaysSilentOnUnrelatedFailures) { + // The advice is appended to EVERY failed build, so a needle that matched + // loosely would attach allocator advice to a missing board symbol. + const std::string out = + "ld.lld: error: undefined symbol: board_uart_init\n" + "error: cannot open crt0.o: No such file or directory\n"; + EXPECT_TRUE(mcpp::build::link_failure_advice(out).empty()); +} + +TEST(LinkFailureAdvice, CarriesNoVersionLiteral) { + // ⚠️ The `import std` advice needed tests/e2e/135 to keep its version + // honest. This one is built so it cannot go stale the same way: it names a + // package and a FEATURE, and the feature pulls the implementation. A digit + // sequence that looks like a version here would be a regression in kind. + auto advice = mcpp::build::link_failure_advice( + "ld.lld: error: undefined symbol: operator new(unsigned long)\n"); + ASSERT_FALSE(advice.empty()); + EXPECT_EQ(advice.find("0."), std::string::npos) << advice; +} From 1b61bd952cf248fcedc827519420603163e6386e Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:45:12 +0800 Subject: [PATCH 09/20] =?UTF-8?q?chore(release):=202026.8.20.2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=94=9F=E6=80=81=E5=AE=9E=E6=96=BD=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E3=80=81=E6=96=87=E6=A1=A3=E4=B8=8E=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 实施计划(.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 通过(双语标题层级一致)。 --- ...estanding-ecosystem-implementation-plan.md | 177 ++++++++++++++++++ CHANGELOG.md | 53 ++++++ docs/05-mcpp-toml.md | 39 +++- docs/13-baremetal.md | 92 ++++++++- docs/zh/05-mcpp-toml.md | 30 ++- docs/zh/13-baremetal.md | 81 +++++++- mcpp.toml | 2 +- src/version.cppm | 2 +- 8 files changed, 468 insertions(+), 8 deletions(-) create mode 100644 .agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md new file mode 100644 index 00000000..dcdcdd95 --- /dev/null +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md @@ -0,0 +1,177 @@ +# freestanding 生态实施计划与依赖图 + +**状态**:实施中。本文记录任务分解、依赖关系、每步的可证伪判据,以及**实施过程中 +被实测推翻的设计主张**。 + +上游文档: + +- [`2026-08-20-freestanding-ecosystem-positioning.md`](2026-08-20-freestanding-ecosystem-positioning.md) + —— 五项决策及其依据。 +- [`2026-08-20-openarch-implementation-design.md`](2026-08-20-openarch-implementation-design.md) + —— openarch 的仓库形态与 A0 门。 + +证据来源分两类:标注为「实测」的结论在本机验证过,环境为 mcpp(由本仓库 +`docs/baremetal-chapter` 分支构建)· `x86_64-linux-gnu` 宿主 · 目标 +`riscv64-none-elf` / `riscv32-none-elf` · `xim:llvm 22.1.8` · +`xim:picolibc-riscv 1.8.12` · `xim:qemu-riscv 9.2.4-1`;其余为设计主张。 + +--- + +## 0. 八维度评估 + +| 维度 | 评估 | 依据 | +|---|---|---| +| **架构** | 三条新能力各自落在既有机制上,**引擎未新增任何轴**:sysroot 与 `toolchain` 同轴,三个查询与 `sysroot_dir()` 同形,分配器走既有的 feature + capability | §2.1、§2.2 | +| **稳定性** | 每项都做了 **revert-A 探针**(关掉实现看判据是否变红);两处「同一决策两处推导」在加新能力时被就地收敛 | §3 | +| **优雅简洁** | 新增用户可见概念:**一个清单键 + 三个查询 + 一个 feature 名**。三个 provider 包各 30–80 行 | §2.3 | +| **用户体验** | 裸机 `std::vector` 从「自己写 12 个重载」变成一个 feature;失败路径全部在**解析期**报错并点名包 | §2.4 | +| **兼容性** | 索引 `min_mcpp` 未动;旧版本包全部保留;`sysroot` 缺席与空串可区分,故老工程行为不变 | §4 | +| **跨平台** | 引擎改动与宿主无关(纯解析与字符串);⚠️ 生态验证只在 Linux 上做过 | §7 | +| **一致性** | 分配器沿用 `backend-openblas` 已文档化的形状;裸机后端的选择键改为与其它板级事实同源 | §2.2 | +| **无感升级** | 新键在旧引擎上是一条 warning 而非错误;⚠️ 但 `feature-deps` 需 mcpp ≥ 2026.8.6.2,故 `std-freestanding` 0.3.0 对更旧客户端不可用(0.2.0 保留) | §4.2 | + +--- + +## 1. 任务依赖图 + +``` +引擎(单 PR,合入 mcpp-community/mcpp#466) + T2 [target.X].sysroot + 零 libc 档 ────┐ + T3 三个目标查询 ──────────────────────┤ + T4 operator new 具名诊断 ─────────────┼──> T13 版本 bump + CI + 合入 + 发布 + T5 诊断承诺 e2e 守卫 ─────────────────┘ │ + │ +生态(五个仓库,并行) │ + T7 std-freestanding-alloc-kal ──┐ │ + T7'std-freestanding-alloc-libc ─┼──> T6 std-freestanding 0.3.0 ──┐ + T8 std-freestanding-nolibc ─────┘ │ + T9 openkal 裸机后端 ────────────┐ │ + ├──> T10 riscv-virt-rt 0.4.0 ─────┤ + T3(引擎)────────────────────┘ │ + ├──> T14 生态闭环验证 + 索引 mcpplibs/mcpp-index#226 ─────────────────────────────────────┘ +``` + +**关键路径**:T3 → T10 → 索引 → T14。板级包用不了未发布的查询接口,所以引擎必须先发布。 + +⚠️ **一个真实的顺序约束**:`riscv-virt-rt` 0.4.0 的 `build.mcpp` 调用 +`mcpp::target_builtins_lib()`,而该名字只存在于本轮的 mcpp。用更旧的 mcpp 消费 +0.4.0 会得到「`target_builtins_lib` 不是 `mcpp` 的成员」并附带升级提示 —— 这条提示 +正是为这类情形补的,见定位文档 §3.1。0.3.0 保留在索引中,旧客户端仍可用。 + +--- + +## 2. 已完成项与其判据 + +### 2.1 T2 —— `[target.X].sysroot` 与零 libc 档 + +⭐ **一个旋钮解锁三条线**:内核要一份 C 库都不要、厂商 SDK 上的工程要换成 newlib、 +`std-freestanding` 坐到 openkal 的 C 库上。 + +判据(两侧都钉,实测): + +| | 结果 | +|---|---| +| 不覆盖 | picolibc 头可用,`text 12` | +| `sysroot = ""` | `fatal error: 'stdio.h' file not found` | +| 零 libc 自包含镜像 | **`text 108`,qemu 里打印 `zero-libc ok`** | +| 裸名 `"newlib"` | 解析期拒绝并说明期望形式 | + +⭐ **revert-A 探针**:注释掉 `effective_sysroot` 的覆盖分支后,e2e/134 精确地死在 +第 4 步那条承重断言。 + +### 2.2 T3 —— 三个目标查询 + +⚠️ 它解除的耦合**在任何 manifest 里都看不见**:`riscv-virt-rt` 自 #459 起既不声明 +LLVM 也不声明 picolibc,却依然服务不了第二种工具链或第二份 C 库。 + +判据**不是**「能编过」(只有一种取值时永远成立),而是: + +- 板级包代码里 `clang_rt` 与 `rv64gc/lp64d` 两个字面量**归零**(实测:仅存于解释性注释); +- rv32 / rv64 双档仍通过(实测:两档 `vector 5 last=16`)。 + +### 2.3 T6–T10 —— 分配器与零 libc 层 + +形状沿用 `docs/05` §2.8.2 已文档化的 `backend-openblas`:feature 作开关, +capability 作仲裁,实现在别的包。 + +实测的四条: + +| | 结果 | +|---|---| +| `features = ["alloc-libc"]` | `vector 5 last=16`,**用户零 `operator new`** | +| `features = ["alloc-kal"]` + 板级 openkal 后端 | `kal vector 6 last=15`,全链路 `vector → operator new → kal_alloc → picolibc` | +| 只开 `alloc`,图中无 provider | 解析期:`no package provides capability 'freestanding-allocator'` | +| 两个 provider 同图 | 解析期:点名 `[std-freestanding-alloc-kal, std-freestanding-alloc-libc]` | + +### 2.4 零 libc 内核模板 + +`mcpp new --template riscv-virt-rt:nolibc` —— 实测 **369 字节**,无 C 库、 +无启动对象、无板级包,在 qemu 中打印并停机。 + +--- + +## 3. ⚠️ 实施中被实测推翻或修正的设计主张 + +这一节是本文最有价值的部分:计划里凡是带具体断言的句子,实测都可能推翻它。 + +| 计划/直觉写的 | 实测 | 处置 | +|---|---|---| +| `std-freestanding` 需要堆,「没有办法说不要堆」 | tier-0 **完全不碰堆**;`vector` 编得过链不上;补 12 个重载即通 | 定位文档 §3.2 改写为分配中立 | +| 「`std-freestanding` 应基于 openkal」 | tier-0 全部未定义符号只有 `memmove` + `strlen`;耦合在**头文件**而非服务 | 接触面收窄到 tier-1 分配器一处 | +| 默认实现随包提供、程序可覆盖 | **依赖以裸 `.o` 参与链接,整份 build.ninja 零个 `.a`** ⇒ 归档语义不适用,两份定义是重复而非替换 | 改为「开关控制是否存在」,两份定义从不共存 | +| `[capabilities]` 可以消歧两个 provider | **消歧不裁剪链接行**,pin 之后死于 `multiple definition` | 单例符号类能力:两个 provider 是**待修的缺陷**;已写入 `docs/05` §2.8.1 | +| openkal 设计 §6.3 用 ISA 级 cfg 选裸机后端 | UART 基址是**板级**事实;换板后能编能链能跑**却不打印** | 后端改由板级包提供,选择键是那条板级依赖 | +| 模板用 `[build] ldflags = ["-T", "link.ld"]` | 链接器工作目录是构建目录 ⇒ `cannot find linker script link.ld` | 改用 `mcpp:link-script=`,它按包根解析 | +| `alignof(__max_align_t)` 可用 | 该标识符来自 ``,零 libc 包没有 | 改用编译器预定义的 `__BIGGEST_ALIGNMENT__` | +| Cortex-M「只缺一行目标表」 | xim 索引里裸机 C 库只有 `picolibc-riscv`,qemu 只有 `qemu-riscv` | 真实成本是**两个新载荷** + 一行 + 一个包 | + +⇒ 八条里有七条**若不实测就会写进文档**。 + +--- + +## 4. 兼容性与无感升级 + +### 4.1 不破坏既有工程 + +| 变更 | 影响面 | +|---|---| +| `[target.X].sysroot` 新键 | 缺席时行为与从前逐字节相同(`effective_sysroot` 返回目标表行) | +| 三个查询 | 纯新增;旧 `build.mcpp` 不调用即不受影响 | +| `operator new` 诊断 | 仅在构建**已经失败**时追加,且需链接器点名该符号 | +| 索引 | `min_mcpp` 未动;旧版本包全部保留 | + +### 4.2 ⚠️ 一处真实的版本下限 + +`std-freestanding` 0.3.0 使用 `[feature-deps]`,需 mcpp ≥ 2026.8.6.2; +`riscv-virt-rt` 0.4.0 使用本轮的三个查询。两者的旧版本(0.2.0 / 0.3.0)**都保留在 +索引里**,因此旧客户端解析既有的 pin 不受影响,只有主动请求新版本才会遇到下限。 + +这与「索引是数据、mcpp 是程序,发布数据不得让程序失效」一致:失效的是**新数据对 +旧程序**,而旧程序请求旧数据仍然成立。 + +--- + +## 5. 未完成项及其阻塞原因 + +⚠️ 本节如实记录,不以「已规划」代替「已实现」。 + +| 项 | 状态 | 阻塞 | +|---|---|---| +| **openarch A0 的第二个 arch** | 受阻 | A0 的门要求**两个真实不同的 arch**。aarch64 缺目标表行与模拟器(xim 只有 `qemu-riscv`)。⭐ 只做一个 arch **不能**证明抽象不碎 —— 这正是该门存在的理由,伪造它比不做更糟 | +| **openarch 的 addrspace 判据** | 受阻 | 内存属性的语义判据需要在两个 arch 上真跑 | +| **Cortex-M(决策 E 步 1)** | 未开始 | 需新建 `xim:picolibc-arm` 与 `xim:qemu-arm` 两个载荷 | +| **`provides = ["freestanding"]` 的索引侧表示(决策 A)** | 形状已定,schema 未设计 | 需要索引 schema 变更,应单独立项 | +| **目标表工程内定义(决策 B(a))** | 未开始 | 与决策 A 同批评估更合适 | + +--- + +## 6. 当前边界 + +| 边界 | 状态 | +|---|---| +| 生态侧验证 | **仅 Linux**。macOS / Windows 宿主上的裸机链无持续验证 | +| 两个 alloc provider 的互斥 | 由解析器保证,**已实测**;但未验证第三方 provider 的情形 | +| `std-freestanding-nolibc` 与 `-lc` 共存 | 设计上不可共存,**未实测**其失败信息是否可读 | +| openkal 裸机后端 | 实现 core 三件;`kal_stream_read` 走 semihosting,**未实测**输入路径 | +| 零 libc 模板的 rv32 档 | **未实测**(模板只写了 rv64 的 `[target.*]` 段) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c0c1b7a..c15fa6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,59 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.20.2] — 2026-08-20 + +### 新增 + +- **`[target.].sysroot` —— 目标的 C 库可按工程覆盖,并由此有了零 libc 档。** + + 目标表把一份 C 库绑在每个 triple 上,而三条互不相干的需求都想和它分歧:内核要 + 一份都不要、厂商 SDK 上的工程要换成 newlib、`std-freestanding` 坐到 openkal 的 + C 库上也要换。**一个旋钮解锁三条线。** + + ⚠️ 字段是可选值而不是字符串,因为**缺席与空是两个不同的答案**:缺席继承目标表行, + `sysroot = ""` 是零 libc 档。用普通字符串两者不可区分,而空串正是没有 sysroot 的 + 目标行本来的样子 —— 内核工程会静默地把 picolibc 拿回去。 + + 实测两侧:不覆盖时 picolibc 头可用;`sysroot = ""` 后 `'stdio.h' file not found`; + 零 libc 自包含镜像 **`text 108`**,在 qemu 中打印 `zero-libc ok`。 + +- **三个目标查询,解除板级包对编译器与 C 库的隐式耦合。** + + `mcpp::target_builtins_lib()` / `mcpp::target_libc_profile()` / + `mcpp::target_libc()`。 + + ⚠️ 这层耦合**在任何 manifest 里都看不见**:`riscv-virt-rt` 既不声明 LLVM 也不 + 声明 picolibc,却依然服务不了第二种工具链或第二份 C 库,因为 + `clang_rt.builtins-riscv64` 与 `rv64gc/lp64d` 写进了它的 build.mcpp。**声明出来的 + 依赖可评审;写死的名字只在换东西时才失败。** + + 判据不是「能编过」(只有一种取值时永远成立),而是板级包代码里两个字面量**归零** + 且 rv32/rv64 双档仍通过。 + +- **裸机上缺 `operator new` 时的具名诊断。** + + 裸机工程一用 `std::vector`,链接就死在标准库深处某个头文件里的 mangled 符号上, + 而消息里没有一处说明哪个包提供它。现在追加说明,并给出激活 feature 的写法。 + ⭐ 该建议**不带版本字面量** —— 它点名的是包与 feature,因此跨该包的每个版本都成立。 + +### 测试 + +- **e2e 机器校验 `import std` 诊断里那条可粘贴的依赖行。** + + ⚠️ 同一个缺陷发过两次(先是包不存在,后是版本过期),而两次的修法都是「改字面量 + + 加注释」;第二次发生时,第一次留下的注释就在断掉的那一行正上方。**注释强制不了 + 跨仓库不变量。** + + 新守卫不写版本号,而是触发诊断、从输出里抠出那一行、原样粘进 manifest、再构建。 + revert-A 探针确认它对**历史缺陷本身**变红。 + +### 生态 + +- `mcpplibs/std-freestanding` 0.3.0 —— `alloc` / `alloc-kal` / `alloc-libc` feature。 +- `mcpplibs/std-freestanding-alloc-kal` 0.1.0、`-alloc-libc` 0.1.0、`-nolibc` 0.1.0(新)。 +- `mcpplibs/riscv-virt-rt` 0.4.0 —— 解耦、openkal 裸机后端、零 libc 内核模板。 + ## [2026.8.20.1] — 2026-08-20 ### 修复 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 4692a524..b1599fa2 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -893,9 +893,42 @@ for arch/env conditions and combinators. them generating noise on the other two — the same way an inactive feature's entries simply are not there. A zero-hit glob in the *unconditional* table still warns, because there it is a real defect. -- **`toolchain` / `linkage` are exact-triple only** — they describe one specific - cross target, so put them under `[target.]` (above), not under a bare - alias or `cfg(...)`. +- **`toolchain` / `linkage` / `sysroot` are exact-triple only** — they describe + one specific cross target, so put them under `[target.]` (above), not + under a bare alias or `cfg(...)`. + +#### `sysroot` — the target's C library + +`sysroot` (mcpp 2026.8.20.2+) overrides the C library the target table binds to +a triple, on the same axis as `toolchain` overriding the compiler pin: one names +the compiler a target resolves, the other names its C library, and both were +engine-only until a project had a reason to disagree. + +```toml +[target.riscv64-none-elf] +sysroot = "xim:newlib-riscv@4.4" # a different C library +``` + +```toml +[target.riscv64-none-elf] +sysroot = "" # no C library at all +``` + +⚠️ **An absent key and an empty one are different answers.** Absent inherits the +target table's C library. Present-and-empty is the **zero-libc tier**: no C +library is resolved, no include or library path is added, and the link carries +only what the project and its dependencies supply. `#include ` stops +resolving. A kernel or a bootloader wants exactly that, and collapsing the two +cases would silently hand such a project the target's C library back. + +The value is an xpkg reference or the empty string; a bare name is rejected when +the manifest is parsed, because accepting it would install nothing and then fail +much later naming a missing libc. + +A build program can ask which C library was resolved: `mcpp::target_libc()` +returns its package name and `mcpp::target_libc_profile()` the sub-directory for +the target's ISA profile. Both are empty on the zero-libc tier. See +[13 — Bare-Metal and Freestanding Targets](13-baremetal.md). ### 2.7.2 Bare metal (`os = none`) — freestanding targets diff --git a/docs/13-baremetal.md b/docs/13-baremetal.md index 6e3e49d3..5bdbc355 100644 --- a/docs/13-baremetal.md +++ b/docs/13-baremetal.md @@ -237,6 +237,96 @@ std::mutex m; error: no type named 'mutex' in namespace 'std' ``` +### The allocating half of the subset + +The subset does not change what compiles. All of its headers are included +unconditionally and `std::vector` compiles today; what fails is the **link**, +because a freestanding target has no compiled `libc++` and therefore no +`operator new`: + +``` +ld.lld: error: undefined symbol: operator new(unsigned long) +>>> referenced by allocate.h:58 +``` + +| Needs nothing | Needs an allocator | +|---|---| +| `array` `span` `optional` `expected` `atomic` `string_view` `ranges` `algorithm` `bit` `charconv` `tuple` | `vector` `string` `deque` `list` `map` `set` `unordered_*` `function` `any` `make_unique`, and the default coroutine frame | + +An allocator arrives with a feature: + +```toml +[dependencies] +riscv-virt-rt = "0.4.0" +std-freestanding = { version = "0.3.0", features = ["alloc-libc"] } +``` + +``` + Size blinky text 13764 data 80 bss 5668 total 19512 + +vector 5 last=16 +``` + +`alloc-libc` forwards to the target's C library and is the shorter path when the +target has one. `alloc-kal` forwards to openkal instead, which is what a project +wants when the same sources must also build for a target whose environment is +not a C library; on bare metal the board package supplies the openkal backend, +because the console and the heap region are board facts: + +```toml +riscv-virt-rt = { version = "0.4.0", features = ["openkal"] } +std-freestanding = { version = "0.3.0", features = ["alloc-kal"] } +``` + +`operator new` is a whole-program singleton, so the implementation is a separate +package and the choice belongs to the program. The feature states the +requirement as a capability; the implementations provide it; the resolver binds +exactly one. Both failure modes are therefore reported when the graph resolves, +naming packages rather than mangled symbols: + +``` +error: no package provides capability 'freestanding-allocator' required by 'std-freestanding' + +error: capability 'freestanding-allocator' has multiple providers in the graph: + [std-freestanding-alloc-kal, std-freestanding-alloc-libc] +``` + +### A target with no C library + +`[target.].sysroot` overrides the C library the target table binds, on +the same axis as `toolchain` overriding the compiler pin. The empty string +declines a C library altogether: + +```toml +[target.riscv64-none-elf] +sysroot = "" +``` + +With that line the C headers leave the compile line and the C library leaves the +link. `#include ` stops resolving, and the image contains only what the +project and its dependencies put in it. Measured: a self-contained image with +its own entry point and linker script links at **108 bytes** and boots. + +An absent key and an empty one are different answers. Absent inherits the target +table's C library; present-and-empty declines it. A kernel or a bootloader wants +the second, and + +```bash +mcpp new mykernel --template riscv-virt-rt:nolibc +``` + +produces a project already in that arrangement — an entry point, a memory map +and a device, measured at 369 bytes. + +Four of the C functions a freestanding translation unit still reaches for are an +obligation rather than a convenience: `memcpy`, `memmove`, `memset` and `memcmp` +must exist because the compiler lowers structure assignment and array +initialisation onto them. `std-freestanding-nolibc` supplies those and `strlen`. + +⚠️ That package is for the zero-libc tier only. A dependency package's object +files enter the consumer's link unconditionally, so a project whose board +package links `-lc` would get two definitions of `memcpy`. + ### Running tests on the target `mcpp test` builds one image per `tests/*.cpp`, runs each under the emulator @@ -488,6 +578,6 @@ targets, but that expectation is **not** covered by a test. | `std::format`, `std::sort` over builtin scalar types, and a complete `std::string` | Fail at **link** time naming the undefined symbol. libc++ places these entities in the compiled library — the scalar `__sort` instantiations are `extern template`, with no macro that disables them — so a target-built `libc++.a` is required. No such payload is published. | | Exceptions and RTTI | Disabled across the whole graph. `try`/`catch` is unavailable at compile time. A board shipping a target-built `libc++abi` and unwinder has a genuine case for re-enabling them; that is the point at which this becomes a manifest key. | | Board coverage | One board family. `riscv32-none-elf` demonstrates that the ISA table is data, not that a second machine has been ported. ARM Cortex-M has not been attempted. | -| C library substitution | `[target.]` accepts `toolchain`, `linkage`, `runner` and `cxx_runtime`, but has **no `sysroot` key**. Replacing picolibc with newlib is therefore not expressible in a project's manifest today. | +| C library substitution | Expressible since 2026.8.20.2 through `[target.].sysroot`, and **verified only for the empty value** (the zero-libc tier). Pointing it at a different C library is accepted and installed through the same channel, but no second bare-metal C library is published, so that path is untested. | | `qemu-riscv` on `win32-arm64` | The upstream package publishes no asset for that host, so installation fails on it. The failure is correct rather than silent, but the host cannot run a bare-metal image. | | Ecosystem CI breadth | The two ecosystem packages run their own CI on `ubuntu-24.04` only. mcpp-index's `tests/examples/` workspace members run unconditionally on three platforms with no capability gate, so a package requiring an emulator and a target sysroot cannot be added there. This is a known coverage gap. | diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 4561e388..f89dd76d 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -793,9 +793,37 @@ cxxflags = ["-march=x86-64-v2"] 「glob 未匹配到任何源文件」的警告。于是一份 manifest 可以同时携带三个 OS 的 flag 表,而不会在另外两个上制造噪声 —— 与未启用 feature 的条目根本不存在是 同一个道理。**无条件**表里的零命中 glob 仍然告警,因为那里它是真实缺陷。 -- **`toolchain` / `linkage` 仅限精确三元组** —— 它们描述某一个具体的交叉目标, +- **`toolchain` / `linkage` / `sysroot` 仅限精确三元组** —— 它们描述某一个具体的交叉目标, 因此写在 `[target.]` 下(见上),而不是裸别名或 `cfg(...)` 下。 +#### `sysroot` —— 目标的 C 库 + +`sysroot`(mcpp 2026.8.20.2+)覆盖目标表为某个三元组绑定的 C 库,与 `toolchain` +覆盖编译器 pin 同轴:一个指名目标所解析的编译器,另一个指名它的 C 库,两者在工程 +有理由与之分歧之前都只由引擎决定。 + +```toml +[target.riscv64-none-elf] +sysroot = "xim:newlib-riscv@4.4" # a different C library +``` + +```toml +[target.riscv64-none-elf] +sysroot = "" # no C library at all +``` + +⚠️ **键缺席与键为空是两个不同的答案。** 缺席继承目标表的 C 库。存在且为空是 +**零 libc 档**:不解析任何 C 库,不加入头文件与库目录,链接行上只有工程与其依赖 +提供的内容,`#include ` 不再解析。内核与 bootloader 要的正是这一档,而把 +两种情形合并会让这类工程静默地把目标的 C 库拿回去。 + +取值是 xpkg 引用或空字符串;裸名在解析清单时即被拒绝,因为接受它会导致什么都不安装, +然后在很晚的时候以「缺少 libc」失败。 + +构建程序可以询问解析到的是哪份 C 库:`mcpp::target_libc()` 返回其包名, +`mcpp::target_libc_profile()` 返回目标 ISA 档位对应的子目录。零 libc 档上两者均为空。 +参见[13 —— 裸机与 freestanding 目标](13-baremetal.md)。 + ### 2.7.2 裸机(`os = none`)—— freestanding target `riscv64-none-elf` 与 `riscv32-none-elf` 是底下没有操作系统的 target。它们不需要 diff --git a/docs/zh/13-baremetal.md b/docs/zh/13-baremetal.md index 57527109..aba665fe 100644 --- a/docs/zh/13-baremetal.md +++ b/docs/zh/13-baremetal.md @@ -217,6 +217,85 @@ std::mutex m; error: no type named 'mutex' in namespace 'std' ``` +### 子集中会分配的那半边 + +子集不改变什么能编。它的全部头文件都是无条件包含的,`std::vector` 今天就编得过; +失败发生在**链接**,因为 freestanding 目标没有编译版 `libc++`,也就没有 +`operator new`: + +``` +ld.lld: error: undefined symbol: operator new(unsigned long) +>>> referenced by allocate.h:58 +``` + +| 不需要分配器 | 需要分配器 | +|---|---| +| `array` `span` `optional` `expected` `atomic` `string_view` `ranges` `algorithm` `bit` `charconv` `tuple` | `vector` `string` `deque` `list` `map` `set` `unordered_*` `function` `any` `make_unique`,以及默认的协程帧 | + +分配器随一个 feature 到来: + +```toml +[dependencies] +riscv-virt-rt = "0.4.0" +std-freestanding = { version = "0.3.0", features = ["alloc-libc"] } +``` + +``` + Size blinky text 13764 data 80 bss 5668 total 19512 + +vector 5 last=16 +``` + +`alloc-libc` 转发到目标的 C 库,目标有 C 库时它是更短的路径。`alloc-kal` 转发到 +openkal,适用于同一份源码还要为「环境不是 C 库」的目标构建的工程;裸机上 openkal +后端由板级支持包提供,因为控制台与堆区都是板级事实: + +```toml +riscv-virt-rt = { version = "0.4.0", features = ["openkal"] } +std-freestanding = { version = "0.3.0", features = ["alloc-kal"] } +``` + +`operator new` 是全程序单例,因此实现是独立的包,而选择属于程序。feature 以能力的 +形式声明这条要求,实现方提供该能力,解析器绑定恰好一个。两种失败因此都在图解析时 +报告,点名的是包而不是 mangled 符号: + +``` +error: no package provides capability 'freestanding-allocator' required by 'std-freestanding' + +error: capability 'freestanding-allocator' has multiple providers in the graph: + [std-freestanding-alloc-kal, std-freestanding-alloc-libc] +``` + +### 没有 C 库的目标 + +`[target.].sysroot` 覆盖目标表所绑定的 C 库,与 `toolchain` 覆盖编译器 pin +同轴。空字符串表示完全不要 C 库: + +```toml +[target.riscv64-none-elf] +sysroot = "" +``` + +有了这一行,C 头文件离开编译行,C 库离开链接行。`#include ` 不再解析, +镜像里只剩工程与其依赖放进去的内容。实测:一个自带入口点与链接脚本的自包含镜像 +链接后为 **108 字节**,并能启动。 + +键缺席与键为空是两个不同的答案。缺席继承目标表的 C 库,存在且为空则拒绝它。内核与 +bootloader 要的是后者,而 + +```bash +mcpp new mykernel --template riscv-virt-rt:nolibc +``` + +生成的工程已处于该安排 —— 一个入口点、一份内存映射、一个设备,实测 369 字节。 + +freestanding 翻译单元仍会用到的 C 函数中,有四个是义务而非便利:`memcpy`、 +`memmove`、`memset` 与 `memcmp` 必须存在,因为编译器把结构体赋值与数组初始化下降到 +它们之上。`std-freestanding-nolibc` 提供这四个与 `strlen`。 + +⚠️ 该包仅用于零 libc 档。依赖包的目标文件无条件进入消费方的链接,因此板级支持包链了 +`-lc` 的工程会得到两份 `memcpy`。 + ### 在目标上运行测试 `mcpp test` 为每个 `tests/*.cpp` 构建一个独立镜像,在板级支持包提供的模拟器里运行, @@ -451,6 +530,6 @@ deps = ["xim:qemu-riscv@9.2.4-1"] | `std::format`、内建标量类型上的 `std::sort`、以及完整的 `std::string` | 在**链接**期失败并点名未定义符号。libc++ 把这些实体放在编译版库中 —— 标量 `__sort` 的实例化是 `extern template`,没有可用于关闭它们的宏 —— 因此需要为目标编出的 `libc++.a`。该载荷尚未发布。 | | 异常与 RTTI | 在整张图上关闭。`try`/`catch` 在编译期即不可用。一块随包提供目标版 `libc++abi` 与 unwinder 的板子有重新开启它们的正当理由;那也正是这一项应当成为一个清单键的时刻。 | | 板子覆盖面 | 只有一个板级家族。`riscv32-none-elf` 证明的是 ISA 表为数据,而不是已移植第二台机器。ARM Cortex-M 尚未尝试。 | -| 替换 C 库 | `[target.]` 接受 `toolchain`、`linkage`、`runner` 与 `cxx_runtime`,但**没有 `sysroot` 键**。因此把 picolibc 换成 newlib 在今天的工程清单里无法表达。 | +| 替换 C 库 | 自 2026.8.20.2 起可经 `[target.].sysroot` 表达,而**仅空值一侧经过验证**(零 libc 档)。指向另一份 C 库同样被接受并经同一通道安装,但生态中没有第二份裸机 C 库,该路径未经测试。 | | `win32-arm64` 上的 `qemu-riscv` | 上游包未为该宿主发布资产,因此在其上安装会失败。该失败是正确的而非静默的,但该宿主无法运行裸机镜像。 | | 生态侧 CI 广度 | 两个生态包各自的 CI 只跑 `ubuntu-24.04`。mcpp-index 的 `tests/examples/` workspace 成员在三个平台上无条件运行且没有能力门,因此需要模拟器与目标 sysroot 的包无法加入其中。这是一个已知的覆盖缺口。 | diff --git a/mcpp.toml b/mcpp.toml index dd921dcd..e706fd59 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.20.1" +version = "2026.8.20.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/version.cppm b/src/version.cppm index c4f41023..504dd73b 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.20.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.20.2"; } // namespace mcpp From 69701fc2acfffafcdee859218eab72fbca4a92d2 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:56:24 +0800 Subject: [PATCH 10/20] =?UTF-8?q?docs:=20=E6=9B=B4=E6=AD=A3=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E8=A2=AB=E5=AE=9E=E6=B5=8B=E6=8E=A8=E7=BF=BB=E7=9A=84?= =?UTF-8?q?=E6=96=AD=E8=A8=80=20=E2=80=94=E2=80=94=20nolibc=20=E4=B8=8E=20?= =?UTF-8?q?C=20=E5=BA=93=E5=B9=B6=E5=AD=98=E4=B8=8D=E6=8A=A5=E9=94=99,?= =?UTF-8?q?=E8=80=8C=E6=98=AF=E9=9D=99=E9=BB=98=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ **本轮唯一一次「已经写进已发布文本才被推翻」的断言。** `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 记下了这一条, 以及它为什么发生:「结构上可能 ≠ 运行时确实」这次是在我自己身上生效的。 --- ...8-20-freestanding-ecosystem-implementation-plan.md | 10 ++++++++-- docs/13-baremetal.md | 11 ++++++++--- docs/zh/13-baremetal.md | 7 +++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md index dcdcdd95..61817d5b 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md @@ -125,8 +125,14 @@ capability 作仲裁,实现在别的包。 | 模板用 `[build] ldflags = ["-T", "link.ld"]` | 链接器工作目录是构建目录 ⇒ `cannot find linker script link.ld` | 改用 `mcpp:link-script=`,它按包根解析 | | `alignof(__max_align_t)` 可用 | 该标识符来自 ``,零 libc 包没有 | 改用编译器预定义的 `__BIGGEST_ALIGNMENT__` | | Cortex-M「只缺一行目标表」 | xim 索引里裸机 C 库只有 `picolibc-riscv`,qemu 只有 `qemu-riscv` | 真实成本是**两个新载荷** + 一行 + 一个包 | +| ⭐ `nolibc` 与 `-lc` 并存会**重复定义而失败** | **冷构建成功**,`nm` 只找到一处 `memcpy` —— C 库是**归档**,成员只在符号仍未定义时才拉入,而包的 `.o` 先定义了它 | 危害不是链接失败,而是**静默替换**了 C 库的优化实现。已更正包注释、README、`docs/13` 中英两版 | -⇒ 八条里有七条**若不实测就会写进文档**。 +⇒ 九条里有八条**若不实测就会写进文档**。 + +⚠️ 最后一条是本轮唯一一次**已经写进了已发布文本才被推翻**的:它进了包的 mcpp.toml、 +README、索引描述符与 `docs/13`,之后才被实测否掉。推理链本身没错(依赖确实以 `.o` +参与链接),错在**没有把它与「C 库是归档」这一半合起来看**。 +「结构上可能 ≠ 运行时确实」这一条,这次是在我自己身上生效的。 --- @@ -172,6 +178,6 @@ capability 作仲裁,实现在别的包。 |---|---| | 生态侧验证 | **仅 Linux**。macOS / Windows 宿主上的裸机链无持续验证 | | 两个 alloc provider 的互斥 | 由解析器保证,**已实测**;但未验证第三方 provider 的情形 | -| `std-freestanding-nolibc` 与 `-lc` 共存 | 设计上不可共存,**未实测**其失败信息是否可读 | +| `std-freestanding-nolibc` 与 `-lc` 共存 | ⚠️ **已实测,而且推翻了设计主张** —— 见 §3 末行 | | openkal 裸机后端 | 实现 core 三件;`kal_stream_read` 走 semihosting,**未实测**输入路径 | | 零 libc 模板的 rv32 档 | **未实测**(模板只写了 rv64 的 `[target.*]` 段) | diff --git a/docs/13-baremetal.md b/docs/13-baremetal.md index 5bdbc355..3f17bf30 100644 --- a/docs/13-baremetal.md +++ b/docs/13-baremetal.md @@ -323,9 +323,14 @@ obligation rather than a convenience: `memcpy`, `memmove`, `memset` and `memcmp` must exist because the compiler lowers structure assignment and array initialisation onto them. `std-freestanding-nolibc` supplies those and `strlen`. -⚠️ That package is for the zero-libc tier only. A dependency package's object -files enter the consumer's link unconditionally, so a project whose board -package links `-lc` would get two definitions of `memcpy`. +⚠️ That package is for the zero-libc tier only, and using it alongside a C +library fails silently rather than loudly. A C library ships as an archive, and +an archive member is pulled only while the symbol is still undefined; a +dependency package's object files enter the link unconditionally. The package +therefore defines `memcpy` first, the C library's member is never pulled, and +the build succeeds — with the byte-at-a-time implementations in place of the C +library's optimised ones, and no report of the substitution. Measured with +picolibc present: a cold build links, and `nm` finds one definition. ### Running tests on the target diff --git a/docs/zh/13-baremetal.md b/docs/zh/13-baremetal.md index aba665fe..16cad7eb 100644 --- a/docs/zh/13-baremetal.md +++ b/docs/zh/13-baremetal.md @@ -293,8 +293,11 @@ freestanding 翻译单元仍会用到的 C 函数中,有四个是义务而非便 `memmove`、`memset` 与 `memcmp` 必须存在,因为编译器把结构体赋值与数组初始化下降到 它们之上。`std-freestanding-nolibc` 提供这四个与 `strlen`。 -⚠️ 该包仅用于零 libc 档。依赖包的目标文件无条件进入消费方的链接,因此板级支持包链了 -`-lc` 的工程会得到两份 `memcpy`。 +⚠️ 该包仅用于零 libc 档,而与 C 库并用时是**静默**失败而非响亮失败。C 库以归档形式 +发布,归档成员只在符号仍未定义时才被拉入;而依赖包的目标文件无条件进入链接。于是该包 +先定义了 `memcpy`,C 库的成员从不被拉入,构建**成功** —— 程序拿到的是逐字节实现而不是 +C 库经过优化的那份,且没有任何提示。实测(picolibc 在场):冷构建链接通过,`nm` 只找到 +一处定义。 ### 在目标上运行测试 From 557a1f373017f1e552bdcac0bbf165e1e54f8142 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:12:50 +0800 Subject: [PATCH 11/20] =?UTF-8?q?docs:=20openkal=20=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E6=96=B0=E5=90=8E=E7=AB=AF,=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E8=A2=AB=E5=AE=83=E4=BB=AC=E6=8E=A8=E7=BF=BB=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 两个新后端(生态侧已发布并进索引) * `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 逐条记录。 --- ...estanding-ecosystem-implementation-plan.md | 8 ++++-- ...8-20-freestanding-ecosystem-positioning.md | 25 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md index 61817d5b..2118030b 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md @@ -127,7 +127,10 @@ capability 作仲裁,实现在别的包。 | Cortex-M「只缺一行目标表」 | xim 索引里裸机 C 库只有 `picolibc-riscv`,qemu 只有 `qemu-riscv` | 真实成本是**两个新载荷** + 一行 + 一个包 | | ⭐ `nolibc` 与 `-lc` 并存会**重复定义而失败** | **冷构建成功**,`nm` 只找到一处 `memcpy` —— C 库是**归档**,成员只在符号仍未定义时才拉入,而包的 `.o` 先定义了它 | 危害不是链接失败,而是**静默替换**了 C 库的优化实现。已更正包注释、README、`docs/13` 中英两版 | -⇒ 九条里有八条**若不实测就会写进文档**。 +| ⭐ UEFI 需要一个不存在的 PE 形态裸机目标 ⇒ 受阻 | `x86_64-windows-gnu` + 三个链接 flag 即产出 `IMAGE_SUBSYSTEM_EFI_APPLICATION`,**零 DLL 依赖**,OVMF 下启动通过 | `openkal-uefi` 0.1.0 已发布;定位文档 §7.4 已更正 | +| `kind = "lib"` 能让依赖以归档参与链接,从而只拉被引用的成员 | 两个变体**逐字节相同**(`text 1027`),`build.ninja` 里零个 `.a` | 拆分 `nolibc` 的编译器档与库档**不做** —— 能让该边界产生收益的机制不存在 | + +⇒ 十一条里有十条**若不实测就会写进文档**。 ⚠️ 最后一条是本轮唯一一次**已经写进了已发布文本才被推翻**的:它进了包的 mcpp.toml、 README、索引描述符与 `docs/13`,之后才被实测否掉。推理链本身没错(依赖确实以 `.o` @@ -164,6 +167,7 @@ README、索引描述符与 `docs/13`,之后才被实测否掉。推理链本身 | 项 | 状态 | 阻塞 | |---|---|---| +| **openkal 后端覆盖面** | ⭐ **已扩到四个**:linux / macos / windows / musl 之外,新增 `openkal-opensbi`(RISC-V 可移植)与 `openkal-uefi`(固件),加上板级包内的裸机后端 | —— | | **openarch A0 的第二个 arch** | 受阻 | A0 的门要求**两个真实不同的 arch**。aarch64 缺目标表行与模拟器(xim 只有 `qemu-riscv`)。⭐ 只做一个 arch **不能**证明抽象不碎 —— 这正是该门存在的理由,伪造它比不做更糟 | | **openarch 的 addrspace 判据** | 受阻 | 内存属性的语义判据需要在两个 arch 上真跑 | | **Cortex-M(决策 E 步 1)** | 未开始 | 需新建 `xim:picolibc-arm` 与 `xim:qemu-arm` 两个载荷 | @@ -180,4 +184,4 @@ README、索引描述符与 `docs/13`,之后才被实测否掉。推理链本身 | 两个 alloc provider 的互斥 | 由解析器保证,**已实测**;但未验证第三方 provider 的情形 | | `std-freestanding-nolibc` 与 `-lc` 共存 | ⚠️ **已实测,而且推翻了设计主张** —— 见 §3 末行 | | openkal 裸机后端 | 实现 core 三件;`kal_stream_read` 走 semihosting,**未实测**输入路径 | -| 零 libc 模板的 rv32 档 | **未实测**(模板只写了 rv64 的 `[target.*]` 段) | +| 零 libc 模板的 rv32 档 | **已实测**:355 字节,在 `qemu-system-riscv32` 中打印并停机。⚠️ 需手改 `[target.]` 段的键 —— 该段按精确三元组匹配,而模板只能写出一个 | diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md b/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md index 1d2d8ede..1a8d744e 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-positioning.md @@ -508,16 +508,31 @@ openkal 的接口划分与 UEFI 的服务几乎一一对应,因此 `openkal-uefi ⇒ 一个 bootloader 可以用 `openkal` 写业务(读文件、打印、分配),用 `std-freestanding` 的 tier-0 写数据结构,而**完全不碰 UEFI 的具体 API**。 -⚠️ 两条约束: +⚠️ 两条约束,而**第一条已被实测推翻**: + +1. ~~UEFI 需要一个今天不存在的 PE 形态裸机目标行。~~ ⭐ **实测:不需要。** + `x86_64-windows-gnu` 加三个链接 flag(`-nostdlib`、`-Wl,--subsystem,10`、 + `-Wl,-e,efi_main`)产出的正是 `IMAGE_SUBSYSTEM_EFI_APPLICATION (0xA)`, + 且**不依赖任何 DLL**;该目标默认就是 Microsoft x64 调用约定,固件函数指针可 + 直接调用。已实现为 `mcpplibs/openkal-uefi` 0.1.0,在 OVMF 下作为 + `EFI/BOOT/BOOTX64.EFI` 启动验证。 + + ⇒ 原判断是从「裸机链接行是 ELF 形状」推出来的,错在**没有考虑已有的 PE 目标 + 能否被降到 freestanding**。这是本轮第二次「结构上可能 ≠ 运行时确实」在我自己 + 身上生效。 -1. **UEFI 应用是 PE/COFF(subsystem `EFI_APPLICATION`),不是 ELF。** 目标不是 - `x86_64-none-elf`,需要一个 PE 形态的裸机目标行 + `lld-link`。载荷里有 - `lld-link`,但**该目标行今天不存在**,且它与现有裸机行的形状不同(§5 的 - 决策 B 应当先落地)。 2. ⚠️ **legacy BIOS 基本不在射程内**:512 字节 MBR 是 16 位实模式,clang 无法 有意义地生成。现实的「BIOS 路径」是 **multiboot2 ELF**(由 GRUB 加载), 而那正好就是 `x86_64-none-elf`,不需要新形态。 +### 7.5 RISC-V 上还有一个比板级更可移植的后端 + +`mcpplibs/openkal-opensbi` 0.1.0:控制台是对**已经知道机器是什么**的固件的一次 +`ecall`,因此同一个镜像在 QEMU `virt` 的 OpenSBI 下与真实板子上都能跑,不必重新 +构建 —— 而板级后端往固定地址写,换板即静默失效(§6.4)。 + +⇒ 两种后端并列而非替代:**SBI 要求底下有固件,板级后端不要求。** + ### 7.3 ⭐ 最小示例不是一个内核 给内核开发者的最小示例应当是**一个零 libc 的 `riscv64-none-elf` 工程**: From 7450898dfee3d18e70ea4b085bd229a05ba07beb Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:21:39 +0800 Subject: [PATCH 12/20] =?UTF-8?q?fix(docs):=20`[feature-deps]`=20=E7=9A=84?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B=E7=89=88=E6=9C=AC=E5=BD=A2=E5=BC=8F=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E4=B8=8D=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ `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 逐条记录。 --- ...eestanding-ecosystem-implementation-plan.md | 9 ++++++++- docs/05-mcpp-toml.md | 18 +++++++++++++++++- docs/zh/05-mcpp-toml.md | 15 ++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md index 2118030b..6f74681c 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md @@ -130,7 +130,14 @@ capability 作仲裁,实现在别的包。 | ⭐ UEFI 需要一个不存在的 PE 形态裸机目标 ⇒ 受阻 | `x86_64-windows-gnu` + 三个链接 flag 即产出 `IMAGE_SUBSYSTEM_EFI_APPLICATION`,**零 DLL 依赖**,OVMF 下启动通过 | `openkal-uefi` 0.1.0 已发布;定位文档 §7.4 已更正 | | `kind = "lib"` 能让依赖以归档参与链接,从而只拉被引用的成员 | 两个变体**逐字节相同**(`text 1027`),`build.ninja` 里零个 `.a` | 拆分 `nolibc` 的编译器档与库档**不做** —— 能让该边界产生收益的机制不存在 | -⇒ 十一条里有十条**若不实测就会写进文档**。 +| ⭐ `[feature-deps]` 可以写 `"0.1.x"`(照抄自 `docs/05` §2.8.2 的示例) | **解析失败**。对照组:`cmdline` 的 `0.0.1` 通过、`0.0` 通过、`0.0.x` 失败 | `std-freestanding` 0.3.1 改用两段前缀;`docs/05` 中英两版的示例本身也已更正 | +| feature 是消费者控制的,不开就不包含 | **中间库能替根工程打开。** 实测:根不请求 `loud`,中间库请求了,根自己的翻译单元里就得到 `LOUD` | 这是 `nolibc` 必须独立成包的决定性证据 —— 做成 feature 的话,一个库能让每个消费者静默换掉 C 库的 `memcpy` | + +⇒ 十三条里有十二条**若不实测就会写进文档**。 + +⚠️ ⭐ **`0.1.x` 那条是本轮最贵的一次**:它让 `std-freestanding` 0.3.0 的两个 feature +**实际不可用**,而它在我这里「全部通过」—— 因为本地每一次测试都用 path 依赖, +**path 依赖根本不查索引**。判据必须至少走一次真实索引,否则「能用」证明的是别的东西。 ⚠️ 最后一条是本轮唯一一次**已经写进了已发布文本才被推翻**的:它进了包的 mcpp.toml、 README、索引描述符与 `docs/13`,之后才被实测否掉。推理链本身没错(依赖确实以 `.o` diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index b1599fa2..10874a0c 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1148,9 +1148,25 @@ backend-openblas = { implies = ["use_blas"] } # Pulled ONLY when `backend-openblas` is active. Each entry is a full dependency # spec (version/path/git + its own features). [feature-deps.backend-openblas] -compat.openblas = "0.3.x" +compat.openblas = "0.3" ``` +⚠️ **`"0.3"` and not `"0.3.x"`.** A trailing `.x` is not a selector this resolver +has; the literal reaches the installer, which reports the package as not found. +Measured against a control, with a package the index certainly carries: + +| Written | Result | +|---|---| +| `cmdline = "0.0.1"` | resolves | +| `cmdline = "0.0"` | resolves | +| `cmdline = "0.0.x"` | **`E_NOT_FOUND`** | + +A two-segment prefix expresses "any patch of this minor" and works. This matters +more here than in `[dependencies]`, because a feature whose implementation +cannot be fetched is a feature that does not exist — and a project using a path +dependency during development never consults the index, so the failure appears +only after publication. + This composes with capabilities (§2.8.1): a single `backend-openblas` feature both **pulls** the provider (`compat.openblas`, which `provides = ["blas"]`) and **turns on** the consumer switch (`implies = ["use_blas"]`, which diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index f89dd76d..581fb20f 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -1003,9 +1003,22 @@ backend-openblas = { implies = ["use_blas"] } # 仅当 `backend-openblas` 激活时才拉取。每个条目都是完整的依赖 spec #(version/path/git + 其自身的 features)。 [feature-deps.backend-openblas] -compat.openblas = "0.3.x" +compat.openblas = "0.3" ``` +⚠️ **写 `"0.3"` 而不是 `"0.3.x"`。** 结尾的 `.x` 不是本解析器拥有的选择器;该字面量 +原样送到安装器,安装器报告找不到该包。以索引中确定存在的包作对照实测: + +| 写法 | 结果 | +|---|---| +| `cmdline = "0.0.1"` | 解析通过 | +| `cmdline = "0.0"` | 解析通过 | +| `cmdline = "0.0.x"` | **`E_NOT_FOUND`** | + +两段前缀表达「该 minor 下的任意 patch」且可用。这一点在此处比在 `[dependencies]` 中 +更要紧,因为**实现取不回来的 feature 等于不存在的 feature** —— 而开发期使用 path +依赖的工程根本不查索引,该失败只在发布之后才出现。 + 该机制与能力(§2.8.1)组合:单个 `backend-openblas` feature 既**拉取** provider (`compat.openblas`,其 `provides = ["blas"]`),又**开启**消费方开关 (`implies = ["use_blas"]`,其 `requires = ["blas"]`)。当图中只有一个 provider 时, From 7eed07d6e905f540ec3ff10240c8ea69fdb551c7 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:29:30 +0800 Subject: [PATCH 13/20] =?UTF-8?q?fix(manifest):=20=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E7=94=A8=E5=B7=B2=E6=9C=89=E7=9A=84=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=99=A8=E6=A0=A1=E9=AA=8C,=E8=80=8C=E8=BF=99?= =?UTF-8?q?=E6=9D=A1=E8=B7=AF=E5=BE=84=E5=8E=9F=E6=9C=AC=E6=B2=A1=E7=94=A8?= =?UTF-8?q?=E5=AE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ **解析器一直都在,而这条路径没有调用它。** `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 里可粘贴的版本行已指向可用版本。 --- ...estanding-ecosystem-implementation-plan.md | 6 ++ src/manifest/toml.cppm | 50 ++++++++++- tests/unit/test_manifest.cpp | 90 ++++++++++++++++++- 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md index 6f74681c..fa86b077 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md @@ -135,6 +135,12 @@ capability 作仲裁,实现在别的包。 ⇒ 十三条里有十二条**若不实测就会写进文档**。 +⭐ **`.x` 那条还有第三层**:同一个坏形式也在 mcpp **自己的测试套件**里 +(`test_manifest.cpp` 的 `zlib = "1.3.x"`)。文档教它、测试用它、生态照抄它 —— +而三处没有一处会去解析它,因为 fixture 只需要**能解析清单**,从不问索引。 +⇒ 已在引擎侧补上校验:依赖版本用**已有的** `version_req::parse_req` 检查, +并用单测钉住五种既有可用形式不被收窄。 + ⚠️ ⭐ **`0.1.x` 那条是本轮最贵的一次**:它让 `std-freestanding` 0.3.0 的两个 feature **实际不可用**,而它在我这里「全部通过」—— 因为本地每一次测试都用 path 依赖, **path 依赖根本不查索引**。判据必须至少走一次真实索引,否则「能用」证明的是别的东西。 diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index 1fd3bafd..b90261d5 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -7,10 +7,38 @@ import std; import mcpp.source_kind; import mcpp.libs.toml; import mcpp.pm.dep_spec; +import mcpp.version_req; import mcpp.pm.dependency_selector; import mcpp.pm.index_spec; import mcpp.platform; +// A dependency's version requirement, checked with the parser that will later +// be asked to match it. +// +// ⚠️ THE PARSER EXISTED AND THIS PATH DID NOT USE IT. +// +// `version_req::parse_req` is what decides which published version satisfies a +// requirement. The dependency reader handed its string straight to the +// installer instead, so a requirement the matcher could never satisfy reached +// the network — and came back as +// +// E_NOT_FOUND: package 'compat.std-freestanding-alloc-libc@0.1.x' not +// found in the synced index +// +// which names the PACKAGE. The package exists; the requirement is what does +// not parse. Measured 2026-08-20, from a form that this repository's own +// documentation recommended (`docs/05` §2.8.2 said `compat.openblas = "0.3.x"`). +// +// Checking here converts a network round-trip and a misleading answer into a +// message that names the actual problem, at the point where the text was +// written. +std::optional version_req_problem(std::string_view spec) { + if (spec.empty()) return std::nullopt; // path/git/workspace deps + if (auto r = mcpp::version_req::parse_req(spec); !r) return r.error(); + return std::nullopt; +} + + export namespace mcpp::manifest { std::expected parse_string(std::string_view content, @@ -632,7 +660,18 @@ std::expected parse_string(std::string_view content, "typo, or a field a newer mcpp understands.", section, fqName, sk)); } if (auto it = sub.find("path"); it != sub.end() && it->second.is_string()) spec.path = it->second.as_string(); - if (auto it = sub.find("version"); it != sub.end() && it->second.is_string()) spec.version = it->second.as_string(); + if (auto it = sub.find("version"); it != sub.end() && it->second.is_string()) { + spec.version = it->second.as_string(); + if (auto why = version_req_problem(spec.version)) + return std::unexpected(error(origin, std::format( + "[{}.\"{}\"] version = '{}' is not a requirement this " + "resolver can match: {}.\n" + " Accepted: an exact version (\"1.2.3\"), a prefix " + "(\"1.2\" = any patch of that minor),\n" + " a comparator (\"^1.2\", \">=1.0, <2.0\"), or " + "\"*\". A trailing `.x` is not one of them.", + section, fqName, spec.version, *why))); + } if (auto it = sub.find("git"); it != sub.end() && it->second.is_string()) spec.git = it->second.as_string(); if (auto it = sub.find("visibility"); it != sub.end() && it->second.is_string()) { spec.visibility = it->second.as_string(); @@ -746,6 +785,15 @@ std::expected parse_string(std::string_view content, auto key = selector.stableMapKey; if (value.is_string()) { spec.version = value.as_string(); + if (auto why = version_req_problem(spec.version)) + return std::unexpected(error(origin, std::format( + "[{}] {} = '{}' is not a requirement this resolver can " + "match: {}.\n" + " Accepted: an exact version (\"1.2.3\"), a prefix " + "(\"1.2\" = any patch of that minor),\n" + " a comparator (\"^1.2\", \">=1.0, <2.0\"), or " + "\"*\". A trailing `.x` is not one of them.", + section, key, spec.version, *why))); } else if (value.is_table()) { auto& sub = value.as_table(); if (!looks_like_inline_dep_spec(sub)) { diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index b843c2d1..be0dcb3a 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -1059,6 +1059,11 @@ package = { // Feature System v2 Stage 2a: optional deps activated by a feature. // TOML surface uses a dedicated [feature-deps.] section. +// ⚠️ The fixture used to say `zlib = "1.3.x"`, which cannot resolve: a trailing +// `.x` is not a selector this resolver has. It went unnoticed because the same +// form was in the documented `[feature-deps]` example, and because a fixture +// only has to PARSE — nothing here ever asked the index for that package. It +// reached a published package (`std-freestanding` 0.3.0) before it was measured. TEST(Manifest, FeatureDepsTomlSection) { constexpr auto src = R"( [package] @@ -1070,7 +1075,7 @@ kind = "lib" default = [] backend = [] [feature-deps.backend] -zlib = "1.3.x" +zlib = "1.3" )"; auto m = mcpp::manifest::parse_string(src); ASSERT_TRUE(m.has_value()) << m.error().format(); @@ -3728,3 +3733,86 @@ sysroot = "" for (auto const& w : m->schemaWarnings) EXPECT_EQ(w.find("sysroot"), std::string::npos) << w; } + +// ── Dependency version requirements are checked where they are written ─────── +// +// ⚠️ The parser that decides which published version satisfies a requirement +// existed all along; the dependency reader did not use it, and handed its +// string to the installer instead. A requirement the matcher could never +// satisfy therefore reached the network and came back as +// `E_NOT_FOUND: package '…@0.1.x' not found` — naming the PACKAGE, which +// exists. This repository's own documentation recommended that form. + +TEST(Manifest, DependencyVersionAcceptsEveryFormThatResolves) { + // ⭐ This test exists to prevent the check from NARROWING anything. Each of + // these was measured to resolve against the real index before the check was + // added, so a future tightening that breaks one of them fails here first. + for (const char* v : { "0.0.1", "0.0", "^0.0.1", ">=0.0.1, <0.1.0", "*" }) { + auto src = std::format(R"( +[package] +name = "x" +version = "0.1.0" +[dependencies] +cmdline = "{}" +)", v); + auto m = mcpp::manifest::parse_string(src); + EXPECT_TRUE(m.has_value()) << v << ": " << (m ? "" : m.error().format()); + } +} + +TEST(Manifest, DependencyVersionRejectsATrailingX) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[dependencies] +cmdline = "0.0.x" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()); + auto msg = m.error().format(); + EXPECT_NE(msg.find("not a requirement"), std::string::npos) << msg; + // The message has to say what IS accepted, because the reader arrived here + // by writing something that looked reasonable. + EXPECT_NE(msg.find("1.2"), std::string::npos) << msg; +} + +TEST(Manifest, DependencyVersionRejectsTrailingGarbage) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[dependencies] +cmdline = "1.2.3abc" +)"; + EXPECT_FALSE(mcpp::manifest::parse_string(src).has_value()); +} + +// A path or git dependency has no version, and requiring one would break every +// workspace member. +TEST(Manifest, DependencyWithoutAVersionIsUnaffected) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[dependencies] +local = { path = "../local" } +)"; + auto m = mcpp::manifest::parse_string(src); + EXPECT_TRUE(m.has_value()) << (m ? "" : m.error().format()); +} + +// The long form reaches a different code path than the bare string, and only +// one of the two used to set `spec.version`. Both are checked. +TEST(Manifest, LongFormDependencyVersionIsCheckedToo) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[dependencies] +cmdline = { version = "0.0.x", features = ["a"] } +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()); + EXPECT_NE(m.error().format().find("not a requirement"), std::string::npos); +} From 9e22c9c61fd81590e81cc1fe11d2340031602904 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:33:36 +0800 Subject: [PATCH 14/20] =?UTF-8?q?fix(docs):=20=E7=89=88=E6=9C=AC=E5=BD=A2?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=E6=8E=A8=E8=8D=90=E5=80=BC=E4=B9=9F=E6=98=AF?= =?UTF-8?q?=E9=94=99=E7=9A=84,=E5=88=A4=E6=8D=AE=E6=8D=A2=E6=88=90?= =?UTF-8?q?=E3=80=8C=E6=9E=84=E5=BB=BA=E6=88=90=E5=8A=9F=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ 上一次提交把 `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 保留,其不分配的那半边 不受影响)。 --- ...estanding-ecosystem-implementation-plan.md | 17 +++++++++-- docs/05-mcpp-toml.md | 28 +++++++++++-------- docs/zh/05-mcpp-toml.md | 23 +++++++++------ tests/unit/test_manifest.cpp | 11 +++++--- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md index fa86b077..6f179df0 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md @@ -141,9 +141,20 @@ capability 作仲裁,实现在别的包。 ⇒ 已在引擎侧补上校验:依赖版本用**已有的** `version_req::parse_req` 检查, 并用单测钉住五种既有可用形式不被收窄。 -⚠️ ⭐ **`0.1.x` 那条是本轮最贵的一次**:它让 `std-freestanding` 0.3.0 的两个 feature -**实际不可用**,而它在我这里「全部通过」—— 因为本地每一次测试都用 path 依赖, -**path 依赖根本不查索引**。判据必须至少走一次真实索引,否则「能用」证明的是别的东西。 +⚠️ ⭐ **`0.1.x` 那条是本轮最贵的一次,而且它花了三个版本才修对。** + +| 版本 | 写法 | 结果 | 击败它的弱判据 | +|---|---|---|---| +| 0.3.0 | `"0.1.x"` | `E_NOT_FOUND`,点名的是**包**(而包存在) | 「本地测试全过」—— 全用 path 依赖 | +| 0.3.1 | `"0.1"` | 解析通过,随后 `install path missing after fetch` | 「没有 `E_NOT_FOUND`」 | +| 0.3.2 | `"^0.1.0"` | ✅ 构建通过 | —— | + +⭐ **三个判据强度递增,而前两个各放行了一种不可用的写法:** + + 本地能编过 < 没有 E_NOT_FOUND < 装得上 < 对着真实索引构建成功 + +只有最后一个能定论。**path 依赖根本不查索引**,所以开发期的「全部通过」证明的是别的 +东西 —— 而这条链上每一环我都在当时认为已经验证过了。 ⚠️ 最后一条是本轮唯一一次**已经写进了已发布文本才被推翻**的:它进了包的 mcpp.toml、 README、索引描述符与 `docs/13`,之后才被实测否掉。推理链本身没错(依赖确实以 `.o` diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 10874a0c..89df3b9a 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1151,21 +1151,25 @@ backend-openblas = { implies = ["use_blas"] } compat.openblas = "0.3" ``` -⚠️ **`"0.3"` and not `"0.3.x"`.** A trailing `.x` is not a selector this resolver -has; the literal reaches the installer, which reports the package as not found. -Measured against a control, with a package the index certainly carries: +⚠️ **`"^0.3.0"` and not `"0.3.x"` or `"0.3"`.** Measured against a package the +index certainly carries, with a **build** as the criterion: | Written | Result | |---|---| -| `cmdline = "0.0.1"` | resolves | -| `cmdline = "0.0"` | resolves | -| `cmdline = "0.0.x"` | **`E_NOT_FOUND`** | - -A two-segment prefix expresses "any patch of this minor" and works. This matters -more here than in `[dependencies]`, because a feature whose implementation -cannot be fetched is a feature that does not exist — and a project using a path -dependency during development never consults the index, so the failure appears -only after publication. +| `cmdline = "0.0.1"` | builds | +| `cmdline = "^0.0.1"` | builds | +| `cmdline = "0.0"` | resolves, then `install path missing after fetch` | +| `cmdline = "0.0.x"` | `E_NOT_FOUND`, naming the package — which exists | + +⭐ The three outcomes are worth distinguishing, because two weaker criteria each +admit a form that does not work: "no `E_NOT_FOUND`" admits the two-segment +prefix, and "resolves" admits it as well. Only building against the real index +settles it. + +⚠️ This matters more here than in `[dependencies]`. A feature whose +implementation cannot be fetched is a feature that does not exist, and a project +using a **path** dependency during development never consults the index — so the +failure appears only after publication, to somebody else. This composes with capabilities (§2.8.1): a single `backend-openblas` feature both **pulls** the provider (`compat.openblas`, which `provides = ["blas"]`) and diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 581fb20f..185b68c4 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -1006,18 +1006,23 @@ backend-openblas = { implies = ["use_blas"] } compat.openblas = "0.3" ``` -⚠️ **写 `"0.3"` 而不是 `"0.3.x"`。** 结尾的 `.x` 不是本解析器拥有的选择器;该字面量 -原样送到安装器,安装器报告找不到该包。以索引中确定存在的包作对照实测: +⚠️ **写 `"^0.3.0"`,而不是 `"0.3.x"` 或 `"0.3"`。** 以索引中确定存在的包作对照, +判据取**构建成功**: | 写法 | 结果 | |---|---| -| `cmdline = "0.0.1"` | 解析通过 | -| `cmdline = "0.0"` | 解析通过 | -| `cmdline = "0.0.x"` | **`E_NOT_FOUND`** | - -两段前缀表达「该 minor 下的任意 patch」且可用。这一点在此处比在 `[dependencies]` 中 -更要紧,因为**实现取不回来的 feature 等于不存在的 feature** —— 而开发期使用 path -依赖的工程根本不查索引,该失败只在发布之后才出现。 +| `cmdline = "0.0.1"` | 构建通过 | +| `cmdline = "^0.0.1"` | 构建通过 | +| `cmdline = "0.0"` | 解析通过,随后 `install path missing after fetch` | +| `cmdline = "0.0.x"` | `E_NOT_FOUND`,点名的是包 —— 而该包存在 | + +⭐ 这三种结果值得分开,因为两个更弱的判据各自会放行一种不可用的写法: +「没有 `E_NOT_FOUND`」放行两段前缀,「解析通过」同样放行它。**只有对着真实索引构建 +一次**才能定论。 + +⚠️ 这一点在此处比在 `[dependencies]` 中更要紧:**实现取不回来的 feature 等于不存在的 +feature**,而开发期使用 **path** 依赖的工程根本不查索引 —— 该失败只在发布之后才出现, +而且是出现在别人身上。 该机制与能力(§2.8.1)组合:单个 `backend-openblas` feature 既**拉取** provider (`compat.openblas`,其 `provides = ["blas"]`),又**开启**消费方开关 diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index be0dcb3a..90f0a0df 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -3743,10 +3743,13 @@ sysroot = "" // `E_NOT_FOUND: package '…@0.1.x' not found` — naming the PACKAGE, which // exists. This repository's own documentation recommended that form. -TEST(Manifest, DependencyVersionAcceptsEveryFormThatResolves) { - // ⭐ This test exists to prevent the check from NARROWING anything. Each of - // these was measured to resolve against the real index before the check was - // added, so a future tightening that breaks one of them fails here first. +// ⚠️ PARSE-acceptance, which is a weaker property than installability and was +// measured to be weaker: `"0.0"` parses here and then fails at fetch with +// `install path missing`. The check added below is a manifest check and has no +// business ruling on the installer's path derivation, so this test pins what +// the PARSER must keep accepting and says nothing about what fetches. +TEST(Manifest, DependencyVersionParserAcceptsEveryEstablishedForm) { + // ⭐ This test exists to prevent the check from NARROWING anything. for (const char* v : { "0.0.1", "0.0", "^0.0.1", ">=0.0.1, <0.1.0", "*" }) { auto src = std::format(R"( [package] From dea55c36fe29321cfa4ce018e88c1420ddee5123 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:41:11 +0800 Subject: [PATCH 15/20] =?UTF-8?q?fix(manifest):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E6=94=B9=E4=B8=BA=E6=8A=A5=E5=91=8A=E8=80=8C?= =?UTF-8?q?=E9=9D=9E=E6=8B=92=E7=BB=9D,=E4=BB=A5=E5=8F=8A=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E8=A2=AB=E5=AE=9E=E6=B5=8B=E5=AE=9A=E6=A1=88=E7=9A=84?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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` 中英两版。 --- ...estanding-ecosystem-implementation-plan.md | 10 ++++- docs/13-baremetal.md | 6 +++ docs/zh/13-baremetal.md | 4 ++ src/manifest/toml.cppm | 39 ++++++++++++------- tests/unit/test_manifest.cpp | 36 +++++++++++------ 5 files changed, 69 insertions(+), 26 deletions(-) diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md index 6f179df0..0840033b 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md @@ -133,7 +133,15 @@ capability 作仲裁,实现在别的包。 | ⭐ `[feature-deps]` 可以写 `"0.1.x"`(照抄自 `docs/05` §2.8.2 的示例) | **解析失败**。对照组:`cmdline` 的 `0.0.1` 通过、`0.0` 通过、`0.0.x` 失败 | `std-freestanding` 0.3.1 改用两段前缀;`docs/05` 中英两版的示例本身也已更正 | | feature 是消费者控制的,不开就不包含 | **中间库能替根工程打开。** 实测:根不请求 `loud`,中间库请求了,根自己的翻译单元里就得到 `LOUD` | 这是 `nolibc` 必须独立成包的决定性证据 —— 做成 feature 的话,一个库能让每个消费者静默换掉 C 库的 `memcpy` | -⇒ 十三条里有十二条**若不实测就会写进文档**。 +| `nolibc` 也应做成 `std-freestanding` 的 feature(默认关),与 `alloc-*` 一致 | ⭐ **该状态不可达。** `std-freestanding` 需要 C 库的**头文件**,零 libc 档上死在 `'inttypes.h' file not found`(编译期);而 `sysroot = ""` 同时拿走头与库,没有「有头无库」这一档 | 不加该 feature —— 它会是**构造上恒不生效**的。独立包保持独立 | + +⇒ 十四条里有十三条**若不实测就会写进文档**。 + +⭐ 上面这条值得单独记:提法本身是对的(默认关的 feature 与主动加依赖等价,且能让包的 +表面一致),**我原来反对它的理由也确实站不住**(中间库无论经 feature 还是经直接依赖 +都能把它注入全图)。否掉它的是一个**第三方事实**:两个包服务于互斥的安排。 + +⇒ 判据不是「这个设计好不好」,而是「有没有一个用户能处在需要它的状态」。 ⭐ **`.x` 那条还有第三层**:同一个坏形式也在 mcpp **自己的测试套件**里 (`test_manifest.cpp` 的 `zlib = "1.3.x"`)。文档教它、测试用它、生态照抄它 —— diff --git a/docs/13-baremetal.md b/docs/13-baremetal.md index 3f17bf30..4c0ac8d8 100644 --- a/docs/13-baremetal.md +++ b/docs/13-baremetal.md @@ -323,6 +323,12 @@ obligation rather than a convenience: `memcpy`, `memmove`, `memset` and `memcmp` must exist because the compiler lowers structure assignment and array initialisation onto them. `std-freestanding-nolibc` supplies those and `strlen`. +⚠️ `std-freestanding` and `std-freestanding-nolibc` serve **mutually exclusive** +arrangements. The subset needs the C library's headers — measured: on the +zero-libc tier it fails at compile with `'inttypes.h' file not found` — so a +project that can use the subset has a C library and does not need the five +functions, while a project on the zero-libc tier cannot use the subset at all. + ⚠️ That package is for the zero-libc tier only, and using it alongside a C library fails silently rather than loudly. A C library ships as an archive, and an archive member is pulled only while the symbol is still undefined; a diff --git a/docs/zh/13-baremetal.md b/docs/zh/13-baremetal.md index 16cad7eb..18f5649e 100644 --- a/docs/zh/13-baremetal.md +++ b/docs/zh/13-baremetal.md @@ -293,6 +293,10 @@ freestanding 翻译单元仍会用到的 C 函数中,有四个是义务而非便 `memmove`、`memset` 与 `memcmp` 必须存在,因为编译器把结构体赋值与数组初始化下降到 它们之上。`std-freestanding-nolibc` 提供这四个与 `strlen`。 +⚠️ `std-freestanding` 与 `std-freestanding-nolibc` 服务于**互斥**的安排。子集需要 +C 库的头文件 —— 实测:零 libc 档上它在编译期即死于 `'inttypes.h' file not found` +—— 因此能用子集的工程有 C 库、不需要这五个函数,而处在零 libc 档的工程根本用不了子集。 + ⚠️ 该包仅用于零 libc 档,而与 C 库并用时是**静默**失败而非响亮失败。C 库以归档形式 发布,归档成员只在符号仍未定义时才被拉入;而依赖包的目标文件无条件进入链接。于是该包 先定义了 `memcpy`,C 库的成员从不被拉入,构建**成功** —— 程序拿到的是逐字节实现而不是 diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index b90261d5..d86633ee 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -32,6 +32,19 @@ import mcpp.platform; // Checking here converts a network round-trip and a misleading answer into a // message that names the actual problem, at the point where the text was // written. +// +// ⚠️ A WARNING AND NOT AN ERROR, AND THE FIRST VERSION GOT THIS WRONG. +// +// Rejecting the manifest breaks every consumer of a PUBLISHED package that +// carries such a string — including one where the offending entry belongs to a +// feature nobody activates. Measured: with the check as an error, a project +// pinned to `std-freestanding` 0.3.0 stopped loading entirely, although the +// half of that package it used was unaffected. +// +// This is the mirror of the rule the index already follows. Published data must +// not invalidate a running program; equally, a new program must not invalidate +// published data. A manifest check has no standing to do so over an entry that +// may never be reached. std::optional version_req_problem(std::string_view spec) { if (spec.empty()) return std::nullopt; // path/git/workspace deps if (auto r = mcpp::version_req::parse_req(spec); !r) return r.error(); @@ -663,14 +676,13 @@ std::expected parse_string(std::string_view content, if (auto it = sub.find("version"); it != sub.end() && it->second.is_string()) { spec.version = it->second.as_string(); if (auto why = version_req_problem(spec.version)) - return std::unexpected(error(origin, std::format( + m.schemaWarnings.push_back(std::format( "[{}.\"{}\"] version = '{}' is not a requirement this " - "resolver can match: {}.\n" - " Accepted: an exact version (\"1.2.3\"), a prefix " - "(\"1.2\" = any patch of that minor),\n" - " a comparator (\"^1.2\", \">=1.0, <2.0\"), or " - "\"*\". A trailing `.x` is not one of them.", - section, fqName, spec.version, *why))); + "resolver can match ({}). The fetch will fail naming the " + "PACKAGE, which may well exist; it is this requirement that " + "does not parse. Accepted: an exact version (\"1.2.3\") or " + "a comparator (\"^1.2.3\", \">=1.0.0, <2.0.0\").", + section, fqName, spec.version, *why)); } if (auto it = sub.find("git"); it != sub.end() && it->second.is_string()) spec.git = it->second.as_string(); if (auto it = sub.find("visibility"); it != sub.end() && it->second.is_string()) { @@ -786,14 +798,13 @@ std::expected parse_string(std::string_view content, if (value.is_string()) { spec.version = value.as_string(); if (auto why = version_req_problem(spec.version)) - return std::unexpected(error(origin, std::format( + m.schemaWarnings.push_back(std::format( "[{}] {} = '{}' is not a requirement this resolver can " - "match: {}.\n" - " Accepted: an exact version (\"1.2.3\"), a prefix " - "(\"1.2\" = any patch of that minor),\n" - " a comparator (\"^1.2\", \">=1.0, <2.0\"), or " - "\"*\". A trailing `.x` is not one of them.", - section, key, spec.version, *why))); + "match ({}). The fetch will fail naming the PACKAGE, which " + "may well exist; it is this requirement that does not " + "parse. Accepted: an exact version (\"1.2.3\") or a " + "comparator (\"^1.2.3\", \">=1.0.0, <2.0.0\").", + section, key, spec.version, *why)); } else if (value.is_table()) { auto& sub = value.as_table(); if (!looks_like_inline_dep_spec(sub)) { diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 90f0a0df..626bf1a7 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -3763,7 +3763,12 @@ cmdline = "{}" } } -TEST(Manifest, DependencyVersionRejectsATrailingX) { +// ⚠️ REPORTED, NOT REJECTED, and the distinction is load-bearing. The first +// version of this check returned an error, and a project pinned to a PUBLISHED +// package carrying such a string stopped loading entirely — including when the +// offending entry belonged to a feature nobody activates. Published data must +// not be invalidated by a new program any more than the reverse. +TEST(Manifest, DependencyVersionReportsATrailingXWithoutFailingTheLoad) { constexpr auto src = R"( [package] name = "x" @@ -3772,15 +3777,19 @@ version = "0.1.0" cmdline = "0.0.x" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - auto msg = m.error().format(); - EXPECT_NE(msg.find("not a requirement"), std::string::npos) << msg; - // The message has to say what IS accepted, because the reader arrived here - // by writing something that looked reasonable. - EXPECT_NE(msg.find("1.2"), std::string::npos) << msg; + ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); + bool found = false; + for (auto const& w : m->schemaWarnings) + if (w.find("not a requirement") != std::string::npos) found = true; + EXPECT_TRUE(found) << "no warning naming the requirement"; + // ⭐ And it must say that the PACKAGE is not the problem, because the + // failure the reader will otherwise see names exactly that. + for (auto const& w : m->schemaWarnings) + if (w.find("not a requirement") != std::string::npos) + EXPECT_NE(w.find("PACKAGE"), std::string::npos) << w; } -TEST(Manifest, DependencyVersionRejectsTrailingGarbage) { +TEST(Manifest, DependencyVersionReportsTrailingGarbage) { constexpr auto src = R"( [package] name = "x" @@ -3788,7 +3797,9 @@ version = "0.1.0" [dependencies] cmdline = "1.2.3abc" )"; - EXPECT_FALSE(mcpp::manifest::parse_string(src).has_value()); + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()); + EXPECT_FALSE(m->schemaWarnings.empty()); } // A path or git dependency has no version, and requiring one would break every @@ -3816,6 +3827,9 @@ version = "0.1.0" cmdline = { version = "0.0.x", features = ["a"] } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().format().find("not a requirement"), std::string::npos); + ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); + bool found = false; + for (auto const& w : m->schemaWarnings) + if (w.find("not a requirement") != std::string::npos) found = true; + EXPECT_TRUE(found); } From be6a309b6d89a400b547a672319d273fd9ffd5a8 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:48:04 +0800 Subject: [PATCH 16/20] =?UTF-8?q?docs:=20=E9=9B=B6=20libc=20=E6=A1=A3?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E6=A0=87=E5=87=86=E5=BA=93=E5=AD=90=E9=9B=86?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E4=B8=80=E4=B8=AA=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E6=B5=8B=E4=BA=86=E5=9B=9B=E9=81=8D=E6=89=8D=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ **前三次测量全部无效,而每一次我都以为已经有答案了。** | 次 | 做法 | 为什么无效 | |----|------|-----------| | 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 不会报错,只会让类型 静默不匹配。** 本轮不做,记为后续项。 --- ...estanding-ecosystem-implementation-plan.md | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md index 0840033b..65196f39 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md @@ -133,7 +133,7 @@ capability 作仲裁,实现在别的包。 | ⭐ `[feature-deps]` 可以写 `"0.1.x"`(照抄自 `docs/05` §2.8.2 的示例) | **解析失败**。对照组:`cmdline` 的 `0.0.1` 通过、`0.0` 通过、`0.0.x` 失败 | `std-freestanding` 0.3.1 改用两段前缀;`docs/05` 中英两版的示例本身也已更正 | | feature 是消费者控制的,不开就不包含 | **中间库能替根工程打开。** 实测:根不请求 `loud`,中间库请求了,根自己的翻译单元里就得到 `LOUD` | 这是 `nolibc` 必须独立成包的决定性证据 —— 做成 feature 的话,一个库能让每个消费者静默换掉 C 库的 `memcpy` | -| `nolibc` 也应做成 `std-freestanding` 的 feature(默认关),与 `alloc-*` 一致 | ⭐ **该状态不可达。** `std-freestanding` 需要 C 库的**头文件**,零 libc 档上死在 `'inttypes.h' file not found`(编译期);而 `sysroot = ""` 同时拿走头与库,没有「有头无库」这一档 | 不加该 feature —— 它会是**构造上恒不生效**的。独立包保持独立 | +| `nolibc` 也应做成 `std-freestanding` 的 feature(默认关),与 `alloc-*` 一致 | ⚠️ **我判断它「状态不可达」,而那个判断建立在一次测错的探针上**;正确测量后它**可达,但代价是一个独立项目**。见 §3.1 | 本轮不加;记为后续项 | ⇒ 十四条里有十三条**若不实测就会写进文档**。 @@ -169,6 +169,38 @@ README、索引描述符与 `docs/13`,之后才被实测否掉。推理链本身 参与链接),错在**没有把它与「C 库是归档」这一半合起来看**。 「结构上可能 ≠ 运行时确实」这一条,这次是在我自己身上生效的。 +### 3.1 ⭐ 零 libc 档上的标准库子集:一次测了四遍才对 + +问题是「`std-freestanding` 能不能在零 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.h` `stddef.h` + `stdarg.h` `limits.h` `float.h`);缺的只是真正的 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/c++/v1/string.h`), + 它靠 `#include_next` 到 C 库那份去取 `size_t`;没有 C 库时断链。 +- 加一份最小 `string.h` shim(⚠️ 必须放在 libc++ 之后,否则 `#include_next` 跳过它) + 解锁 `optional` 与 `coroutine`;`string_view` 还差 `mbstate_t`(``), + `atomic` 还差 `time_t`(经 `` 到 ``)。 + +⇒ **结论:可达,但它是一个独立项目而不是一个 feature。** 让 `std-freestanding` 在零 +libc 档可用,需要 `std-freestanding-nolibc` 从「五个函数」长成「五个函数 + libc++ +真正需要的那一小组 C 头」。⚠️ 一份写错的 `mbstate_t` 不会报错,只会让类型静默不匹配 —— +这类东西不能顺手做。 + --- ## 4. 兼容性与无感升级 From f2ea0b0b6ae271e6830e7727ec148adb0d581358 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:07:52 +0800 Subject: [PATCH 17/20] =?UTF-8?q?fix(manifest):=20=E6=8A=8A=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=A0=A1=E9=AA=8C=E5=8A=A9=E6=89=8B=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=8E=A5=E5=8F=A3=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=AE=83=E6=AF=92=E5=8C=96=E4=BA=86=E4=B8=8B=E6=B8=B8=E7=9A=84?= =?UTF-8?q?=20BMI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ **两个 Windows job 因此稳定变红,而报错点名的是我从未碰过的测试文件。** 助手原本位于模块 purview 的命名空间作用域,因此它的声明属于本模块接口记录的内容。 它的返回类型是 `std::optional`,而在 clang + MSVC 标准库下,这一点足以 让**每一个构造该类型的下游翻译单元**编不过: MSVC\include\optional:307: error: no matching constructor for initialization of '_SMF_control<_Optional_construct_base`, and under clang +// with the MSVC standard library that was enough to break every downstream +// translation unit that constructs one: +// +// MSVC\include\optional:307: error: no matching constructor for +// initialization of '_SMF_control<_Optional_construct_base version_req_problem(std::string_view spec) { return std::nullopt; } +} // namespace + export namespace mcpp::manifest { From 69a843e0363353682561c7c82cd54cf1e08605a9 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:22:16 +0800 Subject: [PATCH 18/20] =?UTF-8?q?feat(scaffold):=20=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E6=8B=92=E7=BB=9D=E8=87=AA=E4=BE=9D=E8=B5=96?= =?UTF-8?q?,=E5=9B=A0=E4=B8=BA=E6=9C=89=E4=B8=80=E4=B8=AA=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=BF=85=E9=A1=BB=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ **`mcpp new k --template riscv-virt-rt:nolibc` 之后 `mcpp run` 直接失败**, 而那个模板的全部意义就是不依赖任何东西。 自依赖注入的存在是为了让模板不与它所属的库脱节,这对几乎所有模板都对。它对 「主题就是不依赖任何东西」的模板是错的:板级包自己的模块 `#include `, 于是在一个**清单里没有任何依赖**的工程上,报错是 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` 那次同一个方法,也同一个教训: **「我这儿能跑」用的从来不是新用户的路径。** --- ...estanding-ecosystem-implementation-plan.md | 8 ++- src/scaffold/create.cppm | 14 +++-- src/scaffold/template.cppm | 21 +++++++ tests/unit/test_scaffold.cpp | 60 +++++++++++++++++++ 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md index 65196f39..2df7bd29 100644 --- a/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md +++ b/.agents/docs/2026-08-20-freestanding-ecosystem-implementation-plan.md @@ -135,7 +135,13 @@ capability 作仲裁,实现在别的包。 | `nolibc` 也应做成 `std-freestanding` 的 feature(默认关),与 `alloc-*` 一致 | ⚠️ **我判断它「状态不可达」,而那个判断建立在一次测错的探针上**;正确测量后它**可达,但代价是一个独立项目**。见 §3.1 | 本轮不加;记为后续项 | -⇒ 十四条里有十三条**若不实测就会写进文档**。 +| 零 libc 模板发布即可用 | ⚠️ **生成出来就是坏的。** `mcpp new --template` 强制注入自依赖,而板级包自己的模块 `#include ` ⇒ 那个从未被请求的依赖在工程本身之前就编不过 | 引擎加 `[template.inject] self = false`;板级包在零 libc 档改为**不贡献而非报错**,但**照常发 runner** | + +⇒ 十五条里有十四条**若不实测就会写进文档**。 + +⚠️ 最后这条是唯一一次**发布之后**才发现的:0.4.0 的模板在索引里可见、可生成, +而生成出来的工程跑不起来。发现它的方式是**用发布后的包从零走一遍新用户流程** —— +和上一轮 `.3` 那次同一个方法,也同一个教训:**「我这儿能跑」用的从来不是新用户的路径。** ⭐ 上面这条值得单独记:提法本身是对的(默认关的 feature 与主动加依赖等价,且能让包的 表面一致),**我原来反对它的理由也确实站不住**(中间库无论经 feature 还是经直接依赖 diff --git a/src/scaffold/create.cppm b/src/scaffold/create.cppm index e26dabb6..f83151ea 100644 --- a/src/scaffold/create.cppm +++ b/src/scaffold/create.cppm @@ -256,11 +256,15 @@ export int new_from_package_template( mcpp::ui::error(instantiated.error()); return 1; } - auto injected = mcpp::scaffold::inject_self_dependency( - root / "mcpp.toml", vars, chosen->meta.injectSelfFeatures); - if (!injected) { - mcpp::ui::error(injected.error()); - return 1; + // A template may decline the self-dependency — see TemplateMeta::injectSelf + // for the case that requires it. + if (chosen->meta.injectSelf) { + auto injected = mcpp::scaffold::inject_self_dependency( + root / "mcpp.toml", vars, chosen->meta.injectSelfFeatures); + if (!injected) { + mcpp::ui::error(injected.error()); + return 1; + } } // Parsing the completed manifest is the final semantic gate. Nothing is diff --git a/src/scaffold/template.cppm b/src/scaffold/template.cppm index 6534bfc1..09bfda2e 100644 --- a/src/scaffold/template.cppm +++ b/src/scaffold/template.cppm @@ -118,6 +118,21 @@ struct TemplateMeta { std::string postMessage; // [template.inject] self = { features = [...] } std::vector injectSelfFeatures; + // [template.inject] self = false + // + // ⚠️ A TEMPLATE THAT MUST NOT DEPEND ON ITS OWN PACKAGE IS NOT HYPOTHETICAL. + // + // The self-dependency exists so a template cannot drift from the library + // that ships it, and for almost every template that is right. It is wrong + // for one whose whole subject is depending on NOTHING: a bare-metal package + // may ship a kernel starting point for a target with no C library, and its + // own module includes — so injecting it makes the generated + // project fail to compile the dependency it never asked for. + // + // Measured: `mcpp new k --template riscv-virt-rt:nolibc` then `mcpp run` + // failed on the board package's own module, in a project whose manifest + // declares no dependencies at all. + bool injectSelf = true; }; std::expected @@ -142,6 +157,12 @@ load_meta(const std::filesystem::path& templateDir) { meta.isDefault = it->second.as_bool(); if (auto it = t->find("inject"); it != t->end() && it->second.is_table()) { auto& inj = it->second.as_table(); + // The bool form declines the injection outright; the table form + // keeps it and carries features. + if (auto self = inj.find("self"); + self != inj.end() && self->second.is_bool()) { + meta.injectSelf = self->second.as_bool(); + } if (auto self = inj.find("self"); self != inj.end() && self->second.is_table()) { auto& st = self->second.as_table(); diff --git a/tests/unit/test_scaffold.cpp b/tests/unit/test_scaffold.cpp index 1be17aef..0299c23a 100644 --- a/tests/unit/test_scaffold.cpp +++ b/tests/unit/test_scaffold.cpp @@ -388,3 +388,63 @@ widget = "1.0.0" } } // namespace + +// ── [template.inject] self = false ─────────────────────────────────────────── +// +// ⚠️ A TEMPLATE THAT MUST NOT DEPEND ON ITS OWN PACKAGE IS NOT HYPOTHETICAL. +// +// The self-dependency keeps a template from drifting from the library that +// ships it, and for almost every template that is right. It is wrong for one +// whose subject is depending on NOTHING: a bare-metal package may ship a kernel +// starting point for a target with no C library, while the package's own module +// includes . Measured: the injected dependency failed to compile +// before the generated project was reached, in a project whose manifest +// declares no dependencies at all. +TEST(ScaffoldTemplate, InjectSelfCanBeDeclined) { + auto dir = std::filesystem::temp_directory_path() / "mcpp_tmpl_noself"; + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + { + std::ofstream f(dir / "template.toml"); + f << "[template]\ndescription = \"d\"\n[template.inject]\nself = false\n"; + } + auto meta = mcpp::scaffold::load_meta(dir); + ASSERT_TRUE(meta.has_value()) << (meta ? "" : meta.error()); + EXPECT_FALSE(meta->injectSelf); + std::filesystem::remove_all(dir); +} + +// The default must stay ON: every template that does not say otherwise relies +// on the injection, and a silent flip would leave them all without their own +// package. +TEST(ScaffoldTemplate, InjectSelfDefaultsToOn) { + auto dir = std::filesystem::temp_directory_path() / "mcpp_tmpl_self_default"; + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + { + std::ofstream f(dir / "template.toml"); + f << "[template]\ndescription = \"d\"\n"; + } + auto meta = mcpp::scaffold::load_meta(dir); + ASSERT_TRUE(meta.has_value()) << (meta ? "" : meta.error()); + EXPECT_TRUE(meta->injectSelf); + std::filesystem::remove_all(dir); +} + +// The table form still carries features, and must not be read as "declined". +TEST(ScaffoldTemplate, InjectSelfTableFormStillInjects) { + auto dir = std::filesystem::temp_directory_path() / "mcpp_tmpl_self_table"; + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + { + std::ofstream f(dir / "template.toml"); + f << "[template]\ndescription = \"d\"\n" + "[template.inject]\nself = { features = [\"x\"] }\n"; + } + auto meta = mcpp::scaffold::load_meta(dir); + ASSERT_TRUE(meta.has_value()) << (meta ? "" : meta.error()); + EXPECT_TRUE(meta->injectSelf); + ASSERT_EQ(meta->injectSelfFeatures.size(), 1u); + EXPECT_EQ(meta->injectSelfFeatures[0], "x"); + std::filesystem::remove_all(dir); +} From aebf9eac27bdef4c598c056f0c3e9b969b94271a Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:40:54 +0800 Subject: [PATCH 19/20] =?UTF-8?q?fix(manifest):=20=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E9=87=8C=E7=9A=84=20optional=20?= =?UTF-8?q?=E4=BC=9A=E6=AF=92=E5=8C=96=E5=AF=BC=E5=85=A5=E8=80=85=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`。 `std::optional` 作为导出结构体的**数据成员**,会强制这个 模块的接口实例化该特化的特殊成员函数机制;clang 配 MSVC 标准库时, 这份机制毒化了每一个构造它的下游翻译单元。同一个类型此前已经作为 **返回类型**出现在这个模块里且相安无事——是成员位置逼出了实例化。 两个普通成员承载同样的信息,且不实例化任何东西。同理, `effective_sysroot` 的覆盖参数改为指针:三态语义不变,导出接口里 不再有该特化。 上一次把嫌疑判给了 `version_req_problem`(已移入匿名命名空间,那一步 本身是对的),但换成匿名命名空间后 Windows 仍以同样的报错失败——排除 了那条,才落到成员上。 本机是 GCC 配 libstdc++,复现不了这条路径;六个受影响的单测本机全绿, MSVC 侧由 CI 判定。 --- src/build/prepare.cppm | 6 +++--- src/manifest/toml.cppm | 1 + src/manifest/types.cppm | 32 +++++++++++++++++++++------- src/toolchain/triple.cppm | 8 +++++-- tests/unit/test_manifest.cpp | 10 ++++----- tests/unit/test_toolchain_triple.cpp | 15 +++++++++---- 6 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index be4eced0..d6bd7aa2 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -739,13 +739,13 @@ find_target_entry(const mcpp::manifest::Manifest& m, return nullptr; } -// The project's `[target.].sysroot`, or nullopt when it declared none. -std::optional +// The project's `[target.].sysroot`, or nullptr when it declared none. +const std::string* sysroot_override(const mcpp::manifest::Manifest& m, const mcpp::toolchain::triple::Triple& t) { auto* e = find_target_entry(m, t); - return e ? e->sysroot : std::nullopt; + return (e && e->sysrootDeclared) ? &e->sysroot : nullptr; } // The target-facing answers a `build.mcpp` may ask the engine for. diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index 9e97a592..336bbffc 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -1515,6 +1515,7 @@ std::expected parse_string(std::string_view content, "with no C library.", triple, s))); } e.sysroot = std::move(s); + e.sysrootDeclared = true; } // `runner` — the argv template `mcpp run` uses for a target whose diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index 33dbde7b..d4cac1a3 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -699,14 +699,30 @@ struct TargetEntry { // names the compiler the target resolves, the other names the C library, // and both were engine-only until a project had a reason to disagree. // - // ⚠️ `std::optional`, not `std::string`, because ABSENT and EMPTY are - // different answers. Absent inherits the target row. `sysroot = ""` is the - // ZERO-LIBC tier: no C library is resolved, no include or library path is - // added, and the link carries only what the project and its dependencies - // supply. A kernel or a bootloader wants exactly that, and with a plain - // string the two cases would be indistinguishable — the empty string is - // what a target row without a sysroot already looks like. - std::optional sysroot; + // ⚠️ TWO MEMBERS AND NOT AN `std::optional`, AND THE REASON IS + // NOT STYLE. + // + // ABSENT and EMPTY are different answers — absent inherits the target row, + // `sysroot = ""` is the ZERO-LIBC tier — so a plain string alone cannot + // carry the distinction. An optional can, and was the first version. + // + // But an `std::optional` DATA MEMBER of an exported struct + // forces this module's interface to materialise that specialisation's + // special-member machinery, and under clang with the MSVC standard library + // that broke every downstream translation unit constructing one: + // + // MSVC\include\optional:307: error: no matching constructor for + // initialization of '_SMF_control<_Optional_construct_base` + // already appeared in this module as a RETURN type without incident; a + // member is what forces the instantiation. + // + // Two plain members carry the same information and instantiate nothing. + std::string sysroot; + bool sysrootDeclared = false; // ⚠️ NO per-role field here. There used to be a `cxxRuntimeTests`, and it was // parsed nowhere and applied nowhere — a configuration key that looked // available and did nothing (#418). The per-target channel carries the diff --git a/src/toolchain/triple.cppm b/src/toolchain/triple.cppm index d8194b98..b845e5dd 100644 --- a/src/toolchain/triple.cppm +++ b/src/toolchain/triple.cppm @@ -163,7 +163,7 @@ inline bool is_known_target(const Triple& t) { return find_known_target(t) != nu // `override_` is the project's `[target.].sysroot`, and its optionality // is load-bearing: // -// nullopt -> the project said nothing; the target table's column applies +// nullptr -> the project said nothing; the target table's column applies // "xim:..." -> the project named a different C library // "" -> the project asked for NO C library (the zero-libc tier) // @@ -172,8 +172,12 @@ inline bool is_known_target(const Triple& t) { return find_known_target(t) != nu // "add no target sysroot paths". The tier therefore needs no new branch // anywhere downstream — it reuses the answer the engine already knew how to // handle. +// ⚠️ A POINTER AND NOT AN `std::optional`. The tri-state is the +// same — null means "the project said nothing" — and a pointer parameter +// instantiates nothing in this module's interface. See the note on +// `TargetEntry::sysroot` for what the optional cost when it reached one. inline std::string effective_sysroot(const Triple& t, - const std::optional& override_) + const std::string* override_) { if (override_) return *override_; if (auto* k = find_known_target(t)) return std::string(k->sysroot); diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 626bf1a7..d85821b9 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -3664,8 +3664,8 @@ sysroot = "xim:newlib-riscv@4.4" ASSERT_TRUE(m.has_value()) << m.error().format(); auto it = m->targetOverrides.find("riscv64-none-elf"); ASSERT_NE(it, m->targetOverrides.end()); - ASSERT_TRUE(it->second.sysroot.has_value()); - EXPECT_EQ(*it->second.sysroot, "xim:newlib-riscv@4.4"); + ASSERT_TRUE(it->second.sysrootDeclared); + EXPECT_EQ(it->second.sysroot, "xim:newlib-riscv@4.4"); EXPECT_TRUE(m->schemaWarnings.empty()) << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); } @@ -3684,8 +3684,8 @@ sysroot = "" ASSERT_TRUE(m.has_value()) << m.error().format(); auto it = m->targetOverrides.find("riscv64-none-elf"); ASSERT_NE(it, m->targetOverrides.end()); - ASSERT_TRUE(it->second.sysroot.has_value()); - EXPECT_TRUE(it->second.sysroot->empty()); + ASSERT_TRUE(it->second.sysrootDeclared); + EXPECT_TRUE(it->second.sysroot.empty()); } TEST(Manifest, TargetSysrootAbsentStaysNullopt) { @@ -3700,7 +3700,7 @@ linkage = "static" ASSERT_TRUE(m.has_value()) << m.error().format(); auto it = m->targetOverrides.find("riscv64-none-elf"); ASSERT_NE(it, m->targetOverrides.end()); - EXPECT_FALSE(it->second.sysroot.has_value()); + EXPECT_FALSE(it->second.sysrootDeclared); } // A bare name is the plausible typo, and accepting it would install nothing diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index 8dadc2cd..6d4d9318 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -216,6 +216,13 @@ TEST(Triple, BareMetalEabiSpellings) { } // ── effective_sysroot: the project's override, the target row otherwise ────── +// +// The override arrives as a POINTER — null means the project declared none. It +// was an `std::optional` until that parameter type, reaching an +// exported module interface, broke every importer under clang + MSVC STL. +const std::string kNewlib = "xim:newlib-riscv@4.4"; +const std::string kEmpty = ""; + // // ⚠️ The absent/empty distinction is the whole point of these three tests. A // plain `std::string` would make "the project said nothing" and "the project @@ -225,13 +232,13 @@ TEST(Triple, BareMetalEabiSpellings) { TEST(Triple, EffectiveSysrootFallsBackToTheTargetRow) { auto t = parse("riscv64-none-elf"); ASSERT_TRUE(t.has_value()); - EXPECT_EQ(effective_sysroot(*t, std::nullopt), "xim:picolibc-riscv@1.8.12"); + EXPECT_EQ(effective_sysroot(*t, nullptr), "xim:picolibc-riscv@1.8.12"); } TEST(Triple, EffectiveSysrootHonoursAnOverride) { auto t = parse("riscv64-none-elf"); ASSERT_TRUE(t.has_value()); - EXPECT_EQ(effective_sysroot(*t, std::optional{"xim:newlib-riscv@4.4"}), + EXPECT_EQ(effective_sysroot(*t, &kNewlib), "xim:newlib-riscv@4.4"); } @@ -241,11 +248,11 @@ TEST(Triple, EffectiveSysrootEmptyStringIsTheZeroLibcTier) { // stops resolving and a self-contained image links at 108 bytes. auto t = parse("riscv64-none-elf"); ASSERT_TRUE(t.has_value()); - EXPECT_EQ(effective_sysroot(*t, std::optional{""}), ""); + EXPECT_EQ(effective_sysroot(*t, &kEmpty), ""); } TEST(Triple, EffectiveSysrootIsEmptyForHostedTargets) { auto t = parse("x86_64-linux-musl"); ASSERT_TRUE(t.has_value()); - EXPECT_EQ(effective_sysroot(*t, std::nullopt), ""); + EXPECT_EQ(effective_sysroot(*t, nullptr), ""); } From 49e7683ee04f6c2211091822fd32cb21a3232804 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:43:50 +0800 Subject: [PATCH 20/20] =?UTF-8?q?fix(manifest):=20=E6=B6=88=E6=8E=89?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E4=B8=80=E5=A4=84=20optional,?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=B5=8C=E4=B8=80=E8=BD=AE=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一次把嫌疑判给 `version_req_problem` 并把它移进匿名命名空间, Windows 仍以同样的报错失败;那证明它不是唯一的因,却没有证明它无辜。 成员那一处已经修好,但两个候选一次只排除一个,每轮要四十分钟。 `version_req_problem` 现在返回 `std::string`,空串表示没有问题。调用侧 不需要区分「没有问题」与「问题是空串」,所以不损失任何东西,而这个模块 的新代码里再没有 `std::optional`——下一轮 CI 的结论因此 不含歧义。 --- src/manifest/toml.cppm | 22 ++++++++++++++-------- tests/unit/test_manifest.cpp | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index 336bbffc..256c19dd 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -16,7 +16,7 @@ import mcpp.platform; // // The first version of this helper sat at namespace scope in the module // purview, which makes its declaration part of what this module's interface -// records. Its return type is `std::optional`, and under clang +// records. Under clang // with the MSVC standard library that was enough to break every downstream // translation unit that constructs one: // @@ -65,10 +65,16 @@ namespace { // not invalidate a running program; equally, a new program must not invalidate // published data. A manifest check has no standing to do so over an entry that // may never be reached. -std::optional version_req_problem(std::string_view spec) { - if (spec.empty()) return std::nullopt; // path/git/workspace deps +// ⚠️ RETURNS A PLAIN STRING, EMPTY MEANING "NO PROBLEM", AND NOT AN +// `std::optional`. The optional was the obvious spelling and cost +// two rounds of Windows CI: see the note on `TargetEntry::sysroot` for what +// that specialisation does to importers under clang with the MSVC standard +// library. Nothing here needs to distinguish an absent problem from an empty +// one, so nothing is lost. +std::string version_req_problem(std::string_view spec) { + if (spec.empty()) return {}; // path/git/workspace deps if (auto r = mcpp::version_req::parse_req(spec); !r) return r.error(); - return std::nullopt; + return {}; } } // namespace @@ -697,14 +703,14 @@ std::expected parse_string(std::string_view content, if (auto it = sub.find("path"); it != sub.end() && it->second.is_string()) spec.path = it->second.as_string(); if (auto it = sub.find("version"); it != sub.end() && it->second.is_string()) { spec.version = it->second.as_string(); - if (auto why = version_req_problem(spec.version)) + if (auto why = version_req_problem(spec.version); !why.empty()) m.schemaWarnings.push_back(std::format( "[{}.\"{}\"] version = '{}' is not a requirement this " "resolver can match ({}). The fetch will fail naming the " "PACKAGE, which may well exist; it is this requirement that " "does not parse. Accepted: an exact version (\"1.2.3\") or " "a comparator (\"^1.2.3\", \">=1.0.0, <2.0.0\").", - section, fqName, spec.version, *why)); + section, fqName, spec.version, why)); } if (auto it = sub.find("git"); it != sub.end() && it->second.is_string()) spec.git = it->second.as_string(); if (auto it = sub.find("visibility"); it != sub.end() && it->second.is_string()) { @@ -819,14 +825,14 @@ std::expected parse_string(std::string_view content, auto key = selector.stableMapKey; if (value.is_string()) { spec.version = value.as_string(); - if (auto why = version_req_problem(spec.version)) + if (auto why = version_req_problem(spec.version); !why.empty()) m.schemaWarnings.push_back(std::format( "[{}] {} = '{}' is not a requirement this resolver can " "match ({}). The fetch will fail naming the PACKAGE, which " "may well exist; it is this requirement that does not " "parse. Accepted: an exact version (\"1.2.3\") or a " "comparator (\"^1.2.3\", \">=1.0.0, <2.0.0\").", - section, key, spec.version, *why)); + section, key, spec.version, why)); } else if (value.is_table()) { auto& sub = value.as_table(); if (!looks_like_inline_dep_spec(sub)) { diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index d85821b9..d7beadfd 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -3688,7 +3688,7 @@ sysroot = "" EXPECT_TRUE(it->second.sysroot.empty()); } -TEST(Manifest, TargetSysrootAbsentStaysNullopt) { +TEST(Manifest, TargetSysrootAbsentIsNotDeclared) { constexpr auto src = R"( [package] name = "x"