C语言中的标准IO

本文对 Linux 下C语言的标准IO进行总结,所有代码示例均在 Ubuntu-20.04、GCC 11.3.0 环境下运行通过。

标准IO中的一些概念

流和FILE对象

在 Linux 操作系统中,提供给用户操作文件的接口是“文件描述符”以及对应的函数,例如 read,write等。而在C语言中,提供给用户的文件操作的接口是“流(stream)”,当使用C语言中的标准I/O库打开或创建一个文件时,就使得一个流与一个文件关联起来。而这个 “流” 的概念,在程序上,使用 FILE 对象来表示,例如:

#include 

int main()
{
	// 打开或者创建一个文件,使用 FILE 对象与该文件进行绑定。
	// 也常把 fp 叫为 文件流
	FILE* fp = fopen("example.txt", "a");
	// ...
}

注意:一个进程预定了三个流,标准输入、标准输出和标准错误。而本文的重点讨论对象是 文件流,也即 FILE 对象以及标准I/O中提供的操作 FILE 对象的一系列函数。

流的定向(stream’s orientation)

对于ASCII字符集,一个字符用一个字节表示。对于国际字符集,一个字符可用多个字节表示。标准I/O文件流可用于单字节或多字节(也称“宽”字符)字符集。流的定向(stream’s orientation)决定所读、写的字符是单字节还是多字节的。

下面给出一个宽字符集输出到标准输出的示例:

#include 
#include 

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
*/

注意:下文讨论的都是单字节的流向。

缓冲(buffer)

在 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 提供了三种缓冲类型:

  • 全缓冲。对于写操作,当缓冲区写满后,才将缓冲中的数据写入文件;读操作同理。对于写操作,可以调用 flush 函数主动将缓冲中的数据写入文件而不论缓冲是否被写满。下文将调用 flush 函数的操作称为 ”刷新缓冲“。
  • 行缓冲。当读或写数据遇到换行符时,将缓冲中的数据进行输入或输出。行缓冲的一个限制是:当缓冲已满,即使未遇到换行符,也将其进行输入输出。
  • 不带缓冲。即I/O操作直接写入文件。

I/O标准库中常用的函数

文件流的打开和关闭

下面三个函数可用于文件流的打开,其中 fopen 最为常用,先重点介绍该函数,剩余两个当遇到具体的使用场景时再来补充。在 Linux 中是可以使用 man 命令查看详情。

#include 

/*
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 参数的值及表示的读、写方式如下所示:

  • rrb,以读的方式打开。
  • wwb,以写的方式打开;若指定文件名存在,则将该文件内容清空;不存在,创建新文件。
  • aab,以追加写的方式打开文件。若指定文件名不存在,创建新文件。
  • rr+brb+,以读写的方式打开文件;指定文件名不存在,则出错。
  • w+w+bwb+,以读写的方式打开文件;若指定文件名存在,则将该文件内容清空;不存在,创建新文件。
  • a+a+bab+,以读写的方式打开文件,读写操作在文件尾开始进行,若指定文件名不存在,创建新文件。

使用字符b作为type的一部分,使得标准I/O系统可以区分为文本文件和二进制文件。UNIX不对这两种文件进行区分。
– 《UNIX 高级环境编程》

使用 fopen 函数开打的文件流默认是自带缓冲的,缓冲模式为全缓冲。

fclose 函数用于关闭一个打开的文件流。注意,对于一个已经关闭了的文件流调用 fclose 函数,行为是未定义的。

#include 

/*
若成功,返回0;若出错,返回 EOF
*/
int fclose(FILE* fp);

当调用 fclose 函数或者当一个进程正常终止(调用 exit 函数或从 main 函数返回),会先刷新缓冲。若是使用标准IO默认的缓冲,则会释放缓冲。

给文件流设置自定义的缓冲

若希望自己掌控文件流的缓冲,可以自定义一个缓冲,将其于打开的文件流的进行绑定。主要有如下三个函数可以绑定自定义的缓冲:

#include 

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)
  • 若 buf 参数为空,则文件流为无缓冲。

