Linux 文件描述符与重定向:从 `open` 到自定义 Shell
写在前面
以前我第一次看到 open、read、write、close 时,很容易把它们当成另一套“更难用的文件函数”。后来顺着一次文件访问的路径往下追,我才发现:真正的主角不是函数名,而是进程、文件描述符表和内核打开文件对象之间的关系。
这条主线可以压缩成一句话:
进程不直接拿着文件操作,而是先拿到一个文件描述符;重定向不改变程序的输出语句,只改变某个文件描述符指向谁。
这篇笔记从“为什么文件访问必须经过操作系统”出发,依次走过 open 的位标志、read/write 的字节语义、FILE * 的封装、fd 的数组下标本质、dup2 重定向,最后把 <、>、>> 接入一个简化版 Shell。我的目标不是背 API,而是能从任意一行代码反推出它在进程和内核中改变了什么。
知识框架

目录
- 1. 系统文件接口:程序为什么要经过操作系统
- 2.
open:把打开方式编码进一个整数 - 3.
read与write:系统只负责搬运字节 - 4. 文件描述符的本质
- 5. 重定向:不改输出代码,只改描述符指向
- 6. 把重定向接入自定义 Shell
- 总结
1. 系统文件接口:程序为什么要经过操作系统
1.1 文件操作为什么不能绕过内核
文件通常保存在磁盘、SSD 等外设中。应用进程运行在用户态,不能随意操作硬件,也不能直接访问内核维护的文件系统数据结构。否则一个普通程序就可能越权读取别人的文件、破坏磁盘数据,甚至让整个系统失去稳定性。
因此,应用程序必须向操作系统提出请求:
- 我要打开哪个路径;
- 我准备怎样使用它;
- 我要读取或写入多少字节;
- 使用完毕后我要释放这个打开关系。
Linux 提供的基本系统文件接口正好对应这四件事:
open(); // 打开或创建文件
read(); // 从已打开对象读取字节
write(); // 向已打开对象写入字节
close(); // 释放一个文件描述符
这里的“系统调用”可以理解为应用程序进入内核、请求操作系统服务的一条受控入口。程序不能越过这条入口直接摆弄内核对象。
1.2 系统调用与库函数的关系
C 语言还提供了 fopen、fread、fwrite、fprintf、fclose 等库函数。它们不是和系统调用争抢同一位置,而是位于更高一层。调用会依次经过:应用代码 → C 标准库接口 → Linux 系统调用 → 内核文件系统与设备。
库函数的价值主要有两点:
- 更方便:格式化输入输出、缓冲区管理、字符流操作更容易使用;
- 更可移植:ISO C 规定了标准库接口,而
open、read、write属于 POSIX/Linux 风格接口。
所以,在不同系统上,fopen 的底层实现可能不同,但上层 C 代码可以保持相似。Linux 上的标准 I/O 最终仍要通过系统能力完成真正的文件访问。
2. open:把打开方式编码进一个整数
2.1 位标志为什么能组合
open 的第二个参数叫 flags,用来告诉内核“我要怎样打开这个文件”。它不是一个普通的单选值,而是由“一个访问模式 + 零个或多个附加标志”组成。先把最常见的组合写成带注释的形式:
// 这是准备传给 open 的第二个参数,不是一个完整程序
int flags =
O_CREAT | // 文件不存在时创建文件(create)
O_WRONLY | // 以只写方式打开,不能通过这个 fd 读取(write only)
O_TRUNC; // 文件已经存在时把长度截断为 0,也就是先清空旧内容(truncate)
这里的 | 是按位或,不是逻辑或 ||。它会把多个标志所占用的二进制位合并到同一个整数里,再由 open 分别识别。最终可以把这行理解为:以只写方式打开;文件不存在就创建;文件已存在就先清空。
⚠️ 易错点: O_TRUNC 会让已有文件的长度变成 0,旧内容会丢失。如果想在文件末尾继续写,应该考虑 O_APPEND,不能使用 O_TRUNC。
我先不用系统提供的 O_... 宏,而是自己做三个小开关,看看这些二进制位究竟怎样组合:
// 1 << n 表示:把二进制数字 1 向左移动 n 位
#define ONE_FLAG (1 << 0) // 0001,十进制值为 1,占用第 0 位
#define TWO_FLAG (1 << 1) // 0010,十进制值为 2,占用第 1 位
#define THREE_FLAG (1 << 2) // 0100,十进制值为 4,占用第 2 位
以 ONE_FLAG 为例,1 << 0 表示左移 0 位,所以仍然是 0001;THREE_FLAG 的 1 << 2 会把 1 移到第 2 位,得到 0100。每个宏只占一个不同的位置,组合时就不会互相覆盖。
接下来用按位或 | 组合开关,再用按位与 & 检查开关:
// 0001 | 0100 = 0101,所以 flags 的十进制值为 5
int flags = ONE_FLAG | THREE_FLAG;
// 0101 & 0001 = 0001,结果不是 0,说明 ONE_FLAG 确实存在
if ((flags & ONE_FLAG) != 0) {
// 会进入这里:执行 ONE_FLAG 对应的功能
}
// 0101 & 0010 = 0000,结果为 0,说明 TWO_FLAG 没有被设置
if ((flags & TWO_FLAG) != 0) {
// 不会进入这里
}
这两个运算可以分别记成:
|负责合并:只要某一边对应位为1,结果中的该位就是1;&负责筛选:只有两边对应位都为1,结果中的该位才是1;!= 0负责把筛选结果转换成清楚的“存在 / 不存在”判断。
这种设计比为每一个布尔选项都增加一个函数参数更紧凑,也便于以后扩展新的标志。
不过真实的 open 标志还要多想一步。先澄清一个容易误解的地方:O_RDONLY、O_CREAT 这些名字不是在终端中执行的命令,而是头文件 <fcntl.h> 定义的整数宏。fcntl 可以理解为 file control(文件控制),O_ 前缀表示这些宏用于描述文件的 open(打开)方式。程序把它们组合成 flags,再作为 open 的第二个参数交给内核。
这些标志分成两类。第一类是“访问模式”,负责回答打开后准备用文件描述符做什么,三者必须且只能选择一种:
O_RDONLY:只读打开(read only)。程序可以通过返回的文件描述符读取文件,但不能写入。若把这个文件描述符传给write,调用会失败,常见错误是EBADF,表示该描述符不允许这种操作。O_WRONLY:只写打开(write only)。程序可以写入文件,但不能通过这个文件描述符读取;用它调用read同样会失败。O_RDWR:读写打开(read and write)。同一个文件描述符既可以交给read,也可以交给write。
第二类是“附加标志”,负责回答打开文件时还要顺便做什么。它们可以与上面的某一种访问模式组合:
O_CREAT:文件不存在时创建(create)。如果目标文件已经存在,它本身不会清空文件,只是继续打开;如果文件不存在,内核会创建新文件。使用它时,open通常还要接收第三个mode参数,例如0666,用于申请新文件的初始权限,最终权限还会被umask过滤。O_TRUNC:打开时截断(truncate)。如果目标是已有的普通文件,并且以可写方式成功打开,文件长度会立刻变成0,旧内容随之丢失。它不会在文件不存在时自动创建文件,因此覆盖写通常把它与O_CREAT | O_WRONLY一起使用。O_APPEND:追加写(append)。每次执行write前,内核都会把本次写入位置放到文件末尾,让新内容接在旧内容后面。它不会清空原内容,也不会单独负责创建文件;希望“没有就创建、有就追加”时,通常组合O_CREAT | O_WRONLY | O_APPEND。
把常见组合翻译成直白的话,就是:
O_RDONLY:文件必须已经存在,以只读方式打开;O_CREAT | O_WRONLY | O_TRUNC:没有文件就创建,有文件就清空,然后只写——对应常见的覆盖写;O_CREAT | O_WRONLY | O_APPEND:没有文件就创建,有文件就保留旧内容,然后从末尾继续写——对应追加写。
⚠️ 易错点: O_TRUNC 和 O_APPEND 表达的是相反的写入意图。前者先清空旧内容,后者保留旧内容并在末尾写。二者不应该为了“保险”同时使用。
检查 O_CREAT、O_TRUNC、O_APPEND 这类独立附加标志时,可以使用按位与;提取三选一的访问模式时,则应使用 O_ACCMODE 掩码。
所谓“掩码”,可以先理解为一张只在特定位置开孔的纸:执行按位与以后,只保留我关心的那几位。访问模式可以这样判断:
// O_ACCMODE 只保留 flags 中表示“访问模式”的那几位
int access_mode = flags & O_ACCMODE;
if (access_mode == O_WRONLY) {
// 当前是只写模式
}
// O_CREAT 是独立的附加标志,可以直接用按位与检查
if ((flags & O_CREAT) != 0) {
// 当前要求:文件不存在时创建它
}
这里不能写成 flags & O_RDONLY 来判断只读模式,因为在 Linux 中 O_RDONLY 的值通常就是 0,与任何数按位与都会得到 0。正确思路是先用 O_ACCMODE 提取访问模式,再与 O_RDONLY、O_WRONLY 或 O_RDWR 比较。
所以 O_CREAT | O_WRONLY | O_TRUNC 的含义不是“任意三个开关”,而是:先选择三种访问模式之一的 O_WRONLY,再附加 O_CREAT 和 O_TRUNC 两种行为。把这个整数传给 open 后,内核就能从不同的位中读出这些要求。
2.2 位标志完整示例
下面这个程序把“组合标志”和“检测标志”完整演示了一遍。
文件名:Flags.c
#include <stdio.h>
#define ONE_FLAG (1<<0) // 0000 0000 0000...0000 0001
#define TWO_FLAG (1<<1) // 0000 0000 0000...0000 0010
#define THREE_FLAG (1<<2) // 0000 0000 0000...0000 0100
#define FOUR_FLAG (1<<3) // 0000 0000 0000...0000 1000
void Print(int flags)
{
if(flags & ONE_FLAG)
{
printf("One!\n");
}
if(flags & TWO_FLAG)
{
printf("Two\n");
}
if(flags & THREE_FLAG)
{
printf("Three\n");
}
if(flags & FOUR_FLAG)
{
printf("Four\n");
}
}
int main()
{
Print(ONE_FLAG);
printf("\n");
Print(ONE_FLAG | TWO_FLAG);
printf("\n");
Print(ONE_FLAG | TWO_FLAG | THREE_FLAG);
printf("\n");
Print(ONE_FLAG | TWO_FLAG | THREE_FLAG | FOUR_FLAG);
printf("\n");
Print(ONE_FLAG | FOUR_FLAG);
printf("\n");
return 0;
}
程序从 main 开始,每次把不同的标志组合传给 Print。Print 中使用的是多个独立的 if,不是互斥的 else if,所以一次调用可以打印多个结果。
例如:
Print(ONE_FLAG | FOUR_FLAG);
得到的 flags 同时保留第 0 位和第 3 位,两个判断都会成立。这就是 open 能用一个 int 同时表达“创建、只写、清空”等多种要求的基础。
可以在该文件所在位置直接编译:
gcc Flags.c -o Flags
./Flags
2.3 open 的参数与返回值
常见声明可以写成:
int open(const char *pathname, int flags, ...);
前三个概念分别是:
pathname:要打开的文件路径;flags:打开方式;mode:当调用中含O_CREAT,并且确实要创建新文件时,用来给出权限位。
常见调用如下:
int fd = open("log.txt", O_RDONLY);
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
两个参数还是三个参数,取决于这次调用是否带有需要 mode 的创建语义。返回值必须先检查:
if (fd < 0) {
perror("open");
return 1;
}
- 成功时返回一个非负整数,也就是文件描述符;
- 失败时返回
-1,并设置errno; perror会把与当前errno对应的错误原因打印出来。
⚠️ 易错点: 成功返回值是“非负”,不保证一定大于 0。0 本身就是合法文件描述符。
2.4 创建权限、mode 与 umask
当 flags 中有 O_CREAT 时,需要传第三个参数:
int fd = open("log.txt",
O_CREAT | O_WRONLY | O_TRUNC,
0666);
0666 前面的 0 表示八进制。三组 6 分别对应文件所有者、同组用户和其他用户的“读 + 写”权限。
但新文件的最终权限并不一定就是 0666,因为进程还受到 umask 影响。可以把关系简化为:最终权限 = mode & ~umask。
如果实验中希望暂时排除 umask 的影响,可以调用:
umask(0);
不过在真实程序中不应随意修改进程的 umask,更不应为了图省事给所有文件开放过宽权限。
2.5 覆盖写、追加写和只读
三种常见打开方式要分清:
// 只读:文件必须已经存在
open("log.txt", O_RDONLY);
// 只写;不存在则创建;存在则先清空
open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
// 只写;不存在则创建;每次写入都追加到文件末尾
open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
O_TRUNC 和 O_APPEND 解决的是两个不同问题:
O_TRUNC在成功打开已有普通文件时把长度截断为0;O_APPEND让每次写入都定位到文件末尾,重点不只是“初次打开时把位置挪到末尾”。
只写 O_CREAT | O_WRONLY 并不会自动清空旧内容。如果新写入的数据比旧内容短,旧文件尾部可能残留。
3. read 与 write:系统只负责搬运字节
3.1 第一版:打开文件并写入
先用最小流程跑通“打开—写入—关闭”:
const char *msg = "abcd";
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
write(fd, msg, strlen(msg));
close(fd);
write 的核心参数是:
ssize_t write(int fd, const void *buf, size_t count);
fd:写到哪个已打开对象;buf:数据从哪块内存开始;count:最多写多少字节;- 返回值:实际写入的字节数,失败返回
-1。
这里最重要的不是字符串,而是“内存地址 + 字节数”。内核并不知道 msg 是一句话,它只按要求搬运字节。
严格地说,一次 write 也不保证永远写完全部 count 字节。对普通阻塞文件常常会一次完成,但可靠代码仍应根据返回值处理短写和错误。
3.2 第二版:区分二进制整数与文本整数
假设有:
int a = 1234567;
直接写:
write(fd, &a, sizeof(a));
写入的是这个整数在内存中的二进制表示,而不是字符 '1'、'2'、'3'……用文本编辑器打开时通常不会看到“1234567”。
如果想写入人类可读的十进制文本,需要先格式化:
int a = 1234567;
char buffer[16];
snprintf(buffer, sizeof(buffer), "%d", a);
write(fd, buffer, strlen(buffer));
这一步体现了系统调用与库函数的分工:
snprintf负责把整数转换成字符;write负责把这些字符对应的字节写入目标。
💡 提示: write 不理解“整数”“字符串”“结构体”这些高级语义。如何解释那段内存,是程序和数据格式协议自己的责任。
3.3 第三版:用返回值驱动读取循环
read 的基本形式是:
ssize_t read(int fd, void *buf, size_t count);
一个安全的文本读取循环可以写成:
while(1)
{
char buffer[64];
int n = read(fd, buffer, sizeof(buffer)-1);
if(n > 0)
{
buffer[n] = 0;
printf("%s", buffer);
}
else if(n == 0)
{
break;
}
else
{
perror("read");
break;
}
}
返回值的三种情况就是循环的控制信号:
n > 0:本次读到了n个字节;n == 0:到达文件末尾;n < 0:读取失败。
之所以使用 sizeof(buffer) - 1,是为了给结尾的 '\0' 留一个位置。read 只返回字节,不会自动把文本变成 C 字符串;如果后面使用 %s,就必须自己补终止符。
3.4 为什么还要有 FILE *
系统调用使用整数文件描述符,而标准 I/O 使用 FILE *:
FILE *fp = fopen("log.txt", "w");
fprintf(fp, "%d\n", 123);
fclose(fp);
FILE 是标准库维护的流对象。具体成员属于实现细节,不能依赖某个实现中的私有字段;从概念上看,它通常需要维护:
- 底层文件描述符;
- 用户态缓冲区及当前位置;
- 读写状态;
- 错误标记、文件结束标记等。
因此可以把二者理解为:FILE * 经过标准库封装关联到 fd,fd 再通过进程描述符表找到内核打开文件对象。
标准库缓冲会提高小块 I/O 的效率,但也带来一个现象:printf 和 write 混用时,可见顺序可能受缓冲刷新时机影响。程序正常结束、缓冲区满、满足特定行缓冲条件或主动 fflush 时,标准库才会把相应内容继续向下提交。这里只用这个事实解释可见顺序,不展开缓冲策略的内部实现。
FILE * 还有一层很重要的价值:可移植性。C 代码使用统一的标准库接口,不同平台的 C 库再分别连接各自的底层系统能力。也就是说,语言层接口负责给程序员稳定、方便的抽象,操作系统接口负责完成真正的资源访问。
3.5 回到 fread 和 fwrite:返回值到底表示什么
标准 I/O 的二进制读写接口形式是:
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
这里容易把 size、nmemb 和返回值混在一起:
size表示每个数据单元的字节数;nmemb表示期望处理多少个数据单元;- 返回值表示成功处理的数据单元个数,不是固定意义上的“字节数”。
只有在 size == 1 时,返回的单元个数才恰好等于字节数。例如:
size_t n = fread(buffer, 1, sizeof(buffer) - 1, fp);
此时 n 可以直接理解为读到的字节数,并且还能给 buffer[n] = '\0' 留出位置。如果写成:
size_t n = fread(array, sizeof(int), 10, fp);
那么返回 n == 3 表示成功读到 3 个 int,对应的字节数是 3 * sizeof(int)。
⚠️ 易错点: fread 返回值小于 nmemb 时,可能是到达文件末尾,也可能是发生错误,需要结合 feof 和 ferror 判断,不能只靠一个 else 把两种情况混为一谈。
4. 文件描述符的本质
4.1 0、1、2 是什么
一个普通进程启动后,通常已经拥有三个标准文件描述符:0 是标准输入 stdin,1 是标准输出 stdout,2 是标准错误 stderr。
在终端中运行程序时,它们通常连接到当前终端:
- 从键盘来的内容可由
0读取; - 普通输出通过
1显示; - 错误信息通过
2显示。
这也是下面两段代码能默认与终端交互的原因:
read(0, buffer, sizeof(buffer));
write(1, "hello\n", 6);
fgets(buffer, sizeof(buffer), stdin);
fprintf(stdout, "hello\n");
4.2 进程怎样找到已打开文件
文件描述符不是文件内容,也不是全系统统一的“文件身份证”。它更像当前进程文件描述符表中的下标。

