【Linux】基础IO超万字解析(文件描述符)(2)

一.文件描述符 fd

通过对open函数的学习,我们知道了⽂件描述符就是⼀个⼩整数

1-1 0 & 1 & 2

• Linux进程默认情况下会有3个缺省打开的⽂件描述符,分别是标准输⼊0, 标准输出1, 标准错误2.

• 0,1,2对应的物理设备⼀般是:键盘,显⽰器,显⽰器

所以输⼊输出还可以采⽤如下⽅式:

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
    ssize_t s = read(0, buf, sizeof(buf));  // 从键盘读取最多1024字节
    if(s > 0){                              // 如果读到了内容
    buf[s] = 0;                         // 添加字符串结束符
    write(1, buf, strlen(buf));         // 输出到标准输出(屏幕)
    write(2, buf, strlen(buf));         // 输出到标准错误(也是屏幕)
    }
    return 0;
}

【补充】

唯一可能用 strlen 的场景

如果你不知道 数据长度,只知道它以 \0 结尾(比如来自 strcpy 的字符串),这时才需要 strlen

但这里你明明知道 sread 刚返回的),没必要扔掉这个信息再重新去数一遍。

【解释】

|------------------|------------------------------------|
| task_struct | Linux 内核中描述进程的核心结构体 |
| *files | 指向 files_struct 的指针 |
| files_struct | 管理进程打开的所有文件的结构 |
| fd_array[] | 文件描述符数组,下标 = 文件描述符值 |
| 0,1,2,3... | 文件描述符编号 |
| 标准输入/输出/错误 | 默认打开的 0(stdin)、1(stdout)、2(stderr) |
| myfile(新打开的文件) | 通过 open() 获得新描述符(如 3) |
| file 对象 | 内核中代表一次打开的文件实例 |
| inode 元信息 | 文件在磁盘上的元数据(大小、权限、时 |

【补充】

概念 说明
进程 → 文件 通过 fd → fd_array[fd]fileinode
多个 fd → 同一个 file dup() / fork() 后父子进程共享 file(包括 f_pos
多个 file → 同一个 inode 同一文件被多次打开 (如两个独立 open)→ 各有各的 f_pos
磁盘 inode(唯一) 一个文件只有一个磁盘 inode
内存 inode 可能多个 file 指向同一个内存 inode 对象
  • ⽽现在知道,⽂件描述符就是从0开始的⼩整数。
  • 一个程序启动成为进程后,内核会为该进程维护一个 task_struct 结构体(包含进程状态、PID等信息),其中包含一个指向 files_struct 的指针。files_struct 内部有一个 fd_array[] 数组,数组的下标就是用户态所见的文件描述符(如0、1、2、3...),数组元素则是指向内核 file 对象的指针。每个 file 对象代表一次打开的文件实例 (同一文件打开多次会产生多个独立的 file 对象),内部保存了当前读写位置 f_pos、引用计数 f_count(用于共享和延迟释放)、文件操作函数表 f_op 以及指向 inode 的指针。inode 存储了文件的元信息(如大小 i_size、权限 i_mode、所有者 i_uid/gid、时间戳等),一个磁盘文件唯一对应一个 inode。用户态调用 write(fd, ...) 等系统调用时,内核会通过当前进程的 task_struct 找到 files_struct,再用文件描述符作为索引从 fd_array[] 中取出对应的 file 对象,最终通过 file 对象访问 inode 并执行真正的设备或磁盘读写操作。图中还标注了默认打开的标准输入(fd 0)、标准输出(fd 1)、标准错误(fd 2)以及用户自己打开的新文件(如myfile,通常获得fd 3),并通过 write() 调用示例展示了从用户态 FILE*fd 到内核 file 对象再到内存/磁盘数据的完整路径。

对于以上原理结论我们可通过内核源码验证:

⾸先要找到 task_struct 结构体在内核中为位置,地址为: /usr/src/kernels/3.10.0-

1160.71.1.el7.x86_64/include/linux/sched.h (3.10.0-1160.71.1.el7.x86_64是内核版本,可使⽤ uname -a ⾃⾏查看服务器配置, 因为这个⽂件夹只有⼀个,所以也不⽤刻意去分辨,

内核版本其实也随意)

  • 要查看内容可直接⽤vscode在windows下打开内核源代码
  • 相关结构体所在位置

◦ struct task_struct : /usr/src/kernels/3.10.0-

1160.71.1.el7.x86_64/include/linux/sched.h

◦ struct files_struct : /usr/src/kernels/3.10.0-

1160.71.1.el7.x86_64/include/linux/fdtable.h

◦ struct file : /usr/src/kernels/3.10.0-

1160.71.1.el7.x86_64/include/linux/fs.h


1-2⽂件描述符的分配规则

运行结果:


关闭0

  1. 有 O_CREAT → 必须写权限参数(0666),不能省

  2. 无 O_CREAT → 可以不写,写了也没用

  3. 0666 只管新建文件权限,老文件权限不受影响

  4. 配合 umask(0) ,才能拿到 rw‑rw‑rw‑ 权限


关闭2

文件描述符分配规则:在 files_struct 数组中,找到当前没有被使用的最小且没有被占用的文件描述符"

  • files_struct 中存放当前进程打开的文件指针。

  • 关闭 2 后,2 这个位置就空了。

  • 下次 open 时,系统会从 0 开始扫描,找到第一个空闲位置 2 来用,即使 2 原先对应的是标准错误。

现是结果是: fd: 0 或者 fd 2 ,可⻅,⽂件描述符的分配规则:在files_struct数组当中,找到当前没有被使⽤的最⼩的⼀个下标,作为新的⽂件描述符


  • 屏幕:无输出(因为标准输出已被关闭)close(fd)

  • log.txt 文件中:有内容 fd: 1

两段代码最终 log.txt 都有内容,但本段代码通过 fflush 主动刷新缓冲区,更可靠。


1-3"文件描述符分配规则"的底层原理 &&fopen 和 open 之间耦合关系

fopen 和 open 之间耦合关系 的观点:

核心耦合关系:

用户层 内核层
FILE*(C库) struct file(内核)
_fileno(fd编号) fd_array[fd]
fopen() 调用 open() open() 分配 fd

耦合关系详解

1. fopen 内部会调用 open

cpp 复制代码
FILE *fopen(const char *path, const char *mode)
{
    // 1. 分配 FILE 结构体
    // 2. 调用 open() 系统调用获取 fd
    int fd = open(path, flags, mode);
    // 3. 将 fd 存入 FILE->_fileno
    // 4. 返回 FILE*
}
2. 数据流动

fread/fwrite → FILE* 缓冲区 → write/read 系统调用 → fd → 内核文件

3. 关键耦合点

  • FILE* 中的 _fileno 字段就是内核 fd_array 的下标

  • 关闭 fd 会影响对应的 FILE*(因为 _fileno 变成无效)

  • 关闭 FILE*fclose)也会关闭底层 fd


补充

1. 缓冲区的独立性

  • FILE* 有自己的用户态缓冲区_IO_read_ptr_IO_write_ptr 等)

  • open 直接返回的 fd 没有用户态缓冲区

  • 这就是为什么 printf 需要 fflush,而 write 不需要

  • printf 需要 fflush 才能保证落盘

  • write 不需要刷新,直接进内核

