您的位置:首页 > 其它

实时嵌入式软件开发的25个常见错误(三)

2011-11-24 10:58 197 查看

#16 使用消息传送作为主要的进程间通信方式

当软件按照功能模块划分进行开发的时候,首先想到的是以消息作为输入、输出。尽管这种方式在非实时环境(例如:分布式网络)应用的很好,但在实时系统应用中,却存在一些问题。

在实时系统中,使用消息传输会引发三种主要的问题:

1.消息传送需要同步,这是实时调度不可预知的主要原因。如果功能模块同步终止执行,将导致系统的时序分析变得困难,即便不是不可能。

2.在存在进程间双向通信或反馈回路的系统中,都有死锁的可能性。相反,应该使用基于状态的系统,比如:要说明“打开制动装置”的操作,状态的更改被描述为“制动装置应当打开”。

3.与使用共享内存的方法相比,存在着开销更大的问题。通过网络和串行线路通信可能需要通过消息,而如果能够随机访问数据的话,比如单处理器上的进程间通信,消息传送通常是效率低下。

在嵌入式系统中,更推荐使用基于状态的通信方式以确保更高的可靠性。一个基于状态的系统使用结构化的共享内存,可以使通信的开销降低。当进程需要状态信息的时候,总是可以得到最近的状态信息。Streenstrup和Arbib发展了一种port-automaton(端口自动仪)的理论来正式地证明一个稳定、可靠的控制系统可以仅仅通过读取最新的数据来建立[6]。通过创建共享数据的本地拷贝,确保每个进程可以互斥的访问自己所需要信息 就消除了代价昂贵的阻塞。如果系统有可能出现消息丢失,或者如果不是所有模块按照相同的速度运行以及如果想应用共享内存以降低操作系统的开销,使用状态而不是消息都可以为系统提供更好的稳定性。在[4]中,给出了一个高效的基于状态通信协议的例子。

将一个基于消息通信的控制系统转换成一个基于状态通信的控制系统通常是容易的。例如:为实现列车管理的最大化,一个智能铁路控制系统可以独立的控制每一个车上的制动装置。当需要停车时,为让列车在最短的距离内停止,就需要所有车上的制动装置一起启动。由于每一个制动装置的输入/输出逻辑是受控于一个独立的进程,而控制模块必须通知每一个制动模块去打开制动开关。当使用基于消息的系统时,控制系统发送一条消息,“打开制动开关”给每一个制动进程。这种方式会导致:很大的通信开销;如果任务运行的频率不同,还有丢消息的潜在危险;不确定的阻塞;每个进程一份独立的消息拷贝;以及存在死锁的可能性。由于进程之间的相互依赖性,消息通信方式建立起的实时系统是难于分析,而且不适合需要重新配置的系统。相反,在基于状态的通信机制中,每一个制动模块周期性的执行,检测制动变量brake来及时更新自己的制动I/O端口的状态。因为进程是周期性的,因此操作时序分析相对容易。每个进程也只需要状态变量表中的一个单一元素相关,这样消除了进程之间的直接依赖关系。相对与消息传送系统,共享内存的通信方式也减少了系统的开销。

当在对象之间传送数据流的时候,需要在共享内存中,建造一个“制造者/消费者”型的缓冲区,这样在每个周期循环中可以处理最大数量的数据。

#15 没有人可以帮助我

几乎所有的教师都有这样的共识:通过讲授的方式,可以是你对一个问题理解的更深刻。

在遇到问题的时候,实时系统的程序员经常感到无助,如:操作的I/O设备并不象文档中描述的的那样工作。在多数情况下,团队中的其他人都没有他本人在这个领域了解的知识丰富,导致只有一个人独自面对眼前的问题。由于没有找到合理的处理方法,这种错误的观点往往会导致整个产品开发进度和开发质量的下降。当面对这样的情况时,如果没有人比自己更有经验,那么就将相关的材料教授给哪些缺少相关经验的人们,使他们更好的理解问题。

如果没有对知识理解更深刻的人来帮助,那么就去向那些理解相对较浅的人寻求帮助。特别是很多团队中有一些非常愿意学习新东西以获取经验的新人,向这些渴求知识的人去讲解程序是如何工作的,以及目前存在的问题。他们很可能不能完全理解问题,但他们所提出的问题也许会使你产生一些想法或暴露出一些被忽视的问题,并最终使你产生解决问题的方法。

