Skip to content

Latest commit

 

History

History
1109 lines (769 loc) · 63.6 KB

2device-driver-2.rst.org

File metadata and controls

1109 lines (769 loc) · 63.6 KB

virtio设备驱动程序 =========================================

本节导读


本节主要介绍了QEMU模拟的RISC-V计算机中的virtio设备的架构和重要组成部分,以及面向virtio设备的驱动程序主要功能;并对virtio-blk设备及其驱动程序,virtio-gpu设备及其驱动程序进行了比较深入的分析。这里选择virtio设备来进行介绍,主要考虑基于两点考虑,首先这些设备就是QEMU模拟的高性能物理外设,操作系统可以面向这些设备编写出合理的驱动程序(如Linux等操作系统中都有virtio设备的驱动程序,并被广泛应用于云计算虚拟化场景中。);其次,各种类型的virtio设备,如块设备(virtio-blk)、网络设备(virtio-net)、键盘鼠标类设备(virtio-input)、显示设备(virtio-gpu)具有对应外设类型的共性特征、专有特征和与具体处理器无关的设备抽象性。通过对这些设备的分析和比较,能够比较快速地掌握各类设备的核心特点,并掌握编写裸机或操作系统驱动程序的关键技术。

virtio设备


virtio概述 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. chyyuu https://blogs.oracle.com/linux/post/introduction-to-virtio https://docs.oasis-open.org/virtio/virtio/v1.1/csprd01/virtio-v1.1-csprd01.html https://ozlabs.org/~rusty/virtio-spec/virtio-paper.pdf

Rusty Russell 在2008年左右设计了virtio协议,并开发了相应的虚拟化解决方案 lguest,形成了VirtIO规范(Virtual I/O Device Specification)。其主要目的是为了简化和统一虚拟机(Hypervisor)的设备模拟,并提高虚拟机环境下的I/O性能。virtio 协议是对hypervisor 中的一组通用模拟设备的抽象,即virtio协议定义了虚拟设备的输入/输出接口。而基于virtio协议的I/O设备称为virtio设备。下图列出了两种在虚拟机中模拟外设的总体框架。

.. chyyuu https://cloud.tencent.com/developer/article/1065771 virtio 简介

.. image:: ../../os-lectures/lec13/figs/two-hypervisor-io-arch.png

在上图左侧的虚拟机模拟外设的传统方案中,如果guest VM 要使用底层 host主机的资源,需要 Hypervisor 截获所有的I/O请求指令,然后模拟出这些I/O指令的行为,这会带来较大的性能开销。

.. note::

**虚拟机(Virtual Machine,VM)**

虚拟机是物理计算机的虚拟表示形式或仿真环境。 虚拟机通常被称为访客机(Guest Machine,简称Guest)或访客虚拟机(Guest VM),而它们运行所在的物理计算机被称为主机(Host Machine,简称Host)。

**虚拟机监视器 Hypervisor**

虚拟机监视器(Hypervisor或Virtual Machine Monitor,简称VMM)是创造并且运行虚拟机的软件、固件、或者硬件。这样主机硬件上能同时运行一个至多个虚拟机,这些虚拟机能高效地分享主机硬件资源。

在上图右侧的虚拟机模拟外设的virtio方案中,模拟的外设实现了功能最小化,即虚拟外设的数据面接口主要是与guest VM 共享的内存、控制面接口主要基于内存映射的寄存器和中断机制。这样guest VM 通过访问虚拟外设来使用底层 host主机的资源时,Hypervisor只需对少数寄存器访问和中断机制进行处理,实现了高效的I/O虚拟化过程。

.. note::

**数据面(Data Plane)**

设备与处理器之间的I/O数据传输相关的数据设定(地址、布局等)与传输方式(基于内存或寄存器等)

**控制平面(Control Plane)**

处理器发现设备、配置设备、管理设备等相关的操作,以及处理器和设备之间的相互通知机制。

另外,各种类型的virtio设备,如块设备(virtio-blk)、网络设备(virtio-net)、键盘鼠标类设备(virtio-input)、显示设备(virtio-gpu)具有共性特征和独有特征。对于共性特征,virtio设计了各种类型设备的统一抽象接口,而对于独有特征,virtio尽量最小化各种类型设备的独有抽象接口。这样,virtio就形成了一套通用框架和标准接口(协议)来屏蔽各种hypervisor的差异性,实现了guest VM和不同hypervisor之间的交互过程。

.. image:: ../../os-lectures/lec13/figs/compatable-hypervisors-io-arch.png

上图意味着什么呢?它意味着在guest VM 上看到的虚拟设备具有简洁通用的优势,这对运行在guest VM上的操作系统而言,可以设计出轻量高效的设备驱动程序(即上图的 Front-end drivers)。

