【Linux基础IO】printf 为什么会写进文件?一文搞懂重定向和 dup2 底层原理
个人主页爱和冰阔乐专栏传送门《数据结构与算法》 、C学习方向C方向学习爱好者⭐人生格言得知坦然 失之淡然博主简介文章目录前言一、重定向到底改了什么1.1 文件描述符的分配规则1.2 dup2标准重定向接口1.3 输出重定向1.4 追加重定向1.5 输入重定向1.6 标准输出和标准错误21二、一个容易踩坑的现象重定向后 printf 为什么可能重复输出三、在 MiniShell 中如何实现重定向3.1 为什么不能让父进程做重定向3.2 核心流程代码3.3 execvp 后重定向关系还在吗四、多进程下 close 会影响其他进程吗五、全文总结前言在上一篇文章中我们已经从 C 文件接口讲到了系统调用也理解了 fd 的本质它其实就是进程文件描述符表的数组下标。这一篇就继续往下看一个很常见、但刚开始学时特别容易懵的问题重定向到底改了什么平时我们在命令行里经常写./a.outlog.txt ./a.outlog.txt ./a.outinput.txt ./cmdlog.txt21这些命令看起来只是把输入输出换了个地方但底层并不是 printf、scanf 这些函数变了而是文件描述符表中对应位置的指向发生了变化。所以本文会按照实验现象一步步往下推先看 fd 的分配规则再理解关闭 1 后为什么内容会写进文件然后介绍标准的重定向接口 dup2。最后再把这些知识放到 MiniShell 中看看一个简单 shell 是怎么支持 、、 的。看完这一篇后再遇到重定向、dup2(fd, 1)、21 这些内容就不会只是死记命令了而是能知道它们背后到底改的是哪一张表、哪一个位置、哪一个指针。一、重定向到底改了什么理解了文件描述符表后重定向就很好解释了。1.1 文件描述符的分配规则文件描述符的分配规则是新文件打开时操作系统会选择当前进程文件描述符表中最小的、没有被使用的下标作为新的 fd。先看普通情况如果0、1、2都还在新打开的文件一般拿到的是3。如果我们提前关闭0或者2再打开文件情况就不一样了#includestdio.h#includesys/types.h#includesys/stat.h#includefcntl.h#includeunistd.hintmain(){close(0);// 也可以试试 close(2)intfdopen(log.txt,O_RDONLY);if(fd0){perror(open);return1;}printf(fd: %d\n,fd);close(fd);return0;}如果关闭的是0新文件就可能拿到fd 0如果关闭的是2新文件就可能拿到fd 2。这就证明了 fd 不是固定从 3 开始而是遵守“最小未使用下标”的分配规则。只不过正常情况下0、1、2已经被默认占用所以普通文件才从 3 开始。接着再看一个和重定向直接相关的实验#includestdio.h#includesys/types.h#includesys/stat.h#includefcntl.h#includeunistd.hintmain(){close(1);// 关闭标准输出intfdopen(log.txt,O_CREAT|O_WRONLY|O_TRUNC,0666);if(fd0){perror(open);return1;}printf(fd: %d\n,fd);printf(hello world\n);close(fd);return0;}运行后会发现屏幕上没有输出但是log.txt里面出现了内容。实验结果关闭 1 后再打开文件新文件会拿到 fd 1。原本应该打印到显示器的内容被写入到了 log.txt 中。原因是一开始fd 1指向标准输出也就是显示器。close(1)后1 号位置空出来了。再打开log.txt操作系统发现 1 是最小空闲位置于是把log.txt放到了 1 号位置。printf底层还是往 1 号文件描述符写。但此时 1 号位置已经不是显示器而是log.txt。所以内容就写进文件了。这就是重定向的核心上层使用的 fd 数字没变底层文件描述符表中对应位置的指针变了。补充图close(1) 后新打开的文件会占用 1 号位置。也就是说printf没变stdout绑定的 1 也没变变的是 1 号位置背后指向的文件对象。1.2 dup2标准重定向接口实际写程序时不建议手动close(1)再open。因为中间有空窗期多线程环境下可能会出现问题。Linux 提供了dup2#includeunistd.hintdup2(intoldfd,intnewfd);dup2 系统调用接口。dup2 的核心作用让 newfd 成为 oldfd 的副本。它的作用可以简单理解为让 newfd 成为 oldfd 的副本。也就是把oldfd指向的文件对象复制到newfd这个位置。补充图dup2(fd, 1) 的本质是让 1 号位置复制 fd 的指向。1.3 输出重定向如果想让标准输出写入文件intfdopen(log.txt,O_CREAT|O_WRONLY|O_TRUNC,0666);dup2(fd,1);close(fd);含义是让 1 号标准输出指向log.txt。之后所有写向标准输出的内容都会进入log.txt。比如printf(hello printf\n);fprintf(stdout,hello stdout\n);write(1,hello write\n,12);它们最终都会写入文件。实验结果dup2(fd, 1) 后printf/fprintf 都会写入目标文件。1.4 追加重定向追加重定向只需要打开文件时换成O_APPENDintfdopen(log.txt,O_CREAT|O_WRONLY|O_APPEND,0666);dup2(fd,1);close(fd);这对应 shell 中的./a.outlog.txt1.5 输入重定向如果想让程序本来从键盘读的内容变成从文件读intfdopen(input.txt,O_RDONLY);dup2(fd,0);close(fd);含义是让 0 号标准输入指向 input.txt。这样scanf、fgets(stdin)等函数就不再从键盘读而是从文件中读。输入重定向后程序原本从键盘读取的内容会变成从文件读取。这对应 shell 中的./a.outinput.txt实际写小工具时文件名最好不要写死在代码里可以通过命令行参数传入。这样同一份程序就可以读取不同文件不需要每次修改源码重新编译。通过命令行参数传入文件名可以让输入重定向相关程序更加通用。1.6 标准输出和标准错误21有时候我们会看到这样的命令./cmdlog.txt21这个命令不要只背成“把错误也写进文件”最好把它拆开看。首先普通的输出重定向./cmdlog.txt它默认只影响stdout也就是文件描述符1。如果程序把错误信息写到了stderr也就是文件描述符2那么错误信息依然可能显示在屏幕上。比如下面这个命令./cmdlog.txt可以理解成底层做了类似的事情intfdopen(log.txt,O_CREAT|O_WRONLY|O_TRUNC,0666);dup2(fd,1);// 只让标准输出指向 log.txtclose(fd);此时文件描述符表大致是fd 1 - log.txt fd 2 - 显示器所以普通输出进文件错误信息还在屏幕。而下面这个命令./cmdlog.txt21它可以分成两步第一步log.txt让fd 1指向log.txt。第二步21这一步的含义不是“把 2 写到数字 1 里面”而是让 2 号标准错误复制当前 1 号标准输出的指向。也就是类似dup2(1,2);注意这里复制的是“当前 1 号 fd 背后指向的文件对象”。由于第一步已经让1指向了log.txt所以第二步执行完以后2也会指向log.txt。最终效果就是fd 1 - log.txt fd 2 - log.txt普通输出和错误输出都会进入同一个文件。还有一个很容易混的写法./cmdlog.txt21这个和21不是一回事。21的意思是把标准错误重定向到一个名字叫1的文件里而21的意思才是把标准错误重定向到标准输出当前指向的位置。补充图21 的核心是让标准错误复制当前标准输出的指向。二、一个容易踩坑的现象重定向后 printf 为什么可能重复输出前面一直在讲printf、fprintf、write都能输出内容但是它们底层经过的路线并不完全一样。先看一段经典代码#includestdio.h#includestring.h#includeunistd.hintmain(){printf(hello printf );fprintf(stdout,hello fprintf );write(1,hello write, strlen(hello write ));fork();return0;}如果直接运行通常会看到三行输出。但是如果执行输出重定向./a.outlog.txt查看log.txt时可能会看到类似结果hello write hello printf hello fprintf hello printf hello fprintf也就是说write的内容只出现了一次。printf和fprintf的内容出现了两次。write的内容还可能跑到最前面。这个现象不是fork把代码从头执行了一遍也不是printf出错了。关键在于printf/fprintf是 C 库函数中间会经过用户态缓冲区write是系统调用会直接把数据交给内核。当输出目标是显示器时C 库通常采用行缓冲遇到 就会尽快刷新所以执行到fork()时printf/fprintf的内容可能已经输出了。但是当输出目标变成普通文件时C 库通常会采用全缓冲。此时printf/fprintf的内容可能还留在用户态缓冲区中没有马上写入文件。接着执行fork()父子进程都会带着这份尚未刷新出去的缓冲区内容。等父子进程各自退出时都会刷新自己的那份缓冲区于是同一份printf/fprintf内容就可能被写入两次。而write不走 C 库用户态缓冲区它在fork()前就已经通过系统调用写出去了所以只出现一次。补充图重定向到文件后printf/fprintf 和 write 的刷新路线不同。这个实验不是为了单独展开“缓冲区专题”而是为了说明一件事C 库函数和系统调用虽然都能完成输出但它们中间隔着一层用户态封装所以在 fork、重定向这些场景下现象可能会不一样。三、在 MiniShell 中如何实现重定向如果我们自己写一个简单的 shell重定向应该在哪里做答案是必须在子进程中做而且要先做重定向再做程序替换。3.1 为什么不能让父进程做重定向假设父进程就是我们的 shell。如果父进程直接执行dup2(fd,1);那么父 shell 自己的标准输出就被改掉了。之后提示符、命令执行结果等都可能写到文件中终端就会被污染。普通命令的重定向只是临时针对当前命令不能影响整个 shell。所以正确做法是父进程fork创建子进程。子进程根据命令中的、、做重定向。子进程再执行execvp进行程序替换。父进程等待子进程退出自己的标准输入输出不受影响。补充图MiniShell 中重定向的执行流程。3.2 核心流程代码在 MiniShell 中实现重定向不能只写dup2前面还要先把命令行中的重定向符号解析出来。比如ls-a-llog.txtcatinput.txtlslog.txt可以定义一个变量记录重定向类型再用一个指针保存目标文件名#defineNoneRedir0#defineInputRedir1#defineOutputRedir2#defineAppRedir3intredirNoneRedir;char*filenameNULL;解析命令行时从后往前找、、找到之后把命令和文件名切开voidTrimSpace(char**pos){while(**posisspace(**pos)){(*pos);}}voidParseRedir(charcommand_buffer[],intlen){intendlen-1;while(end0){if(command_buffer[end]){redirInputRedir;command_buffer[end]\0;filenamecommand_buffer[end]1;TrimSpace(filename);break;}elseif(command_buffer[end]){if(end-10command_buffer[end-1]){redirAppRedir;command_buffer[end]\0;command_buffer[end-1]\0;filenamecommand_buffer[end]1;TrimSpace(filename);break;}else{redirOutputRedir;command_buffer[end]\0;filenamecommand_buffer[end]1;TrimSpace(filename);break;}}end--;}}解析完成后子进程根据redir的不同取值完成对应的open dup2voidDoRedir(){if(redirInputRedir){intfdopen(filename,O_RDONLY);if(fd0)exit(2);dup2(fd,0);close(fd);}elseif(redirOutputRedir){intfdopen(filename,O_CREAT|O_WRONLY|O_TRUNC,0666);if(fd0)exit(4);dup2(fd,1);close(fd);}elseif(redirAppRedir){intfdopen(filename,O_CREAT|O_WRONLY|O_APPEND,0666);if(fd0)exit(6);dup2(fd,1);close(fd);}}最后再放到fork的子进程里执行。下面只写核心流程pid_tidfork();if(id0){// 子进程中处理重定向intfdopen(redir_file,O_WRONLY|O_CREAT|O_TRUNC,0666);if(fd0){perror(open);exit(1);}dup2(fd,1);close(fd);// 重定向完成后再进行程序替换execvp(myargv[0],myargv);// execvp 成功后不会走到这里perror(execvp);exit(1);}这里的顺序不能反。如果先execvp程序替换成功后当前代码就被替换掉了后面的open和dup2根本执行不到。所以一定是先重定向再程序替换。3.3 execvp 后重定向关系还在吗还在。因为execvp替换的是进程用户态的代码和数据比如代码段、数据段等。但是文件描述符表属于进程的内核资源默认不会因为程序替换而消失。所以子进程完成dup2(fd, 1)后再执行execvp替换成ls新的ls程序一运行它的 1 号标准输出就已经指向文件了。这就是 shell 能实现ls-llog.txt的原因。四、多进程下 close 会影响其他进程吗这里还有一个容易混的点父子进程都可能拿着同一个 fd如果子进程执行close(fd)会不会把父进程对应的文件也关掉一般不会。原因在于内核文件对象有引用计数。可以先这样理解父进程 fd_array[1] ----\ --- struct file 引用计数 2 子进程 fd_array[1] ----/当某个进程调用close(fd);内核主要做两件事清空当前进程文件描述符表中对应的下标。将对应struct file的引用计数减一。只有当引用计数减到 0说明没有任何进程再使用这个打开文件对象了内核才会真正释放相关资源。所以一个进程close(fd)只是自己不再使用这个文件不会直接影响其他仍然持有该文件的进程。这里还可以顺便区分两个场景场景特点fork后父子进程共享 fd父子进程可能共享同一个struct file文件偏移量也可能相互影响多个进程各自open同一个文件每次open通常会创建独立的struct file各自有自己的文件偏移量五、全文总结本篇主要围绕 Linux 文件 I/O 中的重定向展开。首先通过实验理解了 fd 的分配规则新文件打开时操作系统会选择当前进程文件描述符表中最小的空闲下标作为新的 fd。也正因为这样关闭 1 后再打开文件新文件就会占用标准输出的位置导致 printf 原本应该输出到屏幕的内容写入文件。接着学习了 dup2它的作用是让 newfd 成为 oldfd 的副本。比如 dup2(fd, 1) 就是让标准输出指向 fd 对应的文件这也是输出重定向的核心。后面还理解了 21它不是把错误写到数字 1而是让标准错误复制当前标准输出的指向。最后把重定向放到 MiniShell 中知道了重定向必须在子进程中完成并且要先 dup2再 execvp。所以本篇最核心的一句话就是重定向不是修改 printf、scanf 这些函数而是修改进程文件描述符表中对应 fd 背后的指向。理解了这一点、、、21 和 MiniShell 重定向实现就能串起来了。【Linux】基础 I/O 详解从 fopen 到 open终于搞懂 fd 为什么从 3 开始【Linux进程控制】从exec程序替换到手写简易Shellfork、execvp、环境变量与内建命令【Linux系统篇】从 fork 到 WNOHANG进程创建与等待机制详解