Ext文件系统

1、数据的存储

  虽然从Ext2到Ext4,找数据的方式发生了变化,但是,磁盘的布局还是非常相似的。其实这个东西也不需要变化,因为现在也没什么特别巧妙的方式,而且磁盘的吞吐量、效率的瓶颈也不在这里。当然,这里排除那些根据自身文件特点设计的数据库,毕竟还是为了支持通用文件。磁盘布局如下:

  Boot在第一个块,放的应该是引导程序,超级块就放在了第二个块上,如果不是可以在mount的时候通过参数sb来设置。对于经常要访问(比较重要)的内容可以在每个块组中都存储(当然这是比较浪费空间的,所有有的只选择在部分),在不同的块组中访问这些内容的时候磁头移动的距离就小了。如果开启了sparse block功能,那么只会在3、5、7的幂级的块组上才冗余这些信息。

  在超级块里面保存了这个文件系统的统计信息等,而且从中可以看出这个文件系统的特点。在内存中装载的时候也从超级块开始,知道了磁盘上的布局之后才能顺利的进行后面的操作,ext4_super_block的内容大概如下:

  1. 块、inode等统计信息,访问时间等。
  2. 块大小、以及每块数目等布局信息。
  3. uid、gid。
  4. 特性相关。
  5. 预先分配相关。
  6. 日志支持相关。
  7. 64位的支持,其实保存了需要的高16位。

  块组中其实没有保存什么有用的信息(例如超级块),ext4_group_desc的存在就是为了把磁盘分成一个个的块组。那分成块组之后有什么好处呢?试想一下如果没有块组,那么是不是位图这个关键的数据是不是要集中放到磁盘上的一个固定的位置?访问当然是没有问题的,但是不管在磁盘上的什么地方访问的时候都要跑到一个相同的地方去再访问一次位图,这样的代价就比较大了。而且在分配的时候尽量把文件的数据块放在同一个组,那么能在分配阶段就一定程度地避免了碎片。下面是ext4_group_desc中大概的内容(和Ext2中的不同的是对更大范围的支持):

  1. 块组中块位图、inode位图、inode表的位置。
  2. 空闲块(inode)、目录的数目。
  3. 校验。

  接下来就是块位图、inode位图了。为什么需要inode位图呢?因为ext4文件系统在要分配一个新的inode的时候并不是像在内存中分配其他数据结构的时候需要的时候分配数据结构占用的空间,而是预先分配好一组放在位图后面,就像一个inode池的感觉。那么在需要ext4_inode的时候就需要知道哪些是空闲的,哪些是用过的,这就是位图的作用了。位图具体就没什么好介绍的了,下面看ext4_inode的内容:

  1. mode、uid、gid等我们能看到的信息。
  2. 各种时间戳。
  3. i_data用来找到数据块(下面介绍怎么找)。
  4. file acl。

  大家也许奇怪了,这里的inode中看不到层次关系啊,那我们看到的是从哪里来的?其实对计算机来说找一个文件最方便、最简单的方法就是通过ino。但是这个对人类来说就太不方便了。所以Ext就做了一个看起来比较单独的一层来支持人的查找习惯:ext4_dir_entry_2。这个结构其实就是从name到ino的一个转换。但是为什么不在inode中直接保存了这个信息?如果那样的话代价就挺大的。现在相当于用ext4_dir_entry_2来体现了Ext4文件系统的骨架,然后具体的数据却是放在一个池子里面的。下面是ext4_dir_entry_2的内容:

  1. ino。
  2. ext4_dir_entry_2的长度(其实可以看成是到下个项的指针)。
  3. 名字长度。
  4. inode的类型。
  5. name。

关于目录项在磁盘上怎么保存的呢?大概如下图:

  图中的黄色的部分是一个目录项,然后通过ren_len就可以找到下一个项的位置。这样的话就相当于内存中的链表中的next指针了。而且,从上面图中也能大概看出如何分配、回收项占用磁盘的内存了。

  一个目录项当然是有其对应的inode,该目录项的内容以及子目录中有哪些inode是在ext4_dir_entry_2中保存的,而这些值的位置是在inode对应的数据块中的。这貌似又到了一个先有鸡(ino)还是先有蛋(ext4_dir_entry_2)的问题了。不过这个问题的解决方法都是很简单的(可以回想一下slab 初始化中的类似问题)。不管有多少的目录,他们总是有一个根目录的,也就是root。这样只要把root的ino设置为一个固定的值所有的问题都解决了,如果没有记错的话root的ino是2。

  到了这里,也许大家在想数据块中的内容貌似不只是数据嘛(其实子目录也是目录文件的数据了),那到底数据块中保存的是什么呢?对不同类型的文件来说当然是不同的,大概的区分如下:

  1. 常规文件就是保存数据了。
  2. 目录文件中保存自己、父目录、子目录的目录项。
  3. 对于符号链接文件,如果路径很短就保存在inode中,不然为其分配数据块来保存。
  4. 设备、管道、socket没有数据块,设备文件的主、从设备号保存在inode中。

2、查找数据

  在对Ext文件系统还什么都不知道的时候应该比较关心从路径名到ino的过程。这个过程的大部分工作量应该在VFS中吧,而且感觉没什么特别的地方。现在查找数据对这部分的内容就忽略不计了。我们关系的是,给定ino&offset,怎么样知道在磁盘上的位置呢?在课本上学到的只是告诉我们这个地方应该是用B树的,但是从Ext2好像没看的B树的影子,反而看到了内存管理中见过的东西:间接块。

  间接块管理磁盘上文件的数据块位置和内存中分页的效果有点像,当然细节还是不同的。不过这个地方的坏处就很明显了,如果是访问大文件中的两个顺寻的物理块,那么即使他们物理上也是相邻的(如果内存中没有其缓存的话),也要从第一层的间接块开始访问,直到最后一层才知道真正的块号。在内存中分页当然是问题不大的,但是磁盘上这样高出几倍的访问时间效率会很差。

  实际中当然是没有这么差的,间接块被缓存在内存中了,那么在很多情况下是不需要在取磁盘上读取其内容了。这种管理方式大概如下:

  另外,磁盘上的间接块一个比较复杂的地方是并不是所有的块都需要用到三级间接块,只有直接寻址用完了才需要一级寻址,二、三级寻址是同样的道理。所以,在寻找之前就需要知道到底用到几级间接寻址?在各级间接寻址中的偏移量是多少(其实这还算一个比较简单的过程吧)?

  在Ext4中引入了Extents的管理方式,每个extent是一组连续的块,提高了不少效率。就像间接块那样,要浪费一些空间来保存管理结构。其中ext4_extent表示一组连续的数据块,所表示的范围是从ee_start_hi<<32+ee_start到ee_start_hi<<32+ee_start+ee_len-1的范围。ee_len是16位的无符号数,但是最高位被预分配特性中用来标识是否一级被初始化过,所以一个extent可以表示2^15个连续的数据块。ext4_extent的定义如下:

struct ext4_extent {
__le32 ee_block;
__le16 ee_len;
__le16 ee_start_hi;
__le32 ee_start_lo;
};

  ext4_extent是在这个树的最底层的,上面则是通过ext4_extent_idx构建整个树的结构的,只有到了字节点的时候才用到ext4_extent来表示真正的范围,就像下面这个图所表示的(一棵高度一定的树):
  这样的话每个块中以ext4_extent_header开头,保存了层数、魔数、该块中的项的数目等信息。下面来看这个结构的具体定义:

struct ext4_extent_header {
__le16 eh_magic; /* probably will support different formats */
__le16 eh_entries; /* number of valid entries */
__le16 eh_max; /* capacity of store in entries */
__le16 eh_depth; /* has tree real underlying blocks? */
__le32 eh_generation; /* generation of the tree */
};

  而ext4_extent_idx的作用也就很明显了,帮助我们很容易地找到ext4_extent就可以了。这里只需要存储一个范围、以及这个范围内下层的位置就好啦,该结构的具体定义如下:

struct ext4_extent_idx {
__le32 ei_block;
__le32 ei_leaf_lo;
__le16 ei_leaf_hi;
__u16 ei_unused;
};

  其中ei_block就是偏移量的范围了,在查找的时候可以通过二分(ext4_ext_binsearch_idx)来找到所在的ext4_extent_idx,这里可以看出这种管理方式能表示的单个文件的大小是比ext2大了些。ei_leaf_lo+ei_leaf_hi<<32就是下层的ext4_extent_idx的位置了。关于如何定位的细节就不在这里说明了,看代码还是比较容易的。

  Ext4中给定一个文件,怎么知道他是间接块方式还是extents方式的呢?通过ext4_inode中的flag的相应位来检查。两种管理方式都用到了i_data段,所以是不可能共存的,况且共存不会带来什么好处,反而会让复杂度急剧增加。
