4. UART驱动封装(轮询模式):串口参数结构体设计,发送与接收函数封装,阻塞式数据收发
UART 驱动,说白了就是让芯片能开口说话。我做了这么多年嵌入式,串口始终是最常用的调试手段,也是很多传感器、无线模块的通信基础。今天咱们就聊聊轮询模式下的 UART 驱动封装。
你可能会问:为什么先讲轮询模式?因为简单、可靠、容易理解。中断和 DMA 模式虽然高效,但调试起来坑多。先把轮询模式吃透,后面再升级就顺了。
4.1 串口参数结构体设计
写驱动第一步,不是上来就操作寄存器。我习惯先定义好参数结构体。这样代码可读性强,移植也方便。
一个完整的 UART 配置,至少需要这几个参数:
| 参数 | 类型 | 说明 | 典型值 |
|---|---|---|---|
| 波特率 | uint32_t | 每秒传输的符号数 | 9600, 115200, 921600 |
| 数据位 | uint8_t | 每帧数据位数 | 7, 8, 9 |
| 停止位 | uint8_t | 停止位长度 | 1, 2 |
| 校验位 | uint8_t | 奇偶校验模式 | 无、奇校验、偶校验 |
| 硬件流控 | uint8_t | 是否启用 RTS/CTS | 0 或 1 |
我个人习惯用枚举来定义校验位和停止位,这样代码更清晰:
typedef enum {
UART_PARITY_NONE = 0,
UART_PARITY_ODD = 1,
UART_PARITY_EVEN = 2
} UART_Parity_t;
typedef enum {
UART_STOPBITS_1 = 0,
UART_STOPBITS_2 = 1
} UART_StopBits_t;
typedef struct {
uint32_t baudrate;
uint8_t data_bits;
UART_StopBits_t stop_bits;
UART_Parity_t parity;
uint8_t hw_flow_ctrl;
} UART_Config_t;
我的经验:波特率字段用 uint32_t 而不是 uint16_t,因为有些模块支持 921600 甚至更高。我曾经用 uint16_t 存波特率,结果客户要求 256000 就溢出了,改起来很麻烦。
4.2 初始化与配置函数
结构体定义好了,接下来就是初始化函数。这里我采用分层设计:底层操作寄存器,上层只传参数。
void UART_Init(UART_Reg_t *uart, UART_Config_t *cfg) {
// 1. 使能时钟
UART_ClockEnable(uart);
// 2. 配置 GPIO 复用功能
UART_GPIO_Config(uart);
// 3. 设置波特率
uint16_t div = (SystemCoreClock / (16 * cfg->baudrate));
uart->BRR = div;
// 4. 配置数据位、停止位、校验位
uint32_t cr1 = 0;
cr1 |= (cfg->data_bits == 9) ? (1 << 12) : 0;
cr1 |= (cfg->parity == UART_PARITY_ODD) ? (1 << 10) : 0;
cr1 |= (cfg->parity == UART_PARITY_EVEN) ? (3 << 10) : 0;
uart->CR1 = cr1;
uint32_t cr2 = 0;
cr2 |= (cfg->stop_bits == UART_STOPBITS_2) ? (2 << 12) : 0;
uart->CR2 = cr2;
// 5. 使能 UART
uart->CR1 |= (1 << 13); // UE 位
uart->CR1 |= (1 << 2); // RE 位
uart->CR1 |= (1 << 3); // TE 位
}
嗯,这里要注意:不同型号的 STM32,波特率计算公式可能不一样。F1 系列是 PCLK / (16 * DIV),F4 系列多了 OVER8 位。所以移植的时候,这部分要单独抽出来。
4.3 阻塞式发送函数
轮询模式下的发送,核心逻辑就是:把数据塞进寄存器,等发送完成标志位。
void UART_SendByte(UART_Reg_t *uart, uint8_t data) {
// 等待上一次发送完成
while (!(uart->SR & (1 << 6))); // TC 位
// 发送新数据
uart->DR = data;
}
void UART_SendBuffer(UART_Reg_t *uart, uint8_t *buf, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
UART_SendByte(uart, buf[i]);
}
}
我曾经踩过的坑:有些芯片的 TXE 标志和 TC 标志不一样。TXE 表示数据已移到移位寄存器,TC 表示数据已从引脚发完。如果连续发大量数据,只等 TXE 会导致最后一帧发不完整。我建议发送用 TC 标志,或者发完最后加个 TC 等待。
4.4 阻塞式接收函数
接收就简单了,等 RXNE 标志置位,读数据寄存器:
uint8_t UART_ReceiveByte(UART_Reg_t *uart) {
// 等待接收到数据
while (!(uart->SR & (1 << 5))); // RXNE 位
return (uint8_t)(uart->DR & 0xFF);
}
uint32_t UART_ReceiveBuffer(UART_Reg_t *uart, uint8_t *buf, uint32_t max_len) {
uint32_t count = 0;
while (count < max_len) {
// 检查是否有数据
if (uart->SR & (1 << 5)) {
buf[count++] = (uint8_t)(uart->DR & 0xFF);
}
// 这里可以加超时处理
}
return count;
}
你想想看,如果对方不发数据,程序就卡死在 while 循环里了。所以实际项目中,我一般会加超时机制:
uint32_t UART_ReceiveBuffer_Timeout(UART_Reg_t *uart, uint8_t *buf,
uint32_t max_len, uint32_t timeout_ms) {
uint32_t count = 0;
uint32_t tick_start = GetTick();
while (count < max_len) {
if (uart->SR & (1 << 5)) {
buf[count++] = (uint8_t)(uart->DR & 0xFF);
tick_start = GetTick(); // 收到数据就重置超时
}
if ((GetTick() - tick_start) > timeout_ms) {
break; // 超时退出
}
}
return count;
}
核心要点:轮询模式虽然简单,但超时处理是必须的。没有超时的阻塞接收,在工程上就是定时炸弹。我见过太多因为串口卡死导致整个系统挂掉的案例了。
4.5 知识结构总览
下面这张图,把 UART 轮询驱动的整体结构画出来了。你可以看到,从参数结构体到底层寄存器操作,再到上层的收发函数,每一层都有明确的职责。
4.6 完整使用示例
最后,看看实际怎么用:
// 定义配置
UART_Config_t uart_cfg = {
.baudrate = 115200,
.data_bits = 8,
.stop_bits = UART_STOPBITS_1,
.parity = UART_PARITY_NONE,
.hw_flow_ctrl = 0
};
// 初始化 USART1
UART_Init(USART1, &uart_cfg);
// 发送字符串
char msg[] = "Hello, STM32!\r\n";
UART_SendBuffer(USART1, (uint8_t*)msg, strlen(msg));
// 接收数据(带超时)
uint8_t rx_buf[64];
uint32_t len = UART_ReceiveBuffer_Timeout(USART1, rx_buf, 64, 1000);
if (len > 0) {
// 处理接收到的数据
ProcessData(rx_buf, len);
}
移植小技巧:把 UART_Reg_t 定义成指针类型,这样换芯片时只需要改寄存器基地址。我习惯在头文件里用宏定义:
#define USART1 ((UART_Reg_t *)0x40013800)。换芯片改这一个文件就够了。
轮询模式虽然看起来简单,但它是理解 UART 工作原理的基础。把这一层封装好了,后面加中断、加 DMA、加 FIFO,都是在现有框架上做加法。代码结构清晰了,移植和调试都会省很多时间。