块设备的读写

2016-3-12 chenhui 设备驱动

阅读本文前,请先阅读:文件读写和页缓存 。这篇文章详细解释了文件读写的缓存,而在这篇文章的结尾,也因为涉及到块设备的读写并没有继续写下去,本文也算是对这篇文章的续写吧!


因是续写,所以先要续缓存页的回写步骤:

  1. 遍历 address_space 中的每一个脏页
  2. 把所有相邻的脏页加入一个 bio 对象, 提交 bio 对象
  3. 创建 request 对象, 记录 bio 对象, 并插入请求队列
  4. 调用驱动里定义的请求队列的回调函数, 处理请求队列里的 request, 写入数据
  5. 完。

本文写的很简略,目的在于帮助大家了解步骤,而不是了解细节。所以很多东西删掉后,可能会造成误解,但对大的整个流程的理解,还是有帮助的。


块设备是为应用程序提供存储空间的设备,块设备上设置一个文件系统,而文件系统又被 VFS(虚拟文件系统)管理,VFS 作为应用程序的直接交互者,他为文件的读写提供了一套缓存机制,缓存机制下面才是对块设备的读写。


我们知道,对文件的写操作,分为两种:一是同步写,二是异步写。第一种写法一般会在用户层自己做缓存时用到,他跳过了内核的缓存,直接提交给文件系统来写;第二种就是常用的,先写到页缓存里,并把页设为脏页,最后通过 pdflush 来刷新脏页。第二种用到的是最多的,文件读写和页缓存 介绍的也是第二种,所以本文在介绍完基础知识后,会从 pdflush 开始讲解。


我们知道,物理内存被 Page 结构体,也就是页描述符以 4K 为单位划分成多个页,而每一个页又可以起到多个不同的作用,比如作为缓存页,专门用来缓存某个文件的一部分。但是,虽然页描述符以 4K 为单位,但文件是存放在块设备上的,块设备的写单位不一定是 4K,而且基本上都小于 4K,这就意味着,我们需要以块设备写单位对缓存页进行分块,这样子我们就可以在刷新脏页(把脏页中的数据回写到磁盘内)时,更方便地操作缓存页。


Linux 中用来分块缓存页的结构体称为 buffer_head,一个 buffer_head 描述一个缓存页中的块,块的大小和块设备的写单位相等。我们假设块设备的写单位为 1K,那么一个缓存页将会被分成四个块,也就是对应四个 buffer_head,这四个 buffer_head 组成一个链表,而第一个 buffer_head 则存放在页描述符的 private 字段。


实际上,缓存页在映射文件时(嗯..说映射可能有些误解,就是把缓存页和一个文件的一部分对应,对缓存页的修改过段时间会写到文件内),他不是直接映射一个页的内容,原因很简单,就是因为块设备的写单位不一定和页大小相等。所以在映射时,实际上是映射一个 buffer_head。


下文以缓冲块来称呼 buffer_head 。


当然,对块设备的读写不会只有这一个,下面陆续会介绍到。

我们从 do_writepages() 这个函数重新开始,这个函数是 pdflush 执行的,详细可见:文件读写和页缓存 。下文会涉及到文件系统,这里以最简单的 EXT2 为例。


int do_writepages(struct address_space *mapping, struct writeback_control *wbc)
{
	...
	if (mapping->a_ops->writepages)//ext2_writepages 
		return mapping->a_ops->writepages(mapping, wbc);
	...
}


这个 a_ops->writepages() 指向的是 ext2_writepages()


static int
ext2_writepages(struct address_space *mapping, struct writeback_control *wbc)
{
	return mpage_writepages(mapping, wbc, ext2_get_block);
}


继续。。。


int mpage_writepages(struct address_space *mapping, 
struct writeback_control *wbc, get_block_t get_block)
{
	struct bio *bio = NULL;
	sector_t last_block_in_bio = 0;
	int ret = 0;
	int done = 0;
	int (*writepage)(struct page *page, struct writeback_control *wbc);
	struct pagevec pvec;
	int nr_pages;
	pgoff_t index;
	pgoff_t end = -1;	 
	
	// writepage = NULL
	writepage = NULL;
	if (get_block == NULL)
		writepage = mapping->a_ops->writepage;
		

	pagevec_init(&pvec, 0);

	...
	
retry:

	while (!done && (index <= end) &&
			(nr_pages = pagevec_lookup_tag(&pvec, mapping, &index,
			PAGECACHE_TAG_DIRTY,
			min(end - index, (pgoff_t)PAGEVEC_SIZE-1) + 1))) {
		// 每次取出若干个脏页存放到 pagevec 内	
			
		unsigned i;
 
		for (i = 0; i < nr_pages; i++) {
			//处理存放在 pagevec 内的脏页
			
			struct page *page = pvec.pages[i];
			
			...

			if (writepage) {
				...
			} else {
				// 把脏页记录在一个 bio 对象内
				
				bio = mpage_writepage(bio, page, get_block,
						&last_block_in_bio, &ret, wbc);
			}
			
			...
		}
		
		// 释放 pagevec
		pagevec_release(&pvec);
		cond_resched();
	}
	
