第4章:UART驱动封装实战:环形缓冲区设计、波特率动态配置、DMA传输与中断协同
UART这个外设,说简单也简单,说复杂也复杂。我见过不少工程师,写个轮询收发没问题,但一旦要上中断、上DMA,就开始手忙脚乱。说白了,UART驱动的核心不在于怎么配置寄存器,而在于怎么把数据流管理好。
这一章,咱们就围绕三个实战点展开:环形缓冲区怎么设计才不丢数据、波特率怎么在运行时动态切换、DMA与中断怎么协同工作。嗯,这些都是我在项目里踩过坑的地方。
4.1 环形缓冲区:UART驱动的“蓄水池”
先问个问题:为什么需要环形缓冲区?
你想想看,CPU处理数据的速度和UART接收数据的速度,往往不在一个节奏上。如果每次收到一个字节就立刻处理,那CPU就被绑死了。如果等收完再处理,又可能丢数据。环形缓冲区就是中间那个“蓄水池”,让生产和消费解耦。
4.1.1 数据结构设计
我个人习惯用这种结构体来管理环形缓冲区:
typedef struct {
uint8_t *buffer; // 数据存储区
uint32_t size; // 缓冲区大小(必须是2的幂)
volatile uint32_t head; // 写指针
volatile uint32_t tail; // 读指针
} ring_buffer_t;
这里有个关键点:size为什么必须是2的幂?因为可以用位运算代替取模,效率高很多。我在一个低功耗项目里,CPU主频只有8MHz,用位运算比取模快了将近5倍。
4.1.2 核心操作函数
写指针和读指针的移动,用这个宏:
#define RB_INC(idx, size) ((idx + 1) & (size - 1))
判断空和满:
static inline bool rb_is_empty(ring_buffer_t *rb) {
return rb->head == rb->tail;
}
static inline bool rb_is_full(ring_buffer_t *rb) {
return RB_INC(rb->head, rb->size) == rb->tail;
}
写入一个字节:
bool rb_put(ring_buffer_t *rb, uint8_t data) {
uint32_t next = RB_INC(rb->head, rb->size);
if (next == rb->tail) {
return false; // 缓冲区满,丢数据
}
rb->buffer[rb->head] = data;
rb->head = next;
return true;
}
rb_put,但没加 volatile 修饰 head 和 tail。结果编译器优化后,中断里改了 head,主循环却读不到新值。嗯,从此以后我再也不敢漏掉 volatile 了。
4.1.3 多生产者/单消费者场景
UART接收中断是生产者,主循环是消费者。这种场景下,只需要保证写操作不被读操作打断就行。我的做法是:
- 中断里只写 head,不读 tail
- 主循环里只读 head,只写 tail
- 这样就不需要关中断,性能好很多
4.2 波特率动态配置:运行时切换的陷阱
波特率动态配置,说白了就是系统运行过程中,根据通信对象改变UART的速率。比如和GPS模块通信用9600,和蓝牙模块通信用115200。
4.2.1 配置流程
我建议的步骤是:
- 停止收发:先关掉UART的发送和接收使能
- 等待当前传输完成:检查发送移位寄存器是否为空
- 重新配置波特率寄存器:写入新的分频值
- 重新使能:打开收发使能
代码示例:
void uart_set_baudrate(uart_t *uart, uint32_t baud) {
// 1. 停止收发
uart->regs->CR1 &= ~(USART_CR1_TE | USART_CR1_RE);
// 2. 等待发送完成
while (!(uart->regs->SR & USART_SR_TC));
// 3. 计算并设置波特率
uint32_t div = uart->pclk / baud;
uart->regs->BRR = div;
// 4. 重新使能
uart->regs->CR1 |= (USART_CR1_TE | USART_CR1_RE);
}
4.2.2 避坑指南
我曾经在一个项目里,动态切换波特率后,发现第一个字节总是错的。查了半天,原来是切换后没有清空接收移位寄存器里的残留数据。解决方案很简单:切换前读一次DR寄存器,把残留数据扔掉。
4.3 DMA传输与中断协同:让CPU喘口气
UART加上DMA,才是真正的高性能方案。CPU只需要在DMA传输完成时处理一次,中间完全不用管。
4.3.1 架构设计
我常用的方案是:
- 接收:DMA循环模式 + 半满/全满中断
- 发送:DMA普通模式 + 传输完成中断
接收用循环模式,相当于硬件帮你维护了一个环形缓冲区。DMA每收满一半缓冲区,触发一次中断,CPU去处理前半段数据。这样CPU的负载非常均匀。
4.3.2 中断优先级分配
这里有个容易忽略的点:中断优先级怎么配?
| 中断类型 | 推荐优先级 | 说明 |
|---|---|---|
| DMA接收半满中断 | 高 | 数据必须及时搬走,否则会被覆盖 |
| DMA接收全满中断 | 高 | 同上 |
| DMA发送完成中断 | 中 | 可以稍微延迟,不影响接收 |
| UART错误中断 | 最高 | 溢出、帧错误等需要立即处理 |
4.3.3 代码框架
初始化DMA接收:
void uart_dma_rx_init(uart_t *uart, uint8_t *buf, uint32_t len) {
// 配置DMA为循环模式
dma_channel_config_t cfg = {
.src_addr = &uart->regs->DR,
.dst_addr = buf,
.size = len,
.circular = true,
.half_int = true, // 半满中断
.full_int = true // 全满中断
};
dma_channel_init(uart->dma_rx_ch, &cfg);
// 使能UART的DMA接收
uart->regs->CR3 |= USART_CR3_DMAR;
}
中断处理:
void uart_dma_rx_irq_handler(uart_t *uart) {
uint32_t remaining = dma_get_remaining(uart->dma_rx_ch);
uint32_t processed = uart->dma_buf_size - remaining;
// 计算本次要处理的数据区间
if (processed > uart->last_processed) {
// 有新数据到达,交给上层处理
uint32_t start = uart->last_processed;
uint32_t end = processed;
uart->data_callback(uart->dma_buf + start, end - start);
uart->last_processed = end;
}
// 如果绕回,处理尾部数据
if (processed < uart->last_processed) {
// 处理 [last_processed, size) 和 [0, processed)
uart->data_callback(uart->dma_buf + uart->last_processed,
uart->dma_buf_size - uart->last_processed);
uart->data_callback(uart->dma_buf, processed);
uart->last_processed = processed;
}
}
4.4 整体架构图
下面这张图,把UART驱动的三层结构画清楚了:
4.5 性能对比
最后,我用一个表格总结三种模式的性能差异:
| 模式 | CPU占用率 | 最大波特率 | 适用场景 |
|---|---|---|---|
| 轮询模式 | 100%(阻塞) | 115200 | 调试、低速率通信 |
| 中断模式 | 10%~30% | 921600 | 中等速率、数据量不大 |
| DMA+中断 | <5% | 3Mbps+ | 高速率、大数据量 |
嗯,看到这个表你就明白了。如果你的系统里UART只是用来打印日志,轮询就够了。但如果是和传感器、GPS模块、蓝牙模块通信,我建议直接上DMA+中断方案。前期多花点时间封装好,后面省心很多。
这一章的内容就到这里。环形缓冲区、波特率动态配置、DMA与中断协同,这三个点你吃透了,UART驱动这块基本就稳了。