第9章 块设备

对于驱动工程师来说,块设备和字符设备是开发过程中经常用到的概念。字符设备通过函数 init_special_inode 为字符设备设置函数指针。对于块设备而言,这部分的架构是相同的,也是通过 init_special_inode 为块设备设置函数指针。不同之处是,赋予字符设备的函数指针结构是 def_chr_fops,而赋予块设备的函数指针结构是 def_blk_fops

这种类似的架构减小了学习的理解难度,能从已知的知识点推广到未知的知识点,可以提升学习的信心。

9.1 块设备的架构

和字符设备比较,块设备有很多不同的地方。实际上,块设备常常和磁盘关联在一起,它的使用和管理比字符设备要复杂。本章的分析忽略块设备和字符设备相同的地方,重点介绍块设备的独特之处。首先从块设备的结构定义着手。

9.1.1 块设备、磁盘对象和队列

块设备一般总和通用磁盘对象 gendisk 捆绑在一起。块设备的结构定义如下所示,其中省略了当前不关心的内容:

struct block_device {
  /*......省略部分代码*/
  struct gendisk *        bd_disk;
}

通用磁盘对象是在计算机启动时扫描磁盘或者磁盘插入计算机槽位时,内核为物理磁盘创建的数据结构(光盘、磁带设备也用通用磁盘对象表示)。

通用磁盘对象创建后,一般要在根目录的 dev 目录下面,创建一个设备文件,这个设备文件被指明为块设备,具有自己的磁盘名。所以通用磁盘对象创建在前,等用户打开块设备时,会绑定块设备到相关的通用磁盘对象。

通用磁盘对象的结构定义如下:

struct gendisk {
  int major;                /* major number of driver */
  int first_minor;
  int minors; /* maximum number of minors, =1 for disks that c */
  char disk_name[32];        /* name of major driver */
  struct block_device_operations *fops;
  struct request_queue *queue;
  /*......省略部分代码*/
}

结构定义中省略了一些无关的成员,保留重要的成员。结构成员首先是主从设备号,然后是磁盘的名字。区别块设备和字符设备的最重要成员就是队列 queue,所有对通用磁盘对象的 I/O 操作都要进入这个队列 queue 排队,然后再由内核处理。这里块设备队列是个笼统的说法,其实块设备使用的队列既包括块设备自身的队列,也包括块设备隐含的电梯对象的队列。在具体的使用中,可以看到这两种队列的不同之处。

9.1.2 块设备和通用磁盘对象的绑定

通用磁盘对象需要把自身注册到系统的管理链表中。这是通过 blk_register_region 函数来实现的,如代码清单 9-1 所示。

代码清单 9-1 blk_register_region

void blk_register_region(dev_t dev, unsigned long range, struct module *module,
           struct kobject *(*probe)(dev_t, int *, void *),
           int (*lock)(dev_t, void *), void *data){
  kobj_map(bdev_map, dev, range, module, probe, lock, data);
}

kobj_map 是一个熟悉的函数,在第 5 章已经分析过,作用是把设备号注册到系统的管理链表。

通过设备号获得通用磁盘对象时,需要调用 get_gendisk 函数,如代码清单 9-2 所示。

代码清单 9-2 get_gendisk

struct gendisk *get_gendisk(dev_t dev, int *part){
  struct kobject *kobj = kobj_lookup(bdev_map, dev, part);
  return  kobj ? to_disk(kobj) : NULL;
}

kobj_lookup 也是一个熟悉的函数,作用是根据设备号搜索 kobj 结构,然后通过 container 方法获得通用磁盘对象结构指针。

再次回顾 init_special_inode 函数对块设备的处理代码:

} else if (S_ISBLK(mode)) {
       inode->i_fop = &def_blk_fops;
       inode->i_rdev = rdev;
}

块设备的特殊 inode 的操作函数结构被设置为 def_blk_fops,而 inode 自身也保存了设备号,共同的设备号把块设备和通用磁盘对象关联了起来。

9.1.3 块设备的队列和队列处理函数

通用磁盘对象包含一个队列 queue,块设备的队列其实是借用这个队列。队列的结构定义如代码清单 9-3 所示。

代码清单 9-3 request_queue

struct request_queue
{
  /*
   * Together with queue_head for cacheline sharing
   */
  struct list_head        queue_head;
  
  elevator_t              *elevator;
  request_fn_proc         *request_fn;
  merge_request_fn        *back_merge_fn;
  merge_request_fn        *front_merge_fn;
  merge_requests_fn       *merge_requests_fn;
  make_request_fn         *make_request_fn;
  prep_rq_fn              *prep_rq_fn;
  unplug_fn               *unplug_fn;
  merge_bvec_fn           *merge_bvec_fn;
  activity_fn             *activity_fn;
  issue_flush_fn          *issue_flush_fn;
  prepare_flush_fn        *prepare_flush_fn;
  softirq_done_fn         *softirq_done_fn;
}

这个结构中最重要的有两点,一是结构中封装了一个 elevator_t 指针,二是队列中包含了众多的队列处理函数指针。

块设备一般具有连续读写快、随机读写慢的特征(硬盘这种机械装置的物理特征)。所有在块设备队列中排队的读写请求,需要经过 elevator_t 进行次序调整,然后才真正由块设备执行读写。elevator_t 结构提供了一种框架,这个框架提供不同的调度算法,后文将分析块设备的调度算法。

在内核的 I/O 处理流程中,需要调用队列中的处理函数。比如 make_request_fn 用来将 I/O 请求插入到队列,而 request_fn_proc 则用来从队列中获得一个 I/O 请求。当完成 I/O 时,调用软中断处理函数 softirq_done_fn 处理。入队列的次序和出队列的次序可能不同,这是 I/O 调度算法提供的功能。

队列与调度

块设备队列的 elevator_t 提供了 I/O 调度算法框架,可以对排队请求进行重排序,以优化机械硬盘的读写性能。队列中包含多个函数指针,用于处理请求的插入、合并、派发以及完成等环节。

9.2 块设备创建的过程分析

从内核中,选择位于目录 drivers\blocknbd 驱动作为一个简单的块设备例子,根据代码前部的说明,这个驱动是为了在网络环境中使用块设备而提供的。

这个例子可以帮助我们快速理解块设备的整体架构,了解队列、通用磁盘对象和块设备这种架构的使用方式。

9.2.1 nbd 驱动的初始化

首先从 nbd 驱动的初始化函数开始分析,这个初始化函数是 nbd_init,如代码清单 9-4 所示。

代码清单 9-4 nbd_init(nbd.c)

