Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ C library.

```toml
[dependencies]
openkal-llvm-runtime = "0.14.0"
openkal-llvm-runtime = "0.15.0"
```

> **Engine floor (mcpp 2026.9.20.1):** this version of this package pins
Expand Down
35 changes: 35 additions & 0 deletions examples/cxx/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ static int hidden = 7;
static int weak = 11;
struct weak_alias { int value; };

// THE thread_local PROBE'S SUBJECT, at file scope because a local struct's
// destructor cannot reach a lambda capture. See the criterion in `main`.
static std::atomic<int> tls_dtor_ran{0};
struct TlsProbe { int v; ~TlsProbe() { tls_dtor_ran.store(v); } };

static int depth_three(int n) { if (n > 2) throw std::runtime_error("thrown"); return n; }
static int depth_two(int n) { return depth_three(n) + 1; }
static int depth_one(int n) { return depth_two(n) + 1; }
Expand Down Expand Up @@ -286,6 +291,36 @@ int main() {
check(!(a == b && b == c), "three draws from the entropy source differ");
}

// --- thread_local destructors, where no other C runtime supplies them ---

// TWO CHECKS, AND THE SECOND IS THE ONE THAT WAS SILENTLY FALSE.
//
// `__cxa_thread_atexit` is exported by upstream libc++abi on Linux and
// Fuchsia only, because everywhere else another runtime already defines it
// --- on an ordinary MinGW target, `libmingw32.a`. openkal replaces the C
// library and its runtime together, so both sides assumed the other would
// and the link failed by name on `x86_64-windows-gnu`.
//
// Exporting it was not enough. The fallback kept its list of pending
// destructors in a `__thread` variable, and this runtime is built with
// `-femulated-tls` for PE: emutls releases the thread's block behind a
// pthread key of its own, and the key destructor that walks the list runs
// after that, reading a FRESH ZEROED block at a different address. The
// link then succeeded, the program ran, and nothing was destroyed.
//
// So a check that only asserted construction --- or only asserted that the
// program linked --- would have passed through both defects. The list now
// lives in the key's own value, which no other key's teardown can reach.
{
std::atomic<int> seen{0};
std::thread t([&]{ thread_local TlsProbe p{7}; seen.store(p.v); });
t.join();
check(seen.load() == 7,
"a thread_local is constructed in a spawned thread");
check(tls_dtor_ran.load() == 7,
"and its destructor runs when that thread ends");
}

std::printf("-- failures: %d --\n", failures);
return failures != 0;
}
136 changes: 112 additions & 24 deletions llvm/PATCHES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 这棵 vendored 树被动过的地方

**规矩三条,与 `openkal-musl` 的一致:**
**规矩三条,与 `openkal-musl` 的一致:**

1. **可以动源码。** 移植就是动源码;假装不动只会把差异藏进别处。
2. **动过的地方要标注清楚。** 每一处都夹在
Expand All @@ -21,15 +21,15 @@ grep -rn "openkal ─── BEGIN" llvm/

> upstream 在这里问「这是哪个 OS」,而这个问题真正的答案是 openkal 的 `<接口>`。

⚠️ 填不进去的**不要动**。`sizeof(long)`、`int64_t` 的拼法、目标格式、调用约定
注意:填不进去的**不要动**。`sizeof(long)`、`int64_t` 的拼法、目标格式、调用约定
不是「下面是谁」,是**目标自己的定义** —— 那些归 C 库(`musl-generated/` 按
(arch, os) 分档),不归这里。

---

## 已替换

五处,四个文件,一个判据 —— 每一处都能填进上面那句话。
五处,四个文件,一个判据 —— 每一处都能填进上面那句话。

