深入理解 Linux 基础 IO:从文件操作到缓冲区机制

发布于:2025-03-25 ⋅ 阅读:(36) ⋅ 点赞:(0)

亲爱的读者朋友们😃,此文开启知识盛宴与思想碰撞🎉。

快来参与讨论💬,点赞👍、收藏⭐、分享📤,共创活力社区。

        在 Linux 系统中,文件输入输出(IO)操作是程序开发的基础部分,它涉及从简单的文件读写到复杂的系统调用和缓冲区管理。本文将基于相关知识进行详细梳理,帮助读者深入理解 Linux 基础 IO 的核心概念和操作原理。


目录

一、文件的多元理解

1.1 狭义与广义认知

1.2 文件构成与操作本质

二、以C语言为主,先回忆一下C接口

2.1 文件打开、读写与关闭

2.2 标准输入输出流

2.3 文件打开模式

三、系统文件 I/O 详解

3.1 系统调用接口与标志位传递

3.2 文件描述符

3.3 重定向原理与实现

四、“一切皆文件” 的深度剖析

五、缓冲区机制探究

5.1 引入一些奇怪的现象

5.2 缓冲区不在操作系统内部

5.3 缓冲区的刷新策略

5.4 为什么要有缓冲区

5.5 用户层缓冲区在哪

5.6 内核缓冲区在哪


一、文件的多元理解

1.1 狭义与广义认知

狭义: 

  • 文件在磁盘里
  • 磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的
  • 磁盘是外设(即是输出设备也是输入设备)
  • 磁盘上的文件 本质是对文件的所有操作,都是对外设的输入和输出 简称 IO 

广义: 

        在 Linux “一切皆文件” 的理念下,键盘、显示器、网卡等硬件设备都被抽象为文件。这种广义理解极大地简化了系统资源的访问方式,开发者可以使用统一的文件操作接口来与不同设备交互。 

1.2 文件构成与操作本质

        文件由文件属性(元数据)和文件内容组成所有文件操作归根结底是对文件内容和属性的操作,而这些操作在系统层面是由进程执行的,进程通过系统调用接口来实现文件的读写等操作,C 语言的库函数只是为用户提供了更便捷的调用方式🌈。


二、以C语言为主,先回忆一下C接口

2.1 文件打开、读写与关闭

 

        C 标准库提供了一系列文件操作函数,如fopen用于打开文件,其打开路径默认是程序当前路径,可通过/proc/[进程id]/cwd查看。fwritefread分别用于文件的写入和读取,在使用fread时需注意返回值和参数设置。文件使用完毕后,要用fclose关闭,以释放资源。

fopen 是 C 语言中用于打开文件的函数,其函数原型为:

 FILE *fopen(const char *filename, const char *mode);

特性

  1. 文件打开模式mode 参数决定了文件的打开方式,常见的有:
    • r:以只读方式打开文本文件。文件必须存在,否则打开失败。
    • w:以写入方式打开文本文件。若文件不存在则创建若存在则会清空文件原有内容 ,从头开始写入新数据。
    • a以追加方式打开文本文件。文件不存在时创建;存在时则定位到文件末尾,新写入的数据会追加在原有内容之后。
  2. 返回值:函数调用成功时返回一个指向 FILE 类型的指针,后续对文件的读写操作可通过该指针进行;若打开失败则返回 NULL
    FILE
  3. 与操作系统交互fopen 函数依赖操作系统提供的文件管理功能,其文件打开模式的特性(如 w 模式清空文件)是操作系统文件系统的特性,并非 C 语言本身独有。

 代码示例

#include <stdio.h>
#include <stdlib.h>

int main() {
    // 以写入模式打开文件
    FILE *writeFile = fopen("test.txt", "w");
    if (writeFile == NULL) {
        perror("Failed to open file for writing");
        return 1;
    }
    fprintf(writeFile, "This is a test content.\n");
    fclose(writeFile);

    // 以追加模式打开文件
    FILE *appendFile = fopen("test.txt", "a");
    if (appendFile == NULL) {
        perror("Failed to open file for appending");
        return 1;
    }
    fprintf(appendFile, "This content is appended.\n");
    fclose(appendFile);

    // 以只读模式打开文件
    FILE *readFile = fopen("test.txt", "r");
    if (readFile == NULL) {
        perror("Failed to open file for reading");
        return 1;
    }
    char buffer[100];
    while (fgets(buffer, sizeof(buffer), readFile) != NULL) {
        printf("%s", buffer);
    }
    fclose(readFile);

    return 0;
}

上述代码中:

  • 首先以 w 模式打开文件 test.txt ,写入一行文本,此时若文件存在会被清空。
  • 接着以 a 模式打开同一文件,追加写入一行文本。
  • 最后以 r 模式打开文件,读取并打印文件内容,展示了不同打开模式的效果。每次操作完成后,都使用 fclose 函数关闭文件,以释放相关资源。

 

  • fwrite函数原型为size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream) ,其中ptr是指向要写入的数据块的指针;size是每个数据块的大小(以字节为单位);count是要写入的数据块的数量;stream是指向目标文件的指针。返回值是实际写入的数据块数目。
  • fprintf函数原型为int fprintf(FILE *stream, const char *format, ...) stream是指向要写入的文件的指针;format是格式化字符串。

 

 

 

2.2 标准输入输出流

        C 默认打开stdin(标准输入) stdout(标准输出) stderr(标准错误)  2 三个流,它们的类型都是FILE*。可以使用fwriteprintffprintf等函数向stdout输出信息,不同函数在输出方式和格式化上各有特点。

 

2.3 文件打开模式

        文件打开模式决定了文件的访问权限和初始位置,如r表示只读打开,w表示只写打开并截断文件,a表示追加写等。正确选择打开模式对文件操作的准确性和安全性至关重要。


三、系统文件 I/O 详解

3.1 系统调用接口与标志位传递

        系统文件 IO 提供了底层的文件访问方式,以open函数为例,它可以通过标志位指定打开文件的方式。标志位采用位运算进行组合,如O_RDONLY(只读)、O_WRONLY(只写)、O_CREAT(创建文件)等,开发者可根据实际需求进行灵活设置。

#include <stdio.h>
// 定义标志位 ONE,对应的二进制为 0000 0001
#define ONE 0001 
// 定义标志位 TWO,对应的二进制为 0000 0010
#define TWO 0002 
// 定义标志位 THREE,对应的二进制为 0000 0100
#define THREE 0004 

// 该函数用于根据传入的标志位判断包含哪些特定标志并输出信息
void func(int flags) {
    // 通过位与运算判断 flags 中是否包含 ONE 标志位
    if (flags & ONE) printf("flags has ONE! ");
    // 通过位与运算判断 flags 中是否包含 TWO 标志位
    if (flags & TWO) printf("flags has TWO! ");
    // 通过位与运算判断 flags 中是否包含 THREE 标志位
    if (flags & THREE) printf("flags has THREE! ");
    // 换行
    printf("\n");
}

int main() {
    // 调用 func 函数,传入 ONE 标志位
    func(ONE);
    // 调用 func 函数,传入 THREE 标志位
    func(THREE);
    // 调用 func 函数,传入通过位或运算组合的 ONE 和 TWO 标志位
    func(ONE | TWO);
    // 调用 func 函数,传入通过位或运算组合的 ONE、TWO 和 THREE 标志位
    func(ONE | THREE | TWO);
    // 程序正常结束返回 0
    return 0;
}

 open 函数是用于打开或创建文件的系统调用

 函数原型:

#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

参数解释

  • pathname:这是一个字符串,代表要打开或创建的文件的路径。它可以是绝对路径(例如 /home/user/file.txt),也可以是相对路径(例如 file.txt)。
  • flags:该参数是一个整数,用来指定文件的打开方式。以下是一些常见的标志:
    • O_RDONLY:以只读模式打开文件。
    • O_WRONLY:以只写模式打开文件。
    • O_RDWR:以读写模式打开文件。
    • O_CREAT:若文件不存在,则创建该文件。使用此标志时,需要提供第三个参数 mode 来指定文件的权限。
    • O_EXCL:与 O_CREAT 一起使用时,如果文件已经存在,则 open 调用会失败。
    • O_TRUNC:若文件存在且以写模式打开,则将文件截断为长度为 0。
    • O_APPEND:以追加模式打开文件,写入数据时会追加到文件末尾。
  • mode当使用 O_CREAT 标志时,需要提供这个参数来指定新创建文件的权限。它是一个八进制数,例如 0644 表示文件所有者有读写权限,而组用户和其他用户只有读权限。

