在基础IO这篇博客中,我们将了解到文件系统的构成,以及缓冲区究竟是个什么东东,我们都知道缓冲区,有时也谈论缓冲区,但不一定真的去深入了解过缓冲区。为什么内存和磁盘交互速度如此之慢?为什么都说Linux中一切皆文件?别急,在这篇博客中,你都会找到答案。此类博客是笔者在学习过程中整理的笔记,有错误偏差的地方实属正常,还望大佬多多指点。
目录
关于文件操作的基础知识
操作系统内的文件结构
文件描述符fd
什么是文件描述符fd
文件描述符fd的分配规则
重定向
dup2函数
将简易shell加上重定向的功能
对于Linux上一切皆文件的理解
缓冲区
缓冲区的刷新规则
缓冲区的创建
缓冲区在哪创建的?
磁盘文件系统
磁盘的物理结构
盘面的存储结构
LBA地址
磁盘的分区和分组
软硬链接
动静态库
静态库的实现
动态库的实现
理解动静态库加载的过程
关于文件操作的基础知识
我们先来回顾一下文件操作的相关知识,在之前说文件权限的时候,我们就说过
文件 = 内容 + 属性
内容就是文件中实实在在存放的数据,属性则是记录文件的大小,时间戳等关于这个文件本身的一些信息,这也就是说,即使你创建了一个新文件,什么数据都不存储,但是它也是会占据内存的,因为文件的属性信息也是要被存储的。那么我们就可以说对文件进行操作实质上是对文件的内容和属性进行操作
那么如何在系统中定位一个文件呢?首先你得知道该文件叫什么名字,如果连叫什么都不知道那是没有办法找的,其次你需要知道文件的路径地址,当然这一步并不是一定需要的,只要我们知道文件名,我们可以通过查找算法,遍历整个磁盘去寻找这个文件,但这个效率是相当的低,总不可能每次程序打开这个文件就需要查找一次吧。所以我们在定位一个文件时,需要同时知道该文件的文件名和该文件的路径
文件的存储路径分为两种,分别是绝对路径和相对路径,关于这两种路径大家应该都明白其中的含义,所以这里就不再赘述。
文件平时就在磁盘里存放着,我们要想在程序中使用一个文件,首先就得在程序中打开这个文件,拿C语言来说,要想打开一个文件,得使用文件操作函数
FILE * fopen ( const char * filename, const char * mode )
在第一个参数中填入该文件的文件名,如果你只填了文件名,不给出文件路径,那么编译器会先在当前工作路径下进行查找,如果找不到,编译器会去系统默认指定的路径下查找,再找不到,就返回NULL,所以这里需要注意,如果你不想填写文件路径地址只给出文件名,那么要被打开的文件最好就放在当前程序的工作目录下
第二个参数就是要表明对该文件进行什么样的操作了,这里我直接把文档里的表格拿过来了,忘了的同学可以复习查看一下
如果我们想关闭这个文件了,那么就需要使用函数
int fclose ( FILE * stream )
参数中传入我们要关闭的文件名即可,除了打开和关闭文件函数,还有很多对文件进行操作的函数,比如常用的fwrite, fread, fseek等,分别是对文件进行写入,读取,改变文件指针在文件中的位置,通过fseek函数,我们可以改变文件指针的位置,方便写入
简单的复习了解C语言的一些文件操作函数,我们也许会思考这样一个问题,对文件进行各种操作究竟是谁做的?是C语言吗?C语言给我们提供了这么多关于文件操作的函数,对文件进行操作想必是C语言提供的函数处理的。事实并非如此,如果你读过笔者之前写的进程详解,你应该能想到,内存都是由操作系统来进行管理的,你既然想在程序中打开并使用文件,那必然要把文件加载到内存中,那文件也就该由操作系统来进行管理,那为什么我们是使用C语言的函数来进行文件操作的呢?这就是这篇文章我们要讲明白的知识之一
在程序中使用文件时,文件被加载到内存中,文件就由操作系统来管理,这意味着打开,写入,读取,关闭文件等操作都是由操作系统来负责的。但是那些C语言文件操作函数是怎样工作的呢? 我明明调用的是C语言的文件操作函数呀,事实上,并非只有C语言,很多程序语言都具有文件操作函数,五花八门的,但是有一点是不变的,它们的文件操作函数本质都是在调用操作系统提供的接口。
操作系统在管理文件时,提供了open() , close(), write(), read(), lseek() 等接口,大家对比一下C语言提供的文件操作函数和操作系统提供的文件操作接口。
可以发现fopen和open,fclose和close等等,看着很相识的,其实fopen本质上就是在调用open,同样的,fclose就是在调用close,fopen是C语言提供的函数,是对系统提供的函数open的一种封装,为什么要这样做呢?直接调用系统提供的open不就可以了吗?这个问题的原因是多方面的,这里简单提一提,一是直接使用open的操作比较复杂,我们还需要了解系统的相关知识,这样学习成本就很高了,二来通过fopen进行封装,可以进行功能的扩展,因为系统提供的接口功能一般比较单一,我们不可能去修改系统提供的接口函数,要想进行功能方面的扩展,我们就需要自己封装一个函数
本篇文章不介绍C语言是如何封装系统提供的文件函数的,我们知道有这回事就行了。我们来详细了解一下操作系统提供的文件函数接口是如何运作的,通过了解这些接口函数,就能从本质上理解所谓对文件进行操作究竟是怎么回事。无论我们使用的编程语言提供的文件操作函数是怎样的,我们都知道本质上就是在调用系统提供的函数接口
首先是open函数
int open(const char *pathname, int flags, mode_t mode);
第一个参数是要打开或创建的文件名
第二个参数就要复杂的多了,我们先看看第二个参数所包含的相关命令
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR : 读,写打开
上面这三个参数,必须指定一个且只能指定一个下面是附加选项,没有限制
O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
O_APPEND: 追加写O_TRUNC: 清除文件中的内容
我们看看open函数中第二个参数的类型竟然是int,必选项和附加项同时有的话,这么多参数怎么传过去呢?这里操作系统采用的是位运算,也就是用比特位来标识是否传入该参数,如果你想传入多个参数,那么将这些参数进行 “或” 运算就可以了,比如我想在文件中追加写一些数据,那么flag的参数可以传 O_WRONLY | O_APPEND
第三个参数是用来指定该文件的权限的,配合之前讲过的 umask码 来确定该文件的权限
下面这张图很好的解释了第三个参数要传入的参数,这里一样是用位运算,传入多个参数需要使用或运算
上面是对open函数使用的一个demo,在这个demo中我想将arr字符数组中的字符写入到某一个文件中,因为test.txt这个文件是不存在的,所以我加了O_CREAT选项来创建这个文件,不过这里我犯了一个错误就是没有使用close关闭该文件,希望大家引以为戒 ,在上面的demo中,我将umask码设为了0,这样在传入权限参数时就不需要考虑umask了,同时大家可能会注意到open函数会返回一个值,这个值我们后面会讲,这里就当成是这个文件的身份证号就可以了,通过这个值,我们可以找到这个文件。
操作系统内的文件结构
到这里大家应该大概了解了系统提供的文件操作接口的用法,接下来我们开始要深入到底层,去看看这个这些被进程打开的文件,操作系统是怎样管理并操作它们的。
我们知道运行中的程序(即进程),如果要打开并使用某个文件,首先要把被打开的文件加载到内存中才能被系统使用,操作系统中有很多的进程,有各种各样的设备程序,那么就肯定有相当多的文件被打开使用。
这些文件总不可能在内存中随意存放吧,操作系统就要想办法去管理这些被打开的文件,还记得我们之前多次提到的要想管理某些东西,就得先描述再组织,那就得根据文件的信息抽象出一种数据类型,然后用某种数据结构将这些数据类型连接起来统一管理。在管理进程的数据时,抽象建立了 task_struct,而在管理系统中的文件时,抽象出了struct_file,这里的结构名称不是一定的,具体是什么名字那就要去看源码了,这里我们了解是什么意思就可以了
struct_file是文件从磁盘加载到内存中时,为了便于操作系统管理,根据被加载文件的相关内容属性而抽象出的一种结构体,该结构体包含了许多字段,用于存储文件的各种属性和状态,例如文件类型、文件描述符、文件操作函数、读写指针、缓冲区等等,有些名词不理解没有关系,只需要知道操作系统可以通过该结构体来操作文件就可以了。
前面说过,进程可以打开文件,那么在内存中,肯定不止被某个进程打开的文件,这时进程就要想办法把自己打开的文件给存下来,这样才能找到自己打开了哪些文件。也就是说,我们访问进程就可以知道该进程打开了哪些文件。按照这样理解的话,被进程打开的某些文件的struct_file 直接存放在进程的结构中就好了。这样确实可以做到,但是如果要打开很多的文件,那么进程的结构岂不是显得非常的臃肿,并且如果有两个不同进程都要打开同一个文件,那这个文件同时拷贝到两个进程中,这个文件在内存中就存在了两份,如果有更多的进程都打开这个文件呢?想想是不是会造成大量的代码冗余。大佬们也早已想到这些问题,因此,在进程的结构体中,并非是直接存储struct_file 而是包含一个结构体指针struct files_struct file* ,这个结构体指针指向结构体struct files_struct,此时你可能会感到懵,刚才还提到struct_file呢?怎么又冒出来一个struct files_struct,不要急,我们暂时先忘记struct_file
而这个struct files_struct里面包含了一个结构体指针数组,其中的每一个元素都是一个struct_file* 指向一个 struct_file,这个结构体指针数组又被称为文件描述符表,struct file* fd_arry[];
我知道这里是有些绕的,如果没有明白,请耐下心来多看两遍或者看看下图
如下图,在某个进程中,我们假设用open系统调用,打开文件x.txt
细心的同学可能会发现,在上图中,我们只打开了一个文件,但是这个被打开的文件却在文件描述符表中第4个位置,这是什么情况?不应该是放在第一个嘛,这是因为前三个位置已经被占了。操作系统中文件描述符表中的前三个位置是留给标准输入,标准输出,标准错误的,这三个流在创建进程时就会被打开,这里不理解没关系,后面明白C语言的文件类型FILE,就能够想明白了
我们简单认为在默认情况下,进程的文件描述符表中的标准输入是指向系统中的键盘文件,可以接收键盘输入的数据,标准输出,是指向显示器文件,可以将数据输出到显示器上,而标准错误也是输出到显示器上,不用奇怪,因为Linux下一切皆文件,如下图
文件描述符fd
什么是文件描述符fd
文件描述符fd其实就是文件在文件描述符表中的下标值,在上面的图片中,标准输入的fd为0,因为其在文件描述符表中第一个位置,我们自己打开的文件x.txt的fd为3,因为其在文件描述符表中第四个位置
文件描述符就是从0开始的小整数,当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述和管理目标文件,于是就有了结构体struct_file。表示一个已经打开的文件对象,而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个结构体指针, 指向一个结构体struct files_struct,该表最重要的部分就是包涵一个指针数组,这个指针数组的每个元素都是一个指向文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件
文件描述符fd的分配规则
先看下面几张图片中的程序及其运行结果
再观察下面两个程序及其运行结果
这里我们可以得到一个结果,文件描述符fd的分配规则就是:找到当前没有被使用的最小的一个下标,作为新的文件描述符。
在上面的程序代码中,当标准输入,标准输出,标准错误都没有人为关闭时,此时打开test.txt,test.txt的fd值为3,这没有什么问题,因为此时文件描述符中的未被使用的最小的一个下标为3
当我们人为的关闭标准输入和标准错误时,此时打开fd,发现fd的值为0,这是因为此时文件描述符表中,未被使用的最小的一个小标为0,可能有些同学会问,如果关闭标准输出呢?别急,这点我们留到重定向里讲
重定向
前面我们为了测试文件描述符fd的分配规则,分别关闭了标准输入和标准错误,那么接下来,我们试试关闭标准输出会发生什么?看下面一段程序
我们发现,当把标准输出关闭时,我们打印的fd的值竟然输出test.txt中了,根据我们前面说的fd的分配规则也不难理解,标准输出被关闭了,此时又打开test.txt,那么test.txt就会被分配到标准输出的位置,从而将原本应该输出到屏幕上的数值,输出到test.txt文件中,这个fd指向对象被修改的过程,我们称之为重定向
可见重定向的本质是上层用到的fd不变,而是在内核中更改fd所对应的struct file*的地址
dup2函数
如果只靠使用close来进行重定向的更改,不仅用起来麻烦,而且有着不确定性,因此在进行重定向修改时,系统提供了dup2函数
dup2(int oldfd, int newfd) the newfd be the copy of oldfd
oldfd 是要被复制的文件描述符,newfd 是新文件描述符的值。执行 dup2() 函数后,会把oldfd的所有属性都复制到newfd上,包括文件偏移量、文件状态标志和文件打开方式等,而且oldfd不会被删掉,也就是说,dup2执行后,newfd 和 oldfd 将指向相同的文件
概念今天仍然发挥稳定,依旧不讲人话,接下来我们看看实列
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
char arr[30] = "this is a test";
int fd = open("test.txt", O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR | S_IRGRP);
//此时fd的值因该是3,因为前三个是留给标准输入,输出,错误的
dup2(fd, 1); //这里利用dup2是fd中文件覆盖标准输出
printf("%s",arr);//往标准输出中打印字符串
write(fd, arr, sizeof(arr));//往fd中输出字符串
//我们能在test.txt中看到arr打印两次
fflush(stdout);
return 0;
}
下图是运行结果,根据结果可知,此时标准输出和文件描述符3都是指向test.txt这个文件,因此两次输出都是输出到test.txt文件中
至于中间为什么会出现乱码^@呢?这是因为我们第一次输出使用的是printf,直接把整个字符数组arr都输出出来,因为arr数组我们并没有把空间用完,因此其他空间都是存放着'\0',而'\0'本是二进制零字节,表示字符串的结束,在文本文件中打印出来就是乱码^@了
将简易shell加上重定向的功能
好了,现在我们已经了解过并用dup2实践了如何进行重定向的操作,因此我们直接上强度,把我们之前在进程中实现的模拟简易的shell再翻出来,给其加上重定向的功能
还记得我们模拟实现shell命令行的原理吗?我们的实现方法是从输入中读取用户输入的命令行,然后把命令行中的命令,选项,参数都分割存储起来,然后创建一个子进程,在子进程中进行进程替换,再把这些分割好的命令作为参数传给进程替换函数,这样子进程执行完命令,不会影响到父进程,当子进程运行结束也就代表着这个命令已经执行结束,可以再次输入命令
我们要想添加重定向的功能,那么首先要对命令进行一个判断,这个命令中是否包含了字符,' > ' , " >> " , ' < ' 如果命令出现了这些字符,那就说明这个命令是一个重定向命令,重定向分为三种,输出重定向,追加输出重定向,输入重定向,分别对应上面的那三个重定向符号,所以我们要逐个判断,首先要写一个函数,这个函数用来判断当前用户输入的命令是否具有重定向的操作,其次要判断是哪种重定向操作,具体代码如下
//line是命令行字符串,也就是我们用户输入的命令
#define NONE_REDIR 0 //不进行重定向
#define INPUT_REDIR 1 //输入重定向
#define OUTPUT_REDIR 2 //输出重定向
#define APPEND_REDIR 3 //追加输出重定向
//该宏函数用来跳过命令行中的空格
#define trim_space(start) while(isspace(*start)) start++
char line[MAXSIZE];
char* args[100] = {NULL};
int lastcode = 0;
int lastsig = 0;
int redir_type = NONE_REDIR; //重定向标志
char *redir_file = NULL; //指向被重定向的对象
void commend_check(char *line){
char* start = line;
char* end = line + strlen(line);
while(start < end){
//在这里判断是否出现重定向标识符
if (*start == '>'){
//这里把'>'设为'\0'的目的是将前面的命令和后面的重定向对象分割开
//例如:ls > test.txt,把 > 设为'\0'可以将该字符串分成两部分
*start = '\0';
start++;
//如果出行两个>>则说明是追加重定向
if (*start == '>'){
// *start = '\0'; 这里不用加,一个'\0'就能隔绝了
start++;
redir_type = APPEND_REDIR;
}
else{
redir_type = OUTPUT_REDIR;
}
trim_space(start);
redir_file = start;
break;
}
else if (*start == '<'){
*start = '\0';
redir_type = INPUT_REDIR;
start++;
trim_space(start);
redir_file = start;
break;
}
else start++;
}
}
判断完重定向之后,我们要根据重定向标识来确定当前是否处于重定向,如果是的话,继续判断属于哪种重定向,这里用switch分支判断语句来进行判断
int main(){
while (1){
printf("\n[myshell@vm-centos]$ ");
fflush(stdout);
//这里的初始化是一定要有的,否则会导致一直进行重定向操作
redir_type = NONE_REDIR;
char* s = fgets(line, sizeof(line)-1, stdin);
assert(s != NULL);
line[strlen(line)-1] = 0;
//命令行的判断一定要在分割字符串之前,否则判断的字符串不完整
commend_check(line);
args[0] = strtok(line," ");
int i = 1;
if(args[0] != NULL && strcmp(args[0], "ls") == 0)
args[i++] = (char*)"--color=auto";
while(args[i++] = strtok(NULL," "))
;
if (args[0] != NULL && strcmp(args[0], "cd") == 0){
if(args[1] != NULL) chdir(args[1]);
else chdir(getenv("HOME"));
continue;
}
if (args[0] != NULL && strcmp(args[0], "echo") == 0){
if (args[1] != NULL && strcmp(args[1],"$?") == 0)
printf("%d %d\n", lastcode, lastsig);
else printf("%s\n",args[1]);
continue;
}
pid_t id = fork();
if (id == 0){
switch(redir_type){
case NONE_REDIR: break;
case INPUT_REDIR: {
int fd = open(redir_file, O_RDONLY);
if (fd < 0) {perror("open"); exit(errno);}
dup2(fd,0);
} break;
case OUTPUT_REDIR:
case APPEND_REDIR: {
umask(0);
int file_status = O_WRONLY | O_CREAT;
if (redir_type == APPEND_REDIR) file_status |= O_APPEND;
else file_status |= O_TRUNC;
int fd = open(redir_file, file_status, 0666);
if (fd < 0) {perror("open"); exit(errno);}
dup2(fd, 1);
} break;
default: printf("error\n"); break;
}
execvp(args[0], args);
exit(0);
}
int status;
pid_t tmp = waitpid(-1, &status, 0);
lastcode = ((status>>8) & 0xFF);
lastsig = (status & 0x7F);
}
}
对于Linux上一切皆文件的理解
可能在学习Linux之前我们就听说过Linux上一切皆文件,但是为什么是一切皆文件,我们可能并不是很清楚,而现在随着我们对Linux下文件系统理解的加深,我们可以尝试去理解这个结论,当然这个过程是相当之复杂,这里我们了解其大致原理就好
我们能直接接触的肯定就是硬件,键盘,鼠标,显示器等等,要想让这些硬件能够工作,那么我们就需要驱动,驱动是用来控制硬件设备的软件,里面包含着各种与硬件进行交互的代码指令,比如上图中写方法和读方法,也就是往硬件中写入数据或者读取数据,那这和一切皆文件有什么关系呢?我们接着往下看
如果这些硬件设备已经连接到电脑上,那么操作系统会为其创建一个struct file,这个结构体里包含了当前硬件的一些状态信息,并且包含了驱动这些硬件的函数指针,这样操作系统就可以仅通过管理这些文件结构体来操控这些硬件,所以这些硬件在操作系统的眼中就是一个文件,这就是为什么Linux中一切皆文件,这里需要提醒各位,这个过程比较繁杂,目前我们不需要关心中间具体实现细节,但是设计思想这点是不变的
你可能会有这样的疑问,如果我们当前进程不使用某个文件了,那么操作系统会不会将这个文件结构体和文件信息从内存中删掉呢?
这个问题的答案是不一定,操作系统是否会将一个文件从内存中去除,主要看这个文件的引用计数,那什么又是文件的引用计数呢?
一个文件可能不只是一个进程在打开使用,其他的进程也有可能使用该文件,文件引用计数就是记录当前文件有多少个进程在使用,如果一个新的进程也要打开使用这个文件,那么这个文件的引用计数就会加1,相反的,如果某个进程不再使用这个文件,那么这个文件的引用计数就会减1,当一个文件的引用计数减到0的时候,就说明目前没有任何进程在使用这个文件,那么操作系统就会释放当前文件的文件结构体和所占用的内存资源
缓冲区
我们之前听到过缓冲区的概念,要想把某个数据打印到显示屏上,首先得先写入到输出缓冲区中,然后再由输出缓冲区刷新到显示屏上,接下来我们写个小程序做个实验
上面两个程序就很好的证明了缓冲区的存在,第一个程序中我们没有加上'\n',那么程序的打印语句会一直打印到缓冲区中,等满足一定条件时再全部打印到屏幕上,所以你看到的打印是密密麻麻挤在一起的
在第二个程序中我们加上了'\n',程序好像并不会等待很久,而是立即开始打印,这种情况又是为什么呢?
上面我们只是证明了缓冲区这东西确实存在,确实有这么回事,但是为什么会产生刚才那种结果?以及缓冲区存在的意义是什么?我们慢慢探讨,其实发现问题就已经解决了问题的一半,接下来我们借用生活中的例子讲解一下为什么要有缓冲区
小明是一个商家,每周都要售卖大量的货物,但是买家可能在全国各地,如果小明和买家完成订单之后,要亲自去送货的话,那么小明肯定是忙的不可开交,可能一周的时间都用在去送货的路上,这对小明来说,肯定是苦不堪言的,好在,现在的物流服务发展的不错,小明不需要亲自去送货,只要通过物流来进行发货就行,剩下的时间小明可以用来干其他想干的事情
我们把缓冲区的概念带入进去,小明就是进程,买家就是磁盘中的某个文件,小明亲自去送货给买家就相当于进程亲自去把数据写入到某个文件中,内存的速度和磁盘的速度那不是一个量级的,进程将有99.9%的时间浪费在等待磁盘响应的过程中
而这个时候的物流中心就是缓冲区,进程不需要亲自将数据写入到磁盘的文件中,而是直接写入到缓冲区中,剩下的就不需要它操心了,数据将由缓冲区刷新到磁盘的目标文件中,进程可以接着处理其他的事情
缓冲区的刷新规则
可见缓冲区的作用还是挺大的,但是缓冲区又什么时候将数据写入到目标文件中呢?这个问题就关系到缓冲区的刷新规则,那么具体的应用场景不同,刷新规则也是不同的,这点和现实中的快递还是很像的,现实中的快递有加急运送,就是用飞机等高速交通工具配送,也有普速配送的,也有慢速配送的
那么进程也是如此,针对不同的运用场景,会采用不同的刷新策略,具体如下
1.立即刷新,立即刷新就是数据写入完毕,不需要等待,立即刷新到目标中
2.行刷新,行刷新就是数据写满缓冲区的一行时,才将缓冲区的数据刷新到目标中,我们的显示器就是采用行刷新的策略,这更符合我们的阅读习惯,一行一行阅读
3.全缓冲,全缓冲就是将缓冲区写满了才将缓冲区的数据刷入到目标中,一般往磁盘中写入大量数据时,会采用这种刷新策略,因为这样可以减少和磁盘的IO次数,从而提高写入效率,试想如果往磁盘中写入采用行刷新,那么原本一次IO可以解决的问题,硬生生被拖成多次IO操作,这是相当浪费资源的行为
不过在一些特殊情况下,我们可能得打破这些规则,比如有些需要立即刷新的情况,但是当前场景可能采用全缓冲刷新策略,那么这个时候我们只能人为强行刷新,C语言为我们提供了fflush()函数,使用该函数即可强行刷新缓冲区,当进程即将结束的时候,也会强制将缓冲区里的内容刷新到目标文件中
缓冲区的创建
我们大概了解了缓冲区是个什么东西,有什么作用了,缓冲区本质就是一块内存空间,能够提高IO效率,但是缓冲区是谁来创建的呢? 看下面两段程序
这段程序运行结果是没有什么大问题的,就是将程序结果重定向到文件中时,好像顺序发生了一些小变化,怎么处于最后打印的语句却是第一个打印处理来的呢?这个问题我们会慢慢解释,接下来看另一段程序
接着看上图这个程序,我们仅仅只是在return 0上面加了一个fork() 语句,创建了一个子进程,将程序结果打印到屏幕上时,结果还是比较正常的,但是把结果重定向到test.txt文件中时,使用C语言提供的函数会打印两次,而系统提供的函数只打印一次,这又是什么原因导致的呢?
遇到问题,我们一般第一反应都是慌张,但是慌张归慌张,我们要静下心来分析,这能够很好锻炼我们的抗压能力。接下来一起分析一下,想想我们上面了解过缓冲区就是帮助提高IO效率的,这些打印语句不会直接输出到屏幕或其他文件上,而是先输出到缓冲区中,再由缓冲区刷新到目标文件上,所以问题应该是产生于缓冲区,且第二段程序中只有C语言的输出语句才会打印两次,系统调用的是正常的,说明现在使用的这个缓冲区是编程语言封装提供的,和操作系统内核的关系不大
找到问题的源头了,那么我们就可以针对具体目标进行分析了,我们先分析第一段程序中出现的问题,打印到屏幕上,结果是符合预期的,屏幕一般采用的是行刷新策略,我们每一个打印语句后面都加了'\n'表示此行结束,所以每一个语句写入到缓冲区后就会被刷新出来,结果是符合我们预期的。但是当我们将内容重定向到磁盘中的某个文件中时,采用的是全缓冲刷新策略,即使我们每一个打印语句后面都加了'\n'表示此行结束,但这仅仅表明只是一行的结束,并不足以填满整个缓冲区,因此内容并不会立即刷新到文件中。然后前面三条C语言的输出语句都被存放到缓冲区中, 因为系统调用和缓冲区的关系不大,所以系统调用可以直接输出到屏幕上或者目标文件上,而此时因为缓冲区的数据还没满,所以C语言这三条语句仍未刷新,直到程序即将结束,强制刷新缓冲区,把前面C语言这三条语句给刷新到屏幕上了,这就是为什么系统调用输出语句明明处在程序的最后面,反而第一个打印的原因了
接下来我们看看第二个程序出现问题的原因,为什么我们加了一个fork()就会导致C语言输出语句都打印了两次呢?这个程序和我们前面分析的那个一样,只是后面多了一个fork(),也就是创建了一个子进程,问题就是出在创建了这个子进程,我们知道,子进程会和父进程共享资源的,在程序创建完毕,紧接着面临程序退出,我们不知道子进程和父进程谁先退出,但总有个先后,先退出的这个进程会强制刷新缓冲区中的数据,强制刷新缓冲区本质就是修改了缓冲区内的数据,那么这个时候就触发了写时拷贝,我们知道,父进程和子进程共享资源,但仅在只读条件下,如果任一方试图修改其中的数据,那么就会触发写时拷贝,保证进程的独立性,如此,缓冲区中的数据就会被拷贝一份,这样当两个进程都退出的时候,就会产生C语言输出语句打印两次的情况
缓冲区在哪创建的?
通过上面的两个程序,我们知道,我们现在提到的缓冲区都是编程语言提供的,那么在哪里呢?拿C语言来说,文件缓冲区其实就是封装在FILE中,我们知道FILE是C语言中的文件数据类型,FILE结构体不仅封装了文件在系统内核中的文件描述符,还封装了一个缓冲区和其他各种文件的数据属性
C语言中的stdin,stdout,stderr其实都是FILE类型,内部都封装了缓冲区和文件描述符,其中的fd分别封装的是进程文件描述符表中的0,1,2。这也就是为什么默认情况下,我们在使用C语言函数时,例如sprintf() ,往stdout这个FILE文件里写数据,最终是在显示器上输出数据,C语言中的stdout封装的是进程中文件描述表的stdout,也就是进程文件表述表中下标为1的位置,即fd为1,而文件描述符表fd为1默认指向的是系统中的显示器文件。那么整个过程就是,使用sprintf函数往C语言中的stdout里写入数据,数据就会先存放到stdout这个文件的FILE结构体内部的缓冲区中,因为C语言中的stdout封装的fd的值为1,那么再将数据从stdout缓冲区中刷新到fd为1这个位置指向的文件的缓冲区,默认情况下fd为1指向的文件是显示器文件,最后由内核将其刷新到显示器上,大家这里不需要管内核内部的缓冲区,我们讨论的缓冲区的范围仅限于编程语言提供的,也就是FILE结构体内部包含的缓冲区
言语千句,不如实践一次,接下来,我们自己封装一个FILE结构体,写一个缓冲区演示的demo,这里附上测试demo的代码,大家可根据代码自行修改测试
磁盘文件系统
了解完文件是如何在操作系统内部进行管理和运作的,接下来我们来了解文件是如何在磁盘中被存储和管理的。文件平时都是存放在磁盘中的,当我们使用某个文件时,这个文件就会被加载到内存中,由操作系统来执行我们想要的操作命令,那文件在未加载到内存时,又是以怎样的形式存在磁盘中呢?我们一一探索
磁盘的物理结构
磁盘是计算机中唯一一个采用机械结构的硬件, 不过我们目前使用的电脑的硬件存储设备很少使用磁盘,大多都是固态硬盘了,原因有很多,固态硬盘相比较于机械磁盘,具有更高的读写速度,更低的功耗和抗震性能。机械磁盘因为其使用的是机械结构,经常移动就会造成机械结构损坏,像笔记本这类经常移动的个人PC,使用机械磁盘就很容易出问题
按理说机械磁盘作为早期产物,应该退出历史舞台,但是在企业中,仍然大量使用机械磁盘。因为机械磁盘虽然抗震性能低,但是企业中都有自己的服务器机房和数据中心,磁盘在这里不需要被移动,自然就不容易出问题。其次企业中往往需要大量的存储设备,这就要考虑到存储设备的成本问题了,机械磁盘相比于固态硬盘,有着更低的造价,更高的存储容量,这就比较贴合企业的实际需要,因此在企业中,机械磁盘仍然处于主流地位
机械磁盘通过磁头的高速旋转,读取盘面存取的数据,但是需要注意的是,磁头和盘面并不是直接接触,也不会有磁头把盘面刮花的问题,但是如果盘面上灰尘之类的,那么盘面就会损坏,所以磁盘是被严密封装的,一旦拆开,基本就报废了。从上面的描述中,我们可以了解到,整个磁盘中,磁头和盘面是最重要的部分,盘面是数据存储的硬件,磁头是读取盘面数据的工具,我们接下来主要围绕着这两部分介绍
盘面的存储结构
盘面的物理结构就是一个圆,那么数据怎么在这个圆上存储的呢?首先得根据盘面做一个分区,便于管理数据,通常的分区就是围绕着盘心划分同心圆,每一个圆环被称为磁道,然后再把磁道划分为多个弧面,每一个弧面被称为扇区,扇区就是磁盘对存储容量划分的最小单位,每一个扇区的大小一般都为512字节
可能你会有疑问,越靠近盘心的位置,扇区的面积越小,而这种面积较小的扇区也是512字节大小吗?这个取决于硬盘制造商来怎么规划了,一般为了便于管理磁盘空间,会把每一个扇区的大小都设为相同的,这样必然会导致靠近盘心的扇区的存储密度比较高,远离盘心的扇区的存储密度比较低,算是合理取舍了
不过也会有一些厂商不愿放过存储密度比较低的扇区,把磁盘空间利用到极致,但这样会导致磁盘的管理算法复杂,可能会降低磁头定位目的位置的效率
磁盘中对数据进行定位及做增删查改的操作是由磁头来完成的。假设我们现在要查找一个文件,这个文件在A磁道的B扇区里,磁头可以进行一定幅度的摆动,摆动的目的是去定位磁道,找到磁道A后,磁头就不再挪动,接下来就是盘面不断地转动,而磁头的任务就是在盘面转动的过程中,找到目的扇区B,然后对扇区B完成相应的读写操作
由此看来,盘面的转速决定了磁盘产商的技术水平,盘面的转速高,意味着厂商有把握让磁头在这么高的转速下完成对扇区的操作,所以,如果厂商宣传自家磁盘的转速很高,说明其技术水平还是可以的
上面的例子只是针对单个盘面来进行的,实际上,整个磁盘可不止一个盘面,而是有相当多的盘面,也不是只有一个磁头,而是每一个盘面都会配有一个磁头,所以接下来,咱们把视角升到三维上,整体的看待整个磁盘
大家可以看到,图片中有一个柱面的概念,柱面其实就是各个盘面上相同磁道的全部总和,但是在逻辑上柱面其实是等同于磁道的,我们知道要查找的某个扇区在哪个柱面(cylinder)就等于知道了这个扇区在哪个磁道上
然后再根据计算得到具体在哪个磁头(head)上,每一个盘面都会配有一个磁头,这个过程实际就是在定位盘面
最后在确定了磁道的盘面上查找扇区(sector),这种对目的位置的定位方法被称为CHS定位法
举个例子,我们要查找某个扇区,如果只知道柱面是不够的,因为柱面是各个盘面上相同的磁道的全部总和,知道了柱面只是知道这个扇区在哪个磁道上,但是每一个盘面都有这个磁道,所以我们还需要知道在哪个盘面上,确定了盘面也就确定了该由哪个磁头来处理,柱面和磁头都确定了,那么剩下的操作就是盘面旋转,磁头来定位扇区了
LBA地址
上面所说的CHS定位法是磁盘内部在查找数据时使用的,我们平时说查找数据可并不是站在磁盘的角度上查找数据,想一想,我们发布增删查改指令,操作系统替我们完成这些操作,所以我们应该站在操作系统的角度来对磁盘进行操作,那么操作系统是如何看待磁盘中的数据存储的呢?
想必大家都见过磁带,如果你把磁带盒给拆开,那么可以把里面的磁带拉直了玩,磁盘中的盘片和磁带盒里的磁圈很像,操作系统在管理磁盘空间时,就相当于把盘片像磁带一样给拉直了,也就是把磁盘中的每个圆盘给抽象成一个大数组,如下图
每一个盘面都有很多的磁道,每一个磁道上又有很多的扇区,我们把这些磁盘中的概念对应到这个大数组中,需要注意,操作系统把磁盘抽象成数组时,并没有盘面,磁道,扇区的概念,把这些概念标上,是为了便于理解操作系统是如何把磁盘抽象成一个数组的,也为了便于理解后面LBA转化成CHS的过程
既然磁盘空间在操作系统中被抽象成一个数组,那么操作系统在访问磁盘空间时就是以下标的形式访问的,操作系统的这种访址形式被称为LBA地址,翻译过来就是逻辑块地址,接下来我们模拟一下操作系统是如何访问磁盘空间的
假设我们要访问文件A,文件A在创建时就标明了其存放在哪些逻辑块地址上,假设文件A在下标为235的位置上,那么操作系统就开始执行查找命令了,操作系统把要查找位置的下标编号以及查找命令发给磁盘驱动,磁盘驱动来执行这个命令,但是这个下标位置是以操作系统的视角来看的,磁盘内部用的是CHS定位法,因此要把这个下标位置转化成CHS定位法
假设整个磁盘有6个盘面,每个盘面有10个磁道,每个磁道上有100个扇区
盘面:235 / 10*100 = 0 所以该文件位置在0号盘面上
磁道:235 / 100 = 2 该文件在0号盘面2号磁道上
扇区:235 % 100 = 35 该文件在0号盘面,2号磁道,35号扇区上
上述就是简单的描述LBA地址转化成磁盘中的CHS定位法的过程
这个过程看起来挺麻烦的,为什么操作系统不直接使用磁盘中的CHS定位法,而非要自己搞一个LBA地址?这是为了保证操作系统不和磁盘进行强耦合,想一想,当前使用的是机械硬盘,采用的是CHS定位法,如果我换成固态硬盘,或者其他的什么硬盘,那不一定使用CHS定位法,如果操作系统使用CHS定位法访问磁盘就无法衔接其他类型的磁盘。而如果操作系统单独把磁盘抽象成数组,那么管你是什么类型的磁盘,我都会统一把你当成一个数组来管理,然后由硬盘的驱动来负责将LBA地址转化成对应硬盘的定位法
上面主要是演示一下如何将LBA地址转换成CHS,目前的操作系统并不是以这样的形式访问磁盘的,因为一个扇区的512字节相对于磁盘来说实在是太小了,如果操作系统每次只访问一个扇区,也就是512字节,那么将要进行大量的与磁盘的IO操作,这就意味着,将有大量的时间浪费在等待磁盘查找和响应上。所以操作系统在与磁盘进行IO操作时,一次访问的大小一般为4KB,这个是不一定的,也有2KB,也有16KB,可以根据具体应用场景来更改,绝大多数操作系统使用的都是4KB,操作系统在对内存空间进行分区时,也是按照4KB大小来划分的,其中每一个分区被称为页框,而磁盘中的文件尤其是可执行文件也是按照4KB大小划分的块,每一个块被称为页帧
操作系统将整个磁盘抽象成一个数组,并不是前面说的那般潦草,而是会对这个数组进行分区的,这样更利于操作系统对数据的管理,那么操作系统又是如何对其进行分区的呢?
磁盘的分区和分组
我们以个人PC的磁盘大小为例,一般个人PC磁盘大小为500GB,那么这500GB被抽象成数组时会被划分为多个区,每一个区又分为了多个组,下面图片中分区和分组的容量大小是笔者假设的,实际大小不同操作系统都不一样
这个过程又可以对应下图
其中Boot Block分区存放着系统充电启动,加载操作系统,加载图形库时所需要的文件信息, 每一个Block group代表着一个分组,其中每一个分组也存放着对应的信息
Super Block: 存放着文件系统,记录当前分区有多少个组,每个分组的ID,有哪些分组被使用了,哪些分组没有被使用。一个分区中的每一个分组都有这么一个SuperBlock,相当于多次备份,如果某个分组的SuperBlock产生错误,可以从其他分组中重新拷贝一份数据,如果不从事文件系统开发工作,对于SuperBlock我们不需要深入了解
inode table:我们先来了解什么是inode?前面提到过,文件 = 文件属性+文件内容,其中的文件属性就存放在inode中,我们平时在某个文件夹里创建一个文件,那么在操作系统的文件系统中就会创建一个inode,这个inode就映射着这个文件,只不过文件名是给我们人看的,inode是给操作系统看的。每一个inode的大小是固定的,需要注意的是每一个文件的文件名是不存放在该文件的inode中的,这个稍后解释。
我们系统中可以创建很多的文件,每一个文件对应着一个inode,为了区分彼此,每一个inode都有一个ID,使用命令ls -li 即可查看当前目录下所有文件的inode编号。inode table就存放着当前分组所有的inode编号(已使用+未使用)
inode Bitmap:那么怎么知道哪些inode被使用了,哪些inode没有被使用呢?这个工作就交给了inode Bitmap来负责,每一个inode在inode Bitmap中对应着一个比特位,如果这个inode没有被使用,那么这个比特位就会被置为0,如果这个inode被使用了,那么这个比特位就会被置为1
Data blocks:文件=文件属性+文件内容,文件属性保存在inode中,文件内容就保存在Data blocks中,Data blocks中有大量的数据块,这些数据块用来存放文件的内容,后面会提到数据块如何存放文件信息
Data Bitmap:那么怎么知道Data blocks中的哪些数据块被使用了,哪些数据块没有被使用呢?这个工作就交给了Data Bitmap来负责,每一个数据块在Data Bitmap中对应着一个比特位,如果这个数据块没有被使用,那么这个比特位就会被置为0,如果这个数据块被使用了,那么这个比特位就会被置为1
Group Descriptor Table:这个记录着分组的一些信息,比如分组中被使用的inode的数目等等,当系统需要这些信息时,可以直接提取,而不用去计算哪些用过了,哪些没有被使用过
了解完这些概念,现在我们需要想一个问题,知道了文件的inode编号,那么该如何通过inode编号来访问文件中的数据呢?其实在inode结构中不仅存放着文件的属性信息,还有一个数据块数组,这个数组里的元素记录着存储文件内容的数据块的编号,这里的数据块指的就是前面提到过的Data blocks
如果该文件的内容比较大,inode的数据块数组前12个元素都没有办法存完,那么到了第十三个元素,也就是下标为12时,这个元素指向的块并不直接存放文件数据,而是存放其他数据块的id,然后通过这个id找到对应的数据块,接着才存放文件的数据
如果这个文件的内容非常的大,就连inode的数据块数组第13个元素都存不下,那么就会来到inode的数据块数组的第十四个元素,也就是下标为13的元素,这个元素存放的块是个二级索引,也就是说这个块存放的是块的id,不直接存放文件内容,假设存放了一个id为30的数据块,然后来到id为30的这个数据块时,同样不存放文件数据,而是接着存放其他数据块的id,通过两层索引之后的块才存放文件数据,可以算一算,这个存储容量已经相当巨大了,如果你还嫌不够,那么后面还有一个三级索引
了解完文件,接下来我们看看目录,任何文件都是在一个目录之下的,不过目录也是一个文件,不过目录的数据块存放的内容不像文件那样,目录的数据块里存放的内容是文件名和文件的inode的映射关系
软硬链接
软硬链接就是软性链接和硬性链接,那什么又是软性链家和硬性链接呢?
看下面这段程序
使用命令 ln -s 目标文件名 链接文件名 创建软性链接
使用命令 ln 目标文件名 链接文件名 创建硬性链接
命令 ls -i 可以查看每个文件的inode编号
上述代码过程分别为文件test.txt创建了软性链接文件soft_linck(蓝色),和硬性链接文件 hard_linck,可以观察到,硬性链接文件的inode编号和源文件的inode是一样的,我们知道inode代表着唯一的一个文件,既然inode编号相同,那就意味着硬性链接文件本质上和源文件是同一个文件,我们再看看引用计数,硬性链接文件和源文件的引用计数也是相同的,为了验证这个猜想,我们只对源文件中写入一串字符,看看硬性链接文件中是否发生变化
我们往test.txt中写入字符,hard_linck文件中也出现了字符,这就证明了硬性链接和被它链接的源文件本质都是一个文件
硬性链接的应用在目录上比较广泛,我们在一个目录下使用命令ls -al 会发现两个比较特殊的目录,那就是"." 和 "..",其中"."就是当前目录的硬链接,表示当前目录,而 ".."这是当前目录的上一层目录的硬链接,使用命令 cd ..,就会回到上一级目录
不过系统只允许这两个对目录进行硬性链接的文件,其他的文件一律不准对目录进行硬链接
这是因为,如果允许用户给目录建立硬性链接会导致循环链接的问题,假设有目录A,目录A中有目录B,在目录B中创建一个文件test,硬性链接到目录A,那么此时test就是目录A,这就造成了循环,在某些情况下会出现死循环进而导致程序崩溃,因此不允许用户给目录进行硬链接
软性链接是一个单独的文件,拥有自己的inode编号,软性链接很像window中的快捷方式,通过软性链接就能够找到指定的目标文件,在后面测试动态库时有大作用
需要注意的是,如果改变了目标文件的位置,那么软性链接就会失效
动静态库
静态库的实现
静态库:程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库,静态库的后缀为(.a),关于静态库,笔者在介绍gcc时简单提过,使用静态库链接,那么在使用静态库中的某个函数实现时,就会去静态库将该函数的二进制代码拷贝到当前文件中,使用静态库会导致可执行文件的代码量偏大,好处就是生成可执行文件后,以后这个可执行文件就脱离静态库了
我们接下来的任务是手搓个简单的静态库,感受一下实现静态库的过程
现在要使用函数test_1和test_2,平常的做法就是将main函数文件和这三个.c文件同时编译链接
这一切的前提是t1.c和t2.c这两个源文件和main.c的源文件放在一起,现在假设我的朋友要使用我的t1.c和t2.c中的函数,但是我又不想暴露我的源码给他,那么该怎么解决呢?我们可以这样做,把我们的t1.c和t2.c分别编译成二进制代码的.o文件,然后把t1.o,t2.o以及这两个源文件对应的头文件t1.h,t2.h都发给我的朋友,这样,当他想用我的函数,首先他的先包含我发给他的.h文件,其次他生成的.o文件和我的所有的.o文件一起链接就可以了。这样的做法可以,但是相当的麻烦,如果我有几十个.o文件,那么一一发给他,他在一一链接是相当的麻烦的。有没有简单的解决方法呢?当然有,那就是把t1.o t2.o给打包成一个库文件,到时候main.c生成的main.o和这个库文件一起链接就可以生成可执行文件了。由此我们也知道了,库文件本质就是非常多的.o文件的集合
将t1.o 和 t2.o归档成一个库文件,可以使用命令ar -rc t1.o t2.o
现在我们来模拟这个过程,首先写个makefile把t1.o,t2.o打包成一个库文件
然后把所有的.h文件和库文件统一压缩发给他
注意归档的库名是有规范的,如果是静态库,那么库名要加上前缀lib,后缀.a,表示这是一个静态库,例如,上图的makefile文件中,我生成的库名是my_test,加上前缀lib,后缀.a
如图我们把库文件压缩发给我的朋友,假设我的朋友在test目录下,写了一个main.c文件,这个文件使用了t1.h 和 t2.h中的函数test_1() 和 test_2(),他现在需要把我发给他的库文件给解压了,然后链接就可以执行他的函数了
但是注意,这个库文件属于第三方库,如果我们想要使用这个库,就得在这个库和main.o链接时明确指出这个库的库名,注意是指出库名,也就是要去掉前缀和后缀
具体操作命令如下,我们将生成的可执行文件取名为tmp
因为main.c函数中包含了这两个函数的头文件,因此在链接的时候还需要加上-I 选项,把头文件的地址告诉编译器,-L选项是告诉编译器该用哪个库
为什么我们平时包含的stdio.h等头文件不需要告诉编译器呢?而我们的头文件要告诉编译器具体的路径呢?这是因为这些标准库文件在编译器内是专门配置过的,编译器可以区分,而我们写的第三方头文件和库文件只有指定了具体的路径,编译器才能找到
事实上这个过程和我们安装应用类似,安装应用的本质就是把应用的头文件和库文件分别拷贝到我们的电脑上,然后运行这些代码,就能使用应用的功能了
不过还有一定需要大家注意,当我们生成可执行文件tmp之后,使用命令 file tmp可以看到下面的结果
结果显示,tmp文件竟然是动态链接,这是怎么回事,我们不是搞得静态链接吗?怎么到这里就变成动态链接了?
其实我们t1.h和t2.h这两个文件里都包含了头文件stdio.h,我们用的标准库文件一般都是动态库的,我们知道编译器默认使用的也是动态链接,如果t1.h和t2.h这两个静态库的头文件和标准库文件同时出现时,那么编译器就会采用动态链接的方法,至于静态链接那部分,编译器会去对应的静态库里把需要的代码给拷贝出来,然后采用动态库链接法
也就是说只有编译的文件里出现了动态库,那么编译器就会采用动态链接法,采用静态库的那部分编译器会去静态库把需要的代码拷贝过来
动态库的实现
有了实现静态库的基础,我们就能很好的实现动态库,不过动态库的实现和静态库还是有一些区别的,动态库的后缀为.so,接下来笔者使用上面静态库同样的例子生成一个动态库
老规矩,先生成.o文件,不过这里和静态库就稍有区别了,这里多加了一个选项 -fPIC
这个是生成与位置无关码,至于什么是与位置无关码,笔者在后面解释
生成.o文件后,接下来就是将这些.o文件给打包成一个库了,不过这次我们不用在使用命令ar来帮助我们归档了,我们可以直接使用gcc来完成动态库的打包
gcc -shared -o ''lib动态库名.so'' 要打包的.o文件
如此以来,我们就能把所有的.o文件给打包成一个动态库了,接下来我们和头文件一起打包发给我的工具人朋友test,让它试试能不能和main.c一起链接生成可执行文件
如图,我的朋友已经收到了我发给它的动态库文件,并且已经解压了,接下来开始实验
我们看一下运行结果,发现竟然报错了,这是什么情况,报错结果显示我们的可执行程序找不到对应的动态库,这又是什么情况,我们不是在编译的时候就已经告诉gcc 我们动态库的地址了吗?而且都生成可执行文件了,结果却报错找不到库,这是什么问题导致的?我们先看看动静态库加载的原理,然后再回头看看这个问题
理解动静态库加载的过程
静态库的加载过程不难理解,当我们的程序使用了静态库的代码时,在编译链接阶段,编译器会去指定的路径下找执行的静态库,然后拷贝相关的实现代码,在生成可执行文件并且执行后,会把所有的执行代码都加载到进程地址空间中的代码段
编译器所需要的静态库中的代码已经成了源代码的一部分,但是动态链接不一样
当我们的程序要调用动态库中的代码时,在编译链接阶段,编译器会将我们在动态库中所需要的代码相对于动态库中的相对位置给填上去,然后链接生成可执行文件
当我们运行可执行程序时, 大致过程是可执行文件会加载到内存中,代码文件会被存放到进程地址空间的代码区,然后通过页表映射到物理内存
首先在编译可执行文件的时候,用到了动态库my_lib中的函数,因为动态库中的.o文件用到的都是与地址无关码,也就是说我们用到的动态库中的函数代码没有一个确切的地址,那没有确切的地址,程序怎么去动态库中找到相关的代码呢?动态库采用的是偏移量,就是当前你要使用的函数的代码在动态库中的相对位置,只要能够找到动态库,那么就能找到这个函数在哪个位置。确定这个偏移量地址后,在编译阶段,编译器检测到要使用这个函数,那么编译器会把这个函数在动态库中的偏移量地址给填上去
我们也知道,只有这个偏移量地址肯定是找不到这个函数的实现代码的,因为这个偏移量是 相对于动态库而言的,你还得知道动态库的地址,那么你才能找到这个函数的实现代码
不过动态库可不是直接加载到可执行程序的代码中的,那我们该怎么确定动态库的地址呢?
还记得我们前面运行使用了动态库的可执行程序时报错的事吗?为什么在编译阶段不报错,而在运行阶段报错了呢?这是因为在编译的时候,当编译器检测到使用了动态库中的函数,我们假设这个函数叫A,就会在函数名映射表中把A相对于动态库的偏移量给填了上去,这是符合语法规则的,所以能够通过编译形成可执行程序
当可执行程序加载到内存中,并且运行到A时,操作系统就会识别出这是某个动态库里的函数,假设这个动态库叫lib,然后操作系统就会去系统配置文件,环境变量等一些地址去找lib库的地址,并把它加载到内存中,然后通过页表映射到进程地址空间的共享区
问题就出现在这里,我们确实告诉编译器了这个动态库的具体位置,编译器也确实知道,但是编译器仅仅是找到A在lib中的偏移量,然后填到函数映射表完成编译,剩下的就和编译器没有关系了,至于你运行可执行程序,那是操作系统的活
也就是说操作系统并不知道lib库的具体位置,而且为了效率,操作系统只会去环境变量,系统配置这些地方去找,lib库并不在这些地方,那操作系统自然就找不到,找不到就报错,也就出现了我们之前的程序报错
解决方法也很简单,那就是把我们写的动态库给加载到系统执行时的指定路径下
这里有多种不同的方法
1.把我们写的动态库的准确地址添加到环境变量中
这样做比较简单,但是缺点是重新登陆时,系统会刷新环境变量,把我们添加的环境变量给刷新掉
2.把我们写的动态库拷贝到系统目录下,在Linux中可以拷贝到 lib64目录下
因为我们写的库只是测试,没有其他的价值,测试完最好从系统目录下删掉
3.修改配置文件,去etc/ld.so.conf.d/ 目录下把动态库的准确地址写到任意一个配置文件中,然后使用命令 ldconfig 更新配置文件即可
4.通过软链接的形式,在可执行文件的目录下建立一个软链接,这个软链接链接到我们写的动态库的位置,操作系统会检查可执行文件的当前目录,通过软链接就能找到动态库了