个人博客声明

这是个人博客,页面上表达的观点仅代表个人,不代表雇主观点,且内容非AI生成,所有想法均为个人作为人类的奇思妙想。

C语言里全是未定义行为

2026年5月19日,从编程、安全分类角度来看,若红衣主教黎塞留是程序员,或许会说:“给我世界上最专业的C程序员手写的六行代码,我就能从中找出足以触发未定义行为的问题。”作为近30年来几乎每天都编写C和C++代码,收听C++相关播客、观看C++会议演讲、喜欢阅读和编写C++代码的人认为,没有人能写出完全正确的C或C++代码。C++虽曾带来很大帮助,但如今是2026年,与1985年(C++诞生)或1972年(C语言诞生)时的环境大不相同。

有人约十年前读过一篇知名人士文章,提到使用C++很可能违反《萨班斯 - 奥克斯利法案》(Sarbanes–Oxley Act,简称SOX),虽不认同文章其他观点,但在这一点上没有异议。随着时间推移,越发觉得未定义行为(Undefined Behavior,简称UB)比想象的多。大家都知道双重释放内存、释放后使用、访问对象越界以及访问未初始化的内存属于未定义行为,然而行业仍反复犯这些错误,且未定义行为还有更微妙、更不合逻辑的情况。

这与优化无关

有些人认为编译时不开启优化选项,未定义行为就不会造成影响,觉得编译器会故意利用代码漏洞,不开启优化就不会如此。但这种想法错误,未定义行为意味着编译器假定代码有效,人类阅读代码的意图在编译器各阶段或模块间可能无法表达,编译器甚至不必处理某些特殊情况,因为这些情况“不可能发生”。编译器和底层硬件处理未定义行为代码就像玩传话游戏,最终结果虽可能符合预期,但无法保证。

未定义行为无处不在

以下并非列举所有未定义行为,旨在说明其无处不在。观点认为所有非平凡的C/C++代码都存在未定义行为。

访问未正确对齐的对象

如代码“int foo(const int* p) { return *p; }”,若调用时传入指针未正确对齐,属于未定义行为,参考C23标准的6.3.2.3节。在Linux Alpha系统中,某些情况会陷入内核,内核软件模拟操作,其他情况程序会因SIGBUS信号崩溃;在SPARC架构上会引发SIGBUS信号;在x86/amd64架构上可能没问题,甚至是原子读取操作。未来架构情况未知,编译器无义务生成处理未对齐指针的汇编指令。

代码“void set_it(std::atomic* p) { p->store(123); } int get_it(std::atomic* p) { return p->load(); }”,对象未正确对齐时,操作是未定义行为,易引发原子性问题。当认为是原子读取的对象跨页时也是未定义行为。

实际上,问题在这之前就出现了

