4、HAL层实战:UART驱动抽象

好,咱们进入真正的实战环节。前面几章讲了分层思想,讲了接口规范,这一章我们就拿UART开刀,看看HAL层到底该怎么设计。

UART这个东西,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发,复杂是因为不同芯片的UART外设寄存器差异太大了。我早年做项目时,就吃过这个亏——产品换了MCU,UART驱动几乎重写了一遍。从那以后,我下定决心,必须把HAL层做厚实。

UART的HAL接口设计

先说说接口设计的原则。我个人习惯,HAL层的接口一定要满足三个字:稳、简、全。稳是指行为可预期,简是指参数不冗余,全是指覆盖所有常用场景。

一个典型的UART HAL接口,我通常会分成三大类:

接口类别 典型函数 说明
配置类 HAL_UART_Init, HAL_UART_DeInit, HAL_UART_Config 波特率、数据位、停止位、校验位、流控
发送类 HAL_UART_Transmit, HAL_UART_Transmit_IT, HAL_UART_Transmit_DMA 阻塞、中断、DMA三种模式
接收类 HAL_UART_Receive, HAL_UART_Receive_IT, HAL_UART_Receive_DMA 同样三种模式,加上超时机制

你可能会问,为什么要把中断和DMA分开?其实说白了,就是让上层调用者能明确知道当前用的是哪种传输方式,避免误用。我在项目中见过有人用阻塞方式接收不定长数据,结果CPU被卡死——这就是接口语义不清的锅。

接口设计要点:

  • 每个函数名要带模式后缀(_IT, _DMA),一眼就能看出是哪种传输
  • 参数中必须包含超时时间,阻塞模式靠它保命
  • 返回值统一用标准错误码,比如 HAL_OK, HAL_BUSY, HAL_TIMEOUT

来看一个具体的接口声明示例:

/* UART句柄结构体 */
typedef struct {
    uint32_t        Instance;       /* UART实例编号 */
    uint32_t        BaudRate;       /* 波特率 */
    uint32_t        WordLength;     /* 数据位:8或9 */
    uint32_t        StopBits;       /* 停止位:1或2 */
    uint32_t        Parity;         /* 校验位:无/奇/偶 */
    uint32_t        Mode;           /* 模式:TX/RX/TXRX */
    uint8_t         *pRxBuffPtr;    /* 接收缓冲区指针 */
    uint16_t        RxXferSize;     /* 接收数据长度 */
    void            (*RxCpltCallback)(void *handle); /* 接收完成回调 */
} UART_HandleTypeDef;

/* 配置接口 */
HAL_StatusTypeDef HAL_UART_Init(UART_HandleTypeDef *huart);
HAL_StatusTypeDef HAL_UART_DeInit(UART_HandleTypeDef *huart);

/* 发送接口 */
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, 
                                     uint8_t *pData, uint16_t Size, 
                                     uint32_t Timeout);
HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, 
                                        uint8_t *pData, uint16_t Size);
HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, 
                                         uint8_t *pData, uint16_t Size);

/* 接收接口 */
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, 
                                    uint8_t *pData, uint16_t Size, 
                                    uint32_t Timeout);
HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, 
                                       uint8_t *pData, uint16_t Size);
HAL_StatusTypeDef HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, 
                                        uint8_t *pData, uint16_t Size);

嗯,这里要注意,句柄结构体里我放了一个回调函数指针。这是为了把中断和DMA的完成事件通知给上层。你想想看,如果不用回调,上层就得轮询状态,那跟裸机有什么区别?

环形缓冲区设计与实现

接下来聊聊环形缓冲区。这东西在UART接收中太重要了。我曾经接手过一个项目,接收数据用的是固定数组,数据一多就丢包。后来改成环形缓冲区,问题迎刃而解。

环形缓冲区的核心思想就一句话:用两个指针(读指针和写指针)在一个固定大小的数组里循环存取。写指针追着读指针跑,永远不会溢出——除非你读得不够快。

环形缓冲区工作原理 0 1 2 3 4 5 6 7 8 9 写指针 (W) 读指针 (R) 数据写入方向 → 循环绕回 当写指针追上读指针时,缓冲区满;读指针追上写指针时,缓冲区空

实现环形缓冲区,我一般用这个结构体:

typedef struct {
    uint8_t     *buffer;        /* 缓冲区基地址 */
    uint32_t    size;           /* 缓冲区大小(必须是2的幂) */
    volatile uint32_t head;     /* 写指针 */
    volatile uint32_t tail;     /* 读指针 */
} RingBuffer_t;

为什么size必须是2的幂?因为可以用位运算代替取模,速度快很多。head和tail用volatile修饰,是因为中断和主循环会同时访问它们。

核心操作就四个:

  1. 初始化:head = tail = 0,清空缓冲区
  2. 写入:把数据放到buffer[head],head++,如果head == size则绕回0
  3. 读取:从buffer[tail]取数据,tail++,同样绕回
  4. 判空判满:head == tail为空,(head + 1) % size == tail为满

