一、文件描述符的概念

1.1 文件描述符

上篇文章故意回避了一个问题:open 系统调用的返回值是什么?答案是"文件描述符(new file descriptor)"。

在这里插入图片描述

文件描述符是什么?我们打印它看一看:

void Func3()
{
    int fd1 = open("log1.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    int fd2 = open("log2.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    int fd3 = open("log3.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    int fd4 = open("log4.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    if (fd1 < 0 || fd2 < 0 || fd3 < 0 || fd4 < 0)
    {
        printf("文件打开失败\n");
        return;
    }
    printf("fd1: %d\n", fd1);
    printf("fd2: %d\n", fd2);
    printf("fd3: %d\n", fd3);
    printf("fd4: %d\n", fd4);
    close(fd1);
    close(fd2);
    close(fd3);
    close(fd4);
}

在这里插入图片描述

我们发现,文件描述符是一个整型值。但是有人会发现:为什么文件描述符是从 3 开始打印而不是从 0 开始打印?

因为,我们上一篇文章也说了:程序启动的时候为我们自动打开了三个流(文件),标准输入(0)、标准输出(1)、标准错误(2)。以 C 为例,就是 stdin、stdout 和 stderr。

1.2 基于文件描述符的向上讨论

回到我们 C 语言的文件操作,我想告诉大家:上篇文章我们讲到 fopen 函数底层封装了 open 函数,那么 FILE 返回值这个结构体里面一定也封装了一个 fd 文件描述符。

使用指令打开:

view /usr/include/bits/types/struct_FILE.h

在这里插入图片描述

这里的 fileno 就是我们要找的文件描述符。

在操作系统看来,它只认识文件描述符。任何语言,不论它的顶层怎么设计,底层都封装了文件描述符。

以下全部都是基于文件描述符的向下讨论。

我刚才说文件描述符是一个数字,它是从 0 开始计数的,有没有想到什么?对,数组下标。文件描述符就是数组下标。从哪里开始讲呢?

二、文件描述符表

2.1 文件描述符表的概念

我们的系统在为我们创建进程的时候,除了 task_struct、页表、虚拟地址空间等等,还创建了一个文件描述符表结构体,这个结构体里面有一个数组:文件描述符数组(这个数组是一个结构体指针数组)。

我们找一个 task_struct 的源代码看看:

在这里插入图片描述

文件描述符数组在源代码中:

在这里插入图片描述

这个文件描述符数组里面存储的结构体是什么?是结构体 struct file,我们找到它的源代码:

在这里插入图片描述

至此,进程与被打开的文件联系起来了。

当我们打开一个文件,操作系统会为我们创建一个文件描述结构体 struct file,这个结构体中存放文件的属性,然后里面有一个指针指向一块文件缓冲区,这个文件缓冲区里面存放了这个文件的内容。

在这里插入图片描述

将我们上面画的两张图拼在一起,结论出来了:

文件描述符本质是文件描述符表这个结构体(struct file_struct)中的文件描述符数组的下标,这些下标指向一个个文件描述结构体(struct file),于是,拿到文件描述符就可以获取文件的描述结构体,就可以获得某个被打开的文件的全部信息。

在这里插入图片描述

2.2 结合文件描述符底层重新理解 I/O

1. open

对文件内容做任何的操作,都必须将文件内容加载(从磁盘到内存的拷贝)到内核中对应的文件缓冲区中。

在这里插入图片描述

2. read

read 函数的本质是:从内核到用户空间的拷贝函数。

在这里插入图片描述

3. write

write 函数的本质是:从用户空间到内核的拷贝函数。

在这里插入图片描述

2.3 struct file 里面有什么

来看看这个 struct file 结构体里面有什么吧:先看看就行,不用管,后续的文章中再详细讲解。其中主要包含的内容比如:

struct file
{
    // 属性集合
    // int mode
    // 读写位置
    // 读写选项
    // 缓冲区 --- TODO
    // 操作方法
    // struct list_head list;
}

文件描述结构体里面也有一个 union 来构建 file 与 file 之间的连接,方便管理("先描述再组织"无处不在):

union {
    struct list_head    fu_list;     // 内核文件链表指针★
    struct rcu_head     fu_rcuhead;  // RCU 锁释放时的内存头
} f_u;

在这里插入图片描述

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;  // RCU 锁释放时的内存头
    } f_u;

    struct dentry           *f_dentry;   // 【属性】指向目录项对象的指针(通过它能找到文件名、inode 等)
    struct vfsmount         *f_vfsmnt;   // 【属性】指向文件系统挂载点的指针

    const struct file_operations *f_op;  // 【操作方法】极其重要!指向该文件操作函数指针集合(如 read, write 等底层实现)

    atomic_t                f_count;     // 【属性】文件对象的引用计数(有多少个 fd 指向它,为 0 时文件才真正关闭)
    unsigned int            f_flags;     // 【读写选项】打开文件时的标志(如 O_RDONLY, O_WRONLY, O_NONBLOCK)
    mode_t                  f_mode;      // 【属性/int mode】文件的访问权限(如可读、可写)
    loff_t                  f_pos;       // 【读写位置】当前文件的读写偏移量(即下一次 read/write 开始的字节位置)

    struct fown_struct      f_owner;     // 【属性】异步 I/O 时接收信号的进程属主信息
    unsigned int            f_uid, f_gid;// 【属性】文件所有者的 User ID 和 Group ID
    struct file_ra_state    f_ra;        // 【属性】文件预读(Read-Ahead)状态流,用来优化磁盘读取性能

    unsigned long           f_version;   // 【属性】版本号,每次使用后会自动更迭
    void                    *f_security; // 【属性】安全模块(如 SELinux)使用的安全上下文指针

    /* 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;  // 【属性】被 epoll 监听时,挂载到 epoll 事件等待队列的链表节点
    spinlock_t              f_ep_lock;   // 【属性】保护 epoll 链表的自旋锁
#endif /* #ifdef CONFIG_EPOLL */

    struct address_space    *f_mapping;  // 【缓冲区 - TODO】指向页高速缓存(Page Cache)映射的指针!
                                         // 这就是你上一张图说到的"内核开辟的文件缓冲区"在内核里的核心代理人
};

