写在前面

以前我第一次看到 openreadwriteclose 时,很容易把它们当成另一套“更难用的文件函数”。后来顺着一次文件访问的路径往下追,我才发现:真正的主角不是函数名,而是进程、文件描述符表和内核打开文件对象之间的关系。

这条主线可以压缩成一句话:

进程不直接拿着文件操作,而是先拿到一个文件描述符;重定向不改变程序的输出语句,只改变某个文件描述符指向谁。

这篇笔记从“为什么文件访问必须经过操作系统”出发,依次走过 open 的位标志、read/write 的字节语义、FILE * 的封装、fd 的数组下标本质、dup2 重定向,最后把 <>>> 接入一个简化版 Shell。我的目标不是背 API,而是能从任意一行代码反推出它在进程和内核中改变了什么。

知识框架

在这里插入图片描述

目录

1. 系统文件接口:程序为什么要经过操作系统

1.1 文件操作为什么不能绕过内核

文件通常保存在磁盘、SSD 等外设中。应用进程运行在用户态,不能随意操作硬件,也不能直接访问内核维护的文件系统数据结构。否则一个普通程序就可能越权读取别人的文件、破坏磁盘数据,甚至让整个系统失去稳定性。

因此,应用程序必须向操作系统提出请求:

  1. 我要打开哪个路径;
  2. 我准备怎样使用它;
  3. 我要读取或写入多少字节;
  4. 使用完毕后我要释放这个打开关系。

Linux 提供的基本系统文件接口正好对应这四件事:

open();   // 打开或创建文件
read();   // 从已打开对象读取字节
write();  // 向已打开对象写入字节
close();  // 释放一个文件描述符

这里的“系统调用”可以理解为应用程序进入内核、请求操作系统服务的一条受控入口。程序不能越过这条入口直接摆弄内核对象。

1.2 系统调用与库函数的关系

C 语言还提供了 fopenfreadfwritefprintffclose 等库函数。它们不是和系统调用争抢同一位置,而是位于更高一层。调用会依次经过:应用代码 → C 标准库接口 → Linux 系统调用 → 内核文件系统与设备。

库函数的价值主要有两点:

  • 更方便:格式化输入输出、缓冲区管理、字符流操作更容易使用;
  • 更可移植:ISO C 规定了标准库接口,而 openreadwrite 属于 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 位,所以仍然是 0001THREE_FLAG1 << 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_RDONLYO_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_TRUNCO_APPEND 表达的是相反的写入意图。前者先清空旧内容,后者保留旧内容并在末尾写。二者不应该为了“保险”同时使用。

检查 O_CREATO_TRUNCO_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_RDONLYO_WRONLYO_RDWR 比较。

所以 O_CREAT | O_WRONLY | O_TRUNC 的含义不是“任意三个开关”,而是:先选择三种访问模式之一的 O_WRONLY,再附加 O_CREATO_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 开始,每次把不同的标志组合传给 PrintPrint 中使用的是多个独立的 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 对应的错误原因打印出来。

⚠️ 易错点: 成功返回值是“非负”,不保证一定大于 00 本身就是合法文件描述符。

2.4 创建权限、modeumask

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_TRUNCO_APPEND 解决的是两个不同问题:

  • O_TRUNC 在成功打开已有普通文件时把长度截断为 0
  • O_APPEND 让每次写入都定位到文件末尾,重点不只是“初次打开时把位置挪到末尾”。

只写 O_CREAT | O_WRONLY 并不会自动清空旧内容。如果新写入的数据比旧内容短,旧文件尾部可能残留。

3. readwrite:系统只负责搬运字节

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));

这一步体现了系统调用与库函数的分工:

  1. snprintf 负责把整数转换成字符;
  2. 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 的效率,但也带来一个现象:printfwrite 混用时,可见顺序可能受缓冲刷新时机影响。程序正常结束、缓冲区满、满足特定行缓冲条件或主动 fflush 时,标准库才会把相应内容继续向下提交。这里只用这个事实解释可见顺序,不展开缓冲策略的内部实现。

FILE * 还有一层很重要的价值:可移植性。C 代码使用统一的标准库接口,不同平台的 C 库再分别连接各自的底层系统能力。也就是说,语言层接口负责给程序员稳定、方便的抽象,操作系统接口负责完成真正的资源访问。

3.5 回到 freadfwrite:返回值到底表示什么

标准 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);

这里容易把 sizenmemb 和返回值混在一起:

  • 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 时,可能是到达文件末尾,也可能是发生错误,需要结合 feofferror 判断,不能只靠一个 else 把两种情况混为一谈。

4. 文件描述符的本质

4.1 012 是什么

一个普通进程启动后,通常已经拥有三个标准文件描述符:0 是标准输入 stdin1 是标准输出 stdout2 是标准错误 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 进程怎样找到已打开文件

文件描述符不是文件内容,也不是全系统统一的“文件身份证”。它更像当前进程文件描述符表中的下标。

在这里插入图片描述

用简化模型表示:进程持有文件管理结构,其中的描述符表以 0123 等下标保存指向不同已打开对象的引用。上面的配图展示了这层映射关系。

系统调用收到 fd = 3 后,会在当前进程的描述符表中查找第 3 项,然后找到相应的内核打开文件对象。图中继续画出了文件缓冲区、inode 元数据和磁盘文件,是为了把访问路径接完整:

  • struct file 描述“一次打开”产生的状态,例如打开方式和读写位置;
  • 文件内容需要经过内核管理的数据通路到达磁盘内容;
  • inode 一侧代表文件属性和身份等元数据信息;
  • fd 不是磁盘地址,也不会越过这些中间对象直接指向磁盘内容。

