✨感谢您阅读本篇文章,文章内容是个人学习笔记的整理,如果哪里有误的话还请您指正噢✨
✨ 个人主页:余辉zmh–CSDN博客
✨ 文章所属专栏:Linux篇–CSDN博客
文章目录
- 一.初步了解文件
- 二.C语言文件接口函数
- fopen()函数
- fwrite()函数
- fread()函数
- fclose()函数
- 三.文件系统调用函数
- open()函数
- write()函数
- read()函数
- close()函数
- 四.文件描述符表
- 五.重定向
- 输出重定向
- 追加重定向
- 输入重定向
- 六.缓冲区
一.初步了解文件
文件=文件内容+文件属性
其中文件又分为打开的文件和没有打开的文件:
对于没有打开的文件则是在磁盘上存放,但是没有打开的文件非常多,对于没有打开的文件主要研究文件是如何被分门别类的放置好,也就是如何存储,通过对文件的存储进行管理,实现用户可以快速的找到目标文件,进行增删查改。
而对于打开的文件则是由进程打开,因此研究打开的文件,主要是研究进程和文件的关系!而文件被打开,必须先加载到内存中;此外,一个进程可以多个文件,就注定了操作系统内部,一定存在大量的被打开的文件!
既然存在大量的被打开的文件,操作系统就要对这些文件进行管理!如何管理?
当然是之前讲过的管理策略:先描述,再组织。
在内核中,一个被打开的文件都必须有自己的文件打开对象(struct XXX{文件属性;指针信息…}),这个对象中包含了该文件的很多属性。
每个文件都有自己的内核数据结构对象,这就是先描述;多个对象再通过某种数据结构(比如链表)进行存储,这就是组织。
注意:后面讲解的内容都是围绕打开的文件来讲解,和未打开的文件无关。
二.C语言文件接口函数
fopen()函数
FILE *fopen(const char *pathname, const char *mode);
-
功能:
打开或创建一个文件,返回指向该文件的指针(FILE *),后续读写操作都是通过指向该文件的指针进行。
-
参数:
pathname
:文件的路径名(绝对或相对路径);mode
:文件的打开模式,决定读写权限和文件状态;常用的模式有:“r” , “w” , "a"等 -
返回值:
打开成功返回指向该文件的指针(FILE *);如果失败,返回空指针(比如路径错误,权限不足,磁盘满等)。
模式 | 含义 | 说明 |
---|---|---|
"r" | 只读 | 文件必须存在,否则返回NULL |
"w" | 写入 | 若文件存在则清空内容;不存在则创建 |
"a" | 追加 | 写入时追加到文件末尾,不会清空文件内容,不存在则创建 |
"rb"/"wb" | 二进制读/写 | 以二进制模式操作文件(避免换行符转换) |
"r+"/"w+" | 读写 | "r+"要求文件存在;"w"会清空文件 |
fwrite()函数
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
-
功能:
将内存中的数据块以二进制方式写入文件。
-
参数:
ptr
:指向要写入数据的内存块的指针。比如&num
(写入整数);buffer
(写入数组,数组名就是地址)。size
:每个数据项的大小(以字节为单位),比如sizeof(int)
(写入整数数组)。nmemb
:数据项的数量,比如100
(表示要写入100个整数)。stream
:要写入的目标文件的地址,由fopen()函数返回。 -
返回值:
如果写入成功,返回实际写入的数据项数量(可能小于参数
nmemb
,需要检查是否全部写入);如果写入失败,返回0。
fread()函数
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
-
功能:
从文件中读取二进制数据块到内存。
-
参数:
ptr
:指向存储读取数据的内存块的指针,比如&num
(读取整数),buffer
(存储读取的字节)。剩余三个等同于
fwrite()
函数的参数。 -
返回值:
如果读取成功,返回实际读取的数据项数量。
fclose()函数
void fclose(FILE *stream);
-
功能:
将目标文件关闭。
-
参数:
stream
:要关闭的目标文件的地址。
通过代码测试来讲解打开模式中"w"
和"a"
的区别:
如果目标文件不存在,并且是w
,还不带路径,默认在当前进程的工作路径下创建文件:
如果更改了当前进程的工作路径,就会创建到更改的路径下:
,不清空文件:
三.文件系统调用函数
open()函数
#include <fcntl.h>int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
-
功能:
打开或创建一个文件,返回一个整数,后续通过这个整数对文件进行其他操作(这个整数是什么后面会讲解,可以先暂时理解为是指向该文件的指针)。
-
参数:
pathname
:文件的路径名(绝对或相对路径);flags
:打开文件的模式和行为,类似于C语言中的打开模式"r","w"等。mode
:仅在O_CREAT
标志存在时有效,指定创建新文件的权限(以八进制表示,后面会讲如何使用)。 -
返回值:
如果打开成功,返回一个非负整数;如果打开失败,返回-1。(返回值具体内容后面讲解)
常用的几种flags
标志:
标志 | 含义 |
---|---|
O_RDONLY | 只读模式(不能写入) |
O_WRONLY | 只写模式(不能读取) |
O_RDWR | 读写模式(可以读取,也可以写入) |
O_CREAT | 如果文件不存在,则创建文件,需配合第三个参数mode |
O_TRUNC | 如果文件存在,则清空文件内容 |
O_APPEND | 追加模式,写入时追加到文件末尾,不清空文件内容 |
第二个参数flags采用的是比特位方式的标志位传递参数,先补充一下这种传参方式是如何实现的:
通过一段代码模拟实现比特位方式的标志位传递参数:
flags参数通过该方式传递参数,可以实现多种标志搭配,从而实现不同的效果。
接下来通过代码来演示标志位的具体使用方式。
单独使用O_WRONLY
,对于不存在的文件并不会创建:
如果想要对于不存在的文件,进行创建,需要带上O_CREAT
标志位
int fd = open("log.txt", O_wRONLY|O_CREAT);
但是带上O_CREAT
后,创建的文件却没有对应的权限:
这时候就要用到第三个参数mode
,指定权限:
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
但是,mode
参数指定的权限为0666,创建出来的文件对应的权限却是0664,这是因为存在权限掩码umask(0002)
,导致最后的最终权限为0664。
如果想指定权限掩码的值,可以通过umask()
函数,自己设置对应的umask值:
设置成0后,创建出来的文件对应的权限就是0666
umask(0);
int fd = open("log.txt", O_WRONLY|O_CREAT,0666);
常用的mode
权限(默认umask为0002的情况下):
权限 | mode(八进制) | 含义 |
---|---|---|
-rw-r--r-- | 0644 | 用户可读写,组合其他用户只读 |
-rwxr-xr-x | 0755 | 用户可读写执行,组合其他用户可读执行 |
-rw-rw-rw- | 0666 | 所有用户可读写,但都不能执行 |
write()函数
#include <unistd.h>ssize_t write(int fd, const void *buf, size_t count);
-
功能:
将数据写入目标文件或设备。
-
参数:
fd
:由open()函数返回的一个整数,可以先暂时理解为指向目标文件的指针。buf
:指向要写入的数据的指针。count
:要写入的字节数。 -
返回值:
写入成功,返回实际写入的字节数(可能小于参数
count
,比如磁盘空间不足);如果写入失败,返回-1。
该函数需要搭配open()函数使用,写入方式由open()函数的标志位决定。
测试两次写入不同的内容,第一次写入后不删除,第二次继续写入后的现象:
红色方框是第一次写入的内容,绿色方框是第二次写入的内容:
第二次写入的内容覆盖第一次的内容,并没有先清空文件再写入。
如果要想先清空文件内容再写入,需要在open函数中添加O_TRUNC
标志位:
int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
如果不清空之前的内容,而是追加写入,需要在open函数中添加O_APPEND
标志位:
int fd = open("log.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
read()函数
#include <unistd.h>ssize_t read(int fd, void *buf, size_t count);
-
功能:
从目标文件或设备中读取数据到指定数据块中
-
参数:
fd
:暂时理解为指向目标文件的指针。buf
:指向存储读取数据的数据块的指针。count
:要读取的最大字节数。 -
返回值:
如果成功,返回实际读取到的字节数(可能小于参数count,比如达到文件末尾);如果读取到文件,返回0;如果读取失败,返回-1。
close()函数
关闭目标文件
#include<unistd.h>void close(int fd);
简单总结一下,系统调用open函数flags标志位和C语言fopen()函数文件模式的对应关系:
RDONLY | "r" |
---|---|
O_WRONLY|O_CREAT|O_TRUNC | "w" |
O_WRONLY|O_CREAT|O_APPEND | "a" |
四.文件描述符表
接下来讲解open函数的返回值int fd
是什么。
在刚开始的时候提到过,文件是由进程打开,而一个进程可以打开多个文件,所以需要对打开的文件的进行管理,由此引出了文件内核数据结构struct file
,该内核数据结构是描述一个被打开文件的信息。
struct file{//直接或间接地包含以下属性://在磁盘的什么位置//基本属性,权限,大小,读写位置//哪个进程打开的//文件的内核缓冲区信息//指向下一个文件结构体对象的指针struct file *next;....
};
每一个被打开的文件都要创建一个内核数据结构struct file
对象,通过双链表的形式组织,打开一个文件就是从链表中插入一个新的struct file
对象,关闭一个文件对应的引用计数减一,当减为0时就是从链表中删除对应的struct file
对象。
而一个进程可以打开多个文件,因此进程就需要对多个被打开的文件进行管理(通过结构体对象进行管理),如何进行管理?
这就要引出一个新的内核数据结构体struct files_struct
,该结构体的指针存放在打开文件的进程的PCB中,在该结构体中有一个指针数组struct files *fd_array[]
,存放的是每个文件结构体对象的地址struct file*
,而该数组的下标是整数,这就是open()函数的返回值。
open()
函数的返回值int fd
本质上就是数组的下标,又叫做文件描述符,而该数组就是文件描述符表。
通过代码验证:连续打开四个文件,得到的返回值一定是连续的几个数字
但是数组下标不应该是从0开始吗?为什么这里是直接从3开始?
这是因为C语言程序在启动时,会默认打开三个文件:标准输入流,标准输出流,标准错误流:
其中文件描述符0对应的是标准输入流(键盘文件),文件描述符1对应的是标准输出流(显示器文件),文件描述符2对应的标准错误流(也是显示器文件)。
这时候再来看C语言中的fopen函数返回值FILE *
,因为库函数底层要调用系统调用函数,而系统调用只认文件描述符,所以对文件的操作最后都要依赖文件描述符,因此FILE
这个C语言封装的结构体一定会包含文件描述符这个变量:
补充内容:引用计数
还是上面的一段代码,如果把标准输出流关闭:
什么都没有打印出来
因此printf
打印内容一定会用到标准输出流,显示器文件。
如果用fprintf
函数打印返回值:
依然可以打印,为什么?
一个文件可以被多个程序打开,每个被打开的文件都有一个内核数据结构struct file
,里面包含一个引用计数count
,表示当前文件被多少个程序打开。
而关闭文件的本质:当某个程序关闭当前文件时,当前文件的引用计数就会减一,该文件在数组中对应下标中的地址置为空,如果该文件的引用计数变为0,表示没有被任何程序打开了,操作系统就会回收该文件的内核数据结构。
而文件描述符1和2都是显示器文件,引用计数为2,关闭文件描述符1显示器文件后,显示器文件引用计数减一,变为1,所以内核数据结构并不销毁,依然可以向标准错误流写入,在显示器上看到。
五.重定向
讲解重定向之前先来了解文件描述符表的分配规则。
通过一段代码测试,测试四次,第一次:在默认打开三个文件的情况下,打开一个新的文件,打印这个文件的文件描述符最后得到的是3;第二次:在关闭文件描述符0(键盘文件)后,再打开一个新的文件,打印这个新文件的文件描述符就是0;第三次:在关闭文件描述符1(显示器文件)后,再打开一个新的文件,这个新文件的文件描述符就是1,因为前面提到过printf
函数底层要用到文件描述符1显示器文件,但是现在关闭也就没有内容打印出来;第四次:在关闭文件描述符2(显示器文件)后,再打开一个新的文件,打印这个新文件的文件描述符就是2:
根据上面的测试可以发现,文件描述符表的分配规则就是从数组的低下标依次往后遍历找空位置,一旦找到空位置就将要打开的文件的struct file*
存放到数组中,然后返回下标。
输出重定向
在三个默认文件没有关闭的情况下打开一个文件,但是并不写入,而是直接写入到标准输出流文件中,就可以在显示器上看到写入的内容,
但是如果在上面的前提下,先将标准输出流也就是显示器文件关闭(close(1)
),再打开一个新的文件,要写入的内容就不会在显示器上看到,而是写入到了新打开的文件中
将原本要写入到显示器文件中的内容,重定向写入到目标文件中,这就是输出重定向。
如果每次重定向之前都先将原本的文件关闭,就太麻烦,可以直接使用系统调用dup2()
函数:
dup2()函数:
#include <unistd.h>
int dup2(int oldfd, int newfd);
-
功能:
第一个参数oldfd是重定向的目标文件的文件描述符,第二个参数newfd是原本要写入文件的文件描述符。将文件描述符表中oldfd对应的
struct file*
拷贝到newfd对应的位置上,通过指向同一份地址使两个文件描述符指向同一个文件。(如果newfd是已经打开的文件,dup2函数会先关闭该文件,类似隐式调用close(newfd)。)。 -
返回值:
成功时返回newfd;失败时返回-1。
通过系统调用实现输出重定向:
原理:
追加重定向
输出重定向每次写入都会清空原本的内容,如果想要不清空,再原有的基础上继续写入,就需要使用追加重定向。
追加重定向,还是上面的测试代码将open函数中的O_TRUNC
改成O_APPEND
:
int fd = open(filename, O_CREAT|O_WRONLY|O_APPEND,0666);
输入重定向
将原本要从键盘文件中读取的内容,重定向为从目标文件中读取,这就是输入重定向。
补充内容:
分别向文件描述符1标准输出流和2标准错误流写入内容,两个都是显示器文件,正常执行程序,就会在显示器上看到写入的内容:
如果输出重定向到normal.txt
文件,就会将写入标准输出流的内容重定向到该文件,标准错误流还是写入到显示器文件:
该指令默认是将文件描述符1的内容重定向到目标文件,因此该指令实际是./mytest 1>normal.txt
;当然还可以指定文件描述符重定向:
除了上面的将两部分内容分别重定向到不同的文件,还可以重定向到同一个文件中:
这里的2>&1
的意思是,取文件描述符1对应的文件结构体地址拷贝给文件描述符2。使文件描述符1和2都指向同一份文件的结构体地址.
六.缓冲区
如果输出带换行符的语句:
红色方框中第一次的结果是不加close(1)
语句,绿色方框中是第二次加上该语句的结果:
如果是不带换行符:
红色方框中第一次是不加close(1)
语句的结果,绿色方框中是第二次加上该语句的结果
根据上面的两组测试可以发现:对于C语言接口函数打印没有换行符的语句,在最后关闭标准输出流显示器文件后,没有内容打印出来,而系统调用函数却不受影响,这就说明一定存在两种不同的缓冲区!而C语言接口函数这些使用的缓冲区一定不在系统内部,一定不是系统级别的缓冲区!
当调用printf(),fread()
等函数时,因为要在显示器文件上显示的内容都是写入到显示器文件中,而这些C语言接口函数并不是立即写入到显示器文件中,而是先暂时写入到C语言为我们提供的一个缓冲区,这个缓冲区就是用户缓冲区。
同样的,系统调用函数write()
等,也并不是立即写入到显示器文件中,也是先写入到缓冲区中,而这个缓冲区则是系统缓冲区(也就做内核缓冲区)。
C语言等接口函数要将数据先写入到用户缓冲区,然后再根据刷新策略调用系统函数write
刷新到内核缓冲区,最后内核缓冲区再根据刷新策略刷新到显示器文件或磁盘中。
接下来根据用户缓冲区分三个问题讲解:
1.用户缓冲区的刷新问题:
对于内核缓冲区的刷新策略,何时刷新以及维护由操作系统决定。只需了解语言层次的用户缓冲区刷新问题即可。
-
无缓冲刷新:
不考其他虑刷新策略,直接刷新。
-
行缓冲刷新:
写入到缓冲区不刷新,直到遇到
\n
换行符后才刷新。如果本次写入缓冲区没有换行符,就会先暂存到缓冲区中,然后结束本次函数调用返回,直到某次写入遇到换行符,将缓冲区中的所有内容全部刷新。 -
全缓冲刷新:
缓冲区写满才刷新。如果本次写入后缓冲区还没有满,就会先暂存到缓冲区中,然后结束本次函数调用返回,直到某次写入后写满才全部刷新。
对于显示器文件通常采用行刷新策略,所以printf
函数在遇到换行符后就会立即刷新。而对于普通文件通常采用全缓冲刷新,缓冲区全部写满后才刷新。
用户缓冲区刷新的本质就是将数据通过系统调用函数write
写入到内核缓冲区中。
2.为什么要有用户缓冲区(存在的意义):
-
解决用户的效率问题:
写入到内核缓冲区时需要调用系统函数
write
,而调用系统函数时,CPU需要从用户态切换到内核态,涉及到权限验证,上下文保存与恢复等操作,导致开销较大。而用户缓冲区的存在,可以实现批量和并写入:将多次小数据操作累积到用户缓冲区,再通过刷新策略,一次性通过系统调用写入到内核缓冲区,减少用户态到内核态的切换开销。
-
解决格式化问题:
printf,fprintf
等函数需要先将整形,浮点型等数据格式化为字符串,格式化需要执行类型转换和内存拷贝。如果每次格式化后直接调用write
,需要频繁的执行类型转化和内存拷贝。而用户缓冲区的存在,可以集中处理格式化:在用户空间完成多次格式化操作,结果暂存到缓冲区,避免每次格式化后立即触发系统调用。
3.用户缓冲区在哪存放:
C语言程序中只要是涉及到文件的操作,一定离不开FILE
结构体。在上面讲解文件描述符的时候,提到过C语言结构体FILE
中一定包含文件描述符,除了这个外,其实还包括用户缓冲区字段和维护信息。
FILE
结构体是标准I/O库的核心,用户管理文件流的状态和缓冲区,通常包含以下关键成员:
//缓冲模式分别对应无缓冲 行缓冲 全缓冲
#define FLUSH_NOW 1
#define FLUSH_LINE 2
#define FLUSH_ALL 3struct FILE{//缓冲区起始地址int _IO_buf_base;//缓冲区结束地址int _IO_buf_end;//当前读取位置int _IO_read_ptr;//当前写入位置int _IO_write_ptr;//缓冲模式标志int _flags;//文件描述符int _fileno;
};
用户缓冲区的数据实际存储在一块动态分配的内存中,通常由标准库函数(如fopen
)在打开文件时自动分配。而FILE
结构体通过两个指针指向缓冲区起始地址和结束地址,进行管理。当关闭文件(如fclose
)时缓冲区会自动释放。
C语言程序中每个文件都有自己的FILE
结构体对象,也就都有一份自己的缓冲区。通过这种方式。C语言的标准I/O库在用户空间实现了高效的缓冲机制,同时通过FILE
结构体灵活管理不同文件的I/O行为。
补充内容(父子进程刷新共享缓冲区的写时拷贝):
明白了上面的原理后,再来测试fork()函数:
如果是正常执行,带fork()函数和不带结果都一样,但是如果是重定向到文件中,结果就不一样了。
红色方框中是不加fork()
语句重定向到目标文件中,绿色方框中是加上该语句重定向到目标文件的结果:
原理分析:
首先,用户缓冲区是一个动态内存,存放在堆区,而堆区属于进程的地址空间,在fork创建子进程后,子进程以父进程的内核数据结构为模板填充自己的PCB,地址空间,页表等。使父子进程最后共享物理内存上的代码和数据;也就是说最后父子进程会共享用户缓冲区。
而正常执行时,是将打印内容写入到显示器文件上,这里用户缓冲区采用的是行缓冲刷新方式,遇到换行符会立即刷新内核缓冲区再写入到显示器文件中,即使最后创建子进程共享用户缓冲区也并不影响,因为已经在之前写入到内核缓冲区了。所以正常执行带fork和不带fork没有区别。
而如果是重定向写入到普通文件中,此时用户缓冲区采用的是全缓冲刷新方式,写满才刷新。所以即使遇到换行符也并不刷新依然在用户缓冲区中暂存。而如果之后再调用fork创建子进程,父子进程就会共享用户缓冲区,此时数据内容还暂存在用户缓冲区,相当于父子进程共享数据。即使最后缓冲区并没有写满,但是最后程序退出时也会强制刷新用户缓冲区。因为父子进程共享,一旦某个进程先刷新时,就会触发写时拷贝,重新分配空间,此时父子进程就不再共享用户缓冲区,而是使用各自的。最后父进程刷新写入一次,子进程也要刷新写入一次,就会导致重定向到普通文件时写入两次。而对于write直接写入到内核缓冲区,父子进程不会共享,所以只写入一次。
以上就是关于文件管理的部分讲解,如果哪里有错的话,可以在评论区指正,也欢迎大家一起讨论学习,如果对你的学习有帮助的话,点点赞关注支持一下吧!!!