软件架构这东西,众说纷纭,各有观点。在我看来,软件架构是软件系统的基本结构,包含其组件、组件之间的关系、组件设计与演进的规则,以及体现这些规则的基础设施。软件架构,从来不是一件容易事,它贯穿在产品的整个生命周期,需要所有团队成员遵守并自律,才能将架构思想在软件中体现。新手工程师,由于经历的项目太少,看不到项目全貌,很难从全局理解软件架构。但软件架构真的只是资深工程师的专利吗?这个也不见得。古人作文,讲究立意为先。今天工程师做项目和产品,也应该先立意。这个意,就是指要有高度。工程师入门能从软件架构的高度出发,看待软件问题,相信对软件的理解,会更加深刻一些。因此,我总结了软件架构的六个步骤,供嵌入式工程师参考。
- 隔离硬件相关代码,建立抽象层
- 建立统一的软件基础设施
- 识别和管理系统数据
- 功能分层与分解
- 组件及其接口设计
- 测试、调试与跨平台开发的支持
今天,们将探讨设计嵌入式软件架构的第三步:识别和处理产品数据。当工作过程中,我发现嵌入式工程师们,在思考架构问题,有两种倾向:
首先,思考问题的出发点,大部分都是硬件。嵌入式工程师们总是会倾向于,把整个嵌入式程序,搞成一个时刻在与硬件交互的底层代码,没有有效隔离硬件,更没有合理分层,甚至一竿子从应用层捅到寄存器。在《嵌入式软件架构的六个步骤(一)抽象层》中,我们对这一现象做过解释。这种做法,是由大部分嵌入式工程师的知识结构偏硬件决定的,可以理解,但绝不提倡。因为这种写法,不符合软件发展的主流趋势,不符合层次化和复用性原则,无法支撑大规模的嵌入式软件开发。目前嵌入式开发产品的软件规模大了很多,硬件有关的部分所占的比例,已经降到了非常小的份额。在大多数嵌入式系统中,真正的价值在于应用层代码,即与硬件无关的部分。
其次,工程师总是喜欢围绕中断、任务、总线的原始数据等底层资源,展开对架构的思考。这依然是面向硬件和面向底层的思维。在硬件资源紧张的年代里(15年前),任务的数量是有限制的(uC/OS-II只支持64个任务,而当前大多支持无限任务,只要RAM和ROM允许),而RAM也是非常紧张的,那时候的工程师,不得不将大量的精力,放在底层上,以便以最小的资源进行数据的处理。而现在,当资源变得不那么稀缺甚至有些过剩时,我们要面向业务逻辑进行数据结构的设计。而业务逻辑体现到软件里,本质上就是数据抽象与数据结构的设计,其次才是程序的编写。
一旦团队完成了软件架构的第一步和第二步,对硬件相关代码进行了剥离,并建立了统一的软件基础设施(非必须步骤),设计嵌入式软件架构的第三步就是识别和管理产品数据。数据包含任何类型,只要是系统内部,又利用其功能执行的任何数据,包括一些中间数据和临时数据,都算系统数据。拿我曾经做过的机器人控制器举例,嵌入式系统可能具有以下系统数据:
- IO口数据(含开关量输入口、开关量输出口、模拟量输入口、模拟量输入口)
- 通信口数据(含串口、CAN接口、RS485等)
- 传感器实时数据
- 电机实时数据
- 车体状态数据
- 地图数据
- 任务数据
- 指令数据
- 当前线路数据
- 当前位置数据
- 错误与报警历史
- LOG数据
- 配置参数
- 脚本数据
- 其他数据
这里面,有些数据,为一些功能独享,但也有很多的数据,由不同的器件产生,并被多个功能所共享。系统中数据越多,数据类型越多,数据共享越多时,系统架构就越复杂。当我们设计和构建一个实时嵌入式系统时,我们所做的核心是识别和管理数据。
嵌入式软件设计的第一原则
数据决定设计,是现代嵌入式软件设计的第一原则。Linux的创始人Linus Torvalds在一次演讲中也曾经说过说: "烂程序员关心的是代码,好程序员关心的是数据结构和它们之间的关系。"他在谈到Git时,也曾表达过类似的观点,“好程序员和烂程序员之间的差别,就在于他们认为是代码更重要还是数据结构更重要。”我们上学时,就学过一个公式:程序 = 数据结构 + 算法。如果从嵌入式程序的宏观视角去理解这个公式:所谓的数据结构,就是数据结构与处理机制;而所谓的算法,就是代码逻辑。好的数据结构,总是会简化代码;而差的数据结构,会导致代码变的比较复杂。
我们可以创建各种漂亮的架构,去开发项目,完成产品。但最有效的架构是围绕系统数据设计的架构。一个只有10个数据和1000个数据的处理方式,是完全不同的。无论工程师琢磨出多么漂亮和优雅的软件架构,只要这个架构不是有效的支撑数据的处理,都是无助于实际开发的。
当我们专注于数据进行架构设计时,需要工程师对数据本身以及数据的转换进行高度关注,关注到数据在软件内部转换的每一个关键环节。实际上,每一个软件(含承载它的硬件),都可以看做一个黑盒子,一端是输入数据,一端是输出数据,而黑盒子是对数据的处理和转换。比如,我们开发一个恒温壶的项目,其原理大致为从一个热敏电阻获取温度,并经过ADC的采集、转换、滤波等操作,根据采集的温度,决定加热丝是否加热,以保持温度维持在恒定问题。原始温度数据,可以看做输入,加热丝的打开,可以看做输出。
架构可以变得高度关注它应该如何处理数据。事实上,数据的处理仅仅需要少量操作。首先,系统可以输入数据。例如,用户可以通过通信接口按下按钮或接收串行数据。其次,系统可以输出数据。例如,显示像素映射到显示器或驱动电机。第三,系统可以处理数据。例如,串行数据可能以数据包格式进入系统,然后对其进行解码。进行处理以验证数据包,然后解压缩存储的数据。最后,系统可以将数据存储在易失性或非易失性存储器中。众所周知,在Linux和Unix系统中,五个操作就已经抽象所有的关于文件(可以认为数据的一种):
- open
- close
- read
- write
- control
识别系统数据,以及可对该数据执行的操作,可以极大地帮助团队设计其嵌入式软件体系结构。分析系统数据,能够很简单的明确设计中的架构需求。不幸的是,太多的团队忽略了数据,要不就是凭感觉和经验在编程,要么就是强行引入了并不适合但光鲜亮丽的架构方案。在很多时候,合适的架构,都是朴素的。朴素到什么地步呢?当架构对软件质量提升没有帮助时,那就去他的架构!这就是为什么8位单片机和简单的嵌入式产品,不必讲究什么架构,就能完成设计和实现。但当软件规模增大(个人观点是一万行代码以上)时,引入合理架构就变成了不得不做的事情,这时对系统的核心数据进行识别和分析,就势在必行。
那么什么是系统的核心数据?很多工程师在应用层里直接处理物理通信口(比如UART)来的数据,这依然是硬件思维。资源(RAM)紧张时,我们当然需要在底层,甚至在中断函数中,直接解析数据,以便节省资源。但从系统架构的角度来说,显然物理中断接收到原始数据,与业务层面,并没有直接关系。在RAM资源允许的情况下,物理中断要经过几次转换,才会到应用层,这几次转换可能包含:外设框架缓冲区、协议栈、设备层、应用层。对于复杂系统,经过的环节可能会更多。
举例来说,我们假定主控制器使用UART(RS232)通过一个电机驱动器,间接控制一个电机,并获取电机实时数据,这在工业控制和机器人等行业是非常常见的场景。外设框架缓冲区,暂存收到的串口数据,然后协议栈解析数据,将有效的数据负载传递给应用层,应用层对数据进行合理转换(滤波、计算等),最后才得到电机的速度、电流、温度等数据。
typedef struct motor_status_data
{
float speed; /* RPM */
float current; /* A */
float temperature; /* °C */
} motor_status_data_t;
void device_motor_get_status(device_motor_t *device, motor_status_data_t *data);
上述代码里,所表达的才是电机这个设备,在系统中的核心数据。它代表电机设备的核心特性。至于这个电机是串口驱动、SPI驱动还是CAN驱动,对应用层来说,并不重要,我们也不应该去关心他,我们将其屏蔽在设备层以下。在设备层里,我们只保留硬件无关的数据。这部分内容,我将在后续文章《设备抽象层》系列中进行详述。
以数据为中心的架构意味着什么?
对很多喜欢搞底层的工程师来说,“数据决定架构”的想法,显得很奇怪。在各种编程领域,面向对象的编程都是被重视的,因为它能将复杂的系统简化。而对象是什么?本质上,对象就是各种彼此相关数据的集合,以及对该数据进行操作。而面向对象的编程理念已经出现几十年了,有很多人已经在讨论更加先进的编程范式了,但单片机上,面向对象还并未普及。实际上,以数据为核心的软件架构设计,在很多领域不仅是必要,而且是强制的。在机器人领域,如果不使用数据驱动的架构设计原则,采集数据并脱机复现场景,是不可能实现的。以数据为中心的架构,有如下优势。
首先,可以完美的解决数据安全问题。不同类型的数据有着不同的安全等级。如果能以数据为中心进行软件架构,那么软件架构会和系统安全,完美融合。工程师可以对数据的安全等级进行设置,不同的安全等级有着不同的保护措施,后续会在《防御式编程》系列文章中详细阐述这个问题。
其次,识别系统数据,可以帮助我们正确将系统拆分到模块这个粒度上。模块(组件),是工程师进行编程工作的最小任务。作为工程师,如果能严格遵循单一职责原则(SRP),每一组数据都会被单独封装在其模块内部,并执行相应的操作。如果模块切分的太粗,会导致很多无关的数据都放在了同一个模块进行处理。在这种情况下,不符合模块设计的“单一职责原则”,从而导致内聚性差,复用性低。如果模块切分的太细,会导致关联密切的数据,被分到多个不同的模块进行处理。在这种情况下,模块之间会产生数据交换,从而导致耦合性太强,复用性也会降低。
第三,以数据为中心,进行系统架构,就意味着工程师是面对应用层进行系统和软件架构,而不是面向底层。在架构阶段上,工程师所关注到东西,是一组数据及其操作,如何达到简洁的状态,从来都不会关注获取这个数据,所需要的硬件资源是什么?一般而言,硬件机制、中断、缓冲区和DMA等底层细节,都会被直接屏蔽在驱动层以内(参考《嵌入式软件架构的六个步骤之抽象层》)。
那么,围绕数据进行的嵌入式软件架构的原则是什么?这就不得不提到老掉牙的一句话,那就是著名的“高内聚,低耦合”原则。何为高内聚?将彼此联系紧密的数据,放进同一个模块进行处理;何为低耦合?让模块间的数据交互,尽可能的小。实际上,一旦一个软件模块符合了上述标准,其接口必然变得简洁。简洁的接口,才更有可能是合理的。
对于上述的AGV系统数据来说,我们可以大体分为如下几个域:
- 外设与驱动:IO口数据、通信口数据
- 设备与驱动:传感器实时数据、电机实时数据
- 运动控制:车体状态数据
- 业务逻辑:地图数据、任务数据、指令数据、当前线路数据、当前位置数据、错误与报警历史、LOG数据
- 配置层:配置参数、脚本数据等
每一个域中,可能还会分为几个软件模块,软件模块包含自己的数据。具体的层次与模块分解,我们将在下一步骤中进行详述。
思维稍微脱离底层的工程师,可能会以任务作为架构设计的核心要素。对于多年之前的嵌入式软件设计,这是合适的。对从前资源紧张的MCU而言,任务是一个稀缺资源,每开一个任务,就意味着要耗费不少RAM的开销,这对于RAM资源紧张的MCU来说,不得不从全局上,由架构师小心规划任务的创建。但在现在的嵌入式系统中,任务也成为了模块中的资源。面向过程式的开发,一般在任务里调用模块。而在合理的架构设计,要不要在模块中启动任务,一般在对模块进行实现的时候,才由负责模块实现的工程师决定。在项目开始时,合格的工程师,想好所有的细节;而优秀的工程师,则会预留最大的空间,推迟细节的决策,提高软件架构的灵活性。
结论
设计嵌入式软件架构的第三步是识别和管理系统数据。对于关注硬件的工程师来说,以数据为中心的软件架构,似乎很奇怪。帮助改变我们思维方式的一种方法是将嵌入式软件的定义修改为:“嵌入式软件,是设计和构建为确定性运行的代码,通畅具备实时性,通过各种形式的输入、处理、输出和存储来管理数据。”
数据体现的是嵌入式系统的本质,体现的是嵌入式系统的抽象特性,只有以数据为核心的嵌入式架构,才可能诞生最合理的软件架构。识别数据,然后跟踪它如何与系统中的其他数据交互,可以帮助工程师了解架构是如何出现的。当工程师从数据的视角看待嵌入式系统时,一个抽象而非具象的视角已经形成;嵌入式系统架构的蓝图,也已经在他的脑中展开。我们就能进入嵌入式软件架构的第四个步骤,系统层次与模块分解。
了解架构是如何出现的。当工程师从数据的视角看待嵌入式系统时,一个抽象而非具象的视角已经形成;嵌入式系统架构的蓝图,也已经在他的脑中展开。我们就能进入嵌入式软件架构的第四个步骤,系统层次与模块分解。