我曾经踩过的坑:判满条件如果用head == tail - 1,在多线程环境下会出问题。正确的做法是预留一个空位,即缓冲区实际可用大小为size - 1。这样head和tail相等时一定是空,不会歧义。

来看写入和读取的实现:

/* 写入一个字节 */
int32_t RingBuffer_Write(RingBuffer_t *rb, uint8_t data) {
    uint32_t next_head = (rb->head + 1) & (rb->size - 1);
    if (next_head == rb->tail) {
        return -1;  /* 缓冲区满 */
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 0;
}

/* 读取一个字节 */
int32_t RingBuffer_Read(RingBuffer_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必须是2的幂,比如64、128、256。我习惯用256,不大不小,够用。

中断与DMA模式下的HAL封装

好了,接口有了,缓冲区有了,接下来就是把它们串起来。中断模式和DMA模式,本质区别在于数据搬运是谁干的。

中断模式:每收到一个字节,CPU就进一次中断,把数据从DR寄存器搬到缓冲区。优点是响应快,缺点是频繁进中断会拖慢系统。

DMA模式:DMA控制器自动把数据从DR搬到内存,搬完了才通知CPU。优点是CPU几乎零负担,缺点是需要DMA通道,而且配置稍复杂。

我在项目中是这样封装的:

/* 中断模式接收启动 */
HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, 
                                       uint8_t *pData, uint16_t Size) {
    if (huart->RxState != HAL_UART_STATE_READY) {
        return HAL_BUSY;
    }
    huart->pRxBuffPtr = pData;
    huart->RxXferSize = Size;
    huart->RxXferCount = Size;
    huart->RxState = HAL_UART_STATE_BUSY_RX;
    
    /* 使能接收中断 */
    __HAL_UART_ENABLE_IT(huart, UART_IT_RXNE);
    return HAL_OK;
}

/* 中断服务函数(在启动文件中被调用) */
void UART_IRQHandler(UART_HandleTypeDef *huart) {
    if (__HAL_UART_GET_IT(huart, UART_IT_RXNE)) {
        uint8_t data = (uint8_t)(huart->Instance->DR & 0xFF);
        
        /* 写入环形缓冲区 */
        if (RingBuffer_Write(&huart->RxRingBuffer, data) != 0) {
            /* 缓冲区满,溢出处理 */
            huart->ErrorCode |= HAL_UART_ERROR_OVERRUN;
        }
        
        __HAL_UART_CLEAR_IT(huart, UART_IT_RXNE);
        
        /* 如果上层指定了接收缓冲区,顺便拷贝 */
        if (huart->RxXferCount > 0) {
            *huart->pRxBuffPtr++ = data;
            huart->RxXferCount--;
            if (huart->RxXferCount == 0) {
                huart->RxState = HAL_UART_STATE_READY;
                if (huart->RxCpltCallback) {
                    huart->RxCpltCallback(huart);
                }
            }
        }
    }
}

DMA模式稍微复杂一点,但核心思路一样:

/* DMA模式接收启动 */
HAL_StatusTypeDef HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, 
                                        uint8_t *pData, uint16_t Size) {
    if (huart->RxState != HAL_UART_STATE_READY) {
        return HAL_BUSY;
    }
    huart->pRxBuffPtr = pData;
    huart->RxXferSize = Size;
    huart->RxState = HAL_UART_STATE_BUSY_RX;
    
    /* 配置DMA:外设地址=UART_DR,内存地址=pData,传输长度=Size */
    HAL_DMA_Start(huart->hdmarx, 
                  (uint32_t)&huart->Instance->DR, 
                  (uint32_t)pData, 
                  Size);
    
    /* 使能UART的DMA接收请求 */
    __HAL_UART_ENABLE_DMA(huart, UART_DMA_RX);
    return HAL_OK;
}

/* DMA传输完成回调 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    /* 数据已经通过DMA直接搬到了pData里,直接通知上层 */
    huart->RxState = HAL_UART_STATE_READY;
    if (huart->RxCpltCallback) {
        huart->RxCpltCallback(huart);
    }
}

我的经验:DMA模式配合环形缓冲区时,可以用DMA的循环模式(circular mode)。这样DMA会自动把数据源源不断地写入环形缓冲区,CPU只需要在空闲时从缓冲区读取。我做过一个4G模块的项目,就是用这种方式,CPU占用率不到5%。

最后,总结一下三种模式的选择建议:

场景 推荐模式 原因
调试输出、日志打印 阻塞模式 简单可靠,不在乎那点延时
不定长数据接收(如AT指令) 中断模式 + 环形缓冲区 每个字节都能及时捕获,不丢数据
大数据量传输(如文件、音频) DMA模式 CPU零负担,带宽高
低功耗场景 DMA模式 CPU可以睡大觉,DMA自己干活

嗯,这一章的内容就到这儿。UART的HAL层设计,说白了就是三件事:接口定义清晰、缓冲区设计稳健、中断/DMA封装到位。把这三点做好了,上层应用写起来就舒服多了。