Linux系统进阶-基础IO

Linux系统进阶-基础IO

C语言中的文件接口

打开一个文件进行读写操作,C语言中用的FILE结构体。

对文件进行写入
//写操作
#include <stdio.h>
#include <string.h>
int main()
{
	FILE *fp = fopen("log.txt", "w");
	//这里可以r w a 对应读、写、追加操作
	if(!fp){
		printf("fopen error!\n");
	}
	const char *msg = "hello bit!\n";
	int count = 5;
	while(count--){
		fwrite(msg, strlen(msg), 1, fp);//二进制写入字符串
	}
	fclose(fp);
	return 0;
}
对文件进行读取
//读操作
#include <stdio.h>
#include <string.h>
int main()
{
	FILE *fp = fopen("log.txt", "r");
	if(!fp){
		printf("fopen error!\n");
	}
	char buf[1024];
	const char *msg = "hello bit!\n";
	while(1){
	//注意返回值和参数,此处有坑,仔细查看man手册关于该函数的说明
		ssize_t s = fread(buf, 1, strlen(msg), fp);
		if(s > 0){
			buf[s] = 0;
			printf("%s", buf);
		}
		if(feof(fp)){
			break;
		}
	}
	fclose(fp);
	return 0;
}

这里有一个小的细节,fwrite(msg, strlen(msg), 1, fp)中的msg不需要加\0,因为这是C语言的规定,操作系统并不认识。

什么是当前路径

我们知道,当以fopen写入的方式打开一个文件是,若该文件不存在,则会在当前路径下创建该文件,那么这里所说的当前路径是什么呢?

举个例子,我们在mycode2目录下创建一个可执行程序myproc,那么该可执行程序创建的log.txt文件会出现在mycode2目录下。image-20220412195344482

但是当我们退回上一级目录后,再次执行./mycode2/myproc后,打开进程信息。

我们可以看到同样的软连接文件cwd指向的文件不一样了。

image-20220412200503952

cwd就是进程运行时我们所处的路径,而exe就是该可执行程序的所处路径。

总结: 实际上,我们这里所说的当前路径不是指可执行程序所处的路径,而是指该可执行程序运行成为进程时我们所处的路径

默认打开的三个流

Linux下一切皆文件,也就是说Linux下的任何东西都可以看作是文件,那么显示器和键盘当然也可以看作是文件。我们能看到显示器上的数据,是因为我们向“显示器文件”写入了数据,电脑能获取到我们敲击键盘时对应的字符,是因为电脑从“键盘文件”读取了数据。

为什么我们向“显示器文件”写入数据以及从“键盘文件”读取数据前,不需要进行打开“显示器文件”和“键盘文件”的相应操作?

​ 需要注意的是,打开文件一定是进程运行的时候打开的,而任何进程在运行的时候都会默认打开三个输入输出流,即标准输入流、标准输出流以及标准错误流,对应到C语言当中就是stdin、stdout以及stderr。
​ 其中,标准输入流对应的设备就是键盘,标准输出流和标准错误流对应的设备都是显示器。

​ 查看man手册我们就可以发现,stdin、stdout以及stderr这三个家伙实际上都是FILE*类型的。

extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;

​ 当我们的C程序被运行起来时,操作系统就会默认使用C语言的相关接口将这三个输入输出流打开,之后我们才能调用类似于scanf和printf之类的函数向键盘和显示器进行相应的输入输出操作。

​ 也就是说,stdinstdout以及stderr与我们打开某一文件时获取到的文件指针是同一个概念,试想我们使用fputs函数时,将其第二个参数设置为stdout,此时fputs函数会不会之间将数据显示到显示器上呢?

#include <stdio.h>
int main()
{
	fputs("hello stdin\n", stdout);
	fputs("hello stdout\n", stdout);
	fputs("hello stderr\n", stdout);
	return 0;
}

答案是肯定的,此时我们相当于使用fputs函数向“显示器文件”写入数据,也就是显示到显示器上。

image-20220412201519617

注意: 不止是C语言当中有标准输入流、标准输出流和标准错误流,C++当中也有对应的cin、cout和cerr,其他所有语言当中都有类似的概念。实际上这种特性并不是某种语言所特有的,而是由操作系统所支持的。

stdout & stderr

我们来看看下面这段代码,代码中分别向标准输出流和标准错误流输出了两行字符串。

#include <stdio.h>
int main()
{
	printf("hello printf\n"); //stdout
	perror("perror"); //stderr

	fprintf(stdout, "stdout:hello fprintf\n"); //stdout
	fprintf(stderr, "stderr:hello fprintf\n"); //stderr
	return 0;
}

​ 直接运行程序,结果很显然就是在显示器上输出四行字符串。

image-20220414214533559

​ 这样看起来标准输出流和标准错误流并没有区别,都是向显示器输出数据。但我们若是将程序运行结果重定向输出到文件log.txt当中,我们会发现log.txt文件当中只有向标准输出流输出的两行字符串,而向标准错误流输出的两行数据并没有重定向到文件当中,而是仍然输出到了显示器上。

image-20220414214514941

​ 实际上我们使用重定向时,重定向的是文件描述符是1的标准输出流,而并不会对文件描述符是2的标准错误流进行重定向。


系统文件IO

​ 操作文件除了C语言接口、C++接口或是其他语言的接口外,操作系统也有一套系统接口来进行文件的访问。
​ 相比于C库函数或其他语言的库函数而言,系统调用接口更贴近底层,实际上这些语言的库函数都是对系统接口进行了封装。

image-20220412204723578

