2026/8/27 20:31:15

《从零入门Linux系统篇(二十九):文件篇·二——深入文件描述符:从文件描述符表到重定向,再到Shell实现》

《从零入门Linux系统篇(二十九):文件篇·二——深入文件描述符:从文件描述符表到重定向,再到Shell实现》 这一篇我们要把Linux文件I/O最后一块拼图摁进槽里。话题从一个看似不起眼的小整数开始——文件描述符fd。你每次open一个文件它都会塞给你一个数字3、4、5……这数字背后究竟牵着一根多长的线我们会一层层剥开fd的本质这个整型值怎么就跟标准输入、标准输出、标准错误死死绑在了一起它凭什么能代表一个文件内核里的三级指针链从task_struct出发穿过files_struct一路追到struct file文件描述符在内核里到底是怎么层层映射的“最小分配规则”为什么新打开的文件总是挑最小的空闲号这个特性恰恰是手动实现重定向的钥匙。dup与dup2两个看起来不起眼的系统调用怎么靠“指针覆盖”完成文件描述符的乾坤大挪移struct file的引用计数为什么fork之后父子进程能共享文件为什么有时候关了文件数据却还活着最后我们会把这些知识统统塞进我们手写的迷你Shell里给它来一次“超级升级”支持和重定向。这一波是真正的学以致用。好了不废话我们直接开凿。目录一、文件描述符——Linux I/O的入口1.1 文件描述符的基本认识1.2 从文件描述符重新理解文件I/O二、文件描述符表——进程如何管理打开的文件2.1 文件描述符表的基本结构2.2 从文件描述符表重新理解I/O三、重定向的本质——让文件描述符“指向”不同的文件3.1 手动模拟文件描述符重定向3.1.1 重定向之后发生了什么3.1.2 重定向的底层原理3.2 dup2——系统提供的重定向接口3.2.1 dup2函数介绍3.2.2 dup2的实际应用四、升级自定义Shell——实现命令重定向4.1 重定向功能的整体设计思路4.2 命令行解析——如何识别重定向符号4.3 进程创建、程序替换与重定向的结合4.4 自定义Shell重定向功能完整实现五、进一步理解——重定向背后的文件机制5.1 自定义Shell如何实现内建命令重定向5.1.1 dup系统调用5.2 从代码实现理解文件描述符的复制5.3 文件对象与引用计数一、文件描述符——Linux I/O的入口1.1 文件描述符的基本认识上篇文章我们刻意留了一个问题没展开open系统调用成功之后返回的那个数字到底是什么答案就是文件描述符英文叫new file descriptor简称fd。光听名字还是虚的直接上代码把它揪出来看看#include stdio.h #include stdlib.h #include unistd.h #include sys/types.h #include sys/stat.h #include fcntl.h int main() { umask(0); 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) exit(1); 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); }运行结果很直白文件描述符就是一个普普通通的整型值。fd1是3fd2是4fd3是5fd4是6依次递增。但细心的你肯定会犯嘀咕凭什么从3开始0和1和2哪去了别急这就要翻回上篇文章埋下的伏笔了。我们说过程序一启动运行时环境就自动帮它打开了三个标准流标准输入stdin对应文件描述符0标准输出stdout对应文件描述符1标准错误stderr对应文件描述符2所以0、1、2这三个号从程序出生那一刻起就被这三尊“元老”占了。你后面再open新文件只能从 3 开始往后排。这就是为什么我们打印出来的第一个fd是3而不是0不是系统故意耍你是前三把交椅早就有人坐了。文件描述符编号体系从0开始但0到2永远属于默认标准流。1.2 从文件描述符重新理解文件I/O回到我们熟悉的C语言文件操作。上篇文章我们说过fopen底层封装了open 系统调用。既然open返回的是一个整数fd那fopen返回的那个FILE*结构体里十有八九也偷偷揣着这个文件描述符。我们直接去头文件里找证据view /usr/include/bits/types/struct_FILE.h打开这个头文件你会发现struct _IO_FILE里有个成员名字正是_fileno这就是我们要找的文件描述符。FILE结构体包装得再漂亮内核真正认的其实就藏在这个_fileno字段里。所以结论很硬核在操作系统眼里它只认文件描述符。不管你的语言顶层设计得多么花哨C 的FILE*也好C的fstream也罢甚至是Python的file object底层统统封了一个文件描述符。语言层可以千变万化但最底下的那个数字永远是fd。以下内容全部基于文件描述符向下讨论。刚才说文件描述符是一个从0开始计数的整数。一听到“从0开始的整数”你脑子里是不是瞬间蹦出了“数组下标”四个字没错就是这么回事。文件描述符本质上就是数组下标。内核里藏着一张表文件描述符就是这张表的下标索引。那么这张表长什么样它又是怎么从进程一路牵到真正的文件上的别急我们这就一层层往下扒。二、文件描述符表——进程如何管理打开的文件2.1 文件描述符表的基本结构操作系统在创建进程时除了给进程安排task_struct、页表、虚拟地址空间这一堆家当还会顺手发一张“文件描述符表”。这张表在内核里的正式名字叫files_struct。它肚子里有个核心成员一个结构体指针数组。数组的每个槽位都装着指向某个被打开文件对象的指针而这个数组的下标就是我们前面看到的那个整数fd。所以文件描述符和数组下标真就是同一个东西的两种叫法。你拿到的fd 3就是这张表里下标为3的那个位置。翻一翻内核源码task_struct里确实藏着这个字段名字就叫files类型是struct files_struct *。它跟mm、pid这些字段并列是进程控制块里管文件的那一条线。接下来我们就顺着这条线看看从task_struct到files_struct再到struct file整条指针链到底是怎么串起来的。文件描述符数组在源代码中那么画一个简单的草图就是这个结构这个文件描述符数组里存的结构体是什么是struct file。我们先把它的源码找出来待会儿再回头看里面装了什么。当我们打开一个文件操作系统会为它创建一个文件描述结构体struct file。这个结构体里存放着文件的各种属性同时内部有一个指针指向一块文件缓冲区缓冲区里装的是这个文件的内容。把上面两张图拼在一起结论就出来了文件描述符本质上是文件描述符表struct files_struct中文件描述符数组的下标。这些下标指向一个个文件描述结构体struct file。于是拿到文件描述符就能顺藤摸到文件的描述结构体进而拿到某个被打开文件的全部信息。2.2 从文件描述符表重新理解I/O对文件内容做任何操作前提都是先把文件内容从磁盘搬到内核里对应的文件缓冲区。没有这步拷贝后面的读写全是空谈。read函数的本质是从内核到用户空间的拷贝函数。写操作也是一个道理应用层先把数据塞进缓冲区操作系统再定期把缓冲区里的内容刷回磁盘。数据不是一写就落盘的中间要经过缓冲区这道“中转站”。Tipsstruct 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长这样struct file { 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 指向它归零时文件才真正关闭 unsigned int f_flags; // 【读写选项】打开文件时的标志位如 O_RDONLY、O_WRONLY、O_NONBLOCK mode_t f_mode; // 【属性】访问权限可读、可写等 loff_t f_pos; // 【读写位置】当前读写偏移量下一次 read/write 从这里开始 struct fown_struct f_owner; // 【属性】异步 I/O 时接收信号的进程属主信息 unsigned int f_uid, f_gid; // 【属性】文件所有者的用户 ID 和组 ID struct file_ra_state f_ra; // 【属性】预读状态用来优化磁盘读取性能 unsigned long f_version; // 【属性】版本号每次使用后自动更迭 void *f_security; // 【属性】安全模块如 SELinux的安全上下文指针 void *private_data; // 【属性】私有数据指针系统调用或驱动程序常用来挂自定义结构 #ifdef CONFIG_EPOLL struct list_head f_ep_links; // 【属性】被 epoll 监听时挂到事件等待队列的链表节点 spinlock_t f_ep_lock; // 【属性】保护 epoll 链表的自旋锁 #endif struct address_space *f_mapping; // 【缓冲区 - TODO】指向页高速缓存Page Cache的指针 // 这就是上一张图里那个“内核文件缓冲区”的核心代理人 };重点留意两个字段f_op这个太关键了。它指向一套函数指针集合里面装着这个文件专属的read、write等操作的真正实现。同一个read系统调用到不同文件上执行的底层代码可能完全不同普通文件走普通文件的读法socket走socket的读法设备文件又有一套。这就是多态在内核里的原始形态。f_mapping这就是上一张图里说的“内核文件缓冲区”的真身。它指向页高速缓存文件内容从磁盘读进来后就驻扎在这片区域里。所有对文件内容的读写都要经过它。struct file这个结构体就是内核眼里“一个已打开文件”的完整画像。属性、位置、选项、操作方法、缓冲区全在这一张结构体里。文件描述符指向的正是它。三、重定向的本质——让文件描述符“指向”不同的文件3.1 手动模拟文件描述符重定向3.1.1 重定向之后发生了什么文件描述符的底层逻辑数组下标我们已经吃透了。现在再补上操作系统分配fd的一条铁律最小的、没有被使用的那个整数会成为新的文件描述符。换句话说fd的分配永远从最小的空闲号开始。0被占了看1。1也占着看2。谁先空出来谁就先给新文件用。既然程序启动时0、1、2已经被标准输入、标准输出、标准错误占了那如果我们故意把1关掉再打开一个新文件会发生什么根据“最小分配原则”系统一看0有人1空着得就把1给这个新文件吧。光想不够直接看代码#include stdio.h #include stdlib.h #include unistd.h #include sys/types.h #include sys/stat.h #include fcntl.h int main() { close(1); // 故意关闭标准输出 // 打开一个新文件。由于 1 被释放根据最小分配原则fd 必然是 1 int fd open(log.txt, O_CREAT | O_WRONLY | O_TRUNC, 0666); // 往标准输出打印看看会发生什么 printf(fd: %d\n, fd); close(fd); return 0; }按常理printf是往标准输出也就是屏幕上打印的。可当你编译运行后屏幕上干干净净啥都没有。再敲一下ll看看当前目录多出来一个log.txt。打开这个文件一瞧本该躺在屏幕上的fd: 1居然神不知鬼不觉地钻进了这个文件里。这就是重定向的雏形。你没有调用任何高级接口只是关掉了fd 1再让新文件占上这个坑位。从此所有冲着“标准输出”去的写操作都会顺着fd 1找到那个新文件而不是屏幕。文件描述符这个“数组下标”一旦被替换上层那些printf根本感觉不到它们还是傻傻地往1号位写只是1号位背后的指向已经悄悄变了。真正的重定向内核里干的也就是这件“偷梁换柱”的事。3.1.2 重定向的底层原理为什么会这样答案就藏在“上层”和“底层”的错位里。对应用层来说printf只认stdout而stdout结构体内部封装的那个fileno就是1。所以对应用层而言“把数据写到1号下标对应的文件里”这件事从头到尾没有变过它甚至连怀疑都没有怀疑过。真正动手脚的是内核层。我们用close(1)一剪子断开了1号下标和标准显示器之间的连接然后又让这个空出来的1号位重新指向了log.txt的struct file。于是局面就变成了上层还在原地踏步底层已经偷梁换柱。上层拿着的还是那个文件描述符1可底层1号位背后的地址已经换成了另一个文件的struct file。printf无脑往1写数据却顺着新地址流进了log.txt。这就是重定向的底层原理说白了就四个字上层不变底层变。文件描述符这个“门牌号”没摘门后住的却已经从显示器换成了普通文件。以后我们见到的、追到内核层干的全是这种“换门牌指向”的活。3.2 dup2——系统提供的重定向接口3.2.1 dup2函数介绍先close再open确实能实现重定向但说实话这套操作又笨又啰嗦还容易在两步之间出岔子。Linux早就看不下去了于是派来一个干练的专用接口dup2。直接看它的手册int dup2(int oldfd, int newfd);返回值成功时返回新的文件描述符失败时返回-1并设置errno。dup2的两个参数oldfd和newfd很多人第一次看文档时都会搞混。官方文档里有一句极其关键的解释dup2() makes newfd be the copy of oldfd, closing newfd first if necessary.翻译一下就是让newfd成为oldfd的一份拷贝。如果newfd本来已经打开了就先把newfd关掉再说。注意这里的“拷贝”可不是拷贝那个整数数字。它拷贝的是文件描述符表数组项里的内容也就是那个指向struct file的指针。简而言之dup2把oldfd指向的文件对象指针原样覆盖到newfd的位置上。最终结果就是newfd和oldfd双双指向同一个文件也就是原来oldfd所指向的那个文件。两个门牌号背后通的是同一间屋子。有了这个接口以后想让标准输出重定向到某个文件就再也不用先close(1)再open了。一行 dup2(fd, 1)干净利落。3.2.2 dup2的实际应用理解了dup2的参数顺序输出重定向就变得极其简单。我们的目标是让原本该滚到屏幕上的内容改道流进某个文件里。按照dup2(oldfd, newfd)的规则newfd要成为oldfd的拷贝。我们想动的是1号标准输出所以1是newfd它要拷贝的对象是新打开的那个文件fd。所以代码必须写成dup2(fd, 1); // 让 1 号位指向 fd 所指向的文件方向千万别搞反。dup2(1, fd)和dup2(fd, 1)干的事完全相反前者把fd覆盖成标准输出后者才是把标准输出重定向到文件。这里栽跟头的人不在少数。下面用dup2重写刚才那个例子#include stdio.h #include stdlib.h #include unistd.h #include sys/types.h #include sys/stat.h #include fcntl.h int main() { // 1. 正常打开文件拿到一个普通的 fd比如 3 int fd open(myfile.txt, O_CREAT | O_WRONLY | O_TRUNC, 0666); if (fd 0) { perror(open); exit(1); } // 2. 用 dup2 做输出重定向 // 从此凡是往 1 号 fd 写的内容都会落进 myfile.txt // 而不是再打印到标准输出上。 dup2(fd, 1); // 3. 测试输出 printf(凡是往1号文件描述符写的内容都写到了myfile当中而不再写到标准输出\n); printf(fd: %d\n, fd); // 4. 关闭原来的 fd close(fd); return 0; }运行之后屏幕依旧一片安静。打开myfile.txt一看那两行printf的输出安安稳稳地躺在里面连“fd: 3”也一起被写了进去。这里有两个细节值得单独说说dup2不会主动关闭oldfd。重定向完成后fd和1同时指向同一个文件。就算你把fd关了1号位还稳稳指着那个文件数据照写不误。所以后面那个close(fd)并不会切断重定向它只是清理掉一个多余的入口。注意缓冲区的刷新时机。printf的输出可能滞留在缓冲区里不一定会立刻写进文件。程序正常退出时会自动冲刷所以这个例子没问题但如果你在printf之后立即手动close(1)而没给刷新机会就可能丢掉最后一点数据。必要时可以用fflush(stdout)提前冲刷保证数据落地。用dup2做重定向就是一层窗户纸。捅破了后面那些、的原理全都不再神秘。四、升级自定义Shell——实现命令重定向这篇文章我们终于把Shell第四阶段的坑填上了。前面在进程篇里我们手写的迷你Shell只能跑跑普通命令、处理几个内建命令遇到和这种重定向符号就抓瞎。现在我们要给它装上最后一块重要拼图重定向功能。4.1 重定向功能的整体设计思路想让Shell支持重定向思路其实很清晰拆成两步走第一步宏观检测解析用户输入的命令行字符串时先扫一眼里面有没有、、这些重定向符号。如果有就得把符号本身、以及它后面的目标文件名从原命令行里“拆”出来。不然这些字符串混在参数里后面execvp会把它们当普通参数处理那就全乱套了。第二步底层替换在fork()出子进程之后、execvp()执行程序替换之前根据第一步识别的重定向类型调open()打开目标文件再用dup2()把子进程的标准输入或标准输出改道过去。这个时间点必须掐准只有在子进程里动手才不会污染父进程自己的标准流。思路清楚了先写点准备工作。代码顶层定义四个宏分别代表四种重定向状态#define NONE_REDIR 0 // 无重定向 #define INPUT_REDIR 1 // 输入重定向 #define OUTPUT_REDIR 2 // 输出重定向 #define APPEND_REDIR 3 // 追加重定向 int redir NONE_REDIR; // 记录当前重定向类型 string filename; // 记录重定向的目标文件名redir是全局状态标志解析阶段把它填上filename存目标文件的名字后面打开文件要用。解析和执行两阶段就靠这两个全局变量传递信息。接下来我们分头去实现这两步。4.2 命令行解析——如何识别重定向符号用户的输入可能长这样ls -a -l log.txt。重定向符号和它后面的文件名一般总待在命令行的最右端。所以我们的扫描方向也反过来从字符串末尾开始一路往前找。一旦扫到、 或 就做两件事把符号所在位置直接抹成\0将前面的有效命令和后面的文件名一刀两断跳过符号与文件名之间可能存在的空格把纯净的文件名提取出来交给全局变量filename。核心实现如下void RedirCheck(char* cmd) { redir NONE_REDIR; filename.clear(); int end strlen(cmd) - 1; // 从后往前扫描 while (end 0) { if (cmd[end] ) { cmd[end] \0; // 截断前半段保留为命令 end; while (isspace(cmd[end])) end; // 跳过空格 redir INPUT_REDIR; filename cmd end; // 拿到文件名 break; } else if (cmd[end] ) { if (end 0 cmd[end - 1] ) // 连续两个 是追加 { cmd[end - 1] \0; end; while (isspace(cmd[end])) end; redir APPEND_REDIR; filename cmd end; } else // 单个 是覆盖 { cmd[end] \0; end; while (isspace(cmd[end])) end; redir OUTPUT_REDIR; filename cmd end; } break; } end--; } }这里有个无数人踩过的坑值得单独拎出来说。很多人在处理截断时会先写cmd[end] \0然后马上接while (isspace(cmd[end])) end;。看着挺顺其实已经翻车了因为此时cmd[end]已经被你亲手改成了\0而isspace(\0)永远返回假。循环条件一上来就是假循环体压根不执行end不会往后走文件名里就会夹带空格甚至指针指向错位后续open直接失败。正确的顺序是先置空再end把指针挪到符号后方然后才去跳空格。步子不能乱顺序不能反。这也是字符串解析里一个非常典型的“隐形陷阱”。4.3 进程创建、程序替换与重定向的结合文本解析完了状态也存好了。接下来就是最核心的一步把重定向和程序替换拧在一起写进Execute()里。Tips为什么必须在子进程里做dup2这是升级自定义Shell时最容易犯的原则性错误绝对不能在父进程里执行重定向。道理其实很直白。如果你在父进程也就是Shell自己里调了dup2(fd, 1)那等于是把Shell自己的标准输出给改了道。从此以后你的Shell再打印提示符[userhost...]$它不会出现在屏幕上而是全部哗啦啦流进了那个重定向文件里。到那时终端上只剩一个沉默的光标连自己该敲命令都看不见了。所以重定向这种“脏活”必须关起门来在子进程里干。升级后的执行逻辑void Execute() { // 无论普通命令还是重定向命令统一先 fork pid_t id fork(); if (id 0) { // 子进程按需完成重定向再做程序替换 if (redir INPUT_REDIR) { // 输入重定向只读打开 int fd open(filename.c_str(), O_RDONLY); if (fd 0) { perror(open); exit(1); } dup2(fd, 0); // 覆盖标准输入 close(fd); } else if (redir OUTPUT_REDIR) { // 输出重定向只写、无则创建、覆盖式截断 int fd open(filename.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0666); if (fd 0) { perror(open); exit(1); } dup2(fd, 1); // 覆盖标准输出 close(fd); } else if (redir APPEND_REDIR) { // 追加重定向只写、无则创建、追加式写入 int fd open(filename.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666); if (fd 0) { perror(open); exit(1); } dup2(fd, 1); // 覆盖标准输出 close(fd); } execvp(g_argv[0], g_argv); perror(execvp); // 能走到这里说明 execvp 失败了 exit(1); } // 父进程只负责等待、收尸、拿退出码 int status 0; waitpid(id, status, 0); if (WIFEXITED(status)) { exitcode WEXITSTATUS(status); } }几个细节再点一下重定向三兄弟的打开标志位跟它们的语义一一对应。输入用O_RDONLY输出覆盖用O_TRUNC追加用O_APPEND。写错任何一个行为都会跑偏。dup2之后原来的fd就没用了顺手close掉。这样能避免多余的描述符占着位保证干净。execvp后面那两行是经典防御。只要执行到了perror就说明替换失败。子进程必须自己exit不能再往下走否则会跟父进程抢戏。到这里我们的迷你Shell 就真正升级完毕了它能跑普通命令能处理内建命令能维护环境变量现在还能支持、、 三种重定向。一个从零写出来的、五脏俱全的小Shell已经站在你眼前。4.4 自定义Shell重定向功能完整实现到这里我们终于把自定义Shell的最终形态拼了出来。它不再只是跑跑命令、切切目录而是能解析 、、能维护环境变量能处理内建命令甚至在子进程里安全地完成重定向。下面是完整代码。#include iostream #include cstdio #include cstdlib #include cstring #include unistd.h #include sys/types.h #include sys/wait.h #include string #include sys/stat.h #include fcntl.h #include cctype using namespace std; const int COMMAND_SIZE 128; const int LINE_SIZE 1024; #define PROMPT [%s%s %s]%s const int ENV_SIZE 100; int g_envs 0; char* g_env[ENV_SIZE] {0}; #define NONE_REDIR 0 #define INPUT_REDIR 1 #define OUTPUT_REDIR 2 #define APPEND_REDIR 3 int redir NONE_REDIR; string filename; int g_argc 0; char* g_argv[LINE_SIZE] {0}; int exitcode 0; const char* GetHostName() { const char* host getenv(HOSTNAME); return host NULL ? None : host; } const char* GetUserName() { const char* user getenv(USER); return user NULL ? None : user; } string GetDirectory(char* _cwd) { if (_cwd NULL) return ; string cwd _cwd; int pos cwd.rfind(/); if (pos string::npos) return ; return cwd.substr(pos 1); } string GetCwd() { char* cwd getenv(PWD); return GetDirectory(cwd); } const char* GetSign() { if (strcmp(GetUserName(), root) 0) return #; else return $; } const char* GetHome() { const char* home getenv(HOME); return home NULL ? None : home; } void MakeCommandPrompt(char* det, int size) { snprintf(det, size, PROMPT, GetUserName(), GetHostName(), GetCwd(), GetSign()); } void PrintCommandPrompt() { char Prompt[COMMAND_SIZE] {0}; MakeCommandPrompt(Prompt, sizeof(Prompt)); printf(%s, Prompt); fflush(stdout); } bool GetCommandLine(char* cmd, int size) { char* buf fgets(cmd, size, stdin); if (buf NULL) return false; cmd[strlen(cmd) - 1] 0; if (strlen(cmd) 0) return false; return true; } void Initenv() { extern char** environ; for (int i 0; environ[i]; i) { g_env[i] (char*)malloc(strlen(environ[i]) 1); if (g_env[i] NULL) { string enverror(环境变量初始化异常); throw enverror; } strcpy(g_env[i], environ[i]); g_envs; } g_env[g_envs] NULL; for (int i 0; g_env[i]; i) putenv(g_env[i]); } void RedirCheck(char* cmd) { redir NONE_REDIR; filename.clear(); int start 0; int end strlen(cmd) - 1; while (end start) { if (cmd[end] ) { cmd[end] 0; end; // 先向后移动一位再跳过空格 while (isspace(cmd[end])) end; redir INPUT_REDIR; filename cmd end; break; } else if (cmd[end] ) { if (cmd[end - 1] ) // 检测到 { cmd[end - 1] 0; cmd[end] 0; end; while (isspace(cmd[end])) end; redir APPEND_REDIR; filename cmd end; } else // 单个 { cmd[end] 0; end; while (isspace(cmd[end])) end; redir OUTPUT_REDIR; filename cmd end; } break; } else end--; } } bool AnalyseCommandLine(char* cmd) { #define EXC g_argc 0; g_argv[g_argc] strtok(cmd, EXC); while ((bool)(g_argv[g_argc] strtok(NULL, EXC))); g_argv[g_argc] NULL; g_argc--; return g_argc ! 0; } void Cd() { if (g_argc 1) chdir(GetHome()); else if (g_argc 2) { if (strcmp(g_argv[1], ~) 0) chdir(GetHome()); else if (strcmp(g_argv[1], -) 0) { const char* oldpwd getenv(OLDPWD); if (oldpwd) chdir(oldpwd); } else chdir(g_argv[1]); } else { string cderror(cd命令执行错误); throw cderror; } // 关键同步更新 PWD 环境变量防止提示符与实际路径脱节 char cwd_buf[LINE_SIZE]; if (getcwd(cwd_buf, sizeof(cwd_buf))) setenv(PWD, cwd_buf, 1); } void Echo() { if (g_argv[1] NULL) cout endl; else if (strcmp(g_argv[1], $?) 0) cout exitcode endl; else if (g_argv[1][0] $) { const char* tmp getenv(g_argv[1] 1); if (tmp) cout tmp endl; else cout endl; } else printf(%s\n, g_argv[1]); } bool BuildinCommandCheck() { if (strcmp(g_argv[0], cd) 0) { Cd(); return true; } else if (strcmp(g_argv[0], echo) 0) { Echo(); return true; } return false; } void Execute() { pid_t id fork(); if (id 0) { // 子进程内完成重定向 if (redir INPUT_REDIR) { int fd open(filename.c_str(), O_RDONLY); dup2(fd, 0); close(fd); } else if (redir OUTPUT_REDIR) { int fd open(filename.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0666); dup2(fd, 1); close(fd); } else if (redir APPEND_REDIR) { int fd open(filename.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666); dup2(fd, 1); close(fd); } execvp(g_argv[0], g_argv); exit(1); // execvp 失败才走到这里 } int status 0; waitpid(id, status, 0); if (WIFEXITED(status)) exitcode WEXITSTATUS(status); } int main() { try { Initenv(); } catch (string error) { cout error endl; } try { while (true) { PrintCommandPrompt(); // 1. 打印提示符 char commandline[LINE_SIZE] {0}; if (!GetCommandLine(commandline, sizeof(commandline))) // 2. 获取输入 continue; RedirCheck(commandline); // 3. 检测重定向 if (!AnalyseCommandLine(commandline)) // 4. 解析命令 continue; if (BuildinCommandCheck()) // 5. 内建命令在父进程执行 continue; Execute(); // 6. 外部命令交给子进程 } } catch (string error) { cout error endl; } catch (...) { cout 未知异常 endl; } return 0; }五、进一步理解——重定向背后的文件机制5.1 自定义Shell如何实现内建命令重定向完成Shell的重定向升级后你可能会发现一个隐藏的致命问题如果用户输入的是内建命令重定向该怎么处理内建命令是在父进程里执行的如果我们直接对标准输出做dup2那Shell自己的输出通道就改道了提示符都跑进文件里。所以内建命令重定向的核心关键不在于“改过去”而在于改完之后怎么把指针恢复如初。为了做到这一点我们得再请出一个系统调用dup。5.1.1 dup系统调用跟dup2那种“强制覆盖指定下标”的霸道不同dup温和得多它会复制一份指针放到当前最小的空闲位置上。#include unistd.h int dup(int oldfd);返回值成功时返回新分配的文件描述符这个新fd和oldfd指向同一个文件对象失败返回-1。有了dup恢复指针就变成了一套流程分明的四步走备份在对1号标准输出做重定向之前先调int save_stdout dup(1);。此时系统会分配一个新的fd比如3让3号也指向标准显示器。也就是说我们把显示器的指针备份到了3号位。重定向接着调dup2(fd, 1);放心地把1号指向目标文件。此时标准输出的指针已经被替换内建命令的打印会流进文件里。执行调用内建命令的执行函数比如Echo()让它把内容写进那个文件。恢复内建命令执行完毕调dup2(save_stdout, 1);把刚才备份在3号的显示器指针重新覆盖回 1 号。标准输出归位Shell 提示符照常显示。善后最后close(save_stdout);和close(fd);把临时占用的描述符清理干净不留垃圾。5.2 从代码实现理解文件描述符的复制有了理论打底直接看改造后的内建命令处理逻辑。核心思路就一句话先备份后重定向执行完内建命令后再把手动过的指针一个不落地恢复回来。bool BuildinCommandCheck() { // 如果不是内建命令直接返回 false交给外部命令流程处理 if (strcmp(g_argv[0], cd) ! 0 strcmp(g_argv[0], echo) ! 0) { return false; } int save_stdin -1; int save_stdout -1; // 输入重定向备份标准输入再把文件接到 0 号位 if (redir INPUT_REDIR) { save_stdin dup(0); // 备份原标准输入 int fd open(filename.c_str(), O_RDONLY); if (fd 0) { dup2(fd, 0); // 0 号位改指文件 close(fd); } } // 输出重定向备份标准输出再把文件接到 1 号位 else if (redir OUTPUT_REDIR) { save_stdout dup(1); // 备份原标准输出 int fd open(filename.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0666); if (fd 0) { dup2(fd, 1); // 1 号位改指文件 close(fd); } } // 追加重定向逻辑同输出重定向只是打开方式换成追加 else if (redir APPEND_REDIR) { save_stdout dup(1); int fd open(filename.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666); if (fd 0) { dup2(fd, 1); close(fd); } } // 执行真正的内建命令此时打印内容已经流进文件里 if (strcmp(g_argv[0], cd) 0) Cd(); else if (strcmp(g_argv[0], echo) 0) Echo(); // 关键恢复现场 // 内建命令跑完了必须把标准输入/输出还给 Shell 自己 // 否则提示符就写进文件里终端上再也看不到任何反馈。 if (save_stdin ! -1) { dup2(save_stdin, 0); // 恢复标准输入 close(save_stdin); // 释放备份描述符 } if (save_stdout ! -1) { dup2(save_stdout, 1); // 恢复标准输出 close(save_stdout); // 释放备份描述符 } return true; }这一段补上之后内建命令和外部命令在重定向这件事上就“平权”了外部命令靠子进程隔离随便改fd都伤不到父进程内建命令在父进程里执行就用dup备份、dup2恢复把现场保护得滴水不漏。5.3 文件对象与引用计数我们前面聊过一个文件可以被多个进程同时打开。这带来一个很实际的问题当某个进程关闭这个文件时操作系统凭什么判断这个文件到底该不该从内核里彻底释放答案就藏在struct file的一个成员里f_count也就是引用计数。引用计数的增减规则增加每当有新的文件描述符指向这个文件时引用计数就加一。哪些情况会触发比如open打开文件、fork后子进程继承父进程的文件描述符表、或者调用dup/dup2复制指针——只要多了一个fd指向它计数就往上蹦一格。减少每当用户层调用close(fd)或者进程退出导致整个文件描述符表被销毁时操作系统并不会马上把文件干掉而是先让这个引用计数减一。关闭只有当引用计数一路降到0确认没有任何fd再指向这个文件了内核才真正把它关闭、释放资源。这套机制就保证了“你关了不代表别人也关了”。同一个文件可能被父子进程、被多个fd同时引着。只要还有一个人在用文件就老老实实待着等最后一个引用消失它才寿终正寝。有人可能会问struct file_struct里怎么也有一个引用计数那个现在没法细讲得等线程章节才好展开。你现在只需要记住一句话那个引用计数决定的是文件描述符表本身何时真正被销毁。两个引用计数一个管文件对象一个管描述符表各司其职。如果这篇文章对你有帮助欢迎点赞、收藏、关注三连支持。你的每一个正反馈都是我继续硬核输出的最大动力。磁盘深处我们下篇见。