Linux内核-文件系统-高速缓冲区

文件系统-高速缓冲区

文件系统基本概念

磁盘中要有目录的映射,我们把磁盘分成盘片,这里的“盘片”指的就是逻辑划分出的独立文件系统区域,也就是我们今天说的 C盘、D盘。

每一个盘片都有一个文件系统的子系统(章节目录)

引导块:用来引导设备的,引导块可以为空,但一定要空开,因为保持格式的一致性

超级块:该文件子系统的描述符(记录该盘片的逻辑块位图的地址,i节点位图的地址)

逻辑块位图:其中每一位对应一个逻辑块的使用情况,对应逻辑块如果使用了则逻辑位图对应位置置1

i节点位图:其中每一位对应一个i节点的使用情况,对应i节点如果使用了则i节点位图上的bit位置1

i节点:目录与磁盘的桥接,文件的属性描述

逻辑块:用来存储数据的数据存储单元

盘片结构png

inode结构体

struct m_inode {
    unsigned short i_mode;// 文件类型和属性(rwx 位)
    unsigned short i_uid; // 用户 id(文件拥有者标识符)
    unsigned long i_size;// 文件大小(字节数)
    unsigned long i_mtime;// 修改时间(自 1970.1.1:0 算起,秒)
    unsigned char i_gid;// 组 id(文件拥有者所在的组)
    unsigned char i_nlinks; // 链接数(多少个文件目录项指向该 i 节点)
    unsigned short i_zone[9]; // 直接(0-6)、间接(7)或双重间接(8)逻辑块号。 zone 是区的意思,可译成区段,或逻辑块。  指向数据块
};
/* these are in memory also */
    struct task_struct * i_wait;    // 等待该 i 节点的进程
    unsigned long i_atime;          // 最后访问时间 access time
    unsigned long i_ctime;          // i 节点自身修改时间 change time
    unsigned short i_dev;          //存储该文件所在的设备,dev_t中包括主设备号和次设备号
    unsigned short i_num;          // i 节点号
    unsigned short i_count;        // i 节点被使用的次数,0 表示该 i 节点空闲
    unsigned char i_lock;            // 锁定标志
    unsigned char i_dirt;            // 已修改(脏)标志
    unsigned char i_pipe;            // 管道标志
    unsigned char i_mount;         // 安装标志(挂载标志)
    unsigned char i_seek;          // 搜寻标志(用于lseek)
    unsigned char i_update;            // 更新标志
};

知道前面7个成员变量就能够知道文件的类型和属性

i_zone:是文件和磁盘的映射

  • i_zone[0]~i_zone[6]是直接块号,如果文件只使用7个逻辑块,那么数组中的每个元素则存储了一个逻辑块号
  • i_zone[7]是一次间接块号,如果占用的逻辑块较多大于7个且小于512+7个,则占用一次间接块号
  • i_zone[8]是二次间接块号,如果占用逻辑块太多大于512+7且小于512*512+7,则启动二次间接逻辑块

高速缓冲区

应用程序不是直接与磁盘进行数据交互,而是中间通过高速缓冲区

高速缓冲区对于应用程序是无感的,应用程序只以为自己在和磁盘交互

高速缓冲区的管理要素

  1. 映射关系(内存与磁盘之间的映射关系)
  2. 应用程序和高速缓冲区的交互API
  3. 高速缓冲区与磁盘的交互API
  4. 高速缓冲区的管理系统(循环链表+哈希表+单链表)

高速缓冲区工作流程

高速缓冲区中存储着对应块设备驱动(磁盘,硬盘,闪存等)的数据

当从块设备中读取数据时候,操作系统会先从高速缓冲区中检索,如果没有则从块设备中读出数据;如果有并且是最新的,就直接和该高速缓冲区进行数据交互

高速缓冲区大致结构

高速缓冲区结构png

高速缓冲区中有低区和高区,低区中对应这高速缓冲区的头,而高区对应着高速缓冲区数据,每一个低区和高区一一对应。高区中每一个逻辑块的大小为1024字节

高速缓冲区的头是一个结构体,里面存这高速缓冲区的所有状态,时间,信息

缓冲区头结构

缓冲区的头结构使用buffer_head结构体来管理

// 缓冲区头数据结构。(极为重要!!!) 
 // 在程序中常用 bh 来表示 buffer_head 类型的缩写
