fuzidage
专注嵌入式、linux驱动 、arm裸机研究

导航

 

1 块设备驱动简介

块设备是针对存储设备的,比如 SD 卡、EMMC、NAND Flash、Nor Flash、SPI Flash、机械硬盘、固态硬盘等。因此块设备驱动其实就是这些存储设备驱动。块设备驱动比字符设备复杂,不同类型的存储设备又对应不同的驱动子系统,如MTD(memory technology device内存技术设备)是用于访问memory设备(RAM、ROM、flash)的Linux的子系统,又例如mmc子系统如下图:

image

1.1 块设备特点

块设备驱动相比字符设备驱动的主要区别如下:

读写单位 是否支持随机访问 是否支持缓冲区
块设备 块为单位 支持 支持
字符设备 字节为单位 不支持 不支持

块设备使用缓冲区来暂时存放数据,等到条件成熟以后再一次性将缓冲区中的数据写入块设备中。 这么做的目的为了提高块设备寿命,有些硬盘或者 NAND Flash 就会标明擦除次数(flash 的特性,写之前要先擦除),比如擦除 100000 次等。因此,为了提高块 设备寿命引入了缓冲区,数据先写入到缓冲区中,等满足一定条件后再一次性写入到真正的物理存储设备中,这样就减少了对块设备的擦除次数,提高了块设备寿命。

不同的存储其 I/O 算法也会不同,比如对于 EMMC、SD 卡、NAND Flash 这类没有 任何机械设备的存储设备就可以任意读写任何的扇区(块设备物理存储单元)。

2 块设备驱动框架

2.1 数据结构和块设备API

2.1.1 block_device

通常不会直接去放问存储块设备,而是通过文件系统的方式去访问,比如:open(“filename”); write(fd, buf, len);

block_device定义在include/linux/fs.h

struct block_device {
	ev_t bd_dev; /* not a kdev_t - it's a search key */
	nt bd_openers;
	truct inode *bd_inode; /* will die */
	truct super_block *bd_super;
	truct mutex bd_mutex; /* open/close mutex */
	truct list_head bd_inodes;
	oid * bd_claiming;
	oid * bd_holder;
	int bd_holders;
	bool bd_write_holder;
	#ifdef CONFIG_SYSFS
	struct list_head bd_holder_disks;
	#endif
	struct block_device *bd_contains;
	unsigned bd_block_size;
	struct hd_struct *bd_part;
	/*number of times partitions within this device have been opened.*/
	unsigned bd_part_count;
	int bd_invalidated;
	struct gendisk *bd_disk;
	struct request_queue *bd_queue;
	struct list_head bd_list;
	/*
	* Private data. You must have bd_claim'ed the block_device
	* to use this. NOTE: bd_claim allows an owner to claim
	* the same device multiple times, the owner must take special
	* care to not mess up bd_private for that case.
	*/
	unsigned long bd_private;
	/* The counter of freeze processes */
	int bd_fsfreeze_count;
	/* Mutex for freeze */
	struct mutex bd_fsfreeze_mutex;
};

内核使用 block_device 来表示一个具体的块设备对象,比如一个硬盘或者分区。bd_disk成员是一个gendisk 结构体指针类型,如果是硬盘的话 bd_disk 就指向通用磁盘结构 gendisk。

2.1.1.1 注册/注销块设备

int register_blkdev(unsigned int major, const char *name);

向内核注册新的块设备、申请设备号,块设备注册函数为 register_blkdev。

major:主设备号(1-255)如果填0表示动态分配主设备号。
name:块设备名字
返回值:如果major不是0,那么成功范围0,失败返回错误码。如果major传入0,那么返回值表示主设备号。

void unregister_blkdev(unsigned int major, const char *name);

2.1.2 gendisk

gendisk 来描述一个磁盘设备。定义在 include/linux/genhd.h

