本文是笔者拜读《UNIX环境高级编程》第5章(标准I/O库)的学习笔记。本文的主要内容包括文件流、FILE指针、缓冲、读写流、各种I/O的效率。文中不仅包含书中的知识点,也包括笔者的理解。
以UNIX
为例,操作系统的体系结构如下图所示:
shell
是一个特殊的应用程序,为运行其他应用程序提供接口。系统调用和库函数是应用程序访问内核的接口,前面两章介绍的函数大都属于系统调用,如open
、read
、write
和lstat
。
库函数是对系统调用的封装,更便于用户使用。其中的标准I/O
库由ISO C
标准所定义,该标准库也移植到了UNIX
之外的很多系统中。标准I/O
库处理了很多细节,如缓冲区分配、以优化的块长度执行I/O
等。这些处理使用户不必担心如何选择正确的块长度。
在使用
man
命令查询函数时,选项2
表示系统调用,3
表示库函数。
流和FILE对象
标准I/O
是围绕流(stream
)而不是文件描述符进行的。当用标准I/O
库打开或创建一个文件时,使一个流与一个文件相关联。
标准I/O
文件流可用于单字节或多字节(宽)字符集。流的定向决定了所读写的字符是单字节(字节定向)还是多字节(宽定向)。一个流刚被创建时,没有定向。fwide
函数用于设置流的定向,freopen
函数清除一个流的定向。
如果mode
参数值为负,将指定流设置为字节定向。
如果mode
参数值为正,将指定流设置为宽定向。
如果mode
值为0
,不设置流的定向,返回标识该流定向的值。
fwide
不改变已定向流的定向,且无出错返回。
fopen
函数返回一个FILE
对象的指针,FILE
对象包含了标准I/O
库为管理该流所需要的所有信息:文件描述符、流缓冲区地址、缓冲区长度、缓冲区中的字符数、出错标志等。我们称FILE*
为文件指针。
在前面几篇博客里,笔者将文件偏移量也称为文件指针。笔者把系统调用和库函数里的相关概念搞混了,表示抱歉。
标准输入、标准输出和标准错误
文件描述符 | 文件指针 | 说明 |
---|---|---|
STDIN_FILENO | stdin | 标准输入 |
STDOUT_FILENO | stdout | 标准输出 |
STDERR_FILENO | stderr | 标准错误 |
以上3
个文件指针定义在了头文件<stdio.h>
中。
缓冲
标准I/O
库提供缓冲的目的是尽可能减少read
和write
的调用次数(即减少访问内核的次数)。如下图所示,进程每次读/写磁盘时,首先访问缓冲区,如果无法完成期望的行为,才访问真正的磁盘。
标准I/O
提供了3
种类型的缓冲。
(1)
全缓冲。在标准I/O
缓冲区被填满后才进行实际的I/O
操作。对于驻留在磁盘上的文件通常是由标准I/O
库实施全缓冲的。(调用malloc
获得缓冲区)
冲洗(flush
)说的是标准I/O
缓冲区的写操作。函数fflush
可以冲洗一个流(有的编译器不支持)。在标准I/O
库方面,冲洗指的是将缓冲区里的内容写到磁盘(不管缓冲区有没有满);在终端驱动程序方面,冲洗表示丢弃已存储在缓冲区中的数据。
冲洗缓冲区:要么把缓冲区里的数据用掉,要么删掉。
(2)
行缓冲。在输入和输出中遇到换行符时,执行实际的I/O
操作。标准输入/输出使用的是行缓冲。行缓冲的限制:
a.
缓冲区的长度是固定的,只要填满了缓冲区,即使没遇到换行符,也会进行I/O
操作。
b.
任何时候只要通过标准I/O
库要求从一个不带缓冲的流m
,或者一个行缓冲的流n
(从内核请求数据)得到数据,那么就会冲洗相应的行缓冲输出流。
在输入数据来到缓冲区前,要冲洗缓冲区中的输出数据。输入/输出共用一个缓冲区。
(3)
不带缓冲。标准I/O
库不对字符进行缓冲存储。标准错误流stderr
通常是不带缓冲的。
很多系统默认使用以下类型的缓冲:
(1)
标准错误流不带缓冲。
(2)
如果流(除了标准错误流)指向的是终端设备,则行缓冲的,否则全缓冲。
更换缓冲类型的函数:
成功返回0
,出错返回非0
.
setbuf
打开或关闭缓冲机制。buf
为NULL
时,关闭缓冲。buf
指向一个长度为BUFSIZ
的缓冲区时,设置为全缓冲或行缓冲。
使用setvbuf
可以精确地说明缓冲类型,如果指定不带缓冲,则忽略buf
和size
,否则buf
和size
可选择地指定缓冲区的地址和长度。一般而言,应由系统选择缓冲区的长度并自动分配缓冲区。
fflush
可强制冲洗一个流。此函数使该流所有未写的数据都被传送至内核。如果stream
是 NULL
,则冲洗所有输出流。
打开流
以下函数打开一个标准I/O
流:
fopen
:打开路径名为path
的指定文件。
fdopen
:从一个已有的文件描述符上打开文件,使一个文件指针和该描述符相关联。此函数常用于由创建管道和网络通信通道函数返回的描述符,因为一开始只能直接使用文件描述符访问这些特殊文件。
freopen
:在一个指定的流上打开一个指定的文件。如果流已经打开,则先关闭它。若流已经定向,则清除定向。此函数一般用于将一个指定的文件打开为一个预定义的流:标准输入、标准输出或标准错误流。
使
stream
和path
关联起来,并返回stream
。
mode
参数指定了对流的读写方式,该参数和open
函数的标志对应。
mode | open 标志 |
---|---|
r 或rb | O_RDONLY |
w 或wb | O_WRONLY | O_CREAT | O_TRUNC |
a 或ab | O_WRONLY | O_CREAT | O_APPEND |
r+ 或r+b 或rb+ | O_RDWR |
w+ 或w+b 或wb+ | O_RDWR | O_CREAT | O_TRUNC |
a+ 或a+b 或ab+ | O_RDWR | O_CREAT | O_APPEND |
fopen
函数区分了文本文件和二进制文件,而open
函数将它们都看做普通文件。
使用fdopen
时,因为文件已经被打开,所以以写方式打开文件时不截断文件,以追加方式打开时也不能创建该文件。
当以读和写方式打开一个文件时,具有以下限制:
(1)
如果中间没有fflush
、fseek
、fsetpos
或rewind
,则在输出的后面不能直接跟随输入。
(2)
如果中间没有fseek
、fsetpos
或rewind
,或者一个输入操作没有到达文件尾端,则在输入操作之后不能直接跟随输出。
说到底,是因为读和写共用一个缓冲区,要避免读和写数据混合存储在缓冲区里。
限制 | r | w | a | r+ | w+ | a+ |
---|---|---|---|---|---|---|
文件必须已存在 | + | + | ||||
放弃文件以前的内容 | + | + | ||||
流可以读 | + | + | + | + | ||
流可以写 | + | + | + | + | + | |
流只可在尾端处写 | + | + |
在指定w
和a
类型创建一个新文件时,我们无法说明该文件的权限位。POSIX
要求实现使用如下的权限位来创建文件:
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH
默认权限?我们可以通过
umask
函数屏蔽一些权限。
fclose
关闭一个打开的流。
在该流被关闭之前,会自动冲洗缓冲区的输出数据,缓冲区中的输入数据被丢弃。如果标准I/O
库自动为该流分配了一个缓冲区,则释放缓冲区。
当一个进程正常终止时,所有缓冲区里的输出数据被冲洗,所有I/O
流被关闭。
***例: ***
fopen
和freopen
的一般使用。
// test.c
#include <stdio.h>
int main() {
FILE *fp = fopen("./t1.txt", "w+");
fputs("hello t1.txt\n", fp);
fp = freopen("./t2.txt", "w+", fp);
fputs("hello t2.txt\n", fp);
fclose(fp);
return 0;
}
运行结果:
读和写流
一旦打开了流,则可在3
种不同类型的非格式化I/O种进行选择,对其进行读、写操作。
(1)
每次一个字符的I/O
。一次读写一个字符,如fgetc
,fputc
。如果流是带缓冲的,则标准I/O
函数处理缓冲。
(2)
每次一行的I/O
。一次读写一行,以一个换行符终止,如fgets
、fputs
。
(3)
直接I/O
,有时被称为二进制I/O
。每次读写一定数量的数据,如fread
和fwrite
。
nmemb
是数据项的个数,size
是每个数据项的大小(单位是字节)。
以上的都是非格式化
I/O
,格式化I/O
包括printf
、scanf
。
输入函数
getchar()
相当于getc(stdin)
。
getc
可以被实现为宏,fgetc
不能实现为宏。这意味着:
(1)
getc
的参数不应当是具有副作用的表达式,因为它可能会被计算多次。
(2)
fgetc
是一个函数,可以取到其地址。
(3)
调用函数fgetc
所需的时间通常长于调用宏getc
。
调用fgets
时,应说明能处理的最大行长。
从流中读取数据后,可以调用ungetc
将字符再压送回流中。并没有将压送字符写到底层文件或设备中,只是将它写回了标准I/O
缓冲区中。
调用一次
ungetc
相当于将文件偏移量前移了1
位。
例:
测试ungetc
的功能。
// test.c
#include <stdio.h>
int main() {
FILE *fp = fopen("./t.txt", "a+");
int c = fgetc(fp);
printf("%c\n", c);
c = fgetc(fp);
printf("%c\n", c);
c = ungetc(c, fp);
c = fgetc(fp);
printf("%c\n", c);
fclose(fp);
return 0;
}
运行结果如下:
getchar
、getc
、fgetc
在返回一个字符时,将读到的unsigned char
类型数据转换为int
类型。常量EOF
(-1
)表示读出错或者是到了文件末尾,为了区分这两种不同的情况,需要调用ferror
或feof
。
每个流在FILE
对象种维护了两个标志:出错标志;文件结束标志。调用clearerr
可以清除这两个标志。
输出函数
输出函数与上面的输入函数对应。putchar(c)
等同于fputc(c, stdout)
。putc
可被实现为宏,fputc
不能实现为宏。
每次一行I/O
fgets
和gets
提供每次读取一行的功能。gets
从标准输入读,fgets
从指定流读。
对于fgets
,必须指定缓冲长度size
,此函数一直读到'\n'
为止,以'\0'
结尾。如果该行包括最后一个换行符的字符数超过了size-1
,则会读到一个不完整的行,对fgets
的下一次调用会继续读该行。
gets
不将'\n'
存入缓冲区。不推荐使用gets
,因为可能会引起缓冲区溢出。
fputs
和puts
提供输出一行的功能。
fputs
和puts
均将一个以'\0'
作为终止符的字符串写到指定流或标准输出中,不打印'\0'
本身。不同的是,puts
会自动追加一个'\n'
,因此请尽量避免使用puts
。
在
I/O
系统调用的API
中,文件描述符通常是第一个参数。
在标准I/O
库的API
中,流指针通常是最后一个参数。
标准I/O的效率
下面三个程序将标准输入复制到了标准输出。
a. 每次一个字符 I/O
// copy1.c
#include <stdio.h>
int main() {
int c;
while ((c = fgetc(stdin)) != EOF) {
if (fputc(c, stdout) != c) {
perror("fputc error");
return -1;
}
}
return 0;
}
b. 每次一行字符 I/O
// copy2.c
#include <stdio.h>
#define N 1024
int main() {
char buf[N];
while (fgets(buf, N, stdin)) {
if (!fputs(buf, stdout)) {
perror("fputs error");
return -1;
}
}
return 0;
}
c. 直接系统调用 I/O
// copy3.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define N 4096
int main() {
char buf[N];
int wNum;
int rNum;
while ((rNum = read(STDIN_FILENO, buf, N)) > 0) {
if ((wNum = write(STDOUT_FILENO, buf, rNum)) != rNum) {
perror("write error");
return -1;
}
}
if (rNum == -1) {
perror("read error");
}
return 0;
}
a
和b
使用的是库函数,c
使用的是系统调用。对测试结果进行分析:
(1)
a
的用户CPU
时间最长,因为它每读一个字符都要执行一次循环;b
次之,循环的次数至少和文件中'\n'
的数量相当。c
的最短,可以设置程序,使其每次最多读一个磁盘块大小的数据,循环次数最少。
(2)
三者的系统CPU
时间几乎相同,因为所有这些程序对内核提出的读、写请求数基本相同。
(3)
三者的时钟时间差主要来源于用户CPU时间差和等待I/O结束所消耗的时间差。
(4)
a
和b
是带缓冲的I/O
,缓冲区的大小是系统的默认值;而c
不带缓冲。这就意味着,当c
中的N
被用户指定的很小时,效率会极低,因为每次I/O
都要访问内核,而不是直接读写缓冲区。
综合来看,标准I/O
库与read
和write
相比并不慢很多。对大多数比较复杂的应用程序而言,用户CPU
时间的主要部分是应用程序本身的各种数据处理,而不是I/O
例程。