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\n",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\n"
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中相关的一部分代码也同时分析一下。这才不违背这个主题。但看来只好分成两部分来说了。呵呵。

时间: 2024-10-11 10:35:03

C语言栈调用机制初探的相关文章

java反射机制初探

反射,reflection,听其名就像照镜子一样,可以看见自己也可以看见别人的每一部分.在java语言中这是一个很重要的特性.下面是来自sun公司官网关于反射的介绍: Reflection is a feature in the Java programming language. It allows an executing Java program to examine or "introspect" upon itself, and manipulate internal pro

java语言实现跨平台机制的原因

JVM(java虚拟机的发展史): (1)Sun Classic classic jvm要么采用纯解释器解释执行,要么采用JIT编译执行,一旦JIT进行编译执行,则解释器不再生效 如果使用JIT编译代码,则JIT会对每个方法,每行代码都进行编译,对于那种只需运行一次,不具有编译价值的代码,也会被JIT编译执行.迫于程序响应时间的压力,此阶段的JIT不敢采用编译耗时的优化技术,所以及时采用JIT输出本地代码,他的执行效率也和C代码有很大差距.被人诟病"java语言很慢" (2)Exact

浅析Go语言的Interface机制

前几日一朋友在学GO,问了我一些interface机制的问题.试着解释发现自己也不是太清楚,所以今天下午特意查了资料和阅读GO的源码(基于go1.4),整理出了此文.如果有错误的地方还望指正. GO语言的interface是我比较喜欢的特性之一.interface与struct之间可以相互转换,struct不需要像JAVA在源码中显示说明实现了某个接口,可以通过约定的形式,隐式的转换到interface,还可以在运行时查询接口类型,这样有种用动态语言写代码的感觉,但是又可以在编译时进行检查,捕捉

C语言中调用Lua

C语言和Lua天生有两大隔阂: 一.C语言是静态数据类型,Lua是动态数据类型 二.C语言需要程序员管理内存,Lua自动管理内存 为了跨越世俗走到一起,肯定需要解决方案. 解决第一点看上去比较容易,C语言中有union. 可是第二点呢?万一C语言正引用着Lua的一个值,Lua自动释放了怎么办? 所以就有了一种比union更好的解决方案:栈. 这是一个虚拟的栈,是沟通两者的桥梁,两者的数据交换全都通过这个栈进行,这样只要不pop,Lua就不会自动释放,而什么时候pop由C语言说了算. 下面是一段喜

java之jvm学习笔记六-十二(实践写自己的安全管理器)(jar包的代码认证和签名) (实践对jar包的代码签名) (策略文件)(策略和保护域) (访问控制器) (访问控制器的栈校验机制) (jvm基本结构)

java之jvm学习笔记六(实践写自己的安全管理器) 安全管理器SecurityManager里设计的内容实在是非常的庞大,它的核心方法就是checkPerssiom这个方法里又调用 AccessController的checkPerssiom方法,访问控制器AccessController的栈检查机制又遍历整个 PerssiomCollection来判断具体拥有什么权限一旦发现栈中一个权限不允许的时候抛出异常否则简单的返回,这个过程实际上比我的描述要复杂 得多,这里我只是简单的一句带过,因为这

android内核剖析系列---JNI调用机制分析

为什么需要JNI? android这个庞大的系统从下到上主要由linux内核,C/C++库,java应用程序框架,java应用程序组成.这就涉及到一个问题,C/C++库如何与java应用有交集,或者说能相互调用,要解决这个问题,就需要JNI登场了. JNI调用机制分析 JNI--java native interface,翻译成中文是java本地接口,所谓的"本地"是指C/C++库一层的C/C++语言(以下统称C).

Android系统篇之----解读AMS远端服务调用机制以及Activity的启动流程

一.为何本文不介绍Hook系统的AMS服务 在之前一篇文章中已经讲解了 Android中Hook系统服务,以及拦截具体方法的功能了,按照流程本文应该介绍如何Hook系统的AMS服务拦截应用的启动流程操作,但是本文并不会,因为在介绍这个知识点之前,还有一件大事要做,那就是得先分析一下Android中应用的启动流程,如果这个流程不搞清楚的话,后面没办法Hook的,因为你都找不到Hook点,当然Hook代理对象倒是很容易获得,如果没有Hook点,是没办法后续的操作的,所以得先把流程分析清楚了,当然现在

Android系统篇之----Binder机制和远程服务调用机制分析

一.前景概要 最近要实现Android中免注册Activity就可以运行的问题,那么结果是搞定了,就是可以不用在AndroidManifest.xml中声明这个Activity即可运行,主要是通过骗取系统,偷龙转凤技术的,这个知识点后面会详细讲解的,因为在研究了这个问题过程中遇到了很多知识点,当然最重要也是最根本的就是Android中的Binder机制和远程服务调用机制,而关于Binder机制的话,在Android中算是一个非常大的系统架构模块了,光这篇文章是肯定不能讲解到全部的,而且本人也不是

Java记录 -83- Java语言的反射机制

Java语言的反射机制 在Java运行时环境中,对于任意一个类,能否知道这个类有哪些属性和方法?对于任意一个对象,能否调用它的任意一个方法?答案是肯定的.这种动态获取类的信息以及动态调用对象的方法的功能来自于Java语言的反射(Reflection)机制. Java反射机制主要提供了以下功能: 1. 在运行时判断任意一个对象所属的类: 2. 在运行时构造任意一个类的对象: 3. 在运行时判断任意一个类所具有的成员变量和方法: 4. 在运行时调用任意一个对象的方法: Reflection是java