​ 我们在Linux平台下运行C代码时,C库函数就是对Linux系统调用接口进行的封装,在Windows平台下运行C代码时,C库函数就是对Windows系统调用接口进行的封装,正是由于C语言对不通过操作系统的系统调用接口进行了封装,这种行为使得C语言具有了跨平台性,所以我们使用C语言才能放之四海任我行。

文件描述符fd

​ 文件是由进程运行时打开的,一个进程可以打开多个文件,而系统当中又存在大量进程,也就是说,在系统中任何时刻都可能存在大量已经打开的文件。因此,操作系统务必要对这些已经打开的文件进行管理,操作系统会为每个已经打开的文件创建各自的struct file结构体,然后将这些结构体以双链表的形式连接起来,之后操作系统对文件的管理也就变成了对这张双链表的增删查改等操作。而为了区分已经打开的文件哪些属于特定的某一个进程,我们就还需要建立进程和文件之间的对应关系。

进程和文件之间的对应关系是如何建立的?

​ 当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的task_struct、mm_struct、页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的映射关系。

​ task_struct当中有一个指针file,该指针指向一个名为files_struct的结构体,在该结构体当中就有一个名为fd_array的指针数组,该数组的下标就是我们所谓的文件描述符。
​ 当进程打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file插入文件双链表,并将该结构体的首地址填入到fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后返回该文件的文件描述符给调用进程即可。

image-20220412230602870

​ 因此,我们只要有某一文件的文件描述符,就可以找到与该文件相关的文件信息,进而对文件进行一系列输入输出操作。

注意: 向文件写入数据时,是先将数据写入到对应文件的缓冲区当中,然后定期将缓冲区数据刷新到磁盘当中。

为什么进程创建的时候会默认打开0、1、2?

​ 0就是标准输入流,对应键盘;1就是标准输出流,对应显示器;2就是标准错误流,也是对应显示器。
​ 而键盘和显示器都属于硬件,属于硬件就意味着操作系统能够识别到,当某一进程创建时,操作系统就会根据键盘、显示器、显示器形成各自的struct file,将这3个struct file连入文件双链表当中,并将这3个struct file的地址分别填入fd_array数组下标为0、1、2的位置,至此就默认打开了标准输入流、标准输出流和标准错误流。

什么是磁盘文件?什么是内存文件?

​ 当文件存储在磁盘当中时,我们将其称之为磁盘文件。当磁盘文件被加载到内存当中后,我们将加载到内存当中的文件称之为内存文件。

​ 磁盘文件和内存文件之间的关系就像程序和进程的关系一样,当程序运行起来后便成了进程,而当磁盘文件加载到内存后便成了内存文件。
磁盘文件由两部分构成,分别是文件内容和文件属性(元信息)。文件内容就是文件当中存储的数据,文件属性就是文件的一些基本信息,例如文件名、文件大小以及文件创建时间等信息都是文件属性。
​ 文件加载到内存时,一般先加载文件的属性信息,当需要对文件内容进行读取、输入或输出等操作时,再延后式加载文件数据。

文件描述符的分配规则

尝试连续打开五个文件,看看这五个打开后获取到的文件描述符。

#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
	umask(0);
	int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);
	int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);
	int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);
	int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);
	int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 0666);
	printf("fd1:%d\n", fd1);
	printf("fd2:%d\n", fd2);
	printf("fd3:%d\n", fd3);
	printf("fd4:%d\n", fd4);
	printf("fd5:%d\n", fd5);
	return 0;
}

​ 可以看到这五个文件获取到的文件描述符都是从3开始连续递增的。因为文件描述符本质就是数组的下标,而当进程创建时就默认打开了标准输入流、标准输出流和标准错误流,也就是数组下标0、1、2已经被占用了,所以只能从3开始进行分配。

image-20220413062154727

若我们在打开这五个文件前,先关闭文件描述符为0的文件,此后文件描述符的分配又会是怎样的呢?

close(0)

image-20220413062433412

可以看到,第一个打开的文件获取到的文件描述符变成了0,而之后打开文件获取到的文件描述符还是从3开始依次递增的。

再试试在打开这五个文件前,将文件描述符为0和2的文件都关闭(不要将文件描述符为1的文件关闭,因为这意味着关闭了显示器文件,此时运行程序将不会有任何输出)。

image-20220413063131829

重定向

输出重定向

​ 输出重定向就是,将我们本应该输出到一个文件的数据重定向输出到另一个文件中。

​ 例如,如果我们想让本应该输出到“显示器文件”的数据输出到log.txt文件当中,那么我们可以在打开log.txt文件之前将文件描述符为1的文件关闭,也就是将“显示器文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是1。

image-20220413064556654

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
	close(1);
	int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
	printf("hello world\n");
	printf("hello world\n");
	printf("hello world\n");
	printf("hello world\n");
	printf("hello world\n");
	fflush(stdout);
	
	close(fd);
	return 0;
}

​ 运行结果后,我们发现显示器上并没有输出数据,对应数据输出到了log.txt文件当中。

image-20220413064913595

​ 这里需要注意的是,对于C语言提高的printf()函数,它默认是将内容输出到stdout中的,stdout指向一个struct FILE类型的对象,该对象中包含一个变量fd(存储文件描述符),操作系统根据文件描述符去找到对应下表为1的结构体指针struct file*,此时这个指针指向的是显示器文件,但是如果关闭显示器文件(操作系统将下标为1的结构体指针指向新的文件),根据文件描述符的分配规则,操作系统为log.txt创建新的struct FILE结构体变量,下标为1的struct file*指向log.txt的struct FILE,此时printf()函数只会将内容输出到文件描述符为1的文件中,至于这个文件是谁,它并不关心,所以在显示器上我们不会看见任何内容,它将内容直接输出到log.txt中。