在使用文件流的进行文件操作时,全缓冲的缓冲模式用得最多,因此推荐使用 setbuffer ,不用费力去记缓冲模式的参数值。

文件流的读写

打开文件流后,有三种类型的非格式化I/O操作:

  • 每次读写一个字符。一次读写一个字符。
  • 每次读写一行。一次读写一行,每一行以换行符终止。
  • 直接读写,即指定读写的字节数。每次IO操作读写某种类型的对象,每个对象具有指定的长度。

读写一个字符

对于读一个字符,有如下三个函数可供选择:

#include 

int getc(FILE* fp);

int fgetc(FILE* fp);

int getchar(void);

/*
以上三个函数,成功返回读取的字符,返回前将 unsigned char 类型转换为 int 类型;若已到达文件尾端或出错,返回 EOF。
*/

对上面三个函数进行补充说明:

  • getchar(void) 等价于 getc(stdin)
  • 在《UNIX 环境高级编程》中,” getc 函数可能被实现为宏,而 fgetc 一定为函数“。因此推荐使用 fget 函数,因为宏定义的参数存在副作用。

对于上述三个函数的出错,在文件流 FILE 对象中,每个 FILE 对象维护了两个标志:

  • 出错标志;
  • 文件结束标志;

可以使用 ferrorfeof 函数进行检查:

#include 

/* 检查 fp 指定的流是否发生了错误。若为真,则返回非0;否则,返回0 */
int ferror(FILE* fp);

/* 检查 fp 指定的流是否到达文件尾。若为真,则返回非0;否则,返回0 */
int feof(FILE* fp);

/* 清楚上述两个标志 */
void clearerr(FILE* fp);

在使用文件流进行文件操作时,一个好的编码习惯是,使用 ferror 函数检测读写后的文件流状态。


对于写一个字符,有如下三个函数可供选择:

#include 

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 

// n 为指定的缓冲区大小
// 将 fp 文件中的内容写入 buf 中,直至遇到换行符或者buf写满。
char* fgets(char* buf, int n, FILE* fp);

// gets 从标准输入进行读
char* gets(char* buf);

/*
以上两个函数,若成功,返回buf;若已达到文件末尾或出错,返回NULL。
*/

gets 函数不能指定缓冲区大小,建议只使用 fgets 函数。


对于写一行,有以下两个函数可供选择:

#include 

// fputs 不会将换行符写入到文件流中
int fputs(const char* str, FILE* fp);

// puts 将字符串输出到标准输出,会将换行符作为输出。
int puts(const char* str);

/*
以上两个函数,若成功,返回非负责;若出错,返回 EOF。
*/

建议只是用 fputs 函数。

直接读写

在《UNIX环境高级编程》一书中,直接读写也即二进制读写,指一次读写一个完整的结构,例如一个结构体对象。通常用来读写指定的字节大小的数据。

常用的直接读写的函数如下:

#include 

// 若出错或到达文件末尾,返回值可以小于 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 五个函数,下面介绍最常用的 printffprintf 函数。

#include 

int printf(const char* format, ...);

int fprintf(FILE* fp, const char* format, ...);

/*
以上两个函数,成功,返回输出的字符数;出错,返回复制。
format 表示格式化字符串;
... 为C语言的可变参数,需要与 format 中的格式化进行匹配。
*/

格式化输入常用的有如 scanf, fscanfsscanf

对于标准库中格式化读写的更多细节,太过琐碎,可参考:https://en.cppreference.com/w/cpp/io/c/fscanf

多线程的安全性

上述的章节中介绍的文件流的读写函数都是线程安全的,会在正常进行磁盘文件读写时进行加锁操作。标准库中也提供非线程安全的版本,它们都以 _unlocked 后缀结尾。例如对于 fread 和 fwrite 的非线程安全版本为 fread_unlocked 和 fwrite_unlocked。

(留个坑,待补一个多线程环境下,使用 fread_unlocked 和 fwrite_unlocked 读写文件造成 bug 的示例)

你可能感兴趣的:(cpp,linux,c语言,开发语言,c++)