转自 | 嵌入式系统
嵌入式软件开发的解耦对于后期升级和维护至关重要,如果不重视,后期将会很痛苦,今天就来讲讲解耦的内容!
1 系统分层
嵌入式软件开发,实现解耦的第一步就是合理的分层架构设计。对资源充裕的复杂项目,为满足复杂业务逻辑、系统可扩展性要求,以及长期的可维护,必须采用分层设计和模块化架构思想。
方案
嵌入式软件最常见的分层架构,一般从上到下分别是:
应用层 (业务层): 用户的业务逻辑、用户界面等
中间件层 (公共组件层): 通用模块
操作系统层 :如RTOS内核
硬件抽象层 (板级支持包):HAL封装底层硬件,为上层提供统一的硬件操作接口
硬件层: 物理组件与外设资源。

为了实现关注点分离,分层遵循一定规则:
1、每一层都有特定的角色和职责。如应用层实现业务逻辑,不关注具体的硬件接口;关注点分离,便于构建高效的角色和职责
2、分层架构中的请求从一层移到另一层,必须逐层传递,不能跃层访问
3、各层的变更理论上不影响其它层(当大变更不可避免时,应及时沟通,或者在技术层面阻止编译或者运行,研发阶段解决影响)
软件架构设计需要对关注点进行清晰且条理分明的分离,以便各层可以独立地开发和维护,每一层都是一组模块,提供高内聚的服务。
即使是资源受限的单片机裸机系统,采用大循环体架构,或者中断-循环前后台(中断处理紧急事务,主循环处理常规任务)架构,也尽量划分至少两层,即保留硬件抽象层和业务层。对极少数只能使用汇编或者奇葩C(不支持函数传参)的平台不在考虑范畴。
应用
分层会导致性能轻微下降,因为一个业务请求经过架构中的多层来实现影响效率,还会增加系统运行成本和复杂性。但对于绝大多数消费电子,没有非常严苛的时隙要求,不存在这些问题;事实上分层架构也是嵌入式软件最常用的架构。唯一需要注意的是合理控制内存消耗,避免因层间调用过度设计引入额外开销。
基础分层实现方法很多,可以参考微信公众号【嵌入式系统】的《嵌入式软件分层隔离的典范》,从系统层面实现的详细方法。针对RTOS开发,思想很重要,可以参考《基于RTOS的软件开发理论》、《FreeRTOS及其应用,万字长文,基础入门》。
2 子模块设计
分层只是宏观上的隔离,实际开发中还需借助多种设计模式来进一步实现模块间的松耦合。对于嵌入式软件难解耦的,是上层业务的灵活多变,和底层硬件器件更替的驱动兼容。
2.1 事件驱动
事件驱动模式将系统分解为"事件生产者"和"事件消费者",通过事件队列解耦系统组件。事件调度程序根据调度策略从队列中拉取事件并将它们分配到合适的事件处理器。系统不是周期性地轮询各个任务,而是对外部或内部触发的事件做出响应。方案
事件驱动模式常用于对异步响应要求高、用户交互多的系统。将事件和处理关系绑定,预先建立关系表(软件层面就是表驱动法),或者动态订阅和取消订阅维护关系表。实际操作需要注意几点:
1、设计事件过滤机制,避免不必要的,或者重复的事件;减少事件源从根本减少系统负担
2、一些高频触发的事件,可考虑事件合并,或者延迟处理最终结果,类似中断防抖的效果
3、事件处理的效率也很关键,有时需要特殊处理优先级高的事件,可单独建立队列优先保证处理
4、处理执行超时,可以分解为多个步骤执行
5、对于资源受限系统使用静态事件池而非动态分配;充裕的队列可以选择RTOS内核的队列或自己实现循环队列
实现方式(非完整源码,后续范例也是,表意而已)如下:
//微信公众号【嵌入式系统】
//定义事件类型
typedefstruct {
int eventType;
void* eventData;
} Event;
//定义事件处理函数
typedef void (*EventHandler)(Event*);
//注册事件处理
void registerEventHandler(int eventType, EventHandler handler);
//取消注册
void unregisterEventHandler(int eventType, EventHandler handler);
//发布事件
void publishEvent(int eventType, void* eventData);
//处理事件的示例函数
void eventHandle_1(Event* event)
{
//处理事件
}
void eventHandle_2(Event* event)
{
//处理事件
}
int test(void)
{
//...
//注册事件处理函数
registerEventHandler(EVENT_1, handleEvent_1);
registerEventHandler(EVENT_2, handleEvent_2);
//发布事件
publishEvent(EVENT_1, eventData_1);
publishEvent(EVENT_2, eventData_2);
//...
return0;
}
//微信公众号【嵌入式系统】
在上面的示例中,registerEventHandler函数用于注册事件处理函数,publishEvent函数用于发布事件。通过这种方式,事件的处理逻辑与事件的发布逻辑解耦合,可以独立开发和测试。
若事件与处理函数为固定组合,也可采用表驱动法。
//微信公众号【嵌入式系统】
typedef struct
{
int event; //事件
pFun event_handle; //对应处理的函数指针
}event_table_struct;
static const event_table_struct event_table[]={
{EVENT_1, handleEvent_1},
{EVENT_2, handleEvent_2},
};
//微信公众号【嵌入式系统】
组件之间通过事件进行通信,而不是直接调用彼此,也就是解耦,可以独立开发和测试每个组件。后续需求增加,扩展事件不影响整体结构。
驱动事件存在依赖关系的,其最终实现和状态机类似。事件驱动模式嵌套分层架构的应用层处理外部触发的事件,诸如人机交互,传感器监测,外设通信等;也可以作为单片机裸机系统的架构(轮训标记处理不同任务)。
设计模式大多针对面向对象的语言,可以参考微信公众号【嵌入式系统】的《嵌入式软件的设计模式(上)》、《嵌入式软件的设计模式(下)》,了解针对嵌入式软件的设计模式方法。
2.2 数据流
数据流场景如数据采集、预处理、分发响应等,这类系统需要需要对数据进行一系列处理,即管道-过滤器模式。将系统分解为多个独立的组件,每个组件负责执行特定的数据转换或处理任务,可以灵活地相互结合,并通过“管道”将这些组件串联起来,形成一个完整的工作流程。这些通用松耦合的组件容易复用,可独立的开发维护。
方案
这种架构中的管道构成了过滤器之间的通信通道,管道接收来自一个源的输入并输出到另外一个源,起点经过不同的过滤器组件逐级处理转换到达终点。
1、定义过滤器的标准接口,确保过滤器都能接收相同格式的输入,并输出相同格式的数据
2、根据需求创建具体的过滤器,每个过滤实现特定的功能,注意顺序考虑,以及对输入输出进行校验、定义边界
3、使用链表将过滤器按顺序连接起来,形成完整的处理链条
应用
比如基于位置服务的物联网设备,实时接收卫星NMEA数据,依次经过数据解码,过滤,再分发给其它报警模块,如定时上报后台服务器,行驶超速或急变速报警等。
//微信公众号【嵌入式系统】
typedefstruct
{
char* rawData; //NMEA原始数据
void* parsedData; //解析后的数据
int flag; //数据流标记,作为后端过滤器是否继续处理的依据
} NmeaDataPacket;
//接收过滤器
void receiveNmeaData(NmeaDataPacket* packet)
{
//从串口读取NMEA数据
packet->rawData = readFromSerialPort();
}
//解析过滤器
void decodeNmeaData(NmeaDataPacket* packet)
{
if (packet->rawData == NULL)
{
return;
}
//解析NMEA数据,将字符串数据解析转存到结构体
packet->parsedData = nmedDecode(packet->rawData);
}
//过滤过滤器
//NMEA数据过滤,会经常扩展调整
void filterNmeaData(NmeaDataPacket* packet)
{
if (packet->parsedData == NULL)
{
return;
}
//根据应用需求过滤数据
//注意:这里是瞎写的,实际应用中需要根据各种组合关系判断
if (dataError)
{
packet->parsedData = NULL; // 过滤掉该数据
packet->flag=xx; //标记数据丢弃原因
}
}
//分发过滤器
void dispatchNmeaData(NmeaDataPacket* packet)
{
if (packet->parsedData == NULL)
{
return;
}
//清洗后的有效数据,将数据分发给其他模块
//这里就可以使用前面的事件驱动模式了
}
//微信公众号【嵌入式系统】
上面是各种过滤器定义,(其中关于数据过滤算法,以及嵌入式系统常用的算法,可以参考微信公众号【嵌入式系统】的嵌入式软件常用算法)可以下面是组合使用。各过滤器对输入的数据务必进行边界校验,可以参考《防御式编程》。
//微信公众号【嵌入式系统】
//使用链表将过滤器按顺序连接起来,形成完整的处理链条
typedefstruct FilterNode
{
void (*filter)(NmeaDataPacket*);
struct FilterNode* next;
} FilterNode;
//过滤器链表即管道模式
void addFilter(FilterNode** head, void (*newFilter)(NmeaDataPacket*))
{
FilterNode* newNode = malloc(sizeof(FilterNode));
newNode->filter = newFilter;
newNode->next = NULL;
if (*head == NULL)
{
*head = newNode;
}
else
{
FilterNode* current = *head;
while (current->next != NULL)
{
current = current->next;
}
current->next = newNode;
}
}
使用方法:
//微信公众号【嵌入式系统】
//主程序中初始化数据包并通过管道处理
int track_gnss_task(void)
{
FilterNode* pipeline = NULL;
//将4个过滤器添加到链表形成管道
addFilter(&pipeline, receiveNmeaData);
addFilter(&pipeline, decodeNmeaData);
addFilter(&pipeline, filterNmeaData);
//扩展点
//结合实际NMEA处理算法,识别经纬度漂移点,可能需要持续维护完善,后续可以再不改其它代码的情况下,在此新增过滤器
addFilter(&pipeline, dispatchNmeaData);
NmeaDataPacket packet;
FilterNode* current;
while (1)
{
//持续处理数据流
//这里假设UART读取数据是阻塞,否则 while 1是会死的
memset(&packet, 0, sizeof(NmeaDataPacket));
current = pipeline;
while (current != NULL)
{
current->filter(&packet);
current = current->next;
}
}
return0;
}
也许有更好的组织方式,但这种对数据流的处理更直观,各组件可独立维护,也可插入或者删减过滤器;其它非数据流场景,处理流程嵌套会增加编写过滤器本身的复杂性,一般不使用管道-过滤器模式,一般考虑事件驱动或者状态机。可以参考微信公众号【嵌入式系统】的《嵌入式软件的设计模式(上)》、《嵌入式软件的设计模式(下)》。
2.3 依赖注入
前面的方式是针对业务场景,而依赖注入则是针对逻辑关系,是正经的解耦方法。其基本思想是将组件之间的依赖关系从组件内部移到外部,一个模块不自己创建其依赖项,而由外部传入,降低模块间的耦合度,各组件可独立开发测试。
方案
通过初始化构造函数,或动态设置方法将依赖项传递给需要它们的对象,而不是由对象自己创建依赖项。(依赖倒置等编码设计原则可参考《嵌入式软件设计原则随想》、《Unix哲学之编程原则》)通俗的解释就是模块执行的动作是个函数指针,由调用者传入。这样可以更容易地替换依赖项,便于单元测试,并提高代码的复用性。

