Introduction to vhost-device-i2c

0 前言

vhost-device-i2c来源于rust-vmm社区的vhost-device仓库。vhost-device-i2c作为一个用户空间的daemon,是vhost-user协议中的back-end部分,该部分是virtqueue的消费者,主要完成virtio协议的数据层面的工作,其架构如下图所示。本文主要是对vhost-device-i2c源码进行讲解,看看如何实现一个vhost-user 设备的backend部分。

vhost-user diagram

1 线程模型

vhost-user-i2c

上图详细描述了一个 vhost-device-i2c 进程的结构图。此进程可能涉及多个 vhost-user 守护进程,它们通过克隆(clone)共享 I2C 映射(I2C_map)。每个守护进程内部有两个子线程:

  1. vring_worker:该线程负责处理 vring 中的请求,它在守护进程创建时同时被初始化。
  2. socket_worker:此线程专门处理基于 socket 的 vhost-user 协议交互,它在守护进程启动后创建。

守护进程启动后,会立即创建一个 socket 以便监听。当虚拟机管理程序(VMM)尝试连接时,守护进程通过新的文件描述符(fd)与 VMM 交互。在 socket_worker 线程中,会调用 handle_request() 函数来处理 vhost-user 协议请求。

一旦协议协商完成,位于 GVM 的前端驱动程序便会将 I/O 请求提交到 virtqueue。在执行 vring.kick() 以通知后端之后,vhost-device-i2c 通过 eventfd 被告知前端驱动程序已发送 I/O 请求,进而由 vring_worker 调用后端的 handle_event() 函数来处理这些 I/O 请求,并最终操作物理设备上的 ioctl。

值得一提的是,在当前的实现中,一个用于监听的 socket 只能支持单一 VMM 的连接。这意味着,该 socket 采用阻塞模式,accept() 函数只会调用一次。目前暂不支持重连。一旦GVM断开连接,socket线程会被关闭。不过整个daemon会重新启动。

2 目录结构

1
2
3
4
5
├── Cargo.toml
└── src
├── i2c.rs
├── main.rs
└── vhu_i2c.rs

组成vhost-device-i2c的源代码只有三个文件

  1. main.rs:读取参数并依赖VhostUserDaemon框架实现Daemon进程的创建
  2. i2c.rs:定义和实现与物理I2C适配器进行交互的逻辑和方法,例如打开i2c适配器,与i2c适配器进行ioctl等
  3. vhu_i2c.rs:实现处理virtqueue, 这一部分按照virtio i2c spec标准实现,需要实现VhostUserBackend trait

3 代码详解

3.1 main.rs

main中使用vhost-user-backend crate提供的vhost-user banckend框架, 整个框架通过VhostUserDaemon实现了vhost-user backend后端服务。

1
2
3
4
5
6
7
8
9
10
11
12
// 读取配置并创建I2c_map 
let config = I2cConfiguration::try_from(args).unwrap();
let i2c_map = I2cMap::new(&config.devices);

//创建VhostUserI2cBackend
let backend = VhostUserI2cBackend::new(i2c_map.clone());

// 创建一个VhostUserDaemon实例
let mut daemon = VhostUserDaemon::new( String::from("vhost-device-i2c-backend"), backend, GuestMemoryAtomic::new(GuestMemoryMmap::new()));

// 启动daemon
daemon.serve(&socket);

上面展示的是 main 函数的主要流程,整个流程设计得相当直观简单。我们可以按以下步骤逐一分析:

  1. 读取配置与初始化: 首先,函数从参数列表中读取配置信息,基于这些信息构造一个 I2C_MAP 实例。这个实例本质上是一个哈希表(hashmap),用于将设备的地址映射到相应的物理 I2C 适配器。例如,假设输入参数指定了适配器 11 和 18,其中适配器 11 连接了从地址为 114 和 116 的设备。当请求访问地址 114 的设备时,通过哈希表可以定位到 i2c-11 适配器,进而正确执行 ioctl 操作。这种映射机制允许一个 virtio-i2c 适配器控制多个物理 I2C 设备。显然,这要求各设备的从地址不能重复;若出现地址冲突,则需要配置多个守护进程(daemon)来分别管理这些设备。