2. 混合使用的风险

cpp 复制代码
int fd = open("log.txt", O_WRONLY);
FILE* fp = fdopen(fd, "w");  // 将 fd 包装成 FILE*
fprintf(fp, "hello");         // 通过 FILE* 写
write(fd, "world", 5);        // 直接通过 fd 写 ------ 可能乱序!

补充点 :混用 FILE*fd 会导致缓冲区问题,因为 FILE* 有缓冲,fd 直接写穿透。

3. 关闭的层次

操作 效果
close(fd) 只关内核 fd,FILE* 缓冲区还在,_fileno 变脏
fclose(fp) 先刷新缓冲区,再 close(fd),再释放 FILE*
fflush(fp) 只刷新缓冲区,不关 fd

总结:

  • fopenopen 通过文件描述符 耦合,但 FILE* 多了一层用户态缓冲区

  • 这层缓冲区带来了便利(printf 格式化),也带来了陷阱(需要 fflush

  • 混用时记住:close 关 fd,fclose 刷缓冲区再关,fflush 只刷不关

从 close(1) 到 printf 失效:一次读懂文件描述符与用户态缓冲区

贴出你的第一段代码:

cpp 复制代码
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fd: %d\n", fd);

现象:

  • 屏幕无输出

  • log.txt 里有没有内容,取决于是否手动 close(fd)fflush

为什么?


1.文件描述符分配规则

  • 内核用 fd_array[] 管理打开的文件

  • 分配规则:最小的未使用 fd

  • close(1) 释放 1 → open 返回 1 → fd 1 现在指向 log.txt


2.stdout 与 FILE 结构体

  • printf 写入的是 stdout(类型 FILE*

  • FILE 包含:

    • _fileno(底层 fd,初始为 1)

    • 用户态缓冲区指针(_IO_write_ptr 等)

关键点close(1) 关闭的是内核 fd,不影响 stdout 这个 FILE* 的存在,也不清空它的缓冲区。


3.缓冲区与 \n 的真相

底层 fd 指向 缓冲模式 \n 是否刷新
终端(tty) 行缓冲
普通文件 全缓冲

当 fd 1 从终端变成 log.txt\n 不再触发系统调用,数据留在用户态缓冲区。


4.为什么 close(fd) 会丢数据?

cpp 复制代码
printf(...);      // 数据进 stdout 缓冲区
close(fd);        // 只关内核 fd,不刷新用户态缓冲区
return 0;         // 程序结束,但缓冲区已无机会刷新

close 是系统调用,绕过了 FILE,不知道有缓冲区存在。


5.为什么"不关 fd"反而能写入?

cpp 复制代码
printf(...);      // 数据进缓冲区
return 0;         // exit() 会遍历 _chain 链表,自动刷新所有 FILE*

exit() 会调用 _IO_cleanup → 刷新所有未关闭的 FILE 缓冲区。

这就是你观察到的:

"关闭 fd 则不打印,不关则打印"


6.正确做法:fflushfclose

cpp 复制代码
printf(...);
fflush(stdout);   // 手动刷新缓冲区
close(fd);        // 安全关闭

或者直接用 fclose(stdout) 代替 close(fd)


二.重定向

那如果关闭1呢?看代码:

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
    close(1);
    int fd = open("myfile", O_WRONLY|O_CREAT, 00644);
    if(fd < 0){
    perror("open");
    return 1;
    }
    printf("fd: %d\n", fd);
    fflush(stdout);

    close(fd);
    exit(0);
}

运行结果:

此时,我们发现,本来应该输出到显⽰器上的内容,输出到了⽂件 log.txt当中,其中,fd=1。这种现象叫做输出重定向。常⻅的重定向有: > , >> , <

类型 符号 作用
输出重定向 > 将命令输出写入文件(覆盖)
追加重定向 >> 将命令输出追加到文件末尾
输入重定向 < 从文件读取输入,而不是键盘

那重定向的本质是什么呢?什么是"fd 对应的内容指向"?

每个进程的 files_struct 中有一个 fd_array[],数组的每个元素是一个指针,指向内核中对应的 struct file(代表一个打开的文件/设备)。

cpp 复制代码
fd_array[0] → 指向键盘的 struct file
fd_array[1] → 指向显示器的 struct file
fd_array[2] → 指向显示器的 struct file

重定向就是修改这个指针的指向

cpp 复制代码
// 输出重定向 > output.txt 之后:
fd_array[1] → 指向 output.txt 的 struct file(不再指向显示器)
复制代码
很多人学重定向只记住了 `>`、`>>`、`<` 这些符号,但不知道底层发生了什么。
 直白来说:就是在 OS 内部更改 fd 对应的内容指向。 每个进程都有一个 fd_array[] 数组: fd_array[0]默认指向键盘 fd_array[1]默认指向显示器 fd_array[2]默认指向显示器 假设:执行 ./program > log.txt 时,shell 做的事情是: 1. close(1) ------ 断开与显示器的连接 2. open("log.txt") ------ 打开文件,返回的 fd 恰好是 1(最小未使用) 3. 此后 printf写入 fd 1,实际就写入了 log.txt 这就是重定向的全部秘密。

重定向的底层本质就是修改进程内核中 fd_array[] 数组的指针指向:默认情况下 fd_array[0] 指向键盘的 file 对象,fd_array[1]fd_array[2] 指向显示器的 file 对象;当执行 close(1) 后再 open("log.txt"),系统会分配最小未使用的 fd(即 1),使 fd_array[1] 转而指向 log.txtfile 对象,此后所有往 fd 1 写入的数据(如 printf)都会进入文件而非屏幕;而 dup2(fd, 1) 则是直接让 fd_array[1] 复制 fd_array[fd] 的指针,同样实现重定向。一句话:重定向就是改 fd_array[] 指针,让标准 IO 的下标指向你指定的文件对象

2-1输出重定向 >

定义

将程序的标准输出(stdout,fd 1)从显示器改为写入文件。覆盖模式:如果文件已存在,先清空再写入。

cpp 复制代码
# 创建一个测试程序
echo 'int main() { printf("Hello World\n"); return 0; }' > test.c
gcc test.c -o test

# 正常执行:输出到屏幕
./test
# 输出:Hello World

# 输出重定向:输出到文件
./test > output.txt

# 屏幕无输出,output.txt 内容为:Hello World

# 再次执行:文件被覆盖
./test > output.txt
# output.txt 仍只有一行 Hello World(旧内容被覆盖)

代码模拟重定向底层原理

cpp 复制代码
// 模拟输出重定向:将 stdout 指向文件
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
    // 关闭标准输出(fd 1)
    close(1);
    
    // 打开文件,O_TRUNC 表示清空/覆盖
    int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    
    // 分配规则:最小未使用 fd = 1,所以 fd 得到 1
    printf("fd = %d\n", fd);  // 输出:fd = 1
    
    // 此时 printf 写入 fd 1,实际写入 output.txt
    printf("这是输出重定向的内容\n");
    
    // 数据还在缓冲区,需要刷新
    fflush(stdout);
    close(fd);
    
    return 0;
}

执行结果

cpp 复制代码
屏幕:无输出(stdout 已被重定向)

output.txt 内容:fd = 1 + 这是输出重定向的内容

使用 dup2 实现

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
    int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    
    // dup2(fd, 1):将 fd 复制到 1,让 1 也指向 output.txt
    dup2(fd, 1);
    close(fd);  // 原 fd 可关闭,因为 1 已经指向它
    
    printf("通过 dup2 实现输出重定向\n");
    fflush(stdout);
    
    return 0;
}

> 的 open 标志解析

cpp 复制代码
open("file", O_WRONLY | O_CREAT | O_TRUNC, 0666);
标志 作用
O_WRONLY 只写模式
O_CREAT 文件不存在则创建
O_TRUNC 文件存在则清空(覆盖)
0666 创建时的权限(rw-rw-rw- 再被 umask 限制)

2-2追加重定向 >>

定义

将程序的标准输出(stdout,fd 1)改为追加写入文件末尾,不覆盖原有内容

cpp 复制代码
# 准备初始文件
echo "第一行" > log.txt
cat log.txt
# 输出:第一行

# 追加重定向
echo "第二行" >> log.txt
cat log.txt
# 输出:
# 第一行
# 第二行

# 再次追加
echo "第三行" >> log.txt
cat log.txt
# 输出:
# 第一行
# 第二行
# 第三行

代码模拟追加重定向底层原理

cpp 复制代码
// 模拟追加重定向:追加写入文件末尾
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
    close(1);
    
    // 关键区别:使用 O_APPEND 而不是 O_TRUNC
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    
    printf("这是追加的内容\n");
    printf("另一行追加内容\n");
    
    fflush(stdout);
    close(fd);
    
    return 0;
}

使用 dup2 实现追加重定向

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    dup2(fd, 1);
    close(fd);
    
    printf("通过 dup2 实现追加重定向\n");
    fflush(stdout);
    
    return 0;
}

>> 的 open 标志解析

cpp 复制代码
open("file", O_WRONLY | O_CREAT | O_APPEND, 0666);
标志 作用
O_WRONLY 只写模式
O_CREAT 文件不存在则创建
O_APPEND 每次写入自动定位到文件末尾(关键区别)
0666 创建时的权限

O_APPEND 的内核保证

css 复制代码
// 没有 O_APPEND 时(多进程问题)
// 进程 A:lseek 到末尾,准备写入
// 进程 B:lseek 到末尾,准备写入
// 进程 A:写入 → 覆盖了 B 的位置?
// 结果:数据互相覆盖