struct gendisk {
	/* major, first_minor and minors are input parameters only,
	* don't use directly. Use disk_devt() and disk_max_parts().
	*/
	int major; /* major number of driver */
	int first_minor;
	int minors; /* maximum number of minors, =1 for
	* disks that can't be partitioned. */
	char disk_name[DISK_NAME_LEN]; /* name of major driver */
	char *(*devnode)(struct gendisk *gd, umode_t *mode);
	unsigned int events; /* supported events */
	unsigned int async_events; /* async events, subset of all */
	/* Array of pointers to partitions indexed by partno.
	* Protected with matching bdev lock but stat and other
	* non-critical accesses use RCU. Always access through
	* helpers.
	*/
	struct disk_part_tbl __rcu *part_tbl;
	struct hd_struct part0;
	const struct block_device_operations *fops;
	struct request_queue *queue;
	void *private_data;
	int flags;
	struct device *driverfs_dev; // FIXME: remove
	struct kobject *slave_dir;
	struct timer_rand_state *random;
	atomic_t sync_io; /* RAID */
	struct disk_events *ev;
	#ifdef CONFIG_BLK_DEV_INTEGRITY
	struct blk_integrity *integrity;
	#endif
	int node_id;
}

major:磁盘设备的主设备号。
first_minor:磁盘的第一个次设备号。
minors :磁盘的次设备号数量,也就是磁盘的分区数量,这些分区的主设备号一 样,次设备号不同。
part_tbl:磁盘对应的分区表,为结构体 disk_part_tbl 类型,disk_part_tbl 的核心 是一个 hd_struct 结构体指针数组,此数组每一项都对应一个分区信息。
fops:块设备操作集,为 block_device_operations 结构体类型。和字符设备操作 集 file_operations 一样,是块设备驱动中的重点!
queue:磁盘对应的请求队列,所以针对该磁盘设备的请求都放到此队列中,驱动程序需要处理此队列中的所有请求

2.1.2.1 申请/删除 gendisk

struct gendisk *alloc_disk(int minors);

minors:次设备号数量,也就是 gendisk 对应的分区数量。
返回值:成功:返回申请到的 gendisk,失败:NULL。

void del_gendisk(struct gendisk *gp);

2.1.2.2 将 gendisk 添加到内核

将申请到的 gendisk 添加到内核中。

void add_disk(struct gendisk *disk);

2.1.2.3 设置 gendisk 容量

void set_capacity(struct gendisk *disk, sector_t size);

disk:要设置容量的 gendisk。
size:磁盘容量大小,注意这里是扇区数量。块设备中最小的可寻址单元是扇区,一个扇区 一般是 512 字节,有些设备的物理扇区可能不是 512 字节。不管物理扇区是多少,内核和块设备驱动之间的扇区都是 512 字节。所以 set_capacity 函数设置的大小就是块设备实际容量除以 512 字节得到的扇区数量。比如一个 2MB 的磁盘,其扇区数量就是(2*1024*1024)/512=4096

2.1.2.4 调整 gendisk 引用计数

truct kobject *get_disk(struct gendisk *disk);//增加 gendisk 的引用计数
void put_disk(struct gendisk *disk);//减少 gendisk 的引用计数

2.1.3 block_device_operations(块设备操作集)

//和字符设备的 file _operations 一样,块设备也有操作集。include/linux/blkdev.h
struct block_device_operations {
	int (*open) (struct block_device *, fmode_t);//open 函数用于打开指定的块设备
	void (*release) (struct gendisk *, fmode_t);
	int (*rw_page)(struct block_device *, sector_t, struct page *, int rw);//rw_page 函数用于读写指定的页
	int (*ioctl) (struct block_device *, fmode_t, unsigned, unsigned long);
	int (*compat_ioctl) (struct block_device *, fmode_t, unsigned, unsigned long);
	long (*direct_access)(struct block_device *, sector_t, void **, unsigned long *pfn, long size);
	unsigned int (*check_events) (struct gendisk *disk, unsigned int clearing);
	/* ->media_changed() is DEPRECATED, use ->check_events() instead */
	int (*media_changed) (struct gendisk *);
	void (*unlock_native_capacity) (struct gendisk *);
	int (*revalidate_disk) (struct gendisk *);
	int (*getgeo)(struct block_device *, struct hd_geometry *);//getgeo 函数用于获取磁盘信息,包括磁头、柱面和扇区等信息。
	/* this callback is with swap_lock and sometimes page table lock held */
	void (*swap_slot_free_notify) (struct block_device *, unsigned long);
	struct module *owner;
};

2.1.4 块设备 I/O 请求过程

仔细点可以看到块设备fops中并没有read,write函数。那么块设备是怎么从物理块设备中读写数据?

