第3章 UART串口抽象:异步收发器的HAL设计

UART,说白了就是嵌入式世界里最古老也最通用的“对话通道”。我做了十几年嵌入式,几乎每个项目都离不开它——从调试打印到外设通信,从蓝牙模块到GPS模组,串口永远是第一选择。

但问题来了:不同芯片的UART寄存器千差万别。STM32有USART,NXP有LPUART,TI的CC系列又是另一套。如果每次换芯片都重写驱动,那项目周期就别想控制了。

所以我们需要一个硬件抽象层,把UART的共性抽出来,把差异藏下去。

3.1 UART的核心要素:波特率、帧格式、流控

先理清楚UART到底在管什么。其实就三件事:

  • 波特率:每秒传输的符号数。常见的有9600、115200、921600。
  • 数据帧格式:起始位、数据位、校验位、停止位的组合。
  • 流控:硬件流控(RTS/CTS)还是软件流控(XON/XOFF)。

我在一个工业网关项目里遇到过这样的坑:设备端设的是8N1(8数据位、无校验、1停止位),但传感器模块默认是8E1(偶校验)。结果通信全是乱码,排查了整整一天才发现是帧格式不匹配。嗯,从那以后我养成了一个习惯——所有串口参数必须显式配置,绝不用默认值。

参数 常见值 说明
波特率 9600 / 115200 / 921600 越高越快,但受限于线长和噪声
数据位 7 / 8 / 9 8位最常用,7位用于ASCII传输
校验位 None / Even / Odd 偶校验Even最常用
停止位 1 / 1.5 / 2 1位足够,噪声环境用2位

3.2 HAL接口设计:把差异关在门后面

我个人习惯把UART的HAL分成三层:

  1. 配置层:负责波特率、帧格式、流控的设定。
  2. 传输层:提供发送和接收的接口,支持阻塞、中断、DMA三种模式。
  3. 回调层:把底层中断或DMA完成事件向上通知。

你想想看,如果应用层代码直接操作寄存器,那换芯片时得改多少地方?但如果应用层只调用 uart_send() 和 uart_receive(),底层实现随便换,上层纹丝不动。

核心设计原则:HAL接口只暴露“做什么”,不暴露“怎么做”。波特率配置、中断使能、DMA通道选择这些细节,全部封装在底层。

3.3 波特率配置:从公式到代码

波特率配置是UART HAL里最“数学”的部分。不同芯片的计算公式不同,但思路一致:

// 通用波特率计算公式
// 对于过采样为16的UART模块:
// BRR = 时钟频率 / (16 × 目标波特率)

// STM32示例:USART2,时钟72MHz,目标115200
uint32_t brr = 72000000 / (16 * 115200);  // 结果 ≈ 39

// 但实际要处理小数部分,否则误差会累积
// 我建议用以下方式保证精度:
uint32_t uart_calc_baudrate(uint32_t clk_hz, uint32_t baud) {
    uint32_t div = (clk_hz + (baud * 8)) / (16 * baud);  // 四舍五入
    return div;
}

这里有个细节:波特率误差必须控制在±2%以内,否则长时间传输会丢帧。我在一个GPS数据采集项目里,因为晶振偏差加上波特率计算没做四舍五入,导致每1000帧就丢一帧。排查起来特别痛苦,因为不是每次都丢,而是概率性丢。

注意:如果芯片主频不是目标波特率的整数倍,一定要计算实际误差。误差超过2%时,要么换晶振,要么用芯片内部的PLL微调时钟源。

3.4 数据帧格式:结构体封装

我喜欢用一个结构体把帧格式参数打包,这样接口清晰,扩展也方便:

typedef struct {
    uint32_t baudrate;      // 波特率
    uint8_t  data_bits;     // 数据位:7或8
    uint8_t  parity;        // 校验:0=无, 1=偶校验, 2=奇校验
    uint8_t  stop_bits;     // 停止位:1或2
    uint8_t  flow_ctrl;     // 流控:0=无, 1=硬件, 2=软件
} uart_config_t;

// 初始化接口
int uart_init(uart_port_t port, const uart_config_t *config);

为什么用结构体而不是分开的参数?因为串口配置通常是一起变的,很少只改其中一个。而且结构体方便做默认配置:

// 默认配置:115200, 8N1, 无流控
const uart_config_t UART_CONFIG_DEFAULT = {
    .baudrate = 115200,
    .data_bits = 8,
    .parity = 0,
    .stop_bits = 1,
    .flow_ctrl = 0
};

