4. 串口打印调试(UART)
串口打印,说白了就是嵌入式世界的“printf”。你想想看,一个单片机跑起来,里面到底发生了什么?寄存器配对了没有?中断进来了没有?状态机走到哪一步了?没有串口打印,你只能靠猜。我做了这么多年固件,可以负责任地告诉你:一个没有串口调试接口的裸机项目,就像在黑夜里开车还不开灯。
这一章,我们就来把UART调试接口彻底搞透。从初始化到printf重定向,从环形缓冲区到日志分级,一步到位。
4.1 UART初始化——别让波特率坑了你
UART初始化其实不复杂,但有几个细节容易翻车。我个人习惯把初始化拆成三步:
- 时钟使能——GPIO时钟和USART时钟都要开
- GPIO配置——TX推挽复用,RX浮空输入或上拉输入
- USART配置——波特率、数据位、停止位、校验位
这里我贴一段我常用的初始化代码,STM32F103为例:
void UART_Init(void)
{
// 1. 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// 2. GPIO配置
GPIO_InitTypeDef gpio;
gpio.GPIO_Pin = GPIO_Pin_9; // TX
gpio.GPIO_Mode = GPIO_Mode_AF_PP; // 推挽复用
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &gpio);
gpio.GPIO_Pin = GPIO_Pin_10; // RX
gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入
GPIO_Init(GPIOA, &gpio);
// 3. USART配置
USART_InitTypeDef usart;
usart.USART_BaudRate = 115200;
usart.USART_WordLength = USART_WordLength_8b;
usart.USART_StopBits = USART_StopBits_1;
usart.USART_Parity = USART_Parity_No;
usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
usart.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_Init(USART1, &usart);
USART_Cmd(USART1, ENABLE);
}
我曾经踩过的坑:波特率设置不对,打印出来全是乱码。后来发现是外部晶振频率和库函数默认值不一致。记住:检查HSE_VALUE宏定义,确保它和你板子上的晶振一致。
4.2 printf重定向——把printf“骗”到串口上
裸机环境下,printf默认输出到标准输出,也就是显示器。但我们没有显示器,只有串口。怎么办?重定向!
说白了,就是告诉编译器:“你别管printf往哪写,我来接管底层输出函数。”在ARM MDK环境下,重写fputc函数即可:
int fputc(int ch, FILE *f)
{
// 等待发送寄存器空
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
USART_SendData(USART1, (uint8_t)ch);
return ch;
}
然后在工程设置里勾选“Use MicroLIB”,这样printf就能通过串口输出了。
小提示:如果你用GCC工具链,重定向的是
_write函数。不同编译器入口不同,但思路完全一样。
4.3 环形缓冲区——别让打印拖慢你的系统
直接调用printf有个大问题:阻塞。每次打印都要等串口发送完一个字节,如果打印量大,CPU就被卡死了。我见过一个项目,就因为调试打印太多,导致中断响应超时,整个系统跑飞。
解决方案就是环形缓冲区。原理很简单:
- 写数据时,只往缓冲区里写,不直接操作串口
- 读数据时,由中断或定时器从缓冲区取数据发送
这样,printf瞬间返回,CPU继续干正事。
下面是一个轻量级环形缓冲区的实现:
#define RING_BUF_SIZE 256
typedef struct {
uint8_t buffer[RING_BUF_SIZE];
volatile uint16_t head;
volatile uint16_t tail;
} RingBuf_t;
RingBuf_t txRing;
// 写一个字节到环形缓冲区
int RingBuf_Put(RingBuf_t *rb, uint8_t data)
{
uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
if (next == rb->tail) {
return -1; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next;
return 0;
}
// 从环形缓冲区读一个字节
int RingBuf_Get(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;
return 0;
}
然后在USART发送完成中断里,调用RingBuf_Get取出数据发送:
void USART1_IRQHandler(void)
{
if (USART_GetITStatus(USART1, USART_IT_TXE) != RESET) {
uint8_t data;
if (RingBuf_Get(&txRing, &data) == 0) {
USART_SendData(USART1, data);
} else {
USART_ITConfig(USART1, USART_IT_TXE, DISABLE);
}
}
}
核心要点:环形缓冲区用“头尾指针”实现先进先出。头指针指向写位置,尾指针指向读位置。当head == tail时,缓冲区为空;当(head+1)%SIZE == tail时,缓冲区满。注意:实际可用空间是SIZE-1,因为要区分空和满。
4.4 日志分级——让调试信息不再“一锅粥”
项目大了以后,调试信息会爆炸。什么“进入函数A”、“变量x=123”、“错误:传感器无响应”……全混在一起,根本看不清。这时候就需要日志分级。
我一般分四个等级:
| 等级 | 宏定义 | 颜色(终端) | 使用场景 |
|---|---|---|---|
| ERROR | LOG_ERROR |
红色 | 系统无法继续运行的致命错误 |
| WARN | LOG_WARN |
黄色 | 不影响运行但需要注意的异常 |
| INFO | LOG_INFO |
绿色 | 关键状态变化、初始化完成等 |
| DEBUG | LOG_DEBUG |
白色 | 详细调试信息,发布时关闭 |
实现方式很简单,用宏控制:
#define LOG_LEVEL LOG_LEVEL_DEBUG
#define LOG_ERROR(fmt, ...) \
do { printf("[ERROR] " fmt "\r\n", ##__VA_ARGS__); } while(0)
#define LOG_WARN(fmt, ...) \
do { printf("[WARN] " fmt "\r\n", ##__VA_ARGS__); } while(0)
#define LOG_INFO(fmt, ...) \
do { printf("[INFO] " fmt "\r\n", ##__VA_ARGS__); } while(0)
#if LOG_LEVEL >= LOG_LEVEL_DEBUG
#define LOG_DEBUG(fmt, ...) \
do { printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__); } while(0)
#else
#define LOG_DEBUG(fmt, ...) // 发布时直接空定义
#endif
使用时:
LOG_INFO("系统初始化完成,主频 %d MHz", 72);
LOG_WARN("传感器读取超时,重试第 %d 次", retry);
LOG_ERROR("Flash写入失败,地址: 0x%08X", addr);
我的习惯:开发阶段LOG_LEVEL开到DEBUG,发布前改成INFO或WARN。DEBUG信息全部编译掉,不占ROM也不占CPU。
4.5 本章知识体系
下面这张图把本章的核心逻辑串起来了:
4.6 避坑指南
最后,分享几个我这些年踩过的坑:
- 波特率误差:系统时钟不准时,波特率会偏移。115200以下还好,超过1Mbps时一定要用示波器量一下实际波形。
- 环形缓冲区溢出:打印太快,中断来不及发送。我一般把缓冲区设到512字节以上,同时控制单次打印长度。
- 中断优先级:USART中断优先级不能太低,否则会被其他高优先级中断长时间阻塞,导致丢数据。
- 日志等级宏:发布前记得把LOG_LEVEL调到INFO或WARN,否则DEBUG信息会拖慢系统。