Linux - 基础IO(Linux 当中的文件,文件系统调用接口,文件描述符)- 上篇

前言

首先,关于文件我们最先要理解的是,文件不仅仅存储的是数据,一个文件包括 内容 + 数据。内容好理解,就是我们先要这文件存储哪一些数据,这些数据就是文件的内容。

但是,在计算机当中,有两种文件,一种是 正在打开的文件 ,另一种是 没有被打开文件。

文件没有被打开,那么就是存储在外设当中,最常见的存储数据的外设比如 磁盘,u盘,硬盘等等,那么我们知道,外设当中存储速度是比不上内存的,但是,外设有一个好处,就是存储容量大。

那么也就意味着,在外设当中存储了很多个文件, 我们如何 对这些文件进行 增删查改 ,其实靠的就是文件的属性,文件的属性有很多,比如文件的最近一次的修改时间,文件的创建的时间,文件的大小,文件的存储位置等等···  这些,在windows 当中右键某一个文件,我们也可以查看文件的属性:

而,对于未被打开的文件,文件的属性就是为了方便我们来对文件 进行 增删查改 。也就是说,如何在“浩瀚”的外设当中找到文件,进行 增删查改,文件的属性不能少。

就好比是在快递站当中找快递,这些快递不是随机的,混乱的堆放在某处,然后让我们自己的一个一个找的;而是,把这个快递 编写一个快递号,比如是 12 - 3 - 4444,那么我就在 12 号货架的 第 3 层的位置,来找 4444 的快递就行了,这样不仅方便了 快递站的人来存放,也方便了 找快递的人来拿。 不会显得很乱。


文件要被打开,也就是要被修改,那么肯定是要加载到内存当中 ,而且,一个进程可能不只是打开一个文件,一般大多数情况下, 一个进程打开的文件数 是 1 : n 的关系。因为,存储进程的代码 数据,本身就是一个可执行文件当中存储的,同时,进程很可能还会打开其他的多个文件。

所以,不仅仅是外设当中有很多的文件,在内存当中,操作系统也要维护很多的 文件。

所以,这就要谈一个六字真言了 -- 先描述在组织

其实在操作系统 在 内存当中管理这么多个进程,和 文件,本质上就是管理这些进程 和 文件 的属性。操作系统 像 Linux 使用 C 语言实现的,所以,其实本质上就是用 一个个 struct 结构体来表述 一个进程 / 文件 的属性(也就是属性的集合)(这叫做描述),然后操作系统只要管理好了这些个 strutc 结构体就可以管理好 所以的进程 / 文件(这叫做组织)。而管理(组织)这些 结构体对象,本质上就是用 某种 数据结构来维护某种关系。

比如 进程 等待 硬件资源,但是这个硬件当前可能是多个进程在等待资源,那么就有先后顺序,就要等待。所以,在Linux 当中,基本上每一个 硬件在操作系统当中都有一个等待队列,用于进程之间在这个硬件上的排队。

所以,我们才说,在Linux 当中一切都是文件,为什么,就因为上述我们所说的 -- 先描述在组织


所以文件也是一样,操作系统在内存当中维护这些个资源,本质上就是维护 操作系统为 这个加载到内存当中文件 所 创建的 文件结构体对象

你在很多的编程语言当中看到的 ,为什么 像类似 fopen()这样的函数,返回的往往是一个 文件对象的指针,其实就离不开 操作系统当中为了更好管理文件,为这个文件所单独创建的 文件结构体对象。两者实际上是大同小异的。

文件

在C 语言当中的 各个文件接口(函数)

首先当然是 fopen()函数打开一个文件:
 

可知道 fopen()函数返回的是一个 FILE*(文件指针 / 文件句柄) 。

  • path : 打开文件的路径;(相对路径,绝对路径,或者不带路径)
  • mode:打开文件的方式;

后续我们对这个文件的操作都需要 fopen()函数的返回值,也就是这个 FILE* 文件指针。

  • 如果你以 "w" 写的方式打开一个文件的话,如果文件在你传入的路径之下不存在,那么就会自动创建一个文件;
  • 而且,"w" 方式写入数据到文件的话,如果文件当中有数据,那么就先把文件的长度清零,然后在文件的最开始处写入。(这种方式 和 Linux 命令行当中的 ">" 输出重定向 向文件当中的写入方式是一样的。如 echo "hello Linux!" > text.txt  这个文件当中输入 "hello Linux!"字符串,就是以 'w' 的方式来写入的。所以像 "> text.txt"  这样的方式就可以 清空 text.txt 这个文件当中的数据
  • "a" 是在文件末尾处以 追加的方式来写。 同样 , 如果文件在你传入的路径之下不存在,那么就会自动创建一个文件;(这种方式 和 Linux 命令行当中的 ">>" 输出重定向 向文件当中的写入方式是一样的。">>" 就是追加的方式来在文件当中输入数据的
  • 对于 path 路径,如果你只是直接写上文件名,没有带上路径,那么就会默认是在当前 路径下查找这个文件。(此处的 "当前路径"指的是 调用 fopen()函数的 进程的当前路径)。

在 /proc/进程PID 当中就有这个进程对应的 当前工作路径:

所以,如果把这个进程的  cwd,也就是这个进程的当前工作路径给改掉了,那么像 fopen("text.txt",'w'); 这样的操作,就会在新修改的 工作路径的下创建这个新文件。


c 程序在运行之时,会自动打开 三个 标准输入输出流

 像 fprintf()这些函数可以像写入数据到文件,或者是读取数据到程序当中的一样,来像上述的 三种 标准输入输出流当中输入和输出数据:


上述只是简单的把 C 语言当中的一下文件操作浅谈一下,因为不是本篇博客的主要内容,所以不在过多阐述。

被打开的文件(被加载到内存当中的文件)

 我们说文件是存储在 磁盘上的,而磁盘是外部设备,访问文件的操作,其实是在访问硬件!!所以,其实 三个标准输入输出流也是在访问文件。

 又因为,像在 C 语言当中使用 printf/fprintf/fscanf/fwrite/fread/fgets/gets ···· 这些函数,想访问文件的话,其实就是想访问对应的硬件。这些函数使用用户所书写的,用户不能直接绕过中间的层级,直接访问到最底层的 硬件:

 如上图所示,用户和底层硬件之间 还有 很多层级。只能一层一层去访问到 底层硬件

所以,这些访问到硬件的函数,本质上是 用户操作接口,然后在这些函数的实现当中,就需要使用到 系统调用接口几乎所有的库,只要是想要访问到底层硬件设备,都必定是要封住 系统调用接口的。

Linux 当中常见的 文件系统调用接口介绍

 open()函数

#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);

    pathname: 要打开或创建的目标文件
    flags: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。

参数:
    O_RDONLY: 只读打开
    O_WRONLY: 只写打开
    O_RDWR : 读,写打开

上述这三个常量,必须指定一个且只能指定一个

    O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
    O_APPEND: 追加写

返回值:

    成功:新打开的文件描述符
    失败:-1

上述的 类似 O_RDONLY 的5个参数,其实是宏,上述的 flags 这个参数,其实是作 标志的作用,用户表示某一些状态是否存在,比如 O_RDONLY 就表示 只读打开。

以往我们使用一个变量作为一个标志位的时候,一般是这一个变量只能作一个标志位,也就只能表示一种状态。但是其实,一个 int 变量,有 32 个bit 位,如果每一个位表示一个状态的话,那么按道理这个 int 变量是可以表示 32 种状态是否存在的。

上述的 flags 变量就是一个 比特位方式的标志位传递方式

我们可以利用像上述一样的 二进制操作符 取出 int 变量 每一个 bit 位的值,从而判断某一个 function 状态是否存在,从而实现不同的功能。 

这样的话,我们在外部使用这个函数,就可以在 show 函数当中传入这些宏,而且,顺序无关,在 show 函数内部,就会一次按位与上 每一个 宏,从而解析出 当前传入了多少个宏(状态)。 


O_WRONLY 参数  和 O_CREAT   参数

而且,O_WRONLY 这个参数,如果路径当中没有 此文件的话,不会创建的新的文件。除非又引入了 O_CREAT  参数。

此时,在进程的当前目录下是没有 log.txt 这个文件的。发现:文件打开失败了,而且在进程的工作目录下没有 创建新的文件。

 所以,要想上述一样 传入O_CREAT    参数告诉操作系统,如果打开文件之时,没有该文件,就创建一个新的文件。

但是我们又发现了问题:
 

使用上述  O_CREAT   参数来创建 文件的话,发现权限完全是不对的。

因为 在 Linux 当中新建一个文件的时候,必须要告诉 操作系统,要 创建的 新文件的 访问权限。

 我们要指定 新创建文件的权限,那么就要使用 带 mode 参数的 open()函数:

向下述一样传参:

 此时发现,新创建的 log.txt 文件就已经创建成功了,而且 文件的 访问权限还是 664 权限。

为什么不是 666 权限呢?因为 ,Linux 创建文件时候,有一个 umask 来控制 新创建的文件的权限。 

 如果必须要使用 666 权限的话,有一个 umask()系统调用接口
 

进程的代码当中调用 umask ()函数,可以把当前进程的 umask 值修改为传入的参数的值。 

 调用 umask ()函数之后,在系统当中是系统的 umask,在进程当中是进程自己的 umask

两个 umask,那么进程此时应该使用 哪一个 umask 呢?使用 就近原则。此时在进程当中,最近就有一个新刷新的 umask,那么就使用 这个 umask,如果没有,就使用系统当中的。


open()函数的返回值是一个 整形

 这个返回值 称之为 -- file descriptor ,换句话说 就是 文件描述符。代表的意思就是:如果打开文件成功,返回的是一个  >0 的数,此时这个数代表的就是一个 文件。


O_TRUNC参数 

O_WRONLY  参数不仅不会 帮我们创造新的文件,而且,它不是像 fopen()函数当中的 "w" 一样,先把文件当中的数据进行清空,然后从文件的开始处进行书写;它虽然依然是从文件的开始处进行书写,但是, O_WRONLY 不会 把文件当中的数据进行清空;

 所以,如果先要实现 fopen()函数当中的 "w" 参数一样的效果的话,需要加上 O_TRUNC 这个参数,它所实现的功能就是 把文件当中原本的数据清空

此时才能实现   fopen()函数当中的 "w" 参数一样的效果。


O_APPEND 参数

 这就是追加模式,在文件末尾追加数据。

很显然,追加 参数 和 清空 参数,两个参数是冲突的。

此时我们进行追加的方式在文件当中输入数据:
 


close()函数

关闭文件。

fildes: 文件描述符,也就是上诉 open()函数当中,打开文件成功返回的  file descriptor。


 write()函数

像文件当中写入数据:
 


 文件系统调用接口 和 文件库函数的关系

 我们之前说过,库函数是用户层面使用的接口,用户如果想要访问到 底层硬件的话,必须要一层一层往下去调用,才能调用到 底层硬件,或者是下层应用,数据或者说是 接口。

操作系统不会容许 用户,或者是上层的各种接口 跨层级 去访问到下层当中的数据,硬件,接口等等。

 所以,才会出现了上述所说的 文件系统调用接口 和 文件库函数的关系。库函数要想访问下层,只能通过系统调用接口。

 他一定是像下述类似的方式来在调用系统调用接口的:


 fd 文件描述符 

 操作系统也要维护 打开了的文件,这些文件都是被加载到内存当中的来才能进行 增删查改的操作的。

操作系统如何进行管理这么多的文件,用的就是 --- 先描述在组织

 谁管理谁来描述这个被打开的文件,操作系统要把这个 被打开的文件的属性,放到一起(也就是放到一个 struct 一个结构体当中),每一个被打开的文件,都会在内核当中创建一个 内核结构体。比如 我们把这个内核结构体 称职为 --- struct file。

在这个 struct file 当中,之间或者间接的包含如下属性:

  • 在磁盘的什么位置?
  • 基本属性:权限,大小,读写位置(当前要从那个位置开始读写(开始位置的偏移量)),谁打开的·······
  • 文件的内核的缓冲区信息(文件被打开时,这个文件在内核当中对应的内核空间位置)(将来要想文件当中写数据,会先写到这个缓冲区当中,然后操作系统会定期从 缓冲区当中 向磁盘当中拷贝数据)
  • ·····

上述只是举了一些例子,其实一个文件还有很多信息,每一个被打开的文件,这些信息都会保存到一个 struct file 当中,操作系统可能要维护很多个 struct file;

操作系统会以 双链表的形式,把这一个一个的 struct file 链接在一起
 

现在,我们对被打开的文件进行操作的话,就变成了对 这个 文件结构体对象双联表  的 增删查改
这个过程就是  先描述在组织 的过程,和进程当中  先描述在组织  没什么区别。


在前言当中也进行了说明:一个进程打开的文件数 是 1 : n 的关系。所以,我们如何知道,哪一个进程 打开了那些文件?或者说是,这个进程 和 那些文件有什么关系?  进程 如何和 自己打开的文件做关联的?    这些都是需要 进程 的 PCB对象当中需要去维护 的。

进程PCB 当中会存在一个指针,比如是 struct files_struct* f ; 这个

源码当中是这样写的:

这个指针指向一个 files_struct 结构体对象,在这个 files_struct 结构体对象 当中有一个数组 -- struct file * fd_array[]

struct file * fd_array[]  数组是一个指针数组,数组当中每一个元素都指向一个文件对象,而,在这个数组当中的文件对象 所 对应的文件,就是这个进程所 打开的文件

 如上如图所示,这个 数组维护的就是一个一个 文件对象,所以我们使用 open()函数之时,open()函数的返回值就是一个 int 类型的返回值,其实对应的就是 这个 struct file * fd_array[]  数组 下标。而这个 struct file * fd_array[]  数组 我们称之为 -- 文件描述符表

在这个 文件描述符表当中就存储了 这个进程所打开的 各个文件的 struct file 文件的对象的地址

 所以,当我们调用open()函数打开一个文件,那么 就会帮这个文件创建一个 struct file 文件对象,然后,在 struct file * fd_array[]  数组当中,找到一个没有被使用过的 成员位置,把这个文件对象的 地址 存储到这个成员位置。

然后,把这个 成员 的下标,返回给用户。


 所以,之所以 被打开的文件(被加载到内存当中文件)为什么要单独用一个双链表来连接起来呢?

因为 ,进程可能会中途退出,但是文件可能还会被访问修改,而且打开文件,把文件内容加载到内存当中是操作系统做的事情,不是进程做的事情,进程只能向操作系统申请某一个文件的打开,但是不能直接访问到这个文件。

所以,关于文件的加载和操作系统所做的事情,那么操作系统就要用 一个数据结构来关联 为各个文件创建的 文件对象。方便操作系统进行管理。

进程要想和 操作系统维护的 文件对象数据结构 的话,就要用到上述所说的  files_struct 结构体对象。在这个 files_struct 结构体对象 当中有一个数组 -- struct file * fd_array[] 。这个数组就存储了这个进程当前所打开的文件的文件对象的地址。所以,就用这个数组的下标的方式 和 操作系统维护的 文件对象数据结构 联系起来了。

 stdin,stdout,stderr 三个标准输入输出流的 文件描述符

在上图的程序当中,fd1 - fd4 是分别打开的四个文件的 下标返回值。那么输出结果是什么呢?

输出:
 

 发现,输出结果是 3 - 6 四个连续的下标,但是,fd1 不是我们打开的第一个文件吗?为什么fd 是从 3 开始而不是 从 0 号开始存储呢?

是不是 前三个 (0 - 2 下标的三个位置)已经存储了其他的文件了呢?

是的

在上述我们说过,运行一个C语言程序,默认就会打开三个标准输入输出流, stdin,stdout,stderr。而这  三个 标准输入输出流 其实本质上就是  文件

 而这 stdin,stdout,stderr 三个 标准输入输出流的名字,其实是 C语言当中规定的,不是操作系统当中规定的

操作系统当中,对于文件,只认 fd(文件描述符)。

 这三个对应的输入输出流的, stdin,stdout,stderr  对应 fd 就是 0 ,1 , 2

所以,对于 stdout,stderr   两个文件当中写入数据的话,程序执行就会直接 在屏幕上输出我们在文件当中写入的内容:
 

输出:

 同理,我们还可以从 stdin 文件当中读取数据到 程序当中,使用 read()函数可以从文件当中读取数据出来:
 

输出:
 

在上两个例子当中,我们什么文件都没有打开,(0,1,2)三个 标准输出输入的文件是 默认打开的。


操作系统当中的  三个标准输入输出流

 stdin,stdout,stderr 三个标准输入输出流 是 C语言的特性吗?

肯定不是

各种语言肯定会已有自己的  三个标准输入输出流,这  三个标准输入输出流 不是C语言特有的。

所以,其实不是 C语言的程序在运行之时 自动打开 这 三个标准输入输出流。

  • 而是,这三个 标准输出输入流 文件,在操作系统启动之时,就已经被打开了
  • 所有的进程,只是把操作系统在 文件对象双链表当中,找到这三个文件,把这个三个文件 链接到 自己的  文件描述符表 当中。

 因为使用进程 和调试进程的 用户和程序员,就是需要程序的输入输出来查看 进程运行结果


 在 C 当中的 FILE 结构体,是 C库当中自己封装的一个结构体。因为操作系统当中只认 fd 文件描述符,所以在 FILE 这个结构体当中,一定封装得 有 fd 文件描述符

 而, C 当中的 stdin,stdout,stderr 三个标准输入输出流 他们的类型就是 FILE 结构体,所以在这 stdin,stdout,stderr 三个标准输入输出流  当中,一定有 对应的 保存 fd 文件描述符  的 成员属性

 所以,我们现在就来验证一下这 stdin,stdout,stderr 三个标准输入输出流 当中的  fd 文件描述符:

程序输出:
 

发现:

 stdin,stdout,stderr 三个标准输入输出流 对应的 fd 就是 0 , 1 , 2。

 同样的,如果使用 close ()接口之间关闭了 fd 为   某一个 文件(0 , 1 , 2,那么就相当于是这个文件没有在这个进程当中打开了

 输出:

发现程序没有任何输出,因为 printf()函数就是在 stdout 当中输出,所以,这个 stdout 文件已经被关闭了,就不能在输出了 printf()函数底层必定用了 1 号文件

 其实这里的 printf()函数是写入成功了,已经在 操作系统当中的 显示器文件 当中输入了,但是当前进程已经把这个 显示器文件 从 数组当中剔除了,不能再打印了。

操作系统 文件对象双链表 中的 引用计数

 操作系统维护的 文件对象双链表 当中的文件对象,肯定是被很多的进程所指向的。一个文件对象,可能要多很多个 进程所服务。

比如 stdin,stdout,stderr 三个标准输入输出流 ,这三个文件,就是要本很多文件所使用的。

那么,操作系统如何知道一个文件被多少个 进程所使用呢?

答案就是使用 引用计数

 在 每一个文件对象当中都有一个 count 的字段,这个字段就 记录了 当前有多少 个 文件描述符指向这个文件对象。

 如果 count 字段不为了0,说明当前,虽然有进程 不再引用这个 文件对象了,但是还有进程 在引用 这个文件对象,那么就不能释放这个 文件对象空间。必须要等到 count 为0 才能释放这个文件对象空间。


 所以,一个进程 关闭文件 打开文件,本质上其实就是 把 这个文件对象当中的 count 字段           -- 或者 ++。修改 进程自己的 文件描述符表当中 对应下标当中的指针,比如 置空一个 或者 增加一个 下标位置的 指针。

在 各种语言当中 的 文件流,本质其实就是 用 结构体 或者 类 等等的方式把 fd 文件描述符 封装起来了,其中一定是有  fd 文件描述符 的。

  • 7
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

chihiro1122

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值