* 文中的异常, 不是指C++语言特性的异常, 而是指程序中很少出现的边界条件.
- Bug 还是异常?
如果只要你给出一条关于写出高质量代码的最有效的编程技术, 你会选择什么?
面向对象? 泛型编程? 设计模式? 软件组件? Framework? 还是UML, CASE工具
?
我不知道是否以上所举的已经在你的答案之列, 不过我估模着不会有太多人选
择断言(或者更计算机科学化一点的说法: 按契约设计)作为最有效的编程技巧
. 不过, 仔细捉摸之后, 老实说我发现上述的任何一项技术(我甚至还没提到
我个人最为钟情的层次化状态机技术)都不及一致地采用断言带给我更大的益
处.
在本期的"嵌入式角"栏目, 我将解释按契约设计的哲学, 它能带给你什么, 以
及为什么我认为嵌入式系统的程序员应该关注这种设计思想.
- 错误 vs 异常条件
在本期栏目开始(2003年2月), 我曾经讨论过嵌入式系统所特有的复杂性, 但
与通用计算机相比它同时也有更多达到简化机会. 对错误和异常条件的处理也
许就是关于这一点的最好例子. 只要想一想, 你有多次看看到嵌入式软件需要
费心巴力地在代码的纵深层次上传播错误, 目的只是实现微不足道的一点小事
, 比如让系统重启(reset)?
我所谓错误(也叫"Bug"), 指的是一个由于设计或实现的失误而导致的固有缺
陷(例如, 数组下标越界或者还没打开文件就尝试写入). 当你的软件有一个
Bug时, 往往你不太可能有合理的办法来应对这种情况. 真正应该做的是集中
精力检测(以及最终修复)引起问题的根源. 这种情形与异常条件恰恰相反, 异
常条件的出现在系统的生命期中是合法的, 只是相对于主要的执行路径来说很
少出现而已. 与错误相比, 你要做的是为这种异常条件设计并实现一个恢复的
策略.
以动态内存分配为例. 任何系统中, 使用malloc()(或者C++的new )分配内存
都可能会失败. 在通用计算机环境中, 一个malloc失败只是说明此刻操作系统
不能提供所需的内存. 这在一个高度动态的通用目的计算机环境中很容易发生
. 一旦发生了你可以选择从这种情形下进行恢复. 应用程序可以选择释放它的
一些内存然后重试内存分配. 另一个选择是提示用户出现的问题并窜掇着用户
退出其它程序这样你的程序就有机会使用更多的内存. 另一个可能的做法是把
数据存盘然后退出. 无论选择了上述做法的任何一种,处理这种情况都要大动
干戈, 这些做法都与你的程序的主要目的相去甚远. 而且, 在一个桌面环境中
, 你还就得这样去设计和实现因为一个malloc()失败在此情况下是一个异常条
件.
在一个典型的嵌入式系统中, 同样的malloc()失败很可能应该被视为是一个
Bug. 那是因为嵌入式系统没什么理由容许内存耗尽, 所以一旦这样的事发生
了, 那就昭示着软件的缺陷. 你不可能从中恢复正常运行. 也不能选择退出其
它程序,或者什么存盘退出之类的事. 无论你怎么看待这事, 它都与栈溢出或
者解引用一个空指针或者数组下标越界没有两样. 没必要赴汤蹈火地拼死解救
这种情况(在桌面环境下你却应该这么做), 该做是首先找到问题的根源然后修
复(要是我就先查内存泄漏, 你呢?)
这里的要点是, 通用计算环境中很多传统上应该做为异常条件进行处理的情况
, 在嵌入式系统中却是Bug. 也就是说, 嵌入式系统(专注于一件良好定义的单
一目的的计算机)的特殊性允许你简单地视之为Bug(不需要在发生时处理而是
修改系统使它根本不会发生)而不是异常条件(发生了也是程序允许的, 要合理
地处理). 对这两种情况的正确区分往往要视情况而定, 所以你不能简单地把
一个计算环境下的经验法则移用于嵌入式实时系统. 我的建议是你应该这样批
判性地自问下面两个问题: "某个特定的情形的出现是否正当?", "如果确实发
生了, 有没有什么特别的事要做", 如果两个问题的答案都是"是", 那么你应
该把它作为异常来处理, 否则它就是Bug.
错误与异常条件的区分在任何类型的软件(不限于固件)都是很重要, 因为对待
错误需要的编程策略与异常条件刚好相反. 面对错误第一要务是尽可能早地检
测出它们. 做任何尝试去处理这样的Bug(象应该对异常条件做的那样)只会要
么让代码不必要地复杂化, 要么把Bug掩盖起来或者延缓它再次被暴露出来. (
最坏的情况还可能因此招致新的Bug), 不管怎样, 找到和修复一个Bug都不那
么容易.
- 嵌入根据契约的设计
这正是根据契约的设计(DBC)所要处理的. DBC由Bertrand Meyer首先提出(请
参考 Object Oriented Software Construction 2nd, Prentice Hall出版社,
1997), 这种观点把软件系统看作一组组件的相互协作, 协作基于准确定义的
规范和对这种契约的遵守. 该方法的核心思想是把这种契约内置到代码里并在
运行时自动验证. 能统一地采用该方法有两个明显的好处:(1)它能自动帮助检
测到Bug(而不是处理它们) (2)这也是对代码的最好的文档形式.
你可以用断言在C/C++中实现DBC最重要的一面.标准C库的assert()库很少能适
用于嵌入式系统.而且, 因为它的默认行为(当传给宏的整数表达式结果是0时)
是显示一个错误信息后就退出. 这两种行为对多数嵌入式系统来说都没什么意
义,这样的系统很少会有一个屏幕来显示错误信息,也不能真正地退出(至少不
能象桌面环境上的程序那样). 所以, 在嵌入式环境下, 你通常需要定义自己
的断言来适应你的工具并且指定如何响应错误. 我建议你在准备增强一个断言
之前要三思而行, 因为断言本身的大部分威力恰恰是因为它的简单性.
清单1显示了一个简单的嵌入式系统友好的断言,我发现这样的断言对多数嵌入
式项目就足够了(请参考该栏目6月刊所附代码中的头文件qassert.h). 清单1
近似于标准的(在C++中是), 以下几点是例外: (1)允许 定制对错误的响应. (2) 为可能多处要用的文件名节省了内存 (3) 提供了一
个条件宏来测试和文档化前条件(REQUIRE), 后条件(ENSURE), 以及不变式
(INVARIANT), (这三个宏的名字直接取自Eiffel语言. 该语言提供了对DBC的
内建支持)
====== Listing 1 =====
1: #ifndef qassert_h
2: #define qassert_h
3:
4: /** NASSERT macro disables all contract validations
5: * (assertions, preconditions, postconditions, and invariants).
6: */
7: #ifdef NASSERT /* NASSERT defined--DbC disabled */
8:
9: #define DEFINE_THIS_FILE
10: #define ASSERT(ignore_) ((void)0)
11: #define ALLEGE(test_) ((void)(test_))
12:
13: #else /* NASSERT not defined--DbC enabled */
14:
15: #ifdef __cplusplus
16: extern "C" {
17: #endif
18: /* callback invoked in case of assertion failure */
19: void onAssert__(char const *file, unsigned line);
20:
21: #ifdef __cplusplus
22: }
23: #endif
24:
25: #define DEFINE_THIS_FILE \
26: static char const THIS_FILE__[] = __FILE__
27:
28: #define ASSERT(test_) \
29: ((test_) ? (void)0 : onAssert__(THIS_FILE__, __LINE__))
30:
31: #define ALLEGE(test_) ASSERT(test_)
32:
33: #endif /* NASSERT */
34:
35: #define REQUIRE(test_) ASSERT(test_)
36: #define ENSURE(test_) ASSERT(test_)
37: #define INVARIANT(test_) ASSERT(test_)
38:
39: #endif /* qassert_h */
======================
通用的ASSERT(28-29 行)非常类似于标准的assert(). 如果传给宏的参数值为
0(false)并且条件宏 NASSERT没有定义, 那 ASSERT()就会调用一个全局的回
调 onAssert__(). 函数onAssert__()在断言失败时给调用者代码一个机会来
定制错误响应. 在嵌入式系统中, onAssert__()典型的做法先垄断下CPU(通过
禁用中断)的控制权, 然后把系统置为故障模式, 最后触发系统重启(reset).(
很多嵌入式系统都在故障模式中最终都要重启, 所以在重启之前进入该模式经
常并无必要) 如果可能的话, onAssert__函数也应该留下关于错误原因的一些
线索, 或者是通过把文件名和行号保存在永久存储中. (如果你在用调试器,
onAssert__()函数的入口点也是设置断点的一个理想位置. 提示: 检查你的调
试器手册看看怎么能把一个固定的断言放在onAssert__()函数内)
与标准的assert()相比, ASSERT()宏通过把THIS_FILE__(清单1中第26行)传给
onAssert__()作为第一个参数节省了内存(典型的是ROM), 而没有使用标准的
宏__FILE__. 这样做避免了__FILE__字符串被到处复制. 但是这样做要求先行
定义DEFINE_THIS_FILE(第25行), 一般是在每个C/C++文件的开头. (Steve
Maguire在他的 Writing Solid Code, 微软出版社1993中描述了这项技术).
定义NASSERT宏(清单1, 第7行)可以禁用断言检查. 当禁用时断言宏不会生成
任何代码(第10行,行35-37行); 特别是, 作为参数传递的表达式不会被测试,
所以你应该小心避免在传递给宏的测试表达式中引入任何的副作用(这种副作
用是正常程序代码的一部分). 值得注意的是ALLEGE()宏(第11和第31行), 它
总是会执行表达的测试,即使是在断言被禁用的情况下, 但它不会回调
onAssert__(). ALLEGE()在某种情况下是有用的, 比如避免测试的副作用要求
引入临时变量, 这样的临时变量往往要向栈上压入额外的寄存器值--在嵌入式
系统中往往是要尽力减少对栈的使用.
- DBC的哲学
理解软件契约(C/C++中表现为断言形式)最重要的一点是它既不处理也不阻止
错误的产生, 这正如普通人类生活中契约不能防止诈骗一样. 比如对成功内存
分配的断言: ALLEGE((foo = new Foo) != NULL), 可能会让你有一种感到些
许舒适的模糊错觉: 你已经处理或者防止了一个Bug,但事实上你并没有. 你的
确建立了一个契约, 但是, 其中所述却是在此处代码中如果不能成功动态分配
一个Foo对象是一种错误. 在代码中这一点契约会被自动检查并保证一旦契约
被违反程序就会被无情地中止运行. 一开始你可能会想这反而退步了. 契约非
但没有对Bug作任何处理(留下错误不作修复), 还把每个断言变成了严重错误,
这不是让事情显得更糟糕了吗! 然而, 想想我们之前论及的处理Bug的第一要
务就是先检测到他们, 而不是上来就加以处理. 从这点上看, 引起系统大张旗
鼓地(并且能准备指出哪人契约被违反了)崩溃掉的Bug更容易被发现 -- 相比
于那些只是间歇性地出现在远离你本来能轻易检测到的地点数百万机器指令之
外的Bug.
软件中的断言在很多方面类似于电路中的保险丝. 电子工程师在电路中的多处
插入保险丝来控制损坏(通过融断保险丝). 任何象样一点的电路中, 比如车上
的电路系统, 都装有多个不同承载力的保险丝(20A的保险丝可能用于前大灯,
但对于开关信号来说就太大了)来帮助缩小查找问题出现的范围以及有效地阻
止代价高昂的损毁. 另一方面, 保险丝却即不能阻止也不能修复故障, 所以换
一个被融断的保险丝并不能帮助解决问题除非你把问题的根源给拔除掉. 也正
如软件中的断言, 保险丝的主要威力来自于它们的简单性.
由于简单性, 断言也往往被视为过于原始的错误检查机制--或者对小程序来说
还不错, 但在真正的工业级软件中必需用真正的错误处理来取代它. 这种观点
与DBC的哲学是相背的, DBC把契约(C/C++中的断言)视之为软件设计的一部分.
契约体现了重要的设计决策, 也就是把某种条件声明为错误而不是异常条件,
所以在大规模工业级软件中嵌入这样的契约比之在小型系统中更显重要. 想象
一下在大型工业电路中(比如电站)不使用保险丝会是什么样...
- 攻还是守
"防御式编程"一词有两种互相补充的意思. 第一个描述那种基于断言的编程风
格, 这种风格鼓励你断言任何软件要正常运行就必需满足的假设(参考 Bruce
Eckel和Chunk Allion所著的Thinking in C++第二版第2卷第2章. 也可以通过
<在线访问).这个意思
中的"防御式编程"实际上就等同于DBC. 它的另一个意思代表了这样一种编程
风格: 能接受并处理更大范围的输入值或者允许操作的顺序不必顾及对象状态
的一致性, 从而让软件对于错误更为健壮. 这一层意思对于DBC来说是一个补
充. 比如考虑下面的一个Port类:
==========================================
class Port {
bool open_;
public:
Port() : open_(false) {}
void open() {
if (!open_) {
// open the port ...
open_ = true;
}
}
void transmit(unsigned char const *buffer, unsigned nBytes) {
if (open_ && buffer != NULL && nBytes > 0) {
// transmit nBytes from the buffer ...
}
}
void close() {
if (!open_) {
open_ = false;
// close the port ...
}
}
// . . .
};
==========================================
这个类以一种防御式的风格编程(第二层意思),因为它默默地接受了错误的调
用顺序(比如在open()之前就调用了transmit()), 还能接受错误的参数值(比
如 transmit(NULL, 0)).
防御式编程往往被宣传为一种更好的编程风格,但可惜的是, 后果是这种风格
往往把Bug给隐藏了起来. 在port.open()之前就调用port.transmit()能是一
个好程序吗? 传给transmit()一个未初始化的缓冲真的可行吗? 我要说的是一
个正确的设计和实现代码中根本不应该有这种代码. 这种情况一旦出现就肯定
表明有存在更大的问题.
相对于DBC方法 Port类应该这样设计:
==========================================
class Port {
bool open_;
public:
Port() : open_(false) {}
void open() {
REQUIRE(!open_);
// open the port ...
open = true;
}
void transmit(unsigned char const *buffer, unsigned nBytes) {
REQUIRE(open_ && buffer != NULL && nBytes > 0);
// transmit n-bytes from the buffer ...
}
void close() {
REQUIRE(open_);
open_ = false;
// close the port ...
}
// . . .
};
==========================================
这个设计有意弄的不那么灵活, 但是不象上面的防御式风格的版本, 这个类很
难被误用. 另外(虽然很难在这样一个玩具例子中让人信服)断言也更有助于消
除大量代码, 这些代码在防御式编程中用于处理更大范围的输入值.
但是, DBC远不止是防御式编程的一个补充. 发挥DBC全部威力的关键在于主动
地寻找那些需要断言的条件. 最为有效的断言可以通过下面两个启发式的问题
得到: "对这段代码和客户代码而言有哪些隐含的假设必需被满足?", "我怎样
能更显式更有效地测试这些假设?", 通过对你所写的每一片代码不断地反思这
两个问题, 你就能注意到其它情况下你往往不会想到的价值连城的条件. 对断
言的这种思考方式将把你对编程的态度从"守"转之为"攻", 这种态度让你主动
地寻找可能引起Bug的情形.
最后一点, 嵌入式系统特别适合于实现这种一种主动式的哲学. 嵌入式CPU周
边往往有一大堆特殊外设, 它们只是等着被用于验证程序的正确执行. 比如当
一个FIFO溢出时一个串口通讯通道(比如一个16550型的UART)的线路状态寄存
器可能会被设置上一个bit位. 一个典型的串口驱动往往会忽略这个位(毕竟,
驱动不能恢复已经丢失的字节), 但你的驱动可以断言这个位永远不被置位.
这样做你就能知道你的硬件什么时候丢了数据(是由于UART的间歇性延迟), 否
则这样的情况很难被检测或复现. 不用说有了这个断言信息你就不用浪费时间
去调试协议栈了, 而是把精力用于查找和修复时序故障. 你可以以类似的方式
使用定时器/计数器来构建实时断言来检查是否错过你的底限. 在我公司中我
们使用一个GPS相关芯片中的一个寄存器来断言每个GPS通道总是在它的每个
C/A码周期之内(大约每1毫秒)--这只是另一种形式的实时断言. 这些例子的要
点是这些来自硬件的信息如果不是用于这种主动构造的断言就没有在别的地方
被使用. 但是这样的信息却是无价之宝, 因为它们往往是能够直接验证你的代
码中的时域性能唯一办法.
- 断言和测试
在GE的医疗系统中, 我有一次参与开发一个对X射线机器进行错误检测的自动
测试包, 我们叫它"cycler". cycler实质上是一个随机程序, 它模拟激活我们
触摸屏上的软按键并且按下脚踏开关来启动X射线照射. 我们的想法是让
cycler自动在夜间和周末演练我们的系统. cycler确实帮我们抓到不少问题,
很多都在错误日志中有记录.然而, 因为我们的软件以强防御的风格进行编码,
所以如果日志中没有错误我们无从得知到底是由于cycler确实运行成功, 还是
因为防御式代码已经处理了碰到的问题.
相对地, 如果代码中加入了断言这种佐料那么每次成功运行一次测试就能带给
你对软件更大的信心. 我不知道断言的使用到底应该达到什么样的密集程度,
但至少要保证测试不再输出未定义的行为, 断故障, 或者系统挂起--所有的这
些Bug都以断言失败的形式出现. DBC的效果确实让人惊叹. 由断言实现的集成
在代码中的检查防止了代码偏离预设的路径甚至被打断的构建版本也不至于崩
溃, 而是表现为触发一个断言.
根据DBC原则写出的测试代码对程序员有着巨大的心理上的影响. 因为断言把
每个被断言的条件升级到了严重错误的级别, 所以的Bug都必需引起注意. DBC
使得你很难再把间歇性出现Bug不当回事地看作小问题--毕竟, 你有一个关于
在哪个文件中哪一行发生断言失败的记录. 一旦你知道代码中什么地方开始着
手, 多数Bug都暴露无疑.
-- 在最终软件制品中使用断言
关于断言的标准实践是在开发和测试中使用, 但在最终产品中通过定义NDEBUG
宏禁用. 在清单1中, 我把这个宏替换成了NASSERT, 因为多数开发环境会在你
切换至产品版本时自动定义NDEBUG宏, 我希望解除这种何时禁用断言的固定规
则. 因为我坚信保持断言生效, 尤其是在产品的最终发货版本中, 是一种更好
的做法.
关于这事最经常被引用的观点来自C.A.R Hoare,他认为在最终产品中禁用断言
就象在现实中使用求生圈, 但是不要被这种说法打扰到了你的真实生活. 我发
现把断言比作保险丝更具吸引力. 你会不会只在设计原型的电路板中使用小心
额定好的保险丝, 然后在真正的产品运行时却把它全部替换成0欧姆的电阻?
在最终产品中使用断言的问题可以归结为两个.首先是加到你代码中的断言引
起的开销. 显然,如果这种开销太大, 你别无选择得移除它.(但在你移除它之
前我必需再问你一次你怎么样构建和测试你的固件?) 然而, 断言应该被视为
固件整体的一部分, 容量合理的硬件应该有能力容纳它们. 随着硬件价格快速
下滑而性能却一路窜升,把消耗在CPU马力和内存上的资源抽掉一小部分来换取
更好的系统整体性是非常划算的. 另外,就象我之前已经说过的, 断言已经通
过消除大部分防御性代码为它自己买过单了.
另一个问题是在这个领域中一旦断言真正被触发系统应该如何正确地作出回应
. 事实证明, 对多数嵌入式设备来说简单的重启系统从用户角度看带来的不便
最少 -- 肯定比锁死一个设备然后永久地关上它所能提供的服务要好. 这与有
一天发生在我身上的事正好相似, 我妻子的手机锁死了, 唯一让它恢复正常的
办法就是拔掉电池(我不知道她是怎么做的, 但是从那之后她只要在VCR或电视
旁边就经常挂断电话). 我脑子里想的是手机这样的固件里是否使用了断言(或
者断言有没有被启用)--答案明显是不, 因为如果是的话固件就应该会自动重
启.
- 首尾循环的检查
断言是很多文章中反复被论及的一个主题. 比如在CUJ 专家论坛上由Andrei
Alexandrescu就在两篇文章中描述了怎么使用模板和异常来构建真正聪明的断
言(参考: "Generic: Assertions", , 2003年4月, 和“Generic
alexandr.htm>, 2003年6月). 由于更特定于嵌入式领域, 我更喜欢的是Niall
Murphy在嵌入式系统编程杂志上的专栏中专门讨论断言的前后两篇文章(参考
“Assertiveness Training for Programmers”, ESP, 2001年4月, 和“
Assert Yourself”, ESP, 2001年5月, 两篇文章都可以通过
<在线访问).顺便说一下, 以电子系统中的保
险丝来类比断言的想法最初就是由Niall和我在圣弗郎西斯科举办的最近嵌入
式系统研讨会上讨论时提出的.
何时, 以及如何使用断言. 本文的主要目标是让你确信DBC设计哲学会从根本
上改变你设计、实现、测试和布置你的软件的方式. Eiffel软件的网站
<(另外你还能在那找到关于臭名昭著的Ariane 5号软件故障
的解释)就是学习DBC的好的起点.
在嵌入式系统领域, 那种通过逻辑分析仪或者电路板内部的住址器来访问全部
的CPU状态信息的做法已经再也不可能了. 即使你能访问到CPU的地址和数据信
号(通常做不到, 因为根本没有那么多针脚), 多阶段流水线和缓存也会让你难
以知晓里面到底发生了什么. 解决方案要求测试手段(断言)直接集成到系统的
固件中. 你再也不能从头设计一个系统时不考虑测试成本. 总是假设所有的
CPU周期、RAM和ROM都纯粹地只做眼前的工作并不会让整个任务就能顺利进行.