struct buffer_head {
    char * b_data;            /* pointer to data block (1024 bytes) 数据块 */
    unsigned long b_blocknr;    /* block number 数据逻辑块号 */
    unsigned short b_dev;        /* device (0 = free) 数据源的设备号 */
    unsigned char b_uptodate;   // 更新标志,表示数据是否已经更新
    unsigned char b_dirt;        /* 0-clean,1-dirty */
    unsigned char b_count;        /* users using this block */  //使用的用户数, reference count? 
    unsigned char b_lock;        /* 0 - ok, 1 -locked */
    struct task_struct * b_wait;          // 指向等待该缓冲区解锁的任务
    struct buffer_head * b_prev;        // 前一块(这四个指针用于缓冲区的管理)
    struct buffer_head * b_next;        // 下一块
    struct buffer_head * b_prev_free;    // 前一空闲块
    struct buffer_head * b_next_free;    // 下一空闲块
};
  • b_wait是一个等待该缓冲区释放的任务队列

空闲链表

b_prev_freeb_next_free构成循环链表

空闲块循环链表png
  • 包含范围只包含 b_count == 0(即当前无人使用)且可被回收重用的缓冲区头

哈希散列表

b_prevb_next使用哈希散列表存储,哈希链表一共有307项

每一项的标识使用设备号^逻辑块号作为哈希值,对于拥有同一个哈希值的缓冲区头结构都在同一个散列项组成的循环链表中,这种方式提高了查找管理效率

哈希散列表结构png
  • 包含范围:包含所有已经缓存了特定磁盘块数据的缓冲区头。只要该缓冲区头对应着一个确定的磁盘逻辑块(即 b_devb_blocknr 有效),无论此时有没有进程在读/写它(b_count 可能是 0,也可能是 5),它都待在这条哈希链上。

同时存在于两条链中(最常见的情况)

条件: b_count == 0(当前无人使用) b_dev != 0(缓存着有效的磁盘数据)。

  • 此时,这个缓冲区虽然在哈希链表中(因为内核可以通过 (设备, 块号) 快速找到它,实现缓存命中),但它同时也挂在空闲链表中(因为没人用,随时可以被内核征用去加载其他磁盘块)。
  • 这就是“缓存保留”策略:数据虽然暂时不用,但先不丢弃,留在哈希表里等着万一有人再次读取,就能秒级响应。只有当系统需要新的缓冲块且空闲链表不够用时,才会把这种块从哈希链中摘除,换成新块。

高速缓冲区函数

获取空闲块getblk

// 在缓冲区获得空闲缓冲块,首次调用时, dev = 0x300, block = 0
struct buffer_head * getblk(int dev,int block)

整体流程图

getblk流程图png

这个函数用来获取空闲的高速缓冲区块,首先去哈希表中寻找已经在高速缓冲区中但是没人使用的块

repeat:
    // 搜索 hash 表,如果指定块已经在高速缓冲中,则返回对应缓冲区头指针,退出
    if (bh = get_hash_table(dev,block))
        return bh;

在哈希表中获取空闲块get_hash_table

/**
 * @brief 为指定的设备和块号查找缓冲头, get bh from hash table
 * 
 * @param dev 设备
 * @param block 块号
 * @return struct buffer_head* 缓冲头指针
 */
struct buffer_head * get_hash_table(int dev, int block)
{
    struct buffer_head * bh;

    for (;;) {
        if (!(bh=find_buffer(dev,block)))
            return NULL;
        bh->b_count++;
        wait_on_buffer(bh);
        if (bh->b_dev == dev && bh->b_blocknr == block)
            return bh;
        bh->b_count--;
    }
}

在这里通过devblock去哈希表中寻找


find_buffer

struct buffer_head * hash_table[NR_HASH];// 初次调用   (0x300^0)%307 = 154
#define _hashfn(dev,block) (((unsigned)(dev^block))%NR_HASH)
#define hash(dev,block) hash_table[_hashfn(dev,block)]// 在缓冲区查找指定的缓冲块, 初次调用dev = 0x300, block = 0

static struct buffer_head * find_buffer(int dev, int block)
{        
    struct buffer_head * tmp;

    for (tmp = hash(dev,block) ; tmp != NULL ; tmp = tmp->b_next)
        if (tmp->b_dev==dev && tmp->b_blocknr==block)
            return tmp;
    return NULL;
}

hash是一个宏定义,最终是一个buffer_head结构体数组,也就是对应上图中的散列表,这里通过devblock去异或操作拿到对应散列项,也就拿到了对应的链表,通过遍历链表得到对应的高速缓冲区头结构体


