原文地址:https://blog.regehr.org/archives/1467
作者:John Regehr
在C与C++里,未定义行为(UB)对开发者来说是一个清晰且现实的危险,特别是当他们编写将在一个信任边界执行的代码时。一类不那么为人所知的未定义行为存在于大多数优化、领先的编译器的中间表达(IR)里。例如,除了让你一脸茫然的C形式的UB外,LLVMIR还增加了undef与poison。当人们开始意识到这时,一个典型的反应是:“呃,为什么?LLVM就像C那么糟!”本文解释为什么这个反应是不正确的。
未定义行为是设计决策的结果:在系统的特定一层避免彻底陷入程序的错误。避免这些错误的责任委托给更高层面的抽象。例如,显然一个安全的编程语言可以被编译为机器码,同样显而易见,不安全的机器码没有办法对由语言实现做出的高阶保证做出妥协。Swift与Rust都被编译为LLVM IR;它们的某些安全保证由生成代码里的动态检查来实施,其他保证则通过类型检查,它们在LLVM层面没有表示。任一方式,对Swift与Rust的安全子集,在LLVM层面的UB都不是问题,也无从检测。如果开发环境中的某个工具确保不会执行UB,甚至C也可以被安全使用。L4.verified就是这样的项目。
未定义行为的本质是避免强制绑定错误检查与非安全操作的自由(The essence of undefined behavior is the freedom to avoid a forcedcoupling between error checks and unsafe operations)。一旦松绑,检查可以被优化,例如通过提升出循环或完全消除。在设计良好的IR中,留下的非安全操作可以被映射到很少或没有开销的基本处理器操作。作为一个具体的例子,考虑这个Swift代码:
func add(a : Int, b :Int)->Int {
return (a & 0xffff) + (b & 0xffff);
}
尽管在整数溢出处,Swift的实现必须陷入,编译器观察到溢出是不可能的,发布这个LLVM IR:
define i64 @add(i64 %a,i64 %b) {
%0 = and i64 %a, 65535
%1 = and i64 %b, 65535
%2 = add nuw nsw i64 %0, %1
ret i64 %2
}
不仅被检查的加法操作被降级为没有检查的加法,而且add指令被标记上LLVM的nsw与nuw属性,表示有符号与无符号溢出都是未定义的。单独地,这些属性不提供任何利益,但在这个函数被内联后,它们使额外的优化成为可能。当Swift基准测试集被编译到LLVM,大约八分之一加法指令具有一个表示整数溢出是未定义的属性。
在这个特别的例子里,nsw与nuw属性是重复的,因为一个优化遍可以重新推导出add不会溢出这个事实。不过,通过避免可能需要代价高昂的静态分析来重新发现已知的程序事实,这些属性以及其他类似的属性通常体现了自己的价值。同样,某些事实在后面不能被重新发现,甚至在理论上,因为信息已经在某些编译步骤里丢失。
总的来说,在程序员可见的抽象表示里的未定义行为代表一个积极与危险的权衡:它牺牲了程序的正确性,以支持性能以及编译器的简单化。另一方面,在系统更底层的UB,比如机器码或者一个编译器IR,是一个内部的设计选择,无需对程序员所面向的系统部分有任何影响。这种UB只是要求我们接受:安全性检查可用于剔除相应的非安全操作,以提供高效的执行。