​ C语言的printf()函数会将内容先输出到C语言的缓冲区中,所以使用fflush(stdout)才能将内容打印到log.txt中。

image-20220414190613796
追加重定向

​ 追加重定向和输出重定向的唯一区别就是,输出重定向是覆盖式输出数据,而追加重定向是追加式输出数据。image-20220414211750119

例如,如果我们想让本应该输出到“显示器文件”的数据输出到log.txt文件当中,那么我们可以在打开log.txt文件之前将文件描述符为1的文件关闭,也就是将“显示器文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是1。

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
	close(1);
	int fd = open("log.txt", O_WRONLY | O_APPEND, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
	printf("hello Linux\n");
	printf("hello Linux\n");
	printf("hello Linux\n");
	printf("hello Linux\n");
	printf("hello Linux\n");
	fflush(stdout);
	
	close(fd);
	return 0;
}

运行结果后,我们发现显示器上并没有输出数据,对应数据输出到了log.txt文件当中。

image-20220414213152200
输入重定向

输入重定向就是,将我们本应该从一个文件读取数据,现在重定向为从另一个文件读取数据。image-20220414213612979

例如,如果我们想让本应该从“键盘文件”读取数据的scanf函数,改为从log.txt文件当中读取数据,那么我们可以在打开log.txt文件之前将文件描述符为0的文件关闭,也就是将“键盘文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是0。

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
	close(0);
	int fd = open("log.txt", O_RDONLY | O_CREAT, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
	char str[40];
	while (scanf("%s", str) != EOF){
		printf("%s\n", str);
	}
	close(fd);
	return 0;
}

运行结果后,我们发现scanf函数将log.txt文件当中的数据都读取出来了。

image-20220414214010618
dup2

要完成重定向我们只需进行fd_array数组当中元素的拷贝即可。例如,我们若是将fd_array[3]当中的内容拷贝到fd_array[1]当中,因为C语言当中的stdout就是向文件描述符为1文件输出数据,那么此时我们就将输出重定向到了文件log.txt。

image-20220415091352123

在Linux操作系统中提供了系统接口dup2,我们可以使用该函数完成重定向。dup2的函数原型如下:

int dup2(int oldfd, int newfd);

函数功能: dup2会将fd_array[oldfd]的内容拷贝到fd_array[newfd]当中,如果有必要的话我们需要先使用关闭文件描述符为newfd的文件。

函数返回值: dup2如果调用成功,返回newfd,否则返回-1。

使用dup2时,我们需要注意以下两点:

  1. 如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。

  2. 如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。

例如,我们将打开文件log.txt时获取到的文件描述符和1传入dup2函数,那么dup2将会把fd_arrya[fd]的内容拷贝到fd_array[1]中,在代码中我们向stdout输出数据,而stdout是向文件描述符为1的文件输出数据,因此,本应该输出到显示器的数据就会重定向输出到log.txt文件当中。

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
	int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
	close(1);
	dup2(fd, 1);
	printf("hello printf\n");
	fprintf(stdout, "hello fprintf\n");
	return 0;
}

代码运行后,我们即可发现数据被输出到了log.txt文件当中。

image-20220415092130534

FILE

FILE当中的文件描述符

​ 因为库函数是对系统调用接口的封装,本质上访问文件都是通过文件描述符fd进行访问的,所以C库当中的FILE结构体内部必定封装了文件描述符fd。

​ 首先,我们在/usr/include/stdio.h头文件中可以看到下面这句代码,也就是说FILE实际上就是struct _IO_FILE结构体的一个别名。

typedef struct _IO_FILE FILE;

​ 而我们在/usr/include/libio.h头文件中可以找到struct _IO_FILE结构体的定义,在该结构体的众多成员当中,我们可以看到一个名为_fileno的成员,这个成员实际上就是封装的文件描述符。

struct _IO_FILE {
	int _flags;       /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

	//缓冲区相关
	/* The following pointers correspond to the C++ streambuf protocol. */
	/* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
	char* _IO_read_ptr;   /* Current read pointer */
	char* _IO_read_end;   /* End of get area. */
	char* _IO_read_base;  /* Start of putback+get area. */
	char* _IO_write_base; /* Start of put area. */
	char* _IO_write_ptr;  /* Current put pointer. */
	char* _IO_write_end;  /* End of put area. */
	char* _IO_buf_base;   /* Start of reserve area. */
	char* _IO_buf_end;    /* End of reserve area. */
	/* The following fields are used to support backing up and undo. */
	char *_IO_save_base; /* Pointer to start of non-current get area. */
	char *_IO_backup_base;  /* Pointer to first valid character of backup area */
	char *_IO_save_end; /* Pointer to end of non-current get area. */

	struct _IO_marker *_markers;

	struct _IO_FILE *_chain;

	int _fileno; //封装的文件描述符
#if 0
	int _blksize;
#else
	int _flags2;
#endif
	_IO_off_t _old_offset; /* This used to be _offset but it's too small.  */

#define __HAVE_COLUMN /* temporary */
	/* 1+column number of pbase(); 0 is unknown. */
	unsigned short _cur_column;
	signed char _vtable_offset;
	char _shortbuf[1];

	/*  char* _save_gptr;  char* _save_egptr; */

