Linux --基础IO
理解"文件"
1.狭义理解:(1)文件在磁盘里 (2)磁盘是永久性储存介质,所以文件在磁盘上的储存是永久的。(3)磁盘是外设,既是输入也是输出设备。 (4)磁盘上的文件本质是对文件的所有操作,都是对外设的输入和输出,简称IO。
2.广义理解:Linux下一切皆文件(键盘,显示器,网卡,磁盘抽象化的过程)
3.文件操作的归类认知:(1)对于0kb的空文件也是会占用磁盘空间的,显示kb是因为文件内容为空。 (2)文件是文件属性和文件内容的集合 (3)所有的文件操作本质都是对文件内容和文件属性的操作。
4.系统角度:
(1)对文件的操作本质是进程对文件的操作。当一个文件被读写访问的时候实际上是进程在进行访问,根据冯诺伊曼体系此时文件就会从磁盘被加载到内存之中,进程可以通过cpu访问文件的内容数据和属性。一个进程可以打开多个文件,那么多个进程就可以打开多个文件,此时OS需要管理内存中的文件就必须通过数据结构来将众多的文件关联起来,所以在Linux内核中文件就由一个struct file的结构体管理着每一个文件的内容和属性,然后通过指针的方式将每一个struct file对象连接起来,这样OS就完成了对文件的组织管理,这和进程的管理思想是统一的。
(2)⽂件的读写本质不是通过 C 语⾔ / C++ 的库函数来操作的(这些库函数只是为⽤⼾提供⽅便),⽽是通过⽂件相关的系统调⽤接⼝来实现的,所以语言层次的库函数也只是封装了系统调用。
这里使用了fopen来打开文件,此时文件被加载到内存中,通过fwrite,fprintf,fread等接口可以完成对文件的读写操作。
使用对文件读写的方式模拟cat读取文件,注意:buffer[bytes_read] 不会自动添加 '\0',需手动处理。
5.stdin & stdout & stderr
在进程打开以后会自动打开三个输入输出流,分别是stdin,stdout,stderr,这三个流类型都是FILE*,在上面提到过一切皆文件,所以如果进程会自动打开这三个输入输出流那么我们就能够直接对他们进行读写,stdout和stderr都代表显示器,stdin代表键盘。
所以我们可以通过写入文件的方式将字符写到显示器然后打印出来。
系统⽂件I/O
1.打开⽂件的⽅式不仅仅是fopen,ifstream等流式,语⾔层的⽅案,其实系统才是打开⽂件最底层的⽅案,其中底层有两个宏封装的函数可以进行打开文件。
这两个函数唯一的区别是多了一个mode,对应的是文件的权限,即可以在打开一个不存在的文件时会新建这个文件并设置他的权限,不过他会继承父进程的umask,所以我们设置的权限是会受umask影响的。结论:如果一个文件不需要新建则使用第一个open,需要创建则需要使用第二个open,否则文件权限会错乱。
pathname对应的是一个目录下的文件名字,如果只写文件名字不带路径默认在当前进程工作木兰下进行查找。flags参数对应文件操作的标志位,这是一个32个比特位的参数,可以在不同的比特位设置1表示不同的读写权限等,这样就可以一次传"多个"参数,我们可以用以下的代码来理解这种以比特位传递标志位的方法。
通过比特位的按位操作符我们就能够使传入一个参数执行符号多个标志位的操作,而open中定义了几个常用的标志位
所以我们就能够通过这些参数来实现不同的文件打开读写方式
所以意味着在c语言中的文件打开,读写函数都是通过调用系统接口以及传递不同的标志来实现的,fopen("file.txt","w") = open("file.txt",O_WRONLY|O_CREAT|O_TRUNC) ,fopen("file.txt","a") = open("file.txt",O_WRONLY|O_CREAT|O_APPEND)
其中open的返回值fd是一个文件描述符,我们可以通过fd对指定的文件进行读写操作,打开失败返回-1。Linux进程默认情况下会有3个缺省打开的⽂件描述符,分别是标准输⼊0, 标准输出1, 标准错误2。0,1,2对应的物理设备⼀般是:键盘,显⽰器,显⽰器。
所以输⼊输出还可以采⽤如下⽅式:
char buf[1024];
ssize_t s = read(0, buf, sizeof(buf));
if(s > 0){
buf[s] = 0;
write(1, buf, strlen(buf));
write(2, buf, strlen(buf));
}
return 0;
那么这个fd究竟是什么?fd其实是一个数组的下标,是系统层面访问文件的唯一方式。C语言中的FILE* 其实是对fd进行了一个封装,printf,scanf函数这些也是语言层面对系统调用的封装,这不仅是为了用户的方便操作,不需要传递多个标志位等复杂的操作,也是对多系统的一个可移植性,因为上层使用的都是fopen,但是底层有不同的fopen的实现,用户在使用的时候不需要关系系统,只需要关注语言本身即可。
为什么说fd是一个数组下标,在进程task_struct的内部有一个struct files_struct *files指针,在这个files_struct结构体中又在存在一个struct file* fd_array[]的指针数组,这个file就是我们上面提到的OS用来管理文件的内容和属性的结构
这个指针数组是每个进程PCB内部独享的,每个元素都是一个file*,文件在被加载到内存中就会创建自己的file对象,其中包括路径属性等一系列信息,通过这个指针就能读写该文件,本质上,⽂件描述符就是该数组的下标。所以,只要拿着⽂件描述符,就可以找到对应的⽂件。
由于进程在打开以后默认就会打开三个输入输出流,所以我们其他文件的文件描述符都是从3开始的,但是如果我们将其中stdout关闭,下标1就没有元素,此时如果有新打开的文件就会使用1作为它的文件描述符,可⻅,⽂件描述符的分配规则:在files_struct数组当中,找到 当前没有被使⽤的最⼩的⼀个下标,作为新的⽂件描述符。
printf()函数默认是向stdout写入数据,这里的stdout其实是封装的一个宏,代表1的文件描述符,所以是不是意味着如果我们将stdout关闭,再打开一个新文件,此时再使用printf则会向新文件写入?我这里写了一个代码来测试一下
结果显然易见确实将打印的内容输出到文件之中了,这种操作我们称之为输出重定向,常⻅的重定向有: > , >> , <。
重定向的本质其实是将关闭了的stdout的下标位置的元素的file*指针覆盖成为新打开文件的file*指针,printf是C库当中的IO函数,⼀般往 stdout 中输出,但是stdout底层访问⽂件的时候,找的还是fd:1, 但此时,fd:1下标所表⽰内容,已经变成了log.txt的地址,不再是显⽰器⽂件的地址,所以,输出的任何消息都会往⽂件中写⼊,进⽽完成输出重定向。
在linux中我们有对应的系统调用函数dup2(int oldfd, int newfd)来完成这种操作.
但是还有一个问题就是不同的硬件为什么可以使用同样的读写操作?其实并不是,在struct file内部有一个struct file_operation *f_op,file_operation其中的成员变量都是一些函数指针,包括读写函数,也就是说文件或者硬件在初始化的时候都会将它们的读写方法函数进行初始化,每个硬件即使读写方法不同但是也可以通过这种类似多态的方式完成相关的操作,所以说Linux下一切都是文件,包括硬件。这样做最明显的好处是,开发者仅需要使⽤⼀套 API 和开发⼯具,即可调取 Linux 系统中绝⼤部分的资源。举个简单的例⼦,Linux 中⼏乎所有读(读⽂件,读系统状态,读PIPE)的操作都可以⽤read 函数来进⾏;⼏乎所有更改(更改⽂件,更改系统参数,写 PIPE)的操作都可以⽤ write 函数来进⾏。
file_operation 就是把系统调⽤和驱动程序关联起来的关键数据结构,这个结构的每⼀个成员都对应着⼀个系统调⽤。读取 file_operation 中相应的函数指针,接着把控制权转交给函数,从⽽完成了Linux设备驱动程序的⼯作。
缓冲区
缓冲区是内存空间的一部分,在内存空间中对文件进行的IO操作并不是直接写入文件的,而是会将需要写入的内容拷贝到缓冲区,在合适的时候由OS写入文件中。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
为什么要引入缓冲区机制?读写⽂件时,如果不开辟对⽂件操作的缓冲区,直接通过系统调⽤对磁盘进⾏操作(读、写等),那么每次对⽂件进⾏⼀次读写操作时,都需要使⽤读写系统调⽤来处理此操作,即需要执⾏⼀次系统调⽤,执⾏⼀次系统调⽤将涉及到CPU状态的切换,即从⽤⼾空间切换到内核空间,实现进程上下⽂的切换,这将损耗⼀定的CPU时间,频繁的磁盘访问对程序的执⾏效率造成很⼤的影响。为了减少使⽤系统调⽤的次数,提⾼效率,我们就可以采⽤缓冲机制。⽐如我们从磁盘⾥取信息,可以在磁盘⽂件进⾏操作时,可以⼀次从⽂件中读出⼤量的数据到缓冲区中,以后对这部分的访问就不需要再使⽤系统调⽤了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作快于对磁盘的操作,故应⽤缓冲区可提⾼计算机的运⾏速度。⼜⽐如,我们使⽤打印机打印⽂档,由于打印机的打印速度相对较慢,我们先把⽂档输出到打印机相应的缓冲区,打印机再⾃⾏逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是⼀块内存区,它⽤在输⼊输出设备和CPU之间,⽤来缓存数据。它使得低速的输⼊输出设备和⾼速的CPU能够协调⼯作,避免低速的输⼊输出设备占⽤CPU,解放出CPU,使其能够⾼效率⼯作。
标准I/O提供了3种类型的缓冲区:
1.全缓冲区:这种缓冲⽅式要求填满整个缓冲区后才进⾏I/O系统调⽤操作。对于磁盘⽂件的操作通常使⽤全缓冲的⽅式访问。








write
直接写入内核,也不会在标准 I/O 缓冲区停留。所以 fork
时,父进程的标准 I/O 缓冲区已经是空的,子进程复制父进程地址空间后,不会有残留的缓冲区内容需要输出。最终父进程和子进程退出时,标准 I/O 缓冲区无内容,因此不会重复打印
当输出被重定向到文件时,printf
/fwrite
的标准 I/O 缓冲区会切换为 全缓冲(需要缓冲区写满、显式 fflush
或程序退出时才会刷新)。fork 的关键影响:程序执行流程中,printf 和 fwrite 的内容会先放到标准 I/O缓冲区(因为全缓冲,没触发刷新),然后执行 fork:fork 会复制父进程的整个地址空间,包括标准 I/O 缓冲区里未刷新的内容(printf 和 fwrite 的数据)。之后,父进程和子进程各自退出时,标准库会自动刷新缓冲区,导致:
printf 的内容:父进程退出刷一次,子进程退出刷一次 → 重复。
fwrite 的内容:父进程退出刷一次,子进程退出刷一次 → 重复。
write 是系统调用,直接写入内核,不经过标准 I/O 缓冲区,所以只输出一次。
所以说明了这个缓冲区是存在于语言层面的,与操作系统内核级的缓冲区并不相同,这也是c/c++语言高效的原因之一,为了减少系统调用采用了和内核级缓冲区一样的思路去储存用户输入输出的内容,因为系统调用也是有消耗的。那么这个缓冲区究竟是在哪里?我们可以在库中发现是和fd一起封装在FILE结构体之中,会将用户读写的内容开辟内存空间进行储存,当需要全缓冲区满了或者行缓冲区遇到换行符就会将其中的内容写到系统的内核级缓冲区,由系统自主写入文件之中。
了解到了以上的原理知识我们知道了IO相关的操作都是对系统调用封装,所以我们自己也能去封装属于自己的库,这里就模仿封装一个简单的libc库
完整代码已经上传gitee仓库,需要的可以自取https://gitee.com/toutie40/study_code.git