linux下helloworld程序从开始编译到运行结束过程详解

       本文旨在以hello world程序在ubuntu 16.04 x86_64机器上运行为例,详细讲述这个程序从编译、链接(包括动态链接和静态链接)到加载到运行这个过程中,工具链gcc,运行库glibc,内核,他们是怎么分工协作让这个程序顺利完成加载和运行的;

程序编译运行的过程

总体流程如下图:

以hello world程序为例,使用gcc  --verbose参数查看编译的详细过程如下:

其中cc1为gcc工具链的C编译器,预处理和编译都由该工具完成,as为汇编器,完成汇编工作,collect2为链接器ld的封装形式,最终负责链接收尾工作;

汇编详述

汇编阶段就是使用as汇编器简单的参照指令编码表将汇编代码翻译为机器指令,并将解析汇编文件中重要的部分形成辅助的信息结构并(符号,函数名等)以分节(section)的方式组织起来, 汇编之后的hello文件,使用命令readelf -S main.o 可以查看该文件的分节结构:

可以看到该汇编文件由12个段组成,其中.symtab为符号表,记录了该文件中所有全局符号以及函数名引用但是未定义的符号名等,.rela.text为代码重定位,这些段都将在链接阶段被使用用来构成最终的可执行文件;

链接详述

       链接是编译的最后一个阶段,链接主要是将多个文件(目标文件或者库文件)合并为可执行文件,并分配运行时地址,然后进行重定位工作,在进行链接的时候链接器使用静态库来解析引用的,在符号解析阶段,链接器从左到右按照他们在编译器驱动命令行上出现的相同顺序来扫描可重定位目标文件和库文件,链接器维持一个可重定位目标文件的集合E,一个未解析的符号集合U吗,一个全局已定义符号表D,在最初RUD都为空;
          对于命令行上的每个输入文件F,链接器都会判断F是一个目标文件还是一个库文件,如果F是一个目标文件,那么链接器把F添加到E中,再将F中的所有符号更新到UD,并继续解析下一个文件;
          如果输入文件为库文件,那么链接器就尝试匹配U中的未解析的符号,库文件由多个目标文件所组成,如果某个库文件的目标文件成员M,定义了一个符号来解析U中的一个引用,那么就将M加到E中,并且链接器将M成员的符号更新到U,D中,接着处理库文件中的下一个目标文件,如果该目标文件中的所有符号均未被引用,则丢弃该目标文件(不会被加到E中),接着链接器将处理下一个库文件(或者目标文件);
           直至链接器处理完所有的输入文件,U是非空的,链接器将报链接未定义的错误,否则,他会合并和重定位E中的目标文件,构建出可执行文件;
注意:由此可以看出 链接的时候库文件和目标文件的顺序非常重要,依赖项一般要放在命令行的最后面,当库文件互相依赖的时候则需要重复书写库文件,例如:gcc main.o add.a sub.a add.a

链接又分为动态链接和静态链接,基于动态链接的优势,一般linux 机器中默认都使用动态链接的方式进行链接工作,我们将分别进行叙述;

静态链接:使用命令gcc -static main.o -o main -v 查看helloworld程序的详细链接过程如下:

其中collect2 是链接器ld的封装形式,可以看到链接器最终会将这些目标文件crt1.o,crti.o,crtbegin.o,main.o,crtn.o,crtend.o 和库-lgcc,-lc,-lgcc_s(除非我们制定链接静态库,默认情况下-lxx执行的链接库都是动态库)中所必需的的成员目标文件链接成一个可执行文件main;

       正如上面所说的,链接器解析库文件之后将会获得该程序所必需的库文件的成员目标文件(.o文件)以及其他目标文件,我们知道目标文件是以各个节(section)组织起来的一个结构,将目标文件合并的过程就是将节进行合并,静态链接的过程就是把所有目标文件的相似节进行合并;

(该图线条只是画出了简要的步骤)到此我们可以总结静态链接的过程为:

       首先链接器将以本节最开始的算法扫描gcc命令行中所有.o文件以及库文件中必要的.o文件,将这些.o文件的相同节进行合并,然后进行重定位工作最终形成可执行文件,注意在最终形成可执行文件可以使用命令readelf -S mains查看共有32个段,相比原来目标文件main.o的12个要大得多,不仅仅是因为多个.o文件的合并,这个过程中可执行文件中还要生成一些段表结构,例如该可执行程序被装载时如何映射到内存的策略这些信息都以额外辅助节的形式存储在可执行文件中,这将指导并辅助该可执行程序正确的加载到内存并完成运行;

动态链接:使用命令gcc -static main.o -o main -v 查看helloworld程序的详细链接过程如下:

可以对比静态链接,动态链接和静态链接所链接的文件都基本一致,唯一不同的是动态链接必须要指定动态链接器的路径,如图中ld-linux-x86-64.so.2为该程序的动态连接器;动态链接的编译时链接(链接还分为编译时链接和运行时链接,静态链接只有编译时链接,而动态链接有编译时链接和运行时链接,与GCC相关的为编译时链接)过程比较简单;

动态链接的流程与静态链接较为相似,不同的地方在于:

  • 静态链接时在查找完所有目标文件与库文件的依赖之后会将所有相关目标文件进行合并,而动态链接只是合并所有.o文件,关于库文件的部分,会解析并生成相关的间接访问信息节结构存储在可执行文件中,一切都为最终运行时链接;
  • 动态链接中会生成许多动态链接相关的节,可使用readelf -S maind查看可执行文件的所有节,一般动态链接段的数量一定比静态链接多,因为动态链接多一个动态连接器相关的节;

动态链接的过程总结如下:

首先链接器将以本节最开始的算法扫描gcc命令行中所有.o文件以及库文件中必要的.o文件,将.o文件合并,如下图动态链接将只合图中看的见得.o文件,并不会合并例如-lc -lg++ 这些库文件中的.o文件,这里只将crti.o crt1.o crtn.o rtbegin.o crtend.o文件与main.o文件合并,对于依赖的其他库文件中的成员目标文件,将间接或者优化访问的方式以段的形式存储于可执行文件中,真正的链接被放在了装载运行时进行;

ps:在使用gcc指定参数-static的时候,其所链接的-lc -lgcc等库均为静态库,不适用-static参数的时候其使用为动态库,一般系统中会存在同一个库的静态和动态两种形式;

详述ELF文件映射到内存(装载运行阶段)

      文件被编译成可执行文件之后被以节的形式组织起来存储在硬盘中,此时我们执行./main之后装载流程会将该文件读取并依据该文件在编译时形成的各种节(里面存储了其映射的策略),然后依照计划映射到内存中;其中内存映射是以页为单位进行的(32位机为4k),也就是说不管合并之后段为多大,在映射到内存中的时候都会至少占据一个页面大小,这样势必会造成内存的浪费,为了使内存使用更有效率以及安全性考虑,在将目标文件段映射到内存的时候,将相同属性的节合并为一个段(segment),比如可执行可读的段.init .fina .text ...几个合并为一个段,合并的结果可以使用命令readelf -l main 查看,该合并的信息在编译时链接过程已经形成,被以节的形式存储在可执行文件中;如下图为静态链接映射

可以看到该文件最终被合并为6个段,其中前两个为load可加载类型,第一个段合并了.note.ABI-tag .init .plt .text ...等节,其都有可执行可读共同属性,第二个段合并了.data .got .got.plt .bss...等节,这两个段最终会被映射到内存中;这个过程如下图所示:

紧接着使用命令查看该进程的内存映射区域为:

可以看到mains对应的两个VMA都被映射到内存中,第一个为可读可执行属性,第二个为可读可写属性;在映射完成之后程序将初始化ELF进程环境,最后将系统调用的返回值修改为可执行程序的入口地址,可执行文件的装载过程到这里就结束了,可执行程序开始运行;

动态链接文件映射内存相比静态链接要复杂一些,动态库文件,也叫共享文件,因此动态链接时并没有把程序依赖的库文件合并进可执行文件,而是使用一些间接访问的策略,而在装载阶段依赖的库文件并没有装载进内存,这个过程最终由动态连接器来完成;动态链接段的映射如下图:

可以看到动态链接文件最终形成了8个段,其中包括动态链接器interp和dynamic段,而interp段就是动态连接器的路径,所以动态链接文件的映射不仅会映射可执行文件的段,还会将动态链接器也加载进内存,如下图中的/lib/x86_64-linux-gnu/ld-2.23.so;同样在映射完成之后,将系统调用的返回值修改动态链接器的入口地址,动态链接器开始执行;

注意:图中的libc-2.23.so库文件在elf文件加载过程中还没有被加载到内存,这个加载过程最终会由动态链接器来进行;

动态链接步骤 

动态链接的步骤分为3步骤,首先启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化;

       当可执行文件映射完毕,程序把最终控制权交给动态链接器的时候,动态链接器开始自举,动态链接器本身也是一个共享对象,对于普通的文件,他的重定位工作由动态链接器来完成,普通文件依赖的其他共享对象,也有动态链接器来链接和装载,而动态链接器本身也完成自己的重定位工作,首先动态链接器是静态链接的,所以他不依赖任何共享库,在没有完成重定位自己之前动态链接器中不能使用任何全局变量甚至函数,动态链接器自举完成之后就可以自由的访问函数和全局变量了,接着动态链接器寻找可执行文件的.dynamic段并找到该文件所依赖的库文件,然后读取该库文件的.dynamic继续寻找依赖库文件然后将相应的节都映射到(栈)内存空间,这个过程就是一个图的遍历,一般遵循广度优先搜索算法来遍历这个图;

这个过程就是加载libc-2.23.so运行库的过程,当依赖的共享对象都被加载进内存之后,动态链接器将开始最终的重定位和初始化工作,依然是依据可执行文件或者共享对象的重定位节来进行,将所有的文件重定位结束之后,动态链接器将程序的控制权交给可执行程序,可执行程序将开始运行;

        运行库部分相对比较简单,在这里就不详细描述了,这部分的知识是学习《程序员的自我修养》这本书梳理出来的,也算是总结吧,脉络要理清楚,明天就是2020年的第一天了,新年新气象,加油!!!

原创文章 7 获赞 3 访问量 954

猜你喜欢

转载自blog.csdn.net/w346665682/article/details/103675228
今日推荐