static int __init nbd_init(void)
{
  int err = -ENOMEM;
  int i;
  BUILD_BUG_ON(sizeof(struct nbd_request) != 28);
  /*判断nbd设备数目不能超过最大许可数目*/
  if (nbds_max > MAX_NBD) {
       printk(KERN_CRIT "nbd: cannot allocate more than %u nbd devices, "
               "%u requested.\n", MAX_NBD, nbds_max);
       return -EINVAL;
  }
  for (i = 0; i < nbds_max; i++) {
       struct gendisk *disk = alloc_disk(1);
       if (!disk)
            goto out;
       nbd_dev[i].disk = disk;
                
     /*注册nbd自身的request_fn函数指针*/
       disk->queue = blk_init_queue(do_nbd_request, &nbd_lock);
       if (!disk->queue) {
            put_disk(disk);
            goto out;
       }
  }
  /*注册块设备*/
  if (register_blkdev(NBD_MAJOR, "nbd")) {
       err = -EIO;
       goto out;
  }

nbd_init 函数第一部分首先申请足够的通用磁盘对象,然后为每个通用磁盘对象注册它的处理函数,这是通过 blk_init_queue 函数实现的。

nbd_init 函数第二部分设置通用磁盘对象的参数。

for (i = 0; i < nbds_max; i++) {
     struct gendisk *disk = nbd_dev[i].disk;
     nbd_dev[i].file = NULL;
     nbd_dev[i].magic = LO_MAGIC;
     nbd_dev[i].flags = 0;
     spin_lock_init(&nbd_dev[i].queue_lock);
     INIT_LIST_HEAD(&nbd_dev[i].queue_head);
     mutex_init(&nbd_dev[i].tx_lock);
     init_waitqueue_head(&nbd_dev[i].active_wq);
     nbd_dev[i].blksize = 1024;
     nbd_dev[i].bytesize = 0x7ffffc00ULL << 10; /* 2TB */
     /*设置通用磁盘对象的主从设备号*/
     disk->major = NBD_MAJOR;
     disk->first_minor = i;
     /*设置nbd设备的I/O control函数*/ 
     disk->fops = &nbd_fops;
     disk->private_data = &nbd_dev[i];
     disk->flags |= GENHD_FL_SUPPRESS_PARTITION_INFO;
     sprintf(disk->disk_name, "nbd%d", i);
     set_capacity(disk, 0x7ffffc00ULL << 1); /* 2 TB */
     add_disk(disk);
  }
  return 0;

块设备的读写不是通过独立的读写函数实现的,而是通过队列处理函数来完成的。通用磁盘对象的操作函数结构体 nbd_fops 并不需要设置读写函数,所以只提供了 I/O control 函数。

设置通用磁盘对象的名字和设备容量之后,调用 add_disk 把磁盘对象注册到系统。

9.2.2 为通用磁盘对象创建队列成员

nbd_init 调用了 blk_init_queue 函数,目的是为通用磁盘对象创建 queue 成员,并设置队列的处理函数,如代码清单 9-5 所示。

代码清单 9-5 blk_init_queue(ll_rw_blk.c)

request_queue_t *blk_init_queue(request_fn_proc *rfn, spinlock_t *lock)
{
  return blk_init_queue_node(rfn, lock, -1);
}
 
request_queue_t *
blk_init_queue_node(request_fn_proc *rfn, spinlock_t *lock, int node_id)
{     
  /*创建一个队列结构*/
  request_queue_t *q = blk_alloc_queue_node(GFP_KERNEL, node_id);
  /*......省略部分代码*/
  /*注册request_fn函数*/
  q->request_fn                = rfn;
  q->back_merge_fn               = ll_back_merge_fn;
  q->front_merge_fn              = ll_front_merge_fn;
  q->merge_requests_fn        = ll_merge_requests_fn;
  q->prep_rq_fn                = NULL;
  q->unplug_fn                = generic_unplug_device;
  q->queue_flags                = (1 << QUEUE_FLAG_CLUSTER);
  q->queue_lock                = lock;
  blk_queue_segment_boundary(q, 0xffffffff);
  /*设置块设备入队列函数*/
  blk_queue_make_request(q, __make_request);
  /*设置最大段的尺寸*/
  blk_queue_max_segment_size(q, MAX_SEGMENT_SIZE);
  /*硬件能处理的最大段数目*/ 
  blk_queue_max_hw_segments(q, MAX_HW_SEGMENTS);
  blk_queue_max_phys_segments(q, MAX_PHYS_SEGMENTS);
  /*
   * all done
   */
   /*申请一个elevator结构*/
  if (!elevator_init(q, NULL)) {
       blk_queue_congestion_threshold(q);
       return q;
  }
  blk_put_queue(q);
  return NULL;
}

blk_init_queue 函数设置了队列的入队列函数 make_request_fn 和出队列函数 request_fn,还有众多 I/O 请求合并函数以及队列闭塞函数等。为队列设置的众多参数中很多都和具体物理设备有关,这些参数的应用将在内核的 I/O 处理流程中体现。最后调用 elevator_init 初始化一个 elevator 对象,并设置默认的 I/O 调度算法。

9.2.3 将通用磁盘对象加入系统

现在返回 nbd_init 函数,分析 add_disk 函数如何把通用磁盘对象加入系统,如代码清单 9-6 所示。

代码清单 9-6 add_disk(genhd.c)

void add_disk(struct gendisk *disk)
{
  disk->flags |= GENHD_FL_UP;
  blk_register_region(MKDEV(disk->major, disk->first_minor),
       disk->minors, NULL, exact_match, exact_lock, disk);
  register_disk(disk);
  blk_register_queue(disk);
}

这个函数由三个子函数组成。blk_register_region 调用的就是 kobj_map,把通用磁盘对象的设备号加入系统的块设备链表。后续查找通用磁盘对象通过函数 kobj_lookup 执行。register_disk 创建了一个块设备对象,并完成块设备和通用磁盘对象的绑定。这个过程在块设备打开时候还会执行一次,这在后文分析。

9.3 块设备文件系统

Linux 通过块设备文件系统来管理块设备,这听起来似乎不可理解,但思索 Linux 文件系统的架构,文件系统可以看做一个对象,对象中包含超级块、inode 和文件等结构,以及它们的处理函数。而块设备是文件系统的一个文件(通过 mknod 命令生成),和字符设备 input 的架构类似,同样可以把块设备作为一个框架,注册不同的处理函数。

用面向对象的思想比较容易理解内核的这种设计思路。块设备文件系统是一种对象,这种对象提供的方法和数据可以被另外的文件系统对象使用。通读内核代码,面向对象的设计思路使用的非常普遍,I/O 调度算法和电梯 elevator 结构都可以看做封装的对象。

9.3.1 块设备文件系统的初始化

对块设备文件系统的分析需要从初始化函数 bdev_cache_init 开始,如代码清单 9-7 所示。

代码清单 9-7 bdev_cache_init

void __init bdev_cache_init(void)
{
  int err;
  bdev_cachep = kmem_cache_create("bdev_cache", sizeof(struct bdev_inode),
            0, (SLAB_HWCACHE_ALIGN|SLAB_RECLAIM_ACCOUNT|
            SLAB_MEM_SPREAD|SLAB_PANIC),
            init_once, NULL);
  err = register_filesystem(&bd_type);
  if (err)
      panic("Cannot register bdev pseudo-fs");
      bd_mnt = kern_mount(&bd_type);
      err = PTR_ERR(bd_mnt);
      if (IS_ERR(bd_mnt))
          panic("Cannot create bdev pseudo-fs");
  blockdev_superblock = bd_mnt->mnt_sb;        /* For writeback */
}

这个初始化函数首先把块设备文件系统注册到内核,然后调用 kern_mount 创建文件系统必要的数据结构。第 2 章已经分析过类似的文件系统初始化代码,此处不赘述。

9.3.2 块设备文件系统的设计思路

根据掌握的文件系统知识,我们从两个方面理解文件系统。

  • 第一个方面是文件系统超级块对象提供的操作函数。
  • 第二个方面是文件系统为 inode 结构和文件对象提供的操作函数。

首先分析块设备文件系统超级块提供的操作函数 bdev_sops,如下所示:

static struct super_operations bdev_sops = {
  .statfs = simple_statfs,
  .alloc_inode = bdev_alloc_inode,
  .destroy_inode = bdev_destroy_inode,
  .drop_inode = generic_delete_inode,
  .clear_inode = bdev_clear_inode,
};

这个结构里面,只有 bdev_alloc_inode 函数和 bdev_destroy_inode 函数是块设备文件系统提供的独特函数,其他都是借用系统的通用处理函数。函数 bdev_destroy_inode 非常简单,读者自行分析即可。

通过分析 bdev_alloc_inode 函数即可了解块设备文件系统的主要设计思路,如代码清单 9-8 所示。

代码清单 9-8 bdev_alloc_inode(block_dev.c)

static struct inode *bdev_alloc_inode(struct super_block *sb)
{
  struct bdev_inode *ei = kmem_cache_alloc(bdev_cachep, SLAB_KERNEL);
  if (!ei)
      return NULL;
  return &ei->vfs_inode;
}

块设备文件系统提供了一个独特的结构 bdev_inode。这个结构封装了一个块设备结构和一个 inode 结构。inode 结构适应文件系统统一的接口需要,而块设备结构则提供了块设备需要的功能,这就是块设备文件系统架构设计的巧妙之处。

文件系统的第二个重要方面是 inode 结构和文件对象操作函数。块设备文件系统不需要创建目录和文件因此没有提供 inode 的操作函数。文件对象的操作函数中,读写函数使用了内核提供的通用读写函数(将在第 10 章分析),因此本节重点关注块设备的打开流程。

9.4 块设备的打开流程

通常打开块设备有两种方式,一种是直接打开块设备,也称为裸设备使用方式。通过这种方式打开块设备,实际上是调用了块设备文件系统提供的 blkdev_open 函数。如下面代码所示:

fopen(/dev/sda)

另外一种方式是通过文件系统间接使用块设备。块设备文件系统提供了 open_bdev_excl 函数。当文件系统建立在块设备之上时,需要调用这个函数来绑定块设备和文件系统,在文件系统的超级块结构中保存所在块设备的信息。

从内核代码的角度来看,这两种打开方式实际上类似。因此本节以裸设备使用方式为例进行分析。

打开一个块设备文件,最终是调用 init_special_inode 函数为块设备文件提供的默认处理函数封装结构 def_blk_fops,这个结构也就是块设备文件系统提供的文件操作函数结构。打开一个块设备,实际就调用了这个结构里面提供的 blkdev_open 函数,如代码清单 9-9 所示。

代码清单 9-9 blkdev_open(block_dev.c)

static int blkdev_open(struct inode * inode, struct file * filp)
{
  struct block_device *bdev;
  int res;
 
  filp->f_flags |= O_LARGEFILE;
 
  /*获得块设备对象*/
  bdev = bd_acquire(inode);
  res = do_open(bdev, filp, BD_MUTEX_NORMAL);
  if (res)
      return res;
 
  if (!(filp->f_flags & O_EXCL) )
      return 0;
 
 /*设置块设备和文件的参数*/
  if (!(res = bd_claim(bdev, filp)))
      return 0;
 
  blkdev_put(bdev);
  return res;
}

函数 blkdev_open 首先通过 bd_acquire 获得块设备对象,然后调用 do_open 执行块设备的打开过程。

第9-10章 块设备与文件系统读写

9.4.1 获取块设备对象

首先分析 bd_acquire 函数,它的作用是从块设备文件系统获得块设备对象。本节分两部分介绍 bd_acquire 函数。

1. 块设备对象已经存在

bd_acquire 函数第一部分,判断块设备对象是否存在。如果已经存在,只增加 bd_inode 的引用计数,然后返回,如代码清单9-10所示。

代码清单9-10 bd_acquire (block_dev.c)

static struct block_device *bd_acquire(struct inode *inode)
{
    struct block_device *bdev;
    spin_lock(&bdev_lock);
    bdev = inode->i_bdev;
    /*如果已经有块设备对象,则只增加引用计数,然后返回*/        
    if (bdev) {
        atomic_inc(&bdev->bd_inode->i_count);
        spin_unlock(&bdev_lock);
        return bdev;
    }

启动时扫描硬盘

操作系统启动时要扫描硬盘,对扫描到的硬盘要调用 add_disk 函数注册。注册的过程执行了块设备的打开过程,创建块设备文件系统的数据结构,如 inode 结构和块设备对象。因此当用户调用 fopen 打开块设备的时候,将直接获得已经打开的块设备对象。

2. 申请块设备对象

bd_acquire 函数第二部分,通过 bdget 函数申请块设备对象。如果成功,则 inodei_mapping 设置为块设备关联 inodei_mappingi_mapping 成员是块设备读写功能的一个关键成员,后文将继续分析这个成员。

    /*申请块设备对象*/
    bdev = bdget(inode->i_rdev);
    if (bdev) {
        spin_lock(&bdev_lock);
        if (!inode->i_bdev) {
            /*增加引用计数*/
            atomic_inc(&bdev->bd_inode->i_count);
            inode->i_bdev = bdev;
            inode->i_mapping = bdev->bd_inode->i_mapping;
            list_add(&inode->i_devices, &bdev->bd_inodes);
        }

bdget 函数可以分为两部分,如代码清单9-11所示。第一部分通过块设备文件系统分配一个 bdev_inode 对象,第二部分为这个对象设置一系列的参数,包括设备号、块设备指针等。

代码清单9-11 bdget (block_dev.c)

struct block_device *bdget(dev_t dev)
{
    struct block_device *bdev;
    struct inode *inode;
    /*创建一个bdev_inode结构*/
    inode = iget5_locked(bd_mnt->mnt_sb, hash(dev),
             bdev_test, bdev_set, &dev);
    if (!inode)
        return NULL;
    /*返回bdev_inode结构包含的块设备结构*/
    bdev = &BDEV_I(inode)->bdev;
    if (inode->i_state & I_NEW) {
        bdev->bd_contains = NULL;
        bdev->bd_inode = inode;
        bdev->bd_block_size = (1 << inode->i_blkbits);
        bdev->bd_part_count = 0;
        bdev->bd_invalidated = 0;
        inode->i_mode = S_IFBLK;
        inode->i_rdev = dev;
        inode->i_bdev = bdev;
        inode->i_data.a_ops = &def_blk_aops;
        mapping_set_gfp_mask(&inode->i_data, GFP_USER);
        /*backing_dev_info主要为文件的预读服务*/
        inode->i_data.backing_dev_info = &default_backing_dev_info;
        spin_lock(&bdev_lock);
        list_add(&bdev->bd_list, &all_bdevs);
        spin_unlock(&bdev_lock);
        unlock_new_inode(inode);
    }
    return bdev;
}

inode 结构成员 i_data 对象包含的处理函数设置为块设备文件系统提供的默认函数结构 def_blk_aops,这个结构包含的函数指针是对磁盘进行读写的底层处理函数,在后续章节中将继续分析。

分配 bdev_inode 对象通过 iget5_locked 实现,如代码清单9-12所示。

代码清单9-12 iget5_locked (inode.c)

struct inode *iget5_locked(struct super_block *sb, unsigned long hashval,
      int (*test)(struct inode *, void *),
      int (*set)(struct inode *, void *), void *data)
{
    struct hlist_head *head = inode_hashtable + hash(sb, hashval);
    struct inode *inode;
    /*搜索hash 链表,寻找是否已经存在一个inode*/
    inode = ifind(sb, head, test, data, 1);
    if (inode)
        return inode;
    return get_new_inode(sb, head, test, set, data);
}
 
static struct inode * get_new_inode(struct super_block *sb, 
    struct hlist_head *head, int (*test)(struct inode *, void *),
    int (*set)(struct inode *, void *), void *data)
{
    struct inode * inode;
    /*申请一个inode结构*/   
    inode = alloc_inode(sb);
    if (inode) {
        struct inode * old;
        spin_lock(&inode_lock);
        /* We released the lock, so.. */
        old = find_inode(sb, head, test, data);
        if (!old) {
            /* ... 省略部分代码*/
            return inode;
        }
        /*其他任务已经创建了inode*/ 
        __iget(old);
        spin_unlock(&inode_lock);
        destroy_inode(inode);
        inode = old;
        wait_on_inode(inode);
    }
    return inode;
}

函数 iget5_locked 首先搜索 inode 的 hash 链表,如果不能找到同设备号的 inode,则创建一个新 inode 结构。创建完毕,仍然要调用 find_inode 再搜索一遍,这是因为在创建 inode 的过程中,可能有其他任务抢先创建了,这种情况下,要释放刚创建的 inode

9.4.2 执行块设备的打开流程

现在返回 blkdev_open 函数,通过 bd_acquire 获得块设备对象后,do_open 执行块设备的打开流程。函数 do_open 分三部分介绍。

1. 获得和块设备绑定的通用磁盘对象

第一部分调用 get_gendisk 获得和块设备绑定的通用磁盘对象。

do_open(struct block_device *bdev, struct file *file, unsigned int flag)
{
    struct module *owner = NULL;
    struct gendisk *disk;
    int ret = -ENXIO;
    int part;
    /*为文件的f_mapping赋值.f_mapping主要为文件的读写过程服务*/
    file->f_mapping = bdev->bd_inode->i_mapping;
    lock_kernel();
    disk = get_gendisk(bdev->bd_dev, &part);
    if (!disk) {
        /*如果通用磁盘对象不存在,返回失败*/
        unlock_kernel();
        bdput(bdev);
        return ret;
    }
    owner = disk->fops->owner;

get_gendisk 函数 part 参数的目的是获得通用磁盘对象的分区信息。众所周知,硬盘可以存在多个分区,分区的主设备号相同,而次设备号不同。硬盘自身存在和它对应的块设备对象,每个分区也都有各自的块设备对象。part 为 0,说明使用的是硬盘自身的块设备对象,如果 part 不为 0,说明使用的是分区的块设备对象。

2. 块设备未被打开的处理

函数 do_open 第二部分是处理块设备未被打开,且块设备是硬盘设备自身的情况。

这种情况首先执行磁盘本身提供的 open 函数,然后设置块设备的容量信息以及后备设备信息(backing_dev_info),后备设备信息主要为设备的预读算法服务。最后如果需要扫描分区,应调用 rescan_partitions 扫描硬盘的所有分区。

    if (!bdev->bd_openers) { /*如果块设备未被打开*/
        bdev->bd_disk = disk;
        bdev->bd_contains = bdev;
        if (!part) {
            struct backing_dev_info *bdi;
            if (disk->fops->open) {
                ret = disk->fops->open(bdev->bd_inode, file);
                if (ret)
                    goto out_first;
            }
            if (!bdev->bd_openers) {
                bd_set_size(bdev, (loff_t)get_capacity(disk) << 9);
                bdi = blk_get_backing_dev_info(bdev);
                if (bdi == NULL)
                    bdi = &default_backing_dev_info;
                bdev->bd_inode->i_data.backing_dev_info = bdi;
            }
            if (bdev->bd_invalidated)
                rescan_partitions(disk, bdev);
        }

3. 块设备是硬盘分区的处理

函数 do_open 第三部分处理块设备是硬盘分区的情况。

这种情况首先要获得硬盘自身块设备对象。这一步通过调用输入参数为 0 的 bdget_disk 函数实现,参数为 0 说明要求的对象索引值为 0,正是整个硬盘对应的块设备对象。

    } else {
        struct hd_struct *p;
        struct block_device *whole;
        /*获得磁盘索引为0的块设备,也就是主盘的块设备对象 */
        whole = bdget_disk(disk, 0);
        ret = -ENOMEM;
        if (!whole)
            goto out_first;
        ret = blkdev_get_whole(whole, file->f_mode, file->f_flags);
        if (ret)
            goto out_first;
        /*设置块设备的容器bd_contains是整个盘,而不是自身*/
        bdev->bd_contains = whole;
        mutex_lock_nested(&whole->bd_mutex, BD_MUTEX_WHOLE);
        whole->bd_part_count++;
        p = disk->part[part - 1];
        bdev->bd_inode->i_data.backing_dev_info =
                 whole->bd_inode->i_data.backing_dev_info;
        if (!(disk->flags & GENHD_FL_UP) || !p || !p->nr_sects) {
            whole->bd_part_count--;
            mutex_unlock(&whole->bd_mutex);
            ret = -ENXIO;
            goto out_first;
        }
        kobject_get(&p->kobj);
        bdev->bd_part = p;
        bd_set_size(bdev, (loff_t) p->nr_sects << 9);
        mutex_unlock(&whole->bd_mutex);
    }

每个分区的信息(比如分区的起始扇区地址和容量)都保存在通用磁盘对象的 part 成员里面,因此块设备的容量要根据分区的信息来设置。

第三部分整个分支执行成功的话,硬盘自身的块设备对象的分区数目要加 1。

9.5 本章小结

块设备和字符设备有明显不同,主要体现在块设备的队列,以及块设备和通用磁盘对象的关系。这种设计是基于磁盘的物理特性,为使用块设备提供了方便,不需要程序员再做额外的工作。

内核有多种块设备使用了这种架构,比如 CD、磁带、MTD 设备等,建议读者以这些设备为例,分析它们的具体实现代码。


第10章 文件系统读写

Linux 系统内核为文件设置了一个缓存,对文件读写的数据内容都缓存在这里。这个缓存称为 page cache(页缓存)。

10.1 page cache 机制

page cache 是 Linux 操作系统的一个特色,其中存储的数据在 I/O 完成后并不回收,而是一直保留在内存中,除非内存紧张,才开始回收占用的内存。

10.1.1 buffer I/O 和 direct I/O

使用 page cache 的 I/O 操作称为 buffer I/O,默认情况下,内核都使用 buffer I/O;但有的应用不希望使用内核缓存,而是由应用提供内存,这种由应用提供内存的 I/O 称为 direct I/O,它的特点是不使用系统提供的 page cache。

Linux 应用编程接口提供了文件的读写接口,就是 readwrite 接口。readwrite 接口是同步 I/O 接口,调用这两个函数的进程会被阻塞,直到读写过程完成,才返回应用程序。和同步 I/O 接口对应的是异步 I/O 接口。异步 I/O 接口不会阻塞进程,而是立即返回。异步接口需要提供机制判断 I/O 是否完成。

Linux 系统的 buffer I/O 由于要填充 page cache,必须等读 I/O 完成才能返回,所以 buffer I/O 本身在内核中就会阻塞。所以 Linux 的异步 I/O 必须是 direct I/O,才能不阻塞进程立即返回。

10.1.2 buffer head 和块缓存

page cache 顾名思义,是以页面为单位组织的。Linux 内核对内存的管理以页面为单位,对文件缓存的管理也是以页面为单位。如果一个文件大小为 16KB,它正好可以用 4 个 4KB 的页面来缓存。因为内存有可能需要交换到硬盘上,而对硬盘文件的访问也可以通过 mmap 方式像访问内存一样进行访问。这两个管理单位的统一,减少了内核程序转换的麻烦。

硬盘这种物理介质以扇区为最小访问单位。通常一个扇区为 512 字节,对硬盘的读写最小单位是 512 字节,而文件系统是以块的方式来组织文件,文件块一般为 2 扇区、4 扇区,或者 8 扇区的格式。文件系统这种组织方式,要求提供一种块缓存机制来暂存文件的内容。所以内核提供了 buffer head 管理结构来管理块缓存。

buffer head 本身并没有保存文件内容,文件内容实际上还是在 page cache 中,buffer head 是个管理结构,它只是标识文件块的序号以及文件块缓存的地址。buffer head 同时提供对底层硬件设备(块设备)的映射。代码清单 10-1 给出了 buffer head 的结构定义。

代码清单 10-1 buffer_head 结构定义

struct buffer_head {
    unsigned long b_state;        /* buffer state bitmap (see above) */
    struct buffer_head *b_this_page;        /* circular list of page's buffers */
    struct page *b_page;        /* the page this bh is mapped to */
    sector_t b_blocknr;        /* start block number */
    size_t b_size;        /* size of mapping */
    char *b_data;        /* pointer to data within the page */
    struct block_device *b_bdev;
    bh_end_io_t *b_end_io;        /* I/O completion */
    void *b_private;        /* reserved for b_end_io */
    struct list_head b_assoc_buffers;        /* associated with another mapping */
    atomic_t b_count;        /* users using this buffer_head */
};

解释一下 buffer_head 数据结构的重要成员。

  • b_this_pagebuffer_head 单向链表,指向下一个 buffer_head 结构。
  • b_page:指向数据所在的页面。
  • b_blocknrbuffer_head 的起始块号。这个块号是以整个硬盘为空间编址的,所以可以转换为硬盘的物理扇区地址。
  • b_data:指向数据的地址。
  • b_bdev:文件系统绑定的块设备。
  • b_end_io:回调函数。I/O 处理完毕后调用这个函数。

b_blocknr 是以整个硬盘为空间编址,这个信息只有文件系统可以知道。在第9章分析了文件系统打开块设备的过程,文件系统的超级块对象保存了块设备指针,通过块设备指针可以获得硬盘的容量信息和硬盘分区的信息,同时文件的数据空间是由文件系统分配的,因此文件系统知道硬盘上的数据分布,可以提供以整个硬盘为编址空间的块号。硬盘文件系统一般提供 get_block 调用将文件的位置翻译为硬盘的块号信息。

10.1.3 page cache 的管理

通过数据结构 address_space 管理 page cache。这个数据结构提供了一个 radix tree 成员,文件内容的缓存页保存在这个 radix tree 里面。

对 page cache 而言,最重要的调用有两个,一是插入页面到 page cache,另一个是从 page cache 搜索页面。前者通过 add_to_page_cache 来实现,如代码清单 10-2 所示。

代码清单 10-2 add_to_page_cache (filemap.c)

int add_to_page_cache(struct page *page, struct address_space *mapping,
      pgoff_t offset, gfp_t gfp_mask)
{
    int error = radix_tree_preload(gfp_mask & ~__GFP_HIGHMEM);
    if (error == 0) {
        write_lock_irq(&mapping->tree_lock);
        error = radix_tree_insert(&mapping->page_tree, offset, page);
        if (!error) {
            page_cache_get(page);
            SetPageLocked(page);
            page->mapping = mapping;
            page->index = offset;
            mapping->nrpages++;
            __inc_zone_page_state(page, NR_FILE_PAGES);
        }
        write_unlock_irq(&mapping->tree_lock);
        radix_tree_preload_end();
    }
    return error;
}

这个函数逻辑很清晰,首先是创建 radix tree 根节点,然后把页面加入到 radix tree加入成功后,设置页面的 index

从 page cache 搜索一个页面通过 find_get_page 实现。这个函数实现很简单,不再分析。

10.1.4 page cache 的状态

页面有多种状态,由于内存管理的页和 page cache 的页是同一个结构,所以页面的状态其实也包含了 page cache 页面需要的状态。解释几个 page cache 中比较重要的状态。

  • PG_uptodate:页包含最新有效的数据。当读与该页对应的文件内容时,可以直接把页的内容复制给调用者。
  • PG_dirty:页包含脏数据,需要写入到硬盘。
  • PG_private:页私有属性。在 page cache 中,设置私有属性意味着为页创建了块缓存结构(buffer head),同时页面数据结构的 private 成员指向块缓存结构的头指针。
  • PG_mappedtodisk:页已经映射到硬盘。页映射到硬盘意味着这个页包含的所有块都已经映射到了硬盘。

块缓存也有多种状态,解释其中比较重要的几种状态。

  • BH_Mapped:块已经映射到硬盘。这个块通过调用文件系统的 get_block 已经获得了底层块设备的指针和以整个硬盘为编址空间的文件块号。
  • BH_Uptodate:块缓存包含最新有效的数据。
  • BH_Dirty:块缓存包含脏数据,需要写入硬盘。

10.2 文件预读

对于文件读请求,Linux 内核提供了预读策略,比要求读的长度要多读一些,存储在 page cache 里,后续读如果是顺序的,马上可以利用 page cache 的数据返回,不必再次读硬盘。对于硬盘这种慢速设备而言,利用缓存数据可以大大提升 I/O 的效率。

内核提供了默认的预读参数,如代码清单 10-3 所示。

代码清单 10-3 default_backing_dev_info

struct backing_dev_info default_backing_dev_info = {
    .ra_pages        = (VM_MAX_READAHEAD * 1024) / PAGE_CACHE_SIZE,
    .state                = 0,
    .capabilities        = BDI_CAP_MAP_COPY,
    .unplug_io_fn        = default_unplug_io_fn,
};

VM_MAX_READAHEAD 默认设置为 128KB,也就是默认预读页面是 32 个 4KB 页面。

Linux 内核会根据文件读是否顺序启动预读参数和设置预读窗口,对于连续的顺序读,会尽量多读一些内容填充 page cache。

预读代码学习建议

这部分内容在 readahead.c 文件里面,文件本身不大,也比较孤立,不涉及太多关联的知识点,读者可以作为一个例子,分析一下预读代码。

10.3 文件锁

如果一个进程对其他进程正在读取的文件进行写操作,虽然每次读写调用都是原子的,但是读写调用之间并没有同步,因此可能导致读进程读到被破坏或者不完整的数据。为了避免这种问题出现,必须有某种机制避免多进程并发访问的冲突问题。Linux 提供的机制就是文件锁。根据实现机制的不同,文件锁可分为建议锁和强制锁两种类型。

  • 建议锁:由应用层实现,内核只为用户提供程序接口,并不参与锁的控制和协调,也不对读写操作做内部检查和强制保护。如果有进程不检查文件是否有建议锁就写入数据,内核不加以阻拦。所以建议锁要求进程都遵守规则。建议锁可以对整个文件加锁,也可以只对文件的一部分加锁。
  • 强制锁:由内核强制实施。只要有进程调用读写操作,内核都会检查与存在的锁是否冲突,如果冲突,内核就会加以阻拦。

根据访问方式的不同,文件锁又分为读锁和写锁。

  • 读锁:允许多个进程同时进行读操作,又称共享锁。文件加了读锁就不能再设置写锁,但允许其他进程在同一区域再设置读锁。
  • 写锁:主要目的是隔离文件,使所写内容不被其他进程的读写干扰,以保证数据的完整性。写锁一旦加上,只有上锁的人可以操作,其他进程无论读还是写都只能等待写锁释放后才能执行,故写锁又称互斥锁

如果一个文件已经被加上了读锁,其他进程再对这个文件进行写操作就会被内核阻止。如果一个文件已经被加上了写锁,其他进程再对这个文件进行读取或者写操作就会被内核阻止。

10.4 文件读过程代码分析

为了便于理解文件的读写操作,图10-1给出一个例子文件的内容分布图。

图10-1 文件内容分布图 文件总长度4096字节×7=28672字节。从4096字节×2+1000字节的位置开始读,读4096字节+1000字节+3096字节。同时,设定文件开始在硬盘的第10000个扇区,前面我们已经知道硬盘扇区是512字节,文件的起始位置就是10000×512字节。

内核处理读文件从 sys_read 函数开始,我们就从这个函数开始读过程分析。sys_read 函数的实现代码如下所示。

// 代码清单10-4 sys_read(read_write.c)
asmlinkage ssize_t sys_read(unsigned int fd, char __user *buf, size_t count)
{
  struct file *file;
  ssize_t ret = -EBADF;
  int fput_needed;
  file = fget_light(fd, &fput_needed);
  if (file) {
      /*获取文件的当前位置*/
      loff_t pos = file_pos_read(file);
      ret = vfs_read(file, buf, count, &pos);
      file_pos_write(file, pos);
      fput_light(file, fput_needed);
  }
  return ret;
}

sys_read 函数首先根据文件ID获得文件结构的指针。每个进程都有一个 files_struct 结构指针,它保存了进程所有打开的文件,因此以文件ID为索引,就可以获得文件结构指针。其次取得文件的当前位置,这个参数是文件系统内部保存,每次执行读函数调用,都要记录读操作的最后位置,以备下次操作使用。

最后调用 vfs_read 函数执行文件读,读完之后,要把更新的文件当前位置写入文件指针。vfs_read 函数的实现代码如下所示。

// 代码清单10-5 vfs_read(read_write.c)
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
  ssize_t ret;
  ....../*省略部分代码*/   
  /*校验文件的锁*/
  ret = rw_verify_area(READ, file, pos, count);
  if (ret >= 0) {
      count = ret;
      ret = security_file_permission (file, MAY_READ);
      if (!ret) {
          if (file->f_op->read)
              ret = file->f_op->read(file, buf, count, pos);
          else
              ret = do_sync_read(file, buf, count, pos);
      }
  }
  return ret;
}

vfs_read 函数首先检查文件读写锁的权限。如果文件不支持强制锁,这个检查直接通过,如果支持强制锁,就要按前一节的描述检查锁是否冲突。

如果文件定义了 read 函数,调用文件自身的读函数,否则的话,系统提供了一个函数 do_sync_read 作为读函数。文件系统的函数是如何注册到文件的 f_op 指针?这是文件初始化期间生成 inode 结构时赋予的。数据文件、目录文件或者设备文件有各自不同的读写函数,这在第2章已经分析过,此处不再重复。

不同文件系统定义了不同的读写函数(很多也是相同的)。为了便于分析,我们选择一个广泛使用的文件系统——ext2文件系统作为例子,它的读写函数具有普遍的意义。

1. generic_file_read 函数

ext2文件系统的读函数使用了 generic_file_read。从名字可以看出,这个函数是个通用函数,实际上很多文件系统都使用了这个函数,如下所示。

// vfs_read→generic_file_read(filemap.c)
ssize_t
generic_file_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos)
{
  struct iovec local_iov = { .iov_base = buf, .iov_len = count };
  struct kiocb kiocb;
  ssize_t ret;
  init_sync_kiocb(&kiocb, filp);
  ret = __generic_file_aio_read(&kiocb, &local_iov, 1, ppos);
  if (-EIOCBQUEUED == ret)
      ret = wait_on_sync_kiocb(&kiocb);
  return ret;
}

generic_file_read 函数主要解决文件同步操作和异步操作的问题,这是通过一个同步控制结构 kiocb 实现的。函数开始调用 init_sync_kiocb 初始化一个同步控制块 kiocb,然后将读操作异步提交,如果读操作返回 EIOCBQUEUED(说明读操作未完成,尚在队列中,需要等待操作完成),进程置为睡眠态,等待 kiocb 的成员 ki_users 变为0。kiocb 结构的定义在文件 \include\aio.h 中,而它的控制逻辑主要在内核的异步I/O实现文件 aio.c 中。

前面的章节中分析过,真正的异步操作是很难实现的。使用 page cache 的 buffer I/O 时因为要等待读I/O完成才能返回,这个过程有可能阻塞进程,所以 buffer I/O 的实现过程本身就不能保证异步,等 buffer I/O 读过程返回,实际上已经完成了读操作。

2. __generic_file_aio_read 函数

generic_file_read 函数最后调用 __generic_file_aio_read 执行文件读,输入参数 iov 包含用户传入的用户态地址和希望读的字节数,如下所示。

// __generic_file_aio_read(filemap.c)
ssize_t __generic_file_aio_read(struct kiocb *iocb, const struct iovec *iov,
      unsigned long nr_segs, loff_t *ppos)
{
  ....../*省略部分代码*/
  count = 0;
  /*计算希望读文件的字节数*/
  for (seg = 0; seg < nr_segs; seg++) {
      const struct iovec *iv = &iov[seg];
      /*
       * If any segment has a negative length, or the cumulative
       * length ever wraps negative then return -EINVAL.
       */
      count += iv->iov_len;
      if (unlikely((ssize_t)(count|iv->iov_len) < 0))
          return -EINVAL;
      /*检查用户态地址是否合法*/
      if (access_ok(VERIFY_WRITE, iv->iov_base, iv->iov_len))
          continue;
      if (seg == 0)
          return -EFAULT;
      nr_segs = seg;
      count -= iv->iov_len;        /* This segment is no good */
      break;
  }

__generic_file_aio_read 函数第一部分计算希望读文件的字节数,校验用户态地址是否合法。在当前场景,已经限制了 nr_segs 为1,所以 iovec 只有一个向量。参数 count 得到了希望读文件的字节数,也就是我们前面赋予的4096字节+1000字节+3096字节。access_ok 是校验用户态地址是否合法,我们知道在32位计算机系统中,Linux用户态的地址空间从0到3G字节(通常的情况),这个函数经常在内核用到。

__generic_file_aio_read 函数后面部分是对 direct I/O 的处理以及底层调用,如下所示。

retval = 0;
  if (count) {
      for (seg = 0; seg < nr_segs; seg++) {
           read_descriptor_t desc;
           /*赋值读描述符*/
           desc.written = 0;
           desc.arg.buf = iov[seg].iov_base;
           desc.count = iov[seg].iov_len;
           if (desc.count == 0)
               continue;
           desc.error = 0;
           do_generic_file_read(filp,ppos,&desc,file_read_actor);
           retval += desc.written;
           if (desc.error) {
               retval = retval ?: desc.error;
               break;
           }
      }
  }
out:
  return retval;
}

__generic_file_aio_read 函数第二部分处理 direct I/O。direct I/O 只是业务逻辑,和 buffer I/O 基本流程大部分是相同的,所以本节重点在 buffer I/O,略过 direct I/O。

__generic_file_aio_read 函数第三部分使用了读描述符结构 descdescwritten 成员代表从文件中读到的字节数,当调用 do_generic_file_read 函数返回后,written 就等于此时读到的字节数,而函数指针 file_read_actor 用来将数据从内核的 page cache 复制到用户提供的 buf,也就是 iov_base 地址描述的内存。

3. do_generic_file_read 函数

do_generic_file_read 函数是内核提供的通用读函数,如下所示。

// do_generic_file_read(filemap.c)
static inline void do_generic_file_read(struct file * filp, loff_t *ppos,
                   read_descriptor_t * desc, read_actor_t actor)
{
  do_generic_mapping_read(filp->f_mapping,
                &filp->f_ra,
                filp,
                ppos,
                desc,
                actor);
}

do_generic_file_read 函数封装了 do_generic_mapping_read。输入参数 f_mapping 封装了块设备的读页面和写页面函数。对于 ext2 文件系统,它在文件 inode 初始化的时候设置了读写页面函数结构 ext2_aops,打开文件的时候,设置文件的 f_mapping 等于 inode 结构提供的结构指针。

4. do_generic_mapping_read 函数

函数 do_generic_mapping_read 要计算文件读操作涉及的页面参数。这个函数非常庞杂,因此分为七个部分解释。

对于使用了 page cache 的 buffer I/O,文件的操作要落地到对 page cache 的操作。如果文件的内容已经在 page cache 里面,不需要读,直接复制内存就可以;如果 page cache 没有文件内容,则需要申请 page cache,然后从硬盘读文件内容到 page cache。为完成对 page cache 的查找,首先要把文件读的字节数计算为 page cache 使用的页面索引值,然后才便于查找。第一部分如下所示。

// do_generic_mapping_read(filemap.c) 第872行开始
872 void do_generic_mapping_read(struct address_space *mapping,
873                 struct file_ra_state *_ra,
874                 struct file *filp,
875                 loff_t *ppos,
876                 read_descriptor_t *desc,
877                 read_actor_t actor)
878 {
       ....../*省略部分代码*/
       /*计算读文件的第一个页面*/
892    index = *ppos >> PAGE_CACHE_SHIFT;
893    next_index = index;
894    prev_index = ra.prev_page;
895    last_index = (*ppos + desc->count + PAGE_CACHE_SIZE-1) >> PAGE_CACHE_SHIFT;
896    offset = *ppos & ~PAGE_CACHE_MASK;
897 
898    isize = i_size_read(inode);
899    if (!isize)
900        goto out;
901 
902    end_index = (isize - 1) >> PAGE_CACHE_SHIFT;
903    for (;;) {
904        struct page *page;
905        unsigned long nr, ret;
906 
907        /* nr is the maximum number of bytes to copy from this page */
908        nr = PAGE_CACHE_SIZE;
909        if (index >= end_index) {
910            if (index > end_index)
911                goto out;
912            nr = ((isize - 1) & ~PAGE_CACHE_MASK) + 1;
913            if (nr <= offset) {
914                goto out;
915            }
916        }
917        nr = nr - offset;
918 
919        cond_resched();
920        if (index == next_index)
921            next_index = page_cache_readahead(mapping, &ra,
922                     index, last_index - index);

第892行计算读文件的第一个页面。文件读设定从文件位置4096字节×2+1000字节开始读,计算得到index就是2。prev_index 又是什么?它得到文件上次操作读到的最后一个页面。last_index 计算读的最后一个页面,得到结果5。第892行和895行是内核常用的计算页面对齐的方法,第892行是向下对齐,而895行则是向上对齐。第896行 offset 计算文件位置在页面内的字节数,计算得到1000字节。第902行则是计算文件的最后一个页面索引。前面设定文件长度为7×4096字节,end_index 计算得到6。注意 end_index 是向下对齐的。

从第903行开始循环遍历所有的页面,检查页面是否在 page cache 之内,如果是则从 page cache 直接复制到用户 buf,否则需要申请页面,然后从硬盘读入页面数据,再复制到用户 buf。

首先第908行 nr 设为4096,这是一个页面内最大可读的字节数。第909~916行则是判断是否已经读到文件末尾的情况。如果是文件最后一个页面,则要根据文件的总长度调整能读到的字节数。

第917行计算页面内可读的字节数。因为我们是从 4096×2+1000 字节开始读,第一个页面能读的字节就是3096字节。

第920行,因为是第一次循环,所以 index 等于 next_index,调用 page_cache_readahead 进入文件预读。传入的参数指明是从第二个页面开始读,读三个页面,读完后,正常情况下,next_index 得到5。

do_generic_mapping_read 函数第二部分检查 page cache 是否存在我们需要的页面。

924 find_page: /*在page cache中查找page*/
925       page = find_get_page(mapping, index);
926       if (unlikely(page == NULL)) {
927           handle_ra_miss(mapping, &ra, index);
928           goto no_cached_page;
929       }
930       if (!PageUptodate(page))
931           goto page_not_up_to_date;

第924行的 find_page 用来在 page cache 里面搜索页面。结果可以分为三种情况:

  • 页面存在而且是最新的,只需要调用传进来的函数指针 actor,将 page cache 的内容复制到用户 buffer 即可。
  • 页面存在,但不是最新的内容,进入 page_not_up_to_date 分支读这个页面,获得最新的内容。页面更新后,将最新内容复制到用户 buffer。
  • 页面根本不存在,进入 no_cached_page 分支申请一个页面,然后再去读这个页面,读完成后,将最新内容复制到用户 buffer。

下面代码是对 page cache 内不同页面状态的处理。

932 page_ok:
          ....../*省略部分注释*/
938       if (mapping_writably_mapped(mapping))
939           flush_dcache_page(page);
940 
945       if (prev_index != index)
946           mark_page_accessed(page);
947       prev_index = index;
          ....../*省略部分注释*/
959       ret = actor(desc, page, offset, nr);
960       offset += ret;
961       index += offset >> PAGE_CACHE_SHIFT;
962       offset &= ~PAGE_CACHE_MASK;
963 
964       page_cache_release(page);
965       if (ret == nr && desc->count)
966           continue;
967       goto out;

do_generic_mapping_read 函数第三部分处理页面状态是最新的情况。

这种情况说明 page cache 中有```c 969 page_not_up_to_date: /页存在,但不是最新/ 970 /* Get exclusive access to the page … / 971 lock_page(page); 972 973 / Did it get unhashed before we got the lock? / 974 if (!pagemapping) { 975 unlock_page(page); 976 page_cache_release(page); 977 continue; 978 } 979 980 / Did somebody else fill it already? */ 981 if (PageUptodate(page)) { 982 unlock_page(page); 983 goto page_ok; 984 } 985


`do_generic_mapping_read` 函数第四部分处理页面在 page cache 中,但是数据不是最新的情况。

此时要调用 `lock_page` 锁页面,这个函数可能阻塞进程,因为可能有别的进程正在读写这个页面,等其他进程读写完毕后,进程被唤醒继续执行。第981行第二次检查页面是否更新,因为锁页面导致进程阻塞时候,可能其他进程已经把数据读入页面了。如果页面状态未更新,继续执行进入 `readpage` 分支。其次处理页面中数据不存在或者虽然存在但不是最新,因此需要读页面的情况。

```c
986 readpage:
987        /* Start the actual read. The read will unlock the page. */
988        error = mapping->a_ops->readpage(filp, page);
989 
990        if (unlikely(error)) {
991            if (error == AOP_TRUNCATED_PAGE) {
992                page_cache_release(page);
993                goto find_page;
994            }
995            goto readpage_error;
996        }
997 
998        if (!PageUptodate(page)) {
999            lock_page(page);
1000           if (!PageUptodate(page)) {
1001               if (page->mapping == NULL) {
1002                   /*
1003                    * invalidate_inode_pages got it
1004                    */
1005                   unlock_page(page);
1006                   page_cache_release(page);
1007                   goto find_page;
1008               }
1009               unlock_page(page);
1010               error = -EIO;
1011               shrink_readahead_size_eio(filp, &ra);
1012               goto readpage_error;
1013           }
1014           unlock_page(page);
1015        }
            ....../*省略部分代码*/
1025        isize = i_size_read(inode);
1026        end_index = (isize - 1) >> PAGE_CACHE_SHIFT;
1027        if (unlikely(!isize || index > end_index)) {
1028           page_cache_release(page);
1029           goto out;
1030        } 
            ....../*省略部分代码*/
1044        readpage_error:
1045        /* UHHUH! A synchronous read error occurred. Report it */
1046        desc->error = error;
1047        page_cache_release(page);
1048        goto out;

do_generic_mapping_read 函数第五部分处理读页面操作。

此时调用文件系统提供的 readpage 函数真正从硬盘读入一个页面的数据,因为 readpage 函数只是将读命令发送到硬盘,真正的读到数据必须等中断返回,所以第999行再次锁页面会导致进程睡眠(如果页面数据不是最新),直到中断返回,在中断处理函数中将页面状态改为最新并唤醒进程,此时页面状态已经是最新,从第1025~1041行和第908~917行的处理一样,设置参数值后进入 page_ok 分支开始复制数据。

上述情况有一个例外,如果中断返回后,页面状态仍不是最新,说明发生了错误,进入 readpage_error 分支释放页面后返回。第1001行检查 page 的 mapping 成员是否为空,如果为空,说明发生了 invalidate_inode_pages 事件,这种情况返回 find_page 分支继续处理。

1050 no_cached_page:
1055       if (!cached_page) {
1056           cached_page = page_cache_alloc_cold(mapping);
1057           if (!cached_page) {
1058               desc->error = -ENOMEM;
1059               goto out;
1060           }
1061       }
1062       error = add_to_page_cache_lru(cached_page, mapping,
1063               index, GFP_KERNEL);
           ....../*省略部分代码*/ 
1070       page = cached_page;
1071       cached_page = NULL;

do_generic_mapping_read 函数第六部分处理页面在 page cache 中根本不存在的情况。

此时需要调用 page_cache_alloc_cold 函数从伙伴系统分配一个冷页面,然后调用 add_to_page_cache_lru 函数将新页面添加到 page cache 和 LRU 链表中。添加成功后,将 page 指针指向新页面,并将 cached_page 置为 NULL。之后进入 readpage 分支执行读磁盘操作。最后,函数第七部分处理循环结束和清理工作(省略部分代码对应 out 标签后的内容)。

至此,do_generic_mapping_read 函数的七个部分分析完毕。整个读过程从 sys_read 开始,经过 VFS 层、通用文件读函数,最终通过 mapping->a_ops->readpage 将读请求下发到块设备驱动,数据经中断返回后写入 page cache,再复制到用户态缓冲区。

## 第9-10章 块设备与文件系统读写(续)
 
### 10.4.3 通用读页面函数细节
 
`do_generic_mapping_read`函数第六部分申请一个页面,然后将页面插入page cache.如果成功,则进入`readpage`分支开始从硬盘读入数据.
 
```c
1072       goto readpage;
1073   }
1075 out:
1076   *_ra = ra;
1077 
1078   *ppos = ((loff_t) index << PAGE_CACHE_SHIFT) + offset;
1079   if (cached_page)
1080       page_cache_release(cached_page);
1081   if (filp)
1082       file_accessed(filp);

do_generic_mapping_read函数第七部分更新文件的位置,修改文件的预读状态.

从硬盘读数据是通过文件系统提供的readpage函数实现的.而ext2文件系统提供的读页面函数就是ext2_readpage,如代码清单10-10所示.

代码清单10-10 ext2_readpage(inode.c)

static int ext2_readpage(struct file *file, struct page *page)
{
  return mpage_readpage(page, ext2_get_block);
}
 
int mpage_readpage(struct page *page, get_block_t get_block)
{
  struct bio *bio = NULL;
  sector_t last_block_in_bio = 0;
  struct buffer_head map_bh;
  unsigned long first_logical_block = 0;
  /*清除map_bh的映射标志*/
  clear_buffer_mapped(&map_bh);
  bio = do_mpage_readpage(bio, page, 1, &last_block_in_bio,
           &map_bh, &first_logical_block, get_block);
  /*如果bio有效,则发送一个读请求bio*/
  if (bio)
      mpage_bio_submit(READ, bio);
  return 0;
}

ext2_readpage函数没执行任何动作,直接调用了mpage_readpage.后者是内核提供的一个通用函数,它调用do_mpage_readpage将读请求转换为一个bio结构,如果bio有效,则提交bio给底层去执行读操作.

文件系统的读写请求,最终要转换成对块设备的读写请求.这涉及几个问题:

  • 文件对用户呈现了一个连续的读写接口,但是文件在真正物理设备硬盘上的存储可能并不是连续的,如果是不连续的,对文件的读写就不能用同一个I/O完成,而是需要拆分.
  • 硬盘的读写最小单元是扇区,通常一个扇区是512字节,而文件的最小单元是块,一个块可以由多个扇区组成.组成块的扇区物理地址必须连续,而块之间可以不连续.
  • 内核通过submit_bio来提交一个I/O给底层.同时内核又提供了一个函数submit_bh来提交块.submit_bh最终也是通过submit_bio来实现,它只是多了将块地址转换为硬盘物理扇区地址的过程.

5. do_mpage_readpage函数

do_mpage_readpage函数必须对上面的问题做出处理,对一个页面包含的块进行检查,判断读文件的请求是否通过一个bio即可提交,还是需要拆分为多个bh,如代码清单10-11所示.

代码清单10-11 do_mpage_readpage函数

175 static struct bio *
176 do_mpage_readpage(struct bio *bio, struct page *page, unsigned
177        sector_t *last_block_in_bio, struct buffer_head *ma
178        unsigned long *first_logical_block, get_block_t get
179 {
     ……/*省略部分代码*/
196    if (page_has_buffers(page))
197        goto confused;
198 
199    block_in_file = (sector_t)page->index << (PAGE_CACHE_SH
200    last_block = block_in_file + nr_pages * blocks_per_page
201    last_block_in_file = (i_size_read(inode) + blocksize - 
       /*如果最后一个块超过文件长度,则以文件长度为准*/
202    if (last_block > last_block_in_file)
203        last_block = last_block_in_file;
204    page_block = 0;

do_mpage_readpage函数第一部分首先判断当前页面是否已经创建了块缓存.

page_has_buffers通过检查page结构是否设置PG_private标志位,如果设置了这个标志位,说明该页面已经创建了管理块缓存的buffer head结构.有的块缓存可能已经有了最新数据,有的块缓存可能还没有数据,这种情况转入confused分支,逐个检查每个块的具体情况.如果没有设置PG_private标志位,则要获得每个块的块号.

第199行计算第一个块在文件中的块号,第200行计算最后一个块的块号,如果最后一个块超过文件长度,则以文件长度为准.

     /*
207   * Map blocks using the result from the previous get_bloc
208   */
209  nblocks = map_bh->b_size >> blkbits;
210  if (buffer_mapped(map_bh) && block_in_file > *first_logic
211           block_in_file < (*first_logical_block + nblocks)
212      unsigned map_offset = block_in_file - *first_logical_
213      unsigned last = nblocks - map_offset;
214 
215      for (relative_block = 0; ; relative_block++) {
216           if (relative_block == last) {
217               clear_buffer_mapped(map_bh);
218               break;
219           }
220           if (page_block == blocks_per_page)
221               break;
222           blocks[page_block] = map_bh->b_blocknr + map_off
223                         relative_block;
224           page_block++;
225           block_in_file++;
226      }
227      bdev = map_bh->b_bdev;
228   }

do_mpage_readpage函数第二部分处理页面已经映射过的情况.

函数第一部分计算的块号是文件内的逻辑块号,而要从硬盘读数据,必须根据逻辑块号获得硬盘的物理块号,这个过程就是映射.函数指针get_block就是文件系统提供的映射函数.

局部变量blocks是个数组,当前页面中每一个块的物理块号都保存在这个数组中.如果当前页面已经映射过,循环把文件块的物理块号写入blocks数组,直到页面内的所有块都被处理完毕.

do_mpage_readpage函数随后部分检查每个文件块的状态,代码如下:

231  /* Then do more get_blocks calls until we are done with */
232    */
233  map_bh->b_page = page;
     /*循环处理要读的所有块*/
234  while (page_block < blocks_per_page) {
235      map_bh->b_state = 0;
236      map_bh->b_size = 0;
237 
238      if (block_in_file < last_block) {
239          map_bh->b_size = (last_block-block_in_file) << bl
             /*get_block是文件系统提供,用来获得文件块的物理块号*/
240          if (get_block(inode, block_in_file, map_bh, 0))
241              goto confused;
242          *first_logical_block = block_in_file;
243      }
244      /*未被映射。有可能是文件中的洞*/
245      if (!buffer_mapped(map_bh)) {
246          fully_mapped = 0;
247          if (first_hole == blocks_per_page)
248              first_hole = page_block;
249          page_block++;
250          block_in_file++;
251          clear_buffer_mapped(map_bh);
252          continue;
253      }
         /*如果这个块的内容已经被缓存而且是最新的*/
261      if (buffer_uptodate(map_bh)) {
262          map_buffer_to_page(page, map_bh, page_block);
263          goto confused;
264      }
265      /*处理文件的洞*/
266      if (first_hole != blocks_per_page)
267          goto confused;    /* hole -> non-hole */
268 
269      /* Contiguous blocks? */
270      if (page_block && blocks[page_block-1] != map_bh->b_b
271          goto confused;
272      nblocks = map_bh->b_size >> blkbits;
         /*如果从文件系统一次get_block了多个块,则循环处理*/
273      for (relative_block = 0; ; relative_block++) {
274           if (relative_block == nblocks) {
275               clear_buffer_mapped(map_bh);
276               break;
277           } else if (page_block == blocks_per_page)
278               break;
279           blocks[page_block] = map_bh->b_blocknr+relative_
280           page_block++;
281           block_in_file++;
282      }
283      bdev = map_bh->b_bdev;
284  }

do_mpage_readpage函数第三部分循环遍历每一个文件块,调用get_block获得它的物理块号.

第245行检查文件块如果未被映射,说明可能是文件中的空洞,则处理下一个块.

第261行检查map_bh中的内容已经是最新的,说明当前块最新的数据已经在page cache,即使文件块的物理地址连续,也需要拆分I/O(避免重复读硬盘),因此转入confused分支.

第270行检查文件块的物理块号和map_bh的物理块号不连续,说明不是顺序I/O,需要跳转confuse分支.

因为一次调用get_block可以获得多个文件块的物理块号,所以从第272~282行逐个将映射获得的文件块写入blocks数组,写入blocks数组的数目不超过一个页面容纳的文件块数.变量nblocks保存了映射获得的文件块数目.

do_mpage_readpage函数随后部分检查I/O能否合并的情况,代码如下:

301  /*
302   * This page will go to BIO.  Do we need to send this BIO
303   */
     /*上一个bio的最后块和当前I/O的不连续,则先发送上一个bio*/
304  if (bio && (*last_block_in_bio != blocks[0] - 1))
305      bio = mpage_bio_submit(READ, bio);

do_mpage_readpage函数的传入参数有一个bio指针和bio中最后一个块的物理块号.如果最后一个块的物理块号和blocks数组的物理块号连续,说明传入的bio可以和当前I/O合并,否则就必须立即把传入的bio进行提交,交给底层处理.

do_mpage_readpage函数第四部分用于处理I/O合并,如果前一个bio和当前I/O要读的物理块号连续,两个I/O可以进行合并.随后部分处理I/O不能合并,因此需要申请新bio结构的情况.

do_mpage_readpage函数第五部分申请一个新的bio结构.代码如下:

307 alloc_new:
308   if (bio == NULL) {
309       bio = mpage_alloc(bdev, blocks[0] << (blkbits - 9),
310                min_t(int, nr_pages, bio_get_nr_vecs(bdev))
311                GFP_KERNEL);
312       if (bio == NULL)
313           goto confused;
314   }
315 
316   length = first_hole << blkbits;
317   if (bio_add_page(bio, page, length, 0) < length) {
318       bio = mpage_bio_submit(READ, bio);
319       goto alloc_new;
320   }
321 
322   if (buffer_boundary(map_bh) || (first_hole != blocks_per
323       bio = mpage_bio_submit(READ, bio);
324   else
325       *last_block_in_bio = blocks[blocks_per_page - 1];
326 out:
327   return bio;

这个bio的起始扇区地址根据blocks数组第一个块的物理块号计算得出.如果存在文件空洞或者map_bh有边界标志,说明这个bio不能和随后的I/O合并,则调用mpage_bio_submit立即提交I/O.否则,返回最后一个块的物理块号和bio地址,由系统判断何时提交bio.

do_mpage_readpage函数随后检查是否递交bio结构给下层,代码如下:

confused:
  if (bio)
      bio = mpage_bio_submit(READ, bio);
  if (!PageUptodate(page))
        block_read_full_page(page, get_block);
  else
      unlock_page(page);
  goto out;
}

do_mpage_readpage函数第六部分是confused分支代码.

如果页面状态是PG_uptodate,说明page cache的当前页面已经全部是最新数据,不需要从硬盘读数据,直接返回,否则就要调用block_read_full_page函数逐个遍历页面内的每一个文件块,检查数据是否最新.

6. block_read_full_page函数

block_read_full_page函数与do_mpage_readpage前面部分的流程类似,所以只分析最后部分,如代码清单10-12所示.

代码清单10-12 block_read_full_page

int block_read_full_page(struct page *page, get_block_t *get_b
{       
  ……/*省略部分代码*/
  /*
   * Stage 3: start the IO.  Check for uptodateness
   * inside the buffer lock in case another process reading
   * the underlying blockdev brought it uptodate (the sct fix)
   */
  for (i = 0; i < nr; i++) {
       bh = arr[i];
       if (buffer_uptodate(bh))
           end_buffer_async_read(bh, 1);
       else
           submit_bh(READ, bh);
  }
  return 0;
}

局部变量arr是个数组,保存了页面内的每一个文件块的物理块号.如果文件块号的状态为BH_Uptodate,说明块的数据已经是最新,不需要从硬盘读数据,否则调用submit_bh把块提交给底层.


# 第9-10章 块设备与文件系统读写

## 10.5 读过程返回

文件系统通过 `mpage_bio_submit` 提交一个 I/O。这个 I/O 什么时候返回?返回通过什么机制通知上层?这涉及内核 I/O 过程的阻塞点设计。

本章开始部分分析了同步 I/O 和异步 I/O,Linux 的同步 I/O 调用了文件系统提供的 `read` 函数,这个 `read` 函数最终要调用 `do_generic_mapping_read` 把文件内容按照页面读入 page cache。这个过程实际上是阻塞的,读页面的过程中两次调用了 `lock_page` 函数,而这个函数使用了 `__wait_on_bit_lock` 可能让进程进入睡眠状态。如果进程进入睡眠状态,谁来唤醒它?这就要靠 `mpage_bio_submit` 注册的回调函数了。

在这里需要进一步探讨异步 I/O 的实现。从前面过程分析可以知道,buffer I/O 不能实现异步 I/O,要避免进程阻塞,实现异步 I/O 必须使用 direct I/O。即使这样,异步 I/O 中仍然使用了自旋锁和信号量,进程仍然可能在某些地方被阻塞。

函数 `mpage_bio_submit` 提交 bio 的时候,要注册回调函数,以供中断处理函数中调用,如代码清单10-13所示。

**代码清单10-13 mpage_bio_submit(mpage.c)**

```c
static struct bio *mpage_bio_submit(int rw, struct bio *bio)
{
  bio->bi_end_io = mpage_end_io_read;
  if (rw == WRITE)
       bio->bi_end_io = mpage_end_io_write;
  submit_bio(rw, bio);
  return NULL;
}

mpage_bio_submit 函数提供了回调函数 mpage_end_io_read。这个函数是在 I/O 完成后的中断过程中调用的。I/O 中断在第11章分析,本章重点关注这个回调函数的实现,如代码清单10-14所示。

代码清单10-14 mpage_end_io_read(mpage.c)

static int mpage_end_io_read(struct bio *bio, unsigned int byt
{     /*测试数据是否更新*/
  const int uptodate = test_bit(BIO_UPTODATE, &bio->bi_flags);
  struct bio_vec *bvec = bio->bi_io_vec + bio->bi_vcnt - 1;
       
  if (bio->bi_size)
       return 1;
  do {
       struct page *page = bvec->bv_page;
       if (--bvec >= bio->bi_io_vec)
           prefetchw(&bvec->bv_page->flags);
       if (uptodate) {
           /*读成功,设置page为update*/
           SetPageUptodate(page);
       } else {
           ClearPageUptodate(page);
           SetPageError(page);
       }
       /*解锁page ,唤醒被阻塞的读page进程*/
       unlock_page(page);
  } while (bvec >= bio->bi_io_vec);
  bio_put(bio);
  return 0;
}

函数 mpage_end_io_read 要遍历 bio 结构的每个向量,看相关页面是否获得最新的数据。如果成功,则设置页面状态为最新,同时解锁页面。这个解锁动作会唤醒等待该页面更新的进程,从之前阻塞点继续往下执行,如果数据未更新,则设置页面出错,然后解锁页面,唤醒等待页面更新的进程。回顾一下读页面的代码,此时进程被唤醒后,会检查页面状态是否为最新,如果不为最新,将设置 readpage_error 错误返回。


10.6 文件写过程代码分析

文件写的系统调用是 write。在内核中,文件的写过程是从 sys_write 函数开始,这个函数以及它调用的 vfs_write 和文件的读过程非常类似,就不再赘述。本节从文件系统提供的 write 函数开始分析。

1. generic_file_write 函数

ext2 文件系统提供的 write 函数是 generic_file_write,这个函数也是个通用函数,被很多文件系统所使用,如代码清单10-15所示。

代码清单10-15 generic_file_write(filemap.c)

ssize_t generic_file_write(struct file *file, const char __use
              size_t count, loff_t *ppos)
{
  struct address_space *mapping = file->f_mapping;
  struct inode *inode = mapping->host;
  ssize_t        ret;
  struct iovec local_iov = { .iov_base = (void __user *)buf,
                             .iov_len = count };
  mutex_lock(&inode->i_mutex);
  ret = __generic_file_write_nolock(file, &local_iov, 1, ppos)
  mutex_unlock(&inode->i_mutex);
  /*如果文件有SYNC标志,或者inode有SYNC标志*/
  if (ret > 0 && ((file->f_flags & O_SYNC) || IS_SYNC(inode)))
      ssize_t err;
      err = sync_page_range(inode, mapping, *ppos - ret, ret);

buffer I/O 的读写对象是 page cache,文件写只把数据写到 page cache 就返回。但是某些时候,用户希望真正写到硬盘,文件就需要设置 SYNC 标志。设置 SYNC 标志后,系统将调用 sync_page_range 把 page cache 的页面写入硬盘,这部分在第12章中分析,此处略过。

2. generic_file_buffered_write 函数

__generic_file_write_nolock 类似文件的读函数,它调用 __generic_file_aio_write_nolock,这个函数和读函数 __generic_file_aio_read 也很类似,所以此处略过。__generic_file_aio_write_nolock 调用了 generic_file_buffered_write 函数执行写操作,我们从这里开始分析。generic_file_buffered_write 函数的代码如代码清单10-16所示。

代码清单10-16 generic_file_buffered_write(filemap.c)

ssize_t
generic_file_buffered_write(struct kiocb *iocb, const struct i
      unsigned long nr_segs, loff_t pos, loff_t *ppos,
      size_t count, ssize_t written)
{
  struct file *file = iocb->ki_filp;
  struct address_space * mapping = file->f_mapping;
  /*mapping 的a_ops结构是初始化inode的时候由文件系统提供*/
  const struct address_space_operations *a_ops = mapping->a_op
  struct inode         *inode = mapping->host;
  long                status = 0;
  struct page        *page;
  struct page        *cached_page = NULL;
  size_t                bytes;
  struct pagevec        lru_pvec;
  const struct iovec *cur_iov = iov; /* current iovec */
  size_t                iov_base = 0;            /* offset in 
  char __user        *buf;
  pagevec_init(&lru_pvec, 0);
  if (likely(nr_segs == 1))
      buf = iov->iov_base + written;
  else {
      filemap_set_next_iovec(&cur_iov, &iov_base, written);
      buf = cur_iov->iov_base + iov_base;
  }

generic_file_buffered_write 函数第一部分设置必要的参数。

文件的 f_mapping 成员是一个指向 address_space 结构的指针,而 address_space 结构包含了文件的读写操作函数。

输入参数 nr_segs 是段的数目。每个段代表独立的一段内存,文件读写时,可以指定多个段,由内核一次性处理。当前场景只有一个段,buf 参数指向保存写入数据的用户态内存的地址。

generic_file_buffered_write 函数随后部分检查需要写入的每个页面的状态,代码如下:

do {
    unsigned long index;
    unsigned long offset;
    size_t copied;
    offset = (pos & (PAGE_CACHE_SIZE -1)); /* Within page */
    index = pos >> PAGE_CACHE_SHIFT;
    bytes = PAGE_CACHE_SIZE - offset;
    /* Limit the size of the copy to the caller's write size */
    bytes = min(bytes, count);
    bytes = min(bytes, cur_iov->iov_len - iov_base);
    fault_in_pages_readable(buf, bytes);
    /*从page cache申请一个页面*/ 
    page = __grab_cache_page(mapping,index,&cached_page,&lru_p
    ....../*省略部分代码*/
    /*如果要写入的字节为0,跳转zero_length_segment*/
    if (unlikely(bytes == 0)) {
        status = 0;
        copied = 0;
        goto zero_length_segment;
    }
    status = a_ops->prepare_write(file, page, offset, offset+b
    ....../*省略部分代码*/
    /*复制用户内存到page cache的页面*/
    if (likely(nr_segs == 1))
        copied = filemap_copy_from_user(page, offset, buf, byt
    else
        copied = filemap_copy_from_user_iovec(page, offset,
                     cur_iov, iov_base, bytes);
    flush_dcache_page(page);
    status = a_ops->commit_write(file, page, offset, offset+by
    if (status == AOP_TRUNCATED_PAGE) {
    /*如果这个页面无效了,需要重来一次*/
    page_cache_release(page);
    continue;
}

generic_file_buffered_write 函数第二部分循环遍历要写入数据的每一个页面。

变量 offset 是当前页面内的偏移值,bytes 是要写入的字节数,index 计算当前页面的索引值。以页面索引值为参数,向 page cache 申请页面,如果页面存在,则锁定页面,禁止其他任务再使用文件的这个页面。如果页面不存在,需要创建一个页面,加入 page cache,然后锁定页面。

锁定当前页面后,首先调用文件系统的 prepare_write 函数检查当前页内的每个文件块,然后将数据从用户状态缓存复制到 page cache,最后调用文件系统的 commit_write 再次检查当前页的每个文件块并修改块和页面的状态。

返回值 AOP_TRUNCATED_PAGE 代表所操作的 page cache 页面无效,这种情况文件位置和页面索引都不改变,重新尝试申请 page cache 页面,重复上述写入的过程。

generic_file_buffered_write 函数随后部分检查需要写入的字节数,代码如下:

zero_length_segment:
      if (likely(copied >= 0)) {
          /*根据复制的字节数目,计算剩余的字节数和当前位置*/
          if (!status)
              status = copied;
          if (status >= 0) {
              written += status;
              count -= status;
              pos += status;
              buf += status;
              if (unlikely(nr_segs > 1)) {
                  filemap_set_next_iovec(&cur_iov, &iov_base, 
                  if (count)
                      buf = cur_iov->iov_base + iov_base;
              } else {
                  iov_base += status;
              }
          }
      }
      if (unlikely(copied != bytes))
          if (status >= 0)
              status = -EFAULT;
      unlock_page(page);
      mark_page_accessed(page);
      page_cache_release(page);
      if (status < 0)
          break;
      /*检查是否需要真正写硬盘*/
      balance_dirty_pages_ratelimited(mapping);
      cond_resched();
} while (count);

generic_file_buffered_write 函数第三部分是 zero_length_segment 分支,也是当前页写入完成,进入下一个页面之前的参数调整部分。

如果当前页面已经成功写入 page cache,文件位置 pos,用户态内存都要加上已经完成的字节数,而需要写入的字节数 count 则减去已经完成的字节。

balance_dirty_pages_ratelimited 函数的作用是检查是否触发了内核的回写策略,是否需要将写入 page cache 的数据真正写入硬盘,这个函数将在第12章进行分析。

generic_file_buffered_write 函数随后检查文件的特殊标志,代码如下:

  *ppos = pos;
  if (cached_page)
      page_cache_release(cached_page);
  if (likely(status >= 0)) {
      if (unlikely((file->f_flags & O_SYNC) || IS_SYNC(inode))
          if (!a_ops->writepage || !is_sync_kiocb(iocb))
              status = generic_osync_inode(inode, mapping,
                       OSYNC_METADATA|OSYNC_DATA);
      }
  }
 ....../*省略direct I/O的代码*/
  pagevec_lru_add(&lru_pvec);
  return written ? written : status;
}

generic_file_buffered_write 函数第四部分检查文件是否具有 SYNC 标志。通常文件数据写到 page cache 就结束了,何时从 page cache 真正写入硬盘由内核的回写机制控制。但是如果文件具有 SYNC 标志或者文件系统 mount 时候设置了 MS_SYNCHRONOUS 标志,立即将修改的内容同步到硬盘并等待写入数据完成。

最后,generic_file_buffered_write 函数返回最终写入 page cache 的总字节数。

3. 获得文件块的物理块号

通过文件写数据到硬盘,必须获得文件块的物理块号,才能真正执行数据写入硬盘。这是文件系统提供的 prepare_write 函数和 commit_write 函数的功能。对 ext2 文件系统而言,它提供的 prepare_write 函数是 ext2_prepare_writecommit_write 函数是 generic_commit_write

ext2_prepare_write 是个封装函数,它真正调用的是 __block_prepare_write 函数。该函数要逐个获得页面内文件块的物理块号并检查它的状态是否最新,如代码清单10-17所示。

代码清单10-17 __block_prepare_write(buffer.c)

static int __block_prepare_write(struct inode *inode, struct p
     unsigned from, unsigned to, get_block_t *get_block)
{
  ....../*省略部分代码*/
  blocksize = 1 << inode->i_blkbits;
  /*为页面创建块缓存*/
  if (!page_has_buffers(page))
      create_empty_buffers(page, blocksize, 0);
  head = page_buffers(page);
  bbits = inode->i_blkbits;
 /*计算页面内第一个块在文件内的块号*/ 
  block = (sector_t)page->index << (PAGE_CACHE_SHIFT - bbits);

__block_prepare_write 函数第一部分为页面创建 buffer_head 管理结构,然后计算需要用到的参数。

变量 blocksize 保存文件块的大小,block 计算页面内第一个块在文件内的逻辑块号。

__block_prepare_write 函数随后检查所有需要写入的文件块,代码如下:

/*循环遍历所有的块*/
for(bh = head, block_start = 0; bh != head || !block_start;
    block++, block_start=block_end, bh = bh->b_this_page) {
     block_end = block_start + blocksize;
     /*如果块地址不在写的范围内,则进入下一个块*/
     if (block_end <= from || block_start >= to) {
         if (PageUptodate(page)) {
             if (!buffer_uptodate(bh))
                 set_buffer_uptodate(bh);
         }
         continue;
     }
     if (buffer_new(bh))
         clear_buffer_new(bh);
     /*文件块未映射到硬盘*/
     if (!buffer_mapped(bh)) {
         WARN_ON(bh->b_size != blocksize);
         /*获得文件块的物理块号,映射到具体的物理设备*/
         err = get_block(inode, block, bh, 1);
         if (err)
             break;
         if (buffer_new(bh)) {
             unmap_underlying_metadata(bh->b_bdev, bh->b_block
             /*如果页面已经是最新内容,那么设置块缓存为最新*/
             if (PageUptodate(page)) {
                 set_buffer_uptodate(bh);
                 continue;
             }
             /*将写入范围之外的数据填充为0*/ 
             if (block_end > to || block_start < from) {
                 void *kaddr;
                 kaddr = kmap_atomic(page, KM_USER0);
                 if (block_end > to)
                     memset(kaddr+to, 0, block_end-to);
                 if (block_start < from)
                     memset(kaddr+block_start,
                         0, from-block_start);
                 flush_dcache_page(page);
                 kunmap_atomic(kaddr, KM_USER0);
             }
             continue;
         }
     }
     if (PageUptodate(page)) {
         if (!buffer_uptodate(bh))
             set_buffer_uptodate(bh);
         continue; 
     }
      /*写入的地址没按照文件块对齐,需要读出文件内容*/ 
     if (!buffer_uptodate(bh) && !buffer_delay(bh) &&
         (block_start < from || block_end > to)) {
         ll_rw_block(READ, 1, &bh);
         *wait_bh++=bh;
     }
  }

__block_prepare_write 函数第二部分逐个遍历页面内所有的文件块。

  • 如果当前块不在写入的范围内,只顺便检查一下页面状态。页面状态为 PG_uptodate,说明块缓存的状态也必然为 BH_uptodate,因此设置块缓存的状态为最新。如果页面状态不是 PG_uptodate,跳转下一个文件块,并不试图获得文件块的物理块号。
  • 如果当前块在写入的地址__block_prepare_write 函数随后部分检查是否需要等待前面的读请求,代码如下:
while(wait_bh > wait) {
    wait_on_buffer(*--wait_bh);
    if (!buffer_uptodate(*wait_bh))
        err = -EIO;
}
if (!err) {
    bh = head;
    /*遍历块缓存,清除new标志*/
    do {
        if (buffer_new(bh))
            clear_buffer_new(bh);
    } while ((bh = bh->b_this_page) != head);
    return 0;

__block_prepare_write 函数第三部分检查前面的处理过程是否产生了读请求,有读请求必须等读完成,否则块缓存状态不是最新的,将产生一个错误。

ext2 文件系统提供的 commit_write 就是 generic_commit_write。它的作用是逐个遍历所有的文件块,检查块缓存是否最新。如果块缓存是最新内容,标记文件块缓存为 uptodate,同时设置块缓存为 dirty,标记着块缓存的内容需要写入硬盘。

如果所有的文件块缓存都是最新的,标记整个页面为 uptodate

文件写有可能导致文件长度变化。如果文件长度变化,则需要修改文件的长度。


10.7 本章小结

文件读写的过程比较复杂,涉及文件中一些复杂参数的计算和 page cache 中页面缓存的状态处理。读者可以将复杂问题形象化,自己设计一个文件,设置它的长度和需要读写的字节位置,对照代码进行推演,这样可以比较直观地理解文件的读写过程。