	_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

那么C语言中的fopen函数究竟在做什么?

image-20220415144025096

fopen函数在上层为用户申请FILE结构体变量,并返回该结构体的地址(FILE*),在底层通过系统接口open打开对应的文件,得到文件描述符fd,并把fd填充到file结构体当中的_fileno变量中,至此便完成了文件的打开操作。

FILE的缓冲区

我们来看看下面这段代码,代码当中分别用了两个C库函数和一个系统接口向显示器输出内容,在代码最后还调用了fork函数。

#include <stdio.h>
#include <unistd.h>
int main()
{
	//c
	printf("hello printf\n");
	fputs("hello fputs\n", stdout);
	//system
	write(1, "hello write\n", 12);
	fork();
	return 0;
}

但是,当我们将程序的结果重定向到log.txt文件当中后,我们发现文件当中的内容与我们直接打印输出到显示器的内容是不一样的。

image-20220415104311079

那为什么C库函数打印的内容重定向到文件后就变成了两份,而系统接口打印的内容还是原来的一份呢?

首先我们应该知道的是,缓冲的方式有以下三种:

  1. 无缓冲
  2. 行缓冲(遇见\0后刷新,常见与对显示器文件进行数据刷新)
  3. 全缓冲(当文件缓冲区写满后在进行刷新,常见于对磁盘文件的刷新)

​ 当我们直接执行可执行程序,将数据打印到显示器时所采用的就是行缓冲,因为代码当中每句话后面都有\n,所以当我们执行完对应代码后就立即将数据刷新到了显示器上。
​ 而当我们将运行结果重定向到log.txt文件时,数据的刷新策略就变为了全缓冲,此时我们使用printffputs函数打印的数据都打印到了C语言自带的缓冲区当中,之后当我们使用fork函数创建子进程时,由于进程间具有独立性,而之后当父进程或是子进程对要刷新缓冲区内容时,本质就是对父子进程共享的数据进行了修改,此时就需要对数据进行写时拷贝,至此缓冲区当中的数据就变成了两份,一份父进程的,一份子进程的,所以重定向到log.txt文件当中printfputs函数打印的数据就有两份。但由于write函数是系统接口,我们可以将write函数看作是没有缓冲区的,因此write函数打印的数据就只打印了一份。

这个缓冲区是谁提供的

​ 实际上这个缓冲区是C语言自带的,如果说这个缓冲区是操作系统提供的,那么printf、fputs和write函数打印的数据重定向到文件后都应该打印两次。


这个缓冲区在哪里

​ 我们常说printf是将数据打印到stdout里面,而stdout就是一个FILE*的指针,在FILE结构体当中还有一大部分成员是用于记录缓冲区相关的信息的。

//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr;   /* Current read pointer */
char* _IO_read_end;   /* End of get area. */
char* _IO_read_base;  /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr;  /* Current put pointer. */
char* _IO_write_end;  /* End of put area. */
char* _IO_buf_base;   /* Start of reserve area. */
char* _IO_buf_end;    /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base;  /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */

也就是说,这里的缓冲区是由C语言提供,在FILE结构体当中进行维护的,FILE结构体当中不仅保存了对应文件的文件描述符还保存了用户缓冲区的相关信息。

操作系统有缓冲区吗

​ 操作系统实际上也是有缓冲区的,当我们刷新用户缓冲区的数据时,并不是直接将用户缓冲区的数据刷新到磁盘或是显示器上,而是先将数据刷新到操作系统缓冲区,然后再由操作系统将数据刷新到磁盘或是显示器上。(操作系统有自己的刷新机制,我们不必关系操作系统缓冲区的刷新规则)

image-20220415105821259

对于操作系统,它不信任任何人,多以数据不会直接由用户缓冲区到磁盘,而是经由它手才能刷新到磁盘上(操作系统是软硬件交互的桥梁)。image-20220415110047209

理解文件系统

我们知道文件可以分为磁盘文件和内存文件,内存文件前面我们已经谈过了,下面我们来谈谈磁盘文件。

磁盘

什么是磁盘

磁盘是一种永久性存储介质,在计算机中,磁盘几乎是唯一的机械设备。与磁盘相对应的就是内存,内存是掉电易失存储介质,目前所有的普通文件都是在磁盘中存储的。

磁盘在冯诺依曼体系结构当中既可以充当输入设备,又可以充当输出设备。

image-20220415154829418

什么是盘片:

硬盘一般有一个或多个盘片,每个盘片可以有两面,即第一个盘片的正面为0面,反面为1面然后依次类推。

什么是磁道:

每个盘片的盘面在出厂的时候被划分出了多个同心圆环,数据就存储在这样的同心圆环上面,我们将这样的圆环称为磁道(Track),每个盘面可以划分多个磁道。但肉业不可见。

什么是扇区:

在硬盘出厂时会对磁盘进行一次低格,其实就是再每个磁道划分为若干个弧段,每个弧段就是一个扇区 (Sector)。扇区是硬盘上存储的物理单位,现在每个扇区可存储512字节数据已经成了业界的约定。

什么是柱面:

柱面实际上就是我们抽象出来的一个逻辑概念,简单来说就是处于同一个垂直区域的磁道称为 柱面 ,即各盘面上面相同位置磁道的集合。这样数据如果存储到相同半径磁道上的同一扇区,这样可以实现并行读取,主要是减少磁头寻道时间。

什么是磁头:

读取磁盘磁道上面金属块,主要负责读或写入数据。

image-20220415155642955
磁盘的寻找方案

对磁盘进行读写操作时,一般有以下几个步骤:

