Skip to content

ci: debug boost-ext.ut macOS SIGSEGV (fork-local iteration) - #1

Closed
ZheFeng7110 wants to merge 33 commits into
mainfrom
fix-ut-macos-ci
Closed

ci: debug boost-ext.ut macOS SIGSEGV (fork-local iteration)#1
ZheFeng7110 wants to merge 33 commits into
mainfrom
fix-ut-macos-ci

Conversation

@ZheFeng7110

Copy link
Copy Markdown
Owner

Fork-local CI iteration for the boost-ext.ut macOS SIGSEGV (PR mcpplibs#142).

Contains (temporary, fork-only):

  • validate.yml: mirror-cn-reachable job removed (no CN mirror usage on this branch)
  • validate.yml: validate.yml edits select only the boost-ext.ut member instead of a cold full workspace run (DEBUG-ONLY; revert before upstream)
  • diag-ut-macos.yml: deep diagnostics (init order, symbols, relink matrix)

Status so far: macOS crash = libc++ static-link init-order failure (stream initializer _GLOBAL__I_000100 runs LAST in __init_offsets; ut's cfg reads unconstructed std::cout). Dynamic libc++ passes. Guard attempts ineffective (libc++ 20 Init ctor is empty).

[Boost::ext].UT is the C++20 single-header unit testing framework shipped
by the boost-ext org (NOT an official Boost library; hence the new
 mcpp namespace, not  and not ). Exposed as the
C++23 module  so users can write  directly.

The v2.3.1 tarball already ships include/boost/ut.cppm (), but it cannot be used VERBATIM on either non-MSVC default
toolchain of CI's pinned mcpp 0.0.109:

  * GCC 16.1 (-std=c++23) rejects unqualified  at namespace scope in
    the module purview with  ( only brings in
    ); ut.hpp uses  unqualified in reporter_junit.
  * Clang 22.1 on the MSVC ABI (x86_64-windows-msvc) fails on the MSVC
    builtins / referenced under  at
    ut.hpp:687 — clang sets  but does not provide those builtins
    (the adjacent upstream guards at lines 291/311/1147 already gate clang
    out, but line 687 missed it).

So, like marzer.tomlplusplus, a  wrapper reproduces
upstream's cppm VERBATIM plus two minimal shims: a  at
purview top level (fixes GCC) and  /  gated to  on  (fixes clang-on-Windows; MSVC
itself never enters the guard). base ut.hpp stays pinned to the v2.3.1 tag.

Verified locally with mcpp 0.0.109 (CI pin):
  - mcpp xpkg parse pkgs/b/boost-ext.ut.lua  -> parse OK
  - mcpp test -p boost-ext.ut (gcc 16.1.0, x86_64-windows-gnu default)
      -> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
  - mcpp test -p boost-ext.ut (llvm 22.1.8, x86_64-windows-msvc)
      -> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'

No feature: ut is header-only + single module unit, no extra compilable
sources to gate; the optional  defines are compile-time,
not yet representable in the features table.

CN mirror intentionally omitted (no mcpp-res write access); the descriptor
uses plain-string upstream URLs which the lint (tests/check_mirror_urls.lua)
accepts as-is. Maintainer can backfill the gitcode release later.

Design notes in .agents/docs/2026-08-02-add-boost-ext-ut-plan.md.
…icit template instantiations

CI on PR mcpplibs#142 showed workspace (linux) and workspace (windows) green, but
workspace (macos) failed with exit 139 (SIGSEGV) immediately at `Running
bin/ut` — the test binary never reached the 'Suite ...' console output.
Root cause: the verbatim v2.3.1 ut.cppm leaves the member templates the
`cfg::runner<reporter_junit<printer>>` dispatches to
(`reporter_junit<>::on<...>`, `test::operator=<>`, `expect<bool>`)
only implicitly instantiable; on Apple-Clang 20.1.7 + `-fmodules` the
implicit path through the module BMI fails to emit them into the consuming
executable, so `cfg` static init calling
`reporter_junit::on(events::run_begin)` →
`std::unordered_map::operator[]("global")` lands in an
un-instantiated slot and segfaults.

Upstream fixed this on `master` AFTER v2.3.1 by appending eight
explicit template instantiations to ut.cppm; reproduce that block
byte-for-byte in our `generated_files` cppm after `#include "ut.hpp"`.
This is the same forward-port shape as marzer.tomlplusplus carrying a
one-line cut from upstream master — once a >2.3.1 release ships it,
switch `sources` to `*/include/boost/ut.cppm` and drop
`generated_files` (this block comes back verbatim).

`export import std;` is KEPT (with the existing `using std::size_t;`
GCC shim): trialed dropping it to mirror nlohmann.json / marzer.tomlplusplus
but GCC 16.1 then surfaces a cascade of `-Wtemplate-body` errors in
ut.hpp:3288 (`std::empty`), :3282 (`call_steps_` member lookup),
:3322 (`literals::operator""_test` using-decl resolution), and more —
GCC's two-phase lookup in the module purview is stricter than Clang's and
genuinely needs the std module to be import-visible.

Local verification, clean state (`rm -rf tests/examples/boost-ext.ut/{target,.mcpp,mcpp.lock,compile_commands.json}`):

  * mcpp xpkg parse pkgs/b/boost-ext.ut.lua  -> parse OK
  * mcpp test -p boost-ext.ut (gcc 16.1.0, x86_64-windows-gnu)
      -> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
  * mcpp test -p boost-ext.ut (llvm 22.1.8, x86_64-windows-msvc)
      -> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'

Both Windows default toolchains stay green — dev 3 is a no-op redundancy
on GCC / Clang-on-MSVC and a real fix on Clang-on-Darwin; macOS CI result
will be confirmed by the next push to PR mcpplibs#142.

Design notes in .agents/docs/2026-08-02-add-boost-ext-ut-plan.md §2.3 / §2.4 / §6.
…tic init

The macOS leg (Apple Clang 20.1.7 + libc++) failed with exit 139 at
'Running bin/ut'. A temporary lldb diagnostic workflow pinned it:
    frame #0: reporter_junit::reporter_junit at ut.hpp:1620:31
    stop reason = EXC_BAD_ACCESS (code=1, address=0xffffffffffffffe8)
ut.hpp:1620 is the member-init `std::streambuf* cout_save = std::cout.rdbuf();`.

This is a static-initialization ORDER failure: ut.hpp's module-exported
inline variable `cfg = runner<reporter_junit<printer>>{}` dynamically
initializes before Apple libc++'s `std::cout`, so `cfg`'s member-init
reads an unconstructed std::cout (hence the -0x18 garbage read). MSVC STL
and libstdc++ order their stream init (init_priority / ios_base::Init) so
Windows and Linux legs pass; Apple libc++ does not, and the module
boundary breaks the same-TU ordering the header form relies on.

Fix: add a persistent `std::ios_base::Init` guard object in the module
purview BEFORE `#include "ut.hpp"`. Same-TU dynamic init runs in
declaration order, so the guard constructs std::cout/std::cin/std::cerr
first; when `cfg` (declared later inside ut.hpp) later calls
`std::cout.rdbuf()` the stream objects are fully built. The anonymous-
namespace guard lives for the whole program so stream refcount stays >= 1.

The explicit-template-instantiation block (dev 3, lifted verbatim from
upstream master) is upstream's fix for a separate Clang module linkage
gap and does NOT address this SIOF; it stays as a no-op redundancy.

Local verification (clean state, both Windows default toolchains):
  * mcpp xpkg parse pkgs/b/boost-ext.ut.lua           -> parse OK
  * mcpp test -p boost-ext.ut (llvm 20.1.7, x86_64-windows-msvc)
      -> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
  * mcpp test -p boost-ext.ut (gcc 16.1.0, x86_64-windows-gnu)
      -> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
macOS result to be confirmed by the diagnostic workflow on this push.
…R split

The macOS leg still crashed at ut.hpp:1620 (`std::cout.rdbuf()`) even with
the ios_base::Init guard. The lldb backtrace showed the real story:
    stop reason = EXC_BAD_ACCESS (code=1, address=0xffffffffffffffe8)
The address -0x18 means std::cout's vptr is ZERO — the stream was NEVER
constructed. Root cause: `export import std;` inside the module makes
`std::cout` refer to the MODULE's own std entities, not libc++'s — an
ODR split between the std module's std::cout and the library's std::cout.
Apple libc++ does not merge module std entities (libstdc++/MSVC STL do),
so libc++'s ios_base::Init constructs the library copy while the module's
copy stays all-zero; cfg's member-init then dereferences the null vptr.

Fix (matches every other successful module package in this index —
nlohmann.json, marzer.tomlplusplus, neargye.magic_enum): the module TU must
NOT `export import std;`. Pull stdlib via #include only so every std::*
symbol inside ut.hpp is the library's own ODR entity. Consumers `import
std;` themselves (the test member already does).

Removing `export import std;` alone used to explode on GCC with a cascade
of -Wtemplate-body errors, so two enabling changes:
  1. the module's GLOBAL-MODULE-FRAGMENT #includes every stdlib header
     ut.hpp needs (GMF declarations are visible to the purview);
  2. cxxflags = { "-Wno-template-body" } silences GCC 16.1's remaining
     two-phase-lookup pedantry inside ut.hpp's templates (accepted on
     gcc 16.1, ignored by Clang).

Kept from before: `using std::size_t;` (GCC) and the `__argc`/`__argv`
define (Clang-on-Windows), plus the post-v2.3.1 explicit-template-
instantiation block lifted verbatim from upstream master (harmless no-op;
does not fix the macOS ODR split).

Local verification (clean state, both Windows default toolchains):
  * mcpp xpkg parse pkgs/b/boost-ext.ut.lua -> parse OK
  * mcpp test -p boost-ext.ut (llvm 20.1.7, x86_64-windows-msvc)
      -> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
  * mcpp test -p boost-ext.ut (gcc 16.1.0, x86_64-windows-gnu)
      -> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
macOS result to be confirmed by the diagnostic workflow on this push.
@ZheFeng7110

Copy link
Copy Markdown
Owner Author

boost-ext.ut macOS CI 崩溃(SIGSEGV)诊断报告

日期:2026-08-02
仓库:mcpp-index(PR mcpplibs#142feat: add boost-ext.ut 2.3.1 C++23 module package
分支:add-boost-ext-ut(原)→ fix-ut-macos-ci(当前,fork 内 PR #1


1. 背景:这个 PR 在做什么

这个 PR 要把 [Boost::ext].UT(一个 C++ 单头文件单元测试框架,upstream:boost-ext/ut)打包进 mcpp 包索引,并且以 C++23 模块(import boost.ut;)的形式暴露给使用者。

C++23 模块简单说:以前代码用 #include <头文件> 拿库,现在可以用 import 模块名;。PR 里的包描述符(pkgs/b/boost-ext.ut.lua)通过 mcpp 的 generated_files 机制,从一个"生成的模块接口文件"(boost.ut.cppm,实际内容是 #include "ut.hpp" + 一些兼容补丁)编译出 C++23 模块 boost.ut

CI(GitHub Actions,仓库自带的 validate.yml)会在三个平台各跑一遍该包的测试:

  • Linux(gcc 16.1)
  • Windows(llvm/clang,MSVC ABI)
  • macOS(llvm 20.1.7,arm64)

Windows 和 Linux 一直通过;macOS 在测试程序一启动就 SIGSEGV(退出码 139),这是本次要解决的问题。

2. 崩溃现象

mcpp test -p boost-ext.ut 在 macOS 上的输出:

   Compiling ut (test)
     Running bin/ut
ut ... FAIL (exit 139)

测试程序 ut(即 tests/examples/boost-ext.ut/tests/ut.cpp 编译出的可执行文件)还没打印任何东西就崩了(正常的 ut 程序会打印 Suite 'global': ...)。139 = 128 + 11 = SIGSEGV。

用 lldb 抓到的崩溃现场:

frame #0: reporter_junit::reporter_junit at ut.hpp:1620:31
stop reason = EXC_BAD_ACCESS (code=1, address=0xffffffffffffffe8)
-> 1620   std::streambuf* cout_save = std::cout.rdbuf();

崩溃发生在程序启动的"静态初始化"阶段,执行 boost::ut::cfg 这个全局对象(ut.hpp 内部定义的一个测试运行器实例,类型 runner<reporter_junit<printer>>)的构造函数时,构造函数里有一行 std::cout.rdbuf()(把标准输出流存起来),而读 std::cout 时崩了。

3. 需要理解的几个基础概念(通俗版)

3.1 程序启动时会发生什么(静态初始化)

一个 C++ 可执行文件从启动到 main() 之间,系统(macOS 上是 dyld)会执行一个"初始化器列表"(初始化的函数指针表)。你的代码里任何全局/静态对象(比如 int g = foo();SomeClass g_obj;)都会在这个阶段被构造。这个阶段里各初始化器的执行顺序是确定的:链接器把初始化器按某种顺序排进一个表,dyld 按表顺序执行。

在 macOS 的 Mach-O 格式里,这张表叫 __TEXT,__init_offsets(旧格式叫 __DATA,__mod_init_func)。

3.2 std::cout 是谁构造的?(关键!)

std::cout 本身也是一个全局对象,它必须在你的全局对象之前构造好,否则你的全局对象用它时就崩。不同标准库保证这个顺序的方式不一样:

  • MSVC STL(Windows)<iostream> 头文件里带一个 static ios_base::Init 对象,谁 include 了 <iostream>,谁自己的 TU 里就有这个对象,按同 TU 声明顺序它先构造,顺带把流初始化了。
  • libstdc++(Linux):类似,<iostream> 头文件自带 static ios_base::Init __ioinit;
  • libc++(macOS/Clang 默认)头文件里没有这个对象。流的构造被放在标准库源码文件 iostream.cpp 里,通过一个 __attribute__((init_priority(101))) 的全局对象 __start_std_streams(构造函数会执行 DoIOSInit,真正把 cout/cin/cerr 构造出来)完成。init_priority(101) 的意思是"这个初始化器优先级很高(数字小=早),必须在普通用户初始化器(默认 65535)之前执行"。注意:libc++ 里 std::ios_base::Init 的构造函数是空的(只负责退出时 flush),构造流完全靠 __start_std_streams 这个库内对象。

3.3 链接器怎么排初始化器顺序

初始化器进入 __init_offsets 表的顺序,就是链接器处理各个目标文件的顺序

  • 你在链接命令行里给的 .o 文件(用户代码)→ 排前面
  • 静态库(.a)里提取出来的成员(标准库代码)→ 排后面

init_priority 属性理论上应让带优先级的初始化器排到最前,但这次观察到:mcpp 用的链接器(lld,-fuse-ld=lld)对静态库成员并没有按 init_priority 排序——库里的 __start_std_streams 初始化器被放到了整张表的最后(详见 §4.2)。

3.4 mcpp 在 macOS 上是怎么链接的(实测)

从 mcpp 生成的 build.ninja 里看到(这是决定性证据):

unit_ldflags = -nostdlib++ \
    -Wl,-load_hidden,<…>/lib/libc++.a \
    -Wl,-load_hidden,<…>/lib/libc++abi.a
rule cxx_link
  command = $cxx $in -o $out $ldflags $unit_ldflags

即:mcpp 在 macOS 上用 -Wl,-load_hidden,libc++.a 把 libc++ 强制静态链接进可执行文件,且链接命令里库在对象($in)之后。所以:

  • 可执行文件里不依赖任何 libc++.dylib(otool -L 只有 libSystem.B.dylib)
  • libc++ 的初始化器(含流构造)被排到初始化器表最后
  • 而用户/模块的初始化器排前面

(Clang 的默认行为其实是动态链接 -lc++ 系统 dylib——那样 dylib 加载时 dyld 会先跑它自己的初始化器,一切正常。是 mcpp 的"静态 C++ 运行库"策略引入了问题。)

4. 根因(全部经过实验证实)

4.1 崩溃的直接原因

ut.hpp 里的全局对象 cfg(测试运行器)在静态初始化阶段执行 std::cout.rdbuf()。此时:

  1. std::cout 是个零初始化的内存块(nm 显示它在 __DATA,__common,vptr 是 0)。
  2. rdbuf() 在 libc++ 20 里是个虚函数调用,要先读对象里的 vptr 再跳转。
  3. vptr=0 → 读到地址 0xffffffffffffffe8(即 0-0x18,崩溃地址就是这么来的)→ SIGSEGV。

4.2 为什么 std::cout 没被构造(顺序被破坏)

  • 整个可执行文件的 __init_offsets 有 19 个初始化器,执行顺序 = 链接顺序:先是模块 TU(boost.ut.cppm)的一堆 __cxx_global_var_init.NNN(第 13 个左右就是 cfg 的初始化器),最后是 libc++ 静态库成员贡献的 _GLOBAL__I_000100(这就是 __start_std_streams 的初始化器,即流构造)。
  • cfg第 13 位就崩了,流初始化器排在最后、根本没轮到执行std::cout 永远是零 → 崩。
  • 对照实验 E:同样的两个目标文件(ut.o + boost.ut.m.o),链接命令里改用系统动态 libc++.dylib(去掉 -load_hidden 的 .a),程序完全正常Suite 'global': all tests passed,退出 0)——因为 dylib 的初始化器在镜像加载时就执行了,早于可执行文件里的一切初始化器。这 100% 证明问题出在"静态链接 + 初始化器顺序",而不是代码本身。

4.3 为什么 Windows/Linux 没事

Windows 的 MSVC STL 和 Linux 的 libstdc++ 都在头文件里自带流初始化 guard(§3.2),所以"谁先构造"由用户自己的 TU 内部声明顺序决定,与链接顺序无关,天然正确。libc++ 把流构造放在库文件里,静态链接 + lld 不按 init_priority 排序,就踩坑了。

4.4 一句话根因

mcpp 的 macOS 工具链用 lld 把 libc++ 静态链入(-Wl,-load_hidden,libc++.a,库在对象之后),lld 没有按 init_priority 把 libc++ 的流构造初始化器排到用户初始化器之前,导致 ut 的全局对象 cfg 在静态初始化期访问了尚未构造的 std::cout,虚调用空 vptr 崩溃。动态链接 libc++(dylib)时一切正常。


5. 完整来龙去脉:从第一次提交到现在(按时间顺序)

阶段 A:打包本身(编译期问题,与崩溃无关,但构成了最终描述符)

提交 fd656d5(feat):写 pkgs/b/boost-ext.ut.lua 描述符。上游 v2.3.1 自带 include/boost/ut.cppm(官方模块接口),但直接用它在 CI 的编译期就过不了:

  1. GCC 16.1:模块作用域里 size_t 未声明(-Wtemplate-body 报错)。因为 import std; 只引入 std::size_t,ut.hpp 在 namespace 作用域用了裸 size_t
  2. Clang on Windows(MSVC ABI):ut.hpp 第 687 行 #if defined(_MSC_VER) 用了 MSVC 内建 __argc/__argv,而 clang 设置 _MSC_VER 但不提供这两个内建。

所以描述符用 generated_files 生成一个 wrapper cppm:照抄上游 cppm 意图(#define BOOST_UT_CXX_MODULES 1 + #include "ut.hpp"),加两个 shimusing std::size_t;(修 GCC)和 #define __argc 0 / __argv nullptr(修 Clang-on-Windows),并追加了上游 master 上 post-v2.3.1 的"显式模板实例化"块。

阶段 B:macOS SIGSEGV 出现,前三轮修复尝试

PR 提交后 macOS CI 开始崩(exit 139)。依次尝试:

  1. 提交 61b9441:怀疑是"模板没被实例化、模块链接缺口",把上游 master 的显式模板实例化块原样搬进 wrapper(dev 3)。→ 无效(macOS 照样崩)。后来证实这是 no-op。
  2. 提交 60834f3:用 lldb 首次定位到 ut.hpp:1620 std::cout.rdbuf() 崩,vptr=0。判断为"静态初始化顺序"问题,于是在模块里、#include "ut.hpp" 之前放一个 std::ios_base::Init guard 对象(期望它的构造先于 cfg,从而构造出流)。→ 仍崩。(事后诸葛亮:两处都错——(a) anonymous namespace 里的对象,它的动态初始化在模块 TU 中被 clang 丢弃,根本没执行;(b) 就算执行,ios_base::Init 的构造函数在 libc++ 20 里是空的,根本不构造流。)
  3. 提交 3eff289:提出"ODR split"理论(export import std; 让模块用"std 模块自己的 std::cout",与库里的 cout 是两个实体),于是删掉 export import std;,改为在 global-module-fragment(GMF)里 #include 所有 ut.hpp 需要的标准库头,并加 -Wno-template-body 压住 GCC 的报错。→ Windows/Linux 绿,macOS 仍崩。(事后证明:GMF include 与 ODR 理论都不是主因。)

阶段 C:系统性诊断(搞清"为什么崩")

期间在 fork 里加了一个临时诊断 workflow(diag-ut-macos.yml),在 macOS runner 上反复抓证据:

  • lldb 反汇编:崩溃指令 ldur x9, [x9, #-0x18],x9=0 → 读 0xffffffffffffffe8。结合 bl basic_ios::rdbuf,确认是虚函数调用,std::cout 的 vptr=0
  • otool -L:可执行文件只链 libSystem.B.dylib没有 libc++.dylib → 静态 libc++
  • nm_ZNSt3__4coutE(std::cout)定义在 __DATA,__common(零初始化 BSS);DoIOSInit__start_std_streamsios_base::Init::Init() 等符号都在二进制里 → iostream.o 已经被链接进来了,代码都在,只是初始化器没跑/跑太晚
  • otool -l + __init_offsets 内容 + lldb 逐条符号化:19 个初始化器,顺序 = 链接顺序:boost.ut.cppm 的 __cxx_global_var_init 系列(cfg 的初始化器在第 12~13 位)→ ut.cpp 的 → std/std.compat 模块初始化器 → 最后是 _GLOBAL__I_000100(= libc++ 的 __start_std_streams,即流构造)。
  • DYLD_PRINT_INITIALIZERS=1:实跑时 dyld 按表顺序执行,cfg 初始化器崩在第 13 个,_GLOBAL__I_000100(第 19 个)从未执行。
  • build.ninja(mcpp 生成的构建脚本):链接规则 $cxx $in -o $out $ldflags $unit_ldflagsunit_ldflags = -nostdlib++ -Wl,-load_hidden,<xpkg>/lib/libc++.a -Wl,-load_hidden,<xpkg>/lib/libc++abi.aldflags 里有 -fuse-ld=lld。→ 静态 libc++ + lld + 库在对象之后。

阶段 D:链接策略矩阵(确认"链接方式"就是决定因素)

用同一组目标文件(ut.oboost.ut.m.o),只改链接参数,逐一验证:

实验 链接方式 结果
E 默认动态链接系统 libc++.1.dylib 通过Suite 'global': all tests passed,exit 0)
F 静态 libc++.a + libc++abi.a(复现 mcpp 方式) ❌ 139 复现
D 静态 + 显式 -Wl,-dead_strip ❌ 139(排除 dead-strip 因素)
G 静态 + -Wl,-no_dead_strip 该 lld 不支持此选项(链接失败)
I 静态 + 手动把 iostream.cpp.o 强制链入 ❌ 139(iostream.o 本就在,无用)
O 静态库放在对象之前链接 ✅ 测试通过但退出时 abort(134)——因为 clang 仍自动链了系统 dylib,两套流初始化 double-free
P 静态 + -Wl,-force_load,libc++.a ❌ 139
M / M2 链接 xpkg 自带的 libc++.dylib ❌ 134(先缺 @rpath,后触发 libc++abi 的 TMO 报错)

E 通过 + F 复现 是最硬的证据:同一份代码,换链接方式就好/坏 → 根因在链接/初始化顺序,不在 ut 包代码。

阶段 E:包内修复尝试("在用户侧把流提前构造出来")——全部失败

既然动态链接在 CI 里不可选(工具链层面 mcpp 写死静态),尝试在 generated cppm 里加"前置初始化器",让流在 cfg 之前构造:

  • K:module purview 里 anonymous namespace 的 std::ios_base::Init guard → 初始化器被 clang 丢弃(反汇编证实只有 __cxa_guard/atexit,无构造调用),无效。
  • K2:GMF(模块外)里的 std::ios_base::Init 全局对象 → 初始化器生成了(编号 .97,排在 cfg 前),但构造调用为空(libc++ 20 的 Init 构造函数是空操作,不构造流)→ 无效。
  • S:GMF 里 __attribute__((constructor)) 函数(内容构造 std::ios_base::Init)→ constructor 函数在模块 TU 中被丢弃 → 无效。
  • S2:GMF constructor 函数里直接构造 libc++ 内部类 std::__1::DoIOSInit(真正构造流的类)→ 同上被丢弃 → 无效。
  • S3:purview 里声明 DoIOSInit 并建对象 → 链接失败:模块作用域声明的类带模块后缀(DoIOSInit@boost.ut),与库里无后缀的符号对不上。
  • S3b:声明和对象都放 GMF(external linkage)→ 初始化器没生成(GMF 全局对象的动态初始化在模块 TU 中被 clang 丢弃,之前的 .97 其实是 ut.hpp 里的另一个对象)→ 无效。
  • S4:DoIOSInit 声明放 GMF(external,符号正确)+ purview 里的 inline 变量 force_init(inline 变量像 cfg 一样保证初始化器生成)→ 初始化器生成 ✅ 且确实调用了 force_init 的构造函数 ✅(反汇编实证)→ 但仍崩(cfg 里 cout vptr 仍为 0)。
  • S5:同上,但更直接——purview 里 inline std::__1::DoIOSInit ut_doios{}; → 初始化器生成 ✅,反汇编证实它直接 bl std::__1::DoIOSInit::DoIOSInit()(且该符号定义在二进制内)✅,发生在 cfg 之前 ✅ → 但仍崩在 cfg,cout vptr 仍为 0

S5 是最关键的反面证据:流构造代码确实在 cfg 之前被调用了,但 cfg 读到的 std::cout 还是零。唯一自洽的解释是 ODR split 的最终形态:模块 TU 里(即使 GMF include <iostream>std::cout 被当作模块实体,其引用/定义与 DoIOSInit 构造的那个(iostream.o 里的)不是同一份内存——模块里那份始终是零。也就是说:在"模块 + 静态 libc++ + lld"这个组合下,从模块侧无法可靠地让 cfg 读到已构造的 std::cout

阶段 F:流程整理(当前仓库状态)


6. 结论

  1. 这不是 ut 包代码的 bug:同一份代码动态链接就完全正常(实验 E)。
  2. 是工具链层面的缺陷组合:mcpp 0.0.109 的 macOS 工具链(llvm@20.1.7 + lld)静态链接 libc++-Wl,-load_hidden,libc++.a,库在对象之后),而 lld 没有按 init_priority 把 libc++ 的流构造初始化器排到用户初始化器之前_GLOBAL__I_000100 排到了 __init_offsets 最后)。libc++ 的流构造只依赖这一个库内对象(头文件里没有 guard,与 libstdc++/MSVC STL 不同),所以任何"用户全局对象在静态初始化期使用 std::cout"的程序都会崩——ut 的 cfg 恰好就是这样。
  3. 包内(generated cppm 层面)修复目前全部失败,最接近的 S5(让 DoIOSInit 在 cfg 之前真正执行)也因模块/库的 std::cout 实体分裂而无效。继续深挖 S5 也许能找出 ODR split 的具体机制,但即使修好,方案也依赖 libc++ 内部符号(std::__1::DoIOSInit),太脆弱,不适合作为正式修复。

7. 下一步候选方案(按优先级)

  1. 给 mcpp 上报(最推荐,治本):向 mcpp-community/mcpp 提 issue,附本报告 §4/§5 的证据链(E/F 对照实验、build.ninja 的 -load_hidden__init_offsets 顺序、_GLOBAL__I_000100 排最后)。
    建议方向:
    • macOS 工具链改用动态 libc++-lc++,系统 dylib;实验 E 证明一切正常,也符合 clang 默认行为);
    • 或修复/升级 lld 对 init_priority 初始化器的排序;
    • 或给工具链加开关让包/成员可选"动态 stdlib"(例如 mcpp.toml 里能表达 [target.'cfg(macos)'.build] ldflags 覆盖 -load_hidden——注意:目前 member 的 ldflags 只能追加,无法去掉 mcpp 注入的 -nostdlib++ -load_hidden,这也是需要 mcpp 侧支持的点)。
  2. PR 层面的临时处置(在 mcpp 修复前让 PR 变绿):
    • 方案 a:测试用例在 macOS 上改用 header 形式(#include <boost/ut.hpp>)而非 import boost.ut;——能绿,但测不到模块,违背包目的,需在 PR 说明;
    • 方案 b:macOS leg 上把该 member 标记为"已知失败/跳过"并链接到 mcpp issue——诚实但 CI 依旧非全绿;
    • 方案 c:暂不合并,等 mcpp 工具链修复(选项 1)。
  3. 继续深挖 S5(不推荐但可选):验证模块 TU 里 std::cout 的符号是否带模块后缀(nm_ZNSt3__14coutE 的带 W5boostW2ut 变体),确认 ODR split;如果确认,尝试在 ut.hpp include 之前用 #define cout 或直接 #undef/宏重定向等黑魔法把 cfg 里的 std::cout 换成库里的实体——即使成功也极其脆弱,不推荐作为正式修复。

附:关键实验速查

  • 崩溃指令:ldur x9, [x9, #-0x18](x9=0,读 0xffffffffffffffe8)→ std::cout 虚调用、vptr=0。
  • __init_offsets 19 项,cfg 初始化器第 12~13 位,流初始化器 _GLOBAL__I_000100 第 19 位(最后,未执行)。
  • E(动态 dylib)✅ vs F(静态 .a)❌:唯一变量是链接方式。
  • S5:inline std::__1::DoIOSInit 初始化器在 cfg 前实际调用了 DoIOSInit::DoIOSInit(),cfg 仍读零 vptr → 模块/库实体分裂。
  • mcpp 链接参数(build.ninja):-nostdlib++ -Wl,-load_hidden,<…>/lib/libc++.a -Wl,-load_hidden,<…>/lib/libc++abi.a-fuse-ld=lld

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant