Skip to content

Latest commit

 

History

History
398 lines (280 loc) · 18.3 KB

File metadata and controls

398 lines (280 loc) · 18.3 KB

B3 — 产物命名按 target 而非 host(交叉构建每次重链)— 修复方案

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]]

0. 一句话

plan.cppm::target_output()主机常量拼产物名,交叉构建时 ninja 声明的输出文件根本不会 出现,于是 每次 mcpp build 都重新链接一遍 —— 这不是命名难看,是增量构建对 PE 目标失效。


1. 先更正一处判断

windows-host-linux-cross-design.md §6.5 把 B3 写成「对称地错」:

方向 现状 应当
Windows → linux-musl mcpp.exe(却是 ELF) mcpp
Linux → 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 foofoo 无扩展名时, mingw GCC 输出 foo.exe。mcpp 从未参与这个决定。

真正的问题因此换了一个形态,而且更实际。


2. 真实症状:ninja 的输出声明与产物不符 ⇒ 每次重链

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
MISSING

ninja 每次都发现声明的输出缺失 ⇒ 重跑链接边。实测(连续两次 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) ⚠️ 声明与产物一致,构建正常;只是 ELF 顶着 .exe

也就是说:Windows→Linux 只是难看,Linux→Windows 是功能缺陷。 后者恰恰是已经发布、 CI 每天在跑的那条路径(mingw-cross linux→windows)。

为什么 CI 一直绿:102_mingw_cross_wine.sh:58find target/$TRIPLE -name '*.exe' 找产物 —— 它找的是真实产物,不是 ninja 声明的那个,所以完全感知不到这层不一致。 测试断言的是「产物存在且是 PE32+」,这两条都成立。一个测试可以全绿而问题就在它脚下, 与 [[explicit-ninja-goals-two-regressions]] 同型。


3. 根因

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)。今天交叉构建共享库会发出宿主形态的链接参数。

3.1 已核对:其余 platform:: 用法都是对的

exe_suffix 全仓 16 处引用,除 plan.cppm 外全部在找主机上的可执行文件 (ninja/xlings/nasm/llvm-ar/clang-scan-deps/clang++)—— config.cppmxlings.cppmclang.cppmllvm.cppmninja_backend.cppm:1394fallback/xlings_binary.cppm这些是正确的 host 语义,不要动。

build_program.cppm:506build.mcpp.exe 也是 host 语义(L3 构建程序跑在主机上), 同样不动 —— 参见 [[build-mcpp-helper-self-containment]]。

本方案的改动面只有 src/build/plan.cppm 一个文件。


4. 正确的命名是 (os, env) 二元函数,不是 os 一元

这是本方案唯一需要动脑的地方 —— 直觉上「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)。 这一条与交叉无关,是存量缺陷,顺带一并修掉。


5. 方案

5.1 引入 ArtifactNaming(从 triple 导出,一次求值)

放在 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 构建逐位不变

5.2 target_output 接受它

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 传下去即可。不需要改任何跨模块签名。

5.3 shared_library_link_flags 同步改为按 target

is_windowsn.sharedNeedsImportLib(或直接判 triple.is_pe()); supports_rpath / is_macos → 按 target 的 os 求值。

5.4 runtime_aliases_for_target 要一起看

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。


6. 影响面(已实测清点)

6.1 会改变行为的只有交叉构建

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 已经两种都匹配。

6.2 需要确认的一处

release.yml / bootstrap-macos.yml 里的打包路径(find target -path "*/bin/mcpp" 等)全部是 host 构建的产物,不经过交叉路径,不受影响。已逐条核对。


7. 验证判据(缺一不可)

  1. 回归断言先行:新增测试,断言 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]]。
  2. windows-gnu 用 GNU 约定:静态库断言为 libfoo.a,不是 foo.lib(§4 的存量缺陷)。
  3. 不再重链(本方案的核心收益,也是唯一能证伪「修好了」的判据):
    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" ]        # 修复前必失败
    这条要进 e2e,否则它会悄悄退化回去。
  4. host 构建逐位不变:Linux/macOS/Windows 三个 host 的 mcpp build 产物名与改前一致。
  5. soname 别名仍然生成(§5.4)。
  6. 现有 22 个含 .exe 的 e2e 全绿。

8. 分期

三个 PR,不要合并(依据见 §9):

PR-1 — B3 主体(本文档的 §5)

单 PR,提交分层(与 B2 同款,理由见 [[windows-host-linux-cross-canadian]]):

commit 内容
1 artifact_naming() + 判据 1/2 的断言,实现为返回 host 常量的桩 ⇒ 测试红
2 实现按 (os, env) 求值 ⇒ 测试绿(含 windows-gnulibfoo.a,§9.1)
3 target_output / runtime_aliases_for_target 接受 ArtifactNaming
4 shared_library_link_flags 按 target 求值
5 e2e:判据 3 的「不重链」断言

CHANGELOG 必须写出 Windows + mingw 静态库改名这条对外可见的行为变更

PR-2 — 非 ELF 目标上的共享库明确拒绝(§9.2 路线 B)

独立小 PR。make_plan 入口检查:target 非 ELF 且存在 SharedLibrary 目标 ⇒ 返回明确错误, 指向追踪 issue。附一条 e2e:「PE 目标上声明 SharedLibrary 得到可读的 mcpp 错误, 而不是一个链接不上的产物」。

先于 PR-3,也可以先于 PR-1 —— 它不依赖命名改动,且立刻消除一类未定义行为。

PR-3 — import lib 完整支持(§9.2 路线 A)

另开设计文档。 前置条件是先补齐 PE 与 Mach-O 的共享库 e2e 覆盖,否则又是一批 无验证代码。不要在没有覆盖的前提下加产物边。


9. 两个未决问题的深度分析(2026-08-03 补,基于代码实测)


9.1 windows-gnu 静态库 foo.liblibfoo.a:同期做,影响面实测为空

原本的顾虑是「它会改变 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 之外的世界用的人。 这让改名从「牵一发动全身」降级为「改一个对外文件名」。

缓存与 fingerprint:影响为零(实测)

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 静态库产物改名),因为它对外可见 —— 但它影响的是一个今天就是错的名字。


9.2 PE import lib:先别建模,先把边界画出来

调研到一个改变问题性质的事实。

共享库在 PE 和 Mach-O 上是零验证覆盖

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,它需要 .lib import library。这条路径必然失败。
  • 而且这个分支按 host 求值(is_windows),交叉时连方向都是错的 —— 与 B3 主体同病。

长期架构:三条路,建议第 2 条

路线 内容 评价
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 主体的关系

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 另开设计文档。


9.3 汇总:两个问题的处置

# 问题 决定 依据
1 foo.liblibfoo.a 同期做 消费链实测无影响(包依赖走 object 内联、外部库走 ldflags、fingerprint 不含产物名);且它是存量正确性缺陷,不是顺带优化
2 PE import lib 本期不建模,改为明确拒绝 共享库在 PE/Mach-O 上零 e2e 覆盖,现有分支是未验证推测;静默产出不可用产物比明确报错坏得多。完整支持另开设计