Linux文件系统

前言:基本原理和问题聚焦

  1. 文件 = 内容 + 属性

  2. 文件分为打开的文件没打开的文件

  3. 打开的文件:像"fopen"这样的代码也是存储在可执行程序中的,所以打开文件这个操作一定是进程做的,进程的操作对象是存储在内存中的代码和数据,所以文件要被放入内存才能被进程打开,而操作系统要对内存做管理,就要按照**"先描述,再组织"** 的原则对被打开的文件进行管理,所以研究已打开文件本质是研究进程和文件的关系

  4. 没打开的文件:这些文件放在磁盘上,且没被打开的文件非常多。需要关注文件如何被分门别类的放置好从而快速找到文件。


回顾C文件接口

fopen

参数filename需指定路径文件路径,mode是选择打开模式


fwrite

注:如果文件名不存在且不指定地址,会自动创建且路径和当前进程路径一致。如果进程运行时将进程更改到其他路径下(chdir),文件的创建路径就随之更改了

注写入大小不需要把\0考虑进去 也就是strlen后不用+1,因为\0是C语言的规定,和文件没有关系


代码示例

下面演示fopen的"w"和"a"的使用

cpp 复制代码
#include<stdlib.h>
#include<stdio.h>
#include <errno.h>
#include <string.h> 
int main(){
    //创建并写入
    char s[12]="Hello File!";
    FILE*pfile;
    pfile=fopen("test.txt","w");
    if(pfile==NULL){
        printf("文件打开失败,错误码:%d\n",errno);
        printf("错误信息:%s\n",strerror(errno));
        return 0;
    }    
    fwrite(s,sizeof(char),strlen(s),pfile);
    fclose(pfile);
    //追加
    char ss[13]="Hello Linux!";
    pfile=fopen("test.txt","a");
    if(pfile==NULL){
        printf("文件打开失败,错误码:%d\n",errno);
        printf("错误信息:%s\n",strerror(errno));
        return 0;
    }
    fwrite(ss,sizeof(char),strlen(ss),pfile);
    fclose(pfile);
    return 0;
}

运行结果如下:


文件系统调用

系统不会让用户直接修改操作系统,实际上C的文件接口是对系统调用接口的封装

open

打开方式flags:

flags控制打开方式,用宏定义按位或组合

cpp 复制代码
O_RDONLY    只读
O_WRONLY    只写
O_RDWR      读写
O_CREAT     文件不存在则创建(需要指定 mode 权限)
O_TRUNC     打开时清空文件内容
O_APPEND    追加写入,每次写自动定位到文件末尾

权限参数mode:

cpp 复制代码
0666 → rw-rw-rw-
0644 → rw-r--r--
0755 → rwxr-xr-x

mode控制新建文件时的权限 ,只在 O_CREA方式下生效。


各个语言的打开文件函数都是对open的封装,写、关闭文件同理

cpp 复制代码
//C库函数
FILE *fp = fopen("log.txt", "a");
//底层调用
int fd = open("log.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
cpp 复制代码
//C库函数
FILE *fp = fopen("log.txt", "w");
//底层调用
int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);

close

cpp 复制代码
int close(int fd);

关闭文件描述符(fd),释放 fd_array 中对应的槽位。对应文件的引用计数 -1,降到 0 后可以释放资源,fclose是对close的封装


write

cpp 复制代码
ssize_t write(int fd, const void *buf, size_t count);

fd:文件描述符,通过它在 fd_array 中找到对应的 struct file
buf:用户态缓冲区的起始地址
count:写入字节数
返回值:实际写入的字节数

fcwrite是对write的封装


访问文件的本质

系统要管理被打开的文件,根据"先描述,再组织"的原则,为文件创建文件控制块(FCB),并采用某种数据结构对所有FCB进行管理。

而每个进程控制块里有一个为打开文件专门创立的struct对象(files_struct),内有一指针数组(*fd_array),存放指向已打开文件的控制块的地址的指针。

