原文地址: bio 与块设备驱动
 
   系统中能够随机访问固定大小数据片(chunk)的设备被称作块设备,这些数据片就称作块。块设备文件都是以安装文件系统的方式使用,此也是块设备通常的访问方式。块设备的访问方式是随机的,也就是可以在访问设备时,随意的从一个位置跳转到另一个位置。块设备的访问位置必须能够在介质的不同区间前后移动。
所以事实上内核不必提供一个专门的子系统来管理字符设备,但是对块设备的管理却必须要有一个专门的提供服务的子系统。块设备中,最小的可寻址单元是扇区。扇区大小一般是2的整数倍,而最常见的大小是512个字节。扇区的大小是设备的物理属性,扇区是所有块设备的基本单元——块设备无法对比它还小的单元进行 寻址和操作,许多块设备能够一次就传输多个扇区。块是文件系统的一种抽象——只能基于块来访问文件系统。虽然物理磁盘寻址是按照扇区级进行的,但是内核执 行的所有磁盘操作都是按照块进行的。由于扇区是设备的最小可寻址单元,所以块不能比扇区还小,只能数倍于扇区大小。内核还要求块大小是2的整数倍,而且不 能超过一个页的长度。所以对块大小的最重要求是:必须是扇区大小的2的整数倍,而且要小于页面大小,所以通常大小是512字节,1K或者4K。

在linux2.5之前,当一个块被调入内存时,要存储在一个缓冲区中,每个缓冲区与一个块对应,相当于是磁盘块在内存中的表示,由于内核在处理数据时需要 一些相关控制信息,所以每一个缓冲区都有一个对应的描述符。该描述符用buffer_head结构体表示,也称作缓冲区头。

struct buffer_head {

    unsigned long b_state; //缓冲区状态标志

struct buffer_head *b_this_page; //页面中的缓冲区

struct page *b_page; //存储缓冲区的页面

sector_t b_blocknr; //逻辑块号

size_t b_size; //块大小

char *b_data; //页面中的缓冲区

struct block_device *b_bdev; //块设备

bh_end_io_t *b_end_io; //I/O完成方法

void *b_private; //完成方法数据

struct list_head b_assoc_buffers; //相关映射链表
    /* mapping this buffer is associated with */
    struct address_space *b_assoc_map;    

    atomic_t b_count; //缓冲区使用计数

};

b_state域表示缓冲区的状态,合法的标志存放在bh_state_bits枚举中,定义在

enum bh_state_bits {
BH_Uptodate,该缓冲区包含可用数据
BH_Dirty,该缓冲区是脏的(缓存中的内容比磁盘中的块内容新,所以缓冲区内容必须被写回磁盘)
BH_Lock,该缓冲区正被I/O操作使用,被锁定以防被并发访问
BH_Req,该缓冲区有I/O请求操作
BH_Uptodate_Lock,
BH_Mapped,该缓冲区是映射磁盘块的可用缓冲区
BH_New,该缓冲区是通过get_block(0刚刚映射的,并且不能访问
BH_Async_Read,该缓冲区正通过end_buffer_async_read()被异步I/O读操作使用
BH_Async_Write,该缓冲区正通过end_buffer_async_write()被异步I/O写操作使用
BH_Delay,该缓冲区尚未和磁盘块关联
BH_Boundary,该缓冲区处于连续块区的边界——下一个块不再连续
BH_Write_EIO,
BH_Ordered,
BH_Eopnotsupp,
BH_Unwritten,
BH_PrivateStart,
};

   驱动程序可以在这些位中安全的定义自己的状态标志,只要保证自定义的状态标志不与块I/O层的专用位发生冲突就可以了。
而在b_count中,表示缓冲区的使用计数,则通过两个函数来进行增减:
get_bh(struct buffer_head *bh)-->atomic_inc(&bh->b_count)
put_bh(struct buffer_head *bh)-->atomic_dec(&bh->b_count)

一个块设备驱动程序主要通过传输固定大小的随机数据来访问设备。高效的块设备驱动程序在性能上是严格要求的,并不仅仅体现在用户应用程序的读写操作中。现代 操作系统使用虚拟内存工作,把不需要的数据转移到诸如磁盘等其他存储介质上,块驱动程序是在核心内存与其他存储介质之间的管道,因此它们可以认为是虚拟内 存子系统的组成部分。一个数据块指定的是固定大小的数据,而大小的值由内核确定,数据块的大小通常是4096个字节,但是可以根据体系结构和所使用的文件 系统进行改变。与数据块对应的是扇区,它是由底层硬件决定大小的一个块。内核所处理的设备扇区大小是512字节。如果用户的设备使用了不同的大小,需要对 内核进行修改,以避免产生硬件所不能处理的I/O请求。无论何时内核为用户提供了一个扇区编号,该扇区的大小就是512字节。如果要使用不同的硬件扇区大小,用户比对内核的扇区做相应的修改。同样,此部分也是由不少数据结构与相应方法组成,下面先来看相关数据结构:

内核使用gendisk结构来表示一个独立的磁盘设备。内核还使用gendisk结构表示分区,在此结构中,很多成员必须由驱动程序来进行初始化。此结构定义在


struct gendisk {
int major; //主设备号

int first_minor; //第一个从设备号

int minors;
/* 描述被磁盘使用的设备号的成员.一个驱动器必须使用最少一个次编号.如果你的驱动会是可分区的,但是(并且大部分应当是),你要分配一个次编号给每个可能 的分区.次编号的一个普通的值是 16, 它允许"全磁盘"设备盒 15 个分区. 一些磁盘驱动使用 64 个次编号给每个设备.*/

char disk_name[32]; //应当被设置为磁盘驱动器名子的成员. 它出现在 /proc/partitions 和 sysfs.

struct hd_struct **part; /* [indexed by minor] */
struct block_device_operations *fops;// 设备操作集合.

struct request_queue *queue;//被内核用来管理这个设备的 I/O 请求的结构;

void *private_data;//块驱动可使用这个成员作为一个指向它们自己内部数据的指针.

sector_t capacity;
//这个驱动器的容量,以512-字节扇区来计.sector_t类型可以是64位宽.驱动不应当直接设置这个成员;相反,传递扇区数目给set_capacity.

int flags;
// 一套标志(很少使用),描述驱动器的状态.如果你的设备有可移出的介质,你应当设置GENHD_FL_REMOVABLE.CD-ROM驱动器可设置 GENHD_FL_CD. 如果, 由于某些原因, 你不需要分区信息出现在 /proc/partitions, 设置 GENHD_FL_SUPPRESS_PARTITIONS_INFO.

struct device *driverfs_dev; // FIXME: remove

struct device dev;
struct kobject *holder_dir;
struct kobject *slave_dir;
struct timer_rand_state *random;
int policy;
atomic_t sync_io; /* RAID */
unsigned long stamp;
int in_flight;
#ifdef CONFIG_SMP
struct disk_stats *dkstats;
#else
struct disk_stats dkstats;
#endif
struct work_struct async_notify;
};

此结构是一个动态分配的结构。需要一些内核的特殊处理来进行初始化;驱动程序不能自己动态分配该结构,而是必须调用。
struct gendisk *alloc_disk(int minors);//参数是次设备号的数目。此后就无法改变minors成员。动态分配该结构。 
void del_gendisk(struct gendisk *gd);//卸载磁盘。参数是一个引用计数结构,包含kobject对象。
void add_disk(struct gendisk *gd); //初始化结构函数,一旦调用此函数,设备将被激活,并随时会调用它提供的方法。在驱动程序完全被初始化并且能够相应对磁盘的请求前,不要调用此函数。

当内核以文件系统、虚拟内存子系统或者系统调用的形式决定从块I/O设备输入、输出块数据时,它将再结合一个bio结构,用来描述这个操作。该结构被传递给 I/O代码,代码会把它合并到一个已经存在的request结构中,或者根据需要,再创建一个新的request结构。bio结构包含了驱动程序执行请求 的全部信息,而不必与初始化这个请求的用户空间的进程相关联。

内核中块I/O操作的基本容器由bio结构体表示,定义在中,该结构体代表了正在现场的(活动的)以片段(segment)链表形式组织的块I/O操作。一个片段是一小 块连续的内存缓冲区。这样的好处就是不需要保证单个缓冲区一定要连续。所以通过片段来描述缓冲区,即使一个缓冲区分散在内存的多个位置上,bio结构体也 能对内核保证I/O操作的执行,这样的就叫做聚散I/O.
bio为通用层的主要数据结构,既描述了磁盘的位置,又描述了内存的位置,是上层内核vfs与下层驱动的连接纽带。

struct bio {

//该bio结构所要传输的第一个(512字节)扇区:磁盘的位置
sector_t bi_sector;

struct bio *bi_next; //请求链表

struct block_device *bi_bdev;//相关的块设备

unsigned long bi_flags//状态和命令标志

unsigned long bi_rw; //读写

unsigned short bi_vcnt;//bio_vesc偏移的个数

unsigned short bi_idx; //bi_io_vec的当前索引

unsigned short bi_phys_segments;//结合后的片段数目

unsigned short bi_hw_segments;//重映射后的片段数目

unsigned int bi_size; //I/O计数

unsigned int bi_hw_front_size;//第一个可合并的段大小;

unsigned int bi_hw_back_size;//最后一个可合并的段大小

unsigned int bi_max_vecs; //bio_vecs数目上限

struct bio_vec *bi_io_vec; //bio_vec链表:内存的位置

bio_end_io_t *bi_end_io;//I/O完成方法

atomic_t bi_cnt; //使用计数

void *bi_private; //拥有者的私有方法

bio_destructor_t *bi_destructor; //销毁方法

};

此结构体的目的主要就是代表正在现场执行的I/O操作,所以该结构体中的主要域都是用来相关的信息的,而其中bi_io_vec、bi_vcnt、bi_idx重要
这三者形成了这样一种关系:bio-->bi_io_vec,bi_idx(就如基地址加偏移量一般,可以轻易的找到具体的bio_vec)-->page(再通过vec找到page)
其 中bi_io_vec指向一个bio_vec结构体数组,该结构体链表包含了一个特定的I/O操作所需要使用到的所有片段。每个bio_vec都是<page,offset,len>的向量,描述的是一个特定的片段:片段所在的物理页,块在物理页中的偏移位置,从给定偏移量开始的块长度,整个bio_io_vec结构体数组表示了一个完整的缓冲区。

struct bio_vec {
struct page    *bv_page;指向整个缓冲区所驻留的物理页面
unsigned int    bv_len;这个缓冲区以字节为单位的大小
unsigned int    bv_offset;缓冲区所驻留的页中以字节为单位的偏移量。
};

bi_vcnt域用来描述bi_io_vec所指向的bio_vec数组中的向量数目。当I/O操作完成后,bi_idx指向数组的当前索引。一个块请求通过一个bio表示。每个请求包括多个或者一个块,而这些块有都存储在bio_vec结构体的数组中,这些结构描述了每个片段在物理页中的实际位置,并且如向量一样的组织在一起,I/O操作的第一个片段由b_io_vec结构体所指向,其他片段则在其后依次放置,共有bi_vcnt个片段,当I/O层开始执行请求,需要各个使用片段时,bi_idx会不断更新,从而总指向当前的片段。看,这就是在入门C语言中用到的最朴实的概念,数组寻址的概念相类似。

块设备将挂起的块请求保存在请求队列中,该队列由request_queue结构体表示,定义在文件中,包含一个双向请求队列以及相关控制信息。通过内核中像文件系统这样高层的代码将请求加入到队列中,请求队列只要不为空,队列对应的块设备驱动程序就会从队列头 获取请求,然后将其加入到对应的块设备中去,请求队列表中的每一项都是一个单独的请求,由request结构体表示。

而队列中的请求request,定义在中,一个请求可能要操作多个连续的磁盘块,所以每个请求可以由多个bio结构体组成。每个bio结构体都可以描述多个片段。下面就是request中比较常用的几个域。

struct request {
struct list_head queuelist;//连接这个请求到请求队列. 
//追踪请求硬件完成的扇区的成员.第一个尚未被传送的扇区被存储到 hard_sector,已经传送的扇区总数在hard_nr_sectors,并且在当前bio中剩余的扇区数是hard_cur_sectors.这些成员打算只用在块子系统;驱动不应当使用它们.
struct request_queue *q;
sector_t hard_sector;    
unsigned long hard_nr_sectors;    
unsigned int hard_cur_sectors;
struct bio *bio;//bio 是给这个请求的 bio 结构的链表. 你不应当直接存取这个成员; 使用 rq_for_each_bio(后面描述) 代替.
unsigned short nr_phys_segments;//被这个请求在物理内存中占用的独特段的数目, 在邻近页已被合并后
char *buffer;//随着深入理解,可见到这个成员仅仅是在当前 bio 上调用 bio_data 的结果.
};

而几个关键结构之间的关系是如何的呢?request_queue中是请求队列,通过它找到request,将这些请求连成一体,然后在request中包含bio,然后通过bio结构体找到对应的page,然后通过page读取物理内存中的信息。大体就是这样一个关系。

块驱动程序步骤与实例:

对于大多数块驱动程序来说,首先都该是向内核注册自己!这个任务的函数是register_blkdev(在中定义):
int register_blkdev(unsigned int major, const char *name); 
参数是设备要使用的主编号和关联的名子(内核将显示它在/proc/devices). 如果major传递为0,内核分配一个新的主编号并且返回它给调用者.
取消注册的对应函数是:int unregister_blkdev(unsigned int major, const char *name);参数必须匹配传递给 register_blkdev 的那些。
在2.6内核,register_blkdev所进行的功能已随时间正在减少;这个调用唯一的任务是如果需要,分配一个动态主编号,并且在/proc/devices创建一个入口.

描述虚拟设备的结构体,里面的结构体除去timer_list都在前面介绍:
struct sbull_dev 
{
int size; //以扇区为单位,设备的大小
u8 *data; //数据数组
short users;//用户数目 
short media_change;//介质改变标志 
spinlock_t lock;//用户互斥
struct request_queue *queue;//设备请求队列 
struct gendisk *gd;//gendisk结构
struct timer_list timer;//模拟介质改变
};

static struct sbull_dev *Devices = NULL;//申请一个设备
memset (dev, 0, sizeof (struct sbull_dev));//申请内存空间
dev->size = nsectors*hardsect_size;//设备大小:1024*512
dev->data = vmalloc(dev->size);

switch (request_mode) {
case RM_NOQUEUE:
dev->queue = blk_alloc_queue(GFP_KERNEL);
blk_queue_make_request(dev->queue, sbull_make_request);
break;
case RM_FULL:
dev->queue = blk_init_queue(sbull_full_request, &dev->lock);
break;
default:
printk(KERN_NOTICE "Bad request mode %d, using simple\n", request_mode);
case RM_SIMPLE:
dev->queue = blk_init_queue(sbull_request, &dev->lock);
if (dev->queue == NULL)
goto out_vfree;
break;
}
使用bio结构编写的块设备驱动程序。
static void sbull_full_request(request_queue_t *q)
{
struct request *req;
int sectors_xferred;
struct sbull_dev *dev = q->queuedata;
while ((req = elv_next_request(q)) != NULL) {//获得队列中的下一个request
if (! blk_fs_request(req)) {
printk (KERN_NOTICE "Skip non-fs request\n");
end_request(req, 0);//配合elv_next_request使用,完成一个请求
continue;
}
sectors_xferred = sbull_xfer_request(dev, req);//返回数量
if (! end_that_request_first(req, 1, sectors_xferred)) {//驱动程序从前一次结束的地方开始,完成了规定数目的扇区的传输
blkdev_dequeue_request(req);//从队列中删除一个请求函数,当end_that_request_first都被传输后,则必须调用此函数
end_that_request_last(req);//通知任何等待已经完成请求的对象,并重复利用该request结构。
}
}
}
static int sbull_xfer_request(struct sbull_dev *dev, struct request *req)
{
struct bio *bio;
int nsect = 0;
rq_for_each_bio(bio, req) {//以宏的形式实现的控制结构,遍历请求中的每个bio
sbull_xfer_bio(dev, bio);
nsect += bio->bi_size/KERNEL_SECTOR_SIZE;//#define KERNEL_SECTOR_SIZE    512
}
return nsect;
}
static int sbull_xfer_bio(struct sbull_dev *dev, struct bio *bio)
{
int i;
struct bio_vec *bvec;
sector_t sector = bio->bi_sector;
bio_for_each_segment(bvec, bio, i) //用来遍历组成bio结构的段的伪控制结构
{
char *buffer = __bio_kmap_atomic(bio, i, KM_USER0);//底层函数直接映射了指定索引号为i的bio_vec中的缓冲区。
sbull_transfer(dev, sector, bio_cur_sectors(bio),buffer, bio_data_dir(bio) == WRITE);//完全简单的基于ram设备。完成实际传输。
//bio_cur_sectors用来访问bio结构中的当前段,bio_data_dir用来获得bio结构描述的大小和传输方向
sector += bio_cur_sectors(bio);
__bio_kunmap_atomic(bio, KM_USER0);
}
return 0; 
}
static void sbull_transfer(struct sbull_dev *dev, unsigned long sector,unsigned long nsect, char *buffer, int write)
{
unsigned long offset = sector*KERNEL_SECTOR_SIZE;
unsigned long nbytes = nsect*KERNEL_SECTOR_SIZE;
if (write)
memcpy(dev->data + offset, buffer, nbytes);
else
memcpy(buffer, dev->data + offset, nbytes);
}
register_blkdev可用来获得一个主编号,但不使任何磁盘驱动器对系统可用.有一个分开的注册接口你必须使用来管理单独的驱动器.它是 struct block_device_operations, 定义在 .
struct block_device_operations {
int (*open) (struct inode *, struct file *);//设备打开函数
int (*release) (struct inode *, struct file *);//设备关闭函数
int (*ioctl) (struct inode *, struct file *, unsigned, unsigned long);//实现ioctl系统调用的方法.大部分的块驱动 ioctl 方法相当短.
long (*unlocked_ioctl) (struct file *, unsigned, unsigned long);//
long (*compat_ioctl) (struct file *, unsigned, unsigned long);
int (*direct_access) (struct block_device *, sector_t,void **, unsigned long *);
int (*media_changed) (struct gendisk *);
//被内核调用来检查是否用户已经改变了驱动器中的介质的方法,如果是这样返回一个非零值.显然,这个方法仅适用于支持可移出的介质的驱动器(并且最好给驱动一个"介质被改变"标志); 在其他情况下可被忽略.
int (*revalidate_disk) (struct gendisk *);
//revalidate_disk方法被调用来响应一个介质改变;它给驱动一个机会来进行需要的任何工作使新介质准备好使用.这个函数返回一个int值,但是值被内核忽略.
int (*getgeo)(struct block_device *, struct hd_geometry *);
struct module *owner;//一个指向拥有这个结构的模块的指针; 它应当常常被初始化为 THIS_MODULE.
};
继续初始化:
dev->gd = alloc_disk(SBULL_MINORS);//动态分配gendisk结构(表是一个独立的磁盘设备)
dev->gd->major = sbull_major;//设定主设备号
dev->gd->first_minor = which*SBULL_MINORS;//每个设备所支持的次设备号数量
dev->gd->fops = &sbull_ops;//块操作方法
dev->gd->queue = dev->queue;
dev->gd->private_data = dev;
snprintf (dev->gd->disk_name, 32, "sbull%c", which + 'a');
set_capacity(dev->gd, nsectors*(hardsect_size/KERNEL_SECTOR_SIZE));
//使用KERNEL_来进行内核512字节扇区到实际使用扇区大小的转换。
add_disk(dev->gd);//结束设置过程。
其余部分参见ldd3的sbull

【转】 bio 与块设备驱动的更多相关文章