三、重定向的原理

3.1 手动模拟重定向

在理解了文件描述符的底层逻辑(也就是数组下标)之后,我们再来看操作系统分配文件描述符的规则:

最小的、没有被使用的,作为新的 fd 给用户。

既然程序启动时默认打开了 0(stdin)、1(stdout)、2(stderr),那如果我们故意把 1 给关闭了,然后再去打开一个新的文件,会发生什么?

根据规则,操作系统一看,0 占着,1 空着,好,那就把 1 分配给新打开的文件!

我们来看下面这段代码,手动模拟一下这个过程:

void Func4()
{
    close(1);
    int fd = open("log.txt", O_WRONLY | O_TRUNC | O_CREAT, 0666);
    printf("fd:%d\n", fd);
    // 这里要加 fflush:原本打印到屏幕是行刷新,现在打印到文件刷新策略变了(下一篇讲)
    fflush(stdout);
    close(fd);
}

在这里插入图片描述

按道理说,printf 是往标准输出里面打印东西的。但是当我们编译运行后,屏幕上什么都没有输出。

我们通过 ll 指令查看当前目录,发现多了一个 log.txt。打开这个文件一看,本该打印在屏幕上的 fd: 1 居然被写入到了 log.txt 里面!

3.2 重定向的原理

为什么会这样?

因为 printf 只认 stdout,而 stdout 结构体内部封装的 fileno 就是 1。对应用层来说,"往 1 号下标对应的文件写数据"这一逻辑从未改变。

但是在内核层,我们通过 close(1) 断开了 1 号下标指向标准显示器的连接,并让它重新指向了 log.txt 的 struct file。

上层不变底层变,上层还是原来的文件描述符,底层变成了别的地址,这——就是重定向的底层原理!