从本质上讲,virtio是一个接口,允许运行在虚拟机上的操作系统和应用软件通过访问 virtio 设备使用其主机的设备。这些 virtio 设备具备功能最小化的特征,Guest VM中的设备驱动程序(Front-end drivers只需实现基本的发送和接收I/O数据即可,而位于Hypervisor中的Back-end drivers和设备模拟部分让主机处理其实际物理硬件设备上的大部分设置、维护和处理。这种设计方案极大减轻了virtio驱动程序的复杂性。

virtio设备是虚拟外设,存在于QEMU模拟的RISC-V 64 virt 计算机中。而我们要在操作系统中实现virtio驱动程序,来管理和控制这些virtio虚拟设备。每一类virtio设备都有自己的virtio接口,virtio接口包括了数据结构和相关API的定义。这些定义中,有共性内容,也有属于设备特定类型的非共性内容。

virtio架构 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

总体上看,virtio 架构可以分为上中下三层,上层包括运行在QEMU模拟器上的前端操作系统中各种驱动程序(Front-end drivers);下层是在QEMU中模拟的各种虚拟设备 Device;中间层是传输(transport)层,就是驱动程序与虚拟设备之间的交互接口,包含两部分:上半部是virtio接口定义,即I/O数据传输机制的定义:virtio 虚拟队列(virtqueue);下半部是virtio接口实现,即I/O数据传输机制的具体实现:virtio-ring,主要由环形缓冲区和相关操作组成,用于保存驱动程序和虚拟设备之间进行命令和数据交互的信息。

.. image:: virtio-arch.png

操作系统中virtio 驱动程序的主要功能包括:

  • 接受来自用户进程或操作系统其它组件发出的 I/O 请求
  • 将这些 I/O 请求通过virqueue发送到相应的 virtio 设备
  • 通过中断或轮询等方式查找并处理相应设备完成的I/O请求

Qemu或Hypervisor中virtio 设备的主要功能包括:

  • 通过virqueue接受来自相应 virtio 驱动程序的 I/O 请求
  • 通过设备仿真模拟或将 I/O 操作卸载到主机的物理硬件来处理I/O请求,使处理后的I/O数据可供 virtio 驱动程序使用
  • 通过寄存器、内存映射或中断等方式通知virtio 驱动程序处理已完成的I/O请求

运行在Qemu中的操作系统中的virtio 驱动程序和Qemu模拟的virtio设备驱动的关系如下图所示:

.. image:: ../../os-lectures/lec13/figs/virtio-driver-device.png

I/O设备基本组成结构 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

virtio设备代表了一类I/O通用设备,为了让设备驱动能够管理和使用设备。在程序员的眼里,I/O设备基本组成结构包括如下恩利:

  • 呈现模式:设备一般通过寄存器、内存或特定I/O指令等方式让设备驱动能看到和访问到设备;
  • 特征描述:让设备驱动能够了解设备的静态特性(可通过软件修改),从而决定是否或如何使用该设备;
  • 状态表示:让设备驱动能够了解设备的当前动态状态,从而确定如何进行设备管理或I/O数据传输;
  • 交互机制:交互包括事件通知和数据传输;对于事件通知,让设备驱动及时获知设备的状态变化的机制(可基于中断等机制),以及让设备及时获得设备驱动发出的I/O请求(可基于寄存器读写等机制);对于数据传输,让设备驱动能处理设备给出的数据,以及让设备能处理设备驱动给出的数据,如(可基于DMA或virtqueue等机制)。

virtio设备具体定义了设备驱动和设备之间的接口,包括设备呈现模式、设备状态域、特征位、通知、设备配置空间、虚拟队列等,覆盖了上述的基本接口描述。

virtio设备基本组成要素 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

virtio设备的基本组成要素如下:

  • 设备状态域(Device status field)
  • 特征位(Feature bits)
  • 通知(Notifications)
  • 设备配置空间(Device Configuration space)
  • 一个或多个虚拟队列(virtqueue)

其中的设备特征位和设备配置空间属于virtio设备的特征描述;设备状态域属于virtio设备初始化时的状态表示;通知和虚拟队列属于virtio设备的交互机制,也包含virtio设备运行时的状态表示。

virtio设备呈现模式 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

virtio设备支持三种设备呈现模式:

  • Virtio Over MMIO,虚拟设备直接挂载到系统总线上,我们实验中的虚拟计算机就是这种呈现模式;
  • Virtio Over PCI BUS,遵循PCI规范,挂在到PCI总线上,作为virtio-pci设备呈现,在QEMU虚拟的x86计算机上采用的是这种模式;
  • Virtio Over Channel I/O:主要用在虚拟IBM s390计算机上,virtio-ccw使用这种基于channel I/O的机制。

在Qemu模拟的RISC-V计算机 – virt 上,采用的是Virtio Over MMIO的呈现模式。这样在实现设备驱动时,我们只需要找到相应virtio设备的I/O寄存器等以内存形式呈现的地址空间,就可以对I/O设备进行初始化和管理了。

virtio设备特征描述 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

virtio设备特征描述包括设备特征位和设备配置空间。

**特征位**

特征位用于表示VirtIO设备具有的各种特性和功能。其中bit0 – 23是特定设备可以使用的feature bits, bit24 – 37预给队列和feature协商机制,bit38以上保留给未来其他用途。驱动程序与设备对设备特性进行协商,形成一致的共识,这样才能正确的管理设备。

**设备配置空间**

设备配置空间通常用于配置不常变动的设备参数(属性),或者初始化阶段需要设置的设备参数。设备的特征位中包含表示配置空间是否存在的bit位,并可通过在特征位的末尾添加新的bit位来扩展配置空间。

设备驱动程序在初始化virtio设备时,需要根据virtio设备的特征位和配置空间来了解设备的特征,并对设备进行初始化。

virtio设备状态表示 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

virtio设备状态表示包括在设备初始化过程中用到的设备状态域,以及在设备进行I/O传输过程中用到的I/O数据访问状态信息和I/O完成情况等。

**设备状态域**

设备状态域包含对设备初始化过程中virtio设备的6种状态:

  • ACKNOWLEDGE(1):驱动程序发现了这个设备,并且认为这是一个有效的virtio设备;
  • DRIVER (2) : 驱动程序知道该如何驱动这个设备;
  • FAILED (128) : 由于某种错误原因,驱动程序无法正常驱动这个设备;
  • FEATURES_OK (8) : 驱动程序认识设备的特征,并且与设备就设备特征协商达成一致;
  • DRIVER_OK (4) : 驱动程序加载完成,设备可以正常工作了;
  • DEVICE_NEEDS_RESET (64) :设备触发了错误,需要重置才能继续工作。

在设备驱动程序对virtio设备初始化的过程中,需要经历一系列的初始化阶段,这些阶段对应着设备状态域的不同状态。

**I/O传输状态**

设备驱动程序控制virtio设备进行I/O传输过程中,会经历一系列过程和执行状态,包括 `I/O请求` 状态、 `I/O处理` 状态、 `I/O完成` 状态、 `I/O错误` 状态、 `I/O后续处理` 状态等。设备驱动程序在执行过程中,需要对上述状态进行不同的处理。

virtio设备进行I/O传输过程中,设备驱动会指出 `I/O请求` 队列的当前位置状态信息,这样设备能查到I/O请求的信息,并根据 `I/O请求` 进行I/O传输;而设备会指出 `I/O完成` 队列的当前位置状态信息,这样设备驱动通过读取 `I/O完成` 数据结构中的状态信息,就知道设备是否完成I/O请求的相应操作,并进行后续事务处理。

比如,virtio_blk设备驱动发出一个读设备块的I/O请求,并在某确定位置给出这个I/O请求的地址,然后给设备发出’kick’通知(读或写相关I/O寄存器映射的内存地址),此时处于I/O请求状态;设备在得到通知后,此时处于 `I/O处理` 状态,它解析个I/O请求,完成个I/O请求的处理,即把磁盘块内容读入到内存中,并给出读出的块数据的内存地址,再通过中断通知设备驱动,此时处于 `I/O完成` 状态;如果磁盘块读取发生错误,此时处于 `I/O错误` 状态;设备驱动通过中断处理例程,此时处于 `I/O后续处理` 状态,设备驱动知道设备已经完成读磁盘块操作,会根据磁盘块数据所在内存地址,把数据传递给文件系统进行进一步处理;如果设备驱动发现磁盘块读错误,则会进行错误恢复相关的后续处理。

virtio设备交互机制 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

virtio设备交互机制包括基于Notifications的事件通知和基于virtqueue虚拟队列的数据传输。事件通知是指设备和驱动程序必须通知对方,它们有数据需要对方处理。数据传输是指设备和驱动程序之间进行进行I/O数据(如磁盘块数据、网络包)传输。

**Notification通知**

驱动程序和设备在交互过程中需要相互通知对方:驱动程序组织好相关命令/信息要通知设备去处理I/O事务,设备处理完I/O事务后,要通知驱动程序进行后续事务,如回收内存,向用户进程反馈I/O事务的处理结果等。

驱动程序通知设备可用“门铃 doorbell“机制,即采用PIO或MMIO方式访问设备特定寄存器,QEMU进行拦截再通知其模拟的设备。设备通知驱动程序一般用中断机制,即在QEMU中进行中断注入,让CPU响应并执行中断处理例程,来完成对I/O执行结果的处理。

**virtqueue虚拟队列**

在virtio设备上进行批量数据传输的机制被称为虚拟队列(virtqueue),virtio设备的虚拟队列(virtqueue)可以由各种数据结构(如数组、环形队列等)来具体实现。每个virtio设备可以拥有零个或多个virtqueue,每个virtqueue占用多个物理页,可用于设备驱动程序给设备发I/O请求命令和相关数据(如磁盘块读写请求和读写缓冲区),也可用于设备给设备驱动程序发I/O数据(如接收的网络包)。

.. _term-virtqueue:

**virtqueue虚拟队列** ~~~~~~~~~~~~~~~~~~~~~~~~~

virtio协议中一个关键部分是virtqueue,在virtio规范中,virtqueue是virtio设备上进行批量数据传输的机制和抽象表示。在设备驱动实现和Qemu中virtio设备的模拟实现中,virtqueue是一种数据结构,用于设备和驱动程序中执行各种数据传输操作。

操作系统在Qemu上运行时,virtqueue是 virtio 驱动程序和 virtio 设备访问的同一块内存区域。

当涉及到 virtqueue 的描述时,有很多不一致的地方。有将其与vring(virtio-rings或VRings)等同表示,也有将二者分别单独描述为不同的对象。我们将在这里单独描述它们,因为vring是virtueues的主要组成部分,是达成virtio设备和驱动程序之间数据传输的数据结构, vring本质是virtio设备和驱动程序之间的共享内存,但 virtqueue 不仅仅只有vring。

virtqueue由三部分组成(如下图所示):

  • 描述符表 Descriptor Table:描述符表是描述符为组成元素的数组,每个描述符描述了一个内存buffer 的address/length。而内存buffer中包含I/O请求的命令/数据(由virtio设备驱动填写),也可包含I/O完成的返回结果(由virtio设备填写)等。
  • 可用环 Available Ring:一种vring,记录了virtio设备驱动程序发出的I/O请求索引,即被virtio设备驱动程序更新的描述符索引的集合,需要virtio设备进行读取并完成相关I/O操作;
  • 已用环 Used Ring:另一种vring,记录了virtio设备发出的I/O完成索引,即被virtio设备更新的描述符索引d 集合,需要vrtio设备驱动程序进行读取并对I/O操作结果进行进一步处理。

.. image:: ../../os-lectures/lec13/figs/virtqueue-arch.png

**描述符表 Descriptor Table**

描述符表用来指向virtio设备I/O传输请求的缓冲区(buffer)信息,由 “Queue Size“ 个Descriptor(描述符)组成。描述符中包括buffer的物理地址 – addr字段,buffer的长度 – len字段,可以链接到 “next Descriptor“ 的next指针(用于把多个描述符链接成描述符链)。buffer所在物理地址空间需要设备驱动程序在初始化时分配好,并在后续由设备驱动程序在其中填写IO传输相关的命令/数据,或者是设备返回I/O操作的结果。多个描述符(I/O操作命令,I/O操作数据块,I/O操作的返回结果)形成的描述符链可以表示一个完整的I/O操作请求。

**可用环 Available Ring**

可用环在结构上是一个环形队列,其中的条目(item)仅由驱动程序写入,并由设备读出。可用环中的条目包含了一个描述符链的头部描述符的索引值。可用环用头指针(idx)和尾指针(last_avail_idx)表示其可用条目范围。virtio设备通过读取可用环中的条目可获取驱动程序发出的I/O操作请求对应的描述符链,然后virtio设备就可以进行进一步的I/O处理了。描述符指向的缓冲区具有可读写属性,可读的缓冲区用于Driver发送数据,可写的缓冲区用于接收数据。

比如,对于virtio-blk设备驱动发出的一个读I/O操作请求包含了三部分内容,由三个buffer承载,需要用到三个描述符 :(1) “读磁盘块”,(2)I/O操作数据块 – “数据缓冲区”,(3)I/O操作的返回结果 –“结果缓冲区”)。这三个描述符形成的一个完成的I/O请求链,virtio-blk从设备可通过读取第一个描述符指向的缓冲区了解到是“读磁盘块”操作,这样就可把磁盘块数据通过DMA操作放到第二个描述符指向的“数据缓冲区”中,然后把“OK”写入到第三个描述符指向的“结果缓冲区”中。

