深入浅出 C 语言标准 I/O
C 语言 I/O 的本质
I/O(input/output),即输入输出,通常指数据在存储器或其他周边设备之间的输入输出
- 输入指的是数据从外部设备(如键盘、磁盘、网卡)拷贝到内存中
- 输出指的是数据从内存拷贝到外部设备中
由于外部设备和内存的不同的规范,C 语言不太可能为每一套设备都写一套 I/O,因此,C 语言 I/O 不直接操作外部设备和内存,而是操作流(Stream)
在 C 语言中,流的表现形式为FILE*,FILE实际上是一个结构体,里面记录了文件的读写位置,缓冲区状态,以及错误标记等。同时,C 语言通过FILE*操作流,C 语言的标准输入输出库(stdio.h)中通过FILE*为我们定义了三种常用的流
-
stdin:标准输入流(Standard Input Stream),默认指向键盘- 典型函数:
scanf()、getchar()
- 典型函数:
-
stdout:标准输出流(Standard Output Stream),默认指向显示器,- 典型函数:
printf()、putchar()
- 典型函数:
-
stderr:标准错误流(Standard Error Stream),默认指向显示器- 典型函数:
perror()
- 典型函数:
标准输入输出
格式化 I/O
格式化输出printf()
格式化输出使用printf()(print format),其函数原型一般定义如下
1 | int __cdecl printf( const char * __restrict__ _Format,... ); |
函数还有一个返回值,表示成功打印的字符总数,如果出错,会返回一个负数
关于格式化字符,可以参考笔者的这篇文章 C语言中的格式化输出和转义字符
有关格式化字符串,我们还可以了解下面两个函数
1 | int sprintf( char* restrict buffer, const char* restrict format, ... ); |
这两个函数可以将格式化字符串存入到字符串buffer中,区别是snprintf()可以指定存入的长度,如果写入失败,则返回EOF
1 |
|
格式化输入scanf()
格式化输入使用scanf()(scan format),其函数原型如下
1 | int scanf( const char *restrict format, ... ); |
函数的返回值表示成功接收值的变量的个数
一般来说,我们在使用scanf()函数时,都要检查变量是否都被分配了值,比如
1 | int a, b; |
scanf()会忽略空白字符(如空格,换行,制表符等),如果读取数字,还会忽略前导 0,并从第一个数字字符(0-9)开始读取(并转化为有效数字),到下一个空白字符结束
以下面的代码举例
1 |
|
如果输入a,那么输出为
1 | num = -469536704, a = 0 |
这里 num 的值与字符 a 没有任何关系,它表示一个垃圾值,a = 0表示num没能被分配一个值
如果输入 010,那么输出为
1 | num = 10, a = 1 |
可以看到,scanf()忽略了前导空白字符和前导 0,而直接读取了 10,a=1表示成功读取
字符 I/O
对于字符的输出输出,我们不需要使用printf()和scanf(),毕竟用它们处理字符过于“大材小用”了
比如在输出换行时,可以直接采用putchar('\n'),在读取一个字符时,也可以直接使用char a = getchar(),这样会使得代码更加简洁
这里需要注意的是,putchar()和getchar()返回或接收的都是int类型,可以直接对应字符的 ASCII 码值,但是当输出或输入失败的时候,会返回EOF(一般是 -1)
EOF(End Of File):当
scanf(),getchar()等读取数据时发现没东西可读时,会返回 EOF,这时一个定义在stdio.h中的宏,通常为 -1。在 Windows 中,我们可以通过Ctrl + Z(Linux/MaxOS 是Ctrl + D)模拟发送一个 EOF
接下来可移步至 缓冲机制
字符串 I/O
字符串输入
当我们想读取含有空格的字符串时,scanf()似乎不太能实现,通过循环 + getchar() 又过于繁琐,这里我们可以使用 fgets() 函数,其函数原型如下
1 | char* fgets( char* restrict str, int count, FILE* restrict stream ); |
参数
-
str:字符串 -
count:字符数组的长度(包括最后的\0,即字符串的长度 + 1) -
stream:输入流(标准输入流或文件流)
如果读取成功,返回str,否则返回空指针
需要注意的是,有的读者可能见过gets()函数,但这个函数及不安全,且在现代 C 标准中已经被废弃了
字符串输出
字符串输出可以使用puts()和fputs()函数,它们的函数原型如下
1 | int puts( const char* str ); |
这两个函数最大的区别是前者不需要指定输出流(默认为stdout)
除此之外,还需要知道的是,它们都以\0作为字符串的结束,但是puts()会在结束时多加一个\n
如果输出成功,返回一个非负数,否则返回EOF
文件输入输出
打开和关闭文件
使用fopen()函数可以创建一个文件流指针(全缓冲,如果失败,返回空指针),函数原型如下
关于缓冲机制,参见 缓冲机制
1 | FILE *fopen( const char *restrict filename, const char *restrict mode ); |
-
filename:要打开的文件的路径 -
mode:文件的打开方式,常用的打开方式如下
| 模式 | 描述 |
|---|---|
r |
只读模式 |
w |
只写模式,如果文件不存在,则创建该文件;如果存在,则会将原文件清空 |
a |
追加模式,如果文件不存在,则创建该文件 |
r+ |
读写模式 |
w+ |
读写模式,果文件不存在,则创建该文件;如果存在,则会将原文件清空 |
a+ |
读写模式,果文件不存在,则创建该文件;如果存在,则在原文件后追加内容 |
特殊的,如果处理的是二进制文件,则在后面加b,如rb,r+b(rb+)
在文件操作结束后,要用fclose()函数关闭文件,将缓冲区数据写入磁盘,如
1 | FILE *fp = fopen("./test.txt", "w"); |
读取和写入文件
在前面的标准输入输出部分,我们fgets()和fputs()可以指定输入流和输出流,因此,只要将流换成文件流就可以实现文件的 I/O
此外,我们还可以用fprintf()和fscanf()进行格式化读取和写入,它们的函数原型如下
1 | int fprintf( FILE* restrict stream, const char* restrict format, ... ); |
它们的用法与printf()和scanf()一样,只是将第一个参数换成了流(还要注意FILE*的模式的对应),比如
1 |
|
这个程序的输出为
1 | I know it is a apple |
读取和写入二进制文件
读写二进制文件,我们用到下面两个函数
1 | size_t fread( void *restrict buffer, size_t size, size_t count, FILE *restrict stream ); |
参数
-
buffer:表示读取/传入数据的指针 -
size:表示每个数据的大小 -
count:表示数据的个数 -
stream:表示流
如果成功,返回读取/写入的数据的个数
可以看出,这两个函数适合存大量相同数据,比如数组,参见下面的代码
1 |
|
得到的test.dat文件内容如下
缓冲机制
缓冲区
缓冲区(buffer)是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区,显然缓冲区是具有一定大小的。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
实际上,我们调用printf(),数据只是被内存拷贝到了FILE结构体所指向的一块内存区域(缓冲区),此时,操作系统和硬件根本不知道这些数据的存在
接下来只要刷新缓冲区(或缓冲区满了),例如调用函数fflush(stdout),内容就可以被输出到控制台上
为什么要设置缓冲区?
-
输入设备的角度:缓冲区中的数据可以作为一整个“块”发送到指定位置,比逐个发送数据要节约时间,同时也可以减少读写次数
-
输出设备的角度:当输出设备的运行很慢时,高速设备可以直接将数据发送到缓冲区,然后输出设备直接从缓冲区读取数据即可,如打印机
可以把缓冲区理解为一个特定大小的字符串,其大小通常为 512 字节或它的倍数
缓冲区的类型
-
全缓冲(Fully Buffered):只有缓冲区彻底满了,才会进行实际的 I/O
- 文件的读写就是全缓冲,比如打开磁盘文件的
fopen() - 因为磁盘的读写非常缓慢,并且反复的读写会减少磁盘的寿命,因此,
fopen()默认分配一个较大的缓冲区(由FILE*指向) - 因此,如果
fopen()后没有fclose(),数据就会永远停留在缓冲区;反之,如果调用fclose(),即使缓冲区没满,数据也会从缓冲区写入磁盘
- 文件的读写就是全缓冲,比如打开磁盘文件的
-
行缓冲(Line Buffered):遇到换行符或缓冲区满时,才进行实际 I/O
stdout就是行缓冲,因此每次printf()最后加一个\n实际上也表示将数据发送到控制台- 但是,现在很多系统会自动刷新缓冲区,导致即使不加
\n,通过printf进入缓冲区的数据也会即刻显示在控制台(即编译器可能会根据场景,自动将stdout设置为无缓冲)
-
无缓冲(Unbufferd):数据立即发送,不经过任何停留
- 标准错误流
stderr就是无缓冲,这样有利于将错误尽快的显示出来
- 标准错误流
一个较为复杂的缓冲机制的例子
想象一个场景:我们在一个文本编辑器中输入文字
接下来我们探究这里面的缓冲机制
-
首先我们按下键盘的一个字符(此时键盘是输入设备)
-
操作系统的缓冲区接收到这个字符的数据
-
文本编辑器软件在系统缓冲区中拿到这个数据(此时文本编辑器是输出设备)
-
文本编辑器将这个数据加载(行缓冲)到自己的缓冲区(此时文本编辑器是输入设备)
-
文本编辑器通过图形接口将这个字符在显示上画(行缓冲)出来(此时显示器是输出设备)
-
保存文件时,文本编辑器将字符写入(全缓冲)磁盘文件(此时硬盘是输出设备)
C 语言中的缓冲区“陷阱”
前面我们提到过,scanf()在读取到空白字符之前就会停止读取,因此,我们输入完之后的回车就会滞留在缓冲区中,当我们下次读取字符时,就有可能出错
因此,当我们每次读完字符后(尤其是字符和字符串),需要养成刷新缓冲区的习惯
我们可以用如下代码实现缓冲区的刷新
1 | void flush_buffer() |
注意这里的ch是int而不是char
需要注意的是,fflush(stdin)这种写法是未定义的,因此不建议在代码中写这样的语句
使用setvbuf()函数设置流的缓冲模式
运行下面的代码
1 |
|
如果你发现,程序先输出Hello world,然后停了 2 秒程序再结束,说明你的stdout是无缓冲
我们可以通过下面的函数将其改回行缓冲
1 | int setvbuf( FILE *restrict stream, char *restrict buffer, int mode, size_t size ); |
参数
-
stream:流 -
buffer:自定义缓冲区,也可以传空指针 -
mode:缓冲模式_IOFBF:全缓冲_IOLBF:行缓冲_IONBF:无缓冲
-
size:缓冲区大小- 若
buffer为空指针,则重设默认缓冲区的大小为size - 若
buffer为非空指针,则将缓冲区设置为由buffer开始,大小为size的区域
- 若
设置成功,返回 0;否则返回非 0
具体操作方法如下
1 |
|
这时会发现程序启动后先停了 2 秒,然后才输出Hello world

