3. HAL实战:GPIO驱动封装——从寄存器操作到HAL接口
GPIO,说白了就是芯片的“手脚”。你让它高它就高,让它低它就低,还能检测外部电平变化。但真正写好一个GPIO驱动,让它能在不同芯片间轻松移植,这里面门道不少。
我个人习惯,写驱动前先画个分层图。这样代码结构清晰,后期维护也省心。
核心思路: 底层寄存器操作 → 中间层HAL接口 → 上层应用调用。每一层只关心自己的事。
3.1 寄存器操作层:先搞定硬件
不管用STM32还是GD32,GPIO寄存器就那么几个。模式寄存器、输出寄存器、输入寄存器、上下拉寄存器。我刚开始写驱动时,总喜欢把所有寄存器操作混在一起写,结果换个芯片就得重写一遍。
后来我学乖了——先把寄存器操作封装成最底层的宏或内联函数。
/* 寄存器基址定义 */
#define GPIOA_BASE 0x40020000
#define GPIOB_BASE 0x40020400
/* 寄存器偏移 */
#define GPIO_MODER_OFF 0x00
#define GPIO_OTYPER_OFF 0x04
#define GPIO_OSPEEDR_OFF 0x08
#define GPIO_PUPDR_OFF 0x0C
#define GPIO_IDR_OFF 0x10
#define GPIO_ODR_OFF 0x14
#define GPIO_AFRL_OFF 0x20
#define GPIO_AFRH_OFF 0x24
/* 寄存器访问宏 */
#define REG32(addr) (*(volatile uint32_t *)(addr))
#define GPIO_MODER(port) REG32((port) + GPIO_MODER_OFF)
#define GPIO_ODR(port) REG32((port) + GPIO_ODR_OFF)
#define GPIO_IDR(port) REG32((port) + GPIO_IDR_OFF)
小技巧: 用宏定义寄存器地址,编译时直接替换,零开销。我习惯把基址和偏移分开定义,这样扩展新端口时只需加一行基址。
3.2 HAL接口设计:定义契约
底层寄存器操作写好了,接下来定义HAL接口。接口要通用,不能绑定某个芯片的特性。
我一般会定义一个GPIO配置结构体,把所有可配置项都放进去:
/* GPIO配置结构体 */
typedef struct {
uint32_t Pin; /* 引脚号,支持位掩码 */
uint32_t Mode; /* 输入/输出/复用/模拟 */
uint32_t Pull; /* 上拉/下拉/浮空 */
uint32_t Speed; /* 低速/中速/高速 */
uint32_t Alternate; /* 复用功能编号 */
} GPIO_InitTypeDef;
/* HAL接口函数声明 */
void GPIO_Init(GPIO_TypeDef *port, GPIO_InitTypeDef *config);
void GPIO_WritePin(GPIO_TypeDef *port, uint32_t pin, uint8_t state);
uint8_t GPIO_ReadPin(GPIO_TypeDef *port, uint32_t pin);
void GPIO_TogglePin(GPIO_TypeDef *port, uint32_t pin);
void GPIO_IRQHandler(uint16_t pin);
你看,接口里没有出现任何寄存器名字。上层应用只需要知道“我要初始化哪个引脚、什么模式”,至于底层怎么配寄存器,那是HAL的事。
3.3 接口实现:把寄存器操作填进去
接口定义好了,接下来是实现。以GPIO_Init为例,它要完成:时钟使能、模式配置、上下拉配置、速度配置、复用功能配置。
void GPIO_Init(GPIO_TypeDef *port, GPIO_InitTypeDef *config)
{
uint32_t pinpos;
uint32_t pos = 0;
/* 遍历每个引脚位 */
for (pinpos = 0; pinpos < 16; pinpos++) {
if ((config->Pin >> pinpos) & 0x01) {
pos = pinpos * 2;
/* 配置模式寄存器 */
GPIO_MODER(port) &= ~(0x03 << pos);
GPIO_MODER(port) |= (config->Mode << pos);
/* 配置上下拉寄存器 */
GPIO_PUPDR(port) &= ~(0x03 << pos);
GPIO_PUPDR(port) |= (config->Pull << pos);
/* 配置速度寄存器 */
GPIO_OSPEEDR(port) &= ~(0x03 << pos);
GPIO_OSPEEDR(port) |= (config->Speed << pos);
/* 配置复用功能 */
if (config->Mode == GPIO_MODE_AF) {
uint32_t afpos = (pinpos < 8) ? (pinpos * 4) : ((pinpos - 8) * 4);
if (pinpos < 8) {
GPIO_AFRL(port) &= ~(0x0F << afpos);
GPIO_AFRL(port) |= (config->Alternate << afpos);
} else {
GPIO_AFRH(port) &= ~(0x0F << afpos);
GPIO_AFRH(port) |= (config->Alternate << afpos);
}
}
}
}
}
注意: 配置寄存器时一定要先清零再赋值。我曾经在一个项目里忘了清零,结果引脚模式死活不对,查了两天才发现是残留位在作怪。
3.4 中断回调:让GPIO会“说话”
GPIO输入最常用的场景就是按键。但轮询方式太浪费CPU,中断才是正经做法。
中断处理的流程:
- 配置中断触发条件: 上升沿、下降沿、双边沿
- 使能中断线: 在NVIC中使能对应的中断通道
- 编写中断服务函数: 读取中断标志,执行回调
- 清除中断标志: 防止重复触发
我习惯在HAL层提供一个弱回调函数,用户可以在应用层重写它:
/* 弱定义回调函数,用户可重写 */
__weak void HAL_GPIO_EXTI_Callback(uint16_t pin)
{
/* 默认什么都不做 */
}
/* 中断服务函数 */
void EXTI0_IRQHandler(void)
{
/* 检查中断标志 */
if (EXTI->PR & (1 << 0)) {
/* 清除中断标志 */
EXTI->PR = (1 << 0);
/* 调用回调函数 */
HAL_GPIO_EXTI_Callback(GPIO_PIN_0);
}
}
避坑指南: 中断服务函数里不要做耗时操作。我曾经在回调里加了个延时,结果系统直接卡死。正确的做法是:回调里只设置标志位,具体处理放在主循环里。
3.5 可移植性设计:换芯片不换代码
写驱动最怕什么?怕换芯片。客户今天用STM32F103,明天说换成GD32F303,后天又换成AT32F403。如果驱动写死了,那改起来真要命。
我总结了一套可移植性设计原则:
| 原则 | 说明 | 示例 |
|---|---|---|
| 接口统一 | 所有芯片使用相同的HAL接口 | GPIO_Init() 参数不变 |
| 宏定义分离 | 芯片相关参数放在单独头文件 | gpio_stm32f1.h / gpio_gd32f3.h |
| 条件编译 | 用宏选择芯片型号 | #ifdef STM32F1 |
| 弱定义函数 | 允许用户覆盖默认实现 | __weak HAL_GPIO_EXTI_Callback |
举个例子,不同芯片的时钟使能方式不同:
/* stm32f1_gpio.h */
#define __HAL_RCC_GPIOA_CLK_ENABLE() RCC->APB2ENR |= (1 << 2)
/* gd32f3_gpio.h */
#define __HAL_RCC_GPIOA_CLK_ENABLE() RCU->APB2EN |= (1 << 2)
/* 在GPIO_Init中统一调用 */
void GPIO_Init(GPIO_TypeDef *port, GPIO_InitTypeDef *config)
{
__HAL_RCC_GPIOA_CLK_ENABLE(); /* 不同芯片展开不同宏 */
/* ... 后续配置相同 ... */
}
我的习惯: 每个芯片系列建一个文件夹,里面放对应的寄存器定义和时钟宏。上层HAL代码完全不用动,换芯片时只需要换文件夹和重新编译。
3.6 实战案例:按键控制LED
理论说完了,来个完整的例子。用GPIO中断检测按键,控制LED亮灭。
/* main.c */
int main(void)
{
GPIO_InitTypeDef gpio_config;
/* 配置LED引脚 - PA5 推挽输出 */
gpio_config.Pin = GPIO_PIN_5;
gpio_config.Mode = GPIO_MODE_OUTPUT_PP;
gpio_config.Pull = GPIO_NOPULL;
gpio_config.Speed = GPIO_SPEED_LOW;
GPIO_Init(GPIOA, &gpio_config);
/* 配置按键引脚 - PA0 上拉输入 */
gpio_config.Pin = GPIO_PIN_0;
gpio_config.Mode = GPIO_MODE_IT_FALLING; /* 下降沿触发 */
gpio_config.Pull = GPIO_PULLUP;
GPIO_Init(GPIOA, &gpio_config);
/* 使能NVIC中断 */
HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
while (1) {
/* 主循环做其他事 */
}
}
/* 重写回调函数 */
void HAL_GPIO_EXTI_Callback(uint16_t pin)
{
if (pin == GPIO_PIN_0) {
GPIO_TogglePin(GPIOA, GPIO_PIN_5); /* 翻转LED */
}
}
你看,应用层代码完全不知道寄存器长什么样。它只知道调用GPIO_Init、GPIO_TogglePin,然后等着回调函数被触发。这就是HAL抽象的魅力。
总结一下: GPIO驱动封装的核心就三件事——底层寄存器操作要干净、HAL接口要通用、中断回调要灵活。做到这三点,你的驱动就能在多个芯片间自由移植。