linux中如何描述一个文件

619 阅读21分钟

基本概念

在了解linux如何描述一个文件之前。我们有必要了解一些基础概念。

扇区、磁盘块、页

扇区是磁盘读写的基本单位。磁盘上的每个磁道被等分为若干个弧段,这些弧段称之为扇区。硬盘的物理读写以扇区为基本单位。通常情况下每个扇区的大小是 512 字节。linux 下可以使用 fdisk -l 了解扇区大小:

扇区是磁盘物理层面的概念,操作系统是不直接与扇区交互的,而是与多个连续扇区组成的磁盘块交互。由于扇区是物理硬件层面的概念,所以无法在系统中进行大小的更改。 磁盘块是文件系统读写磁盘数据的最小单位,一个磁盘块由 n (n=2、4、8...)个相邻的扇区组合而成。磁盘块是操作系统所使用的逻辑概念,而非磁盘的物理概念,linux文件系统对磁盘块进行管理。磁盘块的大小是可以通过blockdev命令更改的,磁盘块的大小可以通过命令 stat 命令来查看: 为了更好地管理磁盘空间和更高效地从硬盘读取数据,linux规定一个磁盘块中只能放置同一个文件的内容。因此文件所占用的空间只能是磁盘块的整数倍,那就意味着会出现文件的实际大小,会小于其所占用的磁盘空间的情况。 A:Track 磁盘磁道 B:Geometrical sector 几何学中的扇形 C:Track sector 磁盘扇区 D:Nlock 块

页是物理内存或虚拟内存中一个连续的块,如图:

页的大小通常为磁盘块大小的 2^n 倍,一般设置为4KB,可以通过命令 getconf PAGE_SIZE 来获取页的大小:

可见,在linux系统中,页的大小和磁盘块大小保持了一致

物理内存大家都知道,但是虚拟内存可能不太了解。其实虚拟内存简单来说就是把磁盘当内存用,你可以把它当作内存,他会通过某种方式将磁盘中的数据调入内存中。

Page Cache

我们都知道文件可以持久化保存在磁盘,但是操作系统是在哪里读写文件的呢?总不能在磁盘读写文件吧,这可太慢了,因此就出现了Page Cache。Page Cache中文译名叫页高速缓存或页缓存用于将文件的磁盘块缓存到内存中,有了它,Linux 就可以把一些磁盘上的文件数据保留在内存中以提高文件的访问速度

而linux是如何组织Page Cache的?其实它就是以前面提到的页来组织的,一页大小为4KB。当用户对文件中的某个数据块进行读写操作时,linux首先会申请一个Page Cache与文件中的磁盘数据块进行绑定。如下图所示: image.png 因此,用户对文件的读写,其实并不是直接在磁盘上进行读写,因为那太慢了,而是在Page Cache中读写即可。如果用户读写的文件内容位于Page Cache中,则直接读写;否则要先从磁盘中读取相关内容到Page Cache中。因此,用户对文件的读写,其实是读写Page Cache,即位于内存中。

那么对Page Cache的写,什么时候刷回磁盘? 这一点和mysql的redo log日志刷盘很像,会有一个后台线程定时刷盘Page Cache。而且其实对文件的写入不仅和Page Cache有关,还和下面的Buffer Cache。

Buffer Page

前面介绍了Page Cache,现在又来一个Buffer Cache,他们两到底有啥区别和联系? 首先需要知道,Buffer Cache也是位于内存中的。前面提到linux中的一个Page Cache对于磁盘中的一个文件,而Buffer Cache则是对应磁盘中的一个磁盘块。因此一个Page Cache对应若干个磁盘块,而Buffer Cache则对应一个磁盘块。下面是它两的主要区别:

  • Page Cache比Buffer Cache大:一个Buffer Cache对应一个物理磁盘块;但是一个Page Cache对应多个物理磁盘块
  • 缓存对象不一致:Buffer Page的缓存对象是一个物理磁盘块,即使没有文件系统也是存在的,是物理层面的一个概念;而Page Cache缓存的是文件内容或一部分的文件内容,即缓存的是多个在物理磁盘上连续或者不连续的磁盘块,是文件系统的概念。简单来说:page cache用来缓存文件数据,buffer cache用来缓存磁盘块数据
  • 存在的意义不同:Buffer Cache以块形式缓冲了块设备的操作,定时或手动的同步到硬盘,它是为了缓冲写操作然后一次性将很多改动写入硬盘,避免频繁写硬盘,提高写入效率;Page Cache以页的形式缓存了文件系统的文件,给需要使用的程序读取,它是为了给读操作提供缓冲,避免频繁读硬盘,提高读取效率

