OpenEdv-开源电子网

 找回密码
 立即注册
正点原子全套STM32/Linux/FPGA开发资料,上千讲STM32视频教程免费下载...
查看: 4113|回复: 1

《I.MX6U嵌入式Linux C应用编程指南 V1.1》第四章 标准I/O库

[复制链接]

1118

主题

1129

帖子

2

精华

超级版主

Rank: 8Rank: 8

积分
4672
金钱
4672
注册时间
2019-5-8
在线时间
1224 小时
发表于 2021-8-11 18:23:04 | 显示全部楼层 |阅读模式
本帖最后由 正点原子运营 于 2021-8-11 18:31 编辑

1)实验平台:正点原子阿尔法Linux开发板
2)  章节摘自【正点原子】《I.MX6U嵌入式Linux C应用编程指南 V1.1》

3)购买链接:https://detail.tmall.com/item.htm?id=609033604451
4)全套实验源码+手册+视频下载地址:http://www.openedv.com/docs/boards/arm-linux/zdyz-i.mx6ull.html
5)正点原子官方B站:https://space.bilibili.com/394620890
6)正点原子阿尔法Linux交流群:1027879335 QQ群.png

原子哥.jpg

微信公众号.png



第四章 标准I/O库


本章介绍标准I/O库,不仅是Linux,很多其它的操作系统都实现了标准I/O库。标准I/O虽然是对文件I/O进行了封装,但事实上并不仅仅只是如此,标准I/O会处理很多细节,譬如分配stdio缓冲区、以优化的块长度执行I/O等,这些处理使用户不必担心如何选择使用正确的块长度。
本章将会讨论如下主题内容。
1.标准I/O库简介;
2.流和FILE对象;
3.标准输入、标准输出以及标准错误;
4.使用标准I/O库函数打开、读写、关闭文件;
5.格式化I/O,格式化输出printf、格式化输入scanf;
6.文件I/O缓冲,内核缓冲区和stdio缓冲区;
7.文件I/O与标准I/O混合编程。

4.1标准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库函数。


4.2FILE指针
在第二章中,所介绍的所有文件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中。


4.3标准输入、标准输出和标准错误
关于标准输入、标准输出以及标准错误这三个概念在2.2小节有所提及,所谓标准输入设备指的就是计算机系统的标准的输入设备,通常指的是计算机所连接的键盘;而标准输出设备指的是计算机系统中用于输出标准信息的设备,通常指的是计算机所连接的显示器;标准错误设备则指的是计算机系统中用于显示错误信息的设备,通常也指的是显示器设备。
用户通过标准输入设备与系统进行交互,进程将从标准输入(stdin)文件中得到输入数据,将正常输出数据(譬如程序中printf打印输出的字符串)输出到标准输出(stdout)文件,而将错误信息(譬如函数调用报错打印的信息)输出到标准错误(stderr)文件。
标准输出文件和标准错误文件都对应终端的屏幕,而标准输入文件则对应于键盘。
每个进程启动之后都会默认打开标准输入、标准输出以及标准错误,得到三个文件描述符,即0、1、2,其中0代表标准输入、1代表标准输出、2代表标准错误;在应用编程中可以使用宏STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO分别代表0、1、2,这些宏定义在unistd.h头文件中:
  1. /* Standard file descriptors.  */
  2. #define        STDIN_FILENO        0        /* Standard input.  */
  3. #define        STDOUT_FILENO        1        /* Standard output.  */
  4. #define        STDERR_FILENO        2        /* Standard error output.  */
复制代码


0、1、2这三个是文件描述符,只能用于文件I/O(read()、write()等),那么在标准I/O中,自然是无法使用文件描述符来对文件进行I/O操作的,它们需要围绕FILE类型指针来进行,在stdio.h头文件中有相应的定义,如下:
  1. /* Standard streams.  */
  2. extern struct _IO_FILE *stdin;                /* Standard input stream.  */
  3. extern struct _IO_FILE *stdout;                /* Standard output stream.  */
  4. extern struct _IO_FILE *stderr;                /* Standard error output stream.  */
  5. /* C89/C99 say they're macros.  Make them happy.  */
  6. #define stdin stdin
  7. #define stdout stdout
  8. #define stderr stderr
复制代码


Tips:struct _IO_FILE结构体就是FILE结构体,使用了typedef进行了重命名。
所以,在标准I/O中,可以使用stdin、stdout、stderr来表示标准输入、标准输出和标准错误。
4.4打开文件fopen()
在第二章所介绍的文件I/O中,使用open()系统调用打开或创建文件,而在标准I/O中,我们将使用库函数fopen()打开或创建文件,fopen()函数原型如下所示:
  1. #include <stdio.h>

  2. FILE *fopen(const char *path, const char *mode);
复制代码


使用该函数需要包含头文件stdio.h。
函数参数和返回值含义如下:
path:参数path指向文件路径,可以是绝对路径、也可以是相对路径。
mode:参数mode指定了对该文件的读写权限,是一个字符串,稍后介绍。
返回值:调用成功返回一个指向FILE类型对象的指针(FILE *),该指针与打开或创建的文件相关联,后续的标准I/O操作将围绕FILE指针进行。如果失败则返回NULL,并设置errno以指示错误原因。
参数mode字符串类型,可取值为如下值之一:
表1.png
表 4.4.1 标注I/O fopen()函数的mode参数
新建文件的权限
由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)
使用示例
使用只读方式打开文件:
  1. fopen(path, "r");
复制代码


使用可读、可写方式打开文件:
  1. fopen(path, "r+");
复制代码


使用只写方式打开文件,并将文件长度截断为0,如果文件不存在则创建该文件:
  1. fopen(path, "w");
复制代码


fclose()关闭文件
调用fclose()库函数可以关闭一个由fopen()打开的文件,其函数原型如下所示:
  1. #include <stdio.h>

  2. int fclose(FILE *stream);
复制代码


参数stream为FILE类型指针,调用成功返回0;失败将返回EOF(也就是-1),并且会设置errno来指示错误原因。
4.5读文件和写文件
当使用fopen()库函数打开文件之后,接着我们便可以使用fread()和fwrite()库函数对文件进行读、写操作了,函数原型如下所示:
  1. #include <stdio.h>

  2. size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
  3. 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()函数来判断,具体参考4.7小节内容的介绍。
库函数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结构体数据写入到文件中,可按如下方式写入:
  1. fwrite(buf, sizeof(struct mystr), 1, file);
复制代码


当然也可以按如下方式写:
  1. fwrite(buf, 1, sizeof(struct mystr), file);
复制代码


使用示例
结合使用本小节与上小节所学内容,我们来编写一个简单地示例代码,使用标准I/O方式对文件进行读写操作。示例代码 4.5.1演示了使用fwrite()库函数将数据写入到文件中。
示例代码 4.5.1 标准I/O之fwrite()写文件
  1. #include <stdio.h>
  2. #include <stdlib.h>

  3. int main(void)
  4. {
  5.     char buf[] = "Hello World!\n";
  6.     FILE *fp = NULL;

  7.     /* 打开文件 */
  8.     if (NULL == (fp = fopen("./test_file", "w"))) {
  9.         perror("fopen error");
  10.         exit(-1);
  11.     }

  12.     printf("文件打开成功!\n");

  13.     /* 写入数据 */
  14.     if (sizeof(buf) >
  15.         fwrite(buf, 1, sizeof(buf), fp)) {
  16.         printf("fwrite error\n");
  17.         fclose(fp);
  18.         exit(-1);
  19.     }

  20.     printf("数据写入成功!\n");

  21.     /* 关闭文件 */
  22.     fclose(fp);
  23.     exit(0);
  24. }
复制代码


首先使用fopen()函数将当前目录下的test_file文件打开,调用fopen()时mode参数设置为"w",表示以只写的方式打开文件,并将文件的长度截断为0,如果指定文件不存在则创建该文件。打开文件之后调用fwrite()函数将"Hello World!"字符串数据写入到文件中。
写入完成之后,调用fclose()函数关闭文件,退出程序。
编译运行:
第四章 标准I6496.png
图 4.5.1 测试结果
示例代码 4.5.2演示了使用库函数fread()从文件中读取数据。
示例代码 4.5.2 标准I/O之fread()读文件
  1. #include <stdio.h>
  2. #include <stdlib.h>

  3. int main(void)
  4. {
  5.     char buf[50] = {0};
  6.     FILE *fp = NULL;
  7.     int size;

  8.     /* 打开文件 */
  9.     if (NULL == (fp = fopen("./test_file", "r"))) {
  10.         perror("fopen error");
  11.         exit(-1);
  12.     }

  13.     printf("文件打开成功!\n");

  14.     /* 读取数据 */
  15.     if (12 > (size = fread(buf, 1, 12, fp))) {
  16.         if (ferror(fp)) {   //使用ferror判断是否是发生错误
  17.             printf("fread error\n");
  18.             fclose(fp);
  19.             exit(-1);
  20.         }

  21.         /* 如果未发生错误则意味着已经到达了文件末尾 */
  22.     }

  23.     printf("成功读取%d个字节数据: %s\n", size, buf);

  24.     /* 关闭文件 */
  25.     fclose(fp);
  26.     exit(0);
  27. }
复制代码


首先同样使用fopen()打开当前目录下的test_file文件得到FILE指针,调用fopen()时其参数mode设置为"r",表示以只读方式打开文件。
接着使用fread()函数从文件中读取12 * 1=12个字节的数据,将读取到的数据存放在buf中,当读取到的字节数小于指定字节数时,表示发生了错误或者已经到达了文件末尾,程序中调用了库函数ferror()来判断是不是发生了错误,该函数将会在4.7小节中介绍。如果未发生错误,那么就意味着已经达到了文件末尾,其实也就说明了在调用fread()读文件时对应的读写位置到文件末尾之间的字节数小于指定的字节数。
最后调用printf()打印结果,编译测试:
第四章 标准I7637.png
图 4.5.2 测试结果
4.6fseek定位
库函数fseek()的作用类似于2.7小节所学习的系统调用lseek(),用于设置文件读写位置偏移量,lseek()用于文件I/O,而库函数fseek()则用于标准I/O,其函数原型如下所示:
  1. #include <stdio.h>

  2. int fseek(FILE *stream, long offset, int whence);
复制代码


函数参数和返回值含义如下:
stream:FILE指针。
offset:与lseek()函数的offset参数意义相同。
whence:与lseek()函数的whence参数意义相同。
返回值:成功返回0;发生错误将返回-1,并且会设置errno以指示错误原因;与lseek()函数的返回值意义不同,这里要注意!
调用库函数fread()、fwrite()读写文件时,文件的读写位置偏移量会自动递增,使用fseek()可手动设置文件当前的读写位置偏移量。
譬如将文件的读写位置移动到文件开头处:
  1. fseek(file, 0, SEEK_SET);
复制代码


将文件的读写位置移动到文件末尾:
  1. fseek(file, 0, SEEK_END);
复制代码


将文件的读写位置移动到100个字节偏移量处:
  1. fseek(file, 100, SEEK_SET);
复制代码


使用示例
示例代码 4.6.1 使用fseek()调整文件读写位置
  1. #include <stdio.h>
  2. #include <stdlib.h>

  3. int main(void)
  4. {
  5.     FILE *fp = NULL;
  6.     char rd_buf[100] = {0};
  7.     char wr_buf[] = "正点原子<a href="http://www.openedv.com/forum.php" target="_blank">http://www.openedv.com/forum.php</a>\n";
  8.     int ret;

  9.     /* 打开文件 */
  10.     if (NULL == (fp = fopen("./test_file", "w+"))) {
  11.         perror("fopen error");
  12.         exit(-1);
  13.     }
  14.     printf("文件打开成功!\n");

  15.     /* 写文件 */
  16.     if (sizeof(wr_buf) >
  17.         fwrite(wr_buf, 1, sizeof(wr_buf), fp)) {
  18.         printf("fwrite error\n");
  19.         fclose(fp);
  20.         exit(-1);
  21.     }
  22.     printf("数据写入成功!\n");

  23.     /* 将读写位置移动到文件头部 */
  24.     if (0 > fseek(fp, 0, SEEK_SET)) {
  25.         perror("fseek error");
  26.         fclose(fp);
  27.         exit(-1);
  28.     }

  29.     /* 读文件 */
  30.     if (sizeof(wr_buf) >
  31.         (ret = fread(rd_buf, 1, sizeof(wr_buf), fp))) {
  32.         printf("fread error\n");
  33.         fclose(fp);
  34.         exit(-1);
  35.     }

  36.     printf("成功读取%d个字节数据: %s\n", ret, rd_buf);

  37.     /* 关闭文件 */
  38.     fclose(fp);
  39.     exit(0);
  40. }
复制代码


程序中首先调用fopen()打开当前目录下的test_file文件,参数mode设置为"w+";接着调用fwrite()将wr_buf缓冲区中的字符串数据"正点原子http://www.openedv.com/forum.php"写入到文件中;由于调用了fwrite(),所以此时的读写位置已经发生了改变,不再是文件头部,所以程序中调用了fseek()将读写位置移动到了文件头,接着调用fread()从文件头部开始读取刚写入的数据,读取成功之后打印出信息。
运行测试:
第四章 标准I9555.png
图 4.6.1 测试结果
ftell()函数
库函数ftell()可用于获取文件当前的读写位置偏移量,其函数原型如下所示:
  1. #include <stdio.h>

  2. long ftell(FILE *stream);
