目录
什么是文件
文件是存储在外部介质(如硬盘、U盘)上的数据集合,由文件名标识,通过文件系统管理。我们可以将文件分为内容和属性两个部分,一个没有数据的空文件会占用存储空间吗?答案是会占用,因为文件不止有内容,还有属性,一个文件即使为空也还是要有对应的空间存储它的属性。对于文件,我们还可以根据它的状态将其分为两种,即打开的文件和没有打开的文件,这两者有什么区别呢?根据冯诺依曼体系,我们计算机的核心是cpu和内存,一个没有打开也就是没有用到的文件自然会被存在外设当中,对应到我们的计算机,这些外设通常是机械硬盘,固态硬盘等。对于一个打开的文件也就是正在使用的文件,我们都知道计算机的一切动作都是进程,一个文件被打开,那它一定是被进程打开的,此时的文件一定是被加载到了内存中。在内存中,我们的进程与打开的文件一定是少对多的关系,对于大量的打开的文件,我们操作系统毫无疑问要对其做管理,怎么管理呢?肯定是先描述再组织,也就是说,我们的操作系统中一定有对应的管理打开文件的对象,里面包含了很多打开文件的属性,这些结构体通过一些数据结构组织在一起被操作系统有效地管理起来。这些我们之后都会讲。
C语言的文件操作函数
fopen
FILE *fopen(const char *path, const char *mode);
第一个参数指定文件路径,第二个参数指定路径,第二个参数指定文件打开模式,文件的打开模式有:
r:只读模式,打开文本文件用于读取。文件流定位在文件开头。文件必须存在,否则报错。
r+:读写模式,打开文件用于读取和写入。文件流定位在文件开头。文件必须存在,可修改现有内容(覆盖写入)。
w:只写模式,若文件存在则截断为零长度(清空内容);若不存在则创建新文件。文件流定位在文件开头,适用于覆盖写入新内容。
w+:读写增强模式,打开文件用于读取和写入。文件不存在时创建新文件;存在时清空内容。文件流定位在文件开头。
a:追加模式,打开文件用于末尾追加写入。文件不存在时创建新文件。文件流始终定位在文件末尾(新内容自动追加),不可读取。
a+:读写追加模式,打开文件用于读取和末尾追加写入。文件不存在时创建新文件。读取时定位在文件开头,写入时自动追加到末尾。
该函数返回一个FILE *的指针,我们可以通过这个指针向函数写入读取内容。
fclose
int fclose(FILE *fp);
关闭指定FILE *的文件流,成功返回0,失败返回-1。文件流(File Stream)是计算机编程中用于在程序与文件(或其他I/O设备)之间传输数据的抽象机制。
fwrite
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
fwrite可以向指定文件流写入指定的数据。ptr指针要输入的内容,因为ptr是void指针,所以可以指向任意类型。size指定输入数据每个数据单元的大小,比如我输入的是字符串,每个数据单元(char)就是1字节,这里就是1,我输入的是整形,每个数据单元(int)就是4字节。nmemb指定输入指定数据单元的个数。stream指定文件流。该函数返回成功写入的数据单元的数量,函数成功写入时返回值应该与nmemb相等,实际可能小于该值(例如因缓冲区满或信号中断)。
fread
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
fread可以向指定文件流读取指定的数据。ptr是要写入的地址的指针,size指定输入数据每个数据单元的大小,nmemb指定输入指定数据单元的个数,stream指定文件流。函数成功读取时返回成功读取的数据单元的个数,函数成功写读取时返回值应该与nmemb相等,返回值 < nmemb:可能因文件结束(EOF)或读取错误(如磁盘损坏、权限不足)。返回 0:当 size 或 nmemb 为 0,或读取完全失败。
有了这些文件操作函数,我们就能写一点文件操作的代码了。
我们可以发现,我们以写方式打开的文件在没有文件的情况下会自动创建一个,我们可以以这种方式创建文件。这样的方式似曾相识,
我们在使用echo文件重定向时,也是在重定向的文件不存在的情况下会自动创建,再极端一点,我们还能直接重定向
这样也是能直接创建文件。
我们再看向下面这段代码,
我们提前向文件log.txt中写入了内容,当我们在运行完test程序后,文件的内容就变成了hello linux,而且我特意使文件一开始的内容比要写入的内容长,所以我们就能明白write的写入是定位到文件头写入的,而且再写入前会直接清空文件,因为若不是清空,我们写入的数据因为比较短,再覆盖完一部分后后面长出来的应该还会有的,所以我们就能明白这不是覆盖,而是直接清空再写入。此外,我们应明白此时的fwrite可以不用多加一位将\0写入文件,因为文件中和c语言不一样,不以\0作为文件的结尾,所以没必要。我们将同样的情况放到文件重定向上看看会怎么样,
可以看到结果是一样的,我们也同样可以极端一点
用重定向直接清空文件。所以我们可以推断,虽然我们不知道重定向的底层的具体实现,但是其绝对使用了w的文件打开模式。
如果我们不想就这么清空,我们也可以使用a也就是追加的打开模式,这个模式会定位到文件的结尾开始写入,不会清空文件内容。
我们在使用fwrite时,没有指定文件的路径,而是直接写了文件名,文件也是直接在我们所在的路径下直接创建了,为什么呢?学习过进程的我们都知道,我们在shell命令行下用指令运行的程序会成为shell的子进程,子进程会继承父进程的环境变量,环境变量中有一个PWD会记录进程所在的目录,这个环境变量会被子进程继承,之后当我们使用w模式打开文件时,系统因为没有指定目录就会根据环境变量在当前目录下寻找,没找到的话因为w模式的特性就会在当前目录下创建文件。我们可以写一个程序,改变文件的所在目录,让我们的shell进程和test进程不在一个目录下,看看还会不会继续在shell进程路径下创建文件,
可以看到更改路径确实改变了文件的创建路径。
stdin、stdout、stderr
我们除了可以使用上面所说的函数对文件进行输出,我们也能用它们进行打印
这是怎么做到的呢?这就要讲到stdin、stdout和stderr了,这三个是系统自动为我们创建的标准数据流,用于处理输入、输出和错误信息。stdin是标准输入流,用于读取外部输入的数据,默认来源是键盘。stdout是标准输出流,用于向外部输出正常结果,默认显示到屏幕。stderr是标准错误流,用于向外部输出错误或诊断信息(如警告、异常),默认也显示到屏幕。在Linux中,一切皆文件,我们的键盘也好,屏幕也好,本质都可以看成一个文件,由于键盘屏幕是我们与主机交互的重要手段,所以在主机开机时就会启动,在我们的进程创建时,系统会自动为每一个进程都分配这三个数据流(文件流)。在明白了这些之后,我们可以使用这三个文件流来写一些代码
我们可以不去向stdout输出数据,我们也能向stderr输出,因为两者都是向屏幕输出,所以可以做到,但是我们一般还是会用stdout来输出。
我们也能用stdin获取键盘的写入数据,不过这种获取方式过于死板,必须指定大小,所以我们也可以使用其他的函数接口,
int fscanf(FILE *stream, const char *format, ...);
stream指定输出流,format使用占位符搭配后面的可变参数列表可以指定输入格式,
和fscanf一套的还有fprintf,使用方法类似,这里我就不多说了。
IO相关的系统调用接口
谈完了c语言的函数接口,我们再来谈谈系统自身的系统调用接口。我们都知道,我们c语言的库只要是要访问硬件的,必定要对系统调用接口做封装,因为我们的硬件有对应的硬件驱动,并且通过硬件驱动被系统内核管理,我们操作系统对下管理着各种硬件,对上为了保持安全性,限制用户对其管理的各种数据做访问,用户层想要访问数据,必定要通过系统指定的系统调用接口才行,我们的c语言属于用户层,想要与硬件进行io交互,必定要经过系统,所以必须使用系统调用接口。
说了那么多,我们先来认识一下IO相关的几个系统调用接口。
open
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
第一个参数是文件路径。而对于第二个参数我们需要着重讲一下,第二个参数flags要求我们传一个整型过去,但是这个整型可不一般,它代表了我们的打开行为,类似于c语言中fopen的mode参数,但是这个相比较于那个更加的自由,这个参数使用类似于位图的方式,用整型中的32个字节当作标记位,传递各种的打开行为,各种行为对应的标记位所对应的整型值都被定义成了宏,下面是我们常用的几个宏:
访问模式标志(必选其一):
O_RDONLY:只读模式,文件仅允许读取操作,禁止写入。
O_WRONLY:只写模式,文件仅允许写入操作,禁止读取。
O_RDWR:读写模式:同时支持读取和写入操作。
这三个选项我们必须三者选其一,不能同时选,也不能全部不选。
创建与截断标志(可选):
O_CREAT:文件不存在时自动创建,需配合mode参数设置权限(如0644)。
O_TRUNC:文件存在且以可写模式(O_WRONLY/O_RDWR)打开时,清空文件内容(长度截为0)。
写入行为标志(可选):
O_APPEND:追加写入,每次写操作前移动文件指针到末尾,避免覆盖(多进程/线程安全)。
选项支持叠加传入,具体怎么做呢?使用按位或操作符|,因为其是遇1为1,这样就能将多个标记为叠加到一起。
第三个参数是在我们使用了O_CREAT选项后才需要传入的,因为O_CREAT选项表示第一个参数对应的文件要是不存在的情况下就自动创建一个,创建文件就需要设置权限,这个参数是用来设置权限的,传入方式是四位八进制数字。
该函数的返回值也是值得一说的,这个我们之后再谈。
close
int close(int fd);
close函数顾名思义,是用来关闭文件流的,可是这里要我们传一个整型变量,这是什么意思?其实这里要我们传的就是open函数的返回值,open函数返回的整型变量可以用来找到其打开的文件流,至于为什么,我们还是后面再谈。
write
ssize_t write(int fd, const void *buf, size_t count);
fd传open函数的返回值,和close一样。buf传待写入数据的用户空间内存地址。count传请求写入的数据长度(单位是字节)。返回值类型是ssize_t,其实就是int,函数写入正常时会返回实际写入的字节数(<= count),失败时则会返回-1,设置errno,也就是正常情况和size_t一样,失败时还能返回-1。
read
ssize_t read(int fd, void *buf, size_t count);
fd传open函数的返回值。buf传指向用户缓冲区的指针,存储读取的数据。count传请求读取的最大字节数。返回值和write类似,函数成功返回实际读取的字节数(<= count),失败返回-1,设置errno。
有了上面几个系统调用,我们也能来写一些代码了。
这里我们以只读+无文件自动创建+写入时清空文件模式打开文件,可以看到在该文件没有的情况下我们确实自动创建了一个文件,但是我们看到该文件被黄色标记,我们发现它的权限是一段乱码,这就是我之前说过的,传入O_CREAT需要再传一个创建文件的权限参数,没有的话系统就会随机给它设置权限,这里我们加上
可以看到这时创建的文件权限就正常了,但是和我们设置的0666还是不一样,为什么呢?因为有权限掩码的存在,我可以使用系统调用接口
mode_t umask(mode_t mask);
设置权限掩码。
umask函数更改的是进程级的权限掩码,会被子进程继承,但不会影响父进程。
了解了这些系统调用接口之后,我们就能合理的猜测fopen函数以w模式打开的底层调用是:
int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
fopen函数以a模式打开的底层调用是:
int fd = open("log.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
文件描述符
我们在介绍系统调用接口时说过open函数会返回一个整型值,通过该整型值可以找到对应的文件流,这是怎么做到的呢?
当我们使用系统调用接口打开一个文件时,我们首先会为打开的文件创建一个struct file结构体,这个结构体包含了打开文件的各种信息:磁盘的位置、权限、文件大小、读写位置等基本属性,以及next指针指向下一个struct file将系统打开的所有文件串联起来,count引用计数记录该文件被多少个文件描述符引用,最后还有文件的内核缓冲区信息,这个我们之后会讲。
我们要想实现对文件的输入输出,必然是要找到struct file,怎么找呢?当然是指针。我们系统为每一个进程分配了一个结构体struct files_struct,该结构体的指针存在task_struct也就是PCB中,这样我们的内核可以很轻松的找到它,这个结构体中有一个数组fd_arry,这个数组存的就是这个进程打开的文件流对应的struct file指针,讲到这,open函数返回的神秘整型值就有答案了,对,这个整型值就是这个fd _struct数组的下标,fd是文件描述符的简写,当我们调用open系统调用接口时,我们的内核就会找到对应的文件,这个文件可能被打开也可能没被打开,没有被打开就创建对应的struct file,cout计数为1,打开了就将对应文件的struct file中的cout计数++,然后内核会将这个struct file的指针存进对应进程的fd_arry中,之后将对应的下标fd返回,就完成了open函数的调用。由于每个进程都有对应的struct files_struct,一个进程的fd不会重样,所以fd能成为文件标识符找到对应的文件流。
当我们使用close关闭文件流时,我们会先将对应struct file中的count计数–,如果–完为0,就释放这个文件流,如果没有,表示其还被其他文件描述符引用,就不能关闭。
0、1、2
知道了文件描述符的原理,我们不妨来打印一下文件描述符,
可以看到返回的文件描述符是一段连续的文件描述符,连续这不难理解,但令我们好奇的是为什么fd是从3开始的,如果是数组下标,按理讲应该是从0开始的,所以我们可以推断0,1,2的位置上有对应的指针存在了,但是是谁呢?我们打开的第一个文件就是3,那么前面的几个就是在进程创建时就自动创建的了,我们不难联想,没错,就是stdin、stdout、stderr这三个文件流,这三个文件流在进程创建时就被默认打开,写入到fd_arry数组中,stdin对应0,stdout对应1,stderr对应2,我们可以使用这三个fd写一些代码,
我们可以使用文件描述符1向stdout写入,输出到屏幕上。
也能使用文件描述符0从stdin中读取,
我们还能关闭文件描述符1,使向stdout输出的内容无效。
重定向
输出重定向
我们先看下面一段代码,
我们先将文件标识符1也就是stdout关闭了,之后我们打开了一个文件,然后我们1t写入字符串,按常理来讲这时由于1已经关闭了,我们的屏幕上就不会打印字符串了,实际运行时确实没有打印,但是,我们惊奇的发现,本来要打印到屏幕上的内容被输出到了文件中,这是为什么呢?我们不妨打印一下我们打开的文件的文件标识符
我们发现我们打开的文件的文件表标识符竟然是1,这是为什么呢?其实,我们在打开一个文件时分配文件fd的思路是从fd_arry数组开头开始往后找第一个空位子,将其分配给该文件,所以当我们关闭stdout时,对应的1下标的位子就空了出来,我们之后又打开了一个新文件,那1下标的位子自然是分配给了新打开的文件了,所以当我们向1输出数据时,这些数据被输入到了新打开的文件中。这样的方式是不是似曾相识,没错,这就是我们输出重定向的原理。输出重定向的本质就是更改fd_arry数组对应下标的struct file指针,使其指向其他文件,从而达到让原本输出到stdout上的文件输出到其他文件上。
dup
我们刚才通过关闭stdout文件流再打开新文件的方式实现了文件重定向,但是这实在太麻烦了,操作繁琐,别人还容易看不懂,要是有能直接更改fd_arry数组的函数就好了,你别说,还真有,那就是dup系列函数。dup是一个函数系列,其中最常用的就是dup2函数。
int dup2(int oldfd, int newfd);
dup2函数的作用是将oldfd复制到指定的描述符newfd,若newfd已打开则先关闭。我们写一段代码使用一下这个函数
可以看到确实是拷贝成功了,无论是向原本的fd输出数据还是向1号输出数据,最终都到了文件中。
输入重定向
不止有输出重定向,还有输入重定向。我写一段代码演示一下
用fd替换0也就是stdin,这样实际运行时就不会从stdin中读取,进程也就不会停下来等待我们输入,而是直接从对应文件中读取。这里我用了fgets函数,是一个C语言封装的库函数,所以对应的,我使用了stdin,其实这里也能明白,无论是系统调用还是库函数,重定向都是有用的,库函数底层也是封装的系统调用,输出用1,输入用0,都一样,我们的输出重定向当然也能用printf、fprintf,都是能实现的,因为我们掌握了底层原理,运用了底层原理,所以上层的各种封装在我们眼里都一样。
自己实现一个重定向的逻辑
在之前的进程详解那篇文章中,我实现了一个简易的bash程序,这里我们运用我们学到的重定向的知识,在那段代码的基础上进行迭代,增加重定向的功能,
#include<iostream>
#include<stdio.h>
#include<string.h>
#include <stdlib.h>
#include<unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define PATH_MAX 1024
#define INPUT_MAX 1024
#define NONE -1
#define IN_RDIR 0
#define OUT_RDIR 1
#define APPEND_RDIR 2
using namespace std;
char myenv[INPUT_MAX][INPUT_MAX] = {0};
int myenv_sz = 0;
int lastcode = 0;
char *rdirfilename = NULL;
int rdir = NONE;
void check_redir(char *cmd)
{
while(*cmd)
{
if(*cmd == '>')
{
if(*(cmd+1) != '>')
{
*cmd = '\0';
cmd++;
while(*cmd == ' ')
{
cmd++;
}
rdirfilename = cmd;
rdir = OUT_RDIR;
break;
}
else
{
*cmd = '\0';
cmd++;
*cmd = '\0';
cmd++;
while(*cmd == ' ')
{
++cmd;
}
rdirfilename = cmd;
rdir = APPEND_RDIR;
break;
}
}
else if(*cmd == '<')
{
*cmd = '\0';
cmd++;
while(*cmd == ' ')
{
++cmd;
}
rdirfilename = cmd;
rdir = IN_RDIR;
}
cmd++;
}
}
void Print_Menu()
{
char arr_cwd[PATH_MAX];
getcwd(arr_cwd, PATH_MAX);
char* pm_token = strtok(arr_cwd, "/");
char* prev_pm_token = NULL;
char tmp[] = "/";
while (pm_token)
{
prev_pm_token = pm_token;
pm_token = strtok(NULL, "/");
}
if (!prev_pm_token) prev_pm_token = tmp;
cout << "mybash:[" << getenv("USER") << "@" << getenv("HOSTNAME") << " " << prev_pm_token << "]"<< (strcmp(getenv("USER"), "root") ? "$ " : "# ");
}
int My_Cd(char* str)
{
setenv("PWD", str, 1);
return chdir(str);
}
int My_Export(char* str)
{
// char* tmp = new char[INPUT_MAX];
// strcpy(tmp, str);
// myenv[myenv_sz++] = tmp;
strcpy(myenv[myenv_sz++], str);
return putenv(myenv[myenv_sz - 1]);
}
int My_Echo(char* str)
{
if(strcmp(str, "$?") == 0)
{
printf("%d\n", lastcode);
lastcode = 0;
}
else if(*str == '$')
{
char* val = getenv(str+1);
if(val) printf("%s\n", val);
}
else
{
printf("%s\n", str);
}
return 0;
}
int Partition(char* AR[], char AI[])
{
fgets(AI, INPUT_MAX, stdin);
size_t len = strlen(AI);
if (len > 0 && AI[len - 1] == '\n')
{
AI[len - 1] = '\0';
}
check_redir(AI);
int i = 0;
AR[i++] = strtok(AI, " ");
while (AR[i++] = strtok(NULL, " ")){}
return i - 1;
}
int Branch(char** AR, int sz)
{
if (strcmp(AR[0], "cd") == 0 && sz == 2)
{
return My_Cd(AR[1]);
}
else if(strcmp(AR[0], "export") == 0 && sz == 2)
{
return My_Export(AR[1]);
}
else if(strcmp(AR[0], "echo") == 0 && sz == 2)
{
return My_Echo(AR[1]);
}
else
{
int ret = fork();
if (ret == 0)
{
if(rdir == OUT_RDIR)
{
int fd = open(rdirfilename, O_WRONLY|O_CREAT|O_TRUNC, 0666);
dup2(fd, 1);
}
else if(rdir == APPEND_RDIR)
{
int fd = open(rdirfilename, O_WRONLY|O_CREAT|O_APPEND, 0666);
dup2(fd, 1);
}
else if(rdir == IN_RDIR)
{
int fd = open(rdirfilename, O_RDONLY);
dup2(fd, 0);
}
execvp(AR[0], AR);
exit(0);
}
else if (ret > 0)
{
int st = 0;
wait(&st);
if (WIFEXITED(st))
{
return WEXITSTATUS(st);
}
else
{
return WTERMSIG(st) + 128;
}
}
else
{
return -1;
}
}
return 0;
}
int main()
{
while (1)
{
Print_Menu();
char* ARGV[INPUT_MAX] = { 0 };
char arr_input[INPUT_MAX] = { 0 };
int num = Partition(ARGV, arr_input);
if(num == 0) continue;
lastcode = Branch(ARGV, num);
}
return 0;
}
实验过后也是符合结果的,不过这里只是对于普通命令的重定向,内建命令因为需要单独处理,我就没有实现了。这里有一点要注意的就是我们进程打开的文件没有因为进程替换而失效,这个现象我们之后会讲到。
我们的重定向操作还有更加进阶的使用方式
这里我们原本写了一段代码是向stdout和stderr分别输出一些数据,但是这里我们使用了特殊的重定向
./test 1> Message.txt 2> Error.txt
意思是将1也就是stout重定向到Message.txt中,将2也就是stderr重定向到Error.txt中,这里的1也能省略
./test > Message.txt 2> Error.txt
这种重定向方式方便我们将标准输出与标准错误分流到不同的文件,方便写日志文件。
当然如果我们就想要两个流重定向到一个文件,我们可以
这里的2>&1的意思就是将将标准错误重定向到当前标准输出的位置(即文件)。所以我们要先重定向1,使其指向文件,再重定向2。
宏观上看文件管理
我们的系统调用接口返回的是整型,而c语言库提供的函数返回的却是FILE*,这是为什么呢?其实FILE是我们C语言中维护的一个结构体,而在其中就有我们的文件描述符,这就是为什么我们可以通过FILE *指针找到对应文件流的原因。
我们之前看到我们进程打开的文件不会因为进程替换而是失效,这是因为内核文件管理和进程管理部分解耦的结果,进程管理由task_struct -> mm_struct -> 页表 -> 物理空间组成,文件管理由FILE和task_struct -> struct files_struct -> struct file组成,两者高度解耦,互不干扰。
宏观上看文件系统,我们需理解一切皆文件的概念。我们的进程到底是怎么与外设进行IO的呢?我们都知道我们的硬件有对应的驱动,这些驱动中必然要提供对应的IO接口,我们的操作系统会提供一个方法表结构体来存储对应的函数指针,比如写方法的函数指针,读方法的函数指针,这些公共的方法每个设备都有(没有就是NULL),我们将其抽取出看来,与硬件层的方法解耦。这样的方法表每个文件流都有一个,struct file中有对应的指针指向他们,这就是我们宏观上的文件管理。
缓冲区
缓冲区这个概念我们之前或多或少可能听说过,我们接下来来详细聊聊它,我们先来看几组代码。
简单的四天函数调用,这些接口上面都加工过,结果也符合预期,没什么特别的。然后我们来看向下面这组代码,
我们提前关闭了1号文件流,也成功打印出来了,没什么问题,没什么特别的。我们再来看看下面这段代码,
用户层缓冲区与内核层缓冲区
当我们在刚才代码的基础上把\n去掉了,我们这时发现,打印出现了问题,只打印了write,这是怎么一个回事呢?答案是缓冲区没有及时刷新带来的问题。我们先不去想为什么其他几个函数没有打印出来,我们先来想一想为什么就write能打印出来,我们都知道,write相比较于其他函数的区别是它是系统调用接口,由内核提供。也就是说,内核提供给的函数输出的数据没有因为缓冲区的问题丢失数据,而C语言提供的却丢失了,那我们就能明白,这个缓冲区一定不是内核空间的缓冲区,一定是用户空间的缓冲区。我们的进程在运行时打开文件所对应的缓冲区分为用户层的缓冲区和内核层的缓冲区,内核层的缓冲区由内核维护,有着自己的一套算法,用户层的缓冲区由C语言自己创建,***我们C语言创建的缓冲区就在FILE结构体中。***我们在使用C语言提供的文件操作接口对文件进行写入时,会先将数据写入到C语言自己的缓冲区中,在适当的时机刷新到内核的缓冲区中,当我们没有使用\n结尾时,会导致数据不会及时的刷新,用C语言接口进行写入的数据存在C语言的缓冲区中,在不进行及时写入的情况下也是会在,main函数结束时强制刷新的,但是我们又提前关闭了stdout文件流,所以此时即便刷新也没有用了,文件流已经关闭了,数据就会这样丢失了,但是为什么我们的write函数写入发的就没事呢?因为write是系统接口,它写入的数据是输出到内核缓冲区的,当我们使用close函数时,因为其也是系统调用接口,系统调用接口不会管用户层的东西,因为那不是自己维护的,但是对于内核层自己维护的缓冲区,肯定是要负责任地看一下的,所以使用close函数会强制刷新内核层地缓冲区,write写入的数据就会被刷新到屏幕上了。
刷新策略
我们的缓冲区刷新策略一般分为三种:
(1)无缓冲——直接刷新
(2)行缓冲——不刷新,知道碰到\n
(3)全刷新——缓冲区满了才刷新
我们的缓冲策略讲究的是***越慢刷新,效率越高。***因为与外设进行IO是很慢的,很影响效率,这也就是缓冲区存在的原因之一,我们不与外设进行频繁的IO,而是与缓冲区进行IO,等到合适的时机就将数据刷进来或刷出去,这就大大提升了效率。我们缓冲区一次性屯的数据越多,效率就越高。
我们对显示器文件进行输出时采用的就是行缓冲,因为毕竟是给人看的,要尽量保证数据的及时性。当我们与文件进行输入输出时,采用的就是全缓冲,因为文件不像显示器,人看不到,不需要那么频繁的刷新。
当我们的文件流关闭时,也是会自动刷新缓冲区的,另外进程结束时,也是会自动刷新缓冲区的,这些都是保护我们数据不被丢失的手段。
fork
我们先来看一下这段代码
我们发现,这段代码当我们正常运行时打印是正常的。但是当我们将它重定向到文件中时我们发现它多打了好多内容,这是怎么回事呢?我们先俩分析一下文件中的这段内容,一共是9行,其中只有write没有打印两次,而且原本是最后打印的write最先打印了出来,其他的都是按顺序打印了两轮。这是怎么回事呢?首先我们先屏幕打印时,由于屏幕是行缓冲,所以刷新及时,在调用fork函数时,缓冲区的数据已经全部刷新完成了,fork就是一次没什么作用的调用。但是当我们将其重定向到普通文件时,行缓冲变成了全缓冲,\n此时就不起作用了,前面几行的代码输出的数据就被存在了缓冲区中,当我们使用fork函数创建了子进程,父子共享缓冲区,此时进程结束时刷新缓冲区,缓冲区清空触发父子进程的写实拷贝,就又会拷贝出一份缓冲区,这样就有两份缓冲区,当然这是用户层的缓冲区,归进程所有的,所以C语言接口的输出数据就有了两份,刷新了两次,所以文件中就有两份数据了,而write因为是系统调用接口,不在用户层,所以只有一次,而且由于内核的刷新策略随着算法因主机情况而变动(比如主机在空闲状态,就会定期刷盘,主机压力很大的话就会延迟批量写入),还被提前刷新了出来。
模拟实现几个C语言文件接口
#include<iostream>
#include<stdio.h>
#include<unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include<string.h>
#include<assert.h>
using namespace std;
#define SIZE 1024
#define FLUSH_NOW 1
#define FLUSH_LINE 2
#define FLUSH_ALL 4
#define FILE_MODE 0666
//int _fflush(_FILE *stream);
struct _FILE
{
int _fd = 0;
int _flag = FLUSH_ALL;
char _outbuffer[SIZE] = {0};
int _out_pos = 0;
};
int _fflush(_FILE *stream);
_FILE *_fopen(const char *path, const char *mode)
{
assert(path);
assert(mode);
int fd = -1;
if(!strcmp(mode, "w"))
{
fd = open(path, O_WRONLY|O_CREAT|O_TRUNC, FILE_MODE);
}
else if(!strcmp(mode, "a"))
{
fd = open(path, O_WRONLY|O_CREAT|O_APPEND, FILE_MODE);
}
else if(!strcmp(mode, "r"))
{
fd = open(path, O_RDONLY);
}
_FILE *newfile = new _FILE;
newfile->_fd = fd;
return newfile;
}
int _fclose(_FILE *fp)
{
_fflush(fp);
close(fp->_fd);
delete fp;
return 0;
}
size_t _fwrite(const void *ptr, size_t size, size_t nmemb, _FILE *stream)
{
memcpy(&stream->_outbuffer[stream->_out_pos], ptr, size * nmemb);
stream->_out_pos += size * nmemb;
if(stream->_flag == FLUSH_NOW)
{
write(stream->_fd, stream->_outbuffer, stream->_out_pos);
stream->_out_pos = 0;
}
else if(stream->_flag == FLUSH_LINE)
{
if(stream->_outbuffer[stream->_out_pos - 1] == '\n')
{
write(stream->_fd, stream->_outbuffer, stream->_out_pos);
stream->_out_pos = 0;
}
}
else if(stream->_flag == FLUSH_ALL)
{
if(stream->_out_pos == SIZE)
{
write(stream->_fd, stream->_outbuffer, stream->_out_pos);
stream->_out_pos = 0;
}
}
return nmemb;
}
int _fflush(_FILE *stream)
{
write(stream->_fd, stream->_outbuffer, stream->_out_pos);
stream->_out_pos = 0;
return 0;
}
int main()
{
_FILE *fp = _fopen("log.txt", "a");
_fwrite("hello world\n", sizeof(char), strlen("hello world\n"), fp);
close(fp->_fd);
//_fclose(fp);
return 0;
}
实现步骤相对简单,不做过多赘述。
文件系统
我们在谈完了内存中的文件,我们再来谈谈存在外设中的文件。我们先拿最具代表性的磁盘来说,磁盘用来存储数据,就会有对应的寻找数据的手段,磁盘的寻址方法由柱面、磁头、扇区组成。
我们由磁头找到对应的数据在那个磁面,柱面找到在哪个磁轨,扇区对应磁轨上的扇区,以这种方式找到磁盘上的对应数据,简称CHS(Cylinder-Head-Sector,柱面-磁头-扇区)地址。这种方式在Linux被抽象成了LBA(LogicalBlockAddressing,逻辑块寻址)地址,即我们Linux将实际的磁盘空间视为一个线性结构,就像一个线性数组,在这个线性结构中每一个扇区都有自己的小标,一个扇区的大小是4kb(老式磁盘中一个扇区可能为512b,现在的磁盘一般为4kb),我们使用LBA地址,那要怎么转换成对应的CHS地址呢,毕竟有对应的CHS地址才能实际在磁盘中找到数据,我们其实就是用最简单的除余操作来完成的,因为我们的LBA线性地址从第一个磁面的第一个磁轨的第一个扇区开始直到最后一个磁面的最后一个磁轨的最后一个扇区,所以我们用LBA的下标除一个磁面的扇区数就能知道在那个磁面,再取其余数,将余数除以一个磁轨上的扇区数就能知道在对应磁面的哪一个磁轨了,之后再取余数就能知道在对应磁轨的哪个扇区了。这就是我们LBA向CHS转化的过程。一个 LBA直接对应磁盘的一个物理扇区,两者是1:1的映射关系。
实际我们的磁盘中也有对应的寄存器,像控制器寄存器控制读写,数据寄存器配合数据IO,地址寄存器记录LBA地址,状态寄存器返回IO操作结果状态,这些寄存器共同辅助我们磁盘完成工作。
我们的磁盘虽然被划分成了LBA线性地址,但是众多的扇区管理起来还是很困难,根据分治的思维,整体困难我们就先分开,我们先将磁盘划分成一个个分区,用对应的结构体将起始和终止LBA地址存起来,然后在分区内部,我们再将其分成一个个block group(块组),
分区的结构大致如图。
分区开头的boot block一般是存在LBA0(磁盘第一个物理扇区)中,与开机相关,通常为了优化,我们在其他扇区中也可能会有。这个区域不在我们这篇文章的讨论范围中。
我们的分区中有着很多块组,在块组当中我们的实际数据是在data blocks这个区域,这个区域占据块组的绝大部分空间,该区域中有着大量的block(数据块,连续的,用对应起始地址和根据小块大小得来的偏移量就能实现访问),一个数据块的大小可以被设置(1024、2048、4096字节等),一般是4096字节。我们最好是要保持数据块的大小大于等于扇区的大小也就是4kb,因为我们的扇区是读写数据的最小单元,数据块若小于扇区,会降低IO效率,因为单个数据块太小会跨数据块触发多次IO,更改单个数据块时也会连着整个扇区一起改,可能导致数据污染,所以最好保持数据块的大小大于等于扇区的大小。数据块与LBA存在直接的映射关系,1:1或1:多。我们的文件想要存储,最少是要申请一个数据块的大小的空间的,不够再申,一个数据块只能存一个文件的内容,即使有空位也不行。也就是说我们的文件想要存储时,就是直接使用那种没有存过数据的数据块来用的,如果不够用就再申,这样一个文件就只有最后一个没有被填满的块的空间被浪费了。我们的块组中也有对应的block bitmap这样的位图结构记录对应块结构中是否存储了内容。
文件光有数据也不行,我们还要有对应的属性,那这些属性存在那呢?我们的块组中有一个inode table,是一个数组数据结构,当中存放的条目是一个个inode结构体,这个结构体固定128字节,一个文件一个,inode结构体中存储了对应文件的属性,
inode结构大致像上面这样,注意inode中不包含文件名,Linux系统不认文件名,这在之后会详细讲。
我想先来说说int blocks[]数组,这个数组用来存储文件所被存储的数据块的编号,例如,若数组元素为[5, 12, 19],表示文件内容分别存储在编号为5、12、19的数据块中。操作系统通过inode找到blocks[]数组 → 读取数组中的块编号 → 定位磁盘上的数据块 → 加载文件内容到内存。int blocks[]数组的大小一般为15,那么问题来了,数组大小就15,一个数据块大小就4kb,如果数组中的编号与数据块是一对一映射,那表示我这个文件最大只能有60kb,那也太小了。我们的操作系统通过多级索引解决了这个问题,即我我指向的数据块不用来存数据,而是用来存指针,这些指针再去指向数据块,实现数组的扩展,当然我这里说的是一级间接指针,实际我们还有更多层的索引。我们的blocks[0]~blocks[11]用于直接存储数据块编号,适用于小文件。blocks[12]则为一级间接指针,指向一个间接块,该块存储1024个数据块编号(每个编号4字节)。blocks[13]为二级间接指针,指向一个二级间接块,其下再指向1024个一级间接块。blocks[14]则是三级间接指针,指向一个三级间接块,实现更大文件的扩展。
再来说说inode number,inode number(索引节点号)是 Linux/Unix 文件系统中用于唯一标识文件或目录的数字标识符。我们在Linux中可以使用指令
ls -i
来显示inode编号。
我们的inode编号在一个文件系统中是唯一的,注意是文件系统,每个分区都有独立的文件系统,也就是说这里的唯一仅在单一分区中有效。
在我们的块组中也有对应的inode bitmap这样的位图结构记录对应inode table中是否存储了inode。
通过block bitmap和inode bitmap这两个位图我们也能知道,操作系统在删文件时不会动文件数据本身,而是会通过修改这两个位图标明文件已经删除,因为这样快,效率高,节省资源,这也就是我们下东西快删东西慢的原因,也是我们在误删文件后能够恢复文件的原因。
我们的块和inode,在块组分配好时就已经确定了数量,也就是说一个分区中的inode编号和块数量一开始就被分配好了,不同分区的inode编号可以重复,所以每个分区都可以从0开始分配inode编号,分配好了分区就再对块组进行分配,比如这个分区分0到10000的inode编号,拿10000个块,这些都是一开始确定的,如果我们的inode用完了,块还有剩余就浪费了,同样的,如果块用完了inode还有剩余也就浪费了,所以我们的系统会提前估计好分配,确保尽量不浪费。
除此之外,我们的块组中还有GDT(Group Descriptor Table,块组描述符表),这是一个描述符数组,每个块组对应一个描述符(Group Descriptor),存储该块组的基本信息和数据情况。例如:块位图的起始块号,inode位图的起始块号,inode表的起始块号,当前空闲块数、空闲inode数、目录数等。
最后我们还有super block(超级块),是存储整个文件系统全局元数据的核心数据结构,它记录了文件系统的关键参数和状态信息,确保系统能正确管理磁盘空间和文件操作。比如分区中一共有多少组,每个组的大小,每个组的inode数量,每个组的block数量,每个组的起始inode编号,文件系统的类型与名称。super block不会在每个组中都存在,会存在每个分组的开头,但是也并不意味着唯一,因为我们需要super block载入内存来获取文件系统信息从而管理它,如果super block唯一且数据损坏了,我们就管理不了了,所以我们会随机在其他组存上几个以防万一,块组0的Super Block是主副本,其他块组的副本按概率分布存储(如每千个块组存一份)。我们系统也会使用魔数这样的东西来识别super block。
我们的磁盘在存储文件之前,需要先创建文件系统,初始化数据结构(如目录表、位图),这样的过程称之为格式化。通过格式化我们可以建立文件存储规则(如文件命名、权限管理)、清除数据(实际仅标记为“可覆盖”,非物理删除)、检测并修复坏道。
我们将文件系统在实际增删查改时会做什么呢?当我们要创建文件时,我们先通过super block知道分区使用情况,找到对应合适的块组,再通过gdt知道块组使用情况,扫描inode位图找到合适的inode编号,在inode table中找到对应inode编号的inode写入文件信息,如果要写入数据,我们就需要先根据数据大小(这就是为什么系统调用接口都要确认数据大小)确认要写入多少块,在对应block位图中找到空闲的编号,将编号填入对应inode的int blocks[]数组,将数据写入,最后更新GDT就行。
当我们要删一个数据时,我们先根据文件的inode编号找到对应的inode,再根据inode属性中的int blocks[]数组把对应block位图中的1置为0。之后再将inode位图中对应inode编号的1置为0,更新GDT即可。
我们在查找一个文件时,我们就是根据inide编号去找的,不过这里还有分区查找的问题,这里先不做考虑。
我们在更改一个文件时,就是根据inode编号找到对应内容进行更新,然后更新GDT。
重新认识目录
我们可以通过inode编号找到文件,那么问题来了,我们平时都是用的文件名,操作系统又不看,我们是如何通过文件名找到文件的呢?我们此时就需要重新认识一下目录,目录是不是一个文件呢?是的,
可以看到普通文件有的属性目录都有,文件=属性+内容,那么目录作为文件有内容吗?答案是有的,目录有内容,需要数据块。文件的内容是什么呢?一个目录中存着该目录下文件的文件名和文件inode的映射关系。我们也能由此知道rwx控制目录权限的实质:r权限控制着是否允许读取目录的映射表(即文件名列表)、w权限控制着是否允许修改目录的映射表(增删改映射关系)、x权限控制着是否允许通过文件名查找并访问其inode(即“进入”目录)。我们的目录也有对应的inode编号,虽然系统中的文件非常多,但是因为是树状结构,我们若对每个文件不断地向上找父目录,最终会找到根目录,我们的根目录/的inode是固定的,系统可以直接读取,我们在实际查找文件时就是通过根目录的inode不断的根据路径向下查找最终找到对应文件的inode编号的。当然实际这种每次都要从根目录开始的解析工作肯定很费时间,所以系统有对应的Dentry缓存做优化,提前对高频访问的文件的inode编号做缓存。
软链接
什么是软链接,我们直接给出一个演示,
使用指令
ln -s <源路径> <链接名称>
即可创建软链接。软链接可以指向文件或目录,
我们使用软链接时就像使用了原本的文件一样,软连接到底是什么呢?我们查看一下对应的属性,
可以看到软链接文件有单独的文件类型l,它的inode编号也和指向的文件不一样,这说明它和指向的文件不是一个文件。在Linux系统中,软连接(Symbolic Link,符号链接) 是一种特殊的文件类型,其本质是一个指向另一个文件或目录的路径指针。我们的软链接文件存的就是目标文件或目录的路径字符串,访问软连接时,系统自动重定向到目标路径,这就导致我们可以像使用被指向的文件一样使用软链接,当然可能有一点区别,比如我们直接使用ll时因为是软链接,所以先被识别成了文件打印,当我们在后面加上/表示是目录时才会打印指向目录的内容。
ln -s <源路径> <链接名称>
我们的软链接在创建时需要注意源路径是以被创建的软链接所在的路径为基准的,而软链接本身的创建路径又是以我们自己的所在路径pwd为基准的,我们想要使用相对路径的话一定要注意这点,两个的相对路径基准不一样,如果把握不住我们就用绝对路径,严谨又正确。
当我们的软链接指向的文件或目录不存在时,软链接就不能正常使用了,这种状态被称为断链(也称为“悬空链接”或“死链”),这时的软链接会以红色显示。
这时我们使用指令
unlink 要删除的链接
可以删除链接,这个对软硬连接都有用。
软链接有什么用呢?我们可以参考windows中的桌面快捷方式,它就非常类似于我们的软链接,因为我们的软件可能安装在系统中很深的路径下,要是没有桌面快捷方式或其他界面的快捷启动,而是真的去那么深的路径下启动就会很麻烦,而且实际应用目录中不止有启动文件,还有很多其他文件(日志,配置文件,加载资源等),对于小白用户简直是噩梦,所以就有了快捷方式,快捷方式本质也是指向实际软件目录中的启动文件的。我们这里的软链接也是一样的,我们的软件存在复杂目录下且软件目录中有很多其他文件怕客户乱用,我们就可以建立软链接方便用户使用,此外,我们还可以将软链接建在系统环境变量PATH指定的查找路径下,这样用户就能更方便的使用,像指令一样。
硬链接
什么是硬链接,我们同样给出一段演示,
使用指令
ln <源路径> <链接名称>
可以创建硬链接,也就是ln加-s是创建软链接,ln什么都不加就是创建硬链接。硬链接与软链接不同的一点就是硬链接只能指向文件,不能指向目录,为什么后面讲。使用硬链接时和使用了指向的文件一样,这点和软链接一样,硬链接到底是什么?我们老样子想来看看硬链接的属性,
可以看到我们发现硬链接和指向的文件的属性一模一样,硬链接没有和软链接一样有单独的文件类型,也没有单独的inode编号,但是我们发现文件属性中有一项属性由1变成了2,它是什么呢?其实这项参数就是硬链接数,表示一个文件的硬链接数,硬连接数(Hard Link Count)是Linux文件系统中表示指向同一文件实体的不同路径名数量的核心概念。什么意思呢,我们之前也说过,我们的操作系统不看文件名,文件名存在目录文件中的映射表中,用来解析路径索引用的,我们的文件创建时会为其分配inode编号,这才是系统分辨文件的关键,当我们将名字与inode编号映射起来时,该文件的就有了一个硬链接数,这时我们的inode结构体中也有对应的引用计数记录实体文件被文件名引用的数量,我们在创建新的新的硬链接时实质上没有创建新文件,而是在对应目录文件中创建新的映射关系,文件名自定义,指向的inode编号与我们的硬连接指向的文件的inode编号一样,这时文件的硬链接数+1,对应实体文件的inode的引用计数也+1,当我们删除文件时,实际是删的文件名与inode编号的映射关系,删了之后,硬连接数-1,inode中的引用计数也-1,当引用计数减到0时才会真正的删除文件。
可以看到,即使我们删除了硬链接指向的文件,文件本身实际也没有删除,因为还有硬链接指向它,inode的引用计数还为1,所以即使删除,我们的硬链接照样正常使用,这点与软链接截然不同。
硬链接有什么用呢?老实说,硬链接不如软链接实用,使用场景比较少,但是有一个我们使用Linux经常会用到的东西,它就是硬链接,我们在任意目录使用ls -a
我们就会看见那两个最常用的东西,没错,它们就是 . 和 … ,常用Linux都知道,这两个玩意共同组成了相对路径这个体系,. 表示当前路径,… 表示上级路径,这两个都是对应目录的硬链接,
这时我们就有疑问了,之前说了不能给目录创建硬链接,为什么这里有目录的硬链接呢?我们就要来说一下为什么不能给目录创建硬链接了,当我们的硬链接指向目录时,使用一些指令可能会引发死循环,即目录里有我,我又指向目录。那为什么 . 和 … 就可以呢?因为 . 和 … 属于隐藏文件,系统会自动回避掉这两个硬链接,所以没事。那为什么软链接也行呢?因为软链接属于文件,而且有特定类型,所以系统也能识别回避,所以没事。系统就是怕我们误用硬链接陷入死循环,所以禁止我们使用。
数据刷新到磁盘的过程
我们的内存读取写入数据都是以4kb为单位的,这种物理内存中固定大小的连续存储块我们称之为页框,页框与磁盘中的数据块进行IO数据交换。为什么是四字节呢,这是根据大量科学实验得出的,因为我们可能只读了100字节,但是载入了4kb,根据局部性原理,读取指定数据的周围的数据之后很大可能会在被用到,所以载入了这么多,当然实际也可能浪费,所以需要对页框大小进行试验得出数据最好的一个,4kb就是这样规定的,我们的磁盘中很多东西的大小也常被定成4kb,大概也是这个原因。
我们的内存不断地进行数据存储与删除,所以我们需要用对应的数据结构进行管理,页框是操作系统管理物理内存的基本单位,所以每个页框有对应的数据结构对其进行管理,这就是page结构体,
struct page {
page_flags_t flags; /* Atomic flags, some possibly
* updated asynchronously */
atomic_t _count; /* Usage count, see below. */
atomic_t _mapcount; /* Count of ptes mapped in mms,
* to show when page is mapped
* & limit reverse map searches.
*/
unsigned long private; /* Mapping-private opaque data:
* usually used for buffer_heads
* if PagePrivate set; used for
* swp_entry_t if PageSwapCache
* When page is free, this indicates
* order in the buddy system.
*/
struct address_space *mapping; /* If low bit clear, points to
* inode address_space, or NULL.
* If page mapped as anonymous
* memory, low bit is set, and
* it points to anon_vma object:
* see PAGE_MAPPING_ANON below.
*/
pgoff_t index; /* Our offset within mapping. */
struct list_head lru; /* Pageout list, eg. active_list
* protected by zone->lru_lock !
*/
/*
* On machines where all RAM is mapped into kernel address space,
* we can simply calculate the virtual address. On machines with
* highmem some memory is mapped into kernel virtual memory
* dynamically, so we need a place to store that address.
* Note that this field could be 16 bits on x86 ... ;)
*
* Architectures with slow multiplication can define
* WANT_PAGE_VIRTUAL in asm/page.h
*/
#if defined(WANT_PAGE_VIRTUAL)
void *virtual; /* Kernel virtual address (NULL if
not kmapped, ie. highmem) */
#endif /* WANT_PAGE_VIRTUAL */
};
这里的flags的每一个字节都是一项信息,也就是这个变量被拿来当位图用,flags当中记录了很多对应内存相关的信息,比如对应内存是否被使用。
实际这个结构体的大小很小,因为这个结构体仅仅管理着一个4kb大小的页框,如果很大就得不偿失了,这样的结构体操作系统中有很多,被统一存在struct page mem_arry[]数组中管理。这样,我们对于内存的管理就变成了对数组的管理。我们要访问一个内存,就要先找到对应的page结构体,从而才能找到对应的物理内存,所有申请内存的操作都是在访问page数组。
我们的操作系统在将文件载入内存时,会先将么各分区的super block载入内存以链表的形式串起来,以此查找对应的文件,再将文件的属性分区先载入,特别是inode,该结构体包含了文件的绝大部分属性,另外在struct file中也有一些属性,struct file有对应的指针指向inode结构体,也有指针指向内存缓冲区address_space,这是内核级别的缓冲区,利用基数树快速按文件偏移定位缓存页,避免全量遍历。
动静态库
我们在写代码时常常会用到库,因为程序的每一个步骤我们不可能都要自己去实现,这是我们就得用库了,库可以是语言提供的库,也可以是系统提供的库,也可以是第三方库。我们自己想要写一个库,发布出来,该怎么做呢?
静态库
静态库,了解过的人都知道,其在编译时就将库代码完整复制到可执行文件中,生成独立的可执行文件,生成后无需额外库文件支持,启动速度快,安全性高,但是代码体积大,更新困难。
我们如何生成一个静态库呢?首先在生成库之前我们先写好一个源文件,里面有一些自己实现的接口,
这里为了演示,我就写的很简单了,注意不要有main函数,有main函数那这个库就没有意义了。之后我们应该为其再写一个头文件,回想我们之前使用库的方式,我们在使用库函数时都是要先包头文件的,头文件保证我们的预编译能通过,起到说明书的作用,是必须要有的,
这是我们就可以把这两个文件交给别人了,别人也可以正常使用了,但是如果我们不想让别人知道我们是怎么实现这个库的,比如这个库是闭源的,我们要拿来出售的,不想给别人知道,那我们该怎么办呢?我们应该给这个库进行一下加密,我们这时就想到了目标文件,目标文件内部已经是二进制了,一般来说别人是看不出来了,而且目标文件也能正常和其他源文件一起编译,
可以看到我们的想法是正确的。但是这还不够,我们还可以进一步将其封装成静态库,为什么呢?我们这里的库的实现很简单,接口少,逻辑简单,如果是一个大项目,库就会很大,目标文件进而就会很多,这是使用起来就会很麻烦,我们可以将多个源文件封装成一个静态库,这样使用时就只用声明这一个静态库就行,用户不用关注内部实现细节,而且静态库在实际加载时仅提取被调用的目标文件,可以提高效率,节省一定的存储空间。怎么生成静态库呢,这里我们将原本的源文件拆成两份,模拟多文件的情况,
然后生成对应的目标文件,
之后我们使用指令
ar -rc <静态库名称> <目标文件1.o> [目标文件2.o ...]
就能打包出静态库
这里我们的静态库一定要以lib开头,以 .a 结尾,至于为什么我们后面讲,这时我们使用静态库写一段代码,
为了模拟真实的使用情况,我们先将头文件和库剪切到文件中,因为我们使用库时肯定不会将它与我们的实际项目放到一起,这样很乱,
转移完我们删掉多余的文件大概就是这样的,这是我们有了库,有头文件,有源文件,这下我们就只差编译了,我们编译一下
这里显示我们找不到头文件,可是我们有对应的源文件啊,为什么呢?因为我们的头文件不在源文件路径下,我们的编译器在寻找源文件时,使用<>默认去系统默认路径下找,使用""先在源文件当前路径下寻找,再去系统默认路径下找。因为我们的头文件既不在源文件路径下又不在系统默认路径下,所以报错了,这也是我提前转移头文件的原因,模拟真实情况下我们会遇到的问题,这时应该怎么办呢?两个办法,一个是在包头文件时就明确指定路径,
另一种是在编译时指定头文件目录
使用g++编译器编译时,加上选项 -I 后跟路径可以指定路径,如果有多个头文件可以加空格连续指定,注意这里只用指定到目录,不能指定到文件,因为我们的 #include 已经指定了文件了,这里再去指定文件会导致编译器不能正确添加搜索路径。当我们加上选项指定路径时,路径的查找顺序就变成了先在源文件当前路径下寻找,再去指定路径下找,最后去系统环境变量指定路径下找了。
但是我们发现即使使用这两种方法指定了头文件还是报了错,为什么呢?其实这里已经不是头文件报的错了,阅读错误我们能明白这是因为找不到函数实现发生了链接错误,是链接阶段的错误了,也就是说我们的头文件确实是被正确识别了,但是我们的库没有被正确识别。我们的库不像头文件,即使是在源文件所在的路径下我们的编译器也是找不到的,在编译文件时,我们的编译器会去系统默认的路径下寻找,如果没有就找不到了,也就是库的寻找会跳过源文件目录这一步,所以我们要注意。当然这里我们的库文件也并没有和源文件在一个目录,我们该怎么指定库文件的目录呢?使用选项 -L 后跟路径可以指定路径,这里如果有多个库我们同样可以加空格连续指定,注意这里同样也是只能指定到路径,
可以看到这里即使使用了选项指定了库文件所在的目录也还是和编译不过,报错和没加库文件路径时是一样,这是为什么呢?难道我们的库文件路径没有生效吗?其实不是,我们之前也说过,我们的 -I 选项之所以没有加路径是因为我们在包头文件时已经指定了头文件名了,但是我们是没有指定库文件名的,所以我们的编译器就找不到。那么我们应该 -L指定路径到文件名吗?也不是,这个选项本身就只是让我们指定路径的,我们要想指定库文件名称,我们要再加一个选项 -l(指定库文件名是小写L,指定头文件路径是大写i,这里两个字母很像)。
然后我们就会发现还是不行,为什么呢?格式有问题,我们在指定库名称时,应该去掉lib开头,再去掉 .a 后缀,而我们的编译器在拿到库名之后会自动加上lib开头和 .a 结尾,这也是为什么我们一定要以lib开头命名静态库的原因,名字虽然可以随便起,但是你随便起不符合静态库的命名规范这里就找不到,当然找不到我们还有最后一招,就是把静态库当成目标文件一样和源文件一起编译,当然这种用法就不规范了。我们还是要遵守规范,符合命名规范,使用选项时,我们还习惯把-l和库名直接连在一起,不留空格,这样最合规范。
当然这种指定头文件和库的方式实际上很麻烦,我们不妨想一想为什么我们在使用标准库时就不用呢。答案很简单,因为他们在系统默认路径下,我们编译器会默认寻找,所以我们不妨试着也这样做,
将两个文件都复制到了系统默认路径下,
这是我们就不需要在指定目录了,但是我们还是要指定库文件名,有没有办法不用指定呢,抱歉,没有。我们在使用第三方库时,不论怎样都得指定库名,那为什么使用语言标准库和系统库时就不用呢,因为他们特殊,有特权,我们没办法。
将对应的头文件和库文件都复制到系统默认路径下是最常用的规避指定目录的方法,这种形式就是我们日常所讲的软件安装。当然,除此之外,我们也能在默认路径下创建软链接来是我们不用指定目录,
当然我们还是将对应文件直接复制进去这种方法最为常见。
动态库
动态库,也称为共享库,是一种在程序运行时才被加载的库文件,通常以 .so 为扩展名。与静态库不同,动态库的代码不会在编译时嵌入可执行文件,而是在程序启动或运行时由操作系统的动态链接器加载到内存中,供多个程序共享使用。
我们怎么生成一个动态库呢,
这我们使用和静态库一样的源文件,生成一个上面的静态库的动态库版本,我们还是和静态库时一样,先生成目标文件,但是这里我们必需要加上一个选项 -fPIC,这个选项用于生成位置无关代码,这个之后会讲到。
之后我们就可以用他们打包动态库了,打包动态库的方式也是必然与静态库不同的,使用指令
g++ -shared -o 生成的动态库名称 目标文件
这里的指令就不是ar了,可以认为动态库是亲儿子,g++自带指令生成,我们用g++编译时也是有动态库就使用动态库,没有才使用静态库,亲儿子格外地照顾,-shared 选项表示生成共享库也就是动态库,生成的动态库的名字还是必须以lib开头。
之后我们就能使用动态库来编译了,老样子,我们先将头文件和库文件转进文件中,
我们在编译时和静态库要注意的点和静态库是一样的,这里不过多赘述。
因为编译完就结束了吗?我们不妨运行一下,
我们查看错误发现是说找不到共享库,说没有这个文件,可是我们编译都正常过了,也就表明编译器找到了,怎么这里就找不到了呢?我们的编译器找到了不代表程序运行时就能找到,我们不妨使用一下ldd指令,ldd 是 Linux 系统中用于分析可执行文件或共享库动态依赖关系的核心工具,
我们发现我们的动态库没有被找到,怎么办呢?四种方法:
(1)将库拷贝到系统默认路径下( /lib64/ 或 /usr/lib64 )。
(2)在系统默认路径下( /lib64/ 或 /usr/lib64 )创建软连接。
(3)将库所在的路径添加到环境变量LD_LIBRARY_PATH中,指定到目录就行,因为此时程序已经知道库的名字了
如果我们之前没有设置过,这个环境变量大概是没有的。当然我们只是在shell命令行上设置环境变量的话就不是永久的,退出登录再登入就失效了,想要永久修改环境变量我们就得修改配置文件,我们的环境变量其实就是主机启动时配置文件载入的,
这时我重新登入主机会发现还是能找到动态库,环境变量保留下来了。
(4)/etc/ld.so.conf.d建立自己的动态库路径配置文件,然后使用指令 ldconfig 重新加载一下就行
这里注意权限问题,指令都要sudo,配置文件的名字随便起,和库文件名无关,一个路径一个配置文件。
其实说了那么多,最推荐的还是直接将文件拷贝到默认目录中,这是最好最常用的。
我们对动态库的属性进行打印
会发现动态库有可执行权限,可是动态库本身并不能被执行,为什么呢?这是因为动态库本身需要在进程运行时动态将代码载入进程地址空间,所以需要运行权限。
我们的动态库在制作时需要先生成目标文件,目标文件生成时加的 -fPIC 选项是什么呢?在讲述这个问题之前我们先来谈谈动态库是怎么被加载的,我们的动态库在实际加载时会被加载到进程地址空间中的共享区中
共享区中有什么呢?主要有共享库映射、共享内存区域、内存映射文件,我们所说的父子进程的共享数据的内存映射也在这,我们的动态库中也会定义或使用全局数据,像我之前演示中errno一样,共享区中的数据发生拷贝就会触发写实拷贝。我们系统中使用动态库就要将其载入内存,所以系统中有很多的动态库,系统也有对应的数据结构管理,当我们的进程需要动态库时就会将其载入进程地址空间。我们的程序在编译之后,程序内部因为不知道自己载入内存之后的地址,所以程序内部会用虚拟地址对应每一句指令,当我们调用函数时,就会用代码中的虚拟地址进行跳转,这种虚拟地址我们也叫逻辑地址,
这里我列出了一些汇编,里面就有call指令会触发跳转。总之实际程序中的地址用的是逻辑地址,那在实际运行时怎么找到对应的函数地址呢?以前的代码都是分段的,现在我们都是平坦模式,就是说现在代码中的地址和实际载入进程地址空间中的相对地址都也是一样的了,栈堆这种实际运行时才有的区域都给他空出来了,我们程序都有对应的入口地址,其实就是程序初始化函数的逻辑地址,地址在程序开始运行时给到系统(由EIP(PC)寄存器拿到),系统先创建PCB等各种结构,进程开始时拿到对应的入口地址,将程序载入物理地址,分页用页表将虚拟地址和物理地址映射起来,之后我们就从入口地址开始运行,我们程序 call 或 jmp 逻辑地址时就用页表转换成物理地址实现逻辑地址的转换。可是问题来了,共享区都是公用的,数据一会载入一会删除,就像栈和堆中的数据一样,实际动态库也可能因为用不到所以不会一开始就载入,也就是说,本来应该固定的代码数据现在却开始像变量一样变幻莫测了,我们应该如何定位他呢?系统给出的方法就是不去依赖逻辑地址,而是用偏移量,什么意思呢?我们的程序在运行时要是真的需要动态库了,我们就将动态库载入内存(如果之前没有载入的话),再将其填入页表,之后我们实际代码中跳转动态库中的函数接口时,用的不是物理地址,也不是逻辑地址,而是动态库中接口地址相对于库开头的偏移量,我们拿到偏移量时,将其与之前就记录好的动态库的物理地址(库的地址就相当于开头地址)相加,这样我们就拿到了实际的动态库对应接口的物理地址,从而成功跳转。而我们的 -fPIC 选项就是表示生成位置无关码,其核心作用是生成可在内存任意位置加载执行的代码,无需因加载地址不同而调整绝对地址,从而解决动态库共享时的兼容性问题。