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 上,只换了底层操作函数表,上层代码一行没改。

核心思想:函数指针表 = 驱动层的「虚函数表」。C 语言没有面向对象,但我们可以用结构体+函数指针模拟出多态的效果。

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)。驱动内部通过函数指针调用具体的底层实现,发生错误时通过回调通知上层。

我的建议:回调函数指针最好在初始化时由上层传入,驱动内部不要自己赋值。这样上层可以灵活决定要不要处理这个事件。如果不需要,传 NULL 就行,驱动里调用前判断一下。

2.5 知识结构总览

下面这张图把本章的核心逻辑串起来了。你可以看到,结构体负责「打包数据」,函数指针负责「解耦行为」,回调机制负责「反向通知」。三者配合,就能写出高内聚、低耦合的驱动层。

驱动层接口抽象三要素 结构体封装 函数指针 回调机制 打包硬件配置 统一参数传递 降低接口复杂度 解耦行为实现 模拟多态效果 支持多种底层 异步事件通知 控制权反转 减少轮询开销 高内聚 · 低耦合 · 可移植

2.6 避坑指南

  • 结构体不要太大:我见过有人把整个外设寄存器映射塞进一个结构体,结果栈空间爆了。结构体尽量只放配置和状态,大数据用指针引用。
  • 函数指针类型要统一:返回值、参数列表必须完全一致,否则编译器不会报错,但运行时必崩。我建议用 typedef 给函数指针起别名,方便统一管理。
  • 回调注册要线程安全:如果在中断里注册回调,注意加临界区保护。否则主循环正在调用回调,中断突然改了指针,后果你懂的。
  • 默认回调不能为空:如果上层没注册回调,驱动内部最好给一个空函数(什么都不做)作为默认值。这样驱动里不用每次都判断 NULL,代码更干净。
本章小结:结构体封装解决「参数太多」的问题,函数指针解决「实现绑定」的问题,回调机制解决「事件通知」的问题。这三个工具组合起来,就是驱动层抽象的基石。后面几章我们会基于这个基础,一步步搭建完整的 HAL 库框架。