从零到负一

Linux编程基础1 - I/O总结

2020/06/18

Linux下的I/O主要分为两种:文件I/O和标准I/O。文件I/O直接使用系统调用,遵循POSIX标准(不遵循ANSI C标准),是无缓冲的I/O;标准I/O对文件I/O进行了封装,遵循ANSI C标准(不遵循POSIX标准),是有缓冲的I/O。

理解有/无缓冲

这里的有无缓存是指函数在用户空间是否有缓冲。不管用什么I/O,最后都需要文件I/O来和内核进行通信。在内核中,有专门的缓冲来储存文件I/O输入的信息。比如write()会直接把数据写入内核的相应缓冲中;比如行缓冲的函数,需要接收\n后才会将缓冲中的数据写入内核中的缓冲。注意1,缓冲并不是指函数参数中的buf;注意2,每个流在进程中都有与其对应的缓冲,对于每个流,不同的标准I/O函数作用于相同的缓冲。

有一点我不清楚,到底读写各有一个缓冲还是一共只有一个缓冲?这里暂且认为一共只有一个缓冲吧。不管有几个缓冲,读写都需要使用缓冲,但读操作和写操作不一样。读是从内核缓冲中读一大块到I/O的缓冲,然后用户程序慢慢地从I/O缓冲中读取数据;而写就是只有I/O缓冲满了才会写入内核的缓冲(如果是全缓冲的话)。

文件I/O

open和close

open()close()用于打开、关闭文件,其函数原型如下:

1
2
3
4
// 错误返回-1,成功打开返回文件描述符
int open(const char *pathname, int flags, mode_t mode);
// 错误返回-1,成功返回0
int close(int fd);

open()函数的flags必须要有O_RDONLY, O_WRONLYO_RDWR;如果需要创建文件,还需要添加O_CREAT并且给出相应的mode值(比如0777)。open()函数返回当前可用的最小文件描述符。close()函数可以被直接调用,也可以通过结束进程的方式来间接调用。

write和read

write()read()函数原型如下:

1
2
3
// 函数成功返回读/写的字节数
ssize_t write(int fd, const void *buf, size_t count);
ssize_t read(int fd, void *buf, size_t count);

这两个函数成功的话都返回读/写的字节数,这个字节数可能小于第三个参数设置的count,比如从socket读或者读到了EOF都会导致读的数量少于count。我们这里看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>

int main(int argc, char **argv)
{
char *buf = "sdsdababcdcd\n";
// 输出 sdsdababcdcd
int wrLen = write(STDOUT_FILENO, buf, strlen(buf));

buf = "sdsd\0abab\ncdcd";
// 输出 sdsd
wrLen = write(STDOUT_FILENO, buf, strlen(buf));
// 输出 4
printf("\nstdout wrLen = %d\n\n", wrLen);

// 注意创建文件需要添加mode
int fd = open("file_io_test.log", O_RDWR | O_CREAT, 0666);
buf = "sdsd\0abab\ncdcd";
// 写入 sdsd
wrLen = write(fd, buf, strlen(buf));
// 输出 file wrLen = 4
printf("file wrLen = %d\n", wrLen);

close(fd);
fd = open("file_io_test.log", O_RDWR);
char rdbuf[20];
int rdLen = read(fd, rdbuf, 20);
// file rdLen = 4
printf("file rdLen = %d\n", wrLen);

// 进程结束前不会输出
printf("%s", rdbuf);

return 0;
}

从这个例子我们可以看出,

  1. write()read()函数遇到\0都会自动结束读写(遇到EOF也会);
  2. write()函数可以不加\n输出到STDOUT_FILENO,但printf()却不行;
  3. write()文件后需要close()或者调整偏移量,否则之后不能正确地读取文件内容。

上面的第2点牵涉行缓存的内容,这个会在下面进行讨论。我们这里先来看看文件描述符以及文件的偏移量偏移量。

lseek和文件描述符

Linux中每一个文件, socket, 设备等都是“文件”,都可以用文件I/O的函数对它们进行操作。其中fd0是标准输入,fd1是标准输出,fd2是标准错误输出,其它的文件描述符都从fd3开始。

在每个进程的进程表中都有一个记录文件表的项,每一个项对应一个文件表项。文件表项有文件的标志位、偏移量、打开文件的数量以及指向v-node表的指针等。图1是一个进程中的文件表项示意图:

图1. 文件表项示意图
不同的进程可以存在文件指针指向相同文件表项的情况,比如父进程`fork()`的子进程,它的文件指针会指向和父进程一样的文件表项。我们每次对文件描述符进行读写操作后,偏移量都会自动更新,我们也可以用`lseek()`函数手动更新偏移量。`lseek()`函数原型如下:
1
2
// 如果成功,返回当前相对于文件开始的偏移量;失败返回-1
off_t lseek(int fd, off_t offset, int whence);

我们可以改变whence来调整偏移量修改的位置,可以是当前的位置(pcur + offset)、也可是文件开始的位置(0 + offset),也可以是文件结束的位置(file_size + offset)。

标准I/O

文件I/O直接操作文件描述符,而标准I/O操作流,流将标准I/O和文件描述符联系在了一起。标准I/O和文件I/O的一个不同之处在于,标准I/O有缓冲而文件I/O没有缓冲,我们先来看看缓冲。

缓冲

缓冲分为3种,1. 全缓冲;2. 行缓冲;3. 无缓冲。

在一般情况下,行缓冲的I/O函数只有在遇到\n时才会将缓冲中的数据写入内核(如果I/O的缓冲写满了,也会进行同样的操作);而全缓冲必须在I/O的缓冲写满时才会将数据写入内核。

标准I/O对stdout/stdin的操作是行缓冲,对stderr的操作是无缓存,而对文件的操作是全缓冲。我们可以通过下面函数修改流的缓冲类型:

1
2
void setbuf(FILE *stream, char *buf);
int setvbuf(FILE *stream, char *buf, int mode, size_t size);

如果我们希望在缓冲没有写满的情况下将缓冲内容写入文件,那么我们要用fflush()函数(或者关闭文件、终止进程)。fflush()作用于输出流时,会清空(而不是写入磁盘)缓冲中的数据。除了fflush()fseek()也可以实现相同的作用。

流的打开/关闭

标准I/O有3个函数用于打开流:

1
2
3
4
// 成功返回FILE类型指针,否则返回NULL
FILE *fopen(const char *pathname, const char *mode);
FILE *fdopen(int fd, const char *mode);
FILE *freopen(const char *pathname, const char *mode, FILE *stream);

fopen()函数用于打开/新建文件,mode可以是w, r, a, w+, r+a+,具体意义请参考APUE 5.5节。注意,在文件I/O中,我们同样可以设置mode,但那个mode和这里的mode不一样。fopen()创建的文件没有设置访问权限,我们需要额外使用umask()函数来设置访问权限。

fdopen()是POSIX标准下的函数,它可以将文件描述符转换成相应的FILE指针。由于fd已经存在,因此一些mode的意义和fopen()不同,具体说明参考APUE 5.5节。

freopen()函数用于将stream绑定到pathname这个文件处,然后关闭stream;或者改变streammode具体细节参考本链接

标准I/O用于关闭流的函数是fclose(),该函数原型如下:

1
2
// 成功返回0,否则返回EOF(-1)
int fclose(FILE *stream);

文件关闭后,该文件缓冲中所有数据被冲洗;进程正常终止时,所有标准I/O会被关闭,并且所有I/O中的缓冲会被冲洗。

流的读写(一次一个字符)

标准I/O中,流的读写有很多函数,它们可以分为3类:一次一个字符,一次一行和二进制/直接读写。这里先看一次一个字符的函数。

1
2
3
4
5
6
7
8
9
// 成功返回下一个读取的字符;失败或者读到文件尾,返回EOF
int getc(FILE *fp);
int fgetc(FILE *fp);
int getchar(void);

// 成功返回写入的字符;失败返回EOF
int putc(int c, FILE *fp);
int fputc(int c, FILE *fp);
int putchar(int c);

getc()fgetc()作用一样,不过getc()可以是宏,因此getc()的参数不能有副作用。一般情况下,getc()要比fgetc()快一些。getchar()getc(stdin)等效。因为读取失败和读到EOF都返回EOF,我们需要使用如下函数测试具体是失败还是读到EOF

1
2
3
4
// 不是EOF返回0,否则返回非0
int feof(FILE *stream);
// 不是error返回0,否则返回非0
int ferror(FILE *stream);

putc()fputc()putchar()和读取函数一样,这里就不详细介绍了。

流的读写(一次一行)

一次一行的标准I/O读写函数有3个:

1
2
3
4
5
6
// 成功返回s,错误或者读到EOF返回NULL
char *fgets(char *s, int size, FILE *stream);
// 成功返回非0,否则返回EOF
int fputs(const char *s, FILE *stream);
// 直接输出到stdout
int puts(const char *s);

gets()函数已经被弃用,因此一共只有3个一次读一行的标准I/O函数。fgets()用于读取一行,读到\n停止,或者读了size - 1个字符。如果第size - 1个字符不是\n,那么函数返回不完整的一行,再次调用fgets()会继续读剩下的部分。不管是否读完,s的最后一个字符一定是\0

fputs()puts()都将一个以\0结尾的字符串写出去,fputs()puts()的输出都不包括\0,但fputs()可以没有\n,但puts()会添加一个\nputs()将字符串输出到stdout。和write()一样,如果要输出的字符串中有\0,那么只输出\0前的字符串。

