Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 243 additions & 61 deletions .agents/docs/2026-08-21-freestanding-outstanding-four.md

Large diffs are not rendered by default.

65 changes: 57 additions & 8 deletions docs/13-baremetal.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,79 @@ covers the hosted link model this chapter departs from.
## Overview

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

| Triple | Tier | C library |
|---|---|---|
| `riscv64-none-elf` | verified | `xim:picolibc-riscv` |
| `riscv32-none-elf` | verified | `xim:picolibc-riscv` |
| `aarch64-none-elf` | preview | none — the zero-libc tier |
| `x86_64-none-elf` | preview | none — the zero-libc tier |

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

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

Such a target needs no per-host cross toolchain. clang and lld are
cross-compilers by construction — one binary emits every target it was built
with — so the target table pins `llvm@22.1.8` on every host, and any machine
that can install the LLVM payload can produce an image for any of the three.
that can install the LLVM payload can produce an image for any of the four.

### The x86_64 row is not four strings

⚠️ **A target row is normally an entry in two tables and nothing else. 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 links with `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`. The one thing that
does change it is putting `linux` in the OS position, which makes clang link
directly and adds eight host `-L` paths to a bare-metal link.

Neither outcome is acceptable: routing through a host `g++` makes the row work
on a Linux host and nowhere else, and host search paths on a freestanding link
are the hermeticity this engine exists to keep. So the row carries a fifth
column, `lldEmulation`, 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
(`-nostdlib++`, the loader tag) are dropped rather than translated.

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.

### `-mno-red-zone` is part of the target, not a preference

The System V x86-64 ABI reserves 128 bytes below `rsp` that a leaf function may
use without adjusting the stack pointer, because on a hosted system nothing else
writes there. On bare metal the processor pushes an interrupt frame at `rsp` —
into the red zone — and the interrupted leaf resumes to find its locals
overwritten. There is no fault and no diagnostic, and it happens only when an
interrupt arrives inside a leaf.

There is no bare-metal x86_64 program for which the red zone is safe, so the
flag is a property of the row rather than something a project remembers. It
reaches the command line through a new `extra` column in the ISA-profile table,
which exists because `-march`/`-mabi`/`-mcmodel` could not express it. RISC-V
and aarch64 have no equivalent, which is why the column did not exist before.

Three things a bare-metal build requires are not properties of the ISA, and
mcpp does not attempt to derive them: which startup object and libraries to
Expand Down
54 changes: 47 additions & 7 deletions docs/zh/13-baremetal.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,66 @@
## 概述

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

| Triple | 档位 | C 库 |
|---|---|---|
| `riscv64-none-elf` | verified | `xim:picolibc-riscv` |
| `riscv32-none-elf` | verified | `xim:picolibc-riscv` |
| `aarch64-none-elf` | preview | 无 —— 零 libc 档 |
| `x86_64-none-elf` | preview | 无 —— 零 libc 档 |

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

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

这类目标不需要逐宿主的交叉工具链。clang 与 lld 在构造上就是交叉编译器 ——
一个二进制发射它构建时支持的全部目标 —— 因此目标表在每个宿主上都钉
`llvm@22.1.8`,任何能安装 LLVM 载荷的机器都能为这三个中的任何一个产出镜像。
`llvm@22.1.8`,任何能安装 LLVM 载荷的机器都能为这四个中的任何一个产出镜像。

### x86_64 这一行不止是四个字符串

⚠️ **一个目标行通常就是两张表里的两条记录,不含引擎代码。这一行需要引擎代码,
而原因是 clang 的属性而非指令集的属性。**

clang 由 triple 选择工具链。它为 arm、aarch64、riscv 备有 *BareMetal* 工具链,
直接以 `ld.lld` 链接;它没有 x86_64 的,于是裸 x86_64 triple 的每一种写法都落到
通用 GCC 工具链上 —— 而后者的链接器是**宿主的 `g++`**:

