4. 硬件抽象层(HAL)设计:UART的轮询与中断模式封装

好,咱们进入第四章。这一章要解决一个很实际的问题——UART的驱动到底该怎么封装,才能让上层应用代码不用关心底层寄存器操作。

我见过不少项目,UART的收发代码直接散落在各个模块里,换个芯片平台就得重写一遍。说白了,这就是没有硬件抽象层的结果。今天咱们就动手,把UART的轮询模式和中断模式都封装好。

4.1 为什么需要UART的硬件抽象层?

先想一个问题:你的应用层代码里,能不能出现USART1->DR这种寄存器操作?

我的答案是:绝对不能。一旦出现,就意味着你的代码和STM32这个具体型号绑死了。将来换到GD32、AT32,甚至STM32的另一个系列,你都得改应用代码。

硬件抽象层要做的,就是给上层提供一套稳定的接口。上层只管调用UART_SendByte、UART_RecvByte,底层怎么操作寄存器,那是HAL层的事。

核心原则:应用层代码中,不允许出现任何寄存器级别的操作。所有硬件访问必须通过HAL接口。

4.2 轮询模式封装

轮询模式是最简单的。CPU不断检查状态寄存器,直到数据发送完成或接收到数据。效率不高,但胜在代码简单、时序可控。

4.2.1 接口设计

我习惯把UART的接口分成三组:初始化、发送、接收。先看头文件定义:

/* uart_hal.h */
#ifndef __UART_HAL_H
#define __UART_HAL_H

#include "stm32f1xx.h"
#include <stdint.h>

/* UART句柄结构体 */
typedef struct {
    USART_TypeDef *instance;   /* UART实例基地址 */
    uint32_t       baudrate;   /* 波特率 */
    uint8_t        data_bits;  /* 数据位:8或9 */
    uint8_t        stop_bits;  /* 停止位:1或2 */
    uint8_t        parity;     /* 校验位:0=无,1=奇校验,2=偶校验 */
} UART_Handle_t;

/* 初始化 */
void UART_Init(UART_Handle_t *huart);

/* 轮询发送 */
void UART_SendByte(UART_Handle_t *huart, uint8_t data);
void UART_SendBuffer(UART_Handle_t *huart, uint8_t *buf, uint16_t len);

/* 轮询接收 */
uint8_t UART_RecvByte(UART_Handle_t *huart);
uint16_t UART_RecvBuffer(UART_Handle_t *huart, uint8_t *buf, uint16_t max_len);

#endif

这里有个关键点:句柄结构体。我把UART的配置参数和实例指针打包在一起,上层只需要传递一个句柄指针,所有操作都通过这个句柄完成。

4.2.2 轮询发送实现

发送一个字节,流程很简单:等待发送寄存器空,然后写入数据。

void UART_SendByte(UART_Handle_t *huart, uint8_t data)
{
    /* 等待发送数据寄存器空 */
    while (!(huart->instance->SR & USART_SR_TXE));
    
    /* 写入数据 */
    huart->instance->DR = data;
}

嗯,这里要注意一点:USART_SR_TXE标志位表示发送数据寄存器已空,可以写入下一个字节。但如果你连续发送大量数据,这个while循环会一直占用CPU。我在项目中遇到过一个问题——发送1KB数据时,CPU被卡住了几毫秒,导致其他任务错过了中断。所以轮询模式只适合小数据量或调试场景。

4.2.3 轮询接收实现

接收一个字节,同样简单:等待接收非空,然后读取数据。

uint8_t UART_RecvByte(UART_Handle_t *huart)
{
    /* 等待接收数据寄存器非空 */
    while (!(huart->instance->SR & USART_SR_RXNE));
    
    /* 读取数据 */
    return (uint8_t)(huart->instance->DR & 0xFF);
}

你想想看,这个函数有个问题:如果一直没收到数据,它就永远卡在while循环里。所以实际项目中,轮询接收通常配合超时机制使用。我一般会加一个超时参数:

uint8_t UART_RecvByte_Timeout(UART_Handle_t *huart, uint8_t *data, uint32_t timeout_ms)
{
    uint32_t tick_start = GetTick();  /* 假设有个系统滴答函数 */
    
    while (!(huart->instance->SR & USART_SR_RXNE)) {
        if ((GetTick() - tick_start) > timeout_ms) {
            return 0;  /* 超时,返回失败 */
        }
    }
    
    *data = (uint8_t)(huart->instance->DR & 0xFF);
    return 1;  /* 成功 */
}

经验之谈:轮询接收一定要加超时保护。我曾经见过一个设备,因为通信线松动,程序卡死在UART接收函数里,整机死机。加个超时,至少能保证系统不会完全挂掉。

4.3 中断模式封装

轮询模式最大的问题就是阻塞CPU。中断模式可以解决这个问题——CPU只在数据到达或发送完成时才处理,其他时间可以干别的事。

4.3.1 环形缓冲区设计

中断模式的核心是环形缓冲区。为什么用环形?因为UART数据是流式的,你不知道什么时候来数据,也不知道来多少。环形缓冲区可以一边收一边取,互不干扰。

/* 环形缓冲区结构体 */
#define UART_RX_BUF_SIZE  256
#define UART_TX_BUF_SIZE  256

typedef struct {
    uint8_t  buffer[UART_RX_BUF_SIZE];
    uint16_t head;   /* 写入位置 */
    uint16_t tail;   /* 读取位置 */
} RingBuf_t;

这里head指向下一个要写入的位置,tail指向下一个要读取的位置。当head == tail时,缓冲区为空;当(head + 1) % SIZE == tail时,缓冲区为满。

4.3.2 中断服务函数封装