```sh
grep -rn "openkal ─── BEGIN" llvm/ # 10 处标记(含配对的 #else/#endif)
Expand Down Expand Up @@ -62,16 +62,16 @@ _WIN32 → WaitOnAddress / WakeByAddressSingle
openkal 的答案是 `kal_task_wait` / `kal_task_wake` —— 规范管它叫**挂起原语**,
而它在每一个目标上是同样的两个调用,**包括底下没有操作系统的那个**。

⚠️ **它本来就已经到 openkal 了,只是绕了一圈。** 在 Linux 上,上游那条分支发
**它本来就已经到 openkal 了,只是绕了一圈。** 在 Linux 上,上游那条分支发
`SYS_futex`,openkal-musl 的 port 拦下这个系统调用,它的 `__okm_futex` 调
`kal_task_wait`。直接走去掉了那一圈,并且让另外两种目标格式也能用 —— 在那里
「拦系统调用」没有系统调用可拦。

⚠️ 两处细节按上游的语义保留:两秒的默认超时(没有超时的等待注意不到与它竞争的
注意:两处细节按上游的语义保留:两秒的默认超时(没有超时的等待注意不到与它竞争的
唤醒),以及 `-1` 表示「全部唤醒」(上游写作 `INT_MAX`,openkal 的计数是
`kal_uintptr`,所以按回绕写)。

### ⭐⭐ `libunwind/src/AddressSpace.hpp` — 这个镜像的展开表在哪
### `libunwind/src/AddressSpace.hpp` — 这个镜像的展开表在哪

上游按 OS 答:`EnumProcessModules` 枚举进程的全部模块,逐个解析 PE 头找
`.eh_frame`。**而这个问题这份文件已经答过三遍,没有一遍问操作系统**:
Expand All @@ -81,14 +81,14 @@ openkal 的答案是 `kal_task_wait` / `kal_task_wake` —— 规范管它叫**
| 裸机 | 链接器脚本定义的 `__eh_frame_start` / `__eh_frame_end` |
| Darwin | `_dyld_find_unwind_sections`(`openkal-macos` 用链接器的 `section$start$` 实现) |
| ELF | 自己的 program headers |
| **PE(上游)** | ⚠️ 让操作系统枚举模块 |
| **PE(上游)** | 注意:让操作系统枚举模块 |

⇒ openkal 的答案和前三条同一句话:**镜像自己知道**。静态链接的 openkal 程序
只有一个模块,基址是链接器定义的符号 `__ImageBase`(不是调用),段表在离它固定
的偏移上。读它是**目标格式**的知识 —— 展开器本来就是由这种知识构成的 —— 不是
系统调用,也不是操作系统。

⚠️ **而台账原先写着这一支「已由 DWARF 路线绕开」,那是凭读守卫写的,实测否掉了。**
**而台账原先写着这一支「已由 DWARF 路线绕开」,那是凭读守卫写的,实测否掉了。**
`_WIN32 && DWARF` 正是它的守卫,DWARF 路线就是它。2026-08-23 实测:

```
Expand All @@ -99,11 +99,11 @@ AddressSpace.hpp:114 → /usr/x86_64-w64-mingw32/include/windows.h:69

—— 一条 include 把**宿主的 mingw sysroot** 拉进了刚刚摆脱了厂商 SDK 的构建。

