第3章 UART串口通信:从初始化到调试实战
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
3.3 UART初始化流程
初始化UART,我一般按这个顺序来:
- 使能时钟——先开GPIO时钟,再开UART时钟
- 配置GPIO——TX设为推挽复用输出,RX设为浮空输入
- 复位UART——确保寄存器处于默认状态
- 配置波特率——写入USARTDIV寄存器
- 配置数据格式——数据位、停止位、校验位
- 使能UART——先开模块,再开收发
- 配置中断(可选)——使能接收中断或发送完成中断
下面是一个完整的初始化代码示例:
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);
}
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++);
}
}
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使能 |
3.7 本章知识体系
下面这张图,把UART串口通信的核心流程串起来了。你可以把它当作调试时的检查清单:
嗯,这一章的内容就到这里。UART串口看似简单,但它是整个嵌入式调试体系的基石。把这一章吃透了,后面调试I2C、SPI、甚至是网络协议栈,你都会觉得心里有底。毕竟,串口是你和芯片之间最直接的对话通道。