	...
	 
	// 提交 BIO 对象,脏页数据就记录在 BIO 对象里了
	if (bio)
		mpage_bio_submit(WRITE, bio);
	return ret;
}


这个函数非常简单,他做下下面这几件事:

  1. 创建一个 pagevec 对象,这个对象可以存放 14 个 page 对象(页描述符)
  2. 遍历 address_space 中所有脏页,每次取出最多 14 个页描述符存放在 pagevec 对象内
  3. 把所有脏页都记录在一个 bio 对象内
  4. 提交 bio 对象


这里又涉及到一个 bio 对象,那么,这个 bio 对象又是干什么的?嗯...从他的名字上就可以看出来,Block IO,也就是块设备的读写的封装。实际上,内核把我们对块设备的读写操作都封装成一个 bio 对象,然后再放入块设备的一个请求队列里。这个请求队列叫做 struct reqeust_queue。


说到这里,我们应该再回味一下块设备驱动是怎么编写的。在 设备管理之块设备 中,我们提供了一份最简单的块设备驱动源代码,并给出了详细解释。读者可以仔细去阅读,这里仅稍微再提起一部分关键信息。




在这个块设备驱动的入口函数 ramblock_init() 中,他使用 do_ramblock_request() 这个函数申请了一个 request_queue_t,实际上这个 requeue_queue_t 就是 request_queue 的 typedef,也就是说这个就是块设备的请求队列。而这个 do_ramblock_request() 就是请求队列的处理函数。在内核经过一系列调度后,他会依次取出请求队列中的请求并调用 do_ramblock_request() 来处理他们。


那么,内核是怎么把请求交给他的呢?暂且先不谈,我们继续来看内核怎么把脏页加入到 bio 对象内。


static struct bio *
mpage_writepage(struct bio *bio, struct page *page, get_block_t get_block,
	sector_t *last_block_in_bio, int *ret, struct writeback_control *wbc)
{
	const unsigned blkbits = inode->i_blkbits;
	const unsigned blocks_per_page = PAGE_CACHE_SIZE >> blkbits;
	unsigned first_unmapped = blocks_per_page;
	sector_t blocks[MAX_BUF_PER_PAGE];
	unsigned page_block;
	struct block_device *bdev = NULL;
	int length;
	
	if (page_has_buffers(page)) {
		 
		// 对于缓存页而言,private 存放的就是 buffer_head
		// 这段代码计算脏页中哪些缓冲块是脏的, 并在 blocks 中记录这个脏块为了简略篇幅, 这里假设所有的缓冲块都是已映射且脏的
		struct buffer_head *head = page_buffers(page);
		struct buffer_head *bh = head;

		page_block = 0;
		do {
			...
			
			
			// 记录下缓冲块对应的设备块的块号
			blocks[page_block++] = bh->b_blocknr;
			
			// 取出块设备
			bdev = bh->b_bdev;
		} while ((bh = bh->b_this_page) != head);

		if (first_unmapped)
			goto page_is_mapped;

		 
		...
	}

	...

page_is_mapped:

	...
 
alloc_new:

	// 创建BIO
	if (bio == NULL) {
		bio = mpage_alloc(bdev, blocks[0] << (blkbits - 9),
				bio_get_nr_vecs(bdev), GFP_NOFS|__GFP_HIGH);
		if (bio == NULL)
			goto confused;
	}
	
	//4*1024 = 4K
	length = first_unmapped << blkbits;
	 
	// 把脏页添加到 bio, 但若不相邻, 则提交 bio, 然后别的页继续用新的 bio
	if (bio_add_page(bio, page, length, 0) < length) {
		bio = mpage_bio_submit(WRITE, bio);
		goto alloc_new;
	}

	...
	
	if (boundary || (first_unmapped != blocks_per_page)) {
		...
	} else {
		// 缓存页中最后一个缓冲块对应的块号
		*last_block_in_bio = blocks[blocks_per_page - 1];
	}
	goto out;

	...
out:
	return bio; 
}


这个函数非常长,我删除了很多行甚至比较重要的代码,目的是为了更容易地理解块设备的读写流程。简单的说,他和 mpage_writepages() 结合起来看就是:第一次进入 mpage_writepage() 添加脏页时,肯定是没有 bio 的,这个时候就会先创建 bio,再把脏页的信息记录进去;第二次为了添加脏页而进入时,就有 bio 了,这个时候就会直接把脏页信息添加到 bio 内。


和 bio 配套的数据结构是 bio_vec,每添加一个脏页到 bio 内就会生成一个新的 bio_vec,这个 bio_vec 记录了脏页的页描述符和脏页的数据范围。怎么添加的,就不进去分析了,感兴趣的可以自己去看。


这个函数结束后,所有的脏页都被记录到一个 bio 对象内。接下来就是提交这个 bio 对象。


struct bio *mpage_bio_submit(int rw, struct bio *bio)
{
	
	bio->bi_end_io = mpage_end_io_read;
	//如果是读方向,那么IO完成后调用mpage_end_io_read
	