这种处理问题的方法还有一个非常好的副作用。他对新员工起到了培训的作用,当公司需对这方面高级编程技能人员有需求时,就不是单单一个人可以胜任了。

#14 只有一张设计图表

大多数软件系统的设计,整个系统通过一张设计图表来描述(更有甚者,干脆没有)。然而,一个象桌、椅这样的物理实体,尽管他们没有软件项目那么复杂,但仍可以有很多种图表来描述它,如:顶视图、侧视图、底视图、细节图、功能图...

当设计软件的时候,将整个设计在图纸上展现出来是最基本的。普遍被接受的方式是通过软件设计图的建立来实现。整个设计有很多不同的设计图,每张图的设计都从一个不同的角度展现了系统。

此外,还存在在好设计图和差设计图的区别。一份好的设计图在图纸上清晰地反映了设计者的思想;而差的设计图则是混乱的,模棱两可的,在图中遗留了很多未解的问题的。为建立一个优秀的软件,优秀的软件设计图是基础。

通过好的设计图来实现设计的常用技巧有以下一些:

1)一张大型项目自顶向下分解的结构设计图。它可以是描述对象、模块之间关系的数据流图,也可以是基于子系统之间数据交换的子系统图。

2)在结构设计图中的每一个成员,都需要通过一张详细设计图来描述。这张设计图要充分详细,使编程员可以毫无疑问地执行设计中的细节。应当说明的是,在一个多层分解的结构设计图中,在某一层面的详细设计可能成为更低一级设计的结构设计图。因此,相同的图表技巧可以应用在各种类型的图表中。

软件设计人员必须明确区分出进行的是面向过程还是面向数据的设计。

1)面向过程的设计,如:很多控制和通信系统中的设计,应当有数据流图(例如:控制系统描述)、处理流图(也被称为流程图),和有限状态机描述。

2)面向数据的设计,通常在一些基本应用和数据库中使用,应当包含关系图、数据结构图、层次结构,以及表格。

3)面向对象的设计是一种将面向处理和面向数据相结合的设计,应当包含表现不同视角的图表。

#13 在设计图中没有图例

即便是有了设计图表,很多情况下也是没有图例的。这使得图表中的数据流和处理流模块被混淆,而且由于图中的矛盾和模棱两可使整个图表的设计失败。即使是在一些软件工程教科书中的图表也存在有这样问题!!!

评判一张设计图是否存在缺陷的最快捷的方法是看它的图例,确认图中每个方块、每条线、每个点、每个箭头、粗细、填充色以及其他标志是否都与在图例中规定的功能匹配。这条简单的准则就象一个语法检测器一样,使开发人员和检视人员能够快速的的找到设计中的问题。此外,它强迫每个不同类型的块、线和箭头被画成不同的样式,使得不同类型的对象在视觉上容易区分。

事实上,是否采用象UML这样的标准或采用公司开发出的一套规范去画图并不重要。重要的是在每张设计图中,都有图例,而且在同一类型的图表中,使用相同的图例。一致性是关键。

下面是创建一致的数据流图、进程流图和数据结构图的一些方法。对于应用所需的其他类型的图表,也可以建立起类似的方法。

数据流图

这类图表,根据模块之间通信的数据来描述模块之间的关系和依赖性。它通常用于模块分解阶段,是在结构设计一层中最常使用的图表。不幸的是,大多数的数据流图设计的很糟糕,而主要的原因往往就是在于图中的混乱和矛盾。

要做出好的数据流图,要按照下面的方法去做:建立一种规范,并严格的遵守它,做一些图例来解释这个规范。要尽量减少进程或模块之间的连线(数据流)数目。要意识到在流图中,每个方块将成为一个模块或进程,而每一条连线将成为模块之间的某种耦合或进程之间的通信。因此连线越少越好。

一些典型的数据流图规范包括下面几点:

1)矩形代表数据存储区,比如缓冲区、消息队列或共享内存。

2)圆角矩形代表有自己进程执行的模块;