如果找到了缓冲区头结构体buffer_head,先将这个缓冲区块的引用计数+1,如果缓冲区当前被锁定(其余进程正在操作),那么就wait_on_buffer等待缓冲区解锁


等待缓冲区解锁wait_on_buffer

//// 等待指定缓冲区解锁
static inline void wait_on_buffer(struct buffer_head * bh)
{
    cli();
    while (bh->b_lock)
        sleep_on(&bh->b_wait);
    sti();
}

这里的cli()sti()分别是关中断和开中断,是为了保证 “检查锁状态”“进入睡眠等待” 这两个操作成为一个不可分割的原子操作

如果不加这两条汇编,在单核 CPU (早期 Linux/Minix 的设计场景)下,会发生经典的 “唤醒丢失”(Lost Wakeup) 问题,导致进程永远卡死(死锁)

为了让你看清这个微妙的陷阱,我们假设没有 cli()sti(),代码变成:

// 假设没有关中断
while (bh->b_lock)
 sleep_on(&bh->b_wait);

此时,如果缓冲区正好被锁住(b_lock = 1),进程 A 准备睡眠。CPU 的时间线可能是这样的:

时间点进程 A (当前执行)硬盘中断处理程序 (随时打断)
T1执行 while (bh->b_lock),发现条件为 (锁住了)。
T2(正准备调用 sleep_on 将自己挂起)突发中断! 硬盘读取完成,执行中断例程。例程将 b_lock 设为 0(解锁),并调用 wake_up(&bh->b_wait) 唤醒等待者。
T3中断处理完毕,CPU 回到进程 A。进程 A 继续执行 sleep_on(&bh->b_wait)

结果:在 T2 时刻,等待队列是空的(因为进程 A 还没挂进去),wake_up 白白喊了一声,没有任何进程被唤醒。紧接着 T3 时刻,进程 A 把自己挂入了等待队列,并进入“不可中断睡眠”状态。从此以后,再也没有人(因为锁已经解了,中断不会再来了)来唤醒进程 A 了——进程 A 永久阻塞,系统卡死

