程序员的自我修养-链接装载与库笔记
花了近一个礼拜的时间算是把这本书看完了大部分的内容,因为工作接触的是linux有关windows的部分没有去看。个人觉得如果要做底层的话,这本书不得不看,看完之后虽然没有全部理解,但是对于我之前的知识体系结构有了很大的一个补充。现在就要记录下书中一些重难点,可以以后去回顾,将基础知识打扎实。为了协调I/O设备与总线之间的速度,也为了能够让CPU能够和I/O设备进行通信,一般每个设备都会有一个相
花了近一个礼拜的时间算是把这本书看完了大部分的内容,因为工作接触的是linux有关windows的部分没有去看。个人觉得如果要做底层的话,这本书不得不看,看完之后虽然没有全部理解,但是对于我之前的知识体系结构有了很大的一个补充。现在就要记录下书中一些重难点,可以以后去回顾,将基础知识打扎实。
为了协调I/O设备与总线之间的速度,也为了能够让CPU能够和I/O设备进行通信,一般每个设备都会有一个相应的I/O控制器。
CPU采用倍频的方式与系统总线进行通信。
为了协调CPU,内存和高速的图形设备,人们专门设计了一个高速的北桥芯片,以便他们之间能够高速地交换数据。
PC硬件模型是CPU,内存,以及I/O的基本结构。
理论上,增加CPU的数量就可以提高运算速度,并且理想情况下,速度的提高与CPU的数量成正比。
多核和SMP在缓存共享等方面有细微的差别,使得程序在优化上可以有针对性地处理。
除了硬件和应用程序,其他都是所谓的中间层,每个中间层都是对它下面的那层的包装和扩展,正是这些中间层的存在,使得应用程序和硬件之间保持相对的独立。
最近流行的虚拟机技术是在硬件和操作系统之间增加了一层的虚拟层,使得一个计算机上可以同时运行多个操作系统,这也是层次结构带来的好处,在尽可能少改变甚至不改变
其他层的情况下,新增加一个层次可以提供前所未有的功能。
从整个层次结构来看,开发工具和应用程序是属于同一个层次的,因为它们都使用一个接口,那就是操作系统应用程序编程接口。应用程序接口的提供者是运行库,什么样的运
行库提供什么样的API。
Linux使用0x80号中断作为系统调用接口,Windows使用0x2E号中断作为系统调用接口。
多任务系统:操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程的方式运行咋比操作系统权限更低的级别,每个进程都有自己
独立的地址空间,使得进程之间的地址空间相互隔离。
在Windows系统,系统硬件被抽象成了GDI,声音和多媒体设备设备被抽象成了DirectX对象,磁盘被抽象成了普通文件系统。
硬盘基本存储单位为扇区,每个扇区一般为512字节。
整个硬盘中所有的扇区从0开始编号,一直到最后一个扇区,这个扇区编号叫做逻辑扇区号。
程序在编写时,它访问数据和指令跳转时的目标地址很多都是固定的,这涉及程序的重定位问题。
分段对内存区域的映射还是按照程序为单位,如果内存不足,被换入换出到磁盘的都是整个程序,这样势必会造成大量的磁盘访问操作,从而严重影响速度,这种方法还是显得
粗糙,粒度比较大。
分页的基本方法是把地址空间人为分为固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。
在同一时刻只能选择一种大小,所以对整个系统来说,页就是固定大小的。
把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘中,把需要用到的时候再把它从磁盘里取出来即可。
当进程需要用到不在内存中的页时,硬件会捕获到这个消息,就是所谓的页错误,然后操作系统接管进程,负责将磁盘中读出来并且装入内存,然后将内存中的页建立映射关系。
一个标准的线程由线程ID,当前指令指针,寄存器集合和堆栈组成,通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段,数据段,堆等)以及一些进程级的资源(如打开文件和信号)。
相对于多进程应用,多线程在数据共享方面效率要高的多。
线程访问非常自由,它可以访问进程内存里的所有数据,甚至包括其他线程的堆栈但是实际运用中线程也拥有自己的私有存储空间。
不论是在多处理器的计算机上还是在单处理器的计算机上,线程总是“ 并发”执行的。
不断在处理器上切换不同的线程的行为称为线程调度。
处于运行中线程拥有一段可以执行的时间,这段时间称为时间片。
在优先级调度的环境下,线程的优先级一般由三种方式:1 用户指定优先级 2 根据进入等待状态的频繁程度提升或降低优先级 3长时间得不到执行而被提升优先级
在不可抢占的调度模型下,线程必须主动进入就绪状态,而不是靠时间片用尽来被强制进入。
Linux将所有的执行实都成为任务,,每一个任务在概念上都类似于一个单线程的进程,具有内存空间,执行实体,文件资源等。
fork和exec通常用于产生新任务,而如果要产生新线程,则可以使用clone
临界区是比互斥量更加严格的同步手段。
临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任何系统的任何进程里都是可见的,除此之外,临界区具有和互斥量相同的性质。
对于同一个锁,读写锁有两种获取方式,共享的或独占的。
一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。
一个函数要成为可重入的,必须有如下几个特点:
1 不使用任何静态或局部的非const变量
2 不返回任何静态或局部的非const变量的指针
3 仅依赖于调用方提供的函数
4 不依赖任何单个资源的锁
5 不调用任何不可重入函数
volatile基本可以做到两件事情:
1 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回
2 组织编译器调整操作volatile变量的指令顺序
CPU的乱序执行能力让我们对多线程的安全保障的努力变得异常困难。
用户态线程并不一定在操作系统内核里对应同等数量的内核线程。一个内核里的线程在用户态不一定有对应的线程存在。
预编译过程主要处理那些源代码文件中的以#开始的预编译指令。在该阶段会保留所有的#pragma编译器指令,因为编译器必须要使用它们。
现在的版本GCC把预编译和编译两个步骤合并成一个步骤,使用一个叫做cc1的程序来完成这两步骤。
实际上gcc这个命令只是后台程序的包装,它会根据不同的参数要求去调用预编译编译程序cc1,汇编器as,连接器ld。
编译过程一般分为6步,扫描,语法分析,语义分析,源代码分析,代码生成和目标代码优化。
lex程序可以实现词法扫描,会按照用户之前描述好的词法规则将输入的字符串分割成一个个记号。
词法分析器将对由扫描器产生的记号进行语法分析,从而产生语法树。整个分析过程采用了上下文无关语法的分析手段。词法分析也有一个现成的工具yacc(yet another compiler compiler)。
编译器所能分析的语义是静态语义,所谓静态语义是指在编译器可以确定的语义。
经过语义分析阶段以后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点。
中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对于一些可以跨平台的编译器而言,
他们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端。编译器后端主要包括代码生成器和目标代码优化器。
至今没有一个编译器能够完整支持C++语言标准所规定的所有语言特性。
定义其他模块的全局变量和函数在最终运行时的绝对地址都要在最终链接的时候才能确定。
重新计算各个目标的地址过程被叫做重定位。
符号这个概念随着汇编语言的普及迅速被使用,它用来表示一个地址,这个地址可以是一段子程序的起始地址,也可以是一个变量的起始地址。
基于符号的模块化的一个直接结果是链接过程在整个程序开发中变得十分重要和突出。
连接过程主要包括了地址和空间分配,符号决议和重定位等步骤。
最常见的库是运行库,它是支持程序运行的基本函数的集合。
重定位所做的就是给程序中每个这样的绝对地址引用的位置打补丁,使它们指向正确的地址。
目标文件从结构上讲,是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整,其实它本身就是按照可执行文件格式存储的。
当程序意外终止时,系统可以将该进程地址空间的内容及终止时的一些其他信息转储到核心转储文件(core dump file)。
COFF的主要贡献是在目标文件里面引入了“段”机制,不同的目标文件可以拥有不同数量及不同类型的“段”,另外它还定义了调试数据格式。
目标文件包括了链接时所需要的一些信息,比如符号表,调试信息,字符串等。一般目标文件将这些信息按不同的属性,以“节”section的形式存储,有时候也叫"段"segment.
ELF文件的开头是一个“文件头”,它描述了整个文件的文件属性,包括文件是否可执行,是静态链接还是动态链接及入口地址,目标硬件,目标操作系统等信息,文件头还包括
一个段表,段表其实是一个描述文件中各个段的数组。
总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。
当程序被装载以后,数据和指令分别被映射到两个虚拟区域。
现代CPU的缓存一般都能被设计成数据缓存和指令缓存分离。
objdump -h 表示把ELF文件的各个段的基本信息打印出来。-s 将所有段的内容以十六进制的方式打印出来 -d 将所有包含指令的段反汇编。
".rodata"段存放的是只读数据,一般是程序里面的只读变量(如const修饰的变量和字符串常量)。这样系统在加载的时候可以将“。rodata”段的属性映射成只读,这样对于
这个段的任何修改都会作为非法操作处理。
有些编译器会将全局的未初始化变量存放在目标文件的.bss段,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间.
size用来查看ELF文件的代码段,数据段和BSS段的数据长度。
一个ELF文件也可以拥有几个相同段名的段。
在全局变量或函数之前加上“__attribute__((section("name")))”属性就可以把相应的变量或函数放到以“name”作为段名的段中。
ELF文件中与段有关的重要结构是段表,该表描述了ELF文件包含的所有段的信息,比如每个段的段名,段的长度,在文件中的偏移,读写权限及段的其他属性。
ELF文件的文件头中定义了ELF魔数,文件机器字节长度,数据存储方式,版本,运行平台,ABI版本,ELF重定位类型,硬件平台,硬件平台版本,入口地址,程序头入口和长度,段表的位置和长度及段的数量。
魔数用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载。
ELF文件的段结构是由段表决定的,编译器,连接器和装载器都是依靠段表来定位和访问各个段的属性的。
事实上段的名字对于编译器,连接器是有意义的,但是对于操作系统来说并没有实质的意义,对于操作系统来说,一个段该如何处理取决于它的属性和权限,即由段的类型和段
的标志为这两个成员决定。
重定位的信息都记录在ELF文件的重定位表里,对于每个必须要重定位的代码段或数据段,都会有一个相应的重定位表。
链接过程的本质就是要把多个不同的目标文件之间相互粘在一起,实际上就是目标文件之间对地址的引用,即对函数和变量的地址的引用。
在链接中,将函数和变量统称为符号,函数名或变量名就是符号名。
整个链接过程是基于符号才能正确完成,每个目标文件都会有一个相应的符号表,这个表里记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值
。对于变量和函数来说,符号值就是他们的地址。
ELF文件中的符号表往往是文件中的一个段,段名叫做".symtab"。
只有使用ld链接器生产最终可执行文件的时候特殊符号才会存在。(__exectable_start, __etext, __edata, _end)。
GCC编译器可以通过参数选项"-fleading-underscore"或“-fno-leading-underscore”来打开和关闭是否在C语言符号前加上下划线。
函数签名包含了一个函数的信息,包括函数名,它的参数类型,它所在的类和名称空间及其他信息,函数签名用于识别不同的函数。
签名和名称修饰机制不光被使用到函数上,C++中的全局变量和静态变量也有同样的机制。名字修饰机制也被用来防止静态变量的名字冲突。
不同的编译器厂商的名称修饰方法可能不同,所以不同的编译器对于同一个函数签名可能对应不同的修饰后名称。由于不同的编译器采用不同的名字修饰方法,必然会导致由不
同编译器编译产生的目标文件无法正常相互链接,这是导致不同编译器之间不能互操作的主要原因之一。
对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。也可以通过GCC的"__attribute((weak))"来定义任何一个强符号为弱符号。注意强符号和弱符号都是针对定义来说的,不是针对符号的引用。
在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议,如果该符号未被定义,则链接器对于该引用不报错。
弱引用和弱符号主要用于库的连接过程。?????
可执行文件中的代码段和数据段都是由输入的目标文件中合并而来的。
“.bss”段在目标文件和可执行文件中并不占用文件的空间,但是它在装载时占用地址空间。所以连接器在合并各个段的同时,也将“.bss”段合并,并且分配虚拟空间。
链接器为目标文件分配地址和空间有两个含义:第一个是在输出的可执行文件中的空间;第二个是在装载后的虚拟地址中的虚拟地址空间。
整个链接过程分两步:1 空间与地址的分配 扫描所有的输入目标文件,获得他们的各个段的长度,属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算输出文件中各个段合并后的长度和位置,并建立映射关系。2 符号解析与重定位 使用上面第一步中收集的所有信息,读取输入文件中段的数据,重定位信息,并且进行符号解析与重定位,调整代码中的地址等。第二步是链接过程的核心,特别是重定位过程。
ld -e main 表示将main函数作为程序入口,ld链接器默认的程序入口为_start.
在第一步的扫描和空间分配阶段,链接器按照一定的空间分配方法进行分配,这时候输入文件中的各个段在链接后的虚拟地址就已经确定了。
事实上在ELF文件中,有一个叫重定位表的结构专门用来保存与重定位相关的信息,而一个重定位表往往是ELF文件中的一个段,所以其实重定位表也可以叫做重定位段。
从普通程序员的角度看,符号的解析占据了链接过程的主要内容。
重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器必须要对某个符号的引用进行重定位时,他就要确定这个符号的目标地址,这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到对应的符号后进行重定位。
绝对寻址修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址,相对寻址修正后的地址为符号距离被修正位置的地址差。
目前的链接器本身并不支持符号的类型,即变量类型对链接器来说是透明的。
在目标文件中,编译器为什么不直接把未初始化的全局变量也当作未初始化的局部静态变量一样处理,为它在BSS段分配空间,而是将其标记为一个COMMON类型的变量?因为当链接器读取所有输入目标文件以后,任何一个弱符号的最终大小都可以确定了,所以它可以在最终输出文件的BSS段为其分配空间。
C++的一些语言特性使之必须由编译器和链接器共同支持才能完成工作。
C++编译器在很多时候会产生重复的代码,比如模板,外部内联函数和虚函数表都有可能在不同的编译单元里生成相同的代码。
GCC的编译选项“-ffunction-sections”和"-fdata-sections",作用就是将每个函数或者变量分别保持到独立的段中。
如果要使两个编译器编译出来的目标文件能够互相链接,那么这两个目标文件必须满足下面的条件:采用同样的目标文件格式,拥有同样的符号修饰标准,变量的内存分布方式相同,函数的调用方式相同等等,也就是所谓的ABI要相同。
C++一直为人诟病的一大原因是它的二进制兼容性不好,或者说比起C语言来更为不易。
gcc -verbose 表示将整个编译链接过程的中间步骤打印出来。
GCC调用collect2程序来完成最后的链接。collect2可以看作是ld链接器的一个包装,它会调用ld链接器来完成对目标文件的链接,然后再对链接结果进行一些处理,主要是收集所有与初始化有关的信息并且构造初始化的结构。
GCC编译器提供了很多内置函数,它会把一些常用的C库函数替换成编译器的内置函数以达到优化的功能。
简单来讲,控制链接过程无非是控制输入段如何变成控制输出段,比如哪些输入段要合并一个输出段,哪些输入段要丢弃,指定要输出段的名字,装载地址,属性等等。
在默认情况下,ld链接器在产生可执行文件时会产生3个段。对于可执行文件来说,符号表和字符串表是可选的,但是段名字符串表为用户保存段名,所以他是必不可少的。
链接脚本由一系列语句组成,语句分两种,一种是命令语句,另外一种是赋值语句。
SECTIONS负责指定链接过程的段转换过程,这也是链接的最核心和最复杂的部分。
入口地址即进程执行的第一条用户空间的指令在进程地址空间的地址。
ld有很多种方法可以设置进程入口地址1 ld命令行的-e选项 2 链接脚本的ENTRY命令 3 如果定义了_start符号,使用_start符号值 4 如果存在.text段,使用.text段的第一个字节的地址 5使用值0
现在GCC(更具体的讲是GNU汇编器GAS),链接器ld,调试器GDB及binutils的其他工具都通过BFD(binary file descriptor library)库来处理目标文件,而不是直接操作目标文件。
每个程序被运行起来后,将拥有自己独立的虚拟地址空间,这个虚拟地址空间的大小有计算机的硬件平台决定,具体说是CPU的位数决定。
一般来说,C语言指针大小的位数与虚拟空间的位数相同。
从原则上讲,我们的进程最多可以使用3GB的虚拟空间,也就是说整个进程在执行的时候,所有代码数据包括C语言malloc等方法申请的虚拟空间之和不可以超过3GB。
将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里,就是动态装入的原理。
将内存和所有磁盘中的数据和指令按照页为单位划分成若干页,以后所有的装载和操作的单位就是页。
从操作系统来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,使得它有别于其他进程。
创建一个进程:1 创建一个独立的虚拟地址空间(一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建以一个虚拟空间实际上并不是创建空间而是很粗昂见映射函数所需要的相应的数据结构。在i386的linux下,创建虚拟地址空间实际上只是分配一个页目录就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置)
2× 读取可执行文件头,并建立虚拟空间与可执行文件的映射关系,这种映射关系只是保存在操作系统内部的一个数据结构,linux将进程虚拟空间中的一个段叫做虚拟内存区域(VMA),操作系统创建进程后,会在进程相应的数据结构中设置有一个.text段的VMA
3 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。上面的步骤执行完成之后,其实可执行文件的真正指令和数据都没有被装入内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已。
ELF文件被映射时,是以操作系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍
操作系统只关心一些跟装载有关的问题,最主要是段的权限(可读,可写,可执行)。
ELF可执行文件引入了一个概念叫做"segment",一个segment包含一个或多个属性类似的section。
从链接的角度看,ELF文件是按section存储的,从装载的角度看,ELF文件可以按照segment划分的。
正如描述section属性的结构叫做段表,描述segment的结构叫做程序头,它描述了ELF文件该如何被操作系统映射到进程的虚拟空间。
ELF可执行文件中有一个专门的数据结构叫做程序头表用来保存segment的信息,因为ELF文件不需要被装载,所以他没有程序头表,而ELF的可执行文件和共享库文件都有。
在操作系统里面,VMA除了被用来映射可执行文件中的各个segment以外,它还可以有其他的作用,操作系统通过使用VMA来对进程的地址空间进行管理。
很多情况下,一个进程中的栈和堆分别有对应的一个VMA。
可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位。
进程启动以后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给main函数。
linux系统装载ELF并且执行:1 在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve系统调用执行指定的ELF文件,进入execve系统调用之后,linux内核就开始进行真正的装载工作。
每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称为魔数,通过对魔数的判断可以确定文件的格式和类型。
对于静态链接诶ELF可执行文件,这个程序入口就是ELF文件的文件头中e_entry所指的地址,对于动态链接的ELF可执行文件,程序入口就是动态链接器。
动态链接的一个特点就是程序在运行时可以动态地选择加载各种程序模块。
动态链接涉及运行时的链接及多个文件的装载,必需要有操作系统的支持,因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理,内存共享,进程线程等机制在动态链接下也会有一些微妙的变化。
当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库装载到进程地址空间,并且将程序中所有为决议的符号绑定到相应的动态链接库中,并进行重定位工作。
ld-2.6.so实际上是linux下的动态链接器。动态链接器与普通共享对象一样被映射到了进程的地址空间,在系统运行程序之前,首先会把控制权交给动态链接器,由它完成所有的动态连接工作以后再把控制权交给程序,然后开始执行。
共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间的相应的共享对象。
共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。与此不同的是,可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的地址。
动态连接模块被装载映射至虚拟地址空间后,指令部分是在多个进程之间共享的,同时指令被重定位之后对于每个进程来讲是不同的,当然,动态链接库中的可修改数据部分对于不同的进程来说有多个副本,所以他们可以采用装载时重定位的方法来解决。
对于现代的系统来讲,模块内部的跳转,函数调用都可以是相对地址调用,或者基于寄存器的相对调用,所以对于这种指令是不需要重定位的。
一个模块前面一般是若干个页的代码,后面紧跟着若干页的数据,这些页之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。
其他模块的全局变量的地址是跟模块装载地址有关的。ELF的做法是在数据段里棉建立一个指向这些变量的指针数组,也被称为编剧偏移表(GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用,当然GOT中每个地址对应于哪个变量是由编译器决定的。
GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。
如何区分一个动态共享对象是否为PIC:“readelf -d *.so | grep TEXTREL”PIC的DSO是不会包含任何代码段重定位表的,TEXTREL表示代码段重定位表地址。
多进程共享全局变量被叫做“共享数据段”,而多个线程访问不同的全局变量副本叫做“线程私有存储(TLS)”。
对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表。
如果代码不是地址无关的,它就不能被多个进程之间共享,于是也就失去了节省内存的特点。但是装载时中定位的共享对象的运行速度要比使用地址无关代码的共享对象块,因为他省去了地址无关代码中每次要访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程。
动态链接的可执行文件中存在".got"这样的段。
动态连接比静态链接慢的主要云隐是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于吗模块键的嗲用也要先定位GOT,然后再进行间接跳转,如此一来,程序的运行速度必定会减慢。
ELF采用了一种叫做延迟绑定的做法,基本思想就是当函数第一次被用到时才进行绑定(符号查找,重定位等)如果没有用到则不进行绑定。所以程序开始执行时,模块键的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定。
ELF将GOT拆分成了两个表叫做".got"和".got.plt"。其中,“.got”用来保存全局变量引用的地址,“.got.plt”用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了".got.plt"中,另外“.got.plt”还有一个特殊的地方就是他的前三项是有特殊意义的。所以前三项是被系统占据的,从第四项开始才真正是存放导入函数地址的地方。
PLT在ELF文件中以独立的段存放,段名通常叫做".plt“,因为他本身是一些地址无关的代码,所以可以跟代码段等一起合并成一个可读可执行的"segment"被装入内存。
静态链接装载:操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的program header中读取每个segment的虚拟地址,文件地址和属性,并将他们映射到进程虚拟空间的对应位置。
在linux下,动态链接器ld.so实际上是一个共享对象,操作系统同样通过映射的方式将他加载到进程的地址空间中。操作系统在加载完动态链接之后,就将控制权交给动态链接器的入口地址(和可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权之后,他开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接完成工作以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
动态链接器的位置既不是由系统配置指定,也不是由环境参数决定,而是由ELF可执行文件决定。在动态链接的ELF可执行文件中,有一个专门的段叫做".interp"。
在linux中,操作系统在对可执行文件爱你的进行加载的时候,它回去寻找装载该可执行文件所需要相应的动态链接器,即".interp"段指定的路径的共享对象。
动态链接文件ELF中最重要的结构应该是".dynamic"段,这个段里面保存了动态链接器所需要的基本信息,比如依赖哪些共享对象,动态链接符号表的位置,动态链接器重定位表的位置,共享对象初始化代码的地址等,所以".dynamic"段可以看成是动态链接下ELF文件的文件头。
linux-gate.so.1不存在于文件西戎中,实际上是一个内核虚拟共享对象,涉及到linux的系统调用和内核。
很多时候动态链接的模块同时拥有".dynsym"和".symtab"两个表,".symtab"中往往保存了所有符号,包括".dynsym"中的符号。
为了加快符号的查找过程,往往还有辅助的符号哈希表".hash".
动态链接下,无论是可执行文件或共享对象,一旦它依赖于其他共享对象,也就说有导入的符号时,那么它的代码或数据中就会有对于导入符号的引用。
PIC模式的共享对象也需要重定位,对于使用PIC技术的可执行文件或共享对象来说,虽然他们的代码段不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用,因为代码段中绝对地址相关的部分被分离了出来,变成了GOT,而GOT实际上是数据段的一部分。
目标文件的重定位是在静态链接完成的,而共享对象的重定位是在装载时完成的。
共享对象的数据段是没有办法做到地址无关的,他可能会包含绝对地址的引用,对于这种绝对地址的引用,我们必须在装载时将其重定位。
动态链接基本上分为3步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化。
动态链接器本身不可以依赖于其他任何对象,其次是动态链接本身所需要的全局和静态变量的重定位工作由它本身完成。(动态链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作同时又不能用到全局和静态变量,这种具有一定限制条件的启动代码被称为自举)。
动态链接器入口地址即是子句代码的入口,档操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。自举代码首先会找到它自己的GOT。而GOT的第一个入口保存的就是“.dynamic”段的偏移地址,由此找到了动态链接器本身的".dynamic"段。通过".dynamic"中的信息,自举代码便可以获得动态连接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将他们全部重定位。从这一步开始,动态链接器代码中才可以开始使用自己的全局变量和静态变量。
完成基本的自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中。我们可以称他为全局符号表。然后链接器开始寻找可执行文件所依赖的共享对象。".dynamic"段中有一种类型的入口是DT_NEEDED,他所指出的是该可执行文件或共享对象所依赖的共享对象。由此,链接器可以列出可执行文件的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后连接诶其开始从结合中取一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和“.dynamic”段,然后将他想in个的代码段和数据段映射到进程空间中。如果这个ELF共享对象还依赖其他共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止。
“-XLinker -rpath”表示链接器在当前路径寻找共享对象。
当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
动态链接器是一个非常特殊的共享对象,他不仅是一个共享对象,还是个可执行程序,可以直接在命令行下运行。
共享库和可执行文件实际上没有上呢么区别,除了文件头的标志位和扩展名不同之外,其他都是一样的。
动态链接器本身是静态链接的,它不能依赖于其他的共享对象,动态链接器本身是用来帮助其他ELFwenjian解决共享对象依赖问题的。
一般的共享对象不需要进行任何修改就可以进行运行时装载,这种共享对象往往被叫做动态装载库。
动态库和共享对象之间的主要区别是共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都是由动态链接器自动完成的,对于程序本身是透明的,而动态库的装载是通过一系列由动态连接器提供的API,具体的讲有4个函数:打开动态库,查找符号,错误处理,以及关闭动态库,程序可以通过这几个API对动态库进行操作。
符号不仅仅是函数和变量,有时还是常量,比如表示编译单元文件名的符号等,这一般由编译器和链接器产生,而且对外是不可见,但他们的确存在于模块的符号表中。
共享库的ABI跟程序语言有着很大的关系,不同的语言对于接口的兼容性要求不同。
因为C++标准对于C++的ABI没有做出规定,所以不同的编译器甚至同一个编译器的不同版本对于C++的一些特性的实现都有着各自的方案,而且互不兼容,比如虚函数表,模块实例化,多重继承等等。
程序中必须包含被依赖的共享库的名字和主版本号,因为不同主板本号之间的共享库是完全不兼容的。
在linux系统中,系统会为每个共享库在他所在的目录创建一个跟“SO-NAME”相同的并且指向它的软连接。
建立以SO-NAME为名字的软链接目的是,使得所有依赖某个共享库的模块,在编译,链接和运行时,都使用共享库的SO-NAME,而不使用详细的版本号。
GCC允许使用一个叫做“.symver”的汇编宏指令来指定符号的版本,这个汇编指令可以被用在GAS汇编中,也可以在GCC的C/C++源代码中以嵌入汇编指令的模式使用。
在linux下,当我们使用ld链接一个共享库时,可以使用“--version-script”参数;如果使用GCC,则可以使用"-Xlinker"参数加“--version-script”,相当于把“--version-script”传递给链接器。
目前大多数包括Linux在内的开源操作系统都遵守一个叫做FHS的标准,这个标准规定了一个系统中的系统文件应该如何存放,包括各个目录的结构,组织和作用,这有助于促进各个开源操作系统之间的兼容性。
任何一个动态链接的模块所依赖的模块路径保存在“.dynamic”段里面。由DT_NEED类型的项表示。动态链接器对于模块的查找有一定的规则:如果DT_NEED里面保存的是绝对路径,那么动态链接器就按照这个路径去查找;如果DT_NEED里面保存的hi相对路径,那么动态链接器会在/lib,/usr/lib和/etc/ld.so.conf配置文件指定的目录中查找共享库。
linux系统中有一个叫做ldconfig的程序,这个程序的作用是为共享库目录下的各个共享库创建,删除或更新相应的SO-NAME(即相应的符号链接),这样每个共享库的SO-NAME就能够指向正确的共享库文件,并且这个程序还会将这些SO-NAME收集起来,集中存放到/etc/ld.so.cache文件里面,并建立一个SO-NAME的缓存。当动态链接器要查找共享库时,它可以直接从/etc/ld.so.config里面查找。而/etc/ld.so.cache的结构是经过特殊设计的、非常适合查找,所以这个设计大大加快了共享库的查找过程。
如果不适用-soname来指定共享库二等SO-NAME,那么该共享库默认就没有SO_NAME,即使用ldconfig更新SO-NAME二等软连接时,对该共享库也没有效果。
使用LD_LIBRARY_PATH可以指定共享库的查找路径,还可以使用链接器的"-rpath"选项指定链接产生的目标程序的共享库查找路径。
在共享模块中反向引用主模块中的符号时,只有那些在链接时被共享模块引用到的符号才会被导出。
ld链接器提供了一个"-export-dynamic"的参数,这个参数表示链接器在乘胜可执行文件时,将所有全局符号导出到动态符号表。
GCC提供了一种共享库的构造函数函数,只要在函数声明时加上"__attribute__((constructor))"的属性,即指定该函数为共享库构造函数,拥有这种属性的函数会在共享库加载时被执行,即在程序的main函数之前执行。但是如果使用了这种构造函数,那么必须使用系统默认的标准运行库和启动文件,即不可以使用GCC的"-nostartfiles"或"-nostdllib"这两个参数。
实际上,共享库还可以是符合一定格式的链接脚本文件。
在平坦的内存模型中,整个内存是一个同一的地址空间,用户可以使用一个32位的指针访问任意内存位置。
栈通常在用户空间的最高地址处分配,通常有数兆字节的大小。
在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多,可以有几十至数百兆字节的容量。
没有栈就没有函数,没有局部变量。
栈保存了一个函数调用所需要的维护的信息,这常常被称为堆栈帧或活动记录(函数的返回地址和参数,临时变量,保存的上下文)。
之所以要保存一些寄存器,在于编译器可能要求某些寄存器在调用前后保持不变,那么函数就可以在调用开始时将这些寄存器的值压入栈中,在结束后再取出。
GCC编译器有一个参数叫做-fnomit-frame-pointer可以取消帧指针,即不使用任何帧指针,但是这样就无法准确定位函数的调用轨迹。
在C语言里,存在着多个调用惯例,而默认的调用惯例是cdecl。任何一个没有显式指定调用惯例的函数都默认是cdecl惯例。
如果返回值类型的尺寸太大,C语言在函数返回时会使用一个临时的栈上内存区域作为中转,如果返回值对象会被拷贝两次。因而不到万不得已,不要轻易返回大尺寸的对象。
返回对象的拷贝情况完全不具备可移植性,不同的编译器产生的结果可能不同。
函数传递大尺寸的返回值所使用的方法并不是可移植的,不同的编译器,不同的平台,不同的调用惯例甚至不同的编译参数都有权利采用不同的实现方法。
系统调用的性能开销是很大的,当程序对堆的操作比较频繁时。这样做的结果是会严重影响程序的性能的。比较好的做法就是程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,而具体来讲,管理堆空间分配的往往是程序的运行库。
linux提供了两种堆空间分配的方式,即两个系统调用,一个是brk(),另外一个是mmap()。
brk()的作用实际上是设置进程数据段的结束地址,即它可以扩大或者缩小数据段(Linux下数据段和BSS合并在一起统称为数据段)。
mmap的作用是向操作系统申请一段虚拟地址空间,当然这块虚拟地址空间可以映射到某个文件,当他不讲地址空间映射到某个文件时,又称这块空间为匿名空间,匿名空间就可以拿来作为堆空间。他是系统虚拟空间申请函数,他申请的空间的其实地址和大小都必须是系统页大小的整数倍,对于字节数很小的请求如果也使用mmap的话,无疑会浪费大量的空间。
mmap申请匿名空间时,系统会为它在内存或交换空间中预留地址,但是申请的空间大小不能超出空闲内存空闲交换空间的综合。
堆的分配算法对于glibc来说,对于小于64字节的空间申请是采用类似于对象池的方法,而对于大于512字节的空间申请采用的是最佳适配算法,对于大于64字节而小于512字节的,会根据情况采取上述方法中的最佳折中策略;对于大于128KB的申请,会使用mmap机制直接向操作系统申请空间。
atexit接受一个函数指针作为参数,并保证在程序正常退出(指从main里返回或调用exit函数)时,这个函数指针指向的函数被调用。
程序的入口点实际上是一个程序二等初始化和结束的部分,他往往是运行库的一部分。
glibc的程序入口为_start(这个入口是由ld链接器默认的链接脚本所指定),_start由汇编实现,并且和平台相关。在调用_start前,装载器会把用户的参数和环境变量压入栈中。
环境变量是存在于系统中的一些公用数据,任何程序都可以访问。C语言可以使用getenv这个函数来获取环境变量信息。
_exit的作用仅仅是调用了exit这个系统调用。也就是说_exit调用后,进程就会直接结束。程序正常结束有两种情况,一种是main函数的正常退出,一种是程序中exit退出。在_libc_start_main里可以看到,即使main返回了,exit也会被调用。exit是进程正常退出的必经之路,因此把调用用atexit注册的函数的任务交给exit来完成可以说万无一失。
在linux里,进程必须使用exit系统调用结束。一旦exit被调用,程序的运行就会终止。因此实际上,exit末尾的hlt不会执行,从而_libc_start_main永远不会返回,以至于_start末尾的hlt指令也不会执行。_exit里的hlt指令是为了检测exit系统调用是否成功。
无论是Linux还是Windows,文件句柄总是和内核的文件对象相关联的,但如何关联细节用户并不可见。内核通过句柄来计算出内核里文件对象的地址,但此能力并不对用户开放。
在内核中,每一个进程都有一个私有的“代开文件表”,这个表是一个指针数组,每一个元素都只想一个内核的打开文件对象。而fd,就是这个表的下标。
首先I/O初始化函数需要在用户空间中建立stdin,stout,sterr及其对应的FILE结构,使得程序进入main之后可以直接使用printf,scanf等函数。
一个C语言运行库大致包含了如下功能:
1 启动和退出:包括入口函数及入口函数所以来的其他函数等 2标准函数:由C语言标准规定的C语言标准库所拥有的函数实现 3 I/O:I/O功能的封装和实现 4 堆:对的封装和实现 5 语言实现:语言中的一些特殊功能的实现 6调试
在GCC编译器下 ,变长参数宏可以使用"##"宏字符连接操作实现。
非局部跳转即使在C语言里也是一个备受争议的机制。使用非局部跳转,可以实现从一个函数体内向另一个事先登记过的函数体内跳转,而不用担心堆栈混乱。
运行库是平台相关的,因为它与操作系统化结合的分厂紧密。C语言的运行从某种程度上来讲是C语言的程序和不同操作系统之间的抽象层,它将不同的操作系统API抽象成相同的库函数。
glibc事实上是标准C语言运行库的超集,他们各自对C标准库进行了一些扩展。
crt0.o和crt1.o之间的区别是crt0.o为原始的,不支持".init"和".finit"的启动代码,而crt1.o是改进过的后的,支持".init"和".finit"的版本。
为了保证最终输出文件中的".init"和".finit"的正确性,我们必须保证在链接时,crti.o必须在用户目标文件和系统库之前,而crtn.o必须在用户目标文件和系统库之后。
GCC提供了两个参数"-nostaratfile"和"-nostdlib",分别用来取消默认的启动文件和C语言运行库。
我们可以使用"__attribute__((section("init")))"将函数放到.init段里面,但要注意的是普通函数放在".init"是会破坏他们的结构饿 ,因为函数的返回指令使得_init()函数会提前返回,必须使用汇编指令,不能让编译器产生"ret"指令。
C++这样的语言的实现是跟编译器密切相关的,而glibc知识一个C语言运行库,他对C++的实现并不了解,而GCC是C++的真正实现者,他对C++的全局构造和析构了如指掌。
线程的访问非常自由,他可以访问进程内存的所有数据,甚至包括其他线程的堆栈,但实际运用中线程也拥有自己的私有存储空间。
对于C/C++标准库来说,线程相关的部分是不属于标准库的内容的,他和网络,图形图像一样,属于标准库之外的系统相关库。、
在多线程版本的运行库中,线程不安全的函数内部都会自动地进行加锁,包括malloc。printf等,而异常处理的错误也早早就解决了。
一旦一个全局变量被定义成TLS类型的,那么每个线程都会拥有这个变量的副本,任何线程对该变量的修改都不会影响其他线程中该变量的副本。
对于每个Windows进程来说,系统都会建立一个关于线程信息的结构,叫做线程环境块,这个结构里面保存的是线程的堆栈地址,线程ID等相关信息,其中有一个域是一个TLS数组。
对于隐式TLS,程序员无需关心TLS变量的申请,分配赋值和释放,编译器,运行库还有操作系统已经将这一切悄悄处理了。
链接器在进行最终链接时,有一部分目标文件来自于GCC,他们是那些与语言密切相关的支持函数。
为了实现在main函数前执行代码,必须在链接时进行特殊的处理。collect2这个程序就是用来实现这个功能的,它会收集所有输入目标文件中那些命名特殊的符号,这些特殊的符号表明他们是全局构造函数在main前执行,collect2会生成临时的.c文件,将这些符号的地址收集成一个数组,然后放到这个.c文件里面,编译后与其他目标文件一起被链接到最终输出文件。
与任何系统级别的软件一样,真正复杂的并且有挑战性的往往是软件与外部通信的部分,即IO部分。
因为系统调用的开销是很大的,他要进行上下文切换,内核参数检查,复制等,如果频繁进行系统调用,将会严重影响程序和系统的性能。
所谓flush一个缓冲,即指对写缓冲而言,将缓冲内的数据全部写入实际的文件,并将缓冲清空,这样可以保证文件处于最新的状态。
系统调用是应用程序(运行库也是应用程序的一部分)与操作系统内核之间的接口,它决定了应用程序是如何与内核打交道的。
运行时库将不同的操作系统的系统调用包装为统一固定的节诶口,使得同样的代码,在不同的操作系统下都可以直接编译,并产生一致的效果,这就是源代码级别上的可移植性。
操作系统一般通过中断来从用户态切换到内核态。
系统调用号在执行int指令前会被放置在某个固定的寄存器里,对应的中断代码会缺德这个系统调用号,并且调用正确的函数。
C语言的大多数函数都以返回-1表示调用失败,而将出错信息存储在一个名为errno的全局变量(在多线程库中,errno存储与TLS中)里。
当用户调用某个系统调用的时候,实际是执行另外一段汇编代码。
在Linux中,内核态和用户态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。
每个进程都有自己的内核栈。
aslinkage是一个宏,定义为__attribute__((regparm(0)))这个扩展关键字的意义是让这个函数值从栈上获取参数。
linux用于支持新型系统调用的“虚拟”共享库。linux-gateso.1并不在实际的文件,它只是操作系统生成的一个虚拟动态共享库(VDSO)。
调用sysenter之后,系统会直接跳转到由某个寄存器指定的函数执行,并自动完成特权级转换,堆栈切换等功能。
dd的作用为复制文件,if参数代表输入的文件,而of参数代表输出的文件,/proc/self/mem总是等价于当前进程的内存快照。
作为一个商业操作系统,应用程序兼容性是评价操作系统是否有竞争力最重要的指标之一。
程序运行的最初入口不是main函数,而是由运行库为其提供的入口函数,它主要负责三部分工作:准备好程序运行环境和初始化环境,调用main函数执行程序主体,清理程序运行后的各种资源。
atexit()注册回调函数的机制主要是用来实现全局对象的析构机制的。
brk系统调用可以设置进程的数据段边界,而sbrk可以移动进程的数据段边界,他们仅仅是分配了虚拟空间,这些空间一开始是不会提交的,(即不分配物理页面)。
v[romtf是这些函数中真正实现字符串格式化的函数。
通常C++的运行库都是独立于C语言运行库的。
构造函数主要实现的是依靠特殊的段合并后形成构造函数数组,而析构则依赖与atexit()函数。
对于GCC来说,须要定义".ctor"段的起始部分和结束部分,然后定义两个函数指针分别指向他们。真正的构造部分则只要由一个循环将这两个函数指针指向的所有函数都调用一遍即可。
所有的全局对象的析构函数,不管是LInux还是Windows,都是通过atexit或其类似函数来注册的,以达到在程序退出时执行的目的。
大端小端的区别就是大端规定MSB在存储时存放在低地址,在传输时MSB放在流的开始;LSB存储时放在高地址,在传输时放在流的末尾,小端则相反。
更多推荐
所有评论(0)