// 有 O_APPEND 时
// 内核保证每次 write 都是原子性地定位到末尾再写入
// 多进程追加写入不会互相覆盖

2-3输入重定向 <

定义

将程序的标准输入(stdin,fd 0)从键盘改为从文件读取。

命令行示例

cpp 复制代码
# 准备输入文件
echo "hello from file" > input.txt

# 编译读取程序
cat > read.c << 'EOF'
#include <stdio.h>
int main() {
    char str[100];
    scanf("%s", str);
    printf("读取到: %s\n", str);
    return 0;
}
EOF
gcc read.c -o read

# 输入重定向
./read < input.txt
# 输出:读取到: hello

代码模拟输入重定向底层原理

cpp 复制代码
// 模拟输入重定向:从文件读取输入
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
    close(0);  // 关闭标准输入
    
    int fd = open("input.txt", O_RDONLY);
    // fd 得到 0(最小未使用)
    
    char str[100];
    scanf("%s", str);  // 实际从 input.txt 读取
    printf("从文件读取到: %s\n", str);
    
    close(fd);
    return 0;
}

使用 dup2 实现输入重定向

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
    int fd = open("input.txt", O_RDONLY);
    dup2(fd, 0);  // 让 fd 0 指向 input.txt
    close(fd);
    
    char str[100];
    fgets(str, sizeof(str), stdin);  // 从 input.txt 读取
    printf("读取: %s", str);
    
    return 0;
}

2-4三种重定向对比

类型 符号 文件描述符 open 标志 作用
输入重定向 < 0 (stdin) O_RDONLY 从文件读输入
输出重定向 > 1 (stdout) `O_WRONLY O_CREAT
追加重定向 >> 1 (stdout) `O_WRONLY O_CREAT

O_TRUNC vs O_APPEND 的内核区别:

  • O_TRUNC:打开文件时立即清空内容

  • O_APPEND:每次 write 系统调用前,内核自动把文件偏移量移到末尾

【补充】问题进程替换会影响重定向的结果?

进程替换(exec 族函数)不会影响重定向结果 ------ 重定向在 exec 之前设置,exec 后会保留。

核心原理

cpp 复制代码
// fork 之后,exec 之前设置重定向
pid_t pid = fork();
if (pid == 0) {
    // 子进程:先重定向
    int fd = open("log.txt", O_WRONLY);
    dup2(fd, 1);        // 修改 fd_array[1] 指针
    close(fd);
    
    // 后执行进程替换
    execvp("ls", args); // 新进程会继承 fd_array 的状态!
}

关键exec 族函数会替换进程的代码段、数据段、堆栈 ,但保留文件描述符表 (除非设置了 FD_CLOEXEC 标志)。

2-5dup2 系统调用详解------重定向的核心

1.函数原型与核心理解

cpp 复制代码
#include <unistd.h>
int dup2(int oldfd, int newfd);

核心理解:dup2(oldfd, newfd)newfd 成为 oldfd 的拷贝,最终 newfdoldfd 指向同一个文件。


2.重定向时参数怎么传?

场景 :输出重定向 ./program > log.txt,让标准输出(fd 1)指向文件(fd 3)

错误写法 正确写法
dup2(1, fd) dup2(fd, 1)

为什么? 你想让 1(标准输出) 变成 fd(文件) 的样子,所以 oldfd = fd(源),newfd = 1(目标)。

cpp 复制代码
执行 dup2(fd, 1) 前:         执行后:
fd(3) → log.txt              fd(3) → log.txt
1    → 显示器                1    → log.txt  ← 变成和 fd 一样

3.代码示例

