南京大学的李樾和谭添老师在2020年春季开设了《软件分析》课程,讲授静态程序分析(static program analysis, SPA)的相关内容。由于一直很想系统学习编译器优化及编程语言的知识,但无奈一直没有找到好的资料,国内开设相关课程的学校和老师可以说几乎没有。刚好看到谭添老师在知乎上的宣传,又恰逢疫情期间,就跟着B站的录播视频一起学了。
本文算是课程的学习笔记吧,会随着课程进度不断更新,完整的课程课件需要等两位老师放出来(已经放出来了,参见课程网站)。由于一年前我已经跟着UFMG的DCC 888学过一段静态程序分析,所以其中一些细节可能我理解了就不会再附在这。从短短几节课的感受来说,南大的课程更加适合新手入门,以Java为基础,以Soot作为静态分析工具,老师会事无巨细地讲解分析算法步骤。而UFMG的课程以C/C++为基础,以LLVM作为分析器,看上去他们的课时更加充裕,从理论到工具的使用也都有详细的讲解。
编程语言(programming language, PL)的研究主要可以分为三个部分:
从上个世纪以来,语言的内核就没有怎么改变,但是程序却变得越来越大、越来越复杂,因此需要有方法确保程序的可靠性(reliability)、安全性(security)和
这里可粗略将递归可枚举(recursively enumerable)看成是可被图灵机执行的程序(那也就是大部分程序都在这个范畴内),而非平凡(non-trivial)性质则可以认为是那些与程序运行时行为相关的性质,比如
我们希望得到一个程序点P是否满足上述这些非平凡性质,也即给出确切的答案——是或否。但Rice定理告诉我们,这是不可能的,不存在这样完美的静态分析,能够准确判断所有非平凡性质,也即静态分析不可能既是sound又是complete的。
sound > truth > complete
既然没有办法做到既sound又complete,那就只满足一个好了。大多数静态分析都妥协了complete,也即满足sound,但是不一定精确(precise)。
考虑一个类指针转换
B b = new B(); // B1
a.fld = b;
C c = new C(); // B2
a.fld = c;
// Cast
B b2 = (B) a.fld;
如果程序分析是unsound的,那么它只会分析一条路径,发现B1下来的路径没有问题,就说这个转换是safe cast;但显然这是错误的结论。只有分析是sound的,才会考虑所有路径,得出正确结论。
if (input)
x = 1;
else
x = 0;
// -> x = ?
再看上面的例子,静态分析可以有两种不同的结果:
input
为真时,x=1
;input
为假时,x=0
x=1
或x=0
这两种结果都是正确的(sound),但前者精确,代价也高;后者不那么精确,计算开销小。因此静态分析想要保证soundness的同时,在分析的精度和速度上达到一个权衡。
总结来说,
抽象需要针对不同程序做不同的抽象。
比如确定一个程序中所有变量的符号,那么抽象域(abstract domain)中就需要有5个符号$+$、$-$、$0$、$\top$、$\bot$。其中,倒数第二个符号$\top$代表未知(unknown),用于处理v=e?1:-1
这种变量;最后一个符号$\bot$代表未定义(undefined),用于处理v=w/0
。然后就可以建立这5个符号的运算规则。
抽象语法树(abstract syntax tree, AST)和中间表示(Intermediate Representation, IR)的区别
AST | IR |
---|---|
高层贴近语言语法结构 | 低层贴近机器码 |
与语言相关 | 与语言无关 |
方便快速类型检查 | 紧凑统一 |
没有控制流信息 | 有控制流,静态分析基础 |
对于Java来说,静态分析的软件为Soot。
静态单赋值(static single assignment, SSA)使得3地址码(3-address code, 3AC)中的变量只有唯一一个定义,即每条语句中的变量都用不同的名字表示。
3AC | SSA |
---|---|
p = a + b |
p1 = a + b |
q = p - c |
q1 = p1 - c |
p = q * d |
p2 = q1 * d |
p = e - p |
p3 = e - p2 |
q = p + q |
q2 = p3 + q1 |
当考虑分支操作时,SSA引入Phi函数。比如x0=0
和x1=1
汇聚,引入x2=phi(x0,x1)
,再进行之后的操作,确保单赋值。SSA可以让一些静态分析好操作,但是明显也带来了大量的冗余变量。
控制流图(control flow graph, CFG)的结点通常是一个基本块(basic block, BB)
基本块是最大连续的三地址码指令,满足
因此创建基本块很关键的就是找到首指令(leader):
在两个leader之间的则成为一个基本块。
CFG的连边需满足:
通常在最前面添加虚拟Entry结点,最后添加Exit结点。
这两种分析方式都能使得程序是safe的,因此要看不同的情况进行分析。
数据流分析的两条基本公式(前向)
\[\begin{cases} OUT[B] = f_B(IN[B]), f_B=f_{s_n}\circ\cdots\circ f_{s1}\\\\ IN[B] = \bigwedge_{P\text{ is a pred of B}} OUT[P] \end{cases}\]其中$s_1,\ldots,s_n$是基本块B内的$n$条指令,$\bigwedge$为meet算子。
x op y
,且在计算x op y
之后没有x和y的重定义,则称表达式x op y
在点p是活的。
x op y
换成它最后一次计算得到的值。也可用于检测全局公共子表达式。exp(16) * x
表达式不应是活的,因为走左侧可能会改变其值,因此要保守估计。
a = exp(16) * x
/ |
/ |
x = 3 |
b = exp(16) * x |
\ |
\ |
c = exp(16) * x
基本的数据流分析程序执行框架
kill_B
和gen_B
都已经为每个基本块计算好了) OUT[entry] = empty;
for (each basic block B\entry)
OUT[B] = ?;
while (changes to any OUT occur)
for (each basic block B\entry) {
IN[B] = Meet_{P is a pred of B} OUT[P];
OUT[B] = gen_B \cup (IN[B] - kill_B);
}
定义可达性 | 活变量 | 可用表达式 | |
---|---|---|---|
域 | 定义集合 | 变量集合 | 表达式集合 |
方向 | 前向 | 后向 | 前向 |
May/Must | May | May | Must |
边界 | OUT[entry]=$\varnothing$ | IN[exit]=$\varnothing$ | OUT[entry]=$\varnothing$ |
初始化 | OUT[B]=$\varnothing$ | IN[B]=$\varnothing$ | OUT[B]=$\cup$ |
转换函数 | OUT=gen$\cup$(IN-kill) | OUT=gen$\cup$(IN-kill) | OUT=gen$\cup$(IN-kill) |
Meet | $\cup$ | $\cup$ | $\cap$ |
如果将数据流分析中的值域设为$V$,那么可以定义
\[(OUT[n_1],OUT[n_2],\ldots,OUT[n_k])\subset(V_1\times V_2\cdots V_k)=V^k\]那么每一轮迭代相当于作用一个函数$F:V^k\mapsto V^k$,最终会到达一个不动点$X=F(X)$。
定义偏序集(poset)为$(P,\sqsubseteq)$,其中$\sqsubseteq$在$P$上定义了一个二元偏序关系,满足
偏序意味着有些在$P$内的有些元素对是没有办法比较的。
下面的幂集是一个典型的偏序集的例子。
如上例中,考虑$S$为左侧三个结点{a},{b},{a,b},则上界为{a,b}和{a,b,c},上确界为{a,b},下界和下确界都是{}。
但并不是所有偏序集都有上确界或下确界,而如果有的话一定唯一(反证法)。注意界不一定在子集$S$中。
故上例的幂集是一个格,join算子代表$\cup$,meet算子代表$\cap$。
幂集依然是个完全格。结论:有限的格都是完全格。
证明分为两个部分,存在性与最值性。存在性用经典的无穷递增与有限集矛盾,最值性用反证法。
作业: