一、问题复现:看似无害的 printf 导致死机
事情起因是在一个紧急项目中,整个项目基本完成了,在最后优化测试阶段了,整个系统运行一段时间后突然死机,此时心已死,市场又急着需要样机进行推广,在这种煎熬的时刻,系统逻辑又没发现明显问题,系统运行接近二十分钟,就死机,抓狂,采用单片机的调试也不好定位问题,只能通过逐个任务(采用threadx系统,任务划分基本可以独立运行互不干扰)屏蔽来进行排除,先找出那个任务出的问题,再逐个函数屏蔽排除,再到函数里面逐个语句进行排除,最终结果让我想了几天都不知道为何,居然是printf语句导致。
所以啊,真正让系统崩溃的,往往不是你设防最严的地方,而是那些你最不设防的地方。 那些看起来"绝对不会出问题"的地方,才是最容易藏着问题的地方。
这让我想起一个段子:一个扫地阿姨路过程序员的工位,随口说了一句"你这好像少了个分号",结果真就帮他解决了 bug。被淹死的,往往不是不会游泳的,而是自信游得很远的人。
还记得当年第一次接触 C 语言,老师教我们的第一行代码是 printf("Hello, world!");。没想到多年后,居然也会在这句看似最熟悉的代码上"翻了船"。
arduino
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
后来闲下来了,去查了相关资料,结果让人一惊
二、接下来好好扒一下这个毒瘤
看一个简单的测试
1. 没有使用printf
函数的内存情况

scss
============================================================================
Total RO Size (Code + RO Data) 1904 ( 1.86kB)
Total RW Size (RW Data + ZI Data) 1064 ( 1.04kB)
Total ROM Size (Code + RO Data + RW Data) 1940 ( 1.89kB)
============================================================================
2. 使用C 标准库<stdio.h>
内存情况

scss
============================================================================
Total RO Size (Code + RO Data) 5856 ( 5.72kB)
Total RW Size (RW Data + ZI Data) 1064 ( 1.04kB)
Total ROM Size (Code + RO Data + RW Data) 5896 ( 5.76kB)
============================================================================
3. 使用自定义mini_printf
函数内存情况(仅支持%d
, %x
, %s
,不支持浮点 %f
、科学计数法)

