0.前言
您好,这里是limou3434的一篇博文,感兴趣可以看看我的其他内容。本次我给您带来了C语言的“可变参数列表”,要明白这些内容,您可能需要重新复习下C语言视角的栈帧空间知识。最后我还给出两个小的C语言知识点:“命令行参数”和“递归调用”,这旨在补全您的C语言知识面。
1.函数栈帧(x86环境)
1.1.认识通用寄存器
- ebp:栈底寄存器
- esp:栈顶寄存器
- eip:指令寄存器,保存当前指令的下一条指令的地址
- eax:通用寄存器,保留临时数据,常用于返回值
- ebx:通用寄存器,保留临时数据
- ecx:通用寄存器,保留临时数据
1.2.认常见的汇编指令
- mov:数据转移指令
- push:数据入栈,同时esp栈顶寄存器也会发生改变/浮动
- pop:数据弹出至指定位置,同时esp栈顶寄存器也会发生改变/浮动
- sub:减法命令
- add:加法命令
- call:函数调用,作用有两个“1.压入返回地址2.转入目标函数”
- jump:通过修改eip,转入目标函数,进行调用
- ret:恢复返回地址,压入eip,类似“pop eip”命令
1.3.函数调用以及相对应的汇编代码
可以去看看我之前的博客
1.4.栈空间图解补充
关于我之前讲解的栈帧空间图解,还有一些细节的方面在这里需要强调,以便后续理解可变参数列表。
- 首先“main()”函数也是被其他函数调用的,这个函数就是“__mainCRTStartup()”,而调用这个函数的函数就是“mainCRTStartup()”,再深入探究就是操作系统的知识了
- 临时变量在函数被正式被调用之前就形成了
- 形参的实例化顺序是右向左的(即:对传过来的参数,从右向左依次进行push)
- 使用call指令调用完函数后,就需要返回call下一个命令的地址(所以可以看到在拷贝完变量后,栈空间又压入了call下一条指令的地址,然后进行跳转。这些都是call指令干的,即:call有两个作用)
- 减值来开辟空间是由编译器决定的,而减去的大小是编译器依靠类型来减去的(编译器有能力知道所有数据类型对应的定义变量的大小)
- 函数调用时,因拷贝形成的临时变量,在变量与变量之间有一定规律可循。例如:在push两个变量a、b时,可以发现a、b的地址空间是连续的(但这只限于较老的设备),因此是可以做到通过a的地址来修改b,如下代码(注意并不是所有情况下,下面的代码都有效。要看具体的编译器,在某些老的编译器里容易通过预测栈空间的存储顺序来反向推测代码的逻辑,进而达到篡改代码的效果,这是极其不安全的,易被他人入侵,因此很多编译器为了安全,都对此做了优化,其push后各临时变量的地址并不一定会连续。例如有种栈保护技术叫做“金丝雀技术(Canary机制)”,就是为了为此而诞生的,您可以去了解一下)
int Add(int a, int b)
{printf("Before:%d\n", b);*(&a + 1) = 100;printf("After:%d\n", b);int z;z = a + b;return z;
}
- 函数在被调用完后就会被销毁
- 在函数内定义的临时变量,其空间都是在该函数的栈帧内开辟的。另外,临时变量具有临时性的本质是因为函数栈帧会被销毁(由于临时变量空间在栈帧内开辟,栈帧销毁那么临时变量也就跟着销毁了)
- 调用函数是有成本的,成本体现在时间和空间上,本质是形成和释放栈帧有成本
- 从栈帧空间可以看出,即便函数原型是不需要传参数的,而在调用函数的时候依旧进行了传参,也没有任何影响(只是进行了多次的push而不使用罢了。这点很重要,有助于理解后面的可变参数列表)
2.可变参数列表
2.1.可变参数列表的使用
在我们了解可变参数列表的原理之前,可以先来看看可变参数列表具体的使用是怎么样的。有了这一铺垫,哪怕您看不懂原理,使用起可变参数列表也是没有问题的。
#include <stdio.h>
#include <windows.h>
int GetMax(int number, ...)//注意:可变参数要被使用,则其前面至少有一个及以上个明确参数
{//使用四个宏来编写代码:va_list、va_start、va_arg、va_end//由于我们自己是不太可能在栈帧空间中一一找到所有临时变量对应的地址并且进行解引用,所以我们为了方便使用,C语言提供了“三个操作符”和“一个类型符”,来完成寻找临时变量的操作va_list arg;//1.定义一个可以访问可变参数部分的变量,其实就是一个char*类型的变量,这意味着该变量可以按照一个字节的方式访问数据 va_start(arg, number);//2.使arg指向可变参数部分int max = va_arg(arg, int);//3.根据类型大小可以获取可变参数列表中的参数数据(这里获取的是第一个int参数数据)for (int i = 0; i < number - 1; i++){int x = va_arg(arg, int);//4.持续获取下一个参数if (max < x){max = x;}}va_end(arg);//5.arg使用完毕,收尾工作,本质就是将arg指向NULL(类似free的使用,避免arg成为野指针)return max;
}
int main()
{int max = GetMax(5, 1, 2, 3, 4, 5);printf("%d", max);return 0;
}
2.2.可变参数列表的原理(x86环境)
本质就是利用:哪怕函数调用的时候使用了多余的实际参数,栈帧空间也会创建临时变量来保存这些实际参数,并且是根据参数列表从右向左创建对应的临时变量(从右往左一一进行push)。
那么具体的细节应该怎么理解呢?
-
首先在创建临时变量的时候,编译器依次push了5, 4, 3, 2, 1, 5这六个变量(注意顺序在函数调用中是GetMax(5, 1, 2, 3, 4, 5),即:从右到左push)
-
然后根据va_list arg;这就定义一个可以访问可变参数部分的变量,其实就是一个char*类型的变量,这意味着该变量可以按照一个字节的方式访问数据
-
va_start(arg, number);使arg指向可变参数部分,即指向临时变量number的地址
-
int max = va_arg(arg, int);根据类型大小可以获取可变参数列表中的参数数据(这里获取的是第一个int参数数据)
-
for (int i = 0; i < number - 1; i++) { int x = va_arg(arg, int)};这个语句则持续获取下一个参数(在x86比较好演示,甚至于我们可以根据栈帧规则手动创建一个不需要“…”可变参数的可变参数函数。然而在现代编译器中就比较难以实现了,比如在x64环境中连续push后的临时变量地址不容易手动找到,要查找只能依靠关键字va_arg(arg, int);而从这一点我们也可以明白为什么可变参数“…”必须放在最后,这是为了避免读取到参数列表中靠前面的临时参数)
如果感兴趣,可以试着将多个char类型的实参传给GetMax函数,求得多个字符中ACSII码值最大的字符,但是上述代码都不变,这个时候会有一个很神奇的现象,代码依旧能正常运行。有人会问“va_arg(arg, int)”语句不是会根据“int”类型来查找临时变量的地址吗?每一次arg的挪动都应该是int个字节才对。
是的没错,arg变量每一次的确是移动了4个字节(int的大小),但是由于char类型的实参在调用GetMax函数之前,会在栈帧中压入这几个cha类型变量的值,但是这里发生了整型提升(char->int),根本原因就是因为内存中存储的数据基本都是4个字节/8个字节起步的,char数据会隐式提升位int类型来压入栈。
这就导致一个比较违反直觉的事情:将代码改成va_arg(arg, char)这种行为是错误的!!!(当然也不只是char类型是特殊的,short和float类型也会发生类似的事情。)
因此根据类型提取数据的时候,我们更多是依靠int和double类型来进行提取的,不规范使用关键字va_arg的话,会造成不可预估的后果。
- va_end(arg);arg使用完毕,就需要进行收尾工作,其本质就是将arg指向NULL(类似动态内存管理中free的使用,避免arg成为野指针)
- 注意可变参数必须从头到尾逐个访问,如果使用宏va_arg访问到中途就停止,这是被允许的,但是一开始就像直接访问中间的参数,这是不行的(但是可以间接,或者在函数内创建变量来临时存储)
- 而在printf中,第一个参数计数“%”格式符号的个数,用来对应后面的可变参数(感兴趣可以自己实现一个printf函数,这个还是比较有难度的)
- 前面我有提到,如果没有规范使用关键字va_arg,在其中指定了错误的类型,其结果是未知的
思考一下,如果超出了可变参数列表的范围会怎么样呢?
2.3.四个宏的详细细节
注意:这几个宏在不同的编译器有可能有不一样的定义
2.3.1.va_list(VS2013的定义(x86环境))
typedef char* va_list;//本质就是一个char类型指针
2.3.2.va_start(VS2013的定义(x86环境))
注释可能有点长,其中需要对除法有更进一步的数学理解能力,还请您耐心看下去……
#define va_start __crt_va_start
#define _crt_va_start(ap, v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )//1.而其中宏“_INTSIZEOF”的定义是“#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )”
//2.即,“_INTSIZEOF”的作用是“4字节对齐(向上取整,整体为4的倍数)”
//3.而“_INTSIZEOF”能“4字节对其”的原理是://3.1.对于“_INTSIZEOF”来说,n可能是char类型、double类型、int类型等,而“_INTSIZEOF”的作用就是把这些不同的类型转化为“4字节对其”的数字,让“(va_list)_ADDRESSOF(v)”能找到可变参数的第一个可变参数的地址,例如“char和short类型通过_INTSIZEOF计算出来的数值都是4,而double则是8”//3.2.从数学角度上来说,“_INTSIZEOF”的意思就是计算一个x,满足(x>=N)&&(x%4==0),其中N就是sizeof(n)的大小,例如:“sizeof(char) = 1,则x=4”。在C语言中N的取值只有1、2、4、8等//3.3.因此实际上,再讲大白话一点“_INTSIZEOF”做的事情就是找到一个x,满足x为4的倍数,即“x=4*m”,并且“x>=N”(m!=0,N!=0)//3.4.如果N能被4整除,则m==N/4①//3.5.如果N不能被4整除,则m==N/4+1②//3.6.其实按照上面的逻辑,就可以写出一个普通的“_INTSIZEOF”了。但是若要简介,则可以合并起来写来求得m,常见的写法就是“m=(N+4-1)/4”,这个式子可以整合两个公式//3.6.1.“+4”是为了凑够数,让没能整除4的N变得能整除,并且得到的m==1//3.6.2.“-1”是受到“+4”的影响,如果有“+4”并且N能被4整除,则m会计算多一个,因此“-1”后就不多了(或者理解为“N=能被4整除的部分+不能被4整除的部分r”,而“+3”就会导致“4<=r+3<7”,则“m==N/4+1”)//3.7.这样就顺利得到m的值,就可以推导出4*m的大小啦,进而理解了宏“_INTSIZEOF”的工作原理,有了上述的公式,完全可以写一个和库里等价的“_INTSIZEOF”//3.8.但是这样的方法还是不够简洁,对于表达式“4*m==4*(N+4-1)/4”,假设“w=N+4-1”,则得到“(w/4)*4”,欸?这不就是“(w/(2^2)*(2^2))”么?那么在比特位上不就相当于先右移两位,再左移两位么?这不就相当于给一个二进制序列的末两位二进制位给清0了?那不就可以直接写“w & ~3”了?直接一步到位,所以简洁版诞生了“w&~3”等价于“(N+4-1) & ~3”,这么一写,可不就是库里定义的“_INTSIZEOF”了嘛……这就是二进制的力量!!!将“/*”算术运算转化为“&~”位运算,效率得到了极大的提高//3.9.最后再提一嘴,这其实是一种取整的方法嘛…
//4.因此“_crt_va_start”的整体实现就是:“ap = (char*)&v + 4*m”,其中m由v来决定,既:4*m通过“_INTSIZEOF”确认。这样就让qp参数存储的是指向可变参数的地址
2.3.3.va_arg(VS2013的定义(x86环境))
#define va_arg __crt_va_arg
#define _crt_va_arg(ap, t) (*(t *)((ap += _INTSIZEOF(t))- _INTSIZEOF(t)) )
//1.“_crt_va_arg”的作用有两个:一个是找到目标值,另一个是找到将ap自增
//2.这个代码写的非常具有特色和技术:自增"4*m"找到下一个可变参数2的起始地址,但是又减去“4*m”找回原来的可变参数1的起始地址。
//3.这个时候就厉害了,ap存储的是可变参数2的起始地址,但是使用宏“_crt_va_arg”时获取的是可变参数1的起始地址。使用宏“_crt_va_arg”就能达到既能取得可变参数1的起始地址,而下一个使用宏“_crt_va_arg”的时候自动指向下一个可变参数2的起始地址。
//4.获得可变参数1的起始地址后,就可以通过可变参数的类型来强制转化,并且进行解引用得到可变参数1内的数据
2.3.4.va_end(VS2013的定义(x86环境))
#define va_end __crt_va_end
#define _crt_va_end(ap) ( ap = (va_list)0 )
//这个就比较简单,就是将ap指针指向空而已
2.3.5.其他定义
在不同编译器中,这四个关键字还有可能存在不一样的定义,但是其基本逻辑是大差不差的,就比如VS2022的C库里定义的四个关键字和上面的就有很大区别,不过您只需了解即可……
3.命令行参数
main函数也是一个函数,也是可以携带参数的
int main(int argc, char* argv[], char* envp[])
{program-statements
}
而可以像下面一样使用,“argc”就是命令行中一串命令的子字符串个数,而每一个命令内包含的子字符串会被分别存储到“argv[]”这个数组中,这样就可以让程序在命令行中表现出不一样的行为
$ vim main.c
//--------
//vim中书写的代码
int main(int argc, char* argv[])
{int i = 0;for(i = 0; i < argc; i++){printf("%s\n", argv[i]); }return 0;
}
//--------
$ gcc main.c
$ ./a.out abcdef ghij
./a.out
abcdef
ghij
而“envp[]”这个数组又是什么呢?存储的是环境变量,不存在就以NULL结尾
int main()
{for(int i = 0; envp[i]; i++){printf("envp[i] = %d\n", i, envp[i]);}return 0;
}
4.递归的简单使用以及理解
4.1.递归的概念
函数在创建的时候,可以调用别的函数,包括自己调用自己,即:C语言支持递归。(比如:main调用自己,但是如果控制不好,容易崩溃)
4.2.递归的深入理解
只要是函数调用就会创建函数栈帧,而递归只是一种特殊的函数调用,而内存的大小是有限的,因此递归一定是有限次递归的,即:大部分情况下,递归必须有递归出口来结束递归。
递归会消耗时间和空间上的消耗
递归最适合在那种“问题和子问题是同一个解决方法”的问题里,例如某些有关二叉树的代码,您可以去了解一下,这是属于数据结构的知识。
#include <stdio.h>
int my_strlen(const char* str)
{if(*str == '\0'){return 0;}return 1 + my_strlen(str + 1);
}
int main()
{int len = my_strlen("abcdef");printf("%d\n", len)return 0;
}
递归还有可能出现重复运算的问题,比如最经典的斐波那契的求解。其树形结构的递归就会出现大量冗余重复的计算
4.3.解决递归消耗过大问题
这里只针对斐波那契数列问题,介绍两种常见的方法供您使用。
4.3.1.方法一
int main()
{int n = 10;int* f = (int*)malloc(sizeof(int) * (n + 2));if (!f) exit(-1);//实际上这就是一个简单的动态规划例子//1.条件初始化f[1] = 1;f[2] = 1;int i = 3;while (i <= n){//2.递推过程f[i] = f[i - 1] + f[i - 2];i++;}printf("%d\n", f[n]);free(f);return 0;
}
4.3.2.方法二
int main()
{int n = 10;int first = 1;int second = 1;int third = 1;while (n >= 3){third = second + first;first = second;second = third;n--;}printf("%d\n", third);return 0;
}
5.总结
本次我和您一起复习了C语言的栈帧空间,并且引出可变参数列表的使用和原理。还和您补充了一些有关“命令行参数”和“递归”的相关应用。到此我的C语言系列基础文章算是告一段落……