**已用环 Used Ring**

已用环在结构上是一个环形队列,其中的的条目仅由virtio设备写入,并由驱动程序读出。已用环中的条目也一个是描述符链的头部描述符的索引值。已用环也有头指针(idx)和尾指针(last_avail_idx)表示其已用条目的范围。

比如,对于virtio-blk设备驱动发出的一个读I/O操作请求(由三个描述符形成的请求链)后,virtio设备完成相应I/O处理,即把磁盘块数据写入第二个描述符指向的“数据缓冲区”中,可用环中对应的I/O请求条目“I/O操作的返回结果”的描述符索引值移入到已用环中,把“OK”写入到第三个描述符指向的“结果缓冲区”中,再在已用环中添加一个已用条目,即I/O操作完成信息;然后virtio设备通过中断机制来通知virtio驱动程序,并让virtio驱动程序读取已用环中的描述符,获得I/O操作完成信息,即磁盘块内容。

上面主要说明了virqueue中的各个部分的作用。对如何基于virtqueue进行I/O操作的过程还缺乏一个比较完整的描述。我们把上述基于virtqueue进行I/O操作的过程小结一下,大致需要如下步骤:

**1. 初始化过程:(驱动程序执行)**

1.1 virtio设备驱动在对设备进行初始化时,会申请virtqueue(包括描述符表、可用环、已用环)的内存空间;

1.2 并把virtqueue中的描述符、可用环、已用环三部分的物理地址分别写入到virtio设备中对应的控制寄存器(即设备绑定的特定内存地址)中。至此,设备驱动和设备就共享了整个virtqueue的内存空间。

**2. I/O请求过程:(驱动程序执行)**

2.1 设备驱动在发出I/O请求时,首先把I/O请求的命令/数据等放到一个或多个buffer中;

2.2 然后在描述符表中分配新的描述符(或描述符链)来指向这些buffer;

2.3 再把描述符(或描述符链的首描述符)的索引值写入到可用环中,更新可用环的idx指针;

2.4 驱动程序通过 `kick` 机制(即写virtio设备中特定的通知控制寄存器)来通知设备有新请求;

**3. I/O完成过程:(设备执行)**