用简化模型表示:进程持有文件管理结构,其中的描述符表以 0、1、2、3 等下标保存指向不同已打开对象的引用。上面的配图展示了这层映射关系。
系统调用收到 fd = 3 后,会在当前进程的描述符表中查找第 3 项,然后找到相应的内核打开文件对象。图中继续画出了文件缓冲区、inode 元数据和磁盘文件,是为了把访问路径接完整:
struct file描述“一次打开”产生的状态,例如打开方式和读写位置;- 文件内容需要经过内核管理的数据通路到达磁盘内容;
- inode 一侧代表文件属性和身份等元数据信息;
- fd 不是磁盘地址,也不会越过这些中间对象直接指向磁盘内容。
这个模型能解释几个现象:
- 不同进程都可以拥有
fd = 3,但它们可能指向完全不同的文件; - 同一进程的两个 fd 可以指向同一个打开文件对象;
- 重定向只需修改表项的指向,不必修改
printf或目标程序的业务逻辑。
这里是为了理解而做的简化。不同内核版本的真实结构成员和实现细节可能变化,缓冲与 inode 的实际关系也比一张图复杂,不应把示意图当成稳定的 ABI(应用二进制接口)。
4.3 文件描述符的分配规则
Linux 通常从当前进程尚未使用的最小非负整数开始分配新文件描述符。
正常情况下,0、1、2 已被占用,所以第一个额外打开的文件常得到 3。继续打开可能得到 4、5……
如果先关闭标准输出:
close(1);
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
此时最小空位是 1,新文件很可能就获得 fd = 1。这个“最小空位优先”的规则,是早期重定向实验成立的关键。
⚠️ 易错点: “通常返回 3”只是标准描述符都存在时的常见现象,不是 open 的固定承诺。
5. 重定向:不改输出代码,只改描述符指向
5.1 先关闭再打开的实验
假设程序一直这样输出:
printf("hello bit\n");
fprintf(stdout, "hello stdout\n");
如果在输出前执行:
close(1);
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
新文件占据描述符 1 后,原来通过标准输出写出的内容就会进入 log.txt。输出语句完全没有变化,变化的是“描述符 1 对应谁”。
这就是输出重定向的本质:重定向前 1 指向终端,重定向后 1 指向 log.txt。
这个实验能证明原理,但真实代码不应依赖“先关掉 1,再希望下一次 open 恰好拿到 1”。更直接、表达意图更清楚的工具是 dup2。
5.2 dup2 的参数方向
函数形式是:
int dup2(int oldfd, int newfd);
我记忆参数方向的方式是:
让
newfd变成oldfd的另一个入口。
例如:
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd, 1);
close(fd);
printf("hello file\n");
dup2(fd, 1) 的效果是让描述符 1 和 fd 指向同一个内核打开文件对象。它不是复制文件内容,也不是把 1 赋值给变量 fd。

