符号执行开山之作--Symbolic Execution and Program Testing

符号执行和程序测试

摘要:本文描述了程序的符号执行。而不是提供正常的输入程序(如数字),一个提供符号表示任意值。执行与正常执行一样进行,只是值可以在输入符号上使用符号公式。在条件分支类型语句的符号执行过程中出现了困难但有趣的问题。还描述了一个称为EFFIGY的特殊系统,它为程序测试和调试提供符号执行,它解释性地执行用简单的PL/I风格编程语言编写的程序。它包括许多标准的调试特性、管理和证明符号表达式的能力、一个简单的程序测试管理器和一个程序验证器。简要讨论了符号执行和程序证明之间的关系。

1 引言

    大规模生产可靠的程序是应用计算机解决当今具有挑战性的问题的基本要求之一。在实践中使用了几种技术;其他的则是当前研究的重点。本文中报告的工作旨在确保即使在没有给出正式规范的情况下,程序也能满足其需求。目前该领域的技术基本上是一种测试技术。也就是说,将程序期望处理的数据的一些小样本呈现给程序。如果判断程序对样本产生正确的结果,则假定它是正确的。目前的许多工作[11]都集中在如何选择这个样本的问题上。

    最近通过形式分析证明程序正确性的工作[15]显示出巨大的希望,似乎是生成可靠程序的终极技术。然而,这一领域的实际成就还不足以成为一种常规使用的工具。把理论转化为实践的一些根本问题不可能在近期内得到解决。

    程序测试和程序证明可以看作是两种极端的替代方法。在测试时,程序员可以通过仔细检查结果来确保样本测试运行正常。对于不在示例中的输入的正确执行仍然存在疑问。或者,在程序证明中,程序员形式地证明程序符合其所有执行的规范,而根本不需要执行程序。为了做到这一点,他给出了正确的程序行为的精确规范,然后遵循一个正式的证明过程来证明程序和规范是一致的。这种方法的可信度取决于在规范的创建和证明步骤的构建中所采用的谨慎和准确性,以及对溢出、舍入等与机器相关的问题的关注。

    本文描述了一种介于这两个极端之间的实用方法。从一个简单的角度来看,它是一种增强的测试技术。不是对一组示例输入执行程序,而是对一组输入类“象征性”地执行程序。也就是说,每个符号执行结果可能等同于大量的正常测试用例。这些结果可以根据程序员对正确性的期望进行正式或非正式的检查。

    每个符号执行所表征的输入类型由程序的控制流对其输入的依赖关系决定。如果程序的控制流完全独立于输入变量,那么一个符号执行就足以检查程序的所有可能执行。如果程序的控制流依赖于输入,就必须进行实例分析。通常,耗尽所有可能情况所需的输入类集实际上是无限的,因此这仍然基本上是一种测试方法。然而,输入类仅由控制流中所涉及的输入决定,对于大多数程序来说,符号测试比常规测试更容易提供更好的结果。

