第二十九章 整洁的嵌入式架构
James Grenning 作
前一段时间,我在Doug Schmidt的博客上看到一篇题为“国防部可持续软件的重要性日益增长”的文章。 Doug提出如下声明:
“虽然软件不会磨损,但是固件和硬件已经过时,因此需要对软件进行修改。”
这对我来说是一个澄清的时刻。Doug提到了两个我认为很明显的术语,但也许不是。软件是可以有很长使用寿命的东西,但随着硬件的发展,固件将变得过时。如果你花时间在嵌入式系统开发上,那么你知道硬件正在不断发展和改进。与此同时,功能被添加到新的“软件”,并不断增长的复杂性。
我想补充Doug的说法:
尽管软件不会磨损,但可以通过对固件和硬件的非托管依赖来破坏软件。
嵌入式软件由于受硬件依赖性的影响而被剥夺潜在的长寿命并不罕见。我喜欢Doug对固件的定义,但是让我们看看那里有哪些其他的定义。我发现了这些替代品:
- “固件保存在ROM,EPROM或闪存等非易失性存储设备中。”(https://en.wikipedia.org/wiki/Firmware)
- “固件是一个软件程序或一组指令硬件设备“(https://techterms.com/definition/firmware)
- ”固件是嵌入在一个硬件中的软件“(https://www.lifewire.com/what- is-firmware-2625881 )
- "固件是“已经写入只读存储器(ROM)的软件(程序或数据)”(http://www.webopedia.com/TERM/F/firmware.html)
Doug的声明让我意识到,这些被接受的固件定义是错误的,至少是过时的。固件并不意味着代码居住在ROM中。它不是固件,因为它存储的地方;相反,它是固件,因为它依赖于硬件,随着硬件的发展,它有多难改变。硬件的发展(停下来看看你的手机是否有证据),所以我们应该考虑到这个现实。
我没有反对固件或固件工程师(我已经知道自己写一些固件)。但是我们真正需要的是更少的固件和更多的软件。事实上,我很失望,固件工程师写这么多的固件!
非嵌入式工程师也写固件!无论何时你将代码嵌入代码中,或者在整个代码中传播平台依赖关系时,非嵌入式开发人员都必须编写固件。Android应用程序开发人员在不将Android业务逻辑与Android API分开时编写固件。
在产品代码(软件)和与产品硬件(固件)交互的代码之间的界限模糊到不存在的地方,我已经参与了许多努力。例如,在20世纪90年代后期,我有了帮助重新设计从时分多路复用(TDM)向IP语音(VOIP)过渡的通信子系统的乐趣。VOIP是现在如何做的,但是TDM被认为是20世纪五六十年代最先进的技术,并且在二十世纪八九十年代被广泛部署。
每当我们向系统工程师询问一个电话应该如何对特定情况作出反应时,他就会消失,稍后会出现一个非常详细的答案。“他从哪里得到这个答案?”我们问道。“从目前的产品代码,”他会回答。纠结的遗留代码是新产品的规格!现有的实现在TDM和拨打电话的业务逻辑之间没有分离。整个产品从上到下依赖于硬件/技术,无法解开。整个产品本质上变成了固件。
考虑另一个例子:命令消息通过串口到达这个系统。毫不奇怪,有一个消息处理器/调度器。消息处理器知道消息的格式,能够解析它们,然后可以将消息分派给可以处理请求的代码。除了消息处理器/调度程序驻留在与UART硬件交互的代码所在的文件相同的文件中,这一点都不足为奇。消息处理器受到UART细节的污染。消息处理器本来可以是具有可能很长使用寿命的软件,而是固件。消息处理器被剥夺了成为软件的机会 - 这是不对的!
我已经了解并理解了将软件与硬件分离很长时间的需要,但Doug的话澄清了如何使用术语软件和固件之间的关系。
对于工程师和程序员来说,这个信息是明确的:停止写这么多的固件,并给你的代码一个长的使用寿命的机会。当然,要求它不会这样做。让我们来看看如何保持嵌入式软件架构的清洁,为软件带来长久而有用的生活战斗机会。
APP-TITUDE测试
为什么这么多潜在的嵌入式软件成为固件?看来,大部分的重点是让嵌入式代码工作,并没有太多的重点放在构建一个很长的使用寿命。Kent Beck介绍了软件开发中的三项活动(所引用的文字是肯特的话,斜体是我的评论):
1.“先让它工作”。如果它不起作用,你就会失业。
2.“然后做对”。重构代码,以便你和其他人能够理解它,并随着需求的变化或更好的理解而发展。
3.“然后让它变快。”重构“需要”性能的代码。
我在野外看到的大多数嵌入式系统软件似乎都是用“让它工作”的方式来编写的 ——也许也是对“快速实现”目标的痴迷,通过在每个机会上添加微优化。在“神话人月”中,弗雷德·布鲁克斯建议我们“打算抛弃一个人”。肯特和弗雷德给出了几乎相同的建议:了解什么是有效的,然后做出更好的解决方案。
嵌入式软件在解决这些问题时并不特别。大多数非嵌入式应用程序都是为了工作而构建的,很少考虑使代码适合长时间使用。
获得一个应用程序的工作是我所谓的程序员的应用程序测试。程序员,不管是否嵌入,只关心让他们的应用程序工作,他们正在做他们的产品和雇主破坏。编程不仅仅是让应用程序工作。
作为通过应用程序测试时生成的代码示例,请查看位于小型嵌入式系统的一个文件中的这些函数:
ISR(TIMER1_vect) { ... }
ISR(INT2_vect) { ... }
void btn_Handler(void) { ... }
float calc_RPM(void) { ... }
static char Read_RawData(void) { ... }
void Do_Average(void) { ... }
void Get_Next_Measurement(void) { ... }
void Zero_Sensor_1(void) { ... }
void Zero_Sensor_2(void) { ... }
void Dev_Control(char Activation) { ... }
char Load_FLASH_Setup(void) { ... }
void Save_FLASH_Setup(void) { ... }
void Store_DataSet(void) { ... }
float bytes2float(char bytes[4]) { ... }
void Recall_DataSet(void) { ... }
void Sensor_init(void) { ... }
void uC_Sleep(void) { ... }
该函数列表按照我在源文件中找到的顺序排列。现在我将他们分开,并关注他们:
- 具有域逻辑的功能
float calc_RPM(void) { ... }
void Do_Average(void) { ... }
void Get_Next_Measurement(void) { ... }
void Zero_Sensor_1(void) { ... }
void Zero_Sensor_2(void) { ... }
- 建立硬件平台的功能
ISR(TIMER1_vect) { ... }*
ISR(INT2_vect) { ... }
void uC_Sleep(void) { ... }
Functions that react to the on off button press
void btn_Handler(void) { ... }
void Dev_Control(char Activation) { ... }
A Function that can get A/D input readings from the hardware
static char Read_RawData(void) { ... }
- 将值存储到持久性存储的函数
char Load_FLASH_Setup(void) { ... }
void Save_FLASH_Setup(void) { ... }
void Store_DataSet(void) { ... }
float bytes2float(char bytes[4]) { ... }
void Recall_DataSet(void) { ... }
- 函数不会做它的名字所暗示的
void Sensor_init(void) { ... }
看看这个应用程序中的其他文件,我发现了很多障碍来理解代码。我还发现了一个文件结构,暗示测试这些代码的唯一方法是嵌入到目标中。几乎所有的代码都知道它是在一个特殊的微处理器体系结构中,使用“扩展的”C结构,将代码绑定到特定的工具链和微处理器上。这个代码没有办法有很长的使用寿命,除非产品永远不需要移动到不同的硬件环境。
此应用程序的工作原理:工程师通过了应用程序测试。但是,应用程序不能说有一个整洁的嵌入式架构。
目标硬件的瓶颈
嵌入式开发人员需要处理非嵌入式开发人员所面临的许多特殊问题,例如有限的内存空间,实时限制和最后期限,有限的IO,非传统的用户界面以及传感器和现实世界的连接。大多数情况下,硬件是与软件和固件同时开发的。作为开发这种系统的代码的工程师,你可能没有地方来运行代码。如果这还不够,一旦你拿到硬件,硬件很有可能会有自己的缺陷,使得软件开发进度比往常慢。
是的,嵌入式是特殊的。嵌入式工程师很特别。但嵌入式开发并不是特别的,本书的原理不适用于嵌入式系统。
其中一个特殊的嵌入问题是目标——硬件瓶颈。当嵌入式代码的结构没有应用整洁的架构原则和实践时,你经常会遇到只能在目标上测试代码的情况。如果目标是唯一可以进行测试的地方,那么目标硬件瓶颈会让你放慢速度。
一个干净的嵌入式架构是一个可测试的嵌入式架构
让我们看看如何将一些架构原理应用于嵌入式软件和固件,以帮助您消除目标硬件瓶颈。
层级
分层有许多方法。我们从三层开始,如图29.1所示。底部是硬件。正如道格警告我们的,由于技术进步和摩尔定律,硬件将会改变。部件变得过时,新部件使用更少的功率或提供更好的性能或更便宜。不管是什么原因,作为一名嵌入式工程师,当硬件发生不可避免的变化时,我不希望有比这更大的工作。
图29.1 三层结构
硬件和系统其余部分之间的分离是给定的——至少一旦硬件被定义(图29.2)。当你尝试通过应用程序测试时,问题经常出现在这里。没有什么能够保持硬件知识不会污染所有的代码。如果你不小心在哪里放置东西,哪些模块被允许了解另一个模块,那么代码将很难改变。我不只是在谈论什么时候硬件改变,而是当用户要求改变,或者当一个错误需要修复的时候。
图29.2 硬件必须与系统其余部分分离
软件和固件混合是一种反模式。展示这种反模式的代码将抵制变化。此外,变化将是危险的,往往导致意想不到的后果。整个系统的全面回归测试将需要进行小的改变。如果您还没有创建外部仪表测试,期望厌倦手动测试,然后您可以期待新的错误报告。
硬件是一个细节
软件和固件之间的界限通常与代码和硬件之间的界限不太清楚,如图29.3所示。
图29.3 软件和固件之间的界限比代码和硬件之间的界线模糊一些
作为一名嵌入式软件开发人员,你的工作之一就是巩固这一路线。软件和固件之间的边界名称是硬件抽象层(HAL)(图29.4)。这不是一个新的想法:自从Windows之前,它已经在PC上。
图29.4硬件抽象层
HAL存在于位于其之上的软件,其API应该根据软件的需要量身定制。例如,固件可以将字节和字节数组存储到闪存中。相比之下,应用程序需要存储和读取名称/值对的一些持久性机制。软件不应该担心名称/值对存储在闪存,旋转磁盘,云或核心存储器中。HAL提供了一个服务,它不会向软件透露它是如何做到的。Flash实现是一个应该从软件隐藏的细节。
又如,LED连接到GPIO位。固件可以提供对GPIO位的访问,其中HAL可能提供Led_TurnOn(5)
。这是一个非常低级的硬件抽象层。让我们考虑从硬件角度提高抽象级别到软件/产品的角度。什么是LED指示?假设它表示电量不足。在某种程度上,固件(或板支持包)可以提供Led_TurnOn(5)
,而HAL提供Indicate_LowBattery()
。你可以看到应用程序所需的HAL表示服务。您还可以看到图层可能包含图层。它是一个重复的分形模式,而不是一组有限的预定义层。 GPIO分配是应该从软件隐藏的细节。
不要向HAL的用户提供硬件细节
整洁的嵌入式体系结构的软件可以从目标硬件上测试。一个成功的HAL提供了那些便于脱靶测试的接缝或一组替代点。
处理器是细节
当你的嵌入式应用程序使用专门的工具链时,它通常会提供头文件给帮助你。这些编译器经常采用C语言,增加新的关键字来访问他们的处理器特性。代码看起来像C,但不再是C。
有时,供应商提供的C编译器提供了看起来像全局变量的东西,直接访问处理器寄存器,IO端口,时钟定时器,IO位,中断控制器和其他处理器功能。访问这些东西很容易,但是意识到使用这些有用设施的任何代码不再是C,它不会为另一个处理器编译,甚至不会针对同一个处理器使用不同的编译器。
我不愿意认为芯片和工具提供商是愤世嫉俗的,把你的产品捆绑到编译器上。假设它真的是在帮助,让我们给这个提供者一个怀疑的好处。但是,现在取决于你如何以一种不会在未来受到伤害的方式来使用这种帮助。你将不得不限制哪些文件被允许了解C扩展。
让我们来看看这个为ACME系列DSP设计的头文件——你知道,Wile E. Coyote使用的是:
#ifndef _ACME_STD_TYPES
#define _ACME_STD_TYPES
#if defined(_ACME_X42)
typedef unsigned int Uint_32;
typedef unsigned short Uint_16;
typedef unsigned char Uint_8;
typedef int Int_32;
typedef short Int_16;
typedef char Int_8;
#elif defined(_ACME_A42)
typedef unsigned long Uint_32;
typedef unsigned int Uint_16;
typedef unsigned char Uint_8;
typedef long Int_32;
typedef int Int_16;
typedef char Int_8;
#else
#error <acmetypes.h> is not supported for this environment
#endif
#endif
acmetypes.h
头文件不应该直接使用。如果你这样做,你的代码被绑定到一个ACME DSP上。你说,你正在使用ACME DSP,那么有什么危害?除非包含此这个头文件,否则无法编译你的代码。 如果你使用标题并定义了_ACME_X42或_ACME_A42
,那么如果你尝试将目标代码置于非目标位置,则整数将会是错误的大小。如果这还不够糟,总有一天你会想把你的应用程序移植到另一个处理器上,而你不会选择可移植性,也不会限制什么文件知道ACME,从而使得这个任务变得更加困难。
而不是使用acmetypes.h
,你应该尝试遵循更加标准化的路径并使用stdint.h
。但是如果目标编译器不提供stdint.h
呢? 你可以写这个头文件。 你为目标版本编写的stdint.h
使用acmetypes.h
来进行目标编译,如下所示:
#ifndef _STDINT_H_
#define _STDINT_H_
#include <acmetypes.h>
typedef Uint_32 uint32_t;
typedef Uint_16 uint16_t;
typedef Uint_8 uint8_t;
typedef Int_32 int32_t;
typedef Int_16 int16_t;
typedef Int_8 int8_t;
#endif
让你的嵌入式软件和固件使用stdint.h有助于保持你的代码清洁和便携。当然,所有的软件都应该是独立于处理器的,但并不是所有的固件都可以。下一个代码片段利用C的特殊扩展,使你的代码可以访问微控制器中的外设。这很可能是你的产品使用这个微控制器,以便你可以使用它的集成外设。该函数向串行输出端口输出一个表示“hi”的行。 (这个例子是基于野外的真实代码。)
void say_hi()
{
IE = 0b11000000;
SBUF0 = (0x68);
while(TI_0 == 0);
TI_0 = 0;
SBUF0 = (0x69);
while(TI_0 == 0);
TI_0 = 0;
SBUF0 = (0x0a);
while(TI_0 == 0);
TI_0 = 0;
SBUF0 = (0x0d);
while(TI_0 == 0);
TI_0 = 0;
IE = 0b11010000;
}
这个小功能有很多问题。有一件事可能会跳出你的存在0b11000000
。这个二进制符号很酷;C可以做到吗?很不幸的是,不行。其他一些问题直接使用自定义的C扩展与此代码相关:
IE
:中断使能位。SBUF0
:串行输出缓冲器。TI_0
:串行发送缓冲区空中断。
读取1表示缓冲区为空。大写的变量实际上是访问微控制器内置的外设。如果要控制中断和输出字符,则必须使用这些外设。是的,这很方便——但不是C。
一个整洁的嵌入式架构可以在很少的地方直接使用这些器件访问寄存器,并将它们完全限制在固件中。任何有关这些寄存器的知识都将成为固件,并因此而与硅片绑定。将代码绑定到处理器将会伤害你,当你想在稳定的硬件之前得到代码工作。当你将嵌入式应用程序移动到新的处理器时,它也会对你造成伤害。
如果你使用这样的微控制器,你的固件可以用某种形式的处理器抽象层(PAL)来隔离这些低级功能。PAL以上的固件可以脱离目标进行测试,使其不那么坚定。
操作系统是一个细节
HAL是必要的,但它是足够的吗?在裸机嵌入式系统中,你可能需要使用HAL来防止代码过度沉迷于操作环境。但是使用实时操作系统(RTOS)或嵌入式版本的Linux或Windows的嵌入式系统呢?
为了给你的嵌入式代码提供一个很好的机会,你必须将操作系统视为一个细节,并防止操作系统依赖性。
该软件通过操作系统访问操作环境的服务。操作系统是将软件与固件分开的层(图29.5)。直接使用操作系统可能会导致问题。例如,如果你的RTOS供应商被其他公司购买,版税上涨,质量下降怎么办?如果你的需求发生变化,你的RTOS不具备你现在需要的功能,该怎么办?你将不得不改变很多代码。由于新操作系统的API,这些不仅仅是简单的语法变化,而且还可能必须在语义上适应新操作系统的不同功能和原语。
图29.5 在操作系统中添加
干净的嵌入式体系结构通过操作系统抽象层(OSAL)(图29.6)将操作系统的软件与操作系统隔离开来。在某些情况下,实现这个层可能就像改变一个函数的名字一样简单。在其他情况下,它可能涉及将多个功能包装在一起。
图29.6 操作系统抽象层
如果你曾经把你的软件从一个RTOS移到另一个,那么你知道这很痛苦。如果你的软件直接取决于OSAL而不是OS,那么你将主要写一个与旧的OSAL兼容的新的OSAL。你应该怎么做:修改一堆复杂的现有代码,或者将新的代码写入到已定义的接口和行为中?这不是一个诡计的问题。我选择后者。
你现在可能开始担心代码膨胀了。不过,真的,这个层级变成了使用操作系统的重复的地方。这种重复不必施加大的开销。如果你定义一个OSAL,你也可以鼓励你的应用程序有一个共同的结构。你可以提供消息传递机制,而不是让每个线程都手动创建它的并发模型。
OSAL可以帮助提供测试点,以便软件层中有价值的应用程序代码可以在脱离目标和脱离OS的情况下进行测试。一个干净的嵌入式架构的软件是可测试的目标操作系统。成功的OSAL提供了便于脱靶测试的接缝或一组替代点。
编程接口和可替代性
除了在每个主要层次(软件,操作系统,固件和硬件)内部添加HAL和潜在的OSAL之外,你可以,也应该应用贯彻本书所描述的原则。这些原则鼓励分离问题,编程接口和可替代性。
分层体系结构的思想是建立在编程接口的基础上的。当一个模块通过一个接口与另一个模块交互时,可以用一个服务提供者替换另一个。许多读者将编写自己的小版本的printf
用于部署目标。只要printf
的接口与printf
的标准版本相同,就可以替换另一个的服务。
一个基本的经验法则是使用头文件作为接口定义。但是,当你这样做的时候,你必须小心头文件中的内容。将头文件内容限制为函数声明以及函数所需的常量和结构名称。
不要将接口头文件与只有实现需要的数据结构,常量和typedef混淆。这不仅仅是一个混乱的问题:这种混乱将导致不必要的依赖。限制实现细节的可见性。预计实施细节将改变。代码知道细节的地方越少,代码将不得不被追踪和修改的地方越少。
一个整洁的嵌入式体系结构在层内是可测试的,因为模块通过接口交互。每个接口都提供有助于脱靶测试的接缝或替代点。
DRY条件汇编指令
经常被忽视的替代性的一个用途涉及嵌入式C和C++程序如何处理不同的目标或操作系统。有一种使用条件编译打开和关闭代码段的趋势。我记得一个特别有问题的情况,在一个电信应用程序中提到#ifdef BOARD_V2
几千次。
代码的这种重复违反了不要重复自己(DRY1)的原则。如果我看到#ifdef BOARD_V2
一次,这不是一个真正的问题。六千次是一个极端的问题。标识目标硬件类型的条件编译通常在嵌入式系统中重复使用。但是我们还能做什么?
如果有硬件抽象层呢?硬件类型将成为隐藏在HAL下的细节。如果HAL提供了一组接口,而不是使用条件编译,我们可以使用链接器或某种形式的运行时绑定将软件连接到硬件。
小结
开发嵌入式软件的人从嵌入式软件以外的经验中学习很多东西。如果你是一个已经拿起本书的嵌入式开发人员,你会发现在这个文字和想法中有丰富的软件开发智慧。让所有的代码成为固件不利于您的产品的长期健康。
只能在目标硬件上进行测试,不利于产品的长期健康。一个干净的嵌入式架构是有利于你的产品的长期健康。
1. don't repeat yourself 别重复造轮子 ↩