📌 更新(2026-09-01):整理归档,全部代码用 GCC 15.1.0 重新编译复测,输出与当年一致——另外新编译器对老代码的几处写法有话要说,见正文。
printf 是怎么做到想吃多少参数就吃多少参数的?这个问题的标准答案在 stdarg.h 里:va_list、va_start、va_arg 三件套。机制本身之前在《C 语言可变参数实战》里已经拆解过一次,那篇是机制篇;这一篇是应用篇——同一个文件夹里的三个程序,横跨 2022 年 9 月到 2024 年 4 月,记录了从”会数参数”到”手搓 printf”的完整进化链。
一、先数数,再吃参数:mysum 和 my_max
变参函数的第一课:光有 ... 没法干活,因为函数体里根本不知道这次传了几个参数。于是有了最朴素的协议——count-first,第一个参数先报数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| int mysum(int num, ...) { va_list valist; int i; int sum = 0; va_start(valist, num);
for(i = 0; i < num; i++) { sum += va_arg(valist, int); } return sum; }
|
va_start(valist, num) 把游标对齐到 num 之后,va_arg(valist, int) 每调一次往后搬一个 int,报了几个数就搬几次。配套的 my_max 结构一样,边搬边比。实测:
1 2
| mysum(5,1,2,3,4,5) → 15 mysum(4,1,-5,9,4) → 9
|
这个文件里有两处值得圈出来的细节。第一处是进化痕迹:mysum 忘了写 va_end,而 my_max 规规矩矩写了”清理参数内存”——两个函数挨在一起,正好是”写第二个的时候刚学会”的活化石现场。
第二处是颗至今还埋着的雷:my_max 里 int max = 0;。初始值是 0,意味着如果哪天有人调用 my_max(3, -5, -9, -4),返回值会是 0 而不是 -4。全负数输入直接翻车。当年没踩到纯属测试数据里全是正数——和《我的分数开发记》里那个潜伏四年的 pow 一样,没被调用过的 bug 分支不等于不存在。修复方法也简单:max 初始化成第一个参数,或者干脆 INT_MIN。
还有一个时间彩蛋:这个文件的 exe 编译于 2022-11-17 17:56——而《我的分数》那个库的 exe 编译于 2022-11-17 17:55,一分钟之差。同一个晚上:先在变参文件夹里学会了 va_list,一转头就在分数库里用 Fractional_add(int n, ...) 实现了任意个分数连加。学以致用的闭环用时六十秒。《我的分数开发记》里那个变参全家桶,出生证明就在这。
二、报菜名协议:按格式串消费
count-first 协议的问题是每次都得先报数,参数一多手忙脚乱。更优雅的做法是 printf 的思路:格式串驱动。字符串里每个字母对应一种类型的参数,走一步吃一个:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| void foo(char *fmt, ...) { va_list ap; int d; char c, *s; double f;
va_start(ap, fmt); while(*fmt) { switch(*fmt++) { case 's': s = va_arg(ap, char *); printf("字符串:%s\n", s); break; case 'd': d = va_arg(ap, int); printf("整型:%d\n", d); break; case 'c': c = (char) va_arg(ap,int); printf("字符:%c\n", c); break; case 'f': f = va_arg(ap, double); printf("浮点数:%f\n", f); break; } } va_end(ap); }
|
调用 foo("sdcfs","hello",666,'A',123.456,"sadasd"),格式串 s-d-c-f-s 就是菜单,参数按序上菜:
1 2 3 4 5
| 字符串:hello 整型:666 字符:A 浮点数:123.456000 字符串:sadasd
|
注意 case 'c' 里那个 (char) va_arg(ap, int)——变参列表里的 char 传进函数时早被默认参数提升抬成了 int,取的时候必须按 int 取再自己转回来,直接 va_arg(ap, char) 是未定义行为。同理 'f' 档位取的是 double 而不是 float。这两个坑,printf 的源码里也是这么处理的。
(顺带:注释里写”va_list 定义一个 char 类型指针”——各家的 va_list 实现确实常常是 char* 的 typedef,但这是实现细节不是标准承诺,x86-64 下它其实是个数组结构的别名。当年这个理解在 32 位时代够用,放在今天要打个补丁。)
三、毕业设计:手搓 My_printf
前两个程序是吃一个打一个,最终形态是更接近 printf 的做法:把结果拼进缓冲区,最后一次性输出。这就是 2024 年 4 月的 My_printf,支持 %s %d %c %f 和夹在中间的普通字符:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| void My_printf(char *format, ...) { int str_len;
va_list valist; int data_integer; char data_character; char * data_String; double data_float;
char str[100] = "";
va_start(valist, format); while(*format) { if(*format == '%') { switch(*(format + 1)) { case 's': data_String = va_arg(valist, char *); strcat(str,data_String); break; case 'd': data_integer = va_arg(valist, int); char *temp = int_to_str(data_integer); strcat(str,temp); free(temp); break; case 'f': data_float = va_arg(valist, double); char temp_arr[20]; sprintf(temp_arr,"%lf",data_float); strcat(str,temp_arr); break; case 'c': data_character = (char) va_arg(valist, int); int str_len = strlen(str); str[str_len] = data_character; str[str_len + 1] = '\0'; break; default: break; } *format++; } else { str_len = strlen(str); str[str_len] = *format; str[str_len + 1] = '\0'; } *format++; } va_end(valist);
int i = 0; while(str[i] != '\0') putchar(str[i++]); }
|
运行实拍:
1 2
| My_printf("%s + %d + %c + %f","helloworld",666,'B',123.456); → helloworld + 666 + B + 123.456000
|
这个函数里最有故事的是 %d 分支调用的 int_to_str。注释里留着手搓版:先把数字一位一位拆出来(temp /= 10 数位数,count *= 10 算权重,再 num / count % 10 逐位提取),后来换成了 itoa(num, str, 10) 库函数一把梭。手搓版能看出当年对”整数其实是数字的排列”这件事的较真,库函数版能看出较真过后的豁达——两个版本都留在代码里,像作业本的修改痕迹。
2026 年复测,GCC 15 对这个函数提了两条意见:
1
| warning: value computed is not used [-Wunused-value]
|
两处 *format++。写的人想表达”指针往后走一格”,但 *format++ 是先取 *format 的值再让指针后移,取出来的值没人用,于是编译器提醒”你算了半个东西扔了”。语义上没错(后移生效了),规范写法是 format++。这个写法能活下来是因为老编译器不吭声——新编译器的话,还是要听的。
还有一个当年的自觉留白:char str[100] 拼接全程没有长度检查,格式串多来几个 %s 就能把它撑爆。真要产品化,下一步就是带上 str[100] 的容量做安全拼接——printf 的实现里这套叫 va 一族+缓冲区管理,这里就先点到为止。
四、复盘:两种协议,一条铁律
回头看这三个程序,变参函数的设计要点其实就两条:
怎么知道参数有几个/是什么——mysum 选择 count-first(先报数),foo 和 My_printf 选择格式串驱动(边走边看菜单)。前者简单粗暴适合同类型参数,后者是 printf 路线,代价是要写格式串解析。两条路都通向同一个终点:va_arg 的类型参数必须和人家的实际传参严格对齐,取错类型就是未定义行为,没有例外。
va_end 不是可选项——mysum 忘了它没出事,那是因为在常见 ABI 下 va_end 常常被编译成空操作或简单的指针复位。标准要求它,是因为在别的平台上它可能真的要干活。忘了写没炸,和买了彩票没中奖一样,都不构成规律。
最后一条铁律:变参函数至少要有一个命名参数——foo(char *fmt, ...) 里的 fmt 就是那个锚点,va_start 靠它定位参数起点。纯粹的 f(...) 在 C 里写不出来(C23 才松口),这也是为什么所有变参函数的第一个参数总是”垫场”的。
五、完整源码
三个文件按进化顺序排列(前两份 GBK 原件转录 UTF-8,第三份本身就是 UTF-8,内容均未改动):
函数参数数量可变.c1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| #include <stdio.h> #include <stdarg.h>
int mysum(int num, ...) { va_list valist; int i; int sum = 0; va_start(valist, num);
for(i = 0; i < num; i++) { sum += va_arg(valist, int); } return sum; }
int my_max(int num, ...) { va_list valist; int i; int max = 0; int temp; va_start(valist, num);
for(i = 0; i < num; i++) { temp = va_arg(valist, int); if(temp > max) { max = temp; } }
va_end(valist);
return max; }
int main() { printf("%d\n",mysum(5,1,2,3,4,5)); printf("%d\n",mysum(4,1,-5,9,4)); getchar(); }
|
提取可变形参列表里的单个数据.c1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdarg.h>
void foo(char *fmt, ...); int main(int argc,char **argv) { foo("sdcfs","hello",666,'A',123.456,"sadasd"); return 0; }
void foo(char *fmt, ...) { va_list ap; int d; char c, *s; double f; va_start(ap, fmt); while(*fmt) { switch(*fmt++) { case 's': s = va_arg(ap, char *); printf("字符串:%s\n", s); break; case 'd': d = va_arg(ap, int); printf("整型:%d\n", d); break; case 'c': c = (char) va_arg(ap,int); printf("字符:%c\n", c); break; case 'f': f = va_arg(ap, double); printf("浮点数:%f\n", f); break; } } va_end(ap); }
|
提取字符.c1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
| #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdarg.h>
char * int_to_str(int num); void My_printf(char *fmt, ...);
int main(int argc,char **argv) { My_printf("%s + %d + %c + %f","helloworld",666,'B',123.456);
getchar(); return 0; }
char * int_to_str(int num) {
char * str = (char *)malloc(sizeof(char) * 20); itoa(num,str,10); return str; }
void My_printf(char *format, ...) { int str_len;
va_list valist; int data_integer; char data_character; char * data_String; double data_float;
char str[100] = "";
va_start(valist, format); while(*format) { if(*format == '%') { switch(*(format + 1)) { case 's': data_String = va_arg(valist, char *); strcat(str,data_String); break; case 'd': data_integer = va_arg(valist, int); char *temp = int_to_str(data_integer); strcat(str,temp); free(temp); break; case 'f': data_float = va_arg(valist, double); char temp_arr[20]; sprintf(temp_arr,"%lf",data_float); strcat(str,temp_arr); break; case 'c': data_character = (char) va_arg(valist, int); int str_len = strlen(str); str[str_len] = data_character; str[str_len + 1] = '\0'; break; default: break; } *format++; } else { str_len = strlen(str); str[str_len] = *format; str[str_len + 1] = '\0'; } *format++; } va_end(valist);
int i = 0; while(str[i] != '\0') putchar(str[i++]); }
|
从”报数求和”到”格式串驱动”再到”缓冲区拼接”,三个程序连起来正好是 printf 的缩微进化史。理解一样东西最好的方式,永远是亲手做一个不如它好但属于自己的版本。