也就是说,Page cache是针对文件系统的,是对文件的缓存,主要是为了给文件的读提供缓存,而不是写。在文件层面上的数据会缓存到 page cache。文件的逻辑层需要映射到实际的物理磁盘,这种映射关系由文件系统来完成。当 page cache 的数据需要刷新时,page cache中的数据交给buffer cache,然后buffer cache就会找到一个合适的时间将它刷新到物理磁盘。简单说来,page cache负责文件的读操作,buffer cache负责文件的写操作

索引节点和目录项

首先,需要明确,索引节点和目录项都是linux系统定义的两个结构体

  • 索引节点:本质是结构体 strcut inode,其中存储着文件系统处理文件所需要的所有信息,主要是文件属性(即文件元信息),主要包括:inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置.... strcut inode结构比较复杂,参考:Linux struct inode结构索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间
  • 目录项:目录项本质是结构体 struct dentry,主要用于存储:文件名、索引节点指针、其他目录项的层级关联关系。它的存放位置和索引节点不太一样,它是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存

不难发现,索引节点是每个文件的唯一标志,而目录项维护的正是文件系统的树状结构,一个文件由索引节点和目录项共同描述。如下图所示: 索引节点是存储在硬盘上的数据,linux为了加速文件的访问,通常会把索引节点加载到内存中。另外,磁盘进行格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区

  • 超级块:用来存储文件系统的详细信息,比如:块个数、块大小、空闲块
  • 索引节点区:用来存储这个文件的索引节点,索引节点有存放指向数据块区的指针
  • 数据块区:用来存储文件或目录数据

我们不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的:

  • 超级块:当文件系统挂载时进入内存,因为需要描述这个文件系统
  • 索引节点区:当文件被访问时进入内存

下面来讨论一个问题,struct inode和struct dentry是一一对应关系吗?先说答案:不是。 dentry结构体:

{
 atomic_t d_count;        /* 目录项引用计数器 */
 unsigned int d_flags;    /* 目录项标志 */
 struct inode  * d_inode;   /* 与文件名关联的索引节点 */
 struct dentry * d_parent;       /* 父目录的目录项 */
 struct list_head d_hash;        /* 目录项形成的哈希表 */
 struct list_head d_lru;         /*未使用的 LRU 链表 */
 struct list_head d_child;       /*父目录的子目录项所形成的链表 */
 struct list_head d_subdirs;     /* 该目录项的子目录所形成的链表*/
 struct list_head d_alias;       /* 索引节点别名的链表*/
 int d_mounted;                  /* 目录项的安装点 */
 struct qstr d_name;             /* 目录项名(可快速查找) */
 unsigned long d_time;           /* 由 d_revalidate函数使用 */
 struct dentry_operations  *d_op; /* 目录项的函数集*/
 struct super_block * d_sb;      /* 目录项树的根 (即文件的超级块)*/
 unsigned long d_vfs_flags; 
 void * d_fsdata;                /* 具体文件系统的数据 */
 unsigned char d_iname[DNAME_INLINE_LEN]; /* 短文件名 */
 };

重要字段:

  • struct inode d_inode:当前文件的inode指针(如果当前文件是目录,则目录文件存储的内容是文件名和inode号)
  • struct dentry * d_parent:指向父目录的指针
  • struct list_head d_subdirs:该目录项的子目录所形成的链表
  • struct list_head d_alias: 文件别名的链表

