C语言栈调用机制初探

学习linux离不开c语言,也离不开汇编,二者之间的相互调用在源代码中几乎随处可见。所以必须清楚地理解c语言背后的汇编结果才能更好地读懂linux中相关的代码。否则会有很多疑惑,比如在head.s中会看到调用main函数,在调用之前会看到几次压栈行为,在《linux内核完全注释》一书中会看到这几句汇编后面的注释说是为main函数的参数进行压栈,可是查看main的代码发现main函数根本不需要任何参数,这里为什么会有几次压入参数的动作呢?再比如fork函数中会看到有众多参数,但在调用这时却没有看到任何参数入栈动作,这又是为什么呢?fork函数中的参数是什么时间怎么传递到fork函数中去的呢?要想理解这些,必须要理解c语言函数是如何被编译成汇编指令的,也就是说c函数编译后长成什么样子。不理解这部分,不但无法看懂linux中很多源码,其实也没有真正明白c语言本身的很多奥秘。当然这些内容在c语言的课上是几乎无法学到的。似乎到目前为止没有哪个c语言课程里面会讲到c函数编译后的内容。好了,现在让我们来看看c函数编译后到底长成什么样子。

例子1、没有参数的函数调用

//nopara.c – filename

#include <stdio.h> 

void f(void) 
{ 
    int i=0; 
} 

int main(void) 
{ 

    f(); 

    return 0; 
}

上面几乎是个最简单的c程序,除了f函数定义了一个变量外,什么也不做。下面来看一下编译后的汇编文件。 使用-S选项会让gcc只进行汇编而不连接生成可执行程序。所以在终端中输入: gcc -S -o nopara.s nopara.c 我的ubuntu中gcc版本为4.8.4(之所以强调版本是因为不同的gcc版本处理后的汇编代码可能会不同,版本之间距离越远差异可能越大,所以是可能,是因为我机器上只装了3.4.6和4.8.4两个版本。),会生成一个nopara.s的汇编文件。如下:

#nopara.c – nopara.c生成的汇编文件,gcc – 4.8.4
1.        .file    "nopara.c" 
2.        .text 
3.        .globl    f 
4.        .type    f, @function 
5.    f: 
6.    .LFB0: 
7.        .cfi_startproc 
8.        pushl    %ebp 
9.        .cfi_def_cfa_offset 8 
10.        .cfi_offset 5, -8 
11.        movl    %esp, %ebp 
12.        .cfi_def_cfa_register 5 
13.        subl    $16, %esp 
14.        movl    $0, -4(%ebp) 
15.        leave 
16.        .cfi_restore 5 
17.        .cfi_def_cfa 4, 4 
18.        ret 
19.        .cfi_endproc 
20.    .LFE0: 
21.        .size    f, .-f 
22.        .globl    main 
23.        .type    main, @function 
24.    main: 
25.    .LFB1: 
26.        .cfi_startproc 
27.        pushl    %ebp 
28.        .cfi_def_cfa_offset 8 
29.        .cfi_offset 5, -8 
30.        movl    %esp, %ebp 
31.        .cfi_def_cfa_register 5 
32.        call    f 
33.        movl    $0, %eax 
34.        popl    %ebp 
35.        .cfi_restore 5 
36.        .cfi_def_cfa 4, 4 
37.        ret 
38.        .cfi_endproc 
39.    .LFE1: 
40.        .size    main, .-main 
41.        .ident    "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04) 4.8.4" 
42.        .section    .note.GNU-stack,"",@progbits

其中会有很多连接符号,对于和本文内容无关的,也不影响理解程序的部分完全可以删除,这样看起来就容易多了。删除符号之后的汇编代码如下(仅保留了程序中使用的几个符号)。

1.    f:
2.        pushl    %ebp                #保存栈帧ebp
3.        movl    %esp, %ebp            #修改当前函数的ebp
4.        subl        $16, %esp            #为局部变量保留栈空间,同时进行16字节对齐
5.        movl    $0, -4(%ebp)        #为局部变量赋值
6.        leave                        #相当于movl %ebp,%esp;popl %ebp
7.        ret                            #此处相当于popl eip
8.    main:
9.        pushl    %ebp
10.        movl    %esp, %ebp
11.        call    f                        #将返回地址入栈,并跳到函数f执行
12.        movl    $0, %eax            #返回值存入eax寄存器中
13.        popl    %ebp                #由于没有调整和使用栈,此处仅弹出ebp
14.        ret

下面看一下这个程序的栈使用情况。

图1 nopara.c程序中栈的示意图

nopara.c程序中栈的入栈顺序,图中所示为调用函数f运行未返回时的栈中状态。下面分析一下各数据的入栈情况。

