4. 接口隔离思想

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 接口抽象整体架构

下面这张图展示了整个抽象层的结构,我建议你保存下来,做移植时对照着看。

UART 驱动接口抽象架构 上层应用 / 协议栈 uart_hal_t 抽象接口层 init() | send_byte() | recv_byte() | is_data_ready() | enable_rx_interrupt() STM32 实现 uart_hal_stm32_polling.c uart_hal_stm32_int.c 操作 USART1->DR / SR / CR1 ESP32 实现 uart_hal_esp32_polling.c uart_hal_esp32_int.c 操作 UART1->FIFO / STATUS / INT_ENA 硬件寄存器 / 外设 说明:上层只依赖 uart_hal_t 结构体,不直接操作任何寄存器。换芯片只需替换底层 .c 文件。 轮询模式和中断模式各自独立实现,通过不同的结构体实例暴露给上层。

4.5 两种模式的选择策略

什么时候用轮询,什么时候用中断?我列个表给你参考:

场景 推荐模式 原因
调试阶段打印日志 轮询 简单可靠,不依赖中断系统
低速率数据收发(< 115200 bps) 轮询 CPU 占用可接受,代码简单
高速率数据收发(> 115200 bps) 中断 避免 CPU 死等,提高吞吐量
多任务系统(RTOS) 中断 + 信号量 中断中发信号量,任务中处理数据
低功耗场景 中断 CPU 可以休眠,数据来了再唤醒

我的习惯:开发阶段先用轮询模式调通基本功能,再切到中断模式优化性能。这样出问题时容易定位——先排除中断相关的坑。

4.6 移植适配检查清单

每次移植新芯片,我都会按这个清单走一遍,避免遗漏:

  1. 时钟配置:UART 模块的时钟源是否使能?分频系数对不对?
  2. GPIO 复用:TX/RX 引脚是否配置为 UART 功能?
  3. 波特率计算:寄存器配置值是否在误差范围内?
  4. 中断向量:中断服务函数名字对不对?优先级设了没?
  5. FIFO 处理:ESP32 有硬件 FIFO,STM32 没有,代码逻辑要区分。
  6. 错误标志:溢出、帧错误、校验错误是否处理了?

我曾经踩过的坑:有一次移植到新芯片,波特率算错了 0.5%,结果 115200 下偶尔丢字节。用示波器一看,每个字节的位宽差了半个位。后来我养成了习惯:移植完先用示波器量 TX 脚的波形,确认波特率准确再跑上层代码。

4.7 小结

UART 驱动移植的核心就一句话:用函数指针结构体隔离硬件差异。轮询模式抽象 4 个函数就够了,中断模式再加 2 个回调管理函数。STM32 和 ESP32 的差异主要在寄存器操作和中断处理上,但接口层完全一致。

嗯,这套方法我用了好几年,从 Cortex-M0 到 M4,再到 ESP32、RISC-V,基本没翻过车。你下次移植 UART 驱动时,不妨试试这个思路。