返回值

  • 若 open 调用成功,会返回一个新的文件描述符(一个非负整数),后续可使用该文件描述符进行文件操作。
  • 若调用失败,会返回 -1,并设置 errno 来指示错误类型。

3.2 文件描述符

        文件描述符是一个小整数,Linux 进程默认有 0(标准输入)、1(标准输出)、2(标准错误)三个缺省打开的文件描述符。文件描述符本质是进程打开文件表中对应文件指针数组的下标,通过它可以找到对应的文件。其分配规则是在数组中寻找当前未使用的最小下标,这一特性在文件操作中具有重要意义。(高效利用资源)

3.3 重定向原理与实现

        重定向是改变文件描述符默认指向的操作,常见的重定向符号有>(输出重定向)、>>(追加输出重定向)、<(输入重定向)。

        通过关闭特定的文件描述符并重新打开文件,可以实现重定向。

// 标准输出重定向函数
void redirect_stdout(const char *filename) {
    // 关闭标准输出文件描述符
    close(STDOUT_FILENO);
    // 以写入和创建模式打开文件,若文件存在则截断内容
    int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    // 此时新打开的文件会占用之前关闭的标准输出文件描述符(1)
    printf("这行内容会被重定向到文件中。\n");
    close(fd);
}

 

        dup2系统调用也可用于实现重定向,它能将一个文件描述符的内容复制到另一个文件描述符,从而改变数据的流向

下面将详细介绍 dup2 函数的用法:

函数原型

#include <unistd.h>

int dup2(int oldfd, int newfd);

参数解释

  • oldfd:这是已存在的文件描述符,dup2 会将这个文件描述符所引用的文件表项复制到 newfd 所代表的文件描述符上。
  • newfd:这是目标文件描述符,dup2 会把 oldfd 的文件表项复制到这里。如果 newfd 之前已经打开了一个文件,dup2 会先关闭 newfd 对应的文件,然后再进行复制操作。

返回值

  • 若调用成功,dup2 会返回 newfd,也就是复制后的文件描述符。
  • 若调用失败,会返回 -1,并设置 errno 来指示具体的错误类型。
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>

int main() {
    int fd;
    // 以写入模式打开文件,如果文件不存在则创建,存在则截断内容
    fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    // 使用 dup2 将标准输出(文件描述符为 1)重定向到新打开的文件
    if (dup2(fd, STDOUT_FILENO) == -1) {
        perror("dup2");
        close(fd);
        return 1;
    }
    // 关闭原文件描述符,因为已经完成复制
    close(fd);
    // 此时 printf 的输出会重定向到文件中
    printf("这行内容会被重定向到 output.txt 文件中。\n");
    return 0;
}

 

       那重定向的本质是什么呢?

 ⭐重定向的实现原理:

        重定向的核心是改变文件描述符对应的数据流向。在操作系统中,每个进程有标准输入(文件描述符 0,默认连键盘)、标准输出(文件描述符 1,默认连屏幕)和标准错误输出(文件描述符 2,默认连屏幕)。重定向操作就是修改这些文件描述符的连接目标:

  • 输出重定向>将标准输出重定向到文件,覆盖原有内容;>>同样重定向到文件,但追加内容。
  • 输入重定向<把标准输入从键盘重定向到文件。
  • 错误输出重定向2>将标准错误输出重定向到文件。

 


四、“一切皆文件” 的深度剖析

 

        在 Linux 系统中,不仅常规文件,进程、硬件设备、管道、套接字等都被视为文件。操作系统为每个打开的文件创建file结构体,其中的f_op指针指向file_operations结构体,该结构体中的函数指针对应着各种系统调用,如readwrite等。这种机制使得开发者可以用统一的文件操作接口来访问不同类型的资源,极大地简化了开发过程,提高了代码的通用性和可维护性。

         上图中的外设,每个设备都可以有自己的 read、write,但一定是对应着不同的操作方法!😎 但通过struct filefile_operation中的各种函数回调,让我们开发者只用 file 便可调取 Linux 系统中绝大部分的资源!👏 这便是 “linux 下一切皆文件” 的核心理解。😄


五、缓冲区机制探究

5.1 引入一些奇怪的现象

  • 现象 1:在向文件写入时,write 函数的内容会先被打印出来。通过如下代码验证:
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main()
{
    const char *fstr = "hello fwrite\n";
    const char *str = "hello write\n";

    printf("hello printf\n"); // stdout -> 1
    fprintf(stdout, "hello fprintf\n"); // stdout -> 1
    fwrite(fstr, strlen(fstr), 1, stdout); // fread, stdout->1
    write(1, str, strlen(str)); // 1
    return 0;
}

        当直接运行程序时,输出顺序为:

当将输出重定向到文件(./text > log.txt )后,用 cat log.txt 查看,输出顺序变为:

        这是因为 printffprintffwrite 这些 C 库函数有缓冲区,数据先存缓冲区,而 write 是系统调用,直接操作,所以先被打印。重定向时,缓冲区刷新策略改变,之前在缓冲区的数据后被刷新到文件。

变化如下:

输出到显示器时

此时标准输出采用行缓冲策略,即:

  • 数据先写入用户空间的缓冲区,当遇到换行符(\n)、缓冲区被填满、程序正常结束 ,或者调用了 fflush 等刷新函数时,缓冲区中的数据才会被刷新到内核缓冲区,进而显示在屏幕上。在代码printf("hello printf\n"); 、fprintf(stdout, "hello fprintf\n"); 、fwrite(fstr, strlen(fstr), 1, stdout); 中,输出的内容带有换行符,满足行缓冲的刷新条件,所以会依次输出。
  • 像 write 这类系统调用函数,它直接操作内核缓冲区,不经过 C 库的用户缓冲区,数据会立即被写入,所以 write 的内容也能及时显示 ,因此运行程序时看到的输出顺序是按代码顺序来的。

重定向到普通文件时

这种情况下标准输出采用全缓冲策略,即:

  • printffprintffwrite 这类 C 库函数写入的数据先暂存于用户缓冲区,直到缓冲区被填满,或者程序结束,又或者主动调用 fflush 函数时,数据才会被刷新到内核缓冲区,最终写入文件。在重定向输出时,printffprintffwrite 这几个函数输出的数据因为缓冲区未满且未主动刷新,所以一直留在缓冲区中 。
  • 而 write 作为系统调用,不依赖 C 库缓冲区,会直接将数据写入文件,所以在使用 cat 查看重定向文件内容时,write 的内容先出现。直到程序结束,缓冲区统一刷新,printffprintffwrite 对应的数据才依次写入文件,这就导致用 cat 查看文件时出现了和直接在终端运行程序不同的输出顺序。

 

  • 现象 2:加入 fork 之后,向文件写入时 C 接口会被调两次,且 write 先被打印。代码如下:
int main()
{
    const char *fstr = "hello fwrite\n";
    const char *str = "hello write\n";

    printf("hello printf\n"); 
    fprintf(stdout, "hello fprintf\n"); 
    fwrite(fstr, strlen(fstr), 1, stdout); 
    write(1, str, strlen(str)); 
/*函数原型:ssize_t write(int fd, const void *buf, size_t count)
    fd:文件描述符,通过open等函数打开文件或设备得到,用于指定要写入的目标。
    buf:指向要写入数据的缓冲区的指针,可以是字符数组等。
    count:要写入的字节数。
    返回值:成功时返回实际写入的字节数(可能小于count);失败时返回-1 */
    fork();
    return 0;
}

和现象 1 类似,前三个 C 接口数据存缓冲区,write 先输出。

   fork 时,子进程复制父进程缓冲区数据,当进程退出刷新缓冲区,所以 C 接口数据被打印两次。

  • 现象 3:关闭文件描述符 1(close(1) )后,就没有结果输出。代码如下:
int main()
{
    const char *str = "hello write";

    write(1, str, strlen(str)); 
    fprintf(stdout,str);
    close(1); 
    return 0;
}

 

             这段代码运行时没有完整输出结果,主要是因为标准输出缓冲区和文件描述符关闭机制的影响,下面为你详细解释:

1. 标准输出的缓冲机制

        在 C 语言中,标准输出(stdout,文件描述符为 1)存在缓冲区。当你使用 printffprintf 等 C 库函数进行输出时,数据并不会立即被输出到终端,而是先被存储在用户空间的缓冲区中。缓冲区的刷新时机取决于不同的缓冲策略,常见的有行缓冲、全缓冲和无缓冲:

 
  • 行缓冲:当遇到换行符(\n)、缓冲区满或者手动调用 fflush 函数时,缓冲区中的数据会被刷新到内核,进而输出到终端。标准输出连接到终端时默认采用行缓冲策略。
  • 全缓冲:只有当缓冲区满、程序正常结束或者手动调用 fflush 函数时,缓冲区中的数据才会被刷新。标准输出重定向到文件时通常采用全缓冲策略。
  • 无缓冲:数据会立即被输出,不会进行缓冲。标准错误输出(stderr)通常采用无缓冲策略。

