一、标准 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 中。
三、标准输入、标准输出和标准错误
关于标准输入、标准输出以及标准错误这三个概念在 2.2 小节有所提及,所谓标准输入设备指的就是计算机系统的标准的输入设备,通常指的是计算机所连接的键盘;而标准输出设备指的是计算机系统中用于输出标准信息的设备,通常指的是计算机所连接的显示器;标准错误设备则指的是计算机系统中用于显示错误信息的设备,通常也指的是显示器设备。
用户通过标准输入设备与系统进行交互,进程将从标准输入(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
所以,在标准 I/O 中,可以使用 stdin、stdout、stderr 来表示标准输入、标准输出和标准错误。
四、打开文件 fopen()、fclose()关闭文件
在 0 所介绍的文件 I/O 中,使用 open()系统调用打开或创建文件,而在标准 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 以指示错误原因。
新建文件的权限
由 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");
#include <stdio.h>
int fclose(FILE *stream);
参数 stream 为 FILE 类型指针,调用成功返回 0;失败将返回 EOF(也就是-1),并且会设置 errno 来指示错误原因。
五、读文件fread()和写文件fwrite()
当使用 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()函数来判断,具体参考 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 结构体数据写入到文件中,可按如下方式写入:
fwrite(buf, sizeof(struct mystr), 1, file);
使用示例
结合使用本小节与上小节所学内容,我们来编写一个简单地示例代码,使用标准 I/O 方式对文件进行读写操作。示例代码 4.5.1 演示了使用 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()函数关闭文件,退出程序。
#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()来判断是不是发生了错误,该函数将会在 4.7 小节中介绍。如果未发生错误,那么就意味着已经达到了文件末尾,其实也就说明了在调用 fread()读文件时对应的读写位置到文件末尾之间的字节数小于指定的字节数。
六、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://xxxxxxxxxxxxx\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 缓冲区中的字符串数据 http://xxxxxxxxxxxxx\n"写入到文件中;由于调用了fwrite(),所以此时的读写位置已经发生了改变,不再是文件头部,所以程序中调用了 fseek()将读写位置移动到了文件头,接着调用 fread()从文件头部开始读取刚写入的数据,读取成功之后打印出信息。
七、ftell()、feof()、ferror()函数
1、ftell()函数
库函数 ftell()可用于获取文件当前的读写位置偏移量,其函数原型如下所示
#include <stdio.h>
long ftell(FILE *stream);
参数 stream 指向对应的文件,函数调用成功将返回当前读写位置偏移量;调用失败将返回-1,并会设置errno 以指示错误原因。
2、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 {
/* 未到达文件末尾 */
}
3、ferror()函数
库函数 ferror()用于测试参数 stream 所指文件的错误标志,如果错误标志被设置了,则调用 ferror()函数将返回一个非零值,如果错误标志没有被设置,则返回 0。
其函数原型如下所示:
#include <stdio.h>
int ferror(FILE *stream);
当对文件的 I/O 操作发生错误时,错误标志将会被设置。
if (ferror(file)) {
/* 发生错误 */
}
else {
/* 未发生错误 */
}
4、clearerr()函数
库函数 clearerr()用于清除 end-of-file 标志和错误标志,当调用 feof()或 ferror()校验这些标志后,通常需要清除这些标志,避免下次校验时使用到的是上一次设置的值,此时可以手动调用 clearerr()函数清除标志。
clearerr()函数原型如下所示:
#include <stdio.h>
void clearerr(FILE *stream);
八、格式化I/O
1、格式化输出
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, ...);
2、printf()函数
printf("Hello World!\n");
printf("%d\n", 5);
3、fprintf()函数
fprintf(stderr, "Hello World!\n");
fprintf(stderr, "%d\n", 5);
4、dprintf()函数
dprintf(STDERR_FILENO, "Hello World!\n");
dprintf(STDERR_FILENO, "%d\n", 5);
5、sprintf()函数
char buf[100];
sprintf(buf, "Hello World!\n");
char buf[20] = {0};
sprintf(buf, "%d", 100);