From:http://blog.chinaunix.net/uid-22327815-id-3540305.html
从 Hello World 说程序运行机制:http://www.sohu.com/a/132798003_505868
C/C++ 中如何在 main() 函数之前执行一条语句?:https://www.zhihu.com/question/26031933
(深入理解计算机系统) bss段,data段、text段、堆(heap)和栈(stack):https://www.cnblogs.com/yanghong-hnu/p/4705755.html
深入理解 GOT表 和 PLT表:https://zhuanlan.zhihu.com/p/130271689
深入理解 Linux 可执行程序:https://zhuanlan.zhihu.com/p/533635132
动态库、可执行文件符号表分析:https://blog.51cto.com/u_13291771/2798456
可执行文件概述:https://yubincloud.github.io/notebook/pages/de3669/
1. 前言
C 语言算是大学里接触的最早,用的最"多"的语言了,对于大部分学习计算机的学生基本上是从开始学习C语言起,凭借着一句经典的 "hello, world!" 迈入了计算机的世界的,体验了一把这个世界还有个叫编程的活。作为系统级的开发首选语言,自诞生以来就屹立不倒,C语言的重要性是不言而喻的。做为一个菜鸟级别的程序员,使用C有些年,但对于C没有有真正的了解。我想有必要从新了解这门古老的语言背后的东西,知其然还要知其所以然,才能更好的使用这门语言。
对于C语言编写的 Hello World 程序(如下),对于程序员来说肯定如雷贯耳,就是这样一个简单的程序,你真的了解她吗?
#include <stdio.h>
int main()
{ printf("Hello World\n") return 0;
}
对于下面这些问题,你脑子里能够马上反映出一个清晰、明显的答案吗?
- 程序为什么要被编译器编译之后才可以运行?
- 编译器在把C语言程序转换成可以执行的机器码的过程中做了什么?怎么做的?
- 最后编译出来的可执行文件里面是什么?除了机器码还有什么?他们怎么存放的?怎么组织的?
- #include <stdio.h> 是什么意思?把 stdio.h 包含进来意味着什么?C语言库又是什么?它怎么实现的?
- 不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM),以及不同的操作系统(Windows、Linux、UNIX、Solaris),最终编译出来的结果一样吗?为什么?
- Hello World 程序是怎么运行起来的?操作系统是怎么装载它的?他从哪里开始执行?到哪儿结束?main函数之前发生了什么?main函数结束之后又发生了什么?
- 如果没有操作系统,Hello World 可以运行吗?如果要在一台没有操作系统的机器上运行 Hello World 需要什么?应该怎么实现?
- printf 是怎么实现的?他为什么可以有不定数量的参数?为什么它能够在终端上输出字符串?
- Hello World 程序在运行时,它在内存中是什么样子的?
可执行文件从装载到运行的全过程
源代码通过预处理,编译,汇编,链接后形成可执行文件,)那么当我们在cmd窗口敲出指令$test argv1 argv2\n 后,操作系统是怎么将我们的可执行文件加载并运行的呢?
首先知道,计算机的操作系统的启动程序是写死在硬件上的,每次计算机上电时,都将自动加载启动程序,之后的每一个程序,每一个应用,都是不断的 fork 出来的新进程。那么我们的可执行文件,以linux 系统为例,也是由shell 进程 fork 出一个新进程,在新进程中调用exec函数装载我们的可执行文件并执行。
1. execve()
当shell中敲入执行程序的指令之后,shell进程获取到敲入的指令,并执行execve()函数,该函数的参数是敲入的可执行文件名和形参,还有就是环境变量信息。execve()函数对进程栈进行初始化,即压栈环境变量值,并压栈传入的参数值,最后压栈可执行文件名。初始化完成后调用 sys_execve()
2. sys_execve()
该函数进行一些参数的检查与复制,而后调用 do_execve()
3. do_execve()
该函数在当前路径与环境变量的路径中寻找给定的可执行文件名,找到文件后读取该文件的前128字节。读取这128个字节的目的是为了判断文件的格式,每个文件的开头几个字节都是魔数,可以用来判断文件类型。读取了前128字节的文件头部后,将调用 search_binary_handle()
4. search_binary_handle()
该函数将去搜索和匹配合适的可执行文件装载处理程序。Linux 中所有被支持的可执行文件格式都有相应的装在处理程序。以Linux 中的ELF 文件为例,接下来将会调用elf 文件的处理程序:load_elf_binary()
5. load_elf_binary()
该函数执行以下三个步骤:
a)创建虚拟地址空间:实际上指的是建立从虚拟地址空间到物理内存的映射函数所需要的相应的数据结构。(即创建一个空的页表)
b)读取可执行文件的文件头,建立可执行文件到虚拟地址空间之间的映射关系
c)将CPU指令寄存器设置为可执行文件入口(虚拟空间中的一个地址)
load_elf_binary()函数执行完毕,事实上装载函数执行完毕后,可执行文件真正的指令和数据都没有被装入内存中,只是建立了可执行文件与虚拟内存之间的映射关系,以及分配了一个空的页表,用来存储虚拟内存与物理内存之间的映射关系。
6. 程序返回到execve()中
此时从内核态返回到用户态,且寄存器的地址被设置为了ELF 的入口地址,于是新的程序开始启动,发现程序入口对应的页面并没有加载(因为初始时是空页面),则此时引发一个缺页错误,操作系统根据可执行文件和虚拟内存之间的映射关系,在磁盘上找到缺的页,并申请物理内存,将其加载到物理内存中,并在页表中填入该虚拟内存页与物理内存页之间的映射关系。之后程序正常运行,直至结束后回到shell 父进程中,结束回到 shell。
2. C 程序编译流程
编译一个 C程序可以分为四阶段:预处理阶段 ---> 生成汇编代码阶段 ---> 汇编阶段 ---> 链接阶段。
这里以 linux 环境下 gcc 编译器为例。直接使用 gcc 时,默认会直接完成以上四个步骤然后生成可以执行的程序,但通过编译选项可以控制值进行某些阶段,查看中间的文件。
gcc 指令的一般格式为(man gcc 或者 gcc -h 查看更过选项帮助):
gcc [选项] 要编译的文件 [选项] [目标文件]其中,目标文件可缺省,gcc默认生成可执行的文件名为:a.outgcc main.c 直接生成可执行文件 a.outgcc -E main.c -o hello.i 生成预处理后的代码(还是文本文件)gcc –S main.c -o hello.s 生成汇编代码gcc –c main.c -o hello.o 生成目标代码
C程序 目标文件和可执行文件 结构
目标文件和可执行文件可以有几种不同的格式,有ELF(Excutable and linking Format,可执行文件和链接)格式,也有COFF(Common Object-File Format,普通目标文件格式)。
虽然格式不一样,但具有一个共同的概念,那就是 段(segments),这里段指二进制格式文件中的一块区域。
linux下的可执行文件有三个段:( 可用 nm 命令查看目标文件的符号清单 )
- 文本段(text)
- 数据段(data)
- bss段
编译过程
源文件 -----> 到可执行文件。图引自《C专家编程》
注意:
- BSS段,并没有保存未初始化段的映像,只是记录了该段的大小(应为该段没有初值,不管具体值),到了运行时再到内存为未初始化变量分配空间,这样可以节省目标文件空间。
- data段,只是保存在目标文件中,运行时直接载入。
C程序的内存布局
讲C语言内存管理的书籍或者博客?:https://www.zhihu.com/question/29922211
- readelf命令: http://man.linuxde.net/readelf
- 面试官问我:bss 段的大小记录在哪里?:http://bbs.csdn.net/topics/390613528
- 内存区划分、内存分配、常量存储区、堆、栈、自由存储区、全局区:http://www.cnblogs.com/CBDoctor/archive/2011/12/24/2300624.html
- 常量存在内存中的那里?:http://bbs.csdn.net/topics/390510503
运行过程
可执行文件 -----> 内存空间
不管是在 Linux 下 C 程序还是 Windows 下 C 程序,他们都是由 text段、data段、BSS段、堆(heap)、栈(stack) 等段构成的,只不过可能他们的各段分配地址不一样。
Linux下的C程序正文段在低地址,而Windows下的C程序的正文段(代码段)在高地址。
所以不用担心 Linux环境和Windows环境测试时得到不正确的数据。
图示 1:
图示 2:
C语言 存储空间 的布局
C语言一直由下面部分组成:
- 正文段(code segment/text segment,.text段):或称 代码段,通常是用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。CPU执行的机器指令部分。( 存放函数体的二进制代码 。)
- 只读数据段(RO data,.rodata):只读数据段是程序使用的一些不会被改变的数据,使用这些数据的方式类似查表式的操作,由于这些变量不需要修改,因此只需放在只读存储器中。
- 已初始化读写数据段(data segment,.data段):通常是用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。常量字符串就是放在这里的,程序结束后由系统释放(rodata—read only data)。已初始化读写数据段(RW data,.data):已初始化数据是在程序中声明,并且具有初值的变量,这些变量需要占用存储器空间,在程序执行时它们需要位于可读写的内存区域,并具有初值,以供程序读写。
*只读数据段 和数据段统称为 数据段 - BSS段(bss segment,.bss段):未初始化数据段(BSS,.bss)通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS是英文Block Started by Symbol的简称。BSS段属于静态内存分配。全局变量 和 静态变量 的存储是放在一块的。初始化的全局变量和静态变量在一块区域(.rwdata or .data),未初始化的全局变量和未初始化的静态变量在相邻的另一块区域(.bss), 程序结束后由系统释放。未初始化数据是在程序中声明,但是不具有初值的变量,这些变量在程序运行之前不需要占用存储空间。读取BSS段大小,然后在内存中紧跟data段之后分配空间,并且清零(这也是为什么全局表量和static局部变量不初始化会有0值得原因)
* 在 C++中,已经不再严格区分 bss 和 data了,它们共享一块内存区域
* 静态存储区包括bbs段和data段 -
堆(heap):堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆上被剔除(堆被缩减)。一般由程序员分配释放(new/malloc/calloc delete/free),若程序员不释放,程序结束时可能由 OS 回收。注意:它与数据结构中的堆是两回事,但分配方式倒类似于链表
- 栈(stack):栈又称堆栈,是用户存放程序临时创建的局部变量,也就是我们函数大括号"{}"中定义的变量(不包括static声明的变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且等调用结束后,函数的返回值也会被存放在回栈中。由于栈的先进先出特性,所有栈特别方便用来保存/恢复调用现场。从这个意义上讲,把堆栈看成一个寄存、交换临时数据的内存区。由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈
上图是目标文件的典型结构,实际的情况可能会有所差别,但都是在这个基础上衍生出来的。
- ELF文件头:即上图中的第一个段。其中的 header 是目标文件的头部,里面包含了这个目标文件的一些基本信息。如该文件的版本、目标机器型号、程序入口地址等等。
- 文本段:里面的数据主要是程序中的代码部分。
- 数据段:程序中的数据部分,比如说变量。
- 重定位段:重定位段包括了文本重定位和数据重定位,里面包含了重定位信息。一般来说,代码中都会存在引用了外部的函数,或者变量的情况。既然是引用,那么这些函数、变量并没存在该目标文件内。在使用他们的时候, 就要给出他们的实际地址(这个过程发生在链接的时候)。正是这些重定位表,提供了寻找这些实际地址的信息。理解了上面之后,文本重定位和数据重定位也就不难理解了。
- 符号表:符号表包含了源代码中所有的符号信息 。 包括每个变量名、函数名等等。里面记录了每个符号的信息,比如说代码中有 “student” 这个符号,对应的在符号表中就包括这个符号的信息。包括这个符号所在的段、它的属性(读写权限)等相关信息。其实符号表最初的来源可以说是在编译的词法分析阶段。在做词法分析的时候,就把代码中的每个符号及其属性都记录在符号表中。
- 字符串表:和符号表差不多的功能,存放了一些字符串信息。
实际中的目标文件会比这个模型要复杂些,但是它的思路都是一样的,就是按照类型来存储,再加上一些描述目标文件信息的段和链接中需要的信息。
详细验证可以参看 (C语言存储空间布局以及static详解:http://blog.csdn.net/thanksgining/article/details/41960369)
程序 和 目标 的对应关系
使用 readelf 和 objdump 解析目标文件(http://www.jianshu.com/p/863b279c941e)
int a = 0; // a 在 data
char *p1; // p1 在 bssmain()
{int b; // b 在 stackchar s[] = "abc"; // s 在 stack, abc\0 在常量区char *p2; // p2 在 stackchar *p3 = "123456"; // p3 在 stack, 123456\0 在常量区static int c = 0; // c 在 datap1 = (char *)malloc(10); // 申请的10字节内存在 heap, bss中的指针指向heap中的内存p2 = (char *)malloc(20); // 申请的20字节内存在 heap, stack中的指针指向heap中的内存strcpy(p1, "123456"); // 123456\0 在常量区,编译器可能会将它与 p3 所指向的 "123456\0" 优化成一块
}
堆 和 栈 的区别
- 管理方式:对于栈来讲,是由编译器自动管理;对于堆来说,释放工作由程序员控制,容易产生 memory leak。
- 空间大小:一般来讲在 32 位系统下,堆内存可以达到接近 4G 的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,例如,在 VC6 下面,默认的栈空间大小大约是 1M。
- 碎片问题:对于堆来讲,频繁的new/delete 势必会造成内存空间的不连续,从而造成大量碎片,使程序效率降低;对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,永远都不可能有一个内存块从栈中间弹出。
- 生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。
- 分配方式:堆都是动态分配的,没有静态分配的堆;栈有 2 种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配,动态分配由 alloca 函数进行分配,但是栈的动态分配和堆是不同的,它的动态分配是由编译器进行释放,不需要我们手工实现。
- 分配效率:栈是机器系统提供的数据结构,计算机会在底层分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高; 堆则是 C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,然后进行返回。显然,堆的效率比栈要低得多。
- 无论是堆还是栈,都要防止越界现象的发生。
关于 Global 和 Static 类型的一点讨论
1. static 全局变量 与 普通的全局变量 有什么区别 ?
全局变量(外部变量)的定义前面再加上 static 就构成了静态的全局变量。
全局变量本身就是静态存储方式, 静态全局变量当然也是静态存储方式。 这两者在存储方式上并无不同。
这两者的区别在于非静态全局变量的作用域是整个源程序, 当一个源程序由多个源文件组成时,非静态的全局变量在各个源文件中都是有效的。 而静态全局变量则限制了其作用域, 即只在定义该变量的源文件内有效, 在同一源程序的其它源文件中不能使用它。
由于静态全局变量的作用域局限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其它源文件中引起错误。
static 全局变量只初使化一次,防止在其他文件单元中被引用。
2. static 局部变量 和 普通局部变量 有什么区别 ?
把局部变量改变为静态变量后是改变了它的存储方式,即改变了它的生存期。把全局变量改变为静态变量后是改变了它的作用域,限制了它的使用范围。
static 局部变量只被初始化一次,下一次依据上一次结果值。
3. static 函数 与 普通函数 有什么区别?
static 函数 与 普通函数 作用域不同:
- static函数 仅在本文件有效,只在当前源文件中使用的函数应该说明为内部函数(static),内部函数应该在当前源文件中说明和定义。
- 普通函数 可在当前源文件以外使用。应该在一个头文件中说明,要使用这些函数的源文件要包含这个头文件。
- static 函数 在内存中只有一份(.data),普通函数 在每个被调用中维持一份拷贝。
函数调用栈
作为面向过程的语言,C基本的特色就是模块化、过程化。一个C程序或一个模块由一堆函数组成,然后程序执行,按代码的结构调用这些函数,完成功能。那么函数调用的背后编译器到底为我们做了什么呢?
void fun(int a, double b)
{int c = 300;c += 1;
}
int main()
{fun(100, 200);return 0;
}.globl _fun ;全局函数符号.def _fun;
_fun: ;函数fun入口pushl %ebp ;保存ebp值movl %esp, %ebp ;采用ebp来访问栈顶subl $4, %esp ;esp用来扩展堆栈分配局部变量空间movl $300, -4(%ebp) ;局部变量赋值leal -4(%ebp), %eax ;得到局部变量有效地址incl (%eax) ;访问局部变量leave ;相当于movl ebp, esp pop ebp ret.globl _main.def _main;
_main: ;main函数入口;....movl $200, 4(%esp) ; 参数入栈 movl $100, (%esp) ; 参数入栈call _fun;.....
函数调用过程
- 参数 按 从右到左 顺序放到栈顶上
- call 调用,将返回地址 ip 入栈保存
- 在栈上分配 局部变量 空间
- 执行函数操作
可以使用 OD 动态调试,就可以明白。。。
函数返回过程
- ret 会从栈上弹出返回地址
- 执行调用前后面的代码
由此得的结论是,函数调用一个动态的过程,调用的时候又有一个栈帧,调用的时候展开,结束的时候收缩。局部变量在运行到该函数的时候在栈上分配内存,这些内存实际上没有名字的,不同于数据段,有符号名字,局部变量在函数结束就销毁了。这也是什么局部变量同名互补干涉的原因,因为编译以后 ,根本就不是通过名字来访问的。
全局变量
全局变量有 初始化 或 未初始化 之分:
- 初始化了的全局变量保存在data段,
- 未初始化全局变量保存在BSS段,
data段 和 BSS段 都是程序的 数据段
int global1 = 100;
int main()
{global1 = 101;extern int global2;global2 = 201;return 0;
}
int global2 = 200;.globl _global1 ;全局符号global1.data ;位于数据段.align 4
_global1:.long 100 ;全局变量初值;.....
.globl _main ;全局符号main .def _main; ;是一个函数
_main: ;函数入口;...movl $101, _global1 ;通过符号访问全局变量movl $201, _global2 ;通过符号访问全局变量,这个变量还未定义movl $0, %eaxleaveret
.globl _global2 :全局符号golbal2.data ;位于数据段.align 4
_global2: ;全局变量的定义,初始化值.long 200 int global1;
int main()
{global1 = 101;extern int global2;global2 = 201;return 0;
}
int global2;.globl _main.def _main;
_main:;....movl $101, _global1 ;通过符号访问全局变量,这个符号可以在之后,或其他文件中定义movl $201, _global2movl $0, %eaxleaveret.comm _global1, 16 # 4 ;标明这是个未初始化全局变量,声明多个,但最后运行时在bss段分配空间.comm _global2, 16 # 4
可以得出结论:全局变量独立于函数存在,所有函数都可以通过符号访问,并且在运行期,其地址不变。
编译 与 链接
看下面这个程序链接出错,找不符号a、print,但生成汇编代码并没有问题。这是因为编译的时候只是把符号地址记录下来,等到链接的时候该符号定义了才会变成具体的地址。
如果链接的时候所有符号地址都有定义,那么生成可执行文件。如果有不确定地址的符号,则链接出错。
#include<stdio.h>
int main()
{extern int a ;print("a = %d\n", a);return 0;
}.file "fun.c".def ___main; .section .rdata,"dr"
LC0:.ascii "a = %d\12\0".text
.globl _main.def _main; .
_main:;..movl _a, %eax ;通过符号访问全局变量amovl %eax, 4(%esp)movl $LC0, (%esp)call _print ;通过符号访问函数printmovl $0, %eaxleaveret.def _print; ;说明print是个函数符号
全局变量 的 链接属性
全局变量的默认是 extern 的,最终存放在 数据段,整个程序的所有文件都能访问,如果加上 static 则表明值能被 当前文件访问。
#include<stdio.h>
static int a = 10;
int main()
{a = 20;return 0;
}.data.align 4
_a: ;全局变量a定义,少了glbal的声明.long 10.def ___main; .text
.globl _main.def _main;
_main:; ...movl $20, _amovl $0, %eax去掉int a前面的static产生的汇编代码为:.globl _a ; global声明符号 a为全局.data.align 4
_a:.long 10.def ___main.text
.globl _main.def _main
_main:;...call __allocacall ___mainmovl $20, _amovl $0, %eax对于未初始化全局变量
#include<stdio.h>
static int a;
int main()
{a = 20;return 0;
}.globl _main.def _main; .scl 2; .type 32; .endef
_main:;..movl $20, _amovl $0, %eaxleaveret.lcomm _a,16 ; 多了个l表明是local的未初始化全局变量去掉int a前面的static
.globl _main.def _main; .scl 2; .type 32; .endef
_main:;..movl $20, _amovl $0, %eaxleaveret.comm _a, 16 # 4 ;extern链接属性的未初始化全局变量
static 局部变量
static 局部变量具备外部变量的生存期,但作用域却和局部变量一样,离开函数就能访问
#include<stdio.h>
int fun()
{static int a = 10;return (++a);
}
int main()
{printf("a = %d\n",fun());printf("a = %d\n",fun());
}.data.align 4
a.0: ;static局部变量是放在代码段.long 10 ;分配空间初始化.text
.globl _fun.def _fun;
_fun:pushl %ebpmovl %esp, %ebpincl a.0movl a.0, %eaxpopl %ebpret.def ___main; .section .rdata,"dr"
编译实际还是还是把 static 局部变量放在数据段存储(要么怎么可能在程序运行期间地址不变呢),只不过符号名会动点手脚(这样出了函数就访问不了了)。多个函数中定义同名的 static 局部变量,实际上是不同的内存单元,互补干涉了。
3. a.out 剖析
a.out 是目标文件的默认名字。也就是说,当编译一个文件的时候,如果不对编译后的目标文件重命名,编译后就会产生一个名字为 a.out 的文件。具体的为什么会用这个名字这里就不在深究了。有兴趣的可以自己 google。我们现在就来研究一下 hello world 编译后形成的目标文件,这里用 C 来描述。
简单的 hellow world 源码:
/*hello.c*/
#include<stdio.h>
int main()
{int a=5;printf("hello world n");return 0;
}
为了能看清楚内部到底是如何处理的,我们使用 GCC 来编译。运行:gcc hello.c 再看我们的目录下,就多了目标文件 a.out。
现在我们想做的是看看 a.out 里到底有什么,可能有童鞋回想到用 vim 文本查看,当时我也是这么天真的认为。但 a.out 是何等东西,怎能这么简单就暴露出来呢 。是的,vim不行。“我们遇到的问题大多是前人就已经遇到并且已经解决的”,对,其中有一个很强悍的工具叫做 objdump。有了它,我们就能彻底的去了解目标文件的各种细节,当然还有一个叫做 readelf 也很有用,这个在后面介绍。这两个工具一般Linux 里面都会自带有有,可以自行 google
注:这里的代码主要是在 Linux 下用 GCC 编译,查看目标文件用的是 objdump、readelf
下面是 a.out 的组织结构:(每段的起始地址、、大小等等)。查看目标文件的命令是 objdump -h a.out
每一行都被分成了 6 列。从左到右:
- 第一列(Idx Name)是段的名字,
- 第二列(Size)是大小 ,
- 第三列 VMA 为 虚拟地址,
- 第四列 LMA 为 物理地址,
- 第五列 File off 是文件内的偏移。也就是这段相对于段中某一参考(一般是段起始)的距离。
- 第六列 Algn 是对段属性的说明,暂时不用理会
段:
- text 段:代码段。
- data 段:也就是上面说的数据段,保存了源代码中的数据,一般是以初始化的数据。
- bss 段:也是数据段,存放那些未初始化的数据,因为这些数据还未分配空间,所以单独存放。
- rodata 段:只读数据段,里面存放的数据是只读的。
- cmment 存放的是编译器版本信息。
剩下的两段对我们的讨论没有实际意义,就不再介绍。认为他们包含了一些链接、编译、装在的信息就可。
注:这里的目标文件格式只是列出实际情况中主要部分。实际情况还有一些表未列出。如果你也在用Linux,可以用objdump -X 列出更详细的段内容。
查看 a.out 的 段
上面通过实例说了目标文件中的典型的段,主要是段的信息,如:大小 等相关的属性。那么这些段里面究竟有些什么东西呢?"text段" 里到底存了什么东西?
还是用 objdump 来查看下。objdump -s a.out 通过 -s 选项就可以查看目标文件的十六进制格式。
查看结果如下:
如上图所示,列出了各段的十六进制表示形式。可以看出图中共分为两栏,
- 最左边:十六进制地址,
- 中间:十六进制数据
- 最右边:十六进制数据 对应的 ASCII 码
比较明显的如 "rodata" 只读数据段中就有 "hello world"。
“comment”上文中说的这个段包含了一些编译器的版本信息,这个段后面的内容就是了:GCC编译器,后面的是版本号。
a.out 反汇编
编译的过程总是先把源文先变为汇编形式,再翻译为机器语言。下面看下 a.out 的汇编
objdump -d a.out 可以列出文件的汇编:
不过这里只列出了主要部分,即 main 函数部分,其实在 main 函数执行的开始和 main 函数执行以后,都还有多工作要做。即初始化函数执行环境以及释放函数占用的空间等。
a.out 头文件
在介绍目标文件格式的时候,提到过头文件这个概念,里面包含了这个目标文件的一些基本信息。如该文件的版本、目标机器型号、程序入口地址等等。
下图是文件头的形式: readelf -h a.out
( 图中查看的是 hello.o,它是源文件 hello.c 编译但未链接的文件。 这个和查看 a.out 大部分是一样的 )
图中分为两栏,
- 左边一栏表示的是属性,
- 右边是属性值。
第一行常被称为魔数。后面是一连串的数字,其中的具体含义就不多说了,可以自己去 google。
对链接的简单认识
链接通俗的说就是把几个可执行文件进行组装。
如果程序A中引用了文件B中定义的函数,为了A中的函数能正常执行,就需要把B中的函数部分也放在A的源代码中,那么将A和B合并成一个文件的过程就是链接了。
有专门的过程用来链接程序,称为链接器。他将一些输入的目标文件加工后合成一个输出文件。这些目标文件中往往有相互的数据、函数引用。
上面的 hello world 的反汇编形式,是一个还没有经过链接的文件,也就是说当引用外部函数的时候是不知道其地址的,如下图:
上图中,cal指令就是调用了printf()函数,因为这时候 printf() 函数并不在这个文件中,所以无法确定它的地址,在十六进制中就用“ff ff ff ”来表示它的地址。等经过链接以后,这个地址就会变为函数的实际地址,应为连接后这个函数已经被加载进入这个文件中了。
链接的分类:按把A相关的数据或函数合并为一个文件的先后可以把链接分为静态链接和动态链接。
静态链接:
在程序执行之前就完成链接工作。也就是等链接完成后文件才能执行。但是这有一个明显的缺点,比如说库函数。如果文件A 和文件B 都需要用到某个库函数,链接完成后他们连接后的文件中都有这个库函数。当A和B同时执行时,内存中就存在该库函数的两份拷贝,这无疑浪费了存储空间。当规模扩大的时候,这种浪费尤为明显。静态链接还有不容易升级等缺点。为了解决这些问题,现在的很多程序都用动态链接。
动态链接:
和静态链接不一样,动态链接是在程序执行的时候才进行链接。也就是当程序加载执行的时候。还是上面的例子 ,如果A和B都用到了库函数Fun(),A和B执行的时候内存中就只需要有Fun()的一个拷贝。
对装载的简单解释
我们知道,程序要运行是必然要把程序加载到内存中的。
- 在过去的机器里都是把整个程序都加载进入物理内存中,
- 现在一般都采用了虚拟存储机制,即每个进程都有完整的地址空间,给人的感觉好像每个进程都能使用完成的内存。然后由一个内存管理器把虚拟地址映射到实际的物理内存地址。
按照上文的叙述, 程序的地址可以分为 虚拟地址 和 实际地址。
- 虚拟地址 即她在她的虚拟内存空间中的地址,
- 物理地址 就是她被加载的实际地址。
在上文中查看段 的时候或许你已经注意到了,由于文件是未链接、未加载的,所以每个段的虚拟地址和物理地址都是0。
加载的过程可以这样理解:先为程序中的各部分分配好虚拟地址,然后再建立虚拟地址到物理地址的映射。其实关键的部分就是虚拟地址到物理地址的映射过程。程序装在完成之后,cpu的程序计数器pc就指向文件中的代码起始位置,然后程序就按顺序执行。
4. 预处理 ( gcc -E )
预编译:主要处理那些源代码文件中的以 # 开始的预编译指令,如 #include、#define、#if,同时并删除注释行,还会添加行号和文件名标识,以便于编译时编译器产生调试用的行号信息,及用于编译时产生编译错误或警告时能够显示行号。
经过预编译的 .i 文件不包含任何宏定义,因为所有的宏已经被展开并且包含的文件也已经被插入到 .i 文件中。
所以当我们无法判断 宏定义是否正确 或 头文件包含是否正确 时,可以查看已编译后的文件来确认问题。比如:hello.c 中第一行的 #include<stdio.h>命令告诉预处理器读取系统头文件 stdio.h 的内容,并且把它直接插入到程序文本中,结果就得到了另一个C程序,通常是以 .i 作为文件扩展名。在该阶段,编译器将 C 源代码中的包含的头文件如 stdio.h 编译进来,用户可以使用 gcc 的选项 -E 进行查看。
用法:#gcc -E main.c -o main.i
作用:将main.c预处理输出main.i文件[user:test] ls
main.c
[user:test] gcc -E main.c -o main.i
[user:test] ls
main.c main.i
使用 gcc -E 参数完成。
预处理会干什么事情:
- 展开所有的宏定义并删除 #define
- 处理所有的条件编译指令,例如 #if #else #endif #ifndef …
- 把所有的 #include 替换为头文件实际内容,递归进行
- 把所有的注释 // 和 / / 替换为空格
- 添加行号和文件名标识以供编译器使用
- 保留所有的 #pragma 指令,因为编译器要使用
- ……
处理完成之后看看我们的 Hello.i,发现原来8行代码现在变成了接近700行,因为将 <stdio.h> 的文件被替换进来了,在最后几行找到了我们自己 Hello.c 的代码:
使用系统默认的预处理器 cpp 完成。
预处理除了使用 GCC -E 参数完成之外,我们还可以使用系统默认的预处理器 cpp 完成。如下所示
我们看看Hello.ii的代码:
虽然 Hello.i 和 Hello.ii 的代码对应的行数不同,但是内容却是一模一样的,只是中间空行的数量不同而已。
OK ,接下来,继续向编译出发。
5. 编译 ( 源文件 转换成 汇编代码 )
gcc -S
编译是将 源文件 转换成 汇编代码 的过程,具体的步骤主要有:词法分析 ---> 语法分析 ---> 语义分析及相关的优化 ---> 中间代码生成 ---> 目标代码生成(汇编文件.s)。
具体生成过程可以参考《编译原理》。在这个阶段中,gcc 首先要检查代码的规范性、是否有语法错误等,以确定代码的实际要做的工作,在检查无误后,gcc 把代码翻译成汇编语言。
用户可以使用 -S 选项来进行查看,该选项只进行编译而不进行汇编,生成汇编代码。
选项 -S
用法:[user]# gcc –S main.i –o main.s
作用:将预处理输出文件main.i汇编成main.s文件。[user:test] ls
main.c main.i
[user:test] gcc -S main.i -o main.s
[user:test] ls
main.c main.i main.s
注意:gcc 命令只是一个后台程序的包装,会根据不同的参数要求去调用预编译编译程序cc1(c)、汇编器 as、连接器 ld。
使用 gcc -S 参数完成。
查看 Hello.s 发现已经是汇编代码了。
使用系统默认的编译器 cc1 完成这个过程。
前面的预处理命令 cpp
可能大家的系统上都有,我们输入cp
,然后 Tab
两下(Linux系统上表示提示补全命令),系统提示如下:
倒数第二个命令就是 cpp
了。但是我们 cc
同样的过程的时候却发现:
并没有 cc1
这个命令,但是 cc1
确实是 Linux
系统上默认的编译器呀,我们在系统上找找看:
看上图第二条,/usr/libexec/gcc/x86_64-redhat-linux/4.8.2/cc1
,尝试着去看下:
有可执行权限,那为何不试试能不能用来编译 Hello.ii
呢?
好像没有什么报错,迫不及待的看看 Hello.ss
的内容:
发现和 Hello.s
的是一样的。编译成功。Goto 汇编。
6. 汇编
汇编阶段是把编译阶段生成的 ”.s” 文件转成二进制目标代码。汇编器(as)将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它的字节编码是机器语言指令而不是字符。如果我们在文本编译器中打开 hello.o 文件,看到的将是一堆乱码。
选项 -c
用法:[user]# gcc -c main.s -o main.o
作用:将汇编输出文件main.s编译输出main.o文件。[user:test] ls
main.c main.i main.s
[user:test] gcc -c main.s -o main.o
[user:test] ls
main.c main.i main.o main.s
使用 gcc -c 参数完成。
其实也可以查看下 Hello.o 的内容:
只是乱码罢了。要是想看,我们可以使用 hexedit, readelf 和 objdump 这三个工具。
hexedit 只是个将二进制文件用十六进制打开的工具,我们执行:
$ sudo yum install hexedit
$ hexedit Hello.o
可以看到:
最右边是源文件被翻译成可见字符,点.表示的都是不可见字符。这样看当然没有多大实际意义,但是一些输出的字符串 Hello World,包括整个文件的类型 ELF 都是可以看到的。
readelf 和 objdump 我们后面再说。
使用系统默认的汇编器as完成。
hexedit 看看 :
使用 cmp 命令比较 Hello.oo 和 Hello.o
只有极少数字符不同。可能也是格式问题。
总结:上面的过程中,我们已经将 Hello.c 源程序经过预处理、编译、汇编阶段变成了二进制代码,这三个过程我们都是用两种方法完成的,一种是 GCC + 参数的方法,另一种是使用系统默认的预处理器,编译器,汇编器。这两种方法都达到了我们的目的,最后给它加上x权限。然后运行
chmod a+x a.out
./a.out
7. 链接
这阶段就是把汇编后的机器指令集变成可以直接运行的文件,而对目标文件进行链接主要是因为在目标文件中可能用到了在其他文件当中定义的字段(或者函数),通过链接来把多个不同目标文件关联到一起。
比如:有2个目标文件 a 和 b,在 b 中定义了一个函数 "method",而在文件 a 中则使用到了b文件中的函数 "method",通过链接文件a才能调用到函数"method",不然文件a根本就不知道到函数 "method" 底做了些什么操作。
hello 程序调用了一个 printf 函数,它是每个 C 编译器都会提供的标准C库中的一个函数,printf 函数存在于一个名为 printf.o 的单独预编译好了的标准文件中,而这个文件必须以某种方式合并到我们的 hello.o 程序中,链接器(ld)就负责处理这种合并,结果就得到 hello 文件,他是一个可执行目标文件(简称:可执行文件),可以被加载到内存中,有系统执行。
gcc的无选项的编译就是链接
用法:[user]# gcc main.o -o main.elf
作用:将编译输出文件main.o链接成最终可执行文件main.elf[user:test] ls
main.c main.i main.o main.s
[user:test] gcc main.o -o main.elf
[user:test] ls
main.c main.elf* main.i main.o main.s
模块之间的通信有两种方式:一种是模块间的函数调用,另一种是模块间的变量访问。函数访问需知道目标函数的地址,变量访问也需要知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是模块间符号的引用。模块间依靠符号来通信类似于拼图版,定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,两者一拼接刚好完美组合。这个模块的拼接过程就是“链接”。
在链接中,函数和变量统称为符号(symbol),函数名或变量名就是符号名(symbol name)。可以将符号看做是链接中的粘合剂,整个链接过程正是基于符号才能够正确完成。链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表(symbol table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(symbol value),对于变量和函数来说,符号值就是它们的地址。符号表中所有的符号分类:
- 1、定义在本目标文件的全局符号,可以被其他目标文件引用。
- 2、在本目标文件中引用的全局符号,却没有定义在本目标文件,这一般叫做外部符号(external symbol),比如printf。
- 3、段名,这种符号往往由编译器产生,它的值就是该段的起始地址,比如“.text”、“.data”。
- 4、局部符号,这类符号只在编译单元内部可见。这些局部符号对于链接过程没有作用,链接器往往忽略它们。
- 5、行号信息,即目标文件指令与源代码中代码行的对应关系。
链接过程主要包括了地址和空间分配、符号决议和重定位。符号决议有时候也叫做符号绑定、名称绑定、名称决议,甚至还有叫做地址绑定、指令绑定,大体上它们的意思都一样,但从细节角度来区分,它们之间还存在一定区别,比如“决议”更倾向于静态链接,而“绑定”更倾向于动态链接,即它们所使用的范围不一样。
每个目标文件都可能定义一些符号,也可能引用到定义咋其他目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用重定位时,它就是要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。
更多可以参考 :《编译原理》、《程序员的自我修养》