3. HAL实战:GPIO驱动封装——从寄存器操作到HAL接口

GPIO,说白了就是芯片的“手脚”。你让它高它就高,让它低它就低,还能检测外部电平变化。但真正写好一个GPIO驱动,让它能在不同芯片间轻松移植,这里面门道不少。

我个人习惯,写驱动前先画个分层图。这样代码结构清晰,后期维护也省心。

核心思路: 底层寄存器操作 → 中间层HAL接口 → 上层应用调用。每一层只关心自己的事。
GPIO驱动分层架构 应用层(Application) LED闪烁、按键扫描、中断处理 HAL接口层(硬件抽象层) GPIO_Init / GPIO_WritePin / GPIO_ReadPin / GPIO_IRQHandler 寄存器操作层(HAL底层) MODER / OTYPER / ODR / IDR / PUPDR / AFR 每层只通过接口与上下层通信,更换芯片只需重写最底层寄存器操作 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接口要通用、中断回调要灵活。做到这三点,你的驱动就能在多个芯片间自由移植。