复制代码


参数stream指向对应的文件,函数调用成功将返回当前读写位置偏移量;调用失败将返回-1,并会设置errno以指示错误原因。
我们可以通过fseek()和ftell()来计算出文件的大小,示例代码如下所示:
示例代码 4.6.2 使用fseek()和ftell()函数获取文件大小
  1. #include <stdio.h>
  2. #include <stdlib.h>

  3. int main(void)
  4. {
  5.     FILE *fp = NULL;
  6.     int ret;

  7.     /* 打开文件 */
  8.     if (NULL == (fp = fopen("./testApp.c", "r"))) {
  9.         perror("fopen error");
  10.         exit(-1);
  11.     }

  12.     printf("文件打开成功!\n");

  13.     /* 将读写位置移动到文件末尾 */
  14.     if (0 > fseek(fp, 0, SEEK_END)) {
  15.         perror("fseek error");
  16.         fclose(fp);
  17.         exit(-1);
  18.     }

  19.     /* 获取当前位置偏移量 */
  20.     if (0 > (ret = ftell(fp))) {
  21.         perror("ftell error");
  22.         fclose(fp);
  23.         exit(-1);
  24.     }

  25.     printf("文件大小: %d个字节\n", ret);

  26.     /* 关闭文件 */
  27.     fclose(fp);
  28.     exit(0);
  29. }
复制代码


首先打开当前目录下的testApp.c文件,将文件的读写位置移动到文件末尾,然后再获取当前的位置偏移量,也就得到了整个文件的大小。
运行测试:
第四章 标准I10555.png
图 4.6.2 测试结果
从上图可知,程序计算出的文件大小与ls命令查看到的文件大小是一致的。
4.7检查或复位状态
调用fread()读取数据时,如果返回值小于参数nmemb所指定的值,表示发生了错误或者已经到了文件末尾(文件结束end-of-file),但fread()无法具体确定是哪一种情况;在这种情况下,可以通过判断错误标志或end-of-file标志来确定具体的情况。
4.7.1feof()函数
库函数feof()用于测试参数stream所指文件的end-of-file标志,如果end-of-file标志被设置了,则调用feof()函数将返回一个非零值,如果end-of-file标志没有被设置,则返回0。
其函数原型如下所示:
  1. #include <stdio.h>

  2. int feof(FILE *stream);
复制代码


当文件的读写位置移动到了文件末尾时,end-of-file标志将会被设置。
  1. if (feof(file)) {
  2.         /* 到达文件末尾 */
  3. }
  4. else {
  5.         /* 未到达文件末尾 */
  6. }
复制代码


4.7.2ferror()函数
库函数ferror()用于测试参数stream所指文件的错误标志,如果错误标志被设置了,则调用ferror()函数将返回一个非零值,如果错误标志没有被设置,则返回0。
其函数原型如下所示:
  1. #include <stdio.h>

  2. int ferror(FILE *stream);
复制代码


当对文件的I/O操作发生错误时,错误标志将会被设置。
  1. if (ferror(file)) {
  2.         /* 发生错误 */
  3. }
  4. else {
  5.         /* 未发生错误 */
  6. }
复制代码


4.7.3clearerr()函数
库函数clearerr()用于清除end-of-file标志和错误标志,当调用feof()或ferror()校验这些标志后,通常需要清除这些标志,避免下次校验时使用到的是上一次设置的值,此时可以手动调用clearerr()函数清除标志。
clearerr()函数原型如下所示:
  1. #include <stdio.h>

  2. void clearerr(FILE *stream);
复制代码


此函数没有返回值,调用将总是会成功!
对于end-of-file标志,除了使用clearerr()显式清除之外,当调用fseek()成功时也会清除文件的end-of-file标志。
使用示例
示例代码 4.7.1 clearerr()函数使用示例
  1. #include <stdio.h>
  2. #include <stdlib.h>

  3. int main(void)
  4. {
  5.     FILE *fp = NULL;
  6.     char buf[20] = {0};

  7.     /* 打开文件 */
  8.     if (NULL == (fp = fopen("./testApp.c", "r"))) {
  9.         perror("fopen error");
  10.         exit(-1);
  11.     }

  12.     printf("文件打开成功!\n");

  13.     /* 将读写位置移动到文件末尾 */
  14.     if (0 > fseek(fp, 0, SEEK_END)) {
  15.         perror("fseek error");
  16.         fclose(fp);
  17.         exit(-1);
  18.     }

  19.     /* 读文件 */
  20.     if (10 > fread(buf, 1, 10, fp)) {
  21.         if (feof(fp))
  22.             printf("end-of-file标志被设置,已到文件末尾!\n");

  23.         clearerr(fp);   //清除标志
  24.     }

  25.     /* 关闭文件 */
  26.     fclose(fp);
  27.     exit(0);
  28. }
复制代码


4.8格式化I/O
在前面编写的测试代码中,会经常使用到库函数printf()用于输出程序中的打印信息,printf()函数可将格式化数据写入到标准输出,所以通常称为格式化输出。除了printf()之外,格式化输出还包括:fprintf()、dprintf()、sprintf()、snprintf()这4个库函数。
除了格式化输出之外,自然也有格式化输入,从标准输入中获取格式化数据,格式化输入包括:scanf()、fscanf()、sscanf()这三个库函数,那么本小节将向大家介绍C语言库函数的格式化I/O。
4.8.1格式化输出
C库函数提供了5个格式化输出函数,包括:printf()、fprintf()、dprintf()、sprintf()、snprintf(),其函数定义如下所示:
  1. #include <stdio.h>

  2. int printf(const char *format, ...);
  3. int fprintf(FILE *stream, const char *format, ...);
  4. int dprintf(int fd, const char *format, ...);
  5. int sprintf(char *buf, const char *format, ...);
  6. 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”:
  1. printf("Hello World!\n");
复制代码


打印数字5:
  1. printf("%d\n", 5);
复制代码


fprintf()函数
fprintf()可将格式化数据写入到由FILE指针指定的文件中,譬如将字符串“Hello World”写入到标准错误:
  1. fprintf(stderr, "Hello World!\n");
复制代码


向标准错误写入数字5:
  1. fprintf(stderr, "%d\n", 5);
复制代码


函数调用成功返回写入到文件中的字符数;失败将返回一个负值!
dprintf()函数
dprintf()可将格式化数据写入到由文件描述符fd指定的文件中,譬如将字符串“Hello World”写入到标准错误:
  1. dprintf(STDERR_FILENO, "Hello World!\n");
复制代码


向标准错误写入数字5:
  1. dprintf(STDERR_FILENO, "%d\n", 5);
