4、HAL接口标准化:UART接口标准化设计——阻塞、中断与DMA模式封装
UART,串口,嵌入式开发里最常用的外设,没有之一。调试打印、传感器通信、AT指令交互,哪哪都离不开它。但问题也来了——不同芯片的UART寄存器长得完全不一样,驱动代码经常被写死在一个项目里,换个MCU就得重写。
我早年吃过这个亏。一个产品从STM32换到GD32,UART底层几乎全改,上层协议栈也跟着遭殃。从那以后,我下定决心:必须把UART接口标准化,让上层代码永远不碰寄存器。
这一章,我们就来聊聊UART的HAL接口怎么设计。核心思路就一句话:把阻塞、中断、DMA三种模式封装成统一API,上层只管收发,底层随便换。
4.1 为什么UART接口需要标准化?
你想想看,一个典型的UART收发流程:
- 配置波特率、数据位、停止位、校验位
- 发送一个字节或一包数据
- 接收数据,可能用轮询、中断或DMA
- 处理错误(溢出、帧错误、校验错误)
不同MCU的寄存器操作天差地别。但上层应用在乎这些吗?不在乎。它只想知道:发出去没有?收到没有?出错了没有?
所以,标准化的目标就是:定义一套与硬件无关的UART操作接口,底层实现可以随时替换。
核心原则:接口稳定,实现可变。上层代码只依赖接口,不依赖具体芯片。
4.2 接口设计:三模式统一抽象
我个人习惯把UART操作分成三个层次:
- 配置层:波特率、模式、流控等参数设置
- 收发层:阻塞、中断、DMA三种模式的发送/接收
- 控制层:中断使能、DMA通道绑定、错误处理
下面这张图展示了整体架构:
4.3 接口定义:头文件里的契约
先看接口定义。我习惯用一个结构体把UART设备抽象出来:
/* uart_hal.h */
#ifndef UART_HAL_H
#define UART_HAL_H
#include <stdint.h>
#include <stddef.h>
/* 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硬件 */
} uart_config_t;
/* 收发模式枚举 */
typedef enum {
UART_MODE_BLOCKING = 0, /* 阻塞模式 */
UART_MODE_INTERRUPT, /* 中断模式 */
UART_MODE_DMA /* DMA模式 */
} uart_mode_t;
/* 回调函数类型 */
typedef void (*uart_rx_cb_t)(uint8_t data);
typedef void (*uart_tx_cb_t)(void);
typedef void (*uart_err_cb_t)(uint32_t err_flags);
/* UART设备句柄 */
typedef struct {
uint8_t port_id; /* 端口号 */
uart_mode_t mode; /* 当前模式 */
uart_config_t config; /* 配置参数 */
void* hw_regs; /* 硬件寄存器基址 */
uart_rx_cb_t rx_callback; /* 接收回调 */
uart_tx_cb_t tx_callback; /* 发送完成回调 */
uart_err_cb_t err_callback; /* 错误回调 */
} uart_handle_t;
/* 标准API */
int uart_init(uart_handle_t* handle, const uart_config_t* config);
int uart_send(uart_handle_t* handle, const uint8_t* data, size_t len);
int uart_recv(uart_handle_t* handle, uint8_t* buffer, size_t len);
int uart_ioctl(uart_handle_t* handle, uint32_t cmd, void* arg);
#endif /* UART_HAL_H */
这里有几个设计要点:
- 句柄模式:所有操作都通过句柄,支持多实例
- 回调分离:中断和DMA模式依赖回调,阻塞模式不用
- ioctl扩展:特殊控制(如流控开关、中断使能)通过ioctl统一入口
我的经验:ioctl的cmd参数建议用枚举+位域,不要用魔数。我曾经见过一个项目用0x01、0x02这种数字,三个月后没人记得0x03是什么意思。
4.4 阻塞模式实现:简单但危险
阻塞模式最直接,发送时CPU死等,接收时轮询。适合低负载场景。
/* 阻塞模式发送 */
int uart_send_blocking(uart_handle_t* handle, const uint8_t* data, size_t len) {
for (size_t i = 0; i < len; i++) {
/* 等待发送寄存器空 */
while (!(UART_SR(handle) & UART_SR_TXE));
/* 写入数据 */
UART_DR(handle) = data[i];
}
/* 等待发送完成 */
while (!(UART_SR(handle) & UART_SR_TC));
return 0;
}
/* 阻塞模式接收 */
int uart_recv_blocking(uart_handle_t* handle, uint8_t* buffer, size_t len) {
for (size_t i = 0; i < len; i++) {
/* 等待接收数据 */
while (!(UART_SR(handle) & UART_SR_RXNE));
buffer[i] = UART_DR(handle);
}
return 0;
}
警告:阻塞模式在RTOS环境下会挂起整个任务。如果接收超时,系统可能死锁。建议加超时机制,或者干脆不用阻塞模式做长数据收发。
4.5 中断模式实现:事件驱动
中断模式是嵌入式开发的标配。发送用中断逐字节,接收用中断缓存。
/* 中断模式发送 */
int uart_send_interrupt(uart_handle_t* handle, const uint8_t* data, size_t len) {
/* 保存数据到发送缓冲区 */
handle->tx_buf = data;
handle->tx_len = len;
handle->tx_idx = 0;
/* 使能发送中断 */
UART_CR(handle) |= UART_CR_TXEIE;
/* 触发第一个字节发送 */
UART_DR(handle) = handle->tx_buf[handle->tx_idx++];
return 0;
}
/* 发送中断服务函数 */
void uart_tx_isr(uart_handle_t* handle) {
if (handle->tx_idx < handle->tx_len) {
UART_DR(handle) = handle->tx_buf[handle->tx_idx++];
} else {
/* 发送完成,关闭中断 */
UART_CR(handle) &= ~UART_CR_TXEIE;
if (handle->tx_callback) {
handle->tx_callback();
}
}
}
接收中断类似,但需要环形缓冲区来缓存数据。我习惯用双缓冲,一个给ISR写,一个给应用读。
4.6 DMA模式实现:零CPU干预
DMA模式是性能王者。CPU只需要配置一次,DMA控制器自动搬运数据。
/* DMA模式发送 */
int uart_send_dma(uart_handle_t* handle, const uint8_t* data, size_t len) {
/* 配置DMA通道 */
dma_channel_config_t dma_cfg;
dma_cfg.src_addr = (uint32_t)data;
dma_cfg.dst_addr = (uint32_t)&UART_DR(handle);
dma_cfg.transfer_size = len;
dma_cfg.src_inc = DMA_INC_ENABLE; /* 源地址递增 */
dma_cfg.dst_inc = DMA_INC_DISABLE; /* 目标地址固定 */
dma_config(handle->dma_tx_ch, &dma_cfg);
dma_start(handle->dma_tx_ch);
/* 使能UART的DMA发送 */
UART_CR(handle) |= UART_CR_DMAT;
return 0;
}
/* DMA完成回调 */
void uart_dma_tx_complete(uart_handle_t* handle) {
/* 关闭DMA使能 */
UART_CR(handle) &= ~UART_CR_DMAT;
if (handle->tx_callback) {
handle->tx_callback();
}
}
性能对比:发送1KB数据,阻塞模式约消耗CPU 100%时间,中断模式约5%,DMA模式接近0%。但DMA有启动延迟,短数据(<16字节)用中断反而更快。
4.7 三种模式对比与选择
| 特性 | 阻塞模式 | 中断模式 | DMA模式 |
|---|---|---|---|
| CPU占用率 | 高(100%轮询) | 低(仅中断处理) | 极低(硬件搬运) |
| 实时性 | 差(阻塞等待) | 好(事件驱动) | 好(后台运行) |
| 代码复杂度 | 低 | 中(需环形缓冲) | 高(DMA配置) |
| 适用场景 | 调试、短数据 | 通用收发 | 大数据、高速率 |
| 内存开销 | 无 | 中(缓冲区) | 低(DMA自动管理) |
怎么选?我一般这样判断:
- 调试打印、初始化配置 → 阻塞模式,简单直接
- 传感器数据、AT指令交互 → 中断模式,平衡性能与复杂度
- 音频流、高速日志、文件传输 → DMA模式,CPU解放出来干正事
4.8 避坑指南:我踩过的雷
做UART标准化这么多年,有些坑说出来都是泪:
- 波特率误差:不同晶振下波特率计算可能不准。我遇到过标称115200实际只有114800的情况,通信偶尔丢字节。解决方案:用整数分频,或者加自动波特率检测。
- 中断嵌套:高波特率下中断频繁,如果中断优先级设置不当,可能丢失数据。建议UART中断优先级设高,且不要在中断里做耗时操作。
- DMA缓存对齐:有些DMA控制器要求源地址和长度对齐到4字节。我曾经传一个奇数长度的包,DMA直接卡死。解决方案:检查芯片手册,必要时做内存对齐拷贝。
- 环形缓冲区溢出:中断模式下如果应用层读得慢,缓冲区会溢出。建议加水位告警,或者用流控通知对端暂停发送。
曾经有一次,我在一个产品里用了DMA+环形缓冲区的组合,结果DMA传输完成中断和UART空闲中断同时触发,导致数据重复读取。后来加了互斥标志才解决。记住:中断共享资源一定要保护。
4.9 小结
UART接口标准化的核心,就是把阻塞、中断、DMA三种模式封装成统一API。上层代码永远调用uart_send和uart_recv,底层实现随便换。这样,换MCU时只需要重写底层驱动,上层协议栈纹丝不动。