i2c_map

  1. 构造VhostUserBackend和vhost-user-daemon并启动:使用 I2C_map 作为参数初始化一个 VhostUserBackend 实例,并以此实例作为基础来构建一个 vhost-user 守护进程(daemon)。初始化完成后,启动该守护进程

3.2 i2c.rs

在该文件中定义了在Linux中打开i2c适配器以及对i2c适配器进行ioctl的req协议,这部分按照标准做即可

  • struct I2cMsg i2c标准相关
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[repr(C)]
struct I2cMsg {
addr: u16,
flags: u16,
len: u16,
buf: *mut u8,
}

/// This is the structure as used in the I2C_RDWR ioctl call
#[repr(C)]
pub(crate) struct I2cRdwrIoctlData {
msgs: *mut I2cMsg,
nmsgs: u32,
}
  • trait I2cDevice

​ 为了实现没物理I2C设备的情况下能够测试I2C, 抽象出一个trait I2cDevice, 这个trait定义了I2cDevice 的一些行为集合,例如open, rdwr等操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub(crate) trait I2cDevice {
// Open the device specified by the adapter identifier, number or name.
fn open(adapter_identifier: &AdapterIdentifier) -> Result<Self>
where
Self: Sized;

// Corresponds to the I2C_FUNCS ioctl call.
fn funcs(&mut self) -> Result<u64>;

// Corresponds to the I2C_RDWR ioctl call.
fn rdwr(&self, reqs: &mut [I2cReq]) -> Result<()>;

// Corresponds to the I2C_SMBUS ioctl call.
fn smbus(&self, msg: &mut SmbusMsg) -> Result<()>;

// Corresponds to the I2C_SLAVE ioctl call.
fn slave(&self, addr: u64) -> Result<()>;

// Returns the adapter number corresponding to this device.
fn adapter_no(&self) -> u32;
}
  • struct PhysDevice

PhysDevice表征Linux OS下物理I2C设备,file对于userspace下的open /dev/i2c-对应的fd, adapter_no即标号,PhysDevice 需要实现trait I2cDevice下的方法,其中定义了如何与物理I2C设备进行交互

1
2
3
4
5
6
7
8
pub(crate) struct PhysDevice {
file: File,
adapter_no: u32,
}

impl I2cDevice for PhysDevice {
<snip>
}
  • struct DummyDevice

DummyDevice 的定位与PhysDevice 等同,只是为了功能测试,在此不赘述

  • struct I2cAdapter<D: I2cDevice>

​ 为了实现I2cDevice泛型的使用,创建struct I2cAdapter表征一个I2cAdapter,其中的device在实际使用中将是PhysDevice,在该结构体中定义一些i2c设备的方法,例如对从设备进行读写等,最终会调用device中的方法

1
2
3
4
5
6
7
8
9
10
pub(crate) struct I2cAdapter<D: I2cDevice> {
device: D,
adapter_no: u32,
smbus: bool,
}
impl I2cAdapter {
/// Perform i2c transfer
fn transfer(&self, reqs: &mut [I2cReq]) -> Result<()>;
<snip>
}
  • struct I2cMap<D: I2cDevice>

​ 为了实现在gvm中一个virtio-i2c adapter,可以访问多个物理i2c从设备,引入哈希表,其中key为从设备地址,value为物理I2cAdapter在Vec中的索引,如此一来,通过解析I2cMsg 中addr可以寻找到其对应的I2cAdapter,从而选择对应的物理设备进行i2c ioctl

1
2
3
4
pub(crate) struct I2cMap<D: I2cDevice> {
adapters: Vec<I2cAdapter<D>>,
device_map: HashMap<u16, usize>,
}

