设计:
2026-08-16-windows-toolchain-three-axes-design.md(§6.5 记录逐条状态) 实现:mcpp #448(单 PR,含设计文档本身) 前一轮:2026-08-16-msvc-ecosystem-final-report.md
上一轮给编译器装上了版本轴;这一轮把它编译时用的头文件和产物加载的
运行时也各自变成了声明出来、解析一次的值 —— 并且顺手让 mcpp pack
不再需要运行产物才能知道产物依赖什么。
| 轴 | 之前 | 现在 |
|---|---|---|
| 来源 编译器哪来的 | ✅ 已建模,代价摊在 ~30 处分支 | 解析一次(Origin),三处重复各消掉一份 |
| SDK 用哪套头/导入库 | ❌ 靠搜,两种来源同一条链 | 按来源绑定;受管 toolset 忽略环境并说出来 |
| 运行时分发 产物带不带 vcruntime | ❌ 对 MSVC 一律拒绝 | PE 上 toolchain-coupled 真正成立 |
| 打包(轴三的下游) | ❌ Windows 硬拒绝;跨不了 OS/架构 | 静态读导入表,任何宿主都能给 PE 打 zip |
find_windows_sdk() 过去按 WindowsSdkDir → 兄弟 store → 常规路径扫,两种来源
走同一条链。于是被 pin 住的 msvc@<toolset> 是一个环境可以悄悄改写的 pin,
两台机器可以用两套 SDK 编同一份 manifest,而日志里没有一行提到 SDK。这不是假设:
这一轮之前追的那个 LNK1104,就是一个只解包了一半的 payload 因为版本号更高赢了这场
扫描。
msvc@<toolset> → 随该 toolset 装进 store 的 windows-sdk payload。
WindowsSdkDir / WindowsSdkVersion 被忽略 —— 并且打印 note。
msvc@system → 维持今天的搜索链。机器上的东西只能靠找,而在那里
"明确声明"应当压过"扫描"。
旁边没有 SDK payload 的受管 toolset 仍然退回机器的 SDK —— 能用 > 失败 —— 并且说出来,因为那次构建已经不可复现,而除此之外没有东西会记录它。
RuntimeBinding::runtimeId 的注释从一开始就写着
"(glibc@…, macos_sdk@…, ucrt@…)",而仓库里从来没有一处写过 ucrt@。
于是 SDK 版本进不了 runtimeContractHash,两套 SDK 共用一个构建缓存键。
它和 glibc@ 不同构,而且这一点写在会被读到的地方:
glibc@2.39 |
ucrt@10.0.26100.0 |
|
|---|---|---|
| 绑的是 | 一个 payload,头和 .so 都在里面 |
— |
| 能真绑上去吗 | ✅ patchelf 让产物真跑在那份上 | ❌ ucrtbase.dll 是 OS 组件,换不掉也不该发 |
| 于是标识的含义 | 运行时绑定 | 兼容性下限声明 |
所以它不投影进 libc —— 否则私有 libc 那套机制会去找一个本就不该存在的
payload。各处 starts_with("glibc@") 改成 runtime_provider() 分派:另一个
provider 读起来是"这里没有规则",而不是"没有身份"。
§2.4(manifest 的 SDK 版本键)明确不做 —— 它会和 _WIN32_WINNT 并排,看起来
可以互相替代,实际管的是不同的事。
原来的拒绝语是 MSVC 运行时"随 OS/redistributable 分发,而不是随工具链"。
对 ucrtbase.dll 是对的;对 vcruntime140.dll / msvcp140.dll 是错的 ——
它们躺在每个 toolset 的 VC\Redist\MSVC\… 里,正是 gcc 和 libstdc++.so 的关系。
于是它拿同一个契约。PE 没有 rpath,所以机制是拷到产物旁而不是加一条搜索路径 —— 同一个契约,不同的机制,这正是三层模型存在的理由。
/MT 仍然是降级,而且是真正的矛盾:静态 CRT 根本没有 DLL 可以耦合,消息会说清
是哪一边赢了。DLL 集合来自 vc_redist_dir() —— 排除 debug_nonredist\(不可再分发)
的唯一判据。在这里再写一条按名字的规则,可能和它不一致,而在这件事上不一致
是许可问题,不是 bug。
mcpp pack 用 #if defined(_WIN32) 拒绝 Windows,理由写的是工具是 POSIX-only。
那是症状。闭包来自
LD_TRACE_LOADED_OBJECTS=1 '<binary>'
—— 它要把产物跑起来,所以既跨不了 OS,也跨不了架构。移植 tar 没有用,而
2026-05 那份设计提的每一个工具(dumpbin、ImageNtHeader、Compress-Archive)都会
把障碍下移一层,因为它们都只存在于问题已经消失的那个平台上。
mcpp.pack.binfmt—— ELFDT_NEEDED(经段表翻译);PE 导入表和延迟导入表 (少一个延迟导入不会在启动时失败,而是在第一次调用它时失败,更难查)mcpp.pack.zip—— mcpp 自己写压缩包,理由相同:没有哪个 zip 工具在每个宿主上都 存在。条目 stored 不压缩,这是真实代价和诚实的取舍:DEFLATE 编码器是这里唯一 可能产出解出来是错的而不是响亮失败的部分,而 mcpp 没有 zlib 可借。确定性: 不读任何时间戳,所以公布校验和才有意义- §4.3 契约终于到达打包这一步:以前它止步于编译/链接旗标,决定"哪些文件真的跟着
走"的那一步看不见承诺过什么 —— ELF 上
ldd闭包碰巧一致,PE 上没有任何东西一致
明确没做的一半,连同代价:ELF 闭包仍然运行产物。ldd 交回的是已解析的
路径,DT_NEEDED 只有名字,把名字变成路径要重新实现 loader 的搜索顺序
($ORIGIN、DT_RPATH → LD_LIBRARY_PATH → DT_RUNPATH → ld.so.cache、hwcaps)。
在一条已经正确、有 e2e 覆盖的路径上重写它,风险大于收益 —— 于是跨架构的 ELF
打包仍然不支持,这正是 §4.1 指出的第二个限制。
gcc@system/llvm@system在被读到的地方就拒绝,并同时给出两种可能的本意。 它们过去能解析,然后在别处以xim:gcc@system→ "no such package" 失败,把读者 引向一个根本不会存在的版本。msvc@system是对一个平台的让步,不是别的族缺失 的能力;不带族的system逃生口原样保留- spec 过去在相隔十几行的地方被解析了两遍,各自下结论。现在一次,
origin_of()分派 resolve_managed_msvc()取代两份手写的"受管 toolset 在哪、为什么不能用 fetcher 的root" —— 而理由只写在其中一份里needs_linux_sysroot_payloads()取代同一条规则的两种拼写,其中一份的注释声称它们 互为镜像。它们不是 —— 少了 PE 那一项。今天不可达,这正是它活下来的原因- 工具链解析顺序被写了两遍,一处说 3 步一处说 4 步,合起来点到 9 个真实输入里的
5 个,还互相矛盾。现在一张表,按
TcOrigin的枚举名写,不会悄悄失配
WindowsSdkDirCannotOverrideAPinnedToolsetsSdk—— 设计 §6 的验收判据写成单元测试。 把WindowsSdkDir指到别处,payload 的 SDK 必须仍然赢,并且 note 必须说出变量 被忽略了。一个被静默忽略的覆盖,和一个从未设置过的覆盖无法区分NothingElseAsksForStagedRuntimeFiles—— 那个 deploy 标志会到达一个拷贝步骤,所以 一个多余的true会在一个根本没有这种东西的平台上往产物目录里放 DLL。扫过 (format × stdlib × contract × /MT × explicit) 的每一格
放在 mingw-cross job 里,因为在 Windows 上跑它什么也证明不了。它构建一个真正的 交叉 PE,放一个该 EXE 会导入的 DLL 的替身,打包,然后由 Python 独立验证压缩包:
msvcrt.dll在 —— 正面那一半。一个什么都没读到的解析器产不出它kernel32.dll不在 —— 反面那一半。单独看,一个什么都没读到的解析器也能通过; 两条合起来才是决定性的--mode system不放任何 DLL;toolchain-coupled+--mode system被拒绝,消息里 同时有契约名和出路
单元测试钉住的是哈希函数(两个 SDK 版本 → 两个哈希)。它看不见的是:这个值在 真实的 Windows 构建上到底有没有被填进去 —— 而 Windows CI 绿了并不能区分 "身份填好了"和"代码路径跑了但什么都没产出":一个空字符串会毫无怨言地流过其中 每一个 job。
所以 241 读 resolution.json,断言值本身:runtime_id 以 ucrt@ 开头、
contract_hash 非空(一个不参与任何事的身份就是装饰)、并且它没有被投影进
libc(那个字段指的是私有 libc payload,ucrt 没有对应物)。
它显式 pin 了 msvc@system:Windows 的默认工具链是 clang targeting MSVC ABI,
那条路径上 SDK 是 clang 自己找的、mcpp 确实无可声明 —— 用默认工具链写这条测试,
会断言一个空身份然后因为错误的理由通过。
在这台 Linux 上,mcpp pack --target x86_64-windows-gnu 产出的 zip 被
python3 -m zipfile 和 unzip -t 双双接受,内含 exe 和解析出来的 DLL,没有别的。
--format dir、--mode self-contained、--mode static --target … 逐一验过。
mcpp #448:19 项全绿(Linux / macOS ARM64 / Windows × build+unit+e2e+toolchains、 hermetic 容器、四条 cross-build、xlings 集成)。
不是本地构建产物,而是 xlings install mcpp@2026.8.17.1 从索引装下来的那一个;
不是这台开发机,而是 xlings subos new 出来的全新 SubOS,并且每一条都跑在
xlings subos use <name> --sandbox --cmd "…" 里面。
| 验证 | 结果 |
|---|---|
| 从索引安装 | ✓ xim:mcpp@2026.8.17.1 |
new → build → run |
✓ Hello from ecoproj!(import std + C++23) |
mcpp test |
✓ 1 passed |
mcpp pack(ELF,未改动的路径) |
✓ ecoproj-0.1.0-x86_64-linux-gnu.tar.gz |
mcpp pack --target x86_64-windows-gnu |
✓ 在这个 Linux 沙盒里产出 ecowin-0.1.0-x86_64-w64-mingw32.zip,zipfile.testzip() 通过 |
mcpp self doctor |
✓ all checks passed |
gcc@system |
✓ 被拒绝,消息里同时给出 pin 与 PATH 逃生口两种写法 |
裸 system 逃生口 |
✓ 仍解析到宿主 gcc 13.3.0,然后因为不相干且正确的理由失败(那个 gcc 没有 import std)—— 这正是"没有被破坏"的样子 |
发布链路同样逐段核过:四平台产物齐全 → 镜像 all assets mirrored + verified on 2 host(s) in 488s → 用真实 GET(不是 curl -I,它在 gitcode 上会骗人)复核四个
归档与上游逐字节同尺寸 → 索引 PR openxlings/xim-pkgindex#643 合入 →
xlings update 后可安装。
上一轮预留的「本地
gtc补 gitcode 资源」这次没有用到:镜像那一条腿自己过了。 授权仍在,只是没有需要修的东西。
同一份新代码,gcc 到处都绿,而 clang:
| 现象 | |
|---|---|
| Windows | clang 20.1.7(MSVC ABI)编译 mcpp.pack 时段错误,0xC0000005,无诊断,五个 job 同时红 |
| macOS ARM64 | test_pack_binfmt 在运行时 SIGSEGV |
解析器不是问题,而且这是量出来的:同一份代码在 clang 22.1.8 + libc++ 下 ASan+UBSan 干净,在 x86_64 Linux 上作为 clang 模块编译并运行正确(-O0/-O2)。
处理方式沿用本仓库已有的判例(hostflags.cppm 的开头注释:往一个模块的匿名命名空间里
加一个没被使用的函数,会让相邻函数被误编译;结论是"机制未知,复现稳定,便宜的
反应是别往那个命名空间里加东西")。于是移除形状、保留行为:跨模块的作用域枚举做导出
结构体的默认成员、ranges 投影取导入类型的成员、跨模块边界的 std::span、模块 purview
里的函数模板 —— 全部换成更简单的等价写法。哪一个是原因并未确定,注释里就是这么写的,
而不是编造一个结论。
macOS 那一半后来被拆分测试定位到了:崩在 ThePeFixtureItselfIsWellFormed ——
完全不碰任何模块的测试夹具代码。把夹具改写成最朴素的形式(不用 span 参数、不用捕获
可变字符串的 lambda)之后消失。夹具现在带一个 MCPP_TEST_TRACE=1 才开的分步 trace:
如果它再来,一轮 CI 就能定位,而不是这次的四轮。
合入之后重读 binfmt.cppm,发现 identify() —— 注释写着"never throws" ——
在一种输入上会终止进程:
if (b.substr(*lfanew, 4) == "PE\0\0") // *lfanew 直接读自文件std::string_view::substr 在 pos > size() 时抛 std::out_of_range。
触发面要说准,不能夸大:identify() 拿到的不是任意文件,而是链接产物,
所以现实中的触发路径是一次被截断/写坏的链接输出(磁盘满、链接被 kill),
而不是"用户随便丢了个文件进来"。这一条的分量不在于它多常见,而在于:这个模块
写明了自己"对垃圾输入是全函数",而它不是 —— 一个自称从不抛异常的函数,在一种
输入上终止了进程。
不是推理出来的,是量出来的:std::string_view{256 字节}.substr(0xFFFFFFFF, 4)
在 libc++ 下抛 string_view::substr。
模块里其它每一处读取都走了带边界检查的访问器,只有这两处比较是例外 —— 一个自称
"对垃圾输入是全函数"的模块,就是这样不再是的。已改为 has_at(),并补了回归测试。
原来的截断测试为什么没抓到:它构造的 e_lfanew 只落在正好等于文件末尾的位置,
那里 substr 是良定义的、返回空。
docs/03-toolchains.md 的 MinGW 段说 [build] linkage 这个键不存在、会被静默忽略;
两百行之后的 MSVC 段恰好把它当成选 /MT 的写法展示。是照着文档写了一遍、看着 mcpp
打印 unsupported key 'linkage' (ignored) 才发现的 —— 而跟着这一页做的用户,得到的也是
这个,只是没人告诉他为什么什么都没变。两个语种都已修正。
强制 musl 的那次重新 prepare 忽略了 opts.targetTriple,于是
--mode static --target x86_64-windows-gnu 悄悄变成一次 Linux 构建。在 PE 打包
还不存在时这是看不见的 —— 根本没有 Windows 包可以让人发现它缺了 —— 而现在它是个错误答案。
| # | 事项 | 为什么这次不做 |
|---|---|---|
| 1 | macOS 上的 mcpp pack 会执行用户的产物 |
ldd_parse 在 macOS 上等于直接跑二进制(LD_TRACE_LOADED_OBJECTS 在那里不是环境变量)。两个 pack e2e 都 requires: pack patchelf elf,即 macOS 上完全没有覆盖,改一条没有测试的路径无法验证。binfmt 已经识别 Mach-O,补 LC_LOAD_DYLIB 读取是自然的下一步 |
| 2 | 跨架构 ELF 打包仍不支持 | 见 §4「明确没做的一半」 |
| 3 | [pack] include / exclude 被解析、存进 Plan,从未被消费 |
早于本轮;文档把它们当作已有功能。属于 pack 的另一条轴,不在三条轴的范围内 |
| 4 | Windows CI 仍用 7z a -tzip 手工打包 release |
那是一个自带 registry/ 的定制布局,不是 pack 的输出。现在 mcpp pack 能产 zip 了,迁移是可能的,但那是发布流程的改动 |
| 5 | mcpp new + mcpp add compat.gtest@1.15.2 开箱即坏 —— 脚手架的 test_smoke.cpp 自带 main(),而 gtest 依赖会把 gtest_main.o 链进每个测试目标,multiple definition of 'main'。生态验证时撞到的 |
先于本轮,已用 2026.8.16.3 在沙盒里复现过同样的失败,所以不是本次回归。它属于依赖/测试目标的链接策略,和这三条轴无关;放在这里是因为它是一条开箱即坏的路径,值得单独一轮 |
| 6 | release 的 SHA256SUMS 只覆盖 4 个平台里的 1 个 —— 里面只有 linux-x86_64 的两行(带版本名 + 无版本别名),aarch64 / macOS / Windows 都不在。核对发布产物时撞到的 |
先于本轮:v2026.8.16.3 与 v2026.8.15.1 同样如此。机制很清楚 —— build-release(linux x86_64)最先跑并写下这个文件,另外三个平台各自上传自己的 .sha256 边车,但没有人往 SHA256SUMS 里追加。能校验(边车在),只是那个名字承诺了它没做到的事。属于发布流水线,不属于这三条轴 |
| 7 | Windows 宿主 → Linux target 时 dist::Format 仍解析为 PE |
早于本轮。本轮的 triple 判据只新增答案(说不出 OS 的 triple 走原推导),刻意没有动这一条:它会改变一个正在通过的 CI job 的旗标,而对这三条轴没有好处 |
| 改动 | 用户可见格式 | 迁移 |
|---|---|---|
ucrt@ 进 runtimeContractHash |
Windows 构建缓存重建一次,和任何 contract 变更同类 | |
| 受管 toolset 绑定 SDK | 行为,非格式 | pinned toolset 上 WindowsSdkDir 从"生效"变成"忽略 + 报告" |
拒绝 gcc@system |
消息给出两种替代写法 | |
PE toolchain-coupled / PE pack |
新增能力 | 之前是 degraded / 硬错误 |
--mode static --target <pe> |
现在按 target 走 |
其余全部是内部改动。