中断服务函数不能放在HAL层吗?我的做法是:HAL层提供中断处理回调,具体的中断向量映射由平台层完成。

/* uart_hal.c */
static RingBuf_t rx_ring;   /* 接收环形缓冲区 */
static RingBuf_t tx_ring;   /* 发送环形缓冲区 */

/* 中断处理函数 - 由平台层的中断向量调用 */
void UART_IRQHandler(UART_Handle_t *huart)
{
    uint8_t data;
    
    /* 接收中断 */
    if (huart->instance->SR & USART_SR_RXNE) {
        data = (uint8_t)(huart->instance->DR & 0xFF);
        /* 写入环形缓冲区 */
        uint16_t next = (rx_ring.head + 1) % UART_RX_BUF_SIZE;
        if (next != rx_ring.tail) {  /* 缓冲区未满 */
            rx_ring.buffer[rx_ring.head] = data;
            rx_ring.head = next;
        }
        /* 如果缓冲区满了,数据丢弃(可扩展溢出回调) */
    }
    
    /* 发送完成中断 */
    if (huart->instance->SR & USART_SR_TC) {
        /* 检查发送缓冲区是否有数据待发 */
        if (tx_ring.head != tx_ring.tail) {
            huart->instance->DR = tx_ring.buffer[tx_ring.tail];
            tx_ring.tail = (tx_ring.tail + 1) % UART_TX_BUF_SIZE;
        } else {
            /* 发送缓冲区空,关闭发送完成中断 */
            huart->instance->CR1 &= ~USART_CR1_TCIE;
        }
    }
}

这里有个细节:发送完成中断(TC)和发送寄存器空中断(TXE)不一样。TC是数据真正从移位寄存器送出去了,TXE只是数据从DR寄存器移到了移位寄存器。我习惯用TC中断,因为可以确保数据确实发送完成了。

4.3.3 中断模式接口

有了环形缓冲区和中断处理,上层接口就变得很简洁:

/* 启动中断接收 */
void UART_StartRxInterrupt(UART_Handle_t *huart)
{
    huart->instance->CR1 |= USART_CR1_RXNEIE;  /* 使能接收中断 */
}

/* 从环形缓冲区读取一个字节 */
uint8_t UART_ReadByte(UART_Handle_t *huart, uint8_t *data)
{
    if (rx_ring.head == rx_ring.tail) {
        return 0;  /* 缓冲区空 */
    }
    
    *data = rx_ring.buffer[rx_ring.tail];
    rx_ring.tail = (rx_ring.tail + 1) % UART_RX_BUF_SIZE;
    return 1;
}

/* 发送一个字节(放入发送缓冲区,启动中断发送) */
void UART_WriteByte(UART_Handle_t *huart, uint8_t data)
{
    /* 等待发送缓冲区有空位 */
    uint16_t next = (tx_ring.head + 1) % UART_TX_BUF_SIZE;
    while (next == tx_ring.tail);  /* 缓冲区满则等待 */
    
    tx_ring.buffer[tx_ring.head] = data;
    tx_ring.head = next;
    
    /* 使能发送完成中断 */
    huart->instance->CR1 |= USART_CR1_TCIE;
}

注意:中断服务函数里不能调用printf、不能做复杂运算、不能等待。中断服务的原则是“快进快出”。环形缓冲区的读写操作必须保证原子性,必要时可以关中断保护。

4.4 轮询 vs 中断:如何选择?

我整理了一个对比表格,方便你根据场景选择:

对比项 轮询模式 中断模式
CPU占用 高(等待时一直占用) 低(只在收发时占用)
实时性 差(取决于轮询频率) 好(硬件触发)
代码复杂度 低 中(需要环形缓冲区)
适用场景 调试输出、低速通信 高速通信、多任务系统
数据丢失风险 低(CPU一直盯着) 高(缓冲区满会丢数据)

我的建议是:调试阶段用轮询,简单直接;产品阶段用中断,保证系统响应。如果你做的是低功耗设备,中断模式还能配合休眠——没数据时CPU睡大觉,数据来了中断唤醒。

4.5 本章知识结构图

下面这张图展示了UART硬件抽象层的整体架构:

UART硬件抽象层架构 应用层 HAL接口层 UART_Init / UART_SendByte / UART_RecvByte UART_WriteByte / UART_ReadByte / UART_IRQHandler 实现层 轮询模式 中断模式 环形缓冲区 硬件层(寄存器操作) 上层不关心底层是轮询还是中断,只调用HAL接口

从这张图可以看得很清楚:应用层只跟HAL接口层打交道,不关心底层是轮询还是中断。实现层封装了两种模式的具体细节,而硬件层负责最终的寄存器操作。这就是分层架构的魅力——每一层只关心自己的事。

4.6 避坑指南

最后分享几个我踩过的坑:

  • 中断优先级设置不当:UART中断优先级不能太高,否则会阻塞其他关键中断(如系统滴答)。我一般把UART中断设为中等优先级。
  • 环形缓冲区大小选择:太小容易丢数据,太大浪费内存。我的经验是:根据最大数据包长度×2来设置。比如你的协议最大包是128字节,缓冲区设256就够。
  • 临界区保护:中断和主循环都会访问环形缓冲区,必须保证操作原子性。最简单的做法是在读写前关中断,操作完再开。
  • 波特率误差:STM32的UART波特率不是所有值都能精确生成的。我建议用8MHz、36MHz、72MHz这些整数倍时钟,波特率选115200、57600这些常用值。

个人习惯:我会在HAL层加一个调试开关。调试阶段用轮询模式,方便printf输出;发布前切换到中断模式,保证性能。这个开关只需要改一个宏定义,不用动任何应用代码。