2. 硬件抽象层(HAL)基础:GPIO的抽象设计——结构体封装、接口定义与多平台实现(STM32/ESP32)

好,我们直接进入正题。

做嵌入式开发,尤其是要跑多个芯片平台的时候,最头疼的是什么?我告诉你,不是业务逻辑有多复杂,而是换个芯片,所有底层代码都得重写。GPIO点个灯,在STM32上是一套寄存器操作,到了ESP32上又是另一套。代码里到处都是 #ifdef,维护起来简直想骂人。

那怎么办?抽象。把硬件的差异,用一层软件包起来。这层就叫硬件抽象层,也就是HAL。今天我们就拿GPIO开刀,看看怎么设计一个低耦合、高内聚的抽象层。

2.1 为什么GPIO需要抽象?

你想想看,GPIO的本质是什么?无非就是:输入、输出、中断。不管哪家芯片,功能都一样。但实现方式千差万别。

我举个例子。STM32的GPIO初始化,你得配置MODER、OTYPER、OSPEEDR、PUPDR这一堆寄存器。ESP32呢?它用的是 gpio_config_t 结构体,直接填字段。你要是每个项目都对着寄存器手册写,那换个平台,代码基本等于重来。

所以,我们要做的,就是定义一套通用的接口。这套接口只描述“做什么”,不关心“怎么做”。具体的实现,留给各个平台的驱动文件去完成。

核心思想: 接口与实现分离。上层代码只依赖接口,不依赖具体硬件。

2.2 结构体封装:把GPIO的属性打包

我个人习惯,第一步先把GPIO的“属性”抽象成一个结构体。这样,所有配置信息都放在一个地方,清晰明了。

这个结构体应该包含哪些东西?

  • 端口号: 是GPIOA还是GPIOB?
  • 引脚号: 第0脚还是第15脚?
  • 模式: 输入、输出、还是复用功能?
  • 上下拉: 上拉、下拉、还是浮空?
  • 驱动能力: 推挽还是开漏?
  • 速度: 低频还是高频?

你看,这些属性是通用的。不管哪家芯片,都得有。那我们就可以定义一个这样的结构体:

/* gpio_common.h */
#ifndef _GPIO_COMMON_H_
#define _GPIO_COMMON_H_

#include <stdint.h>

/* GPIO端口枚举 */
typedef enum {
    GPIO_PORT_A = 0,
    GPIO_PORT_B,
    GPIO_PORT_C,
    GPIO_PORT_D,
    /* ... 根据平台扩展 */
    GPIO_PORT_MAX
} gpio_port_t;

/* GPIO引脚号枚举 */
typedef enum {
    GPIO_PIN_0  = 0,
    GPIO_PIN_1,
    GPIO_PIN_2,
    /* ... */
    GPIO_PIN_15,
    GPIO_PIN_MAX
} gpio_pin_t;

/* GPIO模式枚举 */
typedef enum {
    GPIO_MODE_INPUT  = 0,
    GPIO_MODE_OUTPUT,
    GPIO_MODE_ALT_FUNC,  /* 复用功能 */
    GPIO_MODE_ANALOG    /* 模拟模式 */
} gpio_mode_t;

/* 上下拉配置 */
typedef enum {
    GPIO_PULL_NONE = 0,
    GPIO_PULL_UP,
    GPIO_PULL_DOWN
} gpio_pull_t;

/* 输出类型 */
typedef enum {
    GPIO_OTYPE_PUSH_PULL = 0,
    GPIO_OTYPE_OPEN_DRAIN
} gpio_otype_t;

/* GPIO配置结构体 */
typedef struct {
    gpio_port_t  port;      /* 端口 */
    gpio_pin_t   pin;       /* 引脚 */
    gpio_mode_t  mode;      /* 模式 */
    gpio_pull_t  pull;      /* 上下拉 */
    gpio_otype_t otype;     /* 输出类型 */
    uint8_t      speed;     /* 速度等级 (平台相关, 用数字表示) */
} gpio_cfg_t;

#endif /* _GPIO_COMMON_H_ */

嗯,这里要注意。枚举值虽然通用,但具体数值在不同平台下可能不一样。比如STM32的GPIOA可能是0,但ESP32的GPIOA可能是1。所以,这个结构体里的 port 和 pin,我们只把它当作一个逻辑编号。真正的物理映射,在实现层去处理。

2.3 接口定义:只描述行为

结构体定义好了,接下来就是接口。接口要尽量精简,功能单一。我一般只定义这几个核心操作:

  • 初始化: 根据配置结构体初始化GPIO。
  • 写电平: 设置引脚输出高或低。
  • 读电平: 读取引脚当前电平。
  • 翻转电平: 把当前输出电平取反。
  • 中断配置: 使能/失能中断,设置触发方式。

接口函数命名,我习惯用 hal_gpio_ 作为前缀,一看就知道是抽象层的东西。

