📌 更新(2026-09-01):整理归档,全部代码用 GCC 15.1.0 重新编译复测,输出与当年一致——另外新编译器对老代码的几处写法有话要说,见正文。

printf 是怎么做到想吃多少参数就吃多少参数的?这个问题的标准答案在 stdarg.h 里:va_listva_startva_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_maxint 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; //定义一个char类型指针
int d;
char c, *s;
double f;

va_start(ap, fmt); //指针地址赋值--初始化
while(*fmt) //遍历fmt指针指向空间的值
{
switch(*fmt++)
{
case 's': /* string */
s = va_arg(ap, char *);
printf("字符串:%s\n", s);
break;
case 'd': /* int */
d = va_arg(ap, int);
printf("整型:%d\n", d);
break;
case 'c': /* char */
c = (char) va_arg(ap,int);
printf("字符:%c\n", c);
break;
case 'f': /* float */
f = va_arg(ap, double);
printf("浮点数:%f\n", f);
break;
}
}
va_end(ap); //将ap指针置为NULL
}

调用 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) //遍历fmt指针指向空间的值
{
//printf("%c",*format);
if(*format == '%')
{
switch(*(format + 1))
{
case 's': /* string */
data_String = va_arg(valist, char *);
//printf("字符串:%s\n", data_String);
strcat(str,data_String);
break;
case 'd': /* int */
data_integer = va_arg(valist, int);
//printf("整型:%d\n", data_integer);
char *temp = int_to_str(data_integer);
strcat(str,temp);
free(temp);
break;
case 'f': /* float */
data_float = va_arg(valist, double);
// printf("浮点数:%lf\n", data_float);
char temp_arr[20];
sprintf(temp_arr,"%lf",data_float);
strcat(str,temp_arr);
break;
case 'c': /* char */
data_character = (char) va_arg(valist, int);
//printf("字符:%c\n", data_character);
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); //将ap指针置为NULL

//printf("%s",str);
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(先报数),fooMy_printf 选择格式串驱动(边走边看菜单)。前者简单粗暴适合同类型参数,后者是 printf 路线,代价是要写格式串解析。两条路都通向同一个终点:va_arg 的类型参数必须和人家的实际传参严格对齐,取错类型就是未定义行为,没有例外。

va_end 不是可选项——mysum 忘了它没出事,那是因为在常见 ABI 下 va_end 常常被编译成空操作或简单的指针复位。标准要求它,是因为在别的平台上它可能真的要干活。忘了写没炸,和买了彩票没中奖一样,都不构成规律。

最后一条铁律:变参函数至少要有一个命名参数——foo(char *fmt, ...) 里的 fmt 就是那个锚点,va_start 靠它定位参数起点。纯粹的 f(...) 在 C 里写不出来(C23 才松口),这也是为什么所有变参函数的第一个参数总是”垫场”的。

五、完整源码

三个文件按进化顺序排列(前两份 GBK 原件转录 UTF-8,第三份本身就是 UTF-8,内容均未改动):

函数参数数量可变.c
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
#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();
}
提取可变形参列表里的单个数据.c
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
#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; //定义一个char类型指针
int d;
char c, *s;
double f;

va_start(ap, fmt); //指针地址赋值--初始化
while(*fmt) //遍历fmt指针指向空间的值
{
switch(*fmt++)
{
case 's': /* string */
s = va_arg(ap, char *);
printf("字符串:%s\n", s);
break;
case 'd': /* int */
d = va_arg(ap, int);
printf("整型:%d\n", d);
break;
case 'c': /* char */
c = (char) va_arg(ap,int);
printf("字符:%c\n", c);
break;
case 'f': /* float */
f = va_arg(ap, double);
printf("浮点数:%f\n", f);
break;
}
}
va_end(ap); //将ap指针置为NULL
}
提取字符.c
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
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 %s %f","hello",666,'A',"sadasd",123.52);
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);
// char * result = str;
// int temp = num;
// int count = 1;
// while(temp)
// {
// temp /= 10;
// count *= 10;
// }
// while(count /= 10)
// {
// *str++ = ((num / count) % 10 + '0');
// }
// *str = '\0';
// return result;

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) //遍历fmt指针指向空间的值
{
//printf("%c",*fmt);
if(*format == '%')
{
switch(*(format + 1))
{
case 's': /* string */
data_String = va_arg(valist, char *);
//printf("字符串:%s\n", data_String);
strcat(str,data_String);
break;
case 'd': /* int */
data_integer = va_arg(valist, int);
//printf("整型:%d\n", data_integer);
char *temp = int_to_str(data_integer);
strcat(str,temp);
free(temp);
break;
case 'f': /* float */
data_float = va_arg(valist, double);
// printf("浮点数:%lf\n", data_float);
char temp_arr[20];
sprintf(temp_arr,"%lf",data_float);
strcat(str,temp_arr);
break;
case 'c': /* char */
data_character = (char) va_arg(valist, int);
//printf("字符:%c\n", data_character);
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); //将ap指针置为NULL

//printf("%s",str);
int i = 0;
while(str[i] != '\0')
putchar(str[i++]);
}

从”报数求和”到”格式串驱动”再到”缓冲区拼接”,三个程序连起来正好是 printf 的缩微进化史。理解一样东西最好的方式,永远是亲手做一个不如它好但属于自己的版本。