《 嵌入式系统设计与实践》一一2.2 从框图到架构-阿里云开发者社区

开发者社区> 华章出版社> 正文

《 嵌入式系统设计与实践》一一2.2 从框图到架构

简介: 本节书摘来自华章出版社《 嵌入式系统设计与实践 》一 书中的第2章,第2. 2节,作者:Elecia White 著 ,更多章节内容可以访问云栖社区“华章计算机”公众号查看

2.2 从框图到架构
至此,我们已经有了三种不同的架构设计图,那么下一步怎么做呢?也许会认为开始的时候有些代码没有考虑到,又或者我们会进一步去找出这些模块之间是如何交互的。在开始讨论这些交互(接口)之前,花一些时间讨论一件事情是值得的,那就是:哪些部分将发生变化?在目前这个阶段,所有的事情都是实验性的,因此系统的任何一个部分都可能发生变化。
给出了产品需求规格后,我们可能对系统要实现什么功能比较有信心。在我们的例子里,不管最终要做什么,都需要一个显示器,将位图数据发送给它最好的方式就是闪存。很多闪存芯片是SPI接口的,所以这个会发生变化。将要使用哪款闪存芯片也没有确定。液晶显示屏(LCD)、图像或者字体数据也会发生变化。甚至,存储图像或者字体数据的方式也会发生变化。图2-5中的模块通常表示的是理想状况,而不是具体的实现。
2.2.1 封装模块
我们要设计不同模块之间的接口,而这些接口不依赖于其内部是如何实现的(这就是封装)。我们通过这三个不同的架构设计来找出这些接口的恰当位置。每个模块可能都有自己的接口,或许这些接口可以合并为一个对象。但有什么理由可以让我们现在这样做而不是以后再这样做呢?
有时候是这样的,如果你能在降低设计复杂度的同时又保持足够灵活,那么或许“修剪”依赖树是值得一试的。以下是一些我们可以着手的地方:
在控制层级图中找出只被一个对象调用的那些对象,看看这些对象是不是都固定不变了吗?或者它们各自独立变化?
在层次视图中,找出总是被一起调用的对象集合,然后看看这些对象能不能被组织成高一层次的接口来管理对象? 这样你就能创建一个硬件抽象层。
看看哪些模块有最多的相互依赖性?能把这些模块进一步拆分和简化吗?或者这些依赖关系能不能组织在一起?
垂直方向上相邻对象之间的接口是否可以用少数几句话来描述?此时,可以利用封装来创建一个接口(便于其他人使用这些代码或者只是为了方便测试)。
在我们的例子中,LCD连接到并行接口。在每张图中,这都是比较简单的,因为没有任何额外的依赖关系,同是也没有其他子系统需要访问并行接口。因此,不需要暴露并行接口,可以将它封装在LCD模块内部。
相反,试想如果系统中没有用来封装显示子系统的渲染模块,那么结果会如何。 层次图(见图2-5)将这一点表达得淋漓尽致。字体、图像、LCD和背光模块之间相互连接,让整张图看起来杂乱无章。
由于每个方框都可能成为一个模块(或者对象),所以看看这张图。如何才能让它更简单些?软件中的封装可以让架构图更加简洁,反之亦然。
2.2.2 分派任务
架构视图可以帮我们划分开发工作并分配给适当的团队成员。系统的哪些部分可以分解为独立的、可以描述的部分以便于其他人实现?
我们非常想要有把乏味的开发工作交给团队成员去做,而我们自己只去做有趣的部分。(“过来,给我写测试代码、写文档、叠衣服。”)这种想法不但赶走了好的团队成员,而且还可能降低产品的质量。相反,想想哪个方框(或整个子树)可以分给别人做。在试图对假想的团队成员描述相互依赖性时,就会发现情况比我们在架构视图里描述的要糟糕。(或者找出一个描述谁当前拥有资源的简单标识(比如,信号量)已经足够让人头疼了。
注意: 如果没有团队成员该怎么办?即使如此,完成这样的思考过程也是比较重要的。需要尽可能减少相互依赖性,否则可能导致重新设计系统。而对那些没有办法减少的相互依赖性,至少可以做到在编程时谨记在心。
找出那些可以分解并由其他人完成的部分,这样有助于确定具有简单接口的代码块。而且,当市场部询问怎么能让项目进展更快的时候,可以做到胸有成竹。然而,我们想象中的团队成员还有一个作用:假设他有点能力不足,那么我们该怎么保护自己和代码免受团队成员坏代码的影响呢。
另外一个问题就是打算在模块之间构造什么样的保护机制呢?想象在模块之间传递的数据。能够在模块(或一组模块)之间传递的最小数据量是多少?增加一个模块到分组中意味着传递数据的显著减少?数据怎么样存储才既安全又方便所有需要使用的对象呢?
将模块之间(或者至少是模块组之间)的复杂度最小化可以让项目进行得更顺利。团队成员集中于他们自己代码的开发工作,如果对接口理解透彻,那么每个人都能更容易地开发和测试他们自己的代码。
2.2.3 驱动程序接口:打开(Open)、关闭(Close)、读(Read)、写(Write)和输入输出控制(IOCTL)
前两节采用一种模块接口的自上而下的视图来教大家如何封装模块,以及如何从别人那里得到帮助。用自底向上的方法也可以起到同样的作用。这里的“底”是指那些和硬件打交道的底层模块(驱动程序)。
许多嵌入式系统中的驱动程序都是基于UNIX系统上的应用程序编程接口(API)去调用设备。为什么呢?因为这种模式在许多场合都工作得很好,而且节省了我们很多时间,在需要访问硬件的时候不需要重新发明轮子。UNIX驱动程序的接口是简洁明了的:
Open
打开驱动设备。与此类似的是Init(有时候被替换为Init)。
Close
清除驱动程序,通常关闭之后其他子系统才可以调用open。
read
从设备读取数据。
write
将数据发送到设备。
ioctl
输入/输出接口中其他操作没有控制和处理的一些事情。虽然有时候内核程序员并不鼓励使用它,但因为它缺少结构性,所以它的应用仍然很流行。
在UNIX系统中,驱动程序是内核的一部分。每个这样的函数都会有一个文件描述符作为参数,文件描述符表示要请求的设备(比如/dev/tty01代表系统的第一个输出终端)。对于没有操作系统的嵌入式系统来说,就相当麻烦了。这里的思路是像UNIX系统驱动程序设计那样来设计你的驱动程序。举个例子,对于嵌入式系统设备的驱动程序来说,其功能可能类似以下这样注1:
spi.open()
sip_open()
spiOpen(WITH_LOCK)
spi.ioctl_changeFrequency(THIRTY_MHz)
spiIoctl(kChangFrequency, THRITY_MHz)
这个接口让驱动程序层的操作直接明了,同时驱动程序也更少地针对特定的应用程序,便于创建可重用的代码。而且,当其他人看到我们的代码时,如果驱动程序和这些函数一样,那么他们就很容易明白是怎么回事了。
注意: UNIX系统中的驱动程序模型有时候包括两个比较新的函数。第一个函数是select(或者poll),等待设备改变状态。过去这是通过空读或者轮询ioctl消息来完成的,但现在有了自己的函数。另一个函数是mmap,它控制驱动程序和调用驱动程序的代码之间的共享内存映射。
如果驱动程序接口没有做到与POSIX兼容,那么不用强制这样做。但是如果可能适合,就从这个标准接口开始设计,这样的设计更好、更容易维护。
2.2.4 适配器模式
适配器模式是一种比较常用的软件设计模式(有时候叫做包装器)。它将对象的接口转换成对于客户端(或者高层模块)来说比较容易使用的接口。通常情况下,适配器用于应用程序编程接口(API)之上,以隐藏丑陋的接口或者可能发生变化的库。
很多硬件接口像笨拙的软件接口一样。可以把每个驱动程序设计为一个适配器,如图2-6所示。如果把驱动程序 (即使不是open、close、read、write、ioctl) 设计成通用的接口,那么当硬件接口改变时就不需要改变软件了。在理想的情况下,可以切换整个平台而只需要修改底层实现。
image

图2-6:实现了适配器模式的驱动程序
注意,驱动程序是可以堆叠的,如图2-7所示。我们的显示组件调用闪存,接着闪存调用SPI进行通信。当调用显示组件的open方法的时候,该方法调用其子系统的初始化代码,在这个代码中调用闪存的open方法,然后这个方法调用SPI驱动程序的open方法。这里三个层次的适配器都是为了提高软件的可移植性和可维护性。
如果对每个层次的接口都是一致的,那么上层代码就不可能变化了。比如,假设我们的SPI接口的闪存换成了I2C接口的EEPROM(另一种不同的通信总线和不同的内存),显示组件的驱动程序可能不需要变化,或者只需要将调用闪存的函数换成调用EEPROM的函数就可以了。
在图2-7中,我在每个模块的接口中都加入了一个叫做test的函数。第3章会讨论一些自动化测试的策略,这些测试可以让你的代码更加健壮。但是,现在它们只是个占位符而已。
image

图2-7:显示子系统及其低层接口
2.2.5 开始设计其他接口
从驱动程序开始,就得必须根据系统的规格要求定义模块的接口。可以比较安全地说,大部分模块都需要一个初始化函数(对驱动程序来说就是open函数)。初始化可以发生在启动期间对象实例化的时候,或者它可以是系统初始化时的一个函数调用。为了保持模块的可封装性(更容易被重用),高层函数应该负责初始化它们依赖的模块。好的init函数应该可以被不同的子系统多次调用。一个非常好的init函数应该在系统部分失效的时候,可以将系统(或者硬件资源)重置到一个已知的状态。
现在,我们已经不是一片空白的状态了,这时候将每个模块填入接口可能比较容易。同时需要考虑如何保持模块的可封装性,如何划分工作和构建驱动程序模型,开始设计架构图上每个模块的职责。
注意: 设计了三个不同版本的架构图,你可能不想同时维护每个图。在设计接口的时候,可能只需要关注那个对你来说最有用(或者对你的老板来说最清晰)的那个图。
2.2.6 例子:一个日志接口
对于一个资源受限的系统来说,通常缺少和外部通信的途径。本例中的日志模块的目标就是要实现一个健壮和有用的日志系统。本节从定义接口需求开始,然后探讨接口(和本地存储器)的不同方案。至于通信方法是什么无关紧要。在面对限制针对这些接口编程的时候,可以在其他的系统上重用我们的代码。
注意: 将调试日志输出会严重降低处理器的性能。如果在打开和关闭日志的时候,代码行为发生了变化,就需要考虑不同子系统的时序如何一起工作。
具体的实现依赖于具体的系统。有时,可以将一根输入/输出线连接到发光二极管(LED),通过摩尔斯编码将日志消息往外发送(开个玩笑)。然而,大多数时候,需要将调试消息写到某个接口。将系统设计成可调试的是可维护性设计的一部分。即使代码是非常完美的,但在其他人需要增加新的特性的时候也许没有那么幸运了。日志子系统不仅在开发阶段很有帮助,在维护阶段也是非常有用的。
日志接口隐藏了日志的具体实现细节,也隐藏了变化(和复杂性)。但日志需求可能在产品开发周期中发生变化。例如,在开始阶段,开发工具可能有一个额外的串口,此时,可以将日志信息输出到计算机。在稍后的某个阶段,串口可能不再可用,于是,就需要简化日志并把它通过一个或者两个发光二极管输出。
有时候,我希望在系统异常的时候能够考虑得非常全面。不幸的是,没有足够的资源输出想要的一切信息。而且,日志输出方法可能在产品开发过程中发生变化。这点就是应该通过将发生变化的函数调用进行封装的地方。这里一点儿额外的开销会带来极大的灵活性。所以,如果可以面向接口编程,那么底层实现方法的变化就没有关系。
通常,我们想要通过一个相对狭窄的通信通道输出大量的信息。当系统日益增大的时候,这个通道就会显得更小。通道可以是通过RS232连接到计算机的简单的串行接口,这是一种需要特殊硬件的方法。还可以是通过网络传送的调试数据包。数据可以存储在外部RAM上,只有暂停处理器运行和读JTAG时才可以读取日志。只有运行在开发环境时日志才是可用的,而在用户的硬件环境上就不可用。日志模块的需求有三点。
第一点,日志接口应该可以处理各种不同的实现情况。
第二点,当我们正在调试系统的某一部分时,也许不希望看到来自其他部分的消息。所以,记录日志的方法应该是特定于某个子系统。当然,肯定需要知道其他子系统崩溃时发生的问题。
第三点,就是关于优先级。优先级可以让我们在调试某个子系统的细节时不至于忽略来自于其他部分的重要信息。
日志典型调用
定义一个模块的主要接口需求比定义本身来说要做更多的事情,尤其在设计阶段。但是,千言万语总可以总结成类似下面这句代码:
Void Log(enum eLogSubSystem sys, enum eLogLevel level, char *msg);
这个函数原型并不是固定的,它可能随着接口的发展而变化。但它提供了一个对于其他开发者来说非常有用的速记符号。
日志级别可以包括:空、信息、调试、警告、错误以及危险。子系统则取决于具体的系统,可以包括:通信、显示、系统、传感器、升级固件等。
注意,日志消息是个字符串,与printf和iostream中的可变参数不同。如果有这个功能,可以使用库来构造这个消息。但是,printf和iostream系列的函数在需要更多代码空间和内存的系统中,通常是首先要去掉的。如果情况是这样,那么我们可能需要自己去实现需要的功能,于是,这个接口应该具备满足需求的最小功能。除了打印字符串之外,通常还需要能够每次输出至少一个数字:
void LogWithNum(enum eLogSubSystem sys, enum eLogLevel level, char *msg, int number);
使用子系统标识符和优先级可以做到远程修改调试选项(如果系统允许这么做)。在开始调试的时候,可能将所有的子系统都设置为低优先级(如调试),并且当一个子系统调试完之后,就提升其优先级(如错误)。这样就可以只在需要的时候输出想要的信息。因此我们需要定义一个能调整子系统和优先级灵活性的接口:
void LogSetOutputLevel(enum eLogSubSystem sys, enum eLogLevel level)
因为调用代码不关心底层是如何实现的,所以调用代码不应该直接访问这个接口。所有的日志函数都应该调用这个日志接口。在理想情况下,底层接口不应该和任何其他模块共享,但架构图会告诉你是否正确。日志初始化函数应该调用任何它依赖的函数,不管是初始化一个串口驱动程序还是设置输入/输出线。
由于日志可以改变系统时序,所以有时候需要以一种全局的方式关掉日志输出。这样就可以肯定地说这里的调试子系统没有对任何其他代码造成干扰。虽然用的不是很多,但将打开/关闭(on/off)开关加到接口中是个很棒的方法:
void LogGlobalOn();
void LogGlobalOff();
其他子系统的设计不会(也不应该)依赖于日志系统的实现方式。如果我们能就模块的接口这么说(“其他子系统不依赖于XYZ子系统的实现方式,它们只需要调用其给出的接口”),那么系统接口的设计就比较成功了。
代码的版本
有时候,需要知道当前运行代码的准确版本。在现实应用中,在帮助/关于对话框中放置版本号是一个简单直接的做法。在嵌入式系统中, 版本号应该可以通过主要通信方式获取到(串口、I2C、其他总线等)。如果可以,应该在系统启动的时候通过这些方式自动输出版本号。如果这样不行,试着通过查询的方式获取版本号。如果这样还是行不通,那么就应该将版本号编译到对象文件中,存储在特定的地址内,以便在查询的时候可用。
理想的版本号采用A.B.C的方式:
A是主版本号(1字节)
B是次版本号(1字节)
C是构建号(2字节)
如果构建号没有自动递增,就应该经常递增它(数字是不需要额外成本的)。根据具体的输出方式和系统原理,为了恰当地显示版本号,可以在日志代码中增加一个接口:
void LogVersion(struct sFirmwareVersion *v)
运行时代码不是系统中唯一需要版本号的部分。系统中每一个需要单独构建和升级的部分都需要版本号,这应当是规约的一部分。比如,如果在生产的时候需要对一块EEPROM编程, 那么它就应该有一个版本号以便代码在使用EEPROM之前检查。在某些情况下,可能没有足够的空间和能力做到向下兼容,但确保系统中各个运行组件之间当前能够相互兼容则非常关键。
日志状态
在设计系统架构的时候,有些部分比其他一些部分容易定义,尤其是那些与之前曾经做过的东西很类似的部分。在定义这些接口的时候,我们会感到成竹在胸,实现起来容易而且有趣,而所要做的就是立刻着手。
暂且抑制一下急于求成的心情吧,应该尽量将所有的部分保持在同一个级别。如果在定义一个模块与其他子系统的接口之前,把该模块实现得完美无缺,你就可能会最终发现这些子系统并不能很好地配合。
在对系统的各个模块做进一步的研究之后,就应该考虑这个模块的状态。总的说来,一个模块所拥有的状态越少越好(这样函数在每次被调用时都会做同样的事情)。但是,去掉所有的状态通常是不可能的(或者至少是件很棘手的事情)。
回到我们的日志模块:能设置所要的内部状态吗?LogGlobalOn和LogGlobalOff函数设置(和清除)同一个变量。LogSetOutPutLevel需要知道每个子系统的级别。
有多种不同的选项去实现这些变量。如果想去掉局部状态,可以将它们放到一个结构体(或者对象)中,这样每个调用日志模块的函数都必须拥有。但是,这需要将日志对象传递给所有需要日志功能的函数,以及每一个需要调用某个函数并且该函数需要日志功能的函数。
注意:你可能会认为像这样传递状态变量有些令人费解。对于日志模块,我同意。但是,你有没有想过当你打开一个文件的时候,获取的文件句柄中都有什么吗?打开文件的操作包含了大量的状态信息。
也许传递所有这些参数并不是一个很好的主意。那么,每个使用日志子系统的调用者如何访问它呢?如《Object-Oriented Programming in C》中提到的,也可以在C语言中创建一些面向对象的特性。即使在一个更面向对象的语言中,也可以有一些模块,在该模块中有些全局函数并将状态保存在一个局部对象中。然而,还有另外一种方法可以提供对日志对象的访问而不需要将该模块完全开放。
C语言中面向对象的编程
既然大多数系统都有比较好的C++编译器,那么为什么不使用C++呢?事实上,有大量的代码早已经用C写好了,有时候需要匹配早已完成的工作。或者我们因为C语言的速度而喜欢它。所有的这一切并不意味着可以将面向对象的原则抛之脑后。
其中一个最重要的思想就是数据隐藏。在面向对象语言中,对象(类)可以包含私有变量。这样我们可以说它们具有内部状态,这些内部状态对其他对象是透明的。C语言有多种不同的全局变量。可以通过适当的设置变量作用域来模拟私有变量(甚至友元对象)。首先,我们来看看C中与公共变量的对等实现,它们通常声明在C文件的顶部,在函数外部:
// everyone can see this global with an "extern tBoolean_t gLogOnPublic;" in the
// file or in the header
tBoolean gLogOnPublic;
这些全局变量会导致意大利面条式的代码。为了避免这些问题,可以在函数外部用static关键字定义一个私有变量,并且通常定义在C文件的顶部。
// file variables are globals with some encapsulation
static tBoolean gLogOnPrivate;
警告: static关键字在不同场合下意义不一样,这让人感到有点懊恼。对于函数和函数外变量,这个关键字的意思是“把我隐藏起来,这样其他模块都看不到”以限制其作用域。对于函数内部的变量来说,static关键字的意思是在不同的调用之间保持值,在这个函数内部起着全局变量的作用。
一组松散的变量有点难以追踪,所以可以考虑将一个模块内部的私有变量封装到结构体中:
// contain all the global variables into a structure:
struct {
tBoolean logOn;
static enum eLogLevel outputLevel[NUM_LOG_SUBSYSTEMS];
} sLogStruct;
static struct sLogStruct gLogData;
如果想让C代码看起来像个对象,那么这个结构体就不应该是模块的一部分,而应该在初始化(对于日志系统来说,就是LogInit)的时候创建(分配内存),然后将其返回给调用函数:
struct sLogStruct* LogInit() {
int i;
struct sLogStruct logData = malloc(sizeof(logData));
logData->logOn = FALSE;
for (i=0; i < NUM_LOG_SUBSYSTEMS; i++) {

logData-> outputLevel = eNoLogging;

}
return logData;
}
这样就可以像对象一样传递这个结构。当然,还需要增加一个方法去释放这个对象,这只需要在接口中增加一个函数就可以了。
模式:单例
要确保系统中每个部分都可以访问相同日志对象还有一种方法,那就是采用另外一种设计模式,这个模式称为“单例”。
当需要一个类有且仅有一个实例时,单例模式是很常用的。在一个面向对象语言中,单例负责解析创建对象的请求并保持其独立的状态。对资源的访问是全局的,但是所有的访问都必须经过这个唯一实例。单例类中没有公共构造函数。在C++中,类似这样:
class Singleton {
public:
static Singleton* Instance() {

if (mInstance == 0) {
  mInstance = new Singleton;
}
return mInstance;

}
protected:
Singleton(); // 除了这个类本身没有其他类可以创建这个实例
private:
static Singleton* mInstance = 0;
}
对于日志系统来说,单例可以让整个系统通过唯一的实例来访问日志对象。通常,当有一个单一的资源(如串口)在系统的多个部分之间共享,单例可以比较容易地避免冲突。
在面向对象语言中,单例也允许延迟资源分配和初始化,这样那些从来没有使用的模块就不会消耗资源。
共享私有全局变量
即使在面向过程的语言(如C语言)中,单例的思想也有一席之地。面向对象设计的数据隐藏的优点已经在 《Object-Oriented Programming in C》中讨论过。保护一个模块的变量不被其他文件所修改(或者使用)可以让你的设计更健壮。
但是,有时候需要一个后门去访问这些私有信息,或者需要重用某块内存以实现其他功能(第8章)或者因为需要从外部代理去测试某个模块(第3章)。在C++中,可以使用友元类来访问这些隐藏的内部成员。
在C语言中,移除static关键字可以让模块变量变成真正的全局变量,我们可以采用另一种稍微欺骗的方法,那就是返回指向私有变量的指针:
static struct sLogStruct gLogData;
struct sLogStruct* LogInternalState() {
return &gLogData;
}
从保持封装性和数据隐藏的角度来说,这并不是一个很好的方法,因此不能滥用。在正式的开发过程中可以对其进行某些限制:
static struct sLogStruct gLogData;
struct sLogStruct* LogInternalState() {

if PRODUCTION

#error "Internal state of logging protected!"

else

return &gLogData;

endif / PRODUCTION /

}
在设计接口并考虑模块的状态信息时,有一点需要铭记于心,那就是需要一些方法对系统进行验证。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:

华章出版社

官方博客
官网链接