4、UART驱动封装:轮询模式与中断模式、环形缓冲区设计、DMA传输封装
UART,说白了就是串口。做单片机开发的人,没有谁离得开它。调试打印、跟传感器通信、对接蓝牙模块……哪哪都有它。但很多人写UART驱动,就是对着寄存器一顿操作,能用就行。等要换芯片平台了,或者要加个DMA功能,代码就得重写。
我个人习惯,从一开始就把UART驱动拆成三层:硬件抽象层、传输模式层、数据缓冲层。这样不管你是用轮询、中断还是DMA,上层调用的接口都一样。今天咱们就把这三层怎么搭,掰开揉碎了讲清楚。
核心思路:UART驱动封装的目标是——上层应用不关心底层用哪种传输模式,只调用 uart_send() / uart_recv() 就行。
4.1 轮询模式:最基础,但别小看它
轮询模式最简单,CPU一直盯着状态寄存器,等数据来了就收,等发送缓冲区空了就发。我在项目中遇到过一种情况:系统刚启动时,中断还没初始化好,这时候只能用轮询来打印启动日志。
轮询模式的封装,我建议做成这样:
// 硬件抽象层接口
typedef struct {
void (*init)(uint32_t baud);
void (*send_byte)(uint8_t data);
uint8_t (*recv_byte)(void);
uint8_t (*is_tx_idle)(void);
uint8_t (*is_rx_ready)(void);
} uart_hw_t;
// 轮询发送
void uart_poll_send(uart_hw_t *hw, const uint8_t *buf, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
while (!hw->is_tx_idle()); // 等发送缓冲区空
hw->send_byte(buf[i]);
}
}
// 轮询接收
uint8_t uart_poll_recv(uart_hw_t *hw, uint8_t *buf, uint32_t len, uint32_t timeout_ms) {
uint32_t start = get_tick();
uint32_t i = 0;
while (i < len) {
if (hw->is_rx_ready()) {
buf[i++] = hw->recv_byte();
start = get_tick(); // 收到数据就重置超时
} else if (get_tick() - start > timeout_ms) {
return i; // 超时,返回已收字节数
}
}
return len;
}
你看,这里我把硬件操作抽象成了函数指针。换芯片时,只需要重新实现 uart_hw_t 里的几个函数,上层代码完全不用动。
我的经验:轮询接收一定要加超时机制。我曾经有个项目没加超时,结果对方设备掉线了,程序就卡死在 while 循环里,看门狗都救不回来。
4.2 中断模式:效率提升的关键
轮询模式在数据量小的时候还行,一旦数据量大或者系统要处理多任务,CPU就被UART占死了。这时候就得用中断。
中断模式的核心是:收数据靠中断,发数据也靠中断。发送时,先把数据放到缓冲区,然后使能发送中断,每次中断发一个字节,发完自动关中断。
我建议的中断驱动结构:
typedef struct {
uart_hw_t *hw;
uint8_t tx_buf[UART_TX_BUF_SIZE];
volatile uint32_t tx_head;
volatile uint32_t tx_tail;
uint8_t rx_buf[UART_RX_BUF_SIZE];
volatile uint32_t rx_head;
volatile uint32_t rx_tail;
void (*tx_cplt_cb)(void);
void (*rx_cplt_cb)(uint8_t data);
} uart_int_t;
// 中断发送启动
void uart_int_send_start(uart_int_t *uart) {
if (uart->tx_head != uart->tx_tail) {
uart->hw->send_byte(uart->tx_buf[uart->tx_tail]);
uart->tx_tail = (uart->tx_tail + 1) % UART_TX_BUF_SIZE;
uart->hw->enable_tx_int(); // 使能发送中断
}
}
// 中断服务函数(示例)
void UART_IRQHandler(void) {
uart_int_t *uart = &g_uart1;
if (uart->hw->is_tx_int()) {
if (uart->tx_head != uart->tx_tail) {
uart->hw->send_byte(uart->tx_buf[uart->tx_head]);
uart->tx_head = (uart->tx_head + 1) % UART_TX_BUF_SIZE;
} else {
uart->hw->disable_tx_int(); // 发完了关中断
if (uart->tx_cplt_cb) uart->tx_cplt_cb();
}
}
if (uart->hw->is_rx_int()) {
uint8_t data = uart->hw->recv_byte();
uart->rx_buf[uart->rx_head] = data;
uart->rx_head = (uart->rx_head + 1) % UART_RX_BUF_SIZE;
if (uart->rx_cplt_cb) uart->rx_cplt_cb(data);
}
}
注意:中断服务函数里不要做复杂处理,比如协议解析、内存分配。我曾经见过有人在中断里做 printf,结果系统直接崩了。中断里只做数据搬运,处理逻辑放到主循环或任务里。
4.3 环形缓冲区:中断模式的基石
环形缓冲区,说白了就是一个固定大小的数组,用两个指针(头指针和尾指针)来管理。头指针指向下一个要写入的位置,尾指针指向下一个要读取的位置。数据写满时,新数据会覆盖旧数据。
为什么用环形缓冲区?因为中断是异步的,CPU可能在任意时刻收到数据。如果没有缓冲区,数据来了你没及时读走,就丢了。环形缓冲区能暂存数据,让CPU有空的时候再处理。
我常用的环形缓冲区实现:
typedef struct {
uint8_t *buf;
uint32_t size;
volatile uint32_t head;
volatile uint32_t tail;
} ring_buf_t;
// 初始化
void ring_buf_init(ring_buf_t *rb, uint8_t *buf, uint32_t size) {
rb->buf = buf;
rb->size = size;
rb->head = 0;
rb->tail = 0;
}
// 写入一个字节
uint8_t ring_buf_put(ring_buf_t *rb, uint8_t data) {
uint32_t next = (rb->head + 1) % rb->size;
if (next == rb->tail) {
return 0; // 缓冲区满
}
rb->buf[rb->head] = data;
rb->head = next;
return 1;
}
// 读取一个字节
uint8_t ring_buf_get(ring_buf_t *rb, uint8_t *data) {
if (rb->head == rb->tail) {
return 0; // 缓冲区空
}
*data = rb->buf[rb->tail];
rb->tail = (rb->tail + 1) % rb->size;
return 1;
}
// 查询已用空间
uint32_t ring_buf_used(ring_buf_t *rb) {
return (rb->head - rb->tail + rb->size) % rb->size;
}
避坑指南:我曾经把环形缓冲区的大小设成256,结果头尾指针用 uint8_t 存,取模运算时出了bug。后来我统一用 uint32_t,缓冲区大小必须是2的幂次方,这样取模可以用位运算 (head + 1) & (size - 1),效率高很多。
4.4 DMA传输封装:解放CPU
中断模式虽然比轮询好,但每个字节都要进一次中断,数据量大时CPU开销还是不小。DMA(直接存储器访问)能彻底解放CPU——数据从内存到外设,或者从外设到内存,全程不需要CPU参与。
DMA传输的封装,我建议分两步:配置DMA通道和启动传输。
先看配置:
typedef struct {
uint32_t src_addr; // 源地址
uint32_t dst_addr; // 目的地址
uint32_t length; // 传输长度
uint8_t src_inc; // 源地址是否递增
uint8_t dst_inc; // 目的地址是否递增
uint8_t dir; // 方向:内存到外设 / 外设到内存
void (*cplt_cb)(void); // 传输完成回调
} dma_cfg_t;
// DMA发送封装
void uart_dma_send(uart_hw_t *hw, dma_t *dma, const uint8_t *buf, uint32_t len) {
dma_cfg_t cfg;
cfg.src_addr = (uint32_t)buf;
cfg.dst_addr = hw->get_tx_reg_addr(); // UART发送数据寄存器地址
cfg.length = len;
cfg.src_inc = 1; // 源地址递增
cfg.dst_inc = 0; // 目的地址不变(UART寄存器)
cfg.dir = DMA_MEM_TO_PERIPH;
cfg.cplt_cb = uart_dma_tx_cplt;
dma->start(&cfg);
}
// DMA接收封装
void uart_dma_recv(uart_hw_t *hw, dma_t *dma, uint8_t *buf, uint32_t len) {
dma_cfg_t cfg;
cfg.src_addr = hw->get_rx_reg_addr(); // UART接收数据寄存器地址
cfg.dst_addr = (uint32_t)buf;
cfg.length = len;
cfg.src_inc = 0; // 源地址不变
cfg.dst_inc = 1; // 目的地址递增
cfg.dir = DMA_PERIPH_TO_MEM;
cfg.cplt_cb = uart_dma_rx_cplt;
dma->start(&cfg);
}
你看,DMA的封装跟轮询、中断一样,都基于 uart_hw_t 这个硬件抽象层。上层调用时,只需要选择用哪个模式:
// 统一的上层接口
void uart_send(uart_t *uart, const uint8_t *buf, uint32_t len) {
switch (uart->mode) {
case UART_MODE_POLL:
uart_poll_send(uart->hw, buf, len);
break;
case UART_MODE_INT:
uart_int_send(uart->int, buf, len);
break;
case UART_MODE_DMA:
uart_dma_send(uart->hw, uart->dma, buf, len);
break;
}
}
注意:DMA传输时,源数据缓冲区必须是静态的或者全局的,不能是局部变量。因为DMA是异步的,函数返回后局部变量就释放了,DMA还在读那块内存,数据就乱了。我踩过这个坑,调试了一整天才发现。
4.5 三种模式对比与选型建议
三种模式各有优劣,我整理了一个对比表,方便你选型:
| 特性 | 轮询模式 | 中断模式 | DMA模式 |
|---|---|---|---|
| CPU占用率 | 高(一直等) | 中(每字节中断) | 低(几乎不占) |
| 实时性 | 差(阻塞) | 好(异步) | 好(异步) |
| 代码复杂度 | 低 | 中 | 高 |
| 适用场景 | 调试、低速率 | 中速率、多任务 | 高速率、大数据量 |
| 内存开销 | 无 | 需缓冲区 | 需缓冲区+DMA描述符 |
我个人建议:调试阶段用轮询,简单可靠;产品阶段用中断,兼顾效率和复杂度;高速通信(如1Mbps以上)用DMA,否则CPU会被中断淹死。
4.6 整体架构图
下面这张图展示了UART驱动封装的整体架构,从硬件到应用层的分层关系:
从这张图可以看得很清楚:硬件抽象层在最底下,换芯片时只需要重写这一层;数据缓冲层提供环形缓冲区,给中断和DMA模式用;传输模式层实现三种模式的具体逻辑;最上层是统一接口,应用层调用时完全不用关心底层细节。
嗯,这就是UART驱动封装的完整思路。你想想看,如果一开始就这么分层设计,后面不管是加协议解析、加流控、还是换芯片,都只需要动对应的那一层,其他代码完全不用改。这就是封装的价值。