在 LLVM 中对抗 Hyrum 定律
摘要
本文概述了 LLVM 编译器基础设施中旨在防止依赖未指定行为(即 Hyrum 定律)以保障构建可重现性的机制。
<p><a href="https://lobste.rs/s/wugfx4/fighting_hyrum_s_law_llvm">评论</a></p>
查看缓存全文
缓存时间: 2026/05/11 21:10
# 在 LLVM 源码中对抗海勒姆定律
来源:https://maskray.me/blog/2026-05-10-fighting-hyrums-law-in-llvm
> *对于一个 API 而言,只要用户数量足够多,你在契约中承诺什么都不重要:系统的所有可观察行为都会被某些人依赖。* — 海勒姆定律 (Hyrum's Law)
在编译器中,海勒姆定律最常见的形式是依赖*未指定行为* — 哈希桶顺序、`std::sort` 后相等元素的顺序、填充偏移。同样的框架涵盖了一些技术上是未定义行为的情况(使用失效的迭代器)或纯粹的 incidental 属性(ABI 结构体布局、ELF 段偏移)。当编译器本身存在这种依赖时,症状通常是输出随构建而变化:标准库更改后不稳定排序落在不同位置,哈希函数更改后哈希映射的迭代顺序 shifts。偶尔变化发生在单次构建内的运行之间 — 具有源自 ASLR 种子的 `DenseMap` 键在每次调用时重新排序桶。无论如何,可重现构建、二分排查和错误报告都假设相同输入 → 相同输出,而隐蔽的海勒姆依赖会破坏这一点。本文调查了一些扰动契约盲点的机制,以便依赖无法静默形成。
## 哈希种子扰动
第一道防线是哈希函数本身。`llvm/include/llvm/ADT/Hashing.h`:
```cpp
inline uint64_t get_execution_seed() {
#if LLVM_ENABLE_ABI_BREAKING_CHECKS
return static_cast<uint64_t>(
reinterpret_cast<uintptr_t>(&install_fatal_error_handler));
#else
return 0xff51afd7ed558ccdULL;
#endif
}
```
异或到每个 `llvm::hash_value` 中的种子是 `install_fatal_error_handler` 的运行时地址 — 在 ASLR 下,每个进程都不同。头部注释明确说明:
> *种子是每个进程非确定性的(LLVMSupport 中函数的地址),以防止用户依赖特定的哈希值。*
每次 `hash_combine`/`hash_integer_value` 调用都会获取种子,每个由使用 `hash_value` 的类型键控的 `DenseMap` 随后会在每次运行时重新排序其桶。MD5, BLAKE3, SHA1, SHA256 保持字节稳定 — 当你确实想要摘要时,这些是正确的工具。我的提交 `ce80c80dca45` (https://github.com/llvm/llvm-project/commit/ce80c80dca45) 于 2024 年引入了该种子。
## 容器迭代顺序
代码可能会对迭代顺序产生依赖。`LLVM_ENABLE_REVERSE_ITERATION` 反向遍历哈希容器以标记违规行为。`llvm/include/llvm/Support/ReverseIteration.h`:
```cpp
template <typename T>
constexpr bool shouldReverseIterate() {
#if LLVM_ENABLE_REVERSE_ITERATION
return detail::IsPointerLike<T>::value;
#else
return false;
#endif
}
```
`DenseMap` 将其 `BucketItTy` 翻转为 `std::reverse_iterator`;`SmallPtrSet` 交换 `begin()` 和 `end()`;`StringMap` 在桶选择前对哈希进行按位非操作 — 这是唯一扰动 `StringMap` 的东西,因为其哈希绕过了 `get_execution_seed`。与哈希种子不同,反向迭代不会随断言自动开启;`-DLLVM_REVERSE_ITERATION=ON` 显式启用。2026 年已经合并了由其触发的修复:`7f703cabf728` (https://github.com/llvm/llvm-project/commit/7f703cabf728)(MLIR SSA 值完成顺序)、`0b3afd35c41d` (https://github.com/llvm/llvm-project/commit/0b3afd35c41d)(MLIR SROA alloca 顺序)和 `f5e2c5ddcec7` (https://github.com/llvm/llvm-project/commit/f5e2c5ddcec7)(一个 clang 测试)。
## 迭代器失效
与迭代顺序正交:突变后现有迭代器会发生什么。`llvm/include/llvm/ADT/EpochTracker.h`:
```cpp
class DebugEpochBase {
uint64_t Epoch = 0;
public:
void incrementEpoch() { ++Epoch; }
~DebugEpochBase() { incrementEpoch(); }
class HandleBase {
bool isHandleInSync() const {
return *EpochAddress == EpochAtCreation;
}
};
};
```
`DenseMap` 及其友元继承自 `DebugEpochBase`。突变会增加 epoch;迭代器在构造时捕获它,并在不匹配时断言。析构函数也会增加 epoch,因此指向已销毁容器的陈旧迭代器会断言,而不是读取已释放内存。没有它,迭代期间突变“碰巧有效”,取决于桶布局 — 而桶布局正是上述哈希种子和反向迭代所扰动的。epoch 检查将潜在 bug 转变为干净的断言,无论运行落在哪个“幸运”布局上。在 `NDEBUG` 下折叠为无操作。
## 预洗牌不稳定排序
相同的防御模式在 monorepo 中出现了两次,在不同的子项目中,相隔数年。
### `EXPENSIVE_CHECKS` 下的 `llvm::sort`
`llvm/include/llvm/ADT/STLExtras.h`:
```cpp
#ifdef EXPENSIVE_CHECKS
namespace detail {
inline unsigned presortShuffleEntropy() {
static unsigned Result(std::random_device{}());
return Result;
}
template <typename IteratorTy>
inline void presortShuffle(IteratorTy Start, IteratorTy End) {
std::mt19937 Generator(presortShuffleEntropy());
llvm::shuffle(Start, End, Generator);
}
} // end namespace detail
#endif
template <typename IteratorTy, typename Compare>
inline void sort(IteratorTy Start, IteratorTy End, Compare Comp) {
#ifdef EXPENSIVE_CHECKS
detail::presortShuffle(Start, End);
#endif
std::sort(Start, End, Comp);
}
```
`std::sort` 和 `qsort` 是不稳定的;观察相等元素顺序的代码依赖于未记录的行为。预洗牌使该观察每次运行都不同。提交 `5a3d47fabcb6` (https://github.com/llvm/llvm-project/commit/5a3d47fabcb6) 于 2018 年添加了该包装器,动机是 PR35135 (https://bugs.llvm.org/show_bug.cgi?id=35135)。LLVM 还发布了自己的 `llvm::shuffle` 而不是调用 `std::shuffle`,"*以便 LLVM 在使用不同标准库时表现一致。*"一个可重现性取决于宿主 stdlib 的可重现性工具比没有工具更糟 — 而下方的链接器部分依赖于这一点。`llvm::stable_sort` 故意不预洗牌;它是确实需要相等元素排序的代码的显式启用。
### libc++ `_LIBCPP_DEBUG_RANDOMIZE_UNSPECIFIED_STABILITY`
libc++ 有一个近乎完美的并行机制,专为下游用户设计,而非项目内部。`libcxx/include/__debug_utils/randomize_range.h`:
```cpp
template <class _Iterator, class _Sentinel>
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR_SINCE_CXX14
void __debug_randomize_range(_Iterator __first, _Sentinel __last) {
#ifdef _LIBCPP_DEBUG_RANDOMIZE_UNSPECIFIED_STABILITY
if (!__libcpp_is_constant_evaluated())
std::__shuffle<_AlgPolicy>(__first, __last, __libcpp_debug_randomizer());
#else
(void)__first; (void)__last;
#endif
}
```
三个调用点:
- `std::sort` — 预洗牌输入。
- `std::partial_sort` — 预洗牌输入*并*随后重新洗牌未排序的尾部。
- `std::nth_element` — 预洗牌,然后重新洗牌分区的每一侧。
种子处理与 `get_execution_seed` 类似:ASLR 或静态 `std::random_device` 用于每进程变化,并使用 `_LIBCPP_RANDOMIZE_UNSPECIFIED_STABILITY_SEED=` 作为固定种子逃生舱。默认关闭;仅限 C++11 及更高版本。`libcxx/docs/DesignDocs/UnspecifiedBehaviorRandomization.rst` 解释了动机:
> *Google 测量了数千个测试依赖于排序和选择算法的稳定性。由于我们也计划更新(或至少,在标志下提供更多)排序算法,这项工作有助于逐步且可持续地进行。*
它引用了 PR20837 (https://llvm.org/PR20837) — 一个最坏情况 `O(n2)` 的 `std::sort` — 作为 libc++ 特别希望发布的升级。洗牌是 gating 工具:如果下游测试在启用它时通过,它们在算法更改后也会通过。比较两者比单独看任何一个更有趣:
- `llvm::sort` 的包装器是内部卫生:LLVM 是其自己的主要用户,因此洗牌位于构建标志后的 `STLExtras.h` 中且无文档。
- libc++ 的包装器是面向用户的 — `DesignDocs/` 页面、公共宏、公共种子覆盖、显式的 "Patches welcome." 邀请。必须如此:libc++ 的用户不是 libc++,且被防御的契约是 C++ 标准本身。
- libc++ 泛化了原语:`__debug_randomize_range` 应用于三个调用点,每个都声明算法留下未指定的子范围。LLVM 的包装器仅覆盖更简单的相等元素情况。
- 哈希容器 — `std::unordered_*` 迭代顺序 — 在两者中均未指定,但 libc++ 不随机化它们。LLVM 库确实如此;在这一表面上 LLVM 领先于其自己的 stdlib。
## 链接器输出:`--shuffle-sections` 和 `--randomize-section-padding`
两个仅 ELF 的 lld 旋钮扰动契约未覆盖的布局细节。
### `--shuffle-sections=<pattern>=<seed>`
`lld/ELF/Writer.cpp`:
```cpp
for (const auto &patAndSeed : ctx.arg.shuffleSections) {
...
const uint32_t seed = patAndSeed.second;
if (seed == UINT32_MAX) {
std::reverse(matched.begin(), matched.end());
} else {
std::mt19937 g(seed ? seed : std::random_device()());
llvm::shuffle(matched.begin(), matched.end(), g);
}
}
```
一个选项中的三种模式:
- `seed = -1` — 确定性反向,即使出现新段也稳定。Glob `\.init_array\*` 到 `-1`,重建,运行测试套件:任何破坏的都是真正的静态初始化顺序 bug。一个标志,无需 Frankenstein 链接脚本。
- `seed > 0` — 确定性随机洗牌,跨运行和主机可重现(因为 `llvm::shuffle` 与主机无关)。在 CI 中很有用,不会破坏二分排查。
- `seed = 0` — `std::random_device()` 种子。每次链接都是新的非确定性。
历史:`423cb321dfae` (https://github.com/llvm/llvm-project/commit/423cb321dfae) 引入了 `=-1` 反向模式;`16c30c3c23ef` (https://github.com/llvm/llvm-project/commit/16c30c3c23ef) 泛化为每 glob 种子,这使得 `\.init_array*=-1` 方案成为可能;`c135a68d426f` (https://github.com/llvm/llvm-project/commit/c135a68d426f) 修复了一个 bug,该功能本身产生了无效的动态重定位顺序 — 即使海勒姆缓解措施也有正确性陷阱。
### `--randomize-section-padding=<seed>`
姐妹选项通过在输入段之间和段起点插入填充来扰动段*偏移*(`lld/ELF/Writer.cpp`):
```cpp
static void randomizeSectionPadding(Ctx &ctx) {
std::mt19937 g(*ctx.arg.randomizeSectionPadding);
}
```
调用者会对链接器从未承诺的填充导致的偏移产生依赖 — profile 引导的流水线、侧信道研究、固定到特定地址的利用工具链。种子扰动使这些依赖可见。两个选项都仅限 ELF;MachO 和 COFF 端口没有等效项。
## ABI 破坏检测
`llvm/include/llvm/Config/abi-breaking.h.cmake`:
```cpp
#if LLVM_ENABLE_ABI_BREAKING_CHECKS
ABI_BREAKING_EXPORT_ABI extern int EnableABIBreakingChecks;
LLVM_HIDDEN_VISIBILITY __attribute__((weak))
int *VerifyEnableABIBreakingChecks = &EnableABIBreakingChecks;
#else
ABI_BREAKING_EXPORT_ABI extern int DisableABIBreakingChecks;
...
#endif
```
每个包含头文件的 TU 根据其自身的构建标志对 `EnableABIBreakingChecks` 或 `DisableABIBreakingChecks` 采取弱引用。将两者与同一个 `libLLVM` 混合会在链接时产生未解析符号。MSVC 通过 `#pragma detect_mismatch` 获得相同的保证。树外用户通常针对一个树的头文件编译,并链接到不同的 `libLLVM`。没有这个门,链接碰巧选择的任何结构体布局都会静默错误编译;有了它,链接会失败。
## LLVM *没有* 做什么
上述机制都针对稳定消费者不应关心的表面:桶顺序、相等元素排序顺序、init-array 顺序。调试器、性能分析器、sanitizer 和可重现构建基础设施消费这些输出并需要它们稳定。在某些情况下,更强的保证仅通过显式选项提供。例如,Bitcode 和文本 IR 仅在 `-preserve-bc-uselistorder`/`-preserve-ll-uselistorder` 下保留使用列表顺序。一个近亲:clang 的 `-frandomize-layout-seed`/`__attribute__((randomize_layout))`。机制上相同 — 对结构体字段进行种子 `std::shuffle` — 它确实偶然使 `offsetof` 依赖失效。但意图是漏洞利用缓解,借鉴自 GrSecurity 的 Randstruct GCC 插件:每构建一次的内核硬化,而非开发者工具。
相似文章
错误的架构:从普遍不可能性到局部补丁的LLM可靠性
本文论证了通用LLM可靠性是不可能的,但在操作上受限的补丁(如法律审查、医学RAG)内,失败是稀疏且重复的,使得可靠性成为一个局部目录发现问题。本文通过两个命题和一个推论将其形式化,重新定位而非消解长上下文生成的困难。
语义具体化:如何生成任意控制流且无未定义行为的代码?
Reify 是一款基于语义具体化技术的开源随机 C 程序生成器,能够生成不含未定义行为的代码,专用于编译器测试。它已在 GCC 和 LLVM 中发现了 59 个 bug,并在 OpenJ9 和 Linux 的 eBPF 运行时中发现了额外的缺陷。
对齐但脆弱:通过零阶优化增强LLM安全鲁棒性
本文提出了一个混合框架,结合一阶安全对齐与零阶微调,以增强LLM安全对齐在受到对齐后扰动时的鲁棒性。理论和实验结果表明,仅需少量微调步骤即可在保持安全性的同时提升鲁棒性。
优化 LLVM 的 bump 分配器
这篇博客文章详细介绍了对 LLVM 的 BumpPtrAllocator 进行的三项近期优化,通过移除冗余对齐、空指针检查以及每次分配的记账开销来减少快速路径开销,从而提升了 Clang、lld 及其他 LLVM 组件的性能。
LLMs中的潜在对齐漏洞:来自Gemma-3-12B的行为与隐藏状态证据——指令调优LLMs中预令牌隐藏状态偏移作为对齐策略遍历向量
本文研究指令调优LLMs(特别是Gemma-3-12B)中的一个对齐漏洞,通过展示预令牌隐藏状态偏移可以作为对齐策略遍历向量,从而可能绕过安全措施。