  1. Linux 块设备驱动 (一)

    1.块设备的I/O操作特点 字符设备与块设备的区别: 块设备只能以块为单位接受输入和返回输出,而字符设备则以字符为单位. 块设备对于I/O请求有对应的缓冲区,因此它们可以选择以什么顺序进行响应,字符设 ...

  2. linux下的块设备驱动(二)

    上一章主要讲了请求队列的一系列问题.下面主要说一下请求函数.首先来说一下硬盘类块设备的请求函数. 请求函数可以在没有完成请求队列的中的所有请求的情况下就返回,也可以在一个请求都不完成的情况下就返回. ...

  3. Linux块设备驱动(一) _驱动模型

    块设备是Linux三大设备之一,其驱动模型主要针对磁盘,Flash等存储类设备,本文以3.14为蓝本,探讨内核中的块设备驱动模型 框架 下图是Linux中的块设备模型示意图,应用层程序有两种方式访问一 ...

  4. 乾坤合一~Linux设备驱动之块设备驱动

    1. 题外话 在蜕变成蝶的一系列学习当中,我们已经掌握了大部分Linux驱动的知识,在乾坤合一的分享当中,以综合实例为主要讲解,在一个月的蜕茧成蝶的学习探索当中,觉得数据结构,指针,链表等等占据了代码 ...