3.1 virtio设备通过 `kick` 机制(知道有新的I/O请求,通过访问可用环的idx指针,解析出I/O请求;

3.2 根据I/O请求内容完成I/O请求,并把I/O操作的结果放到I/O请求中相应的buffer中;

3.3 再把描述符(或描述符链的首描述符)的索引值写入到已用环中,更新已用环的idx指针;

3.4 设备通过再通过中断机制来通知设备驱动程序有I/O操作完成;

**4. I/O后处理过程:(驱动程序执行)**

4.1 设备驱动程序读取已用环的idx信息,读取已用环中的描述符索引,获得I/O操作完成信息。

基于MMIO方式的virtio设备 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

基于MMIO方式的virtio设备没有基于总线的设备探测机制。 所以操作系统采用Device Tree的方式来探测各种基于MMIO方式的virtio设备,从而操作系统能知道与设备相关的寄存器和所用的中断。基于MMIO方式的virtio设备提供了一组内存映射的控制寄存器,后跟一个设备特定的配置空间,在形式上是位于一个特定地址上的内存区域。一旦操作系统找到了这个内存区域,就可以获得与这个设备相关的各种寄存器信息。比如,我们在 `virtio-drivers` crate 中就定义了基于MMIO方式的virtio设备的寄存器区域:

.. _term-virtio-mmio-regs:

.. code-block:: Rust

//virtio-drivers/src/header.rs pub struct VirtIOHeader { magic: ReadOnly<u32>, //魔数 Magic value … //设备初始化相关的特征/状态/配置空间对应的寄存器 device_features: ReadOnly<u32>, //设备支持的功能 device_features_sel: WriteOnly<u32>,//设备选择的功能 driver_features: WriteOnly<u32>, //驱动程序理解的设备功能 driver_features_sel: WriteOnly<u32>, //驱动程序选择的设备功能 config_generation: ReadOnly<u32>, //配置空间 status: Volatile<DeviceStatus>, //设备状态

//virtqueue虚拟队列对应的寄存器 queue_sel: WriteOnly<u32>, //虚拟队列索引号 queue_num_max: ReadOnly<u32>,//虚拟队列最大容量值 queue_num: WriteOnly<u32>, //虚拟队列当前容量值 queue_notify: WriteOnly<u32>, //虚拟队列通知 queue_desc_low: WriteOnly<u32>, //设备描述符表的低32位地址 queue_desc_high: WriteOnly<u32>,//设备描述符表的高32位地址 queue_avail_low: WriteOnly<u32>,//可用环的低32位地址 queue_avail_high: WriteOnly<u32>,//可用环的高32位地址 queue_used_low: WriteOnly<u32>,//已用环的低32位地址 queue_used_high: WriteOnly<u32>,//已用环的高32位地址

//中断相关的寄存器 interrupt_status: ReadOnly<u32>, //中断状态 interrupt_ack: WriteOnly<u32>, //中断确认 }

这里列出了部分关键寄存器和它的基本功能描述。在后续的设备初始化以及设备I/O操作中,会访问这里列出的寄存器。

在有了上述virtio设备的理解后,接下来,我们将进一步分析virtio驱动程序如何管理virtio设备来完成初始化和I/O操作。

virtio驱动程序


这部分内容是各种virtio驱动程序的共性部分,主要包括初始化设备,驱动程序与设备的交互步骤,以及驱动程序执行过程中的一些实现细节。

设备的初始化 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

操作系统通过某种方式(设备发现,基于设备树的查找等)找到virtio设备后,驱动程序进行设备初始化的常规步骤如下所示:

  1. 重启设备状态,设置设备状态域为0
  2. 设置设备状态域为 “ACKNOWLEDGE“ ,表明当前已经识别到了设备
  3. 设置设备状态域为 “DRIVER“ ,表明驱动程序知道如何驱动当前设备
  4. 进行设备特定的安装和配置,包括协商特征位,建立virtqueue,访问设备配置空间等, 设置设备状态域为 “FEATURES_OK“
  5. 设置设备状态域为 “DRIVER_OK“ 或者 “FAILED“ (如果中途出现错误)

注意,上述的步骤不是必须都要做到的,但最终需要设置设备状态域为 “DRIVER_OK“ ,这样驱动程序才能正常访问设备。

在 `virtio_driver` 模块中,我们实现了通用的virtio驱动程序框架,各种virtio设备驱动程序的共同的初始化过程为:

  1. 确定协商特征位,调用 `VirtIOHeader` 的 `begin_init` 方法进行virtio设备初始化的第1-4步骤;
  2. 读取配置空间,确定设备的配置情况;
  3. 建立虚拟队列1~n个virtqueue;
  4. 调用 `VirtIOHeader` `finish_init` 方法进行virtio设备初始化的第5步骤。

.. _term-virtio-blk-init:

比如,对于virtio_blk设备初始化的过程如下所示:

.. code-block:: Rust

// virtio_drivers/src/blk.rs //virtio_blk驱动初始化:调用header.begin_init方法 impl<H: Hal> VirtIOBlk<’_, H> { /// Create a new VirtIO-Blk driver. pub fn new(header: &’static mut VirtIOHeader) -> Result<Self> { header.begin_init(|features| { … (features & supported_features).bits() }); //读取virtio_blk设备的配置空间 let config = unsafe { &mut *(header.config_space() …) }; //建立1个虚拟队列 let queue = VirtQueue::new(header, 0, 16)?; //结束设备初始化 header.finish_init(); … } // virtio_drivers/src/header.rs // virtio设备初始化的第1~4步骤 impl VirtIOHeader { pub fn begin_init(&mut self, negotiate_features: impl FnOnce(u64) -> u64) { self.status.write(DeviceStatus::ACKNOWLEDGE); self.status.write(DeviceStatus::DRIVER); let features = self.read_device_features(); self.write_driver_features(negotiate_features(features)); self.status.write(DeviceStatus::FEATURES_OK); self.guest_page_size.write(PAGE_SIZE as u32); }

// virtio设备初始化的第5步骤 pub fn finish_init(&mut self) { self.status.write(DeviceStatus::DRIVER_OK); }

驱动程序与设备之间的交互 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. https://rootw.github.io/2019/09/firecracker-virtio/

.. 对于驱动程序和外设之间采用virtio机制(也可称为协议)进行交互的原理如下图所示。

.. .. image:: virtio-cpu-device-io2.png .. :align: center .. :name: virtio-cpu-device-io2

驱动程序与外设可以共同访问约定的virtqueue,virtqueue将保存设备驱动的I/O请求信息和设备的I/O响应信息。virtqueue由描述符表(Descriptor Table)、可用环(Available Ring)和已用环(Used Ring)组成。在上述的设备驱动初始化过程描述中已经看到了虚拟队列的创建过程。

当驱动程序向设备发送I/O请求(由命令/数据组成)时,它会在buffer(设备驱动申请的内存空间)中填充命令/数据,各个buffer所在的起始地址和大小信息放在描述符表的描述符中,再把这些描述符链接在一起,形成描述符链。

而描述符链的起始描述符的索引信息会放入一个称为环形队列的数据结构中。该队列有两类,一类是包含由设备驱动发出的I/O请求所对应的描述符索引信息,即可用环。另一类由包含由设备发出的I/O响应所对应的描述符索引信息,即已用环。

一个用户进程发起的I/O操作的处理过程大致可以分成如下四步:

  1. 用户进程发出I/O请求,经过层层下传给到驱动程序,驱动程序将I/O请求信息放入虚拟队列virtqueue的可用环中,并通过某种通知机制(如写某个设备寄存器)通知设备;
  2. 设备收到通知后,解析可用环和描述符表,取出I/O请求并在内部进行实际I/O处理;
  3. 设备完成I/O处理或出错后,将结果作为I/O响应放入已用环中,并以某种通知机制(如外部中断)通知CPU;
  4. 驱动程序解析已用环,获得I/O响应的结果,在进一步处理后,最终返回给用户进程。

.. image:: vring.png

**发出I/O请求的过程** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~

虚拟队列的相关操作包括两个部分:向设备提供新的I/O请求信息(可用环–>描述符–>缓冲区),以及处理设备使用的I/O响应(已用环–>描述符–>缓冲区)。 比如,virtio-blk块设备具有一个虚拟队列来支持I/O请求和I/O响应。在驱动程序进行I/O请求和I/O响应的具体操作过程中,需要注意如下一些细节。

驱动程序给设备发出I/O请求信息的具体步骤如下所示:

  1. 将包含一个I/O请求内容的缓冲区的地址和长度信息放入描述符表中的空闲描述符中,并根据需要把多个描述符进行链接,形成一个描述符链(表示一个I/O操作请求);
  2. 驱动程序将描述符链头的索引放入可用环的下一个环条目中;
  3. 如果可以进行批处理(batching),则可以重复执行步骤1和2,这样通过(可用环–>描述符–>缓冲区)来添加多个I/O请求;
  4. 根据添加到可用环中的描述符链头的数量,更新可用环;
  5. 将”有可用的缓冲区”的通知发送给设备。

注:在第3和第4步中,都需要指向适当的内存屏障操作(Memory Barrier),以确保设备能看到更新的描述符表和可用环。

.. note::

内存屏障 (Memory Barrier)

大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障在某些情况下成为必须要执行的操作。内存屏障是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在内存屏障之前的指令和内存屏障之后的指令不会由于系统优化等原因而导致乱序。内存屏障分为写屏障(Store Barrier)、读屏障(Load Barrier)和全屏障(Full Barrier),其作用是:

  • 防止指令之间的重排序
  • 保证数据的可见性

**将缓冲区信息放入描述符表的操作**

缓冲区用于表示一个I/O请求的具体内容,由零个或多个设备可读/可写的物理地址连续的内存块组成(一般前面是可读的内存块,后续跟着可写的内存块)。我们把构成缓冲区的内存块称为缓冲区元素,把缓冲区映射到描述符表中以形成描述符链的具体步骤:

对于每个缓冲区元素 “b“ 执行如下操作:

  1. 获取下一个空闲描述符表条目 “d“ ;
  2. 将 “d.addr“ 设置为 “b“ 的的起始物理地址;
  3. 将 “d.len“ 设置为 “b“ 的长度;
  4. 如果 “b“ 是设备可写的,则将 “d.flags“ 设置为 “VIRTQ_DESC_F_WRITE“ ,否则设置为0;
  5. 如果 “b“ 之后还有一个缓冲元素 “c“ :

    5.1 将 “d.next“ 设置为下一个空闲描述符元素的索引;

    5.2 将 “d.flags“ 中的 “VIRTQ_DESC_F_NEXT“ 位置1;

**更新可用环的操作**

描述符链头是上述步骤中的第一个条目 “d“ ,即描述符表条目的索引,指向缓冲区的第一部分。一个驱动程序实现可以执行以下的伪码操作(假定在与小端字节序之间进行适当的转换)来更新可用环:

.. code-block:: Rust

avail.ring[avail.idx % qsz] = head; //qsz表示可用环的大小

但是,通常驱动程序可以在更新idx之前添加许多描述符链 (这时它们对于设备是可见的),因此通常要对驱动程序已添加的数目 “added“ 进行计数:

.. code-block:: Rust

avail.ring[(avail.idx + added++) % qsz] = head;

idx总是递增,并在到达 “qsz“ 后又回到0:

.. code-block:: Rust

avail.idx += added;

一旦驱动程序更新了可用环的 “idx“ 指针,这表示描述符及其它指向的缓冲区能够被设备看到。这样设备就可以访问驱动程序创建的描述符链和它们指向的内存。驱动程序必须在idx更新之前执行合适的内存屏障操作,以确保设备看到最新描述符和buffer内容。

**通知设备的操作**

在包含virtio设备的Qemu virt虚拟计算机中,驱动程序一般通过对代表通知”门铃”的特定寄存器进行写操作来发出通知。

.. code-block:: Rust

// virtio_drivers/src/header.rs pub struct VirtIOHeader { // Queue notifier 用户虚拟队列通知的寄存器 queue_notify: WriteOnly<u32>, … impl VirtIOHeader { // Notify device. pub fn notify(&mut self, queue: u32) { self.queue_notify.write(queue); }

**接收设备I/O响应的操作** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

一旦设备完成了I/O请求,形成I/O响应,就会更新描述符所指向的缓冲区,并向驱动程序发送已用缓冲区通知(used buffer notification)。一般会采用中断这种更加高效的通知机制。设备驱动程序在收到中断后,就会更加I/O响应信息进行后续处理。相关的伪代码如下所示:

.. code-block:: Rust

// virtio_drivers/src/blk.rs impl<H: Hal> VirtIOBlk<’_, H> { pub fn ack_interrupt(&mut self) -> bool { self.header.ack_interrupt() }

// virtio_drivers/src/header.rs pub struct VirtIOHeader { // 中断状态寄存器 Interrupt status interrupt_status: ReadOnly<u32>, // 中断响应寄存器 Interrupt acknowledge interrupt_ack: WriteOnly<u32>, impl VirtIOHeader { pub fn ack_interrupt(&mut self) -> bool { let interrupt = self.interrupt_status.read(); if interrupt != 0 { self.interrupt_ack.write(interrupt); true } …

这里给出了virtio设备驱动通过中断来接收设备I/O响应的共性操作过程。如果结合具体的操作系统,还需与操作系统的总体中断处理、同步互斥、进程/线程调度进行结合。

virtio-blk驱动程序


virtio-blk设备是一种virtio存储设备,在QEMU模拟的RISC-V 64计算机中,以MMIO和中断等方式方式来与驱动程序进行交互。这里我们以Rust语言为例,给出virtio-blk设备驱动程序的设计与实现。主要包括如下内容:

  • virtio-blk设备的关键数据结构
  • 初始化virtio-blk设备
  • virtio-blk设备的I/O操作
  • virtio-blk设备的中断处理

virtio-blk设备的关键数据结构 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

这里我们首先需要定义virtio-blk设备的结构:

.. code-block:: Rust

pub struct VirtIOBlk<’a, H: Hal> { header: &’static mut VirtIOHeader, queue: VirtQueue<’a, H>, capacity: usize, }

其中的 “VirtIOHeader“ 数据结构的内存布局与上一节描述 :ref:`virt-mmio设备的寄存器内存布局 <term-virtio-mmio-regs>` 是一致的。而 “VirtQueue“ 数据结构与上一节描述的 :ref:`virtqueue <term-virtqueue>` 在表达的含义上基本一致的。

.. code-block:: Rust

#[repr(C)] pub struct VirtQueue<’a, H: Hal> { dma: DMA<H>, // DMA guard desc: &’a mut [Descriptor], // 描述符表 avail: &’a mut AvailRing, // 可用环 Available ring used: &’a mut UsedRing, // 已用环 Used ring queue_idx: u32, //虚拟队列索引值 queue_size: u16, // 虚拟队列长度 num_used: u16, // 已经使用的队列项目数 free_head: u16, // 空闲队列项目头的索引值 avail_idx: u16, //可用环的索引值 last_used_idx: u16, //上次已用环的索引值 }

其中成员变量 “free_head“ 指空闲描述符链表头,初始时所有描述符通过 “next“ 指针依次相连形成空闲链表,成员变量 “last_used_idx“ 是指设备上次已取的已用环元素位置。成员变量 “avail_idx“ 是指设备上次已取的已用环元素位置。

这里出现的 “Hal“ trait是 `virtio_drivers` 库中定义的一个trait,用于抽象出与具体操作系统相关的操作,主要与内存分配和虚实地址转换相关。这里我们只给出trait的定义,具体的实现在后续的章节中会给出。

.. code-block:: Rust

pub trait Hal { /// Allocates the given number of contiguous physical pages of DMA memory for virtio use. fn dma_alloc(pages: usize) -> PhysAddr; /// Deallocates the given contiguous physical DMA memory pages. fn dma_dealloc(paddr: PhysAddr, pages: usize) -> i32; /// Converts a physical address used for virtio to a virtual address which the program can /// access. fn phys_to_virt(paddr: PhysAddr) -> VirtAddr; /// Converts a virtual address which the program can access to the corresponding physical /// address to use for virtio. fn virt_to_phys(vaddr: VirtAddr) -> PhysAddr; }

初始化virtio-blk设备 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. 在 “virtio-drivers“ crate的 “examples\riscv\src\main.rs“ 文件中的 “virtio_probe“ 函数识别出virtio-blk设备后,会调用 “virtio_blk(header)“ 来完成对virtio-blk设备的初始化过程。其实具体的初始化过程与virtio规范中描述的一般virtio设备的初始化过程大致一样,步骤(实际实现可以简化)如下:

.. 1. (可忽略)通过将0写入状态寄存器来复位器件; .. 2. 将状态寄存器的ACKNOWLEDGE状态位置1; .. 3. 将状态寄存器的DRIVER状态位置1; .. 4. 从host_features寄存器读取设备功能; .. 5. 协商功能集并将接受的内容写入guest_features寄存器; .. 6. 将状态寄存器的FEATURES_OK状态位置1; .. 7. (可忽略)重新读取状态寄存器,以确认设备已接受协商的功能; .. 8. 执行特定于设备的设置:读取设备配置空间,建立虚拟队列; .. 9. 将状态寄存器的DRIVER_OK状态位置1,使得该设备处于活跃可用状态。

virtio-blk设备的初始化过程与virtio规范中描述的一般virtio设备的初始化过程大致一样,对其实现的初步分析在 :ref:`virtio-blk初始化代码 <term-virtio-blk-init>` 中。在设备初始化过程中读取了virtio-blk设备的配置空间的设备信息:

.. code-block:: Rust

capacity: Volatile<u64> = 32 //32个扇区,即16KB seg_max: Volatile<u32> = 254 cylinders: Volatile<u16> = 2 heads: Volatile<u8> = 16 sectors: Volatile<u8> = 63 blk_size: Volatile<u32> = 512 //扇区大小为512字节

了解了virtio-blk设备的扇区个数,扇区大小和总体容量后,还需调用 “ VirtQueue::new“ 成员函数来创建虚拟队列 “VirtQueue“ 数据结构的实例,这样才能进行后续的磁盘读写操作。这个函数主要完成的事情是分配虚拟队列的内存空间,并进行初始化:

  • 设定 “queue_size“ (即虚拟队列的描述符条目数)为16;
  • 计算满足 “queue_size“ 的描述符表 “desc“ ,可用环 “avail“ 和已用环 “used“ 所需的物理空间的大小 – “size“ ;
  • 基于上面计算的 “size“ 分配物理空间; //VirtQueue.new()
  • “VirtIOHeader.queue_set“ 函数把虚拟队列的相关信息(内存地址等)写到virtio-blk设备的MMIO寄存器中;
  • 初始化VirtQueue实例中各个成员变量(主要是 “dma“ , “desc“ ,“avail“ ,“used“ )的值。

做完这一步后,virtio-blk设备和设备驱动之间的虚拟队列接口就打通了,可以进行I/O数据读写了。但virtio_derivers 模块还没有与操作系统内核进行对接。我们还需在操作系统中封装virtio-blk设备,让操作系统内核能够识别并使用virtio-blk设备。首先操作系统需要建立一个表示virtio_blk设备的全局变量 “BLOCK_DEVICE“ :

.. code-block:: Rust

// os/src/drivers/block/virtio_blk.rs pub struct VirtIOBlock { virtio_blk: UPIntrFreeCell<VirtIOBlk<’static, VirtioHal>>, condvars: BTreeMap<u16, Condvar>, } // os/easy-fs/src/block_dev.rs pub trait BlockDevice: Send + Sync + Any { fn read_block(&self, block_id: usize, buf: &mut [u8]); fn write_block(&self, block_id: usize, buf: &[u8]); fn handle_irq(&self); } // os/src/boards/qemu.rs pub type BlockDeviceImpl = crate::drivers::block::VirtIOBlock; // os/src/drivers/block/mod.rs lazy_static! { pub static ref BLOCK_DEVICE: Arc<dyn BlockDevice> = Arc::new(BlockDeviceImpl::new()); }

操作系统对接virtio-blk设备初始化过程 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

从上面的代码可以看到,操作系统中表示表示virtio_blk设备的全局变量 “BLOCK_DEVICE“ 的类型是 “VirtIOBlock“ ,封装了来自virtio_derivers 模块的 “VirtIOBlk“ 类型。这样,操作系统内核就可以通过 “BLOCK_DEVICE“ 全局变量来访问virtio_blk设备了。而 “VirtIOBlock“ 中的 “condvars: BTreeMap<u16, Condvar>“ 条件变量结构,是用于进程在等待 I/O读或写操作完全前,通过条件变量让进程处于挂起状态。当virtio_blk设备完成I/O操作后,会通过中断唤醒等待的进程。而操作系统对virtio_blk设备的初始化除了封装 “VirtIOBlk“ 类型并调用 “VirtIOBlk::<VirtioHal>::new“ 外,还需要初始化 “condvars“ 条件变量结构,而每个条件变量对应着一个虚拟队列条目的编号,这意味着每次I/O请求都绑定了一个条件变量,让发出请求的线程/进程可以被挂起。

.. code-block:: Rust

impl VirtIOBlock { pub fn new() -> Self { let virtio_blk = unsafe { UPIntrFreeCell::new( VirtIOBlk::<VirtioHal>::new(&mut *(VIRTIO0 as *mut VirtIOHeader)).unwrap(), ) }; let mut condvars = BTreeMap::new(); let channels = virtio_blk.exclusive_access().virt_queue_size(); for i in 0..channels { let condvar = Condvar::new(); condvars.insert(i, condvar); } Self { virtio_blk, condvars, } } }

在上述初始化代码中,我们先看到看到 “VIRTIO0“ ,这是 Qemu模拟的virtio_blk设备中控制寄存器的物理内存地址, “VirtIOBlk“ 需要这个地址来对 “VirtIOHeader“ 数据结构所表示的virtio_blk设备控制寄存器进行读写操作,从而完成对某个具体的virtio_blk设备的初始化过程。而且我们还看到了 “VirtioHal“ 结构,它实现virtio_derivers 模块定义 “Hal“ trait约定的方法 ,提供DMA内存分配和虚实地址映射操作,从而让virtio_derivers 模块中 “VirtIOBlk“ 类型能够得到操作系统的服务。

.. code-block:: Rust

// os/src/drivers/bus/virtio.rs impl Hal for VirtioHal { fn dma_alloc(pages: usize) -> usize { //分配页帧 page-frames } let pa: PhysAddr = ppn_base.into(); pa.0 }

fn dma_dealloc(pa: usize, pages: usize) -> i32 { //释放页帧 page-frames 0 }

fn phys_to_virt(addr: usize) -> usize { addr }

fn virt_to_phys(vaddr: usize) -> usize { //把虚地址转为物理地址 } }

virtio-blk设备的I/O操作 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

virtio-blk驱动程序发起的I/O请求包含操作类型(读或写)、起始扇区(块设备的最小访问单位的一个扇区的长度512字节)、内存地址、访问长度;请求处理完成后返回的I/O响应仅包含结果状态(成功或失败,读操作请求的读出扇区内容)。系统产生的一个I/O请求在内存中的数据结构分为三个部分:Header(请求头部,包含操作类型和起始扇区);Data(数据区,包含地址和长度);Status(结果状态),这些信息分别放在三个buffer,所以需要三个描述符。

virtio-blk设备使用 “VirtQueue“ 数据结构来表示虚拟队列进行数据传输,此数据结构主要由三段连续内存组成:描述符表 “Descriptor[]“ 、环形队列结构的 “AvailRing“ 和 “UsedRing“ 。驱动程序和virtio-blk设备都能访问到此数据结构。

描述符表由固定长度(16字节)的描述符Descriptor组成,其个数等于环形队列长度,其中每个Descriptor的结构为:

.. code-block:: Rust

struct Descriptor { addr: Volatile<u64>, len: Volatile<u32>, flags: Volatile<DescFlags>, next: Volatile<u16>, }

包含四个域:addr代表某段内存的起始地址,长度为8个字节;len代表某段内存的长度,本身占用4个字节(因此代表的内存段最大为4GB);flags代表内存段读写属性等,长度为2个字节;next代表下一个内存段对应的Descpriptor在描述符表中的索引,因此通过next字段可以将一个请求对应的多个内存段连接成链表。

可用环 “AvailRing“ 的结构为:

.. code-block:: Rust

struct AvailRing { flags: Volatile<u16>, /// A driver MUST NOT decrement the idx. idx: Volatile<u16>, ring: [Volatile<u16>; 32], // actual size: queue_size used_event: Volatile<u16>, // unused }

可用环由头部的 “flags“ 和 “idx“ 域及 “ring“ 数组组成: “flags“ 与通知机制相关; “idx“ 代表最新放入IO请求的编号,从零开始单调递增,将其对队列长度取余即可得该I/O请求在可用环数组中的索引;可用环数组元素用来存放I/O请求占用的首个描述符在描述符表中的索引,数组长度等于可用环的长度(不开启event_idx特性)。

已用环 “UsedRing“ 的结构为:

.. code-block:: Rust

struct UsedRing { flags: Volatile<u16>, idx: Volatile<u16>, ring: [UsedElem; 32], // actual size: queue_size avail_event: Volatile<u16>, // unused }

已用环由头部的 “flags“ 和 “idx“ 域及 “ring“ 数组组成: “flags“ 与通知机制相关; “idx“ 代表最新放入I/O响应的编号,从零开始单调递增,将其对队列长度取余即可得该I/O响应在已用环数组中的索引;已用环数组元素主要用来存放I/O响应占用的首个描述符在描述符表中的索引, 数组长度等于已用环的长度(不开启event_idx特性)。

针对用户进程发出的I/O请求,经过系统调用,文件系统等一系列处理后,最终会形成对virtio-blk驱动程序的调用。对于写操作,具体实现如下:

.. code-block:: Rust

//virtio-drivers/src/blk.rs pub fn write_block(&mut self, block_id: usize, buf: &[u8]) -> Result { assert_eq!(buf.len(), BLK_SIZE); let req = BlkReq { type_: ReqType::Out, reserved: 0, sector: block_id as u64, }; let mut resp = BlkResp::default(); self.queue.add(&[req.as_buf(), buf], &[resp.as_buf_mut()])?; self.header.notify(0); while !self.queue.can_pop() { spin_loop(); } self.queue.pop_used()?; match resp.status { RespStatus::Ok => Ok(()), _ => Err(Error::IoError), } }

基本流程如下:

  1. 一个完整的virtio-blk的I/O写请求由三部分组成,包括表示I/O写请求信息的结构 “BlkReq“ ,要传输的数据块 “buf“,一个表示设备响应信息的结构 “BlkResp“ 。这三部分需要三个描述符来表示;
  2. (驱动程序处理)接着调用 “VirtQueue.add“ 函数,从描述符表中申请三个空闲描述符,每项指向一个内存块,填写上述三部分的信息,并将三个描述符连接成一个描述符链表;
  3. (驱动程序处理)接着调用 “VirtQueue.notify“ 函数,写MMIO模式的 “queue_notify“ 寄存器,即向 virtio-blk设备发出通知;
  4. (设备处理)virtio-blk设备收到通知后,通过比较 “last_avail“ (初始为0)和 “AvailRing“ 中的 “idx“ 判断是否有新的请求待处理(如果 “last_vail“ 小于 “AvailRing“ 中的 “idx“ ,则表示有新请求)。如果有,则 “last_avail“ 加1,并以 “last_avail“ 为索引从描述符表中找到这个I/O请求对应的描述符链来获知完整的请求信息,并完成存储块的I/O写操作;
  5. (设备处理)设备完成I/O写操作后(包括更新包含 “BlkResp“ 的Descriptor),将已完成I/O的描述符放入UsedRing对应的ring项中,并更新idx,代表放入一个响应;如果设置了中断机制,还会产生中断来通知操作系统响应中断;
  6. (驱动程序处理)驱动程序可用轮询机制查看设备是否有响应(持续调用 “VirtQueue.can_pop“ 函数),通过比较内部的 “VirtQueue.last_used_idx“ 和 “VirtQueue.used.idx“ 判断是否有新的响应。如果有,则取出响应(并更新 “last_used_idx“ ),将完成响应对应的三项Descriptor回收,最后将结果返回给用户进程。当然,也可通过中断机制来响应。

I/O读请求的处理过程与I/O写请求的处理过程几乎一样,仅仅是 “BlkReq“ 的内容不同,写操作中的 “req.type_“ 是 “ReqType::Out“,而读操作中的 “req.type_“ 是 “ReqType::In“ 。具体可以看看 “virtio-drivers/src/blk.rs“ 文件中的 “VirtIOBlk.read_block“ 函数的实现。

这种基于轮询的I/O访问方式效率比较差,为此,我们需要实现基于中断的I/O访问方式。为此在支持中断的 “write_block_nb“ 方法:

.. code-block:: Rust

pub unsafe fn write_block_nb( &mut self, block_id: usize, buf: &[u8], resp: &mut BlkResp, ) -> Result<u16> { assert_eq!(buf.len(), BLK_SIZE); let req = BlkReq { type_: ReqType::Out, reserved: 0, sector: block_id as u64, }; let token = self.queue.add(&[req.as_buf(), buf], &[resp.as_buf_mut()])?; self.header.notify(0); Ok(token) }

// Acknowledge interrupt. pub fn ack_interrupt(&mut self) -> bool { self.header.ack_interrupt() }

与不支持中的 “write_block“ 函数比起来, “write_block_nb“ 函数更简单了,在发出I/O请求后,就直接返回了。 “read_block_nb“ 函数的处理流程与此一致。而响应中断的 “ack_interrupt“ 函数只是完成了非常基本的 virtio设备的中断响应操作。在virtio-drivers中实现的virtio设备驱动是看不到进程、条件变量等操作系统的各种关键要素,只有与操作系统内核对接,才能完整实现基于中断的I/O访问方式。

操作系统对接virtio-blk设备I/O处理过程 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

操作系统中的文件系统模块与操作系统中的块设备驱动程序 “VirtIOBlock“ 直接交互,而操作系统中的块设备驱动程序 “VirtIOBlock“ 封装了virtio-drivers中实现的virtio_blk设备驱动。在文件系统的介绍中,我们并没有深入分析virtio_blk设备。这里我们将介绍操作系统对接virtio_blk设备驱动并完成基于中断机制的I/O处理过程。

接下来需要扩展文件系统对块设备驱动的I/O访问要求,这体现在 “BlockDevice“ trait的新定义中增加了 “handle_irq“ 方法,而操作系统的virtio_blk设备驱动程序中的 “VirtIOBlock“ 实现了这个方法,并且实现了既支持轮询方式,也支持中断方式的块读写操作。

.. code-block:: Rust

// easy-fs/src/block_dev.rs pub trait BlockDevice: Send + Sync + Any { fn read_block(&self, block_id: usize, buf: &mut [u8]); fn write_block(&self, block_id: usize, buf: &[u8]); // 更新的部分:增加对块设备中断的处理 fn handle_irq(&self); } // os/src/drivers/block/virtio_blk.rs impl BlockDevice for VirtIOBlock { fn handle_irq(&self) { self.virtio_blk.exclusive_session(|blk| { while let Ok(token) = blk.pop_used() { // 唤醒等待该块设备I/O完成的线程/进程 self.condvars.get(&token).unwrap().signal(); } }); }

fn read_block(&self, block_id: usize, buf: &mut [u8]) { // 获取轮询或中断的配置标记 let nb = *DEV_NON_BLOCKING_ACCESS.exclusive_access(); if nb { // 如果是中断方式 let mut resp = BlkResp::default(); let task_cx_ptr = self.virtio_blk.exclusive_session(|blk| { // 基于中断方式的块读请求 let token = unsafe { blk.read_block_nb(block_id, buf, &mut resp).unwrap() }; // 将当前线程/进程加入条件变量的等待队列 self.condvars.get(&token).unwrap().wait_no_sched() }); // 切换线程/进程 schedule(task_cx_ptr); assert_eq!( resp.status(), RespStatus::Ok, “Error when reading VirtIOBlk” ); } else { // 如果是轮询方式,则进行轮询式的块读请求 self.virtio_blk .exclusive_access() .read_block(block_id, buf) .expect(“Error when reading VirtIOBlk”); } }

“write_block“ 写操作与 “read_block“ 读操作的处理过程一致,这里不再赘述。

然后需要对操作系统整体的中断处理过程进行调整,以支持对基于中断方式的块读写操作:

.. code-block:: Rust

// os/src/trap/mode.rs //在用户态接收到外设中断 pub fn trap_handler() -> ! { … crate::board::irq_handler(); //在内核态接收到外设中断 pub fn trap_from_kernel(_trap_cx: &TrapContext) { … crate::board::irq_handler(); // os/src/boards/qemu.rs pub fn irq_handler() { let mut plic = unsafe { PLIC::new(VIRT_PLIC) }; // 获得外设中断号 let intr_src_id = plic.claim(0, IntrTargetPriority::Supervisor); match intr_src_id { … //处理virtio_blk设备产生的中断 8 => BLOCK_DEVICE.handle_irq(), } // 完成中断响应 plic.complete(0, IntrTargetPriority::Supervisor, intr_src_id); }

“BLOCK_DEVICE.handle_irq()“ 执行的就是 “VirtIOBlock“ 实现的中断处理方法 “handle_irq()“ ,从而让等待在块读写的进程/线程得以继续执行。

有了基于中断方式的块读写操作后,当某个线程/进程由于块读写操作无法继续执行时,操作系统可以切换到其它处于就绪态的线程/进程执行,从而让计算机系统的整体执行效率得到提升。

virtio-gpu驱动程序


让操作系统能够显示图形是一个有趣的目标。这可以通过在QEMU或带显示屏的开发板上写显示驱动程序来完成。这里我们主要介绍如何驱动基于QEMU的virtio-gpu虚拟显示设备。大家不用担心这个驱动实现很困难,其实它主要完成的事情就是对显示内存进行写操作而已。我们看到的图形显示屏幕其实是由一个一个的像素点来组成的,显示驱动程序的主要目标就是把每个像素点用内存单元来表示,并把代表所有这些像素点的内存区域(也称显示内存,显存, frame buffer)“通知”显示I/O控制器(也称图形适配器,graphics adapter),然后显示I/O控制器会根据内存内容渲染到图形显示屏上。

virtio-gpu设备的关键数据结构 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. code-block:: Rust

pub struct VirtIOGpu<’a> { header: &’static mut VirtIOHeader, rect: Rect, /// DMA area of frame buffer. frame_buffer_dma: Option<DMA>, /// Queue for sending control commands. control_queue: VirtQueue<’a>, /// Queue for sending cursor commands. cursor_queue: VirtQueue<’a>, /// Queue buffer DMA queue_buf_dma: DMA, /// Send buffer for queue. queue_buf_send: &’a mut [u8], /// Recv buffer for queue. queue_buf_recv: &’a mut [u8], }

“header“ 结构是virtio设备的共有属性,包括版本号、设备id、设备特征等信息。显存区域 “frame_buffer_dma“ 是一块要由操作系统或运行时分配的内存,后续的像素点的值就会写在这个区域中。virtio-gpu驱动程序与virtio-gpu设备之间通过两个 virtqueue 来进行交互访问,“control_queue“ 用于驱动程序发送显示相关控制命令, “cursor_queue“ 用于驱动程序发送显示鼠标更新的相关控制命令(这里暂时不用)。 “queue_buf_dma“ 是存放控制命令和返回结果的内存, “queue_buf_send“ 和 “queue_buf_recv“ 是 “queue_buf_dma“ 的切片。

初始化virtio-gpu设备 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

在 “virtio-drivers“ crate的 “examples\riscv\src\main.rs“ 文件中的 “virtio_probe“ 函数识别出virtio-gpu设备后,会调用 “virtio_gpu(header)“ 函数来完成对virtio-gpu设备的初始化过程。virtio-gpu设备初始化的工作主要是查询显示设备的信息(如分辨率等),并将该信息用于初始显示扫描(scanout)设置。具体过程如下:

.. code-block:: Rust

impl VirtIOGpu<’_> { pub fn new(header: &’static mut VirtIOHeader) -> Result<Self> { header.begin_init(|features| { let features = Features::from_bits_truncate(features); let supported_features = Features::empty(); (features & supported_features).bits() });

// read configuration space let config = unsafe { &mut *(header.config_space() as *mut Config) };

let control_queue = VirtQueue::new(header, QUEUE_TRANSMIT, 2)?; let cursor_queue = VirtQueue::new(header, QUEUE_CURSOR, 2)?;

let queue_buf_dma = DMA::new(2)?; let queue_buf_send = unsafe { &mut queue_buf_dma.as_buf()[..PAGE_SIZE] }; let queue_buf_recv = unsafe { &mut queue_buf_dma.as_buf()[PAGE_SIZE..] };

header.finish_init();

Ok(VirtIOGpu { header, frame_buffer_dma: None, rect: Rect::default(), control_queue, cursor_queue, queue_buf_dma, queue_buf_send, queue_buf_recv, }) }

首先是 “header.begin_init“ 函数完成了对virtio设备的共性初始化的常规步骤的前六步;第七步在这里被忽略;第八步完成对virtio-gpu设备的配置空间(config space)信息,不过这里面并没有我们关注的显示分辨率等信息;紧接着是创建两个虚拟队列,并分配两个 page (8KB)的内存空间用于放置虚拟队列中的控制命令和返回结果;最后的第九步,调用 “header.finish_init“ 函数,将virtio-gpu设备设置为活跃可用状态。

虽然virtio-gpu初始化完毕,但它目前还不能进行显示。为了能够进行正常的显示,我们还需建立显存区域 frame buffer,并绑定在virtio-gpu设备上。这主要是通过 “VirtIOGpu.setup_framebuffer“ 函数来完成的。

.. code-block:: Rust

pub fn setup_framebuffer(&mut self) -> Result<&mut [u8]> { // get display info let display_info: RespDisplayInfo = self.request(CtrlHeader::with_type(Command::GetDisplayInfo))?; display_info.header.check_type(Command::OkDisplayInfo)?; self.rect = display_info.rect;

// create resource 2d let rsp: CtrlHeader = self.request(ResourceCreate2D { header: CtrlHeader::with_type(Command::ResourceCreate2d), resource_id: RESOURCE_ID, format: Format::B8G8R8A8UNORM, width: display_info.rect.width, height: display_info.rect.height, })?; rsp.check_type(Command::OkNodata)?;

// alloc continuous pages for the frame buffer let size = display_info.rect.width * display_info.rect.height * 4; let frame_buffer_dma = DMA::new(pages(size as usize))?;

// resource_attach_backing let rsp: CtrlHeader = self.request(ResourceAttachBacking { header: CtrlHeader::with_type(Command::ResourceAttachBacking), resource_id: RESOURCE_ID, nr_entries: 1, addr: frame_buffer_dma.paddr() as u64, length: size, padding: 0, })?; rsp.check_type(Command::OkNodata)?;

// map frame buffer to screen let rsp: CtrlHeader = self.request(SetScanout { header: CtrlHeader::with_type(Command::SetScanout), rect: display_info.rect, scanout_id: 0, resource_id: RESOURCE_ID, })?; rsp.check_type(Command::OkNodata)?;

let buf = unsafe { frame_buffer_dma.as_buf() }; self.frame_buffer_dma = Some(frame_buffer_dma); Ok(buf) }

上面的函数主要完成的工作有如下几个步骤,其实就是驱动程序给virtio-gpu设备发控制命令,建立好显存区域:

  1. 发出 “GetDisplayInfo“ 命令,获得virtio-gpu设备的显示分辨率;
  2. 发出 “ResourceCreate2D“ 命令,让设备以分辨率大小( “width *height“ ),像素信息( “Red/Green/Blue/Alpha“ 各占1字节大小,即一个像素占4字节),来配置设备显示资源;
  3. 分配 “width *height * 4“ 字节的连续物理内存空间作为显存, 发出 “ResourceAttachBacking“ 命令,让设备把显存附加到设备显示资源上;
  4. 发出 “SetScanout“ 命令,把设备显示资源链接到显示扫描输出上,这样才能让显存的像素信息显示出来;

到这一步,才算是把virtio-gpu设备初始化完成了。

virtio-gpu设备的I/O操作 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

接下来的显示操作比较简单,就是在显存中更新像素信息,然后给设备发出刷新指令,就可以显示了,具体的示例代码如下:

.. code-block:: Rust

for y in 0..768 { for x in 0..1024 { let idx = (y * 1024 + x) * 4; fb[idx] = (0) as u8; //Blue fb[idx + 1] = (0) as u8; //Green fb[idx + 2] = (255) as u8; //Red fb[idx + 3] = (0) as u8; //Alpha } } gpu.flush().expect(“failed to flush”);

测试virtio设备


在 “virtio-drivers“ crate的 “examples\riscv\src\main.rs“ 文件是一个可让virtio-blk设备读写磁盘,让virtio-gpu设备显示图像的测试用例,我们可以通过执行如下命令来尝试这些virtio设备:

.. code:: shell

$ cd virtio-driver/examples/riscv64 $ make run

.. image:: virtio-test-example.png

目前virtio-blk驱动程序已经包含在第七章实现的操作系统中,有兴趣的同学可以参考这个例子,把virtio-gpu驱动程序也包含在本章的操作系统中。当然也鼓励设计实现其他更多的virtio设备的驱动程序,让操作系统有更有趣的I/O交互能力。