概览
编译器驱动程序
通常包括 语言预处理器、编译器、汇编器、链接器,在各个阶段完成不同的操作
加载器
它将可执行文件中的代码和数据复制到内存,然后将控制转移到这个程序的开头
__链接__的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接
静态链接
目标文件
可重定位目标文件
Object文件,典型的 ELF 可重定位目标文件格式:
-
.text 段
- 已编译程序的机器代码
-
.data
- 保存已经初始化了的全局变量和静态变量 -
.bss
- 保存未初始化的全局和静态变量,以及所有被初始化为0的全局或静态变量;这个段并不在目标文件中占据实际的空间,它仅仅是一个占位符,运行时才为这些变量分配内存,初始值为0 -
.rodata
- 保存只读数据,一般是程序里面的只读变量和字符串常量 -
.symtab
- 符号表,保存在程序中定义和引用的函数和全局变量的信息。每个可重定位目标文件在 .symtab 中都有一张符号表,它由汇编器构造,与运行时栈中的符号表不同 -
.rel.text
- 指令重定位表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。通常可执行目标文件不需要重定位信息 -
.rel.data
- 数据重定位表,保存被本模块引用或定义的所有全局变量和重定位信息。通常可执行目标文件不需要重定位信息 -
COMMON
- 编译器将未初始化的全局变量标记为一个 COMMON 类型的变量。可执行目标文件中没有这个段
- 当编译器将一个编译单元编译成目标文件的时候,如果该编译单元包含了弱符号,那么该弱符号最终所占空间的大小在此时是未知的,因为有可能其他编译单元中该符号所占的空间比本编译单元该符号所占的空间要大。所以编译器此时无法为该弱符号在 BSS 段分配空间,因为所需要空间的大小未知。但是链接器在链接过程中可以确定弱符号的大小,因为当链接器读取所有输入目标文件以后,任何一个弱符号的最终大小都可以确定了,所以它可以在最终输出文件的 .bss 段为其分配空间。总体来看,未初始化全局变量最终还是被放在 BSS 段的
共享目标文件
可执行目标文件
链接器将多个目标文件合并成一个可执行目标文件,它是完全链接的(已被重定位),因此不再需要 .rel 相关段;它是一个二进制文件,包含加载程序到内存并运行它所需的所有信息。可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为它往往是第一个被加载的文件。典型的 ELF 可执行目标文件:
链接器上下文中的三种不同的符号
每个可重定位目标模块(文件)都有一个符号表
在 C 中,源文件扮演模块的角色
全局符号
全局链接器符号,本模块中定义并能被其他模块引用,对应于非静态 C 函数和全局变量
编译时,编译器向汇编器输出每个全局符号,分为强符号与弱符号,汇编器把这个信息隐含的编码在可重定位目标文件的符号表里
函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号
-fno-common 链接时遇到多重定义的全局符号时触发错误
链接器根据以下规则处理多重定义的全局符号名:
- 不允许有多个同名的强符号
- 如果有一个强符号与多个弱符号同名,选择强符号
- 如果有多个弱符号同名,从这些弱符号中任意选择一个
比如在有多个相同的模板函数实例时,链接器任意选择其中一个
外部符号
本模块中引用,其他模块中定义的非静态 C 函数和全局变量
局部符号
带 static 属性的 C 函数和全局变量,在本模块中可见,但不能被其他模块引用
静态库
将所有相关的目标模块打包成为一个单独的文件,称为静态库。当链接器构造一个输出的可执行文件时,只复制静态库中被应用程序引用的目标模块
存档(archive)文件格式
- 一组连接起来的可重定位目标文件的集合
- 有一个头部用来描述每个成员目标文件的大小和位置
- gcc -c addvec.c multvec.c
- ar rcs libvector.a addvec.o multvec.o
- gcc --static main.cpp -L. -lvector
符号解析
将每个符号引用和一个符号定义关联起来
链接器将每个符号引用和输入的可重定位目标文件的符号表中的一个确定的符号定义(即一个输入目标模块中的一个符号表条目)关联起来
此时链接器就知道它的输入目标模块中的代码节和数据节的确切大小
- 符号解析方式
- 在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位文件和存档文件
- 根据一定规则进行符号解析
- 如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析
重定位(链接时重定位)
把每个符号定义与一个内存位置关联起来。即为每个符号分配运行时地址
重定位条目(节和符号定义)
链接器合并所有输入的目标模块中相同类型的节,然后将运行时地址赋给新的聚合节、输入模块定义的每个节以及输入模块定义的每个符号
这一步完成后程序中的每条指令和全局变量都有唯一的运行时内存地址
重定位符号引用
链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址
重定位条目
- 汇编器生成一个目标模块时,并不知道数据和代码最终将放在内存中的什么位置,也不知道这个模块引用的任何外部定义的函数或者全局变量的位置
- 汇编器遇到对最终位置未知的目标引用,就会生成一个重定位条目,如代码的重定位条目放在 .rel.text 节,数据的重定位条目放在 .rel.data 节
- 两种最基本的重定位类型
- 32位 PC 相对地址的引用
- 32位绝对地址的引用
动态链接
共享库
与静态库的代码会被复制到每个运行进程的文本段中不同,共享库以两种不同的方式实现”共享“:
- 一个库对应一个.so文件,所有引用该库的可执行目标文件共享这个.so文件中的代码和数据
- 在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享
共享目标文件
- gcc -shared -fpic -o libvector.so addvec.c multvec.c
- -shared 使用加载时重定位
- -fpic 生成地址无关代码 - gcc -o prog2l main2.c ./libvector.so
符号解析
链接器需要确定一个符号的性质,如果它是一个定义于其他静态目标模块中的函数,那么链接器将会按静态链接的原则进行重定位操作;如果它是一个定义在某共享库中的函数,链接器将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,而把这个过程留到加载时再进行。由于共享库中保存了完整的符号信息,把共享库也作为链接的输入文件之一,从而链接器就可以知道引用的符号的性质。
链接器不对一个动态链接符号进行地址重定位的原因:
- 为了简化内存管理和提高内存利用率,共享目标文件的最终加载地址在编译时是不确定的,而是在加载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享目标文件
重定位(加载时重定位)
基本思路:在链接时对绝对地址的引用不作重定位,而把这一步推迟到加载时再完成。一旦模块加载地址确定,即目标地址确定,那么系统就对程序中指令和数据中的绝对地址引用进行重定位。整个程序是按照一个整体被加载的,程序中指令和数据的相对位置不会改变。
共享目标文件中的可修改数据部分对于不同的进程来说需要有多个副本,所以它们可以采用加载时重定位的方法来解决。但加载时重定位不能完全解决共享目标文件的问题,原因如下:
- 共享目标文件被加载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于加载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的
位置无关代码
那么如何将共享的指令部分在加载时不需要因为加载地址的改变而改变?基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有单独副本。即可以加载而无需重定位的代码------位置无关代码
具体实现待补充
加载可执行目标文件
运行前加载
加载器运行时,它为将为当前运行的 Linux 程序创建运行时内存映像:
加载完全链接可执行目标文件
具体来讲,当shell运行一个程序时,父shell进程生成一个子进程(复制),子进程通过execve系统调用启动加载器。execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序:
- 删除已存在的用户区域
- 映射私有区域
- 映射共享区域
- 设置程序计数器(PC)
加载共享目标文件
共享目标文件是由动态链接器完成链接的,具体来讲是加载器加载了部分链接的可执行目标文件时,注意到其中包含一个.interp节,它包含动态链接器的路径,随后加载器加载和运行动态链接器,后者会将程序所需要的所有共享目标文件加载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的共享目标文件中,并进行重定位操作。在静态链接阶段需要共享库中的重定位和符号表信息:
运行时加载
通过dlopen、dlsym、dlclose系列接口在程序运行时动态加载和链接某个共享库,实现在运行时无需停止服务,就可以更新已存在的函数,以及添加新的函数等
使用代码待补充
虚拟内存
概念
虚拟内存被组织为一个由存放在磁盘上的 N 个连续字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址作为到数组的索引。磁盘上的数组内容被缓存在主存中。VM 系统通过将虚拟内存分割为固定大小的块,称作虚拟页。任何时刻虚拟页面的集群分为三个不相交的子集:
- 未分配
- VM 系统还未分配(或者创建)的页,没有任何数据相关联 - 已缓存
- 当前已经缓存了在物理内存中的已分配页 - 未缓存
- 未缓存在物理内存中的已分配页
页表提供了将虚拟页映射到物理页的信息,假如页表条目由一个有效位和一个n位地址字段组成,有效位被设置时则表示该虚拟页已缓存,地址字段保存的是主存中相应物理页的起始位置;有效位没有被设置时,如果地址字段为空则表明这个虚拟页还未被分配,如果地址字段非空则表示该虚拟页未缓存,这个地址就指向该虚拟页在磁盘上的起始位置。
虚拟内存有以下几点作用:
- 高效使用主存,它将主存看成磁盘的高速缓存
- 简化内存管理,它为每个进程提供一致的地址空间
- 保护每个进程的地址空间不被其他进程破坏
按需页面调度+独立地址空间
操作系统为每个进程提供一个独立的页表,对应一个独立的虚拟地址空间,它可以在以下几方面简化内存管理:
- 简化链接
- 独立地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处 - 简化加载
- 加载器不会从磁盘到内存复制任何数据。在每个页初次被引用时,虚拟内存系统会按照需要自动地调入数据页 - 简化共享
- 独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间共享的一致机制。一般而言,每个进程都有私有代码、数据、堆以及栈区域,操作系统通过将相应的虚拟页映射到不连续(不同)的物理页面,实现不和其他进程共享;对于相同的操作系统内核代码,操作系统通过将不同进程中适当的虚拟页面映射到相同的物理页面,实现多个进程共享这部分代码的一个副本 - 简化内存分配
- 虚拟内存向用户进程提供一个简单的分配额外内存的机制。即可分配连续的虚拟内存页面,但物理内存页面没有必要连续
Linux 虚拟内存系统
Linux 为每个进程维护了一个单独的虚拟地址空间:
Linux 虚拟内存区域
Linux 将虚拟内存组织成一些区域(段)的集合,如代码段、数据段、堆、共享库段、用户栈等。每个存在的虚拟页面都保存在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程引用。内核为系统中的每个进程维护一个单独的任务结构 task_struct:
<img src="https://img2020.cnblogs.com/blog/1053272/202008/1053272-20200809190308762-1542228844.jpg” width = "500" align=center />
Linux 缺页异常处理
<img src="https://img2020.cnblogs.com/blog/1053272/202008/1053272-20200809190501396-1669692770.jpg” width = "500" align=center />
内存映射
概念
Linux 通过将一个虚拟内存区域与一个磁盘上的对象关系起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。虚拟内存区域可以映射以下任一两种类型文件:
- 普通文件
- 一个区域可以映射到一个普通磁盘文件的连续部分,如可执行文件。因为按需页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到 CPU 第一次引用到页面 - 匿名文件
- 匿名文件是由内核创建的,包含的全是二进制零。CPU 第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中为其分配,并且磁盘与内存之间是没有实际的数据传送的
交换空间
待补充
从 free 命令的输出学起
$ free -h
total used free shared buff/cache available
Mem: 62G 4.5G 541M 11M 57G 57G
Swap: 4.0G 129M 3.9G
- used + available ~~ total
- free + buff ~~ available
- Swap:操作系统总是在物理内存不够时,才进行Swap交换
内存
链接
是什么原因导致了 core dump
dopen的使用
运行时替换二进制文件
参考
《深入理解计算机系统》(第3版)
《程序员的自我修养-链接、装载与库》
Linux Swap交换分区介绍总结