基于Linux系统理解 IO文件系统

文章目录

回顾C语言 文件IO

在正式说明 Linux 中是如何对文件进行 IO 前,我们先简单回顾一下 C 语言中是如何进行文件的 IO 操作的。

对于 C 语言部分接口,本博客不做详解,只回顾一些重要概念。

fopen

fopen 用于打开文件,包含在头文件 <stdio.h> 中,函数原型如下:

c 复制代码
FILE* fopen(const char *pathname, const char *mode);
  • pathname:打开文件路径,可以是绝对路径或相对路径
  • mode:打开文件的模式

常见的打开文件模式有:

  • "r":只读,若文件不存在则报错
  • "w":只写,若文件不存在则创建,打开时清空文件原有内容
  • "a":只写,若文件不存在则创建,打开时从文件末尾追加

fopen 会返回一个 FILE* 类型的指针。在 C 语言中,通过操作这个 FILE* 来控制文件的 IO。

文件信息区与文件指针

当我们打开文件时,每个被使用的文件都会在内存中开辟一个相应的文件信息区,用来存放文件的相关信息。这些信息保存在一个由系统声明的结构体变量中,结构体类型为 FILE

我们可以通过操控这个 FILE 类型的结构体,来操作文件。

大部分情况下,我们会得到一个指向该结构体的指针,即文件指针 FILE*,后续通过文件指针来操控文件。

文件指针示意

例如,在下图中,三个指针 pf1pf2pf3,它们分别指向了一个 FILE 类型的文件信息区。而每个文件信息区都存储着一个文件的信息,后续就通过这三个指针来操控这三个文件。

标准文件流

C 语言程序在启动时,默认打开了三个流:

  1. stdin - 标准输入流,在大多数环境中从键盘输入。
  2. stdout - 标准输出流,默认输出到显示器界面。
  3. stderr - 标准错误流,默认输出到显示器界面。

这三个流也是通过文件指针来操作的。

以上是从 C 语言的角度来看文件的操作,fd是文件描述符,下文会谈,接下来我们将从操作系统的角度来讲解文件的处理方式。


💦 为什么要学习文件系统接口

在 C 语言中要访问硬件,必须贯穿计算机体系结构,而 fopenfclose 等系列的库函数,其底层都要调用系统接口,这里它们对应的系统接口也很好记 ------ 去掉 " f " 即为系统接口。不同语言所提供的接口本质是对系统接口封装,学习封装的接口本质学的就是语言级别的接口,换言之要学习不同的语言,就得会不同语言操作文件的方法,但实际上对特定的操作系统,最终都会调用系统提供的接口。

所以接下来我们当然是要学习系统接口,我们要学习它的原因主要有两点:其一,只要把它学懂了,以后学习其它语言上的文件操作,只是学习它的语法,底层就不用学习了;其二,这些系统接口,更贴近于系统,所以我们就能通过接口,来学习文件的若干特性。


Linux 文件IO

在 C 语言中,所有文件都通过文件指针 FILE* 来操控,而在 Linux 中,所有 IO 操作都围绕文件描述符 fd

open 接口

open 用于打开文件,需要头文件 <sys/types.h><sys/stat.h><fcntl.h>,函数原型如下:

c 复制代码
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

open 会返回一个 int 类型的值,这个值就是文件描述符 fd,后续通过 fd 操控对应文件。

该接口有两个版本,先说只有两个参数的接口:

  • pathname:打开文件的路径
  • flags:打开文件的模式

这个模式和 fopen 的相似,比如控制读、写、追加等,以下是常见的选项:

选项 功能
O_RDONLY 以只读方式打开
O_WRONLY 以只写方式打开
O_RDWR 以读写方式打开
O_CREAT 若打开时文件不存在,则创建文件

假设用以下代码打开一个不存在的文件:

c 复制代码
int fd = open("log.txt", O_WRONLY);
  • 如果使用 C 语言的 fopenw 形式打开,遇到一个不存在的文件 log.txt,则会默认创建该文件。
  • 但是对于 open 接口,以 O_RDONLY 形式打开一个文件,如果文件不存在,它什么也不做。

如果想要打开文件并同时创建文件,则需要加上 O_CREATopen 接口是以位图的形式传参的,把所需的选项进行按位或即可。

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>

int main()
{
    int fd = open("log.txt", O_WRONLY | O_CREAT);
    return 0;
}

运行 test.exe 后,当前目录就会出现 log.txt 文件,但它的权限值是乱码,如 -r--rws--T。如果删除该文件后再次运行代码,下一次的权限值可能与上次不同。

