
去年我给一个采集程序加日志导出功能跑了大半个月的数据导出后日志文件老是被截断或者干脆没内容。排查到最后发现不是程序逻辑问题而是重定向的缓冲区没刷。从那时候起我就想把Linux下IO和重定向这件事彻底捋清楚前后手写了几个demo验证也把常见坑一个个踩了一遍。标题写的是“基础IO二”这次咱们聊四件事文件重定向的本质是什么dup2怎么用怎么给一个自定义的shell程序加上重定向功能以及“一切皆文件”这句老话到底该咋理解。适合正在学Linux系统编程的同学也适合那些平时会敲命令、但不太明白背后原理的人。你现在敲的cmd file.log底层其实就是一次fd数组的替换。把这个机制吃透了以后排查“输出没生成”“日志被截断”“管道没通”这类问题会顺手得多。1. 先把文件描述符和重定向这事拆明白1.1 文件描述符到底是什么一个进程在运行的时候内核里有一张文件描述符表。这张表本质上是一堆指针每个指针指向内核里的一个file结构体。每当你调用open打开一个文件内核就在这张表里找一个空闲位置挂上去然后把对应的整数下标返回给你。这个整数就是文件描述符。那0、1、2是什么是进程建立时内核提前准备好的三个特殊下标0stdin默认指向键盘输入设备1stdout默认指向显示器2stderr默认也指向显示器很多人会把这几个数字当“玄学”但一旦用/proc看一眼一切都清楚了。写个最简单的程序#include stdio.h #include unistd.h #include fcntl.h int main(void) { int fd open(log.txt, O_CREAT | O_WRONLY | O_TRUNC, 0644); printf(fd %d\n, fd); sleep(10); return 0; }编译运行在另一个终端执行ls -l /proc/$(pgrep demo)/fd你会看到类似这样的输出0 - /dev/pts/1 1 - /dev/pts/1 2 - /dev/pts/1 3 - /path/to/log.txt注意那个/dev/pts/1它对应的正是你当前终端设备的文件路径。也就是说你平时往屏幕上打印字符本质上就是往一个终端设备文件里写数据。fd 0、1、2并不是什么特殊魔法它们只是三个已经被预设好指向的文件描述符。1.2 重定向的本质替换fd表里的指针理解了fd表的模型重定向就一句话把fd 1原本指向的那个file对象换成另一个文件的file对象。你敲cmd out.txtshell实际干的事就是几步先用open打开out.txt拿到一个fd比如3通过某种机制把fd 1的指针改成和fd 3指向同一个file对象然后执行cmdcmd往stdout里写的所有内容最终都写进了out.txt这就是为什么重定向会覆盖原文件、为什么会追加、为什么重定向失败时shell会报“No such file or directory”。因为它在open那一步就失败了。1.3 最简单的重定向demo不借助任何库函数直接用close和open可以完成一个最原始的重定向#include stdio.h #include unistd.h #include fcntl.h int main(void) { close(1); // 先把显示器“关掉” int fd open(out.txt, O_CREAT | O_WRONLY | O_TRUNC, 0644); // open返回当前最小的空闲fd关掉1以后新的fd通常就是1 printf(这行字会写到out.txt里\n); return 0; }这里有一个微妙的点open一定从最小的空闲fd开始分配。你先把fd 1关闭了那再open时返回的就很可能是1于是fd 1顺理成章指向了out.txt。后面的printf往fd 1写内容就进了文件。这个demo能说明原理但实际项目里千万别这么裸写。原因有两个如果你中间不小心先关了别的fdopen返回值可能不是1重定向就悄悄失效close(1)之后如果open失败整个程序的输出系统就崩了后续报错都看不到所以标准做法是走dup2。2. dup2的使用与常见坑2.1 dup和dup2是什么Linux提供了三个跟fd复制相关的系统调用#include unistd.h int dup(int oldfd); int dup2(int oldfd, int newfd); int dup3(int oldfd, int newfd, int flags);dup返回一个空闲的最小编号fd和oldfd指向同一个file对象dup2把oldfd的“指向关系”复制给newfd。如果newfd已经打开会在复制前自动关闭它整个操作是原子的dup3Linux特有版本多了flags可以传O_CLOEXEC指定exec时自动关闭实际工作中dup2最常用因为重定向这个场景需要的就是“让fd 1指向某个file对象”。它的原子性很重要后面细说。2.2 用dup2把标准输出重定向到文件#include stdio.h #include unistd.h #include fcntl.h #include stdlib.h int main(void) { int fd open(out.txt, O_CREAT | O_WRONLY | O_TRUNC, 0644); if (fd 0) { perror(open); exit(EXIT_FAILURE); } if (dup2(fd, STDOUT_FILENO) 0) { perror(dup2); exit(EXIT_FAILURE); } close(fd); // fd 1已经指向目标文件3可以关了 printf(这行字会写到out.txt里\n); return 0; }这段话值得盯着看两分钟。dup2(fd, 1)的意思是把fd这个描述符所指向的file对象也挂到下标1上。执行完之后fd 1和fd 3都指向同一个文件此时关掉fd 3不影响fd 1。整段代码下来效果和上面的closeopen一样但安全很多因为不依赖“最小空闲fd”的巧合。2.3 为什么不用closeopen偏要用dup2我见过不少人在代码里写close(1); int fd open(...);这样通常能跑通但它有隐患。假设你程序前面已经关掉了fd 3再执行close(1)、open这时的最小空闲fd是3而不是1于是open返回3你的stdout完全没有被重定向但代码还在照常往下走错误被掩盖了。dup2则没有这种问题。它不管newfd之前是什么状态直接帮你替换没有窗口期不需要先close。在并发场景下closeopen这种先释放再申请的方式还有竞态风险别的线程可能在你close之后、open之前抢到了那个fd。dup2把这个过程收敛成一次原子操作这才是它存在的意义。2.4 重定向后怎么恢复现场有时候你不想永久改掉进程的stdout只想在某个函数里把输出导到文件函数结束再恢复回来。这个模式很常见做法是先保存再恢复#include stdio.h #include unistd.h #include fcntl.h void write_to_file(void) { // 保存当前stdout指向的file对象 int saved dup(STDOUT_FILENO); int fd open(tmp.log, O_CREAT | O_WRONLY | O_TRUNC, 0644); dup2(fd, STDOUT_FILENO); close(fd); printf(这行字会在tmp.log里\n); // 恢复stdout dup2(saved, STDOUT_FILENO); close(saved); } int main(void) { printf(这行在终端\n); write_to_file(); printf(这行也回到终端了\n); return 0; }注意恢复顺序先dup(saved, STDOUT_FILENO)把stdout指回原来的终端再把saved关掉。这样不会出现stdout被临时关闭的间隙。2.5 dup2的常见坑不检查返回值。dup2失败会返回-1但很多人漏掉这一步导致后续输出全丢oldfd和newfd相等。Linux下如果两者相同dup2直接返回newfd不会关闭它dup2之后不关闭oldfd。一般进程接下来马上exec或exit影响不大但如果是长驻服务等于多占一个fd项迟早耗尽混淆“重定向谁”。dup2只改变当前进程的fd表不会影响别的进程更不会影响已经打开的fd指向。如果你想重定向子进程必须在fork之后的子进程里调用dup23. 给shell程序添加重定向功能3.1 先搭一个能跑命令的迷你shell要演示重定向最好自己写一个迷你shell。先来一个最朴素的版本#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h #define CMD_MAX 256 #define ARG_MAX 32 int main(void) { char line[CMD_MAX]; while (1) { printf(mysh ); fflush(stdout); if (fgets(line, sizeof(line), stdin) NULL) break; line[strcspn(line, \n)] \0; if (strcmp(line, exit) 0) break; // 切分命令和参数 char *argv[ARG_MAX]; int argc 0; char *token strtok(line, ); while (token ! NULL argc ARG_MAX - 1) { argv[argc] token; token strtok(NULL, ); } argv[argc] NULL; if (argc 0) continue; // 内置命令cd不能放子进程里执行 if (strcmp(argv[0], cd) 0) { if (chdir(argv[1] ? argv[1] : /) ! 0) perror(cd); continue; } pid_t pid fork(); if (pid 0) { perror(fork); continue; } if (pid 0) { execvp(argv[0], argv); perror(execvp); exit(127); } waitpid(pid, NULL, 0); } return 0; }这里面有个细节值得注意cd这种内置命令必须在父进程里执行。因为chdir改的是当前进程的工作目录fork出来的子进程改了也没用父进程不会变。3.2 解析重定向符号现在加上重定向。为了控制复杂度先支持四种形式cmd args filecmd args filecmd args filecmd args 2 file解析思路在字符串里找到第一个或把它切出来判断具体类型然后把重定向后的部分作为文件名。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include sys/wait.h // 返回1表示 2表示 3表示 4表示 20表示没有重定向 int parse_redirect(char *cmd, char *file, int file_size) { char *p cmd; char *pos NULL; int type 0; // 找第一个 或 for (; *p; p) { if (*p || *p ) { pos p; break; } } if (pos NULL) return 0; // 判断重定向类型 if (*pos ) { if (*(pos 1) ) { type 2; // } else { type 1; // } } else { type 3; // } // 把重定向符号切开cmd部分单独留下来 *pos \0; // 处理文件名前导空格 char *fp pos; if (type 2) fp 2; else fp 1; while (*fp || *fp \t) fp; snprintf(file, file_size, %s, fp); file[strcspn(file, \n)] \0; return type; }这里有个要点解析时要先切开重定向符号再去解析命令参数。如果顺序反了strtok会把cat file这种拆成cat、、file三个token传给execvp后execvp会拿去当命令名直接报错。3.3 在子进程里完成重定向重定向的完整流程是fork之后、exec之前在子进程里open文件并dup2。注意必须在fork之后否则父进程的stdout也会被改掉你的shell就无法正常显示提示符了。void apply_redirect(int type, const char *file) { int fd; switch (type) { case 1: // fd open(file, O_CREAT | O_WRONLY | O_TRUNC, 0644); if (fd 0) { perror(open); exit(1); } dup2(fd, STDOUT_FILENO); close(fd); break; case 2: // fd open(file, O_CREAT | O_WRONLY | O_APPEND, 0644); if (fd 0) { perror(open); exit(1); } dup2(fd, STDOUT_FILENO); close(fd); break; case 3: // fd open(file, O_RDONLY); if (fd 0) { perror(open); exit(1); } dup2(fd, STDIN_FILENO); close(fd); break; case 4: // 2 fd open(file, O_CREAT | O_WRONLY | O_TRUNC, 0644); if (fd 0) { perror(open); exit(1); } dup2(fd, STDERR_FILENO); close(fd); break; } }然后修改主循环里的fork分支if (pid 0) { // 子进程里先解析重定向 char rfile[CMD_MAX]; int rtype parse_redirect(line, rfile, sizeof(rfile)); // 重新切分命令注意顺序先解析重定向再切分argv char *argv[ARG_MAX]; int argc 0; char *token strtok(line, ); while (token ! NULL argc ARG_MAX - 1) { argv[argc] token; token strtok(NULL, ); } argv[argc] NULL; if (argc 0) exit(0); apply_redirect(rtype, rfile); execvp(argv[0], argv); perror(execvp); exit(127); }这里有个容易踩的坑parse_redirect要放在strtok之前调用。因为strtok会在原字符串里插入\0等你再找重定向符号时后半段已经被切碎了。我给这套代码起名时加了2的支持但要提醒一下bash里真正复杂的21把stderr并到stdout需要按顺序处理两个重定向不是一个简单的parse_redirect能搞定的。想继续扩展的话思路是解析多个重定向段依次执行fd变换。3.4和的差别究竟在哪对应open的O_TRUNCO_TRUNC的意思是“打开文件的同时把长度截断成0”而对应O_APPENDO_APPEND的意思是“每次写入前先把偏移量定位到文件末尾”。所以同样是打开同一个文件会清空旧内容会保留旧内容并在末尾追加。在实际排查时我看到不少人以为不覆盖、才覆盖概念正好搞反。记住一条截断是O_TRUNC干的活追加是O_APPEND干的活跟你用不用dup2没关系。3.5 不处理管道和引号你损失了什么上面这个迷你shell没有实现管道也没有处理引号。这意味着echo hello world这种命令会被拆成hello和world两个参数。如果要正确支持引号解析逻辑得从strtok升级成state machine在解析时进入引号状态、忽略空格和重定向符。不过这些都不影响你理解重定向。管道本质上是把fd 1重定向到管道文件的写端、把fd 0重定向到管道文件的读端跟文件重定向是同一条路子。mini shell加上管道就是多做一次解析和多次dup2的事。4. 如何理解“一切皆文件”4.1 文件描述符为什么能统一所有IOLinux把几乎所有东西都抽象成“可打开的对象”普通文件是文件目录是文件终端是文件磁盘设备是文件网络socket也返回fd。用户态的程序只需要记住一个int就可以通过read、write、close去操作五花八门的底层对象。这个“统一”不是口号而是内核实打实地做了一套VFS虚拟文件系统层。不管底层是ext4、是NTFS、是块设备、是网络协议栈VFS都要求它们提供一套统一的接口。用户态看到的样子永远是一样的复杂差异都被内核藏起来了。4.2 内核里的核心结构体在Linux内核源码里一切皆文件的根基是这个结构体struct file_operations { ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); int (*open) (struct inode *, struct file *); int (*release) (struct inode *, struct file *); // ... 还有llseek、poll、mmap等 };每种设备驱动都会实现自己的file_operations。你打开一个普通硬盘文件走到的是ext4的read/write实现你打开一个终端走到的是tty驱动的read/write实现你读写一个socket走到的是网络协议栈的实现。但无论是哪个实现从用户态看都是read(fd, buf, count)三个统一参数。这就是“一切皆文件”的真正意义——接口统一实现各异。4.3 用三个小实验感受一下第一个实验echo hello /dev/null/dev/null也是一个文件只不过这个文件的write实现是“什么都不做直接返回写入成功”。所以echo hello写了文件文件把内容丢了。嵌入式开发里常见的方式是往/sys/class/gpio/gpioXX/value写0或1底层也是文件write驱动帮你拉高拉低引脚电平。第二个实验cat /proc/cpuinfo/proc是procfs它里面的文件其实不占磁盘空间是内存在read时现场生成的。你读它内核把当前CPU信息打包返回给你。第三个实验int sockfd socket(AF_INET, SOCK_STREAM, 0); write(sockfd, GET / HTTP/1.1\r\nHost: example.com\r\n\r\n, 37);socket()返回的依然是一个int fd。你拿write往这个fd写数据内核把数据按TCP协议封装成网络包发出去。这就是网络编程里最基础的原理socket也是文件。4.4 “一切皆文件”的边界严格抠字眼的话Linux里并非绝对一切都是文件。信号、定时器、进程调度参数这些原生接口并不是文件。但Linux提供了很多“把它们包装成文件”的机制最典型的就是signalfd、eventfd、timerfd。这些系统调用把信号、事件、定时器统统转成fd让你能用read/select/epoll一套流程去统一处理。所以这句话更准确的理解是在Linux的编程模型下凡是你需要做IO的对象都可以被抽象为文件描述符。它带来的最大红利是IO多路复用、重定向、管道这些机制能无缝工作。你不需要给“读终端”和“读socket”各写一套逻辑一套read就够。4.5 对日常开发的意义理解了一切皆文件很多问题会变得异常简单。比如程序突然没输出你先去看它的fd 1指向哪里比如你想给一个程序喂假输入./test input.txt就是最直接的办法比如你想调试网络程序可以用socketpair创建一个管道状的socket对两端互相write/read省得真的去联一台服务器。我平时排查脚本问题时最喜欢用strace直接看系统调用。拿重定向来说执行strace -f -e traceopen,dup2,close,execve bash -c echo hello out.txt会看到类似这样的输出片段open(out.txt, O_WRONLY|O_CREAT|O_TRUNC, 0666) 3 dup2(3, 1) 1 close(3) 0 execve(/usr/bin/echo, [echo, hello], ...) 0这一串输出把重定向的全部秘密都摆在了桌面上先open拿到fd 3然后dup2把3复制到1再close(3)最后才exec执行echo。这也是“一切皆文件”在重定向链路里的铁证。我每次给新人讲重定向都会让他们亲手跑一遍strace比自己读十篇博客都管用。5. 实操中踩过的坑与排查技巧5.1 高频问题速查现象原因处理方法重定向了但文件不生成open失败权限或路径不对检查目录写权限执行前手动touch验证重定向后文件是空的但终端没报错程序输出的是stdout而不是stderr或输出被缓冲确认重定向的是fd 1还是fd 2加21echo hi file之后马上读文件没内容缓冲区没刷程序还没退出或没fflushprintf后加fflush或者等待进程退出后再读重定向把原文件清空了用了而不是根据需求选择O_TRUNC还是O_APPEND迷你shell执行重定向命令后shell自己输出也进文件在fork之前做了dup2把dup2移进子进程分支execvp报“No such file or directory”文件名被strtok拆开重定向符号被当成参数先解析重定向再切分argv日志文件巨大但内容全是上一次的残留open时没加O_TRUNC确认flags是否带了O_TRUNC5.2 重定向没生效先怀疑这两件事第一怀疑现有fd被占用了。程序如果一开始就打开了别的文件你又在某个位置执行close(1)open那open返回的不一定是1可能是一个更小的空位。这种问题最隐蔽因为代码看起来完全正常。解决方法是别用closeopen一律用dup2。第二怀疑你重定向的是错误的对象。很多人把stderr输出的错误信息当成stdout重定向了屏幕照样刷错误。这时候应该看程序到底是往fd 1写还是fd 2写。把两个通道都重定向是最稳妥的办法./myapp out.log 21这个写法的精髓在于21不是把stderr重定向到out.log而是把stderr重定向到fd 1当前指向的目标。所以21必须出现在 out.log之后顺序错了stderr还是会跑到终端里。5.3 缓冲区问题重定向后printf不给你面子的原因Linux下的printf输出默认是行缓冲当输出目标是终端时遇到换行就自动刷新当输出目标是普通文件或管道时变成全缓冲攒够一块才写入。这就是为什么程序在处理过程中被kill掉日志文件里什么也没有。解决办法要么是让程序正常退出让exit时的清理逻辑把缓冲区刷出去要么在关键位置手动fflush(stdout)。我写采集程序时习惯在每条日志后面都调用fflush避免程序异常崩溃丢数据。代价是频繁的系统调用稍微影响一点点性能但换取的是日志的可靠性值。另外fprintf(stderr, ...)本身不带缓冲因为它定位就是错误信息需要立刻可见。这也是为什么很多程序把日志写到stderr而不是stdout。5.4 迷你shell扩展时最容易翻车的点如果继续扩展上面那个迷你shell有这几个点最容易被坑strtok会修改原字符串。解析完重定向再切参数没问题但如果你先切参数再解析重定向后半段已经不存在了多个命令的参数里包含空字符串。strtok会把连续的空格跳过去没法处理空参数execvp失败必须exit。子进程execvp失败后如果不exit会继续执行父进程后面的代码出现双shell打印提示符的诡异现象fork之前不要碰fd表。fd表是复制的父进程改动不会影响子进程但子进程的改动也不会影响父进程正因为这个模型重定向才必须放在子进程里5.5 我调试重定向时常用的三板斧第一板斧开一个窗口执行程序另一个窗口随时用ls -l /proc/PID/fd看fd指向。这是最直观的“fd表可视化”手段。第二板斧strace跟踪系统调用一条条看open、dup2、close到底做了什么。第三板斧在代码里临时加打印把每次open/dup2前后的fd状态打出来。这三板斧组合起来几乎能解决所有重定向相关的问题。我上次排查一个守护进程日志丢失的问题就是用/proc/PID/fd看到它的fd 1指向的是一个已删除的日志文件inode还被占着所以日志一直在写但文件在磁盘上已经看不见了。这个问题不看fd是永远猜不到原因的。我个人在实践中最大的体会是Linux下IO相关的很多“灵异现象”本质上都是fd表被悄悄改动导致的。只要你脑子里始终有“fd 下标下标指向file对象”这个模型遇到重定向问题就不需要瞎猜。最后再分享一个建议手写一个带重定向的迷你shell加上管道、加上21在这个过程中把strace输出和你的代码逐行对照。这个练习做完你对文件描述符的理解会超过绝大多数面试者。