3. MCU外设驱动封装实战(GPIO)

好,咱们直接进入正题。这一章我带你手把手做一件事:把寄存器操作封装成HAL接口。说白了,就是让你写的代码能在不同型号的单片机之间轻松移植。

为什么要拿GPIO开刀?因为GPIO最简单,也最典型。你学会了GPIO的封装套路,后面UART、SPI、I2C全都是一个模子刻出来的。

核心思想:上层应用只调用HAL接口,底层驱动隔离硬件差异。换芯片?只改底层,不动上层。

3.1 分层架构长什么样?

先看一张图,把整体结构印在脑子里。

GPIO驱动分层架构图 应用层(App) HAL接口层(HAL_GPIO_xxx) 寄存器封装层(REG_GPIOx->ODR/BSSR/IDR) 硬件层(MCU GPIO外设) 换芯片只改这两层 寄存器封装层 HAL接口层 应用层不动 HAL接口清单 HAL_GPIO_Init HAL_GPIO_WritePin HAL_GPIO_ReadPin

看到没?一共四层。应用层只管调用,硬件层是芯片厂家的事。我们真正要写的,是中间那两层。

3.2 寄存器封装层——先搞定底层

我个人习惯,先把寄存器定义写成结构体。这样代码可读性高,也方便移植。

以STM32F103为例,GPIO寄存器映射是这样的:

/* 寄存器封装结构体 */
typedef struct {
    volatile uint32_t CRL;      // 端口配置低寄存器
    volatile uint32_t CRH;      // 端口配置高寄存器
    volatile uint32_t IDR;      // 输入数据寄存器
    volatile uint32_t ODR;      // 输出数据寄存器
    volatile uint32_t BSRR;     // 位设置/清除寄存器
    volatile uint32_t BRR;      // 位清除寄存器
    volatile uint32_t LCKR;     // 配置锁定寄存器
} GPIO_TypeDef;

/* 基地址定义 */
#define GPIOA_BASE      0x40010800
#define GPIOB_BASE      0x40010C00
#define GPIOC_BASE      0x40011000

/* 方便访问的宏 */
#define GPIOA           ((GPIO_TypeDef *)GPIOA_BASE)
#define GPIOB           ((GPIO_TypeDef *)GPIOB_BASE)
#define GPIOC           ((GPIO_TypeDef *)GPIOC_BASE)

小技巧:volatile关键字不能丢。编译器优化会吃掉你的寄存器读写,我踩过这个坑,debug了一下午才发现。

3.3 HAL接口层——让上层用着舒服

接下来是重头戏。我们要封装四个接口:初始化、输出、输入、中断配置。

3.3.1 枚举和结构体定义

先定义好参数类型,让调用者不用记数字。

/* GPIO引脚号 */
typedef enum {
    GPIO_PIN_0  = 0x0001,
    GPIO_PIN_1  = 0x0002,
    GPIO_PIN_2  = 0x0004,
    /* ... 以此类推 ... */
    GPIO_PIN_15 = 0x8000,
    GPIO_PIN_ALL = 0xFFFF
} GPIO_Pin;

/* GPIO模式 */
typedef enum {
    GPIO_MODE_INPUT         = 0x00,  // 输入模式
    GPIO_MODE_OUTPUT_PP     = 0x01,  // 推挽输出
    GPIO_MODE_OUTPUT_OD     = 0x02,  // 开漏输出
    GPIO_MODE_AF_PP         = 0x03,  // 复用推挽
    GPIO_MODE_AF_OD         = 0x04,  // 复用开漏
    GPIO_MODE_IT_RISING     = 0x05,  // 上升沿中断
    GPIO_MODE_IT_FALLING    = 0x06,  // 下降沿中断
    GPIO_MODE_IT_RISING_FALLING = 0x07 // 双边沿中断
} GPIO_Mode;

/* 初始化结构体 */
typedef struct {
    GPIO_Pin    Pin;        // 引脚
    GPIO_Mode   Mode;       // 模式
    uint8_t     Speed;      // 速度(输出模式有效)
} GPIO_InitTypeDef;

3.3.2 初始化函数

初始化是最复杂的,因为要配置CRL/CRH寄存器。我把它拆成两步:先算寄存器地址,再写配置值。