在这里插入图片描述

3.3 dup2 系统调用

虽然通过先 close 再 open 可以实现重定向,但这未免有点太笨拙了,而且不够优雅。为此,Linux 系统为我们提供了一个高效率的系统调用接口:dup2。

我们先来看一下它的系统调用手册:

int dup2(int oldfd, int newfd);

关于 dup2 的两个参数 oldfd 和 newfd,很多人第一次看文档极其容易搞混。官方文档有一句非常核心的解释:

dup2() makes newfd be the copy of oldfd, closing newfd first if necessary.

这句话翻译过来就是:让 newfd 成为 oldfd 的一份拷贝。如果 newfd 已经打开了,会先把它关闭。

这里的"拷贝",拷贝的绝对不是数组下标的数字,而是文件描述符表数组项中的内容(即 struct file 的指针)。

在这里插入图片描述

简而言之,就是把 oldfd 指向的文件对象指针,覆盖到 newfd 的位置上。最终的结果是:newfd 和 oldfd 都指向了原来 oldfd 所指向的文件。

3.4 dup2 实际应用

如果要进行输出重定向(比如把本该输出到屏幕的内容重定向到文件中):我们希望原本往 1 号(newfd)打的内容走到新打开的文件 fd(oldfd)中去。所以代码应该写成:dup2(fd, 1)。

下面我们用 dup2 来重写一下上面的功能:

int fd = open("log.txt", O_WRONLY | O_TRUNC | O_CREAT, 0666);
dup2(fd, 1);
printf("你好,文件\n");
fflush(stdout);
close(fd);

在这里插入图片描述

四、Shell:实现重定向功能

4.1 核心设计思路与宏定义

要想让 Shell 支持重定向,我们需要把整个过程拆解为两步:

  1. 宏观检测:在解析用户输入的命令行字符串时,检查其中是否包含 <、>、>> 等重定向符号。如果存在,必须将这些符号以及后面的文件名从原命令行中"拆离"出来;
  2. 底层替换:在 fork() 出子进程之后、程序替换 execvp() 之前,根据重定向的类型,调用 open() 系统调用打开对应的文件,并使用 dup2() 改变子进程的标准输入或标准输出。

首先,我们在代码顶层定义代表重定向类型的 4 个宏状态,以及用于保存全局重定向状态和目标文件名的全局变量:

#define NONE_REDIR   0  // 无重定向
#define INPUT_REDIR  1  // 输入重定向 <
#define OUTPUT_REDIR 2  // 输出重定向 >
#define APPEND_REDIR 3  // 追加重定向 >>

int redir = NONE_REDIR; // 记录当前重定向的类型
char *filename = NULL;  // 记录重定向的目标文件名

由于用户的输入可能长这样:ls -a -l > log.txt。我们需要从字符串的末尾开始向前扫描。因为重定向符号及其文件名一般都在命令行的最右侧。

一旦扫描到符号,我们需要做两件事:

  1. 将符号位置置为 \0,从而把前面的有效命令和后面的文件名彻底分隔开;
  2. 跳过符号与文件名之间的空格,提取出纯净的文件名给 filename。

检测函数的核心实现如下:

void RedirCheck(char *cmd)
{
    redir = NONE_REDIR;
    int begin = 0;
    int end = strlen(cmd) - 1;
    while (end >= 0)
    {
        if (cmd[end] == '<')
        {
            // 分割
            cmd[end] = '\0';
            end++;
            // 取出空格
            while (isspace(cmd[end]))
                end++;
            // 首元素地址加 end
            filename = cmd + end;
            redir = INPUT_REDIR;

            break;
        }
        else if (cmd[end] == '>')
        {
            // 这里当 end 走到 0 的时候要注意越界问题
            if (end > 0 && cmd[end - 1] == '>')
            {
                // 分割
                cmd[end - 1] = '\0';
                end++;
                // 取出空格
                while (isspace(cmd[end]))
                    end++;
                // 首元素地址加 end
                filename = cmd + end;
                redir = APPEND_REDIR;
                break;
            }
            else
            {
                // 分割
                cmd[end] = '\0';
                end++;
                // 取出空格
                while (isspace(cmd[end]))
                    end++;
                // 首元素地址加 end
                filename = cmd + end;
                redir = OUTPUT_REDIR;
                break;
            }
        }
        end--;
    }
}

很多人在写这段字符串解析时,会先把 cmd[end] = 0,紧接着在循环条件里写 while(isspace(cmd[end])) end++;。此时 cmd[end] 已经是 \0(字符串结束符)了,isspace('\0') 永远为假,导致循环直接跳出,提取的文件名里会夹杂着大量的非法空格或者错位指针,导致后续 open 失败。正确的做法是先将位置置空,然后将指针 end++ 移向后方,再去跳过空格。

4.2 进程替换与重定向的结合

文本解析好了,状态也保存了。接下来到了最核心的命令执行函数 NormalExcute。

if (_argv[0] == NULL)
    return;
pid_t id = fork();

if (id == 0)
{
    if (redir == INPUT_REDIR)
    {
        int fd = open(filename, O_RDONLY);
        dup2(fd, 0);
        close(fd);
    }
    else if (redir == OUTPUT_REDIR)
    {
        int fd = open(filename, O_TRUNC | O_WRONLY | O_CREAT, 0666);
        dup2(fd, 1);
        close(fd);
    }
    else if (redir == APPEND_REDIR)
    {
        int fd = open(filename, O_APPEND | O_WRONLY | O_CREAT, 0666);
        dup2(fd, 1);
        close(fd);
    }

    int i = execvp(_argv[0], _argv);
    if (i == -1)
    {
        perror("execvp");
        exit(1);
    }
}
else
{
    int status = 0;
    // 阻塞等待
    pid_t w_id = waitpid(id, &status, 0);
    if (w_id == id)
    {
        lastcode = WEXITSTATUS(status);
    }
}

1. 为什么一定要在子进程里做 dup2?

这也是自定义 Shell 升级时最容易犯的原则性错误:绝对不能在父进程中执行重定向。

如果我们在父进程(Shell 本身)里直接调用 dup2(fd, 1),那么完蛋了,后续你的 Shell 打印命令提示符 [user@host...]$ 时,提示符不会显示在屏幕上,而是全部被写进了你的重定向文件里!

五、补充内容

5.1 自定义 Shell 如何重定向内建命令

在完成了自定义 Shell 的重定向升级后,你可能会发现一个隐藏的致命问题:如果用户输入的是内建命令,应该如何重定向?

因为内建命令是在父进程里执行的,所以我们同样需要在父进程里重定向,但重定向完还得把指针还原回去。因此,内建命令的重定向,核心关键在于:重定向操作完成后,如何将底层指针恢复如初?

为了解决这个问题,我们需要引入另一个系统调用:dup。

1. dup 系统调用

与 dup2 强制覆盖指定下标不同,dup 的作用是复制一份标准指针到当前最小的空闲位置。

#include <unistd.h>
int dup(int oldfd);

返回值:成功时返回新分配的文件描述符(该描述符指向与 oldfd 相同的文件对象);失败返回 -1。

  • 利用 dup 恢复指针:
  1. 备份:在对 1 号(stdout)进行重定向覆盖之前,先调用 int save_stdout = dup(1);。此时,系统会分配一个新 fd(比如 3),让 3 号也指向标准显示器。这样我们就把显示器的指针备份到了 3 号位置;
  2. 重定向:调用 dup2(fd, 1);,放心地让 1 号指向目标文件,供内建命令使用;
  3. 执行:调用内建命令的执行函数(如 Echo());
  4. 恢复:内建命令执行完毕后,调用 dup2(save_stdout, 1);。把刚才备份在 3 号的显示器指针重新覆盖回 1 号位置;
  5. 善后:调用 close(save_stdout); 和 close(fd);,释放不需要的临时描述符。

5.2 代码实现