如果 newfd 原先已经打开,dup2 会先以原子语义处理其原有指向,再让它复制 oldfd 的打开文件描述。成功时返回 newfd,失败返回 -1。
⚠️ 易错点:
dup2(fd, 1); // 让标准输出去 fd 指向的位置
dup2(1, fd); // 让 fd 去标准输出的位置
这两个调用方向相反,意义完全不同。
5.3 输入、覆盖输出与追加输出
三种重定向只是“打开方式 + 替换哪个标准描述符”的组合。
输入重定向 <
int fd = open(filename, O_RDONLY);
if(fd < 0) exit(1);
dup2(fd, 0);
close(fd);
之后从 stdin、scanf、fgets 或 read(0, ...) 读取的数据都会来自文件,而不是键盘。
这一阶段先关注 main 中已经生效的输入重定向流程:
int main(int argc, char *argv[])
{
if(argc != 2) exit(1);
int fd = open(argv[1], O_RDONLY);
if(fd < 0) exit(1);
dup2(fd, 0);
close(fd);
while(1)
{
char buffer[64];
if(!fgets(buffer, sizeof(buffer), stdin))break;
printf("%s", buffer);
}
return 0;
}
这段只展示 main 的阶段逻辑,依赖 <stdio.h>、<stdlib.h>、<unistd.h>、<fcntl.h> 等头文件,单独复制还不是完整源文件。程序入口要求一个文件名参数;文件被打开后,dup2(fd, 0) 改写标准输入,因此循环仍然使用 fgets(..., stdin),却能够逐行读取文件。
覆盖输出重定向 >
int fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0) exit(2);
dup2(fd, 1);
close(fd);
追加输出重定向 >>
int fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
if(fd < 0) exit(2);
dup2(fd, 1);
close(fd);
三者可以压缩成三句话:
<使用O_RDONLY打开文件,再替换描述符0;>使用O_CREAT | O_WRONLY | O_TRUNC打开文件,再替换描述符1;>>使用O_CREAT | O_WRONLY | O_APPEND打开文件,再替换描述符1。
5.4 引用计数与资源释放
执行:
dup2(fd, 1);
close(fd);
之后重定向仍然有效,因为 fd 和 1 曾共同指向同一个内核打开文件对象。dup2 建立了新的描述符引用,close(fd) 只移除其中一个引用;描述符 1 仍然保留。
可以把它理解为:dup2 之后,fd 和 1 都引用同一个打开文件对象;close(fd) 之后,只剩 1 继续引用它。
内核通过引用关系管理打开对象的生命周期。只有相关引用都释放后,对应的打开文件对象才会进入真正可回收的状态。
6. 把重定向接入自定义 Shell
前面已经解决了单个进程怎样重定向。现在的新问题是:Shell 收到一整行命令后,怎样识别 <、>、>>,又怎样保证 Shell 自己不会被永久改写?
整体流程如下。图中的 fork 同时分出两条路径:父进程直接进入 waitpid,子进程直接进入 open → dup2 → close → execvp,两条路径不存在“父进程先等待、再让子进程开始重定向”的先后关系。

