话接上篇:
1.文件描述符fd
磁盘文件 VS 内存文件?
当文件存储在磁盘当中时,我们将其称之为磁盘文件,而当磁盘文件被加载到内存当中后,我们将加载到内存当中的文件称之为内存文件。磁盘文件和内存文件之间的关系就像程序和进程的关系一样,当程序运行起来后便成了进程,而当磁盘文件加载到内存后便成了内存文件。
进程想要访问文件必须先打开文件,一个进程可以打开多个文件,而系统当中又存在大量进程,也就是说,在系统中任何时刻都可能存在大量已经打开的文件,已经打开的文件会被加载到了内存中,这些文件也叫内存文件,反之,没有打开的文件就叫做磁盘文件。那么操作系统就要管理这些打开的文件。
如何管理就是先描述,再组织。操作系统为每个已经打开的文件创建各自的struct file结构体,然后将这些结构体以双链表的形式连接起来,那么操作系统对文件的管理也就变成了对这张双链表的增删改查等操作,在每个节点中不仅有链表的指针,还应该存在着文件的内容+属性,这些信息大部分在磁盘中就保留在文件内部了,加载的时候就从磁盘中把数据加载到内存。
而为了区分已经打开的文件哪些属于特定的某一个进程,我们就还需要建立进程和文件之间的对应关系。
进程和文件之间的对应关系是如何建立的?
当进程运行的时候,操作系统会将该程序的代码和数据加载到内存,然后创建对应的task_struct, mm_struct, 页表等…
task_struct 里面有一个指针,指向files_struct结构体,结构体里面有名为fd_array的指针数组,该数组的下标就是文件描述符fd。
使用read和write的时候要传入文件描述符,通过文件描述符找到这个数组中的指针,进而对文件访问。
当进程打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file连入文件双链表,并将该结构体的首地址填入到fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后返回该文件的文件描述符给调用进程即可。
因此,我们只要有某一文件的文件描述符,就可以找到与该文件相关的文件信息,进而对文件进行一系列输入输出操作。
注意: 向文件写入数据时,是先将数据写入到对应文件的缓冲区当中,然后定期将缓冲区数据刷新到磁盘当中。
1.1.文件描述符的分配规则
我们之前连续打开了6个文件,我们发现文件描述符是从3开始的,并且是连续地址的。那真的是一直从3开始吗?下面我们看一段代码:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
close(0);
int fd1=open("./log1.txt",O_WRONLY|O_CREAT,0644);
int fd2=open("./log2.txt",O_WRONLY|O_CREAT,0644);
int fd3=open("./log3.txt",O_WRONLY|O_CREAT,0644);
int fd4=open("./log4.txt",O_WRONLY|O_CREAT,0644);
printf("%d\n",fd1);
printf("%d\n",fd2);
printf("%d\n",fd3);
printf("%d\n",fd4);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}
我们发现怎么fd从0开始了,而之后的又是从3开始了。现在我们在将2也关了,我们再来看结果会是如何。
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
close(0);
close(2);
int fd1=open("./log1.txt",O_WRONLY|O_CREAT,0644);
int fd2=open("./log2.txt",O_WRONLY|O_CREAT,0644);
int fd3=open("./log3.txt",O_WRONLY|O_CREAT,0644);
int fd4=open("./log4.txt",O_WRONLY|O_CREAT,0644);
printf("%d\n",fd1);
printf("%d\n",fd2);
printf("%d\n",fd3);
printf("%d\n",fd4);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}
我们发现0和2也被用起来了。现在我们就明白了文件描述符的分配规则是从最小的未被使用的下标开始的
事实上
Linux下进程默认会打开三个文件描述符,0:标准输入、1:标准输出、2:标准错误。
0,1,2对应的物理设备一般是:键盘、显示器、显示器。
我们之前验证了文件描述符默认是从3开始的,也就是说0,1,2是默认被打开的。
- 0代表的是标准输入流,对应硬件设备为键盘;
- 1代表标准输出流,对应硬件设备是显示器;
- 2代表标准错误流,对应硬件设备为显示器。
当一个进程被创建时,OS就会根据键盘、显示器、显示器形成各自的struct file,将这3个struct file链接到文件的双链表当中,并将这3个struct file的地址分别填入fd_array数组下标为0、1、2的位置,至此就默认打开了标准输入流、标准输出流和标准错误流。
文件描述符的分配规则:分配最小的,没有被占用的。如果我把0号关闭,那么为新文件分配的时候就从最小的0分配。
2.重定向
2.1.输出重定向
1.输入重定项。
我们之前学习过的输出重定向就是,将我们本应该输出到显示器上的数据重定向输出到另一个文件中。那他的原理是什么了?
例如: 如果我们想让本应该输出到“显示器文件”的数据输出到log.txt文件当中,那么我们可以在打开log.txt文件之前将文件描述符为1的文件关闭,也就是将“显示器文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是1。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.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 | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
exit(1);
}
// 打开成功
printf("fd: %d\n", fd);
printf("fd: %d\n", fd);
printf("fd: %d\n", fd);
printf("fd: %d\n", fd);
fprintf(stdout, "hello fprintf\n");
const char* s = "hello fwrite\n";
fwrite(s, strlen(s), 1, stdout);
fflush(stdout);
// 关闭文件
close(fd);
return 0;
}
通过上面的现象也可以看出,打印的数据没有到显示器上,而是到了磁盘的文件中,这是为什么呢?
上面就说过,0、1、2默认是被打开的,对应的就要打开显示器,所以stdout的文件描述符就是1,所以C语言的接口fprintf认识的就是stdout或者说就是1,我们一开始就关闭了1号文件描述符,把数组下标为1的位置设置为NULL,然后打开了log.txt文件,此时1没有被占用,所以就把下标为1的位置填入log.txt的结构体的地址,log.txt的文件描述符就是1了,但是上层的C语言函数认识的还是1,他们还是继续往1中写入,这样就不能打印到屏幕而是重定向到了文件中。
重定向的本质是在操作系统中更改fd对应的内容,上面演示的这就就叫做输出重定向。
2.2.输入重定向
输入重定向就是,将我们本应该从一个键盘上读取数据,现在重定向为从另一个文件读取数据。
比如说我们的fget函数是从标准输入读取数据,现在我们让它从log1.txt当中读取数据,我们在scanf读取数据之前close(0).这样键盘文件就被关闭,这样一样log1.txt的文件描述符就是0.
int main()
{
close(0);
// 打开文件
int fd = open("log.txt", O_RDONLY);
if (fd < 0)
{
perror("open");
exit(1);
}
printf("fd: %d\n", fd);
char buffer[64];
fgets(buffer, sizeof(buffer), stdin);
printf("%s\n", buffer);
// 关闭文件
close(fd);
return 0;
}
关闭了0号文件描述符,所以打卡的新文件的文件描述符就变成了0,然后读取了文件中的第一行数据。
2.3.追加重定向
还有一种就是追加重定向,更改一下选项就行了。
int main()
{
close(1);
// 打开文件
int fd = open("log.txt", O_WRONLY | O_APPEND | O_CREAT);
if (fd < 0)
{
perror("open");
exit(1);
}
printf("%d\n", fd);
fprintf(stdout, "append success\n");
fflush(stdout);
// 关闭文件
close(fd);
return 0;
}
【注意】:“>”输出重定向修改的只是1号也就是stdout标准输出,所以尽管程序中有两行代码,一行向1号文件描述符中打印,另一行向2号文件描述符中打印,那么使用输出重定向只会使1号文件描述符重定向,2号还是打印到显示器上。
2.4.dup2
我们发现我们上面只能通过close关闭对应的文件描述符实习对应的输出重定向和输出重定向,那我们能不能不关闭呢?
要完成重定向我们只需对fd_array数组当中元素进行拷贝即可。
例如,我们若是将fd_array[3]当中的内容拷贝到fd_array[1]当中,因为C语言当中的stdout就是向文件描述符为1文件输出数据,那么此时我们就将输出重定向到了文件log.txt。而在linux当中就给我们提供了这个系统调用:
- 函数功能: dup2会将fd_array[oldfd]的内容拷贝到fd_array[newfd]当中。
- 函数返回值:调用成功返回0,失败返回-1
使用的过程中需要注意:
- 如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。
- 如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。
只需要把想要重定向的文件在数组中拷贝过去,比如我想要输出重定向,重定向到某个文件,那么1就代表标准输出,所以就要改变1的指向,就把3的地址拷贝过去,这样1就指向了重定向的文件。
输入重定向也是一样的,0是标准输入,就要从其他文件输入,就把其他文件的地址拷贝到0的位置。
下面通过dup2演示一下前面的输出重定向:
1 #include<stdio.h>
2 #include<sys/types.h>
3 #include<sys/stat.h>
4 #include<unistd.h>
5 #include<fcntl.h>
6 int main()
7 {
8 int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);
9 dup2(fd,1);
10 printf("hello world\n");
11 printf("hello world\n");
12
13 }
2.5.重定向的本质
重定向的本质,其实是在OS内部,更改fd对应的内容的指向!!
3.FILE
FILE是C语言定义的文件结构体,里面包含了各种文件信息。可以肯定的一点是,FILE结构体内一定封装了 fd 。为什么?来看接下来的思路分析:
1.使用系统接口的必然性
文件存储在磁盘上,属于外设。谁有权限访问外设呢?只有操作系统。因为操作系统对上要提供稳定的服务,对下要管理好各种软硬件资源。
如果文件操作能绕开操作系统,那么操作系统怎么知道某个文件到底有没有被创建,有没有被销毁呢,还怎么给你提供稳定的服务呢?基于上述简单的认识,我们不难理解,要想访问硬件资源,就必须通过操作系统。
而操作系统出于安全性和减少使用成本的角度考虑,是不相信任何人的。就像银行一样,不会将金库直接向大众开放,而是只会有几个业务窗口为大家提供服务。操作系统也是这样,操作系统提供的窗口就是系统接口。
至此通过我们的逻辑推演,我们已经可以得出以下的结论:要想访问外设就必须使用操作系统提供的系统接口。所以C语言的各种文件操作函数本质就是对系统接口的封装
2.FILE结构体封装fd的必然性
C语言的文件操作都是系统统接口的封装,而系统接口的使用只认fd,因此FILE结构体中必然会封装fd
验证的方法也很简单直接:
FILE究竟是个什么东西呢?是一个c语言提供的结构体类型
我们在/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
};
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;
}
运行该程序,我们可以看到printf、fputs和write函数都成功将对应内容输出到了显示器上
但是,当我们将程序的结果重定向到log.txt文件当中后,我们发现文件当中的内容与我们直接打印输出到显示器的内容是不一样的。
同样一个程序,为什么C库函数printf和fputs打印的内容重定向到文件后就变成了两份,而系统接口write打印的内容还是原来的一份呢?
我们先来了解一下缓冲区
首先我们应该知道的是,缓冲的方式有以下三种:
- 无缓冲。
- 行缓冲。(常见的对显示器进行刷新数据)
- 全缓冲。(常见的对磁盘文件写入数据)
当我们直接执行可执行程序,将数据打印到显示器时所采用的就是行缓冲,因为代码当中每句话后面都有\n,所以当我们执行完对应代码后就立即将数据刷新到了显示器上。
而当我们将运行结果重定向到log.txt文件时,数据的刷新策略就变为了全缓冲,此时我们使用printf和fputs函数打印的数据都打印到了C语言自带的缓冲区当中,之后当我们使用fork函数创建子进程时,由于进程间具有独立性,而之后当父进程或是子进程对要刷新缓冲区内容时,本质就是对父子进程共享的数据进行了修改,此时就需要对数据进行写时拷贝,至此缓冲区当中的数据就变成了两份,一份父进程的,一份子进程的,所以重定向到log.txt文件当中printf和puts函数打印的数据就有两份。
但由于write函数是系统接口,我们可以将write函数看作是没有缓冲区的,因此write函数打印的数据就只打印了一份。
这个缓冲区是谁提供的?
printf fwrite 是库函数,write是系统调用,库函数在系统调用的“上层”, 是对系统 调用的“封装”,但是 write 没有缓冲区,而 printf fwrite 有,足以说明,该缓冲区是二次加上的,又因为 是C,所以是C标准库提供的
换句话说如果说这个缓冲区是操作系统提供的,那么printf、fputs和write函数打印的数据重定向到文件后都应该打印两次。
这个缓冲区在哪里?
我们常说printf是将数据打印到stdout里面,而stdout就是一个FILE*的指针,在FILE结构体当中还有一大部分成员是用于记录缓冲区相关的信息的。
//缓冲区相关
/* 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. */
也就是说,这里的缓冲区是由C语言提供,在FILE结构体当中进行维护的,FILE结构体当中不仅保存了对应文件的文件描述符还保存了用户缓冲区的相关信息。
操作系统有缓冲区吗?
操作系统实际上也是有缓冲区的,当我们刷新用户缓冲区的数据时,并不是直接将用户缓冲区的数据刷新到磁盘或是显示器上,而是先将数据刷新到操作系统缓冲区,然后再由操作系统将数据刷新到磁盘或是显示器上。(操作系统有自己的刷新机制,我们不必关系操作系统缓冲区的刷新规则)
进程替换时,是否会干扰重定向对应的数据结构?
它们当然不会互相影响。换而言之,将来 fork 创建子进程,子进程会以父进程的大部分数据为模板,子进程进行程序替换时并不会影响曾经打开的文件,也就不会影响重定向对应的数据结构。