1前言
嵌入式是软件设计领域的一个分支,它自身的诸多特点决定了系统架构师的选择,同时它的一些问题又具有相当的通用性,可以推广到其他的领域。
提起嵌入式软件设计,传统的印象是单片机,汇编,高度依赖硬件。传统的嵌入式软件开发者往往只关注实现功能本身,而忽视诸如代码复用,数据和界面分离,可测试性等因素。从而导致嵌入式软件的质量高度依赖开发者的水平,成败系之一身。随着嵌入式软硬件的飞速发展,今天的嵌入式系统在功能,规模和复杂度各方面都有了极大的提升。比如,Marvell公司的PXA3xx系列的最高主频已经达到800Mhz,内建USB,WIFI,2D图形加速,32位DDR内存。在硬件上,今天的嵌入式系统已经达到甚至超过了数年前的PC平台。在软件方面,完善的操作系统已经成熟,比如Symbian, Linux, WinCE。基于完善的操作系统,诸如字处理,图像,视频,音频,游戏, 页浏览等各种应用程序层出不穷,其功能性和复杂度比诸PC软件不遑多让。原来多选用专用硬件和专用系统的一些商业设备公司也开始转换思路,以出色而廉价的硬件和完善的操作系统为基础,用软件的方式代替以前使用专有硬件实现的功能,从而实现更低的成本和更高的可变更,可维护性。
2.决定架构的因素和架构的影响
架构不是一个孤立的技术的产物,它受多方面因素的影响。同时,一个架构又对软件开发的诸多方面造成影响。
下面举一个具体的例子。
摩托车的发动机在出厂前必须通过一系列的测试。在流水线上,发动机被送到每个工位上,由工人进行诸如转速,噪音,振动等方面的测试。要求实现一个嵌入式设备,具备以下基本功能:
1. 安装在工位上,工人上班前开启并登录。
2. 通过传感器自动采集测试数据,并显示在屏幕上。
3.记录所有的测试结果,并提供统计功能。比如次品率。
如果你是这个设备的架构师,哪些问题是在设计架构的时候应该关注的呢?
2.1. 常见的误解
2.1.1. 小型的系统不需要架构
有相当多的嵌入式系统规模都较小,一般是为了某些特定的目的而设计的。受工程师认识,客户规模和项目进度的影响,经常不做任何架构设计,直接以实现功能为目标进行编码。这种行为表面上看满足了进度,成本,功能各方面的需求,但是从长远来看,在扩展和维护上付出的成本,要远远高于最初节约的成本。如果系统的最初开发者继续留在组织内并负责这个项目,那么可能一切都会正常,一旦他离开,后续者因为对系统细节的理解不足,就可能引入更多的错误。要注意,嵌入式系统的变更成本要远远高于一般的软件系统。好的软件架构,可以从宏观和微观的不同层次上描述系统,并将各个部分隔离,从而使新特性的添加和后续维护变得相对简单。
举一个城铁刷卡机的例子,这个例子在前面的课程中出现过。简单的城铁刷卡机只需要实现如下功能:
一个While循环足以实现这个系统,直接就可以开始编码调试。但是从一个架构师的角度,这里有没有值得抽象和剥离的部分呢?
未来很可能出现的需求变更:
如果直接以上面的流程图编码,当出现变更后,有多少代码可以复用?
不过,也不要因此产生过度的设计。架构应当立足满足当前需求,并适当的考虑重用和变更。
2.1.2. 敏捷开发不需要架构
极限编程,敏捷开发的出现使一些人误以为软件开发无需再做架构了。这是一个很大的误解。敏捷开发是在传统瀑布式开发流程出现明显弊端后提出的解决方案,所以它必然有一个更高的起点和对开发更严格的要求。而不是倒退到石器时代。事实上,架构是敏捷开发的一部分,只不过在形式上,敏捷开发推荐使用更高效,简单的方式来做设计。比如画在白板上然后用数码相机拍下的UML图;用用户故事代替用户用例等。测试驱动的敏捷开发更是强迫工程师在写实际代码前设计好组件的功能和接口,而不是直接开始写代码。敏捷开发的一些特征:
2. 嵌入式环境下软件设计的特点
要谈嵌入式的软件架构,首先必须了解嵌入式软件设计的特点。
2.1. 和硬件密切相关
嵌入式软件普遍对硬件有着相当的依赖性。这体现在几个方面:
这些特点导致几方面的后果:
针对这些问题,有几方面的解决思路:
2.2. 稳定性要求高
大部分嵌入式软件都对程序的长期稳定运行有较高的要求。比如手机经常几个月开机,通讯设备则要求24*7正常运行,即使是通讯上的测试设备也要求至少正常运行8小时。为了稳定性的目标,有一些比较常用的设计手段:
举例,Symbian上的GPRS访问受不同硬件和操作系统版本影响,功能不是非常稳定。其中有一个版本上当关闭GPRS连接时一定会崩溃,而且属于known issue。将GPRS连接,HTTP协议处理,文件下载等操作独立到一个进程中,虽然每次操作完毕该进程都会崩溃,对用户却没有影响。
2.3. 内存不足
虽然当今的嵌入式系统的内存比之以K计数的时代已经有了很大的提高,但是随着软件规模的增长,内存不足的问题依然时时困扰着系统架构师。有一些原则,架构师在进行设计决策的时候可以参考:
2.3.1. 虚拟内存技术
有一些嵌入式设备需要处理巨大的数据量,而这些数据不可能全部装入内存中。一些嵌入式操作系统不提供虚拟内存技术,比如WinCE4.2每个程序最多只能使用32M内存。对这样的应用,架构师应该特别设计自己的虚拟内存技术。所谓的虚拟内存技术的核心是,将暂时不太可能使用的数据移出内存。这涉及到一些技术点:
下图是一个全国电信机房管理系统的界面示意图:
每个节点下都有大量的数据需要装载,可以使用上述技术将内存占用降到最低。
2.3.2. 两段式构造
在内存有限的系统里,对象构造失败是必须要处理的问题,失败的原因中最常见的则是内存不足(实际上这也是对PC平台的要求,但是在实际中往往忽略,因为内存实在便宜)。两段式构造就是一种常用而有效的设计。举例来说:
当创建CMyCompoundClass的时候会发生什么呢?
CMyCompoundClass* myCompoundClass = new CMyCompoundClass;
一切看起来都很简单,但是如果第三步创建CMySimpleClass对象的时候发生内存不足的错误怎么办呢?构造函数无法返回任何错误信息以提示调用者构造没有成功。调用者于是获得了一个指向CMyCompoundClass的指针,但是这个对象并没有构造完整。
如果在构造函数中抛出异常会怎么样呢?这是个著名的噩梦,因为析构函数不会被调用,在创建CMySimpleClass对象之前如果分配了资源就会泄露。关于在构造函数中抛出异常可以单讲一个小时,但是有一个建议是:尽量避免在构造函数中抛出异常。
所以,使用两段式构造法是一个更好的选择。简单的说,就是在构造函数避免任何可能产生错误的动作,比如分配内存,而把这些动作放在构造完成之后,调用另一个函数。比如:
这样可以保证当Construct不成功的时候释放已经分配的资源。
在最重要的手机操作系统Symbian上,二段式构造法普遍使用。
2.3.3. 内存分配器
不同的系统有着不同的内存分配的特点。有些要求分配很多小内存,有的则需要经常增长已经分配的内存。一个好的内存分配器对嵌入式的软件的性能有时具有重大的意义。应该在系统设计时保证整个系统使用统一的内存分配器,并且可以随时更换。
2.3.4. 内存泄漏
内存泄漏对嵌入式系统有限的内存是非常严重的。通过使用自己的内存分配器,可以很容易的跟踪内存的分配释放情况,从而检测出内存泄漏的情况。
2.4. 处理器能力有限,性能要求高
这里不讨论实时系统,那是一块很大的专业话题。对一般的嵌入式系统而言,由于处理器能力有限,要特别注意性能的问题。一些很好的架构设计由于不能满足性能要求,最终导致整个项目的失败。
2.4.1. 抵御新技术的诱惑
架构师必须明白,新技术常常意味着复杂和更低的性能。即使这不是绝对的,由于嵌入式系统硬件性能所限,弹性较低。一旦发现新技术有和当初设想不同之处,就更难通过修改来适应。比如GWT技术。这是Google推出的Ajax开发工具,它可以让程序员像开发一个桌面应用程序一样开发Web的Ajax程序。这使得在嵌入式系统上用一套代码实现远程和本地操作界面成为了很容易的一件事。但是在嵌入式设备上运行B-S结构的应用,性能上是一个很大的挑战。同时,浏览器兼容方面的问题也很严重,GWT目前的版本还不够完善。
事实证明,嵌入式的远程控制方案还是要采用Activex,VNC或者其他的方案。
2.4.2. 不要有太多的层次
分层结构有利于清晰的划分系统职责,实现系统的解耦,但是每多一个层次,就意味着性能的一次损失。尤其是当层和层之间需要传递大量数据的时候。对嵌入式系统而言,在采用分层结构时要控制层次数量,并且尽量不要传递大量数据,尤其是在不同进程的层次之间。如果一定要传递数据,要避免大量的数据格式转换,如XML到二进制,C++结构到Python结构。
嵌入式系统能力有限,一定要将有限的能力用在系统的核心功能上。
2.5. 存储设备易损坏,速度较慢
除了因为静态的文件分区表等区块被频繁的读写而提前损坏,一些嵌入式设备还要面对直接断电的挑战,这会在存储设备上产生不完整的数据。
2.5.1. 损耗均衡
损耗均衡的基本思路是平均地使用存储器上的各个区块。需要维护一张存储器区块使用情况的表,这个表包括区块的偏移位置,当前是否可用,以及已经擦写地次数。当有新的擦写请求的时候,根据以下原则选择区块:
即使是更新已经存在的数据,也会使用以上原则分配新的区块。同样,这张表的存放位置也不能是固定不变的,否则这张表所占据的区块就会最先损坏。当要更新这张表的时候,同样要使用以上算法分配区块。
如果存储器上有大量的静态数据,那么上述算法就只能针对剩下的空间生效,这种情况下还要实现对这些静态数据的搬运的算法。但是这种算法会降低写操作的性能,也增加了算法的复杂度。一般都只使用动态均衡算法。
目前比较成熟的损耗均衡的文件系统有JFFS2, 和 YAFFS。也有另一种思路就是在FAT16等传统文件系统上实现损耗均衡,只要事先分配一块足够大的文件,在文件内部实现损耗均衡算法。不过必须修改FAT16的代码,关闭对最后修改时间的更新。
现在的CF卡和SD卡有的已经在内部实现了损耗均衡,这种情况下就不需要软件实现了。
2.5.2. 错误恢复
如果在向存储器写数据的时候发生断电或者被拔出,那么所写的区域的数据就处于未知的状态。在一些应用中,这会导致不完整的文件,而在另一些应用中,则会导致系统失败。所以对这类错误的恢复也是嵌入式软件设计必须考虑的。常用的思路有两种:
1.日志型的文件系统
这种文件系统并不是直接存储数据,而是一条条的日志,所以当发生断电的时候,总可以恢复到之前的状态。这类文件系统的代表如ext3。
21.双备份
双备份的思路更简单,所有的数据都写两份。每次交替使用。文件分区表也必须是双备份的。假设有数据块A,A1是他的备份块,在初始时刻和A的内容是一致的。在分区表中,F指向数据块A,F1是他的备份块。当修改文件时,首先修改数据块A1的内容,如果此时断电,A1的内容错误,但因为F指向的是完好的A,所以数据没有损坏。如果A1修改成功,则修改F1的内容,如果此时断电,因为F是完好的,所以依然没有问题。
现在的Flash设备,有的已经内置错误检测和错误校正技术,可以保证在断电时数据的完整。还有的包括自动的动态/静态损耗均衡算法和坏块处理,完全无须上层软件额外对待,可以当作硬盘使用。所以,硬件越发达,软件就会越可靠,技术不断的进步,将让我们可以把更多的精力投入到软件功能的本身,这是发展的趋势。
2.6. 故障成本高昂
嵌入式产品都是软硬件一起销售的给用户的,所以这带来了一个纯软件所不具备的问题,那就是当产品发生故障时,如果需要返厂才能修复,则成本就很高。嵌入式设备常见有以下的几类故障:
针对前三类故障,要尽可能保证客户自己,或者现场技术人员就可以解决。从架构的角度考虑,如下原则可以参考:
a) 使用具备错误恢复能力的数据管理设计。当数据发生错误时,用户可以接受的处理依次是:
i. 错误被纠正,所有数据有效
ii. 错误发生时的数据(可能不完整)丢失,之前的数据有效。
iii. 所有数据丢失
iv. 数据引擎崩溃无法继续工作
3. 软件框架
在桌面系统和 络系统上,框架是普遍应用的,比如著名的ACE, MFC, Ruby On Rails等。而在嵌入式系统中,框架则是很少使用的。究其原因,大概是认为嵌入式系统简单,没有重复性,过于注重功能的实现和性能的优化。在前言中我们已经提到,现在的嵌入式发展趋势是向着复杂化,大型化,系列化发展的。所以,在嵌入式下设计软件框架也是很有必要,也很有价值的。
3.1. 嵌入式软件架构面临的问题
前面我们讲到,嵌入式系统软件架构所面临的一些问题,其中很重要的一点是,对硬件的依赖和硬件相关软件的复杂性。还包括嵌入式软件在稳定性和内存占用等方面的苛刻要求。如果团队中的每个人都是这些方面高手的话,也许有可能开发出高质量的软件,但事实是一个团队中可能只有一两个资深人员,其他大部分都是初级工程师。人人都去和硬件打交道,都负责稳定性,性能等等指标的话,是很难保证最终产品质量的。如果组件团队时都是精通硬件等底层技术的人才,又很难设计出在可用性,扩展性等方面出色的软件。术业有专攻,架构师的选择决定着团队的组成方式。
同时,嵌入式软件开发虽然复杂,但是也存在大量的重用的可能性。如何重用,又如何应对将来的变更?
所以,如何将复杂性对大多数人屏蔽,如何将关注点分离,如何保证系统的关键非功能指标,是嵌入式软件架构设计师应该解决的问题。一种可能的解决方案就是软件框架。
3.2. 什么是框架
框架是在一个给定的问题领域内,为了重用和应对未来需求变化而设计的软件半成品。框架强调对特定领域的抽象,包含大量的专业领域知识,以缩短软件的开发周期,提高软件质量为目的。使用框架的二次开发者通过重写子类或组装对象的方式来实现特殊的功能。
3.2.1. 软件复用的层次
复用是在我们经常谈到的话题,“不要重复发明轮子”也是耳熟能详的戒条。不过对于复用的理解实际上是有很多个层次的。
最基础的复用是复制粘贴。某个功能以前曾经实现过,再次需要的时候就复制过来,修改一下就可以使用。经验丰富的程序员一般都会有自己的程序库,这样他们实现的时候就会比新的程序员快。复制粘贴的缺点是代码没有经过抽象,往往并不完全的适用,所以需要进行修改,经过多次复用后,代码将会变得混乱,难以理解。很多公司的产品都有这个问题,一个产品的代码从另一个产品复制而来,修改一下就用,有时候甚至类名变量名都不改。按照“只有为复用设计的代码才能真正复用”的标准,这称不上是复用,或者说是低水平的复用。
更高级的复用是则是库。这种功能需要对经常使用的功能进行抽象,提取出其中恒定不变的部分,以库的形式提供给二次开发程序员使用。因为设计库的时候不知道二次开发者会如何使用,所以对设计者有着很高的要求。这是使用最广泛的一种复用,比如标准C库,STL库。现在非常流行的Python语言的重要优势之一就是其库支持非常广泛,相反C++一直缺少一个强大统一的库支持,成为短板。在公司内部的开发中总结常用功能并开发成库是很有价值的,缺点是对库的升级会影响到很多的产品,必须慎之又慎。
框架是另一种复用。和库一样,框架也是对系统中不变的部分进行抽象并加以实现,由二次开发者实现其他变化的部分。典型的框架和库的最大的区别是,库是静态的,由二次开发者调用的;框架是活着的,它是主控者,二次开发者的代码必须符合框架的设计,由框架决定在何时调用。
举个例子,一个 络应用总是要涉及到连接的建立,数据收发和连接的关闭。以库的形式提供是这样的:
框架则是这样的:
框架会在“适当”的时机创建mycomm对象,并查询host和port,然后建立连接。在连接建立后,调用onconnected接口,给二次开发者提供进行处理的机会。当数据到达的时候调用ondataarrived接口让二次开发者处理。这是好莱坞原则,“不要来找我们,我们会去找你”。
当然,一个完整的框架通常也要提供各种库供二次开发者使用。比如MFC提供了很多的库,如CString, 但本质上它是一个框架。比如实现一个对话框的OnInitDialog接口,就是由框架规定的。
3.2.2. 针对高度特定领域的抽象
和库比较起来,框架是更针对特定领域的抽象。库,比如C库,是面向所有的应用的。而框架相对来说则要狭窄的多。比如MFC提供的框架只适合于Windows平台的桌面应用程序开发,ACE则是针对 络应用开发的框架,Ruby On Rails是为快速开发web站点设计的。
越是针对特定的领域,抽象就可以做的越强,二次开发就可以越简单,因为共性的东西越多。比如我们上面谈到嵌入式系统软件开发的诸多特点,这就是特定领域的共性,就属于可以抽象的部分。具体到实际的嵌入式应用,又会有更多的共性可以抽象。
框架的设计目的是总结特定领域的共性,以框架的方式实现,并规定二次开发者的实现方式,从而简化开发。相应的,针对一个领域开发的框架就不能服务于另一个领域。对企业而言,框架是一种极好的积累知识,降低成本的技术手段。
3.2.3. 解除耦合和应对变化
框架设计的一个重要目的就是应对变化。应对变化的本质就是解耦。从架构师的角度看,解耦可以分为三种:
3.2.4. 框架可以实现和规定非功能性需求
非功能性需求是指如性能,可靠性,可测试性,可移植性等。这些特性可以通过框架来实现。以下我们一一举例。
性能。对性能的优化最忌讳的就是普遍优化。系统的性能往往取决于一些特定的点。比如在嵌入式系统中,对存储设备的访问是比较慢的。如果开发者不注意这方面的问题,频繁的读写存储设备,就会造成性能下降。如果对存储设备的读写由框架设计,二次开发者只作为数据的提供和处理者,那么就可以在框架中对读写的频率进行调节,从而达到优化性能的目的。由于框架都是单独开发的,完成后供广泛使用,所以就有条件对关键的性能点进行充分的优化。
可靠性。以上面的 络通讯程序为例,由于框架负责了连接的创建和管理,也处理了各种可能的 络错误,具体的实现者无须了解这方面的知识,也无须实现这方面错误处理的代码,就可以保证整个系统在 络通讯方面的可靠性。以框架的方式设计在可靠性方面的最大优势就是:二次开发的代码是在框架的掌控之内运行的。一方面框架可以将容易出错的部分实现,另一方面对二次开发的代码产生的错误也可以捕获和处理。而库则不能代替使用者处理错误。
可测试性。可测试性是软件架构需要考虑的一个重要方面。下面的章节会讲到,软件的可测试性是由优良的设计来保证的。一方面,由于框架规定了二次开发的接口,所以可以迫使二次开发者开发出便于进行单元测试的代码。另一方面,框架也可以在系统测试的层面上提供易于实现自动化测试和回归测试的设计,例如统一提供的TL1接口。
可移植性。如果软件的可移植性是软件设计的目标,框架设计者可以在设计阶段来保证这一点。一种方式是通过跨平台的库来屏蔽系统差异,另一种可能的方式更加极端,基于框架的二次开发可以是脚本化的。组态软件是这方面的一个例子,在PC上组态的工程,也可以在嵌入式设备上运行。
3.3. 一个框架设计的实例
3.3.1. 基本架构
3.3.2. 功能特点
上面是一个产品系列的架构图,其特点是硬件部分是模块化的,可以随时插拔。不同的硬件应用于不同的通讯测试场合。比如光通讯测试,xDSL测试,Cable Modem测试等等。针对不同的硬件,需要开发不同的固件和软件。固件层的功能主要是通过USB接口接收来自软件的指令,并读写相应的硬件接口,再进行一些计算后,将结果返回给软件。软件运行在WinCE平台,除了提供一个触摸式的图形化界面外,还对外提供基于XML(SOAP)接口和TL1接口。为了实现自动化测试,还提供了基于Lua的脚本语言接口。整个产品系列有几十个不同的硬件模块,相应的需要开发几十套软件。这些软件虽然服务于不同的硬件,但是彼此之间有着高度的相似性。所以,选择先开发一个框架,再基于框架开发具体的模块软件成了最优的选择。
3.3.3. 分析
软件部分的结构分析如下:
系统分为软件,固件和硬件三大块。软件和固件运行在两块独立的板子上,有各自的处理器和操作系统。硬件则插在固件所在的板子上,是可以替换的。
软件和固件其实都是软件,下面我们分别分析。
软件
软件的主要工作是提供各种用户界面。包括本地图形化界面,SOAP访问界面,TL1访问界面。
整个软件部分分为五大部分:
通讯层要屏蔽用户对具体通信介质和协议的了解,无论是USB还是socket,对上层都不产生影响。通讯层负责提供可靠的通讯服务和适当的错误处理。通过配置文件,用户可以改变所使用的通讯层。
协议层的目的是将数据进行编码和解码。编码的产生物是可以在通讯层发送的流,按照嵌入式软件的特点,我们选择二进制作为流的格式。解码的产生物是多种的,既有供界面使用的C Struct,也可以是XML数据,还可以是Lua的数据结构(tablegt)。如果需要,还可以产生JSON,TL1,Python数据,TCL数据等等。这一层在框架中是通过机器自动生成的,我们后面会讲到。
内存数据库,SOAP Server和TL1 Server都是协议层的用户。图形界面通过读写内存数据库和底层通讯。
图形界面是框架设计的重点之一,原因是这里工作量最大,重复而无聊的工作最多。
让我们分析一下在图形界面开发工作中最主要的事情是什么。
同时有一些库用来进一步简化开发:
这是一个简化的例子,但是很好的说明了框架的特点:
固件
固件的主要工作是接受来自软件的命令,驱动硬件工作;获取硬件的状态,进行一定的计算后返回给软件。早期的固件是很薄的一层,因为绝大部分工作是由硬件完成的,固件只起到一个中转通讯的作用。随着时代发展,现在的固件开始承担越来越多原来由硬件完成的工作。
整个固件部分分为五大部分:
硬件抽象层,提供对硬件的访问接口
互相独立的任务群
任务/消息派发器
协议层
通讯层
针对不同的设备,工作量集中在硬件抽象层和任务群上。硬件抽象层是以库的形式提供的,由对硬件最熟悉,经验最丰富的工程师来实现。任务群则由一系列的任务组成,他们分别代表不同的业务应用。比如测量误码率。这部分由相对经验较少的工程师来实现,他们的主要工作是实现规定的接口,按照标准化文档定义的方式实现算法。
任务定义了如下接口,由具体开发者来实现:
框架的代码流程如下:(伪代码)
这样,具体任务的实现者所关注的最重要的事情就是实现这几个接口。其他如硬件的初始化,消息的收发,编码解码,结果的上 等等事情都由框架进行了处理。避免了每个工程师都必须处理从上到下的所有方面。并且这样的任务代码还有很高的重用性,比如是在以太 上还是在Cable Modem上实现PING的算法都是一样的。
3.3.4. 实际效果
在实际项目中,框架大大降低了开发难度。对软件部分尤其明显,由实习生即可完成高质量的界面开发,开发周期缩短50%以上。产品质量大大提升。对固件部分的贡献在于降低了对精通底层硬件的工程师的需要,一般的工程师熟知测量算法即可。同时,框架的存在保证了性能,稳定和可测试性等要素。
3.4. 框架设计中的常用模式
3.4.1. 模板方法模式
模板方法模式是框架中最常用的设计模式。其根本的思路是将算法由框架固定,而将算法中具体的操作交给二次开发者实现。例如一个设备初始化的逻辑,框架代码如下:
DownloadFPGA和InitKeyPad都是CBaseDevice定义的虚函数,二次开发者创建一个继承于CBaseDevice的子类,具体来实现这两个接口。框架定义了调用的次序和错误的处理方式,二次开发者无须关心,也无权决定。
3.4.2. 创建型模式
由于框架通常都涉及到各种不同子类对象的创建,创建型模式是经常使用的。例如一个绘图软件的框架,有一个基类定义了图形对象的接口,基于它可以派生出椭圆,矩形,直线各种子类。当用户绘制一个图形时,框架就要实例化该子类。这时候可以用工厂方法,原型方法等等。
3.4.4. 装饰器模式
装饰器模式赋予了框架在后期增加功能的能力。框架定义装饰器的抽象基类,而由具体的实现者实现,动态地添加到框架中。
举一个游戏中的例子,图形绘制引擎是一个独立的模块,比如可以绘制人物的静止,跑动等图像。如果策划决定在游戏中增加一种叫“隐身衣”的道具,要求穿着此道具的玩家在屏幕上显示的是若有若无的半透明图像。应该如何设计图像引擎来适应后期的游戏升级呢?
当隐身衣被装备后,就向图像引擎添加一个过滤器。这是个极度简化的例子,实际的游戏引擎要比这个复杂。装饰器模式还常见用于数据的前置和后置处理上。
3.5. 框架的缺点
一个好的框架可以大大提高产品的开发效率和质量,但也有它的缺点。
1.框架一般都比较复杂,设计和实现一个好的框架需要相当的时间。所以,一般只有在框架可以被多次反复应用的时候适合,这时候,前提投入的成本会得到丰厚的回 。
2.框架规定了一系列的接口和规则,这虽然简化了二次开发工作,但同时也要求二次开发者必须记住很多规定,如果违反了这些规定,就不能正常工作。但是由于框架屏蔽了大量的领域细节,相对而言,其学习成本还是大大降低了的。
3.框架的升级对已有产品可能会造成严重的影响,导致需要完整的回归测试。对这个问题有两个办法。第一是对框架本身进行严格的测试,有必要建立完善的单元测试库,同时开发示例项目,用来测试框架的所有功能。第二则是使用静态链接,让已有产品不轻易跟随升级。当然,如果已有产品有较好的回归测试手段,就更好。
4.性能损失。由于框架对系统进行了抽象,增加了系统的复杂性。诸如多态这样的手段使用也会普遍的降低系统的性能。但是从整体上来看,框架可以保证系统的性能处于一个较高的水平。
4. 自动代码生成
4.1. 机器能做的事就不要让人来做
懒惰是程序员的美德,更是架构师的美德。软件开发的过程就是人告诉机器如何做事的过程。如果一件事情机器自己就可以做,那就不要让人来做。因为机器不仅不知疲倦,而且绝不会犯错。我们的工作是让客户的工作自动化,多想一点,就能让我们自己的工作也部分自动化。极有耐心的程序员是好的,也是不好的。
经过良好设计的系统,往往会出现很多高度类似而且具有很强规律的代码。未经良好设计的系统则可能对同一类功能产生很多不同的实现。前面关于框架设计的部分已经证明了这一点。有时候,我们更进一步,分析出这些相似代码之中的规律,用格式化的数据来描述这些功能,而由机器来产生代码。
4.2. 举例
4.2.1. 消息的编码和解码
上面关于框架的实例中,可以看到消息编解码的部分已经被独立出来,和其他部分没有耦合。加上他本身的特点,非常适合进一步将其“规则化”,用机器产生代码。
编码,就是把数据结构流化;解码反之。以编码为例,代码无非是这样的:(二进制协议)
(为了简化,这里假设已经设计了一个流对象,可以流化各种数据类型,并且已经处理了诸如字节序转换等问题。)
最后我们得到一个stream。大家是否已经习惯了写这种代码?但是这样的代码不能体现工程师任何的创造性,因为我们早已经知道有i, 有j, 还有一个object,为什么还要自己敲入这些代码呢?如果我们分析一下a的定义,是不是就可以自动产生这样的代码呢?
只需要一个简单的语义分析器解析这段代码,得到一棵关于数据类型的树,就可以轻易的产生流化的代码。这样的分析器用Python等字符串处理能力强的语言不过两百行左右。关于数据类型的树类似下图:
只要遍历这棵树,就可以生成所有数据结构的流化代码。
在上一个框架所举例的项目中,为一个硬件模块自动产生的消息编码解码器代码量高达三万行,几乎相当于一个小软件。由于是自动产生,没有任何错误,为上层提供了高可靠性。
还可以用XML或者其他的格式定义数据结构,从而产生自动代码。根据需要,C++/Java/Python,任何类型的都可以。如果希望提供强检查,可以使用XSD来定义数据结构。有一个商业化的产品,xBinder,很贵,很难用,还不如自己开发。(为什么难用?因为它太通用)。除了编码为二进制格式,还可以编码为任何你需要的格式。我们知道二进制格式虽然效率很高,但是太难调试(当然有些人看内存里的十六进制还是很快的),所以我们可以在编码成二进制的同时,还生成编码为其他可阅读的格式的代码,比如XML。这样,通讯使用二进制,而调试使用XML,两全其美。产生二进制的代码大概是这样的:
同样也很适合机器产生。同样的思路可以用来让软件内嵌脚本支持。这里不多说了。(内嵌脚本支持最大的问题是在C/C++和脚本之间交换数据,也是针对数据类型的大量相似代码。)
最近Google 发布了它的protocol buffer,就是这样的思路。目前支持C++/Python,估计很快会支持更多的语言,大家可以关注。以后就不要再手写编码解码器了。
4.2.2. GUI代码
上面的框架设计部分,我们说到框架对界面数据收集和界面更新无能为力,只能抽象出接口,由程序员具体实现。但是让我们看看这些界面程序员做的事情吧。(代码经过简化,可以看作伪代码)。
这样的代码很有趣吗?想想我们可以怎么做?(XML描述界面,问题是对于复杂逻辑很难)
4.2.3. 小结
由此可见,在软件架构的过程中,首先要遵循一般性的原则,尽量将系统各个功能部分独立出来,实现高内聚低耦合,进而发现系统存在的高度重复,规律性很强的代码,进一步将他们规则化,形式化,最后用机器来产生这些代码。目前这方面最成功的应用就是消息的编解码。对界面代码的自动化生成有一定局限,但也可以应用。大家在自己的工作中要擅于发现这样的可能,减少工作量,提高工作效率。
4.2.4. Google Protocol Buffer
Google刚刚发布的Protocol Buffer是使用代码自动生成的一个典范。
Protocol buffers are a flexible, efficient, automated mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages. You can even update your data structure without breaking deployed programs that are compiled against the “old” format.
你要做的首先是定义消息的格式,Google指定了它的格式:
Once you’ve defined your messages, you run the protocol buffer compiler for your application’s language on your .proto file to generate data access classes. These provide simple accessors for each field (like query and set_query) as well as methods to serialize/parse the whole structure to/from raw bytes – so, for instance, if your chosen language is C++, running the compiler on the above example will generate a class called Person. You can then use this class in your application to populate, serialize, and retrieve Person protocol buffer messages. You might then write some code like this:
Protocol Buffer的编码格式是二进制的,同时也提供可读的文本格式。效率高,体积小,上下兼容。目前支持Java,Python和C++,很快会支持更多的语言。
5. 面向语言编程(LOP)
5.1. 从自动化代码生成更进一步
面向语言编程的通俗定义是:将特定领域的知识融合到一种专用的计算机语言当中,从而提高人与计算机交流的效率。
自动化代码生成其实就是面向语言编程。语言不等于是编程语言,可以是图,也可以是表,任何可以建立人和机器之间交流渠道的都是计算机语言。软件开发历史上的一次生产率的飞跃是高级语言的发明。它让我们以更简洁的方式实现更复杂的功能。但是高级语言也有它的缺点,那就是从问题领域到程序指令的过程很复杂。因为高级语言是为通用目的而设计的,所以离问题领域很远。举例来说,要做一个图形界面,我可以跟另一个工程师说:这里放一个按钮,那边放一个输入框,当按下按钮的时候,就在输入框里显示Hello World。我甚至可以随手给他画出来。
对于我和他直接的交流而言,这已经足够了,5分钟。但是要让转变为计算机能够理解的语言,需要多久?
如果是汇编语言?(告诉计算机如何操作寄存器和内存)
如果是C++? (告诉计算机如何在屏幕上绘图,如果响应鼠标键盘消息)
如果有一个不错的图形界面库?(告诉计算机创建Button,Label对象,管理这些对象,放置这些对象,处理消息)
如果有一个不错的开发框架+IDE? (用WYSIWYG工具绘制,设计类,类的成员变量,编写消息响应函数)
如果有一门专门做图形界面开发的语言?
可以是这样的:
通用的计算机语言是基于变量,类,分支,循环,链表,消息这些概念的。这些概念离问题本身有着遥远的距离,而且表达能力非常有限。自然语言表达能力很强,但是歧义和冗余太多,无法格式化标准化。传统的思想告诉我们:计算机语言就是一条条的指令,编程就是写下这些指令。而面向语言编程的思想是,用尽量贴近问题,贴近人的思维的办法来描述问题,从而降低从人的思想到计算机软件转换的难度。
用LPC创建一个NPC的代码类似如下:
LPC培养了一大批业余游戏开发者,甚至成为很多人进入IT行业的起点。原因就是它简单,易理解,100%为游戏开发设计。这就是LOP的魅力。
5.2. 优势和劣势
LOP最重要的优点是将领域知识固化到语言中,从而:
其次,由于LOP不是通用语言,所涉及的范围就狭窄很多,所以:
-
更容易得到稳定的系统
-
更容易移植
相应的,LOP也有它的劣势:
5.3. 在嵌入式系统中的应用
举例,嵌入式设备的Web服务器。很多设备都提供Web服务用于配置,比如路由器,ADSL猫等等。这种设备所提供的web服务的典型用例是用户填写一些参数,提交给Web服务器,Web 服务器将这些参数写入硬件,并将操作结果或者其他信息生成页面返回给浏览器。由于典型的Apache,Mysql,PHP组合体积太大且不容易移植,通常嵌入式系统的Web服务都是用C/C++直接写就的。从socket管理,http协议到具体操作硬件,生成页面,都一体负责。然而对于功能复杂,Web界面要求较高的情况,用C来写页面效率就太低了。
shttpd是一个小巧的web服务器,小巧到只有一个.c文件,4000余行代码。虽然体积很小,却具备了最基本的功能,比如CGI。它既可以独立运行,
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!