使用 -fsanitize=undefined 和 Picolibc 的乐趣
使用 -fsanitize=undefined 和 Picolibc 的乐趣
无论是 GCC 还是 Clang,都支持 -fsanitize=undefined
标志,该标志会对生成的代码进行检测,以发现程序进入 C 语言规范中未定义或由实现定义的部分的情况。许多此类情况也是常见的编程错误。如果能有针对其他易于检测的错误的检测工具就太好了,但至少目前,未定义行为检测工具确实可以捕获一些有用的问题。
支持 Sanitizer
可以使用 Sanitizer 构建工具,使其在发生任何错误时捕获错误或调用处理程序。在这两种模式下,都会识别出相同的问题,但是当启用捕获模式时,编译器会插入一个 trap 指令,并且不希望程序继续运行。当使用处理程序时,每个已识别的问题都会标记有许多有用的数据,然后调用特定的 Sanitizer 处理函数。
这些特定函数的文档记录不是很完善,它们接收的参数也没有详细说明。也许这是因为两个编译器都提供了它们使用的所有函数的实现,并且并不真正希望存在外部实现?但是,为了使这些函数在嵌入式环境中可用,picolibc
需要提供一整套处理程序,以支持 GCC 和 Clang 的所有版本,因为编译器提供的版本取决于特定的 C(和 C++)库。
当然,程序可以在 trap-on-error 模式下构建,但这使得查明错误原因变得更加困难。
修复 Sanitizer 问题
一旦实现了 Sanitizer 处理程序,就可以启用它们来构建 picolibc
,并运行所有 picolibc
测试,以发现库中的问题。
与去年的静态分析器探索一样,绝大多数 Sanitizer 投诉来自以无害方式调用未定义或实现定义的行为:
- 计算超出
&array[size+1]
的指针。我没有发现实际使用结果指针的情况,但是仅仅计算仍然是未定义的行为。通过调整代码以避免计算此类指针来解决这些问题。结果是更清晰的代码,这很好。 - PRNG 代码中的有符号算术溢出。库中有几个线性同余 PRNG,它们使用有符号整数算术。
rand48
生成器仔细地使用了无符号short
值。当然,在 C 中,如果int
比short
更宽,则对它们执行的算术运算将使用有符号int
。C 将有符号溢出指定为未定义的,但是 GCC 和 Clang 仍然生成预期的代码。此处的修复很简单;只需将计算切换为无符号算术,并根据需要调整类型并插入强制类型转换。 - 将指针传递到数据结构的中间。例如,
free
接受指向分配开始位置的指针。管理结构出现在内存中的紧前面;计算其地址似乎是编译器未定义的行为。我在这里可以做的唯一修复是在执行这些计算的函数中禁用 Sanitizer —— Sanitizer 错误地检测到正确的代码,并且它没有提供跳过每个运算符检查的方法。 - 空指针加或减零。C 语言规定,任何涉及
NULL
指针的算术运算都是未定义的,即使要加或减的值为零也是如此。此处的修复是创建一个宏,仅在启用 Sanitizer 时启用,该宏检查这种情况并跳过算术运算。 - 丢弃溢出的计算。有一些地方计算出一个值,然后检查该值是否会溢出并丢弃结果。即使程序不依赖于该计算,仅仅它的存在就是未定义的行为。通过将计算移到溢出检查中的
else
子句中来解决这些问题。这会插入额外的分支指令,这很烦人。 - 数学代码中的有符号整数溢出。在各种想要与 1.0 进行比较的函数中,存在一种常见的模式。他们不使用浮点相等运算符,而是使用两个 32 位半部分进行计算,即
((hi - 0x3ff00000) | lo) == 0
。这很有效,但是因为这些函数中的大多数都将“hi”部分存储在有符号整数中(以快速检查符号位),所以当hi
是一个很大的负值时,结果是未定义的。通过插入强制转换为无符号类型来修复这些问题,因为始终对结果进行相等性测试。
有符号整数移位
这是 C 语言规范完全错误的一个领域。
对于左移,在 C99 之前,它作为按位运算符在有符号整数上工作,等效于无符号整数上的运算符。在此之后,负整数的左移变得未定义。幸运的是,通过将操作数强制转换为无符号数,执行移位并将其强制转换回原始类型,可以轻松(如果繁琐)地解决此问题。Picolibc
现在有一个内部宏 lsl
,它可以执行此操作:
#define lsl(__x,__s) ((sizeof(__x) == sizeof(char)) ? \
(__typeof(__x)) ((unsigned char) (__x) << (__s)) : \
(sizeof(__x) == sizeof(short)) ? \
(__typeof(__x)) ((unsigned short) (__x) << (__s)) : \
(sizeof(__x) == sizeof(int)) ? \
(__typeof(__x)) ((unsigned int) (__x) << (__s)) : \
(sizeof(__x) == sizeof(long)) ? \
(__typeof(__x)) ((unsigned long) (__x) << (__s)) : \
(sizeof(__x) == sizeof(long long)) ? \
(__typeof(__x)) ((unsigned long long) (__x) << (__s)) : \
__undefined_shift_size(__x, __s))
右移的实现要复杂得多。我们想要的是算术移位,其中符号位在向右移位的过程中被复制。C 没有定义这样的运算符。相反,负整数的右移由实现定义。幸运的是,GCC 和 Clang 都将有符号整数上的 >>
运算符定义为算术移位。同样幸运的是,C 还没有将其定义为未定义的,因此程序本身最终不会变为未定义的。
算术右移的问题在于,它不等效于无符号值的右移。这是我使用标准 C 运算符提出的:
int
__asr_int(int x, int s) {
return (int) ((unsigned int) x >> s) |
-(((unsigned int) x & ((unsigned int) 1 << (8 * sizeof(int) - 1))) >> s);
}
符号位被单独复制,然后或运算到结果中。此函数为五个标准整数类型中的每一种类型复制,然后将它们包装在另一个选择 sizeof
的宏中:
#define asr(__x,__s) ((sizeof(__x) == sizeof(char)) ? \
(__typeof(__x))__asr_char(__x, __s) : \
(sizeof(__x) == sizeof(short)) ? \
(__typeof(__x))__asr_short(__x, __s) : \
(sizeof(__x) == sizeof(int)) ? \
(__typeof(__x))__asr_int(__x, __s) : \
(sizeof(__x) == sizeof(long)) ? \
(__typeof(__x))__asr_long(__x, __s) : \
(sizeof(__x) == sizeof(long long)) ? \
(__typeof(__x))__asr_long_long(__x, __s): \
__undefined_shift_size(__x, __s))
lsl
和 asr
宏使用 sizeof
而不是类型通用机制,以保持与缺少类型通用支持的编译器的兼容性。
一旦编写了这些宏,就需要将它们应用于需要的地方。为了保留检测编程错误的好处,它们仅在需要的地方应用,而不是盲目地应用于整个代码库。
在使用移位运算符的数学代码中有几个常见的模式。一种是计算次正规数的指数值时。
for (ix = -1022, i = hx << 11; i > 0; i <<= 1)
ix -= 1;
此代码通过将有效数左移 11 位(指数字段的宽度)来计算指数,然后以增量方式将其一位一位地移位,直到符号翻转,这表明设置了最高有效位。这里有意使用 C99 之前版本的左移运算符的定义;因此,这两个移位都替换为我们的 lsl
运算符。
在 pow
的实现中,最终指数计算为两个指数之和,这两个指数都在允许的范围内。然后测试结果之和是否为零或负数,以查看最终值是否为次正规数:
hx += n << 20;
if (hx >> 20 <= 0)
/* do sub-normal things */
在这种情况下,指数调整 n
是一个有符号值,因此该移位被替换为 lsl
宏。测试值需要计算正确的符号位,因此我们将其替换为 asr
宏。
因为右移操作不是未定义的,所以我们仅在启用未定义行为 Sanitizer 时才使用上面的花哨宏。另一方面,lsl
宏应具有零成本并涵盖未定义的行为,因此始终使用它。
实际发现的 Bug!
这次小冒险的目标既是为了使将未定义行为 Sanitizer 与 picolibc
结合使用成为可能,又是为了使用 Sanitizer 来识别库代码中的 Bug。我完全期望大部分精力将用于掩盖无害的未定义行为实例,但希望这项工作也能发现代码中的实际 Bug。我没有失望。通过这项工作,我在代码中发现了(并修复了)八个 Bug:
setlocale/newlocale
未检查NULL
区域设置名称qsort
正在使用uintptr_t
来交换数据。在“large”模式下的MSP430
上,这是一个 32 位表示形式内的 20 位类型。random()
返回的值在int
范围内,而不是long
范围内。- 用于
memcpy
的m68k
程序集对于大于 64kB 的大小已损坏。 - 即使成功,
freopen
也返回NULL
- 优化版本的
memrchr
始终 执行未对齐的访问。 - 字符串到浮点数的转换缺少一个包含四个值的表。这导致数组访问溢出,从而在某些情况下导致不精确的值。
vfwscanf
错误地解析了浮点值,因为它假定wchar_t
是无符号的。
Sanitizer 愿望
虽然有一种方法可以检测 C 代码中调用未定义和实现定义行为的位置,但这很好,但似乎可以轻松扩展此工具以检测其他常见的编程错误,即使根据语言规范很好地定义了代码也是如此。一个明显的例子是无符号算术。有多少 Bug 来自这行看似无害的代码?
p = malloc(sizeof(*p) * c);
因为 sizeof
返回一个无符号值,所以即使乘法回绕,结果计算也永远不会导致未定义的行为,因此即使启用了未定义行为 Sanitizer,也不会捕获此 Bug。Clang 似乎有一个无符号整数溢出 Sanitizer,应该可以做到这一点,但是我在 GCC 中找不到类似的东西。
总结
Clang 和 GCC 中存在的未定义行为 Sanitizer 都提供了有用的诊断信息,可以发现一些常见的编程错误。在大多数情况下,用定义的行为替换未定义的行为非常简单,尽管标准 C 中缺少算术右移运算符令人恼火。我建议任何使用 C 的人都尝试一下。
标签: tags/picolibc 上次编辑时间:周日 2025 年 4 月 13 日 15:28:18