输出重定向(>
cpp 复制代码
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(fd, 1);   // 让 stdout 指向文件
close(fd);     // 原 fd 可关闭,因为 1 已经指向它
printf("写入文件\n");
fflush(stdout);
输入重定向(<
cpp 复制代码
int fd = open("input.txt", O_RDONLY);
dup2(fd, 0);   // 让 stdin 指向文件
close(fd);
scanf("%s", str);  // 从文件读取
追加重定向(>>
cpp 复制代码
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
dup2(fd, 1);   // 和输出重定向写法一样,区别只在 open 的 O_APPEND
close(fd);

4.边界情况

情况 行为
oldfd 无效 调用失败,返回 -1,newfd 不被关闭
oldfd == newfd 不做任何操作,直接返回 newfd
newfd 原本是打开的 dup2自动关闭 newfd,再让它指向 oldfd

5.dup2close+open比较

方式 代码 特点
close+open close(1); open("file", ...); 依赖"最小未使用"分配规则,不够直观
dup2 dup2(fd, 1); 意图明确:直接说"让 1 指向 fd 指向的文件"

推荐使用 dup2,因为它不依赖分配规则,代码意图更清晰。


简单总结

dup2(fd, 1) 让标准输出重定向到文件:关闭 1 原来的指向,然后让 1 指向 fd 指向的文件。

2-6在minishell中添加重定向功能

重定向的底层本质

核心一句话:重定向就是修改进程的 fd_array[] 指针,让标准输入/输出不再指向默认设备,而是指向目标文件。

每个进程在内核中维护一个 files_struct 结构,里面有个 fd_array[] 数组:

cpp 复制代码
默认状态:
fd_array[0] → 键盘设备文件
fd_array[1] → 显示器设备文件  
fd_array[2] → 显示器设备文件

执行 ls -l > list.txt 后:
fd_array[0] → 键盘设备文件
fd_array[1] → list.txt 文件  ← 指针被修改了
fd_array[2] → 显示器设备文件

Shell 实现重定向的三步走:

  1. open() 打开目标文件,获得一个文件描述符(比如 3)

  2. dup2(3, 1) ------ 让 fd_array[1] 指向和 fd 3 一样的文件

  3. close(3) ------ 关闭多余的 fd


dup2 系统调用的底层行为

cpp 复制代码
#include <unistd.h>
int dup2(int oldfd, int newfd);
dup2(oldfd, newfd) 的底层逻辑:


执行前:
fd_array[oldfd] → 文件A
fd_array[newfd] → 文件B

执行 dup2(oldfd, newfd) 时内核做的事:
1. if (fd_array[newfd] 正在使用) 关闭它
2. fd_array[newfd] = fd_array[oldfd]  // 指针复制
3. 文件A 的引用计数 +1

执行后:
fd_array[oldfd] → 文件A
fd_array[newfd] → 文件A  ← 现在指向同一个文件

为什么重定向要用 dup2(fd, 1) 而不是 dup2(1, fd)

cpp 复制代码
// 假设 fd = open("list.txt") 返回 3
// 目标:让 stdout(1) 指向 list.txt

dup2(3, 1)  // 正确:把 fd_array[1] 改成指向 list.txt
dup2(1, 3)  // 错误:把 fd_array[3] 改成指向显示器,1 没变

口诀dup2(oldfd, newfd)newfd 变成 oldfd 的样子。你想让 1 变成文件的样子,所以 oldfd=文件fd, newfd=1


代码逐段解析

1 .数据结构
cpp 复制代码
typedef struct {
    char *input_file;   // 输入重定向的文件名,NULL 表示无
    char *output_file;  // 输出重定向的文件名,NULL 表示无
    int append;         // 0=覆盖(>),1=追加(>>)
} Redir;
2. 解析函数:从命令行提取重定向信息
cpp 复制代码
void parse(char *cmd, char **args, Redir *redir) {
    redir->input_file = NULL;
    redir->output_file = NULL;
    redir->append = 0;
    
    int i = 0;
    char *token = strtok(cmd, " \t\n");
    
    while (token && i < MAX_ARGS - 1) {
        if (strcmp(token, "<") == 0) {
            // 输入重定向:下一个 token 是文件名
            token = strtok(NULL, " \t\n");
            redir->input_file = token;
        }
        else if (strcmp(token, ">") == 0) {
            // 输出重定向(覆盖)
            token = strtok(NULL, " \t\n");
            redir->output_file = token;
            redir->append = 0;
        }
        else if (strcmp(token, ">>") == 0) {
            // 输出重定向(追加)
            token = strtok(NULL, " \t\n");
            redir->output_file = token;
            redir->append = 1;
        }
        else {
            // 普通参数,存入 args 数组
            args[i++] = token;
        }
        token = strtok(NULL, " \t\n");
    }
    args[i] = NULL;  // execvp 需要 NULL 结尾
}

解析过程:

cpp 复制代码
输入: ls -l > list.txt

token="ls"    → args[0]="ls"
token="-l"    → args[1]="-l"  
token=">"     → 识别为输出重定向,下一个 token="list.txt" → output_file="list.txt"
token=NULL    → 结束

结果: args[0]="ls", args[1]="-l", args[2]=NULL
     redir->output_file="list.txt", redir->append=0
3. 执行函数:fork + 重定向 + exec
cpp 复制代码
void execute(char **args, Redir *redir) {
    pid_t pid = fork();
    
    if (pid == 0) {
        // ========== 子进程 ==========
        // 子进程会复制父进程的 fd_array,但修改只影响自己
        
        // 【输入重定向】让 stdin(0) 从文件读取
        if (redir->input_file) {
            int fd = open(redir->input_file, O_RDONLY);
            if (fd < 0) { perror("open input"); exit(1); }
            dup2(fd, 0);   // fd_array[0] 指向文件
            close(fd);     // 关闭原 fd,因为 0 已经指向它了
        }
        
        // 【输出重定向】让 stdout(1) 写入文件
        if (redir->output_file) {
            int flags = O_WRONLY | O_CREAT;
            // 追加模式用 O_APPEND,覆盖模式用 O_TRUNC
            flags |= redir->append ? O_APPEND : O_TRUNC;
            int fd = open(redir->output_file, flags, 0666);
            if (fd < 0) { perror("open output"); exit(1); }
            dup2(fd, 1);   // fd_array[1] 指向文件
            close(fd);
        }
        
        // 执行命令(此时 printf/write 都会写入文件)
        execvp(args[0], args);
        
        // exec 失败才会走到这里
        perror("execvp");
        exit(1);
    } 
    else if (pid > 0) {
        // ========== 父进程 ==========
        // 父进程的 fd 没有被修改,等待子进程结束
        wait(NULL);
    } 
    else {
        perror("fork");
    }
}

为什么重定向代码要放在 fork 之后的子进程?

因为重定向只应该影响当前命令,不应该影响 Shell 本身。如果直接在 Shell 进程里修改 fd,那么执行完命令后 Shell 的标准输出就永久指向文件了,后续命令的输出都会写到文件里。fork 后子进程是 Shell 的副本,子进程修改自己的 fd 不影响父进程。

常见问题与坑

1.为什么重定向后 printf 不输出?

printf 有用户态缓冲区,重定向到文件后变为全缓冲,需要 fflush 或程序正常退出才会写入。但 Shell 中执行的是外部命令(如 ls),它们内部会用 fflush 或直接 write,所以一般没问题。

2.为什么先 dup2close(fd)

dup2 后,fd 和 1 都指向同一个文件。close(fd) 只是减少引用计数,文件不会真正关闭因为 1 还在指向它。不关也可以,但会浪费一个 fd 条目。

3.同时有输入和输出重定向怎么办?

代码中已经支持,分别执行两个 if 块即可,顺序无关紧要。

4.文件打开失败怎么办?

必须检查 open 返回值,失败时打印错误并 exit(1),否则会继续执行危险操作。


三.理解"⼀切皆⽂件"

⾸先,在windows中是⽂件的东西,它们在linux中也是⽂件;其次⼀些在windows中不是⽂件的东西,⽐如进程、磁盘、显⽰器、键盘这样硬件设备也被抽象成了⽂件,你可以使⽤访问⽂件的⽅法访问它们获得信息;甚⾄管道,也是⽂件;将来我们要学习⽹络编程中socket(套接字)这样的东西,使⽤的接⼝跟⽂件接⼝也是⼀致的。这样做最明显的好处是,开发者仅需要使⽤⼀套 API 和开发⼯具,即可调取 Linux 系统中绝⼤部分的资源。举个简单的例⼦,Linux 中⼏乎所有读(读⽂件,读系统状态,读PIPE)的操作都可以⽤read 函数来进⾏;⼏乎所有更改(更改⽂件,更改系统参数,写 PIPE)的操作都可以⽤ write 函数来进⾏。之前我们讲过,当打开⼀个⽂件时,操作系统为了管理所打开的⽂件,都会为这个⽂件创建⼀个file结构体,该结构体定义在 /usr/src/kernels/3.10.0-

1160.71.1.el7.x86_64/include/linux/fs.h 下,以下展⽰了该结构部分我们关系的内容:

cpp 复制代码
struct file {
struct inode
*f_inode;
/* cached value */
const struct file_operations
*f_op;
...
atomic_long_t
f_count;
// 表⽰打开⽂件的引⽤计数,如果有多个⽂件指针指
向它,就会增加f_count的值。
unsigned int
f_flags;
// 表⽰打开⽂件的权限
fmode_t
f_mode;
// 设置对⽂件的访问模式,例如:只读,只写等。所
有的标志在头⽂件<fcntl.h> 中定义
loff_t
f_pos;
// 表⽰当前读写⽂件的位置
...
} __attribute__((aligned(4))); /* lest something weird decides that 2 is OK
*/
  • struct file 中的 f_op 指针指向了⼀个 file_operations 结构体,这个结构体中的成员除了struct module* owner 其余都是函数指针。该结构和 struct file 都在fs.h下。
cpp 复制代码
struct file_operations {
     struct module *owner;
    //指向拥有该模块的指针;
    loff_t (*llseek) (struct file *, loff_t, int);
    //llseek ⽅法⽤作改变⽂件中的当前读/写位置, 并且新位置作为(正的)返回值.
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    //⽤来从设备中获取数据. 在这个位置的⼀个空指针导致 read 系统调⽤以 -
    EINVAL("Invalid argument") 失败. ⼀个⾮负返回值代表了成功读取的字节数( 返回值是⼀个
    "signed size" 类型, 常常是⽬标平台本地的整数类型).
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    //发送数据给设备. 如果 NULL, -EINVAL 返回给调⽤ write 系统调⽤的程序. 如果⾮负,
    返回值代表成功写的字节数.
    ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long,loff_t);
    //初始化⼀个异步读 -- 可能在函数返回前不结束的读操作.
    ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
    //初始化设备上的⼀个异步写.
    int (*readdir) (struct file *, void *, filldir_t);
    //对于设备⽂件这个成员应当为 NULL; 它⽤来读取⽬录, 并且仅对**⽂件系统**有⽤.
    unsigned int (*poll) (struct file *, struct poll_table_struct *);
    int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
    int (*mmap) (struct file *, struct vm_area_struct *);
    //mmap ⽤来请求将设备内存映射到进程的地址空间. 如果这个⽅法是 NULL, mmap 系统调⽤
    返回 -ENODEV.
    int (*open) (struct inode *, struct file *);
    //打开⼀个⽂件
    int (*flush) (struct file *, fl_owner_t id);
    //flush 操作在进程关闭它的设备⽂件描述符的拷⻉时调⽤;
    int (*release) (struct inode *, struct file *);
    //在⽂件结构被释放时引⽤这个操作. 如同 open, release 可以为 NULL.
    int (*fsync) (struct file *, struct dentry *, int datasync);
    //⽤⼾调⽤来刷新任何挂着的数据.
    int (*aio_fsync) (struct kiocb *, int datasync);
    int (*fasync) (int, struct file *, int);
    int (*lock) (struct file *, int, struct file_lock *);
    //lock ⽅法⽤来实现⽂件加锁; 加锁对常规⽂件是必不可少的特性, 但是设备驱动⼏乎从不实现它.
    ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *,int);
    unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned lonunsigned long, unsigned long);
    int (*check_flags)(int);
    int (*flock) (struct file *, int, struct file_lock *);
    ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_ *, size_t,     unsigned int);
    ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *,
    size_t, unsigned int);
    int (*setlease)(struct file *, long, struct file_lock **);
};