复制代码


函数调用成功返回写入到文件中的字符数;失败将返回一个负值!
sprintf()函数
sprintf()函数将格式化数据存储在由参数buf所指定的缓冲区中,譬如将字符串“Hello World”存放在缓冲区中:
  1. char buf[100];
  2. sprintf(buf, "Hello World!\n");
复制代码


当然这种用法并没有意义,事实上,我们一般会使用这个函数进行格式化转换,并将转换后的字符串存放在缓冲区中,譬如将数字100转换为字符串"100",将转换后得到的字符串存放在buf中:
  1. char buf[20] = {0};
  2. 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字段使用一个字符(字母字符)来表示,可取值如下:
表2.png
表 4.8.1 转换说明中的type字段介绍
㈡、flags
flags规定输出样式,%后面可以跟0个或多个以下标志:
表3.png
表 4.8.2 转换说明中的flags字段介绍
㈢、width
最小的输出宽度,用十进制数来表示输出的最小位数,若实际的输出位数大于指定的输出的最小位数,则以实际的位数进行输出,若实际的位数小于指定输出的最小位数,则可按照指定的flags标志补0或补空格。
width的可能取值如下:
表4.png
表 4.8.3 转换说明中的width字段介绍
㈣、precision精度
精度字段以点号" . "开头,后跟一个十进制正数,可取值如下:
表5.png
表 4.8.4 转换说明中的precision字段介绍
㈤、length长度修饰符
长度修饰符指明待转换数据的长度,因为type字段指定的的类型只有int、unsigned int以及double等几种数据类型,但是C语言内置的数据类型不止这几种,譬如有16bit的short、unsigned short,8bit的char、unsigned char,也有64bit的long long等,为了能够区别不同长度的数据类型,于是乎,长度修饰符(length)应运而生,成为转换说明的一部分。
length长度修饰符也是使用字符(字母字符)来表示,结合type字段以确定不同长度的数据类型,如下所示:
表6.png       
表 4.8.5 length长度修饰符说明
譬如:
  1. printf("%hd\n", 12345);                //将数据以short int类型进行转换
  2. printf("%ld\n", 12345);                //将数据以long int类型进行转换
  3. printf("%lld\n", 12345);                //将数据以long long int类型进行转换
复制代码


关于格式控制字符串format就给大家介绍完了,这种东西不用去记,需要时查询即可!需要说明的是,转换说明的描述信息需要和与之相对应的参数对应的数据类型要进行匹配,如果不匹配通常会编译报错或者警告!
示例代码
前面为了说明格式控制字符串format的输出效果,我们使用了printf()函数进行演示,其它格式化输出函数也是一样,接下来我们编写一个简单的测试程序,对上面学习的内容进行练习。
示例代码 4.8.1 格式化输出函数使用练习
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>
  4. #include <string.h>

  5. int main(void)
  6. {
  7.     char buf[50] = {0};

  8.     printf("%d (%s) %d (%s)\n", 520, "我爱你", 1314, "一生一世");
  9.     fprintf(stdout, "%d (%s) %d (%s)\n", 520, "我爱你", 1314, "一生一世");
  10.     dprintf(STDOUT_FILENO, "%d (%s) %d (%s)\n", 520, "我爱你", 1314, "一生一世");

  11.     sprintf(buf, "%d (%s) %d (%s)\n", 520, "我爱你", 1314, "一生一世");
  12.     printf("%s", buf);

  13.     memset(buf, 0x00, sizeof(buf));
  14.     snprintf(buf, sizeof(buf), "%d (%s) %d (%s)\n", 520, "我爱你", 1314, "一生一世");
  15. printf("%s", buf);

  16.     exit(0);
  17. }
复制代码


运行结果:
第四章 标准I19961.png
图 4.8.1 运行结果
关于格式化输出这几个函数其实用法上比较简单,主要是需要掌握格式控制字符串format参数的写法,对后续参数列表中不同类型的数据搭配不同的格式控制字符,以实现转换输出、并且控制输出样式。
本小节所学内容,大家可以多多练习!
4.8.2格式化输入
C库函数提供了3个格式化输入函数,包括:scanf()、fscanf()、sscanf(),其函数定义如下所示:
  1. #include <stdio.h>

  2. int scanf(const char *format, ...);
  3. int fscanf(FILE *stream, const char *format, ...);
  4. 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个输入地址,以此类推。譬如:
  1. int a, b, c;
  2. scanf("%d %d %d", &a, &b, &c);
复制代码


当程序中调用scanf()的时候,终端会被阻塞,等待用户输入数据,此时我们可以通过键盘输入一些字符,譬如数字、字母或者其它字符,输入完成按回车即可!接着来scanf()函数就会对用户输入的数据进行格式转换处理。
函数调用成功后,将返回成功匹配和分配的输入项的数量;如果较早匹配失败,则该数目可能小于所提供的数目,甚至为零。发生错误则返回负值。
fscanf()函数
fscanf()函数从指定文件中读取数据,作为格式转换的输入数据,文件通过FILE指针指定,所以它有两个固定参数,FILE指针和格式控制字符串format。譬如从标准输入文件中读取数据进行格式化转换:
int a, b, c;
  1. fscanf(stdin, "%d %d %d", &a, &b, &c);
复制代码


此时它的作用与scanf()就是相同的,因为标准输入文件的数据就是用户输入的数据,譬如通过键盘输入的数据。
函数调用成功后,将返回成功匹配和分配的输入项的数量;如果较早匹配失败,则该数目可能小于所提供的数目,甚至为零。发生错误则返回负值。
sscanf()函数
sscanf()将从参数str所指向的字符串缓冲区中读取数据,作为格式转换的输入数据,所以它也有两个固定参数,字符串str和格式控制字符串format,譬如:
  1. char *str = "5454 hello";
  2. char buf[10];
  3. int a;

  4. sscanf(str, "%d %s", &a, buf);
复制代码


函数调用成功后,将返回成功匹配和分配的输入项的数量;如果较早匹配失败,则该数目可能小于所提供的数目,甚至为零。发生错误则返回负值。
格式控制字符串format
本小节的重点依然是这个format参数的格式,与格式化输出函数中的format参数格式、写法上比较相似,但也有一些区别。format字符串包含一个或多个转换说明,每一个转换说明都是以百分号"%"或者"%n$"开头(n是一个十进制数字),关于"%n$"这种开头的转换说明就不介绍了,实际上用的不多。
以%百分号开头的转换说明一般格式如下:
  1. <font size="4">%</font><font size="4">[width][length]type
  2. %[m][width][length]type</font>
复制代码


