本篇文章是组内分享的小结,主要介绍源代码 -> 可执行程序 -> 执行这一过程。也就是源代码是如何转化为可执行程序,然后可执行程序又是如何执行的。在用java或python时,只需要java ClsName或者python a.py就可以执行相应的程序,实际上它们都是依托于底层的虚拟机。本文主要介绍的是操作系统级别的连接、加载、执行等,而不是虚拟机语言的执行。这里只对链接、加载进行一个简介,详细内容推荐大家去看《深入理解计算机系统》和《程序员的自我修养》,第二本要比第一本讲的更加详细,但稍显啰嗦,如果只是了解建议阅读第一本的第七章。
先看两个示例程序,后续会以它们为例:
[cpp] view plaincopyprint?
- // foo.c
- #include <stdio.h>
- int a = 10;
- int b;
- void bar(int c);
- int
- main(){
- bar(a);
- printf("...");
- }
[cpp] view plaincopyprint?
- // bar.c
- void bar(int c){
- // ...
- }
通常c程序是由多个模块组成的,每个模块对应一个c文件,会被编译成可连接目标文件,然后由连接器将所有的模块组合成一个可执行程序。可以通过下面命令完成编译动作:
[plain] view plaincopyprint?
- > gcc -c foo.c
编译之后当前目录会生成foo.o,就是对应的可连接目标文件。实际上由源代码转化成目标文件是由多个步骤组成的:
预处理(cpp):完成宏替换、文件引入,以及去除空行、注释等为词法分析准备。
编译(cc):将预处理后的代码编译成汇编代码,由于加入了汇编器这一层,隔离了底层硬件的不同实现,提高了移植性。
汇编(as):将汇编代码转化成机器码,也就是01序列。
我们知道,一个程序是由代码和数据组成的,目标文件必须以某种方式组织这些信息,以便链接器和加载器从文件中去识别相应的信息。在Linux下,目标文件的格式是ELF(Executable Linkable Format),可以用来描述可链接目标文件、可执行目标文件盒共享目标文件。下面就来看看可链接目标文件中主要包含什么内容。目标文件以节(Section)组织数据,同时具有一个节头部表(Section Header)用来描述所有的节。主要的节包括:
.data:已初始化的全局变量和静态局局变量。foo.c中的全局变量a就是存在.data节中。
.bss:未初始化的全局变量和静态局部变量,这个节在载入内存时会被清0,所以未初始化的全局变量和静态局部变量默认值是0。foo.c中的全局变量b存在.bss节。
.text:编译后的机器代码。所有的函数编译后的二进制代码会存在.text节中,比如main函数。
.string:用来存储目标文件中用到的字符串以及字符串常量。
.symtab:符号表。符号就是目标文件中的全局变量和函数,符号表描述目标文件中的所有符号,这个是链接器进行链接的基础。符号分为:
导入符号:当前模块引用其他模块中定义的符号,比如:在foo.c中使用的bar.c中定义的bar函数,那么foo.o的符号表就包含导入符号bar。
导出符号:就是当前模块定义的符号,可以被其他模块引用。这些导出符号就是模块中定义的初始化的全局变量和非静态函数。
目标文件中其实还有很多个节,这里只介绍上面几个主要的节。
多个c文件分别编译成可链接的目标文件后,要生成可执行文件那么还需要进行链接。链接就是解决多个模块的引用和库调用,然后进行重定位以便生成可执行文件。链接过程最重要的就是符号解析,就是将模块中的导入符号找到其定义的地方,然后将符号替换为指针。
在链接时,符号可以分为强符号和弱符号:
强符号:就是初始化的全局变量和非静态函数。比如,foo.c中的全局变量a和函数main以及bar.c中的函数bar。
弱符号:未初始化的全局变量。比如,foo.c中的全局变量b。
链接时,如果遇到重名的强符号(比如在foo.c和bar.c中都定义了int a = 1;),会报错“duplicated symbols”,具体名称记不清了。如果遇到重名的弱符号,链接的行为取决于具体实现,这里不再深入讨论。
链接器需要把多个可链接目标文件组合形成一个可执行目标文件,它会收集各个模块中相同类型的节然后组成可执行文件的对应的节,比如:收集foo.o和bar.o的.data节,然后合并在一起组成可执行文件的.data节。链接器还需要完成重定位,因为在合并节时,原来模块节的地址会改变,所以重定位就是修改模块中指针的地址。
完成连接之后,在磁盘上就会生成可执行目标文件。要执行一个程序时,必须要把可执行目标文件载入内存。我们知道,进程是程序执行的容器,每个运行的程序都有自己的内存地址空间,需要将可执行目标文件中数据和代码节载入到进程的地址空间。下面看一下进程的地址空间:
每个进程都有自己私有的虚拟内存地址空间,在32bit机器上,地址空间的大小是4GB,高地址的1GB的内存空间被映射为内核空间,用于提供内核服务。用户栈就是函数调用栈用于实现函数调用,在栈上为局部变量分配空间。共享库用于实现类似C标准库的代码和数据。堆用于动态内存分配。剩下的数据区和代码区是与可执行文件相关的,需要从磁盘加载。
加载器载入可执行目标文件时需要虚拟存储器的支持,通过mmap的文件映射方式将可执行目标文件中的.data节和.bass节映射到进程地址空间中的数据区,将.text节映射到代码区。栈和堆采用的是mmap的匿名映射,也就是没有提供文件参数。地址空间中的每个被占用的区域就是一个VMA(Virtual Memory Area),这些VMA会通过链表和红黑树组织起来,采用链表是为了便于顺序遍历,红黑树是为了根据地址快速检索到对应的VMA。当我们通过一个地址p访问内存时,os会进行地址合法性检查,第一必须保证p包含在某个VMA中;第二对于每个VMA都有一个读、写和执行的权限,进程必须具备相应的权限才能执行操作。如果不满足上述两点,就会抛出“Segment Fault”错误。
在完成映射之后,接下来开始执行程序,首先执行的是_start函数这个是属于glibc的库函数,完成程序的初始化,为程序的运行准备,接下来就会调用main函数,这是通过符号表完成main函数入口的定位。经过mmap实际上文件并没有载入内存,当第一次访问时会从磁盘加载对应的内容,这是由虚拟存储器机制完成的,对进程是透明的。
用户栈中的元素就是一个个函数调用对应的栈帧,当前栈帧有CPU寄存器%esp和%ebp标识,%esp是栈指针指向栈顶,%ebp是帧指针,在%esp和%ebp之间的区域就是当前函数对应的栈帧。栈帧存放的是函数的实参已经局部变量。
运行时堆是动态内存分配的区域,指针sbrk指向堆顶,可以通过改变sbrk进行动态内存的分配和释放。C标准库中的malloc和free底层就是基于sbrk指针,当然我们也可以通过sbrk实现内存分配器,不过还要设计自己的内存分配算法,通常建议还是使用标准库进行内存分配。
上面就是小组分享的全部内容,由于完全裸讲,没有充分准备,想到哪讲到哪,所以难免会有纰漏,见谅啊~~