⭐️这篇博客就要开始聊一聊Linux中基础IO相关知识,IO相信大家都不陌生,我们在C/C++中对文件进行读写的操作,也就是文件IO,这篇博客我也会带大家回顾一下。这篇博客还会介绍系统中的文件IO调用的接口,还有文件系统相关的内容和概念,文件描述符等相关知识的分享。
C语言的专栏中有专门讲到这一块知识,这里会介绍一些,更细节的内容可以参考这篇博客——C语言文件操作
先看一下C语言的两个库函数:
size_t fwrite( const void *buffer, size_t size, size_t count, FILE *stream );// 写文件
size_t fread( void *buffer, size_t size, size_t count, FILE *stream );// 读文件
实例演示:
实例1: 写文件
#include
#include
int main()
{
FILE* fp = fopen("log.txt", "w");
if (fp == NULL){
perror("open file fail");
exit(-1);
}
const char* msg = "hello world!\n";
int count = 5;
while (count--){
fwrite(msg, strlen(msg), 1, fp);
}
fclose(fp);
return 0;
}
实例2: 读文件
#include
#include
int main()
{
FILE* fp = fopen("log.txt", "r");
if (fp == NULL){
perror("open file fail");
exit(-1);
}
char buf[256] = {0};
int ret = 0;
while ((ret = fread(buf, 1, 13, fp))){
printf("%s", buf);
}
fclose(fp);
return 0;
}
Linux下一切皆文件,硬件设备也是被当做文件看待的,也就是说这些硬件设备也是可以通过IO打开的,并且进行读写。那他们是操作的呢?一般来说,C语言程序运行起来,都会默认打开3个流,分别是:
stdin,stdout和stderr背后支撑的硬件设备分别是键盘、显示器和显示器。
#include
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
仔细观察可以发现,它们都是FILE*类型的,也就是文件指针。所以,我们不需要考虑要打开键盘和屏幕这些流。这也是为什么我在打印数据到屏幕或从键盘上输入数据时,即使我们没有打开这些流,我们也可以执行这些操作的原因。
输出信息到屏幕的几种方式:
#include
#include
int main()
{
FILE* fp = fopen("log.txt", "w");
if (fp == NULL){
perror("open file fail");
exit(-1);
}
const char* msg = "hello world!\n";
int count = 5;
while (count--){
fwrite(msg, strlen(msg), 1, stdout);// 把fp改成stdout
}
fclose(fp);
return 0;
}
总结: C语言会默认打开三个流,而且C++等其它的语言都会有对应的手段,且这些都是由操作系统来进行支持。由不同的语言进行封装。
操作系统底层其实是提供了文件IO的系统调用接口的,有write,read,close和lseek等一套系统调用接口,不同语言会对这些系统调用接口进行封装,封装成某个语言自己的一套文件IO的库函数,这样在语言层面,程序员只需要语言的调用库函数,无需关系底层的系统调用,降低了开发成本。
这里主要介绍open、read、wirte和close
作用: 打开一个文件
函数原型:
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode)
参数介绍:
返回值:
实例演示: open函数的使用,研究函数返回值
#include
#include
#include
#include
#include
#include
#include
int main()
{
int fd1 = open("log.txt1", O_RDONLY|O_CREAT, 0664);
int fd2 = open("log.txt2", O_RDONLY|O_CREAT, 0664);
int fd3 = open("log.txt3", O_RDONLY|O_CREAT, 0664);
int fd4 = open("log.txt4", O_RDONLY|O_CREAT, 0664);
int fd5 = open("log.txt5", O_RDONLY|O_CREAT, 0664);
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;
}
代码运行结果如下:
观察运行结果可以发现,返回值fd是从3开始分配,且是递增的,不知道大家对这一连串的数字可以联想到什么。
是数组下标吗?对的,fd的本质就是数组的下标,其实这些返回值就是一个数组的下标。那问题又来了,既然是数组下标,那0,1,2去哪了?其实在Linux下,进程会默认把3个文件描述符分配(0,1和2)给标准输入、标准输出和标出错误,所以,后序如果打开文件,文件描述符就是从3开始分配的。
作用: 关闭文件
函数原型:
int close(int fd);
函数参数:
作用: 写文件
函数原型:
ssize_t write(int fildes, const void *buf, size_t nbyte);
函数参数:
函数返回值:
实例演示:
#include
#include
#include
#include
#include
#include
#include
int main()
{
int fd = open("log.txt", O_WRONLY|O_CREAT, 0664);
char buf[15] = "hello world\n";
write(fd, buf, sizeof(buf)/sizeof(buf[0]));
close(fd);
return 0;
}
作用: 写文件
函数原型:
ssize_t read(int fd, void *buf, size_t count);
函数参数:
函数返回值:
实例演示:
#include
#include
#include
#include
#include
#include
#include
int main()
{
int fd = open("log.txt", O_RDONLY|O_CREAT, 0664);
char buf[15] = "hello world\n";
read(fd, buf, 15);
printf("%s", buf);
close(fd);
return 0;
}
fd: 打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。
注意:
思考1:一个进程可以打开多个文件,那这些文件是如何管理起来的呢?
答案是先描述,再组织。操作系统在内存中创建相应的数据结构来描述目标文件。也就是用一个struct file的结构体把每个文件描述起来,如下:
其中,为了模拟面向对象中的成员方法,这里通过函数指针来模拟实现,里面用不同的函数指针指向了对应的文件(键盘、显示器或磁盘等等)的操作方法,指向不同的文件对应的硬件设备读写方法,这里就模拟实现了C++中面向对象的多态的一大特性。
在文件系统之上,有一层内核软件层——vfs(Virtual File System) 。VFS是一个可以让open()、read()、write()等系统调用不用关心底层的存储介质和文件系统类型就可以工作的粘合层。为不同文件系统的通信提供了媒介。在操作系统看来,每个file结构体在文件层面上都是一样的,所以说多态是对一切皆文件更高层次理解的一种表现。
对于进程而言,先找到file_struct,然后找到fd_array的指针数组,通过下标fd找到对应的file*,从而找到对应的文件,然后struct file中的函数指针就可以实现对文件的读写操作。
如何组织?
用一个双链表的结构把打开的文件链接起来,文件的打开和关闭就是对双链表的增删查改。
思考2:进程如何与文件关联起来?
每个进程中都有一个struct file_struct* 的结构体指针,指向的是一个file_struct的结构体,这个结构体里面有一个sturct file* 的指针数组fd_array[],里面指向的就是一个一个的struct file,如下图:
进程如何找到对应文件?(进程和文件关联)
进程的task_struct中可以找到一个叫struct file*的指针,这个指针指向file_struct这张表,这样表里面又有一个指针数组,指向的是每一个文件的结构体,通过fd (数组下标)可以找到对应的struct file,这个结构体里面就包含文件属性和相关的inode元信息,还有对文件进行读写操作的一些方法。这样就达到通过fd找到对应文件的目的。
在open的使用那块我已经演示了fd的一些分配规则,也就是默认从3开始分配,因为Linux进程默认情况下会打开3个缺省的文件描述符,上面介绍过了。所以我们的进程在打开文件时,就是从3开始配。
下面做一个小实验: 关闭fd为0的文件,也就是标准输入,此时我们打开两个文件,看看这两个fd分别是多少
#include
#include
#include
#include
#include
#include
#include
int main()
{
close(0);
int fd1 = open("log.txt1", O_WRONLY|O_CREAT, 0664);
int fd2 = open("log.txt2", O_WRONLY|O_CREAT, 0664);
printf("fd1:%d\n", fd1);
printf("fd2:%d\n", fd2);
close(fd1);
close(fd2)
return 0;
}
代码运行结果如下:
观察实验结果,可以发现,关闭了fd为0的文件后,后序打开的文件,文件描述符就是从0开始分配,然后分配没有被使用的fd,也就是3。从最小未被使用的文件描述符开始分配。
文件描述符分配规则:在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符
概念: 重定向是指修改原来默认的一些东西,对原来系统命令的默认执行方式进行改变
重定向一般有以下几种:
看下面的演示:
正常使用echo命令,字符串是输出在显示器上的,加了输出重定向后,字符串被输出到文件上了。也就是把本应该打印到显示器上的内容打印到了文件上了。输出重定向改变了默认的输出方式。
实例演示: 关闭标准输出流,也就是fd为1的文件,此时我们再以写的方式打开一个文件,然后进行输出,观察现象
#include
#include
#include
#include
#include
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY|O_CREAT, 00644);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d, you can see me...\n", fd);
fflush(stdout);
close(fd);
return 0;
}
运行结果如下:
观察实验结果,可以发现关闭标准输出流后,本应该打印到显示器上的字符串被输出到了log.txt 的文件中。
从上述实验现象分析重定向原理: 关闭了标准输出流,再以写的方式打开一个文件(被分配fd=1),凡是要在fd=1写的内容,现在都写到了log.txt中,如下图:
实例演示: 关闭标准输出流,也就是fd为1的文件,此时我们再以追加(O_APPEND)的方式打开一个文件,然后进行输出,观察现象
#include
#include
#include
#include
#include
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY|O_APPEND|O_CREAT, 00644);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d, you can see me...\n", fd);
fflush(stdout);
close(fd);
return 0;
}
运行结果如下:
根据输出重定向的原理,我们也不难介绍这个现象,关闭了标准输出流,我们以追加的方式打开一个文件,这个文件被分配了一个为1的文件描述符。因为printf是库函数,是往fd为1的文件进行输出,所以这里也是直接在log.txt文末进行追加
实例演示: 关闭标准输入流,以读的方式打来文件
#include
#include
#include
#include
#include
int main()
{
close(0);
int fd = open("log.txt", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
char buf[256] = {0};
scanf("%s", buf);
printf("%s\n", buf);
close(fd);
return 0;
}
作用: 复制文件描述符给一个新的文件描述符,让fd_array数组中下标为oldfd的内容拷贝给下标为newfd的内容,也就是让newfd的指向发生改变,指向oldfd所指向的文件
函数原型:
int dup2(int oldfd, int newfd);
参数介绍:
实例演示: 用系统调用dup2来实现输出重定向
#include
#include
#include
int main() {
int fd = open("log.txt", O_CREAT | O_WRONLY);
if (fd < 0) {
perror("open");
return 1;
}
dup2(fd, 1);
printf("fd:%d, you can see me\n", fd);
close(fd);
return 0;
}
代码运行结果如下:
dup2原理分析: printf是C库当中的IO函数,一般往 stdout 中输出,但是stdout底层访问文件的时候,找的还是fd:1, 但此时,fd:1下标所表示内容,已经变成了log.txt的地址,不再是显示器文件的地址,所以,输出的任何消息都会往文件中写入
概念: FILE是C语言的一个对文件进行描述的一个结构体。因为IO相关的函数与系统调用接口是对应的,且库函数封装了系统调用,所以本质上访问文件都是通过fd(fd_array数组下标)进行访问的,所以C语言中的FILE结构体内部,必定封装了fd。
我们可以在 /usr/include/libo.h 打开文件,查看FILE 结构体
typedef struct _IO_FILE FILE;
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
};
从FILE结构体的源码可以看出,FILE内部其实是封装了fd的,这里面就是 _fileno ,里面还有对缓冲区的划分。这里的缓冲区就是C语言级别的缓冲区,之前进度条小程序试验过。
缓冲区和刷新策略:
刷新一般有三种方式: 第一个是对应系统调用而言,后两个是对应库函数而言
所以我们应该知道,重定向会改变缓冲区的刷新方式。比如输出重定向,原来是将数据刷新到显示器上,采用的是行缓冲,重定向后,要刷新到文件中,所以就采取全缓冲。
实例演示: 关闭标准输出流,把内容都输出到文件中,改变缓冲方式
#include
#include
#include
#include
#include
#include
#include
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY);
if (fd < 0){
perror("open file fail");
exit(-1);
}
const char* msg1 = "hello write\n";
const char* msg2 = "hello printf\n";
const char* msg3 = "hello fwrite\n";
write(fd, msg1, strlen(msg1));
printf(msg2);
fwrite(msg3, strlen(msg3), 1, stdout);
fork();
fflush(stdout);
close(fd);
return 0;
}
我们可以发现,系统调用write只打印了一次,而库函数printf和fwrite都打印了两次,这是为什么?
因为对应系统调用write而言,是无缓冲的,有数据就直接刷新在文件或显示器上,所以数据值刷新额一份。但是对应库函数而言,它们在文件上刷新采取的是全缓冲,缓冲区有数据时不会立即被刷新,所以fork之后,子进程会把父进程的数据进行写实拷贝,父进程的缓冲区也会被子进程拷贝拷贝一份,所以父子进程分别对各自缓冲区中的数据进行刷新,所以我们就看到了文件中库函数输出的内容刷新了两份。
总结: printf和fwrite库函数会自带缓冲区,而write系统调用没有带缓冲区。另外,我们这里所说的缓冲区,都是用户级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区,数据也并不是直接刷新在硬件设备上的(毕竟OS不信任任何人),而是先刷新到操作系统的缓冲区,最后由操作系统来刷新到对应的硬件设备上。
概念: inode是在Linux操作系统中的一种数据结构,其本质是结构体,它包含了与文件系统中各个文件相关的一些重要信息。在 Linux 中创建文件系统时,同时将会创建大量的 inode 。
我们可以通过ls -l查看文件名还有文件元数据,我们还可以通过-i选项查看每个文件的inode id号,如下:
一般来说,文件包括内容和属性,其中内容用block的结构体描述起来,属性信息用一个叫inode的结构体描述起来,二者是分开存储。
磁盘分区: 磁盘分区就是将磁盘划分为几个逻辑部分进行管理,且每个分区对管理方式都是类似的。在操作系统层面上,磁盘是被看做成一个线性的存储结构,这个线性存储结构被划分为数个逻辑分区。所以操作系统管理磁盘其实就是对数组进行管理,操作系统只需要管理好一个分区,就可以以相同的方式管理好其他分区。
如下图,是Linux ext2文件系统上磁盘文件系统图,下面主要展示的是一个分区图,这个分区又会被划分成了多个块组,我们只需要管理好一个块组,就可以用样的方式管理好其它块组,所以这里单独拿一个块组进行研究,其中分区头部Boot Block是一个启动块,且大小是固定的
inode和data block的关系:
从上图可以看出,我们只需要知道一个文件的inode,我们就找到文件的数据内容。这就实现了将文件的数据和属性分离。
看一份简图:
思考下面几个问题:
在对应的磁盘分区和块组中,遍历inode Bitmap,找到为0的那一个位置,也就是空闲的inode,把文件属性信息存入inode中,并填入到inode表中,然后inode Bitmap的那一位置为1,表示该inode已经被占用.接下来就是存储数据,在Block BItmap 中找到空闲的数据区,然后分配若干块数据区给该文件,并把相应的块编号填入inode中的文件数据块列表中,建立inode和块编号的映射关系。最后把这个inode id和文件名的映射关系放入目录的存储列表中
先找到文件所在的磁盘分区和块组,如何在inode表中找到文件的属性信息,根据inode中的块数据映射关系找到对应的数据,如何取出来即可。
先找到这个文件,如何把这个inode 在inode Bitmap的那一位和数据列表在Block BItmap的那一位由1置0,表示该inode已经是空闲的,以及对应的数据块也空闲是空闲的。这是一种伪删除,这也是为什么创建一个文件需要的时间比删除一个文件需要的时间多很多。最后删去该目录下inode id和文件名的映射关系。
Linux下一切皆文件,目录当然是文件。文件包括inode和数据,所以目录的inode就包括目录的属性信息,那目录的数据是存储什么呢?目录的数据存储的时该目录下文件名和inode id的对应关系就可以了。目录下所有文件的属性只在每个文件对应的inode中,目录没必要存储这些,只要有映射关系,自然而然地,目录就可以找到这些信息。
两个概念
创建软链接和硬链接: 使用ln指令,创建的是硬链接,加上-s选项创建的是软链接
ln [选项] 源文件 链接文件
创建硬链接: 看下图,可以知道,硬链接和链接的那一个文件共享同一个inode,硬链接其实就是在目录下创建了一个inode和文件名的映射关系
创建软链接: 可以看出,软链接具有一个独立的inode,是一个独立的文件,所以软链接更像是Windows下的快捷方式
这是为什么呢?
因为dir目录下有两个文件,一个是 . ,另一个是 ..。分别是当前目录和上级目录,硬链接数为2也就是dir和dir目录下的 .。所以创建一个目录显示硬链接数为2。
删除硬链接: 可以发现原文件还是存在,只是对应的硬链接数减1了
删除源文件对软链接有什么影响?
先看实验现象
可以看到的是,软链接的颜色变成了红色。因为软链接的数据块中存储了源文件的路径信息,但是这个源文件已经被删除,软链接找不到对应的源文件,所以这里就变红了。删除源文件对硬链接其实是没有什么影响的,只是硬链接是减1了。
如果此时再创建一个log.txt的文件,看会有什么现象:
显然,链接关系恢复了。当然这只是因为软链接数据块中存储的路径找到了对应的文件,这个时候的log.txt已经不是当时那个文件了,里面的内容已经是空的了,大家也可以去试验一下。