利用 SPARK 95 和 GNORT 对安全关键应用进行重新工程化
1 引言
如今,Ada95 的实现开始涌现,且至少宣称适用于高完整性系统。工程师们可能会考虑在新项目中使用 Ada95,或者将现有应用转换为 Ada95,但目前缺乏实证来表明这种转变能带来哪些好处。本文主要介绍 GNAT-No-Runtime(GNORT),它是 GNAT Ada 95 编译器的一个开发版本,适用于高完整性嵌入式系统的生产。同时,还将介绍一个现有的安全关键应用(SHOLIS)是如何使用 GNORT 进行重新工程化的。这项工作主要有两个目标:
- 评估 GNORT 允许的 Ada 95 子集是否是 SPARK 95 支持语言的超集,并为其开发者提供在编译实际应用时的早期性能反馈。
- 评估使用 SPARK 95 和 GNORT 对现有应用进行重新工程化在程序大小、目标代码质量等方面能带来的改进。
2 GNORT
2.1 GNORT 开发的动机
“运行时库”是随着更高级的高级语言出现的一个相对较新的概念。对于安全关键代码的开发,由于复杂语言语义(如任务处理和异常处理)需要运行时代码来支持基本语言语义,这引入了新的考虑因素。有两种方法可以解决:一是对运行时库本身进行认证;二是完全消除运行时系统。
| 方法 | 优点 | 缺点 |
|---|---|---|
| 认证运行时库 | - | 成本高,可能需要对运行时进行重大重新工程化,且处理不同认证要求时程序上较麻烦 |
| 消除运行时系统 | 完全解决认证问题,可根据应用的特定认证需求对可执行映像的整个代码进行认证 | 需要对语言进行较严格的子集化 |
实际上,许多安全关键应用出于认证原因已经使用语言的小子集,所以这种子集化带来的限制可能是可以接受的。值得注意的是,GNORT 可以处理 SPARK 语言中出现的所有 Ada 语言结构。
2.2 GNORT 在高完整性系统开发中的潜在用途
在高完整性系统中使用 Ada 有很多吸引人的原因。Ada 的强抽象设施和强类型语义意味着许多错误可以在编译时被捕获,这有助于提高应用的可靠性,从而更轻松地生成高完整性系统。这里主要关注 Ada 的静态语义设施,而不是像异常这样的运行时设施,因为在安全关键系统中不太可能使用这些运行时设施。
使用 GNORT 的运行时模型与 C 大致相当,非常简单。但 GNORT 的优势在于,在实现这种简单性的同时,不会牺牲 Ada 95 精心设计的高级特性,这些特性提供了出色的抽象设施和强大的编译时检查能力。
此外,GNAT 致力于为所有生成的文件使用标准系统格式,这意味着可以使用非常简单的编译模型。Ada 单元被简单编译,生成标准系统格式的目标文件,这些目标文件可以使用任何标准工具链进行处理,不会引入特定于 Ada 的工具链或运行时环境问题。调试信息设计为可与 C 风格的调试器一起使用,同时也包含可供更复杂的 Ada 调试器使用的编码,以提供对 Ada 数据结构的完全访问。
2.3 GNORT 的实现
GNORT 实现的第一步是提供一个配置编译指示
No Run Time
,它限制了可以使用的语言特性。如果使用了需要访问运行时库的特性,会发出致命错误。同时,对绑定器进行了修改,以确保该编译指示在分区的所有单元中统一使用,消除基本运行时例程的自动包含,并生成一个完全独立的主程序。
然而,实现过程中发现,可支持的语言子集取决于内联是否激活。在 Ada 95 中,使用
Inline
编译指示并指定
-gnatn
开关可以激活内联。在
-gnatn
存在的情况下,一些重要特性(如动态调度)可以在 GNORT 模式下使用,因为运行时例程的主体可以内联到生成的代码中,从而消除对运行时例程本身的需求。
为了扩展可支持的子集,实现了一个
Inline Always
编译指示,即使未指定
-gnatn
也会进行内联,并将该编译指示应用于一些关键例程,从而显著扩展了 GNORT 模式下可以处理的子集。同时,绑定器也进行了相应修改,以理解即使调用了运行时库单元中的例程,内联主体后也不需要运行时单元本身。
2.4 GNORT 与其他高完整性 Ada 编译系统的比较
GNORT 是一种简单且经济高效的方法,其提供的子集足够大,可以使用 Ada 的大多数重要功能。与 Aonix C - SMART 系统相比,两者都提供 Ada 的子集,且子集非常相似,但 Aonix 方法需要一个小的运行时系统,对其进行认证成本较高。
Aonix 最近推出的 Raven 产品扩展了系统以包含 Ravenscar 配置文件,这需要额外的运行时支持。GNAT 技术有两种可能的扩展来提供类似功能:
- GNARP(GNAT No - Runtime with Ravenscar Profile):依赖于在 JGNAT(Ada 95 到 JVM)产品中使用 Java VM。JVM 提供的本地任务处理非常简单,但足以支持所有 Ravenscar 任务构造的内联生成。结合提供硬件任务支持的 Java 芯片,纯 GNORT 方法可以扩展到涵盖 Ravenscar 配置文件。
- 在其他环境中,GNAT 现在提供了一个几乎完全对应于 Ravenscar 配置文件的受限运行时选项。未来的开发将简化这个受限运行时,可以提供一个实现该运行时的认证组件,或者更实际地,记录所需的非常简单的接口,并期望应用程序提供必要的简单任务构造。
此外,GNAT 采用的开源软件分发模式特别适合无运行时的方法。在专有模式下,GNORT 某种程度上“什么都没有”,很难对产品本身收费;而在开源模式下,主要收取支持费用,可以轻松适应这种方法。Ada Core Technologies 会在正常支持费用基础上收取适度(额外 25%)的费用,以提供对 GNORT 功能的全面支持。
3 SHOLIS
3.1 SHOLIS 软件
Ship/Helicopter Operational Limits Instrumentation System(SHOLIS)是一个舰载计算机系统,用于在各种场景下为船员提供直升机操作安全性的建议。它是一个容错、实时的嵌入式系统,是第一个满足临时国防标准 00 - 55 对安全关键软件要求的系统。
该标准提出了一些挑战,要求使用严格定义的小编程语言。SHOLIS 几乎完全用 SPARK 83 构建,它是 Ada83 的一个子集,并增加了形式化注释,允许进行静态分析和程序证明。SPARK 消除了不适合形式化定义、程序证明和静态分析的语言特性,以及可能导致运行时或内存使用不可预测行为的特性。它还消除了几乎所有 Ada83 的实现依赖。
SHOLIS 代码约 133000 行,包括约 13000 个声明和 14000 条语句。使用的编译器是 Alsys(现在的 Aonix)的 Ada83 到 68k 交叉编译器,带有“SMART”运行时系统,且所有优化选项都被禁用。
在 SHOLIS 软件的构建过程中,主要面临以下挑战:
-
实时性与可证明代码的平衡
:编写既易于形式化证明又能以可接受速度运行的软件是一个重大挑战。例如,纯函数式编程风格对证明有帮助,但由于从函数返回大型数据结构的成本在执行时间和内存使用方面都过高,因此在 SHOLIS 中被放弃。
-
非功能性实现依赖
:SPARK 消除了 Ada 中的大多数语义实现依赖,但仍发现一些非功能性属性会生成不可接受的代码。例如,在使用大聚合初始化复合常量时,编译器会生成在堆上分配临时对象的详细代码,即使该聚合可以在编译时求值。由于 SMART 默认没有堆管理器,这些情况需要通过逐字段初始化代码来解决,这通常被认为是糟糕的编程风格。
-
无异常性
:SPARK 和 SMART 都没有异常,SHOLIS 的要求需要静态分析来表明预定义异常不会被引发。SPARK 检查器生成验证条件,通过证明这些条件可以确保程序没有约束错误。任务错误和程序错误通过排除引发这些错误的语言特性或通过检查器进行简单静态分析来消除。存储错误通过对生成代码进行静态分析以确定最坏情况下的堆栈使用来消除。SHOLIS 经过精心编码以避免从堆中动态分配对象,这些分析还允许合理地关闭编译器生成的运行时检查,从而提高系统的运行时性能和开发者对系统的信心。
graph LR
A[SHOLIS 软件构建挑战] --> B[实时性与可证明代码的平衡]
A --> C[非功能性实现依赖]
A --> D[无异常性]
B --> E[放弃纯函数式编程风格]
C --> F[逐字段初始化复合常量]
D --> G[静态分析消除异常]
4 重新工程化 SHOLIS
4.1 阶段 1 - 最小化移植到 SPARK 95
在这一阶段,使用自托管版本的 GNORT 将现有的 SHOLIS 软件移植到 SPARK 95。这几乎不需要什么努力,只需将现有的实现定义编译指示(如
No Image
)替换为 Ada95 等效项,并调整地址表示子句以与 Ada95 的
System
包兼容。完成这些步骤后,GNORT 首次编译 SHOLIS 就成功了,并且没有指出 SHOLIS 中使用的任何语言特性与它支持的语言子集不兼容,这在一定程度上证明了 GNORT 编译的语言子集是 SPARK 95 的超集。
4.2 阶段 2 - 利用 SPARK 95 的新特性
阶段 1 得到了一个可用的 SHOLIS 版本,但通过采用 SPARK 95 的新特性仍有改进的空间。
-
use type
子句
:在 SPARK 中,不允许使用
use
子句,因此在 SPARK 83 程序中通常需要显式重命名运算符以使类型的中缀运算符直接可见。SPARK 95 引入的
use type
子句可以消除这种重命名。在 SHOLIS 中,这种简化使应用程序的大小减少了约 270 行源代码。
-
Atomic
编译指示
:在 SHOLIS 中,约 100 个库级变量表示特定 I/O 设备的寄存器和缓冲区。大多数设备只允许使用特定大小和对齐方式访问这些寄存器,并且所有输入设备和可读控制寄存器都必须被视为
Volatile
。此外,一些 I/O 寄存器需要多次读写,需要确保生成的代码中对每个设备的访问次数符合预期。在原始的 SHOLIS 版本中,只能依靠关闭编译器的优化器并进行精心编码来确保变量的大小、对齐和易失性得到尊重。Ada95 提供的
Atomic
和
Volatile
编译指示可以在保留预期动态语义的同时,允许启用优化。
-
初始化中的聚合
:之前提到,为了避免在堆上隐式分配大型临时对象,SHOLIS 中的一些复合对象必须逐字段初始化。而 GNORT 总是在栈上分配这些临时对象,因此可以恢复原始代码。例如,逐字段初始化数组:
--# pre True;
for I in A_Index loop
A(I) := 0;
--# assert (forall J : A_Index, A_Index’First <= J and
--# J <= I -> A(J) = 0);
end loop;
--# post A = A_Type’(others => 0);
在 SPARK 的程序证明模型中,这段代码有 3 条基本路径,检查器会生成 3 个验证条件,证明这些条件并非易事,不能由自动 VC 简化器完成,必须使用交互式证明检查器手动完成。而使用聚合初始化的替代代码:
--# pre True;
A := A_Type’(others => 0);
--# post A = A_Type’(others => 0);
只有一条基本路径,生成的验证条件可以由简化器轻松证明。
-
详细控制
:通过各种规则,SPARK 程序不会表现出对详细顺序的任何实现依赖。不过,尝试使用 Ada95 的
Preelaborate
和
Pure
编译指示来确定它们的潜在用途仍然是有意义的。SHOLIS 中的各种包大致可分为三类:
- 仅声明类型和对这些类型的简单操作的包,通常是“无状态”的,不声明变量。
- 实现设备和 I/O 驱动程序的包,通常声明使用地址表示子句映射到物理 I/O 设备的对象。
- 主包,实现 SHOLIS 系统的核心状态和操作。
发现第一类包很容易设置为
Pure
,这会告知编译器此类包中的所有函数也是
Pure
的,即没有副作用。但对于第二类包,大多数情况下不能应用
Preelaborate
或
Pure
,这是因为地址表示子句的正常形式
for A’Address use To_Address(...)
中的
To_Address
函数不是静态函数,不能进行预详细化。此外,大型复合常量的详细化也存在问题,其初始化使用的聚合在 Ada95 中不是静态表达式,编译器不一定会在编译时求值。GNORT 在这方面表现较好,它常常超出语言参考手册(LRM)的要求,在编译时对这些聚合进行求值。
-
ROM 数据和延迟常量
:SHOLIS 包含一个操作场景数据库,物理上位于包含一组闪存 EEPROM 的单个卡上。该数据库可以随时在原地更改、升级或改进,而无需更改主应用程序软件。在 Ada 中,数据库表示为一个大的包,导出一组内存映射对象。在 SPARK 83 中,通常使用带有地址表示子句的变量声明。为了便于检查器分析,需要引入一个影子包规范,包含模仿每个数据库变量声明的常量声明。这样会有两个版本的数据库包:一个用于编译,一个用于提交给 SPARK 检查器。这种方法虽然可行,但影子包的分析时间较长,并且需要保持两个包规范“同步”,这增加了配置控制的复杂性。SPARK 95 允许任何类型的延迟常量,提供了更优雅的解决方案:
ClearScreenC : constant GraphicTypes.GraphicString;
...
private
for ClearScreenC’Address use
System.Storage_Elements.To_Address(...);
pragma Import(Ada, ClearScreenC);
这种方法只需要一个包规范用于编译和分析,并且分析时间也显著缩短。
4.3 重新工程化 SHOLIS 的总结
通过上述两个阶段对 SHOLIS 进行重新工程化,我们可以看到使用 SPARK 95 和 GNORT 带来了多方面的改进。以下是一个简单的对比表格,展示了重新工程化前后的一些变化:
| 对比项 | 重新工程化前 | 重新工程化后 |
| — | — | — |
| 源代码行数 | 约 133000 行 | 减少约 270 行(仅
use type
子句带来的变化) |
| 代码可维护性 | 部分代码因非功能性依赖和证明困难导致维护复杂 | 部分代码简化,证明难度降低,可维护性提高 |
| 性能优化 | 所有优化选项禁用,存在非功能性实现依赖问题 | 可启用优化,解决部分非功能性依赖问题 |
| 异常处理 | 通过静态分析和编码避免异常 | 保持无异常性,静态分析更有效 |
| 数据库处理 | 需要两个包规范,分析时间长 | 仅需一个包规范,分析时间显著缩短 |
graph LR
A[重新工程化 SHOLIS] --> B[阶段 1: 最小化移植]
A --> C[阶段 2: 利用新特性]
B --> D[替换编译指示和调整子句]
C --> E[使用 `use type` 子句]
C --> F[使用 `Atomic` 和 `Volatile` 编译指示]
C --> G[恢复聚合初始化]
C --> H[尝试 `Preelaborate` 和 `Pure` 编译指示]
C --> I[使用延迟常量处理数据库]
D --> J[首次编译成功]
E --> K[减少源代码行数]
F --> L[启用优化]
G --> M[简化程序证明]
H --> N[部分包设置为 `Pure`]
I --> O[缩短分析时间]
5 进一步思考
虽然使用 SPARK 95 和 GNORT 对 SHOLIS 进行重新工程化取得了一定的成果,但仍有一些方面值得进一步思考和探索。
-
语言子集的进一步扩展
:尽管 GNORT 已经能够处理 SPARK 语言中出现的所有 Ada 语言结构,但随着安全关键系统的不断发展,可能需要进一步扩展语言子集以满足更多的需求。例如,对于一些特定领域的应用,可能需要支持更多的并发模型或高级数据结构。
-
与其他工具和技术的集成
:在实际的安全关键系统开发中,往往需要使用多种工具和技术。如何将 GNORT 和 SPARK 95 与其他工具(如代码审查工具、测试工具等)进行有效的集成,以提高开发效率和系统的可靠性,是一个需要研究的问题。
-
性能优化的深入研究
:虽然重新工程化后可以启用优化,但对于一些对性能要求极高的安全关键系统,还需要深入研究如何进一步优化代码。例如,通过对代码进行更细致的分析和调整,减少不必要的内存访问和计算,提高系统的实时性能。
6 结论
使用 SPARK 95 和 GNORT 对安全关键应用 SHOLIS 进行重新工程化是一次有意义的尝试。通过此次实践,我们验证了 GNORT 允许的 Ada 95 子集是 SPARK 95 支持语言的超集,并且在程序大小、代码可维护性、性能优化等方面都取得了一定的改进。
SPARK 95 的新特性,如
use type
子句、
Atomic
和
Volatile
编译指示、延迟常量等,为安全关键系统的开发提供了更强大的支持。而 GNORT 的无运行时系统设计,不仅解决了运行时库的认证问题,还使得编译模型更加简单,与标准工具链的兼容性更好。
总的来说,对于考虑使用 Ada95 进行新安全关键项目开发或转换现有 Ada83 应用的工程师来说,SPARK 95 和 GNORT 是值得考虑的选择。在未来的安全关键系统开发中,我们可以进一步探索和应用这些技术,以提高系统的可靠性和安全性。
6110

被折叠的 条评论
为什么被折叠?



