diff --git a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md index 1e0bc25b..037cc75b 100644 --- a/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md +++ b/.agents/docs/2026-08-19-baremetal-phase3-usable-plan.md @@ -470,3 +470,72 @@ manifest」的目录里,manifest 永远是最新的那个文件,快路径**永 **教训**:`mcpp run` 单独跑是对的,`mcpp build && mcpp run` 才错 —— **顺序本身就是被测 对象**。而 130/131/132 三个测试都恰好先 `run`,所以谁也看不见。 + +### 11.5 ⚠️ 我说 E-STD「要从头实现、不该在收尾时半做」—— 这条是错的,而且调研文档早就写着 + +用户指出后回查 `2026-08-18-freestanding-baremetal-analysis.md`,X1–X10 把这条路测到了底: + +| 我说的 | 文档实测(本轮已用**已发布载荷**复现) | +|---|---| +| `-nostdinc++` 之下 libc++ 的头一个都用不了 | 合成 freestanding `__config_site` 后 **103 / 110 可编**;失败的 7 个在 x86_64 宿主上**同样失败** ⇒ 裸机这层**编译期损失为 0** | +| 子集要从头实现 | libc++ 自己带 110 个 `std/
.inc`(每个就是该头的 `export namespace std {...}`)⇒ **子集是机械挑选**,生成物 217 行全是 `#include` | +| 是独立规模的工作 | T1+T2 当时就跑通了,只有 **T3**(`std::format`/标量 `sort`/完整 `string`)需要为目标编 `libc++.a` | + +⚠️ **`-nostdinc++` 恰恰是这条路的机制而不是障碍**:它让 libc++ 的头**私有**给子集包, +包再 export 一个模块 —— 和 BSP 私有 include 目标 libc 头是同一个形状。我把它读反了。 + +⭐ 复现时只差**一个符号**:`std::__libcpp_verbose_abort`。⚠️ 而且它必须**通过 libc++ +自己的头**声明 —— 真符号在 ABI 内联命名空间 `std::__1::` 里,手写 `namespace std {...}` +**编得过、链不上、报错一字不变**。 + +#### ⚠️ 真正的阻塞点是异常,而且它是「整图属性」 + +`optional::value()` 一个就拉进 `__cxa_throw` + `vtable for std::exception` 等 4 个符号。 +而 `-fno-exceptions`: + +- 写在工程 `[build] cxxflags` 里**没用** —— 到不了依赖的模块编译,于是 BMI 与导入者不一致, + clang 报的是 **`.pcm` configuration mismatch**,点名一个模块文件而不是那个 flag。 +- `is_dialect_flag`(整图传播的那张表)**刻意排除了它**,理由写着「依赖可能假设异常可用」—— + **这条在 hosted 成立,在裸机上正好反过来**:没有 unwinder,谁都用不了。 + +⇒ 放进**引擎的 freestanding target flags**(和 `-ffreestanding`/`-nostdinc++` 同处), +那是唯一能保证整图一致的地方。 + +#### ⚠️ 它顺带暴露了一个「升级即坏」的缓存缺陷 + +依赖缓存键有 triple,**没有 triple 隐含的那组 flag**。而**哪些 flag 由 triple 隐含是 mcpp 的决定**, +会随版本变、triple 字符串不变 ⇒ 升级后复用了升级前的 BMI,硬失败,错误只点名一个 `.pcm`。 +已加 `targetImpliedFlags` 轴(hosted 为空,不动任何现有键)。 + +### 11.6 ⭐ 用户指出的架构错误:目标的 libc 不该由包声明 + +我把 `xim:picolibc-riscv@1.8.12` 写进了 BSP 和 std 子集两个包的 `[xlings] deps`。 +用户指出三条,**三条都对**:不该绑 libc、不该绑 riscv、不该绑编译器;而且 freestanding +在普通宿主上一样能用(实测:`-ffreestanding` + 宿主 libc,同一份代码编得过)。 + +⚠️ **真因是引擎的一个结构性缺口**,不是包写得随意: + +| | 编译器 | 目标 libc | +|---|---|---| +| hosted(`x86_64-linux-musl`) | 目标表 `pin` **自动** | musl 在 gcc 载荷里 / glibc 走 `PayloadPaths` —— **自动**,没人写过 `xim:glibc` | +| freestanding | 目标表 `pin = llvm@22.1.8` **自动** | **没有任何一条轴** ⇒ 外溢到每个包 | + +⇒ **给 `TargetInfo` 加 `sysroot` 轴**(和 `pin` 并列),复用现有 `[xlings] deps` 物化通道 +安装,引擎把 `-isystem /include/<档位>` 和 `-L /lib/<档位>` 放上去。 + +**位置是目标的事实,选择是板级的事实**:引擎给位置,BSP 用**裸名**选 `-lcrt0-semihost` +`-lc` `-lsemihost` 并指定链接脚本。 + +同时补两个「问引擎」的接口:`mcpp::toolchain_dir()` 与 `mcpp::sysroot_dir()` +⇒ **两个包的 `[xlings] deps` 里 libc 与编译器全部消失**,std 子集变成与架构/libc/实现无关。 + +#### 实施中撞到的三条 + +1. ⚠️ **`-L` 加到 `f.ld` 上会被丢掉** —— freestanding **整条替换**链接线(否则载荷 cfg + 注入宿主 dynamic linker),所以必须放进 `LinkInputs`。实测:flag 拼出来了,然后不在。 +2. ⚠️ **`link-script` 的裸名会被按包根绝对化** ⇒ `picolibcpp.ld` 指到包目录里。板级包要 + 用 `sysroot_dir()` 自己拼 —— 它知道**要哪个脚本**,不知道**在哪**。 +3. ⚠️ **e2e/131 的私有性断言判据失效了**:它用 `#include ` 编不过来证明依赖的 + `include-dir` 不到达消费者。libc 归目标之后 `` **本来就该编得过**(和宿主一样)。 + ⇒ 拆成两条:目标 C 头**必须**到达(正面断言),板级包**自己的**头**必须不**到达。 + **原来那条测的其实是「libc 从哪来」,不是「作用域对不对」。** diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index fb5ede2a..6b443920 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -174,7 +174,8 @@ jobs: # PASS line or it does not. for t in tests/e2e/130_freestanding_riscv_build_and_run.sh \ tests/e2e/131_freestanding_bsp_supplies_everything.sh \ - tests/e2e/132_freestanding_test_and_artifacts.sh; do + tests/e2e/132_freestanding_test_and_artifacts.sh \ + tests/e2e/133_freestanding_std_subset.sh; do echo "=== $t ===" bash "$t" 2>&1 | tee "$(basename "$t").log" rc=${PIPESTATUS[0]} @@ -192,6 +193,9 @@ jobs: grep -q 'PASS: bare-metal mcpp test names its failure' \ 132_freestanding_test_and_artifacts.sh.log || { echo "132 (test + artifacts) skipped on the runner that must run it"; exit 1; } + grep -q 'PASS: the freestanding std subset' \ + 133_freestanding_std_subset.sh.log || { + echo "133 (std subset) skipped on the runner that must run it"; exit 1; } # ────────────────────────────────────────────────────────────────── # Hermetic (no host toolchain): the ONLY environment class that diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 442508a9..925c6a26 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -931,7 +931,9 @@ that has none. |---|---| | Link line | `-nostdlib -nostartfiles -static`, and nothing hosted — no crt files, no dynamic linker, no C++ runtime. The linker is addressed by **absolute path** (`-fuse-ld=/bin/ld.lld`), because `-fuse-ld=lld` resolves through `PATH` and finds GNU ld on any machine with binutils earlier on it. | | ISA flags | `-march` / `-mabi` / `-mcmodel` come from the target table, so `--target ` alone is enough to produce a correct object file. | -| `import std` | **Unavailable.** `std` is one module over the entire library — threads, filesystem and iostreams included — so there is no subset of it to build without an OS. What a firmware imports instead is the module its **board package** exports, which is where the target's C library is already wrapped. | +| C library | **The target's**, resolved by mcpp from the target's own row exactly as the compiler is — a bare-metal project declares no libc, just as a hosted one declares no glibc. Its headers reach every translation unit and its directory is on the link search path, so a board package selects out of it by bare name (`-lc`, `-lcrt0-semihost`). *Which* objects and *which* linker script remain board decisions. | +| Exceptions and RTTI | **Off**, on every translation unit including a dependency's. There is no unwinder and no `libc++abi`, so nothing can throw; `std::optional::value()` alone would otherwise pull in `__cxa_throw` and three more undefined symbols. It belongs to the target rather than to a project's `cxxflags` because a BMI records it — a dependency compiled with exceptions cannot be imported by a unit without them. | +| `import std` | **Unavailable.** `std` is one module over the entire library — threads, filesystem and iostreams included — so there is no subset of it to build without an OS. Two ordinary dependencies replace it: the **board package** wraps the target's C library, and **`std-freestanding`** carries the parts of the standard library that need no OS (103 of libc++'s 110 headers, measured). | | Entry point | `int main()` works **as long as something supplies a `crt0`** — a board package normally does, and then a firmware's entry point is an ordinary `main` whose return value reaches the host through semihosting. Only a zero-libc board needs an explicit target whose `main` points at the file carrying `_start`. | **A minimal firmware** diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index e29e841f..6c6989c2 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -133,6 +133,28 @@ both claiming to know how to run the artifact is a configuration error, and mcpp reports it naming both rather than merging them into an argv that is neither one's. +### Asking instead of declaring: `toolchain_dir` / `sysroot_dir` (2026.8.19.4+) + +```cpp +const char* tc = mcpp::toolchain_dir(); // the resolved toolchain's payload root +const char* sr = mcpp::sysroot_dir(); // the TARGET's C library root, or "" +``` + +A package that needs headers shipped by the toolchain — libc++'s, for a +freestanding standard-library subset — or a file inside the target's C library +— a linker script, for a board-support package — asks for the directory rather +than declaring a dependency on the thing that provides it. + +⚠️ The difference is not cosmetic. Declaring `xim:llvm` pins a package to one +standard-library implementation; declaring `xim:picolibc-riscv@1.8.12` pins it +to one C library, one architecture and one version. Neither is a property of a +package whose content is implementation-neutral. Asking follows whatever +`[toolchain]` and `--target` actually resolved. + +`sysroot_dir()` is empty on a hosted target: there the C library arrives with +the compiler payload or through the runtime binding, and nothing has to look +for it. + ### Finding an `[xlings] deps` payload: `xpkg_dir` (2026.8.19+) `dep_dir` answers for **mcpp** dependencies. An xlings package is a different diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 1109dcf2..31bae2da 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -827,7 +827,9 @@ cd blinky && mcpp run |---|---| | 链接线 | `-nostdlib -nostartfiles -static`,且不带任何 hosted 的东西 —— 没有 crt 文件、没有动态链接器、没有 C++ 运行时。链接器用**绝对路径**寻址(`-fuse-ld=<载荷>/bin/ld.lld`),因为 `-fuse-ld=lld` 走 `PATH` 解析,在任何 binutils 排前面的机器上都会找到 GNU ld。 | | ISA flag | `-march` / `-mabi` / `-mcmodel` 来自 target 表,所以只写 `--target ` 就足以产出正确的目标文件。 | -| `import std` | **不可用。** `std` 是覆盖整个库的一个模块 —— 线程、文件系统、iostreams 全在内 —— 没有 OS 就没有它的子集可编。固件真正 import 的是**板级包导出的模块**,目标的 C 库已经在那里包好了。 | +| C 库 | **属于 target**,由 mcpp 从目标自己那一行解析,和解析编译器同理 —— 裸机工程不声明 libc,正如宿主工程不声明 glibc。它的头进入每一个翻译单元,它的目录进入链接搜索路径,所以板级包用**裸名**选库(`-lc`、`-lcrt0-semihost`)。**选哪个**启动对象、**用哪份**链接脚本仍然是板级决定。 | +| 异常与 RTTI | **关闭**,作用于每一个翻译单元,依赖的也不例外。没有 unwinder、没有 `libc++abi`,谁都抛不了;否则光是 `std::optional::value()` 就会拉进 `__cxa_throw` 等四个未定义符号。它属于 **target** 而不是工程的 `cxxflags`,因为 **BMI 会记录这个配置** —— 带异常编出来的依赖,不带异常的单元 import 不进来。 | +| `import std` | **不可用。** `std` 是覆盖整个库的一个模块 —— 线程、文件系统、iostreams 全在内 —— 没有 OS 就没有它的子集可编。取代它的是两个普通依赖:**板级包**包住目标的 C 库,**`std-freestanding`** 提供标准库里不需要 OS 的那部分(实测 libc++ 110 个头里的 103 个)。 | | 入口点 | **只要有人提供 `crt0`,`int main()` 就能用** —— 板级支持包通常就提供它,于是固件的入口就是普通的 `main`,它的返回值经 semihosting 传回宿主。**只有零 libc 的板子**才需要显式声明 target 并把 `main` 指向携带 `_start` 的那个文件。 | **一个最小固件** diff --git a/docs/zh/07-build-mcpp.md b/docs/zh/07-build-mcpp.md index 8aa8c5bf..3fdf173a 100644 --- a/docs/zh/07-build-mcpp.md +++ b/docs/zh/07-build-mcpp.md @@ -121,6 +121,24 @@ for (auto a : {"-machine","virt","-nographic","-no-reboot","-kernel"}) ⚠️ **只能有一个依赖提供 runner。** 两个板级支持包都声称知道怎么跑这个产物是配置 错误;mcpp 会**同时点名两个**并报错,而不是把它们并成一个谁也不是的 argv。 +### 问,而不是声明:`toolchain_dir` / `sysroot_dir`(2026.8.19.4+) + +```cpp +const char* tc = mcpp::toolchain_dir(); // 已解析工具链的载荷根目录 +const char* sr = mcpp::sysroot_dir(); // 目标的 C 库根目录,没有则为 "" +``` + +一个包需要工具链自带的头(比如 freestanding 标准库子集要的 libc++ 头),或者需要 +目标 C 库里的某个**文件**(比如板级支持包要的链接脚本)时,应当**问这个目录在哪**, +而不是去声明一个依赖来把它拽进来。 + +⚠️ 这不是写法差异。声明 `xim:llvm` 会把包**钉死在一个标准库实现**上;声明 +`xim:picolibc-riscv@1.8.12` 会把它钉死在**一个 C 库、一种架构、一个版本**上。而这些 +都不是一个内容全是标准规定的名字的包的属性。**问**则会跟随 `[toolchain]` 与 +`--target` 真正解析到的结果。 + +宿主目标上 `sysroot_dir()` 为空:那里 C 库随编译器载荷或运行时绑定而来,没人需要找它。 + ### 找到 `[xlings] deps` 的载荷:`xpkg_dir`(2026.8.19+) `dep_dir` 回答的是 **mcpp** 依赖。xlings 包是另一个命名空间、另一套 store 布局, diff --git a/mcpp.toml b/mcpp.toml index 7dc6d173..8f51eeeb 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.19.3" +version = "2026.8.19.4" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index ac61bbdc..31c715e6 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -38,6 +38,11 @@ export namespace mcpp::build { // target/profile/feature change re-runs the program. struct BuildProgramEnv { std::string targetTriple; // resolved canonical triple; "" = host + // The resolved toolchain's payload root and the target's own C library + // root. Both exist so a package can ASK instead of DECLARE — see + // hostprogram::toolchain_dir / sysroot_dir for why declaring was wrong. + std::string toolchainDir; + std::string targetSysroot; 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 @@ -295,6 +300,12 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv e.emplace_back("MCPP_TARGET_ENV", t.env); } e.emplace_back("MCPP_HOST", hostT); + // Always emitted, empty when they do not apply: a build program reads + // these through `env_or`, which cannot tell "absent" from "empty", and an + // absent variable would make the answer depend on whatever the parent + // process happened to export. + e.emplace_back("MCPP_TOOLCHAIN_DIR", env.toolchainDir); + e.emplace_back("MCPP_TARGET_SYSROOT", env.targetSysroot); 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/cache_key.cppm b/src/build/cache_key.cppm index 240aa827..0efc3489 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -56,6 +56,8 @@ import mcpp.manifest; import mcpp.modgraph.scanner; import mcpp.toolchain.detect; import mcpp.toolchain.fingerprint; +import mcpp.toolchain.triple; +import mcpp.freestanding.target; // the flags a bare-metal triple implies export namespace mcpp::build::cache_key { @@ -79,6 +81,22 @@ struct BuildAxes { std::string compilerVersion; std::string driverIdentity; std::string targetTriple; + // ⚠️ The flags the TRIPLE implies, not the ones anyone wrote down. + // + // A freestanding triple silently carries `-march`/`-mabi`/`-mcmodel`/ + // `-ffreestanding`/`-nostdinc++`/`-fno-exceptions`/`-fno-rtti`, and which + // ones is a decision MCPP MAKES — so it changes between mcpp versions + // while the triple string does not. Without this axis the key cannot tell + // the two apart, and an upgrade that adds a flag serves BMIs built before + // it. That is not a stale-cache annoyance; it is a hard failure whose + // message names a .pcm file: + // + // error: exception handling was enabled in precompiled file + // 'mcpplibs.riscv_virt_rt.pcm' but is currently disabled + // + // Measured on exactly that upgrade. Empty for hosted targets, so nothing + // else's key moves. + std::vector targetImpliedFlags; std::string stdlibId; std::string stdlibVersion; // B @@ -198,6 +216,7 @@ nlohmann::json to_json(const BuildAxes& b, const PackageAxes& p) { {"compiler_version", b.compilerVersion}, {"driver_identity", b.driverIdentity}, {"target_triple", b.targetTriple}, + {"target_implied_flags", b.targetImpliedFlags}, {"stdlib", b.stdlibId}, {"stdlib_version", b.stdlibVersion}, }; @@ -244,6 +263,7 @@ std::string key_hex(const BuildAxes& b, const PackageAxes& p) { put(s, "ccver", b.compilerVersion); put(s, "driver", b.driverIdentity); put(s, "triple", b.targetTriple); + put_list(s, "targetflags", b.targetImpliedFlags); put(s, "stdlib", b.stdlibId); put(s, "stdlibv", b.stdlibVersion); // B @@ -295,6 +315,9 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, : (tc.binaryPath.empty() ? std::string{} : mcpp::toolchain::hash_file(tc.binaryPath)); b.targetTriple = tc.targetTriple; + if (auto ft = mcpp::toolchain::triple::parse(tc.targetTriple)) + if (auto spec = mcpp::freestanding::resolve(*ft)) + b.targetImpliedFlags = mcpp::freestanding::compile_flags(*spec); b.stdlibId = tc.stdlibId; b.stdlibVersion = tc.stdlibVersion; diff --git a/src/build/flags.cppm b/src/build/flags.cppm index a5dc5207..15d24c04 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -1285,12 +1285,38 @@ CompileFlags compute_flags(const BuildPlan& plan) { f.cc += prefix; f.as += mcpp::freestanding::assemble_prefix(*spec); + // ── The target's C library, from the TARGET, not from a package ── + // + // A hosted target never made anyone write this down: musl rides + // inside its gcc payload and glibc arrives through PayloadPaths. + // Bare metal pins a generic clang, which carries no target libc, + // and until this the gap leaked outward — every board-support + // package and every library had to declare + // `[xlings] deps = ["xim:picolibc-riscv@1.8.12"]`, which bound + // each of them to one libc, one ISA and one version of both. + // + // What the engine supplies is exactly the libc's LOCATION. Which + // objects to start with, which linker script, which libraries — + // `-lcrt0-semihost` versus a UART crt0 — remain board decisions + // and stay in the board package. Location is a target fact; + // selection is a board fact. + // Compile side: the target's C headers. Link side is NOT here — + // it goes into LinkInputs below, because the freestanding link + // line is replaced wholesale and anything added to f.ld here is + // discarded. + if (!plan.toolchain.targetSysrootInclude.empty()) { + const auto flag = + " -isystem " + ninjaEsc(plan.toolchain.targetSysrootInclude); + f.cxx += flag; f.cc += flag; f.as += flag; + } + mcpp::freestanding::LinkInputs in; // Absolute path, not `-fuse-ld=lld`: the name resolves through // PATH and finds GNU ld on any machine with binutils earlier on // it, which then dies with 'unrecognised emulation mode'. // Reproduced on this toolchain 2026-08-19. in.lld = mcpp::freestanding::resolve_lld(plan.toolchain.binaryPath); + in.sysrootLib = plan.toolchain.targetSysrootLib; // ⚠️ `--no-default-config` FIRST, and it is not hygiene. // // The llvm payload ships bin/clang++.cfg with an unconditional diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 7f35b42a..7f8a69a9 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -182,6 +182,27 @@ inline const char* target_env() { return env_or("MCPP_TARGET_E inline const char* host() { return env_or("MCPP_HOST"); } inline const char* profile() { return env_or("MCPP_PROFILE"); } inline const char* out_dir() { return env_or("MCPP_OUT_DIR"); } + +// Where the TOOLCHAIN mcpp resolved for this build lives — the payload root, +// the directory whose `bin/` holds the driver. +// +// ⚠️ This exists so a package never has to DECLARE a toolchain. A package that +// needs headers the toolchain ships (libc++'s, for a freestanding standard +// library subset) previously had to put `xim:llvm` in `[xlings] deps`, which +// pinned it to one implementation — and the measured fact is that the same +// subset works over libstdc++'s freestanding mode too, so pinning was not +// merely inelegant, it closed a road. Asking here follows whatever +// `[toolchain]` actually resolved. +inline const char* toolchain_dir() { return env_or("MCPP_TOOLCHAIN_DIR"); } + +// Where the TARGET's C library lives, for targets that have one of their own +// (today: bare metal). Same argument one line up: the libc is a property of +// the target, mcpp resolves it from the target's own row, and a package that +// needs to name a FILE inside it (a linker script) asks rather than declares. +// +// 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"); } 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 84fd71e2..95c552ef 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -35,6 +35,7 @@ import mcpp.toolchain.fingerprint; import mcpp.toolchain.msvc; import mcpp.toolchain.registry; import mcpp.toolchain.stdmod; +import mcpp.freestanding.target; // the target sysroot layout (libdir) import mcpp.toolchain.post_install; import mcpp.toolchain.abi; import mcpp.toolchain.triple; @@ -1768,6 +1769,42 @@ prepare_build(bool print_fingerprint, tc->hasImportStd = false; tc->stdModuleSource.clear(); tc->stdCompatSource.clear(); + + // ── The target's C library, resolved like its compiler ───────── + // + // The row in kKnownTargets names it, exactly as it names the + // toolchain pin, and it is installed through the same channel a + // project's `[xlings] deps` use (see the materialization above). + // Resolved HERE because the config is already open; the flag + // builder only reads the result. + // + // Absent is not an error at this point: the install happens + // earlier in this function and may legitimately not have run yet + // 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 (auto cfg3 = get_cfg(); cfg3) { + auto ref = mcpp::xlings::paths::parse_xpkg_ref( + std::string(known->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)) { + const auto inc = + *dir / "include" / std::string(spec->libdir); + const auto lib = + *dir / "lib" / std::string(spec->libdir); + std::error_code ec2; + tc->targetSysrootRoot = *dir; + if (std::filesystem::is_directory(inc, ec2)) + tc->targetSysrootInclude = inc; + if (std::filesystem::is_directory(lib, ec2)) + tc->targetSysrootLib = lib; + } + } + } + } } } @@ -2054,14 +2091,40 @@ prepare_build(bool print_fingerprint, // custom non-builtin index entries (so xlings can clone them) plus the // [xlings] deps/workspace/subos/envs materialized verbatim. const auto& runtimeOwnerManifest = wsManifest ? *wsManifest : *m; + // The TARGET's C library, if this target has one. Resolved here and not by + // any package, for the same reason the compiler pin is: it is a property + // of the target. + // + // ⚠️ It rides the SAME channel as `[xlings] deps` rather than getting an + // install path of its own — one materialization, one place that can be + // wrong. What it must NOT do is depend on the project having an `[xlings]` + // section: a bare-metal project written to the template has none, and the + // whole point is that it never mentions a libc. + 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); + } const bool materializeRootRuntime = - !overrides.inherited_runtime_binding && !runtimeOwnerManifest.xlings.empty(); + !overrides.inherited_runtime_binding + && (!runtimeOwnerManifest.xlings.empty() || !targetSysroot.empty()); if (!m->indices.empty() || materializeRootRuntime) { auto cfg2 = get_cfg(); if (cfg2) { mcpp::xlings::ProjectEnv penv; if (materializeRootRuntime) { penv.deps = runtimeOwnerManifest.xlings.deps; + // Appended, never substituted: a project may legitimately + // declare other xim packages, and a target sysroot is one more + // entry rather than a replacement for the list. Deduplicated + // because a manifest written before this axis existed still + // names it, and declaring it twice is not an error the author + // should have to hear about. + if (!targetSysroot.empty() + && std::ranges::find(penv.deps, targetSysroot) == penv.deps.end()) + penv.deps.push_back(targetSysroot); penv.subos = runtimeOwnerManifest.xlings.subos; for (auto const& [k, v] : runtimeOwnerManifest.xlings.workspace) penv.workspace.emplace_back(k, v); @@ -4937,6 +5000,13 @@ 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{}; bpEnv.profile = effectiveProfile; bpEnv.features = feature_closure(pkg.manifest, req, depDefaultFeatures); bpEnv.artifactsDir = workRoot / "target" / ".build-mcpp" / "deps" @@ -5097,6 +5167,13 @@ 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{}; bpEnv.profile = effectiveProfile; // Set explicitly rather than relying on build_dir()'s root-relative // default: under BuildOverrides::work_dir the package root is shared @@ -5273,18 +5350,15 @@ prepare_build(bool print_fingerprint, // no subset of it to build without an OS. Saying "provides no std // module source" sends the reader to look for a broken payload. // - // ⚠️ It used to end with a copy-pasteable - // - // [dependencies] - // mcpplibs.std.freestanding = "0.1" - // - // and that package is NOT published. A diagnostic whose suggested fix - // fails at the next command is worse than one that explains and stops: - // the reader spends the next minutes deciding whether their index is - // broken. Point at what a bare-metal project actually has today — the - // board package it already depends on exports a module — and describe - // the subset package as a shape rather than as a line to paste. - // Restore the concrete line when such a package ships. + // ⚠️ The line below is copy-pasteable, and that is a PROMISE: it has + // to resolve today. It briefly did not — an earlier version of this + // message named `mcpplibs.std.freestanding` before any such package + // existed, so following the advice failed at the very next command + // with "package not found" and sent the reader off to debug their + // index. The package is published now (103 of libc++'s 110 headers, + // 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. if (auto ft = mcpp::toolchain::triple::parse(tc->targetTriple); ft && ft->is_freestanding()) { @@ -5295,19 +5369,20 @@ prepare_build(bool print_fingerprint, "filesystem, iostreams\n" " included), so there is no subset of it to build without " "an OS underneath.\n" + " Use the freestanding subset instead — an ordinary " + "dependency carrying\n" + " the parts of the library that need no OS " + "(array, span, optional, atomic,\n" + " string_view, ranges, expected, charconv, coroutines):\n" "\n" - " What a bare-metal project uses instead:\n" - " * the module its BOARD package exports — that is where " - "the target's\n" - " C library is already wrapped (riscv-virt-rt exports " - "`mcpplibs.riscv_virt_rt`);\n" - " * or a freestanding subset package, which is an " - "ordinary dependency\n" - " providing the header-only parts of the library that " - "need no OS.\n" + " [dependencies]\n" + " std-freestanding = \"0.1.0\"\n" "\n" - " No such subset package is published yet, so there is no " - "line to paste here.", + " then `import mcpplibs.std.freestanding;` in place of " + "`import std;`.\n" + " The target's C library itself comes from the BOARD " + "package (riscv-virt-rt\n" + " exports `mcpplibs.riscv_virt_rt`).", tc->targetTriple)); } return std::unexpected(std::format( diff --git a/src/freestanding/linkline.cppm b/src/freestanding/linkline.cppm index 2e3be178..23aee76e 100644 --- a/src/freestanding/linkline.cppm +++ b/src/freestanding/linkline.cppm @@ -66,6 +66,11 @@ inline std::string assemble_prefix(const Spec& s) { return compile_prefix(s); } struct LinkInputs { std::filesystem::path lld; // absolute path to ld.lld std::filesystem::path linkerScript; // "" = none (BSP-supplied when present) + // Where the TARGET's C library lives. Supplied by the engine from the + // target's own row, so a board package selects libraries by bare name + // (`-lc`, `-lcrt0-semihost`) without knowing which libc it is or where it + // landed. "" = this target has no sysroot of its own. + std::filesystem::path sysrootLib; std::vector prologue; // before the user's objects std::vector epilogue; // after them }; @@ -80,6 +85,13 @@ inline std::string link_flags(const Spec& s, const LinkInputs& in, out += " -nostdlib -nostartfiles -static"; if (!in.lld.empty()) out += " -fuse-ld=" + esc(in.lld); + // ⚠️ BEFORE the libraries the board selects, and it has to be on THIS line + // rather than in the generic ld flags: a freestanding link line is + // REPLACED wholesale (the payload cfg would otherwise inject a host + // dynamic linker), so anything appended to the ordinary ldflags earlier is + // discarded. Measured — the -L was built, and then silently was not there. + if (!in.sysrootLib.empty()) + out += " -L" + esc(in.sysrootLib); if (!in.linkerScript.empty()) out += " -T " + esc(in.linkerScript); return out; diff --git a/src/freestanding/target.cppm b/src/freestanding/target.cppm index efc02208..6b0ca965 100644 --- a/src/freestanding/target.cppm +++ b/src/freestanding/target.cppm @@ -101,6 +101,33 @@ inline std::vector compile_flags(const Spec& s) { // mcpp.build.directives), so a libc wrapper includes the target headers // privately and exports what it wants seen. out.emplace_back("-nostdinc++"); + // ⚠️ Exceptions and RTTI off, and this belongs HERE — with the target — for + // the same reason `-ffreestanding` does: it is a property every TU in the + // graph must agree on. + // + // manifest/types.cppm's `is_dialect_flag` deliberately does NOT propagate + // `-fno-exceptions` graph-wide, on the grounds that "dependencies may + // assume exceptions are available". That is right for a hosted target and + // exactly backwards here: a freestanding target has no unwinder and no + // libc++abi, so nothing CAN throw — `optional::value()` alone drags in + // `__cxa_throw`, `vtable for std::exception` and two more, and the link + // fails with no hint that exceptions were the cause. + // + // Leaving it to the project's own `cxxflags` does not work, and the way it + // fails is worse than not linking. Measured: those flags reach the root's + // TUs but not a dependency's module compile, so the BMI and the importer + // disagree and clang says + // + // error: exception handling was enabled in precompiled file + // 'mcpplibs.riscv_virt_rt.pcm' but is currently disabled + // + // which names a module file rather than the setting that split the graph. + // + // Not a preference, then, but not permanent either: a board that ships a + // target-built libc++abi and unwinder has a real case for turning these + // back on, and that is the point at which this becomes a manifest key. + out.emplace_back("-fno-exceptions"); + out.emplace_back("-fno-rtti"); return out; } diff --git a/src/toolchain/model.cppm b/src/toolchain/model.cppm index e6660064..7600ac03 100644 --- a/src/toolchain/model.cppm +++ b/src/toolchain/model.cppm @@ -83,6 +83,18 @@ struct Toolchain { std::filesystem::path stdCompatSource; // bits/std_compat.cc / std.compat.cppm std::filesystem::path sysroot; // -print-sysroot output (or empty) std::optional payloadPaths; // fine-grained sysroot from xpkgs + // The TARGET's C library, for targets whose row in kKnownTargets names one + // (today: bare metal). Resolved during prepare, where the config is + // already open, rather than in the flag builder — computing it there would + // put a config load on a path that runs per build and answers a question + // prepare has already answered. + // + // Empty for every hosted target: they get their libc from the compiler + // payload or from PayloadPaths, which is why nobody ever had to write one + // down for them. + std::filesystem::path targetSysrootRoot; + std::filesystem::path targetSysrootInclude; + std::filesystem::path targetSysrootLib; std::vector compilerRuntimeDirs; // LD_LIBRARY_PATH for private tools std::vector linkRuntimeDirs; // -L/-rpath dirs for produced binaries // Environment the toolchain's tools need when invoked (set on the ninja diff --git a/src/toolchain/triple.cppm b/src/toolchain/triple.cppm index a8f02f54..862b27e2 100644 --- a/src/toolchain/triple.cppm +++ b/src/toolchain/triple.cppm @@ -98,30 +98,46 @@ struct TargetInfo { // Convention toolchain pin for `--target ` with no explicit // [target.X] toolchain override. Empty = no convention (host default). std::string_view pin; + // The TARGET's C library, resolved at compile time exactly the way `pin` + // resolves the compiler. Empty = none applies. + // + // ⚠️ This axis exists because bare metal was the one target class without + // it, and the gap leaked into every package. A hosted target gets its libc + // automatically — `x86_64-linux-musl` carries musl inside its gcc payload, + // and glibc arrives through PayloadPaths — so nobody writes `xim:glibc` in + // a manifest. Freestanding pins a generic clang, which brings no target + // libc at all, so before this every bare-metal package had to declare + // `[xlings] deps = ["xim:picolibc-riscv@1.8.12"]` itself. That is not a + // dependency of the package; it is a property of the target, and stating + // it per-package bound a board-support package and a standard-library + // subset alike to one libc, one ISA and one version. + std::string_view sysroot; bool defaultStatic; // target's default linkage is static }; // (note deliberately excludes "static" — the display layer derives that tag // from defaultStatic, so listing it here would duplicate it.) inline constexpr TargetInfo kKnownTargets[] = { - // canonical tier note pin defaultStatic - { "x86_64-linux-gnu", "verified", "", "", false }, - { "x86_64-linux-musl", "verified", "", "gcc@16.1.0", true }, - { "aarch64-linux-musl", "verified", "", "gcc@16.1.0", true }, - { "x86_64-windows-gnu", "verified", "PE", "gcc@16.1.0", true }, - { "x86_64-windows-msvc", "verified", "PE", "", false }, - { "aarch64-macos", "verified", "", "", false }, - { "riscv64-linux-musl", "planned", "", "", true }, - { "aarch64-linux-gnu", "planned", "", "", false }, - { "x86_64-macos", "planned", "", "", false }, + // canonical tier note pin sysroot defaultStatic + { "x86_64-linux-gnu", "verified", "", "", "", false }, + { "x86_64-linux-musl", "verified", "", "gcc@16.1.0", "", true }, + { "aarch64-linux-musl", "verified", "", "gcc@16.1.0", "", true }, + { "x86_64-windows-gnu", "verified", "PE", "gcc@16.1.0", "", true }, + { "x86_64-windows-msvc", "verified", "PE", "", "", false }, + { "aarch64-macos", "verified", "", "", "", false }, + { "riscv64-linux-musl", "planned", "", "", "", true }, + { "aarch64-linux-gnu", "planned", "", "", "", false }, + { "x86_64-macos", "planned", "", "", "", false }, // Bare metal. `defaultStatic` is not a preference here — there is no // loader, so there is no other option. The pin is llvm on every host // because clang/lld are cross-compilers by construction: unlike the hosted // rows above, these need no per-host cross payload at all. // ISA profile (-march/-mabi/-mcmodel) lives in mcpp.freestanding.target, // which is the single place that decision is made. - { "riscv64-none-elf", "verified", "bare","llvm@22.1.8", true }, - { "riscv32-none-elf", "verified", "bare","llvm@22.1.8", true }, + // The sysroot column is what keeps a bare-metal PACKAGE from having to + // name a libc: the C library is the target's, like the compiler. + { "riscv64-none-elf", "verified", "bare","llvm@22.1.8","xim:picolibc-riscv@1.8.12", true }, + { "riscv32-none-elf", "verified", "bare","llvm@22.1.8","xim:picolibc-riscv@1.8.12", true }, }; inline std::span known_targets() { return kKnownTargets; } diff --git a/src/version.cppm b/src/version.cppm index b939a1ce..f83be425 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.19.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.19.4"; } // namespace mcpp diff --git a/tests/e2e/131_freestanding_bsp_supplies_everything.sh b/tests/e2e/131_freestanding_bsp_supplies_everything.sh index 6b730a33..ad42d0cf 100755 --- a/tests/e2e/131_freestanding_bsp_supplies_everything.sh +++ b/tests/e2e/131_freestanding_bsp_supplies_everything.sh @@ -169,14 +169,50 @@ grep -q 'float 3.1416' run.log || { grep -q 'MALLOC-OK' run.log || { cat run.log; echo "the heap is not wired"; exit 1; } -# ── the private half of the seam, pinned from the other side ──────────────── -# If include-dir DID reach the consumer, this would compile — and the whole -# reason the BSP exports a module would be gone. +# ── the two include seams, which are NOT the same seam ────────────────────── +# +# ⚠️ This used to test both with `#include ` and expect it to FAIL. +# That criterion was wrong once the target's C library became the target's: +# `` now arrives the way it does on a hosted build, from mcpp, for +# every unit — nobody declares glibc on x86_64 either. Testing the private +# scope with a libc header was really testing where the libc came from. +# +# So both halves, separately: +# +# 1. The TARGET's C headers DO reach the consumer. This is the new behaviour +# and it deserves a positive assertion, not just the absence of a failure. cat > src/main.cpp <<'EOF' #include -extern "C" int main() { printf("leaked\n"); return 0; } +extern "C" int main() { printf("TARGET-LIBC-OK\n"); return 0; } +EOF +"$MCPP" build --target riscv64-none-elf > libc.log 2>&1 || { + cat libc.log + echo "the target's C headers did not reach the consumer" + exit 1; } + +# 2. A DEPENDENCY's own include-dir still does not. Tested with a header the +# BOARD ships, which is the only thing `Scope::PackagePrivate` was ever +# about — a package must not widen its consumer's include path. +mkdir -p "$TMP/board/private" +cat > "$TMP/board/private/board_secret.h" <<'EOF' +#define BOARD_SECRET 1 +EOF +python3 - "$TMP/board/build.mcpp" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); t = p.read_text() +marker = " return 0;\n}" +assert marker in t, t[-200:] +t = t.replace(marker, + ' std::println("mcpp:include-dir={}/private", std::getenv("MCPP_MANIFEST_DIR"));\n' + + marker, 1) +p.write_text(t) +PY +cat > src/main.cpp <<'EOF' +#include +extern "C" int main() { return BOARD_SECRET; } EOF if "$MCPP" build --target riscv64-none-elf > leak.log 2>&1; then + cat leak.log echo "a dependency's include-dir reached the consumer — it is supposed to be" echo "package-private (mcpp.build.directives, Scope::PackagePrivate)" exit 1 diff --git a/tests/e2e/133_freestanding_std_subset.sh b/tests/e2e/133_freestanding_std_subset.sh new file mode 100755 index 00000000..46a89eb7 --- /dev/null +++ b/tests/e2e/133_freestanding_std_subset.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# requires: llvm qemu-riscv unix-shell +# The freestanding subset of the standard library, as an ordinary package. +# +# `import std;` is one module over the whole library — threads, filesystem and +# iostreams included — so there is no subset of IT to build without an OS. But +# libc++'s HEADERS are almost entirely freestanding-capable already. What stops +# them is one per-target file, `__config_site`, which the llvm payload ships +# only for its own host triple. +# +# ⚠️ MEASURED, and the control group is what makes the number mean anything: +# with a synthesised `__config_site`, 103 of libc++'s 110 headers compile for +# riscv64-none-elf. The 7 that fail (generator, hazard_pointer, rcu, +# spanstream, stacktrace, stdfloat, text_encoding) fail on an x86_64 host with +# full libc++ and glibc too — they are headers libc++ has not implemented. The +# freestanding loss at compile time is zero. Without the host control group, +# "libc++ has not written it" reads as "bare metal cannot do it". +# +# This test does not re-run that sweep (110 compiles is a package-CI job). It +# pins the MECHANISM the sweep depends on, end to end, in the emulator. +set -e + +QEMU="$(command -v qemu-system-riscv64 || true)" +for d in "$HOME"/.mcpp/registry/data/xpkgs/*-x-qemu-riscv/*/bin \ + "$HOME"/.xlings/data/xpkgs/*-x-qemu-riscv/*/bin; do + [[ -x "$d/qemu-system-riscv64" ]] && QEMU="$d/qemu-system-riscv64" +done +[[ -n "$QEMU" ]] || { echo "SKIP: no qemu-system-riscv64"; exit 0; } + +MH="${MCPP_HOME:-$HOME/.mcpp}" +[[ -d "$MH/registry/data/xpkgs/xim-x-picolibc-riscv" ]] \ + || { echo "SKIP: xim:picolibc-riscv not installed in $MH"; exit 0; } + +# libc++'s headers come from the llvm payload; pick by CONTENT, not by name +# order — an empty directory for an older version sorts first. +LLVM="" +for d in "$MH"/registry/data/xpkgs/xim-x-llvm/*/; do + [[ -x "$d/bin/clang++" ]] && LLVM="$d" +done +[[ -n "$LLVM" ]] || { echo "SKIP: no llvm payload with clang++"; exit 0; } +LLVM="${LLVM%/}" +[[ -d "$LLVM/share/libc++/v1/std" ]] \ + || { echo "SKIP: llvm payload has no libc++ std/*.inc"; exit 0; } + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# ── the board (same shape as 131) ─────────────────────────────────────────── +"$MCPP" new board > /dev/null +cd board +rm -f src/main.cpp tests/*.cpp 2>/dev/null || true +# ⚠️ NO libc anywhere in this package. The target's C library is the TARGET's, +# resolved by mcpp from the target's own row exactly as the compiler is; the +# board selects OUT of it (which crt0, which libraries) and names the linker +# script it wants, and asks where the sysroot is rather than declaring one. +cat > mcpp.toml <<'EOF' +[package] +name = "board" +version = "0.1.0" + +[xlings] +deps = ["xim:qemu-riscv@9.2.4-1"] +EOF +cat > build.mcpp <<'EOF' +import std; +int main() { + // Bare names: mcpp already put -L for the target sysroot on the link line. + std::println("mcpp:link-lib=crt0-semihost"); + std::println("mcpp:link-lib=c"); + std::println("mcpp:link-lib=semihost"); + std::println("mcpp:link-lib=clang_rt.builtins-riscv64"); + if (const char* s = std::getenv("MCPP_TARGET_SYSROOT"); s && *s) + std::println("mcpp:link-script={}/lib/rv64gc/lp64d/picolibcpp.ld", s); + if (const char* q = std::getenv("MCPP_XPKG_XIM_QEMU_RISCV_DIR"); q && *q) { + std::println("mcpp:runner={}/bin/qemu-system-riscv64", q); + for (auto a : {"-machine","virt","-nographic","-no-reboot", + "-semihosting","-bios","none","-kernel"}) + std::println("mcpp:runner={}", a); + } + return 0; +} +EOF +cat > src/board.cppm <<'EOF' +module; +#include +export module board; +export namespace board { + inline void print(const char* s) { fputs(s, stdout); } + inline void printf_d(const char* f, int v) { printf(f, v); } +} +EOF + +# ── the std subset package ────────────────────────────────────────────────── +cd "$TMP" +"$MCPP" new stdfs > /dev/null +cd stdfs +rm -f src/main.cpp tests/*.cpp src/stdfs.cppm 2>/dev/null || true +# ⚠️ NO [xlings] section. The C++ headers come from whatever toolchain mcpp +# resolved, and the target's C headers mcpp already put on the compile line. +# Declaring either would pin this package to one implementation, one libc and +# one ISA — which is what an earlier version of it did. +cat > mcpp.toml <<'EOF' +[package] +name = "stdfs" +version = "0.1.0" +EOF + +# ⚠️ The config has to be a FILE. `_LIBCPP_HAS_THREADS` and friends are read +# from `__config_site` by <__config>; defining them with -D does not reach the +# check at all (measured — the obvious `-D_LIBCPP_HAS_THREADS=0` does nothing). +cat > build.mcpp <<'EOF' +import std; +int main() { + const char* llvm = std::getenv("MCPP_TOOLCHAIN_DIR"); + if (!llvm || !*llvm) { std::cerr << "no toolchain dir\n"; return 1; } + + const std::string out = std::getenv("MCPP_OUT_DIR") ?: "."; + const std::string cfg = out + "/libcxx-config/c++/v1"; + std::filesystem::create_directories(cfg); + { + std::ofstream f(cfg + "/__config_site"); + f << "#ifndef _LIBCPP___CONFIG_SITE\n#define _LIBCPP___CONFIG_SITE\n" + "#define _LIBCPP_ABI_VERSION 1\n#define _LIBCPP_ABI_NAMESPACE __1\n" + "#define _LIBCPP_ABI_FORCE_ITANIUM 0\n#define _LIBCPP_ABI_FORCE_MICROSOFT 0\n" + "#define _LIBCPP_HAS_THREADS 0\n#define _LIBCPP_HAS_MONOTONIC_CLOCK 0\n" + "#define _LIBCPP_HAS_FILESYSTEM 0\n#define _LIBCPP_HAS_LOCALIZATION 0\n" + "#define _LIBCPP_HAS_TERMINAL 0\n#define _LIBCPP_HAS_RANDOM_DEVICE 0\n" + "#define _LIBCPP_HAS_TIME_ZONE_DATABASE 0\n#define _LIBCPP_HAS_WIDE_CHARACTERS 0\n" + "#define _LIBCPP_HAS_UNICODE 1\n#define _LIBCPP_HAS_MUSL_LIBC 0\n" + "#define _LIBCPP_HAS_THREAD_API_PTHREAD 0\n#define _LIBCPP_HAS_THREAD_API_EXTERNAL 0\n" + "#define _LIBCPP_HAS_THREAD_API_WIN32 0\n#define _LIBCPP_HAS_THREAD_API_C11 0\n" + "#define _LIBCPP_HAS_VENDOR_AVAILABILITY_ANNOTATIONS 0\n" + "#define _LIBCPP_INSTRUMENTED_WITH_ASAN 0\n" + "#define _LIBCPP_PSTL_BACKEND_SERIAL\n" + "#define _LIBCPP_HARDENING_MODE_DEFAULT 2\n" + "#define _LIBCPP_ASSERTION_SEMANTIC_DEFAULT 2\n" + "#define _LIBCPP_LIBC_PICOLIBC 1\n#define _LIBCPP_LIBC_NEWLIB 0\n#endif\n"; + } + // Order matters: the synthesised config first, libc++ before the C library + // ( exists in both trees and the C++ wrapper has to win). + std::println("mcpp:include-dir={}", cfg); + std::println("mcpp:include-dir={}/include/c++/v1", llvm); + std::println("mcpp:include-dir={}/share/libc++/v1", llvm); + return 0; +} +EOF + +# The export table is libc++'s, not ours: one `std/
.inc` per header, +# each `export namespace std { using std::X; }`. Selecting is the whole job. +cat > src/stdfs.cppm <<'EOF' +module; +#include +#include +#include +#include +#include +#include +#include +export module mcpplibs.std.freestanding; +#include "std/array.inc" +#include "std/span.inc" +#include "std/optional.inc" +#include "std/atomic.inc" +#include "std/string_view.inc" +#include "std/algorithm.inc" +#include "std/ranges.inc" +EOF + +# ⚠️ Declared through libc++'s OWN header. The real symbol lives in the ABI +# inline namespace (std::__1::), so a hand-written `namespace std { ... }` +# definition compiles, links nothing, and leaves the undefined-symbol error +# looking exactly as it did before. +cat > src/verbose_abort.cpp <<'EOF' +#include <__verbose_abort> +_LIBCPP_BEGIN_NAMESPACE_STD +[[noreturn]] void __libcpp_verbose_abort(const char*, ...) _NOEXCEPT { + for (;;) { +#if defined(__riscv) + __asm__ volatile("wfi"); +#endif + } +} +_LIBCPP_END_NAMESPACE_STD +EOF + +# ── the consumer ──────────────────────────────────────────────────────────── +cd "$TMP" +"$MCPP" new app > /dev/null +cd app +rm -f tests/*.cpp 2>/dev/null || true +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[build] +target = "riscv64-none-elf" + +[dependencies] +board = { path = "../board" } +stdfs = { path = "../stdfs" } +EOF +cat > src/main.cpp <<'EOF' +import board; +import mcpplibs.std.freestanding; + +struct Task { int prio; const char* name; }; + +extern "C" int main() { + // ranges::sort with a PROJECTION: entirely header-resident, unlike the + // scalar std::sort, whose instantiation libc++ keeps in the compiled + // library as an extern template. + std::array t{{ {3,"c"}, {1,"a"}, {4,"d"}, {2,"b"} }}; + std::ranges::sort(t, {}, &Task::prio); + for (const auto& x : t) board::print(x.name); + board::print("\n"); + + std::optional o = 41; + std::atomic a{0}; + a.fetch_add(o.value() + 1); + board::printf_d("atomic %d\n", a.load()); + + std::span s{t}; + std::string_view sv{"sv"}; + board::printf_d("span %d\n", (int)(s.size() + sv.size())); + return 0; +} +EOF + +"$MCPP" run > run.log 2>&1 || { cat run.log; echo "the std subset did not run"; exit 1; } + +grep -q 'abcd' run.log || { cat run.log; echo "ranges::sort with a projection did not work"; exit 1; } +grep -q 'atomic 42' run.log || { cat run.log; echo "optional/atomic did not work"; exit 1; } +grep -q 'span 6' run.log || { cat run.log; echo "span/string_view did not work"; exit 1; } + +# ── the half that must NOT be there ───────────────────────────────────────── +# Turning a capability off in __config_site makes it VANISH rather than leaving +# a stub that fails at run time. That is the property worth pinning: a +# bare-metal author finds out at compile time, by name. +cat > src/main.cpp <<'EOF' +import board; +import mcpplibs.std.freestanding; +extern "C" int main() { std::mutex m; (void)m; return 0; } +EOF +if "$MCPP" build > mutex.log 2>&1; then + cat mutex.log + echo "std::mutex compiled on a target with no threads" + exit 1 +fi +grep -qi "mutex" mutex.log || { + cat mutex.log; echo "the error did not name std::mutex"; exit 1; } + +echo "PASS: the freestanding std subset builds, runs, and omits what it must" diff --git a/tests/unit/test_cache_key.cpp b/tests/unit/test_cache_key.cpp index 1b5a1dbd..274ee906 100644 --- a/tests/unit/test_cache_key.cpp +++ b/tests/unit/test_cache_key.cpp @@ -95,6 +95,30 @@ TEST(CacheKey, ToolchainIdentityChangesTheKey) { EXPECT_NE(ck::key_hex(b, pkg()), base); } } +// ⚠️ The axis that exists because an UPGRADE broke a build. +// +// A freestanding triple silently implies -march/-mabi/-ffreestanding/ +// -nostdinc++/-fno-exceptions/-fno-rtti, and WHICH ones is mcpp's decision — +// so the set changes between mcpp versions while the triple string does not. +// Keyed on the triple alone, an mcpp that adds a flag reuses BMIs built +// before it, and clang refuses them with a message that names a .pcm file: +// +// error: exception handling was enabled in precompiled file +// 'mcpplibs.riscv_virt_rt.pcm' but is currently disabled +// +// Measured on exactly that upgrade, on a project that had built once before. +TEST(CacheKey, TargetImpliedFlagsChangeTheKey) { + auto base = ck::key_hex(axes(), pkg()); + { auto b = axes(); b.targetImpliedFlags = {"-ffreestanding"}; + EXPECT_NE(ck::key_hex(b, pkg()), base); } + // Adding one flag to an existing set must move it too — the upgrade case + // is never "empty becomes non-empty", it is "one more flag than before". + auto before = axes(); before.targetImpliedFlags = {"-ffreestanding", "-nostdinc++"}; + auto after = axes(); after.targetImpliedFlags = {"-ffreestanding", "-nostdinc++", + "-fno-exceptions"}; + EXPECT_NE(ck::key_hex(after, pkg()), ck::key_hex(before, pkg())); +} + // ── B axis: language and dialect ───────────────────────────────────────────── TEST(CacheKey, LanguageAndDialectChangeTheKey) { auto base = ck::key_hex(axes(), pkg()); diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index 1acc9bb5..d7788e72 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -228,6 +228,60 @@ TEST(XpkgEnvVar, BothSpellingsAreDerivedFromOneSanitizer) { "MCPP_XPKG_PICOLIBC_RISCV_DIR"); } +// ── the target owns its C library ─────────────────────────────────────────── + +TEST(FreestandingTarget, BareMetalRowsNameTheirSysroot) { + // ⚠️ The axis that stops every bare-metal PACKAGE from naming a libc. + // Before it, a board-support package and a standard-library subset each + // had to carry `[xlings] deps = ["xim:picolibc-riscv@..."]`, which bound + // both to one libc, one ISA and one version of each — none of which is a + // property of either package. + for (const char* s : { "riscv64-none-elf", "riscv32-none-elf" }) { + auto* k = mcpp::toolchain::triple::find_known_target( + *mcpp::toolchain::triple::parse(s)); + ASSERT_NE(k, nullptr) << s; + EXPECT_FALSE(k->sysroot.empty()) << s; + // Same spelling the install channel takes, so the two cannot drift. + EXPECT_TRUE(k->sysroot.starts_with("xim:")) << s; + } +} + +TEST(FreestandingTarget, HostedRowsNameNone) { + // A hosted target's libc comes with the compiler payload or through the + // runtime binding. Naming one here would resolve a second copy. + for (const char* s : { "x86_64-linux-gnu", "x86_64-linux-musl", + "aarch64-macos", "x86_64-windows-gnu" }) { + auto* k = mcpp::toolchain::triple::find_known_target( + *mcpp::toolchain::triple::parse(s)); + ASSERT_NE(k, nullptr) << s; + EXPECT_TRUE(k->sysroot.empty()) << s; + } +} + +// ── whole-graph properties ────────────────────────────────────────────────── + +TEST(FreestandingFlags, ExceptionsAndRttiAreOffForTheWholeGraph) { + auto spec = resolve("riscv64-none-elf"); + ASSERT_TRUE(spec.has_value()); + auto f = compile_flags(*spec); + // ⚠️ These live with the TARGET, not in a project's cxxflags, because a + // BMI records them: a dependency compiled with exceptions cannot be + // imported by a TU without them, and clang reports that as a .pcm + // "configuration mismatch" rather than as a flag disagreement. + EXPECT_TRUE(std::ranges::find(f, "-fno-exceptions") != f.end()); + EXPECT_TRUE(std::ranges::find(f, "-fno-rtti") != f.end()); + // The company they keep — same argument, already shipped. + EXPECT_TRUE(std::ranges::find(f, "-ffreestanding") != f.end()); + EXPECT_TRUE(std::ranges::find(f, "-nostdinc++") != f.end()); +} + +TEST(FreestandingFlags, HostedTargetsGetNoneOfThis) { + // The flags are a property of a freestanding triple, and the cache key + // reads them through the same resolve(). A hosted target must produce no + // Spec, or every hosted key would move for no reason. + EXPECT_FALSE(resolve("x86_64-linux-gnu").has_value()); +} + // ── engine floor: a package that needs a newer mcpp ───────────────────────── TEST(BuildProgramCompatHint, RecognisesAllThreeFrontendSpellings) {