3)直线代表从一个进程或模块中输出到输入另一个进程或模块的数据。

在[4]中,给出了了一些关于控制系统数据流图的案例。

进程流图

这类图表通常描述在模块或进程内部的细节。他们通常使用于详细设计阶段。

向数据流图的方法一样,建立一个规范,并严格遵守它,并做一些图例来解释这个规范。进程流图的典型规范有如下几点:

1)矩形代表处理过程或计算过程;

2)菱形代表判断;

3)圆形代表开始、结束或转换点;

4)直线代表执行代码的顺序;

5)椭圆形代表进程间通信;

6)平行四边形代表I/O;

7)条状物代表同步点。

数据结构图和类结构图

数据结构图和类结构图描述的是多个数据结构和对象之间的关系。这类图表应包含足够的细节,可以直接在模块的.h文件中创建结构(在C语言中)或类(在C++中)。

这类图表典型的规范如下:

1)单个矩形代表一个结构或类中的一个域;

2)一组相邻的矩形代表同一结构或类中的所有域;

3)非临近的矩形说明同他们属于不同的结构或类;

4)从一个矩形伸出的箭头代表一个指针;箭头的另一侧代表被指向的结构或对象;

5)实线代表类之间的关系。在图中,应当有描述不同关系类型的图例。不同的关系类型,应当采用一条不同线宽、颜色或类型的线来代替。

在图2中有一个数据结构图的例子。在[3]中有一个多种关系的类结构图的例子。



#12 使用POSIX风格的设备驱动

设备驱动是用来提供一个对硬件I/O设备的操作接口层,这样高层软件就可以通过统一的,与硬件无关的方式访问设备。不幸的是,很多商用的实时操作系统采用的UNIX/POSIX 风格的设备驱动并不能满足嵌入式系统设计的需要。

特别要说的是,目前系统中使用的接口,如 open(),read(),write(),ioctl(),以及 close()等都是为文件或者是其他面向流的设备而设计的。相反,大多数实时 I/O 都有连接到I/O端口的传感器和激励器。I/O端口包括并口,模数转换器,数模转换器,串口,或者是其他特殊用途的处理器,如处理照相机或麦克风数据的DSP。为了尽量在这些设备上适应POSIX设备驱动应用程序设计风格,程序员将不得不在应用层编码实现硬件特性。

考虑下面例子:控制一个机电设备的实时软件要打开两个螺线管,它们分别连接在一个8-bit 数字I/O输出板的bit-3和bit-7,但是不能影响端口其他六位的值。没有任何POSIX接口能让程序员实现这样的功能。

在实际中,常用三种方法将硬件和这个设备的接口映射起来。一种是修改 write() 函数的参数,如将原来表示写入字节数的第三个参数改为表示写哪个端口。修改标准的API定义的参数后,就没有了驱动程序以及调用它的代码的与硬件无关的特性,因为不能保证不同的I/O设备驱动程序都以同样的格式定义参数。如我们想要定义一个8端口的I/O单板的端口4的bit 3和bit 7,怎么办?对于这块单板就需要采用不同的参数定义。

第二种方法是使用 ioctl()。请求和值作为参数输入。但不幸的是,没有请求的标准,每个设备都可以自由选择自己支持的一套请求。一个关于这个问题的例子就是设置串口波特率为9600bps。不同的设备驱动程序使用不同的位图请求结构来实现这个功能。从而,本来应该具有相同硬件操作接口的设备却不兼容了。这样,使用这些设备的应用程序就依赖于设备特性,在另一种配置环境下就没用了。而且,和read()和write()操作不同,ioctl()主要是在初始化时使用,因为ioctl()通常首要的一点是决定有什么请求,并为该请求转换适当形式的参数。

第三种,一个常用的方法是使用 mmap()来映射设备的寄存器。这种方法允许程序员直接访问设备寄存器。虽然这种方法提供了最好的性能,它损害了使用设备驱动来为设备建立一个与硬件无关的操作接口的目的。用这种方法来写代码是不可移植的,通常难以维护,并且不能在其他配置环境下发挥作用。。

