C语言中的一切皆为未定义行为

Hacker News Top 新闻

摘要

一位经验丰富的C++开发者认为,所有非平凡的C和C++代码都包含未定义行为,使得内存安全无法实现,并质疑这些语言在现代软件开发中的持续使用。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/20 08:28

# C语言中一切都是未定义行为 来源:https://blog.habets.se/2026/05/Everything-in-C-is-undefined-behavior.html 如果红衣主教黎塞留(https://en.wikiquote.org/wiki/Cardinal_Richelieu)是一位程序员,他大概会说:“给我六行由世界上最老练的C程序员写下的代码,我就能从中找出足够的未定义行为。” 没有人能写出正确的C或C++代码。这话出自一个近30年来几乎每天写C和C++的人之口。我听C++播客,看C++会议演讲,也享受阅读和编写C++。C++曾很好地服务于我们,但现在是2026年,1985年(C++)或1972年(C)的环境已不复存在。 我绝对不是第一个说这话的人。我记得大约十年前,一位知名人士曾发文称,有充分理由认为使用C++可能违反SOX(https://en.wikipedia.org/wiki/Sarbanes–Oxley_Act)。虽然我不同意他其余部分的长篇大论(以及他对“its”和“it’s”的混淆),但我从未质疑过这一点。随着时间的推移,我越发觉得这是真的。 未定义行为(UB)比你想象的要多得多。每个人都知道双重释放、释放后使用、越界访问对象(如数组)、访问未初始化的内存是UB。毕竟,C/C++不是内存安全的语言。然而,我们行业似乎无法停止反复犯这些错误。但这还不是全部。还有更细微的、更不合逻辑的。 ## 这与优化无关 有些人似乎认为,只要不开启优化编译,未定义行为就无法伤害他们。他们认为编译器是故意敌对,说“啊哈!UB!我可以在此为所欲为!”,而不开优化就不会这样。这是错误的。 UB并不意味着编译器可以利用你的粗心。UB意味着编译器可以假设你的代码是有效的。这意味着,你的代码在人类眼中显而易见的意图,在编译器阶段或模块之间甚至没有表达方式。UB意味着编译器甚至不必在其代码生成中实现某些特殊情况,因为它们“不可能发生”。编译器,以及底层硬件,都像是在和你UB的意图玩传话游戏。最终结果可能符合你的预期,但无论现在还是未来,都没有保证。 ## UB无处不在 以下并非试图列举世界上所有UB。只是要证明UB无处不在,如果没有人能正确做到,那又怎么能怪罪程序员呢?我的观点是:**所有**非平凡的C/C++代码都存在UB。 ### 访问未正确对齐的对象 举例如下: ``` int foo(const int* p) { return *p; } ``` 如果该函数被调用时传入的指针未正确对齐(可能意味着地址是`sizeof(int)`的倍数,但谁知道呢),这就是UB。C23 6.3.2.3。在Linux Alpha上,*某些*情况下,这只会陷入内核,由内核软件模拟你想要的。其他情况下,它(可能)会导致程序因SIGBUS而崩溃。在SPARC上会导致SIGBUS。当然,在x86/amd64(以下统称x86)上,这很可能没问题。甚至可能是一次原子读取。x86以其对缓存一致性细节的极度宽容而闻名。 所以这里有三种情况: 1. 内核提供帮助(Alpha上的*某些*加载) 2. 崩溃(其他Alpha加载和SPARC) 3. 没问题(x86) ARM、RISC-V和其他架构呢?未来的架构呢?未来的架构甚至可能拥有特殊的`int指针寄存器`,不会填充最低位,因为这样的指针不可能存在。即使它现在能用,也许有一天编译器改为使用另一条加载指令,而内核不再修复它。因为*编译器没有义务生成能在未对齐指针上工作的汇编指令*。因为这是UB。 或者这个: ``` void set_it(std::atomic<int>* p) { p->store(123); } int get_it(std::atomic<int>* p) { return p->load(); } ``` 当对象未正确对齐时,这个操作是原子的吗?这是错误的问题。Mu(https://en.wikipedia.org/wiki/Mu_(negative)),不要问这个问题。这是UB。(但确实,在实践中这很容易导致原子性问题) 如果你想更确信,可以思考一下:如果你认为你正在原子读取的对象跨页面(https://en.wikipedia.org/wiki/Memory_paging),会发生什么?但不要想太多,否则你可能会得出结论“没问题”。不是的。这是UB。 ### 实际上,在此之前就已经是UB了 别怪上面的`foo()`函数。解引用指针不是问题。仅仅是*创建*指针就足以成为问题。例如: ``` bool parse_packet(const uint8_t* bytes) { const int* magic_intp = (const int*)bytes; // UB! int magic_raw = foo(magic_intp); // 在SPARC上可能崩溃 int magic = ntohl(magic_raw); // 这个至少没问题 [...] } ``` 这个强制转换才是问题,而不是`foo()`。编译器完全可以将特定含义(如垃圾回收或安全标记位)赋予`int*`的低位。 ### `isxdigit()` 对 `char` 输入 ``` bool bar(char ch) { return isxdigit(ch); } ``` `isxdigit()`是一个简单函数,接收一个字符,如果它是十六进制数字(0-9或a-f)则返回`1`。它也可以接受值`EOF`。嗯,好的。`EOF`是什么值?根据C23(https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3088.pdf)7.4p1,我们知道它是一个`int`,并且可以推断它不能用`unsigned char`表示。因此`isxdigit()`接受一个`int`,而不是`char`。`char`的所有值都适合`int`,所以我们应该没问题。从`char`到`int`的转换是合适的,根据6.3.1.3,没问题,对吧?不对。因为如果`bar()`被调用时传入了0-127之外的值,**且**在你的架构上`char`是`signed`(实现定义的,C23 6.2.5第20段),那么整数值就会变成负数。而下面是一个有效的`isxdigit()`实现,会导致读取未知内存。甚至可能是I/O映射内存,触发比随机值或崩溃更严重的事情。可能导致电机启动。在桌面操作系统上的应用程序中不太可能,但在嵌入式系统中是可能的。不过,用户空间网络驱动(为了性能)也存在,所以即使是用户空间也无法保护你。 ``` int isxdigit(int c) { if (c == EOF) { return false; } return some_array[c]; } ``` ### 从`float`转换为`int` ``` int milliseconds(float seconds) { int tmp = (int)(seconds * 1000.0); /* 错误 */ return tmp + 1; /* 另外,也是错误(有符号溢出是UB) */ } ``` “当有限实浮点类型的值转换为整数类型时……如果整数部分的值无法用整数类型表示,则行为未定义。”——6.3.1.4 而且,通过遗漏,如果浮点数是非有限值,也是UB。 那么如何将浮点数与`INT_MAX`比较呢?把浮点数转换为`int`?不,那正是你想避免的UB。所以把`INT_MAX`转换为浮点数?你怎么知道它能被精确表示?也许将`INT_MAX`转换为`float`会舍入到一个无法用`int`表示的值,你的比较就会失去代表性?也许下面这样能行?你会错过一些非常高的值,但也许可以接受? ``` 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标准的方法可以将对象放在地址零。这在操作系统内核和嵌入式编码中可能出现。根据6.3.2.3,整数常量零(可转换为指针)和`nullptr`是“空指针常量”(我简称为`NULL`)。C语言并未指定实际指针`NULL`指向机器地址零(https://c-faq.com/null/confusion4.html),因为C标准只谈论C抽象机,而非硬件。C保证的是,如果你*比较*`NULL`与零,它们会相等。但很可能这是因为零被转换为本地平台的`NULL`,而它恰好是`0xffff`。它还明确说明解引用空指针,无论其值如何,都是未定义行为。这是3.4.3下UB的*典型*例子。 这也意味着你不能假设`memset(&ptr, 0, sizeof(ptr));`会创建一个`NULL`指针!你不能用这种方式初始化你的结构体,并假设成员指针是`NULL`!而这*确实*适用于大多数程序员。是的,一些历史机器确实使用非零的空指针(https://c-faq.com/null/machexamp.html)。 但假设你有一台现代机器,其中`NULL`是指向地址零的指针,而且你确实在那里有一个对象。同样,C 6.3.2.3说`NULL`与“任何对象或函数”比较都不相等。所以这是UB: ``` void (*func_ptr)() = NULL; func_ptr(); ``` C说“那里没有函数”。编译器内部甚至没有方式来表达你的意图。你可能会争辩说“但肯定它只会发出一个调用指令,目标地址是全零的位模式?没有其他合理的做法。”但什么是“全零”呢?在16位x86上,是`0000:0000`吗?是`CS:0000`吗? ### 可变参数和类型(例如printf使用`%ld`而不是`%lld`) 这是UB: ``` execl("/bin/sh", "sh", "-c", "date", NULL); /* 错误 */ execl("/bin/sh", "sh", "-c", "date", 0); /* 错误 */ ``` 这不是: ``` execl("/bin/sh", "sh", "-c", "date", (char*)NULL); ``` 因为参数需要是指针,而`NULL`宏可能被误解为整数零。 类似地,这也是UB: ``` uint64_t blah = 123; printf("%ld\n", blah); /* 错误 */ ``` 需要写成: ``` uint64_t blah = 123; printf("%"PRIu64"\n", blah); ``` 那么如何打印`uid_t`呢?你可以将它们强制转换为`uintmax_t`并使用`PRIuMAX`打印。但`uid_t`甚至不一定是无符号的?嗯,最坏情况是打印出无意义的值而非`-1`,我想。 ## 除以零是UB 当然,你可能已经知道。但你是否考虑过它的安全影响?分母来自不可信输入的情况并不少见。而且还有更多。C23标准中包含了283次“undefined”一词。这还不包括那些因遗漏而未定义的情况。 ## 额外福利:非UB的情况 没有人能在代码扫读速度下应用整数提升规则。**没有人**。这篇文章已经够长了,但先看一个例子: ``` 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; // 额外的UB(?) // b 现在是 18446744071562067968 (ffffffff80000000),而不是 2147483648 (0x80000000) // 即使所有变量都是无符号的。 ``` ## LLM比我们更擅长这个 将LLM指向任何C代码,要求它找出UB,它就能做到。而且现在几乎总是正确的。我因为LLM正确找出了我代码中的UB而感到有点难过,于是我想把它指向成熟且严谨的OpenBSD。我只是用了第一个想到的工具`find`,它就吐出了一堆。我给该项目发送了一个补丁,修复了越界写入(https://marc.info/?t=177910871100010&r=1&w=2)(以及一个非UB的逻辑错误(https://marc.info/?l=openbsd-tech&m=177910856447778&w=2))。我没有给他们发送其他UB的补丁,部分原因是OpenBSD项目过去对错误报告不太接受,加上我的感觉是“这在实际中可能没问题”,而且如果OpenBSD想从代码库中清除UB,那将是一个重大项目,应该以更好的方式进行,而不是我作为LLM和它们之间的中间人,时不时地传递一个补丁。 ## 那么我们该怎么做? 我们不能抛弃现有的C/C++代码库。但让它们保持天生有缺陷也不是办法。我们需要某种方式大规模修复UB,而不至于搞出AI垃圾,也不至于让人类审查者不堪重负。这也不是什么新观点或伟大的启示。但是,是的,在2026年编写C/C++而没有LLM监督你避免UB,可能应被视为违反SOX,并且纯粹是不负责任的行为。如果OpenBSD的人30多年都找不到这些问题,我们其他人还有什么机会? 对于大型代码库可能难以扩展,但对于我自己的项目,我已经让LLM找出UB,必要时解释并修复它。然后盯着输出,直到我能确认问题和修复。这样做的问题在于,为了确认发现,你需要一个专家级的人类。但专家通常忙于其他事情。这是清洁工的工作,但过于微妙,不能交给传统上负责清洁工作的初级程序员。 - 无法在C中解析整数(https://blog.habets.se/2022/10/No-way-to-parse-integers-in-C.html) - 整数处理有缺陷(https://blog.habets.se/2022/11/Integer-handling-is-broken.html) - Linux内核中的UB(https://pooladkhay.com/posts/first-kernel-patch/) - 整数提升(https://www.123microcontroller.com/en/cpp-integer-promotion/) disqus开始显示广告了。:-( 显示(可能不完整的)评论,以静态只读视图显示。点击按钮才能发表评论。