修改后的内建命令处理逻辑如下:

int buildCommand(char **_argv, int argc)
{
    int save_stdin = -1;
    int save_stdout = -1;

    if (redir == INPUT_REDIR)
    {
        save_stdin = dup(0); // 备份原标准输入
        int fd = open(filename, O_RDONLY);
        if (fd >= 0)
        {
            dup2(fd, 0); // 重定向标准输入
            close(fd);
        }
    }
    else if (redir == OUTPUT_REDIR)
    {
        save_stdout = dup(1); // 备份原标准输出
        int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0666);
        if (fd >= 0)
        {
            dup2(fd, 1); // 重定向标准输出
            close(fd);
        }
    }
    else if (redir == APPEND_REDIR)
    {
        save_stdout = dup(1); // 备份原标准输出
        int fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0666);
        if (fd >= 0)
        {
            dup2(fd, 1); // 重定向标准输出
            close(fd);
        }
    }

    // echo
    if (strcmp(_argv[0], "echo") == 0 && argc == 2)
    {
        if (strcmp(_argv[1], "$?") == 0)
        {
            printf("退出码:%d\n", lastcode);
            lastcode = 0;
        }
        else if (strcmp(_argv[1], "$") == 0)
        {
            char *val = _argv[1] + 1;
            if (val)
                printf("%s\n", getenv(val));
        }
        else
        {
            printf("%s\n", _argv[1]);
        }
        return 1;
    }
    // cd
    else if (strcmp(_argv[0], "cd") == 0 && argc == 2)
    {
        int flag = -1;
        if (strcmp(_argv[1], "-") == 0)
        {
            char *dir = getenv("OLDPWD");
            flag = chdir(dir);
        }
        else if (strcmp(_argv[1], "~") == 0)
        {
            char *dir = getenv("HOME");
            flag = chdir(dir);
        }
        else
        {
            const char *dir = _argv[1];
            flag = chdir(dir);
        }
        // 这里注意一下:我们 GetPwd() 是从环境变量表里面拿,
        // 而 chdir 没有改环境变量里的 PWD,所以我们要更新环境变量表
        if (flag == -1)
        {
            printf("路径错误\n");
        }
        else
        {
            getcwd(env, sizeof(env));
            setenv("PWD", env, 1);
        }
        return 1;
    }
    // export
    else if (argc == 2 && strcmp(_argv[0], "export") == 0)
    {
        char *eq = strchr(_argv[1], '=');
        if (eq != NULL)
        {
            *eq = '\0';
            setenv(_argv[1], eq + 1, 1);
        }
        return 1;
    }
    // 加个颜色
    if (strcmp(_argv[0], "ls") == 0)
    {
        _argv[argc++] = "--color";
        _argv[argc] = NULL;
    }

    // 关键:恢复
    if (save_stdin != -1)
    {
        dup2(save_stdin, 0); // 恢复标准输入
        close(save_stdin);   // 释放备份
    }
    if (save_stdout != -1)
    {
        dup2(save_stdout, 1); // 恢复标准输出
        close(save_stdout);   // 释放备份
    }
    return 0;
}

5.3 文件与引用计数

我们在前面的草图中提到了系统的核心原则:一个文件可以被多个进程同时打开。这就带来了一个问题:当某个进程关闭一个文件时,操作系统怎么知道该不该把这个文件从内核中真正释放掉?

1. 引用计数的增减规则

在内核的 struct file 结构体内部,有一个名为 f_count 的成员(即引用计数)。它的工作机制:

在这里插入图片描述

  • 增加:每当有一个新的文件描述符(fd)指针指向该文件(如通过 open、fork 进程继承,或调用 dup 系列接口拷贝指针),该文件的引用计数就会 ++;
  • 减少:每当用户层调用 close(fd),或者进程退出导致描述符表被销毁时,操作系统并不会直接抹除该文件,而是先让对应的引用计数 --;
  • 关闭:当文件的引用计数为 0 的时候,文件才会被正式关闭。

在这里插入图片描述

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