C/C++函数调用时传参过程与可变参数实现原理

C/C++函数调用时传参过程与可变参数实现原理

C语言的经典swap问题

在学习C语言的时候,我们大都遇到过一些经典例题,这些经典例题背后所代表的是往往是C/C++背后的一些运行原理,比如下面这个示例:

请问下面这个swap()函数能否用来进行值交换?
void swap(int x,int y)
{
    int temp=x;
    x=y;
    y=temp;
}

稍微有些经验的程序员肯定要脱口而出:不行!!

为什么不行呢?

这个题我都看过十遍了,因为要用指针!!

好吧,确实是要用指针,估计十个人有九个能写出标准答案:

void swap(int *px,int *py)
{
    int temp=*px;
    *px=*py;
    *py=temp;
}

嗯,非常不错!那我们再来做做这个题:

下面这个swap函数能否用来进行值交换?
void swap(int *px,int *py)
{
    int *p;
    p = px;
    px = py;
    py = p;
}

这时就有一些朋友想也不用想:可以啊,用指针进行交换肯定是可以的。

那么,到底这个“交换数据要用指针”的概念是不是完全正确的呢?还是其中另有隐情?

是骡子是马,拉出来遛遛!我们来实践出真知:

void swap(int *px,int *py)
{
    int *p;
    p = px;
    px = py;
    py = p;
}
int main(int argc,char *argv[])
{
    int a=3,b=5;
    printf("Before swap:%d %d\n",a,b);
    swap(&a,&b);
    printf("After swap:%d %d\n",a,b);
    return 0;
}

编译运行,输出结果为:
Before swap:3 5
After swap:3 5

结果是不能交换,不是用了指针么,为什么会是这样的结果??

这得从C语言的函数调用机制说起。

程序的运行

首先,我们需要知道的是,程序是怎么运行的?

从底层角度来看:我们将源代码经过编译链接阶段生成二进制可执行文件,即bin文件。

单片机系统则是下载到片内flash中,上电启动程序(在操作系统中运行这个可执行文件),然后CPU从内存中读取指令到内部寄存器,再操作内部寄存器中的数据,执行完成之后将内部寄存器中的值写回内存。

同时外设寄存器映射到相应的内存地址中,当需要操作硬件外设时,就对外设映射地址上的数据进行操作,如GPIO/I2C/SPI/TIMER等等。(这只是大概流程,具体实现会更复杂,这里不过多描述),其实CPU的运行就是对数据的处理过程。

从程序代码的角度来看:一般情况下,程序从main()函数开始(main()是开发者可见的程序入口,但事实上main()函数也是被系统调用的一个函数,这里不再赘述)。

程序按顺序执行,当遇到函数调用时,执行被调用函数,等被调用函数执行完毕(递归调用通常是存在的),函数返回,继续执行main()函数,直到程序结束(而在操作系统中是进程结束)。

函数调用的过程

即使是现在的MCU,内部寄存器的资源也是极其有限,以目前非常流行的Cortex M3为例,15个内部寄存器,除去三个特殊寄存器(SP,PC,LR),共有12个通用寄存器,由于是32bit MCU,所以即使在极限状态下,寄存器也只能存几十个字节的数据。

所以一旦出现函数调用时,需要保存当前数据和状态,内部寄存器是完全不够用的。

而栈就是从专门内存中开辟出来用于保存程序运行时状态的内存结构。

很多朋友对栈并不陌生,知道这是一种先进后出的数据结构,就像我们堆货物,后来的放在上面,取得时候也是先取最上层的。

这里所说的栈不是数据结构,但是它也是遵循这个原理的内存实现。

实参和形参

我们都知道函数是带有参数的,在函数定义和声明时,这时候指定的参数叫形参,即形式参数,是在定义函数名和函数体的时候使用的参数,目的是用来接收调用该函数时传入的参数.

