使用 -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))

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:

  1. setlocale/newlocale 未检查 NULL 区域设置名称
  2. qsort 正在使用 uintptr_t 来交换数据。在“large”模式下的 MSP430 上,这是一个 32 位表示形式内的 20 位类型。
  3. random() 返回的值在 int 范围内,而不是 long 范围内。
  4. 用于 memcpy 的 m68k 程序集对于大于 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