【Linux】内存中的“文件”

📝 本篇文章主要讲解在 Linux 内核中是如何组织被打开的文件的,包括文件描述符、各种文件相关系统调用;同时,我们会理解什么叫做 "一切皆文件"。看完相信你会对 Linux 内核有更加深刻的理解。

📄 如果想要查看 Linux 2.6.18 版本的内核源代码,请去作者主页资源中获取。


目录​​​​​​​

[1 Linux 对于文件的理解](#1 Linux 对于文件的理解)

[1.1 文件 = 文件属性 + 文件内容](#1.1 文件 = 文件属性 + 文件内容)

[1.2 文件的操作本质都是进程对文件的操作](#1.2 文件的操作本质都是进程对文件的操作)

[2 Linux 文件相关系统调用](#2 Linux 文件相关系统调用)

[2.1 打开文件与关闭文件](#2.1 打开文件与关闭文件)

[2.2 读写文件](#2.2 读写文件)

[3 文件描述符](#3 文件描述符)

[3.1 被打开的文件如何被管理](#3.1 被打开的文件如何被管理)

[3.2 进程与被打开文件之间的关系](#3.2 进程与被打开文件之间的关系)

[3.3 stdin && stdout && stderr](#3.3 stdin && stdout && stderr)

[3.4 文件描述符的分配规则](#3.4 文件描述符的分配规则)

[3.5 重定向](#3.5 重定向)

[3.6 fork 父子进程如何看待被打开的文件](#3.6 fork 父子进程如何看待被打开的文件)

[3.7 程序替换是否影响文件描述符表](#3.7 程序替换是否影响文件描述符表)

[4 Linux 下一切皆文件](#4 Linux 下一切皆文件)

[5 缓冲区](#5 缓冲区)

[5.1 为什么存在缓冲区机制](#5.1 为什么存在缓冲区机制)

[5.2 缓冲区类型以及 FILE 结构体](#5.2 缓冲区类型以及 FILE 结构体)

[5.3 用户文件缓冲区的刷新策略](#5.3 用户文件缓冲区的刷新策略)

[5.4 mystdio.h](#5.4 mystdio.h)

总结


1 Linux 对于文件的理解

在 Windows 操作系统中,存储各种相关数据的集合就称为文件。我们可以将其想象成一个带标签的盒子,文件名就是这个文件的标签,而文件中的各种数据就是盒子里面的内容。文件是存储在磁盘、U盘等外部永久性存储介质上面的,操作文件本质上就是对外设进行输入或者输出,也就是IO 操作。

在 Linux 操作系统中,除了 Windows 中存储在外设中的文件,像键盘、显示器、网卡、磁盘等硬件也被抽象为了文件,即在 Linux 中一切皆文件

1.1 文件 = 文件属性 + 文件内容

不管在哪个操作系统上,文件都是文件属性(元数据、metadata)和文件内容的集合。所以即使内容是 0 KB 的文件,其在磁盘上也是占空间的,即文件属性占据了空间

既然文件 = 属性 + 内容 ,所以对于文件的操作就是对属性或者内容进行操作。但是不管是对文件属性还是内容进行操作,都是用户对文件进行操作,而文件是存储在磁盘上的,所以操作文件实际上就是对磁盘进行操作,磁盘属于外设,而操作系统是硬件的管理者,用户与操作系统交互的唯一方式就是系统调用。所以我们以前通过命令来修改文件属性,比如 mv 修改文件名,chmod 修改文件权限,touch 修改 ACM 时间,本质上都是这些命令通过调用系统调用陷入内核,从而完成了对文件属性的修改。综上,如果我们要对文件内容进行操作,必然也是要通过系统调用的

我们在 C 语言阶段也学习过访问文件的一些库函数,比如 fopen,fclose,fwrite,fread 等等,而用户访问文件必须要经过系统调用,所以这些库函数实际上是封装了对应的系统调用,从而完成了对于文件的操作

1.2 文件的操作本质都是进程对文件的操作

在任何操作系统中,对于文件的操作,并不是我们凭空对文件进行了操作,我们都是通过一个进程对文件进行操作的。进程就像是我们在计算机中的代理人,帮助我们来完成我们一切想要完成的工作。可以说,在计算机中,进程就是用户本身

在进程概念中我们了解到任何一台计算机都是符合冯诺依曼体系结构,也就是 CPU 只能通过内存进行数据的输入与输出,而文件本身是位于磁盘上的,而磁盘属于外设。所以我们对于文件的操作也都是必须先将文件加载到内存,然后操作系统通过创建进程对文件进行操作,等进程结束后,再将内容写回磁盘,进而就完成了对文件的操作。比如 cat、chown 等命令,都是先将文件打开,也就是加载到内存,然后在内存中读写文件。


2 Linux 文件相关系统调用

在这一小节里面,我们会同时讲解 C 语言中的文件操作函数与 Linux 中的系统调用,以便于我们观察他们的相似点与区别。

2.1 打开文件与关闭文件

C 语言接口与进程 cwd 当前路径

首先我们要想要对文件内容进行操作,我们就必须先打开与关闭文件,在 C 语言中我们使用 fopenfclose来打开与关闭文件,其中 fopen 会返回一个 **FILE***的指针,C 语言文件操作都是通过 fopen 的 FILE* 指针作为句柄来进行操作的:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    //传递文件名与文件打开模式,"w" 模式打开会创建文件
    FILE* fp = fopen("log.txt", "w");
    if (fp == NULL)
    {
        perror("open file error!");
        return 1;
    }

    //可以使用 fprintf 向一个文件中写入内容
    fprintf(fp, "hello world\n");
    
    fclose(fp);

	while (1)
	{
		sleep(1);
	}

    return 0;
}

运行结束之后,我们发现当前目录下新建了一个 log.txt 文件,并且 log.txt 文件里面添加了 hello world 内容:

但是我们在新建文件时,我们只写了文件名啊,查找文件不是需要路径吗?为什么会在当前可执行程序所在目录下新建呢?这是因为一个进程启动之后,cwd (current working directory)会将程序所在目录保存为当前路径:

所以我们只写一个文件名也能新建文件的原因,就是进程会搜索自己的 cwd,然后将文件名拼在 cwd 后面,看似是 log,txt,其实是 /home/ltl/log.txt 。那么如果我们把 cwd 改掉,是不是会在别的路径下新建呢?我们可以通过 chdir系统调用来修改 cwd:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    //先通过 chdir 修改 cwd
    chdir("/home/ltl/Test");
    //传递文件名与文件打开模式,"w" 模式打开会创建文件
    FILE* fp = fopen("log.txt", "w");
    if (fp == NULL)
    {
        perror("open file error!");
        return 1;
    }

    //可以使用 fprintf 向一个文件中写入内容
    fprintf(fp, "hello world\n");
    
    fclose(fp);

	while (1)
	{
		sleep(1);
	}

    return 0;
}

Linux 系统调用

我们可以使用 open系统调用来完成文件的打开:

对于 open 系统调用,其参数有两个或者三个,第一个参数 pathname 可以是文件路径也可以是文件名,文件名会拼上进程的 cwd 构成完整路径;第二个参数 flags 代表一些创建文件的标记位;第三个参数 mode 代表新建文件时文件的权限。第一个和第三个参数好理解,我们重点来讲解一下第二个参数。

系统中为 flags 参数提供了以下几个选项:

其中红框标注的就是我们常用的几个选项,其中 O_RDONLY 表示只读;O_WRONLY 表示只写;O_RDWE 代表既读又写;O_CREAT 代表文件不存在时新建文件;O_TRUNC 代表清空文件。这些选项都是宏值,而且只有一个比特位是 1,我们可以通过位的或操作符(|)来完成多个标志位的传参,此就称为位图传参。接下来我们看一个例子来理解位图传参:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

//定义宏值,实现位图传参
#define Version1 (1 << 0)
#define Version2 (1 << 1)
#define Version3 (1 << 2)
#define Version4 (1 << 3)
#define Version5 (1 << 4)

void Version(int flags)
{
    if (flags & Version1)
        printf("Version1\n");
    if (flags & Version2)
        printf("Version2\n");
    if (flags & Version3)
        printf("Version3\n");
    if (flags & Version4)
        printf("Version4\n");
    if (flags & Version5)
        printf("Version5\n");
}

int main()
{
    int flag = 0;
    Version(Version1 | Version2);
    printf("----------------------\n");
    Version(Version2);
    printf("----------------------\n");
    Version(Version1 | Version2 | Version4);
    printf("----------------------\n");
    Version(Version1 | Version2 | Version4 | Version5);

    return 0;
}

在上述代码中,Version1、Version2 等宏,每个就只占一个比特位,所以就可以用来进行位图传参,通过按位或运算符(|)就可以传递多个参数:

所以对于 open 函数中的 flags 函数,我们就可以通过按位或的方式传递多个标记位。如果想要只写,不存在就新建文件,并且想要每次打开就清空文件,我们就可以使用 O_WRONLY | O_CREAT | O_TRUNC。open 函数的使用如下所示:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
    //新建文件别忘记给权限
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0)
    {
        perror("open file error!\n");
        return 1;
    }

    const char* str = "hello world\n";

    //可以使用 write 系统调用写入内容
    //不要将 '\0' 写入文件中
    write(fd, str, strlen(str));

    //关闭文件使用 close
    close(fd);

    return 0;
}

可以看到添加了 O_TRUNC 之后,每次打开文件都会清空文件,所以上面的代码其实就是 C 语言中 fopen("log.txt", "w") 的简易实现。那么以 "a" 方式打开只需要将 O_TRUNC 改为 O_APPEND 即可。所以 C 语言中的 fopen 函数必然是封装了 open 这个系统调用。

另外我们可以看到 log.txt 的文件权限为 664,但是我们明明将文件权限设为了 666 啊,原因就是 umask 权限掩码的存在,普通用户权限掩膜的 0002,所以权限为 664。

我们可以通过 close 系统调用来关闭一个打开的文件:

其中 close 的参数 fd 就是 open 函数返回的文件描述符。

2.2 读写文件

C 语言接口

C 语言中进行文件读写的接口很多,比如 fwrite、fread、fprintf、fscanf 等等,我们这里就使用 fwrite 来简单的写文件:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
    //新建文件别忘记给权限
    FILE* fp = fopen("log.txt", "a");
    if (fp == NULL)
    {
        perror("open file error!\n");
        return 1;
    }

    const char* str = "hello world\n";

    //可以使用 write 系统调用写入内容
    //不要将 '\0' 写入文件中
    fwrite(str, strlen(str), 1, fp);

    //关闭文件使用 close
    fclose(fp);

    return 0;
}

Linux 系统调用

Linux 中主要通过 readwrite系统调用来完成文件的读写:

read 和 write 的第一个参数都是文件描述符,也就是 open 的返回值;第二个参数在 read 中是存储读到内容的临时缓冲区,write 中是有写入的字符串;第三个参数是写入的字节数。

对于返回值,如果失败,那么会返回 -1,并且 errno 被设置;成功会返回读到或者写入的字节数。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

//write 上面已经举例了,下面举例 read 的使用
int main()
{
    //1. open 打开文件
    int fd = open("log.txt", O_RDONLY);
    if (fd < 0)
    {
        perror("open file error!\n");
        return 1;
    }

    //2. read 读取文件
    char buffer[1024];
    int n = read(fd, buffer, sizeof(buffer) - 1);
    if (n < 0)
    {
        perror("read file error!\n");
        return 2;
    }
    buffer[n] = 0;
    printf("%s\n", buffer);

    //3. close 关闭文件
    close(fd);

    return 0;
}

read 和 write 是系统读写文件的系统调用,所以 fread 和 fwrite 也必然封装了这两个系统调用。另外,我们可以发现不管是 close,还是 read,还是 write,其都是通过 open 的返回值,也就是文件描述符对相关文件进行操作,所以文件描述符在系统中就相当于 C 语言中的 FILE 结构体,是系统进行文件操作的句柄。可以这样说,系统中只认文件描述符。而 C 语言相关接口又必须通过系统调用来完成对文件的操作,所以 C 语言中必然封装了文件描述符,而该文件描述符就封装在 FILE 结构体中的 _fileno 字段,后面讲解 FILE 结构体时我们会看到的。


3 文件描述符

在上一小节中,我们了解到 Linux 对于文件内容的系统调用包括四个,分别是 open、close、read、write,而后三个函数操作文件的句柄都是 open 函数的返回值,也就是文件描述符。

open 函数如果打开文件成功,会返回一个 >= 0 的整数,打开失败会返回 -1。所以文件描述符就是一个 >= 0 的整数。那么该文件描述符具体是什么呢?为什么系统调用通过该整数就可以找到一个被打开的文件呢?在此之前,我们先来看一下被打开的文件是如何被管理的。

3.1 被打开的文件如何被管理

我们平时会打开很多文件,就像进程一样,在 Linux 内核中,不仅会存在大量的进程,还会存在大量被打开的文件,那么这些文件就要被操作系统管理起来,所以就要先描述,再组织,即先使用一个具体的结构体来描述一个被打开的文件,再用具体的数据结构将其组织起来。

描述被打开文件的结构体

在 Linux 内核中,描述一个被打开文件的结构体叫做 struct file

cpp 复制代码
//Linux 2.6.18 内核 struct file 源码
struct file {
	/*
	 * fu_list becomes invalid after file_free is called and queued via
	 * fu_rcuhead for RCU freeing
	 */
	union {
		struct list_head	fu_list;
		struct rcu_head 	fu_rcuhead;
	} f_u;
	struct dentry		*f_dentry;
	struct vfsmount         *f_vfsmnt;
	const struct file_operations	*f_op;
	atomic_t		f_count;
	unsigned int 		f_flags;
	mode_t			f_mode;
	loff_t			f_pos;
	struct fown_struct	f_owner;
	unsigned int		f_uid, f_gid;
	struct file_ra_state	f_ra;

	unsigned long		f_version;
	void			*f_security;

	/* needed for tty driver, and maybe others */
	void			*private_data;

#ifdef CONFIG_EPOLL
	/* Used by fs/eventpoll.c to link all the hooks to this file */
	struct list_head	f_ep_links;
	spinlock_t		f_ep_lock;
#endif /* #ifdef CONFIG_EPOLL */
	struct address_space	*f_mapping;
};

其中,我们可以看到一些熟悉的字段,f_mode 就是被打开文件的权限,f_pos 就是读写位置。

文件 = 文件属性 + 文件内容 ,所以在 struct file 中必然会有承载文件属性和文件内容的字段。其中承载文件属性的字段为 struct dentry *f_dentry,可以借助 f_dentry 字段进而找到 struct inode 结构体,而 struct inode 就是 Linux 内核中描述文件属性的结构体:

cpp 复制代码
struct inode {
	struct hlist_node	i_hash;
	struct list_head	i_list;
	struct list_head	i_sb_list;
	struct list_head	i_dentry;
	unsigned long		i_ino;
	atomic_t		i_count;
	umode_t			i_mode;   
	unsigned int		i_nlink;
	uid_t			i_uid;     //uid
	gid_t			i_gid;     //gid
	dev_t			i_rdev;
	loff_t			i_size;
	struct timespec		i_atime;     //access time
	struct timespec		i_mtime;     //modify time
	struct timespec		i_ctime;     //change time
	unsigned int		i_blkbits;
	unsigned long		i_blksize;
	unsigned long		i_version;
	blkcnt_t		i_blocks;
	unsigned short          i_bytes;
	spinlock_t		i_lock;	/* i_blocks, i_bytes, maybe i_size */
	struct mutex		i_mutex;
	struct rw_semaphore	i_alloc_sem;
	struct inode_operations	*i_op;
	const struct file_operations	*i_fop;	/* former ->i_op->default_file_ops */
	struct super_block	*i_sb;
	struct file_lock	*i_flock;
	struct address_space	*i_mapping;
	struct address_space	i_data;
#ifdef CONFIG_QUOTA
	struct dquot		*i_dquot[MAXQUOTAS];
#endif
	/* These three should probably be a union */
	struct list_head	i_devices;
	struct pipe_inode_info	*i_pipe;
	struct block_device	*i_bdev;
	struct cdev		*i_cdev;
	int			i_cindex;

	__u32			i_generation;

#ifdef CONFIG_DNOTIFY
	unsigned long		i_dnotify_mask; /* Directory notify events */
	struct dnotify_struct	*i_dnotify; /* for directory notifications */
#endif

#ifdef CONFIG_INOTIFY
	struct list_head	inotify_watches; /* watches on this inode */
	struct mutex		inotify_mutex;	/* protects the watches list */
#endif

	unsigned long		i_state;
	unsigned long		dirtied_when;	/* jiffies of first dirtying */

	unsigned int		i_flags;

	atomic_t		i_writecount;
	void			*i_security;
	union {
		void		*generic_ip;
	} u;
#ifdef __NEED_I_SIZE_ORDERED
	seqcount_t		i_size_seqcount;
#endif
};

而承载文件内容的字段为 struct address_space *f_mapping,其可以间接找到 struct radix_tree_node 字段,其中的 slots 数组就会存储着指向描述物理内存的结构体 struct page 的指针:

cpp 复制代码
struct radix_tree_node {
	unsigned int	count;
	void		*slots[RADIX_TREE_MAP_SIZE];
	unsigned long	tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS];
};

struct file 中描述文件内容与文件属性关系如图所示:

如果简单画一下就是:

其中存储文件内容的内存空间又称为内核文件缓冲区。

组织被打开文件的数据结构

我们可以看到在 strcut file 中存在一个结构体字段叫做 struct list_head,所以 struct file 的 struct task_struct 的组织方式相同,都是使用双链表将其链接起来。所以 Linux 系统对打开文件的管理就转变成了对双链表的增删查改。

但是我们之前说,文件都是被进程打开的,那么进程与被打开文件之间的关系又是什么呢?文件描述符到底是什么呢?

3.2 进程与被打开文件之间的关系

文件是被进程打开的,而且一个进程不仅可以打开一个文件,也就是 进程:文件 = 1:n,所以对于一个进程来说,必须能够找到自己所打开的全部文件,也就是在描述进程的结构体 task_struct 中必须包含被打开文件的相关字段,而这个字段就叫做 struct files_struct *files

cpp 复制代码
//Linux 2.6.18 内核 task_struct 源码
struct task_struct {
    //...

	int prio, static_prio, normal_prio;
	struct list_head run_list;
	struct prio_array *array;

    //...

	struct mm_struct *mm, *active_mm;

    //...

	pid_t pid;
	pid_t tgid;
	

	//...

/* open file information */
	struct files_struct *files; //描述被进程打开文件的结构体
    
    //...
};
cpp 复制代码
#define BITS_PER_LONG 64
#define NR_OPEN_DEFAULT BITS_PER_LONG

struct files_struct {
  /*
   * read mostly part
   */
	atomic_t count;
	struct fdtable *fdt;
	struct fdtable fdtab;
  /*
   * written part on a separate cache line in SMP
   */
	spinlock_t file_lock ____cacheline_aligned_in_smp;
	int next_fd;
	struct embedded_fd_set close_on_exec_init;
	struct embedded_fd_set open_fds_init;
	struct file * fd_array[NR_OPEN_DEFAULT]; //文件描述符表
};

我们可以看到在 task_struct 中存在一个 struct files_struct *files,而 struct files_struct 中又存在一个 struct file* 的指针数组 fd_array,这个数组称为文件描述符表,指向了一个一个的 struct file 结构体。所以进程与被打开文件在源码的关系为:

所以一个进程就可以通过 task_struct 中的 files 找到文件描述符表 fd_array\[\],进而根据再找到自己所打开的文件。而文件描述符正是 fd_array 这个数组的下标,操作系统之所以只认文件描述符 fd,就是因为其可以根据这个下标在 fd_array 中找到对应的 struct file,进而找到被打开的文件。

3.3 stdin && stdout && stderr

在 C 语言中我们学过,一个程序在启动之后,会默认打开三个文件流,分别是标准输入(stdin)、标准输出(stdout)、标准错误(stderr),而这三个流的类型也是 FILE*。所以这三个其实也是文件流,因为 Linux 下一切皆文件,所以这三个流打开的其实是抽象文件,也就是键盘文件与显示器文件,其中 stdin 对应的是键盘文件,stdout 与 stderr 对应的是显示器文件。

在 Linux 操作系统中对于打开的文件只认文件描述符 fd,那么这三个默认文件就 FILE 里面封装的 fd 是多少呢?

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

//验证 fd 的分配规则
int main()
{
    printf("stdin->%d\n", stdin->_fileno);
    printf("stdout->%d\n", stdout->_fileno);
    printf("stderr->%d\n", stderr->_fileno);

    return 0;
}

可以看到 stdin、stdout、stderr 对应的 fd 分别是 0、1、2,所以 Linux 进程在默认情况下会有三个默认的文件描述符 0、1、2。所以我们可以通过这三个默认的文件描述符来读取和输出信息:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

//验证 fd 的分配规则
int main()
{
    char buffer[1024];
    //从标准输入中读取,也就是从键盘文件中读取
    ssize_t n = read(0, buffer, sizeof(buffer) - 1);
    if (n < 0)
    {
        perror("read file error!\n");
        return 1;
    }

    //再将获取的数据输出到显示器文件上
    ssize_t num = write(1, buffer, strlen(buffer));

    return 0;
}

所以输入信息,其实就是从 stdin 里面获取,也就是从 0 号 fd 对应的 struct file 中获取数据;而将数据打印到屏幕上,其实就是向 stdout 里面输出信息,也就是向 1 号 fd 对应的 struct file 中输出数据

那么一个进程为什么要默认打开这三个流呢?因为一个进程就是满足某种任务而生,无非就是计算任务或者是 IO 任务,在执行任务过程中,难免会发生错误,所以会打开标准错误,让我们看到错误发生在了哪里;我们还需要知道任务执行进度啊,执行效果等等,也就是打印日志,所以会默认打开标准输出;有时进程还需要从键盘中获取数据,所以就会默认打开标准输入。一句话,默认打开三个流就是为了满足一个进程任务执行过程中最基本的交互需求。

3.4 文件描述符的分配规则

从内核源代码中,我们可以知道文件描述符本质上就是一个数组下标,是 fd_array 的数组下标。那么系统是如何分配这个文件描述符的呢?分配规则是怎么样的呢?我们可以通过下面这个代码来简单看一下:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

//验证 fd 的分配规则
int main()
{
    int fd = open("log1.txt", O_CREAT | O_TRUNC | O_WRONLY, 0666);
    if (fd < 0)
    {
        perror("open file error!\n");
        return 1;
    }

    printf("new fd: %d\n", fd);

    close(fd);
    return 0;
}

可以看到新打开的一个文件默认 fd 是 3,因为 0、1、2 被标准输入、标准输出、标准错误占了嘛,那为什么会分配 3 呢?是从最小的、没有被使用的开始分配吗?我们可以把 0 关掉试试,看看是否会分配 0:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

//关闭0,验证 fd 的分配规则
int main()
{
    //先将标准输入关掉
    close(0);
    int fd = open("log1.txt", O_CREAT | O_TRUNC | O_WRONLY, 0666);
    if (fd < 0)
    {
        perror("open file error!\n");
        return 1;
    }

    printf("new fd: %d\n", fd);

    close(fd);
    return 0;
}

所以,文件描述符的分配规则就是从 fd_array\[\] 数组中,找到当前没有被使用的、最小的一个下标,作为新的文件描述符

3.5 重定向

重定向原理

在 Linux 命令行中,可以使用 ">" 来完成输出重定向,">>" 来完成追加重定向,"<" 来完成输入重定向:

那么该重定向的本质是什么呢?其实就是修改了文件描述符指向的 struct file 指针。比如下面这个代码:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

//关闭1,验证重定向的本质
int main()
{
    //先将标准输出关掉
    close(1);
    int fd = open("log.txt", O_CREAT | O_TRUNC | O_WRONLY, 0666);
    if (fd < 0)
    {
        perror("open file error!\n");
        return 1;
    }

    printf("hello world\n");
    fflush(stdout); //刷新的虽然是 stdout,但是底层的文件描述符已经被替换了,这里一定要刷新
    close(fd);
    return 0;
}

可以看到一旦关掉 1 号 fd 之后,printf 函数并不会向屏幕上进行打印了,而是直接向 log.txt 文件中打印,原因就是 printf 输出时,是向 stdout 中打印的,stdout 是 FILE* 类型,底层封装的文件描述符为 1,但是代码中调用了系统调用关闭了 1,根据文件描述符的分配规则,此时打开 log.txt 文件,分配的就是 1 号文件描述符,但是上层的 stdout 并不知道,所以 printf 还是向 1 号文件描述符中打印,所以就输出到了 log.txt 文件中。具体过程如下图所示:

上述过程就是重定向的本质。所谓输出重定向与追加重定向都是将 1 号文件描述符里的 struct file* 替换为新打开文件的 struct file*,只不过打开文件的方式不同;而输入重定向就是将 0 号文件描述符的 struct file* 替换为新打开文件的 struct file*,那么就不从键盘中获取数据而是从文件中获取数据

了解了上述重定向的原理,我们就可以实现一个简单的输出重定向代码:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

//利用命令行参数实现一个简易的输出重定向代码
int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        printf("Usage: %s filename string\n", argv[0]);
        return 1;
    }

    //先将标准输出关掉
    close(1);
    int fd = open(argv[1], O_CREAT | O_TRUNC | O_WRONLY, 0666);
    if (fd < 0)
    {
        perror("open file error!\n");
        return 2;
    }

    write(fd, argv[2], strlen(argv[2]));

    close(fd);
    return 0;
}

dup2 系统调用

了解了重定向的原理,我们就可以完成重定向了,但是每次都 close 再 open 太麻烦了,我们可以使用 dup2系统调用来完成重定向:

dup2 系统调用中,一共会有两个参数,oldfd 与 newfd,这两个参数代表两个文件描述符,其作用是将 newfd 重定向为 oldfd,也就是将 oldfd 中的 struct file* 指针拷贝到 newfd 中,即 newfd 与 oldfd 中都指向了 oldfd 中的 struct file。比如,完成上面代码的输出重定向,就是 dup2(fd, 1)。函数成功返回 newfd,失败返回 -1。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

//利用命令行参数实现一个简易的输出重定向代码
int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        printf("Usage: %s filename string\n", argv[0]);
        return 1;
    }

    //先将标准输出关掉
    int fd = open(argv[1], O_CREAT | O_TRUNC | O_WRONLY, 0666);
    if (fd < 0)
    {
        perror("open file error!\n");
        return 2;
    }

    int n = dup2(fd, 1);
    printf("%d\n", n);
    fflush(stdout);
    
    write(fd, argv[2], strlen(argv[2]));

    close(fd);
    return 0;
}

3.6 fork 父子进程如何看待被打开的文件

fork 之后,会创建一个新的子进程,在进程概念中,我们了解到一个进程 = 内核数据结构 + 自己的代码和数据,当时内核数据结构就只有 task_struct,也就是描述进程的 PCB,子进程会以父进程的 task_struct 为模板创建一个新的 task_struct。

那么在文件这里呢?父子进程又是如何看待被打开的文件呢?是将所有的 struct file 再复制一份给子进程吗?实际上,fork 之后,子进程会拷贝父进程的 files_struct,也就是文件描述符表,然后指向当时父进程指向的 struct file 结构体

这样设计,父子进程对于文件的管理就可以相互独立,一个进程的文件描述符表并不会影响另一个。所以之前的定义可以添加一层了:进程 = 内核数据结构(task_struct、files_struct)+ 自己的代码和数据

但是这样有一个问题,就是父子进程的文件描述符表中的 file* 指向了同一个 struct file,子进程把文件关掉了,不会影响父进程吗?不会的,因为 struct file 中会有一个引用计数的字段:

cpp 复制代码
struct file {
	//...

	atomic_t		f_count; //struct file 中的引用计数
	
    //...
};

只要有一个指针指向 struct file 本身,那么其中的 f_count 就会++,失去一个指针指向,f_count 就会 --,只有当 f_count == 0 时,当前 struct file 才会真正的被操作系统释放。

所以我们在运行一个程序时,我们并没有打开三个标准流,为什么会默认打开呢?就是因为子进程会继承父进程的文件描述符表,而在 Linux 中,我们运行的一切程序都是 bash 的子进程,所以只要 bash 打开了三个标准流,那么我们运行的一切程序就都会默认打开三个标准流。

3.7 程序替换是否影响文件描述符表

我们在进程控制中学到过进程替换,其原理为:

程序替换只是将磁盘中的新代码、新数据替换到物理内存中的旧代码、旧数据处,不会影响进程的描述结构体 task_struct,更别提 files_struct 了:

所以说,进程替换丝毫不会影响进程描述符表,进程替换之前打开了哪些文件,替换之后,进程依旧会打开哪些文件


4 Linux 下一切皆文件

在 Windows 中,我们一般只会把存储数据的集合称为文件,包括文本文件和二进制文件,但是像键盘、磁盘、显示器这些硬件统称为外设,并不是文件。但是在 Linux 中,不仅数据的集合称为文件,像键盘、磁盘、管道(进程间通信的一种方式),甚至是网络通信中的 socket(套接字)也被抽象为了文件,可以说,Linux 下一切皆文件。

这样做的好处是,开发者仅需一套 API 和开发工具,就可以调用 Linux 中大部分资源。不仅文件可以通过 read、write 系统调用来读写,像管道、socket 也可以使用 read、write 来读写,大大降低了用户的学习成本。那么一切皆文件在内核中是如何实现的呢?

我们之前说描述被打开文件的结构体为 struct file,在 file 中存在一个字段 f_op:

cpp 复制代码
struct file {
    //...

	const struct file_operations	*f_op; //文件方法集
	
    //...
};
cpp 复制代码
struct file_operations {
	struct module *owner;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*aio_read) (struct kiocb *, char __user *, size_t, loff_t);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	ssize_t (*aio_write) (struct kiocb *, const char __user *, size_t, loff_t);
	int (*readdir) (struct file *, void *, filldir_t);
	unsigned int (*poll) (struct file *, struct poll_table_struct *);
	int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
	long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
	long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
	int (*mmap) (struct file *, struct vm_area_struct *);
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *, fl_owner_t id);
	int (*release) (struct inode *, struct file *);
	int (*fsync) (struct file *, struct dentry *, int datasync);
	int (*aio_fsync) (struct kiocb *, int datasync);
	int (*fasync) (int, struct file *, int);
	int (*lock) (struct file *, int, struct file_lock *);
	ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
	ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
	ssize_t (*sendfile) (struct file *, loff_t *, size_t, read_actor_t, void *);
	ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
	unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
	int (*check_flags)(int);
	int (*dir_notify)(struct file *filp, unsigned long arg);
	int (*flock) (struct file *, int, struct file_lock *);
	ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
	ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
};

可以看到在 file_operations结构体中,除了 owner 的回指指针之外,其余的都是函数指针,而在这些函数指针中,我们可以看到有 read、write、flush 等方法,这个结构体就是一个文件的操作方法集。

file_operations 本质上是一个将系统调用与驱动程序关联起来的数据结构,每一个函数指针都对应一个系统调用。虽然表面上看似操作系统调用的都是同一个系统调用,但是底层的具体实现是不同的,每一个驱动程序都有自己的读写等方法。所以操作系统正是通过 struct file 中的操作方法集来调用相同的系统调用,从而完成对不同硬件的控制。不管是普通文件,还是硬件,还是 socket,操作系统只需要找到对应的 struct file 结构体,然后找到 file_oprations 结构体,调用对应的系统调用,即可完成读写等操作,这就是 Linux 一切皆文件的核心含义。我们可以通过一张图来总结一下其核心含义:

所有的外设以及文件都会有 read、write 方法,但是具体实现是不同的,只需要填入 struct file 中 file_operations 的具体函数指针,上层就可以通过 struct file 结构体来访问所有的 Linux 资源了。所以在进程与外设和文件之间就通过了一层 struct file 解决了读写所有外设以及文件的问题,所以 srtuct file 这一层又称为 VFS(virtual file system)。


5 缓冲区

缓冲区在计算机世界中是一个非常重要的概念,不仅会在文件中存在缓冲区,而且在硬件、网络中也会有缓冲区,比如 Cache 硬件就是一个十分经典的缓冲区,Cache 通过缓存一些数据,大大提高了 CPU 的读写效率。当然,在这一下小节,我们主要是讲解文件中的缓冲区机制了。

缓冲区的作用就是用来缓存一些数据的,也就是存储一些数据,所以其本质就是一段内存空间,这段空间用来缓存输入或者输出的数据。所以缓冲区会根据其对应的是输入还是输出设备来区分输入缓冲区与输出缓冲区。那么为什么要引入缓冲区机制,以及为什么可以提高效率呢?

5.1 为什么存在缓冲区机制

如果不存在缓冲区,那么进程每次从文件读入或者向文件写入数据,就必须调用一次系统调用,也就是每次进行一次读写操作,都会直接调用一次系统调用,但是每次系统调用都必须从用户态切换到内核态,从用户空间切换到内核空间,需要切换一部分进程上下文(上面看不懂没关系,只需要知道系统调用是需要成本的),那么大量的系统调用就会降低运行效率。而且进程每次执行read、write 系统调用,都必须等读取、写入磁盘结束才会返回,而磁盘效率相比于 CPU 和内存非常低,所以会大大降低进程的执行效率。

而如果使用了缓冲区机制,那么进程只需要将读写的数据放到缓冲区之中,缓冲区是内存空间,会比磁盘快很多,而且进程将数据放到缓冲区之后,会立即返回,不用自己将数据刷新到磁盘。所以引入了缓冲区机制之后,就可以允许进程单位时间内做更多的事情,不用花大量的时间在效率低的 IO 上,从而提高了使用缓存的进程的效率。

另外,缓冲区的存在也可以允许大量数据在缓冲区中积压,一次就可以刷新大量数据,调用一次系统调用就可以完成大量数据的刷新,减少了 IO 的次数。

综上,缓冲区通过提高使用缓冲区进程的效率,以及减少 IO 次数两点,提高了整体的运行效率

5.2 缓冲区类型以及 FILE 结构体

在讲解 struct file 结构体时,我们提到了内核文件缓冲区,但是缓冲区不仅仅是内核中会有,在用户层面也会有缓冲区的存在。

我们在使用 C 语言读写文件时,都会使用 fopen 函数返回一个 FILE* 句柄,此时你就得到了一个用户文件缓冲区,或者是语言级文件缓冲区,我们可以看一下 C 语言中 FILE 的源码:

cpp 复制代码
// /usr/include/stdio.h
typedef struct _IO_FILE FILE;

// /usr/include/x86_64-linux-gnu/bits/types/struct_FILE.h(Ubuntu 系统)
struct _IO_FILE
{
  int _flags;           /* High-order word is _IO_MAGIC; rest is flags. */

  /* The following pointers correspond to the C++ streambuf protocol. */
  char *_IO_read_ptr;   /* Current read pointer */
  char *_IO_read_end;   /* End of get area. */
  char *_IO_read_base;  /* Start of putback+get area. */
  char *_IO_write_base; /* Start of put area. */
  char *_IO_write_ptr;  /* Current put pointer. */
  char *_IO_write_end;  /* End of put area. */
  char *_IO_buf_base;   /* Start of reserve area. */
  char *_IO_buf_end;    /* End of reserve area. */

  /* The following fields are used to support backing up and undo. */
  char *_IO_save_base; /* Pointer to start of non-current get area. */
  char *_IO_backup_base;  /* Pointer to first valid character of backup area */
  char *_IO_save_end; /* Pointer to end of non-current get area. */

  struct _IO_marker *_markers;

  struct _IO_FILE *_chain;

  int _fileno;
  int _flags2;
  __off_t _old_offset; /* This used to be _offset but it's too small.  */

  /* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];

  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

可以看到 FILE 其实是 typedef 了 struct _IO_FILE。在 _IO_FILE 中,我们可以看到熟悉的文件描述符字段 _fileno,其中上面的各种 char* 字段,就是 FILE 内部的缓冲区字段。

所以说不仅存在内核文件缓冲区,也存在语言文件缓冲区(用户文件缓冲区),而这个缓冲区是存在在 FILE 内部的 。之前我们说,printf 等向显示器打印的函数是向 stdout 中写入,其实就是将数据由你自己定义的内存空间拷贝到了 stdout 对应的 FILE 结构体的缓冲区空间;而 write 等系统调用,也只是在用户空间和内核空间之间相互拷贝罢了,所以 write、read 系统调用本质上是拷贝函数。用 C 语言读写的过程如图所示:

5.3 用户文件缓冲区的刷新策略

从用户文件缓冲区刷新到内核文件缓冲区时会有三种刷新策略:

(1) 全缓冲:填满整个缓冲区才进行刷新,磁盘文件一般采取这种策略。

(2) 行缓冲:遇到换行符,IO 库函数就会调用系统调用刷新到内核,一般显示器采用这种刷新策略。

(3) 无缓冲:即没有缓冲区,进行 IO 操作时,直接调用系统调用刷新到内核,一般标准错误 stderr 会采用这种刷新策略。

除了上述刷新策略,当满足某种特殊条件时,也会出发用户文件缓冲区的刷新策略:

(1) 缓冲区满时

(2) 执行 flush 或者 fflush 语句

(3) 进程退出

了解了用户文件缓冲区以及刷新策略,我们就可以理解下面这个现象了:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

//带换行符 ------ 进程未结束,直接打印
int main()
{
    printf("hello world\n");
    sleep(1);

    return 0;
}

//不带换行符 ------ 进程结束后才打印
int main()
{
    printf("hello world");
    sleep(1);

    return 0;
}

上面两个代码,printf 中一个后面带了换行符,一个不带,带了换行符会立即打印,但是不带换行符的,需要等 1 秒之后进程结束时才会打印。原因就是 printf 是向显示器打印,采用行缓冲刷新策略,当携带换行符时,printf 写入 stdout 缓冲区中的内容会立即刷新到内核;但是不带换行符就必须等待进程结束之后,触发强制刷新条件,才会刷新到内核。

有了上面对于缓冲区的理解,我们就可以试着理解下面这个代码了(下面这段代码的原理与进程地址空间/虚拟内存有关系,可以了解完虚拟内存之后再来看这段代码):

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
    const char* s1 = "hello printf\n";
    printf("%s", s1);
    const char* s2 = "hello fprintf\n";
    fprintf(stdout, "%s", s2);
    const char* s3 = "hello fwrite\n";
    fwrite(s3, strlen(s3), 1, stdout);
    const char* s4 = "hello write\n";
    write(1, s4, strlen(s4));
    fork();

    return 0;
}

可以看到,当我们直接向屏幕打印时,结果是符合我们预期的,向屏幕打印采用行缓冲策略,所以 s1、s2、s3、s4 会按顺序打印到屏幕上,但是当我们重定向到 log.txt 时,只有 hello write 写入了一次,其余的竟然都写入了两次,这是为什么呢?

原因就是语言级文件缓冲区的存在,在我们重定向到文件之后,用户缓冲区的刷新策略就由行缓冲隐式转换为了全缓冲,所以前三个库函数就会将字符串暂存在 stdout 的缓冲区中,并没有刷新到内核,但是 write 是系统调用,直接将数据拷贝到了 1 号文件描述符的内核文件缓冲区处;后面进行 fork,创建子进程,子进程会拷贝父进程的 task_struct、files_struct、mm_struct,但是 struct file 依然是一个,即:

此时父子进程的页表映射指向同一块物理内存页。后来,父子进程各自结束,父进程结束时,会将用户缓冲区的内容刷新到内核文件缓冲区,此时父进程的内存空间发生了变化,发生写时拷贝,为子进程新申请一块物理内存页,然后修改子进程的页表映射,所以父子进程各自持有自己的用户缓冲区,父子进程结束时各自将自己的用户缓冲区数据刷新到内核缓冲区,最终再由操作系统统一将内核缓冲区数据刷新到磁盘。最终,hello write 因为其本来就存在于内核缓冲区,所以只会有一份;而其余的字符串由于在用户空间,所以父子进程各自刷新一份,就有了两份。

5.4 mystdio.h

有了上述知识,我们就可以自己封装系统调用来实现一个简单的 IO 读写库了。这里仅实现基础功能,包括 open、write、close、flush 功能,其余的如果有兴趣,也可以自己实现。

在实现过程中,我们用到了 fsync系统调用,作用就是强制操作系统将内核文件缓冲区的数据立即刷新到磁盘:

cpp 复制代码
//mystdio.h
#pragma once
#define SIZE 1024

#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2

typedef struct _MY_IO_FILE
{
    int _flag; //刷新方式
    int _fileno; //封装文件描述符
    char _outbuffer[SIZE]; // 输出缓冲区
    int _cap; //缓冲区容量
    int _size; // 当前缓冲区的数据量
}MYFILE;

MYFILE* myfopen(const char* filename, const char* mode); // 打开文件
void myfclose(MYFILE* fp); // 关闭文件
int myfwrite(const char* str, int size, MYFILE* fp); //向文件中写入
void myfflush(MYFILE* fp); //刷新用户缓冲区
cpp 复制代码
//mystdio.c
#include "mystdio.h"
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

MYFILE* myfopen(const char* filename, const char* mode)
{
    //打开文件就是封装了系统调用 open
    int fd = -1;
    if (strcmp(mode, "r") == 0)
    {
        fd = open(filename, O_RDONLY);
    }
    else if (strcmp(mode, "w") == 0)
    {
        fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
    }
    else if (strcmp(mode, "a") == 0)
    {
        fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
    }
    
    if (fd < 0) return NULL;

    //为 MYFILE 开辟空间
    MYFILE* fp = (MYFILE*)malloc(sizeof(MYFILE));
    if (fp == NULL)
    {
        return NULL;
    }

    fp->_size = 0;
    fp->_fileno = fd;
    fp->_flag = FLUSH_LINE;
    fp->_cap = SIZE;
    fp->_outbuffer[0] = 0;

    return fp;
}

void myfclose(MYFILE* fp)
{
    if (fp->_fileno >= 0)
    {
        //关闭文件之前需要进行一次刷新
        myfflush(fp);
        close(fp->_fileno);
        free(fp); // 不要忘记释放 malloc 的 MYFILE 数据
    }
}

int myfwrite(const char* str, int size, MYFILE* fp)
{
    //1. 将数据拷贝到语言文件缓冲区中
    memcpy(fp->_outbuffer + fp->_size, str, size);
    fp->_size += size;

    //2. 判断是否满足刷新条件
    if (fp->_flag == FLUSH_LINE && fp->_size > 0 && fp->_outbuffer[fp->_size-1] == '\n')
    {
        write(fp->_fileno, fp->_outbuffer, fp->_size);
        fp->_size = 0;
    }
    else if (fp->_flag == FLUSH_FULL && fp->_size == fp->_cap)
    {
        write(fp->_fileno, fp->_outbuffer, fp->_size);
        fp->_size = 0;
    }
    else if (fp->_flag == FLUSH_NONE)
    {
        write(fp->_fileno, fp->_outbuffer, fp->_size);
        fp->_size = 0;
    }
}

void myfflush(MYFILE* fp)
{
    //刷新用户缓冲区,就是将用户缓冲区中的数据拷贝到内核的文件缓冲区中
    if (fp == NULL)
    {
        return;
    }

    if (fp->_size > 0)
    {
        //调用系统调用 write 写入内核
        write(fp->_fileno, fp->_outbuffer, fp->_size);
        //再调用 fsync 将内核文件缓冲区数据刷新到磁盘
        fsync(fp->_fileno);
        
        //清空缓冲区
        fp->_size = 0;
    }
}
cpp 复制代码
//test.c
#include "mystdio.h"
#include <string.h>
#include <unistd.h>

int main()
{
    MYFILE* fp = myfopen("log.txt", "w");
    if (fp == NULL)
    {
        return 1;
    }
    
    //在程序运行过程中时刻打印一下 log.txt,看下效果
    //可以将 s1 最后加个 \n,再看下效果如何
    const char* s1 = "hello world"; 
    int count = 20;
    while (count--)
    {
        myfwrite(s1, strlen(s1), fp);
        sleep(1);
    }

    myfclose(fp);

    return 0;
}

总结

本篇文章我们了解了一个磁盘文件在内存中被打开后,Linux 内核通过管理 struct file 结构体来组织并管理被打开文件,其中进程可以通过文件描述符来找到对应文件的 struct file 结构体,所以在 Linux 内核中,对于一个被打开的文件,所有的文件相关的系统调用都是以文件描述符作为句柄的。

我们还了解了重定向原理以及一切皆文件的理解。重定向本质就是替换文件描述符,而一切皆文件就是通过 struct file 这一层 VFS 来实现一套 API 与工具调用 Linux 绝大多数资源的效果。

对于文件中的缓冲区,分为用户缓冲区(FILE 内部)与内核文件缓冲区(struct file 中可以间接找到),缓冲区的存在可以通过提高使用缓冲区的进程效率以及减少 IO 次数,提高整个系统的运行效率。

总之,通过这一篇文章,相信你会对 Linux 中如何组织被打开的文件以及 Linux 内核有了更加深刻的理解。