嵌入式系统分层开发:架构模式与工程实践(一)

发布于:2025-07-30 ⋅ 阅读:(32) ⋅ 点赞:(0)



摘要

根据自己工作知识,分享如何一步步搭建框架,实现模块化编程,应对不同情况下的项目开发。个人认为一个软件架构应该分为以下五层:

BSP层(Board Support Package,板级支持包)—搭舞台的工人(配置硬件环境)
HDL层(Hardware Driver Layer,硬件驱动层)—操作设备的技工(读写寄存器)
FML层(Functional Model Layer,功能模型层)—设备管理员(统一操作标准)
BLL层(Bussines Logic Layer,业务逻辑层)—产线经理(决策何时启动哪条生产线)
FSM层(Finite State Machine,有限状态机)—智囊团(帮助产线经理决策)

本系列将逐一分析不同层的作用、如何使用以及实际项目中的用法。并在最后分享自己实现的源码(基于买的开发版自己构想的一些功能)。

一、BSP层(Board Support Package,板级支持包)—搭舞台的工人

BSP(Board Support Package)层:之所以使用这个层,是为了避免更换芯片导致大面积的切换底层配置。相当于是做一个隔离,在改层进步初始化硬件端口以及中断函数的处理,这是因为本身这一层的函数都是在主循环初始化的时候被调用,而里面实现的中断函数是自动调用的。并且如果是Uart这种通信口的中断函数,那么就会在调用BLL层的函数进行解析数据,

如果我更换了芯片,不管是不是PIN to PIN其实都是不影响的,因为业务层或者是逻辑层是看不到BSP层内容的,因此我们只需要更改BSP层的内容,并且我们只需要按照我们使用的一些接口更改,例如我们使用了UART接口、PWM接口、Timer定时器接口,我们只需要在对应的BSP_UART_Init()、BSP_PWM_Init()、BSP_TIMER1_Init()函数进行修改,甚至BSP_UART.c文件中包含的中断里面的一些数据传输函数都不需要修改。还是那句话底层配置是和具体怎么用他是没有关系的。之所以要将每一个片上外设分开也是为了清晰可见,当使用的片上外设多的时候便于统计和更改。

为什么要将中断也放在这里,这是因为不同的底层不同的芯片,中断实现也可能不一样。因此需要将每一个芯片板级包实现独立起来。可以这么说,凡是需要配置芯片的,一定是在这个BSP层实现,哪怕中断中需要实现一些功能,也不能改变中断的位置,越是越体现了这种写法的好处,我们可以通过回调函数使BSP层的内容调用上层函数,这样还了芯片也同样是回调函数过来,因此毫无影响。

个人认为:GPIO的配置之所以是将其独立于对应的C文件,是为了统一管理,因为不同的芯片引脚功能不一样,并且没有使用的引脚也需要进行配置,那么还是考虑更换芯片情况,更换以后对于不同引脚的功能很大可能就会改变,那么我这个时候如果没有集中管理,是需要逐个文件去看的,这样很容易遗漏,而统一在GPIO.c文件管理以后最少不会遗漏,然后再根据每个引脚的配置项封装为一个结构体,这样就实现了面向对象管理每一个GPIO,最后在使用一个数组将所有的GPIO统一放在一起,避免遗漏GPIO没有配置。

  • 步骤一:定义GPIO配置结构体

    通过结构体数组集中管理每个GPIO的端口、引脚、模式、输出类型、上下拉、复用功能等参数。

    支持混合配置:例如同时存在推挽输出(GPIO_OType_PP)、开漏输出(GPIO_OType_OD)、浮空输入等不同模式。

// 1. 定义GPIO配置结构体
typedef struct {
    GPIO_TypeDef *port;      // GPIO端口(如GPIOA)
    uint16_t pin;            // 引脚编号(如GPIO_Pin_13)
    uint32_t mode;           // 工作模式(如GPIO_Mode_OUT)
    uint32_t otype;          // 输出类型(如GPIO_OType_PP)
    uint32_t pupd;           // 上下拉(如GPIO_PuPd_NOPULL)
    uint8_t af_config;       // 复用功能配置(0表示不启用)
} GPIO_Config;
  • 步骤二:声明配置数组
