linux平台实现虚拟磁盘驱动(通用的块设备驱动和基于SCSI的磁盘驱动)

                                       by fanxiushu 2023-08-16 转载或引用请桌面原始作者。

实现linux平台的虚拟磁盘驱动,是为了要实现在linux远程无盘启动的。

linux平台下的无盘启动,现成的办法有许多,比如iSCSI,NFS,NBD等都可以,
不过我都没去试过,所以不清楚具体的细节。
但是可以肯定得是,比windows下实现无盘启动轻松,windows中也有现成的比如iSCSI办法,
不过 windows的中的iSCSI我也没有具体去实验过,
所以这里也得不出两个系统下的搭建无盘启动过程的难度对比情况。

上面说得iSCSI这些,都是操作系统本身集成的方案,
都是现成的,唯一需要去做的就是如何使用现成的东西,去搭建无盘启动的整个过程。
这中间不牵涉到程序的开发过程,顶多就是做一些脚本什么的方便维护。

而我这里说的是如何开发无盘启动的过程
(包括客户端驱动和服务器端,网络传输协议等都是自定义实现)。
在CSDN上前面的5,6篇文章,都是在阐述windows下的无盘启动开发内容,
其中以如何开发Legacy BIOS 和UEFI的引导程序讲述的比较详细,
其实最麻烦的还是windows下的驱动开发问题,
只是因为很早前讲述过windows中虚拟磁盘驱动开发,所以现在就省略了。

本章将介绍linux平台下的虚拟磁盘驱动开发过程。
为了增加一些兴趣,可以首先去查看我在windows平台下的无盘启动的演示视频:
win10系统的无盘启动过程-CSDN直播这是开发的虚拟驱动实现的win10平台下的无盘启动过程演示,在vmware中的演示效果。https://live.csdn.net/v/320536
无盘启动过程,演示的是win7平台和本地镜像启动-CSDN直播无盘启动的网络方式和本地镜像方式的演示视频https://live.csdn.net/v/314881

其中以win7的无盘启动最快,因为毕竟体积小(以前是觉得win7体积大,winxp小,现在是win7变小了)。
win10启动过程,我估计了一下,从UEFI引导程序调用bootmgr开始到进入到windows界面。
需要大约50秒时间。
当然这个时间跟具体的硬件配置有关,因为毕竟我这的电脑硬件配置,
在我的真实电脑上启动也得花个30-40秒的时间,现在网启也才多了10-20秒而已。

回到linux平台中的虚拟磁盘驱动开发上来。
其实linux中的磁盘驱动,本质上就是块设备驱动。
而在linux中分成三大类基本驱动类型:
1 字符设备驱动, 2 块设备驱动,3 网络设备驱动。

可想而知,磁盘驱动作为linux核心中基本类型,实现想必不会简单。
也确实,linux里边的关于块设备的核心代码确实比较复杂。
再回到windows,windows中关于磁盘相关的实现同样也是复杂的。
我只举个简单的层级例子: windows底层storport接口给程序员调用来实现具体的磁盘设备。
上层是disk.sys驱动实现底层磁盘设备的通用接口层,这个跟linux内核中 generic Block Layer 层基本是同一个概念。
再上层是partmgr.sys 实现对磁盘分区管理,再朝上是volmgr驱动负责每个分区卷的管理,
再然后就是对应的文件系统驱动,比如ntfs,fat等文件系统驱动。
linux内核中关于块设备也有同样的分层概念。
只是linux把这些一股脑儿的的塞进一个单一的linux内核中,而windows是以单独的模块来实现这些过程。

这里不阐述研究linux内核代码的事(这可是个相当绒长的,几百篇文章都可能研究不完),
而只是研究如何利用linux内核提供的接口,实现块设备驱动,从而达到实现我们的虚拟磁盘驱动的目的。

linux内核提供的块设备接口,那可真是太过于简单,
以至于总有种小孩子过家家一般的荒缪的感觉。
如下代码,就能实现一个内存磁盘的功能,代码却只有短短70多行。
(相同的简单功能,在windows中使用storport框架实现简单的内存磁盘,代码起码要到千行左右了,
而且还得编写inf配置文件,生成 cat签名文件,等等。反正不是在一个等级的)

int major;
int disk_size = 1024 * 1024;
char* memdisk = 0;
const char* DRIVER_NAME = "nbt_scsi";
spinlock_t queue_spinLock;
struct gendisk* disk = 0;
static const struct block_device_operations nbt_fops =
{
    .owner = THIS_MODULE,
}
void disk_do_request(struct request_queue *q)
{
    struct request *req;
    while ((req = blk_fetch_request(q)) != NULL) {
        BOOLEAN is_read;
        int64_t offset;
        long    length;
        struct req_iterator iter;
        struct bio_vec *bvec;
        ///读写offset和length
        offset = ((uint64_t)blk_rq_pos(req)) << 9;
        length = blk_rq_bytes(req);
        is_read = TRUE;
        if (rq_data_dir(req) == WRITE) {
            is_read = FALSE;
        }

        rq_for_each_segment(bvec, req, iter) {  查询request请求的所以数据块分段,
            void *kaddr = kmap(bvec->bv_page);
            char* buf = (char*)kaddr + bvec->bv_offset;
            int len = bvec->bv_len;
          
            ///读写磁盘内容
            if (is_read)memcpy(buf, memdisk + offset, len);
            else memcpy(memdisk + offset, buf, len);
            kunmap(bvec->bv_page);
            offset += len;
        }
       
        __blk_end_request_all(req, 0); /// success complete request
    }
}
static int __init blk_init(void)
{
    spin_lock_init(&scsi->queue_spinLock);
    major = register_blkdev(0, DRIVER_NAME);
    ///
    memdisk = kmalloc(disk_size, GFP_KERNEL);
    disk = alloc_disk(16);//创建
    disk->major = major;
    disk->first_minor = 0;
    disk->fops = &nbt_fops;
    set_capacity(disk, disk_size / 512); /// disk size
    strcpy(disk->disk_name, "nbt-disk");
    struct request_queue* q = blk_init_queue(disk_do_request, &queue_spinLock);
    disk->queue = q;
    add_disk(disk); 启动磁盘
    return 0;
}
static void __exit blk_exit(void)
{
    del_gendisk(disk);
    blk_cleanup_queue(disk->queue);
    put_disk(disk);
    kfree(memdisk);
    unregister_blkdev(scsi->major, DRIVER_NAME);
}
module_init(blk_init);
module_exit(blk_exit);

够简单的吧,不到100行代码,刚启蒙的小孩说不定都能做,哈哈。
大概的流程就是首先在初始化函数中调用 register_blkdev 注册块设备,
在退出函数中相应的使用un register_blkdev注销块设备。
然后就可以调用 alloc_disk 函数分配gendisk的结构体。
接着在gendisk中填充一些必须的参数,
包括上面代码中的 major,fops, queue,disk_name等参数。
其中 fops指向 block_device_operations 结构体,类似 字符设备中对应的操作回调函数,
不过呢,块设备的绝大部分操作函数都是linux内核帮我们实现了,
所以如果不是特别需求,都可以不填写,简单申明一个结构体就行。
然后就是设置 disk_name 块设备名字,这个名字对应 /dev目录下的名字,
再然后调用set_capacity 设置块设备的大小,
因为linux内核固定把 512字节当成一个块大小,所以实际磁盘大小除以512就行。

最后就比较重要的,磁盘读写问题,与字符设备不同,不是在操作回调函数中响应IO读写,
而是有一个专门的 queue来实现。
如上代码,使用 blk_init_queue 函数来创建一个队列,赋值给 gendisk的queue参数。
同时 blk_init_queue 函数需要提供一个回调函数,这个回调函数就会实现 磁盘的IO读写请求。
关于 queue问题,linux内核还实现一个读写方案,就是make request,
上面简单提到过, linux内核有个 generic Block Layer,处理通用的磁盘请求,
这一层,所有通信其实使用的是 bio 结构体传输数据的。
blk_init_queue 函数是传统的办法,会在generic block layer发送读写请求时候,
把一些相邻的bio请求合并起来,形成 request 请求,
然后再传输给 blk_init_queue提供的回调函数处理,
也就是相当于在 generic block layer 层和 block driver layer层的中间还有一个合并零散的bio的算法层。
而make request呢, 相当于略过了合并 bio请求的算法层,直接把bio请求发给 block driver。
这样做的好处也很明显,比如上面的内存磁盘,
其实到了 block driver layer也就是上面的代码disk_do_request回调函数中。
只需要简单内存拷贝(memcpy)就行,没必要浪费CPU再在算法层合并相邻 bio 请求。
所以make request 很适合 内存磁盘,SSD等响应非常快的磁盘系统。
至于make request如何调用,这里不再啰嗦,有兴趣可自行去查阅,反正也是很简单的几个函数而已。

到此,我们就轻轻松松的实现了linux系统下的磁盘设备了。
当然,严格来说,是实现了一个通用的块设备驱动,
作为通常的需求完全足够了,甚至作为linux的无盘启动来说,也已经足够了。
这个与windows平台完全不同,
linux下的这种 gendisk 通用的块设备驱动,其实在windows也有对应的实现方案:
windows驱动中,调用 IoCreateDevice 函数创建类型是 FILE_DEVICE_DISK 的设备,
然后响应 一些关于磁盘的特殊IOCTL, 同时响应 IRP_MG_READ和IRP_MJ_WRITE的读写磁盘扇区请求。
在应用层使用 DefineDosDevice等函数挂载,就能在应用层看到一个能被绝大部分程序访问的分区卷。
但是,在windows的这种做法,也就只能在应用层玩玩,在内核中基本不被承认,更不可能用它来作为启动磁盘了。

