一、dup2 系统调用实现重定向
前面我们了解重定向的底层原理:关闭旧的文件描述符,再 open 新文件,利用文件描述符分配规则(优先分配最小的未被使用的 fd),让新文件占据 1 号(标准输出)、0 号(标准输入)、2 号(标准错误),完成重定向。
但是手动close再open的写法比较麻烦,Linux 提供dup2系统调用专门用来做文件描述符拷贝,实现重定向。
函数原型
#include <unistd.h> int dup2(int oldfd, int newfd);oldfd:已经打开的有效文件描述符newfd:想要让 oldfd 拷贝到的目标文件描述符- 逻辑:如果
newfd原本是打开状态,dup2 会自动先关闭newfd;然后让fd_array[newfd]指向oldfd对应的内核file对象。 - 返回值:成功返回 newfd;失败返回‑1。
注意:dup2 不会复制磁盘文件,只是复制进程内文件描述符表的指针,内核
file结构体引用计数f_count会 + 1。
示例代码,实现输出重定向,把原本打印到显示器的内容输出到 log 文件:
#include <stdio.h> #include <unistd.h> #include <fcntl.h> int main() { int fd = open("./log", O_CREAT | O_RDWR, 0644); if (fd < 0) { perror("open"); return 1; } // 把fd拷贝到1号描述符,等价输出重定向 > log dup2(fd, 1); char buf[1024] = {0}; ssize_t read_size = read(0, buf, sizeof(buf)-1); if(read_size > 0) { printf("%s", buf); fflush(stdout); } close(fd); return 0; }这里printf是 C 库函数,底层访问依旧去找 fd=1,现在 fd=1 指向 log 文件,输出全部写入文件,完成重定向。
二、给 minishell 增加重定向功能
shell 中常见三种重定向:
<输入重定向:把文件内容当作程序标准输入>输出重定向:截断式输出,清空原有文件再写入>>追加重定向:在文件末尾追加内容,不删除原有内容
实现思路
- 解析命令行:扫描命令字符串,识别
</>/>>,分割出真正执行的命令和重定向文件名;标记重定向类型。 - 重定向逻辑放在子进程:
fork()创建子进程之后,程序替换exec*之前执行重定向。
重点:程序替换只会替换进程代码和数据,不会修改进程的文件描述符表,所以子进程做 dup2 重定向之后,exec 执行外部命令,重定向依旧生效。
- 父进程等待子进程退出。
核心逻辑片段:
// 重定向类型 #define NoneRedir 0 #define InputRedir 1 // < #define OutputRedir 2 // > #define AppRedir 3 // >> int redir = NoneRedir; char *filename = nullptr; // 执行重定向,子进程调用 void DoRedir() { if(redir == InputRedir) { int fd = open(filename, O_RDONLY); dup2(fd,0); close(fd); } else if(redir== OutputRedir) { // O_TRUNC:截断清空文件 int fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666); dup2(fd,1); close(fd); } else if(redir == AppRedir) { // O_APPEND:追加写 int fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666); dup2(fd,1); close(fd); } }执行流程:
pid_t id = fork(); if(id == 0) { DoRedir(); //子进程完成重定向 execvpe(gargv[0], gargv, genv); //程序替换 exit(7); } waitpid(id, &status, 0);小知识点:
cd、export属于 shell内建命令,不能 fork 子进程执行。子进程修改工作目录、环境变量只会影响子进程,父 shell 环境不会变化,必须 shell 进程自己调用chdir、修改环境变量。
三、Linux “一切皆文件” 真正含义
狭义文件:磁盘上普通文件。 广义:Linux 下键盘、显示器、网卡、磁盘、管道、socket 套接字全部抽象成文件。
内核层面原理
每个打开的资源在内核中都有一个struct file结构体,结构体内部有一个struct file_operations *f_op指针。file_operations是一个巨大的函数指针表,里面包含read、write、llseek、open、release等函数指针。
不同硬件 / 设备,驱动实现自己的 read、write 方法:
- 磁盘驱动的 read:从磁盘扇区读取数据;
- 键盘驱动 read:等待键盘输入;
- 显示器驱动 write:把数据输出到屏幕;
- socket 套接字 read/write:收发网络数据包。
对上层用户来说,不管操作什么设备,只需要调用统一的系统调用read / write。操作系统根据 fd 找到struct file,调用file->f_op->read/write,底层就会走到对应设备驱动的函数。
这就是一切皆文件的核心:对外提供同一套系统调用接口,底层驱动各自实现差异化操作。
四、IO 缓冲区详解
缓冲区本质是内存空间,用来缓存数据,减少系统调用次数。
一次系统调用会发生用户态↔内核态切换,开销大;缓冲区攒一批数据再调用一次系统调用,大幅提升 IO 效率。
三种缓冲模式(C 标准库,用户态缓冲区)
- 全缓冲:缓冲区写满才触发系统调用。普通磁盘文件默认全缓冲。
- 行缓冲:遇到换行符
\n,或者缓冲区写满,刷新缓冲区。标准输出 stdout 指向终端(显示器)默认行缓冲。 - 无缓冲:不缓存,调用库函数立刻执行系统调用。
stderr标准错误,无缓冲,保证错误信息立刻输出。
触发缓冲区刷新的条件:
- 缓冲区存满;
fflush()手动强制刷新;- 程序进程正常退出,会自动刷新用户缓冲区。
⚠️坑点:重定向现象 当 stdout 原本指向显示器(行缓冲),一旦重定向到普通磁盘文件,stdout 自动切换为全缓冲模式。 此时 printf 打印的内容带有
\n,也不会立刻刷新,只有缓冲区满、fflush、进程退出才会写入。
示例坑代码:
int main() { close(1); int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); printf("hello world\n", fd); close(fd); return 0; }运行后 log.txt 是空文件! 原因:重定向之后 stdout 变成全缓冲,字符串不足以填满缓冲区;没有 fflush,在进程退出之前我们就close(fd)关闭文件,缓冲区数据还留在用户内存,没有落地到文件。 修复:fflush(stdout);在 close 之前强制刷新缓冲区。
区分:用户缓冲区 VS 内核缓冲区
- C 库 FILE 里面的缓冲区:用户态缓冲区,由 libc 提供,
printf/fwrite使用;系统调用read/write没有用户缓冲区。 - 调用 write 之后,数据进入内核缓冲区(操作系统维护),由操作系统决定什么时候刷到磁盘硬件;可以调用
fsync/fdatasync强制把内核缓冲区落地磁盘。
五、FILE 结构体 和 文件描述符 fd
fd:int 类型的小整数,内核层面标识打开文件,系统调用 open/read/write/close 使用。FILE*:C 标准库的结构体指针,用户层库函数封装,fopen/fprintf/fwrite 使用。
FILE结构体内部:
- 内部成员
_fileno保存封装的文件描述符 fd; - 一整套缓冲区指针:
_IO_write_base、_IO_write_ptr、_IO_write_end等,维护用户缓冲区。
经典 fork 缓冲区面试题
#include <stdio.h> #include <unistd.h> int main() { printf("hello printf\n"); fwrite("hello fwrite\n", 13,1,stdout); write(1,"hello write\n",12); fork(); return 0; }- 直接运行输出到显示器:三行各输出一次。stdout 是行缓冲,遇到
\n,fork 之前缓冲区已经刷新完毕。 - 使用重定向
./a.out > file输出到普通文件:printf、fwrite输出两次,write只输出一次。
原因:
- 重定向到文件,stdout 变成全缓冲。
\n不会触发刷新;printf、fwrite 的数据还留在 C 库的用户缓冲区。 fork()创建子进程,发生写时拷贝。父进程用户空间缓冲区数据拷贝一份给子进程。- 父子进程各自退出,都会刷新自己的用户缓冲区,所以 printf/fwrite 打印两遍。
write是系统调用,没有用户缓冲区,数据直接交给内核,fork 不会拷贝这份数据,只会输出一次。
总结:用户缓冲区是 C 库提供,存在用户空间;fd 属于内核。库函数 = 用户缓冲区逻辑 + 封装系统调用。
六、系统调用与库函数的关系
open / close / read / write / dup2:系统调用,操作系统内核提供接口,用户态切换内核态完成文件操作。fopen / fclose / fread / fwrite / printf:C 标准库函数,运行在用户空间。
调用链路:
应用程序 → C 库函数(处理用户缓冲区) → 系统调用 → 内核 → 硬件驱动
库函数不是替代系统调用,是在系统调用之上封装缓冲区逻辑,给程序员更友好的接口。我们甚至可以自己模拟简易 C 库,封装 fd、自己实现缓冲区,模拟fopen/fwrite/fflush。
本篇小结
dup2(oldfd, newfd):拷贝文件描述符指针,实现重定向,子进程中使用,exec 程序替换不影响 fd 表。- shell 重定向解析:识别
< > >>,子进程做 dup2 重定向;cd/export 等属于内建命令不能 fork。 - Linux 一切皆文件:依靠
struct file和file_operations函数指针表,统一 read/write 系统调用,底层设备驱动各自实现。 - 缓冲区分为全缓冲、行缓冲、无缓冲;重定向会改变 stdout 缓冲模式;缓冲区刷新时机。
- fd 是内核文件描述符;FILE 结构体是 C 库封装,内部包含 fd + 用户缓冲区;fork 会拷贝用户缓冲区造成重复输出。
- 库函数封装系统调用,用户缓冲区属于 libc,write 系统调用没有用户缓冲区。