目录
- 1. 深刻理解函数调用过程
- 1.1 基本概念
- 1.2 函数栈帧的创建于销毁
- 1.2.1 栈帧创建
- 1.2.2 栈帧销毁
- 1.2.3 有趣的现象
- 2. 了解可变参数列表的使用与原理
- 2.1 可变参数列表与函数栈帧的关系
- 2.2 宏的工作过程
- 2.3 宏的具体实现原理
1. 深刻理解函数调用过程
1.1 基本概念
关于函数的栈帧,目前只知道当函数被调用时,会在栈区上开辟一块足够大的空间来供该函数使用,这块空间就叫做函数栈帧。其中局部变量的定义等等都是在这块空间中进行二次开辟空间来存放数据,当整个函数调用结束后,栈帧结构被释放,自此便完成了一次函数栈帧的创建与销毁。
以上是对其简单理解,后续会以汇编的角度,深入理解函数栈帧创建与销毁的整个过程。
代码测试环境均为 Visual Studio 2019
示例代码:
int myAdd(int a, int b)
{
int c = 0;
c = a + b;
return c;
}
int main()
{
int x = 0x1;
int y = 0x2;
int z = 0;
z = myAdd(x, y);
printf("%d\n", z);
return 0;
}
现在需要了解的是main函数也是函数(当然也有其调用方,至于是谁调用了main函数并不在本文的探讨范围),也会开辟栈帧空间。
因此后续会直接从main函数的栈帧入手,在汇编层面来探究在调用myAdd函数的前后都发生了什么。
开始前需要先了解一些相关的寄存器和汇编指令:
eax:通用寄存器,保存临时数据,常用于返回值
ebx:通用寄存器,保存临时数据
eip:指令寄存器,保存将要执行指令的地址
ebp:栈底寄存器(指针)
esp:栈顶寄存器(指针)
正如它俩的名字,一个栈顶一个栈底,其实本质就是它俩所指向的位置之间的那块空间就是该函数的栈帧
mov:数据转移命令,将数据写入对应空间
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1. 压入待返回的地址 2. 转入目标函数
jump:修改eip,转入目标函数进行调用
ret:恢复返回地址,压入eip,类似于pop eip指令
C程序地址空间简单示意图:
在C语言中,每个C程序加载进内存时,操作系统都会给它分配虚拟地址空间,并分为几个区域,存储不同的数据
由于栈帧结构只与其中的栈区有关,因此后续只会把栈区放大单独划分出来进行分析。
根据对栈帧的简单理解,上述示例的代码在栈区空间上的栈帧结构大体如下图所示:
栈区的使用习惯是先使用高地址处的空间,然后在使用低地址处的空间
而本文的主要任务是明白myAdd函数所在的那块空间是如何产生与释放的。
由于不同规模函数在形成栈帧的方式会有些许差别,但是栈帧结构的创建与释放过程大体是一致的,所以搞明白了一个函数的栈帧,对于其余函数栈帧结构的理解也就差不多明白了。
1.2 函数栈帧的创建于销毁
1.2.1 栈帧创建
以上都是一些准备工作,接下来开始探究函数的栈帧。
由于栈帧形成的过程,基本上都是由CPU中的多个寄存器来进行协调控制,所以后续都会在图上带上寄存器,并分析它们的作用。
由于计算机只认识二进制,所以本质上内存和寄存器中保存的全都是二进制数据信息
程序开始执行时,main函数栈帧创建,此时寄存器ebp中保存着main栈帧空间的栈底地址,也就是指向栈帧的栈底,而esp则指向main函数的栈顶位置。
在执行了红色方框中的汇编语句后,栈帧结构如下图所示:
暂时不需要知道方框中的含义,在创建myAdd函数时,会对其进行分析
然后紧接着分析将要执行的这条汇编指令:
这条指令的含义是将1,转移到变量x所在的那块空间进行保存。
这里的x是用户定义的变量名,将其取消显示如下:
此时可以发现,x所在的位置本质上是栈底指针ebp所在的位置减8个字节,即找到ebp-8个字节所在的地址,将数据1放到这个地址所表示的空间中进行保存。
寄存器eip,它是用来保存将要执行指令的地址,也是保存着上面这条将要执行指令的地址。
执行后的变化如下:
为变量x向下(向高地址处)依次开辟四个字节的大小的空间存放数据。
内存变化:
可以发现内存中的地址从下往上就是由高到低变化的
eip变化:
继续,这是将要执行的一条汇编指令,可以发现此时eip中已经保存了它的地址。
该指令的作用是,把数据2转移到栈底指针ebp所在的位置减去14h(十六进制)个字节后指向的地址所代表的那块空间中进行保存。
执行后示意图如下:
内存变化:
这里可以看出,虽然两个变量是连续定义的,但是在内存中的位置却不是连续。这种做法叫做栈随机化,防止通过一个变量的地址去猜测另一个变量的地址,所以之间会预留出一部分空间,本质是出于安全性的考虑。
eip变化:
依旧指向将要执行指令的地址,接着分析下条指令:
与上面两条指令的作用相同,即把数据0转移到栈底指针ebp-20h个字节后指向的地址所表示的那块空间中进行保存。
示意图如下:
内存变化:
同样的现象,两个变量之间依然保持着一定的"安全距离"。
eip变化:
依旧指向下一条将要执行的指令,这里其实可以说明eip不断会递增依次保存相应的指令地址,CPU根据其中保存的地址,依次去执行对应的指令。
有点像一个大哥(EIP)带领着一个帮它办事的小弟(CPU),后续不在一次次的观察该寄存器的变化
上面完成的工作,是在main函数的栈帧结构中,定义三个局部变量并且进行初始化。
接下来要执行一些汇编指令,就是为了后面的函数调用做准备了。
先看将要执行的指令:
此时这里新出现了一个寄存器eax,最开始提到过该寄存器是用来保存临时数据。
而这条汇编的作用是把栈底指针ebp-14h个字节后指向的地址所表示的那块空间中的数据转移到寄存器eax中保存。
那么ebp-14h那块空间中保存的是什么?其实是y的值也就是0x2,将该值放入到eax中保存。
执行后变化如下:
继续看下一条指令:
push的作用:数据入栈,同时esp栈顶寄存器也要发生改变。
该条指令的作用是将eax中的数据入栈,又因为栈是向上增长,所以入栈会从栈顶压入,压入后会修改栈顶指针esp,即esp向上(地址减小)移动,指向新的栈顶。
先保存下执行前esp的指向,以便后续观察:
执行后esp的指向变化如下:
正如上面所说的结果一致,eax中的数据入栈后,栈顶指针esp指向新的栈顶地址。
栈区的变化如下:
继续看下面将要执行的指令:
不难看出,这两条指令的作用,与上面两条是相同的,也就是把栈底指针ebp-8个字节后指向的地址所表示的那块空间中的数据保存到寄存器ecx中。
ebp-8的地址空间中保存的是变量x的值,也就是0x1放入ecx中,然后把ecx中保存的数据入栈,并且修改栈顶指针esp,向上移动让其指向新的栈顶地址。
修改后的esp:
原本的地址为44,现在又向上移动了四个字节。
而栈区的变化如下:
下条指令就开始调用函数了,此时先暂停,回顾下在调用函数前干了什么?
先把实参中保存的数据拷贝到寄存器,然后把拷贝的数据依次入栈?这是不是形参实例化啊?就是形成临时变量!
这时可以得出两个结论:
- 临时变量(形参)是在正式调用函数前就已经形成了,而且是以压入栈中的形式。
- 观察形参的入栈顺序,先入y的值再入x的值,也就是先形成b在形成a,所以形参实例化的顺序是从右向左的!!!!
短暂暂停后,接下来继续分析下面的指令:
这里遇到了一个新的汇编命令call,它是用来进行函数调用的,作用有两个:
- 压入函数调用返回后的下一条指令的地址
- 转入目标函数的地址处
因为函数也是有地址的,所以函数调用本质是修改寄存器eip,让其保存跳转到目标函数的地址,当进入目标函数后继续依次保存目标函数中将要执行指令的地址,然后让CPU逐条执行对应地址的指令,其实和在本函数中的作用一致。
但是这里有一个问题,跳出去执行别的函数中的指令时,当目标函数执行完后该怎么跳回到什么地方来继续执行本函数后续的指令呢?不能只考虑出去,也要考虑怎么回来。
所以答案是:跳回来的时候要回到call命令的下一条执行的地址处!
因此这就须要保存下一条指令的地址数据。
所以call命令作用1的本质就是:函数调用完毕是需要返回继续执行后续指令的。
而保存该地址的数据也是通过入栈的方式来进行的。
在执行前先保存对应的地址与数据信息,然后与执行后的变化进行对比,观察是否与上面的结论是否一致:
执行后变化如下:
可以发现执行完后对应寄存器的变化与上面得出的结论相同,注意红色框中压入的返回地址的数据顺序与执行前的地址数据顺序不一样是因为大小端存储问题,其实是一样的。
这时,call命令的下一条指令的地址也就是待返回的地址就被压入栈中保存了,同时修改了栈顶指针esp,让其指向新的栈顶。
栈区变化:
本质存放的都是二进制的数据信息,但是方便表述直接采用数字或者字符
继续执行下面这条指令:
其中又出现了一个新的汇编命令jmp,它的作用是:修改eip,转入目标函数进行调用。
所以需要观察执行前后寄存器eip的变化:
执行后:
执行后eip就跳转进了目标函数,保存了该函数将要执行的首条指令的地址。
到目前为止,才算是正式地进入了myAdd函数。
简短概括进入函数前做了什么:形参列表初始化,将待返回地址入栈,eip跳转到目标函数准备运行。
下面是myAdd函数在汇编层面需要执行的指令:
可以发现的是在汇编下的一条指令与源文件当中的第一条代码之间多了很多条指令,那么它们是干什么的?下面会简单叙述。
最开始的三条汇编是要重点研究的,这三个指令的作用是栈帧的核心,搞清楚这个栈帧也就学会一半了。
后续这部分的汇编指令的主要作用是对一些临时变量进行初始化以及对某一块区域进行清空的动作,这个初始化动作与编译器有很大关系,这个不用关心。
接下来就只需要把重点放在前三条汇编指令上,先看第一条:
push的作用是将ebp中保存的数据入栈,然后修改栈顶指针esp。
这里的ebp是栈底指针,它保存(指向)的是main函数栈帧的栈底地址,也就是说把它保存的地址数据压入栈中,同时让栈底指针esp向上移动指向它。
执行后寄存器变化:
与上面的分析的结果是一致的,示意图如下:
接着下一条指令:
该指令的含义是:把寄存器esp中保存的地址放到到寄存器ebp中进行保存。
ebp是栈底指针,指向main函数栈帧的栈底,而esp是指向main函数栈帧的栈顶,本质都是保存的地址数据,如图:
这里把esp中保存的地址拷贝放入ebp中会有两个问题:
- 拷贝会把ebp中的数据覆盖,那原来ebp中保存的地址怎么办?
- 拷贝的过程中是之间在CPU中进行而没有访问内存吗?
回答这两个问题:
- 由于上一条指令提前把ebp中保存的栈底地址入栈保存了,所以覆盖后后续也可以找到它。
- 是的,寄存器与CPU之间可以直接进行数据传输不需要经过内存。
注意观察寄存器执行前后的变化:
执行后:
可以发现此时栈底和栈顶指针都指向了同一个位置。
示意图如下:
继续分析下条指令:
sub是减法命令,作用是将栈顶指针esp所指向的地址减去0xCCh个字节后的地址进行保存。
减去的数字具体是由编译器来决定的。这就存在一个问题,编译器凭什么来决定呢?
其实很好回答,由于C语言定义的变量都必须要结合数据类型来决定自身所占空间的大小。而且我们都知道一个关键字sizeof,它是用来求一个类型或者变量所占的空间大小,它是在编译阶段完成的,也就是说其实编译器是有能力知道对应变量或者类型的大小的。
在函数内定义的各种变量本质都是在栈区上开辟空间,而这样变量的大小都是可以用sizeof得到,因此编译器只需要计算在本函数内定义的所有变量所占的空间大小,然后设置一个合适值让esp减去该值即可。
注意此时esp中保存的地址:
执行后:
可以发现esp变小了,也就是此时它指向上面(地址减小)的某个位置。又因为ebp保存的esp是移动前的地址,那么当esp移动后,它俩之间就形成了一块新的空间,而这个空间就称为myAdd函数的栈帧,如图:
新的栈帧结构,它的栈顶和栈底依然是由寄存器esp和ebp(保存)指向。
在清楚了这个之后,栈帧的形成过程可以推而广之到其它函数中,当然main函数的栈帧结构形成也是如此。
由于下面几条指令不是探究重点,因此直接跳过。
然后继续看下条指令:
这条指令的作用想必很清楚了,就是把数据0放入栈底指针ebp减8个字节后指向的地址所表示的那块空间中进行保存,如图:
下条指令:
该指令的含义是:把ebp加8个字节后指向的地址所表示的那块空间中的数据放入到寄存器eax中,ebp+8在哪呢?
这块位置的空间中保存的实际上是形参a的值,由于a是整形,占四个字节,所以向下访问四个字节后把数据保存到寄存器eax中。
继续下一条指令:
这里的add为加法命令,含义是:把寄存器eax中的值与栈底指针ebp加上0Ch个字节后指向的地址所表示的那块空间中的值进行相加,并把得到的结果放入eax中。
其中eax现在保存的值是1,而ebp+0Ch是哪?十六进制的0Ch转为十进制为12,也就是ebp+12。根据上一条指令可知,ebp+8是形参a的位置,那么+12,中间差了四个字节也就是一个int类型大小,因此正好跳过a。根据入栈顺序,a的下面就是形参b所在的位置,所以ebp+12是指向了b,把b中保存的值与eax相加,结果为3,然后保存在寄存器eax中。
执行后eax的变化:
接着看下条指令:
该指令的含义是:把eax中的数据放到栈底指针epb减8位置的那块空间中进行保存,从上面的栈区图中可知,ebp-8就是变量c的空间所在的位置,所以执行后此时c中保存的数据变成了0x3。
关于函数栈帧的形成全部结束,接下来就开始返回了。
1.2.2 栈帧销毁
返回指令:
该指令的含义是:把栈底指针ebp-8的位置中的值,也就是变量c中保存的0x3,放入到到寄存器eax中。
所以函数返回值的做法本质是通过把数据保存在寄存器中的方式。
如下几条指令的作用是将一些变量弹出栈,将esp加上一些值然后与ebp比较等作用,但是这些对于理解栈帧并不重要,所以直接跳过。
紧接着看下一条指令:
这里是将栈底指针ebp中的地址数据放入栈顶指针esp中进行保存,也就是说执行后栈顶指针esp也指向了栈底指针ebp的位置,它俩便指向同一个位置,即myAdd函数栈帧的栈底。
执行后两个寄存器中保存的数据的变化:
可以发现,此时两个寄存器指向同一个地址。
示意图如下:
不难发现,其实这一条指令就几乎完成了释放"栈帧"的动作。
可以简单理解为:当栈顶指针与栈底指针指向相同的位置时,栈顶指针减去一个特定的值,形成栈帧。它俩再次指向同一个时释放栈帧。
继续分析下一条指令:
这里出现了新的汇编命令pop,它的作用是:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变,简称弹栈。
这条指令的含义是把栈顶的数据pop到ebp中进行保存。此时栈顶指针esp所指向的那块空间中的数据是之前保存的main函数栈帧的栈底地址,pop后数据保存到ebp中,然后修改esp的指向。
先保存执行前的内存与寄存器数据:
执行后:
正如之前所说的结果是相同的,此时ebp中就保存了main函数栈帧的栈底地址,同时修改了esp的指向,让其指向了新的栈顶。
示意图:
可以发现执行完后,ebp又重新指向了main函数的栈底。
地址数据一般都占用4或者8个字节来保存,因此push或者pop等单位都是4或者8字节
继续看下一条指令:
ret这条汇编指令的作用时:恢复返回地址,压入eip,类似于pop eip命令。
通俗点说,就是把栈顶数据放入eip中保存,同时修改esp。
ret前eip内容:
ret后eip内容:
而此时eip中保存的这个地址就是main函数中调用myAdd函数的下一条指令地址:
同时可以发现栈顶指针esp再次向下(地址增大方向)移动,指向新的栈顶。
示意图如下:
不难发现返回的本质是:返回到main函数的栈帧中,具体是返回到调用函数的下一条指令的地址处,继续后续的执行。
继续看下一条指令:
该指令的含义是:将esp中保存的地址值加8,然后再次保存到esp中。
执行后的变化:
其实是跳过了形参的存储空间,示意图如下:
这时esp指向main栈帧的栈顶,ebp指向main栈帧的栈底。
距离函数调用结束还有最后一步,即接收该函数的返回值:
这条指令的含义为:将寄存器eax中的值,放入栈底指针ebp减20h个字节后指向的地址所表示的那块空间中,而ebp-20h的那块空间就是变量z,也就是将eax中的值放入变量z中保存。
执行后的结果:
示意图:
自此便彻底完成了一次函数调用,以及该函数栈帧的创建与销毁的全过程。
栈帧创建与释放的过程本质都是通过若干寄存器来实现的
1.2.3 有趣的现象
在上面探究的过程中,可以发现的是两个栈帧结构中间区域的数据都是push进去的。与定义变量不同,即使是连续定义的变量在内存布局中并不一定能是连续,而push的数据在内存中则是连续的。
那么能否通过找到形参a的地址+1,把形参b中的值给改掉呢?接下来进行测试:
整形指针+1跳过4个字节
结果显示没有问题。
其实这种操作与文章后面要介绍的可变参数列表有点点相似,具体后面再说。
那么还有没有一种可能把函数调用结束返回的地址给改了呢?
具体测试方法是把这个这个地址改为其它函数的地址,看其是否会进行调用其它函数。
因为a的地址-1就是待返回地址,修改它:
显然是可以进行调用的,但是调用完bug函数后,eip就再也找不到回来的地址了,因此函数调用异常结束。
到目前可以对栈帧的创建与销毁的过程发生的一系列现象进行总结,有如下几点:
- 调用函数需要先进行临时拷贝,也就是形参实例化,其形成顺序是自右向左的
- 临时空间的开辟,是在对应函数栈帧内部开辟的
- 函数调用结束后,栈帧结构被释放
- 临时变量具有临时性的本质:栈帧具有临时性
- 调用函数是有成本的,体现在时间和空间上,具体是栈帧的形成和释放有成本
- 函数调用所形成的临时变量(形式参数),互相之间的位置是有规律的
2. 了解可变参数列表的使用与原理
2.1 可变参数列表与函数栈帧的关系
通过它的名字大概可以得出可变参数列表大概是什么意思,也就是函数调用时需要传递的参数个数并不是明确的。
常见的可变参数列表函数有:printf、fprintf、sprintf等输出流以及scanf、fscanf和sscanf等输入流函数等。
三个点就代表该函数的参数是可变参数列表。
接下来从一个简单的示例,开始引入可变参数列表的基本使用。
实现一个函数,求出两个数中的较大值,非常简单,代码如下:
int getMax(int x, int y)
{
return x > y ? x : y;
}
int main()
{
int x = 0;
int y = 0;
scanf("%d %d", &x, &y);
int max = getMax(x, y);
printf("%d\n", max);
return 0;
}
这时把示例的要求改一下:求出一组数据的最大值,这组数据不使用数组传入,而是依次作为参数进行传入。会有多组数据,每组数据的个数并不相同,但是会给出每组数据的个数。
如果没了解过可变参数列表和函数栈帧的概念是无法解决的。
//这种函数定义的写法是比较麻烦的,当传入的实参更多时维护成本也比较高
int getMax(int cnt, int x1, int x2, int x3, int x4, int x5)
{}
//而是应该采用下面这种写法,参数部分以可变参数列表的形式表示
int getMax(int cnt, ...)
{}
int main()
{
int max = getMax(5, 1, 4, 3, 2, 5);
printf("%d\n", max);
return 0;
}
需要注意的是,可变参数列表必须要有一个参数
除此之外,如何操作可变参数列表,还有一个数据类型和三个宏需要了解:
#include <stdarg.h>
//定义可以访问可变参数部分的变量,本质是一个char*类型的指针
va_list arg;
//使arg指向可变参数部分
va_start(arg, 第一个参数);
//根据类型,获取可变参数列表中的第一个数据
va_arg(arg, 参数类型);
//arg使用完毕后,将arg指针置空
va_end(arg);
具体这几个宏的细节细节先不谈,接下来需要谈谈可变参数列表与函数栈帧的关系。
根据前面对于栈帧的理解,正式调用函数前,如果传递了参数那么就必然会形成临时拷贝(变量),也就将形参实例化依次压入到栈中保存。
栈区上的结构大致如下:
由于每个形参的位置是连续的,并且入栈顺序为从右向左,所以只要拿到最左边参数的地址,就可以知道能向下访问多少个数据,然后根据数据类型来依次访问对应空间中的数据。这也就是为什么可变参数列表最少也要有一个明确参数。
在语法层面上也要求最少要带一个参数
以上所说的也就是那几个宏大致的工作过程,对于其实现原理后续会对其进行详细地分析。
2.2 宏的工作过程
int getMax(int cnt, ...)
{
//定义可以访问可变参数部分的变量,本质是一个char*类型的指针
va_list arg;
//使arg指向可变参数部分
va_start(arg, cnt);
int max = va_arg(arg, int);//根据类型,获取可变参数列表中的第一个数据
for (int i = 1; i < cnt; ++i)
{
int cur = va_arg(arg, int);//依次获取并比较其它的
if (cur > max)
{
max = cur;
}
}
va_end(arg);//arg使用完毕后,将arg指针置空
return max;
}
int main()
{
int max = getMax(5, 1, 4, 3, 2, 5);
printf("%d\n", max);
return 0;
}
先看输出结果:
接下来开始逐步分析上面代码中的这几个宏是如何工作的:
如果要访问可变参数部分,首先需要先定义一个va_list arg;
变量,这里的va_list
是一个数据类型:
本质是一个char*
类型的指针,被重命名为了va_list
。
每个内存单元大小都是1字节,而char*的指针±1正好也是跳过1个字节,所以使用使用char*类型最合适
定义好指针后,使用va_start(arg, cnt);
根据第一个参数cnt的地址,让指针指向其可变参数部分,示意图如下:
此时指针arg便指向了可变参数部分第一个数据的地址。
接下来便是找最大值的实现方案:使用一个变量max保存这组数据中的第一个数据,然后将max依次与后续的所有数据进行比较,如果比max大则更新max,最后max中保存的即为这组数据中的最大数。
具体地,需要使用宏va_arg(arg, int)
,它的作用是根据类型,获取可变参数列表中的数据。
将第一个参数的值放入max中后,会修改arg指针的指向,让其向下(地址增高)移动4个字节(int类型大小),指向后续待访问的元素:
后续操作与之相似,通过循环的方式使用va_arg(arg, int)
依次找出后续的数据与max进行比较,最后max即保存了最大值:
循环结束后使用va_end(arg);
,它的作用是,当arg使用完毕后,将arg指针置空,避免出现野指针。宏体如下:
最后返回max中保存的值。
此时把示例代码中主函数部分稍作修改:
int main()
{
char a = '1';
char b = '2';
char c = '3';
char d = '4';
char e = '5';
int max = getMax(5, a, b, c, d, e);
printf("max = %d\n", max);
return 0;
}
getMax函数部分不做任何改动,那么执行的结果还对吗?
53即为字符’5’的ASCII码值,得到的结果也是对的。
可是根据上面的概念,宏va_arg(arg, int)
它的作用是根据类型,获取可变参数列表中的数据。
而此时的传入的参数都是char类型,为什么按照int类型来读取对应空间中的数据时依然能得到正确的结果?这里需要观察函数传参时的汇编代码:
这里出现了一个新的汇编命令:movsx。它的含义如下:
扩展即发生整型提升,也就是在传递参数时(形参入栈),如果类型大小不足整形类型大小时就要发生整形提升,将其隐式地转化为整形后入栈。
CPU内整型运算器(ALU)的操作数的字节长度一般就是int的字节长度,同时也是CPU的通用寄存器的长度。
可以观察入栈前后栈顶指针esp是否向上移动四个字节:
执行后:
结果正如上面所说,发生了整型提升。
这也就是为什么代码最后的结果是正确的,如果使用va_arg(arg, char)
来访问反而是不对的。
而使用这些宏,有以下事项需要注意:
- 可变参数必须是从头到尾的顺序访问,如果访问了几个参数后终止访问是可以的。但是,如果不是从开始位置访问参数是不行的。
- 参数列表必须要有一个命名参数,如果一个没有
va_start
将无法使用。 - 这些宏是无法直接判断可变参数的具体数量。
- 这些宏无法判断这些参数的类型。
- 如果在
va_arg
中使用了错误的数据类型,结果是无法预测的。
2.3 宏的具体实现原理
上面代码中用到的一个数据类型和三个宏如下:
其中第一个和最后一个很简单,上面已经对其进行分析。因此接下来主要研究第二和第三个宏的实现细节。
int getMax(int cnt, char s)
{
//定义可以访问可变参数部分的变量,本质是一个char*类型的指针
va_list arg;
//使arg指向可变参数部分
va_start(arg, cnt);
int max = va_arg(arg, int);//根据类型,获取可变参数列表中的第一个数据
for (int i = 1; i < cnt; ++i){
int cur = va_arg(arg, int);//依次获取并比较其它的
if (cur > max){
max = cur;
}
}
va_end(arg);//arg使用完毕后,将arg指针置空
return max;
}
先来看va_start(ap, v)
的宏定义:
其中在宏体中又出现了两个宏,先看第一个宏的定义:
该宏的作用:取出参数v的地址。
第二个宏的定义:
这个宏是最难理解的,并且后续会着重对它进行分析。
这里先说明它的作用:求出参数n所占的空间大小,如果其大小是小于4字节则会默认将其进行整形提升至4个字节,如果大于4字节则提升为4的倍数。
简言之就是进行4*m字节对齐的功能(m >= 1)。
对其原因是因为,在形参入栈时,就是以4的倍数为单位个字节来存放形参的,所以取的时候也应该按照同样的方式去取
因此va_start(ap, v)
把参数带入可以这样表示:
va_start(arg , cnt)
((void)(arg = (char*)&cnt + 4)
作用是使得指针arg通过参数cnt的地址,强转为char*
类型加上4个字节,指向可变参数的第一个数据的地址。
接下来看宏va_arg(ap, t)
的定义:
其中在它的宏体中又出现了两个相同的宏,也就是上面提到过的:
它的作用上面也简单的提到了,所以直接带入参数表示如下:
va_arg(arg, int)
( *(int*)((arg += 4) - 4) )
//注意区分括号
这里的非常巧妙,需要画图来表示这一条代码执行期间会发生的变化。
初始状态:
第一步使arg+4个字节后,由于有赋值操作,所以此时的arg指向被修改,指向+4个字节后的地址:
第二步再将arg-4个字节,注意这里并没有赋值操作,所以不会修改arg当前的指向,仅仅是得到arg-4个字节后所指向的那块地址:
最后把得到的arg-4个字节所指向的地址先进行强制类型转换为符合的类型(这里为int*
),然后解引用提取出符合数据类型大小的数据:
自此,该宏执行完毕。可以发现它巧妙的地方在于,一条代码完成了两个作用:
- 先将arg修改,指向下一个待访问的元素地址
- 然后回过头来取出上一个参数数据
而后续访问剩下参数的操作与上面相同,便不再一一画图展示了。
几个主要的宏都已介绍完毕,最后只剩下这一个额外的宏:
那么它是如何做到进行4*m字节对齐的呢?下面用简单的数学论证来对其进行分析。
使用的测试环境为VS 2019,在32位系统下sizeof(int) = 4,其余环境不考虑
_INTSIZEOF(n)
这个宏的作用是:找到一个数x,这个x要满足以下两个条件:
x >= n
x % 4 == 0
比如当n为1,2,3,4,此时x = 4。
当n为5,6,7,8,此时x = 8。
…
不难发现,其实要找的x即为4*向上取整的最小倍数m,用表达式表示如下:x = 4 * m (m >= 1)
,其中m是多少,取决于n是多少。当n为1时,m为1,n为5时,m位2。
具体地,如果n能被4整除,那么m = n / 4
,否则m = n / 4 + 1
。那么能否将这两个情况合并为一种写法呢?
当n无法被4整除时,那么它的余数r一定满足:1 <= r <= 3,用表达式表示:n = 4 * m + r
。
此时如果想要使得n能被整除,就应该给其加上[1,3]之间的数,当余数为1时,需要加3,余数为3时需要加1,但是这里还有一个问题,当余数不同时需要根据不同的余数来加上对应的值吗?
显然是不需要的,因为程序在执行整数除法时,如果不能整除是会将余数部分抹除,所以可以不用考虑余数是多少,直接加上最大值3就好。当余数为1时,加3正好正好整除,同时m+1;当余数为3时,加3也会使得m+1,然后抹除余数部分,此时便满足了n不能整除时m = n / 4 + 1
。
当n能被整除时,表达式如下:n = 4 * m
,根据整数除法会去掉余数的性质可以发现,当给n加上任意一个数r,r满足0 <= r <= 3时,最后的结果并不会发生变化,因为无法被整除,所以本质上加上等于没加,结果依旧满足m = n / 4
.
因此便有了以下这种写法:
有了这个公式再对n能否被4整除的情况进行简单论证:
如果n能整除4,那么m就是(n+4-1) / 4 -> (n+3) / 4
,+3的值无意义,会因取整自动消除,等价于n / 4
。
如果n不能整除4,那么n=最大能整除4部分+r
,1 <= r <= 3,那么m就是(n+4-1) / 4
-> (能整除4部分+r+3) / 4,其中4 <= r+3 <= 6 -> 能整除4部分 / 4 + (r+3) / 4 -> n / 4 + 1
。
上面搞清楚了在满足条件下,m最小是几倍的问题,那么对于找到一个数x,就等价于下面的式子:
这样就可以求出来最小的对齐数是4的几倍了,其实这种写法在功能上以及与源代码中的宏等价了,但是在写法上还可以进行优化。
继续分析这条式子((n + 4 - 1) / 4) * 4
,设y = n + 4 - 1
,那么该表达式可以转化为(y / 4) * 4
,其中 4 = 22,而在程序中/4就相当于将自身的二进制序列右移2位,再*4就相当于将自身的二进制序列再左移两位。
先右移两位再左移两位,其实就等价于将自身最低的两个比特位清0!所以不需要先除再乘这么费劲,直接使y & ~3
,即可完成上述操作,使用位运算来代替乘除运算,优雅的同时也提高了运行效率。
因此最终的表达式可以写成:(n + 4 - 1) & ~3
,这种写法也就是源代码中宏的写法.
自此便完成了在可变参数列表中对所有宏的使用和实现原理的介绍与分析。
本篇完。
知识水平有限,如有错误请帮忙指出