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 抽象层的核心结构画清楚了:
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 的底层实现了。到时候你会发现,有了这层抽象,换芯片真的就是换一个底层文件的事。