第3章 UART串口通信:从初始化到调试实战

UART串口,说它是嵌入式开发者的「第二双眼睛」一点也不过分。我做了十几年硬件,每次调板子,第一件事就是把串口先打通。为什么呢?因为只有串口能告诉你芯片到底在干什么——是跑飞了、死锁了,还是压根没启动。

这一章,咱们就围绕CH32V203的UART模块,把初始化、波特率配置、中断接收、轮询发送,还有串口打印调试这些基本功,一个一个啃透。

本章核心目标: 掌握CH32V203 UART的完整驱动流程,能独立实现串口收发,并利用串口进行高效的调试信息输出。

3.1 UART模块的硬件基础

CH32V203内置了最多3路UART(USART1、USART2、UART3)。其中USART1挂载在APB2总线上,USART2和UART3挂载在APB1总线上。这个总线归属直接决定了你要用哪个时钟源来算波特率——我见过不少新手在这里栽跟头。

UART编号 总线归属 最大波特率(理论) 常用引脚
USART1 APB2(最高144MHz) 4.5 Mbps PA9(TX), PA10(RX)
USART2 APB1(最高72MHz) 2.25 Mbps PA2(TX), PA3(RX)
UART3 APB1(最高72MHz) 2.25 Mbps PB10(TX), PB11(RX)

我个人习惯,调试口固定用USART1,因为它的时钟频率高,波特率配置更灵活,而且PA9/PA10这两个引脚在大多数开发板上都直接引出来了。

3.2 波特率配置——算不对就乱码

波特率配置的核心公式其实就一个:

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

其中USARTDIV是一个16位的值,高12位是整数部分,低4位是小数部分。说白了,你要用这个分频系数把时钟频率降到目标波特率的16倍。

举个例子,假设USART1的时钟是144MHz,我想要115200bps:

USARTDIV = 144000000 / (16 × 115200) = 78.125
整数部分 = 78 (0x4E)
小数部分 = 0.125 × 16 = 2 (0x2)
最终USARTDIV寄存器值 = (78 << 4) | 2 = 0x4E2
我曾经踩过的坑: 有一次我直接用整数除法算USARTDIV,结果小数部分被截断了,串口输出全是乱码。后来才发现,CH32V203的波特率生成器对小数部分的分辨率是1/16,必须手动做四舍五入处理。建议用浮点运算算完再取整,或者用移位加查表的方式做定点计算。

3.3 UART初始化流程

初始化UART,我一般按这个顺序来:

  1. 使能时钟——先开GPIO时钟,再开UART时钟
  2. 配置GPIO——TX设为推挽复用输出,RX设为浮空输入
  3. 复位UART——确保寄存器处于默认状态
  4. 配置波特率——写入USARTDIV寄存器
  5. 配置数据格式——数据位、停止位、校验位
  6. 使能UART——先开模块,再开收发
  7. 配置中断(可选)——使能接收中断或发送完成中断

下面是一个完整的初始化代码示例:

void UART1_Init(uint32_t baudrate)
{
    GPIO_InitTypeDef GPIO_InitStructure = {0};
    USART_InitTypeDef USART_InitStructure = {0};
    NVIC_InitTypeDef NVIC_InitStructure = {0};

    // 1. 使能时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);

    // 2. 配置GPIO
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;           // TX
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;     // 复用推挽输出
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;          // RX
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入
    GPIO_Init(GPIOA, &GPIO_InitStructure);

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

    // 4. 配置波特率
    USART_InitStructure.USART_BaudRate = baudrate;

    // 5. 配置数据格式:8数据位,1停止位,无校验
    USART_InitStructure.USART_WordLength = USART_WordLength_8b;
    USART_InitStructure.USART_StopBits = USART_StopBits_1;
    USART_InitStructure.USART_Parity = USART_Parity_No;

    // 6. 配置流控(一般不用)
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;

    // 7. 配置工作模式:同时使能收发
    USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;

    USART_Init(USART1, &USART_InitStructure);

    // 8. 使能接收中断
    USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);

    // 9. 配置NVIC
    NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);

    // 10. 使能UART
    USART_Cmd(USART1, ENABLE);
}
小技巧: 初始化完成后,我习惯先发一个0x55(二进制01010101)来测试串口。如果示波器上能看到方波,说明TX通路没问题。如果收到的是0x55,说明RX也没问题。这个习惯帮我省了不少排查时间。

3.4 中断接收与轮询发送

在实际项目中,接收用中断、发送用轮询,这是最常见的搭配。为什么呢?你想想看,接收是异步的——你不知道数据什么时候来,所以必须用中断去「等」。而发送是同步的——你知道什么时候要发,直接往数据寄存器里写就行了。

3.4.1 中断接收的实现

中断接收的核心是RXNE(接收非空)标志位。当UART收到一个字节,硬件会把RXNE置1,如果中断使能了,就会跳转到中断服务函数。

// 环形缓冲区定义
#define RX_BUF_SIZE 256
static uint8_t rx_buffer[RX_BUF_SIZE];
static volatile uint16_t rx_head = 0;
static volatile uint16_t rx_tail = 0;

