C程序中的未定义行为(Undefined Behavior)

什么是UB

LLVM IR和C语言中都有UB的概念。很多在C语言中看似合理的事都可能导致UB,UB是代码中很多BUG的源泉。

UB在C或类C语言中存在的原因是因为追求极致的性能。类似JAVA之类的语言为了安全和可重现行为的特性,和有意避开了UB,但损失了性能。

在讲解更多的细节之前,先简单讲解一下要实现一个对大部分C程序性能友好的编译器,有哪些关键要素:

  • a) 做好关键算法的实现:寄存器分配、调度等等。
  • b) 知道大量的小技巧(tricks),比如peephole optimization(将一部分指令替换为更高效的指令)、循环变换等。
  • c) 消除不必要的抽象(比如函数inline、消除C++中的临时对象等)
  • d) 不要搞砸任何事???

UB的优点

1)使用未初始化的变量:很多C程序BUG的根源,编译器等很多工具都可以轻松找出这种问题。

但C程序中不像JAVA等语言对变量进行清零初始化,也带来了性能的收益,特别是对栈上的数组,malloc申请的内存等需要调用memset的场景。

2)有符号整型溢出:如果有符号整型的算数运算导致溢出,那么结果是未定义的。一个例子是INT_MAX+1并不一定等于INT_MIN。

这个UB对某些特定的优化非常重要,比如"X + 1 > X"一定为true,"X * 2 / 2" 一定等于X。

一些更重要的优化,比如如下的循环:

for (i = 0; i <= N; ++i) { ... }

编译器可以假定这个循环一定迭代N+1次,这样就可以应用很多循环优化,提升循环的性能。

Clang和GCC都提供了"-fwrapv"选项强制编译器将有符号整型溢出变为确定的行为(Defined Behavior),这样自然就会丢掉这些优化机会。

3)移位溢出:对一个uint32_t移位超过32bit的结果是未定义的。

原因是因为不同的CPU的移位实现差异很大,比如x86会将移位的位数截断到5bit表示的范围(移位32bit = 移位0bit)。

但是PowerPC将移位的位数截断到6bit表示的范围(移位32bit = 产生数字0)。

编译器如果要消除这类UB,需要增加一条额外的指令,比如and,会带来性能损失。

4)解引用野指针和数组访问越界:解引用野指针(空指针、指向已经free的内存等)和数组访问越界是C程序中的常见BUG。

要消除这类UB,每次数组访问需要增加越界检查,并且ABI也需要保证指针对应的边界信息被保存下来,用于检查指针的数值算术运算。

5)解引用空指针:解引用空指针是未定义行为,意味着就算你mmap了一个page到0地址,也不保证解引用空指针会访问到这个page。

在C系列语言中,解引用空指针的未定义给编译器提供了一系列的优化机会,表现为宏展开和inline中的优化等。

6)违反类型规则:比如将int*转换为float*,再进行解引用,是未定义行为。

这个UB为编译器做基于类型的别名分析(TBAA)提供了帮助。

比如clang可以直接把下列代码:

float *P;
void zero_array() {
   int i;
   for (i = 0; i < 10000; ++i)
     P[i] = 0.0f;
}

优化为:

memset(P, 0, 40000)

可以通过-fno-strict-aliasing来关闭这个UB,这样会导致上面的代码不能被优化为memset。

因为编译器不能保证对P[i]的赋值不会改变P本身,比如如下代码:

int main() {
  P = (float*)&P;  // cast causes TBAA violation in zero_array.
  zero_array();
}

当然上面这样的类型滥用是不常见的,所以标准委员会决定将违反类型规则定义为UB,保证更好的性能。

值得注意的是JAVA没有这样的问题,因为JAVA没有不安全的指针类型转换。

整体来说,除了由于优化导致的UB,还有一些其他的类型,比如序列点违规:"foo(i, i++)",多线程竞争条件,restrict违规,除0等。

相互影响的编译器优化导致的出人意料的结果

现代的编译器都包含了多种以特定顺序执行的优化,这些优化之间会相互影响。