%后面可选择性添加星号*或字母m,如果添加了星号*,格式化输入函数会按照转换说明的指示读取输入,但是丢弃输入,意味着不需要对转换后的结果进行存储,所以也就不需要提供相应的指针参数。
如果添加了m,它只能与%s、%c以及%[一起使用,调用者无需分配相应的缓冲区来保存格式转换后的数据,原因在于添加了m,这些格式化输入函数内部会自动分配足够大小的缓冲区,并将缓冲区的地址值通过与该格式转换相对应的指针参数返回出来,该指针参数应该是指向char *变量的指针。随后,当不再需要此缓冲区时,调用者应调用free()函数来释放此缓冲区。
譬如:
  1. char *buf;

  2. scanf("%ms", &buf);
  3. ......
  4. free(buf);
复制代码


介绍了星号*和字母m之后,再来看看转换说明的格式,中括号[ ]表示的部分是可选的,所以可知,与格式化输出函数中的format参数一样,只有type字段是必须的。
width:最大字符宽度;
length:长度修饰符,与格式化输出函数的format参数中的length字段意义相同。
type:指定输入数据的类型。
我们先来看看type字段。
㈠type(类型)
此type字段与格式化输出函数中的format参数的type字段是同样的意义,用于指定输入数据的类型,如下所示:
表7上.png
表7下.png
表 4.8.6 type类型描述
㈡、width最大字符宽度
是一个十进制表示的整数,用于指定最大字符宽度,当达到此最大值或发现不匹配的字符时(以先发生者为准),字符的读取将停止。大多数type类型会丢弃初始的空白字符,并且这些丢弃的字符不会计入最大字符宽度。对于字符串转换来说,scanf()会在字符串末尾自动添加终止符"\0",最大字符宽度中不包括此终止符。
譬如调用scanf()函数如下:
  1. scanf("%4s", buf);                //匹配字符串,字符串长度不超过4个字符
复制代码


用户输入abcdefg,按回车,那么只能将adcd作为一个字符串存储在buf数组中。
㈢length长度修饰符
与格式化输出函数的格式控制字符串format中的length字段意义相同,用于对type字段进行修饰,扩展识别更多不同长度的数据类型。如下所示:
表8.png    
表 4.8.7 length长度修饰符
譬如:
  1. scanf("%hd", var);                //匹配short int类型数据
  2. scanf("%hhd", var);                //匹配signed char类型数据
  3. scanf("%ld", var);                //匹配long int类型数据
  4. scanf("%f", var);                //匹配float类型数据
  5. scanf("%lf", var);                //匹配double类型数据
  6. scanf("%Lf", var);                //匹配long double类型数据
复制代码


关于格式化输入函数的format参数就介绍到到这里了,接下来编写一个简单地示例进行测试。
使用示例
示例代码 4.8.2 scanf()函数使用示例
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <string.h>

  4. int main(void)
  5. {
  6.     int a;
  7.     float b;
  8.     char *str;

  9.     printf("请输入一个整数:\n");
  10.     scanf("%d", &a);
  11.     printf("你输入的整数为: %d\n", a);

  12.     printf("请输入一个浮点数:\n");
  13.     scanf("%f", &b);
  14.     printf("你输入的浮点数为: %f\n", b);

  15.     printf("请输入一个字符串:\n");
  16.     scanf("%ms", &str);
  17.     printf("你输入的字符串为: %s\n", str);
  18.     free(str);      //释放字符串占用的内存空间

  19.     exit(0);
  20. }
复制代码


当程序中调用scanf()之后,终端就会被阻塞、等待用户输入数据,当我们输入完成之后,按回车即可!第三个scanf()函数调用中,使用%m,所以我们不需要提供存放字符串的缓冲区,scanf()函数内部会分配缓冲区,并将缓冲区地址存放在str这个我们给定的char指针变量中。使用完之后记得调用free()释放内存即可。
编译测试:
第四章 标准I25547.png
图 4.8.2 测试结果
4.8.3小结
本小节(4.8)对标准I/O中的格式化I/O做了比较详细的介绍,相信大家对此都比较熟悉了,当然,重要的是需要灵活使用它们。关于格式化I/O还存在一些比较细节的问题需要我们注意,主要是围绕缓冲的问题,关于缓冲的问题将会在下一小节向大家介绍!
4.9I/O缓冲
出于速度和效率的考虑,系统I/O调用(即文件I/O,open、read、write等)和标准C语言库I/O函数(即标准I/O函数)在操作磁盘文件时会对数据进行缓冲,本小节将讨论文件I/O和标准I/O这两种I/O方式的数据缓冲问题,并讨论其对应用程序性能的影响。
除此之外,本小节还讨论了屏蔽或影响缓冲的一些技术手段,以及直接I/O技术—绕过内核缓冲直接访问磁盘硬件。
4.9.1文件I/O的内核缓冲
read()和write()系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区(kernel buffer cache)之间复制数据。譬如调用write()函数将5个字节数据从用户空间内存拷贝到内核空间的缓冲区中:
  1. 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的内核缓冲区,但受限于物理内存的总量,如果系统可用的物理内存越多,那自然对应的内核缓冲区也就越大,操作越大的文件也要依赖于更大空间的内核缓冲。
4.9.2刷新文件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()函数才会返回,其函数原型如下所示:
  1. #include <unistd.h>

  2. int fsync(int fd);
复制代码


参数fd表示文件描述符,函数调用成功将返回0,失败返回-1并设置errno以指示错误原因。
前面提到了元数据这个概念,元数据并不是文件内容本身的数据,而是一些用于记录文件属性相关的数据信息,譬如文件大小、时间戳、权限等等信息,这里统称为文件的元数据,这些信息也是存储在磁盘设备中的,在3.1小节中介绍过。
使用示例
示例代码 4.9.1实现了一个文件拷贝操作,将源文件(当前目录下的rfile文件)的内容拷贝到目标文件中(当前目录下的wfile文件)。
示例代码 4.9.1 fsync()函数使用示例
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <sys/types.h>
  4. #include <sys/stat.h>
  5. #include <fcntl.h>
  6. #include <unistd.h>

  7. #define BUF_SIZE    4096
  8. #define READ_FILE   "./rfile"
  9. #define WRITE_FILE  "./wfile"

  10. static char buf[BUF_SIZE];

  11. int main(void)
  12. {
  13.     int rfd, wfd;
  14.     size_t size;

  15.     /* 打开源文件 */
  16.     rfd = open(READ_FILE, O_RDONLY);
  17.     if (0 > rfd) {
  18.         perror("open error");
  19.         exit(-1);
  20.     }

  21.     /* 打开目标文件 */
  22.     wfd = open(WRITE_FILE, O_WRONLY | O_CREAT | O_TRUNC, 0664);
  23.     if (0 > wfd) {
  24.         perror("open error");
  25.         exit(-1);
  26.     }

  27.     /* 拷贝数据 */
  28.     while(0 < (size = read(rfd, buf, BUF_SIZE)))
  29.         write(wfd, buf, size);

  30.     /* 对目标文件执行fsync同步 */
  31.     fsync(wfd);

  32.     /* 关闭文件退出程序 */
  33.     close(rfd);
  34.     close(wfd);
  35.     exit(0);
  36. }
复制代码


代码没什么好说的,主要就是拷贝完成之后调用fsync()函数,对目标文件的数据进行了同步操作,整个操作完成之后close关闭源文件和目标文件、退出程序。
㈡、fdatasync()函数
系统调用fdatasync()与fsync()类似,不同之处在于fdatasync()仅将参数fd所指文件的内容数据写入磁盘,并不包括文件的元数据;同样,只有在对磁盘设备的写入操作完成之后,fdatasync()函数才会返回,其函数原型如下所示:
  1. #include <unistd.h>

  2. int fdatasync(int fd);
复制代码


㈢、sync()函数
系统调用sync()会将所有文件I/O内核缓冲区中的文件内容数据和元数据全部更新到磁盘设备中,该函数没有参数、也无返回值,意味着它不是对某一个指定的文件进行数据更新,而是刷新所有文件I/O内核缓冲区。其函数原型如下所示:
  1. #include <unistd.h>

  2. void sync(void);
复制代码


在Linux实现中,调用sync()函数仅在所有数据已经写入到磁盘设备之后才会返回;然后在其它系统中,sync()实现只是简单调度一下I/O传递,在动作未完成之后即可返回。
控制文件I/O内核缓冲的标志
调用open()函数时指定一些标志也可以影响到文件I/O内核缓冲,譬如O_DSYNC标志和O_SYNC标志,这些标志在2.3小节并未向大家介绍过,联系本小节所学内容,接下来向大家简单地介绍下。
㈠、O_DSYNC标志
在调用open()函数时,指定O_DSYNC标志,其效果类似于在每个write()调用之后调用fdatasync()函数进行数据同步。譬如:
  1. fd = open(filepath, O_WRONLY | O_DSYNC);
复制代码


㈡、O_SYNC标志
在调用open()函数时,指定O_SYNC标志,使得每个write()调用都会自动将文件内容数据和元数据刷新到磁盘设备中,其效果类似于在每个write()调用之后调用fsync()函数进行数据同步,譬如:
  1. fd = open(filepath, O_WRONLY | O_SYNC);
复制代码


对性能的影响
在程序中频繁调用fsync()、fdatasync()、sync()(或者调用open时指定O_DSYNC或O_SYNC标志)对性能的影响极大,大部分的应用程序是没有这种需求的,所以在大部分应用程序当中基本不会使用到。
4.9.3直接I/O:绕过内核缓冲
从Linux内核2.4版本开始,Linux允许应用程序在执行文件I/O操作时绕过内核缓冲区,从用户空间直接将数据传递到文件或磁盘设备,把这种操作也称为直接I/O(direct I/O)或裸I/O(raw 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版本开始生效,譬如:
  1. fd = open(filepath, O_WRONLY | O_DIRECT);
复制代码


直接I/O的对齐限制
因为直接I/O涉及到对磁盘设备的直接访问,所以在执行直接I/O时,必须要遵守以下三个对齐限制要求:
&#61548;应用程序中用于存放数据的缓冲区,其内存起始地址必须以块大小的整数倍进行对齐;
&#61548;写文件时,文件的位置偏移量必须是块大小的整数倍;
&#61548;写入到文件的数据大小必须是块大小的整数倍。
如果不满足以上任何一个要求,调用write()均为以错误返回Invalid argument。以上所说的块大小指的是磁盘设备的物理块大小(block size),常见的块大小包括512字节、1024字节、2048以及4096字节,那我们如何确定磁盘分区的块大小呢?可以使用tune2fs命令进行查看,如下所示:
  1. tune2fs -l /dev/sda1 | grep "Block size"
复制代码


-l后面指定了需要查看的磁盘分区,可以使用df -h命令查看Ubuntu系统的根文件系统所挂载的磁盘分区:
第四章 标准I30704.png
图 4.9.1 查看根文件系统挂载的磁盘分区
通过上图可知,Ubuntu系统的根文件系统挂载在/dev/sda1磁盘分区下,接着下使用tune2fs命令查看该分区的块大小:
第四章 标准I30836.png
图 4.9.2 磁盘块大小
从上图可知/dev/sda1磁盘分区的块大小为4096个字节。
直接I/O测试与普通I/O对比测试
接下来编写一个使用直接I/O方式写文件的测试程序和一个使用普通I/O方式写文件的测试程序,进行对比。
示例代码 4.9.2演示了以直接I/O方式写文件的操作,首先我们需要在程序开头处定义一个宏定义_GNU_SOURCE,原因在于后面open()函数需要指定O_DIRECT标志,这个宏需要我们在程序中定义了O_DIRECT宏之后才能使用,否则编译程序就会报错提示:O_DIRECT未定义。
Tips:_GNU_SOURCE宏可用于开启/禁用Linux系统调用和glibc库函数的一些功能、特性,要打开这些特性,需要在应用程序中定义该宏,定义该宏之后意味着用户应用程序打开了所有的特性;默认情况下,_GNU_SOURCE宏并没有被定义,所以当使用到它控制的一些特性时,应用程序编译将会报错!定义该宏的方式有两种:
&#61548;直接在源文件中定义:#define _GNU_SOURCE
&#61548;gcc编译时使用-D选项定义_GNU_SOURCE宏:
  1. gcc -D_GNU_SOURCE -o testApp testApp.c
复制代码


gcc的-D选项可用于定义一个宏,并且该宏定义在整个源码工程中都是生效的,是一个全局宏定义。使用以上哪种方式都可以。
示例代码 4.9.2 直接I/O示例程序
  1. /** 使用宏定义O_DIRECT需要在程序中定义宏_GNU_SOURCE
  2. ** 不然提示O_DIRECT找不到 **/
  3. #define _GNU_SOURCE

  4. #include <stdio.h>
  5. #include <stdlib.h>
  6. #include <sys/types.h>
  7. #include <sys/stat.h>
  8. #include <fcntl.h>
  9. #include <unistd.h>

  10. /** 定义一个用于存放数据的buf,起始地址以4096字节进行对其 **/
  11. static char buf[8192] __attribute((aligned (4096)));

  12. int main(void)
  13. {
  14.     int fd;
  15.     int count;

  16.     /* 打开文件 */
  17.     fd = open("./test_file",
  18.             O_WRONLY | O_CREAT | O_TRUNC | O_DIRECT,
  19.             0664);
  20.     if (0 > fd) {
  21.         perror("open error");
  22.         exit(-1);
  23.     }

  24.     /* 写文件 */
  25.     count = 10000;
  26.     while(count--) {
  27.         if (4096 != write(fd, buf, 4096)) {
  28.             perror("write error");
  29.             exit(-1);
  30.         }
  31.     }

  32.     /* 关闭文件退出程序 */
  33.     close(fd);
  34.     exit(0);
  35. }
复制代码


前面提到过,使用直接I/O方式需要满足3个对齐要求,程序中定义了一个static静态数组buf,将其作为数据存放的缓冲区,在变量定义后加了__attribute((aligned (4096)))修饰,使其起始地址以4096字节进行对其。
Tips:__attribute是gcc支持的一种机制(也可以写成__attribute__),可用于设置函数属性、变量属性以及类型属性等,对此不了解的读者请自行查找资料学习,本书不会对此进行介绍!
程序中调用open()函数是指定了O_DIRECT标志,使用直接I/O,最后通过while循环,将数据写入文件中,循环10000次,每次写入4096个字节数据,也就是总共写入4096*10000个字节(约等于40MB)。首次调用write()时其文件读写位置偏移量为0,之后均以4096字节进行递增,所以满足直接I/O方式的位置偏移量必须是块大小的整数倍这个要求;每次写入大小均是4096字节,所以满足了数据大小必须是块大小的整数倍这个要求。
接下来编译测试:
第四章 标准I32770.png
图 4.9.3 直接I/O测试结果
通过time命令测试可知,每次执行程序需要花费2.7秒左右的时间,使用直接I/O方式向文件写入约40MB数据大小。
Tips:对于直接I/O方式的3个对齐限制,大家可以自行进行验证,譬如修改上述示例代码使之不满足3个对齐条件种的任何一个,然后编译程序进行测试,会发生write()函数会报错,均是“Invalid argument”错误。
对示例代码 4.9.2进行修改,使其变成普通I/O方式,其它功能相同,最终修改后的示例代码如下所示:
示例代码 4.9.3 普通I/O方式
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <sys/types.h>
  4. #include <sys/stat.h>
  5. #include <fcntl.h>
  6. #include <unistd.h>

  7. static char buf[8192];

  8. int main(void)
  9. {
  10.     int fd;
  11.     int count;

  12.     /* 打开文件 */
  13.     fd = open("./test_file", O_WRONLY | O_CREAT | O_TRUNC, 0664);
  14.     if (0 > fd) {
  15.         perror("open error");
  16.         exit(-1);
  17.     }

  18.     /* 写文件 */
  19.     count = 10000;
  20.     while(count--) {//循环10000次,每次写入4096个字节数据
  21.         if (4096 != write(fd, buf, 4096)) {
  22.             perror("write error");
  23.             exit(-1);
  24.         }
  25.     }

  26.     /* 关闭文件退出程序 */
  27.     close(fd);
  28.     exit(0);
  29. }
复制代码


再次进行测试:
第四章 标准I33759.png
图 4.9.4 普通I/O测试结果
使用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方式就会更加准确。
4.9.4stdio缓冲
介绍完文件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缓冲区进行设置,譬如缓冲区的缓冲模式、缓冲区的大小、起始地址等。其函数原型如下所示:
  1. #include <stdio.h>

  2. 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,在7.6小节介绍)或静态的方式在堆中为该缓冲区分配一块空间,而不是分配在栈上的函数内的自动变量(局部变量)。如果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()之上,执行类似的任务,其函数原型如下所示:
  1. #include <stdio.h>

  2. void setbuf(FILE *stream, char *buf);
复制代码


setbuf()调用除了不返回函数结果(void)外,就相当于:
  1. setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ);
