3、HAL接口定义实战:GPIO驱动接口标准化、定时器接口抽象、串口接口抽象

好,咱们直接进入正题。上一章讲了HAL层的设计原则,说白了就是「接口要稳,实现要活」。这一章,我拿三个最常用的外设——GPIO、定时器、串口——来手把手带你走一遍接口定义的全过程。

这三个外设,几乎每个嵌入式项目都会用到。但不同芯片的寄存器操作天差地别。我见过太多项目,代码里直接写寄存器地址,换芯片就得重写一半。嗯,咱们今天的目标就是:定义一套接口,让上层代码完全不知道底层是什么芯片

3.1 GPIO驱动接口标准化

GPIO看似简单,但坑不少。你想想看,不同芯片的GPIO模式定义不一样:有的叫推挽输出,有的叫开漏输出,还有的上拉下拉配置藏在另一个寄存器里。如果接口不统一,上层代码写起来会非常痛苦。

我个人习惯,先定义一组枚举,把GPIO的通用属性抽象出来:

/* gpio_hal.h */
typedef enum {
    GPIO_MODE_INPUT,          /* 输入模式 */
    GPIO_MODE_OUTPUT_PP,      /* 推挽输出 */
    GPIO_MODE_OUTPUT_OD,      /* 开漏输出 */
    GPIO_MODE_AF_PP,          /* 复用推挽 */
    GPIO_MODE_AF_OD,          /* 复用开漏 */
    GPIO_MODE_ANALOG          /* 模拟模式 */
} gpio_mode_t;

typedef enum {
    GPIO_PULL_NONE,           /* 无上下拉 */
    GPIO_PULL_UP,             /* 上拉 */
    GPIO_PULL_DOWN            /* 下拉 */
} gpio_pull_t;

typedef enum {
    GPIO_SPEED_LOW,           /* 低速 */
    GPIO_SPEED_MEDIUM,        /* 中速 */
    GPIO_SPEED_HIGH,          /* 高速 */
    GPIO_SPEED_VERY_HIGH      /* 极速 */
} gpio_speed_t;

有了这些枚举,接口函数就清晰了。我定义三个核心操作:初始化、写电平、读电平。

/* 初始化GPIO引脚 */
void gpio_init(uint8_t port, uint8_t pin, 
               gpio_mode_t mode, gpio_pull_t pull, gpio_speed_t speed);

/* 设置引脚输出电平 */
void gpio_write_pin(uint8_t port, uint8_t pin, uint8_t level);

/* 读取引脚输入电平 */
uint8_t gpio_read_pin(uint8_t port, uint8_t pin);

关键点:port和pin用uint8_t,而不是直接传寄存器地址。这样底层实现可以自由映射到任意芯片的端口号。

我在项目中遇到过一个问题:某款芯片的GPIO输出速度配置会影响EMC性能。当时我们统一用了高速模式,结果产品做CE认证时辐射超标。后来改成根据实际需求选择速度,才解决问题。所以接口里保留speed参数,是有实际意义的。

小技巧:如果芯片支持位带操作(bit-banding),gpio_write_pin和gpio_read_pin可以用宏实现,效率极高。但接口声明不要暴露这个细节。

3.2 定时器接口抽象

定时器比GPIO复杂得多。不同芯片的定时器资源差异很大:有的有16位,有的有32位;有的支持PWM,有的只支持基本计时。我建议把定时器接口分成三层:基础定时、PWM输出、输入捕获。今天咱们先搞定基础定时。

先定义定时器的配置结构体:

/* timer_hal.h */
typedef struct {
    uint32_t period_us;       /* 定时周期,单位微秒 */
    uint8_t auto_reload;      /* 是否自动重载 */
    void (*callback)(void);   /* 定时中断回调函数 */
} timer_config_t;

/* 初始化定时器 */
int timer_init(uint8_t timer_id, timer_config_t *config);

/* 启动定时器 */
void timer_start(uint8_t timer_id);

/* 停止定时器 */
void timer_stop(uint8_t timer_id);

/* 更新定时周期(运行时动态调整) */
int timer_set_period(uint8_t timer_id, uint32_t period_us);

这里我故意把timer_id设计成uint8_t,而不是直接传定时器基地址。为什么?因为有些芯片有十几个定时器,用ID索引比用指针更安全。底层实现里,用一个数组把ID映射到寄存器地址就行。

我曾经在一个项目中,需要同时使用4个定时器做不同频率的PWM输出。当时底层芯片的定时器时钟源不同,导致实际频率和计算值有偏差。后来我在初始化函数里加了一个校准步骤,用系统时钟自动修正分频系数。这个经验告诉我:接口要预留扩展性,比如timer_set_period这个函数,就是后来补上去的。

注意:回调函数在中断上下文中执行,里面不要做耗时操作。我见过有人直接在回调里调用printf,结果系统直接卡死。回调里应该只设置标志位,或者用消息队列通知任务。

3.3 串口接口抽象

串口通信,说白了就是收发数据。但不同芯片的串口寄存器差异很大:有的有FIFO,有的没有;有的支持DMA,有的只支持中断。接口设计的目标是:让上层代码只关心数据,不关心怎么收发

我定义的串口接口如下:

/* uart_hal.h */
typedef struct {
    uint32_t baudrate;        /* 波特率 */
    uint8_t data_bits;        /* 数据位:5/6/7/8 */
    uint8_t stop_bits;        /* 停止位:1/2 */
    uint8_t parity;           /* 校验位:0=无,1=奇校验,2=偶校验 */
    void (*rx_callback)(uint8_t data);  /* 接收回调 */
} uart_config_t;

/* 初始化串口 */
int uart_init(uint8_t uart_id, uart_config_t *config);

/* 发送一个字节(阻塞) */
void uart_send_byte(uint8_t uart_id, uint8_t data);

/* 发送缓冲区(非阻塞,通过中断或DMA发送) */
int uart_send_buffer(uint8_t uart_id, uint8_t *data, uint16_t len);

/* 接收一个字节(阻塞,超时返回-1) */
int uart_receive_byte(uint8_t uart_id, uint32_t timeout_ms);

你可能会问:为什么接收回调只传一个字节?因为不同芯片的FIFO深度不一样,有的只有1字节,有的16字节。如果回调里传缓冲区指针,底层实现会很别扭。我建议在回调里自己做数据缓存,上层再统一处理。

我记得有一次调试一个串口通信问题,波特率设置正确,但数据总是乱码。查了半天,发现是芯片的时钟源配置不对,导致实际波特率偏移了3%。从那以后,我在uart_init里加了一个波特率误差检查,如果误差超过2%就返回错误码。这个检查放在接口层,上层代码不用关心。

核心思想:接口定义的是「做什么」,不是「怎么做」。比如uart_send_buffer,底层可以用中断,也可以用DMA,甚至可以用轮询。上层调用者不需要知道。

3.4 接口设计避坑指南

最后,我总结几个实际项目中踩过的坑:

  • 参数类型要统一:所有外设ID都用uint8_t,不要有的用int,有的用枚举。否则上层代码写起来很分裂。
  • 返回值要规范:我习惯用0表示成功,负数表示错误码。正数留给特殊状态。这样上层可以用if (func() != 0)统一处理错误。
  • 不要暴露寄存器细节:接口里不要出现REG、BIT这样的字眼。一旦出现,说明抽象没做好。
  • 预留错误处理:比如uart_init返回-1表示波特率不支持,-2表示参数错误。上层可以根据错误码做不同处理。

我曾经在一个项目里,因为接口定义得太死板,导致换芯片时不得不修改上层代码。那次教训让我明白:接口设计不是一蹴而就的,需要在实际项目中不断迭代。但核心原则不变——让上层代码对硬件「无知」。

好,这一章就到这里。下一章咱们聊聊中断管理,那又是一个容易踩坑的地方。