C语言编译连接过程简述-程序员的自我修养笔记

 

注:里面有些概念以及使用不详细,需要了解可以通过百度查找这个概念是什么,能解决什么问题,怎么使用的方式去了解。

  • 概述:

1.计算机三大部件:cpu,内存,i/o控制芯片

其通信方式为总线:cpu与内存,以及cpu与i/o控制芯片通过前端总线(FSB)进行通信,后来随着cpu与内存的工作频率的差异化(cpu频率日渐加快,而内存以及其他外设跟不上其频率),i/o口不需要那么高的通信频率,发展出南北桥控制芯片,cpu通过前端总线连接北桥芯片然后北桥芯片连接显卡,内存高频率的设备,与此同时,北桥还连接南桥芯片,而南桥连接usb,鼠标,键盘等慢速频率i/o设备。此设计作用为:让cpu与某个设备通信时无需等待,让外设先将数据发送北桥,然后cpu直接与北桥进行高速通信。提高cpu使用效率。(cpu贵,让其闲下来很亏)。内存与i/o控制器通过dma控制器进行直接的数据传输,无需通过cpu来将i/o外设的数据往内存地址搬运。(如音频数据可以将内存固定地址的数据往外设I2S控制器中,以此来将数据传输到dac外设芯片,省却cpu的参与,提高cpu效率)。

2.计算机三大频率:

主频:cpu的工作频率

前端总线频率:cpu与外设进行通信的频率

外频:指内存频率

三者关系:主频=前端总线频率*2^n   前端总线频率=内存频率*2^n

可以通过多内存通道方式(多内存技术)将原本内存频率倍频到前端总线频率

3.cpu管理:

硬件:目前因为工艺问题,导致cpu频率被限制再4GHz,使用多cpu以此来提高效率(对称多处理器-每个cpu在系统中所处的地位和功能一样,虽然程序不能被拆分执行,但是对于上网数据请求这种可以并发的能同时多个cpu同时工作),在个人计算机演化出多核处理器(使用多个cpu,阉割数据缓存部分,保留其核心计算能力,来提高性能节省成本)

软件:按照进程优先级进行分时轮转执行,目前主流操作系统都是这种系统能够按照分时轮转方法管理应用程序在cpu中的执行时间(抢占式)。

4.内存管理:

4.1硬件:每个进程访问自己的内存空间时,cpu使用虚拟地址通过MMU(memory manageer unit )部件来访问实际的内存芯片中的物理地址。此部件集成在cpu内部了一般。

4.2软件:

4.2.1意义:将有限的物理内存分配给多个进程使用,且互不干扰的高效访问。

4.2.2方式:4GB独立虚拟地址以及分页管理:首先采用每个进程都有4GB独立的虚拟内存,然后将虚拟内存进行分页管理,将不常访问的虚拟页写入磁盘页(文件),将访问频繁的虚拟页映射到物理页(物理内存的访问单元,),当两个进程的虚拟页映射到同一个物理页时,也就是所说的共享内存。虚拟页由操作系统根据物理内存支持的物理页大小来确定,一般为4KB或4MB。

4.2.3访问流程:当进程需要访问虚拟内存时,查看物理页是否存在,如果不存在就将磁盘页置换到物理页后再访问。

4.2.4解决问题:

进程间内存独立防止恶意进程-独立4GB虚拟内存

内存使用率以及访问地址高效-分页管理,将物理内存尽可能利用起来。

物理内存不够问题-将不常用的虚拟页映射到磁盘页

5.线程:

5.1定义:轻量级进程(在内核中都是任务):采取的运行方式和进程是类似的,只是访问数据的权限不一样。将共享同一个内存空间的不同任务归属于同一个进程的不同线程。

5.2数据访问权限:线程私有:局部变量,函数参数,TLS数据。线程共享:全局变量,堆上的数据,函数里的静态变量,程序代码,文件指针。注意:同一个进程里线程能拿到的地址就都可以访问,因为线程中用到的地址还是进程的虚拟4GB空间,那么其映射到物理内存和磁盘的方式是一样的。进程间拿到地址页不能访问,因为其地址是独立的,同一个虚拟地址映射到物理内存是不一样的。

5.3线程调度与优先级:三态:运行态,就绪态,等待态,等待态不能切换到运行态,只有先切换到就绪态之后才能切换到运行态。cpu根据线程优先级按照分时轮转法来调度线程,线程的优先级有三种方式改变:(1)用户指定(2)进入等待状态的频繁程度的来提高或降低优先级(3)优先级非常低的线程一直得不到调用的线程逐步提高优先级防止线程饿死

5.4线程安全:将线程间访问同一段内存这种非原子操作(一条汇编指令能完成的就是原子操作)进行同步以及线程访问相同函数得是可重入函数。

5.4.1同步与锁:线程同步可以用信号量,互斥锁与条件变量,临界区以及读写锁。其优缺点:信号量与互斥锁可以被其他进程访问到,是进程共享的,临界区是进程内部独有的,三者作用一样。读写锁有两种状态,共享与独占,它比前三者在读取平凡而偶尔写入的情况高效很多,共享时,所有线程都能获取到锁并访问资源,有线程切换到独占时,得等到所有的共享被释放之后才能切换到独占,独占以后其他线程均得等待锁被释放才能获取到锁。

5.4.2可重入函数:定义:一个函数在执行过程中再次被调用。特点:不使用且不返回静态和全局非const变量及其指针,运行过程中仅仅依赖于调用的形参,不访问任何单个资源(mutex等),不调用任何不可重入函数,一句话,可重入函数不访问任何共享资源,仅仅依赖于形参以及函数内部的资源-函数调用开辟,结束就释放的资源(栈)(局部静态在堆中属于共享)。

