幸运的兔脚

2018年09月26日

NASA的10条安全编码准则

大型而复杂的软件项目使用某种编码标准和准则。这些准则确定了编写软件时应遵循的基本规则。

  1. 代码应如何组织?
  2. 应该或不应该使用哪种语言功能?

出于效果的角度考虑,这些原则必须尽可能精简并且足够具体,这样才能更好地被人理解、记忆。

在 NASA 工作的全球顶级程序员遵循一套开发安全关键代码的准则。实际上,包括 NASA 的喷气推进实验室 (JPL)在内的许多组织都 将重点放在用 C 编程语言编写的代码上。原因是,该语言有广泛的工具支持,包括逻辑模型提取器,调试器,稳定的编译器,强大的代码分析器和指标工具。

有时,使用编码规则是必不可少的,尤其是当代码的正确性会对人的生命产生决定性影响的时候。JPL 首席科学家 Gerard J. Holzmann 提出了 NASA 针对安全参数的 10 条编码规则。该准则也可以应用于其他编程语言。

10 条安全编码准则

1、简化控制流程

使用尽可能精简的控制流程构造编写程序,不要使用 setjmp、 longjmp 构造和 goto 语句,以及直接或间接的 recursion。

原因:简化控制流程有助于提高代码清晰度,增强代码可验证能力。不使用递归,便不会产生循环的函数调用图,这样也可证明所有本应有界的执行实际上都是有界的。

2、为循环使用固定次数上限

所有循环必须有固定次数的上限,我们可以通过验证工具静态地证明,为循环中迭代数量所设立的上限次数未被超越。

如果无法以静态方式对循环的次数界限加以证明,则可认为未遵守该原则。

原因:为循环设置次数界限,避免使用递归,这些做法有助于预防代码失控。然而该原则无法适用于本就不应终止的迭代(例如进程调度器)。此时将沿用该原则的逆向原则:必须能够静态地证明迭代不能终止。

3、不使用动态内存分配

不要在初始化完成后进行动态内存分配。

原因:为诸如 malloc 等内存分配机制,以及垃圾回收器通常会产生无法预知的行为,进而可能会对性能产生影响。更重要的是,还有可能因为程序员的失误造成内存错误,例如:

l 试图分配超过可用物理内存数的内存

l 忘记释放内存

l 继续使用已被释放的内存

l 对已分配内存进行越界使用

应强制所有模块位于固定大小、预先分配的存储区域中,借此可避免此类问题,并简化内存使用情况的验证工作。未分配内存的情况下,动态请求内存的唯一方式是使用栈内存。

4、不使用冗长的函数

任何函数的长度不应超过使用标准参考格式(每个声明最多一行、每个语句最多一行),也就是在打印的纸张上一页纸所能容纳的字符数——这意味着函数的代码不应超过 60 行。

原因:过长的函数通常意味着结构并非最优,每个函数都应是可理解、可验证的单一逻辑单位。如果在计算机显示器上需要多屏界面才能完整显示,这样的逻辑单位通常会极难理解。

5、低断言密度

图片来源:rankred

程序的断言密度(assertion density)应平均保持为每个函数最少两个断言,断言可用于检查现实运行过程中本绝不应出现的异常状况。因此应定义为 Boolean 测试。当断言失败后,应执行明确的恢复操作,如果静态检查工具证明断言绝对不会 Fail 或 Hold,则可认为未遵守该原则。

原因:业界的代码编写工作统计报告显示——通过单元测试可发现,通常我们所编写的每 10-100 行代码中至少会存在一处缺陷。随着断言密度的增高,拦截缺陷的机会也会增大。

断言的另一个重要之处在于,它是防御性编程(Defensivecoding)策略的重要组成部分。我们可以使用断言验证函数执行前后的状况,函数的执行参数和返回值,以及循环不变式(Loop-invariant),在完成性能关键代码的测试工作后,可将断言选择性地禁用。

6、以最小范围级别声明数据对象

该原则同时也是数据隐蔽(Data hiding)的基本原则。所有数据对象均必须以尽可能最小的范围级别进行声明。

原因:如果某对象不在范围内,意味着其值将无法引用或已损坏。该原则不鼓励出于多种可能导致故障诊断工作变得更复杂的互斥意图重用变量。

7、检查参数和返回值

应在每次调用函数后检查非空函数的返回值,并应在每个函数内部检查参数的合法性。在最严格的形式下,该原则意味着就算 printf 语句和文件 close 语句的返回值也应进行检查。

原因:如果对错误结果的响应与对成功结果的响应没有任何区别,那么很明显需要检查返回值。通常对 close 和 printf 的调用便符合这种情况,此时一种可行的方法是将函数的返回值明确抛出给 void,这意味着开发者明确(而非意外地)决定忽略该返回值。

8、限制预处理程序的使用

预处理程序(Preprocessor)应仅限用于头文件和宏定义。递归的宏调用、令牌传递,以及变量参数列表均不允许使用。

就算大型应用程序开发工作中,标准样板文件(Boilerplate)之外也可能有必要使用一两个以上的条件编译指令,这是为了避免将同一个头文件包含多次。每个这种用法必须通过工具检查器添加标记,并通过代码阐述原因。

原因:C 语言预处理程序是一个强大但较为含糊的工具,有可能彻底破坏代码的清晰度,并让很多基于文本的检查器产生混淆。就算具备正式的语言定义,包含无界限预处理程序代码的构造也会显得非常难以解读。

有关条件编译的注意事项同样很重要,就算只使用 10 个条件编译指令,代码也有会产生 1024(2^10)个可能的版本,这会导致测试工作量剧增。

9、限制指针的使用

指针的使用必须加以限制。通常只允许不超过一层的解引用(Dereferencing),指针解引用操作不应隐藏在 typedef 声明或宏定义内部,此外函数指针也是不允许使用的。

原因:指针很容易被滥用,就算专家也难以彻底避免。指针的存在会使得我们难以跟踪或分析程序中数据的流动,尤其是在使用基于工具的静态分析器执行这些操作时。

函数指针还会对静态分析器所能执行的检查类型产生限制,因此除非有非常必要的理由,否则一般不推荐使用。如果使用函数指针,通常将几乎无法通过工具证明递归的缺席,此时只能提供其他方法弥补这种分析能力的缺失。

10、编译所有代码

从开发工作第一天开始时,就必须对所有代码进行编译。必须启用编译器的警告功能,并使用最细致的检查选项。代码必须能通过这样的设置、在不产生任何警报的情况下顺利编译完成。

所有代码必须每天一次、且使用至少一种(多种则更好)最新型的静态源代码分析器进行检查,并且必须顺利通过分析器的整个检查过程而不产生任何警告。

原因:市面上有很多效果卓越的源代码分析器,其中很多甚至是以免费软件的形式发布的。对于这样可以直接使用的现成技术,任何软件开发工作都没理由不加以充分利用。

如果编译器或静态分析器遇到问题,导致问题或错误的代码必须重写,这样才能进一步改善代码质量。

以上这些准则是基于 NASA 喷气推进实验室首选的 C 语言,但同样适用于其它编程语言。它们就如同汽车安全带,也许一开始会让你觉得有些不舒适,但一旦用上它们,过不了多久,就会变成每个人的第二本能,到时候如果不这么做反倒会觉得不舒服。

参考资料
《applying nasa coding standards to java》
《NASA’s 10 rules for developing safety-critical code》
《NASA’s 10 Coding Rules for Writing Safety Critical Program》