有人坚持认为C语言的未定义行为(UB)没什么大不了:代码会做什么我们心里有数,最坏不过是个段错误。这个说法站不住脚。

先看一个例子。代码里声明了一个volatile int device_command,注释说假装它是控制电机和齿轮的内存映射设备。函数 serve_hot_chocolate() 把 device_command 设为 42,然后调用 std::unreachable()——这个函数的定义就是“在指定位置触发未定义行为”。另一个函数 explode() 把 device_command 设为 3。

用 GCC 16.2 加 -O1 编译,汇编输出是这样的:serve_hot_chocolate() 里写入 42,紧接着 explode() 里写入 3,然后返回。explode() 从未被调用,却照样被执行了。热巧克力炸了。

再看空指针解引用。有人坚称这也没事:不过是访问地址零,那里没映射东西就吃个段错误。同样不对。

把 std::unreachable() 换成 int* ptr = nullptr; *ptr = 99;,汇编变成:先往 device_command 写 42,然后往地址零写入 0——不是 99——最后用一条非法指令让程序崩溃。数字 99 根本没出现在生成的目标程序里,但空指针写入出现了。按推测,这次写入是为了改善调试器的报错信息。

换 clang 试试,结果又是另一番景象:serve_hot_chocolate() 写 42,explode() 写 3,返回。热巧克力再次爆炸。

那给空指针做点指针运算呢?ptr += 10 之后解引用,这本身就是三重 UB:拿空指针做比较以外的用途;即便空指针处真有个合法对象(并没有),造出指向对象外或数组尾后的指针也是 UB;即便前两条都成立(并不成立),解引用地址还是 UB。

实际编译出来:serve_hot_chocolate 写 42,然后往地址 40 写入 99,返回。这次它做了按字面理解代码的人所期待的事。运气好而已。

为什么 GCC 和 clang 没发现 *(ptr + 10) = 99 和 *ptr = 99 一样是 UB,从而做同样的事?这个问题问错了。没有任何编译器有义务去“检测”UB。看起来 GCC 和 clang 的反应是:空指针解引用?那不可能发生,那我就不为这个函数生成指令了,省点二进制体积。由于停机问题,编译器在一般情况下无法知道这段代码到底会不会被执行。

所以 UB 会做什么?什么都可能。同一处触发 UB 的位置,已经演示出三种结果:

  • 执行内存里碰巧排在后面的代码,热巧克力爆炸;
  • 往地址零存一个实际上随机的值(写的是 0 不是 99),然后故意崩溃;
  • 按代码字面意思,把值写到字面上看到的地址。

还有人至今不相信鼻妖(nasal demons)这回事。