c-free5.0 程序代码正确 结果运行程序错误_科普 - 程序验证(1)

本文深入浅出地介绍了程序验证的概念,通过实例展示如何将源代码、断言和逻辑公式相结合,验证程序的正确性。涉及动态与静态方法的区别,以及SSA、逻辑公式和循环不变式在验证中的运用。重点讲解了验证算法和工具如KLEE、Coq和逻辑求解在验证过程中的作用。
摘要由CSDN通过智能技术生成

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

1.1 背景

在软件工程领域,用于确保程序正确性的手段有很多,大体可分为两类:动态方法静态方法。动态方法需要将软件源码编译,再通过特定的输入考察软件的运行结果。动态方法中使用得比较广泛的是软件测试,有手动测试(手写Test Case)、自动化测试(Fuzzing Test)等。动态方法效率高,错误定位准确,但应用场合受限,它要求程序必须能运行起来。而且,其最大的问题在于,动态方法只能证明运行过的部分程序没有问题,不能证明整个程序不存在错误。静态方法则是基于程序源代码进行,不需要编译运行,所以应用场景更加广泛。常用的静态方法有静态分析、符号执行、定理证明、程序验证等等。不同的静态方法各有优劣,其应用门槛也比较高,一般用于对程序正确性要求较高的场景。

常规的静态分析方法比较轻量级,适用于大规模代码,但比较大的问题是较高比例的误报(False Positive)。所以静态分析报出的结果,需要逐一人工排查,从而导致耗费大量的人力资源。静态分析引擎比较典型的是Coverity,目前已经实现较大规模商用。符号执行可以看作是更加准确的测试方法,它通过符号值来静态“执行”程序,积累路径条件(Path Condition),直到到达目标位置,再对路径条件进行约束求解,判断目标位置的可达性。由于需要使用约束求解,而且对循环不友好,所以符号执行方法难以大规模应用。符号执行比较典型的工具是KLEE。定理证明方法是使用高阶逻辑(High Order Logic,HOL)对程序以及其需要满足的性质进行建模,然后使用机器辅助证明的方法,一步一步证明程序能够满足要求的性质。定理证明方法主要的缺陷是需要大量的专业人工参与,而且对软件的更新迭代不友好。辅助定理证明的典型工具是Coq。

程序验证旨在自动化地证明程序的正确性,即程序在运行的过程中不会出错,并且程序的功能能够满足。程序验证的优点是能够自动化地进行程序的正确性证明。但其缺点也很显著,其一是对高阶功能属性不友好,一般用于证明一些低阶属性,比如“除0错误”,“指针的use after free”,“数组越界或缓冲区溢出”等等。其二是程序验证一般也依赖于约束求解,所以也难以大规模地应用。但随着验证算法和约束求解引擎的不断进步,程序验证的可用性将变得越来越好。

1.2 例子

虽然是科普性质的文章,但在接下来的内容中,我们假定读者对基础的数理逻辑有一定的了解,如:命题逻辑以及谓词逻辑。如果读者不了解这部分内容,建议先寻找适当的资料学习。

我们将用几个非常简单直观的小例子来说明程序验证。我们希望借助这几个例子,让读者对以下几个问题产生比较直观的认识:

  • 程序验证的目的;
  • 程序验证的基本要素;
  • 程序验证的基本方法思路;

在以下的几个例子中,我们将使用以下的步骤进行程序验证:

  1. 给出程序源码;
  2. 标注我们需要验证的属性;
  3. 将我们需要验证的问题转换为逻辑公式;
  4. 证明逻辑公式的正确性;

交换两个整数的值

如下的C语言程序,交换了整数ab的值。我们在程序的最后一行,使用C语言中的断言(assert)来描述这一功能属性

int a0 = a;
int b0 = b;
a = a + b;
b = a - b;
a = a - b;
assert(a == b0 && b == a0);

在该程序中,我们实际上需要证明,对于任意的ab,经过执行程序,最后一行的assert表达式都成立。我们遇到的第一个问题就是如何用数学公式来表达a = a + b。这也是我们初学程序编写时遇到的第一个问题,即“等号”和“赋值”的区别。针对这个问题,我们将程序转换为静态单赋值形式(Static Single Assignment Form,SSA),即每次对某个变量进行写操作时,我们都引入一个新的别名,来代替这个变量。在后续的程序中使用这个变量时,我们会用这个别名。例如,我们会把a = a + b转化为a1 = a + b。而在b = a - b中需要使用之前的a,所以也会被转为b1 = a1 - b。以下是对整个程序的SSA转化:

// SSA
int a0 = a;
int b0 = b;
a1 = a + b;
b1 = a1 - b;
a2 = a1 - b1;
assert(a2 == b0 && b1 == a0);

借助于这样的方法,可以将“等号”和“赋值”不加区分地等同起来。从而,我们可以将SSA程序直接转化为逻辑公式:

公式的蕴含号(

)之前部分(
)是对输入以及程序本身的描述(注:本例中对输入没有要求),之后部分(
)是对要求的属性的描述。**该公式描述了,对于所有的变量,只要其值满足对输入的要求和程序的执行逻辑(蕴含运算的前提
为真),那么在程序运行后,要求的属性也应该被满足(蕴含运算的结论
为真)。**因此,只要以上逻辑公式是永真的,那我们所要验证的属性也就是成立的。