2. 代码中 write 函数的执行情况

        在代码里,write 是一个系统调用函数,它直接将数据写入内核,不经过 C 库的用户缓冲区。当执行 write(1, str, strlen(str)); 时,"hello write" 会被直接写入到标准输出对应的内核缓冲区。但由于字符串末尾没有换行符,在行缓冲策略下,这些数据可能还没有被刷新到终端。

3. close(1) 的影响

  close(1) 会关闭标准输出的文件描述符。文件描述符是操作系统用来标识打开文件的整数,关闭文件描述符后,后续无法再通过该文件描述符进行读写操作。由于之前 write 写入的数据还在标准输出的内核缓冲区中,没有被刷新到终端,关闭文件描述符后,这些数据就无法再被输出,因此看不到完整的输出结果。

解决方案

若要确保数据能完整输出,可以在关闭文件描述符之前手动刷新缓冲区。以下是修改后的代码:

#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main()
{
    const char *str = "hello write\n";  // 添加换行符,触发行缓冲刷新
    write(1, str, strlen(str));
    fprintf(stdout,str);
    // 或者使用 fflush(stdout); 手动刷新标准输出缓冲区
    close(1);
    return 0;
}

        在上述代码中,给字符串添加了换行符 \n,这样在执行 write 时,由于行缓冲策略,数据会被立即刷新到终端。或者使用 fflush(stdout); 手动刷新标准输出的缓冲区,也能确保数据完整输出。

 

5.2 缓冲区不在操作系统内部

        通过现象 3 可知,close 作为系统调用接口不能在关闭文件前刷新缓冲区,说明操作系统看不到这个缓冲区。printffprintffwrite 等 C 库函数提供了一个缓冲区,先将内容存于此,需要刷新时才调用 write 函数写入。进程退出时会刷新缓冲区,但 close 后,文件描述符关闭,即使调用 write 也无法写入,缓冲区数据被丢弃。

5.3 缓冲区的刷新策略

  • 无缓冲:数据直接刷新,常见于 stderr ,比如 fputs 操作直接刷新到显示器文件。
  • 行缓冲:遇到换行符时刷新,像标准输出到显示器默认是行缓冲 ,数据先存缓冲区,遇到换行才写入。
  • 全缓存:缓冲区写满或者调用 fflush 等函数强制刷新时,数据才写入,普通文件默认是全缓存。

        不同策略是为满足不同场景需求。显示器行刷新可及时显示信息,普通文件全缓存可减少系统调用次数提升效率。

5.4 为什么要有缓冲区

  • 方便使用:如同寄快递,不用自己长途运送物品,交给快递站更省心。数据先存缓冲区,不用每次小量数据就直接操作硬件,使用更便捷。
  • 提高效率:攒够一定量数据再操作硬件,类似装满一袋快递再运送,减少操作次数,提高整体效率。
  • 配合格式化:比如 C 语言中整数格式化后再写入,可在缓冲区完成格式化操作,减少对硬件的直接频繁操作。

5.5 用户层缓冲区在哪

   exit 函数会调用 fflush 刷新缓冲区数据。fflush 函数操作的是 FILE* 类型指针指向的流对应的缓冲区。FILE 结构体不仅封装了文件描述符 fd 信息,还维护了对应文件的缓冲区字段和文件信息。打开一个文件就有专属缓冲区,通过 fprintf 等函数操作文件时,数据先进入该缓冲区。

5.6 内核缓冲区在哪

        内核缓冲区由操作系统的 file 结构体维护,和用户层缓冲区类似。用户一般无需关心其刷新时间,只要数据刷新到内核,就能到达硬件 ,因为现代操作系统会高效管理,避免浪费资源。


        通过对 Linux 基础 IO 各个方面的梳理,我们对文件操作、系统调用、缓冲区机制等有了更深入的理解。这些知识是 Linux 系统开发的重要基础,对于开发者编写高效、稳定的程序具有重要指导意义。在实际开发中,应根据具体需求合理选择文件操作方式和缓冲区策略,以充分发挥 Linux 系统的优势。

 


网站公告

今日签到

点亮在社区的每一天
去签到