本文使用 Zhihu On VSCode 创作并发布
前言
本篇我们来讲一个简单验证算法有界程序验证(Software Bounded Model Checking)。 这个名词更加正确的翻译应该是软件有界模型检测,但是我们这一系列的文章专注于程序验证,不太想引入过多的关于模型检测(Model Checking)的内容。 所以,在接下来的内容中,我们仍称其为有界程序验证。 在后文中,我们会介绍有界程序验证的基本思想,算法,以及优势和局限性。 我们也同样会使用大量的例子来进行演示。
有界程序验证
上近似与下近似
我们先介绍上近似(Over-approximation)和下近似(Under-approximation)思想。 上近似思想在前篇讲不变式时已经有过简单的介绍。 倘若我们希望证明某个性质
a
的下标变量
i
的取值集合。 而属性
a
的访问不会发生越界,即
因此,换一种思路,我们可以试图寻找一个便于表达的集合
上近似思想用于证明属性
举例来说,在程序分析中,我们可以用如下的方式进行上近似和下近似。 以下表示三个具有子集关系的集合。 中间的实线集合表示程序运行的实际状态空间
i = c ? 1 : 2
,那么实际的程序状态集合为
i = *
,表示
i
可能被赋值为任意整数,那么上近似的集合为
i = 1
。 显然,我们有
_-=====================-_
//
//________________
|_ _ _ _
| i = 1 i = c ? | i = * ||
|_ _ _ _/ 1 : 2 | ||
________________/ //
//
-======================-/
最后,我们重申一点,上近似用于证明,而下近似用于证伪(找错,Bug Finding)。 对于一次验证过程,我们只能使用这两者之一。 同时使用上近似和下近似,会导致证明不正确,找错也可能找到伪反例,可谓是“既丢了西瓜,也丢了芝麻”。 希望读者在看待程序验证问题时牢记这一点,活用上近似和下近似思想。
断言语义
在程序验证简介这一篇中,我们简单地讲述了assert
和assume
的意思。 在这一小节,我们更加具体地讲一下。
Assertion
我们使用assert
来表示我们需要验证的属性。 在进行程序验证时,我们需要证明,对于所有满足断言之前程序本身形成的约束的情况,断言都要成立。 我们使用
int addone(int a) {
int b = a + 1;
assert(b > 1);
return b;
}
在这个程序中,断言为
a
等于
-1
时,断言不成立。
Assumption
在前文中,我们将assume
解释为假定其条件成立,再执行后面的代码。 assume
实际上就是在程序本身的约束中,添加一个假定的条件。 所以assume
的内容和程序本身的约束并没有太大的差别。 倘若assume
的内容不成立,那么,因为前提不成立,所以我们无需在乎要证明的属性成立与否。 这是因为蕴含关系
int addone(int a) {
assume(a > 0); // added.
int b = a + 1;
assert(b > 1);
return b;
}
我们在之前的代码中添加了对函数输入参数a
的约束,即assume(a > 0)
。 进行程序验证时,我们需要证明如下公式有效。 assume
的内容和程序本身的约束共同作为蕴含的前提。 倘若
assume
内容的简单分情况讨论,都由SMT求解器自动完成了)
循环展开和找错
在程序验证简介这一篇中,我们介绍了将程序验证问题转化为一阶逻辑公式求解问题的基本方法。 有界程序验证(Software Bounded Model Checking)也是按照这个思路来的。 两者的不同之处在于对程序中循环的处理。 在前文中,我们使用上近似方法来证明程序的功能正确性。 其中,循环不变式被用来作为执行循环后程序状态空间的上近似。 有界程序验证是一个下近似验证算法,使用不变式属于上近似方法,两者不能混用。 所以有界程序验证不需要,也不能使用循环不变式。
有界模型检测的下近似思想很朴素,它对于程序中执行次数未定的循环,尝试将其展开固定次数进行分析。 也就是说,对于一个带有执行次数未定的循环的程序,在它实际的执行路径集合中,循环可能执行
此外,所谓执行次数未定的循环指不能从代码中轻易得出循环的执行次数。 比如for(i = 0; i < 10; i++) { ... }
这样的循环,我们可以很简单地得出循环会执行10次。 而对于while
循环,我们一般默认其为执行次数未定的循环。 例如,以下代码中,while
循环的执行次数在较浅的语义层面是未确定的。 虽然我们可以通过稍为深入一些的语义分析,得到循环的执行次数,但作为例子,我们仍将其视为执行次数未定的循环进行处理。
int i = 0;
int j = 2;
while(j >= 0) {
i = i + 1;
j = j - 1;
}
assert(i == 2);
对于以上程序,若让我们人工判定以上程序中的assert
断言成立与否,我们很难看到程序立刻断定断言不成例。 事实上,我们会在大脑中尝试逐步执行循环,并记录下运行时变量i
和j
的值。 i
和j
初始值为(0, 2)
,循环条件满足,执行一次循环后,变为(1, 1)
。 循环条件仍然满足,再执行一次循环,变为(2, 0)
。 循环条件仍满足,再次执行变为(3, -1)
。 此时循环条件不满足,跳出循环。 差不多在这时,我们可以很自信地作出判断,断言不成立。 有界程序验证对循环也是这样进行处理的。 例如,我们按照以下的方式,将循环展开1次。其中的assume
语句用于判断是否满足循环跳出条件,以及是否需要考虑循环后的断言。
int i = 0;
int j = 2;
if(j >= 0) {
i = i + 1;
j = j - 1;
assume(j < 0);
}
assert(i == 2);
我们可以使用之前讲过的方法,将以上代码转为SMT公式。 之前对于if
语句的两个分支,我们是分开处理的,得到了两个SMT公式。 在进行有界程序验证中,我们使用上篇讲过的
按照之前讲过的思路,我们先将以上代码转为SSA:
int i = 0; // guard: true
int j = 2; // guard: true
// g0 <=> j >= 0;
i1 = i + 1; // guard: g0
j1 = j - 1; // guard: g0
// s0 <=> j < 0);
i2 = ite(g0, i1, i); // guard: true
j2 = ite(g0, j1, j); // guard: true
assert(i2 == 2); // // guard: !(g0 && !s0)
我们这里使用的SSA形式上和前篇中的不同,它没有控制流结构,而是一系列语句的集合,并且每个语句都带有一个守卫(Guard)。 语句的守卫指该语句被执行到时所要满足的条件。 举例来说,int i = 0;
这条语句,无需任何条件就会被执行,所以它的守卫是
i1 = i + 1;
这条语句,当
if(j >= 0)
中的条件
j >= 0
满足时,才会被执行,所以它的守卫是
j >= 0
的别名便是
g0
。 我们将每条语句的守卫都标注在语句后的注释中。 我们称这种SSA为
带守卫的SSA(Guarded SSA)。 带守卫的SSA由于没有控制流结构,语句之间也没有顺序关系,所以可以直接使用一个集合来描述。 这样的SSA形式在进行分析处理时,会比较方便,效率也会高一些。
我们再解释一下SSA最后一行的断言的守卫!(g0 && !s0)
的含义。 先看断言无法到达的情况,它要求进入循环执行一次,且assume
表达式值为假,从而程序的执行被阻塞,无法到达断言。 这一情况对应的条件为
我们可以将以上SSA表达式直接转为SMT公式。 其中带下划线的部分表示断言的可达性,只有满足断言可达后,我们才要求断言的表达式要成立。
我们可以直接将以上SMT表达式交给求解器求解,但我们在介绍SMT求解时曾提到过,求解带有全称量词的逻辑公式效率是非常低的。 同时,我们也介绍了使用对偶性来消除全称量词的方法。 证明以上公式有效等价于证明它取非后不可满足,同时,寻找一组使以上公式不成立的解等价于寻找一组使它取非后可满足的解。 对于形如
我们可以将无量词公式
所以,对于例子程序展开一次后转化得到的SMT公式,我们可以将其按照以上方法转化为以下无量词的形式(注意带下划线的部分,我们做存在量词消去时不换名,蕴含被转为了合取,断言属性的谓词取了非):
将以上公式交给SMT求解器求解,将返回UNSAT。 这说明例子程序循环执行1次以内时,不会触发断言失败。 但这并不能说明循环执行更多次不会导致断言失败。 至此,我们使用有界程序验证方法,既没有找到错误,也没有证明程序正确性。 我们仅仅证明了循环执行1次,不会触发断言失败。 这显然没有太大的意义。 所以,我们需要尝试循环更多次的展开。 接下来,我们把循环展开2次。
int i = 0;
int j = 2;
if(j >= 0) {
i = i + 1;
j = j - 1;
if(j >= 0) {
i = i + 1;
j = j - 1;
assume(j < 0);
}
}
assert(i == 2);
同样,转为SMT公式后求解,结果仍然是UNSAT。 我们再尝试展开3次:
int i = 0;
int j = 2;
if(j >= 0) {
i = i + 1;
j = j - 1;
if(j >= 0) {
i = i + 1;
j = j - 1;
if(j >= 0) {
i = i + 1;
j = j - 1;
assume(j < 0);
}
}
}
assert(i == 2);
将以上带断言的无循环(Loop Free)程序转为SMT公式后,求解结果为SAT,并返回了一组
证明正确性
实事上,只要展开次数足够,达到了程序中循环运行次数的上界,有界程序验证方法就可以用来证明程序的正确性。 但是,由于展开的次数
在上面的例子中,我们已经使用有界程序验证,展开循环3次,找到了程序的错误路径。 我们发现是因为循环条件写得不对,导致了错误,所以我们把循环条件修正为j > 0
。 然后,我们试图使用有界程序验证来证明我们修正后的程序是正确的。
我们同样先展开循环1次。 为了证明正确性,我们需要判定展开次数是否足够,也就是判定,到当前的展开路径,是否已经满足循环退出的条件。 我们只需要把原先添加的assume
语句换成assert
即可。 替换完之后,我们有了两个验证目标。 其一是最后一行对应属性的断言。 其二是我们添加的,判断当前展开是否足以跳出循环的断言。 倘若两个断言都验证通过,我们就可以断定,当前的展开次已经足够跳出循环,而且跳出循环后,属性也成立。 也就是说,我们成功地验证了属性的正确性。
之前我们说有界程序验证是下近似方法,不能用于证明程序的正确性。 但是,需要注意的是,当我们添加了判断当前展开是否足以跳出循环的断言后,该断言验证通过足以说明,我们的下近似已经是和原本程序的执行行为一样了。 也即是说,程序的实际状态空间为
int i = 0;
int j = 2;
if(j > 0) { // fixed.
i = i + 1;
j = j - 1;
assert(j <= 0); // changed.
}
assert(i == 2);
如上,我们将修正过的程序的循环展开1次后,发现判定展开足够的断言验证失败。 所以,我们选择增大展开次数到2。 这时我们发现,两条断言全部验证通过,我们成功验证了修正过后的程序的正确性。
int i = 0;
int j = 2;
if(j > 0) {
i = i + 1;
j = j - 1;
if(j > 0) {
i = i + 1;
j = j - 1;
assert(j <= 0);
}
}
assert(i == 2);
小结
有界程序验证的思想非常简单,但它却是目前最强大的程序验证算法之一。 基于这一方法的知名C语言程序验证工具CBMC,在程序验证大赛SV-COMP中取得了非常不错的成绩,并且蝉联证伪(Falsification)得分冠军数年,远超其他验证工具。 这实际上得益于有界程序验证是目前最有效的找错(Bug Finding)方法。 因为一个程序有错,那么就肯定存在至少一条确定的错误路径,而在这条错误路径上,循环的执行次数肯定是有限的。 我们假定在循环在这条路径上执行