初始权限与 umask

open 没有传入第三个参数时,生成文件的权限值是随机的。

此时需要使用三个参数版本的 open 接口:

c 复制代码
int open(const char *pathname, int flags, mode_t mode);

其中,mode 用于控制文件的初始权限,最终权限还要经过 umask 过滤。

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>

int main()
{
    umask(0000);
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
    return 0;
}

通过 umask(0000) 将当前的 umask 设置为 0000,随后通过 open 的第三个参数传入 0666,即 log.txt 的初始权限。由于 umask 已经设置为 0000,不会影响初始权限,因此 log.txt 的最终权限就是 0666,即 rw-rw-rw-

  • close 接口

close 用于关闭文件,需要头文件 <unistd.h>,函数原型如下:

c 复制代码
int close(int fd);

直接传入对应文件的 fd 即可。

  • write 接口

write 用于向文件写入,需要头文件 <unistd.h>,函数原型如下:

c 复制代码
ssize_t write(int fd, const void *buf, size_t count);
  • fd:被写入文件的文件描述符
  • buf:指向被写入的字符串
  • count:写入字符的个数
c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>

int main()
{
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);

    const char* str = "Hello\n World!\n";
    write(fd, str, 6);

    close(fd);
    return 0;
}

在这段代码中,向 log.txt 写入了一个字符串 strwrite 的第三个参数为 6,因此只写入了六个字符 "Hello\n"

覆盖问题

如果再写入以下内容:

c 复制代码
const char* str = "123";
write(fd, str, 3);

write 只写入了三个字符 "123",结果会发现 log.txt 的内容变成了 123lo

原因是:以 O_WRONLY 模式对文件进行写入时,文件内容不会被清空,而是直接从头开始覆盖。因此,新写入的 123 会覆盖原先 "Hello" 的头三个字符,而后面的字符没有被覆盖或清空。

解决方案:

对于一个文件重复写入,有两种解决方案,对应两个 open 的选项:

选项 功能
O_TRUNC 打开时清空文件内容
O_APPEND 以追加的形式写入

这两者的组合方式如下:

  • O_WRONLY | O_CREAT | O_TRUNC:以只写模式打开,如果文件不存在则创建,且打开时清空文件内容。
  • O_WRONLY | O_CREAT | O_APPEND:以只写模式打开,如果文件不存在则创建,且打开时从文件末尾开始追加内容。

你会发现,这两个组合其实和 fopen 对应的 wa 模式非常相似。fopen 就是对 open 的封装。

read 接口

read 用于读取文件,需要头文件 <unistd.h>,函数原型如下:

c 复制代码
ssize_t read(int fd, void* buf, size_t count);
  • fd:目标文件的文件描述符
  • buf:将文件内容读取到 buf 指向的空间中
  • count:最多读取 count 个字节的内容

返回值:

  • < 0:读取发生错误
  • = 0:读取到文件末尾
  • > 0:读取成功,返回读取到的字符个数
c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <string.h>

int main()
{
    int fd = open("log.txt", O_RDONLY);

    char buffer[1024];
    ssize_t ret1 = read(fd, buffer, 1024);

    printf("ret1 = %ld\n", ret1);
    printf("%s", buffer);

    ssize_t ret2 = read(fd, buffer, 1024);
    printf("ret2 = %ld\n", ret2);

    close(fd);

    return 0;
}

log.txt 的内容为字符串 "Hello Linux\n"。通过 open 以只读模式打开文件后,第一次通过 read(fd, buffer, 1024) 将内容存储到数组 buffer 中,并输出返回值 ret1buffer

  • 第一次 read 返回值为 13,表示文件中的 13 个字符 "Hello Linux\n" 被读取到 buffer 中。
  • 第二次 read 返回值为 0,因为上次读取时已经遇到文件末尾。

输出结果如下:

ret1 = 13
Hello Linux
ret2 = 0

内存文件管理

struct file

在操作系统中,访问硬件设备是系统特有的权限,因此想要打开文件并访问磁盘上的数据,必须通过操作系统来管理。在 Linux 中,操作系统使用一个叫做 struct file 的结构体来描述一个被打开的文件,并把这个文件的数据从磁盘加载到内存中。

当一个文件被打开并加载到内存中,它就变成了一个内存文件。本部分将讨论如何管理这些内存文件。

在 Linux 2.6.10 内核中,struct file 的定义如下:

c 复制代码
struct file {
    struct list_head     f_list;
    struct dentry        *f_dentry;
    struct vfsmount      *f_vfsmnt;
    struct file_operations *f_op;
    atomic_t             f_count;
    unsigned int         f_flags;
    mode_t               f_mode;
    int                  f_error;
    loff_t               f_pos;
    struct fown_struct   f_owner;
    unsigned int         f_uid, f_gid;
    struct file_ra_state f_ra;

    unsigned long        f_version;
    void                 *f_security;

    /* needed for tty driver, and maybe others */
    void                 *private_data;

#ifdef CONFIG_EPOLL
    struct list_head     f_ep_links;
    spinlock_t           f_ep_lock;
#endif /* #ifdef CONFIG_EPOLL */
    struct address_space *f_mapping;
};

struct file 是一个非常重要的数据结构,它表示一个打开的文件,并提供了访问和操作文件的接口。我们提取出一些重要的成员,并做一个简化模型,说明它们的功能:

  • loff_t f_pos:保存文件当前的读写位置。许多文件操作函数都会根据这个位置来读写数据。
  • unsigned int f_flags :保存打开文件时设置的标志,例如 O_RDONLY, O_WRONLY, O_APPEND 等,决定文件的访问权限和行为。
  • struct fown_struct f_owner:保存文件所有者的信息,包括进程 ID 和用户 ID,用于文件的所有权和权限检查。
  • struct file_operations *f_op :指向一个 struct file_operations 类型的结构体,定义了各种文件操作函数,如 read(), write(), open(), release() 等。这些函数由文件系统或驱动程序实现,用于处理对文件的各种操作。

在 Linux 中,"一切皆文件",包括显示器、键盘、磁盘、内存等软硬件都通过文件来控制。因此,每个文件的读写方式可能不同,但我们通过统一的 readwrite 接口来对文件进行操作。为了实现这一点,struct file 中通过 f_op 指向一个结构体 struct file_operations,在该结构体中存放各种函数指针。

struct file_operations

在 Linux 2.6.10 内核中,struct file_operations 的定义如下:

c 复制代码
struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*aio_read) (struct kiocb *, char __user *, size_t, loff_t);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    ssize_t (*aio_write) (struct kiocb *, const char __user *, size_t, loff_t);
    int (*readdir) (struct file *, void *, filldir_t);
    unsigned int (*poll) (struct file *, struct poll_table_struct *);
    int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
    int (*mmap) (struct file *, struct vm_area_struct *);
    int (*open) (struct inode *, struct file *);
    int (*flush) (struct file *);
    int (*release) (struct inode *, struct file *);
    int (*fsync) (struct file *, struct dentry *, int datasync);
    int (*aio_fsync) (struct kiocb *, int datasync);
    int (*fasync) (int, struct file *, int);
    int (*lock) (struct file *, int, struct file_lock *);
    ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
    ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
    ssize_t (*sendfile) (struct file *, loff_t *, size_t, read_actor_t, void *);
    ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
    unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
    int (*check_flags)(int);
    int (*dir_notify)(struct file *filp, unsigned long arg);
    int (*flock) (struct file *, int, struct file_lock *);
};

以上结构体中的成员大多数是函数指针,其中的 read, write, open 等就是常见的文件操作函数。通过这些函数指针,文件系统或驱动程序可以实现自定义的文件读写操作。通过 struct file_operations 结构体,用户可以使用统一的接口来进行文件的操作。

struct files_struct

struct file 是描述单个文件的结构体,而操作系统需要管理所有进程打开的文件。这就需要一个结构体来组织这些 struct file。这个结构体就是 struct files_struct

在 Linux 2.6.10 内核中,struct files_struct 的定义如下:

c 复制代码
struct files_struct {
    atomic_t count;
    spinlock_t file_lock;     /* Protects all the below members.  Nests inside tsk->alloc_lock */
    int max_fds;
    int max_fdset;
    int next_fd;
    struct file ** fd;      /* current fd array */
    fd_set *close_on_exec;
    fd_set *open_fds;
    fd_set close_on_exec_init;
    fd_set open_fds_init;
    struct file * fd_array[NR_OPEN_DEFAULT];
};

其中最重要的成员是 fd_array,它是一个数组,指向类型为 struct file* 的指针,每个数组元素都指向一个 struct file,即一个打开的文件。

管理文件描述符

每个进程都有一个 struct files_struct,它用于管理进程打开的多个 struct file。通过 fd_array 数组,操作系统可以有效地管理进程的文件描述符。文件描述符 fd 就是 fd_array 数组的下标。

