【Linux】文件描述符&&dup2&&重定向
一、文件描述符的概念
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 支持重定向,我们需要把整个过程拆解为两步:
- 宏观检测:在解析用户输入的命令行字符串时,检查其中是否包含
<、>、>>等重定向符号。如果存在,必须将这些符号以及后面的文件名从原命令行中"拆离"出来; - 底层替换:在
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。我们需要从字符串的末尾开始向前扫描。因为重定向符号及其文件名一般都在命令行的最右侧。
一旦扫描到符号,我们需要做两件事:
- 将符号位置置为
\0,从而把前面的有效命令和后面的文件名彻底分隔开; - 跳过符号与文件名之间的空格,提取出纯净的文件名给
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 号(stdout)进行重定向覆盖之前,先调用
int save_stdout = dup(1);。此时,系统会分配一个新 fd(比如 3),让 3 号也指向标准显示器。这样我们就把显示器的指针备份到了 3 号位置; - 重定向:调用
dup2(fd, 1);,放心地让 1 号指向目标文件,供内建命令使用; - 执行:调用内建命令的执行函数(如
Echo()); - 恢复:内建命令执行完毕后,调用
dup2(save_stdout, 1);。把刚才备份在 3 号的显示器指针重新覆盖回 1 号位置; - 善后:调用
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 的时候,文件才会被正式关闭。

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


所有评论(0)