嵌入式Linux系统编程 — 2.4 标准I/O库:I/O缓冲详解

目录

1 I/O 缓冲简介

1.1 什么是I/O 缓冲

1.2 I/O 缓冲的目的

2 文件 I/O 的内核缓冲

3 刷新文件 I/O 的内核缓冲区

3.1 什么是刷新文件 I/O 的内核缓冲区

3.2 控制文件 I/O 内核缓冲的系统调用函数

3.3 示例程序

4 控制文件 I/O 内核缓冲的标志

4.1 O_DSYNC 和 O_SYNC标志简介

4.2 示例程序

5 直接 I/O:绕过内核缓冲

5.1 绕过内核缓冲含义

5.2 为什么不都使用直接 I/O

5.3 直接 I/O 的对齐限制

6 stdio 缓冲

6.1 stdio 缓冲简介

6.2 标准 I/O 的 stdio 缓冲函数

6.3 标准输出 printf()的行缓冲模式测试

6.4 将标准输出配置为无缓冲模式测试

6.5 fflush()刷新 stdio 缓冲区

6.6 关闭与退出时刷新 stdio 缓冲区


1 I/O 缓冲简介

1.1 什么是I/O 缓冲

出于速度和效率的考虑,系统 I/O 调用(即文件 I/O)和标准 C 语言库 I/O 函数(即标准 I/O 函数)在操作磁盘文件时会对数据进行缓冲。I/O缓冲指的是在数据传输过程中,用于临时存储数据的内存区域。它允许程序先将数据写入或从内存中的一个缓冲区读取,然后再与外部设备(如硬盘、网络或控制台)进行数据交换。

1.2 I/O 缓冲的目的

提高效率:减少实际的I/O操作次数,因为可以积累一定量的数据后再执行一次较大的传输,而不是每次只传输少量数据。

减少阻塞:在缓冲的帮助下,程序可以继续执行其他任务,而不必等待每次I/O操作完成。

数据整合:在输出时,可以将多次小的数据写入合并为一次较大的写入操作。

2 文件 I/O 的内核缓冲

read()和 write()系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区(kernel buffer cache)之间复制数据。譬如调用 write()函数将 5 个字节数据从用户空间内存拷贝到内核空间的缓冲区中:

write(fd, "Hello", 5); //写入 5 个字节数据

调用 write()后仅仅只是将这 5 个字节数据拷贝到了内核空间的缓冲区中,拷贝完成之后函数就返回了,在后面的某个时刻,内核会将其缓冲区中的数据写入(刷新)到磁盘设备中,所以由此可知,系调用 write()与磁盘操作并不是同步的, write()函数并不会等待数据真正写入到磁盘之后再返回。如果在此期间, 其它进程调用 read()函数读取该文件的这几个字节数据,那么内核将自动从缓冲区中读取这几个字节数据返回给应用程序。

与此同理,对于读文件而言亦是如此,内核会从磁盘设备中读取文件的数据并存储到内核的缓冲区中,当调用 read()函数读取数据时, read()调用将从内核缓冲区中读取数据,直至把缓冲区中的数据读完,这时,内核会将文件的下一段内容读入到内核缓冲区中进行缓存。

文件 I/O 的内核缓冲区自然是越大越好, Linux 内核本身对内核缓冲区的大小没有固定上限。内核会分配尽可能多的内核来作为文件 I/O 的内核缓冲区,但受限于物理内存的总量,如果系统可用的物理内存越多,那自然对应的内核缓冲区也就越大,操作越大的文件也要依赖于更大空间的内核缓冲。

3 刷新文件 I/O 的内核缓冲区

3.1 什么是刷新文件 I/O 的内核缓冲区

刷新文件 I/O 的内核缓冲区:就是强制将文件 I/O 内核缓冲区中缓存的数据写入(刷新)到磁盘设备中。对于某些应用场景来说,可能是很有必要的,例如应用程序在进行某操作之前, 必须要确保前面步骤调用 write()写入到文件的数据已经真正写入到了磁盘中, 诸如一些数据库的日志进程。

以Ubuntu系统中的文件传输为例,当用户将文件复制到U盘后,拔除U盘前通常需要执行sync命令。sync命令的作用是确保所有文件I/O操作已经完成,内核缓冲区中的数据已被强制写入到U盘。如果省略此步骤,直接拔出U盘,可能会导致数据丢失或文件损坏,因为缓冲区中的数据可能尚未完全写入到U盘。

3.2 控制文件 I/O 内核缓冲的系统调用函数

Linux 中提供了一些系统调用可用于控制文件 I/O 内核缓冲,包括系统调用 sync()、 syncfs()、 fsync()以及 fdatasync()。

#include <unistd.h>

void sync(void);
int syncfs(int fd);
int fsync(int fd);
int fdatasync(int fd);

sync()将所有未写的或延迟写入的缓冲数据发送到磁盘。

  • 用法:通常在系统范围内刷新所有的文件系统缓冲区。

syncfs()同步指定文件描述符的文件系统缓冲区到磁盘。

  • 用法:syncfs() 调用只影响特定的文件系统,而不是整个系统。

fsync()强制将指定文件描述符的所有未写入数据同步到磁盘。

  • 用法:fsync() 通常用于确保对某个特定文件的所有更改都已持久化。

fdatasync()类似于 fsync(),但它只同步文件的数据部分,不包括元数据(如修改时间)。

  • 用法:当只需要同步文件内容而不需要更新文件属性时,使用 fdatasync()

3.3 示例程序

下面是程序示例演示了如何使用 fsync()fdatasync() 函数来确保文件数据被同步到磁盘:

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

int main() 
{
    // 打开文件用于写入
    int fd = open("example.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("Failed to open file");
        return 1;
    }

    // 写入数据到文件
    const char *data = "Important data to sync to disk";
    if (write(fd, data, strlen(data)) == -1) {
        perror("Failed to write to file");
        close(fd);
        return 1;
    }

    // 使用 fsync() 同步文件的所有数据和元数据到磁盘
    if (fsync(fd) == -1) {
        perror("fsync failed");
        close(fd);
        return 1;
    }
    printf("File and metadata synced to disk using fsync.\n");

    // 再次写入数据到文件
    if (write(fd, data, strlen(data)) == -1) {
        perror("Failed to write to file");
        close(fd);
        return 1;
    }

    // 使用 fdatasync() 只同步文件的数据部分到磁盘
    if (fdatasync(fd) == -1) {
        perror("fdatasync failed");
        close(fd);
        return 1;
    }
    printf("Data synced to disk using fdatasync.\n");


    // 关闭文件描述符
    close(fd);

    return 0;
}

程序首先打开(或创建)一个名为 example.txt 的文件用于写入,然后向其中写入一段数据。接着,使用 fsync() 函数确保这些数据和文件的元数据被同步到磁盘,以保证数据的持久性。之后,程序再次写入相同的数据,并使用 fdatasync() 函数仅同步数据部分到磁盘,而不包括元数据。最后,关闭文件描述符。程序运行结果如下:

4 控制文件 I/O 内核缓冲的标志

4.1 O_DSYNCO_SYNC标志简介

O_DSYNCO_SYNC 是两个用于控制文件I/O操作同步性的标志,定义了文件操作的同步写入行为。可以与 open() 函数一起使用,以改变文件的默认写入行为。

O_SYNC

  • 当使用 O_SYNC 标志打开文件时,所有对该文件的写操作都将被执行为同步写入。意味着每次写操作(write())完成后,系统都会确保数据被实际写入到磁盘,而不仅仅是内核缓冲区,但可能会降低性能,因为每次写操作都需要等待磁盘I/O完成。

O_DSYNC

  • O_DSYNC 标志用于打开文件,使得所有写操作都执行为同步的,就像 O_SYNC 一样。但是,与 O_SYNC 不同的是,O_DSYNC 只保证数据的同步性,而不保证文件元数据(如修改时间)的同步更新。意味着使用 O_DSYNC 标志时,文件内容会及时写入磁盘,但文件属性可能不会立即更新。

4.2 示例程序

下面的示例演示如使用 O_SYNCO_DSYNC 标志打开文件,并进行写操作。

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

int main() {
    const char *filename = "sync_example.txt";
    const char *data = "Data that needs to be synced immediately";

    // 使用 O_SYNC 标志打开文件,每次写操作后都会同步数据和元数据
    int sync_fd = open(filename, O_WRONLY | O_CREAT | O_SYNC, 0644);
    if (sync_fd == -1) {
        perror("Failed to open file with O_SYNC");
        return 1;
    }
    write(sync_fd, data, strlen(data)); // 写操作会立即同步到磁盘
    close(sync_fd);

    // 使用 O_DSYNC 标志打开文件,每次写操作后都会同步数据,但元数据可能不会
    int dsync_fd = open(filename, O_WRONLY | O_CREAT | O_DSYNC, 0644);
    if (dsync_fd == -1) {
        perror("Failed to open file with O_DSYNC");
        return 1;
    }
    write(dsync_fd, data, strlen(data)); // 数据会立即同步到磁盘,元数据可能不会
    close(dsync_fd);

    return 0;
}
  • 使用 O_SYNC 标志打开文件 sync_example.txt 进行写操作,这确保了每次写操作后,数据和文件的元数据都会立即同步到磁盘。
  • 使用 O_DSYNC 标志再次打开同一个文件进行写操作,这确保了数据会立即同步,但文件的元数据(如修改时间)可能不会立即更新到磁盘。

5 直接 I/O:绕过内核缓冲

5.1 绕过内核缓冲含义

前面的内容提到,数据传输过程中会临时存储数据到内存缓冲区,为了保持数据同步,会利用相关函数和标志将数据立即更新到磁盘。那么,有没有方法省略中间的缓冲步骤,直接将数据传递到文件或磁盘设备?

从Linux内核2.4版本起,Linux系统就支持应用程序执行直接I/O操作,即绕过内核缓冲区,直接在用户空间和磁盘之间传输数据。

5.2 为什么不都使用直接 I/O

在有些情况下,这种操作通常是很有必要的,例如,某应用程序的作用是测试磁盘设备的读写率, 那么在这种应用需要下,我们就需要保证 read/write 操作是直接访问磁盘设备,而不经过内核缓冲,如果不能得到这样的保证,必然会导致测试结果出现比较大的误差。

在特定情况下,如磁盘读写性能测试,直接I/O是必要的,以确保读写操作不经过内核缓冲,从而获得准确的测试结果。对于大多数应用程序而言,使用直接 I/O 可能会大大降低性能,这是因为为了提高 I/O 性能,内核针对文件 I/O 内核缓冲区做了不少的优化,譬如包括按顺序预读取、在成簇磁盘块上执行 I/O、允许访问同一文件的多个进程共享高速缓存的缓冲区。如果应用程序使用直接 I/O 方式, 将无法享受到这些优化措施所带来的性能上的提升,直接 I/O 只在一些特定的需求场合,譬如磁盘速率测试工具、数据库系统等。

要实现直接I/O,可以在打开文件时通过open()函数并带上O_DIRECT标志来指定。例如:

int fd = open("file.dat", O_WRONLY | O_CREAT | O_DIRECT, 0644);
if (fd < 0) {
    perror("open");
    // 处理错误
}
// 使用write()等函数进行I/O操作
// ...
close(fd);

示例中,O_DIRECT标志被添加到open()函数的参数中,操作系统进行直接I/O操作。

5.3 直接 I/O 的对齐限制

