Skip to content

Commit a5db8a6

Browse files
feat(freestanding): x86_64-none-elf,以及驱动拒绝执行的那次链接 (#472)
* feat(freestanding): x86_64-none-elf, and the link that the driver refuses A target row is normally two table entries. This one needed engine code, and the reason is a property of clang rather than of the instruction set. clang selects a toolchain from the triple. It has a BareMetal toolchain for arm, aarch64 and riscv, which reaches `ld.lld` directly; it has none for x86_64, so every spelling of a bare x86_64 triple falls through to the generic GCC toolchain, whose linker is the HOST'S `g++`: g++: error: unrecognized command-line option '-fuse-ld=/…/llvm/22.1.8/bin/ld.lld' Measured for `x86_64-none-elf`, `x86_64-unknown-none-elf`, `x86_64-unknown-none`, `x86_64-elf`, `x86_64-none-none` and `x86_64-unknown-unknown`, and unchanged by `-fuse-ld=lld`, `--ld-path=`, `--gcc-toolchain=` or `-B`. Only putting `linux` in the OS position changes it, and that adds eight host `-L` paths to a bare-metal link. Neither outcome is acceptable: a host `g++` makes the row work on one host and nowhere else, and host search paths on a freestanding link are the hermeticity this engine exists to keep. So the ISA-profile row carries an `lldEmulation` column, and when it is set the engine drives the link with `ld.lld` itself. The flag vocabulary changes with the tool — `-Map=` rather than `-Wl,-Map=`, `-m elf_x86_64` rather than `--target=` — and the driver-only flags are dropped rather than translated, because the first attempt left two behind: ld.lld: error: unknown argument '-nostdlib++' ld.lld: error: unknown argument '-Wl,--disable-new-dtags' The column is empty for the riscv and aarch64 rows: their driver already reaches lld, and changing a working link to make three rows look alike is how a regression is introduced. Their link lines are byte-identical to before. A second new column, `extra`, carries `-mno-red-zone`. That is not a preference either. The System V ABI's 128-byte red zone is safe on a hosted system because the kernel switches stacks for interrupts; on bare metal the processor pushes the interrupt frame into it, and the interrupted leaf function resumes with its locals overwritten — no fault, no diagnostic, and only when an interrupt happens to land inside a leaf. There is no bare-metal x86_64 program for which the red zone is safe. Verified: `tests/e2e/137_x86_64_zero_libc_target.sh` asserts the image is x86-64, loads where the script says, has zero undefined symbols, names no dynamic loader, produced a link map through a linker-native flag, went through `ld.lld`, and carries `-march=x86-64` and `-mno-red-zone`. The 92 unit tests pass unchanged. Also verified outside this repository: openarch's probe — one source file — builds and runs on riscv64, aarch64 and x86_64 with byte-identical output. That is the first evidence its interface abstracts rather than merely fits two similar RISC machines. * docs(plan): 第 3、4 节按 0.4.0 与 2026.8.21.1 的实际落地修订 ⚠️ 两处本方案原先判断错了,记在文档里而不是悄悄改掉: 1. §4 断言 x86_64 目标行「不是代码,是模拟器载荷」。实测下来它需要引擎代码 —— clang 没有 x86_64 的 BareMetal 工具链,于是每一种裸 x86_64 triple 的链接都落到 宿主的 g++ 上。 2. §3 给的目录树是「根为虚拟 workspace、接口在 spec/ 成员里」。那个形状会让根上的 跨编译对所有成员扇出,把 aarch64 汇编喂给 riscv 汇编器。混合式的根修好了它。 以及一处排序错误:第 5 项(x86_64)原先排在最后,理由是它被外部依赖卡住。它却是 唯一一项改变了对已完成工作之信心的 —— 第三台机器挖出三条两台 RISC 机器合起来也 看不见的东西。「被卡住」和「价值低」在列表上看起来一样,而它们不是一回事。 * fix(features): `mcpp test` compiled sources a feature had not activated Same package, same manifest, two commands, different source sets: mcpp build --target riscv64-none-elf skips src/kal/** (correct) mcpp test --target riscv64-none-elf compiles it, and dies on 'openkal/abort.h' file not found The header arrives through that feature's `[feature-deps]`; with the feature inactive it is not there, and neither are the files that include it. ⭐ TWO FAMILIES REACH THIS CODE AND THEY WANT OPPOSITE THINGS. gtest lists `*/googletest/src/gtest_main.cc` in base `sources` AND under `features.main`. The package provides the file unconditionally; the feature is a gate over it, and the dev-dependency track's per-test main detection must SEE it in order to prune it per test. riscv-virt-rt names `src/kal/**` under `features.openkal` and nowhere else. Those files are not part of the package without the feature. The engine gated the whole exclusion on "is this a test build", which is right for the first family and wrong for the second. ⚠️ THIS IS THE FOURTH ATTEMPT, AND THE THIRD WAS ABANDONED ON A MISTAKEN READING. It was recorded as failing because "gtest's base entry is a glob that MATCHES the file rather than the same string". Measured against the descriptor the index actually carries — `compat.gtest.lua` lines 71-73 and 90 — the two entries are byte-identical. The reason had been written from memory without opening the file. What actually defeated that attempt was WHEN the criterion was applied: it tested membership against `bc.sources` after `drop()` had already removed the entry, so the test could only ever be false. The fix snapshots the base globs BEFORE the drop and uses them only in test mode: a glob in both places is a gate and stays visible; a glob in one place is a provider and gets the `!` exclusion. The exclusion is the whole mechanism — `src/kal/**` is never in `bc.sources` at all, because that package declares no `sources` and its files are matched by the inferred `src/**`, so erasing the string erases nothing. Verified, all three of the plan's criteria: 1. riscv-virt-rt: `mcpp test` no longer compiles src/kal/**; both commands exit 0. 2. mcpp's own 92 unit tests still link and pass (the gtest family). 3. tests/e2e/138_feature_sources_gate_vs_provider.sh covers both families with two minimal packages and no ecosystem dependency. ⭐ (3) was checked the way a regression test has to be: run against the engine before this commit it fails with exactly the symptom above; against the engine after it, it passes. The defect survived this long because no test covered both families at once, and each of the three earlier attempts broke the one it did not cover. * fix(freestanding): 直连链接下的共享库,用话说清楚而不是丢一条 ninja 规则 直连链接路径定义 cxx_link / c_link / cxx_archive,不定义共享库规则 —— 没有加载器 的目标没有东西去加载它。缺这条检查时,失败长这样: ninja: error: build.ninja:88: unknown build rule 'cxx_shared' 对一个在清单里写下 kind = "shared" 的人,这句话指的是一条内部规则。 ⚠️ 补上那条规则比省掉它更糟:ld.lld -shared 会成功,产出一个那台机器上没有任何 东西能加载的对象 —— 静默的无意义,而不是响亮的拒绝。 e2e 137 加了第 7 条断言,同时检查两件事:拒绝时提到了原因,且**没有**提到内部 规则名。 --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
1 parent a278f25 commit a5db8a6

13 files changed

Lines changed: 998 additions & 86 deletions

.agents/docs/2026-08-21-freestanding-outstanding-four.md

Lines changed: 243 additions & 61 deletions
Large diffs are not rendered by default.

docs/13-baremetal.md

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,79 @@ covers the hosted link model this chapter departs from.
1313
## Overview
1414

1515
A freestanding target is a target whose `os` field is `none`. The target table
16-
at `src/toolchain/triple.cppm` carries three of them:
16+
at `src/toolchain/triple.cppm` carries four of them:
1717

1818
| Triple | Tier | C library |
1919
|---|---|---|
2020
| `riscv64-none-elf` | verified | `xim:picolibc-riscv` |
2121
| `riscv32-none-elf` | verified | `xim:picolibc-riscv` |
2222
| `aarch64-none-elf` | preview | none — the zero-libc tier |
23+
| `x86_64-none-elf` | preview | none — the zero-libc tier |
2324

2425
`verified` means an image has been built **and run** for the row. `preview`
2526
means it builds and has been observed to run, but is not yet covered by the
2627
engine's own emulator jobs.
2728

28-
⚠️ The third row's C library column is empty, and that is a statement rather
29-
than an omission: no aarch64 build of picolibc exists in the package index, and
30-
the first consumer of that row — the `openarch` layer of machine mechanism —
31-
references no C library symbol. An empty column means exactly what
32-
`[target.<triple>].sysroot = ""` means in a manifest, so a project targeting it
29+
⚠️ The last two rows' C library column is empty, and that is a statement rather
30+
than an omission: no aarch64 or x86_64 build of picolibc exists in the package
31+
index, and the first consumer of both rows — the `openarch` layer of machine
32+
mechanism — references no C library symbol. An empty column means exactly what
33+
`[target.<triple>].sysroot = ""` means in a manifest, so a project targeting one
3334
begins on the zero-libc tier without asking. A project that wants a C library on
34-
that target declares one, which is also how it would choose a different one.
35+
those targets declares one, which is also how it would choose a different one.
3536

3637
Such a target needs no per-host cross toolchain. clang and lld are
3738
cross-compilers by construction — one binary emits every target it was built
3839
with — so the target table pins `llvm@22.1.8` on every host, and any machine
39-
that can install the LLVM payload can produce an image for any of the three.
40+
that can install the LLVM payload can produce an image for any of the four.
41+
42+
### The x86_64 row is not four strings
43+
44+
⚠️ **A target row is normally an entry in two tables and nothing else. This one
45+
needed engine code, and the reason is a property of clang rather than of the
46+
instruction set.**
47+
48+
clang selects a toolchain from the triple. It has a *BareMetal* toolchain for
49+
arm, aarch64 and riscv, which links with `ld.lld` directly; it has none for
50+
x86_64, so every spelling of a bare x86_64 triple falls through to the generic
51+
GCC toolchain — whose linker is the **host's `g++`**:
52+
53+
```
54+
g++: error: unrecognized command-line option '-fuse-ld=/…/llvm/22.1.8/bin/ld.lld'
55+
```
56+
57+
Measured for `x86_64-none-elf`, `x86_64-unknown-none-elf`, `x86_64-unknown-none`,
58+
`x86_64-elf`, `x86_64-none-none` and `x86_64-unknown-unknown`, and unchanged by
59+
`-fuse-ld=lld`, `--ld-path=`, `--gcc-toolchain=` or `-B`. The one thing that
60+
does change it is putting `linux` in the OS position, which makes clang link
61+
directly and adds eight host `-L` paths to a bare-metal link.
62+
63+
Neither outcome is acceptable: routing through a host `g++` makes the row work
64+
on a Linux host and nowhere else, and host search paths on a freestanding link
65+
are the hermeticity this engine exists to keep. So the row carries a fifth
66+
column, `lldEmulation`, and when it is set the engine drives the link with
67+
`ld.lld` itself. The flag vocabulary changes with the tool — `-Map=` rather than
68+
`-Wl,-Map=`, `-m elf_x86_64` rather than `--target=` — and the driver-only flags
69+
(`-nostdlib++`, the loader tag) are dropped rather than translated.
70+
71+
The column is empty for the riscv and aarch64 rows. Their driver already reaches
72+
lld, and changing a working link to make three rows look alike is how a
73+
regression is introduced.
74+
75+
### `-mno-red-zone` is part of the target, not a preference
76+
77+
The System V x86-64 ABI reserves 128 bytes below `rsp` that a leaf function may
78+
use without adjusting the stack pointer, because on a hosted system nothing else
79+
writes there. On bare metal the processor pushes an interrupt frame at `rsp`
80+
into the red zone — and the interrupted leaf resumes to find its locals
81+
overwritten. There is no fault and no diagnostic, and it happens only when an
82+
interrupt arrives inside a leaf.
83+
84+
There is no bare-metal x86_64 program for which the red zone is safe, so the
85+
flag is a property of the row rather than something a project remembers. It
86+
reaches the command line through a new `extra` column in the ISA-profile table,
87+
which exists because `-march`/`-mabi`/`-mcmodel` could not express it. RISC-V
88+
and aarch64 have no equivalent, which is why the column did not exist before.
4089

4190
Three things a bare-metal build requires are not properties of the ISA, and
4291
mcpp does not attempt to derive them: which startup object and libraries to

docs/zh/13-baremetal.md

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,66 @@
1111
## 概述
1212

1313
freestanding 目标是 `os` 字段为 `none` 的目标。`src/toolchain/triple.cppm`
14-
的目标表中有三个:
14+
的目标表中有四个:
1515

1616
| Triple | 档位 | C 库 |
1717
|---|---|---|
1818
| `riscv64-none-elf` | verified | `xim:picolibc-riscv` |
1919
| `riscv32-none-elf` | verified | `xim:picolibc-riscv` |
2020
| `aarch64-none-elf` | preview | 无 —— 零 libc 档 |
21+
| `x86_64-none-elf` | preview | 无 —— 零 libc 档 |
2122

2223
`verified` 意味着该行的镜像被构建**并被运行**过。`preview` 意味着它构建得出、
2324
也被观察到能运行,但尚未纳入引擎自己的模拟器作业。
2425

25-
⚠️ 第三行的 C 库列为空,这是声明而非遗漏:包索引里不存在 aarch64 的 picolibc
26-
构建,而这一行的第一个消费者 —— 机器机制层 `openarch` —— 一个 C 库符号都不引用。
27-
空列在这里的含义与清单里 `[target.<triple>].sysroot = ""` 完全一致,因此面向它的
28-
工程无需声明即处在零 libc 档。想要 C 库的工程自行声明一个,而那也正是它换用另一份
29-
C 库的做法。
26+
⚠️ 后两行的 C 库列为空,这是声明而非遗漏:包索引里不存在 aarch64 与 x86_64 的
27+
picolibc 构建,而这两行的第一个消费者 —— 机器机制层 `openarch` —— 一个 C 库符号
28+
都不引用。空列在这里的含义与清单里 `[target.<triple>].sysroot = ""` 完全一致,
29+
因此面向它们的工程无需声明即处在零 libc 档。想要 C 库的工程自行声明一个,而那也
30+
正是它换用另一份 C 库的做法。
3031

3132
这类目标不需要逐宿主的交叉工具链。clang 与 lld 在构造上就是交叉编译器 ——
3233
一个二进制发射它构建时支持的全部目标 —— 因此目标表在每个宿主上都钉
33-
`llvm@22.1.8`,任何能安装 LLVM 载荷的机器都能为这三个中的任何一个产出镜像。
34+
`llvm@22.1.8`,任何能安装 LLVM 载荷的机器都能为这四个中的任何一个产出镜像。
35+
36+
### x86_64 这一行不止是四个字符串
37+
38+
⚠️ **一个目标行通常就是两张表里的两条记录,不含引擎代码。这一行需要引擎代码,
39+
而原因是 clang 的属性而非指令集的属性。**
40+
41+
clang 由 triple 选择工具链。它为 arm、aarch64、riscv 备有 *BareMetal* 工具链,
42+
直接以 `ld.lld` 链接;它没有 x86_64 的,于是裸 x86_64 triple 的每一种写法都落到
43+
通用 GCC 工具链上 —— 而后者的链接器是**宿主的 `g++`**:
44+
45+
```
46+
g++: error: unrecognized command-line option '-fuse-ld=/…/llvm/22.1.8/bin/ld.lld'
47+
```
48+
49+
`x86_64-none-elf``x86_64-unknown-none-elf``x86_64-unknown-none`
50+
`x86_64-elf``x86_64-none-none``x86_64-unknown-unknown` 逐一实测,结果一致;
51+
`-fuse-ld=lld``--ld-path=``--gcc-toolchain=``-B` 逐一实测,均不改变结果。
52+
唯一能改变它的是把 `linux` 放进 OS 位,而那会给一次裸机链接带来八条宿主 `-L`
53+
54+
两种结果都不可接受:经宿主 `g++` 会让这一行只在 Linux 宿主上成立;宿主搜索路径
55+
出现在 freestanding 链接上,正是本引擎存在的意义所要守住的封闭性。因此该行带了
56+
第五列 `lldEmulation`,置位时引擎直接用 `ld.lld` 驱动链接。标志的词汇随工具一同
57+
改变 —— `-Map=` 而非 `-Wl,-Map=`,`-m elf_x86_64` 而非 `--target=` —— 而仅属于
58+
驱动的标志(`-nostdlib++`、加载器标签)是被丢弃而不是被翻译。
59+
60+
riscv 与 aarch64 两行的该列为空。它们的驱动本就到得了 lld,为了让三行看起来一致
61+
而改动一条可用的链接,正是引入回归的方式。
62+
63+
### `-mno-red-zone` 是目标的属性,不是偏好
64+
65+
System V x86-64 ABI 在 `rsp` 之下保留 128 字节,叶函数可以不调整栈指针直接使用,
66+
因为在有操作系统的机器上没有别的东西会写那里。裸机上不存在做这件事的一方:处理器
67+
`rsp` 处压入中断帧 —— 正压进红区 —— 被中断的叶函数恢复后发现自己的局部变量已被
68+
覆盖。既不触发异常也没有诊断,而且只在中断恰好落在叶函数内部时发生。
69+
70+
不存在红区安全的裸机 x86_64 程序,所以这个标志是行的属性,而不是工程需要记住的
71+
事情。它经 ISA 档表新增的 `extra` 列到达命令行 —— 该列之所以存在,是因为
72+
`-march`/`-mabi`/`-mcmodel` 表达不了它。RISC-V 与 aarch64 没有对应物,这也正是
73+
此前不需要这一列的原因。
3474

3575
裸机构建需要的三样东西并不是 ISA 的属性,mcpp 也不试图推导它们:选哪个启动对象
3676
与哪些库、哪份链接脚本描述这台机器的内存、以及如何执行产出的镜像。这三样随

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "2026.8.20.3"
3+
version = "2026.8.21.1"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

src/build/flags.cppm

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ struct CompileFlags {
5959
std::filesystem::path ccBinary; // gcc / clang (derived; cl.exe = same)
6060
std::filesystem::path arBinary; // ar / llvm-ar / lib.exe (empty → PATH)
6161
std::filesystem::path ldBinary; // link.exe (SeparateLinker dialects only)
62+
// ⚠️ THE LINK IS DRIVEN BY THIS BINARY INSTEAD OF BY THE COMPILER, AND
63+
// WHEN IT IS SET THE WHOLE FLAG VOCABULARY CHANGES WITH IT.
64+
//
65+
// Empty everywhere except a freestanding target whose row carries an
66+
// `lldEmulation` — today `x86_64-none-elf`, where clang delegates the link
67+
// to the host's `g++`. `ld`/`ldC` then hold LINKER flags rather than driver
68+
// flags: `-Map=` and not `-Wl,-Map=`, `-m elf_x86_64` and not `--target=`.
69+
// A caller that mixes the two gets a diagnostic from the linker naming a
70+
// flag the reader never wrote.
71+
std::filesystem::path ldDriver;
6272
std::string sysroot; // --sysroot=... (for ninja ldflags)
6373
std::string bFlag; // -B<binutils> (for ninja ldflags)
6474
bool staticStdlib = true;
@@ -1327,8 +1337,22 @@ CompileFlags compute_flags(const BuildPlan& plan) {
13271337
// RISC-V firmware image with an x86-64 PT_INTERP baked in, which
13281338
// links clean and reports success. Measured, on this very change,
13291339
// before this line existed.
1330-
std::string fsLd = isClangWithCfg ? " --no-default-config" : "";
1331-
fsLd += mcpp::freestanding::link_flags(*spec, in, ninjaEsc);
1340+
// ⚠️ TWO SHAPES, AND WHICH ONE IS A PROPERTY OF THE TARGET'S ROW.
1341+
//
1342+
// With an `lldEmulation` the compiler driver is bypassed entirely,
1343+
// because for that target it would hand the link to a host `g++`
1344+
// that cannot take our linker's path — see the column's comment in
1345+
// mcpp.freestanding.target. `--no-default-config` is then not
1346+
// needed either: the payload's clang config file is a DRIVER
1347+
// config, and nothing here runs the driver.
1348+
std::string fsLd;
1349+
if (!spec->lldEmulation.empty()) {
1350+
f.ldDriver = in.lld;
1351+
fsLd = mcpp::freestanding::link_flags_direct(*spec, in, ninjaEsc);
1352+
} else {
1353+
fsLd = isClangWithCfg ? " --no-default-config" : "";
1354+
fsLd += mcpp::freestanding::link_flags(*spec, in, ninjaEsc);
1355+
}
13321356
f.ld = fsLd + user_ldflags;
13331357
f.ldC = f.ld;
13341358
// Nothing hosted survives: these carry payload/sysroot/-B flags
@@ -1337,6 +1361,22 @@ CompileFlags compute_flags(const BuildPlan& plan) {
13371361
f.bFlag.clear();
13381362
f.ldRuntimeFallback.clear();
13391363
f.linkage = "static";
1364+
// ⚠️ WHEN THE LINKER IS DRIVEN DIRECTLY, EVERY REMAINING DRIVER
1365+
// FLAG IS AN ERROR RATHER THAN A NO-OP. Measured on the first
1366+
// attempt, where two survived the freestanding replacement and
1367+
// reached `ld.lld`:
1368+
//
1369+
// ld.lld: error: unknown argument '-nostdlib++'
1370+
// ld.lld: error: unknown argument '-Wl,--disable-new-dtags'
1371+
//
1372+
// The first is the per-role C++ runtime contract; the second is the
1373+
// loader tag, which names a dynamic-section entry an image with no
1374+
// loader does not have. Both are meaningful to a compiler driver
1375+
// and meaningless here, so they are cleared rather than translated.
1376+
if (!f.ldDriver.empty()) {
1377+
for (auto& r : f.ldStdlibByRole) r.clear();
1378+
for (auto& r : f.ldStdlibCByRole) r.clear();
1379+
}
13401380
}
13411381
}
13421382

src/build/ninja_backend.cppm

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,11 @@ std::string emit_ninja_string(const BuildPlan& plan) {
633633
flags.ldBinary.empty() ? std::string("link.exe")
634634
: escape_ninja_path(flags.ldBinary)));
635635
}
636+
// The linker itself, for a freestanding target whose compiler driver would
637+
// hand the link to a foreign one. Bound only when set, so a graph that does
638+
// not use it has no variable naming a tool it never runs.
639+
if (!flags.ldDriver.empty())
640+
append(std::format("ld_driver = {}\n", escape_ninja_path(flags.ldDriver)));
636641
// `$mcpp` is needed by stage_file in EVERY configuration (dyndep or not),
637642
// so the binding cannot live inside the `if (dyndep)` below.
638643
append(std::format("mcpp = {}\n", escape_ninja_path(mcpp_exe_path())));
@@ -1152,6 +1157,19 @@ std::string emit_ninja_string(const BuildPlan& plan) {
11521157
"$ld /nologo /DLL /OUT:$out $implib_flag $def_flag "
11531158
"$in $ldflags $unit_ldflags",
11541159
"SHARED");
1160+
} else if (!flags.ldDriver.empty()) {
1161+
// ⚠️ THE LINKER, NOT THE DRIVER, AND THE OBJECTS COME LAST.
1162+
//
1163+
// A compiler driver accepts objects anywhere on the line and sorts
1164+
// them out; `ld` resolves left to right, so a library named before
1165+
// the object that needs it contributes nothing and the link fails
1166+
// on a symbol that is present. This shape is only reached by a
1167+
// freestanding target whose row carries an `lldEmulation`.
1168+
link_rule("cxx_link",
1169+
"$ld_driver $ldflags $unit_ldflags -o $out $in", "LINK");
1170+
link_rule("cxx_archive", std::string(dial.archiveCmd), "AR");
1171+
link_rule("c_link",
1172+
"$ld_driver $c_ldflags $unit_ldflags -o $out $in", "LINK");
11551173
} else {
11561174
link_rule("cxx_link",
11571175
"$cxx $in -o $out $ldflags $unit_ldflags", "LINK");
@@ -2008,14 +2026,26 @@ std::string emit_ninja_string(const BuildPlan& plan) {
20082026
// in every graph that builds a static library.
20092027
if (lu.kind != LinkUnit::StaticLibrary)
20102028
tail.runtimeFallback = flags.ldRuntimeFallback;
2011-
tail.loaderTag = lu.loaderTagFlag;
2029+
// ⚠️ NOT ON A DIRECT LINK. The tag selects between `DT_RPATH`
2030+
// and `DT_RUNPATH`, entries of a dynamic section; an image with no
2031+
// loader has neither, and `ld.lld` rejects the flag's `-Wl,` form
2032+
// outright.
2033+
if (flags.ldDriver.empty()) tail.loaderTag = lu.loaderTagFlag;
20122034
// The link map — per unit, because it is named after the artifact.
20132035
if (!fsObjcopy.empty()
2014-
&& (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary))
2015-
tail.dependencies += mcpp::freestanding::map_flag(
2016-
lu.output, [](const std::filesystem::path& q) {
2017-
return escape_ninja_path(q);
2018-
});
2036+
&& (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary)) {
2037+
// ⚠️ `-Map=` OR `-Wl,-Map=`, DECIDED BY WHO IS BEING SPOKEN TO.
2038+
// With `ldDriver` set the link is `ld.lld` itself, and `-Wl,`
2039+
// is a driver's way of saying "pass this on" — handed to the
2040+
// linker it is an unknown option, on the one target where the
2041+
// map matters most.
2042+
auto esc = [](const std::filesystem::path& q) {
2043+
return escape_ninja_path(q);
2044+
};
2045+
tail.dependencies += flags.ldDriver.empty()
2046+
? mcpp::freestanding::map_flag(lu.output, esc)
2047+
: mcpp::freestanding::map_flag_direct(lu.output, esc);
2048+
}
20192049
if (auto unit = tail.render(); !unit.empty())
20202050
out_line += " unit_ldflags =" + unit + "\n";
20212051
}
@@ -2276,6 +2306,33 @@ std::expected<BuildResult, BuildError> NinjaBackend::build(const BuildPlan& plan
22762306
"compile_commands.json was not updated: {}", cdb.error().message));
22772307
}
22782308

