文章目录
在Linux操作系统当中,有一个非常重要的概念:一切皆文件。无论是键盘还是显示器,又或是驱动程序,都可以看成文件。
如果学习文件操作,只停留在语言层面上,是很难对文件有一个比较深刻的理解的
1、系统级IO
在我们学习C语言的时候,使用printf时,为什么可以直接在屏幕上打印数据呢?使用scanf的时候为什么能从键盘上读取数据呢?
原因就是:
C程序默认会打开三个输入输出流,它们分别是stdin,stdout和stderr
stdin:键盘
stdout:显示器
stderr:显示器
无论是哪种语言写出来的程序都会打开它们。
那语言级别的IO和系统级别的IO有区别吗?答案是有区别:操作系统是硬件的管理者,如果通过语言向磁盘写文件,或者读文件,是不是要经过操作系统的允许?或者需要操作系统帮你完成这件事。所以,所有的语言上的对文件的操作,都必须贯穿OS,因为操作系统不相信任何人,访问操作系统,需要通过系统调用接口的。几乎所有语言对文件的操作的库函数,底层一定需要使用OS提供的系统调用!!!
1.1 open
参数:
pathname:要打开或创建的 目标文件 //支持相对路径和绝对路径
flag:传参标志位
flag参数有以下几种,可以传1个或多个。使用多个时,用“|”或运算符连结。
O_RDONLY:只读打开
O_WRONLY:只写打开
O_RDWR :读、写 打开
O_CREAT :若文件不存在,则创建它。使用该选项时,必须要用mode选项指定新文件的权限
O_APPEND :追加 写
mode:当创建新文件时,对权限的设置。如:0644 (注意,该权限还需要与umask进行运算)
返回值:
成功:返回新打开文件的文件描述符
失败:-1
#include<stdio.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
int main()
{
int fd = open("./log.txt",O_WRONLY | O_CREAT);
if(fd < 0)
{
printf("open error\n");
}
close(fd);
}
通过运行上面的代码,我们发现log.txt的权限全是乱码。为什么呢?
因为最开始并没有log.txt文件,当我们要创建它的时候需要给定的权限
int fd = open("./log.txt",O_WRONLY | O_CREAT,0644);
将代码稍作修改,再次运行
1.2 close
对于打开的文件,需要关闭时,只需要使用close,参数为fd(文件描述符)
1.3 write
参数:
fd:文件描述符
buf:指向一个缓冲区的指针
count:需要写入多少个字节的数据
返回值:
ssize_t:返回写入数据的字节个数
可能又有人疑问了:明明参数已经有count了,就能表示写入数据的大小,为什么返回值还是写入数据的大小呢?
原因是:count只是人为的希望写入多少字节的数据到文件当中,但是实际上就真的能写入这么多吗?万一进程意外终止了呢?万一磁盘满了,不能再写入数据了呢?所以返回值才是成功写入数据的字节个数
#include<stdio.h>
#include<string.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
int main()
{
int fd = open("./log.txt",O_WRONLY | O_CREAT,0644);
if(fd < 0)
{
printf("open error\n");
}
const char* msg = "hello feng\n";
int cnt = 5;
while(cnt--)
{
write(fd,msg,strlen(msg));
}
close(fd);
}
在我们写入文件的过程中,我们需要写入\0吗?
注意:答案是不需要的,因为\0作为字符串的结束标志,只是C语言的规定,在文件中需要写的是文件内容,而不是结束标志
1.4 read
参数:
fd:文件描述符
buf:指向缓冲区的指针(把读到的数据放在这个缓冲区里面)
count:读多少个字节的数据
返回值:
ssize_t:实际读取数据的大小,单位为字节
#include<stdio.h>
#include<string.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
int main()
{
int fd = open("./log.txt",O_RDONLY);
if(fd < 0)
{
printf("open error\n");
}
char buffer[1024];
ssize_t s = read(fd,buffer,sizeof(buffer)-1);
if(s > 0)
{
buffer[s] = 0;//因为写数据时,不会写\0,所以需要手动添加\0
printf("%s",buffer);
}
close(fd);
}
2、文件描述符
通过对open函数的学习,我们知道了文件描述符就是一个小整数
2.1 0 & 1 & 2
#include<stdio.h>
#include<string.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
int main()
{
int fd0 = open("./log0.txt",O_CREAT|O_WRONLY, 0644);
int fd1 = open("./log1.txt",O_CREAT|O_WRONLY, 0644);
int fd2 = open("./log2.txt",O_CREAT|O_WRONLY, 0644);
int fd3 = open("./log3.txt",O_CREAT|O_WRONLY, 0644);
int fd4 = open("./log4.txt",O_CREAT|O_WRONLY, 0644);
printf("%d, %d, %d, %d, %d\n",fd0, fd1, fd2, fd3, fd4);
close(fd0);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
}
上诉代码的结果为3,4,5,6,7,这是一串连续整数
,这没什么问题。但我们很好奇为什么从3开始呢?打开文件失败,返回值为-1,这我们能理解,但是0,1,2跑去哪里了呢?
结论:当我们的程序运行起来后,变为进程后,默认情况下,OS会帮助我们进程打开三个标准输入输出!
0:标准输入,键盘
1:标准输出,显示器
2:标准错误,显示器
这和C语言中的stdin、stdout、stderr很类似呀。
这里的34567是我们打开文件的返回值(也就是文件描述符),说明012也是文件描述符,那就说明标准输入、标准输出、标准错误也是文件,这也体现了Linux中一切皆文件的概念。
从这串从0开始的整数,我们很容易就想到的数组的下标。那么这个数组在哪里呢?
因为open是系统调用,所以open的返回值一定是OS给我们的!因此,我们敢肯定这个数组一定在OS内部,OS帮我们维护。
一个进程可能打开多个文件,OS中会存在多个进程,那么OS内一定是打开了更多的文件,OS就必须对其进行管理,那么OS是怎么管理文件的呢?一定是先描述再组织
.所以在内核当中描述打开文件的结构体叫做struct_file,这里放的什么呢?之前说进程PCB的时候,就谈到了PCB里面放的是描述进程各种属性的信息,同样的struct_file里面也存放的是文件相关的属性信息,那这些信息从哪里来呢?我们知道文件在磁盘中就已经有属性信息了,当打开文件时,无非就是把磁盘中的信息加载到struct_file中。
OS中有许多进程,这些进程又打开了许多文件,但是如何知道某个文件是哪个进程打开的呢?所以操作系统为了让文件和进程之间产生关系,在进程中添加了一个结构,这个结构的名字叫做files_strcut,这个结构内又包含了一个数组,数组里面存放的就是一个file* 的指针,每个file* 指针都会指向一个文件
当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files, 指向一张files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件
2.2 文件描述符的分配规则
直接看代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
此时我们发现是3,不过还能想通,毕竟0,1,2被占用了嘛
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(0);
//close(2);
int fd = open("myfile", O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
当我们关闭0时
当我们关闭2时
结果已经很明确了:文件描述符的分配规则:在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符
3、重定向
对于前面我们只是关闭了0和2,假设我们关闭1呢?
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(1);
int fd = open("./log.txt", O_CREAT|O_WRONLY, 0644);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
return 0;
}
我们惊奇的发现竟然什么都没有显示。printf函数默认是向1打印,也就是向stdout打印,但是我们把stdout关闭了,1的位置也就空闲出来了,当log.txt打开后,会在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符,结果发现下标为1的位置没人使用,自己就去把这个位置占了。printf打印的时候只关心往下标为1的文件中打印,并不关心是在向谁打印。所以内容被打印到了log.txt当中了。
是这样吗? 我们验证一下:
我们又惊奇的发现结果跟我们想象的一样,我们就成为这种想象为重定向。常见的重定向有:>, >>, <
再看这样一个程序
#include<stdio.h>
#include<unistd.h>
#include<string.h>
int main()
{
const char* msg1 = "hello 标准输出\n";
write(1, msg1, strlen(msg1));
const char* msg2 = "hello 标准输入\n";
write(2, msg2, strlen(msg2));
return 0;
}
运行这个程序,都正常打印了,没什么问题。
假设我们对这个程序的打印进行重定向,会让我们不可思议。例如:
我们将程序中打印的数据进行重定向后,结果只有标准输入被重定向了,而标准错误没有被重定向,为什么呢?
其实原因很简单:这个符号 “>” 叫标准重定向,只会重定向1号文件描述符中(标准输出)里面的内容,而不会重定向其他文件描述符(标准错误)里面的内容
。
假设我们需要将标准错误也进行重定向,则可以使用这个指令:
./test > log.txt 2>&1
重定向的本质:
4、使用dup2的系统调用
对于重定向,难道我们每次都要把stdout关掉,然后再打开一个文件,这个文件去将iles_struct数组中下标为1的位置占了,然后才能进行重定向吗?
答案当然不是,我们有一个系统调用叫dup2,它可以很方便的帮我们完成重定向
虽然有dup1、dup2、dup3,但推荐使用dup2。
dup2的原理:
因为上层调用系统调用的时候,只关心文件描述符。假设文件描述符是1,就操作files_struct数组中下标为1中里面文件指针指向的文件。换句话说,假设下标为1中里面文件指针指向的是标准输入,就操作标准输入,如果指向的是标准输出,就操作标准输出。并不会关心指针指向的是哪个文件。所以,我们只需要将files_struct数组中某个位置中的指针用指向其它文件的指针替换即可。
dup2中的两个参数:
oldfd:重定向到哪个文件的文件描述符
newfd:需要进行重定向文件的文件描述符
也就是说本来我需要将数据放到nwefd所对应的文件中,但是调用dup2后,将数据放到oldfd中
newfd是oldfd的一份拷贝,这里不是拷贝的下标,操作系统会帮我们拷贝下标所对应位置中的数据,也就是文件指针
5、缓冲区
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
int main()
{
close(1);
int fd = open("./log.txt", O_CREAT | O_WRONLY, 0664);
printf("fd : %d\n", fd);
fprintf(stdout, "hello fl\n");
fprintf(stdout, "hello fl\n");
fprintf(stdout, "hello fl\n");
fprintf(stdout, "hello fl\n");
fprintf(stdout, "hello fl\n");
fprintf(stdout, "hello fl\n");
return 0;
}
这个程序先将1号文件描述符的文件关闭(标准输出),然后再打开一个文件(log.txt),因为1号文件描述符空闲了,所以这个文件就会占用1号,也就是说log.txt的文件描述符是1号了,所以stdout也就指向了log.txt。通过六个fprintf函数,我们将在log.txt中打印六句"hello fl"加上最开始的fd。
通过上述分析,我们运行程序,并查看log.txt中的内容
结果跟我们分析的一样。
当我们在打印的最后的一个fprintf后加上一句close(fd)后,再看看结果
结果log.txt中什么都没有,归根结底,最大的问题在close(fd)。为什么会这样,原因就是C语言给我们提供了缓冲区
所谓缓冲区就是
ANSIC 标准采用“缓冲文件系统”处理的数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块“文件缓冲区”。从内存向磁盘输出数据会先送到内存中的缓冲区,再送到磁盘上。如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区,然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。如果缓冲区对应的是输入设备,那么则为输入缓冲区,如果对应输出设备,则为输出缓冲区。
我们调用fprintf时,参数给的是stdout,stdout是什么呢?它是一个FILE*的指针,这个指针指向的是一个结构体,这个结构体里面就有一个缓冲区,这个缓冲区是C语言给我们提供的。当我们向std中写数据时,其实是先向这个缓冲区写数据,缓冲区定期的
将数据给操作系统,操作系统再将数据显示到终端。
那C缓冲区是怎么将数据给操作系统呢?一定是通过系统调用,既然是系统调用,那么就一定需要文件描述符(fd)
那么对于缓冲区定期的将数据给操作系统中的定期是何时呢?
当我们在向缓冲区中写入数据时,可能写了一段数据,缓冲区会刷新,将数据给操作系统。也可能直到缓冲区满了才将数据写给操作系统。
其实对于用户到OS过程中
缓冲区的类型有三种:
- 全缓冲。缓冲区也是内存,也有大小。当标准IO缓冲区被填满时,再对缓冲区进行实际IO操作。典型代表为磁盘文件的读写操作。
- 行缓冲。当我们在进行输入操作时,是先对缓冲区进行操作的,也就是说我们输入的每个字符都会放在缓冲区,当遇到结束标识符时,才进行实际的IO。典型代表为print()和与之相似的输入函数。
- 不带缓冲。也就是不进行缓冲。为什么会有不带缓冲呢?原因就是每个程序都可能会报错,作为程序的管理者,我们尽快的想知道程序出错的地方,这样才能将损失降到最低。典型代表是C语言中的stderr(标准错误),C++中为cerr。
除上述所说的之外,利用缓冲区的特性刷新缓冲区,其实进程退出的时候,也会刷新缓冲区中的数据到OS缓冲区
说到这里,我们再来分析一下最开始的代码。如果我们没有关闭stdout的话,数据会被打印到显示器上,因为fprintf中的字符串最后有"\n",所以每打印一句就会被刷新到OS中。而当我们关闭了stdout后,数据会被重定向到 log.txt 中,而文件的缓冲区的刷新策略是全缓冲,当数据放满时,才会将数据刷新置OS中。当进程准备结束之前,又将 log.txt 关闭,进程结束后就无法将数据刷新到 log.txt 中,所以最终 log.txt 中没有任何数据。
那我们如何解决这个问题呢?
其实很简单,我们只要在关闭文件之前将缓冲区的数据刷新到 log.txt 中即可。对于刷新缓冲区,C语言给我们提过了这么一个接口来刷新缓冲区
fflush的参数类型是FILE*,也就是一个指针,而stdout本质也是一个FILE*的指针,所以通过对stdout的刷新,就能在关闭文件之前,将数据写入 log.txt 中。
事实证明,确实是这样的。
再看这样一段代码
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
int main()
{
const char* msg1 = "hello 标准输出\n";
write(1, msg1, strlen(msg1));
const char* msg2 = "hello 标准错误\n";
write(2, msg2, strlen(msg2));
printf("hello fl\n");
fprintf(stdout, "hello fl\n");
close(1);
return 0;
}
这段代码会将所有的数据都打印到显示器上
确实是这样。但是当我们把它重定向到 log.txt 中,会有不一样的结果。
对于"hello 标准输出"被重定向到 log.txt 中,而"hello 标准错误"却没有,这个很容易理解,因为重定向只会重定向标准输出里面的内容,而不会重定向标准错误里面的内容。但是对于printf()和fprintf(),它们将数据打印的地方也是标准输出,为什么没有被重定向呢?
这里的原因还是close(1)。因为本来是打印到显示器中的内容被重定向到 log.txt 中,行缓冲变为了全缓冲,关闭stdout的时候,缓冲区里面的数据并没有被刷新到 log.txt 中。所以最终 log.txt 中没有"hello fl"。
如果这么说,很好的解释了后面两句打印,但对于前面的"hello 标准输出"又怎么解释呢?
细心的读者已经发现了,前两句打印,我们是通过系统调用的接口(write)完成的,而系统调用不是语言层面,它会将数据直接刷新到OS,OS再将我们数据重定向到文件。所以对于系统调用,是不会受其语言给我们提供的缓冲区的影响的,因为系统调用不会经过语言层,它在语言层的下面。
最后我们再来看一段程序
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
int main()
{
//系统接口
const char* msg1 = "hello 标准输出\n";
write(1, msg1, strlen(msg1));
//C语言接口
printf("hello printf\n");
fprintf(stdout ,"hello fprintf\n");
fputs("hellow fputs\n", stdout);
fork();
return 0;
}
对于输出结果,没什么异议,很合理。但是如果我们将其重定向到文件当中,却又发生了奇怪的事
处理系统调用接口write之外,C语言接口的输出都打印了两次。也就是说系统接口不受影响,C接口却受影响。这里fork创建子进程是在打印数据的最后,难道子进程不是从fork之后开始执行的吗?
对于C语言的缓冲区一定是语言提供的,不是操作系统提供的。在父进程中,C语言打印的数据都会先放入缓冲区中,当我们重定向到文件之后,刷新策略从行缓冲变为了全缓冲,而这些数据并不能将缓冲区填满,所以在进程结束的时候才将缓冲区的数据刷新到文件中。缓冲区也是父进程的资源,在创建子进程之后,子进程会也会继承这些资源。当子进程结束后,会先查看缓冲区里面是否有数据,如果有就会刷新缓冲区。又因为父子进程在不修改数据的前提下是共享数据的,如果一旦有一方先要修改数据,就会发生写时拷贝
所以fork之后,父进程结束,需要刷新缓冲区,子进程结束也需要刷新缓冲区,刷新缓冲区就需要写时拷贝。这里的本质也就是因为写时拷贝发生的重复刷新缓冲区。所有最终C接口中打印的内容在文件中有两份。
重点!!:这也再次证明,这里的缓冲区是语言给我们提供的,而不是OS。如果是OS提供的,那么所以的数据在文件中都应该有两份。但事实却是系统调用接口write中的数据只有一份。我们把语言提供的缓冲区也叫用户级缓冲区#D1EEEE |
同样的如果我们需要解决缓冲区重复被刷新的问题,也需要在fork之间添加fflush刷新缓冲区,子进程退出时,发现缓冲区里面没有数据,也就不会发生写时拷贝了