1. C语言中的文件操作
1 #include<stdio.h>
2
3 int main()
4 {
5 FILE* pf = fopen("log.txt", "w");
6 if(NULL == pf)
7 {
8 perror("fopen");
9 return 1;
10 }
11 fprintf(pf, "Hello:%d\n",10);
12 fclose(pf);
13
14 return 0;
15 }
fopen
函数以w
的方式打开文件:
- 如果指定的文件不存在,就在当前路径下创建
- 默认每次打开文件都会清空目标文件
而Linux中输出重定向>
的行为跟fopen
以w
的方式打开文件类似,追加重定向>>
跟a
的方式类似,由此我们知道>
和>>
一定是进行了文件操作,它们之间有啥关系?这是我们接下来要讨论的问题
2. 认识文件
我们对文件的初步认识:
- 想要进行文件操作,必须在程序中使用对应的函数,而程序最终是一个进程,我们所做的任何文件的操作,本质是进程对文件的操作
- 一个进程可以打开多个文件,而OS内部有很多的进程,每个进程都可能打开多个文件,也就意味着OS内部有很多被打开文件,操作系统管理这些文件:先描述,再组织
也就是说每一个被打开的文件,在OS内部,都对应一个类似于PCB的内核结构体,用来描述文件的属性信息,这些结构体再以一定的结构组织起来
3. 理解文件
文件存放在磁盘中,磁盘是外设也就是硬件,进程向文件写入数据本质是进程向硬件写入数据,根据前面我们对操作系统的理解,要想对硬件写入数据,必须通过操作系统,而我们又不能直接访问操作系统,因为操作系统不相信任何人,但同时它必须满足我们向硬件写入数据的要求,因此给我们提供了系统调用函数,我们只能通过系统调用来向硬件写入数据
文件打开/关闭的系统调用:
-
flags
:标记位传参-
O_RDONLY
:以读的方式打开文件 -
O_WRONLY
:以写的方式打开文件 -
O_CREAT
:没有指定文件就创建 -
O_TRUNC
:每次打开文件先清空 -
O_APPEND
:追加文件
-
-
mode
:指定文件被创建时的权限,文件的最总权限 =mode & (~umask)
文件读/写的系统调用:
知道了如何使用系统调用来操作文件,但对于open
函数的返回值我们该怎么理解呢?
文档中是这样说明的:
如果打开成功,返回新文件的文件描述符,如果打开失败,返回-1并设置错误码
文件描述符是什么?
每一个被打开的文件在OS内都对应一个内核数据结构,该结构体存放着文件的属性,同时指向一块属于该文件的内核级的缓存区域;文件 = 属性 + 内容,进程打开文件时,先创建文件结构体,将文件的属性存放到结构体中,同时将文件内容拷贝到内核级的文件缓存区域
每一个被打开的文件都需要知道自己被哪个进程打开,进程同样也需要知道自己打开了哪些文件,在进程task_struct
中,有一个struct files_struct* files
的结构体指针,该指针指向一个struct files_struct
类型的结构体,该结构体当中有一个struct file* fd_array[N]
的结构体指针数组,数组中的每个元素指向一个文件结构体
数组的下标就表示代表一个文件,其中:
- 0:标准输入(键盘)
- 1:标准输出(显示器)
- 2:标准错误(显示器)
这三个下标所对应的文件在OS启动时默认打开
进程每打开一个文件,就在该数组中添加一个文件指针,然后返回下标,通过下标,我们就能访问文件了;所谓open
函数的返回值,即文件描述符,其本质就是内核进程中文件映射关系数组的下标
在OS内部,系统在访问文件时,只认文件描述符
再来理解write
和read
函数,无论读写,都必须在合适的时候,让OS把文件的内容读到文件缓冲区,write
和read
无非就是将数据拷贝到内核级的文件缓冲区或者将内核级的文件缓冲区的数据拷贝到我的变量中,其本质是拷贝函数
open
函数所做的工作:
- 创建
struct file
- 将文件内容读到内核级的文件缓冲区中
- 进程的文件描述表建立新空间
- 将
struct file
的地址填入到文件描述表中 - 返回下标
理解了什么是文件描述符,并且我们知道0,1,2下标分别表示键盘,显示器,显示器,但是这些都是硬件,OS是如何做到对每个硬件进行读写操作
Linux中,一切皆文件,操作系统要管理好底层的硬件,就表示在OS内部一定有与硬件对应的内核数据结构struct file
,每个硬件的读写方式都不同,但各种硬件的读写方式不需要OS关心,它们由生产硬件的厂家提供,而在struct file
内部,有读写的函数指针,指向硬件的读写方法,未来OS只需要调用厂家提供的读写方法就能完成硬件的读写
结合对文件描述符的理解,如果我们要向硬件写数据,数据先是拷贝到struct file
的内核缓存区,OS再调用底层的函数指针表,就能完成写入数据的操作了
此时,我们再来分析C语言中fopen
函数和它的返回值
不管是w
还是a
方式打开文件,操作文件的方式都和上面我们用open
的结果相类似,可以肯定,fopen
的底层一定是open
,只不过C语言进行了封装;不仅如此,任何高级语言的文件操作函数底层都是对系统调用的封装
fopen
的返回值类型是FILE*
,它是C语言提供的一个结构体,虽然我们不知道这个结构体的实现细节如何,但既然我们能通过FILE*
的变量来操作文件,那么它的底层也一定是文件描述符的封装,因为OS内部,系统访问文件时,只认文件描述符
那么,这些高级语言为什么要封装底层系统调用?
因为这些高级语言需要有跨平台性,每款操作系统,它的系统调用都不同,如果语言直接使用系统调用,那么我在Linux下写的代码在Window下就不能跑了,这表示该语言不具有跨平台性;语言被设计出来,就希望更多人来使用,如果该语言只能在Linux下跑,那么Windows用户就不会使用,因此,语言必须具有跨平台性
为了实现跨平台性,工程师在设计语言的标准库时,通常一个文件操作函数要实现三份,比如fopen
,这三份函数名都叫fopen
,但底层分别调用不同系统的接口,然后在Windows,Linux,Macos下都编译一篇,你需要哪个就下载哪个
文件 = 内容 + 属性,wirte
和read
都是对文件的内容做操作,而文件的属性在struct stat
的结构体中
const char* filename = "log.txt";
int main()
{
int fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0666);
const char* message = "Hello Linux\n";
write(fd,message,strlen(message));
close(fd);
return 0;
}
// 先创建文件写入一些内容
int main()
{
struct stat st;
int fd = open(filename, O_RDONLY);
fstat(fd, &st);
printf("filesize:%ld\n", st.st_size);
char* file_content = (char*)malloc(st.st_size + 1);
int n = read(fd, file_content, st.st_size);
if(n > 0)
{
file_content[n] = '\0';
printf("file_content->%s",file_content);
}
close(fd);
return 0;
}
// 获取文件大小
4. 理解重定向和缓冲区
根据上述现象,可以得知文件描述符的分配规则:分配未被使用的最小下标
对于上述现象,我们有两个问题:
- 原本
printf
和fprintf
是向显示器打印数据,为什么打印到文件里了呢? - 为什么调用
fflush(stdout)
才向文件里打印数据
问题1:我们知道,1号文件描述符对应显示器文件,关闭1后,又将1分配给了log.txt
,发现原本向显示器文件输出的内容跑到了文件中了,这不就是输出重定向吗?由此,也不难理解,重定向的本质其实是在底层改变了文件描述符下标中的内容,和上层无关,上层只管向指定的fd
中输入/输出
问题2:C语言中,FILE
结构体类型中,除了有_fileno
外,肯定还有其他内容,其中就有一个语言级的文件缓冲区,之前我们使用的print/fprintf/fflush
等语言层面的函数,都是对语言级的文件缓冲区操作,再按照一定的刷新策略将语言级的缓冲区中的数据刷新到内核级的文件缓冲区;
现在我们知道了,重定向的本质是文件描述符下标内容的改变,而使用dup2
系统调用能直接完成重定向
- 什么是缓冲区?
- 缓冲区就是一段内存空间
- 为什么要有缓冲区?
- 有了语言级缓冲区,我们用户就只需要将数据放到语言级缓冲区即可,下面的事就不需要我们管了,同理,有了内核级缓冲区,系统调用就需要将语言级缓冲区的数据放到内核级缓冲区即可,至于OS是怎么将数据放到外设就不需要系统调用管了;有了缓冲区,提高了使用者的效率
- 再者,系统调用是有一定的消耗的,如果没有语言级缓冲区,我们有一段数据就调用一次系统调用,有了语言级缓冲区,先将数据存放到语言级缓冲区,等到一定的条件,调用一次系统调用就将大部分数据放到内核级缓冲区;这也提高了刷新IO的效率
- 缓冲区的刷新测率(对于语言级缓冲区而言):
- 立即刷新,如
fflush(stdout)
,int fsync(int fd)
(立即刷新内核缓冲区) - 行刷新,针对显示器
- 全缓存,缓冲区满了才刷新,针对普通文件而言
- 特殊情况:进程退出,系统会自动刷新
- 立即刷新,如
同一份代码,分别向显示器文件和普通文件打印数据,结果不同,该怎么解释?
向显示器文件打印,语言级缓冲区按照行刷新的策略,printf/fprintf
输出到stdout
中直接就刷新到内核缓冲区了,即使创建子进程,只会拷贝父进程的代码和数据,不会拷贝内核空间内容;而向普通文件打印,按照全缓冲刷新策略,printf/fprintf
执行完后,数据在语言级缓冲区中,创建子进程,拷贝语言级缓冲区中的数据,父子进程结束前,都自动刷新语言级缓冲区
5. 理解stderr
0,1,2文件描述符对应的文件默认被打开;0,1分别代表键盘文件和显示器文件,程序其实是一堆的数据,我们在运行程序时,需要看到这些数据,知道从哪来,往哪去,由此0,1默认被打开就是自然的
2表示标准错误,对应显示器文件,打开了1,我们就能得到所有的数据,为什么还要打开2呢?
由于stdout
和stderr
底层指向都是显示器文件,第一种结果我们能够理解;第二种stdout
的数据在指定文件中,stderr
的数据仍在显示器文件中,该如何解释?
实际上,>
默认是标准输出重定向,它的完整写法应该是./Mycode 1 > log.txt
,这中间没有改变stderr
中的文件描述符
如果我想stdout
和stderr
的信息分离./Mycode 1>ok.txt 2>err.txt
,或者想让它们都打印到同一个文件./Mycode 1>all.txt 2>&1
C语言函数perror
其实就是向2中打印
有了stderr
我们就能将错误信息和正确信息分离,未来程序出错时,只需要查看错误信息即可
6. 文件系统
上面所讨论的都是被打开的文件,那么还有大量未被打开的文件是怎样的?
未被打开的文件存放在磁盘上,接下来我们讨论的都是这些文件在磁盘上如何存取的问题
目前,大多数笔记本使用的都是固态硬盘(SSD),机械硬盘公司内部用的多
6.1 物理磁盘
磁盘读写的基本单位是扇区,一扇区是512字节,也有4KB的
一块盘片有两面,每一面都有一个磁头;盘片在主轴的驱动下,不停的转动,用来定位扇区;磁头在马达的驱动的,不停的左右摇摆,用来定位磁道
整个盘片上,充满了磁性物质,文件本质是二进制数据,存放在磁盘中其实就是磁性物质记录下文件的二进制数据;因此,文件就变成了在磁盘中占有几个扇区,将来想找到一个文件,只要找到文件对应的几个扇区就可以了
磁盘如何找到一个指定位置的扇区?
- 找到指定的磁头(Header)
- 找到指定的磁道(Cylinder)
- 找到指定的扇区(Sector)
我们把这种方法叫做CHS定址法
但是OS寻找磁盘中的文件时,并不直接使用CHS定址法,因为这种方法耦合度太高,磁盘各种各样,每种磁盘的CHS都不同,换种磁盘,就意味着OS内部也要跟着改变,很麻烦
为了方便内核管理磁盘,我们将磁盘的所有扇区逻辑抽象化成一个数组,将来OS查找文件时,只要找到文件的下标index
,再将index
转换成CHS交给磁盘,磁盘再根据CHS定址法找到对应文件
OS如何将index
转换成CHS(假设磁盘有n个盘片,每个盘片有10个磁道,每个磁道有100个扇区,也就是每个盘片有1000个扇区):
index / 1000 = H
(index % 1000) / 100 = C
(index % 1000) % 100 = S
此时,文件 = 很多个Sector
数组的下标
但假设文件大小是8KB,OS读取文件时就要进行16次的index
的转换,效率太低,因此,磁盘读写的基本单位是512字节,但OS与磁盘交互时,基本单位是4KB,也就是8个连续的扇区,我们称为块;对于OS而言,向磁盘读取数据的基本单位是块
未来我们只要知道磁盘总大小,就能得到所有的块数,知道了文件块号,就能获得文件的多个index
,再转换成多个CHS
我们把块的起始地址叫做LBA(逻辑区块地址)
此时,文件 = 很多个块
6.2 文件系统的理解
现在,我们知道了OS访问磁盘的基本单位是块,但磁盘还是太大了,不方便管理,于是对磁盘进行分区,我们电脑上的C盘,D盘等就是分区后的结果;但分区后每一个区还是太大了,再进行分组,这样,我们的OS只要将一个组管理好,就能管理好每一个组,相当于管理好了一个分区,而一个分区管理好了,每一个分区也就能管理好了,我们把这种思想叫做分治
文件 = 内容 + 属性,磁盘存储文件也就是要存储文件的内容和文件的属性,在Linux中,文件的内容和文件的属性是分开存储的
在每一个组中,分成了几个区域,每一个区域有不同的用处,我们把这种管理文件数据的方式叫做磁盘级文件系统
Data Blocks
:由很多个块构成,只存放文件的内容,占整个组的空间最大block bitmap
:位图,用bit
位来表示Data Blocks
中每一个块的使用情况;将来新建文件时,先去block bitmap
中查找哪一个块的bit
位为0,表示该块可用,再给文件分配块inode table
:inode表,用来文件的属性;Linux中,文件的属性被被存放在一个大小固定(128字节)的结构体中;一个文件对应一个inode
结构体;但注意,文件名并不保存在inode
结构体中inode bitmap
:inode位图,用bit位来表示inode table
中inode
结构体的使用状况,bit位的位置表示inode
结构体的位置,bit位的值,表示indode
结构体的使用情况GDT(Group Descriptor Table)
:存放该组的属性信息super block
:超级块,存放文件系统本身的属性信息,代表的是一个区/整个文件系统,blocks,inode
的总量,还有多少可用,一个block,inode
的大小,最近一个挂载,写入数据的时间;如果super block
的结构被破坏,整个分区就被破坏了,因此,super block
会存在多分,放在不同的组,但也不是每个组都有
之前我们可能听说过格式化这个词,其本质就是在分区之后,在每个组中写入上述文件系统的管理数据
inode
结构体:
struct inode
{
size_t size;// 大小
mode_t mode;// 权限
int time;// 时间
int creater;// 创建者
......;
int inode_number;// inode编号
int datablocks[N];// 对应块号
......;
}
以上就是我们对文件系统的初步理解
6.3 inode
编号的理解
在inode
结构体中,有一条属性为inode
编号,每一个文件都要有一个inode
编号,它以分区为单位,也就是说,同一个分区不能有两个相同的inode
编号的文件;我们要找到文件时,首先要拿到该文件的inode
号,去inode bitmap
中查找对应的bit
为是否正常,然后找到该文件的inode
结构体,这样文件的属性和内容就拿到了
分区过后,会给每个组分配一定范围的inode
编号,同时在super block
和GDT
中记录,假如我要找inode
号为20004的文件,先去匹配该号在哪个分组,找到组后,减去改组的起始inode
,就找到了文件在改组中的相对位置
为什么说拿到了文件的inode
表就拿到了文件的属性和内容了呢?内容不是存在data blocks
中吗?
在文件的inode
表中,有一个数组,该数组的元素个数是15,该数组记录着文件对应的块号,将来我们只要找到文件的inode
表,根据数组就能拿到文件的所有块号;其中数组的[0,11]下标是直接映射到数据块的,但如果所有下标都直接映射,一个最大的文件也才60KB,太小了,因此[12,14]下标是间接映射,即映射的块不存放文件内容,也存放块号,有可能是多级映射
6.4 目录的理解
只要拿到文件的inode
号,就能拿到文件的属性和内容,现在的问题是,我们怎么拿到文件的inode
号,之前我们操作一个文件时,从来没有使用过inode
号,使用的是文件名,这就得谈谈目录了
首先目录也是文件,既然是文件,就有inode
结构体存放着它的属性,它的内容是什么呢?上面提到一个普通文件的文件名不在它的inode
结构体中,它实际在目录的内容中;目录的内容是它里面所有文件inode
号和文件名的映射关系
我们操作任何一个文件,需要在目录的内容中根据文件名拿到对应文件的inode
号,才能找到文件的inode
结构体;这也是为什么一个目录下不能有同名的文件,如果出现同名,就无法分别哪个文件名对应哪个inode
号
这时,我们再来理解目录的r
权限和w
权限:
- 在一个目录下,我们使用
ls
查看文件、touch
创建文件时,只给了文件名,进程怎么知道我应该在哪个目录下执行操作呢?我们输入的文件名,最总会在前面加上进程提供的当前工作路径(cwd
),组合成一条完整的路径 r
权限本质是是否允许我们读取文件名和inode
号的映射关系w
权限本质是是否允许我们创建/删除文件名和inode
号的映射关系
对于文件的删除,我们并不需要将文件的内容清空,只需要将文件的inode bit
位和inode
结构体重置即可,此时文件的内容还在的,这也是为什么删除文件后我们能利用一些工具进行数据的复原;但注意,文件内容随时可能会被覆盖,因此,不小心删除文件后最好的办法是什么都不做
如果大家仔细思考一下,会发现一个问题,我们要拿到文件的inode
,得先在目录中,才能根据文件名和inode
的映射关系找到inode
,但目录也是文件,得先拿到目录的inode
,才能进入目录,而要拿到目录的inode
,得先在目录的目录中…这不就成死循环了吗?
/home/byh/Lesson16/code.c
,对于code.c
文件,想要访问它,OS进行上述操作的逆向解析工作,最总找到根目录,根目录在OS内自己有定义,于是再正向的找到code.c
;难道每次我们操作一个文件时,OS都要进行逆向解析的工作吗?这太麻烦了
在Linux中,会缓存路径结构,Linux内核被使用时,一定存在大量的已经被解析完毕的路径,OS需要对这些路径进行管理,于是将这些路径用结构体描述,用数据结构组织
有了上面的知识,我们就能理解为什么定位一个文件,必须带上路径,因为有了路径+文件名,OS才能对路径逆向解析,最后找到文件;而目录则是文件系统提供写入并组织好,由我们或进程提供
inode
号的分配以分区为单位,拿到一个文件的inode
号后,怎么知道它在哪个分区;实际上,分区之后,给每个区写入文件系统,然后将区挂载到指定的目录下,比如根目录,它是系统中定义的,之后一旦我们目录中,就在指定的分区中了
7. 软硬链接
根据上述现象,发现:
- 软链接是一个独立的文件,他有自己的
inode number
,内容是目标文件路径的字符串 - 硬链接不是一个独立的文件,内容跟目标文件一样,且每对一个文件建立硬链接,它的硬链接数就+1
软链接:
它类似于windows下的快捷方式,当我们想要快速访问一个在很深的目录下的文件,可以在外面对该文件建立软链接,这样就能够直接访问文件
硬链接:
它实际是在目标文件的inode
结构体中,添加新的文件名与原本inode
的映射关系,并没有新建文件;硬链接数是该inode number
对应多少个文件名
Linux中,任何一个目录在新建时,硬链接数一定是2,因为在Linux下,任何一个目录都有.
和..
,其中.
就是对a目录建立硬链接,同时,在a目录中新建目录,会让a目录的硬链接数+1;一个目录中的目录数 = 该目录的硬链接数 - 2
但用户在Linux中,不能对目录建立硬链接,防止查找文件时形成路径闭环;系统找文件时,知道.
和..
是什么意思,不会去对应的目录下寻找
总结,硬链接的作用:
- 构成Linux系统的路径结构,能够使用
.
和..
- 对一个文件进行备份
8. 动静态库
在C语言中使用printf/scanf
等函数,我们好像没有实现这些函数,但代码能正常运行;这是因为这些函数的具体实现在库中;我们的代码被编译成.o
文件,再和库进行链接,形成可执行程序
库分成静态库和动态库,后缀在Linux和Windows下不同
Linux下:
.so
:动态库.a
:静态库
Windows下:
.dll
:动态库.lib
静态库
8.1 静态库
有这样的场景,你要给你的朋友提供一套函数方法,但又不想让他看到源文件;于是,你将函数的使用方法也就是头文件交给了他,再把源文件编译成目标文件提供给你的朋友,你的朋友只要将自己的源文件和你提供的目标文件一起编译,就能形成可执行程序
这样的方式虽然可行,但如果目标文件有很多,难道要将所有目标文件都交给你的朋友,放在同一目录下吗?那么多目标文件,我又不能看,就放在那,太碍眼了
于是,你将所有的目标文件打个包,形成一个文件,和头文件一起交给你的朋友,之后,你们朋友就只需要将源文件跟这个打包好的文件一起编译,也能形成可执行程序
因此,所谓的库,其实就是多个.o
文件的打包,库真正的名字是去掉lib
和后缀,比如上述库libmyc.a
,库名是myc
那么为什么有库呢?为了提高用户的开发效率,我们使用别人做好的库,就能更高效的开发我们要的东西
但仅仅这样还不够简便,我们将库文件和头文件分开,形成你自己的一套库,提供给你的朋友,再让它将头文件和库文件分别拷贝到系统的头文件和库中,这样直接编译源文件,让编译器自己找到对应的库文件
我们使用的编译器是gcc/g++
,对于系统的C/C++库文件,它是认识的,但我们自己添加到系统中的库文件(第三方库),它是不认识,需要我们在编译时指定库文件名
将第三方库拷贝到系统中,叫做库的安装;从系统中删除,叫做库的卸载;但是,不推荐将第三方库直接拷贝到系统的库中,我们有其他方式
不把库拷贝到系统中,形成可执行程序,需要给gcc/g++
一些选项
8.2 动态库
如果编译不加-static
选项,编译器默认使用动态库编译;只提供静态库的就用静态库,其他的使用动态库
如果编译加了-static
选项,强制全部使用静态库,如果没有对应的静态库,就报错
形成动态库后,编译main.c
,指定头文件和库
我们发现,编译时能够形成可执行程序,但不能运行
实际上,采用动态链接的程序,在运行时要加载动态库到内存,才能使用库中的方法,而要加载,得先找到动态库,报错的原因就是运行时找不到动态库
我明明已经指明了动态库的路径,为什么还是找不到?你指明的路径是给gcc
编译器的,编译器也确实找到了并形成了可执行程序,但运行程序就和编译器无关了,是我们的OS在运行程序,你没有给OS指明路径,OS也就找不到动态库了
要解决这个问题,有很多方法:
- OS找动态库会去
/lib
目录下找,把动态库拷贝到该目录下 - 在
/lib
目录下建立第三方库的软链接 - 在环境变量
LD_LIBRARY_PATH
中添加第三方库的路径 - 在系统文件
.bashrc
中添加环境变量,让环境变量永久生效 - 在
/etc/ld.so.conf.d/
目录中添加库路径的配置文件,然后执行ldconfig
命令刷新配置文件
9.动态库的加载
现在我们知道,如果程序使用动态链接,将来调用库中的方法时,需要将库加载到内存中,那么库是如何加载到内存中呢?将来又有程序使用库中的方法,该怎么办呢?
从整体上看,可执行程序加载到内存,构建虚拟内存和物理内存的映射关系,将来CPU运行,执行到某行代码,需要调用库中的方法,将库加载到物理内存,并在地址空间上的共享区和物理内存之间构建映射关系;如果有其他程序也要调用库库中的方法,发现物理内存中已经有一份库了,就不需要加载,直接构建映射关系
进程 = 内核数据结构 + 代码和数据,那么运行一个可执行程序,在内存中是先有它的内核数据结构,还是先有它的代码和数据?这里直接给出结论,是先有内核数据结构,再有代码和数据
既然是先创建tack_struct、mm_struct
和页表,那就有疑问了,我们说地址空间其实是一个mm_struct
结构体对象,本质上是一系列区域的划分,里面有很多成员变量,code_start
和code_end
,初始化这些数据时,初始值从哪来呢?
实际上,可执行程序在被加载到内存之前就已经就"地址"了,使用objdump
指令将程序反汇编
可以看到,每条汇编代码前都有一个数字,这个数字就是每条指令的"地址"
可执行程序是二进制的,二进制是有格式的,叫做elf格式;在elf格式的可执行程序的头部,存放着该程序的属性,包括各个区域的起始地址、结束地址,main
函数的地址等,我们把这个"地址"叫做逻辑地址,也叫虚拟地址
那么如何给每条汇编代码分址呢?规定一个范围,比如0000000000000000~ffffffffffffffff,由低到高,依次给每条汇编代码一个地址,这样汇编代码的地址也是该代码在程序中的偏移量,我们把这种编址叫做平坦模式
CPU内部,有一个寄存器,叫做pc指针,记录正在执行指令的下一条地址,它指向哪,CPU就执行哪条代码
可执行程序未加载到内存之前,先将程序头部记录下的main
函数地址存放到CPU的pc指针中,表示程序开始执行的位置,并用每个区域的地址初始化mm_struct
中的值,等到程序加载到内存时,每条指令就都有了物理地址,有了物理地址和虚拟地址,就能构建映射关系,从而完成程序的整个加载
理解了程序的加载过程,动态库的加载跟它类似;当程序中需要调用库中的方法时,OS要找到库,加到到内存,构建映射关系;知道了库的起始地址和函数的偏移量,就能映射到物理内存中的函数方法
在整个过程中,库加载到地址空间的任何地方都没有关系,因为,函数的偏移量在编译时就确定,只要有库的起始地址,总能找到对应函数在物理内存中的位置,这就叫做与地址无关,也是为什么形成动态库的目标文件编译时要加上-fPIC
,表明能够以任意地址的方式编译
一个程序有可能需要很多库,也就意味着会有很多库加载到内存中,既然是OS找到库并加载到内存,那么它肯定要管理好这些库,要管理,先描述,再组织;因此,在内存中,一定会有描述库属性的结构体对象,该库有没有加载到内存等信息,再用数据结构将这些结构体组织起来;未来如果有其他程序也要使用库中的方法,只要去查看该库的结构体,如果库已经加载到内存,直接使用即可