C语言可变参数详解

01. 什么是可变参数函数

在C语言编程中有时会遇到一些参数可变的函数,例如printf()、scanf(),其函数原型为:

int printf(const char* format,…)

int scanf(const char *format,…)

就拿 printf 来说吧,它除了有一个参数 format 固定以外,后面的参数其个数和类型都是可变的,用三个点“…”作为参数占位符。

 

参数列表的构成

任何一个可变参数的函数都可以分为两部分:固定参数和可选参数。至少要有一个固定参数,其声明与普通函数参数声明相同;可选参数由于数目不定(0个或以上),声明时用"…"表示。固定参数和可选参数共同构成可变参数函数的参数列表。

 

02. 实现原理

C语言中使用 va_list 系列变参宏实现变参函数,此处va意为variable-argument(可变参数)。

X86平台 VS2017中stdarg.h头文件内变参宏定义如下:

#pragma once
#define _INC_STDARG

#include <vcruntime.h>

_CRT_BEGIN_C_HEADER



#define va_start __crt_va_start
#define va_arg   __crt_va_arg
#define va_end   __crt_va_end
#define va_copy(destination, source) ((destination) = (source))



_CRT_END_C_HEADER

 

03. _CRT_BEGIN_C_HEADER  宏展开结果如下:

    #define _CRT_BEGIN_C_HEADER            \

        __pragma(pack(push, _CRT_PACKING))

    #define _CRT_END_C_HEADER \

        __pragma(pack(pop))

_CRT_BEGIN_C_HEADER   的作用是设置当前内存对其为8.

 

04. 其他宏展开结果如下:

typedef char * va_list;

#define _INTSIZEOF(n)       ( (sizeof(n)+sizeof(int)-1) & ~(sizeof(int)-1) )

 

// 初始化 ap 指针,使其指向第一个可变参数。v 是变参列表的前一个参数

#define va_start(ap,v)      ( ap = (va_list)&v + _INTSIZEOF(v) )

 

// 该宏返回当前变参值,并使 ap 指向列表中的下个变参

#define va_arg(ap, type)    ( *(type *)((ap += _INTSIZEOF(type)) - _INTSIZEOF(type)) )

 

// /将指针 ap 置为无效,结束变参的获取

#define va_end(ap)             ( ap = (va_list)0 )

 

逐条宏的作用进行分析:

_INTSIZEOF(n)

#define _INTSIZEOF(n)       ( (sizeof(n)+sizeof(int)-1) & ~(sizeof(int)-1) )

~是位取反的意思。

_INTSIZEOF(n)整个做的事情就是将n的长度化为int长度的整数倍。

比如n为5,二进制就是101b,int长度为4,二进制为100b,那么n化为int长度的整数倍就应该为8。

~(sizeof(int) - 1) )就应该为~(4-1)=~(00000011b)=11111100b,这样任何数& ~(sizeof(int) - 1) )后最后两位肯定为0,就肯定是4的整数倍了。

(sizeof(n) + sizeof(int) - 1)就是将大于4m但小于等于4(m+1)的数提高到大于等于4(m+1)但小于4(m+2),这样再& ~(sizeof(int) - 1) )后就正好将原先的类型长度补齐到4的倍数了。

_INTSIZEOF(n)的最终效果就是将原来的类型长度补齐到4的倍数(该长度如果 大于4m但小于等于4(m+1)的数提高到大于等于4(m+1)但小于4(m+2))

参考网址:

https://zhidao.baidu.com/question/80276013.html

va_start(ap,v)

//初始化 ap 指针,使其指向第一个可变参数。v 是变参列表的前一个参数

#define va_start(ap,v)      ( ap = (va_list)&v + _INTSIZEOF(v) )

va_start宏首先根据(va_list)&v得到参数 v 在栈中的内存地址,加上_INTSIZEOF(v)即v所占内存大小后,使 ap 指向 v 的下一个参数。在使用的时候,一般用这个宏初始化 ap 指针,v 是变参列表的前一个参数,即最后一个固定参数,初始化的结果是 ap 指向第一个变参,此时ap的类型是char* 指针。

va_arg(ap, type)

// 该宏返回当前变参值,并使 ap 指向列表中的下个变参

#define va_arg(ap, type)    ( *(type *)((ap += _INTSIZEOF(type)) - _INTSIZEOF(type)) )

首先char* ap 进行ap += _INTSIZEOF(type),即 ap 跳过当前可变参数而指向下个变参的地址;然后ap-_INTSIZEOF(type)得到当前变参的内存地址,类型转换后解引用,最后返回当前变参值。

va_end(ap)

// /将指针 ap 置为无效,结束变参的获取

#define va_end(ap)             ( ap = (va_list)0 )

va_end 宏使 ap 不再指向有效的内存地址。该宏的某些实现定义为((void*)0),编译时不会为其产生代码,调用与否并无区别。但某些实现中 va_end 宏用于在函数返回前完成一些必要的清理工作:如 va_start 宏可能以某种方式修改栈,导致返回操作无法完成,va_end 宏可将有关修改复原;又如 va_start 宏可能为参数列表动态分配内存以便于遍历,va_end 宏可释放此内存。因此,从使用 va_start 宏的函数中退出之前,必须调用一次 va_end 宏。

 

05.代码示例

变参宏无法智能识别可变参数的数目和类型,因此实现变参函数时需自行判断可变参数的数目和类型。所以我们就要想一些办法,比如

 

显式提供变参数目或设定遍历结束条件

显式提供变参类型枚举值,或在固定参数中包含足够的类型信息(如printf函数通过分析format字符串即可确定各变参类型)

主调函数和被调函数可约定变参的数目和类型

例1:函数通过固定参数指定可变参数个数,打印所有变参值。

 

#define _CRT_SECURE_NO_WARNINGS

#include<stdio.h>

#include<string.h>

#include<stdlib.h>

#include<math.h>

#include <stdarg.h>

void parse_valist_by_num(int arg_cnt, ...);

int main(void){

    parse_valist_by_num(4, 1, 2, 3, 4);

    parse_valist_by_num(4, 1, 2, 3);

    parse_valist_by_num(4, 1, 2, 3, 4, 5); //多余的变参被忽略

    system("pause");

}

//第一个参数定义可变参数的个数

//...所表示的多个形参 当函数调用的时候将会在栈区从高地址到低地址,依次开辟连续空间进行存放形参

void parse_valist_by_num(int arg_cnt, ...){

    va_list p_args;

    va_start(p_args, arg_cnt);

    int idx;int val;

    for (idx = 1; idx <= arg_cnt; ++idx) {

        val = va_arg(p_args, int);

        printf("第 %d 个参数: %d\n", idx, val);

    }

    printf("---------------\n");

    va_end(p_args);

}

结果:注意第2个结果,第4个参数是一个随机数,这是因为打印出了栈中参数3上方的未知参数值。

 

 

例2:函数定义一个结束标记(-1),调用时通过最后一个参数传递该标记,打印标记前所有变参值。

 

#include <stdarg.h>

#include <stdio.h>

void parse_valist_by_flag(int num_1, ...);

int main(void){

    parse_valist_by_flag(1, -1);

    parse_valist_by_flag(1, 2, 3, 5, -1);

    parse_valist_by_flag(-1);}

//函数定义一个结束标记(-1),调用时通过最后一个参数传递该标记,以结束变参的遍历打印。

//最后一个参数作为变参结束符(-1),用于循环获取变参内容

void parse_valist_by_flag(int num_1, ...){

    va_list p_args;

    va_start(p_args, num_1);

    int idx = 0;

    int val = num_1;

    while (val != -1) {

        ++idx;

        printf("第 %d 个参数: %d\n", idx, val);

        val = va_arg(p_args, int); //得到下个变参值

    }

    va_end(p_args);

    printf("---------------\n");

}

运行结果是:

需要注意

va_arg(ap, type)宏中的 type 不可指定为以下类型:

  • char
  • short
  • float