相似文章

关于C数组类型语义的讨论

Lobsters Hottest

本文解释了C数组类型的令人困惑的行为,包括它们退化为指针、sizeof和函数参数等例外情况,并将其与函数类型进行比较,提出了一种数组和指针严格分离的心理模型。

内存安全是生死攸关的问题

Lobsters Hottest

作者认为,内存不安全的开源软件极易受到即将到来的人工智能漏洞查找代理的攻击,这使内存安全成为道德义务,并且Rust必须作为领先且零开销的内存安全语言取得成功。

C++26:标准库强化

Lobsters Hottest

C++26 引入了标准化的库强化机制,用于在运行时捕获常见的未定义行为(如越界访问)。基于 Google 的生产经验,此举仅带来 0.30% 的性能开销,同时将段错误减少了 30%。

Int a = 5; a = a++ + ++a; a =? (2011)

Hacker News Top

分析了C/C++表达式 'a = a++ + ++a;' 在 int a=5 时的未定义行为,展示了因编译器相关的求值顺序和后置递增处理而可能出现的三种结果(11、12、13),并进行了理论和实验分析。

回归构建模块的构建模块

Lobsters Hottest

本文类比了C/C++中的安全漏洞与Verilog中的安全漏洞,指出硬件描述语言的设计导致了缺陷,并认为行业应投资于更安全的替代方案,类似于软件领域对内存安全编程语言的推动。