【Linux 系统】基础IO——Linux中对文件的理解
13.基础IO(1)
文章目录
- 13.基础IO(1)
- 文件的基本概念:内容与属性
- 文件的打开机制:fopen 和 open
- 被打开的文件与磁盘文件的区别
- 文件的内核数据结构
- 文件与进程的交互方式
- 标准输入/输出/错误与文件流
- 系统调用与文件描述符
- 文件打开模式(r/w/a/a+)与权限控制
- C 语言与系统调用在文件管理中的应用
- 权限控制与 `umask` 的影响
- 位图与标志位参数传递的机制
文件的基本概念:内容与属性
在 Linux 中,文件是操作系统存储数据的基本单位,它包含两部分:内容(即数据本身)和属性(metadata)。属性包括文件名、类型、大小、所有者、权限、创建/修改时间等元信息。据讲稿与相关资料指出,任何文件都包含内容和属性,即使一个文件没有任何内容(空文件),它仍具有名称、权限等属性,并且这些属性也需要占用磁盘空间。例如,用 ls -l
可以看到空文件大小为 0,但目录中仍保留了它的 inode 信息和属性;这是因为文件的属性也记录在磁盘上。
文件的内容则存储在磁盘的数据块中,当文件被打开后才会被加载到内存供进程访问。根据冯诺依曼体系结构,CPU 只能直接访问内存而无法直接访问磁盘,因此要访问一个文件,必须先将文件加载到内存,这个过程即为“打开文件”。综上,文件 = 内容 + 属性,而文件的属性往往保存在 inode 等结构中。
【课外补充】在 Linux 文件系统中,每个文件在磁盘上用 inode(索引节点)来记录其元数据和数据块位置,包括权限、类型、大小、时间戳、硬链接数等信息。例如,ls -il
显示的第一列就是 inode 编号,通过 inode 可以获取文件的各种属性。
文件的打开机制:fopen 和 open
在编程中访问文件时,必须显式地打开文件。C 语言标准库提供 fopen
函数(位于 <stdio.h>
)来打开文件,返回一个 FILE*
类型的文件指针供后续读写使用;而在系统调用层面,Linux 提供了 open
系统调用(位于 <fcntl.h>
)来打开文件,返回一个非负整数的文件描述符(file descriptor)。两者的主要区别是:fopen
是库函数,会对调用参数进行封装并返回 FILE*
;open
是直接与内核交互的系统调用,需要传入文件路径、标志位 (flags) 和权限模式 (mode),返回一个 int
型文件描述符。
只有当程序执行到打开文件的语句时,文件才真正被加载到内存中。例如,在下面代码段中,只有当 fopen
或 open
运行时,文件才被打开:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>int main() {// 使用 fopen 打开文件,返回 FILE*FILE *fp = fopen("example.txt", "w");if (fp == NULL) {perror("fopen");return 1;}fputs("Hello, world!\n", fp);fclose(fp);// 使用 open 打开文件,返回文件描述符int fd = open("example2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);if (fd < 0) {perror("open");return 1;}write(fd, "Hello, world!\n", 14);close(fd);return 0;
}
在上例中,直到程序运行到 fopen
或 open
并执行成功时,文件才真正被打开并加载到内存中。执行 fopen
返回 FILE*
后,可以通过 fread/fwrite/fputs
等接口对文件内容进行读写;执行 open
返回的文件描述符可以与 read
、write
、lseek
等系统调用配合使用。如果打开失败,fopen
返回 NULL
,open
返回 -1
,并设置相应的错误码。
被打开的文件与磁盘文件的区别
磁盘上的文件和被打开的文件是两个不同的概念。磁盘上的文件是静态存在的,包含文件内容和元数据(属性),但尚未加载到内存。只有当进程调用 fopen
或 open
并成功时,文件才被打开,它的内容和属性才被加载到内存,并由内核为其分配数据结构以供访问。这种加载过程类似将文件从磁盘映射到内核空间,使得 CPU 可以直接操作它。
简言之:未打开的文件只能在磁盘上存在;被打开的文件则在内存中有对应结构体供进程访问。根据讲稿和资料的描述,当我们打开一个文件时,操作系统会把文件的内容和属性加载到内存中。在内存中的文件通常用文件对象(如 Linux 内核中的 struct file
)来表示,该结构体包含文件的各种属性以及指向文件数据的指针。相比之下,磁盘上的文件只是存储在文件系统上的数据块和 inode 信息。
因此,在一个系统中存在大量磁盘文件,但只有当进程需要时才会打开其中的少数文件。例如,一个 Linux 系统可能有上万文件,但同时被打开的可能只有几百个。操作系统需要跟踪管理哪些文件已打开、对应哪个进程以及何时关闭,保障文件访问的正确性和资源回收。
文件的内核数据结构
在 Linux 内核中,每个被打开的文件都会对应内核级数据结构来表示其状态和属性。常见的结构有 struct file
(表示已打开的文件实例)和 struct inode
(表示磁盘上的文件元数据)。讲稿中提到,文件在内核中本质上也等于 内容 + 属性,即有对应的结构体来描述,包括文件大小、权限、读写位置等信息。我们可以把 struct file
理解为文件的“内核镜像”,它会保存文件当前位置(offset)、文件系统操作函数指针、引用计数等,当进程通过文件描述符读写文件时,实际上是通过这些结构体进行操作的。
操作系统如何管理被打开的文件呢?每个进程都有一个 task_struct
,其中包含一个指向 struct files_struct
的指针。files_struct
中维护了一个文件描述符数组 fd_array
,该数组的每个元素指向一个内核中的 struct file
结构。当进程调用 open
打开新文件时,内核会在这个数组中找到一个空闲的下标(例如 3、4、5 等)分配给该文件,并返回该下标作为文件描述符。可以说,系统层面访问文件的唯一途径就是文件描述符。例如,进程启动时默认占用 0、1、2 三个文件描述符(分别对应 stdin
、stdout
、stderr
),后续打开的第一个文件会获得描述符 3。
此外,struct file
结构中还包含一个引用计数,用以记录有多少个文件描述符或进程引用该文件。这意味着同一个文件可以被多个描述符(甚至不同进程)共享读取或写入。文件操作(读/写/关闭)最终都会通过这些内核结构执行,内核维护的这种数据结构保证了对文件的并发访问和正确释放。
文件与进程的交互方式
在 Linux 中,进程是访问和操作文件的主体。只有进程才能调用 fopen
、open
等接口来操作文件。讲稿强调,当程序中出现了 fopen
或 open
语句,并且进程实际执行到这一句时,文件才被打开。也就是说,即便源代码中有 fopen
调用,如果程序尚未运行或者未执行到该行,文件也不会被打开。执行完成后,进程会得到一个文件指针或文件描述符,通过它可以进行后续的读写操作,最后再调用 fclose
或 close
关闭文件释放资源。
一个进程可以同时打开多个文件。实际上,每个进程启动时就自动打开了三个标准流(stdin
,stdout
,stderr
),而用户程序可以根据需要继续打开其他文件。操作系统为每个进程维护独立的文件描述符表,确保各进程对文件的操作互不干扰。当进程结束或主动关闭文件时,内核会关闭对应的 struct file
,更新引用计数,并回收内存和描述符。
总之,文件操作的发生总是伴随着一个进程:访问文件的始终是进程,而非静态的代码文本。进程运行时必须先打开文件,此时操作系统将文件加载到内存,并返回用于标识该文件的文件描述符或 FILE*
。后续对文件内容的读写,都是通过该进程内的指针或描述符发起的。进程关闭文件后,文件可以从内存中卸载,相关资源被释放。
标准输入/输出/错误与文件流
每个进程默认启动时都会打开三个标准流,用于与外界(键盘、显示器等)进行交互:标准输入(stdin
,通常对应键盘,文件描述符 0)、标准输出(stdout
,通常对应显示器,文件描述符 1)和标准错误(stderr
,也对应显示器或终端,文件描述符 2)。在 C 语言中,这三个标准流都是 FILE*
类型指针,分别指向 stdin
,stdout
,stderr
。例如,printf
默认向 stdout
写入,而 scanf
则从 stdin
读取。虽然键盘和显示器是硬件设备,但 C 标准库将它们抽象为文件流(通过底层的系统调用与操作系统交互),因此对它们的读写操作与普通文件类似。
标准流的具体对应关系如下:
stdin
:标准输入(键盘),文件描述符 0。stdout
:标准输出(显示器或终端),文件描述符 1。stderr
:标准错误(显示器或终端),文件描述符 2。
例如,下面的代码会从标准输入读取一行,然后再通过标准输出打印出来:
#include <stdio.h>int main() {char buf[100];// 从标准输入(stdin)读一行if (fgets(buf, sizeof(buf), stdin)) {// 将读取到的内容打印到标准输出(stdout)printf("你输入了: %s", buf);}return 0;
}
可以看到,我们使用 stdin
和 stdout
完成了输入输出。由于它们都是 FILE*
,底层实际上对应文件描述符 0 和 1。总之,标准输入/输出/错误在实现上也是文件,只是内核默认为每个新进程打开了这三个文件流。
系统调用与文件描述符
在 Linux 中进行文件操作的系统调用有 open
、close
、read
、write
等。文件描述符是内核为进程打开文件后分配的整数句柄,用于索引进程的文件描述符表。调用 open
时,如果成功,内核返回一个非负整数(通常从 3 开始,因为 0、1、2 已被标准流占用)。这个整数就是文件描述符。例如:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>int main() {int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);if (fd < 0) {perror("open失败");return 1;}printf("打开文件得到文件描述符: %d\n", fd);// 可以使用 write(fd, ..., ...) 和 close(fd)close(fd);return 0;
}
上例中,如果 log.txt
打开成功,将打印出类似 3
这样的文件描述符值。打开失败时 fd
为 -1
,并通过 perror
输出错误信息。我们注意到文件描述符是一个索引,它指向内核中某个 struct file
结构。只有通过文件描述符,系统调用才能找到对应的文件资源。
此外,所有针对文件的系统调用(如 read
、write
、lseek
、close
等)都以文件描述符作为参数。内核通过进程的 files_struct
中的 fd_array
找到对应的内核文件对象,然后执行操作。因此,系统层面上访问文件的唯一途径就是文件描述符。正因如此,FILE*
之类的用户态结构内部也保存了一个文件描述符(可通过 fp->_fileno
获取),以便通过系统调用完成实际的读写。
⽽现在知道,⽂件描述符就是从0开始的⼩整数。当我们打开⽂件时,操作系统在内存中要创建相应的数据结构来描述⽬标⽂件。于是就有了file结构体。表⽰⼀个已经打开的⽂件对象。⽽进程执⾏open系统调⽤,所以必须让进程和⽂件关联起来。每个进程都有⼀个指针*files, 指向⼀张表files_struct,该表最重要的部分就是包含⼀个指针数组,每个元素都是⼀个指向打开⽂件的指针!所以,本质上,⽂件描述符就是该数组的下标。所以,只要拿着⽂件描述符,就可以找到对应的⽂件。
文件打开模式(r/w/a/a+)与权限控制
在 C 标准库的 fopen
中,文件打开模式通过模式字符串指定,常用模式包括:
"r"
:以只读方式打开文件,文件必须存在;读操作从文件开头开始。"r+"
:以读写方式打开文件,文件必须存在;操作位置在开头。"w"
:以写方式打开文件,如果文件不存在则创建;如果文件存在则清空原内容,相当于截断文件再写入。"w+"
:以读写方式打开,效果类似于w
。"a"
:以追加方式打开文件,如果文件不存在则创建;写入时总是追加到文件末尾。"a+"
:以读写方式打开,写操作追加到末尾。
例如,上述代码示例中 fopen("log.txt", "w")
会创建新文件或清空已有文件,并将数据写入。如果改为 "a"
模式,则不会清空原文件,而是将内容追加到末尾。这些模式与 shell 中的重定向符号类似:>
对应清空写入,>>
对应追加写入。
对于系统调用 open
,访问模式和标志通过参数 flags
指定。常见的标志包括:
- 访问模式:
O_RDONLY
(只读)、O_WRONLY
(只写)、O_RDWR
(读写) - 创建模式:
O_CREAT
(如果文件不存在则创建)、O_EXCL
(配合O_CREAT
使用,如果文件已存在则打开失败) - 截断追加:
O_TRUNC
(如果文件存在则清空其内容)、O_APPEND
(写操作追加到末尾)
这些标志是 位标志,可以通过按位或组合多个选项(位图方式)。每个宏对应一个二进制位,例如 O_CREAT
= 0x40。我们可以写 O_WRONLY | O_CREAT
来同时设置只写和创建标志。例如:
int fd = open("data.txt", O_RDWR | O_CREAT | O_TRUNC, 0666);
上述调用试图以读写模式打开 data.txt
,如果不存在则创建,并在打开时清空原有内容。权限参数 0666
指定新文件的初始权限(随后会受 umask
掩码的影响,详见下一节)。
需要注意的是,open
系统调用本身并不设置文件访问权限,它只是使用提供的参数和当前进程的 umask
决定文件的最终权限。如果我们只读打开已有文件(不需要创建),可以省略权限参数,只传两个参数即可。
C 语言与系统调用在文件管理中的应用
在 C 语言中,文件操作常用标准库函数,如 fopen
/fclose
、fread
/fwrite
、fprintf
/fscanf
等。这些函数在用户态提供了方便的接口,但它们底层最终都会调用相应的系统调用。简而言之:
- C 标准库函数(例如
fopen
,fclose
,fread
,fwrite
)是一种对系统调用的封装,使用起来更方便,自动管理缓冲区。 - 系统调用(例如
open
,close
,read
,write
)是内核提供的底层接口,功能更原始,需要程序员自己处理缓冲和错误。
讲稿总结道:
fopen, fclose, fwrite, fread
—— C 库函数;
open, close, write, read
—— 系统调用;
C 库函数就是系统调用的封装。
我们在前面的示例代码中就使用了 fopen
/fputs
和 open
/write
的组合。二者的使用基本相同,只是一个返回 FILE*
,另一个返回 int fd
。C 库函数在底层自动调用了相同功能的系统调用,并通常带有文件缓冲机制。
比如,可以用下面代码分别展示两种方法写文件:
// 使用 C 库函数 fwrite
#include <stdio.h>
int main() {FILE *fp = fopen("test.txt", "w");if (!fp) return 1;fprintf(fp, "Hello, libc!\n");fclose(fp);return 0;
}
// 使用系统调用 write
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {int fd = open("test_sys.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);if (fd < 0) return 1;const char *msg = "Hello, syscall!\n";write(fd, msg, strlen(msg));close(fd);return 0;
}
两个示例效果类似,但实现层次不同。第二个示例是直接与内核交互,第一种方式在内部会调用第二种方式并做缓冲。了解两者的差异有助于在需要精细控制时直接使用系统调用,而大部分应用开发可以继续使用更易用的 C 库函数。
权限控制与 umask
的影响
Linux 中每个文件都有访问权限设置,一般用三组 rwx
位表示属主、属组和其它用户的读写执行权限。对于新创建的文件,open
系统调用第三个参数指定了文件的初始权限(如 0666
,即默认可读可写),但最终权限还会受进程的文件模式掩码(umask
)影响。操作系统会将给定的权限值与 umask
做按位与再取反,从而得出文件的实际权限。例如:如果进程的 umask
为 022
,新文件的默认权限 0666
会变成 0644
(去掉了对“其它用户”的写权限)。
讲稿中演示了这一过程:在 open("log.txt", O_CREAT, 0666)
后发现文件权限并非 0666
而是根据掩码修正后的值。可以在程序中调用 umask(0)
来临时将掩码清零,这样之后创建文件就会严格使用指定的权限。要注意,umask
是进程级的属性,修改当前进程的 umask
不会影响其他进程。
例如:
# 进程默认 umask 为 022,创建文件时权限=0666&~022=0644
$ touch file1.txt
$ ls -l file1.txt
-rw-r--r-- # 改变 umask 后再创建
$ umask 000
$ touch file2.txt
$ ls -l file2.txt
-rw-rw-rw- # 此时文件权限就是0666
在上述示例中,可以看到 umask
影响了新文件的权限。此外,open
的权限参数只有在指定 O_CREAT
时才有效,如果只读打开现有文件就不需提供权限参数。
位图与标志位参数传递的机制
在许多系统调用(如 open
、mmap
、socket
等)中,为了同时传递多个选项,Linux 通常采用**位掩码(bitmap)**的方式。即将整型参数的每一位作为独立的开关。这样我们可以用按位或(|
)来组合多个选项,仅需一个参数即可表示多个布尔配置。
以 open
为例,其 flags
参数就是一个 32 位的位图,每个标志宏(如 O_RDONLY=0x0000
、O_WRONLY=0x0001
、O_CREAT=0x0040
等)在这个整数中只有一个位为 1。多个标志可以组合,比如 O_WRONLY | O_CREAT | O_TRUNC
。下面这个示例片段演示了位掩码的原理(简化示意):
#define ONE (1<<0)
#define TWO (1<<1)
#define THREE (1<<2)void Test(int flags) {if (flags & ONE) printf("ONE\n");if (flags & TWO) printf("TWO\n");if (flags & THREE) printf("THREE\n");
}int main() {Test(ONE | THREE); // 输出 ONE 和 THREEreturn 0;
}
在 open("file", O_WRONLY | O_CREAT)
的调用中,flags
参数的值就是上述位或的结果。内核读取这个整数后,通过与操作检查各个位是否被设置,从而知道用户希望启用哪些功能。这种位图传参机制非常灵活,能够支持在单个参数中传递多个选项,也避免了传递过多单独参数。对开发者来说,只需记住各个宏代表的含义,并使用按位或即可组合使用。
【课外补充】此处介绍的位图传参方法在很多系统调用和库接口中都很常见,不限于文件操作。例如 open
、fcntl
、mmap
、网络编程的 socket
等调用都使用类似方式传递标志位。