4. UART驱动封装(轮询模式):设计UART句柄结构体,实现阻塞式发送与接收,并加入超时机制
UART驱动,可以说是嵌入式开发中最基础也最常用的外设接口了。很多初学者上来就调库函数,发一个字节等一个字节,觉得挺简单。但真正到了项目里,你会发现——没有超时机制的阻塞式收发,就是个定时炸弹。
我个人习惯,不管多简单的驱动,第一件事就是先把句柄结构体定义好。结构体就是驱动的“身份证”,后面所有操作都围着它转。
4.1 UART句柄结构体设计
句柄结构体,说白了就是把UART相关的所有资源打包在一起。包括硬件外设指针、引脚配置、收发缓冲区、状态标志,还有我们今天要重点讲的超时参数。
我见过不少工程师把波特率、数据位这些参数散落在各个函数里,改个配置得翻遍整个文件。你想想看,这要是项目大了,维护起来得多痛苦?
下面是我在项目中沉淀下来的一套UART句柄定义:
/* UART工作模式枚举 */
typedef enum {
UART_MODE_TX = 0x01, /* 仅发送 */
UART_MODE_RX = 0x02, /* 仅接收 */
UART_MODE_TX_RX = 0x03 /* 收发全双工 */
} UART_Mode_t;
/* UART状态枚举 */
typedef enum {
UART_OK = 0x00, /* 操作成功 */
UART_BUSY = 0x01, /* 忙,正在收发 */
UART_TIMEOUT = 0x02, /* 超时 */
UART_ERROR = 0x03 /* 参数或硬件错误 */
} UART_Status_t;
/* UART句柄结构体 */
typedef struct {
USART_TypeDef *Instance; /* USART外设基地址 */
GPIO_TypeDef *TxPort; /* TX引脚端口 */
uint16_t TxPin; /* TX引脚号 */
GPIO_TypeDef *RxPort; /* RX引脚端口 */
uint16_t RxPin; /* RX引脚号 */
uint32_t BaudRate; /* 波特率 */
uint32_t WordLength; /* 数据位 */
uint32_t StopBits; /* 停止位 */
uint32_t Parity; /* 校验位 */
UART_Mode_t Mode; /* 工作模式 */
uint32_t TxTimeout; /* 发送超时(ms) */
uint32_t RxTimeout; /* 接收超时(ms) */
volatile uint8_t State; /* 驱动状态 */
void (*ErrorCallback)(void *handle); /* 错误回调 */
} UART_Handle_t;
这里有几个设计要点,我重点说一下:
- Instance指针:直接指向USART1/2/3等外设,方便切换目标串口
- TxTimeout/RxTimeout:收发超时分开配置,因为发送和接收的实时性要求往往不同
- State标志:用volatile修饰,因为中断或轮询中会修改它
- ErrorCallback:错误回调函数指针,方便上层做异常处理
4.2 阻塞式发送实现
阻塞式发送,就是调用发送函数后,CPU一直在等,直到数据发完或者超时。说白了就是“你不发完,我就不干别的”。
实现思路很简单:把数据一个字节一个字节往DR寄存器里写,每写一个等TC标志位置位。但这里有个坑——如果硬件出问题了,或者对端不响应,你就永远等下去了。所以超时机制必须加。
UART_Status_t UART_Transmit(UART_Handle_t *handle,
const uint8_t *data,
uint16_t size) {
uint32_t tick_start;
uint16_t i;
/* 参数检查 */
if (handle == NULL || data == NULL || size == 0) {
return UART_ERROR;
}
/* 检查状态,防止重入 */
if (handle->State & UART_STATE_TX_BUSY) {
return UART_BUSY;
}
handle->State |= UART_STATE_TX_BUSY;
tick_start = HAL_GetTick();
for (i = 0; i < size; i++) {
/* 等待发送数据寄存器空 */
while (!(handle->Instance->SR & USART_SR_TXE)) {
/* 超时检查 */
if ((HAL_GetTick() - tick_start) > handle->TxTimeout) {
handle->State &= ~UART_STATE_TX_BUSY;
return UART_TIMEOUT;
}
}
/* 写入数据 */
handle->Instance->DR = data[i];
/* 等待发送完成 */
while (!(handle->Instance->SR & USART_SR_TC)) {
if ((HAL_GetTick() - tick_start) > handle->TxTimeout) {
handle->State &= ~UART_STATE_TX_BUSY;
return UART_TIMEOUT;
}
}
}
handle->State &= ~UART_STATE_TX_BUSY;
return UART_OK;
}
这段代码里,我用了两层while循环:第一层等TXE(发送寄存器空),第二层等TC(发送完成)。为什么要等两个标志?
嗯,这里要注意:TXE只表示数据从DR移到了移位寄存器,但移位寄存器还没发完。如果不等TC就直接发下一个字节,在高速率下可能会造成数据覆盖。我曾在115200波特率下遇到过这个问题,排查了半天才发现是TC没等够。
4.3 阻塞式接收实现
接收和发送是对称的,但有个区别:发送是我们主动推数据,接收是等数据来。所以接收的超时处理更关键——你不知道对端什么时候发数据,但你不能无限等下去。
UART_Status_t UART_Receive(UART_Handle_t *handle,
uint8_t *buffer,
uint16_t size) {
uint32_t tick_start;
uint16_t i;
if (handle == NULL || buffer == NULL || size == 0) {
return UART_ERROR;
}
if (handle->State & UART_STATE_RX_BUSY) {
return UART_BUSY;
}
handle->State |= UART_STATE_RX_BUSY;
tick_start = HAL_GetTick();
for (i = 0; i < size; i++) {
/* 等待接收数据寄存器非空 */
while (!(handle->Instance->SR & USART_SR_RXNE)) {
/* 超时检查 */
if ((HAL_GetTick() - tick_start) > handle->RxTimeout) {
handle->State &= ~UART_STATE_RX_BUSY;
return UART_TIMEOUT;
}
}
/* 读取数据 */
buffer[i] = handle->Instance->DR;
}
handle->State &= ~UART_STATE_RX_BUSY;
return UART_OK;
}
4.4 超时机制设计详解
超时机制,说白了就是给阻塞操作加一个“闹钟”。到点了还没完成,就放弃等待,返回超时错误。
实现方式有很多种,我推荐用系统滴答定时器(SysTick)来做时间基准。STM32的HAL库默认就是1ms中断一次,用HAL_GetTick()就能拿到当前毫秒数。
超时判断的核心逻辑:
/* 记录开始时间 */
uint32_t start = HAL_GetTick();
/* 在循环中检查 */
while (条件不满足) {
if ((HAL_GetTick() - start) > timeout_ms) {
return UART_TIMEOUT;
}
}
这里有个细节:用减法而不是加法来判断超时。因为HAL_GetTick()是32位无符号数,运行50多天后会溢出回0。如果用start + timeout < HAL_GetTick(),溢出时就会出问题。而减法在C语言无符号数运算中是安全的,即使溢出也能正确计算时间差。
| 超时参数 | 推荐值 | 说明 |
|---|---|---|
| TxTimeout | 100ms | 发送1KB数据通常不超过10ms,100ms足够 |
| RxTimeout | 根据协议定 | Modbus通常3.5字符时间,自定义协议建议50-200ms |
| 最小超时 | ≥ 2ms | SysTick精度1ms,太短容易误判 |
4.5 完整驱动封装示例
把上面这些组合起来,就是一个完整的UART轮询驱动。初始化、发送、接收、错误处理,全在句柄里搞定。
/* 初始化UART */
UART_Status_t UART_Init(UART_Handle_t *handle) {
/* 配置GPIO为复用功能 */
GPIO_InitTypeDef gpio = {0};
gpio.Mode = GPIO_MODE_AF_PP;
gpio.Speed = GPIO_SPEED_FREQ_HIGH;
gpio.Pull = GPIO_PULLUP;
gpio.Pin = handle->TxPin;
HAL_GPIO_Init(handle->TxPort, &gpio);
gpio.Pin = handle->RxPin;
gpio.Mode = GPIO_MODE_AF_INPUT;
HAL_GPIO_Init(handle->RxPort, &gpio);
/* 配置USART */
handle->Instance->CR1 = 0;
handle->Instance->CR2 = 0;
handle->Instance->CR3 = 0;
/* 设置波特率、数据位、停止位、校验位 */
/* ... 具体配置代码略,与标准库一致 */
/* 使能USART */
handle->Instance->CR1 |= USART_CR1_UE;
handle->State = UART_STATE_READY;
return UART_OK;
}
/* 使用示例 */
void UART_Example(void) {
UART_Handle_t uart1 = {
.Instance = USART1,
.TxPort = GPIOA, .TxPin = GPIO_PIN_9,
.RxPort = GPIOA, .RxPin = GPIO_PIN_10,
.BaudRate = 115200,
.WordLength = UART_WORDLENGTH_8B,
.StopBits = UART_STOPBITS_1,
.Parity = UART_PARITY_NONE,
.Mode = UART_MODE_TX_RX,
.TxTimeout = 100,
.RxTimeout = 50,
.ErrorCallback = NULL
};
uint8_t tx_buf[] = "Hello UART!";
uint8_t rx_buf[32];
UART_Init(&uart1);
UART_Transmit(&uart1, tx_buf, sizeof(tx_buf));
UART_Receive(&uart1, rx_buf, 10);
}
- 句柄结构体是驱动的“中枢神经”,所有资源都通过它管理
- 阻塞式收发必须加超时,否则硬件异常时系统会死锁
- 超时判断用减法,避免32位溢出问题
- 发送要等TC标志,不能只等TXE
- 接收超时时间要根据数据帧长度和波特率合理设置
这套封装思路,我在多个项目里验证过。从简单的串口打印,到工业现场的Modbus协议,再到和4G模块的AT指令交互,都跑得很稳。你想想看,一个结构体搞定所有配置,一个函数搞定收发,出错了还有超时兜底——这才是工程级的代码。
这张图把整个驱动的架构理清楚了。句柄结构体是中心,初始化、发送、接收三个模块都依赖它,而超时机制贯穿收发全过程。你写驱动时,就按这个结构来组织代码,不会乱。