void HAL_GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *Init)
{
    uint32_t pinpos;
    uint32_t pos;
    uint32_t currentpin;
    uint32_t crl_mask, crh_mask;
    uint32_t *crl, *crh;

    /* 遍历所有引脚 */
    for(pinpos = 0; pinpos < 16; pinpos++)
    {
        pos = 1 << pinpos;
        currentpin = Init->Pin & pos;

        if(currentpin == pos)
        {
            /* 确定是CRL还是CRH */
            if(pinpos < 8)
            {
                crl = &GPIOx->CRL;
                crl_mask = ~(0x0F << (pinpos * 4));
                *crl &= crl_mask;
                *crl |= (Init->Mode << (pinpos * 4));
            }
            else
            {
                crh = &GPIOx->CRH;
                crh_mask = ~(0x0F << ((pinpos - 8) * 4));
                *crh &= crh_mask;
                *crh |= (Init->Mode << ((pinpos - 8) * 4));
            }
        }
    }
}

注意:写CRL/CRH之前一定要先清对应位,再写新值。我曾经直接赋值,结果把其他引脚配置冲掉了,板子上的LED乱闪,排查了好久。

3.3.3 输出函数

输出用BSRR寄存器,一次操作就能完成置位和复位,不需要读-改-写。

void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t Pin, uint8_t State)
{
    if(State != 0)
    {
        GPIOx->BSRR = Pin;      // 置位
    }
    else
    {
        GPIOx->BRR = Pin;       // 复位
    }
}

void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t Pin)
{
    GPIOx->ODR ^= Pin;         // 异或翻转
}

3.3.4 输入函数

输入更简单,直接读IDR寄存器。

uint8_t HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t Pin)
{
    return (uint8_t)((GPIOx->IDR & Pin) ? 1 : 0);
}

3.3.5 中断配置

中断配置涉及NVIC和EXTI,这里只展示GPIO相关的部分。

void HAL_GPIO_EnableIT(GPIO_TypeDef *GPIOx, uint16_t Pin)
{
    uint32_t exti_line = 0;

    /* 计算EXTI线号 */
    while(Pin >>= 1) exti_line++;

    /* 使能EXTI中断 */
    EXTI->IMR |= (1 << exti_line);

    /* 配置触发方式(在初始化时已设置) */
    /* 这里只使能中断线 */
}

/* 中断服务函数示例 */
void EXTI0_IRQHandler(void)
{
    if(EXTI->PR & (1 << 0))
    {
        /* 清除中断标志 */
        EXTI->PR = (1 << 0);

        /* 用户回调 */
        HAL_GPIO_EXTI_Callback(GPIO_PIN_0);
    }
}

/* 弱定义回调,用户可重写 */
__weak void HAL_GPIO_EXTI_Callback(uint16_t Pin)
{
    /* 用户实现 */
}

3.4 接口对比表

我把封装前后的差异整理了一下,方便你理解为什么要这么做。

功能 原始寄存器操作 HAL接口 优势
输出高电平 GPIOA->BSRR = 0x0001 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, 1) 参数语义化,不用记位号
读取输入 val = GPIOA->IDR & 0x0002 val = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) 返回值标准化,0/1清晰
配置模式 GPIOA->CRL |= 0x33333333 HAL_GPIO_Init(...) 不会误改其他引脚
中断使能 NVIC->ISER[0] |= 0x00000001 HAL_GPIO_EnableIT(...) 屏蔽NVIC细节

3.5 实际项目中的避坑指南

做项目这么多年,有些坑我替你们踩过了,直接列出来:

  • 初始化顺序不能乱:先开RCC时钟,再配GPIO。我曾经反过来写,寄存器写不进去,查了半天才发现时钟没开。
  • 中断标志必须软件清除:硬件不会自动清,不清除会一直进中断。我见过有人把整个系统卡死在中断里。
  • BSRR和BRR不要同时用:BSRR的低16位置位,高16位复位。如果同时操作同一引脚,结果不确定。
  • 输出模式要配速度:默认速度可能很低,导致波形畸变。我遇到过I2C因为速度配太低,通信失败的案例。

我的建议:刚开始做封装时,先只封装最常用的接口。等项目稳定了,再慢慢加高级功能。一口吃不成胖子,封装也是迭代出来的。

3.6 移植性验证

封装得好不好,换个芯片试试就知道。假设从STM32F103换到GD32F103:

/* 只需要改寄存器封装层 */
/* STM32F103版本 */
#define GPIOA_BASE      0x40010800

/* GD32F103版本 */
#define GPIOA_BASE      0x40010800  /* 地址一样,不用改 */

/* 如果地址不同,只改这一个宏 */
/* #define GPIOA_BASE      0x40020000 */

你看,只要底层地址映射对了,上层的HAL_GPIO_WritePin一行代码都不用动。这就是分层封装的价值。

嗯,这一章的内容就到这。GPIO封装是基础,但也是最重要的。你把这个套路练熟了,后面封装其他外设就是复制粘贴加微调的事。