// 2. 声明配置数组
GPIO_Config gpio_configs[] = {
    // OUT->O-LED-S5 (PA13)
    {GPIOA, GPIO_Pin_13, GPIO_Mode_OUT, GPIO_OType_PP, GPIO_PuPd_NOPULL, PA13_GPIO},
    // OUT->O-LED-S6 (PA14)
    {GPIOA, GPIO_Pin_14, GPIO_Mode_OUT, GPIO_OType_PP, GPIO_PuPd_NOPULL, PA14_GPIO},
    // TX (PA15) - 复用推挽输出
    {GPIOA, GPIO_Pin_15, GPIO_Mode_AF_PP, GPIO_OType_PP, GPIO_PuPd_NOPULL, PA15_RXD0},
    // RX (PB0) - 复用开漏输入
    {GPIOB, GPIO_Pin_0, GPIO_Mode_AF_OD, GPIO_OType_OD, GPIO_PuPd_UP, PB0_TXD0}
};
  • 步骤三:循环初始化函数

    通过af_config字段判断是否启用复用,避免对非复用引脚执行无效操作。

    复用功能与基本模式解耦(如PA15配置为复用推挽输出,PB0配置为复用开漏)。

// 3. 循环初始化函数
void GPIO_BatchInit(void) {
    for (int i = 0; i < sizeof(gpio_configs)/sizeof(gpio_configs[0]); i++) {
        GPIO_InitTypeDef GPIO_InitStruct = {0};
        
        // 配置复用功能(若启用)
        if (gpio_configs[i].af_config != 0) {
            GPIO_PinAFConfig(gpio_configs[i].port, gpio_configs[i].pin, gpio_configs[i].af_config);
        }
        
        // 设置初始电平(仅输出模式)
        if (gpio_configs[i].mode & GPIO_Mode_OUT) {
            GPIO_WriteBit(gpio_configs[i].port, gpio_configs[i].pin, Bit_SET);
        }
        
        // 填充初始化结构体
        GPIO_InitStruct.GPIO_Pin = gpio_configs[i].pin;
        GPIO_InitStruct.GPIO_Mode = gpio_configs[i].mode;
        GPIO_InitStruct.GPIO_OType = gpio_configs[i].otype;
        GPIO_InitStruct.GPIO_PuPd = gpio_configs[i].pupd;
        GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; // 固定参数可统一或单独配置
        
        // 应用配置
        GPIO_Init(gpio_configs[i].port, &GPIO_InitStruct);
    }
}

主要解决:

1、当应用层代码直接访问硬件寄存器(如GPIO、UART等)而非通过驱动接口时,硬件初始化逻辑与应用层业务逻辑深度绑定。

2、应用层直接调用芯片原厂的库函数(如STM32的HAL_UART_Transmit()),而非通过统一的抽象接口。

3、应用层代码直接配置时钟树、中断控制器等硬件资源,而非由板级支持包(BSP)或硬件抽象层(HAL)封装。硬件配置变更(如时钟频率调整)需修改应用层,违反“上层不依赖下层”的分层原则。

还是有一些地方需要解释一下:

实例一、滴答定时器(中断)

以滴答定时器为例:

涉及到的知识点是时间中断,并且还不止一个地方需要该时基。


该函数位于BSP_Timer.c

/**************************************************

*函数名称:
*函数功能:滴答定时器中断,125us触发一次中断。
*入口参数:
*出口参数:
*运行时基:125us

**************************************************/

void SysTick_Handler(void)
{

    SysTick_Handler_soft();

}

个人理解这样写的目的:使用一个函数将我们会用到这个中断的地方给封装起来,这是因为考虑到芯片拓展性问题,一方面这个是中断,就应该按照我们之前分析的放到 BSP_Timer.c 文件里面,另一方面也是为了上层函数移至性强,因为这样写是得BSP根本看不到用这个中断的函数业务,它只知道用人用,但是不知道谁用,而BSP就是需要这种作用,他给你提供的地方就是告诉你你们谁用都行,只要合理。

实例二、数码管控制(GPIO)

数码管的操作设计到GPIO需要让GPIO输出高低电平控制MOS管的开关,进而控制LED亮灭,这个过程是输出。而也有输入的就是按键,但是一般按键是通过触摸芯片来获取简直从而实现按键的判断。

