Introduction to C program proof with Frama-C and its WP plugin(十一)

本内容为2020年7月1号出版的《Introduction to C program proof with Frama-C and its WP plugin》中第5.3~5.4节的翻译内容

5. ACSL属性

5.3 逻辑函数

逻辑函数用于描述只能在规范中使用的函数。它首先允许我们将这些规范分解,其次允许我们在integerreal上定义一些操作,并保证这些操作不会溢出,因为它们涉及数学类型。

类似于谓词,它们可以在参数中接收不同的标签和值。

5.3.1 语法

要定义一个逻辑函数,语法如下:

/*@
	logic return_type my_function{ Label0, ..., LabelN }( type0 arg0, ..., typeN argN ) =
		formula using the arguments ;
*/

我们可以例如使用逻辑函数来定义一个数学线性函数

/*@
	logic integer ax_b(integer a, integer x, integer b) =
		a * x + b;
*/

此方法可用于验证以下函数的源代码:

/*@
	assigns \nothing ;
	ensures \result == ax_b(3,x,4);
*/
int function(int x){
	return 3*x + 4;
}

在这里插入图片描述
此代码确实已得到证明,但似乎仍有可能出现一些运行时错误。我们可以在前置条件中添加一些约束,以便结果可以存储到C整数中:

/*@
	requires INT_MIN <= ax_b(3, x, 4) <= INT_MAX;
	assigns \nothing ;
	ensures \result == ax_b(3,x,4);
*/
int function(int x){
	return 3*x + 4;
}

某些运行时错误仍然是可能的。确实,尽管我们的逻辑函数为x提供的界限适用于整个计算过程,但它并未对中间计算过程中获得的具体值作出任何说明。例如,在此情况下,3 * x + 4不小于INT_MIN的事实并不能保证3 * x也是如此。我们可以设想两种解决这个问题的方法,而这种选择的指导应当基于该函数将被使用的项目。

要么我们进一步限制输入值:

/*@
	requires INT_MIN <= 3*x ;
	requires INT_MIN <= ax_b(3, x, 4) <= INT_MAX;
	assigns \nothing ;
	ensures \result == ax_b(3,x,4);
*/ 
int restricted(int x){
	return 3*x + 4;
}

或者我们可以修改源代码,以消除溢出的风险:

/*@
	requires INT_MIN <= ax_b(3, x, 4) <= INT_MAX;
	assigns \nothing;
	ensures \result == ax_b(3,x,4);
*/
int function_modified(int x){
	if(x > 0)
		return 3 * x + 4;
	else
		return 3 * (x + 2) - 2;
}

请注意,在规范中,计算是使用数学整数进行的。因此,在使用ax_b函数时,我们无需担心某些溢出风险:

void mathematical_example(void){
	//@ assert ax_b(42, INT_MAX, 1) < ax_b(70, INT_MAX, 1) ;
}

WP能够正确验证,不会产生任何与溢出相关的报警。
在这里插入图片描述

5.3.2 递归函数与逻辑函数的局限

逻辑函数(以及谓词)可以被递归定义。然而,这种方式在使用于程序证明时会很快显示出一些局限性。实际上,当自动求解器在推导这些逻辑属性时,如果遇到这样的函数,必须对其进行求值。SMT求解器并不适用于高效完成这项任务,因此通常成本高昂,导致证明解析过程过于冗长,最终可能会超时。

我们可以通过一个具体的例子来说明阶乘函数,使用逻辑和C语言:

/*@
	logic integer factorial(integer n) = (n <= 0) ? 1 : n * factorial(n-1);
*/

/*@
	assigns \nothing ;
	ensures \result == factorial(n) ;
*/
int facto(int n){
	if(n < 2) return 1 ;
	
	int res = 1 ;
	/*@ 
		loop invariant 2 <= i <= n+1 ;
		loop invariant res == factorial(i-1) ;
		loop assigns i, res ;
		loop variant n - i ;
	*/
	for(int i = 2 ; i <= n ; i++){
		res = res * i ;
	}
	return res ;
}