	if (rw == WRITE)
		//如果是写方向,那么IO完成后调用mpage_end_io_write
		bio->bi_end_io = mpage_end_io_write;

    submit_bio(rw, bio);
    //提交
    
    return NULL;
}


继续。。。


void submit_bio(int rw, struct bio *bio)
{

	bio->bi_rw = rw; 
	//记录读写方式 
	...

	generic_make_request(bio);
}


实际上,bio 也不是直接就插到请求队列里面去,而是先尝试和请求队列里已有的、相邻的 bio 进行合并,再进行排序,这个过程我们称为“调度”。从 generic_make_request() 开始,干的就是调度的工作。


void generic_make_request(struct bio *bio)
{

	request_queue_t *q;
	sector_t maxsector;
	int ret;
     
	...
	
	do { 	
		char b[BDEVNAME_SIZE];	

		q = bdev_get_queue(bio->bi_bdev);
		//获取块设备的请求队列。
			
		...

		block_wait_queue_running(q);
		//等待IO调度就绪。

		ret = q->make_request_fn(q, bio);
		//请BIO请求传递给IO调度层,或者说把新的IO请求插入其请求队列。
		//常见块设备的回调函数是 __make_request
		
	} while (ret); 
}


执行调度的就是 q->make_request() 这个函数。这个函数的设置可见  设备管理之块设备 的 blk_init_queue_node() 函数,这个函数里设置了 __make_request() 为回调函数,也正是 __make_request(),开始对 bio 进行调度。


static int __make_request(request_queue_t *q, struct bio *bio)
{
	struct request *req, *freereq = NULL;
	int el_ret, rw, nr_sectors, cur_nr_sectors, barrier, err;
	sector_t sector;

    ...
	// BIO 中, 读写的第一个设备块号
	sector = bio->bi_sector;
	// BIO 中, 读写的设备块数
	nr_sectors = bio_sectors(bio);
	// BIO 是读, 还是写?
	rw = bio_data_dir(bio);
again:

	... // 省略一大堆调度代码

get_rq:
	if (freereq) {
		
		...
	} else {
		spin_unlock_irq(q->queue_lock);

		if ((freereq = get_request(q, rw, GFP_ATOMIC)) == NULL) {
			// 创建一个请求描述符
			
			...
		}
		goto again;
	}

	... // 省略大堆关于 bio 对 request 的初始化

	// BIO 读写的开始设备块号
	req->hard_sector = req->sector = sector;
	// BIO 读写的设备块数
	req->hard_nr_sectors = req->nr_sectors = nr_sectors;
	// BIO 读写的开始地址
	req->buffer = bio_data(bio);

	req->bio = req->biotail = bio;
	// 把请求加入请求队列
	add_request(q, req);
out:
	
	...
	return 0;

	...
}
	 
我们并不关心他是怎么进行调度的,我们只关心他最后是怎么让块设备执行读写的。


接下来就是 add_request(),他把请求插入了请求队列。


static inline void add_request(request_queue_t * q, struct request * req)
{
	...
	__elv_add_request(q, req, ELEVATOR_INSERT_SORT, 0); 
}


继续。。。


void __elv_add_request(request_queue_t *q, struct request *rq, int where,
		       int plug)
{

	...

	rq->q = q;

	if (!test_bit(QUEUE_FLAG_DRAIN, &q->queue_flags)) {
		
		// 插入队列
		q->elevator->ops->elevator_add_req_fn(q, rq, where);
		
		// 处理请求
		if (blk_queue_plugged(q)) {
			int nrq = q->rq.count[READ] + q->rq.count[WRITE]
				  - q->in_flight;

			if (nrq == q->unplug_thresh)
				__generic_unplug_device(q);
		}
	} else
		list_add_tail(&rq->queuelist, &q->drain_list);
}


这里的插入队列的操作是由回调函数实现的,这是因为,调度算法有很多种,也就有着不同的调度器。

内核中带了一个 noop 调度器,嗯.. 其实就是没有调度,对于这个调度器而言,elevator_add_req_fn() 指向的是 elevator_noop_add_request()。


static void elevator_noop_add_request(request_queue_t *q, struct request *rq,
				      int where)
{
	if (where == ELEVATOR_INSERT_FRONT)
		list_add(&rq->queuelist, &q->queue_head);
	else
		list_add_tail(&rq->queuelist, &q->queue_head);

	...
}


嗯... 你看,没有调度。就是直接把请求插到请求队列里去。

最后... 就是块设备的处理了。


void __generic_unplug_device(request_queue_t *q)
{
	...
	
	// 只要请求队列里还有请求,就调用块设备的回调函数来处理请求
	if (elv_next_request(q))
		q->request_fn(q);
}


完结!!!这个 request_fn() 就是我们自己定义的请求处理函数!也就是上面的 do_ramblock_request()。

如果我们的是 nand flash 的块设备驱动,那么就应该在这里对 nand flash 进行读写,如果是其他的块设备,那就用相应的操作即可!





发表评论:

Copyright ©2015-2016 freehui All rights reserved