第一个 C* 程序
本节中,我们会编写并验证第一个 C* 程序:求绝对值。这个例子虽然简单,却能说明:C 语义中的细微之处很容易导致代码出现未定义行为(UB)。确保代码没有未定义行为,是 C 程序验证的一个基本目标。后面的教程还会讨论如何确保内存安全和功能正确。本节最后,我们会下载并配置 C* 提供的证明标准库。它支持常见的规约与证明,后续示例也会用到这个库。
- 创建一个新文件夹:
mkdir cstar_verification ; cd cstar_verificationmkdir cstar_verification ; cd cstar_verification。 - 初始化项目:
cstarc initcstarc init。这一步会创建cstar.tomlcstar.toml与compile_flags.txtcompile_flags.txt。 - 创建
.vscode/settings.json.vscode/settings.json文件,内容如下:
{ "clangd.path": "cst_clangd"}
{ "clangd.path": "cst_clangd"}
- 在 VSCode 中打开项目。
- 使用快捷键
ctrl+shift+pctrl+shift+p(macOS 上为shift+cmd+pshift+cmd+p)打开命令面板,输入并选择CStar IDE: Start HOL Light ServerCStar IDE: Start HOL Light Server。
- HOL Light Server 面板显示以下内容时,就说明启动成功了:
...[INFO] Use Ctrl-\ to quit the server![INFO] RPC server listening on 127.0.0.1:7000 ..
...[INFO] Use Ctrl-\ to quit the server![INFO] RPC server listening on 127.0.0.1:7000 ..
在刚创建的 C* 项目中添加文件 abs.cabs.c,内容如下:
#cst_include "cstar.h"int myabs(int x){ int r; if (x >= 0) { r = x; } else { r = -x; } return r;}
#cst_include "cstar.h"int myabs(int x){ int r; if (x >= 0) { r = x; } else { r = -x; } return r;}
第一行的 #cst_include#cst_include 导入一个验证时(Verification Time)使用的头文件。其中定义的符号,比如定理证明中用到的证明函数,只能用于程序验证,不能用于实现代码。 cstar.hcstar.h 是 C* 提供的核心头文件,主要提供与上述 HOL Light Server 交互的接口。我们暂时可以忽略这一行,等到需要编写证明代码时,再解释它的机制。现在只需记住:所有 C* 程序都需要以 #cst_include "cstar.h"#cst_include "cstar.h" 开头。
在 VSCode 中,右键单击代码的第 4 行,选择 Show Symbolic StateShow Symbolic State。这是 C* 提供的验证功能,用于显示该行对应的符号状态(稍后解释)。此时却会报错,VSCode 会在对应行上标出以下诊断信息:
error: function without a specification near: int myabs(int x) ▸{ at .../cstar_verification/abs.c:3:5 in myabs fed to the engine: int myabs(int x) {
error: function without a specification near: int myabs(int x) ▸{ at .../cstar_verification/abs.c:3:5 in myabs fed to the engine: int myabs(int x) {
这提示我们,验证程序前,需要先编写它的规约(Specification)。 C* 采用广泛使用的契约式规约,以函数为单位给出:
- 前置条件(Pre-condition):函数的输入需要满足的条件。
- 后置条件(Post-condition):函数的输出应该满足的条件。
在 C* 中,我们通过标注 [[cst::require(...)]][[cst::require(...)]] 以及 [[cst::ensure(...)]][[cst::ensure(...)]] 来编写函数契约:
#cst_include "cstar.h"int myabs(int x) [[cst::require(`emp`)]] [[cst::ensure(` fact( (x >= 0i && __return == x) || (x < 0i && __return == --x) ) `)]]{ int r; if (x >= 0) { r = x; } else { r = -x; } return r;}
#cst_include "cstar.h"int myabs(int x) [[cst::require(`emp`)]] [[cst::ensure(` fact( (x >= 0i && __return == x) || (x < 0i && __return == --x) ) `)]]{ int r; if (x >= 0) { r = x; } else { r = -x; } return r;}
在上面的代码中,逻辑表达式写在反引号 `...``...` 中。 C* 使用分离逻辑(Separation Logic)作为规约逻辑,稍后会详细介绍。在这个例子中:
empemp可以先理解为真值,即没有对程序状态施加任何约束。-
fact()fact()表示对程序状态施加 表达的约束。myabsmyabs的后置条件用它表达以下约束:- 要么参数
xx非负并且函数返回值等于xx; - 要么参数
xx为负数并且函数返回值等于xx的相反数。
- 要么参数
现在,右键单击代码的第 11 行(函数入口的大括号处),再次选择 Show Symbolic StateShow Symbolic State。VSCode 右侧的符号状态面板会显示:
这也是一个分离逻辑表达式:data_at data_at 表示在地址 处存储了一个 类型的值 。分离逻辑刻画的是资源(Resource):要描述程序操作的某块内存,就需要显式描述这块内存资源。上面的符号状态表示,变量 xx 的地址处存储着一个 intint 类型的值,等于 xx 在函数入口处的初值。
接下来,右键单击代码的第 12 行,再次选择 Show Symbolic StateShow Symbolic State,得到以下符号状态:
其中的 undef_data_atundef_data_at 谓词与 data_atdata_at 含义类似,区别在于前者不指定内存中存储的值。如果接下来立即使用 rr 的值,也会报错,因为 rr 尚未初始化。这个符号状态中还出现了逻辑连接词 ****,即分离逻辑中十分重要的分离合取(Separating Conjunction)连接词。逻辑表达式 ** ** 所描述的内存空间可以分成两个不相交的部分,分别满足 和 。所以,上述符号状态明确表示,变量 xx 和 rr 占据的内存空间互不相交。后面我们还会看到,分离逻辑的这一能力可以用来精确、简洁地刻画别名关系。
如果在函数返回后(第 18 行)查看符号状态,得到的是:
目前,C* 不返回函数出口处的符号状态。这在一定程度上符合直觉:函数返回后,它所拥有的内存空间(参数和局部变量)都已回收。
查看符号状态的过程已经包含了一些验证:没有编写函数规约、访问的内存资源未被描述,或使用了未初始化变量的值,都会报错。符号状态的推理能够继续推进,本身就已在一定程度上说明了内存安全性。不过,程序验证还需要考虑功能正确性。如果在函数出口的大括号处(第 19 行)查看符号状态,面板显示的就是验证条件(Verification Condition):
如果所有验证条件都成立,就能保证函数没有未定义行为、满足内存安全性,而且功能符合函数契约的描述。上面的验证条件对应第 16 行的赋值语句 r = -x;r = -x;。它要求 |--|--(分离逻辑中的蕴含连接词)前的符号状态蕴含其后的符号状态。具体来说,它要求证明:如果 xx 在函数入口处为负,那么它的值就不等于 -2147483648-2147483648(逻辑表达式中的 ~~ 表示逻辑非)。这个条件显然不成立:整型变量完全可以取值 -2147483648-2147483648。为什么要验证这个条件呢?原因在于 C 语义中的一个细节:有符号整型运算溢出属于未定义行为,对 -2147483648-2147483648 取相反数就会出现这种情况。验证条件中 |--|-- 后的部分,正是为了确保取相反数时不触发未定义行为。
为了让求绝对值函数通过验证,我们可以加强函数的前置条件,约束函数输入:
int myabs(int x) [[cst::require(`fact(x > --2147483648i)`)]] ...
int myabs(int x) [[cst::require(`fact(x > --2147483648i)`)]] ...
也就是说,我们限制了输入范围,使其绝对值能够在整型范围内正确计算。修改后,再次在函数结束后的位置查看符号状态,面板会显示:
至此,第一个 C* 程序就编写并验证完成了!接下来看看,如果实现代码写错了,会出现什么样的验证条件。比如,把第 16 行的 r = -x;r = -x; 改为 r = x;r = x;,再次在函数末尾查看符号状态,得到以下验证条件:
如果 xx 在函数入口处的初值为负,规约要求返回 xx 的相反数,代码却返回了 xx。这样就得到一个无法证明的验证条件 x == -xx == -x。由此可见,程序验证可以在开发过程中发现逻辑错误,确保实现代码的行为符合规约。
在继续学习之前,先在项目中配置 C* 的证明标准库。
- 进入项目根目录(
cstar_verificationcstar_verification),执行git initgit init,初始化 git 版本控制。 - 执行
git submodule add https://gitee.com/cstarlang/cstar_stdlib.git proofgit submodule add https://gitee.com/cstarlang/cstar_stdlib.git proof。不要修改proofproof文件夹名! - 把
abs.cabs.c的第一行从#cst_include "cstar.h"#cst_include "cstar.h"改为#include "proof/proof.h"#include "proof/proof.h"。 - 在
abs.cabs.c中再次尝试查看符号状态,确认仍然能够正常工作。
- 用
cstarc initcstarc init初始化 C* 项目,在 VSCode 中启动 HOL Light Server;所有 C* 程序经过 C 预处理器处理后,都以#cst_include "cstar.h"#cst_include "cstar.h"开头。 - 验证从规约开始:C* 采用契约式规约,用
[[cst::require(...)]][[cst::require(...)]]与[[cst::ensure(...)]][[cst::ensure(...)]]标注前置与后置条件;契约中变量名表示其在函数入口处的值,__return__return表示返回值。 - 反引号
`...``...`中是逻辑表达式,语法源自 HOL Light:整数常量带ii后缀,用----取相反数,用~~表示逻辑非。 Show Symbolic StateShow Symbolic State显示某一行处的符号状态:data_atdata_at/undef_data_atundef_data_at显式刻画每一块内存资源,****表示两部分内存不相交。- 验证条件形如“符号状态
|--|--符号状态”;若全部验证条件成立,函数就没有未定义行为、满足内存安全性,而且功能符合契约。myabsmyabs的例子展示了验证条件如何揭示有符号整型溢出这类 UB,以及如何通过加强前置条件来修复。 - 配置证明标准库后,用
#include "proof/proof.h"#include "proof/proof.h"替代#cst_include "cstar.h"#cst_include "cstar.h";后续教程都基于该库。