第4章 UART驱动HAL封装:轮询、中断与DMA三模式设计
UART这个外设,说简单也简单,说复杂也复杂。我做了这么多年嵌入式,几乎每个项目都离不开它。调试打印、协议通信、固件升级……UART就像嵌入式世界的"通用语言"。
但说实话,很多工程师写UART驱动就是"能用就行"。轮询模式一把梭,中断处理写得稀里糊涂,DMA更是碰都不碰。这样搞,小项目还行,一旦上了产品级系统,问题就全冒出来了。
这一章,我就带你把UART的HAL封装彻底讲透。三种模式——轮询、中断、DMA,每种模式的接口怎么设计,波特率怎么配,数据收发怎么搞,咱们一个一个来。
4.1 UART HAL的接口抽象
先说说接口设计。我个人习惯,把UART的HAL接口分成三层:
- 底层硬件操作层——直接读写寄存器,跟芯片绑死
- HAL抽象层——封装成统一的API,屏蔽芯片差异
- 应用层——只管调用,不管底层是STM32还是GD32
咱们重点在中间这层。来看一个典型的UART HAL接口定义:
/* UART HAL 接口定义 */
typedef struct {
uint32_t baudrate; /* 波特率 */
uint8_t data_bits; /* 数据位:5/6/7/8 */
uint8_t stop_bits; /* 停止位:1/2 */
uint8_t parity; /* 校验位:NONE/ODD/EVEN */
uint8_t flow_ctrl; /* 流控:NONE/RTS_CTS */
uint8_t mode; /* 模式:POLL/IRQ/DMA */
} uart_config_t;
/* HAL API 原型 */
int32_t uart_init(uart_handle_t *handle, uart_config_t *config);
int32_t uart_deinit(uart_handle_t *handle);
int32_t uart_send(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_recv(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_send_it(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_recv_it(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_send_dma(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_recv_dma(uart_handle_t *handle, uint8_t *data, uint32_t len);
void uart_irq_handler(uart_handle_t *handle);
void uart_dma_tx_cplt_cb(uart_handle_t *handle);
void uart_dma_rx_cplt_cb(uart_handle_t *handle);
你可能会问:为什么要把三种模式分开成不同的API?统一成一个不行吗?
嗯,这里有个坑。我曾经在一个项目里试图用"万能接口",传个参数决定用哪种模式。结果呢?代码耦合得一塌糊涂,中断回调里还要判断模式,DMA完成回调里还要处理轮询逻辑……最后我自己都看不下去了。
我的建议是:接口可以统一命名规范,但底层实现必须分开。轮询就是轮询,中断就是中断,DMA就是DMA。上层调用时,根据场景选对应的API就行。
4.2 波特率配置:看似简单,实则暗藏玄机
波特率配置,说白了就是算一个分频系数。但不同芯片的算法不一样,这也是HAL层要屏蔽的差异。
以常见的ARM Cortex-M芯片为例,波特率计算公式一般是:
/* 波特率计算(以STM32 USART为例) */
/* 公式:baud = fclk / (16 * usartdiv) */
/* 其中 usartdiv 包含整数部分和小数部分 */
uint32_t uart_calc_bauddiv(uint32_t fclk, uint32_t baudrate)
{
uint32_t div = 0;
uint32_t tmp = 0;
/* 整数部分 */
div = fclk / (16 * baudrate);
/* 小数部分(取整到最近的1/16) */
tmp = (fclk * 16) / (16 * baudrate) - div * 16;
return (div << 4) | (tmp & 0x0F);
}
这里有个细节:小数部分的舍入。我遇到过一个问题,某个芯片的UART在115200波特率下,小数部分舍入误差导致实际波特率偏差超过2%。这在普通通信中可能没问题,但如果你在做Modbus或者DMX512这种对时序敏感的应用,就会丢帧。
所以,HAL层在配置波特率时,最好加一个误差检查:
/* 波特率误差检查 */
int32_t uart_check_baudrate(uint32_t fclk, uint32_t baudrate)
{
uint32_t actual = fclk / (16 * (fclk / (16 * baudrate)));
int32_t error = (int32_t)(actual - baudrate) * 100 / (int32_t)baudrate;
if (error > 2 || error < -2) {
return -1; /* 误差超限 */
}
return 0;
}
4.3 轮询模式:简单但别滥用
轮询模式,说白了就是CPU死等。发送时循环检查发送完成标志,接收时循环检查数据到达标志。
代码很简单:
/* 轮询模式发送 */
int32_t uart_send_poll(uart_handle_t *handle, uint8_t *data, uint32_t len)
{
for (uint32_t i = 0; i < len; i++) {
/* 等待发送缓冲区空 */
while (!(handle->reg->SR & UART_SR_TXE));
/* 发送数据 */
handle->reg->DR = data[i];
}
/* 等待发送完成 */
while (!(handle->reg->SR & UART_SR_TC));
return 0;
}
/* 轮询模式接收 */
int32_t uart_recv_poll(uart_handle_t *handle, uint8_t *data, uint32_t len)
{
for (uint32_t i = 0; i < len; i++) {
/* 等待数据到达 */
while (!(handle->reg->SR & UART_SR_RXNE));
/* 读取数据 */
data[i] = (uint8_t)(handle->reg->DR & 0xFF);
}
return 0;
}
轮询模式最大的问题是什么?阻塞!CPU在等待期间啥也干不了。你想想看,如果波特率是9600,发送1KB数据,CPU要白白等差不多1秒钟。这在实时系统里简直是灾难。
我的建议:轮询模式只用在两个场景——系统初始化阶段的调试打印,或者低优先级的数据收发。其他情况,老老实实用中断或DMA。
4.4 中断模式:最常用的方案
中断模式,就是让硬件在数据到达或发送完成时通知CPU。CPU不用死等,可以干别的事。
中断模式的HAL设计,核心在于环形缓冲区。我习惯在handle结构体里放两个环形缓冲区,一个收一个发:
/* UART handle结构体(中断模式) */
typedef struct {
uart_reg_t *reg; /* 寄存器基址 */
/* 发送环形缓冲区 */
uint8_t tx_buf[UART_TX_BUF_SIZE];
uint32_t tx_head;
uint32_t tx_tail;
uint32_t tx_count;
/* 接收环形缓冲区 */
uint8_t rx_buf[UART_RX_BUF_SIZE];
uint32_t rx_head;
uint32_t rx_tail;
uint32_t rx_count;
/* 回调函数 */
void (*tx_cplt_cb)(void *arg);
void (*rx_cplt_cb)(void *arg, uint8_t data);
void *cb_arg;
} uart_handle_t;
中断服务函数怎么写?我见过很多工程师把整个收发逻辑都塞进ISR里,结果中断响应时间飙到几十微秒。正确的做法是:ISR里只做最必要的事——把数据搬进缓冲区,然后清标志位。
/* UART中断服务函数(精简版) */
void uart_irq_handler(uart_handle_t *handle)
{
uint32_t sr = handle->reg->SR;
/* 接收中断 */
if (sr & UART_SR_RXNE) {
uint8_t data = (uint8_t)(handle->reg->DR & 0xFF);
/* 放入环形缓冲区 */
uint32_t next = (handle->rx_head + 1) % UART_RX_BUF_SIZE;
if (next != handle->rx_tail) {
handle->rx_buf[handle->rx_head] = data;
handle->rx_head = next;
handle->rx_count++;
}
/* 调用回调 */
if (handle->rx_cplt_cb) {
handle->rx_cplt_cb(handle->cb_arg, data);
}
}
/* 发送中断(发送缓冲区空) */
if (sr & UART_SR_TXE) {
if (handle->tx_count > 0) {
handle->reg->DR = handle->tx_buf[handle->tx_tail];
handle->tx_tail = (handle->tx_tail + 1) % UART_TX_BUF_SIZE;
handle->tx_count--;
} else {
/* 没有数据要发了,关闭发送中断 */
handle->reg->CR1 &= ~UART_CR1_TXEIE;
/* 发送完成回调 */
if (handle->tx_cplt_cb) {
handle->tx_cplt_cb(handle->cb_arg);
}
}
}
}
4.5 DMA模式:高性能的终极方案
DMA模式,就是把数据搬运的工作交给DMA控制器,CPU彻底解放。适合大数据量、高速率的场景,比如用UART做固件升级、日志输出等。
DMA模式的HAL设计,比中断模式多了一个东西——DMA通道的配置和管理。来看代码:
/* DMA模式发送 */
int32_t uart_send_dma(uart_handle_t *handle, uint8_t *data, uint32_t len)
{
/* 配置DMA */
dma_channel_config_t dma_cfg;
dma_cfg.src_addr = (uint32_t)data;
dma_cfg.dst_addr = (uint32_t)&handle->reg->DR;
dma_cfg.transfer_size = len;
dma_cfg.src_inc = DMA_ADDR_INC_ENABLE; /* 源地址递增 */
dma_cfg.dst_inc = DMA_ADDR_INC_DISABLE; /* 目标地址固定 */
dma_cfg.circular = DMA_MODE_NORMAL; /* 非循环模式 */
/* 启动DMA传输 */
dma_channel_start(handle->dma_tx_ch, &dma_cfg);
/* 使能UART的DMA发送 */
handle->reg->CR3 |= UART_CR3_DMAT;
return 0;
}
这里有个关键点:DMA传输完成后,一定要在回调里关闭UART的DMA使能位。不然下次传输时,硬件可能还在用上次的DMA配置。
4.6 三种模式的对比与选择
说了这么多,到底什么时候用哪种模式?我整理了一个表格,方便你对照:
| 特性 | 轮询模式 | 中断模式 | DMA模式 |
|---|---|---|---|
| CPU占用率 | 高(阻塞等待) | 低(仅ISR处理) | 极低(硬件搬运) |
| 实时性 | 差(阻塞) | 好(中断响应) | 好(后台传输) |
| 数据吞吐量 | 低 | 中 | 高 |
| 代码复杂度 | 低 | 中 | 高 |
| 适用场景 | 调试打印、低速率 | 常规通信、协议交互 | 大数据量、高速率 |
| 内存开销 | 无 | 环形缓冲区 | DMA描述符+缓冲区 |
我个人习惯是:调试阶段用轮询,产品阶段用中断,大数据场景用DMA。但这不是绝对的,具体还得看你的系统需求。
4.7 整体架构图
下面这张图,把UART HAL的整体架构和三种模式的数据流画出来了。你可以对照着理解:
4.8 实战中的几个关键点
最后,分享几个我在项目中踩过的坑,希望能帮你少走弯路:
- 波特率切换时要复位UART——有些芯片在运行中改波特率会导致数据错乱,最好先停掉UART,配置完再重新使能。
- 中断优先级要合理设置——UART中断优先级不要太高,否则会阻塞其他更紧急的中断(比如系统滴答定时器)。我一般设在中等级别。
- DMA缓冲区对齐——有些DMA控制器要求缓冲区地址按4字节对齐,不然会触发总线错误。这个在芯片手册里一般会写,但很容易被忽略。
- 环形缓冲区的大小要合理——太小容易溢出,太大浪费内存。我一般根据波特率和最大帧长度来算:缓冲区大小 = 波特率 / 10 * 最大响应时间(秒)。