2.1.4.1 请求队列 request_queue

在内核中,对块设备的读写都会发送到请求队列 request_queue,request_queue是request的集合,request里面包含bio结构,bio 保存了读写相关数据,比如从块设备的哪个 地址开始读取、读取的数据长度,读取到哪里,如果是写的话还包括要写入的数据等。这些数据结构都定义在:include/linux/blkdev.h

2.1.4.1.1 初始化请求队列
  1. 申请并初始化一个 request_queue,申请成功后需把request_queue地址赋值给 gendisk 的 queue 成员变量。

request_queue *blk_init_queue(request_fn_proc *rfn, spinlock_t *lock);

rfn:请求处理函数,原型为:void (request_fn_proc) (struct request_queue *q);驱动人员需要自己定义实现请求处理函数。
lock:自旋锁,需要驱动编写人员定义一个自旋锁,然后传递进来。,请求队列会使用 这个自旋锁。
返回值: 成功返回请求队列指针,失败返回NULL。

  1. blk_init_queue的请求队列有去绑定请求处理函数,这个一般用于像机械硬盘这样的存储设备,需要 I/O 调度器来优化数据读写过程。但是对于 EMMC、SD 卡这样的非机械设备,可以进行完全随机访问,所以就不需要复杂的 I/O 调度器了。因此可以直接申请request_queue:

struct request_queue *blk_alloc_queue(gfp_t gfp_mask);

gfp_mask:内存分配掩码,具体可选择的掩码值请参考 include/linux/gfp.h 中的相关宏定义, 一般为 GFP_KERNEL。
返回值:申请到的无 I/O 调度的 request_queue。

2.1.4.1.2 删除请求队列

当卸载块设备驱动的时候还需要删除请求队列。

void blk_cleanup_queue(struct request_queue *q);

2.1.4.1.3 请求队列绑定制造请求函数

void blk_queue_make_request(struct request_queue *q, make_request_fn *mfn);

q:需要绑定的请求队列,也就是 blk_alloc_queue 申请到的请求队列。
mfn:“制造”请求函数,原型如下: void (make_request_fn) (struct request_queue *q, struct bio *bio);“制造请求”函数需要驱动编写人员实现。

2.1.4.2 请求 request

以我们需要从 request_queue 中取出一个一个的 request,然后再从每个 request 里面取出 bio, 最后根据 bio 的描述讲数据写入到块设备,或者从块设备中读取数据。

2.1.4.2.1 获取请求

request *blk_peek_request(struct request_queue *q);

从request_queue中依次获取每个request。

2.1.4.2.2 开启请求

void blk_start_request(struct request *req);

2.1.4.2.3 一次性获取和开启请求
struct request *blk_fetch_request(struct request_queue *q) {
	struct request *rq;
	rq = blk_peek_request(q);
	if (rq)
		blk_start_request(rq);
	return rq;
}

2.1.4.3 请求有关的函数

函数 描述
blk_end_request() 请求中指定字节数据被处理完成
blk_end_request_all() 请求中所有数据全部处理完成
blk_end_request_cur() 当前请求中的 chunk
blk_end_request_err() 处理完请求,直到下一个错误产生
__blk_end_request() 和 blk_end_request 函数一样,但是需要持有队列锁
__blk_end_request_all() 和 blk_end_request_all 函数一样,但是需要持有队列锁
__blk_end_request_cur() 和 blk_end_request_cur 函数一样,但是需要持有队列锁
__blk_end_request_err() 和 blk_end_request_err 函数一样,但是需要持有队列锁

2.1.5 bio

每个 request 里面会有多个 bio,bio 保存着最终要读写的数据、地址等信息。

image

bio 结构描述了要读写的起始扇区、要读写的扇区数量、是读取还是写入、页偏移、数据长度等等信息.

