一、标准IO简介
所谓标准 I/O 库则是标准 C 库中用于文件 I/O 操作(譬如读文件、写文件等)相关的一系列库函数的集合,通常标准 I/O 库函数相关的函数定义都在头文件 <stdio.h> 中,所以我们需要在程序源码中包含 <stdio.h> 头文件。
标准 I/O 库函数是构建于文件 I/O ( open() 、 read() 、 write() 、 lseek() 、 close() 等)这些系统调用之上的, 譬如标准 I/O 库函数 fopen() 就利用系统调用 open() 来执行打开文件的操作、 fread() 利用系统调用 read() 来执行读文件操作、fwrite() 则利用系统调用 write() 来执行写文件操作等等。
标准 I/O 和文件 I/O 的区别如下:
⚫ 虽然标准 I/O 和文件 I/O 都是 C 语言函数,但是标准 I/O 是标准 C 库函数,而文件 I/O 则是 Linux系统调用;
⚫ 标准 I/O 是由文件 I/O 封装而来,标准 I/O 内部实际上是调用文件 I/O 来完成实际操作的;
⚫ 可移植性:标准 I/O 相比于文件 I/O 具有更好的可移植性,通常对于不同的操作系统,其内核向应用层提供的系统调用往往都是不同,譬如系统调用的定义、功能、参数列表、返回值等往往都是不 一样的;而对于标准 I/O 来说,由于很多操作系统都实现了标准 I/O 库,标准 I/O 库在不同的操作系统之间其接口定义几乎是一样的,所以标准 I/O 在不同操作系统之间相比于文件 I/O 具有更好的 可移植性。
⚫ 性能、效率:标准 I/O 库在用户空间维护了自己的 stdio 缓冲区,所以标准 I/O 是带有缓存的,而 文件 I/O 在用户空间是不带有缓存的,所以在性能、效率上,标准 I/O 要优于文件 I/O 。
二、FILE指针
之前所有文件 I/O 函数( open() 、 read() 、 write() 、 lseek() 等)都是围绕文件描述符进行的, 当调用 open() 函数打开一个文件时,即返回一个文件描述符 fd ,然后该文件描述符就用于后续的 I/O 操作。
而对于标准 I/O 库函数来说,它们的操作是围绕 FILE 指针进行的,当使用标准 I/O 库函数打开或创建一个文件时,会返回一个指向 FILE 类型对象的指针(FILE * ),使用该 FILE 指针与被打开或创建的文件相关联,然后该 FILE 指针就用于后续的标准 I/O 操作(使用标准 I/O 库函数进行 I/O 操作),所以由此可知, FILE 指针的作用相当于文件描述符,只不过 FILE 指针用于标准 I/O 库函数中、而文件描述符则用于文件I/O 系统调用中。
FILE 是一个结构体数据类型,它包含了标准 I/O 库函数为管理文件所需要的所有信息,包括用于实际 I/O 的文件描述符、指向文件缓冲区的指针、缓冲区的长度、当前缓冲区中的字节数以及出错标志等。 FILE 数据结构定义在标准 I/O 库函数头文件 stdio.h 中。
三、标准输入、输出、错误
0 、 1 、 2 这三个是文件描述符,只能用于文件 I/O ( read() 、 write() 等),那么在标准 I/O 中,自然是无法使用文件描述符来对文件进行 I/O 操作的,它们需要围绕 FILE 类型指针来进行,在 stdio.h 头文件中有相应的定义,如下:
/* Standard streams. */
extern struct _IO_FILE *stdin; /* Standard input stream. */
extern struct _IO_FILE *stdout; /* Standard output stream. */
extern struct _IO_FILE *stderr; /* Standard error output stream. */
/* C89/C99 say they're macros. Make them happy. */
#define stdin stdin
#define stdout stdout
#define stderr stderr
所以,在标准 I/O 中,可以使用 stdin 、 stdout 、 stderr 来表示标准输入、标准输出和标准错误。
四、fopen() fread() fwrite()
在标准 I/O 中,我们将使用库函数 fopen()打开或创建文件, fopen() 函数原型如下所示:
#include <stdio.h>
FILE *fopen(const char *path, const char *mode);
函数参数和返回值含义如下:
path : 参数 path 指向文件路径,可以是绝对路径、也可以是相对路径。
mode : 参数 mode 指定了对该文件的读写权限,是一个字符串,稍后介绍。
返回值: 调用成功返回一个指向 FILE 类型对象的指针( FILE * ),该指针与打开或创建的文件相关联, 后续的标准 I/O 操作将围绕 FILE 指针进行。如果失败则返回 NULL ,并设置 errno 以指示错误原因。
五、fseek 定位
库函数 fseek() 的作用类似于 系统调用 lseek() ,用于设置文件读写位置偏移量, lseek() 用于文件 I/O ,而库函数 fseek() 则用于标准 I/O。
六、检查或复位标志
调用 fread() 读取数据时,如果返回值小于参数 nmemb 所指定的值,表示发生了错误或者已经到了文件末尾(文件结束 end-of-file ),但 fread() 无法具体确定是哪一种情况;在这种情况下,可以通过判断错误标志或 end-of-file 标志来确定具体的情况。
feof() 函数
ferror() 函数
clearerr() 函数
七、格式化IO
在前面编写的测试代码中,会经常使用到库函数 printf() 用于输出程序中的打印信息, printf() 函数可将格式化数据写入到标准输出,所以通常称为格式化输出。除了 printf() 之外,格式化输出还包括: fprintf() 、 dprintf()、 sprintf() 、 snprintf() 这 4 个库函数。 除了格式化输出之外,自然也有格式化输入,从标准输入中获取格式化数据,格式化输入包括:scanf() 、 fscanf()、 sscanf() 这三个库函数。
格式化输出:
C 库函数提供了 5 个格式化输出函数,包括: printf() 、 fprintf() 、 dprintf() 、 sprintf() 、 snprintf() ,其函数定义如下所示:
#include <stdio.h>
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int dprintf(int fd, const char *format, ...);
int sprintf(char *buf, const char *format, ...);
int snprintf(char *buf, size_t size, const char *format, ...);
可以看到,这 5 个函数都是可变参函数,它们都有一个共同的参数 format ,这是一个字符串,称为格式控制字符串,用于指定后续的参数如何进行格式转换,所以才把这些函数称为格式化输出,因为它们可以以调用者指定的格式进行转换输出。
格式控制字符串 format
把这个参数称为格式控制字符串,顾名思义,首先它是一个字符串的形式,其次它能够控制后续变参的格式转换。
格式控制字符串由两部分组成:普通字符(非 % 字符)和转换说明。普通字符会进行原样输出,每个转换说明都会对应后续的一个参数,通常有几个转换说明就需要提供几个参数(除固定参数之外的参数),使 之一一对应,用于控制对应的参数如何进行转换。
转换说明的描述信息需要和与之相对应的参数对应的数据类型要进行匹配,如果不匹配通常会编译报错或者警告!
格式化输入:
C 库函数提供了 3 个格式化输入函数,包括: scanf() 、 fscanf() 、 sscanf() ,其函数定义如下所示:
#include <stdio.h>
int scanf(const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);
int sscanf(const char *str, const char *format, ...);
八、I/O缓冲
出于速度和效率的考虑,系统 I/O 调用(即文件 I/O , open 、 read 、 write 等)和标准 C 语言库 I/O 函数 (即标准 I/O 函数)在操作磁盘文件时会对数据进行缓冲。
1、文件 I/O 的内核缓冲
read() 和 write() 系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区(kernel buffer cache )之间复制数据。
调用 write()后仅仅只是将 字节数据拷贝到了内核空间的缓冲区中,拷贝完成之后函数就返回了, 在后面的某个时刻,内核会将其缓冲区中的数据写入(刷新)到磁盘设备中,所以由此可知,系统调用 write() 与磁盘操作并不是同步的,write()函数并不会等待数据真正写入到磁盘之后再返回。此外当调用
close()
或显式同步操作(如 fsync()
、 fdatasync()
或 sync()
)时,操作系统也会将相应文件的缓冲区数据写入磁盘。 如果在此期间,其它进 程调用 read() 函数读取该文件的这几个字节数据,那么内核将自动从缓冲区中读取这几个字节数据返回给应用程序。
我们把这个内核缓冲区就称为文件 I/O 的内核缓冲。这样的设计,目的是为了提高文件 I/O 的速度和效 率,使得系统调用 read()、write()的操作更为快速,不需要等待磁盘操作(将数据写入到磁盘或从磁盘读取 出数据),磁盘操作通常是比较缓慢的。同时这一设计也更为高效,减少了内核操作磁盘的次数。
刷新文件 I/O 的内核缓冲区
强制将文件 I/O 内核缓冲区中缓存的数据写入(刷新)到磁盘设备中,对于某些应用程序来说,可能是 很有必要的,例如,应用程序在进行某操作之前,必须要确保前面步骤调用 write() 写入到文件的数据已经真 正写入到了磁盘中,诸如一些数据库的日志进程。
联系到一个实际的使用场景,当我们在 Ubuntu 系统下拷贝文件到 U 盘时,文件拷贝完成之后,通常在拔掉 U 盘之前,需要执行 sync 命令进行同步操作,这个同步操作其实就是将文件 I/O 内核缓冲区中的数据更新到 U 盘硬件设备,所以如果在没有执行 sync 命令时拔掉 U 盘,很可能就会导致拷贝到 U 盘中的文件遭到破坏!
㈠、 fsync() 函数
㈡、 fdatasync() 函数
㈢、 sync() 函数
对性能的影响
在程序中频繁调用 fsync() 、 fdatasync() 、 sync() (或者调用 open 时指定 O_DSYNC 或 O_SYNC 标志) 对性能的影响极大,大部分的应用程序是没有这种需求的,所以在大部分应用程序当中基本不会使用到。
直接 I/O:绕过内核缓冲
从 Linux 内核 2.4 版本开始, Linux 允许应用程序在执行文件 I/O 操作时绕过内核缓冲区,从用户空间 直接将数据传递到文件或磁盘设备,把这种操作也称为直接 I/O ( direct I/O )或裸 I/O ( raw I/O)。直接 I/O 只在一些特定的需求场合,譬如磁盘速率测试工具、数据库系统等。
2、标准IO的stdio 缓冲
标准 I/O(fopen、fread、fwrite、fclose、fseek 等)是 C 语言标准库函数,而文件 I/O(open、read、write、 close、lseek 等)是系统调用,虽然标准 I/O 是在文件 I/O 基础上进行封装而实现(譬如 fopen 内部实际上调 用了 open、fread 内部调用了 read 等),但在效率、性能上标准 I/O 要优于文件 I/O,其原因在于标准 I/O 实 现维护了自己的缓冲区,我们把这个缓冲区称为 stdio 缓冲区。
通过这样的优化操作,当操作磁盘文件时,在用户空间缓存大块数据以减少调用系统调用的次数,使得 效率、性能得到优化。使用标准 I/O 可以使编程者免于自行处理对数据的缓冲,无论是调用 write() 写入数据、还是调用 read() 读取数据。
对 stdio 缓冲进行设置
C 语言提供了一些库函数可用于对标准 I/O 的 stdio 缓冲区进行相关的一些设置,包括 setbuf() 、 setbuffer() 以及 setvbuf() 。
刷新 stdio 缓冲区
㈠、关闭文件时刷新 stdio 缓冲区
fclose(stdout); //关闭标准输出
㈡、程序退出时刷新 stdio 缓冲区
总结:
从图中自上而下,
1)首先应用程序调用标准 I/O 库函数将用户数据写入到 stdio 缓冲区中, stdio 缓冲区是 由 stdio 库所维护的用户空间缓冲区。
2)针对不同的缓冲模式,当满足条件时, stdio 库会调用文件 I/O (系统 调用 I/O )将 stdio 缓冲区中缓存的数据写入到内核缓冲区中,内核缓冲区位于内核空间。
3)最终由内核向磁 盘设备发起读写操作,将内核缓冲区中的数据写入到磁盘(或者从磁盘设备读取数据到内核缓冲区)。
应用程序调用库函数可以对 stdio 缓冲区进行相应的设置,设置缓冲区缓冲模式、缓冲区大小以及由调 用者指定一块空间作为 stdio 缓冲区,并且可以强制调用 fflush() 函数刷新缓冲区;而对于内核缓冲区来说, 应用程序可以调用相关系统调用对内核缓冲区进行控制,譬如调用 fsync() 、 fdatasync() 或 sync() 来刷新内核 缓冲区(或通过 open 指定 O_SYNC 或 O_DSYNC 标志),或者使用直接 I/O 绕过内核缓冲区( open 函数 指定 O_DIRECT 标志)。
九、文件描述符与 FILE 指针互转
在应用程序中,在同一个文件上执行 I/O 操作时,还可以将文件 I/O (系统调用 I/O )与标准 I/O 混合使 用,这个时候我们就需要将文件描述符和 FILE 指针对象之间进行转换,此时可以借助于库函数 fdopen() 、 fileno()来完成。
库函数 fileno() 可以将标准 I/O 中使用的 FILE 指针转换为文件 I/O 中所使用的文件描述符,而 fdopen() 则进行着相反的操作。
当混合使用文件 I/O 和标准 I/O 时,需要特别注意缓冲的问题,文件 I/O 会直接将数据写入到内核缓冲 区进行高速缓存,而标准 I/O 则会将数据写入到 stdio 缓冲区,之后再调用 write() 将 stdio 缓冲区中的数据写入到内核缓冲区。譬如下面这段代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("print");
write(STDOUT_FILENO, "write\n", 6);
exit(0);
}
执行结果你会发现,先输出了 "write" 字符串信息,接着再输出了 "print" 字符串信息,产生这个问题的原因很简单:
printf函数是标准IO函数,且是行缓冲的,他会将数据先输入到stdio缓冲区中,遇到换行符、程序退出或者主动刷新时才会将数据刷新进内核缓冲区,在终端显示。
write函数是系统调用函数,会直接将数据写入内核缓冲区中。