第四章:硬件抽象层设计:GPIO、UART、SPI、I2C的驱动封装、设备树与板级支持包(BSP)的抽象

各位同学,欢迎来到第四章。这一章,我们要啃一块硬骨头——硬件抽象层。

说实话,很多做嵌入式的人,一辈子都在跟寄存器打交道。点个灯,直接写GPIOx->ODR;发个串口数据,直接往DR寄存器里塞。这有什么问题吗?短期看没问题,甚至效率还高。但如果你要做的是一个网关中间件,要跑在十几种不同的板子上,这么干就是给自己挖坑。

我早年吃过这个亏。当时给一个客户做项目,用的是STM32F4,驱动写得飞起。结果客户中途换了NXP的芯片,好家伙,整个驱动层几乎重写。从那以后,我养成了一个习惯:写驱动之前,先想好抽象层怎么设计

4.1 为什么需要硬件抽象层?

硬件抽象层,说白了就是给上层软件一个统一的接口,把底层的寄存器差异给藏起来。

你想想看,上层应用要的其实很简单:

  • 把GPIO拉高拉低
  • 通过UART发一串字节
  • 通过SPI读写一个寄存器
  • 通过I2C读一个传感器的数据

它不关心你是用ARM Cortex-M3还是RISC-V,也不关心你的寄存器地址是多少。这就是抽象的意义。

核心原则:上层代码只调用抽象接口,不直接操作硬件寄存器。这样换芯片时,只需要重写底层实现,上层代码一行都不用改。

4.2 GPIO驱动封装:从寄存器到接口

我们先从最简单的GPIO开始。GPIO的底层操作,无非就是设置方向、输出高低电平、读取输入电平。

在裸机环境下,我们通常这样写:

// 直接操作寄存器(不推荐)
#define GPIOA_BASE 0x40020000
#define GPIOA_MODER (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_ODR   (*(volatile uint32_t *)(GPIOA_BASE + 0x14))

// 设置PA5为输出
GPIOA_MODER |= (1 << 10);  // MODER[5] = 01
// 拉高PA5
GPIOA_ODR |= (1 << 5);

这种写法,换一个芯片就全废了。我建议的做法是定义一套抽象接口:

/* gpio_hal.h - 硬件抽象层GPIO接口 */
typedef enum {
    GPIO_DIR_INPUT,
    GPIO_DIR_OUTPUT,
    GPIO_DIR_ALT_FUNC
} gpio_direction_t;

typedef enum {
    GPIO_PIN_LOW,
    GPIO_PIN_HIGH
} gpio_pin_state_t;

/* 抽象接口 */
void gpio_init(uint32_t pin, gpio_direction_t dir);
void gpio_set(uint32_t pin, gpio_pin_state_t state);
gpio_pin_state_t gpio_get(uint32_t pin);

然后在具体的芯片实现中,比如stm32_gpio.c里,去填充这些函数:

/* stm32_gpio.c - STM32平台实现 */
void gpio_init(uint32_t pin, gpio_direction_t dir) {
    uint32_t port = pin / 16;
    uint32_t num  = pin % 16;
    // 根据port和num操作对应的寄存器
    // ...
}

void gpio_set(uint32_t pin, gpio_pin_state_t state) {
    uint32_t port = pin / 16;
    uint32_t num  = pin % 16;
    if (state == GPIO_PIN_HIGH) {
        // 写BSRR寄存器
    } else {
        // 写BRR寄存器
    }
}

我的经验:GPIO的pin编号,我习惯用一个uint32_t来表示,高8位是端口号,低8位是引脚号。比如PA5就是0x0005,PB12就是0x010C。这样上层代码写起来很直观,底层解析也方便。

4.3 UART驱动封装:收发缓冲与回调

UART比GPIO复杂一些,因为它涉及到数据收发、中断处理、波特率配置等。

我个人习惯把UART抽象成三个层次:

  1. 配置层:设置波特率、数据位、停止位、校验位
  2. 发送层:支持单字节发送和缓冲区发送
  3. 接收层:支持轮询接收和中断接收,最好有环形缓冲区

来看接口定义:

/* 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: none, 1: odd, 2: even
} uart_config_t;

typedef void (*uart_rx_callback_t)(uint8_t data);

void uart_init(uint32_t uart_id, uart_config_t *config);
void uart_send_byte(uint32_t uart_id, uint8_t data);
void uart_send_buffer(uint32_t uart_id, uint8_t *data, uint32_t len);
void uart_set_rx_callback(uint32_t uart_id, uart_rx_callback_t cb);
uint8_t uart_read_byte(uint32_t uart_id);

这里有个关键点:中断回调的设计。我在项目中遇到过一个问题,某个传感器通过UART上报数据,频率很高,每次中断里都去处理业务逻辑,结果导致其他中断响应不及时。

避坑指南:我曾经在中断回调里直接调用了一个耗时的printf函数,结果系统直接卡死。记住:中断回调里只做最轻量的事情,比如把数据丢进环形缓冲区,真正的处理放到主循环或任务里去做。

4.4 SPI驱动封装:主从模式与DMA

SPI的抽象,核心在于区分主设备和从设备。在网关中间件里,我们通常作为SPI主机去访问外设,比如LCD屏、Flash芯片、RF模块等。

SPI的接口设计,我建议这样:

/* spi_hal.h */
typedef enum {
    SPI_MODE_0,  // CPOL=0, CPHA=0
    SPI_MODE_1,  // CPOL=0, CPHA=1
    SPI_MODE_2,  // CPOL=1, CPHA=0
    SPI_MODE_3   // CPOL=1, CPHA=1
} spi_mode_t;