  1. 确定读写信息在磁盘的哪个盘面。
  2. 确定读写信息在磁盘的哪个柱面。
  3. 确定读写信息在磁盘的哪个扇区。

通过以上三个步骤,最终确定信息在磁盘的读写位置。

磁盘分区与格式化

什么是线性存储

​ 理解文件系统,首先我们必须将磁盘想象成一个线性的存储介质,想想磁带,当磁带被卷起来时,其就像磁盘一样是圆形的,但当我们把磁带拉直后,其就是线性的。

image-20220415160619672

磁盘分区

磁盘通常被称为块设备,一般以扇区为单位,一个扇区的大小通常为512字节。我们若以大小为512G的磁盘为例,该磁盘就可被分为十亿多个扇区。

image-20220415160806719

计算机为了更好的管理磁盘,于是对磁盘进行了分区。磁盘分区就是使用分区编辑器在磁盘上划分几个逻辑部分,盘片一旦划分成数个分区,不同的目录与文件就可以存储进不同的分区,分区越多,就可以将文件的性质区分得越细,按照更为细分的性质,存储在不同的地方以管理文件,例如在Windows下磁盘一般被分为C盘和D盘两个区域。

在Linux操作系统中,我们也可以通过以下命令查看我们磁盘的分区信息:

ls /dev/vda* -l

磁盘格式化

当磁盘完成分区后,我们还需要对磁盘进行格式化。磁盘格式化就是对磁盘中的分区进行初始化的一种操作,这种操作通常会导致现有的磁盘或分区中所有的文件被清除。
简单来说,磁盘格式化就是对分区后的各个区域写入对应的管理信息。

image-20220415161438772

其中,写入的管理信息是什么是由文件系统决定的,不同的文件系统格式化时写入的管理信息是不同的,常见的文件系统有EXT2、EXT3、XFS、NTFS等。

EXT2文件系统的存储方案

计算机为了更好的管理磁盘,会对磁盘进行分区。而对于每一个分区来说,分区的头部会包括一个启动块(Boot Block),对于该分区的其余区域,EXT2文件系统会根据分区的大小将其划分为一个个的块组(Block Group)。

image-20220415161638818

注意: 启动块的大小是确定的,而块组的大小是由格式化的时候确定的,并且不可以更改。

其次,每个组块都有着相同的组成结构,每个组块都由超级块(Super Block)、块组描述符表(Group Descriptor Table)、块位图(Block Bitmap)、inode位图(inode Bitmap)、inode表(inode Table)以及数据表(Data Block)组成。

  1. Super Block: 存放文件系统本身的结构信息。记录的信息主要有:Data Block和inode的总量、未使用的Data Block和inode的数量、一个Data Block和inode的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了。
  2. Group Descriptor Table: 块组描述符表,描述该分区当中块组的属性信息。
  3. Block Bitmap: 块位图当中记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用。
  4. inode Bitmap: inode位图当中记录着每个inode是否空闲可用。
  5. inode Table: 存放文件属性,即每个文件的inode(不保存文件名,文件名在目录的Block Group中)。
  6. Data Blocks: 存放文件内容。

注意:

  1. 其他块组当中可能会存在冗余的Super Block,当某一Super Block被破坏后可以通过其他Super Block进行恢复。
  2. 磁盘分区并格式化后,每个分区的inode个数就确定了。
什么是inode

经过前面的学习,我们得出一个结论,一个磁盘文件包含文件内容与文件属性(元信息)。文件内容就是文件中存储的数据,文件属性就是文件的基本信息。

在Linux命令行中输入ls -l亦或是ll,即可查看当前目录下的文件信息。

image-20220415153129081

在Linux操作系统中,文件的元信息和内容是分离存储的,其中保存元信息的结构称之为inode,因为系统当中可能存在大量的文件,所以我们需要给每个文件的属性集起一个唯一的编号,即inode号。也就是说,inode是一个文件的属性集合,Linux中几乎每个文件都有一个inode,为了区分系统当中大量的inode,我们为每个inode设置了inode编号。

image-20220415171042788
如何理解创建空文件
  1. 通过遍历inode位图的方式,找到一个空闲的inode。
  2. 在inode表当中找到对应的inode,并将文件的属性信息填充进inode结构中。
  3. 将该文件的文件名和inode指针添加到目录文件的数据块中。
如何理解对文件写入信息
  1. 通过文件的inode编号找到对应的inode结构。
  2. 通过inode结构找到存储该文件内容的数据块,并将数据写入数据块。
  3. 若不存在数据块或申请的数据块已被写满,则通过遍历块位图的方式找到一个空闲的块号,并在数据区当中找到对应的空闲块,再将数据写入数据块,最后还需要建立数据块和inode结构的对应关系。

说明一下:
一个文件使用的数据块和inode结构的对应关系,是通过一个数组进行维护的,该数组一般可以存储15个元素,其中前12个元素分别对应该文件使用的12个数据块,剩余的三个元素分别是一级索引、二级索引和三级索引,当该文件使用数据块的个数超过12个时,可以用这三个索引进行数据块扩充。

如何理解删除一个文件
  1. 将该文件对应的inode在inode位图当中置为无效。
  2. 将该文件申请过的数据块在块位图当中置为无效。

因为此操作并不会真正将文件对应的信息删除,而只是将其inode号和数据块号置为了无效,所以当我们删除文件后短时间内是可以恢复的。
为什么说是短时间内呢,因为该文件对应的inode号和数据块号已经被置为了无效,因此后续创建其他文件或是对其他文件进行写入操作申请inode号和数据块号时,可能会将该置为无效了的inode号和数据块号分配出去,此时删除文件的数据就会被覆盖,也就无法恢复文件了。

为什么拷贝文件的时候很慢,而删除文件的时候很快

因为拷贝文件需要先创建文件,然后再对该文件进行写入操作,该过程需要先申请inode号并填入文件的属性信息,之后还需要再申请数据块号,最后才能进行文件内容的数据拷贝,而删除文件只需将对应文件的inode号和数据块号置为无效即可,无需真正的删除文件,因此拷贝文件是很慢的,而删除文件是很快的。

这就像建楼一样,我们需要很长时间才能建好一栋楼,而我们若是想拆除一栋楼,只需在这栋楼上写上一个“拆”字即可。

如何理解目录
  1. 都说在Linux下一切皆文件,目录当然也可以被看作为文件。
  2. 目录有自己的属性信息,目录的inode结构当中存储的就是目录的属性信息,比如目录的大小、目录的拥有者等。
  3. 目录也有自己的内容,目录的数据块当中存储的就是该目录下的文件名以及对应文件的inode指针。

注意: 每个文件的文件名并没有存储在自己的inode结构当中,而是存储在该文件所处目录文件的文件内容当中。因为计算机并不关注文件的文件名,计算机只关注文件的inode号,而文件名和文件的inode指针存储在其目录文件的文件内容当中后,目录通过文件名和文件的inode指针即可将文件名和文件内容及其属性连接起来。

软链接

我们可以通过以下命令创建一个文件的软连接。

ln -s test mytest-s
image-20220415171458894

通过ll -i命令我们可以看到,软链接文件的inode号与源文件的inode号是不同的,并且软链接文件的大小比源文件的大小要小得多。

image-20220415171605988

软链接又叫做符号链接,软链接文件相对于源文件来说是一个独立的文件,该文件有自己的inode号,但是该文件只包含了源文件的路径名,所以软链接文件的大小要比源文件小得多。软链接就类似于Windows操作系统当中的快捷方式。

image-20220415171715539

但是软链接文件只是其源文件的一个标记,当删除了源文件后,链接文件不能独立存在,虽然仍保留文件名,但却不能执行或是查看软链接的内容了。

image-20220415171827394
硬链接

我们可以通过以下命令创建一个文件的硬连接。

ln test mytest-h

通过ll -i命令我们可以看到,硬链接文件的inode号与源文件的inode号是相同的,并且硬链接文件的大小与源文件的大小也是相同的,特别注意的是,当创建了一个硬链接文件后,该硬链接文件和源文件的硬链接数都变成了2。

image-20220415172026166

硬链接文件就是源文件的一个别名,一个文件有几个文件名,该文件的硬链接数就是几,这里inode号为786433的文件有test和mytest-h两个文件名,因此该文件的硬链接数为2。

与软连接不同的是,当硬链接的源文件被删除后,硬链接文件仍能正常执行,只是文件的链接数减少了一个,因为此时该文件的文件名少了一个。

image-20220415172257865

总之,硬链接就是让多个不在或者同在一个目录下的文件名,同时能够修改同一个文件,其中一个修改后,所有与其有硬链接的文件都一起修改了。

为什么新创建的目录的链接数是2

因为每个目录创建后,该目录下默认会有两个隐含文件.和…,它们分别代表当前目录和上级目录,因此这里创建的目录有两个名字,一个是dir另一个就是该目录下的.,所以刚创建的目录硬链接数是2。通过命令我们也可以看到dir和该目录下的.的inode号是一样的,也就可以说明它们代表的实际上是同一个文件。

image-20220415172741022
软硬链接的区别
  1. 软链接是一个独立的文件,有独立的inode,而硬链接没有独立的inode。
  2. 软链接相当于快捷方式,硬链接本质没有创建文件,只是建立了一个文件名和已有的inode的映射关系,并写入当前目录。

文件的三个时间

在Linux当中,我们可以使用命令stat 文件名来查看对应文件的信息。

image-20220415210116842

这其中包含了文件的三个时间信息:

  • Access: 文件最后被访问的时间。

  • Modify: 文件内容最后的修改时间。

  • Change: 文件属性最后的修改时间。

当我们修改文件内容时,文件的大小一般也会随之改变,所以一般情况下Modify的改变会带动Change一起改变,但修改文件属性一般不会影响到文件内容,所以一般情况下Change的改变不会带动Modify的改变。
我们若是想将文件的这三个时间都更新到最新状态,可以使用命令touch 文件名来进行时间更新。

注意: 当某一文件存在时使用touch命令,此时touch命令的作用变为更新文件信息。

动态库与静态库

动态库与静态库的原理

动静态库的本质是可执行程序的“半成品”。即一堆xxx.o文件的集合。

我们都知道,一堆源文件和头文件最终变成一个可执行程序需要经历以下四个步骤:

  1. 预处理: 完成头文件展开、去注释、宏替换、条件编译等,最终形成xxx.i文件。
  2. 编译: 完成词法分析、语法分析、语义分析、符号汇总等,检查无误后将代码翻译成汇编指令,最终形成xxx.s文件。
  3. 汇编: 将汇编指令转换成二进制指令,最终形成xxx.o文件。
  4. 链接: 将生成的各个xxx.o文件进行链接,最终形成可执行程序。

例如,用test1.c、test2.c、test3.c、test4.c以及main1.c形成可执行文件,我们需要先得到各个文件的目标文件test1.o、test2.o、test3.o、test4.o以及main1.o,然后再将这写目标文件链接起来,最终形成一个可执行程序。
而实际上,对于可能频繁用到的源文件,比如这里的test1.c、test2.c、test3.c、test4.c,我们可以将它们的目标文件test1.o、test2.o、test3.o、test4.o进行打包,之后需要用到这四个目标文件时就可以之间链接这个包当中的目标文件了,而这个包实际上就可以称之为一个库。
在这里插入图片描述

初识动态库与静态库

在Linux下创建文件编写以下代码,并生成可执行程序。

#include <stdio.h>

int main()
{
	printf("hello world\n"); //库函数
	return 0;
}

这是最简单的代码,运行结果大家也都知道,就是hello world。

image-20220415211410728在这份代码当中我们可以通过调用printf输出hello world,主要原因是gcc编译器在生成可执行程序时,将C标准库也链接进来了。

在Linux下,我们可以通过ldd 文件名来查看一个可执行程序所依赖的库文件。

image-20220415212018921

实际上该软链接的源文件libc-2.17.solibc.so.6在同一个目录下,为了进一步了解,我们可以通过file 文件名命令来查看libc-2.17.so的文件类型。

image-20220415212406157

此时我们可以看到,libc-2.17.so实际上就是一个共享的目标文件库,准确来说,这还是一个动态库。

