
大家好,欢迎来到 huangjin007_ 的博客
⭐ 个人主页:huangjin007_
🔥 文章收录专栏:Linux 内功修炼手册(系统篇)
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇
Linux 系统篇(二十五) ------ 系统文件I/O
文章目录
- [Linux 系统篇(二十五) ------ 系统文件I/O](#Linux 系统篇(二十五) —— 系统文件I/O)
-
- 一、从标志位说起
-
- [1.1 问题:如何给函数传递多个开关?](#1.1 问题:如何给函数传递多个开关?)
- [1.2 位运算基础](#1.2 位运算基础)
- [1.3 示例代码与解析](#1.3 示例代码与解析)
- [1.4 为什么系统调用也这么用?](#1.4 为什么系统调用也这么用?)
- [二、系统文件 I/O 初体验](#二、系统文件 I/O 初体验)
-
- [2.1 写文件:hello.c](#2.1 写文件:hello.c)
- [2.2 读文件:hello.c](#2.2 读文件:hello.c)
- [三、open 函数详解](#三、open 函数详解)
-
- [3.1 函数原型与头文件](#3.1 函数原型与头文件)
- [3.2 flags 参数:打开模式与附加选项](#3.2 flags 参数:打开模式与附加选项)
- [3.3 mode 参数与 umask](#3.3 mode 参数与 umask)
- [3.4 返回值:文件描述符](#3.4 返回值:文件描述符)
- [3.5 语言层与系统调用的映射](#3.5 语言层与系统调用的映射)
- [3.6 文本写入 vs 二进制写入](#3.6 文本写入 vs 二进制写入)
- 四、系统调用与库函数
-
- [4.1 库函数:fopen, fclose, fread, fwrite](#4.1 库函数:fopen, fclose, fread, fwrite)
- [4.2 系统调用:open, close, read, write](#4.2 系统调用:open, close, read, write)
- [4.3 关系图](#4.3 关系图)
- [4.4 为什么要有库函数?](#4.4 为什么要有库函数?)
- [五、文件描述符 fd](#五、文件描述符 fd)
-
- [5.1 fd 是什么?](#5.1 fd 是什么?)
- [5.2 默认打开的 0、1、2](#5.2 默认打开的 0、1、2)
- [5.3 文件描述符的分配规则:最小未使用](#5.3 文件描述符的分配规则:最小未使用)
- [5.4 文件描述符的分配规则总结](#5.4 文件描述符的分配规则总结)
- 六、重定向
-
- [6.1 输出重定向:close(1) + open](#6.1 输出重定向:close(1) + open)
- [6.2 重定向的本质](#6.2 重定向的本质)
- [6.3 dup2 系统调用](#6.3 dup2 系统调用)
- [6.4 输入重定向:dup2(fd, 0)](#6.4 输入重定向:dup2(fd, 0))
- 七、深入内核
-
- [7.1 进程控制块 task_struct](#7.1 进程控制块 task_struct)
- [7.2 files_struct 与 fd_array](#7.2 files_struct 与 fd_array)
- [7.3 struct file:打开的文件对象](#7.3 struct file:打开的文件对象)
- [7.4 inode:磁盘文件的元信息](#7.4 inode:磁盘文件的元信息)
- [7.5 一个进程可以打开多个文件,一个文件可以被多个进程打开](#7.5 一个进程可以打开多个文件,一个文件可以被多个进程打开)
- [7.6 引用计数 ref_cnt](#7.6 引用计数 ref_cnt)
- 结语:
一、从标志位说起
1.1 问题:如何给函数传递多个开关?
假设你要写一个函数,它支持三种功能:ONE、TWO、THREE。用户可以单独使用某一个,也可以组合使用。怎么设计参数?
一种笨办法是传三个布尔值:func(bool one, bool two, bool three)。但这样不优雅,而且如果功能增加到几十个,参数列表会爆炸。
更好的办法是:用一个整数的不同二进制位来表示不同的功能。这就是位掩码(bitmask)。
1.2 位运算基础
先复习一下位运算:
&按位与:两个位都为 1 时结果为 1,否则为 0。|按位或:两个位只要有一个为 1,结果就为 1。<<左移:把二进制位向左移动,右边补 0。
例如:
c
#define ONE (1 << 0) // 0000 0001
#define TWO (1 << 1) // 0000 0010
#define THREE (1 << 2) // 0000 0100
#define FOUR (1 << 3) // 0000 1000
这样每个宏只占一个二进制位,互不干扰。
1.3 示例代码与解析
代码:
c
#include <stdio.h>
#define ONE 0001 // 0000 0001
#define TWO 0002 // 0000 0010
#define THREE 0004 // 0000 0100
void func(int flags)
{
if (flags & ONE) printf("flags has ONE! ");
if (flags & TWO) printf("flags has TWO! ");
if (flags & THREE) printf("flags has THREE! ");
printf("\n");
}
int main()
{
func(ONE);
func(THREE);
func(ONE | TWO);
func(ONE | THREE | TWO);
return 0;
}
解析:
ONE、TWO、THREE分别是八进制数0001、0002、0004,对应二进制的001、010、100。func(ONE):传入0001,flags & ONE非零,打印 ONE。func(ONE | TWO):0001 | 0010 = 0011,所以 ONE 和 TWO 都会被打印。- 通过
&运算,可以检测某个位是否被设置;通过|运算,可以组合多个标志。
1.4 为什么系统调用也这么用?
因为系统调用是 C 函数,参数是整数。用位掩码可以在一个整数里塞下几十个开关,既节省参数,又灵活高效。
二、系统文件 I/O 初体验
语言层的 fopen、fwrite 我们很熟悉。现在看看如何用系统调用实现同样的功能。
2.1 写文件:hello.c
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
umask(0); // 设置文件创建时的权限掩码为 0,这样创建的文件权限就是我们指定的 0644
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
if (fd < 0)
{
perror("open");
return 1;
}
int count = 5;
const char *msg = "hello Linux!\n";
int len = strlen(msg);
while (count--)
{
write(fd, msg, len); // fd: 文件描述符,msg: 缓冲区首地址,len: 期望写入的字节数
// 返回值:实际写入的字节数
}
close(fd);
return 0;
}
解释:
umask(0):umask是文件权限掩码。系统在创建文件时,会用mode & ~umask计算实际权限。把umask设为 0,文件权限就完全由open的第三个参数决定。这里我们指定0644,即rw-r--r--。open("myfile", O_WRONLY | O_CREAT, 0644):O_WRONLY:只写打开。O_CREAT:如果文件不存在则创建。0644:创建文件时的权限。- 返回值
fd是文件描述符,是一个非负整数。失败返回 -1。
write(fd, msg, len):把msg指向的len个字节写入文件。返回值是实际写入的字节数。close(fd):关闭文件描述符,释放资源。

2.2 读文件:hello.c
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if (fd < 0)
{
perror("open");
return 1;
}
const char *msg = "hello Linux!\n";
char buf[1024];
ssize_t s;
while (1)
{
s = read(fd, buf, strlen(msg));
if (s > 0)
{
buf[s] = '\0'; // 读到s个字节,在s下标位置补字符串结束符
printf("%s", buf);
}
else
{
// s==0 读到文件末尾;s<0 读取出错
if(s < 0)
{
perror("read");
}
break;
}
}
close(fd);
return 0;
}
三、open 函数详解
3.1 函数原型与头文件
c
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
pathname:要打开或创建的目标文件路径。flags:打开方式,由多个常量按位或(|)组成。mode:创建文件时的权限,仅在使用了O_CREAT时有效。- 返回值:成功返回新的文件描述符(非负整数),失败返回 -1 并设置
errno。
3.2 flags 参数:打开模式与附加选项
必须指定且只能指定一个的访问模式:
O_RDONLY:只读打开。O_WRONLY:只写打开。O_RDWR:读写打开。
常用的附加选项:
O_CREAT:若文件不存在,则创建它。需要使用mode参数指明新文件的访问权限。O_APPEND:追加写。每次写操作前,文件偏移量自动移到文件末尾。O_TRUNC:截断。如果文件已存在,且以只写或读写方式打开,则将其长度截断为 0(清空)。O_EXCL:与O_CREAT一起使用,如果文件已存在,则open失败。用于确保创建的是新文件。
示例:
c
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666); // 类似 "w"
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666); // 类似 "a"
int fd = open("log.txt", O_RDONLY); // 类似 "r"
3.3 mode 参数与 umask
mode 是一个八进制数,表示文件权限。比如 0644 表示:
- 所有者:读、写(6)
- 组:读(4)
- 其他:读(4)
实际创建的文件权限是 mode & ~umask。umask 是进程的文件权限掩码,通常默认为 022。所以 0644 & ~022 = 0644。如果 umask 是 002,那么 0644 & ~002 = 0644 不变;如果 umask 是 077,那么 0644 & ~077 = 0600。
可以用 umask 命令查看当前 shell 的掩码,也可以用 umask() 函数在程序中设置。
3.4 返回值:文件描述符
open 成功时返回一个文件描述符,它是一个小的非负整数。失败返回 -1。
文件描述符是本文的核心概念,后面会详细讲。
3.5 语言层与系统调用的映射
C 标准库的 fopen 有几种模式:
"r":只读打开,对应O_RDONLY。"w":只写打开,文件存在则清空,不存在则创建,对应O_WRONLY | O_CREAT | O_TRUNC。"a":追加写,文件不存在则创建,对应O_WRONLY | O_CREAT | O_APPEND。"r+":读写打开,对应O_RDWR。"w+":读写打开,清空或创建,对应O_RDWR | O_CREAT | O_TRUNC。"a+":读写追加,对应O_RDWR | O_CREAT | O_APPEND。
所以,fopen 内部就是根据这些字符串拼出 flags,然后调用 open。
3.6 文本写入 vs 二进制写入
系统调用不关心 你写的是文本还是二进制。write 只认字节流:你给它一个缓冲区,它就把里面的字节原样写入文件。所谓的"文本模式"和"二进制模式"是 C 标准库在 Windows 等平台上的概念(比如换行符转换)。在 Linux 上,文本和二进制没有区别,系统只认字节。
因此,你可以用 write 写入整数、结构体、图片等任何数据,只要把它们转成字节序列即可。
四、系统调用与库函数
4.1 库函数:fopen, fclose, fread, fwrite
这些是 C 标准库(libc)提供的函数,我们称之为库函数。它们运行在用户态,提供了更高级、更可移植的接口。
4.2 系统调用:open, close, read, write
这些是操作系统内核提供的接口,我们称之为系统调用。它们直接操作文件描述符,是底层的。
4.3 关系图

fopen 等库函数内部会调用 open 等系统调用。比如 fwrite 最终会调用 write。
4.4 为什么要有库函数?
- 可移植性 :不同操作系统的系统调用可能不同,但 C 标准库的接口是统一的。程序用
fopen可以在 Linux、Windows、macOS 上编译运行,而open是 Linux 特有的。- 缓冲 :库函数自带用户态缓冲区,减少系统调用次数,提高效率。比如
fwrite可能先写到缓冲区,攒够了一定数量再调用write。- 格式化 :
printf、fprintf等提供了格式化输出,系统调用write只能写原始字节。
五、文件描述符 fd
5.1 fd 是什么?
通过 open 返回的 fd,是一个小的非负整数。比如 3、4、5......它到底是什么?
本质:文件描述符是进程的文件描述符表中的数组下标。
操作系统为了管理进程打开的文件,在每个进程的 PCB(进程控制块)中维护了一个文件描述符表 。这个表是一个指针数组,每个元素指向一个打开文件对象 (struct file)。数组的下标就是文件描述符。
所以,你拿着 fd=3,操作系统就能通过 files_struct 找到对应的 struct file,从而找到文件的所有信息。

5.2 默认打开的 0、1、2
Linux 进程在启动时,默认会打开三个文件描述符:
0:标准输入(stdin),通常对应键盘。1:标准输出(stdout),通常对应显示器。2:标准错误(stderr),通常对应显示器。
在 C 语言中,stdin、stdout、stderr 是 FILE* 类型的指针,它们内部封装了对应的文件描述符。
示例:用 read/write 操作标准输入输出
c
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
char buf[1024];
ssize_t s = read(0, buf, sizeof(buf)); // 从标准输入读
if (s > 0)
{
buf[s] = 0;
write(1, buf, strlen(buf)); // 写到标准输出
write(2, buf, strlen(buf)); // 写到标准错误
}
return 0;
}
这段代码从键盘读取输入,然后分别输出到显示器和标准错误。因为 0、1、2 已经默认打开,所以可以直接使用。
5.3 文件描述符的分配规则:最小未使用
当进程打开一个新文件时,操作系统会在 files_struct 的数组中,找到当前没有被使用的最小的下标,作为新的文件描述符。
验证代码:
c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if (fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
因为 0、1、2 已经被占用,所以新打开的 fd 通常是 3。

如果关闭 0:
c
close(0);
int fd = open("myfile", O_RDONLY);
printf("fd: %d\n", fd); // 输出 0

如果关闭 2:
c
close(2);
int fd = open("myfile", O_RDONLY);
printf("fd: %d\n", fd); // 输出 2

这就验证了分配规则:最小未使用。
5.4 文件描述符的分配规则总结
- 默认打开 0、1、2。
- 新打开的 fd 是当前未被使用的最小下标。
- 关闭一个 fd 后,它的下标可以被重新分配。
六、重定向
6.1 输出重定向:close(1) + open
看这段代码:
c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
close(1); // 关闭标准输出
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
if (fd < 0) {
perror("open");
return 1;
}
printf("fd: %d\n", fd);
fflush(stdout);
close(fd);
exit(0);
}

现象: 本来应该输出到显示器的内容,现在输出到了 myfile 文件中,而且 fd 是 1。
原因: 我们关闭了 1 号文件描述符(标准输出),然后 open 新文件时,根据"最小未使用"原则,新文件占用了 1 号。此时 fd_array[1] 指向的不再是显示器,而是 myfile。printf 默认往 stdout 输出,而 stdout 底层就是 1 号 fd,所以内容写到了 myfile。
这就是输出重定向 。常见的 shell 重定向 >、>>、< 就是基于这个原理。
6.2 重定向的本质
重定向的本质:更改文件描述符表的指针指向。
fd_array 的下标(如 1)没有变,但它指向的 struct file 对象变了。所有往这个 fd 写数据的行为,都会写到新的文件。
6.3 dup2 系统调用
手动 close + open 实现重定向有点麻烦,而且容易出错。Linux 提供了 dup2 系统调用,专门用来复制文件描述符。
函数原型:
c
#include <unistd.h>
int dup2(int oldfd, int newfd);
功能: 让 newfd 成为 oldfd 的副本。如果 newfd 已经被打开,会先自动关闭它。成功返回新的描述符(即 newfd),失败返回 -1。
官方描述: dup2() makes newfd be the copy of oldfd, closing newfd first if necessary.
示例:输出重定向
c
#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;
}
// close(1); // dup2 会自动关闭 newfd,所以这行可以省略
dup2(fd, 1); // 让 1 号 fd 指向 fd 所指向的文件
for (;;)
{
char buf[1024] = {0};
ssize_t read_size = read(0, buf, sizeof(buf) - 1);
if (read_size < 0)
{
perror("read");
break;
}
printf("%s", buf);
fflush(stdout);
}
return 0;
}
过程:
fd是新打开文件的描述符(比如 3)。dup2(fd, 1):让 1 号描述符指向fd指向的struct file。原本 1 号指向显示器,现在指向log文件。显示器的struct file引用计数减 1,如果减到 0 就关闭。- 之后
printf往stdout(即 1 号)写,就写到了log文件。
注意: dup2 会自动关闭 newfd,所以不需要手动 close(1)。
6.4 输入重定向:dup2(fd, 0)
类似地,可以重定向标准输入:
c
int fd = open("input.txt", O_RDONLY);
dup2(fd, 0); // 让 0 号 fd 指向 input.txt
close(fd); // 关闭多余的 fd
char buffer[64];
while (fgets(buffer, sizeof(buffer), stdin)
{
printf("%s", buffer);
}
这样,fgets 从 stdin 读取时,实际是从 input.txt 读取。

七、深入内核
要真正理解文件描述符,必须看看内核中的数据结构。我们以 Linux 内核为例(版本不同,细节可能略有差异,但思想一致)。

7.1 进程控制块 task_struct
每个进程在内核中都有一个 task_struct 结构体,它包含了进程的所有信息。其中有一个成员:
c
struct files_struct *files;
它指向该进程的文件描述符表。
7.2 files_struct 与 fd_array
files_struct 是文件描述符表,内部最重要的部分是一个指针数组:
c
struct file *fd_array[NR_OPEN_DEFAULT];
这个数组的每个元素都是一个指向 struct file 的指针。数组的下标就是文件描述符。
7.3 struct file:打开的文件对象
struct file 代表一个已经打开的文件。注意:它不代表磁盘上的文件本身,而是代表"进程与文件的一次打开会话"。
它包含:
int ref_cnt;:引用计数。有多少个文件描述符指向它。- 文件读写位置(偏移量)。
- 文件操作函数指针(如
read、write的具体实现)。- 指向
inode的指针(磁盘文件的元信息)。- 文件缓冲区(内核态)。
7.4 inode:磁盘文件的元信息
inode 是磁盘上文件的元信息,包括文件大小、权限、创建时间、数据块位置等。每个文件在磁盘上都有一个唯一的 inode。struct file 通过指针关联到 inode,从而找到文件的实际数据。
7.5 一个进程可以打开多个文件,一个文件可以被多个进程打开
- 一个进程可以打开多个文件:
files_struct的数组中有多个struct file指针。- 一个文件可以被多个进程打开:每个进程的
open会创建不同的struct file对象,但它们都指向同一个inode。struct file中的ref_cnt记录有多少个这样的对象。- 同一个进程也可以多次打开同一个文件:每次
open都会创建新的struct file,返回不同的 fd。
7.6 引用计数 ref_cnt
当 dup2 或 fork 等操作导致多个 fd 指向同一个 struct file 时,ref_cnt 增加。当 close 一个 fd 时,ref_cnt 减少。减到 0 时,内核才会释放这个 struct file 对象。
图示:
进程 A
task_struct
files (files_struct)
fd_array[0] --> struct file (键盘)
fd_array[1] --> struct file (显示器)
fd_array[2] --> struct file (显示器)
fd_array[3] --> struct file (myfile) --> inode (myfile)
当 close(1) 后,fd_array[1] 置空。再 open 新文件,新文件占用 1 号,fd_array[1] 指向新的 struct file。
结语:
今天的内容到这里就结束了,希望你能有所收获~
干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _