在上一节中,我们学习了操作系统对被打开文件的管理,但是对于一台计算机来说,磁盘上大部分的文件是未被打开的,而这些文件也需要被静态管理起来,方便我们随时打开。操作系统对未打开文件的管理,称为文件系统。
要理解操作系统如何对磁盘上的未打开文件进行管理,首先我们需要对磁盘这个设备的物理结构、存储结构与逻辑结构进行理解,然后再在此基础上理解操作系统对磁盘的管理方法。
磁盘的物理结构如下:磁盘主要由永磁铁、磁头、主轴、盘片以及下面的电路板组成,其中盘片为一摞,每一个盘片都分为上下两面,这两面都可以用来存储数据,且每一面都配有一个磁头。
注意事项:
1、磁盘中每个盘片的每一面都配有一个磁头,且磁头和盘面是没有接触的,二者的距离非常非常低,而一旦有灰尘等杂质落入到磁盘中就可能会导致磁头撞击灰尘从而刮花盘面,所以磁盘拆开后就会损坏。
2、现在一般个人的笔记本都是使用固态硬盘 SSD,而不再使用磁盘,因为磁盘的磁头与盘面距离非常近,所以为了避免磁盘与盘面接触而刮花盘面导致数据丢失,磁盘不能抖动;但是笔记本通常要进行移动,很可能会发生上述故障;同时,SSD 的读写速度要高于磁盘。
3、但是在企业端,磁盘仍然是存储的主流,因为企业中主机都统一放置在机房中,轻易不会移动;同时,SSD 存在造价贵、读写次数有限等缺点。
4、磁盘是计算机中唯一一个纯机械结构的设备,同时磁盘还是外设,所以磁盘进行数据读写的速度很慢。
磁盘的存储结构如下:
磁道:磁盘的表面即盘面由一些磁性物质组成,可以用这些磁性物质来记录二进制数据;同时,盘面被划分为一个个同心圆,这些同心圆被称为 磁道 (一个同心圆就是一个磁道),相邻磁道之间是有间隙的,我们的数据就存在磁道上。
扇区:从圆心向外放射,与磁道围成的一小块区域称为扇区,一个磁道会被划分为许多个扇区,每个扇区就是一个 “磁盘块”,这是磁盘寻址的基本单位,即数据进行 IO 的单位,大小一般为 512 byte。(注:每个扇区的大小是固定的,所以从圆心往外,扇区的数据存储密度会随着扇区面积的增大而减小)
柱面:磁盘中所有盘面的同一个磁道被称为一个柱面,可以说,柱面和磁道是等价的。
磁盘寻址的过程:
磁盘在进行 IO 时,首先需要进行寻址,而寻址的过程如下 – 首先定位磁道,即在哪一个柱面 (cylinder);然后再定位盘面,即定位到寻找哪一个盘面的磁头 (head),最后再定位在哪一个扇区 (sector);
上述过程在物理上的表现方式如下:启动主轴后,所有的盘片以同样的方式进行高速旋转,同时所有的磁头也共同从圆心到半径左右摆动,当定位到柱面后,磁头停止摆动,盘片继续旋转,当盘片对应扇区旋转到磁头下方后,对应盘面的磁头向扇区中写入/读取数据。
所以,在磁盘中定位任意一个/多个扇区,采用的基本硬件定位方式是 柱面、磁头、扇区定位,即 CHS 定位法。
拓展知识:
在大型公司中,一个磁盘通常是几个T,里面保存着大量的用户数据,为了用户数据安全,企业需要要对不用/损坏的磁盘进行消磁;
常见的磁盘消磁方法有两种:一、加热,其缺点是销毁成本高,销毁的磁盘不能回收造成浪费,并且不能保证所有盘的数据全部消磁。二、向磁盘中写入垃圾数据或将磁盘格式化,其缺点是某些磁盘厂商有能力恢复之前的数据。
所以一般大公司都会和磁盘厂商进行协商,要求厂商提供磁盘深度清理的定制协议功能,比如企业通过向磁盘输入协议密文来对磁盘进行深度清理。
注:一般大型公司都有自己固定的磁盘供应厂商,该企业的大部分磁盘都由该厂商提供,但是企业选择磁盘是不会全部都选一家公司的盘的,一是防止杀熟,二是防止磁盘批量化故障。
我们以磁带的结构来引出磁盘的逻辑结构,如图,磁带盒里面一共有两个齿轮,其中一个齿轮上面缠绕着一圈圈的磁带,当我们把磁带盒插入磁带录音机后,磁带里面的音频数据就会读取然后通过录音机播放出来;当我们把磁带盒拆开后,我们可以发现,磁带扯出来后其结构是线性的,也就是说,磁带里面的数据是按线性方式来读取的。
对比到磁盘,磁盘的盘面由一个个磁道构成的,且这些磁道都是同心圆,和磁带卷起来时一模一样,那么我们也可以将磁盘结构抽象为线性结构,然后使用数组来存储数据:
如上,我们将整个磁盘从逻辑上看作一个 sector arr[n] – 数组的一个元素代表磁盘中的一个扇区,然后由多个扇区组成一个磁道,由多个磁道组成一个扇面,最后在由多个磁道组成整个磁盘。
自此,我们只需要知道数组中的一个下标就可以定位磁盘中的一个扇区,我们对磁盘的管理也转变为了对数组进行管理;在操作系统内部,我们将这种地址称为 LBA (logical block address) 地址。
LBA 地址转 CHS 定位例子:
假设一个磁盘有两个盘片,每个盘片有两个盘面,每个盘面有10个磁道,每个磁道有100个扇区;现在,某个扇区的LBA地址为1234,求该扇区在磁盘上的具体位置:
最后,操作系统为什么要对 CHS 进行逻辑抽象呢?有如下两个原因:
- 数组更便于管理;
- 不让操作系统代码与底层硬件强耦合 – 即使磁盘的存储结构改变,操作系统仍然可以使用 LBA 地址进行定位寻找,只需要改变 LBA 与磁盘扇区的映射关系即可。
在上面我们提到,磁盘的 IO 单位是一个扇区大小,即 512 byte,但这还是太小了,为了减少 IO 次数,提高 IO 效率,操作系统的文件系统会定制的一次进行多个扇区的读取,如 1KB/2KB/4KB。(文件系统默认采用 4KB 大小为单位 (8个扇区) 进行IO)
即内存被划分为了许多个 4KB 大小的空间 (页框),磁盘中的文件尤其是可执行文件也由多个 4KB 大小的块组成 (页帧)。
注:文件系统以 4KB 作为数据 IO 的单位,那么当读取的数据小于 4KB 时,我们仍然需要读取 4KB 数据,那么就有同学可能担心数据无用的问题,其实,计算机中的局部性原理已经很好的解决了这个问题。
企业中的较小的磁盘有 500G,较大的磁盘有几个G,但是文件系统的 IO 单位只有 4KB,这就存在磁盘太大不方便管理的问题,所以我们需要对磁盘进行区域划分;但是磁盘分区之后仍然比较大,所以我们需要继续对分区进行分组,如下:
注意:每个分区的第一部分数据是 Boot Block 启动块,后面才是各个分组,它与计算机开机相关,我们不用关心。
现在,我们只需要管理好一个分组,然后管理模式复制到其他分组就可以管理好一个分区;再将一个分区的管理模式复制到其他分区就可以管理好整个磁盘了。其中,操作系统对一个分区的管理就被称为文件系统。
上面这种管理的方法被称为分治,分治的管理方法在我们生活中其实非常常见,比如国家管理划分为省、市、区、乡镇、村,快递管理分为 A/B/C 等货架,每个货架有分为1/2/3 等层,等等。
文件系统
下图为磁盘文件系统图(内核内存映像有所不同),磁盘是典型的块设备,硬盘分区被划分为一个个的 block,一个block的大小是由格式化的时候确定的,并且不可以更改。(启动块(Boot Block)的大小是确定的)
Super Block
超级块,属于整个分区,保存的是整个分区的信息,主要有:bolck 和 inode 的总量,未使用的block和inode的数量,一个block和inode的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了;
之所以将 super block 放在分组里面,而不是和 boot block 一样放在分区的最前面,是为了数据安全 – 当常用分组里面 super block 的数据损坏之后,我们可以直接从其他分组将正确数据复制过来。
注:super block 不是每个分组里面必备的数据,有些分组里面并没有 super block。
inode Table
文件的节点表,保存了分组内部所有文件的 inode 块(已用+未用),inode 块的大小是固定的 (128/256 byte),其中包含了一个文件所有的属性信息 – 文件创建时间、文件权限、文件大小、文件类型等等,每个文件都有其对应的 inode。(inode 里面没有文件名)
注:inode 为了区分彼此,每个 inode 都有自己的 ID,可以在 ls 指令中通过 i 选项查看。
inode Bitmap
inode 位图,里面每个比特位都对应着 inode table 的一个下标,比特位为0表示该小标对应的 inode 未被使用,为1表示该 inode 已被占用。
Data blocks
数据块,保存了分组内部所有文件的 data block 块,数据块的大小不是固定的,它随着应用类型的变化而变化,其中包含了一个文件的全部/部分内容 (文件内容太多时需要使用多个 data block)。
Block Bitmap
数据块位图,里面每个比特位都对应着 data blocks 的一个下标,比特位为0表示该小标对应的 data block未被使用,为1表示该 data block 已被占用。
Group Descriptor Table (GDT)
块组描述符表,描述块组属性信息
新建文件
在了解了一个分组的具体组成之后,如何新建文件也显而易见了 – 在 inode bitmap 里面查找为0的比特位编号,将该比特位置1,然后将文件的所有属性填写到 inode table 对应编号下标的空间中;再在 block bitmap 中查找一个/多个为0的比特位编号,将其置为1,然后将文件的所有内容填写到 data blocks 对应编号下标的空间中;最后再修改 super block、GDT 等位置中的数据。同时,需要将新文件文件名与 inode 的映射关系写入到目录的 data block 中。
获取文件 inode
在 Linux 中,查找文件统一使用 inode 编号,但是我们平时只适用过文件名,从没有使用过 inode,那么操作系统是如何将文件名与 inode 一一对应的呢?答案是通过目录。目录和普通文件不同,目录的内容是下级目录或者普通文件,所以目录的 data block 里面存储的是当前目录下文件名与 inode 的映射关系。
所以当我们在某一个目录下使用文件名查找文件时,操作系统会读取目录 data block 里面的数据,找到文件名对应的 inode 编号,找不到就提示 文件不存在。而当我们在目录下新建文件/文件夹时,操作系统会向目录 data block 里面写入新文件与 inode 的映射关系。这也是为什么在目录下读取文件信息需要 r 权限,在目录下新建文件需要 w 权限的原因。
读取文件属性
先通过目录 data block 得到文件的 inode 编号,然后在 inode bitmap 查看对于编号比特位是否为1,检查 inode 有效性,然后从 inode table 中读取对应 inode 中的文件属性。
注:inode 编号可以跨分组,但不可以跨分区,即同一分区内 inode 是统一编号的。
读取文件内容
读取文件内容比较复杂,首先需要通过 inode 读取文件信息,然后通过 inode 结构体的内容查找 data block 编号,再到 block bitmap 中查找对应比特位是否是否为1,检查有效性,最后在从 data block 中读取文件内容。
struct inode {
int id;
mode_t mode;
int uid;
int gid;
int size;
//...
int blocks[15];
};
注:一般来说,blocks 里面前12个元素存放的都是一个 block 编号,代表 data blocks 里面的一块空间,但是最后三个元素不同,虽然它们存放的也是一个 block 编号,但 data blocks 对应 block 编号中存放的内容却很特殊,blocks[12] 指向的 data block 中存放的是一级索引,即其中存放的也是一个类似于 blocks[15] 的数组,指向多个 data block;blocks[13] 指向的 data block 中存放的是二级索引,即其中存放的内容类似于 blocks[12];以此类推,blocks[14] 里面存放的是三级索引。这样,即使该文件很大,操作系统也能够成功读取文件的内容。
删除文件
删除文件很简单,只需要将 inode bitmap 和 block bitmap 里面对应比特位置为 0 即可,后面新文件的文件属性和文件内容直接覆盖原来已删除文件的属性和内容。
恢复文件
在理解了删除文件的原理之后,我们就明白文件删除之后是可以恢复的 – 操作系统包含了文件的日志信息,会将文件名与 inode 的映射关系保存在一个临时的日志文件里,我们通过这个日志文件找到误删文件的 inode,然后将 inode bitmap 里面对应的比特位重新置为1,再通过 inode 结构体中的 blocks 成员找到所有的数据块,再将 block bitmap 中对应比特位置为1即可;
不过这一切的前提是原文件的 inode 没有被新文件使用 – 如果新文件使用了原文件的 inode,那么对应的 inode table 以及 data block 里面的数据都会被覆盖,所以文件误删之后最好的做法就是什么都别做,避免新建文件将原文件的 inode 占用。
在 Linux 中,我们可以通过 ln 指令来为一个文件创建硬链接,如下:
//为myfile.txt文件创建硬链接hard_myfile.link
ln myfile.txt hard_myfile.link
如图,我们可以观察到几个现象:
创建硬链接会改变原文件的硬链接数; (文件权限后紧跟的数字代表文件的硬链接数)
硬链接文件与原文件的文件属性完全相同,即硬链接文件与原文件使用同一个 inode;
同时,我们还可以看到:向硬链接文件中写入数据时原文件中也会存在该数据,且删除原文件后硬链接文件除了硬链接数减1以外并不会受影响;
我们在学习文件系统时说过,inode 是一个文件的唯一标识,它里面存放着文件的所有属性,每一个文件都有自己独立的 inode,但是硬链接文件没有,它与原文件使用同一个 inode。
所以,创建硬链接不会创建新文件,硬链接文件仅仅是原文件的一个别名,它使用原文件的 inode 和 data block;而创建硬链接的本质其实仅仅是在指定目录下新增原文件 inode 与硬链接文件名的映射关系,同时将原文件的硬链接数加1。
注:文件 inode 中存在一个类似于 count 的整形变量来记录文件的硬链接数,当我们为文件创建硬链接时 count 加1,删除原文件或者硬链接文件时 count 减1 (C++中称为引用计数,Linux中称为文件的硬链接数),这也就是我们上面观察到文件的硬链接数发生变化的原因;所以,当一个文件的硬链接数变为0时,操作系统才会真正删除该文件,即执行将该文件的 inode bitmap 和 block bitmap 对应比特位置0等操作。
在 Linux 中,我们可以通过 ln 指令带上 ‘s’ 选项来为一个文件创建硬=软链接,如下:
//为myfile.txt文件创建硬链接hard_myfile.link
ln -s myfile.txt soft_myfile.link
可以看到:
- 软链接文件的文件类型为 ‘l’ (第一个字母代表文件类型),即链接文件;
- 软链接并不会改变原文件的硬链接数;
- 软链接拥有自己独立的 inode,是一个全新的文件,所以软链接文件的文件属性和原文件并不相同。
同时,可以看到:和硬链接一样,软链接文件向文件中写入数据时原文件中也会存在该数据,但是当原文件被删除后,再次查看软链接文件报错。
由上面的现象我们可以推断,软链接是通过文件名而不是文件 inode 来链接文件的,因为上面的原文件存在硬链接文件,而硬链接文件与原文件的 inode 是相同的,但是我们删除原文件后软链接直接失效了;同时,我们创建了一个与原文件文件名相同的新文件,尽管该文件与原文件的 inode 并不相同,但软链接仍然重新建立了。(我们也可以使用 unlink 指令来删除链接文件)
所以,创建软链接会创建新文件,软链接文件有自己的独立的 inode,软链接通过文件名的方式来链接文件,所以本质上软链接是将原文件的路径写入到新文件的 data block 中。
注:Linux 中的软链接就相当于 Windows 中的快捷方式,通过该快捷方式我们可以快速方便的对目标文件进行操作。
我们上面学习了软硬链接,知道了硬链接相当于文件的别名,其本质是在指定目录下新增原文件 inode 与硬链接文件名的映射关系,软链接相当于快捷方式,本质是将目标文件的路径写入到软链接文件的 data block 中;那么软硬链接有什么用呢?
软链接的作用
软链接最常见的作用之一就是作为快捷方式使用,如下:test 程序在很深的路径下,以至于我们每次运行它都很不方便,此时我们就可以为它建立一个软链接。
硬链接的作用
Linux 中每个目录下都存在两个隐藏目录 . 和 …,其中 . 代表当前目录,… 代表上级目录,而它们本质是某一个目录的硬链接,如下:
我们新建一个普通文件,其硬链接数是1,因为普通文件本身就有一个文件名与 inode 相对应;而我们新建一个目录时,目录的硬链接数为2,这是因为 Linux 中目录下存在隐藏的 .,它是目录的一个硬链接;如果我们在当前目录下再新建一个下级目录,那么当前目录的硬链接数就会变为3,这是因为下级目录中的 … 也是当前目录的一个硬链接。(这也是为什么 cd … 能够回退到上级目录的原因,因为… 是上级目录的一个别名)
注:Linux 不允许用户给目录创建硬链接,只能系统自己创建 (. 和 …),这是因为给目录创建硬链接可能会在目录中引入循环,使得在目录遍历时系统陷入无限循环当中,从而导致无法定位到访问目录 (比如系统从根目录开始查找一个文件,当在查找过程中遇到根目录的硬链接时就会造成环路查找)。
我在之前的博客中已经对动静态库以及动静态链接进行了基本的介绍 – 链接方式与函数库,这里就直接总结一下:
- 静态库 (.a):程序在编译链接的时候把库的代码链接 (拷贝) 到可执行文件中,程序运行的时候将不再需要静态库。
- 动态库 (.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
什么是库
我们以一个简单的加减函数来引入库:
//add.h
#pragma once
#include
extern int Add(int a, int b);
//sub.h
#pragma once
#include
extern int Sub(int a, int b);
//add.c
#include "add.h"
int Add(int a, int b) { return a + b; }
//sub.c
#include "sub.h"
int Sub(int a, int b) { return a - b; }
//mian.c
#include "add.h"
#include "sub.h"
int main() {
int a = 20;
int b = 10;
int ret = Add(a, b);
printf("%d + %d = %d\n", a, b, ret);
ret = Sub(a, b);
printf("%d - %d = %d\n", a, b, ret);
return 0;
}
现在,我们分别将它们进行汇编得到可重定向二进制目标文件,最后在同一汇编即可得到可执行程序:
上面的过程是一个程序实现的过程,然而对于一个库来说,库里面是不需要存在 main 函数的,因为库仅仅作为某一个功能的实现来供库的使用者调用,所以实际上只有 add.h add.c sub.h sub.c 属于库;同时,库的提供者通常不希望暴露函数的源代码,所以早期给别人提供函数时通常只提供头文件 (有哪些方法) 和 经过汇编后得到的可重定向二进制目标 .o 文件 (方法的实现)。
但是这样存在一个问题 – 如果一个库文件中的方法非常多的话,就需要向库的使用者提供对应个数的 .o 文件,非常麻烦且容易丢失;所以我们就尝试着将所有的 “.o” 文件进行打包,然后给对方提供打包得到的一个库文件,这就是库文件的由来,而根据打包工具和打包方式的不同,又分为动态库和静态库。
所以,库的本质是 .o 文件的集合。
静态库的制作
制作静态库就是将多个 .o 文件打包到一个文件中,所以我们可以使用 Linux 中的归档工具 ar (rc : replace and create):
注:我们最好将生成 .o 文件以及归档 .o 文件这些操作都统一到 makefile 中。
上面我们将所有 .o 文件打包成了库文件,但是别人使用库文件时还需要头文件,所以我们可以做一个发布版本,将头文件也拷贝到对应路径下:
现在,我们的软件就已经发布出来了,我们就可以将其打包然后放在网站或者yum的资源中供别人进行下载使用了:
静态库的使用
静态库的使用首先需要从 yum 源或者其他地方将软件包下载下来,我们自己制作的直接 cp 然后解压即可:
然后,我们在进行 gcc 编译汇编时需要通过 I 选项来指定头文件路径、通过 L 选项来指定库所在路径、以及通过 l 选项来指定库名称,最终得到可执行程序:
注意:当我们链接库时,必须指定库的名称,这是因为同一路径下可能同时存在许多库 (头文件不需要指定名称,只需指定路径,因为 main 中指明了我们需要的头文件名称),同时,库需要去掉前缀 lib 和 后缀 .a/.so 才是库真正的名称,这里需要特别注意。
拓展:我们之前连接程序从来没有指明过库名称,这是因为 gcc/g++ 默认帮我们填写了库名称 – gcc/g++ 是 C/C++ 专门的编译器,且我们之前从来没有使用过第三方库,即 C/C++ 自带的库它能够帮我们默认填写。
虽然现在已经成功形成可执行程序并运行,但是这里还存在一个奇怪的地方:mymath 的依赖库中并看不到 libmymath.a,并且 mymath 是动态链接的;
这是由如下原因造成的:
1、Linux 默认使用动态链接,这是针对动静态库都存在的情况说的,如果只存在静态库,那么 Linux 也只能使用静态链接,同样,如果只存在动态库,即使指明 static 选项也只会使用动态链接;
2、同时,一个可执行程序的形成可能不仅仅只依赖一个库,如果依赖的库中有一部分不只有静态库,有一部分库有动态库,那么形成的可执行程序整体是动态链接的,但其中只有静态库的地方也只能拷贝;
3、这里的现象和第二点一样,mymath 的形成不仅仅依赖一个库 (使用了 C 语言库函数),且Linux存在C语言动态库,所以这里是使用动态链接的,我们自己的库 libmymath.a 以静态的方式进行链接。
最后,除了指定头文件路径和库文件路径的方式,我们也可以直接将头文件和库文件拷贝到系统头文件及库文件路径下 (本质上就是安装),这样下次就可以指定库名称后直接链接了:
注:测试完成后记得删除对应目录下的文件,避免污染系统库。
动态库的制作与使用
动态库的制作和静态库存在很多相似的地方,但也有不同:
1、动态库汇编形成 .o 文件需要指定 fPIC 选项,用于形成位置无关码;
2、动态库归档不使用 ar 指令,gcc 中指定 shard 选项就可以完成归档工作。
注:理解位置无关 – 假设在一个班级中,由于某种特殊原因,无论怎么换位置,张三和李四永远是同桌,那么此时要定位张三有两种方法,一是指明某排某列,二是指明李四的位置,即绝对位置定位和相对位置定位,而位置无关就相当于相对位置定位,不用管位置变动,只需要确定李四的位置以及张三与李四之间的偏移量即可。
归档后的工作就和静态库一模一样了 – 发布、压缩、下载、指定头文件路径、库文件路径以及库文件名称:
现在一切准备就绪,但是当我们运行程序的时候却发现,程序运行出错了,找不到库文件:
这是因为我们的库路径只告诉了 gcc,而 gcc 只工作到可执行程序形成,之后就与 gcc 无关了,但是动态库是程序在运行的时候才去链接动态库的代码的,而操作系统和 shell 并不知道库文件的位置,所以我们还需要在程序运行时告诉操作系统动态库的位置,而程序运行时操作系统会去两个地方查找动态库,一个是默认库路径下 (lib64),另一个就是环境变量 $LD_LIBRARY_PATH 中,所以我们可以将我们的库文件添加到这两个地方。
注:使用 export 配置环境变量只在本次登录有效,如果希望其永久有效,我们可以将其写入到配置文件 “/etc/ld.so.conf.d/” 中,即在该目录下新建一个文件,然后将库文件的路径写入其中,最后使用 ldconfig 更新缓存即可。
最后,我们还可以在系统库文件目录下为我们自己的库文件建立一个软链接,这也是永久永久有效的:
在学习动态库的加载策略之前,我们要先明白:**静态库是不需要加载的。**原因如下:
通过 虚拟进程地址空间 的学习我们知道,进程地址空间不仅使得进程能够以统一的视角来看待内存的各个区域 – 每个进程都认为自己独享整个内存空间,且自己的数据被放置在对应的区域,如代码段、数据区、栈区等等;同时它还让编译器也以相同的视角来进行代码的编译工作 – 程序在编译时就已经按照进程地址空间的划分规则来对不同的数据进行地址分配了;也就是说,可执行程序即使没有被加载进内存,也没有生成 mm_struct,其内部也存在代码段、全局数据区等空间区域!
而静态链接是在多个可重定向文件进行链接时直接将静态库中的代码拷贝到代码段中,最终形成可执行程序;那么后面程序运行时将对应数据加载到虚拟内存的对应区域、建立页表映射、执行代码等系列过程与静态库就完全无关了,所以静态库不需要加载。
虽然静态库不需要加载,但是它存在另一个缺陷 – 如果多个进程调用同一个静态库,由于每个进程的代码段中都存在该静态库代码,那么程序加载后物理内存中也会存在多份静态库代码,然后通过页表映射到不同进程的地址空间代码段处,造成物理内存浪费。
现在,我们来正式学习动态库加载:
如图,对于动态链接来说,可执行程序中存放的是动态库中某具体 .o 文件的地址,同时,由于组成动态库的可重定向文件是通过位置无关码 fPIC 生成的,所以这个地址并不是 .o 文件的真正地址,而是该 .o 文件在动态库中的偏移量;
然后就是程序运行的过程:操作系统会将磁盘中的可执行程序加载到物理内存中,然后创建 mm_struct,建立页表映射,然后开始执行代码,当执行到库函数时,操作系统发现该函数链接的是一个动态库的地址,且该地址是一个外部地址,操作系统就会暂停程序的运行,开始加载动态库;
加载动态库:操作系统会将磁盘中动态库加载到物理内存中,然后通过页表将其映射到该进程的地址空间的共享区中,然后立即确定该动态库在地址空间中的地址,即动态库的起始地址,然后继续执行代码;
此时操作系统就可以根据库函数中存放的地址,即 .o 文件在动态库中的偏移量,再加上动态库的起始地址得到 .o 文件的地址,然后跳转到共享区中执行函数,执行完毕后跳转回来继续执行代码段后面的代码。这就是完整的动态库的加载过程。
注:动态库可以避免静态库内存空间浪费的问题,这是由于如果多个进程链接了同一个动态库,动态库也只需要加载一次 – 动态库被加载到物理内存中并通过页表映射到某一个进程 (假设A进程) 的共享区之后,操作系统会记录该动态库在A进程共享区中的地址,当其他进程也需要执行动态库代码时,操作系统会根据记录的地址加上偏移量通过页表跳转到A进程的共享区中执行函数,执行完毕后再跳回到当前进程地址空间的代码段处。所以从始至终物理内存中都只有一份动态库代码。