4. UART串口驱动:轮询模式、中断模式、DMA模式实现,环形缓冲区设计与波特率误差分析
UART串口,做嵌入式的人几乎天天打交道。调试打印、与传感器通信、升级固件,都离不开它。但说实话,很多人只是调个库函数就完事了,一旦遇到丢数据、波特率对不上、高并发收发卡死,就抓瞎了。
这一章,我带你把这层窗户纸捅破。三种模式怎么选?环形缓冲区怎么设计才不踩坑?波特率误差到底多少算安全?咱们一个一个来。
4.1 三种驱动模式:轮询、中断、DMA
这三种模式,说白了就是CPU参与收发的程度不同。我画了张图,你看一眼就明白了。
4.2 轮询模式实现
轮询模式最简单,但代价也最大。CPU一直在那死等,啥也干不了。我一般只在系统刚启动、RTOS还没跑起来的时候用一下。
// 轮询发送一个字节
void uart_poll_send(uint8_t data) {
// 等待发送缓冲区空
while (!(UART->SR & UART_SR_TXE));
UART->DR = data;
}
// 轮询接收一个字节
uint8_t uart_poll_recv(void) {
// 等待接收到数据
while (!(UART->SR & UART_SR_RXNE));
return (uint8_t)(UART->DR & 0xFF);
}
4.3 中断模式实现
中断模式是实际项目中最常用的。CPU不用死等,来了数据才处理。但这里有个关键点:中断服务函数里千万别干重活。
// 中断接收服务函数
void UART_IRQHandler(void) {
uint8_t data;
if (UART->SR & UART_SR_RXNE) {
data = (uint8_t)(UART->DR & 0xFF);
// 把数据扔进环形缓冲区,立刻退出中断
ringbuf_push(&rx_ring, data);
}
// 发送中断同理
if (UART->SR & UART_SR_TXE) {
if (ringbuf_pop(&tx_ring, &data)) {
UART->DR = data;
} else {
// 没数据要发了,关掉发送中断
UART->CR1 &= ~UART_CR1_TXEIE;
}
}
}
4.4 DMA模式实现
DMA模式是性能王者。CPU只需要配置好DMA通道,剩下的搬运工作硬件全包了。我做过一个项目,用115200波特率连续收发大数据包,中断模式CPU占用率大概15%,换成DMA后直接降到0.5%以下。
// DMA发送配置示例
void uart_dma_send(uint8_t *buf, uint32_t len) {
// 等待上一次DMA传输完成
while (DMA->ISR & DMA_ISR_TCIF1);
// 配置DMA源地址、目的地址、长度
DMA->CPAR1 = (uint32_t)&UART->DR; // 外设地址
DMA->CMAR1 = (uint32_t)buf; // 内存地址
DMA->CNDTR1 = len; // 传输长度
// 使能DMA通道
DMA->CCR1 |= DMA_CCR1_EN;
// 使能UART的DMA发送请求
UART->CR3 |= UART_CR3_DMAT;
}
4.5 环形缓冲区设计
环形缓冲区,说白了就是一个固定大小的数组,加上读写指针。写指针追着读指针转圈圈。设计得好,中断和任务之间可以无缝配合。
typedef struct {
uint8_t *buffer; // 缓冲区基地址
uint32_t size; // 缓冲区大小(必须是2的幂)
volatile uint32_t head; // 写指针(生产者)
volatile uint32_t tail; // 读指针(消费者)
} 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;
}
// 写入一个字节
int ringbuf_push(ringbuf_t *rb, uint8_t data) {
uint32_t next = (rb->head + 1) & (rb->size - 1);
if (next == rb->tail) {
return -1; // 缓冲区满了
}
rb->buffer[rb->head] = data;
rb->head = next;
return 0;
}
// 读取一个字节
int ringbuf_pop(ringbuf_t *rb, uint8_t *data) {
if (rb->head == rb->tail) {
return -1; // 缓冲区空了
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) & (rb->size - 1);
return 0;
}
n % size 换成 n & (size - 1),前提是size必须是2的幂。
4.6 波特率误差分析
波特率误差,是串口通信里最容易忽略但又最致命的问题。两个设备波特率对不上,数据就全乱了。
波特率误差的来源主要有三个:
- 时钟源精度:内部RC振荡器精度一般在±1%~±3%,外部晶振可以做到±50ppm甚至更高
- 分频器量化误差:分频系数必须是整数,导致实际波特率与目标值有偏差
- 温度漂移:温度变化会影响时钟频率
我整理了一个常见波特率的误差表,你看看就明白了:
| 目标波特率 | 时钟频率 | 分频系数 | 实际波特率 | 误差 | 是否可用 |
|---|---|---|---|---|---|
| 9600 | 16MHz | 104.17 | 9615 | +0.16% | ✅ 安全 |
| 115200 | 16MHz | 8.68 | 111111 | -3.55% | ❌ 危险 |
| 115200 | 72MHz | 39.06 | 115385 | +0.16% | ✅ 安全 |
| 921600 | 72MHz | 4.88 | 922000 | +0.04% | ✅ 安全 |
为什么会这样?因为串口是在每个bit的中间采样,误差累积到最后一个bit时,如果偏差超过半个bit周期,采样点就跑到下一个bit上去了。你想想看,8个数据位加1个停止位,总共9个bit,误差累积9倍,能不乱吗?
// 波特率计算示例(以STM32为例)
// 目标:115200bps,时钟72MHz
// 公式:baud = fck / (16 * USARTDIV)
// USARTDIV = 72000000 / (16 * 115200) = 39.0625
// 整数部分:39
// 小数部分:0.0625 * 16 = 1(4位小数精度)
// 所以 USARTDIV = 0x271
// 实际波特率 = 72000000 / (16 * 39.0625) = 115200
// 误差 = 0% (完美匹配!)
4.7 三种模式的选择策略
说了这么多,到底怎么选?我一般按这个原则来:
- 轮询模式:只在系统初始化阶段、或者调试时临时用一下。产品代码里尽量别用。
- 中断模式:最通用的方案。波特率不超过460800、数据量不大的场景,首选中断。
- DMA模式:高速率(921600以上)、大数据量、或者CPU负载已经很紧张的项目,上DMA。
嗯,这一章的内容就到这。环形缓冲区的代码你可以直接拿去用,波特率误差表也建议收藏一下。下次调串口遇到问题,先查查是不是波特率对不上——我敢说,一半的串口问题都是这个原因。