在不检查溢出的情况下,这个函数易于且快速证明。如果我们添加运行时错误检查,我们会发现乘法操作存在溢出的可能性。
int类型上,我们能够计算阶乘的最大值是12。如果再进一步,就会发生溢出。因此,我们可以添加这个前置条件:

/*@
	requires n <= 12 ;
	assigns \nothing ;
	ensures \result == factorial(n) ;
*/
int facto(int n){

如果我们要求对此输入进行证明,Alt-ergo可能会失败,而Z3则能在不到一秒的时间内计算出证明。原因在于,在这种情况下,Z3所采用的启发式方法认为,在函数评估上花费稍多一点时间是值得的。

逻辑函数可以递归地定义,但由于验证器需要执行求值或通过归纳“推理”(这两项任务它们的效率不高),没有更多帮助的情况下,我们很快会受到限制。这可能会限制程序证明的可能性,但我们将在后面看到如何解决这些问题。

5.3.3 练习

5.3.3.1 Distance

指定并证明以下程序:

int distance(int a, int b){
	if(a < b) return b - a ;
	else return a - b ;
}

为此,定义两个逻辑函数absdistance。使用这些函数来编写函数的规范。

5.3.3.2 Square

编写Square函数的函数体。指定并证明程序的正确性。使用Square逻辑函数。

int abs(int x){
	return (x < 0) ? -x : x ;
}

unsigned square(int x){

}

请注意变量的类型,并且不要过度限制函数的输入。此外,在验证是否存在运行时错误时,不要忘记提供选项-warn-unsigned-overflow-warn-unsigned-downcast

5.3.3.3 Iota

这是一个可能的iota函数实现:

#include <limits.h>
#include <stddef.h>

void iota(int* array, size_t len, int value){
	if(len){
		array[0] = value ;
		for(size_t i = 1 ; i < len ; i++){
			array[i] = array[i-1]+1 ;
		}
	}
}

编写一个逻辑函数,返回输入值加一的结果。证明在执行iota操作后,数组的第一个值为输入值,并且数组中的每个值都对应于其前一个值加一的结果(使用之前定义的逻辑函数)。

5.3.3.4 Vector add

在下面的程序中,vec_add函数将输入中的第二个向量加到第一个向量上。为show_the_difference函数编写一个契约,该契约表达了向量v1中每个值在之前条件和后置条件之间的差。为此,定义一个逻辑函数diff,该函数返回内存位置在标签L1处的值与标签L2处的值之间的差。

#include <stddef.h>
#include <limits.h>

/*@
	predicate unchanged{L1, L2}(int* ptr, integer a, integer b) =
		\forall integer i ; a <= i < b ==> \at(ptr[i], L1) == \at(ptr[i], L2) ;
*/

/*@
	requires \valid(v1 + (0 .. len-1));
	requires \valid_read(v2 + (0 .. len-1));
	requires \separated(v1 + (0 .. len-1), v2 + (0 .. len-1));
	requires
		\forall integer i ; 0 <= i < len ==> INT_MIN <= v1[i]+v2[i] <= INT_MAX ;

	assigns v1[0 .. len-1];

	ensures
		\forall integer i ; 0 <= i < len ==> v1[i] == \old(v1[i]) + v2[i] ;
	ensures
		\forall integer i ; 0 <= i < len ==> v2[i] == \old(v2[i]) ;
*/
void vec_add(int* v1, const int* v2, size_t len){
	/*@
		loop invariant 0 <= i <= len ;
		loop invariant
			\forall integer j ; 0 <= j < i ==> v1[j] == \at(v1[j], Pre) + v2[j] ;
		loop invariant unchanged{Pre, Here}(v1, i, len) ;
		loop assigns i, v1[0 .. len-1] ;
		loop variant len-i ;
	*/
	for(size_t i = 0 ; i < len ; ++i){
		v1[i] += v2[i] ;
	}
}

void show_the_difference(int* v1, const int* v2, size_t len){
	vec_add(v1, v2, len);
}

重新表达unchanged的谓词,使用您定义的逻辑函数。

5.3.3.5 The sum of the N first integers

以下函数计算前N个整数的总和。编写一个递归逻辑函数,返回前N个整数的总和,并为C函数编写规范,说明它计算的值与逻辑函数提供的值相同。

int sum_n(int n){
	if(n < 1) return 0 ;

	int res = 0 ;
	for(int i = 1 ; i <= n ; i++){
		res += i ;
	}
	return res ;
}

尝试验证运行时错误的缺失。整数溢出并非简单到可以轻易消除。然而,编写一个前置条件,该条件应足以证明函数(记住,前N个整数的和可以用一个非常简单的公式表示…)。这当然不足以直接证明溢出的缺失,但我们将在下一节中看到如何提供这样的信息。

5.4 引理

引理是关于谓词或函数的一般性质。这些性质可以通过自动或(更常见的是)交互式证明器在程序证明的其余部分之外独立证明。一旦完成此证明,它所陈述的信息就可以安全地用于简化其他更复杂证明中的推理,而无需再次证明。例如,如果我们陈述一个引理 L L L,它在任何情况下都表明 P ⇒ Q P \Rightarrow Q PQ,如果在另一个证明中的某个点,我们需要证明 Q Q Q并且我们知道 P P P,我们可以直接通过使用引理 L L L得出结论,而无需再次执行从 P P P Q Q Q的推理过程。

在上一节中,我们提到递归函数会使SMT求解器的证明变得更加困难。在这种情况下,引理可以帮到我们。我们可以自行编写需要归纳推理的证明,这些证明针对我们声明为引理的某些性质,而这些引理可以被SMT求解器高效地用于执行与我们程序相关的其他证明。

5.4.1 语法

此外,我们使用ACSL注释引入引理。语法如下:

/*@
	lemma name_of_the_lemma { Label0, ..., LabelN }:
		property ;
*/

这一次,我们想要表达的性质并不依赖于接收的参数(标签除外)。因此,我们将这些性质表示为对全称量化的变量的属性。例如,我们可以陈述这个引理,即使它很微不足道,但它确实是成立的:

/*@
	lemma lt_plus_lt:
		\forall integer i, j ; i < j ==> i+1 < j+1;
*/

这个证明可以通过WP进行。当然,该性质是通过仅使用Qed来证明的。

5.4.2 示例:线性函数的性质

我们可以回到线性函数,并表达它们的一些有趣特性:

/*@
	lemma ax_b_monotonic_neg:
		\forall integer a, b, i, j ;
			a < 0 ==> i <= j ==> ax_b(a, i, b) >= ax_b(a, j, b);

	lemma ax_b_monotonic_pos:
		\forall integer a, b, i, j ;
			a > 0 ==> i <= j ==> ax_b(a, i, b) <= ax_b(a, j, b);

	lemma ax_b_monotonic_nul:
		\forall integer a, b, i, j ;
			a == 0 ==> ax_b(a, i, b) == ax_b(a, j, b);
*/

对于这些证明,Alt-ergo可能无法处理所有生成的验证条件。在这种情况下,Z3肯定会完成这项任务。我们可以编写如下示例代码:

/*@
	requires INT_MIN <= a*x <= INT_MAX ;
	requires INT_MIN <= ax_b(a,x,4) <= INT_MAX ;
	assigns \nothing ;
	ensures \result == ax_b(a,x,4);
*/
int function(int a, int x){
	return a*x + 4;
}

/*@
	requires INT_MIN <= a*x <= INT_MAX ;
	requires INT_MIN <= a*y <= INT_MAX ;
	requires a > 0;
	requires INT_MIN <= ax_b(a,x,4) <= INT_MAX ;
	requires INT_MIN <= ax_b(a,y,4) <= INT_MAX ;
	assigns \nothing ;
*/
void foo(int a, int x, int y){
	int fmin, fmax;
	if(x < y){
		fmin = function(a,x)
		fmax = function(a,y);
	} else {
		fmin = function(a,y);
		fmax = function(a,x);
	}
	//@assert fmin <= fmax;
}

如果我们不提供先前提到的引理,Alt-ergo将无法证明fmin小于或等于fmax的证明。然而,有了这些引理,证明就变得非常容易了,因为这一性质仅仅是引理ax_monotonic_pos的一个实例,而我们的引理在被认为已经是成立的情况下,证明就是微不足道的。请注意,在function的广义版本中,Z3可能在证明无运行时错误方面更为高效。

5.4.3 示例:数组和标签

在本教程的后续部分,我们将看到一些定义类型,当内存中发生某些修改时,这些定义有时难以被SMT求解器推理。因此,我们通常需要引理来陈述标签之间内存内容的关系。

目前,我们先通过一个简单的例子来说明。考虑以下两个谓词:

/*@
	predicate sorted(int* array, integer begin, integer end) =
		\forall integer i, j ; begin <= i <= j < end ==> array[i] <= array[j] ;

	predicate unchanged{L1, L2}(int *array, integer begin, integer end) =
		\forall integer i ; begin <= i < end ==>
			\at(array[i], L1) == \at(array[i], L2) ;
*/

例如,人们可能想陈述这样一个事实:当一个数组被排序后,如果内存中发生了某些变化(导致新的内存状态),但数组的内容保持不变,那么该数组仍然是有序的。这可以通过以下引理来实现:

/*@
	lemma unchanged_sorted{L1, L2}:
		\forall int* array, integer b, integer e ;
			sorted{L1}(array, b, e) ==>
			unchanged{L1, L2}(array, b, e) ==>
				sorted{L2}(array, b, e) ;
*/

我们针对两个标签L1L2陈述这个引理,并表明如果在任意数组中的任意范围在L1时已排序,并且从L1L2保持不变,那么在L2时它仍然保持有序。

请注意,这一引理可以很容易地通过SMT求解器证明。我们将在后面看到一些例子,其中证明并不那么容易获得。

5.4.4 练习

5.4.4.1 Multiplication property

编写一个引理,说明对于三个整数 x x x y y y z z z,如果 x x x大于或等于0,如果 z z z大于或等于 y y y,则 x ∗ z x∗z xz大于或等于 x ∗ y x∗y xy

该引理不会被SMT求解器证明,但如果使用Coq进行证明,默认策略很可能会自动满足此验证条件。

5.4.4.2 Locally sorted to globally sorted

以下程序包含一个函数,该函数要求数组按每个元素小于或等于其后元素的方式排序,并调用二分查找函数。

/*@ 
	predicate element_level_sorted(int* array, integer fst, integer end) =
		\forall integer i ; fst <= i < end-1 ==> array[i] <= array[i+1] ;
*/
/*@
	//lemma element_level_sorted_implies_sorted:
	// ... 
*/

/*@
	requires \valid_read(arr + (0 .. len-1));
	requires element_level_sorted(arr, 0, len) ;
	requires in_array(value, arr, len);

	assigns \nothing ;
	
	ensures 0 <= \result < len ;
	ensures arr[\result] == value ;
*/
unsigned bsearch_callee(int* arr, size_t len, int value){
	return bsearch(arr, len, value);
}

请回到练习5.2.3.4中你已经验证过的二分查找函数。你可能会注意到,二分查找函数的前置条件比我们在bsearch_callee的前置条件中所知道的更强。然而,我们的前置条件意味着数组是全局排序的。编写一个引理,说明如果一个数组是element_level_sorted,那么它就是sorted。这个引理可能不会被SMT求解器证明,所有剩余的属性都应该被证明。

我们在此书的GitHub仓库中提供了相应的解决方案和Coq证明。

5.4.4.3 Sum of the N first integers

回到你在练习5.3.3.5中关于前N个整数之和的解。写一个引理,陈述调用逻辑函数的结果是 n ∗ ( n + 1 ) / 2 n ∗ (n + 1)/2 n(n+1)/2。此引理将不会被SMT求解器证明。

我们在此书的GitHub仓库中提供了相应的解决方案和Coq证明。

5.4.4.4 Shift transitivity

以下程序由两个函数组成。第一个是shift_array函数,它根据给定的偏移量(名为shift)移动数组的元素。第二个函数对同一数组执行两次连续的移动操作。

#include <stddef.h>
#include <limits.h>

/*@
	predicate shifted_cell{L1, L2}(int* p, integer shift) =
		\at(p[0], L1) == \at(p[shift], L2) ;

	// predicate shifted{L1, L2}(int* arr, integer fst, integer last, integer shift) =
	// ...

	// predicate unchanged{L1, L2}(int *a, integer begin, integer end) =
	// ...

	// lemma shift_ptr{...}:
	// ...

	// lemma shift_transivity{...}:
	// ...
*/
void shift_array(int* array, size_t len, size_t shift){
	for(size_t i = len ; i > 0 ; --i){
		array[i+shift-1] = array[i-1] ;
	}
}

/*@
	requires \valid(array+(0 .. len+s1+s2-1)) ;
	requires s1+s2 + len <= UINT_MAX ;
	assigns array[s1 .. s1+s2+len-1];
	ensures shifted{Pre, Post}(array, 0, len, s1+s2) ;
*/
void double_shift(int* array, size_t len, size_t s1, size_t s2){
	shift_array(array, len, s1) ;
	shift_array(array+s1, len, s2) ;
}

完成谓词shiftedunchanged。谓词shifted应使用shifted_cell。谓词unchanged应使用shifted。完成shift_array函数的契约,并使用WP证明它。

解释两个关于shifted性质的引理。

第一个shift_ptr应声明,将array中范围fst+s1last+s1的元素移动一个偏移量s2,等价于将内存位置array+s1的范围fstlast的元素移动一个偏移量s2

第二个应陈述:当数组的元素首先以偏移量s1进行一次移动,然后以偏移量s2进行第二次移动时,完整的移动等效于以偏移量s1+s2进行的一次移动。

引理shift_ptr将不会被SMT求解器证明,我们提供了一个解决方案,并在本书的GitHub仓库中提供了相应的Coq证明。所有剩余的性质应自动证明。

5.4.4.5 Shift sorted range

以下程序由两个函数组成。函数shift_and_search将数组的元素进行移位,然后执行二分查找。

/*@
	predicate shifted_cell{L1, L2}(int* p, integer shift) =
		\at(p[0], L1) == \at(p[shift], L2) ;
*/

size_t bsearch(int* arr, size_t beg, size_t end, int value);

/*@
	// lemma shifted_still_sorted{...}:
	// ...
	// lemma in_array_shifted{...}:
	// ...
*/

/*@
	requires sorted(array, 0, len) ;
	requires \valid(array + (0 .. len));
	requires in_array(value, array, 0, len) ;

	assigns array[1 .. len] ;

	ensures 1 <= \result <= len ;
*/
unsigned shift_and_search(int* array, size_t len, int value){
	shift_array(array, len, 1);
	return bsearch(array, 1, len+1, value);
}

回到练习5.2.3.4中你已经证明的二分查找函数,修改该二分查找函数、其契约及其证明,以便能够在任何范围内进行搜索。

使用上一练习中证明的shift_array函数。

完成函数shift_and_search的契约。你可能会注意到,要求数组排序的前置条件并未得到验证,调用函数的后置条件也未得到验证。首先,完成引理shifted_still_sorted,该引理应当表明,如果某个标签处的范围是排序的,然后对其进行移位,则结果范围仍然保持有序,现在应该能够证明前置条件。然后,完成引理in_array_shifted,该引理应当表明,如果某个值位于随后被移位的值范围中,则该值仍然位于移位后的新范围中。现在应该能够证明调用函数的后置条件。

引理不必通过SMT求解器来证明。我们在本书的GitHub仓库中提供了相应的解决方案和Coq证明。


在本教程的这一部分中,我们了解了不同的ACSL构造,这些构造使我们能够分解我们的规范,并表达可以被我们的求解器使用的通用属性,从而使其任务更轻松。

我们所讨论的所有技术都是安全的,因为它们从原则上不允许我们编写虚假或自相矛盾的规范。至少如果规范仅使用这些逻辑结构,并且每个引理、前置条件(在调用点)、每个后置条件、断言、variant和不变式都得到了正确的证明,那么代码就是正确的。

然而,有时这些结构并不足以表达我们在程序中想要表达的所有属性。接下来我们将看到的构造为我们提供了一些新的可能性,但在使用它们时必须非常小心,因为一个错误可能会让我们引入错误的假设或静默修改正在验证的程序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值