4、UART驱动移植实战:轮询模式与中断模式下的接口抽象,适配STM32与ESP32
UART 驱动移植,说白了就是让同一套上层代码,能在 STM32 和 ESP32 上都能收发数据。我刚开始做这个的时候,以为只是改改寄存器名字,结果踩了不少坑。今天咱们就聊聊怎么把轮询和中断两种模式抽象好,真正做到“一套接口,两套实现”。
4.1 为什么需要接口抽象?
你想想看,STM32 的 UART 寄存器叫 USARTx->DR,ESP32 的叫 UARTx->fifo。如果上层代码直接操作这些寄存器,换芯片就得重写所有逻辑。接口抽象就是在这中间加一层“翻译官”。
核心原则:上层只调用 uart_send_byte()、uart_recv_byte(),底层驱动负责跟硬件打交道。
我在项目中遇到过最头疼的事:产品中期换了主控,从 STM32F103 切到 ESP32。幸好前期做了接口抽象,只重写了底层 200 行代码,上层 3000 行纹丝不动。嗯,这就是抽象的价值。
4.2 轮询模式接口抽象
轮询模式最简单,CPU 死等数据。但抽象起来也有讲究。
4.2.1 定义统一接口
我习惯先定义一个结构体,把操作函数指针放进去:
/* uart_hal.h - 硬件抽象层接口 */
typedef struct {
void (*init)(uint32_t baud);
void (*send_byte)(uint8_t data);
uint8_t (*recv_byte)(void);
uint8_t (*is_data_ready)(void);
} uart_hal_t;
/* 外部声明 */
extern const uart_hal_t uart_hal_polling;
这样上层代码只需要调用 uart_hal_polling.send_byte('A'),完全不用关心底层是 STM32 还是 ESP32。
4.2.2 STM32 轮询实现
/* uart_hal_stm32_polling.c */
#include "uart_hal.h"
#include "stm32f1xx.h"
static void stm32_init(uint32_t baud) {
/* 配置 GPIO、USART 时钟、波特率 */
USART1->BRR = 72000000 / baud;
USART1->CR1 |= USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
}
static void stm32_send_byte(uint8_t data) {
while (!(USART1->SR & USART_SR_TXE)); /* 等待发送缓冲区空 */
USART1->DR = data;
}
static uint8_t stm32_recv_byte(void) {
while (!(USART1->SR & USART_SR_RXNE)); /* 等待接收数据 */
return (uint8_t)(USART1->DR & 0xFF);
}
static uint8_t stm32_is_data_ready(void) {
return (USART1->SR & USART_SR_RXNE) ? 1 : 0;
}
const uart_hal_t uart_hal_polling = {
.init = stm32_init,
.send_byte = stm32_send_byte,
.recv_byte = stm32_recv_byte,
.is_data_ready = stm32_is_data_ready
};
4.2.3 ESP32 轮询实现
/* uart_hal_esp32_polling.c */
#include "uart_hal.h"
#include "esp32_uart_regs.h"
static void esp32_init(uint32_t baud) {
/* 配置 UART 时钟、波特率、FIFO 模式 */
UART1->CLKDIV = (APB_CLK_FREQ / baud) & 0xFFFFF;
UART1->CONF0 |= (1 << 0); /* 使能 UART */
UART1->CONF1 |= (1 << 2); /* 使能发送 */
}
static void esp32_send_byte(uint8_t data) {
while (UART1->STATUS & (1 << 0)); /* 等待 TX FIFO 不满 */
UART1->FIFO = data;
}
static uint8_t esp32_recv_byte(void) {
while (!(UART1->STATUS & (1 << 1))); /* 等待 RX FIFO 有数据 */
return (uint8_t)(UART1->FIFO & 0xFF);
}
static uint8_t esp32_is_data_ready(void) {
return (UART1->STATUS & (1 << 1)) ? 1 : 0;
}
const uart_hal_t uart_hal_polling = {
.init = esp32_init,
.send_byte = esp32_send_byte,
.recv_byte = esp32_recv_byte,
.is_data_ready = esp32_is_data_ready
};
个人经验:轮询模式虽然简单,但 is_data_ready() 这个函数很关键。上层可以用它做非阻塞轮询,避免死等。我在一个低功耗项目里就用它配合定时器,每 1ms 查一次,既省电又不丢数据。
4.3 中断模式接口抽象
中断模式比轮询复杂,因为涉及回调函数和缓冲区管理。我建议把中断处理也抽象出来。
4.3.1 定义中断回调接口
/* uart_hal.h - 增加中断相关接口 */
typedef void (*uart_rx_callback_t)(uint8_t data);
typedef struct {
void (*init)(uint32_t baud);
void (*send_byte)(uint8_t data);
uint8_t (*recv_byte)(void);
uint8_t (*is_data_ready)(void);
/* 中断相关 */
void (*enable_rx_interrupt)(uart_rx_callback_t cb);
void (*disable_rx_interrupt)(void);
} uart_hal_t;
4.3.2 STM32 中断实现
/* uart_hal_stm32_int.c */
static uart_rx_callback_t rx_cb = NULL;
static void stm32_enable_rx_interrupt(uart_rx_callback_t cb) {
rx_cb = cb;
USART1->CR1 |= USART_CR1_RXNEIE; /* 使能接收中断 */
NVIC_EnableIRQ(USART1_IRQn);
}
void USART1_IRQHandler(void) {
if (USART1->SR & USART_SR_RXNE) {
uint8_t data = USART1->DR & 0xFF;
if (rx_cb) {
rx_cb(data); /* 调用上层回调 */
}
}
}
4.3.3 ESP32 中断实现
/* uart_hal_esp32_int.c */
static uart_rx_callback_t rx_cb = NULL;
static void esp32_enable_rx_interrupt(uart_rx_callback_t cb) {
rx_cb = cb;
UART1->INT_ENA |= (1 << 0); /* 使能 RX FIFO 满中断 */
/* ESP32 中断向量注册略 */
}
void uart1_isr(void) {
if (UART1->INT_ST & (1 << 0)) {
while (UART1->STATUS & (1 << 1)) { /* 读完 FIFO */
uint8_t data = UART1->FIFO & 0xFF;
if (rx_cb) {
rx_cb(data);
}
}
UART1->INT_CLR |= (1 << 0); /* 清中断标志 */
}
}
避坑指南:我曾经在 ESP32 上忘了清中断标志,结果中断一直触发,系统卡死。查了整整半天才发现。记住:中断服务函数里一定要清标志,而且要在回调之后清,避免回调期间又被触发。
4.4 接口抽象整体架构
下面这张图展示了整个抽象层的结构,我建议你保存下来,做移植时对照着看。
4.5 两种模式的选择策略
什么时候用轮询,什么时候用中断?我列个表给你参考:
| 场景 | 推荐模式 | 原因 |
|---|---|---|
| 调试阶段打印日志 | 轮询 | 简单可靠,不依赖中断系统 |
| 低速率数据收发(< 115200 bps) | 轮询 | CPU 占用可接受,代码简单 |
| 高速率数据收发(> 115200 bps) | 中断 | 避免 CPU 死等,提高吞吐量 |
| 多任务系统(RTOS) | 中断 + 信号量 | 中断中发信号量,任务中处理数据 |
| 低功耗场景 | 中断 | CPU 可以休眠,数据来了再唤醒 |
我的习惯:开发阶段先用轮询模式调通基本功能,再切到中断模式优化性能。这样出问题时容易定位——先排除中断相关的坑。
4.6 移植适配检查清单
每次移植新芯片,我都会按这个清单走一遍,避免遗漏:
- 时钟配置:UART 模块的时钟源是否使能?分频系数对不对?
- GPIO 复用:TX/RX 引脚是否配置为 UART 功能?
- 波特率计算:寄存器配置值是否在误差范围内?
- 中断向量:中断服务函数名字对不对?优先级设了没?
- FIFO 处理:ESP32 有硬件 FIFO,STM32 没有,代码逻辑要区分。
- 错误标志:溢出、帧错误、校验错误是否处理了?
我曾经踩过的坑:有一次移植到新芯片,波特率算错了 0.5%,结果 115200 下偶尔丢字节。用示波器一看,每个字节的位宽差了半个位。后来我养成了习惯:移植完先用示波器量 TX 脚的波形,确认波特率准确再跑上层代码。
4.7 小结
UART 驱动移植的核心就一句话:用函数指针结构体隔离硬件差异。轮询模式抽象 4 个函数就够了,中断模式再加 2 个回调管理函数。STM32 和 ESP32 的差异主要在寄存器操作和中断处理上,但接口层完全一致。
嗯,这套方法我用了好几年,从 Cortex-M0 到 M4,再到 ESP32、RISC-V,基本没翻过车。你下次移植 UART 驱动时,不妨试试这个思路。