struct bio {
	struct bio *bi_next; /* 请求队列的下一个 bio */
	struct block_device *bi_bdev; /* 指向块设备 */
	unsigned long bi_flags; /* bio 状态等信息 */
	unsigned long bi_rw; /* I/O 操作,读或写 */
	struct bvec_iter bi_iter; /* I/O 操作,读或写 */
	unsigned int bi_phys_segments;
	unsigned int bi_seg_front_size;
	unsigned int bi_seg_back_size;
	atomic_t bi_remaining;
	bio_end_io_t *bi_end_io;
	void *bi_private;
	#ifdef CONFIG_BLK_CGROUP
	/*
	* Optional ioc and css associated with this bio. Put on bio
	* release. Read comment on top of bio_associate_current().
	*/
	struct io_context *bi_ioc;
	struct cgroup_subsys_state *bi_css;
	#endif
	union {
	#if defined(CONFIG_BLK_DEV_INTEGRITY)
	struct bio_integrity_payload *bi_integrity;
	#endif
	};
	unsigned short bi_vcnt; /* bio_vec 列表中元素数量 */
	unsigned short bi_max_vecs; /* bio_vec 列表长度 */
	atomic_t bi_cnt; /* pin count */
	struct bio_vec *bi_io_vec; /* bio_vec 列表 */
	struct bio_set *bi_pool;
	struct bio_vec bi_inline_vecs[0];
};

bvec_iter和bi_io_vec结构体

//设备扇区等信息
struct bvec_iter {
    sector_t bi_sector; /* I/O 请求的设备起始扇区(512 字节) */
    unsigned int bi_size; /* 剩余的 I/O 数量 */
    unsigned int bi_idx; /* blv_vec 中当前索引 */
    unsigned int bi_bvec_done; /* 当前 bvec 中已经处理完成的字节数 */
};
//页,长度,偏移信息
struct bio_vec {
	struct page *bv_page; /* 页 */
	unsigned int bv_len; /* 长度 */
	unsigned int bv_offset; /* 偏移 */
};

bio和bvec_iter,bio_vec结构关系:

image

2.1.5.1 bio处理

2.1.5.1.1 遍历请求中的bio

遍历 请求中的 bio 使用函数__rq_for_each_bio:

#define __rq_for_each_bio(_bio, rq) \
if ((rq->bio)) \
	for (_bio = (rq)->bio; _bio; _bio = _bio->bi_next)

_bio 就是遍历出来的每个 bio,rq 是要进行遍历操作的请求,_bio 参数为 bio 结构体指针类 型,rq 参数为 request 结构体指针类型。

2.1.5.1.2 遍历 bio 中的所有段

bio 包含了最终要操作的数据,因此还需要遍历 bio 中的所有段,这里要用到 bio_for_each_segment 函数:

#define bio_for_each_segment(bvl, bio, iter) \
	__bio_for_each_segment(bvl, bio, iter, (bio)->bi_iter)

第一个 bvl 参数就是遍历出来的每个 bio_vec,第二个 bio 参数就是要遍历的 bio,类型为 bio 结构体指针,第三个 iter 参数保存要遍历的 bio 中 bi_iter 成员变量。

2.1.5.1.3 通知 bio 处理结束

void bio_endio(struct bio *bio, int error);
bio:要结束的 bio
error:如果 bio 处理成功的话就直接填 0,如果失败的话就填个负值,比如-EIO。

3 块设备驱动实验

3.1 使用请求队列实验

使用开发板上的 RAM 模拟一段块设备,也就是 ramdisk,然后编写块设备驱动。
参考linux 内核 drivers/block/z2ram.c

3.1.1 块设备驱动模块源码编写

#include <linux/types.h>
#include <linux/kernel.h>
#include <linux/delay.h>
#include <linux/ide.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/errno.h>
#include <linux/gpio.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/of_gpio.h>
#include <linux/semaphore.h>
#include <linux/timer.h>
#include <linux/i2c.h>
#include <linux/genhd.h>
#include <linux/blkdev.h>
#include <linux/hdreg.h>
#include <asm/mach/map.h>
#include <asm/uaccess.h>
#include <asm/io.h>
#define RAMDISK_SIZE	(2 * 1024 * 1024) 	/* 容量大小为2MB */
#define RAMDISK_NAME	"ramdisk"			/* 名字 */
#define RADMISK_MINOR	3					/* 表示有三个磁盘分区!不是次设备号为3! */

/* ramdisk设备结构体 */
struct ramdisk_dev{
	int major;					/* 主设备号 */
	unsigned char *ramdiskbuf;	/* ramdisk内存空间,用于模拟块设备 */
	spinlock_t lock;			/* 自旋锁 */
	struct gendisk *gendisk; 	/* gendisk */
	struct request_queue *queue;/* 请求队列 */

};

