Variable Arguments 可变参数函数

#Introduction

有时你会希望函数带有可变数量的参数,而不是固定数量的参数。
在C语言中可以使用<stdarg.h>头文件来实现可变参数。

可变参数函数分为两个部分:

  • 前一部分是固定的参数,如intchar*double
  • 后一部分是可变参数,使用...来表示
1
2
/* printf的声明 */
int printf(const char* const _Format, ...);

在定义可变参数函数的时候,需要遵循一些规则:

  1. 可变参数函数参数列表至少有一个固定参数
  2. 可变参数列表必须放在形参列表最后

#用法

为了引用可变参数列表中的参数,需要用到<stdarg.h>头文件定义的:

  • 变量类型: va_list
  • 三个宏: va_start, va_arg, va_end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 定义一个指向可变参数列表的指针。
va_list 

// 初始化可变参数列表指针 ap,使其指向可变参数列表的起始位置,
// 即函数的固定参数列表的最后一个固定参数 last_fix_arg 的后面第一个参数。
va_start(va_list ap, last_fix_arg)
  
// 以类型 type 返回可变参数,并使 ap 指向下一个参数。
va_arg(va_list ap, type)
    
// 清零 ap。
va_end(va_list ap)
  • 可变参数必须从头到尾逐个访问
  • 这些宏无法直接判断有几个可变参数列表中有几个参数
  • 这些宏无法判断每个参数的类型
  • 若对参数指定了错误的类型,会影响到后面参数的读取

#机理

回到C/C++的函数调用规则 —— 在函数调用前,函数的参数从右往左依次入栈,最后压入返回值。
无论参数数量有多少,这些参数都被压到了栈上,只不过并不知道这些栈上的参数的具体含义。所以va_arg需要指定参数的类型后才能引用函数的可变参数。
栈的增长方向是从高地址到低地址,因此参数从左到右,地址依次增大,固定参数列表最后一个参数的作用就是告诉可变参数列表的起始地址,va_start就是实现了下面这条公式:

可变参数列表的起始地址 = 固定参数列表最后一个参数的地址 + 这个参数的大小

从本质上来说,paras就是指向函数调用栈的一个指针,va_arg需要指明参数的类型,才能做出正确的偏移。va_arg返回后,paras指针会自己指向下一个参数。

为了安全考虑,防止以后继续使用paras指针访问了未定义的地址空间,我们需要使用va_end宏清空paras指针。

#整型加法函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdarg.h>
#include <stdio.h>

int add(int count, ...) {
    /* 定义可变参数列表 */
    va_list paras;
    /* 初始化,让paras指向第一个参数 */
    va_start(paras, count);

    int sum = 0;
    for (int i = 0; i < count; ++i) {
        /* 引用paras指向的参数,解读为int类型 */
        sum += va_arg(paras, int);
    }

    /* 清零paras指针 */
    va_end(paras);
    return sum;
}

int main() {
    printf("%d\n", add(5, 1, 2, 3, 4, 5));
    return 0;
}

#不使用va_list实现可变参数

函数调用时的参数传递方式与 CPU 的体系结构和编译器实现有关。但通常认为,在参数数量较少的情况下,参数会从右到左依次压入栈中。但这不是一定的,有些参数会通过寄存器传递,这属于调用约定

可变参数函数也是基于类似的原理实现的:多个参数被依次压栈,只要知道第一个已知参数(通常是固定参数,如 count)在栈中的位置,就可以通过指针运算访问后续的可变参数。

基于这一机制,下面展示一个不使用 va_list 而通过指针手动访问可变参数的 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
#include <stdint.h>
#include <stdio.h>

#if INTPTR_MAX == INT64_MAX
    #define ARG_TYPE long long  // 适配64位系统的指针宽度
#elif INTPTR_MAX == INT32_MAX
    #define ARG_TYPE int       // 适配32位系统的指针宽度
#else
    #error "Unsupported platform"
#endif

int addWithoutVaList(int count, ...) {
    int sum = 0;
    ARG_TYPE *args = (ARG_TYPE *)&count + 1;  // 从第一个变参开始访问
    for (int i = 0; i < count; ++i) {
        sum += (*args++);  // 累加每个变参
    }
    return sum;
}

