第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分成三层:
- 配置层:负责波特率、帧格式、流控的设定。
- 传输层:提供发送和接收的接口,支持阻塞、中断、DMA三种模式。
- 回调层:把底层中断或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的核心脉络画出来了。你可以看到,从硬件寄存器到应用接口,中间经过了配置抽象、模式封装、回调机制三层过滤。
3.8 避坑指南:我踩过的那些串口坑
最后分享几个实战中容易翻车的地方:
- 波特率误差累积:我曾经在一个多机通信系统里,每个节点的晶振都有微小偏差,波特率误差叠加后,长数据包必丢。解决方案是用GPS的PPS信号做时钟同步。
- 中断服务函数里做太多事:早期我习惯在ISR里直接解析协议,结果高波特率下中断嵌套导致丢数据。后来强制ISR只做数据搬运,解析放任务里。
- DMA缓冲区溢出:如果DMA接收速度大于上层处理速度,缓冲区会被写穿。解决办法是加水位线告警,或者用链表式缓冲区动态扩展。
- 硬件流控没接对:RTS/CTS交叉接反,通信直接瘫痪。我建议在初始化时做一次自环测试,确认流控正常再开始业务。
UART看似简单,但要做好HAL抽象,让上层代码不受底层芯片影响,还是需要花心思的。核心就一句话:把变化封装好,把不变暴露出去。