【分层架构】关于嵌入式分层的探讨

本文从实际开发痛点出发,结合具体代码案例,介绍嵌入式项目为何需要分层架构,并详细解析各层职责、划分规则以及公共层的灵活运用


一、前言

  • 刚接触嵌入式那会儿,我动手做项目的方式很直接:想到什么逻辑就直接写在代码里,也就是俗称的“硬编码”。做点简单的小玩意儿倒还好,没觉得有什么问题。
  • 可一旦项目变复杂了,这种写法的毛病就全出来了。做项目很少有一次就能把代码写到完美无瑕的,后期的修改和迭代是常态。如果代码逻辑全硬揉在一起,改一个地方,常常得翻遍整个工程去找相关联的另外几处,费时费力不说,万一漏改了哪里,新 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
  • 但我相信,此时你应该已经初步窥见分层架构的好处

三、分层架构的优劣

(一)优点

  1. 解耦
    • 不同层次的代码各司其职,互不干扰。
    • 例如:驱动层只负责操作硬件,应用层只关注业务逻辑。驱动出了问题不会让应用层跟着“生病”,修改驱动也不用担心破坏上层逻辑。
  2. 标准化
    • 每一层都按统一的接口规范编写,既便于替换和升级,也有助于养成良好的编程习惯。
    • 例如:应用层调用底层时,不管底层用的是 A 型号传感器还是 B 型号,只要新硬件的接口与原来保持一致,上层代码一行都不用改,直接换掉底层模块即可。
  3. 可移植性
    • 更换芯片或开发板时,只需重写最底层的驱动,上层的业务代码、算法、协议处理都无需改动
    • 就像给电脑换个键盘,操作系统和软件照样正常使用。
  4. 可测试性
    • 可以单独测试每一层。
    • 例如:想验证应用逻辑是否正确,无需接上真实硬件,只需写一个“假驱动”模拟硬件返回数据即可。
    • 这样在电脑上就能完成大部分测试,发现 Bug 更快、更安全。
  5. 可维护性
    • 代码结构清晰,哪块功能出问题就去哪一层查找。
    • 不像把所有逻辑揉在一起的项目,改一处可能要在整个工程里翻找,还容易引出新的 Bug。
  6. 可理解性
    • 在设计项目的业务逻辑和任务架构时,能更清楚地梳理各层之间的依赖关系,分清主次业务,降低开发难度。
    • 代码分层明确,自己回头看时,能快速理解整体设计思路。

(二)缺点

  • 性能开销
    • 下层向上层提供的接口都会进行封装,依次类推,层层递进,最后的应用层执行时需要层层跳跃才能得到真正的执行,这会带来额外的执行时间和栈空间开销
  • 资源消耗
    • 分层架构通常都会在内部定义一些私有变量、封装各种 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℃内
    • 支持显示当前温度和目标温度
    • 通过按键来设置温度、开启和关闭

设计思路 (结合实际,从上到下):

  1. 分层考虑
    1. 项目较为简单,不需要详细分层,粗分层即可,去除服务层,并允许跨层调用
  2. 判断主要的业务
    1. 需要能根据当前温度的大小来判断是制冷还是制热
    2. 既有数据显示又有按键设置,说明需要人机交互
    3. 有目标温度、开启/关闭状态,说明需要进行数据的管理
  3. 基于主要业务设计应用层
    1. UI 应用 App_GUI :订阅数据、显示当前温度、目前温度、系统状态,通知管理者如何修改配置、状态
    2. 管理者应用 App_Manager:读取数据,发布数据,修改目标温度,改变系统状态
    3. 温度控制应用 App_TempCtrl :订阅数据,判断系统状态,若处于系统工作态,则比较当前温度与目标温度,自主判断制冷或者制热
  4. 基于应用层,设计硬件驱动层(不涉及硬件连接,单纯是对硬件的操作逻辑):
    1. drv_temp:读取热敏电阻的数据
    2. drv_heat :操作制热单元
    3. drv_cold :操作制冷单元
    4. drv_oled :oled 驱动的封装
    5. drv_key :识别按键的短按、长按、双击等功能
  5. 基于硬件驱动层,设计 BSP 层,同时决定硬件连接:
    1. bsp_spi :封装对芯片硬件 SPI 的硬件配置、初始化及其相应操作,供 OLED 驱动使用
    2. bsp_gpio :封装对芯片 GPIO 的硬件配置、初始化以及读取功能
    3. bsp_adc :封装对芯片 ADC 的硬件配置、初始化以及读取功能
  6. 其它层略
  7. 最终的分层架构如下所示:

虽然,以上案例的设计较为粗糙,接近下层的分层已经省略,若是具体开发还需要具体修改一番分层架构。
但是,我认为目前的设计思路已经足以让人大致地理解了分层架构。


五、应用层、服务层与中间件

  • 在参考不同的分层方案时,我发现分歧往往集中在应用层、服务层和中间件这三者之间的关系上。这里补充一些我的理解。

(一)服务层 vs 中间件

  • 从本质上看,这两类都是某种能力或特性的集合,并向上提供给应用层调用。它们最核心的区别在于可移植性
    • 中间件:通常是成熟的第三方代码库(如 FreeRTOS、LVGL、FATFS),独立于具体项目,可以在不同芯片和平台之间移植。
    • 服务层:为当前项目量身定制的功能封装(如传感器数据更新服务),与项目强绑定,一般不跨项目复用。
  • 当然,也有少数方案会把服务层直接归入中间件的范畴,但本文采用上述区分方式。

(二)服务层的细分

  • 对于服务层本身,不同方案的粒度也不一样:
    • 本文采用广义定义:服务层即“提供某种功能的组件”。
    • 更专业的做法:将服务层进一步细分为服务模块/组件
      • 服务:拥有独立任务和消息队列,能够主动处理请求,可以有自己的运行状态和优先级。
      • 模块/组件:纯被动的函数集合,只能被调用,不具备独立的任务上下文。

(三)服务层并入应用层

  • 在一些简化的分层方案中,服务层会被直接并入应用层,作为主应用的“子应用”或“任务模块”存在。这种方式结构更扁平,适合中小规模项目。

有关分层的摸索便到此为止,我们接下来看看基本上很少出现在分层架构图、但实际由经常用到的公共层。


五、关于公共层

  • 我发现,在介绍分层时,很少有人会介绍公共层(common),但我个人觉得公共层还是比较有用的,所以在这里介绍一下。
  • 所谓公共层,其实并不复杂,从字面意思上便可以看出,就是一个基本上所有层级或者大部分层级都可以调用的特殊层级
  • 对于一般的嵌入式项目而言,公共层都是既具有轻量性,同时又高度复用,其组成部分基本可以划分为以下几类:
    • 数据类型定义、封装
    • 通用宏,如位操作宏
    • 常用的数据结构实现,如队列、环形缓冲区
    • 工具函数,如字符串处理、进制转换、校验算法
    • 内存管理辅助,如简单的内存池分配
    • 全局配置头文件,如整个项目的开关宏
    • 调试与日志接口
  • 灵活使用公共层,即有利于提升代码一致性,又可避免出现重复造轮子的情况,还可以简化我们对代码的维护与调试过程,可以在项目开发的时候多尝试尝试。

posted @ 2026-04-21 18:46  临祁  阅读(45)  评论(0)    收藏  举报