如何理解 "文件"?
狭义理解
-
文件通常指存储在磁盘上的数据集合。
-
磁盘是一种永久性存储介质,所以文件在断电后依然存在。
-
磁盘属于外设 (既是输入设备也是输出设备),因此对文件的所有操作,本质上都是对设备的输入/输出,简称为 I/O(Input/Output)。
广义理解
-
在 Linux 下,一个核心设计哲学是:一切皆文件。
-
这不仅仅是说普通文件,还包括键盘、显示器、网卡、管道、进程等,它们都被统一抽象为"文件"。
-
这种抽象使得开发者可以用一套统一的接口(open、read、write、close)来操作各种不同的硬件和逻辑资源。
文件 = 属性 + 内容
-
一个空文件(0 KB)也会占用磁盘空间,因为它需要存储元数据(如文件名、大小、权限、时间戳等)。
-
文件操作分为两类:
-
内容操作:读写数据。
-
属性操作:修改权限、查看大小等。
-
系统角度
-
对文件的操作,本质上是进程对文件的操作。没有进程,就没有文件操作。
-
磁盘的管理者是操作系统 ,因此任何进程想要访问文件,都必须通过操作系统提供的系统调用接口,而不能直接操作磁盘硬件。
C 语言文件 I/O 接口
打开文件:fopen
FILE *fp = fopen("myfile", "w");
打开的文件 myfile 会在哪个路径下?
当前进程的工作目录 。每个进程都有一个 cwd(Current Working Directory),可以通过 /proc/<pid>/cwd 查看。
写文件:fwrite
const char *msg = "hello bit!\n";
fwrite(msg, strlen(msg), 1, fp);
读文件:fread
ssize_t s = fread(buf, 1, strlen(msg), fp);
输出到显示器的多种方式
fwrite("hello fwrite\n", 1, strlen("hello fwrite\n"), stdout);
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
-
stdin、stdout、stderr是 C 默认打开的三个标准流,类型都是FILE*。 -
它们对应的文件描述符分别是 0、1、2。
系统级文件 I/O 接口
C 库函数虽然方便,但它们本质上是对系统调用的封装。底层真正干活的是系统调用。
标志位传递技巧
Flag 就是一个开关 或选项,用二进制的一位来表示某个状态是否开启。
优点:一个整数可以表示多个开关状态
系统调用(如 open)常常使用位掩码(bitmask)来传递多个选项:
#define ONE 0001 // 0001
#define TWO 0002 // 0010
#define THREE 0004 // 0100
void func(int flags) {
if (flags & ONE) printf("ONE ");
if (flags & TWO) printf("TWO ");
if (flags & THREE) printf("THREE ");
}
func(ONE | TWO); // 同时传递 ONE 和 TWO
| 标志 | 含义 |
|---|---|
O_RDONLY |
只读打开(Read ONLY) |
O_WRONLY |
只写打开(Write ONLY) |
O_RDWR |
读写打开(Read + Write) |
写文件:open + write
umask(0); // 设置文件创建掩码
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
//成功:返回文件描述符(一个非负整数,如 3)
//失败:返回 -1,并设置 errno
write(fd, msg, len);
// │ │ │
// │ │ └─ 要写入的字节数
// │ └────── 数据缓冲区指针
// └─────────── 文件描述符(从open获得)
//返回值
//成功:返回实际写入的字节数
//失败:返回 -1
close(fd);
读文件:open + read
open("myfile", O_RDONLY);
// │ └───── 打开标志:只读
// └─────────────── 文件名
ssize_t read(int fd, void *buf, size_t count);
// │ │ │ │
// │ │ │ └─ 要读取的最大字节数
// │ │ └──────────── 存储数据的缓冲区
// │ └──────────────────── 文件描述符
// └──────────────────────────── 返回值类型(有符号)
主要接口说明
| 系统调用 | 功能 | 头文件 |
|---|---|---|
open |
打开/创建文件 | <fcntl.h> |
read |
从文件读取数据 | <unistd.h> |
write |
向文件写入数据 | <unistd.h> |
close |
关闭文件描述符 | <unistd.h> |
lseek |
移动文件读写指针 | <unistd.h> |
重要参数:
-
O_RDONLY/O_WRONLY/O_RDWR:必须且只能选一个。 -
O_CREAT:文件不存在则创建,需要指定mode(权限)。 -
O_APPEND:追加写入。 -
O_TRUNC:打开时清空文件内容。
文件描述符(fd)与重定向
什么是文件描述符?
open 的返回值是一个小整数,这就是文件描述符(file descriptor)。
-
默认情况下,每个 Linux 进程会打开三个文件描述符:
-
0:标准输入(stdin) -
1:标准输出(stdout) -
2:标准错误(stderr)read(0, buf, sizeof(buf)); // 从键盘读
write(1, buf, strlen(buf)); // 输出到屏幕
write(2, buf, strlen(buf)); // 输出到错误输出
-
内核中的数据结构
每个进程在内核中有一个 task_struct,其中包含一个指向 files_struct 的指针。
-
files_struct中有一个指针数组fd_array[],每个元素指向一个已打开文件的file结构体。 -
文件描述符就是这个数组的下标。
进程 task_struct
└── files_struct
└── fd_array[0] ──> file (stdin)
└── fd_array[1] ──> file (stdout)
└── fd_array[2] ──> file (stderr)
└── fd_array[3] ──> file (我们打开的文件)
分配规则
总是分配当前未被使用的最小下标。
close(0); // 释放 0
int fd = open("myfile", O_RDONLY);
printf("fd: %d\n", fd); // 输出 fd: 0
重定向的本质
当我们执行 close(1) 再打开一个新文件时,新文件会分配到 fd = 1。
close(1);
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
printf("hello\n"); // 本来应该输出到屏幕,现在输出到 myfile
这就是输出重定向的本质 :改变 fd 数组下标指向的 file 对象。
更优雅的重定向:dup2
dup2(oldfd, newfd) 让 newfd 指向 oldfd 所指向的文件,并关闭 newfd 原来的文件。
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
dup2(fd, 1); // 将标准输出重定向到 log.txt
printf("这条消息会写入文件,而不是屏幕\n");
理解 "一切皆文件" 的内核实现
struct file 与 file_operations
-
每个打开的文件在内核中都对应一个
struct file。 -
关键在于
struct file中的f_op成员,它指向一个struct file_operations结构体。 -
file_operations中包含了大量的函数指针,如read、write、open、release、poll等。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 *);
// ...
};
多态的魅力
-
对于磁盘文件 ,
read指向磁盘驱动函数。 -
对于键盘 ,
read指向键盘驱动函数。 -
对于显示器 ,
write指向显示驱动函数。
对于上层应用来说,我们永远调用 read(fd, ...) 或 write(fd, ...),由内核根据 fd 找到对应的 file,再调用相应的 file_operations 中的函数。
结论 :通过 struct file 和函数指针,Linux 实现了对所有设备的统一抽象,这就是 "一切皆文件" 的真谛。
缓冲区机制详解
什么是缓冲区?
缓冲区是内存中的一块区域,用于暂存数据,以减少系统调用的次数。
为什么需要缓冲区?
-
系统调用(如
read/write)会触发 CPU 从用户态切换到内核态,开销较大。 -
磁盘 I/O 速度远慢于内存。
-
通过缓冲区,我们可以积累一定量的数据后一次性进行系统调用,大幅提升效率。
三种缓冲类型(C 标准库提供)
| 类型 | 刷新时机 | 适用场景 |
|---|---|---|
| 全缓冲 | 缓冲区满或主动 flush | 普通磁盘文件 |
| 行缓冲 | 遇到换行符 \n 或缓冲区满 |
终端(屏幕) |
| 无缓冲 | 立即输出 | stderr(错误信息) |
一个经典案例:fork() + 重定向
printf("hello printf\n");
fwrite("hello fwrite\n", 1, strlen("hello fwrite\n"), stdout);
write(1, "hello write\n", strlen("hello write\n"));
fork();
-
直接运行到终端(行缓冲):看到三条输出各一次。
-
重定向到文件(全缓冲):
-
write是系统调用,没有用户级缓冲区,直接写入文件,输出一次。 -
printf和fwrite是库函数,自带缓冲区。由于是全缓冲,数据暂存在缓冲区中,fork后,子进程复制了父进程的缓冲区。父进程退出刷新缓冲区,子进程退出也刷新缓冲区,导致输出两次。
-
核心结论:
-
printf/fwrite的缓冲区是 C 库提供的用户级缓冲区 ,与write系统调用无关。 -
系统调用本身没有用户级缓冲区,但内核有自己的页缓存(内核级缓冲区)。
FILE 结构体内部
因为IO相关函数与系统调⽤接⼝对应,并且库函数封装系统调用,所以本质上,访问文件都是通
过fd访问的。
所以FILE不仅仅是一个指针,它内部一定封装了文件描述符和缓冲区相关字段:
struct _IO_FILE {
int _fileno; // 封装的 fd
char* _IO_read_ptr; // 读缓冲区指针
char* _IO_write_ptr;// 写缓冲区指针
// ...
};
Linux I/O 数据结构关系详解
用最通俗的语言,把整个 I/O 系统的数据结构关系说清楚。
为了方便理解,我们先打个比方:
-
进程就像一个"公司"。
-
task_struct就是这个公司的"营业执照",上面记录了公司的一切信息。 -
files_struct是公司的"固定资产登记表",记录了公司所有在用的设备。 -
文件描述符(fd) 就是给每个设备编的"资产编号"(0号是键盘,1号是屏幕,2号是错误输出)。
-
struct file是每个设备的"使用登记卡",记录了当前这个设备被谁在用、用到什么位置了。 -
file_operations是设备的"操作说明书",告诉你怎么用这个设备(怎么读、怎么写)。 -
inode是设备的"身份证",记录了它本身的属性(大小、权限、创建时间等)。 -
FILE*是 C 库给每个设备配的"秘书",秘书手里有个小本本(缓冲区),先记下来再统一汇报给系统,减少打扰领导的次数。
核心数据结构关系
task_struct 与 files_struct
关系:包含关系
-
每个进程在内核中都有一个
task_struct结构体,它记录了进程的所有信息。 -
task_struct里面有一个成员叫files,它是一个指针,指向一个files_struct结构体。 -
简单说 :进程通过
task_struct找到了自己的files_struct。task_struct(进程控制块)
│
└── files 指针 ──────→ files_struct(文件描述符表)
files_struct 与文件描述符(fd)
关系:数组存储关系
-
files_struct里面最重要的东西是一个指针数组,叫fd_array[]。 -
这个数组的下标就是文件描述符(fd),是一个从 0 开始的整数。
-
数组的每个元素 都是一个指针,指向一个
struct file结构体。 -
简单说:fd 就是数组下标,通过 fd 就能找到对应的文件对象。
files_struct
│
└── fd_array[](指针数组)
│
├── [0] ──→ struct file(标准输入,键盘)
├── [1] ──→ struct file(标准输出,屏幕)
├── [2] ──→ struct file(标准错误,屏幕)
├── [3] ──→ struct file(我们自己打开的文件)
└── [4] ──→ NULL(未使用)
struct file 与 file_operations
关系:指向关系
-
struct file里面有一个成员叫f_op,它是一个指针,指向一个file_operations结构体。 -
file_operations里面全是函数指针,比如.read、.write、.open、.release等。 -
简单说 :
struct file通过f_op知道了"怎么操作这个文件"。struct file(打开的文件)
│
├── f_inode ──→ inode(文件属性)
├── f_pos(当前读写位置)
├── f_count(引用计数,几个 fd 在用)
├── f_flags(打开方式:只读、只写等)
│
└── f_op ──→ file_operations(函数表)
│
├── .read = 某驱动_read函数
├── .write = 某驱动_write函数
├── .open = 某驱动_open函数
└── .release = 某驱动_close函数
struct file 与 inode
关系:多对一关系
-
inode代表一个"文件实体",记录了文件的静态属性(大小、权限、创建时间、数据在磁盘的位置等)。 -
一个
inode可以对应多个struct file。 -
场景1 :同一个进程打开同一个文件两次,得到两个
struct file,但都指向同一个inode。 -
场景2 :两个进程打开同一个文件,得到两个
struct file,但都指向同一个inode。 -
简单说 :
inode是"文件本身",struct file是"这次打开的状态"。磁盘上的文件 "data.txt"
│
└── inode(唯一,存储文件属性)
│
├── 被进程 A 打开 → struct file A(f_pos=0,引用计数1)
├── 被进程 A 再次打开 → struct file A2(f_pos=0,引用计数1)
└── 被进程 B 打开 → struct file B(f_pos=0,引用计数1)三个 struct file 的 f_inode 都指向同一个 inode
区别总结:
| 概念 | 代表什么 | 生命周期 | 存在位置 |
|---|---|---|---|
inode |
文件本身 | 文件创建到删除 | 磁盘 + 内存缓存 |
struct file |
一次打开的状态 | open() 到 close() |
内核内存 |
| 文件描述符 fd | struct file 的索引 |
open() 到 close() |
进程的 files_struct |
文件描述符(fd)与 FILE*
关系:封装关系
-
FILE*是 C 标准库提供的结构体指针,定义在stdio.h中。 -
FILE结构体内部有一个成员叫_fileno,它就是对应的文件描述符(fd)。 -
简单说 :
FILE*是fd的"包装盒",里面除了 fd,还加了缓冲区等功能。FILE 结构体(C 库)
│
├── _fileno(封装的 fd,比如 3)
├── _IO_read_ptr(读缓冲区指针)
├── _IO_write_ptr(写缓冲区指针)
├── _IO_buf_base(缓冲区起始地址)
└── _IO_buf_end(缓冲区结束地址)这个 FILE 结构体的地址就是 fopen() 返回的 FILE*
关键理解:
-
fopen()内部先调用open()获得 fd,再创建FILE结构体包装它。 -
fwrite()先把数据写到FILE的缓冲区,条件满足时再通过write()系统调用把数据刷到内核。 -
printf()是fprintf(stdout, ...)的简化版,stdout就是一个全局的FILE*,它的_fileno= 1。
多个 fd 指向同一个 struct file:dup() 和 fork()
关系:共享关系
-
dup(fd):创建一个新的 fd,新 fd 和旧 fd 指向同一个struct file。 -
fork():子进程复制父进程的files_struct,子进程的 fd 也指向和父进程相同的struct file。 -
此时,
struct file中的f_count引用计数会增加。 -
后果 :这些 fd 共享
f_pos(读写位置),互相影响。情况1:dup()
fd=3 ──→ struct file A(f_count=2)
fd=4 ──→ 同一个 struct file A(dup 返回的新 fd)此时 read(fd=3) 和 read(fd=4) 共享读写位置情况2:fork()
父进程 fd=3 ──→ struct file A(f_count=2)
子进程 fd=3 ──→ 同一个 struct file A(复制了 fd 表)父子进程共享读写位置
file_operations 与设备驱动的关系
关系:回调关系
-
设备驱动在初始化时,会注册自己的
file_operations结构体。 -
当用户调用
read()时,内核通过 fd 找到struct file,再找到file_operations,然后调用其中的.read函数指针。 -
简单说 :
file_operations是驱动提供的"服务清单",内核根据这个清单来调用驱动的功能。一个磁盘驱动注册的 file_operations:
.read = ext4_read_file
.write = ext4_write_file
.open = ext4_open_file
.release= ext4_release_file一个键盘驱动注册的 file_operations:
.read = keyboard_read_key
.write = NULL(键盘不需要写)
.open = keyboard_open
.release= keyboard_release一个显示驱动注册的 file_operations:
.read = NULL(显示器不需要读)
.write = display_write_char
.open = display_open
.release= display_release
统一调用:
// 用户调用 read(fd, buf, size)
// 内核内部实际执行的是:
ret = file->f_op->read(file, buf, size, &file->f_pos);
// 不管 fd 对应的是磁盘、键盘还是其他设备,调用方式完全一样!
总结
-
进程通过
task_struct找到files_struct,再通过 fd 找到struct file,再通过file_operations调用驱动,最终操作硬件。 这是一条完整的调用链。 -
fd 就是个数组下标,分配规则是最小未使用。重定向就是换掉数组中某个下标指向的对象。
-
"一切皆文件"是靠
file_operations函数表实现的,不同设备提供不同的函数表,但上层调用接口统一。 -
FILE*是对 fd 的封装,加上缓冲区是为了减少系统调用次数,提升性能。 -
缓冲区问题 是 C 库的"用户级缓冲",与系统调用的"内核级缓冲"是两码事。
fork()会复制用户级缓冲区,导致某些奇怪的现象。