在C语言中,调用不带原型声明或声明为变参的函数时,主调函数会在传递未显式声明的参数前对其执行缺省参数提升(default argument promotions),将提升后的参数值传递给被调函数。

提升操作如下:

  • float 类型的参数提升为 double 类型
  • char、short 和相应的 signed、unsigned 类型参数提升为 int 类型
  • 若 int 类型不能容纳原值,则提升为 unsigned int 类型

 

最后来一张图,帮助大家理解前文讲的宏。

参考资料:

https://www.cnblogs.com/clover-toeic/p/3736748.html

https://blog.csdn.net/longintchar/article/details/85490103

 

 

知识点须知:

  1. 内存对齐

 

内存对齐原因

我们知道内存的最小单元是一个字节,当cpu从内存中读取数据的时候,是一个一个字节读取,所以内存对我们应该是入下图这样:

但是实际上cpu将内存当成多个块,每次从内存中读取一个块,这个块的大小可能是2、4、8、16等,

那么下面,我们来分析下非内存对齐和内存对齐的优缺点在哪?

内存对齐是操作系统为了提高访问内存的策略。操作系统在访问内存的时候,每次读取一定长度(这个长度是操作系统默认的对齐数,或者默认对齐数的整数倍)。如果没有对齐,为了访问一个变量可能产生二次访问。

 

至此大家应该能够简单明白,为什么要简单内存对齐?

  1. 提高存取数据的速度。比如有的平台每次都是从偶地址处读取数据,对于一个int型的变量,若从偶地址单元处存放,则只需一个读取周期即可读取该变量;但是若从奇地址单元处存放,则需要2个读取周期读取该变量。
  2. 某些平台只能在特定的地址处访问特定类型的数据,否则抛出硬件异常给操作系统。

如何内存对齐

  1. 对于标准数据类型,它的地址只要是它的长度的整数倍。
  2. 对于非标准数据类型,比如结构体,要遵循一下对齐原则:

 

