一、文件描述符的概念
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 支持重定向,我们需要把整个过程拆解为两步:
- 宏观检测:在解析用户输入的命令行字符串时,检查其中是否包含
<、>、>>等重定向符号。如果存在,必须将这些符号以及后面的文件名从原命令行中"拆离"出来; - 底层替换:在
fork()出子进程之后、程序替换execvp()之前,根据重定向的类型,调用open()系统调用打开对应的文件,并使用dup2()改变子进程的标准输入或标准输出。
首先,我们在代码顶层定义代表重定向类型的 4 个宏状态,以及用于保存全局重定向状态和目标文件名的全局变量:
#defineNONE_REDIR0// 无重定向#defineINPUT_REDIR1// 输入重定向 <#defineOUTPUT_REDIR2// 输出重定向 >#defineAPPEND_REDIR3// 追加重定向 >>intredir=NONE_REDIR;// 记录当前重定向的类型char*filename=NULL;// 记录重定向的目标文件名由于用户的输入可能长这样:ls -a -l > log.txt。我们需要从字符串的末尾开始向前扫描。因为重定向符号及其文件名一般都在命令行的最右侧。
一旦扫描到符号,我们需要做两件事:
- 将符号位置置为
\0,从而把前面的有效命令和后面的文件名彻底分隔开; - 跳过符号与文件名之间的空格,提取出纯净的文件名给
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 号(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 代码实现
修改后的内建命令处理逻辑如下:
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 的时候,文件才会被正式关闭。