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
1,177 changes: 1,177 additions & 0 deletions .agents/docs/2026-08-18-freestanding-baremetal-analysis.md

Large diffs are not rendered by default.

358 changes: 358 additions & 0 deletions .agents/docs/2026-08-19-baremetal-ecosystem-closure-plan.md

Large diffs are not rendered by default.

2,576 changes: 2,576 additions & 0 deletions .agents/docs/2026-08-19-freestanding-baremetal-design.md

Large diffs are not rendered by default.

308 changes: 308 additions & 0 deletions .agents/docs/2026-08-19-freestanding-baremetal-implementation-plan.md

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions .github/workflows/ci-linux-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,97 @@ jobs:
"$MCPP" toolchain install gcc 16.1.0-musl
bash tests/e2e/run_all.sh

# ──────────────────────────────────────────────────────────────────
# Bare metal: the one chain the sharded suite above cannot be trusted
# to exercise.
#
# tests/e2e/130_freestanding_riscv_build_and_run.sh declares
# `# requires: qemu-riscv`, which is legitimately absent on the macOS and
# Windows runners — so it must be a SOFT token, and a soft token means the
# test skips in silence on a Linux runner that lost qemu too. That is the
# exact shape this repository has been burned by twice (65_* never ran at
# all; ten pack e2e skipped on two platforms), and no token can tell the two
# cases apart.
#
# So the guard lives here, where it can be exact: install qemu, run the one
# test, and assert its PASS line appeared. A skip fails this job.
# ──────────────────────────────────────────────────────────────────
baremetal:
name: bare-metal e2e (riscv64-none-elf, qemu)
runs-on: ubuntu-24.04
timeout-minutes: 40
env:
MCPP_HOME: /home/runner/.mcpp
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/bootstrap-mcpp

- name: Build mcpp from source (self-host)
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
"$XLINGS_BIN" config --mirror GLOBAL 2>/dev/null || true
"$MCPP" self config --mirror GLOBAL 2>/dev/null || true
"$MCPP" build

- name: Install the emulator (xim:qemu-riscv)
run: |
# ⚠️ BOTH homes. The shim on PATH dispatches against whichever home
# owns it, and `mcpp run` runs the runner through that shim — so an
# emulator installed only in the ambient xlings home answers
# "xlings: 'qemu-system-riscv64' is not installed" when mcpp asks.
# Measured: the job installed it once, the shim resolved, and the
# run still failed.
"$XLINGS_BIN" install xim:qemu-riscv -y
XLINGS_HOME="${MCPP_HOME:-$HOME/.mcpp}/registry" \
"$XLINGS_BIN" install xim:qemu-riscv -y
# Assert it is reachable AND runnable BEFORE the tests. Without this
# the capability probe simply would not add `qemu-riscv` and the
# tests would skip — which is what this job exists to prevent.
command -v qemu-system-riscv64
qemu-system-riscv64 --version | head -1
# The target sysroot, into the home MCPP uses. Test 131's BSP
# declares it as an `[xlings] deps` entry and finds it through
# `xpkg_dir`; installed into the ambient xlings home instead, the
# test would SKIP and the seam would go unexercised.
XLINGS_HOME="${MCPP_HOME:-$HOME/.mcpp}/registry" \
"$XLINGS_BIN" install xim:picolibc-riscv -y
test -d "${MCPP_HOME:-$HOME/.mcpp}/registry/data/xpkgs/xim-x-picolibc-riscv"

- name: Bare-metal e2e
timeout-minutes: 25
run: |
MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)")
test -x "$MCPP"
export MCPP
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
export MCPP_E2E_TOOLCHAIN_MIRROR=GLOBAL
"$MCPP" self config --mirror "$MCPP_E2E_TOOLCHAIN_MIRROR"
# llvm is the toolchain a freestanding target pins; install it
# explicitly rather than relying on whatever the sandbox cache holds.
"$MCPP" toolchain install llvm 22.1.8
# Run the two scripts DIRECTLY rather than through run_all.sh.
# They are standalone (they take $MCPP and nothing else), run_all.sh
# accepts no filter — it would run the whole 250-test suite here for
# two tests — and, more to the point, run_all.sh exits 0 on a skip.
# Invoked directly, a skip is visible: the script either prints its
# 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; do
echo "=== $t ==="
bash "$t" 2>&1 | tee "$(basename "$t").log"
rc=${PIPESTATUS[0]}
[ "$rc" = "0" ] || { echo "$t failed (exit $rc)"; exit 1; }
done
# The assertion this job exists for: both tests RAN. Each has an
# early `exit 0` for a missing capability, so a zero exit code alone
# does not distinguish "passed" from "skipped".
grep -q 'PASS: freestanding riscv64 build + run' \
130_freestanding_riscv_build_and_run.sh.log || {
echo "130 (engine chain) skipped on the runner that must run it"; exit 1; }
grep -q 'PASS: BSP supplies the sysroot' \
131_freestanding_bsp_supplies_everything.sh.log || {
echo "131 (ecosystem chain) skipped on the runner that must run it"; exit 1; }