示例代码“bool parse_packet(const uint8_t* bytes) { const int* magic_intp = (const int*)bytes; // 未定义行为! int magic_raw = foo(magic_intp); // 在SPARC架构上可能会崩溃。 int magic = ntohl(magic_raw); // 至少这一步没问题。 [...] }”,问题出在类型转换上,编译器可为int*低位赋予特定含义。

对`char`类型输入使用`isxdigit()`函数

代码“bool bar(char ch) { return isxdigit(ch); }”,`isxdigit()`接受int类型,若`bar()`函数传入值不在0 - 127范围内,且架构中`char`是有符号类型,转换后整数值为负数。`isxdigit()`的有效实现可能读取未知内存,触发意想不到的事情,在嵌入式系统中更可能发生。

从`float`类型转换为`int`类型

代码“int milliseconds(float seconds) { int tmp = (int)(seconds * 1000.0); /* 错误 */ return tmp + 1; /* 单独来看也是错误的(有符号溢出属于未定义行为) */ }”,根据C标准,将实浮点类型有限值转换为整数类型,若整数部分无法用该整数类型表示,行为未定义;浮点数是非有限值也属于未定义行为。

虽有改进代码“int milliseconds(float seconds) { const float ftmp = seconds * 1000.0f; if (!isfinite(ftmp)) { // 或者进行其他错误处理。 return 0; } if ((float)(INT_MIN + 1000) > ftmp) { // 或者进行其他错误处理。 return 0; } if ((float)(INT_MAX - 1000) < ftmp) { // 或者进行其他错误处理。 return 0; } // 现在可以安全转换了。 const int tmp = (int)ftmp; if (INT_MAX == tmp) { // 或者进行其他错误处理。 return 0; } // 现在可以安全地进行加法运算了。 return tmp + 1; }”,但很多代码只是简单转换。

地址为零的对象

符合C标准情况下,几乎无法将对象放在地址为零的位置,在操作系统内核和嵌入式编程中可能出现。根据C标准,整数常量零和`nullptr`是“空指针常量”,标准未规定`NULL`实际指向机器地址为零,解引用空指针属于未定义行为,不能假设`memset(&ptr, 0, sizeof(ptr));`会创建`NULL`指针,历史上有机器使用非零的`NULL`指针。

代码“void (*func_ptr)() = NULL; func_ptr();”属于未定义行为,C标准说“这里没有函数”,编译器可能无法理解意图,“全零”在不同架构含义不同。

可变参数和类型(例如`printf`中使用`%ld`而不是`%lld`)

代码“execl("/bin/sh", "sh", "-c", "date", NULL); /* 错误 */ execl("/bin/sh", "sh", "-c", "date", 0); /* 错误 */”属于未定义行为,正确写法是“execl("/bin/sh", "sh", "-c", "date", (char*)NULL);”。代码“uint64_t blah = 123; printf("%ld\n", blah); /* 错误 */”也属于未定义行为,正确写法是“uint64_t blah = 123; printf("%"PRIu64"\n", blah);”。打印`uid_t`类型值可转换为`uintmax_t`类型,用`PRIuMAX`打印,但`uid_t`是否为无符号类型不确定。

除零操作属于未定义行为

除零操作属于未定义行为,分母通常来自不可信输入。C23标准中“undefined”出现283次,还不包括省略未明确的未定义情况。

额外的非未定义行为示例

没人能在快速浏览代码时正确应用整数提升规则。示例代码“unsigned char a = 0xff; unsigned char b = 1; unsigned char zero = 0; bool overflowed = (a + b) == zero; // overflowed 的值为 0,而不是 1。”和“unsigned char a = 0x80; uint64_t b = a << 24; // 额外的未定义行为(?) // b 的值现在是 18446744071562067968(ffffffff80000000),而不是 2147483648(0x80000000)。 // 即使所有变量都是无符号类型。”体现了这一点。

大语言模型(LLM)在这方面比我们更厉害

大语言模型分析C代码,几乎每次都能准确找出未定义行为。让其分析OpenBSD代码,找出很多未定义行为。提交了修复越界写入问题和非未定义行为逻辑错误的补丁,但未提交其他未定义行为补丁,原因包括OpenBSD项目对bug报告接受度不高,且清除未定义行为是大工程。

我们现在该怎么办?

不能抛弃现有C/C++代码库,但让其存在缺陷也不行。需要一种大规模修复未定义行为的方法,既不依赖质量不佳的AI输出,也不让人类审查者不堪重负。在2026年,没有大语言模型监督,编写C/C++代码可能违反SOX法案且不负责任。对于自己的项目,会让大语言模型找出未定义行为,解释原因并修复,然后仔细检查输出。但确认发现需要专业人员,而专业人员通常很忙,这工作琐碎且微妙,不能交给初级程序员。

相关文章

相关文章有“C语言中无法解析整数”“整数处理存在问题”“Linux内核中的未定义行为”“整数提升”。

Blargh

这是记录随机技术内容的博客,可通过“thomas@habets.se”联系,也可在github和twitter上找到相关账号。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