1、聊一聊
今天跟大家分享一首坤坤同学的新歌<情人>,个人觉得旋律有一种花花公子的味道,还是比较带感的,大家感兴趣可以欣赏下!
好了,今天为大家带来一篇printf剖析的文章,该函数应该是大家在初学阶段用得非常频繁的一个函数,进入嵌入式的小伙伴也是经常将其重定位进行系统相关调试信息的打印,同样也有很多大佬为了增加程序运行效率直接将其进行了改写来满足需求。
所以今天作者就带大家看看printf里面究竟是怎么处理的?为什么能够输入可变参数?到底支持哪些输出输出?等等。
2、想看看C库源码怎么办?
在嵌入式中一般谈到C Library大家都会想到glibc,glibc是GUN旗下的一个C标准库。那么libc又是什么呢?对于这个名词的定义有点歧义,有些人把所有的C标准库都统称为libc,而有部分人认为libc是最开始linux下的标准库。
所以说C标准库也是多种多样的,不同平台都有所不同,比如嵌入式中非常小型的uClibc等等。
大家如果有对C库感兴趣的可以去简单看一些源码,里面的宝藏也是特别的丰富。下面作者提供glic和uClibc的网站,大家可以到网站下载对应的源码进行研究。
glibc官方网站 : http://www.gnu.org/software/libc/
uClibc官方网站 : https://uclibc-ng.org/
由于uClibc相对glibc来说小得多,所以在嵌入式中也是经常使用到,作者也是特意下载了源码并解压出来了,证明访问路径是OK的:
并用sourceInsight打开找到了printf的具体实现:
由于glibc和uClibc中的printf实现相对嵌套比较多,不便于直接分析,后面作者会选择相对比较有条理的printf函数实现进行分析讲解,设计实现上都是大同小异,如果大家感兴趣也可以下载源码进行阅读。
3、函数参数入栈问题
C语言函数参数一定会入栈吗?入栈一定是从右向左吗?
其实这是一个与平台和处理器相关的问题,所以需要具体情况具体分析,首先大家要明确函数参数和定义的局部变量大部分都是存在堆栈中(不过也可以通过借助寄存器来传递,比如说之前剖析register关键字文章中把局部变量放到寄存器中提高效率),使用完毕以后通过堆栈指针的移动进行自动的释放。
对于X86-32bit平台一般都是从右向左参数入栈,而对于X86-64bit为了提高程序运行效率,会把前面的部分参数通过对应的寄存器进行传递,如果有更多的参数就通过压栈进行处理,所以需要根据具体的平台和编译器进行分析。那么为什么作者这里首先提到入栈顺序呢?因为printf需要实现可变参数,那么肯定是需要有约定的传参数的规则,该约定的规则就决定了函数内部如何获得对应参数。
4、可变参数的实现
对于大部分小伙伴在平时的开发中基本上都是使用固定的参数类型,不过对于类似于printf这种用户接口使用型函数,实现可变参数就显得更加具有灵活性。学习过C++的小伙伴应该有种感觉,可变参数有点类似于函数重载,不过C的可变参数必须包含一个参数。下面作者简单的实现一个可变参数函数使用demo供大家参考。
参考demo:
1#include<stdio.h>
2#include<stdarg.h>
3
4/***********************************
5 * Fuction: sCalSum
6 * Author :(公众号:最后一个bug)
7 **********************************/
8int sCalSum(int Num,...)
9{
10 //定义获取参数列表结构体
11 va_list ap;
12 int sum = 0;
13 int i = 0 ;
14 //定位起始变量
15 va_start(ap, Num);
16
17 for(i = 0 ;i < Num;i++)
18 {
19 //根据参数类型进行索引
20 sum+= va_arg(ap,int);
21 }
22 //结束变量获取,并释放资源
23 va_end(ap);
24 return sum;
25}
26
27
28int main(void)
29{
30 printf("%d + %d = %d\n",1,2,sCalSum(2,1,2));
31 printf("%d + %d + %d = %d\n",1,2,4,sCalSum(3,1,2,4));
32 printf("%d + %d + %d + %d = %d\n",1,2,4,8,sCalSum(4,1,2,4,8));
33 return 0;
34 }
最后输出结果:
5、printf源码分析
前面的两个知识点都是为下面printf源码分析铺路的,浮点在处理器中运算是比较耗时间的,同时占用的资源也是非常多的,所以很多集成开发环境或者编译链接工具都会为标准库提供精简版本供大家选择。
特别是对于使用单片机的小伙伴调用库相关的函数,如果精简版本能够满足需求,就尽量使用精简版本,如果觉得精简版本还是太占用资源,那就自己手动编写修改吧,所以printf中的浮点处理成为了精简的一部分,如果在使用过程中发现使用printf打印不了浮点可以查看一下是不是libc中不支持浮点打印等相关功能。(下图是IAR编译工具中的相关配置选项)
![]() | ![]() |
为了方便大家学习和理解,所以这里并没有选用非常复杂的函数实现,而是选用IAR中的精简版printf跟大家讲讲思路:(下面的代码截图均来自IAR安装目录,IAR安装目录下还有很多其他宝藏,大家可以参考学习)
具体实现过程:
在调用printf函数都会使用到头文件#include<stdio>,所以大家搜索该文件即可,然后顺着包含关系可以找到其他函数设计实现,所以推荐大家使用SI编辑器阅码。
下面作者截取了printf函数实现,大家仔细观察会发现printf竟然还有返回值,估计80%的小伙伴都没有使用过吧。
从printf函数形式来看来和我们前面实现的可变参数实现并没有太大的区别,只是说第一个参数变成了指针,这个指针就是平时所指定的参数格式,如"ADCSample:%d",函数内部就是通过解析该字符串获得后面传入参数的具体类型等信息,从而进行相应的转化处理。
vprintf函数会最终调用vfprintf, vfprintf调用vsprintf,如下图所示
对于vfprintf函数中vsprintf仅仅只是通过ap参数和pFormat格式进行转化为pstr,通过pstr把最终的输出信息通过fputs进行输出,所以你只需要改写对应 fputs就可以把最终输出到对应的终端上(比如串口,LCD屏幕等等),所以玩stm32使用重新位串口输出也就是同样的道理了。
下面我们来具体看看vsprintf里面的实现思路,vsprintf会调用vsnprintf,同时通过宏定义限制了最终通过printf的长度。
下面我们截取vsnprintf中重要的两段来进行分析:
该部分通过%来进行每个参数格式的查找。
通过不同格式对参数列表中的参数进行对应的解析,所以说%的顺序也就决定了参数的顺序,对于不同参数类型的转化封装成了不同的函数,大家有时间可以细细读读里面具体的实现代码,这里就不展开了,细心的小伙伴应该会发现里面并没有%f的相关处理,因为被精简了。
最后一点:
总体来看printf实现并不是很复杂,因为C库中封装的va_arg宏把参数为我们准备好了,前面我们说了不同的平台函数参数处理不一样,所以va_arg搜索参数的实现方法也不尽相同,不过肯定是根据相应的约定进行查找,最简单的约定就是全部压入到堆栈中,然后通过堆栈指针根据参数类型一一获得对应的参数值。下面是IAR中一段该部分的实现,大家欣赏一下就好了。
5、最后小节
printf函数的基本实现就跟大家讲解到这里,其实很多libc并没有想象中那么神秘,大家如果在平时使用libc的过程中发现了相关问题完全可以通过阅读相关源码进行分析处理,也可以对其源码进行改写来满足自身需求,自所谓"见源码如见真理"。
好了,这里是公众号:“最后一个bug”,一个为大家打造的技术知识提升基地。同时非常感谢各位小伙伴的支持,我们下期精彩见!
推荐好文 点击蓝色字体即可跳转