1、main函数中
1.1、pushl    %ebp           将当前的ebp入栈(ebp值为main运行前的值),esp减少4。
1.2、movl    %esp, %ebp      将当前的esp值赋给ebp,此时esp及ebp均指向main函数的栈帧开始。
1.3、 call    f              将调用的返回地址入栈,esp减少4。并跳转到f函数中执行
2、f函数中
2.1、pushl    %ebp           将当前的ebp入栈(指向main函数的栈帧),esp减少4。
2.2、movl    %esp, %ebp      将当前的esp值赋给ebp,此时esp及ebp均指向f函数的栈帧开始。
2.3、subl    $16, %esp       为局部变量保留栈空间,同时进行16字节对齐,esp减少16。
2.4、movl    $0, -4(%ebp)    局部变量i赋值,放在栈空间中,esp保持不变。
2.5、leave                   函数准备返回,esp指向f函数的栈帧开始处,弹出ebp,此时ebp恢复指向main函数栈帧值,而esp则指向返回地址。
2.6、ret                     弹出返回地址。esp值减4,指向main函数中的栈帧开始处。
3、返回到main函数中
3.1、movl    $0, %eax        返回值存入eax寄存器中
3.2、popl    %ebp            将保存的main函数运行前的ebp值弹出,esp减4,指向main函数的返回地址。
3.3、ret                     弹出返回地址。esp值减4,指向main函数开始运行前的位置。

这里我们看到共有两个寄存器来保存栈的状态。一个是esp,一个是ebp,其中esp是栈顶指针,始终指向当前栈的底部(栈是向下增长的,即向小地址方向扩展),而ebp是栈帧基址指针。这个名字不能特别清楚地表示它本身的作用,而在这个程序中也似乎看不到这个名字的来源,我们会通过下面的程序的分析来看清楚它的作用以及为什么会被叫作基址指针寄存器。但这里可以看到,ebp可以用来作为不同函数栈帧的分界。在f函数中我们可以看到当为i变量赋值时使用的正是ebp这个寄存器。而在ebp之下刚好是局部变量的存放位置。这里只有一个局部变量i,它被存放在ebp-4的地址处。在函数返回时我们看到esp的值直接减少到ebp的位置,这样就跳过了局部变量存储的区域,所以这些局部变量在函数返回后直接被系统抛弃,不再使用,这样也能明白c函数中定义的变量其作用域仅限于函数内部的原因。因为在函数返回时它们被放弃在栈中,而esp越过了这些地址,也就不能再被访问了。这里虽然可以看到一些c函数调用过程中栈的状态,但还不能完全解决我们的问题。比如,参数是如何放置的,其顺序如何?被调用的函数如何拿到其参数?在main函数中定义的参数也在栈中吗?让我们继续c函数的汇编查看,以期找到这些问题的答案。

例子2、有参数的函数调用

