NASA 的软件开发十条规则

阅读这份完整文档(本地副本)或者原始来源后,再来阅读以下讨论。

这些规则是从编写“昂贵航天器上的嵌入式软件”这一角度制定的。在这种场景下,为了保证不让一个任务功败垂成,选择“容忍大量编程痛点”通常是值得的。我并不清楚,为什么他们不使用为程序验证而生、并且比 C 更适合嵌入式编程的 SPARK(Ada 的子集)。这里我会从“编写编程语言处理器(编译器、解释器、编辑器)以及应用软件”的角度来对这些规则进行批判性评述。

我们需要强调批判性思维的重要性。以下问题值得思考:

  • Gerard J. Holzmann 与我在不同语境下,判断为何会大相径庭?
  • 在没有考虑“你的”实际语境时,你是否可以盲目遵循他的建议?
  • 同样,如果不考虑你所处的环境,你能否盲目地遵循“我的”观点?
  • 如果换成另一种(或更好的)编程语言,这些规则是否依然适用?如果函数指针被驯服,情况又会如何?如果语言像 Ada 那样,提供不透明的抽象数据类型,又会如何?

1. 限制所有代码只使用非常简单的控制流结构 —— 禁止使用goto语句、setjmp()或longjmp(),也禁止直接或间接递归。

需要注意的是,C 的异常处理机制就靠setjmp()和longjmp()来实现,因此这里事实上禁止了任何“异常处理”的用法。

不可否认,如果限制递归、跳转以及没有显式上限的循环,的确能让你确定程序最终会终止;不过,递归函数是否终止,就和循环能否终止一样,可以用一套相对成熟的方法来证明。更重要的是,即便“保证一定会终止”,也不代表会在合理时间内终止:

int const N = 1000000000; for (x0 = 0; x0 != N; x0++) for (x1 = 0; x1 != N; x1++) for (x2 = 0; x2 != N; x2++) for (x3 = 0; x3 != N; x3++) for (x4 = 0; x4 != N; x4++) for (x5 = 0; x5 != N; x5++) for (x6 = 0; x6 != N; x6++) for (x7 = 0; x7 != N; x7++) for (x8 = 0; x8 != N; x8++) for (x9 = 0; x9 != N; x9++) -- do something --; 这个循环遍历的迭代次数是 N10N^{10},若 N=10^9,则次数是 109)1010^9)^{10},也就是 109010^{90}。假设每次迭代只需要 1 纳秒,总时间依旧是 108110^{81} 秒,折合约 7.9×10727.9 \times 10^{72} 年。对人类而言,这和“永远不会停”几乎没有区别。

更糟糕的是,有些问题本来用递归写起来简明易懂,你却要硬改成显式的栈操作,往往会把原本清晰的代码写成容易出错的意大利面条式结构。(我自己做过这种事,做出来的东西并不好维护;网站上也有类似示例。)

2. 所有循环都必须有一个固定的上限。必须让静态检查工具“显而易见”地证明循环迭代次数不会超过某个预设上限。若静态工具无法证明循环上限,视为违规。

这是个老想法。正如前面例子所示,光有一个固定上限并不足以带来实际帮助。你必须尽可能让这个上限“够紧”,并且一旦到达这个上限就得当作运行时错误来处理。

顺带一提,给递归函数加深度限制,也能和“有固定上限的循环”一样安全。

3. 初始化之后,禁止使用动态内存分配。

这也是一个很老的理念。有些嵌入式语言甚至根本没有动态内存分配机制。嵌入式系统常常只有固定的内存,拿不到更多空间,不希望因为要分配新对象而崩溃。

然而,真正想规避风险,意味着模拟动态分配也应该被视作违背此规则的本意。譬如,以下用数组和链表实现的简单内存池虽然形式上没用malloc(),但它在使用上还是存在“和真实分配器一样的问题”,甚至可能更糟糕,因为你把这块数组“绑死”成了特定结构:

