2. MCU与外设基础
好,咱们正式开始啃 HAL 的核心概念。
很多工程师写 MCU 驱动,上来就对着寄存器一顿操作。功能跑通了,代码也跑起来了。但等到换芯片、换平台的时候——嗯,那叫一个酸爽。我早年就吃过这个亏,一个项目从 STM32F1 迁移到 GD32,光重写驱动就花了两周,还踩了一堆坑。
后来我才真正理解:硬件抽象层的本质,不是“封装寄存器”,而是“隔离变化”。
2.1 分层架构:把系统切成三块
先看一张图,这是我个人习惯用的 HAL 分层模型:
说白了,就是把系统切成三块:
- 应用层:只关心业务逻辑。比如“每 100ms 发一次温度数据”。它不知道 UART 是哪个型号的,也不关心。
- HAL 层:定义统一的接口。比如
hal_uart_send(),应用层只管调用它。 - MCU 抽象层:真正操作寄存器的地方。换芯片时,只改这一层。
关键点:每一层只依赖相邻下层。应用层不跨层调用 MCU 抽象层。这是分层架构的底线。
2.2 接口与实现分离:写代码就像做乐高
接口与实现分离,听起来高大上。其实就一句话:头文件里只放声明,不放实现。
我见过不少项目,头文件里直接写函数体。当时觉得方便,后来维护起来简直噩梦。你想想看,如果 uart.h 里直接写了寄存器操作,换芯片时你得改多少地方?
正确的做法是这样:
// 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 void* hal_uart_handle_t;
hal_uart_handle_t hal_uart_init(hal_uart_config_t *config);
int32_t hal_uart_send(hal_uart_handle_t handle, uint8_t *data, uint32_t len);
int32_t hal_uart_recv(hal_uart_handle_t handle, uint8_t *data, uint32_t len);
void hal_uart_deinit(hal_uart_handle_t handle);
#endif /* HAL_UART_H */
// hal_uart_stm32f4.c —— 针对 STM32F4 的实现
#include "hal_uart.h"
#include "stm32f4xx_hal.h"
typedef struct {
UART_HandleTypeDef huart;
uint8_t initialized;
} hal_uart_stm32_t;
hal_uart_handle_t hal_uart_init(hal_uart_config_t *config) {
hal_uart_stm32_t *dev = malloc(sizeof(hal_uart_stm32_t));
if (!dev) return NULL;
dev->huart.Instance = USART1;
dev->huart.Init.BaudRate = config->baudrate;
// ... 其他配置
if (HAL_UART_Init(&dev->huart) != HAL_OK) {
free(dev);
return NULL;
}
dev->initialized = 1;
return (hal_uart_handle_t)dev;
}
int32_t hal_uart_send(hal_uart_handle_t handle, uint8_t *data, uint32_t len) {
hal_uart_stm32_t *dev = (hal_uart_stm32_t *)handle;
if (!dev || !dev->initialized) return -1;
return HAL_UART_Transmit(&dev->huart, data, len, 1000);
}
// ... 其他函数实现
我的习惯:接口头文件放在 hal/inc/,实现文件放在 hal/src/stm32f4/ 或 hal/src/gd32/。换芯片时,只需要换 hal/src/ 下的目录,应用层代码一行都不用改。
2.3 依赖倒置原则:让高层定义规则,底层去遵守
依赖倒置原则(DIP)是 SOLID 原则里的“D”。它的核心思想是:高层模块不应该依赖低层模块,两者都应该依赖抽象。
翻译成人话:应用层不应该知道“我用的是 STM32 还是 GD32”。它只应该知道“我有一个 UART,能发数据就行”。
我曾经在一个项目中,应用层代码里直接调用了 HAL_UART_Transmit()。当时觉得省事,结果客户要求换芯片——嗯,应用层 80% 的代码都得重写。从那以后,我再也不敢让应用层直接依赖底层库了。
正确的做法是:
| 错误做法(违反 DIP) | 正确做法(遵循 DIP) |
|---|---|
应用层直接调用 HAL_UART_Transmit() |
应用层调用 hal_uart_send() |
应用层包含 stm32f4xx_hal.h |
应用层只包含 hal_uart.h |
| 换芯片时应用层代码大面积修改 | 换芯片时只替换 HAL 实现文件 |
| 代码耦合度高,难以测试 | 可以轻松编写 Mock 进行单元测试 |
避坑指南:我曾经见过有人把 HAL 接口定义成 stm32_uart_init() 这种名字。这其实违背了“接口与实现分离”的原则。接口名字里不应该带芯片型号。正确的命名是 hal_uart_init(),这样换芯片时接口名不变,应用层代码完全不受影响。
2.4 三者如何协同工作
分层架构、接口与实现分离、依赖倒置原则,这三者不是孤立的。它们是一个整体:
- 分层架构 决定了代码的组织方式——把系统切成三层。
- 接口与实现分离 决定了每一层的边界——头文件里只放声明。
- 依赖倒置原则 决定了依赖的方向——高层依赖抽象,不依赖具体实现。
我习惯用一个简单的流程图来描述它们的关系:
嗯,这就是 HAL 的核心骨架。你把这个骨架搭好了,后面的驱动移植、单元测试、多平台支持,都会变得非常轻松。我这些年做过的项目,凡是前期把这三件事做扎实的,后期几乎没有因为换芯片而返工过。