4、HAL实战:UART驱动抽象:轮询、中断、DMA三种模式的统一接口封装。
UART,串口,嵌入式开发里最常用的外设,没有之一。调试打印、跟传感器通信、跟蓝牙模块对接,哪哪都离不开它。
但有个问题很头疼:不同芯片的UART寄存器不一样。换个MCU,底层驱动就得重写。更麻烦的是,收发数据有三种模式——轮询、中断、DMA。每次切换模式,上层应用代码也得跟着改。
这合理吗?不合理。
所以这一章,我们就来解决这个问题。用HAL(硬件抽象层)把UART的三种模式封装成统一接口。上层只管调用 UART_Read()、UART_Write(),底层具体怎么跑,交给HAL去管。
4.1 为什么需要统一接口?
先看个反面教材。我早期做项目时,代码里到处都是这样的调用:
// 轮询发送
while(!(USART1->SR & USART_SR_TXE));
USART1->DR = data;
// 中断接收
void USART1_IRQHandler(void) {
if(USART1->SR & USART_SR_RXNE) {
rx_buf[rx_idx++] = USART1->DR;
}
}
// DMA发送
DMA1->CPARx = (uint32_t)&USART1->DR;
DMA1->CMARx = (uint32_t)tx_buf;
DMA1->CCRx |= DMA_CCRx_EN;
看着眼熟吧?每个模式一套写法,上层业务逻辑里混着底层寄存器操作。后来换芯片,这些代码全废了,从头改。
统一接口的价值就在这里:把“做什么”和“怎么做”分开。上层只关心“我要发100个字节”,底层决定“用中断发还是DMA发”。
核心目标:
- 上层调用统一API,不感知底层模式
- 切换模式只需改初始化参数,不改业务代码
- 换MCU平台时,只替换HAL层,应用层零改动
4.2 接口设计:三模式统一API
接口怎么设计?我个人习惯用结构体函数指针,或者直接定义一组全局API。这里我选择后者,更直观。
先定义操作句柄:
/* uart_hal.h */
#ifndef __UART_HAL_H__
#define __UART_HAL_H__
#include <stdint.h>
/* 传输模式枚举 */
typedef enum {
UART_MODE_POLLING = 0,
UART_MODE_INTERRUPT,
UART_MODE_DMA
} uart_mode_t;
/* 配置参数结构体 */
typedef struct {
uint32_t baudrate;
uint8_t data_bits; /* 5/6/7/8 */
uint8_t stop_bits; /* 1/2 */
uint8_t parity; /* 0=none, 1=odd, 2=even */
uart_mode_t tx_mode; /* 发送模式 */
uart_mode_t rx_mode; /* 接收模式 */
uint32_t tx_buf_size; /* 中断/DMA用 */
uint32_t rx_buf_size;
} uart_config_t;
/* 统一API */
int32_t uart_init(uint8_t port, uart_config_t *cfg);
int32_t uart_write(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t uart_read(uint8_t port, uint8_t *buf, uint32_t len, uint32_t timeout_ms);
int32_t uart_ioctl(uint8_t port, uint32_t cmd, void *arg);
#endif
你看,uart_write 和 uart_read 的参数完全一样。不管底层是轮询、中断还是DMA,上层调用方式不变。
uart_ioctl 是扩展接口,用来做控制类操作,比如:
- 清空接收缓冲区
- 设置波特率
- 使能/禁止中断
- 获取当前状态
4.3 三种模式的实现要点
接口定好了,接下来看底层怎么实现。每种模式的特点和坑,我一个个说。
4.3.1 轮询模式
轮询最简单,CPU死等。适合短数据、低实时性场景。
/* 轮询发送 */
static int32_t uart_polling_write(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms) {
uint32_t i;
uint32_t start_tick = get_tick();
for(i = 0; i < len; i++) {
/* 等待发送寄存器空 */
while(!(UART_REG(port)->SR & UART_SR_TXE)) {
if((get_tick() - start_tick) > timeout_ms) {
return -1; /* 超时 */
}
}
UART_REG(port)->DR = data[i];
}
/* 等待发送完成 */
while(!(UART_REG(port)->SR & UART_SR_TC)) {
if((get_tick() - start_tick) > timeout_ms) {
return -2;
}
}
return len;
}
注意:轮询接收时,如果对方没发数据,程序会卡死在等待循环里。所以一定要加超时机制。我曾经有个项目,就因为没加超时,对方死机后整个系统跟着卡死,看门狗都救不回来。
4.3.2 中断模式
中断模式适合不定长数据接收,CPU不用空等。
/* 中断发送:使用环形缓冲区 */
static int32_t uart_irq_write(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms) {
/* 把数据写入环形缓冲区 */
ringbuf_write(&uart_ctx[port].tx_ring, data, len);
/* 使能发送中断 */
UART_REG(port)->CR1 |= USART_CR1_TXEIE;
return len;
}
/* 中断服务函数(由芯片中断向量表调用) */
void uart_irq_handler(uint8_t port) {
if(UART_REG(port)->SR & USART_SR_TXE) {
if(ringbuf_read(&uart_ctx[port].tx_ring, &ch) == 0) {
UART_REG(port)->DR = ch;
} else {
/* 数据发完,关中断 */
UART_REG(port)->CR1 &= ~USART_CR1_TXEIE;
}
}
if(UART_REG(port)->SR & USART_SR_RXNE) {
ch = UART_REG(port)->DR;
ringbuf_write(&uart_ctx[port].rx_ring, &ch, 1);
}
}
中断模式的核心是环形缓冲区。发送时把数据塞进缓冲区,开中断让硬件慢慢发;接收时中断把数据放进缓冲区,上层轮询或等待读取。
避坑指南:我曾经在中断服务函数里调用 printf 做调试,结果递归调用把自己搞死了。记住:ISR里不要做耗时操作,不要调用可能触发中断的函数。
4.3.3 DMA模式
DMA模式适合大数据量、高速传输。CPU只需要配置好,剩下的DMA控制器搞定。
/* DMA发送 */
static int32_t uart_dma_write(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms) {
/* 等待上一次DMA传输完成 */
while(uart_ctx[port].dma_tx_busy) {
if((get_tick() - uart_ctx[port].start_tick) > timeout_ms) {
return -1;
}
}
uart_ctx[port].dma_tx_busy = 1;
uart_ctx[port].start_tick = get_tick();
/* 配置DMA:外设地址=UART数据寄存器,内存地址=data */
DMA_Config(port, DMA_DIR_MEM_TO_PERIPH,
(uint32_t)&UART_REG(port)->DR, (uint32_t)data, len);
DMA_Enable(port);
return len;
}
/* DMA完成中断回调 */
void uart_dma_tx_complete(uint8_t port) {
uart_ctx[port].dma_tx_busy = 0;
}
DMA模式要注意几个点:
- 缓冲区对齐:有些DMA控制器要求缓冲区地址按4字节对齐
- Cache一致性:带Cache的MCU上,DMA和CPU访问同一块内存时,需要做Cache刷新或使用非Cache内存
- 传输完成标志:DMA传输完成不等于UART发送完成,还要等UART的TC标志
三种模式对比:
| 特性 | 轮询 | 中断 | DMA |
|---|---|---|---|
| CPU占用率 | 高(空等) | 中(进出中断) | 低(配置完就不管) |
| 实时性 | 差(阻塞) | 好 | 好 |
| 适合场景 | 短数据、调试 | 不定长、交互式 | 大数据块、高速 |
| 代码复杂度 | 低 | 中 | 高 |
| 内存开销 | 无 | 环形缓冲区 | DMA描述符+缓冲区 |
4.4 统一调度:模式切换与回调机制
接口统一了,但底层怎么知道当前用哪种模式?
我的做法是在 uart_init 里根据配置注册不同的函数指针:
/* 函数指针表 */
typedef int32_t (*write_func_t)(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms);
typedef int32_t (*read_func_t)(uint8_t port, uint8_t *buf, uint32_t len, uint32_t timeout_ms);
static write_func_t write_table[3] = {
uart_polling_write, /* UART_MODE_POLLING */
uart_irq_write, /* UART_MODE_INTERRUPT */
uart_dma_write /* UART_MODE_DMA */
};
static read_func_t read_table[3] = {
uart_polling_read,
uart_irq_read,
uart_dma_read
};
int32_t uart_write(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms) {
if(port >= UART_PORT_MAX) return -1;
uart_mode_t mode = uart_ctx[port].config.tx_mode;
return write_table[mode](port, data, len, timeout_ms);
}
这样,上层调用 uart_write 时,自动路由到对应的模式实现。想切换模式?改一下 uart_config_t 里的 tx_mode 就行。
另外,中断和DMA模式需要回调机制通知上层数据收发完成。我定义了一组回调函数:
typedef void (*uart_callback_t)(uint8_t port, uint32_t event, void *arg);
/* 事件定义 */
#define UART_EVENT_TX_COMPLETE 0x01
#define UART_EVENT_RX_COMPLETE 0x02
#define UART_EVENT_RX_TIMEOUT 0x03
#define UART_EVENT_ERROR 0xFF
int32_t uart_set_callback(uint8_t port, uart_callback_t cb, void *arg);
上层注册回调后,底层在中断或DMA完成时调用它。这样上层不用轮询,效率更高。
4.5 整体架构图
下面这张图展示了UART HAL层的整体结构:
4.6 使用示例
最后看一个完整的使用例子,感受一下统一接口的便利:
/* main.c */
#include "uart_hal.h"
/* 回调函数 */
void uart_event_cb(uint8_t port, uint32_t event, void *arg) {
if(event == UART_EVENT_RX_COMPLETE) {
/* 收到数据,做处理 */
process_data(port, (uint8_t*)arg);
}
}
int main(void) {
uart_config_t cfg;
/* 配置:中断接收,DMA发送 */
cfg.baudrate = 115200;
cfg.data_bits = 8;
cfg.stop_bits = 1;
cfg.parity = 0;
cfg.tx_mode = UART_MODE_DMA;
cfg.rx_mode = UART_MODE_INTERRUPT;
cfg.tx_buf_size = 256;
cfg.rx_buf_size = 256;
uart_init(0, &cfg);
uart_set_callback(0, uart_event_cb, NULL);
/* 发送数据 - 底层用DMA */
uint8_t msg[] = "Hello HAL!";
uart_write(0, msg, sizeof(msg), 1000);
/* 接收数据 - 底层用中断 */
uint8_t buf[64];
uart_read(0, buf, sizeof(buf), 5000);
while(1) {
/* 主循环做其他事 */
}
}
你看,业务代码里完全看不到寄存器操作,也看不到中断或DMA的配置细节。想换模式?改一下 cfg.tx_mode 和 cfg.rx_mode 就行。
个人建议:初期开发调试时用轮询模式,简单可靠。功能稳定后再切到中断或DMA。这样出了问题,先排除硬件和协议问题,再优化性能。