目录
一、缓冲区
1、作用
# 缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
# 为什么要引入缓冲区机制呢?
# 读写文件时,如果不会开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。
# 为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。
# 又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备占用CPU,解放出CPU,使其能够高效率工作。
2、用户级(语言)缓冲区
# 我们看之前写的一段代码:
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
close(1);//关闭标准输出流
int fd=open("log.txt",O_WRONLY|O_CREAT,0666);
if(fd<0)
{
perror("open fail:");
return 1;
}
//向屏幕打印信息
printf("hello tata!\n");
printf("hello tata!\n");
printf("hello tata!\n");
printf("hello tata!\n");
printf("hello tata!\n");
close(fd);
return 0;
}
# 为什么没有打印信息呢?其实这就与我们C语言的缓冲区有关。
# 我们都知道操作系统内部会为每个文件创建一个文件内核缓冲区,而我们的库函数printf、fprintf、fput等,并不是直接把内容直接写到文件内核缓冲区里面,而是写入C语言标准库里面的语言级缓冲区里面。只有当我们的刷新条件满足时,才会把语言级别缓冲区的内容拷贝到文件内核缓冲区里面。
# 那他是如何拷贝到内核缓冲区的呢?答案是fd+write系统调用根据文件描述符找到对应文件缓冲区在通过系统调用write拷贝。
# 所以这就是为什么我们close(fd)后,库函数的内容没有写入log.txt文件里面。因为我们return前close(fd) ,而我们使用库函数并没有将字符串写入到内核缓冲区,而是保留在语言缓冲区。当进程退出时,语言缓冲区的内容会刷新到内核缓冲区,但是我们在return前关闭了文件描述符,而刷新要有fd+write,所以此时就找不到fd,导致内核缓冲区无法刷新。
# 那么我们的缓冲区是定义在哪的呢?
# 由于我们使用的printf
是C语言提供的接口,所以这个缓冲区也是C语言提供的,其被包含在名为File
的结构体中,不光是缓冲区,文件描述符fd
也被包含在其中。这也是为什么C语言的文件接口需要返回File*
的原因。
//在/usr/include/libio.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
};
3、缓冲类型
# 进程强制刷新就是我们手动刷新,进程退出后操作系统在我们程序结束后刷新,那刷新条件又是什么呢?
# 这就是缓冲区常见的三种刷新策略:
- 无缓冲。
- 行缓冲。(常见的对显示器进行刷新数据)
- 全缓冲。(常见的对磁盘文件写入数据)
# 刷新条件一般有这三种,那为什么C语言要提供缓冲区呢?
# 因为系统调用也是有成本的,malloc底层也是系统调用,所以我们vector扩容时扩2倍或1.5倍,就是为减少扩容次数,从而减少系统调用次数提高效率。所以我们有了语言缓冲区就可以把多次文件写入的内容通过一次系统调用刷新到内核缓冲区,减少系统调用的次数进而提高效率,否则我们写一次就需要一次系统调用。C++也是如此,所以C++不带endl刷新,也可能不会刷新。
# 我们的全缓冲效率是最高的,因为刷新的越多系统调用次数越少。所以我们的普通文件一般使用的是全缓冲,但是我们的显示器文件一般是行缓冲,所以我们printf是他会检测\n,如果有\n,他就会把\n之前的所有数据刷新到缓冲区上。如果没有加\n
就是全缓冲,否则就是行缓冲。
# 为什么显示器要采用行刷新呢?因为行刷新更符合用户阅读习惯,否则一刷新一大片不利于阅读。同时内核缓冲区的刷新条件也是一样的,但是操作系统的刷新由操作系统自己决定,我们用户只要把数据交给操作系统,就相当于交给了硬件,因为操作系统可能面临内存不足的场景,此时操作系统就要立即刷新。
# 我们通过库函数接口,把数据拷贝到语言缓冲区,把语言缓冲刷新到内核缓冲区,内核缓冲区刷新到硬件等本质都是拷贝。所以计算机数据的流动本质:一切皆拷贝!
# 知道了缓冲区的刷新类型后,我们再对上面的代码进行分析:因为我们对文件进行了重定向,让本应该向屏幕打印的信息输入进一个磁盘文件,这时缓冲策略就从行缓冲变成了全缓冲,全缓冲需要程序结束之后才会向磁盘刷新文件内容,但是在此之前文件我们已经调用close接口关闭了对于的文件描述符,此时程序结束后就无法找到对应的文件,自然也不会对文件进行任何写入。所以一般为了解决这个问题,我们可以使用fflush函数提前刷新缓冲区。
4、系统级(内核)缓冲区
# 不仅是我们语言方面存在缓冲区,我们操作系统内部也会存在一个缓冲区,我们一般称为内核缓冲区。同样语言缓冲区刷新到系统缓冲区也遵循三种刷新策略。
# 所以说我们使用语言所提供的接口如printf
对文件进行写入数据,首先会将数据存放在语言缓冲区,然后根据不同的刷新规则再刷新到系统缓冲区中,最后才会将系统缓冲区的数据刷新到磁盘或者对应的外设之中。
# 比如说我们看看下面这段代码:
int main()
{
//库函数
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char *s = "hello fwrite\n";
fwrite(s, strlen(s), 1, stdout);
//系统调用
const char *ss = "hello write\n";
write(1, ss, strlen(ss));
//???
fork();
return0;
}
# 为什么重定向之后的内容会与之前截然不同呢?
# 这是因为我们执行可执行程序,打印到屏幕,默认是行缓冲,所以直接打印所以数据。但是如果我们对数据进行重定向的话,向磁盘写入数据,默认为全缓冲,此时数据都会存在语言缓冲区中。而此时我们创建子进程,父子进程之间代码数据共享,进程结束之后对语言缓冲区进行刷新,本质就是对数据进行修改,为了进程之间的独立性,就会发生写实拷贝,所以重定向之后C语言接口的数据打印会答应两份。而因为系统接口write写入的数据是直接写入系统缓冲区的,不需要发生写实拷贝,所以只打印一份。
二、自定义glibc
# 现在我们可以自己封装出一个fopen等库函数。
1、头文件
- 定义宏表示文件打开方式权限位
- 定义IO_FILE结构体 包含文件描述符 文件打开方式
- 文件缓冲区字符数组 缓冲区长度 刷新方式
- 声明库函数
#pragma once
#include <stdio.h>
#define MAX 1024
#define NONE_FLUSH (1<<0)
#define LINE_FLUSH (1<<1)
#define FULL_FLUSH (1<<2)
typedef struct IO_FILE
{
int fileno;
int flag;
char outbuffer[MAX];
int bufferlen;
int flush_method;
}MyFile;
MyFile *MyFopen(const char *path, const char *mode);
void MyFclose(MyFile *);
int MyFwrite(MyFile *, void *str, int len);
void MyFFlush(MyFile *);
2、BuyFile函数
- 申请IO_FILE结构体空间
- 初始化结构体内容 meset初始化缓冲区内容为0
- 返回结构体指针
static MyFile* BuyFile(int fd, int flag)
{
MyFile* ret = (MyFile*)malloc(sizeof(MyFile));
ret->fileno = fd;
ret->flag = flag;
ret->bufferlen = 0;
ret->flush_method = LINE_FLUSH;
memset(ret->outbuffer, 0, sizeof(ret->outbuffer));
return ret;
}
3、MyFopen函数
- 定义文件描述符fd和打开方式
- 根据打开方式参数mode分流 设置对应的打开方式
- 调用open系统调用打开path文件 BuyFile申请IO_FILE结构体
- 申请缓冲区 返回BuyFile的指针
MyFile* MyFopen(const char* path, const char* mode)
{
int fd = -1;
int flag = 0;
if (strcmp(mode, "w") == 0)
{
flag = O_CREAT | O_WRONLY | O_TRUNC;
fd = open(path, flag, 0666);
}
else if (strcmp(mode, "a") == 0)
{
flag = O_CREAT | O_WRONLY | O_APPEND;
fd = open(path, flag, 0666);
}
else if (strcmp(mode, "r") == 0)
{
flag = O_RDONLY;
fd = open(path, flag);
}
else
{
}
if (fd < 0)
{
return NULL;
}
return BuyFile(fd, flag);
}
4、MyFclose函数
- 判断文件描述符合法性 调用MyFFlush刷新文件缓冲区
- 调用close关闭文件 free释放IO_FILE结构体
void MyFclose(MyFile* file)
{
if (file->fileno 《》 0)
{
return;
}
MyFFlush(file);
MyFclose(file);
free(file);
}
5、MyFwrite函数
- memcpy拷贝内容到缓冲区 更新缓冲区长度
- 判断如果刷新方式为行刷新并且最后缓冲区最后一个字符为\n
- 调用MyFFlush刷新缓冲区
int MyFwrite(MyFile* file, void* str, int len)
{
memcpy(file->outbuffer + file->bufferlen, str, len);
file->bufferlen += len;
printf("%d->\n", file->bufferlen);
if ((file->flush_method & LINE_FLUSH) && file->outbuffer[file->bufferlen - 1] == '\n')
{
MyFFlush(file);
}
return 0;
}
6、MyFFlush函数
- 如果缓冲区为空不刷新
- 调用write刷新到文件缓冲区
- 文件缓冲区是否刷新到文件有操作系统决定
- 如果一定要刷新到文件中 可以使用fsync强制刷新 然后缓冲区长度为0;
void MyFFlush(MyFile* file)
{
if (file->bufferlen <= 0)
{
return;
}
int n = write(file->fileno, file->outbuffer, file->bufferlen);
//强制刷新
fsync(file->fileno);
file->bufferlen = 0;
}
7、测试一
- 所以这里我们不断向文件缓冲区写入不刷新 此时缓冲区的内容越来越多 当循环结束Flose后 语言缓冲区的内容统一刷新到内核文件缓冲区
- 此时文件的内容突然增多了 因为我们fsync强制刷新了 否则刷新到内核文件缓冲区不一定刷新到文件!
int main()
{
MyFile *filep = MyFopen("./log.txt", "a");
if(!filep)
{
printf("fopen error!\n");
return 1;
}
//char *msg = (char*)"hello myfile!\n";
int cnt = 10;
while(cnt--)
{
char *msg = (char*)"hello myfile!!!!!";
MyFwrite(filep, msg, strlen(msg));
printf("buffer:%s\n", filep->outbuffer);
sleep(1);
}
MyFclose(filep);// FILE *fp
return 0;
}
8、测试二
这里我们写一次刷新一次 所以缓冲区的内容永远只有一条消息 但是文件的内容写入一条增多一条消息。
int main()
{
MyFile *filep = MyFopen("./log.txt", "a");
if(!filep)
{
printf("fopen error!\n");
return 1;
}
//char *msg = (char*)"hello myfile!\n";
int cnt = 10;
while(cnt--)
{
char *msg = (char*)"hello myfile!!!!!";
MyFwrite(filep, msg, strlen(msg));
MyFFlush(filep);
printf("buffer:%s\n", filep->outbuffer);
sleep(1);
}
MyFclose(filep);// FILE *fp
return 0;
}
三、文件系统
# 前面我们谈论的文件都是加载进内存的内存文件,而接下来我们就来谈谈磁盘文件。
1、磁盘
# 磁盘是一种永久性存储介质,在计算机中,磁盘几乎是唯一的机械设备。与磁盘相对应的就是内存,但是内存是掉电易失存储介质,所以目前所有的普通文件都是在磁盘中存储的。
# 了解磁盘,我们首先需要了解一下几个常见的基本概念。
1.1 物理结构
1.2 存储结构 
扇区:是磁盘存储数据的基本单位,512字节,块设备
# 如何定位一个扇区呢?
- 可以先定位磁头(header)
- 确定磁头要访问哪一个柱面(磁道)(cylinder)
- 定位一个扇区(sector)
# 如果我们需要确定磁盘的哪个特定的区域,只需要找到对应的扇区,磁道,柱面即可。
# 首先磁头延伸到对应的磁道上,然后停止,等待磁盘循环到对应的扇区,然后还需要确定磁头写入面,此时就可以写入了。 所以磁头来回摆动是为了定位柱面磁道,磁盘旋转是为了定位扇区,结合磁头写入面就可以写入了。我们把这种定位方式叫做CHS定址。
# ⽂件 = 内容+属性,都是数据,⽆⾮就是占据那⼏个扇区的问题!能定位⼀个扇区了,就定位多个扇区。
- 扇区是从磁盘读出和写入信息的最小单位,通常大小为 512 字节。
- 磁头(head)数:每个盘片一般有上下两面,分别对应1个磁头,共2个磁头
- 磁道(track)数:磁道是从盘片外圈往内圈编号0磁道,1磁道…,靠近主轴的同心圆用于停靠磁头,不存储数据
- 柱面(cylinder)数:磁道构成柱面,数量上等同于磁道个数
- 扇区(sector)数:每个磁道都被切分成很多扇形区域,每道的扇区数量相同
- 圆盘(platter)数:就是盘片的数量
- 磁盘容量=磁头数 × 磁道(柱面)数 × 每道扇区数 × 每扇区字节数
- 细节:传动臂上的磁头是共进退的(这点比较重要,后面会说明)
# 在Linux
操作系统中,我们也可以通过指令ll /dev/vda*
查看我们磁盘的分区信息。
1.3 逻辑结构
1.3.1 理解过程
- 磁带上面可以存储数据,我们可以把磁带“拉直”,形成线性结构
- 那么磁盘本质上虽然是硬质的,但是逻辑上我们可以把磁盘想象成为卷在一起的磁带,那么磁盘的逻辑存储结构我们也可以类似于:
- 这样每一个扇区,就有了一个线性地址(其实就是数组下标),这种地址叫做LBA(LogicalBlockAddress)。
1.3.2 真实过程
# 我们知道,传动臂上的磁头是共进退的。柱⾯是⼀个逻辑上的概念,其实就是每⼀⾯上,相同半径的磁道逻辑上构成柱⾯。所以,磁盘物理上分了很多⾯,但是在我们看来,逻辑上,磁盘整体是由“柱⾯”卷起来的。所以,磁盘的真实情况是:
# 我们之前学过C/C++的数组,在我们看来,其实全部都是⼀维数组:
# 下面是更为简化且清晰的图示:
1.4 CHS与LBA地址相互转化
所以:从此往后,在磁盘使⽤者看来,根本就不关⼼CHS地址,⽽是直接使⽤LBA地址,磁盘内部⾃⼰转换。
所以:从现在开始,磁盘就是⼀个元素为扇区的⼀维数组,数组的下标就是每⼀个扇区的LBA地址。OS使⽤磁盘,就可以⽤⼀个数字访问磁盘扇区了。
2、文件系统中的基本概念
2.1 块
# 其实硬盘是典型的“块”设备,操作系统读取硬盘数据的时候,其实是不会一个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个“块”(block)。
# 硬盘的每个分区是被划分为一个个的“块”。一个“块”的大小是固定格式化的时候确定的,并且不可以更改,最常见的是4KB,即连续八个扇区组成一个“块”。“块”是文件存取的最小单位。
# 注意:
- 磁盘就是一个三维数组,我们把它看待成为一个“一维数组”,数组下标就是LBA,每个元素都是扇区
- 每个扇区都有LBA,那么8个扇区一个块,每一个块的地址我们也能算出来。
- 知道LBA:块号 = LBA / 8
- 知道块号:LBA = 块号 * 8 + n (n是块内第几个扇区)
# 下面是更为简化且清晰的图示:
# 而我们也可以进行块和LBA相互转换,操作系统以块为单位也就是4kb,通过块中8个扇区转换为LBA交给磁盘,磁盘就可以对块进行整存整取了。所以文件系统中对文件的管理就变为对块的管理!
2.2 分区
# 其实磁盘是可以被分成多个分区(partition)的,以Windows观点来看,你可能会有一块磁盘并且将它分区成C,D,E盘。那个C,D,E就是分区。分区从实质上说就是对硬盘的一种格式化。但是Linux的设备都是以文件形式存在,那是怎么分区的呢?
# 柱面是分区的最小单位,我们可以利用参考柱面号码的方式来进行分区,其本质就是设置每个区的起始柱面和结束柱面号码。此时我们可以将硬盘上的柱面(分区)进行平铺,将其想象成一个大的平面,如下图所示:
# 下面是更为简化且清晰的图示:
# 问题:假设我们有800GB的磁盘要如何管理?
# 所以我们的只需要管理好每个组即可那这个组里面有什么呢?首先组里面肯定也是以块为基本单位的,也就是4kb。
# 我们都说文件=内容+属性,其实我们Linux下内容和属性是分开存储的。内容存储在DateBlocks里面,也是以多个4kb的块构成的,文件的属性在inodeTable里面。
# Linux下任何文件都要有属性集合,每个文件的属性值都不一样,但是每个文件都要有属性,例如文件大小、文件类型、文件权限... 。只是有的文件是4kb大小,有的文件是200kb大小,属性值不同,但是都有文件大小这个属性。
# 所以文件属性其实就是一个struct inode结构体,他的大小为128kb。所以创建一个文件在内存中就会创建一个inode结构体然后写入inodeTable表里面。
2.3 inode
# 在Linux
操作系统中,我们都知道文件=内容+属性,文件的元信息和内容是分离存储的,其中保存元信息的结构称之为inode
,因为系统当中可能存在大量的文件,所以我们需要给每个文件的属性集起一个唯一的编号,即inode
编号。元信息也叫元数据 / 属性。
# 其中我们可以通过指令ls -i
,显示当前目录下各文件的inode
编号。