inode结构比较多,主要看一下几个重要的字段即可:

  • umode_t i_mode:文件访问权限,即那9个字符,rwxwxrw

  • i_uid、i_gid:文件拥有者id和用户组id

  • struct super_block i_sb:所属的超级块

  • loff_t i_size:应的文件的大小

  • blkcnt_t blocks:对应文件内容使用的磁盘块数量

  • int i_nlink:硬链接个数

  • long i_ino:inode号,linux使用该号来唯一确定一个文件,而不是文件名

  • atomic_t i_writecount:记录有多少个进程以可写的方式打开此文件

  • struct inode_operations *i_op: 各种函数接口

  • struct address_space i_data:用于关联内存页面,详见:文件怎么映射到内存

  • struct hlist_head i_dentry:所有引用该inode的目录项形成的链表 image.png 因此,答案显而易见,索引节点和目录项并不是一对一的关系:

  • 一个有效的dentry结构必定有一个inode结构。这是因为一个目录项要么代表着一个文件,要么代表着一个目录,而目录实际上也是文件。所以,只要dentry结构是有效的,则其指针d_inode必定指向一个inode结构

  • 一个inode却可能对应着不止一个dentry结构。这是因为一个文件可以有不止一个文件名或路径名,这是因为一个已经建立的文件可以被连接(link)到其他文件名。所以在inode结构中有一个队列i_dentry,指向所有引用该inode的目录项形成的链表

文件在磁盘中的组织形式

首先需要了解文件在磁盘上主要有三种分配方式:

  • 连续分配
  • 链表分配
  • 索引分配

但其实总的来看就两种分配方式:分配 n 个的连续的磁盘块或者拆分成多个并不一定连续的块

连续分配

最简单的分配方案是把每个文件作为一连串连续数据块存储在磁盘上。因此,在具有 4KB 块的磁盘上,将为 200 KB 文件分配 50 个连续块。

连续的磁盘空间分配有两个优点:

  • 连续文件存储实现起来比较简单,只需要记住两个数字就可以:第一个块的文件地址和文件的块数量,给定第一个块的编号,可以通过简单的加法找到任何其他块的编号
  • 读取性能强,可以通过一次操作从文件中读取整个文件。只需要一次寻找第一个块。后面就不再需要寻道时间和旋转延迟,所以数据会以全带宽进入磁盘

而缺点也很明显,磁盘会变得很零碎。如图: 这里有两个文件 D 和 F 被删除了。当删除一个文件时,此文件所占用的块也随之释放,就会在磁盘空间中留下一些空闲块。磁盘并不会在这个位置挤压掉空闲块,因为这会复制空闲块之后的所有文件,可能会有上百万的块,这个量级就太大了。因此,如果要使用该方式存储文件,那么最好指定文件的大小,否则会产生大量的碎片,但很明显,这完全不符合用户体验。因此计算机磁盘存储数据通常不用这种方式,而这种方式通常用于只读光盘存储:这种光碟只能写入数据一次,信息将永久保存在光碟上,使用时通过光碟驱动器读出信息。

链表分配

第二种存储文件的方式是为每个文件构造磁盘块链表,每个文件都是磁盘块的链接列表,就像下面所示: 每个块的第一个字作为指向下一块的指针,块的其他部分存放数据,其在磁盘位置如下所示: 与连续分配方案不同,这一方法可以充分利用每个磁盘块。除了最后一个磁盘块外,不会因为磁盘碎片而浪费存储空间。同样,在目录项中,只要存储了第一个文件块,那么其他文件块也能够被找到。但是,缺点也很明显:

  • 链表的分配方案中,尽管顺序读取非常方便,但是随机访问却很困难
  • 由于指针会占用一些字节,每个磁盘块实际存储数据的字节数并不再是 2 的整数次幂。虽然这个问题并不会很严重,但是这种方式降低了程序运行效率。许多程序都是以长度为 2 的整数次幂来读写磁盘,由于每个块的前几个字节被指针所使用,所以要读出一个完成的块大小信息,就需要当前块的信息和下一块的信息拼凑而成,因此就引发了查找和拼接的开销

由于连续分配和链表分配都有其不可忽视的缺点。所以提出了使用内存中的表来解决分配问题。取出每个磁盘块的指针字,把它们放在内存的一个表中,就可以解决上述链表的两个不足之处。下面是一个例子: 使用这种组织方式,整个块都可以存放数据。进而,随机访问也容易很多。虽然仍要顺着链在内存中查找给定的偏移量,但是整个链都存放在内存中,所以不需要任何磁盘引用。与前面的方法相同,不管文件有多大,在目录项中只需记录一个整数(起始块号),按照它就可以找到文件的全部块。这种方式存在缺点,那就是必须要把整个链表放在内存中。对于 1TB 的磁盘和 1KB 的大小的块,那么这张表需要有 10 亿项。每一项对应于这 10 亿个磁盘块中的一块。每项至少 3 个字节,为了提高查找速度,有时需要 4 个字节。根据系统对空间或时间的优化方案,这张表要占用 3GB 或 2.4GB 的内存