int main() {
    printf("add without va_list: %d\n", addWithoutVaList(5, 1, 2, 3, 4, 5));
    return 0;
}

1
2
3
运行结果:
PS F:\C++\VarifyCode> ./main  
add without va_list: 15

此外,在通过地址访问参数时,必须注意程序当前的编译模式是32位还是64位。这是因为不同架构下,指针大小与参数对齐方式不同,影响了我们使用指针访问变参的正确性。

例如,在 64 位平台下,大多数整数参数会以 8 字节对齐,因此我们在代码中定义了 ARG_TYPE 来适配系统的指针宽度,避免因解引用宽度不一致而出现访问错误或未定义行为。

我们可以使用 gdb 查看函数调用时参数在栈中的排列方式。以下是在64位系统中观察 count 变量地址开始的栈内容:

1
2
3
4
5
(gdb) x/8xg &count
0x5ffe60:       0x00007ff600000005      0x0000000000000001
0x5ffe70:       0x0000000000000002      0x0000000000000003
0x5ffe80:       0x0000000000000004      0x0000000000000005
0x5ffe90:       0x00000000005fff10      0x00007ff632031340

这种行为是一种未定义行为,如果涉及复杂参数,应避免这样访问参数。可以看到count后面还有0x7ff6,这并不是纯粹的count=5。后面我再次运行程序调试得到如下结果,所以避免直接这样访问参数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Thread 1 hit Breakpoint 1, addWithoutVaList (count=5) at main.c:13
13          int sum = 0;
(gdb) x/xg &count
0x5ffe60:       0x0000000000000005

WSL gdb调试结果,结果错误:
(gdb) x/10xg &count
0x7fffffffddbc: 0x00000d6800000005      0x5555601d00000000
0x7fffffffddcc: 0x0000000100005555      0x0000000100000000
0x7fffffffdddc: 0x0000000200000000      0x0000000300000000
0x7fffffffddec: 0x0000000400000000      0x0000000500000000
0x7fffffffddfc: 0xf7ffd04000000000      0xf7c8cec300007fff

#实现自己的print函数

 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
int print(const char* const fmt, ...);

int main() {
	print("int: %d %x char: %c string: %s float: %f", 45, 45, 'q', "aweu", 1231.45211456);
	return 0;
}

int print(const char* const fmt, ...) {
	/* 定义可变参数列表 */
	va_list paras;
	/* 初始化,让paras指向第一个参数 */
	va_start(paras, fmt);

	for (int i = 0; fmt[i] != '\0'; ++i) {
		if (fmt[i] != '%') {
			cout << fmt[i];
		}
		else {
			++i;
			if (fmt[i] == '\0')	break;
			switch (fmt[i]) {
				case '%': {
					cout << '%';
					break;
				}
				case 'd': {
					cout << va_arg(paras, int);
					break;
				}
				case 'x': {
					cout << hex << va_arg(paras, int);
					break;
				}
				case 'c': {
					/* 字符 */
					/* 对吗? */
					cout << va_arg(paras, char);
					break;
				}
				case 's': {
					/* 字符串const char* */
					cout << va_arg(paras, const char*);
					break;
				}
				case 'f': {
					/* 单精度浮点型 */
                    /* 对吗? */
					cout << va_arg(paras, float);
					break;
				}
				default: {
					/* 如果是未处理的格式符号,输出原字符 */
					cout << '%' << fmt[i];
					break;
				}
			}
		}
	}
	
	/* 清空指针 */
	va_end(paras);
	return 0;
}

Output

上述代码没有考虑到C/C++的默认参数提升。可以看到单精度浮点输出时错误的。在函数参数列表中有...时,就会发生默认参数提升。

  • float类型的实际参数将提升到double
  • charshort和对应的signedunsigned类型的实际参数提升为int
  • 如果int不能存储原值,则提升到unsigned int

所以上述代码37行和48行应改为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
case 'c': {
    /* 字符 */
    cout << static_cast<char>(va_arg(paras, int));
    break;
}
case 'f': {
    /* 单精度浮点型 */
    cout << static_cast<float>(va_arg(paras, double));
    break;
}

output

updatedupdated2025-07-252025-07-25