查看文章
 
C语言中变长参数中的一个有趣的现象。关于默认参数提升(default argument promotion)(一)
2010-12-31 19:07

这几天在忙着写一个操作系统编程的教程。

(这题说的太大了呵呵。其实已经发出来的在这里:http://www.cnblogs.com/bombless/archive/2010/12/28/writing-x86-os-part1.html

 

于是在琢磨一个返回多个值或者返回整个系统调用的返回数据的解决方案。

准备在发展操作系统的系统调用时将这个的成果作为返回大量信息时的一个通用方法。

 

这个有点像windows底层用的很多的那个RPC(它总是返回512字节的内容)。

 

一开始想用栈上的数据执行能力,让操作系统核心构建代码然后返回给调用函数的局部变量,让局部变量去灵活的执行。

(这个学的linux,它的信号量处理是在栈上执行指令的)。

 

后来觉得好像这方案也不是很适合做这任务,于是改用统一做一个结构体或是数组,然后调用操作系统函数让操作系统填充这个结构体就算数了。(这个可能有点像Lua,Lua有一个数据栈结构,用来使Lua引擎和本地代码之间交互数据的。)

 

在C语言层面,做这个调用\返回系统主要用的是函数的可变参数特性,还有就是利用union。

不知为何这2点刚好是C语言初学者不会去仔细学习的内容:-)

我把数据打包成一个打包,然后用类型安全的方法(其实也不太安全,不过代码里面不需要强制类型转换就是了,这个靠的是union提供的能力),利用变长参数函数处理这个包。

 

处理的过程中,我终于理解了C语言标准库函数中的一个困扰我的大问题!

 

那就是,为什么scanf中,输入的时候必须详细区分%f和%fl,但是printf的格式化输出却不需要区分%l和%lf。

我的代码里有这么一段:
#include <stdarg.h>
int init_pkg(Package pkg,char *fmt,...){
va_list args;
va_start(args,fmt);
...
...
float value = va_arg(args, float); /* line 39 */
....
return 0;
}

然后编译器(我用gcc)有提示:
pkg.c:39: warning: `float' is promoted to `double' when passed through `...'
pkg.c:39: warning: (so you should pass `double' not `float' to `va_arg')
pkg.c:39: note: if this code is reached, the program will abort

翻译一下,哈哈:

源文件pkg.c:第39行: 警告: 当 `float' 被传递给 `...' 时会被提升为 `double' 类型
源文件pkg.c:第39行: 警告: (所以你应该把 `double' 而不是 `float' 传值给 `va_arg')
源文件pkg.c:第39行: 注意: 当这里的代码被执行,程序会强制退出

换句话说,把float值或double值传给`...`时,怎么都是double,因此printf不在乎你传给它的是float还是double,

实际上调用的时候总是把float提升为double之后才调用的printf,所以printf感受不到这种区别。

给scanf传值的时候它需要知道你要写的是4字节的一段内存(也就是float值)还是8字节的一段内存(double值),所以它在乎你给它的格式里标明的到底是%lf还是%f。

 

另外,编译器警告说,我们这里程序会强制退出?是不是真的啊!

 

我们看看它到底会怎么做。

新写一个文件,targ.c,代码如下:

#include <stdarg.h>
int parse(char *fmt,...){
    va_list args;
    va_start(args,fmt);
    float f = va_arg(args,float);
    return 0xabcdef;
}

这里没有main函数,我们也不需要它。

 

这样一个简单的C源文件便于我们观察。

 

让gcc把它编译成汇编语言:

gcc -S -o targ.s targ.c

查看产生的文件targ.s:

_parse:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $8, %esp
        leal    12(%ebp), %eax
        movl    %eax, -4(%ebp)
        int     $5

 

我擦,你玩真的啊!

这里使用了int 5中短,gcc产生的代码让我们在这里强制退出。你狠!

注意,gcc这里是做绝了,因为它本来应该有一个return 0xabcdef;的过程,

翻译成它的AT&T汇编就是:

