C 和 C++ 中未定义行为的普遍性

几十年来,C 和 C++ 一直是系统编程的基石,因其高性能和接近硬件的特性而备受推崇。然而,随着我们步入 21 世纪,一个令人清醒的现实浮现了:编写真正“正确”的 C 或 C++ 代码几乎是不可能的。语言规范中充满了未定义行为 (UB),以至于即使是拥有数十年经验的专家级程序员,也可能编写出违反标准的代码。

这不仅仅是一个学术上的钻牛角尖问题。在现代编译环境中,UB 是一个无声的杀手,可能导致灾难性的故障、安全漏洞以及几乎无法调试的逻辑错误。

超越显而易见的问题:UB 究竟意味着什么

大多数开发者都熟悉那些“重大”的 UB 陷阱:双重释放 (double-free)、释放后使用 (use-after-free) 以及数组越界访问。由于 C/C++ 不是内存安全语言,这些被视为预期的风险。然而,一个常见的误解仍然存在,即认为只有在开启优化时 UB 才会产生影响。有些人认为,如果没有高等级的优化,编译器不会“利用” UB 来表现出敌意。

这是一个根本性的误解。UB 并不意味着编译器在积极地试图破坏你的代码;它意味着编译器被允许假设你的代码是有效的。当程序员引入 UB 时,他们本质上是在告诉编译器:“这种情况永远不会发生。”因此,编译器可能会省略必要的检查,或者生成忽略程序员实际意图的代码,因为该意图从未在语言规则内被合法地表达出来。

正如一位社区成员所指出的:

编译器期望 UB 代码不会发生,所以如果你执意编写 UB 代码,编译器(尤其是优化器)被允许将其翻译成对其“快乐路径”而言最方便的任何形式。

不可见的雷区:常见 UB 的示例

UB 的普遍性远超内存损坏。它隐藏在最平凡的操作中。

对齐与指针转换

一个简单的解引用整数指针的函数,如果指针没有正确对齐(例如,不是 sizeof(int) 的倍数),可能会触发 UB。虽然 x86 架构以对非对齐读取的宽容著称,但其他架构(如 SPARC 或 Alpha)可能会触发 SIGBUS 或需要内核模拟,从而损害性能或导致程序崩溃。

至关重要的是,UB 往往发生在解引用之前。将字节缓冲区(例如网络数据包)直接转换为整数指针通常被视为 UB,因为编译器可能会为指针的低位分配特定的含义,用于安全标记或垃圾回收。

标准库陷阱:isxdigit()

即使是标准库函数也可能成为陷阱。isxdigit() 函数期望接收一个可以表示为 unsigned charEOF 值的 int。如果程序员在 char 为有符号类型的系统上传递一个 char,那么任何超出 0-127 范围的值都会变成负整数。如果 isxdigit() 的内部实现使用该输入作为数组索引而没有检查负值,则可能导致越界内存读取——在嵌入式系统中,这可能会触发 I/O 映射内存。

浮点数转换

float 转换为 int 是 UB 的常见来源。根据 C23 标准,如果浮点数值的整数部分无法由目标整数类型表示,则其行为是未定义的。这包括非有限值(NaN 或 Infinity)。

为了安全地将 float 转换为整数,开发者必须实现广泛的有限性检查和范围边界检查——对于一个看似只需一次类型转换的任务来说,这这需要编写大量的样板代码。

空指针悖论

虽然大多数开发者假设 NULL 就是地址零,但 C 标准仅保证 NULL 与零相等。解引用空指针是 UB 的典型例子。此外,使用 memset 将结构体清零并不在技术上保证指针成员会被设置为该平台的 NULL 值,尽管这在大多数现代系统上都能正常工作。

前行之路:人类专家 vs. AI

鉴于 C23 标准中包含数百个“undefined”一词的使用,手动审计大型代码库以查找 UB 的任务是极其繁重的。这就是大语言模型 (LLMs) 展示出惊人效用的地方。LLMs 通常能够发现细微的 UB——例如错误的 printf 格式说明符或对齐问题——这些是人类审查员可能会忽略的。

然而,这引入了一种新的紧张关系。虽然 AI 可以发现这些错误,但确认修复方案仍然需要人类专家。随着行业的转向,存在一种风险,即我们依赖 AI 来“修复” UB,而不完全理解其底层的架构含义,从而可能引入新的、更隐蔽的虫子。

结论

在现代时代,如果没有严格的工具链和监督,编写 C 或 C++ 代码是越来越不负责任的行为。人类阅读代码的方式与现代编译器如何解释代码的方式之间的差距已经变得太大了。无论是通过采用内存安全语言,还是通过集成 LLM 驱动的审计,行业必须找到一种方法来解决 C 抽象机器中固有的系统性不稳定性。

Sources