我们可以通过简单的代入消去,将以上公式简化为:

再执行一步算数简化,可得:

这个公式等同于

,显然是永真的。所以我们要验证的功能属性是成立的。

从以上的过程中,我们可以发现,程序源码到SSA是可以自动化进行的;从SSA到逻辑公式也是可以自动化进行的;而证明逻辑公式的正确性,也可以借助于约束求解器来自动进行。所以,在程序验证中,我们只要提供程序源码和描述属性的断言,就可以通过自动化的方法,证明属性成立。

按大小排列两个整数

在这个例子中,我们引入了if条件分支语句,我们将看到程序验证如何处理条件语句。如下的C语言程序,会将a变为ab中较大的值。我们直接使用最后一行的断言来描述这一功能属性。

if(a < b) {
  a = a + b;
  b = a - b;
  a = a - b;
}
assert(a >= b);

以上程序实际上有两条路径,即if语句的a < ba >= b两个分支,其中a < b分支的SSA为:

// SSA 1
assume(a < b);
a1 = a + b;
b1 = a1 - b;
a2 = a1 - b1;
assert(a2 >= b1);

这里我们将a < b的条件用assume(a < b)来表示,其语义为假定条件a < b成立,再执行后面的代码。转化为逻辑公式可得:

代入消去和化简后,我们可以得到:

这个式子也显然是永真的,所以在a < b分支下属性成立。而在如下的a >= b分支,属性也显然是成立的,所以,我们证明了该程序的功能正确性。

// SSA 2
assume(a >= b);
assert(a >= b);

将某个数增加到N

我们已经看到了程序验证中,对顺序程序,以及带分支程序的处理。在接下来的例子中,我们将演示程序验证对带循环程序的处理。以下例子将一个小于N的整数i增加到N,我们直接使用断言来表达这一属性。

if (i < N) {
  while(i < N) {
    i = i + 1;
  }
  assert(i == N);
}

对带循环的程序而言,最大的问题在于,我们无法直接将循环转写为逻辑公式,但循环恰恰是一般程序的重要组成部分。所以,首先我们要解决循环。注意到在循环入口处,i是小于N的。同时,在循环执行的过程中,i的值始终不会超过N,即在循环头位置,我们有:

。我们将这个式子称为
循环不变式(Loop Invariant),即在 任意多次循环执行后,这个式子始终是成立的。有了这个信息,我们就可以使用循环不变式,来替代表示循环执行的效果,即循环的执行对程序变量的值的影响。循环不变式意味着,不论循环执行多少次,对程序变量的值所造成的影响,都会在循环不变式所描述的范围内。

需要注意的是,循环不变式还有另一层含义,即从循环之前的代码到循环头,循环不变式也要成立。这实际上可以看作是循环执行0次(即循环还未执行)的情况。所以,循环不变式也包含了循环头之前的代码的执行,对程序变量的值产生的影响。

assume(i <= N); // Invariant
assume(i >= N); // Jump out of the loop
assert(i == N);

所以,我们使用循环不变式来替代循环头之前的代码以及循环本身,从而无需考虑进入循环体的情况,仅考虑跳出循环的情况。如上所示,我们移除循环以及其之前的代码,代之以循环不变式和跳出循环的情况(有注释的两行assume)。接着,我们可以将其转化为逻辑公式可得:

很显然,这个逻辑公式是永真的,我们要验证的属性成立。

证明数组不越界

同样,以上使用循环不变式的方法,也可以用来证明一些其他的属性,比如无数组越界。如下的例子中,我们需要验证,i的值在数组a下标范围[0, 10)内。

int a[10];
int i = 0;
while(i < 10) { // Invariant 0 <= i <= 10
  assert(0 <= i < 10);
  a[i] = 0;
  i = i + 1;
}

同上一个例子,我们可以得到一个循环不变式:

。使用这个循环不变式,我们可以证明以下逻辑公式永真,即数组每次访问时,下标
i的值都在 [0, 10)内。

1.3 小结

通过以上的几个例子,相信大家对程序验证已经有了比较初步和直观的认识。首先,程序验证的目的是证明程序的正确性,这既包括程序不会有运行时错误,也包括程序功能的正确性。其次,程序验证的基本要素是程序的源码,要验证的属性,这是实现自动化验证的前提。同时,在必要时,我们需要有循环不变式。最后,程序验证的基本思路是,在给定源码和属性的基础上,通过验证算法,将我们需要验证的问题转化为逻辑公式,再证明逻辑公式的正确性。验证算法是程序验证所研究的核心内容,对程序验证的效率,有着至关重要的影响。以上几个例子展示了最基础的验证算法,它直接简单粗暴地将程序转化为逻辑公式进行求解。事实上,还有很多巧妙的验证算法,比如:谓词抽象(Predicate Abstraction)、路径抽象(Path Abstraction)、返利制导的抽象精化(Counter-example-guided Abstraction and Refinement,CEGAR)等等。这些验证算法基本思想都很简洁巧妙,值得学习和了解。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值