【Linux】基础 IO(文件描述符)-- 详解(上)https://developer.aliyun.com/article/1515560?spm=a2c6h.13148508.setting.29.11104f0e63xoTy
四、系统调用接口介绍
1、man 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);
使用 open 需要包含三个头文件,它有两个版本:
- 以 flags 方式打开 pathname。(读取)
- 以 flags 方式打开 pathname,并设置 mode 权限。(创建)
flags 可以是 O_RDONLY(read-only)、O_WRONLY(write-only)、O_RDWR(read/write),且必须包含以上访问模式之一,此外访问模式还可以带上 ‘|’ 标志位。
- pathname:要打开或创建的目标文件。
- flags:打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行 “或” 运算,构成 flags。
参数:
- O_RDONLY:只读打开
- O_WRONLY:只写打开
- O_RDWR:读,写打开
这三个常量,必须指定一个且只能指定一个
- O_CREAT:若文件不存在则创建它。需要使用 mode 选项来指明新文件的访问权限。
- O_APPEND:追加写。
返回值:
- 成功:新打开的文件描述符。
- 失败:-1。
open 函数具体使用哪个和具体应用场景相关,如目标文件不存在,需要 open 创建,则第三个参数表示创建文件的默认权限, 否则,使用两个参数的 open 。
(1)操作系统传递标志位的方案
grep -ER 'O_CREAT | O_RDONLY' /usr/include/ 筛选标志位。
接着我们的 vim 标志位所在路径,发现默认是只读,而 O_CREAT 以下是使用了八进制,不管如何,它们经过转换后,最终只有一个唯一比特位。我们也可以通过组合标志位,传入多个选项。
vim /usr/include/asm-generic/fcntl.h
为什么传两个标志位时需要使用 ‘|’ 操作符?
O_WRONLY、 O_RDONLY、O_CREATE、O_APPEND 都是标志位。
如果我们自己在设计 open 接口时,这里通常是使用整数,0 表示不要,1 表示要。
而系统是怎么做的呢?一个整数有 32 个比特位,所以一个标志位传一个整数显得有点浪费,所以我们可以让一个标志位占一个比特位:用最低比特位来表示是否读、第二低比特位表示是否写、第三低比特位表示是否追加等等。之后我们可以定义一些宏,将来传入了 flags,系统要检测是什么标志位,它只需要 falgs & O_RDONLY,这也解释了为什么上面需要两个标志位时是 O_WRONLY|O_APPEND。
语言都要对系统接口做封装,本质是兼容自身语法特性,系统调用使用成本较高且不具备可移植性,那么在封装后就可以在语言层屏蔽操作系统的底层差异,从而实现语言本身的可移植性。如果所有语言都用 open 这一套接口, 那么这套接口在 Windows 下是不能运行的,所以我们写的程序是不具备可移植性的,而 fopen 能在 Windows 和 Linux 下运行的原因是 C 语言对 open 进行了封装,也就是说这些接口会自动根据平台来选择底层对应的文件接口。同样的,fopen 在 Windows 和 Linux 中头文件的实现也是不同的 。
2、open 函数的返回值
- 上面的 fopen fclose fread fwrite 都是 C 标准库当中的函数,我们称之为库函数(libc)。
- open close read write lseek 都属于系统提供的接口,称之为系统调用接口。
回忆一下学习操作系统概念时画的一张图:
系统调用接口和库函数的关系,一目了然。那么可以认为,f# 系列的函数都是对系统调用的封装,方便二次开发。
在应用层看到一个很简单的动作,在系统接口层面甚至 OS 层面可能要做非常多的工作。
如果没有设置权限:
加上权限之后:
前面说到返回小于 0 的数代表 open 失败,这里显示 open 成功了,但这里为什么不从 0 开始依次返回呢?
因为它们已经被使用了,C 程序运行起来会默认打开三个文件(stdin、stdout、stderr),所以 0、1、2 分别与之对应。
为什么这里每打开一个文件所返回的文件描述符都是类似数组下标的呢?一个进程可以打开多个文件吗?
这里返回的文件描述符就是数组下标。一个进程是可以打开多个文件的,且系统内被打开的文件一定是有多个的,一般而言,进程:打开的文件 = 1:n。如果是多个进程都打开自己的文件呢?系统中会存在大量被打开的文件,那么这些多个被打开的文件,操作系统使用 “先描述,再组织” 的方式将它们管理起来,描述一个打开文件的数据结构叫做 struct file,组织一堆 struct file 就是在 task_struct 中有一个 struct files_struct* files 指针指向 struct files_struct,它的作用就是构建进程和文件之间的对应关系,其中包含了一个指针数组,这里可以理解为定长数组,struct file* fd_array[NR_OPEN_DEFAULT] ➡ #define NR_OPEN_DEFAULT BITS_PER_LONG ➡ #define BITS_PER_LONG 32。
所以用户层看到的 fd 返回值,本质上是系统中维护进程和文件对应关系的数组的下标。比如创建一个文件会多一个 struct file,再把地址存储于指针数组中最小且没有使用过的数组中,这里对应是下标 6,然后把 6 作为返回值返回给用户。所以当用户后续要对文件进行操作时就可以使用 fd 返回值作为参数,比如 read(fd) ,当前进程就会拿着 fd 去 struct files_struct* 指向的指针数组中找 fd 下标,根据 fd 下标对应的地址找到对应的文件,再从文件中找到对应的 read 方法,对 disk 中的数据进行读取或者写入。
FILE 是什么?由谁提供的?
FILE 是一个 struct 结构体,C 标准库提供的。
C 文件的库函数内部一定要调用系统调用。在系统的角度,要读写文件是一定要有 FILE,还是 fd?
fd。结合前一个问题,可以得出:在 FILE 结构体里面必定封装了 fd。
Linux 2.6 内核源码验证:
对于 file_operations,不同硬件是有不同的方法的。在大部分情况下,方法是和我们的硬件驱动匹配的。虽然如此,但最终文件通过函数指针实现你要打开的是磁盘,那就让所有的方法指向磁盘的方法,你要打开的是其它硬件,那就让所有的方法指向其它硬件的方法,而这里底层的差异在上层看来,已经被完全屏蔽了。
所以于进程而言,对所有的文件进行操作统一使用一套接口(现在我们明确了它是一组函数指针),也就是说,对进程来讲,我们的操作和属性接口统一使用 struct file 来描述,那么在进程看来就是 “一切皆文件”。上面所画的内容全部都在操作系统内部完成,而用户只需要通过 fd 来进行文件的读写。
五、文件描述符 fd
通过对 open 函数的学习,知道了文件描述符就是一个小整数。
1、0 & 1 & 2
- Linux 进程默认情况下会有 3 个缺省打开的文件描述符,分别是标准输入 0, 标准输出 1, 标准错误 2。
- 0、1、2 对应的物理设备一般是:键盘、显示器、显示器。
所以输入输出还可以采用如下方式:
#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <string.h> int main() { 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; }
现在知道,文件描述符就是从 0 开始的小整数。
当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件,于是就有了 file 结构体,表示一个已经打开的文件对象。而进程执行 open 系统调用,所以必须让进程和文件关联起来。
每个进程都有一个指针 *files, 指向一张表 files_struct, 该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针。
所以在本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。
2、文件描述符的分配规则
- 关闭 0:
- 关闭 2:
可见,文件描述符的分配规则:在 files_struct 数组当中,找到当前没有被使用的最小且没有被占用的文件描述符来作为新的文件描述符。
这里我们先搞明白 fopen 和 open 之间的耦合关联:
其实 FILE 结构体是被 _IO_FILE typedef 的(typedef struct_IO_FILE FILE),_IO_FILE 在 /usr/include/libio.h 下,在 _IO_FILE 结构中包含两个重要的成员:
- 底层对应的文件描述符下标 int _fileno,它是封装的文件描述符。也就是说,在 C 的文件接口中一定是使用 fileno 来调用系统接口 read(fp->fileno),所以 fopen 和 open 是通过 C 语言结构体内的文件描述符耦合的。
- 应用层 C 语言提供的缓冲区。之前写进度条小程序时,没有 '\n',数据不显示,必须以 fflush 强制刷新,其中数据所处的缓冲区就是由 __IO_FILE 维护的。
这里 close 1 后,1 下标就不再指向显示器文件,而是指向 log1.txt,FILE* stdout 当然还在,stdout 依然认为它的文件描述符值是 1,这里 printf 时会先把数据放到 C 语言提供的 __IO_FILE 缓冲区中,还没来得及刷新,但已经把 fd1 关闭了,所以操作系统没有办法由用户语言层刷新到操作系统底层的,所以自然也没看到结果。但这里不是有 '\n' 吗,为什么没有往操作系统刷新?因为此时 1 指向的是磁盘文件,磁盘文件是全缓冲,必须等待缓冲区满了再刷新,或者 fflush 强制刷新。在显示器文件中,无论用户层还是内核层都是行刷新,因为它无论怎样最终都会往显示器上刷新。
六、重定向
此时,我们发现本来应该输出到显示器上的内容,输出到了文件 myfile 当中。
其中, fd = 1 。这种现象叫做输出重定向。常见的重定向有:>,>>,<
1、重定向的本质
重定向的本质其实就是在 OS 内部更改 fd 对应的内容指向。
本应该从键盘读取的内容,现在从 log.txt 中读取。这就叫作输入重定向。
- 追加重定向:
2、使用 dup2 系统调用
要输出的文件描述符是 1,而要重定向的目标文件描述符是 fd (echo “hello” > log.txt),dup2 应该怎么传参 —— dup2(1, fd) || dup2(fd, 1) ?
很明显依靠函数原型,我们就能认为 dup2(1, fd),因为 1 是先打开的,而 fd 是后打开的.可实际上并不是这样的,文档中说 newfd 是 oldfd 的一份拷贝,这里拷贝的是文件描述符对应数组下标的内容,所以数组内容最终应该和 oldfd 一致。
换而言之,这里就是想把让 1 不要指向显示器了,而指向 log.txt,fd 也指向 log.txt。所以这里的 oldfd 对应 fd,newfd 对应 1,所以应该是 dup2(fd, 1)。
oldfd copy to newfd -> 最后要和谁一样?
oldfd。
假设输出重定向:显示器(1) -> log.txt(3)。应该是 dup2(1, 3);,还是dup2(3, 1); ?
dup2(3, 1);
3 的内容 copy 到 1 里面 -> 最终和 3 一致
(1)输入重定向
- < 就是 dup2(fd, 0),且 open 文件的方式是 O_RDONLY;
(2)追加重定向
- >> 同 >,都是 dup2(fd, 1),只不过它打开文件的方式是 O_WRONLY | O_APPEND;
(3)输出重定向
echo "hello world" > log.txt —— echo 是一个进程。“hello world” 默认是调用 printf 或 write 往显示器上输出,log.txt 是调用 open 使用 O_WRONLY|O_CREAT 打开,> 是调用 dup2,将默认标准输出 1 的内容改为 log.txt。
3、在 minishell 中添加重定向功能
七、FILE
因为 IO 相关函数与系统调用接口对应,并且库函数封装系统调用。所以本质上,访问文件都是通过 fd 访问的。 所以 C 库当中的 FILE 结构体内部,必定封装了 fd,还包含该文件 fd 对应的语言层的缓冲结构。
1、对进程实现输出重定向
同样一个程序,向显示器打印输出 4 行文本,而向普通文件(磁盘)上打印输出 7 行文本。其中,printf 和 fwrite (库函数)都输出了 2 次,而 write 只输出了一次(系统调用),为什么呢?
- 一般 C 库函数写入文件时是全缓冲的,而写入显示器是行缓冲。
- printf fwrite 库函数会自带缓冲区(进度条例子就可以说明),当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。
- 而我们放在缓冲区中的数据,就不会被立即刷新,甚至 fork 之后。
- 但是进程退出之后,会统一刷新,写入文件当中。
- 但是 fork 的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。
- write 没有变化,说明没有所谓的缓冲。
- fork 执行的时候,一定是函数已经执行完了,但是数据还没有刷新。在当前进程对应的 C 标准库中的缓冲区中,这部分数据是父进程的数据。return 0; 代表父子进程各自退出。
进程替换时,是否会干扰重定向对应的数据结构?
它们当然不会互相影响。换而言之,将来 fork 创建子进程,子进程会以父进程的大部分数据为模板,子进程进行程序替换时并不会影响曾经打开的文件,也就不会影响重定向对应的数据结构。
2、有关缓冲区的认识
一般而言,行缓冲的设备文件 -- 显示器,全缓冲的设备文件 -- 磁盘文件。
所有的设备永远都倾向于全缓冲,因为缓冲区满了才刷新,也就意味着需要更少次数的 IO,也就是更少次的访问,提高效率。
和外部设备 IO 时,数据量的大小不是主要矛盾,与外部设备 IO 的过程才是最耗费时间的。
其它刷新策略是结合具体情况做的妥协。比如:显示器是行刷新,因为显示器直接给用户看的,一方面要照顾效率,一方面要照顾用户体验。在极端情况下,是可以自己自定义规则的。磁盘经过效率考量,采用的是全缓冲的策略。
什么是缓冲区?
就是 一段内存空间。
综上所述: printf fwrite 库函数会自带缓冲区,而 write 系统调用没有带缓冲区。另外,我们这里所说的缓冲区都是用户级缓冲区。(其实为了提升整机性能,OS 也会提供相关内核级缓冲区。)
那这个缓冲区是谁提供的呢?用户?语言?还是 OS?
绝对不是 OS,否则上面的例子应该都是 4 条文本。
printf fwrite 是库函数, write 是系统调用,库函数在系统调用的 “ 上层 ” , 是对系统调用的 “ 封装 ”, 但是 write 没有缓冲区,而 printf fwrite 有,足以说明该缓冲区是二次加上的,又因为是 C,所以由 C 标准库提供。
为什么要有缓冲区?
提高整机效率,主要是为了提高用户的响应速度。
刷新策略 = 一般策略 + 特殊策略
- 一般策略:
- 立即刷新
- 行刷新(行缓冲) \n aaaaa\n bbbb
- 满刷新(全缓冲)
- 特殊策略:
- 用户强制刷新(fflush)
- 进程退出