file_operation 就是把系统调⽤和驱动程序关联起来的关键数据结构,这个结构的每⼀个成员都对应着⼀个系统调⽤。读取 file_operation 中相应的函数指针,接着把控制权转交给函数,从⽽完成了Linux设备驱动程序的⼯作。

图解:

上图中的外设,每个设备都可以有⾃⼰的read、write,但⼀定是对应着不同的操作⽅法!!但通过struct file 下 file_operation 中的各种函数回调,让我们开发者只⽤file便可调取 Linux 系统中绝⼤部分的资源!!这便是"linux下⼀切皆⽂件"的核⼼理解。


四.FILE

  • 因为IO相关函数与系统调⽤接⼝对应,并且库函数封装系统调⽤,所以本质上,访问⽂件都是通过fd访问的。
  • 所以C库当中的FILE结构体内部,必定封装了fd。

运行结果:

但如果对进程实现输出重定向呢? ./test > file , 我们发现结果变成了:

我们发现 printf 和 fwrite (库函数)都输出了2次,⽽ write 只输出了⼀次(系统调⽤)。为

什么呢?肯定和fork有关!

  • ⼀般C库函数写⼊⽂件时是全缓冲的,⽽写⼊显⽰器是⾏缓冲。
  • printf fwrite 库函数+会⾃带缓冲区(进度条例⼦就可以说明),当发⽣重定向到普通⽂件时,数据的缓冲⽅式由⾏缓冲变成了全缓冲。
  • ⽽我们放在缓冲区中的数据,就不会被⽴即刷新,甚⾄fork之后
  • 但是进程退出之后,会统⼀刷新,写⼊⽂件当中。
  • 但是fork的时候,⽗⼦数据会发⽣写时拷⻉,所以当你⽗进程准备刷新的时候,⼦进程也就有了同样的⼀份数据,随即产⽣两份数据。
  • write 没有变化,说明没有所谓的缓冲。