索引分配

索引分配的实现是为每个文件创建一个索引数据块,里面存放的是指向文件数据块的指针列表。只需要知道了文件的索引数据块,就可以通过索引数据块里的索引信息找到对应的数据块。如图: image.png 索引的方式优点在于:

  • 文件的创建、增大、缩小很方便;
  • 不会有碎片的问题;
  • 支持顺序读写和随机读写;

而缺点就是由于索引数据也是存放在磁盘块的,如果文件很小,明明只需一块就可以存放的下,但还是需要额外分配一块来存放索引数据,所以缺陷之一就是存储索引带来的开销

如果文件很大,大到一个索引数据块放不下索引信息,这时又要如何处理大文件的存放呢?我们可以通过组合的方式,来处理大文件的存。 链表 + 索引的组合: 在索引数据块留出一个存放下一个索引数据块的指针,当一个索引数据块的索引信息用完了,可以通过指针的方式,找到下一个索引数据块的信息。那这种方式也会出现前面提到的链表方式的问题,万一某个指针损坏了,后面的数据也就会无法读取了。

索引 + 索引的组合: 通过一个索引块来存放多个索引数据块,一层套一层索引。

linux的组织形式

linux文件系统组合了前面的文件存放方式的优点,如下图: 早期 Unix 文件系统 它是根据文件的大小,存放的方式会有所变化:

  • 如果存放文件所需的数据块小于 10 块,则采用直接查找的方式;
  • 如果存放文件所需的数据块超过 10 块,则采用一级间接索引方式;
  • 如果前面两种方式都不够存放大文件,则采用二级间接索引方式;
  • 如果二级间接索引也不够存放大文件,这采用三级间接索引方式;

那么,索引节点(Inode)就需要包含 13 个指针:

  • 10 个指向数据块的指针;
  • 第 11 个指向索引块的指针;
  • 第 12 个指向二级索引块的指针;
  • 第 13 个指向三级索引块的指针;

所以,这种方式能很灵活地支持小文件和大文件的存放:

  • 对于小文件使用直接查找的方式可减少索引数据块的开销;
  • 对于大文件则以多级索引的方式来支持,所以大文件在访问数据块时需要大量查询;

那么,磁盘块这么多,linux如何管理空闲的磁盘块? 管理空闲磁盘块的方式有挺多,比如链表法啥的。但是linux采用的是位图法

位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。它形式如下:

1111110011111110001110110111111100111 ...

位图法不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。

文件在内存中的组织形式