//para.c
1.    #include <stdio.h>  
2.    int f(int v) 
3.    { 
4.        return v*v; 
5.    } 
6.     
7.    int main(void) 
8.    { 
9.        int i=2; 
10.        int double_value=f(i); 
11.        printf("double of i is = %d
",double_value); 
12.     
13.        return 0; 
14.    }

这个程序调用一个拥有int变量参数的函数,并返回其运算后的结果给main函数,并在main函数中显示。其中main函数中定义了两个局部变量,f函数只有一个形参变量。下面看一下这个程序的汇编代码。

#para_s.txt
1.    f: 
2.        pushl    %ebp 
3.        movl    %esp, %ebp 
4.        movl    8(%ebp), %eax                 #取出形参变量v,其在main栈帧中
5.        imull    8(%ebp), %eax                 #对变量v进行运算,结果由eax返回
6.        popl    %ebp                         #f函数中没有局部变量,此处仅弹出ebp
7.        ret                                     #弹出返回地址
8.    .LC0: 
9.        .string    "double of i is = %d
" 
10.    main: 
11.        pushl    %ebp 
12.        movl    %esp, %ebp 
13.        andl        $-16, %esp                     #栈指针需要16字节对齐
14.        subl        $32, %esp                     #为局部变量保留空间,同时也要16字节对齐
15.        movl    $2, 24(%esp)                 #为变量i赋值,放在栈帧开始的8字节处。
16.        movl    24(%esp), %eax                 #将变量i赋值给eax
17.        movl    %eax, (%esp)                 #将eax中值放在栈底,作为f的调用参数
18.        call    f                                 #将返回地址入栈,并跳到f函数去执行
19.        movl    %eax, 28(%esp)                 #将f函数的返回值放在栈中即变量double_value
20.        movl    28(%esp), %eax                 #将变量double_value值赋给eax
21.        movl    %eax, 4(%esp)                 #将eax中值放入栈中,为printf调用参数
22.        movl    $.LC0, (%esp)                 #将字符串地址放入栈中,为printf调用参数
23.        call    printf                             #调用printf函数打印结果
24.        movl    $0, %eax                     #将函数返回值放在eax中
25.        leave 
26.        ret

图2:para.c程序中f函数运行时栈帧状态

图2表示的是f函数被调用时栈的状态。由于f函数没有任何局部变量,所以它的栈帧中仅保留有main函数栈帧的ebp值。而其运行时形参变量并不在其栈帧中,而是在main函数的栈帧中,由main函数放在栈内。而取形参的语句使用的是movl 8(%ebp),%eax,刚好是跳过返回地址的地方。也就是说,函数运行时的参数总是从其栈帧减8的位置处开始的,如果有多个参数,则依次存放。而函数取参数时只需要知道自身ebp值,即可按需要取出参数即可。虽然参数本身不在函数的栈帧中,由于ebp值在函数生存周期并不改变,而且调用函数的栈帧在被调函数未返回前也不会销毁,所以其作用域会一直存在。现在也可以明白为什么ebp会被叫做基址指针寄存器,因为它是分界函数与其调用者之间栈帧的界限。也是局部变量及形参变量的分界线(当然二者之间还有一个返回值如果是段间函数则还会有cs值。)。ebp寄存器的存在,让函数分界自身的栈区域有了很方便的实现。如果没有ebp寄存器,仅有一个esp是无法分清函数调用者及被调用者之间的限界,如果单纯依靠数字的加减更加困难,更何况栈指针本身要求16字节对齐。这样会导致通过esp的值来计算变量地址变得不可行。而加上一个ebp寄存器来保存一个栈基址指针,就方便多了。
这里要指出另外两个问题。一个是main函数中出现的andl $-16,%esp,这条语句是为了让栈指针进行16字节对齐而添加的。其结果是将esp的值后16位清零,以进行16字节对齐,具体的作法和原理可以在gcc的官方文档中查看到。随后的subl $32,%esp,也同理,一方面要为局部变量保留栈空间,另一方面要保证栈指针16字节对齐,所以最小的栈预留空间是16,所以常会看到这样的汇编语句:andl $-16,%esp;sub $16,esp。有时在函数中并未有任何局部变量也会出现这两条语句。其作用主要是为了让栈指针16字节对齐。第二问题是,如果main函数中有形参,是不是也会被放在main函数的栈帧之上呢?也就是放在main的返回地址之上?其实并不如此。传递给main函数的形参和其它函数中的形参并不等同,它们被称做运行环境变量,是放在整个栈之上的。这一点会在以后我们谈到linux中程序文件在内存中的映像时会清楚地看到这一点。所以main函数的两个参数并不在栈中,而在栈外。

明白了ebp的意义和形参的取得,我们继续看para.c程序的后续运行时的栈帧状态(见图3)。

图3:para.c程序运行到printf函数时栈帧状态

 

此时f函数已经结束,其形参已经失去生存意义,所以此时main函数不再保留,而是将其值赋给变量double_value,而double_value则被放在栈帧中。在这里也可以看到变量定义的顺序和在栈帧中的顺序刚好相反,其实形参的入栈顺序也一样是倒过来的。然后程序将double_value作为printf的形参入栈中,并将其要显示的字符串地址也放入栈中,然后将返回地址放入栈中,准备调用printf函数。这在里发现库函数的调用机制和普通函数是一样的,所以我们在这里所作的一切探究也同样适用于所有的c语言函数,包括库函数。而且在这里插一句,哪怕c程序中嵌入了汇编语句,这一切都不受影响。 到此为止,我们明白了c语言函数调用时的栈机制。这会对我们理解linux代码多少有些帮助。也能明白为什么有时我们明明没有看到在调用函数之前为其准备调用参数,但在函数中的参数却依旧可以获得的原因。其实只要在调用函数之前将其参数按照即定顺序放在栈内,那么函数调用就会顺利进行。因为函数并不在乎你是否为其准备好参数,它在运行时只会到其约定的位置去取它想要的参数。比如在linux0.11 中(2.6版本中也有)fork函数在运行时会用到寄存器参数,但查看代码时你不会看到明显的为fork函数准备参数的过程,是因为在此之前的int调用就将所有的寄存器数值均已入栈,而在那之后并没有对栈进行改变,而且在0.11中,这部分代码是汇编语句,也不存在栈帧的概念,所以当fork函数运行时按照约定就可以取到所有参数。当然,对于main这个入口函数的argc及argv两个参数是例外的,属于程序的运行环境部分管理的。 对于argc及argv两个参数的问题,有的c语言教程上会说如果你的main函数不想处理运行参数,那么最好要将main函数定义为int main(void),强制告知编译器你的main函数没有参数。但通过笔者的实验,发现二者编译后得到的汇编语句并没有不同。而且通过命令行给显性通知没有参数的程序加上运行参数,程序不会有任何问题,只是你在程序中不能显性地使用这些通过命令行传递的参数罢了。所以说是否为main函数加上void参数说明并不重要。因为这两个参数是放在栈外的。哪怕你声明了void,但在程序运行时给予了参数,则这些参数同样也会被放在栈外的环境变量内存区的。

Ps:本以为是挺简单的一件事情,却花了一整天的时间才准备到目前这个样子。也不知说清楚没有。看来想要阐明一件事情真的不太容易。最初打算这篇文章不仅要讲c函数的栈调用机制,而且要将linux0.11中相关的一部分代码也同时分析一下。这才不违背这个主题。但看来只好分成两部分来说了。呵呵。

原文地址:https://www.cnblogs.com/mqmelon/p/4763913.html