```
g++: error: unrecognized command-line option '-fuse-ld=/…/llvm/22.1.8/bin/ld.lld'
```

对 `x86_64-none-elf`、`x86_64-unknown-none-elf`、`x86_64-unknown-none`、
`x86_64-elf`、`x86_64-none-none`、`x86_64-unknown-unknown` 逐一实测,结果一致;
`-fuse-ld=lld`、`--ld-path=`、`--gcc-toolchain=`、`-B` 逐一实测,均不改变结果。
唯一能改变它的是把 `linux` 放进 OS 位,而那会给一次裸机链接带来八条宿主 `-L`。

两种结果都不可接受:经宿主 `g++` 会让这一行只在 Linux 宿主上成立;宿主搜索路径
出现在 freestanding 链接上,正是本引擎存在的意义所要守住的封闭性。因此该行带了
第五列 `lldEmulation`,置位时引擎直接用 `ld.lld` 驱动链接。标志的词汇随工具一同
改变 —— `-Map=` 而非 `-Wl,-Map=`,`-m elf_x86_64` 而非 `--target=` —— 而仅属于
驱动的标志(`-nostdlib++`、加载器标签)是被丢弃而不是被翻译。

riscv 与 aarch64 两行的该列为空。它们的驱动本就到得了 lld,为了让三行看起来一致
而改动一条可用的链接,正是引入回归的方式。

### `-mno-red-zone` 是目标的属性,不是偏好

System V x86-64 ABI 在 `rsp` 之下保留 128 字节,叶函数可以不调整栈指针直接使用,
因为在有操作系统的机器上没有别的东西会写那里。裸机上不存在做这件事的一方:处理器
在 `rsp` 处压入中断帧 —— 正压进红区 —— 被中断的叶函数恢复后发现自己的局部变量已被
覆盖。既不触发异常也没有诊断,而且只在中断恰好落在叶函数内部时发生。

不存在红区安全的裸机 x86_64 程序,所以这个标志是行的属性,而不是工程需要记住的
事情。它经 ISA 档表新增的 `extra` 列到达命令行 —— 该列之所以存在,是因为
`-march`/`-mabi`/`-mcmodel` 表达不了它。RISC-V 与 aarch64 没有对应物,这也正是
此前不需要这一列的原因。