3、日志

  什么是日志以及日志的作用就不在这里说了。Ext3与Ext2的管理数据块的方式都是差不多的,不同之处是加上了记录日志的功能,可以从Ext2平滑地过渡到Ext3。Ext3日志文件系统的思想就是对文件系统任何的高级操作都分两步进行,首先,把待写入的块的一个副本存放在日志中;其次,当发往日志的I/O数据传送完成时,块就写入文件系统。当发往文件系统的I/O数据传送终止时,日志的块副本就被丢弃。

  Ext3既可以只对元数据的修改做日志,也可以记录所有的日志。有下面的三种日志模式:

  1. journal,把所有数据块的改变都记入日志,最安全也最慢。
  2. ordered,只记录元数据的改动,Ext3会把元数据和相关的数据块进行分组,以便把元数据写入磁盘之前写入数据块。
  3. writeback,只记录元数据改动,最快的一种模式。

  Ext3本身不处理日志,而是利用日志块设备(JBD)。Ext3调用JBD例程来确保在系统万一出现故障时他的后续操作不会损坏磁盘数据结构。Ext3与JBD之间的交互本质上基于三个基本单元:日志记录、原子操作、事务。磁盘上把日志记录在.journal中,下面看该文件的结构:

可以看到日志的内容是一组组的提交块以及撤销块,下面看提交块的结构:

在日志中,每个块都开头都是journal_header_t(当然是不包括数据块的)。这个结构表明了这个描述块是一种描述块(其实可以说成是三种中的一种)还是普通的数据块。下面来看journal_header_t结构:

typedef struct journal_header_s{
__be32 h_magic;
__be32 h_blocktype;
__be32 h_sequence;
} journal_header_t;

h_magic算是一个标志吧,如果死一个日志块描述则为JFS_MAGIC_NUMBER。h_blocktype表示块的类型,有以下五种:

#define JBD2_DESCRIPTOR_BLOCK   1
#define JBD2_COMMIT_BLOCK 2
#define JBD2_SUPERBLOCK_V1 3
#define JBD2_SUPERBLOCK_V2 4
#define JBD2_REVOKE_BLOCK 5

  如果数据块被转义,那么开头的四个字节可能正好是JFS_MAGIC_NUMBER,那么这个块就背误认为是描述符块了。解决方法是当要往日志中写一个普通的数据块时,如果发现其开头的四个字节刚好是JFS_MAGIC_NUMBER,则将该4个字节改写成0,并且在描述符块的索引项中设置JFS_FLAG_ESCAPE,表示被转义过了,在恢复的时候重新把这四个字节该为JFS_MAGIC_NUMBER。而h_sequence表示这个描述块的序号。在journal_header_t后面跟着的是n个要提交的块的描述结构,也就是journal_block_tag_t,该结构如下:

typedef struct journal_block_tag_s{
__be32 t_blocknr;
__be32 t_flags;
__be32 t_blocknr_high;
} journal_block_tag_t;

t_blocknr和t_blocknr_high组合起来指明要处理的是哪个磁盘上的块,t_flags则指明了操作,有下面的几个值:

#define JBD2_FLAG_ESCAPE       1
#define JBD2_FLAG_SAME_UUID 2
#define JBD2_FLAG_DELETED 4
#define JBD2_FLAG_LAST_TAG 8

1代表这个块经过转义了,2表明和前面是相同的UUID(在第一个索引项中包含了128位的UUID,表明该块所属的文件系统),有这个标志可以节省空间。journal_header_t中没有指出下面有多少个索引项,所以如果发现t_flags中设置了JBD2_FLAG_LAST_TAG就说明是最后一个了。

  提交块就很简单了,只是一个journal_header_t了,下面看取消块的结构:

  虽然对journal_header_t进行了一次包装,其实也只是增加了一个r_count,表示取消块中实际使用的字节数。接下来是保存的一组块好,这个是用来加快恢复速度的。如果在恢复的时候发下了保存的这些块并且当前transaction ID <= 67则不必恢复了(因为transaction 67中已经将这些块删除了)。从这里可以看出取消块中保存的应该是一些特别的journal_block_tag_t所指向的块号(也就是这些块是没办法恢复的了?)。好了,下面在来一张日志的布局图(相比来说这张图片更清楚一些):

  日志处理的关键不在磁盘上,而是在内存中(貌似很多其他的东西也是这样的==)。在内存中,事务用transaction_t来表示,描述了一个日志文件中的一个描述符块。主要有些一下内容:

  1. 事务的序列号、事务的状态、在日志中的开始位置、所管理的缓存数目。
  2. 几个由journal_head构成的列表,保存不同状态的缓存。
  3. 最长等待时间、启动时间、到期时间。
  4. 检查点统计。
  5. running number。
  6. handle数目。
