手把手教你用UART实现printf重定向:从原理到实战的完整指南
你有没有过这样的经历?
代码烧进单片机后,一切看似正常——LED在闪、电机在转,但程序到底运行到了哪一步?变量值对不对?心里完全没底。这时候,如果能像写PC程序那样,加一句printf("x = %d\n", x);就能看到输出,该多好!
遗憾的是,大多数嵌入式设备没有显示器,也没有键盘。那我们还能不能用printf?答案是:完全可以!而且方法比你想得更简单。
本文将带你一步步打通“在MCU上使用printf”的任督二脉。不是照搬手册,而是从一个工程师的真实视角出发,讲清楚背后的机制、常见的坑、以及如何写出稳定可靠的串口打印功能。
为什么我们需要“重定向”?
在标准C环境中,printf默认把内容输出到“标准输出”(stdout),也就是你的电脑终端或控制台。但在裸机嵌入式系统中,并不存在这个“终端”。此时,调用printf实际上调用了C库中的一个弱符号函数——通常是_write。
这个函数原本是个空壳,它的作用就是:当有人想往 stdout 写数据时,由你来决定这些数据去哪儿。
所以,“重定向”的本质其实非常朴素:
拦截标准输出请求,把每个字符通过UART发出去。
听起来是不是很简单?但要真正用起来不出错,还得搞明白几个关键点。
UART不是魔法,它是怎么传数据的?
先别急着写代码,咱们得知道UART是怎么工作的。否则一旦出问题,连查都不知道从哪儿查起。
UART是一种异步串行通信协议,意味着它不需要时钟线同步,只靠双方事先约定好的波特率来协调收发节奏。
数据帧长什么样?
一次典型的UART传输包含以下几个部分:
| 部分 | 说明 |
|---|---|
| 起始位 | 1 bit,低电平,表示开始 |
| 数据位 | 5~8 bit,常用8位(一个字节) |
| 校验位 | 可选,用于简单错误检测 |
| 停止位 | 1 或 2 bit,高电平,表示结束 |
举个例子:你要发送字符'A'(ASCII码为0x41),采用8N1格式(8数据位、无校验、1停止位),那么实际在线路上看到的就是这样一串电平变化:
[低] 1 0 0 0 0 0 1 0 [高] → 总共10位 ↑ ↑ LSB MSB + 停止注意:低位先发(LSB first),这是UART的默认规则。
波特率必须匹配!
如果你的MCU设置的是115200 bps,而串口助手设成了9600,结果只能是乱码或者根本收不到数据。
而且别忘了:晶振不准也会导致波特率偏差。一般要求误差小于±2%,否则采样会出错。建议开发阶段使用外部晶振,而不是内部RC振荡器。
printf 到底干了啥?别被它的表象骗了
很多人以为printf是直接和硬件打交道的,其实不然。它只是一个高级封装,真正的输出动作是由底层系统调用完成的。
当你写下这行代码:
printf("Temp: %.2f°C\n", temp);背后发生了什么?
printf解析格式字符串;- 把浮点数
temp转成'2','3','.','5','6'这样的字符; - 把整个字符串放进缓冲区;
- 最终调用
_write(1, buffer, len)—— 这才是关键!
这里的file=1表示标准输出(stdout),ptr是字符数组,len是长度。
也就是说,只要我们自己实现一个_write函数,就能接管所有printf的输出行为。
开始动手:让printf从串口“说话”
第一步:确保你用的是支持 newlib 的工具链
本文基于ARM-GCC + Newlib(或 newlib-nano)环境,常见于STM32CubeIDE、Keil AC5/AC6、GCC ARM Embedded等开发平台。
如果是IAR或其他编译器,对应的函数名可能是__write或_sys_write,需要查阅对应文档。
第二步:实现底层UART发送函数
假设你已经配置好了UART外设,比如USART1,波特率115200,8N1。你需要提供一个最基础的阻塞式发送函数:
// uart_driver.h void uart_send_byte(uint8_t ch); // 等待发送完成(轮询方式) void uart_send_byte(uint8_t ch) { while (!(USART1->SR & USART_SR_TXE)); // 等待发送寄存器空 USART1->DR = ch; }✅ 提示:如果你用的是HAL库,可以用
HAL_UART_Transmit(&huart1, &ch, 1, 100)替代。
第三步:重写_write函数
现在到了最关键的一步:
#include <stdio.h> #include <unistd.h> // 提供 STDOUT_FILENO 和 STDERR_FILENO int _write(int file, char *ptr, int len) { if (file != STDOUT_FILENO && file != STDERR_FILENO) { return -1; // 不处理其他文件描述符 } for (int i = 0; i < len; i++) { // 如果是换行符 \n,自动补一个回车 \r if (ptr[i] == '\n') { uart_send_byte('\r'); } uart_send_byte(ptr[i]); } return len; // 返回成功发送的字节数 }就这么几行代码,printf就能用了!
为什么加\r?
因为Windows系统的串口终端(如PuTTY、XCOM)通常需要\r\n才能正确换行。Linux/macOS下\n就够了。为了兼容性,我们在遇到\n时主动加上\r。
主函数里试试看
int main(void) { system_init(); // 芯片初始化 uart_init(115200); // 初始化UART printf("🎉 System boot OK! Hello from embedded world!\n"); int count = 0; while (1) { printf("Count = %d, Time=%.3fs\n", count, count * 0.1f); delay_ms(1000); count++; } }打开串口助手,设置波特率115200,选择正确的COM口,你应该能看到清晰的输出:
🎉 System boot OK! Hello from embedded world! Count = 0, Time=0.000s Count = 1, Time=0.100s ...恭喜你,已经完成了嵌入式调试的第一道“成人礼”。
常见问题与避坑指南
❌ 问题1:串口显示乱码或乱七八糟的字符
排查思路:
- ✅ 检查波特率是否一致(MCU和串口助手都要确认)
- ✅ 检查主频配置是否正确(影响UART时钟源)
- ✅ 检查GPIO是否配置为复用推挽输出
- ✅ 检查TX/RX是否接反(TTL电平直连即可)
🛠 工具推荐:用示波器抓一下TX引脚波形,观察周期是否符合115200bps(约8.7μs/bit)。
❌ 问题2:程序卡死在printf上
最常见的原因是开启了浮点格式输出%f,但未启用浮点支持。
Newlib默认不包含浮点转换代码,除非你显式告诉链接器需要它。
解决方案一:强制链接浮点版本
在编译选项中添加:
-u _printf_float同时使用 nano 版本减少体积:
-specs=nano.specs -specs=nosys.specs解决方案二:避免使用%f
改用整数模拟小数输出:
float t = 23.56f; printf("Temp: %d.%02d°C\n", (int)t, (int)(t*100)%100);这样既节省空间,又避免依赖FPU。
❌ 问题3:多任务环境下输出混乱
如果你在RTOS中多个任务都调用printf,可能会出现类似下面这种“拼接怪”:
Task1: coTask2: hello unter=5这是因为_write函数是非原子操作,两个任务交替写入导致数据交错。
解决办法:加锁保护
以FreeRTOS为例:
extern SemaphoreHandle_t print_mutex; int _write(int file, char *ptr, int len) { if (file != STDOUT_FILENO) return -1; if (xSemaphoreTake(print_mutex, pdMS_TO_TICKS(10)) != pdTRUE) { return -1; // 超时放弃 } for (int i = 0; i < len; i++) { if (ptr[i] == '\n') uart_send_byte('\r'); uart_send_byte(ptr[i]); } xSemaphoreGive(print_mutex); return len; }记得在系统初始化时创建互斥量:
print_mutex = xSemaphoreCreateMutex();性能优化:别让调试拖慢系统
虽然轮询+阻塞的方式最简单,但它有个致命缺点:每发一个字节,CPU就得停下来等。这对实时性要求高的系统来说不可接受。
进阶方案:使用中断+FIFO缓冲区
我们可以改造uart_send_byte(),让它只负责把数据放入环形缓冲区,然后启动中断发送:
#define TX_BUFFER_SIZE 128 static uint8_t tx_buffer[TX_BUFFER_SIZE]; static volatile uint16_t tx_head, tx_tail; void uart_send_byte(uint8_t ch) { uint16_t next = (tx_head + 1) % TX_BUFFER_SIZE; while (next == tx_tail); // 简单阻塞等待缓冲区有空(可改为返回错误) __disable_irq(); tx_buffer[tx_head] = ch; tx_head = next; __enable_irq(); // 启动发送中断(如果尚未开启) USART1->CR1 |= USART_CR1_TXEIE; }然后在中断服务程序中逐个发送:
void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_TXE) { if (tx_tail != tx_head) { USART1->DR = tx_buffer[tx_tail]; tx_tail = (tx_tail + 1) % TX_BUFFER_SIZE; } else { // 缓冲区空,关闭中断 USART1->CR1 &= ~USART_CR1_TXEIE; } } }这样一来,_write函数几乎不占用时间,大大提升系统响应能力。
更优雅的做法:封装日志接口
不要到处写裸printf,建议统一封装一层日志宏:
#define LOG_ENABLE #ifdef LOG_ENABLE #define LOG_DEBUG(fmt, ...) printf("[DBG] " fmt "\n", ##__VA_ARGS__) #define LOG_INFO(fmt, ...) printf("[INF] " fmt "\n", ##__VA_ARGS__) #define LOG_WARN(fmt, ...) printf("[WRN] " fmt "\n", ##__VA_ARGS__) #define LOG_ERROR(fmt, ...) printf("[ERR] " fmt "\n", ##__VA_ARGS__) #else #define LOG_DEBUG(...) #define LOG_INFO(...) #define LOG_WARN(...) #define LOG_ERROR(...) #endif好处显而易见:
- 输出带级别标识,便于过滤;
- 发布版本中一键关闭所有日志;
- 后期可轻松替换为更复杂的日志系统(如SD卡存储、网络上报)。
半主机 vs UART重定向?别再用半主机了!
有些初学者喜欢用半主机(semihosting),因为它能让printf直接输出到IDE的调试控制台,看起来很方便。
但它的代价太高了:
| 项目 | 半主机 | UART重定向 |
|---|---|---|
| 是否依赖调试器 | 是 | 否 |
| 断电重启后能否运行 | 否 | 能 |
| 性能影响 | 极大(每次触发软中断) | 小(尤其是中断发送) |
| 场景适用性 | 开发初期 | 所有阶段 |
一句话总结:半主机适合快速验证,UART重定向才是工程级选择。
写在最后:掌握这项技能意味着什么?
当你能在任何一块新板子上快速搭起printf输出通道,你就拥有了一个强大的“探针”。
无论是验证ADC读数、跟踪状态机跳转、还是排查通信协议错误,一条简单的打印语句往往胜过千言万语的猜测。
更重要的是,这个过程教会你一件事:
标准库不是黑盒,只要你愿意深入,就能把它变成你想要的样子。
而这,正是嵌入式开发的魅力所在。
如果你正在学习STM32、GD32、ESP32或者其他Cortex-M系列芯片,不妨现在就动手试一试。哪怕只是打印出第一句"Hello, UART!",也是迈向专业嵌入式开发者的重要一步。
有什么问题欢迎留言交流,我们一起踩坑、填坑、成长。