函数除了可以接收固定的参数之外,还可以接受不确定的函数参数,称为变参函数,典型的变参函数是我们已经用得非常熟练的printf和scanf函数,它们都可以接受不确定个数的参数,它们的函数声明形式如下:

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

可以发现上面两个函数的声明都有一个共同点,那就是都含有一个占位符...。这个...并不是参数,而是告诉编译器,该函数是变参函数,不管该函数使用时参数有多少个,都对其一一做压栈处理,这就实现了变参函数。

变参函数的实现与密切相关,栈是一种数据结构,是一种只能在一端进行插入和删除的特殊线性表(线性表可以暂时理解成数组)。栈的存储规律是先进后出(First In Last Out),先入栈的数据被压入栈底,最后入栈的数据在栈顶,需要读数据时则从栈顶开始弹出数据(最后一个数据被第一个弹出来)。

一个栈中,栈底是固定的,而栈顶是浮动的,对栈的插入和删除操作,不需要改变栈底的位置。

在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。在i386机器中,栈底位于高地址,栈顶位于低地址,压栈(PUSH)使得栈顶地址变小,弹栈(POP)(也可以称为退栈)使栈顶地址变大。

栈在程序的运行中有着举足轻重的作用。栈可以用来在函数调用时存储断点信息,做递归时要用到栈。最重要的是栈保存了一个函数调用时所需要的维护信息,通常称为堆栈帧,保存函数的返回地址和参数,以及函数的局部变量。

下面我们来分析变参函数参数的压栈过程。一般来说,函数参数的入栈顺序是从右向左的,意味着,最右边的参数最先入栈,位于高地址处,而最左边的参数最后入栈,位于低地址。下面通过一个具体的例子来看函数的压栈操作。

#include <stdio.h>
void print(int n, ...)
{
    int *p, i;
    p = &n + 1;
    for (i = 0; i < n; i++)
    {
        printf("%d\t", *(p + i));
    }
    printf("\n");
    return;
}

int main()
{
    print(4, 12, 34, 56, 78);
    return 0;
}

编译并运行以上程序,可以顺序实现打印后续参数的效果(上面的代码只在32位系统下能够运行成功,这里增加-m32参数,表示编译32位架构下的可执行程序,编译之前需要执行apt-get install gcc-multilib以安装32位的库和运行环境):

root@DESKTOP-38B6GK1:~/C# gcc myprint.c -m32 -o myprint
root@DESKTOP-38B6GK1:~/C# ./myprint
12      34      56      78

以上代码,首先在print函数中使用占位符...,因此该函数在编译时被当成变参函数来处理,对该函数调用中的参数将一一进行压栈。上述代码定义了一个int型指针p,由于函数参数的压栈顺序是从右向左,参数的存储的地址由高地址到低地址,所以p = &n + 1得到的是指向第一个可变参数的地址,接下来通过循环一一取出函数中的参数。

在使用变参函数时,...前面至少要有一个普通的参数。必须知道参数什么时候结束,如果没有给出变参函数的个数,直接结出第一个参数,则必须约定一个参数作为结束标志。

当然,C语言标准库也提供了用于实现变参函数的宏va_list, va_start, va_arg, va_end,用于简化变参函数的操作,位于头文件stdarg.h中,它们的一种可能的实现方式如下:

typedef char *va_list;
#define _INTSIZEOF(n) ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
#define va_start(ap, v) (ap = (va_list)&v + _INTSIZEOF(v))
#define va_arg(ap, t) (*(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
#define va_end(ap) (ap = (va_list)0) 

下面通过这些宏来重写print变参函数:

#include <stdarg.h>
void myprint(int n, ...)
{
    int arg, i;
    va_list p;
    va_start(p, n);
    for (i = 0; i < n; i++)
    {
        arg = va_arg(p, int);
        printf("%d\t", arg);
    }
    printf("\n");
    va_end(p);
    return;
}

这里要注意的一点是,函数的压栈是按照4字节对齐的,小于4字节的统统按4字节对齐来入栈,而这里的_INTSIZEOF宏,就是用于实现内存中的字节对齐操作。宏va_start(ap, v)的作用是先得到变量v的地址,然后将其转化成char型指针,再加上v对齐之后所占用的内存大小,使指针指向下一个参数。注意此时的指针为char类型,所以接下来在使用va_arg(ap, t)时要将其强制转换为当前参数类型t的指针。对于宏va_arg(ap, t)要注意的是,“ap += _INTSIZEOF(t)”得到的是下一个参数的地址,再减去_INTSIZEOF(t)得到当前参数的地址。通过一个for循环就可以一一取出其中压栈的所有参数。最后一个宏va_end(ap)的作用清除指针,表示在接下来的部分不再使用该指针变量。

//通过变参函数的宏实现一个类似printf()的函数
void myprintf(const char *fmt, ...)
{
    va_list p;
    char c;
    va_start(p, fmt);
    while (*fmt != '\0')
    {
        c = *fmt;
        if (c != '%')
        {
            putchar(c);
        }
        else
        {
            fmt++;
            switch (*fmt)
            {
            case 'd':
                printf("%d", *((int *)p));
                va_arg(p, int);
                break;
            case 'c':
                printf("%c", *((int *)p));
                va_arg(p, int);
                break;
            case 'f':
                printf("%f", *((double *)p));
                va_arg(p, double);
                break;
            }
        }
        fmt++;
    } // end while
    va_end(p);
    return;
}
  • 无标签