ACSL 及Frama-C验证工具简介(二)

本文详细介绍了ACSL注释和Frama-C工具如何用于C程序验证,通过实例演示了如何验证包含ACSL的C项目。文章阐述了如何使用WP验证函数合约,确保函数正确性,并展示了如何进行模块化验证。此外,还讨论了ACSL的合约完整性、库函数支持、处理复杂数据结构的方法,以及循环和副作用的处理。

实例演示

我们已经介绍了 ACSL 和 WP 的基本思想,下面我们将结合实例演示如何使用 WP 来验证包含 ACSL 注释的 C 项目。


示例代码介绍

我们的示例代码围绕寻找数组中最大值的问题展开。这个项目包含三个文件。项目的入口 main 函数在 max_seq_main.c 文件中。该 main 函数调用一个名为 max_seq 的函数计算给定数组中的最大值。此 max_seq 函数的定义位于头文件 max_seq.h 中。而 max_seq 函数的实现则在 max_seq.c 文件中。我们力图通过这种组织方式模拟一个真实项目的结构,max_seq_main.c 文件代表上层逻辑,max_seq.c 代表底层实现,头文件则是接口。三个文件的内容如下:

/* max_seq_main.c */
#include "max_seq.h"

int main() {
    int array[10] = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3};
    int m = max_seq(array, 10);
    //@ assert \exists int i; m == array[i];
    //@ assert \forall int i; 0 <= i < 10 ==> m >= array[i];
    return 0;
}
/* max_seq.h */
/*@ requires n > 0 && \valid(p + (0..n-1));
    ensures \forall int i; 0 <= i <= n−1 ==> \result >= p[i];
    ensures \exists int e; 0 <= e <= n−1 && \result == p[e];
*/
int max_seq(int* p, int n);
/* max_seq.c */
#include "max_seq.h"

int max_seq(int* p, int n) {
    int res = *p;
    //@ ghost int e = 0;
    /*@ loop invariant \forall integer j;
            0 <= j < i ==> res >= p[j];
        loop invariant \valid(p + e) && p[e] == res;
        loop invariant about_i: 0 <= i <= n;
        loop invariant 0 <= e < n;
        loop invariant p == \at(p, Pre) && n == \at(n, Pre);
        loop invariant \valid(p + (0..n-1));
    */
    for(int i = 0; i < n; i++) {
        if (res < p[i]) {
            res = p[i];
            //@ ghost e = i;
        }
    }
    return res;
}

在 max_seq_main.c 文件中,main 函数首先创建了一个数组,然后调用 max_seq 函数寻找数组中的最大值并存储在变量 m 中。为了保证我们确实找到了数组中最大值,我们做出了两个断言(assert)。第一个断言声明 m 等于数组中的某个元素(m 存在于数组中);第二个断言声明 m 大于等于数组中所有的元素(m 的值最大)。我们将在演示环节看到 WP 能够帮助我们验证这两条性质。

正如我们所见,max_seq_main.c 文件包含了 max_seq.h 头文件,因为 max_seq 函数的声明(或曰接口)在该头文件内。与 max_seq 函数的声明一起,max_seq.h 头文件还包含了该函数的合约。合约的前置条件部分要求了(requires)数组的长度 n 大于 0;且从数组头 p 开始的 n 个内存位置均可合法访问。合约的后置条件对函数的功能正确性做出了保证(ensures)。其保证的内容和上面讨论的 main 函数中的断言形式类似。不过作为函数的合约,此处的表达是基于函数的形式参数,更具一般性。也是这个原因,当我们验证过 max_seq 函数的合约成立之后,再去验证 main 函数中的断言就会变得非常容易。

相对困难的是验证 max_seq 函数的实现确实符合合约。从 max_seq.c 文件中可见,max_seq 函数的实现并不复杂,它先把变量 res 赋值为 p[0],然后从头(p[0])到尾(p[n-1])检查数组中元素的值,如果发现更大的,就更新 res。但要 WP 验证这个实现符合合约,一些提示是必须的。提示包括幽灵变量(ghost)e 的引入和 6 个循环不变式(loop invariant)。直观上来讲,e 是一个辅助变量,记录了已搜索范围内最大元素的坐标(相对数组头地址的偏移)。第一条循环不变式指出在已搜索的范围内,没有比 res 更大的值。第二条不变式指出这个 res 的值不是凭空出现的,它等于数组中一个合法的元素 p[e]。随后两条不变式给出了搜索范围 i 和最大元素坐标 e 的取值范围。