/* hal_gpio.h */
#ifndef _HAL_GPIO_H_
#define _HAL_GPIO_H_

#include "gpio_common.h"

/* 中断触发方式 */
typedef enum {
    GPIO_INT_DISABLE    = 0,
    GPIO_INT_RISING,      /* 上升沿触发 */
    GPIO_INT_FALLING,     /* 下降沿触发 */
    GPIO_INT_BOTH_EDGE    /* 双边沿触发 */
} gpio_int_mode_t;

/* 中断回调函数类型 */
typedef void (*gpio_isr_callback_t)(void *arg);

/**
 * @brief 初始化GPIO引脚
 * @param cfg GPIO配置结构体指针
 * @return 0: 成功, -1: 失败
 */
int hal_gpio_init(const gpio_cfg_t *cfg);

/**
 * @brief 设置引脚输出电平
 * @param port 端口号
 * @param pin  引脚号
 * @param level 0: 低电平, 1: 高电平
 */
void hal_gpio_write(gpio_port_t port, gpio_pin_t pin, uint8_t level);

/**
 * @brief 读取引脚输入电平
 * @param port 端口号
 * @param pin  引脚号
 * @return 0: 低电平, 1: 高电平
 */
uint8_t hal_gpio_read(gpio_port_t port, gpio_pin_t pin);

/**
 * @brief 翻转引脚输出电平
 * @param port 端口号
 * @param pin  引脚号
 */
void hal_gpio_toggle(gpio_port_t port, gpio_pin_t pin);

/**
 * @brief 配置引脚中断
 * @param port     端口号
 * @param pin      引脚号
 * @param mode     中断触发方式
 * @param callback 中断回调函数
 * @param arg      回调函数参数
 * @return 0: 成功, -1: 失败
 */
int hal_gpio_irq_config(gpio_port_t port, gpio_pin_t pin, 
                        gpio_int_mode_t mode, 
                        gpio_isr_callback_t callback, 
                        void *arg);

#endif /* _HAL_GPIO_H_ */

你看,这些接口里没有任何平台相关的关键字。没有 GPIOA,没有 RCC,没有 GPIO.out1_val。上层代码只需要调用 hal_gpio_write(GPIO_PORT_A, GPIO_PIN_5, 1) 就能点灯。至于这个操作在底层是怎么实现的,上层完全不用管。

我的经验: 接口参数尽量用枚举,不要用魔法数字。比如电平用 0 和 1,虽然简单,但可读性差。我后来习惯定义成 GPIO_LEVEL_LOW 和 GPIO_LEVEL_HIGH,代码一看就懂。

2.4 多平台实现:STM32与ESP32的落地

接口定义好了,接下来就是真刀真枪地实现了。我们拿两个最常用的平台举例:STM32和ESP32。

2.4.1 STM32实现

STM32的HAL库其实已经封装了一层,但它的接口还是太“ST”了。我们要做的是把我们的抽象接口,映射到ST的HAL库上。

/* hal_gpio_stm32.c */
#include "hal_gpio.h"
#include "stm32f4xx_hal.h"  /* 假设使用STM32F4系列 */

/* 端口映射表: 逻辑端口号 -> 实际GPIO_TypeDef指针 */
static GPIO_TypeDef *port_map[] = {
    [GPIO_PORT_A] = GPIOA,
    [GPIO_PORT_B] = GPIOB,
    [GPIO_PORT_C] = GPIOC,
    /* ... */
};

int hal_gpio_init(const gpio_cfg_t *cfg)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    /* 使能GPIO时钟 (这里需要根据端口号使能对应的RCC时钟) */
    /* ... 省略RCC使能代码 ... */
    
    /* 映射模式 */
    switch(cfg->mode) {
        case GPIO_MODE_INPUT:  GPIO_InitStruct.Mode = GPIO_MODE_INPUT;  break;
        case GPIO_MODE_OUTPUT: GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT; break;
        /* ... 其他模式映射 ... */
    }
    
    /* 映射上下拉 */
    switch(cfg->pull) {
        case GPIO_PULL_NONE: GPIO_InitStruct.Pull = GPIO_NOPULL;   break;
        case GPIO_PULL_UP:   GPIO_InitStruct.Pull = GPIO_PULLUP;   break;
        case GPIO_PULL_DOWN: GPIO_InitStruct.Pull = GPIO_PULLDOWN; break;
    }
    
    GPIO_InitStruct.Pin   = (1 << cfg->pin);  /* STM32用位掩码表示引脚 */
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    
    HAL_GPIO_Init(port_map[cfg->port], &GPIO_InitStruct);
    return 0;
}

void hal_gpio_write(gpio_port_t port, gpio_pin_t pin, uint8_t level)
{
    HAL_GPIO_WritePin(port_map[port], (1 << pin), (GPIO_PinState)level);
}

