1. 前言
Linux 中"一切皆文件",键盘、显示器、普通文件在系统中都以文件的形式统一管理。本章节主要目标:
- 理解文件描述符 fd 的本质与分配规则。
- 理解重定向的原理,掌握 dup2 系统调用,分析 shell 中
>的实现。 - 理解用户级缓冲区的工作机制,解释 printf 重定向后输出重复的现象。
- 了解 inode、软硬链接的区别以及动静态库的使用。
2. 文件描述符 fd
2.1 fd 的本质
进程打开文件时,操作系统会在内存中创建对应的struct file结构体来描述这个被打开的文件。进程与打开文件之间的关联关系如下:
进程PCB → *files 指针 → files_struct → fd_array[] 指针数组 ├─ [0] → 键盘 ├─ [1] → 显示器 ├─ [2] → 显示器 └─ [3] → 新打开的文件PCB 中有一个指针指向files_struct,该结构体中最重要的成员是指针数组fd_array[],数组的每个元素指向一个被打开的文件。因此文件描述符 fd 的本质就是这个数组的下标,通过 fd 即可找到对应的文件。
2.2 默认打开的三个文件
每个进程创建时默认打开三个文件:
| fd | C 语言中的名称 | 对应设备 |
|---|---|---|
| 0 | stdin(标准输入) | 键盘 |
| 1 | stdout(标准输出) | 显示器 |
| 2 | stderr(标准错误) | 显示器 |
这解释了为什么 C 程序不调用 open 就可以直接 printf/scanf:进程创建时 fd 0/1/2 已经被占用。
2.3 fd 的分配规则
fd 的分配规则是:在 fd_array 中找到当前最小的未被占用的下标,分配给新打开的文件。通过代码验证:
// fd_demo.c #include <stdio.h> #include <unistd.h> #include <fcntl.h> int main() { close(0); // 关闭标准输入,腾出 0 号下标 int fd = open("myfile", O_RDONLY); printf("fd: %d\n", fd); close(fd); return 0; }运行结果为fd: 0。不关闭 0 的话,新文件会分配到 3(0/1/2 已被占用)。关闭某个 fd 之后再 open,新文件会占用刚释放的下标,这一规则是理解重定向的基础。
3. 重定向
3.1 重定向实验
既然 printf 只是向 fd 1 写入数据,那么如果把 1 号下标指向的文件从显示器换成普通文件,输出内容就会写入文件。通过代码验证:
// redir_demo.c #include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <stdlib.h> int main() { close(1); // 第一步:释放 1 号下标 int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644); // 第二步:新文件分配到 1 号 if (fd < 0) { perror("open"); return 1; } printf("hello, 我本该去显示器\n"); // 第三步:向 fd 1 写入,实际写入 log.txt fflush(stdout); // 全缓冲模式下需要手动刷新缓冲区 close(fd); exit(0); }gcc redir_demo.c -o redir_demo ./redir_demo # 屏幕上没有任何输出 cat log.txt # hello, 我本该去显示器printf 本身不知道显示器的存在,它只向 fd 1 写入。因此重定向的本质是:不改变 fd,只修改 fd 下标背后指向的文件。
3.2 dup2 系统调用
先 close 再 open 的写法存在缺陷:如果两次调用之间有其他文件占用了 1 号下标,逻辑就会出错。Linux 提供了专门的系统调用dup2:
int dup2(int oldfd, int newfd); // 功能:让下标 newfd 也指向 oldfd 所指向的文件 // 如果 newfd 原来指向其他文件,该文件会被隐式关闭参数的记忆方式:newfd 是被覆盖的一方,oldfd 是要保留的一方。dup2(fd, 1)表示让 1 号下标指向 fd 指向的文件,该操作在内核中修改指针,是原子操作。 注意:dup2(fd, 1)执行后,1 号和 fd 两个下标指向同一个文件,因此之后再close(fd)不影响通过 1 号进行输出。
3.3 shell 中>的实现
结合 fork、exec 与重定向的知识,可以分析ls > out.txt这条命令的完整执行流程:
1. bash 调用 fork 创建子进程 2. 子进程在 exec 之前 open("out.txt", O_WRONLY|O_CREAT|O_TRUNC),得到 fd 3. 子进程调用 dup2(fd, 1),将自己的 1 号下标指向 out.txt 4. 子进程调用 execlp("ls", ...),替换为 ls 程序 5. ls 执行时向 fd 1 写入,内容全部进入 out.txt 6. bash 作为父进程调用 waitpid 回收子进程,自身的 1 号下标不受影响三个关键问题:
- 为什么重定向要在子进程中进行?如果 bash 直接修改自己的 1 号下标,后续所有命令的输出都会写入文件。在子进程中修改只影响当前这一条命令。
- 为什么要在 exec 之前完成?fd 表会被 exec 之后的程序继承,替换完成后再修改就来不及了。
>与>>的区别是什么?区别仅在 open 的标志位:>使用O_TRUNC(清空后重写),>>使用O_APPEND(追加到文件末尾)。
另外,stderr(fd 2)与 stdout 是独立的下标,因此ls > out.txt 2> err.txt中错误信息不会被重定向到 out.txt 中。
4. 用户级缓冲区
4.1 实验:printf 输出重复的现象
// test_buf.c #include <stdio.h> #include <unistd.h> #include <string.h> int main() { printf("hello printf\n"); // 库函数,先写入用户级缓冲区 write(1, "hello write\n", strlen("hello write\n")); // 系统调用,直接写入内核 fork(); return 0; }直接运行./test_buf,输出正常的两行。但重定向到文件后:
./test_buf > out.txt cat out.txthello write hello printf hello printfprintf 的内容出现了两次,write 的内容只有一次。fork 之后没有任何打印语句,多出来的一次输出需要解释。
4.2 缓冲模式
FILE*结构体中封装了 fd 和一个用户级缓冲区。printf 只是把数据拷贝到该缓冲区中,并没有立即写入文件;write 是系统调用,数据直接交给内核。这就是 out.txt 中 hello write 排在前面(先落盘)的原因。
缓冲区的刷新时机取决于输出的设备类型:
| 设备 | 缓冲模式 | 刷新时机 |
|---|---|---|
| 显示器 | 行缓冲 | 遇到\n即刷新 |
| 普通文件(重定向) | 全缓冲 | 缓冲区写满或进程退出时 |
| stderr | 不缓冲 | 立即输出 |
直接运行时,printf 带有\n,行缓冲立即刷新,fork 之前数据已经输出;重定向到文件后变为全缓冲,\n不触发刷新,数据一直留在缓冲区中,fork 发生时数据仍然存在。
4.3 原因分析
完整过程如下:
1. printf → 数据进入父进程的用户级缓冲区(全缓冲,暂不刷新) 2. write → 直接写入 out.txt 文件第 1 行 3. fork → 子进程复制父进程的资源,包括该缓冲区 此时父子进程的缓冲区中各有一份 "hello printf" 4. 父进程 return → exit → 退出前刷新缓冲区 文件第 2 行 5. 子进程 return → exit → 退出前刷新缓冲区 文件第 3 行结论:fork 时缓冲区中残留的数据被子进程复制了一份,父子进程退出时各自刷新一次,导致 printf 的内容写入两次。write 没有用户级缓冲区,fork 时没有数据可以复制,因此不受影响。
4.4 修复方法
在 fork 之前手动刷新缓冲区,让数据提前写入文件:
printf("hello printf\n"); fflush(stdout); // 强制刷新用户级缓冲区 write(1, "hello write\n", 12); fork();修改后输出恢复正常,验证了上述分析。这也解释了前一篇中提到的区别:exit 退出前会刷新缓冲区,而 _exit 不会,使用 _exit 的程序需要特别注意手动 fflush。
4.5 为什么需要缓冲区
引入缓冲区是为了减少系统调用次数,提高 IO 效率。系统调用涉及用户态到内核态的切换,存在一定开销。假设向文件写入大量数据,没有缓冲区时每次写入都是一次系统调用;有了缓冲区后,数据先在用户内存中积累,攒够一批再通过一次 write 交给内核,系统调用次数大幅减少。
5. FILE 与 fd 的关系
open/close/read/write:Linux 系统调用,操作对象是 fdfopen/fclose/fread/fwrite/printf:C 标准库函数,操作对象是FILE*
FILE 是 C 库封装的结构体,内部包含真实的 fd 和用户级缓冲区,库函数最终通过系统调用完成真正的 IO。
6. inode 与软硬链接
6.1 inode
磁盘上的文件由内容和属性两部分组成,Linux 将二者分开管理:属性(大小、权限、时间、数据块位置等)保存在inode结构体中,每个文件对应一个 inode,通过 inode 编号标识。注意:inode 中不保存文件名。文件名保存在目录中,目录的内容就是一张"文件名 → inode 编号"的映射表。访问文件的完整流程是:在目录中通过文件名查到 inode 编号 → 读取 inode 获得属性与数据块位置 → 读取数据。
6.2 软硬链接实验
touch abc ln abc hard # 创建硬链接 ln -s abc soft # 创建软链接 ls -li # 1183459 -rw-rw-r-- 2 user user 0 ... abc # 1183459 -rw-rw-r-- 2 user user 0 ... hard ← inode 与 abc 相同,链接数变为 2 # 1183466 lrwxrwxrwx 1 user user 3 ... soft -> abc ← 新的 inode,文件类型为 l rm abc cat hard # 正常输出 cat soft # 报错:No such file or directory,软链接失效从实验结果可以看到:硬链接与源文件 inode 编号相同,删除源文件后不受影响;软链接有独立的 inode,其内容是源文件的路径,源文件删除后软链接失效。
6.3 软硬链接的区别
| 对比项 | 硬链接 | 软链接 |
|---|---|---|
| 独立 inode | 没有,与源文件共用 | 有,是独立的新文件 |
| 本质 | 同一 inode 的另一个文件名 | 保存源文件路径的文件 |
| 删除源文件后 | 不受影响 | 失效(悬空) |
| 跨分区 | 不支持 | 支持 |
删除文件的本质是删除目录中的映射项,并将 inode 的硬链接计数减 1;计数减到 0 时,inode 与数据块才真正被释放。
7. 动静态库
7.1 静态库与动态库
库由**头文件(接口声明)+ 库文件(二进制实现)**两部分组成。
- 静态库(.a):编译链接时把库代码拷贝到可执行程序中,程序体积较大,但运行时不依赖外部环境
- 动态库(.so):链接时只记录依赖关系,程序运行时才加载库,体积小,且整个系统共享一份;Linux 默认采用动态链接
7.2 gcc 链接选项
使用自己制作的库编译程序时,需要指定三个选项:
gcc main.c -o main -I ./mylib/inc -L ./mylib/lib -l mymath| 选项 | 含义 |
|---|---|
-I | 指定头文件搜索路径(Include) |
-L | 指定库文件搜索路径(Library) |
-l | 指定库名,自动补全 lib 前缀(写 mymath 即查找 libmymath.a/.so) |
7.3 动态库加载失败的排查
使用自定义动态库的程序,编译通过但运行时报错:
./main: error while loading shared libraries: libmymath.so: cannot open shared object file排查流程:先执行ldd ./main查看缺失的库;原因是编译时的-L只对 gcc 有效,程序运行时由动态加载器搜索库文件,它只查找默认路径。解决方法三选一:将库目录加入LD_LIBRARY_PATH环境变量;把 .so 拷贝到 /lib64;或在 /lib64 下建立指向该库的软链接。
8. 本章小结
本章围绕文件描述符展开: fd 是 files_struct 中 fd_array 数组的下标,分配规则为最小未占用下标,进程默认占用 0/1/2。 重定向的本质是修改 fd 背后指向的文件,shell 的>通过 fork + open + dup2 + exec 实现。 printf 重定向到文件后变为全缓冲,fork 会复制缓冲区中的残留数据,导致退出时内容写入两次;fork 前 fflush 可避免。 inode 保存文件属性,不包含文件名;硬链接共享 inode,软链接是保存路径的独立文件。 静态库链接期拷入程序,动态库运行期加载,加载失败时注意 LD_LIBRARY_PATH 等搜索路径问题。
下一篇介绍进程间通信:匿名管道与共享内存。如果本篇文章有什么不足欢迎指正。