深圳做二维码网站,东莞找工作在什么平台找合适,微商免费推广平台有哪些,泰安网页在学习 C 语言的过程中#xff0c;printf 应该是我们打交道最多的函数了。但你是否思考过下面的问题#xff1a;普通的函数参数个数都是固定的#xff0c;为什么 printf 可以接受无限个参数#xff1f;编译器又是怎么知道我们传了几个参数的#xff1f;为什么 %c 明明是打…在学习 C 语言的过程中printf应该是我们打交道最多的函数了。但你是否思考过下面的问题普通的函数参数个数都是固定的为什么printf可以接受无限个参数编译器又是怎么知道我们传了几个参数的为什么%c明明是打印字符底层却要按int类型来处理在这篇文章我们就从零手写一个my_printf彻底弄懂 C 语言变参函数和函数调用栈的底层原理。1. 核心原理1.1 编译器对于我们比较常见的普通函数他们的参数类型和数量一般是固定的像这样voidfunc(inta,intb);这种情况下编译器在编译时非常清楚调用这个函数需要传递 2 个int。我们再来看看printf函数的声明1.1.1 restrict 的作用在进入咱们的正题之前我们先需要了解一下图中的restrict到底是起了什么作用。restrict是 C99 标准引入的关键字通俗一点来说它的作用是告诉编译器这个const char *指针是访问它所指向内存的唯一入口除此之外别无其他指针从而使得编译器可以放心大胆地去优化代码。仅仅这样语言描述一下可能还是有点不够形象为了帮助大家理解我下面举个例子编译器在优化代码时其实是非常保守的因为它非常害怕两个不同的指针实际上指向同一块内存指针别名。请看下面的代码voidadd(int*a,int*b,int*val){*a*val;*b*val;}这个add函数接收三个int *类型的参数分别是abval然后将val所指向内存的值分别加到a和b所指向内存中的值。上面其实也提到了编译器优化代码时是很保守的。我们来梳理一下这个过程编译器在第一步会读取* val的值然后加到* a中去第二步再次读取* val的值然后将读取到的* val的值加到* b中去。可能有人这样想第一步不是已经读取了* val的值了吗为什么第二步还要再读取一遍呢直接使用第一步读取到的值不行吗而编译器是这样想的如果在传参数是a和val指向的是同一个地址怎么办第一步的操作改变了* a的值同时也改变了* val的值如果还使用改变之前的* val的值那计算不是出错了吗因此在编译器不能确保两个指针指向的不是同一块内存时它在使用这块内存中的值之前都会去内存里面重新读取一下以防计算发生错误。而如果我们加上restrict像下面这样voidadd_2(int*a,int*b,int*restrict val){*a*val;*b*val;}这就相当于告诉编译器* val的值绝对不会被a或b修改这样编译器在第一次读取了* val的值后第二步时直接就能使用省得再回内存里面去查。这样也会带来相应的性能提升。1.1.2 restrict 案例分析为了让大家能够更清晰地看到这个现象我进行了下面的实验voidadd(int*a,int*b,int*val){*a*val;*b*val;}voidadd_2(int*a,int*b,int*restrict val){*a*val;*b*val;}这段代码add函数的val我没加restrict而add_2函数我加了。然后可以使用下面的命令将这段代码编译成汇编代码gcc -O2 -S demo.c -o demo.s-O2选项是开启编译器优化-S是生成汇编代码。下面是我截取的部分汇编代码生成的汇编代码中我们只需要看关键部分对于add函数我们需要看第 10-13 行对于add_2函数我们需要看 25-27 行。在我使用的 64 位 Linux 系统中函数参数是通过寄存器传递的有固定的顺序。第一个参数a放在寄存器%rdi中第二个参数b放在寄存器%rsi中第三个参数val存放在寄存器%rdx中。而%eax是一个通用寄存器用于临时存放数据。在了解了一些必备的知识后我们先来看函数add的关键部分。第10行movl (%rdx), %eax去val指向的内存地址%rdx把里面的数字取出来放到%eax中。第11行addl %eax, (%rdi)将%eax中的数加到a指向的内存地址%rdi中去。在看第12行又去val指向的%rdx把里面的值取出来放到%eax中第13行addl %eax, (%rsi)把%eax中的数加到%rsi中去。在我们看来第二次再去读* val的值是多此一举的但是编译器害怕旧值被修改就只能再去读一次拿到最新的值。再来看函数add_2这里的val加了restrict关键字我们看看情况是否有所不同。第25行依然是去val指向的内存将值取出来放到%eax中第26行将%eax中的值加到a所指向的内存地址%rdi中去。而到了第27行它没有再去读一遍* val的值而是直接使用旧的* val的值直接加到b指向的内存%rsi中去。到这里相信大家已经很清晰了。内存是慢速设备而寄存器速度是极快的加上restrict关键词我们节省了一次去内存读取的开销。1.1.3 小结在了解了restrict之后新的问题有浮出水面了。回过头去看printf函数的声明编译器只能知道有一个固定参数format他不知道后面还有没有参数更不知道后面参数是什么类型的。既然编译器不知道那么printf是怎样正确的打印出内容的呢答案藏在下一章的内容里面。1.2 函数调用栈假如我们在32位系统下调用下面的代码my_printf(Num:%d,100);此时我们来看一下函数调用栈的布局注现代 x64 或 ARM 架构通常优先使用寄存器传参上面汇编的解释中提到过但stdarg.h的封装让我们在逻辑上还是可以像操作栈一样去处理它们核心思想是不变的。首先我们要知道栈是从高地址向低地址生长的。可以仔细观察一下图中左边函数调用栈的布局最上面的是之前的栈帧。其次我们还要知道函数参数的入栈顺序是从右向左的。在我举的这个例子里面最右边的参数100先入栈它紧贴着之前的栈帧然后它左边的参数也就是 “Num:%d” 的所在的地址入栈紧贴着参数100存放。最后函数的返回地址入栈。理解了这张图那一切就都明了了。虽然编译器只知道第一个固定参数而不知道它后面有什么但根据 C 语言的调用约定变参一定紧紧挨着固定参数存放并且位于更高的地址。下面我们来顺一下整个流程参数format的类型是const char *也就是说它本身是一个指针真正的字符串内容 “Num:%d” 存放在只读数据区(.rodata)在32为系统下它的大小是4 个字节而本身存放format的地址我们也是知道的。我们要做的是拿到format变量本身在栈上的地址然后加上format的大小也就是4个字节这样指针就跳过了format占据的地址从而能够得到变参100的地址。同理就算后面还有其他变参我们也能通过地址偏移的方法找到他。如果使用的是64位系统那么format的大小就会改变为 8 个字节但是原理还是这样的。这也就是为什么编译器并不知道format后面的参数是什么而printf却能正确打印出来的原因。1.3 变参宏的本质理论上我们确实可以手动通过(char*)format 4来找到下一个参数。这里先简要提一下为什么取format的地址后要转为char *类型这就涉及到C语言中一个基础知识点——指针运算。在C语言中指针加减的单位不是字节而是它指向的数据类型的大小。我把指针转为char *类型是为了能够让它以字节为单位移动。在转换类型之后(char*)format的数据类型为char *它指向的是char类型长度为 1 个字节所以(char*)format 4指针就会精确的移动4个字节刚好跳过format变量。如果不转的话format的类型是char**它指向的是char*(指针在32位系统里面大小为 4 )再给他加上 4 那么这个指针就会移动16个字节已经错过了我们要找的变参。但直接写硬编码的数字4是非常危险的。因为在 64 位系统上它应该是 8在某些特殊架构上对齐方式可能更复杂。因此为了解决跨平台问题C 语言在stdarg.h中封装了一套标准宏。为了弄懂这些宏执行的操作和我上面描述的是否一致我查看了现代Linux环境下stdarg.h的源码。结果发现GCC的stdarg.h的实现如下这里要简单补充一下普通的头文件如stdio.h通常由 C 标准库提供放在/usr/include。但stdarg.h非常特殊它涉及参数传递的底层细节如寄存器使用、栈帧布局这些是依赖编译器实现的。因此GCC 不会使用系统默认的 stdarg.h而是会优先使用自己私有目录下的版本。因此我们要使用下面的命令去查看vim$(gcc -print-file-nameinclude/stdarg.h)为什么要使用这条命令呢因为gcc安装目录版本众多路径随版本变化而这条命令能获得gcc实际使用的文件并使用vim编辑器打开它。现在我们来看一下stdarg.h的源码全是__builtin_开头的函数。这是因为现代 CPU 架构如 x86-64的参数传递非常复杂优先使用寄存器寄存器用完了才压栈单纯的指针加减法已经无法搞定了。编译器为了极致的性能优化选择把这些操作直接内置在编译器内部。为了能够让大家看到实际的C语言实现我翻出了Linux 0.11 内核源码我把源码的内容直接放在下面的代码块中注原汁原味我一点没改#ifndef_STDARG_H#define_STDARG_Htypedefchar*va_list;/* Amount of space required in an argument list for an arg of type TYPE. TYPE may alternatively be an expression whose type is used. */#define__va_rounded_size(TYPE)\(((sizeof(TYPE)sizeof(int)-1)/sizeof(int))*sizeof(int))#ifndef__sparc__#defineva_start(AP,LASTARG)\(AP((char*)(LASTARG)__va_rounded_size(LASTARG)))#else#defineva_start(AP,LASTARG)\(__builtin_saveregs(),\AP((char*)(LASTARG)__va_rounded_size(LASTARG)))#endifvoidva_end(va_list);/* Defined in gnulib */#defineva_end(AP)#defineva_arg(AP,TYPE)\(AP__va_rounded_size(TYPE),\*((TYPE*)(AP-__va_rounded_size(TYPE))))#endif/* _STDARG_H */源码第 910 行定义了一个宏__va_rounded_size这行代码看着复杂其实只做一件事就是向上取整到 int 的倍数。也就是说如果你传char算出来是 4如果你传int算出来还是 4如果你传double算出来就是 8 。这也解释了为什么我们在手动推导时默认步长是 4 的原因本质上是为了保证栈上的内存对齐。然后请看源码第 13 行相信大家已经看到了char *强转LASTARG取出的是固定参数的地址如果不转成char *指针加法会按照该类型的步长移动转成char *后加法就变成了字节级的移动。这也证实了必须要将指针转为单字节步长才能精确跨过固定参数指向变参的第一个元素。再看源码第 24 行这里的操作非常有意思它利用了 C 语言的逗号表达式(A, B)。规则是先执行 A再执行 B并且整个表达式的值等于 B 的值。在逗号前游标 AP 直接向前移动跨过了当前这个参数指向了下一个参数的起点。逗号后用新的参数起始地址减去当前参数的大小就是当前参数的起始地址把这个地址强转为TYPE *再解引用最后拿到数据。最后这个宏的值就是拿到的数据。至于逗号后面为什么要强转为TYPE *然后再解引用这是因为解引用后取出数据的长度是根据被解引用的这个东西的数据类型决定的如果这个数据类型不是TYPE *那么解引用取出的数据长度自然就不符合我们的预期。2. 默认参数提升搞懂了底层的宏定义我们终于可以开始写代码了。但在动手之前必须先讲一个大家可能会踩的坑。我们在实现my_printf处理%c字符时如果没有了解过这个细节直觉上会这样写charcva_arg(ap,char);//错误写法这样写会导致指针偏移量出现错误甚至读到乱码。2.1 为什么不能直接取char不知道大家对我们 1.3 节中提到的__va_rounded_size这个宏还有印象吗。无论你传入的数据类型多小栈上的槽位最小也是sizeof(int)。在 C 语言的变参函数调用约定中有一条默认参数提升规则char、short在入栈前会自动升级为int。float在入栈前会自动升级为double。2.2 原理分析如果你写va_arg(ap, char)宏计算出的步长是sizeof(char) 1 字节。游标 AP 只往后移了 1 个字节。但实际上栈里那个字符占了 4 个字节。结果就是指针错位后面的所有参数读取全乱套。那么怎样写才是正确的呢既然编译器把char变成了int塞进栈里那我们取的时候也必须按int去取然后再强转回来。想下面这样intvalva_arg(ap,int);// 按照 int 的步长去栈里取数据my_putchar((char)val);// 强转回 char 使用3. 核心代码实现理论现在已经了解的差不多了我们现在来直接使用系统调用write实现一个my_printf。3.1 基础函数编写printf的核心难点在于把整数转换成字符串例如把整数 123 变成字符数组{1, 2, 3, \0}。标准库有itoa但我们要自己写一个。请看下面代码#includeunistd.h#includestdarg.h//往屏幕写一个字符voidmy_putchar(charc){write(1,c,1);}//往屏幕写字符串voidmy_putstr(constchar*str){inti0;while(str[i])my_putchar(str[i]);}//将整数 value 转换为字符串并打印//base表示 10 进制或 16 进制voidmy_itoa(intvalue,intbase){charbuffer[32];inti0,is_neg0;// 0 是特殊情况单独处理if(value0){my_putchar(0);return;}//处理负数 (这里只处理十进制)if(value0base10){is_neg1;value-value;}//循环取模把 value 的各位数从后往前依次存在 buffer 中while(value!0){intremvalue%base;buffer[i](rem9)?(rem-10)a:rem0;//如果是十六进制要处理 a-fvalue/base;}//如果是负数给 buffer 最后再补个负号if(is_neg)buffer[i]-;//逆序打印把 buffer 的内容从后往前依次打印while(i0)my_putchar(buffer[--i]);}虽然上面的注释已经很完善了但我还是想简单解释一下上面代码的实现。我们假设传进去的数字是 123 。计算机不认识 123 这个整体它只认识二进制。为了把它变成字符 ‘1’, ‘2’, ‘3’我们需要从个位开始剥离请结合while循环中的代码理解下面过程value % base拿到最后一位数字例如123 % 10 3。然后将它放在buffer[0]然后i变成 1 。value / base去掉最后一位例如123 / 10 12。就这样循环 3 次buffer[] {3, 2, 1}这时i为 3 。因为它不是负数下一步就可以循环打印了。打印的时候i先减 1变成 2 这时打印出buffer[2]也就是 1依次类推最终打印出 123 三个字符。3.2 主要逻辑有了my_itoa和标准宏我们就可以编写主函数逻辑了。intmy_printf(constchar*format,...){va_list ap;va_start(ap,format);//初始化让 ap 指向第一个变参constchar*pformat;while(*p!\0){if(*p%){p;// 跳过 %switch(*p){cased:// 十进制整数{intvalva_arg(ap,int);// 从栈里取出一个 intmy_itoa(val,10);break;}casex:// 十六进制整数{intvalva_arg(ap,int);my_putstr(0x);// 加个前缀my_itoa(val,16);break;}casec:// 字符{intvalva_arg(ap,int);my_putchar((char)val);break;}cases:// 字符串{// 字符串本质是 char* 指针指针不需要提升char*strva_arg(ap,char*);my_putstr(str);break;}default:// 如果不是上面的几种原样打印my_putchar(%);//记得先把跳过的那个 % 打印了my_putchar(*p);break;}}else{// 普通字符my_putchar(*p);}p;// 继续扫描下一个字符}va_end(ap);//将 ap 置空防止野指针return0;//这里简化版返回 0标准库通常返回打印的字符数}这段代码的主要逻辑是如果遇到普通字符那就直接输出。如果遇到%暂停输出查看下一个字符是什么再决定去栈里取什么数据。理解了主要逻辑我们再看一下几个关键细节va_start(ap,format);执行完这句指针ap就已经跳过了format字符串本身停在了第一个变参的内存起始位置准备随时被调用。然后是switch-case逻辑根据%后面的字母决定用什么类型去取栈里的数据。如果是%d调用va_arg(ap, int)。编译器会生成指令从当前栈位置复制 4 个字节解释为int然后调用我们写好的my_itoa转成字符打印。如果是%x逻辑和上面相同但我手动打印了0x前缀这样能与十进制有所区别。如果是%s调用va_arg(ap, char*)。注意这里取出的只是一个指针真正的字符串内容存储在常量区或堆区我们把这个地址交给my_putstr去遍历打印。然后就是那个反直觉的%c虽然我们要打印的是char但在va_arg里必须写int正如第二章所述C 语言的默认参数提升规则char在入栈时已经被强转为int了因此我们取的时候也要按照int来取。最后va_end(ap)调用它来将指针置空。4. 测试与验证为了验证我们的my_printf是否经得起考验我编写了一个main函数覆盖了整数、十六进制、字符串和最关键的字符提升。intmain(){my_putstr( My Printf Test \n);my_printf(Integer: %d\n,12345);my_printf(Negative: %d\n,-6789);my_printf(Hex: %x\n,255);my_printf(String: %s\n,Hello World);my_printf(Char: %c\n,A);my_printf(Mix: %d %c %s\n,1,2,Three);return0;}运行结果如下大家可以看到各种类型都能正常打印。包括涉及到参数提升的char型也能正常打印。5. 总结写到这里我们的my_printf已经跑通了。但说实话这不到 100 行的代码本身并不重要毕竟在实际应用中我们永远会去用标准库那个经过千锤百炼的printf。而这个手写printf真正的价值在于它是我们探索底层原理的一个引导。通过这几章的分析我们收获的不是一个粗糙的打印函数而是对 C 语言运行机制的深刻理解。我们亲手证明了stdarg.h里那些看似高深的宏剥去它光鲜亮丽的外壳后不过是朴实无华的指针加减法。我们深刻体会到了默认参数提升的存在。这不是书本上的死记硬背而是为了让 CPU 跑得更快、内存对齐更方便而设计的硬件妥协。我们还知道参数在内存里是挨着放的拿到一个参数就能顺藤摸瓜拿到其他的参数。好了这篇文章到这里就结束了。最后我还想说两句私房话我真心希望这篇文章能帮助到大家如果大家觉得看完这篇文章有所收获的话并且你自己也愿意的话希望你能留下一个关注我在这里先说一声感谢后面我还会再发这种能帮助大家深入理解底层原理的文章。