• 2008-09-06

    发现一隐藏多年的Bug? - [技术前沿]

    版权声明:转载时请以超链接形式标明文章原始出处和作者信息及本声明
    http://bigwhite.blogbus.com/logs/28582799.html

    C语言程序员在平时工作中,到底如何获取成就感呢?我几乎可以肯定的是:找到一个隐藏已久,多年无人发现的大Bug肯定可以归属到C程序员成就感的范畴中。与操作系统斗、与编译器斗、与内存斗,其乐无穷吗^_^。

    今天测试人员在进行平台迁移测试时发现一个致命的问题,导致系统不能正常工作。问题提到我这,为了不耽误测试进度,马上丢下手头的工作开始问题的查找,经过GDB多次跟踪调试,终于发现了一隐藏多年的问题,至于能否称为Bug呢,我还不敢确定,因为我尚不清楚当年的前辈们在书写这些代码时到底是如何考虑的。

    前不久听说隐藏在FreeBSD系统中长达25年的一个Bug终于被Fixed了,当然今天我发现的这个问题肯定不及FreeBSD的这个Bug重要,但是对于我们的产品来说还是有很大意义的。

    其实这个问题很简单,这里简单用一个例子来展示这个问题(稍后我还会用这个例子做进一步深入分析):
    /* TestFoo.c 注意该文件并不一定在所有编译器下都能顺利编译通过,警告是不可避免的了 */

    typedef struct Foo {
            int     a;
            int     b;
            int     c;
    } Foo;

    int main() {
            Foo f;
            f.a = 17;
            f.b = 23;
            f.c = 19;

            test_foo(f);
    }

    void test_foo(Foo *pfoo) {
            pfoo->c = 29;
    }

    明眼人一眼就能看得出来,test_foo调用时,没有按照test_foo的原型传入f的地址,而是将f以值得形式传给了test_foo这个函数。就是这样的一个很低级的问题。当然了如果一个系统只有几行代码的话,这个问题可能会马上暴露出来;但是在一个拥有几十万行代码且稳定运行了若干年的系统中,没人会注意这个问题。

    有人马上会提出两个疑问:
    1) 为什么编译器没能给出参数类型不匹配的警告?
    2) 为什么系统能在这样明显的问题下稳定运行若干年而不出错呢?

    首先回答第一个问题:之所以编译器没能给出警告是因为项目遗留代码不规范的缘故,在调用test_foo这个角色函数的C文件中并没有引用test_foo原型声明所在的头文件,更不专业的是:test_foo这个函数根本没有在任何头文件中给予原型声明;这样一来,编译器在编译阶段无从知道test_foo到底是个什么样子的函数,也就无法给出正确的调用检查了。而在链接阶段根本不对参数进行有效检查,导致漏洞得以延续。

    第二个问题也是今天在发现这个问题后我最最疑惑的了。按理论上分析,如果按照上述例子中代码,f以值传递方式传入test_foo,test_foo会将f的头4个字节转换成一个Foo指针类型,这样在test_foo中引用pfoo时实际上访问的地址应该是0x11(17d),这个地址在应用程序进程地址空间属于系统地址空间,用户根本无法访问,一旦访问势必违法,如果在SUN SPARC平台上势必是要崩core的。但是实际情况是这样吗?我将上述程序放到SPARC Solaris9平台上用GCC 3.2版本编译器编译后,居然执行后一切OK。而这个源代码放到X86 Solaris 10上用GCC 3.4.6编译后(如果想编译成功,需要将test_foo的返回值改成int)运行就会出Core。初步得出结论:不同CPU体系对该种代码的处理有不同,需逐一分析。

    先来看看SPARC Solaris9,用GDB跟踪程序:
    Starting program: a.out

    Breakpoint 1, test_foo (pfoo=0xffbff0c0) at TestFoo.c:20
    20              pfoo->c = 29;
    (gdb) up
    #1  0x0001069c in main () at TestFoo.c:15
    15              test_foo(f);
    (gdb) p &f
    $1 = (Foo *) 0xffbff0d0

    可以看到在main中,f的地址是0xffbff0d0,而传入test_foo后,pfoo指向的地址居然是0xffbff0c0了。一个推翻前面推理的猜想:编译器在栈上复制了一份f,得到了f',并将f'的地址传给了test_foo。但是编译器为什么要这么做呢?似乎是当编译器发现传入函数的实际参数的值类型大于形式参数类型的时候,都要这么来做,这里我也没有什么特殊的根据,只是通过实验得出这个结论。比如:

    /* testvaluepass.c */
    typedef struct Foo {
            int     a;
            int     b;
            int     c;
    } Foo;

    int main() {
            Foo     f;
            f.a     = 17;
            func(f);
    }

    void func(int x) {
            x = 7;
    }

    /* testvaluepass.s , <=gcc -S testvaluepass.c*/
    main:
            !#PROLOGUE# 0
            save    %sp, -144, %sp        // 寄存器窗口切换(似乎是SPARC独有的机制),fp<- old_sp, new_sp <- old_sp - 144
            !#PROLOGUE# 1
            mov     17, %o0
            st      %o0, [%fp-32]        //%fp-32 <=> &f.a

            ldd     [%fp-32], %o0
            std     %o0, [%fp-48]        //从%fp-48开始,复制f得到f',先copy一个dword,再来一个word,一共12个字节
            ld      [%fp-24], %o0
            st      %o0, [%fp-40]

            add     %fp, -48, %o0        //将f'的地址存入%o0,在subroutine func中, %o0随着寄存器窗口的变动,新栈帧中%i0等于old栈帧中的%o0,也就是f'在栈上的首地址
            call    func, 0
             nop
            mov     %o0, %i0
            nop
            ret
            restore

    func:
            !#PROLOGUE# 0
            save    %sp, -112, %sp
            !#PROLOGUE# 1
            st      %i0, [%fp+68]        //将f'地址写入本地变量x中
            mov     7, %i0
            st      %i0, [%fp+68]        //将7赋值给x
            nop
            ret
            restore

    有了这个例子之后,我们可以分析第一个例子了,同样也是在经过汇编之后:
    main:
            !#PROLOGUE# 0
            save    %sp, -144, %sp
            !#PROLOGUE# 1
            mov     17, %o0
            st      %o0, [%fp-32]
            mov     23, %o0
            st      %o0, [%fp-28]
            mov     19, %o0
            st      %o0, [%fp-24]

            ldd     [%fp-32], %o0        //这四行语句在重新复制一个f
            std     %o0, [%fp-48]
            ld      [%fp-24], %o0
            st      %o0, [%fp-40]

            add     %fp, -48, %o0         //将新f'的地址放到%o0中,而不是将[%fp-48]存入%o0,关键啊!
            call    test_foo, 0
             nop
            mov     %o0, %i0
            nop
            ret
            restore

    test_foo:
            !#PROLOGUE# 0
            save    %sp, -112,         // 寄存器窗口切换,fp<- old_sp, new_sp <- old_sp - 144,%o0->%i0
            !#PROLOGUE# 1
            st      %i0, [%fp+68]          //%i0存储的是f'的地址,是在save时由%o0得来的,存入[%fp+68],即形式参数变量在栈上的地址。而恰好的是这个参数还是一个Foo*类型,这也是在SPARC上没出错的原因了。
            ld      [%fp+68], %i1        //%i此时存储的是f'的地址, 这个就是gdb跟踪时的0xffbff0c0
            mov     29, %i0
            st      %i0, [%i1+8]        //将29存入f'.c里面去了
            nop
            ret
            restore

    这样一来,没有出core的原因也就找到了,但是编译器为何如此做,还无法得出确切结论。

    前面说过,在X86平台上,第一个例子程序是出core的,我们同样也来看看x86平台下的汇编码(与SPARC不同,esp一直在动):
    .globl main
            .type   main, @function
    main:
    .LFB2:
    .LM1:
            pushl   %ebp
    .LCFI0:
            movl    %esp, %ebp        //ebp <- old sp
    .LCFI1:
            subl    $24, %esp        
    .LCFI2:
            andl    $-16, %esp        
            movl    $0, %eax
            addl    $15, %eax
            addl    $15, %eax
            shrl    $4, %eax
            sall    $4, %eax
            subl    %eax, %esp
    .LM2:
            movl    $17, -24(%ebp)        //f.a  init %ebp-24
    .LM3:
            movl    $23, -20(%ebp)        //f.b  init %ebp-20
    .LM4:
            movl    $19, -16(%ebp)        //f.c  init %ebp-16
    .LM5:
            subl    $4, %esp
            pushl   -16(%ebp)        //push onto stack, as first parameter
            pushl   -20(%ebp)
            pushl   -24(%ebp)       
    .LCFI3:
            call    test_foo
            addl    $16, %esp
    .LM6:
            leave
            ret
    test_foo:
    .LFB3:
    .LM7:
            pushl   %ebp            //save old ebp
    .LCFI4:
            movl    %esp, %ebp        //current ebp <- old esp
    .LCFI5:
    .LM8:
            movl    8(%ebp), %eax        //eax <- ebp + 8 ,将ebp+8那块内存的值放到%eax,而这个值恰好是0x11(17d)
            movl    $29, 8(%eax)        //访问0x11+8显然不合理,出core

    看来,不同平台的编译器生成代码差异还是不小的,但是在系统里发现的这个问题到底是否定性为Bug呢?也许这样的一个问题在早期的实现者头脑里早已经是已知的了,他可能就是故意这么做的。如果真的是这样的话,那还真不能算作一个bug,而是我们水平太浅,没能意识到这点。但可以肯定的是是这样编写代码绝对是一个不好的代码风格和习惯。另外发现代码中除了这一处之外还有多处相类似的调用,多是将变量值直接付给一个地址参数了。

    附:  SPARC汇编笔记

    收藏到:Del.icio.us




    引用地址:

    评论

  • 恩,hope so!兴趣是最好的老师,(*^__^*) 嘻嘻~~~
    Tony Bai回复supermaomao说:
    没错。兴趣是最好的老师,特别是程序设计师行业。
    2008-10-22 23:18:51
  • 太崇拜你了,写的好清晰明白深入啊!!!我是网络专业的,喜欢编程就是了,菜鸟一个,羡慕你们高手啊!!!我会经常浏览你blog的哦...期待你多发些精彩的文章,嘻嘻
    Tony Bai回复supermaomao说:
    不要搞个人崇拜哦,呵呵。只要你努力,你也可以做的很好。努力吧。
    2008-10-18 18:43:10
  • 你们这个bug太强了,怎么会,现在不得而知了,编译器根本就不过.难道早期编译器没处理这类问题,哈哈.
    Tony Bai回复blueoceanli说:
    x86上这个代码的确是编译不过的。但是在sun sparc solaris上用gcc 3.4.6可以编译通过。运行居然正常。我能找到的原因只能是blog中所写的那些了。呵呵
    2008-09-28 20:38:30
  • 当调用函数时,会复制一份实参的值到栈中,对于第一个例子:
    typedef struct Foo {
    int a;
    int b;
    int c;
    } Foo;

    int main() {
    Foo f;
    f.a = 17;
    f.b = 23;
    f.c = 19;

    test_foo(f);
    }

    void test_foo(Foo *pfoo) {
    pfoo->c = 29;
    }
    调用:
    test_foo(f);时,编译器就会将结构体f复制到栈中(正如你接下来汇编分析中说看到的)。然后调用函数test_foo。而这个函数的参数是个指针,在编译器编译的时候会按照指针的方式处理,所以这样调用下来后,就将栈中那个复制了的结构体的首地址赋给pfoo指针了。
    :)我的理解
    Tony Bai回复freshmn说:
    一开始我也是这么想的,但是通过一些简单例子测试了一下,发现编译器不是这么做的。应该说这个我们没法控制,这个是编译器的事情。一般实际参数应该在被调用函数栈帧的上一个栈帧中存储的,就是上一个栈帧给这个实际参数分配的栈上内存的位置,编译器少有复制一份之动作。你不妨写代码测试一下。
    2008-09-21 23:50:50