# ──────────────────────────────────────────────────────────────────
# Hermetic (no host toolchain): the ONLY environment class that
# faithfully reproduces issue #195. Standard runners ship gcc +
Expand Down
53 changes: 53 additions & 0 deletions docs/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,59 @@ for arch/env conditions and combinators.
cross target, so put them under `[target.<triple>]` (above), not under a bare
alias or `cfg(...)`.

### 2.7.2 Bare metal (`os = none`) — freestanding targets

`riscv64-none-elf` and `riscv32-none-elf` are targets with no operating system
underneath. They need no per-host cross toolchain: clang and lld are
cross-compilers by construction, so any host that can install the llvm payload
can produce them.

```bash
mcpp build --target riscv64-none-elf
mcpp run --target-triple riscv64-none-elf # via [target.<triple>].runner
```

**What changes on a freestanding target**

| | |
|---|---|
| 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=<payload>/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 <triple>` 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. The freestanding subset package replaces it, and mcpp's diagnostic names it. |
| Entry point | There is no `main`. Declare the target explicitly and point `main` at the file carrying `_start`. |

**A minimal firmware**

```toml
[package]
name = "fw"
version = "0.1.0"

[build]
ldflags = ["-T", "/abs/path/to/link.ld"]

[targets.firmware]
kind = "bin"
main = "src/start.S" # the entry lives in assembly, not in main()

[target.riscv64-none-elf]
runner = ["qemu-system-riscv64", "-machine", "virt", "-nographic",
"-no-reboot", "-bios", "default", "-kernel"]
```

**`runner` — how `mcpp run` executes something this machine cannot run**

A bare-metal image has the wrong ISA, no loader, and expects to own the address
space; exec'ing it directly gives "Exec format error". `runner` is the argv
template that stands in front of it. The artifact path is **appended**, or
substituted for `{}` when the template contains it.

mcpp ships **no default runner**, deliberately. Which emulator, which machine
model and which firmware mode are board facts — two boards on the same ISA need
different argv (`-bios default` for an OpenSBI boot, `-bios none -semihosting`
for a picolibc image) — and an engine that guesses one is an engine the other
board has to fight. A board-support package normally supplies it.

### 2.8 `[features]` — Features (Cargo-style, additive)

```toml
Expand Down
32 changes: 32 additions & 0 deletions docs/07-build-mcpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ is ignored, so diagnostics may be logged freely.
| `mcpp:source=<path>` *(0.0.100+)* | select a **pre-existing** source file into the build (absolute, or relative to the package root). Same downstream effect as `generated=`; use it for files the program *chose* (payload/vendored tree) rather than wrote — e.g. a per-target source selection over a large tarball |
| `mcpp:include-dir=<dir>` *(0.0.100+)* | add a **private** include directory (`-I`) for this package's own TUs (absolute, or relative to the package root; normalized). Replaces the `cxxflag=-I` + `cflag=-I` double emission |
| `mcpp:include-dir-after=<dir>` *(0.0.100+)* | like `include-dir`, but searched **after** the system directories (`-idirafter`) — for payload trees that shadow system headers |
| `mcpp:link-script=<path>` *(2026.8.19+)* | link with this **linker script** (`-T`; relative resolves against the package root, and the emitted path is absolute because the link runs in the build directory). Reaches the **consumer**, unlike `include-dir` — a board's memory layout is the one thing a consumer cannot write for itself |
| `mcpp:rerun-if-changed=<path>` | re-run `build.mcpp` when this file changes |
| `mcpp:rerun-if-env-changed=<VAR>` | re-run `build.mcpp` when this env var changes |