前面提到,文件在内存中的存在形式对应的是一个Page Cache概念,而Page Cache的存放基本单位就是一个页(即4KB)大小,一个Page Cache由多个页组成page(页)是linux内核管理物理内存的最小单位,内核将整个物理内存按照页对齐方式划分成千上万个页进行管理,内核为了管理这些页将每个页抽象成struct page结构管理每个页状态及其他属性,针对一个4GB内存,那么将会存在上百万个struct page结构。struct page定义在include/linux/mm_types.h文件中,其结构比较复杂,只需要了解几个重要的成员即可。

  • flags:描述page的状态,这些状态包括页是不是脏的,是不是被锁定在内存中等。flag的每一位单独表示一种状态,所以它至少可以同时表示出32种不同的状态
  • _count:引用计数,表示内核中引用该page的次数,如果要操作该page,引用计数会+1,操作完成-1。当该值为0时,表示没有引用该page的位置,所以该page可以被解除映射,这往往在内存回收时是有用的
  • virtual:表示页的虚拟地址,它就是页在虚拟内存中的地址。有些内存(即所谓的高端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的时候,必须动态地映射这些页
  • _mapcount:被页表映射的次数,也就是说该page同时被多少个进程共享。初始值为-1,表示没有被进程映射,如果只被一个进程的页表映射了,该值为0 注意区分_count和_mapcount:

_mapcount表示的是映射次数,而_count表示的是使用次数;被映射了不一定在使用,但要使用必须先映射。

  • mapping:表示页面所指向的地址空间。内核中的地址空间通常有两个不通的地址空间,一个用于文件映射页面,例如:在读取文件时,地址空间用于将文件内容数据与装载数据的存储介质区关联起来。另一个用于匿名映射。有三种含义
    a、如果mapping = 0:说明该page属于交换缓存(swap cache),当需要使用地址空间时会指定交换分区的地址空间swap_space
    b、如果mapping != 0,bit[0] = 0:说明该page属于页缓存或文件映射,mapping指向文件的地址空间address_space(另外一个重要的结构体)
    c、如果mapping != 0,bit[0] != 0:说明该page为匿名映射,mapping指向struct anon_vma对象
  • index:记录这个页在文件中的页偏移量,可以将此页帧定位到一个文件中的具体位置

struct page结构体值得注意的:

  • page结构与物理页相关,而并非与虚拟页相关。因此,该结构对页的描述只是短暂的。即使页中所包含的数据继续存在,由于交换等原因,它们也可能并不再和同一个page结构相关联。内核仅仅用这个数据结构来描述当前时刻在相关的物理页中存放的东西。 这种数据结构的目的在于描述物理内存本身,而不是描述包含在其中的数据
  • 内核用这一结构来管理系统中所有的页。因为内核需要知道一个页是否空闲(也就是页有没有被分配)。如果页已经被分配,内核还需要知道谁拥有这个页。拥有者可能是用户空间进程、动态分配的内核数据、静态内核代码或页高速缓存等
  • 系统中的每个物理页都要分配一个这样的结构体,这样需要占用多大空间?就算struct page占40个字节的内存吧,假定系统的物理页为8KB大小,系统有4GB物理内存。那么, 系统中共有页面 524288个,而描述这么多页面的page结构体消耗的内存只不过20MB:也许绝对值不小,但是相对系统4GB内存而言,仅是很小的一部分罢了。因此,要管理系统中这么多物理页面,这个代价并不算太

linux如何判断一个文件是否已经存在Page Cache(物理内存)中?通过前面的mapping字段的其中一种状态:“在读取文件时,地址空间用于将文件内容数据与装载数据的存储介质区关联起来”。可见,管理物理内存(struct page描述)和磁盘文件的中间桥梁就是这个地址空间struct address_space,它被称为文件系统和页缓存的中间适配器,用来指示一个文件在页缓存中已经缓存了的物理页。struct address_space的结构如下:

                                                                                                        struct address_space {
     //指向拥有该对象的索引节点(即代表这磁盘上面的文件)的指针
struct inode		*host;		
    //表示拥有者页的基数radix tree 的根
struct radix_tree_root	page_tree;	
    //保护基树的自旋锁
spinlock_t		tree_lock;
    //地址空间中共享内存映射的个数
atomic_t		i_mmap_writable;
    //radix优先搜索树的根
struct rw_semaphore	i_mmap_rwsem;	
    
/* Protected by tree_lock together with the radix tree */
unsigned long		nrpages;	/* number of total pages */
unsigned long		nrshadows;	/* number of shadow entries */
pgoff_t			writeback_index;/* writeback starts here */
const struct address_space_operations *a_ops;	/* methods */
unsigned long		flags;		/* error bits/gfp mask */
spinlock_t		private_lock;	/* for use by the address_space */
struct list_head	private_list;	/* ditto */
void			*private_data;	/* ditto */
  • 该对象就嵌在inode结构体的i_data字段中,address_space对象的host字段指向其所有者的索引节点对象
  • 地址空间address_space的page_tree指针指向页缓存基数树,页缓存实际上就是采用了一个基数树结构将一个文件的内容组织起来存放在物理内存中

页缓存基数树的概要结构如下所示: 也就是说在linux中,一个文件在内存中存在的形式就是页缓存基数树,基数树的叶子节点存放的就是描述文件内容的页(struct page)。现在,总体来看:

可以看到地址空间address_space就是连接了内存中的页缓存和磁盘中的inode,这也是他被称为文件系统和页缓存的中间适配器的原因。