6.1 先把命令与重定向信息分开
首先记录重定向类型和目标文件名:
#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3
int redir = NONE_REDIR;
std::string filename;
解析函数从命令行末尾向前扫描:
void TrimSpace(char cmd[], int &end)
{
while(isspace(cmd[end]))
{
end++;
}
}
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;
TrimSpace(cmd, end);
redir = INPUT_REDIR;
filename = cmd+end;
break;
}
else if(cmd[end] == '>')
{
if(cmd[end-1] == '>')
{
cmd[end-1] = 0;
redir = APPEND_REDIR;
}
else
{
redir = OUTPUT_REDIR;
}
cmd[end++] = 0;
TrimSpace(cmd, end);
filename = cmd+end;
break;
}
else
{
end--;
}
}
}
以命令:
ls -a -l >> file.txt
为例,解析完成后形成三类信息:命令部分是 ls -a -l,重定向类型是 APPEND_REDIR,目标文件名是 file.txt。
代码把重定向符所在位置改成 '\0',于是同一块字符数组的前半部分变成独立命令字符串;filename 保存后半部分。这一步必须发生在普通参数分割之前,否则 >、>> 和文件名会被错误地当成传给目标程序的普通参数。
这一版解析器的目标是讲清主线,不是复刻完整 Bash 语法。它只识别末尾的一处简单重定向,暂不处理引号、转义、多重重定向和无空格的复杂组合。
6.2 再让子进程修改文件描述符
Shell 执行外部命令时会先 fork。重定向应该放在子进程分支:
int Execute()
{
pid_t id = fork();
if(id == 0)
{
int fd = -1;
if(redir == INPUT_REDIR)
{
fd = open(filename.c_str(), O_RDONLY);
if(fd < 0) exit(1);
dup2(fd,0);
close(fd);
}
else if(redir == OUTPUT_REDIR)
{
fd = open(filename.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0) exit(2);
dup2(fd, 1);
close(fd);
}
else if(redir == APPEND_REDIR)
{
fd = open(filename.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0666);
if(fd < 0) exit(2);
dup2(fd, 1);
close(fd);
}
execvp(g_argv[0], g_argv);
exit(1);
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
lastcode = WEXITSTATUS(status);
}
return 0;
}
数据流是:父 Shell 读取命令并执行 fork;子进程根据 redir 打开文件,用 dup2 修改自己的 0 或 1,再调用 execvp;目标程序继承这套描述符关系,而父 Shell 的 0、1、2 保持不变。前面的流程图正对应这一顺序。
之所以只让子进程重定向,是因为父 Shell 还要继续打印提示符并读取下一条命令。如果父进程直接把自己的 1 指向文件,后续提示符也会被写进文件;如果把自己的 0 指向文件,下一轮命令可能不再从键盘读取。
6.3 为什么 exec 不会撤销重定向
execvp 会用新程序替换当前进程的代码和数据,但正常情况下会保留已经打开的文件描述符。于是正确顺序是:fork → 子进程 open → 子进程 dup2 → execvp。
目标程序启动后仍然把 1 当标准输出使用,它不必知道 1 此刻连着终端还是文件。这正是重定向可以对大量既有程序透明生效的原因。
严格地说,如果某个描述符设置了 close-on-exec 标志,它会在成功 exec 时关闭;这里的简化代码没有为标准输入输出设置这种行为。
6.4 内建命令为什么更麻烦
cd、export 这类命令必须由父 Shell 自己执行:
- 子进程执行
cd只会改变子进程工作目录; - 子进程执行
export也不能反向修改父 Shell 的环境。
这意味着,若要支持:
echo hello > log.txt
而 echo 又由当前 Shell 当作内建命令执行,就不能直接套用“只在子进程重定向”的办法。比较完整的思路是:
- 先备份父 Shell 原来的标准描述符,例如用
dup(1)获得一个新的描述符入口; - 对父 Shell 临时执行
dup2; - 运行内建命令;
- 用
dup2(backup_fd, 1)从备份恢复标准描述符; - 关闭备份。
当前实现虽然能识别重定向符,但内建命令在 Execute 之前执行,还没有完成这套“保存—重定向—恢复”机制。因此,外部命令重定向是本阶段已经跑通的主线,内建命令重定向仍属于明确的后续完善点。
6.5 构建方式
文件接口实验使用一个很小的 Makefile。
文件名:openfile/Makefile
myfile:myfile.c
gcc -o $@ $^
.PHONY:clean
clean:
rm -f myfile
在 openfile 目录中执行:
make
./myfile input.txt
make clean
其中 $@ 代表目标名 myfile,$^ 代表全部依赖文件,这里就是 myfile.c。当前生效阶段接收一个输入文件名,通过 dup2(fd, 0) 让 fgets(stdin) 改从文件读取。
自定义 Shell 的构建规则如下。
文件名:myshell/Makefile
myshell:myshell.cc
g++ -o $@ $^ -std=c++11 #-std=c99
.PHONY:clean
clean:
rm -f myshell
在 myshell 目录中执行:
make
./myshell
make clean
该程序从 main 进入循环,依次打印提示符、读取命令行、识别重定向、解析参数、检查内建命令,再执行外部命令。重定向新增逻辑依赖原有 Shell 的命令读取、参数表、fork、execvp 和 waitpid 流程。
6.6 当前实现的边界
我把这份 Shell 当成学习文件描述符的实验,而不是完整命令解释器。当前边界主要有:
- 只处理
<、>、>>三种简单重定向; - 一次只识别一处重定向;
- 不支持管道
|、错误重定向2>、描述符复制2>&1; - 不完整处理引号、反斜杠转义和文件名中的特殊字符;
open、dup2、close、fork、waitpid的错误处理还比较简化;- 内建命令的重定向保存与恢复尚未实现;
cd -、cd ~等分支仍有未完成项;waitpid的状态解释没有覆盖信号终止等全部情况。
明确边界很重要:代码能证明“解析重定向 → 子进程改 fd → exec 继承”的机制,但不能据此声称已经实现 Bash 的完整语法。
6.7 常见误区
误区一:open 成功后返回值一定大于 0
成功条件是 fd >= 0。如果 0 恰好空闲,open 可以返回 0。
误区二:文件描述符是全系统统一的文件编号
fd 是相对于当前进程描述符表的索引。两个进程中的 3 没有必然关系。
误区三:dup2 会复制文件内容
它复制的是打开文件描述关系,让两个描述符可以指向同一个内核打开对象,不复制磁盘数据。
误区四:dup2(1, fd) 与 dup2(fd, 1) 一样
参数方向决定谁覆盖谁。要做输出重定向,通常需要让 1 变成 fd 的副本,即 dup2(fd, 1)。
误区五:O_CREAT | O_WRONLY 会自动清空旧文件
不会。需要覆盖写时还要加 O_TRUNC;需要追加时加 O_APPEND。
误区六:write 会理解整数和字符串
write 只搬运内存中的字节。人类可读文本需要先完成格式转换。
误区七:关闭原 fd 会让重定向失效
只要标准描述符仍然引用同一个打开文件对象,关闭原 fd 不会使重定向失效。
误区八:printf 与 write 的可见顺序永远一致
printf 经过标准库缓冲,write 直接进入系统调用层。混用时要考虑 fflush、进程退出和缓冲模式。
误区九:exec 会自动恢复标准输入输出
通常不会。未设置 close-on-exec 的文件描述符会被新程序继承,这正是 Shell 重定向能工作的基础。
误区十:能识别 > 就等于支持完整 Shell 重定向
完整 Shell 还要处理引号、转义、多重重定向、错误输出、管道和内建命令恢复等问题。简单扫描只适合说明核心机制。
总结
这一节真正需要串起来的不是几个孤立 API,而是一条完整因果链:
- 应用程序不能直接控制文件系统和硬件,所以通过系统调用请求内核服务;
open用位标志组合打开要求,成功后返回当前进程中的文件描述符;- fd 是进程描述符表的索引,表项再指向内核维护的打开文件对象;
read、write按“内存地址 + 字节数”搬运数据,不负责理解高级类型;FILE *在 fd 之上增加缓冲、格式化和状态管理;- 标准输入、标准输出、标准错误通常对应
0、1、2; - 重定向的本质是改变这些标准描述符的指向;
dup2(oldfd, newfd)让newfd成为oldfd的另一个入口;- Shell 在子进程中先完成
open + dup2,再exec目标程序,就能让重定向对目标程序透明生效; - 内建命令在父 Shell 内执行,必须额外处理描述符的备份和恢复。
只要牢牢记住“程序认 fd,fd 的指向由内核描述符表决定”,open、dup2、输入输出重定向和 Shell 执行流程就不再是零散知识。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)