movl $0xabcdef, %eax

leave

ret

这里它没有返回值了。整个return 0xabcdef;代码被它忽略了。(它预计整个程序直接崩溃退出,后面的C代码它懒得编译了?)

另外,

        leal    12(%ebp), %eax
        movl    %eax, -4(%ebp)

 

这2句其实就是va_start(args,fmt);的实现代码。

(前面va_list args;这个是局部变量声明而已,不会产生代码的。)。

现在这个问题就讨论完了!

没错,讨论完了!(重点强调,呵呵)

不过我还想玩玩。

 

为什么这个函数开头的时候,gcc要subl    $8, %esp给本次函数栈留下8字节的空间?

因为从这里可以看到,整个float f = va_arg(args,float);这一行的代码都停止解析了,只留下一个int 5,

那么实际上这个函数里只用到了va_list args;声明的变量而已,而这其实可以看做一个指针,只占4个字节而已。

 

为什么呢?

继续探究一下。

新写一个程序命名为targ.c:

(这里我们不使用外部函数了,没必要。直接调试。

gc产生的main函数代码还要调用外部的alloc函数和留给链接器解决的__main符号。这2个符号的解析对我们来说压根没意义,

所以我们这里不写传统的C程序了,直接指定一个入口函数另外来。这样可以避免链接带来的麻烦。

 

#include <stdarg.h>
int parse(char *fmt,...){
    va_list args;
    va_start(args,fmt);
    float f = (float)va_arg(args,double);
    return 0xabcdef;
}
int start(){
    parse((char *)0xabcde,1.0);
}

 

够简单吧!


要不我们先看看如果让gcc直接汇编一下是个什么情况?

好。

看看:

_parse:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $12, %esp
    leal    12(%ebp), %eax
    movl    %eax, -4(%ebp)
    movl    -4(%ebp), %edx
    leal    -4(%ebp), %eax
    addl    $8, (%eax)
    fldl    (%edx)
    fstps    -8(%ebp)
    movl    $11259375, %eax
    leave
    ret

_start:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $12, %esp
    fld1
    fstpl    4(%esp)
    movl    $703710, (%esp)
    call    _parse
    leave
    ret

不看不知道,一看吓一跳。

我们对比下start函数本身:

int start(){
    parse((char *)0xabcde,1.0);
}

本来我这里根本就没有局部变量。但是gcc为我们开了12字节的栈帧!

上次1个局部变量还只有8个字节的……

这里需要解释下。这个start函数我们看到它用2个参数调用parse函数。

但是这里其实只有一个4字节立即数。我们看看start函数是怎么调用parse函数的:

    fld1
    fstpl    4(%esp)
    movl    $703710, (%esp)
    call    _parse

 

这里fld1是给处理器的浮点堆栈压入一个1.0。这个可以参考http://www.alloc.net/blog/162.html

然后,fstpl    4(%esp)把已经压入浮点堆栈的这个1.0给放入下一个函数的形参的位置,[esp + 4]。

这样就把parse需要的2个参数给准备好了。

 

所以整个start函数只有一个立即数,没有任何局部变量。可是他占用了12字节的栈帧!

好吧,我只有猜测,gcc其实会统计每个函数的栈帧最大占多少字节,然后统一用这个最大的字节数来开拓栈帧。

我们来试试:

#include <stdarg.h>
int parse(char *fmt,...){
    va_list args;
    va_start(args,fmt);
    float f = (float)va_arg(args,double);
    return 0xabcdef;
}
int start(){
    parse((char *)0xabcde,1.0);
}
int fake_start(){

    int a,b,c,d;
    start();
}

fake_start,有16字节的局部变量。

嗯,我们来试验一下。


_parse:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $12, %esp
    leal    12(%ebp), %eax
    movl    %eax, -4(%ebp)
    movl    -4(%ebp), %edx
    leal    -4(%ebp), %eax
    addl    $8, (%eax)
    fldl    (%edx)
    fstps    -8(%ebp)
    movl    $11259375, %eax
    leave
    ret

_start:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $12, %esp
    fld1
    fstpl    4(%esp)
    movl    $703710, (%esp)
    call    _parse
    leave
    ret

_fake_start:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $16, %esp
    call    _start
    leave
    ret

好吧,16,12,12,start和parse的栈帧容量完全不受fake_start函数的局部变量增大这一点的影响!

 

下面实验很多,我就不全部列出来了。

列其中一些数据吧:

                 调用          调用

fake_start——》start——》parse

 

fake_start的局部变量大小

start的形参大小

start的局部变量大小

parse的形参大小

parse的局部变量大小

结果(按fake_start栈帧大小,start栈帧大小,parse栈帧大小来排列)

0

0

0

12

8

(0,12,12)

0

4

0

12

8

(4,12,12)

0

4

0

12

12

(4,12,16)

4

8

0

12

8

(12,12,12)

总结来说,fake_start的栈帧大小 = start的形参大小 + fake_start的局部变量大小

start的栈帧大小 = parse的形参大小 + start的局部变量大小

这下可以看出一些规律了吧。

 

这里我觉得隐含的逻辑就是,gcc会为函数调用时给参数调用需要扩展的栈帧容量提前留出来,这样就不用每次函数调用的时候拓展栈帧了。

 

不过parse编译出来的结果似乎和我们的猜测有出入。

总是多了4个字节?是不是我想错了,parse除了args变量和f变量之外还有其他隐含的局部变量,毕竟它是变长参数函数?

 

试试改改:

#include <stdarg.h>
int parse(char *fmt,...){
    va_list args;
    //va_start(args,fmt);
    float f;// = (float)va_arg(args,double);
    return 0xabcdef;
}

_parse:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $8, %esp
    movl    $11259375, %eax
    leave
    ret

好,va_start这句减少之后,又少了4个字节。从12减少到8了。

也就是说va_start其实也占4字节的栈帧容量?还是说……难道是va_arg占去了4字节的栈帧容量?

int parse(char *fmt,...){
    va_list args;
    //va_start(args,fmt);
    //float f;// = (float)va_arg(args,double);
    return 0xabcdef;
}

这里是把局部变量float f;给注释掉了。也就是去掉了4字节的局部变量。

编译为:

_parse:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $4, %esp
    movl    $11259375, %eax
    leave
    ret

没错。但是这还是不能区别出到底是va_arg给占了4字节的容量,还是说va_start占去了4字节。我们继续思考一下。

下面我们把va_list args;这里句声明给省了,让函数里没有标准的局部变量,看看va_list类型的args是不是真的占据4字节的栈帧容量:

 

#include <stdarg.h>
int parse(va_list args,...){
    //va_list args;
    va_start(args,args);
    //float f;// = (float)va_arg(args,double);
    return 0xabcdef;

}

试试……

_parse:
    pushl    %ebp
    movl    %esp, %ebp
    leal    12(%ebp), %eax
    movl    %eax, 8(%ebp)
    movl    $11259375, %eax
    popl    %ebp
    ret

很好,压根没做栈帧拓展。这一个栈帧的容量只有0。也就是说,va_start估计是不会占栈帧容量的了。

把ebp + 12这个地址赋值给eax了……?什么意思呢?等后面我们再考虑。

#include <stdarg.h>
int parse(va_list args,...){
    va_list local_arg;
    va_start(local_arg,args);
    //float f;// = (float)va_arg(args,double);
    return 0xabcdef;
}

_parse:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $4, %esp
    leal    12(%ebp), %eax
    movl    %eax, -4(%ebp)
    movl    $11259375, %eax
    leave
    ret

这下好了,只有4字节了……

和我们的期望相符,因为这时候函数里只有local_arg这个局部变量了。

也就是说,这次parse的栈帧里的全部内容就是这个local_arg。

#include <stdarg.h>
int parse(int i,...){
    va_list local_arg;
    va_start(local_arg,i);
    //float f;// = (float)va_arg(args,double);
    return 0xabcdef;
}

用上面这个程序再试:

_parse:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $4, %esp
    leal    12(%ebp), %eax
    movl    %eax, -4(%ebp)
    movl    $11259375, %eax
    leave
    ret

还是4字节……嗯,

#include <stdarg.h>
int parse(int i,...){
    va_list local_arg;
    va_start(local_arg,i);
    float f;// = (float)va_arg(args,double);
    return 0xabcdef;
}

算了不再贴汇编代码了。我试了下,上面这个是8字节的栈帧容量,刚好是8字节的局部变量。

int parse(char *fmt,...){
    va_list local_arg;
    va_start(local_arg,fmt);
    float f = (float)va_arg(local_arg,double);
    return 0xabcdef;
}

这个是12,区别只是做了一次va_arg.

基本上总结出来了。那就是,参数可变的函数,它的栈帧容量仍然以它调用时需要的最大形参空间加上其自身的局部变量所需的空间为基数。

在这个基础上,它的栈帧容量在做va_arg时需要拓展。

但是,如果va_arg在循环里面怎么办?那它不能事先知道要做多少次va_arg啊?

试试这个,这里就不管代码的合理性了。(不过,有时可能的确需要类似的代码)

#include <stdarg.h>
int parse(int i,...){
    va_list args;
    va_start(args,i);
    for(;i>0;i++){
        i = va_arg(args,int);
    }
}

_parse:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $4, %esp
    leal    12(%ebp), %eax
    movl    %eax, -4(%ebp)
L2:
    cmpl    $0, 8(%ebp)
    jle    L3
    movl    -4(%ebp), %eax
    leal    -4(%ebp), %edx
    addl    $4, (%edx)
    movl    (%eax), %eax
    movl    %eax, 8(%ebp)
    incl    8(%ebp)
    jmp    L2
L3:
    leave
    ret

args一个4字节局部变量,于是栈帧大小是4字节,很好。很符合我们的预期。循环的时候就这样了啊。

#include <stdarg.h>
int parse(int i,...){
    va_list args;
    va_start(args,i);
    //for(;i>0;i++){
        i = va_arg(args,int);
    //}
}

_parse:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $4, %esp
    leal    12(%ebp), %eax
    movl    %eax, -4(%ebp)
    movl    -4(%ebp), %edx
    leal    -4(%ebp), %eax
    addl    $4, (%eax)
    movl    (%edx), %eax
    movl    %eax, 8(%ebp)
    leave
    ret

去掉循环还是4字节。我没看错吧?那我们就有理由相信,这根本不是因为循环还是不循环导致的。

这个估计应该是double和int导致的差别。

换句话说,做va_arg的时候,真正影响我们的栈帧容量的还是double和int的区别,而不是是否循环。

 

这个假设等我们后面再论证。我们再仔细检查一下int型的情况是否与我们的猜测相冲突。

#include <stdarg.h>
int parse(int i,...){
    va_list args;
    va_start(args,i);
    //for(;i>0;i++){
        i = va_arg(args,int);i = va_arg(args,int);
    //}
}

 

做2次呢?还是4字节。

#include <stdarg.h>
int parse(int i,...){
    va_list args;
    va_start(args,i);
    int j;
    //for(;i>0;i++){
        j = va_arg(args,int);
    //}
}

i改j,那是8字节了。那没错。这时在函数里是args的四字节变量和 局部变量 j 这个四字节变量总共8字节。

#include <stdarg.h>
int parse(int i,...){
    va_list args;
    va_start(args,i);
    int j;
    for(;i>0;i++){
        j = va_arg(args,int);j=va_arg(args,int);
    }
}

试试上面这个,放循环里做va_arg?

_parse:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $8, %esp
    leal    12(%ebp), %eax
    movl    %eax, -4(%ebp)
L2:
    cmpl    $0, 8(%ebp)
    jle    L3
    movl    -4(%ebp), %edx
    leal    -4(%ebp), %eax
    addl    $4, (%eax)
    movl    (%edx), %eax
    movl    %eax, -8(%ebp)
    movl    -4(%ebp), %edx
    leal    -4(%ebp), %eax
    addl    $4, (%eax)
    movl    (%edx), %eax
    movl    %eax, -8(%ebp)
    incl    8(%ebp)
    jmp    L2
L3:
    leave
    ret

还是8字节。局部变量的总字节数也是共8字节。

对int型的情况的仔细验证就到此为止了。

下面我们可以做个更具有启发性的例子。

#include <stdarg.h>
int f1(int i,...){
    va_list args;
    va_start(args,i);
    i = (int)va_arg(args,double);
}
int f2(int i,...){
    va_list args;
    va_start(args,i);
    int j = (int)va_arg(args,double);
}
int f3(int i,...){
    va_list args;
    va_start(args,i);
    int j = (int)va_arg(args,int);
}

f1,f2,f3三个函数。

它们的栈帧大小方别是12,12,8。

看起来有点不好理解,不是吗?

注意看f2和f3的区别。它们其实很像,不过有一点不同,那就是,f3是从参数列表里解析一个4字节数出来,而f2函数的va_arg操作试图从参数列表中解析一个8字节的double类型值出来。

 

看出点道道了么?

其实是这样的,va_arg在做参数转换的时候会对栈上用来接受va_arg产生的结果的那一段内存做重复应用。

也就是,既然需要类型转换,那么就在栈上做原地的转换。

这里f2函数里的int j本来只占4个字节的,但是由于它要用来接收一个8字节的double值,

那么它就会用掉8字节的内存大小。

从X86系统的角度来说,FPU会把它的浮点数栈上的值(本来在FPU内部是80位的)处理成8字节double格式的值,放到栈上某个地址addr开头的8个字节的内存中。

而地址addr起始的4个字节实际上只是临时占用的。

再往上的4个字节,也就是更靠近栈底的一个双字,才是局部变量j真正的居所。

addr开始的这4个字节相当于只是临时使用的。更高位的4个字节被临时的double值和局部变量j的值所复用了。

换句话也可以说,从FPU来的一个临时的double值和局部变量j在生存的时间上没有重叠过,这是重复利用这几个字节的关键,

在这个前提下,这个临时的double值和局部变量j在存储的位置上重叠了。

也就是局部变量j利用了临时double值所占的8字节的内存位置。

 

根据上面这段有点绕口的阐述,其实可以这么理解:局部变量j就别记它的栈空间了,要计算时直接用那个临时存在的双精度浮点值占的8字节空间就好了啦!

 

按f2函数来举例,那就是args占4字节空间,临时的双精度浮点值占8字节空间,总共12字节空间。没错吧!

关于gcc对栈帧空间的利用的这段研究就到此为止了。

 

 

结论就是,变长参数函数的确符合上面的假设,"参数可变的函数,它的栈帧容量仍然以它调用其他函数时需要的最大形参空间加上其自身的局部变量所需的空间为基数。在这个基数上,它的栈帧容量在做va_arg时需要拓展。".

当存在va_arg过程时,它有可能会使栈帧的需求增加4字节。

怎样会使栈帧的需求增加几个字节呢?

前面我们已经说了,变长参数的函数里,当调用者传值给`...'时,会有2种提升的方法。

一种是int和short,不足int的大小,这个时候就是提升为int。

一种是double和float这些值,传float值的时候,`...'会实际接收到一个从原float提升到double的值。

栈帧的需求增加,会在从double强制转换到占用更少内存的值时发生。

而被提升为int的值如果想转换回shot或者char,当然也就有类似的过程了。

 


类别:Ansi C||添加到搜藏 |分享到i贴吧|浏览(245)|评论 (0)
 
最近读者:
 
网友评论:
发表评论:
姓 名:
网址或邮箱: (选填)
内 容:
     

   
帮助中心 | 空间客服 | 投诉中心 | 空间协议
©2012 Baidu