复制代码


要么将buf设置为NULL以表示无缓冲,要么指向由调用者分配的BUFSIZ个字节大小的缓冲区(BUFSIZ定义于头文件<stdio.h>中,该值通常为8192)。
㈢、setbuffer()函数
setbuffer()函数类似于setbuf(),但允许调用者指定buf缓冲区的大小,其函数原型如下所示:
  1. #include <stdio.h>

  2. void setbuffer(FILE *stream, char *buf, size_t size);
复制代码


setbuffer()调用除了不返回函数结果(void)外,就相当于:
  1. setvbuf(stream, buf, buf ? _IOFBF : _IONBF, size);
复制代码


关于标准I/O库stdio缓冲相关的内容就给大家介绍这么多,接下来我们进行一些测试,来说明无缓冲、行缓冲以及全缓冲区之间的区别。
标准输出printf()的行缓冲模式测试
我们先看看下面这个简单地示例代码,调用了printf()函数,区别在于第二个printf()没有输出换行符。
示例代码 4.9.4 printf()输出测试
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>

  4. int main(void)
  5. {
  6.     printf("Hello World!\n");
  7.     printf("Hello World!");

  8.     for ( ; ; )
  9.         sleep(1);
  10. }
复制代码


printf()函数是标准I/O库函数,向终端设备(标准输出)输出打印信息,编译测试:
第四章 标准I36916.png
图 4.9.5 运行结果
运行之后可以发现只有第一个printf()打印的信息显示出来了,第二个并没有显示出来,这是为什么呢?这就是stdio缓冲的问题,前面提到了标准输出默认采用的是行缓冲模式,printf()输出的字符串写入到了标准输出的stdio缓冲区中,只有输出换行符时(不考虑缓冲区填满的情况)才会将这一行数据刷入到内核缓冲区,也就是写入标准输出文件(终端设备),因为第一个printf()包含了换行符,所以已经刷入了内核缓冲区,而第二个printf并没有包含换行符,所以第二个printf输出的"Hello World!"还缓存在stdio缓冲区中,需要等待一个换行符才可输出到终端。
联系4.8.2小节介绍的格式化输入scanf()函数,程序中调用scanf()函数进行阻塞,用户通过键盘输入数据,只有在按下回车键(换行符键)时程序才会接着往下执行,因为标准输入默认也是采用了行缓冲模式。
譬如对示例代码 4.9.4进行修改,使标准输出变成无缓冲模式,修改后代码如下所示:
示例代码 4.9.5 将标准输出配置为无缓冲模式
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>

  4. int main(void)
  5. {
  6.     /* 将标准输出设置为无缓冲模式 */
  7.     if (setvbuf(stdout, NULL, _IONBF, 0)) {
  8.         perror("setvbuf error");
  9.         exit(0);
  10.     }

  11.     printf("Hello World!\n");
  12.     printf("Hello World!");

  13.     for ( ; ; )
  14.         sleep(1);
  15. }
