【分层架构】关于嵌入式分层的探讨
本文从实际开发痛点出发,结合具体代码案例,介绍嵌入式项目为何需要分层架构,并详细解析各层职责、划分规则以及公共层的灵活运用
一、前言
- 刚接触嵌入式那会儿,我动手做项目的方式很直接:想到什么逻辑就直接写在代码里,也就是俗称的“硬编码”。做点简单的小玩意儿倒还好,没觉得有什么问题。
- 可一旦项目变复杂了,这种写法的毛病就全出来了。做项目很少有一次就能把代码写到完美无瑕的,后期的修改和迭代是常态。如果代码逻辑全硬揉在一起,改一个地方,常常得翻遍整个工程去找相关联的另外几处,费时费力不说,万一漏改了哪里,新 Bug 就冒出来了。更麻烦的是,代码耦合得太紧,有时候我们连 Bug 具体藏在哪里都难以定位。
- 为了解决这个让人头疼的问题,我翻了不少技术博客,也啃了一些开源项目的源码,终于找到了一个行之有效的办法——给代码分层
二、结合案例初步了解
- 先看一个简单的例子,来初步了解一下分层架构:串口发送传感器数据(主要研究串口)
(一)硬编码写法
#include "main.h"
#include "aht20.h"
#include "uart.h"
int main()
{
/* 初始化串口 */
uart_init();
/* 初始化aht20 */
aht20_init();
while(1)
{
/* 读取并发送数据 */
uint8_t temp = aht20_get_temp();
HAL_UART_Transmit(&huart1,&temp,1,HAL_MAX_DELAY);
uint8_t humi = aht20_get_humi();
HAL_UART_Transmit(&huart1,&humi,1,HAL_MAX_DELAY);
/* 延时 */
HAL_Delay(500);
}
}
- 当我们要更换使用的硬件串口乃至 HAL 库时,我们需要在去修改串口的初始化配置,需要去主循环每一处调用串口发送的地方修改代码,非常繁琐
(二)采用分层架构
- 我们把串口通信打包成一个 BSP 单元,位于 BSP 层,名为
bsp_uart。 bsp_uart会调用下层的硬件抽象层(HAL)- 以下为
bsp_uart.c(头文件省略)
#include "bsp_uart.h"
#include "uart.h"
/* 串口服务使用的硬件串口 */
const static UART_HandleTypeDef* bsp_huart = &huart1;
void bsp_uart_init()
{
/* 初始化硬件串口 */
// ......
}
void bsp_uart_send(uint8_t* p_data,uint8_t len)
{
if(p_data!=NULL&&len!=0)
{
HAL_UART_Transmit(bsp_huart,p_data,len,HAL_MAX_DELAY);
}
}
- 然后,在应用层调 BSP 层的
bsp_uart( 此处的应用层是mani.c)
#include "main.h"
#include "bsp_uart.h"
#include "aht20.h"
int main()
{
bsp_uart_init();
aht20_init();
while(1)
{
/* 读取并发送数据 */
uint8_t temp = aht20_get_temp();
bsp_uart_send(&temp,1);
uint8_t humi = aht20_get_humi();
bsp_uart_send(&humi,1);
/* 延时 */
HAL_Delay(500);
}
}
- 这样,当我们改变硬件串口时,只需要修改
bsp_uart.c内部的硬件串口指针即可,同样,更换 HAL 库时,只需要去修改bsp_uart.c内部的发送函数即可 - 当然,以上的案例十分简陋,而在正常的项目时,
bsp_uart通常还会包含接收功能、执行结果的传递等内容,同时还会把传感器的使用也封装成一个srv_sensor - 但我相信,此时你应该已经初步窥见分层架构的好处
三、分层架构的优劣
(一)优点
- 解耦
- 不同层次的代码各司其职,互不干扰。
- 例如:驱动层只负责操作硬件,应用层只关注业务逻辑。驱动出了问题不会让应用层跟着“生病”,修改驱动也不用担心破坏上层逻辑。
- 标准化
- 每一层都按统一的接口规范编写,既便于替换和升级,也有助于养成良好的编程习惯。
- 例如:应用层调用底层时,不管底层用的是 A 型号传感器还是 B 型号,只要新硬件的接口与原来保持一致,上层代码一行都不用改,直接换掉底层模块即可。
- 可移植性
- 更换芯片或开发板时,只需重写最底层的驱动,上层的业务代码、算法、协议处理都无需改动
- 就像给电脑换个键盘,操作系统和软件照样正常使用。
- 可测试性
- 可以单独测试每一层。
- 例如:想验证应用逻辑是否正确,无需接上真实硬件,只需写一个“假驱动”模拟硬件返回数据即可。
- 这样在电脑上就能完成大部分测试,发现 Bug 更快、更安全。
- 可维护性
- 代码结构清晰,哪块功能出问题就去哪一层查找。
- 不像把所有逻辑揉在一起的项目,改一处可能要在整个工程里翻找,还容易引出新的 Bug。
- 可理解性
- 在设计项目的业务逻辑和任务架构时,能更清楚地梳理各层之间的依赖关系,分清主次业务,降低开发难度。
- 代码分层明确,自己回头看时,能快速理解整体设计思路。
(二)缺点
- 性能开销
- 下层向上层提供的接口都会进行封装,依次类推,层层递进,最后的应用层执行时需要层层跳跃才能得到真正的执行,这会带来额外的执行时间和栈空间开销
- 资源消耗
- 分层架构通常都会在内部定义一些私有变量、封装各种 API,而这就需要更多的 ROM 和 RAM 空间
- 需要经验
- 并不是只要了解分层架构就可以设计出很好的分层来,一个健壮的分层架构,既要求对层次的划分恰到好处,职责清晰、依赖明显,又需要一个良好的接口设计,而这些都是需要投入时间进行、经常对项目进行分层才能逐渐摸索出成熟的方案
并不是分层架构好就一定要用上,在一些资源和性能都十分吃紧的芯片上,直接逻辑(硬编码)反而是更务实、更高效的选择
四、具体如何分层
(一)对分层的观察
- 我在浏览博客或者参考开源项目时,我发现很多开发者的分层方式都不太一样,但基本都是大同小异
- 比如各层的命名不同:服务层、功能层这些都是对一些基本能力的封装,供上层调用
- 比如划分的粗细不同:有些项目会把外设驱动划分进 BSP,而有一些项目则会区分开来
- 我个人认为,分层架构并没有一个严格的标准,层次的划分大体如下,一般我们遇到的、不同的分层方案不外乎是对以下各个层次的融合、分割:
| 层级 | 特点 | 案例 |
|---|---|---|
| 应用层(Application) | 顶层、高度定制化;产品的主要业务实现,只进行最核心的逻辑判断,不关心具体操作 | 调用 BSP 层,读取温度,控制制冷/制热 |
| 服务层(Service) | 对项目内的基本功能的封装;可复用,一般不可移植;供应用层调用 | 传感器更新服务,调用 BSP 的各种传感器接口 |
| 中间件(Middleware) | 主要是软件服务;可移植,也可被应用层的各个应用复用, | RTOS、GUI 库、PID 算法、文件系统 |
| 硬件驱动(Driver) | 对外部设备初始、读写等操作的封装;基本可移植 | OLED 驱动库 |
| 板级支持包(BSP) | 决定电路的硬件关联,具体芯片的片上外设初始化实现,提供芯片某个功能的调用接口;移植时需要改动 | bsp_i2c、bsp_adc、bsp_flash |
| 硬件抽象层(HAL) | MCU 片上外设的基本操作封装 | STM32 的 HAL 库 |
| 硬件层(Hardware) | 主控芯片以及外部传感器、存储芯片等实物 | STM32、MPU6050、LED |
(二)总结的核心规则
1. 必须遵守的分层规则
- 绝对单向:上层可以调用下层,但下层一定不可以调用上层
- 队列唤醒、任务通知、回调函数这些不算是调用,只要调用接口才算是调用
- 例如下层通过函数指针回调上层注册的事件处理函数,这属于依赖倒置,是保持分层解耦的重要手段
- 保持独立:同层内各组成的划分必须清晰,不可相互依赖、相互影响
- 统一规范:每个层的层内接口要统一样式
2. 个人把握的余地
- 是否允许跨层调用:
- 跨层调用,即上层直接调用和自己间隔 \(\geq 1\) 层的下层接口,如前面的示例,应用层跨越服务层调用板级支持包的接口
- 跨层调用可以减少对性能和存储的消耗,但同时也会牺牲一定的架构纯洁性
- 对层次的命名
- 命名可以因人而异,此无限制,但最好要贴合层次特点
- 对于层次的划分
- 不是越详细越好,太详细容易增加设计难度和开发时间
- 对于性能和资源都不太宽松但又不至于特别有限的芯片,可以划分得粗一点
- 对于初次分层架构的人,建议先尝试粗划分
(三)我们可以结合一个简单的项目例子大概理解一下
- 设计一个控温杯垫,大致要求如下:
- 可控制温度,范围在 5~50℃内
- 支持显示当前温度和目标温度
- 通过按键来设置温度、开启和关闭
设计思路 (结合实际,从上到下):
- 分层考虑:
- 项目较为简单,不需要详细分层,粗分层即可,去除服务层,并允许跨层调用
- 判断主要的业务:
- 需要能根据当前温度的大小来判断是制冷还是制热
- 既有数据显示又有按键设置,说明需要人机交互
- 有目标温度、开启/关闭状态,说明需要进行数据的管理
- 基于主要业务设计应用层:
- UI 应用
App_GUI:订阅数据、显示当前温度、目前温度、系统状态,通知管理者如何修改配置、状态 - 管理者应用
App_Manager:读取数据,发布数据,修改目标温度,改变系统状态 - 温度控制应用
App_TempCtrl:订阅数据,判断系统状态,若处于系统工作态,则比较当前温度与目标温度,自主判断制冷或者制热
- UI 应用
- 基于应用层,设计硬件驱动层(不涉及硬件连接,单纯是对硬件的操作逻辑):
drv_temp:读取热敏电阻的数据drv_heat:操作制热单元drv_cold:操作制冷单元drv_oled:oled 驱动的封装drv_key:识别按键的短按、长按、双击等功能
- 基于硬件驱动层,设计 BSP 层,同时决定硬件连接:
bsp_spi:封装对芯片硬件 SPI 的硬件配置、初始化及其相应操作,供 OLED 驱动使用bsp_gpio:封装对芯片 GPIO 的硬件配置、初始化以及读取功能bsp_adc:封装对芯片 ADC 的硬件配置、初始化以及读取功能- 略
- 其它层略
- 最终的分层架构如下所示:
虽然,以上案例的设计较为粗糙,接近下层的分层已经省略,若是具体开发还需要具体修改一番分层架构。
但是,我认为目前的设计思路已经足以让人大致地理解了分层架构。
五、应用层、服务层与中间件
- 在参考不同的分层方案时,我发现分歧往往集中在应用层、服务层和中间件这三者之间的关系上。这里补充一些我的理解。
(一)服务层 vs 中间件
- 从本质上看,这两类都是某种能力或特性的集合,并向上提供给应用层调用。它们最核心的区别在于可移植性:
- 中间件:通常是成熟的第三方代码库(如 FreeRTOS、LVGL、FATFS),独立于具体项目,可以在不同芯片和平台之间移植。
- 服务层:为当前项目量身定制的功能封装(如传感器数据更新服务),与项目强绑定,一般不跨项目复用。
- 当然,也有少数方案会把服务层直接归入中间件的范畴,但本文采用上述区分方式。
(二)服务层的细分
- 对于服务层本身,不同方案的粒度也不一样:
- 本文采用广义定义:服务层即“提供某种功能的组件”。
- 更专业的做法:将服务层进一步细分为服务和模块/组件:
- 服务:拥有独立任务和消息队列,能够主动处理请求,可以有自己的运行状态和优先级。
- 模块/组件:纯被动的函数集合,只能被调用,不具备独立的任务上下文。
(三)服务层并入应用层
- 在一些简化的分层方案中,服务层会被直接并入应用层,作为主应用的“子应用”或“任务模块”存在。这种方式结构更扁平,适合中小规模项目。
有关分层的摸索便到此为止,我们接下来看看基本上很少出现在分层架构图、但实际由经常用到的公共层。
五、关于公共层
- 我发现,在介绍分层时,很少有人会介绍公共层(common),但我个人觉得公共层还是比较有用的,所以在这里介绍一下。
- 所谓公共层,其实并不复杂,从字面意思上便可以看出,就是一个基本上所有层级或者大部分层级都可以调用的特殊层级
- 对于一般的嵌入式项目而言,公共层都是既具有轻量性,同时又高度复用,其组成部分基本可以划分为以下几类:
- 数据类型定义、封装
- 通用宏,如位操作宏
- 常用的数据结构实现,如队列、环形缓冲区
- 工具函数,如字符串处理、进制转换、校验算法
- 内存管理辅助,如简单的内存池分配
- 全局配置头文件,如整个项目的开关宏
- 调试与日志接口
- 灵活使用公共层,即有利于提升代码一致性,又可避免出现重复造轮子的情况,还可以简化我们对代码的维护与调试过程,可以在项目开发的时候多尝试尝试。

浙公网安备 33010602011771号