裸机构建需要的三样东西并不是 ISA 的属性,mcpp 也不试图推导它们:选哪个启动对象
与哪些库、哪份链接脚本描述这台机器的内存、以及如何执行产出的镜像。这三样随
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
version = "2026.8.20.3"
version = "2026.8.21.1"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
Expand Down
44 changes: 42 additions & 2 deletions src/build/flags.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ struct CompileFlags {
std::filesystem::path ccBinary; // gcc / clang (derived; cl.exe = same)
std::filesystem::path arBinary; // ar / llvm-ar / lib.exe (empty → PATH)
std::filesystem::path ldBinary; // link.exe (SeparateLinker dialects only)
// ⚠️ THE LINK IS DRIVEN BY THIS BINARY INSTEAD OF BY THE COMPILER, AND
// WHEN IT IS SET THE WHOLE FLAG VOCABULARY CHANGES WITH IT.
//
// Empty everywhere except a freestanding target whose row carries an
// `lldEmulation` — today `x86_64-none-elf`, where clang delegates the link
// to the host's `g++`. `ld`/`ldC` then hold LINKER flags rather than driver
// flags: `-Map=` and not `-Wl,-Map=`, `-m elf_x86_64` and not `--target=`.
// A caller that mixes the two gets a diagnostic from the linker naming a
// flag the reader never wrote.
std::filesystem::path ldDriver;
std::string sysroot; // --sysroot=... (for ninja ldflags)
std::string bFlag; // -B<binutils> (for ninja ldflags)
bool staticStdlib = true;
Expand Down Expand Up @@ -1327,8 +1337,22 @@ CompileFlags compute_flags(const BuildPlan& plan) {
// RISC-V firmware image with an x86-64 PT_INTERP baked in, which
// links clean and reports success. Measured, on this very change,
// before this line existed.
std::string fsLd = isClangWithCfg ? " --no-default-config" : "";
fsLd += mcpp::freestanding::link_flags(*spec, in, ninjaEsc);
// ⚠️ TWO SHAPES, AND WHICH ONE IS A PROPERTY OF THE TARGET'S ROW.
//
// With an `lldEmulation` the compiler driver is bypassed entirely,
// because for that target it would hand the link to a host `g++`
// that cannot take our linker's path — see the column's comment in
// mcpp.freestanding.target. `--no-default-config` is then not
// needed either: the payload's clang config file is a DRIVER
// config, and nothing here runs the driver.
std::string fsLd;
if (!spec->lldEmulation.empty()) {
f.ldDriver = in.lld;
fsLd = mcpp::freestanding::link_flags_direct(*spec, in, ninjaEsc);
} else {
fsLd = isClangWithCfg ? " --no-default-config" : "";
fsLd += mcpp::freestanding::link_flags(*spec, in, ninjaEsc);
}
f.ld = fsLd + user_ldflags;
f.ldC = f.ld;
// Nothing hosted survives: these carry payload/sysroot/-B flags
Expand All @@ -1337,6 +1361,22 @@ CompileFlags compute_flags(const BuildPlan& plan) {
f.bFlag.clear();
f.ldRuntimeFallback.clear();
f.linkage = "static";
// ⚠️ WHEN THE LINKER IS DRIVEN DIRECTLY, EVERY REMAINING DRIVER
// FLAG IS AN ERROR RATHER THAN A NO-OP. Measured on the first
// attempt, where two survived the freestanding replacement and
// reached `ld.lld`:
//
// ld.lld: error: unknown argument '-nostdlib++'
// ld.lld: error: unknown argument '-Wl,--disable-new-dtags'
//
// The first is the per-role C++ runtime contract; the second is the
// loader tag, which names a dynamic-section entry an image with no
// loader does not have. Both are meaningful to a compiler driver
// and meaningless here, so they are cleared rather than translated.
if (!f.ldDriver.empty()) {
for (auto& r : f.ldStdlibByRole) r.clear();
for (auto& r : f.ldStdlibCByRole) r.clear();
}
}
}

Expand Down
69 changes: 63 additions & 6 deletions src/build/ninja_backend.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,11 @@ std::string emit_ninja_string(const BuildPlan& plan) {
flags.ldBinary.empty() ? std::string("link.exe")
: escape_ninja_path(flags.ldBinary)));
}
// The linker itself, for a freestanding target whose compiler driver would
// hand the link to a foreign one. Bound only when set, so a graph that does
// not use it has no variable naming a tool it never runs.
if (!flags.ldDriver.empty())
append(std::format("ld_driver = {}\n", escape_ninja_path(flags.ldDriver)));
// `$mcpp` is needed by stage_file in EVERY configuration (dyndep or not),
// so the binding cannot live inside the `if (dyndep)` below.
append(std::format("mcpp = {}\n", escape_ninja_path(mcpp_exe_path())));
Expand Down Expand Up @@ -1152,6 +1157,19 @@ std::string emit_ninja_string(const BuildPlan& plan) {
"$ld /nologo /DLL /OUT:$out $implib_flag $def_flag "
"$in $ldflags $unit_ldflags",
"SHARED");
} else if (!flags.ldDriver.empty()) {
// ⚠️ THE LINKER, NOT THE DRIVER, AND THE OBJECTS COME LAST.
//
// A compiler driver accepts objects anywhere on the line and sorts
// them out; `ld` resolves left to right, so a library named before
// the object that needs it contributes nothing and the link fails
// on a symbol that is present. This shape is only reached by a
// freestanding target whose row carries an `lldEmulation`.
link_rule("cxx_link",
"$ld_driver $ldflags $unit_ldflags -o $out $in", "LINK");
link_rule("cxx_archive", std::string(dial.archiveCmd), "AR");
link_rule("c_link",
"$ld_driver $c_ldflags $unit_ldflags -o $out $in", "LINK");
} else {
link_rule("cxx_link",
"$cxx $in -o $out $ldflags $unit_ldflags", "LINK");
Expand Down Expand Up @@ -2008,14 +2026,26 @@ std::string emit_ninja_string(const BuildPlan& plan) {
// in every graph that builds a static library.
if (lu.kind != LinkUnit::StaticLibrary)
tail.runtimeFallback = flags.ldRuntimeFallback;
tail.loaderTag = lu.loaderTagFlag;
// ⚠️ NOT ON A DIRECT LINK. The tag selects between `DT_RPATH`
// and `DT_RUNPATH`, entries of a dynamic section; an image with no
// loader has neither, and `ld.lld` rejects the flag's `-Wl,` form
// outright.
if (flags.ldDriver.empty()) tail.loaderTag = lu.loaderTagFlag;
// The link map — per unit, because it is named after the artifact.
if (!fsObjcopy.empty()
&& (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary))
tail.dependencies += mcpp::freestanding::map_flag(
lu.output, [](const std::filesystem::path& q) {
return escape_ninja_path(q);
});
&& (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary)) {
// ⚠️ `-Map=` OR `-Wl,-Map=`, DECIDED BY WHO IS BEING SPOKEN TO.
// With `ldDriver` set the link is `ld.lld` itself, and `-Wl,`
// is a driver's way of saying "pass this on" — handed to the
// linker it is an unknown option, on the one target where the
// map matters most.
auto esc = [](const std::filesystem::path& q) {
return escape_ninja_path(q);
};
tail.dependencies += flags.ldDriver.empty()
? mcpp::freestanding::map_flag(lu.output, esc)
: mcpp::freestanding::map_flag_direct(lu.output, esc);
}
if (auto unit = tail.render(); !unit.empty())
out_line += " unit_ldflags =" + unit + "\n";
}
Expand Down Expand Up @@ -2276,6 +2306,33 @@ std::expected<BuildResult, BuildError> NinjaBackend::build(const BuildPlan& plan
"compile_commands.json was not updated: {}", cdb.error().message));
}

