亲密接触C可变参数函数

Posted 海枫

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了亲密接触C可变参数函数相关的知识,希望对你有一定的参考价值。

本文从程序员实践的角度来剖析C可变参数函数在Intel 32CPU上的实现与原理

可变参数函数的实现

如果说C语言具有很多低级语言的特性,那么可变参数函数便是这些特性中的一个。无论是C专家还是C初学者,都对printf标准库函数相当了解,因为它是我们步入C语言的第一个函数。使用printf函数时,就不知不觉地接触到C语言的可变参数函数机制。 

printf函数的原型定义如下:

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

与此类似,C语言的可变参数函数的定义如下:

type fun( type arg1, type arg2, ...); 

其中type表示类型,arg1, arg2表示参数名,而最重要的是可变参数函数的参数列表中出现了“...”符号。符号“...”用来表示参数的个数以及相应的类型都是可变的,相当于多个参数的占位符,可为0个,1个或多个参数,并且要求“...”前至少有一个参数,并且它的后面不能再出现参数。 C语言提供可变参数函数可以根据实际的需要来实现参数个数和类型为可变的情况,在C标准库库中以printf最为出名。而在Unix环境中,exec家族函数就是最好的例证。在此以一个求和函数(sum)来讨论如何实现可变参数函数的实现。sum函数的目标,用于实现可变个整数求和,函数原型:int sum(int num, ...)。用户在使用该函数是非常方便的,只需指定求和的个数以及每个参数,通过调用返回求和的值。但是从以前使用的方法来实现,那是非常因难的,这是因为:

  1. 当用户调用时,运行时每个参数值的难以获得,在普通函数中,通过形参即可获得,但是在可变参数函数的参数列表中只有"...",而不知各个形参的名字。
  2. 可变参数的个数是不确定的,虽然可以通过前面的参数来确定后面的可变参数的个数和类型(如sum函数通过num参数来表明后面可变参数的个数,printf函数通过format来决定可变参数的个数以及它的类型),但是这个函数定义的语义问题,C的编译器不能检测到任何相关的错误,并且也可能运行时也可能捕捉不到相关的错误。 

如果读者对CPU有相当的了解或者对C语言函数调用的约定熟悉,或者对汇编的经验,那么用C语言(或结合汇编)来写一个可变参数函数并不是很难的。显然,结合汇编来实现可变参数函数会降低程序的可移植性。为了保持C语言的较好的移植性,ANSI C标准制订了可移植的可变参数函数的实现方法。该标准制定了一个专门用于处理可变参数的头文件stdarg.h,为了确保移植性,该文头件对实现可变参数函数提供三个宏和一种隐式的数据类型。

提供的三个宏分别如下:

void va_start(va_list ap, last);

type va_arg(va_list ap, type);

void va_end(va_list ap);

隐式的数据类型是va_list 

上面宏的前缀va表示variable argument,即可变参数的意思。变量ap专门用来记录获取可变参数。下面依次介绍三个宏的意义和用法。

void va_start( va_list ap, last)

last为函数形参中"..."前的最后一个形参名字,宏va_start用于根据last的位置(或指针)来初始化变量ap,以供宏ar_arg来依次获得可变参数的值。变量ap在被va_argva_end使用前,必须使用va_start初始化。

type va_arg(va_list ap, type)

va_arg宏用来获得下一个参数的值,type为该参数的类型,它的参数ap必须被va_start初始化,通过该宏后,返回参数值并使用ap指向下一个参数,以供va_arg再次使用。如果没有下一个参数时调用va_argarg指定的类型不兼容时,会产生可知的错误。

void va_end(va_list ap)

va_endva_start必须要同一函数里面对称使用,调用va_start(ap,last)ap得到初始化,在完成参数处理后,需要调用va_end(ap)释放”ap   

然而ANSI C制定的标准只解决上面遇到问题的第一个,而第二个关于可变参数的个数属于语义问题仍然要通过其它方法来处理。下面通过ANSI C标准接口实现的可变参数sum函数代码:

 //sum的目标是计算可变个参数的知,要求可变参数的类型是整型的,现不考虑结果溢出的情况。

#include <stdarg.h>
#include <stdio.h>

int sum(int num, int arg, ...);  

