C标准库之stdarg

✏️ 1、可变长参数

C语言支持可变长参数,正常情况下C的函数参数入栈规则为__stdcall,它是从右到左的,即函数中的最右边的参数最先入栈。例如,对于函数:

void func(int a, char b, int c, double d, int e) {
    int f = 0;
    printf("&a = 0x%p\n", &a);
    printf("&b = 0x%p\n", &b);
    printf("&c = 0x%p\n", &c);
    printf("&d = 0x%p\n", &d);
    printf("&e = 0x%p\n", &e);
    printf("&f = 0x%p\n", &f);
}
// 输出
&a = 0x0058F968   
&b = 0x0058F96C  // 4  字对齐
&c = 0x0058F970  // 4
&d = 0x0058F974  // 4
&e = 0x0058F97C  // 8
&f = 0x0058F954  

用户栈是从上往下生长的,先占用高地址的空间,再占用低地址空间。对于在32位系统的多数编译器,每个栈单元的大小都是sizeof(int),而函数的每个参数都至少要占一个栈单元大小。对于固定参数列表的函数,每个参数的名称、类型都是直接可见的,他们的地址也都是可以直接得到的。

按照C标准的说明,支持变长参数的函数在原型声明中,必须有至少一个最左固定参数(这一点与传统C有区别,传统C允许不带任何固定参数的纯变长参数函数),这样我们可以得到其中固定参数的地址,根据上面的参数入栈顺序,我们可尝试写一个可变长参数的函数:

void var_args_func(const char* fmt, ...)
{
    char* ap;
    ap = ((char*)&fmt) + sizeof(fmt);
    printf("%d\n", *(int*)ap);
    ap = ap + sizeof(int);
    printf("%d\n", *(int*)ap);
    ap = ap + sizeof(int);
    printf("%s\n", *((char**)ap));
}

int main()
{
    var_args_func("%d %d %s\n", 4, 5, "hello world");
    return 0;
}

解释:用ap获取第一个变参的地址,我们知道第一个变参是4,一个int 型,所以我们用(int)ap以告诉编译器,以ap为首地址的那块内存我们要将之视为一个整型来使用,(int)ap获得该参数的值;接下来的变参是5,又一个int型,其地址是ap + sizeof(第一个变参),也就是ap + sizeof(int),同样我们使用(int)ap获得该参数的值;最后的一个参数是一个字符串,也就是char,与前两个int型参数不同的是,经过ap + sizeof(int)后,ap指向栈上一个char类型的内存块(我们暂且称之tmp_ptr)的首地址,即ap -> &tmp_ptr,而我们要输出的不是printf("%s\n", ap),而是printf("%s\n", tmp_ptr)printf("%s\n", ap)是意图将ap所指的内存块作为字符串输出了,但是ap -> &tmp_ptrtmp_ptr所占据的4个字节显然不是字符串,而是一个地址。如何让&tmp_ptrchar类型的,我们将ap进行强制转换(char)ap <=> &tmp_ptr,这样我们访问tmp_ptr只需要在(char)ap前面加上一个即可,即printf("%s\n", (char)ap)

从理论上看,该程序没有问题,但是在GCC上不能输出预期的结果,在VC上可以按预期输出。

✏️ 2、内存对齐

🖋️ 2.1、字节对齐

现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些架构的CPU在访问一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐。其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台要求对数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那 么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据,显然在读取效率上下降很多。

struct Student
{
    double score;
    char sex;
    int ID;
};

sizeof(struct Student) = 16

其实,这是VC对变量存储的一个特殊处理。为了提高CPU的存储速度,VC对一些变量的起始地址做了“对齐”处理。在默认情况下,VC规定各成员变量存放的起始地址相对于结构的起始地址的偏移量必须为该变量的类型所占用的字节数的倍数。下面列出常用类型的对齐方式(vc6.0,32位系统):

  • char:偏移量必须为sizeof(char)即1的倍数

  • int:偏移量必须为sizeof(int)即4的倍数

  • float:偏移量必须为sizeof(float)即4的倍数

  • double:偏移量必须为sizeof(double)即8的倍数

  • Short:偏移量必须为sizeof(short)即2的倍数

各成员变量在存放的时候根据在结构中出现的顺序依次申请空间,同时按照上面的对齐方式调整位置,空缺的字节VC会自动填充。同时VC为了确保结构的大小为结构的字节边界数(即该结构中占用最大空间的类型所占用的字节数)的倍数,所以在为最后一个成员变量申请空间后,还会根据需要自动填充空缺的字节。

struct Student
{
    char sex;     //偏移量为0,满足对齐方式,cda占用1个字节;
    double score; //下一个可用的地址的偏移量为1,不是sizeof(double)=8 
                  //的倍数,需要补足7个字节才能使偏移量变为8(满足对齐 
                  //方式),因此VC自动填充7个字节,dda存放在偏移量为8 
                  //的地址上,它占用8个字节。 
    int ID;  //下一个可用的地址的偏移量为16,是sizeof(int)=4的倍 
             //数,满足int的对齐方式,所以不需要VC自动填充,type存 
             //放在偏移量为16的地址上,它占用4个字节。
             
    //所有成员变量都分配了空间,空间总的大小为1+7+8+4=20,不是结构 
    //的节边界数(即结构中占用最大空间的类型所占用的字节数sizeof 
    //(double)=8)的倍数,所以需要填充4个字节,以满足结构的大小为 
    //sizeof(double)=8的倍数。
};

sizeof(struct Student) = 24

🖋️ 2.2、自定义对齐

VC对结构的存储的特殊处理确实提高CPU存储变量的速度,但是有时候也带来了一些麻烦,我们也屏蔽掉变量默认的对齐方式,自己可以设定变量的对齐方式。VC 中提供了#pragma pack(n)来设定变量以n字节对齐方式。n字节对齐就是说变量存放的起始地址的偏移量有两种情况:

  1. 如果n大于等于该变量所占用的字节数,那么偏移量必须满足默认的对齐方式;

  2. 如果n小于该变量的类型所占用的字节数,那么偏移量为n的倍数,不用满足默认的对齐方式。

结构的总大小也有个约束条件,分下面两种情况:如果n大于所有成员变量类型所占用的字节数,那么结构的总大小必须为占用空间最大的变量占用的空间数的倍数;否则必须为n的倍数。下面举例说明其用法:

#pragma pack(push) //保存对齐状态 
#pragma pack(4)    //设定为4字节对齐 
struct Student
{
    char sex;
    double score;
    int ID;
};
#pragma pack(pop)  //恢复对齐状态 

sizeof(struct Student) = 16

首先为sex分配空间,其偏移量为0,满足我们自己设定的对齐方式(4字节对齐),sex占用1个字节。接着开始为 score 分配空间,这时其偏移量为1,需要补足3个字节,这样使偏移量满足为n=4的倍数(因为sizeof(double)大于n),score 占用8个字节。接着为ID分配空间,这时其偏移量为12,满足为4的倍数,ID占用4个字节。这时已经为所有成员变量分配了空间,共分配了4+8+4=16个字节,满足为n的倍数。如果把上面的#pragma pack(4)改为#pragma pack(16),那么我们可以得到结构的大小为24。再看下面这个例子:

#pragma pack(push) 
#pragma pack(16)   
struct Score
{
    int prj;
    double number;
};

struct Student
{
    char sex;            // 1 -> 8
    struct Score score;  // 8 * 2
    int ID;              // 4 -> 8
};
#pragma pack(pop)   

sizeof(struct Student) = 32

这里有三点很重要:

  1. 每个成员分别按自己的方式对齐,并能最小化长度。

  2. 复杂类型(如结构)的默认对齐方式是它最长的成员的对齐方式,这样在成员是复杂类型时,可以最小化长度。

  3. 对齐后的长度必须是成员中最大的对齐参数的整数倍,这样在处理数组时可以保证每一项都边界对齐。

🖋️ 2.3、可移植性

C标准库在stdarg.h中定义了如下一个宏:

/* Amount of space required in an argument list for an arg of type TYPE.
 * TYPE may alternatively be an expression whose type is used.
 */

#define __va_rounded_size(TYPE)  \
  (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))

n字节对齐就是说变量存放的起始地址的偏移量有两种情况:

  1. 第一、如果n大于等于该变量所占用的字节数,那么偏移量必须满足默认的对齐方式(各成员变量存放的起始地址相对于结构的起始地址的偏移量必须为该变量的类型所占用的字节数的倍数);

  2. 第二、如果n小于该变量的类型所占用的字节数,那么偏移量为n的倍数,不用满足默认的对齐方式。

此时n = 4,对于sizeof(TYPE)一定为自然数,sizeof(int) - 1 = 3sizeof(TYPE)只可能出现如下两种情况:

  1. sizeof(TYPE) >= 4偏移量 = (sizeof(TYPE)/4)*4

  2. sizeof(TYPE) < 4偏移量 = 4

