4. UART驱动HAL化:轮询模式、中断模式、DMA模式、环形缓冲区设计
UART,串口,嵌入式开发者的老朋友了。从调试信息到外设通信,几乎每个项目都离不开它。但很多新手写UART驱动,就是操作寄存器,收发各一个buffer,能用就行。项目小还好,一旦系统复杂起来,这种写法会让你吃尽苦头。
我个人习惯,拿到一个新MCU,第一件事就是把UART驱动HAL化。把轮询、中断、DMA三种模式封装好,再配上环形缓冲区。这套东西做好了,后面所有串口相关的开发都会很顺畅。
4.1 为什么UART驱动需要HAL化?
说白了,就是要把“怎么发”和“发什么”分开。你的应用层代码不应该关心当前用的是轮询还是DMA,它只需要调用一个 UART_Send() 函数就够了。
我在项目中遇到过这样的情况:产品初期用轮询模式跑原型,验证功能。到了量产阶段,发现轮询占CPU太多,要改成中断。如果驱动和业务代码耦合在一起,那几乎要重写整个通信模块。但如果一开始就做了HAL抽象,改个配置结构体就行。
- 应用层与硬件解耦:上层只调用标准API
- 模式切换零成本:轮询/中断/DMA通过配置切换
- 可移植性:换MCU平台时,只改底层HAL实现
4.2 轮询模式:简单但致命
轮询模式,就是CPU死等。发送时,循环检查发送寄存器是否空;接收时,循环检查有没有数据进来。
// 轮询发送一个字节
void UART_Polling_SendByte(uint8_t data) {
while (!(UART->SR & UART_SR_TXE)); // 等待发送寄存器空
UART->DR = data;
}
// 轮询接收一个字节
uint8_t UART_Polling_RecvByte(void) {
while (!(UART->SR & UART_SR_RXNE)); // 等待接收数据
return (uint8_t)(UART->DR & 0xFF);
}
这段代码,每个初学者都写过。它的优点是简单、确定性强。但缺点也很明显:CPU被完全占住。你想想看,如果波特率是115200,接收一个字节大约87微秒,这期间CPU什么都干不了。
轮询模式适合什么场景?我个人认为,只有两种:一是系统初始化阶段的调试输出,二是对实时性要求极高且数据量极小的场合。其他情况,建议用中断或DMA。
4.3 中断模式:最常用的方案
中断模式,就是让硬件在数据到达或发送完成时通知CPU。CPU不用死等,可以去做其他事情。
中断驱动的UART HAL,通常包含这几个部分:
- 初始化配置: 设置波特率、数据位、停止位、中断使能
- 发送中断: 发送寄存器空时触发,从buffer取下一个字节发送
- 接收中断: 接收到数据时触发,把数据存入接收buffer
- 错误中断: 帧错误、溢出错误等异常处理
// 中断模式初始化示例
void UART_Interrupt_Init(UART_HandleTypeDef *huart) {
// 配置GPIO、时钟等(略)
// 使能接收中断
huart->Instance->CR1 |= UART_CR1_RXNEIE;
// 使能发送完成中断(用于发送)
huart->Instance->CR1 |= UART_CR1_TCIE;
// 使能全局中断(NVIC)
NVIC_EnableIRQ(huart->IRQn);
}
// 中断服务函数
void UART_IRQHandler(UART_HandleTypeDef *huart) {
if (huart->Instance->SR & UART_SR_RXNE) {
uint8_t data = (uint8_t)(huart->Instance->DR & 0xFF);
// 将数据存入环形缓冲区
RingBuf_Write(&huart->rxRingBuf, data);
}
if (huart->Instance->SR & UART_SR_TC) {
// 发送完成,检查是否还有数据要发送
if (RingBuf_GetCount(&huart->txRingBuf) > 0) {
uint8_t data;
RingBuf_Read(&huart->txRingBuf, &data);
huart->Instance->DR = data;
} else {
// 没有数据了,关闭发送中断
huart->Instance->CR1 &= ~UART_CR1_TCIE;
}
}
}
这里有个细节要注意:发送中断的触发时机。很多芯片的发送中断是“发送寄存器空”时触发,而不是“发送完成”时触发。这两个概念不一样。发送寄存器空意味着你可以写入下一个字节,但上一个字节可能还没从移位寄存器发完。如果你连续写入太快,会造成数据覆盖。
4.4 DMA模式:高性能的终极方案
DMA模式,就是让DMA控制器直接搬运数据,CPU完全不参与数据搬移。CPU只需要告诉DMA:从哪搬,搬到哪,搬多少。然后就可以去睡觉了。
DMA模式特别适合大数据量的UART通信,比如:
- 通过串口升级固件(一次几KB到几MB)
- 高速传感器数据采集(如激光雷达点云数据)
- 与WiFi/蓝牙模块的AT指令通信
// DMA发送配置示例
void UART_DMA_Send(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size) {
// 配置DMA通道
// 源地址:pData,目的地址:UART->DR
// 传输方向:内存到外设
// 传输大小:Size
// 使能DMA传输完成中断
// 启动DMA传输
// 使能UART的DMA发送请求
huart->Instance->CR3 |= UART_CR3_DMAT;
}
// DMA接收配置示例(使用循环模式)
void UART_DMA_Receive_Circular(UART_HandleTypeDef *huart, uint8_t *pBuffer, uint16_t BufferSize) {
// 配置DMA通道
// 源地址:UART->DR,目的地址:pBuffer
// 传输方向:外设到内存
// 传输大小:BufferSize
// 模式:循环模式(Circular Mode)
// 使能DMA传输完成中断(半传输和全传输中断)
// 启动DMA传输
// 使能UART的DMA接收请求
huart->Instance->CR3 |= UART_CR3_DMAR;
}
DMA模式有个坑:接收时,如果使用循环模式,你需要知道当前DMA写到了哪个位置。通常通过DMA的“当前数据计数器”寄存器来判断。我建议在DMA的半传输和全传输中断中处理数据,这样你有两个时间点来处理接收到的数据,不会丢失。
| 模式 | CPU占用 | 实时性 | 数据量 | 适用场景 |
|---|---|---|---|---|
| 轮询 | 高(100%占用) | 高 | 小(<100字节/秒) | 调试输出、简单控制 |
| 中断 | 低(约1-5%) | 中 | 中(<10KB/秒) | 通用通信、传感器数据 |
| DMA | 极低(<1%) | 低(有延迟) | 大(>10KB/秒) | 固件升级、高速数据流 |
4.5 环形缓冲区设计:中断和DMA的基石
不管是中断模式还是DMA模式,都离不开缓冲区。而环形缓冲区(Ring Buffer)是嵌入式系统中最经典、最高效的缓冲区设计。
为什么用环形缓冲区?因为它是固定大小的,不会动态分配内存,而且读写操作都是O(1)复杂度。你想想看,如果每次中断都去malloc一个buffer,那系统早就崩了。
// 环形缓冲区结构体
typedef struct {
uint8_t *buffer; // 缓冲区内存
uint32_t head; // 写指针
uint32_t tail; // 读指针
uint32_t size; // 缓冲区大小(必须是2的幂)
uint32_t count; // 当前数据量
} RingBuf_t;
// 初始化
void RingBuf_Init(RingBuf_t *rb, uint8_t *buf, uint32_t size) {
rb->buffer = buf;
rb->size = size;
rb->head = 0;
rb->tail = 0;
rb->count = 0;
}
// 写入一个字节
bool RingBuf_Write(RingBuf_t *rb, uint8_t data) {
if (rb->count >= rb->size) {
return false; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = (rb->head + 1) & (rb->size - 1); // 利用位运算实现循环
rb->count++;
return true;
}
// 读取一个字节
bool RingBuf_Read(RingBuf_t *rb, uint8_t *data) {
if (rb->count == 0) {
return false; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) & (rb->size - 1);
rb->count--;
return true;
}
注意看 (rb->head + 1) & (rb->size - 1) 这个操作。它要求缓冲区大小必须是2的幂(如64、128、256、512)。这样可以用位运算代替取模运算,效率高很多。我在项目中习惯用256字节的环形缓冲区,既不会浪费内存,也足够应对大部分场景。
- 缓冲区大小必须是2的幂,用位运算实现循环
- 读写操作必须原子化,或者在关中断下进行
- 区分“满”和“空”的状态:用count计数,或者留一个空位
- 中断服务函数中只写不读,应用层只读不写,避免竞争
4.6 三种模式的统一HAL接口
好了,现在三种模式都实现了,环形缓冲区也准备好了。怎么把它们统一成一个HAL接口呢?
我的做法是定义一个配置结构体,里面包含模式选择、缓冲区指针、回调函数等。初始化时根据配置选择不同的底层实现。
// UART HAL配置结构体
typedef enum {
UART_MODE_POLLING = 0,
UART_MODE_INTERRUPT,
UART_MODE_DMA
} UART_Mode_t;
typedef struct {
UART_TypeDef *Instance; // UART外设基地址
UART_Mode_t Mode; // 工作模式
uint32_t BaudRate; // 波特率
RingBuf_t *pTxRingBuf; // 发送环形缓冲区
RingBuf_t *pRxRingBuf; // 接收环形缓冲区
void (*RxCallback)(uint8_t data); // 接收回调
void (*TxCompleteCallback)(void); // 发送完成回调
} UART_Config_t;
// 统一的发送接口
bool UART_Send(UART_HandleTypeDef *huart, uint8_t *pData, uint32_t Size) {
switch (huart->Config.Mode) {
case UART_MODE_POLLING:
// 轮询发送
for (uint32_t i = 0; i < Size; i++) {
UART_Polling_SendByte(huart, pData[i]);
}
return true;
case UART_MODE_INTERRUPT:
// 中断发送:将数据写入环形缓冲区,使能发送中断
for (uint32_t i = 0; i < Size; i++) {
if (!RingBuf_Write(huart->Config.pTxRingBuf, pData[i])) {
return false; // 缓冲区满
}
}
huart->Instance->CR1 |= UART_CR1_TCIE;
return true;
case UART_MODE_DMA:
// DMA发送
UART_DMA_Send(huart, pData, Size);
return true;
default:
return false;
}
}
这样,应用层调用 UART_Send() 时,完全不需要关心底层是轮询还是DMA。换模式?改一下配置结构体里的 Mode 字段就行。
4.7 知识体系总览
下面这张图,把UART驱动HAL化的整体结构梳理清楚了。你可以把它当作一个设计蓝图。
从这张图可以看得很清楚:应用层在最上面,它只跟HAL API打交道。HAL API根据配置的模式,分发到不同的底层实现。每个底层实现都直接操作硬件寄存器,但对外暴露的接口是完全一致的。
嗯,这就是UART驱动HAL化的精髓。轮询、中断、DMA,三种模式各有优劣,没有银弹。但通过合理的抽象,你可以让它们在同一个框架下和谐共存。环形缓冲区则是连接中断/DMA和应用层的桥梁,设计得好不好,直接影响到系统的稳定性和性能。
我曾经在一个项目中,因为环形缓冲区设计得太小(只有32字节),导致高速数据接收时频繁丢包。后来改成256字节,问题就解决了。所以,缓冲区大小的选择也很关键,要根据你的数据速率和CPU处理能力来权衡。