  • 在Linux当中,以.so为后缀的是动态库,以.a为后缀的是静态库。

  • 在Windows当中,以.dll为后缀的是动态库,以.lib为后缀的是静态库。

这里可执行程序所依赖的libc.so.6实际上就是C动态库,当我们去掉一个动静态库的前缀lib,再去掉后缀.so或者.a及其后面的版本号,剩下的就是这个库的名字。

而gcc/g++编译器默认都是动态链接的,若想进行静态链接,可以携带一个-static选项。

gcc -o lib-s lib.c -static

此时生成的可执行程序就是静态链接的了,可以明显发现静态链接生成的可执行程序的文件大小,比动态链接生成的可执行程序的文件大小要大得多。

静态链接生成的可执行程序并不依赖其他库文件,此时当我们使用ldd 文件名命令查看该可执行程序所依赖的库文件时就会看到以下信息。

image-20220415213318885

动静态库各自的特征

静态库

静态库是程序在编译链接的时候把库的代码复制到可执行文件当中的,生成的可执行程序在运行的时候将不再需要静态库,因此使用静态库生成的可执行程序的大小一般比较大。

优点:

  • 使用静态库生成可执行程序后,该可执行程序就可以独自运行,不再需要库了。

缺点:

  • 使用静态库生成可执行程序会占用大量空间,特别是当有多个静态程序同时加载而这些静态程序使用的都是相同的库,这时在内存当中就会存在大量的重复代码。

动态库

动态库是程序在运行的时候才去链接相应的动态库代码的,多个程序共享使用库的代码。一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。

在可执行文件开始运行前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接。动态库在多个程序间共享,节省了磁盘空间,操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。

image-20220415213618068

优点:

  • 节省磁盘空间,且多个用到相同动态库的程序同时运行时,库文件会通过进程地址空间进行共享,内存当中不会存在重复代码。

缺点:

  • 必须依赖动态库,否则无法运行
静态库的打包

为了更容易理解,下面演示动静态库的打包与使用时,都以下面的四个文件为例,其中两个源文件add.csub.c,两个头文件add.hsub.h

/*
add.h
*/
#pragma once

extern int my_add(int x, int y);
/*
add.c
*/
#include "add.h"

int my_add(int x, int y)
{
	return x + y;
}
/*
sub.h
*/
#pragma once

extern int my_sub(int x, int y);
/*
sub.c
*/
#include "sub.h"

int my_sub(int x, int y)
{
	return x - y;
}

打包

  • 第一步:让所有源文件生成对应的目标文件

    image-20220416080541283
  • 第二步:使用ar命令将所有目标文件打包为静态库

    ar命令是gnu的归档工具,常用于将目标文件打包为静态库,下面我们使用ar命令的-r选项和-c选项进行打包。

    • -r(replace):若静态库文件当中的目标文件有更新,则用新的目标文件替换旧的目标文件。
    • -c(create):建立静态库文件。
    ar -rc libcal.a *.o
    
    image-20220416080750951

    此外,我们可以用ar命令的-t选项和-v选项查看静态库当中的文件。

    • -t:列出静态库中的文件。
    • -v(verbose):显示详细的信息。
    image-20220416080819359
  • 第三步:将头文件和生成的静态库组织起来

    当我们把自己的库给别人用的时候,实际上需要给别人两个文件夹,一个文件夹下面放的是一堆头文件的集合,另一个文件夹下面放的是所有的库文件。

    因此,在这里我们可以将add.h和sub.h这两个头文件放到一个名为include的目录下,将生成的静态库文件libcal.a放到一个名为lib的目录下,然后将这两个目录都放到mathlib下,此时就可以将mathlib给别人使用了。

    image-20220416081157222 image-20220416081301582 image-20220416081315990
  • 使用Makefile

    mylib=libcal.a
    CC=gcc
    $(mylib):add.o sub.o
    	ar -rc -o $(mylib) $^
    %.o:%.c
    	$(CC) -c $<
    
