
✨道路是曲折的,前途是光明的!
📝 专注C/C++、Linux编程与人工智能领域,分享学习笔记!
🌟 感谢各位小伙伴的长期陪伴与支持,欢迎文末添加好友一起交流!

- 前言
- 一、打印命令符字符串
- 二、解析命令行字符串
- 三、普通命令的执行
- 四、内建命令的执行
- 源代码
- 五、给自主shell添加重定向Shell命令行解释器添加重定向功能
- 六:思考:程序的替换是否会影响函数的重定向?
-
-
- [6.1 先拆解关键概念](#6.1 先拆解关键概念)
- [6.2 程序替换(exec)到底改了什么?](#6.2 程序替换(exec)到底改了什么?)
- [6.3 结合Shell重定向场景举例(比如`ls > log.txt`)](#6.3 结合Shell重定向场景举例(比如
ls > log.txt)) - [6.4 为什么程序替换不影响重定向?(核心原因总结)](#6.4 为什么程序替换不影响重定向?(核心原因总结))
- [6.5 关键类比](#6.5 关键类比)
- [6.7 总结](#6.7 总结)
-
前言
Shell 是操作系统与用户交互的屏障,它作为命令行解释器,负责将用户的指令转化为内核可执行的系统调用;而在 Linux 中,Bash 便是这一角色的最核心实现。从本质上看,Shell 是一个在用户登录时自动启动的常驻进程,它通过"创建子进程"来执行指令的机制,确保了系统环境的稳定与安全。深入理解 Shell 的运行原理,不仅能让我们洞察 Linux 进程管理的底层逻辑,更让我们有能力通过编程自主实现一个 Mini-Shell,在现有解释器的基础上构建出属于自己的交互世界。
一、打印命令符字符串
为了让自定义 Shell 在视觉体验上贴近真实的 Bash,我们需要构建一个格式为 [用户名@主机名 当前路径]# 的命令行提示符。在实现细节上,我们采取了以下策略:
- 视觉区分: 考虑到普通用户通常使用
$作为结束符,为了在实验过程中将我们的自定义 Shell 与系统原生 Bash 进行直观区分,我们统一采用#作为提示符的末尾。 - 数据来源: 提示符中的核心动态信息主要源自系统的环境变量 。我们通过
getenv系统调用分别获取USER(当前用户名)、HOSTNAME(主机名)以及PWD(当前工作目录)的值。 - 动态演进: 虽然初始阶段使用环境变量
PWD能够快速构建出提示符,但考虑到后续需要实现cd等内建命令(Built-in Commands),届时我们将对路径获取方式进行优化,以确保路径信息的实时准确性。
注意:此处因为我们需要等待用户输入命令,所以我们在printf函数里不添加换行符 \n。避免提示符自动换行导致输入位置偏离预期。

html
//获取用户名
const char *GetUserName()
{
char *name = getenv("USER");
if(name == NULL)
return "None";
return name;
}
//获取主机名
const char *GetHostName()
{
char *hostname = getenv("HOSTNAME");
if(NULL == hostname)
return "None";
return hostname;
}
//获取当前路径
const char *GetPwd()
{
//char *pwd = getenv("PWD");
char *pwd = getcwd(cwd,sizeof(cwd));
if(NULL == pwd)
return "None";
return cwd;
}
//打印命令行字符串
void PrintCommandLine()
{
printf("[%s@%s %s]# ",GetUserName(),GetHostName(),rfindDir(GetPwd()).c_str());
fflush(stdout);
}
这里
printf没有加换行符\n,若不调用fflush(stdout)会触发关键问题:
- 提示符
[用户名@主机名 当前路径]#会滞留于输出缓冲区,终端无法显示该提示符;- 程序会持续等待用户输入命令,但用户因看不到输入位置,会误以为程序发生卡顿。
fflush(stdout)的核心作用是强制将缓冲区中暂存的提示符内容立即输出至终端,确保用户能清晰看到提示符并正常输入命令。
接下来我们就要获取我们命令行的字符串了,首先我们得知道比如这个命令 ls -a -l 它包含空格,如果我们用scanf 获取字符串的话是不行的,因为scanf默认空格分隔符,这里我们介绍一个新的函数。

fgets从指定文件流获取一行字符串,从键盘stdin获取

明明我们打印 printf("echo: %s\n", commandline) 只有一个 \n 换行,可是这里却多了一个换行,也就是出现了两个换行,那么这个额外的换行究竟是怎么来的呢?

因为当我们输入命令后会按一下回车,相当于他会读取到 \n,所以会自动换行一次,所以我们这里删除手动换行


我们发现现在能够正常打印出我们的命令。但是此时又出现了一个新的问题,当不输入字符敲回车后它也会换行,我们期待的结构应该是一直等待着我们的输入,不应该换行,所以我们应该将最后识别的回车字符换成\0

输入的字符求长度后-1就是最后一个字符,就为回车的\n,我们把他设置为\0,这样回车为换行的命令就消除了,并且让我们读取到第一个字符为\0的时候就一直持续这个状态,然后自己加一个打印的换行


然后将我们的实现逻辑放到一个获取命令行的函数里面

html
//获取用户名
const char *GetUserName()
{
char *name = getenv("USER");
if(name == NULL)
return "None";
return name;
}
//获取主机名
const char *GetHostName()
{
char *hostname = getenv("HOSTNAME");
if(NULL == hostname)
return "None";
return hostname;
}
//获取当前路径
const char *GetPwd()
{
//char *pwd = getenv("PWD");
char *pwd = getcwd(cwd,sizeof(cwd));
if(NULL == pwd)
return "None";
return cwd;
}
//打印命令行字符串
void PrintCommandLine()
{
printf("[%s@%s %s]# ",GetUserName(),GetHostName(),rfindDir(GetPwd()).c_str());
fflush(stdout);
}
//获取用户输入
int GetCommand(char commandline[], int size)
{
if(NULL == fgets(commandline,size,stdin))
return 0;
commandline[strlen(commandline)-1] = '\0';
return strlen(commandline);
}
int main()
{
//0.从配置文件中获取环境变量填充环境变量表
LoadEnv();
char command_line[MAXSIZE] = {0};
while(1)
{
//1.打印命令行字符串
PrintCommandLine();
//2.获取用户命令行输入
if(0 == GetCommand(command_line,sizeof(command_line)))
continue;
}
return 0;
}
思考:为什么main()里面要用死循环?
:::info
需要对 commandline 字符数组做特殊处理,将其中的换行符替换为 \0;而 bash 命令行解释器本质是一个进程,登录 xShell 时该进程启动,每次输入命令按下回车后,bash 会创建子进程执行任务,之后等待下一条指令输入。bash、微信、QQ、网易云等程序运行时均以进程形式存在,且都通过死循环持续运行,除非主动关闭(如点击窗口右上角关闭按钮)或系统异常(如断电)才会终止进程,因此模拟 bash 的自定义 Shell 程序整体也应设计为死循环结构。
:::
二、解析命令行字符串
上一个模块,我们可以将自己输入的命令输出到自己的电脑屏幕上,那么接下来我们就要解析一下用户的输入,执行相对应的命令;如果用户的输入带选项的指令,例如ls -a -l,所以我们应该按照空格为分隔符进行分隔用户的输入,即字符数组commandline
这里我们可以使用字符串切割strtok,自己指定分隔符来切割

- 我们需要获取分隔的字符串,并且将这个分隔的字符串放到一个字符串数组gargv中,初始化gargv的大小的时候,我们使用宏MAXSIZE定义一下大小为32,因为一个命令就算选项在这么多,一行中带的选项一般不会超过32个
- 同样的,我们可以根据分隔的次数-1,去统计gargc的次数,即命令加选项的个数
html
#define MAXSIZE 12
#define MAXARGS 32
// shell自己内部维护的第一张表:命令行参数表
char *gargv[MAXARGS];
int gargc = 0;
const char *gsep = " ";
// 解析字符串:按空格切割命令行,填充gargv和gargc
// 返回值:有效参数个数(gargc)
int PraseCommand(char commandline[])
{
// 1. 重置参数表和参数计数(初始化)
gargc = 0;
memset(gargv, 0, sizeof(gargv));
// 2. 处理空输入(用户只按回车)
if (commandline == NULL || commandline[0] == '\0')
{
return 0;
}
// 3. 第一次切割,获取第一个参数
gargv[0] = strtok(commandline, gsep);
// 若第一个参数为空(如输入全是空格),直接返回0
if (gargv[0] == NULL)
{
return 0;
}
// 4. 循环切割后续参数,限制数组上限,避免越界
while (gargc + 1 < MAXARGS - 1) // 预留最后一个位置为NULL(符合exec系列函数要求)
{
gargv[++gargc] = strtok(NULL, gsep);
// 切割完毕(无更多参数),终止循环
if (gargv[gargc] == NULL)
{
break;
}
}
// 5. 修正参数计数(若因数组上限终止,最后一个NULL不计入)
if (gargv[gargc] == NULL)
{
gargc--;
}
// 调试打印(可选保留)
// printf("gargc: %d\n", gargc);
// for (int i = 0; i <= gargc; i++)
// {
// printf("gargv[%d]: %s\n", i, gargv[i]);
// }
return gargc;
}
memset函数在里面的作用是什么?

不然的话就会出现如下的问题:


所以我们才需要每一次获取需要把之前的清空,展示如下:

三、普通命令的执行
- 上述我们已经解析了用户输入的命令字符串,所以接下来就是根据解析出来的命令和选项去执行命令了,对于普通命令,是由bash创建子进程,子进程去执行普通命令,由于我们有命令为gargv[0],但是我们没有路径,我们有命令行参数gargv,子进程进行程序替换execvp即可。
- 接下来就是父进程使用waitpid等待指定的子进程即可,获取子进程的退出码即可
html
//命令行执行
int ExecuteCommand()
{
pid_t id = fork();
if(id < 0) return -1;
else if(id == 0)
{
//子进程 gargv,gargc
execvpe(gargv[0],gargv,genv);
exit(0);
}
else
{
//父进程
int status = 0;
pid_t rid = waitpid(id,&status,0);
if(rid > 0)
{
lastcode = WEXITSTATUS(status);
//printf("wait child process successs!\n");
}
}
return 0;
}
这里我们使用了execvpe,然后给了一个环境变量env,这个我们在后续会讲到。
这样我们的shell已经能够解析并且执行出来了。
四、内建命令的执行
当我们使用cd...的时候会发现回不去上一路劲,因为cd的本质是改的父进程路径
但是我门店普通命令是在子进程执行的,所以变化的是子进程的当前工作路径,和当前的进程,也就是父进程的工作路径的无关,所以我们应该让父进程执行这个cd命令,这样当前进程的路径才能切换,这种不创建子进程执行,而是由父进程亲自执行的命令我们称为内建命令

- 我们使用 if 语句进行判断即可
- 注意这个内建命令的判断以及执行的位置应该是在普通命令执行之前进行判断,因为内建命令我们不期望让子进程来执行,所以也应该使用一个变量ret进行判断,执行内建命令就执行普通命令,不执行内建命令就执行普通命令
c
//shell自己所处的工作路径
char cwd[MAXSIZE];
//最后一个命令执行完毕,退出码
int lastcode = 0;
//检查内建命令 0:不是内建命令 1:是内建命令&&执行完毕
int CheckBuiltInExecute()
{
if(strcmp(gargv[0],"cd") == 0)
{
//内建命令
if(gargc == 2)
{
//新的目标路径:gargv[1]
//1.更改进程内核中的路径
chdir(gargv[1]);
//2.更改环境变量
char pwd[1024];
getcwd(pwd,sizeof(pwd));
snprintf(cwd,sizeof(cwd),"PWD =%s",pwd);
putenv(cwd);
lastcode = 0;
}
return 1;
}
return 0;
}
这里只是一个简单的举例!源代码如下:
源代码
c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <iostream>
#include <string>
#define MAXSIZE 128
#define MAXARGS 32
// 新增:存储PWD环境变量的独立缓冲区(避免覆盖)
char pwd_env_buf[MAXSIZE] = {0};
static std::string rfindDir(const std::string &p)
{
if(p == "/")
return p;
const std::string psep = "/";
auto pos = p.rfind(psep);
if(pos == std::string::npos)
{
return std::string();
}
return p.substr(pos+1);
}
//shell自己内部维护的第一张表:命令行参数表
char *gargv[MAXARGS];
int gargc = 0;
const char *gsep = " ";
//环境变量表
char *genv[MAXARGS];
int genvc = 0;
void LoadEnv()
{
extern char **environ;
for(;environ[genvc] && genvc < MAXARGS-1; genvc++) // 防越界
{
genv[genvc] = (char*)malloc(sizeof(char)*4096);
if (genv[genvc] == NULL) {
perror("malloc failed");
exit(1);
}
strcpy(genv[genvc],environ[genvc]);
}
genv[genvc] = NULL;
// 注释调试打印,避免干扰交互
// printf("Load env: \n");
// for(int i = 0; genv[i];i++)
// {
// printf("genv[%d]: %s\n",i,genv[i]);
// }
}
//shell自己所处的工作路径
char cwd[MAXSIZE];
//最后一个命令执行完毕,退出码
int lastcode = 0;
//获取用户名
const char *GetUserName()
{
char *name = getenv("USER");
if(name == NULL)
return "None";
return name;
}
//获取主机名
const char *GetHostName()
{
char *hostname = getenv("HOSTNAME");
if(NULL == hostname)
return "None";
return hostname;
}
//获取当前路径
const char *GetPwd()
{
char *pwd = getcwd(cwd,sizeof(cwd));
if(NULL == pwd)
return "None";
return cwd;
}
//打印命令行字符串
void PrintCommandLine()
{
printf("[%s@%s %s]# ",GetUserName(),GetHostName(),rfindDir(GetPwd()).c_str());
fflush(stdout);
}
//获取用户输入
int GetCommand(char commandline[], int size)
{
if (commandline == NULL || size <= 1) return 0;
if(NULL == fgets(commandline, size, stdin))
return 0;
// 处理输入超长/空输入
size_t len = strlen(commandline);
if (len == 0) return 0;
// 移除换行符(兼容输入超长时无换行的情况)
if (commandline[len-1] == '\n')
commandline[len-1] = '\0';
return strlen(commandline);
}
//解析字符串
int PraseCommand(char commandline[])
{
gargc = 0;
memset(gargv,0,sizeof(gargv));
// 处理空命令
if (commandline == NULL || commandline[0] == '\0')
return 0;
gargv[0] = strtok(commandline, gsep);
if (gargv[0] == NULL)
return 0;
// 防数组越界
while (gargc + 1 < MAXARGS - 1)
{
gargv[++gargc] = strtok(NULL, gsep);
if (gargv[gargc] == NULL)
break;
}
// 修正参数计数
if (gargv[gargc] == NULL)
gargc--;
return gargc;
}
//检查内建命令 0:不是内建命令 1:是内建命令&&执行完毕
int CheckBuiltInExecute()
{
// 空指针防护
if (gargv == NULL || gargv[0] == NULL)
{
lastcode = 0;
return 0;
}
// 处理cd命令
if(strcmp(gargv[0],"cd") == 0)
{
const char *target_path = NULL;
// cd无参数:切换到主目录
if (gargc == 1)
{
target_path = getenv("HOME");
if (target_path == NULL)
{
fprintf(stderr, "cd: 未找到主目录\n");
lastcode = 1;
return 1;
}
}
// cd带一个参数
else if (gargc == 2)
{
target_path = gargv[1];
}
// cd多参数
else
{
fprintf(stderr, "cd: 参数过多\n");
lastcode = 1;
return 1;
}
// 执行cd并检查结果
if (chdir(target_path) == -1)
{
perror("cd 失败");
lastcode = 1;
return 1;
}
// 更新环境变量
char pwd[1024];
if (getcwd(pwd, sizeof(pwd)) == NULL)
{
perror("获取当前路径失败");
lastcode = 1;
return 1;
}
// 修正环境变量格式,使用独立缓冲区
snprintf(pwd_env_buf, sizeof(pwd_env_buf), "PWD=%s", pwd);
unsetenv("PWD");
putenv(pwd_env_buf);
// 更新shell的cwd变量
snprintf(cwd, sizeof(cwd), "%s", pwd);
lastcode = 0;
return 1;
}
// 处理echo命令
else if(strcmp(gargv[0],"echo") == 0)
{
// echo无参数
if (gargc < 2)
{
printf("\n");
lastcode = 0;
return 1;
}
// 处理环境变量/退出码
if(gargv[1][0] == '$')
{
if(strcmp(gargv[1]+1,"?") == 0)
{
printf("%d\n", lastcode);
}
else if(strcmp(gargv[1]+1,"PATH") == 0)
{
char *path = getenv("PATH");
printf("%s\n", path ? path : "");
}
// 通用环境变量
else
{
char *env_val = getenv(gargv[1]+1);
printf("%s\n", env_val ? env_val : "");
}
}
// 普通字符串输出
else
{
for (int i = 1; i <= gargc; i++)
{
printf("%s ", gargv[i]);
}
printf("\n");
}
lastcode = 0;
return 1;
}
return 0;
}
//命令行执行
int ExecuteCommand()
{
// 空命令防护
if (gargv == NULL || gargv[0] == NULL)
return 0;
pid_t id = fork();
if(id < 0)
{
perror("fork失败");
lastcode = 1;
return -1;
}
else if(id == 0)
{
// 子进程执行命令
execvpe(gargv[0], gargv, genv);
// 执行到这里说明execvpe失败
perror("命令执行失败");
exit(1); // 非0退出码标识失败
}
else
{
// 父进程等待子进程
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
// 区分正常退出和异常退出
if (WIFEXITED(status))
lastcode = WEXITSTATUS(status);
else
lastcode = 1; // 被信号终止
}
else
{
perror("等待子进程失败");
lastcode = 1;
}
}
return 0;
}
int main()
{
// 加载环境变量
LoadEnv();
char command_line[MAXSIZE] = {0};
while(1)
{
// 1. 打印提示符
PrintCommandLine();
// 2. 获取用户输入
if(0 == GetCommand(command_line, sizeof(command_line)))
continue;
// 3. 解析命令
PraseCommand(command_line);
// 4. 执行内建命令
if(CheckBuiltInExecute())
{
continue;
}
// 5. 子进程执行外部命令
ExecuteCommand();
}
// 释放环境变量内存(简易Shell可忽略,进程退出会自动释放)
for (int i = 0; genv[i]; i++)
free(genv[i]);
return 0;
}
五、给自主shell添加重定向Shell命令行解释器添加重定向功能
为自定义Shell实现
<(输入)、>(输出)、>>(追加)三种重定向功能,核心逻辑是:解析命令行字符串提取重定向信息 → 子进程中通过文件操作+dup2完成重定向 → 执行命令。
5.1基础定义

5.1.1 重定向类型宏定义
c
//ls -a -l > XX.txt ?> "ls -a -l" && "XX.txt" && 重定向的方式
// 初始化/无重定向(区别于具体重定向类型)
#define NoneRedir 0
// 输入重定向(对应命令行 < 符号)
#define InPutRedir 1
// 输出重定向(对应命令行 > 符号)
#define AppRedir 2
// 追加重定向(对应命令行 >> 符号)
#define OutPutRedir 3
5.1.2 全局/模块级变量
c
// 存储重定向目标文件名(如 log.txt)
char *filename = NULL; //保存重定向目标文件
// 存储重定向类型(值为上述宏,初始化为 NONE_RDIR)
int redir_type = NoneRedir; //重定向方式
5.2 核心步骤拆解
5.2.1 解析命令行
作用:从命令行字符串(如ls -a -l > log.txt)中提取"重定向类型"和"目标文件名",存入上述变量。
具体操作步骤:
- 遍历命令行字符串 :从首字符开始逐个检查,优先识别长符号(避免>
和>>混淆):- 先判断是否是
>>(追加重定向):若当前字符是>且下一个字符也是>,则设置rdir = 追加重定向; - 再判断是否是
>(输出重定向):若仅当前字符是>,则设置rdir = 输出重定向; - 最后判断是否是
<(输入重定向):若当前字符是<,则设置rdir = 输入重定向。
- 先判断是否是
c
void ParseRedir(char commandline[])
{
redir_type = NoneRedir;
filename = NULL;
char *start = commandline;
char *end = commandline + strlen(commandline) - 1;
while(start < end)
{
// 第一步:优先识别长符号 >>(追加重定向),避免 > 被误判
if(*start == '>')
{
if(*(start+1) == '>')
{
// 追加重定向(>>)逻辑 - 待实现
// TODO: 截断命令字符串、跳过空白字符、设置redir_type和filename
break;
}
// 第二步:识别短符号 >(输出重定向)
else
{
// 输出重定向(>)逻辑 - 待实现
// TODO: 截断命令字符串、跳过空白字符、设置redir_type和filename
break;
}
}
// 第三步:最后识别 <(输入重定向)
else if(*start == '<')
{
// 输入重定向(<)逻辑 - 待实现
// TODO: 截断命令字符串、跳过空白字符、设置redir_type和filename
break;
}
else
{
// 非重定向符号,继续遍历
start++;
}
}
}
- 跳过空白字符 :找到重定向符号后,继续向后遍历,用
isspace()函数判断并跳过所有空白字符(空格、制表符等); - 提取文件名 :跳过空白字符后,当前位置即为文件名的起始位置,将该地址赋值给
filename; - 截断命令串 :将重定向符号的位置替换为
\0,使原命令行字符串仅保留"纯命令部分"(如ls -a -l > log.txt变为ls -a -l)。
c
// 以追加重定向(>>)为例,补全逻辑
*start = '\0'; // 截断命令部分(第一个 > 位置置空)
start++;
*start = '\0'; // 第二个 > 位置置空
start++;
TrimSpace(start); // 跳过文件名前的空白字符
redir_type = AppRedir; // 设置追加重定向类型
filename = start; // 赋值目标文件名


5.2.2 子进程中执行重定向+命令(fork后、程序替换前)
作用:根据解析得到的重定向信息,修改文件描述符,实现输入/输出的重定向。
具体操作步骤:
- 判断重定向类型 :根据
rdir的值分支处理; - 打开目标文件 :不同重定向类型对应不同的open参数:
- 输入重定向 :以"只读"方式打开文件(
O_RDONLY); - 输出重定向 :以"只写+创建+清空"方式打开(
O_WRONLY | O_CREAT | O_TRUNC,权限建议0666); - 追加重定向 :以"只写+创建+追加"方式打开(
O_WRONLY | O_CREAT | O_APPEND,权限建议0644);
- 输入重定向 :以"只读"方式打开文件(
- dup2重定向文件描述符 :
- 输入重定向:将打开文件的fd重定向到标准输入(fd=0)→
dup2(fd, 0); - 输出/追加重定向:将打开文件的fd重定向到标准输出(fd=1)→
dup2(fd, 1);
- 输入重定向:将打开文件的fd重定向到标准输入(fd=0)→
- 关闭文件描述符:重定向完成后,关闭open返回的fd(避免资源泄漏);
- 执行程序替换:调用exec系列函数执行命令(如execvp)。
c
pid_t id = fork();
if(id < 0) return -1;
else if(id == 0)
{
//子进程 gargv,gargc
if(redir_type == NoneRedir)
{
//DoNothing
}
else if(redir_type == OutPutRedir)
{
//输出重定向
int fd = open(filename,O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd < 0) { perror("open file failed"); exit(1); }
dup2(fd,1);
close(fd); // 重定向后关闭原文件描述符,避免资源泄漏
}
else if(redir_type == AppRedir)
{
//追加重定向
int fd = open(filename,O_WRONLY | O_CREAT | O_APPEND, 0666);
if(fd < 0) { perror("open file failed"); exit(1); }
dup2(fd,1);
close(fd);
}
else if(redir_type == InPutRedir)
{
//输入重定向
int fd = open(filename,O_RDONLY);
if(fd < 0) { perror("open file failed"); exit(1); }
dup2(fd,0);
close(fd);
}
else{
//出bug
perror("unknown redir type");
exit(1);
}
execvpe(gargv[0],gargv,genv);
// exec系列函数执行失败才会走到这里
perror("execvpe failed");
exit(1);
}

5.3 核心示例
- 解析阶段:函数遍历字符串找到
>,设置rdir=输出重定向,跳过空格后将log.txt赋值给filename,原命令串截断为ls -a -l; - 执行阶段:
- 子进程中
open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666)得到fd; dup2(fd, 1)将标准输出重定向到log.txt;- 关闭fd,执行
execvp("ls", ["ls", "-a", "-l", NULL]); - 最终
ls -a -l的输出不再打印到终端,而是写入log.txt。
- 子进程中

关键点总结
- 解析时先判断长符号(>>)再判断短符号(>) ,避免>
被误判为>>; - 重定向的核心是
dup2函数:将目标文件的fd"替换"标准输入/输出的fd(0/1); - 所有文件操作(open/dup2)必须在子进程中执行,避免影响Shell主进程。
六:思考:程序的替换是否会影响函数的重定向?
程序替换(exec系列函数)只会替换进程的代码和数据段,不会改变进程已建立的文件描述符、文件打开关系、重定向关联------简单说:"文件相关的内核状态不变,只换用户态的代码/数据"。
6.1 先拆解关键概念
要理解这个问题,先搞懂3个核心内核数据结构的关系(从进程视角):
| 数据结构 | 作用 |
|---|---|
| task_struct(PCB) | 进程的"身份证",记录进程所有核心属性(包括指向文件描述符表的指针) |
| 文件描述符表 | 进程专属的数组(默认大小1024),下标(0/1/2/...)就是文件描述符,数组值指向"文件打开对象" |
| 文件打开对象 | 内核级的结构体(记录文件路径、打开模式、偏移量、引用计数等),是进程和物理文件的"桥梁" |
三者的关系:进程(PCB) → 指向 → 文件描述符表 → 下标(fd)→ 指向 → 文件打开对象 → 关联 → 物理文件。
6.2 程序替换(exec)到底改了什么?
exec系列函数的本质是:在原有进程的内核上下文(PCB、文件描述符表、打开的文件等)不变的前提下,替换进程用户态的代码段、数据段、堆、栈。
具体改动只有2处(用户态层面):
- 进程地址空间(mm_struct)的代码段、数据段等字段指向新程序的物理内存;
- 页表重新映射(让进程地址空间和新程序的物理代码/数据对应)。
而内核态的关键结构完全不变:
- PCB(task_struct)没换(进程PID、优先级、文件描述符表指针等都不变);
- 文件描述符表没动(0/1/2等fd对应的文件打开对象地址不变);
- 已打开的文件对象没删(重定向时dup2绑定的fd关联关系还在)。
6.3 结合Shell重定向场景举例(比如ls > log.txt)
我们一步步看整个过程:
- Shell主进程解析命令 :识别到
>,记录重定向类型和文件名log.txt; - Shell创建子进程(fork):子进程完全继承父进程的文件描述符表(0/stdin、1/stdout、2/stderr都指向终端);
- 子进程执行重定向 :
- 调用
open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666),内核创建"文件打开对象",并分配一个空闲fd(比如3); - 调用
dup2(3, 1):把fd=1(stdout)的指向改成fd=3对应的"文件打开对象"(相当于stdout绑定到log.txt); - 关闭fd=3(释放空闲fd,不影响绑定关系);
- 调用
- 子进程执行execvp("ls", ...) :
- 替换子进程的代码/数据(换成ls的程序逻辑);
- 但fd=1依然指向log.txt的"文件打开对象";
- ls程序执行:ls的输出会写入stdout(fd=1),而fd=1已经绑定到log.txt,所以输出写入文件而非终端。
6.4 为什么程序替换不影响重定向?(核心原因总结)
- 作用层面不同 :
- 重定向(open/dup2)操作的是内核态的文件描述符表、文件打开对象;
- 程序替换(exec)操作的是用户态的代码/数据段;
- 进程身份不变 :
exec不会创建新进程(PID不变),进程的内核上下文(PCB、文件描述符表)完全复用,只是换了"要执行的代码"; - 文件描述符是内核级属性 :
文件描述符的关联关系存在于内核,而非用户态的代码/数据中,所以不管用户态执行什么程序,内核里fd的绑定关系都不会变。
6.5 关键类比
可以把进程比作"一间办公室":
- 进程的代码/数据 = 办公室里的员工(exec相当于把员工换成新的,但办公室本身不变);
- 文件描述符表 = 办公室里的电话交换机(fd是分机号);
- 重定向 = 把"1号分机"(stdout)的线路接到"log.txt传真机"(而非终端);
- exec替换员工后,1号分机的线路依然连到传真机,新员工用1号分机打电话,还是会打到传真机上。
6.7 总结
- 程序替换(exec)仅替换进程用户态的代码/数据,不改变内核态的文件描述符表和文件打开关系;
- 重定向的本质是修改内核级的文件描述符关联,和用户态执行的程序无关;
- fork创建的子进程继承父进程的文件描述符表,exec不改变这一表的内容,因此重定向在exec后依然有效。
✍️ 坚持用 清晰易懂的图解 + 可落地的代码,让每个知识点都 简单直观!
💡 座右铭 :"道路是曲折的,前途是光明的!"
