一款基于Rust语言异步语法的virtio块设备驱动

前言

笔者初步实现了一款基于 Rust 语言异步语法的virtio块设备驱动,目前在 qemu 平台上结合无相之风团队开发的飓风内核,可以像下面的伪代码一样运行块设备读写任务:

// 下面的接口定义可能和库的具体实现有些差别
pub async fn virtio_test() -> async_driver::Result<()> {
    // 创建 virtio 异步块设备实例
    let async_blk = VirtIOAsyncBlock::new().await?;
    // 读缓冲区,可变,大小为一个扇区(512 字节)
    let mut read_buf = [0u8; 512];
    // 写缓冲区,不可变,大小为一个扇区(512 字节)
    let write_buf = [1u8; 512];
    // 把写缓冲中的数据写到虚拟设备的 0 号扇区上
    async_blk.async_write(0, &write_buf).await?;
    // 从虚拟设备的 0 号扇区上读取数据到读缓冲
    async_blk.async_read(0, &mut read_buf).await?;
    // 比较读写结果
    assert_eq!(read_buf, write_buf);
    // 成功
    Ok(())
}

Rust 编译器会将上面的这个异步函数转换成一个 Future,然后这个 Future 会在飓风内核里面被包装成一个任务,放到共享调度器里面去运行,伪代码如下:

// 下面的接口定义和具体实现不一致
#[no_mangle]
pub extern "C" fn rust_main() -> ! {
    // 创建一个内核任务
    let task = KernelTask::new(virtio_test());
    // 将任务放入到共享调度器
    shared_scheduler.add_task(task);
    // 执行器执行任务
    Runtime::executor::run_until_idle();
    // 系统退出
    System::shutdown();
}

本项目大量参考了rCore社区中的virtio-drivers项目,感谢 rCore 社区!
笔者在写该项目之前学习了 rCore 教程第三版中关于设备驱动的章节,里面有对 virtio 基础知识非常详尽的讲解,因此本文档不会重复讲述这部分内容,非常推荐读者去浏览一下 rCore 教程相关章节。

初探 virtio

首先我们来思考两个问题:

  1. 什么是 virtio
  2. 为什么需要 virtio

第一个问题,virtio 是半虚拟化场景中的一个 IO 传输标准,它的标准文档可以在这里找到。可以理解为半虚拟化场景中客户机和硬件资源之间的数据传输需要一些通用标准(或协议)来完成,virtio 就是这样一套标准。
然后第二个问题,virtio 如上所述是一个标准,标准一般会有个作用就是提高兼容性,减少开发工作量。对于 virtio 来说,如果没有这个标准的话,那么开发者就需要为每个虚拟设备单独实现一个设备驱动,如果有 N 个虚拟设备,每个驱动程序的平均代码量是 M,那就需要开发者贡献 N x M 大小的代码量。但是如果有 virtio 标准的话,开发者就可以针对这个标准写一套框架(假设框架的代码量为 A),然后再基于这个框架去写每个虚拟设备的驱动程序(平均代码量为 M - A),这样一来总代码量就是 A + N x (M - A) = N x M - (N - 1) x A,因此在虚拟设备数量较多的情况下,该标准可以减少开发者们的工作量。

virtio 架构大概可以分为三层:

网上很多博客会分四层,中间是 virtio 和 virtio-ring,但笔者写代码的时候没感受到中间分为两层的必要,因此这里分为三层,个人感觉这个区别不影响实现

设备前端驱动有块设备驱动(virtio_blk),网卡驱动(virtio-net)等等,后端就是各种具体的硬件实现。笔者实现的是前端驱动部分。更多详细信息请查阅 rCore 教程第三版。

virtio 块设备架构

笔者实现的是各种虚拟设备驱动中的块设备驱动,下面是一张架构图,其中包含一些具体实现:
virtio架构

下面的分析针对的场景是 virtio 设备是挂载在系统总线上,也就是 MMIO 方式的场景。对于其他 virtio 设备的呈现方式,比如挂在 PCI 总线上,由于笔者还没有深入研究,这里不作展开。

其中DMA是一种内存与外设进行数据传输的手段,无需占用 CPU。操作系统内核分配一段连续的内存空间,用于存放虚拟队列,虚拟队列中包含三个部分,描述符表,可用环,还有已用环。
可用环和已用环中的项指向描述符表中的表项,可用环是驱动程序修改,设备只读的,而已用环是设备修改,驱动程序只读的。驱动程序想要发起一个 IO 请求的时候,会创建一个描述符链,并将该链的头部描述符索引放到可用环中。设备完成一次 IO 请求后,会更新已用环。一次 IO 操作的具体流程请参考 rCore 教程第三版。
描述符表中的表项是描述符,描述符的结构如以下代码所示:

#[repr(C, align(16))]
#[derive(Debug)]
pub struct Descriptor {
    /// buffer 的物理地址
    pub paddr: Volatile<u64>,
    /// buffer 的长度
    len: Volatile<u32>,
    /// 标识
    flags: Volatile<DescriptorFlags>,
    /// 下一个描述符的指针
    next: Volatile<u16>
}

注意这里的paddr是物理地址,因为虚拟设备会读取这里的地址和长度去直接操作内存中的数据,而这个操作是不经过 MMU,也就是不经过地址映射的,因此我们需要把缓冲区的物理地址而不是虚拟地址告诉虚拟设备。

BlockFuture 的设计

目前该设计不是最终设计,新设计出来之后会在这里更新

异步核心——Future

Future 是 Rust 语言异步编程模型中的核心概念,它可以被理解为一个“未完成的值”,这个值在刚创建的时候不代表返回结果,而是会在将来被异步运行时中的执行器不断地进行”Poll”操作。
而 Poll 操作的结果有两种,第一种是 Pending,表示因为没数据或者资源没准备好,还不能直接返回,第二种是 Ready(T),表示该 Future 已经完成,可以释放占用的栈资源,并返回结果 T。
在其他大部分编程语言中,Future 的表示方式是基于回调的方法,在这种方式中开发者交给 Future 一个回调函数,Future 会在完成的时候运行这个回调函数。这样的 Future 设计会存在一些问题,体现在很多开发者进行大量尝试之后,发现他们不得不写很多分配性的代码以及使用动态分发,另外每个回调过程都需要独立的内存空间,比如堆内存分配,这样一来违背了 Rust 零成本抽象的第二个原则:如果你要使用它,它将比你自己写要慢得多,那你为什么还要使用它。

关于 Rust 零成本抽象可以参考这篇文章

Rust 采用了基于轮询的新方案——不是由 Future 来调度回调函数,而是我们去轮询 Future,执行器做的就是这个工作,它负责实际运行 Future。当轮询到某个 Future 具体执行返回 Pending 的时候,这个 Future 将会进入“睡眠”状态,等待被reactor唤醒。
下面一张图简单地描绘了 Future 在异步运行时中运行的流程:
Future

上图来自: 知乎 @李晓辉 的文章

块设备读写的“将来返回值”——BlockFuture

在 Rust 异步编程模型的基本概念的基础上,笔者尝试实现一款异步版的 virtio 块设备驱动。
首先笔者的大致思路是:对块设备的一次读写操作不会直接返回结果,而是返回一个 Future(BlockFuture),这个 Future 会放到具体异步运行时中运行。当对这个 Future 进行 Poll 操作返回 Pending 的时候,将其睡眠。当数据准备好的时候,虚拟设备会产生一个外部中断,操作系统内核收到该中断后,通过某种方法唤醒这个 Future,让它可以再次被执行器进行 Poll 操作。

思路在这里,具体实现上会有些细节问题,这里先把 BlockFuture 的初步实现放出来:

pub struct BlockFuture {
    /// 该块设备的内部结构,用于 poll 操作的时候判断请求是否完成
    /// 如果完成了也会对这里的值做相关处理
    inner: Arc<Mutex<VirtIOBlockInner>>,
    /// IO 请求的描述符链头部
    head: u16,
    /// IO 请求缓冲区
    req: NonNull<BlockReq>,
    /// IO 回应缓冲区
    resp: NonNull<BlockResp>,
    /// 是否是第一次 poll
    first_poll: RefCell<bool>
}

VirtIOBlockInner的代码也一并给出来:

/// 并发场景中经常需要 VirtIOHeader 和 VirtQueue 共同完成一些原子操作
/// 因此把这两者放到一个结构体里面
pub struct VirtIOBlockInner {
    /// MMIO 头部
    pub header: &'static mut VirtIOHeader,
    /// 虚拟队列
    pub queue: VirtQueue,
    /// IO 请求池
    pub req_pool: [BlockReq; VIRT_QUEUE_SIZE],
    /// IO 回应池
    pub resp_pool: [BlockResp; VIRT_QUEUE_SIZE]
}

首先 BlockFuture 面对的是多并发的场景,对于一些可变的数据,我们需要保证其原子操作。VirtIOBlockInner 包含 MMIO 数据的可变引用,虚拟队列等频繁进行可变操作的数据,因此我们需要 Mutex 锁来保证 inner 成员的原子访问和修改。此处 Mutex 还有一个作用就是提供内部可变性,下文会讨论这里。 另外我们需要在多个地方拥有 VirtIOBlockInner 的可变引用,比如 VirtIOBlock 结构体需要保存一份,每次对虚拟设备的读写申请都需要创建一份放到 Future 中,因此我们需要在外面包一层原子引用计数 Arc。 对于 BlockFuture 中的 first_poll,这个成员表示该 Future 是否是第一次被 Poll,因为这个成员的读写不会影响并发场景中 BlockFuture 的运行正确性(至少目前不会),因此我们不对齐进行 Mutex 加锁操作,毕竟使用 Mutex 会产生一部分运行时开销。但是我们需要 Mutex 提供的内部可变性。原因是在对 BlockFuture 进行 Poll 操作的时候,我们需要获取 inner 的可变引用,也就意味着获取了 self 的可变引用,而我们不能同时获得 first_poll 的可变引用,因为后者也需要获取 self 的可变引用,Rust 编译器不允许同时拥有一个值的两个可变引用,所以笔者借助了 RefCell 的内部可变性解决这个问题。RefCell 允许我们在运行时检查借用规则,它用于开发者确信代码遵守借用规则,而编译器不能理解和确定的时候。 内部可变性同时体现在 VirtIOBlock 和 BlockFuture 的 inner 成员的设计上。比如对块设备进行异步读的代码,如果不使用内部可变性,则我们必须这样写:

impl VirtIOBlock {
    /// 以异步方式读取一个块
    pub fn async_read(&mut self, block_id: usize, buf: &mut [u8]) -> BlockFuture {
        // ...省略
        // 获取 inner 的可变引用
        let mut inner = &mut self.inner;
        // 对 inner 进行操作
        do_process!(inner);
        // 返回一个 Future
        // ...省略
        BlockFuture {
            // todo
        }
    }
}

这样子我们就必须在方法签名里加上 &mut self ——我们需要对 self.inner 进行修改。
然后,我们每次对虚拟设备请求读操作的时候都需要获取 VirtIOBlock 的可变引用,而又由于 Rust 的借用规则,任意时候只能有一个可变引用,也就是说任何时候只能有一个 IO 操作在进行,结果是退化回了同步 IO,无法发挥异步优势。

感谢 wrj 学长的 issue,指出了原本实现上的问题。

基于以上考虑,我对 VirtIOBlockInner 使用了 Arc<Mutex> 包了起来,借助 Mutex 的内部可变性,对块设备异步读的代码可以这样写:

impl VirtIOBlock {
    /// 以异步方式读取一个块
    pub fn async_read(&self, block_id: usize, buf: &mut [u8]) -> BlockFuture {
        // ...省略
        // 获取 inner 的锁
        let inner = self.inner.lock();
        // 对 inner 的 queue 成员进行可变操作
        do_process!(&mut inner.queue);
        // ...省略
        // 返回一个 Future
        BlockFuture {
            // todo
        }
    }
}

Poll in BlockFuture

下面为 BlockFuture 实现 Future Trait:

impl Future for BlockFuture {
    type Output = Result<()>;
    // warn: 这里需要仔细考虑操作的原子性
    // 这里可能有外部中断进入
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let mut inner = self.inner.lock();
        let (h, q) = inner.header_and_queue_mut();
        unsafe {
            // 如果是第一次 Poll,则通知设备,直接返回 Pending
            if *(self.first_poll.as_ptr()) {
                h.notify(0);
                *(self.first_poll.borrow_mut()) = false;
                return Poll::Pending;
            }
        }
        // 可用环是否有元素可以弹出,表示数据是否准备好
        match q.can_pop() {
            true => {
                // 更新已用环
                let pop_ret = q.pop_used()?;
                assert_eq!(self.head, pop_ret.0);
                unsafe {
                    // 读取设备回应状态
                    let resp = *self.resp.as_ptr();
                    match resp.status {
                        BlockRespStatus::Ok => {
                            if h.ack_interrupt() {
                                return Poll::Ready(Ok(()))
                            } else {
                                return Poll::Ready(Err(VirtIOError::AckInterruptError))
                            }
                        },
                        _ => return Poll::Ready(Err(VirtIOError::DeciveResponseError))
                    }
                }
            }
            false => {
                // 这里不进行唤醒,直接返回 pending
                // 外部中断到来的时候在内核里面唤醒
                Poll::Pending
            }
        }
    }
}