在 Linux 0.11 内核源码中,开中断的动作并不是由 sleep_on 完成的,而是由 硬件(CPU 任务切换)调用者(wait_on_buffer 末尾的 sti() 完成的。

sleep_on 末尾调用了 schedule()(进程调度函数)。schedule() 最终会调用一个汇编宏 switch_to,通过 ljmp(长跳转) 指令切换到新进程的 TSS(任务状态段)

  • 关键硬件机制:x86 CPU 在执行任务切换(通过 ljmp 跳转到 TSS 描述符)时,会自动从新任务的 TSS 中加载 EFLAGS 寄存器到 CPU 中。
  • 新任务的 EFLAGS 标志位里,中断允许位(IF)默认是 1(开中断)
  • 因此,当 CPU 开始执行新进程的代码时,中断已经在硬件层面被自动打开了sleep_on 根本不需要(也无法)在这个时间点执行 sti()

再次确认缓冲区

get_hash_table函数最后还有一次判断当前块是否是所选择的设备号和块号

因为可能在等待块释放的过程中其他进程对这个块进行了修改,可能更改了设备号和块号

        if (bh->b_dev == dev && bh->b_blocknr == block)
            return bh;
        bh->b_count--;

最后要记得把引用计数-1

在空闲链表中获取空闲块

    // 扫描空闲数据块链表,寻找空闲缓冲区     
    tmp = free_list;  //指向空闲缓冲区头
    do {
        if (tmp->b_count)        // 如果该缓冲区正被使用(引用计数不等于 0),则继续扫描下一项
            continue;

        // 如果缓冲区头指针 bh 为空,或者 tmp 所指缓冲区头的标志(修改、锁定)少于(小于)bh 头的标志, 
         // 则让 bh 指向该 tmp 缓冲区头。如果该 tmp 缓冲区头表明缓冲区既没有修改也没有锁定标志置位, 
         // 则说明已为指定设备上的块取得对应的高速缓冲区,则退出循环    
        if (!bh || BADNESS(tmp)<BADNESS(bh)) {
            bh = tmp;
            if (!BADNESS(tmp))
                break;
        }
/* and repeat until we find something good */
    } while ((tmp = tmp->b_next_free) != free_list);

这里使用的一个循环去遍历空闲链表while ((tmp = tmp->b_next_free) != free_list)

1.首先如果是正在被使用的,那么就不管直接下一次循环

2.如果为被使用,查看当前tmp指向的缓冲区头的修改和锁定标志

// 下面宏定义用于同时判断缓冲区的修改标志和锁定标志,并且定义修改标志的权重要比锁定标志大
#define BADNESS(bh) (((bh)->b_dirt<<1)+(bh)->b_lock)

要获取空闲缓冲区那么肯定是没有被修改的,以及没有被锁定的,这样的话b_dirtb_lock都是为0,那么BADNESS肯定为0,直接退出循环表示找到

如果没有就会对于BADNESS设置的权限进行比较,bh始终指向更小的一项

    if (!bh) {
        // 如果所有缓冲区都正被使用(所有缓冲区的头部引用计数都>0),则睡眠,等待有空闲的缓冲区可用
        //static struct task_struct * buffer_wait = NULL;
        sleep_on(&buffer_wait);
        goto repeat; 
    }

如果一次循环遍历没有找到空闲的缓冲区,那么就会在buffer_wait这个等待队列里面等待,直到有空闲的缓冲区,重复上述流程

等待缓冲区解锁

在从空闲链表中拿到空闲缓冲区块之后,先要看去查看这块区域是否有被锁定(正在进行磁盘写入操作),使用wait_on_buffer等待解锁,如果在睡眠过程中被其他进程抢先使用了这块内存,那么就重新流程找一块新的

    // 等待该缓冲区解锁(如果已被上锁的话)
    wait_on_buffer(bh);
    if (bh->b_count)
        goto repeat;

脏数据写盘

如果从空闲链表中拿到了一个空闲块,并且并没有被上锁,则查看这块空闲块是否是脏数据(已经被写入了数据但是还没有落盘),如果是那么就调用sync_dev函数同步写入磁盘

    while (bh->b_dirt) {        // 如果该缓冲区已被修改,则将数据写盘,并再次等待缓冲区解锁
        sync_dev(bh->b_dev);
        wait_on_buffer(bh);
        if (bh->b_count)
            goto repeat;
    }

sync_dev

该函数是对指定设备进行高速缓冲数据与设备上的数据同步操作, 其中ll_rw_block是调用底层的磁盘写入函数

//// 对指定设备进行高速缓冲数据与设备上数据的同步操作
int sync_dev(int dev)
{
    int i;
    struct buffer_head * bh;

    bh = start_buffer;
    for (i=0 ; i<NR_BUFFERS ; i++,bh++) {
        if (bh->b_dev != dev)
            continue;
        wait_on_buffer(bh);
        if (bh->b_dev == dev && bh->b_dirt)
            ll_rw_block(WRITE,bh);
    }
    sync_inodes();
    bh = start_buffer;
    for (i=0 ; i<NR_BUFFERS ; i++,bh++) {
        if (bh->b_dev != dev)
            continue;
        wait_on_buffer(bh);
        if (bh->b_dev == dev && bh->b_dirt)
            ll_rw_block(WRITE,bh);
    }
    return 0;
}

可以看到这里进行了两次数据落盘操作中间间隔的是一个同步刷新inode节点的操作

  • 第一次是为了把缓存数据全部写入磁盘,所有普通文件内容(数据块)的脏缓冲区都被提交了写请求。调用 ll_rw_block 后,这些缓冲区的 b_dirt 会被清零,b_lock 会被加锁,等待磁盘控制器慢慢去写。
  • sync_inodes() 遍历内存中的 inode_table(索引节点表),把所有被修改过的 inode(比如文件大小、修改时间变了)写回到它们对应的磁盘逻辑块中。在执行 sync_inodes() 之前,这些存放 inode 的磁盘块可能还是干净的;但执行完之后,这些元数据块变成了“脏”缓冲区。第一遍循环已经结束了,错过了它们。
  • 第二次写入磁盘操作是为了把刚刚inode同步产生的脏数据写入磁盘。确保文件的**元数据(inode)**也落盘。

不能把sync_inodes()放在第一遍的前面因为那样的话可能原本磁盘的有效数据就被inode节点刷新的数据覆盖调了

  • 如果先刷 inode,再刷数据块,万一在刷数据块时系统崩溃了,磁盘上的 inode 记录了“我有一个很大的文件”,但数据块还是旧的,文件系统就损坏了(数据不一致)。所以必须先确保数据真正写入磁盘后,再更新 inode 指针(这就是日志文件系统的雏形理念)

等待脏数据落盘解锁

在发起落盘请求之后,会进入等待b_lock解锁,因为磁盘写入是会上锁的

当然如果这时候又被其他进程抢险占用了,就需要再次重复执行流程

检查哈希表中是否已经挂载对应哈希值

 // 在高速缓冲 hash 表中检查指定设备和块的缓冲区是否已经被加入进去。如果是的话,就再次重复 
 // 上述过程。
    if (find_buffer(dev,block))
        goto repeat;

在空闲表中拿到了一个空闲块,并且没有被使用,没有被上锁,没有脏数据之后,还需要检查(dev,block)组合是否已经在哈希表中

这里肯定有一个疑惑点:不是最开始已经去get_hash_table在哈希表中寻找了并没有找到对应的块吗?为什么还要判断这个从空闲链表中拿到的块是否在哈希表中

这是因为有可能在当前进程把(dev,block)(对应一个bh1)挂进哈希表前,已经有进程把(dev,block)(对应一个bh2)挂进去了!!

流程如下:

假设当前进程是 A,它想要 (dev=3, block=100),但哈希表里没有(缓存未命中)。

步骤进程 A 的动作系统状态 / 其他进程的动作
T1调用 get_hash_table,在哈希表中没找到 (3,100)哈希表中确实没有。
T2扫描 free_list,选中了一个完美的空闲块 bhb_count=0, b_lock=0, b_dirt=0)。准备把它拿来用。这个 bh 目前还在空闲链表里,且尚未被挂到新的哈希链上。
T3关键点:调用 wait_on_buffer(bh)(虽然锁是0,这个函数会快速返回,但为了严谨,这里也可能因为锁而睡眠)。或者,在接下来的 while (bh->b_dirt) 中,因为 b_dirt=0 也被跳过。假设此时发生了进程调度(时间片用完),进程 A 睡眠了!
T4(睡眠中)进程 B 开始运行。进程 B 也需要 同一个磁盘块 (3, 100)!进程 B 调用 get_hash_table,依然没找到(因为 A 还没挂进去)。于是 B 也去扫描空闲链表。
T5(睡眠中)进程 B 拿到了另一个空闲块 bh2,或者更巧,它可能拿到了同一个 bh(取决于空闲链表指针和调度时序)。但无论如何,B 成功完成了 remove_from_queues -> 设置 b_dev=3, b_blocknr=100 -> insert_into_queues此时,哈希表中已经有了 (3,100) 的条目! 然后 B 返回使用这个块。
T6进程 A 被唤醒,继续执行 getblk 中 T3 之后的代码。A 手里还拿着它之前在 T2 选中的 bh(可能现在 b_count 已经变成1了,或者 b_dev 变了)。

此时,如果没有最后的 if (find_buffer(dev,block)) goto repeat;,进程 A 会霸王硬上弓,把自己手里的旧 bh 强行覆盖成 (3,100),并且插入哈希表。灾难发生了——内存中出现了两个同时缓存 (3,100) 的缓冲头! 文件系统数据将彻底错乱。

它保证了 “一个磁盘块在内存中绝对只有一个缓冲头” 的铁律。因为在多进程并发环境下,“检查哈希表”“把新块挂入哈希表” 这两个动作之间隔着巨大的时间鸿沟(可能包含多次睡眠),必须用这最后一道防线来兜底

更新块状态

在完成了上面众多操作之后,确保该块还没被使用,没有被上锁,并且是干净的没有被修改过,那么就占用当前缓冲区,引用计数+1,更新状态信息

/* OK,最终我们知道该缓冲区是指定参数的唯一一块,*/ 
 /* 而且还没有被使用(b_count=0),未被上锁(b_lock=0),并且是干净的(未被修改的)*/ 
 // 于是让我们占用此缓冲区。置引用计数为 1,复位修改标志和有效(更新)标志。
    bh->b_count=1;
    bh->b_dirt=0;
    bh->b_uptodate=0;

在两个链表中移除该缓冲区头

    // 从 hash 队列和空闲块链表中移出该缓冲区头,让该缓冲区用于指定设备和其上的指定块。
    remove_from_queues(bh);
    bh->b_dev=dev;
    bh->b_blocknr=block;

这里移除空闲链表好理解,为什么要从哈希表中移除呢,是因为这个从空闲链表中拿去的块可能已经挂在了某一个哈希散列项中,就如同上面所说的同时存在两条链表的情况

remove_from_queues