2.符号执行

    本节在理想意义下描述程序的符号执行,然后,在第6节中,讨论已经建立的一个特定的实际系统(接近理想情况)。使用理想这个术语有以下几个原因:

    1. 这里假设程序只能处理整数,而且实际上只能处理任意大小的整数。不考虑机器寄存器溢出。

    2. 许多(大多数)程序的符号执行所产生的“执行树”(稍后定义)是无限的。

    3. IF语句的符号执行需要定理证明,即使对于一般的程序语言,这在机械上也是不可能的。

    尽管如此,对理想状态的讨论确实提供了一个标准,用于衡量实际计算机系统的符号执行。

    每一种编程语言都有一个执行语义来描述程序变量可能表示的数据对象,用这种语言编写的语句如何操作数据对象,以及控制如何在程序语句中流动。人们还可以为编程语言定义另一种“符号执行”语义,在这种语义中,不需要使用真正的数据对象,但可以用任意符号表示。符号执行是普通执行的自然扩展,将普通计算作为一种特殊情况提供。扩展了语言基本操作符的计算定义,以接受符号输入并产生符号公式作为输出

    让我们考虑一种简单的编程语言。该语言中的程序变量仅限于“有符号整数”类型。包含简单的赋值语句、IF语句(带有THEN和ELSE子句)、GO-TO语句(跳转到标签),以及获取输入的方式(例如,通过过程参数、全局变量、读取操作)。将算术表达式限制为基本的整数运算符,如加法(+)、减法(-)和乘法(X)。将布尔表达式(用于IF语句)限制为对算术表达式是否非负的简单测试(即{算术表达式} ≥ 0)。

    现在描述如何在这种简单的语言中进行程序的符号执行,并假设普通的执行语义已经存在。执行语义本身并不会改变语言的语法或该语言中编写的程序。引入符号数据对象(表示整数的符号)的唯一机会是在程序的输入处。为简化起见,我们假设每次程序需要新的输入值时,它从符号列表{a1, a2, a3, …}中符号化地提供。程序输入最终会被赋值给程序变量(例如,通过过程参数、全局变量或读取语句)。因此,为了处理符号输入,我们允许变量的值既可以是符号a的形式,也可以是有符号的整数常量。

    用于赋值和IF语句中的算术表达式的求值规则必须扩展,以处理符号值。由整数、一组不确定符号{a1, a2, …}、括号和运算符(+、-、X)以常规方式形成的表达式是这些符号上的整数多项式(整数值,整数系数)。通过允许程序变量将符号a的多项式作为值,赋值语句的符号执行自然地延续了下来。语句右侧的表达式被求值,可能会将变量替换为多项式表达式。结果是一个多项式(整数是最简单的情况),然后该多项式被赋值为赋值语句左侧变量的新值。

    GO-TO语句的功能与普通执行中的完全相同,通过无条件地将控制从GO-TO语句转移到与相应标签关联的语句。

    程序执行的“状态”通常包括程序变量的值和一个语句计数器(表示当前正在执行的语句)。定义符号执行中的IF语句时,还需要在执行状态中包含一个“路径条件”(pc)。路径条件是一个基于符号输入{a}的布尔表达式,它不包含程序变量。对于我们的简单语言,路径条件是形式为R ≥ 0或¬(R ≥ 0)的表达式列表的合取,其中R是关于符号{a}的多项式。例如:a1 ≥ 0 ∧ a1 + 2 × a2 ≥ 0 ∧ ¬(a3 > 0)。

    如将看到的那样,路径条件(pc)是输入必须满足的条件的累积,以便执行能够沿着特定的路径前进。每次符号执行从pc初始化为true开始。当做出关于输入的假设时,为了在程序中的不同路径(如IF语句所示)之间进行选择,这些假设将被添加(合取)到pc中。

    IF语句的符号执行开始时,类似于其正常执行方式:通过将变量替换为它们的值来求值相关的布尔表达式。由于变量的值是关于符号{a}的多项式,因此条件是形如R ≥ 0的表达式,其中R是一个多项式。将这种表达式称为q。使用当前的路径条件(pc)形成以下两个表达式:

(a) pc ⊃ q

(b) pc ⊃ ¬q

