☰
Linux系统编程(二)——基础IO
2026/10/20 11:21:11 网站建设 项目流程

1. 前言

Linux 中"一切皆文件",键盘、显示器、普通文件在系统中都以文件的形式统一管理。本章节主要目标:

  1. 理解文件描述符 fd 的本质与分配规则。
  2. 理解重定向的原理,掌握 dup2 系统调用,分析 shell 中>的实现。
  3. 理解用户级缓冲区的工作机制,解释 printf 重定向后输出重复的现象。
  4. 了解 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 默认打开的三个文件

每个进程创建时默认打开三个文件:

fdC 语言中的名称对应设备
0stdin(标准输入)键盘
1stdout(标准输出)显示器
2stderr(标准错误)显示器

这解释了为什么 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 号下标不受影响

三个关键问题:

  1. 为什么重定向要在子进程中进行?如果 bash 直接修改自己的 1 号下标,后续所有命令的输出都会写入文件。在子进程中修改只影响当前这一条命令。
  2. 为什么要在 exec 之前完成?fd 表会被 exec 之后的程序继承,替换完成后再修改就来不及了。
  3. >与>>的区别是什么?区别仅在 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.txt
hello write hello printf hello printf

printf 的内容出现了两次,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 系统调用,操作对象是 fd
  • fopen/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 等搜索路径问题。

下一篇介绍进程间通信:匿名管道与共享内存。如果本篇文章有什么不足欢迎指正。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询