这一篇,我们要把Linux文件I/O最后一块拼图摁进槽里。话题从一个看似不起眼的小整数开始------文件描述符fd。你每次open一个文件,它都会塞给你一个数字,3、4、5......这数字背后,究竟牵着一根多长的线?
我们会一层层剥开:
- **fd的本质:**这个整型值,怎么就跟标准输入、标准输出、标准错误死死绑在了一起?它凭什么能代表一个文件?
- **内核里的三级指针链:**从task_struct出发,穿过files_struct,一路追到struct file,文件描述符在内核里,到底是怎么层层映射的?
- **"最小分配规则":**为什么新打开的文件,总是挑最小的空闲号?这个特性,恰恰是手动实现重定向的钥匙。
- **dup与dup2:**两个看起来不起眼的系统调用,怎么靠"指针覆盖"完成文件描述符的乾坤大挪移?
- **struct file的引用计数:**为什么fork之后父子进程能共享文件?为什么有时候关了文件,数据却还活着?
最后,我们会把这些知识统统塞进我们手写的迷你Shell里,给它来一次"超级升级",支持>和>>重定向。这一波,是真正的学以致用。好了,不废话,我们直接开凿。
目录
[一、文件描述符------Linux I/O的入口](#一、文件描述符——Linux I/O的入口)
[1.1 文件描述符的基本认识](#1.1 文件描述符的基本认识)
[1.2 从文件描述符重新理解文件I/O](#1.2 从文件描述符重新理解文件I/O)
[2.1 文件描述符表的基本结构](#2.1 文件描述符表的基本结构)
[2.2 从文件描述符表重新理解I/O](#2.2 从文件描述符表重新理解I/O)
[3.1 手动模拟文件描述符重定向](#3.1 手动模拟文件描述符重定向)
[3.1.1 重定向之后发生了什么?](#3.1.1 重定向之后发生了什么?)
[3.1.2 重定向的底层原理](#3.1.2 重定向的底层原理)
[3.2 dup2------系统提供的重定向接口](#3.2 dup2——系统提供的重定向接口)
[3.2.1 dup2函数介绍](#3.2.1 dup2函数介绍)
[3.2.2 dup2的实际应用](#3.2.2 dup2的实际应用)
[4.1 重定向功能的整体设计思路](#4.1 重定向功能的整体设计思路)
[4.2 命令行解析------如何识别重定向符号](#4.2 命令行解析——如何识别重定向符号)
[4.3 进程创建、程序替换与重定向的结合](#4.3 进程创建、程序替换与重定向的结合)
[4.4 自定义Shell重定向功能完整实现](#4.4 自定义Shell重定向功能完整实现)
[5.1 自定义Shell如何实现内建命令重定向](#5.1 自定义Shell如何实现内建命令重定向)
[5.1.1 dup系统调用](#5.1.1 dup系统调用)
[5.2 从代码实现理解文件描述符的复制](#5.2 从代码实现理解文件描述符的复制)
[5.3 文件对象与引用计数](#5.3 文件对象与引用计数)
一、文件描述符------Linux I/O的入口
1.1 文件描述符的基本认识
上篇文章我们刻意留了一个问题没展开:open系统调用成功之后,返回的那个数字到底是什么?答案就是文件描述符,英文叫new file descriptor,简称fd。

光听名字还是虚的,直接上代码把它揪出来看看:
cpp
#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是3,fd2是4,fd3是5,fd4是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*结构体里,十有八九也偷偷揣着这个文件描述符。我们直接去头文件里找证据:
bash
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函数的本质是:从内核到用户空间的拷贝函数。

写操作也是一个道理:应用层先把数据塞进缓冲区,操作系统再定期把缓冲区里的内容刷回磁盘。数据不是一写就落盘的,中间要经过缓冲区这道"中转站"。
Tips:struct file里面有什么
来看看这个struct file结构体里到底装了些什么。先混个脸熟,不用深究每个字段,后面的文章会逐个展开。
cpp
struct file
{
// 属性集合
// int mode
// 读写位置
// 读写选项
// 缓冲区 --- TODO
// 操作方法
// struct list_head list;
};
文件描述结构体里也藏着一个union,用来搭建file与file之间的连接,方便内核统一管理。又是熟悉的"先描述,再组织",无处不在。
cpp
union {
struct list_head fu_list; // 内核文件链表指针★
struct rcu_head fu_rcuhead; // RCU 锁释放时的内存头
} f_u;

完整版的struct file长这样:
cpp
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给这个新文件吧。光想不够,直接看代码:
cpp
#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。直接看它的手册:
cpp
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。所以代码必须写成:
cpp
dup2(fd, 1); // 让 1 号位指向 fd 所指向的文件
方向千万别搞反。dup2(1, fd)和dup2(fd, 1)干的事完全相反,前者把fd覆盖成标准输出,后者才是把标准输出重定向到文件。这里栽跟头的人不在少数。
下面用dup2重写刚才那个例子:
cpp
#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()把子进程的标准输入或标准输出改道过去。这个时间点必须掐准,只有在子进程里动手,才不会污染父进程自己的标准流。
思路清楚了,先写点准备工作。代码顶层定义四个宏,分别代表四种重定向状态:
cpp
#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。
核心实现如下:
cpp
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--;
}
}
这里有个无数人踩过的坑,值得单独拎出来说。
很多人在处理截断时,会先写cmdend = '\0',然后马上接while (isspace(cmdend)) end++;。看着挺顺,其实已经翻车了,因为此时cmdend已经被你亲手改成了\0,而isspace('\0')永远返回假。循环条件一上来就是假,循环体压根不执行,end不会往后走,文件名里就会夹带空格,甚至指针指向错位,后续open直接失败。
正确的顺序是:先置空,再end++把指针挪到符号后方,然后才去跳空格。 步子不能乱,顺序不能反。这也是字符串解析里一个非常典型的"隐形陷阱"。
4.3 进程创建、程序替换与重定向的结合
文本解析完了,状态也存好了。接下来就是最核心的一步,把重定向和程序替换拧在一起,写进Execute()里。
Tips:为什么必须在子进程里做dup2?
这是升级自定义Shell时最容易犯的原则性错误:绝对不能在父进程里执行重定向。
道理其实很直白。如果你在父进程(也就是Shell自己)里调了dup2(fd, 1),那等于是把Shell自己的标准输出给改了道。从此以后,你的Shell再打印提示符user@host...$,它不会出现在屏幕上,而是全部哗啦啦流进了那个重定向文件里。到那时,终端上只剩一个沉默的光标,连自己该敲命令都看不见了。所以,重定向这种"脏活",必须关起门来在子进程里干。
升级后的执行逻辑:
cpp
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的最终形态拼了出来。它不再只是跑跑命令、切切目录,而是能解析 <、>、>>,能维护环境变量,能处理内建命令,甚至在子进程里安全地完成重定向。下面是完整代码。
cpp
#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温和得多:它会复制一份指针,放到当前最小的空闲位置上。
cpp
#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 从代码实现理解文件描述符的复制
有了理论打底,直接看改造后的内建命令处理逻辑。核心思路就一句话:先备份,后重定向,执行完内建命令后,再把手动过的指针一个不落地恢复回来。
cpp
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里怎么也有一个引用计数?那个现在没法细讲,得等线程章节才好展开。你现在只需要记住一句话:那个引用计数,决定的是文件描述符表本身何时真正被销毁。 两个引用计数,一个管文件对象,一个管描述符表,各司其职。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注三连支持。你的每一个正反馈,都是我继续硬核输出的最大动力。磁盘深处,我们下篇见。