4. UART驱动接口封装(一):标准UART收发接口定义,波特率/数据位/停止位/校验位配置

UART 这个外设,说简单也简单,说复杂也复杂。简单在哪?就是一根 TX、一根 RX,两根线就能通信。复杂在哪?不同厂家的 MCU,UART 寄存器布局天差地别。你想想看,ST 的 USART 和 NXP 的 LPUART,光波特率计算公式就不一样。

我早期做项目时,最头疼的就是换芯片。项目中期要换 MCU,UART 驱动几乎要重写。后来我学乖了——先定义抽象层接口,再谈具体实现。这节课,我们就来聊聊 UART 抽象层该怎么设计。

4.1 标准UART收发接口定义

接口设计有个原则:够用就好,别过度设计。UART 最核心的操作就三个:初始化、发送、接收。再加上一些控制接口,比如中断使能、回调注册。

我个人习惯把接口分成两组:

  • 基础操作接口:Init、DeInit、Send、Receive
  • 高级控制接口:SendIT、ReceiveIT、RegisterCallback、IRQHandler

下面是我在多个项目中沉淀下来的接口定义:

/* UART 抽象层接口定义 */
typedef struct {
    uint32_t baudrate;      /* 波特率 */
    uint8_t  data_bits;     /* 数据位:5/6/7/8 */
    uint8_t  stop_bits;     /* 停止位:1/2 */
    uint8_t  parity;        /* 校验位:0=无, 1=奇校验, 2=偶校验 */
    uint8_t  flow_ctrl;     /* 流控:0=无, 1=RTS/CTS */
} UART_Config_t;

/* 回调函数类型 */
typedef void (*UART_Callback_t)(uint8_t data);

/* 标准接口 */
int32_t UART_Init(UART_Config_t *config);
int32_t UART_DeInit(void);
int32_t UART_Send(uint8_t *data, uint16_t len, uint32_t timeout);
int32_t UART_Receive(uint8_t *data, uint16_t len, uint32_t timeout);

/* 中断模式接口 */
int32_t UART_SendIT(uint8_t *data, uint16_t len);
int32_t UART_ReceiveIT(uint8_t *data, uint16_t len);
int32_t UART_RegisterCallback(UART_Callback_t rx_cb, UART_Callback_t tx_cb);
void    UART_IRQHandler(void);

关键设计点:返回值统一用 int32_t,0 表示成功,负数表示错误码。这样上层代码可以统一处理错误,不用关心底层具体实现。

4.2 波特率配置——最坑的地方

波特率配置,说白了就是算分频系数。但不同 MCU 的时钟源、分频方式完全不同。我踩过最大的坑,就是 STM32 的 USART 和 GD32 的 USART,看起来寄存器一样,但时钟树不同,算出来的值差很多。

抽象层里,我们只传递目标波特率,具体计算交给底层:

/* 抽象层只做参数校验 */
int32_t UART_SetBaudrate(uint32_t baudrate) {
    if (baudrate == 0) return -1;  /* 无效参数 */
    if (baudrate > 3000000) return -1;  /* 超出范围 */
    
    /* 调用底层具体实现 */
    return HAL_UART_SetBaudrate(baudrate);
}

这里有个经验:波特率误差要控制在 3% 以内。超过这个范围,通信就可能丢数据。我建议在底层实现里加一个误差检查:

小技巧:在调试阶段,把实际配置的波特率通过另一个串口打印出来,跟目标值对比。我曾经用这个方法发现某款国产芯片的 UART 时钟源默认是内部 RC,误差高达 5%。

4.3 数据位/停止位/校验位配置

这三个参数,看起来简单,但组合起来有讲究。我整理了一个常用配置表:

数据位 停止位 校验位 典型应用
8 1 无 标准串口通信(最常见)
8 1 偶校验 Modbus RTU
7 1 偶校验 老式 ASCII 通信
8 2 无 高噪声环境
9 1 无 特殊协议(如 DMX512)

配置接口的设计,我建议用结构体一次性传入,而不是一个个 set 函数。为什么?因为有些 MCU 的 UART 配置需要同时设置多个寄存器,分开设置可能导致中间状态错误。

/* 一次性配置所有参数 */
int32_t UART_Configure(UART_Config_t *config) {
    if (config == NULL) return -1;
    
    /* 参数有效性检查 */
    if (config->data_bits < 5 || config->data_bits > 9) return -1;
    if (config->stop_bits != 1 && config->stop_bits != 2) return -1;
    if (config->parity > 2) return -1;
    
    /* 调用底层配置函数 */
    return HAL_UART_Configure(config);
}

注意:9 位数据位模式通常和校验位冲突。因为校验位会占用第 9 位。如果你配置了 9 位数据位,校验位必须设为无。这个逻辑要在参数检查里处理掉。

4.4 知识结构总览

下面这张图,把 UART 抽象层的核心结构画清楚了:

UART 抽象层接口结构 应用层代码 UART 抽象接口层 Init / DeInit / Send / Receive / SendIT / ReceiveIT UART_Config_t 波特率/数据位/停止位/校验位 回调函数 UART_Callback_t MCU 底层实现 HAL_UART_SetBaudrate / HAL_UART_Configure UART 硬件外设 抽象层隔离应用与硬件,换 MCU 只需重写底层实现,上层代码零改动 配置结构体 + 回调机制 + 标准接口 = 可移植的 UART 驱动

4.5 中断与轮询模式的选择

UART 收发有两种模式:轮询和中断。抽象层里,我建议两种都支持,让上层自己选。

  • 轮询模式:简单,但会阻塞 CPU。适合低速率、短数据的场景。
  • 中断模式:效率高,但需要处理临界区。适合高速率、大数据量的场景。

我在实际项目中,通常这样分配:

/* 发送用轮询(短数据,快) */
int32_t UART_Send(uint8_t *data, uint16_t len, uint32_t timeout) {
    while (len--) {
        while (!(UART->SR & TXE_FLAG) && timeout--) {
            /* 等待发送寄存器空 */
        }
        if (timeout == 0) return -2;  /* 超时 */
        UART->DR = *data++;
    }
    return 0;
}

/* 接收用中断(不确定何时来数据) */
int32_t UART_ReceiveIT(uint8_t *data, uint16_t len) {
    /* 注册接收缓冲区 */
    rx_buffer = data;
    rx_len = len;
    rx_idx = 0;
    
    /* 使能接收中断 */
    UART->CR1 |= RXNEIE;
    return 0;
}

经验之谈:中断服务函数里,尽量只做数据搬运,不要做协议解析。我曾经在一个项目中,在 ISR 里做 Modbus 解析,结果导致高波特率下丢数据。后来改成 DMA + 双缓冲,问题才解决。

4.6 错误处理与调试

UART 通信中,常见的错误有:

  • 溢出错误:接收缓冲区满了,新数据覆盖旧数据
  • 帧错误:停止位不正确,通常是波特率不匹配
  • 校验错误:奇偶校验失败

抽象层里,我建议把这些错误都暴露出来:

/* 错误码定义 */
#define UART_OK         0
#define UART_ERROR      -1
#define UART_TIMEOUT    -2
#define UART_OVERRUN    -3
#define UART_FRAME_ERR  -4
#define UART_PARITY_ERR -5

/* 获取错误状态 */
int32_t UART_GetError(void);

调试时,我习惯在初始化完成后,自发自收一个测试字节。如果收不到,说明配置有问题。这个方法帮我快速定位过很多问题,尤其是波特率不匹配的情况。

嗯,UART 抽象层的基础框架,到这里就搭好了。下一层,我们就要深入到具体 MCU 的底层实现了。到时候你会发现,有了这层抽象,换芯片真的就是换一个底层文件的事。