``` typedef struct FooRecord *foo; struct FooRecord { foo next; ... };

define MAX_FOOS ...

static struct FooRecord foozone[MAXFOOS]; foo foofree_list = 0;

void initfoofreelist() { for (int i = MAXFOOS - 1; i >= 0; i--) { foozone[i].next = foofreelist; foofreelist = &foozone[i]; } }

foo mallocfoo() { foo r = foofreelist; if (r == 0) reporterror(); foofreelist = r->next; return r; }

void freefoo(foo x) { x->next = foofreelist; foofreelist = x; } ``` 从规则精神来看,与直接调用malloc()/free()并无本质区别;甚至可能更糟,因为这片内存只能用于存FooRecord,而无法应对其他类型的需求。

因此,真正需要的是一个能保证分配行为可控的分配器,并且证明在程序运行时所需的总内存(数据+分配头信息)有一个已知上限。

NASA 禁用动态内存分配还提到“性能不可预测”。但除此之外,我们难道没有用到其他性能不可预测的函数吗?malloc()/free()的不可预测性真的“天生”如此吗?而我们也知道硬实时垃圾回收机制的存在,说明并非完全无解。

NASA 在给出此规则的理由时还说:

如果不使用堆上的动态内存分配,那么唯一能动态获取内存的地方就是栈。而禁止递归(见规则 1)后,栈空间的最大需求可被静态证明,于是可以确保应用必然在预先分配的内存限制内运行。 但实际上,这种说法过于乐观。依据 ISO C 标准(无论 C89、C99 还是 C11),我们并无法得知栈使用的确切上限,因为标准里没有规定编译器对栈帧大小的分配方式。甚至同一个函数,不同编译选项、不同编译器版本或是否内联,都会导致栈空间用量变化。要想得出确切的栈空间上限,只能在具体编译器和特定版本、特定选项下测试、验证。

void f() { char a[100000]; } 编译调试版可能真的会给a[]分配一大块栈空间;若编译器优化到极致,甚至可能内联并完全消除这段代码。若要真正依赖“栈定长”来进行安全性保证,必须对编译器行为进行锁定和验证。

4. 任何函数不应超过一页纸的长度(按常规打印格式、每个声明和语句都各占一行,大约 60 行以内)。

在如今大多数人都在屏幕上阅读代码的时代,用纸张尺寸来做标准似乎有些过时。然而,规则的本意更可能是“阅读和理解一段代码时,最好不要超过一个可控的规模。”

