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 轮询驱动的整体结构画出来了。你可以看到,从参数结构体到底层寄存器操作,再到上层的收发函数,每一层都有明确的职责。

UART 轮询驱动分层架构 应用层调用 收发函数封装层 UART_SendByte / UART_SendBuffer / UART_ReceiveByte / UART_ReceiveBuffer 配置初始化层 UART_Init / UART_Config_t 结构体 / 波特率计算 硬件抽象层 (HAL) 寄存器映射 / 时钟使能 / GPIO 复用配置 STM32 UART 外设硬件 用户代码 main() 中调用 参数结构体 移植时修改 芯片相关 移植时重写

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,都是在现有框架上做加法。代码结构清晰了,移植和调试都会省很多时间。