第3章 UART串口通信驱动与优化

UART串口,搞嵌入式的人天天打交道。调试打印、跟传感器通信、和上位机握手,哪样都离不开它。CH32V203这颗芯片的UART模块,我用了快两年,有些坑确实得跟大家说说。

这一章,咱们就围绕UART的初始化、中断接收、轮询发送、波特率误差和环形缓冲区这几个核心点展开。说白了,就是让你能写出一个稳定、高效的串口驱动。

3.1 UART初始化流程

初始化UART,步骤其实不多,但顺序错了就容易出问题。我个人习惯按这个流程走:

  1. 使能时钟:先开GPIO时钟,再开UART模块时钟。顺序反了可能会卡住。
  2. 配置GPIO复用:TX设为推挽输出,RX设为浮空输入或上拉输入。
  3. 复位UART:调用USART_DeInit,把寄存器清干净。
  4. 配置参数:波特率、数据位、停止位、校验位。
  5. 配置中断:使能接收中断(RXNE),如果需要发送完成中断也可以开。
  6. 使能UART:最后调用USART_Cmd。

这里有个细节——先配置再使能。我在项目中遇到过,有人先使能了UART再去配参数,结果第一个字节就发乱了。

初始化代码示例(简化版):

void UART_Init(uint32_t baudrate)
{
    GPIO_InitTypeDef GPIO_InitStructure = {0};
    USART_InitTypeDef USART_InitStructure = {0};

    // 1. 使能时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE);

    // 2. GPIO配置:TX-PA2, RX-PA3
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    // 3. 复位UART
    USART_DeInit(USART2);

    // 4. 配置参数
    USART_InitStructure.USART_BaudRate = baudrate;
    USART_InitStructure.USART_WordLength = USART_WordLength_8b;
    USART_InitStructure.USART_StopBits = USART_StopBits_1;
    USART_InitStructure.USART_Parity = USART_Parity_No;
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
    USART_Init(USART2, &USART_InitStructure);

    // 5. 使能接收中断
    USART_ITConfig(USART2, USART_IT_RXNE, ENABLE);
    NVIC_EnableIRQ(USART2_IRQn);

    // 6. 使能UART
    USART_Cmd(USART2, ENABLE);
}

3.2 中断接收与轮询发送

接收用中断,发送用轮询——这是工业现场最常用的组合。为什么?

接收用中断:因为你不知道数据什么时候来。用中断,CPU可以干别的事,数据到了再处理。我见过有人用轮询接收,结果CPU占用率飙到80%,别的任务全卡死了。

发送用轮询:发送是主动行为,你知道什么时候发。轮询发送简单可靠,而且发送速度通常比接收快,不会阻塞太久。

中断服务函数里,我建议只做一件事:把收到的字节扔进环形缓冲区。千万别在中断里做复杂处理,比如解析协议、写Flash——那是应用层的事。

中断服务函数模板:

void USART2_IRQHandler(void)
{
    if(USART_GetITStatus(USART2, USART_IT_RXNE) != RESET)
    {
        uint8_t data = USART_ReceiveData(USART2);
        RingBuf_Write(&uart_rx_buf, data);  // 写入环形缓冲区
    }
}

轮询发送就简单了:

void UART_SendByte(uint8_t data)
{
    while(USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET);
    USART_SendData(USART2, data);
}

嗯,这里要注意:发送完成标志(TC)和发送寄存器空标志(TXE)是两回事。TXE表示数据已经移到移位寄存器,可以写下一个字节了。TC表示整个字节已经发送完毕。轮询发送用TXE就够了,效率更高。

3.3 波特率误差分析

波特率误差,这是个老生常谈的问题。CH32V203的UART时钟源来自APB总线,通过分频得到目标波特率。公式很简单:

波特率 = 时钟频率 / (16 × USARTDIV)

但问题是,USARTDIV是整数分频,不一定能精确匹配目标值。比如系统时钟96MHz,想要115200bps:

USARTDIV = 96000000 / (16 × 115200) ≈ 52.0833

取整后是52,实际波特率 = 96000000 / (16 × 52) ≈ 115384.6

误差 = (115384.6 - 115200) / 115200 ≈ 0.16%

这个误差在±2%以内,通信没问题。但有些特殊波特率,比如250000bps,误差可能超过3%,就会出现丢字节。

目标波特率 实际波特率 误差 是否可用
9600 9600 0% ✅ 完美
115200 115384 +0.16% ✅ 可用
250000 260869 +4.35% ❌ 可能丢数据
921600 923076 +0.16% ✅ 可用

避坑指南:我曾经在一个项目里用了250000bps的波特率,结果两台设备通信时好时坏。查了两天才发现是波特率误差太大。后来改成230400bps,问题解决。所以,选波特率时最好算一下误差,别想当然。

3.4 环形缓冲区实现与优化

环形缓冲区,说白了就是一个固定大小的数组,加上读写指针。写指针追着读指针转圈。它的好处是:中断里只管写,主循环里只管读,互不干扰。

我常用的环形缓冲区结构体:

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} RingBuf_t;

读写操作:

// 写一个字节
uint8_t RingBuf_Write(RingBuf_t *rb, uint8_t data)
{
    uint16_t next_head = (rb->head + 1) & (RING_BUF_SIZE - 1);
    if(next_head == rb->tail) {
        return 1;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 0;
}

// 读一个字节
uint8_t RingBuf_Read(RingBuf_t *rb, uint8_t *data)
{
    if(rb->head == rb->tail) {
        return 1;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) & (RING_BUF_SIZE - 1);
    return 0;
}

这里有个优化点:缓冲区大小必须是2的幂。这样取模运算可以用位与(&)代替,速度快很多。我一般设成256或512字节,够用又不浪费内存。

优化建议:

  • 读写指针加volatile,防止编译器优化导致中断和主循环不同步。
  • 缓冲区大小用宏定义,方便调试时调整。
  • 如果数据量很大,可以考虑用DMA+环形缓冲区,CPU几乎零负担。

你想想看,有了环形缓冲区,中断服务函数就变得非常轻量——只写一个字节,然后退出。主循环里再慢慢处理数据。这样既不会丢数据,也不会影响实时性。

UART串口驱动核心结构 UART外设 CH32V203 USART2 中断服务函数 USART2_IRQHandler 环形缓冲区 RingBuf_t RXNE中断 写入数据 应用层读取 主循环轮询 轮询发送 TXE标志 关键设计原则: • 中断服务函数只做一件事:将接收到的字节写入环形缓冲区 • 主循环负责从环形缓冲区读取数据并进行协议解析或处理 • 发送采用轮询方式,利用TXE标志判断发送寄存器是否空闲

最后说一句,UART驱动写好了,后面调试其他外设会轻松很多。打印日志、跟PC通信,全靠它。所以,花点时间把这一块打磨好,绝对值。