Keil5实战指南:手把手教你用串口打印调试日志
从“灯闪了没”到“日志说了啥”——嵌入式调试的进化之路
你还记得第一次点亮LED时的心情吗?那盏小小的灯,承载着无数嵌入式工程师的入门记忆。但很快我们就会发现,光靠“灯闪不闪”、“变量停不停”,根本无法应对真实项目中复杂的逻辑跳转、中断嵌套和任务调度。
尤其是在没有操作系统的裸机系统或轻量级RTOS里,程序一旦跑飞,你连它死在哪都无从得知。
这时候,串口打印日志就成了最直接、最有效的“听诊器”。它不像JTAG那样可能暂停CPU运行影响实时性,也不依赖昂贵的逻辑分析仪。只要一根USB-TTL线 + 一个串口助手软件,就能让你“听见”MCU的心跳。
而我们今天要讲的主角——Keil MDK(即Keil5),正是ARM Cortex-M系列开发中最主流的IDE之一。它稳定、成熟、生态完善,尤其适合工业控制、医疗设备等对可靠性要求高的场景。
本文不讲空泛理论,而是带你一步步打通从printf("Hello World");到PC终端显示的完整链路,并分享我在实际项目中总结出的避坑秘籍与优化技巧。
为什么是UART?因为它简单又强大
在所有通信方式中,UART可能是最“土味”但也最实用的一个。
它到底有多简单?
只需要两根线:
-TXD(发送)
-RXD(接收)
再加上一个双方约定好的波特率(比如115200),就能实现全双工通信。不需要时钟线同步,也不需要地址寻址,简直是为调试量身定做的接口。
📌 小知识:STM32上电默认串口1(USART1)通常挂在PA9/PA10引脚,配合ST-Link自带的虚拟串口,几乎零成本就能用起来。
数据是怎么传出去的?
UART传输的是“帧”,每一帧包含:
| 部分 | 内容说明 |
|---|---|
| 起始位 | 1 bit,低电平,标志开始 |
| 数据位 | 8 bit(常用),可7或9 |
| 校验位 | 可选,奇偶校验防误码 |
| 停止位 | 1 或 2 bit,高电平,表示结束 |
举个例子:你调用printf("A"),实际发送的就是字符’A’的ASCII码(0x41),按位从低位到高位依次输出,接收端以相同波特率采样还原。
只要两边波特率误差不超过5%,基本不会出错。像STM32这类现代MCU,内部波特率发生器精度很高,配个115200轻轻松松。
在Keil5里让printf真正“说话”
很多人初学时都会遇到这个问题:代码写了printf,编译也没报错,结果串口就是没输出。
问题出在哪?——标准库函数没有落地到硬件。
C语言里的printf原本是面向PC控制台设计的,在单片机这种“裸奔”环境下,必须手动告诉它:“你要把数据发到哪里去”。
第一步:打开MicroLIB这扇门
这是关键一步!否则你的printf只能活在梦里。
🔧 操作路径:
Project → Options for Target → Target → ✅ Use MicroLIB✅ 打上这个勾之后,Keil才会启用精简版的标准输入输出库(MicroLIB),支持stdio.h中的printf、scanf等功能。
⚠️ 注意:MicroLIB不支持浮点格式化(如
%f)除非额外开启,若需打印float,建议先转成整数或使用sprintf预处理。
第二步:重定向__io_putchar,给printf指条明路
接下来我们要“劫持”标准输出流,让它不再试图写屏幕,而是通过UART发出去。
#include <stdio.h> #include "usart.h" // 确保已初始化huart1 int __io_putchar(int ch) { HAL_UART_Transmit(&huart1, (uint8_t*)&ch, 1, 0xFFFF); return ch; }💡 提示:
__io_putchar是ARM Compiler推荐的标准输出重定向函数,比传统的fputc更规范。
这段代码干了什么?
- 拦截每一个要输出的字符(
ch) - 调用HAL库将该字节通过
huart1发送出去 - 设置超时时间为
0xFFFF,确保发送完成再返回(防止缓冲区溢出)
⚠️注意陷阱:如果超时设为HAL_MAX_DELAY,当TX引脚断开或外设故障时,程序会卡死在这里!
✅ 推荐做法:调试阶段可用有限超时;发布前改为中断或DMA发送。
第三步:写个测试程序,看它动起来
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); printf("🎉 Keil5串口日志启动成功!\r\n"); while (1) { printf("⏱️ 当前时间: %lu ms\r\n", HAL_GetTick()); HAL_Delay(1000); } }烧录后打开XCOM、SSCOM或者Tera Term,选择对应COM口,波特率设为115200,你就应该能看到每秒刷新的时间戳了。
🎯 成功标志:
🎉 Keil5串口日志启动成功! ⏱️ 当前时间: 1000 ms ⏱️ 当前时间: 2000 ms ...如果看不到?别急,后面有专门的【常见问题排查清单】。
日志不是随便打的——高手都在用的设计模式
你以为能打出“Hello World”就完了?真正的工程级日志远不止如此。
1. 分级日志系统:只看我想看的
不同阶段关注的信息不一样。开发初期需要详细跟踪,量产阶段则只需记录错误。
我们可以定义日志级别:
#define LOG_LEVEL_DEBUG 4 #define LOG_LEVEL_INFO 3 #define LOG_LEVEL_WARN 2 #define LOG_LEVEL_ERROR 1 #define LOG_LEVEL_NONE 0 #ifndef LOG_LEVEL #define LOG_LEVEL LOG_LEVEL_DEBUG #endif #define LOG_DEBUG(fmt, ...) do { if(LOG_LEVEL >= LOG_LEVEL_DEBUG) printf("[DBG] " fmt "\r\n", ##__VA_ARGS__); } while(0) #define LOG_INFO(fmt, ...) do { if(LOG_LEVEL >= LOG_LEVEL_INFO) printf("[INF] " fmt "\r\n", ##__VA_ARGS__); } while(0) #define LOG_WARN(fmt, ...) do { if(LOG_LEVEL >= LOG_LEVEL_WARN) printf("[WRN] " fmt "\r\n", ##__VA_ARGS__); } while(0) #define LOG_ERROR(fmt, ...) do { if(LOG_LEVEL >= LOG_LEVEL_ERROR) printf("[ERR] " fmt "\r\n", ##__VA_ARGS__); } while(0)然后这样使用:
LOG_INFO("系统初始化完成"); LOG_DEBUG("ADC读值: %d", adc_value); LOG_ERROR("I2C设备%d通讯失败", dev_addr);再通过编译开关控制输出等级:
# 调试版本 -DLOG_LEVEL=4 # 发布版本 -DLOG_LEVEL=1 // 只显示错误2. 时间戳加持:事件排序不再靠猜
没有时间的日志就像没有经纬度的地图。
强烈建议每条日志带上时间戳:
#define LOG_INFO(fmt, ...) do { \ if(LOG_LEVEL >= LOG_LEVEL_INFO) \ printf("[%lu][INF] " fmt "\r\n", HAL_GetTick(), ##__VA_ARGS__); \ } while(0)输出效果:
[1245][INF] 主循环第5次执行 [2246][WRN] 传感器响应超时 [3247][ERR] CRC校验失败结合毫秒级时间差,轻松判断是否卡顿、延迟异常等问题。
3. 条件编译封装:一键关闭所有日志
为了不影响最终产品的性能和安全性,一定要在发布版本中移除调试日志。
推荐做法:
#ifdef DEBUG #define DEBUG_LOG(fmt, ...) printf("[LOG] " fmt "\r\n", ##__VA_ARGS__) #else #define DEBUG_LOG(fmt, ...) #endif然后在调试时添加-DDEBUG编译选项,发布时不加即可自动消除所有调试输出。
实战避坑指南:那些年我踩过的雷
别以为配置完就能一帆风顺。下面这些坑,我都替你踩过了。
❌ 问题1:串口助手收不到任何数据
🔍 检查清单:
- ✅ 是否启用了Use MicroLIB
- ✅ UART外设是否正确初始化(波特率、GPIO复用)
- ✅ TX引脚是否接反(应接PC的RX)
- ✅ 串口助手波特率是否匹配(常见错误:程序设115200,助手设9600)
- ✅ 使用的是哪个串口?有些板子默认串口不是USART1
- ✅ ST-Link虚拟串口驱动是否安装?(CH340/CP2102等芯片需单独装驱动)
💡 快速验证法:用示波器或逻辑分析仪看TX引脚是否有波形。
❌ 问题2:程序卡死在HAL_UART_Transmit
原因:轮询发送+无限等待,一旦硬件异常就会死锁。
✅ 解决方案:
- 改为有限超时:HAL_UART_Transmit(&huart1, &ch, 1, 10);
- 或升级为中断/DMA发送(适用于大量日志场景)
❌ 问题3:中断里调用printf导致崩溃
🚨 危险操作!中断服务程序(ISR)中禁止使用printf!
因为:
-printf涉及内存分配、字符串处理,耗时长
- 可能引发递归调用(如UART中断触发自身)
- 容易造成堆栈溢出
✅ 正确做法:
- 中断中仅设置标志位
- 在主循环中判断标志并打印日志
volatile uint8_t irq_flag = 0; void EXTI_IRQHandler(void) { irq_flag = 1; __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0); } while(1) { if(irq_flag) { LOG_INFO("外部中断触发"); irq_flag = 0; } }❌ 问题4:中文乱码 or 字符丢失
原因:
- 波特率不匹配(尤其是使用内部RC振荡器时误差大)
- 缓冲区溢出(连续快速打印太多内容)
- PC端串口助手缓冲区满未及时清空
✅ 对策:
- 使用外部晶振提高时钟精度
- 控制日志频率,避免短时间密集输出
- 选用支持大缓冲的串口工具(如SecureCRT、CoolTerm)
架构之美:构建可复用的日志系统
一个好的日志模块应该是低耦合、易移植、可配置的。
参考架构如下:
应用层 │ ├── LOG_INFO("Task started") ├── LOG_ERROR("SPI timeout") │ ↓ 抽象日志接口层(log.h / log.c) │ - 统一日志格式 │ - 支持分级过滤 │ - 时间戳注入 │ ↓ 硬件输出层(usart.c / debug_io.c) │ - __io_putchar 实现 │ - 可替换为SWO、RTT、UDP等 │ ↓ 物理通道 └─ UART → USB-TTL → PC这样的设计使得将来你可以轻松切换输出方式,比如:
- 用SWO引脚 + ITM实现非侵入式跟踪
- 用SEGGER RTT实现高速实时日志
- 用Wi-Fi + UDP实现无线远程诊断
而不必改动上层业务代码。
写在最后:日志虽小,意义重大
也许你会觉得,“不就是打个printf嘛,有这么复杂吗?”
但我想说:能把最基础的事做到极致的人,才配谈高级玩法。
串口日志看似原始,却是嵌入式系统可观测性的起点。它是你在黑暗中摸索时的第一束光,是客户现场出问题后唯一的线索来源。
掌握它,不只是学会一个技术点,更是建立起一种系统化调试思维。
当你能从容地说出“让我先看看日志”而不是“重启试试”,你就已经超越了大多数人。
如果你正在学习Keil5开发,不妨现在就动手:
1. 新建一个工程
2. 配好串口
3. 让第一行printf出现在屏幕上
那一刻,你会感受到一种奇妙的连接——那是你和MCU之间,第一次真正的对话。
🔧 文末彩蛋:想要本文配套的Keil工程模板(含日志分级宏、时间戳、条件编译)?欢迎留言交流,我可以打包分享给你!
💬 互动时间:你在项目中是怎么做日志管理的?有没有因为一条关键日志救过场?欢迎在评论区分享你的故事!