那也就是说在需要用到硬件的时候(中断),或者是需要输出的时候。我们都应该将这个状态给固话下来,例如我们需要知道GPIO输出高电平是什么样子,然后进行封装。像滴答定时器一样,外面谁都可以用,只要合理。而控制LED亮灭的端口也是一样,我们封装好一下,也是谁都可以用,只要用了这个地方,我就知道现在是输出高电平。本质来讲还是对GPIO的初始化,让他输出什么状态不也是属于初始化的一部分,所以在这里我们要明白,用于初始化的BSP层不能仅仅停留在配置,给出一些状态的封装也是初始化。

这个地方我觉得可以通过这样理解,只要我们使用了芯片的偏上外设,不管是输入还是输出都应该将这一部分配置或者声明留在 BSP 层,换句话说,只要是跟芯片强相关的内容(什么内容强相关,片上外设) ,如果换了芯片必须要改变的内容,都是需要BSP层的。

这种改变怎么判断呐,我觉得可以从Pin to Pin的角度去理解,==因为Pin to Pin 芯片的更换,相对来说改动最小,如果软件设计的好,也就是只需要改动BSP,其他任何都不用改动,基于这种参考在实际项目中我们才能知道什么内容放在BSP层什么内容不应该放在BSP层。==并且我们对于中断函数名字的理解,我们把其想为随时可以改变的,并且是根据芯片变动的,那么这样我们也就必须要中断的实现留在DSP层。

或者从另外一个角度,只要你写的函数内容中包含需要调用芯片原厂提供的库函数的内容,都需要将这个函数留在BSP层,也就是你写的这个内容是属于BSP层的。


BSP_GPIO.c

这是在 BSP_GPIO.h 文件中声明或者实现的GPIO高低电平函数。

#define LED1_ON      GPIO_WriteBit(GPIOA, GPIO_Pin_13, Bit_RESET);     
#define LED1_OFF     GPIO_WriteBit(GPIOA, GPIO_Pin_13, Bit_SET);

#define LED2_ON      GPIO_WriteBit(GPIOD, GPIO_Pin_12, Bit_RESET);      
#define LED2_OFF     GPIO_WriteBit(GPIOD, GPIO_Pin_12, Bit_SET);

#define LED3_ON      GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_RESET);      
#define LED3_OFF     GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_SET);

#define LED4_ON      GPIO_WriteBit(GPIOA, GPIO_Pin_2, Bit_RESET);       
#define LED4_OFF     GPIO_WriteBit(GPIOA, GPIO_Pin_2, Bit_SET);

主要应用场景

1、如在开发过程中更换了芯片,导致本身的HAL库不一样,例如目前工作中赛元、中微同样是51芯片但是一些如中断号写法不一样,再或者极端一些,原本是用ARM单片机现在更换成51单片机。这就使得不同芯片的寄存器地址、时钟配置、外设控制逻辑可能不同。

2、同一个芯片移植到不同开发板,外设电路设计变化(如LED/按键的GPIO引脚、传感器通信接口变更)。那么我们只需要改写BSP就可以直接复用。若原开发板的LED接在PA5,新板改为PB10,只需修改BSP层的宏定义。

3、再或者改变了操作系统。

4、或者新增硬件外设,那我们只需引入对应的.c文件就行。

  • 硬件无关性​:

    BSP通过封装片上外设操作​(如GPIO、UART)和板级硬件配置​(如LED引脚),确保应用层代码与硬件解耦。只要接口不变,上层逻辑无需修改。

  • 降低移植成本​:

    • 芯片/板级变更​:仅需调整BSP,避免重写业务逻辑。

    • 操作系统变更​:BSP为OS提供统一硬件抽象,减少OS移植工作量。

  • 协作与维护​:

    硬件工程师修改电路后,只需同步BSP的引脚定义,软件团队无需深入原理图细节。

实际项目中,BSP的边界定义越清晰,系统可维护性越强。

比如在实际项目中,借用开发,

例如某产品1是该系列产品的基板,然后拓展出来产品2,

并且产品2为了降本更换了芯片,但是功能是不改变的,这样我就只需要修改BSP层。

