本文编译自外文博客《The most stupid C bug ever》,讲述了一个因C语言转义字符和预处理器特性导致的、令人啼笑皆非的跨平台开发BUG。
我相信,即使你是经验丰富的开发者,也可能掉入这个坑。下面我们一起来看看原作者遇到的这个“愚蠢”的BUG。
首先,作者的意图很明确:他想写一段程序来创建一个文件。如果调用者提供了文件名,就创建对应的实体文件;如果没有提供,则调用 tmpfile() 创建一个临时文件。
这段代码是他编写的HTTP下载C程序的一部分。其中,code == 200 判断的是HTTP请求的成功返回码。
else if (code == 200) { // Downloading whole file
/* Write new file (plus allow reading once we finish) */
g = fname ? fopen(fname, "w+") : tmpfile();
}
但这段代码在 Windows 平台上出现了问题。原因是 Microsoft 对标准 C 库函数 tmpfile() 的实现,默认将临时文件创建在 C:\ 根目录下。这对于没有管理员权限的用户来说会失败,甚至在 Windows 7 下,即便有管理员权限也可能出问题。
所以,在 Windows 下不能直接使用这个 tmpfile() 函数,需要特殊的处理。于是,作者先在代码中留下了一个“FIXME”注释以标记此事:
else if (code == 200) { // Downloading whole file
/* Write new file (plus allow reading once we finish) */
// FIXME Win32 native version fails here because
// Microsoft's version of tmpfile() creates the file in C:\
g = fname ? fopen(fname, "w+") : tmpfile();
}
接下来,作者决定实现一个跨平台的版本。他的初步想法是写一个包装函数:
FILE * tmpfile( void ) {
#ifndef _WIN32
return tmpfile();
#else
//code for Windows;
#endif
}
但他很快意识到,这个写法很糟糕,会导致函数名冲突,让代码变得难以理解。
于是,他采用了更常见的跨平台做法:实现一个自己的函数 w32_tmpfile,然后在 Windows 平台下通过宏定义将 tmpfile “重定向”到这个自定义函数。
#ifdef _WIN32
#define tmpfile w32_tmpfile
#endif
FILE * w32_tmpfile( void ) {
//code for Windows;
}
大功告成!编译,运行…… 等等!为什么没有调用到我的 w32_tmpfile()?通过调试和单步跟踪,确认宏替换确实没有生效。
难道是三元运算符 (? :) 的问题?作者将代码改成了 if-else 结构,结果…… 居然可以正常工作了!
if(NULL != fname) {
g = fopen(fname, "w+");
} else {
g = tmpfile();
}
三元运算符应该没问题啊,难道是这个宏定义对三元运算符不起作用?这难道是编译器预处理器的一个隐秘BUG?作者当时一定是这么怀疑的。
现在,让我们把能工作的代码和不能工作的代码放在一起对比看看:
能正常工作的代码
#ifdef _WIN32
# define tmpfile w32_tmpfile
#endif
FILE * w32_tmpfile( void ) {
code for Windows;
}
else if (code == 200) { // Downloading whole file
/* Write new file (plus allow reading once we finish) */
// FIXME Win32 native version fails here because
// Microsoft's version of tmpfile() creates the file in C:\
//g = fname ? fopen(fname, "w+") : tmpfile();
if(NULL != fname) {
g = fopen(fname, "w+");
} else {
g = tmpfile();
}
}
不能正常工作的代码
#ifdef _WIN32
# define tmpfile w32_tmpfile
#endif
FILE * w32_tmpfile( void ) {
code for Windows;
}
else if (code == 200) { // Downloading whole file
/* Write new file (plus allow reading once we finish) */
// FIXME Win32 native version fails here because
// Microsoft's version of tmpfile() creates the file in C:\
g = fname ? fopen(fname, "w+") : tmpfile();
}
你看出问题所在了吗?也许聪明的你一开始就发现了,但作者当时没有。所有问题的根源,都出在那行注释上:
// Microsoft's version of tmpfile() creates the file in C:\
关键在于最后的 C:\。在 C 语言的字符串和字符常量中,反斜杠 \ 是转义字符。但在注释里呢?实际上,在 // 单行注释中,反斜杠同样具有特殊含义:它表示“续行”。编译器会将下一行物理代码也当作注释的一部分!
于是,原本的代码在预处理阶段实际上变成了:
// Microsoft's version of tmpfile() creates the file in C: g = fname ? fopen(fname, "w+") : tmpfile();
整个赋值语句都被注释掉了!宏定义 #define tmpfile w32_tmpfile 自然也就没有机会应用到已经被注释掉的 tmpfile() 上。这涉及到 C 语言预处理器对源代码的处理顺序。
而为什么改成 if-else 就能工作?因为作者当时恰好把旧的三元运算符语句注释掉了(//g = ...),新的 if-else 块不在那个“续行注释”的范围内,所以宏替换正常生效了。
我相信,当作者最终发现这个 BUG 的真相时,内心一定是崩溃的。这个由转义字符在注释中引发的“惨案”,肯定耗费了他大量的调试时间。
这个案例也提醒我们,在处理文件路径、正则表达式等涉及大量反斜杠的场景时,要格外小心。无独有偶,笔者也曾犯过一个“愚蠢程度”不相上下的错误。
我写了一个小函数,需要传入一个 int* pInt 指针,并在代码中用它做除数。由于当时使用没有语法高亮的 vi 编辑器,我写出了这样的代码:
float result = num/*pInt; ....
/* some comments */
-x<10 ? f(result):f(-result);
程序编译通过了,但运行时行为极其诡异。用 GDB 调试时,发现有些语句似乎被直接跳过了。花费大量时间后,我终于找到原因:缺少空格。
我们用有语法高亮的视角来看这段代码:
float result = num/*pInt;
....
/* some comments */
-x<10 ? f(result):f(-result);
在 C 语言中,/* 被识别为块注释的开始。因此,第一行实际上变成了:
float result = num
而从 /*pInt 开始,直到 */ 结束的整个区域(包括中间的 .... 和那行独立的注释)都被当作注释处理了。最终,编译器“看到”的代码是:
float result = num-x<10 ? f(result):f(-result);
这完全扭曲了原本的意图!这个错误深刻地提醒我们,C/C++的语法细节和书写规范何其重要,一个空格的有无可能彻底改变程序逻辑。
这两个故事,都堪称是 开发者 在特定情境下因语言特性而踩中的“经典深坑”。它们告诉我们,在编程时,尤其是在处理看似简单的字符串、注释和宏时,保持警惕和遵循良好的编码规范是多么关键。
原文链接:https://coolshell.cn/articles/5388.html
原始出处:https://www.elpauer.org/2011/08/the-most-stupid-c-bug-ever/