本内容为2020年7月1号出版的《Introduction to C program proof with Frama-C and its WP plugin》中第1-2节的翻译内容
目录
1. 简介
尽管历史悠久,C语言仍然是一种广泛使用的编程语言。事实上,没有其他语言能够声称可以在如此多的不同(硬件和软件)平台上运行,其底层定位以及在优化编译器上投入的大量时间使得它能够生成非常轻量且高效的机器码(当然,前提是代码允许),并且存在大量C语言专家,这是一个重要的知识库。
此外,许多系统依赖于历史上用C语言编写的大量代码,这些代码需要维护,有时还需要修复,因为重写这些系统的成本将过于高昂。
然而,任何已经使用C语言进行开发的人也都知道,要完全掌握这门语言是非常困难的。原因有很多,但ISO C的复杂性以及它对内存管理的高度宽容性,使得即使是经验丰富的程序员也很难开发出健壮的C程序。
然而,C语言通常被选中用于关键系统(航空电子、铁路、武器装备等),在这些领域中,它因其出色的性能、技术成熟度以及编译的可预测性而受到青睐。
在这样的情况下,源代码的测试覆盖需求变得极高。因此,“我们的软件测试得足够吗?”这个问题变得非常难以回答。程序证明可以帮到我们。与其测试所有可能的和(难以)想象到的输入,我们将通过静态和数学的方法证明程序在运行时不会出现任何问题。
本教程的目标是使用Frama-C(一种由CEA LIST开发的工具)及其演绎证明插件WP,来学习C程序证明的基础知识。不仅仅是为了使用工具本身,本教程的目标还在于使读者相信,编写没有任何编程错误的程序是越来越可能的,同时还要使读者对一些简单概念有所感知,这些概念有助于更好地理解和编写程序。
2. 程序证明及本教程所用工具:Frama-C
本部分的首要目标是,在第一节中,简要介绍程序证明的概念而不涉及过多细节;随后,在第二节中,提供安装Frama-C及本教程将使用的一些自动证明工具所需的必要指导。
2.1 程序证明
2.1.1 确保程序可靠性
确保我们的程序仅表现出正确的行为往往很困难。此外,确立让我们有足够信心断言程序运行正确的良好标准已经相当复杂:
- 初学者仅仅是“尝试”使用他们的程序,如果程序没有崩溃(这在C语言中并不是一个真正好的指示),他们就认为这些程序是有效的。
- 高级开发人员会为一些已知预期结果的测试案例设置测试用例,并将他们获得的结果进行比对。
- 大多数公司建立了完整的测试基地,覆盖尽可能多的代码;这些测试系统地执行在其代码上。一些公司采用测试驱动开发。
- 在关键领域,如航空航天、铁路或军工,源代码需要通过使用标准化流程进行认证,这些流程对编码规则和代码覆盖率有非常严格的标准。
在所有这些确保程序只产生我们预期结果的方法中,有一个词似乎是共通的:测试。我们尝试不同的输入,以隔离出有问题的案例。我们提供我们认为能代表程序实际使用情况的输入(注意,意外的使用场景往往不被考虑,而它们通常是最危险的情况),并验证我们得到的结果是否正确。但我们无法测试所有情况。我们无法尝试程序所有可能输入的每一种组合。因此,选择好的测试变得相当困难。
程序证明的目标是确保,对于提供给特定程序的任何输入,如果它符合规范,那么程序将仅表现出良好行为。然而,由于我们无法测试所有情况,我们将通过形式化、数学化的方式建立一个证明,证明我们的软件只能展现出规定的行为,而运行时错误不属于这些行为的一部分。
一个广为人知的Dijkstra语录准确地表达了测试和证明之间的区别:
程序测试可以用来证明错误的存在,但绝不能证明其不存在!
2.1.1.1 开发者的圣杯:无bug软件
每当我们阅读到有关计算机系统被攻击、病毒或漏洞导致知名应用程序崩溃的新闻时,总会有一个评论:“不存在无漏洞/绝对安全的程序”。这句话虽然颇有道理,但有些被误解了。
首先,我们并没有真正定义“无缺陷”的含义。创建软件通常至少依赖两个步骤:我们制定一个规范,明确我们对程序的期望,然后我们编写必须符合此规范的程序源代码。此外,我们还可以补充说,编程语言本身定义了正确使用它的方式。在每个方面,错误都可能导致引入缺陷,这些缺陷可以分为三个类别:
- 根据语言规范,程序可能不正确或未定义行为(例如,程序在搜索数组最小值的索引时访问了数组边界之外)。
- 该程序不符合我们为其定义的规范(例如,我们定义了程序必须找到数组中最小值的索引,但由于错误,实际上它没有检查数组的最后一个值)。
- 规范并未完全反映我们对我们函数的设计意图,因此,程序并未实现我们真正期望的功能(例如,我们定义了函数必须找到数组中最小值的索引,但未明确指出在存在多个最小值时应返回第一个最小值的位置,因为这看似过于直接,但实际上,程序并未实现这一功能)。
每个类别都可以影响我们程序的safety和/或security,但这两个概念并不等同。宽松地说,在security中,恶意实体能够攻击系统,而在safety中,我们希望验证在使用上符合规范时,系统的良好行为。因此,safety是我们在获得security之前的首要考虑。
在本教程中,我们将展示如何证明我们的程序实现不包含与上述定义的前两类相关的错误,即它们符合:
- 语言规范
- 我们为他们提供的规范
但与程序测试相比,程序证明的论据是什么?首先,证明是完整的,如果行为被指定,它不会遗漏一些边缘情况(程序测试无法做到完整,因为穷举测试的成本过高)。其次,以逻辑形式正式指定义务的要求,迫使我们准确理解我们需要证明的关于程序的内容。
可以说,程序证明表明了“实现中不包含规范中不存在的错误”,因为我们不处理上述定义的第三类错误。但是,要知道“实现中不包含规范中不存在的错误”已经是一个巨大的进步,相比于知道“实现中不包含太多规范中不存在的错误”,因为它已经对应了我们可以排除的两个完整类别的错误,这些错误可能会严重损害我们程序的安全性和安全性。而且,还存在处理第三类错误的方法,允许通过分析规范来发现错误或未充分指定的行为。例如,通过模型检验技术,我们可以从规范中创建一个抽象模型,并生成根据该模型可以到达的状态集。通过确定什么是错误状态,我们可以判断可达状态是否为错误状态。
2.1.2 背景介绍
形式化方法,顾名思义,在计算机科学中允许我们严格地、数学地推理程序。存在许多形式化方法,它们可以在从程序设计到实现、分析和验证的不同层次上应用,并且适用于所有能够处理信息的系统。
在这里,我们将专注于一种方法,该方法能够形式化验证我们的程序只具有正确的行为。我们将使用能够分析源代码并确定程序是否正确实现了我们意图表达内容的工具。我们将使用的分析器提供静态分析,这与动态分析相对立。
在静态分析中,被分析的程序并不会被执行。我们通过对程序在执行过程中可能达到的状态的数学模型进行推理。相反,动态分析方法,如程序测试,要求执行被分析的源代码。需要注意的是,存在一些形式化的动态分析方法,例如自动化测试生成,或代码监控技术,这些技术允许在执行过程中对源代码进行插桩,以验证某些属性(例如,正确的内存使用)。
谈到静态分析,我们使用的模型可以根据所采用的技术或多或少地抽象化,这始终是对程序可能状态的一种近似。近似越精确,模型就越具体;近似越模糊,模型就越抽象。
为了说明具体模型和抽象模型的区别,我们可以看一下简单计时器的模型。一个非常抽象的计时器模型可以是这样的:
我们有一个关于计时器行为的模型,根据我们可以执行的不同动作,它可以达到不同的状态。然而,我们没有模型化这些状态在程序中是如何实现的(这是一个C语言枚举吗?源代码中的一个特定程序点?),也没有模型化经过时间的计算是如何进行的(一个变量?多个变量?)。这将使得在程序中指定属性变得困难。我们可以添加一些信息:
- 状态 stopped at 0 : time = 0s
- 状态 running : time > 0s
- 状态 stopped : time > 0s
这为我们提供了一个更具体的模型,但仍然不够精确,无法提出一些有趣的问题,例如:“当计时器处于停止状态时,程序是否可能继续更新时间变量?”因为我们没有模拟计时器如何更新时间测量。
相反,拥有程序的源代码,我们拥有了一个具体的计时器模型。源代码表达了计时器的行为,因为它允许我们生成可执行文件。但这仍然不是更具体的模型!例如,我们在编译后获得的机器码格式的可执行文件,远比我们的程序更为具体。
模型越具体,就越能准确描述我们程序的行为。源代码比图表更准确地描述了行为,但它又不如机器代码精确。然而,模型越精确,就越难对定义的行为有一个全局视角。我们的图表一眼就能看懂,源代码则需要更多时间,而对于可执行文件,每个曾错误地用文本编辑器打开过可执行文件的人都知道,阅读它并不是一件愉快的事。
当我们对一个系统进行抽象时,我们会对它进行近似处理,以限制我们对它的了解,并使我们的推理更加容易。如果我们希望分析结果正确,必须遵守的一个约束是,永远不要低估系统的行为:我们可能会删除包含错误的行为(从而在分析过程中忽略它)。然而,当我们高估程序的行为时,我们可能会添加实际上不会发生的行为,如果添加过多,我们可能无法证明程序的正确性,因为其中一些行为可能是错误的。
在我们的案例中,模型非常具体。每种类型的指令,每种控制结构,都与一个精确的语义相关联,即其在纯逻辑、数学世界中的行为模型。
我们在此使用的逻辑是Hoare逻辑的一种变体,经过调整以适应C语言及其所有复杂的细微差别(这使得该模型具体化)。
2.1.3 Hoare三元组
Hoare逻辑是一种由托尼·霍尔于1969年在一篇题为《An Axiomatic Basis for Computer Programming》的论文中提出的程序形式化方法。该方法定义了:
- 公理,即我们承认的性质,例如“skip动作不会改变程序状态”。
- 规则,用于推理不同允许的动作组合,例如“skip动作后执行动作A”等价于“执行动作A”。
程序的行为由我们称为“Hoare三元组”的内容来定义:
{ P } C { Q } \{P\}C\{Q\} {P}C{Q}
设P和Q为谓词,表示在特定程序点上关于内存的逻辑公式。C是一个定义程序的指令列表。此语法表达以下意思:“如果我们处于P被验证的状态,在执行C之后,若C终止,则Q对于执行的新状态被验证”。换言之,P是确保C能达到后置条件Q的充分前置条件。例如,与skip动作对应的Hoare三元组如下:
{ P } s k i p { P } \{P\}skip\{P\} {P}skip{P}
当我们无所作为时,后置条件就是前置条件。
在本教程中,我们将使用Hoare逻辑来展示不同程序结构(条件块、循环等)的语义。因此,我们现在暂时跳过这些细节,因为稍后我们会详细讨论。虽然不需要记忆这些概念,也不需要完全理解所有的理论背景,但对我们的工具的工作方式有一些大致的了解还是有帮助的。
这一切为我们提供了基础,使我们能够说“这一行动的作用是什么”,但它并没有为我们提供任何机械化证明的方法。我们将使用的工具依赖于一种称为最弱前置条件演算的技术。
2.1.4 最弱前置条件演算
最弱前置条件演算是一种由Dijkstra于1975年在《Guarded commands, non-determinacy and formal derivation of programs》中提出的谓词转换语义。
这个标题看似复杂,但实际上文章的内容相当简单。我们之前已经了解到,Hoare逻辑为我们提供了解释程序不同动作行为的规则,但它并没有说明如何应用这些规则来建立程序的完整证明。
Dijkstra通过对Hoare逻辑进行重新表述,解释了在三元组 { P } C { Q } \{P\}C\{Q\} {P}C{Q}中,指令或指令块C如何将谓词P转换为Q。这种推理方式被称为前向推理。我们从前置条件和一条或多条指令出发,计算出我们所能达到的最强后置条件。非正式地说,考虑输入的内容,我们计算输出将得到的结果。如果所需的后置条件与计算出的后置条件一样强或更弱,那么我们就证明了不存在意外行为。
例如:
int a = 2;
a = 4;
//calculated postcondition: a == 4
//expected postcondition : 0 <= a <= 30
好的,a的允许值包括4。
我们感兴趣的谓词转换语义的形式是反向推理的。从期望的后置条件和我们推理的指令出发,我们找到确保这种行为的最弱前置条件。如果我们的实际前置条件至少与计算出的前置条件一样强,也就是说,如果它隐含了计算出的前置条件,那么我们的程序就是正确的。
例如,如果我们有以下指令:
{
P
}
x
:
=
a
{
x
=
42
}
\{P\}x:=a\{x=42\}
{P}x:=a{x=42}
最弱的前置条件是什么,以验证后置条件
{
x
=
42
}
\{x = 42\}
{x=42}?规则将定义P为
{
a
=
42
}
\{a = 42\}
{a=42}。
目前,我们先将其置之脑后,在本教程中使用这些概念时,我们将重新探讨它们,以理解我们的工具如何运作。所以现在,我们可以来看看这些工具。
2.2 Frama-C
2.2.1 什么是Frama-C,什么是WP
Frama-C(用于模块化分析C代码的框架)是由CEA LIST和Inria创建的一个专注于分析C程序的平台。它基于模块化架构,允许使用不同的插件。默认插件包括不同的静态分析(不执行源代码)、动态分析(需要代码执行)或结合两者。这些插件可以协同工作或不协同工作,要么通过直接通信,要么通过使用Frama-C提供的规范语言。
这种规范语言称为ACSL(“Axel”),即ANSI C Specification Language,它允许我们表达我们想要验证的程序属性。这些属性是通过在注释部分的代码注释来编写的。如果读者已经使用过Doxygen,会发现它与Doxygen非常相似,只不过我们编写的是逻辑公式而不是文本。在本教程中,我们将大量编写ACSL代码,因此我们暂且跳过这一部分。
我们将在这个教程中使用的分析方法由WP插件(Weakest Precondition的缩写)提供,这是一个用于演绎验证的插件。它实现了我们之前提到的一种技术:从ACSL注释和源代码中,该插件生成了我们称之为验证条件的逻辑公式,这些公式必须被验证是否可满足。这种验证既可以手动进行,也可以自动进行,在这里我们使用自动工具。
我们将使用一个SMT求解器(satisfiability modulo theory,我们不详细介绍其工作原理)。该求解器是Alt-Ergo,最初由巴黎南大学信息研究实验室开发,如今由OCamlPro维护。
2.2.2 安装(不翻译)
2.2.3 验证安装(不翻译)
2.2.4 其他证明器
本部分为可选内容,本节中的内容在教程中并不特别有用。然而,当我们开始对证明更复杂的程序感兴趣时,通常可能会遇到Alt-Ergo的默认限制,因此我们需要其他证明器。对于基本属性,几乎所有求解器都具有相同的能力,而对于更复杂的属性,每个求解器都有其偏好的领域。
2.2.4.1 Why3
Why3是由位于Orsay的LRI开发的一个演绎证明平台。它提供了一种编程语言和一种规范语言,以及一个可以与各种自动和交互式证明器进行交互的模块。这一点是我们在这里感兴趣的。WP使用Why3作为后端与外部证明器进行通信。
在其网站上,我们可以找到支持的证明器列表。我们推荐安装由微软研究院开发的Z3和由多个研究团队(纽约大学、爱荷华大学、谷歌、CEA List)开发的CVC4。这两款证明器非常高效且在某些方面相互补充。
新证明器可以在安装了Frama-C之后随时安装。然而,Why3证明器列表必须进行更新。
$ why3 config --full-config
然后在Frama-C中激活。在左侧面板的WP部分,点击“Provers…”,
然后点击弹出窗口中的“Detect”。完成之后,点击证明器旁边的按钮即可激活。
2.2.4.2 Coq
Coq, 由Inria开发,是一款证明助手。基本上,我们使用专门的语言自行编写证明,而助手则通过类型检查来验证该证明是否确实有效。
为什么我们需要这样的工具?有时候,我们想要证明的性质可能过于复杂,以至于无法通过SMT求解器自动解决,尤其是在每一步都需要精确的归纳推理和谨慎的选择时。在这种情况下,WP允许我们生成转换为Coq语言的验证条件,并让我们自己编写证明。
学习Coq的话,我们推荐这篇教程。
如果已使用Linux发行版的包管理器安装了Frama-C,Coq可以自动安装。
如果需要了解更多关于Coq及其安装的信息,可以参考此页面:The Coq Proof Assistant。
当前对Coq的支持在Frama-C中已被弃用,但仍可使用。由于我们提供了可用的Coq脚本,因此我们提供这些说明,以便用户可以测试这种验证程序的方式。要运行支持Coq的 Frama-C,请使用:
$ frama-c -wp-prover=native:coq
我们的工具现已安装完毕,随时可以使用。
本部分的目标之一,除了安装我们的工具外,还在于突出两大主要理念:
- 程序证明是一种在不执行程序的情况下,确保我们的程序仅具有由规范描述的正确行为的方法。
- 这是我们确保该规范正确性的工作。