综上,当文件第一次被进程打开(open函数被调用),一方面操作系统创建了FCB,另一方面这个FCB地址被存储到打开该文件进程fd_array空余的地方,最后返回存储位置下标fd,这就是文件描述符,write接口要写的文件的位置就是通过fd来确定的。

此外,每个文件不是只能和单个进程挂钩,多个进程可以打开同一个文件。而在 fork 的场景下,子进程会继承父进程的 fd_array,父子进程的 fd 指向同一个 struct file 对象,引用计数加 1。只有当所有引用该 struct file 的 fd 都关闭后,计数归 0,该对象才会被销毁。

现已知open的返回的文件描述符为int类型,而fopen是对open的封装,那C接口的返回值FILE是什么?为什么能接收文件描述符?

FILE是C库封装的一个结构体,里面包含了文件描述符。


重定向

如果让某个文件描述符指向另一struct file就叫做重定向, 比如把stdout(fd 1)从屏幕重定向到log.txt,对应的其实就是我们在shell里面输入的命令echo > log.txt


重定向如何实现

系统重定向的实现依赖于dup函数,dup2是dup系列函数之一

下面代码示例将文件log.txt重定向到显示屏后,打印文字不再向显示屏输出,而是向文件中输出,这就叫做输出重定向。

cpp 复制代码
#include<stdlib.h>
#include<stdio.h>
#include <errno.h>
#include <string.h> 
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include<unistd.h>

int main(){
    //实现重定向
    char str[]="hello linux1!!!\n";
    int oldfd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);//打开log.txt后oldfd已经加载到file_array中
    //write(oldfd,str,strlen(str));
    dup2(oldfd,1);//重定向:让原本屏幕的fd(1)指向log.txt的fd所指向的文件
    printf("%s",str);
    return 0;
}

可以看到echo命令原本要将字符串打印到显示屏,现在输出到文件中,底层就是输出重定向。


下面代码演示将程序打印的标准输出和标准错误通过重定向输出到两份文件中

cpp 复制代码
int main() {
    // 标准输出(stdout,fd 1)------ 会被 1>normal.log 捕获
    printf("这是一条正常日志信息\n");
    printf("程序正在运行...\n");
    printf("程序运行结束\n");

    // 标准错误(stderr,fd 2)------ 会被 2>err.log 捕获
    fprintf(stderr, "这是一条错误信息\n");
    fprintf(stderr, "发生了一个警告\n");

    return 0;
}


shell如何处理重定向指令的呢?

当检测到命令行中有">"、">>"、"<"这些符号时,在子进程中按前文所述对应方式重定向显示屏或键盘,然后符号前面的指令正常执行,因为已经重定向,直接输出即可。

可参考下面的shell模拟实现代码:

shell的简单模拟实现

代码中有如下片段:

cpp 复制代码
void normalcommand() {
    check_redirect();//判断是否使用重定向,并将重定向符号前后分割开
    pid_t ret = fork();
    if (ret == 0) {
        if(state==1){//用状态表示发生重定向与否
            int fd = open(filename, O_WRONLY|O_CREAT|O_TRUNC, 0666);
            dup2(fd,1);//重定向显示器
            close(fd);
        }
        else if(state==2){
            int fd = open(filename, O_WRONLY|O_CREAT|O_APPEND, 0666);
            dup2(fd,1);
            close(fd);
        }
        execvp(argv[0], argv);//这里不用'p',默认把环境变量传过去就好了
        exit(EXIT_CODE);
    }
    else if (ret > 0){
        //...
    }
}

可以看到子进程发生重定向后,程序替换并不会影响重定向,因为进程替换掉的是数据和代码,而打开文件后files_struct属于PCB,不会发生替换。


如何理解操作系统一切皆文件?

学习了重定向后,再来理解一下操作系统中一切皆文件:

计算机由CPU、内存和各种I/O设备(磁盘、显示器、网卡等)组成,每个设备在初始化的时候有自己的驱动方式,存储在驱动文件中

