Hacker News 中文摘要

RSS订阅

减少 C 语言中的未定义行为 -- Reducing undefined behavior in the C language

马丁·于克尔(Martin Uecker)是生物医学工程教授,看似不像内核食谱(Kernel Recipes)的典型演讲者,却是长期的 Linux 用户,并参与开发用于控制磁共振成像(MRI)扫描仪的自由软件。他在会上谈 C 语言、C 中未定义行为,以及 C 最终能否成为内存安全语言。为什么到了 2026 年还要在意 C?他表示 C 依然优秀:可移植、长期稳定、编译快,生成的二进制代码也快。

正文

Martin Uecker 是生物医学工程教授,也是长期 Linux 用户,平时开发用于控制磁共振成像(MRI)扫描仪的自由软件。他在 2026 年的 Kernel Recipes 会议上谈了 C 语言、C 中未定义行为这一具体问题,以及 C 最终能否成为一种内存安全语言。

为什么 2026 年还要关心 C?他说,C 仍然是一门很好的语言:可移植、长期稳定、编译快,生成的二进制代码也快。它“所见即所得”(What you see is what you get),读 C 代码大致就能想象计算机实际会做什么。围绕 C 有大量工具,而且必要时 C 不会挡路。

C 历史悠久,这影响了今天的语言形态。C89 标准必须面对各种硬件:有符号数值(signed-magnitude)或反码(one's-complement)整数表示、分段内存、奇特的指针表示,以及出人意料的类型大小。例如某些 Honeywell 机器的字节是 9 位。这让制定一套能写出可移植代码的标准变得非常复杂。

Martin Uecker

当时采取的办法是用一台抽象机(abstract machine)来定义语言语义。所有操作都像是在这台抽象机上执行,而抽象机可能与真实硬件并不一致。程序的可观测行为(observable behavior)必须与抽象机一致。这里的“可观测”很关键:对 volatile 变量的访问被定义为可观测,必须完全按照抽象机发生;其他一切只要求最终结果相同。

标准为实现者留有很大自由;只有可观测行为必须保留。许多行为要么未定义,要么由实现定义。它们不属于可观测行为,因此不限制编译器实现者。当然,在 C 标准没有约束的地方,其他规范仍可约束编译器开发者,例如 ABI 要求、POSIX 等标准,或向后兼容的需要。

当程序做了不可移植或标准根本没有定义的事情时,就产生未定义行为。C89 标准称,这种情况下标准对实现“不施加任何要求”(imposes no requirements)。未定义行为存在有多种原因:它让实现能支持扩展、管理与硬件安全机制的交互,并进行激进优化,同时允许忽略难以检测的错误。它明确赋予编译器忽略整类难以检测错误的权利。

鼻烟恶魔(nasal demons)

问题在于,Uecker 说,标准允许编译器在遇到未定义行为时“为所欲为”,甚至到了召唤鼻烟恶魔(nasal demons)的程度。按照编译器开发者的说法,如果程序含有任何未定义行为,它就没有预期语义。C++23 标准更进一步,明确规定标准对此类程序不施加任何要求。这导致开发者之间对语言应有什么预期产生广泛分歧。

例如,如果先用 memset() 把整个结构清零,再写入某些字段,那么读取该结构里的填充字节(padding bytes)会发生什么?这些字节里会不会含安全相关数据?2015 年的一项调查显示,人们对这种情况应如何处理并没有共识。再看这段简单代码:extern int x; int f(int a, int b) { x = b ? 42 : 43; return a/b; }。如果 b 为 0,return 语句就是除以零,属于未定义行为。既然 b = 0 的情况没有预期语义、可以被忽略,编译器是否有权完全去掉那个判断、直接执行 x = 42?确实有编译器会这么做,因为对 x 的写入并不是可观测行为。

但再看这个版本:extern void g(int x); int f(int a, int b) { g(b ? 42 : 43); return a/b; }。表面看情况相同,编译器似乎也有权去掉判断、直接把 42 传给 g(),而且有些编译器确实这么处理过——但那种编译器行为其实是个 bug。假设 g() 的定义在 b 为 0 时调用 exit(),那么除法永远不会发生,程序行为也就不是未定义的。因此省掉判断、直接把 42 传给 g() 是不正确的。

还有一个有意思的例子: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 标准新增了“禁止时间旅行”(no time travel)条款来禁止它。在 C++ 里,则必须显式插入 std::observable_checkpoint() 才能阻止这种提升。

时间旅行类 bug 最终应该会消失,但在其他许多情形里,即使标准写得很清楚,编译器开发者仍常常意见不一。例如读取未初始化变量(这几乎总是有定义的),以及指针相等比较——指针比较总是有定义的,但 Clang 和 GCC 都会错误编译。

对抗未定义行为

为了解决这些问题以及更多问题,C 标准委员会设了三个研究小组,分别关注内存对象模型、内存安全和未定义行为。目前 C 标准里大约有 100 处未定义行为,而制定中的 C2y 草案已经删掉了其中 45 处。情况确实在变好。

用于发现问题的工具也越来越丰富:编译器警告、静态分析器、sanitizer、基于大语言模型的工具、形式化验证等等。编译器在检测到可能的未定义行为时发出警告的场景正在增加,近期的例子包括针对整数溢出和潜在 use-after-free 的更好警告。静态分析器既有独立工具,也越来越多地被内建进编译器本身;例如 GCC 现在可以对一些潜在的缓冲区溢出情况发出警告。Sanitizer 通过插入运行时检查来工作,能捕捉大量未定义行为,在 trapping 模式下还能用于加固。

内存安全从来不是 C 的强项,但 Uecker 想说明它可以改进。这个问题可拆成三个子问题:类型安全、空间内存安全、时间内存安全。

他说,C 有很强的类型系统,剩下的问题可以修。例如无标签联合(tagless unions)会造成类型混淆,但编译器可以借助一些额外标注来强制类型。新的诊断可以捕捉从 void * 出发的不安全强制转换。传统上,由于头文件用来保证类型一致,C 在跨翻译单元的类型检查方面问题不大,但可以通过链接时检查器进一步改善。

空间内存安全,即边界检查,是个已部分解决的问题;编译器现在在许多情况下都能做数组边界检查。有些代码需要稍作修改才能充分受益于这种检查。例如使用 counted_by 属性,就能对柔性数组成员启用检查。

时间内存安全,即避免 use-after-free 之类的 bug,更难一些,Uecker 说,Rust 在这方面肯定有优势。不过,更好的时间内存安全 enforcement 仍然是可能的。CHERI 这类架构也能提供帮助。Fil-C 能发现很多时间安全 bug。

这些工具和语言改动能把我们带到完全的内存安全吗?他说,彻底解决这个问题要么需要昂贵的运行时检查,要么需要形式化验证。在不久的将来,最完整的成果将来自受限语言加上形式化验证工具的组合。

他最后表示,C 仍是一门活的语言,而且仍在改进。C23 标准删掉了一些有问题的特性,包括旧式(K&R)函数定义、对符号数值和反码机器的支持,以及三字符组(trigraphs)。它增加了位精确整数类型、受检查的整数运算等。C2y 会走得更远,加入 case ranges、具名 for 循环、用于确定数组长度的 _Countof() 宏,以及大量“清除恶魔”(demon removal)的工作。它不会让 C 达到完全内存安全,但那是未来可能实现的目标,而且会随着时间推移变得更可行。他鼓励有兴趣的人参与工作组。

本场演讲的视频和幻灯片已经公开。

评论

多数评论对C的未定义行为(UB)现状不满,认为它荒谬、沉默危险或应被标准进一步定义;少数人则强调C是系统编程的“高级汇编”,不能为安全付出运行时或全程序分析的代价。副线分歧在于:该用Rust式语言层检查、Fil-C/CHERI的ABI层方案,还是形式化编译器来接管复杂度。

认为UB荒谬、应被减少或定义清楚,且不能沉默

  • 直言C/C++把未定义行为写进规范本身难以接受,至今没读到任何让它显得合理的解释(高赞)。
  • 具体诉求包括把有符号整数溢出定义为确定行为;减少UB是标准委员会至少该做的事(较多人)。
  • 未初始化但已分配内存的读取应被明确规定为“可运行但字节值任意”,并强制要求uint8t、uint32t等固定宽度整型;独立编译器开发者难以及时跟进新标准也是障碍(少数人)。
  • 有玩笑提议让UB“名副其实”地未定义——用随机数生成器和编译器插件系统不断引入新的Bug(B)来搭配未定义(U),代表社区对UB不可预测的吐槽(少数人)。 > “C/C++把未定义行为写进语言本身,我一直觉得这疯狂至极,至今没读到任何让它显得合理的解释。”(@p1necone)

认为UB最危险的是沉默,编译器应报错或立即终止

  • UB本身未必可怕,可怕的是它静默发生、零诊断;编译器应把因UB导致的激进优化(如把分支折叠、删掉函数尾声后继续执行相邻函数)变成响亮警告,宏场景可用pragma临时关闭诊断(较多人)。
  • 更理想的是提供类似sanitizer的全局开关:任何运行时UB都以确定方式立即崩溃,哪怕牺牲性能,也比事后调试或被RCE便宜(较多人)。 > “C++编译器曾直接删掉我写的无限循环的函数尾声,程序于是继续执行下面恰好链接的函数,全程零诊断。”(@vbezhenar)

认为C是系统编程的高级汇编,不能为安全牺牲性能

  • C运行在操作系统和每秒调用数千次的热路径上,本质是“高级汇编”;为对抗UB加入任何运行时检查都会拖慢执行,程序员必须知道自己做什么,否则不该写C(较多人)。
  • 静态分析能抓到很多UB,但保证零UB需要全程序分析,编译时间会爆炸,还会产生淹没开发者的误报(较多人)。
  • 最快算法常依赖位运算技巧,这些技巧在x86上能正常工作,但按标准正是UB;这被视为C贴近硬件的代价(少数人)。 > “C是操作系统和每秒被调用数千次的代码的高级汇编,不能承受哪怕一丝运行时检查;开发者必须知道自己是在做什么,否则就该离开厨房。”(@hn_submit)

认为底层约束不可避免,应把复杂度交给哪一层才是真问题

  • 真正约束来自POSIX和ABI,程序员要理解的契约分布在语言、操作系统和ABI多层;若像Rust那样把检查塞进语言层/编译器(如借用检查器),C“自由”的底层定位就会消失,人们会厌倦这门本应自由的语言(少数人)。
  • 单层必然限制自由,需要把程序员管理的复杂度转移给机械管理,但语言层还是操作系统层(甚至未来某种“复杂度定理”决定的层)最优,仍无答案(少数人)。 > “如果把编译器像Rust的借用检查器那样膨胀,程序员可用的自由就会消失,人们会厌倦一门本应自由的语言。”(@jdw64)

认为ABI层方案或形式化编译器比Rust更彻底

  • Fil-C不只是“发现”时空安全bug,而是给整个C语言套上紧致语义、堵死内存安全利用;CHERI也有类似效果,且二者在ABI层工作,保护覆盖全部代码,不像Rust只保护用安全子集重写的部分(高赞)。
  • Rust的编译期检查或许更快,但若关心语义定义性和可利用性,这并不重要;安全关键行业实际用CompCert(虽非自由软件)形式化验证大型C代码,也应被提及(少数人)。 > “Fil-C和CHERI都比Rust更全面,因为它们在ABI层解决问题,保护不会只作用于用安全子集重写的那部分代码。”(@pizlonator)

有价值的补充

  • Honeywell某些机器有9位字节,OS 2200有36位字长且至今仍是受支持平台,还是首个SMP UNIX实现;@chasil 用这类硬件多样性解释C为何把许多行为留作未定义。
  • @strenholme 实测:在GCC、clang、TCC的所有优化等级下,依赖malloc(2)后未初始化字节的密码例程都照常运行;Cygwin下该字节随机但稳定,Ubuntu 26下恒为0。可见编译器并不总会利用UB把代码整个删掉,但结果不可移植。
  • 现代获取熵早已不必依赖未初始化内存,clock_gettime()、/dev/urandom、CryptGenRandom()等已较通用;但作者仍不盲目信任/dev/urandom,因为嵌入式设备或树莓派上也有过问题。