你的第一行代码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

欢迎关注交流!!!

相关推荐
探索为何20 小时前
Transformer:从神坛到笑坛的华丽转身
设计模式·程序员·代码规范
tager1 天前
Vue 3 组件开发中的"双脚本"困境
前端·vue.js·代码规范
写bug写bug2 天前
如何应用服务器端的防御式编程
java·后端·代码规范
尖椒土豆sss2 天前
关于vue3 项目中使用 eslint-plugin-vue 报错踩坑记录
vue.js·代码规范·eslint
shilim5 天前
这位老哥提交了一个12万行代码的PR,程序员看了都说LGTM
人工智能·github·代码规范
北_鱼5 天前
设计模式1:创建型模式
java·设计模式·软件工程·代码规范·设计规范
shepherd1118 天前
从List与Tree相互转换工具类实现中谈谈菜鸟到老鸟的一些思考
java·后端·代码规范
闲不住的李先森9 天前
使用 Ultracite 告别 ESLint 和 Prettier 的配置地狱
前端·代码规范
回家路上绕了弯10 天前
追求代码简洁之道:我的实践与感悟
后端·代码规范