目录
- 1. 建立共识原理
- 2. 回忆 C 文件接口
- 2.1 当前工作路径
- 2.2 w / a 方式写入
- 2.3 默认打开的三个文件流
- 3. 认识文件系统调用
- 3.1 O_WRONLY|O_CREAT 写时创建
- 3.2 O_TRUNC 截断长度(也即全覆盖式写入)
- 3.3 O_APPEND 追加
- 4. 浅谈文件访问的本质
- 4.1 简证
1. 建立共识原理
- 文件 = 内容 + 属性
- 文件分为两种:打开的文件 和 没打开的文件
- 文件是被谁打开的? ----- 进程。因此研究文件打开这个话题,本质就是在研究进程与文件的关系。
- 没打开的文件,存储在磁盘;在磁盘上我们最关注什么问题?----- 没有被打开的文件非常多,文件如何被有序分类的放置好,因为我们要快速对文件进行增删查改等操作,所以我们需要快速的找到指定文件。
我们要打开一个文件,在打开之前,文件存储在磁盘中,而打开文件的本质就是访问文件,最终都是要通过代码去访问的(对文件增删查看等操作),编译完运行起来变为进程。换言之,访问文件一定是 cpu 去执行的,根据冯诺依曼体系结构,访问时,文件就一定需要被加载到内存中。
在代码编写上,我们是可以打开诸多个文件的,这也就代表着一个进程与打开文件的比例一定是 1: n,系统中存在着大量的进程,也就一定存在着更加大量的被打开的文件。操作系统都需要对进程做管理,因此大量的被打开的文件同样的需要被管理起来。如何管理?? ----- 先描述,再组织!
在内核中,一个被打开的文件都必须有自己的文件打开对象,包含文件的诸多属性。 例如:struct File { file_attributes; struct xxx* next };
2. 回忆 C 文件接口
FILE *fopen(const char *path, const char *mode);
示例:
FILE *fopen("log.txt", "w");
2.1 当前工作路径
这个 C 打开文件的库函数我们在熟悉不过了,并且以 w 的方式打开指定文件时,若该文件不存在,那么 fopen 这个函数会自动在当前路径下创建该文件,再打开它。
而这里的当前路径到底是什么呢?? ----- 当前运行的进程所处的工作目录 cwd。
换言之,只要我把当前正在运行的这个进程的 cwd 更改了,那么如果 fopen 时指定是路径依旧不带全局路径,那么就会在 cwd 所指向的目录下创建该文件。
chdir("/home/outlier");
FILE *fp = fopen("log111111111111111111.txt", "a");
2.2 w / a 方式写入
fopen w 的方式打开文件,对文件进行写入,我们还发现了,每次重新写入的时候,文件上一次的内容总是不见了。这不仅仅是 fopen 每次都会从文件的起始地址开始写入,而是在写入之前,还会将文件的长度置 0。man 手册中对 fopen 的读写方式是这样说的:
w+ Open for reading and writing. The file is created if it does not exist, oth‐erwise it is
truncated. The stream is positioned at the beginning of the file.
在 linux 中,我们也可以通过 echo + 输出重定向 向一个文件写入啊。要向一个文件写入,那么就必须先打开文件。但是 > 重定向写入文件时,我们一样发生,上次的内容被清空了,只留下最新写入的内存。所以 > 的本质还是 “w”, 它做的事情还是 打开文件 + w 方式的覆盖式写入!如果你愿意,在代码中 fopen 之后,对文件不做任何写入,程序运行起来之后,也可以达到清空文件的效果。
a Open for appending (writing at end of file). The file is created if it does not exist.
The stream is positioned at the end of the file.
与 w 方式不同写入就是 a 方式写入,w 清空并从文件头开始写入,a 在上次写入的结尾,继续追加写入。
2.3 默认打开的三个文件流
在 C 程序启动时,会默认打开 stdin,stdout,stderr 这三个文件输入输出流,C++ 则有 cin, cout,cerr。
其中的 stdin 对应的是键盘文件,stdout,stderr 对应显示器文件
换言之,在 c 代码中,我们同样也可以实现向 stdout 这个文件流做写入操作
const char *s = "hello, linux!";
fprintf(stdout, "%s: %d\n", s, 1234);
fprintf(stderr, "%s: %d\n", s, 1234);
3. 认识文件系统调用
我们需要有一个共识:文件是存储在磁盘上的,而磁盘是外部设备,所以访问磁盘文件,本质就是访问硬件!但是访问硬件这件事,我们用户并不擅长啊!更重要的是,硬件是被操作系统所管理的,作为用户,我们无法绕开操作系统访问硬件,操作系统不允许这种操作,但是作为用户的我们又有访问硬件的需求,怎么办呢?? ----- 于是操作系统向外提供了系统调用接口,供用户访问磁盘文件。换言之,语言层面上的各种 fopen,fprint 等访问文件的库函数,底层一定是调用的系统接口。
3.1 O_WRONLY|O_CREAT 写时创建
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags); //通常用于文件已经创建的前提
int open(const char *pathname, int flags, mode_t mode);
关于 flag 参数,O_RDONLY 只读,O_WRONLY 只写,O_RDWR 读写,必须包含这三大方式中的其中一中。flag 采用的是比特位作为标志进行传递的。
#define ONE (1<<0)
#define TWO (1<<1)
#define THREE (1<<2)
#define FOUR (1<<3)
void show(int flags)
{
if(flags & ONE) printf("function1\n");
if(flags & TWO) printf("function2\n");
if(flags & THREE) printf("function3\n");
if(flags & FOUR) printf("function4\n");
}
类似于上面的小demo,一种 flag 只会有一个比特位为1,可以将多个 flag 通过位运算组合起来使用。
如果 open 打开一个未存在的文件,并且 flag 只设置为 O_WRONLY,是不会自动创建文件的,需要再加一个 O_CREAT 进行或运算传递。
open("log.txt", O_WRONLY|O_CREAT);
但是这样还不够,open 创建文件时,如果不加以设置文件权限,就会出现 “乱码” 的现象。
open("log.txt", O_WRONLY|O_CREAT, 0666);
在讲 Linux中常见的权限问题 时,我们谈论到了目录起始权限是777,文件的起始权限是 666,而因为有 umask 的存在,创建出来的文件权限并不会是 666,而是 664,那么在 open 打开创建一个文件时,同样也会受到系统中 umask 的限制。
如果想要创建出来的文件权限,所见及所写,那么就需要在调用 open 时,先调用一下 umask,它也是一个系统调用,作用域只局限于该进程。
- 在 C 代码中调用了 umask 系统调用修改了,指定了 umask,但是系统中也有一个 umask,那进程运行起来该听谁的呢?? ----- 局部优先原理。肯定是听该进程内设置的 umask。
umask(0);
open("log.txt", O_WRONLY|O_CREAT, 0666);
3.2 O_TRUNC 截断长度(也即全覆盖式写入)
RETURN VALUE:
open() and creat() return the new file descriptor, or -1 if an error occurred (in
which case, errno is set appropriately).
// 其中的 file descriptor 就是文件描述符,int 类型,可用于关闭文件
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
// 第一次文件内容
const char* s = "this is a system call!";
write(fd, s, strlen(s)); // write 也是系统调用
close(fd); // 关闭文件
// 第二次
const char* s = "aaaa";
write(fd, s, strlen(s));
现象:aaaa 覆盖了上一次的内容,但是并没有清空文件内容,因为上一次的其它内容还在。只是从文件头等长度覆盖而已。
也就是说,write 这个系统调用只会覆盖式写入,不会清空,是这样吗?? ------ 其实不是 write 的问题,是 open 写入方式的问题,flag 参数上我们只设置了 O_WRONLY|O_CREAT,如果想要达到每次打开文件都截断文件长度,使其为 0, 还需要加上 O_TRUNC,即
// 打开文件时,文件不存在自动创建,文件起始权限设置为 666。如果文件存在,清空文件内容。
int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
3.3 O_APPEND 追加
// 打开文件时,文件不存在自动创建,文件起始权限设置为 666。如果文件存在,追加写入
// 追加写入与截断长度O_TRUNC是相矛盾的,无法同时存在
int fd = open("log.txt", O_WRONLY|O_CREAT|O_APPEND , 0666);
在了解了上述这些文件的系统调用,我们应该要明天,诸如 C 中的 fopen,fwrite 这样的库函数,底层一定是封装的系统调用。
FILE *fopen("log.txt", "w"); ===> int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
FILE *fopen("log.txt", "a"); ===> int fd = open("log.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
4. 浅谈文件访问的本质
一个文件被打开,就需要被操作系统管理起来。管理的理念:先描述,再组织。
如何描述呢? ----- 还是 struct 结构体对象,其中必定要直接或间接的包含文件的各种属性,例如:
- 文件所在磁盘的位置
- 文件基本属性,权限,大小,读写位置,谁打开的…
- 文件的内核缓冲区信息
- struct file* next 指针
如何组织? ----- 操作系统会以双链表的形式,将所有文件对象连接起来,所以对文件的管理,就转换成对双链表的增删查改,新增一个文件,就创建一个 struct file 对象,插入到双链表中;关闭一个文件,在双链表中删除结点,释放 struct 对象,把数据刷新到磁盘。
文件打开都是通过代码实现的,编译运行后都会变成进程。换言之,我们需要知道,哪些文件被哪些进程所打开,即 要关联起进程与打开文件的关系。进程的 PCB 中会包含一个 struct file_struct* files
这样的结构体指针,指向的结构体内部含有一个数组 struct file_struct* fd_array[]
,数组内部存储的都是 struct file* 指针,指向的就是 stuct file 的地址(即被打开文件对象的地址)。而 这个数组就称为 文件描述符表!
因此 int open(const char *pathname, int flags, mode_t mode);
open 的返回值就是记录文件对象地址在数组中所处的下标位置!
当我们 open 创建一个文件时,在文件对象的双链表中连接起来,然后把文件对象的地址填充到文件描述符表中,再把所在数组下标返回。当我们调用 write 对文件做写入操作时,传递的 fd 参数,就是通过进程的 PCB 中的 files_struct* 找到 files 这个结构体,然后根据 fd 索引到对应位置,根据里面的 strcut file* 指针找到指定的文件对象,对其做增删查改等操作!
4.1 简证
上面讲了那么多,fd 就是数组下标,如何证明呢??
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
int fd2 = open("log2.txt", O_WRONLY|O_CREAT, 0666);
int fd3 = open("log3.txt", O_WRONLY|O_CREAT, 0666);
int fd4 = open("log4.txt", O_WRONLY|O_CREAT, 0666);
printf("fd: %d\n", fd);
printf("fd2: %d\n", fd2);
printf("fd3: %d\n", fd3);
printf("fd4: %d\n", fd4);
数组下标是从0开始的,但是我打开的第一个文件,它的下标是3。那么 0 1 2 这三个位置去哪了呢??
文章一开始的时候就说过,C 语言会默认为我们打开三个输入输出流 stdin,stdout,stderr。我们可以再来证明一下,这三个默认打开的,就是存储在 fd_array[ ] 数组中的0、1、2号下标处。
char input[1024] = {0};
read(0, input, sizeof(input));
printf("ehco: %s\n", input);
printf("--------------------\n");
const char* s = "hello, linux\n";
write(1, s,strlen(s));
write(2, s,strlen(s));
wirte 和 read 这两个系统调用,第一个参数传递的可是 fd 啊!fd 是什么?? fd 不就数组下标吗,所以这次我直接往 0 1 2 下标做读写,0 号下标对应的就是键盘文件,而 1 号 显示器文件,2 号也是显示器,分别对应 C 默认打开的 stdin,stdout,stderr。
-
C语言程序默认打开三个标准输入输出流,真的是 C 语言做的事情吗??又或者这是语言上的特性吗??
c/c++、java等语言都会默认会打开三个输入输出流,这是操作系统的特性!是操作系统做的事情,因为一起程序运行起来都会变成进程,进程会默认打开键盘、显示器,而进程是操作系统的! -
为什么操作系统要默认打开键盘和显示器呢??
因为用户有需求!当我们启动操作系统时,操作系统就会默认打开键盘文件和显示器文件;当启动一个进程时,操作系统只需要讲键盘文件和显示器文件的地址填充到 fd_array[ ] 数组的 0 1 2 号下标处即可。
我们说过,C 中的 fopen 是对系统调用 open 的封装,但是他们两个的返回值好像看起来就不一样,一个是 FILE,一个是 int,FILE 就是 C 库自己封装的结构体,这个结构体里面必定封装了文件描述符。
再次证明:
printf("stdin->fd: %d\n", stdin->_fileno);
printf("stdout->fd: %d\n", stdout->_fileno);
printf("stderr->fd: %d\n", stderr->_fileno);
C 中的 stdin,stdout,stderr 也必定封装了 fd 这样的字段(即 _fileno),而三个默认打开的输入输出流,fd 依次就是 0 1 2。这就是我们打开的文件,fd 一定是从 3 开始的,不可能从 0 1 2 这几个下标开始(在不关闭文件描述符 0 1 2 的前提下)。
close(1);
int ret = printf("stdin->fd: %d\n", stdin->_fileno);
printf("stdout->fd: %d\n", stdout->_fileno);
printf("stderr->fd: %d\n", stderr->_fileno);
fprintf(stderr, "printf ret: %d\n", ret);
当我们 close(1) 关闭输出流之后,printf 确实就无法使用了。而把 1 号文件描述符关了,还有 2 号指向显示器文件,所以stderr 依旧可以正常写入。
我们要知道,一个文件是可以被多个进程打开的;一个进程中,不同的文件描述符中 struct file* 也可以指向同一个文件。在显示器文件中会维护一个引用计数 count,当有一个 struct file* 指向显示器文件,count++。当我们调用 close(1) 时,操作系统就对该进程的 1号文件描述符的指向置空,显示器文件中的 count - 1,再判断 count == 0 ? 如果还没有等于 0,就说明还有其它的 文件描述符指向该文件,等于 0 时,就回收该文件对象。
所以一句话总结:任何编程语言,都会默认打开三个输入输出(因为进程会默认打开)一个用于输入,两个用于输出,并且这些输入输出流一定直接或间接的包含 fd 字段,如果没有包含 fd,那么就无法访问文件。
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!