《从零入门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的入口](#一、文件描述符——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的实际应用)

四、升级自定义Shell------实现命令重定向

[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。重定向符号和它后面的文件名,一般总待在命令行的最右端。所以我们的扫描方向也反过来,从字符串末尾开始,一路往前找。

一旦扫到<、> 或 >>,就做两件事:

  1. 把符号所在位置直接抹成\0,将前面的有效命令和后面的文件名一刀两断;
  2. 跳过符号与文件名之间可能存在的空格,把纯净的文件名提取出来,交给全局变量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里怎么也有一个引用计数?那个现在没法细讲,得等线程章节才好展开。你现在只需要记住一句话:那个引用计数,决定的是文件描述符表本身何时真正被销毁。 两个引用计数,一个管文件对象,一个管描述符表,各司其职。


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

相关推荐
jing.wang_20251 小时前
制作最小Linux(Ubuntu)系统
linux·嵌入式硬件·ubuntu·dsp开发
陈年老古董1 小时前
Shell 脚本编程基础完整学习笔记
linux·sql·mysql
竣达技术1 小时前
守护变电站直流 “生命线”|竣达 BMS‑PRO‑II 智能电池综合管理单元,让蓄电池运维看得见、管得牢
运维·监控系统·机房监控
byte轻骑兵1 小时前
【BlueZ 】蓝牙地址与 UUID:BlueZ 源码中核心标识的定义与使用
linux·bluez·电脑蓝牙·嵌入式蓝牙
云上工程笔记1 小时前
2026 年 GPU 云服务器成本对比榜单:显存、算力、单小时价格和任务完成成本怎么算
运维·服务器
楠楠子呀1 小时前
号码验证技术解析:从原理到实践
运维·人工智能·ai·electron·自动化
码匠许师傅1 小时前
【C++ 面试真题】35. 聊聊 C++ 的万能引用(T&&)和完美转发(std::forward)
java·c++·面试
Baron X2 小时前
Linux PAM登录告警
linux·运维·告警
团子股股东峥哥2 小时前
day27-RHEL-访问Linux文件系统
linux·运维·服务器