typedef struct {
    uint32_t    frequency;
    spi_mode_t  mode;
    uint8_t     data_width;  // 8 or 16
} spi_config_t;

void spi_init(uint32_t spi_id, spi_config_t *config);
void spi_select_chip(uint32_t spi_id, uint8_t cs_pin);
void spi_deselect_chip(uint32_t spi_id, uint8_t cs_pin);
void spi_transfer(uint32_t spi_id, uint8_t *tx_data, uint8_t *rx_data, uint32_t len);

为什么要把片选信号单独拿出来?因为在实际项目中,一个SPI总线上可能挂多个设备,每个设备的片选引脚不同。把片选操作暴露给上层,灵活性更高。

另外,spi_transfer函数同时支持发送和接收,这是SPI的全双工特性决定的。如果你只需要发送,可以把rx_data传NULL;只需要接收,就把tx_data传NULL。

性能优化:对于大数据量的SPI传输(比如刷LCD屏),一定要用DMA。我在一个项目中,用轮询方式刷320x240的LCD,CPU占用率直接飙到80%。换成DMA后,降到5%以下。所以抽象层里最好预留DMA的接口。

4.5 I2C驱动封装:多设备与超时处理

I2C的抽象,难点在于多设备管理和超时处理。I2C总线上可以挂多个从设备,每个设备有独立的地址。而且I2C协议本身有应答机制,如果从设备没响应,主机需要做超时处理。

来看接口设计:

/* i2c_hal.h */
typedef struct {
    uint32_t frequency;  // 标准模式100kHz,快速模式400kHz
    uint8_t  own_address; // 作为从机时使用,通常不用
} i2c_config_t;

void i2c_init(uint32_t i2c_id, i2c_config_t *config);
int i2c_write(uint32_t i2c_id, uint8_t dev_addr, uint8_t *data, uint32_t len, uint32_t timeout_ms);
int i2c_read(uint32_t i2c_id, uint8_t dev_addr, uint8_t *data, uint32_t len, uint32_t timeout_ms);
int i2c_write_read(uint32_t i2c_id, uint8_t dev_addr, 
                   uint8_t *w_data, uint32_t w_len,
                   uint8_t *r_data, uint32_t r_len, uint32_t timeout_ms);

注意返回值的设计:返回0表示成功,返回负数表示错误。这样上层可以通过返回值判断是通信超时、还是从设备无应答。

我遇到过最坑的一个问题:某个I2C传感器在初始化时,需要先发一个软复位命令,然后等待100ms。但我的驱动里没有加延时,结果传感器一直不工作。后来查了三天才找到原因。

我的习惯:I2C的读写函数,一定要加超时参数。不要用死等。我曾经见过一个驱动,读I2C时用while循环死等应答,结果从设备挂了,整个系统卡死。加上超时后,至少能优雅地报错并恢复。

4.6 设备树:让硬件描述与代码分离

讲完驱动封装,我们来聊聊设备树。设备树这个概念,最早来自Linux内核,目的是把硬件描述信息从内核代码中分离出来。

在嵌入式网关中间件里,我们也可以借鉴这个思想。比如,我们可以用类似设备树的格式来描述硬件资源:

/* board_device_tree.h - 板级设备树 */
typedef struct {
    uint32_t base_addr;
    uint32_t irq_num;
    uint32_t clock_hz;
} uart_device_t;

typedef struct {
    uint32_t base_addr;
    uint32_t irq_num;
    uint32_t cs_pin;
    uint32_t clock_hz;
    uint8_t  mode;
} spi_device_t;

/* 具体的板级配置 */
static const uart_device_t board_uart_devices[] = {
    { .base_addr = 0x40011000, .irq_num = 37, .clock_hz = 115200 },  // UART1
    { .base_addr = 0x40004400, .irq_num = 38, .clock_hz = 9600 },    // UART2
};

static const spi_device_t board_spi_devices[] = {
    { .base_addr = 0x40013000, .irq_num = 35, .cs_pin = 5, .clock_hz = 1000000, .mode = 0 },
};

这样,当你要适配一块新板子时,只需要修改这个设备树文件,驱动代码完全不用动。

4.7 板级支持包(BSP)的抽象

BSP,说白了就是板子相关的所有代码的集合。包括时钟配置、引脚复用、中断向量表、内存映射等。

我建议把BSP抽象成以下几个模块:

模块 职责 典型接口
时钟管理 配置系统时钟、外设时钟 bsp_clk_init(), bsp_clk_get_hclk()
引脚复用 配置GPIO的复用功能 bsp_pin_mux(pin, func)
中断管理 配置中断优先级、使能/禁能 bsp_irq_enable(irq_num), bsp_irq_set_priority()
系统定时器 提供毫秒级/微秒级延时 bsp_delay_ms(), bsp_get_tick()

每个板子只需要实现这些接口,中间件就能跑起来。这就是BSP抽象的价值。

总结一下:硬件抽象层设计的核心,就是定义接口、隐藏实现、分离变化。GPIO、UART、SPI、I2C这些外设,虽然寄存器千差万别,但行为模式是相似的。把相似的部分抽象成接口,把差异的部分封装在底层实现里,这就是嵌入式架构师的看家本领。

下一章,我们会讲内存管理。到时候我会分享一个我在项目中用到的轻量级内存池方案,很有意思,敬请期待。