// ⚠️ A SHARED LIBRARY ON A TARGET WHOSE LINK IS DRIVEN BY THE LINKER, SAID
// IN WORDS RATHER THAN AS A MISSING NINJA RULE.
//
// The direct-linker path defines `cxx_link`, `c_link` and `cxx_archive` and
// no shared-library rule, because a target with no loader has nothing to
// load one. Without this check the failure is
//
// ninja: error: build.ninja:88: unknown build rule 'cxx_shared'
//
// which names an internal rule to somebody who wrote `kind = "shared"` in a
// manifest. Defining the rule would be worse: `ld.lld -shared` succeeds and
// produces an object nothing on that machine can load.
if (!flags.ldDriver.empty()) {
for (auto const& lu : plan.linkUnits) {
if (lu.kind != LinkUnit::SharedLibrary) continue;
return std::unexpected(BuildError{std::format(
"target '{}' is a shared library, and '{}' has no dynamic "
"loader to load one.\n"
" A freestanding image is linked statically because there is "
"no other option: nothing on that machine resolves a symbol at "
"run time.\n"
" Use `kind = \"lib\"` for a static library, or `kind = "
"\"bin\"` for the image itself.",
lu.targetName, plan.toolchain.targetTriple)});
}
}

// A distribution contract that could not be honored is reported, never
// silently downgraded — the whole point of the model (INV-1/INV-4 in
// .agents/docs/2026-08-02-issue336-pr142-analysis.md). Emitted here rather
Expand Down
Loading
Loading