本文主要参考文献是正点原子的C应用编程指南,很多内容与正点原子文档中的一致。作为初学者,为了和大家一起学习所以准备写这一系列文章,也是作为自己学习的一个笔记,如果有错误欢迎大家提出来一起讨论。
简介
标准 I/O 库则是标准 C 库中用于文件 I/O 操作(譬如读文件、写文件等)相关的一系列库函数的集合,通常标准 I/O 库函数相关的函数定义都在头文件<stdio.h>中,所以我们需要在程序源码中包含<stdio.h>头文件。标准I/O实际上是对文件I/O(open()、read()、write()、lseek()、close()等)进行了封装,在其中处理更多的细节,比如分配 stdio 缓冲区、以优化的块长度执行 I/O 等。标准I/O和文件IO的区别:
- 虽然标准 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 库在用户空间维护了自己的 stdio 缓冲区,所以标准 I/O 是带有缓存的,而文件 I/O 在用户空间是不带有缓存的,所以在性能、效率上,标准 I/O 要优于文件 I/O
C标准库在不同操作系统上提供了一致的接口(API),但在各个操作系统内部,它们各自实现了不同的底层适配层(调用各自的系统调用)。对于应用程序来说,它只看到一致的接口,所以具备可移植性。
标准输入、标准输出和标准错误
在文件IO函数中,都是围绕文件描述符进行的,后续的操作也是基于文件描述符进行的。对于标准IO库函数来说,操作是围绕FILE指针进行的,当使用标准 I/O 库函数打开或创建一个文件时,会返回一个指向 FILE 类型对象的指针(FILE *),FILE 指针用于后续的标准 I/O 操作。
之前所提到的,进程启动之后都会默认打开标准输入、标准输出以及标准错误,得到三个文件描述符,即 0、1、2,其中 0 代表标准输入、1 代表标准输出、2 代表标准错误。0、1、2 这三个是文件描述符,只能用于文件 I/O库。在标准 I/O 中,自然是无法使用文件描述符来对文件进行 I/O 操作的,它们需要围绕 FILE 类型指针来进行,在 stdio.h 头文件中有相应的定义,其中struct _IO_FILE 结构体就是 FILE 结构体,使用了 typedef 进行了重命名,在标准 I/O 中,可以使用 stdin、stdout、stderr 来表示标准输入、标准输出和标准错误:
c
/* 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
常用库函数介绍
打开文件 fopen()
c
#include <stdio.h>
FILE *fopen(const char *path, const char *mode);
path: 参数 path 指向文件路径,可以是绝对路径、也可以是相对路径。mode: 参数 mode 指定了对该文件的读写权限,是一个字符串。返回值: 调用成功返回一个指向 FILE 类型对象的指针(FILE *),该指针与打开或创建的文件相关联,后续的标准 I/O 操作将围绕 FILE 指针进行。如果失败则返回 NULL,并设置 errno 以指示错误原因。
| mode | 说明 | 对应于 open() 函数的 flags 参数取值 |
|---|---|---|
r |
以只读方式打开文件。 | O_RDONLY |
r+ |
以可读、可写方式打开文件。 | O_RDWR |
w |
以只写方式打开文件。如果文件存在,将文件长度截断为0;如果文件不存在则创建该文件。 | `O_WRONLY |
w+ |
以可读、可写方式打开文件。如果文件存在,将文件长度截断为0;如果文件不存在则创建该文件。 | `O_RDWR |
a |
以只写方式打开文件,以追加模式写入(在文件末尾写入)。如果文件不存在则创建该文件。 | `O_WRONLY |
a+ |
以可读、可写方式打开文件,以追加模式写入(在文件末尾写入)。如果文件不存在则创建该文件。 | `O_RDWR |
补充说明 :使用
w、w+、a、a+模式时,如果文件不存在则会自动创建,新建文件的默认权限为S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH。即所有组可读可写。
关闭文件 fclose()
c
#include <stdio.h>
int fclose(FILE *stream);
读文件 fread()
与文件IO不同的是,可以指定读取多少个字节,而不是只能固定读取1 * 字节数。
c
#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);
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)。
示例代码
c
#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);
}
c
#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);
}
程序中调用了库函数
ferror()来判断是不是发生了错误,是用于判断到底是到达了末尾还是发生了错误的函数。
定位fseek
库函数 fseek() 的作用类似于系统调用 lseek(),用于设置文件读写位置偏移量。lseek() 用于文件 I/O,而库函数 fseek() 则用于标准 I/O。
c
#include <stdio.h>
int fseek(FILE *stream, long offset, int whence);
stream:FILE 指针;
offset:与 lseek() 函数的 offset 参数意义相同;
whence:与 lseek() 函数的 whence 参数意义相同;
返回值 :成功返回 0;发生错误将返回 -1,并且会设置 errno 以指示错误原因;与 lseek() 函数的返回值意义不同。后者成功时会返回偏移值。
获取偏移量ftell
c
#include <stdio.h>
long ftell(FILE *stream);
参数 stream 指向对应的文件,函数调用成功将返回当前读写位置偏移量;调用失败将返回-1,并会设置 errno 以指示错误原因。
测试end of file标志 feof
当文件的读写位置移动到了文件末尾时,end-of-file 标志将会被设置。此时可以通过库函数 feof()来检查目前的偏移位置是否到达了文件末尾。
c
#include <stdio.h>
int feof(FILE *stream);
if (feof(file)) {
/* 到达文件末尾 */
}
else {
/* 未到达文件末尾 */
}
测试文件错误标志 ferror
库函数 ferror() 用于测试参数 stream 所指文件的错误标志,如果错误标志被设置了,则调用 ferror() 函数将返回一个非零值,如果错误标志没有被设置,则返回 0。
c
#include <stdio.h>
int ferror(FILE *stream);
if (ferror(file)) {
/* 发生错误 */
}
else {
/* 未发生错误 */
}
清除文件错误标志 clearerr
当调用 feof() 或 ferror() 校验这些标志后,通常需要清除这些标志,避免下次校验时使用到的是上一次设置的值,此时可以手动调用 clearerr() 函数清除标志。
对于 end-of-file 标志,除了使用 clearerr() 显式清除之外,当调用 fseek() 成功时也会清除文件的 end-of-file 标志。
c
#include <stdio.h>
void clearerr(FILE *stream);
函数没有返回值,调用将总是会成功。
IO缓冲
之前的文章中提到了,系统调用 open 函数后,并不会直接从磁盘中取磁盘的文件,而是由内核申请一段内存作为缓冲区,以提高读写数据的效率,这段缓冲区就叫内核缓冲区。read() 和 write() 系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区之间复制数据。
当调用 write()之后,内核稍后会将数据写入到磁盘设备中,具体是什么时间点写入到磁盘,是不确定的,由内核根据相应的存储算法自动判断。Linux 内核本身对内核缓冲区的大小没有固定上限。内核会分配尽可能多的内核来作为文件 I/O 的内核缓冲区,但受限于物理内存的总量,如果系统可用的物理内存越多,那自然对应的内核缓冲区也就越大,操作越大的文件也要依赖于更大空间的内核缓冲。
使用虚拟机时,其中的内存就被分为两个部分,一个是用户空间内存,一个是内核内存,系统运行时的具体内存分配情况由系统判断。
操控文件IO的内核缓冲区
某些应用程序在执行某些流程之前,必须要保证前置条件是否满足,比如 write 写入文件后,需要确认是否真的已经将数据写入到文件了。在实际中:当我们在 Ubuntu 系统下拷贝文件到 U 盘时,文件拷贝完成之后,通常在拔掉 U 盘之前,需要执行 sync 命令进行同步操作,这个同步操作其实就是将文件 I/O 内核缓冲区中的数据更新到 U 盘硬件设备,所以如果在没有执行 sync 命令时拔掉 U 盘,很可能就会导致拷贝到 U 盘中的文件遭到破坏。
fsync() 函数
系统调用 fsync() 将参数 fd 所指文件的内容数据和元数据 写入磁盘,只有在对磁盘设备的写入操作完成之后,fsync() 函数才会返回。
c
#include <unistd.h>
int fsync(int fd);
参数 fd 表示文件描述符,函数调用成功将返回 0,失败返回 -1 并设置 errno 以指示错误原因。
元数据并不是文件内容本身的数据,而是一些用于记录文件属性相关的数据信息,譬如文件大小、时间戳、权限等等信息,这里统称为文件的元数据,这些信息也是存储在磁盘设备中的。
fdatasync() 函数
系统调用 fdatasync() 与 fsync() 类似,不同之处在于 fdatasync() 仅将参数 fd 所指文件的内容数据写入磁盘,并不包括文件的元数据;同样,只有在对磁盘设备的写入操作完成之后,fdatasync() 函数才会返回。只有函数名字不一样,其它一致。
sync() 函数
系统调用 sync() 会将所有文件 I/O 内核缓冲区中的文件内容数据和元数据全部更新到磁盘设备中,该函数没有参数、也无返回值,意味着它不是对某一个指定的文件进行数据更新,而是刷新所有文件 I/O 内核缓冲区。调用sync()函数仅在所有数据已经写入到磁盘设备之后才会返回。
c
#include <unistd.h>
void sync(void);
关于内核缓冲的标志
- 在调用
open()函数时,指定O_DSYNC标志,其效果类似于在每个write()调用之后调用fdatasync()函数进行数据同步。 - 在调用
open()函数时,指定O_SYNC标志,使得每个write()调用都会自动将文件内容数据和元数据刷新到磁盘设备中,其效果类似于在每个write()调用之后调用fsync()函数进行数据同步。
在程序中频繁调用
fsync()、fdatasync()、sync()(或者调用open()时指定O_DSYNC或O_SYNC标志)对性能的影响极大,会导致程序总是在等待同步,因此没有必要的情况下可以不用。
直接I/O
Linux 允许应用程序在执行文件 I/O 操作时绕过内核缓冲区,从用户空间直接将数据传递到文件或磁盘设备,把这种操作也称为直接 I/O。
有些情况下,这种操作通常是很有必要的,例如,某应用程序的作用是测试磁盘设备的读写速率,那么在这种应用需要下,我们就需要保证 read/write 操作是直接访问磁盘设备,而不经过内核缓冲,如果不能得到这样的保证,必然会导致测试结果出现比较大的误差。
除此之外,一般不使用直接I/O,因为系统会对内核缓冲区做许多优化,如果使用直接I/O会导致数据传输速率大大下降。
块对齐
直接 I/O 涉及到对磁盘设备的直接访问,所以需要块对齐,即:
- 应用程序中用于存放数据的缓冲区,其内存起始地址必须以块大小的整数倍进行对齐;
- 写文件时,文件的位置偏移量必须是块大小的整数倍;
- 写入到文件的数据大小必须是块大小的整数倍。
不同磁盘的块大小是可能不同的,可以通过 tune2fs -l /dev/sda1 | grep "Block size"命令查看根文件系统挂载磁盘的块大小。
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 内核缓冲,这是由内核 维护的缓冲区,而标准 I/O 所维护的 stdio 缓冲是用户空间的缓冲区。当应用程序中通过标准 I/O 操作磁盘文件时,为了减少调用系统调用的次数,标准 I/O 函数会将用户写入或读取文件的数据缓存在 stdio 缓冲区,然后再一次性将 stdio 缓冲区中缓存的数据通过调用系统调用 I/O(文件 I/O)写入到文件 I/O 内核缓冲区或者拷贝到应用程序的 buf 中。通过这样的优化操作,当操作磁盘文件时,在用户空间缓存大块数据以减少调用系统调用的次数,使得效率、性能得到优化。
C 语言提供了一些库函数可用于对标准 I/O 的 stdio 缓冲区进行相关的一些设置。
setvbuf()函数
调用 setvbuf()库函数可以对文件的 stdio 缓冲区进行设置,譬如缓冲区的缓冲模式、缓冲区的大小、起始地址等。
c
#include <stdio.h>
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
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默认属于这一种类型,从而保证错误信息能够立即输出。
标准错误
stderr也是属于标准I/O流(FILE),也是一个文件,缓冲是标准 I/O 库(stdio)对所有流的管理机制。
_IOLBF:采用行缓冲 I/O。在这种情况下,当在输入或输出中遇到换行符"\n"时,标准 I/O 才会执行文件 I/O 操作。对于输出流,在输出一个换行符前将数据缓存(除非缓冲区已经被填满),当输出换行符时,再将这一行数据通过文件 I/Owrite()函数刷入到内核缓冲区中;对于输入流,每次读取一行数据。对于终端设备默认采用的就是行缓冲模式,譬如标准输入和标准输出。 因此如果使用printf打印输出,在结尾没有使用换行符则会导致无法正常输出,数据一直停留在用户空间缓冲区,除非缓冲区已满。_IOFBF:采用全缓冲 I/O。在这种情况下,在填满 stdio 缓冲区后才进行文件 I/O 操作(read、write)。对于输出流,当fwrite写入文件的数据填满缓冲区时,才调用write()将 stdio 缓冲区中的数据刷入内核缓冲区;对于输入流,每次读取 stdio 缓冲区大小个字节数据。默认普通磁盘上的常规文件默认常用这种缓冲模式。
size:指定缓冲区的大小。
返回值 :成功返回 0,失败将返回一个非 0 值,并且会设置 errno 来指示错误原因。
需要注意的是,当 stdio 缓冲区中的数据被刷入到内核缓冲区或被读取之后,这些数据就不会存在于用户空间缓冲区中了,数据被刷入了内核缓冲区或被读走了。
setbuf()函数
c
#include <stdio.h>
void setbuf(FILE *stream, char *buf);
没有返回值,作用相当于等于 setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ);,即如果buf为NULL则表示无缓冲,如果有值则分配BUFSIZ字节大小的缓冲区,这个缓冲区的大小可以在stdio.h的头文件中设置。
setbuffer()函数
c
#include <stdio.h>
void setbuffer(FILE *stream, char *buf, size_t size);
没有返回值,作用相当于等于 setvbuf(stream, buf, buf ? _IOFBF : _IONBF, size);允许调用者指定 buf 缓冲区的大小。
刷新stdio缓冲区
可以使用库函数 fflush() 来强制刷新(通过 write() 函数,将输出到 stdio 缓冲区中的数据写入到内核缓冲区)stdio 缓冲区,该函数会刷新指定文件的 stdio 输出缓冲区。
在一些其它的情况下,也会自动刷新 stdio 缓冲区,譬如当文件关闭时、程序退出时。如果使用
exit()、return或像上述示例代码一样不显式调用相关函数或执行return语句来结束程序,这些情况下程序终止时会自动刷新 stdio 缓冲区;如果使用_exit或_Exit()终止程序则不会刷新。这和之前所提到的对应上了,因为使用_exit或_Exit()终止程序会强制退出,而不会刷新缓存区。
c
#include <stdio.h>
int fflush(FILE *stream);
参数 stream 指定需要进行强制刷新的文件,如果该参数设置为 NULL,则表示刷新所有的 stdio 缓冲区。 函数调用成功返回 0,否则将返回 -1,并设置 errno 以指示错误原因。
文件描述符和FILE指针混用
在同一个文件上执行 I/O 操作时,还可以将文件 I/O与标准 I/O 混合使用,这个时候我们就需要将文件描述符和 FILE 指针对象之间进行转换,此时可以借助于库函数 fdopen()、fileno() 来完成。
c
#include <stdio.h>
int fileno(FILE *stream);
FILE *fdopen(int fd, const char *mode);
对于 fileno() 函数来说,根据传入的 FILE 指针得到整数文件描述符,通过返回值得到文件描述符,如果转换错误将返回 -1,并且会设置 errno 来指示错误原因。得到文件描述符之后,便可以使用诸如 read()、write()、lseek()、fcntl() 等文件 I/O 方式操作文件。
fdopen() 函数与 fileno() 功能相反,给定一个文件描述符,得到该文件对应的 FILE 指针,之后便可以使用诸如 fread()、fwrite() 等标准 I/O 方式操作文件了。参数 mode 与 fopen() 函数中的 mode 参数含义相同,若该参数与文件描述符 fd 的访问模式不一致,则会导致调用 fdopen() 失败。
为什么前者不需要传mode,而后者会需要?
我查了一些资料,得到以下的猜想:首先
FILE指针指向的内存中本身就保存了文件描述符,使用这个函数可以直接提取出来,因此不需要其它操作。而文件标识符fd本身只是一个整型变量,这个标识符对应着一个文件表,这个文件表中确实存有open函数打开时的对应标志信息,只是fdopen函数没有使用fcntl(fd, F_GETFL)把这个标志调出来一键实现,至于为什么这个暂时没有查到资料。
当混合使用文件 I/O 和标准 I/O 时,需要特别注意缓冲的问题,文件 I/O 会直接将数据写入到内核缓冲 区进行高速缓存,而标准 I/O 则会将数据写入到 stdio 缓冲区,之后再调用 write()将 stdio 缓冲区中的数据写 入到内核缓冲区。
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("print");
write(STDOUT_FILENO,"write\n",6);
exit(0);
}
使用
printf这个C库函数后,因为默认是行缓冲,因此用户缓冲区中的数据始终没有被送到内核缓冲区中,一直留在用户缓冲区。而write直接调用了系统调用函数,因此直接陷入内核态,将数据存到内核缓冲区,并写入了磁盘。运行exit后,系统会先对各个缓冲区的数据进行传输,此时print这个字符才被传到了内核缓冲区,再到磁盘内。