本章介绍标准 I/O 库, 不仅是 Linux,很多其它的操作系统都实现了标准 I/O 库。 标准 I/O 虽然是对文件 I/O 进行了封装,但事实上并不仅仅只是如此,标准 I/O 会处理很多细节,譬如分配 stdio 缓冲区、以优化的块长度执行 I/O 等,这些处理使用户不必担心如何选择使用正确的块长度。
本章将会讨论如下主题内容。
⚫ 标准 I/O 库简介;
⚫ 流和 FILE 对象;
⚫ 标准输入、标准输出以及标准错误;
⚫ 使用标准 I/O 库函数打开、读写、关闭文件;
⚫ 格式化 I/O,格式化输出 printf、格式化输入 scanf;
⚫ 文件 I/O 缓冲,内核缓冲区和 stdio 缓冲区;
⚫ 文件 I/O 与标准 I/O 混合编程。
标准 I/O 库简介
在第一章介绍应用编程概念时向大家介绍了系统调用与标准 C 语言函数库(以下简称标准 C 库) , 所谓标准 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 之上, 但标准 I/O 却有它自己的优势,标准 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。
关于标准 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 中。
标准输入、标准输出和标准错误
关于标准输入、标准输出以及标准错误这三个概念在前面有所提及, 所谓标准输入设备指的就是计算机系统的标准的输入设备,通常指的是计算机所连接的键盘;而标准输出设备指的是计算机系统中用于输出标准信息的设备,通常指的是计算机所连接的显示器;标准错误设备则指的是计算机系统中用于显示错误信息的设备,通常也指的是显示器设备。
用户通过标准输入设备与系统进行交互, 进程将从标准输入(stdin)文件中得到输入数据,将正常输出数据(譬如程序中 printf 打印输出的字符串) 输出到标准输出(stdout) 文件,而将错误信息(譬如函数调用报错打印的信息)输出到标准错误(stderr) 文件。标准输出文件和标准错误文件都对应终端的屏幕,而标准输入文件则对应于键盘。
每个进程启动之后都会默认打开标准输入、标准输出以及标准错误, 得到三个文件描述符, 即 0、 1、2, 其中 0 代表标准输入、 1 代表标准输出、 2 代表标准错误; 在应用编程中可以使用宏 STDIN_FILENO、STDOUT_FILENO 和 STDERR_FILENO 分别代表 0、 1、 2,这些宏定义在 unistd.h 头文件中:
/* Standard file descriptors. */
#define STDIN_FILENO 0 /* Standard input. */
#define STDOUT_FILENO1 /* Standard output. */
#define STDERR_FILENO2 /* Standard error output. */
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
struct _IO_FILE 结构体就是 FILE 结构体,使用了 typedef 进行了重命名。
所以,在标准 I/O 中,可以使用 stdin、 stdout、 stderr 来表示标准输入、标准输出和标准错误。
打开文件 fopen()
在文件 I/O 中,使用 open()系统调用打开或创建文件,而在标准 I/O 中,我们将使用库函数fopen()打开或创建文件, fopen()函数原型如下所示:
#include <stdio.h>
FILE *fopen(const char *path, const char *mode);
使用该函数需要包含头文件 stdio.h。
函数参数和返回值含义如下:
path: 参数 path 指向文件路径,可以是绝对路径、也可以是相对路径。
mode: 参数 mode 指定了对该文件的读写权限,是一个字符串,稍后介绍。
返回值: 调用成功返回一个指向 FILE 类型对象的指针(FILE *),该指针与打开或创建的文件相关联,后续的标准 I/O 操作将围绕 FILE 指针进行。 如果失败则返回 NULL,并设置 errno 以指示错误原因。
参数 mode 字符串类型,可取值为如下值之一:
mode | 说明 | 对应于 open()函数的 flags 参数取值 |
r | 以只读方式打开文件。 | O_RDONLY |
r+ | 以可读、可写方式打开文件。 | O_RDWR |
w | 以只写方式打开文件,如果参数 path 指定的文件 存在,将文件长度截断为 0;如果指定文件不存在 则创建该文件。 | O_WRONLY | O_CREAT | O_TRUNC |
w+ | 以可读、可写方式打开文件,如果参数 path 指定 的文件存在,将文件长度截断为 0;如果指定文件 不存在则创建该文件。 | O_RDWR | O_CREAT | O_TRUNC |
a | 以只写方式打开文件,打开以进行追加内容(在 文件末尾写入),如果文件不存在则创建该文 件。 | O_WRONLY | O_CREAT | O_APPEND |
a+ | 以可读、可写方式打开文件,以追加方式写入 (在文件末尾写入),如果文件不存在则创建该 文件。 | O_RDWR | O_CREAT | O_APPEND |
新建文件的权限
由 fopen()函数原型可知, fopen()只有两个参数 path 和 mode,不同于 open()系统调用,它并没有任何一个参数来指定新建文件的权限。 当参数 mode 取值为"w"、 "w+"、 "a"、 "a+"之一时,如果参数 path 指定的文件不存在,则会创建该文件,那么新的文件的权限是如何确定的呢?
虽然调用 fopen()函数新建文件时无法手动指定文件的权限,但却有一个默认值:
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH (0666)
使用示例
使用只读方式打开文件:
fopen(path, "r");
使用可读、可写方式打开文件:
fopen(path, "r+");
使用只写方式打开文件,并将文件长度截断为 0,如果文件不存在则创建该文件:
fopen(path, "w");
调用 fclose()库函数可以关闭一个由 fopen()打开的文件,其函数原型如下所示:
#include <stdio.h>
int fclose(FILE *stream);
参数 stream 为 FILE 类型指针,调用成功返回 0;失败将返回 EOF(也就是-1),并且会设置 errno 来指示错误原因。
读文件和写文件
当使用 fopen()库函数打开文件之后,接着我们便可以使用 fread()和 fwrite()库函数对文件进行读、写操作了,函数原型如下所示:
#include <stdio.h>
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
库函数 fread()用于读取文件数据,其参数和返回值含义如下:
ptr: fread()将读取到的数据存放在参数 ptr 指向的缓冲区中;
size: fread()从文件读取 nmemb 个数据项,每一个数据项的大小为 size 个字节,所以总共读取的数据大小为 nmemb * size 个字节。
nmemb: 参数 nmemb 指定了读取数据项的个数。
stream: FILE 指针。
返回值: 调用成功时返回读取到的数据项的数目(数据项数目并不等于实际读取的字节数,除非参数size 等于 1);如果发生错误或到达文件末尾,则 fread()返回的值将小于参数 nmemb,那么到底发生了错误还是到达了文件末尾, fread()不能区分文件结尾和错误, 究竟是哪一种情况,此时可以使用 ferror()或 feof()函数来判断。
库函数 fwrite()用于将数据写入到文件中,其参数和返回值含义如下:
ptr: 将参数 ptr 指向的缓冲区中的数据写入到文件中。
size: 参数 size 指定了每个数据项的字节大小,与 fread()函数的 size 参数意义相同。
nmemb: 参数 nmemb 指定了写入的数据项个数,与 fread()函数的 nmemb 参数意义相同。
stream: FILE 指针。
返回值: 调用成功时返回写入的数据项的数目(数据项数目并不等于实际写入的字节数,除非参数 size等于 1);如果发生错误,则 fwrite()返回的值将小于参数 nmemb(或者等于 0)。
由此可知,库函数 fread()、 fwrite()中指定读取或写入数据大小的方式与系统调用 read()、 write()不同,前者通过 nmemb(数据项个数) *size(每个数据项的大小)的方式来指定数据大小,而后者则直接通过一个 size 参数指定数据大小。譬如要将一个 struct mystr 结构体数据写入到文件中,可按如下方式写入:
fwrite(buf, sizeof(struct mystr), 1, file);
当然也可以按如下方式写:
fwrite(buf, 1, sizeof(struct mystr), file);
使用示例
结合使用本小节与上小节所学内容,我们来编写一个简单地示例代码,使用标准 I/O 方式对文件进行读写操作。 示例代码演示了使用 fwrite()库函数将数据写入到文件中。
#include <stdio.h>
#include <stdlib.h>int main(void)
{char buf[] = "Hello World!\n";FILE *fp = NULL;/* 打开文件 */if (NULL == (fp = fopen("./test_file", "w"))) {perror("fopen error");exit(-1);}printf("文件打开成功!\n");/* 写入数据 */if (sizeof(buf) > fwrite(buf, 1, sizeof(buf), fp)) {printf("fwrite error\n");fclose(fp);exit(-1);}printf("数据写入成功!\n");/* 关闭文件 */fclose(fp);exit(0);
}
首先使用 fopen()函数将当前目录下的 test_file 文件打开,调用 fopen()时 mode 参数设置为"w",表示以只写的方式打开文件,并将文件的长度截断为 0,如果指定文件不存在则创建该文件。打开文件之后调用fwrite()函数将"Hello World!"字符串数据写入到文件中。写入完成之后,调用 fclose()函数关闭文件,退出程序。编译运行:
示例代码演示了使用库函数 fread()从文件中读取数据。
#include <stdio.h>
#include <stdlib.h>int main(void)
{char buf[50] = {0};FILE *fp = NULL;int size;/* 打开文件 */if (NULL == (fp = fopen("./test_file", "r"))) {perror("fopen error");exit(-1);}printf("文件打开成功!\n");/* 读取数据 */if (12 > (size = fread(buf, 1, 12, fp))) {if (ferror(fp)) { //使用 ferror 判断是否是发生错误printf("fread error\n");fclose(fp);exit(-1);}/* 如果未发生错误则意味着已经到达了文件末尾 */}printf("成功读取%d 个字节数据: %s\n", size, buf);/* 关闭文件 */fclose(fp);exit(0);
}
首先同样使用 fopen()打开当前目录下的 test_file 文件得到 FILE 指针, 调用 fopen()时其参数 mode 设置为"r",表示以只读方式打开文件。接着使用 fread()函数从文件中读取 12 * 1=12 个字节的数据,将读取到的数据存放在 buf 中,当读取到的字节数小于指定字节数时,表示发生了错误或者已经到达了文件末尾,程序中调用了库函数 ferror()来判断是不是发生了错误。如果未发生错误,那么就意味着已经达到了文件末尾,其实也就说明了在调用 fread()读文件时对应的读写位置到文件末尾之间的字节数小于指定的字节数。最后调用 printf()打印结果,编译测试:
fseek 定位
库函数 fseek()的作用类似于前面学习的系统调用 lseek(), 用于设置文件读写位置偏移量, lseek()用于文件 I/O,而库函数 fseek()则用于标准 I/O,其函数原型如下所示:
#include <stdio.h>
int fseek(FILE *stream, long offset, int whence);
函数参数和返回值含义如下:
stream: FILE 指针。
offset: 与 lseek()函数的 offset 参数意义相同。
whence: 与 lseek()函数的 whence 参数意义相同。
返回值: 成功返回 0;发生错误将返回-1,并且会设置 errno 以指示错误原因; 与 lseek()函数的返回值意义不同,这里要注意!
调用库函数 fread()、 fwrite()读写文件时,文件的读写位置偏移量会自动递增,使用 fseek()可手动设置文件当前的读写位置偏移量。
譬如将文件的读写位置移动到文件开头处:
fseek(file, 0, SEEK_SET);
将文件的读写位置移动到文件末尾:
fseek(file, 0, SEEK_END);
将文件的读写位置移动到 100 个字节偏移量处:
fseek(file, 100, SEEK_SET);
使用示例:
#include <stdio.h>
#include <stdlib.h>int main(void)
{FILE *fp = NULL;char rd_buf[100] = {0};char wr_buf[] = "正点原子 http://www.openedv.com/forum.php\n";int ret;/* 打开文件 */if (NULL == (fp = fopen("./test_file", "w+"))) {perror("fopen error");exit(-1);}printf("文件打开成功!\n");/* 写文件 */if (sizeof(wr_buf) > fwrite(wr_buf, 1, sizeof(wr_buf), fp)) {printf("fwrite error\n");fclose(fp);exit(-1);}printf("数据写入成功!\n");/* 将读写位置移动到文件头部 */if (0 > fseek(fp, 0, SEEK_SET)) {perror("fseek error");fclose(fp);exit(-1);}/* 读文件 */if (sizeof(wr_buf) > (ret = fread(rd_buf, 1, sizeof(wr_buf), fp))) {printf("fread error\n");fclose(fp);exit(-1);}printf("成功读取%d 个字节数据: %s\n", ret, rd_buf);/* 关闭文件 */fclose(fp);exit(0);
}
程序中首先调用 fopen()打开当前目录下的 test_file 文件,参数 mode 设置为"w+";接着调用 fwrite()将wr_buf 缓冲区中的字符串数据写入到文件中;由于调用了fwrite(),所以此时的读写位置已经发生了改变,不再是文件头部,所以程序中调用了 fseek()将读写位置移动到了文件头,接着调用 fread()从文件头部开始读取刚写入的数据,读取成功之后打印出信息。
ftell()函数
库函数 ftell()可用于获取文件当前的读写位置偏移量,其函数原型如下所示:
#include <stdio.h>
long ftell(FILE *stream);
参数 stream 指向对应的文件,函数调用成功将返回当前读写位置偏移量;调用失败将返回-1,并会设置errno 以指示错误原因。我们可以通过 fseek()和 ftell()来计算出文件的大小,示例代码如下所示:
#include <stdio.h>
#include <stdlib.h>int main(void)
{FILE *fp = NULL;int ret;/* 打开文件 */if (NULL == (fp = fopen("./testApp.c", "r"))) {perror("fopen error");exit(-1);}printf("文件打开成功!\n");/* 将读写位置移动到文件末尾 */if (0 > fseek(fp, 0, SEEK_END)) {perror("fseek error");fclose(fp);exit(-1);}/* 获取当前位置偏移量 */if (0 > (ret = ftell(fp))) {perror("ftell error");fclose(fp);exit(-1);}printf("文件大小: %d 个字节\n", ret);/* 关闭文件 */fclose(fp);exit(0);
}
首先打开当前目录下的 testApp.c 文件,将文件的读写位置移动到文件末尾,然后再获取当前的位置偏移量,也就得到了整个文件的大小。
检查或复位状态
调用 fread()读取数据时,如果返回值小于参数 nmemb 所指定的值,表示发生了错误或者已经到了文件末尾(文件结束 end-of-file),但 fread()无法具体确定是哪一种情况; 在这种情况下,可以通过判断错误标志或 end-of-file 标志来确定具体的情况。
feof()函数
库函数 feof()用于测试参数 stream 所指文件的 end-of-file 标志,如果 end-of-file 标志被设置了,则调用feof()函数将返回一个非零值,如果 end-of-file 标志没有被设置,则返回 0。其函数原型如下所示:
#include <stdio.h>
int feof(FILE *stream);
当文件的读写位置移动到了文件末尾时, end-of-file 标志将会被设置。
if (feof(file)) {/* 到达文件末尾 */
}
else {/* 未到达文件末尾 */
}
ferror()函数
库函数 ferror()用于测试参数 stream 所指文件的错误标志,如果错误标志被设置了,则调用 ferror()函数将返回一个非零值,如果错误标志没有被设置,则返回 0。
其函数原型如下所示:
#include <stdio.h>
int ferror(FILE *stream);
当对文件的 I/O 操作发生错误时,错误标志将会被设置。
if (ferror(file)) {/* 发生错误 */
}
else {/* 未发生错误 */
}
clearerr()函数
库函数 clearerr()用于清除 end-of-file 标志和错误标志,当调用 feof()或 ferror()校验这些标志后,通常需要清除这些标志,避免下次校验时使用到的是上一次设置的值,此时可以手动调用 clearerr()函数清除标志。clearerr()函数原型如下所示:
#include <stdio.h>
void clearerr(FILE *stream);
此函数没有返回值,调用将总是会成功!
对于 end-of-file 标志,除了使用 clearerr()显式清除之外,当调用 fseek()成功时也会清除文件的 end-of-file 标志。
#include <stdio.h>
#include <stdlib.h>int main(void)
{FILE *fp = NULL;char buf[20] = {0};/* 打开文件 */if (NULL == (fp = fopen("./testApp.c", "r"))) {perror("fopen error");exit(-1);}printf("文件打开成功!\n");/* 将读写位置移动到文件末尾 */if (0 > fseek(fp, 0, SEEK_END)) {perror("fseek error");fclose(fp);exit(-1);}/* 读文件 */if (10 > fread(buf, 1, 10, fp)) {if (feof(fp))printf("end-of-file 标志被设置,已到文件末尾!\n");clearerr(fp); //清除标志}/* 关闭文件 */fclose(fp);exit(0);
}
格式化 I/O
在前面编写的测试代码中,会经常使用到库函数 printf()用于输出程序中的打印信息, printf()函数可将格式化数据写入到标准输出,所以通常称为格式化输出。除了 printf()之外,格式化输出还包括: fprintf()、dprintf()、 sprintf()、 snprintf()这 4 个库函数。除了格式化输出之外,自然也有格式化输入,从标准输入中获取格式化数据,格式化输入包括: scanf()、fscanf()、 sscanf()这三个库函数,那么本小节将向大家介绍 C 语言库函数的格式化 I/O。
格式化输出
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 的书写格式以及它们所代表的意义, 稍后介绍 format 参数的格式。每个函数除了固定参数之外,还可携带 0 个或多个可变参数。printf()函数用于将格式化数据写入到标准输出; dprintf()和 fprintf()函数用于将格式化数据写入到指定的文件中,两者不同之处在于, fprintf()使用 FILE 指针指定对应的文件、而 dprintf()则使用文件描述符 fd 指定对应的文件; sprintf()、 snprintf()函数可将格式化的数据存储在用户指定的缓冲区 buf 中。
printf()函数
前面章节内容编写的示例代码中多次使用了该函数,用于将程序中的字符串信息输出显示到终端(也就是标准输出),相信各位读者学习 C 语言时肯定用过该函数,它是一个可变参函数,除了一个固定参数 format外,后面还可携带 0 个或多个参数。函数调用成功返回打印输出的字符数;失败将返回一个负值!
打印“Hello World”:
printf("Hello World!\n");
打印数字 5:
printf("%d\n", 5);
fprintf()函数
fprintf()可将格式化数据写入到由 FILE 指针指定的文件中,譬如将字符串“Hello World”写入到标准错误:
fprintf(stderr, "Hello World!\n");
向标准错误写入数字 5:
fprintf(stderr, "%d\n", 5);
函数调用成功返回写入到文件中的字符数;失败将返回一个负值!
dprintf()函数
dprintf()可将格式化数据写入到由文件描述符 fd 指定的文件中,譬如将字符串“Hello World”写入到标准错误:
dprintf(STDERR_FILENO, "Hello World!\n");
向标准错误写入数字 5:
dprintf(STDERR_FILENO, "%d\n", 5);
函数调用成功返回写入到文件中的字符数;失败将返回一个负值!
sprintf()函数
sprintf()函数将格式化数据存储在由参数 buf 所指定的缓冲区中, 譬如将字符串“Hello World”存放在缓冲区中:
char buf[100];
sprintf(buf, "Hello World!\n");
当然这种用法并没有意义,事实上,我们一般会使用这个函数进行格式化转换,并将转换后的字符串存放在缓冲区中,譬如将数字 100 转换为字符串"100",将转换后得到的字符串存放在 buf 中:
char buf[20] = {0};
sprintf(buf, "%d", 100);
sprintf()函数会在字符串尾端自动加上一个字符串终止字符'\0'。
需要注意的是, sprintf()函数可能会造成由参数 buf 指定的缓冲区溢出,调用者有责任确保该缓冲区足够大,因为缓冲区溢出会造成程序不稳定甚至安全隐患!
函数调用成功返回写入到 buf 中的字节数;失败将返回一个负值!
snprintf()函数
sprintf()函数可能会发生缓冲区溢出的问题,存在安全隐患,为了解决这个问题,引入了 snprintf()函数;在该函数中,使用参数 size 显式的指定缓冲区的大小,如果写入到缓冲区的字节数大于参数 size 指定的大小,超出的部分将会被丢弃!如果缓冲区空间足够大, snprintf()函数就会返回写入到缓冲区的字符数,与sprintf()函数相同,也会在字符串末尾自动添加终止字符'\0'。若发生错误, snprintf()将返回一个负值!
格式控制字符串 format
接下来重点学习以上 5 个函数中的 format 参数应该怎么写,把这个参数称为格式控制字符串,顾名思义,首先它是一个字符串的形式,其次它能够控制后续变参的格式转换。
格式控制字符串由两部分组成:普通字符(非%字符) 和转换说明。普通字符会进行原样输出,每个转换说明都会对应后续的一个参数,通常有几个转换说明就需要提供几个参数(除固定参数之外的参数), 使之一一对应,用于控制对应的参数如何进行转换。如下所示:
printf("转换说明 1 转换说明 2 转换说明 3", arg1, arg2, arg3);
这里只是以 printf()函数举个例子,实际上并不这样用。三个转换说明与参数进行一一对应,按照顺序方式一一对应。
每个转换说明都是以%字符开头,其格式如下所示(使用[ ]括起来的部分是可选的) :
%[flags][width][.precision][length]type
flags: 标志,可包含 0 个或多个标志;
width: 输出最小宽度,表示转换后输出字符串的最小宽度;
precision: 精度,前面有一个点号" . ";
length: 长度修饰符;
type: 转换类型,指定待转换数据的类型。
可以看到,只有%和 type 字段是必须的,其余都是可选的。下面分别对这些字段进行介绍。
㈠、 type 类型
首先说明 type(类型), 因为类型是格式控制字符串的重中之重,是必不可少的组成部分,其它的字段都是可选的, type 用于指定输出数据的类型, type 字段使用一个字符(字母字符)来表示,可取值如下:
字符 | 对应的数据类型 | 含义 | 示例说明 |
d/i | int | 输出有符号十进制表示的整 数, i 是老式写法 | printf("%d\n", 123); 输出:123 |
o | unsigned int | 输出无符号八进制表示的整数 (默认不输出前缀 0,可在 type 字段指定标志#使其输出 前缀 0) | printf("%o\n", 123); 输出:123 |
u | unsigned int | 输出无符号十进制表示的整数 | printf("%u\n", 123); 输出:123 |
x/X | unsigned int | 输出无符号十六进制表示的整 数, x 和 X 的区别在于字母的 大小写问题(x 对应的是 abcdef, X 对应的是ABCDEF); 不输出前缀 0x或 0X, 可在 type 字段指定标 志#使其输出前缀。 | printf("%x\n", 123); 输出:7b printf("%X\n", 123); 输出:7B |
f/F | double | 输出浮点数, 单精度浮点数类 型和双精度浮点数类型都可以 使用, f 和 F 之间的区别就不 去管了,一般表示浮点数使用 f 即可。在没指定精度的情况 下,默认保留小数点后 6 位数 字。 | printf("%f\n", 520.1314); 输出:520.131400 printf("%F\n", 520.1314); 输出:520.131400 |
e/E | double | 输出以科学计数法表示的浮点 数, 使用指数(Exponent)表示 浮点数,此处 e 和 E 的区别在 于以科学计数法表示时,字母 “e”的大小写问题。 | printf("%e\n", 520.1314); 输出:5.201314e+02 printf("%E\n", 520.1314); 输出:5.201314E+02 |
g | double | 根据数值的长度,选择以最短 的方式输出, %f/%e | printf("%g %g\n", 0.000000123, 0.123); 输出:1.23e-07 0.123 |
G | double | 根据数值的长度,选择以最短 的方式输出, %F/%E | printf("%G %G\n", 0.000000123, 0.123); 输出:1.23E-07 0.123 |
s | char * | 字符串,输出字符串中的字符 直至终止字符'\0' | printf("%s\n", "Hello World"); 输出:Hello World |
p | void * | 输出十六进制表示的指针 | printf("%p\n", "Hello World"); 输出:0x400624 |
c | char | 字符型, 可以把输入的数字按 照 ASCII 码相应转换为对应的 字符输出 | printf("%c\n", 64); 输出:A |
㈡、 flags
flags 规定输出样式, %后面可以跟 0 个或多个以下标志:
字符 | 名称 | 作用 |
# | 井号 | type 等于 o 时, 输出字符串增加前缀 0。 type 等于 x 或 X 时,输出字符串增加前缀 0x 或 0X。 type 等于 a、 A、 e、 E、 f、 F、 g 和 G 其中之一时, 在默认情况下,只有输出小数部分时才会输出小数点,如果使用.0 控制不输出小数部分,那么小数点是不会输出的,然而在使用了标志#的情况下,输出结果始终包含小数点; type 等于 g 和 G 时,保留尾部的 0。 |
0 | 数字 0 | 当 type 不等于 c 或 s 时(也就是输出数字时,包括浮点数和整数) , 在输出字符串前面补 0,直到占满指定的最小输出宽度(位数) 。 譬如输出正数 100,指定的最小输出宽度是 5,那么最终就会输出 00100。 如果没有指定标志 0,则默认使用空格占满指定的最小输出宽度。 |
- | 减号 | 输出字符串默认情况下是右对齐的, 不足最小输出宽度时在左边填空格或 0;使用了-标志,则会变成左对齐, 然后在右边填空格,如果同时指定了标志 0 和标志-,则标志-会覆盖标志 0。 |
' ' | 空格 | 输出正数时在前面加一个空格,输出负数时,前面加一个负号-。 |
+ | 加号 | 默认情况下,只有输出负数时,才会输出负号-; 正数前面是没有正号+的;而使用了标志+后,输出的数字前面都带有符号(正数为+、负数为-);如果同时指定了标志+和标志' '(空格),则标志+会覆盖标志空格。 |
㈢、 width
最小的输出宽度,用十进制数来表示输出的最小位数,若实际的输出位数大于指定的输出的最小位数,则以实际的位数进行输出,若实际的位数小于指定输出的最小位数,则可按照指定的 flags 标志补 0 或补空格。
width | 描述 | 示例 |
数字 | 十进制正数 | printf("%06d", 1000); 输出: 001000 |
* | 星号,不显示指出最小输出宽度,而是以星号代替,会在参数列表中指定 | printf("%0*d", 6, 1000); 输出: 001000 |
㈣、 precision 精度
精度字段以点号" . "开头,后跟一个十进制正数,可取值如下:
.precision | 描述 |
数字 | 十进制正数 ①对于整形(type 等于 d、 i、 o、 u、 x 和 X), precision 表示输出的最小的数字个数,不足补前导零,超过不截断。 这里要注意:是数字的个数、与 width 字段是有区别的, width 指的是整个输出字符串的最小位数(最小宽度),并不是数字 的最小宽度,譬如:printf("%8.5d\n", 100); 输出: 00100(前面有 3 个空格); 在这个例子中, width 字段为 8,表示需要整个字符串的输出长度为 8 个字符, .5则表示数字部分位数最少为 5 个,不足 5 个则在前面补 0,所以 100 需要在前面补两个 0 才能满足这个要求;满足这个要求之后,接着需要使整个字符串长度为 8 个字符,那么只需要在前面补 3 个空格即可(这里是左对齐的情况) !会忽略 flags 字段的标志 0,意味着在这种情况下,指定标志 0 和不指定标志 0 都是一样的效果。 ②对于浮点型(type 等于 a、 A、 e、 E、 f、 F), precision 表示小数点后数字的个数, 也就是浮点数精度; 默认为六位,不足补后置 0,超过则截断。 譬如:printf("%.8f\n", 520.1314); 输出:520.13140000 ③type 等于 g、 G 时,表示最大有效位数; ④对于字符串(type 等于 s), precision 表示输出字符串中最大可输出的字符数,不足正常输出,超过则截断。 譬如: printf("%.5s\n", "hello world"); 输出:hello;超过 5 个字符的部分被丢弃! |
* | 以星号代替十进制数字, 类似于 width 字段中的*, 表示在参数列表中指定;譬如: printf("%.*s\n", 5, "hello world"); 输出:hello |
㈤、 length 长度修饰符
长度修饰符指明待转换数据的长度, 因为 type 字段指定的的类型只有 int、 unsigned int 以及 double 等几种数据类型, 但是 C 语言内置的数据类型不止这几种, 譬如有 16bit 的 short、 unsigned short, 8bit 的 char、unsigned char, 也有 64bit 的 long long 等, 为了能够区别不同长度的数据类型,于是乎, 长度修饰符(length)应运而生,成为转换说明的一部分。length 长度修饰符也是使用字符(字母字符)来表示, 结合 type 字段以确定不同长度的数据类型,如下所示:
譬如:
printf("%hd\n", 12345); //将数据以 short int 类型进行转换
printf("%ld\n", 12345); //将数据以 long int 类型进行转换
printf("%lld\n", 12345); //将数据以 long long int 类型进行转换
关于格式控制字符串 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, ...);
可以看到,这 3 个格式化输入函数也是可变参函数,它们都有一个共同的参数 format,同样也称为格式控制字符串, 用于指定输入数据如何进行格式转换,与格式化输出函数中的 format 参数格式相似,但也有所不同。每个函数除了固定参数之外,还可携带 0 个或多个可变参数。scanf()函数可将用户输入(标准输入)的数据进行格式化转换; fscanf()函数从 FILE 指针指定文件中读取数据,并将数据进行格式化转换; sscanf()函数从参数 str 所指向的字符串中读取数据,并将数据进行格式化转换。
scanf()函数
相对于 printf 函数, scanf 函数就简单得多。 scanf()函数的功能与 printf()函数正好相反,执行格式化输入功能; 即 scanf()函数将用户输入(标准输入)的数据进行格式化转换并进行存储,它从格式化控制字符串format 参数的最左端开始,每遇到一个转换说明便将其与下一个输入数据进行“匹配”,如果二者匹配则继续,否则结束对后面输入的处理。而每遇到一个转换说明,便按该转换说明所描述的格式对其后的输入数据进行转换,然后将转换得到的数据存储于与其对应的输入地址中。以此类推,直到对整个输入数据的处理结
束为止。从函数原型可以看出, scanf()函数也是一个“可变参数函数” , 除第一个参数 format 之外, scanf()函数还可以有若干个输入地址(指针) , 这些指针指向对应的缓冲区,用于存储格式化转换后的数据; 且对于每一个输入地址,在格式控制字符串 format 参数中都必须有一个转换说明与之一一对应。即从 format 字符串的左端第 1 个转换说明对应第 1 个输入地址,第 2 个格式说明符对应第 2 个输入地址,第 3 个格式说明符对应第 3 个输入地址,以此类推。 譬如:
int a, b, c;
scanf("%d %d %d", &a, &b, &c);
当程序中调用 scanf()的时候,终端会被阻塞,等待用户输入数据,此时我们可以通过键盘输入一些字符,譬如数字、字母或者其它字符, 输入完成按回车即可! 接着来 scanf()函数就会对用户输入的数据进行格式转换处理。函数调用成功后,将返回成功匹配和分配的输入项的数量;如果较早匹配失败,则该数目可能小于所提供的数目,甚至为零。 发生错误则返回负值。
fscanf()函数
fscanf()函数从指定文件中读取数据,作为格式转换的输入数据,文件通过 FILE 指针指定,所以它有两个固定参数, FILE 指针和格式控制字符串 format。譬如从标准输入文件中读取数据进行格式化转换:
int a, b, c;
fscanf(stdin, "%d %d %d", &a, &b, &c);
此时它的作用与 scanf()就是相同的,因为标准输入文件的数据就是用户输入的数据,譬如通过键盘输入的数据。函数调用成功后,将返回成功匹配和分配的输入项的数量;如果较早匹配失败,则该数目可能小于所提供的数目,甚至为零。 发生错误则返回负值。
sscanf()函数
sscanf()将从参数 str 所指向的字符串缓冲区中读取数据,作为格式转换的输入数据,所以它也有两个固定参数,字符串 str 和格式控制字符串 format,譬如:
char *str = "5454 hello";
char buf[10];
int a;
sscanf(str, "%d %s", &a, buf);
函数调用成功后,将返回成功匹配和分配的输入项的数量;如果较早匹配失败,则该数目可能小于所提供的数目,甚至为零。 发生错误则返回负值。
格式控制字符串 format
本小节的重点依然是这个 format 参数的格式,与格式化输出函数中的 format 参数格式、写法上比较相似,但也有一些区别。 format 字符串包含一个或多个转换说明,每一个转换说明都是以百分号"%"或者"%n$"开头(n 是一个十进制数字),关于"%n$"这种开头的转换说明就不介绍了,实际上用的不多。以%百分号开头的转换说明一般格式如下:
%[*][width][length]type
%[m][width][length]type
%后面可选择性添加星号*或字母 m,如果添加了星号*,格式化输入函数会按照转换说明的指示读取输入,但是丢弃输入,意味着不需要对转换后的结果进行存储,所以也就不需要提供相应的指针参数。如果添加了 m,它只能与%s、 %c 以及%[一起使用,调用者无需分配相应的缓冲区来保存格式转换后的数据,原因在于添加了 m,这些格式化输入函数内部会自动分配足够大小的缓冲区,并将缓冲区的地址值通过与该格式转换相对应的指针参数返回出来,该指针参数应该是指向 char *变量的指针。随后,当不再需要此缓冲区时,调用者应调用 free()函数来释放此缓冲区。
char *buf;
scanf("%ms", &buf);
......
free(buf);
介绍了星号*和字母 m 之后,再来看看转换说明的格式,中括号[ ]表示的部分是可选的,所以可知,与格式化输出函数中的 format 参数一样,只有 type 字段是必须的。
⚫ width: 最大字符宽度;
⚫ length: 长度修饰符,与格式化输出函数的 format 参数中的 length 字段意义相同。
⚫ type: 指定输入数据的类型。
我们先来看看 type 字段。
㈠type(类型)
此 type 字段与格式化输出函数中的 format 参数的 type 字段是同样的意义,用于指定输入数据的类型,如下所示:
字符 | 对应的数据类型 | 含义 | 示例 |
d | int | 匹配一个有符号十进制整数 | int a; scanf("%d", &a); 用户输入: 100 |
i | int | 匹配一个有符号整数,既可以是十进制表示、 也可以是八进制或十六进制方式表示,譬如整 数以 0x 或 0X 开头,则认为是 16 进制,以 0 开 头则认为是八进制,否则认为是十进制。 | int a; scanf("%i", &a); 用户输入: 0x100 |
o | unsigned int | 匹配一个无符号八进制整数 | unsigned int a; scanf("%o", &a); 用户输入: 0100 |
u | unsigned int | 匹配一个无符号十进制整数 | unsigned int a; scanf("%u", &a); 用户输入: 100 |
x | unsigned int | 匹配一个无符号十六进制整数,数字以 0x 开头 | unsigned int a; scanf("%x", &a); 用户输入: 0x100 |
X | unsigned int | 匹配一个无符号十六进制整数,数字以 0X 开头 | unsigned int a; scanf("%x", &a); 用户输入: 0X100 |
f | float | 匹配一个带符号的浮点数 | float a; scanf("%f", &a); 用户输入: 10.123 |
e | float | 等效于 f | |
E | float | 等效于 f | |
g | float | 等效于 f | |
a | float | 等效于 f | |
s | char * | 匹配字符串,不匹配空白字符(包括空格、制 表符、换行符) , 空白字符作为字符串的分隔 符,所以就是匹配一系列非空白字符。所以存 放字符串的缓冲区必须足够大,会自动添加终 止字符"\0" | char buf[100]; scanf("%s", buf); 用户输入: HelloWorld |
c | char | 匹配一个字符 | char c; scanf("%c", &c); 用户输入: A |
p | void * | 匹配一个指针值 | void *a; scanf("%p", &a); 用户输入: 123456 |
[ | char * | 匹配一组字符序列集合, 以字符串形式存储。 譬如%[a-z]匹配 a 到 z 之间的所有字符(包括起 始字符 a 和结束字符 z),譬如%[0-9]则表示匹 配数字 0 到 9 之间所有数字字符;减号-是一个 连字符,放在两个字符(一个起始字符和一个 结束字符)之间,本身并不参与匹配,如果需 要匹配减号-,则可将其放在中括号[ ]旁边,譬 如%[-a-z],表示匹配减号、以及数字 0 到 9 这 些字符;如果要排除匹配这些字符,可以使用 排除符^,将其放在最前面,譬如%[^a-z-], 表 示匹配除 a 到 z 这些字符以及-之外的字符。 | char buf[100]; scanf("%[a-z0-9]", buf); 匹配字母 a 到 z 以及数 字 0 到 9 这些字符。 用户输入: 123abc |
㈡、 width 最大字符宽度
是一个十进制表示的整数,用于指定最大字符宽度, 当达到此最大值或发现不匹配的字符时(以先发生者为准),字符的读取将停止。 大多数 type 类型会丢弃初始的空白字符,并且这些丢弃的字符不会计入最大字符宽度。 对于字符串转换来说, scanf()会在字符串末尾自动添加终止符"\0", 最大字符宽度中不包括此终止符。
譬如调用 scanf()函数如下:
scanf("%4s", buf); //匹配字符串,字符串长度不超过 4 个字符
用户输入 abcdefg,按回车,那么只能将 adcd 作为一个字符串存储在 buf 数组中。
㈢length 长度修饰符
与格式化输出函数的格式控制字符串 format 中的 length 字段意义相同,用于对 type 字段进行修饰,扩展识别更多不同长度的数据类型。如下所示:
type | |||||
length | d、 i | u、 o、 x、 X | e、 f、 g | c | s |
(none) | int | unsigned int | float | char | char * |
h | short int | unsigned short int | |||
hh | signed char | unsigned char | |||
j | intmax_t | uintmax_t | |||
l | long int | unsigned long int | double | wchar_t | wchar_t * |
L | long long int | unsigned long long int | long double |
scanf("%hd", var); //匹配 short int 类型数据
scanf("%hhd", var); //匹配 signed char 类型数据
scanf("%ld", var); //匹配 long int 类型数据
scanf("%f", var); //匹配 float 类型数据
scanf("%lf", var); //匹配 double 类型数据
scanf("%Lf", var); //匹配 long double 类型数据
#include <stdio.h>
#include <stdlib.h>
#include <string.h>int main(void)
{int a;float b;char *str;printf("请输入一个整数:\n");scanf("%d", &a);printf("你输入的整数为: %d\n", a);printf("请输入一个浮点数:\n");scanf("%f", &b);printf("你输入的浮点数为: %f\n", b);printf("请输入一个字符串:\n");scanf("%ms", &str);printf("你输入的字符串为: %s\n", str);free(str); //释放字符串占用的内存空间exit(0);
}
当程序中调用 scanf()之后,终端就会被阻塞、等待用户输入数据,当我们输入完成之后,按回车即可!
第三个 scanf()函数调用中,使用%m,所以我们不需要提供存放字符串的缓冲区, scanf()函数内部会分配缓冲区,并将缓冲区地址存放在 str 这个我们给定的 char 指针变量中。使用完之后记得调用 free()释放内存即可。
I/O 缓冲
出于速度和效率的考虑,系统 I/O 调用(即文件 I/O, open、 read、 write 等)和标准 C 语言库 I/O 函数(即标准 I/O 函数)在操作磁盘文件时会对数据进行缓冲,本小节将讨论文件 I/O 和标准 I/O 这两种 I/O 方式的数据缓冲问题,并讨论其对应用程序性能的影响。除此之外,本小节还讨论了屏蔽或影响缓冲的一些技术手段,以及直接 I/O 技术—绕过内核缓冲直接访问磁盘硬件。
文件 I/O 的内核缓冲
read()和 write()系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区(kernel buffer cache)之间复制数据。譬如调用 write()函数将 5 个字节数据从用户空间内存拷贝到内核空间的缓冲区中:
write(fd, "Hello", 5); //写入 5 个字节数据
调用 write()后仅仅只是将这 5 个字节数据拷贝到了内核空间的缓冲区中,拷贝完成之后函数就返回了,在后面的某个时刻,内核会将其缓冲区中的数据写入(刷新)到磁盘设备中,所以由此可知,系统调用 write()与磁盘操作并不是同步的, write()函数并不会等待数据真正写入到磁盘之后再返回。如果在此期间, 其它进程调用 read()函数读取该文件的这几个字节数据,那么内核将自动从缓冲区中读取这几个字节数据返回给应用程序。
与此同理,对于读文件而言亦是如此,内核会从磁盘设备中读取文件的数据并存储到内核的缓冲区中,当调用 read()函数读取数据时, read()调用将从内核缓冲区中读取数据,直至把缓冲区中的数据读完,这时,内核会将文件的下一段内容读入到内核缓冲区中进行缓存。我们把这个内核缓冲区就称为文件 I/O 的内核缓冲。这样的设计,目的是为了提高文件 I/O 的速度和效率,使得系统调用 read()、 write()的操作更为快速,不需要等待磁盘操作(将数据写入到磁盘或从磁盘读取出数据),磁盘操作通常是比较缓慢的。同时这一设计也更为高效,减少了内核操作磁盘的次数,譬如线程1 调用 write()向文件写入数据"abcd",线程 2 也调用 write()向文件写入数据"1234",这样的话,数据"abcd"和"1234"都被缓存在了内核的缓冲区中,在稍后内核会将它们一起写入到磁盘中,只发起一次磁盘操作请求;如果没有内核缓冲区,那么每一次调用 write(),内核就会执行一次磁盘操作。前面提到,当调用 write()之后,内核稍后会将数据写入到磁盘设备中,具体是什么时间点写入到磁盘,这个其实是不确定的, 由内核根据相应的存储算法自动判断。
通过前面的介绍可知,文件 I/O 的内核缓冲区自然是越大越好, Linux 内核本身对内核缓冲区的大小没有固定上限。内核会分配尽可能多的内核来作为文件 I/O 的内核缓冲区,但受限于物理内存的总量,如果系统可用的物理内存越多,那自然对应的内核缓冲区也就越大,操作越大的文件也要依赖于更大空间的内核缓冲。
刷新文件 I/O 的内核缓冲区
强制将文件 I/O 内核缓冲区中缓存的数据写入(刷新)到磁盘设备中,对于某些应用程序来说,可能是很有必要的, 例如,应用程序在进行某操作之前, 必须要确保前面步骤调用 write()写入到文件的数据已经真正写入到了磁盘中, 诸如一些数据库的日志进程。联系到一个实际的使用场景,当我们在 Ubuntu 系统下拷贝文件到 U 盘时,文件拷贝完成之后,通常在拔掉 U 盘之前,需要执行 sync 命令进行同步操作,这个同步操作其实就是将文件 I/O 内核缓冲区中的数据更新到 U 盘硬件设备,所以如果在没有执行 sync 命令时拔掉 U 盘,很可能就会导致拷贝到 U 盘中的文件遭到破坏!
控制文件 I/O 内核缓冲的系统调用
Linux 中提供了一些系统调用可用于控制文件 I/O 内核缓冲,包括系统调用 sync()、 syncfs()、 fsync()以及 fdatasync()。
㈠、 fsync()函数
系统调用 fsync()将参数 fd 所指文件的内容数据和元数据写入磁盘, 只有在对磁盘设备的写入操作完成之后, fsync()函数才会返回,其函数原型如下所示:
#include <unistd.h>
int fsync(int fd);
参数 fd 表示文件描述符,函数调用成功将返回 0,失败返回-1 并设置 errno 以指示错误原因。
前面提到了元数据这个概念,元数据并不是文件内容本身的数据,而是一些用于记录文件属性相关的数据信息,譬如文件大小、时间戳、权限等等信息,这里统称为文件的元数据,这些信息也是存储在磁盘设备中的。
㈡、 fdatasync()函数
系统调用 fdatasync()与 fsync()类似,不同之处在于 fdatasync()仅将参数 fd 所指文件的内容数据写入磁盘,并不包括文件的元数据;同样,只有在对磁盘设备的写入操作完成之后, fdatasync()函数才会返回,其函数原型如下所示:
#include <unistd.h>
int fdatasync(int fd);
㈢、 sync()函数
系统调用 sync()会将所有文件 I/O 内核缓冲区中的文件内容数据和元数据全部更新到磁盘设备中,该函数没有参数、也无返回值,意味着它不是对某一个指定的文件进行数据更新,而是刷新所有文件 I/O 内核缓冲区。其函数原型如下所示:
#include <unistd.h>
void sync(void);
在 Linux实现中,调用 sync()函数仅在所有数据已经写入到磁盘设备之后才会返回;然后在其它系统中,sync()实现只是简单调度一下 I/O 传递,在动作未完成之前即可返回。
控制文件 I/O 内核缓冲的标志
调用 open()函数时指定一些标志也可以影响到文件 I/O 内核缓冲,譬如 O_DSYNC 标志和 O_SYNC 标志,接下来向大家简单地介绍下。
㈠、 O_DSYNC 标志
在调用 open()函数时,指定 O_DSYNC 标志,其效果类似于在每个 write()调用之后调用 fdatasync()函数进行数据同步。譬如:
fd = open(filepath, O_WRONLY | O_DSYNC);
㈡、 O_SYNC 标志
在调用 open()函数时,指定 O_SYNC 标志,使得每个 write()调用都会自动将文件内容数据和元数据刷新到磁盘设备中,其效果类似于在每个 write()调用之后调用 fsync()函数进行数据同步,譬如:
fd = open(filepath, O_WRONLY | O_SYNC);
在程序中频繁调用 fsync()、 fdatasync()、 sync()(或者调用 open 时指定 O_DSYNC 或 O_SYNC 标志)对性能的影响极大,大部分的应用程序是没有这种需求的,所以在大部分应用程序当中基本不会使用到。
直接 I/O:绕过内核缓冲
从 Linux 内核 2.4 版本开始, Linux 允许应用程序在执行文件 I/O 操作时绕过内核缓冲区,从用户空间直接将数据传递到文件或磁盘设备,把这种操作也称为直接 I/O(direct I/O)在有些情况下,这种操作通常是很有必要的,例如,某应用程序的作用是测试磁盘设备的读写速率, 那么在这种应用需要下,我们就需要保证 read/write 操作是直接访问磁盘设备,而不经过内核缓冲,如果不能得到这样的保证,必然会导致测试结果出现比较大的误差。然后,对于大多数应用程序而言,使用直接 I/O 可能会大大降低性能,这是因为为了提高 I/O 性能,内核针对文件 I/O 内核缓冲区做了不少的优化,譬如包括按顺序预读取、在成簇磁盘块上执行 I/O、允许访问同一文件的多个进程共享高速缓存的缓冲区。如果应用程序使用直接 I/O 方式, 将无法享受到这些优化措施所带来的性能上的提升,直接 I/O 只在一些特定的需求场合,譬如磁盘速率测试工具、数据库系统等。
我们可针对某一文件或块设备执行直接 I/O,要做到这一点,需要在调用 open()函数打开文件时,指定O_DIRECT 标志, 该标志至 Linux 内核 2.4.10 版本开始生效, 譬如:
fd = open(filepath, O_WRONLY | O_DIRECT);
直接 I/O 的对齐限制
因为直接 I/O 涉及到对磁盘设备的直接访问,所以在执行直接 I/O 时,必须要遵守以下三个对齐限制要求:
⚫ 应用程序中用于存放数据的缓冲区,其内存起始地址必须以块大小的整数倍进行对齐;
⚫ 写文件时,文件的位置偏移量必须是块大小的整数倍;
⚫ 写入到文件的数据大小必须是块大小的整数倍。
如果不满足以上任何一个要求,调用 write()均以错误返回 Invalid argument。以上所说的块大小指的是磁盘设备的物理块大小(block size) ,常见的块大小包括 512 字节、 1024 字节、 2048 以及 4096 字节,那我们如何确定磁盘分区的块大小呢?可以使用 tune2fs 命令进行查看,如下所示:
tune2fs -l /dev/sda1 | grep "Block size"
-l 后面指定了需要查看的磁盘分区,可以使用 df -h 命令查看 Ubuntu 系统的根文件系统所挂载的磁盘分区:
通过上图可知, Ubuntu 系统的根文件系统挂载在/dev/sda1 磁盘分区下,接着使用 tune2fs 命令查看该分区的块大小:
从上图可知/dev/sda1 磁盘分区的块大小为 4096 个字节。
直接 I/O 测试与普通 I/O 对比测试
接下来编写一个使用直接 I/O 方式写文件的测试程序和一个使用普通 I/O 方式写文件的测试程序,进行对比。
首先我们需要在程序开头处定义一个宏定义_GNU_SOURCE,原因在于后面 open()函数需要指定 O_DIRECT 标志,这个宏需要我们在程序中定义了O_DIRECT 宏之后才能使用,否则编译程序就会报错提示: O_DIRECT 未定义。
Tips: _GNU_SOURCE 宏可用于开启/禁用 Linux 系统调用和 glibc 库函数的一些功能、特性,要打开这些特性,需要在应用程序中定义该宏,定义该宏之后意味着用户应用程序打开了所有的特性;默认情况下,_GNU_SOURCE 宏并没有被定义,所以当使用到它控制的一些特性时,应用程序编译将会报错!定义该宏的方式有两种:
⚫ 直接在源文件中定义: #define _GNU_SOURCE
⚫ gcc 编译时使用-D 选项定义_GNU_SOURCE 宏:
gcc -D_GNU_SOURCE -o testApp testApp.c
gcc 的-D 选项可用于定义一个宏,并且该宏定义在整个源码工程中都是生效的,是一个全局宏定义。使用以上哪种方式都可以。
/** 使用宏定义 O_DIRECT 需要在程序中定义宏_GNU_SOURCE
** 不然提示 O_DIRECT 找不到 **/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>/** 定义一个用于存放数据的 buf,起始地址以 4096 字节进行对其 **/
static char buf[8192] __attribute((aligned (4096)));int main(void)
{int fd;int count;/* 打开文件 */fd = open("./test_file", O_WRONLY | O_CREAT | O_TRUNC | O_DIRECT, 0664);if (0 > fd) {perror("open error");exit(-1);}/* 写文件 */count = 10000;while(count--) {if (4096 != write(fd, buf, 4096)) {perror("write error");exit(-1);}}/* 关闭文件退出程序 */close(fd);exit(0);
}
前面提到过,使用直接 I/O 方式需要满足 3 个对齐要求,程序中定义了一个 static 静态数组 buf, 将其作为数据存放的缓冲区,在变量定义后加了__attribute((aligned (4096)))修饰,使其起始地址以 4096 字节进行对其。程序中调用 open()函数是指定了 O_DIRECT 标志,使用直接 I/O,最后通过 while 循环,将数据写入文件中, 循环 10000 次,每次写入 4096 个字节数据,也就是总共写入 4096*10000 个字节(约等于 40MB)。首次调用 write()时其文件读写位置偏移量为 0,之后均以 4096 字节进行递增,所以满足直接 I/O 方式的位置偏移量必须是块大小的整数倍这个要求;每次写入大小均是 4096 字节,所以满足了数据大小必须是块大小的整数倍这个要求。接下来编译测试:
通过 time 命令测试可知,每次执行程序需要花费 2.7 秒左右的时间,使用直接 I/O 方式向文件写入约40MB 数据大小。
对示例代码进行修改,使其变成普通 I/O 方式,其它功能相同,最终修改后的示例代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>static char buf[8192];int main(void)
{int fd;int count;/* 打开文件 */fd = open("./test_file", O_WRONLY | O_CREAT | O_TRUNC, 0664);if (0 > fd) {perror("open error");exit(-1);}/* 写文件 */count = 10000;while(count--) {//循环 10000 次,每次写入 4096 个字节数据if (4096 != write(fd, buf, 4096)) {perror("write error");exit(-1);}}/* 关闭文件退出程序 */close(fd);exit(0);
}
使用 time 命令得到的程序运行时间大约是 0.13~0.14 秒左右,相比直接 I/O 方式的 2.7 秒,时间上提升了 20 倍左右(测试大小不同、每次写入的大小不同,均会导致时间上的差别),原因在于直接 I/O 方式每次 write()调用均是直接对磁盘发起了写操作,而普通方式只是将用户空间下的数据拷贝到了文件 I/O 内核缓冲区中,并没直接操作硬件,所以消耗的时间短,硬件操作占用的时间远比内存复制占用的时间大得多。直接 I/O 方式效率、性能比较低,绝大部分应用程序不会使用直接 I/O 方式对文件进行 I/O 操作,通常只在一些特殊的应用场合下才可能会使用, 那我们可以使用直接 I/O 方式来测试磁盘设备的读写速率,这种测试方式相比普通 I/O 方式就会更加准确。
stdio 缓冲
介绍完文件 I/O 的内核缓冲后,接下来我们聊一聊标准 I/O 的 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 的 stdio 缓冲。
前面提到了文件 I/O 内核缓冲,这是由内核维护的缓冲区,而标准 I/O 所维护的 stdio 缓冲是用户空间的缓冲区,当应用程序中通过标准 I/O 操作磁盘文件时,为了减少调用系统调用的次数,标准 I/O 函数会将用户写入或读取文件的数据缓存在 stdio 缓冲区,然后再一次性将 stdio 缓冲区中缓存的数据通过调用系统调用 I/O(文件 I/O)写入到文件 I/O 内核缓冲区或者拷贝到应用程序的 buf 中。通过这样的优化操作,当操作磁盘文件时,在用户空间缓存大块数据以减少调用系统调用的次数,使得效率、性能得到优化。 使用标准 I/O 可以使编程者免于自行处理对数据的缓冲,无论是调用 write()写入数据、还是调用 read()读取数据。对 stdio 缓冲进行设置C 语言提供了一些库函数可用于对标准 I/O 的 stdio 缓冲区进行相关的一些设置, 包括 setbuf()、setbuffer()以及 setvbuf()。
㈠、 setvbuf()函数
调用 setvbuf()库函数可以对文件的 stdio 缓冲区进行设置,譬如缓冲区的缓冲模式、 缓冲区的大小、起始地址等。其函数原型如下所示:
#include <stdio.h>
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
使用该函数需要包含头文件<stdio.h>。
函数参数和返回值含义如下:
stream: FILE 指针,用于指定对应的文件, 每一个文件都可以设置它对应的 stdio 缓冲区。
buf: 如果参数 buf 不为 NULL,那么 buf 指向 size 大小的内存区域将作为该文件的 stdio 缓冲区,因为stdio 库会使用 buf 指向的缓冲区,所以应该以动态(分配在堆内存,譬如 malloc)或静态的方式在堆中为该缓冲区分配一块空间,而不是分配在栈上的函数内的自动变量(局部变量)。如果 buf 等于 NULL,那么 stdio 库会自动分配一块空间作为该文件的 stdio 缓冲区(除非参数 mode 配置为非缓冲模式) 。
mode: 参数 mode 用于指定缓冲区的缓冲类型,可取值如下:
⚫ _IONBF: 不对 I/O 进行缓冲(无缓冲) 。意味着每个标准 I/O 函数将立即调用 write()或者 read(),并且忽略 buf 和 size 参数,可以分别指定两个参数为 NULL 和 0。标准错误 stderr 默认属于这一种类型,从而保证错误信息能够立即输出。
⚫ _IOLBF: 采用行缓冲 I/O。 在这种情况下,当在输入或输出中遇到换行符"\n"时,标准 I/O 才会执行文件 I/O 操作。对于输出流,在输出一个换行符前将数据缓存(除非缓冲区已经被填满), 当输出换行符时,再将这一行数据通过文件 I/O write()函数刷入到内核缓冲区中;对于输入流, 每次读取一行数据。 对于终端设备默认采用的就是行缓冲式,譬如标准输入和标准输出。
⚫ _IOFBF: 采用全缓冲 I/O。 在这种情况下,在填满 stdio 缓冲区后才进行文件 I/O 操作(read、 write)。对于输出流,当 fwrite 写入文件的数据填满缓冲区时,才调用 write()将 stdio 缓冲区中的数据刷入内核缓冲区;对于输入流, 每次读取 stdio 缓冲区大小个字节数据。 默认普通磁盘上的常规文件默认常用这种缓冲模式。
size: 指定缓冲区的大小。
返回值: 成功返回 0,失败将返回一个非 0 值,并且会设置 errno 来指示错误原因。
需要注意的是,当 stdio 缓冲区中的数据被刷入到内核缓冲区或被读取之后,这些数据就不会存在于缓冲区中了,数据被刷入了内核缓冲区或被读走了。
㈡、 setbuf()函数
setbuf()函数构建与 setvbuf()之上,执行类似的任务,其函数原型如下所示:
#include <stdio.h>
void setbuf(FILE *stream, char *buf);
setbuf()调用除了不返回函数结果(void)外,就相当于:
setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ);
要么将 buf 设置为 NULL 以表示无缓冲,要么指向由调用者分配的 BUFSIZ 个字节大小的缓冲区(BUFSIZ 定义于头文件<stdio.h>中,该值通常为 8192)。
㈢、 setbuffer()函数
setbuffer()函数类似于 setbuf(),但允许调用者指定 buf 缓冲区的大小,其函数原型如下所示:
#include <stdio.h>
void setbuffer(FILE *stream, char *buf, size_t size);
setbuffer()调用除了不返回函数结果(void)外,就相当于:
setvbuf(stream, buf, buf ? _IOFBF : _IONBF, size);
标准输出 printf()的行缓冲模式测试
我们先看看下面这个简单地示例代码,调用了 printf()函数,区别在于第二个 printf()没有输出换行符。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int main(void)
{printf("Hello World!\n");printf("Hello World!");for ( ; ; )sleep(1);
}
printf()函数是标准 I/O 库函数,向终端设备(标准输出)输出打印信息,编译测试:
运行之后可以发现只有第一个 printf()打印的信息显示出来了,第二个并没有显示出来,这是为什么呢?这就是 stdio 缓冲的问题,前面提到了标准输出默认采用的是行缓冲模式, printf()输出的字符串写入到了标准输出的 stdio 缓冲区中,只有输出换行符时(不考虑缓冲区填满的情况) 才会将这一行数据刷入到内核缓冲区,也就是写入标准输出文件(终端设备),因为第一个 printf()包含了换行符,所以已经刷入了内核缓冲区,而第二个 printf 并没有包含换行符,所以第二个 printf 输出的"Hello World!"还缓存在 stdio 缓冲区中,
需要等待一个换行符才可输出到终端。联系到格式化输入 scanf()函数,程序中调用 scanf()函数进行阻塞,用户通过键盘输入数据,只有在按下回车键(换行符键)时程序才会接着往下执行,因为标准输入默认也是采用了行缓冲模式。譬如对示例代码进行修改,使标准输出变成无缓冲模式,修改后代码如下所示:
#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!\n");printf("Hello World!");for ( ; ; )sleep(1);
}
可以发现该程序能够成功输出两个“Hello World!”,并且白色的光标在第二个“Hello World!”后面,意味着输出没有换行,与程序中第二个 printf 没有加换行符的效果是一致。
所以通过以上两个示例代码对比可知,标准输出默认是行缓冲模式, 只有输出了换行符时,才会将换行符这一行字符进行输出显示(也就是刷入到内核缓冲区),在没有输出换行符之前,会将数据缓存在 stdio缓冲区中。
刷新 stdio 缓冲区
无论我们采取何种缓冲模式,在任何时候都可以使用库函数 fflush()来强制刷新(将输出到 stdio 缓冲区中的数据写入到内核缓冲区,通过 write()函数) stdio 缓冲区, 该函数会刷新指定文件的 stdio 输出缓冲区,此函数原型如下所示:
#include <stdio.h>
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!\n");printf("Hello World!");fflush(stdout); //刷新标准输出 stdio 缓冲区for ( ; ; )sleep(1);
}
可以看到,打印了两次“Hello World!”, 这就是 fflush()的作用了强制刷新 stdio 缓冲区。除了使用库函数 fflush()之外,还有其它方法会自动刷新 stdio 缓冲区吗?是的,使用库函数 fflush()是一种强制刷新的手段,在一些其它的情况下,也会自动刷新 stdio 缓冲区,譬如当文件关闭时、程序退出时,接下来我们进行演示。
㈠、关闭文件时刷新 stdio 缓冲区
同样还是直接对示例代码进行修改,在调用第二个 printf 函数后关闭标准输出,如下所示:
#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 缓冲区,这样的话就会影响到测试结果。同样对示例代码进行修改,去掉 for 死循环,让程序结束,修改完之后如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int main(void)
{printf("Hello World!\n");printf("Hello World!");
}
从结果可知,当程序退出时,确实会自动刷新 stdio 缓冲区。但是,与程序退出方式有关,如果使用 exit()、return 或像上述示例代码一样不显式调用相关函数或执行 return 语句来结束程序,这些情况下程序终止时会自动刷新 stdio 缓冲区; 如果使用_exit 或_Exit()终止程序则不会刷新,这里各位读者可以自行测试、验证。
关于刷新 stdio 缓冲区相关内容,最后进行一个总结:
⚫ 调用 fflush()库函数可强制刷新指定文件的 stdio 缓冲区;
⚫ 调用 fclose()关闭文件时会自动刷新文件的 stdio 缓冲区;
⚫ 程序退出时会自动刷新 stdio 缓冲区(注意区分不同的情况) 。
从图中自上而下,首先应用程序调用标准 I/O 库函数将用户数据写入到 stdio 缓冲区中, stdio 缓冲区是由 stdio 库所维护的用户空间缓冲区。 针对不同的缓冲模式,当满足条件时, stdio 库会调用文件 I/O(系统调用 I/O)将 stdio 缓冲区中缓存的数据写入到内核缓冲区中,内核缓冲区位于内核空间。最终由内核向磁盘设备发起读写操作,将内核缓冲区中的数据写入到磁盘(或者从磁盘设备读取数据到内核缓冲区)。
应用程序调用库函数可以对 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()则进行着相反的操作,其函数原型如下所示:
#include <stdio.h>
int fileno(FILE *stream);
FILE *fdopen(int fd, const char *mode);
首先使用这两个函数需要包含头文件<stdio.h>。
对于 fileno()函数来说, 根据传入的 FILE 指针得到整数文件描述符,通过返回值得到文件描述符,如果转换错误将返回-1,并且会设置 errno 来指示错误原因。 得到文件描述符之后,便可以使用诸如 read()、 write()、lseek()、 fcntl()等文件 I/O 方式操作文件。fdopen()函数与 fileno()功能相反,给定一个文件描述符,得到该文件对应的 FILE 指针,之后便可以使用诸如 fread()、 fwrite()等标准 I/O 方式操作文件了。参数 mode 与 fopen()函数中的 mode 参数含义相同, 若该参数与文件描述符 fd 的访问模式不一致,则会导致调用 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"字符串信息, 产生这个问题的原因很简单,大家自己去思考下!