证明函数有界的步骤_科普 - 程序验证(7)- 有界程序验证

本文使用 Zhihu On VSCode 创作并发布

前言

本篇我们来讲一个简单验证算法有界程序验证(Software Bounded Model Checking)。 这个名词更加正确的翻译应该是软件有界模型检测,但是我们这一系列的文章专注于程序验证,不太想引入过多的关于模型检测(Model Checking)的内容。 所以,在接下来的内容中,我们仍称其为有界程序验证。 在后文中,我们会介绍有界程序验证的基本思想,算法,以及优势和局限性。 我们也同样会使用大量的例子来进行演示。

有界程序验证

上近似与下近似

我们先介绍上近似(Over-approximation)和下近似(Under-approximation)思想。 上近似思想在前篇讲不变式时已经有过简单的介绍。 倘若我们希望证明某个性质

在某个集合
上成立
,即
中的所有元素都成立(
)。
集合
的准确表达可能会很难获得
,比如说,
表示某个程序运行过程中,数组访问
a的下标变量 i的取值集合。 而属性
则是说该程序运行时对数组
a的访问不会发生越界,即
。 如果程序比较复杂,我们将很难获得集合
的准确表示,从而导致我们
难以直接证明
上成立。

因此,换一种思路,我们可以试图寻找一个便于表达的集合

,使得
超集(Superset),即
。 同时,我们能证明属性
上是成立的。 那么,我们就间接地证明了
上成立。 这个思想比较简单,也很容易证明其正确性。 假设
上成立,且
,那么对于每一个
中的元素
,都有
中,所以
成立。 从而我们可以推导出
上也成立。

上近似思想用于证明属性

在集合
上成立
,而下近似思想则相反, 它用于证明属性
对集合
中的某些元素不成立
,即证伪
。 相比于上近似思想,下近似思想更容易理解。 既然
难以准确表达,那我们就取
中容易表达的子集
来进行证明。 我们不证明
成立,而是在
中寻找某些元素,使得
不成立。 下近似思想非常朴素,也行之有效。

举例来说,在程序分析中,我们可以用如下的方式进行上近似和下近似。 以下表示三个具有子集关系的集合。 中间的实线集合表示程序运行的实际状态空间

,例如,实际的代码为一个三目运算
i = c ? 1 : 2,那么实际的程序状态集合为
。 最外层的双线集合表示程序实际状态空间的上近似
,我们使用了非确定赋值(Havoc)
i = *,表示 i可能被赋值为任意整数,那么上近似的集合为
。 最内层的虚线集合表示程序实际状态空间的下近似
,我们可以任取三目运算的某一种情况,比如
i = 1。 显然,我们有
       _-=====================-_         
      //                               
     //________________                
    |_ _ _ _                          
    | i = 1  i = c ?   |  i = * ||     
    |_ _ _ _/     1 : 2 |        ||     
     ________________/         //     
                              //      
       -======================-/       

最后,我们重申一点,上近似用于证明,而下近似用于证伪(找错,Bug Finding)。 对于一次验证过程,我们只能使用这两者之一。 同时使用上近似和下近似,会导致证明不正确,找错也可能找到伪反例,可谓是“既丢了西瓜,也丢了芝麻”。 希望读者在看待程序验证问题时牢记这一点,活用上近似和下近似思想。

断言语义

在程序验证简介这一篇中,我们简单地讲述了assertassume的意思。 在这一小节,我们更加具体地讲一下。

Assertion

我们使用assert来表示我们需要验证的属性。 在进行程序验证时,我们需要证明,对于所有满足断言之前程序本身形成的约束的情况,断言都要成立。 我们使用

表示程序变量,使用谓词
表示断言之前程序本身的约束,使用谓词
表示断言对应的谓词。 那么,进行程序验证时,我们需要证明
是有效的(永真的)。 我们以如下的程序为例进行说明。
int addone(int a) {
  int b = a + 1;
  assert(b > 1);
  return b;
}

在这个程序中,断言为

