基础IO
一,C语言文件操作
C语言文件接口汇总
🚀在C语言中我们学习了大量关于文件的接口:
🚀如果这些接口有哪些不熟悉可以阅读一下这篇博客: C语言文件操作
C语言文件接口使用
🚀在C语言中,对文件进行读写操作首先第一步是通过fopen打开文件,打开文件的时候我们可以选择以何种方式打开,例如以读方式‘r’,写方式‘w’,追加方式‘a’等等。
🚀打开文件后,根据具体的需求从文件从读取数据or向文件中写入数据。
🚀对文件操作完成后,最后一步要fclose关闭文件。
创建一个log.txt文件,并向其中写入数据
1 #include <stdio.h>2 #include <stdlib.h>3intmain()4{5//1.打开文件6 FILE* pf =fopen("log.txt","w");7if(pf ==NULL)8{9perror("fopen");10exit(-1);11}12//2.写入数据13int cnt =6;14constchar* str ="Hello Linux\n";15while(cnt--)16{17fputs(str,pf);18}19//3.关闭文件20fclose(pf);21return0;22}
打开log.txt文件,并从其中读取数据
1 #include <stdio.h>2 #include <stdlib.h>34intmain()5{6//1.打开文件7 FILE* pf =fopen("log.txt","r");8if(pf ==NULL)9{10perror("fopen");11exit(-1);12}13//2.读取数据14char buffer[1024];15char* res =fgets(buffer,sizeof(buffer),pf);16while(res !=NULL)17{18printf("%s",buffer);19 res =fgets(buffer,sizeof(buffer),pf);20}21//3.关闭文件22fclose(pf);23return0;24}
可以看到read进程成功的将log.txt中的数据读取了出来。
注意: 在使用w或a等方式打开文件的时候,如果文件不存在,并且用户并没有指定文件的路径只是指定了文件的名字,那么系统会在当前路径下创建出这个文件。
如何理解当前路径 :1.可执行程序所在的路径。2.可执行程序运行起来成为进程的当前工作路径。 具体是哪一个呢?下面来验证一下。
🚀write所处的路径是在test目录下,我们尝试在test的上级目录下运行该程序,看看文件被创建在哪一个路径下了。
可以看到如果在test的上级目录下运行write程序,那么log.txt文件就会被创建在上级目录下,由此可以推测 当前路径 指的是进程的当前工作路径。
🚀用户可以在/proc/进程PID 目录下查看关于进程的信息。
当前路径就是进程的当前工作路径,/proc/进程PID 目录下通过一个软链接记录了进程的当前工作路径。
二,Linux文件操作系统调用
🚀C语言和C++和java的文件操作方式各不相同,但是只要在Linux系统下跑,最终都要转化为调用Linux下的系统调用接口,这样我们就可以站在系统的角度以统一的视角来看待文件的操作。
open
- 第一个参数:指定文件的名称。
- 第二个参数:以何种方式打开文件,主要有以下的方式: 必选项:O_RDONLY(只读),O_WRONLY(只写),O_RDWR(读写方式)。 可选项:通常使用O_CREAT(如果文件不存在就创建),O_APPEND(以追加方式打开),O_TRUNC(如果文件存在,并且以只写或读写方式打开,则会清空文件内容)。 这些选项是以按位或的方式组合在一起使用的,实际上这就是一种位图结构每个比特位代表了一种选项,例如:以写的方式打开并且清空文件内容,则选项为O_WRONLY | O_TRUNC。
- 第三个参数:创建文件时,赋予文件的权限,但是这个权限是于umask掩码作用后的结果,权限 = 起始权限 & ~umask。
- 返回值:打开成功返回文件的fd,否则返回-1。
write
- 第一个参数:写入数据文件的fd
- 第二个参数:自定义文件的缓冲区,就是说将哪写数据写入到文件中
- 第三个参数:写入数据的大小,单位字节。
- 返回值:如果写入成功那么返回写入数据的大小,单位字节,如果写入失败将返回-1。
read
- 第一个参数:文件的fd。
- 第二个参数:自定义的缓冲区,将文件中的数据的读取到缓冲区。
- 第三个参数:要读取数据的大小。
- 返回值:如果读取成功那么返回读取到的数据的字节数,如果读取失败那么返回-1。
close
- 参数:要关闭文件的fd。
- 返回值:如果成功返回0,失败返回-1。
使用系统接口
打开文件test.txt不存在就创建,向其中写入数据。
#include<stdio.h>#include<unistd.h>#include<fcntl.h>#include<sys/types.h>#include<sys/stat.h>#include<stdlib.h>#include<string.h>intmain(){// 1.打开文件int fd =open("test.txt", O_WRONLY | O_CREAT,0664);if(fd ==-1){perror("open");exit(-1);}// 2.写入数据char buffer[1024];int cnt =1;while(cnt <=5){snprintf(buffer,sizeof(buffer),"%d:%s", cnt++,"Hello Linux\n");write(fd, buffer,strlen(buffer));}// 3.关闭文件close(fd);return0;}
文件确实被创建了出来,并且数据已经被写到文件中。
以读方式打开文件,读取出文件的内容
#include<stdio.h>#include<unistd.h>#include<sys/types.h>#include<sys/stat.h>#include<fcntl.h>#include<stdlib.h>intmain(){// 1.打开文件int fd =open("test.txt", O_RDONLY);if(fd ==-1){perror("open");exit(-1);}// 2.读取数据char buffer[1024];int n =read(fd, buffer,sizeof(buffer)-1);//-1是为了给字符串留出'\0'的位置if(n ==-1){perror("read");exit(-2);}printf("%s", buffer);// 3.关闭文件close(fd);return0;}
三,文件描述-fd
文件描述符原理
🚀我们发现关于文件的系统接口中都存在fd这个字段,这是什么意思呢?fd-文件描述符。首先要知道open打开文件的本质是什么?我们知道我们可执行程序完成的各种操作都是由CPU执行的,而CPU只与内存做交互,那么我们可执行程序中对于文件的各种操作最终都是要CPU来完成的,所以文件是需要被加载到内存的,所以打开文件的本质就是将需要的文件属性加载到内存中。
🚀操作系统中一定存在大量被打开的文件,所以操作系统要管理这些被打开的文件,要先描述再组织,OS会为每个打开的文件创建struct file结构体,然后用链表将这些struct file结构体组织起来。
🚀由于文件是进程让操作系统加载到内存的,那势必进程与struct file之间肯定会有某种联系,我们知道一个可执行程序被加载到内存成为进程,OS会为其创建pcb结构,pcb中由struct files struct 的结构体,里面有一个指针数组,类型是struct file*类型的,数组中存储的内容就是一个个struct file结构体的指针,而文件描述符-fd就是这个数组的下标。
文件描述符分配规则
🚀我们试着一次多打开一个文件,来看看它们的文件描述符有什么关系。
#include<stdio.h>#include<unistd.h>#include<fcntl.h>#include<sys/types.h>#include<sys/stat.h>intmain(){int fd =open("test.txt", O_CREAT | O_RDONLY,0664);printf("%d\n", fd);
fd =open("test.txt", O_RDONLY);printf("%d\n", fd);
fd =open("test.txt", O_RDONLY);printf("%d\n", fd);
fd =open("test.txt", O_RDONLY);printf("%d\n", fd);
fd =open("test.txt", O_RDONLY);printf("%d\n", fd);return0;}
可以看到文件描述符为3,4,5,6,7,可见这些struct file的地址在files struct 结构体中文件描述符表中是连续存储的。但是有一个问题,数组下标是从0开始的,为什么我们打开文件分配的fd是从3开始的呢?
其实,操作系统为我们默认打开了三个文件,分别是标准输入,标准输出和标准错误,其文件描述符分别为0,1,2,所以我们再打开文件,fd是从3开始分配的。
🚀实验:如果关闭了0号文件描述符,那么我们再打开新的文件为其分配的fd是0,还是3呢?
#include<stdio.h>#include<unistd.h>#include<fcntl.h>#include<sys/types.h>#include<sys/stat.h>intmain(){// 1.关闭0号文件描述符close(0);int fd =open("test.txt", O_CREAT | O_WRONLY,0664);printf("fd: %d\n", fd);return0;}
🚀结论:文件描述符在分配的时候,是从头开始遍历文件描述符表,找到第一个没有被分配的位置,将其分配给目前的struct file。
如何理解一切皆文件
🚀OS默认打开的三个文件,stdin对应的硬件是键盘,stdout和stderr对应的都是显示器,struct file结构体中存在一个缓冲区,事实上我们通过write函数向文件中写入数据,实际上是在向struct file中的内核缓冲区中写入,read函数其实就是从struct file中的内核缓冲区中读取。write,read函数实际的功能就是拷贝。
#include<stdio.h>#include<unistd.h>#include<fcntl.h>#include<sys/types.h>#include<string.h>#include<sys/stat.h>intmain(){char buffer[1024]="hello world\n";write(1, buffer,strlen(buffer));return0;}
🚀可以看到如果我们向fd为2的文件中写入,实际就是向显示器写入,结果就是会在显示器中显示我们写的内容。对于显示器这个硬件来说,write函数实际上是向内核缓冲区中写入,但是是如何做到在显示器上显示出来的呢?事实上struct file中还存在了一批函数指针,其中它们也分为读取数据的函数指针,和写入数据的函数指针。写功能的函数指针的功能就是将内核缓冲区的数据刷到对应的外设中,读功能的函数指针就是将外设中的数据搬到内核缓冲区中。
四,重定向
原理
🚀重定向的原理就是在上层用户无感的情况下,在OS内部对文件描述符表的特定下标的指向做了修改。
#include<stdio.h>#include<unistd.h>#include<fcntl.h>#include<sys/types.h>#include<string.h>#include<sys/stat.h>intmain(){close(1);int fd =open("test.txt", O_CREAT | O_WRONLY,0664);if(fd ==-1){perror("open");exit(-1);}printf("hello world\n");fflush(stdout);//刷新缓冲区close(fd);return0;}
🚀上面的代码首先关闭了标准输出的文件描述符,然后新打开了一个名为test.txt的文件,根据文件描述符的分配规则可以知道为新打开的文件分配的文件描述符为1,所以代码中的printf语句打印的内容并不会打印到显示器上,而是重定向到了test.txt文件中,这其实就是我们自己完成了重定向的功能。
刚才的代码就做了下图的操作,但是真正的重定向并不是这么设计的。
dup2
重定向是这样实现的:首先打开一个新的文件,OS会为其分配一个fd,我们将此fd的指向覆盖掉要冲顶向的文件的fd的指向。
- oldfd:就拿输出重定向来举例:实质是将1号下标的指向,从原来的显示器改为新打开的文件,所以很多人误认为这个oldfd是1号文件描述符,注意图片中框出的那句话 ’newfd be the copy of the oldfd ‘,可见newfd是oldfd的拷贝所以oldfd对应的是新打开文件的fd
- newfd:如果是输入重定向那么newfd为0,输出重定向和追加重定向是1,对于标准错误的冲向newfd为3。
🚀所以真正的重定向是下面这样:
#include<stdio.h>#include<unistd.h>#include<fcntl.h>#include<sys/types.h>#include<string.h>#include<sys/stat.h>#include<stdlib.h>intmain(){int fd =open("test.txt", O_CREAT | O_WRONLY,0664);if(fd ==-1){perror("open");exit(-1);}dup2(fd,1);printf("hahahahhaha\n");fflush(stdout);close(fd);return0;}
FILE
理解现象
intmain(){constchar*msg0 ="hello printf\n";constchar*msg1 ="hello fwrite\n";constchar*msg2 ="hello write\n";printf("%s", msg0);fwrite(msg1,strlen(msg0),1,stdout);write(1, msg2,strlen(msg2));fork();return0;}
运行结果:
输出重定向到log.txt:
可以看到直接输出到显示器和重定向到普通文件的结果是不一样的,这就和缓冲区有很大关系。
🚀首先声明一点,这里说的缓冲区是C语言的缓冲区,不是内核缓冲区。上面这种现象是因为不同的缓冲区刷新策略是不同的,打印到显示器是行缓冲的,写入到普通文件是全缓冲的。对于行缓冲来说创建子进程之前已经将缓冲区的内容刷新到了内核缓冲区,而对于重定向到普通文件就变成了全缓冲,因为write是系统调用直接向内核缓冲区中写入而printf,fwrite都是C语言提供的库函数,它们首先是向C语言的缓冲区中写入然后根据刷新规则冲刷到内核缓冲区的。所以fork创建子进程之前只有write函数将数据写入到了内核缓冲区,而fwrite和printf的数据都在C语言的缓冲区中,当父子进程哪一个先退出的时候就会先刷新缓冲区,就会发生写时拷贝,所以数据会被写入到内核缓冲区中两次。但是内核缓冲区又是如何刷新到外设的,这又要根据OS的刷新策略。
缓冲区
🚀缓冲区主要分为三种:行缓冲(显示器采用),全缓冲(普通文件采用),无缓冲。
🚀C库中的输出缓冲区就是在FILE结构体内的,C库结合一定的刷新策略通过write接口将缓冲区的内容写入到OS。
🚀为什么会存在缓冲区?答案是为了提高效率,节省资源,因为刷新缓冲区是通过调用系统接口完成的,而系统接口调用是需要花费时间的。
🚀重新理解fwrite,write,和内核中的刷新函数:这些函数的本质都是拷贝函数,fwrite是将我们用户的数据拷贝到C库的缓冲区中,而write是将C库缓冲区的内容拷贝到内核缓冲区中,内核缓冲区中的数据又会被刷新到外设中。
🚀重新理解scanf,printf的格式控制:scanf和printf就是从缓冲区中读入和向缓冲区中写入,缓冲区中的数据都是以字符的形式存在的,所以scanf在读取的时候会将这些字符格式转换成相应的类型,printf就是将数据转化成字符串的形式拷贝到缓冲区中。
模拟实现fopen fclose fwrite fflush
🚀C语言中FILE结构体中,必定存在一块缓冲区,并且肯定存在文件描述符fd,下面来模拟实现一下文件操作。
mystdio.h
#pragmaonce #include<stdio.h>#include<string.h>#include<unistd.h>#include<assert.h>#include<sys/types.h>#include<sys/stat.h>#include<fcntl.h>#include<stdlib.h>#defineMAX1024//缓冲区的大小#defineBUFFER_NONE0x1//无缓冲#defineBUFFER_LINE0x2//行缓冲#defineBUFFER_ALL0x4//全缓冲typedefstructMY_FILE{int fd;int flags;char output_buffer[MAX];int buffer_size;}MY_FILE;
MY_FILE*my_fopen(constchar* path,constchar* mode);
size_t my_fwrite(constchar* ptr,size_t size,size_t count,MY_FILE* stream);intmy_fclose(MY_FILE* stream);intmy_fflush(MY_FILE* stream);
my_fopen
MY_FILE*my_fopen(constchar* path,constchar* mode){int flag =0;if(strcmp(mode,"r")==0)
flag |= O_RDONLY;elseif(strcmp(mode,"w")==0)
flag |= O_CREAT | O_WRONLY | O_TRUNC;elseif(strcmp(mode,"a")==0)
flag |= O_CREAT | O_WRONLY | O_APPEND;
mode_t _mode =0664;int fd =0;if(flag & O_RDONLY)
fd =open(path,flag);else
fd =open(path,flag,_mode);if(fd <0)returnNULL;
MY_FILE* pf =(MY_FILE*)malloc(sizeof(MY_FILE));if(pf ==NULL)returnNULL;
pf->fd = fd;
pf->flags = fd ==1? BUFFER_LINE : BUFFER_ALL;
pf->buffer_size =0;memset(pf->output_buffer,'\0',sizeof(pf->output_buffer));return pf;}
🚀首先根据形参的mode来确定以何种方式打开文件,并且要确定缓冲区的刷新方式,还要对缓冲区做初始化。
my_fflush
intmy_fflush(MY_FILE* stream){assert(stream);write(stream->fd,stream->output_buffer,stream->buffer_size);fsync(stream->fd);
stream->buffer_size =0;return0;}
🚀刷新缓冲区的操作就是将语言级别的缓冲区通过系统调用write刷新到内核缓冲区中,fsync 这个系统调用的功能是强制刷新内核缓冲区到外设。
my_fwrite
size_t my_fwrite(constchar* ptr,size_t size,size_t count,MY_FILE* stream){//首先确定要写入数据所占的字节数
size_t sum_size = size * count;
size_t res =0;//比较写入数据大小与缓冲区所剩空间的大小if((size_t)(MAX - stream->buffer_size)>= sum_size){memcpy(stream->output_buffer + stream->buffer_size , ptr,sum_size);
stream->buffer_size += sum_size;
res = count;}else//如果不能把所有数据写入,那么就要计算能写入几个完整的数据{
res =(size_t)(MAX - stream->buffer_size)/ size;memcpy(stream->output_buffer + stream->buffer_size,ptr,size * res);
stream->buffer_size += size * res;}//写入完成后根据刷新规则,看看是否要刷新到内核缓冲区if(stream->flags & BUFFER_ALL)if(stream->buffer_size == MAX)my_fflush(stream);if(stream->flags & BUFFER_LINE)if(stream->output_buffer[stream->buffer_size -1]=='\n')my_fflush(stream);if(stream->flags & BUFFER_NONE)my_fflush(stream);return res;}
my_close
intmy_fclose(MY_FILE* stream){//冲刷缓冲区assert(stream);my_fflush(stream);//关闭文件close(stream->fd);//free空间free(stream);return0;}
理解文件系统
🚀上面所述的文件都是文件被打开的时候,那么文件没有被打开的时候,是如何存储的呢?是如何管理这些文件的呢?
磁盘
什么是磁盘呢?
磁盘是一种永久性的存储介质,在计算机中磁盘几乎是唯一的机械设备。与磁盘相应的就是内存,内存是掉电易失存储介质,所有的普通文件都是在磁盘上存储的。磁盘既可以是输入设备又可以是输出设备。
磁盘的结构
🚀磁盘又称为块设备,它的基本存储单位是一个扇区,一个扇区为512字节。
🚀在磁盘中如何找到一个具体的扇区的呢?首先通过磁头就能够确定具体在哪个盘片上,通过磁道能够确定具体是在哪个同心圆环上,最后通过是第几个扇区就能够确定出一个扇区的具体位置了,确定了一个扇区的位置那么就相当于确定了每一个扇区的位置。这种定位方式叫做CHS定位法。
🚀操作系统是如何管理磁盘的呢?如果操作系统同样使用CHS定位法,来确定每一个扇区的位置,那么一旦磁盘的结构发生变化操作系统的源码也要发生改变,很显然这种方式是不显示的。所以OS对磁盘做了逻辑抽象,使用抽象出来的逻辑地址来对磁盘进行管理,这种确定地址的方式叫做LBA(logical block address)
对磁盘的逻辑抽象
🚀对磁盘会进行分区,对于分区的头部会存在一个boot block启动块是用来启动系统的,除去该区域外每个分区内部又会被分成若干个块组,其中每个块组内部又分为以下部分:
- super block:存放着此分区的信息,分组情况,inode总量等等,并且每个组内都有super block主要是为了防止某个super block坏掉,如果出现故障那么整个分区将不能使用所以做好备份。
- group descriptor table:组描述符表,主要存放该组内的详细属性信息。
- block bitmap:数据块的位图结构,表示哪些数据块可以被使用,哪些数据块已经被使用。
- inode bitmap:inode编号的位图结构,表示哪些inode是可用的
- inode table:一个文件内部所有属性的集合,称为node结点大小为128字节,一个文件有一个inode结点,每个分区内有大量的文件所以有大量的inode结点,所以需要有一个区域来存放这些结点,而这个区域就是inode table。每个分组内有许多个inode结点,每个inode都有其自己的inode编号,并且在一个分区内inode编号是唯一有效的。并且在inode中还有一个data block编号的数组,表示该文件的数据都被存放在了哪些数据块中。
- data blocks:数据块:用来存放文件的内容。
inode
🚀inode是一个文件的属性集合,每个文件都会有一个inode结点,每个inode结点都会有其自己的inode编号,可以通过ls -i执行查看。
第一列的数据就是文件对应的inode编号。
🚀Linux系统只认识inode编号不认识文件名,并且文件的inode属性中不存在文件名,文件名是给用户使用的。重新认识目录结构: 目录也是文件,又有对应的inode,也有内容。我们知道所有的文件肯定都是存在对应的目录下的,目录的内容就是该目录下的文件名与inode编号的一一映射关系。
🚀文件被访问的过程: 访问一个文件的时候,该文件一定属于某个目录下,该目录中存在该文件与其inode编号的映射关系,并且这个目录也是一个文件也是属于某个分区的,通过inode编号找到文件所在的分组,在该分组的inode table中,找到该文件的inode属性块,通过inode属性块内的data block块的信息找到存储文件内容的data block数据块,找到后加载到操作系统中,然后完成显示到显示器。
🚀如何理解文件的删除: 文件的删除不是对其对应的data block数据块的内容做清空。根据文件所在目录的inode编号,找到目录的inode属性快,根据其与data block块的映射关系找到目录的内容,里面就是其中文件和文件inode编号的映射关系,得到文件的inode编号后,找到其inode属性块,从而知道该文件占用了哪几个数据块,然后将data bitmap为图结构中相应的位置由1置为0,然后根据inode编号将inode bitmap的相应位置由1置0,这就是文件的删除过程,本质就是将data bitmap和inode bitmap位图结构由1置0的过程。
🚀理解文件创建过程: 创建文件一定是在某个目录下创建的,该目录一定属于某个分组,在这个分组内扫描inode bitmap,找到一个可以使用的位置将其由0置1,并找到对应的inode属性快,将文件的属性信息填入,然后将文件名与inode映射关系写入到目录的数据块中。
🚀理解文件的写入: 根据文件的inode编号找到其对应的inode属性快,找到该文件对应的data block属性块,然后将内容写入到属性快中。如果刚创建的文件没有属性快,那么就扫描data bitmap找到可用的位置并将其由0置1,并将属性块的编号填入到inode属性快内,然后将内容写入到属性块内。
补充细节
🚀如果文件被误删了,什么都不要干,避免inode位图结构被其他文件覆盖。
🚀Windows下的回收站其实就是一个目录,删除文件时就相当于Linux下的mv指令,而在清空回收站的时候才是真正的删除。
🚀inode编号能够确定分组,inode编号在一个分区内是唯一有效的,但是不能跨分区。
🚀上面的分区,分组,填写系统信息这些都是谁做的呢?是什么时候做的呢?
答案是OS做的,在分区完成后,后面能让分区能够被正常使用,需要对分区做格式化,格式化的过程就是OS在向分区内写入管理属性信息。
🚀inode属性块内不是用数组与data block块建立简单的一级索引,而是存在二级索引甚至三级索引的情况,通常这个数组能存放15个信息,OS规定前几个是直接索引,后面几个为2级索引和3级索引。
1级索引就是对应的数据块存放的是文件的内容数据,而2级索引存放的是其他1级索引的数据块的编号,同理那么3级索引存放的就是2级索引对应的数据块。
🚀存不存在data block没使用完而inode使用完了,或者是inode没使用完但data block使用完了?
这两种情况都是存在的,如果建立大量的空文件就可能造成第一种情况,对一个文件一直写入数据就可能造成第二种情况。
软硬链接
软连接
使用link -s指令可以给文件建立软连接。
ln -s mytest mytest-soft
🚀通过软连接也可以执行可执行程序,并且软链接形成的文件也有自己的inode编号,证明其是一个全新的文件。
🚀软链接形成的文件的内容存放的是所指向文件的路径。
🚀软链接就相当于Windows下的快捷方式,其主要作用就是把一些所处目录结构较深的文件建立软链接方便访问。
硬链接
ln mytest mytest-hard
🚀硬链接的inode编号是与原文件的inode编号是一致的。
🚀硬链接的实质就是建立起新的文件名与老的inode编号的映射关系。
🚀可以使用unlink指令删除链接。
🚀每个目录下都会存在两个隐藏的目录. 和 … 这其实就是两个硬链接。但是OS不允许用户给目录建立硬链接,为了防止在搜索一个文件路径的时候发生无穷递归,. 和 …是经过OS处理过的不会发生无穷递归。
🚀inode属性块中维护了一个字段是链接数,其实就是一个引用计数,表示由多少个文件名与该inode编号建立起了映射关系,当你使用unlink 或者 rm 删除文件的时候,会使该引用计数-1,如果减到0,那么就真正的删除了该文件。
文件的ACM时间
- Access:文件的最新访问时间(老版本的内核每当查看文件后都会将最新的时间刷新到磁盘,但这样的话IO频次过高,使得整机效率降低,所以现在的内核版本会过一段时间做一次刷新)
- Modify:修改文件内容的最新时间
- Change:修改文件属性的最新时间
版权归原作者 大理寺j 所有, 如有侵权,请联系我们删除。