2. 单片机与外设基础回顾
驱动层封装的核心,说白了就是「抽象」。把硬件细节藏起来,给上层一个干净、统一的接口。我刚开始写单片机程序时,也是直接在 main 里操作寄存器,后来项目一复杂,代码根本没法维护。嗯,今天我们就聊聊怎么用 C 语言的三个基本功——结构体、函数指针、回调机制——把驱动层做得漂亮又实用。
2.1 结构体封装:把硬件资源打包
一个外设通常包含多个配置项。比如 UART,有波特率、数据位、停止位、校验位。如果每个配置都单独传参,函数接口会变得又臭又长。我的习惯是:把所有配置塞进一个结构体。
typedef struct {
uint32_t BaudRate;
uint8_t WordLength;
uint8_t StopBits;
uint8_t Parity;
uint8_t Mode;
} UART_Config_t;
这样,初始化函数只需要一个指针:
HAL_StatusTypeDef UART_Init(UART_Handle_t *huart, UART_Config_t *config);
你想想看,如果以后要增加硬件流控制,只需要在结构体里加一个字段,所有调用接口都不用改。这就是封装的好处。
2.2 函数指针:让驱动变得灵活
函数指针是什么?说白了,就是一个变量,存的是函数的地址。你可以通过它来调用函数。这在驱动层最大的用处是:解耦。
举个例子。你写了一个 I2C 驱动,底层可能用 GPIO 模拟,也可能用硬件 I2C 外设。两种方式的读写函数不一样。如果用函数指针,你可以这样设计:
typedef struct {
void (*Init)(void);
HAL_StatusTypeDef (*Write)(uint8_t dev_addr, uint8_t *data, uint16_t len);
HAL_StatusTypeDef (*Read)(uint8_t dev_addr, uint8_t *data, uint16_t len);
} I2C_Ops_t;
然后,你定义两个实例:
I2C_Ops_t I2C_Hardware = {
.Init = I2C_HW_Init,
.Write = I2C_HW_Write,
.Read = I2C_HW_Read
};
I2C_Ops_t I2C_Software = {
.Init = I2C_SW_Init,
.Write = I2C_SW_Write,
.Read = I2C_SW_Read
};
上层代码只需要操作 I2C_Ops_t 指针,底层是硬是软,它根本不用关心。我在一个项目中用过这个技巧,把同一个传感器驱动同时跑在 STM32 和 GD32 上,只换了底层操作函数表,上层代码一行没改。
2.3 回调机制:把控制权交出去
回调函数,就是「你调用我提供的函数,我在适当的时候调用你注册的函数」。在驱动层,回调最常见的场景是异步事件通知。
比如你写一个按键驱动。按键按下、释放、长按,这些事件什么时候发生,驱动不知道。驱动只知道检测电平变化。那怎么办?让上层注册回调:
typedef void (*KeyEventCallback)(uint8_t key_id, KeyEvent_t event);
typedef struct {
uint8_t key_id;
GPIO_Pin_t pin;
KeyEventCallback callback; // 上层注册的回调
} Key_Handle_t;
驱动在检测到按键状态变化时,直接调用 handle->callback(handle->key_id, event);。上层收到回调后,再决定是点亮 LED 还是发送数据。
2.4 三者结合:一个完整的驱动抽象示例
我们用一个 SPI Flash 驱动来演示结构体、函数指针、回调怎么配合。
// 1. 配置结构体
typedef struct {
SPI_Handle_t *spi;
GPIO_Pin_t cs_pin;
uint32_t clock_speed;
} SPIFlash_Config_t;
// 2. 操作函数表
typedef struct {
HAL_StatusTypeDef (*ReadID)(SPIFlash_Config_t *cfg, uint32_t *id);
HAL_StatusTypeDef (*EraseSector)(SPIFlash_Config_t *cfg, uint32_t addr);
HAL_StatusTypeDef (*WritePage)(SPIFlash_Config_t *cfg, uint32_t addr, uint8_t *data, uint16_t len);
HAL_StatusTypeDef (*ReadData)(SPIFlash_Config_t *cfg, uint32_t addr, uint8_t *data, uint16_t len);
} SPIFlash_Ops_t;
// 3. 驱动句柄(包含配置、操作表、回调)
typedef struct {
SPIFlash_Config_t config;
SPIFlash_Ops_t ops;
void (*OnError)(uint8_t error_code); // 错误回调
void (*OnComplete)(uint32_t addr); // 操作完成回调
} SPIFlash_Handle_t;
使用时,上层只需要填充 SPIFlash_Handle_t,然后调用 handle->ops.ReadID(&handle->config, &id)。驱动内部通过函数指针调用具体的底层实现,发生错误时通过回调通知上层。
2.5 知识结构总览
下面这张图把本章的核心逻辑串起来了。你可以看到,结构体负责「打包数据」,函数指针负责「解耦行为」,回调机制负责「反向通知」。三者配合,就能写出高内聚、低耦合的驱动层。
2.6 避坑指南
- 结构体不要太大:我见过有人把整个外设寄存器映射塞进一个结构体,结果栈空间爆了。结构体尽量只放配置和状态,大数据用指针引用。
- 函数指针类型要统一:返回值、参数列表必须完全一致,否则编译器不会报错,但运行时必崩。我建议用 typedef 给函数指针起别名,方便统一管理。
- 回调注册要线程安全:如果在中断里注册回调,注意加临界区保护。否则主循环正在调用回调,中断突然改了指针,后果你懂的。
- 默认回调不能为空:如果上层没注册回调,驱动内部最好给一个空函数(什么都不做)作为默认值。这样驱动里不用每次都判断 NULL,代码更干净。