我们在C语言都学过文件操作,例如fopen,fclose之类的函数接口,在C++中也有文件流的IO接口,那不仅仅是C/C++,python、java、go、hph等等这些语言也都有自己的文件操作的IO接口。那有没有一种统一的视角来看待这些文件操作呢?它们的底层原理到底是什么?下面我们就来好好谈一谈:
目录
我们先来摆出几个事实:
1、文件的基本构成为内容+属性,在对文件进行操作时无非就两种方式:
● 对内容进行操作
● 对属性进行操作
2、文件是保存在磁盘上的,但由于冯诺依曼体系的存在,想要对文件进行操作就必须将其加载到内存中
3、系统在运行时有很多个进程,每个进程又可以打开多个文件,所以在操作系统中一定会同时存在大量被打开的文件
那从这三个事实我们可以得出:每打开一个文件在操作系统中一定会有一个描述该文件的结构体,多个结构体使用了一种数据结构(链表)相互联系起来,形成了管理文件的体系(和进程的组织方式很像)
这个结构体在Linux中名字叫file:
- struct file {
- union {
- struct list_head fu_list; //文件对象链表指针linux / include / linux / list.h
- struct rcu_head fu_rcuhead; RCU(Read - Copy Update)//是Linux 2.6内核中新的锁机制
- } f_u;
- struct path f_path; //包含dentry和mnt两个成员,用于确定文件路径
- #define f_dentry f_path.dentry //f_path的成员之一,当前文件的dentry结构
- #define f_vfsmnt f_path.mnt //表示当前文件所在文件系统的挂载根目录
- const struct file_operations* f_op; //与该文件相关联的操作函数
- atomic_t f_count; //文件的引用计数(有多少进程打开该文件)
- unsigned int f_flags; //对应于open时指定的flag
- mode_t f_mode; //读写模式:open的mod_t mode参数
- off_t f_pos; //该文件在当前进程中的文件偏移量
- struct fown_struct f_owner; //该结构的作用是通过信号进行I / O时间通知的数据。
- unsigned int f_uid, f_gid; //文件所有者id,所有者组id
- struct file_ra_state f_ra; //在linux / include / linux / fs.h中定义,文件预读相关
- unsigned long f_version;
- #ifdef CONFIG_SECURITY
- void* f_security;
- #endif
-
- void* private_data;
- #ifdef CONFIG_EPOLL
-
- struct list_head f_ep_links;
- spinlock_t f_ep_lock;
- #endif
- struct address_space* f_mapping;
- };
下面我们来学习几个系统级的文件操作接口:
可以看到open这个函数和C语言中fopen差别挺大的,该函数是在系统层面用来打开文件的,可以看到open函数有两个,一个有两个形参,另一个有三个形参(有点像C++中的函数重载):
下面我们先分析一下第一个open函数的使用:
返回值:打开文件成功返回新打开的文件描述符,失败返回-1
第一个形参pathname:传入要打开文件的文件名
第二个形参flags:该参数传入的是标志位,根据传入的标志位来决定打开文件的方式。常用的标志位有:
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR: 读,写打开 这三个常量,必须指定一个且只能指定一个
O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限 O_APPEND: 追加写
O_TRUNC:清空文件所有内容
下面我们来讲解一下标志位的使用原理:
在C语言中我们可以通过形参的传入来让函数做不同的事情:
- void Func(int flags)
- {
- if (flags==1)
- {
- //功能1
- }
- if (flags == 2)
- {
- //功能2
- }
- ...
- }
- int main()
- {
- Func(...);
- return 0;
- }
上面的该代码每次输入参数时只能让函数做一件事,那能不能只输入一个int类型的形参,就可以表示所有想让函数执行的功能?
当然可以,我们可以将这个int类型的参数以比特为单位,每一个比特位代表函数的一个功能,其每个比特位上的值表示函数是否执行该功能,这样子一个int类型的参数就可以一次性传入32个数据,让函数执行其对应的功能:
- void Func(int flags)
- {
- if (flags & 0x1)//00000000 00000000 00000000 00000001
- {
- printf("功能1\n");
- }
- if (flags & 0x2)//00000000 00000000 00000000 00000010
- {
- printf("功能2\n");
- }
- if (flags & 0x4)//00000000 00000000 00000000 00000100
- {
- printf("功能3\n");
- }
- if (flags & 0x8)//00000000 00000000 00000000 00001000
- {
- printf("功能4\n");
- }
- if (flags & 0x10)//00000000 00000000 00000000 00010000
- {
- printf("功能5\n");
- }
-
- }
- int main()
- {
- Func(0x1);
- printf("------------------------\n");
- Func(0x4);
- printf("------------------------\n");
- Func(0x4|0x10);
- printf("------------------------\n");
- Func(0x2|0x4|0x8);
- return 0;
- }
运行效果:
最后我们再把每个功能所表示的比特位写成一个宏,这样的宏就成了标志位:
- #define ONE 0x1
- #define TWO 0x2
- #define THREE 0x4
- #define FOUR 0x8
- #define FIVE 0x10
-
- void Func(int flags)
- {
- if (flags & 0x1)//00000000 00000000 00000000 00000001
- {
- printf("功能1\n");
- }
- if (flags & 0x2)//00000000 00000000 00000000 00000010
- {
- printf("功能2\n");
- }
- if (flags & 0x4)//00000000 00000000 00000000 00000100
- {
- printf("功能3\n");
- }
- if (flags & 0x8)//00000000 00000000 00000000 00001000
- {
- printf("功能4\n");
- }
- if (flags & 0x10)//00000000 00000000 00000000 00010000
- {
- printf("功能5\n");
- }
-
- }
- int main()
- {
- Func(ONE);
- printf("------------------------\n");
- Func(THREE);
- printf("------------------------\n");
- Func(FOUR | FIVE);
- printf("------------------------\n");
- Func(TWO | FOUR | FIVE);
- return 0;
- }
在open函数的中的flags参数选项也就是这样的宏表示成的标志位,我们输入不一样的选项使其进行对文件相对应的操作:
- #include
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- int fd = open(LOG, O_WRONLY);//只写打开文件
- if (fd == -1)
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开失败,打印错误码所对应的原因
- }
- else
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开成功,提示一下用户
- }
- close(fd);//文件打开后要使用close函数关闭,该函数的形参传入要关闭文件的文件描述符即可
- return 0;
- }
运行效果:
因为我们并没有test.txt文件所以用O_WRONLY(只写打开),注定会出错,那我们再加一个标志位:O_CREAT (若文件不存在,则创建它),来试试看:
- #include
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- int fd = open(LOG, O_WRONLY | O_CREAT);//只写打开文件,如果文件不存在则创建
- if (fd == -1)
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开失败,打印错误码所对应的原因
- }
- else
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开成功,提示一下用户
- }
- close(fd);//文件打开后要使用close函数关闭,该函数的形参传入要关闭文件的文件描述符即可
- return 0;
- }
运行结果:
文件是创建了,但是这个文件权限怎么怪怪的?S,s,T是什么?这是因为没有传入mode形参的open函数没有创建文件的权限,而创建文件是需要权限的,这样就导致了最终创建出来的文件的权限是乱码的
当我们需要使用open函数来创建文件时,这就要传入第三个参数mode了。我们向mode形参传入配置权限的八进制方案,来控制最终创建出文件的权限(对于文件权限不熟悉的同学可以看这里:【Linux】文件权限 /【Linux】目录权限和默认权限)
下面我们来试试看:
- #include
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- int fd = open(LOG, O_WRONLY | O_CREAT, 0666);//只写打开文件,如果文件不存在则创建,创建文件时给文件权限配置的八方案为666
- if (fd == -1)
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开失败,打印错误码所对应的原因
- }
- else
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开成功,提示一下用户
- }
- close(fd);//文件打开后要使用close函数关闭,该函数的形参传入要关闭文件的文件描述符即可
- return 0;
- }
咦,我们给的方案是666啊,怎么创建出来的权限不一样?
别忘了,文件的最终权限还会受到umask的影响,那我们能不能在自己的代码中修改umask呢?
当然可以!下面这个函数就可以办到:
该函数可以帮我们修改进程中的umask配置,直接调用该函数传入想要设置的八进制方案即可:
- #include
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- umask(0);//将进程中的umask置为0,以免影响到我们所创建文件的最终权限
- int fd = open(LOG, O_WRONLY | O_CREAT, 0666);//只写打开文件,如果文件不存在则创建,创建文件时给文件权限配置的八方案为666
- if (fd == -1)
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开失败,打印错误码所对应的原因
- }
- else
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开成功,提示一下用户
- }
- close(fd);//文件打开后要使用close函数关闭,该函数的形参传入要关闭文件的文件描述符即可
- return 0;
- }
现在我们所创建的文件权限就达到预期了
系统级向文件内部写入的函数接口为write
该函数的返回值为实际写入文件的字节数
第一个参数fd:传入想要写入文件的文件描述符
第二个参数buf:要写入内容的地址
第三个参数count:要写入的字节数
演示:
- #include
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- umask(0);//将进程中的umask置为0,以免影响到我们所创建文件的最终权限
- int fd = open(LOG, O_WRONLY | O_CREAT, 0666);//只写打开文件,如果文件不存在则创建,创建文件时给文件权限配置的八方案为666
- if (fd == -1)
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开失败,打印错误码所对应的原因
- }
- else
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开成功,提示一下用户
- }
- const char* message = "aaaaaaaa";
- int cnt = 5;
- while (cnt--)
- {
- char buff[128];//缓冲区
- snprintf(buff, sizeof(buff), "%d:%s\n", cnt, message);//将要输出到文件的内容加载到缓冲区里
- write(fd, buff, strlen(buff));//写入文件
- }
- close(fd);//文件打开后要使用close函数关闭,该函数的形参传入要关闭文件的文件描述符即可
- return 0;
- }
下面我改变写入的内容,再向文件中写一些他的东西:
- ....
-
- #define LOG "test.txt"
-
- int main()
- {
- umask(0);//将进程中的umask置为0,以免影响到我们所创建文件的最终权限
- int fd = open(LOG, O_WRONLY | O_CREAT, 0666);//只写打开文件,如果文件不存在则创建,创建文件时给文件权限配置的八方案为666
-
- ....
-
- const char* message = "bbbb";
- int cnt = 3;
- while (cnt--)
- {
- char buff[128];//缓冲区
- snprintf(buff, sizeof(buff), "%d:%s\n", cnt, message);//将要输出到文件的内容加载到缓冲区里
- write(fd, buff, strlen(buff));//写入文件
- }
- close(fd);//文件打开后要使用close函数关闭,该函数的形参传入要关闭文件的文件描述符即可
- return 0;
- }
可以看到我们再次向文件写入时,上次向文件中写入的内存并没有被清空,write函数就从文件开头写入了本次的内容,最终造成了文件内容的杂乱
所以我们可以在open函数上再加上一个标志位:O_TRUNC(清空文件所有内容)
- #include
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- umask(0);//将进程中的umask置为0,以免影响到我们所创建文件的最终权限
- int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);//只写打开文件,并且去除文件内所有内容,如果文件不存在则创建,创建文件时给文件权限配置的八方案为666
- if (fd == -1)
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开失败,打印错误码所对应的原因
- }
- else
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开成功,提示一下用户
- }
- const char* message = "bbbb";
- int cnt = 3;
- while (cnt--)
- {
- char buff[128];//缓冲区
- snprintf(buff, sizeof(buff), "%d:%s\n", cnt, message);//将要输出到文件的内容加载到缓冲区里
- write(fd, buff, strlen(buff));//写入文件
- }
- close(fd);//文件打开后要使用close函数关闭,该函数的形参传入要关闭文件的文件描述符即可
- return 0;
- }
或者在open函数中设置另一个标志位:O_APPEND(追加式向文件写入内容)
- ....
-
- #define LOG "test.txt"
-
- int main()
- {
- umask(0);//将进程中的umask置为0,以免影响到我们所创建文件的最终权限
- int fd = open(LOG, O_WRONLY | O_APPEND | O_CREAT, 0666);//只写(追加式写)打开文件,如果文件不存在则创建,创建文件时给文件权限配置的八方案为666
-
- ....
-
- const char* message = "aaaaaaaa";
- int cnt = 5;
- while (cnt--)
- {
- char buff[128];//缓冲区
- snprintf(buff, sizeof(buff), "%d:%s\n", cnt, message);//将要输出到文件的内容加载到缓冲区里
- write(fd, buff, strlen(buff));//写入文件
- }
- close(fd);//文件打开后要使用close函数关闭,该函数的形参传入要关闭文件的文件描述符即可
- return 0;
- }
read函数是系统级读取文件接口
该函数的返回值为实际从文件中读取的字节数
第一个参数fd:传入想要读取文件的文件描述符
第二个参数buf:读取内存存放的缓冲区地址
第三个参数count:要读取的字节数
演示:
- #include
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- int fd = open(LOG, O_RDONLY);//只读打开文件
- if (fd == -1)
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开失败,打印错误码所对应的原因
- }
- else
- {
- printf("fd:%d,errno:%d,errstring%s\n", fd, errno, strerror(errno));//文件打开成功,提示一下用户
- }
- char buff[1024];//缓冲区
- ssize_t cnt = read(fd, buff, sizeof(buff) - 1);//读文件时要考虑到缓冲区结尾最后一个字符为/0
- if (cnt > 0)
- {
- buff[cnt] = '/0';
- printf("%s", buff);
- }
- close(fd);//文件打开后要使用close函数关闭,该函数的形参传入要关闭文件的文件描述符即可
- return 0;
- }
从上面的操作我们可以实现所有系统级文件的操作了,现在我们再来看C语言中fopen、fwrite等等这些文件操作的函数(包括其他语言的文件操作接口),无一例外,想要与硬件所存储的内容进行交互,其内部必须调用系统级文件操作接口!
我们在使用open/write/read等等函数时都涉及到了一个文件描述符,那文件描述符到底是个什么东西呢?
在说这个之前,我们先阐述一些概念:
之前我们在C语言文件操作中说过:对任何一个c程序,只要运行起来,就默认打开3个流:
标准输入流(stdin)、标准输出流(stdout)、标准错误流(stderr)
但是现在我们可以这样子说:对任何一个进程,只要运行起来,就默认打开这三个文件(Linux下一切皆文件)
● 标准输入在C语言中被叫做stdin,在C++中被叫做cin,默认是键盘文件
● 标准输出在C语言中被叫做stdout,在C++中被叫做cout,默认是显示器文件(屏幕)
● 标准错误在C语言中被叫做stderr,在C++中被叫做cerr,默认是显示器文件(屏幕)
这三个标准流本质上都是文件!
下面我们来写段代码验证一下:
- #include
- #include
- int main()
- {
- fprintf(stdout, "Hello fprintf -> stdout\n");
- std::cout << "Helllo cout -> cout" << std::endl;
- fprintf(stderr, "Hello fprintf -> stderr\n");
- std::cerr << "Helllo cout -> cerr" << std::endl;
- return 0;
- }
我们知道无论是C语言还是C++,其标准输出和标准输入流都是默认向显示器文件输出的,所以我们可以在屏幕上看到这些现象
下面我们使用重定向>修改一下其默认输出文件:
我们可以看到默认输出文件应该从显示器文件改成了文本文件(test.txt),默认输出流是向文本文件输出了,但是默认错误流还是向显示器文件进行输出的,这是为什么?
这个问题我们放在后面讨论,现在我们所要知道的就是对任何一个进程,只要运行起来,就会默认打开这三个标准流文件
在Linux中描述进程的结构体task_struct(PCB)有一个file_struct类型的指针,指向一个file_struct结构体,该结构体内有一个file类型的指针数组,每个数组的地址指向一个file类型的结构体:
这样子就构成了一个进程和文件对应的体系,进程可以根据自己的file_struct类型的指针files找到其打开文件的file结构体
但是我们上面有说到对任何一个进程,只要运行起来,就会默认打开那三个标准流文件,对此结构体file_struct中的file类型的指针数组前三个元素,肯定是指向这三个标准流所对应的file结构体
而文件描述符对应的就是指向该文件file结构体的指针所在的元素下标!!!
现在我们可以解释为什么我们在上面打开文件时,每次对应的文件描述符都是3了,就是因为这三个标准流文件的存在占据了指针数组的前三个元素!
下面我们多用几个open函数打开文件看看其文件描述符是什么样的:
- #include
- #include
- #include
- #include
- #include
-
-
- #define LOG "test.txt"
-
- int main()
- {
- int fd1 = open(LOG, O_WRONLY);
- int fd2 = open(LOG, O_WRONLY);
- int fd3 = open(LOG, O_WRONLY);
- int fd4 = open(LOG, O_WRONLY);
- int fd5 = open(LOG, O_WRONLY);
- printf("%d/n", fd1);
- printf("%d/n", fd2);
- printf("%d/n", fd3);
- printf("%d/n", fd4);
- printf("%d/n", fd5);
- return 0;
- }
其实在内存中每个file结构体都对应着一个自己的文件缓冲区:
在我们使用write函数时,该函数先要将我们传入的内容拷贝置文件的缓冲区中,再被OS书刷新到磁盘中做持久化(至于什么时候刷新,操作系统有自己的一套方案)。在使用read函数时,OS先要将我们读取的内容拷贝置文件的缓冲区中,再将其缓冲区的内容拷贝至我们存储内容的地址中
所以从本质来说write和read函数都是拷贝函数!
从上图的模式来看,我们最终可以将这个图画为两个部分:进程管理和文件系统
这个两个模块只通过指针的地址指向进行了低耦合,在运行时互不干涉
我们看到下图:
我们从外设看来,所有的外设都有驱动程序,驱动程序会提供两个最基本的函数接口read(从外设读取数据)和write(对外设输入数据),当然在这里系统没必要对键盘输入数据,所以键盘的write_keyboard驱动函数是个空函数。(以此类推显示器的read_screen函数也是一个空函数体)当我们的进程想与外设交互时,都会通过内存中的文件结构体file中函数指针指向的驱动函数!
所以在Linux操作系统下,我们一切操作的进行都要通过进程,而进程与外设数据交互都要通过file结构体,所以我们在Linux下可以将一切都看作为文件!
我们在之前说过C语言的FILE指针指向的是一个FILE结构体
现在我们又知道了C语言中所有文件操作接口都要调用系统文件操作接口,所以在C语言中的FILE结构体中必有文件描述符(在Linux的C标准库下为_fileno,不同的环境下封装会有差异)
同时我们也明白了三个标准流:stdin、stdout、stderr也是文件
那我们就打印出来它们的文件描述符来看看:
- #include
- #define LOG "test.txt"
- int main()
- {
- printf("%d", stdin->_fileno);
- printf("%d", stdout->_fileno);
- printf("%d", stderr->_fileno);
- FILE* fp = fopen(LOG, "w");
- printf("%d", fp->_fileno);
- fclose(fp);
- return 0;
- }
果然如此~
那不用多说C++中的cin、stdout、stderr、cerr也是如此
我们先来做个小实验:
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- close(0);//关掉文件描述符为0的文件
- close(2);//关掉文件描述符为2的文件
- int fd1 = open(LOG, O_WRONLY);
- int fd2 = open(LOG, O_WRONLY);
- int fd3 = open(LOG, O_WRONLY);
- int fd4 = open(LOG, O_WRONLY);
- int fd5 = open(LOG, O_WRONLY);
- printf("%d\n", fd1);
- printf("%d\n", fd2);
- printf("%d\n", fd3);
- printf("%d\n", fd4);
- printf("%d\n", fd5);
- return 0;
- }
我们可以看到当我们关闭0和2文件描述符所对应的文件后,我们再次打开其他文件时文件描述符从0和2开始了,所以我们可以得出一个结论:打开文件时文件描述符是从未使用的最小元素下标开始的
我们看到下面的代码:
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- close(1);
- int fd1 = open(LOG, O_WRONLY | O_TRUNC | O_CREAT, 0666);
- printf("%d\n", fd1);
- close(fd1);
- return 0;
- }
咦?这一次的printf怎么没有向屏幕上打印?而是向test.txt文件中打印了?
这是因为我们先使用文件描述符2关闭了标准输出流文件(显示器文件),再打开test.txt文件时它的文件描述符就成2了,而printf函数默认是向标准输出流文件描述符打印的,这时数据就被打印到test.txt文件中了
这就是输出重定向的本质!我们在shell中使用>操作符时改变的就是文件描述符2所对应的文件
再来看代码:
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- close(0);
- int fd1 = open(LOG, O_RDONLY);
- int a=0;
- scanf("%d",&a);
- printf("%d\n",a);
- close(fd1);
- return 0;
- }
我们这一次先关闭了文件描述符0所对应的文件(键盘文件) ,再打开文件test.txt文件时其文件描述符就是0,而scanf函数默认是向0所对应的文件描述符的文件中读取内容,所以最后a所对应的值也被修改为1了
这就是输入重定向的本质!我们在shell中使用<操作符时改变的就是文件描述符0所对应的文件
我们从上面两个演示中也不难分析Linux的追加重定向的本质:
- #include
- #include
- #include
- #include
- #include
- #include
-
- #define LOG "test.txt"
-
- int main()
- {
- close(1);
- int fd = open(LOG,O_WRONLY | O_CREAT | O_APPEND, 0666);
- printf("You an see me\n");
- printf("You an see me\n");
- printf("You an see me\n");
- printf("You an see me\n");
- printf("You an see me\n");
- close(fd);
- return 0;
- }
我们这一次先关闭了文件描述符1所对应的文件(显示器文件) ,再打开文件test.txt文件时其文件描述符就是1,而printf函数默认是向1所对应的文件描述符的文件中输入内容,由于打开文件时是追加式写入,所以最后test.txt文件中有You can see me了
这就是追加重定向的本质!我们在shell中使用>>操作符时改变的就是文件描述符1所对应的文件,并且打开文件的方式为追加式写入(所以追加式重定向和输入重定向只是打开文件的方式不同)
看完这些,相信上面三个标准流中的问题我们也可以理解了(stdout,cout,它们都是向1号文件描述符对应的文件打印;stderr , cerr,它们都是向2号文件描述符对应的文件打印;而输出重定向只改变1号对应的指向)
本期的全部到这里就结束了,下一期见~