在调用函数时,实参将赋值给形参。,传入的参数叫实参,即实际参数,实参可以是常量、变量、表达式、函数等,无论实参是何种类型的量,在进行函数调用时,它们都必须具有确定的值,以便把这些值传送给形参。

函数调用时栈的状态

首先,我们继续看上面那份代码:

void swap(int *px,int *py)
{
    int *p;
    p = px;
    px = py;
    py = p;
}
int main(int argc,char *argv[])
{
    int a=3,b=5;
    swap(&a,&b);
    return 0;
}

在上述代码中,我们可以看到,在main()函数中调用了swap()函数,我们来看看在这个执行过程中发生了什么?

  1. 系统将参数压栈,压栈顺序为从右到左,即第一个参数最后压栈(注1),值得注意的是:在现代操作系统中,参数的传递一般是通过寄存器直接传递,而不是栈上传递,只有当参数超过寄存器承受范围时,使用栈传递参数。
  2. 系统将函数返回地址压栈,以便程序执行完之后返回到调用前状态。
  3. 系统为被调用函数的局部变量和其他参数分配内存空间
  4. 如果出现函数的嵌套调用,重复1-3过程
  5. 函数执行结束,如果有返回值,将返回值放入寄存器(如果返回值size太大,则放在内存)
  6. 读取栈上返回地址,函数返回。
    这就是整个函数调用过程(这只是与参数返回值相关的调用结构,实际的实现要复杂得多,会涉及到数据对齐、上下文的保存等具体问题)。

注1:事实上随着操作系统发展,最新的调用方式并不会直接将参数压栈,而是先将参数存在寄存器中,因为直接操作寄存器总比操作内存效率要高,这样可以提高运行效率,这里涉及到调用约定问题,有兴趣的朋友可以自行了解。

再回到swap函数的结果

让我们来看看上述函数调用过程中的第三步,即系统在栈上为局部变量(包括形参)分配地址空间,将寄存器中实参的值传递给形参。

所以,从这里我们可以知道,形参和实参是两个地址独立的变量,参数传递时事实上是变量值的传递,即传值。

很多人提到参数传递有传值和传址两种方式,但是事实上传址是传值的一种形式,本质上传址传的是指针变量的值(即地址值),也是一种值传递,所以严格来说是没有传址和传值的区分的。

需要声明的是,指针是一种数据变量类型,和int,char是同一个概念,
而类似

int *p,
char *str

int i
char c

是同一种定义行为,所以这里的p,str事实上是变量,只不过变量的值是地址。而不是某些书上说的"指针就是地址",搞清这个问题才能对指针有更清晰的了解。

我们回过头来看第一个swap函数为什么不能交换:

void swap(int x,int y)
{
    int temp=x;
    x=y;
    y=temp;
}

我们调用这个函数,例如:

int a=3,b=5;
swap(a,b)

经过上面的讨论,我们知道,系统在栈上给形参x,y分配了内存空间,然后将a的值赋值给x,b的值赋值给y,相当于进行了这样的操作:

x=a=3;
y=b=5;

在函数执行的过程中,x与y成功进行了swap交换,即函数执行完,结果是这样的:

x:5
y:3

但是根据我们列出的函数调用过程的第6点可以看到,在函数运行完之后,x和y被销毁,这次x,y的交换行为根本没有意义,因为a,b根本没有参与到函数执行中来。

那为什么第二个函数就可以交换成功呢?

void swap(int *px,int *py)
{
    int temp=*px;
    *px=*py;
    *py=temp;
}

我们依旧调用这个函数:

int a=3,b=5;
//我们假设a的地址为0x1000,b的地址为0x1004
swap(&a,&b);

在这次调用中,系统为px,py分配空间(px和py为指针类型),然后将a,b的地址赋值给px,py,相当于执行了这样的操作:

px=&a=0x1000;
py=&b=0x1004;

接下来的三行代码:

int temp=*px;
*px=*py;
*py=temp;

用通俗的语言描述就是:

  • 系统取出px的值即0x1000,找到地址0x1000上存储的变量,即a,将a赋值给temp,同temp=a;
  • 系统取出py的值即0x1004,找到地址0x1004上存储的变量,即b,再取出px的值即0x1000,找到0x1000上存储的变量,即a,将b赋值给a,同a=b。
  • 系统取出temp的值即原a的值,取出py的值即0x1004,找到地址0x1004上存储的变量即b,将temp的值赋值给b,同b=temp。

函数结束,px,py被销毁,此时a,b的值已进行交换。

那我们再来看看第三个swap函数为什么不能成功交换。

void swap(int *px,int *py)
{
    int *p;
    p = px;
    px = py;
    py = p;
}

我们还是调用这个函数,来一步步地分析:

int a=3,b=5;
//我们假设a的地址为0x1000,b的地址为0x1004
swap(&a,&b);

接下来的三行代码:

p = px;
px = py;
py = p;

用通俗的语言表达就是:

  • 系统取出px的值即0x1000,将px的值赋值给p,此时p=0x1000;
  • 系统取出py的值即0x1004,将py的值赋值给px,此时px=0x1004;
  • 将p赋值给px,即px=0x1000;

函数结束,px,py被销毁,此时a,b的值不受任何影响。

看到这里,我想你应该看出答案了,这个swap和第一个swap实现其实就是换汤不换药,仅仅是将形参进行了互换,而a,b没受到任何影响。

由此可见,这种参数传递问题根本就不能以是否是指针这种死板的方式来判断是否有效。

思考

我想大家都应该已经懂了参数传递的原理,我来出个小题来验证一下:

请问,下面的释放动态内存的函数有什么问题?
void myFree(char *ptr){     //ptr为指向动态内存的指针
    free(ptr);
    ptr=NULL;
    return;
}

欢迎大家留言讨论。


可变参数函数原理

在上面提到了,在参数压栈的过程中,是从右到左的顺序,即最后一个参数最先压栈,既然提到了函数的参数传递,就必须来看看可变参数函数来怎么实现的。

printf()函数就是可变参数函数的一员,用过printf的盆友都知道,printf()并不固定参数的个数,pritnf()函数原型为:

int printf( const char* format , ... );

虽说是可变参数,但也并不是完全自由的,对于任意的可变参数函数,至少需要指定一个参数,通常这个参数包含对传入参数的描述(下面会提到原因)。
可变参数的实现依赖下列几个库函数(宏定义)的定义:

va_list           //这是一个特殊的指针类型,指代栈中参数的开始地址
va_start(ap,T)    //ap为va_list类型,T为函数第一个参数
va_args(ap,A)     //ap为va_list类型,A为需要取出的参数类型,如int,char
va_end(ap)        //ap为va_list类型。


接下来我们便动手实现一个可变参数函数add(),返回所有传入的int型参数之和:

int add(int cnt, ... )
{
    int sum=0;
    va_list args;
    va_start(args,cnt);
    for(int i=0;i<cnt;i++)
    {
        sum += va_arg(args,int);
    }
    va_end(args);
    return sum;
}
int main()
{
    
    printf("%d\r\n",add(4,1,2,3,4));
    return 0;
}

程序输出结果:

10

老规矩,看完示例我们来探究一下示例实现的原理:

  • va_list args;这一条语句即定义一个va_list类型(可以看成是一种特殊的指针类型)的变量args,args变量指向的对象是栈上的数据。
  • va_start(args,cnt);这一条语句是初始化args,args指向第一个被压栈的参数,即函数的最后一个参数,而cnt则是栈上最后一个参数,系统由此确定栈上参数内存的范围。
  • va_arg(args,int); 这个函数需要传入两个参数,一个是指向栈上参数的指针args,这个指针每取出一个数据移动一次,总是指向栈上第一个未取出的参数,int指代需要取出参数的类型,CPU根据这个类型所占地址空间来进行寻址。
  • va_end(args);当args使用完之后,要将args置为空。
    整个函数实现的过程就是我们不需要通过形参来获取实参的值,而是直接从栈上将一个个参数取出来。