struct ramdisk_dev ramdisk;		/* ramdisk设备 */

int ramdisk_open(struct block_device *dev, fmode_t mode)
{
	printk("ramdisk open\r\n");
	return 0;
}
void ramdisk_release(struct gendisk *disk, fmode_t mode)
{
	printk("ramdisk release\r\n");
}

int ramdisk_getgeo(struct block_device *dev, struct hd_geometry *geo)
{
	/* 这是相对于机械硬盘的概念 */
	geo->heads = 2;			/* 磁头 */
	geo->cylinders = 32;	/* 柱面 */
	geo->sectors = RAMDISK_SIZE / (2 * 32 *512); /* 一个磁道上的扇区数量 */
	return 0;
}
static struct block_device_operations ramdisk_fops =
{
	.owner	 = THIS_MODULE,
	.open	 = ramdisk_open,
	.release = ramdisk_release,
	.getgeo  = ramdisk_getgeo,
};

static void ramdisk_transfer(struct request *req)
{	
	unsigned long start = blk_rq_pos(req) << 9;  	/* blk_rq_pos获取到的是扇区地址,左移9位转换为字节地址 */
	unsigned long len  = blk_rq_cur_bytes(req);		/* 大小   */

	/* bio中的数据缓冲区
	 * 读:从磁盘读取到的数据存放到buffer中
	 * 写:buffer保存这要写入磁盘的数据
	 */
	void *buffer = bio_data(req->bio);		
	
	if(rq_data_dir(req) == READ) 		/* 读数据 */	
		memcpy(buffer, ramdisk.ramdiskbuf + start, len);
	else if(rq_data_dir(req) == WRITE) 	/* 写数据 */
		memcpy(ramdisk.ramdiskbuf + start, buffer, len);

}
void ramdisk_request_fn(struct request_queue *q)
{
	int err = 0;
	struct request *req;

	/* 循环处理请求队列中的每个请求 */
	req = blk_fetch_request(q);
	while(req != NULL) {

		/* 针对请求做具体的传输处理 */
		ramdisk_transfer(req);

		/* 判断是否为最后一个请求,如果不是的话就获取下一个请求
		 * 循环处理完请求队列中的所有请求。
		 */
		if (!__blk_end_request_cur(req, err))
			req = blk_fetch_request(q);
	}
}
static int __init ramdisk_init(void)
{
	int ret = 0;
	/* 1、申请用于ramdisk内存 */
	ramdisk.ramdiskbuf = kzalloc(RAMDISK_SIZE, GFP_KERNEL);
	if(ramdisk.ramdiskbuf == NULL) {
		ret = -EINVAL;
		goto ram_fail;
	}
	/* 2、初始化自旋锁 */
	spin_lock_init(&ramdisk.lock);
	/* 3、注册块设备 */
	ramdisk.major = register_blkdev(0, RAMDISK_NAME); /* 由系统自动分配主设备号 */
	if(ramdisk.major < 0) {
		goto register_blkdev_fail;
	}  
	printk("ramdisk major = %d\r\n", ramdisk.major);
	/* 4、分配并初始化gendisk */
	ramdisk.gendisk = alloc_disk(RADMISK_MINOR);
	if(!ramdisk.gendisk) {
		ret = -EINVAL;
		goto gendisk_alloc_fail;
	}
	/* 5、分配并初始化请求队列 */
	ramdisk.queue = blk_init_queue(ramdisk_request_fn, &ramdisk.lock);
	if(!ramdisk.queue) {
		ret = EINVAL;
		goto blk_init_fail;
	}
	/* 6、添加(注册)disk */
	ramdisk.gendisk->major = ramdisk.major;		/* 主设备号 */
	ramdisk.gendisk->first_minor = 0;			/* 第一个次设备号(起始次设备号) */
	ramdisk.gendisk->fops = &ramdisk_fops; 		/* 操作函数 */
	ramdisk.gendisk->private_data = &ramdisk;	/* 私有数据 */
	ramdisk.gendisk->queue = ramdisk.queue;		/* 请求队列 */
	sprintf(ramdisk.gendisk->disk_name, RAMDISK_NAME); /* 名字 */
	set_capacity(ramdisk.gendisk, RAMDISK_SIZE/512);	/* 设备容量(单位为扇区) */
	add_disk(ramdisk.gendisk);
	return 0;
blk_init_fail:
	put_disk(ramdisk.gendisk);
	//del_gendisk(ramdisk.gendisk);
gendisk_alloc_fail:
	unregister_blkdev(ramdisk.major, RAMDISK_NAME);
register_blkdev_fail:
	kfree(ramdisk.ramdiskbuf); /* 释放内存 */
ram_fail:
	return ret;
}

