news 2026/11/2 1:56:44

【Linux】文件描述符dup2重定向

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【Linux】文件描述符dup2重定向

一、文件描述符的概念

1.1 文件描述符

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

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

voidFunc3(){intfd1=open("log1.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);intfd2=open("log2.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);intfd3=open("log3.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);intfd4=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结构体里面有什么吧:先看看就行,不用管,后续的文章中再详细讲解。其中主要包含的内容比如:

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

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

union{structlist_headfu_list;// 内核文件链表指针★structrcu_headfu_rcuhead;// RCU 锁释放时的内存头}f_u;

structfile{/* * fu_list becomes invalid after file_free is called and queued via * fu_rcuhead for RCU freeing */union{structlist_headfu_list;// 内核文件链表指针★structrcu_headfu_rcuhead;// RCU 锁释放时的内存头}f_u;structdentry*f_dentry;// 【属性】指向目录项对象的指针(通过它能找到文件名、inode 等)structvfsmount*f_vfsmnt;// 【属性】指向文件系统挂载点的指针conststructfile_operations*f_op;// 【操作方法】极其重要!指向该文件操作函数指针集合(如 read, write 等底层实现)atomic_tf_count;// 【属性】文件对象的引用计数(有多少个 fd 指向它,为 0 时文件才真正关闭)unsignedintf_flags;// 【读写选项】打开文件时的标志(如 O_RDONLY, O_WRONLY, O_NONBLOCK)mode_tf_mode;// 【属性/int mode】文件的访问权限(如可读、可写)loff_tf_pos;// 【读写位置】当前文件的读写偏移量(即下一次 read/write 开始的字节位置)structfown_structf_owner;// 【属性】异步 I/O 时接收信号的进程属主信息unsignedintf_uid,f_gid;// 【属性】文件所有者的 User ID 和 Group IDstructfile_ra_statef_ra;// 【属性】文件预读(Read-Ahead)状态流,用来优化磁盘读取性能unsignedlongf_version;// 【属性】版本号,每次使用后会自动更迭void*f_security;// 【属性】安全模块(如 SELinux)使用的安全上下文指针/* needed for tty driver, and maybe others */void*private_data;// 【属性】私有数据指针(系统调用或驱动程序常用来挂载自定义的特殊结构体)#ifdefCONFIG_EPOLL/* Used by fs/eventpoll.c to link all the hooks to this file */structlist_headf_ep_links;// 【属性】被 epoll 监听时,挂载到 epoll 事件等待队列的链表节点spinlock_tf_ep_lock;// 【属性】保护 epoll 链表的自旋锁#endif/* #ifdef CONFIG_EPOLL */structaddress_space*f_mapping;// 【缓冲区 - TODO】指向页高速缓存(Page Cache)映射的指针!// 这就是你上一张图说到的"内核开辟的文件缓冲区"在内核里的核心代理人};

三、重定向的原理

3.1 手动模拟重定向

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

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

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

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

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

voidFunc4(){close(1);intfd=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。

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

intdup2(intoldfd,intnewfd);

关于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来重写一下上面的功能:

intfd=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 个宏状态,以及用于保存全局重定向状态和目标文件名的全局变量:

#defineNONE_REDIR0// 无重定向#defineINPUT_REDIR1// 输入重定向 <#defineOUTPUT_REDIR2// 输出重定向 >#defineAPPEND_REDIR3// 追加重定向 >>intredir=NONE_REDIR;// 记录当前重定向的类型char*filename=NULL;// 记录重定向的目标文件名

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

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

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

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

voidRedirCheck(char*cmd){redir=NONE_REDIR;intbegin=0;intend=strlen(cmd)-1;while(end>=0){if(cmd[end]=='<'){// 分割cmd[end]='\0';end++;// 取出空格while(isspace(cmd[end]))end++;// 首元素地址加 endfilename=cmd+end;redir=INPUT_REDIR;break;}elseif(cmd[end]=='>'){// 这里当 end 走到 0 的时候要注意越界问题if(end>0&&cmd[end-1]=='>'){// 分割cmd[end-1]='\0';end++;// 取出空格while(isspace(cmd[end]))end++;// 首元素地址加 endfilename=cmd+end;redir=APPEND_REDIR;break;}else{// 分割cmd[end]='\0';end++;// 取出空格while(isspace(cmd[end]))end++;// 首元素地址加 endfilename=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_tid=fork();if(id==0){if(redir==INPUT_REDIR){intfd=open(filename,O_RDONLY);dup2(fd,0);close(fd);}elseif(redir==OUTPUT_REDIR){intfd=open(filename,O_TRUNC|O_WRONLY|O_CREAT,0666);dup2(fd,1);close(fd);}elseif(redir==APPEND_REDIR){intfd=open(filename,O_APPEND|O_WRONLY|O_CREAT,0666);dup2(fd,1);close(fd);}inti=execvp(_argv[0],_argv);if(i==-1){perror("execvp");exit(1);}}else{intstatus=0;// 阻塞等待pid_tw_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>intdup(intoldfd);

返回值:成功时返回新分配的文件描述符(该描述符指向与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 代码实现

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

intbuildCommand(char**_argv,intargc){intsave_stdin=-1;intsave_stdout=-1;if(redir==INPUT_REDIR){save_stdin=dup(0);// 备份原标准输入intfd=open(filename,O_RDONLY);if(fd>=0){dup2(fd,0);// 重定向标准输入close(fd);}}elseif(redir==OUTPUT_REDIR){save_stdout=dup(1);// 备份原标准输出intfd=open(filename,O_WRONLY|O_CREAT|O_TRUNC,0666);if(fd>=0){dup2(fd,1);// 重定向标准输出close(fd);}}elseif(redir==APPEND_REDIR){save_stdout=dup(1);// 备份原标准输出intfd=open(filename,O_WRONLY|O_CREAT|O_APPEND,0666);if(fd>=0){dup2(fd,1);// 重定向标准输出close(fd);}}// echoif(strcmp(_argv[0],"echo")==0&&argc==2){if(strcmp(_argv[1],"$?")==0){printf("退出码:%d\n",lastcode);lastcode=0;}elseif(strcmp(_argv[1],"$")==0){char*val=_argv[1]+1;if(val)printf("%s\n",getenv(val));}else{printf("%s\n",_argv[1]);}return1;}// cdelseif(strcmp(_argv[0],"cd")==0&&argc==2){intflag=-1;if(strcmp(_argv[1],"-")==0){char*dir=getenv("OLDPWD");flag=chdir(dir);}elseif(strcmp(_argv[1],"~")==0){char*dir=getenv("HOME");flag=chdir(dir);}else{constchar*dir=_argv[1];flag=chdir(dir);}// 这里注意一下:我们 GetPwd() 是从环境变量表里面拿,// 而 chdir 没有改环境变量里的 PWD,所以我们要更新环境变量表if(flag==-1){printf("路径错误\n");}else{getcwd(env,sizeof(env));setenv("PWD",env,1);}return1;}// exportelseif(argc==2&&strcmp(_argv[0],"export")==0){char*eq=strchr(_argv[1],'=');if(eq!=NULL){*eq='\0';setenv(_argv[1],eq+1,1);}return1;}// 加个颜色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);// 释放备份}return0;}

5.3 文件与引用计数

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

1. 引用计数的增减规则

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

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 1:56:00

店员天天问“这个活动怎么参加“?腾讯云ADP+JOTO零售门店运营助手

JOTO 是腾讯云 ADP CSP 授权服务商&#xff0c;专注企业级 AI 场景落地实施。本文聚焦连锁零售企业总部和门店之间的信息断层&#xff1a;促销政策换得比翻书快、店员搞不懂活动规则、新人培训全靠老带老。下面拆解一套基于腾讯云 ADP 的门店运营知识助手&#xff0c;让几千家门…

作者头像 李华
网站建设 2026/11/2 1:55:20

专为DeepSeek Harness 打造的开源“破甲“插件

文章目录 一、先把话说明白 二、它到底解决了什么问题 它明确划出的使用边界 三、核心心智模型:五层,各管一件事 三个必须记住的推论 四、生命周期:装 → 应用 → 重开 什么时候需要再点一次「应用」 一个容易被忽略的红线 五、安装:三条路,别走错 5.1 三种方式怎么选 5.2…

作者头像 李华
网站建设 2026/11/2 1:54:57

第二天学c++

(color 颜色代码 比如背景灰 字体红 为color 84&#xff09;不同的进制换算常量和变量 关键字指的是 int include 之类的头词定义常量 #define 或者 const int 类型 名字 {初始值} eg:#define dversion " v2.2 "变量 -- 类型 名字 初始值 eg&#x…

作者头像 李华
网站建设 2026/11/2 1:54:52

复现基于 PyTorch 的中文孤立手语词识别项目

项目地址&#xff1a;GitHub - harveyfly/SignLanguageRecognition: 中文孤立手语词识别&#xff1b;Bi-LSTM&#xff1b;SLR&#xff1b;500-CSL GitHub 项目介绍 把一段手语视频的骨骼序列抽成固定长度的 24 维帧特征&#xff0c;用 2 层双向 LSTM 编码时序&#xff0c;取…

作者头像 李华
网站建设 2026/11/2 1:53:57

小白程序员必看:彻底搞懂RAG与Agent的区别,面试不再卡壳!

RAG与Agent并非绑定关系&#xff0c;多数Agent会内置RAG模块但非必需。RAG用于向大模型补充外部知识&#xff0c;解决模型知识局限&#xff1b;Agent则具备规划、调用工具、多步执行、反思能力&#xff0c;解决复杂任务执行。标准RAG属于固定流程的Workflow&#xff0c;Agent是…

作者头像 李华
网站建设 2026/11/2 1:53:57

C++程序员必看:从小白到掌握大模型推理引擎,收藏这篇高薪转型指南!

本文揭示了AI Infra岗位的核心并非仅会写CUDA Kernel&#xff0c;而是构建高性能、低延迟、高并发的推理服务。文章详细阐述了C在推理引擎中的关键作用&#xff0c;以及内存管理、高并发与异步调度、CPU性能优化等方面的核心技术点&#xff0c;并提供了从基础到实战的学习路线&…

作者头像 李华