而在后续的开发过程中又开发了产品3,这次的修改是因为结构的改变,导致的了不得不改变一些硬件的接口,例如原本是在PinA2连接的是LED,PinB3连接的是加热管,然后这次需要将他们互换一下,那么我也是只需要改变BSP的GPIO配置就行,其他的需要改变,因为对于上层来说都是控制这些GPIO输出高低电平,我又不管控制的什么,我只需要在合适的时间给出合适的高低电平就行了。

二、HDL层(Hardware Driver Layer,硬件驱动层)—操作设备的技工(读写寄存器)

要想理解HDL层,一定要先弄明白BSP层的作用,并且我们在理解的时候不妨先强迫自己将BSP层和HDL层隔离开来,因为个人理解在很多时候我们会想当然的觉得这两个层是一样的,没有浪费精力必要分开,所以在接下来的分析中一定要先从隔离开的好处去理解HDL层的作用,并且在最后的时候我们在结合实际项目开发中遇到的问题去分析将BSP和HDL分开的好处。

先看一下两者的区别是什么:

粗暴一点理解就是:

BSP一定是初始化配置和一些声明,并且一定是调用芯片厂商HAL库的。

HDL更像是驱动这些片上外设的操作。并且还能保证设备协议通用性,针对这个问题我们先按下不表,后面在分析。

