4. 驱动封装核心方法
UART这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发器,复杂在于——你要怎么把它用好?
我见过不少工程师,UART驱动写了十几年,还是老一套:轮询收一个字节处理一个,中断来了就丢全局变量。结果呢?数据稍微多点就丢包,CPU占用率还高得离谱。
这一章,咱们就来彻底解决这个问题。从轮询模式讲起,到中断+环形缓冲区,再到DMA传输,最后封装成一个带超时机制的通用UART驱动。每一步我都会结合项目中的实际踩坑经验来讲。
4.1 UART轮询模式:最基础,但别滥用
轮询模式,说白了就是CPU死等。发送一个字节,等发送完成;接收一个字节,等数据到来。
// 轮询发送一个字节
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);
}
这段代码,初学者都能写。但问题在哪?
- CPU 100%占用:while循环一直在跑,啥也干不了
- 实时性差:如果数据间隔很短,轮询可能漏掉
- 功耗高:CPU没法进入休眠
4.2 中断模式:解放CPU的第一步
中断模式的核心思想是:让外设主动通知CPU。数据来了,你处理一下;处理完了,继续干你的事。
但中断模式有个新问题——数据怎么存?
你想想看,如果中断来了,你直接把数据丢到一个全局数组里,那主循环和中断之间就存在竞争。主循环在读数组,中断在写数组,数据就乱了。
解决方案就是——环形缓冲区(RingBuffer)。
4.3 环形缓冲区设计与实现
环形缓冲区,说白了就是一个固定大小的数组,加上两个指针:读指针和写指针。
- 写指针:中断服务函数往里写数据
- 读指针:主循环或应用层从这读数据
- 当写指针追上读指针,缓冲区满
- 当读指针追上写指针,缓冲区空
typedef struct {
uint8_t *buffer; // 数据缓冲区
uint32_t size; // 缓冲区大小
volatile uint32_t head; // 写指针(生产者)
volatile uint32_t tail; // 读指针(消费者)
} ring_buffer_t;
// 初始化
void rb_init(ring_buffer_t *rb, uint8_t *buf, uint32_t size) {
rb->buffer = buf;
rb->size = size;
rb->head = 0;
rb->tail = 0;
}
// 写入一个字节
bool rb_write(ring_buffer_t *rb, uint8_t data) {
uint32_t next = (rb->head + 1) % rb->size;
if (next == rb->tail) {
return false; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next;
return true;
}
// 读取一个字节
bool rb_read(ring_buffer_t *rb, uint8_t *data) {
if (rb->head == rb->tail) {
return false; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % rb->size;
return true;
}
为什么用 volatile 修饰 head 和 tail?因为中断和主循环都会修改它们,编译器可能会优化掉这些变量,导致逻辑错误。这个坑我踩过,调试了一整天才发现。
4.4 UART DMA传输封装
中断模式虽然比轮询好,但高频数据下,中断频繁触发,CPU还是会被频繁打断。这时候就该DMA上场了。
DMA传输,说白了就是数据搬运工。CPU告诉DMA:你把UART接收到的数据搬到这个缓冲区,搬完了通知我。然后CPU就可以去睡觉或者干别的了。
| 模式 | CPU占用 | 实时性 | 适用场景 |
|---|---|---|---|
| 轮询 | 100% | 差 | 调试、低波特率 |
| 中断 | 中(取决于数据量) | 好 | 中等数据量、不定长 |
| DMA | 极低(仅传输完成中断) | 极好 | 大数据量、固定长度 |
// DMA接收初始化(以STM32为例)
void uart_dma_rx_init(UART_HandleTypeDef *huart, uint8_t *buf, uint32_t len) {
// 配置DMA
HAL_UART_Receive_DMA(huart, buf, len);
}
// DMA传输完成回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
// 数据已自动存入buf
// 通知应用层处理
uart_dma_data_ready(huart->Instance);
}
DMA模式有个经典问题:怎么处理不定长数据?
我的做法是:DMA开启循环模式(Circular Mode),配合空闲中断(IDLE Interrupt)。当UART总线空闲时,触发中断,此时从DMA缓冲区中读取当前写指针位置,就知道收到了多少数据。
4.5 封装带超时机制的UART驱动
好了,现在我们把上面这些技术整合起来,封装成一个完整的UART驱动。这个驱动要支持:
- 轮询模式(调试用)
- 中断模式+环形缓冲区(通用)
- DMA模式(高性能)
- 超时机制(防止死等)
typedef struct {
UART_TypeDef *instance; // UART外设基地址
ring_buffer_t rx_rb; // 接收环形缓冲区
ring_buffer_t tx_rb; // 发送环形缓冲区
uint32_t timeout_ms; // 超时时间(毫秒)
uint32_t last_tick; // 上次操作时间戳
uart_mode_t mode; // 当前模式
} uart_device_t;
// 带超时的接收函数
int32_t uart_recv_timeout(uart_device_t *dev, uint8_t *data, uint32_t timeout_ms) {
uint32_t start = get_tick_ms();
while (get_tick_ms() - start < timeout_ms) {
if (rb_read(&dev->rx_rb, data)) {
return 0; // 成功
}
// 让出CPU,避免忙等
os_delay(1);
}
return -1; // 超时
}
// 带超时的发送函数
int32_t uart_send_timeout(uart_device_t *dev, const uint8_t *data, uint32_t len, uint32_t timeout_ms) {
uint32_t start = get_tick_ms();
uint32_t sent = 0;
while (sent < len) {
if (get_tick_ms() - start > timeout_ms) {
return -1; // 超时
}
if (rb_write(&dev->tx_rb, data[sent])) {
sent++;
// 触发发送中断或DMA
uart_tx_trigger(dev->instance);
} else {
os_delay(1);
}
}
return 0;
}
这个驱动封装的核心思想是:上层应用不关心底层是轮询、中断还是DMA。它只需要调用 uart_recv_timeout 和 uart_send_timeout,设置好超时时间,剩下的交给驱动层处理。
下面这张图展示了整个UART驱动的分层架构:
从这张图可以看得很清楚:上层应用只跟API封装层打交道,底层怎么实现,它不关心。这就是分层架构的好处——解耦。
最后,我想说一句:UART驱动看似简单,但真正写好、写稳,需要你对硬件、中断、DMA、数据结构都有深入理解。这一章的内容,是我多年项目经验的总结,希望能帮你少走弯路。