4. UART驱动HAL封装:从轮询到DMA的完整实践
UART这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发器,复杂在于——你要在嵌入式系统里把它用好,就得面对三种模式的选择:轮询、中断、DMA。每种模式都有它的脾气。
我个人习惯把UART的HAL封装分成三层:硬件访问层、传输控制层、数据缓冲层。今天我们就一层层拆开来看。
- UART初始化与配置的HAL封装要点
- 轮询模式——简单但CPU占用高
- 中断模式——实时性好,适合不定长数据
- DMA传输模式——大吞吐量的最优解
- 环形缓冲区设计——解决数据生产与消费的速度匹配
4.1 UART初始化与配置的HAL封装
初始化这块,我见过太多人直接把寄存器操作扔给应用层。你想想看,如果每个模块都直接操作寄存器,那换芯片的时候得改多少代码?
我的做法是:在HAL层定义一个uart_config_t结构体,把所有可配置参数抽象出来。
/* uart_hal.h - UART HAL层配置结构体 */
typedef struct {
uint32_t baud_rate; /* 波特率:9600, 115200, 921600等 */
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=硬件流控(RTS/CTS) */
uint8_t mode; /* 模式:0=轮询, 1=中断, 2=DMA */
uint32_t rx_buf_size; /* 接收缓冲区大小 */
uint32_t tx_buf_size; /* 发送缓冲区大小 */
void (*rx_callback)(uint8_t data); /* 接收回调函数指针 */
void (*tx_callback)(void); /* 发送完成回调 */
} uart_config_t;
初始化函数长这样:
/* uart_hal.c - 初始化实现 */
int uart_init(uart_port_t port, const uart_config_t *config)
{
if (config == NULL) return -1;
/* 1. 使能UART外设时钟 */
uart_clock_enable(port);
/* 2. 配置GPIO复用为UART功能 */
uart_gpio_config(port);
/* 3. 设置波特率 - 内部通过分频器计算 */
uart_set_baudrate(port, config->baud_rate);
/* 4. 配置数据格式 */
uart_set_format(port, config->data_bits,
config->stop_bits, config->parity);
/* 5. 根据模式初始化传输控制层 */
switch (config->mode) {
case UART_MODE_POLLING:
/* 轮询模式不需要额外配置 */
break;
case UART_MODE_INTERRUPT:
uart_interrupt_init(port, config->rx_callback);
break;
case UART_MODE_DMA:
uart_dma_init(port, config->rx_buf_size, config->tx_buf_size);
break;
}
/* 6. 使能UART */
uart_enable(port);
return 0;
}
4.2 轮询模式——简单但别滥用
轮询模式说白了就是CPU死等。发送时循环检查TXE(发送寄存器空)标志,接收时循环检查RXNE(接收寄存器非空)标志。
/* 轮询模式发送 */
int uart_polling_send(uart_port_t port, const uint8_t *data, uint32_t len)
{
for (uint32_t i = 0; i < len; i++) {
/* 等待发送缓冲区空 */
while (!(uart_get_status(port) & UART_SR_TXE));
/* 写入数据 */
uart_write_data(port, data[i]);
}
/* 等待发送完成 */
while (!(uart_get_status(port) & UART_SR_TC));
return len;
}
/* 轮询模式接收 */
int uart_polling_recv(uart_port_t port, uint8_t *buf, uint32_t len, uint32_t timeout_ms)
{
uint32_t count = 0;
uint32_t tick_start = get_tick();
while (count < len) {
/* 检查超时 */
if ((get_tick() - tick_start) > timeout_ms) {
return -2; /* 超时返回 */
}
/* 检查是否有数据 */
if (uart_get_status(port) & UART_SR_RXNE) {
buf[count++] = uart_read_data(port);
tick_start = get_tick(); /* 收到数据后重置超时 */
}
}
return count;
}
轮询模式适合什么场景?说白了就是调试阶段和低速率、短数据的场合。比如打印日志、配置命令交互。你要是用轮询模式跑115200波特率、每秒收发几千字节,CPU就别干别的了。
4.3 中断模式——嵌入式的主力选手
中断模式是嵌入式系统里最常用的UART传输方式。它的核心思想是:CPU不等待,数据来了中断通知你。
我建议把中断处理分成两部分:
- ISR(中断服务函数):只做最轻量的事——读/写数据寄存器,清中断标志
- 回调函数:在ISR之外执行,处理数据解析、协议组包等耗时操作
/* 中断模式初始化 */
void uart_interrupt_init(uart_port_t port, void (*rx_callback)(uint8_t))
{
/* 保存回调函数指针 */
uart_hal_ctx[port].rx_callback = rx_callback;
/* 使能接收中断 */
uart_enable_interrupt(port, UART_INT_RXNE);
/* 配置NVIC优先级 */
NVIC_SetPriority(uart_get_irqn(port), 2);
NVIC_EnableIRQ(uart_get_irqn(port));
}
/* UART中断服务函数 */
void UART_IRQHandler(void)
{
uint32_t status = uart_get_status(UART1);
/* 接收中断 */
if (status & UART_SR_RXNE) {
uint8_t data = uart_read_data(UART1);
/* 调用回调 - 这里只做数据传递 */
if (uart_hal_ctx[UART1].rx_callback) {
uart_hal_ctx[UART1].rx_callback(data);
}
}
/* 发送中断 - 用于发送完一个字节后继续发下一个 */
if (status & UART_SR_TXE) {
/* 从发送缓冲区取下一个字节发送 */
uint8_t next_byte;
if (ringbuf_read(&uart_hal_ctx[UART1].tx_ring, &next_byte) == 0) {
uart_write_data(UART1, next_byte);
} else {
/* 缓冲区空了,关闭发送中断 */
uart_disable_interrupt(UART1, UART_INT_TXE);
}
}
}
- ISR要快进快出,不要在中断里做复杂处理
- 接收中断和发送中断分开使能,按需开启
- 发送中断只在有数据要发时才开启,发完立即关闭——这叫"按需中断"
我记得有个项目,同事把AT指令解析直接放在UART中断里,结果中断服务函数执行了5毫秒。系统里还有更高优先级的定时器中断,直接被堵死——电机控制周期从1ms变成了6ms,电机嗡嗡响。后来我把解析移到任务里,中断只做数据入队,问题就解决了。
4.4 DMA传输模式——大吞吐量的最优解
DMA模式,说白了就是让DMA控制器帮你搬数据,CPU可以去做别的事。适合大数据量传输,比如固件升级、高速数据采集。
DMA传输有两种常见配置:
| 传输方向 | DMA通道配置 | 触发源 | 传输完成中断 |
|---|---|---|---|
| UART RX → 内存 | 外设到内存,循环模式 | UART RXNE事件 | 半传输完成 + 传输完成 |
| 内存 → UART TX | 内存到外设,普通模式 | UART TXE事件 | 传输完成 |
/* DMA模式初始化 */
int uart_dma_init(uart_port_t port, uint32_t rx_buf_size, uint32_t tx_buf_size)
{
/* 分配DMA缓冲区 */
uart_hal_ctx[port].dma_rx_buf = malloc(rx_buf_size);
uart_hal_ctx[port].dma_tx_buf = malloc(tx_buf_size);
if (!uart_hal_ctx[port].dma_rx_buf || !uart_hal_ctx[port].dma_tx_buf) {
return -1; /* 内存分配失败 */
}
/* 配置DMA RX通道 - 循环模式 */
dma_channel_config_t rx_dma_cfg = {
.src_addr = (uint32_t)&UART1->DR,
.dst_addr = (uint32_t)uart_hal_ctx[port].dma_rx_buf,
.transfer_size = rx_buf_size,
.src_inc = 0, /* 外设地址不递增 */
.dst_inc = 1, /* 内存地址递增 */
.circular = 1, /* 循环模式 */
.half_irq = 1, /* 半传输完成中断 */
.complete_irq = 1 /* 传输完成中断 */
};
dma_channel_init(DMA_CH_RX, &rx_dma_cfg);
/* 配置DMA TX通道 - 普通模式 */
dma_channel_config_t tx_dma_cfg = {
.src_addr = (uint32_t)uart_hal_ctx[port].dma_tx_buf,
.dst_addr = (uint32_t)&UART1->DR,
.transfer_size = 0, /* 初始为0,发送时动态设置 */
.src_inc = 1,
.dst_inc = 0,
.circular = 0, /* 非循环 */
.complete_irq = 1
};
dma_channel_init(DMA_CH_TX, &tx_dma_cfg);
/* 使能UART的DMA请求 */
uart_enable_dma(port, UART_DMA_RX | UART_DMA_TX);
return 0;
}
4.5 环形缓冲区设计——数据流的"蓄水池"
环形缓冲区,也叫循环队列。它的作用就是解耦数据生产者和消费者。生产者(中断/DMA)往缓冲区写数据,消费者(应用任务)从缓冲区读数据,两者互不阻塞。
我设计环形缓冲区时,最关注三个指标:
- 缓冲区大小:要能容纳最大数据突发量
- 读写速度:生产者写入不能阻塞,消费者读取要高效
- 线程安全:中断和任务之间不能产生竞态
/* 环形缓冲区结构体 */
typedef struct {
uint8_t *buffer; /* 数据缓冲区 */
uint32_t size; /* 缓冲区大小(必须是2的幂) */
uint32_t head; /* 写指针 */
uint32_t tail; /* 读指针 */
uint32_t count; /* 当前数据量 */
} ringbuf_t;
/* 初始化 */
int ringbuf_init(ringbuf_t *rb, uint8_t *buf, uint32_t size)
{
/* 确保size是2的幂,方便取模运算 */
if ((size & (size - 1)) != 0) return -1;
rb->buffer = buf;
rb->size = size;
rb->head = 0;
rb->tail = 0;
rb->count = 0;
return 0;
}
/* 写入一个字节 - 中断中调用 */
int ringbuf_write(ringbuf_t *rb, uint8_t data)
{
uint32_t next_head;
/* 计算下一个写位置 */
next_head = (rb->head + 1) & (rb->size - 1);
/* 检查是否满 */
if (next_head == rb->tail) {
return -1; /* 缓冲区满,数据丢失 */
}
rb->buffer[rb->head] = data;
/* 内存屏障 - 确保数据写入完成后再更新指针 */
__DSB();
rb->head = next_head;
rb->count++;
return 0;
}
/* 读取一个字节 - 任务中调用 */
int ringbuf_read(ringbuf_t *rb, uint8_t *data)
{
if (rb->head == rb->tail) {
return -1; /* 缓冲区空 */
}
*data = rb->buffer[rb->tail];
__DSB();
rb->tail = (rb->tail + 1) & (rb->size - 1);
rb->count--;
return 0;
}
- 缓冲区大小必须是2的幂:这样可以用位与运算(&)代替取模(%),性能提升明显。我测试过,在Cortex-M4上,取模运算要12个周期,位与只要1个周期。
- 内存屏障不能省:在Cortex-M系列上,中断和主循环之间共享数据时,编译器可能会优化乱序执行。加上
__DSB()或__DMB()确保数据一致性。 - 满和空的判断要清晰:我习惯用
head == tail判空,用(head + 1) % size == tail判满。这样会浪费一个字节的空间,但逻辑清晰,不会混淆。
我曾经在一个物联网网关项目里,UART接收GPS数据,波特率115200,每秒大约输出10KB数据。一开始用中断+环形缓冲区,缓冲区设了2KB。结果GPS模块冷启动时,数据会突然爆发到20KB/s,缓冲区直接溢出。后来我把缓冲区扩大到8KB,并且加了水位告警——当缓冲区使用超过75%时,通知应用层加快处理速度。这才稳住了。
4.6 三种模式的对比与选型建议
| 特性 | 轮询模式 | 中断模式 | DMA模式 |
|---|---|---|---|
| CPU占用率 | 高(持续占用) | 低(仅中断时占用) | 极低(几乎不占用) |
| 实时性 | 差(受主循环影响) | 好(中断响应) | 好(硬件自动传输) |
| 数据吞吐量 | 低(受CPU速度限制) | 中(受中断频率限制) | 高(接近外设极限) |
| 代码复杂度 | 低 | 中 | 高 |
| 适用场景 | 调试、低速命令交互 | 通用通信、不定长数据 | 大数据量、高速传输 |
| 缓冲区需求 | 不需要 | 需要环形缓冲区 | 需要DMA缓冲区 |
选型建议其实很简单:
- 调试阶段:先用轮询模式,简单可靠,出问题好定位
- 产品开发:默认用中断模式+环形缓冲区,覆盖90%的场景
- 大数据量:比如固件OTA、日志记录、高速传感器数据采集,上DMA
uart_config_t.mode字段切换。这样同一个驱动代码,调试时用轮询,发布时切中断,性能不够时改DMA——不用改应用层代码。这就是HAL抽象的价值。