scss
============================================================================
Total RO Size (Code + RO Data) 2608 ( 2.55kB)
Total RW Size (RW Data + ZI Data) 1064 ( 1.04kB)
Total ROM Size (Code + RO Data + RW Data) 2644 ( 2.58kB)
============================================================================
明显可以看出,启动标准库中的printf函数,flash占用居然接近4KB内存,这对于内存紧张的单片机来说,是致命的。至于RAM的使用情况从这里看不出来,看后面分析。 这就很让人迷惑了,一个简单输出函数,为何会占用这么大内存。
要找到具体原因,还是得从原函数开始分析:
三、函数分析
1、函数原型
arduino
int printf(const char *format, ...);
-
所属头文件:
#include <stdio.h>
-
返回值类型:
int
,表示成功写入的字符总数,负值代表出错。 -
函数参数:
format
:格式控制字符串。...
:可变参数,数量不定,取决于format
中指定的格式。
2、函数功能
printf
用于将格式化的数据输出到标准输出stdout
(学习的时候是屏幕,单片机开发时往往进行重定向到串口输出)。它按照format
指定的格式,将后续的可变参数格式化为字符串,并输出。
嵌入式系统中,我们经常重定向 printf
:
arduino
int fputc(int ch, FILE *f) {
// 输出到串口等
}
3、详细参数解析
1. const char *format
格式控制字符串,包括普通字符和格式说明符(以 %
开头)。支持:
- 转换说明符:如
%d
,%s
,%f
等。 - 标志(Flags):如
-
,+
,0
,#
,' '
。 - 最小字段宽度:如
%5d
- 精度:如
%.2f
- 长度修饰符:如
l
,ll
,h
,z
,j
,t
- 转换说明符:如下表
转换说明符 | 含义 |
---|---|
%d / %i |
有符号十进制整数 |
%u |
无符号十进制整数 |
%x / %X |
无符号十六进制整数 |
%o |
无符号八进制整数 |
%f |
十进制浮点数 |
%e / %E |
科学计数法表示的浮点数 |
%g / %G |
自动使用%f 或%e |
%s |
字符串 |
%c |
单个字符 |
%p |
指针地址 |
%% |
输出一个百分号 % |
更详细格式可自行到菜鸟教程学习
2. 可变参数 ...
使用 stdarg.h
提供的宏进行处理:
va_list ap;
va_start(ap, format);
va_arg(ap, type);
va_end(ap);
printf
函数本身是一个变参函数,其内部使用va_list
等宏来逐个提取参数。
四、调用过程分析
以 GNU libc 为例,printf
的实现是分层封装的:
scss
int printf(const char *format, ...) {
va_list args;
int result;
va_start(args, format);
result = vprintf(format, args); // 变参版本处理函数
va_end(args);
return result;
}
核心流程如下:
-
调用者传入
format
和多个参数 -
va_list
宏展开获取参数列表 -
调用
vprintf
,这个函数会进一步处理格式化 -
**
vprintf
实际会调用__vfprintf_internal
,完成:- 格式字符串解析
- 参数取出并类型匹配
- 格式化结果写入输出缓冲区
-
缓冲区内容刷新到标准输出
-
返回输出的字符个数
一般底层真正执行的函数是
vfprintf
,而printf
是一个"外壳"。
简化调用流程图
scss
printf()
└──> vprintf(format, va_list)
└──> __vfprintf_internal(stdout, format, va_list, flags)
├── 解析 format 字符串
├── 从 va_list 中提取参数
├── 类型格式化(整数/浮点/字符串)
├── 写入缓冲区
└── 返回总写入字符数
5、格式化实现细节
1. 解析格式字符串
perl
while (*format) {
if (*format == '%') {
// 检测 flags, width, precision, length, type
} else {
putchar(*format);
}
}
- 状态机方式处理格式字符串。
- 将每个格式项封装为
struct printf_spec
结构,占用约16个字节空间,所以调用的参数越多内存可以说直线上升。
arduino
struct printf_spec {
int width; // 字段宽度
int prec; // 精度(-1 表示未指定)
unsigned int flags; // 标志位,如左对齐、补零、符号等
unsigned char type; // 转换类型字符,如 'd', 's', 'f'
unsigned char base; // 整数进制(如 10, 16)
unsigned char spec; // 原始格式符号(如 'x', 'e')
unsigned char qualifier; // 长度修饰符('l', 'h' 等)
};
2. 类型匹配和转换
根据不同的格式说明符:
%d
:提取int
类型,用itoa
或内部转换函数转为字符串。%s
:提取char*
指针,逐字符输出。%f
:浮点数处理较复杂,可能调用内部dtoa
或sprintf
。%p
:将指针地址按十六进制格式化。
从上述调用中可以看到,运行时通过层层展开会产生大量的局部变量比如上面的
struct printf_spec
结构体,这些都是很占用内存的,尤其是浮点数还会调用其它格式化函数,进一步加大内存的使用,分析到这里,就可以很清晰的知道我的系统为何运行一段时间后才死机,原来是任务栈空间溢出了,恰巧当时系统就是输出浮点数printf("%.2f", value)
,是一个温度值带两位小数点。
后来的解决方案
- 编写轻量替代函数(mini_printf)
arduino
void mini_printf(const char *s, int value) {
char buffer[10];
int_to_str(value, buffer);
puts(s);
puts(buffer);
}
可以支持:
%d
,%x
,%s
- 不支持浮点
%f
、科学计数法
- 开源也有很多替代库,大家知道有更好的也可以留言推荐
库名称 | 特点 |
---|---|
mpaland/printf | 超小型 printf 实现 |
nano-printf | 支持嵌入式的轻量 printf |
一些建议
场景 | 建议 |
---|---|
MCU 仅需输出调试信息 | 用 puts , putchar 替代 |
必须使用格式化 | 使用 printf 替代库(如 mpaland) |
不使用浮点 | 尽量不用或者转换成字符串再输出,节省 10~20 KB |
追求极限体积与性能 | 自定义格式输出函数,只实现需要的功能 |
警示语:"Programming isn't about what you know; it's about what you can figure out." ------ 编程不是你知道什么,而是你能发现什么。
相关附件
本文测试示例工程目录说明
arduino
.vscode // Visual Studio Code 工具目录
P1_PRJ // Keil ARM 工程目录
P2_APP // 应用层目录
P3_BSP // 驱动层目录,主要与硬件相关
P4_CPN // 第三方组件
|-- framework // 裸机框架文件
P5_Libraries // F103标准库文件
P6_DOC // 说明文件,主要是规格书
pics // ReadMe.md 的图片
keilkill.bat // 清空编译过程产生的文件的windows脚本
ReadMe.md // 关于工程的说明文件
开发环境
arduino
软件开发环境:
IDE工具:KEIL MDK-ARM 5.38.0.0
ARM Compiler:V5.06
软件系统:裸机开发
编程语音:C99
硬件环境:
SOC:STM32F103C8T6
内核与系统
-- 32-bit Arm® Cortex®-M3
-- 工作频率可达 72MHz
存储器
-- 64KB Flash 存储器,每页 1KB
-- 20KB SRAM
时钟、复位和电源管理
-- 2.0~3.6V 供电
-- 上电/断电复位(POR/PDR)、可编程电压监测器(PVD)
-- 外晶振 8MHz HSE 高速振荡器
封装:LQFP 48 7x7x1.4 mm
欢迎关注交流!!!