复制代码


在使用printf()之前,调用setvbuf()函数将标准输出的stdio缓冲设置为无缓冲模式,接着编译运行:
第四章 标准I37897.png
图 4.9.6 无缓冲标准输出测试结果
可以发现该程序却能够成功输出两个“Hello World!”,并且白色的光标在第二个“Hello World!”后面,意味着输出没有换行,与程序中第二个printf没有加换行符的效果是一直。
所以通过以上两个示例代码对比可知,标准输出默认是行缓冲模式,只有输出了换行符时,才会将换行符这一行字符进行输出显示(也就是刷入到内核缓冲区),在没有输出换行符之前,会将数据缓存在stdio缓冲区中。
刷新stdio缓冲区
无论我们采取何种缓冲模式,在任何时候都可以使用库函数fflush()来强制刷新(将输出到stdio缓冲区中的数据写入到内核缓冲区,通过write()函数)stdio缓冲区,该函数会刷新指定文件的stdio输出缓冲区,此函数原型如下所示:
  1. #include <stdio.h>

  2. int fflush(FILE *stream);
复制代码


参数stream指定需要进行强制刷新的文件,如果该参数设置为NULL,则表示刷新所有的stdio缓冲区。
函数调用成功返回0,否则将返回-1,并设置errno以指示错误原因。
接下来我们对示例代码 4.9.4进行修改,在第二个printf后面调用fflush()函数,修改后示例代码如下所示:
示例代码 4.9.6 使用fflush()刷新stdio缓冲区
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>

  4. int main(void)
  5. {
  6.     printf("Hello World!\n");
  7.     printf("Hello World!");
  8.     fflush(stdout); //刷新标准输出stdio缓冲区

  9.     for ( ; ; )
  10.         sleep(1);
  11. }
