2026-08-03 · 基于
9a696d2(2026.8.3.2)代码审计 + 本机实测 来源:2026-08-03-windows-host-linux-cross-design.md§6.5 登记的 follow-up 记忆:[[windows-host-linux-cross-canadian]]、[[soname-alias-explicit-ninja-goals]]、 [[explicit-ninja-goals-two-regressions]]
plan.cppm::target_output() 用主机常量拼产物名,交叉构建时 ninja 声明的输出文件根本不会
出现,于是 每次 mcpp build 都重新链接一遍 —— 这不是命名难看,是增量构建对 PE 目标失效。
windows-host-linux-cross-design.md §6.5 把 B3 写成「对称地错」:
方向 现状 应当 Windows → linux-musl mcpp.exe(却是 ELF)mcppLinux → windows-gnu mcpp(却是 PE)mcpp.exe
下面那一行是错的。 本机实测,Linux 主机交叉到 x86_64-windows-gnu:
$ mcpp build --target x86_64-windows-gnu
$ find target -type f -path "*/bin/*"
target/x86_64-windows-gnu/751cb693195ee82c/bin/b3probe.exe ← 有 .exe
$ file …/b3probe.exe
PE32+ executable (console) x86-64, for MS Windows产物名是对的。原因是 mingw 的 GCC driver 自己补的:-o foo 且 foo 无扩展名时,
mingw GCC 输出 foo.exe。mcpp 从未参与这个决定。
真正的问题因此换了一个形态,而且更实际。
target_output() 在 Linux 主机上返回 bin/<name>(exe_suffix 为空),ninja 规则照抄:
build bin/b3probe : cxx_link obj/main.o # ← 声明的产物GCC 却写出 bin/b3probe.exe。于是 bin/b3probe 从来不存在:
$ test -f target/x86_64-windows-gnu/*/bin/b3probe && echo EXISTS || echo MISSING
MISSINGninja 每次都发现声明的输出缺失 ⇒ 重跑链接边。实测(连续两次 build 比对 mtime):
mtime before: 1785736156
mtime after rebuild: 1785736180
RESULT: RELINKED — declared output never exists, so ninja reruns the link every build两个方向的实际后果因此并不对称:
| 方向 | ninja 声明 | 实际产物 | 后果 |
|---|---|---|---|
| Linux → windows-gnu | bin/foo |
bin/foo.exe(GCC 补) |
❌ 声明永不满足 ⇒ 每次重链 |
| Windows → linux-musl | bin/foo.exe |
bin/foo.exe(ELF) |
.exe |
也就是说:Windows→Linux 只是难看,Linux→Windows 是功能缺陷。 后者恰恰是已经发布、
CI 每天在跑的那条路径(mingw-cross linux→windows)。
为什么 CI 一直绿:
102_mingw_cross_wine.sh:58用find target/$TRIPLE -name '*.exe'找产物 —— 它找的是真实产物,不是 ninja 声明的那个,所以完全感知不到这层不一致。 测试断言的是「产物存在且是 PE32+」,这两条都成立。一个测试可以全绿而问题就在它脚下, 与 [[explicit-ninja-goals-two-regressions]] 同型。
src/build/plan.cppm:200-213:
std::filesystem::path target_output(const mcpp::manifest::Target& t) {
if (t.kind == Library)
return "bin" / std::format("{}{}{}", platform::lib_prefix, t.name,
platform::static_lib_ext);
if (t.kind == SharedLibrary)
return "bin" / std::format("{}{}{}", platform::lib_prefix, t.name,
platform::shared_lib_ext);
return "bin" / std::format("{}{}", t.name, platform::exe_suffix);
}四个 platform:: 常量(platform/common.cppm:18-33)按 #if defined(_WIN32)/__APPLE__ 编译期
分支,描述的是这台机器的约定。函数签名 (const Target&) 里没有任何 target 信息,
所以它连"想按 target 求值"都做不到 —— 这才是问题的结构性来源。
同一文件里 shared_library_link_flags()(:236-243)有同样的毛病,而且更隐蔽:
if constexpr (mcpp::platform::is_windows) { // ← host
flags.push_back(target_output(t).generic_string());
} else {
flags.push_back("-L" + …);
if constexpr (mcpp::platform::supports_rpath) { // ← host
if constexpr (mcpp::platform::is_macos) { … } // ← host「链接一个共享库要用完整路径还是 -L/-l/-rpath」是 target 的属性(PE 无 rpath,
Mach-O 用 @loader_path,ELF 用 $ORIGIN)。今天交叉构建共享库会发出宿主形态的链接参数。
exe_suffix 全仓 16 处引用,除 plan.cppm 外全部在找主机上的可执行文件
(ninja/xlings/nasm/llvm-ar/clang-scan-deps/clang++)——
config.cppm、xlings.cppm、clang.cppm、llvm.cppm、ninja_backend.cppm:1394、
fallback/xlings_binary.cppm。这些是正确的 host 语义,不要动。
build_program.cppm:506 的 build.mcpp.exe 也是 host 语义(L3 构建程序跑在主机上),
同样不动 —— 参见 [[build-mcpp-helper-self-containment]]。
本方案的改动面只有 src/build/plan.cppm 一个文件。
这是本方案唯一需要动脑的地方 —— 直觉上「Windows 就是 .lib」是错的:
| target | exe | 静态库 | 共享库 | 导入库 |
|---|---|---|---|---|
*-linux-* |
(无) | libfoo.a |
libfoo.so |
— |
*-macos |
(无) | libfoo.a |
libfoo.dylib |
— |
x86_64-windows-**gnu** |
.exe |
libfoo.a |
foo.dll |
libfoo.dll.a |
x86_64-windows-**msvc** |
.exe |
foo.lib |
foo.dll |
foo.lib |
windows-gnu 用的是 GNU 约定(lib 前缀 + .a),而现行的 _WIN32 分支写死
lib_prefix="" / static_lib_ext=".lib" —— 也就是说在 Windows 主机上用 mingw 构建静态库,
今天的命名就已经是错的(会叫 foo.lib 而 mingw 的 ar 产出的是 GNU archive)。
这一条与交叉无关,是存量缺陷,顺带一并修掉。
放在 toolchain/triple.cppm 旁边(它是 triple 语义的家),或 build/plan.cppm 内部:
// 产物命名约定 —— 由 TARGET 的 (os, env) 决定,与构建主机无关。
struct ArtifactNaming {
std::string_view exeSuffix; // "" | ".exe"
std::string_view libPrefix; // "lib" | ""
std::string_view staticLibExt; // ".a" | ".lib"
std::string_view sharedLibExt; // ".so" | ".dylib" | ".dll"
bool sharedNeedsImportLib; // PE: 链接消费者要 import lib
};
ArtifactNaming artifact_naming(const triple::Triple& t);空 triple(host target)回退到今天的 platform:: 常量 —— 与 B2 的
target_supports_full_static(triple, hostCapability) 完全同型,保证 host 构建逐位不变。
std::filesystem::path target_output(const Target& t, const ArtifactNaming& n);9 处调用点全在 plan.cppm 内,且都在 make_plan() 的作用域里 ——
make_plan 已经有 const Toolchain& tc(plan.cppm:129),tc.targetTriple 直接可达,
在函数入口构造一次 ArtifactNaming 传下去即可。不需要改任何跨模块签名。
is_windows → n.sharedNeedsImportLib(或直接判 triple.is_pe());
supports_rpath / is_macos → 按 target 的 os 求值。
plan.cppm:215-228 依赖 target_output() 的结果去比对 t.soname:
auto output = target_output(t);
if (t.soname != output.filename().string())
aliases.push_back(output.parent_path() / t.soname);签名要一起加参数。这条边曾经因为类似改动漏生成过 soname 别名
(见 [[soname-alias-explicit-ninja-goals]]:别名是独立 ninja 边,只挂 default,
改成显式目标后被跳过)。改完必须有一条断言 soname 别名仍然生成的测试,不能只靠 review。
host 构建(host == target)命名逐位不变,因为空 triple 回退到今天的常量。
22 个 e2e 里出现 .exe 的绝大多数是 Windows 上的 host 构建,不受影响。
真正受影响的是三处交叉断言:
| 文件 | 现状 | 改后 |
|---|---|---|
tests/e2e/102_mingw_cross_wine.sh:58 |
find target/$TRIPLE -name '*.exe' |
✅ 仍然匹配(产物名不变,只是 ninja 声明对上了) |
tests/e2e/112_build_mcpp_cross.sh:53 |
find target -name 'crossbp.exe' |
✅ 仍然匹配 |
.github/workflows/cross-build-test.yml windows→linux job |
已写成 \( -name "mcpp" -o -name "mcpp.exe" \) |
✅ 两种拼写都收,改前改后都对 |
结论与 §6.5 当初的担心相反:e2e 与 CI 基本不用改。 因为 Linux→Windows 的产物名本来就由
GCC 决定为 .exe,修复只是让 ninja 的声明 追上事实;Windows→Linux 的产物名会从
mcpp.exe 变成 mcpp,而唯一消费它的 CI job 已经两种都匹配。
release.yml / bootstrap-macos.yml 里的打包路径(find target -path "*/bin/mcpp" 等)全部是
host 构建的产物,不经过交叉路径,不受影响。已逐条核对。
- 回归断言先行:新增测试,断言
artifact_naming()对x86_64-linux-musl/x86_64-windows-gnu/x86_64-windows-msvc/aarch64-macos/ 空 triple 的五元组取值。先跑一次确认它在修复前是红的 —— 与 B2 的 commit 1 同样的纪律, 见 [[windows-host-linux-cross-canadian]]。 windows-gnu用 GNU 约定:静态库断言为libfoo.a,不是foo.lib(§4 的存量缺陷)。- 不再重链(本方案的核心收益,也是唯一能证伪「修好了」的判据):
这条要进 e2e,否则它会悄悄退化回去。
mcpp build --target x86_64-windows-gnu T1=$(stat -c %Y <artifact>) mcpp build --target x86_64-windows-gnu T2=$(stat -c %Y <artifact>) [ "$T1" = "$T2" ] # 修复前必失败
- host 构建逐位不变:Linux/macOS/Windows 三个 host 的
mcpp build产物名与改前一致。 - soname 别名仍然生成(§5.4)。
- 现有 22 个含
.exe的 e2e 全绿。
三个 PR,不要合并(依据见 §9):
单 PR,提交分层(与 B2 同款,理由见 [[windows-host-linux-cross-canadian]]):
| commit | 内容 |
|---|---|
| 1 | artifact_naming() + 判据 1/2 的断言,实现为返回 host 常量的桩 ⇒ 测试红 |
| 2 | 实现按 (os, env) 求值 ⇒ 测试绿(含 windows-gnu → libfoo.a,§9.1) |
| 3 | target_output / runtime_aliases_for_target 接受 ArtifactNaming |
| 4 | shared_library_link_flags 按 target 求值 |
| 5 | e2e:判据 3 的「不重链」断言 |
CHANGELOG 必须写出 Windows + mingw 静态库改名这条对外可见的行为变更。
独立小 PR。make_plan 入口检查:target 非 ELF 且存在 SharedLibrary 目标 ⇒ 返回明确错误,
指向追踪 issue。附一条 e2e:「PE 目标上声明 SharedLibrary 得到可读的 mcpp 错误,
而不是一个链接不上的产物」。
先于 PR-3,也可以先于 PR-1 —— 它不依赖命名改动,且立刻消除一类未定义行为。
另开设计文档。 前置条件是先补齐 PE 与 Mach-O 的共享库 e2e 覆盖,否则又是一批 无验证代码。不要在没有覆盖的前提下加产物边。
原本的顾虑是「它会改变 host 构建的产物名」。把消费链逐段查完之后,这个顾虑站不住。
| 消费者 | 机制 | 受改名影响? |
|---|---|---|
| mcpp 包间依赖 | object 级内联 —— plan.cppm:836 把依赖包的 .o 直接塞进 lu.objects,根本不产生也不读取 .a |
❌ 无影响 |
| 外部预编译库(compat.* 等) | 自由形式 ldflags(types.cppm:173),库名由包描述符写死,mcpp 不参与命名 |
❌ 无影响 |
[runtime] library_dirs |
是目录而非库名(types.cppm:321);Windows 侧只按 .dll 扩展名做运行期部署(plan.cppm:418-434) |
❌ 无影响 |
| ninja 输出声明 | 来自 target_output() 本身 |
✅ 同步改,这正是修复 |
| 最终用户 / 外部构建系统 | 直接引用产物路径 |
关键结构性事实:mcpp 的静态库不是内部链接单元。 包依赖走 object 内联,所以 Library
目标产出的 .a/.lib 只有一个消费者 —— 把产物拿去给 mcpp 之外的世界用的人。
这让改名从「牵一发动全身」降级为「改一个对外文件名」。
toolchain/fingerprint.cppm:94-103 的 8 个 part 里没有产物名,但有 MCPP_VERSION。
也就是说任何版本 bump 都会换一个 target/<triple>/<fp>/ 目录 —— 改名后不会出现
「旧目录里躺着旧名产物、新逻辑找不到」的混合态。不需要任何缓存迁移或 cache clean 提示。
把「命名由 (os, env) 决定」做成半截,会留下一个比现状更难解释的状态:
exe_suffix 按 target 走了,static_lib_ext 还按 host —— 下一个读代码的人无从判断
哪个常量能信。§4 那张表的价值恰恰在于它是一条完整的规则,而不是四个独立特例。
而且这不是「顺带做的优化」,它是存量正确性缺陷:今天在 Windows 主机上用 mingw 工具链
构建静态库,产物叫 foo.lib,而 mingw 的 ar 产出的是 GNU archive —— 一个 MSVC 拿不去用、
名字又冒充 MSVC 约定的文件。这个错误与交叉编译无关,今天就在发生。
同期做。 唯一需要的额外动作是在 CHANGELOG 明确写出这条行为变更(Windows + mingw 静态库产物改名),因为它对外可见 —— 但它影响的是一个今天就是错的名字。
调研到一个改变问题性质的事实。
5 个共享库 e2e 全部声明 # requires: elf:
tests/e2e/08_shared_library.sh:2: # requires: elf
tests/e2e/55_dependency_shared_artifact.sh:2:# requires: elf
tests/e2e/56_transitive_shared_artifact.sh:2:# requires: elf
tests/e2e/57_static_dep_shared_artifact.sh:2:# requires: elf
tests/e2e/64_shared_soname_runtime_alias.sh:2:# requires: elf
而 elf 这个 capability 只有 Linux 分支会加(run_all.sh:46);
Darwin 加的是 macos,Windows 加的是 windows。
所以 mcpp 的共享库支持是 Linux-only 的既成事实 —— 不只是 PE,连 macOS 的 Mach-O 也从未被端到端验证过。
这把问题从「缺一个 import lib 功能」重新定义为:共享库这条路在非 ELF 平台上从来没走通过, 而代码里却有看起来能走的分支。
plan.cppm:236-243:
if constexpr (mcpp::platform::is_windows) {
flags.push_back(target_output(t).generic_string()); // 直接塞 foo.dll 的路径
} else {
flags.push_back("-L" + …); flags.push_back("-l" + t.name);- mingw:链接器确实容忍直接链
.dll(ld 会自动生成 import stub),所以可能能工作 —— 但没有任何测试证明过。 - MSVC:
link.exe无法链接.dll,它需要.libimport library。这条路径必然失败。 - 而且这个分支按 host 求值(
is_windows),交叉时连方向都是错的 —— 与 B3 主体同病。
| 路线 | 内容 | 评价 |
|---|---|---|
| A. 完整建模 import lib | ArtifactNaming 增加 import lib 产物,ninja 加一条边,链接消费者改用 import lib;mingw 用 -Wl,--out-implib,MSVC 用 link.exe 自动产出的 .lib |
正确但昂贵:要同时补 PE 与 Mach-O 的共享库 e2e,否则又是一批无覆盖代码。不该和 B3 混在一起。 |
| B. 先画边界:非 ELF 目标上共享库明确拒绝 ✅ | SharedLibrary 目标在 PE / Mach-O 目标上返回一条清晰的 mcpp 错误,指明「共享库目前仅支持 ELF 目标,追踪 issue: …」 |
**静默产出不可用的东西,比明确拒绝坏得多。**成本极低,立刻消除一整类未定义行为 |
| C. 维持现状 | 保留未验证分支 | ❌ 最差:代码看起来支持,实际未知,用户踩坑时离根因隔三层 |
推荐 B,而且它应该先于 A。 理由与 [[index-refresh-resolution-driven]] 里那条 「offline-first 被 TTL 门压成不可达代码」同源:一段没有测试覆盖、又没有明确拒绝的分支, 是最难清理的技术债 —— 它既不能被信任,又不能被删除,因为没人知道谁在依赖它。
先把边界写死,ArtifactNaming::sharedNeedsImportLib 这个字段就有了明确语义:
它当前的唯一用途是驱动那条拒绝,而不是假装支持。等真要做 A 时,再把它变成产物声明。
B3 主体(§5)不依赖这个决定:target_output() 对 SharedLibrary 仍按 (os, env) 给出
正确的 foo.dll / libfoo.so / libfoo.dylib,该怎么命名怎么命名。路线 B 只是在
make_plan 入口多一条前置检查。
建议拆分: B3 主体一个 PR(§8 的五段);路线 B 一个独立小 PR(带一条「PE 目标上声明 SharedLibrary 得到明确错误」的 e2e);路线 A 另开设计文档。
| # | 问题 | 决定 | 依据 |
|---|---|---|---|
| 1 | foo.lib → libfoo.a |
同期做 | 消费链实测无影响(包依赖走 object 内联、外部库走 ldflags、fingerprint 不含产物名);且它是存量正确性缺陷,不是顺带优化 |
| 2 | PE import lib | 本期不建模,改为明确拒绝 | 共享库在 PE/Mach-O 上零 e2e 覆盖,现有分支是未验证推测;静默产出不可用产物比明确报错坏得多。完整支持另开设计 |