减少 C 语言中的未定义行为
摘要
Martin Uecker 在 Kernel Recipes 2026 上发表了关于 C 语言中未定义行为的演讲,探讨了其产生的原因、抽象机器模型如何为其提供依据,以及 C 语言最终能否变得内存安全。
暂无内容
查看缓存全文
缓存时间: 2026/10/09 06:38
# 减少 C 语言中的未定义行为
来源:https://lwn.net/Articles/1095811/
> **本文由 LWN 订阅者赞助**LWN.net 的订阅者使本文——以及它周围的一切——成为可能。如果您欣赏我们的内容,请[购买订阅](https://lwn.net/Promo/nst-nag3/subscribe),让下一批文章成为可能。
作为生物医学工程教授,Martin Uecker 也许不太符合 Kernel Recipes(https://kernel-recipes.org/en/2026/)典型演讲者的形象。然而,他是一位长期的 Linux 用户,从事控制磁共振成像(MRI)扫描仪的自由软件开发。他参加了这次会议,讨论 C 编程语言、C 中未定义行为的具体问题,以及它最终是否能被改造成一种内存安全的语言。
为什么在 2026 年还要用 C?他说,C 仍然是一门优秀的语言。C 具有可移植性、长期稳定性、编译速度快,生成的二进制代码执行效率高。"所见即所得"——阅读 C 代码时,很容易了解计算机实际会执行什么操作。围绕这门语言有大量工具可用,而且在必要时 C 不会碍事。
他说,C 有着悠久的历史,这影响了我们今天所看到的语言。C89 标准必须应对各种各样的硬件,包括使用符号-幅值或反码整数表示的机器、分段内存、特殊的指针表示,以及令人意外的类型大小。例如,某些 Honeywell 机器使用九位字节。这极大地增加了编写一个能够支持可移植代码的标准的难度。
所采用的方法是通过一台抽象机来定义语言的语义。所有操作都必须仿佛在那台抽象机上运行一样执行,而抽象机可能并不与实际硬件完全一致。程序的可观察行为必须与抽象机所产生的行为相同。"可观察"这个部分很重要:对 `volatile` 变量的访问被定义为可观察行为,必须严格按照抽象机的规则发生;其他操作只需产生相同的最终结果即可。
标准给予了编译器实现者很大的自由度;只需保留可观察行为。这种行为中有许多方面要么是未定义的,要么是由实现定义的。这些不属于可观察行为,因此不限制编译器实现者可以做什么。当然,还有其他规范*可以*在 C 标准没有约束的地方约束编译器开发者;包括 ABI 要求、POSIX 等标准,或向后兼容性的需要。
当程序做了标准未定义的或不可移植的事情时,就会出现未定义行为。在这种情况下,C89 标准声明它对实现"不施加任何要求"。未定义行为存在的原因有很多。它允许实现支持扩展、管理与基于硬件的安全机制的交互、执行激进优化,同时还允许忽略难以检测的错误。它明确赋予了编译器忽略整类难以检测的错误的权利。
#### 鼻腔恶魔
Uecker 说,问题在于标准允许编译器针对未定义行为做*任何事情*,甚至到了召唤[鼻腔恶魔](http://www.catb.org/jargon/html/N/nasal-demons.html)的程度。按照编译器编写者的说法,如果一个程序包含任何未定义行为,那么它就没有预期的语义。C++23 标准更进一步,明确声明标准对这些程序不施加任何要求。这导致开发者之间对语言可以预期什么行为产生了广泛的分歧。
例如,如果您将整个结构体清零(也许通过调用 [`memset()`](https://man7.org/linux/man-pages/man3/memset.3.html)),然后写入特定字段,那么如果读取该结构体中的填充字节会发生什么?它们可能包含与安全相关的数据吗?[2015 年的一项调查](https://www.cl.cam.ac.uk/~pes20/cerberus/notes50-survey-discussion.html)显示,在这种情况下应该发生什么并没有共识。或者考虑这段简单的代码:
```c
extern int x;
int f(int a, int b)
{
x = b ? 42 : 43;
return a/b;
}
```
如果 `b` 为零,那么 `return` 语句就是除以零,属于未定义行为。在这种情况下,编译器是否有权完全省略这个判断,直接执行 `x = 42`?毕竟,`b = 0` 的情况没有预期的语义,因此可以被忽略。确实有些编译器会这样做。在未定义行为的情况下,对 `x` 的赋值不是可观察行为。但现在考虑这种情况:
```c
extern void g(int x);
int f(int a, int b)
{
g(b ? 42 : 43);
return a/b;
}
```
这看起来可能是同样的情况,编译器有权省略判断,直接将 42 传给 `g()`,一些编译器也确实这样处理过——但那个编译器行为是一个 bug。想象一个 `g()` 的定义,当 `b` 为零时会调用 `exit()`。在这种情况下,除法永远不会发生,程序的行为也不是未定义的。所以省略判断并简单地将 42 传给 `g()` 是不正确的。
再看一个有趣的例子:
```c
volatile int x;
int foo(int a, int b, bool store_to_x)
{
if (! store_to_x)
return a/b;
x = b;
return a/b;
}
```
这里的问题是:编译器能否将最后的除法运算提升到对 `x` 的赋值之前?如果 `b = 0` 的情况没有语义关联,那么可观察行为就没有变化。这也是一些编译器曾经做过的事情,但 C23 标准增加了一项"禁止时间旅行"的规定来禁止这种行为。在 C++ 中,则必须通过插入对 `std::observable_checkpoint()` 的调用来显式防止这种提升。
时间旅行 bug 最终会消失,但还有很多其他情况,即使标准是明确的,编译器编写者也经常持不同意见。包括读取未初始化的变量(这*几乎*总是有定义的),以及指针的相等比较(总是有定义的,但 Clang 和 GCC 都曾错误编译过这种情况)。
#### 与未定义行为作斗争
为了尝试解决所有这些问题以及更多问题,C 委员会设立了三个专门关注内存对象模型、内存安全和未定义行为的研究小组。目前 C 标准中大约有 100 处未定义行为,但正在进行中的 C2y 草案已经删除了其中 45 处。情况确实在好转。
针对发现问题的工具越来越丰富:编译器警告、静态分析器、消毒器(sanitizer)、基于 LLM 的工具、形式化验证等等。在检测到潜在未定义行为时编译器发出警告的情况越来越多;近期的例子包括针对整数溢出和潜在的释放后使用(use-after-free)情况的更好警告。静态分析器可以作为独立工具使用,但也越来越多地被集成到编译器本身中;例如,GCC 现在可以对许多潜在的缓冲区溢出情况发出警告。消毒器通过插入运行时检查来工作;它们可以捕获大量未定义行为,并且在陷阱模式下也可用于加固。
内存安全从来不是 C 的强项,但 Uecker 想指出它可以被改进。这个问题可以分解为三个子问题:类型安全、空间内存安全和时间内存安全。
他说,C 有着强大的类型系统,剩余的问题是可以修复的。例如,无标签联合体(tagless union)可能造成类型混淆,但编译器可以通过一些额外的注解来强制类型安全。新的诊断机制可以从 `void` 中捕获不安全的类型转换。跨翻译单元的类型检查在 C 中传统上不是大问题,因为头文件被用来确保类型一致,但这种情况可以通过链接时检查器来改进。
空间内存安全——边界检查——是一个部分解决的问题;编译器现在可以在许多情况下执行数组边界检查。在某些情况下,需要一些代码改动才能完全受益于这种检查。例如,使用 `counted_by`(https://lwn.net/Articles/936728/)属性可以启用对柔性数组成员的检查。
时间内存安全——避免释放后使用之类的 bug——更难一些,Uecker 说,Rust 在这方面确实有优势。不过,更好的时间内存安全保障是可能的。像 [CHERI](https://en.wikipedia.org/wiki/Capability_Hardware_Enhanced_RISC_Instructions) 这样的架构也可以在这方面提供帮助。[Fil-C](https://lwn.net/Articles/1042938/) 可以发现很多时间安全性 bug。
所有这些工具和语言改动能让我们达到完全的内存安全吗?他说,彻底解决这个问题要么需要昂贵的运行时检查,要么需要形式化验证。在不久的将来,最完整的结果将来自受限语言与形式化验证工具的结合。
总的来说,他总结道,C 仍然是一门活跃的语言,仍在不断改进。C23 标准移除了许多有问题的特性,包括旧式(K&R)函数定义、对符号-幅值和反码机器的支持,以及三字母组(trigraph)。它增加了位精确整数类型、受检查的整数运算等等。C2y 将更进一步,增加 case 范围(case ranges)、具名 `for` 循环、用于确定数组长度的 `_Countof()` 宏,以及大量"恶魔移除"。它不会使 C 达到完全的内存安全,但这是一个最终的可能性,并且随着时间的推移会变得更加切实可行。最后,他鼓励有兴趣的人参与这些工作组。
本次演讲的[视频](https://www.youtube.com/watch?v=dhlYcmX_oBQ&list=PLEVVgAZOU9P8&index=14)和[幻灯片](https://uecker.codeberg.page/kernel-recipes.pdf)均可获取。
\[感谢 Linux Foundation——LWN 的差旅赞助商——支持我参加此次活动的差旅。\]
本文的索引条目:会议(https://lwn.net/Archives/ConferenceIndex/)Kernel Recipes/2026(https://lwn.net/Archives/ConferenceIndex/#Kernel_Recipes-2026)
相似文章
C语言中的一切皆为未定义行为
一位经验丰富的C++开发者认为,所有非平凡的C和C++代码都包含未定义行为,使得内存安全无法实现,并质疑这些语言在现代软件开发中的持续使用。
C++26:减少未定义行为
C++26 引入了减少未定义行为的更改,特别是使删除指向不完整类型的指针成为格式错误,从而提高了程序安全性。
Dependable C
一个全面的资源,提供编写可靠、安全的C代码的指南和建议,涵盖未定义行为、内存模型以及特定版本的注意事项。
语义具体化:如何生成任意控制流且无未定义行为的代码?
Reify 是一款基于语义具体化技术的开源随机 C 程序生成器,能够生成不含未定义行为的代码,专用于编译器测试。它已在 GCC 和 LLVM 中发现了 59 个 bug,并在 OpenJ9 和 Linux 的 eBPF 运行时中发现了额外的缺陷。
关于C数组类型语义的讨论
本文解释了C数组类型的令人困惑的行为,包括它们退化为指针、sizeof和函数参数等例外情况,并将其与函数类型进行比较,提出了一种数组和指针严格分离的心理模型。