4、UART驱动封装:异步串口收发环形缓冲区设计与接口封装
UART 驱动,说白了就是让单片机跟外界“说话”的通道。很多初学者写串口程序,都是轮询收、轮询发,CPU 被死死绑住。我早年做第一个产品时就这么干,结果上位机一秒钟发几十包数据,单片机直接卡死——从那以后,我再也不敢不用环形缓冲区了。
这一章,我们就来聊聊怎么把 UART 驱动封装好,核心就是环形缓冲区。你想想看,收数据时不丢字节,发数据时不阻塞主循环,这才是正经的嵌入式做法。
4.1 为什么需要环形缓冲区?
先看一个场景:你的单片机通过 UART 接收传感器数据,每 10ms 来一帧,每帧 20 字节。如果用最简单的查询方式,主循环里必须不停地读寄存器。一旦主循环被其他任务拖了几毫秒,数据就丢了。
环形缓冲区(Ring Buffer)能解决这个问题。它本质上是一块固定大小的内存,配合两个指针(读指针和写指针),实现先进先出的数据暂存。中断服务程序只管往缓冲区里塞数据,主循环只管从缓冲区里取数据,互不干扰。
核心思想:生产者和消费者模式。中断是生产者,主循环是消费者。缓冲区就是那个“仓库”。
4.2 环形缓冲区的数据结构设计
我习惯用一个结构体来管理环形缓冲区。这样封装性好,移植也方便。
/* ring_buffer.h */
#ifndef RING_BUFFER_H
#define RING_BUFFER_H
#include <stdint.h>
/* 缓冲区大小,必须是2的幂,方便取模运算 */
#define RBUF_SIZE 256
typedef struct {
uint8_t buffer[RBUF_SIZE];
volatile uint16_t head; /* 写指针,中断中修改,加volatile */
volatile uint16_t tail; /* 读指针,主循环中修改 */
} ring_buffer_t;
/* 接口函数声明 */
void rbuf_init(ring_buffer_t *rb);
uint8_t rbuf_is_empty(ring_buffer_t *rb);
uint8_t rbuf_is_full(ring_buffer_t *rb);
uint8_t rbuf_write(ring_buffer_t *rb, uint8_t data);
uint8_t rbuf_read(ring_buffer_t *rb, uint8_t *data);
uint16_t rbuf_available(ring_buffer_t *rb);
#endif /* RING_BUFFER_H */
这里有个细节:head 和 tail 我加了 volatile。为什么?因为 head 在中断里被修改,编译器可能优化掉对它的读取。不加 volatile,主循环可能永远看不到新数据。嗯,这里要注意。
4.3 环形缓冲区的实现
实现部分其实不复杂,但有几个坑。我一个个说。
4.3.1 初始化与空满判断
/* ring_buffer.c */
#include "ring_buffer.h"
void rbuf_init(ring_buffer_t *rb) {
rb->head = 0;
rb->tail = 0;
}
uint8_t rbuf_is_empty(ring_buffer_t *rb) {
return (rb->head == rb->tail);
}
uint8_t rbuf_is_full(ring_buffer_t *rb) {
/* 当 tail 追上 head 时,认为满 */
return (((rb->head + 1) & (RBUF_SIZE - 1)) == rb->tail);
}
注意看 rbuf_is_full 的实现。我没有用 head + 1 == tail,而是用了 & (RBUF_SIZE - 1)。这是因为缓冲区大小是 2 的幂,位与运算比取模快得多。我在项目中测试过,在 72MHz 的 STM32 上,这个优化能省下几十个时钟周期。
我曾经踩过的坑:一开始我用 head == tail 判断空和满,结果发现空和满的状态完全一样!后来才意识到,必须牺牲一个存储单元来区分。也就是说,缓冲区实际可用大小是 RBUF_SIZE - 1。
4.3.2 写入与读取
uint8_t rbuf_write(ring_buffer_t *rb, uint8_t data) {
uint16_t next_head;
next_head = (rb->head + 1) & (RBUF_SIZE - 1);
if (next_head == rb->tail) {
return 0; /* 缓冲区满,写入失败 */
}
rb->buffer[rb->head] = data;
rb->head = next_head;
return 1; /* 写入成功 */
}
uint8_t rbuf_read(ring_buffer_t *rb, uint8_t *data) {
if (rb->head == rb->tail) {
return 0; /* 缓冲区空,读取失败 */
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) & (RBUF_SIZE - 1);
return 1; /* 读取成功 */
}
uint16_t rbuf_available(ring_buffer_t *rb) {
return (uint16_t)((rb->head - rb->tail) & (RBUF_SIZE - 1));
}
写入时,先计算下一个 head 位置,如果等于 tail,说明满了。读取时,先判断是否为空,不为空才取数据。这两个函数都是 O(1) 的,效率很高。
4.4 UART 驱动封装:分层设计
有了环形缓冲区,我们就可以封装 UART 驱动了。我个人喜欢分两层:
- 硬件抽象层(HAL):直接操作寄存器,提供最底层的收发函数。
- 驱动层:基于环形缓冲区,提供中断驱动的收发接口。
下面这张图展示了整体架构:
4.5 UART 驱动接口封装
下面是我常用的 UART 驱动封装。注意,这里只展示核心接口,具体寄存器操作因芯片而异。
/* uart_driver.h */
#ifndef UART_DRIVER_H
#define UART_DRIVER_H
#include "ring_buffer.h"
/* 定义两个环形缓冲区:一个收,一个发 */
typedef struct {
ring_buffer_t rx_buf; /* 接收缓冲区 */
ring_buffer_t tx_buf; /* 发送缓冲区 */
uint8_t tx_busy; /* 发送忙标志 */
} uart_t;
/* 初始化UART硬件和缓冲区 */
void uart_init(uart_t *uart, uint32_t baudrate);
/* 发送一字节(非阻塞,放入缓冲区) */
void uart_send_byte(uart_t *uart, uint8_t data);
/* 发送多字节(非阻塞) */
void uart_send_data(uart_t *uart, uint8_t *data, uint16_t len);
/* 接收一字节(从缓冲区读取) */
uint8_t uart_receive_byte(uart_t *uart, uint8_t *data);
/* 查询接收缓冲区中可用字节数 */
uint16_t uart_available(uart_t *uart);
/* 中断服务函数(由UART中断调用) */
void uart_irq_handler(uart_t *uart);
#endif /* UART_DRIVER_H */
4.6 中断服务函数的实现
中断服务函数是驱动的心脏。我把它单独拎出来讲。
/* uart_driver.c 中的中断处理 */
void uart_irq_handler(uart_t *uart) {
uint8_t data;
uint8_t status;
/* 假设寄存器读取函数 */
status = UART->SR; /* 状态寄存器 */
data = UART->DR; /* 数据寄存器 */
/* 接收中断 */
if (status & UART_SR_RXNE) {
/* 把收到的字节塞入接收缓冲区 */
if (!rbuf_write(&uart->rx_buf, data)) {
/* 缓冲区满了,可以在这里做溢出计数 */
uart->rx_overflow++;
}
}
/* 发送中断(发送保持寄存器空) */
if (status & UART_SR_TXE) {
if (rbuf_read(&uart->tx_buf, &data)) {
/* 还有数据要发,继续发送 */
UART->DR = data;
} else {
/* 没有数据了,关闭发送中断 */
UART->CR1 &= ~UART_CR1_TXEIE;
uart->tx_busy = 0;
}
}
}
个人经验:发送中断不要一直开着。只有在有数据要发时才使能,发完立刻关闭。否则中断会一直触发,浪费 CPU。我见过有人开着发送中断空转,CPU 占用率直接飙到 30%。
4.7 发送函数的实现细节
发送函数需要配合中断。看代码:
void uart_send_byte(uart_t *uart, uint8_t data) {
/* 关中断,防止竞争 */
__disable_irq();
if (uart->tx_busy == 0) {
/* 发送器空闲,直接写入寄存器 */
uart->tx_busy = 1;
UART->DR = data;
/* 使能发送中断 */
UART->CR1 |= UART_CR1_TXEIE;
} else {
/* 发送器忙,放入缓冲区 */
rbuf_write(&uart->tx_buf, data);
}
__enable_irq();
}
这里有个关键点:先判断发送器是否空闲。如果空闲,直接写寄存器,省掉一次中断。如果忙,才放入缓冲区。这样能提高小数据量时的效率。
4.8 接口封装总结
我把整个 UART 驱动的接口整理成一张表,方便你对照:
| 接口函数 | 功能 | 调用位置 | 是否阻塞 |
|---|---|---|---|
uart_init() |
初始化硬件和缓冲区 | 主循环初始化 | 否 |
uart_send_byte() |
发送一字节 | 任意位置 | 否 |
uart_send_data() |
发送多字节 | 任意位置 | 否 |
uart_receive_byte() |
接收一字节 | 主循环 | 否 |
uart_available() |
查询可读字节数 | 主循环 | 否 |
uart_irq_handler() |
中断服务函数 | 中断向量表 | — |
4.9 避坑指南
最后,分享几个我实际项目中踩过的坑:
- 缓冲区大小选择:不是越大越好。256 字节通常够用,但如果你处理高速数据(比如 1Mbps),建议用 512 或 1024。我有个项目用 128 字节,结果在高负载下频繁溢出。
- 临界区保护:中断和主循环共享的变量,一定要关中断保护。我见过有人忘了关中断,结果 head 指针被中断修改到一半,主循环读到了错误值。
- 发送中断使能时机:不要在初始化时就使能发送中断。等有数据要发时再使能,发完立刻关闭。否则你会收到一堆空中断。
- 缓冲区满的处理:不要直接丢弃数据。我习惯加一个溢出计数器,调试时通过串口打印出来,能帮你发现缓冲区是否偏小。
我曾经犯过的错:有一次我把环形缓冲区的 head 和 tail 定义成了 uint8_t,缓冲区大小 256。结果 head 从 255 加 1 变成 0,但 tail 还是 255,逻辑全乱了。从那以后,我统一用 uint16_t,缓冲区大小也严格限制为 2 的幂。
好了,这一章的内容就到这里。环形缓冲区加上中断驱动,是 UART 封装的标准做法。你可以在自己的项目里直接套用这套代码,稍作修改就能跑起来。记住,好的驱动设计,应该是“拿来就能用,用起来不出错”。