本文对 Linux 下C语言的标准IO进行总结,所有代码示例均在 Ubuntu-20.04、GCC 11.3.0 环境下运行通过。
在 Linux 操作系统中,提供给用户操作文件的接口是“文件描述符”以及对应的函数,例如 read,write等。而在C语言中,提供给用户的文件操作的接口是“流(stream)”,当使用C语言中的标准I/O库打开或创建一个文件时,就使得一个流与一个文件关联起来。而这个 “流” 的概念,在程序上,使用 FILE 对象来表示,例如:
#include <stdio.h>
int main()
{
// 打开或者创建一个文件,使用 FILE 对象与该文件进行绑定。
// 也常把 fp 叫为 文件流
FILE* fp = fopen("example.txt", "a");
// ...
}
注意:一个进程预定了三个流,标准输入、标准输出和标准错误。而本文的重点讨论对象是 文件流,也即 FILE 对象以及标准I/O中提供的操作 FILE 对象的一系列函数。
对于ASCII字符集,一个字符用一个字节表示。对于国际字符集,一个字符可用多个字节表示。标准I/O文件流可用于单字节或多字节(也称“宽”字符)字符集。流的定向(stream’s orientation)决定所读、写的字符是单字节还是多字节的。
下面给出一个宽字符集输出到标准输出的示例:
#include <iostream>
#include <cstring>
int main() {
const char* charString = "你好,世界!";
std::cout << "Length of charString: " << std::strlen(charString) << std::endl;
const char* multibyteString = u8"你好,世界!"; // UTF-8编码的多字节字符串
std::cout << "Length of multibyteString: " << std::strlen(multibyteString) << std::endl;
const wchar_t* wideString = L"你好,世界!"; // UTF-16编码的宽字符字符串
std::wcout << L"Length of wideString: " << std::wcslen(wideString) << std::endl;
return 0;
}
/*
运行结果为:
Length of charString: 18
Length of multibyteString: 18
Length of wideString: 6
*/
注意:下文讨论的都是单字节的流向。
在 Linux 中,使用 read、write 函数对文件描述符进行读写操作属于系统调用,用于直接读写磁盘文件。(其实也不是直接读写读写磁盘文件,Linux内核中会维护高速缓冲区用于提高磁盘文件的读写效率,这部分内容超出的本文的讨论范畴,略过。)而C语言I/O标准库提供的I/O操作在用户态,通常带有缓冲(当然,也可以没有缓冲),使用标准I/O库提供的I/O操作先将数据写入缓冲中,然后等待某个条件达成,在将缓冲中的数据写入磁盘文件(调用 write 函数)。标准I/O库提供缓冲的目的是减少 read 和 write 调用的次数,提高 I/O 效率。(而在实际的开发中,I/O缓冲的利用需要根据实际的场景来使用,并不是说有了缓冲,I/O效率就提高了。)
标准 I/O 提供了三种缓冲类型:
下面三个函数可用于文件流的打开,其中 fopen 最为常用,先重点介绍该函数,剩余两个当遇到具体的使用场景时再来补充。在 Linux 中是可以使用 man 命令查看详情。
#include <stdio.h>
/*
pathname参数表示打开的文件路径名;
type参数指定对文件流的读、写方式。
若打开出错,返回 NULL。
*/
FILE* fopen(const char* pathname, const char* type);
FILE* freopen(const char* pathname, const char* type, FILE* fp);
FILE* fdopen(int fd, const char* type);
对于 fopen
函数,type
参数的值及表示的读、写方式如下所示:
r
或 rb
,以读的方式打开。w
或 wb
,以写的方式打开;若指定文件名存在,则将该文件内容清空;不存在,创建新文件。a
或 ab
,以追加写的方式打开文件。若指定文件名不存在,创建新文件。r
或 r+b
或 rb+
,以读写的方式打开文件;指定文件名不存在,则出错。w+
或 w+b
或 wb+
,以读写的方式打开文件;若指定文件名存在,则将该文件内容清空;不存在,创建新文件。a+
或 a+b
或 ab+
,以读写的方式打开文件,读写操作在文件尾开始进行,若指定文件名不存在,创建新文件。使用字符b作为type的一部分,使得标准I/O系统可以区分为文本文件和二进制文件。UNIX不对这两种文件进行区分。
– 《UNIX 高级环境编程》
使用 fopen 函数开打的文件流默认是自带缓冲的,缓冲模式为全缓冲。
fclose
函数用于关闭一个打开的文件流。注意,对于一个已经关闭了的文件流调用 fclose 函数,行为是未定义的。
#include <stdio.h>
/*
若成功,返回0;若出错,返回 EOF
*/
int fclose(FILE* fp);
当调用 fclose 函数或者当一个进程正常终止(调用 exit 函数或从 main 函数返回),会先刷新缓冲。若是使用标准IO默认的缓冲,则会释放缓冲。
若希望自己掌控文件流的缓冲,可以自定义一个缓冲,将其于打开的文件流的进行绑定。主要有如下三个函数可以绑定自定义的缓冲:
#include <stdio.h>
void setbuf(FILE* fp, char* buf);
/*
buf参数为指定缓冲区,mode表示缓冲类型,size指定了缓冲的大小。
成功返回0;出错返回非0。
*/
void setvbuf(FILE* fp, char* buf, int mode, size_t size);
void setbuffer(FILE* fp, char* buf, size_t size);
mode
参数的可选值如下:
_IOFBF
,全缓冲。_IOLBF
,行缓冲。_IONBF
,无缓冲。对上面三个函数进行如下补充说明:
setbuf
等价于 setvbuf(fp, buf, buf ? _IOFBF : _IONBF, BUFFSIX)
;其中, BUFZSIZE 在标准库的默认值,我的环境下为 8096。setbuffer
等价于 setvbuf(fp, buf, buf ? _IOFBF : _IONBF, size)
。在使用文件流的进行文件操作时,全缓冲的缓冲模式用得最多,因此推荐使用 setbuffer
,不用费力去记缓冲模式的参数值。
打开文件流后,有三种类型的非格式化I/O操作:
对于读一个字符,有如下三个函数可供选择:
#include <stdio.h>
int getc(FILE* fp);
int fgetc(FILE* fp);
int getchar(void);
/*
以上三个函数,成功返回读取的字符,返回前将 unsigned char 类型转换为 int 类型;若已到达文件尾端或出错,返回 EOF。
*/
对上面三个函数进行补充说明:
getchar(void)
等价于 getc(stdin)
。getc
函数可能被实现为宏,而 fgetc
一定为函数“。因此推荐使用 fget
函数,因为宏定义的参数存在副作用。对于上述三个函数的出错,在文件流 FILE 对象中,每个 FILE 对象维护了两个标志:
可以使用 ferror
和 feof
函数进行检查:
#include <stdio.h>
/* 检查 fp 指定的流是否发生了错误。若为真,则返回非0;否则,返回0 */
int ferror(FILE* fp);
/* 检查 fp 指定的流是否到达文件尾。若为真,则返回非0;否则,返回0 */
int feof(FILE* fp);
/* 清楚上述两个标志 */
void clearerr(FILE* fp);
在使用文件流进行文件操作时,一个好的编码习惯是,使用 ferror
函数检测读写后的文件流状态。
对于写一个字符,有如下三个函数可供选择:
#include <stdio.h>
int putc(int c, FILE* fp);
int fputc(int c, FILE* fp);
// putchar(c) 等价于 putc(c, stdout);
int putchar(int c);
/*
以上三个函数,成功,返回c;若出错,返回EOF。
*/
和 getc、fgetc 类似,putc 可能实现为宏,fputc 被定义为一个函数,因此推荐使用 fputc。
下面给出几个编码示例:
假设文件中的内容为 python
,一个字符一个字符的把文件中的内容输出到标准输出。
FILE* fp = fopen("example.txt", "a+");
int c;
while ((c = fgetc(fp)) != EOF) {
printf("%c", (unsigned char)c);
}
fclose(fp);
/*
输出为:python
*/
假设文件中的内容为 python
,一个字符一个字符写入文件。
FILE* fp = fopen("example.txt", "a+");
char buf[7] = "golang";
for (int i = 0; i < 6; ++i) {
fputc(buf[i], fp);
}
fclose(fp);
/*
文件中的内容为:pythongolang
*/
(以上只是简单的编码示例,更复杂的操作可以结合文件流的定位来进行,碰到实际的场景在来补充。)
对于读一行,有以下两个函数可供选择:
#include <stdio.h>
// n 为指定的缓冲区大小
// 将 fp 文件中的内容写入 buf 中,直至遇到换行符或者buf写满。
char* fgets(char* buf, int n, FILE* fp);
// gets 从标准输入进行读
char* gets(char* buf);
/*
以上两个函数,若成功,返回buf;若已达到文件末尾或出错,返回NULL。
*/
gets 函数不能指定缓冲区大小,建议只使用 fgets 函数。
对于写一行,有以下两个函数可供选择:
#include <stdio.h>
// fputs 不会将换行符写入到文件流中
int fputs(const char* str, FILE* fp);
// puts 将字符串输出到标准输出,会将换行符作为输出。
int puts(const char* str);
/*
以上两个函数,若成功,返回非负责;若出错,返回 EOF。
*/
建议只是用 fputs 函数。
在《UNIX环境高级编程》一书中,直接读写也即二进制读写,指一次读写一个完整的结构,例如一个结构体对象。通常用来读写指定的字节大小的数据。
常用的直接读写的函数如下:
#include <stdio.h>
// 若出错或到达文件末尾,返回值可以小于 nobj;需要调用 ferror 或 feof 来判断是哪一种情况。
size_t fread(void* ptr, size_t size, size_t nobj, FILE* fp);
// 若出错,返回值小于 nobj
size_t fwrite(const void* ptr, size_t size, size_t nobj, FILE* fp);
/*
以上两个函数,返回读写的对象数量。
ptr 指向待写入的对象
size 表示对象的大小
nobj 表示写入的对象数量
*/
下面给出几个编码示例:
读写char数组形式的字符串。
char buffer[16] = "python";
FILE* wfp = fopen("test2.txt", "w");
fwrite(buffer, 1, strlen(buffer), wfp);
fclose(wfp);
printf("%s\n", buffer);
char buffer2[16];
FILE *rfp = fopen("test2.txt", "r");
fread(buffer2, 1, strlen(buffer), rfp);
fclose(rfp);
printf("%s\n", buffer2);
使用 fread 和 fwrite 读写一个类的示例对象(有bug)。(无意中测试出来的一个bug,暂未解决。一个初步的思路为,需要去学习了解 C++ 的对象模型,即一个C++的对象在内存中是如何布局的,然后在深入 fread 和 fwrite 的源码中,去了解,其底层是如何读写的。在此文中先留个坑,后面再来填补)
void test1()
{
Person p1("Jack", 25);
FILE* wfp = fopen("Person", "wb");
fwrite((void*)&p1, sizeof(Person), 1, wfp);
fclose(wfp);
Person* p2 = new Person("Lisa", 19);
std::cout << p2->name() << std::endl;
FILE* rfp = fopen("Person", "rb");
fread(p2, sizeof(Person), 1, rfp);
fclose(rfp);
std::cout << p2->name() << std::endl;
// delete p2; /* 若在这行执行此语句,出现 Segmentation fault */
Person p3;
FILE* rfp2 = fopen("Person", "rb+");
fread(&p3, sizeof(Person), 1, rfp2);
fclose(rfp2);
std::cout << p3.name() << std::endl;
/* 程序结束,Segmentation fault */
}
格式化输出常用的有 printf
, fprintf
, dprintf
, sprintf
, snprintf
五个函数,下面介绍最常用的 printf
和 fprintf
函数。
#include <stdio.h>
int printf(const char* format, ...);
int fprintf(FILE* fp, const char* format, ...);
/*
以上两个函数,成功,返回输出的字符数;出错,返回复制。
format 表示格式化字符串;
... 为C语言的可变参数,需要与 format 中的格式化进行匹配。
*/
格式化输入常用的有如 scanf
, fscanf
和 sscanf
。
对于标准库中格式化读写的更多细节,太过琐碎,可参考:https://en.cppreference.com/w/cpp/io/c/fscanf
上述的章节中介绍的文件流的读写函数都是线程安全的,会在正常进行磁盘文件读写时进行加锁操作。标准库中也提供非线程安全的版本,它们都以 _unlocked
后缀结尾。例如对于 fread 和 fwrite 的非线程安全版本为 fread_unlocked 和 fwrite_unlocked。
(留个坑,待补一个多线程环境下,使用 fread_unlocked 和 fwrite_unlocked 读写文件造成 bug 的示例)