void USART1_IRQHandler(void)
{
    if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
    {
        uint8_t data = USART_ReceiveData(USART1);

        // 写入环形缓冲区
        uint16_t next_head = (rx_head + 1) % RX_BUF_SIZE;
        if(next_head != rx_tail)  // 缓冲区未满
        {
            rx_buffer[rx_head] = data;
            rx_head = next_head;
        }
        // 如果缓冲区满了,直接丢弃——实际项目中可以置溢出标志

        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

// 从缓冲区读取一个字节
uint8_t UART1_ReadByte(uint8_t *data)
{
    if(rx_head == rx_tail)
        return 0;  // 缓冲区为空

    *data = rx_buffer[rx_tail];
    rx_tail = (rx_tail + 1) % RX_BUF_SIZE;
    return 1;
}

这里用环形缓冲区,说白了就是为了防止数据丢失。我在一个数据采集项目里,上位机以1ms间隔连续发数据,如果不用缓冲区,中断服务函数还没处理完,下一个字节就覆盖了——那丢数据是必然的。

3.4.2 轮询发送的实现

轮询发送就简单多了。你只需要检查TC(发送完成)标志位,确认上一个字节已经发出去了,再发下一个。

void UART1_SendByte(uint8_t data)
{
    // 等待上一次发送完成
    while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET);
    // 发送数据
    USART_SendData(USART1, data);
}

void UART1_SendString(const char *str)
{
    while(*str)
    {
        UART1_SendByte(*str++);
    }
}
注意: 如果你用USART_SendData()之后立即去修改数据寄存器,可能会覆盖还没发完的数据。一定要等TC标志位变高。我曾经在调试一个电机控制程序时,因为没等TC就连续发数据,结果串口输出全是乱序的——排查了整整一个下午才发现是这个问题。

3.5 串口打印调试技巧

串口打印,说白了就是把printf重定向到UART。这样你就可以像在PC上编程一样,用printf输出调试信息。

3.5.1 重定向printf

在CH32V203上,重定向printf需要实现一个底层函数:

// 在usart.c或main.c中添加
int fputc(int ch, FILE *f)
{
    UART1_SendByte((uint8_t)ch);
    return ch;
}

// 记得在编译选项中勾选 "Use MicroLIB" 或实现_sys_write()
// 否则printf不会调用fputc

配置好之后,你就可以这样用了:

printf("System initialized, SYSCLK = %d Hz\r\n", SystemCoreClock);
printf("UART1 baudrate = 115200\r\n");
printf("Current temperature: %.2f °C\r\n", temperature);

3.5.2 调试信息的分级管理

项目大了之后,到处都是printf,反而找不到关键信息。我习惯用宏定义做分级输出:

#define DEBUG_LEVEL_ERROR   0
#define DEBUG_LEVEL_WARN    1
#define DEBUG_LEVEL_INFO    2
#define DEBUG_LEVEL_DEBUG   3

#define CURRENT_DEBUG_LEVEL DEBUG_LEVEL_INFO

#define DBG_ERROR(fmt, ...)  \
    do { if(CURRENT_DEBUG_LEVEL >= DEBUG_LEVEL_ERROR) \
        printf("[ERR] " fmt "\r\n", ##__VA_ARGS__); } while(0)

#define DBG_WARN(fmt, ...)   \
    do { if(CURRENT_DEBUG_LEVEL >= DEBUG_LEVEL_WARN) \
        printf("[WRN] " fmt "\r\n", ##__VA_ARGS__); } while(0)

#define DBG_INFO(fmt, ...)   \
    do { if(CURRENT_DEBUG_LEVEL >= DEBUG_LEVEL_INFO) \
        printf("[INF] " fmt "\r\n", ##__VA_ARGS__); } while(0)

#define DBG_DEBUG(fmt, ...)  \
    do { if(CURRENT_DEBUG_LEVEL >= DEBUG_LEVEL_DEBUG) \
        printf("[DBG] " fmt "\r\n", ##__VA_ARGS__); } while(0)

这样,发布版本时只需要把CURRENT_DEBUG_LEVEL改成DEBUG_LEVEL_ERROR,所有调试信息就自动关闭了,不用一行一行去删printf。

3.6 常见故障与定位方法

串口调不通,90%的问题出在下面这几个地方。我按排查优先级列出来:

故障现象 可能原因 排查方法
完全无输出 时钟未使能、GPIO模式错误、UART未使能 用示波器量TX引脚,看有没有波形
输出乱码 波特率不匹配、时钟频率算错、小数部分精度不够 核对时钟源频率,重新计算USARTDIV
丢数据 中断优先级太低、缓冲区溢出、未等TC标志 加大环形缓冲区,检查中断响应时间
只能发不能收 RX引脚配置错误、中断未使能、NVIC未配置 检查GPIO_Mode是否为IN_FLOATING,确认NVIC使能
我的排查习惯: 先发一个固定的0x55,用示波器看波形。如果波形正常但PC收不到,检查串口线或USB转串口模块。如果波形不对,再回头查代码。这样能快速把问题定位在硬件还是软件上。

3.7 本章知识体系

下面这张图,把UART串口通信的核心流程串起来了。你可以把它当作调试时的检查清单:

UART串口通信核心流程 1. 时钟使能 2. GPIO模式配置 3. 波特率计算 4. 数据格式配置 5. 使能UART模块 中断接收 轮询发送 调试口诀:先时钟、再GPIO、算波特率、配格式、使能模块、最后选收发模式

嗯,这一章的内容就到这里。UART串口看似简单,但它是整个嵌入式调试体系的基石。把这一章吃透了,后面调试I2C、SPI、甚至是网络协议栈,你都会觉得心里有底。毕竟,串口是你和芯片之间最直接的对话通道。