注意
本文为针对C语言的基础技术向,更适合刚入门需要进阶的同学技术进阶使用,当然我会尽量用简单的语言让哪怕是初学者也可以看得懂,核心知识有如下部分:
- 变参函数的概念和使用
__attribute__(())
的概念和使用- printf函数的实现
视频
本文可以结合视频一起食用,效果更佳:视频链接
再观printf函数
相信绝大部分同学写的第一个程序,应该就是大名鼎鼎的Hello World
程序了,这个程序闻名于著名的《C语言程序设计(The C Programming Language)》(C语言之父丹尼斯·里奇(Dennis Ritchie)著)一书中,非常经典,以至于程序员们学习任何语言,都会先用这个语言运行个Hello World出来。
在C语言中,这个程序一般写出来是这个样子的:
#include <stdio.h>
int main()
{
printf("Hello World!\n");
return 0;
}
如果在命令行运行这个程序的话,你将获得一行Hello World!
输出。
如果你是一个不喜欢思考的初学者,可能会认为printf也许是C语言里面最基础最简单的函数了,毕竟这个程序这么简洁、格式这么规范、使用这么简单、甚至闭着眼睛都不会写错。
但如果我们往细究往深了想呢?printf它是如何实现的呢?我们是否可以实现一个自己的printf函数呢?为什么它会运行在命令行中而不是从屏幕某个规定的位置显示呢……
这时你可能会发现,woc,printf似乎比我们想象的要复杂太多了,你以为的简单小函数,其实却是个超级大boss。不过不要担心,我们会从基础开始讲,而且我会保证尽量说得简单明了。
print为什么要加个f
现在,我们重新抱着认真学习的态度来观察printf这个函数吧。首先我们从名字来解析一下。
我们知道,在英语中,print有打印、印刷的意思,这个词语完全可以解释将字符串打印到电脑屏幕上这个过程,那么为什么这个函数的名字要加上f呢?
其实这个f是format的缩写,即格式化的意思,printf其实为格式化打印的意思。那么格式化这个是怎么来的?
现在想想,如果我们希望在屏幕上输出一个变量该怎么做呢?最简单的方法就是使用格式字符,就如下面这个程序一样:
#include <stdio.h>
int main()
{
int a = 25;
printf("a = %d\n", a);
return 0;
}
这个函数将会在屏幕上输出一行a = 25
,在这个过程中%d就是变量a的格式符,它在字符串中占位,然后字符串在输出的时候,遇到了这个占位符,就会解析a的具体值,并把它转换为字符串的形式输出出来。
现在从这个功能中我们其实可以发现很多问题,我们来逐一解释:
格式符是什么时候被解析的?
这个问题是我在学习理解printf的时候第一个思考的问题,即格式符是如何什么时候被解析的,答案有两种,对应了两种不同的结果。
首先是在编译阶段被解析,这种方式的话也有两种情况,一是编译器有针对printf这类函数的优化,二是编译器会主动把字符串中的格式符变成某种执行效果。而另一种方案是printf函数内部实现的解析格式符。
我们可以用如下的程序来测试(为了方便,后面我们生成程序的名称都为demo.exe):
#include <stdio.h>
int main(int argc, char* argv[])
{
int a = 1, b = 2;
char* str = "a=%d\n";
printf(str, a);
printf(argv[1], b);
putchar('\n');
return 0;
}
然后我们把它们编译完成后,在命令行中使用./demo.exe b=%d
来运行(如果使用的某种IDE,可以打开工程文件夹,找到输出exe文件,然后在文件夹中按住shift+右键
,打开powershell,然后用./文件名.exe b=%d
的方式运行即可)。
最后我们会得到两行输出a=1
和b=2
。
对于上面的程序,我们来理解一下:
其中首先是main函数的部分,可以发现我们在里面写了两个参数,其实这种写法才是C语言main函数的标准写法,具体可以参考我之前写的《C的main函数解析》这篇博客,这里不作详述,这里只说一下其中第八行的argv[1]
其实就代表了我们开启程序时输入的第一个参数字符串,在我们的示例里面也就是b=%d
一句。那么我们从第一个输出a=1可以看出,这里的输出语句并不是直接写在printf函数里的,但是占位符仍然有效,而第二个b=2则表明了我们通过外部未知的字符串中的占位符也实现了占位符效果。故可知占位符实际上是在printf中被解析的。
既然知道了这个,那么printf解析的方案其实也就很容易出来了,程序通过检测百分号%
寻找占位符,并根据不同的占位符将数字转换成字符串。
如何实现变参函数
既然知道了如何解析格式符,那么我们似乎就可以实现printf函数了?可是等一下,我们在写printf的时候为什么可以写多个参数?
一般而言,如果我们仅仅跟着学校学习的那点C语言知识,肯定不会讲什么变参函数的,但是printf这个函数的存在却离不开变参函数这个概念。而且,配合变参函数,你还可以实现一些类似于其它更加高级语言的重载、默认参数等功能。
如果需要实现变参函数,那么有一个库文件就很关键了,那就是stdarg.h
。你首先需要包含这个库,然后按照规定的写法就可以实现变参函数了。例如下面我就写了个简单的变参整数加法函数:
#include <stdarg.h>
int MySum(int count, ...)
{
va_list num; // 一个宏定义类型,通过它我们可以访问参数数据
int sum = 0;
va_start(num, count); // 确定起始参数
for(int i = 0; i < count; i++)
{
sum += va_arg(num, int); // 从起始参数开始取一个int类型的数据并加到sum中,同时num将会指向下一个参数
}
va_end(num); // 释放
return sum;
}
通过这个函数我们即可以实现一个变参的加法。我们来看一下这里的写法,其实很简单,除了格式要求在函数的形参列表末尾加上三个点表示这是个变参函数外,重点也就一个数据类型和三个函数。
不过为了防止看不懂,我们来从原理上理解一下这个功能。(此部分仅作参考,我并没有实际去看编译器的内容,而源文件也被隐藏起来了,故下面的内容大多是基于我自己的分析)。
首先是三个点的作用,很显然这个东西是给编译器看的,因为如果不加三个点,那么我们使用函数的时候,实参与形参个数对不上,编译器就会报错,而这三个点就可以告诉编译器,这是个变参函数,实参多了少了你别叫唤。
然后是一个数据类型即va_list
,这个类型从某种意义上感觉应该是个空指针,这个指针可以用来在栈中取数据。当然它也可能是个结构体,但至少绝对拥有指针的功能。
而剩下三个函数中,第一个调用的是va_start
,它应该是用来对va_list
这个指针赋初值。这里我认为应该是程序在编译运行后,函数的实参应该是放在一个栈里的,并且是有规律有顺序的存放的,那么我们要取一个数据,只要确定了这个数据的首地址(va_list指向的位置
)和长度(数据类型),就可以把数据正确取出来了,取出来后因为数据被整齐地排列着,所以指针被完美地跳转到下一个实参的首地址。而va_start
其实作用就是确定这个实参列表整体的首地址。
然后是第二个函数,在程序中被反复调用的va_arg
函数,显然通过我们刚才的分析,应该不难知道这个函数其实就是用来取数据同时移动指针的,在这个函数的参数中,除了要取数据的va_list
,还包括了要取的数据类型(虽然不知道为什么这个数据类型可以直接被写成类型的名称形式,应该是宏定义的一种用法),然后通过这个函数,我们就可以按照格式取出数据,同时把va_list
跳转到下一个参数的位置。
最后第三个函数,我们可以调用va_end
把指针指向一个空地址,防止野指针对内存造成破坏。
除此之外,其实stdarg中还有很多其它的函数,不过我们平常不大用得到,如果感兴趣的话可以自行研究研究。
现在,我们来试试刚刚写的程序吧,在主函数中如此调用:
int result = MySum(5, 1, 2, 3, 4, 5);
printf("%d\n", result);
最后我们可以得到一个15
的输出。
来实现一个printf吧
现在我们来实现一个printf吧,为了方便我用比较简单潦草的程序只写了一个%d
的识别,而且用了一些goto语句,不过总体来说还是可以看懂的。
void MyPrint(char format[], ...)
{
va_list args;
va_start(args, format);
int i = 0;
char intStr[10] = {0};
while (1)
{
switch (format[i])
{
case '%':
i++;
switch (format[i])
{
case 'd':
itoa(va_arg(args, int), intStr, 10);
for(int i = 0; i < sizeof(intStr); i++)
{
if(intStr != '\0')
{
putchar(intStr[i]);
}
else
{
break;
}
}
i++;
break;
default:
putchar('%');
putchar(format[i]);
i++;
break;
}
break;
case '\0':
goto OUT;
default:
putchar(format[i]);
i++;
break;
}
}
OUT: return;
}
上面这个程序其实你们自己都可以写,如果还是有点不懂的话可以仔细读一下,里面用的函数都是非常基础的API,现在我们测试一下:
MyPrint("%d %d\n%c %x", 1, 2, 3);
这个程序可以用来测试MyPrint的以下功能:
- 正常输出打印字符串
- 格式字符%d是否有用
- %c和%x应该没用
- %c和%x的存在不会影响编译警告,同时也不会读取更多或更少的参数
现在,只要我们继续填充内容,就可以实现自己的printf功能了,甚至可以添加一些自己的格式符,来实现一些更加复杂高级的输出。
printf是否有检测功能
我们经过刚才的研究发现,在使用printf的时候哪怕格式字符和数据的个数对不上号,我的编译器似乎也不会报错(这里仅指代MinGW,如果你使用的codeblocks或者其它开源IDE,可能结果与我一样),但是有的编译器不一样,例如针对嵌入式的gcc-arm-none-eabi
编译器,或者一些其它的IDE自研编译器,它们有可能会报错或警告,为什么呢?printf是否拥有某种检测功能,来防止我们错误地多填或者少写实参呢?事实上是有的,不过有的编译器没有用或者没有实现,这种功能一般专属于GNU版本的编译器,那就是使用__attribute(())_
功能。
我们知道,平常我们编程的时候,编译器说什么,我们就得听什么,必须完全听从编译器的指挥。从绝大部分情况来看,它很方便也很可靠,毕竟不用动脑子按规范来就行。但是C语言是一个非常贴近硬件的语言,我们很多时候可能会完成一些匪夷所思的操作来实现某种功能,还有时候我们有些操作非常危险需要用一种方法来时刻提醒我们的大脑,那么我们就可以使用__attribute__
了,这个关键字可以被编译器识别,然后根据其参数,编译器将会执行相应的操作。从另一种方面来讲,__attribute__
是专门针对编译器的指令,我们可以用它实现一些很方便的功能,最经典的就是__attribute__((weak))
了,这个功能可以让函数被弱化,如果在同一个程序中遇到了两个同名同参同返回的函数,那么编译器就会去编译没被弱化或者说更强的那一个函数,这样我们就可以实现函数的重写覆盖功能。
当然,__attribute__
的用法其实有很多,我们再举一个常用的例子,也就是__attribute__((pack))
这个功能,它可以把结构体尽量地压缩到最小体积(平常为了保证速度,结构体会按照速度最优的方案来存储)。
而这里,我们同样可以使用__attribute__((format(printf, 1, 2)))
,来实现对printf的格式检测,这样如果我们的格式符和实参个数不对应,就可以被检测到了。(不过MinGW好像没法实现这个功能,如果有使用gcc-arm-none-eabi
的同学可以试试)
printf只能输出在命令行吗
目前我在Windows上使用MinGW测试,暂时没有发现其printf有重定向功能,不过这个应该是为了安全考虑,毕竟你在Windows上实现在其它地方打印,一般需要使用Windows提供的API,否则很容易出问题,而如Linux这些系统,大多也是如此。
不过对于嵌入式来说,重写pritnf就是很平常很普通的任务了,我们一般会把printf重写在串口上,但是在写了屏幕驱动后,也可以把printf重新封装到屏幕上,而Windows运行的printf应该就是把printf连接在了Windows的屏幕驱动中,然后通过调用驱动的方式来实现的数据打印功能。
本质上printf其实可以当作连续按照格式使用putchar的函数,而putchar也就打印字符的函数,那么我们只要可以确定每个putchar打印的位置,那么printf的使用就迎刃而解了。
结语
现在你已经基本懂得了printf是如何实现的了,那么用这个方法反推,其实你应该也可以实现scanf的功能,因为它们的思想都是大致相同的。从觉得printf是一个非常简单的函数,到觉得printf好像非常麻烦,再到我们现在将它几乎完整地剖析了一遍,现在回头看,它的实现机理其实并不算简单,但也没那么复杂,重点是要主动去思考,去想,去理解,去实验。