此时sizeof(TYPE) = 1 or 2 or 3,而(sizeof(TYPE) + 3) / 4 = 1,为了将上述两种情况统一,偏移量 = ((sizeof(TYPE) + 3) / 4) * 4。在有的源代码中,将内存对齐宏__va_rounded_size通过位操作来实现,代码如下:

#define __va_rounded_size(TYPE)  \
   ((sizeof(TYPE)+sizeof(int)-1)&~(sizeof(int)-1))

由于 ~(sizeof(int) – 1) ) = ~(4-1)=~(00000011B)=11111100B

(sizeof(TYPE) + sizeof(int) – 1)就是将大于4m但小于等于4(m+1)的数提高到大于等于4(m+1)但小于4(m+2),这样再& ~(sizeof(int) – 1) )后就正好将原长度补齐到4的倍数了。

✏️ 3、stdarg

最前面的例子在GCC和VC的表现不一样,主要是编译器的优化导致的,这里重新提供一种写法:

#define __va_rounded_size(TYPE)  \
  (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))

void var_args_func_round(const char* fmt, ...)
{
    char* ap;

    ap = ((char*)&fmt) + sizeof(fmt);
    printf("%d\n", *(int*)ap);

    ap = (char*)ap + sizeof(int) + __va_rounded_size(int);
    printf("%d\n", *(int*)ap);

    ap = ap + sizeof(int) + __va_rounded_size(int);
    printf("%s\n", *((char**)ap));
}

int main()
{
    var_args_func_round("%d %d %s\n", 4, 5, "hello world");
    return 0;
}

该程序在GCC上能输出预期的结果,但在VC上不能按预期输出。

C标准库在stdarg.h中提供了诸多便利以供实现变长长度参数时使用:宏va_startva_argva_end;在ANSI C标准下,三个宏的原型如下:

void va_start(va_list ap, last);// 取第一个可变参数(如上述printf中的i)的指针给ap,
				                        // last是函数声明中的最后一个固定参数(比如printf函数原型中的*fromat);
type va_arg(va_list ap, type);	// 返回当前ap指向的可变参数的值,然后ap指向下一个可变参数;
				                        // type表示当前可变参数的类型(支持的类型位int和double);
void va_end(va_list ap);	      // 将ap置为NULL

借助这三个宏重新实现上述函数的功能:

void std_vararg_func(const char* fmt, ...) {
    va_list ap;
    va_start(ap, fmt);

    printf("%d\n", va_arg(ap, int));
    printf("%f\n", va_arg(ap, double));
    printf("%s\n", va_arg(ap, char*));

    va_end(ap);
}

int main()
{
    std_vararg_func("%d %d %s\n", 4, 5.0, "hello world");
    return 0;
}

该程序在GCC和VC下都是可以正确输出的。

对比一下 std_vararg_funcvar_args_func的实现,va_list似乎就是char*va_start似乎就是 ((char*)&fmt) + sizeof(fmt)va_arg似乎就是得到下一个参数的首地址。没错,多数平台下stdarg.hva_listva_startvar_arg的实现就是类似这样的。

下面我们来探讨如何写一个简单的可变参数的C 函数:

使用可变参数应该有以下步骤:

  1. 首先在函数里定义一个va_list型的变量,这里是arg_ptr,这个变量是指向参数的指针。

  2. 然后用va_start宏初始化变量arg_ptr,这个宏的第二个参数是第一个可变参数的前一个参数,是一个固定的参数。

  3. 然后用va_arg返回可变的参数,并赋值给整数jva_arg的第二个参数是你要返回的参数的类型,这里是int型。

  4. 最后用va_end宏结束可变参数的获取,然后你就可以在函数里使用第二个参数了,如果函数有多个可变参数的,依次调用va_arg获取各个参数。

在《C程序设计语言》中,Ritchie提供了一个简易版printf函数:

#include<stdarg.h>

void minprintf(char *fmt, ...)
{
    va_list ap;
    char *p, *sval;
    int ival;
    double dval;

    va_start(ap, fmt);
    for (p = fmt; *p; p++) {
        if(*p != '%') {
            putchar(*p);
            continue;
        }
        switch(*++p) {
        case 'd':
            ival = va_arg(ap, int);
            printf("%d", ival);
            break;
        case 'f':
            dval = va_arg(ap, double);
            printf("%f", dval);
            break;
        case 's':
            for (sval = va_arg(ap, char *); *sval; sval++)
                putchar(*sval);
            break;
        default:
            putchar(*p);
            break;
        }
    }
    va_end(ap);
}

