目录
前言
我们之前都有学过文件操作相关的函数,能够利用C语言相关的库函数进行文件的写入和读取;我们只是会用相关的库函数接口,但是并不知道文件究竟是怎么被写入的,怎么被读取的,文件操作的底层原理究竟是什么一概不知,本篇博客将会详细介绍文件操作的底层原理。让我们对文件操作有一个新的认识。
接下来将会从一下几点入手,带大家深入的理解文件操作:
- 复习C文件IO相关操作;
- 认识文件相关系统调用接口;
- 认识文件描述符,理解重定向;
- 对比fd和FILE,理解系统调用和库函数的关系;
一、复习C文件的IO相关操作
我们简单的复习一下C文件的操作,以写文件和对文件为例,其他相关的操作可以去看这篇博客:【C语言】—— 文件操作(详解)
1.C语言中相关的文件操作接口介绍
函数接口 | 函数说明 |
---|---|
fopen | 打开文件 |
fclose | 关闭文件 |
fputc | 一次写一个字符 |
fgetc | 一次读一个字符 |
fputs | 写一行数据 |
fgets | 读一行数据 |
fprintf | 格式化输出函数 |
fscanf | 格式化输入函数 |
fwrite | 以二进制的形式将数据写入 |
fread | 以二进制的形式将数据读出来 |
fseek | 根据文件指针的位置和偏移量来定位文件指针 |
ftell | 计算文件指针相对于起始位置的偏移量 |
rewind | 让文件指针回到起始位置 |
feof | 判断是不是遇到文件末尾而结束的 |
ferror | 判断是不是遇到错误后读取结束 |
2.C语言中的写文件
3.C语言中的读文件
4.C程序默认打开的三个输入输出流
以上我们简单演示了C语言的文件操作,但这只是停留在语言层面上,简单的会使用还是不够的,很难对文件有一个比较深刻的理解。我们都知道C程序会打开三个默认输入输出流:
extern FILE *stdin; //标准输入 --- 所对应的是键盘 extern FILE *stdout; //标准输出 --- 所对应的是显示器 extern FILE *stderr; //标准错误 --- 所对应的是显示器
我们刚刚在使用fputs函数向文件写入数据,而文件的类型和这里默认打卡的三个流的类型是一样的,那么我们也可以直接向显示器写入数据:
fputs能够像一般文件或者是硬件写入数据,我们就可以理解为一切皆文件;(后面做解释)
二、系统文件IO
我们无论是写文件还是读文件,文件都是来源于硬件(磁盘...),硬件是由操作系统管理的;用户不能够直接跳过操作系统将文件写入,必须贯穿整个操作系统。那么访问操作系统就需要调用系统接口来实现文件向硬件写入的操作;也就是你在C语言或其他语言上使用的文件相关库函数,其底层都是要调用系统接口的;如下图所示:
1. open
系统接口使用open函数打开文件,其所需的头文件有三个。
#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int open(const char *pathname, int flags, mode_t mode);
1.open的参数
参数:
1.pathname:你要打开的文件路径,如果没有会自动创建
2.flags:以什么样的形式打开:
- O_RDONLY --- 以只读的方式打开文件
- O_WRNOLY --- 以只写的方式打开文件
- O_APPEND --- 以追加的方式打开文件
- O_RDWR --- 以读写的方式打开文件
- O_CREAT --- 当目标文件不存在时,创建文件
3.mode:表示创建文件时的权限设置
在上述代码中的第二个参数需要解释一下,我们刚刚已经了解了第二个参数是以什么样的形式打开文件,它是操作系统在用户层面上给内核层传递的标志位。flags的类型是int,他就有32个比特位,一个比特位就可以代表一个标志位,如果两个或起来就可以传递多个标志位。操作系统内部在进行按位与运算,判断那个位被设置了1或0,从而对文件打开方式进行设置。
实际上传入flags的每一个选项在系统当中都是以宏的方式进行定义的:
2.open的返回值
open的返回值是fd——文件描述符 (文件打开成功,返回对应的fd值,打开失败,返回的是-1)
从下图的运行结果发现,文件描述符从3开始,依次递增,有点像数组的下标;那么0/1/2是什么呢?这里没有从0开始,其实,是将0/1/2分配给了三个流:0---标准输入、1---标准输出、2---标准错误
2.close
关闭文件描述符:
int close(int fd);
关闭文件只需要传入你想要关闭的文件描述符即可;
3.write
向文件描述符写入数据:
#include <unistd.h> ssize_t write(int fd, const void *buf, size_t count);
参数解读:将buf中的数据写入fd,写入count个
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd = open("log1.txt", O_WRONLY | O_CREAT, 0644);
if(fd < 0){
perror("open");
return 1;
}
const char* msg = "hello linux!\n";
int count = 5;
while(count--){
write(fd, msg, strlen(msg));
}
close(fd);
return 0;
}
4.read
从文件描述符读数据:
ssize_t read(int fd, void *buf, size_t count);
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd = open("./log1.txt",O_RDONLY);
if(fd < 0){
perror("open");
return -1;
}
char buffer[128];
ssize_t ss = read(fd, buffer, sizeof(buffer) - 1);
if(ss > 0){
buffer[ss] = 0;
}
close(fd);
printf("%s\n",buffer);
return 0;
}
三、文件描述符fd
一个进程可以打开多个文件,当操作系统中存在大量的文件时,操作系统就需要对这些文件进行管理,在内核当中,管理这些已经打开的文件就要设计对应的结构体,把打开的文件描述起来,我们把描述文件的结构体称为(struct file),然后将这些结构体以双链表的形式链接起来,便于管理。
多个进程和多个文件在操作系统中,是如何区分哪一个文件属于哪一个进程的呢?
操作系统为了能够让进程和文件之间产生关系,进程在内核当中包含了一个结构 struct files_struct,这个结构中又包含了一个数组结构struct file* fd_array[ ] 在task_struct的PCB当中又包含了一个指针 struct files_struct* fs,用来管理这个struct files_struct;我们把对应的描述文件结构的地址写到特定的下标里。所有为什么我们在之前打印文件描述符fd时是从3开始的,是因为前面3个地址留给了三个流,当有新的文件打开时,首先是形成struct files结构体,然后将地址写入下标3的位置。然后返回给上层用户,我们就拿到了3这个下标了。
当我们在使用write和read时,都需要传入fd,本质上就是去进程的PCB中找到fd所对应的文件,就可以对文件进行操作了;
结论:fd本质是内核中进程和文件相关联的数组下标
四、一切皆文件
对于我们的外设(IO设备),在驱动层一定对应了相应的驱动程序,包括他们各自的读写方法;他们的读写方法是不一样的;
在操作系统层面上,对于底层的键盘、显示器、磁盘等外设,需要打开时,操作系统就会给这些外设创建一个struct file的结构体进行维护,这些结构体就包含了相关外设的属性信息,再将它们用双链表管理起来。再与上层的进程结合起来既可以执行对对应的操作了。这里就是所谓的虚拟文件系统(VFS)
我们在C++的学习中,对多态的概念有所了解,就是多个子类继承了相同的父类,每个子类的方法都是不一样的,只要父类的指针或引用调用对应的子类,就去实现对应子类的方法。在C语言中,想要实现多态,我们的方法是通过函数指针;
在这个struct file的结构中,就包含了读写方法的函数指针,对应到了每个外设;在上层看来,所有的文件只要调用对应外设的读写方法即可,根本不关心你到底是什么文件。
本质上,所谓的一切皆文件,就是站在struct file的层面上看待的。
五、文件描述符的分配规则
我们先来看一下连续打开4个文件,所对应的fd都是什么?
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd1 = open("./log1.txt", O_CREAT | O_WRONLY, 0644);
int fd2 = open("./log2.txt", O_CREAT | O_WRONLY, 0644);
int fd3 = open("./log3.txt", O_CREAT | O_WRONLY, 0644);
int fd4 = open("./log4.txt", O_CREAT | O_WRONLY, 0644);
printf("fd1:%d\n",fd1);
printf("fd2:%d\n",fd2);
printf("fd3:%d\n",fd3);
printf("fd4:%d\n",fd4);
return 0;
}
从运行结果看,它是从3开始向上增长的,因为0/1/2被三个流所占用。
如果我们将0关闭,会是什么样的呢? 在原来代码基础上加上 close(0);
我们再将2关闭 close(2);
给新文文件分配fd,是从fd_array数组中找一个最小的,没有被使用过的,作为新的fd。
六、重定向
本来应该写到显示器的数据,去写到了文件中,我们把这种叫做重定向;
1.输出重定向
我们先看一下面的代码,我们本意是想将hello linux!打印到显示器上:
我们只是加了一个close(1);这段代码,为什么就打印到文件中了呢?
printf函数本质是向stdout输出数据的,而stdout是一个struct FILE*类型的指针,FILE是C语言层面上的结构体,该结构体当中有一个存储文件描述符fd,而stdout指向的FILE结构体中存储的文件描述符就是1,因此printf实际上就是向文件描述符为1的文件输出数据。
C语言的数据并不是立马写到操作系统里面,而是写到了C语言的缓冲区当中,所以使用printf打印完后需要使用fflush将C语言缓冲区当中的数据刷新到文件中。
2.追加重定向
追加重定向和输出重定向的本质区别在于,前者不会覆盖原来的数据内容
3.输入重定向
输入重定向就是,将我们本应该从一个文件读取数据,现在重定向为从另一个文件读取数据
4.stdout和stderr有什么区别呢?
标准输出流和标准错误流对应的都是显示器,它们有什么区别呢?
我们同时向标准输出和标准错误中输出数据,都是能够打印到键盘上的;当我们将其重定向到文件中去时,却发现只有stdout的内容重定向到了文件中。实际上我们在使用重定向的时候,是把文件描述符1的标准输出流重定向了,而不会对标准错误流重定向。
七、系统调用dup2
以上的操作我们都是在关闭标注输入和标准输出后完成重定向的,显得很麻烦,如果标准输入和标准输出都被占用(已经打开了),我们如何去完成重定向呢?要完成重定向我们就可以将fd_array数组中的元素进行拷贝即可;例如:我们将fd_array[3]中的内容拷贝到fd_array[1]中,因为C语言当中的stdout就是向文件描述符为1文件输出数据,那么此时我们就将输出重定向到了文件log.txt 。
在Linux操作系统中提供了系统接口dup2,我们可以使用该函数完成重定向。本质上dup2就是将进程中文件描述表中的需要重定向的内容进行相关的拷贝,dup2的函数原型如下:
#include<unistd.h>
int dup2(int oldfd, int newfd);
函数功能:dup2会将fd_array[oldfd]的内容拷贝到fd_array[newfd]中;
函数返回值:调用成功返回0,失败返回-1;
使用dup2时,我们需要注意以下两点:
- 如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。
- 如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd
使用dup2——输出重定向
运行结果:
使用dup2——输入重定向
运行结果:
八、关于FILE
1.C库当中的FILE结构体
因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。所以C库当中的FILE结构体内部,必定封装了fd。我们可以使用vim 打开 usr/include/stdio.h 的文件查看FILE。
typedef struct _IO_FILE FILE; //在/usr/include/stdio.h
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
};
2.FILE结构体中的缓冲区
从FILE的源码当中我们发现了FILE结构体里面封装了fd,也就是里面的_fileno。不难发现我们在里面还看到了缓冲区。这里的缓冲区指的是C语言当中的缓冲区。
1.初步了解缓冲区
我们先来看一下下面的代码,代码的含义是输出重定向,观察是否关闭文件描述符,对结果有何影响?
1.不关闭文件描述符,进行输出重定向
2.关闭文件描述符,进行输出重定向
2.缓冲区的深入理解
通过上面两次的运行结果发现,在关闭文件描述符后,重定向的操作失败了,其本质原因就是数据是暂存在缓冲区(用户级缓冲区)的。在操作系统内部也是存在一个内核缓冲区的,用户缓冲区到内核缓冲区的刷新策略有如下几种:
- 立即刷新:不缓冲
- 行刷新(行缓冲 \n),比如,显示器打印
- 缓冲区满了,才刷新(全缓冲),比如,往磁盘文件中写入
当我们向磁盘,显示器等设备写入数据时,一般的流程为,进程运行起来后,数据先是暂存到用户级缓冲区,通过系统调用接口,数据又被暂存到了内核缓冲区,当进程结束时,会自动刷新内核缓冲区的数据到相应的外设中;(从C缓冲区到内核缓冲区也一定是需要fd的)
显示器是行缓冲,即遇到'\n'就会刷新数据到显示器;磁盘是全缓冲,当缓冲区满了以后,才会刷新数据到磁盘上;
当我们在重定向时,其数据的刷新策略也会发生变化,(上面的代码中)本来我们是行缓冲的,但是重定向后就变成了全缓冲;两者都是要通过系统调用接口(open)来完成数据的写入; 在没有关闭文件描述符fd时,我们能看到重定向后的结果,本质是进程结束了,刷新了缓冲区;在关闭文件描述符fd后,既没有向显示器打印,也没有向文件中打印,本质就是,它要通过系统调用接口先将数据暂存到内核缓冲区,待进程结束后,才刷新到相应的外设中,但是fd已经关闭,就不会刷新到内核在刷新到硬件,所以就看不到任何数据;
再来看一下下面这段代码:
运行结果:
通过上面的运行结果,再结合之前所说,我们这里不是关闭了1吗?为什么还是能够打印出来呢?我们可以看到这里四条输出语句都是向显示器上打印的,并且都有'\n',表明是行刷新,在关闭1之前就已经刷新到显示器上了。
标准错误不会重定向我们能够理解,但是其他三条语句应该是重定向到文件中呀,而这里运行结果只有一条hello stdout。这是因为,我们的msg1是直接通过系统调用接口,把数据暂存到内核缓冲区,不会把数据暂存到上层的用户级缓冲区,所以关闭1根本就不会影响这个数据刷新到文件;但是下面两个语句由于重定向的原因,刷新策略发生了变化(行缓冲->全缓冲),数据暂存到用户级缓冲区后,本来是等待进程结束后刷新到文件中去的,但是这个过程中却把1关闭了,才导致这两条数据并没有被刷新到文件中;
对于以上的理解有了新的认识后,我们再来看一下这段代码:
运行结果:
通过上面的运行结果发现, 当我们直接运行程序时,它向显示器上打印了3条语句,但是我们程序中有创建子进程的语句,当我们重定向后发现文件中多打印了两条语句,而且只是针对C语言的接口,而非系统调用接口;
当我们直接执行可执行程序,将数据打印到显示器时所采用的就是行缓冲,因为代码当中每句话后面都有\n,所以当我们执行完对应代码后就立即将数据刷新到了显示器上。
而当我们将运行结果重定向到log.txt文件时,数据的刷新策略就由行缓冲变成了全缓冲,此时我们使用printf和fprintf函数打印的数据都暂存到了C语言自带的缓冲区当中,之后当我们使用fork函数创建子进程时,由于进程间具有独立性,而之后当父进程或是子进程对要刷新缓冲区内容时,本质就是对父子进程共享的数据进行了修改,此时就需要对数据进行写时拷贝,至此缓冲区当中的数据就变成了两份,一份父进程的,一份子进程的,所以重定向到log.txt文件当中printf和fprintf函数打印的数据就有两份。
3.如何解决缓冲区不刷新的问题
注:部分概念的了解还有待完善(inode和软硬链接)