uint8_t hal_gpio_read(gpio_port_t port, gpio_pin_t pin)
{
    return (uint8_t)HAL_GPIO_ReadPin(port_map[port], (1 << pin));
}
/* ... 其他函数实现类似 ... */

2.4.2 ESP32实现

ESP32用的是乐鑫的ESP-IDF框架,它的GPIO驱动是另一套风格。

/* hal_gpio_esp32.c */
#include "hal_gpio.h"
#include "driver/gpio.h"

/* 引脚映射: 逻辑引脚号 -> ESP32的gpio_num_t */
/* 注意: ESP32的引脚号是连续的, 可以直接转换 */
static gpio_num_t pin_map[] = {
    [GPIO_PIN_0]  = GPIO_NUM_0,
    [GPIO_PIN_1]  = GPIO_NUM_1,
    /* ... */
    [GPIO_PIN_15] = GPIO_NUM_15,
};

int hal_gpio_init(const gpio_cfg_t *cfg)
{
    gpio_config_t io_conf = {};
    
    /* 配置引脚位掩码 */
    io_conf.pin_bit_mask = (1ULL << pin_map[cfg->pin]);
    
    /* 映射模式 */
    switch(cfg->mode) {
        case GPIO_MODE_INPUT:  io_conf.mode = GPIO_MODE_INPUT;  break;
        case GPIO_MODE_OUTPUT: io_conf.mode = GPIO_MODE_OUTPUT; break;
        /* ... */
    }
    
    /* 映射上下拉 */
    switch(cfg->pull) {
        case GPIO_PULL_NONE: 
            io_conf.pull_up_en   = 0;
            io_conf.pull_down_en = 0;
            break;
        case GPIO_PULL_UP:
            io_conf.pull_up_en   = 1;
            io_conf.pull_down_en = 0;
            break;
        /* ... */
    }
    
    io_conf.intr_type = GPIO_INTR_DISABLE;
    
    gpio_config(&io_conf);
    return 0;
}

void hal_gpio_write(gpio_port_t port, gpio_pin_t pin, uint8_t level)
{
    gpio_set_level(pin_map[pin], level);
}

uint8_t hal_gpio_read(gpio_port_t port, gpio_pin_t pin)
{
    return (uint8_t)gpio_get_level(pin_map[pin]);
}
/* ... */

我曾经踩过的坑: ESP32的引脚号不是所有都能用的。比如GPIO6-11被用于Flash,不能当普通IO用。所以,在实现映射表的时候,一定要加上有效性检查。否则,程序跑起来莫名其妙,查半天才发现是引脚冲突。

2.5 知识结构:抽象层的层次关系

说了这么多,我们用一张图来总结一下整个抽象层的结构。这样你看起来会更直观。

GPIO抽象层层次结构图 GPIO抽象层层次结构 应用层 (App) 抽象接口层 (hal_gpio.h) 平台实现层 (hal_gpio_stm32.c / hal_gpio_esp32.c) STM32 HAL库 ESP32 ESP-IDF 硬件 (STM32F4 / ESP32) 上层代码只依赖抽象接口层,更换平台只需替换实现层文件,无需修改应用层代码。

这张图很清楚地展示了分层的思路。应用层在最上面,它只跟抽象接口层打交道。抽象接口层下面是具体的平台实现。每个平台实现,再调用各自厂商的底层驱动。这样,当你从STM32换到ESP32时,只需要换掉中间那一层实现文件,上面的应用代码一行都不用改。

2.6 对比总结:不同实现方式的差异

为了让你更清楚地看到两个平台实现的差异,我整理了一个表格。

对比项 STM32 实现 ESP32 实现
初始化方式 使用 GPIO_InitTypeDef 结构体 使用 gpio_config_t 结构体
引脚表示 位掩码 (1 << pin) 引脚号 GPIO_NUM_X
时钟使能 需要手动使能 RCC 时钟 驱动内部自动处理
中断配置 需要配置NVIC和EXTI 使用 gpio_isr_handler_add()
驱动能力 通过 GPIO_InitTypeDef.Speed 设置 通过 gpio_set_drive_capability() 设置

你看,差异其实挺大的。但通过我们的抽象层,这些差异都被隐藏在了 hal_gpio_stm32.c 和 hal_gpio_esp32.c 这两个文件里。上层代码根本感觉不到。

我的建议: 刚开始做抽象的时候,不要追求一步到位。先抽象你最常用的几个功能,比如初始化、读写。等后面遇到新需求,再慢慢往接口里加。过度设计也是坑。

好了,这一章的内容就到这里。GPIO的抽象设计,说白了就是定义一套通用的结构体和接口,然后为每个平台写一份实现。这个思路不光适用于GPIO,也适用于UART、I2C、SPI等所有外设。掌握了这个思想,你的代码就能真正做到“一次编写,到处编译”。