因为直接 I/O 涉及到对磁盘设备的直接访问,所以在执行直接 I/O 时,必须要遵守以下三个对齐限制要求:

  • 应用程序中用于存放数据的缓冲区,其内存起始地址必须以块大小的整数倍进行对齐;
  • 写文件时,文件的位置偏移量必须是块大小的整数倍;
  • 写入到文件的数据大小必须是块大小的整数倍。

如果不满足以上任何一个要求,调用 write()均为以错误返回 Invalid argument。以上所说的块大小指的是磁盘设备的物理块大小(block size) ,常见的块大小包括 512 字节、 1024 字节、 2048 以及 4096 字节。

可以如下命令进行查看磁盘分区的块大小:

tune2fs -l /dev/sda | grep "Block size"

-l 后面指定了需要查看的磁盘分区,可以使用 df -h 命令查看 Ubuntu 系统的根文件系统所挂载的磁盘分区:

运行得到块大小:
 

6 stdio 缓冲

6.1 stdio 缓冲简介

标准 I/O(fopen、 fread、 fwrite、 fclose、 fseek 等)是 C 语言标准库函数, 而文件 I/O(open、 read、 write、close、 lseek 等)是系统调用,虽然标准 I/O 是在文件 I/O 基础上进行封装而实现, 但在效率、性能上标准 I/O 要优于文件 I/O,其原因在于标准 I/O 实现维护了自己的缓冲区, 我们把这个缓冲区称为 stdio 缓冲区。

前面提到了文件 I/O 内核缓冲,这是由内核维护的缓冲区,而标准 I/O 所维护的 stdio 缓冲是用户空间的缓冲区,当应用程序中通过标准 I/O 操作磁盘文件时,为了减少调用系统调用的次数,标准 I/O 函数会将用户写入或读取文件的数据缓存在 stdio 缓冲区,然后再一次性将 stdio 缓冲区中缓存的数据通过调用系统调用 I/O(文件 I/O)写入到文件 I/O 内核缓冲区或者拷贝到应用程序的 buf 中。通过这样的优化操作,当操作磁盘文件时,在用户空间缓存大块数据以减少调用系统调用的次数,使得效率、性能得到优化。 

6.2 标准 I/O 的 stdio 缓冲函数

C 语言提供了一些库函数可用于对标准 I/O 的 stdio 缓冲区进行相关的一些设置, 包括 setbuf()、setbuffer()以及 setvbuf()。

setvbuf() 函数设置缓冲区。

int setvbuf(FILE *stream, char *buf, int mode, size_t size);
  • stream:指向 FILE 结构的指针,表示要设置缓冲区的流。
  • buf:用户提供的缓冲区,或 NULL 以使用 stdio 的默认缓冲区。
  • mode:缓冲区模式,可以是 _IOFBF(全缓冲)、_IONBF(无缓冲)或 _IOLBF(行缓冲)。
  • size:缓冲区的大小。
  • 返回值: 成功返回 0,失败将返回一个非 0 值,并且会设置 errno 来指示错误原因。
无缓冲(_IONBF)在无缓冲模式下,每次写操作都会直接发送到输出设备,不经过任何内部缓冲区。
行缓冲(_IOLBF)行缓冲模式下,输出通常在遇到换行字符(\n)时被刷新到输出设备。
全缓冲(_IOFBF)全缓冲模式下,输出会被存储在缓冲区中,直到缓冲区满或者通过显式调用刷新缓冲区的函数(如fflush())时,才会将数据发送到输出设备。

setbuf() 函数:函数允许指定一个自定义的缓冲区,如果提供了有效的 buf 指针,将使用这个缓冲区进行数据缓冲。

void setbuf(FILE *stream, char *buf);
  • stream:指向 FILE 结构的指针,表示要设置缓冲区的输出流(如 stdout 或文件流)。
  • buf:指向字符数组的指针,用作流的缓冲区。如果 buf 为 NULL,则流变为无缓冲。

