第四章:硬件抽象层设计: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抽象成三个层次:
- 配置层:设置波特率、数据位、停止位、校验位
- 发送层:支持单字节发送和缓冲区发送
- 接收层:支持轮询接收和中断接收,最好有环形缓冲区
来看接口定义:
/* 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这些外设,虽然寄存器千差万别,但行为模式是相似的。把相似的部分抽象成接口,把差异的部分封装在底层实现里,这就是嵌入式架构师的看家本领。
下一章,我们会讲内存管理。到时候我会分享一个我在项目中用到的轻量级内存池方案,很有意思,敬请期待。