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
知识点须知:
- 内存对齐
内存对齐原因
我们知道内存的最小单元是一个字节,当cpu从内存中读取数据的时候,是一个一个字节读取,所以内存对我们应该是入下图这样:
但是实际上cpu将内存当成多个块,每次从内存中读取一个块,这个块的大小可能是2、4、8、16等,
那么下面,我们来分析下非内存对齐和内存对齐的优缺点在哪?
内存对齐是操作系统为了提高访问内存的策略。操作系统在访问内存的时候,每次读取一定长度(这个长度是操作系统默认的对齐数,或者默认对齐数的整数倍)。如果没有对齐,为了访问一个变量可能产生二次访问。
至此大家应该能够简单明白,为什么要简单内存对齐?
|
如何内存对齐
- 对于标准数据类型,它的地址只要是它的长度的整数倍。
- 对于非标准数据类型,比如结构体,要遵循一下对齐原则:
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。 |
手动设置对齐模数:
显示当前packing alignment的字节数,以warning message的形式被显示。
将当前指定的packing alignment数组进行压栈操作,这里的栈是the internal compiler stack,同事设置当前的packing alignment为n;如果n没有指定,则将当前的packing alignment数组压栈。
从internal compiler stack中删除最顶端的reaord; 如果没有指定n,则当前栈顶record即为新的packing alignement数值;如果指定了n,则n成为新的packing alignment值
指定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