本节还存在问题:
问题一:为什么开辟的内存是0扇区,下文中有具体说明。
问题二:块设备能不能像字符设备一样进行,read,write,下文中有详细说明。
问题三:对于整体的块设备的体系结构开不是很透彻,应该再看看<linux内核注释> 和 <linux内核设计与实现>两本书的块设备部分
本节知识点:
预备知识:
1.块设备与字符设备的区别:a.块设备和字符设备的读取单元不同,块设备是以一块为基本单元进行读写的,一般是512字节,字符设备是以字节为单位进行读写的。
b.块设备可以随机访问(随机对各个块进行访问) 字符设备必须按照顺序访问(按照地址进行访问)
2.块设备在linux中的体系架构:
当访问块设备文件的时候,linux先调用VFS虚拟文件系统,先在硬盘缓存中去寻找有没有要访问的数据,如果有就直接返回,没有就继续调用下面的文件系统或设备文件。然后在Mapping layer层来寻找文件的inode,通过inode让操作系统找到数据在磁盘上的逻辑地址,即sector。Generic Block Layer层是通用块设备层,这个层把块设备分成若干个扇区,并处理上层的读写请求,变成若干个bio结构。IO scheduler layer层针对,各种机械存储设备(硬盘),进行IO请求的调度,如电梯调度算法。Block Device Driver 块设备驱动,负责对硬件进行控制。
总结一下:VFS:负责统一文件接口
Mapping Layer:负责通过inode找到sector
Generic Block Layer:负责分扇区和处理上层读写请求
IO scheduler layer:负责IO请求进行调度
重点函数:
1、描述块设备的结构
struct gendisk
{
int major; // 主设备号
int first_minor; //第一个设备的次设备号
int minor; //有多少设备 一般是在申请块设备函数alloc_disk函数中填写的
char disk_name[DISK_NAME_LEN];//驱动名 /dev路径下的名
struct block_device_operations *fops;// 块设备处理函数
struct request_queue *queue;//IO请求队列
}
2、set_capacity(struct gendisk *gd, MY_BLKDEV_BYTES>>9) 这个函数的第一个参数是 块设备结构 第二个参数是开辟的内存除上512 应该是有多少个块 这个函数的功能应该是告诉内核这个块设备有多少个块
3、alloc_disk(minor) 对于块设备的申请要使用这个函数,不能自己直接给上面的那个结构体赋值,这个函数的参数是 此类块设备有多少个设备
4、add_disk(struct gendisk *gd) 把块设备添加到linux内核中去 如果在内核配置的时候安装了udev 这个函数可以在/dev目录下直接产生块设备文件
5、del_gendisk(struct gendisk *gd) 把块设备从内核中卸载掉
6、IO请求的结构
struct request
{
struct list_head queuelist;//链表结构
sector_t sector;//要操作的首扇区
unsigned long nr_sectors;//要操作的扇区数目
struct bio* bio;//请求的bio结构体的链表
struct bio* biotail;//请求的bio结构体的链表尾
}
request IO请求和bio结构的关系就是 request是操作系统根据IO调度机制把一个一个的bio(一次块设备请求)组成成一个链表 就变成了一次IO请求了 好多个request IO请求就变成了request_queue IO请求队列
7、struct request_queue *blk_init_queue(request_fn_proc *rfn,spinlock_t *lock) 该函数初始化IO请求队列(是用在想要使用linuxIO调度器的情况下的,如访问硬盘等) 第一个参数是处理IO请求队列的函数,第二个参数是自旋锁 返回值是IO请求队列
8、blk_cleanup_queue(request_queue *p) 清楚IO请求队列
9、struct request *elv_next_request(request_queue *q) 通过当前请求队列 去寻找下一个IO请求 (我觉得应该是在请求队列中 用一个链表保存了所有的IO请求和当前IO请求)
10、void blkdev_dequeue_request(request_queue *q) 从请求队列中 删除当前IO请求
11、end_request(req,1) 次函数非常重要 我出了问题 就是在这个函数上面 此函数功能是结束当前IO请求 如果成功第二个参数写1,如果不成功写0。1和0影响很大,千万别写错了,第一个参数是当前IO请求。
上面的函数是使用在 使用linuxIO调度器的情况的,如访问硬盘设备
下面的函数是使用在 无linuxIO调度器的情况 如U盘 内存
12、一次块设备IO请求结构
struct bio
{
sector_t bi_sector;//要访问的第一个扇区
unsigned int bi_size;//以字节为单位所需传输的数据大小
struct bio_vec *bi_io_vec;//实际的vec列表
}
struct bio_vec
{
struct page *bv_page;//页指针 用来寻找IO请求缓冲区的页指针
unsigned int bv_len;//传输的数据长度,与bi_size有区别 后面详细说
unsigned int bv_offset;//偏移量 和页指针配合一起寻找IO请求缓冲区
}
13、struct request_queue *blk_alloc_queue(GFP_KERNEL)申请一个IO请求队列
14、blk_queue_make_request(request_queue *q, make_request_fn *mfn) 绑定请求函数和制造请求函数(详细结构在后面驱动结构里面详细说) 第一个参数是请求队列 第二个参数是处理请求函数
15、bio_endio(bio,0,-EIO) 结束当前bio(失败的情况) bio_endio(bio,bio->bi_size,0)结束当前bio(成功的情况)
bio_endio()原型如下:void bio_endio(struct bio* bio, unsigned int byetes, int error);
bytes是已经传送的字节数(注意:bytes≤bio->bi_size),这个函数同时更新了bio的当前缓冲区指针.当设备进一步处理bio后,驱动应再次调用bio_endio(),如不能完成请求,将错误码赋给error参数,并在函数中得以处理.此函数无论处理IO成功与否都返回0,如果返回非零值,则bio将再次被提交
bytes是已经传送的字节数(注意:bytes≤bio->bi_size),这个函数同时更新了bio的当前缓冲区指针.当设备进一步处理bio后,驱动应再次调用bio_endio(),如不能完成请求,将错误码赋给error参数,并在函数中得以处理.此函数无论处理IO成功与否都返回0,如果返回非零值,则bio将再次被提交
驱动结构:
本节最重要的知识点就是了解块设备的驱动结构。本节的块设备分两类,一类是有IO调度器的,一类是没有IO调度器的。
一、有linux IO调度器的情况
1.使用blk_init_queue函数,初始化一个IO请求队列,并把请求队列处理函数绑定给这个请求队列。
2.使用alloc_disk函数,申请一个块设备结构
3.填充gendisk块设备结构(主设备号 次设备号 设备名 IO请求队列 块设备函数操作集,块设备的函数操作集没有read和write的 只有open,release,ioctl等)
4.使用set_capacity函数,把块设备有多少块,赋值给gendisk
6.使用add_disk函数,将这个块设备注册到linux内核,此时产生了设备文件。
7.填写IO请求处理函数:
a.因为是处理一个请求队列,所以应该使用elv_next_request遍历所有的IO请求,知道没有请求了,才退出函数
b.当通过elv_next_request得到一个请求request的时候,然后通过linux内核回馈的sector头扇区和current_nr_sectors操作扇区个数判断操作的扇区是否,超过了开辟的内存大小。这里默认内存开辟的首地址是0扇区,原因我也不知道。可能是跟后面的格式化有关系,这是我还没弄懂的知识点一。
c.通过rq_data_dir()函数,判断IO请求的数据处理方向,即是读还是写。
d.根据读写,决定是从IO请求的缓冲区(req->buffer)中度数据,还是往缓冲区中写数据。如果是对硬盘进行操作,则是通过寄存器的控制,把硬盘中的数据读出来写入buffer和把buffer中的数据写入硬盘。本节的代码是用内存模拟硬件设备,所以是在内存与缓冲区之间进行copy。
总结:首先声明,对于块设备能否进行应用程序的read和write 我不知道,这是我没弄懂的知识二。本节的测试是ramdisk,把块设备中开辟的内存,格式化成ext3文件系统,并挂载到mnt中的某个目录,制作成硬盘,进行文件的拷贝。
1.写入文件的过程,用户态-------->内核态------------>硬件设备,(1.)内核根据你要拷贝的文件大小先去寻找哪些硬件空间没有被使用,文件被内核分成好多个块即分成好多的bio结构,这些bio都有自己的sector,这些sector跟刚刚的硬件空间有着某种转换关系(Mapping Layer层),(2.)然后内核再把你要拷贝的文件一块一块的写入每一个IO请求的缓冲区中(Generic Block Layer层),(3.)对于这个使用了IO调度器的驱动,blk_init_queue调用了blk_queue_make_request(q,_make_request),这里面首先调用了_make_request函数,这个函数是把所以产生的bio结构,通过IO调度器(调度算法)制作成一个一个的IO请求,即request结构,再把request结构制作成request_queueIO请求队列,然后调用了q->make_request_fn=mfn,mfn这个函数就是blk_init_queue中我们自己写的那个函数,用来处理IO请求队列的。(IO schedule层),在这个层次中,产生了我们常常见到的request中的sector和current_nr_sectors,他们的来源是bio的sector,(4.)在驱动中,你再把这个缓存区中的数据写入硬件设备中去(Block Driver层),但是我觉得硬件设备不应该仅仅是通过纯寄存器去控制,还应该是同过内核分配的sector和current_nr_sectors去找到硬件空间,因为只有这样才能确定那里是被使用的,那里是空闲的。这里面sector和current_nr_sectors与内核直接的关系应该是在,linux内核移植的时候完成的。这个过成就是上面那个体系结构图的具体解释。
补充:bio、request和request_queue三者关系:
首先是IO请求队列,这个里面有好多个IO请求request,当确定了文件大小,找到空闲硬件空间的时候,内核就产生了好多IO请求,每个IO请求都有一个sector,一个current_nr_sectors。一个IO请求一次可以操作好多个块,每一个块的每次IO请求就是一个bio,IO调度器就是优化一个request中的多个bio的。而一个request中的多个bio又有自己的sector,这就保证了不管IO调度器怎么优化,对于一次request的处理,都不会把文件拷贝到错的地址。(这里面就体现出了块设备的访问随机性),在这里我们考虑的最小单位是IO请求,bio的排序是调度器处理的,bio的顺序不会影响IO请求的处理的原因上面已经阐述过了。
2.读出文件的过程,硬件设备------------>内核态---------->用户态,当读一个文件的时候,确定了文件的inode属性,找到了文件的物理地址属性,操作系统可以根据这个物理地址找到文件对应的一组sector和current_nr_sectors并形成一个IO请求队列,因为产生和查找都是根据物理地址来的所以这组sector和current_nr_sectors应该是当时write的时候的那组是完全对应的。所以就可以毫无错误的找到你要的问题,在写入各个IO请求的缓冲区中,用户态就得到了你想要的文件了。这里也体现了块设备的访问随机性。
这里面有一个问题很纠结:比如我用read和write在应用程序中,往块设备中写入和读出数据,因为是顺序读取的,在读取数据的时候没有inode,所以sector和current_nr_sectors是随机的,不一定是写入时候的那个sector和current_nr_sectors,所以我在IO请求的缓存区中没有读出对应的数据,但是貌似我的应用程序还真的接到了对应的数据。原因我也不是很清楚。
注意:在整个过程中,程序中对错误的处理,都是有一个前提的,对于本节代码来说前提就是内存开辟的头地址,就是0扇区(可能是因为格式化成ext3文件系统的缘故,把这个内存空间当成了一个硬盘了,自然内存首地址就成了0扇区)。对于真实的硬盘驱动程序,应该是在内核移植的时候都移植好的,不管是硬盘还是nandflash,他们的起始地址应该就是0扇区,也就是说在安全性检测的时候,只需用 (头扇区+扇区数)*512再与总共硬盘大小进行比较就好了。
二、无linux IO调度器的情况
1.使用blk_alloc_queue(GFP_KERNEL) 申请一个IO请求队列
2.blk_queue_make_request(request_queue *q, make_request_fn *mfn) 这个函数是把申请的IO请求队列和你自己定义的处理函数建立联系
3.使用alloc_disk函数,申请一个块设备结构
4.填充gendisk块设备结构(主设备号 次设备号 设备名 IO请求队列 块设备函数操作集,块设备的函数操作集没有read和write的 只有open,release,ioctl等)
5.使用set_capacity函数,把块设备有多少块,赋值给gendisk
6.使用add_disk函数,将这个块设备注册到linux内核,此时产生了设备文件。
7.填写IO请求队列处理函数:
a.使用bio的sector和size来进行安全检测,判断是否超出我们开辟的内存大小
b.使用bio_for_each_segment函数遍历bio,因为每一个bio应该是由很多个bio_vec组成的,每一个bio_vec中都有一个缓冲区。这里可以看出bi_size和bio_vec->len的区别了,bi_size应该是整个bio进行操作的总字节数,bio_vec->len应该是每一次bio_vec缓冲区数据传输的字节数。
c.使用bio_rw函数来判断bio的数据传输方向
d.根据页指针和偏移量找到bio_vec的缓冲区,再根据读写方向进行数据传输。此处也有像上一方法一样的对硬件进行的操作,此时的硬件应该也是与bio的sector有关的
总结:对于这个没有使用IO调度器的情况,步骤和上面的写入过程是一样的,只有第三步是不一样,当到达第三步的时候直接使用了blk_queue_make_request(q,make_request_fn *mfn)函数,中的mfn函数即我们自己建立的处理好多bio结构的函数,没有调用系统中__make_queue函数了,因为这个函数中有一步IO调度器,优化bio顺序,我们直接使用这个函数来处理各个bio结构,很自然这个方法也没用IO请求request。与上面的方法不同的是有IO调度器的最小单元是request,没有IO调度器的最小单元是bio。
下面的图展示出了,两者中的第三步的函数调用关系:
本节代码:
1.如果对本节代码进行检测:
a.insmod block.ko 块设备
b.ls /dev/my_blkdev 查看有没有产生设备文件
c.mkfs.ext3 /dev/my_blkdev 把块设备格式化成ext3文件系统
d.mkdir -p /mnt/blk 在mnt目录下创建一个文件夹 用来映射这个块设备
e.mount /dev/my_blkdev /mnt/blk 把格式化好的块设备映射到新建的目录下
f.cp考入一些文件 再考出这些文件 看见你的块设备能不能读写
g.umount /dev/my_blkdev 卸载 看看 blk目录下还有没有文件了
2.不使用IO调度器的 block.c:
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/init.h>
#include <linux/sched.h>
#include <linux/kernel.h> /* printk() */
#include <linux/slab.h> /* kmalloc() */
#include <linux/fs.h> /* everything... */
#include <linux/errno.h> /* error codes */
#include <linux/timer.h>
#include <linux/types.h> /* size_t */
#include <linux/fcntl.h> /* O_ACCMODE */
#include <linux/hdreg.h> /* HDIO_GETGEO */
#include <linux/kdev_t.h>
#include <linux/vmalloc.h>
#include <linux/genhd.h>
#include <linux/blkdev.h>
#include <linux/buffer_head.h> /* invalidate_bdev */
#include <linux/bio.h>
#include <linux/major.h>
static struct gendisk *my_blkdev_disk;
static struct request_queue *my_blkdev_queue;
#define MY_BLKDEV_BYTES (16*1024*1024) //块设备大小为16M
unsigned char DATA[MY_BLKDEV_BYTES]; //这是当作块设备的内存
static int do_queue(struct request_queue *q,struct bio *bio) //IO请求队列处理函数
{
struct bio_vec *bvec;
int i;
void *dsk_mem;
/*bio->bi_sector头扇区 bio->bi_size 传输字节大小*/
if ((bio->bi_sector << 9) + bio->bi_size > MY_BLKDEV_BYTES)
{
printk(KERN_EMERG": bad request: block=%llu, count=%u\n",(unsigned long long)bio->bi_sector, bio->bi_size);
bio_endio(bio,0,-EIO);
return 0;
}
dsk_mem = DATA + (bio->bi_sector << 9); //把bio内存中的头地址保存下来
bio_for_each_segment(bvec, bio, i) //遍历bio 应该是一个bio 会分成好多个bvec步骤进行 数据传输
{
void *iovec_mem;
switch(bio_rw(bio))
{
case READ:
case READA:
iovec_mem=kmap(bvec->bv_page) + bvec->bv_offset;//用bio的页地址和偏移量 计算出缓冲区的虚拟地址
memcpy(iovec_mem, dsk_mem, bvec->bv_len);//把头扇区开始的内容copy到 bio的缓冲区中
printk(KERN_EMERG "READing is %s\n",(char *)iovec_mem);
kunmap(bvec->bv_page);//释放映射
break;
case WRITE:
iovec_mem=kmap(bvec->bv_page) + bvec->bv_offset;
memcpy(dsk_mem, iovec_mem, bvec->bv_len); //把来自用户空间的内容 从bio缓冲区copy到头扇区
printk(KERN_EMERG "WRITing is %s\n",(char *)iovec_mem);
kunmap(bvec->bv_page);
break;
default:
printk(KERN_EMERG": unknown value of bio_rw: %lu\n",bio_rw(bio));
bio_endio(bio,0,-EIO);//结束当前bio请求
return 0;
break;
}
dsk_mem += bvec->bv_len; //在每次完成bio中 的一个bvec的时候 都要把模仿设备的内存指针进行移动
}
bio_endio(bio,bio->bi_size,0);
return 0;
}
struct block_device_operations my_blkdev_fop = {
.owner = THIS_MODULE,
};
static int __init blkdev_init(void)
{
printk(KERN_EMERG "module init is finished!!!\n");
int ret;
/*初始化IO请求队列 并绑定IO队列处理函数*/
my_blkdev_queue = blk_alloc_queue(GFP_KERNEL); //申请一个IO请求队列
if (!my_blkdev_queue)
{
ret = -ENOMEM;
goto err_alloc_queue;
}
blk_queue_make_request(my_blkdev_queue, do_queue); //把申请的IO请求队列和我们自己的请求队列处理函数绑定起来 跳过以前操作系统带IO调度的那个函数
/*跟系统申请 描述块设备的结构体 赋值给定义的全局变量指针*/
my_blkdev_disk=alloc_disk(1); //动态分配gendisk结构体 参数为该设备使用的次设备号数量
if (!my_blkdev_disk)
{
ret = -ENOMEM;
goto err_alloc_disk;
}
/*填充块设备结构体*/
my_blkdev_disk->major=255;//主设备号为255
my_blkdev_disk->first_minor=0;//只有一个设备在alloc_disk中体现的,这个是第一个设备的次设备号
strcpy(my_blkdev_disk->disk_name,"my_blkdev");//填写设备名
my_blkdev_disk->fops=&my_blkdev_fop; //填写设备操作函数集合
my_blkdev_disk->queue=my_blkdev_queue; //填写该块设备的IO请求队列
set_capacity(my_blkdev_disk, MY_BLKDEV_BYTES>>9); //一般一个扇区的大小是512个字节 这个函数应该是得到这个块设备上有几个扇区
/*注册块设备 进入内核*/
add_disk(my_blkdev_disk);//注册块设备驱动
return 0;
err_alloc_disk:
blk_cleanup_queue(my_blkdev_queue);//如果IO请求队列申请失败 则应该清除队列
err_alloc_queue:
return ret;
}
static void __exit blkdev_exit(void)
{
blk_cleanup_queue(my_blkdev_queue);
del_gendisk(my_blkdev_disk);
}
module_init(blkdev_init);
module_exit(blkdev_exit);
3.使用IO调度器的block.c:
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/init.h>
#include <linux/sched.h>
#include <linux/kernel.h> /* printk() */
#include <linux/slab.h> /* kmalloc() */
#include <linux/fs.h> /* everything... */
#include <linux/errno.h> /* error codes */
#include <linux/timer.h>
#include <linux/types.h> /* size_t */
#include <linux/fcntl.h> /* O_ACCMODE */
#include <linux/hdreg.h> /* HDIO_GETGEO */
#include <linux/kdev_t.h>
#include <linux/vmalloc.h>
#include <linux/genhd.h>
#include <linux/blkdev.h>
#include <linux/buffer_head.h> /* invalidate_bdev */
#include <linux/bio.h>
#include <linux/major.h>
static struct gendisk *my_blkdev_disk;
static struct request_queue *my_blkdev_queue;
#define MY_BLKDEV_BYTES (16*1024*1024) //块设备大小为16M
unsigned char DATA[MY_BLKDEV_BYTES]; //这是当作块设备的内存
static void do_queue(struct request_queue *q) //IO请求队列处理函数
{
struct request *req;//定义一个请求
/*要处理完这个请求队列上的所有请求*/
while((req=elv_next_request(q))!=NULL) //当第一次调用elv_next_request函数的时候 q中的保存的应该是链表头 然后每次调用elv_next_request函数都指向下一个 也就是说第一次调用elv_next_request函数得到的request应该是第一次io请求
{
/*我想看看每次请求中的sector都是不是0*/
printk(KERN_EMERG "sector is %llu , nr_sectors is %u \n",(unsigned long long)req->sector,req->current_nr_sectors);
if(((req->sector+req->current_nr_sectors)<<9)>MY_BLKDEV_BYTES) //我觉得这里应该不加上req->sector 问问老师
{
printk(KERN_EMERG "error:sector is %llu , nr_sectors is %u \n",(unsigned long long)req->sector,req->current_nr_sectors);
end_request(req,0);//结束本次请求 第二个参数是返回请求是否执行成功 成功为1 失败为0
continue; //如果所操作的扇区大小 大于 我们最大分配内存的大小(此处做ramdisk 用内存当扇区) 则结束这次请求 进行下面的请求
}
/*下面是针对请求的方向进行处理*/
switch(rq_data_dir(req))//该函数是判断请求的方向的 即数据流向
{
case READ: //这里还不能使用copy_from_user或者copy_to_user因为req->buffer是内核空间的内存 是需要操作系统传递到用户空间的不是这里传递的 但是最好还是检查一下这个内存的有效性
/*这里的读 应该是用户空间读 内核把req->buffer中的数据传递给用户空间*/
memcpy(req->buffer,DATA+(req->sector<<9),req->current_nr_sectors<<9);
// memcpy(req->buffer,DATA+(0<<9),3<<9);
// memcpy(req->buffer,"hello0",5);
printk(KERN_EMERG"READing!!! %s\n",req->buffer);
end_request(req,1);//结束本次请求 第二个参数是返回请求是否执行成功 成功为1 失败为0
break;
case WRITE:
/*这里的写 应该是用户空间写 用户空间通过内核把数据写入req->buffer中 应把这个buffer中的值 写入硬件中*/
memcpy(DATA+(req->sector<<9),req->buffer,req->current_nr_sectors<<9);
// memcpy(DATA+(0<<9),req->buffer,3<<9);
printk(KERN_EMERG"WRITEing!!! %s\n",req->buffer);
end_request(req,1);//结束本次请求 第二个参数是返回请求是否执行成功 成功为1 失败为0
break;
default:
break;
}
}
}
struct block_device_operations my_blkdev_fop = {
.owner = THIS_MODULE,
};
static int __init blkdev_init(void)
{
printk(KERN_EMERG "module init is finished!!!\n");
int ret;
/*初始化IO请求队列*/
my_blkdev_queue=blk_init_queue(do_queue,NULL);//初始化块设备的IO请求队列 第一个参数是IO请求队列的处理函数名 第二个参数是一个自旋锁
if (!my_blkdev_queue)
{
ret = -ENOMEM;
goto err_alloc_queue;
}
/*跟系统申请 描述块设备的结构体 赋值给定义的全局变量指针*/
my_blkdev_disk=alloc_disk(1); //动态分配gendisk结构体 参数为该设备使用的次设备号数量
if (!my_blkdev_disk)
{
ret = -ENOMEM;
goto err_alloc_disk;
}
/*填充块设备结构体*/
my_blkdev_disk->major=255;//主设备号为255
my_blkdev_disk->first_minor=0;//只有一个设备在alloc_disk中体现的,这个是第一个设备的次设备号
strcpy(my_blkdev_disk->disk_name,"my_blkdev");//填写设备名
my_blkdev_disk->fops=&my_blkdev_fop; //填写设备操作函数集合
my_blkdev_disk->queue=my_blkdev_queue; //填写该块设备的IO请求队列
set_capacity(my_blkdev_disk, MY_BLKDEV_BYTES>>9); //一般一个扇区的大小是512个字节 这个函数应该是得到这个块设备上有几个扇区
/*注册块设备 进入内核*/
add_disk(my_blkdev_disk);//注册块设备驱动
return 0;
err_alloc_disk:
blk_cleanup_queue(my_blkdev_queue);//如果IO请求队列申请失败 则应该清除队列
err_alloc_queue:
return ret;
}
static void __exit blkdev_exit(void)
{
blk_cleanup_queue(my_blkdev_queue);
del_gendisk(my_blkdev_disk);
}
module_init(blkdev_init);
module_exit(blkdev_exit);
作者:qq418674358 发表于2013-7-21 17:53:19
阅读:71 评论:0