综上:

printf fwrite 库函数会⾃带缓冲区,⽽ write 系统调⽤没有带缓冲区。另外,我们这⾥所说的缓冲区,都是⽤⼾级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区。

那这个缓冲区谁提供呢?

printf fwrite 是库函数, write 是系统调⽤,库函数在系统调⽤的"上层", 是对系统调⽤的"封装",但是 write 没有缓冲区,⽽ printf fwrite 有,⾜以说明,该缓冲区是⼆次加上的,⼜因为是C,所以由C标准库提供。


五.缓冲区

5-1 什么是缓冲区

缓冲区是内存空间的⼀部分。也就是说,在内存空间中预留了⼀定的存储空间,这些存储空间⽤来缓冲输⼊或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输⼊设备还是输出设备,分为输⼊缓冲区和输出缓冲区。

5-2 为什么要引⼊缓冲区机制

  • 读写⽂件时,如果不会开辟对⽂件操作的缓冲区,直接通过系统调⽤对磁盘进⾏操作(读、写等),那么每次对⽂件进⾏⼀次读写操作时,都需要使⽤读写系统调⽤来处理此操作,即需要执⾏⼀次系统调⽤,执⾏⼀次系统调⽤将涉及到CPU状态的切换,即从⽤⼾空间切换到内核空间,实现进程上下⽂的切换,这将损耗⼀定的CPU时间,频繁的磁盘访问对程序的执⾏效率造成很⼤的影响。为了减少使⽤系统调⽤的次数,提⾼效率,我们就可以采⽤缓冲机制。⽐如我们从磁盘⾥取信息,可以在磁盘⽂件进⾏操作时,可以⼀次从⽂件中读出⼤量的数据到缓冲区中,以后对这部分的访问就不需要再使⽤系统调⽤了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作⼤⼤快于对磁盘的操作,故应⽤缓冲区可⼤⼤提⾼计算机的运⾏速度。
  • ⼜⽐如,我们使⽤打印机打印⽂档,由于打印机的打印速度相对较慢,我们先把⽂档输出到打印机相应的缓冲区,打印机再⾃⾏逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是⼀块内存区,它⽤在输⼊输出设备和CPU之间,⽤来缓存数据。它使得低速的输⼊输出设备和⾼速的CPU能够协调⼯作,避免低速的输⼊输出设备占⽤CPU,解放出CPU,使其能够⾼效率⼯作。

缓冲区的刷新策略

1.核心概念

刷新策略 = 一般策略 + 特殊策略

  • 一般策略:系统自动触发的刷新机制

  • 特殊策略:用户或进程状态变化触发的刷新


2.一般策略(三种刷新模式)

刷新方式 触发条件 典型场景 示例
立即刷新(无缓冲) 数据立刻写入 stderr fprintf(stderr, "error")
行刷新(行缓冲) 遇到 \n 终端交互 printf("hello\n")
满刷新(全缓冲) 缓冲区满了 普通文件 printf 重定向到文件

2.1 行缓冲(Line Buffered

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("hello");      // 没有 \n,数据留在缓冲区
    sleep(2);             // 等待2秒,屏幕上没有输出
    printf(" world\n");   // 遇到 \n,缓冲区刷新,输出 "hello world"
    return 0;
}

2.2 全缓冲(Full Buffered)

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main() {
    // 重定向到文件后,stdout 变成全缓冲
    close(1);
    open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    
    printf("hello\n");    // \n 不会触发刷新!数据留在缓冲区
    printf("world\n");    // 还是留在缓冲区
    
    // 程序退出时才会刷新,log.txt 里才能看到内容
    return 0;
}

2.3 立即刷新(No Buffered)

cpp 复制代码
#include <stdio.h>

int main() {
    fprintf(stderr, "error message\n");  // stderr 无缓冲,立即输出
    // 即使没有 \n,也会立刻显示
    return 0;
}

3.特殊策略(四种触发方式)

触发方式 代码示例 说明
用户强制刷新 fflush(stdout) 手动刷新指定缓冲区
进程正常退出 return 0exit(0) 自动刷新所有缓冲区
进程异常退出 _exit()abort() 不刷新缓冲区
关闭文件 fclose(fp) 先刷新再关闭

3.1 用户强制刷新:fflush

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("hello");      // 没有 \n,数据在缓冲区
    fflush(stdout);       // 强制刷新,立刻输出 "hello"
    
    printf("world");      // 数据在缓冲区
    // 不加 fflush,程序退出时会自动刷新
    return 0;
}

