目录
1.前情提要
文件在磁盘里
磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的
磁盘是外设(即是输出设备也是输入设备)
磁盘上的文件 本质是对文件的所有操作,都是对外设的输入和输出简称IO
----------------------------------------------
Linux 下一切皆文件(键盘、显示器、网卡、磁盘 …… 这些都是抽象化的过程)
----------------------------------------------
对于0KB的空文件是占用磁盘空间的
文件是文件属性(元数据)和文件内容的集合(文件=属性(元数据)+内容)
所有的文件操作本质是文件内容操作和文件属性操作
访问文件之前都得先打开(即文件必须被加载到内存中)。修改文件是通过执行代码的方式修改。
打开文件的,是包含有类似fopen代码的进程。且可以打开多个文件
这些文件除了我们自己的写入写出打开关闭,像是缓冲区,刷新,状态变更这些操作都是要做的,但我们不用做,都是os在做,这意味着,这些被打开的文件也需要被os管理。怎么管理?先描述再组织。
----------------------------------------------
对文件的操作本质是进程对文件的操作
磁盘的管理者是操作系统
文件的读写本质不是通过C语言/C++的库函数来操作的(这些库函数只是为用户提供方便),而
是通过文件相关的系统调用接口来实现的----------------------------------------------
删除文件实际上只是删除文件的目录项,文件的数据以及inode并不会立即被删除,因此若进程已经打开文件,文件被删除时,并不会影响进程的操作,因为进程已经具备文件的描述信息
文件内容的修改是直接反馈至磁盘文件系统中的,因此当文件内容被修改,其他进程因为也是针对磁盘数据的操作,因此可以立即感知到
----------------------------------------------
在命令的重定向中, >表示冲定性,0表示标准输入,1表示标准输出,2表示标准错误
如果需要将标准输出和标准错误输出重定向至文件demo.log;
比较典型的方式是:bash demo.sh 1>demo.log 2>&1
先将标准输出重定向到demo.log文件,然后将标准错误重定向到标准输出(这时候的标准输出已经是指向文件了,所以也就是将标准错误重定向到文件)
bash中,需要将脚本demo.sh的标准输出和标准错误输出重定向至文件demo.log bash demo.sh &>demo.log command &> file 表示将标准输出stdout和标准错误输出stderr重定向至指定的文件file中。 bash demo.sh >&demo.log 跟上一样 bash demo.sh >demo.log 2>&1 比较典型的写法,将标准输出和标准错误都重定向到文件, >demo.log是一种把前边的标准输出1忽略的写法 bash demo.sh 2>demo.log 1>demo.log 比较直观的一种写法,不秀技,直观的将标准输入和标准错误分别重定向到文件
2.C语言文件操作回顾
更多的一些文件接口,可以参考C语言文件操作:从基础到高级-优快云博客
首先,头文件是stdio.h
FILE *fp=fopen("./test.txt","w"); 第一个参数是文件的路径(相对、绝对,相对是以当前进程的工作目录为基础) 第二个参数打开方式,分r,r+,w,w+,a,a+ r是只读,r+是读写,文件不存在打开失败,w是写, w+是读写,文件不存在先创建文件,a是文件末尾追加,文件不存在先创建 a+是追加和读,读是从文件起点开始,但追加仍旧是末尾。 b是二进制,b可以跟其他组合。 以w方式打开文件,文件内容会先被自动清空。fclose(fp) 关闭文件 为了良好习惯,文件记得关闭,免得占用内存。
![]()
linux的重定向,> 文件名,是以w的形式打开的,这也是为什么我们每次重定向后,文件内容不是被追加,而是完全全新的内容,同理追加重定向>>,就是a形式打开。
这也是linux中,当我们想清空文件内容的时候,只要>文件名即可。
前面我们讲进程的时候,说了/proc/pid文件中,会有相应进程的信息,比如cwd和exe这两个符号链接,当可执行程序变成进程,exe指向的是可执行程序的路径,cwd指向的是可执行程序所处的目录的路径。依靠cwd,进程在创建文件之类的时候,才可以做到在当前目录下创建文件。
而更改工作目录也就是cwd,其实我上个文章写自定义hsell的时候就有用,就是chdir函数(c语言的)。
注意,写入的时候不要考虑\0,因为c语言规定字符串结尾是\0,但其他语言不一定,所以文件不要写入\0。
写入完之后,文件指针会指向当前写入的最后一个字符之后,如果要接上read,需要lseek(fd,0,SEEK_SET),把文件指针放回文件开头。
2.1流
#include <stdio.h> extern FILE *stdin; extern FILE*stdout; extern FILE *stderr;c语言默认会打开这3个文件流。
因为大多数c程序,最基础就是需要从键盘读取数据,输出结果到显示器,输出错误信息到显示器,而键盘和显示器显然也是文件,根据前面文件操作,就是要打开相应的文件,做读取和写入的工作。所以c语言默认帮忙打开了这些文件。
形象的理解就是
extern FILE *stdin=fopen("键盘文件路径","r"); extern FILE*stdout=fopen("显示器文件路径","w"); extern FILE *stderr=fopen("显示器文件路径","w");stdin-标准输入流,大多数环境中支持从键盘输入数据;
stdout-标准输出流,大多数环境中支持输出至显示器;
stderr-标准错误流,大多数环境中输出到显示器界面
基于上面的内容,像是打印内容到显示器,c语言就可以这样
const char *msg = "hello fwrite\n"; fwrite(msg,,strlen(msg),l,stdout); printf("hello printf\n"); fprintf(stdout, "hello fprintf\n"); fputs("hello fputs\n",stdout);输入的时候,fsacnf,scanf都可以收到键盘,stdin
3.系统文件I/O
我们知道os才是硬件的管理者,程序不允许绕过os对硬件进行读写,也就是说,除了上层语言(比如c语言)提供的文件接口之外,os必然也要提供相应的系统调用接口供上层语言使用。
而fopen,fwrite等c语言提供的文件接口,可以理解为对系统调用的open,write等接口的二次封装。
3.1传递标志位(flags)
#include <stdio.h> #define ONE 1 //0000 0001 #define TWO 2 //0000 0010 #define THREE 4 //0000 0100 void func(int flags){ if (flags & ONE) printf("flags has ONE! "); if (flags & TWO) printf("flags has TWO! "); if (flags & THREE) printf("flags has THREE!"); printf("\n"); } int main() { func(ONE); func(THREE); func(ONE | TWO); func(ONE | THREE | TWO); return 0; }
3.2系统调用
3.2.1open
#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int open(const char *pathname, int flags); int open(const char *pathname, int flags, mode_t mode); pathname:要打开或创建的目标文件 flags:打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行"或"运算,构成 flags 参数: O_RDONLY:只读打开 O_WRONLY:只写打开 O_RDWR:读,写打开 这三个常量,必须指定一个且只能指定一个 O_CREAT:若文件不存在,则创建它。需要使用mode选项,来指明新文件的访 问权限 O_APPEND:追加写 以上的参数(不止,man手册里还可以看到别的)本质都是宏, 是某个数字的宏,且这些数字在比特位上基本只有一位是1,其他都是0,且互相之间1的位置也是错位的。 返回值: 成功:新打开的文件描述符 失败:-1open作用很简单,就是打开或创建文件。
flags的用法,前面有参考的。
这个mode,其实就是文件的权限。比如当我们写文件,并且给了O_CREAT,但不传mode,这时候创建的文件权限是乱码的,会有s什么的。这也是为什么linux中改变文件权限的指令是chmod。
注意,每个进程都有自己的umask,默认是当前用户的,通过umask函数可以临时改变当前进程的umask(会影响后续创建的子进程)
关于文件描述符,后面会讲,可以理解为文件指针(暂时,具体后面讲实际关系)
3.2.2close函数
没什么好说的,就是关闭文件,close(文件描述符)
3.2.3write
第一个是文件描述符,第二个是写入数据的来源,第三个是写入数据的字节数
这样就能实现写入,但是,跟fopen的w还是有点不同的,fopen的w方式是会先清空文件内容的,对应到这里就是还有个选项要加
如果是追加,就是加O_APPEND,不要加O_TRUNC
上面的内容,可以理解为模拟c的fopen。
3.2.4read
都是差不多的内容。
3.2.5其他
剩下的可以自行了解
3.3文件描述符
3.3.1概念
从前面的内容,我们可以发现,文件描述符fd是整数,且-1是打开失败。
文件描述符是从0开始的,c语言默认打开的3个流,stdin,stdout,stderror,对应的就是0,1,2。而我们在程序中用open打开的文件,文件描述符是从3开始增长的。
c语言的FILE类型,就是c标准库对fd的一个封装,是一个结构体,里面包含了fd。同理,前面的那些代码,把fd的参数直接写成0,1,2,就可以指定像键盘、显示器等输入输出。
关于为什么c语言要特意封装fd,封装关于文件的系统调用,其实之前的文章里有说,我这里简单重复下。
不同的系统有不同的系统调用(可能函数名一样,参数却不太一样,也可能函数名都不一样),比如我这里写的关于linux下的系统调用接口,跟windows又有些区别。如果我们的c语言代码直接调用系统接口的话,那么代码的移植性、跨平台性就不行(同样的代码在别的系统下不能运行)。所以不同系统下,同样是c语言,都有不一样的标准库,这里的不一样指的是内部封装的系统调用部分。这样使用者不需要考虑不同平台的系统调用,直接调用c语言的文件接口即可,具体要调用什么系统接口,由不同平台的c语言标准库来管理。
不同的语言,封装出来的文件接口都不一样,用途可能相似,但函数名或参数都有些微的区别,但不管是什么语言,只要是对硬件(比如这里的磁盘中的文件)操作,那么必然都需要封装系统调用。
下面是一张网图,是展示了一部分结构
图中的files_struct也是个结构体类型。
其中file对象就是os用来管理被打开文件的结构体,这些file对象之间也是采用链表进行连接(这是os直接管理的方式,图中的数组是对于某个进程的pcb而言,其关系就跟我们前面讲进程的时候,同一个pcb可以被链入不同的队列,这里的file对象也是如此)。
file对象中存储着间接或直接指向文件的属性(文件的属性)、方法集、缓冲区(文件的内容)的指针。也直接存储着部分文件属性,如权限mode,flag,pos,struct file *next等等。
因此,比如进程1号运行open系统调用,当代码执行之后,如果这个文件没被打开(没有载入内存),那么os会从磁盘将文件载入内存,用file对象封装相关属性、内容、方法,链入链表被os管理,1号的files_struct对象中的数组会在最后有效数据的下一位填入该file对象的地址,最后再把数组下标返回给进程(也就是fd)。
当我们执行write系统调用的时候,根据传入的fd,进程会从fd_array数组找到相应file对象的地址,访问之后,将write的另两个参数决定的,要写入的内容写入内存中该文件的缓冲区,最后由os将缓冲区的内容写入磁盘。
read也是类似,将缓存区的内容传入参数的数组里,缓冲区没内容,就让os从磁盘重新写入缓冲区。
对于close也是类似,就是通过fd找到对应file对象,然后free掉释放资源。
从上面可以总结一句,那就是fd,即文件描述符,本质就是数组(文件描述符表)的下标。
3.3.2fd分配规则
一句话:最小的没有被使用的数组下标,会分配给最新打开的文件。
比如我们打开一个新文件,那么就会是3(0,1,2被占了);
比如我们先close(0)先关闭了stdin的流,那么这时候我们再open打开一个新文件,此时该文件对应的fd就是0;同理关2,开新,得2;关0,2,开新,得0。
3.3.3实现重定向
我们要知道,printf内部系统调用write,fd默认是stdout指针的fileno值 1。
当我们试图先close(1)之后,再open,再printf。这时候printf依旧是默认传stdout的fileno值1为fd给write,write只会对文件描述符表的下标1位置对应的文件执行写入。
而我们前面open之后,此时fd为1对应的文件已经变成了open所打开文件,所以这时候我们可以发现printf并没有向屏幕输出,而是将内容输入到了open所打开的文件中。
依靠上面的发现,不难实现重定向,本质上就是将默认的fd所指向的文件,通过改变地址值,从而指向另一个文件。
上面就是一个很简单的输入重定向,本来是从fd0指向的是键盘,也就是从键盘接受数据,此时fd0指向了test.txt文件,变成了从test.txt文件接受数据,并存入sample变量,再printf输出到屏幕。
这个就是一个简单的输出重定向。
追加重定向,就是把上面open的O_TRUNC换成O_APPEND即可。
3.3.4.dup2系统调用
上面的重定向设计是利用分配规则的。
但我们其实可以选择直接将新的fd对应的指针内容拷贝到0、1、2中(这样0,1,2就指向新打开的文件了),从而实现重定向。
因此os也提供了相应的系统调用,即dup2
注意,这个函数,是把newfd和oldfd所存的指针内容都变成oldfd所存指针内容。
所以如果我们要用这个函数实现重定向,并且假设新打开的文件fd是3,我们要实现输出重定向,那么此时,传参时oldfd就是3,newfd就是1。
注意这样操作不会导致fd3失效,我们如果又open个文件,此时这个文件被分配的fd是4。
3.3.5将重定向功能写入自己写的shell程序
#include<stdio.h> #include<stdlib.h> #include<string.h> #include<unistd.h> #include<sys/types.h> #include<sys/wait.h> #include<ctype.h> #include<sys/stat.h> #include<fcntl.h> #define SIZE 1024 //命令最大长度 #define MAX_ARGV 64 //命令可以分割的最大长度 #define SEP " " //命令分割的依据 #define STREND '\0' //字符串结尾 char *argv[MAX_ARGV]; //用于存放切割后的命令 char pwd[SIZE]; //用于存储当前路径 char env[SIZE]; //用于导入环境变量 int lastcode=0; //存储最近一次子进程的退出码 //重定向 #define NoneRedir -1 //表示不同的状态 #define StdinRedir 0 #define StdoutRedir 1 #define AppendRedir 2 int redir_type = NoneRedir; //重定向的类型,默认不需要重定向 char * filename=NULL; //表示重定向的文件 #define IgnSpace(buf,pos) do {while(isspace(buf[pos])) pos++; }while(0) //注意,这里不加;是因为后面调用的时候适应语法,这是个跳过空格的宏函数 //获取环境变量中需要的内容,比如主机名、用户名、当前工作目录 const char* GetEnv(const char *str){ char *EnvValue=getenv(str); if(EnvValue){ //有获取到 return EnvValue; } else{ return "None"; } } //把交互部分封装起来 int Interactive(char *out,int size){ //输出提示符 printf("[%s@%s %s]$ ",GetEnv("USER"),GetEnv("HOSTNAME"),GetEnv("PWD")); //获取命令字符串,如:ls -a -d,获取一行,fgets会自动加\0,也会读到回车 fgets(out,size,stdin); //把回车去掉,另外fgets可以保证最少会收到一个回车符,不会是空串,可以放心访问 out[strlen(out)-1]='\0'; return strlen(out); } void CheckRedir(char in[]) { redir_type=NoneRedir; filename=NULL; int pos=strlen(in)-1;//最后一个有效字符下标,检查从末尾开始往前查 while(pos>=0){ //这里不需要担心留有空格,导致左半部分命令不完整,下面的strtok是会抛弃空串的。 //cat test >> log.txt //cat test > log.txt //cat < log.txt if(in[pos]=='>'){ if(in[pos-1]=='>'){ redir_type=AppendRedir; in[pos-1]=STREND; pos++; IgnSpace(in,pos); filename=in+pos; break; } else{ redir_type=StdoutRedir; in[pos++]=STREND; IgnSpace(in,pos); filename=in+pos; break; } }else if(in[pos]=='<') { redir_type=StdinRedir; in[pos++]=STREND; IgnSpace(in,pos); filename=in+pos; break; }else{ pos--; } } } void Split(char in[]){ CheckRedir(in);//检查是否有重定向 int i=0; argv[i++]=strtok(in,SEP); //对历史字符串,其余字符要传NULL,另外最后一定要赋予NULL,该数组是用于exec的 while(argv[i++]=strtok(NULL,SEP));//当不能再切了之后,就会返回NULL,NULL赋值之后,while跳出 if(strcmp(argv[0],"ls")==0){ argv[i-1]=(char*)"--color=auto"; argv[i]=NULL; }//linux的shell,是把ls作为ls --color=auto的别名了。 } void Execute(){ pid_t id=fork(); if(id==0){ //重定向处理 int fd=-1; if(redir_type==StdinRedir){ fd=open(filename,O_RDONLY); if(fd==-1){ perror("open faild\n"); return; } dup2(fd,0); }else if(redir_type==StdoutRedir){ fd=open(filename,O_CREAT|O_WRONLY|O_TRUNC,0666); dup2(fd,1); if(fd==-1){ perror("open faild\n"); return; } }else if(redir_type==AppendRedir){ fd=open(filename,O_CREAT|O_WRONLY|O_APPEND); dup2(fd,1); if(fd==-1){ perror("open faild\n"); return; } }else{ //不用做任何事 } //注意,进程替换只会替换程序和数据,但是对于pcb的一些信息都是继承下来的 //也就是说,如文件描述符表也是会继承下来的。 //子进程执行命令 execvp(argv[0],argv); exit(0); } int status=0; pid_t rid=waitpid(id,&status,0); if(rid==id)lastcode=WEXITSTATUS(status); } int BuildinCmd(){ int ret=0; //如果是内键命令,返回1,否则0 if(strcmp("cd",argv[0])==0){ ret=1; char * target=argv[1]; //cd xxx or cd //同样对应target要么是个路径,要么就是个NULL, if(!target)target=(char*)GetEnv("HOME"); chdir(target);//改变工作目录 char tmp[1024]; getcwd(tmp,1024); snprintf(pwd,SIZE,"PWD=%s",tmp);//sn就是把printf的内容传入n大小的字符串里,s就是不限制大小。 //这里就是SIZE大小的pwd字符串。 putenv(pwd);//chdir不会更改环境变量,需要我们手动更改 } else if(strcmp("export",argv[0])==0){ ret=1; //if(argv[1])putenv(argv[1]); 不能这样写,因为argv[1]存的是指针 //,指向的commandline,而commandline每次循环都会被新的命令字符串覆盖,导致原先更改的环境变量内容被覆盖 if(argv[1]){ strcpy(env,argv[1]); putenv(env); }//这样写其实也有问题的,我env是一维数组, //每次都是维护一个环境变量,只要我多次export,旧的环境变量就没了 //,所以,其实是要维护一整个环境变量表的,但这里为了方便,没写 //其实还可以写个函数从父shell获取环境变量,写入到我们的二维env表中 //但是linux的shell是从配置文件读取的,我这个想法也只是取个巧 //这也是export的底层,就是更改shell维护的环境变量表 } else if(strcmp("echo",argv[0])==0){ ret=1; if(argv[1]==NULL){ printf("\n"); } else { if(argv[1][0]=='$'){ if(argv[1][1]=='?'){ printf("%d\n",lastcode); lastcode=0; } else{ char *e=getenv(&argv[1][1]); if(e)printf("%s\n",e); } } else{ printf("%s\n",argv[1]); } } } return ret; } int main(){ while(1){ //交互:输出提示符,获取命令字符串 char commandline[SIZE]; int n=Interactive(commandline,SIZE); if(n==0)continue; //对命令字符串进行切割 Split(commandline); //处理内键命令,如cd等 n=BuildinCmd(); if(n)continue; //执行命令 Execute(); } return 0; }
4.linux下一切皆文件
文字还是太苍白了,这是一张网络搜集的图片。
里面的内容(稍作修改),file是包含了file_operations的一个对象指针的。
可以发现,不管设备的读写操作差异有多大,对于os来说,只要访问file对象的读写函数指针并传参即可,具体怎么读怎么写,完全依赖于对应设备的驱动程序提供的读写方法。注意,大多数操作对于硬件来说,都可以划分到读和写两个操作之中。当然实际进入源代码会发现还有很多其他函数。
之所以linux可以视一切皆文件,就是依赖这个设计,从而屏蔽了硬件的差异,这一层设计也叫做vfs。这个设计也可以看作用c语言设计了对象(有成员属性,有成员方法)。再加上同样的结构体file,调用同一个成员函数(读写),却可以实现不同的读写方法,这也是多态思想的一种实现。
struct file { ... } struct inode *f_inode; /* cached value */ const struct file_operations *f_op; ... atomic_long_t f_count; // 表示打开文件的引用计数,如果有多个文件指针指向它,就会增加f_count的值。 unsigned int f_flags; // 表示打开文件的权限 fmode_t f_mode; // 设置对文件的访问模式,例如:只读、只写等。所有的标志在头文件<fcntl.h> 中定义 loff_t f_pos; // 表示当前读写文件的位置 ... } __attribute__((aligned(4))); /* lest something weird decides that 2 is OK */struct file_operations { struct module *owner; //指向拥有该模块的指针; loff_t (*llseek) (struct file *, loff_t, int); //llseek 方法用作改变文件中的当前读/写位置,并且新位置作为(正的)返回值。 ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); //用来从设备中获取数据。在这个位置的一个空指针导致 read 系统调用以 - EINVAL("Invalid argument") 失败。一个非负返回值代表了成功读取的字节数(返回值是一个 "signed size" 类型,常常是目标平台本地的整数类型)。 ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); //发送数据给设备。如果 NULL, -EINVAL 返回给调用 write 系统调用的程序。如果非负,返回值代表成功写的字节数。 ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t); //初始化一个异步读 -- 可能在函数返回前不结束的读操作。 ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t); //初始化设备上的一个异步写。 int (*readdir) (struct file *, void *, filldir_t); //对于设备文件这个成员应当为 NULL;它用来读取目录,并且仅对**文件系统**有用。 unsigned int (*poll) (struct file *, struct poll_table_struct *); int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long); long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); long (*compat_ioctl) (struct file *, unsigned int, unsigned long); int (*mmap) (struct file *, struct vm_area_struct *); //mmap 用来请求将设备内存映射到进程的地址空间。如果这个方法是 NULL,mmap 系统调用返回 -ENODEV。 int (*open) (struct inode *, struct file *); //打开一个文件 int (*flush) (struct file *, fl_owner_t id); //flush 操作在进程关闭后的设备文件描述符的拷贝时调用; int (*release) (struct inode *, struct file *); //在文件结构被释放时引用这个操作。如同 open, release 可以为 NULL。 int (*fsync) (struct file *, struct dentry *, int datasync); //用户调用来刷新任何进程的数据。 int (*aio_fsync) (struct kiocb *, int datasync); int (*fasync) (int, struct file *, int); int (*lock) (struct file *, int, struct file_lock *); //lock 方法用来实现文件加载;加载时常规定文件是必不可少的特性,但是设备驱动几乎从不实现它。 ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int); unsigned long (*get_unmapped_area) (struct file *, unsigned long, unsigned long, unsigned long); int (*check_flags) (int); int (*flock) (struct file *, int, struct file_lock *); ssize_t (*splice_write) (struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int); ssize_t (*splice_read) (struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int); int (*setlease) (struct file *, long, struct file_lock **); };
5.缓冲区
5.1什么是缓冲区
缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
我们这只考虑用户级缓冲区,内核级的缓冲区暂不考虑。进程中的file结构体中的缓冲区就是内核级缓冲区。而c语言提供的,FILE类型,里面也有相应的缓冲区,这就是用户级缓冲区
用户级缓冲区基本就是语言自带的缓冲区,比如c语言。这里以c语言缓冲区为例。
5.2为什么要引入缓冲区机制
读写文件时,如果不会开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备占用CPU,解放出CPU,使其能够高效率工作。
不难发现,这个操作核心就是空间换时间,因为系统调用是非常耗时间和空间的,一次系统调用就完成100次系统调用的任务是最高效的,调用一次也是调用100次也是调用,那当然是调一次就完成所有任务就最好。
我们平时的c语言调用fwrite等非系统调用接口,实现写入操作的时候,是先把数据写入了语言自带的缓冲区(在内存),然后在合适的时间(具体看下面的缓冲类型)刷新语言缓冲区,刷新缓冲区会执行系统调用,以此来减少系统调用的次数,(内核也有自己的一套缓冲区以及刷新写入磁盘的逻辑,这里可以认为在 语言的缓冲区刷新之后就会写入内核的缓冲区 之前,对语言来说已经完成了写入磁盘的工作)
c语言的文件接口不需要频繁调用系统接口,在没触发缓冲区刷新条件前,只需要将写入的数据拷贝到语言的缓冲区中即可。节省了调用这些文件接口的时候
像我们调用read等接口的时候,os会提前将文件内容读取到内核里的缓冲区,具体操作由os决定,os只保证最后可以将内容反馈给上层,当然如果读取的时候正好缓冲区没有内容,那可能就会发生read阻塞。
5.3缓冲类型
标准I/0提供了3种类型的缓冲区。
全缓冲区:这种缓冲方式要求填满整个缓冲区后才进行I/0系统调用操作。对于磁盘文件的操作通常使用全缓冲的方式访问。
行缓冲区:在行缓冲情况下,当在输入和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作。当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。因为标准I/O库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行1/0系统调用操作,默认行缓冲区的大小为1024。
无缓冲区:无缓冲区是指标准l/O库不对字符进行缓存,直接调用系统调用。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。
除了上述列举的默认刷新方式,下列特殊情况也会引发缓冲区的刷新:
1.缓冲区满时;
2.执行flush语句(即强制刷新);
3.进程结束;
5.4FILE
前面讲了很久语言的缓冲区,实际存在哪里呢?
我们知道file结构体内部是有缓冲区的,所以所谓的语言自带的缓冲区,就是这个缓冲区。有几个文件被打开,就有几个对应的缓冲区(多次但不同权限打开同一个文件,系统产生多个struct file,但共用同一个缓冲区和通一个属性(比如inode相同),因此又读又写会出现错乱,)。
我们的fwrite,fputs等写入数据的时候,就是将数据拷贝到这个file结构体内部维护的缓冲区里,由缓冲区自行根据条件决定是否刷新
可以简单看下file的源码。
父子进程的struct files_struct都是单独开辟的,只是里面的指针内容会一样罢了。
注意,父子进程只会拷贝进程的内容,struct file是由os管理的,不会重复拷贝。(上面两个合起来,就是浅拷贝)
typedef struct _IO_FILE FILE; 在/usr/include/stdio.h 在/usr/include/libio.h 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 -I0_read_ptr and _I0_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 θ 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]; /*xchar*save_gptr;char*-save_egptr; */ _IO_lock_t *_lock; #ifdef _IO_USE_OLD_IO_FILE };
为了验证这个缓冲区,我们可以看下面的代码
我们会发现,fwrite和fprintf输出到屏幕和重定向到文件都没问题,但write却只在输出屏幕的时候出现了,没有输出到文件
首先,我们要明白,输出到显示器的时候,刷新策略是行刷新,碰到\n会刷新,所以3个都会刷新到内核中,然后再出现在显示器中。
其次,当我们使用重定向到文件的时候,刷新策略变成了全缓冲,而缓冲区是比较大的,“fprintf”和“fwrite”显然填不满,也就是说不会马上被刷新到内核,而是放到用户级缓冲区中,当fork之后,无论是父还是子,退出进程的时候,都要刷新缓冲区,而刷新缓冲区就是一种对数据的修改,这时候就会触发写时拷贝,所以这个缓冲区的内容就出现了2份,父子各一份,然后两边各自退出进程,这时候各自刷新自己的缓冲区,这就使得文件里被写入了2份数据“fprintf”和“fwrite”。
通过这个实验也可以发现,write是直接将数据写入了内核中,不会放在缓冲区,自然也就不会因为写时拷贝导致写入2份数据,而fprintf和fwrite是c语言的接口,有语言自带的用户级缓冲区,会触发写时拷贝(file结构体内的缓冲区就是个数据,file又是被层层的链接,一直链接到进程,那么父子进程共享的数据自然也包括了这个缓冲区的数据)。
我们平时说的printf格式化输出,其实就是将数字、字符串,以一个个字符的形式写入到输出缓冲区连接起来(比如printf("%d,%s",a,s) ),而scanf就是将键盘输入的字符先存在缓冲区,然后以格式符的要求将数据写入相应的变量中。
6.简单设计一下libc库
因为有多个文件,我这里就不黏贴文件了,我把gitee的链接贴出来。





















文字还是太苍白了,这是一张网络搜集的图片。

2463

被折叠的 条评论
为什么被折叠?



