一 预备知识
1. 文件=内容+属性,内容与属性都是数据,都要在磁盘中保存。
2. 文件分为打开的文件和没打开的文件。
3. 进程在访问一个文件的时候,都是要先打开这个文件。打开文件之前,文件在磁盘,打开文件之后,文件在内存。
4. 一个进程可以打开多个文件,多个进程可以打开多个文件,被打开的文件要加载都内存,打开文件实际是操作系统去执行,操作系统也要将打开的文件管理起来,如何管理呢?先描述,再组织,一个文件被加载到内存就是一个进程。进程就要有自己的PCB,操作系统用双链表将PCB管理起来,这在之前就讲过了。
二 C语言文件I/O函数
2.1 文件打开关闭函数
2.1.1文件打开函数fopen
头文件stdio.h中包含了一系列的文件操作函数,以便我们对特定的文件进行相应的操作。学会对文件进行操作后,我们就可以将代码与文件联系起来,这样能让编程变得更有意思不是吗?比如你可以用代码写一个游戏,当游戏结束后可以将游戏当前的数据保存到一个文件中,那么当你下一次运行代码时只要先读取该文件中的数据,就可以接着上一次的游戏进度继续玩,而不至于从头再来。
下面介绍了打开以及关闭文件的操作函数,以及其中的一些细节。
FILE *fopen( const char *filename, const char *mode );
该函数的功能就是打开一个文件,函数的第一个参数是你要打开的文件的文件名,第二个参数是打开这个文件的形式。
打开一个文件时,系统会为该文件创建一个文件信息区,该函数调用完毕后,如果打开该文件成功,那么返回指向该文件信息区的指针(FILE*类型);如果打开文件失败,那么返回一个空指针(NULL)。
在C语言中,FILE其实是结构体封装的,FILE
结构体的具体实现取决于不同的编译器和操作系统,因此它的内部变量可能会有所不同。一般来说,FILE
结构体可能包含以下一些常见的成员变量:
-
_IO_FILE * _flags
: 用于存储文件状态标志,以什么模式打开的此文件。 -
_IO_FILE * _IO_read_ptr
: 用于存储读取缓冲区的当前位置。 -
_IO_FILE * _IO_read_end
: 用于存储读取缓冲区的结束位置。 -
_IO_FILE * _IO_read_base
: 用于存储读取缓冲区的起始位置。 -
_IO_FILE * _IO_write_base
: 用于存储写入缓冲区的起始位置。 -
_IO_FILE * _IO_write_ptr
: 用于存储写入缓冲区的当前位置。 -
_IO_FILE * _IO_write_end
: 用于存储写入缓冲区的结束位置。 -
fpos_t _IO_save_base
: 用于存储文件位置指示器的位置。
实际的 FILE
结构体内部成员变量可能远不止以上列出的内容,而且在不同的系统和编译器下可能会有所不同。
FILE封装变量大多数都是对缓冲区操作那这个缓冲区是什么?为什么?
Linux篇小知识点:进程被创建时,操作系统到底为它做了什么工作?
使用上面的模式说明符,文件将作为文本文件打开。为了将文件作为二进制文件打开,模式字符串中必须包含“b”字符。这个附加的“b”字符可以附加在字符串的末尾(从而形成以下复合模式:“rb”、“wb”、“ab”、“r+b”、“w+b”、“a+b”),也可以插入在“+”符号之前(“rb+”、“wb+”、“ab+”)。
如果序列后面有其他字符,则行为取决于库实现:一些实现可能会忽略其他字符,例如,接受额外的“t”(有时用于显式表示文本文件)。
在某些库实现中,使用更新模式打开或创建文本文件可能会视为二进制文件。
举个几个例子:
1.若我们要以文本形式打开一个名叫data.txt的文件,将要对其进行输入操作,那么打开文件时应该这样写:
FILE* pf = fopen("data.txt", "r");
注:data.txt文件必须存在,不然打开文件失败,fopen函数会返回一个空指针。
2.若我们要以二进制打开一个名叫data.bin的文件,将要对其进行输出操作,那么打开文件时应该这样写:
FILE* pf = fopen("data.bin", "wb");
注:data.bin文件若存在,将销毁文件原有内容,再对其进行输出;data.bin文件若不存在,系统将主动创建一个名叫data.bin的文件。
前面说到,如果文件打开成功,fopen函数会返回指向文件信息区的指针,否则fopen函数会返回一个空指针。所以当使用接收fopen函数的返回值的指针前,我们必须检测其有效性,否则可能非法访问内存。
检测有效性:
FILE* pf = fopen("data.txt", "r");
if (pf == NULL)
{
printf("%s\n", strerror(errno));
return 1;//失败返回
}
//使用...
2.1.2 文件关闭函数fclose
当我们打开文件时,会在内存中开辟一块空间,文件被打开就是进程了,不知道看这个链接内容。如果我们打开该文件后不关闭,那么这个空间会一直存在,一直占用那块内存空间,所以当我们对一个文件的操作结束时,一定要记住将该文件关闭。这就需要用到fclose函数来关闭文件。
int fclose( FILE *stream );
我们如果要关闭一个文件,那么直接将该文件的文件指针传入fclose函数即可,fclose函数如果关闭文件成功会返回0。与free函数一样,当我们调用完fclose函数将文件关闭后,我们也要将指向文件信息区的指针置空,避免该指针变成野指针。
fclose(pf);//关闭pf指向的文件
pf = NULL;//及时置空
2.2 文件读写函数
2.2.1 字符输入输出函数 - fgetc和fputc
2.2.2.1 fputc函数
int fputc( int c, FILE *stream );
fputc函数的第一个参数是待输出的字符, 参数是要写入文件的字符,通常是一个 int
类型的字符值。第二个参数该字符输出的位置,即fputc函数的功能是将一个字符输出到指定的位置。该函数调用完毕会返回用户传入的字符。
例子:
#include <stdio.h>
int main() {
FILE *file;
file = fopen("output.txt", "w"); // 以写入模式打开文件
if (file) {
char text[] = "Hello, world!";
int i;
for (i = 0; text[i] != '\0'; i++) {
fputc(text[i], file);
}
fclose(file); // 关闭文件
} else {
printf("无法打开文件\n");
}
return 0;
}
2.2.2.2 fgetc函数
int fgetc( FILE *stream );
fgetc函数只有一个参数,即你要读取的文件的文件指针。fgets函数的功能就是从指定位置读取一个字符。该函数调用成功会返回读取到的的字符;若读取文件时发生错误,或是已经读取到文件末尾,则返回EOF。
为什么返回值是int?
fgetc()
函数会从指定文件流中读取下一个字符,成功读取下一个字符时,fgetc()
将返回读取的字符作为 unsigned char
转换为的 int
类型值。如果到达文件末尾或者发生错误,fgetc()
将返回 EOF
,这通常被定义为 -1
。
2.2.2 文本行输入输出函数 - fgets和fputs
2.2.2.1 fputs函数
int fputs( const char *string, FILE *stream );
puts函数的第一个参数是待输出的字符串,第二个参数该字符串输出的位置,即fputs函数的功能是将一个字符串输出到指定的位置(有没有发现fputs函数的参数设计和fputc函数参数的设计非常类似)。该函数调用成功会返回一个非负值;若输出时发生错误,则返回EOF。
2.2.2.2 fgets函数
char *fgets( char *string, int n, FILE *stream );
fgets函数的第三个参数是你要读取的文件的文件指针,第二个参数是你要读取的字符个数(也可以说是字节个数),第一个参数是你所读取到的数据的储存位置。fgets函数的功能就是从指定位置读取指定字符个数的数据储存到指定位置。该函数调用成功会返回用于储存数据的位置的地址,如果读取过程中发生错误,或是读取到了文件末尾,则返回一个空指针(NULL)。
fgets函数读取字符的过程中会出现两种情况:
在fgets函数读取到指定字符数之前,若读取到换行符(’\n’),则停止读取,读取带回的字符包含换行符。
直到fgets函数读取到第n-1个字符时都没有遇到换行符(’\n’),则返回读取到的n-1个字符,并在末尾加上一个空字符一同返回(共n个字符)。
2.2.3 格式化输入输出函数 - fscanf和fprintf
2.2.3.1 fprintf函数
int fprintf( FILE *stream, const char *format [, argument ]...);
stream
是一个指向已打开文件的指针,指定了要写入数据的文件流。format
是一个格式化字符串,包含了要写入的内容以及格式说明符,类似于printf()
函数中的格式化字符串。...
是可变数量的参数,用于填充到format
字符串中的格式说明符中。
fprintf()
函数根据格式化字符串 format
的内容,将格式化后的数据写入到指定的文件流中。如果成功写入数据,则返回写入的字节数;如果发生错误,则返回一个负数。
可变参数可能有人不太懂,插入可变参数列表的讲解
2.2.3.2 fscanf函数
int fscanf( FILE *stream, const char *format [, argument ]... );
stream
是一个指向已打开文件的指针,指定了要从中读取数据的文件流。format
是一个格式化字符串,包含了要读取的数据的格式以及格式说明符,类似于scanf()
函数中的格式化字符串。...
是可变数量的参数,用于保存从文件中读取的数据。
fscanf()
函数根据格式化字符串 format
中的格式说明符,从指定的文件流中读取数据并存储到相应的变量中。如果成功读取数据,则返回成功匹配和赋值的参数个数;如果读取到文件末尾或发生错误,则返回EOF。
2.2.4 二进制输入输出函数 - fread和fwrite
2.2.4.1 fwrite函数
size_t fwrite( const void *buffer, size_t size, size_t count, FILE *stream );
fwrite函数的第一个参数是输出数据的位置,第二个参数是要输出数据的元素个数,第三个参数是每个元素的大小,第四个参数是数据输出的目标位置。该函数调用完后,会返回实际写入目标位置的元素个数,当输出时发生错误或是待输出数据元素个数小于要求输出的元素个数时,会返回一个小于count的数。
fwrite函数的功能就是将buffer位置的,每个元素大小为size的,count个元素,以二进制的形式输出到stream位置。
2.2.4.2 fread函数
size_t fread( void *buffer, size_t size, size_t count, FILE *stream );
fread函数的第一个参数是接收数据的位置,第二个参数是要读取的每个元素的大小,第三个参数是要读取的元素个数,第四个参数是读取数据的位置。函数调用完会返回实际读取的元素个数,若在读取过程中发生错误或是在未读取到指定元素个数时读取到文件末尾,则返回一个小于count的数。
概括一下,fread函数的功能就是从stream位置,以二进制的形式读取count个每个元素大小为size的数据,到buffer位置。
三 系统文件IO
3.1 默认打开的三个流
都说Linux下一切皆文件,也就是说Linux下的任何东西都可以看作是文件,那么显示器和键盘当然也可以看作是文件。我们能看到显示器上的数据,是因为我们向“显示器文件”写入了数据,电脑能获取到我们敲击键盘时对应的字符,是因为电脑从“键盘文件”读取了数据。
为什么我们向“显示器文件”写入数据以及从“键盘文件”读取数据前,不需要进行打开“显示器文件”和“键盘文件”的相应操作?
标准输入流(stdin)和标准输出流(stdout)分别代表键盘和显示器。这些文件流在程序开始执行时已经自动打开,并且无需显式打开或关闭它们。这是因为它们是标准的输入和输出设备,在程序执行期间一直可用。
stdin、stdout以及stderr这三个家伙实际上都是FILE*
类型的。
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
stdin、stdout以及stderr与我们打开某一文件时获取到的文件指针是同一个概念,试想我们使用fputs函数时,将其第二个参数设置为stdout,此时fputs函数会不会之间将数据显示到显示器上。
#include <stdio.h>
int main()
{
fputs("hello stdin\n", stdout);
fputs("hello stdout\n", stdout);
fputs("hello stderr\n", stdout);
return 0;
}
注意: 不止是C语言当中有标准输入流、标准输出流和标准错误流,C++当中也有对应的cin、cout和cerr,其他所有语言当中都有类似的概念。实际上这种特性并不是某种语言所特有的,而是由操作系统所支持的。
操作文件除了C语言接口、C++接口或是其他语言的接口外,操作系统也有一套系统接口来进行文件的访问。
相比于C库函数或其他语言的库函数而言,系统调用接口更贴近底层,实际上这些语言的库函数都是对系统接口进行了封装。
我们在Linux平台下运行C代码时,C库函数就是对Linux系统调用接口进行的封装,在Windows平台下运行C代码时,C库函数就是对Windows系统调用接口进行的封装,这样做使得语言有了跨平台性,也方便进行二次开发。
3.2 系统调用接口
3.2.1 open
int open(const char *pathname, int flags, mode_t mode);
open的第一个参数:open函数的第一个参数是pathname,表示要打开或创建的目标文件。
open的第二个参数:open函数的第二个参数是flags,表示打开文件的方式。
打开文件时,可以传入多个参数选项,当有多个选项传入时,将这些选项用“或”运算符隔开。
例如,若想以只写的方式打开文件,但当目标文件不存在时自动创建文件,则第二个参数设置如下:
O_WRONLY | O_CREAT
open的第三个参数
open函数的第三个参数是mode,表示创建文件的默认权限。八进制。
创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。 若想创建出来文件的权限值不受umask的影响,则需要在创建文件前使用umask
函数将文件默认掩码设置为0。
注意: 当不需要创建文件时,open的第三个参数可以不必设置。
open的返回值:open函数的返回值是新打开文件的文件描述符。文件描述符不懂的看这里。
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
umask(0);
int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);
int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);
int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);
int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);
int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 0666);
printf("fd1:%d\n", fd1);
printf("fd2:%d\n", fd2);
printf("fd3:%d\n", fd3);
printf("fd4:%d\n", fd4);
printf("fd5:%d\n", fd5);
return 0;
}
运行程序后可以看到,打开文件的文件描述符是从3开始连续且递增的。
我们再尝试打开一个根本不存在的文件,也就是open函数打开文件失败:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("test.txt", O_RDONLY);
printf("%d\n", fd);
return 0;
}
打开文件失败时获取到的文件描述符是-1。
实际上这里所谓的文件描述符本质上是一个指针数组的下标,指针数组当中的每一个指针都指向一个被打开文件的文件信息,通过对应文件的文件描述符就可以找到对应的文件信息。
当使用open函数打开文件成功时数组当中的指针个数增加,然后将该指针在数组当中的下标进行返回,而当文件打开失败时直接返回-1,因此,成功打开多个文件时所获得的文件描述符就是连续且递增的。
而Linux进程默认情况下会有3个缺省打开的文件描述符,分别就是标准输入0、标准输出1、标准错误2,这就是为什么成功打开文件时所得到的文件描述符是从3开始进程分配的。这里有详细讲解。
3.2.2 close
系统接口中使用close函数关闭文件,close函数的函数原型如下:
int close(int fd);
使用close函数时传入需要关闭文件的文件描述符即可,若关闭文件成功则返回0,若关闭文件失败则返回-1。
3.2.3 write
系统接口中使用write函数向文件写入信息,write函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
我们可以使用write函数,将buf位置开始向后count字节的数据写入文件描述符为fd的文件当中。
- 如果数据写入成功,实际写入数据的字节个数被返回。
- 如果数据写入失败,-1被返回。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd < 0){
perror("open");
return 1;
}
const char* msg = "hello syscall\n";
for (int i = 0; i < 5; i++){
write(fd, msg, strlen(msg));
}
close(fd);
return 0;
}
运行程序后,在当前路径下就会生成对应文件,文件当中就是我们写入的内容。
3.2.4 read
ssize_t read(int fd, void *buf, size_t count);
我们可以使用read函数,从文件描述符为fd的文件读取count字节的数据到buf位置当中。
- 如果数据读取成功,实际读取数据的字节个数被返回。
- 如果数据读取失败,-1被返回。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("log.txt", O_RDONLY);
if (fd < 0){
perror("open");
return 1;
}
char ch;
while (1){
ssize_t s = read(fd, &ch, 1);
if (s <= 0){
break;
}
write(1, &ch, 1); //向文件描述符为1的文件写入数据,即向显示器写入数据
}
close(fd);
return 0;
}
四 文件描述符fd
4.1 初始fd
其实这些知识已经在进程被创建时,操作系统到底为它做了什么工作? 这里讲过了,这个Linux小知识点贯穿了整个操作系统的学习。
文件是由进程运行时打开的,一个进程可以打开多个文件,而系统当中又存在大量进程,也就是说,在系统中任何时刻都可能存在大量已经打开的文件。
因此,操作系统务必要对这些已经打开的文件进行管理,操作系统会为每个已经打开的文件创建各自的struct file结构体,然后将这些结构体以双链表的形式连接起来,之后操作系统对文件的管理也就变成了对这张双链表的增删查改等操作。
而为了区分已经打开的文件哪些属于特定的某一个进程,我们就还需要建立进程和文件之间的对应关系。
进程和文件之间的对应关系是如何建立的?
我们知道,当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的PCB、文件描述符表、页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的映射关系。
而task_struct当中有一个指针,该指针指向一个名为files_struct的结构体,在该结构体当中就有一个名为fd_array的指针数组,该数组的下标就是我们所谓的文件描述符。
当进程打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file连入文件双链表,并将该结构体的首地址填入到fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后返回该文件的文件描述符给调用进程即可。
因此,我们只要有某一文件的文件描述符,就可以找到与该文件相关的文件信息,进而对文件进行一系列输入输出操作。
注意: 向文件写入数据时,是先将数据写入到对应文件的缓冲区当中,然后定期将缓冲区数据刷新到磁盘当中。
什么叫做进程创建的时候会默认打开0、1、2?
0就是标准输入流,对应键盘;1就是标准输出流,对应显示器;2就是标准错误流,也是对应显示器。
而键盘和显示器都属于硬件,属于硬件就意味着操作系统能够识别到,当某一进程创建时,操作系统就会根据键盘、显示器、显示器形成各自的struct file,将这3个struct file连入文件双链表当中,并将这3个struct file的地址分别填入fd_array数组下标为0、1、2的位置,至此就默认打开了标准输入流、标准输出流和标准错误流。
磁盘文件 和 内存文件
当文件存储在磁盘当中时,我们将其称之为磁盘文件,而当磁盘文件被加载到内存当中后,我们将加载到内存当中的文件称之为内存文件。磁盘文件和内存文件之间的关系就像程序和进程的关系一样,当程序运行起来后便成了进程,而当磁盘文件加载到内存后便成了内存文件。
磁盘文件由两部分构成,分别是文件内容和文件属性。文件内容就是文件当中存储的数据,文件属性就是文件的一些基本信息,例如文件名、文件大小以及文件创建时间等信息都是文件属性,文件属性又被称为元信息。
文件加载到内存时,一般先加载文件的属性信息,当需要对文件内容进行读取、输入或输出等操作时,再延后式的加载文件数据。
4.2 文件描述符的分配规则
尝试连续打开五个文件,看看这五个打开后获取到的文件描述符。
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
umask(0);
int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);
int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);
int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);
int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);
int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 0666);
printf("fd1:%d\n", fd1);
printf("fd2:%d\n", fd2);
printf("fd3:%d\n", fd3);
printf("fd4:%d\n", fd4);
printf("fd5:%d\n", fd5);
return 0;
}
可以看到这五个文件获取到的文件描述符都是从3开始连续递增的,这很好理解,因为文件描述符本质就是数组的下标,而当进程创建时就默认打开了标准输入流、标准输出流和标准错误流,也就是说数组当中下标为0、1、2的位置已经被占用了,所以只能从3开始进行分配。
若我们在打开这五个文件前,先关闭文件描述符为0的文件,此后文件描述符的分配又会是怎样的呢?
可以看到,第一个打开的文件获取到的文件描述符变成了0,而之后打开文件获取到的文件描述符还是从3开始依次递增的。
我们再试试在打开这五个文件前,将文件描述符为0和2的文件都关闭(不要将文件描述符为1的文件关闭,因为这意味着关闭了显示器文件,此时运行程序将不会有任何输出)。
close(0);
close(2);
可以看到前两个打开的文件获取到的文件描述符是0和2,之后打开文件获取到的文件描述符才是从3开始依次递增的。
结论: 文件描述符是从最小但是没有被使用的fd_array数组下标开始进行分配的。
五 重定向
5.1 重定向的原理
在明确了文件描述符的概念及其分配规则后,现在我们已经具备理解重定向原理的能力了。看完下面三个例子后,你会发现重定向的本质就是修改文件描述符下标对应的struct file*的内容。
输出重定向就是,将我们本应该输出到一个文件的数据重定向输出到另一个文件中。
例如,如果我们想让本应该输出到“显示器文件”的数据输出到log.txt文件当中,那么我们可以在打开log.txt文件之前将文件描述符为1的文件关闭,也就是将“显示器文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是1。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd < 0){
perror("open");
return 1;
}
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
fflush(stdout);
close(fd);
return 0;
}
运行结果后,我们发现显示器上并没有输出数据,对应数据输出到了log.txt文件当中。
printf函数是默认向stdout输出数据的,而stdout指向的是一个struct FILE类型的结构体,该结构体当中有一个存储文件描述符的变量,而stdout指向的FILE结构体中存储的文件描述符就是1,因此printf实际上就是向文件描述符为1的文件输出数据。
C语言的数据并不是立马写到了内存操作系统里面,而是写到了C语言的缓冲区当中,所以使用printf打印完后需要使用fflush将C语言缓冲区当中的数据刷新到文件中。
5.2 追加重定向原理
追加重定向和输出重定向的唯一区别就是,输出重定向是覆盖式输出数据,而追加重定向是追加式输出数据。
例如,如果我们想让本应该输出到“显示器文件”的数据追加式输出到log.txt文件当中,那么我们应该先将文件描述符为1的文件关闭,然后再以追加式写入的方式打开log.txt文件,这样一来,我们就将数据追加重定向到了文件log.txt当中。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY|O_APPEND|O_CREAT, 0666);
if(fd < 0){
perror("open");
return 1;
}
printf("hello Linux\n");
printf("hello Linux\n");
printf("hello Linux\n");
printf("hello Linux\n");
printf("hello Linux\n");
fflush(stdout);
close(fd);
return 0;
}
运行结果,发现对应数据便追加式输出到了log.txt文件当中。
5.3 输入重定向原理
输入重定向就是,将我们本应该从一个文件读取数据,现在重定向为从另一个文件读取数据。
例如,如果我们想让本应该从“键盘文件”读取数据的scanf函数,改为从log.txt文件当中读取数据,那么我们可以在打开log.txt文件之前将文件描述符为0的文件关闭,也就是将“键盘文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是0。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(0);
int fd = open("log.txt", O_RDONLY | O_CREAT, 0666);
if (fd < 0){
perror("open");
return 1;
}
char str[40];
while (scanf("%s", str) != EOF){
printf("%s\n", str);
}
close(fd);
return 0;
}
运行结果后,我们发现scanf函数将log.txt文件当中的数据都读取出来了。
说明一下:
scanf函数是默认从stdin读取数据的,而stdin指向的FILE结构体中存储的文件描述符是0,因此scanf实际上就是向文件描述符为0的文件读取数据。
标准输出流和标准错误流对应的都是显示器,它们有什么区别?
我们来看看下面这段代码,代码中分别向标准输出流和标准错误流输出了两行字符串:
#include <stdio.h>
int main()
{
printf("hello printf\n"); //stdout
perror("perror"); //stderr
fprintf(stdout, "stdout:hello fprintf\n"); //stdout
fprintf(stderr, "stderr:hello fprintf\n"); //stderr
return 0;
}
直接运行程序,结果就是在显示器上输出四行字符串。
这样看起来标准输出流和标准错误流并没有区别,都是向显示器输出数据。但我们若是将程序运行结果重定向输出到文件log.txt当中,我们会发现log.txt文件当中只有向标准输出流输出的两行字符串,而向标准错误流输出的两行数据并没有重定向到文件当中,而是仍然输出到了显示器上。
实际上我们使用重定向时,重定向的是文件描述符是1的标准输出流,而并不会对文件描述符是2的标准错误流进行重定向。
六 dup2
要完成重定向我们只需进行fd_array数组当中元素的拷贝即可。例如,我们若是将fd_array[3]当中的内容拷贝到fd_array[1]当中,因为C语言当中的stdout就是向文件描述符为1文件输出数据,那么此时我们就将输出重定向到了文件log.txt。
在Linux操作系统中提供了系统接口dup2,我们可以使用该函数完成重定向。dup2的函数原型如下:
int dup2(int oldfd, int newfd);
函数功能: dup2会将fd_array[oldfd]的内容拷贝到fd_array[newfd]当中,如果有必要的话我们需要先使用关闭文件描述符为newfd的文件。
函数返回值: dup2如果调用成功,返回newfd,否则返回-1。
使用dup2时,我们需要注意以下两点:
如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。
如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。
例如,我们将打开文件log.txt时获取到的文件描述符和1传入dup2函数,那么dup2将会把fd_arrya[fd]的内容拷贝到fd_array[1]中,在代码中我们向stdout输出数据,而stdout是向文件描述符为1的文件输出数据,因此,本应该输出到显示器的数据就会重定向输出到log.txt文件当中。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd < 0){
perror("open");
return 1;
}
close(1);
dup2(fd, 1);
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
return 0;
}
代码运行后,我们即可发现数据被输出到了log.txt文件当中。
七 FILE
7.1 FILE当中的文件描述符
因为库函数是对系统调用接口的封装,本质上访问文件都是通过文件描述符fd进行访问的,所以C库当中的FILE结构体内部必定封装了文件描述符fd。
首先,我们在/usr/include/stdio.h头文件中可以看到下面这句代码,也就是说FILE实际上就是struct _IO_FILE结构体的一个别名。这里不得不再提出这里已经讲过了。
typedef struct _IO_FILE FILE;
而我们在/usr/include/libio.h
头文件中可以找到struct _IO_FILE
结构体的定义,在该结构体的众多成员当中,我们可以看到一个名为_fileno
的成员,这个成员实际上就是封装的文件描述符。
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno; //封装的文件描述符
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
7.2 FILE当中的缓冲区
我们来看看下面这段代码,代码当中分别用了两个C库函数和一个系统接口向显示器输出内容,在代码最后还调用了fork函数。
#include <stdio.h>
#include <unistd.h>
int main()
{
//c
printf("hello printf\n");
fputs("hello fputs\n", stdout);
//system
write(1, "hello write\n", 12);
fork();
return 0;
}
但是,当我们将程序的结果重定向到log.txt文件当中后,我们发现文件当中的内容与我们直接打印输出到显示器的内容是不一样的。
那为什么C库函数打印的内容重定向到文件后就变成了两份,而系统接口打印的内容还是原来的一份呢?
首先我们应该知道的是,缓冲的方式有以下三种:
- 无缓冲。
- 行缓冲。(常见的对显示器进行刷新数据)
- 全缓冲。(常见的对磁盘文件写入数据)
当我们直接执行可执行程序,将数据打印到显示器时所采用的就是行缓冲,因为代码当中每句话后面都有\n,所以当我们执行完对应代码后就立即将数据刷新到了显示器上。
而当我们将运行结果重定向到log.txt文件时,数据的刷新策略就变为了全缓冲,此时我们使用printf和fputs函数打印的数据都打印到了C语言自带的缓冲区当中,之后当我们使用fork函数创建子进程时,由于进程间具有独立性,而之后当父进程或是子进程对要刷新缓冲区内容时,本质就是对父子进程共享的数据进行了修改,此时就需要对数据进行写时拷贝,至此缓冲区当中的数据就变成了两份,一份父进程的,一份子进程的,所以重定向到log.txt文件当中printf和puts函数打印的数据就有两份。但由于write函数是系统接口,我们可以将write函数看作是没有缓冲区的,因此write函数打印的数据就只打印了一份。
这个缓冲区是谁提供的?
实际上这个缓冲区是C库自带的,如果说这个缓冲区是操作系统提供的,那么printf、fputs和write函数打印的数据重定向到文件后都应该打印两次。
这个缓冲区在哪里?
我们常说printf是将数据打印到stdout里面,而stdout就是一个FILE*
的指针,在FILE结构体当中还有一大部分成员是用于记录缓冲区相关的信息的。也就是说,这里的缓冲区是由C库提供,在FILE结构体当中进行维护的,FILE结构体当中不仅保存了对应文件的文件描述符还保存了用户缓冲区的相关信息 。
操作系统有缓冲区吗?
操作系统实际上也是有缓冲区的,当我们刷新用户缓冲区的数据时,并不是直接将用户缓冲区的数据刷新到磁盘或是显示器上,而是先将数据刷新到操作系统缓冲区,然后再由操作系统将数据刷新到磁盘或是显示器上。(操作系统有自己的刷新机制,我们不必关系操作系统缓冲区的刷新规则)