这两个表达式分别对应于程序执行的两个可能路径,一个是条件q为真时执行的路径,另一个是条件q为假时执行的路径。

    这两个表达式至多有一个可以为真(排除pc恒为假的情况)。当恰好有一个表达式为真时,符号执行将按照通常的方式继续执行IF语句:当表达式(a)为真时,控制流进入THEN部分;当表达式(b)为真时,控制流进入ELSE部分。所有输入值满足路径条件pc的正常执行,都将遵循与此符号执行相同的分支;它们要么选择THEN分支(pc ⊃ q),要么选择ELSE分支(pc ⊃ ¬q)。在这种情况下,IF语句的执行称为“无分叉”执行。

    更有趣的情况是,当表达式(a)和表达式(b)都不为真时。在这种情况下,存在至少一组输入满足路径条件pc并选择THEN分支,另有至少一组输入满足pc并选择ELSE分支。由于这两种分支在此情况下都是可能的,唯一完整的做法是同时探索两个控制路径。因此,符号执行被定义为分叉为两个“并行”的执行:一个遵循THEN分支,另一个遵循ELSE分支。这两种执行都假设在执行IF语句之前存在的计算状态,但之后将独立进行。在这种情况下,IF语句的执行被称为“分叉”执行。需要注意的是,分叉/非分叉的特性是与IF语句的特定执行相关的,而不是语句本身的特性。某次对同一IF语句的执行可能是分叉的,而随后的一次执行则可能是非分叉的。

    由于在选择THEN分支时,假设输入满足IF语句的布尔条件q,这些信息通过赋值操作pc ← pc ∧ q记录到路径条件(pc)中。同样,选择ELSE分支则导致pc ← pc ∧ ¬q。pc被称为“路径条件”,因为它是确定通过程序的唯一控制流路径的条件的累积。每次IF语句的分叉执行都会贡献一个关于输入符号的条件,这个条件由具体的路径选择决定。

    对于非分叉执行的IF语句,路径条件pc保持不变,因为没有做出新的假设或需要新的假设。由于pc的初始值为真,且对pc的唯一操作是形式为:pc ← pc ∧ r的赋值(其中r是q或¬q),因此pc永远不会变为假。只有当(pc ∧ r)是可满足的(即pc ∧ r ≡ ¬ (pc  ⊃¬r)时,才会进行这样的操作,这种情况当且仅当(pc ⊃ ¬r)不是一个定理时才成立。

3 样例

    考虑图1中显示的简单程序。它使用PL/I风格的语法编写,计算三个值的总和。对于整数输入1、3和5,这个程序的常规执行,如图2所示,计算出的输出是9。图3中详细显示的符号执行表明,对于任意三个整数,例如a1、a2、a3,该程序将计算它们的和,即 a1 + a2 + a3。

图1 加和程序

图2显示了SUM(1, 3, 5)的执行情况。图中用破折号表示未更改的值,即与上一行相同的值;问号表示未定义(未初始化)的值。

图3展示了SUM(al, a2, a3)的符号执行。路径条件用简称pc表示。

4 符号执行树

    可以生成一个“执行树”,以描述在符号执行过程中遵循的执行路径。为每个执行的语句关联一个节点(标记有语句编号),并为每个语句之间的转换画一个有向弧连接相关的节点。对于每个分叉的IF语句执行,相关节点有两个离开的弧,分别标记为“T”(表示TRUE,即THEN部分)和“F”(表示FALSE,即ELSE部分)。此外,还将当前完整的执行状态,即变量值、语句计数器和路径条件pc,关联到每个节点。POWER (a1, a2)的执行树(图4和图5)如图6所示。

图4 幂乘程序

图5 POWER (a1, a2)的符号执行

图5 POWER (a1, a2)的执行树

    由符号执行以这种方式形成的树具有以下有趣的特性:

    1. 对于树中的每个终端叶节点(对应于一个完整的执行路径),确实存在一组特定的非符号化输入,使得程序以正常方式执行时,会遵循相同的路径(执行相同的语句列表)。这等价于说路径条件(pc)从未变为恒假。关于这一点的简要论证已在第2节末尾给出。

    2. 任何两个终端叶节点所关联的路径条件(pc)是不同的(即 ¬(pc1 ∧ pc2))。从执行树的公共根节点到达任何两个终端叶节点的两条路径,在某个唯一的分叉节点上发生了分歧。在这个分叉节点,某个条件q被添加到一个路径条件中,而¬q被添加到另一个路径条件中。由于两者的路径条件都保持一致(不为假),它们必须维持这种差异。

    这些特性的重要性在图7的示例中得到了强调。请注意,为了简化,使用了DO语句。它们可以很容易地扩展为IF/GOTO循环,以符合之前的表示法。图7中TWOLOOPS的执行树如图8所示。即使第二个循环中的语句4具有与第一个循环中的语句2相同的语法分支潜力,但在语句2处分叉时所做的假设(保存在路径条件pc中)在语句4中被保留并使用,因此避免了分叉的生成。这些符号执行树类似于Paterson为程序模式在文献[13]中定义的执行树。Paterson的树在文献[12]中也有讨论。

图7 TWOLOOPS程序

图8 TWOLOOPS程序执行树

5 可交换性

    上面为这个简单整数语言定义的符号执行满足一个有趣的可交换性(Commutativity)性质。将符号 {𝑎𝑖}实例化为特定的整数 {𝑗𝑖}和执行程序这两个操作是可交换的。也就是说,如果首先用一组特定的整数 {𝑗𝑖}作为输入来常规执行程序(先将 𝑎𝑖实例化为 𝑗𝑖,然后执行),其结果将与符号执行程序然后实例化符号结果(将 𝑗𝑖 赋值给 𝑎𝑖)得到的结果相同。

    “实例化符号结果”的含义是:对于执行树中的每个终端叶节点,用 𝑗𝑖 替换 𝑎𝑖 所有程序变量值和路径条件 𝑝𝑐 中的值。然后,“结果”是使终端节点的 𝑝𝑐 成为真的那些变量的值。这种可交换性可以用图9所示的方式来表示,其中𝑃代表程序,𝐸(𝑃(𝑋))是在输入𝑋上执行程序𝑃的结果,𝐾是一组特定的整数输入。

图9 可交换性图

    当然,这种可交换性关系正是符号执行引人关注的原因。符号执行准确地捕捉到了与常规执行相同的效果。符号执行不仅仅是一个任意的替代执行定义,而是对常规定义的自然扩展。就像在算术和代数之间的关系一样,程序操作符所规定的具体(算术)计算被推广并“延迟”使用适当的代数公式。

6 交互式符号执行器——EFFIGY

    作者和他的同事们一直在开发一个名为 EFFIGY 的交互式符号执行系统。该系统的开发始于1973年初,至今仍在进行中。EFFIGY 为用户提供了一系列服务,包括用于符号程序执行的基本调试和测试功能,并将正常程序执行作为一种特殊情况。系统提供了一个“全面”测试管理器,用于系统地探索符号执行树中呈现的替代路径。如果提供了输出断言,系统可以自动检查测试用例的结果。最后,EFFIGY 提供了一个程序验证器,该验证器使用符号执行和用户提供的断言来生成验证条件,这一思想通常遵循 Deutsch 的方法【4】。本文侧重于符号执行作为一个独立的概念及其在程序测试中的应用。

    在 EFFIGY 中,符号执行所支持的语言在每个新版本中都有所增强,现在采用了 PL/X 风格的语法,并包括以下内容:

    1. 外部过程(External procedures)采用 PL/I 参数传递约定。

    2. 整数值变量(仅限)和一维整数数组。这些变量可以声明为 STATIC(静态)或 AUTOMATIC(自动),并可以具有 INITIAL(初始)属性。变量的数据类型为 FIXED BINARY (31, 0)。

    3. 赋值语句、IF语句(包括 THEN 和 ELSE 子句)、使用 DO... END 的复合语句以及 GO-TO 语句。

    4. 迭代的 DO 语句和 DO WHILE 语句。

    5.基本的读和写语句。

    6. 算术、关系和逻辑运算符仅包括以下内容:+,-,*,/,**,ABS,MOD(remainder);≧,≤,>,<,=,≠;&(and),|(or),­­ ¬ (not), (implies)。

    系统还提供了一整套交互式调试设施,包括:

    1. 跟踪功能(Tracing):用户可以请求查看语句编号、源代码语句、计算结果,或这些内容的任意组合,适用于在程序执行时的任何或所有语句。这种跟踪功能允许用户详细了解程序的执行过程,并帮助调试和分析程序行为。

    2. 断点(Breakpoints):用户可以在任何语句之前、之后或任意两个语句之间插入“断点”。在这些断点处,程序执行会被中断,并将控制权转交给用户的终端。用户可以在断点处检查执行状态,设置变量,并继续程序执行。

    3. 状态保存(State Saving):用户在探索程序的不同路径时,可能希望保存当前的执行状态,以便稍后返回并探索其他路径。为此,提供了“SAVE”(保存)和“RESTORE”(恢复)功能。

    系统还包括符号执行的基本功能和命令。用户可以通过将任意标识符用双引号括起来(如 "a")来定义符号程序输入(之前提到的 a),并用它们替代具体的整数常量。例如,用户可以通过以下方式调用用于测试的 SUM 过程(来自图 1)。

    前面给出的符号执行定义规定,对于分叉(不可解析的)IF 语句执行,执行过程会在两个替代路径(THEN 选择和 ELSE 选择)上并行进行,从而生成一个完整的执行树。对于大多数程序来说,这是一种无限的过程。解决这个问题的最简单、也许是最通用的方案是让用户交互地选择一次要采取的单一路径。EFFIGY 提供了这一基本功能。通过使用之前提到的 SAVE 和 RESTORE 命令,用户可以保存执行状态,并稍后返回以探索其他路径。这允许用户从根节点开始以任意方式“遍历”执行树。

    每当系统遇到一个分叉执行的 IF 语句(两个选择都可能)时,它会通知用户并允许他进行选择。用户可以:

    1. 输入“go true”,系统将遵循 THEN 选择并相应地更新路径条件(pc)。

    2. 输入“go false”,系统将遵循 ELSE 选择并相应地更新路径条件(pc),或者

    3. 输入“assume (P); go”,系统将假设条件 𝑃 成立并继续执行。

    在第三种形式中,𝑃是一个谓词,它首先使用当前的程序变量值进行评估,然后将其添加(合取)到路径条件(pc)中。此时的“go”指示系统使用修改后的 pc 重新执行 IF 语句。

    例如,假设程序变量 𝑋的值为𝑎,路径条件(pc)的值为𝑎>0,并且正在执行的 IF 语句的形式为:IF X > 5 THEN S1 ELSE S2;

当评估时,𝑋>5变为𝑎>5。由于路径条件(pc),系统无法解析路径选择,因为既不满足以下条件:

    系统邀请用户进行选择。如果用户输入“go true”,pc 会更新为 a>5(形成a>0 &>5),然后执行语句 S1。如果用户输入“go false”,pc 会更新为 a>0 &¬(a>5),然后执行语句 S2。如果用户输入“assume (a > 10); go”,pc 会变为 a>10(由 a>0&a>10 形成),然后重新执行 IF 语句。这次a>10a>5是真的,程序执行语句S1。用户可以通过输入“assume (X > 10); go”来获得相同的结果,因为在第一步中,X>10 会被评估为 a>10。

    当系统在分叉的 IF 语句执行中询问用户选择时,实际上它处于与用户在 IF 语句之前通过断点停止执行时相同的状态。因此,在用户输入“go true”、“go false”或“go”之前,他可以检查程序变量、设置断点、调整跟踪设置等。尽管 ASSUME 语句的定义最容易通过上述用法来说明,但其执行是独立于 IF 语句的。因此,ASSUME 语句可以在任何断点处输入,也可以包含在过程内,并且将产生以下效果:

    1.计算ASSUME 语句的布尔值。

    2. 将结果与当前的路径条件(pc)合取(如果一致)。

    例如,ASSUME 语句可以在程序的开始处使用,以防止系统考虑它未设计用于处理的输入。

    第2节的讨论为了教学上的简便性而限制在整数多项式上。多项式的标准形式是众所周知的,且多项式集合在加法和乘法下是封闭的。在这种情况下,从算术到代数的推广是较为容易理解的。EFFIGY 系统的实现是为了处理上述更一般的表达式类。当然,人们希望拥有一个能够处理像 PL/I 这样的通用编程语言的 EFFIGY 系统。这对系统的公式处理和简化组件提出了极高的要求。实际上,EFFIGY 系统的这一组件是从作者之前构建的程序验证器 [8] 中借鉴来的。EFFIGY 的公式处理能力和局限性直接继承自该早期系统,详细讨论见 [9]。

7 一个进一步的例子

    [10]中也提供了对基本 EFFIGY 系统的简要描述。那篇论文附录中包含了一个实际 EFFIGY 会话的脚本,这可能对一些读者感兴趣。接下来是对如何在 EFFIGY 上检查图10中的程序 SEARCH 的说明。这些步骤可以在 EFFIGY 系统上实际执行。

图10 程序SEARCH

    程序 SEARCH 是为了在一个按升序存储的数组 A 中执行二分查找某个参数 X 而编写的。查找仅限于数组中下标从 L 到 U(包括 U)的元素。如果找到匹配项,则匹配 X 的数组元素的下标值将存储在 J 中,并将 FOUND 设置为 1。否则,将 FOUND 设置为 0,并将 J 设置为满足 A(J) < X < A(J+1) 的值。由于 (U-L) 的初始值可以任意大,因此该程序的符号执行树是无限的。

    SEARCH 的第一次测试可能是以下形式:CALL SEARCH (A, 1, 5, "X", FOUND, J). 假设数组 A 的元素 A(1), A(2), ..., A(5) 分别被设置为符号值 "A(1)", "A(2)", ..., "A(5)"。常量 1、5 和 "X" 是输入参数,而 FOUND 和 J 是整数变量,它们将返回 SEARCH 的结果。符号执行将继续进行到语句编号 7,在此点系统将询问用户 "X" 是否等于 "A(3)"。如果用户在系统的查询下输入 "save; go true",当前执行状态将保存为状态 1,并且执行将运行到完成,确定 pc = ("X" = "A(3)"), FOUND = 1, J = 3。现在通过输入 "restore 1; go false",用户可以检查 "X" 不等于 "A(3)" 的其他可能性。继续以这种方式,用户可以探索由输入 1 和 5 确定的有限子树,在这种情况下找到十一条终端路径:

    用户还可以通过使用 EFFIGY 中的 TEST 功能,自动生成这十一条输出:TEST (200) SEARCH (A, 1, 5, "A", FOUND, J)。这里的 200 用于将符号执行树的穷尽搜索限制在遍历少于 200 个语句执行的路径上。在这种情况下,这个限制是多余的,因为树是有限且较小的。该测试提供了一些证据,表明程序能够成功找到数组中的任意元素。

    接下来可以尝试:CALL SEARCH (A, "N", "N" + 4, "X", FOUND, J)。这是对先前测试的推广。同之前一样,如果全面进行此执行,将产生十一种终端情况,这些情况与之前的相匹配,其中 1 将被替换为 "N",2 将被替换为 "N" + 1,等等。例如,上述列出的第二种情况将变为:

    该测试为我们提供了额外的保证,即数字 1、2、...、5(例如作为 2 的幂)没有对我们之前的结果产生特殊影响。

    另一个相似但更特殊的测试是:CALL SEARCH (A, "N", "N", "X", FOUND, J)。当进行全面探索时,这将生成一个包含三个叶子的树:

    两个调用:CALL SEARCH (A, 1, "N", "X", FOUND, J) 和CALL SEARCH (A, "U', "U", "X", FOUND, J)。 这些是最通用的测试,可以用来检查“边界平均化”和“边界范围缩小”是否正确进行。尽管这两种调用都会导致无限的符号执行树,但可以得到像以下这样有趣的结果(从第一次调用中得出):

    请注意,在执行所有这些测试时,数组 A 或参数 X 都不会提供非符号值。程序中这些值的重要属性(例如,“X” ≤“A(N)”)是在符号执行过程中揭示的。所有测试都仅基于原始程序进行——不需要正确性谓词。如第8节进一步讨论的,输入、输出、归纳和检查谓词可以与程序的符号执行结合使用,以非常有效地进行测试和正确性证明,但它们不是符号测试所必需的。与正常执行一样,在使用符号执行测试程序时,必须小心选择“有趣”的测试用例,并决定何时测试足够。

8 程序正确性、证明和符号执行

    为了使用 Floyd [6] 提出的方法证明程序的正确性,程序员需要为程序提供一个“输入谓词”和一个“输出谓词”。这些谓词定义了程序的“正确”行为,如果对于所有满足输入谓词的输入,程序生成的结果(如果有的话)都满足输出谓词,那么程序就是正确的。Floyd 提出了一种方法来检查程序及其输入/输出谓词的一致性,从而证明其正确性。

    Floyd 证明方法的一个步骤是生成验证条件,这可以通过对程序路径进行符号执行来简单地完成。Deutsch 独立地在他的交互式程序验证器 [4] 中发展了符号执行的概念,以利用这种证明技术。

    或许解释这种技术最简单的方法是使用三个辅助语言语句:ASSUME、PROVE 和 ASSERT,这些语句用于将谓词与程序关联。所有这三个语句都有一个布尔公式作为语句的一部分,在语句名称后的括号中提供,例如 ASSERT(X>0)。这些公式的自由变量被假定为程序变量。ASSUME(B) 先前在第 6 节中已经定义过。当执行时,B 使用当前的程序变量值进行评估,结果值与路径条件结合在一起(即 pc ← pc ∧ value(B))。

    执行 PROVE(B) 语句时,形成的表达式为:pc⊃value(B), 并尝试确立它是一个定理,然后相应地显示真或假。在下面的使用方式中,这些表达式实际上将是 Floyd 验证条件。ASSERT 语句稍后将根据上下文用作 ASSUME 或 PROVE 语句。

    除了用于定义程序正确性的初始和最终谓词之外,Floyd 方法还需要在程序的各个点关联额外的 "归纳谓词"。这通常是为了确保在程序中的每个循环中至少有一个谓词,但它也可以以任何方式进行,只要由谓词之间的程序段定义的控制流路径是有限长度的。归纳谓词允许进行一般的归纳论证(参见 [6] 或 [7]),从而将证明程序正确性的任务简化为证明一组有限数量和有限长度路径的正确性。

    假设通过在适当的位置将 ASSERT 语句放入程序中,已经将输入、输出和归纳谓词与程序关联起来(初始谓词是放在程序第一条语句处的 ASSERT 语句,等等)。现在,我们有一组固定的程序路径,每条路径都以 ASSERT 语句开始,以 ASSERT 语句结束,并且每条路径都必须被证明是正确的。也就是说,必须展示出,使用满足路径起始处谓词的任意变量值集,沿路径执行所得到的结果值必须满足路径终点处的谓词。

    可以通过按如下方式对每条路径进行符号执行来证明其正确性:

1. 将路径起始处的 ASSERT 语句改为 ASSUME,将路径结束处的 ASSERT 语句改为 PROVE。

2. 将路径条件初始化为 true,并将所有程序变量初始化为不同的符号,例如 a1, a2, ......

3. 以符号方式执行路径。每当遇到未解决的 IF 语句执行时,执行“go true”或“go false”,以使执行沿着指定的路径继续。

4.如果路径末尾的 PROVE 显示为 true,则路径是正确的,否则路径是不正确的。

    假如符号执行满足第五节中讨论的交换性质,那么可以很容易看出这是一个有效的证明方法。程序证明是基于路径开始时程序变量的值进行的,并给这些值赋予符号名称。路径条件累积了对这些初始值的假设。执行第一个 ASSUME 语句时,会在路径条件中记录对这些初始值所需的假设("需要证明的是,使用任何满足路径开头谓词的变量值,路径执行后的结果变量值也必须满足路径末尾的谓词")。赋值语句所计算的公式会将程序变量的值更新为路径起始值的函数。未解决的 IF 语句执行时,路径条件中再次记录了为了执行该特定路径所需的额外假设,仍然是关于路径的初始值的假设。

    最后,路径末尾执行的 PROVE 语句建立了一个定理候选(验证条件),其表达了以下问题:假设路径开始时的谓词满足,并且我们沿着这条路径执行(记录在路径条件 pc 中),那么当前路径末尾的程序变量值是否“满足路径末尾的谓词?”

    给定像 EFFIGY 这样的符号执行系统,通过添加 PROVE 语句和管理控制器来枚举路径并在未解决的 IF 语句执行时强制选择路径,可以概念上很直接地使其尝试正确性证明。我们已经这样做,并将其作为研究正确性证明技术的工具。

    事实上,正确性证明和符号执行的概念不仅在概念上密切相关,而且在执行它们所需的工具上也是如此。假设将 Floyd 的输入/输出谓词与程序关联,通过在程序开头放置输入谓词作为 ASSUME 语句,并在结尾放置输出谓词作为 PROVE 语句。按照此处给出的 ASSUME 语句的定义,初始 ASSUME 会将后续分析(无论是符号执行还是正确性证明)限制在满足初始谓词的值范围内。采用正确性证明所需的 PROVE 语句的定义对程序测试(包括符号测试)也是非常有用的。在程序测试中,无论是否使用符号执行,必须检查生成的输出并判断其正确性。如果能够将正确性标准形式化为输出谓词,并将其放置在程序末尾的 PROVE 语句中,符号执行器也可以对测试结果进行适当的检查。

    另一个评论与 PROVE 语句的定义有关。几种语言确实提供了对程序中放置的谓词进行正常运行时检查的支持,例如使用 ASSERT 语句(例如 Algol W【14】)。这样的语句通过考虑以下内容可以很容易地实现:

    由于这是一个普通的IF语句,可以考虑它的符号执行。如果程序是正确的,那么IF语句的执行应始终能解析为“假”的路径。根据符号执行中对IF语句的定义,只有在以下情况下才会发生这种情况:pc⊃valueB. 请注意,这正是 PROVE 试图证明的内容!

    对于任何符号执行树是有限的程序,并且其正确性标准通过输入/输出谓词明确规定,符号执行的穷尽搜索和上述正确性证明过程实际上是相同的。由于不需要归纳谓词,需要证明的路径集合是从输入谓词到输出谓词的路径,这些路径正是由有限的执行树所描述的。

    对于具有无限执行树的程序,符号测试无法穷尽,因此无法建立绝对的正确性证明。增强符号执行以在所有情况下提供正确性证明的显而易见方法是对执行树的无限部分进行某种形式的归纳。这实际上正是Floyd的正确性证明方法所做的。放置在循环中的归纳谓词为符号执行提供了必要的归纳支持,使其能够“执行”在执行树的无限分支上。Topor和Burstall最近也采取了类似但不同的方法,以提供归纳支持,从而“执行”无限分支并提供正确性证明。

    然而,在正确性证明中对谓词操作功能的要求,与符号测试所需的能力之间有一个显著差异。如果仅限于符号执行而不引入任何用户定义的谓词,那么路径条件 (pc) 和需要证明的表达式都是由编程语言在语法和语义上确定的。然而,在正确性证明中,谓词的语义来自程序所解决的问题领域,而非编程语言本身。

    正是这一差异使我们相信,符号执行用于测试程序在短期内是一种更可行的技术,而不是更为广泛的程序验证技术。

9 实际问题

    在程序验证系统中遇到的许多棘手问题也出现在符号执行中。例如,二者的一个共同问题是如何以实际的方式处理变量存储引用。比如,数组符号A(I)会根据I的值引用数组A中的不同元素。当I的值是一个符号表达式时,被引用的具体元素取决于程序初始输入的函数。在许多情况下,即使检查了关于这些输入的所有已知信息(如保存在路径条件pc中),具体的引用仍可能本质上模糊不清。

    对于这个特定问题,至少有两种可能的解决方法,尽管都不太令人满意。

1. 可以进行全面的案例分析,就像在类似的未解决的IF语句执行中所做的那样。

2. 可以将模糊性保留而不解决,通过存储变量的条件值。例如,A(I) 的值(假设 I 的值为 i)可能是“如果 i = 1,则为 x;如果 i = 3,则为 y;如果 i = j + 1,则为 z……”。

    计算机算术的离散特性与实数的连续性(具有无限精度)之间的冲突也是符号执行中的一个问题。为了帮助所需的定理证明并使公式更具可读性,应尽可能简化它们。然而,如果坚持要求结果表达式在计算机评估时产生与原始表达式相同的值,则许多强大的简化将被禁止,因为这会违反第五节讨论的交换律。此外,谓词的真值与自动定理证明系统建立其真值的能力之间存在差距。特别是,自动系统可能会得出某些 IF 语句执行是未解决的结论,实际上并非如此。然后,系统将遵循在实际执行中绝对不可能遵循的路径。前面在第二节末尾声称的 pc 永远不会为假这一说法不再成立。当然,即使对于相对简单的编程语言,执行该语言程序所需的定理证明在理论上也是不可能的。也就是说,不可能构建一个定理证明器来判断该类表达式的真或假。

10结论

    本文提出了一种符号执行程序的概念,这与正常的程序执行概念密切相关。它的优点在于,单次符号执行可以代表大量通常是无限的正常执行。这在程序测试和调试中具有很大的优势。同时介绍了作者及其同事开发的 EFFIGY 系统,该系统将符号执行融入了通用的交互式调试系统。基于基本的符号执行能力,还包括“全面”测试管理器和程序验证器。交互式调试/测试系统是程序开发的强大和实用工具,基于符号执行的功能增强了系统的性能,同时仍然保留了正常功能作为特例。

    符号执行在其他形式的程序分析中也很有用,包括测试用例生成和程序优化。像 EFFIGY 这样的符号执行系统是对现有系统的自然扩展;通过渐进式的方法,可以实现未来的程序验证系统。拥有一个可以逐步增强的运行系统也提供了宝贵的用户体验和支持。虽然 EFFIGY 系统的实际应用仍然相当有限,但在其构建过程中,人们对符号执行的一般概念及其多种应用获得了相当大的洞察力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值