文件描述符 fd

文件描述符 fdfd_array 数组的下标。每当一个文件被打开时,操作系统就为它分配一个文件描述符。文件描述符从 3 开始分配,因为文件描述符 0、1、2 已经被默认用于标准输入、标准输出和标准错误流。

示例:文件描述符分配
c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>

int main()
{
    int fd1 = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd2 = open("log2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd3 = open("log3.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd4 = open("log4.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd5 = open("log5.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);

    printf("fd1 = %d\n", fd1);
    printf("fd2 = %d\n", fd2);
    printf("fd3 = %d\n", fd3);
    printf("fd4 = %d\n", fd4);
    printf("fd5 = %d\n", fd5);

    return 0;
}

执行 ./test.exe 后,输出的文件描述符会从 3 开始,依次为 3, 4, 5, 6, 7,这是因为文件描述符 0、1、2 已经被标准流 stdin, stdout, stderr 占用了。

示例:标准流的文件描述符
c 复制代码
#include <stdio.h>

int main()
{
    int fd1 = stdin->_fileno;
    int fd2 = stdout->_fileno;
    int fd3 = stderr->_fileno;

    printf("stdin->_fileno = %d\n", fd1);
    printf("stdout->_fileno = %d\n", fd2);
    printf("stderr->_fileno = %d\n", fd3);

    return 0;
}

执行上述代码,输出显示标准输入、标准输出、标准错误流的文件描述符分别为 0、1 和 2,证明了它们是由系统默认分配的。

缓冲区

缓冲区本质上是一块内存区域,用于存储文件 IO 操作中的数据。C 语言的文件 IO 函数,如 fwrite,在底层封装了系统调用接口,如 write,并将数据先保存在用户级缓冲区中,等到一定条件满足时,再通过一次系统调用将数据写入磁盘。

操作系统有自己的缓冲区,但本博客关注的是用户级缓冲区。用户级缓冲区的刷新策略有三种:

  • 无缓冲:不进行缓冲,直接输出。
  • 行缓冲 :向显示器写入时,遇到换行符 \n 会强制刷新缓冲区。
  • 全缓冲:向普通文件写入时,缓冲区被写满才会刷新。

缓冲区由 struct FILE 管理,每个文件的 FILE 结构体都有独立的缓冲区。

示例:缓冲区的存在

通过以下代码,我们可以观察到用户级缓冲区的存在和行为:

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <string.h>

int main()
{
    const char* s1 = "hello write!\n";
    write(stdout->_fileno, s1, strlen(s1));

    const char* s2 = "hello printf!";
    printf("%s", s2);

    printf("\n");

    return 0;
}

通过 writeprintf 两种方式向显示器输出数据,显示了不同的缓冲行为。


通过以上讨论,我们了解了在 Linux 中,文件是如何通过结构体 struct file 被加载到内存,并通过文件描述符进行管理的。

继续从内存文件管理的角度深入探讨:


子进程与缓冲区的继承

在 Linux 中,当使用 fork() 创建子进程时,子进程会继承父进程的大部分资源,包括文件描述符和标准流(stdin、stdout、stderr)。但重要的一点是,缓冲区也会被继承。这是因为缓冲区是绑定在文件流(如 FILE*)上的,每个流都有其自己的缓冲区。子进程继承父进程的文件流时,也会继承相应的缓冲区。

示例:缓冲区继承的现象

让我们通过一个例子来观察缓冲区是如何在 fork() 时被继承的。考虑以下代码:

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main()
{
    const char* s1 = "hello write!\n";
    write(stdout->_fileno, s1, strlen(s1));

    const char* s2 = "hello printf!";
    printf("%s", s2);

    fork();  // 创建子进程

    printf("\n");

    return 0;
}

在父进程和子进程中,printf 写入的字符串在输出时发生了奇怪的现象。我们可以看到,printffork() 调用之前执行,但由于没有换行符,它的输出被保留在缓冲区中。

输出结果

当父进程 printf 被调用时,s2 字符串被写入到缓冲区,而没有被立即刷新。然后,调用 fork() 创建了子进程。在子进程执行时,它继承了父进程的文件流和缓冲区,这意味着缓冲区中的内容会在两个进程中重复刷新。因此,hello printf! 被输出两次。

这就证明了,子进程会继承父进程的缓冲区,导致子进程和父进程都会刷新缓冲区。


缓冲区刷新策略

缓冲区的刷新是文件 I/O 操作中的一个重要问题。在用户层,C 语言提供了多种刷新缓冲区的策略。接下来,我们进一步分析这几种常见的刷新策略。

无缓冲

在无缓冲模式下,数据写入文件时不会经过缓冲区,而是直接写入。通常情况下,标准输出流(如 stdout)会在用户调用 printf() 时使用这种方式。写入的数据立刻显示出来,不会被缓存。

c 复制代码
setbuf(stdout, NULL);  // 禁用缓冲区
行缓冲

在行缓冲模式下,输出会缓存在缓冲区中,直到遇到换行符(\n)时才会强制刷新缓冲区。这在处理文本数据时非常有用,可以减少大量的 I/O 操作,提升性能。

c 复制代码
setvbuf(stdout, NULL, _IOLBF, BUFSIZ);  // 行缓冲

当用户调用 printf("hello\n"); 时,缓冲区会在输出换行符时刷新,将数据写入显示设备。

全缓冲

全缓冲模式是指在缓冲区被填满之前,数据不会被写入目标文件。通常情况下,当向磁盘写入数据时,操作系统会采用全缓冲方式。

c 复制代码
setvbuf(stdout, NULL, _IOFBF, BUFSIZ);  // 全缓冲

一旦缓冲区填满,操作系统会一次性将数据写入磁盘。适用于大量数据写入的情况,减少频繁 I/O 操作,提升效率。

刷新缓冲区

除了在特定条件下(如满缓冲区或遇到换行符)自动刷新缓冲区,C 语言也提供了手动刷新缓冲区的功能。用户可以通过调用 fflush() 来强制刷新缓冲区,将缓冲区中的内容写入到目标文件或显示设备。

示例:手动刷新缓冲区
c 复制代码
#include <stdio.h>

int main()
{
    printf("Hello, ");
    fflush(stdout);  // 强制刷新缓冲区
    printf("world!\n");
    return 0;
}

在这段代码中,fflush(stdout) 会立即将 "Hello, " 输出到标准输出(通常是屏幕),而 "world!\n" 会在其后输出,保证输出按顺序进行。

文件关闭与缓冲区

当文件被关闭时,操作系统会将文件缓冲区中的所有数据刷新到磁盘。这就是为什么调用 fclose() 时,文件的数据会被保留并更新到磁盘中的原因。

c 复制代码
fclose(fp);  // 关闭文件,刷新缓冲区

在实际操作中,尽量避免在程序异常终止时文件没有被正确关闭。程序结束时,操作系统通常会自动关闭打开的文件并刷新缓冲区,但为了确保数据安全,建议在文件操作完成后显式地调用 fclose()


总结

通过本篇博客,我们详细探讨了 Linux 系统中文件的内存管理方式,了解了 struct filestruct files_struct 和文件描述符如何协同工作。每个文件被打开时都会通过 struct file 来管理其相关的操作和状态,而每个进程则通过 struct files_struct 来管理其打开的文件。

此外,我们还深入了解了 Linux 文件 I/O 中缓冲区的概念,特别是用户级缓冲区的刷新策略,如无缓冲、行缓冲和全缓冲,并讨论了如何通过 fflush() 强制刷新缓冲区。

在实际开发中,理解这些概念对于高效进行文件操作和优化程序性能至关重要。希望这篇博客帮助你更深入地理解 Linux 文件系统的工作原理!

相关推荐
ん贤17 分钟前
C/C++混合读入cin与scanf问题
c语言·开发语言·c++
fenglei202044 分钟前
DevOps工具链概述
linux·服务器·网络·devops
zjkzjk77111 小时前
dynamic_cast和static_cast和const_cast
linux·运维·服务器
SuSuStarSmile1 小时前
centos 7 关于引用stdatomic.h的问题
linux·运维·centos
攻城狮7号1 小时前
【10.7】队列-解预算内的最多机器人数目
数据结构·c++·算法
洛音竹1 小时前
ubuntu下一键编译
linux·运维·ubuntu
网络安全Jack2 小时前
网络安全用centos干嘛 网络安全需要学linux吗
linux·web安全·centos
不懂网络的坤坤2 小时前
Linux性能优化实战:从CPU到磁盘I/O
linux·网络
kk努力学编程2 小时前
Linux基础14-C语言篇之变量与标识符【入门级】
java·linux·c语言
倔强的石头1063 小时前
【C++指南】解锁C++ STL:从入门到进阶的技术之旅
开发语言·c++