1. 前言 嵌入式是软件设计领域的一个分支,它自身的诸多特点决定了系统架构师的选择,同时它的一些问题又具有相当的通用性,可以推广到其他的领域。 提起嵌入式软件设计,传统的印象是单片机,汇编,高度依赖硬件。传统的嵌入式软件开发者往往只关注实现功能本身,而忽视诸如代码复用,数据和界面分离,可测试性等因素。从而导致嵌入式软件的质量高度依赖开发者的水平,成败系之一身。随着嵌入式软硬件的飞速发展,今天的嵌入式系统在功能,规模和复杂度各方面都有了极大的提升。比如,Marvell公司的PXA3xx系列的最高主频已经达到800Mhz,内建USB,WIFI,2D图形加速,32位DDR内存。在硬件上,今天的嵌入式系统已经达到甚至超过了数年前的PC平台。在软件方面,完善的操作系统已经成熟,比如Symbian, Linux, WinCE。基于完善的操作系统,诸如字处理,图像,视频,音频,游戏,网页浏览等各种应用程序层出不穷,其功能性和复杂度比诸PC软件不遑多让。原来多选用专用硬件和专用系统的一些商业设备公司也开始转换思路,以出色而廉价的硬件和完善的操作系统为基础,用软件的方式代替以前使用专有硬件实现的功能,从而实现更低的成本和更高的可变更,可维护性。 2.决定架构的因素和架构的影响 架构不是一个孤立的技术的产物,它受多方面因素的影响。同时,一个架构又对软件开发的诸多方面造成影响。 下面举一个具体的例子。 摩托车的发动机在出厂前必须通过一系列的测试。在流水线上,发动机被送到每个工位上,由工人进行诸如转速,噪音,振动等方面的测试。要求实现一个嵌入式设备,具备以下基本功能: 安装在工位上,工人上班前开启并登录。 通过传感器自动采集测试数据,并显示在屏幕上。 记录所有的测试结果,并提供统计功能。比如次品率。 如果你是这个设备的架构师,哪些问题是在设计架构的时候应该关注的呢? 2.1. 常见的误解 2.1.1. 小型的系统不需要架构 有相当多的嵌入式系统规模都较小,一般是为了某些特定的目的而设计的。受工程师认识,客户规模和项目进度的影响,经常不做任何架构设计,直接以实现功能为目标进行编码。这种行为表面上看满足了进度,成本,功能各方面的需求,但是从长远来看,在扩展和维护上付出的成本,要远远高于最初节约的成本。如果系统的最初开发者继续留在组织内并负责这个项目,那么可能一切都会正常,一旦他离开,后续者因为对系统细节的理解不足,就可能引入更多的错误。要注意,嵌入式系统的变更成本要远远高于一般的软件系统。好的软件架构,可以从宏观和微观的不同层次上描述系统,并将各个部分隔离,从而使新特性的添加和后续维护变得相对简单。 举一个城铁刷卡机的例子,这个例子在前面的课程中出现过。简单的城铁刷卡机只需要实现如下功能: 一个While循环足以实现这个系统,直接就可以开始编码调试。但是从一个架构师的角度,这里有没有值得抽象和剥离的部分呢? 计费系统。计费系统是必须抽象的,比如从单次计费到按里程计费。 传感器系统。传感器包括磁卡感应器,投币器等。设备可能更换。 故障处理和恢复。考虑到较高的可靠性和较短的故障恢复时间,这部分有必要单独设计。 未来很可能出现的需求变更: 操作界面。是否需要抽象出专门的Model来?以备将来实现View。 数据统计。是否需要引入关系型数据库? 如果直接以上面的流程图编码,当出现变更后,有多少代码可以复用? 不过,也不要因此产生过度的设计。架构应当立足满足当前需求,并适当的考虑重用和变更。 2.1.2. 敏捷开发不需要架构 极限编程,敏捷开发的出现使一些人误以为软件开发无需再做架构了。这是一个很大的误解。敏捷开发是在传统瀑布式开发流程出现明显弊端后提出的解决方案,所以它必然有一个更高的起点和对开发更严格的要求。而不是倒退到石器时代。事实上,架构是敏捷开发的一部分,只不过在形式上,敏捷开发推荐使用更高效,简单的方式来做设计。比如画在白板上然后用数码相机拍下的UML图;用用户故事代替用户用例等。测试驱动的敏捷开发更是强迫工程师在写实际代码前设计好组件的功能和接口,而不是直接开始写代码。敏捷开发的一些特征: 针对比传统开发流程更大的系统 承认变化,迭代架构 简洁而不混乱 强调测试和重构 2. 嵌入式环境下软件设计的特点 要谈嵌入式的软件架构,首先必须了解嵌入式软件设计的特点。 2.1. 和硬件密切相关 嵌入式软件普遍对硬件有着相当的依赖性。这体现在几个方面: 一些功能只能通过硬件实现,软件操作硬件,驱动硬件。 硬件的差异/变更会对软件产生重大影响。 没有硬件或者硬件不完善时,软件无法运行或无法完整运行。 这些特点导致几方面的后果: 软件工程师对硬件的理解和熟练程度会很大程度的决定软件的性能/稳定性等非功能性指标,而这部分一向是相对复杂的,需要资深的工程师才能保证质量。 软件对硬件设计高度依赖,不能保持相对稳定,可维护性和可重用性差 软件不能离开硬件单独测试和验证,往往需要和硬件验证同步进行,造成进度前松后紧,错误定位范围扩大。 针对这些问题,有几方面的解决思路: 用软件实现硬件功能。选用更强大的处理器,用软件来实现部分硬件功能,不仅可以降低对硬件的依赖,在响应变化,避免对特定型号和厂商的依赖方面都很有好处。这在一些行业里已经成为了趋势。在PC平台也经历了这样的过程,比如早期的汉卡。 将对硬件的依赖独立成硬件抽象层,尽可能使软件的其他部分硬件无关,并可以脱离硬件运行。一方面将硬件变更甚至换件的风险控制在有限的范围内,另一方面提高软件部分的可测试性。 2.2. 稳定性要求高 大部分嵌入式软件都对程序的长期稳定运行有较高的要求。比如手机经常几个月开机,通讯设备则要求24*7正常运行,即使是通讯上的测试设备也要求至少正常运行8小时。为了稳定性的目标,有一些比较常用的设计手段: 将不同的任务分布在独立的进程中。良好的模块化设计是关键 Watch Dog, Heart beat,重新启动失效的进程。 完善而统一的日志系统以快速定位问题。嵌入式设备一般缺乏有力的调试器,日志系统尤其重要。 将错误孤立在最小的范围内,避免错误的扩散和连锁反应。核心代码要经过充分的验证,对非核心代码,可以在监控或者沙盒中运行,避免其破坏整个系统。 举例,Symbian上的GPRS访问受不同硬件和操作系统版本影响,功能不是非常稳定。其中有一个版本上当关闭GPRS连接时一定会崩溃,而且属于known issue。将GPRS连接,HTTP协议处理,文件下载等操作独立到一个进程中,虽然每次操作完毕该进程都会崩溃,对用户却没有影响。 双备份这样的手段较少采用 2.3. 内存不足 虽然当今的嵌入式系统的内存比之以K计数的时代已经有了很大的提高,但是随着软件规模的增长,内存不足的问题依然时时困扰着系统架构师。有一些原则,架构师在进行设计决策的时候可以参考: 2.3.1. 虚拟内存技术 有一些嵌入式设备需要处理巨大的数据量,而这些数据不可能全部装入内存中。一些嵌入式操作系统不提供虚拟内存技术,比如WinCE4.2每个程序最多只能使用32M内存。对这样的应用,架构师应该特别设计自己的虚拟内存技术。所谓的虚拟内存技术的核心是,将暂时不太可能使用的数据移出内存。这涉及到一些技术点: 引用计数,正在使用的数据不能移出。 使用预测,预测下一个阶段某个数据的使用可能性。基于预测移出数据或者提前装入数据。 占位数据/对象。 高速缓存。在复杂数据结果下缓存高频率使用的数据,直接访问。 快速的持久化和装载。 下图是一个全国电信机房管理系统的界面示意图: 每个节点下都有大量的数据需要装载,可以使用上述技术将内存占用降到最低。 2.3.2. 两段式构造 在内存有限的系统里,对象构造失败是必须要处理的问题,失败的原因中最常见的则是内存不足(实际上这也是对PC平台的要求,但是在实际中往往忽略,因为内存实在便宜)。两段式构造就是一种常用而有效的设计。举例来说: CMySimpleClass:class CMySimpleClass{ public: CMySimpleClass(); ~CMySimpleClass(); ... private: int SomeData;};CMyCompoundClass:class CMyCompoundClass{ public: CMyCompoundClass(); ~CMyCompoundClass(); ... private: CMySimpleClass* iSimpleClass;};在CMyCompoundClass的构造函数里初始化iSimpleClass对象。CMyCompoundClass::CMyCompoundClass(){ iSimpleClass = new CMySimpleClass;} 当创建CMyCompoundClass的时候会发生什么呢? CMyCompoundClass* myCompoundClass = new CMyCompoundClass; 为CMyCompoundClass的对象分配内存 调用CMyCompoundClass对象的构造函数 在构造函数中创建一个CMySimpleClass的实例 构造函数结束返回 一切看起来都很简单,但是如果第三步创建CMySimpleClass对象的时候发生内存不足的错误怎么办呢?构造函数无法返回任何错误信息以提示调用者构造没有成功。调用者于是获得了一个指向CMyCompoundClass的指针,但是这个对象并没有构造完整。 如果在构造函数中抛出异常会怎么样呢?这是个著名的噩梦,因为析构函数不会被调用,在创建CMySimpleClass对象之前如果分配了资源就会泄露。关于在构造函数中抛出异常可以单讲一个小时,但是有一个建议是:尽量避免在构造函数中抛出异常。 所以,使用两段式构造法是一个更好的选择。简单的说,就是在构造函数避免任何可能产生错误的动作,比如分配内存,而把这些动作放在构造完成之后,调用另一个函数。比如: AddressBook* book = new AddressBook()If(!book->Construct()){…