。 断言之前程序本身形成的约束是
。 那么我们需要证明如下公式有效。 通过SMT求解我们可以发现在变量
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断言成立与否,我们很难看到程序立刻断定断言不成例。 事实上,我们会在大脑中尝试逐步执行循环,并记录下运行时变量ij的值。 ij初始值为(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满足时,才会被执行,所以它的守卫是
。 在以上的SSA代码中,我们给每个条件谓词设定一个别名,
j >= 0的别名便是 g0。 我们将每条语句的守卫都标注在语句后的注释中。 我们称这种SSA为 带守卫的SSA(Guarded SSA)。 带守卫的SSA由于没有控制流结构,语句之间也没有顺序关系,所以可以直接使用一个集合来描述。 这样的SSA形式在进行分析处理时,会比较方便,效率也会高一些。

我们再解释一下SSA最后一行的断言的守卫!(g0 && !s0)的含义。 先看断言无法到达的情况,它要求进入循环执行一次,且assume表达式值为假,从而程序的执行被阻塞,无法到达断言。 这一情况对应的条件为

。 那么,对其取非就得到了断言可达的条件
,也就是断言的守卫。

我们可以将以上SSA表达式直接转为SMT公式。 其中带下划线的部分表示断言的可达性,只有满足断言可达后,我们才要求断言的表达式要成立。

我们可以直接将以上SMT表达式交给求解器求解,但我们在介绍SMT求解时曾提到过,求解带有全称量词的逻辑公式效率是非常低的。 同时,我们也介绍了使用对偶性来消除全称量词的方法。 证明以上公式有效等价于证明它取非后不可满足,同时,寻找一组使以上公式不成立的解等价于寻找一组使它取非后可满足的解。 对于形如

的公式,取非后可以做以下等价化简,其中第一步我们将全称量词转为存在量词,而最后一步我们消去了存在量词:

我们可以将无量词公式

直接交给SMT求解器求解。 若SMT求解器返回结果为
不可满足(UNSAT),那么原公式
是有效的(永真的)。 这代表
当前程序(对应循环展开
次)的属性(断言)成立。 那么实际上相当于我们
证明了循环执行
次内,属性都不会出错
。 若SMT求解器返回 可满足(SAT)以及给出了一组可满足的解
,那么原公式
上的值为假。 这代表
原程序存在一条实际的错误路径,而在这条错误路径上循环会执行不多于
次。 而且,我们可以通过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,并返回了一组

的可满足解释。 实际上
就对应了程序中变量的初始值。
倘若例子程序有未确定的输入变量,那么SMT求解器返回的解释中,这些输入变量也会有值。我们可以使用这些值作为输入,运行程序,触发断言被违反。求解的结果说明,例子程序的循环执行3次将触发断言被违背。 至此,我们使用有界程序验证方法,成功地找到了一条错误路径。

证明正确性

实事上,只要展开次数足够,达到了程序中循环运行次数的上界,有界程序验证方法就可以用来证明程序的正确性。 但是,由于展开的次数

一般是一个常数,而程序中循环运行的次数一般和程序的输入相关。 对于某些输入,循环运行的次数可能会非常大,这时候,即使我们展开循环成千上万次,也仍然无法证明程序的正确性。 所以,
有界程序验证方法几乎不会被用来证明带有未定次数循环的程序的正确性。 但是,作为一种程序验证的方法,我们还是顺带讲一下。

在上面的例子中,我们已经使用有界程序验证,展开循环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)方法。 因为一个程序有错,那么就肯定存在至少一条确定的错误路径,而在这条错误路径上,循环的执行次数肯定是有限的。 我们假定在循环在这条路径上执行

次,那么只要展开循环的次数
大于
,有界程序验证工具就能找出这个错误。 在参加程序验证大赛时,CBMC按照大致如下的
值序列来对程序进行展开验证:
。 也就是说,只要不超时,循环执行2000次以内的错误,CBMC都能准确地找到。 这种增量式的找错方法决定了CBMC在证伪方面的高效。 目前,CBMC的开发者,已经成立了公司,使用CBMC为商业程序提供测试样例生成的服务。 这也是有界程序验证方法强大的体现。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值