int sum(int num, int arg, ...)
{
    if(num < 1) return 0;

    va_list ap;
    int n = num;

    va_start(ap, arg);     //初始化ap
    int summary = arg;
    while(--n > 0)
    {
      summary += va_arg(ap, int);  //遍历所有可变参数的值
    }

    va_end(ap);  //“释放”ap,与va_start对称出现
    return summary;
}

sum函数中,我们对调用者(caller)有如下的约定:可变参数的类型必须为整形,同样要赤参数的个数与参数num要一致,否则结果未定义 

下面是比较复杂的函数foo,它与printf相同有几分相似,代码如下:  

#include <stdio.h>
#include <stdarg.h>

void foo(char *fmt, ...)
{
    va_list ap;
    int d;
    char c, *s;

    va_start(ap, fmt);
    while (*fmt)
    switch(*fmt++)
    {
        case 's': /* 字符串 */
            s = va_arg(ap, char *);
            printf("string %s/n", s);
            break;
        case 'd': /* 整数 */
            d = va_arg(ap, int);
            printf("int %d/n", d);
            break;
        case 'c' //字符
            c = va_arg(ap, char);
            printf("char %c/n", c);
        break;
    }
    va_end(ap);
}

foo函数通过第一个字符串参数fmt的内容来决定后面可变参数的个数以及它的类型。如何来确定可变参数的个数,通常是由函数的实现来约定的,与C语言的标准是无关的。如printf函数,是通过第一个参数来决定可变参数的个数和相应的类型。当然,这不是唯一的。在Unix环境的系统函数中,有些是通过判断参数值是否为(char *)0来决定最后一个可变参数。这些函数中以exec函数族最为典型。下面是execl函数的声明: int execl(const char *path, const char *arg, ...)。根据该函数的调用约定,用户应按如下的方式来调用: execl("/usr/bin/ls", "ls", "-l", (char *)0)。最后一个参数(char *)0仅用于标志可变参的结束。

通过上面两个例子,大家对如何写可变参数函数有一定的感性和理性理解。其实实现可变参数函数可以不使用标准库(stdarg.h)里面所定义的方法,只要你对CPUC语言的调用约定有相当的了解就足够了。下面是我在Intel32CPU下使用自己的方法来重写sum,把新的函数称名为sum_intel。代码如下:

int sum_intel(int num, int arg, ...)
{
     if( num < 1) return 0;
    
    int n = num;
    int summary = arg;
    int* arg_p = &arg + 1;


    while(--n > 0)
    {
        summary += *arg_p;
        arg_p++;
    }

    return summary;
}

sum_intel函数利用了Intel CPUC语言的一些特性。首先是Intel CPU的栈是向下生长的,C语言中调用约定为:从最后一个参数开始压栈,栈的清理由调用者来负责,同时Intel CPU的对边界对齐也对它有一定的影响。上面代码可以简单分析为:int* arg_p = &arg+1语句,使用arg_p指向arg的下一个参数,并且arg_p++使得它依次指向下一个参数,而*arp_p获得它每指向参数的值。如果把上面的代码放到某个CPU中,该CPU的栈是向上生长的,那么该代码肯定是运行不正确,除非改变C语言函数的调用约定。上面的代码没有涉及了数据对齐的细节,如果参数传递进来的不是int型,而是其它数据类型(特别是用户定义类型),这会涉及到对齐问题,而不同CPU的对齐方式是不一样。因此,上面的函数基本是不可移植的。

C语言具有很好的可移植性,因此我们的代码也尽量保持较好移植性。那么写可变参数函数时使用标准库是方式法是很有必要的,它会提供代码的可移植性,从而使用在不同架构的CPU上都可以运行。

可变参数函数实现的原理

如果对标准二字理解不清楚肯定会在心里打起锣鼓,使用准标里的方法是否真的可以在不同的CPU上运行。答案是肯定的。ANSIC为可变参数函数提供了标准的头文件stdarg.h,只是一种约定(机制),而非是实现(策略)。ANSIC制定的C语言的标准(规范,specification)和一些标准库,而每个C编译器必须遵循这些标准,并且提供标准库的实现。这样使用标准库接口(函数或宏)的代码,是可跨平台的,但是它所调用的库代码会根据不同的CPU而实现不同。但提供的功能与却是等同的。在面向对象程序设计里面的设计思想面向接口编程而非实现,在这里可以深刻地体会出来。使用标准库接口的代码,可以在不同的CPU下编译而不用作任何修改,如上述sum函数可以在不同的CPU上编译通过,而且能正确实现它的功能。

