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接收中太重要了。我曾经接手过一个项目,接收数据用的是固定数组,数据一多就丢包。后来改成环形缓冲区,问题迎刃而解。
环形缓冲区的核心思想就一句话:用两个指针(读指针和写指针)在一个固定大小的数组里循环存取。写指针追着读指针跑,永远不会溢出——除非你读得不够快。
实现环形缓冲区,我一般用这个结构体:
typedef struct {
uint8_t *buffer; /* 缓冲区基地址 */
uint32_t size; /* 缓冲区大小(必须是2的幂) */
volatile uint32_t head; /* 写指针 */
volatile uint32_t tail; /* 读指针 */
} RingBuffer_t;
为什么size必须是2的幂?因为可以用位运算代替取模,速度快很多。head和tail用volatile修饰,是因为中断和主循环会同时访问它们。
核心操作就四个:
- 初始化:head = tail = 0,清空缓冲区
- 写入:把数据放到buffer[head],head++,如果head == size则绕回0
- 读取:从buffer[tail]取数据,tail++,同样绕回
- 判空判满: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封装到位。把这三点做好了,上层应用写起来就舒服多了。