5.4.3过度优化问题:(1)编译器会将连续访问的变量写入寄存器,而不写入内存,此时锁已经解开了,不同线程访问的寄存器不一定一样。(2)编译器会调整两条相邻的豪不相干的程序的顺序。使用关键词volatile防止编译器做优化。但是cpu也会产生调整两条毫不相干的程序的执行顺序,根据cpu不同,可以在毫不相同的两条指令间加特殊函数防止cpu的这种优化,保证执行顺序。具体的使用时百度。

5.4.5多线程:一个内核线程对应一个轻量级线程(用户空间)(LWP),当内核线程分时切换运行不同LWP时,就会发生内核空间到用户空间的交换,这个比较占据资源,所以在LWP上又封装了一层线程库(Linux thread),多个用户线程对应一个LWP,对于内核只识别LWP,内核会分时切换LWP(此时设计内核空间与用户空间的转换),而LWP在被运行期间,又会切换不同的用户线程,此时对于内核而言,他运行的线程不变,不涉及空间切换,比较省时间。当一个LWP有用户线程在等待IO口状态时,那么这个LWP对应的所有用户线程都会陷入等待,因为此时内核线程陷入等待,只有被其他内核线程唤醒以后才会继续运行。这种采用LWP模式演化出了一个用户线程对应一个内核线程,多个用户线程对应一个内核线程,以及多个用户线程对应多个内核线程三种模式。

 

  • 编译与链接:(在架构代码时多点分布转换,分模块,以后调试就更方便)

1.编译四步:

预编译:使用预编译器(预编译相关字符识别与转换的算法)删除所有#define将宏进行直接替换以及#include头文件进行展开,并且按照条件预编译指令(#if 等)进行无效代码删除,删除注释,给所有文件添加行号,将代码进行字符上的转换并生成对应 .i文件,需要看自己代码宏是否正确时,可以看预编译的结果。

编译:将预编译进行简单转换后的文件通过词法分析,语法分析,语义分析,以及简单的源代码优化后转换成汇编语言。

词法分析扫描:将源码输入到扫描器,扫描器按照有限状态机算法将字符分割成一系列的记号

语法分析:将词法分析的结果进行语法分析产生语法树(语法二叉树?2+6中2和6为子节点,+为父节点),此时如果语法不合规则,比如缺少括号,封号等会进行报错

语义分析:比如0做除数会报错,将浮点型赋值给整形,需要做隐式转换等,语义分析将语法树进行重新定义,将每个表达式附上类型,需要做类型转换的则插入类型转换的节点。

中间语言生成:将整个语法树转换成中间代码,它是语法树的顺序表示,将一些简单的运算直接得出结果,比如2+6这样的表达式等,直接转换成8,非常接近目标代码,但是没有包含数据尺寸,变量地址,寄存器地址等。

目标文件生成与优化:将中间语言按照环境,比如机器子长,浮点与整型数据类型等进行适配,然后将适配后的代码进行再次优化,比如乘法转行成相对应的位移运算来提高后面的运行效率。生成最终的目标代码里的汇编指令

汇编:按照汇编指令表将目标代码里汇编指令翻译成机器二进制文件。

链接:汇编后的机器二进制文件都是单独存在的,他们之间不存在联系,虽然二级制文件之间有跨文件进行函数调用,数据访问,但是那些函数与数据的地址都是为空,连接的作用就是①符号解析与决议:如果不同目标文件中有相同的非静态全局变量或者函数就会报错,不同目标文件中相同的静态全局和函数按照规则进行重定义。②重定位:将这些二进制文件中为地址为空的符号进行地址与空间分配,变量以及函数调用的实体地址进行赋值等。将不同文件之间的联系拼装成一个整体。

 

三.目标文件:

1.目标文件定义:源码经过编译后但未进行连接的的中间文件,windows下的.obj,Linux的.o

2.文件类型:和可执行文件的内容与结构很相似,一般和可执行文件都采用一种格式存储,从广义上将二者看成同一种类型文件,windows下的PE-COFF,Linux下的ELF文件,以及unix的a.out和inter下的OMF等

3.ELF文件分类:可执行文件,动态链接库,静态链接库,目标文件都是按照同一种文件格式存储,window下的PE-COFF,Linux下ELF。静态链接库稍有不同,是很多目标文件打包在一起加上索引的集合文件包。ELF文件标准将ELF文件分为四类:可重定位文件-.o文件以及.obj,可执行文件-linux的可执行程序以及window可执行程序.exe,共享文件-动态库,核心转储文件-Linux下的core dump

可以使用file命令查看,会有对应的英文显示relocate-重定位

4.ELF文件内容存储格式:按照内容属性进行分段存储。

文件头:包含文件属性:是否可执行,动态或者静态连接,目标操作系统,目标硬件,以及一个段表等。段表存储各个段的偏移以及长度

.text代码段:代码段存储运行代码

.data数据段:初始化了的全局变量和局部静态变量

.bss数据段:未初始化的全局的全局的局部静态变量,因为未初始化的的变量为0,为他们开辟文件空间是没有意义的,所以这个段不占据空间,只是记录这些变量空间的总和,等到连接成可执行文件的时候再开辟空间

.rodata只读数据段:用来存储字符串常量以及const修饰的不可修改的变量

.rel.text/.rel.data等:重定位段:用来记录对应段的需要重定位的相关信息

字符串段:有两张字符串段,段名不同,变量名和函数名(符号)在目标文件的字符串名为”.strtab”,用来表示表字符串的段名为”.shstrtab”,二者的都是把所有字符串排列在一张表中,字符串之间用\0隔开,然后用字符串在表中的偏移量来来表示字符串(有的局部变量是重复的,在进行编译解析的时候不同编译器会做不同的重定义操作来区分)

自定义段:为了初始化工作等将代码或者数据制定放置到固定段 格式:

__attribute__((section(“name”))) x; name为段名,x为变量int a=10;或者函数void foo();

其他段:.comment 注释信息段保存编译器版本内容 .hash符号哈希表 等等段

5.ELF文件进行分段的意义:

5.1不同段落被加载到不同权限的存储区域,代码段防止只读存储区防止被改写,减少系统奔溃概率

5.2cpu设计了数据和指令缓存区,指令和数据分开存储提高cpu命中率

5.3指令和程序分开,提供了程序共享的机会,共享指令段大大提高了存储效率

6.目标文件分析:工具objdump工具使用不同参数能够有效分析目标文件,例如参数 -h能将文件各个段落的属性打印出来,-d可以反汇编

 

四.连接器:

1.连接的入口:符号名-函数名和变量名:连接的实质是将不同目标文件通过“符号”(变量和函数名)粘在一起,为了将不同文件粘合在一起既要找到不同文件之间的关联(文件间的变量和函数访问等符号重定位,重定位信息由连接器扫描所有目标文件中重定位段以及根据所有目标文件建立的全局符号表来进行确定),又得解决冲突(不同文件的static函数名相同等)。

2.连接规则:采用统一的规则,将符号名按照规则(变量名的属性不同有特定前缀等等)进行重新定义,比如:不同目标文件相同的静态全局按照规则进行重定义,按照变量可见性-是否全局,加不同的前缀和后缀来重定义变量名,不同函数的相同局部变量又加上不同前缀和后缀。c++还能跟具参数类型不同将相同函数名进行重新定义来解决冲突。这样就能有效解决冲突了。这也是不同编译器编译出来的代码不同互相识别的重要原因。

3.文件合并:①将目标文件中地址为空的变量和函数访问进行按照其对应的重定位表(每个目标文件不同的段都有一个重定位表)进行地址重定位。②一般来说采用扫描全部目标文件,统计其相关属性,将不同目标文件相同段打包成同一个段放到可执行文件中,建立相互的映射关系,相比于不同目标文件按顺序叠加能节省字节对齐导致的额外空间浪费问题等空间。

3.extern ”C” {}作用:在c++代码中为了防止符号被重定义,使用这关键字的方式来做区分,防止中括号的符号名被C++规则改写。

4.强符号与弱符号:强符号会覆盖弱符号。弱符号可以使用__attribute__ ((weak))  x =5(变量定义);使用强符号就是强引用,使用若符号就是弱引用,强弱应用的区别在于库的使用,可以将重定义和库中弱函数一模一样的函数就能改写库函数实现不同功能。一般不这样用,防止自己出错。未初始化的变量就是弱符号,它可能被别的强符号替换,那么为其分配的空间就是不正确的,这也是为什么将弱符号放置.bss的原因

5.指令修正:根据硬件版本,将指令寻找的方式进行适配,例如寻址指令的长度,寻址方式:绝对地址寻址,相对地址寻址

6.C++特殊的处理:

6.1消除c++在编译器时由模板,虚函数表等造成的不同目标文件中相同实例化,相同实例化会有特殊标识符,碰到这个标识符就做处理。

6.2连接级别选择:将没有用到函数和变量抛弃,减少空间,但是这样因为需要判断所有符号的依赖关系会导致连接时间加长,通过选项来控制-ffunction-sections,-fdata-sections

6.3全局构造和析构:引入两个特殊的段:.init段在main函数被调用前调用其中的函数,.fini段在main函数结束后调用改段,利用这个特性就得此构造函数和析构函数。

6.4不同编译器将同一个源码编译出来的目标文件不同,因此gcc和mavc编译出来的目标文件无法进行连接,于此同时当使用第三方提供的库的时候需要保证双方编译器的规则是同一个连接器所支持的。具体规则使用的时候再说。

7.链接控制:绝大部分下,按照默认规则将目标文件进行连接,但是有特殊要求的程序需要对连接器进行设置和修改。例如:操作系统内核,BIOS(basic input output system),没有操作系统的的程序如boot loader等。因为特殊的条件尤其是硬件条件对各个段的首地址有特殊要求等。注:内核本身就是一个程序,误认为的window内核很大,有很多文件组成,其实windows内核是\WINDOWS\system32\ntoskrnl.exe这个程序

8.连接参数:在进行连接时,制定参数来控制连接器调用不同的脚本来形成输出文件格式-可执行、共享或者静态目标文件等,以及最终可执行文件中是否保留调试信息等。

9.ld连接器实质:将不同目标输出到一个目标,期间涉及段的合并,丢弃, 制定输出段的名字,属性,装在地址等。

10.windows的目标文件PE/COFF和Linux的ELF类似,增加了一个.device段这个段保存了编译器传给连接器参数来制定运行库功能。具体差异有需求再 说。

11.BFD库:因为硬件有机器字长不同,8、16、32位等,字节大小端,内存地址访问对齐等,软件平台有是否支持动态连接,是否支持debug调试等导致目标文件格式很多。种种导致编译器和连接器很难处理不同平台的目标文件。对跨平台的gcc编译链等,有一个统一的接口BFD库。它有绝大部分ELF的文件格式,ld连接器通过BFD库来操作编译器编译出的目标文件,当需要支持一种新的目标文件格式,只需要往添加BFD一种格式。目前它支持25种处理器,50多中目标文件模型。

 

五.可执行文件装载-进程运行机制:

1.进程的虚拟空间:windows下4GB虚拟空间,系统占据1GB,其他占据1GB,进程只有2GB可用,可以通过修改windows跟目录下的Boot.ini加上/3G,来将进程可用内存提高至3G,和Linux分布一样。32位cpu的进程在应用上无法访问超过4GB的内存空间,但是硬件上寻址超过32跟,可以访问超过4GB的物理内存,intel修改页映射方式引入APE(physical address extension)概念。开辟一段特殊虚拟空间,将扩展的物理内存分块,使用哪一块扩展内存就将那一块扩展内存映射到这个特殊的虚拟空间,将物理内存分块管理。Linux下为mmap函数。

2.进程装载方式:有覆盖装入和页映射:基本思路都是利用程序的局部性原理,将程序分块载入。覆盖载入:将程序手动分模块,使用辅助器(内存管理子系统)将模块分场景进行加载到内存,依照的思路为按照调用关系将模块分成树状结构,任何一个模块不能跨树进行调用。页映射:将所有可执行代码进行分页,当物理页只有四页,而程序有八页时,就按照运行哪一页就将那页装载到内存的中去,中间页的切换有先进先出和最少使用算法等,有点类似于线程工作队列。

3.进程建立过程:建立分为三部分:

3.1建立独立的虚拟地址空间:虚拟空间不是实际的空间,是一种数据结构加算法。虚拟空间是指由一组页映射函数将虚拟空间各个页(页结构)映射到相应的物理空间,创建虚拟空间并不是创建空间,而是创建映射函数所需要的相应数据结构(页结构等),i386的Linux下创建虚拟空间是分配一个页目录结构,映射关系暂时不用设置,发生页错误时才需要再建立。在创建虚拟空间结构时会设置一个名为.text段(不是ELF文件的.text段)的VMA(虚拟内存区域-每个被加载的段都会在这个空间中占据一个VMA空间,VMA0-VMAn),它的虚拟空间地址0x08048000~0x08049000,会记录页结构与可执行文件间段的关系.

3.2读取可执行文件头,建立虚拟空间与可执行文件的映射关系:页映射是指虚拟内存空间到物理内存空间的映射,而这一步为建立可执行文件到虚拟空间(页结构等相关结构赋值-比如可执行文件各个段的属性,地址等赋值到结构相应的变量中)的映射。这一部使得可执行文件也叫映像文件。

3.3将cpu的指令寄存器设置为可执行文件的入口地址开始启动进程:ELF中保存的入口地址就是程序执行的起始地址。

4.页错误:当cpu发现执行的虚拟空间页为空时,cpu认为它是一个页错误,cpu将权限还给操作系统,操作系统根据VMA结构重新对可执行文件和页结构建立正确的联系,然后让cpu执行,随着进程的进行,页错误不断爆出并被修正.

5.可执行ELF文件与执行视图:进程运行时,是按照页的大小来装载ELF文件,而ELF文件的段往往有十几个,如果将一个段都独立分页就比较浪费,此处引入”segment”概念,权限相同的ELF段”section”打包成一个段”segment”来进行映射减少因为页对齐而产生的空间浪费,一个segment只有一个VMA.segment是由连接器产生的一个表,描述segment的结构叫程序头,程序头描述了该程序如何被系统加载到进程虚拟空间.程序头只有ELF可执行文件有,ELF目标文件没有.

6.栈和堆:每个VMA不止记录ELF中各个segment信息,操作系统还通过他来管理进程空间的地址,进程中堆和栈有不同的VMA来管理.大小吻别的140Kb和88Kb.malloc函数就是通过VMA结构向系统申请空间.堆和栈空间受硬件和软件的影响.

7.segment段对齐:一个segment对应一个或者多个页结构,但是如果一个段的最后一个页没有用完并且映射到物理页就会存在浪费,为了解决这种浪费,将一个段相邻的映射多个虚拟页,多个虚拟页映射同一个物理地址,这样就能将每一个虚拟页用完并且同步映射到物理页,只是会有发生一个多个相同虚拟页对应多个段对应一个物理页的情况,这个需要系统内存子系统去处理.

8.进程运行以及初始化:进程被运行时,首先bash进程调用fork函数创建新进程,然后新进程调用execve函数执行指定的ELF文件,原先的bash进程返回等待新进程启动的结束,然后继续等待用户输入命令.进程运行时需要环境变量和参数,这些都被放在栈中保存,运行时取出传递给main函数.execve函数会调用系统内核函数开始进程装载并执行新的进程.

注:ELF文件中有个魔数的结构,它决定装载使用的解释装载器路径.ELF头四个字节保存魔术为0x7F-”elf”,java可执行文件头四个字节为”cafe”,shell,python脚本的文件头为”#!bin/sh”,”#!/usu/bin/python”,其中#!构成魔数,系统就知道要去对应路径找脚本解释器.

六.进程编译与装载小结:

操作系统通过给进程划分一个个VMA结构来管理进程虚拟空间,然后一个个VMA对应一个可执行ELF文件中一个个segment,每个segment对应同一个可执行文件中一个个具有相同权限的section段,一个可执行文件中每一个section段对应源码工程一个个独立.o等目标文件具有相同属性的代码.text段和数据.data段等,每个独立的.o等目标文件的.teaxt段和.data段等对应源码.c文件中代码和变量等集合.

一个进程的VMA根据权限被分为几个区域:

代码VMA:可读可执行,有映像文件

数据VMA:可读可写可执行,有映像文件

堆VMA:可读可写可执行,无映像文件,匿名,向上扩展

栈VMA:可读可写,不可执行,无映像文件,匿名,向下扩展

 

七.动态连接:

1.作用:(1)共享给所有使用到这个共享库的进程,节省空间。(2)发布更新时,仅针对需要更新的共享库,不像静态库需要更新所有的程序,提高容错率。(3)共享库有利于分组开发。(4)提高兼容性,有利于制作插件,共享库只要按照源码提高的接口就能制作已有程序的插件,这也是现有应用制作插件的主要方式。(5)增加兼容性,操作系统a和b调用不同printf函数库,减少应用程序对系统依赖性。缺点当新更新的库不能兼容旧的库时,应用程序会发生调用错误,比较著名的windows下的“DLL Hell”及明明有xxx.dll但是系统说xxx.dll丢失,就是典型的dll动态库不兼容的情况。

2.加载:动态连接在程序加载时候由动态连接器加载,共享库加载的内存地址不固定,由当时内存的空闲情况来动态加载,然后将程序中未决议的符号来动态绑定-装载时重定位,这样会使得程序加载时间变长,可以使用延时绑定技术尽可能的减少时间提高效率。

3.地址无关代码:静态库在连接的时候,所有符号的地址都会被固定,而动态连接库的符号都是在装载时才确定下来。多个模块在多个程序中被调用时,如何进行符号地址的确认并且不冲突呢?这里引入GOT(全局偏移表),这个表记录了全局符号的相关属性,模块内部的符号访问使用相对寻址,模块外部来访问模块提供全局符号时,程序会先找自己的GOT表,然后GOT表根据加载时模块地址以及相对位移等信息找到目标。在加载时候,模块需要给引用这模块的其他模块或者程序提供相关地址信息去完善对应的GOT表时,在编译模块时需要加上-fPIC参数,来达到对外提供地址信息目的(小写的-fpic功能一样但是需要相关硬件支持,一般用大写的就行)。数据段在程序加载时会拷贝一份(独立的4G空间,数据段都有独立的副本),然后进行重定位,不需要使用到地址无关的相关算法。

4.全局符号介入:当两个a1.c中有a函数,a2.c中也有a函数,b1.c中有b1函数调用a1.c中的a函数,b2.c中有b2函数调用a2.c中的a函数,此时不会发生错误,因为会b1和b2这两个模块会指定库的路径,当c模块中同时调用b1和b2模块中的b1和b2函数时,那么此时对应的a函数是哪个模块中的呢?此时发生冲突,动态连接器的处理机制为,当一个符号加载入全局符号表后,后来的全局符号会被忽略,因此在c程序中先加载哪个a模块,那么对应的地址被加载,后来的忽略。因此在编写代码时,需要注意这个bug,方式程序莫名死掉。

5.动态库使用两步走:动态库在编译和使用目前有两个点,第一点就是在程序装载时连接,需要在编译时加上参数-shared。第二点,需要达到共享的目的,实现多程序访问多模块,实现模块的加载地址无关,需要在编译时加上参数-fPIC,使模块为调用者提供地址等相关属性,使调用者在加载时完善自己的GOT表。

6.延迟绑定(PLT):不在程序加载时候进行连接,而是在第一次使用的使用的时候进行符号查找和重定位等。实现方法:在got表中将分成两部分,一部分用来保存数据的地址属性,另一部分用来保存函数地址的属性,而保存函数方法是比数据多一层封装,这个封装就能实现延迟绑定,等到第一次调用这个外部函数的时候再进行外部模块加载和函数重定位。分装的大概实现方式为:将重定位修改成统一的相应规则的虚假定位指令,比如函数xfun()将其修改成n@xfun();这条指令不用进行寻址和重定位,一条简单的赋值指令,非常的快,其中n用来表示重定位表中.rel.plt表的下表,而.rel.plt表中记录了外部函数对应的模块,函数名,是否已经重定位等信息,当第一次这个访问这个外部函数时,先根据这些信息进行模块的加载和重定位然后将重定位标值修改,然后进行函数访问,当第二次访问时,只需要查询标值位后直接进行函数访问。延迟绑定的思路是这样,具体实现等到需要的时候再进行查询。

7.动态连接器:它本身就是一个可执行程序,不依赖与外部模块,所有的操作都是自己完成-自举。连接器会根据ELF文件对应的段以及赋值信息(在加载的时候进行初始化压栈,用完以后就丢弃)来完成重定位和地址无关的处理,这些信息段以及辅助信息会在编译的被编译进ELF文件中,就像静态链接库中有.rel.data/.rel.teat,数据/函数重定位段,动态库中也有有对应的段.rel.dyn/.rel.plt。动态连接器和静态连接器工作的架构是类似的,细节上会有对应的规则处理,然后只是放到了程序加载的是才工作。

8.显示运行连接:将共享模块当做驱动模块一样,主动安装,调用和卸载,系统提供了像操作文件io一样的函数。(1)加载模块函数dlopen,第一个参数需要给到库的路径.(2)获取符号dlsym,需要给到符号名,根据符号类型返回结果,常量返回值(void *),变量和函数返回其地址,拿到地址或者值就可以进行相关操作了(3)dlclose,卸载共享库,会有计数器进行计数直到最后一个调用结束才会真的卸载。(4)dlerror,前面三个函数的调用后都可以用这个函数进行查错,无措返回NULL。

 

六.共享库的管理:

1.版本管理方式:

1.1从兼容性来讲,分为兼容跟新非兼容更新,二者的区别主要在于编译出来的对外接口是否发生改变。造成兼容与否的原因有个:函数功能发生改变;导出函数删除;导出的数据结构发生改变;导出函数的接口发生变化,比如返回值,参数更改等。也有其他原因容易操作上诉的四种情况,编译器不一样导致函数重命名不一样,编译环境不一样导致字节对齐不一样。对于C++而言,模板,虚函数,函数访问权限,类中成员变量的位置等等。最好别用c++写库。

1.2命名规则:libname.so.x.y.z 其中,x表示主版本号,不同主版本间不兼容,y表示增量升级,添加新的接口来提供新的功能或者性能。z表示修改bug或者提高性能。这种命名规则作用,不用应用程序依赖于同一个库不用版本,通过主版本号可以用来更加准确的减少bug和节省空间,比如a和b程序开始都依赖与libxx.1,后来b更新后需要依赖libxx.2。那么通过主版本号防止连接错误。

1.3依赖关系:SO-NAME命名机制,Linux会为每个共享库在它所在的目录创建一个跟SO-NAME一样名字的软连接,这个名字也叫做连接名,连接名遵循只保留主板本号,SO-NAME被保存在共享库的一个段中,软连接指向最新的次版本共享库。比如说,一个目录中有两个库,libname.so.5.3.2和libname.so.5.6.1,那么会在当前目录创建一个软连接libname.so.5指向libname.so.5.6.1,libname.so.5.3.2被忽略,两个共享库的SO-NAME都是libname.so.5。其二者依赖关系为,当动态连接器去加载共享库时,会先去库里面对应的段拿到SO-NAME,然后根据SO-NAME找到相同名字的软连接,然后根据软连接找到最新次版本的共享库节省内存,防止库重复加载。共享库升级时,不同主版本的SO-NAME不一样,对应的软连接也就不一样,这样能防止应用错误连接,同时SO-NAME也不会向后兼容。在连接时,如果ld使用-static参数,连接器会找静态库,使用-Bdynamic,连接器会找最新的动态库。Linux提供工具ldconfig能重新定位软连接找到其对应目录的最新的此版本库。

1.4符号版本:连接器会根据软连接找到最新的此版本号,进行连接,但是,当的编译时候,依赖与高版本的库,但是运行的时候,连接的低版本的库,此时就会发生程序的运行出错问题,这种次版本交会问题没有被SO-NAME机制解决(编译时依赖于次版本库,运行时依赖与高版泵不会出问题,此版本号向后兼容,不向前兼容),此时,提供一种符号版本机制,及在编译时,给外部提供的函数打上版本烙印,比如共享库函数fun,在编译出来后加上符号vers_x_y,其中x,y表示主次版本号,那么连接器在链接时,找到一个符号版本大于等于所需要的库,找不到就报错防止进一部的出错。举例:给外部提供的库函数ex_fun被编译出libxx.s.3.1中ver_3.1_ex_fun到libxx.s.3.n中ver_3.n_ex_fun,但是调用者需要的ver_3.3_ex_fun,那么此库对应的符号被加载,其中连接器会遍历获得所有库中这个函数的信息,找到第一个大于等于这个版本的库,没有就报错。

1.5库的路径:大多数开源操作系统遵从FHS标准,规定了各个目录的组织结构以及作用。/lib存放/bin,/sbin,系统启动需要的库,/usr/lib存放非系统运行时所需要要的关键库,这些一般不会被用户用到,/usr/local/lib存访各个应用程序用到的第三方库.

1.6库查找过程:ld连接器会根据对应段.dynamic中DT_NEED的信息去找库,如果DT_NEED是绝对路径会就直接找,如果是相对路径去传统的两个个路径/lib,/usr/lib和配置文件/etc/ld/.so.config去找库。提供一只程序ldconfig会遍历/etc/ld/.so.config,/lib,/usr/lib,建立一个/etc/ld.so.cache,里面保存了各个库SO-NAME信息,这缓存文件被设计成非常适合查找。程序当需要访问共享库时,直接来这个文件中找,如果没有找到还会继续访问

/lib,/usr/lib。

1.7环境变量:LD_LIBRARY_PATH:给程序提供库的路径,设置的方法有是那种,第一种:不同用户永久设置:设置不同用户的home目录对应的的配置文件.bashrc,不同目录对应。第二种临时设置当前用户所用应用:export PATH=$PATH:xxx(路径)。第三种设置当前用户:LD_LIBRARY_PATH=xxx(路径)  yyy(执行程序)。寻找库的先后路径顺序为:先环境变量,然后/etc/ld.so.cache,最后/lib,/usr/lib。LD_PRRELOAD这个环境变量会加载对应路径的所有库,不论是否需要用到,以及会在LD_LIBRARY_PATH之前被访问。LD_DEBUG指定加载库时需要打印的相关信息,比如依赖关系,重定位过程等,参数不同,显示不同。

1.8其他属性与功能:(1)去除调试信息:共享库可以通过strip去除调试信息,使得库大大减小到乃至一般大小,去除调试信息也哭通过给ld加参数-S(去除调试符号),-s(去除所有符号)。Gcc通过参数-Wl,-s传递给ld也可以。(2)指定共享库构造和析构函数,他们优先main函数之前以及调用玩以后执行,例如构造函数需要在函数之前加上__attribute__ (constructor(n)),n表示优先级。数字小优先级高。

1.9Winddows下dll文件和Linux下的.so类似,都是动态库,他们实现方式架构上大体类似。

 

七.内存:

1布局:大部分4GB内存被分为内核和用户空间,Linux一般将高1GB内存给内核,剩下的给用户空间,用户空间的布局一般默认为以下选项(1)栈:用于维护函数的上下文,动态变量,栈一般在用户空间的最高地址分配向下生长,一般有数兆字节的大小(2)堆:用来保存动态分配的空间,以及静态变量如static,字符串等变量,一般有几十兆到数百兆。(3)可执行文件映像:由装载器将可执行文件加载到这里这部分内存。(4)动态可映射区域:用来加载动态库的区域(5)一些保留区域,比如NULL这种空的地址,以及一些用来加载固定参数的区域。

2栈:

2.1.工作模式:一般向下生长,栈顶为esp进行定位,压栈esp变小,弹出esp增大,栈保存的最重要的一项功能就是保存函数调用所需要的活动记录,一般称之为堆栈帧。堆栈帧包含函数的返回地址和参数;函数中产生的临时变量-函数非静态局部变量,编译器生成的其他临时变量;保存的上下文-函数调用前后需要保持不变的寄存器。用来记录栈两个关键帧ebp寄存器和dsp寄存器。ebp在函数执行过程中总是指向固定位置用来定位函数执行体,当函数切换时才改变位置,esp总是指向栈顶位置,这样这两个寄存器就能划分一个函数执行的全部内存范围。一个函数调用新的函数以及返回的流程(递归过程):(1)在cpu指针去下一条函数地址执行之前先将当前ebp栈地址压栈,即将上一个ebp地址保存下来,然后将ebp指向栈顶最新的ebp地址,ebp寄存器就像单链表中的头插法的链表头一样,遵循先入先出,(2)在栈上开辟一段空间用来留在他用(3)然后将需要保存的临时变量,数据等压栈,然后将参数压栈,最后将返回地址压栈,将这些数据压栈其实就是将ebx,esi,edi等寄存器的数值压栈,因为这些寄存器在新的函数执行过程中会被改变(4)在栈上加入一些调试信息,然后去执行新的实体(5)调用完函数需要返回,返回值通过eax寄存器传递(6)在栈上依次将edi,esi,ebx寄存器恢复,这顺序和压栈刚好相反,(7)恢复之前的esp和edp,此时ebp就像头插法链表的链表头节点指针向后移动一样。(8)函数返回调用处开始继续此函数往后执行。

2.2调用惯例:函数在调用过程中使用栈需要遵循一定的顺序,这就是调用惯例,否则压栈和出栈的顺序不一一对应就会发生错误。调用惯例会以下结构大的方面被规定:参数的传递顺序和方式-一般为从右至左;栈的维护方式-及函数调用过程中的如何保存一个函数全部状态以及返回时获取全部状态的过程;对于不同调用惯例方式通过名字修饰的方式进行区分,这一步名字修饰由编译器来完成,这样就能在函数调用的时候统一使用同一种调用惯例。一般采用的调用惯例都是先将函数的参数从右至左依次压栈;然后将调用此函数的下一条执行地址压栈(这个地址被称为“返回地址”-区别return返回,这是两种概念);接着就是上文所说的维护一个堆栈帧操作。不同的调用惯例,其保存一个函数的活动记录方式不一样,上文的为一般默认的cdecl这种方式。

2.3函数返回值:函数一般由eax来保存四个字节的返回值,当时当返回值大于四个字节时,会配合使用edx来保存返回值,此时eax保存低四个字节,edx保存高四个字节,但是当返回值大于八个字节时,此时会用到栈上的函数调用过程种划分的一段临时区域(这种临时区域的思路有点像结构体种开辟一段的void *指针变量这种形式),这段区域的地址会通过函数调用过程种参数压栈时,隐士参数的形式保存,那么它的地址能根据edp的地址做偏移得到,然后在函数返回时,将返回值通过这个隐式参数拷贝到栈上临时空间中去,然后将栈上的临时空间种的值拷贝给函数返回需要赋值的变量,数值的字节数由eax指定,所以这函数使用过程中为了提高效率不要返回超过八个字节的数值,防止栈上空间的两次拷贝造成的效率低下。

3堆:

3.1堆的产生来源:堆的来源有两种,一种是从内存空间中获取实际的物理内存,另外一种是通过申请一段虚拟空间,然后虚拟空间映射到文件,但是不将地址空间映射到这个文件,此时这段空间就是匿名的,被系统当作堆去使用。

3.2堆的管理:用户空间通过函数malloc申请堆,此时就会涉及到堆管理的运行库,统一来管理堆空间,运行库管理堆空间的算法大体是先向系统批发一个大的堆空间,然后将这段空间销售给用户空间,当货物销售完以后根据实际情况再去批发,批发来的空间,运行库会对他进行销售登记于回收登记,防止二次销售。系统给用户空间两个函数进行堆的申请,brk(),mmap(),分别对应堆产生的两种方式。brk函数实现方式是设置数据段的结束地址,通过修改数据段的结束地址就能控制数据段的空间,扩大的空间就能用来做堆。mmap函数就是申请虚拟空间然后映射到文件,形成堆。能够申请的堆的空间最大为2.9GB,其余的地址指针被被栈,共享库等使用了。这个2.9GB只是指针寻址的最大值,实际能申请的堆的总大小还受算法以及空闲内存+空闲交换空间的总和(这个总和就是空闲寻址能力)限制等。Windows堆的管理也是上述方法类似。

3.3堆算法:(1)空闲链表:将空闲的堆空间通过一个链表连接起来,用节点记录属性,当申请空间时就从链表中拿,释放就将空间放回链表(2)位图:将一个堆空间分为分成固定大小的块,然后用两位来表示每一个块的三种状态-头/主体/空闲。这两种方式标记堆的使用情况各有优缺点,前者用来保存节点的地址空间容易被越界改写,后者可能导致空间的浪费以及块过多时命中率低。(3)对象池:根据特定情况将堆分为特定大小的块,这样就能有效避免位图的缺点。实际过程中堆的管理采用多种算法的结合。

 

八.运行库

1.进程初始化与结尾:main函数是一个进程中用户编写程序的的入口但是不是最开始执行的代码,main结束之后也会进行收尾工作,相当于main函数只是在进程中被调用过而已。main函数之前会进行环境初始化,包括堆,i/o,线程,全局变量构造等等。结束之后会将环境销毁,包括堆,全局变量析构,关闭i/o等。main函数之前的初始化以及之后的销毁依赖于编译器如何编写这部分代码。不同编译器也提供了不同符号来声明main函数之前与之后需要调用的函数,这些函数中不能产生ret这种返回指令,否则会导致初始化.init函数提前返回。

2.文件i/o:linux和windows将输入输出都实体化成文件了,这就是文件i/o词语的来历,在linux下,一切皆文件,那么对于文件操作的时候,有打开得到一个句柄或者描述符,Linux下标准输入,输出和错误输出的fd描述符0,1,2。其他文件的描述符依次累加,这个描述符其实就是内核中文件表的下标,每一个下标对应文件表中的一个文件对象,这个文件对象中包含其物理地址,这种映射操作在内核中进行,用户空间只能通过系统提供的api来进行,FILE与fd在内核中也是一一对应的关系。

3.C语言运行库:它一般包含以下几点:main函数之前和之后的启动和退出函数以及其依赖的函数;C语言标准规定的C语言标准库;I/O处理封装;堆的封装;特殊功能的实现以及调试代码加载等。C语言标准库包含stdio.h,string.h,math.h等等。其中有两种特殊的函数:

(1)参数可变型函数:int fun(xxx,...):其中关键点在于的参数...

实例化说明1: int fun(const char *format,...) 这种类型函数表明在使用过程中,其后面的参数...是可以在使用的过程中变化的,只是在format中需要指定后面每个参数的类型防止读取参数越界问题。比如在format中使用%d ,%s等。这个类型的函数有效使用为printf,scanf。

实例化说明2:format可以是整数也,下面通过简单的实例化这种类型来说明原理。int sum(unsinged num,...){int *p = &num+1; int ret =0; while(num--) ret +=*p++;return ret;} sum(3,1,2,3)的结构为6。其原理是利用参数从右至左的压栈顺序,以及栈向下生长的原理,通过得到最左边参数的地址然后计算出所有参数的地址,就可以拿到所有的参数的值进行计算,栈中这些参数会在函数结束时由栈的使用惯例估计自动释放。

  1. 非局部跳转:setjmp函数和longjump函数,这一组函数能够实现函数间的跳转(goto实现函数内部的跳转)。其工作方式为setjump函数会记录函数调转的上下文,然后在调用longjump时就可以通过setjump记录跳转到setjump返回时的地址去执行代码,实现跨越函数的跳转。这种尽量别使用,非结构编程,自己的思路容易出错。

3.运行库与多线程:程序运行中除开需要使用很久前确立了的标准c语言库,还需要一些其他系统库,这些库是有一些机构组织编写的。运行时库大多数都是对系统api的一个抽象,运行时库将不同系统的api适配成同一个api函数给用户使用。用户就不用考虑同一个功能不同系统平台而去调用不同的系统api,这些由运行库解决了。然后运行库会对标准c语言进行补充,比如标准c语言中没有多线程的概念,运行库会多线程操作进行了补充。目前主要的两个平台的C语言运行库为Linux的glibc以及windows的MSVCRT。这两个库因为早期没有多线程的概念,导致很多函数都是可重入的,导致很多问题,比如printf函数会发生打印纠缠在一起。malloc和free也是可重入的。字符串转数字处理函数,atof,atoi,atol等等。在编写代码时,尽可能对可重入函数加锁。MSVC可以通过选项/MT,/MTd来得到多线程运行库,里面对可重入进行了处理。

4.线程局部全局变量:将全局变量变成线程私有,在声明的时候加标识符,Linux的标识符为__thread,例如__thread int x;。

 

九.系统调用:

1.系统调用流程:系统调用时内核提供的函数给用户层使用,中间有两个关键点,第一个就是用户在使用系统调用时,会发生用户空间到内核空间的交换(栈也会切换,各自都有各自的栈,自己维护),这个交换依赖于cpu寄存器,用户态和内核态有的硬件寄存器有些是不一样的,这些寄存器的权限也不一样,这样就能保证用户空间的代码受限,防止产生威胁内核的稳定的情况,这样一个进程崩溃不会导致系统奔溃。第二个就是用户层切换到内核层出发条件,软中断。高权限能够切换到低权限,而低权限切换到高权限必须得限制,否则权限的设置就没有意义了。此时低权限到高权限的切换就提供了一个接口,软中断模式,这个模式是二者都拥有的,然后软中断中有权限修改到其他权限。系统调用就是通过软中断来实现的。中断有对应的中断异常向量表,表格中每一项都有对应的中断号,那么此时中断号有限,Linux将所有的系统调用中断都使用同一个中断号,然后通过寄存器eax寄存器来保存系统调用号,这样就能根据系统调用号找到系统调用表中的据具体处理函数。例如fork系统调用,中断号为0x80,系统调用号为2。中断号0x02为硬件驱动调用号。Linux2.5开始加入新的系统调用机制来增强用户态到内核态的效率问题。增加一个特殊寄存器,这个寄存器指向一段特定的代码,完成特权以及的堆栈的切换功能,其余的都一样。

2.API与子系统:系统api是非常耗时间的。并且不能轻易修改,这样导致向后兼容性较差,在系统api和用户api中间封装一层子系统,这样能在为用户提供新的函数时,更好的保证向后兼容。Windows就将系统api进行了封装,windows只向外提供用户api,系统api不提供。

猜你喜欢

转载自blog.csdn.net/qq_43706825/article/details/103699761