需要提的一点是按照 Future::poll 的实现规范,在返回 Pending 前一般需要将 waker 注册到某个 reactor,使得事件发生的时候能够唤醒 Future。但这里没有注册 Waker 的操作。熟悉 Rust 异步模型的朋友可能会有这点疑惑。
这里是因为该驱动库目前还是和飓风内核紧密结合的,返回的 Future 会在飓风内核里面被包装成一个任务,这个任务是我们异步运行时调度的基本单位,而飓风内核中 executor 的实现是每次 poll 一个任务的时候去注册的 waker,因此这里没有注册。
这里有着兼容性设计的问题,但目前我们想要一个可以结合飓风内核运行的版本。

Async Read/Write

在 BlockFuture 的基础上,我们就可以实现异步的块设备读写函数,以读操作为例:

impl VirtIOBlock {
    /// 以异步方式读取一个块
    pub fn async_read(&self, block_id: usize, buf: &mut [u8]) -> BlockFuture {
        if buf.len() != BLOCK_SIZE {
            panic!("[virtio] buffer size must equal to block size - 512!");
        }
        let mut inner = self.lock_inner.lock();
        // ...省略对 inner 内部数据修改逻辑
        // 不在这里通知设备,在 BlockFuture 第一次 poll 的时候通知
        // h.notify(0);
        BlockFuture {
            inner: Arc::clone(&self.lock_inner),
            // ...省略一部分成员
            first_poll: RefCell::new(true)
        }
    }
}

然后在操作系统内核里面就可以用下面的语句以异步方式读取块设备:

let async_blk = VirtIOBlock::new();
let mut buf = [0u8; 512];
async_blk.read(0, &mut buf).await.expect("failed to read block!");

在飓风内核上运行

初步实现好了 virtio 异步块设备驱动库,我们现在结合基于共享调度器的飓风内核来运行一些块设备读写任务。

为了容易理解,后面会使用伪码呈现。 关于飓风内核的设计可以阅读这个文档

// 定义一个异步写块设备函数
async fn async_write() {
    // 创建块设备抽象结构体
    let async_blk = VirtIOBlock::new();
    let buf = [0u8; 512];
    async_blk.async_write(0, &buf).await.expect("failed to write block!");
}

// 创建一个任务
// async_write() 返回的 Future 会被放到任务结构体中
let task = KernelTask::new(async_write());
// 往共享调度器里面添加任务
shared_scheduler.add_task(task);
// 运行执行器
Runtime::executor::run_until_idle();

飓风内核中的异步运行时会不断从共享调度器里面弹出任务,并对任务中的 Future 进行 Poll 操作,当第一次对 BlockFuture 进行 Poll 操作的时候,会返回 Pending,并且该任务进入睡眠状态。
等 virtio 外部中断来的时候,会在中断处理函数里面唤醒任务:

pub unsafe extern "C" fn rust_supervisor_external(trap_frame: &mut TrapFrame) -> *mut TrapFrame {
    let irq = plic::plic_claim();
    if irq == 1 {
        // virtio 外部中断
        // 获取需要唤醒的任务
        let need_wake_task = get_task!();
        do_wake!(need_wake_task);
        // 通知 PLIC 外部中断已经处理完
        plic::plic_complete(irq);
        trap_frame
    } else {
        panic!("unknown S mode external interrupt! irq: {}", irq);
    }
}

下次 Poll 的时候,BlockFuture 就可以返回 Ready(T) 了。

更多关于飓风内核和异步 virtio 块设备驱动结合的具体实现请看这里

TODO

由于笔者还不是很熟悉 Rust 异步模型的写法,因此库里面有些地方为了绕过所有权和生命周期系统,使用了 unsafe 代码。另外还有一些结构体没有做到天然 Send 和 Sync,不能在编译器层面判断是否并发安全。
该驱动库目前只通过了一个很简单的读写测试,需要更多复杂场景下的测试来验证其正确性。
希望后面可以把这个驱动库完善好,代码写得更加 Rust,然后回馈给 rCore 社区。