复制代码


运行测试:
第四章 标准I38814.png
图 4.9.7 刷新缓冲区
可以看到,打印了两次“Hello World!”,这就是fflush()的作用了强制刷新stdio缓冲区。
除了使用库函数fflush()之外,还有其它方法会自动刷新stdio缓冲区吗?是的,使用库函数fflush()是一种强制刷新的手段,在一些其它的情况下,也会自动刷新stdio缓冲区,譬如当文件关闭时、程序退出时,接下来我们进行演示。
㈠、关闭文件时刷新stdio缓冲区
同样还是直接对示例代码 4.9.4进行修改,在调用第二个printf函数后关闭标准输出,如下所示:
示例代码 4.9.7 关闭标准输出
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>

  4. int main(void)
  5. {
  6.     printf("Hello World!\n");
  7.     printf("Hello World!");
  8.     fclose(stdout); //关闭标准输出

  9.     for ( ; ; )
  10.         sleep(1);
  11. }
复制代码


至于运行结果文档中就不贴出来了,运行结果与图 4.9.7是一样的。所以由此可知,文件关闭时系统会自动刷新该文件的stdio缓冲区。
㈡、程序退出时刷新stdio缓冲区
可以看到上面使用的测试程序中,在最后都使用了一个for死循环,让程序处于休眠状态无法退出,为什么要这样做呢?原因在于程序退出时也会自动刷新stdio缓冲区,这样的话就会影响到测试结果。同样对示例代码 4.9.4进行修改,去掉for死循环,让程序结束,修改完之后如下所示:
示例代码 4.9.8 程序结束
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>

  4. int main(void)
  5. {
  6.     printf("Hello World!\n");
  7.     printf("Hello World!");
  8. }
复制代码


运行结果如下:
第四章 标准I39938.png
图 4.9.8 测试结果
从结果可知,当程序退出时,确实会自动刷新stdio缓冲区。但是,与程序退出方式有关,如果使用exit()、return或像上述示例代码一样不显式调用相关函数或执行return语句来结束程序,这些情况下程序终止时会自动刷新stdio缓冲区;如果使用_exit或_Exit()终止程序则不会刷新,这里各位读者可以自行测试、验证。
关于刷新stdio缓冲区相关内容,最后进行一个总结:
&#61548;调用fflush()库函数可强制刷新指定文件的stdio缓冲区;
&#61548;调用fclose()关闭文件时会自动刷新文件的stdio缓冲区;
&#61548;程序退出时会自动刷新stdio缓冲区(注意区分不同的情况)。
关于本小节内容就给大家介绍这么多,笔者觉得已经非常详细了,如果还有不太理解的地方,希望大家能够自己动手进行测试、验证,然后总结出相应的结论,前面笔者一直强调,编程是一门实践性很强的工作,一定要学会自己分析、验证。
4.9.5I/O缓冲小节
本小节对前面学习的内容进行一个简单地总结,概括说明文件I/O内核缓冲区和stdio缓冲区之间的联系与区别,以及各种stdio库函数,如下图所示:
第四章 标准I40471.png
图 4.9.9 I/O缓冲小结
从图中自上而下,首先应用程序调用标准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标志)。
4.10文件描述符与FILE指针互转
在应用程序中,在同一个文件上执行I/O操作时,还可以将文件I/O(系统调用I/O)与标准I/O混合使用,这个时候我们就需要将文件描述符和FILE指针对象之间进行转换,此时可以借助于库函数fdopen()、fileno()来完成。
库函数fileno()可以将标准I/O中使用的FILE指针转换为文件I/O中所使用的文件描述符,而fdopen()则进行着相反的操作,其函数原型如下所示:
  1. #include <stdio.h>

  2. int fileno(FILE *stream);
  3. 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参数含义相同,具体参考表 4.4.1中所述,若该参数与文件描述符fd的访问模式不一致,则会导致调用fdopen()失败。
当混合使用文件I/O和标准I/O时,需要特别注意缓冲的问题,文件I/O会直接将数据写入到内核缓冲区进行高速缓存,而标准I/O则会将数据写入到stdio缓冲区,之后再调用write()将stdio缓冲区中的数据写入到内核缓冲区。譬如下面这段代码:
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>

  4. int main(void)
  5. {
  6.         printf("print");
  7.         write(STDOUT_FILENO, "write\n", 6);
  8.         exit(0);
  9. }
复制代码


执行结果你会发现,先输出了"write"字符串信息,接着再输出了"print"字符串信息,产生这个问题的原因很简单,大家自己去思考下!
正点原子逻辑分析仪DL16劲爆上市
回复

使用道具 举报

0

主题

201

帖子

0

精华

金牌会员

Rank: 6Rank: 6

积分
2552
金钱
2552
注册时间
2019-12-5
在线时间
352 小时
发表于 2021-8-12 09:25:01 | 显示全部楼层
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则



关闭

原子哥极力推荐上一条 /2 下一条

正点原子公众号

QQ|手机版|OpenEdv-开源电子网 ( 粤ICP备12000418号-1 )

GMT+8, 2024-11-25 17:51

Powered by OpenEdv-开源电子网

© 2001-2030 OpenEdv-开源电子网

快速回复 返回顶部 返回列表