使用 -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 投诉来自以无害方式调用未定义或实现定义的行为:

有符号整数移位

这是 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))

lslasr 宏使用 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:

  1. setlocale/newlocale 未检查 NULL 区域设置名称
  2. qsort 正在使用 uintptr_t 来交换数据。在“large”模式下的 MSP430 上,这是一个 32 位表示形式内的 20 位类型。
  3. random() 返回的值在 int 范围内,而不是 long 范围内。
  4. 用于 memcpym68k 程序集对于大于 64kB 的大小已损坏。
  5. 即使成功,freopen 也返回 NULL
  6. 优化版本的 memrchr 始终 执行未对齐的访问。
  7. 字符串到浮点数的转换缺少一个包含四个值的表。这导致数组访问溢出,从而在某些情况下导致不精确的值。
  8. 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