2309+
// ⚠️ A SHARED LIBRARY ON A TARGET WHOSE LINK IS DRIVEN BY THE LINKER, SAID
2310+
// IN WORDS RATHER THAN AS A MISSING NINJA RULE.
2311+
//
2312+
// The direct-linker path defines `cxx_link`, `c_link` and `cxx_archive` and
2313+
// no shared-library rule, because a target with no loader has nothing to
2314+
// load one. Without this check the failure is
2315+
//
2316+
// ninja: error: build.ninja:88: unknown build rule 'cxx_shared'
2317+
//
2318+
// which names an internal rule to somebody who wrote `kind = "shared"` in a
2319+
// manifest. Defining the rule would be worse: `ld.lld -shared` succeeds and
2320+
// produces an object nothing on that machine can load.
2321+
if (!flags.ldDriver.empty()) {
2322+
for (auto const& lu : plan.linkUnits) {
2323+
if (lu.kind != LinkUnit::SharedLibrary) continue;
2324+
return std::unexpected(BuildError{std::format(
2325+
"target '{}' is a shared library, and '{}' has no dynamic "
2326+
"loader to load one.\n"
2327+
" A freestanding image is linked statically because there is "
2328+
"no other option: nothing on that machine resolves a symbol at "
2329+
"run time.\n"
2330+
" Use `kind = \"lib\"` for a static library, or `kind = "
2331+
"\"bin\"` for the image itself.",
2332+
lu.targetName, plan.toolchain.targetTriple)});
2333+
}
2334+
}
2335+
22792336
// A distribution contract that could not be honored is reported, never
22802337
// silently downgraded — the whole point of the model (INV-1/INV-4 in
22812338
// .agents/docs/2026-08-02-issue336-pr142-analysis.md). Emitted here rather

0 commit comments

Comments
 (0)