Linux 基础 IO
1. 文件操作
#include <stdio.h>int main()
{FILE* pf = fopen("file.txt", "w");if(pf == NULL){printf("opening file failed!\n");return 1;}fclose(pf);return 0;
}
上述代码是 C 语言中的文件操作,fopen 的打开模式:
- “w”:文件不存在则创建,存在则先清空文件内容,再以写方式打开文件
- “r”:以读方式打开文件
- “a”:以追加方式打开文件
对比输出重定向 > ,我们发现 > 的行为跟以 w 模式打开文件类似,由此我们可以推断, > 底层一定是文件操作
之后我们想在命令行创建文件或清空文件内容,可以使用 >filename 的方式
同样的,追加重定向的行为跟以 a 模式打开文件类似
2. 认识文件
目前我们对文件的认识:
- 想要进行文件操作,必须在程序中使用对应的函数,而程序最终是一个进程,将来文件操作的函数是要被 CPU 执行的,我们所做的任何关于文件的操作,本质是进程对文件的操作,打开文件本质是进程在打开文件
- 一个进程可以打开多个文件,而OS内部有很多的进程,每个进程都可能打开多个文件,也就意味着 OS 内部有很多被打开文件,操作系统管理这些文件:先描述,再组织
也就是说每一个被打开的文件,在 OS 内部,都会有描述该文件的内核结构体,这些结构体再以一定的方式被组织起来
3. 理解文件
系统调用
文件存放在磁盘中,磁盘是外设也就是硬件,进程向文件写入数据本质是进程向硬件写入数据,根据前面我们对操作系统的理解,要想对硬件写入数据,必须通过操作系统,而我们又不能直接访问操作系统,因为操作系统不相信任何人,但同时它必须满足我们向硬件写入数据的要求,因此给操作系统提供了系统调用,我们只能通过系统调用来向硬件写入数据
由此能推断语言层的文件操作函数本质是对系统调用的封装
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
#include <unistd.h>int close(int fd);
flags
:标记位O_RDONLY
:以读模式打开文件O_WRONLY
:以写模式打开文件O_APPEND
:以追加模式打开文件O_CREAT
:文件不存在则创建O_TRUNC
:先清空文件内容再打开
mode
:指定文件创建时的权限,文件的最总权限 =mode & (~umask)
#include <unistd.h>ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
int main()
{umask(0);int fd = open("file.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){printf("opening file failed!\n");return -1;}const char* msg = "Hello World\n";write(fd, msg, strlen(msg));close(fd);return 0;
}
文件描述符
知道了如何使用系统调用来操作文件,但对于 open 函数的返回值我们该怎么理解呢?
int main()
{umask(0);int fd1 = open("file1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);printf("fd1 = %d\n", fd1);int fd2 = open("file2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);printf("fd2 = %d\n", fd2);int fd3 = open("file3.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);printf("fd3 = %d\n", fd3);return 0;
}
如果打开成功,返回文件描述符,如果打开失败,返回 -1 并设置错误码
打开文件本质是进程打开文件,进程在内存中,也就表明打开的文件也一定会在内存中,OS 内部会同时存在多个打开的文件,OS 需要对它们管理 — 先描述,再组织
每一个被打开的文件在 OS 内都对应一个内核数据结构struct file
,该结构体存放着文件的属性,同时指向一块属于该文件的内核缓冲区
文件 = 属性 + 内容,进程打开文件时,先创建文件结构体struct file
,文件属性存放到结构体中,文件内容拷贝到内核缓冲区
一个进程可能会打开多个文件,进程也就需要知道自己打开了哪些文件,因此在进程task_struct
中,一定会记录本身打开的文件
其中,fd_array
数组就叫做文件描述符表
进程每打开一个文件,先创建stuct file
,在fd_array
中找到一个空的位置,指向struct file
然后返回下标,上层通过下标,就能访问文件了
所谓open
函数的返回值,即文件描述符,本质是进程的内核数据结构中文件映射关系数组的下标
系统在访问文件时,只认文件描述符
open
函数所做的工作:
- 创建
struct file
- 将文件内容读到内核文件缓冲区中
- 在进程的文件描述符表中找个一个空的位置
- 将
struct file
的地址填入到文件描述表中 - 返回下标
理解了什么是文件描述符,现在的问题是:为什么打开文件返回的 fd 是从 3 开始,0、1、2 呢?
- 0:标准输入 -> 键盘
- 1:标准输出 -> 显示器
- 2:标准错误 -> 显示器
不管是语言层使用的printf
,还是系统调用的write
,它们都是在向硬件读写,如何理解操作系统向硬件进行读写呢?
Linux中,一切皆文件,我们可以理解为各种硬件在 Linux 中也是一个个的文件,未来进程要向这些文件读写,也就意味着在进程要打开这些文件,在 OS 中,也就一定要有描述这些硬件的struct file
每个硬件的读写方式不同,但这不需要 OS 关心,它们由驱动层提供
而在struct file
内部,除了描述硬件的信息外,还有一系列函数指针,指向驱动层提供的操作硬件方法,未来进程通过 fd 找到 struct file
,将数据拷贝到内核缓冲区中,直接调用函数指针就能完成对硬件的读写了
当启动 Linux 时,bash 会默认帮我们打开0,1,2 号 fd
此时,我们再来分析fopen
的返回值
fopen
的返回值类型是FILE*
,它是 C 语言提供的一个结构体,虽然我们不知道这个结构体的实现细节如何,但既然我们能通过FILE*
的变量来操作文件,那么它的底层也一定是文件描述符的封装,因为OS内部,系统访问文件时,只认文件描述符
int main()
{printf("stdin = %d\n", stdin->_fileno);printf("stdout = %d\n", stdout->_fileno);printf("stderr = %d\n", stderr->_fileno);return 0;
}
那么,这些高级语言为什么要封装底层系统调用?
因为这些高级语言需要有跨平台性,每款操作系统,它的系统调用都不同,如果语言直接使用系统调用,那么我在Linux 下写的代码在 Window 下就不能跑了,这表示该语言不具有跨平台性;语言被设计出来,就希望更多人来使用,如果该语言只能在 Linux 下跑,那么 Windows 用户就不会使用,因此,语言必须具有跨平台性
为了实现跨平台性,工程师在设计语言的标准库时,通常一个文件操作函数要实现三份,比如fopen
,这三份函数名都叫fopen
,但底层分别调用不同系统的接口,然后在Windows,Linux,Macos下都编译一篇,你需要哪个就下载哪个
文件 = 内容 + 属性,write
和read
都是对文件的内容做操作,而文件的属性可以通过系统调用获取
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>int stat(const char *pathname, struct stat *statbuf);
int fstat(int fd, struct stat *statbuf);struct stat {dev_t st_dev; /* ID of device containing file */ino_t st_ino; /* Inode number */mode_t st_mode; /* File type and mode */nlink_t st_nlink; /* Number of hard links */uid_t st_uid; /* User ID of owner */gid_t st_gid; /* Group ID of owner */dev_t st_rdev; /* Device ID (if special file) */off_t st_size; /* Total size, in bytes */blksize_t st_blksize; /* Block size for filesystem I/O */blkcnt_t st_blocks; /* Number of 512B blocks allocated */
}
const char* filepath = "file.txt";int main()
{int fd = open(filepath, O_CREAT | O_WRONLY | O_TRUNC, 0666);if(fd < 0){perror("open");return -1;}const char* msg = "Hello World\n";write(fd, msg, strlen(msg));struct stat st;stat(filepath, &st);printf("filesize:%ld\n", st.st_size);close(fd);return 0;
}
4. 重定向
const char* filepath = "file.txt";int main()
{close(1);int fd = open(filepath, O_CREAT | O_WRONLY | O_TRUNC, 0666);if(fd < 0){perror("open");return -1;}fprintf(stdout, "Hello World\n");fflush(stdout);close(fd);return 0;
}
const char* filepath = "file.txt";int main()
{close(1);int fd = open(filepath, O_CREAT | O_WRONLY | O_TRUNC, 0666);if(fd < 0){perror("open");return -1;}fprintf(stdout, "Hello World\n");// fflush(stdout);close(fd);return 0;
}
fprintf
是向 stdout(显示器)打印数据,为什么打印到文件里了呢?- 为什么调用 fflush 文件才有数据?
问题1:我们知道,1 号 fd 对应标准输出(显示器),关闭1号 fd 后,又将1号 fd 分配给了log.txt
,发现原本向显示器文件输出的内容输出到了文件中了,这实际上是一种输出重定向;由此不难理解,重定向的本质其实是在底层改变了文件描述符下标中的内容,和上层无关
问题2:C语言中,FILE
结构体类型中,除了有_fileno
外,肯定还有其他内容,其中就有一个语言级的文件缓冲区,之前我们使用的printf/fprintf/fflush
等的函数,都是将数据放到语言级文件缓冲区中,再按照一定的刷新策略将语言级缓冲区中的数据刷新到内核文件缓冲区中
现在我们知道了,重定向的本质是文件描述符下标内容的改变,而使用dup2
系统调用能直接完成重定向
#include <unistd.h>int dup2(int oldfd, int newfd);
// 让文件描述符表newfd下标中的内容变成oldfd下标中的内容
const char* filepath = "file.txt";int main()
{int fd = open(filepath, O_CREAT | O_WRONLY | O_TRUNC, 0666);if(fd < 0){perror("open");return -1;}dup2(fd, 1);printf("Hello Wolrd\n");close(fd);return 0;
}
5. 缓冲区
- 什么是缓冲区?
- 缓冲区就是一段内存空间
- 为什么要有缓冲区?
- 有了语言级缓冲区,我们用户就只需要将数据放到语言级缓冲区即可,下面的事就不需要我们管了,同理,有了内核级缓冲区,系统调用就需要将语言级缓冲区的数据放到内核级缓冲区即可,至于 OS 是怎么将数据刷新到外设就不需要系统调用管了,提高了使用者的效率
- 再者,系统调用是有一定的消耗的,如果没有语言级缓冲区,我们有一段数据就调用一次系统调用,有了语言级缓冲区,先将数据存放到语言级缓冲区,等到一定的条件,调用一次系统调用就将大部分数据放到内核级缓冲区;这也提高了刷新IO的效率
- 缓冲区的刷新策略(对于语言级缓冲区而言):
- 立即刷新,如
fflush(stdout)
,int fsync(int fd)
(立即刷新内核缓冲区) - 行刷新,针对显示器
- 全缓存,缓冲区满了才刷新,针对普通文件而言
- 特殊情况:进程退出,系统会自动刷新
- 立即刷新,如
int main()
{printf("Hello World-1\n");fprintf(stdout, "Hello World-2\n");const char* msg = "Hello World-3\n";write(1, msg, strlen(msg));fork();return 0;
}
同一份代码,分别向显示器文件和普通文件打印数据,结果不同,该怎么解释?
向显示器文件打印,语言缓冲区按照行刷新的策略,printf/fprintf
输出到stdout
中直接就刷新到内核缓冲区了,即使创建子进程,只会拷贝父进程的代码和数据,不会拷贝内核空间内容
而一旦重定向后,就是向普通文件打印,会按照全缓冲刷新策略,printf/fprintf
执行完后,数据在语言缓冲区中,创建子进程,子进程写时拷贝父进程拷贝语言缓冲区中的数据,父子进程结束前,都自动刷新语言级缓冲区
有了上面的理论知识,我们可以尝试在之前实现的 shell 中添加重定向功能,以及用 C 的方式封装系统调用
6. stderr
0、1、2文件描述符对应的文件默认被打开,其中0,1分别代表键盘文件和显示器文件
程序其实是一堆的数据,我们在运行程序时,需要看到这些数据,需要知道数据从哪来(0),往哪去(1),因此0,1默认被打开就是自然的
2 表示标准错误,对应显示器文件,打开了1,我们就能得到所有的数据,为什么还要打开2呢?
int main()
{fprintf(stdout, "Hello World - stdout\n");fprintf(stderr, "Hello World - stderr\n");return 0;
}
将 1 号重定向到一个文件,将 2 号重定向到另一个文件,这样就能将正确信息和错误信息分离,未来我们希望看到程序有哪些错误,只需要查看错误信息文件即可
stderr 的作用:能够将程序中的错误信息单独输出
7. 文件系统
上面所讨论的都是在程序中打开的文件,那么还有大量未被打开的文件是怎样的?
未被打开的文件存放在磁盘上,接下来我们讨论的都是这些文件在磁盘上如何存取的问题
目前,大多数笔记本使用的都是固态硬盘(SSD),机械硬盘公司内部用的多
7.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(逻辑区块地址)
此时,文件 = 很多个块
7.2 文件系统的理解
现在,我们知道了 OS 访问磁盘的基本单位是块,但磁盘还是太大了,不方便管理,于是对磁盘进行分区,我们电脑上的C盘,D盘等就是分区后的结果;但分区后每一个区还是太大了,再进行分组,这样,我们的 OS 只要将一个组管理好,就能管理好每一个组,相当于管理好了一个分区,而一个分区管理好了,每一个分区也就能管理好了,我们把这种思想叫做分治
文件 = 内容 + 属性,磁盘存储文件也就是要存储文件的内容和文件的属性,在 Linux 中,文件的内容和文件的属性是分开存储的
在每一个组中,分成了几个区域,每一个区域有不同的用处,我们把这种管理文件数据的方式叫做磁盘级文件系统
Data Blocks
:由很多个块构成,只存放文件的内容,占整个组的空间最大block bitmap
:位图,用 bit 位来表示 Data Blocks 中每一个块的使用情况;将来新建文件时,先去 block bitmap 中查找哪一个块的 bit 位为0,表示该块可用,再给文件分配块inode table
:inode 表,用来存放文件的属性;Linux 中,文件的属性存放在一个大小固定(128字节)的结构体中;一个文件对应一个 inode 结构体;但注意,文件名并不保存在 inode 结构体中;一个块可以存放 32 个 inode 结构体inode bitmap
:inode 位图,用 bit 位来表示 inode table 中 inode 结构体的使用状况,bit 位的位置表示 inode 结构体的位置,bit 位的值,表示 indode 结构体是否被使用GDT(Group Descriptor Table)
:存放该组的属性信息,有多少个块,inode 已经使用等super block
:超级块,存放该区的属性信息,该分区有多少块,多少 inode 已经使用, 还剩多少个块,inode等,也可以说它存放文件系统的属性信息;在部分组中存放该字段且内容都相同,这样做的原因是,如果当前组被破坏,能够使用其他组中的 super block 进行恢复
我们上述的一系列结构称为文件系统的管理数据,之前我们可能听说过格式化这个词,其本质就是在分区之后,在每个组中写入文件系统的管理数据
inode
结构体:
struct inode
{size_t size; // 大小mode_t mode; // 权限int time; // 时间int creater; // 创建者......int inode_number; // inode编号int datablocks[N]; // 对应块号......
}
以上就是我们对文件系统的初步理解
7.3 inode 编号的理解
在 inode 结构体中,有一条属性 inode number,每一个文件都要有一个 inode number,它以分区为单位,也就是说,同一个分区不能有两个相同的 inode number 的文件
OS 要找到文件时,必须要拿到该文件的 inode 号,去 inode bitmap 中查找对应的 bit 位是否正常,然后找到该文件的 inode 结构体,这样文件的属性和内容就拿到了
分区过后,会给每个组分配一定范围的 inode 编号,同时在 super block 和 GDT 中会记录每组 inode 号的起始和结束,假如我要找 inode 号为20004的文件,先去根据 GDT 中的记录找该 inode 在哪组,找到组后,减去改组的起始 inode 号,就找到了文件在该组中的相对位置
为什么说拿到了文件的 inode 结构体就拿到了文件的属性和内容了呢?内容不是存在 data blocks 中吗?
在文件的 inode 结构体中,有一个数组,该数组的元素个数是15(不同文件系统大小不同),该数组记录着该文件的内容对应的块号,将来我们只要找到文件的 inode 结构体,根据数组就能拿到文件的所有块号
其中数组的前一部分下标是直接映射到数据块的,但如果所有下标都直接映射,一个最大的文件也才60KB,太小了,因此数组后一部分下标是间接映射,即映射的块不存放文件内容,也存放块号,有可能是多级映射
7.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 中,内核为我们进行路径缓存
有了上面的知识,我们就能理解为什么定位一个文件,必须带上路径,因为有了路径+文件名,OS 才能对路径逆向解析,最后找到文件;而目录则是文件系统提供写入并组织好,由我们或进程提供
inode 号的分配以分区为单位,拿到一个文件的 inode 号后,怎么知道它在哪个分区?
当创建一个分区后,给该分区格式化(写入文件系统),需要将该分区挂载到 Linux 中的某个路径下,才能正常使用该分区,也就是说,能在目录中操作文件之前,该目录一定在某个指定的分区下
8. 软硬链接
根据上述现象,发现:
- 软链接是一个独立的文件,他有自己的 inode number ,内容是目标文件的路径
- 硬链接不是一个独立的文件,内容跟目标文件一样,且每对一个文件建立硬链接,它的硬链接数就+1
软链接:
它类似于windows下的快捷方式,当我们想要快速访问一个在很深的目录下的文件,可以在外面对该文件建立软链接,这样就能够直接访问文件
硬链接:
本质是在文件所在目录的内容(文件名与 inode number 的映射关系)中,添加新的文件名与 inode number 的映射关系,并没有新建文件;硬链接数是该 inode number 对应多少个文件名
Linux 中,任何一个目录在新建时,硬链接数一定是2,因为在 Linux 下,任何一个目录都有 . 和 … ,其中 . 当前目录的硬链接
一个目录中的目录数 = 该目录的硬链接数 - 2
在 Linux 中,不能对目录建立硬链接,防止查找文件时形成路径闭环
总结,硬链接的作用:
- 构成 Linux 系统的路径结构,能够使用 . 和 … 相对路径的方式进行文件定位
- 对一个文件进行备份
9. 动静态库
在 C 语言中使用 printf/scanf 等函数,我们好像没有实现这些函数,但代码能正常运行;这是因为这些函数的具体实现在库中;我们的代码被编译成 .o 文件,再和库进行链接,形成可执行程序
库分成静态库和动态库,后缀在 Linux 和 Windows 下不同
Linux 下:
- .so:动态库
- .a:静态库
Windows 下:
- .dll:动态库
- .lib:静态库
9.1 静态库
ar rcs libmyc.a *.o # 将所有的.o文件打包成静态库
gcc main.c -I static/include/ -L static/lib/ -lmyc
// -I: 指定头文件的搜索路径
// -L: 指定库文件的搜索路径
// -l: 指定库名称
所谓的库,其实就是多个 .o 文件的打包,库真正的名字是去掉 lib 和后缀,比如上述库 libmyc.a ,库名是 myc
那么为什么有库呢?为了提高用户的开发效率,我们使用别人做好的库,就能更高效的开发我们要的东西
我们使用的编译器是 gcc/g++ ,对于 C/C++ 库文件,它是认识的,但我们自己添加到系统中的库文件(第三方库),它是不认识,需要我们在编译时指定库文件名
将第三方库拷贝到系统中,叫做库的安装,从系统中删除,叫做库的卸载
如果编译加了 -static 选项,强制全部使用静态库,如果没有对应的静态库,就报错
9.2 动态库
现在大部分程序都使用动态库,因此 gcc 提供了制作动态库的选项
gcc -fPIC -c xxx.c # -fPIC: 产生位置无关码
gcc -shared *.o -o libmyc.so # -shared: 制作动态库的选项
指定了头文件和库文件的搜索路径以及库名称,发现编译通过了,但程序不能运行,原因是程序在运行时找不到对应的动态库
为什么指明了动态库的路径,还是找不到?因为指明的路径是给 gcc 编译器的,编译器也确实找到了并形成了可执行程序,但运行程序就和编译器无关了,是我们的 OS 在运行程序,你没有给 OS 指明路径,OS也就找不到动态库了
要解决这个问题,有很多方法:
- OS 找动态库会去 /usr/lib 目录下找,把动态库拷贝到该目录下
- 在 /usr/lib 目录下建立第三方库的软链接
- 在环境变量 LD_LIBRARY_PATH 中添加第三方库的路径
- 在系统文件 /etc/environment 中添加环境变量,让环境变量永久生效
- 在 /etc/ld.so.conf.d/ 目录中添加库路径的配置文件,然后执行 ldconfig 命令刷新配置文件
9.3 动态库的原理
程序的加载
可执行程序需要加载到内存称为进程,而进程 = 内核数据结构 + 代码和数据,于是 OS 先创建 task_struct、mm_struct、页面等一系列结构,再将数据和代码加载到物理内存,在页表中构建虚拟地址到物理地址的映射关系
将来 CPU 执行到某行代码,发现需要调用库中的方法,如果库没有加载到内存,由页表进行虚拟地址到物理地址的转换就会失败,进而发生缺页中断,由 OS 将库加载到内存,并在进程地址空间上的共享区构建映射关系,从而就能调用库中的方法了
未来如果有其他进程也要调用库中的方法,发现内存中已经有一份库了,就不需要加载,直接构建映射关系
我们说地址空间其实是一个 mm_struct 结构体对象,本质上是一系列区域的划分,里面记录着各个区域的起始和结束地址,那么这些地址的初始值从哪来的呢?也就是地址空间的虚拟地址从哪来?
实际上,可执行程序在被加载到内存之前就已经就"地址"了
objdump -S a.out
可以看到,每条汇编代码前都有一个数字,这个数字就是每条汇编代码的"地址"
可执行程序是二进制的,二进制是有格式的,叫做 elf 格式,在 elf 格式的可执行程序的头部,记录着该程序的属性,包括各个区域的起始地址、结束地址,main 函数的地址等,我们把这个"地址"叫做逻辑地址,也可以当作虚拟地址
那么编译器如何编址呢?规定一个范围,比如0000000000000000~FFFFFFFFFFFFFFFF,由低到高,依次给每条汇编代码一个地址,我们把这种编址叫做平坦模式
CPU内部,有一个寄存器,叫做 pc 指针,保存着要执行的指令的地址
而在程序未加载到内存之前,OS 构建进程内核数据结构阶段,加载器根据 elf 格式的程序中的头部属性信息,就拿到了程序每个区域的起始地址和结束地址,以及 main 函数的地址,将 main 函数的地址加载到 pc 指针中,将各个区域的地址加载到地址空间中,这样就完成了页表中虚拟地址的构建
而一旦 OS 将程序加载到物理内存,每条代码自然就有了物理地址,OS 再根据这些物理地址,完成页表中物理地址的构建,从而完成虚拟地址到物理地址的映射,完成程序的整个加载
CPU 根据 pc 指针,通过 MMU 将虚拟地址转化为物理地址,去执行对应的代码,同时 pc 指针指向下一条要执行代码的地址,执行到某处,需要调用函数,再根据页表找到对应的物理地址,依次循环,我们的程序就这样运行起来了
动态库的加载
理解了程序的加载过程,动态库的加载跟它类似,同可执行程序类似,动态库在没加载到内存之前每条汇编代码都有地址,也是按照平坦模式进行编址,每条汇编代码的地址将来要映射到地址空间的共享区之外,同时也是相对于动态库的偏移量
但动态库加载到内存时,OS 会将其映射到地址空间的共享区中,同时给予一个动态库的起始地址
未来我们的程序中需要调用动态库中的方法,先找到动态库的起始地址,然后加上方法的偏移量,就能找到要调用的方法
在整个过程中,库加载到地址空间的任何地方都没有关系,因为,函数的偏移量在编译时就确定,只要有库的起始地址,总能找到函数在物理内存中的位置,这就叫做与地址无关,也是为什么形成动态库的目标文件编译时要加上 -fPIC,表明能够以任意地址的方式编译
一个程序有可能需要很多库,也就意味着会有很多库加载到内存中,既然是 OS 找到库并加载到内存,那么它肯定要管理好这些库,要管理,先描述,再组织,因此,在内存中,一定会有描述库属性的结构体对象,记录该库有没有加载到内存、库的起始地址等信息,再用数据结构将这些结构体组织起来
未来如果有其他程序也要使用库中的方法,只要去查看该库的结构体,如果库已经加载到内存,直接使用即可