下面给出一个fputs()fgets()的函数例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
int fgets_fputs_test(void)
{
FILE *wfp, *rfp;
wfp = fopen("./fgets_fputs_test.txt", "w+");
if (wfp == NULL)
return -1;

// 测试1, 写入的string中间有\0,测试结果只会输出ab。
// char wrBuf[5];
// memset(wrBuf, 0, sizeof(wrBuf));
// wrBuf[0] = 'a';
// wrBuf[1] = 'b';
// wrBuf[2] = 0;
// wrBuf[3] = 'c';
// if (fputs(wrBuf, wfp) < 0)
// return -1;

// 测试2, 写入的string没有\0,测试结果还是输出ab。
// char wrBuf[2];
// memset(wrBuf, 0, sizeof(wrBuf));
// wrBuf[0] = 'a';
// wrBuf[1] = 'b';
// if (fputs(wrBuf, wfp) < 0)
// return -1;

// fputs一次写一个char[], 不是按\n为结尾写,而是按\0为结尾写,
// 但最终不会将\0写入流。
if (fputs("this is a test...", wfp) < 0)
return -1;
if (fputs("this is a new \ntest...", wfp) < 0)
return -1;

// 写文件时,fputs使用全缓冲,只有文件关闭、缓冲写满、进程
// 关闭时才将缓冲中数据写入I/O。我们也可以使用fflush()函数
// 手动将缓冲中数据写入I/O。
// fflush(wfp);
// sleep(10);
fclose(wfp);

rfp = fopen("./fgets_fputs_test.txt", "r+");
if (rfp == NULL)
return -1;

char buf[60];
// fgets()读取n个字符,如果提前(n - 1字节)遇到EOF, \n,则
// 结束读取,并且添加\0在返回的buf中;最多读n - 1字节,最后
// 一个字节存放\0。fgets()会在遇到EOF时返回0。
// 函数输出结果:
//this
// is
//a te
//st..
//.thi
//s is
// a n
//ew
//
//test
//...
while (fgets(buf, 5, rfp) != NULL)
// puts()输出到stdout
puts(buf);

fclose(rfp);

return 0;
}

流的读写(二进制/直接读写)

二进制读写函数主要是为了解决上面函数的一个问题:输出时,遇到\0就会停止。我们可以用fputc()函数一个一个的字符/字节输出,但效率比较慢;如果用fputs()的话,又不能处理有\0的字符串;因此这种情况就需要使用二进制读写函数了。

1
2
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);

下面直接引用手册说明:

1
2
3
The function fread() reads nmemb items of data, each size bytes long, from the stream pointed to by stream, storing them at the location given by ptr.

The function fwrite() writes nmemb items of data, each size bytes long, to the stream pointed to by stream, obtaining them from the location given by ptr.

格式化I/O

什么是格式化I/O?格式化I/O就是printf(), sprintf(), snprintf(), scanf()等函数,这类函数如果是对stdinstdout进行操作,那么还是满足行缓存;如果对文件流进行操作,那么就是全缓存;如果只是对本地变量进行操作(比如sprintf()),那么是没有缓冲的。比如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
char buffer[12];
int r;

r = sprintf(buffer,"The total is %d",5+8);

puts(buffer);
printf("%d characters stored\n",r);

return(0);
}

即使"The total is %d"没有换行符,字符串也会写入buffer;但如果"%d characters stored\n"不加换行符,那么这段字符串是不会输出到stdout的。

总结

这部分内容比较繁杂,但只要搞懂了文件和标准I/O的区别,不同I/O缓冲的区别,那么还是比较好理解的。下面是一些比较重要的知识点,需要好好理解记忆:

  1. 文件I/O都不带缓冲;
  2. 标准I/O默认情况下基本都带缓冲,但它们缓冲类型不同;
  3. 当缓冲写满、文件关闭、进程结束、调用fflush(), fseek()等函数时,缓冲中数据写入内核;
  4. 对于行缓冲,遇到\n也要将缓冲中数据写入内核;
  5. 上面3 - 4针对的是写操作;对于读操作,先从内核读取适量数据到缓冲,然后再慢慢读取;
  6. 不管有无用户空间的缓存,内核空间都是有缓冲的。
CATALOG
  1. 1. 理解有/无缓冲
  2. 2. 文件I/O
    1. 2.1. open和close
    2. 2.2. write和read
    3. 2.3. lseek和文件描述符
  3. 3. 标准I/O
    1. 3.1. 缓冲
    2. 3.2. 流的打开/关闭
    3. 3.3. 流的读写(一次一个字符)
    4. 3.4. 流的读写(一次一行)
    5. 3.5. 流的读写(二进制/直接读写)
  4. 4. 格式化I/O
  5. 5. 总结