3.2 进程退出:exit 和 _exit

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("hello");      // 数据在缓冲区
    
    // exit(0) → 刷新缓冲区,输出 "hello"
    // _exit(0) → 不刷新缓冲区,什么也不输出
    
    return 0;
}
退出方式 是否刷新缓冲区 使用场景
return 0 正常退出
exit(0) 正常退出
_exit(0) 异常退出、子进程专用
abort() 异常终止

3.3 fclose 自动刷新

cpp 复制代码
#include <stdio.h>

int main() {
    FILE *fp = fopen("log.txt", "w");
    fprintf(fp, "hello");
    // 没有 fflush,数据在缓冲区
    
    fclose(fp);  // fclose 内部会先刷新缓冲区,再关闭文件
    return 0;
}

4.经典问题:fork + 缓冲区

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("hello");
    fork();
    return 0;
}

直接运行 :输出 1 次 hello(行缓冲,遇到 \n 才刷新?这里没有 \n!)

区分:

  • 终端运行 :行缓冲,但程序退出时 return 会刷新 → 输出 1 次

  • 重定向文件 :全缓冲,fork 复制缓冲区 → 父进程退出刷新一次,子进程退出刷新一次 → 输出 2 次

例子说明:理解缓冲区刷新策略,才能解释 fork 后的输出次数问题。

5-3缓冲区内型

标准I/O提供了3种类型的缓冲区。

  • 全缓冲区:这种缓冲⽅式要求填满整个缓冲区后才进⾏I/O系统调⽤操作。对于磁盘⽂件的操作通常使⽤全缓冲的⽅式访问。
  • ⾏缓冲区:在⾏缓冲情况下,当在输⼊和输出中遇到换⾏符时,标准I/O库函数将会执⾏系统调⽤操作。当所操作的流涉及⼀个终端时(例如标准输⼊和标准输出),使⽤⾏缓冲⽅式。因为标准I/O库每⾏的缓冲区⻓度是固定的,所以只要填满了缓冲区,即使还没有遇到换⾏符,也会执⾏I/O系统调⽤操作,默认⾏缓冲区的⼤⼩为1024。
  • ⽆缓冲区:⽆缓冲区是指标准I/O库不对字符进⾏缓存,直接调⽤系统调⽤。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显⽰出来
  • 除了上述列举的默认刷新⽅式,下列特殊情况也会引发缓冲区的刷新:
  1. 缓冲区满时;

  2. 执⾏flush语句;

  3. 进程结束

  • 这是由于我们将1号描述符重定向到磁盘⽂件后,缓冲区的刷新⽅式成为了全缓冲。⽽我们写⼊的内容并没有填满整个缓冲区,导致并不会将缓冲区的内容刷新到磁盘⽂件中。怎么办呢?可以使⽤fflush强制刷新下缓冲区。

还有⼀种解决⽅法,刚好可以验证⼀下stderr是不带缓冲区的,代码如下:

cpp 复制代码
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
    int main() {
    close(2);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
    perror("open");
        return 0;
    }
    perror("hello world");
    close(fd);
    return 0;
 }

底层原理分析

1 .文件描述符重定向过程

步骤 代码 fd 状态
初始 程序启动 fd 0→键盘,fd 1→显示器,fd 2→显示器
1 close(2) fd 2 变为空闲
2 open("log.txt") 分配最小未使用 fd = 2,fd 2 指向 log.txt
3 perror("hello world") 往 stderr(fd 2)写入,实际写入 log.txt

2 .为什么不需要 fflush

核心原因stderr 是**无缓冲(立即刷新)**模式。

标准流 缓冲模式 刷新条件 是否需要 fflush
stdout(终端) 行缓冲 遇到 \n 或退出 通常不需要
stdout(文件) 全缓冲 缓冲区满或退出 需要
stderr 无缓冲 立即写入 不需要

perror 内部操作的是 stderr,而 stderr 默认是无缓冲的,所以数据立即通过 write 系统调用进入内核,不经过用户态缓冲区。


3验证对比

stdout 做同样的事(需要 fflush)

cpp 复制代码
close(1);                           // 关闭 stdout
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("hello world\n");            // 数据进缓冲区
// 没有 fflush,数据丢失!
close(fd);

stderr 做同样的事(不需要 fflush)

cpp 复制代码
close(2);                           // 关闭 stderr
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
perror("hello world");              // 数据直接 write,无需刷新
close(fd);                          // 数据已经在文件里

总结:stderr 是无缓冲模式,所以重定向 stderr 到文件后,perror 的内容会立即写入文件,无需 fflush。这正好验证了 stderr 不带缓冲区的特性。

相关推荐
WangLanguager1 小时前
Linux命令adduser详细介绍
linux·运维·服务器
lingzhilab1 小时前
零知派ESP32——TCS3200高精度RGB颜色识别系统教程
c++·mfc
小此方1 小时前
Re:Linux系统篇(十四)工具篇 · 五:Git 高效协作实战与“三板斧”秘籍
linux·git·github
不做无法实现的梦~1 小时前
Docker 新手到团队协作指南
运维·docker·容器
蜡笔小马1 小时前
10.C++设计模式-代理模式
c++·设计模式·代理模式
郝学胜-神的一滴1 小时前
CMake 010 :一步到位链接静态库
开发语言·c++·qt·程序人生·系统架构·cmake
feng_you_ying_li1 小时前
linux之库的原理(1)
linux
小则又沐风a1 小时前
C++继承
开发语言·c++
测试员周周1 小时前
【Appium 系列】第10节-手势操作实战 — 滑动、拖拽、缩放与轻拂
linux·服务器·开发语言·人工智能·python·appium·pytest