Expand Down Expand Up @@ -99,8 +100,39 @@ int main() {
| `mcpp::rerun_if_changed(p)` / `mcpp::rerun_if_env_changed(v)` | the matching `rerun-*` directives |
| `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` — re-run when the **set** of files matching `pat` changes (see below) |
| `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* | reads `MCPP_DEP_<PKG>_BIN_<TOOL>` — the absolute path of a **host tool** built by a dependency (see below) |
| `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` |
| `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | the payload directory of a package this manifest declared in `[xlings] deps`; `""` when it was not declared or is not installed (see below) |
| `mcpp::action{…}.submit()` *(2026.8.5.1+)* | `mcpp:action=` — declares a **build-graph node** instead of doing the work here (see below) |

### Finding an `[xlings] deps` payload: `xpkg_dir` (2026.8.19+)

`dep_dir` answers for **mcpp** dependencies. An xlings package is a different
namespace with a different store layout, and `xpkg_dir` is the interface for it:

```cpp
// mcpp.toml
// [xlings]
// deps = ["xim:picolibc-riscv@1.8.12"]

const char* sysroot = mcpp::xpkg_dir("xim", "picolibc-riscv"); // exact
const char* same = mcpp::xpkg_dir("picolibc-riscv"); // bare name
```

The namespaced form answers only for a package declared under that namespace
and is the one to prefer; the bare form is a convenience for the common single
declaration, and when two namespaces claim one name it answers for the first
**declared**. Both return `""` when the package was not declared or is not
installed — a program that needs it should say so itself, because only it knows
whether the absence is fatal.

It is an interface rather than a documented path because the alternative is a
build program encoding `<home>/data/xpkgs/<ns>-x-<name>/<version>`, which is
store internals mcpp is free to change — the same reason `dep_dir` exists.

⚠️ A **pinned** reference resolves to exactly that version or to nothing. A
build that asked for `1.8.12` and silently got `1.9.0` is an answer only
discovered later, in the artifact.

### Host tools from a dependency (2026.8.5.1+)

Declare the need in `mcpp.toml`, then call it:
Expand Down
50 changes: 50 additions & 0 deletions docs/zh/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,56 @@ cxxflags = ["-march=x86-64-v2"]
- **`toolchain` / `linkage` 仅限精确三元组** —— 它们描述某一个具体的交叉目标,
因此写在 `[target.<triple>]` 下(见上),而不是裸别名或 `cfg(...)` 下。

### 2.7.2 裸机(`os = none`)—— freestanding target

`riscv64-none-elf` 与 `riscv32-none-elf` 是底下没有操作系统的 target。它们不需要
逐宿主的交叉工具链:clang 与 lld 天生是交叉编译器,任何能装 llvm 载荷的宿主都能
产出它们。

```bash
mcpp build --target riscv64-none-elf
mcpp run --target-triple riscv64-none-elf # 经 [target.<triple>].runner
```

**freestanding target 上有什么不同**

| | |
|---|---|
| 链接线 | `-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 <triple>` 就足以产出正确的目标文件。 |
| `import std` | **不可用。** `std` 是覆盖整个库的一个模块 —— 线程、文件系统、iostreams 全在内 —— 没有 OS 就没有它的子集可编。freestanding 子集包取代它,mcpp 的诊断会点名。 |
| 入口点 | 没有 `main`。显式声明 target,并把 `main` 指向携带 `_start` 的那个文件。 |

**一个最小固件**

```toml
[package]
name = "fw"
version = "0.1.0"

[build]
ldflags = ["-T", "/abs/path/to/link.ld"]

[targets.firmware]
kind = "bin"
main = "src/start.S" # 入口在汇编里,不在 main()

[target.riscv64-none-elf]
runner = ["qemu-system-riscv64", "-machine", "virt", "-nographic",
"-no-reboot", "-bios", "default", "-kernel"]
```

**`runner` —— `mcpp run` 如何执行本机跑不了的东西**

裸机镜像的 ISA 不对、没有 loader、且期望独占整个地址空间;直接 exec 它得到的是
"Exec format error"。`runner` 就是挡在它前面的 argv 模板。产物路径会被**追加**,
或者在模板含 `{}` 时替换进去。

mcpp **刻意不提供默认 runner**。用哪个模拟器、哪个机器型号、哪种固件模式都是板级
事实 —— 同一 ISA 的两块板需要不同 argv(OpenSBI 启动用 `-bios default`,picolibc
镜像用 `-bios none -semihosting`)—— 引擎一旦猜一个,另一块板就得跟它打架。板级
支持包通常会提供它。

### 2.8 `[features]` —— Feature(Cargo 风格,可加性)

#### 表形式 —— 让 feature 贡献的不止是隐含 feature
Expand Down
28 changes: 28 additions & 0 deletions docs/zh/07-build-mcpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ mcpp build # 编译 + 运行 build.mcpp,然后构建工程
| `mcpp:source=<path>` *(0.0.100+)* | 把一份**既有**源文件选入构建(绝对路径,或相对包根)。下游效果与 `generated=` 相同;语义区别在于文件是程序*选中*的(tarball payload / vendored 源树)而非程序写出的——例如对大型源码包做 per-target 源选择 |
| `mcpp:include-dir=<dir>` *(0.0.100+)* | 为本包自身 TU 增加一个**私有** include 目录(`-I`;绝对路径或相对包根,自动规范化)。取代过去 `cxxflag=-I` + `cflag=-I` 的双重裸发 |
| `mcpp:include-dir-after=<dir>` *(0.0.100+)* | 同 `include-dir`,但排在系统目录**之后**搜索(`-idirafter`)——用于会遮蔽系统头的 payload 源树 |
| `mcpp:link-script=<path>` *(2026.8.19+)* | 用这个**链接脚本**链接(`-T`;相对路径按包根解析,发出的是绝对路径,因为链接是在构建目录里跑的)。与 `include-dir` 不同,它**到达消费者** —— 板子的内存布局恰恰是消费者写不出来的那一项 |
| `mcpp:rerun-if-changed=<path>` | 该文件变化时重跑 `build.mcpp` |
| `mcpp:rerun-if-env-changed=<VAR>` | 该环境变量变化时重跑 `build.mcpp` |

Expand Down Expand Up @@ -92,8 +93,35 @@ int main() {
| `mcpp::rerun_if_changed(p)` / `mcpp::rerun_if_env_changed(v)` | 对应的 `rerun-*` 指令 |
| `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` —— 匹配 `pat` 的文件**集合**发生变化时重跑(见下) |
| `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* | 读 `MCPP_DEP_<PKG>_BIN_<TOOL>` —— 依赖构建出的 **host 工具**的绝对路径(见下) |
| `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` |
| `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | 本 manifest 在 `[xlings] deps` 里声明的包的载荷目录;没声明或没安装时返回 `""`(见下) |
| `mcpp::action{…}.submit()` *(2026.8.5.1+)* | `mcpp:action=` —— **声明一个构建图节点**,而不是在这里把活干了(见下) |

### 找到 `[xlings] deps` 的载荷:`xpkg_dir`(2026.8.19+)

`dep_dir` 回答的是 **mcpp** 依赖。xlings 包是另一个命名空间、另一套 store 布局,
`xpkg_dir` 是它的接口:

```cpp
// mcpp.toml
// [xlings]
// deps = ["xim:picolibc-riscv@1.8.12"]

const char* sysroot = mcpp::xpkg_dir("xim", "picolibc-riscv"); // 精确
const char* same = mcpp::xpkg_dir("picolibc-riscv"); // 裸名
```

带命名空间的形式只对该命名空间下声明的包作答,应当优先使用;裸名形式是常见的单条
声明的便利写法,两个命名空间都声明同一个名字时,它回答**先声明**的那个。两者在包
未声明或未安装时都返回 `""` —— 缺失是否致命只有调用方知道,所以由它自己说。

做成接口而不是给一条路径约定,是因为另一种做法是让构建程序把
`<home>/data/xpkgs/<ns>-x-<name>/<version>` 写进代码,而那是 mcpp 可以随时改的
store 内部结构 —— 与 `dep_dir` 存在的理由相同。

⚠️ **带版本固定**的引用只解析到那个版本,否则什么都不返回。请求 `1.8.12` 却静默拿
到 `1.9.0`,是那种要到产物里才被发现的答案。

### 依赖产出的 host 工具(2026.8.5.1+)

在 `mcpp.toml` 里声明需求,然后调用它:
Expand Down
Loading
Loading