所以说,linux内核windows内核差别真的是非常大,驱动实现难度差别也是非常大。
通常来说,windows下的驱动开发更难。

上面说得linux通用块设备实现,
难道就没有更底层的接口,让我们的linux系统中的虚拟磁盘驱动看起来更像一块硬件磁盘,
而不是处于比较尴尬的通用块设备驱动的这一层。
当然是有的,自然最容易想到的就是基于 SCSI 接口的磁盘,
回到windows平台,windows的storport框架其实是从 winxp系统的scsiport框架升级发展而来,
storport可以实现任何底层协议的磁盘,不限于SCSI。
linux下也有对应的接口,毕竟SCSI是一个通用的接口层,比如U盘的上层协议就是基于SCSI的。

scsi接口比gendisk接口稍微麻烦点,但是总体来说还是很简单。
首先申明scsi_host_template 结构体。
类似下面这样,以下设置仅供参考,参数可以调整:
static struct scsi_host_template nbt_scsi_driver_template = {
    ///
    .name = "Fanxiushu NetBoot Virtual SCSI Adapter",
    .proc_name = "nbtscsi",
    .info = nbt_scsi_info,

    .queuecommand = nbt_scsi_queuecommand,
    .change_queue_depth = nbt_scsi_change_queue_depth,
    .eh_device_reset_handler = nbt_scsi_device_reset,
    .bios_param = nbt_scsi_bios_parm,

    .can_queue = 32,
    .this_id = -1,
    .sg_tablesize = SG_ALL,
    .cmd_per_lun = 128,
    .max_sectors = 8192,
    .use_clustering = DISABLE_CLUSTERING,
    .emulated = 1,

    .module = THIS_MODULE,
};

其中 queuecommand 回调函数是最重要的。函数申明如下:
int nbt_scsi_queuecommand(struct Scsi_Host *sh, struct scsi_cmnd *sc);
它响应SCSI各种命令。

首先如下调用:
  struct Scsi_Host *sh = scsi_host_alloc(&nbt_scsi_driver_template, sizeof(自己定义的私有结构体大小));
scsi_host_alloc 分配SCSI适配器对应的结构体。
接着调用 scsi_add_host(sh, dev ); 启动这个SCSI适配器,
这里dev对应着SCSI适配器总线设备,
但是我们创建的是一个虚拟磁盘驱动,没有真实的SCSI适配器硬件,所以是没有对应的device设备的。

该如何解决这个问题呢?
以前在讲述linux平台下实现虚拟USB控制器驱动的时候,就讲过,可以使用虚拟总线。
调用 platform_driver_register 注册一个平台总线,然后调用 platform_device_register注册一个总线设备。
这样在 probe回调函数中,就能获取到总线设备对应的device。

当scsi_host_alloc 和scsi_add_host调用之后,SCSI适配器就创建成功了。
接下来,就是创建具体的SCSI磁盘设备。
本来按照正常的硬件,我们接下来可以调用 scsi_scan_host 函数,从而触发系统扫描SCSI硬件适配器。
但是我们是虚拟驱动,所以没必要这么做,
直接调用 scsi_add_device 或者 __scsi_add_device 函数添加一个scsi磁盘设备就可以了。
调用scsi_add_device之后,linux内核就认为磁盘设备已经创建好。
这时候 scsi_host_template 里边对应的 queuecommand 回调函数就会被调用,用来处理具体的SCSI磁盘的SCSI请求。

至于queuecommand里边处理的SCSI命令,
这个就和windows下的驱动处理SCSI命令没啥本质区别(除了跟具体系统相关的之外)。
因为SCSI协议是跨平台通用的。

下图是linux下实现的scsi和通用块设备驱动演示图:
图中左边两个红色框里边对应的就是 SCSI接口的磁盘和基于gendisk的磁盘。
其中SCSI接口的磁盘在图中显示的跟底层硬件一样了。

除了vmware硬件设备之外,其它都显示是 Block Device 块设备。
linux平台实现虚拟磁盘驱动(通用的块设备驱动和基于SCSI的磁盘驱动)_第1张图片

可以与windows平台下对应的虚拟磁盘驱动做个对照:

linux平台实现虚拟磁盘驱动(通用的块设备驱动和基于SCSI的磁盘驱动)_第2张图片

 


在windows中,对网络通信做了非常多的工作,
如果只是普通的网络磁盘,没必要这么麻烦,直接使用WSK或TDI通信即可。
但是要作为启动磁盘,问题就比较大了,所以最终采用的是底层NDIS通信。

而linux的网络通信就没这么麻烦了,
非但不麻烦,可以说是非常简单,即便是作为linux启动盘也是一样的简单。
后面章节阐述我们自己的虚拟磁盘驱动实现 linux 下无盘启动的时候会讲述到。
 

你可能感兴趣的:(磁盘驱动,linux,驱动开发,linux,服务器,无盘启动)