更深入的疑问在于:如果使用更合理的语言结构(如 Algol 60、Simula 67、Algol 68、Pascal、Modula2、Ada、Lisp、ML、OCaml、F#、Clean、Haskell 或 Fortran)并支持嵌套过程,那么怎么衡量“函数长度”?折叠编辑器(folding editor)早在 20 世纪 80 年代就存在了,让我们可以折叠内部过程,表面上可视长度很短,但展开后也许很长。到底是以折叠后的还是展开后的行数为准?

因此,有人会说“应该限制的是程序员需要理解的代码块大小,而非单一函数体的行数。”

5. 代码中的“断言密度”应平均达到每个函数不少于 2 个。

断言是最好的文档和调试工具之一,我几乎没见过“断言太多”的真实代码。

NASA 文档给的示例代码比较难看:

if (!c_assert(p >= 0) == true) { return ERROR; } 其实简化后会更合理:

if (!c_assert(p >= 0)) { return ERROR; } 甚至可以这样写:

check(ERROR, p >= 0); 让check这个宏去处理断言及错误报告。

真正值得关注的是要求的“断言密度”:如果最大函数长度是 60 行,那么平均下来大约每 18 行就要有 1 个断言。我个人的建议是“凡是没有类型保证的入参,或来自外部的输入,都应该用断言检验其有效性。”在嵌入式领域,常常会读取来自传感器的数据,更需要做好类似检验。

6. 数据对象必须在尽可能小的作用域内声明。

这是很好的做法,但不应只针对“数据对象”。对某些更先进的语言(如 Ada、Pascal/Delphi、JavaScript,或函数式语言),类型和函数也应该在尽量小的作用域内声明。

7. 必须检查所有非void函数的返回值;函数内部也必须检查其参数是否有效。

这主要针对 C 这样的语言,错误常常用特殊返回值表示。“标准库违反此规则”?实际上是 C 标准库经常这么做。

但要注意“检查参数”也有成本上的现实考量。有些函数的所有参数组合都要检验(比如二分搜索bsearch()需要检查指针地址、数组越界、比较函数安全性、数组是否有序),全面检查可能违背了该函数本身的性能初衷。但在大多数情况下,“不要让运行时错误默默溜过”是关键原则。

8. 预处理器的使用必须仅限于包含头文件和简单的宏定义;禁止使用粘贴宏(##)、可变参数宏(省略号...)以及宏的递归调用。

C 中宏的递归调用基本不可用,这条没什么可争论的。可变参数宏是 C99 引入的,目的之一是让类似fprintf的格式化调用可以写成调试输出宏。实际中这非常实用,可以提高可读性与可维护性。完全禁止未免可惜。

而“宏必须展开成完整的语法单元”这个附加要求,会限制我们用宏模拟某些结构(例如 try-catch)甚至是更简洁的循环封装等。一些情境下,用宏来做抽象能大大提高可读性。

此外,还禁止“条件编译”这件事也有争议。是的,若有 10 个布尔条件,测试组合就是 2^10 = 1024 种,编译时和运行时的条件并没有本质区别。但很多时候,条件编译对于跨平台或调试信息的控制十分常见。若完全禁止,就连断言宏也会受影响。

9. 对指针的使用应有限制:不允许超过一层解引用,不得在宏或typedef中隐藏指针解引用,不允许使用函数指针。

先看禁止函数指针这一点。比如我们想写一个数值分析函数,用 Simpson 法则来近似积分:

``` double integral(double (*f)(double), double lower, double upper, int n) { // 使用 n+1 个点做 Simpson 积分 double const h = (upper - lower) / n; double s; double t; int i;

s = 0.0;
for (i = 0; i < n; i++)
    s += f((lower + h/2.0) + h*i);
t = 0.0;
for (i = 1; i < n; i++)
    t += f(lower + h*i);
return (f(lower) + f(upper) + 4.0*s + 2.0*t) * (h/6.0);

} ``` 这是计算某函数在[lower, upper]区间上的积分,C 里f是函数指针。NASA 却说禁止函数指针。但这类写法在数值计算中非常常见,可以让我们一次性写好积分逻辑,然后用不同的函数去调用。如果没有函数指针,就得改成:

``` enum Fun { FOO, BAR, UGH, ZOO }; double call(enum Fun which, double x) { switch (which) { case FOO: return foo(x); case BAR: return bar(x); case UGH: return ugh(x); case ZOO: return zoo(x); } }

double integral(enum Fun which, double lower, double upper, int n) { double const h = (upper - lower) / n; double s; double t; int i;

s = 0.0;
for (i = 0; i < n; i++)
    s += call(which, (lower + h/2.0) + h*i);
t = 0.0;
for (i = 1; i < n; i++)
    t += call(which, lower + h*i);
return (call(which, lower) + call(which, upper) + 4.0*s + 2.0*t) * (h/6.0);

} ``` 这种写法并不见得更安全,而且更容易出错,比如在调用时把which搞成了错误的枚举值 4,却无法被编译器或静态分析器察觉到。相反,用函数指针可以大大减少类似错误的机会。

此外,函数指针也常用于模拟面向对象,尤其在系统软件或嵌入式驱动中,通过函数指针表来实现统一接口(类似虚函数),这样可以更轻松地对接不同驱动或硬件,而不用在每个地方都写switch语句。

还有关于“不超过一层指针解引用”更是对“抽象数据类型”的打击。现代语言普遍提倡模块化与抽象,数据结构的内部实现对外应当是隐藏的。在 C 里,用typedef隐藏指针类型是常见手段。如果 NASA 的规则一刀切地说“只允许一层解引用”,那么很多封装就会被迫拆开,降低可维护性。

10. 从开发第一天起,就要使用编译器的最高级警告和最严苛模式编译所有代码;所有警告都必须清零。每天至少用一个(最好多个)最先进的静态分析工具检查代码,并保证零警告。

这条建议本身非常好。实际上,前面提到的那些“复杂限制”,一定程度上都是在为静态分析让路,希望在 C 这种语言本身不够安全的前提下,用更多限制来让静态工具能更深入地检测,比如功能指针集合、循环上限、递归等问题。

(完)