IAR日志输出重定向到串口:从零实现方案
调试的“盲区”:为什么我们总在关键时刻看不到日志?
你有没有遇到过这样的场景?
产品在现场运行时突然死机,客户急得打电话来,而你手头只有固件版本和模糊的现象描述。你想查printf输出——可那条调试线早就拔了,板子封装好了,根本没有连着电脑。
或者更糟:你在做长时间稳定性测试,程序跑了十几个小时后出问题,但全程没有任何运行轨迹记录。重启、复现?根本做不到。
这正是传统嵌入式调试方式的软肋——依赖调试器。IAR Embedded Workbench 虽然强大,但它默认的日志机制基于“半主机(semihosting)”,一旦脱离 J-Link 或 ST-Link,printf就像断了网的手机,发不出任何消息。
这不是功能缺失,而是设计局限。
但我们可以绕过去:把日志输出从调试通道转移到物理串口上。
这样一来,哪怕没有调试器,哪怕设备远在千里之外,只要 UART 还通着,你就能看到系统的每一句“心跳”。
本文将带你一步步构建一个完整的日志重定向系统——不靠玄学配置,不依赖 IDE 黑盒机制,从底层原理到实战代码,彻底掌握这项嵌入式开发中的“必杀技”。
半主机是怎么让我们“卡死”的?
先说清楚一件事:半主机不是坏东西。它在开发初期非常有用,能让你用printf直接打印变量,就像写 PC 程序一样方便。
但它的工作方式决定了它的致命弱点。
它的本质是“请求服务”
当你调用printf("Hello, %d\n", value);时,IAR 编译器会把这段代码链接成一个特殊的陷阱指令:
BKPT #0xAB这条指令会让 CPU 暂停执行,通知调试器:“我需要输出一些数据!”
然后调试器通过 SWD 接口读取寄存器里的信息,把字符串拿走,显示在你的 IAR Console 中。
听起来很聪明,对吧?但代价是什么?
- CPU 必须停下来等→ 实时性被破坏
- 必须连着调试器→ 脱机即失效
- Release 版本可能卡住→ 因为没人响应这个中断
所以你会发现,有些固件烧进去后一运行就“卡死”——其实它没死,只是在printf处无限等待一个永远不会来的调试器响应。
🛑 典型症状:程序停在
low_level_init或某个库函数里不动了,单步还能走,全速就卡住 —— 很大概率是 semihosting 在作祟。
如何关闭它?
答案很简单:告诉 IAR 不要用半主机。
打开项目设置:
Project → Options → C/C++ Compiler → Extra Options添加这一行:
--no_semihosts同时确保 Runtime Library 是 “Normal” 而非 “Retarget” 或其他精简模式。
这样编译出来的代码就不会再插入BKPT #0xAB指令了。
但新的问题来了:printf的输出去哪儿了?
——现在轮到我们自己接管了。
把printf的“嘴”接到串口上
目标很明确:让每一次printf都变成 UART 发送一个字节。
幸运的是,IAR 提供了一个极其简洁的接口:只要你实现一个叫low_level_putchar的函数,它就会自动用来输出所有标准流(stdout)的数据。
只需一个函数,改变整个输出路径
#include "uart.h" int low_level_putchar(int ch) { uart_send_byte((uint8_t)ch); while (!uart_tx_complete()); return 1; }就这么简单?没错。
- 函数名必须是
low_level_putchar - 参数是
int类型的字符 - 返回值是成功发送的数量(通常为 1)
当printf格式化完字符串后,会逐个调用这个函数发送每个字符。你不需要修改任何已有代码,也不需要替换printf宏,一切静默完成。
✅ 小贴士:你也可以选择实现
__write(int handle, const unsigned char *buf, size_t size)来支持批量写入,效率更高。但对于大多数调试场景,low_level_putchar已经足够轻量且直观。
为什么选它而不是_write?
虽然_write更通用(可以区分 stdout/stderr),但在 IAR 中,low_level_putchar是专为字符级输出优化的入口点,行为更稳定,尤其适合资源受限的 MCU。
而且它只关注“怎么发一个字”,逻辑清晰,易于调试。
串口驱动:别让硬件拖了后腿
有了输出函数,还得有可靠的底层支撑。如果串口发不出数据,再好的重定向也白搭。
我们来看一个适用于 STM32 或类似 Cortex-M 平台的简化驱动框架。
初始化不能马虎
// uart.h void uart_init(uint32_t baudrate); void uart_send_byte(uint8_t data); int uart_tx_complete(void);// uart.c #include "stm32f4xx.h" // 根据实际芯片包含头文件 static volatile uint8_t tx_done = 1; void uart_init(uint32_t baudrate) { // 使能 GPIO 和 USART1 时钟 RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; RCC->APB2ENR |= RCC_APB2ENR_USART1EN; // PA9 为 TX 复用功能 GPIOA->MODER &= ~GPIO_MODER_MODER9_Msk; GPIOA->MODER |= GPIO_MODER_MODER9_1; // 复用模式 GPIOA->AFR[1] |= 7 << (9 - 8)*4; // AF7 -> USART1 // 波特率设置(假设 PCLK2 = 84MHz) USART1->BRR = 84000000 / baudrate; // 启用发送和 UART USART1->CR1 = USART_CR1_TE | USART_CR1_UE; // 等待发送器准备好 while (!(USART1->SR & USART_SR_TXE)); }初始化要做的事不少:
- 开启外设时钟
- 配置引脚为复用模式
- 设置波特率寄存器
- 启动 UART 模块
其中最容易出错的是时钟频率算错和引脚复用配置错误。建议使用厂商提供的参考手册核对每一位定义。
发送与状态检测
void uart_send_byte(uint8_t data) { tx_done = 0; USART1->DR = data; // 写入数据寄存器,触发发送 } int uart_tx_complete(void) { return (USART1->SR & USART_SR_TC) ? (tx_done = 1) : tx_done; }这里用了简单的标志位跟踪传输完成状态。主循环中通过while(!uart_tx_complete())实现阻塞等待。
⚠️ 注意:TC(Transmission Complete)标志表示帧已完全移出,比TXE(Transmit Data Register Empty)更可靠。
架构之美:解耦带来的自由
这套机制之所以强大,在于它的分层清晰、职责分明:
[应用层] ↓ printf("Status: %d\n", status) [DLIB 标准库] ↓ 自动调用 low_level_putchar(ch) [用户重定向层] ↓ uart_send_byte(ch) [硬件抽象层] ↓ USART1->DR = ch [物理层]每一层都只关心自己的事:
- 应用层专注业务逻辑
- DLIB 处理格式化
- 用户层决定输出去哪
- 驱动层搞定硬件细节
这种解耦让你可以轻松切换输出方式:今天打串口,明天换 USB CDC,后天甚至通过 LoRa 发远程日志,只需改几行代码。
实战中那些“坑”,我都替你踩过了
别以为写完代码就万事大吉。真实项目中,以下几点经常让人抓狂:
坑点一:串口助手收不到第一个字符
现象:每次上电,PC 端串口工具只能收到从第二个字符开始的内容。
原因:UART 模块还没完全启动,你就开始发数据了!
✅ 解决方案:在main()最开始加一小段延迟,或确保SystemCoreClock正确更新后再调用uart_init()。
可以在main开头加:
SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_ENABLE_Msk; for (__IO int i = 0; i < 1000; i++);给硬件一点“热身”时间。
坑点二:高频率日志导致系统卡顿
当你频繁调用printf("%lu: Sensor=%d\n", tick, val);,每个字符都要阻塞等待发送完成,主循环就被拖慢了。
✅ 解决方案:引入环形缓冲 + 中断发送
#define TX_BUFFER_SIZE 128 static uint8_t tx_buffer[TX_BUFFER_SIZE]; static uint16_t tx_head, tx_tail; void uart_send_byte_nonblocking(uint8_t data) { uint16_t next = (tx_head + 1) % TX_BUFFER_SIZE; while (next == tx_tail); // 简单阻塞,防止溢出 tx_buffer[tx_head] = data; tx_head = next; // 开启 TXE 中断(如果尚未开启) USART1->CR1 |= USART_CR1_TXEIE; }然后在中断服务程序中逐个发送:
void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_TXE && tx_head != tx_tail) { uint8_t data = tx_buffer[tx_tail]; tx_tail = (tx_tail + 1) % TX_BUFFER_SIZE; USART1->DR = data; } if (tx_head == tx_tail) { USART1->CR1 &= ~USART_CR1_TXEIE; // 缓冲空,关中断 } }改造后的low_level_putchar改为调用非阻塞版本即可。
坑点三:中文乱码或换行符不对
常见于 Windows 串口助手(如 XCOM、SSCOM)。
\n只换行 → 需要\r\n才能正常显示- 字符编码 UTF-8 vs GBK 混淆
✅ 解决办法:
// 在输出前统一处理 printf("Info: system ready\r\n");或者封装一层宏:
#define LOG(fmt, ...) printf("[LOG] " fmt "\r\n", ##__VA_ARGS__)既规范格式,又避免遗漏换行。
设计进阶:不只是“能用”,更要“好用”
当你已经实现了基本功能,下一步就是让它变得更智能、更适合工程化使用。
日志分级控制
#define LOG_DEBUG 1 #define LOG_INFO 2 #define LOG_WARN 3 #define LOG_ERR 4 #ifndef LOG_LEVEL #define LOG_LEVEL LOG_DEBUG #endif #define debug(fmt, ...) do { if (LOG_LEVEL <= LOG_DEBUG) printf("[D] " fmt "\r\n", ##__VA_ARGS__); } while(0) #define info(fmt, ...) do { if (LOG_LEVEL <= LOG_INFO) printf("[I] " fmt "\r\n", ##__VA_ARGS__); } while(0) #define warn(fmt, ...) do { if (LOG_LEVEL <= LOG_WARN) printf("[W] " fmt "\r\n", ##__VA_ARGS__); } while(0) #define error(fmt, ...) do { if (LOG_LEVEL <= LOG_ERR) printf("[E] " fmt "\r\n", ##__VA_ARGS__); } while(0)编译时通过-DLOG_LEVEL=LOG_WARN控制输出级别,Release 版本直接关闭调试日志。
添加时间戳
结合 SysTick 获取毫秒级时间:
extern uint32_t sys_tick_ms; #define log_ts(level, fmt, ...) \ printf("[%s][%06lu] " fmt "\r\n", level, sys_tick_ms, ##__VA_ARGS__) // 使用 log_ts("INFO", "Sensor %d updated", sensor_id);便于事后分析事件顺序和耗时。
功耗敏感场景怎么办?
在低功耗应用中,一直开着 UART 是浪费电的。
✅ 策略建议:
- 进入 Stop/LowPower 模式前关闭 UART 时钟
- 唤醒后再重新初始化
- 或仅在特定调试阶段启用日志(通过按键触发)
安全起见,量产固件可通过编译开关彻底移除日志代码。
总结:从“能跑”到“专业”的一步之遥
将 IAR 的日志输出重定向至串口,看似只是一个小小的printf重定向,实则是嵌入式开发者迈向专业化的重要一步。
它背后涉及的知识点包括:
- 编译器行为理解(semihosting vs retarget)
- C 运行时库工作机制
- 外设驱动编写能力
- 系统架构设计思维
更重要的是,它教会我们一个道理:不要依赖开发环境的便利,而要建立独立于工具链的可观测性体系。
当你能在无调试器的情况下依然看清系统内部状态时,你就真正掌握了对代码的掌控力。
如果你正在做一个新项目,不妨现在就动手:
1. 关闭 semihosting
2. 实现low_level_putchar
3. 接好串口线
4. 打印第一句 “Hello, embedded world!”
那一刻你会明白:原来调试,也可以如此自由。
欢迎在评论区分享你的实现经验,或提出遇到的问题,我们一起解决。