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
162 changes: 162 additions & 0 deletions .agents/docs/2026-06-26-llvm22-libatomic-self-containment-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# LLVM libatomic 自包含缺口分析与修复设计(libatomic.so.1 cannot open)

Date: 2026-06-26

## Summary

升级到 `llvm@22.1.8` 后,`mcpp run` 编译通过但**运行期崩溃**:

```
error while loading shared libraries: libatomic.so.1: cannot open shared object file
```

切回 `llvm@20.1.7` 一切正常。根因不在 mcpp 的构建/RUNPATH 逻辑,而是 **llvm 22.1.8 的 "slim self-contained" 重打包(xim-pkgindex #317)把 `libatomic.so.1` 从包里删掉了**,而该包的 `libc++.so.1` 仍带着一个对 `libatomic.so.1` 的依赖。配合排查还暴露出第二个**早已存在(20/22 皆有)**的问题:真正使用 16 字节原子的用户程序因链接行缺 `-latomic` 而链接失败。

本文记录完整证据、根因、以及落地修复方案。结论:

- **bug #1(22 回归,全崩)**:任何产物(连 hello world)运行期挂 libatomic。
- **bug #2(20+22 旧问题,小众)**:真用 `std::atomic<16字节>` 的程序链接报 `undefined __atomic_*_16`。

**采纳的修复:① + ③(本仓 + 打包),② 反馈上游 LLVM。**

- **①(mcpp)**:链接行追加 `-latomic -Wl,--as-needed`,修 bug #2。
- **③(llvm 打包)**:把 `libatomic.so.1` 带回包内 `lib/x86_64-unknown-linux-gnu/`(对齐 20.1.7),是 bug #1 与 bug #2 的共同基石。
- **②(上游)**:libc++.so.1 的"幽灵依赖"(`-latomic` 未配 `--as-needed`)反馈给上游 LLVM/libc++ 打包,本仓不做 patchelf 摘除。

## 复现与现场

项目为 `mcpp new` 模板(`import std; std::println(...)`)。

```
mcpp toolchain default llvm 22.1.8 && mcpp run
Compiling ... Finished ... Running ...
<bin>: error while loading shared libraries: libatomic.so.1: cannot open shared object file

mcpp toolchain default llvm 20 && mcpp run
Hello from llvm-test! # 正常
```

## 根因调查(证据)

### 1. 产物本身不直接依赖 libatomic —— 缺的是传递依赖

20 与 22 的最终产物 `DT_NEEDED` **完全一致**,都没有直接 `libatomic`:

```
NEEDED libc++.so.1 libc++abi.so.1 libunwind.so.1 libm.so.6 libc.so.6
RUNPATH <llvm>/lib/x86_64-unknown-linux-gnu : <llvm>/lib : <glibc>/lib64
```

真正带 `NEEDED libatomic.so.1` 的是 `libc++.so.1`(20、22 都带)。

### 2. 差异只在打包:20 自带 libatomic,22 整包没有

```
xim-x-llvm/20.1.7/lib/x86_64-unknown-linux-gnu/libatomic.so.1 → 存在(软链 → .so.1.2.0)
xim-x-llvm/22.1.8/... → find libatomic* 为空
```

libc++.so.1 的 RUNPATH 第一项正是 `<llvm>/lib/x86_64-unknown-linux-gnu`。20 在该目录里有 libatomic → 解析成功;22 该目录里没有 → 失败。

### 3. 为什么不回退系统 libatomic —— 自包含 loader

产物 PT_INTERP = 沙箱 `xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2`。该 loader 默认搜索/ld.so.cache 不含宿主 `/lib/x86_64-linux-gnu`;且可执行文件的 `DT_RUNPATH` **非透传**(不用于解析 libc++ 的子依赖)。这正是 mcpp/xlings"不依赖 host"的设计 —— 因此宿主即便有 libatomic 也不该、也不会被用上。

### 4. RUNPATH 是 mcpp 自己设的(不是上游/xlings)

`src/toolchain/lifecycle.cppm:376-419` 在 llvm 包 post-install 阶段,用 patchelf 对 `lib/` 下所有 .so `--set-rpath` 为
`<llvm>/lib/x86_64-unknown-linux-gnu : <llvm>/lib : <glibc>/lib64`(`post_install.cppm` 的 `patchelf_walk`)。其代码注释明确写着这么设就是为了让
"libatomic.so.1 这种传递依赖被找到 —— 它住在同一个 xpkg 里"。

即 **mcpp 显式假设 libatomic 打在包内**,RUNPATH 已正确指向该目录;22.1.8 slim 删掉文件 = 直接违背 mcpp 代码写明的契约。RUNPATH 没错,错在文件缺失。

### 5. 反转:libc++ 的 libatomic 依赖是"幽灵依赖"(over-link)

`libc++.so.1`(20、22 都一样)带 `NEEDED libatomic.so.1`,但对 libatomic 的**未定义符号数 = 0**(libc++abi、libunwind 同为 0):

```
readelf --dyn-syms libc++.so.1 | grep UND | grep atomic → 空
```

即构建 libc++ 时挂了 `-latomic` 但**没配 `--as-needed`**,留下一个没人引用的 `NEEDED`。ELF loader 对每个 `DT_NEEDED` 仍做存在性加载(不管用不用),所以文件缺失即启动失败。

### libatomic 的归属

`libatomic` 不是 LLVM 的,也不是 glibc 的,而是 **GCC 运行时库**(`__atomic_*` 是编译器无关 ABI;GNU 平台由 GCC 独家提供,LLVM/compiler-rt 不发布独立 libatomic)。实测:`xim-x-glibc/2.39` 无 libatomic;`xim-x-gcc/16.1.0/lib64`、`xim-x-gcc-runtime` 有。20.1.7 包内那份就是从 GCC 拷进去的(软链 → `libatomic.so.1.2.0`,与 gcc 同版本)。包内 `libunwind`/`libc++abi` 是 LLVM 自家的,唯 libatomic 借 GCC。

## 本地验证矩阵(已实测)

两个 mcpp 项目钉 `llvm@22.1.8`:`normal`(import std)、`atomic`(`std::atomic<alignas(16) Big>`,强制 `__atomic_*_16`):

| | normal(import std) | atomic(16 字节,真用 libatomic) |
|---|---|---|
| 出厂基线 | ❌ 运行期 `libatomic cannot open` | ❌ **链接**就挂 `undefined __atomic_load_16/store_16/compare_exchange_16` |
| 仅摘 libc++ 幽灵依赖(②) | ✅ 正常 | ❌ 仍链接失败(无 libatomic 可链) |
| 仅带回 libatomic(③) | ✅ 正常 | ❌ 仍链接失败(mcpp 链接行无 `-latomic`,库在场也白搭) |
| 手动 `clang++ -L<llvmlib> -latomic` | — | ✅ 链接+运行 `a=1 b=2 ok=1 lockfree=0` |
| 对照:同一 atomic 程序在 llvm 20.1.7(无 -latomic) | — | ❌ **同样**链接失败 → bug #2 非 22 回归 |

结论:
- bug #1 由 ③(带回库)单独即可修好(20.1.7 即此形态);② 是上游正确做法,可选清洁。
- bug #2 需要 ①(链接行 `-latomic`)+ ③(库在场)同时满足;`lockfree=0` 证实 16 字节原子按 ABI 走 libatomic 锁实现。

## 为什么会有这两个 bug(统一视角)

二者是"libatomic 链接处理不一致"同一根源的两面,核心是没人一致地用 `-latomic -Wl,--as-needed`:

| 在哪链接 | 当前做法 | 症状 |
|---|---|---|
| 上游构建 libc++ | 加了 `-latomic`,**未配** `--as-needed` | 没用却留 NEEDED → bug #1(幽灵依赖) |
| mcpp 链接用户程序 | **根本没加** `-latomic` | 真用却没链上 → bug #2(undefined) |

`--as-needed` 恰好提供"没用就丢、真用就留"的条件行为。把它补在这两处,加上 libatomic 始终在场(③),两个症状都消失。

16 字节原子为何必须 out-of-line:x86-64 上 ≤8 字节原子映射单条 `lock` 指令(内联);16 字节需 `cmpxchg16b`(基线不保证、且纯读 load 会写内存破坏 const 语义),故编译器保守发 `__atomic_*_16` libcall,由 libatomic 锁实现 —— 这是 ABI 规范,非 bug。

## 修复设计

### ①(本仓 mcpp):链接行追加 `-latomic --as-needed`

- **落点**:`src/build/flags.cppm` 的 LLVM/clang 分支(约 193-195,设 `-stdlib=libc++ -fuse-ld=lld --rtlib=compiler-rt --unwindlib=libunwind` 处),把原子库追加进 ldflags(`f.ld`)。
- **链接规则**:`ninja_backend.cppm:375` 为 `command = $cxx $in -o $out $ldflags $unit_ldflags`。`$in`(目标文件)在 `$ldflags` 之前,满足 `--as-needed` 的顺序要求(库须出现在引用它的目标文件之后)。
- **建议片段**(放在 ldflags 末尾):

```
-Wl,--push-state,--as-needed -latomic -Wl,--pop-state
```

`--push-state/--pop-state` 隔离 `--as-needed`,不污染其它库的链接语义。
- **效果**:用户程序真用原子 → 自动链上并保留 NEEDED;没用 → `--as-needed` 自动丢弃,产物零额外依赖。
- **前提**:链接器要能找到 libatomic → 依赖 ③ 把库放进 `lib/x86_64-unknown-linux-gnu`(已在 -L/RUNPATH 内,与 libc++ 同目录)。
- **范围**:本设计聚焦 LLVM 工具链。GCC 工具链同样有 bug #2(其 libatomic 已在 gcc lib 目录 + RUNPATH 内),建议 ① 做成对 gcc/llvm 通用;最低限度先覆盖 LLVM。

### ③(llvm 打包):把 libatomic 带回包内

- **落点**:llvm 子打包脚本 `.agents/tools/build-llvm-subpkg.sh`(及 `.agents/skills/llvm-subpackaging/`)。
- **做法**:打包时从 GCC 运行时拷 `libatomic.so.1.2.0` 进 `lib/x86_64-unknown-linux-gnu/`,并建 `libatomic.so.1 → libatomic.so.1.2.0`、`libatomic.so → libatomic.so.1` 软链,对齐 20.1.7 的布局。
- **效果**:libc++ 的(幽灵)NEEDED 可解析(修 bug #1);为 bug #2 提供可链接/可加载的库;且全程不依赖 host。
- **代价**:每个 llvm 包多约 187KB 一个小库,换取"任何场景不比 20 倒退"。
- **重发**:更新 asset → XLINGS_RES → 用户 `mcpp toolchain remove llvm 22.1.8 && reinstall`。

### ②(反馈上游,本仓不做):libc++ 幽灵依赖

- **现象**:上游(或 xlings 重打包所基于的)libc++.so.1 以 `-latomic` 链接但未配 `--as-needed`,产生 0 引用的 `NEEDED libatomic.so.1`。
- **上游正确修法**:构建 libc++ 时用 `-latomic -Wl,--as-needed`,或对发布产物 `patchelf --remove-needed libatomic.so.1 libc++.so.1`。
- **本仓决策**:不在 mcpp post-install 摘除(避免对上游产物做隐式改写),改为向上游 LLVM/打包反馈。在 ③ 已带回 libatomic 的前提下,该幽灵依赖不影响正确性,仅是可加载性上的轻微噪音。

### 方案组合判定

- 最小可用集 = **① + ③**(等于"20.1.7 形态" + "mcpp 自动补 -latomic"),两个 bug 全好。
- ②(摘幽灵)为上游清洁项,做了更干净(普通进程根本不加载 libatomic),不做也不影响正确性。

## 验收标准

- `mcpp run`(import std hello,toolchain=llvm@22.1.8)正常输出,产物 `ldd`/`readelf -d` 无未解析依赖。
- 16 字节原子程序 `mcpp build` 链接通过、运行正确(`is_lock_free()==false` 属预期),产物 `NEEDED libatomic.so.1` 且可经 RUNPATH 解析。
- 不使用原子的程序产物**不含** `NEEDED libatomic`(验证 `--as-needed` 生效)。
- 全程不依赖宿主:在无系统 libatomic 的环境(容器/最小化系统)上述两类程序均可运行。

## 关联文档

- `2026-05-22-llvm-runpath-bug-analysis.md` / `2026-05-22-fix-llvm-shared-lib-runpath.md`(同一 RUNPATH/post-install 区域)
- `2026-05-13-llvm-clang-toolchain-support-design.md`(LLVM 工具链抽象)
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。

## [0.0.66] — 2026-06-26

### 修复

- **LLVM 工具链产物运行期 `libatomic.so.1: cannot open` / 真用原子时链接报 `undefined __atomic_*`**:
16 字节及超宽 `std::atomic` 会降级成 `__atomic_*` 外部调用,这些符号位于 **libatomic**
(GCC 运行时库,LLVM 无对应物),而编译器驱动**不会自动链接** libatomic。mcpp 现在在
Linux 链接行注入 `-Wl,--push-state,--as-needed -latomic -Wl,--pop-state`:真正用到原子的
程序自动链上并保留依赖,未用到的程序经 `--as-needed` 自动丢弃、产物零额外依赖。注入是
**自守卫**的——仅当工具链链接目录里存在可解析的 libatomic(动态链接 `libatomic.so`/`.a`,
静态链接 `libatomic.a`)时才发出 `-latomic`,因此对不附带 libatomic 的工具链零回归。
与之配套的 llvm 资源包需把 libatomic 打入 `lib/<triple>/`(详见
`.agents/docs/2026-06-26-llvm22-libatomic-self-containment-design.md`)。

## [0.0.65] — 2026-06-25

### 修复
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 = "0.0.65"
version = "0.0.66"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
Expand Down
38 changes: 36 additions & 2 deletions src/build/flags.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ struct CompileFlags {

CompileFlags compute_flags(const BuildPlan& plan);

// Return the linker flag that pulls in libatomic, or "" when it should be
// omitted. libatomic carries the out-of-line __atomic_* libcalls that
// 16-byte / oversized std::atomic lowers to (a GCC runtime lib — LLVM ships
// no equivalent, and compiler drivers don't auto-link it), so a genuine
// atomic user otherwise fails at link with `undefined __atomic_*`. We guard
// it with --as-needed so binaries that don't use it get no dependency. But
// --as-needed does NOT skip a missing library (the linker still has to open
// it), so the flag is emitted ONLY when a link-resolvable libatomic actually
// exists on one of the toolchain's link dirs — otherwise it would break
// toolchains that ship no libatomic at all. `staticLink` (a `-static` build,
// e.g. musl targets) narrows the resolvable form to `libatomic.a`; a dynamic
// link also accepts `libatomic.so`.
std::string atomic_link_flag(const std::vector<std::filesystem::path>& linkDirs,
bool staticLink);

} // namespace mcpp::build

namespace mcpp::build {
Expand Down Expand Up @@ -90,6 +105,18 @@ std::string normalize_ldflag(const std::filesystem::path& root, const std::strin

} // namespace

std::string atomic_link_flag(const std::vector<std::filesystem::path>& linkDirs,
bool staticLink) {
for (auto& dir : linkDirs) {
std::error_code ec;
if (std::filesystem::exists(dir / "libatomic.a", ec)
|| (!staticLink && std::filesystem::exists(dir / "libatomic.so", ec))) {
return " -Wl,--push-state,--as-needed -latomic -Wl,--pop-state";
}
}
return {};
}

CompileFlags compute_flags(const BuildPlan& plan) {
CompileFlags f;

Expand Down Expand Up @@ -431,8 +458,15 @@ CompileFlags compute_flags(const BuildPlan& plan) {
f.ld = std::format("{}{}{}{} -fuse-ld=lld{}{}{}", full_static, static_stdlib,
b_flag, macos_sdk, version_min, user_ldflags, link_extra);
} else {
f.ld = std::format("{}{}{}{}{}{}{}{}", full_static, static_stdlib, link_toolchain_flags, b_flag,
runtime_dirs, payload_ld, user_ldflags, link_extra);
// libatomic: 16-byte / oversized std::atomic needs the out-of-line
// __atomic_* libcalls from libatomic, which the driver won't add on
// its own. Inject `-latomic` (under --as-needed) after runtime_dirs
// so its -L entries are on the search path; self-guards on the lib
// actually being present (see atomic_link_flag).
std::string atomic_ld = atomic_link_flag(plan.toolchain.linkRuntimeDirs,
!full_static.empty());
f.ld = std::format("{}{}{}{}{}{}{}{}{}", full_static, static_stdlib, link_toolchain_flags, b_flag,
runtime_dirs, atomic_ld, payload_ld, user_ldflags, link_extra);
}

return f;
Expand Down
2 changes: 1 addition & 1 deletion src/toolchain/fingerprint.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import mcpp.toolchain.detect;

export namespace mcpp::toolchain {

inline constexpr std::string_view MCPP_VERSION = "0.0.65";
inline constexpr std::string_view MCPP_VERSION = "0.0.66";

struct FingerprintInputs {
Toolchain toolchain;
Expand Down
112 changes: 112 additions & 0 deletions tests/e2e/47_llvm_atomic_link.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# requires: import-std-libcxx
# 47_llvm_atomic_link.sh — a program using a 16-byte std::atomic lowers to
# __atomic_*_16 libcalls that live in libatomic. Compiler drivers don't
# auto-link libatomic, so without mcpp injecting `-latomic` this fails at link
# with `undefined symbol __atomic_load_16`. Verifies the fix links + runs, and
# that a program NOT using atomics gets no libatomic dependency (--as-needed).
set -e

OS="$(uname -s)"
if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then
echo "SKIP: libatomic/-latomic path is Linux-only"
exit 0
fi
if [[ "$OS" == Darwin* ]]; then
echo "SKIP: libatomic is a GNU/Linux runtime; not applicable on macOS"
exit 0
fi

LLVM_ROOT="${HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7"
if [[ ! -x "$LLVM_ROOT/bin/clang++" ]]; then
echo "SKIP: xlings llvm@20.1.7 is not installed"
exit 0
fi
# The fix only emits -latomic when a link-resolvable libatomic is present in
# the toolchain (self-guarding). 20.1.7 bundles it; if a slimmed package does
# not, the genuine-atomic case is out of scope here.
if [[ ! -e "$LLVM_ROOT/lib/x86_64-unknown-linux-gnu/libatomic.so" \
&& ! -e "$LLVM_ROOT/lib/x86_64-unknown-linux-gnu/libatomic.a" ]]; then
echo "SKIP: llvm@20.1.7 package ships no link-resolvable libatomic"
exit 0
fi

TMP=$(mktemp -d)
trap "rm -rf $TMP" EXIT
export MCPP_HOME="$TMP/mcpp-home"
source "$(dirname "$0")/_inherit_toolchain.sh"

mkdir -p "$TMP/proj/src"
cd "$TMP/proj"

cat > mcpp.toml <<'EOF'
[package]
name = "atomic_link"
version = "0.1.0"

[toolchain]
linux = "llvm@20.1.7"
EOF

# 16-byte atomic → __atomic_load_16 / __atomic_compare_exchange_16 libcalls.
cat > src/main.cpp <<'EOF'
#include <atomic>
#include <cstdio>
struct alignas(16) Big { long long a, b; };
std::atomic<Big> g{{0, 0}};
int main() {
g.store(Big{1, 2});
Big r = g.load();
Big e{1, 2}, d{9, 9};
bool ok = g.compare_exchange_strong(e, d);
std::printf("atomic %lld %lld %d\n", r.a, r.b, (int)ok);
return 0;
}
EOF

"$MCPP" build --no-cache > "$TMP/build.log" 2>&1 || {
cat "$TMP/build.log"
echo "FAIL: 16-byte atomic build failed (libatomic not linked?)"
exit 1
}

binary=$(find target -type f -path '*/bin/atomic_link' | head -1)
[[ -n "$binary" && -x "$binary" ]] || {
echo "FAIL: atomic_link binary missing"
exit 1
}

out=$("$binary")
[[ "$out" == "atomic 1 2 1" ]] || {
echo "FAIL: wrong runtime output: $out"
exit 1
}

# The genuine-atomic binary must actually pull libatomic (used → kept).
if command -v readelf >/dev/null 2>&1; then
readelf -d "$binary" 2>/dev/null | grep -q 'libatomic\.so' || {
echo "FAIL: atomic binary missing libatomic NEEDED"
exit 1
}
fi

# A program that does NOT use atomics must get no libatomic dependency,
# proving --as-needed drops the spurious link.
cat > src/main.cpp <<'EOF'
#include <cstdio>
int main() { std::printf("noatomic\n"); return 0; }
EOF
"$MCPP" build --no-cache > "$TMP/build2.log" 2>&1 || {
cat "$TMP/build2.log"
echo "FAIL: non-atomic rebuild failed"
exit 1
}
binary=$(find target -type f -path '*/bin/atomic_link' | head -1)
if command -v readelf >/dev/null 2>&1; then
if readelf -d "$binary" 2>/dev/null | grep -q 'libatomic\.so'; then
echo "FAIL: non-atomic binary should not depend on libatomic (--as-needed)"
exit 1
fi
fi

echo "OK"
Loading
Loading