最后两条不变式看似和合约的前置条件重复,但却是不可或缺的。这是因为 WP 会认为每次更新后的内存状态与之前的状态都是不同的,所以关于某些数据的性质(比如此处数组能够被合法访问的性质)需要重新建立。因为循环操作的复杂性,某些性质不能被 WP(在合理时间内)自动重建。此时一些提示就是必要的了。此例中倒数第二条不变式指出了 p 和 n 不会被循环体修改;进而如最后一条不变式指出,无论循环次数,从数组头 p 开始的 n 个内存位置总是可以合法访问的。

具体来说,按照 ACSL 的规定,WP 会给每个内存状态一个标签,Pre 是一个内置的标签,指代前一个内存状态。而 \at(p, Pre) 即指在上一个内存状态中 p 的值。我们也可以 \old(v) 指代变量 v 在函数调用之初的值。


实例演示

了解了示例代码之后,我们就可以在 frama-c 中用 WP 对他们进行验证了。在命令行中令 frama-c 打开项目所有相关 C 文件,即可加载整个项目:

frama-c-gui max_seq_main.c max_seq.c

注:此处不需指明头文件的文件名,frama-c 会自动分析并包含必要的头文件。

打开后的项目如图:

图:演示过程1

我们可以看到区域1(参见“图:frama-c GUI”)列出了项目的结构。此时我们先选择 max_seq 函数,在区域2可以看到原本在头文件中给出的函数合约已被自动加载到了 max_seq 之上。待证明的目标左侧的图标为蓝色圆环,表示他们都处于待证明状态。其中第一个目标稍有特殊,它代表了函数对调用环境的要求,我们稍后讨论。第二和第三个目标是函数后置条件,其它目标是为了证明这两个后置条件而存在的辅助性质。我们从第二个目标开始逐条单击右键使用 WP 证明。

图:演示过程2

上图展示了证明部分目标后的结果。已证明的目标状态为绿橙各半的圆球,代表他们自身已经通过验证,但尚有它们所依赖的目标没有验证。

图:演示过程3

上图展示了完成所有依赖链条中的证明目标后,所有目标变成绿色状态,证明完成。

此时我们切换到 main 函数。证明两个关于函数执行结果的断言。因为这两个断言可以直接从函数合约导出,所以证明过程非常迅速。

图:演示过程4

然而我们应该注意到,main 函数中两个断言的上面一行有一个自动生成的证明目标,它对应了函数合约的前置条件,亦即,main 函数要安全调用 max_seq 函数,就要保证 max_seq 的前置条件成立。但若要解决此证明目标,我们其实不应在 main 函数中操作,因为在一个真实的项目中,对 max_seq 的调用可能不只此一处。所以我们回到 max_seq 函数,对着他的前置条件(requires)单击右键,使用 WP 证明。WP 会检查项目中所有对 max_seq 函数的调用是否满足此前置条件。如果检查通过,此前置条件和所有调用者都会自动变绿。

图:演示过程5


模块化验证

上一节我们演示了自下而上保证整个项目正确性的验证过程。但正如前文所说,frama-c 和 WP 支持模块化验证。假设我们不能得到 max_seq 函数的源代码,而只知道它满足某种函数合约,在这种情况下,我们可以仅仅通过该合约对 main 函数中的性质进行验证。

我们可以在命令行键入如下命令:

frama-c-gui max_seq_main.c

此时在区域1的项目结构图中可以看到,只有 main 文件和它使用到的 max_seq.h 头文件得到了加载。如下图所示:

选中 max_seq.h 文件,可以看到因为得不到 max_seq 函数的具体实现,其合约中的后置条件 frama-c 无从验证,此时它们的状态是绿蓝各半的圆球,代表“Considered Valid”。但这并不影响将它们用于 main 函数中性质的证明。像上次一样,我们可以在 main 函数中基于这个合约证明我们关心的性质。我们可以随后单独证明 max_seq 的实现符合合约,这样便能保证项目整体的正确性。

基于这种方法,我们可以实现对大型项目的模块化证明;或者是在功能模块的实现尚不能获得情况下仅仅使用其合约就可以开始上层逻辑的设计和验证。


ACSL 补遗


系统化描述函数合约

正文中我们已经介绍了 ACSL 最重要的的核心理念之一:函数合约。但其实关于函数合约还有一些问题没有讨论到。比如,对于一个给定的函数合约,我们如何判定它完整描述了函数所有可能的行为?

例如对于一个名为 max_ptr() 的函数,我们期望它的功能是接受两个整形指针,比较两个内存位置存放的值的大小,必要的时候交换两个值,从而保证第二个指针指向较大的值。我们可以撰写如下函数合约:

/*@ requires \valid(p) && \valid(q); 
    ensures *p<=*q; 
*/ 
void max_ptr(int *p, int* q); 

该合约在前置条件中要求输入为两个合法指针,并保证函数结束时第二个指针(*q)指向的值更大。这个合约看似刻画了我们想要的性质,但它却并不完整。它可能允许如下所示“偷懒”的函数实现:

void max_ptr(int*p, int*q) {*p = INT_MIN;}

这个实现只是强行给 *p 赋以 int 类型的最小值,但却足以骗过上面的函数合约。

针对这个功能,一个较好的函数合约应该包括更多的信息,以对函数返回时 *p 和 *q 的值进行约束,比如:

/*@ requires \valid(p) && \valid(q); 
    ensures *p<=*q; 
    ensures (*p == \old(*p) && *q == \old(*q)) ||
             (*p == \old(*q) && *q == \old(*p));
*/ 
void max_ptr(int *p, int* q); 

注:对于一个指针变量 x 而言,\old(*x) 和 *\old(x) 的含义是不同的。

上面的合约即可以完整地(completed)刻画我们期望的函数行为,并只允许类似下面所示合格的函数实现通过验证:

void max_ptr(int*p, int*q) { 
    if (*p > *q) { 
        int tmp = *p; 
        *p = *q;
        *q = tmp; 
    } 
} 

对于这样一个简单的功能,我们可以很容易发现问,修改并得到一个完整的合约。但即便如此,第二条后置条件还是显得有些晦涩。可以预见对于更复杂的功能,合约的完整性无疑会变得更加复杂。

针对这个问题,ACSL 提供了一种更系统化的合约撰写模式,也就是使用行为(behavior)来组织函数合约。每个行为着重刻画函数在特定情况下(assumes)将会表现出来的效果(ensures)。我们 max_ptr 函数的合约可以用按照行为更清晰的组织如下:

/*@ requires \valid(p) && \valid(q);
    ensures *p <= *q;
    behavior p_minimum:
        assumes *p < *q;
        ensures *p == \old(*p) && *q == \old(*q);
    behavior q_minimum:
        assumes *p >= *q;
        ensures *p == \old(*q) && *q == \old(*p);
    complete behaviors p_minimum, q_minimum;
    disjoint behaviors p_minimum, q_minimum;
*/
void max_ptr(int* p, int*q);

我们可以看到,新合约清晰地刻画了针对 *p 和 *q 不同的初始情况,函数会有两种不同的行为(由名为 p_minimum 和 q_minimum 的两个 behaviors 刻画),分别是保持 *p 和 *q 各自原来的值和交换它们的值。

行为的定义是很自由的,默认情况并不要求它们处理的情况互斥(disjoint),也不要求它们覆盖了所有可能的情况(complete)。但如果需要,我们也可以做出特别的要求。例如在上述合约中,我们特别声明了我们定义的两个行为是完整和互斥的,亦即我们要求函数能够正确处理所有正常的输入。如此,ACSL 和验证工具就能够帮助我们检查函数的实现是否确实满足功能完整性的要求。


库函数支持

一个实际的工程从来不是孤立的,如下面例子所示,即便是一个简单的 Hello World 程序也需要调用 stdio.h 库:

/* hello.c */
#include <stdio.h>

int main() {
   printf("Hello, World!\n");
   return 0;
}

如此一来,项目的正确性便依赖于其中各个模块和系统功能的配合。Frama-c 工具集提供了系统库函数的形式化合约,从而使得项目的正确性/安全性得以在一个真实的系统环境中全面验证。如下图所示,在 frama-c 中加载 hello.c 文件,即可看到除了该文件本身,frama-c 还加载了全数相关的系统库文件及其合约,点击即可查看某个库文件/库函数的具体定义:

图:随用户项目自动加载的库文件


处理复杂数据结构

C 语言支持递归定义的复杂数据结构,一个典型的例子就是链表:

typedef struct _list { int element; struct _list* next; } list; 

倘若一个规范语言不能处理类似的特性,其实用性将大打折扣。ACSL 提供了丰富的功能来应对复杂的数据结构。例如,我们可以使用逻辑函数(logic function)给出关于链表数据结构中可达性的定义:

/*@ inductive reachable{L} (list* root, list* node) {
        case root_reachable{L}:
            \forall list* root;
            reachable(root,root);
        case next_reachable{L}:
            \forall list* root, *node;
            \valid(root) ==> reachable((*root).next, node) ==>
                reachable(root,node);
} */

具体来讲,reachable 是一个使用归纳法(递归)定义的谓词(亦即它的结果非真即假),它刻画了在何种情况下我们可以声称从链表中的某个节点(root) 到另一个节点(node)是可达的。我们可以看到定义分为两种情况:在基础情况,两个相同的节点是可达的;在递归情况,我们检查从当前开始节点的下一个节点是否能够到达目标节点。

