一.理解文件
狭义上理解:
我们电脑中的各种文件,它们储存在磁盘中。即狭义上文件指储存在磁盘中的文件;
磁盘是永久性储存介质,因此文件在磁盘上的储存是永久性的;
磁盘是外设(既是输入设备也是输出设备);
对磁盘中文件的操作,本质上都是对外设的输入输出,简称IO。
广义上理解:
Linux下一切皆文件。
文件操作:
文件 = 文件属性 + 文件内容,空文件也是会占用磁盘空间的;
所有文件操作本质上是对文件属性或内容进行操作。
系统角度:
对文件的操作是谁执行的?用户吗?
并不是,文件储存在磁盘中,而磁盘的的管理者是操作系统,也就是说对文件的操作是操作系统完成的,进一步讲是对文件的操作本质上是进程对文件的操作,我们使用的函数都是系统调用的封装。
进程对文件操作前是否要打开文件?一个进程是否只能打开一个文件呢?如何理解打开文件?
对文件的任何操作都必须先打开文件,且一个进程是可以打开多个文件的,那么让系统知道每个文件对应的进程就是必要的(先描述,再组织),因此系统会对被打开的文件创建结构体变量管理起来。也就是说文件打开的标志就是OS为其创建结构体变量。
二.回顾C语言文件操作接口
fopen和fclose
FILE*fopen(constchar*path,constchar*mode);fopen是C语言中用来打开文件的接口,path是打开文件的路径(默认当前工作目录),mode是打开文件的方式,它有6个选项:
r:只读打开文件,对于不存在的文件会打开失败,对于存在的文件其内容会被保留;
r+:只读写打开文件,写入时从文件起始位置写入,对于不存在的文件会打开失败,对于存在的文件其内容会被保留;
w:只写打开文件,写入时从文件起始位置写入,对于不存在的文件会创建该文件,对于存在的文件其内容会被清空;
w+:只读写打开文件,写入时从文件起始位置写入,对于不存在的文件会创建该文件,对于存在的文件其内容会被清空;
a:只写打开文件,写入时从文件末尾位置写入,对于不存在的文件会创建,会保留存在文件的内容;
a+:只读写打开文件,写入时从文件末尾位置写入,对于不存在的文件会创建,会保留存在文件的内容;
它的返回值是FILE类型(库中定义的结构体)的指针,可以将它传入fclose以关闭该文件:
intfclose(FILE*fp);fwrite和fread
size_tfwrite(constvoid*ptr,size_tsize,size_tnmemb,FILE*stream);fwrite是让数据以二进制的方式写入文件,而fprintf是让数据以字符串的方式写入。(第三部分会细讲)
ptr:要写入文件中的数据的指针;
size:一次性向文件写入多少字节;
nmemb:向文件写入多少次;
stream:要写入的文件;
值得注意的是,‘/0’是字符串的终止符是语言层面规定的,在系统层面它就是普通的字符,所以向文件写入字符串时,系统不会遇到’/0’而结束,所以size和nmemb要控制好。
如果写入成功它会返回写入到文件中的字节数,失败返回0;
size_tfread(void*ptr,size_tsize,size_tnmemb,FILE*stream);fread会以读二进制的方式将读取到的内容写入指针ptr,fscanf会以读字符的方式将内容写入字符串。所以fwrite和fread、fprintf和fread最好配套使用。
ptr:接收文件读取到的内容;
size:一次向文件读取多少字节;
nmemb:读取多少次;
stream:读取的文件。
如果读取成功它会返回读取到文件中的字节数,失败返回0;
C语言还有很多其他文件操作相关参数,比如控制文件读写位置的rewind等接口,这里不细究。
三.Linux系统文件I/O
我们写程序就是要进行数据处理,那我们至少得能向文件输入数据,文件至少能向我们输出数据,所以我们执行文件时,系统会默认打开三个文件:
stdin:标准输入流,对应键盘文件;
stdout:标准输出流,对应显示器文件;
stderr:标准错误流,对应显示器文件;
位图传参
位图传参是向函数传一个参数而达到传多个参数效果的传参方式,他利用了一个参数有多个bit位这一特点,位图指的就是参数的二进制形式。
#include<stdio.h>#defineONE_FLAG1<<0#defineTWO_FLAG1<<1#defineTHERR_FLAG1<<2#defineFOUR_FLAG1<<3//让它们保存二进制表达上都只有一个1voidTest(intf){if(f&ONE_FLAG)//ONE_FLAG位图上为1的位置,f在此处也为1printf("ONE\n");if(f&TWO_FLAG)printf("TWO\n");if(f&THERR_FLAG)printf("THERR\n");if(f&FOUR_FLAG)printf("FOUR\n");}intmain(){Test(ONE_FLAG);printf("\n");Test(ONE_FLAG|TWO_FLAG);printf("\n");printf("\n");Test(ONE_FLAG|TWO_FLAG|THERR_FLAG|FOUR_FLAG);return0;}运行结果:
文件描述符1
在进程中,文件描述符是用来识别该进程打开的文件的符号,用整形来表示,每个文件的文件描述符都不同。详细会在接下来慢慢展开。
关于文件操作的系统调用
打开文件的系统调用:open;
关闭文件的系统调用:close;
将数据写入文件的系统调用:write;
从文件中读取数据的系统调用:read。
open、close
可以看到这里有两个open函数,但是这是C语言写的,不是函数重载,可能是用了某些技术达到了函数重载的效果。
pathname:要打开文件所在的路径,默认是当前工作目录;
flags:如何打开文件(只读、只写…),该参数用很多选项,这些选项都是在库中定义的宏,而且这个参数的传递方式就是上面说的位图传参;
mode:文件权限,使用open可能会创建新文件,这个参数就是用来设置新文件的权限的,如:0666、0656…,当然权限的最终结果需要与权限掩码作用(设置权限与权限掩码异或)。
flags选项;
O_RDONLY:只读;
O_WRONLY:只写;
O_CREAT:文件不存在就创建文件;
O_TRUNC:打开文件时清空文件内容;
O_APPEND:在文件末尾追加内容。
当然还有其他选项,这里只讲这5个。
如果打开文件成功,open就会返回该文件的文件描述符,失败则返回-1。
C语言库中fopen就是对该函数的封装,其选项r、w等就是以上选项的组合。
intfd=open("log.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);//相当于fopen中的w+intfd=open("log.txt",O_CREAT|O_WRONLY|O_APPEND,0666);//a+intfd=open("log.txt",O_RDONLY);//r当要关闭文件时要用函数close:
向其传递文件描述符就可以关闭该文件描述符对应的文件。
write、read
write:
write是将指针指向的内容写入指定文件的函数。
fd:目标文件的文件描述符;
buf:要写入文件的内容,任何指针类型都可以;
count:要写入文件的字节数。
如果写入成功它将会返回写入的字节数(包括0),写入失败返回-1。
C语言中的printf系列函数和fwrite函数都是对write的封装,但我们上面说printf系列函数是以文本形式写入文件,fwrite以二进制形式,而它们都是同一个系统调用的封装,这是怎么回事?
实际上,在系统层面,往文件写入内容只有二进制写入这一种方法(将数据以字节为单位写入),语言层面上的两种方法只是语言定义的,本质上都是以二进制方式写入文件。
read:
将文件中的内容读入buf。
fd:目标文件的文件描述符;
buf:储存文件内容的指针;
count:从文件中读的字节数。
要注意read读到换行符也不会停,要控制好count。
如果读取成功且读到了文件末尾则返回0,如果没到文件末尾就返回读到的字节数;读取失败返回-1。
C语言中scanf系列函数和fread都是对该函数的封装。
实验
open、write、close:
intmain(){intfd=open("log.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);charput[]="hello open\n";intcount=5;while(count--){write(fd,put,strlen(put));}close(fd);return0;}open、read、close:
intmain(){intfd=open("log.txt",O_RDONLY);charbuffer[64];intn=0;intcount=0;while(1){n=read(fd,buffer,sizeof(buffer));if(n<=0)break;buffer[n]=0;//read不会自动设置'/0'printf("%s",buffer);count++;}printf("%d\n",count);//检查read读了多少次close(fd);return0;}为什么要封装
操作系统是有很多种的,Linux、windows、macos…每个操作系统内的系统调用都是不一样的,如果代码只用系统调用来写,会导致代码在A操作系统上可以运行而不能在B操作系统上运行。但是如果把每个操作系统的系统掉用都封装在库中,运行代码时再根据当前操作系统把其他操作系统的代码段截掉,就可以实现同一代码在不同操作系统下运行。
也就是说,对系统调用的封装是为了让代码有可移植性。
文件描述符2
在每一进程中,每个文件都有着自己的文件描述符,stdin/out/err也有。前面有提到过FILE是C语言库中定义的结构体类型,那我们不难猜出在该结构体中有着储存文件描述符的变量(_fileno)。
intmain(){intfd=open("log.txt",O_RDONLY);printf("%d\n",stdin->_fileno);printf("%d\n",stdout->_fileno);printf("%d\n",stderr->_fileno);printf("%d\n",fd);close(fd);return0;}
可以看到,stdin/out/err分别对应文件描述符0、1、2,而新打开的文件按顺序紧接其后。
操作系统层面的文件管理
打开文件时,OS会为每个文件生成一个结构体file(先描述),然后再用数据结构(如双链表)将这些结构体组织管理起来(再组织)。而PCB中有一个file_struct指针类型的变量files,它指向一个结构体变量,在变量中有一个struct file*类型的数组,它储存在该进程打开的文件地址,而文件描述符实际上就是该数组的下标。
因为同一个文件可以被多个进程同时打开,所以struct file和PCB一样同时存在于多个数据结构。
文件描述符3
当我们打开一个文件时,系统会为它分配可以分配的最小文件描述符。
比如说我关闭了stderr,这是文件描述符表中stderr的位置就会空出来,当系统遍历该表时,找到第一个空的位置就是stderr原本的位置,这使系统就会把文件描述符2分配给新打开的文件:
intmain(){intfd1=open("log.txt1",O_CREAT|O_WRONLY|O_TRUNC,0666);printf("%d\n",stdin->_fileno);printf("%d\n",stdout->_fileno);printf("%d\n",stderr->_fileno);printf("%d\n",fd1);printf("\n\n");close(stderr->_fileno);intfd2=open("log.txt2",O_CREAT|O_WRONLY|O_TRUNC,0666);printf("%d\n",stdin->_fileno);printf("%d\n",stdout->_fileno);printf("%d\n",fd1);printf("%d\n",fd2);return0;}重定向
在Linux中,我们可以使用>、>>、<这些符号来实现重定向,它实际上就是将原本要读取或写入数据的文件替换为了其他文件,那它底层是怎么做到的呢?
重定向的底层
比如说我要向stdout打印信息,而经过重定向后信息打印在了文件A:
stdout和A储存在文件描述符表中,当发生重定向后,文件A的地址会覆盖stdout的地址。
而即使发生了重定向,文件的文件描述符是不会变的,即stdout的fd还是1,A的还是3。但当你向函数传stdout或其文件描述符,进程在文件描述符中找到的却是取代了stdout位置的A,最后使本来要写入stdout内的信息被写入文件A。
那么stdout被覆盖后会被关闭吗?
其实当文件打开时OS创建的结构体内会有一个变量ref,用来计数当前文件被几个文件描述符中的指针指着,只有ref归0时,文件就会自动关闭。如这上面的stdout就会被自动关闭。要注意的是,当进程fork子进程后,父子进程共享文件描述符表,这时也会把子进程内的计数加上。
重定向函数
重定向函数有两个,分别为dup、dup2。
dup
intdup(intoldfd);dup() uses the lowest-numbered unused descriptor for the new descriptor.
dup会将oldfd对应的文件指针拷贝到文件描述表中下标最小且为空的位置,但原本文件的文件描述符不会改变,只不过是多了一个指针指向它。
如果成功它会返回拷贝到的新下标的位置,失败返回0。
dup2
intdup2(intoldfd,intnewfd);dup2() makes newfd be the copy of oldfd, closing newfd first if necessary, but note the following
dup2需要传入两个文件描述符oldfd和newfd,它会将文件描述符表中oldfd位置对应的指针拷贝给newfd对应的位置,但原本在这两个位置上的文件的文件描述符不会改变。原来在newfd位置上的文件不会关闭,如果需要得自行关闭。
要注意oldfd和newfd到底该传哪个文件描述符!
如果成功它会返回newfd,失败返回0。
intmain(){intfd=open("log.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);charput[]="hello dup\n";intn=dup2(fd,1);printf("%d %d\n",fd,n);printf("%s",put);close(fd);return0;}stdout、stderr
标准输出流和错误流都指的是显示器文件:
#include<cstdio>#include<iostream>intmain(){//往标准输出流输出printf("hello printf\n");std::cout<<"hello std::cout"<<std::endl;printf("\n");printf("\n");//往标准错误流输出perror("hello perror");std::cerr<<"hello std::cerr"<<std::endl;return0;}它们都打印在显示器文件。
重定向补充
当我们将上面代码的输出重定向写入log.txt时,会发现文件内只有本应往标准输出流输入的数据:
这是因为这个指令只默认重定向了标准输出流(1),它的完整写法应该是:
值得注意的是,当显示写文件描述符时,它和重定向符号之间不能有空格。
那如果我们想把往标准错误流输出的信息也打印进其他文件怎么办?
像这样,显示器文件没有打印任何东西,显然是成功将stdout/err重定向了,但奇怪的是log.txt文件中只有本来往标准错误流打印的信息。这是因为这条命令会两次打开log.txt,相当于这样:
intfd1=open("log.txt",..|TRUNC,...);dup2(fd1,1);close(fd1);intfd2=open("log.txt",..|TRUNC,...);dup2(fd2,2);close(fd2);所以这样是没法让两种信息全输入进同一文件的。
有两种写法可以做到:
1.第二次使用追加重定向:
这里也是open了两次,但第二次是追加选项,没有清空文件。
2.文件描述符复制重定向
相当于:
intfd1=open("log.txt",..|TRUNC,...);dup2(fd1,1);close(fd1);dup2(1,2);为何区分
经过上面重定向的演示我们可以知道,虽然stdout/err指的是同一文件,当可以通过重定向把它们分别写入不同文件,这样我们就可以更好的拿到错误信息。
四.理解"一切皆文件"
Linux中一切皆文件并不是说所有东西本质都是文件,而是把一部分设备和内核对象抽象成文件,让进程可以用统一的文件描述符和系统调用接口去访问它们;但这只是用户空间访问接口的抽象,不代表硬件本体是文件,也不能把它们都当成普通文件来用。
它是如何抽象的呢?拿硬件举例子:
当系统要打开硬件设备时,它会为要使用的设备用结构体device描述起来并用数据结构将它们组织管理起来。同时还会为他们创建file结构体,也会将它们组织管理起来,我们将这组织起来的结构称为VFS:虚拟文件系统。在每个file结构体中有一系列函数指针,它们用来指向对应硬件的接口(尽管不同硬件的接口实现大不相同,也用相同类型的指针指向),这样就PCB->文件描述符表->file结构体->接口这条链来对硬件进行操作。
这里的file结构体其实就是C语言版的多态,即使其内部成员相同,但函数实现不同。
五.缓冲区
什么是缓冲区
缓冲区就是内存的一段空间。
拿生活来举例子:
如果没有快递站会怎么样?快递到了你就只能第一时间去拿,如果你没空又得麻烦快递员等,这样对双方都不利。但有了快递站就解决了这个问题,无论你是否有时间,快递到了快递员直接将它寄存在快递站,你有时间去拿就好,这样都有利于对方。
在这个例子里,快递站就相当于缓冲区,你是操作系统,快递员是用户。
来看这个代码:
为什么前者没把库函数打印的信息写入文件,后者却可以?
这就是缓冲区的存在导致的,我们也不难推出缓冲区和库有关。
其实有两种缓冲区:用户层缓冲区和内核级缓冲区。
当我们用库函数如printf、fprintf、fwrite(观察这些函数的参数,不难推测出C语言层缓冲区在结构体FILE中)等将信息写入文件时,信息会先被拷贝进语言层缓冲区,然后在满足特定情况下语言层缓冲区将被刷新,信息被拷贝进文件内核缓冲区,最后文件内核缓冲区内的信息再被OS拷贝给文件,只有完成了这一系列操作,数据才被真正写入文件。
我们也可以看出,计算机内的数据流动本质上就是拷贝。
导致上面那种情况的原因就是数据在缓冲区中,要刷新时文件已经关闭了,无法再写入。
刷新缓冲区的情况
对于内核级缓冲区:
内核中的情况复杂多变,OS会根据实际情况刷新缓冲区,我们只需要将数据交给内核缓冲区(OS)就相当于交给文件(没关闭)了。
对于语言层缓冲区:
1.强制刷新;
使用函数强制刷新缓冲区,如fflush()。
2.进程退出时;
当进程退出时会自动刷新缓冲区。
3.刷新条件满足时;
3.1 立即刷新(无缓冲,写透模式);
3.2 缓存满了(全缓冲),该方法效率最高,普通文件一般都采用该形式;
3.3 行刷新(\n,行缓存),该条件只对显示器用:
来看看这个代码:
intmain(){//使用库函数printf("hello printf\n");fprintf(stdout,"hello fprintf\n");constchar*s="hello fwrite\n";fwrite(s,strlen(s),1,stdout);printf("\n");//使用系统调用constchar*put="hello write\n";write(1,put,strlen(put));intid=fork();if(id==0)exit(0);wait(NULL);return0;}将它重定向写入文件:
会发现用库函数打印的数据被拷贝了2份。语言层的缓存区是在用户空间的(FILE结构体),所以当父进程fork创建子进程后,父子进程将共享一块语言层缓冲区,但它们中任一进程要结束时,会使用fflush刷新缓冲区,这时就会触发写时拷贝,操作系统会将还没退出的进程的语言层缓冲区拷贝给要退出的进程,后退出的进程又会刷新一遍缓冲区。这就导致向文件写入了两份内容。
为什么要有缓冲区
系统调用是需要花费时间的,如果没有缓冲区,那你每一次输入都需要使用系统调用,这会花费大量时间。但如果有了缓冲区,你可以将多次输入的数据用一次系统调用写入文件,这大大减少的时间消耗。也就是说缓冲区可以提高使用者的效率。
六.模拟实现极简stdio.h
文件mystdio.h:
#include<stdio.h>#defineMAXBUFFER1024#defineNONE_FULSH0#defineLINE_FLUSH1#definePULL_FLUSH2typedefstructIO_FILE{intfileno;//文件描述符intflags;//读写状态charbuffer[MAXBUFFER];intbufferlen;intflush_method;//刷新方法}MYfile;MYfile*MYfopen(constchar*path,constchar*method);voidMYclose(MYfile*file);intMYfwrite(constvoid*ptr,MYfile*file,intcount);voidMYfflush(MYfile*file);文件mystdio.c:
#include"mystdio.h"#include<string.h>#include<unistd.h>#include<sys/stat.h>#include<sys/types.h>#include<fcntl.h>#include<stdlib.h>MYfile*BuyFile(intfd,intflag){MYfile*file=(MYfile*)malloc(sizeof(MYfile));if(!file){perror("error mlloc");returnNULL;}file->fileno=fd;file->flags=flag;memset(file->buffer,0,sizeof(file->buffer));file->bufferlen=0;file->flush_method=LINE_FLUSH;//默认行刷新returnfile;}MYfile*MYfopen(constchar*path,constchar*method){intflag=0;if(strcmp(method,"r")==0)flag=O_RDONLY;elseif(strcmp(method,"r+")==0)flag=O_RDONLY|O_WRONLY|O_TRUNC;elseif(strcmp(method,"w")==0)flag=O_CREAT|O_WRONLY|O_TRUNC;elseif(strcmp(method,"w+")==0)flag=O_CREAT|O_RDONLY|O_WRONLY|O_TRUNC;elseif(strcmp(method,"a")==0)flag=O_CREAT|O_WRONLY|O_APPEND;elseif(strcmp(method,"a+")==0)flag=O_CREAT|O_RDONLY|O_WRONLY|O_APPEND;elsereturnNULL;intfd=open(path,flag,0666);if(fd<0)returnNULL;returnBuyFile(fd,flag);}voidMYclose(MYfile*file){if(!file)return;MYfflush(file);close(file->fileno);free(file);}intMYfwrite(constvoid*ptr,MYfile*file,intcount){intold_count=count;while(count+file->bufferlen>MAXBUFFER){intsub=MAXBUFFER-file->bufferlen;memcpy(file->buffer+file->bufferlen,ptr,sub);ptr=(char*)ptr+sub;count-=sub;MYfflush(file);}memcpy(file->buffer+file->bufferlen,ptr,count);file->bufferlen+=count;if(file->flush_method==LINE_FLUSH&&file->buffer[file->bufferlen-1]=='\n')MYfflush(file);returnold_count;}voidMYfflush(MYfile*file){if(file->bufferlen==0)return;intn=write(file->fileno,file->buffer,file->bufferlen);(void)n;fsync(file->fileno);//将fd对于的内核级缓存区的内容强制刷新到文件中file->bufferlen=0;}