【Linux】基础IO-文件系统和动静态库
文件系统
上面都是对打开的文件进行操作,打开的文件只是小部分,操作系统中还存在大量没有被打开的文件存放于磁盘中,这些文件被称为磁盘文件。操作系统是如何快速找到物理层面的文件的呢?
物理磁盘
这里先来浅浅地了解一下磁盘的物理结构,如下是一块机械硬盘
其中圆盘状的叫做盘片,一个盘片有正反两个盘面;悬浮在盘片上面的东西叫做磁头,一个盘面就有一个磁头。一块盘可能有多个盘片
如果一块机械硬盘有 6 个盘片,那么就有 12 盘面,对应地就有 12 磁头
这样的盘是如何存储文件的呢?我们知道,计算机只认识二进制,所以文件的数据都会被转换为二进制数据。表示二进制的方式是很多的,例如高低电位、磁性等。这里的磁盘采用的就是用磁性表示二进制位,向磁盘中写数据就是磁头改变盘面的磁性
为了读写数据,盘面可以高速自转,磁头可以左右摇摆,至于为什么是这种方式,我们后面就会知道
磁盘的存储结构
磁盘的盘面被划分为一个个同心圆,每个圆都叫做磁道;每个磁道又被划分为一个个扇区;多个盘片的磁道构成了柱面
每个扇区的大小是 512 字节,磁盘进行读写的基本单位就是 512 字节。就是说即使只往磁盘中写入 1 比特位的数据,也要把 512 个字节加载到缓冲区,进行修改,然后把 512 字节的数据写入到磁盘
基本单位是扇区,也就是文件的数据都会被存放到一个个扇区中,那么读写数据时,该如何定位呢?
- 首先要找到数据位于哪个盘面,也就是寻找磁头 Header
- 定位盘面后,找到数据所在磁道(柱面)Cylinder
- 最后寻找数据所在扇区 Sector
拥有 Header、Cylinder、Sector 就可以定位数据了,这种方法叫做 CHS 寻址法
定位扇区需要盘片自转;定位盘面和磁道需要磁头
对磁盘的存储进行抽象
CHS 地址并不存在于操作系统中,是磁盘自己维护的,所以想在操作系统中管理磁盘,就需要将其存储结构抽象化,就像操作系统管理进程那样——先描述再组织
如果把磁盘像磁带一样展开,那么我们就会得到一个很像数组的结构,那我们就可以把它当做数组使用,每个元素都是一个扇区,通过数组的下标就可以找到某个扇区
但是硬盘还是使用 CHS 进行寻址的,所以应该把数组下标转换为 CHS。假设一个盘面有 1000 扇区,有 10 个磁道,那么 1 个磁道就有 10 个扇区。假设现在有下标 index,把 index 转换为 CHS:
- 每个盘面有 1000 扇区,那么 index / 1000 = H 就是要寻找的盘面号
- 接下来定位是哪个磁道,index % 1000 = tmp,代表是在当前盘面的第 tmp 个扇区
- 一个磁道有 100 扇区,tmp / 100 = Cylinder 得到所在磁道
- tmp % 100 = Sector 就是当前磁道的第几个扇区
所以,文件 = 很多个 sector 数组下标,根据下标就可以定位到磁盘扇区,访问文件内容
对于操作系统而言,一个扇区太小了,访问起来效率不高,于是规定:操作系统和磁盘交互时的基本单位是 4 KB,也就是 8 个连续的扇区
这样的 8 个扇区叫做块,对 sector 数组的管理就变成了对块数组的管理
操作系统读写文件时,就会以块为单位进行读写,那么块还能转换为CHS地址吗?可以的,因为每个块是 8 个 扇区,只要知道块号,就可以得到块的地址,也就是当前块首个扇区的地址,上面的转换方法同样适用。例如块号为0的首个扇区下标是 0*8 = 0,得到第一个扇区下标,连续读 8 个,就是一个块,这 8 个扇区都可以转为 CHS 地址
所以说,只要知道起始地址,磁盘总大小,有多少个块,再加上块号,就可以转换到多个 CHS 地址,每个块的起始地址叫做 LBA(logical block address)逻辑区块地址。这样就可以把磁盘空间看做一个个块了,对磁盘的管理就变成了对块数组的管理。到目前为止,我们对文件的理解就是:文件 = 多个LBA地址
当然,整个硬盘的空间是非常大的,而一个块的大小也不过是 4 KB,所以如果想把一整个硬盘管理好,最好将其分为几个区,只要管理好一个分区,那么经验就可以套用到其他分区。相当于管理好一个分区就是管理好整个硬盘
宏观理解文件系统
在此之前,我们需要知道,文件 = 属性 + 内容,文件的属性也是数据,只要是数据就需要存储起来。所以即使我们创建一个空文件,内容为空,文件也要占用空间来存储它的属性数据,例如创建时间、文件权限等
上面我们说到,为了更好地管理硬盘,需要进行分区;然而一个分区通常情况下也很大,所以需要将分区分为数个块组,方便管理
每个块组的结构如下:
Data blocks
之前说过,我们可以把硬盘的基本单位看做一个个数据块,而 Data blocks 中就是大量的数据块,它的大小占据了块组的绝大部分空间,用来存储文件的内容,是真正存储文件数据的区域
Block Bitmap
那么在 Data block 中那么多的数据块,如何知道哪个被占用了,哪个没有被占用呢?——Block Bitmap 就是用来表示数据块占用情况的块位图。Black Bitmap 的基本单位也是数据块,大小为 4KB = 10244 = 4096B = 40968 = 32768 比特位。也就是说 Block Bitmap 中的一个数据块就可以表示 Data blocks 中 32768 个数据块的占用情况
inode Table
inode 是什么?在 Linux 文件系统中文件内容和文件属性是分开存储的,文件内容存储在 Data blocks 中。那么属性存在哪里呢?
文件属性存储在一个数据结构 struct inode 中,一个文件对应一个 inode 结构,大小一般是 128 字节。为了保证每个 inode 的大小相同,方便管理,所以 inode 中并不存储文件的名字,而是由 int inode_number 来唯一标识文件;同时 inode 中还有一个重要的数据 int datablocks[N],用来定位 data blocks 中文件的内容,也就是说只要找到了 inode,就可以找到文件的内容,这一点放在后面说
话说回来,inode Table 就是用来存储文件属性 inode 的,一个数据块是 4096 字节,而一个 inode 大小是 128 字节,那么一个数据块就可以存储 32 个inode
inode Bitmap
inode 位图的作用和 Block Bitmap 的作用相同,用来记录 inode Table 的使用情况
Group Descriptor Table
GDT,块组描述符表,用于描述块组的各种属性信息。例如 块位图 和 inode位图 的位置信息和使用情况、组内的空闲块数量等等
Super Block
超级块,用于描述整个分区的属性,而不是块组的信息。为什么这样一个分区级别的属性集会存在于块组中呢?难道超级块存放于每个分区的首个分组吗?——超级块少量且分散地存放于各个分组中,为什么要这么存放呢?
因为超级块存着整个分区的属性,如果超级块所在的硬盘盘面被刮花了,那么整个分区就废掉了。所以为了处理这种情况,超级块通常存在很多个,一个损坏了,可以通过其他的超级块来修复,这样分区就更加健壮了
以上大概就是分组的结构,所以说硬盘分区后不是马上可以使用,需要往硬盘中写入以上的文件系统管理信息,这个过程叫做格式化
文件系统的细节
上面粗略地了解了一下文件系统的大概框架,下面我们来处理一些细节问题。
访问一个文件,通过 inode 编号就可以找到 inode,接着通过 inode 里面的数据,可以在 data blocks 中定位到文件的内容。那么 inode 和 data blocks 的分配是怎么进行的呢?
inode 和 data blocks 的分配
首先要明确的是,inode 只在分区内有效,也就是说不同分区,可能存在相同的 inode 编号。每个分区的 super blcok 都会记录各个分组的 inode 的范围,每个分组的 GDT 也会记录当前分组的 inode 范围。
假设每个分组有 10000 个 inode,那么 group0 的 inode 编号就是 0~10000,group的inode编号就是 10001~20000…以此类推
这样,每个分组的 inode 范围都会被记录,当我拥有一个 inode 编号,例如 10010,通过查看 super block 就可以确定当前 inode 在哪个分组中
但是如何将 10010 映射到 inode 位图中呢?位图的都是从 0 开始的呀,只需要用当前 inode 编号减去当前分组的起始 inode 编号即可。例如 10010 - 10001 = 9,那么只需要查看在 inode 位图中的第九个位置合不合法就可以了,如果合法,就可以直接映射到 inode Table 取到相应的 inode 结构,通过其中的属性就可以定位到 Data blocks 中的文件内容了
而 Data blocks 中的数据块的分配也是同理,super block 会记录每个分组的数据块起始与终止位置,GDT 会记录当前分组的数据块起始终止位置
inode 与 data blocks 建立映射
上面一直在说,凭借 inode 中的属性,就可以定位到 Data blocks 中的文件内容,那么这是怎么做到的呢?
之前提过,在 struct inode 中存在一个数组 int datablocks[N],这个数组中就记录着当前的文件内容占据了 Data blocks 中的哪些数据块,N 的大小一般是15
可是 15 个块,大小也才 60 KB,那么文件很大该怎么存储呢?
- inode 指向的数据块直接存储文件的数据,这种是直接存储,如上图
- inode 指向的数据块不直接存储数据,而是存储其他数据块的地址,由其他数据块来存储文件数据,这种是间接存储。一个数据块可以存储 4096/4 = 1024 个块的地址,这样一来一个 inode 就可以指向一块很大的空间了。一级间接不行就上二级间接,二级不行就上三级,总能存下对应的文件
如何获取 inode
上面操作的前提都是拿到了 inode 编号,但是我们说过 struct inode 中不存在文件名,而我们平时在操作文件时只有文件名啊,如何通过文件名获取相应的inode呢?说到这里,就不得不谈一下目录了。
在 Linux 中,一切皆文件,所以目录也是文件,而文件=内容+属性。目录的属性很容易理解,就是目录的创建时间、创建者、目录权限等相关信息。可这目录的内容是个什么意思呢?
目录的内容存放的就是此目录下的文件的**<文件名, inode> 映射关系**,也就是说我想拿到当前文件的 inode,就必须拿着文件名到目录的内容中寻找对应的 inode
所以此时可以有以下理解:
- 查找文件的流程:拿着文件名到目录内容寻找对应的 inode,拿到 inode 之后到对应分组寻找 struct inode 拿到文件内容下标,定位到文件内容
- 同一目录下不能有重复的文件名,因为文件名与 inode 一一对应
- 目录的 r 权限,本质就是允许我们读取目录的内容。如果禁掉 r 权限,那么就拿不到文件的 inode,自然也就访问不到文件了
- 目录的 w 权限,本质就是允许我们建立 文件名与 inode 的映射关系。如果禁掉 w 权限,那么就不可以在目录内容中建立 文件名与inode映射,也就创建不了文件了
增删查改的理解
- 增加文件:先分配 inode 编号和数据块,然后将文件属性与内容分别存入到 inode Table 和 Data blocks。返回分配的 inode 编号,与文件名建立映射关系,存入文件目录内容中
- 查改文件:先通过文件名到目录内容获取相应的 inode 编号,之后到对应分区寻找文件属性,再通过文件属性定位文件内容
- 删除文件:删文件并不需要真的删除文件存储在 inode Table 中的属性 和 Data blocks 中的内容,只需要修改位图即可,将 inode Bitmap 和 Block Bitmap 中的 1 改为 0,标志着文件的属性和内容已经失效,那块空间可以随时被数据覆盖
深入理解细节问题
逆向路径解析
上面说到,寻找文件的 inode 需要到目录的内容找,那么目录也是文件啊,我们访问目录的内容,也是需要找到目录的 inode,进而访问目录内容。那么目录的 inode 怎么获得呢?当然是去目录的目录的内容去找,然后目录的目录也是文件……,这样循环向上寻找,直到找到根目录/
,根目录的 inode 下存储了直接子文件和子目录的文件名与 inode 的映射关系。可以理解根目录就是一个递归出口,从根目录层层返回,这就是逆向路径解析
所以说,我们访问一个文件就必须要有目录。但是有时候我们明明就没有提供目录,就可以访问文件,例如在程序中使用 open
接口打开文件时,只需要写文件名,这是因为进程在启动时会在 PCB 中储存当前工作目录,使用 open 访问文件时,可以将 当前目录与文件名 拼接起来,这样就有了目标文件的路径了
目录一般都是由用户或者用户启动的进程提供的
那么每次打开文件都需要这样逆向路径解析,是不是有点慢呢?是这样的,所以 linux 一般都会缓存路径结构,第一次打开时需要逆向路径解析,但是有了缓存之后,后面就不需要了
这就意味着,Linux 内核在被使用时,一定会存在大量的解析完毕的路径结构,那么操作系统就需要将它们管理起来,如何管理呢?——先描述,再组织。就像进程和PCB那样,存在一个结构体 struct dentry 来存放路径解析的信息,一个文件对应一个 dentry。了解即可
如何确认 inode 在哪个分区
以上我们使用 indoe 寻找文件的前提是已经确定了分区,因为 inode 只在分区内有效,每个分区都有自己的一套 inode
这里就要引入一个概念——挂载,将一个分区(文件系统)关联到一个目录。访问某个目录就是访问某个分区,目录就像一个窗口,通过目录可以进入到被挂载的分区内进行文件操作
例如当前机器只有一个盘 vda,而 vda1 就是这个盘的分区
可以使用 df -i
查看分区挂载到哪个目录
可以看到,当前机器的硬盘的唯一一个分区 vda1 被挂载到了根目录/
,当我们在根目录进行文件访问时,就是在 vda1 分区访问文件
到这里,我们知道:分区被创建时不能立刻使用,而是要先分组写入文件系统(格式化),然后是挂载到指定目录下,进入该目录,就可以在相应的分区进行文件操作
所以说,访问文件时一定要有目录,不但是为了找到文件的 inode,还是为了确定分区
软硬链接
如何创建软硬链接
软硬连接是什么呢?先不管,直接创建看看
现有一文件 log.txt,里面随便写了一些信息
使用以下命令创建软链接,ln 代表 link,s 代表 soft
ln -s 目标文件名 软链接文件名
使用 ll -i 查看文件 inode 编号
发现软链接是指向目标文件 log.txt 的,并且软链接拥有自己的 inode 编号,说明软链接是一个独立的文件,拥有自己的文件属性与文件内容。那么软链接的文件内容是什么呢?软链接文件内容就是目标文件的路径字符串,有了文件的路径就可以通过软链接访问到目标文件
如果删除目标文件,那么目标文件的路径也就失效了,所以软链接就会失效。这不就是 Windows 系统的快捷方式吗?
硬链接
硬链接的创建方式:直接使用 ln ,不加选项
ln 目标文件名 硬链接文件名
我们发现硬链接和目标文件的 inode 相同,并且它们都有一个数字 2,这个数字叫做硬链接数
inode 相同,就说明硬链接不是一个独立的文件,硬链接和目标文件共用同一份文件属性和文件内容
使用 stat 查看这两个文件的信息,都是一样的
两个文件名具有相同的 inode,就是说 inode 与文件名的关系可以是一对多的,而硬链接数表示当前 inode 有几个文件名。创建一个硬链接,就相当于在目录中新添加一对文件名-inode
的映射关系
如果删除其中任意一个文件,那么另一个文件还是可以正常使用的。例如删除原文件,留下其硬链接
可以看到,硬链接的作用就相当于是备份
软硬链接的特征
总结一下软硬连接的特征
软链接:
- 拥有自己的 inode,是一个独立的文件
- 文件内容是目标文件的路径字符串,可以通过此路径访问目标文件
- 目标文件失效,软链接就会失效
- 相当于目标文件的快捷方式
硬链接:
- 没有自己的 inode,与目标文件共用一个 inode
- 创建硬链接,就是在目录中添加新的 文件名-inode 映射关系
- 硬链接数表示当前 inode 有多少文件名指向,相当于引用计数
- 删除目标文件对硬链接没有影响,反过来同样可行
- 相当于目标文件的备份
软硬链接的作用
软链接的作用就是快捷方式
当前目录下有一个嵌套的目录,在最下面的目录有一个文件,如果想在当前目录访问这个文件,就要输入很长的路径
此时就可以在当前目录创建一个软链接,当做快捷方式使用
硬链接作用:
- 备份文件,这个已经演示过了
- 支持相对目录
.
..
的实现
硬链接如何实现相对路径呢?现在创建一个目录 dir,查看它的硬链接数就会发现是 2
这是为什么呢?进入 dir,然后使用 ll -ai
在 dir 目录下,相对目录.
就是指 dir 目录,它们俩的 inode 是相同的,.
是 dir 目录的硬链接,这样我们使用.
目录就可以当做/home/pyu/linux/file/dir
目录使用了
而..
上级目录(file)也是同理,此时它的硬链接数为 4,画一个结构图就很清晰了
得到一个目录的硬链接数n,那么这个目录有多少一级子目录也就知道了,n-2 个
所以说,硬链接可以实现相对路径,那我们可以自己给一个目录建立硬链接吗?——不可以
如果我们可以随意给目录建立硬链接,很容易引发路径环绕,也就是进行目录操作时造成死循环
为什么 .
和 ..
不会引发路径环绕呢?因为 Linux 对 .
和 ..
做了特殊处理,但是系统不认识我们自己创建的目录硬链接啊,所以不会做特殊处理。为了防止引发路径环绕,系统不允许我们给路径建立硬链接
动静态库
什么是库呢?我们平时写 C/C++ 时,都会用到语言提供的标准库。例如我们使用 printf 、scanf时,并有自己去实现,而是由其他人实现,我们直接拿来用了。而这些函数的定义都存放在库中
那为什么要使用库呢?——提高工作效率,很多接口不要我们亲自编写,直接使用即可。站在巨人肩膀上编程
静态库
我们先来实现一些函数接口,分为 .c 和 .h 文件,用来存放函数的定义和声明
#pragma once
int Addint(int x, int y);
#include "myAdd.h"
int Addint(int x, int y)
{
return x + y;
}
#pragma once
#include "stdio.h"
void PrintLog();
#include "myLog.h"
void PrintLog()
{
FILE* fp = fopen("log.txt", "w");
fprintf(fp, "hello log.txt!\n");
fclose(fp);
}
静态库是什么
假如上面的代码是老师布置的作业,同学 A 写了,而同学 B 没写,想让 A 给他发一份。而 A 呢不想暴露源码,担心老师发现,然后两个人一块寄了(老师不会主动去检查源码,只要程序能跑就行),所以就把 .c 文件编译成了 .o 文件发给了 B,这样 B 只需要写一个 main.c 文件,调用这些 .o 文件中的接口最后一起编译就可以了,而且不会暴露源码
如下是 B 写的 main.c 文件
#include "myAdd.h"
#include "myLog.h"
#include <stdio.h>
int main()
{
printf("5 + 6 = %d\n", Addint(5, 6));
PrintLog();
return 0;
}
此时呢,B 有如下文件
于是 B 将main.c 和 .o 文件一起编译,运行,应付过去了这次作业
可是呢,老师又布置了难度更高的作业,B 依旧是请求 A 帮忙。这次需要的 .o 文件非常多,A 担心发给 B 后会漏掉那么一两个,索性就把所有 .o 文件打包成了一个静态库 libmyc.a。静态库明明格式是这样的:lib+库名+.a
打包 .o 文件命令如下:
ar -rc libmyc.a *.o
如下是 B 此时拥有的文件,一个库文件和若干头文件
此时 B 只需要将 main.c 和库文件一起编译即可
所以,静态库的本质就是打包 .o 文件
如何制作/使用静态库
其实如上我们已经演示了如何制作库了,使用如下命令:
ar -rc libmyc.a *.o
其中 r 是 replace,c 是 create,如果库中的 .o 文件有更新,只需要 replace 和 create 即可
但是呢上面制作库并不是那么标准,正常来说一个库要有头文件和库文件,而且要分开存放,如下:
此时 B 的文件如下:
现在应该怎么使用库呢?有两种方法:
- 将这些别人写的库,俗称第三方库,丢到系统的库中去,如下:
然后将 main.c 中包头文件的""
改为<>
#include <myAdd.h>
#include <myLog.h>
#include <stdio.h>
int main()
{
printf("5 + 6 = %d\n", Addint(5, 6));
PrintLog();
return 0;
}
接下来编译 main.c 文件,发现失败
这是因为 gcc 默认只认识库里的文件,不认识第三方库文件,此时需要使用 -l
指定要使用的第三方库名,注意是库名 myc ,而不是 libmyc.a
虽然这样可以使用,但是不建议随便向系统库中塞东西,建议把刚才加的东西删掉,注意别手滑删掉原来库中的文件
接下来介绍第二种方法
- 在 gcc 的选项中指定第三库
- 使用
gcc -I
指定用户自定义头文件的路径,I可以理解为 Include - 使用
gcc -L
指定用户自定义库文件的路径,L可以理解为 Lib - 使用
gcc -l
指定要使用哪个库文件
为什么不需要指定使用哪个头文件呢?因为在程序中已经指定了
// 指定要使用的头文件
#include "myAdd.h"
#include "myLog.h"
#include <stdio.h>
int main()
{
printf("5 + 6 = %d\n", Addint(5, 6));
PrintLog();
return 0;
}
动态库
如何制作/使用
动态库的制作和静态库略有不同,首先是 .o
文件的生成,命令如下
gcc -fPIC -c
选项中的 fPIC 是什么意思呢?——产生位置无关码(position independent code),这个到动态库加载再细说
然后就是打包 .o 文件,还是像打包静态库那样使用 ar
命令吗?动态库有些特殊,我们绝大部分情况都是用的动态库而非静态库,正是因为它很常用,所以使用 gcc -o
即可打包,但是要使用选项 shared
,如下:
动态库的命名和静态库的类似,lib + 库名 + .so
现在我们有了静态库和动态库,那么编译时如何告诉编译器,我们需要的是静态库还是动态库呢?
直接编译即可,gcc/g++编译器编译时默认使用动态库进行编译
如何让程序找到动态库
此时我们运行程序会报错:
提示我们找不到动态库,这是怎么回事呢?我们之前是不是给过动态库的路径,并且指明使用哪个库了吗?
gcc main.c -I mystd/inclue/ -L mystd/lib/ -lmyc
其实上面的路径和库名都是给 gcc 的,告诉编译器应该使用哪个动态库。而程序运行时,由于是使用的动态库而不是静态库,需要先找到动态库才可以使用,但程序默认是不知道第三方在哪里的。所以需要进行配置,让程序可以找到动态库,有如下五种方法:
- 直接将我们写的动态库丢到系统库 /lib64,不推荐
- 给我们的库创建软链接,丢到系统库 /lib64
- 配置环境变量
LD_LIBRARY_PATH
,将我们的动态库的路径添加到这个环境变量。由于直接修改环境变量只是内存级的修改,所以系统重启后,修改就会失效
- 修改环境变量的配置文件
~/.bashrc
,这样修改后就是永久的,系统重启后也不会失效
- 在
/etc/ld.so.conf.d
目录下增加搜索动态库的配置文件,增加之后一定使用ldconfig
刷新才会生效
使用以上任意一种方法即可让程序找到动态库,即可运行成功,并且可以看到程序绑定的库
动态库的加载
以下知识的学习需要以进程的地址空间知识作为前置
可执行程序和地址空间
以下是一个程序启动后,进程相关的内核数据结构。当进程需要使用库中的接口时,就会把库加载到内存中,并且动态库加载后,要通过页表映射到当前进程的共享区中
如果此时其他进程也需要使用动态库中的接口,不需要再加载一遍库,只需要将内存中的动态库映射到进程的共享区即可
可执行程序的虚拟地址
在 Linux 中,二进制文件都是有固定格式的,一般都是 ELF 文件。文件中存在一个头部,存储着可执行程序的属性信息
可执行程序编译成功后,没有运行时,二进制代码中存在地址吗?——存在地址,如下图,使用 objdump -S
反汇编可执行程序
可以看到,可执行程序编译后就会变成很多条汇编语句,每条语句都有自己的地址。这些地址叫做逻辑地址,这里我们也可以把逻辑地址当作虚拟地址
那么这些虚拟地址是如何编址的呢?地址存在一个范围 0~0xffff 中,不一定是从 0 开始,也不一定是从 0xffff 结束,可以存在范围中的任意地方,且地址是线性连续的
地址可以是相对编址,也可以是绝对编址。相对编址意思是说,将各个区域的地址分开来看,每个区域的地址都是相对区域的开始地址来说的,示意图如下:
绝对编址就是将整个程序中的地址当成整体进行编址
以上这种在连续空间中进行绝对编址的编址模式叫做平坦模式
那么问题来了,程序都是从 main 函数开始的,如何确定 main 函数的起始与结束地址呢?——上面我们提过,ELF 文件头中存储着可执行程序的属性,其中就包括了各个区域的起始与结束地址,包括 main 函数的入口地址
而 Linux 中每个程序都存在一个加载器,加载器可以获取 ELF 文件的信息如下
这样通过 ELF+加载器,就可以获得程序的各个区域的地址了
地址空间的初始化
进程 = 内核数据结构+代码数据,那么一个进程启动时,是先创建数据结构还是先加载代码数据呢?举个例子,当我们被一个大学录取时,是档案先过去还是人先过去呢?——当然是档案先过去。同理,一个进程启动时,肯定是先创建内核数据结构,再加载代码和数据到内存
在创建内核数据结构时,地址空间 mm_struct 是一个结构体,那么它肯定有成员变量,例如若干 xxx_start、xxx_end 来划分各个区域的起止位置,那么这些数据是哪里来的呢?操作系统规定的吗?操作系统不知道可执行程序的代码是什么样啊,这些只有可执行程序知道,所以地址空间的数据都是来自可执行程序
首先我们需要知道程序的 main 函数入口,然后将其地址交给 CPU,在 CPU 存在一个 PC 指针。PC 指针的作用就是:储存正在执行指令的下一跳地址,PC 指向哪里,CPU接下来就执行哪里的代码。而且 PC 指针存的是代码的虚拟地址,所以首先要将 main 函数的虚拟地址交给 PC 指针
地址空间创建后先不要初始化,接下来就是把代码数据加载到内存中,一旦在内存中,就会占据空间,占据空间就会有物理地址,同时具有本来就有的虚拟地址。此时就可以把这些虚拟地址和物理地址在页表中建立映射关系,虚拟地址用来初始化地址空间
总结:
- 进程创建阶段,通过 ELF+加载器 拿到虚拟地址和main函数起始地址,交给 PC 指针,后面代码运行会使用
- 加载程序到内存,此时获得物理地址和虚拟地址,建立映射关系构建页表,虚拟地址交给地址空间初始化
- 之后CPU凭借 PC 指针来执行代码,顺表把下一跳的地址存入PC指针;循环进行,代码就跑起来了
动态库的加载
上面讲了那么多,好像都是关于进程的加载,而我们要搞清的是动态库的加载啊,这不是偏题了?不影响,两者的加载是类似的
现在有如下情境:进程在运行时需要使用库函数 add,但是库还没加载,进程只拿到了add的虚拟地址 0x1234,于是就需要加载库
库函数中的地址是绝对编址,范围是 0~0xffff,而add的虚拟地址是 0x1234,换句话说,add的地址是相对起始地址0的偏移量
这时我们再来看将库加载到内存,此时获得物理内存,就可以进行页表映射了,库函数的要映射到地址空间的共享区。假设地址空间给库分配的虚拟起始地址是 0x112233,那么库函数的虚拟地址就是虚拟起始地址0x112233+相对起始的偏移量0x1234
动态库映射到地址空间的精髓操作就在于这个偏移量,库被映射到页表的什么位置都不重要,只要有库的起始地址和库函数偏移量就可以拿到库函数
同理,库可以映射到不同进程的页表,有了偏移量,不管如何映射都不重要了
针来执行代码,顺表把下一跳的地址存入PC指针;循环进行,代码就跑起来了
此时还有最后一个问题,内存中的库可以映射到不同进程,那么如何知道一个库加没加载呢?——先描述,再组织。操作系统内肯定有很多加载到内存中的库,我们需要创建一个结构体,记录某一个库的加载情况和库中的属性,例如库的起始地址,终止地址,库占了多少内存等等。这样一个个结构体相互链接成一个链表,只需查看链表即可知道某一个库是否加载,了解即可