  5. linux块设备驱动

    块设备驱动程序<1>.块设备和字符设备的区别1.读取数据的单元不同,块设备读写数据的基本单元是块,字符设备的基本单元是字节.2.块设备可以随机访问,字符设备只能顺序访问. 块设备的访问:当 ...

  6. Linux块设备驱动详解

    <机械硬盘> a:磁盘结构 -----传统的机械硬盘一般为3.5英寸硬盘,并由多个圆形蝶片组成,每个蝶片拥有独立的机械臂和磁头,每个堞片的圆形平面被划分了不同的同心圆,每一个同心圆称为一个 ...

  7. Linux块设备驱动_WDS

    推荐书:<Linux内核源代码情景分析> 1.字符设备驱动和使用中等待某一事件的方法①查询方式②休眠唤醒,但是这种没有超时时间③poll机制,在休眠唤醒基础上加一个超时时间④异步通知,异步 ...

  8. linux 块设备驱动 (三)块设备驱动开发

    一: 块设备驱动注册与注销 块设备驱动中的第1个工作通常是注册它们自己到内核,完成这个任务的函数是 register_blkdev(),其原型为:int register_blkdev(unsigne ...

  9. linux块设备驱动(一)——块设备概念介绍

    本文来源于: 1. http://blog.csdn.net/jianchi88/article/details/7212370 2. http://blog.chinaunix.net/uid-27 ...

随机推荐

  1. 201521123122 Java 第二周学习总结

    1. 本周学习总结 1.进一步了解了对码云的使用,学会了将本地代码上传到码云以及将码云上的代码克隆到eclipse上. 2.感觉本章学的基本语法和c的基本上差不多啊 3.string的对象创建后无法修 ...

  2. 201521123115《Java程序设计》第14周学习总结

    1. 本周学习总结 1.1 以你喜欢的方式(思维导图或其他)归纳总结多数据库相关内容. 2. 书面作业 1. MySQL数据库基本操作 建立数据库,将自己的姓名.学号作为一条记录插入.(截图,需出现自 ...

  3. MySQL的JOIN(五):JOIN优化实践之排序

    这篇博文讲述如何优化JOIN查询带有排序的情况.大致分为对连接属性排序和对非连接属性排序两种情况.插入测试数据. CREATE TABLE t1 ( id INT PRIMARY KEY AUTO_I ...

  4. JavaScript随笔

    文档模式 主要模式2中混杂模式和标准模式. 1混杂模式,混杂模式会让IE的行为与(包含非标准特性的)IE5相同. 2标准模式,标准模式让IE的行为更接近标准行为. 准标准模式:通过过渡型或框架集型触发 ...

  5. Python可视化----------matplotlib.pylot

    1 >>> import matplotlib.pyplot as plt 2 >>> plt.axis([0,5,0,20]) 3 [0, 5, 0, 20] 4 ...

  6. Oracle函数之chr

    chr()函数将ASCII码转换为字符:字符 –> ASCII码:ascii()函数将字符转换为ASCII码:ASCII码 –> 字符: 在oracle中chr()函数和ascii()是一 ...

  7. 警惕Java编译器中那些“蜜糖”陷阱

    一.前言 随着Java编译器不断地向前发展,它为程序员们提供了越来越多的“蜜糖”(compiler suger),极大地方便了程序的开发,例如,foreach的增强模式,自动拆箱与装箱以及字符串的连接 ...

  8. 框架应用:Spring framework (一) - IoC技术

    IoC概念以及目标 IoC就是让原本你自己管理的对象交由容器来进行管理,其主要的目的是松耦合. IoC发展史 既然IoC的目标是为了松耦合,那它怎么做到的? 最后目标:降低对象之间的耦合度,IoC技术 ...

  9. MySQL binlog 的恢复操作

     测试出有个问题:mysqlbinlog 不加任何参数 恢复整个binlog 日志文件发现里面有这个操作 SET @@SESSION.GTID_NEXT 的操作,  如果需要恢复文件的时候就需要把他过 ...

  10. Eclipse将引用了第三方jar包的Java项目打包成jar文件

    第一步:建议手动 Eclipse插件fatjar 安装方法:1:下载地址:http://downloads.sourceforge.net/fjep/net.sf.fjep.fatjar_0.0.27 ...