在学习C语言的时候,我们学过一些文件操作的函数,比如fopen,fgets,fputs,fclose等等,其实这些函数的功能就是在操作文件,进行IO读取工作。这些是C语言封装的函数。
然而C语言封装的函数里面,一定有OS层面的动作,因为IO设计到硬件操作,而对于硬件的操作一定要通过系统调用,让OS帮助我们完成IO操作。
所以,为了更好的让大家理解IO原理,先介绍一下IO的系统调用接口。
在介绍系统调用函数之前,需要了解一下位图的概念。顾名思义就是每个比特位都有不通过的含义,代表着不同的功能。
下面使用一个例子介绍位图的应用:
1 #include<stdio.h>
2
3
4 // 用int中的不重复的一个bit,就可以标识一种状态
5 // 第1个比特位表示功能一,第二个比特位表示功能二,第三个比特位表示功能三
6 #define ONE 0x1 //0000 0001
7 #define TWO 0x2 //0000 0010
8 #define THREE 0x4 //0000 0100
9
10
11 void show(int flags)
12 {
13 //哪个标志的比特位是1,就完成对应的功能
14 if(flags & ONE) printf("功能ONE\n");
15 if(flags & TWO) printf("功能TWO\n");
16 if(flags & THREE) printf("功能THREE\n");
17 }
18
19 int main()
20 {
21 show(ONE); //选择功能1
22 show(TWO); //选择功能2
23 show(ONE | TWO); //选择功能1和2
24 show(ONE | TWO | THREE); //选择功能123
25 return 0;
26 }
从上面的运行结果可以来看,我们使用位图的概念,实现了的方法可以选择任意功能。
类比fopen的使用,使用man 2 open查询open函数的用法。我们使用时,经常使用带mode的函数。
第一个参数:文件路径
第二个参数flags:功能位(位图的概念)
第三个参数:权限:可以使用16进制来表示,一般使用0x666表示rw权限。
返回值:成功时返回新打开的文件描述符。失败时:返回-1.
flags:打开文件时,可以传入多个参数选项,用下面的一个或多个常量进行"或"运算,构成flags。
下面我们简单举个例子来打开文件:
1 #include<stdio.h>
2 #include<sys/types.h>
3 #include<sys/stat.h>
4 #include<fcntl.h>
5
6 int main()
7 {
8 //只读并且没有文件创建。
9 int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);// rw-rw-rw
10 if(fd < 0)
11 {
12 perror("open:");
13 return 1;
14 }
15
16 printf("打开成功, fd %d \n", fd);
17
18 return 0;
19 }
类比fwrite的使用,使用man 2 write查询open函数的用法。
fd:文件描述符
buf:要写的字符串。
count:要写的大小(单位是字节)
返回值:ssize_t :实际写的有效字节数。
类比fread。
将fd的功能读到字符串buf中,读取count个字节。
关闭指定文件,通过文件描述符关闭。
返回值:0代表成功。-1代表失败。
创建一个log.txt文件,并且追加输入111222333
1 #include<stdio.h>
2 #include<string.h>
3 #include<stdlib.h>
4 #include<unistd.h>
5 #include<sys/types.h>
6 #include<sys/stat.h>
7 #include<fcntl.h>
8
9 int main()
10 {
11 //只读并且没有文件创建,追加写入
12 int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);// rw-rw-rw
13 if(fd < 0)
14 {
15 perror("open:");
16 return 1;
17 }
18
19 printf("打开成功, fd %d \n", fd);
20 const char* s1 = "111111111111";
21 const char* s2 = "222222222222";
22 const char* s3 = "3333333333333";
23 write(fd, s1, strlen(s1));
24 write(fd, s2, strlen(s1));
25 write(fd, s3, strlen(s1));
26
27 close(fd);
28
29 return 0;
30 }
通过open系统调用函数的理解,发现文件描述符其实就是一个小整数,并且一直是3,为什么呢?
这就要引入stdin,stdout,stderr的概念。
在我们操作系统启动的时候,默认会打开三个流,他们就是stdin,stdout,stderr,即标准输入流、标准输出流、标准错误流。对应的硬件分别是:键盘(让我们能输入)、显示器(让我们能看见)、显示器(出错了也能看见)。
看下面一段程序:
1 #include<stdio.h>
4 int main ()
5 {
6
7 printf("stdin: %d\n",stdin->_fileno);
8 printf("stdout: %d\n",stdout->_fileno);
9 printf("stderr: %d\n",stderr->_fileno);
10 return 0;
11 }
12
我们可以猜测,0,1,2是被这默认打开的三个流占用了,所以我们每次再打开文件的时候,被分配的就是从3开始了。
并且在man手册查看一下他们的介绍,发现都是FILE* 类型的。
因此我们猜测FILE是一个结构体,并且结构体的内部一定有fd。当然这个结构体一定是由C语言提供的。
而我们上面学习了系统调用函数,使用的是fd操纵的文件,我们可以推测C语言的文件操作,一定封装了系统调用的接口,并且通过struct FILE里面的fd来和系统沟通,因为系统只认识fd,不认识FILE*。
下面我将fd与文件的关系可视化出来帮助大家理解:
解释:通过进程的pcb里面可以找到一个数组,其中fd就是数组的下标,数组内容存着文件的属性信息。因此当我们打开一个文件的时候,OS会在数组中申请一个最小的下标(通常情况下最小为3),然后内容存上结构体的地址,这样我们就能通过fd操作文件啦。
现在我们知道,文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*file,指向一张表files_struct,该表最重要的部分就是包含一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符fd就是该数组的下标。所以只要拿着文件描述符,就可以找到对应的文件。
现在我们已经理清楚,文件描述符是什么,有什么用。那么他的分配规则是什么呢?fd分配最小下标分配原则。
关闭0号文件,那么我们如果打开文件的话描述符就为0,如下:
1 #include<stdio.h>
2 #include<string.h>
3 #include<stdlib.h>
4 #include<unistd.h>
5 #include<sys/types.h>
6 #include<sys/stat.h>
7 #include<fcntl.h>
8
9 int main()
10 {
12 close(0); //关闭0号文件描述符
13 int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);// rw-rw-rw
14 if(fd < 0)
15 {
16 perror("open:");
17 return 1;
18 }
19
20 printf("打开成功, fd %d \n", fd);
28 close(fd);
30 return 0;
31 }
可以验证OS总是优先分配最小的数组下标,即便是0、1、2号下标也不例外!!!
看一下下面的代码:
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
11 close(1);
12 int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); //fd的文件描述符为1
13 //下面的内容本应该打印到屏幕上。但实际上却没有打印出来。
14 printf("打开文件分配的fd为%d\n", fd);
15 printf("打印到屏幕上的内容\n");
16 fprintf(stdout, "hello fprintf\n");
17 const char* s1 = "hello fwrite\n";
18 fwrite(s1, 1, strlen(s1), stdout);
19
22 fflush(stdout);
23 close(fd);
24 return 0;
25 }
运行结果:本应该显示在屏幕上的内容却被输出到了文件里!这个现象叫做输出重定向,但是为什么呢?
下面我将用图例解释一下产生上面情况的原因:
注意stdin,stdout,stderr在C语言是宏定义,分别被定义成为了0、1、2。在代码中,我们首先关闭了1号文件描述符,然后又打开了文件,显然log.txt文件的文件描述符就是1,也就是说1号下标的内容由标准输出(屏幕)变成了指向log.txt的指针。所以,我们在代码中对stdout文件输出内容,在语言层面其实就是对1号文件描述符输出内容,然而语言层面不知道这个改变(因为文件描述符是OS分配的),所以就造成了输出重定向。
理解输出重定向例子后,我们很容易就能写出输入重定向。
8 int main ()
9 {
10
11 // 关闭stdin
12 close(0);
13 int fd = open("log.txt", O_RDONLY);
14 if(fd<0)
15 {
16 perror("open:");
17 return 1;
18 }
19 printf("fd = %d \n", fd);
20 char buffer[128];
21 fgets(buffer, sizeof buffer, stdin);
22 printf("buffer:%s\n", buffer);
23 close(fd);
}
运行结果:
可知我们本该需要使用键盘输入的时候,但是却不需要我们输入,而是从我们打开的文件中读取了一行。这就完成了输入重定向工作。
我们实现一直往log.txt追加输出内容的代码:
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
11 close(1);
//将文件打开方式变为追加打开即可
12 int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666); //fd的文件描述符为1
13 //下面的内容本应该打印到屏幕上。但实际上却没有打印出来。
14 printf("打开文件分配的fd为%d\n", fd);
15 printf("打印到屏幕上的内容\n");
16 fprintf(stdout, "hello fprintf\n");
17 const char* s1 = "hello fwrite\n";
18 fwrite(s1, 1, strlen(s1), stdout);
19
22 fflush(stdout);
23 close(fd);
24 return 0;
25 }
运行程序前后,log.txt内容确实被追加了!
上面的例子只是让我们见识一下重定向是什么样子。而实际工程中,我们多使用dup2()系统调用,来完成此功能。
把oldfd文件描述符的内容给nwefd(相当于修改的是数组内容)。
看下面代码示例:
8 int main (int argc, char* argv[])
9 {
10 if(argc!=2)
11 {
12 return 1;
13 }
14
15 int fd = open("log.txt", O_CREAT| O_WRONLY|O_TRUNC, 0666);
16 printf("fd:%d\n", fd);
17 dup2(fd,1); //将stdout的内容修改为fd
18 printf("打印屏幕上的内容:%s\n",argv[1]);
19
20 close(fd);
21 return 0;
}
运行结果:
通过上面的例子,我们使用dup2完成了输出重定向!
重定向的本质,其实是在OS内部,更改fd对应的内容指向!!!
重定向很容易解决Linux系统一切皆文件的设计理念。它把显示器、键盘、磁盘当成文件,全部封装成struct file,在用户看来,只需要操作对应的结构体,即可完成重定向的工作。用户根本不需要知道硬件的细节。也恰好能体现封装的思想。
什么是缓冲区?
缓冲区就是一段内存区域,介于CPU和硬盘之间,因为要执行IO的话,CPU肯定会访问硬盘。CPU的速度是ns级别,硬盘的速度是ms级别。如果让CPU直接访问的话,肯定会拖慢系统的速度。因此我们可以在内存中开辟一小段区域,这段区域就叫做缓冲区。
缓冲区有什么用?
缓冲区可以提高效率。
写透模式(wrrite through):CPU直接向硬盘写入(耽误CPU时间,慢)。
写回模式:(write back):CPU将数据写到缓冲区后,就干别的事情去了,让缓冲区自己往硬盘写入,写完报告给CPU即可。(不耽误CPU时间,快,,也可以提高用户的相应速度)。
缓冲区的刷新策略
立即刷新(写完就立马刷新)、行刷新/行缓冲(遇到\n就立马刷新)、满刷新/全缓冲(写满缓冲区才刷新)。
一般而言,行缓冲的设备文件:显示器;全缓冲的设备:磁盘文件
下面我们先看一段代码:
8 int main ()
9 {
10
11 //C语言提供的
12 printf("hello world\n");
13 const char* s1 = "hello frite\n";
14 fwrite(s1, 1, strlen(s1), stdout);
15 fprintf(stdout,"hello fprintf\n");
16
17 //OS系统提供的接口
18 const char *s2 = "hello write\n";
19 write(1, s2, strlen(s2));
20
21
22 fork();
23 return 0;
24 }
程序运行结果如下:
**观察发现,程序运行时是正常的,但是当我们重定向到文件里面后,发现C语言提供的函数打印了两次,而系统调用函数只打印一次。**如果我们把fork去掉之后,程序正常运行。我们猜想,这种情况一定和fork有某种关系。
解释:
我们在输出缓冲区,当时红色框框的代码没有讲解,现在我们可以解释一下:
所有的设备按道理来说,都倾向于全缓冲,缓冲区满了,才刷新,这样能进行更少的IO操作,提高效率。因为和外设IO时,数据量的大小不是主要矛盾,和外设预IO的过程(准备过程)是最耗费时间的。
为了照顾到用户体验,显示器一般都是行刷新。极端情况下,我们也可以自定义刷新规则。
**C语言的缓冲区在哪里呢?**和fd一样,存在FILE结构体里面,包含了该文件fd对应的语言层的缓冲区结构。
stdout和stderr所对应的硬件都是显示器,但是他们具体有什么区别呢?
名字上:stdout叫标准输出流,stderr叫标准错误流。
让我们看下面一段代码:
该程序输出的1表示使用stdout打印的。输出2表示使用stderr打印的。
1 #include<iostream>
2 #include<stdio.h>
3
4 int main()
5 {
6 //stdout -> 1
7 printf("hello printf 1\n");
8 fprintf(stdout, "hello fprintf1\n");
9
10 //stderr
11 perror("hello perror 2");
12
13 // cout -> 1
14 std::cout<< "hello cout 1"<<std::endl;
15 //cerr -> 2
16 std::cerr<<"hello cerr 2"<<std::endl;
17
18 return 0;
19 }
程序首先都被打印到了屏幕上,证明他们对应的硬件都是显示器。
我们在此程序的基础上,运行下面的命令:
重定向后,发现stderr的输出仍然打印出来了,但是stdout的输出被重定向到log.txt。
结论:因为屏幕只有一个,stdin和stdout都打开了显示器文件,即一个显示器文件被打开了两次,有两个文件描述符,输出重定向只是重定向了fd=1描述符,并没有影响到stderr。因此stderr仍然打印到屏幕上。
应用:
//运行下面的命令即可把两种输出,分别重定向到两个文件里。
./myfile > log.txt 2>err.txt
//运行下面的命令即可把两种输出,重定向到一个文件里。
./myfile > log.txt 2>&1
一般而言程序如果可能出问题的话,使用stderr或者cerr来打印。
如果是常规文本的打印,建议使用cout或者stdout打印。
在我们的文件操作函数出错之后,出错信息一般都会存在一个全局变量,errno里面,我们一般都使用perror函数来打印错误信息,但是该接口不需要errno。说明该接口一定封装了errno。代码如下:
5 void myperror(const char* s)
6 {
7 //使用errno全局变量打印错误
8 fprintf(stderr, "%s %s\n",s, strerror(errno));
9 }
文件系统顾名思义是管理文件的系统。如下图是Linux ext2文件系统的磁盘文件系统图。
磁盘是典型的块设备,硬盘分区被划分为一个个的block。一个block的大小是格式化的时候确定的,并且不能更改。其中启动块(Boot Block)的大小是确定的。
众所周知:文件 = 属性 + 数据,因此flie = inode Table + Data blocks
inode是一个结构体,存放着文件的各种属性。
struct inode
{
//文件大小
//文件的inode编号
//其他属性
int blocks[15] //(0-11是直接索引,12-15是间接索引)
}
创建文件过程:首先os申请一个inode号,并且为之分配一个数据块,然后更新inode表。完成之后,更新目录的data block。
因为inode和data block是固定的。可能是没有ionde号了。