    .PHONY:clean
    clean:
    	rm -f $(mylib) ./*.o
    
    .PHONY:output
    output:
    	mkdir -p mathlib/include mathlib/lib
    	cp ./*.h mathlib/include
    	cp ./*.a mathlib/lib
    
    image-20220416085141428

使用

创建源文件main.c,编写下面这段简单的程序来尝试使用我们打包好的静态库。

#include <stdio.h>
#include <add.h>

int main()
{
	int x = 20;
	int y = 10;
	int z = my_add(x, y);
	printf("%d + %d = %d\n", x, y, z);
	return 0;
}

现在该目录下就只有main.c和我们刚才打包好的静态库。

  1. 使用选项

    此时使用gcc编译main.c生成可执行程序时需要携带三个选项:

    • -I:指定头文件搜索路径。
    • -L:指定库文件搜索路径。
    • -l:指明需要链接库文件路径下的哪一个库。
    gcc main.c -I./mathlib/include -L./mathlib/lib -lcal
    

    说明一下:

    1)因为编译器不知道你所包含的头文件add.h在哪里,所以需要指定头文件的搜索路径。
    2)因为头文件add.h当中只有my_add函数的声明,并没有该函数的定义,所以还需要指定所要链接库文件的搜索路径。
    3)实际中,在库文件的lib目录下可能会有大量的库文件,因此我们需要指明需要链接库文件路径下的哪一个库。库文件名去掉前缀lib,再去掉后缀.so或者.a及其后面的版本号,剩下的就是这个库的名字。
    4)-I,-L,-l这三个选项后面可以加空格,也可以不加空格。

    image-20220416085334547
  2. 把头文件和库文件拷贝到系统路径下

    既然编译器找不到我们的头文件和库文件,那么我们直接将头文件和库文件拷贝到系统路径下不就行了。

    需要注意的是,虽然已经将头文件和库文件拷贝到系统路径下,但当我们使用gcc编译main.c生成可执行程序时,还是需要指明需要链接库文件路径下的哪一个库。

动态库的打包与使用

打包:

动态库的打包相对于静态库来说有一点点差别,但大致相同,我们还是利用这四个文件进行打包演示:

  • 第一步:让所有源文件生成对应的目标文件

    此时用源文件生成目标文件时需要携带-fPIC选项:

    • -fPIC(position independent code):产生位置无关码。
    • 说明一下:
      1. -fPIC作用于编译阶段,告诉编译器产生与位置无关的代码,此时产生的代码中没有绝对地址,全部都使用相对地址,从而代码可以被加载器加载到内存的任意位置都可以正确的执行。这正是共享库所要求的,共享库被加载时,在内存的位置不是固定的。
      2. 如果不加-fPIC选项,则加载.so文件的代码段时,代码段引用的数据对象需要重定位,重定位会修改代码段的内容,这就造成每个使用这个.so文件代码段的进程在内核里都会生成这个.so文件代码段的拷贝,并且每个拷贝都不一样,取决于这个.so文件代码段和数据段内存映射的位置。
      3. 不加-fPIC编译出来的.so是要在加载时根据加载到的位置再次重定位的,因为它里面的代码BBS位置无关代码。如果该.so文件被多个应用程序共同使用,那么它们必须每个程序维护一份.so的代码副本(因为.so被每个程序加载的位置都不同,显然这些重定位后的代码也不同,当然不能共享)。
      4. 我们总是用-fPIC来生成.so,但从来不用-fPIC来生成.a。但是.so一样可以不用-fPIC选项进行编译,只是这样的.so必须要在加载到用户程序的地址空间时重定向所有表目。
    image-20220416090306166
  • 第二步:使用-shared选项将所有目标文件打包为动态库

    与生成静态库不同的是,生成动态库时我们不必使用ar命令,我们只需使用gcc的-shared选项即可。

    image-20220416090911604
  • 第三步:将头文件和生成的动态库组织起来

    与生成静态库时一样,为了方便别人使用,在这里我们可以将add.hsub.h这两个头文件放到一个名为include的目录下,将生成的动态库文件libcal.so放到一个名为lib的目录下,然后将这两个目录都放到mlib下,此时就可以将mlib给别人使用了。

    image-20220416091012096 image-20220416090758470

使用:

我们还是用刚才使用过的main.c来演示动态库的使用。

#include <stdio.h>
#include <add.h>

int main()
{
	int x = 20;
	int y = 10;
	int z = my_add(x, y);
	printf("%d + %d = %d\n", x, y, z);
	return 0;
}

现在该目录下就只有main.c和我们刚才打包好的动态库。

说明一下,使用该动态库的方法与刚才我们使用静态库的方法一样,我们既可以使用 -I,-L,-l这三个选项来生成可执行程序,也可以先将头文件和库文件拷贝到系统目录下,然后仅使用-l选项指明需要链接的库名字来生成可执行程序,下面我们仅以第一种方法为例进行演示。

此时使用gcc编译main.c生成可执行程序时,需要用-I选项指定头文件搜索路径,用-L选项指定库文件搜索路径,最后用-l选项指明需要链接库文件路径下的哪一个库。
与静态库的使用不同的是,此时我们生成的可执行程序并不能直接运行。

需要注意的是,我们使用-I-L-l这三个选项都是在编译期间告诉编译器我们使用的头文件和库文件在哪里以及是谁,但是当生成的可执行程序生成后就与编译器没有关系了,此后该可执行程序运行起来后,操作系统找不到该可执行程序所依赖的动态库,我们可以使用ldd命令进行查看。

解决该问题的方法有以下三个:

  • 方法一:拷贝.so文件到系统共享库路径下

    既然系统找不到我们的库文件,那么我们直接将库文件拷贝到系统共享的库路径下,这样一来系统就能够找到对应的库文件了。

  • 方法二:更改LD_LIBRARY_PATH

    LD_LIBRARY_PATH是程序运行动态查找库时所要搜索的路径,我们只需将动态库所在的目录路径添加到LD_LIBRARY_PATH环境变量当中即可。

  • 方法三:配置/etc/ld.so.conf.d/

    我们可以通过配置/etc/ld.so.conf.d/的方式解决该问题,/etc/ld.so.conf.d/路径下存放的全部都是以.conf为后缀的配置文件,而这些配置文件当中存放的都是路径,系统会自动在/etc/ld.so.conf.d/路径下找所有配置文件里面的路径,之后就会在每个路径下查找你所需要的库。我们若是将自己库文件的路径也放到该路径下,那么当可执行程序运行时,系统就能够找到我们的库文件了。

    首先将库文件所在目录的路径存入一个以.conf为后缀的文件当中。

    然后将该.conf文件拷贝到/etc/ld.so.conf.d/目录下。

    但此时我们用ldd命令查看可执行程序时,发现系统还是没有找到该可执行程序所依赖的动态库。

    这时我们需要使用ldconfig命令将配置文件更新一下,更新之后系统就可以找到该可执行程序所依赖的动态库了。

阅读终点,创作起航,您可以撰写心得或摘录文章要点写篇博文。去创作
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

缘起云端,逆光而上

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值