第一个日志程序:从printf到串口输出
说实话,很多嵌入式新手一开始都会问:“调试信息怎么打印出来?”
答案很简单——printf。但问题来了,在单片机里,printf默认是往哪里打印的?标准输出?你的板子上可没有显示器啊。
所以,我们得把printf的输出“拐”到串口上去。这就是今天要干的事。
1. 为什么是printf?
我刚开始做嵌入式那会儿,调试全靠LED闪烁。一个灯闪两下代表变量A超了,闪三下代表变量B错了……后来项目复杂了,这种“灯语”根本不够用。
printf的好处很明显:
- 格式化输出:直接打印变量值、字符串、十六进制,省去手动拼数据
- 可读性强:日志里能加时间戳、函数名,一眼看出问题在哪
- 移植方便:只要重定向底层输出函数,代码几乎不用改
我的习惯:项目初期就把printf调通,后面调试能省一半时间。别等到出bug了再临时加打印,那时候往往已经晚了。
2. 重定向printf到串口
说白了,printf最终会调用一个底层函数来输出字符。在C标准库中,这个函数通常是fputc(或putchar)。我们只需要把这个函数“劫持”掉,让它把字符发到串口就行了。
2.1 硬件准备
你需要一块开发板,串口已经初始化好。以STM32为例,串口初始化代码大致如下:
// 串口初始化(以USART1为例)
void USART1_Init(void)
{
// 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// 配置TX(PA9)为推挽复用输出
GPIO_InitTypeDef gpio;
gpio.GPIO_Pin = GPIO_Pin_9;
gpio.GPIO_Mode = GPIO_Mode_AF_PP;
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &gpio);
// 配置RX(PA10)为浮空输入
gpio.GPIO_Pin = GPIO_Pin_10;
gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &gpio);
// 配置USART1
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);
}
2.2 重定向fputc
接下来是关键一步。在Keil或IAR环境下,重定向fputc:
// 重定向printf到串口1
int fputc(int ch, FILE *f)
{
// 等待发送缓冲区空
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
// 发送一个字节
USART_SendData(USART1, (uint8_t)ch);
return ch;
}
我曾经踩过的坑:在GCC环境下(比如STM32CubeIDE),重定向方式略有不同。需要实现
_write函数,而不是fputc。如果你用的是GCC,记得查一下对应的重定向方法,否则printf死活不输出。
2.3 测试一下
好了,现在可以写个简单的测试程序:
#include <stdio.h>
int main(void)
{
USART1_Init(); // 初始化串口
printf("Hello, Embedded World!\r\n");
printf("System Clock: %d MHz\r\n", 72);
printf("Log Level: %s\r\n", "DEBUG");
while(1)
{
// 主循环
}
}
打开串口助手,波特率设成115200,你应该能看到三行输出。嗯,就是这么简单。
3. 实现简单的日志输出
光能打印还不够,我们得让日志有“层次感”。我习惯把日志分成几个级别:
| 级别 | 宏定义 | 说明 |
|---|---|---|
| 调试 | LOG_DEBUG | 开发阶段用,发布时关掉 |
| 信息 | LOG_INFO | 正常运行时的重要信息 |
| 警告 | LOG_WARN | 可能有问题,但还能继续跑 |
| 错误 | LOG_ERROR | 出错了,需要处理 |
基于这个分级,我们可以封装几个宏:
#define LOG_DEBUG(fmt, ...) printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__)
#define LOG_INFO(fmt, ...) printf("[INFO] " fmt "\r\n", ##__VA_ARGS__)
#define LOG_WARN(fmt, ...) printf("[WARN] " fmt "\r\n", ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) printf("[ERROR] " fmt "\r\n", ##__VA_ARGS__)
使用起来非常直观:
int temperature = 25;
LOG_INFO("Current temperature: %d°C", temperature);
if (temperature > 80)
{
LOG_WARN("Temperature too high!");
}
if (temperature > 100)
{
LOG_ERROR("Overheat! Shutting down.");
}
你想想看:如果不用日志分级,所有打印混在一起,调试时满屏都是信息,根本分不清哪些是警告、哪些是错误。有了分级,一眼就能定位问题。
4. 核心逻辑流程图
下面这张图展示了从printf到串口输出的完整流程:
5. 避坑指南
- 乱码问题:串口波特率、数据位、停止位必须和PC端一致。我遇到过好几次,板子设的115200,串口助手设的9600,出来全是乱码。
- 输出卡死:如果
printf里用了浮点数(比如%f),而你的编译器没有开启浮点支持,程序可能会卡死。解决办法:要么开启浮点支持,要么避免在日志里用浮点。 - 缓冲区溢出:
printf内部有缓冲区,如果连续大量打印,缓冲区可能满。实时性要求高的场合,建议用puts或直接写串口寄存器。
我的小技巧:在调试阶段,我会在日志里加上时间戳。比如用系统滴答定时器获取毫秒数,然后
printf("[%lu] ", tick)。这样就能知道每条日志发生的精确时间,对排查时序问题特别有用。
好了,到这里你已经学会了如何用printf打印日志,并且把它重定向到串口。说白了,就是三步:初始化串口、重定向fputc、封装日志宏。下次调试的时候,别再只靠LED闪烁了,试试这个办法,你会爱上它的。