🖋️ 总结

1、标准C库的中的三个宏的作用只是用来确定可变参数列表中每个参数的内存地址,编译器是不知道参数的实际数目的。

2、在实际应用的代码中,程序员必须自己考虑确定参数数目的办法,如 ⑴在固定参数中设标志-- printf函数就是用这个办法。 ⑵在预先设定一个特殊的结束标记,就是说多输入一个可变参数,调用时要将最后一个可变参数的值设置成这个特殊的值,在函数体中根据这个值判断是否达到参数的结尾。本文前面的代码就是采用这个办法. 无论采用哪种办法,程序员都应该在文档中告诉调用者自己的约定。

3、实现可变参数的要点就是想办法取得每个参数的地址,取得地址的办法由以下几个因素决定:

  • ①函数栈的生长方向

  • ②参数的入栈顺序

  • ③CPU的对齐方式

  • ④内存地址的表达方式

结合源代码,我们可以看出va_list的实现是由④决定的,_INTSIZEOF(n)的引入则是由③决定的,他和①②又一起决定了va_start的实现,最后va_end的存在则是良好编程风格的体现,将不再使用的指针设为NULL,这样可以防止以后的误操作。

4、取得地址后,再结合参数的类型,就可以正确的处理参数,写出适合于自己机器的实现来。

✏️ 4、默认参数提升

C标准中有一个默认参数提升(default argument promotions)规则。 如果一个函数的形参类型未知, 例如使用了Old Style C风格的函数声明,或者函数的参数列表中有 ...,那么调用函数时要对相应的实参做Integer Promotion,此外,相应的实参如果是float型的也要被提升为double类型,这条规则称为Default Argument Promotion。

🖋️ 默认参数提升在可变参数函数中的陷阱

该问题在《C陷阱与缺陷》中有提到。

以上面的简易版printf函数为例,如果我们加一种case,即"%c"按字符输出,则应该写为:

#include<stdarg.h>

void minprintf(char *fmt, ...)
{
    va_list ap;
    ...
    char cval;

    va_start(ap, fmt);
    for (p = fmt; *p; p++) {
        if(*p != '%') {
            putchar(*p);
            continue;
        }
        switch(*++p) {
        case 'c':
            cval = va_arg(ap, int);
            printf("%c", cval);
            break;
        ...
        default:
            putchar(*p);
            break;
        }
    }
    va_end(ap);
}

注意,这里的va_arg(ap, type)取参数的时候,type是int而不是char。为什么呢?-- 这里就牵扯到默认参数提升问题。在C语言中,调用一个不带原型声明的函数时:调用者会对每个参数执行“默认实际参数提升(default argument promotions)。同时,对可变长参数列表超出最后一个有类型声明的形式参数之后的每一个实际参数,也将执行上述提升工作。

提升工作如下:

  • float类型的实际参数将提升到double

  • charshort和相应的signed、unsigned类型的实际参数提升到int

  • 如果int不能存储原值,则提升到unsigned int

然后,调用者将提升后的参数传递给被调用者。 所以,minprintf函数是绝对无法接收到以下类型的实际参数的:

  • char、signed char、unsigned char

  • short、unsigned short

  • signed shortshort int、signed short int、unsigned short int

  • float

如果type是上述类型之一,则在GCC编译器下不能生成有效的汇编代码,运行到相应位置时,会输出Illegal instruction,程序退出。同理, 如果需要使用short和float, 也应该这样:

short shval = (short)va_arg(ap, int);
float fval = (float)va_arg(ap, double);

🖋️ 符号扩展

符号扩展的定义:简单来说,符号扩展是一个整数从位数较小扩展到位数较多的过程。

  1. 对于无符号整数,很简单,只需要在高位补0就可以了,即零扩展。

  2. 对于有符号整数,需要区分正数和负数:① 对于正数,规则和无符号整数一样,零扩展。 ② 对于负数,高位补1就可以了。对于有符号整数的规则又称符号位扩展。

需要澄清的一点是:符号扩展问题和默认参数提升这两个概念本身是没有半毛钱关系的,只是在实现默认参数提升这个标准的过程中会牵扯到符号扩展问题。也就是说,符号扩展问题并不仅仅在是在默认参数提升过程中存在,它还存在于其它很多情况下,比如不同类型的数据之间进行转换时也会进行符号扩展。

最后更新于

这有帮助吗?