
◆ 博主名称: 小此方-CSDN博客 大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)
文章目录
- 概要&序論
- 一、文件描述符的概念
- 二、文件描述符表
-
- 2.1文件描述符表的概念
- 2.2结合文件描述符底层重新理解I/O
- [Tips:struct file里面有什么](#Tips:struct file里面有什么)
- 三、重定向的原理
-
- [3.1 手动模拟重定向](#3.1 手动模拟重定向)
- [3.2 自动接口:dup2 系统调用](#3.2 自动接口:dup2 系统调用)
- 四、升级自定义Shell:实现重定向功能
-
- [4.1 核心设计思路与宏定义](#4.1 核心设计思路与宏定义)
- [4.2 文本解析:重定向检测](#4.2 文本解析:重定向检测)
- [4.3 进程替换与重定向的完美结合](#4.3 进程替换与重定向的完美结合)
-
- [Tips:为什么一定要在子进程里做 dup2?](#Tips:为什么一定要在子进程里做 dup2?)
- [4.4 完整的代码](#4.4 完整的代码)
- 五、加餐补充内容
-
- 5.1自定义Shell如何重定向内建命令
-
- [5.1.1 认识 dup 系统调用](#5.1.1 认识 dup 系统调用)
- [5.2 代码层面的完美适配](#5.2 代码层面的完美适配)
- [5.2 文件与引用计数](#5.2 文件与引用计数)
概要&序論
Hello大家好,我是此方。本文深刻探讨 Linux 文件描述符底层结构与重定向机制。
- 文件描述符(fd)整型值本质与标准输入、输出、错误的绑定关系;
- 内核
task_struct、文件描述符表files_struct与struct file结构体的指针层级映射;- 详解文件描述符的"最小分配规则"及手动闭合标准流实现重定向的底层原理;
dup与dup2系统调用对内核文件对象指针的覆盖与备份操作;- 完成自定义 Shell 的"超级升级",重定向。内核
struct file的引用计数
好的,我们直接开始。
一、文件描述符的概念
1.1文件描述符的基础认识
我们上篇文章故意回避了一个问题:open系统调用的返回值是什么?答案是"文件描述符(new file descriptor) "。

文件描述符是什么?我们打印它看一看:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.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);
}

我们发现,文件描述符是一个整型值 。但是有人会发现:为什么文件描述符是从3开始打印而不是从0开始打印?
因为,我们上一篇文章也说了:"程序启动的时候为我们自动打开了三个流(文件),标准输入(0)、标准输出(1)、标准错误(2)。以C为例,stdin,stdout,和stderr"
1.2基于文件描述符的向上讨论
回到我们C语言的文件操作,我想告诉大家:上篇文章我们讲到fopen函数底层封装了open函数,那么FILE返回值这个结构体里面一定也封装了一个fd文件描述符。
使用指令打开:
bash
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
对文件内容做任何的操作,都必须将文件内容加载(从磁盘到内存的拷贝)到内核中对应的文件缓冲区中。

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;

cpp
struct file {
/*
* fu_list becomes invalid after file_free is called and queued via
* fu_rcuhead for RCU freeing
*/
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指向它,为0时文件才真正关闭)
unsigned int f_flags; // 【读写选项】打开文件时的标志(如 O_RDONLY, O_WRONLY, O_NONBLOCK)
mode_t f_mode; // 【属性/int mode】文件的访问权限(如可读、可写)
loff_t f_pos; // 【读写位置】当前文件的读写偏移量(即下一次 read/write 开始的字节位置)
struct fown_struct f_owner; // 【属性】异步I/O时接收信号的进程属主信息
unsigned int f_uid, f_gid; // 【属性】文件所有者的 User ID 和 Group ID
struct file_ra_state f_ra; // 【属性】文件预读(Read-Ahead)状态流,用来优化磁盘读取性能
unsigned long f_version; // 【属性】版本号,每次使用后会自动更迭
void *f_security; // 【属性】安全模块(如 SELinux)使用的安全上下文指针
/* needed for tty driver, and maybe others */
void *private_data; // 【属性】私有数据指针(系统调用或驱动程序常用来挂载自定义的特殊结构体)
#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct list_head f_ep_links; // 【属性】被 epoll 监听时,挂载到 epoll 事件等待队列的链表节点
spinlock_t f_ep_lock; // 【属性】保护 epoll 链表的自旋锁
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping; // 【缓冲区 - TODO】指向页高速缓存(Page Cache)映射的指针!
// 这就是你上一张图说到的"内核开辟的文件缓冲区"在内核里的核心代理人。
};
三、重定向的原理
3.1 手动模拟重定向
3.1.1修改后产生的现象
在理解了文件描述符的底层逻辑(也就是数组下标)之后,我们再来看操作系统分配文件描述符的规则:
最小的,没有被使用的,作为新的 fd 给用户。
既然程序启动时默认打开了 0 (stdin)、1 (stdout)、2 (stderr),那如果我们故意把 1 给关闭了,然后再去打开一个新的文件,会发生什么?
根据规则,操作系统一看,0 占着,1 空着,好,那就把 1 分配给新打开的文件!
我们来看下面这段代码,手动模拟一下这个过程:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.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 居然被写入到了 log.txt 里面!
3.1.2重定向的原理
为什么会这样?

因为 printf 只认 stdout,而 stdout 结构体内部封装的 fileno 就是 1。对应用层来说,"往 1 号下标对应的文件写数据"这一逻辑从未改变。
但是在内核层,我们通过 close(1) 断开了 1 号下标指向标准显示器的连接,并让它重新指向了 log.txt 的 struct file。
上层不变底层变,上层还是原来的文件描述符,底层变成了别的地址,这------就是重定向的底层原理!
3.2 自动接口:dup2 系统调用
3.2.1接口介绍
虽然通过先 close 再 open 可以实现重定向,但这未免有点太笨拙了,而且不够优雅。为此,Linux 系统为我们提供了一个高效率的系统调用接口:dup2。
我们先来看一下它的系统调用手册:
bash
# int dup2(int oldfd, int newfd);
RETURN VALUE:
On success, these system calls return the new descriptor. On error, -1 is returned, and errno is set appropriately.
关于 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.2.2实际应用
如果要进行输出重定向(比如把本该输出到屏幕的内容重定向到文件中):
我们希望原本往 1 号(newfd)打的内容走到新打开的文件 fd(oldfd)中去。所以代码应该写成:dup2(fd, 1);
下面我们用 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 号文件描述符写入的内容,都写到 myfile 当中,而不再写到标准输出!
dup2(fd, 1);
// 3. 测试输出
printf("凡是往1号文件描述符写的内容,都写到了myfile当中,而不再写到标准输出!\n");
printf("fd: %d\n", fd);
// 4. 关闭文件 //dup2它不回去主动关闭原来的文件
close(fd);
return 0;
}
四、升级自定义Shell:实现重定向功能
我们在自定义Shell编写的那篇中讲到过,第四阶段的代码放在《文件篇》中讲解,它来了。我们要实现的是自定义Shell的"重定向功能"。
4.1 核心设计思路与宏定义
要想让 Shell 支持重定向,我们需要把整个过程拆解为两步:
- 宏观检测: 在解析用户输入的命令行字符串时,检查其中是否包含
<、>、>>等重定向符号。如果存在,必须将这些符号以及后面的文件名从原命令行中"拆离"出来。 - 底层替换: 在
fork()出子进程之后、程序替换execvp()之前,根据重定向的类型,调用open()系统调用打开对应的文件,并使用dup2()改变子进程的标准输入或标准输出。
首先,我们在代码顶层定义代表重定向类型的 4 个宏状态,以及用于保存全局重定向状态和目标文件名的全局变量:
cpp
#define NONE_REDIR 0 // 无重定向
#define INPUT_REDIR 1 // 输入重定向 <
#define OUTPUT_REDIR 2 // 输出重定向 >
#define APPEND_REDIR 3 // 追加重定向 >>
int redir = NONE_REDIR; // 记录当前重定向的类型
string 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--;
}
}
很多人在写这段字符串解析时,会先把
cmd[end] = 0,紧接着在循环条件里写while(isspace(cmd[end])) end++;。此时cmd[end]已经是\0(字符串结束符)了,isspace('\0')永远为假,导致循环直接跳出,提取的文件名里会夹杂着大量的非法空格或者错位指针,导致后续open失败,正确的做法是先将位置置空,然后将指针end++移向后方,再去跳过空格。
4.3 进程替换与重定向的完美结合
文本解析好了,状态也保存了。接下来到了最核心的命令执行函数 Execute()。
Tips:为什么一定要在子进程里做 dup2?
这也是自定义 Shell 升级时最容易犯的原则性错误:绝对不能在父进程中执行重定向。
如果我们在父进程(Shell 本身)里直接调用 dup2(fd, 1),那么完蛋了,后续你的 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);
}
}
4.4 完整的代码
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[(size_t)(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--;
if(g_argc==0)
return false;
return true;
}
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 环境变量,防止 cd 后提示符不更新路径
char cwd_buf[LINE_SIZE];
if (getcwd(cwd_buf, sizeof(cwd_buf))) {
setenv("PWD", cwd_buf, 1);
}
}
void Echo()
{
#define MAX_SIZE_ENV 1000
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 statue = 0;
waitpid(id, &statue, 0);
if (WIFEXITED(statue)) {
exitcode = WEXITSTATUS(statue);
}
}
int main()
{
try
{
Initenv();
}
catch(string error)
{
cout<<error<<endl;
}
try
{
while(true)
{
//打印命令行提示符
PrintCommandPrompt();
//获取命令行参数
char commandline[LINE_SIZE] = {0};
if(!GetCommandLine(commandline ,sizeof(commandline)))
continue;
//检测重定向类型
RedirCheck(commandline);
//分析命令行参数
if(!AnalyseCommandLine(commandline))
continue;
//检测是否是内建命令
if(BuildinCommandCheck())
continue;
//执行命令
Execute();
}
}
catch (string error)
{
cout<<error<<endl;
}
catch(...)
{
cout<<"未知异常"<<endl;
}
return 0;
}
五、加餐补充内容
5.1自定义Shell如何重定向内建命令
在完成了自定义 Shell 的重定向升级后,你可能会发现一个隐藏的致命问题:如果用户输入的是内建命令,应该如何重定向?
因此,内建命令的重定向,核心关键在于:重定向操作完成后,如何将底层指针恢复如初?
为了解决这个问题,我们需要引入另一个系统调用:dup。
5.1.1 认识 dup 系统调用
与 dup2 强制覆盖指定下标不同,dup 的作用是复制一份标准指针到当前最小的空闲位置。
cpp
#include <unistd.h>
int dup(int oldfd);
返回值: 成功时返回新分配的文件描述符(该描述符指向与
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 代码层面的完美适配
修改后的内建命令处理逻辑如下:
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;
if (redir == INPUT_REDIR){
save_stdin = dup(0); // 备份原标准输入
int fd = open(filename.c_str(), O_RDONLY);
if(fd >= 0) {
dup2(fd, 0); // 重定向标准输入
close(fd);
}
}
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); // 重定向标准输出
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();
//关键:恢复
if (save_stdin != -1){
dup2(save_stdin, 0); // 恢复标准输入
close(save_stdin); // 释放备份
}
if (save_stdout != -1){
dup2(save_stdout, 1); // 恢复标准输出
close(save_stdout); // 释放备份
}
return true;
}
5.2 文件与引用计数
我们在前面的草图中提到了系统的核心原则:一个文件可以被多个进程同时打开。这就带来了一个问题:当某个进程关闭一个文件时,操作系统怎么知道该不该把这个文件从内核中真正释放掉?
5.1.1引用计数的增减规则
在内核的 struct file 结构体内部,有一个名为 f_count 的成员(即引用计数)。它的工作机制:

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

有人会问:为什么struct file_struct里面也有一个引用计数?这个没法讲,得等到线程章节再说,你就记住一句话:"这个引用计数决定文件描述符表何时真正被销毁"

好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye!