目录
一、缓冲区
(一)概念
缓冲区的本质就是一段用作缓存的**内存,**可以节省进行数据IO的时间。
(二)刷新策略
1、立即刷新
实际情况比较少,我们可以手动调用 fflush() 函数进行刷新。
2、行缓冲
一般在显示屏上的输出采用行缓冲。相对于全缓冲,虽然提供了IO次数,但按行缓冲刷新对人的阅读更加友好。所以显示器采用行刷新的策略,既保证了人的阅读习惯,又使得数据IO效率不至于太低。
3、全缓冲
当缓冲区满了以后才会刷新缓冲区。大大减少了IO次数。
4、特殊情况
进程退出或者用户强制刷新。
5、案例
cpp
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <unistd.h>
int main()
{
printf("Hello printf\n");
fprintf(stdout,"Hello fprintf\n");
fputs("Hello fputs\n", stdout);
const char* str = "Hello write\n";
write(1, str, strlen(str));
fork();
return 0;
}
当我们分别将可执行文件输出至显示屏和文件中得到的结果并不相同。
这里我们看到,凡是调用C语言接口的输出至文件时都输出了两遍,而系统调用 write() 只输出了一遍。为什么会出现这种情况呢?
文件操作见:【Linux】基础IO-CSDN博客
当我们输出至显示器时,缓冲区刷新策略为行缓冲,每个语句的打印都含有 \n ,因此在 fork() 之前,缓冲区内不含有任何数据,创建的子进程的缓冲区也不含有任何数据;
当我们将输出内容重定向输出至文件中(磁盘),缓冲区刷新策略为全缓冲,只有缓冲区满了以后才会刷新,因此在fork之前,缓冲区内含有调用C语言函数打印的内容,创建子进程之后,子进程的缓冲区也含有这些内容,最终父子进程退出后,其缓冲区内容刷新输出至文件。
调用系统调用 write() 接口,无论刷新策略如何,该内容都只被输出了一次,因为可以得出上述的缓冲区为语言级别的缓冲区,与操作系统内核无关。
(三)仿写FILE
1、myfile.h
cpp
#pragma once
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>
#include <assert.h>
#define SIZE 1024
// 1,2,4二进制1的位置不一样
#define SYNC_NOW 1 // 立即刷新
#define SYNC_LINE 2 // 行刷新
#define SYNC_FULL 4 // 全刷新
typedef struct _FILE
{
int flags; // 刷新方式
int fileno; // 文件描述符
int cap; // buffer总容量
int size; // buffer当前使用量
char buffer[]; // 缓冲区
} FILE_;
FILE_ *fopen_(const char *path_name, const char *mode);
void fwrite_(const void *ptr, int num, FILE_ *fp);
void fclose_(FILE_ *fp);
void fflush_(FILE_ *fp);
2、myfile.c
cpp
#include "myfile.h"
FILE_ *fopen_(const char *path_name, const char *mode)
{
int flag = 0; // 文件打开方式
if (strcmp("w", mode) == 0)
{
flag = flag | O_CREAT | O_WRONLY | O_TRUNC;
}
else if (strcmp("r", mode) == 0)
{
flag = flag | O_RDONLY;
}
else if (strcmp("a", mode) == 0)
{
flag = flag | O_APPEND | O_WRONLY | O_CREAT;
}
int id = 0; // 文件描述符
if (flag & O_RDONLY)
id = open(path_name, flag);
else
id = open(path_name, flag, 0666);
if (id == -1)
{
// 打开失败收集打印错误信息
const char *err = strerror(errno);
write(2, err, strlen(err));
return NULL;
}
// FILE结构体初始化
FILE_ *fp = (FILE_ *)malloc(sizeof(FILE_));
fp->flags = SYNC_LINE; // 默认设置为行刷新
fp->fileno = id;
fp->size = 0;
fp->cap = SIZE;
memset(fp->buffer, 0, SIZE);
return fp;
}
void fwrite_(const void *ptr, int num, FILE_ *fp)
{
memcpy(fp->buffer + fp->size, ptr, num);
fp->size += num;
if (fp->flags & SYNC_NOW)
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
else if (fp->flags & SYNC_LINE)
{
if (fp->buffer[fp->size - 1] == '\n')
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
}
else if (fp->flags & SYNC_FULL)
{
if (fp->size == fp->cap)
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
}
}
void fflush_(FILE_ *fp)
{
if (fp->size > 0)
{
write(fp->fileno, fp->buffer, fp->size);
fsync(fp->fileno); // 将数据强制刷新至磁盘
}
}
void fclose_(FILE_ *fp)
{
fflush_(fp);
close(fp->fileno);
}
(四)内核缓冲区
其实上述的缓冲区是语言级别(封装在FILE结构体中)的缓冲区,在操作系统内核也存在一个缓冲区,其何时刷新由操作系统自主决定。
二、磁盘
(一)磁盘的存储
一个磁盘拥有多个盘面,而在盘面上拥有多个磁道,不同盘面的相同磁道共同形成柱面,同时,磁盘被等分为多个扇形,而各个扇形称为扇区。
磁盘在寻址时,基本单位为512字节。如图所示,绿色部分就是扇区,越靠近同心圆的扇区面积越小,越远离扇区的同心圆面积越大,但是每一个扇区的存储大小均为512字节。
磁盘在寻址时,先有磁头摆动确定读写内容所在磁盘,再通过盘片高速旋转读写目标扇区。
那如何在磁盘中定位指定扇区呢?实际上磁盘也有自己的地址:磁盘定位扇区通常使用CHS定位法 ,即磁道/柱面(cylinder)、定位磁头/盘面(head)、扇区(sector)。
(二)磁盘的抽象存储结构
当我们将不同盘面的磁盘顺序结合,其实我们可以将整个磁盘看作一个顺序存储的数组,这样对磁盘的管理,实际上就是对数组的管理,只要我们得到扇区的下标,即可定位磁盘上的扇区。而这样的管理方式的地址称为LBA 地址。可以根据 LBA 地址映射到 CHS 地址,从而定位至指定扇区。
将对上层的 LBA 地址和下层的 CHS 地址分开,便于操作系统管理磁盘,降低操作系统和磁盘的·耦合度,使得操作系统对磁盘的管理独立于磁盘。
(三)分页思想
上述可知磁盘的最小单位是扇区512字节,但对于IO来讲,读写单位太小会导致频繁IO,因此操作系统的文件系统每次读取数据会以4KB为基本单位(大部分是4KB)读取至内存,无论用户修改多大的数据,操作系统也只会按照4KB为基本单位进行读写。
根据局部性原理,当计算机访问某些数据时,它附近的数据也有非常大的概率被访问到,加载4KB有助于提高IO效率,同时增大缓存命中率。这个特点也印证了顺序表因顺序存储而缓存命中率高的优点,而链表因节点存储地址跳跃,缓存命中率低。
操作系统中内存被划分成了一块块4KB大小的空间,每个空间被称为页框。
磁盘中的空间也是按照4KB大小划分好的块。每个块被称为页帧。
(四)磁盘的文件系统
1、概念
按照上述,如果磁盘都只按照以4KB大小为基本单位进行管理,那么对于磁盘的文件系统来说,这个管理的方式十分的庞杂。
实际上,磁盘采用分而治之的思想,例如将大小为500G的磁盘划分为4个125G进行管理,将125G又可以划分为多个5G进行管理。只要管理好5G大小的存储空间,即可管理好整个磁盘。
1、Super Block:存储文件系统的整体信息,如大小、块大小、空闲和已用块的数量等。在一个分区中超级块的数量不止一个,作用是备份。
2、GDT(Group Descriptor Table):块组描述表,它包含了该块组的元数据,如块组的起始块、可用块数等。
3、Block Bitmap:记录了块组内每个块的使用情况,位图中的每个位对应一个块,用0表示某位没有被使用,用1表示某位数据块已经被使用。
4、inode Bitmap:类似于块位图,但是用于记录inode节点的使用情况,用0表示某位没有被使用,用1表示某位inode已经被使用。
5、inode Table:保存了分组内部所有的可用(已使用+未使用)的inode。存储inode的数组,每个inode包含文件的元数据,如文件大小、权限、所有者、创建时间、inode号等。
单个inode:存放文件中几乎所有的属性,如文件大小,所有者,最近修改时间等,唯独文件名不在inode表中存储。一个文件对应一个inode,inode是固定大小。每个分组中的inode为了区分彼此,它们都有自己的ID。同一分区 inode编号 是连续的,不同分区的 inode编号 没有任何关联。
除了基本属性以外,inode 结构体中还含有一个 block[15] 数据块数组,该数组存储了其属于文件的数据块的指针,可通过该数组找到属于该文件的 Date Block 中的数据块。
6、Data blocks:实际存储文件数据的地方。文件数据被分散存储在不同的数据块中。
2、目录结构
由上述所知,普通文件的属性存储在 inode节点 里,文件的内容存储在 Date Block 里。
目录也属于文件,但目录的存储比较特殊。目录的属性依然存储与 inode 里,但目录的 Date Block 中存储的是所在目录下的文件的文件名与其 inode 编号的映射。对于用户来说,依靠 inode 编号区分文件十分困难,因此用户是通过文件名区分文件,而目录的 Date Block 中存储的是文件名与 inode编号 的映射关系,故可以通过文件名找到文件。这也是在同一目录下不能存在文件名相同的文件的原因。
3、新建、查找与删除文件
对于新建文件,操作系统为该文件分配一个 inode节点 和若干 数据块,同时填充该文件的信息并将 inode节点 添加至 inode Table ,同时建立 inode节点 与 数据块 的映射(inode节点中的block数组)。之后修改 inode Bitmap 和 Date Bitmap,将文件名和inode编号的映射关系添加至目录的数据块中。
对于查找文件,通过所在目录存储的文件名和 inode编号 的映射关系获取其 inode编号 。之后通过 inode Bitmap 查看该 inode编号 是否有效,若有效再通过该 inode编号 在 inode Table中寻找该 inode。通过该 inode 中的 block 数组即可获取该文件的数据块,之后通过 Date Bitmap 查看所用的数据块是否有效,若有效则查找文件成功。
对于删除文件,通过所在目录存储的文件名和 inode编号 的映射关系获取其 inode编号 。通过查找 inode Table 获取其 inode节点,通过该节点获取并修改 inode Bitmap 和 Date Bitmap 为0。即逻辑删除并不清楚其真正的存储信息。
上述可知 inode节点和数据块 的映射是存储在一个 block[15] 的数组里的,如果文件内容过大,应该如何存储呢?
实际上数据块不仅可以存数据,也可以存储地址,也就是数据块也可以存储地址指向新的数据块。
对于block[15]数组前12个下标中对应的数据块直接用于存储文件内容,数组后3个空间中存放的编号对应的数据块中存放了文件剩余数据的数据块编号。其中下标12是一级索引,它对应的数据块中存储的数据块编号直接用于存文件数据;下标13是二级索引,它对应的数据块中存储的数据块编号是一级索引;下标14是三级索引;逐级展开,能存储很大的文件。
三、软硬连接
(一)软链接
1、概念
软链接其实类似windows下的快捷方式,软链接相当于新建了一个文件,拥有自己独立的 inode节点与数据块,而其存储的内容为被链接文件的路径与文件名。
2、软链接的建立删除
bash
//建立软链接
ln -s 被链接文件 新建链接名称
//删除软链接
unlink 软链接名
(二)硬链接
1、概念
硬链接与软链接不同,硬链接并不是一个独立的文件。由上文我们知道,文件名和inode节点是一一对应的,而硬链接则是增加了文件名(硬链接)和inode节点的映射关系。
2、硬链接的建立删除
bash
//建立硬链接
ln 被链接文件 新建链接名称
//删除硬链接
unlink 硬链接名
红框所圈出的数字为该文件被硬链接的数目。
3、普通文件和目录文件的硬链接数
当我们新建一个普通文件和目录文件时,我们可以发现两个文件的硬链接数并不相同。
普通文件很好理解,因为文件本身的名字和 inode节点 就有映射关系,因此硬链接数为1。
那为什么目录的硬链接数为2呢?实际是因为我们新建目录以后,系统会在目录里建立一个 .. 硬链接链接该目录,因此新建目录的硬链接数为2。为了防止破坏目录树形结构,因此系统不允许用户给目录建立硬链接。
四、动静态库
查看可执行文件链接了哪些库:
bash
ldd 可执行文件名
查看可执行文件是动静态方法:
bash
file 可执行文件名
(一)概念
静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库。
动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
静态链接:在可执行文件生成前就把所需要的所有静态库链接在一起,一旦形成可执行程序便与静态库无关。
静态链接不依赖第三方库,一旦形成可执行文件便独立于静态库,程序的可移植性好,但是形成的可执行文件体积巨大。
动态链接:当程序在执行时才会加载所需要的库文件,程序在链接时并不会直接链接动态库,而是记录所需内容在动态库的相对位置(偏移量),只要在执行需要时再根据该相对位置将动态库加载至内存并取出所需内容。
动态链接形成的可执行文件小,节省磁盘存储空间并且加载至内存速度快,但依赖动态库,程序的可移植性差。
(二)动静态库的建立使用
1、静态库
当我们写好所需的程序后,分别将所有的 .c(.cpp)文件进行编译生成 .o 文件(链接文件),之后将所有的 .o(链接文件)进行打包归档即可。.h 文件告诉用户库的使用接口,而库本身是接口实现。
bash
gcc -c Add.c //生成Add.o文件
gcc -c Sub.c //生成Sub.o文件
ar -rf libmath.a Add.o Sub.o //生成名为math的静态库
ar -tv libmath.a //查看静态库中的链接文件
当我们生成好静态库以后,应该如何使用呢?这里我们新建目录模拟库的使用。
当我们使用静态库时:
bash
gcc test.c -o test -I ./lib/include/ -L ./lib/lib64/ -l math
-I:库的头文件所在目录;
-L:静态库所在目录;
-l:所使用静态库的名称;
使用编译器提供的库并行不需要带这些选项,是因为编译器有自己的环境变量,能够找到位于/lib64库文件的存放目录和/usr/include头文件的存放目录。也可以将静态库和头文件放入这些目录或其他相关目录下,这就是一般软件的安装过程。
2、动态库
当我们写好所需的程序后,分别将所有的 .c(.cpp)文件进行编译生成 .o 文件(链接文件),之后再使用gcc生成动态库即可。
bash
gcc -fPIC -c Add.c Sub.c //生成动态库所需的.o文件
gcc -shared -o libmath.so Add.o Sub.o //将.o文件生成动态库
使用动静态库生成可执行文件的是相同,这里不进行赘述了。但是在执行上略不同。
从上文我们可以知道,使用静态库生成可执行文件以后,该可执行文件独立于静态库了,可以直接执行;但是动态库在执行过程中是去动态库中寻找所需,可动态库生成的可执行文件的执行是由系统来做的,因此我们在执行动态库生成的可执行文件之前,还需要告诉系统所需的动态库的位置。
下面有三种方案解决上述问题:
(1)将动态库和头文件拷贝至对应的系统库路径和头文件路径下;
(2)更改环境变量LD_LIBRARY_PATH,这个方法仅对本次登录有用;
bash
export LD_LIBRARY_PATH=path_to_lib.so /*动态库所在目录*/
(3)配置ldconfig,这个方法永久生效
/etc/ld.so.conf.d/是系统搜索动态库的路径
bash
//进入/etc/ld.so.conf.d/后
sudo touch new.conf //创建新配置文件
vim new.conf //将动态库所在目录写入该配置文件
sudo ldconfig //使配置文件生效