4. UART外设抽象:驱动框架、环形缓冲区与DMA传输
UART这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发,复杂在于——你要在HAL层把它抽象好,让上层应用不用关心底层是哪个MCU、中断怎么配、DMA用哪个通道。
我个人习惯把UART抽象分成三层:驱动框架层、数据缓冲层、传输引擎层。今天咱们就一层层拆开来看。
4.1 UART驱动框架:从寄存器到接口
先看驱动框架。说白了,就是定义一组标准操作,让上层调起来像在操作一个“串口对象”,而不是一堆寄存器。
我一般会这样设计基础结构体:
typedef struct {
uint32_t base_addr; // 寄存器基地址
uint32_t baud_rate; // 波特率
uint8_t data_bits; // 数据位
uint8_t stop_bits; // 停止位
uint8_t parity; // 校验位
uint8_t flow_ctrl; // 流控模式
void (*irq_handler)(void); // 中断处理函数指针
} uart_hw_config_t;
typedef struct {
uart_hw_config_t hw_cfg;
uint8_t *rx_buf; // 接收缓冲区指针
uint32_t rx_buf_size;
uint8_t *tx_buf; // 发送缓冲区指针
uint32_t tx_buf_size;
void *priv; // 私有数据(给底层驱动用)
} uart_dev_t;
嗯,这里要注意:priv指针是留给底层BSP用的。我在项目里见过有人把所有配置都塞进结构体,结果换芯片时改得想哭。留个void指针,底层想挂什么就挂什么,上层完全无感。
驱动接口我习惯定义成这样:
typedef struct {
int32_t (*init)(uart_dev_t *dev);
int32_t (*deinit)(uart_dev_t *dev);
int32_t (*send)(uart_dev_t *dev, const uint8_t *data, uint32_t len);
int32_t (*receive)(uart_dev_t *dev, uint8_t *data, uint32_t len);
int32_t (*ioctl)(uart_dev_t *dev, uint32_t cmd, void *arg);
} uart_ops_t;
为什么要有ioctl?因为总有些特殊操作——比如改波特率、清FIFO、使能硬件流控。你不可能把所有功能都做成独立接口,那样接口表会膨胀到没法看。
关键设计原则:驱动框架只定义“做什么”,不定义“怎么做”。具体怎么操作寄存器,那是底层BSP的事。
4.2 环形缓冲区设计:解决数据粘包与丢失
做嵌入式通信,最头疼的问题是什么?数据来了,你还没处理完上一条。或者中断太频繁,丢数据了。
环形缓冲区就是干这个用的。它像一个循环队列,写指针追着读指针跑。只要缓冲区够大,CPU忙的时候数据不会丢。
我常用的环形缓冲区结构:
typedef struct {
uint8_t *buffer; // 内存池
uint32_t size; // 缓冲区大小(必须是2的幂)
volatile uint32_t head; // 写指针
volatile uint32_t tail; // 读指针
} ring_buf_t;
这里有个小技巧:size必须是2的幂。为什么?因为可以用位运算代替取模,速度能快一个数量级。
// 入队
int32_t ring_buf_push(ring_buf_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;
}
// 出队
int32_t ring_buf_pop(ring_buf_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;
}
你看,& (size - 1)代替了% size。我在一个项目中把波特率从115200提到921600,就是因为中断服务函数里省掉了取模运算。
避坑指南:我曾经在环形缓冲区里用volatile修饰head和tail,但忘了在中断和主循环之间加内存屏障。结果优化等级开-O2后,读指针永远看不到写指针更新。嗯,从那以后我养成了习惯——跨中断/任务访问的共享变量,要么加屏障,要么用原子操作。
4.3 DMA传输抽象:让CPU喘口气
UART用中断收发,CPU频繁进出中断,大量时间浪费在上下文切换上。尤其在高波特率下,CPU几乎被UART中断占满。
DMA就是解决方案。它让数据在UART和内存之间直接搬运,搬完了才通知CPU。
DMA传输抽象的核心是描述符链。每个描述符包含:源地址、目的地址、传输长度、完成标志、下一个描述符指针。
typedef struct {
uint32_t src_addr; // 源地址
uint32_t dst_addr; // 目的地址
uint32_t length; // 传输长度
uint32_t flags; // 控制标志(完成中断使能、循环模式等)
void *next; // 下一个描述符
void (*callback)(void *arg); // 传输完成回调
void *cb_arg; // 回调参数
} dma_desc_t;
我习惯把DMA抽象成两层:
- DMA控制器层:负责通道分配、描述符提交、中断处理
- UART DMA适配层:把UART的收发操作映射到DMA传输
适配层的接口大概长这样:
typedef struct {
dma_channel_t *tx_ch; // 发送DMA通道
dma_channel_t *rx_ch; // 接收DMA通道
ring_buf_t *rx_ring; // 接收环形缓冲区
uint32_t rx_timeout; // 接收超时(用于帧结束判断)
} uart_dma_adapter_t;
int32_t uart_dma_send(uart_dev_t *dev, const uint8_t *data, uint32_t len);
int32_t uart_dma_receive(uart_dev_t *dev, uint8_t *data, uint32_t len, uint32_t timeout);
这里有个设计取舍:接收用DMA还是中断?
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 高波特率(>1Mbps) | DMA + 环形缓冲区 | 减少中断频率,避免丢数据 |
| 不定长数据帧 | DMA + 空闲中断 | 利用空闲中断判断帧结束 |
| 低功耗场景 | 中断 + 小缓冲区 | DMA会增加额外功耗 |
| 实时性要求高 | 中断 + 双缓冲 | DMA延迟比中断大 |
你想想看,如果每次收到一个字节就进一次中断,115200波特率下大约每8.7微秒一次中断。CPU光保存恢复现场了,哪还有时间做业务逻辑?
个人经验:我在一个物联网网关项目里,用DMA + 环形缓冲区 + 空闲中断的组合,把UART接收的CPU占用率从35%降到了3%以下。代价是多花了4KB内存做缓冲区。嗯,这个交易很划算。
4.4 整体架构:一张图说清楚
下面这张图展示了UART外设抽象的整体架构。从上层应用到硬件寄存器,每一层各司其职。
你看这个分层结构,每一层只做一件事。应用层不关心数据怎么收发的,驱动接口层不关心缓冲区怎么管理的,传输引擎层不关心寄存器地址是多少的。
这就是HAL层的核心价值——解耦。我在一个项目里从STM32F4换到GD32F4,只改了硬件抽象层不到200行代码,上层纹丝不动。
4.5 性能调优:几个实测数据
最后分享几个我在实际项目中测出来的数据,供你参考:
| 传输方式 | 波特率 | CPU占用率 | 最大吞吐量 | 适用场景 |
|---|---|---|---|---|
| 轮询 | 115200 | 100% | 11.5KB/s | 调试打印 |
| 中断 + 单字节 | 115200 | 35% | 11.5KB/s | 低数据量通信 |
| 中断 + 环形缓冲区 | 115200 | 12% | 11.5KB/s | 通用场景 |
| DMA + 环形缓冲区 | 115200 | 2% | 11.5KB/s | 高数据量通信 |
| DMA + 双缓冲 | 921600 | 5% | 92KB/s | 高速数据采集 |
看到没?同样的波特率,不同的传输方式,CPU占用率能差十几倍。选对方案,你的系统就能多跑很多业务逻辑。
核心总结:UART外设抽象不是把寄存器封装一下就完事了。你要考虑的是——框架怎么设计才能跨平台?缓冲区怎么设计才能不丢数据?传输引擎怎么选才能平衡性能和功耗?这三个问题想清楚了,你的HAL层就成功了一大半。