4、HAL实战:UART驱动分层封装——中断、DMA与轮询模式的统一接口设计
UART这个外设,说简单也简单,说复杂也复杂。简单在于它就是个串行收发,复杂在于——你得同时伺候好中断、DMA、轮询三种模式。我早年做第一个物联网网关项目时,就吃过这个亏:应用层代码里到处是 if (mode == DMA) 这种判断,改一个bug要翻三四个文件。后来我痛定思痛,决定用HAL分层把UART驱动彻底重构一遍。
说白了,我们要达成的目标就一个:上层调接口时,根本不用关心底层是中断还是DMA在干活。就像你开车踩油门,不用管发动机是自然吸气还是涡轮增压。
4.1 分层架构:把UART拆成三层
我个人习惯把UART驱动拆成三层,每层各司其职:
| 层级 | 职责 | 典型文件 |
|---|---|---|
| HAL_UART_LL | 直接操作寄存器,最底层 | hal_uart_ll.c |
| HAL_UART_DRV | 封装中断/DMA/轮询三种模式 | hal_uart_drv.c |
| HAL_UART_API | 对外统一接口,不暴露模式细节 | hal_uart_api.c |
你想想看,如果应用层直接调LL层,那每次换芯片都得重写一遍。但有了中间这层DRV,换芯片时只需要改LL层,API层纹丝不动。
核心原则:上层只能调用相邻下层接口,不允许跨层调用。API层不能直接调LL层的寄存器操作函数。
4.2 统一接口设计:一个结构体搞定所有模式
怎么让上层无感切换模式?我的做法是定义一个操作句柄结构体,里面放函数指针。初始化时根据配置挂载不同的实现。
/* hal_uart_drv.h */
typedef struct {
void (*init)(UART_HandleTypeDef *huart);
void (*deinit)(UART_HandleTypeDef *huart);
int32_t (*transmit)(UART_HandleTypeDef *huart,
uint8_t *pData, uint16_t Size,
uint32_t Timeout);
int32_t (*receive)(UART_HandleTypeDef *huart,
uint8_t *pData, uint16_t Size,
uint32_t Timeout);
void (*irq_handler)(UART_HandleTypeDef *huart);
} UART_DrvOps;
/* 三种模式的实例化 */
extern const UART_DrvOps uart_drv_poll;
extern const UART_DrvOps uart_drv_it;
extern const UART_DrvOps uart_drv_dma;
嗯,这里要注意:irq_handler 这个函数指针,轮询模式里直接赋空函数或者返回,中断和DMA模式才真正实现。我在项目中遇到过有人把轮询模式的IRQ处理函数写成死循环,一进中断就卡死——这种坑踩一次就记住了。
4.3 轮询模式实现:简单但别大意
轮询模式最直接,但有个细节很多人会忽略——超时处理。
/* hal_uart_drv_poll.c */
static int32_t UART_Poll_Transmit(UART_HandleTypeDef *huart,
uint8_t *pData, uint16_t Size,
uint32_t Timeout) {
uint32_t tickstart = HAL_GetTick();
uint16_t tx_cnt = 0;
while (tx_cnt < Size) {
/* 等待发送数据寄存器空 */
while (!(huart->Instance->SR & USART_SR_TXE)) {
if ((HAL_GetTick() - tickstart) > Timeout) {
return HAL_TIMEOUT; /* 超时返回 */
}
}
huart->Instance->DR = pData[tx_cnt++];
tickstart = HAL_GetTick(); /* 重置超时计时 */
}
/* 等待最后一次发送完成 */
while (!(huart->Instance->SR & USART_SR_TC)) {
if ((HAL_GetTick() - tickstart) > Timeout) {
return HAL_TIMEOUT;
}
}
return HAL_OK;
}
避坑:我曾经在等待TC标志时忘记重置超时计时,结果发送大数据包时频繁超时。每个字节的等待时间应该独立计时,而不是从函数入口开始算总账。
4.4 中断模式实现:环形缓冲区是关键
中断模式的核心是环形缓冲区。没有它,你处理一个字节就得进一次中断,CPU啥也别干了。
/* hal_uart_drv_it.c */
typedef struct {
uint8_t buffer[UART_RX_BUF_SIZE];
volatile uint16_t head;
volatile uint16_t tail;
} UART_RingBuf;
static UART_RingBuf rx_ring;
static int32_t UART_IT_Receive(UART_HandleTypeDef *huart,
uint8_t *pData, uint16_t Size,
uint32_t Timeout) {
uint32_t tickstart = HAL_GetTick();
uint16_t received = 0;
while (received < Size) {
/* 检查环形缓冲区是否有数据 */
while (rx_ring.head == rx_ring.tail) {
if ((HAL_GetTick() - tickstart) > Timeout) {
return HAL_TIMEOUT;
}
}
pData[received++] = rx_ring.buffer[rx_ring.tail];
rx_ring.tail = (rx_ring.tail + 1) % UART_RX_BUF_SIZE;
}
return HAL_OK;
}
中断服务函数里只做一件事:把收到的字节塞进环形缓冲区,然后清标志位。千万别在中断里做复杂处理,我见过有人直接在ISR里调用回调函数去解析协议——结果中断嵌套把自己搞死了。
个人经验:环形缓冲区的head和tail一定要加volatile,否则编译器优化后可能读到缓存值。我调试过一个诡异bug,开了-O2优化后收不到数据,关了就好了——就是volatile没加。
4.5 DMA模式实现:双缓冲是王道
DMA模式适合大数据量传输,但有个问题:DMA传输完成中断触发时,你只有一次机会把数据拿走。如果处理慢了,下一批数据就覆盖了。
我的方案是双缓冲:
/* hal_uart_drv_dma.c */
typedef struct {
uint8_t buffer_a[UART_DMA_BUF_SIZE];
uint8_t buffer_b[UART_DMA_BUF_SIZE];
volatile uint8_t active_buffer; /* 0: buffer_a, 1: buffer_b */
void (*rx_complete_cb)(uint8_t *data, uint16_t len);
} UART_DmaContext;
static void UART_DMA_RxComplete(DMA_HandleTypeDef *hdma) {
UART_DmaContext *ctx = &uart_dma_ctx;
uint8_t *completed_buf;
/* 获取刚填满的缓冲区 */
if (ctx->active_buffer == 0) {
completed_buf = ctx->buffer_a;
ctx->active_buffer = 1;
/* 立即切换DMA目标到buffer_b */
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, ctx->buffer_b, UART_DMA_BUF_SIZE);
} else {
completed_buf = ctx->buffer_b;
ctx->active_buffer = 0;
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, ctx->buffer_a, UART_DMA_BUF_SIZE);
}
/* 回调通知上层处理 */
if (ctx->rx_complete_cb) {
ctx->rx_complete_cb(completed_buf, UART_DMA_BUF_SIZE);
}
}
这样DMA永远有一个缓冲区在后台收数据,上层处理另一个缓冲区时不会丢数据。我在一个4G模块的AT指令解析项目里用这个方案,115200波特率下跑满负荷,一包数据都没丢过。
4.6 统一接口的最终呈现
有了上面这些,API层就清爽了:
/* hal_uart_api.c */
static const UART_DrvOps *drv_ops;
int32_t HAL_UART_Init(UART_HandleTypeDef *huart, UART_Mode mode) {
switch (mode) {
case UART_MODE_POLL: drv_ops = &uart_drv_poll; break;
case UART_MODE_IT: drv_ops = &uart_drv_it; break;
case UART_MODE_DMA: drv_ops = &uart_drv_dma; break;
default: return HAL_ERROR;
}
return drv_ops->init(huart);
}
int32_t HAL_UART_Transmit(UART_HandleTypeDef *huart,
uint8_t *pData, uint16_t Size,
uint32_t Timeout) {
return drv_ops->transmit(huart, pData, Size, Timeout);
}
int32_t HAL_UART_Receive(UART_HandleTypeDef *huart,
uint8_t *pData, uint16_t Size,
uint32_t Timeout) {
return drv_ops->receive(huart, pData, Size, Timeout);
}
应用层调 HAL_UART_Transmit() 时,根本不知道底层是轮询还是DMA。换模式只需要改初始化时的参数,其他代码一行不动。
设计精髓:函数指针表 + 编译期常量实例化。既保留了C语言的性能,又实现了类似C++虚函数的多态效果。在资源受限的MCU上,这是最优雅的解法。
4.7 三种模式的选择策略
最后说说怎么选。我一般按这个原则:
- 轮询模式:调试阶段、低波特率(9600以下)、短数据包(几个字节)。简单可靠,但别用在产品里。
- 中断模式:中等数据量、不定长接收、需要低延迟响应。我80%的项目用这个。
- DMA模式:大数据量(几百字节以上)、固定长度传输、CPU负载敏感的场景。比如OTA升级、日志输出。
我曾经在一个项目里把三种模式都暴露给应用层,让产品经理根据场景配置——结果他们全选了中断模式,因为最简单。所以,接口统一了,选择权交给上层,但默认值要选最稳妥的那个。
好了,这一套分层封装下来,UART驱动就变成了可插拔的积木。下次换芯片?重写LL层就行。换模式?改初始化参数就行。应用层的同事再也不用追着你问“这个接口在中断模式下能不能用”了。