3.5 三种传输模式:阻塞、中断、DMA

这是UART HAL里最需要设计功底的部分。三种模式各有适用场景:

模式 CPU占用 实时性 适用场景
阻塞 高(忙等) 低 调试打印、短数据包
中断 中(每字节中断) 中 不定长数据、交互式通信
DMA 低(仅启动和完成) 高 大数据块、高速传输

接口设计上,我习惯统一成异步风格:

// 发送接口
int uart_send(uart_port_t port, const uint8_t *data, uint32_t len, uint32_t timeout_ms);

// 接收接口
int uart_receive(uart_port_t port, uint8_t *buf, uint32_t len, uint32_t timeout_ms);

// 中断模式专用:注册回调
typedef void (*uart_rx_cb_t)(uart_port_t port, uint8_t data);
void uart_set_rx_callback(uart_port_t port, uart_rx_cb_t cb);

// DMA模式专用:环形缓冲区
int uart_dma_start(uart_port_t port, uint8_t *rx_buf, uint32_t buf_size);

这里有个设计取舍:阻塞模式要不要加超时?我的答案是——必须加。不加超时的阻塞就是死锁炸弹。我在一个电机控制项目里就吃过这个亏,串口接收卡住,整个系统跟着挂掉。

经验之谈:中断模式适合“收到就处理”的场景,比如AT指令响应。DMA模式适合“收完再处理”的场景,比如固件升级。如果拿不准,先用中断模式,性能不够再切DMA。

3.6 中断与DMA的封装细节

中断模式的核心是ISR(中断服务函数)要短。我一般只在ISR里做两件事:把数据塞进环形缓冲区,然后置一个标志位。真正的数据处理放在主循环或任务里。

// 中断服务函数示例(伪代码)
void uart_isr_handler(uart_port_t port) {
    uint8_t data = READ_REG(port->DR);  // 读数据寄存器
    ringbuf_push(&port->rx_ring, data); // 塞入环形缓冲区
    port->rx_flag = 1;                  // 通知上层
}

DMA模式则要处理“半传输”和“传输完成”两个中断。这样可以用双缓冲技术,一边处理已收数据,一边继续接收新数据,不丢帧。

// DMA双缓冲接收
// buf被分成两半:buf[0..half-1] 和 buf[half..full-1]
// 半传输中断:处理前半段
// 传输完成中断:处理后段

void uart_dma_half_cplt_cb(uart_port_t port) {
    process_data(port, port->dma_buf, port->dma_buf_size / 2);
}

void uart_dma_cplt_cb(uart_port_t port) {
    process_data(port, port->dma_buf + port->dma_buf_size / 2, port->dma_buf_size / 2);
}

3.7 知识体系总览

下面这张图把UART HAL的核心脉络画出来了。你可以看到,从硬件寄存器到应用接口,中间经过了配置抽象、模式封装、回调机制三层过滤。

UART HAL 知识体系结构图 应用层接口 uart_init / uart_send / uart_receive 传输模式封装 阻塞模式 | 中断模式(回调+环形缓冲区)| DMA模式(双缓冲+半传输中断) 配置抽象层 波特率计算 | 帧格式(数据位/校验/停止位)| 流控配置 硬件寄存器层 BRR / CR1 / CR2 / SR / DR 设计要点 • 配置层用结构体封装,避免散落参数 • 传输层统一异步接口,阻塞/中断/DMA可切换 • 回调层解耦ISR与业务逻辑,ISR只做数据搬运

3.8 避坑指南:我踩过的那些串口坑

最后分享几个实战中容易翻车的地方:

  • 波特率误差累积:我曾经在一个多机通信系统里,每个节点的晶振都有微小偏差,波特率误差叠加后,长数据包必丢。解决方案是用GPS的PPS信号做时钟同步。
  • 中断服务函数里做太多事:早期我习惯在ISR里直接解析协议,结果高波特率下中断嵌套导致丢数据。后来强制ISR只做数据搬运,解析放任务里。
  • DMA缓冲区溢出:如果DMA接收速度大于上层处理速度,缓冲区会被写穿。解决办法是加水位线告警,或者用链表式缓冲区动态扩展。
  • 硬件流控没接对:RTS/CTS交叉接反,通信直接瘫痪。我建议在初始化时做一次自环测试,确认流控正常再开始业务。

UART看似简单,但要做好HAL抽象,让上层代码不受底层芯片影响,还是需要花心思的。核心就一句话:把变化封装好,把不变暴露出去。