1. 数组成员对齐规则。第一个数组成员应该放在offset为0的地方(结构体中最大数据类型长度的正数倍),以后每个数组成员应该放在offset为min(当前成员的大小,#pargama pack(n))整数倍的地方开始(比如int在32位机器为4字节,#pargama pack(2),那么从2的倍数地方开始存储)。

2. 结构体总的大小,也就是sizeof的结果,必须是min(结构体内部最大成员,#pargama pack(n))的整数倍,不足要补齐。

3. 结构体做为成员的对齐规则。如果一个结构体B里嵌套另一个结构体A,还是以最大成员类型的大小对齐,但是结构体A的起点为A内部最大成员的整数倍的地方。(struct B里存有struct A,A里有char,int,double等成员,那A应该从8的整数倍开始存储。),结构体A中的成员的对齐规则仍满足原则1、原则2。

 

手动设置对齐模数:

    1. #pragma pack(show)

显示当前packing alignment的字节数,以warning message的形式被显示。

    1. #pragma pack(push)

将当前指定的packing alignment数组进行压栈操作,这里的栈是the internal compiler stack,同事设置当前的packing alignment为n;如果n没有指定,则将当前的packing alignment数组压栈。

    1. #pragma pack(pop)

从internal compiler stack中删除最顶端的reaord; 如果没有指定n,则当前栈顶record即为新的packing alignement数值;如果指定了n,则n成为新的packing alignment值

    1. #pragma pack(n)

指定packing的数值,以字节为单位,缺省数值是8,合法的数值分别是1,2,4,8,16。

内存对齐案例

#pragma pack(4)

 

typedef struct _STUDENT{

    int a;

    char b;

    double c;

    float d;

}Student;

 

typedef struct _STUDENT2{

    char a;

    Student b;

    double c;

}Student2;

 

void test01(){

 

    //Student

    //a从偏移量0位置开始存储

    //b从4位置开始存储

    //c从8位置开始存储

    //d从12位置开存储

    //所以Student内部对齐之后的大小为20 ,整体对齐,整体为最大类型的整数倍 也就是8的整数倍 为24

 

    printf("sizeof Student:%d\n",sizeof(Student));

 

    //Student2

    //a从偏移量为0位置开始 8

    //b从偏移量为Student内部最大成员整数倍开始,也就是8开始 24

    //c从8的整数倍地方开始,也就是32开始

    //所以结构体Sutdnet2内部对齐之后的大小为:40 , 由于结构体中最大成员为8,必须为8的整数倍 所以大小为40

    printf("sizeof Student2:%d\n", sizeof(Student2));

}

 

 

#pragma pack(push,1)与#pragma pack(1)的区别

这是给编译器用的参数设置,有关结构体字节对齐方式设置, #pragma pack是指定数据在内存中的对齐方式。

#pragma pack (n)             作用:C编译器将按照n个字节对齐。

#pragma pack ()               作用:取消自定义字节对齐方式。

 

#pragma  pack (push,1)     作用:是指把原来对齐方式设置压栈,并设新的对齐方式设置为1个字节对齐

 

#pragma pack(pop)            作用:恢复对齐状态

 

因此可见,加入push和pop可以使对齐恢复到原来状态,而不是编译器默认,可以说后者更优,但是很多时候两者差别不大#pragma pack(push,1)#pragma pack(1)的区别

这是给编译器用的参数设置,有关结构体字节对齐方式设置, #pragma pack是指定数据在内存中的对齐方式。

#pragma pack (n)             作用:C编译器将按照n个字节对齐。
#pragma pack ()               作用:取消自定义字节对齐方式。


#pragma  pack (push,1)     作用:是指把原来对齐方式设置压栈,并设新的对齐方式设置为1个字节对齐

#pragma pack(pop)            作用:恢复对齐状态

因此可见,加入push和pop可以使对齐恢复到原来状态,而不是编译器默认,可以说后者更优,但是很多时候两者差别不大

 

内存对其参考文章:

https://blog.csdn.net/aidem_brown/article/details/77540527

 

 

 

2、内存分区图和栈区空间分配

栈区的变量创建中,变量地址一般情况是从高地址向低地址创建的。

1

一般变量,空间不连续 中间有空位。比如int之间是12个字节的距离。创建方向为顺序创建(地址由高到低)。

2

栈区数组,地址分配,为栈区指针寄存器做一次减法后,直接整个空间分配的!

数组空间分配好了之后,空间连续,a[0]放在低地址,元素下标越大,存放的地址越大

(也就是数组空间的分配方向和栈分配地址方向无关,栈是直接一下子分配出来整个数组,销毁也是整个数组销毁)

 

看看反汇编就知道了

1)分配内存空间的动作,(X86,Win32 MSVC,就是对SP,esp 这样的堆栈指针寄存器,做一下减法)。

2)为变量确定地址,每个变量,根据需要安排一个位置 。(X86,Win32 MSVC,一般是相对ebp 的位置偏移量,如果做了栈帧优化,则可能会是相对esp 的置偏移量)

编译器就做了这些

至于数组,只需要最低地址,和数组空间大小,两个数据就可以了

空间大小,就是第一步要减去的数值,地址,其实只是相对偏移量由于每个函数的某一次调用中,ebp,esp 尤其是 ebp 内容是确定的,所以 ebp 的内容加上相对偏移量,就是地址了。

数组无非是连续的char而已, 一个int其实也是4个连续的char, 如果必须按栈的方向来, 就没有大小端的问题存在了.

3

函数形参,栈区开辟空间,空间连续,由高地址向低地址创建,如果形参是test(int a,int b),则先创建b再创建a。b的地址高于a的地址。创建方向为逆向创建(地址由高到低)。(函数结束,先销毁a,再销毁b)(由于函数形参在栈区开辟,而函数和数组一般占空间都比较大,所以当向函数传递数组或者函数的时候,会自动转换为指针【所谓的退化为指针】)

   参考文章:https://www.cnblogs.com/zhaopengcheng/p/6522523.html

 

 

 

 

  • 30
    点赞
  • 124
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值