另外一个可选择的方法是把设备驱动程序为封装一个它自己的线程。设备数据通过共享存储器来转发(不是象错误#16 使用消息传送作为进程间的主要通信方式 中讨论的那样通过消息传送)。设备驱动程序就成为了一个可以根据设备存在与否或需要与否来执行的独立进程。如【4】中讨论的那样,这个方法已经被证明能够开发出可移植的设备驱动程序。

#11 错误检测和处理是在事后进行,并且是在尝试和错误中实现

错误检测和处理很少在软件设计阶段以一种很有意义的方式具体体现。。更多时候,软件设计主要关注正常操作,任何异常和处理都是程序员在出现错误后补上去的。程序员可能 (1)到处加入错误检测,很多没有必要的地方都加了以至于它的存在影响了性能和时序;(2)除非是测试时发现了问题后加入有限的代码,否则没有任何错误处理代码。上述两种情况都是没有对错误处理进行设计,它的维护将是一个恶梦。

相反,错误检测应该系统设计中作为另一种状态具体体现。因此,如果应用情况是一个有限状态机,异常情况可以看作是一个引起动作和状态迁移的新状态输入。具体实现的最佳方法仍然是学院中的一个研究课题。

#10 没有分析存储器空间

大多数嵌入式系统的存储空间是有限的。但是绝大多数程序员并不清楚他们设计中的存储空间的使用情况。当被问起某段程序或者某个结构占用了多少存储空间时,就算说错数量级也不是少见的事情。

在微控制器和DSP中,访问ROM,内部RAM,或是外部RAM的性能是明显不同的。综合分析存储器和性能,并将最常用的代码和数据段放入最快的存储器中有助于最有效地使用性能最好的存储器。带CACHE的处理器也能提高性能。

在现代的大多数开发环境下分析内存是一件简单的事情。大多数环境都在编译和连接阶段提供关于存储器分配数据的 .map 文件。要综合分析存储器和性能就困难很多,但是如果性能是个问题的话就当然值得这么做。

#9 用#define声明定义的配置信息

嵌入式程序员经常在他们的 C 代码中使用 #define 声明定义寄存器地址,数组上限,以及配置常量等。尽管这种方法经常使用,却并不是很好。因为这样的话紧急情况下就不能在线打补丁,而且也增加了在其他项目中复用代码的困难。

因为代码中到处都是 #define,这个问题就更为麻烦。这个值可能出现在代码中20个不同的地方。如果需要修改这些值,准确的找到修改的地方并不容易。

举个应急补丁的例子,假设用户在他们的应用中发现,硬件设置的64ms的周期定时器不够长,需要改为256ms。如果使用了#define,那整个工程都需要重新编译,或者机器代码中每个使用这个值的地方都需要打补丁。

相反,如果这个信息存在一个可以配置的变量中(也许是存在非易失性的存储器中),那就只需要修改一个地方的值,然后代码也不需要重新编译。最坏的情况就是复位重启系统。如果需要重新编译的话就不能在线升级,因为用户通常不愿接收重新编译并加载代码。另外,设计人员也需要修改并升级一个新版本。

举一个软件复用性的例子,假设操作I/O设备的代码中用 #define 定义了每一个寄存器的地址。如果系统中增加一个同样的设备,原来的代码就不能直接拿来使用。而需要拷贝代码,同时修改端口地址。

其实可以使用一个映射I/O设备可能使用到的所有寄存器地址的数据结构。举个例子,一个I/O设备有一个8位的状态端口,一个8位的控制端口,和一个16位的数据端口,地址分别 0x4080,0x4081,0x4082。我们就可以象下面一样定义:

typedef struct {

uchar_t status;

uchar_t control;

short data

} xyzReg_t;

xyzReg_t *xyzbase = (xyzReg_t *) 0x4080;

.

.

xyzInit(xyzbase);

etc.

要在地址 0x7E0 上增加第二个设备,只需要轻松的象下面一样增加另一个变量:

xyzReg_t *xyzbase2 = (xyzReg_t *) 0x7E0;

.

.

xyzInit(xyzbase2);

如果需要为这个值打应急补丁,可以从符号表中得到变量 xyzbase 的地址,因此就能预先知道需要修改的存储器的准确地址。

续篇:/article/8301873.html
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: