1. 温故而知新
虚拟内存可以解决地址连续带来的不安全,分页解决了内存使用率(换入换出的消耗)问题。
线程数据当机器的处理器数量大于线程数,那么就是真正的并发;如果线程数大于CPU数,那么必然有至少一个处理器是运行多个线程的,这个时候就需要线程调度,让他们看起来在并行。
线程调度的方法们主要都带着优先级 + 轮转的影子,IO密集的比CPU密集的更容易提高优先级,因为IO密集的用CPU时间少。
提高优先级的方式可重入的函数必须满足以下三个条件:可以在执行的过程中可以被打断;被打断之后,在该函数一次调用执行完之前,可以再次被调用(或进入,reentered);再次调用执行完之后,被打断的上次调用可以继续恢复执行,并正确执行。
重入发生的情况有两种,一是多线程,二是函数自己调用自己。可重入的函数有以下特点:(其实就是没有副作用)
可重入函数
锁也不是绝对安全,编译优化可能会延迟寄存器写入 or 调整不相干的指令执行顺序,导致意料之外的结果,volatile
可以阻止寄存器写入延迟。
原来linux也有栅栏,防止在构建对象的时候,内存地址返回先于对象构建:
栗子
2. 静态链接
Build = Compile + Link;一般是四个步骤:预处理 + 编译 + 汇编 + 链接
构建流程 预编译做什么编译就是语法分析之类的,生成语法树以及优化生成汇编代码;而汇编那一步是将汇编代码转为机器码,输出目标文件。(关于编译欢迎看之前的编译原理读书笔记,这里不详述啦)
比如array[index] = (index + 4) * (2 + 6)
经过各种分析会变成下面酱紫的:
t2 = index + 4
t2 = t2 * 8
array[index] = t2
这个时候就面临一个问题了,array和index的地址还米有确定。如果他们定义在其他编译单元呢?
为什么有链接链接主要分为:地址和空间分配、符号决议和重定位
例如两个模块main.c和func.c,main.c中要使用func.c中的函数foo(),在main.c模块中每一处调用foo的时候都要知道foo这个符号的地址,但是模块单独编译,编译main.c的时候不知道foo的地址,编译器暂时搁置,到链接时再去确定。连接器在链接的时候会根据所引用的符号foo,自动去相应的func.o模块中查找foo的地址,然后将main.o模块中所有引用到foo的指令重新修正,是得获取真正的foo函数的地址。
如果没有链接器,每次func模块重新编译,都要手动修改main里面的foo的地址,这就非常难受了。
如全局变量var 在目标文件A中,我们在目标文件B 中需要访问var,则编译目标文件B时由于不知道变量var 的目标地址将其暂时设置为0,等链接器将目标文件A和B 链接起来的时候再将var 的地址进行修正,地址修正也叫重定位,被修改的地方叫重定位入口。
地址修正3. 目标文件里有什么
编译及汇编之后生成的文件叫目标文件,只是没有经过link。
可执行文件 & 目标文件我们大概能猜到,目标文件中的内容至少有编译后的机器指令代码、数据。没错,除了这些内容以外,目标文件中还包括了链接时所须要的一些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息按不同的属性,以“节”的形式存储,有时候也叫“段”,在一般情况下,它们都表示一个一定长度的区域,基本上不加以区别,唯一的区别是在ELF的链接视图和装载视图的时候,后面会专门提到。在本书中,默认情况下统一将它们称为“段”。
image假设上图可执行文件(目标文件)的格式是ELF,ELF文件的开头是一个“文件头”,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统 等信息,文件头还包括一个段表(Section Table ),段表其实是一个描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息。文件头后面就是各个段的内容,比如代码段保存的就是程序的指令,数据段保存的就是程序的静态变量等。
未初始化的数据其实也可以放到.data,然后存个初始值0,但是这样就很浪费。所以.bss(Block Started by Symbol)段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间。
很多人可能会有疑问:为什么要那么麻烦,把程序的指令和数据的存放分开?混杂地放在一个段里面不是更加简单?其实数据和指令分段的好处有很多。主要有如下几个方面。
-
一方面是当程序被装载后,数据和指令分别被映射到两个虚存区域 。 由于数据区域对于进程 来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样可以防止程序的指令被有意或无意地改写。
-
另外一方面是对于现代的CPU来说,它们有着极为强大的缓存(Cache )体系。 由于缓存在现代的计算机中地位非常重要,所以程序必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
-
第三个原因,其实也是最重要的原因,就是当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只须要保存一份改程序的指令部分。 对 于指令这种只读的区域来说是这样,对于其他的只读数据也一样,比如很多程序里面带有的图标、图片、文本等资源也是属于可以共享的。当然每个副本进程的数据区域是不一样的,它们是进程私有的。
ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示比较困难,一种常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。
字符串表链接过程的本质就是要把多个不同的目标文件之间相互“粘”到一起。为了使不同目标文件之间能够相互粘合,这些目标文件之间必须有固定的规则才行。在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。
比如目标文件B要用到了目标文件A中的函数“foo”,那么我们就称目标文件A定义(Define)了函数“foo”,称目标文件B引用(Reference)了目标文件A中的函数“foo”。这两个概念也同样适用于变量。每个函数或变量都有自己独特的名字,才能避免链接过程中不同变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。
我们可以将符号看作是链接中的粘合剂,整个链接过程正是基于符号才能够正确完成。链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。
符号表符号表的name由于避免重复以及函数重载之类的,会有很多生成规则,和函数签名很像需要唯一,不同编译器生成的规则也不一样,所以不同编译器生成的目标文件之间的 link 是无法链接的。
这里就解释了为啥有的时候你build会报错重复符号~ (这里是因为是强符号哦,强符号不能重复定义,多个弱符号是木有问题的)
编译里面的弱引用和iOS的弱引用其实不太一样,编译的弱引用是如果找不到这个符号不会报错,但是强引用找不到会编译不过,但弱引用运行时可能会crash,but它给外部提供了自定义的机会。(内部仅声明,外部去实现)
4. 静态链接
举个例子当我们有多个目标文件时,如何将它们链接起来形成一个可执行文件呢?这就是链接的核心内容:静态链接。
链接就是把几个目标文件合成一个可执行文件,那么要怎么合并呢?如何合并各个段呢?
方法1:直接叠加方法1的问题主要是段太零散了,很多内存碎片,毕竟各段还要内存对齐啥的。
方法2:相似段合并在链接阶段,链接器会为会为所有的目标文件分配地址空间。
这里的地址空间需要区分两种含义:
- 可执行文件自身的空间:用于磁盘上静态存储可执行文件的内容
- 进程虚拟地址空间:由程序运行时,系统加载可执行文件的内容而动态建立
链接器为可执行文件中符号确定的地址即是最后程序运行时所使用的地址,在可执行文件被装载时会被一一映射到进程的虚拟地址空间。典型的装载数据包括代码段和数据段中的数据,一些特殊的段,如.bss段在可执行文件中不占用空间,但是在可执行文件装载后的进程虚拟地址空间中需要进行空间分配。。
现在的链接器空间分配策略基本上采用上述方式中的第二种,使用这种方法的链接器一般都采用一种叫两步链接的方法。也就是整个链接过程分两步。
-
空间与地址分配
扫描所有的输入目标文件,并且获得它们各个段的长度、属性和位置,并且将输入目标文件中的符号表中的所有符号定义和符号引用收集起来,统一放到一个全局符号表。这一步,链接器能够获得所有输入目标段长度,并且将它们合并,计算出输出文件中的各个段合并后的长度与位置,并建立映射关系 -
符号解析与重定位
使用上面一步收集到的所有信息,读取输入段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址。事实上,第二步是链接的核心,特别是重定位的过程。
举例我们把 a b 两个文件合成可执行文件ab:
连接方式
在目标文件里面的函数地址都是不固定的,所以会用0x00之类的替代,再合成为可执行文件以后,会修改这个地址为真实地址,这就是重定位。
地址修正链接器怎么知道哪些指令是需要被调整的呢?这些指令哪些部分要被调整?怎么调整?这些都需要重定位表来提供信息。事实上在ELF文件中,有一个叫重定位表( Relocation Table)的结构专门用来保存这些与重定位相关的信息,我们在前面介绍ELF文件结构时已经提到过了重定位表,它在ELF文件中往往是个或多个段。
比如代码段 "text" 如有要被重定位的地方,那么会有一个相对应叫 "rel.text"的段保存了代码段的重定位表;如果代码段 "data" 有要被重定位的地方,就会有一个相对应叫 "rel.data" 的段保存了数据段的重定位表。
重定位表重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。
visual C++提供了一个编译选项叫函数级别链接,这个选项的作用就是让所有的函数像前面的模板一样,单独保存在一个段里面。当链接器需要用到某个函数时,它就将它合并到输出文件中,对于那些灭有用到的函数则将他们抛弃。这种做法很大程序上减小了输出文件的长度,减少了空间浪费。但是这个选项会减慢编译和链接的过程。
其实就是把段单位改小,然后用的时候就合并,不用就不合并,这样就可以减少最后生成的可执行文件。因为如果你都混到一个段里面,是不好拆出来哪里不要哪里要的,以 segment 为单位就比较容易。但目标文件里面的段数会增加很多。
全局对象的构造会在 main 之前执行,以及它的析构会在 main 之后执行,他们分别处于单独的段 .init 和 .fini 。
目标文件格式、符号修饰标准、变量内存分布方式、函数调用方式等这些跟二进制可执行代码兼容性相关的内容称为ABI(Application Binary Interface)。和API的差别还挺大的,API说的是源代码级别的接口,ABI是二进制层面的。
这其实也说明了,为啥很多jar包会区分系统,也就是我们用的很多库都会提供很多版本,让你根据自己的系统下载。
静态库可以简单看成一组目标文件的集合。用压缩程序将这些目标文件压缩到一起,并进行编号和索引。如linux的usr/lib/libc.a
我们知道在一个 C 语言的运行库,包含了很多跟系统功能相关的代码,比如输入输出、文件操作、时间日期、内存管理等。 glibe 本身是用 C 语言开发的,它由成百上千个 C 语言源代码文件组成,也就是说,编译完成以后有相同数量的目标文件,比如输入输出有 pri ntf.o , scanf.o; 文件操作有 fread.o , fwrite.o等。
把这些目标文件零散的提供给库的使用者,很大程度上会造成文件传输、管理和组织方面的不便,于是通常人们使用"ar"将这些目标文件压缩到一起,并且对其进行编号和索引,以便于查找和检索,这就形成了libc.a 这个静态库文件。
静态库文件内容如果我想找一个符号在那个.o文件里可以酱紫:
查找符号
很多静态库都是一个文件只放一个函数,这个其实也是为了避免链接不需要的文件,这样的话就可以当你用到那个函数就link哪个文件啦。
如何从.c到可执行文件你可以去修改链接的脚本,去除不需要的段,以得到最小的可执行文件。
碎碎念:最近发现可能自己并没有自己想象的那么喜欢编程,也许我并不适合,所以三个月的停更想了也蛮多的,最近很想去英国留学,等疫情过了再申请试试吧,现在这种无法感受到成长的日子实在是有点难熬。但还是会先坚持哒~ MBA和做XX博主也在考虑范围内,但以我的资质吧,感觉难以维持生命吖~
网友评论