维度 HDL层(硬件驱动层)​ BSP层(板级支持包)​
主要目标 驱动外部扩展硬件​(如传感器、存储器、通信模块) 初始化并抽象板级硬件​(如MCU、时钟、外设引脚)
功能范围 - 实现非片外设备的通信协议(如I²C时序、SPI帧解析)
- 封装设备级操作(如EEPROM_Write()
- 芯片及板上资源的初始化(时钟、GPIO模式)
- 提供硬件无关的板级API(如BSP_LED_On()
依赖关系 调用BSP层或HAL层的接口操作硬件 直接操作芯片寄存器或调用厂商HAL库
典型代码 实现设备指令集(如温湿度传感器数据读取逻辑) 配置系统时钟、初始化中断控制器、定义LED引脚映射

首先我们需要理清楚一般的协作关系:

硬件上电 → BSP初始化MCU → HAL操作片内外设 → HDL驱动外部设备 → 应用层调用​。

HDL不仅是驱动外部设备,还是需要操作芯片内置资源

外部设备包含:显示芯片、IIC、触摸芯片、AT24C02(EEPROM)、TJA1041(CAN控制器),需自行实现通信协议(如I²C指令序列)。

芯片内部资源:如GPIO、ADC、UART寄存器。

先以片上外设为例,首先需要明确是BSP对片上外设是没有操作的,只是声明和配置了该芯片的片上外设我可能会怎么使用,例如我可能需要使用GPIO的输入、输出、以及UART串口等。但是我不知道怎么用或者说什么时候用,我只是给你提供,搭建一个硬件场景,像是一个搭舞台的工人。

那这个整个系统需要运行,那么就需要对这些寄存器有序操作,为了便于有序操作,我们就将这个行为全部的封装在一个层,也就是HDL层。

实例一、点亮LED

如果是没有使用显示芯片控制LED,那么我们点亮LED的思路是循环扫描,例如在时基为125us的中断里面循环扫描利用人眼的视觉暂留效应,使得我们看到的LED是一直亮的。那么为了方便的扫描,我们使用一个for循环更便捷,此外还需要实现我是应该点亮LED还是熄灭LED,以及还需要我们认为的将每一个LED赋予其功能,例如LED1表示风机、LED2表示加热、LED3表示制冷等等。但是怎么控制,我们还需要结合整个项目的功能去实现相关功能,我们可以先笼统的理解为是应用层该干的事情,那么问题来了应用层怎么知道我拿什么控制这些LED,所以重点来了,接口函数出现了,我们需要定义个接口函数,供应用层调用,而这个接口函数只需要像上层预留出来两个接口,分别是:哪个LED,该LED应该是什么状态。 此时你会想应用层怎么会知道哪个LED是哪个功能,以及应用层怎么将亮的状态传递进去。基于这两个问题我们进行如下分析

我们知道,在复杂的项目中是尽可能避免变量耦合的,那要是驱动层和应用层使用了同一个变量,那不就完了,相当于是没有解耦,而现在我们不就是面临了同样的问题。那我们要是将LED和状态共享不就是面临上述的问题了。

这是之前我一直不理解的地方,也是自己犯的一个错误,走的误区。我们首先要明白的是,我们不希望“驱动层和应用层使用了同一个变量”,注意:是变量!!!!,我们一直说的是变量,而关于LED的个数和LED的状态我们在定义的时候是用枚举实现的,为什么事枚举实现,主要原因是我们是为了遍历所有的LED,同时状态我们也用枚举实现(一会解释什么原因)。这个时候就变成了常量,只不过这些常量是具有一定意义的,所以上述问题不就迎刃而解了,我们的HDL层和应用层都是用LED这个枚举常量,不属于是变量耦合。

在这个地方我甚至有点疑惑到底是为了不耦合将LED设置成枚举还是因为刚好我们需要使用枚举进行循环遍历才使得避免了使用变量耦合。到底是庄周梦蝶亦是蝶梦庄周? 个人认为这种架构的设计是蕴含哲学思想的,正如上面看起来是巧合,其实正是互相联系的结果,从而又实现了解构。

    for(i=0;i<LED_MAX;i++){
        if(Bright_Count < g_Led[i].brightness){
            g_Led[i].usr_led_do(LED_ON);
        }else{
            g_Led[i].usr_led_do(LED_OFF);
        }
    }
typedef enum

{
    LED1    =0,        
    LED2,               
    LED4,               
    LED_MAX,
}LED_ENUM;  //按键ID


typedef struct

{
	 uint8_t  brightness;
	 void  (*usr_led_do)(LED_CONTL_ENUM);
}LED_T;

并且上述的一大串过程,在实现过程中需要很多代码,因此从代码量来说将这些内容封装起来形成一个 HDL_LED.c 文件也是不过分的。

以下是实现点亮LED的思路:

在这里插入图片描述

经过上述内容我们可以看出,HDL层的作用是利用DSP层的声明和配置,实现如何操作GPIO,就是怎么用这个GPIO。

实例二、触摸芯片

在使用触摸芯片也是如此,我们通过IIC等方式获取了某个键值,而我们要做的就是只需要将这个键值传递出去就行了,然后我只管传递,不需要在HDL赋予其意义,因为这个HDL触摸的作用就是获取,只是一个大型的中转站而已。


uint8_t  Read_Touch_Value(void)

{

        return Touch_Value;  //照明01   启停02

}

而我们只需要将这个地方传递按键检测的函数,而该按键检测属于是FML层,后续进行讲解。

也就是说没有更换芯片,只是更换了GPIO的顺序,或者GPIO的链接方式:我们需要做的是改变BSP层,因为设计到芯片引脚的改动我们不需要改变HDL层,也就是保证了控制方式和控制逻辑没有变。可以简单的划分为:控制方式指的是HDL,控制逻辑指的是应用层。这样就避免因为GPIO的引脚改变而改动控制LED的方式和逻辑,有效的降低工作量。而如果芯片改变了,我们还是需要使用这么多灯,那其实还是不需要变HDL层,因为我们还是只需要改BSP,只需要配置好GPIO的链接方式,最多也就是每个LED的意义可能会改变,带来一点工作量。

而还有一种情况是我们扳级外部设备如更换了触摸芯片,那么这样使用HDL层的好处是只需要预留出和触摸芯片交互的函数,同样不需要改变接受键值和扫描键值的函数,再或者使用显示芯片也是如此,亦或者传感器型号的改变,这样设计就是可以兼容不容传感器的无缝切换。

当然HDL层还有很多其他内容,由于篇幅限制,接下来会逐一分析HDL层中不同外设(片上或者扳级外设)的封装思路



专栏介绍

《嵌入式通信协议解析专栏》
《PID算法专栏》
《C语言指针专栏》
《单片机嵌入式软件相关知识》
《FreeRTOS源码理解专栏》
《嵌入式软件分层架构的设计原理与实践验证》



文章源码获取方式:
如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。

【版权声明】
本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件:
署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。
相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。

感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言。


网站公告

今日签到

点亮在社区的每一天
去签到