C语言算是大学里接触的最早,用的最"多"的语言了,对于大部分学习计算机的学生基本上是从开始学习C语言起,凭借着一句经典的"hello, world!"迈入了计算机的世界的,初体味了一把这个世界还有个叫编程的活。作为系统级的开发首选语言,只诞生以来就屹立不倒,C语言的重要性是不言而喻的。就是怀着这种对C的无比敬意开始了我的伪程序之旅。然而大学里面没写过什么像样的东西,说来惭愧,什么课程设计,或是自称为项目的东西大都由些蹩脚的程序拼凑而成。做为一个菜鸟级别的程序员,使用C有些年,但对于C没有有真正的了解。我想有必要从新了解这门古老的语言背后的东西,知其然还要知其所以然,才能更好的使用这门语言。当然语言是工具,但了解工具的强项、陷阱与缺陷,对于工具威力的发挥 ,对于你去驾驭工具的娴熟程度是那是大有裨益啊。C语言的设计哲学就是给你一把锤子嘛, 用不好可是会砸自己的脚。

##一、C程序编译流程 编译一个C程序一般分为四阶段:
预处理阶段->生成汇编代码阶段->汇编阶段->链接阶段

具体过程如下图。这里以linux环境下gcc编译器为例,使用gcc时默认会直接完成这四个步骤生成可以执行的程序, 但通过编译选项可以控制值进行某些阶段,查看中间的文件。

gcc常用命令:
gcc main.c #直接生成可执行文件a.out
gcc -E main.c -o hello.i #生成预处理后的代码(还是文本文件)
gcc –S main.c -o hello.s #生成汇编代码
gcc –c main.c -o hello.o #生成目标代码
complier

##二、C程序目标文件和可执行文件结构 目标文件和可执行文件可以有几种不同的格式,有ELF(Excutable and linking Format,可执行文件和链接)格式, 也有COFF(Common Object-File Format,普通目标文件格式)。虽然格式不一样,但具有一个共同的概念,那就是段(segments), 这里段值二进制格式文件中的一块区域。linux下的可执行文件有三个段文本段(text)、数据段(data)、bss段, 可用nm命令查看目标文件的符号清单。

编译过程: C程序源文件——->可执行文件 src2exe 其中注意的BSS段,并没有保存未初始化段的映像,只是记录了该段的大小(应为该段没有初值,不管具体值), 到了运行时再到内存为未初始化变量分配空间,这样可以节省目标文件空间。对于data段,只是保存在目标文 件中,运行时直接载入。

##三、C程序的内存布局

对于编译好的可执行文件,运行的时候,程序载入内存,那么一个C程序,内存空间又是如果布局的呢?

运行过程: 可执行程序——–>内存空间 exe2mem 对于data段,保存的是初始化的全局变量和stataic的局部变量,直接载入内存即可。 text段保存的是代码直接 载入。BSS段从目标文件中读取BSS段大小,然后在内存中紧跟data段之后分配空间,并且清零(这也是为什么全 局表量和static局部变量不初始化会有0值得原因)

##四、函数调用过程 作为面向过程的语言,C最大的特色就是模块化、过程化。一个C程序有一系列模块组成,一个模块又由一系列函数组成, 然后程序执行,按代码的结构调用这些函数,完成功能。那么函数调用的背后编译器到底为我们做了什么呢?

void fun(int a,  int b)
{
    int c = 300;
    c += 1;
}

int main()
{
    fun(100, 200);
    return 0;
}

我们看看对应的汇编代码,我这里实验的平台是 CentOS 4.2 + gcc3.4.4

 .file     "demo.c"
     .text
.globl fun
     .type     fun, @function
fun:                                ;fun函数入口
     pushl     %ebp                 ;保存调用前ebp值
     movl     %esp, %ebp            ;ebp 取代esp的作用,指向函数调用准备完毕时的栈顶(函数调用栈顶结构为 参数,返回地址,暂存的ebp)
     subl     $4, %esp              ;用esp来扩展栈空间,相当于为具备变量分配内存
     movl     $300, -4(%ebp)        ;通过ebp加偏移来间接寻址完成局部变量访问
     leal     -4(%ebp), %eax
     incl     (%eax)
     leave                          ;相当与 movl ebp, esp 和 pop ebp, 这样保证函数离开前销毁所有的栈空间,并且复原ebp,esp指向调用时入栈的返回地址
     ret                            ;pop eip ,函数返回
     .size     fun, .-fun
.globl main
     .type     main, @function
main:                               ;main函数入口
     pushl     %ebp
     movl     %esp, %ebp
     subl     $8, %esp
     andl     $-16, %esp
     movl     $0, %eax
     addl     $15, %eax
     addl     $15, %eax
     shrl     $4, %eax
     sall     $4, %eax
     subl     %eax, %esp
     pushl     $200                 ;参数入栈
     pushl     $100                 ;参数入栈
     call     fun                   ;调用fun函数
     addl     $8, %esp
     movl     $0, %eax
     leave
     ret
     .size     main, .-main
     .section     .note.GNU-stack,"",@progbits
     .ident     "GCC: (GNU) 3.4.4 20050721 (Red Hat 3.4.4-2)"

结合汇编代码,我们可以画画函数调用过程的栈 stack 函数调用过程:

  • 1 参数按从右到左顺序放到栈顶上
  • 2 call调用,将返回地址ip入栈保存
  • 3 在栈上分配局部变量空间
  • 4 执行函数操作

函数返回过程:

  • 1 ret会从栈上弹出返回地址(这里ebp起了重要作用)
  • 2 ip改变执行调用前后面的代码

由此得的结论是,函数调用一个动态的过程,调用的时候有一个栈帧,调用的时候展开,结束的时候收缩。 局部变量在运行到该函数的时候在栈上分配内存,这些内存实际上没有名字的(通过ebp加偏移访问)不同 于数据段,有符号名字,局部变量在函数结束就销毁了。这也是什么局部变量同名互补干涉的原因,因为 编译以后 ,根本就不是通过名字来访问的。

这里还有个疑问就是为什么调用过程中用ebp取代esp起到栈顶的作用,原因是这样的:函数调用从开始到 结束,会伴随着大量的压栈和出栈操作,因为调用栈是一种临时结构,最后调用栈销毁,这必定要压栈和 出栈的次数要成对的,即总 的push 和pop此数是相同的,这样函数调用完成后才能回到正确的返回位置。 函数调用准备好以后,ebp不变,始终指向返回地址的上一个单元(暂存了原来的ebp值),这样可以更 安全的销毁栈。



-EOF-
Simple is beautiful博客,转载请注明出处