1、封装函数指针作为接口,初始化时赋值传入依赖关系
2、模块内部只依赖接口,不依赖具体实现,便于独立开发维护
应用
先使用精简版描述使用方法。
//微信公众号【嵌入式系统】
//依赖注入演示版
typedefstruct
{
void (*userFunction)();
} Dependency;
//定义组件
typedefstruct {
Dependency* dependency;
} Component;
//使用依赖接口的函数
void useDependency(Component* component)
{
//...
if(component->dependency->userFunction!=NULL)
{
component->dependency->userFunction();
}
}
//依赖接口实现
void dependencyCustomerHandle()
{
//自定义实现逻辑
}
int test(void)
{
//创建依赖实例
Dependency dependency;
dependency.userFunction = dependencyCustomerHandle;
//创建组件
Component component;
component.dependency = &dependency;
//使用组件
//最终执行的是调用者传入的dependencyCustomerHandle
useDependency(&component);
return0;
}
//微信公众号【嵌入式系统】
如上,将依赖关系通过参数传递给组件,实现了组件useDependency最终运行的是上层注入的dependencyCustomerHandle。这样整体框架不变,上层可按需传入不同参数,与底层解耦。
为了更详细的描述其使用方法,体现其价值,再以不同平台实现LED亮灭为例表示。
//微信公众号【嵌入式系统】
//LED开关模块
typedefstruct
{
void (*on)(void);
void (*off)(void);
} led_driver_interface_t;
typedefstruct
{
led_driver_interface_t *driver;
} led_controller_t;
void led_controller_init(led_controller_t *ctrl, led_driver_interface_t *driver)
{
if (ctrl && driver)
{
ctrl->driver = driver;
}
}
void led_controller_turn_on(led_controller_t *ctrl)
{
if (ctrl && ctrl->driver && ctrl->driver->on)
{
ctrl->driver->on();
}
}
void led_controller_turn_off(led_controller_t *ctrl)
{
if (ctrl && ctrl->driver && ctrl->driver->off)
{
ctrl->driver->off();
}
}
//微信公众号【嵌入式系统】
LED的开关封装,但具体如何实现开关并没定义,需要调用者传入。
//微信公众号【嵌入式系统】
static void gpio_led_on(void)
{
//GPIO or PWM ...
}
static void gpio_led_off(void)
{
//GPIO or PWM ...
}
led_driver_interface_t gpio_led_driver =
{
.on = gpio_led_on,
.off = gpio_led_off,
};
int test(void)
{
led_controller_t led_ctrl;
led_controller_init(&led_ctrl, &gpio_led_driver);//依赖注入
//执行动作
led_controller_turn_on(&led_ctrl);
led_controller_turn_off(&led_ctrl);
return0;
}
//微信公众号【嵌入式系统】
当硬件变更LED的控制方式,只需调用者更改自身的gpio_led_driver配置,框架完全不用改动。这在Linux的设备树很常见。
3 非模式的套路
解耦的目前是为了支持灵活调整,在需求变更时以可最小化侵入或最少改动实现效果,且尽量对已经稳定的版本不产生影响。除了常规设计模式这种复杂玩法,还可按实际硬件资源,编码规则做相应调整,简单粗暴有效的方式,注意软件版本控制,相关工具可以参考《Git版本控制工具使用说明和规范》、《代码的保养》。
1、条件编译与宏控制 对定制代码使用 #if defined CUSTOM_FEATURE 隔离,确保标准版不受影响。通过构建系统(如Makefile)传递宏定义,或者宏定义集中配置的.h头文件定义。可以参考微信公众号【嵌入式系统】的《项目配置与编译自动化》,早期文章可能不太友好。
2、注入机制 在标准流程插入钩子函数,允许客户注入定制逻辑,类似简易版的依赖注入,适合基于SDK开发插入自定义代码的场景。
//微信公众号【嵌入式系统】
//1、定义函数指针为空,支持接口赋值
pfun userFunction!=NULL;
void userFunctionInit(pfun init)
{
userFunction=init;
}
//2、上层配置,调用userFunctionInit
//3、在原项目中插入代码--statr--
...
if(userFunction!=NULL)
{
userFunction();
}
...
//插入代码--end--
3、回调函数 利用回调函数允许一个模块在特定事件发生时通知另一个模块,而不必直接依赖于该模块。回调函数是实现异步操作和非阻塞I/O的一种常见手段。
4、脚本自动化配置 组件的行为和其使用的资源可以通过外部配置文件来决定,从而实现动态的替换和扩展。或者软件运行时根据特定的规则自动选择正确的模块(多种外设兼容匹配),缺点是代码空间消耗大。参考《嵌入式软件分层隔离的典范》。
5、面向对象编程 即使是在C这样的过程式编程语言中,也可以模仿面向对象的概念,通过类和对象来实现更高级别的抽象,从而增强代码的可维护性和可扩展性。

在软件空间足够的情况下,嵌入式C语言采用面向对象的方法是很有必要的,可提大大高代码复用度。可以参考微信公众号【嵌入式系统】的《嵌入式软件开发的对象在哪(上)》、《嵌入式软件开发的对象在哪(下)》。
4 学有所成
在物联网设备等嵌入式产品中,需求碎片化严重,一套源码需兼容多种应用场景,代码解耦成为必须的指标。
一个复杂模块,明确拆分外部输入,内部执行,结果输出三部分,再合理的与其它模块对接,尽量保证对外接口简单稳定,外部无需过多关注内部实现,这是实现软件解耦的基本规则。通用场景结合成熟的设计模式,让编码更容易。
然代码解耦是为了更好的扩展维护,但第一步是先实现可维护性,可阅读性,

几种嵌入式IAP/OTA升级方案对比!

单片机固件版本号常见的规则~

基于MPU,在Ubuntu系统部署AI模型教程