static void __exit ramdisk_exit(void)
{
	/* 释放gendisk */
	del_gendisk(ramdisk.gendisk);
	put_disk(ramdisk.gendisk);
	/* 清除请求队列 */
	blk_cleanup_queue(ramdisk.queue);
	/* 注销块设备 */
	unregister_blkdev(ramdisk.major, RAMDISK_NAME);
	/* 释放内存 */
	kfree(ramdisk.ramdiskbuf); 
}
module_init(ramdisk_init);
module_exit(ramdisk_exit);
MODULE_LICENSE("GPL");

3.1.2 分析

3.1.2.1 块设备驱动注册过程

image

  1. 调用register_blkdev注册块设备,得到主设备号

  2. alloc_diskadddisk申请和将gendisk添加到内核

    1. 初始化gendisk, 3个次设备号,gendisk 对应的分区数量为3
    2. 利用request_queue,初始化请求队列
    3. 设置gendisk属性,包括设备号,fops, 磁盘对应的请求队列。
    4. 设置gendisk容量为2M。注意大小是扇区数,不是字节数,一个扇区是 512 字节
    5. 将gendisk注册到内核。

3.1.2.2 块设备驱动卸载过程

image

3.1.2.3 块设备驱动读写过程

读写通过请求处理函数完成:

image

  1. ramdisk_request_fn主要工作就是依次处理请求队列中的所有请求。
  2. blk_fetch_request获取请求队列中第一个请求,如果请求不为空的话就调用 ramdisk_transfer 函数进行对请求做进一 步的处理,然后就是 while 循环依次处理完请求队列中的每个请求。
  3. __blk_end_request_cur 检查是否为最后一个请求,如果不是的话就继续获取下一个,直至整个请求队列处理完成。

再来看ramdisk_transfer:

针对一次请求做具体的数据传输。

  1. 首先要获取要访问的磁盘地址和大小。
  2. bio_data获取bio的数据缓冲区。
  3. 从磁盘对应地址读出数据到缓冲区或者写入数据到磁盘对应地址。

3.1.3 运行测试

编译改驱动成ramdisk.ko。

depmod //第一次加载驱动的时候需要运行此命令
modprobe ramdisk.ko //加载驱动模块

动加载成功以后就会在/dev/目录下生成一个名为“ramdisk”的设备:
fdisk -l//查看磁盘信息
image

可以看出,ramdisk 已经识别出来了,大小为 2MB,但是同时也提示/dev/ramdisk 没有分区表,因为我们还没有格式化/dev/ramdisk。

使用 mkfs.vfat 命令格式化/dev/ramdisk,将其格式化成 vfat 格式:
mkfs.vfat /dev/ramdisk

格式化完后挂载到/tmp目录下:mount /dev/ramdisk /tmp。挂载成功以后就可以通过/tmp 来访问 ramdisk 这个磁盘。

3.2 不使用请求队列实验

blk_init_queue的请求队列有去绑定请求处理函数,这个一般用于像机械硬盘这样的存储设备,需要 I/O 调度器来优化数据读写过程。但是对于 EMMC、SD 卡这样的非机械设备,可以进行完全随机访问,所以就不需要复杂的 I/O 调度器了。因此可以直接申请request_queue。

image

  1. 这里使用blk_alloc_queue分配一个request_queue。通过blk_queue_make_request让请求队列绑定一个”制造请求“函数。

  2. 制造请求函数:

image

ramdisk_make_request_fn 里面是全部是对 bio 的操作,所有的处理内容都在 bio 参数里面。再次打开bio的结构图:

image

①读取 bio 的 bi_iter 成员变量的 bi_sector 来获取要操作的设备地址(扇区)
②处理每个段
③调用 bio_endio 函数,结束 bio。

posted on 2024-05-29 16:41  fuzidage  阅读(53)  评论(0编辑  收藏  举报