可变参数列表(Variadic Parameters)是指函数在定义时可以接受不定数量的参数。
参数传递机制
在 C 语言中,函数参数通过栈(stack)来传递。当函数被调用时,参数的值会被依次压入栈中。对于可变参数函数,编译器使用函数栈内连续的空间来存储参数也就是栈帧。
栈帧
每个函数调用会创建一个新的栈帧(stack frame),用于存储该函数的参数、局部变量以及返回地址。栈帧在函数调用时分配,在函数返回时释放。
内存布局
当函数被调用时,所有参数(包括可变参数)都会依次从右向左依次压入栈中,而栈是向地址减小的方向增长的,所以当压入最后一个可变参数时就是该栈的栈顶。
通过获取第一个参数的地址,就可以通过正确地址偏移来找到其他参数的地址。
高地址
+-------------------+ <- 栈底
| &"hello ptm" | <- 第五个参数 (const char*) 压入的是地址
+-------------------+
| 3.141590 | <- 第四个参数 (double)
+-------------------+
| 3.140000 | <- 第三个参数 (float, 提升为 double)
+-------------------+
| 2 | <- 第二个参数 (int)
+-------------------+
| 4 | <- 第一个参数 (int, 参数个数 n)
+-------------------+ <- 栈顶
低地址
#include <stdio.h>
// 可变参数函数,第一个参数是参数的个数 主要是用来获取参数起始地址,后面是可变参数
void print(int n, ...)
{
// 将第一个参数的地址转换为char* 用来保存第一个参数的地址
char* p = (char*)&n;
// 获取第一个参数的地址的大小用于判断是32位还是64位系统
int z = sizeof(&n);
// 根据地址大小判断是32位还是64位系统
if (z == 4)
{
// 32位系统
// 打印第一个参数的值
printf("%d ", *p);
// 打印第二个参数的值
printf("%d ", *(p + 4));
// 打印第三个参数的值(float提升为double)
printf("%f ", *(double*)(p + 8));
// 打印第四个参数的值(float 提升为 double)
printf("%lf ", *(double*)(p + 16)); // 前面的 float 参数在传递时会提升为 double 类型,因此该参数占用 8 字节。
// 为了访问下一个参数,需要在当前地址基础上增加 8 字节,即 16 字节,而不是只增加指针大小的 4 字节 (12 字节)。
// 打印第五个参数的值(字符串)
printf("%s ", *(char**)(p + 24));//(这里 p + 24 同上) 此处由于传入的是一个字符串常量值,实际是传入该字符的地址。
// 因此,需要将 p + 24 强转为 char** 类型,以表示这是一个二级指针。
// 解引用后可以得到指向该字符串的地址。
}
else if (z == 8)
{
// 64位系统
// 打印第一个参数的值
printf("%d ", *p);
// 打印第二个参数的值
printf("%d ", *(p + 8));
// 打印第三个参数的值(float提升为double)
printf("%f ", *(double*)(p + 16)); //因为64位系统本身就是8字节指针大小,所以都是当前地址基础上增加 8 字节
// 打印第四个参数的值(float提升为double)
printf("%lf ", *(double*)(p + 24));
// 打印第五个参数的值(字符串)
printf("%s ", *(char**)(p + 32)); //char** 同上
}
}
int main()
{
// 调用可变参数函数
print(4, 2, 3.14f, 3.14159, "hello ptm");
return 0;
}
float
类型的参数在传递给函数时会被提升为double
类型
在C语言中,float
类型的参数在传递给函数时会被提升为double
类型。这是因为C语言的函数调用约定规定,所有浮点数参数都必须以double
类型传递。
这种提升是为了确保浮点数参数在函数调用过程中能够被正确地传递和处理。在早期的C语言版本中,浮点数的表示方式和处理方式并不统一,导致了浮点数参数在函数调用过程中可能会被错误地传递或处理。
为了解决这个问题,C语言标准委员会决定将所有浮点数参数提升为double
类型,这样可以确保浮点数参数在函数调用过程中能够被正确地传递和处理。
具体来说,当你将一个float
类型的变量传递给函数时,编译器会自动将其提升为double
类型,这样函数就可以接收到一个double
类型的参数。这种提升是隐式的,你不需要显式地进行类型转换。
C 标准库之stdarg.h
<stdarg.h>
是 C 语言的标准库之一,定义了处理可变参数函数所需的宏和类型。这些宏提供了一种标准化的方式来访问不定数量的参数,确保代码的可移植性和一致性。
va_list用来存储可变参数的地址
typedef char* va_list;
va_start
用于初始化 va_list
类型的变量,使其指向函数参数列表中的第一个可变参数。
void va_start(va_list ap, last);
ap
: 一个va_list
类型的变量,应该在调用va_start
前声明。last
: 函数参数列表中最后一个确定参数的名称。va_start
使用它来确定可变参数的起始位置。
#define va_start __crt_va_start
//将 va_start 重定向到 __crt_va_start
#define __crt_va_start(ap, x) __crt_va_start_a(ap, x)
// __crt_va_start 则被定义为调用 __crt_va_start_a
#define __crt_va_start_a(ap, v) ((void)(ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v)))
// 定义 __crt_va_start_a 宏
#define _ADDRESSOF(v) (&(v))
#define _INTSIZEOF(n) ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
(void)(ap = (char*)(&(v)) + ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
(char*)(&(v)):将参数 v 的地址转换为 char* 类型的指针。
sizeof(n):获取参数 n 的大小(以字节为单位)。
sizeof(int):获取整型数据类型的大小(以字节为单位)。
((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1)):这个表达式是一个位运算操作,它将 sizeof(n) 加上一个整型数据类型大小减一的结果,然后与一个整型数据类型大小减一的取反结果进行按位与操作。这个操作的目的是将大小按照整型数据类型的对齐方式进行调整。
最终,整个表达式将计算出下一个参数在可变参数列表中的位置,并将该位置赋给 ap,同时使用 (void) 来抑制赋值表达式的返回值。用于获取可变参数列表中下一个参数的地址。
理论基础
当我们要将一个值 x
向上调整到下一个 4 的倍数时,可以使用以下逻辑:
-
4 的倍数: 一个数是 4 的倍数,当且仅当它的二进制表示的最低两位是
00
。- 例如:
0
(二进制00
)4
(二进制100
)8
(二进制1000
)
- 例如:
-
加上
3(
sizeof(int) - 1)
的原因:- 如果
x
不是 4 的倍数,添加3
会确保下一个 4 的倍数能够被计算出来。 - 例如,对于不同的
x
值:x = 1
:1 + 3 = 4
(4 的倍数)x = 2
:2 + 3 = 5
(5 & ~3 = 4)00000101 & 11111100 = 00000100 = 4x = 3
:3 + 3 = 6
(6 & ~3 = 4)00000110 & 11111100 = 00000100 = 4x = 4
:4 + 3 = 7
(7 & ~3 = 4)00000111 & 11111100 = 00000100 = 4x = 5
:5 + 3 = 8
(8 & ~3 = 4)00001000 & 11111100 = 00001000 = 8x = 6
:6 + 3 = 9
(9 & ~3 = 4)00001001 & 11111100 = 00001000 = 8x = 7
:7 + 3 = 10
(10 & ~3 = 8)00001010 & 11111100 = 00001000 = 8
- 如果
位运算解释
使用 & ~3
的目的是通过掩码来清除最低两位,使得结果是一个 4 的倍数。
- 掩码
~3
的二进制表示为...11111100
,当与任何数进行按位与运算时,会将该数的最低两位清零。这可以确保结果是 4 的倍数。
总结
- 使用
+3
是为了确保向上调整到下一个 4 的倍数。 - 使用
& ~3
是为了清除最低两位,保证结果是 4 的倍数。
- 计算当前大小:
sizeof(n)
计算数据的大小。 - 增加对齐值:
sizeof(int) - 1
是为了确保即使当前大小不是 4 的倍数,也能向上调整。 - 位运算:
~(sizeof(int) - 1)
创建一个掩码,用于清除低位。- 将上述两者相与,得到符合 4 字节对齐的值。
假设 n
是一个 char
类型的参数,其 sizeof(n)
为 1,那么:
sizeof(n) + sizeof(int) - 1
=1 + 4 - 1
=4
~(sizeof(int) - 1)
=~(4 - 1)
=~3
=...11111100
(在 32 位系统上)4 & ~3
= . . . 00000100& ...11111100
=4
最终结果是 4,这意味着下一个参数的地址将根据 4 字节对齐来计算。
va_arg()
用于从可变参数列表中逐个获取参数的值。它接受两个参数,一个是 va_list
类型的参数列表,另一个是要获取的参数的类型,然后返回该参数并将 va_list
指针指向下一个参数。
#define va_arg __crt_va_arg
#define __crt_va_arg(ap, t) (*(t*)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
ap += _INTSIZEOF(t)
: 将指针ap
向前移动t
类型的大小。(ap += _INTSIZEOF(t)) - _INTSIZEOF(t)
: 将指针ap
移动回到前一个参数的位置。*(t*)
: 将指针转换为t*
类型,然后取该位置的值。
综合起来,这段代码的作用是从可变参数列表中获取下一个参数,并将其转换为类型 t
,然后返回其值。
va_copy()
用于将一个 va_list
对象的状态复制到另一个 va_list
对象中。这个宏的目的是为了在处理可变参数函数时能够保存和重新使用参数列表的状态。
#define va_copy(destination, source) ((destination) = (source))
它的作用是简单地将 source
的值赋给 destination
。
va_end()
用于结束对可变参数列表的访问。当使用可变参数函数处理完参数后,应该使用 va_end
来清理资源并结束对参数列表的访问。
#define va_end __crt_va_end
#define __crt_va_end(ap) ((void)(ap = (va_list)0))
(va_list)0
: 将整数0
转换为va_list
类型,这通常是将一个空指针值赋给va_list
类型的指针。ap = (va_list)0
: 将上述转换后的空指针值赋给ap
,从而将ap
设置为一个空指针。
#include <stdio.h>
#include <stdarg.h>
// 定义一个可变参数函数 print
void print(int n, ...)
{
va_list arg; // 声明一个 va_list 类型的变量,用于存储可变参数
va_start(arg, n); // 初始化 va_list,指定最后一个固定参数为 n
// 打印第一个参数,期待为 int 类型
printf("%d ", va_arg(arg, int)); // 获取下一个参数并打印,类型为 int
// 打印第二个参数,期待为 double 类型
printf("%lf ", va_arg(arg, double)); // 获取下一个参数并打印,类型为 double
// 注意:float 会提升为 double 类型了
// printf("%f ", va_arg(arg, float)); 是不正确的
// 打印第三个参数,期待为 char* 类型
printf("%s ", va_arg(arg, char*)); // 获取下一个参数并打印,类型为 char*
// 字符串常量是一个指向字符数组的指针
va_end(arg); // 清理 va_list,结束可变参数的处理
}
int main()
{
// 调用 print 函数,传入 3 个参数:一个 int,一个 float 和一个字符串
print(3, 2, 3.14f, "hello"); // 输出: 2 3.140000 hello
return 0;
}