有效类型:第 1 部分
摘要
Martin Uecker 发布了博客系列的第一部分,深入解析 C 语言的有效类型规则如何支撑基于类型的别名分析(TBAA),并说明编译器如何利用这些规则进行与未定义行为(UB)相关的优化。
<p><a href="https://lobste.rs/s/ukcfap/effective_type_part_1">评论</a></p>
查看缓存全文
缓存时间: 2026/10/31 23:29
# Martin Uecker
来源:https://uecker.codeberg.page/2026-10-31.html
### 有效类型:第 1 部分
Martin Uecker,2026\-10\-31
#### 引言
C 编程语言中的*有效类型*规则支持基于类型的别名分析 \(TBAA\)。这些规则由来已久,早在 ANSI X3\.159\-1989 中就已存在。从根本上说,如果变量声明时指定了某种类型,那么使用不兼容的类型访问它就会导致未定义行为。一些编译器利用这里的未定义行为进行优化,但实现也可以允许这些访问,甚至加入运行时类型检查。C 标准委员会过去认为,市场会选出最佳解决方案。正如我们所知,市场更偏爱优化,而非安全性。
实际上,这些规则稍微复杂一些,并不只取决于类型的兼容性。具体来说,规则允许不同的限定符和不同的有符号性,为字符类型设置了例外,还规定了通过指针直接访问结构体或联合体成员的方式。为使本文保持简明,我将只考虑类型兼容性起作用的示例。
在下面的示例中,编译器不会在 return 语句中重新加载左值 `\*p`,而是直接返回第一行写入的值 3。
示例 1\(Godbolt\) (https://godbolt.org/z/ETon75M1Y)
``
int foo(int *p, float *q)
{
*p = 3; // 有效类型变为 int
*q = 1.; // 有效类型变为 float
return *p; // 如果有效类型不是 int,则存在未定义行为
}
``
``
"foo":
mov DWORD PTR [rdi], 3
mov eax, 3
mov DWORD PTR [rsi], 0x3f800000
ret
``
为什么允许这样做?如果对 `\*q` 的写入与随后使用不兼容类型对 `\*p` 的读取确实指向同一个对象,那么行为就是未定义的,此时返回 `3` 也是允许的行为。
语言语义专家将其称为*精化*,因为返回 `3` 是所有允许行为的集合中的一种具体行为。如果这些访问确实存在别名关系,且行为未定义,那么其他具体行为(包括返回 `4`)也同样可以接受。
在这个示例中,编译器利用这种自由,将存在别名关系(并具有未定义行为)的情况的行为,与更可能出现的、不存在别名关系(也没有未定义行为)的情况统一起来;在后一种情况下,`3` 就是必须返回的结果。有些人说,编译器可以*假定*这些访问不存在别名关系,但这种说法有点不严谨,也不完全正确。
请注意,有效类型规则讨论的是通过左值进行访问时使用的类型,因此关注的是实际访问所用的类型。例如,对于 `\*\(float\*\)p`,这个类型是 `float`,而指针对象 `p` 本身的类型则完全无关紧要。强制类型转换本身并不违反别名规则,关键只在于,随后对同一个对象的访问是否使用了类型兼容的左值。具体来说,函数参数的类型也并不重要。
#### C99 中改变类型的写入
C89 最初的措辞直接假定所有对象都有声明类型。在 ISO C99 中,规则得到了修正,允许通过写入改变类型,这是正确支持堆上分配的内存所必需的,因为这类内存最初没有任何类型。具体来说,C99 \(6\.5§6\) 现在有如下规则……如果通过一个非字符类型的左值,将值存入一个没有声明类型的对象,那么该左值的类型就成为该对象在此次访问以及后续不修改所存储值的访问中的有效类型。……有效类型在写入时设置,随后对读取保持有效,直到发生另一次写入。换句话说,规则现在通常允许改变所分配对象的类型,即使之前已经使用另一种类型访问过它。事实上,许多 C 程序利用这一点复用内存,例如在 `malloc` 和 `free` 之上定义自己的分配器时。
下面这段代码的行为是有定义的,尽管在程序执行期间,同一个对象使用了两种不兼容的类型进行访问。
示例 2\(Godbolt\) (https://godbolt.org/z/E5jneeEcG)
``
int foo(int *p, float *q)
{
*q = 0.; // 有效类型变为 float
*p = 3; // 有效类型变为 int
return *p; // 有效类型是 int
}
int main()
{
static_assert(sizeof(int) == sizeof(float));
void *q = malloc(sizeof(int));
if (!q) return 0;
// *q 没有有效类型
int r = foo(q, q);
free(q);
return r;
}
``
第一次写入将有效类型设为 `float`。第二次写入可以将有效类型改为 `int`。最后的读取使用类型 `int`,这与对象此时的有效类型相匹配。
#### 编译器与改变类型的写入
在循环中,情况变得更有意思,因为编译器希望将写入移到循环外。
示例 3\(Godbolt\) (https://godbolt.org/z/e1zb375js)
``
void foo(int N, int *p, float q[N])
{
for (int i = 0; i < N; ++i) {
q[i] = 42;
*p = 1;
}
}
``
``
"foo":
test edi, edi
jle .L1
mov rax, rdx
movsxd rdi, edi
lea rdx, [rdx+rdi*4]
movss xmm0, DWORD PTR .LC0[rip]
.L3:
movss DWORD PTR [rax], xmm0
add rax, 4
cmp rax, rdx
jne .L3
mov DWORD PTR [rsi], 1
.L1:
ret
.LC0:
.long 1109917696
``
如你所见,GCC 将对 `\*p` 的不变写入移到了循环外,并在最后执行。即使存在别名关系,这项优化也是正确的,不会影响最终结果。
现在来看看 clang 的做法 \(godbolt (https://godbolt.org/z/fexrjvz5a)\):
``
foo:
test edi, edi
jle .LBB0_3
mov dword ptr [rsi], 1
mov eax, edi
xor ecx, ecx
.LBB0_2:
mov dword ptr [rdx + 4*rcx], 1109917696
inc rcx
cmp rax, rcx
jne .LBB0_2
.LBB0_3:
ret
``
Clang 将写入提升到了循环之前。但这是不正确的,因为对 `q\[i\]` 进行的改变类型的写入随后会覆盖先前写入的值 `1`,这与 C 源代码不符。*Clang 错误编译了这段代码!*
事实上,Clang 没有实现 ISO C99 的有效类型规则,这是已知的事实(但可能并不广为人知)。只有使用 `\-fno\-strict\-aliasing` 命令行标志关闭 TBAA 时,才能认为 Clang 符合 ISO C99 及后续标准。
过去,GCC 也曾在这一点上出错,记录见 PR 57359 (https://gcc.gnu.org/bugzilla/show_bug.cgi?id=57359),但它在 2020 年修复了这个根本性的写入移动问题。遗憾的是,对应的 LLVM 缺陷 21725 (https://bugs.llvm.org/show_bug.cgi?id=21725) 的链接已经失效,但据我所知,过去十年里并没有人为解决这一问题而付诸努力。GCC 的情况要好得多,因为其中端语义支持改变类型的写入。不过,仍有一些尚未修复的缺陷:某个改变类型的无用写入被优化掉后,优化器随后会对有效类型作出错误假设。因此,即使使用 GCC,关闭 TBAA 也可能是明智之举。
#### 结论
有效类型存在许多容易引起混淆的地方,其中涉及不少微妙的问题。不过,这篇关于该主题的首篇博文所讨论的 ISO C99 基本规则相当简单。遗憾的是,优化编译器仍然没有正确处理这些规则。Clang 直到今天仍未实现 ISO C 所规定的规则,而 GCC 虽然实现了正确的规则,但仍有一些已知缺陷。
相似文章
Typst: Designing for Incrementality
Typst 通过约束记忆化(comemo)和纯函数设计,使语言和编译器协同工作,实现高效的增量编译和实时预览。文章详细介绍了布局缓存、模块评估记忆化、函数纯度以及内省系统的设计思路。
The Unreasonable Effectiveness of Constructive Data Modeling
本文通过将类型视为构造新值的正空间而非限制值的负空间,结合积类型和和类型,可以精确建模数据的不变量,避免与类型检查器的斗争。建议使用简单类型组合而不是复杂类型系统特性。
@yibie: https://x.com/yibie/status/2100541283081023936
这篇文章详细介绍了TypeSafe的Jev工具的使用方法,包括三种提问原语、五条上手路径以及置信度管理的实践建议。
@yibie: https://x.com/yibie/status/2101165795665457154
本文探讨了TypeSafe的Jev API背后的基座模型的两种可能架构假设:双向编码器或改造后的因果解码器,并分析了相关证据和影响。
联合类型与可选类型
一篇探讨联合类型及其特例——可选类型(T|Nothing)的博客文章,以及作者在 Copper(一种极简的底层“更好的 C”语言)中实现它们的实验,包括设计上的限制,以及为什么完整的联合类型被证明过于复杂。