重新谈论文件
我们在学习语言的时候都学习过文件操作,但是文件操作的本质我们并不知道,在我们得出结论之前我们需要先知道几条结论:
- 空文件,也要在磁盘占据空间
- 文件 = 内容 + 属性
- 文件操作 = 对内容的操作 or 对属性的操作 or 对内容和属性的操作
- 标定一个文件,必须使用:文件路径 + 文件名(为了保证文件的唯一性)
- 如果没有标定指定的文件路径,那么默认是在当前路径(此处的当前路径是指进程的当前路径)下进行文件访问的
- 当我们使用例如:fopen,fclose,fread,fwrite等接口写出源文件,编译形成二进制可执行程序程序之后,没执行这个可执行程序时,文件对应的操作有没有被执行呢?----没有!所以:对文件操作的本质是:进程对文件的操作!
- 一个文件如果没有被打开,可以直接进行文件访问吗?------不能!一个文件要被访问的话,就必须要先被打开!
- 我们思考一下第7条,文件是保存在磁盘中的,那么是不是磁盘中所有的文件都被打开了呢?——不是!磁盘中分为a.被打开的文件 b.没有被打开的文件,而我们目前文件操作所使用的则是a.被打开的文件,而 b.没有被打开的文件等到后面文件系统的时候我们再讨论。而文件要被打开需要两个条件:1.用户进程的调度2.操作系统的管理(文件存储在硬盘中,外设当然需要操作系统统一管理)
所以,综上所述,我们可以得出结论:文件操作的本质就是进程与被打开文件之间的关系!
重新谈论文件操作
我们在学习c,c++语言的时候,这两种语言都有自己各自的文件操作接口,而其他语言,例如:Java,python,php,go,shell各种语言都有自己的文件操作接口,每种语言的文件操作接口都不一样,这也就导致了我们学习成本会很大。
我们刚刚讨论过,文件存储在磁盘中,磁盘是硬件,我们想访问磁盘的话不可能绕过OS,也就是说系统必然会提供系统的文件操作接口。那么也就是说,其他语言的文件操作接口,实际上都是对系统文件调用接口的封装。也就是说,无论上层语言怎么变化,底层的系统调用接口是不变的,因此,我们要学习系统调用接口。
复习一下C语言的文件操作
#include<stdio.h>
#include<string.h>
#define FILE_NAME "log.txt"
int main()
{
FILE* fp=fopen(FILE_NAME,"w");
if(fp==NULL)
{
perror("fopen");
return 1;
}
int cnt=5;
while(cnt)
{
fprintf(fp,"%s:%d\n","hello world",cnt--);
}
fclose(fp);
return 0;
}
上面便是一段c语言关于文件操作的代码,其以向名为log.txt的文件中写入了五行文字,这段代码很简单,不做过多解释。
C语言打开的方式有很多:
- r(只读方式,不存在即出错)
- w(只写方式,不存在即创建)
- r+(读写方式,不存在即出错)
- w+(读写方式,不存在即创建)
- a(<append>追加方式只写,不存在即出错)
- a+(追加方式读写,不存在即出错)
- b(二进制打开文件,在其他方式后+,例如r+b,w+b,a+b等)
1 #include<stdio.h>
2 #include<string.h>
3
4 #define FILE_NAME "log.txt"
5
6 int main()
7 {
8 FILE* fp=fopen(FILE_NAME,"r");
9 if(fp==NULL)
10 {
11 perror("fopen");
12 return 1;
13 }
14 //int cnt=5;
15 //while(cnt)
16 //{
17 // fprintf(fp,"%s:%d\n","hello world",cnt--);
18 //}
19 char buffer[64]={0};
20 while(fgets(buffer,sizeof(buffer)-1,fp)!=NULL)
21 {
22 buffer[strlen(buffer)-1]=0;//将最后一个\n改成\0,因为puts其实相当printf("%s\n",s),
//前提是s是C风格字符串,最后以'\0'结尾。若不将最后的\n改为
//\0,打印的出来的就会多空一行
23 puts(buffer);
24 }
25 fclose(fp);
26 return 0;
27 }
结果为:
学习系统的文件调用接口
c语言如何通过bit位传递选项
在学习具体接口之前,我们要学习一个前提知识:
由于c语言没有bool类型,我们c语言传标记位的话就是用int类型来传,但是一个int类型有32个bit位,用int来传难免有些浪费,故此我们可以使用位运算来通过比特位传递选项,来解决这种浪费问题。如何利用位运算来用bit位传递选项呢?也就是说一个bit位,一个选项的bit位位置是不能重复的,每个选项只有一个bit位能为1,其余位全为0,且不能与其他选项重复。如下面的例子:
1 #include<stdio.h>
2 #include<string.h>
3
4 #define FILE_NAME "log.txt"
5 #define ONE 1
6 #define TWO 1<<1
7 #define THREE 1<<2
8 #define FOUR 1<<3
9
10 void print(int flags)
11 {
12 if(flags & ONE)
13 printf("ONE\n");
14 if(flags & TWO)
15 printf("TWO\n");
16 if(flags & THREE)
17 printf("THREE\n");
18 if(flags & FOUR)
19 printf("FOUR\n");
20 }
21
22 int main()
23 {
24 print(ONE);
25 print(ONE|TWO);
26 print(ONE|TWO|THREE);
27 print(FOUR);
28 return 0;
29 }
结果为:
系统文件调用接口
下面我们学习系统文件调用接口:
open:
#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);//文件不存在
我们主要用有三个参数的open函数,第一个参数pathname表示文件名,第二个参数flags表示标记位,第三个参数mode表示文件的权限。
- 第一个参数pathname:可以添加文件路径,也可以不添加,如果不添加的话默认在当前路径下查找文件
- 第二个参数flags:用户向其中传入宏,包括O_RDONLY(read only 只读),O_WRONLY(write only 只写),O_CREAT(create 若没有此文件即创建),O_TRUNC(truncate 截断,即清空原有文件内容重新输入),O_APPEND(append 在文件后追加输入)
- 第三个参数mode:若创建文件,文件权限是什么
- 返回值:返回值是int类型,本质是文件描述符,我们后面再了解
下面我们写一段代码来演示:
结果为:
我们发现我们用只写方式打开已有log.txt后,该文件内部的内容没有变化,并未把该文件内部内容清空。若我们把log.txt文件删除之后,再执行上面的程序的话,我们会发现,在文件不存在的情况下,上面的代码不会创建新文件:
我们c语言的文件操作接口fopen函数在只读(“w”)时的作用是,清空原文件中的内容,并且若文件不存在会创建新文件。如果我们系统的open()函数想达到相同的目的,就需要给他的第二个参数传入两个宏,分别是O_CREAT(若文件不存在,创建新文件),O_TRUNC(文件存在的话,清空原文件,重新输入新数据)。这时,O_CREAT |O_TRUNC|O_WRONLY就是我们open的第二个参数。
这次我们运行上面的代码,可以看到创建出了新文件。
但是,我们看log.txt是红色的,并且他的权限都是乱码,这是我们没有给他加上第三个参数mode的原因,下面我们给他加上权限。
1 #include<stdio.h>
2 #include<string.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5 #include<sys/stat.h>
6 #include<fcntl.h>
7
8 int main()
9 {
10 int fd = open("log.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
11 close(fd);
12 return 0;
13 }
系统默认目录文件权限是777,普通文件权限是666,而文件的实际权限是由设置的权限按位与非上umask得到的,即(mode&(~umask)),而系统默认umask值为002,所以我们最终得到的普通文件权限是664。如果我们就想让我们创建出来的文件实际的权限为我们设置的权限的话,可以在前面通过umask函数,加上umask(0),将我们本进程的umask值设置为零(这里的操作不影响系统命令行的umask值),这里我们就不做演示了。
其实看到这里,我们就可以得出一个结论,其实我们c语言中fopen("文件名","w")的本质就是调用系统接口open("文件名",O_WRONLY|O_CREAT|O_TRUNC,0666)。
write
//write-write to a file descriptor
#include<unistd.h>
ssize_t write(int fd,const void* buf,size_t count);
write函数共有三个参数,第一个参数fd指文件描述符,也就是指明将内容写入那个文件,第二个参数buf指文件的内容来自哪个缓冲区,而且buf指针指向的是空间开始的地方,其类型是void*类型,也就是说无论要写入的数据是什么类型都可以写进文件中,因为在计算机看来,所有的数据都是二进制,而我们平时说的文件类型都是语言进行的封装,所以这个参数的类型是void*即可。第三个参数count表示要写入文件有多少字节,这个函数执行完后的返回值则是实际写入文件的字节数。下面我们写一段代码:
结果为:
我们用cat命令查看的时候,发现没有问题,但是当我们用vim打开文件的时候,就会发现有许多乱码:
这是因为我们在用write函数的时候,写入的字节数是strlen(buffer)+1,就类似于buffer中有一段字符串aaaa\n,这个字符串的长度是5,大小为5字节,但是我们却想向文件中写入6个字节的数据,所以我们就会在字符串的末尾添加上\0来补齐,我们在以前都会在末尾加上\0,这是因为c语言是这样规定的,为了防止c语言的越界访问的问题。可是在文件操作中,字符串并不需要以\0作为结尾,只需要写入字符串的有效内容即可,将strlen(buffer)+1中的+1去掉即可得到正确的结果:
上面的操作是直接覆盖掉文件原有的内容,如果想在文件后面继续写入的话,就将O_TRUNC改成O_APPEND:
我们多运行几次就会发现,后面写入的内容都会加在文件末尾。
所以O_WRONLY|O_APPEND|O_CREAT就是c语言fopen("文件名","a")的作用。
read
#include<unistd.h>
ssize_t read(int fd,void* buf,size_t count);
类似于write函数,fd表示从哪个文件中读取,buf表示读取出的内容放入哪个缓冲区,count参数表示要读取内容的字节数。如果读取成功了,返回值表示读取内容的字节数,如果失败了或者文件为空,返回值为0。
举个例子:
结果为:
close
#include<unistd.h>
int close(int fd);
close函数上面我们演示的时候用过很多次了,作用就是关闭文件,他的参数是文件描述符。
文件描述符
我们多次提到文件描述符,那么文件描述符具体是什么呢?我们写一段代码:
运行这段代码,打印出这几个文件的文件描述符:
我们可以发现,文件描述符是从3开始的连续小整数。我们曾经见过的连续小整数就是数组。其实文件描述符就是一个数组的下标。
而现在我们又有一个问题:就是文件描述符既然是一个数组的下标,那么为什么要从3开始呢?0,1,2呢?下面我们来一一解释:
我们首先要明白一件事,一个进程是会打开多个文件的,且被系统打开的文件一定是有多个的。文件又是被操作系统管理起来的,那么一定会遵从“先描述,再组织”的方式管理,类似于操作系统管理进程需要用task_struct结构体,操纵系统管理打开文件也一定会描述一个结构体,这个结构体叫做struct file,file中保存着文件的绝大部分信息,而组织起struct file的任务就是进程PCB里有一个struct files_struct* files的指针,他指向一个struct files_struct的结构体,这个结构体中又有一个数组为struct file* fd_array[],该数组每项中都存着一个file*的指针,指向一个file结构体。而文件描述符就是指的这个数组的下标。用下面的图做一下演示:
下面我们接着来解释一下为什么文件描述符是从3开始的,其实上面的图已经给出了暗示,是因为系统默认打开三个输入输出流分别为:stdin(标准输入)键盘,stdout(标准输出)显示器,stderr(标准错误)显示器。
文件描述符的分配规则&重定向
文件描述符的分配规则是:从小到大,按照顺序寻找最小且没有被占用的fd。运用这个知识,我们可以模仿重定向操作。重定向的本质就是上层用的fd不变,在内核中更改fd对应的struct file*的地址。例如:我们可以先close(1),在open("log.txt"),就可以将log.txt的地址替换到1的文件描述符所对应的地址。这就是输出重定向,以后的标准输出就会输出到log.txt里。代码如下:
或者下面这样也可:
我们这里先把1关闭,在打开一个名为log.txt的文件,这时根据文件描述符的分配规则,最小且没有被占用的fd就是1,所以我们这时向1中写的结果就是向文件log.txt中写,printf本质上就是向stdout中fd(也就是1)所指向的文件中写入数据,上面两种代码结果都为如图:
我们要注意,如果我们使用系统接口write时不用强制刷新,但是如果我们在使用C语言中printf函数时即使末尾加上了‘\n’时,不fflush强制刷新的话,是没有办法看到结果的,这是因为printf后数据放在了语言层的缓冲区,fflush之后,就会将放在缓冲区的数据刷新到fd指向的文件,加上‘\n’也不管用的原因是原本fd=1指向的是显示器,显示器采用的是行刷新,而现在fd=1指向的磁盘文件log.txt,而磁盘文件采取的是全刷新,也就是缓冲区满后才刷新,所以‘\n’也无法刷新。这部分内容会在后面缓冲区部分讲述。
dup2
我们刚才的操作已经可以实现printf输出重定向进文件内的操作,但是我们如果一直使用这种先close再代开文件的方法来重定向未免有些太麻烦,对此,操作系统给我们提供了dup2函数。
#include<unistd.h>
int dup2(int oldfd,int newfd);
//makes newfd be the copy of oldfd,closing newfd first if necessary.
dup2函数本质也就是将oldfd对应的文件替换掉newfd对应的文件,所以例如我们想要实现输出重定向就使用dup2(fd,1);
而根据我们现在掌握的重定向知识,就可以让我们之前写过的shell实现<,>,>>三种重定向功能。完整代码如下:
1 #include <stdio.h>
2 #include <string.h>
3 #include <unistd.h>
4 #include <stdlib.h>
5 #include <sys/types.h>
6 #include <sys/wait.h>
7 #include <assert.h>
8 #include <ctype.h>
9 #include <sys/stat.h>
10 #include <fcntl.h>
11 #include <errno.h>
12
13 #define NUM 128
14 #define SIZE 32
15
16 #define NONE_REDIR 1
17 #define STDIN_REDIR 2
18 #define STDOUT_REDIR 4
19 #define APPEND_REDIR 8
20
21 #define trimSpace(start)do{\
22 while(isspace(*start))start++;\
23 }while(0) //一个实用小技巧,写宏函数
24
25 char command_line[NUM];
26 char* command_parse[SIZE];
27
28 int RedirType = 0;
29 char* RedirFile=NULL;
30
31 void checkCommend(char* commands)
32 {
33 assert(commands);
34 char* start=commands;
35 char* end=commands+strlen(commands);
36 while(start<end)
37 {
38 if(*start=='>')
39 {
40 *start=0;
41 start++;
42 if(*start=='>')
43 {
44 RedirType=8;
45 start++;
46 trimSpace(start);
47 RedirFile=start;
48 break;
49 }
50 else
51 {
52 RedirType=4;
53 trimSpace(start);
54 RedirFile=start;
55 break;
56 }
57 }
58 else if(*start=='<')
59 {
60 *start=0;
61 RedirType=2;
62 start++;
63 trimSpace(start);
64 RedirFile=start;
65 break;
66 }
67 else
68 {
69 start++;
70 }
71 }
72 }
73
74 int main()
75 {
76 //初始化重定向参数
77 RedirType=NONE_REDIR;
78 RedirFile=NULL;
79 while(1)
80 {
81 //1.获取命令
82 memset(command_line,'\0',sizeof(command_line));//初始化命令数组为全\0
83 printf("[cyf@centos-myshell]$");
84 fflush(stdout);
85 if(fgets(command_line,NUM-1,stdin)) //在数组中获取命令
86 {
87 command_line[strlen(command_line)-1]='\0';//'\n' -> '\0'
88 }
89 //判断是否需要重定向
90 checkCommend(command_line);
91 //2.加工命令
92 int index=0;
93 command_parse[index]=strtok(command_line," ");//以空格分隔命令以及选项
94 if(command_parse[0]!=NULL&&strcmp(command_parse[0],"ls")==0)
95 {
96 index++;
97 command_parse[index]=(char*)"--color=auto";
98 }
99 while(1)
100 {
101 index++;
102 command_parse[index]=strtok(NULL," ");
103 if(command_parse[index]==NULL)//当没有空格的时候,strtok函数会返回NULL,
104 //并导致command_parse数组最后的内容为NULL
105 {
106 break;
107 }
108 }
109 if(command_parse[0]!=NULL&&strcmp(command_parse[0],"cd")==0)
110 {
111 if(command_parse[1]!=NULL)
112 {
113 chdir(command_parse[1]);
114 continue;
115 }
116 }
117 //3.执行命令
118 if(fork()==0)
119 {
120 //switch判断是否有重定向,即重定向的种类
121 switch(RedirType)
122 {
123 case NONE_REDIR:
124 break;
125 case STDIN_REDIR:
126 {
127 int fd = open(RedirFile,O_RDONLY);
128 if(fd>0)
129 {
130 dup2(fd,0);
131 }
132 else{
133 perror("open");
134 }
135 break;
136 }
137 case STDOUT_REDIR:
138 case APPEND_REDIR:
139 {
140 int flags=O_WRONLY|O_CREAT;
141 if(RedirType&APPEND_REDIR)
142 {
143 flags=flags|APPEND_REDIR;
144 }
145 else
146 {
147 flags=flags|O_TRUNC;
148 }
149 int fd=open(RedirFile,flags,0666);
150 if(fd<0)
151 {
152 perror("open");
153 exit(errno);
154 }
155 else
156 {
157 dup2(fd,1);
158 }
159 break;
160 }
161 default:
162 printf("bug\n");
163 break;
164 }
165 execvp(command_parse[0],command_parse);
166 exit(1);//若父进程得到子进程退出码为1的话,说明子进程替换失败
167 }
168 int status=0;
169 pid_t ret = waitpid(-1,&status,0);
170 if(ret>0&&WIFEXITED(status))
171 {
172 printf("Exit Code:%d\n",WEXITSTATUS(status));
173 }
174 }
175 return 0;
176 }
主要就是运用了用bit位传递标记位的方法来判断是何种重定向以及运用dup2函数替换掉fd=0以及fd=1的文件。
再理解文件重定向操作:
实际上,我们在执行重定向操作时,上层并不知道我们在做重定向操作,例如:我们的printf函数还是照常在向FILE* stdout即fd=1所对应的标准输出文件输出,只不过是我们通过dup2函数已经将fd=1对应的标准输出文件替换成了我们想写入的磁盘文件。所以重定向操作是围绕底层操作展开的。
如何理解一切皆文件
我们常说linux下一切皆文件,这句话该如何理解呢?我们先通过下图来理解一下:
在上层看来,所有的文件或硬件都是struct file结构体,里面包括文件类型,状态,读操作,写操作,正在访问的数量等等各种属性。其中需要说明的就是文件的状态,以及读写操作。
首先说文件的状态:我们先来想一个问题,我们平时操作文件时用open和close来打开关闭文件时是真的就打开和关闭文件吗?其实不是,文件中其实有一个专门的数据(假定此数据为count)来保存文件的访问数,也就是说我们平时打开文件的时候,这个文件的count就会++,关闭一个文件的时候,这个文件的count就会--,我们打开和关闭文件只是在这个数上做改变,并不是真正地打开或者关闭。我们也可以这样考虑,file是内核数据结构,只有操作系统可以操作,所以我们用户所创建的进程是没有权力去决定他的关闭或打开的,所以open和close并不是真正的打开或关闭文件。
接下来我们说文件的读写操作,我们刚才说了,在上层来看,所有文件或硬件都是struct file结构体,但是不同的文件或硬件的读写操作肯定是不一样的那么我们如何找到对应正确的读写操作呢?答案是使用函数指针,用函数指针去指向对应的不同的读写函数,以获取正确的读写方法。对上层来说,已经完全被屏蔽了,因为对进程来说,统一使用的是一套接口(即都是函数指针),只不过指针指向的是不同操作函数。而所有的属性和操作接口,都通过struct file来描述,所以在进程来看,就是“一切皆文件”。上面所说的一切都是操作系统自动完成,用户只需要通过fd进行操作即可。
而关于缓冲区的问题,我们留在下篇文章中讲。
希望同学们可以在这篇文章中收获到对自己有用的知识。