Skip to content

Commit a749e9f

Browse files
fix: 产物必须加载它链接的那一份库 —— 链接行顺序成为声明,共享库不再劫持运行时 (2026.8.11.3) (#414)
`mcpp run` 一个 imgui/GLFW 工程,链接 rc=0,运行即死: undefined symbol: _ZNKSt13runtime_error4whatEv ## 缺陷一:`$ORIGIN` 被 SubOS 库视图遮蔽 2026.8.11.2(#413)首次把 SubOS 库视图(farm)写进产物 DT_RPATH,但它落在 `$ORIGIN` **之前**。而这两个目录在本生态里天然装着同名 SONAME —— mcpp 从 `compat.x11` 源码构建 `libX11.so` 部署到产物目录,xlings 又在 farm 里有 `xim:libX11`。于是**链接期用 A,运行期加载 B**。 真因不是"放错位置",而是**一条链接命令行的顺序由两个互不知情的生产者用 `+=` 决定**:`flags.cppm` 把 farm 拼进全局 ldflags(注释还写着 "so it is LAST"), `plan.cppm` 把 `$ORIGIN` 拼进 per-unit,而链接规则渲染的是 `$ldflags $unit_ldflags`。三处各自都对,合起来是错的。 新增 `mcpp.build.link_line`:把 per-unit 尾部声明成**具名槽位** (dependencies → cxxRuntime → runtimeFallback → loaderTag),相对顺序写在类型 里、由单测钉死。新增一个生产者必须先选一个槽 —— "选"正是"在产物自己的目录之前 还是之后"这个问题被提出来的地方。格式中立:槽位按职责命名,PE 的两个槽天然为空, Mach-O 的 dependencies 装 `@loader_path`,没有任何 `if (platform)`。 ## 缺陷二:共享库把自己的 C++ 运行时导出给了别人(ELF) `SharedLibrary` 与可执行文件共用 `Distributable` 角色,于是拿到同一份 self-contained 契约:`-static-libstdc++`。ELF 上这不是"私有一份" —— 只有一个全局 符号命名空间,共享对象导出它定义的每一个全局符号。一个**纯 C** 的 compat 包因此 导出了 777 个 GLOBAL 标准库定义(`libXau.so`:39KB 的 Xau + 9.5MB 的 libstdc++)。 可执行文件链接时 `-lX11` 排在驱动的 `-lstdc++` 之前,ld 用它满足了 `runtime_error::what()`,归档成员从不拉入 —— **exe 的 `-static-libstdc++` 变成 空操作,它的 C++ 运行时事实上是那个 `.so`**。缺陷一之所以致命,根源在这里。 共享库默认契约改为**按目标格式分档**: ELF toolchain-coupled 一个全局命名空间,先加载的定义胜出 Mach-O self-contained 机制本就是 -load_hidden,dyld 不归一; 且 toolchain-coupled 在 macOS 是死路(#202) PE self-contained 没有全局命名空间,导入按 DLL 逐个按名解析 **只有 ELF 的行为变了,而它正是有缺陷的那个**;Mach-O/PE 产物字节不变。 显式 `cxx_runtime = { shared = "self-contained" }` 仍可选回自包含,此时自动补 `-Wl,--exclude-libs`(实测:按归档基名匹配,与 `-l` 还是完整路径无关), 让内嵌的运行时留在动态符号表之外 —— 逃生舱不会重新打开这个洞。 ## 顺带修掉的架构债 `dist::default_contract` 自称"the role -> contract policy, in one place",实际 **没有任何生产调用方** —— 真正的策略在 `flags.cppm` 被第二次推导。这正是 `distribution.cppm` 开篇声讨的那类债("derived independently in five places") 换个位置复发。现在它是唯一真源。 `cxx_runtime` 补进 `[build]` 已知键白名单:它此前会打印 "unsupported key (ignored)" —— 而 "ignored" 是假的,`--strict` 还会直接拒绝 manifest。整个特性的 唯一入口不能一边工作一边说自己被忽略了。 ## 测试 - 新增 `test_link_line`(6):槽位顺序、空槽不产生多余分隔、顺序与赋值序无关 - 新增 `NinjaBackend.SubosFarmRpathFollowsTheArtifactsOwnDirectory`:断言在 **合成后的**链接行上 `$ORIGIN` 早于 farm(只看其中一个变量,正是原缺陷隐形的原因) - `test_distribution` +8:(Role × Format) 全表、`--exclude-libs` 只给共享库 - `test_manifest` +3:`shared` 键、未知角色键报错、标量拼写仍覆盖所有角色 - e2e 219 断言由「farm 是最后一个**绝对路径**条目」收紧为「**字面**最后一项」, 并补一条**行为**不变量(`LD_DEBUG=libs` 实测同名 SONAME 解析到 `$ORIGIN`)。 旧断言把 `$ORIGIN` 过滤掉了,对坏顺序与好顺序给出同一个结论;测试工程也从 `int main()` 换成消费依赖共享库 —— 否则它连 `$ORIGIN` 都不产生 - 新增 e2e 222:共享库不得导出 GLOBAL 标准库符号、必须 NEEDED libstdc++.so.6、 exe 无未定义 std 符号、显式自包含时护栏仍在 **红测**(两条新 e2e 对已发布 2026.8.11.2):219 报「farm is not the last entry」, 222 报「exports 713 GLOBAL standard-library symbols」—— 均以正确理由失败。 **⚠️ 行为不变量不得依赖崩溃**:缺陷二修好后崩溃会消失,依赖崩溃的断言会立刻假绿。 219 的不变量 4 因此测的是加载器的搜索过程,不是程序的退出码。 ## 实测(helloegui:imgui + GLFW + X11) | 判据 | 修复前 | 修复后 | |---|---|---| | DT_RPATH 尾部 | `… : <subos>/lib : $ORIGIN` | `… : $ORIGIN : <subos>/lib` | | `libX11.so.6` 解析到 | farm 里的 xim:libX11 1.8.10 | `$ORIGIN`(farm 未被试到) | | `bin/libX11.so` 导出 std 符号 | 2931(777 GLOBAL) | 0 | | `bin/libXau.so` | 9 557 936 B | 39 368 B | | exe 未定义 `runtime_error::what` | 有 | 无 | | 运行 | symbol lookup error | GUI 正常启动 | 分析:`.agents/docs/2026-08-11-runtime-search-origin-precedence-analysis.md` 计划:`.agents/docs/2026-08-11-origin-precedence-implementation-plan.md` Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
1 parent d24560b commit a749e9f

19 files changed

Lines changed: 1967 additions & 96 deletions
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
# `$ORIGIN` 优先级 + 共享库运行时契约 —— 实施计划
2+
3+
配套分析:`2026-08-11-runtime-search-origin-precedence-analysis.md`
4+
范围:**A + B + C2 + C1**,单 PR(2026.8.11.3)
5+
状态:**已实施**(实测结果见 §2.2,与计划的偏差见 §2.1)
6+
7+
---
8+
9+
## 0. 四个角度的取舍
10+
11+
### 优雅设计 —— 把「顺序」从字符串拼接提升为声明
12+
13+
缺陷的形状是:**一条链接命令行的顺序,由两个互不知情的生产者用 `+=` 决定。**
14+
`flags.cppm` 把 farm 拼在全局 ldflags 末尾并注释「so it is LAST」;`plan.cppm`
15+
`$ORIGIN` 拼在 per-unit;`ninja_backend` 渲染成 `$ldflags $unit_ldflags`
16+
三处都对,合起来是错的。
17+
18+
对策不是「换个地方拼」,而是新增 `src/build/link_line.cppm`
19+
(`mcpp.build.link_line`):把 per-unit 尾部声明成**具名槽位**,相对顺序写在类型里,
20+
由单测钉死。加一个新的调用方不再可能把顺序拧错 —— 它必须选一个槽。
21+
22+
### 架构稳定性 —— 消灭同一决策的第二处推导
23+
24+
`dist::default_contract(Role)` 自称「The role -> contract policy, in one place」,
25+
**却没有任何生产调用方**(只有单测);真正的策略在 `flags.cppm:704-709` 独立算了
26+
一遍。这正是 `distribution.cppm` 开篇声讨的那类债(「used to be derived
27+
independently in five places」),只是换了个位置复发。
28+
29+
本次把 `default_contract` 变成**活的唯一真源**,`flags.cppm` 调它。
30+
31+
### 兼容性 —— 只有一个平台的行为改变,且正是有缺陷的那个
32+
33+
| 目标格式 | 共享库契约 变化 | 产物字节 |
34+
|---|---|---|
35+
| **ELF** | SelfContained → **ToolchainCoupled** | 变(这是修复) |
36+
| Mach-O | 不变(SelfContained) | **不变** |
37+
| PE | 不变(SelfContained) | **不变** |
38+
39+
`-Wl,-rpath,<farm>` 换槽位后在**没有共享库依赖的工程**上字节完全不变
40+
(槽位为空即不渲染);有共享库依赖的工程只有 rpath 次序变化。
41+
42+
用户逃生舱保留:`cxx_runtime = { shared = "self-contained" }` 可恢复旧行为,
43+
且此时自动补 C1 护栏,不会重新打开符号泛滥。
44+
45+
**缓存**:`.so` 的链接边在工程自己的 ninja 图里(`build bin/libX11.so : cxx_shared
46+
…`),不来自依赖缓存;ninja 按命令行变化重跑链接。契约只影响链接标志、不影响编译
47+
标志,因此对象缓存键无需改动。发版 bump 会改工程指纹 ⇒ 全量重建,不存在旧契约残留。
48+
49+
### 跨平台 —— 平台差异只准出现在已有的格式维度里
50+
51+
`distribution.cppm` 的机制表本来就是 `(contract × stdlib × format) → flags`
52+
共享库的危害本身就是格式相关的,所以把 `Format` 提到 `default_contract` 的入参,
53+
是让既有维度承担它,而不是新开一条平台分支:
54+
55+
- **ELF**:一个全局符号命名空间,先加载的定义胜出 ⇒ 静态内嵌 libstdc++ 的 `.so`
56+
会把 2931 个 std 符号无版本导出(其中 **777 个是 GLOBAL 定义**,只可能来自
57+
`libstdc++.a`),可执行文件的 std 引用被绑到它身上
58+
- **Mach-O**:self-contained 的机制本就是 `-Wl,-load_hidden,<archive>`,符号是
59+
hidden,dyld 不会归一 ⇒ **危害不存在**;且 toolchain-coupled 在 macOS 是已知死路(#202)
60+
- **PE**:没有全局命名空间,导入按 DLL 逐个按名解析 ⇒ **危害不存在**;`-static`
61+
是那里的标准约定
62+
63+
`link_line::UnitTail` 保持格式中立:槽位按**职责**命名,不按标志拼写。
64+
PE 的 `runtimeFallback`/`loaderTag` 天然为空,Mach-O 的 `dependencies`
65+
`@loader_path` —— 不需要任何 `if (platform)`
66+
67+
---
68+
69+
## 1. 步骤拆分
70+
71+
每一步都有独立判据,可单独 revert。
72+
73+
### S1 — 新增 `mcpp.build.link_line` 协议模块
74+
75+
`src/build/link_line.cppm`,导出:
76+
77+
```cpp
78+
struct UnitTail {
79+
std::string dependencies; // 1. -L/-l + $ORIGIN / @loader_path
80+
std::string cxxRuntime; // 2. -static-libstdc++ / -load_hidden / --exclude-libs
81+
std::string runtimeFallback; // 3. 兜底运行期搜索(今天=SubOS farm 视图)
82+
std::string loaderTag; // 4. --disable-new-dtags,必须字面最后
83+
std::string render() const; // 按上述顺序连接,自动补分隔空格
84+
bool empty() const;
85+
};
86+
```
87+
88+
每个槽位的注释必须写清**为什么在这个位置**,尤其:
89+
`dependencies` 在最前,因为那些目录装的正是本次链接解析到的物理文件;
90+
`runtimeFallback` 必须晚于它们,因为 farm 是 `xlings install` 会重写的视图;
91+
`loaderTag` 字面最后,因为 ld 认最后一个 `--enable/--disable-new-dtags`。
92+
93+
**判据**:`tests/unit/test_link_line.cpp` 断言四槽顺序 + 空槽不产生多余空格
94+
+ 任意槽为空时其余顺序不变。
95+
96+
### S2 — `CompileFlags` 增加 `ldRuntimeFallback`,farm 不再进 `f.ld`
97+
98+
`flags.cppm`:`farm_ld` 从 `f.ld`(`:996-999`)移出,赋给 `f.ldRuntimeFallback`。
99+
字段名取 provider 中立的 "runtime fallback",与 `link_line` 的槽位同名。
100+
101+
**判据**:`test_build_flags.cpp` 断言 `f.ld` 不含 farm 路径、`f.ldRuntimeFallback` 含。
102+
103+
### S3 — `ninja_backend` 改用 `UnitTail` 组装 `unit_ldflags`
104+
105+
`ninja_backend.cppm:1400-1418` 的三次 `unit +=` 换成填槽 + `render()`。
106+
107+
**判据**:`test_ninja_backend.cpp` 新增用例 —— 带 `$ORIGIN` 的链接单元,
108+
在 `ldflags + " " + unit_ldflags` 合成串里 `$ORIGIN` 位置 **早于** farm。
109+
(仅 Linux 分支产出 farm,其它宿主 `GTEST_SKIP`)
110+
111+
### S4 — `Role` 拆出 `SharedLibrary`,`default_contract` 变 `(Role, Format)` 并成为唯一真源
112+
113+
1. `distribution.cppm`:`Role` **末尾**追加 `SharedLibrary`(不动既有枚举值,
114+
因为 `ldStdlibByRole` 按枚举值索引);导出 `kRoleCount`
115+
2. `default_contract(Role, Format)`:`(SharedLibrary, Elf) → ToolchainCoupled`,
116+
其余一律 `SelfContained`(= 今天的行为)
117+
3. `to_string(Role)` 补 `"shared-library"`
118+
4. `flags.cppm:83-92` `role_of`:`LinkUnit::SharedLibrary → Role::SharedLibrary`
119+
5. `flags.cppm:52,54`:`std::array<…, 3>` → `kRoleCount`
120+
6. `flags.cppm:704-709`:`base` 改为调用 `default_contract`,并新增
121+
`sharedContract`;`mi.format` 的推导上移到契约计算之前
122+
7. `wantsArchives`(`:768`)把 `sharedContract` 纳入
123+
124+
**判据**:`test_distribution.cpp` 逐格断言 `(Role × Format) → Contract` 全表。
125+
126+
### S5 — C1 护栏:ELF 上显式 self-contained 的共享库必须隐藏归档符号
127+
128+
`distribution.cppm` ELF 分支,`Role::SharedLibrary` 且 `effective ==
129+
SelfContained` 时追加:
130+
131+
- libstdc++:`-Wl,--exclude-libs,libstdc++.a`
132+
- libc++:`-Wl,--exclude-libs,libc++.a -Wl,--exclude-libs,libc++abi.a`
133+
(有 libunwind.a 时再加一条)
134+
135+
用**归档基名**而非路径:`--exclude-libs` 按归档文件名匹配,GNU ld 与 lld 一致。
136+
显式列名而非 `ALL`,以免连用户自己的静态库一起隐藏。
137+
138+
**判据**:`test_distribution.cpp` 断言该单元格产出含 `--exclude-libs`,
139+
且 `Role::Distributable` 不含。
140+
141+
### S6 — manifest:`cxx_runtime` 增加 `shared` 键
142+
143+
`types.cppm` 加 `cxxRuntimeShared`(`[build]` 与 `[target.<triple>]` 两处);
144+
`toml.cppm:954-968` 的键白名单加 `"shared"`,错误文案同步。
145+
146+
**判据**:`test_manifest.cpp` 解析 `cxx_runtime = { default=…, tests=…, shared=… }`;
147+
未知键仍报错。
148+
149+
### S7 — 测试:把假绿换成真判据
150+
151+
1. `tests/e2e/219_runtime_search_farm_is_last.sh`:断言 farm 是 DT_RPATH 的
152+
**字面**最后一项,删掉「last ABSOLUTE entry」放宽与那段理由
153+
2. **新增 e2e:行为不变量**。构造 `$ORIGIN` 与 farm 同名 SONAME 的局面,
154+
用 `LD_DEBUG=libs` 断言解析到 `$ORIGIN` 那一份。
155+
⚠️ **不得依赖崩溃** —— C2 落地后崩溃会消失,依赖崩溃的断言会立刻假绿
156+
3. 新增 e2e:ELF 共享库**不得**导出 std 符号(`nm -D` 计数为 0)
157+
158+
### S8 — 文档 + 版本 + pin
159+
160+
- `docs/` 用户文档:`cxx_runtime` 的 `shared` 键、共享库默认契约按平台的说明
161+
- 版本 bump(`YYYY.M.D.N`,月日不补零)
162+
- pin 最新 xlings(唯一真源 `src/platform/xlings/xlings.cppm::kXlingsVersion`,
163+
由 `check_version_pins.sh` 机器校验 16 处)
164+
165+
---
166+
167+
## 2. 合并后的验证清单(缺一不可)
168+
169+
| # | 判据 | 方法 |
170+
|---|---|---|
171+
| V1 | farm 是 DT_RPATH 字面最后一项 | `readelf -d` |
172+
| V2 | `libX11.so.6` 解析到 `$ORIGIN` | `LD_DEBUG=libs`,**看行为不看形状** |
173+
| V3 | `bin/libX11.so` 导出 std 符号数 = 0 | `nm -D \| grep -cE '_ZNSt\|_ZNKSt\|_ZSt'` |
174+
| V4 | exe 不再有 `U _ZNKSt13runtime_error4whatEv` | `nm -D --undefined-only` |
175+
| V5 | helloegui GUI 真的起来 | 本机跑,`timeout` 退 143 |
176+
| V6 | **revert 掉 S2+S3 后 V2 必须重新变红** | 证明 C2 没吃掉 A 的判据 |
177+
| V7 | PE / Mach-O 产物字节不变 | CI 对应 job |
178+
| V8 | 生态:mcpp-index workspace 全绿 | compat 包全是 `.so`,是重灾区 |
179+
180+
---
181+
182+
## 2.1 实施记录 —— 计划没写对的三处
183+
184+
**① `default_contract` 根本没有生产调用方。**
185+
它自称「The role -> contract policy, in one place」,实际只有单测调用;真正的策略
186+
在 `flags.cppm:704-709` 独立算了第二遍。所以 S4 不只是"加一个入参",而是把这个
187+
函数**接回主路径**。这也是本次改动里架构收益最大的一处。
188+
189+
**② 「共享库导出零个 std 符号」这个判据一开始是错的。**
190+
实测:一个用了 `std::string` 的 C++ 共享库,即使 toolchain-coupled,也会导出 **31 个**
191+
std 符号 —— 全部是 `W`(weak/COMDAT 模板实例化,从头文件实例化进它自己的 TU),
192+
GLOBAL 为 0。这些是 C++ ABI 的预期行为,进程内归一它们是**对的**。
193+
194+
真正的判据是 **GLOBAL 计数为 0**:`T` 符号只可能来自 `libstdc++.a`。
195+
对照数据:坏版本的共享库是 **713 GLOBAL**(+ weak),好版本是 **0 GLOBAL**。
196+
如果按"总数为零"写,断言不可满足,最后只会被删掉 —— 一条真不变量换成没有不变量。
197+
198+
**③ e2e 的工程形状换了两次才对。**
199+
- `int main()`:DT_RPATH 里**根本没有 `$ORIGIN`**,整条断言链空转(这正是旧断言
200+
能被写成那样的原因 —— 它从未在有 `$ORIGIN` 的产物上跑过)
201+
- 同包内的 `kind = "shared"` target:mcpp 把该包的模块对象**直接链进可执行文件**,
202+
没有 `-l`、也没有 `$ORIGIN`
203+
- **消费一个 path 依赖提供的共享库**:才产生 `-Lbin -Wl,-rpath,'$ORIGIN' -lgreetdep`,
204+
与出问题的真实产物同形
205+
206+
另外接口里写内联定义(`export int f() { return 7; }`)会让符号被实例化进消费者、
207+
依赖边消失,所以接口与实现必须分文件。
208+
209+
## 2.2 实测结果(helloegui:imgui + GLFW + X11)
210+
211+
| 判据 | 修复前 | 修复后 |
212+
|---|---|---|
213+
| DT_RPATH 尾部 | `… : <subos>/lib : $ORIGIN` | `… : $ORIGIN : <subos>/lib` |
214+
| `libX11.so.6` 解析到 | farm(`xim:libX11 1.8.10`) | `$ORIGIN`(farm 未被试到) |
215+
| `bin/libX11.so` 导出 std 符号 | 2931(777 GLOBAL) | **0** |
216+
| `bin/libXau.so` | 9 557 936 B | **39 368 B** |
217+
| `bin/libXdmcp.so` | 9 561 504 B | **41 552 B** |
218+
| exe 未定义 `runtime_error::what` | 有 | **无** |
219+
| 运行 | `symbol lookup error` | **GUI 正常启动** |
220+
221+
红测(两条新 e2e 对已发布 2026.8.11.2):219 报「farm is not the last entry」,
222+
222 报「exports 713 GLOBAL standard-library symbols」—— 均以正确理由失败。
223+
224+
## 3. 已知风险
225+
226+
1. **C2 掩盖 A 的症状** —— 见 V6,这是本 PR 最大的假绿风险
227+
2. **本机 e2e 噪声** —— 共享 gcc specs 污染、`pipefail`+`grep -q` 的 SIGPIPE
228+
flake;本机红必须逐条与已发布二进制比对,不可直接当回归
229+
3. ~~**`--exclude-libs` 的链接器覆盖面**~~ —— **已实测**。两条路径都验证过:
230+
- libstdc++ 分支(`-lstdc++` 形式):e2e 222 不变量 4,GLOBAL 导出 713 → 0
231+
- libc++ 分支(**按完整路径**给归档):直接对拍
232+
`g++ -shared -nostdlib++ <abs>/libstdc++.a` ± `-Wl,--exclude-libs,libstdc++.a`
233+
⇒ `_ZNKSt13runtime_error4whatEv` 导出数 **1 → 0**,证明它按**基名**匹配、
234+
与归档是 `-l` 还是完整路径给出无关
235+
236+
若将来接入其它链接器,应在机制表里加格式/链接器维度,而不是加平台分支。
237+
238+
4. **`dist::CompileFlags::contractByRole` 只写不读**(既有,非本次引入)。
239+
它与 `TargetEntry::cxxRuntimeTests`(既解析不了也不生效)是同一类死字段,
240+
本次没有顺手清理 —— 删一个公开结构体字段是另一件事,不该混进修缺陷的 PR。

0 commit comments

Comments
 (0)