以下面的例子为例(Linux Kernel中发现的漏洞的简化):

void contains_null_check(int *P) {
  int dead = *P;
  if (P == 0)
    return;
  *P = 4;
}

加入编译器先执行死代码删除优化Pass(Dead Code Elimination),之后再执行删除冗余空指针检查Pass(Redundant Null Check Elimination),那么代码会被优化为:

void contains_null_check_after_DCE_and_RNCE(int *P) {
  //int dead = *P;     // deleted by the optimizer.
  if (P == 0)   // Null check not redundant, and is kept.
    return;
  *P = 4;
}

但是如果交换一下上面两个Pass的顺序,代码则会被优化为:

void contains_null_check_after_RNCE_and_DCE(int *P) {
  //int dead = *P;
  //if (false)
  //  return;
  *P = 4;
}

值得注意的是,不管是contains_null_check_after_DCE_and_RNCE,还是contains_null_check_after_RNCE_and_DCE都是符合C标准的。

UB对代码安全的影响

如下的C代码:

void process_something(int size) {
  // Catch integer overflow.
  if (size > size+1)
    abort();
  ...
  // Error checking from this code elided.
  char *string = malloc(size+1);
  read(fd, string, size);
  string[size] = 0;
  do_something(string);
  free(string);
}

通过之前的例子,我们可以知道,有符号整型溢出是UB,所以编译器可以优化为如下代码:

void process_something(int size) {
  char *string = malloc(size+1);
  read(fd, string, size);
  string[size] = 0;
  do_something(string);
  free(string);
}

这样上面的代码,如果将size=INT_MAX传入,就会导致缓冲区溢出。

当然,修复的方案也很简单,将if (size > size+1)修改为if (size == INT_MAX)即可。

正常工作的包含UB的代码可能由于编译器的升级被破坏

典型的例子如下:

1) 未初始化的变量刚好被初始化为0,但编译器升级后,寄存器分配算法发生了变化,复用了另外一个值为非0的寄存器。

2)栈上的数组溢出之前只是踩到了一个无关紧要的变量,但编译器升级后,stack的栈变量的顺序或者复用栈空间的算法变化了,就可能导致踩到了另一个非常重要的变量。

没有任何一种方法可以保证大型代码库中是否存在UB

如下的一些工具只能帮忙找出部分的UB:

  • valgrind memcheck
  • clang -fcatch-undefined-behavior选项
  • 编译器告警
  • Clang Static Analyzer
  • Klee
  • C-Semantics Tool

为什么编译器不对基于UB的优化上报告警

1)很难保证告警的有效性

比如对例子:

float *P;
 void zero_array() {
   int i;
   for (i = 0; i < 10000; ++i)
     P[i] = 0.0f;
}

如果上报告警"the optimizer is assuming that P and P[i] don't alias",大部分场景都是误报。

2)很难只在需要告警的地方上报告警,比如死代码中的UB,大家就不关心。

3)解释编译器内部做的一系列优化是非常困难的。

比如可能需要上报这样的告警:

warning: after 3 levels of inlining (potentially across files with Link Time Optimization), some common subexpression elimination, after hoisting this thing out of a loop and proving that these 13 pointers don't alias, we found a case where you're doing something undefined. This could either be because there is a bug in your code, or because you have macros and inlining and the invalid code is dynamically unreachable but we can't prove that it is dead.

目前编译器内部基本都没有这样的机制。

Clang对UB的处理

1)Clang默认打开了更多的告警。
2)Clang对很多类型的UB会上报告警。
3)LLVM的Optimizer对利用UB的优化使用得更少。

使用安全的C语言"方言"

如果你并不追求极致的性能,那么可以通过一系列的编译器选项去使用去掉了一系列UB的C语言的方言。

比如使用-fwtrapv可以去掉有符号整型溢出的UB,-fno-strict-aliasing去掉了基于类型的别名分析(TBAA)。

但是就像之前讨论的,要完全消除掉UB,必须要改变C语言的ABI并严重影响性能,是非常困难的,并且也会导致你的代码无法被移植到其他平台。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值