这个模型能解释几个现象:

  • 不同进程都可以拥有 fd = 3,但它们可能指向完全不同的文件;
  • 同一进程的两个 fd 可以指向同一个打开文件对象;
  • 重定向只需修改表项的指向,不必修改 printf 或目标程序的业务逻辑。

这里是为了理解而做的简化。不同内核版本的真实结构成员和实现细节可能变化,缓冲与 inode 的实际关系也比一张图复杂,不应把示意图当成稳定的 ABI(应用二进制接口)。

4.3 文件描述符的分配规则

Linux 通常从当前进程尚未使用的最小非负整数开始分配新文件描述符。

正常情况下,012 已被占用,所以第一个额外打开的文件常得到 3。继续打开可能得到 45……

如果先关闭标准输出:

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) 的效果是让描述符 1fd 指向同一个内核打开文件对象。它不是复制文件内容,也不是把 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);

之后从 stdinscanffgetsread(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);

之后重定向仍然有效,因为 fd1 曾共同指向同一个内核打开文件对象。dup2 建立了新的描述符引用,close(fd) 只移除其中一个引用;描述符 1 仍然保留。

可以把它理解为:dup2 之后,fd1 都引用同一个打开文件对象;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 修改自己的 01,再调用 execvp;目标程序继承这套描述符关系,而父 Shell 的 012 保持不变。前面的流程图正对应这一顺序。

之所以只让子进程重定向,是因为父 Shell 还要继续打印提示符并读取下一条命令。如果父进程直接把自己的 1 指向文件,后续提示符也会被写进文件;如果把自己的 0 指向文件,下一轮命令可能不再从键盘读取。

6.3 为什么 exec 不会撤销重定向

execvp 会用新程序替换当前进程的代码和数据,但正常情况下会保留已经打开的文件描述符。于是正确顺序是:fork → 子进程 open → 子进程 dup2execvp

目标程序启动后仍然把 1 当标准输出使用,它不必知道 1 此刻连着终端还是文件。这正是重定向可以对大量既有程序透明生效的原因。

严格地说,如果某个描述符设置了 close-on-exec 标志,它会在成功 exec 时关闭;这里的简化代码没有为标准输入输出设置这种行为。

6.4 内建命令为什么更麻烦

cdexport 这类命令必须由父 Shell 自己执行:

  • 子进程执行 cd 只会改变子进程工作目录;
  • 子进程执行 export 也不能反向修改父 Shell 的环境。

这意味着,若要支持:

echo hello > log.txt

echo 又由当前 Shell 当作内建命令执行,就不能直接套用“只在子进程重定向”的办法。比较完整的思路是:

  1. 先备份父 Shell 原来的标准描述符,例如用 dup(1) 获得一个新的描述符入口;
  2. 对父 Shell 临时执行 dup2
  3. 运行内建命令;
  4. dup2(backup_fd, 1) 从备份恢复标准描述符;
  5. 关闭备份。

当前实现虽然能识别重定向符,但内建命令在 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 的命令读取、参数表、forkexecvpwaitpid 流程。

6.6 当前实现的边界

我把这份 Shell 当成学习文件描述符的实验,而不是完整命令解释器。当前边界主要有:

  • 只处理 <>>> 三种简单重定向;
  • 一次只识别一处重定向;
  • 不支持管道 |、错误重定向 2>、描述符复制 2>&1
  • 不完整处理引号、反斜杠转义和文件名中的特殊字符;
  • opendup2closeforkwaitpid 的错误处理还比较简化;
  • 内建命令的重定向保存与恢复尚未实现;
  • 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 不会使重定向失效。

误区八:printfwrite 的可见顺序永远一致

printf 经过标准库缓冲,write 直接进入系统调用层。混用时要考虑 fflush、进程退出和缓冲模式。

误区九:exec 会自动恢复标准输入输出

通常不会。未设置 close-on-exec 的文件描述符会被新程序继承,这正是 Shell 重定向能工作的基础。

误区十:能识别 > 就等于支持完整 Shell 重定向

完整 Shell 还要处理引号、转义、多重重定向、错误输出、管道和内建命令恢复等问题。简单扫描只适合说明核心机制。

总结

这一节真正需要串起来的不是几个孤立 API,而是一条完整因果链:

  1. 应用程序不能直接控制文件系统和硬件,所以通过系统调用请求内核服务;
  2. open 用位标志组合打开要求,成功后返回当前进程中的文件描述符;
  3. fd 是进程描述符表的索引,表项再指向内核维护的打开文件对象;
  4. readwrite 按“内存地址 + 字节数”搬运数据,不负责理解高级类型;
  5. FILE * 在 fd 之上增加缓冲、格式化和状态管理;
  6. 标准输入、标准输出、标准错误通常对应 012
  7. 重定向的本质是改变这些标准描述符的指向;
  8. dup2(oldfd, newfd)newfd 成为 oldfd 的另一个入口;
  9. Shell 在子进程中先完成 open + dup2,再 exec 目标程序,就能让重定向对目标程序透明生效;
  10. 内建命令在父 Shell 内执行,必须额外处理描述符的备份和恢复。

只要牢牢记住“程序认 fd,fd 的指向由内核描述符表决定”,opendup2、输入输出重定向和 Shell 执行流程就不再是零散知识。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