在这里,我们需要关注几个问题:

  1. 压栈顺序从右往左是怎样实现可变参数传递的?
  2. printf()函数和上述的add()实现都在可变参数前至少提供了一个具体参数,可不可以省略这个参数呢?
  3. 在使用va_arg()取出函数的值时需要指定类型,如果指定一个错误的类型会怎么样呢?

第一和第二个问题其实可以同时来解释,参数从右往左压栈,在可变参函数调用时,先将最后一个参数入栈,最后将第一个参数入栈,可变参数主要是通过第一个参数来确定参数列表,但是这时候如果第一个参数没有被指定的话,编译器将无法定位参数在栈上的范围。

同时,如果可变参数函数在定义时没有第一个参数的话,编译器直接报错。(gcc)

test.c:10:10: error: ISO C requires a named argument before ‘...’

va_arg对应类型问题

我们再回到第三个问题,如果在va_arg()函数中传入一个错误的类型会发生什么情况呢?

下面是我传入一个int型数据,但是在用va_arg()获取参数时传入了char类型,编译时的信息:

warning: ‘char’ is promoted to ‘int’ when passed through ‘...’
sum += va_arg(args,char);                  ^
note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
note: if this code is reached, the program will abort

警告信息,但是不会报错,依然可以运行,那我们就运行看看结果:

Illegal instruction (core dumped)

果然,如编译时的警告预料的,当执行到那部分代码时,程序就会终止运行。

这是为什么呢?

其实原因也并不难想到,被调用函数并不知道参数的类型和个数,所以只能依靠用户给的信息来寻址获取数据,如果指定错误的类型,很可能会导致栈上数据的混乱,但是这里博主发现一个有意思的问题:

如果传入的参数为char类型,我们在从栈上取参数的时候也指定char类型参数:

sum += va_arg(args,char);

按理说这是完全没有问题的,但是在编译的时候依然会有以下提示:

warning: ‘char’ is promoted to ‘int’ when passed through ‘...’
sum += va_arg(args,char);                  ^
note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
note: if this code is reached, the program will abort

这是为何?

传入的类型和指定接收的类型是匹配的,为什么提示有问题。然后我运行了一次,结果是这样的:

Illegal instruction (core dumped)

我突然想到,printf中也会传入char类型,我看看它是怎么实现的。

case 'c': 
    handle_char(va_arg(arg, int)); 
    continue;

看起来在printf实现中,对传入的char类型的数据,也是根据int类型从栈上获取数据,char是一个字节,int是4字节(32位),这样不会出问题吗?

理论上来说,当程序取一个int型数据时,就在栈上获取了四字节数据,除了这个参数,还会把前一个参数(从右到左压栈)的前三个字节取出来,势必会导致数据的混乱。

但是,计算机系统中还有一个概念就是对齐,不管是数据结构填充还是指令和数据的存储,这是为了寻址时的方便,所以即使是将一个char类型数据压栈,也会占用一个int类型的空间。

所以我们再来分析为什么传入char类型的同时取出char类型的实参会导致程序运行失败:

当使用sum += va_arg(args,char);获取参数时,获取了一个字节的数据,但是由于对齐,后面填充的三个字节依然放在栈上。  

当下一次取参数时,仍然取一个字节,取出的事实上是第一个参数的第二个字节,这时候会有6个字节仍然在栈上,以此类推。  

最要命的是:栈上存储着函数的返回地址,当参数都取完时,再取返回地址,这时候自然取不到真正的返回地址,而是取到了参数,程序跳转到了未知的地方,所以程序运行自然失败。


好了,关于C/C++函数调用时传参过程的讨论就到此为止啦,如果朋友们对于这个有什么疑问或者发现有文章中有什么错误,欢迎留言

原创博客,转载请注明出处!

祝各位早日实现项目丛中过,bug不沾身.

posted @ 2019-03-04 15:59  牧野星辰  阅读(7171)  评论(2编辑  收藏  举报