每个文件都有一个索引节点(inode),其中关联着一张操作方法表(struct file_operations

当进程打开文件时,内核创建一个struct file对象,并将其指针存入进程的 files_struct->fd_array

进程通过文件描述符找到 file 对象,再顺着其中的 file_operations 指针,最终调用到具体的设备操作方法。**同一个调用接口,不同的底层实现,**这就是"一切皆文件"的本质

cpp 复制代码
int fd1 = open("log.txt", O_WRONLY);       // 写磁盘
write(fd1, buf, 100);

int fd2 = open("/dev/monitor", O_WRONLY);  // 写显示器
write(fd2, buf, 100);

int fd3 = open("/dev/keyboard", O_RDONLY); // 读键盘
read(fd3, buf, 100);

缓冲区

缓冲区是什么,在什么场景发挥作用?

语言层会提供一个用户级缓冲区,这个缓冲区在FILE结构体中被定义。

语言层的写入(fwrite,printf)读取(fread,scanf)先暂存到缓冲区,到一定时机缓冲区才被刷新(所谓的刷新其实就是对write的封装)这个时机取决于缓冲区采用的刷新策略。刷新后内容才实际被写入。

缓冲区刷新策略

  • 无缓冲:输入后立即刷新,如printf后直接调用ffush新
  • 行缓冲:直到遇到\n才刷新,显示器采用此方式刷新
  • 全缓冲:缓冲区写满了才刷新,文件写入采用

另外,进程退出时自动会对缓冲区刷新一次,如果在进程退出前关闭文件(相当于销毁了FILE中以及里面的缓冲区)缓冲区就无法刷新,例如代码1因提前将stdout文件关闭,"Hello C"就没有打印出来。

缓冲区具体在哪里?

FILE结构体里包含对应文件的缓冲区字段和维护信息

注意:FILE是C语言额外创建的,所以第一次打开文件用FILE指针接收返回值后,此后要对该文件进行操作都必须带上这个FILE指针,不然无法找到缓冲区,也就是说,每个文件都有自己的缓冲区,打开了10个文件,就会产生10个缓冲区。

为什么要有这个缓冲区?

一次write()系统调用的开销是很大的,不如先把字符存入到缓冲区中,积攒到一定程度再进行系统调用,从而提高了效率

C语言创建了FILE,这个FILE属于用户还是操作系统?

FILE的创建相当于在语言层给我们开了一个存放FILE的空间,这个不归操作系统管理而是用户,因为它的创建销毁缓冲区的更新都是依托语言层的代码实现的。

子进程赋值缓冲区缓冲区

应用:fork()打印两次的问题

现象:打印后创建子进程并通过重定向将会打印两次,但按理子进程应该执行fork()后的代码,所以只会打印一次。

cpp 复制代码
int main(){
    char str[]="看看这句话会打印几次";
    printf("%s\n",str);
    fork();
    return 0;
}

原因:因为重定向到文件,从输出到屏幕改为输出到文件,这时采用全缓冲方式,所以即使带\n也不会刷新,而缓冲区没写满,只能等进程退出时才刷新,进程退出前创建了子进程,对代码和数据复制了一份,父进程想退出的时候要刷新缓冲区,也算是对数据(malloc出来的缓冲区)的写入,此时发生写时拷贝,物理内存就会有两份缓冲区了(原本父子共享一份)父子进程退出时都会刷新自己的缓冲区,所以对文件做了两次write操作

下面代码演语言层如何实现缓冲区

简易缓冲区模拟实现


文件系统

前面记录的是有关"被打开文件"的知识,而开篇提过针对没被打开的文件,我们关注的是文件如何被分门别类的放置好从而快速找到文件。


认识有关硬件(以磁盘为例)

在磁盘上存储文件=存储内容+属性,linux下文件属性和内容是分开存储的。

磁盘是电脑上唯一一个机械设备(不是现在的电脑)同时也是一个外设(计算机叫电子计算机,而磁盘是物理性的,速度肯定不如电流)


磁盘部件简介

盘片:数量不定,两面都是光滑的,依赖主轴马达高速旋转,盘面上存储的是数据

磁头:一面一个(作为整体一起运动),运转后来回摆动,但和盘面不接触


读写数据的原理

写数据原理:电流方向经磁头产生磁场,改变盘片磁性粒子的极性来存储 0/1

存数据原理:内存是掉电易失介质,而磁盘是永久性存储介质,靠的不完全是电,而是电磁特性,电让磁极更改


销毁数据的方法:

消磁,高温 或者专门设计的擦除磁盘的接口


磁盘的存储构成

磁道:以盘片中心为圆心,向外扩散的每个同心圆环就是一个磁道

扇区:磁道被分成很多个扇区,传统扇区为512字节,现代硬盘已普遍采用4K字节扇区

多个盘面的一个磁道构成柱面所以磁头的左右摆动就是在定位磁道和柱面的过程

所以磁盘可被定义为由无数个扇区构成的存储介质

如果要存储数据,就要知道如何定位一个扇区。要知道是哪个磁头,再知道是哪个磁道,最后才能定位到某个扇区。上述过程根据各部件编号实现,这就叫做CHS寻址方式。


磁盘效率

磁盘是机械部件,机械运动越少,效率越高,除了转轴、磁头的速度等,数据如何存放也是一个影响因素,因此在软件设计上,应该将相关联的数据尽量放在一起从而提高效率。


磁盘的逻辑存储

磁盘在物理上是圆形的,但逻辑上是线性的。也就是按顺序从各个面,到各个道,再到各个扇区展开来。因此任意一个扇区都有自己的逻辑 块地址(LBA,L ogical B lock Addressing,)


磁盘的寄存器

不仅cpu有寄存器,其他设备(包括磁盘)也有

cpp 复制代码
控制寄存器:现在是要读还是写(控制IO方向)

数据寄存器:暂存准备写进来/出去的数据

地址寄存器:填入LBA

状态寄存器:准备写出是否就绪,写入是否成功

文件系统

正如虚拟地址空间一般,磁盘同样被操作系统分成多个区,并为每个分区定义结构体(struct partion)上面包含了起始地址和终止地址

在每个分区的第一个扇区存有开机信息,后续的扇区就被分为一个个block group进一步分治(文件是可以跨组的)而各个blockgroup里面再一次被分成多个区域,接下来对这些区域做说明:


Datablocks

用于存文件内容的区域,以块的形式呈现(就是不按照磁盘的基本单位扇区,而是自己划分的块,常见大小为4kb,这就是文件系统的基本单元)块大小确定,所以每个块也有编号。注意,文件并不是按顺序占据Datablocks的,而是碎片化分布的

所以一份文件如果只有1kb也会占据一个4kb的块。但通常文件相对于一个块来说都很大,而每个文件所占据的最后一个块才可能产生浪费,几乎可以忽略不计。


inode table

存放多个inode,inode用于存放文件属性,一般为128字节

每个inode也有自己的编号,这个编号只在当前分区有效,文件属性里并不包含文件名字,在linux里用inode编号标识文件

可data block是一整块的,inode table里面的各个inode怎么和对应数据关联起来的呢?

inode基本构成如下:

cpp 复制代码
#define NUM 15
struct inode
{
    //inode number(编号)
    //文件类型
    //权限
    //引用计数
    //拥有者
    //所属组
    //ACM时间
    int blocks[NUM];
};

每个文件被创建时开头自带inode编号,所以会去inode block里面找到自己的inode再通过blocksNUM判断自己的大小,从而确定了文件的开头和结尾。

blocks数组

存有直接索引、二级和三级索引,以类似指数增长的方式指向一份文件各个不同的块(但能保证文件所有块都被找到)


blockbitmap

用等同于文件占据块的数量的比特位表示哪些块被使用,比特位的位置和块编号映射起来,而比特位的内容(0/1)表示块的使用与否,所以删文件的时候不没必要把块清空,把状态改一下后面覆盖就好了,同理,如果文件被删了还没被覆盖,知道编号就可以马上恢复


inodeBitmap

同blockbitmap,inodeBitmap就是有关inode的位图


Super Block(文件系统的全局配置卡)

cpp 复制代码
Super Block 的内容(不是全部,列关键的):
s_inodes_count:整个文件系统共有多少个 inode
s_blocks_count:整个文件系统共有多少个 block
s_inodes_per_group:每个 Group 里有多少个 inode
s_blocks_per_group:每个 Group 里有多少个 block
s_free_inodes_count:目前空闲的 inode 总数
s_free_blocks_count:目前空闲的 block 总数
s_magic:魔数(标识这是什么文件系统,如 ext4 = 0xEF53)
s_state:文件系统状态(是否正常挂载)
s_log_block_size:block 大小(4KB 等)

作用:这个区域存放了文件系统的配置和现状,有了它系统才知道怎么解读后续的所有结构

存放位置:Group 0 有主份,特定组(1, 3, 5, 7, ...的幂次方)有备份


Group Descriptor Table(GDT)

作用:存放当前分区每个Group的管理结构(bitmap、inode table)的位置。

注意:大部分 Group 没有 Super Block 和 GDT,只有特定的 Group(约十几到几十个)有备份

Group 0一定有主份


总结

cpp 复制代码
Super Block管理整个文件系统的全局配置

Group Descriptor Table管理每个 Group 的位置和空闲情况

Block Bitmap + Inode Bitmap管理每个 Group 内部哪些资源被占用

Inode Table + Data Block是被管理的最终资源

如何理解目录?

系统如何通过文件名拿到inode编号的?

目录也是文件, 也有inode,也有属性,它的数据块放的是该目录下文件名和文件对应inonde的映射关系,所以如果用户给文件名或目录名,系统可以相应地找到该文件或目录的inode,而根目录的inode一开始已经确定,所以系统从根目录开始一层层往下找,因此找文件需要传路径。此外每次进入到新的目录,都会改变shell环境变量pwd。


再谈目录权限

对目录有x权限可以打开该目录的data block并定位到其中的某个文件(如果知道该文件名的话),拿到inode,然后再视情况对该文件进行操作。

对目录有r权限决定了可以看到该目录下的文件名字,但仅限于看到,不能通过该目录的路径进一步访问文件

对目录具有w权限可以在目录中创建和删除文件,也就是在对应的data block中建立或删除文件名和文件的映射关系


综合上面的文件系统知识,删除一份文件a.txt流程如下:

  1. 通过目录data block 找到 "a.txt得到inode number为305" (目录里存放的)

  2. 读 inode 305,找到blocks\[\] 数组 得到:block 1, block 500, block 99999, block 3, block 8000

  3. Block Bitmap:把 1, 500, 99999, 3, 8000... 对应的比特位置0

  4. Inode Bitmap:把 305 对应的比特位置0

  5. 删目录项:"a.txt → 305" 这条记录删掉


软硬链接

软链接

定义:

软链接是一个独立的文件(拥有自己的inode),其数据块存放的是指向文件的路径

添加软链接:

cpp 复制代码
ln [-s] 源文件路径(最好用绝对路径) [目标名]

操作演示:

在当前目录创建子目录linux_file下log.txt的软链接,名为soft_link,发现其内容和源文件一致

软链接应用场景:

将常用目录/文件放到便捷路径,省去反复输入长路径的麻烦

硬链接

定义:硬链接是指向同一inode的新文件名,它与原文件共享同一份数据,没有主从之分。硬链接数代表有几个硬链接指向同一个个可执行程序

添加硬链接:

cpp 复制代码
ln 源文件路径(最好用绝对路径) [目标名]

操作演示:

为当前目录子目录linux_file下log.txt文件创建硬链接,使用ls -l可看到此时log.txt硬链接数为2

硬链接应用场景:维持目录结构,防止文件误删(硬链接数变为0文件才算真正删除)

维持目录结构:

目录自带两个硬链接:. 和**..**,分别指向当前目录和上级目录,没有它们文件系统的目录树就只能往下走,不能往回走。所以当前目录创建出来的时候,上级目录的硬链接数++。

注意:不允许为目录建立硬链接,假如目录拥有硬链接,递归遍历文件的过程就可能产生环路。从本质上看,根据路径访问文件就是去 block data 里找到下一个目录的 inode,而 find 这类工具进入一个目录后会把所有子目录都探索一遍 ,如果存在硬链接让某个子目录指向祖先目录,工具每次进入后又会展开所有子目录,永远无法停止。而 .. 这个硬链接即使存在,工具也不会主动进入,所以只允许 ... 这两个目录硬链接存在。


内存管理简述

内存管理有一套完善的体系,但后续不会深入学习,这里做简述:

操作系统用和页数量相当大小的数组表示内存被占用与否(也就是位图),所以数组下标就是页号

每个页有自己的结构体记录了自身的信息

cpp 复制代码
struct page {
    unsigned long flags;   // 状态标志(是否被使用)
    int count;             // 引用计数:有进程在用这个页
    // ... lru 链表指针 ...
};

这里的引用计数就是父子进程共享页面的机制。fork时不复制内存,父子共用同一个页,计数count+1。谁要写,谁才复制一份(写时复制)。

LRU机制(Least Recently Used)

当内存不足时,要把一些页腾出来给新数据,最近最少使用的页先被踢出去。

文件与内存的关联件

struct file除inode指针外,里面还有一个page_tree指针,这个指针就指向了文件的页缓存

page_tree 是一棵树,用来管理该文件有哪些页在内存中

文件可能很大,但不是所有页都被加载到了内存里。以偏移量作为 key,通过基数树查询偏移量 X 处的数据在不在内存中。

基数树就是用偏移量做索引的哈希表的树形版本,可以快速定位文件的哪一部分已经在内存中了。

所以应用层读文件(调用read)的时候,给一个偏移量是为了查 page_tree偏移量对应的页是否在内存中。如果命中就直接从页缓存拿数据,如果没命中(说明还没加载到内存中)从磁盘读read参数大小的对应的数据到page上,然后再返回数据。命中与否最后都是再从内核缓冲页拷贝到用户缓冲区,再调用write()真正输出到屏幕上。

写文件的时候(调用write)的时候,同样在内核中先找到或加载对应的 page, 把数据写入 page

标记该页为"脏页"(dirty),然后write()返回,应用层到此结束,而后台由内核择机把脏页刷回磁盘。所以在"脏页"里还没刷回磁盘时,断电可能丢数据。

相关推荐
团象科技2 小时前
出海内容创作链路实地调研 关于GPU服务器视频渲染的落地观察
运维·服务器
c238562 小时前
linux文件权限深入了解(下)
linux·运维·服务器
Zh&&Li2 小时前
保姆级安装AI全自动渗透工具(pentestswarm)
linux·运维·服务器·人工智能
骑士雄师2 小时前
17.2 通过 Config 传入用户名 → 工具1存入 State → 工具2读取 State 并返回答案
服务器·windows·microsoft
das2m2 小时前
WSL2 Ubuntu 配置完美版 docker compose 指南
linux·ubuntu·docker
丑过三八线2 小时前
Runc 深度解析:从原理到实操
java·linux·开发语言·docker·容器·rpc
沉在嵌入式的鱼2 小时前
Jetson系列集成第三方库和应用程序到镜像方案
运维·服务器
手可摘星辰的少年2 小时前
Linux字符设备驱动的实现与QEMU验证
linux
手可摘星辰的少年2 小时前
使用额外ext4磁盘镜像在QEMU中传递与加载内核模块
linux