重温C语言 - 编译连接与函数

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程序有一系列模块组成,一个模块又由一系列函数组成, 然后程序执行,按代码的结构调用这些函数,完成功能。那么函数调用的背后编译器到底为我们做了什么呢?

 
 
  1. void fun(int a, int b)
  2. {
  3. int c = 300;
  4. c += 1;
  5. }
  6. int main()
  7. {
  8. fun(100, 200);
  9. return 0;
  10. }

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

 
 
  1. .file "demo.c"
  2. .text
  3. .globl fun
  4. .type fun, @function
  5. fun: ;fun函数入口
  6. pushl %ebp ;保存调用前ebp
  7. movl %esp, %ebp ;ebp 取代esp的作用,指向函数调用准备完毕时的栈顶(函数调用栈顶结构为 参数,返回地址,暂存的ebp)
  8. subl $4, %esp ;用esp来扩展栈空间,相当于为具备变量分配内存
  9. movl $300, -4(%ebp) ;通过ebp加偏移来间接寻址完成局部变量访问
  10. leal -4(%ebp), %eax
  11. incl (%eax)
  12. leave ;相当与 movl ebp, esp pop ebp, 这样保证函数离开前销毁所有的栈空间,并且复原ebp,esp指向调用时入栈的返回地址
  13. ret ;pop eip ,函数返回
  14. .size fun, .-fun
  15. .globl main
  16. .type main, @function
  17. main: ;main函数入口
  18. pushl %ebp
  19. movl %esp, %ebp
  20. subl $8, %esp
  21. andl $-16, %esp
  22. movl $0, %eax
  23. addl $15, %eax
  24. addl $15, %eax
  25. shrl $4, %eax
  26. sall $4, %eax
  27. subl %eax, %esp
  28. pushl $200 ;参数入栈
  29. pushl $100 ;参数入栈
  30. call fun ;调用fun函数
  31. addl $8, %esp
  32. movl $0, %eax
  33. leave
  34. ret
  35. .size main, .-main
  36. .section .note.GNU-stack,"",@progbits
  37. .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值),这样可以更 安全的销毁栈。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值