目录
1. 前置理解
首先理解两件事:
1. 文件 = 内容 + 属性
2. 访问文件之前,都必须先打开文件
(fread或者fwrite之前都必须先fopen)
为什么要先打开?:因为文件在磁盘上,根据冯诺依曼,需要将磁盘上的内容给加载到内存中。因此,访问文件的本质是 进程
(log.txt都是自动创建在当前进程的cwd中的)
在创建、写入、编译等等时候,文件都没有打开。只有当进程跑起来并且fopen到fclose这段时间里时,文件是被打开的。fopen和malloc或者new一样,都是运行时程序。
既然文件是进程打开的,是程序执行到fopen之后才能算打开。那么,进程在内存中(被cpu给读取),文件在磁盘中,根据冯诺依曼,我们可以理解,打开文件就是把文件加载到内存中
一个进程可以打开多个文件,一台机器可以同时跑多个进程,可能会有很多的文件加载进内存,文件的管理需要OS的参与!
类似于加载进程到内存,我们可以猜想,内核中,文件也是由 管理文件的内核数据结构+文件的内容构成的,满足 先描述,再组织的概念!!
研究打开的文件,是在研究进程和文件的关系。相比较打开的文件,没打开的占大多数,这部分内容都存储在磁盘中。
2. C语言接口
为了方便文件读写,引入ssize_t
fopen的几种选项。
1. 每次w选项都会直接清空文件或者新建文件。
直接输出重定向,也会导致清空。
借助snprintf和fputs来向文件中写东西。
2. 同理,a方式打开和echo >>很像
a方式打开,即追加。
3. systemcall : open
用一个例子来复习流的概念
打印到显示器,有多少种操作方法?
纯C语言级别:
三种流:stdin stdout stderr
Linux下一切皆文件,当然包括stdin\stdout\stderr,那这三个文件当然也是被进程打开的。
进程默认打开三个输入输出流,并且这三个流看起来像是由C语言的库函数提供的。
事实是,打开三个流是系统层面的事情,不是语言级别的(每种语言都有对应的三种流的名字)。
往显示器打印、往磁盘写、读写键盘。。。。都是硬件,C语言的这些接口,都是封装过的systemcall。语言必须经过操作系统才能访问底层。回顾老图:
open参数及其机制:
pathname
:
指向文件或设备路径的字符串。
可以是绝对路径(如
/home/user/file.txt
)或相对路径(如./file.txt
)。
flags
:
指定打开文件的方式。常见的标志包括:
O_RDONLY
:只读方式打开文件。
O_WRONLY
:只写方式打开文件。
O_RDWR
:读写方式打开文件。
O_CREAT
:如果文件不存在,则创建。 它 -O_TRUNC
:如果文件已存在且以写方式打开,则将文件长度截断为0。
O_APPEND
:写入时将数据追加到文件末尾。
O_EXCL
:与O_CREAT
一起使用,确保文件不存在时才创建。o_TRUNC,清空文件内容
其他标志还包括
O_SYNC
、O_DIRECT
等,用于控制文件的同步或直接访问方式。
标记位,即int flage ,可能有以下几种选项,只读、只写、读写、创建、追加、清空等
flage原理:
flage是一个位图,其他五种宏是只有一个比特位为1的值
这样在传flage的时候就能有更多的选项参数
当我们输入一个flages参数时,如果按位与之后不为0
(比如flages是0000 0011,那么flages和0000 0001以及0000 0010按位与之后均不为0)
调用时按位或就可以:(0000 0010和0000 0001按位或之后,就变成0000 0011,相当于把ONE和TWO两种功能都包含了)
注意到,因为c语言没有重载,所以有两种写法:
第一个参数是要打开的路径,第二个是位图,第三个是权限参数(接下来会说明)
练习使用open
系统层面不像C语言那样封装的很好,没有创建的时候还需要自己再多加一个选项。没有创建就使用O_WRONLY,会导致打开失败。(可以猜想fopen的w其实是对O_WRONLY和O_CREAT的)
直接O_WRINLY:ll之后没有结果。
应该:
#include <cstdlib> 2 #include <iostream> 3 #include <sys/types.h> 4 #include <sys/stat.h> 5 #include <fcntl.h>//<fcntl.h> 是“File Control Operations”缩写 6 7 using namespace std; 8 9 int main() 10 { 11 open("log.txt",O_WRONLY|O_CREAT); 12 13 return 0; 14 }
创建成功,但是权限出现乱码
操作系统不会直接使用默认的文件权限。
所以建议使用第二个open,可以显示传mode:
重新编译:
可是输入的是0666,得到的竟然是0664(110 110 100)
这和我们以前提到的umask有关
直接umask(0)取消umask默认减小的权限
之后再umask,发现系统中的还没问题。
说明:进程有自己的umask,如果用户没有,自己修改,则默认使用系统的umask;如果进程自己修改,则使用umask
Open类似的系统接口
给了我们一个很直观的感觉:
语言的很多接口只是系统的封装。
请读者自行练习,使用open,write,close等来进行一个文件读写操作
#include <cstdlib>
2 #include <iostream>
3 #include <sys/types.h>
4 #include <sys/stat.h>
5 #include <fcntl.h>
6 #include <unistd.h>
7 #include <string>
8
9 using namespace std;
10
11 int main()
12 {
13 umask(0);
14 int fd = open("log.txt",O_WRONLY|O_CREAT,0666);
15 if(fd == 0)
16 {
17 perror("open");
18 return 1;
19 }
20 cout<<fd<<endl;
21
22 string mes = "hello file aaa";
23 write(fd,mes.c_str(),mes.size());
24
25 close(fd);
26
27 return 0;
28 }
注意,write和close是unistd.h中的 ,使用的fd就是所谓的“文件描述符”,还观察到,此时的文件描述符是3
open返回值
open() return the new file descriptor, or -1 if an error occurred (in which case, errno is set appropriately)
file descriptor---文件描述符, 文件描述符和整数有什么关系呢?我们看到open的返回是个int呀!
![]()
如果修改mes为111,再执行:
难道不是先清空吗?
原因是,我们还需要O_TRUNC选项。
操作系统的接口没有那么适合使用的封装!!!!
不过,如果把Trunc换成Append,就是追加了。
再回头看一下fopen的w选项:
返回值:文件描述符fd的值为什么从3开始?
Linux下一切皆文件,以上三个流也先被当成文件给打开了(0,1,2),所以当我们再打开其他文件的时候,第一个返回的文件描述符就是3。
使用write和read验证以上猜想(两个接口的第一个参数都是fd!):
直接向1写了,并且在执行这个程序的时候是显示屏是打印出来了的,
read:返回值表示的是实际写进去的字符数量
执行的时候居然卡住了,需要我们用键盘输入内容
(read读入的时候会读进去\n,buffer[s-1]是把\n换成\0)
此时说明,1、0都可以直接通过read和write读写(0是键盘,1是显示器)
细节:strlen后面能+1吗?(把\0写进文件),
以\0结尾是C语言的规则,文件中没有这样的规则!文件的写入不能写入\0。
错误的!!
文件描述符:从0开始的连续的小整数
C语言中哪些东西是从0开始的连续整数?
4. 文件描述符、浅显的内存层次理解
下图是CPU与文件、内存间的关系
执行open函数的时候就会打开文件:
需要设计一个strutc file结构,便于操作系统来管理我们导入的文件。
有的属性是从文件的属性中拿到的,
有一些属性(比如文件的读写位置),是文件加载进内存之后才才会生成或者自动维护的。
比如task_struct中的pid,作为一个程序的时候没有这个属性,但是加载进内存之后就有了。
并且还有很多的进程,每个进程还有很多的文件。
类似的,文件管理很像进程管理,但是是两个不同的模块,怎么关联进程和文件呢?
这张绿色的表,叫作 : 文件描述符表
每一个进程都有自己对应的文件描述符表files_struct
文件描述符的本质就是文件下标!
理解FILE
fd作为唯一访问方式,C语言是如何访问的?
FILE是一个结构体,结构体就能通过->直接访问,并且一定包含文件描述符的内容。
可以通过如下实验:
stdin(键盘)是0,stdout(显示器)是1,stderr(显示器)是2,自己再打开的文件对应的是3
这个0\1\2\3。。。对应的表被存在files_strutc中
初识vfs:虚拟文件系统
虚拟文件系统(VFS,Virtual File System)是现代操作系统(如Linux和Unix)中用于管理文件系统的一个抽象层。它为用户和应用程序提供了一个统一的文件系统接口,使得上层应用无需关心底层文件系统的具体实现
VFS的目的:让所有的文件的操作都变成 open read write等接口。打开一个键盘所对应的文件和显示屏所对应的文件(stdout 和 stdin),显然是不一样的,不过只需要传不同的函数指针给struct file类就行了:文件类型多种多样,我们希望使用统一的struct file来描述、管理文件,那就只能在不同的文件中存不一样的函数指针,分别指向每种设备的具体实现
这样的结构,就是CPP中的多态!file是基类,下面的是子类
5. 文本写入以及二进制(理解C/CPP为什么封装)
当你想将12345显示到显示器上:
看一下是12345吗?不是!
显示器是一种字符设备!只能打字符串
直接用系统调用接口不能打印字符,想打印字符必须要自己去格式化
这就是为什么C语言会有那么多的打印功能,否则所有的转换都得程序员自己去把
各种数据转换成char,才能打印到屏幕上。
read同理:
从0(stdin)读到buffer里,然后在buffer中再以“%d”的形式转换成整数,放到a当中去。
当然,以上工作就被scanf一个接口给封装好了,因此C/C++类语言喜欢这样去封装。
write和read的buffer的类型都是void*,因为他不需要区分类型,只是按照一个一个字符去打印。
不同的系统,所对应的系统调用肯定不一样,作为一个语言该怎么办呢?
windos情况下,C语言封装的是win的系统接口;
Linux下,C封装的就是linux的接口(open,write,read)
这就是所谓的运行程序之前要先安装环境,安装环境就是安装所对应的库(比如glibc),
win环境下就可以删除Linux下对应的库;反之亦然。
这样的操作提高语言的可移植性。C语言的接口(fopen,fwrite)在不同的系统下封装的系统接口就不一样,但是使得起在不同平台的使用成本就降低了。
总之,这些都是语言层的东西,系统的操作
6. IO的基本概念
6.1 文件内核缓冲区
区分struct files_struct和struct file
files_struct是PCB中的指针所指向的“文件描述符表”,而一个一个的file是文件描述符表中的描述打开的文件的基本单位。
struct file包含的两个内容(肯定也会包含文件的基础属性等内容):
操作表,就是之前的各种文件操作所对应的函数指针。
![]()
理解内核缓冲区的作用和意义:
内核缓冲区以4字节为单位,以字典树的数据结构管理内存(以后详细讲,不是重点)
写入:当我们要write(3,"hello"...),3可以看作是我们自己打开的一个文件
当进程执行
write()
系统调用时,数据首先从用户空间拷贝到内核缓冲区。操作系统会根据策略决定何时将缓冲区中的数据刷新到磁盘。这可能是定时刷新、缓冲区满时刷新,或者在文件关闭时刷新,这完全由OS自主决定
write的本质是把内容拷贝到缓冲区中,再由缓冲区刷新到外设上(此处是磁盘)。
write本质是一个拷贝函数,本质是从用户拷贝到内核,从缓冲区刷新到外设的过程则由OS自主决定
读取、修改:
读取操作:
当进程执行
read()
系统调用时,数据首先从磁盘读取到内核缓冲区,然后从缓冲区拷贝到用户空间。如果数据已经在缓冲区中(例如,之前已经被读取或写入),则可以直接从缓冲区中获取,而无需访问磁盘(比如文件的预加载)。
修改=先读取+再写入
由冯诺依曼可得,内核缓冲区用于提高内存效率
包括文件的预加载等,也是对缓冲区的利用
6.2 重定向
尝试关掉0和2,再打印一下各个fd值:
其中,文件描述符表满足:
那如果关掉1呢?
为什么此时按理来说,log*.txt应该有内容?
close(1)之后,分配fd1的序号就是1,操作系统遍历整张表的时候发现,1号(最小的没使用的文件描述符)没人用。就把一号的位置给fd1了。
printf的本质是fprintf将流写死成stdout的封装。
printf是上层,只认“1”,但是我调用下层接口,已经把1改了,所以现在再printf就是直接往fd1对应的文件去写入。
加一个fflush,就恢复到了我们预期的样子。至于原因,我们先遗留在这里,看完下面两个点就理解了。
改变了文件描述符表中的特定内容,导致输出或者输入的位置变化的现象叫重定向。
重定向中上层接口毫不知情,不过这种重定向的方法不优雅。
接口:dup2
目的是让上层的接口直接往我们希望被写入的文件去写。
将希望被写入的文件的地址覆盖到对应文件描述符所指向的地址(由左边的参数覆盖到右边的参数)
例如:
dup2(fd,1)
将fd对应的file*所指向的文件覆盖1对应的file* 所指向的文件进行
此时,原来的1号所对应的文件会关掉(closing......if necessay),因此有两个指针指向我们的fd
那想关闭该文件的时候该如何操作?(非重点)
采用引用技术的概念,每次关闭的时候就把指向他的指针数字--。指向他的指针数目为0才表示关闭
练习使用重定向:
#include <iostream>
2 #include <stdio.h>
3 #include <string>
4 #include <cstdlib>
5 #include <string.h>
6 #include <unistd.h>
7 #include <sys/types.h>
8 #include <sys/stat.h>
9 #include <fcntl.h>
10
11 using namespace std;
12
13 int main()
14 {
15 int fd1 = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0666);
16
17 dup2(fd1,1);
18
19 printf("fd1:%d",fd1);
20
21
22
23
24
25
26
27 //int a = 12345;
28 //write(1,&a,sizeof(a));
29
30 return 0;
31 }
甚至直接对着stdout写,依然不是打到屏幕上:
因为stdout是一个FILE*类型的指针,此时这个该FILE*所指向的其实都是fd1对应的文件。
这些C语言接口只管往“1”里写,但不在乎现在的1是什么。
输出重定向和追加重定向的使用没有差别,
7. 在myshell中实现自己的重定向
根据之前对shell的理解,初识Linux(11):写一个自定义shell-优快云博客
我们在自己的shell中来完成对命令行的重写。
int main(argc,argv)
回到之前实现的shell,实现命令行的重定向
输入的命令的格式也不同。
为了在分析指令中把重定向符号前后的内容分开,我们:
先做到一个将指令分开:
编写一个宏来让重定向符号两边的空格不影响语法
完成了分析指令的工作,我们需要fork出子进程,到子进程中去进行重定向
历史上打开的所有文件都不会因为程序替换而受到影响
程序替换只改变代码和数据
所以我们应该在程序替换之前先把dup2工作做好
框架:
实现:注意,一定一定只能在子进程中进行重定向!!!!!因为重定向是覆盖式写法,一旦让shell也重定向,之后再想通过close等操作挽救是行不通的!
if(stat_redir == InputRedir) 252 { 253 //执行输入重定向 254 if(filename) 255 { 256 int fd = open(filename,O_RDONLY); 257 if(fd < 0) 258 { 259 exit(2);//open失败 260 } 261 dup2(fd,0); 262 263 } 264 else 265 { 266 exit(1); 267 } 268 } 269 else if(stat_redir == OutputRedir) 270 { 271 //执行输出重定向 272 if(filename) 273 { 274 int fd = open(filename,O_CREAT | O_WRONLY | O_TRUNC); 275 if(fd<0) 276 { 277 exit(4); 278 } 279 dup2(fd,1); 280 } 281 else 282 { 283 exit(3);//filename不存在 284 } 285 } 286 else if(stat_redir == AddputRedir) 287 { 288 //执行追加重定向 289 if(filename) 290 { 291 int fd = open(filename,O_CREAT | O_WRONLY | O_APPEND); 292 if(fd<0) 293 { 294 exit(6); 295 } 296 dup2(fd,1); 297 } 298 else 299 { 300 exit(5);//filename不存在 301 } 302 }
输入重定向的时候,没有输入filename(表示输入有误),直接exit(1)退出;打开文件失败了,直接exit(2)退出
最后一步,可以模块化这个ParseCommandLine不用担心没有关闭我们open过的函数没有关闭,进程结束时,打开的文件高概率就被自动关掉了
即:文件描述符的生命周期随进程
8. 语言缓冲区与内核缓冲区
读写⽂件时,如果不会开辟对⽂件操作的缓冲区,直接通过系统调⽤对磁盘进⾏操作(读、写等),那么每次对⽂件进⾏⼀次读写操作时,都需要使⽤读写系统调⽤来处理此操作,即需要执⾏⼀次系统调⽤,执⾏⼀次系统调⽤将涉及到CPU状态的切换,即从⽤⼾空间切换到内核空间,实现进程上下⽂的切换,这将损耗⼀定的CPU时间,频繁的磁盘访问对程序的执⾏效率造成很⼤的影响。
得出结论:
调用系统调用,也是有成本的!
系统调用也是函数,其调用成本甚至比一般函数接口高
这也是为什么C++的STL每次扩容时都是1.5倍或者2倍,因为每次扩容其实都是调用系统调用。那么相同于冯诺依曼中,为了弥补外设速度慢而搭建的内核缓冲区,我们在语言层面也可以设置缓冲区,减少内核的消耗。
在FILE中,C语言的接口封装了一个新的用户级缓存区,比如fwrite或者fputs之类的,在拷贝到内核缓冲区之前,先拷贝到用户级缓冲区。再通过用户级缓存区拷贝到内核缓存区,至此,我们认为剩下的工作就交给操作系统,与我们无关了。其中,显示屏文件较为特殊,是“行刷新”,即每次到换行的地方就会将数据刷新到内核缓冲区中去;其他文件为“全刷新”,即等到语言缓冲区较满了才会刷新到内核缓冲区
FILE* 本质是一个结构体,所以C语言各个接口其实都是先向FILE*里面写东西。
回头理解之前的遗留问题:
为什么必须要fflush之后才能观察到?
执行printf之后,首先打印到FILE*的缓冲数组中去:
在我们之前没有fflush的版本中,我们在刷新之前直接就在系统层面(close(fd))把文件关掉了,文字内容留在语言级缓冲区中,自然就打印不进去。
那么,如果close和fflush同时都被注释掉呢??
这样就能写进一号文件了!!
就像exit能冲刷缓存区,而_exit(系统接口)就不能冲刷缓存区。
![]()
但是如果在进程退出之前就close,把文件描述符给关了,这样进程退出的时候,想自动刷新缓存区都不行。
可是显示器文件不是行刷新吗,为什么printf的\n没起作用呢?
因为dup2之后,此时已经不是显示器文件,而是普通文件,执行的是全刷新。
当然,也有可以手动刷新内核缓存的函数:fsync
内核缓存到外设的手动刷新
来个测试代码验证刚才的所有内容:(不要对char*使用sizeof,否则得到的是指针的大小,这是一个典型的C语言错误)
现象:
如果只在return 0前面加一个fork(),结果依然正常,
但是如果改成输出重定向,而非直接执行:
一旦发生重定向,C语言对应的接口的刷新规则就变成了“全刷新”,要在缓冲区写满了才会刷新。但是write是直接向文件写入的,不受语言缓冲区的影响。当fork之后,父子进程写时拷贝,分别刷新各自的缓冲区,所以这些先存在语言缓冲区的内容就会刷新。
因此,只有write(系统接口)打印一遍,其他都打印了两遍。
所以大家可以想想,虽然显示器默认是行刷新,但是假如我们把每次打印的\n都给去掉,不会触发显示器的行刷新,就算不重定向,fork之后也会打印两次C语言库中的东西。