|
| 1 | +# B3 — 产物命名按 target 而非 host(交叉构建每次重链)— 修复方案 |
| 2 | + |
| 3 | +> 2026-08-03 · 基于 `9a696d2`(2026.8.3.2)代码审计 + 本机实测 |
| 4 | +> 来源:`2026-08-03-windows-host-linux-cross-design.md` §6.5 登记的 follow-up |
| 5 | +> 记忆:[[windows-host-linux-cross-canadian]]、[[soname-alias-explicit-ninja-goals]]、 |
| 6 | +> [[explicit-ninja-goals-two-regressions]] |
| 7 | +
|
| 8 | +## 0. 一句话 |
| 9 | + |
| 10 | +`plan.cppm::target_output()` 用**主机**常量拼产物名,交叉构建时 **ninja 声明的输出文件根本不会 |
| 11 | +出现**,于是 **每次 `mcpp build` 都重新链接一遍** —— 这不是命名难看,是增量构建对 PE 目标失效。 |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## 1. 先更正一处判断 |
| 16 | + |
| 17 | +`windows-host-linux-cross-design.md` §6.5 把 B3 写成「对称地错」: |
| 18 | + |
| 19 | +> | 方向 | 现状 | 应当 | |
| 20 | +> |---|---|---| |
| 21 | +> | Windows → linux-musl | `mcpp.exe`(却是 ELF) | `mcpp` | |
| 22 | +> | **Linux → windows-gnu** | **`mcpp`(却是 PE)** | `mcpp.exe` | |
| 23 | +
|
| 24 | +**下面那一行是错的。** 本机实测,Linux 主机交叉到 `x86_64-windows-gnu`: |
| 25 | + |
| 26 | +```console |
| 27 | +$ mcpp build --target x86_64-windows-gnu |
| 28 | +$ find target -type f -path "*/bin/*" |
| 29 | +target/x86_64-windows-gnu/751cb693195ee82c/bin/b3probe.exe ← 有 .exe |
| 30 | +$ file …/b3probe.exe |
| 31 | +PE32+ executable (console) x86-64, for MS Windows |
| 32 | +``` |
| 33 | + |
| 34 | +产物名是对的。原因是 **mingw 的 GCC driver 自己补的**:`-o foo` 且 `foo` 无扩展名时, |
| 35 | +mingw GCC 输出 `foo.exe`。mcpp 从未参与这个决定。 |
| 36 | + |
| 37 | +真正的问题因此换了一个形态,而且更实际。 |
| 38 | + |
| 39 | +--- |
| 40 | + |
| 41 | +## 2. 真实症状:ninja 的输出声明与产物不符 ⇒ 每次重链 |
| 42 | + |
| 43 | +`target_output()` 在 Linux 主机上返回 `bin/<name>`(`exe_suffix` 为空),ninja 规则照抄: |
| 44 | + |
| 45 | +```ninja |
| 46 | +build bin/b3probe : cxx_link obj/main.o # ← 声明的产物 |
| 47 | +``` |
| 48 | + |
| 49 | +GCC 却写出 `bin/b3probe.exe`。于是 `bin/b3probe` **从来不存在**: |
| 50 | + |
| 51 | +```console |
| 52 | +$ test -f target/x86_64-windows-gnu/*/bin/b3probe && echo EXISTS || echo MISSING |
| 53 | +MISSING |
| 54 | +``` |
| 55 | + |
| 56 | +ninja 每次都发现声明的输出缺失 ⇒ 重跑链接边。实测(连续两次 build 比对 mtime): |
| 57 | + |
| 58 | +```console |
| 59 | +mtime before: 1785736156 |
| 60 | +mtime after rebuild: 1785736180 |
| 61 | +RESULT: RELINKED — declared output never exists, so ninja reruns the link every build |
| 62 | +``` |
| 63 | + |
| 64 | +**两个方向的实际后果因此并不对称:** |
| 65 | + |
| 66 | +| 方向 | ninja 声明 | 实际产物 | 后果 | |
| 67 | +|---|---|---|---| |
| 68 | +| Linux → windows-gnu | `bin/foo` | `bin/foo.exe`(GCC 补) | ❌ **声明永不满足 ⇒ 每次重链** | |
| 69 | +| Windows → linux-musl | `bin/foo.exe` | `bin/foo.exe`(ELF) | ⚠️ 声明与产物一致,构建正常;只是 ELF 顶着 `.exe` | |
| 70 | + |
| 71 | +也就是说:**Windows→Linux 只是难看,Linux→Windows 是功能缺陷。** 后者恰恰是已经发布、 |
| 72 | +CI 每天在跑的那条路径(`mingw-cross linux→windows`)。 |
| 73 | + |
| 74 | +> **为什么 CI 一直绿**:`102_mingw_cross_wine.sh:58` 用 `find target/$TRIPLE -name '*.exe'` |
| 75 | +> 找产物 —— 它找的是**真实产物**,不是 ninja 声明的那个,所以完全感知不到这层不一致。 |
| 76 | +> 测试断言的是「产物存在且是 PE32+」,这两条都成立。**一个测试可以全绿而问题就在它脚下**, |
| 77 | +> 与 [[explicit-ninja-goals-two-regressions]] 同型。 |
| 78 | +
|
| 79 | +--- |
| 80 | + |
| 81 | +## 3. 根因 |
| 82 | + |
| 83 | +`src/build/plan.cppm:200-213`: |
| 84 | + |
| 85 | +```cpp |
| 86 | +std::filesystem::path target_output(const mcpp::manifest::Target& t) { |
| 87 | + if (t.kind == Library) |
| 88 | + return "bin" / std::format("{}{}{}", platform::lib_prefix, t.name, |
| 89 | + platform::static_lib_ext); |
| 90 | + if (t.kind == SharedLibrary) |
| 91 | + return "bin" / std::format("{}{}{}", platform::lib_prefix, t.name, |
| 92 | + platform::shared_lib_ext); |
| 93 | + return "bin" / std::format("{}{}", t.name, platform::exe_suffix); |
| 94 | +} |
| 95 | +``` |
| 96 | +
|
| 97 | +四个 `platform::` 常量(`platform/common.cppm:18-33`)按 `#if defined(_WIN32)/__APPLE__` 编译期 |
| 98 | +分支,描述的是**这台机器**的约定。函数签名 `(const Target&)` 里**没有任何 target 信息**, |
| 99 | +所以它连"想按 target 求值"都做不到 —— 这才是问题的结构性来源。 |
| 100 | +
|
| 101 | +同一文件里 `shared_library_link_flags()`(`:236-243`)有同样的毛病,而且更隐蔽: |
| 102 | +
|
| 103 | +```cpp |
| 104 | +if constexpr (mcpp::platform::is_windows) { // ← host |
| 105 | + flags.push_back(target_output(t).generic_string()); |
| 106 | +} else { |
| 107 | + flags.push_back("-L" + …); |
| 108 | + if constexpr (mcpp::platform::supports_rpath) { // ← host |
| 109 | + if constexpr (mcpp::platform::is_macos) { … } // ← host |
| 110 | +``` |
| 111 | + |
| 112 | +「链接一个共享库要用完整路径还是 `-L`/`-l`/`-rpath`」是 **target** 的属性(PE 无 rpath, |
| 113 | +Mach-O 用 `@loader_path`,ELF 用 `$ORIGIN`)。今天交叉构建共享库会发出宿主形态的链接参数。 |
| 114 | + |
| 115 | +### 3.1 已核对:其余 `platform::` 用法都是对的 |
| 116 | + |
| 117 | +`exe_suffix` 全仓 16 处引用,除 `plan.cppm` 外全部在找**主机上的可执行文件** |
| 118 | +(`ninja`/`xlings`/`nasm`/`llvm-ar`/`clang-scan-deps`/`clang++`)—— |
| 119 | +`config.cppm`、`xlings.cppm`、`clang.cppm`、`llvm.cppm`、`ninja_backend.cppm:1394`、 |
| 120 | +`fallback/xlings_binary.cppm`。**这些是正确的 host 语义,不要动。** |
| 121 | + |
| 122 | +`build_program.cppm:506` 的 `build.mcpp.exe` 也是 host 语义(L3 构建程序跑在主机上), |
| 123 | +同样不动 —— 参见 [[build-mcpp-helper-self-containment]]。 |
| 124 | + |
| 125 | +**本方案的改动面只有 `src/build/plan.cppm` 一个文件。** |
| 126 | + |
| 127 | +--- |
| 128 | + |
| 129 | +## 4. 正确的命名是 (os, env) 二元函数,不是 os 一元 |
| 130 | + |
| 131 | +这是本方案唯一需要动脑的地方 —— 直觉上「Windows 就是 `.lib`」是错的: |
| 132 | + |
| 133 | +| target | exe | 静态库 | 共享库 | 导入库 | |
| 134 | +|---|---|---|---|---| |
| 135 | +| `*-linux-*` | *(无)* | `libfoo.a` | `libfoo.so` | — | |
| 136 | +| `*-macos` | *(无)* | `libfoo.a` | `libfoo.dylib` | — | |
| 137 | +| `x86_64-windows-**gnu**` | `.exe` | **`libfoo.a`** | `foo.dll` | `libfoo.dll.a` | |
| 138 | +| `x86_64-windows-**msvc**` | `.exe` | **`foo.lib`** | `foo.dll` | `foo.lib` | |
| 139 | + |
| 140 | +`windows-gnu` 用的是 **GNU 约定**(`lib` 前缀 + `.a`),而现行的 `_WIN32` 分支写死 |
| 141 | +`lib_prefix=""` / `static_lib_ext=".lib"` —— 也就是说**在 Windows 主机上用 mingw 构建静态库, |
| 142 | +今天的命名就已经是错的**(会叫 `foo.lib` 而 mingw 的 `ar` 产出的是 GNU archive)。 |
| 143 | +这一条与交叉无关,是存量缺陷,顺带一并修掉。 |
| 144 | + |
| 145 | +--- |
| 146 | + |
| 147 | +## 5. 方案 |
| 148 | + |
| 149 | +### 5.1 引入 `ArtifactNaming`(从 triple 导出,一次求值) |
| 150 | + |
| 151 | +放在 `toolchain/triple.cppm` 旁边(它是 triple 语义的家),或 `build/plan.cppm` 内部: |
| 152 | + |
| 153 | +```cpp |
| 154 | +// 产物命名约定 —— 由 TARGET 的 (os, env) 决定,与构建主机无关。 |
| 155 | +struct ArtifactNaming { |
| 156 | + std::string_view exeSuffix; // "" | ".exe" |
| 157 | + std::string_view libPrefix; // "lib" | "" |
| 158 | + std::string_view staticLibExt; // ".a" | ".lib" |
| 159 | + std::string_view sharedLibExt; // ".so" | ".dylib" | ".dll" |
| 160 | + bool sharedNeedsImportLib; // PE: 链接消费者要 import lib |
| 161 | +}; |
| 162 | + |
| 163 | +ArtifactNaming artifact_naming(const triple::Triple& t); |
| 164 | +``` |
| 165 | +
|
| 166 | +空 triple(host target)回退到今天的 `platform::` 常量 —— 与 B2 的 |
| 167 | +`target_supports_full_static(triple, hostCapability)` 完全同型,**保证 host 构建逐位不变**。 |
| 168 | +
|
| 169 | +### 5.2 `target_output` 接受它 |
| 170 | +
|
| 171 | +```cpp |
| 172 | +std::filesystem::path target_output(const Target& t, const ArtifactNaming& n); |
| 173 | +``` |
| 174 | + |
| 175 | +9 处调用点全在 `plan.cppm` 内,且都在 `make_plan()` 的作用域里 —— |
| 176 | +`make_plan` 已经有 `const Toolchain& tc`(`plan.cppm:129`),`tc.targetTriple` 直接可达, |
| 177 | +在函数入口构造一次 `ArtifactNaming` 传下去即可。**不需要改任何跨模块签名。** |
| 178 | + |
| 179 | +### 5.3 `shared_library_link_flags` 同步改为按 target |
| 180 | + |
| 181 | +`is_windows` → `n.sharedNeedsImportLib`(或直接判 `triple.is_pe()`); |
| 182 | +`supports_rpath` / `is_macos` → 按 target 的 os 求值。 |
| 183 | + |
| 184 | +### 5.4 `runtime_aliases_for_target` 要一起看 |
| 185 | + |
| 186 | +`plan.cppm:215-228` 依赖 `target_output()` 的结果去比对 `t.soname`: |
| 187 | + |
| 188 | +```cpp |
| 189 | +auto output = target_output(t); |
| 190 | +if (t.soname != output.filename().string()) |
| 191 | + aliases.push_back(output.parent_path() / t.soname); |
| 192 | +``` |
| 193 | + |
| 194 | +签名要一起加参数。**这条边曾经因为类似改动漏生成过 soname 别名** |
| 195 | +(见 [[soname-alias-explicit-ninja-goals]]:别名是独立 ninja 边,只挂 `default`, |
| 196 | +改成显式目标后被跳过)。**改完必须有一条断言 soname 别名仍然生成的测试**,不能只靠 review。 |
| 197 | + |
| 198 | +--- |
| 199 | + |
| 200 | +## 6. 影响面(已实测清点) |
| 201 | + |
| 202 | +### 6.1 会改变行为的只有交叉构建 |
| 203 | + |
| 204 | +host 构建(host == target)命名逐位不变,因为空 triple 回退到今天的常量。 |
| 205 | +**22 个 e2e 里出现 `.exe` 的**绝大多数是 Windows 上的 host 构建,不受影响。 |
| 206 | + |
| 207 | +真正受影响的是三处交叉断言: |
| 208 | + |
| 209 | +| 文件 | 现状 | 改后 | |
| 210 | +|---|---|---| |
| 211 | +| `tests/e2e/102_mingw_cross_wine.sh:58` | `find target/$TRIPLE -name '*.exe'` | ✅ 仍然匹配(产物名不变,只是 ninja 声明对上了) | |
| 212 | +| `tests/e2e/112_build_mcpp_cross.sh:53` | `find target -name 'crossbp.exe'` | ✅ 仍然匹配 | |
| 213 | +| `.github/workflows/cross-build-test.yml` windows→linux job | 已写成 `\( -name "mcpp" -o -name "mcpp.exe" \)` | ✅ 两种拼写都收,改前改后都对 | |
| 214 | + |
| 215 | +**结论与 §6.5 当初的担心相反:e2e 与 CI 基本不用改。** 因为 Linux→Windows 的产物名本来就由 |
| 216 | +GCC 决定为 `.exe`,修复只是让 **ninja 的声明** 追上事实;Windows→Linux 的产物名会从 |
| 217 | +`mcpp.exe` 变成 `mcpp`,而唯一消费它的 CI job 已经两种都匹配。 |
| 218 | + |
| 219 | +### 6.2 需要确认的一处 |
| 220 | + |
| 221 | +`release.yml` / `bootstrap-macos.yml` 里的打包路径(`find target -path "*/bin/mcpp"` 等)全部是 |
| 222 | +**host 构建**的产物,不经过交叉路径,不受影响。已逐条核对。 |
| 223 | + |
| 224 | +--- |
| 225 | + |
| 226 | +## 7. 验证判据(缺一不可) |
| 227 | + |
| 228 | +1. **回归断言先行**:新增测试,断言 `artifact_naming()` 对 |
| 229 | + `x86_64-linux-musl` / `x86_64-windows-gnu` / `x86_64-windows-msvc` / `aarch64-macos` / 空 triple |
| 230 | + 的五元组取值。**先跑一次确认它在修复前是红的** —— 与 B2 的 commit 1 同样的纪律, |
| 231 | + 见 [[windows-host-linux-cross-canadian]]。 |
| 232 | +2. **`windows-gnu` 用 GNU 约定**:静态库断言为 `libfoo.a`,**不是** `foo.lib`(§4 的存量缺陷)。 |
| 233 | +3. **不再重链**(本方案的核心收益,也是唯一能证伪「修好了」的判据): |
| 234 | + ```bash |
| 235 | + mcpp build --target x86_64-windows-gnu |
| 236 | + T1=$(stat -c %Y <artifact>) |
| 237 | + mcpp build --target x86_64-windows-gnu |
| 238 | + T2=$(stat -c %Y <artifact>) |
| 239 | + [ "$T1" = "$T2" ] # 修复前必失败 |
| 240 | + ``` |
| 241 | + 这条要进 e2e,否则它会悄悄退化回去。 |
| 242 | +4. **host 构建逐位不变**:Linux/macOS/Windows 三个 host 的 `mcpp build` 产物名与改前一致。 |
| 243 | +5. **soname 别名仍然生成**(§5.4)。 |
| 244 | +6. 现有 22 个含 `.exe` 的 e2e 全绿。 |
| 245 | + |
| 246 | +--- |
| 247 | + |
| 248 | +## 8. 分期 |
| 249 | + |
| 250 | +单 PR,提交分层(与 B2 同款,理由见 [[windows-host-linux-cross-canadian]]): |
| 251 | + |
| 252 | +| commit | 内容 | |
| 253 | +|---|---| |
| 254 | +| 1 | `artifact_naming()` + 判据 1/2 的断言,**实现为返回 host 常量的桩** ⇒ 测试红 | |
| 255 | +| 2 | 实现按 (os, env) 求值 ⇒ 测试绿 | |
| 256 | +| 3 | `target_output` / `runtime_aliases_for_target` 接受 `ArtifactNaming` | |
| 257 | +| 4 | `shared_library_link_flags` 按 target 求值 | |
| 258 | +| 5 | e2e:判据 3 的「不重链」断言 | |
| 259 | + |
| 260 | +--- |
| 261 | + |
| 262 | +## 9. 未决 |
| 263 | + |
| 264 | +1. **`windows-gnu` 静态库从 `foo.lib` 改名为 `libfoo.a`** 是唯一一处会改变 **host 构建** |
| 265 | + 产物名的改动(Windows 主机 + mingw 工具链)。它是修正存量缺陷,但确实是行为变更 —— |
| 266 | + 是否要同期做,还是拆一个独立 PR?我倾向同期,因为把「命名由 (os,env) 决定」做成半截 |
| 267 | + 反而更难解释。 |
| 268 | +2. **PE 的 import lib(`libfoo.dll.a` / `foo.lib`)目前 mcpp 完全没建模**。共享库交叉到 |
| 269 | + Windows 时消费者拿什么链接,现在靠 `shared_library_link_flags` 直接塞产物路径蒙混过去。 |
| 270 | + 本方案的 `sharedNeedsImportLib` 只是给它留了个位置,**真正的 import lib 支持不在本期范围**, |
| 271 | + 建议另开。 |
0 commit comments