//// 从 hash 队列和空闲缓冲队列中移走指定的缓冲块
static inline void remove_from_queues(struct buffer_head * bh)
{
/* remove from hash-queue */
    if (bh->b_next)
        bh->b_next->b_prev = bh->b_prev;
    if (bh->b_prev)
        bh->b_prev->b_next = bh->b_next;
    if (hash(bh->b_dev,bh->b_blocknr) == bh)
        hash(bh->b_dev,bh->b_blocknr) = bh->b_next;
/* remove from free list */
    if (!(bh->b_prev_free) || !(bh->b_next_free))
        panic("Free block list corrupted");
    bh->b_prev_free->b_next_free = bh->b_next_free;
    bh->b_next_free->b_prev_free = bh->b_prev_free;
    if (free_list == bh)
        free_list = bh->b_next_free;
}

在这里的

    if (hash(bh->b_dev,bh->b_blocknr) == bh)
        hash(bh->b_dev,bh->b_blocknr) = bh->b_next;

是如果当前块刚好是哈希散列项的第一个,那么就让他指向下一个,和下面的如果是空闲链表第一个一样

在两个链表中新增空闲缓冲区头

最后就是将得到的空闲缓冲区头重新插入两个链表中

    // 然后根据此新的设备号和块号重新插入空闲链表和 hash 队列新位置处。并最终返回缓冲头指针
    insert_into_queues(bh);
    return bh;

insert_into_queues

//// 将指定缓冲区插入空闲链表尾并放入 hash 队列中
static inline void insert_into_queues(struct buffer_head * bh)
{
/* put at end of free list */
    bh->b_next_free = free_list;
    bh->b_prev_free = free_list->b_prev_free;
    free_list->b_prev_free->b_next_free = bh;
    free_list->b_prev_free = bh;
/* put the buffer in new hash-queue if it has a device */
    bh->b_prev = NULL;
    bh->b_next = NULL;
    if (!bh->b_dev)
        return;
    bh->b_next = hash(bh->b_dev,bh->b_blocknr);
    hash(bh->b_dev,bh->b_blocknr) = bh;
    bh->b_next->b_prev = bh;
}

这里的两个链表的操作都是循环链表的操作,并且把空闲缓冲区块头插入链表末尾

释放指定缓冲区brelse

//// 释放指定的缓冲区。 
 // 等待该缓冲区解锁。引用计数递减 1。唤醒等待空闲缓冲区的进程
void brelse(struct buffer_head * buf) // 缓冲区占用释放,但是内部数据还在
{
    if (!buf)
        return;
    wait_on_buffer(buf);
    if (!(buf->b_count--))
        panic("Trying to free free buffer");
    wake_up(&buffer_wait);
}

在这里笔者初看时有一个问题:引用计数只是-1,要是有多个进程等着用这块缓冲区,不是只是唤醒了一个吗?

答案是:brelse 确实只把 b_count 减 1,然后调用 wake_up(&buffer_wait)。但是,被唤醒的进程并不代表“这块缓冲区是你的”,而是代表“有机会去重新检查一遍谁有空闲块”

对于等待者可以分为两类:

getblk 函数中,存在两个完全不同的睡眠点:

  • 等待特定缓冲块解锁(bh->b_wait:当进程发现选中的缓冲块正在被磁盘 I/O 占用(b_lock=1)时,会调用 sleep_on(&bh->b_wait)。这些进程只在乎这一块缓冲区的锁
  • 等待任何空闲缓冲块(buffer_wait:当进程扫描整个 free_list,发现所有缓冲块的 b_count 都大于 0(内存池满了,大家都在用)时,会调用 sleep_on(&buffer_wait)。这些进程不在乎具体哪一块,只要有块空出来就行

brelse 末尾的 wake_up(&buffer_wait),唤醒的是那些因为“找不到空闲块”而挂起等待的进程

关键来了wake_up 只是把进程从睡眠队列中拽出来,变成“可运行”状态,并不会直接把那块缓冲区分配给被唤醒的进程。所以,wake_up 给了所有等待者一次“检查机会”,但只有那块真正变成 b_count == 0 的缓冲区,才会被进程选中并拿走

释放制定缓冲区的底层(块设备的通用操作中)

上面的brelse真正表示释放指定缓冲区的操作的是wait_on_buffer(buf);,它会等待块设备通用接口完成对磁盘的读写操作然后将b_lock置为0

用户进程调用 bread()
    ↓
getblk() 返回缓冲区 (b_count++)
    ↓
ll_rw_block(READ, bh)  // 发起读请求,b_lock=1,进入睡眠等待数据
    ↓
[磁盘中断发生]
    ↓
中断处理程序: b_lock=0; wake_up(&bh->b_wait)  // 唤醒等待该特定缓冲区的进程
    ↓
bread() 中的 wait_on_buffer(bh) 返回,数据已就绪
    ↓
用户进程使用完数据后调用 brelse(bh)
    ↓
brelse() 内部:
    - wait_on_buffer(bh)  // 此时 b_lock 已经是 0,快速通过
    - b_count-- (比如从 1 变 0)
    - wake_up(&buffer_wait)  // 如果有进程在等空闲块,唤醒它们

从指定设备上读取指定块bread

/*
 * bread() reads a specified block and returns the buffer that contains
 * it. It returns NULL if the block was unreadable.
 */
//// 从指定设备上读取指定的数据块, 返回缓冲头指针
struct buffer_head * bread(int dev,int block)
{
    struct buffer_head * bh;

    if (!(bh=getblk(dev,block)))                  // 在高速缓冲中申请一块缓冲区
        panic("bread: getblk returned NULL\n");
    if (bh->b_uptodate)                           // 如果该缓冲区中的数据是有效的(已更新的)可以直接使用
        return bh;
    ll_rw_block(READ,bh);             // 否则调用 ll_rw_block()函数,产生读设备块请求。并等待缓冲区解锁 === key ===
    wait_on_buffer(bh);
    if (bh->b_uptodate)               // 如果该缓冲区已更新,则返回缓冲区头指针,退出
        return bh;
    brelse(bh);                       // 否则表明读设备操作失败,释放该缓冲区,返回 NULL 指针,退出
    return NULL;
}

这里首先通过getblk从高速缓冲区拿到指定的块:

1.如果拿到的块b_uptodate为1刚好是有效的,那么就直接返回。

2.如果块数据不是想要的是无效的,那么就触发读写块设备操作,调用ll_rw_block然后等待这块数据块读写完成,再去判断b_uptodate是否有效

3.如果有效则返回,如果还是无效说明读写设备操作失败,返回NULL表示失败

注意

  1. 这里bread拿到的高速缓冲区的块头就是想要的数据块头直接通过**bh->b_data**就可以访问数据了(笔者最开始理解为是从高速缓冲区拿到一个空闲块然后给外部,外部通过操作把磁盘数据放到这个块里面)
    • b_dirt = 1(脏) 代表“内存里的数据比磁盘新(刚被修改过,还没来得及写回)”。
    • b_uptodate = 1(有效) 代表“这块内存里确实装着该磁盘块的有效数据”。

如果哈希表命中(b_uptodate 必然为 1),即使它是脏的,内存里的数据也是最新的、最正确的。 直接读取 bh->b_data 就能拿到最新数据,绝对不能去读磁盘,否则会把内存里刚修改的新数据覆盖成磁盘上的旧数据,导致数据丢失。————–而且当 getblk 在空闲链表中选中一个 “脏的旧块” 时,它会在 while (bh->b_dirt) 循环中把旧数据刷回磁盘,然后清掉脏标志。等到 bread 看到这个块时,它已经是一块“干净但无效(b_uptodate=0)”的空内存了,所以 bread 才会重新从磁盘读取目标块的数据填充进去。

整体流程图

bread流程图png

高速缓冲区初始化函数

完成的就是空闲缓冲区的双向循环链表的创建和哈希表的创建

设置高速缓冲区内存大小

首先是设置高速缓冲区块的总共大小

// 缓冲区初始化
// 参数 buffer_end 是指定的缓冲区内存的末端。对于系统有 16MB 内存,则缓冲区末端设置为 4MB。 
// 对于系统有 8MB 内存,缓冲区末端设置为 2MB。
void buffer_init(long buffer_end)
{
    struct buffer_head * h = start_buffer;
    void * b;
    int i;

// 如果缓冲区高端等于 1Mb,则由于从 640KB-1MB 被显示内存和 BIOS 占用,因此实际可用缓冲区内存 
// 高端应该是 640KB。否则内存高端一定大于 1MB
    if (buffer_end == 1<<20)      //1M
        b = (void *) (640*1024);   // = A0000,  640k
    else
        b = (void *) buffer_end;    // 这里采用2M为例

参数 buffer_end 是指定的缓冲区内存的末端,这里如果buffer_end大于1MB,那么缓冲区块的总大小是640KB,剩余区域存放的是BIOS和显存(360KB,位于内存640KB到1MB位置),BIOS存放的数据就是系统启动的时候uboot传递给内核的tagglist参数

内存图png

在main函数中的调用

    memory_end = (1<<20) + (EXT_MEM_K<<10);        // 内存大小=1Mb 字节+扩展内存(k)*1024 字节
    memory_end &= 0xfffff000;                      // 忽略不到 4Kb(1 页)的内存数,内存对齐4k
    // 假设物理内存为16M,进入第二个分支
    // 主内存开始地址和缓冲区结束地址相同,均为4M位置
    if (memory_end > 16*1024*1024)
        memory_end = 16*1024*1024;    // 如果内存超过 16Mb,则按 16Mb 计
    if (memory_end > 12*1024*1024) 
        buffer_memory_end = 4*1024*1024; //设置高速缓冲区大小
    else if (memory_end > 6*1024*1024)
        buffer_memory_end = 2*1024*1024;
    else
        buffer_memory_end = 1*1024*1024;
    main_memory_start = buffer_memory_end;

        // 缓冲区初始化
    buffer_init(buffer_memory_end);

可以看到在main函数这里根据内存的大小设置了高速缓冲区的大小,然后作为参数传递进入了buffer_init

循环初始化缓冲区头和链表

    while ( (b -= BLOCK_SIZE) >= ((void *) (h+1)) ) {    //BLOCK_SIZE = 1024
        h->b_dev = 0;
        h->b_dirt = 0;
        h->b_count = 0;
        h->b_lock = 0;
        h->b_uptodate = 0;
        h->b_wait = NULL;
        h->b_next = NULL;
        h->b_prev = NULL;
        h->b_data = (char *) b;         // 指向对应缓冲区数据块(1024 字节)
        h->b_prev_free = h-1;
        h->b_next_free = h+1;
        h++;
        NR_BUFFERS++;                   // 缓冲区块数累加, 初始为0
        if (b == (void *) 0x100000)     // 如果地址 b 递减到等于 1MB,则跳过 384KB, 让 b 指向地址 0xA0000(640KB)处,因为640k-1M碱有现存和BIOS信息
            b = (void *) 0xA0000;
    }
    h--;                                // 让 h 指向最后一个有效缓冲头
    free_list = start_buffer;           // 让空闲链表头指向头一个缓冲区头 
    free_list->b_prev_free = h;
    h->b_next_free = free_list;         // 形成一个环链
    for (i=0;i<NR_HASH;i++)             // 初始化 hash 表(哈希表、散列表),置表中所有的指针为 NULL
        hash_table[i]=NULL;

这段代码使用一个while循环来初始化高速缓冲区头结构,一个非常巧妙的写法:

while ( (b -= BLOCK_SIZE) >= ((void *) (h+1)) )

低地址 (0x0)
    |
    |   [ 内核代码段 / 数据段 ]    <- 从 0 开始,到 &end 结束
    |
    ├─────────────────────────────┐  <--- start_buffer (h 指向这里,即 &end)
    | 缓冲区头 0 (32字节)          |  <- h (低地址指针,向上增长)
    | 缓冲区头 1 (32字节)          |
    | 缓冲区头 2 (32字节)          |
    | ...                         |
    | (空闲间隙,用来防止重叠)      |  <- 临界点:当头指针追上数据指针时停止
    | ...                         |
    | 数据块 N (1024 字节)         |  <- b (高地址指针,向下递减)
    | 数据块 2 (1024 字节)         |
    | 数据块 1 (1024 字节)         |  <- b_data 指向这里
    └─────────────────────────────┘  <--- buffer_end (2MB)
高地址 (0x200000)

因为在高速缓冲区的内存结构中,头结构在低区,数据块在高区

  • b(高地址指针):每循环一次,先向下移动 1KB(b -= BLOCK_SIZE),指向当前要分配给缓冲区的数据区
  • h(低地址指针):指向当前正在初始化的缓冲区头h+1 是下一个缓冲区头的起始地址。
  • 临界条件:循环只有在 b(数据区起始地址)大于或等于 h+1(下一个头结构的结束地址)时才继续。

如果 b 小于 h+1,意味着高地址的数据区已经和低地址的头结构区重叠了,内存已被耗尽,无法再容纳一个新的完整的 (头 + 数据块) 组合,循环终止。

h--; // 因为循环体内最后一次 h++ 已经越界了,减回来指向最后一个有效头
free_list = start_buffer;
free_list->b_prev_free = h;
h->b_next_free = free_list; // 构成环形链表

此时,h 指向最后一块有效缓冲区头,b 指向它对应的数据区起始地址。它们之间可能还有一点零散的内存空隙,不足以组成下一个 1KB 数据块 + 头结构,因此被直接废弃不用。

空闲链表头free_list指向高速缓冲区头开始,并让它的上一个b_prev_free指向当前已经是最后一个头结构的h,并且hb_next_free指向头构建循环链表

上一篇
下一篇