你的第一行代码printf没有你想那么简单

一、问题复现:看似无害的 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;
}

核心流程如下:

  1. 调用者传入 format 和多个参数

  2. va_list 宏展开获取参数列表

  3. 调用 vprintf,这个函数会进一步处理格式化

  4. **vprintf 实际会调用 __vfprintf_internal,完成:

    • 格式字符串解析
    • 参数取出并类型匹配
    • 格式化结果写入输出缓冲区
  5. 缓冲区内容刷新到标准输出

  6. 返回输出的字符个数

一般底层真正执行的函数是 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:浮点数处理较复杂,可能调用内部 dtoasprintf
  • %p:将指针地址按十六进制格式化。

从上述调用中可以看到,运行时通过层层展开会产生大量的局部变量比如上面的 struct printf_spec 结构体,这些都是很占用内存的,尤其是浮点数还会调用其它格式化函数,进一步加大内存的使用,分析到这里,就可以很清晰的知道我的系统为何运行一段时间后才死机,原来是任务栈空间溢出了,恰巧当时系统就是输出浮点数printf("%.2f", value),是一个温度值带两位小数点。


后来的解决方案

  1. 编写轻量替代函数(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、科学计数法

  1. 开源也有很多替代库,大家知道有更好的也可以留言推荐
库名称 特点
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

欢迎关注交流!!!

相关推荐
Coffeeee1 小时前
闲聊几句,Android老哥们,你们多久没做技改需求了
android·程序员·代码规范
饼干哥哥3 小时前
扣子3.0测评:我让 Codex 和 Claude Code 住同一个桌面,结果它们打架了!
人工智能·开源·代码规范
码哥字节2 天前
为什么 Claude Code 读你的代码库,光靠 embedding 根本不够?
claude·代码规范
kisshyshy4 天前
从递归到迭代,一文吃透二叉树的核心知识与 JavaScript 实现
javascript·算法·代码规范
用户6919026813398 天前
Vibe Coding 开发项目的基本范式
人工智能·设计模式·代码规范
Cosolar8 天前
藏在 Claude Code 里的极致浪漫:完整 187 条 Spinner Verbs 全收录
后端·程序员·代码规范
Mickey8619 天前
MCP 加持下的零代码逆向:全自动化绕过 APP 验签与加密实战
代码规范
专注VB编程开发20年12 天前
WebView2 + HostObject 架构的核心痛点 ——强耦合、同步阻塞、异常连锁、内核绑定
代码规范
LeahDizon13 天前
AI Coding 协作实践方案
程序员·github·代码规范