要清楚要分析可变参数函数实现的原理,至少要清楚以下内容:

  1. 函数调用栈的生长方向,栈元素大小和对齐方向
  2. C语言的调用约定

由于不同的CPU会对实现有不同,在此以Intel 32位的CPU为分析基础。在Intel CPU中,栈的生长方向是向下的,即栈底在高地址,而栈顶在低地址;从栈底向栈顶看过去,地址是从高地址走向低地址的,因为称它为向下生长,图1显示了这种特性。

 

1 某系统或应用程序执行push e语句,栈的变化图。

从上面压栈前后的两个图可明显看到栈的生长方向,在Intel 32位的CPU中,windownlinux都使用了它的保护模式,ss指定栈所有在的段,ebp指向栈基址,esp指向栈顶。显然执行push指令后,esp的值会减4,而pop后,esp值增加4 栈中每个元素存放空间的大小决定pushpop指令后esp值增减和幅度。Intel 32CPU中的栈元素大小为16位或32位,由定义堆栈段时定义。在WindowLinux系统中,内核代码已定义好栈元素的大小为32位,即一个字长(sizeof(int))。因此用户空间程栈元素的大小肯定为32位,这样每个栈元素的地址向4字节对齐。

C语言的函数调用约定对编写可变参数函数是非常重要的,只有清楚了,才更欲心所欲地控制程序。在高级程序设计语言中,函数调用约定有如下几种,stdcallcdeclfastcall ,thiscal,naked callcdelC语言中的标准调用约定,如果在定义函数中不指明调用约定(在函数名前加上约定名称即可),那编译器认为是cdel约定,从上面的几种约定来看,只有cdel约定才可以定义可变参数函数。下面是cdel约定的重要特征:如果函数A调用函数B,那么称函数A为调用者(caller),函数B称为被调用者(callee)caller把向callee传递的参数存放在栈中,并且压栈顺序按参数列表中从右向左的顺序;callee不负责清理栈,而是由caller清理。 我们用一个简单的例子来说明问题,并采用Nasm的汇编格式写相应的汇编代码,程序段如下:

void callee(int a, int b)
{
  int c = 0;
  c = a +b;
}

void caller()
{
  callee(1,2);
}

来分析一下在调用过程发生了什么事情。程序执行点来到caller时,那将要执行调用callee函数,在跳到callee函数前,它先要把传递的参数压到栈上,并按右到左的顺序,即翻译成汇编指令就是

//nasm 代码
push 2
push 1

//AT&T汇编语言代码,由gcc编译器生成
pushl   %ebp
movl    %esp, %ebp
subl    $8, %esp
movl    $2, 4(%esp)   ;相当于push 2
movl    $1, (%esp)     ;相当于push 1

注:上述AT&格式的汇编代码采用movl而非pushl指令,但它的功能是一样,都是把参数压到栈顶上。

   

2 函数栈的变化图

函数栈如图2a)所示。接着跳到callee函数,即指令call calleCPU在执行call时,先把当前的EIP寄存器的值压到栈中,然后把EIP值设为callee(地址),这样,栈的图变为如图2(b)。程序执行点跳到了callee函数的第一条指令。C语言在函数调用时,每个函数占用的栈段称为stack frame。用ebp来记住函数stack frame的起始地址。故在执行callee时,最前的两条指令为: 

//nasm代码
push ebp
mov ebp, esp


//AT&T代码
pushl   %ebp
movl    %esp, %ebp

经过这两条语句后,callee函数的stack frame就建好了,栈的最新情况如图2(c)所示。 函数callee定义了一个局部变量int c,该变量的储存空间分配在callee函数占用的栈中,大小为4字节(insizeof int)。那么callee会在如下指令:

//nasm代码
sub esp, 4
mov [ebp-4], 0

//AT&T代码
subl    $16, %esp
movl    $0, -4(%ebp)

这样栈的情况又发生了变化,最新情况如图2(d)所示。注意esp总是指向栈顶,而ebp作为函数的stack frame基址起到很大的作用。ebp地址向下的空间用于存放局部变量,而它向上的空间存放的是caller传递过来的参数,当然编译器会记住变量c相对ebp的地址偏移量,在这里为揭密X86架构C可变参数函数实现原理

哪种算法适合用于接触亲密? [关闭]

C语言中如何实现可变参函数

001:我和Python的第一次亲密接触

C语言可变参数函数详解示例

C/C++可变参数模版和函数指针的结合