setbuffer() 函数setbuffer()函数类似于 setbuf(),但允许调用者指定 buf 缓冲区的大小

void setbuffer(FILE *stream, char *buf, size_t size);
  • stream:指向 FILE 结构的指针,表示要设置缓冲区的流。
  • buf:用户提供的缓冲区。
  • size:缓冲区的大小。

6.3 标准输出 printf()的行缓冲模式测试

我们先看看下面这个简单地示例代码,调用了 printf()函数,区别在于第二个 printf()没有输出换行符。

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

int main(void)
{
    printf("Hello World 1\n");
    printf("Hello World 2");
    for ( ; ; )
        sleep(1);
}

运行之后可以发现只有第一个 printf()打印的信息显示出来了,第二个并没有显示出来,这是为什么呢?

这就是 stdio 缓冲的问题,前面提到了标准输出默认采用的是行缓冲模式, printf()输出的字符串写入到了标准输出的 stdio 缓冲区中,只有输出换行符时(不考虑缓冲区填满的情况) 才会将这一行数据刷入到内核缓冲区。因为第一个 printf()包含了换行符,所以已经刷入了内核缓冲区,而第二个 printf 并没有包含换行符,所以第二个 printf 输出的"Hello World!"还缓存在 stdio 缓冲区中。

scanf()函数的行缓冲模式:格式化输入 scanf()函数通过键盘输入数据,只有在按下回车键(换行符键)时程序才会接着往下执行,因为标准输入默认也是采用了行缓冲模式。

6.4 将标准输出配置为无缓冲模式测试

修改上面的代码,使标准输出变成无缓冲模式,修改后代码如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
    /* 将标准输出设置为无缓冲模式 */
    if (setvbuf(stdout, NULL, _IONBF, 0)) {
        perror("setvbuf error");
        exit(0);
    }
    printf("Hello World 1\n");
    printf("Hello World 2");
    for ( ; ; )
        sleep(1);
}

在使用 printf()之前,调用 setvbuf()函数将标准输出的 stdio 缓冲设置为无缓冲模式,可以发现该程序能够成功输出两个“Hello World!”。运行结果如下:

6.5 fflush()刷新 stdio 缓冲区

无论我们采取何种缓冲模式,在任何时候都可以使用库函数 fflush()来强制刷新stdio 缓冲区, 该函数会刷新指定文件的 stdio 输出缓冲区,此函数原型如下所示:

int fflush(FILE *stream);

参数: stream 指定需要进行强制刷新的文件,如果该参数设置为 NULL,则表示刷新所有的 stdio 缓冲区。
返回值:函数调用成功返回 0,否则将返回-1,并设置 errno 以指示错误原因。

进一步修改上面的代码,在第二个 printf 后面调用 fflush()函数,修改后代码如下所示:

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

int main(void)
{
    printf("Hello World 1\n");
    printf("Hello World 2");
    fflush(stdout); //刷新标准输出 stdio 缓冲区
    for ( ; ; )
        sleep(1);
}

可以看到,打印了两次“Hello World”, 这就是 fflush()的作用了强制刷新 stdio 缓冲区。运行结果如下:

6.6 关闭与退出时刷新 stdio 缓冲区

当文件关闭时、程序退出时,也会自动刷新 stdio 缓冲区。修改上面的代码,在调用第二个 printf 函数后关闭标准输出,如下所示:

// 关闭文件时刷新 stdio 缓冲区
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    printf("Hello World!\n");
    printf("Hello World!");
    fclose(stdout); //关闭标准输出
    for ( ; ; )
        sleep(1);
}

程序退出时也会自动刷新 stdio 缓冲区,修改上面的代码,去掉 for 死循环,让程序结束,修改完之后如下所示:

// 程序结束时刷新 stdio 缓冲区
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    printf("Hello World!\n");
    printf("Hello World!");
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

几度春风里

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值