I2cMap 是i2c.rs中最上层的接口,会暴露给main.rs和vhu_i2c.rs文件使用, 需要注意的是,一个daemon的所有线程共享相同的I2cMap,因此物理从设备addr不能重复,比如说需要同时用/dev/i2c-11和/dev/i2c-18的某个从设备,但是他们的地址相同,那就只能启动两个daemon。

3.3 vhu_i2c.rs

在该文件中主要实现virtio-i2c标准相关的内容,包括如何处理virt-queue,如何解析virt-queue中数据,其中最重要的是VhostUserI2cBackend,给出整个结构关系图,当I/O请求来时,将按照虚线部分最终到达物理设备。

detail_i2c

  • virtio-i2c 标准中I/O request

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #[repr(C)]
    struct VirtioI2cOutHdr {
    addr: Le16,
    padding: Le16,
    flags: Le32,
    }
    #[repr(C)]
    struct VirtioI2cInHdr {
    status: u8,
    }
  • struct VhostUserI2cBackend

    struct VhostUserI2cBackend 表征i2c vhost-user backend,I2cMap作为参数用来与物理I2C进行交互。其需要实现trait VhostUserBackendMut下的方法,其中主要包含如何处理virtio_i2c 前端在virtqueue中的请求。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    pub(crate) struct VhostUserI2cBackend<D: I2cDevice> {
    i2c_map: Arc<I2cMap<D>>,
    event_idx: bool,
    pub exit_event: EventFd,
    mem: Option<GuestMemoryLoadGuard<GuestMemoryMmap>>,
    }
    ## 定义如何处理virtqueue中请求
    impl<D: I2cDevice> VhostUserI2cBackend<D> {
    fn process_requests(&self,requests: Vec<I2cDescriptorChain>,vring: &VringRwLock) -> Result<bool>
    }

    ## 实现VhostUserBackendMut
    impl<D: 'static + I2cDevice + Sync + Send> VhostUserBackendMut<VringRwLock, ()>for VhostUserI2cBackend<D>{
    fn handle_event(&mut self,device_event: u16,evset: EventSet,vrings: &[VringRwLock],_thread_id: usize,
    ) -> IoResult<bool>
    }

4 VhostUserDaemon浅谈

在这里,我将进一步介绍一些由rust-VMM提供的vhost-user架构接口,即VhostUserDaemon的技术细节。作为virtio中使用的vhost-user协议,它需要处理两个协议:

  • vhost-user协议:该协议与设备无关。

  • 具体设备的virtio协议:这个协议有部分与具体设备有关,因此需要实现具体的backend,并将其作为参数传递给VhostUserDaemon。

为了完成这两个协议,VhostUserDaemon内部创建了两个子线程来处理。让我们跟随流程图,看看何时以及如何创建这两个子线程:

vhostuserdaemon

第一步.通过new关联函数,我们将VhostUserI2CBackend作为参数创建一个VhostUserDaemon实例。这个实例会调用VhostUserHandle的new函数来创建一个具体的实例。

在这个结构体中,我们通过实现VhostUserHandleReqHandle这个trait来实现通用的virtio协议。在这里,vring worker线程被创建,并且与具体的virtio设备相关的行为通过backend这个参数被注册进去了。

第二步:调用start()启动这个daemon,这将创建一个socket并开始监听(只接受一个连接,阻塞)。

第三步:VMM与这个daemon建立连接。这样accept函数会返回一个文件描述符(fd)通过这个fd构建一个BackendReqHandler实例

第四步:调用start_daemon。在创建这个BackendReqHandler实例的过程中,会创建一个子线程,该实例完成了对vhost-user协议的定义。因此,任何socket请求都会被这里的handle_request处理。

第五步:在vhost-user协议完成后,virtio前后端正式完成了预备工作,当vring中存在新的请求,就会触发调用handle_event,然后就会调用backend具体的方法实现请求处理