资讯详情

STM32F103 printf重定向到串口:裸机调试的实用指南

📅 2026/9/11 10:32:17 | 华诺云谱 👁 阅读
STM32F103 printf重定向到串口:裸机调试的实用指南
简介这是一份STM32F103单片机的开发例程核心解决调试口printf重定向问题帮助嵌入式开发人员将串口作为标准输出直接通过printf打印调试日志与变量参数。代码基于KEIL标准库编写当前适配STM32F103同系列其他芯片只需在工程中调整器件型号与Flash容量即可使用对正在学习标准库开发的单片机初学者特别友好。压缩包共72个文件以29个C源文件与29个头文件为主要组成部分覆盖printf重定向底层实现、串口初始化、GPIO复用、时钟配置等关键环节同时提供汇编启动文件、KEIL工程文件、hex烧录文件和一键编译批处理脚本整体体积仅279KB支持直接打开工程编译下载。工程目录按功能模块划分代码中已定义模块接线说明硬件有差异时可依据注释灵活调整。例程既适合作为快速搭建调试串口输出环境的基础模板也可扩展用于传感器数据采集、上位机通信联调等场景。目前已有57人学习下载整体结构清晰、注释完整是一份轻量而实用的参考例程。1. 调试口 printf 重定向STM32F103 调试为什么卡在“看不进变量”这道坎调试 STM32F103 裸机程序时最常见的一个场景是你有一个状态机、一个环形队列或者一个中断服务例程调试器停在断点数据已经跑飞你放开断点想看下一帧它又跑过去了。这时候把 printf 的输出按到调试串口上是成本最低、覆盖最广的观测手段。STM32F103 调试口 printf 重定向要解决的正是“标准库 printf 不知道把字符串往哪里送”的问题它没有 Linux 的 stdout也没有 Windows 的控制台只有我们指定的 USART1 外设。这件事做起来只需要改一两个函数、勾一个配置项绕开隐式声明和半主机陷阱却能让你从“通电点灯”直接跨到“带日志调逻辑”。新手能用它摆脱一百个断点老手能靠它搭建一个干净的调试输出层。2. 把 printf 的“显示器”换成串口重定向原理与两种落地方式2.1 标准库 printf 的输出路径从 vprintf 到 fputc我先说结论C 标准库的 printf 最终会把格式化好的字符交给 fputc 这个底层函数。在 PC 上fputc 通常被实现为向 stdout 文件写入字符在 STM32F103 裸机上根本不存在 stdout所以默认行为要么是什么都不输出要么是触发半主机异常卡死在 HardFault。为了让 printf 重定向到串口需要做的不是重新实现一套 printf而是把 fputc 的“输出设备”替换成 USART1 的数据寄存器。这个过程从调用侧看是printf(count%d, n)展开后编译器会链接库函数 printfprintf 逐字符调用 putchar而 putchar 的内部实现会转调 fputc。所以只要我们自己实现 fputc并把发送动作写进这个函数整个格式化逻辑都不用关心。还有一个点是输出缓冲在裸机环境中默认没有文件缓冲增删 buffering 的代码反而容易造成分层混乱通常建议直接禁止缓冲或保持默认即可。2.2 方式一Keil MicroLIB 下的精简 fputc 改写常见做法是使用 Keil MDK 的 MicroLIB。它是 ARM 编译器提供的一套精简运行时库占用 Flash/RAM 很小默认关闭浮点格式化的完整支持但对 sprintf/snprintf 这些基础功能保留得比较完整。勾选“Use MicroLIB”之后fputc 的原型在 stdio.h 中依旧是int fputc(int ch, FILE *stream)不过在 MicroLIB 下许多函数会弱化文件 IO 支持因此只要在任意 C 文件里重写这个函数就能把输出引到串口。这里给出最小框架#include stdio.h #include stm32f10x.h #include stm32f10x_usart.h int fputc(int ch, FILE *stream) { // 发送一个字节到 USART1 数据寄存器 USART_SendData(USART1, (uint8_t)ch); // 等待发送数据寄存器为空TXE 置 1 while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET) ; return ch; }这段代码的关键点有两个USART_SendData只是写入 DR 寄存器真正把数据移位送出去还需要等待 TXE 标志位return ch表示调用成功与标准库的契约一致。在实际工程里这个 fputc 不能放在中断服务函数内否则阻塞等待会影响实时性一般是在任务或主循环里调用 printf。2.3 方式二标准 C 库 完整重定向并关闭半主机如果不勾选 MicroLIB而是使用 ARM Standard C Library那就不能只改写 fputc 了。标准库内部还会引用_sys_exit、_ttywrch、__stdout等与主机环境相关的符号典型的错误是“user code does not return”或者链接时报告__use_no_semihosting相关警告。稳健的工程做法是添加以下代码来关闭半主机模式并给标准库提供一个可用的 stdout#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { x x; } void _ttywrch(int ch) { (void)ch; }然后把 fputc 的实现放到同一个文件里。这样不依赖 MicroLIB浮点 sprintf 的支持也更完整代价是编译出来的固件体积会大一些工程中需要额外维护这几个函数。对 128KB Flash 的 STM32F103 中低端型号来说一般也不会超容量。2.4 两种方式的选型逻辑MicroLIB 的优点是链接快镜像小适合对 Flash 有预焦虑的开发板缺点是浮点日志不支持scanf 函数基本没法用。标准 C 库则正好相反。对于常见的 STM32F103C8T664KB Flash、RBT6128KB、VET6512KBMicroLIB 大约能省下 10KB 左右的代码段但换来的是调试时无法打印浮点。在做电机控制或者 PID 参数整定时电流和误差值都是 float不能 printf 相当于把一只手绑起来所以我更倾向于标准 C 库方式。如果只是做通信协议枚举调试用 MicroLIB 就够了。对比项MicroLIB 方式标准 C 库方式需要勾选配置在 Keil Target 里勾选“Use MicroLIB”不勾选 MicroLIB添加#pragma import(__use_no_semihosting)需要重写的函数只重写 fputc还要实现_sys_exit、_ttywrch等printf 浮点支持默认不支持%f支持完整浮点格式化固件体积增加较小较大调试期使用体验问题概率低适合新手适合需要浮点日志的场景在实际调试中我常根据日志里是否需要删除字符串来选只输出整数和十六进制就开 MicroLIB日志里出现temp %.2f就切标准库。表里的差异在切换时候逐一核对基本不会踩坑。3. 实操用 USART1 点亮第一条 printf 输出3.1 硬件连接与最小系统准备STM32F103 的 USART1 发送引脚是 PA9接收引脚是 PA10这里只做输出。把 PA9 接到 USB 转 TTL 模块的 RX 上GND 共地。如果板上没有 USB 转串口芯片至少要保证 TX 引脚有上拉电阻和电平转换。STM32F103 的 IO 是 3.3V TTL 电平不能直接接到 RS232 电平的设备上。这也是常见的接线坑引脚对了但电平不匹配输出乱码或者完全没有。调试口的波特率默认是 1152008 位数据1 停止位无校验。如果原例程里的波特率不是 115200可以在串口助手里配成和代码一致。USART1 在 APB2 总线上时钟频率通常为 72MHz标准外设库已经通过 SystemInit 完成 RCC 配置不需要手动去改分频系数。3.2 使用标准外设库改写 fputc 完整代码以下是一个可以编译通过的完整例程基于标准外设库 v3.50ST 官方早期发布的 STM32F10x 标准库#include stdio.h #include stm32f10x.h #include stm32f10x_usart.h #include stm32f10x_gpio.h #include stm32f10x_rcc.h int fputc(int ch, FILE *stream) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET) ; return ch; } void Debug_USART1_Init(uint32_t baudrate) { GPIO_InitTypeDef gpioInit; USART_InitTypeDef usartInit; /* 打开 GPIOA 和 USART1 时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); /* PA9 复用推挽输出用于 USART1_TX */ gpioInit.GPIO_Mode GPIO_Mode_AF_PP; gpioInit.GPIO_Pin GPIO_Pin_9; gpioInit.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpioInit); /* USART1 配置115200-8-N-1仅发送 */ usartInit.USART_BaudRate baudrate; usartInit.USART_WordLength USART_WordLength_8b; usartInit.USART_StopBits USART_StopBits_1; usartInit.USART_Parity USART_Parity_No; usartInit.USART_HardwareFlowControl USART_HardwareFlowControl_None; usartInit.USART_Mode USART_Mode_Tx; USART_Init(USART1, usartInit); /* 使能 USART1 */ USART_Cmd(USART1, ENABLE); } int main(void) { Debug_USART1_Init(115200); printf(STM32F103 printf redirect OK, %d\n, 42); while (1) { } }代码逻辑说明Debug_USART1_Init先把 PA9 配置为复用推挽输出再按 115200-8-N-1 配置 USART1并保留发送模式。fputc被库函数printf逐字符调用时把字符写入 USART1 的发送数据寄存器并等待 TXE 标志位置位确保前一个字节已经移出后再接受下一个字节。这样就完成了标准输出到串口的重定向。这个例程能跑通有两个前置条件一是在 Keil 的 Options for Target 里勾选 “Use MicroLIB”二是文件顶部必须有#include stdio.h。缺少第二个条件时编译器会因为没看到 printf 声明而报出warning: #223-d: function printf declared implicitly。这一行警告在很多工程里被当成“编译过了无所谓”但如果你在裸机工程里看到它第一反应应该是检查所有包含 printf 的 C 文件是否都包含了 stdio.h。我在写的模板里都会把 fputc 和串口初始化放在一个debug.c中而main.c只负责调用和输出。3.3 HAL 库里的同款做法如果你用的是 STM32CubeMX 生成的 HAL 库代码重定向思路是一样的但发送寄存器和等待方式不同。HAL 版的 fputc 可以这么写int fputc(int ch, FILE *stream) { uint8_t data ch; HAL_UART_Transmit(huart1, data, 1, 10); return ch; }这里HAL_UART_Transmit的最后一个参数是超时时间单位是毫秒。10ms 可以保证在正常波特率下不会超时。如果传入 HAL_MAX_DELAY则可能因为 TXE 标志问题无限阻塞。HAL 库的好处是huart1对象已经由 CubeMX 初始化缺点是如果我们在中断里调用重定向后的 printf超时等待会拖慢系统。所以一般只建议在调试阶段用产品代码里改成非阻塞发送。HAL 库和标准外设库在文件名和初始化流程上差异不少但 fputc 这一层是一致的。对比项标准外设库HAL 库初始化函数USART_InitHAL_UART_Init发送单字节USART_SendDataHAL_UART_Transmit等待完成等待 TXE 标志调用 HAL 库的超时等待生成的工程结构外设库文件直接拖入CubeMX 生成代码再改fputc3.4 验证输出时应该看什么串口助手收到STM32F103 printf redirect OK, 42说明重定向链路是完整的。如果收到的是空字符或者只有低字节优先检查两个地方波特率是否实际生效以及串口助手的 HEX 显示是不是把 ASCII 转成了十六进制。如果上位机显示乱码但单个字符正确就是波特率误差太大把 115200 改成 9600 再试。现象排查方向屏幕上一个字符都没有检查 PA9 引脚是否复用推挽USB 转串口模块的 RX 是否正确连接输出是菱形或乱码先看波特率再看 PA9 是否接到模块的 TX只输出前几个字符检查 fputc 是否持续等待 TXE或者是否被中断打断能输出但重定向前的中文乱码转跳 4.1 节查编码另外如果 USART1 已经被业务代码占用调试口可以切到 USART2 或 USART3。只要把前一节代码中的外设名、GPIO 引脚和 RCC 时钟宏一起替换即可。以 USART2 为例发送引脚是 PA2APB1 时钟域fputc 里的USART_SendData(USART2, ...)初始化时钟改成RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE)。不要只改外设名而漏掉时钟域这是最常见的低级错误。4. 三个让重定向翻车的隐藏参数中文乱码、隐式声明与浮点进坑4.1 中文乱码UTF-8 和 GBK 的终端错位Windows 下用 Keil 写 STM32F103 工程时源代码文件可以是 GBK 编码如果配合 SecureCRT、XCOM 这些默认 GBK 的终端中文显示没问题。把同一份代码通过 Git 克隆到 Linux 环境编译器默认认为源码是 UTF-8可读字符串的内容已经被转成 UTF-8 字节流。再用串口工具按 GBK 解析每个中文三个字节被拆开解码自然是一堆“靠”和“锟斤拷”。这个坑对工程本身没有影响但排查日志时很容易把“编码问题”误判成“串口波特率不对”。我的做法是所有调试输出只用英文和数字如果非要中文就明确给所有打开的终端选 UTF-8并在 Keil 里把源码编码也设置成 UTF-8。Keil 5 的 Encoding 选项在 Edit - Configuration - Editor 下面切完后旧文件会显示成乱码需要重新保存一次。如果你用的是跨平台构建脚本更稳妥的方案是在构建前用 Python 或 iconv 把源码统一转成 UTF-8编译时用 /utf-8 参数把编译器默认字符集指过去。对于 STM32F103 这类资源不紧张的产品日志里完全可以用LOG_WARN(temp...)代替中文描述避免所有编码烦恼。4.2 warning #223-dprintf 被隐式声明的后果warning: #223-d: function printf declared implicitly几乎在每个新建的 STM32F103 工程里都会出现一次。它的来源是直接引用printf但没有任何函数原型之前包含stdio.h。编译器为了继续编译会假设 printf 返回 int并对每个实参都做默认实参提升比如char提升为intfloat提升为double。这在 x86 架构上问题不大但在 ARM 裸机环境里可变参数函数的数据对齐和寄存器参数传递规则稍有不同。如果你恰好传了一个int64_t或浮点数实参可能被错误地放入通用寄存器printf 内部按错误的偏移读取输出自然就是nan、0或者干脆崩溃。修复方式不是关警告而是在源文件顶部写#include stdio.h。如果你用的是头文件包含矩阵也要确保这个头文件在所有包含 printf 调用的文件里可见。常见的隐藏问题是你把 fputc 放到debug.c却只在debug.h里 include结果main.c依然看不到stdio.h。所以我的习惯是在每个 C 文件里都独立包含#include stdio.h不依赖全局预编译头。在检查完所有文件后把这个警告从编译输出里消失比任何代码重构都让人安心。4.3 浮点 %f 是重定向工程的分水岭很多例程为了降低镜像大小默认勾选了 Keil 的 MicroLIB。MicroLIB 对 printf 的实现是裁剪过的直接打印%.2f会在第一个浮点参数字符处停止输出。解决这个问题有两个方向一是放弃 MicroLIB改用标准 C 库同时提供上文提到的__use_no_semihosting和_sys_exit函数来避免半主机死循环二是保留 MicroLIB但利用sprintf把浮点转成字符串再通过 printf 输出。不过 MicroLIB 的sprintf同样不支持浮点所以第二种方案并不总是成立。我在实际工程里的标准做法是不使用 MicroLIB而是完整重定向 fputc 并关闭半主机。其底层增加的成本只是几十 KB 的库代码对于 STM32F103VET6 和 RBT6 这些型号完全可接受。关键步骤是保证代码中添加了以下半主机抑制块#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { (void)x; while(1); } void _ttywrch(int ch) { (void)ch; }这段代码的作用是告诉 ARM C 库不要向调试器发起半主机请求所有底层 IO 都由 fputc 接管。没有这一段只要代码里用到了scanf或fopen相关的符号程序就会在启动时卡死。还有一个经常藏在后台的问题是\n与\r\n。串口终端通常把\r解释为回车\n解释为换行。printf 默认只输出\n在部分终端上会呈现“光标移到下一行同一列”也就是传说中的“阶梯状输出”。解决方法是每行结尾写成\r\n或者在上位机里勾选自动加回车。如果你的重定向代码里已经用了printf(...\r\n)就不用再改。症状真正原因快速检查点中文显示乱码源码编码与终端解码不一致把终端切到 UTF-8看中文是否恢复编译警告 223-d缺#include stdio.h搜索当前文件的前 5 行printf 在第一个 %f 停住使用了 MicroLIB 的裁剪 printf取消 MicroLIB并按半主机方式处理字符每隔一段就丢一个fputc 等待 TXE 后可覆盖改成等待USART_FLAG_TC或加短延时5. 进阶把 printf 重定向做成带时间戳的分级日志5.1 利用 SysTick 给调试串口加毫秒标记单独的重定向只能让你看到字符串但排查状态机问题时事件发生的顺序和间隔往往比字符串本身更重要。加入时间戳后日志可以直观反映“LED 先跳变还是串口先收到数据”。常见做法是让 SysTick 中断每 1ms 累加一个全局变量再在封装函数里打印这个变量。volatile uint32_t g_tick_ms 0; void SysTick_Handler(void) { g_tick_ms; }在标准外设库里可以用SysTick_Config(SystemCoreClock / 1000)初始化中断周期。SystemCoreClock是在 system_stm32f10x.c 里定义的通常是 72000000除以 1000 得到 72000 个时钟周期对应 1ms。注意 SysTick 中断优先级要设在不会和串口中断互相阻塞的位置。5.2 用宏分级输出并在发布版里整体裁剪调试日志最忌讳在代码里散落一地 printf。我一般会封装一个log_write函数支持日志级别和时间戳再通过宏开关来控制编译期裁剪void log_write(uint32_t level, const char *fmt, ...) { printf([%lu][L%u] , (unsigned long)g_tick_ms, (unsigned int)level); va_list args; va_start(args, fmt); vprintf(fmt, args); va_end(args); printf(\r\n); } #define LOG_DEBUG(...) log_write(0, __VA_ARGS__) #define LOG_INFO(...) log_write(1, __VA_ARGS__) #define LOG_ERROR(...) log_write(2, __VA_ARGS__)使用时LOG_ERROR(can id:0x%x, id);就会输出带时间戳和级别符号的日志。如果你希望在产品代码里彻底去掉调试输出将宏定义改为#define LOG_ERROR(...) ((void)0)即可格式化代码不会被编译避免无效代码影响中断时延。这套结构还能加上变长参数检查避免格式串和参数数量不匹配导致栈损坏。如果日志需要在中断服务程序里打印上面的阻塞式printf就不能直接用。一个折中方案是设置一个循环缓冲区中断里只把数据写入缓冲区主循环里再调用vprintf刷出去。这样重定向的 fputc 依然负责底层发送但不会因为等待 TC 标志拖垮实时性。缓冲区建议用无锁环形队列且只在单生产者、单消费者模式下工作写入和读取各维护一个索引这样能避免在中断上下文中加锁。要验证时间戳是否准确可以在main()里做一个简单的 GPIO 翻转进入中断时把g_tick_ms锁定查看串口输出和逻辑分析仪的时间差。大多数情况下1ms 的误差是可以接受的。还要注意g_tick_ms是 32 位变量约 49 天会溢出回绕除非设备连续运行 50 天否则不用处理无符号数回绕问题。在日志里用格式化输出%lu并显式强转(unsigned long)这样在 Keil 的默认参数规则下也能正确打印。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。