2. HAL核心概念

做嵌入式系统设计,尤其是HAL层,最怕什么?

我最怕的是:换了一个MCU,整个应用层代码要重写。或者底层驱动改了个接口,上层业务逻辑跟着崩一片。

说白了,这就是架构没做好。今天咱们就聊聊HAL分层架构的三个核心原则。这些原则不是理论空谈,是我在多个项目里踩坑踩出来的经验。

2.1 高内聚与低耦合:HAL层的“骨架”

先问个问题:你写的驱动代码,是不是经常一个函数干好几件事?比如一个GPIO_Init(),既配置引脚,又初始化时钟,还顺带设了个中断?

嗯,这就是内聚度低的表现。

高内聚:一个模块只做一件事,并且把它做好。

低耦合:模块之间依赖少,改一个不影响另一个。

我在一个车载项目中遇到过这样的问题:底层I2C驱动里混了LCD屏的初始化代码。后来换屏,改驱动改了三天,还引入了新bug。这就是典型的耦合过高。

HAL层怎么做到高内聚低耦合?我个人的习惯是:

  • 每个HAL模块只封装一类硬件资源:GPIO、UART、I2C、SPI各归各的。
  • 模块间通过接口通信,不直接访问对方内部数据。
  • 应用层只调用HAL接口,不碰寄存器。

来看一个对比表格,你就明白了:

设计方式 内聚度 耦合度 换MCU代价
寄存器直接操作 低 极高 全部重写
函数封装但混用 中 高 大部分重写
HAL分层+接口隔离 高 低 只改HAL层

2.2 接口与实现分离:HAL层的“灵魂”

这个原则,说白了就是:你只管告诉我“能干什么”,别管我“怎么干”。

举个例子。你写一个UART发送函数,应用层只关心HAL_UART_Transmit(data, len)。至于底层是用轮询、中断还是DMA,那是HAL层的事。

我曾经在一个项目里,把接口定义和实现写在了同一个文件里。结果后来要支持两种不同的UART硬件,代码变得一团糟。从那以后,我强制自己:

  • 接口头文件:只放函数声明、数据结构定义、宏定义。不包含任何实现细节。
  • 实现源文件:放具体硬件操作代码。可以按MCU型号分多个版本。

看一个典型的接口定义:

// hal_uart.h —— 接口头文件
#ifndef HAL_UART_H
#define HAL_UART_H

#include <stdint.h>

typedef struct {
    uint32_t baudrate;
    uint8_t  data_bits;
    uint8_t  stop_bits;
    uint8_t  parity;
} HAL_UART_Config_t;

typedef enum {
    HAL_UART_OK       = 0,
    HAL_UART_ERROR    = 1,
    HAL_UART_TIMEOUT  = 2
} HAL_UART_Status_t;

// 接口声明 —— 不涉及任何寄存器
HAL_UART_Status_t HAL_UART_Init(uint8_t uart_id, HAL_UART_Config_t *config);
HAL_UART_Status_t HAL_UART_Transmit(uint8_t uart_id, uint8_t *data, uint16_t len);
HAL_UART_Status_t HAL_UART_Receive(uint8_t uart_id, uint8_t *data, uint16_t len);

#endif

你看,这个头文件里没有任何#include "stm32f4xx.h"或者寄存器地址。应用层开发者看到这个头文件,就知道怎么用UART,完全不需要关心底层是什么芯片。

我的一个小技巧:接口头文件里尽量用抽象数据类型,比如用uint8_t uart_id而不是USART_TypeDef *huart。这样换MCU时,应用层代码一行都不用改。

2.3 依赖倒置原则:HAL层的“护城河”

依赖倒置原则,原文是:高层模块不应该依赖低层模块,两者都应该依赖抽象。

翻译成人话:你的应用层代码,不要直接调用MCU的寄存器操作函数。应用层和底层驱动,都应该依赖你定义的HAL接口。

我见过最典型的反面案例:应用层代码里直接写了USART1->DR = data;。嗯,换芯片的时候,这种代码改起来简直想哭。

正确的做法是这样的:

  1. 定义抽象接口:比如HAL_UART_Transmit()
  2. 应用层调用这个接口,不关心底层实现
  3. 底层驱动实现这个接口,可以有不同的版本
  4. 通过函数指针或结构体,在运行时或编译时绑定具体实现

来看一个依赖倒置的SVG结构图:

依赖倒置原则在HAL中的应用 应用层(高层模块) 业务逻辑 / 任务调度 HAL抽象接口(依赖倒置层) hal_uart.h / hal_gpio.h / hal_i2c.h 依赖 MCU-A 实现 STM32F4 / 寄存器操作 MCU-B 实现 GD32 / 库函数封装 实现 实现 依赖倒置的核心 应用层不直接依赖底层驱动,两者都依赖HAL抽象接口 换MCU时,只需替换底层实现,应用层和接口层不动

注意:依赖倒置不是让你把所有函数都变成虚函数或者函数指针。在嵌入式C语言里,通常通过编译时绑定(条件编译、不同源文件)来实现。只有在需要运行时多态的场景(比如同时支持多种硬件),才用函数指针表。

2.4 三个原则如何协同工作

这三个原则不是孤立的。它们一起构成了HAL层的设计哲学:

  • 高内聚低耦合:决定了模块怎么划分,模块间怎么通信。
  • 接口与实现分离:决定了接口怎么定义,实现怎么隐藏。
  • 依赖倒置:决定了依赖关系怎么走,谁依赖谁。

我习惯用一个简单的检查清单来验证设计是否合理:

检查项 合格标准
模块内聚 一个.c文件只封装一类硬件资源
模块耦合 模块间只通过头文件接口调用,不访问全局变量
接口独立 接口头文件不包含任何MCU相关头文件
依赖方向 应用层 -> 接口层 <- 驱动层(箭头指向抽象)
可移植性 换MCU时,应用层代码零修改

嗯,最后一条可能有点理想化。但至少,应用层的核心逻辑不应该因为换了MCU而重写。如果做到了这一点,你的HAL层设计就算合格了。

避坑指南:我曾经在一个项目里,为了“灵活”,把HAL接口设计得过于通用。结果每个接口都有十几个参数,调用起来极其痛苦。后来我学乖了:接口要抽象,但不能过度抽象。每个接口的参数控制在3-5个以内,够用就好。