journal_head双向链表有9中,在得到一个buffer_head的时候要决定把它放在哪个链表上。各个链表之间的不同会在后面给出:
#define BJ_None         0       // 不是日志
#define BJ_SyncData 1 // 同步数据?提交日志之前刷出数据。
#define BJ_Metadata 2 // 元数据
#define BJ_Forget 3 // 被取代的
#define BJ_IO 4 // 临时I/O用的
#define BJ_Shadow 5 // 缓存区的内容正在被保存到日志
#define BJ_LogCtl 6 // 缓存区包含日志描述符
#define BJ_Reserved 7 // 保留缓存区用与日志的访问需要
#define BJ_Locked 8 // 提交期间被锁定
#define BJ_Types 9
这些链表上保存的是journal_head,该结构相当于对buffer_head进行了一次封装,把缓存区纳入日志系统的管理中。该结构定义如下:
struct journal_head {
struct buffer_head *b_bh;      // 指向缓存区
int b_jcount;       //
unsigned b_jlist;         // 日志列表
unsigned b_modified;    // 被现在的运行事务修改了
char *b_frozen_data;    // 保存一份冻结数据
char *b_committed_data;    // 一份未提交数据的保存,避免复写未提交的删除?
transaction_t *b_transaction;    // 所有者,只是把数据放在这个上面?
transaction_t *b_next_transaction;    // 正在修改数据的事务。一个事物正在提交,另一个在修改
struct journal_head *b_tnext, *b_tprev;// 构建双向链表
transaction_t *b_cp_transaction; // 在哪个事务中这个缓存在被检查?
struct journal_head *b_cpnext, *b_cpprev; // 构建双向链表
};

在内存中用journal_s来管理一个日志,该结构如下:

    journal_superblock_t    *j_superblock;// 超级块    
int j_format_version;// 超级块的版本
spinlock_t j_state_lock;// 保护版本
int j_barrier_count;// 等待创建屏障锁的进程数目
struct mutex j_barrier;// 屏障锁
transaction_t *j_running_transaction;// 目前正在运行的事务
transaction_t *j_committing_transaction;// 正在提交的事务
transaction_t *j_checkpoint_transactions;// 等待检查的事务
wait_queue_head_t j_wait_transaction_locked;// 等待一个锁定事务去开始提交
wait_queue_head_t j_wait_logspace;// 等待检查完成
wait_queue_head_t j_wait_done_commit;// 等待提交完成
wait_queue_head_t j_wait_checkpoint;// 触发检查的等待队列
wait_queue_head_t j_wait_commit;// 触发提交的等待队列
wait_queue_head_t j_wait_updates;// 等待更新完成
struct mutex j_checkpoint_mutex;// 避免同时检查
unsigned long j_head;// 第一个没用的块
unsigned long j_tail;// 最后一个还在用的块
unsigned long j_free;// 日志中的空闲块
unsigned long j_first;// 日志的开始
unsigned long j_last;// 日志的结束
struct block_device *j_dev;// 块设备
int j_blocksize;// 块大小
unsigned long long j_blk_offset;// 在块设备上的大小
struct block_device *j_fs_dev;// 客户端的文件系统的块设备
unsigned int j_maxlen;// 日志的最大容量
spinlock_t j_list_lock;// 保护buffer并发
struct inode *j_inode;// 任选的存放日志的inode
tid_t j_tail_sequence;// 最老的事务的序列号
tid_t j_transaction_sequence;// 下个事务的序列号
tid_t j_commit_sequence;// 最近提交的事务的序列号
tid_t j_commit_request;// 最近提交事务的编号
__u8 j_uuid[16];// uuid
struct task_struct *j_task;// 现在正在提交的进程
int j_max_transaction_buffers;// 一次日志提交元数据的限制
unsigned long j_commit_interval;// 事务的生命长度
struct timer_list j_commit_timer;// 定时器
spinlock_t j_revoke_lock;
struct jbd2_revoke_table_s *j_revoke;// 在现在的事务上的取表
struct jbd2_revoke_table_s *j_revoke_table[2];
struct buffer_head **j_wbuf;// 提交的bh数组
int j_wbufsize;
pid_t j_last_sync_writer;
struct transaction_stats_s *j_history;
int j_history_max;
int j_history_cur;
spinlock_t j_history_lock;
struct proc_dir_entry *j_proc_entry;
struct transaction_stats_s j_stats;
void *j_private;
};
posted @ 2011-10-28 10:41  GG大婶  阅读(3134)  评论(0编辑  收藏  举报