实测确认段表里找得到:链接后的镜像里是 `.eh_fram`(PE 的段名字段固定 8 字节,
实测确认段表里找得到:链接后的镜像里是 `.eh_fram`(PE 的段名字段固定 8 字节,
`.eh_frame` 是 9 个字符),大小 0x530c8 —— 这正是上游用
`IMAGE_SIZEOF_SHORT_NAME` 比 8 个字节而不是比全名的原因。

⚠️ **走过一条错路,记下来免得再走**:先试过用 COFF 的分组段
**走过一条错路,记下来免得再走**:先试过用 COFF 的分组段
(`.eh_frame$a` / `.eh_frame$z`)去夹住 `.eh_frame`,链接器的排序是

```
Expand All @@ -117,7 +117,7 @@ AddressSpace.hpp:114 → /usr/x86_64-w64-mingw32/include/windows.h:69

### `libunwind/src/RWMutex.hpp` — 读写锁

⚠️ 两处,而不是一处:`<windows.h>` 的 include 在 `_LIBUNWIND_HAS_NO_THREADS`
注意:两处,而不是一处:`<windows.h>` 的 include 在 `_LIBUNWIND_HAS_NO_THREADS`
**之前**就无条件发生,类的选择是第二处。只撤回第一处会留下引用 `SRWLOCK` 而没有
任何东西声明它。

Expand All @@ -129,7 +129,7 @@ AddressSpace.hpp:114 → /usr/x86_64-w64-mingw32/include/windows.h:69

### `compiler-rt/lib/builtins/emutls.c` — 互斥与对齐分配

⚠️ **这个文件被编译到 PE 上,恰恰是因为那个平台自己的 thread-local 机制用不了**
**这个文件被编译到 PE 上,恰恰是因为那个平台自己的 thread-local 机制用不了**
(`_tls_index` 需要动态加载器),然后它转身去要了同一个平台的 C 运行时:

```
Expand Down Expand Up @@ -178,31 +178,119 @@ openkal-musl 的 `[c-abi] presents = "posix"` 把它和 `_WIN32`、`__MINGW32__`
因为它**是**已安装的。两者读不同的宏,回答同一个关于同一个目标的问题,这正是下面那一节
要求的性质。

⚠️ **下一次改这棵树,`grep` 的清单要加上 `__CYGWIN__`**:
**下一次改这棵树,`grep` 的清单要加上 `__CYGWIN__`**:
`grep -rn "_WIN32\|_WIN64\|__MINGW32__\|__MINGW64__\|__CYGWIN__"`。

## ⭐ 一个名字,不是五个
## 第七处:`libcxxabi/src/cxa_thread_atexit.cpp`(2026-09-21)

**前六处都是「守卫问错了问题,于是落错分支」。第七处不一样:守卫确实要放开,而放开之后
它的实现本身在这个目标上是坏的**——只修守卫,换来的是一个静默的运行期失败。

### 第一层:导出

```c
#if defined(__linux__) || defined(__Fuchsia__)
extern "C" { int __cxa_thread_atexit(Dtor, void*, void*) throw(); }
#endif
```

上游只在这两个系统上导出,因为别处已经有人导出了——普通 MinGW 目标由 `libmingw32.a`
提供(`llvm-nm --defined-only libmingw32.a | grep -c __cxa_thread_atexit` 为 1)。openkal
把 C 库连同它的运行时一起换掉,于是两边都以为对方会提供。实测:索引兼容性测量里
`doctest` 与 `spdlog` 两个成员在 `x86_64-windows-gnu` 上停在
`ld.lld: error: undefined symbol: __cxa_thread_atexit`。

这个文件**不是**已安装的头,所以按本文的规矩用私有 define `OPENKAL_TARGET_WINDOWS`。

### 第二层:`__thread` 在 emutls 下活不过 key 析构

放开守卫之后**链接通了,而析构不跑**。上游的 fallback 把待执行析构的链表存在
`__thread DtorList* dtors` 里,只拿 TLS key 的值当一个触发用的哑元。在本包为 PE 采用的
`-femulated-tls` 下,这个前提不成立。实测(wine,同一个线程):

```
registered dtor, dtors=0x7ffffe994680, &dtors=0x7ffffe9946a8
run_dtors called, dtors=0, &dtors=0x7ffffe9946c8
```

**`&dtors` 两次不同。** emutls 把每线程的块挂在它自己的一个 pthread key 后面,而那个 key
的析构已经先释放了本线程的块;之后再读就新分配一个**清零**的块,地址每次都不一样。于是
`run_dtors` 走的是一个空链表,每一个 `thread_local` 的析构被静默跳过。

**为什么之前一次探针把 emutls 排除掉了。** musl 按 key 的创建顺序逐个调析构。那次探针
自己的 key 建在第一次访问 `thread_local` **之前**,于是 emutls 的 key 在它之后才被析构,
读到的值是对的。真实情形的顺序正好相反——**一个探针报不出它被构造成不会发生的那个顺序**。

### 第三层:macOS 用另一个名字问同一件事,而弱引用探测在 Mach-O 上不成立

**这一层是被上一层的判据挖出来的**——`examples/cxx` 新增那条 `thread_local` 断言之后,
`aarch64-macos` 那条腿立刻红了,而在此之前该目标从未构造过带析构的 `thread_local`。

```
ld64.lld: error: undefined symbol: _tlv_atexit
>>> referenced by main.cpp:316
```

clang 在 Mach-O 上把带非平凡析构的 `thread_local` 降解为 `_tlv_atexit`,在别处才是
`__cxa_thread_atexit`。两者只差一个 dso handle,而 fallback 本来就不读它,所以不需要第二份
实现——一个转发就够。供给 `_tlv_atexit` 的本来是 libSystem,而 libSystem 正是本栈替换掉的。

放开守卫到 `__APPLE__` 之后**又红了一次**,这次是另一个符号:

```
ld64.lld: error: undefined symbol: __cxa_thread_atexit_impl
```

上游用的是**弱声明 + 判空**——`if (__cxa_thread_atexit_impl)`。那是 ELF 的习语:未解析的
弱符号在那里**就是 0**。ld64 在静态链接里不这么做,于是**探测本身**成了未定义符号。
在这个目标上这个探测也是多余的:能定义那个符号的是 libSystem。

所以 Apple 直接走 fallback,而 fallback 的函数体被提出来命名,两个调用点共用一份实现。

### 修法:链表存进 key 自己的值

key 的析构函数本来就被交给 key 的值。把链表存在那里,任何别的 key 的拆除都碰不到它,
`dtors_alive` 也随之不需要——值非空就是「链表在」。

**判据是两条,缺一不可**(`examples/cxx`):

```
ok: a thread_local is constructed in a spawned thread
ok: and its destructor runs when that thread ends
```

只断言「链接通过」或只断言「构造发生」的判据,会同时放过前两层。

**三个目标都构建**(`x86_64-linux-gnu`、`x86_64-windows-gnu`、`aarch64-macos`,均从
Linux 宿主交叉),前两个**实跑** `failures: 0`(Windows 经 wine)。macOS 的运行由 CI 的
`host-dimension` / `run-on-macos` 覆盖。

**这条判据是它自己挖出第三层的。** 加上它之前,三个目标里没有任何一个构造过带析构的
`thread_local`,于是 macOS 那一侧的两个缺口都不在场——判据的价值不在于它今天绿,而在于
它让一类从来没有对象的检查有了对象。

## 一个名字,不是五个

五处补丁全部守卫在 **`OPENKAL`** 上,`cflags` 和 `cxxflags` 各给一次
(`compiler-rt` 是 C)。读法是「在 Windows 上,除非底下是 openkal」。

⚠️ 一度写成 `_LIBUNWIND_OPENKAL`,随后 `emutls.c` 需要同一个事实而它是 C 文件 ——
注意:一度写成 `_LIBUNWIND_OPENKAL`,随后 `emutls.c` 需要同一个事实而它是 C 文件 ——
**同一个事实两个名字**正是这套代码里反复出问题的形状,所以收敛掉了。

---

## 不需要动的 —— 而这一节比上一节重要

整棵树里 `#include <windows.h>` 共 **19 处**。按守卫分类:
整棵树里 `#include <windows.h>` 共 **19 处**。按守卫分类:

| 守卫 | 处数 | 怎么解决的 |
|---|---|---|
| `_LIBCPP_WIN32API` | **12** | `port/include/__config` 撤回了它 → **自己落到 POSIX 分支** |
| `__SEH__` / `_LIBUNWIND_SUPPORT_DWARF_UNWIND` | 2 | `-fdwarf-exceptions`,异常机制跟随我们带的 unwinder |
| 裸 `_WIN32` | 3 | 三处全换(AddressSpace / RWMutex / UnwindCursor) |
| `emutls.c` 的 `_WIN32` | 2 | 走 POSIX 分支 |
| `_LIBCPP_WIN32API` | **12** | `port/include/__config` 撤回了它 → **自己落到 POSIX 分支** |
| `__SEH__` / `_LIBUNWIND_SUPPORT_DWARF_UNWIND` | 2 | `-fdwarf-exceptions`,异常机制跟随我们带的 unwinder |
| 裸 `_WIN32` | 3 | 三处全换(AddressSpace / RWMutex / UnwindCursor) |
| `emutls.c` 的 `_WIN32` | 2 | 走 POSIX 分支 |

⭐⭐ **12 处不用换,因为 libc++ 的 POSIX 分支本来就已经在 openkal 上了。** 它调
**12 处不用换,因为 libc++ 的 POSIX 分支本来就已经在 openkal 上了。** 它调
`fopen` / `clock_gettime` / `pthread_*`,那些是 musl,而 musl 就在 openkal 上。
让谓词答对,它自己就走到那条路。

Expand All @@ -211,7 +299,7 @@ openkal-musl 的 `[c-abi] presents = "posix"` 把它和 `_WIN32`、`__MINGW32__`

---

## ⚠️ 不在这里的两类东西
## 注意:不在这里的两类东西

一开始误以为要在这棵树里解决,实际不在:

Expand All @@ -220,7 +308,7 @@ openkal-musl 的 `[c-abi] presents = "posix"` 把它和 `_WIN32`、`__MINGW32__`
| 数据模型(`sizeof(long)`) | **C 库**(`musl-generated/<arch>[-<os>]`) | 它重建的是 POSIX,而 POSIX 自己命名了 `long`。openkal 接口上不存在这个问题 —— 见 openkal 的 §1.3 |
| `-fdwarf-exceptions` / `-femulated-tls` | **mcpp**(`graph_runtime_compile_flags`) | 它们决定 `throw` 和 `thread_local` **编译成什么**,所以是**整张图**的性质,不是某个包的。写在本包 `[build]` 里时,只覆盖了本包的对象,使用者的 `main.o` 拿不到 |

⚠️ 第二行是实测逼出来的:全部编过之后,链接报
注意:第二行是实测逼出来的:全部编过之后,链接报
`undefined symbol: __gxx_personality_seh0`,引用它的是**使用者的 `main.o`** ——
一个写了 `try` 而没有任何理由知道这件事的翻译单元。

Expand All @@ -233,5 +321,5 @@ openkal-musl 的 `[c-abi] presents = "posix"` 把它和 `_WIN32`、`__MINGW32__`
| **头文件**里的平台分派 | `port/include/` 覆盖 | 靠 include 顺序遮蔽,vendored 树逐字节不动,**不随上游漂移** |
| **源码**里的平台分派 | 本文件记录的原地标记 | 消费者不 include `.cpp`,遮蔽不了 |

⚠️ 顺序是有偏好的:**能用覆盖就别用补丁**。覆盖的漂移面是零,补丁的漂移面是被
注意:顺序是有偏好的:**能用覆盖就别用补丁**。覆盖的漂移面是零,补丁的漂移面是被
标记的那几十行。
Loading
Loading