注:reachable 函数名后面的 {L} 标注代表调用这个函数的时候,可以传入一个标签参数,用以指代某个之前的程序状态(参见前文)。尽管在我们的定义中,这个标签并没有用到。

基于 reachable 函数,我们还可以继续扩展,定义更丰富的内容。比如我们可以定义另外一个谓词,用来刻画一个链表是否有限(没有环),亦即它是否可以到达终止节点 NULL:

/*@ predicate finite{L}(list* root) = reachable(root,\null); */

逻辑函数的定义还可以以公理集的形式给出。例如在下面,我们定义一个逻辑函数来计算链表的长度:

/*@ axiomatic Length {
    logic integer length{L}(list* l);
    axiom length_nil{L}: length(\null) == 0;
    axiom length_cons{L}:
        \forall list* l, integer n;
        finite(l) && \valid(l) ==> length(l) == length((*l).next) + 1;
} */

我们可以看到,函数 length 的定义被安置在名为 Length 的公理集内,分两种情况进行定义。首先 length_nil 的情况表明,从 NULL 开始的链表的长度为 0;随后 length_cons 情况表示在链表有限和当前节点有效(非 NULL)的情况下,从当前节点开始计算的链表长度等于从下一个节点开始计算的长度加 1。

此时,我们已经可以开始对包含链表的程序进行一些简单的验证。比如,下面程序段中的断言能够轻松被 WP 证明成立:

/*@ requires \valid(root);
    requires finite(root);
*/
void list_function(list* root) {
    list* p = (*root).next;
    //@ assert length(root) > length(p);
}

其它

ACSL 提供了丰富的关键词,使用户能够以适合自己的风格灵活的撰写函数合约。还记得在上文中我们用比较基础的语言定义了 max_seq 函数的合约:

/* max_seq.h */
/*@ requires n > 0 && \valid(p + (0..n-1));
    ensures \forall int i; 0 <= i <= n−1 ==> \result >= p[i];
    ensures \exists int e; 0 <= e <= n−1 && \result == p[e];
*/
int max_seq(int* p, int n);

这个合约已经非常完整,但分列两条的后置条件未免显得不太简洁。我们可以利用 ACSL 的一些内建关键词将这个合约(重点是后置条件)简化如下:

/*@ requires n > 0 &&\valid(p + (0..n−1));
    ensures \result == \max(0,n−1,\lambda integer i; p[i]);
*/
int max_seq(int* p, int n); 

我们可以看到合约的后置条件被简化为了一条。它使用了内置的 \max() 函数,简单的声明了函数返回的结果应该是数组中所有元素构成的集合中的最大值。而获得数组中所有元素集合的过程需要将从 0 到 n-1 这 n 个下标代入目标数组 p 。这个过程是由一个 Lambda 表达式刻画的,由内置的 \lambda 关键词定义。

ACSL 类似的关键词/内置函数还有很多,比如 \min(), \sum() … 等。

接下来我们关注下面一个函数及其合约:

/*@ terminates i == 0;
    assigns \nothing;
*/
void loop_function(int i) { while(i); }

这个函数包含了一个没有任何内容的 while 循环。可以预见,除非函数获得的参数 i 是 0,否则它不会终止。合约的第一个条款就是对这一行为进行了形式化的约束。一般地,当我们说“terminates condition;” 的时候,是指我们希望如果 condition 满足,则函数必然终止。如果采用简化的书写方式 “terminates;” 则代表“terminates \true;” 亦即我们期望函数总是终止的。

注:遗憾的是目前 WP 并不支持对终止性的自动证明。

上面例子的合约里还提到了 “assigns \nothing;”。它代表了函数不会修改任何变量,籍此我们可以推断函数没有副作用。如果函数确实需要修改某些变量,我们可以用类似 “assigns var1, var2, ... ;” 的表达来刻画函数作用的具体范围。

另外在处理带有循环的程序的时候,我们也可以在循环的上方标注 “loop assigns …;”,这个技巧有些时候能够极大帮助循环不变式的证明。


参考文献

为了适应现实项目的需要,ACSL 和 frama-c/WP 提供了极为丰富而庞杂的特性。在如此短的篇幅中我们不能系统地涵盖它们所有的特性。于是我们着重于应用,结合实例展示了 ACSL 和 frama-c/WP 最重要的部分功能。

关于 ACSL 的完整介绍,我们推荐读者参考其官方手册: https://frama-c.com/download/frama-c-acsl-implementation.pdf

关于 frama-c 和它的 WP 插件,其手册也能够在 frama-c 官方网站获得: https://frama-c.com/download/frama-c-wp-manual.pdf

此外我们推荐由一位长期使用 WP 和 ACSL 进行研究工作的业者所撰写的教程: https://allan-blanchard.fr/publis/frama-c-wp-tutorial-en.pdf

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值