diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 52530adf..1ac95137 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -189,7 +189,8 @@ jobs: tests/e2e/132_freestanding_test_and_artifacts.sh \ tests/e2e/133_freestanding_std_subset.sh \ tests/e2e/332_cortex_m_builds_and_boots.sh \ - tests/e2e/336_armv7a_builds_and_boots.sh; do + tests/e2e/336_armv7a_builds_and_boots.sh \ + tests/e2e/338_cortex_m_picolibc_sysroot.sh; do echo "=== $t ===" bash "$t" 2>&1 | tee "$(basename "$t").log" rc=${PIPESTATUS[0]} @@ -231,6 +232,15 @@ jobs: a32=$(grep -c 'booted on virt' 336_armv7a_builds_and_boots.sh.log || true) [ "$a32" = "2" ] || { echo "336 booted $a32 rows, expected 2"; exit 1; } + # ⚠️ 338 SKIPS UNTIL `xim:picolibc-arm` IS PUBLISHED, and a skip here + # is legitimate rather than a defect — the payload is a separate + # release. So its PASS line is NOT demanded; what IS demanded is that + # the script either passed or said why, which is what distinguishes a + # skip from a silent zero-exit. + grep -qE 'PASS: a Cortex-M project opts into picolibc|SKIP: picolibc-arm is not installed' \ + 338_cortex_m_picolibc_sysroot.sh.log || { + cat 338_cortex_m_picolibc_sysroot.sh.log + echo "338 neither passed nor reported why it did not run"; exit 1; } # ────────────────────────────────────────────────────────────────── # Hermetic (no host toolchain): the ONLY environment class that diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c460ff..1df0325e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ ## [2026.9.4.2] — 2026-09-04 +### ⭐⭐ Cortex-M 有 C 库了:`libdir` 填上,而它的键是**三元组** + +`xim:picolibc-arm@1.8.12` 带七个多库(picolibc + compiler-rt builtins,同一个 +LLVM 一起构建)。工程一行选入: + +```toml +[target.thumbv7m-none-eabi] +sysroot = "xim:picolibc-arm@1.8.12" +``` + +⚠️⚠️ **`libdir` 这一列在 ARM 上是三元组,不是 `/`,而差别是一次 +无人报告的 ABI 替换。** riscv 的 `mabi` **就是**浮点 ABI(`lp64d` 与 `lp64` 是 +两个值);ARM 的 `mabi` 是过程调用标准,两个变体都是 `aapcs`,浮点 ABI 在三元组的 +`eabi`/`eabihf` 后缀里。于是 `armv7e-m/aapcs` 会给两份互不兼容的库命名同一个目录。 + +实测(构建 `xim:picolibc-arm` 时):按 `/`,七个档位塌成五个目录, +`armv7e-m/aapcs/libc.a` 带着 `Tag_ABI_HardFP_use` —— 硬浮点的构建,坐在软浮点 +程序会去找它的位置上。**构建期什么都没报。** + +⭐ 填这一列**不**给这些行默认配 C 库:只有解析出 sysroot 之后才会读它,目标表仍然 +不绑定任何一个。零 libc 仍是默认,这只是让选入这件事能成立。 + +判据是浮点 ABI 而不是「链接通过」:`tests/e2e/338` 构建**软浮点**那一行,断言产物 +里没有硬浮点 ABI 标记,然后启动它并读退出码。⚠️ 并且断言产物**不为空** —— 实测: +缺了 crt0 的链接会成功并报 `Size fw text 0 data 0`,一个格式良好、什么都没有的 +ELF,任何只看 `Finished` 的检查都会放行。 + runner 有了名字,工具有了档位,`--locked` 成为断言,`mcpp emit sbom`。 ### ⭐⭐ 一条命令,加具名的例外 diff --git a/src/freestanding/target.cppm b/src/freestanding/target.cppm index 0e842213..0773972c 100644 --- a/src/freestanding/target.cppm +++ b/src/freestanding/target.cppm @@ -251,20 +251,38 @@ inline constexpr Spec kTable[] = { // the x86_64 row's problem does not recur here. Verified end to end: a // freestanding thumb object links under `ld.lld` and boots under QEMU. // - // `libdir` is EMPTY on every row because `sysroot` is (see the target - // table): the column is read only when a sysroot has been resolved, and a - // value here could never be checked. + // ⚠️⚠️ `libdir` IS THE TRIPLE HERE, NOT `/` LIKE EVERY OTHER + // ROW, AND THE DIFFERENCE IS AN ABI SUBSTITUTION THAT NOTHING REPORTS. + // + // The column names the sub-directory a multilib C library uses, and on + // riscv `/` separates every profile because `mabi` there IS + // the float ABI — `lp64d` and `lp64` are different values. On ARM `mabi` + // names the PROCEDURE CALL STANDARD and is `aapcs` for both variants, while + // the float ABI lives in this triple's `eabi`/`eabihf` suffix. So + // `armv7e-m/aapcs` would name ONE directory for two incompatible libraries. + // + // Measured while building `xim:picolibc-arm`: under the sibling convention + // the seven profiles collapsed into five directories and + // `armv7e-m/aapcs/libc.a` came out carrying `Tag_ABI_HardFP_use` — the + // hard-float build, sitting exactly where a soft-float program would find + // it. Nothing failed at build time. + // + // ⚠️ Filling this does NOT give these rows a C library by default: the + // column is read only once a sysroot has been resolved, and the target + // table still binds none. The zero-libc tier stays the default and + // `[target.] sysroot = "xim:picolibc-arm@1.8.12"` is the opt-in. + // This makes the opt-in work; it does not take the opt-out away. // // ⚠️ `-mabi=aapcs`, matching the aarch64 row and for the same reason: on // ARM `-mabi` names a procedure call standard, not a data model. // triple march mabi mcmodel libdir extra - { "thumbv6m-none-eabi", "armv6-m", "aapcs", "", "", kThumbSoftExtra }, - { "thumbv7m-none-eabi", "armv7-m", "aapcs", "", "", kThumbSoftExtra }, - { "thumbv7em-none-eabi", "armv7e-m", "aapcs", "", "", kThumbSoftExtra }, - { "thumbv7em-none-eabihf", "armv7e-m", "aapcs", "", "" }, - { "thumbv8m.base-none-eabi", "armv8-m.base","aapcs","", "", kThumbSoftExtra }, - { "thumbv8m.main-none-eabi", "armv8-m.main","aapcs","", "", kThumbSoftExtra }, - { "thumbv8m.main-none-eabihf","armv8-m.main","aapcs","", "" }, + { "thumbv6m-none-eabi", "armv6-m", "aapcs", "", "thumbv6m-none-eabi", kThumbSoftExtra }, + { "thumbv7m-none-eabi", "armv7-m", "aapcs", "", "thumbv7m-none-eabi", kThumbSoftExtra }, + { "thumbv7em-none-eabi", "armv7e-m", "aapcs", "", "thumbv7em-none-eabi", kThumbSoftExtra }, + { "thumbv7em-none-eabihf", "armv7e-m", "aapcs", "", "thumbv7em-none-eabihf" }, + { "thumbv8m.base-none-eabi", "armv8-m.base","aapcs","", "thumbv8m.base-none-eabi", kThumbSoftExtra }, + { "thumbv8m.main-none-eabi", "armv8-m.main","aapcs","", "thumbv8m.main-none-eabi", kThumbSoftExtra }, + { "thumbv8m.main-none-eabihf","armv8-m.main","aapcs","", "thumbv8m.main-none-eabihf" }, // ── ARMv7-A (Cortex-A, 32-bit) ────────────────────────────────────────── // // ⭐ THE FIRST 32-BIT MACHINE WITH A MEMORY MANAGEMENT UNIT, AND THAT IS @@ -295,8 +313,18 @@ inline constexpr Spec kTable[] = { // the WRONG exit status — measured: a program exiting 0 reported 1. That is // a board fact rather than a target fact, recorded here because it is where // the next person to write such a board will look. - { "armv7a-none-eabi", "armv7-a", "aapcs", "", "", kArmSoftExtra }, - { "armv7a-none-eabihf", "armv7-a", "aapcs", "", "" }, + // + // ⚠️ `libdir` IS THE TRIPLE HERE TOO, AND FOR THE SAME REASON AS THE + // M ROWS — even though no package carries an A-profile multilib yet. + // + // The column is a CONVENTION about where a sysroot puts a profile, not a + // claim that one exists: it is read only after a sysroot has been resolved, + // and an absent directory is skipped. Leaving it empty would leave the next + // A-profile payload free to choose `/` — which maps these two + // incompatible libraries onto one directory, exactly as it did for + // `armv7e-m` when `xim:picolibc-arm` was first built. + { "armv7a-none-eabi", "armv7-a", "aapcs", "", "armv7a-none-eabi", kArmSoftExtra }, + { "armv7a-none-eabihf", "armv7-a", "aapcs", "", "armv7a-none-eabihf" }, }; // The single read point. Returns nullopt for anything that is not a known diff --git a/tests/e2e/337_run_takes_features_and_profile.sh b/tests/e2e/337_run_takes_features_and_profile.sh index b73ba792..ba2869f4 100755 --- a/tests/e2e/337_run_takes_features_and_profile.sh +++ b/tests/e2e/337_run_takes_features_and_profile.sh @@ -85,19 +85,37 @@ want "$again" "dev" "a plain run inherited the previous --release" # # Measured before the fix: three builds of one project printed # `quiet`, `LOUD`, `LOUD`. -artifact() { find target -type f -name featrun -newermt '-1 day' | head -1; } +# ⚠️⚠️ THE ASSERTION IS ON WHAT THE CACHE ENTRY RECORDS, NOT ON WHICH FILE A +# `find` HAPPENS TO RETURN FIRST. +# +# The first version of this block ran `find target -name featrun | head -1`. +# Two output directories exist by then — one per feature set — and which one +# `find` walks first is filesystem order. It passed locally and failed on CI, +# which is the signature of an assertion that depends on something nobody +# chose. +# +# What the fix actually changed is the ENTRY: it now records the feature set its +# artefacts were built with, and both fast paths compare it. Entries are written +# most-recently-used first, so the first `features=` line in the cache belongs +# to the build that just ran — and reading it is exact. +mru_features() { awk -F= '/^features=/{print $2; exit}' target/.build_cache; } rm -rf target "$MCPP" build >/dev/null 2>&1 -first="$(./"$(artifact)")" +[ -z "$(mru_features)" ] || { + echo "FAIL: a plain build recorded features '$(mru_features)'"; exit 1; } + "$MCPP" build --features loud >/dev/null 2>&1 +[ "$(mru_features)" = "loud" ] || { + echo "FAIL: --features loud recorded '$(mru_features)', expected loud"; exit 1; } + +# ⚠️ THE ONE THAT CAUGHT THE PRE-EXISTING DEFECT. Before the entry carried a +# feature set, this plain build matched the entry `--features loud` had written, +# reported success in 0.00s and left the loud artefact in place. Measured: three +# consecutive builds of one project printed `quiet`, `LOUD`, `LOUD`. "$MCPP" build >/dev/null 2>&1 -third="$(./"$(artifact)")" -case "$first" in *quiet*) ;; *) echo "FAIL: the first build was not plain"; exit 1 ;; esac -case "$third" in - *quiet*) ;; - *) echo "FAIL: a plain build after --features served the featured artefact" - echo " first=$first third=$third"; exit 1 ;; -esac +[ -z "$(mru_features)" ] || { + echo "FAIL: a plain build after --features matched an entry recorded as '$(mru_features)'" + exit 1; } echo "PASS: mcpp run takes --features and --profile, and the fast path honours both" diff --git a/tests/e2e/338_cortex_m_picolibc_sysroot.sh b/tests/e2e/338_cortex_m_picolibc_sysroot.sh new file mode 100755 index 00000000..7bd58213 --- /dev/null +++ b/tests/e2e/338_cortex_m_picolibc_sysroot.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell qemu-arm +# A Cortex-M project opts into a C library, and gets the RIGHT multilib. +# +# ⭐⭐ THE ZERO-LIBC TIER IS THE DEFAULT AND THIS IS THE OPT-IN. Every +# `thumb*-none-eabi*` row carries an empty C-library column, so a project +# targeting one begins with no libc unless it says otherwise. One line says +# otherwise, and `libdir` is what makes that line find anything. +# +# ⚠️⚠️ AND THE ASSERTION IS THE FLOAT ABI, NOT THAT IT LINKS. +# +# `libdir` names the sub-directory a multilib C library uses. On riscv +# `/` separates every profile because `mabi` there IS the float +# ABI; on ARM `mabi` names the procedure call standard and is `aapcs` for both +# variants, while the float ABI lives in the triple's `eabi`/`eabihf` suffix. So +# `armv7e-m/aapcs` would name ONE directory for two incompatible libraries — +# measured while building `xim:picolibc-arm`, where the soft-float row silently +# received a library carrying `Tag_ABI_HardFP_use`. +# +# A test that only linked would pass either way. This one builds the SOFT-float +# row and asserts the produced image declares no hard-float ABI. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +qemu_arm() { + local d c + for d in "${MCPP_HOME:-$HOME/.mcpp}/registry" "$HOME/.xlings"; do + c=$(ls "$d"/data/xpkgs/xim-x-qemu-arm/*/bin/qemu-system-arm 2>/dev/null | sort -V | tail -1) + [ -n "$c" ] && [ -x "$c" ] && { echo "$c"; return 0; } + done + command -v qemu-system-arm 2>/dev/null && return 0 + return 1 +} +QEMU="$(qemu_arm)" || { echo "SKIP: qemu-system-arm not installed"; exit 0; } + +llvm_tool() { + local c + c=$(ls "${MCPP_HOME:-$HOME/.mcpp}"/registry/data/xpkgs/xim-x-llvm/*/bin/"$1" 2>/dev/null | sort -V | tail -1) + [ -n "$c" ] && { echo "$c"; return 0; } + command -v "$1" 2>/dev/null +} +READELF="$(llvm_tool llvm-readelf)" || { echo "SKIP: llvm-readelf not found"; exit 0; } + +# ⚠️ The payload has to be present. Installing it here rather than skipping +# would make the test about the install; skipping when it is absent keeps the +# criterion about the ENGINE, which is what this suite tests. +# ⚠️ `xim-x-picolibc-arm` AND NOT `*-x-picolibc-arm`. The engine resolves the +# name `xim:picolibc-arm`, so a copy installed under any other namespace — a +# LOCAL index entry, say — satisfies a glob and not the engine. Measured: with +# the loose pattern this test proceeded against a `local-x-` payload and failed +# on `'stdio.h' file not found`, having asserted nothing about the engine. +sysroot_dir() { + local d c + for d in "${MCPP_HOME:-$HOME/.mcpp}/registry" "$HOME/.xlings"; do + for c in "$d"/data/xpkgs/xim-x-picolibc-arm/*/; do + [ -d "$c/lib/thumbv7m-none-eabi" ] && { echo "$c"; return 0; } + done + done + return 1 +} +SYSROOT="$(sysroot_dir)" || { echo "SKIP: picolibc-arm is not installed"; exit 0; } + +mkdir -p "$work/fw/src" +cd "$work/fw" +cat > mcpp.toml <<'TOML' +[package] +name = "fw" +version = "0.1.0" + +[build] +target = "thumbv7m-none-eabi" +sources = ["src/main.c"] + +[targets.fw] +kind = "bin" +main = "src/main.c" + +# The opt-in, and the whole of it. +[target.thumbv7m-none-eabi] +sysroot = "xim:picolibc-arm@1.8.12" +TOML + +# ⭐ LOCATION IS A TARGET FACT; SELECTION IS A BOARD FACT. The engine resolved +# WHERE the C library is and which multilib profile applies. WHICH startup +# object, WHICH linker script and WHICH libraries are decisions a board makes, +# and this fixture stands in for a board package. +# +# ⚠️ `target_libc_profile()` IS THE `libdir` COLUMN, AND WITHOUT IT NONE OF THIS +# CAN BE WRITTEN. It is empty when the column is, and the board is then reduced +# to guessing a directory name — which is the thing that goes wrong silently. +cat > build.mcpp <<'BUILD' +import mcpp; +import std; +int main() { + const std::string root = mcpp::sysroot_dir() ? mcpp::sysroot_dir() : ""; + const std::string prof = mcpp::target_libc_profile() + ? mcpp::target_libc_profile() : ""; + if (prof.empty()) { + std::cerr << "the target row carries no libc profile; a board cannot " + "name a file inside the sysroot\n"; + return 1; + } + const std::string lib = root + "/lib/" + prof; + mcpp::link_script((lib + "/picolibc.ld").c_str()); + mcpp::link_search(lib.c_str()); + mcpp::link_lib("crt0-semihost"); // pulled from the archive by ENTRY + mcpp::link_lib("c"); + mcpp::link_lib("semihost"); + mcpp::link_lib(mcpp::target_builtins_lib() ? mcpp::target_builtins_lib() : ""); + return 0; +} +BUILD + +cat > src/main.c <<'C' +#include +/* `volatile`, so the multiply survives constant folding and picolibc's float + formatting is really exercised — the path that needs the builtins. */ +volatile float fa = 3.0f, fb = 4.0f; +int main(void) { printf("picolibc: %.2f\n", (double)(fa * fb + 1.0f)); return 0; } +C + +# Twice, the first allowed to fail: the target world is installed during a build. +"$MCPP" build > /dev/null 2>&1 || true +"$MCPP" build 2>&1 | tail -20 || { echo "FAIL: the opt-in build did not succeed"; exit 1; } + +elf=$(find target -type f -name fw | head -1) +[ -n "$elf" ] || { echo "FAIL: no artefact"; exit 1; } + +# ⚠️⚠️ AN EMPTY IMAGE IS NOT A PASS, AND IT IS WHAT A MISSING BOARD SELECTION +# PRODUCES. Measured while writing this: without the crt0 the link SUCCEEDED and +# mcpp reported `Size fw text 0 data 0` — a well-formed ELF containing +# nothing. Every check that only looked for "Finished" would have passed. +size=$(wc -c < "$elf") +[ "$size" -gt 4096 ] || { echo "FAIL: the artefact is $size bytes — an empty link"; exit 1; } + +# ⭐ THE ASSERTION THAT CATCHES THE WRONG MULTILIB. A hard-float libc linked +# into a soft-float image leaves `Tag_ABI_HardFP_use` in the attributes. +hard=$("$READELF" -A "$elf" 2>/dev/null | grep -c 'ABI_HardFP_use' || true) +[ "$hard" = "0" ] || { + echo "FAIL: a soft-float image carries $hard hard-float ABI tags — the wrong multilib was linked" + exit 1; } +echo " ok the soft-float row linked a soft-float C library" + +# And it runs: the C library is not merely present, its printf works. +set +e +timeout 30 "$QEMU" -machine mps2-an385 -cpu cortex-m3 -nographic -semihosting \ + -no-reboot -kernel "$elf" > run.log 2>&1 +rc=$? +set -e +cat run.log +grep -q 'picolibc: 13.00' run.log \ + || { echo "FAIL: printf did not produce the expected output"; exit 1; } +[ "$rc" = "0" ] || { echo "FAIL: the image ran but exited $rc"; exit 1; } + +echo "PASS: a Cortex-M project opts into picolibc and gets the right multilib" diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index fffa3c48..79e6a844 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -559,3 +559,48 @@ TEST(FreestandingTarget, EveryRowCompilesWithPerFunctionSections) { } EXPECT_GT(rows, 0); } + +// ⚠️⚠️ THE MULTILIB KEY IS NOT `/` ON ARM, AND ASSUMING IT WAS +// PRODUCES AN ABI SUBSTITUTION THAT NOTHING REPORTS. +// +// The column names the sub-directory a multilib C library uses. On riscv, +// `/` separates every profile because `mabi` there IS the float +// ABI — `lp64d` and `lp64` are different values. On ARM `mabi` names the +// procedure call standard and is `aapcs` for both variants, while the float ABI +// lives in the triple's `eabi`/`eabihf` suffix. +// +// Measured while building `xim:picolibc-arm`: under the sibling convention the +// seven M-profile profiles collapsed into five directories, and +// `armv7e-m/aapcs/libc.a` came out carrying `Tag_ABI_HardFP_use` — the +// hard-float build, where a soft-float program would find it. Nothing failed. +// +// So the assertion is not "libdir is non-empty": it is that the soft and hard +// variants of ONE architecture name DIFFERENT directories, which is the +// property the layout exists to preserve. +TEST(FreestandingTarget, Arm32SoftAndHardVariantsNameDifferentLibdirs) { + std::map> byMarch; + int rows = 0; + for (const auto& spec : mcpp::freestanding::known()) { + if (!is_arm32(spec.triple)) continue; + ++rows; + EXPECT_FALSE(spec.libdir.empty()) + << spec.triple << " has no libdir, so an opt-in sysroot cannot be found"; + byMarch[std::string(spec.march)].push_back(std::string(spec.libdir)); + } + EXPECT_GT(rows, 0); + + int pairs = 0; + for (auto const& [march, dirs] : byMarch) { + if (dirs.size() < 2) continue; + ++pairs; + std::set distinct(dirs.begin(), dirs.end()); + EXPECT_EQ(distinct.size(), dirs.size()) + << march << " maps " << dirs.size() << " rows onto " + << distinct.size() << " directories: a soft-float program would " + "link a hard-float library, silently"; + } + // ⚠️ A denominator. If no architecture had two rows the loop above would + // assert nothing, and the property would be untested rather than held. + EXPECT_GT(pairs, 0) << "no architecture carries both float ABIs; the check " + "above quantified over nothing"; +}