QEMU:如何组织与 I2C 设备的透明交互

大家好!我是大聪明-PLUS

在嵌入式软件开发中,高效的硬件虚拟化正变得越来越重要,它显著提高了开发的速度和灵活性。无需焊接电路板、等待硬件到货,也无需在每个芯片的测试台之间带着示波器奔波。只需在笔记本电脑上运行虚拟机即可。

虚拟化允许您在完全可复现的条件下调试驱动程序和应用程序,并行处理不同的功能,甚至在物理设备准备就绪之前就开始编写代码。这在开发和测试嵌入式解决方案时尤为重要,因为嵌入式解决方案通常需要使用外围设备,例如 I2C 设备:温度、压力、湿度传感器、EEPROM 存储器和其他组件。

如何在虚拟环境中合理地组织与这些设备的交互?与GPIO一样,QEMU 中的 I2C 也面临着类似的挑战:需要一种透明地组织主机设备与虚拟模型外设模块之间通信的方法。开发人员通常使用 QMP 协议或特殊脚本来实现这一点,这既不方便又不直观。理想情况下,我更喜欢使用熟悉的实用程序,例如i2cgeti2cset,以及 libi2c 之类的库。

我们嵌入式软件开发部门萌生了一个想法,并通过libcuse库实现了这个想法。它允许客户操作系统直接与 QEMU 虚拟机上运行的虚拟 I2C 设备交互。这种方法保留了虚拟化的所有优势,同时仍然支持直接访问设备的实际数据。

CUSE:当"设备"位于内核之外时

CUSE是FUSE框架 的扩展 ,用于直接从用户空间实现字符设备驱动程序。开发人员无需编写在特权内核模式下运行的代码,而是可以创建一个常规应用程序。该应用程序使用 libfuse 库和 cuse 内核模块注册一个新的字符设备(例如 /dev/my_custom_device)。

这在实践中是如何应用的?让我们看看虚拟 I2C 控制器在 QEMU 中是如何工作的。

  1. QEMU 虚拟机管理程序中的 remote-i2c-controller 模块为客户操作系统模拟硬件 - I2C 控制器。

  2. 在客户操作系统中,此虚拟控制器驱动程序使用 CUSE 在主机系统上创建一个字符设备(例如 /dev/i2c-33)。主机上的程序(例如 i2cget 或 i2cset 等 i2-ctools 实用程序)现在可以打开并与此设备文件交互。

  3. 主机实用程序发出的所有系统调用(open()、read()、write())和 ioctl() 控制命令都通过 CUSE 机制重定向到 QEMU 中的虚拟 I2C 控制器。

  4. 反过来,QEMU 将这些命令转换为对客户操作系统的虚拟 I2C 总线的请求。

组件与 CUSE 的交互

架构:其内部工作原理

初始配置包括一个 QEMU 虚拟机、一个内部带有 I2C 接口的虚拟传感器、一个主机设备以及主机上的一组 i2c-tools 实用程序。

首先要向 QEMU 添加一个新的虚拟设备。在 QEMU 对象模型中,我们创建一个基本类型 (TYPE_DEVICE) 的设备。该设备将充当一个层,负责所有功能------从初始化客户系统中的虚拟 I2C 节点到处理后续的系统调用。

  • CUSE接口用于在用户空间创建和管理虚拟字符设备/dev/i2c-*。

  • I2C 操作控制块负责处理各种类型的 SMBus 命令和 I2C 事务。

  • 计时器和下半部机制提供异步事件处理,这使得系统在执行操作时不会被阻塞。

该模块开辟了许多重要的发展机会:

  • 无需物理访问硬件即可测试 I2C 设备的驱动程序,

  • 使用详细的 I2C 外设创建更精确的虚拟环境模型,

  • 节省开发嵌入式应用程序的时间和资源,

  • 使用现有的解决方案与 I2C 设备交互,例如 i2c-tools,

  • 测试QEMU虚拟机组件和I2C总线模块。

模块使用示例

要使用,您需要将模块添加到 QEMU 启动参数中:

复制代码
`./qemu-system-arm \
	-M virt \
	-cpu cortex-a53 \
	-nographic \
	-device tmp105,id=temp_sensor,address=0x40,bus=i2c0.0 \
	-device remote-i2c-controller,i2cbus=i2c0.0,devname=i2c-33 \
<...>`

我们指定的设备名称为:*remote-i2c-controller,*然后是 I2C 总线,我们为其创建一个虚拟节点并在 devname(/dev/i2c-33)中指定其名称。

启动后,系统中应该会出现一个可通过 FUSE 访问的特殊设备。让我们检查一下该设备是否已经出现:

复制代码
`ls /dev/i2c-33`

让我们尝试使用 i2c-tools 实用程序:

复制代码
`
i2cget -y 0 0x40 0x00
 
i2cset -y 0 0x40 0x01 0xAB`

执行

初始化FUSE接口

在 QEMU 结构中创建新设备后,我们的下一个任务是将 FUSE 会话集成到其中。

复制代码
`static int i2c_fuse_export(RemoteI2CControllerState *i2c, Error **errp)
{
	struct fuse_session *session = NULL;
	char fuse_opt_dummy[] = FUSE_OPT_DUMMY;
	char fuse_opt_fore[] = FUSE_OPT_FORE;
	char fuse_opt_debug[] = FUSE_OPT_DEBUG;
	char *fuse_argv[] = { fuse_opt_dummy, fuse_opt_fore, fuse_opt_debug };
	char dev_name[128];
	struct cuse_info ci = { 0 };
    char *curdir = get_current_dir_name();
	int ret;
 
	/* Set device name for CUSE dev info */
	sprintf(dev_name, "DEVNAME=%s", i2c->devname);
	const char *dev_info_argv[] = { dev_name };
 
	memset(&ci, 0, sizeof(ci));
	ci.dev_major = 0;
	ci.dev_minor = 0;
	ci.dev_info_argc = 1;
	ci.dev_info_argv = dev_info_argv;
	ci.flags = CUSE_UNRESTRICTED_IOCTL;
 
	int multithreaded;
	session = cuse_lowlevel_setup(ARRAY_SIZE(fuse_argv), fuse_argv, &ci,
      	                        &i2cdev_ops, &multithreaded, i2c);
	if (session == NULL) {
    	error_setg(errp, "cuse_lowlevel_setup() failed");
    	errno = EINVAL;
    	return -1;
	}
 
	/* FIXME: fuse_daemonize() calls chdir("/") */
	ret = chdir(curdir);
	if (ret == -1) {
    	error_setg(errp, "chdir() failed");
    	return -1;
	}
 
	i2c->ctx = iohandler_get_aio_context();
 
	aio_set_fd_handler(i2c->ctx, fuse_session_fd(session),
                   	read_from_fuse_export, NULL,
                   	NULL, NULL, i2c);
 
	i2c->fuse_session = session;
 
	trace_remote_i2c_master_fuse_export();
	return 0;
}`

cuse_lowlevel_setup() 会创建一个 FUSE 会话,但不会启动循环。我们将使用aio_set_fd_handler() 自行处理事件。

FUSE 操作处理程序

实现 FUSE 会话后的下一个任务是准备一组处理程序,这些处理程序将响应通过 FUSE 可用的标准操作:ioctl()、open()、release() 等。

复制代码
`static const struct cuse_lowlevel_ops i2cdev_ops = {
	.init    	= i2cdev_init,
	.open    	= i2cdev_open,
	.release 	= i2cdev_release,
	.read    	= i2cdev_read,
	.ioctl   	= i2cdev_ioctl,
	.poll    	= i2cdev_poll,
};`

主要处理人员:

  • open() 初始化设备上下文,检查访问权限并分配资源。例如,打开 /dev/i2c-33 会​​创建 i2cdev_state 结构,该结构存储总线状态、目标设备地址以及对 FUSE 会话的引用。

  • release() --- 释放资源,重置内部状态(例如,重置 I2C_SLAVE 地址),并终止活动操作。

  • ioctl() 是一个系统调用,允许我们处理所有标准 I2C 命令:

    • I2C_FUNCS - 返回支持的功能(i2c-dev 中的所有内容)。

    • I2C_SLAVE - 设置目标设备的地址:有效范围从 0x00 到 0x7F。

    • I2C_SMBUS --- 处理所有类型的 SMBus 操作:BYTE、BYTE_DATA、WORD_DATA、BLOCK_DATA、I2C_BLOCK。

模块中有一个函数负责处理系统调用,其中ioctl类型和对应的处理程序通过switch来确定:

复制代码
`static void i2cdev_ioctl(fuse_req_t req, int cmd, void *arg,
                      	struct fuse_file_info *fi, unsigned flags,
                      	const void *in_buf, size_t in_bufsz, size_t out_bufsz)
{
	RemoteI2CControllerState *s = fuse_req_userdata(req);
	<...>
 
	switch (ctl) {
	case I2C_SLAVE_FORCE:
    	fuse_reply_ioctl(req, 0, NULL, 0);
    	break;
	case I2C_FUNCS:
    	i2cdev_functional(s, req, arg, in_buf);
	break;
	case I2C_SLAVE:
    	i2cdev_address(s, req, arg, in_buf);
    	break;
	case I2C_SMBUS: {
    	i2cdev_cmd_smbus(s, req, arg, in_buf, in_bufsz, out_bufsz);
	}
	break;
	default:
    	fuse_reply_err(req, EINVAL);
	break;
	}
}`

处理程序会分析接收到的数据并生成响应结构。现在,当接收到系统调用时,系统会选择合适的处理程序,主机将从该处理程序接收响应。

用于虚拟 I2C 总线的适配器

乍一看,这似乎很简单:客户操作系统进行系统调用,然后我们将数据传输到虚拟 I2C 总线。但实际情况要复杂得多。问题在于,客户操作系统所需的数据格式与实际 I2C 总线能够理解的格式不一致。

Linux 使用 struct i2c_smbus_ioctl_data:

复制代码
`union i2c_smbus_data {
	__u8 byte;
	__u16 word;
	__u8 block[I2C_SMBUS_BLOCK_MAX + 2]; /* block[0] is used for length */
               	/* and one more for user-space compatibility */
};`

QEMU中的虚拟I2C总线使用i2c_start_send()和i2c_send()进行通信:

int i2c_send(I2CBus *bus, uint8_t data

我们编写了适配器函数,将通过 ioctl 接收的数据转换为 I2C 数据包,并将其发送到虚拟 I2C 总线,反之亦然。

主要适配器:

send_data_to_slave() --- 接受 i2c_smbus_ioctl_data 结构体、地址和操作类型。适配器功能:

  • 提取命令(cmd)和数据(data),

  • 形成一个字节包:[cmd, data[0], data[1], ...],

  • 使用正确的参数调用 i2c_smbus_write_*(),

  • 处理错误 - 例如,如果设备没有响应,则处理 EIO。

复制代码
`static void send_data_to_slave(RemoteI2CControllerState *i2c,
                           	fuse_req_t req,
                           	const struct i2c_smbus_ioctl_data *in_val,
                           	const void *in_buf)
{
	union i2c_smbus_data data;
	uint8_t buf[64] = { 0 };
	size_t i = 0;
 
	/* Get SMBus data structure */
	<...>
	/* Parse data from SMBus struct */
	<...>
 
	/* Send data to I2C bus */
	i2c_start_send(i2c->i2c_bus, i2c->address);
	for (i = 0; i < buf[2]; i++) {
    	i2c_send(i2c->i2c_bus, buf[3 + i]);
	}
 
	i2c->address = 0x0;
	i2c->ioctl_state = I2C_IOCTL_FINISHED;
	fuse_reply_ioctl(req, 0, NULL, 0);
 
	trace_remote_i2c_master_i2cdev_send(in_val->size);
}`

recv_data_from_slave() --- 接受地址、命令和响应缓冲区。适配器功能:

  • 使用所需类型调用 i2c_smbus_read_*(),

  • 将结果复制到 data->byte、data->word 或 data->block,

  • 返回读取的字节长度(或负错误代码)。

复制代码
`static void recv_data_from_slave(RemoteI2CControllerState *i2c,
                             	fuse_req_t req,
                             	const struct i2c_smbus_ioctl_data *in_val,
                             	const void *in_buf)
{
	union i2c_smbus_data *smbus_data = (union i2c_smbus_data *)(
    	in_buf + sizeof(struct i2c_smbus_ioctl_data)
	);
	uint8_t receive_byte = 0;
	size_t i = 0;
 
	/* Send command to slave */
	i2c_start_send(i2c->i2c_bus, i2c->address);
	i2c_send(i2c->i2c_bus, in_val->command);
	i2c_start_recv(i2c->i2c_bus, i2c->address);
 
	/* Receive data from slave */
	switch (in_val->size) {
	case I2C_SMBUS_BYTE_DATA:
    	smbus_data->byte = i2c_recv(i2c->i2c_bus);
	break;
	case I2C_SMBUS_WORD_DATA:
    	receive_byte = i2c_recv(i2c->i2c_bus);
    	smbus_data->word = ((uint16_t)receive_byte) & 0xFF;
    	receive_byte = i2c_recv(i2c->i2c_bus);
    	smbus_data->word |= (((uint16_t)receive_byte) << 8) & 0xFF00;
	break;
	case I2C_SMBUS_I2C_BLOCK_BROKEN:
	case I2C_SMBUS_BLOCK_DATA:
	case I2C_SMBUS_I2C_BLOCK_DATA:
	{
    	uint8_t len = smbus_data->block[0];
    	for (i = 0; i < len; i++) {
        	smbus_data->block[1 + i] = i2c_recv(i2c->i2c_bus);
    	}
	}
	break;
	}
 
	i2c->ioctl_state = I2C_IOCTL_FINISHED;
	fuse_reply_ioctl(req, 0, smbus_data, sizeof(union i2c_smbus_data *));
}`

操作异步

如果客户操作系统发送从传感器读取的请求,而此时总线已被另一个设备(例如 EEPROM)使用,则无法立即执行该操作,因此我们使用:

  • QEMU 定时器:remote_i2c_timer_cb(),

  • 下半部分处理程序:remote_i2c_bh(),

  • 通过 i2c_schedule_pending_master() 调度操作。

QEMU 有一个轻量级的下半部回调机制,用于延迟需要异步处理的工作。它不会阻塞主线程,并且可以按计划安全地进行调用。

运行i2c_schedule_pending_master(), 假设没有人控制 I2C 总线,将把remote-i2c-master放入主队列并调用 remote_i2c_bh() 处理程序在总线上执行必要的操作:

复制代码
`static void remote_i2c_bh(void *opaque)
{
	RemoteI2CControllerState *s = opaque;
 
	if (s->is_recv) {
    	recv_data_from_slave(s, s->req, s->in_val, s->in_buf);
	} else {
    	send_data_to_slave(s, s->req, s->in_val, s->in_buf);
	}
	i2c_end_transfer(s->i2c_bus);
	i2c_bus_release(s->i2c_bus);
 
	if (s->ioctl_state == I2C_IOCTL_FINISHED) {
    	s->ioctl_state = I2C_IOCTL_START;
    	s->last_ioctl = 0;
	}
}`

如果总线忙,则会启动一个计时器来检查总线的状态:

复制代码
`static void remote_i2c_timer_cb(void *opaque)
{
	RemoteI2CControllerState *s = opaque;
	s->is_recv = (s->ioctl_state == I2C_IOCTL_RECV);
	if (i2c_bus_busy(s->i2c_bus)) {
    	timer_mod(s->timer,
              	qemu_clock_get_ms(QEMU_CLOCK_VIRTUAL) + 5);
	} else {
    	i2c_bus_master(s->i2c_bus, s->bh);
    	i2c_schedule_pending_master(s->i2c_bus);
	}
}`

无论如何,来自主机的调用都会等待对发送的 ioctl 调用的响应。

I2C 设备交易示例

作为示例,让我们尝试使用熟悉的 i2cget 从地址 0x48 处的 TMP105 传感器读取温度。

remote-i2c-master中系统调用事务的一般流程图:

一旦虚拟节点打开,对支持的 I2C 功能的请求如下:

i2cget.c

if (ioctl(file, I2C_FUNCS, &funcs) < 0) { <...> }

I2C 支持的函数列表位于i2c.h中:

  • I2C_FUNC_I2C

  • I2C_FUNC_SMBUS_QUICK

  • I2C_FUNC_SMBUS_字节

  • I2C_FUNC_SMBUS_字节_数据

  • I2C_FUNC_SMBUS_BLOCK_DATA

  • I2C_FUNC_SMBUS_WORD_数据

  • I2C_FUNC_SMBUS_I2C_BLOCK

在响应中,我们的模块声明支持所有标准 I2C 操作。然后,客户系统发出 ioctl 调用来设置目标 I2C 设备的地址:

i2cget.c

if (ioctl(file, I2C_SLAVE, address) < 0) { <...> }

远程 i2c 主模块接收地址,检查其正确性(从 0 到 127 的验证)并将其存储在设备的内部状态中。

如果地址设置成功,系统即可使用该设备,所有操作都将指向指定的地址。该地址将保存到下一次 I2C_SLAVE 调用或会话结束,因此同一地址可用于多个操作,而无需重新设置。

此时,模块已准备好接受读取或写入调用。例如,读取一个字节数据的代码如下:

i2cget.c

复制代码
`struct i2c_smbus_ioctl_data args;
 
args.read_write = I2C_SMBUS_READ;
args.command = 0; // SMBus commands
args.size = I2C_SMBUS_BYTE;
args.data = data;
 
ioctl(file, I2C_SMBUS, &args);`

客户系统使用I2C_SMBUS ioctl 调用,它可以处理各种数据类型:

  • I2C_SMBUS_BYTE_DATA用于传输一个字节,

  • I2C_SMBUS_WORD_DATA为 16 位值,

  • I2C_SMBUS_BLOCK_DATA用于可变长度数据块。

read_write标志用于确定操作类型(读取或写入)。

当系统想要发送数据时,remote-i2c-master 会解析i2c_smbus_ioctl_data结构,提取命令和数据,然后将它们转换为实际 I2C 总线可以理解的格式。

当从主机请求数据时,会发生相反的过程:系统向设备发送命令,然后,一旦虚拟 I2C 总线发送了数据,就读取响应。

模块中的错误通过返回错误代码来处理。这些错误代码会被传回客户操作系统,使其能够正确响应设备问题。

当系统请求发送或接收数据时,远程 i2c 主设备会检查总线状态。如果总线繁忙,则使用 remote_i2c_timer_cb() 定时器在指定时间间隔后重试。在总线上建立主设备状态后,将调用下半部分处理程序 remote_i2c_bh()。根据操作类型,在其中调用 recv_data_from_slave() 或 send_data_to_slave(),完成系统调用并将结果返回给客户机操作系统。

结论

在 QEMU 中开发remote-i2c-master模块使我们能够实现与系统的这种程度的集成,以至于客户操作系统不会注意到真实和虚拟 I2C 设备之间的差异。

开发人员可以使用熟悉的工具和方法来处理虚拟传感器、EEPROM 等。这为更便捷地测试和调试嵌入式系统(尤其是在使用微控制器和传感器时)提供了机会。

该模块是通用的;它可以在不同的虚拟机中使用,而无需发明与虚拟 I2C 总线协同工作的变通方法。

相关推荐
sulikey4 小时前
【Linux权限机制深入理解】为何没有目录写权限仍能修改文件权限?
linux·运维·笔记·ubuntu·centos
liu****4 小时前
8.list的模拟实现
linux·数据结构·c++·算法·list
biubiubiu07064 小时前
VPS SSH密钥登录配置指南:告别密码,拥抱安全
linux
人生苦短,菜的抠脚5 小时前
Linux 内核IIO sensor驱动
linux·驱动开发
jz_ddk5 小时前
[LVGL] 从0开始,学LVGL:进阶应用与项目实战(上)
linux·信息可视化·嵌入式·gui·lvgl·界面设计
望获linux6 小时前
【实时Linux实战系列】Linux 内核的实时组调度(Real-Time Group Scheduling)
java·linux·服务器·前端·数据库·人工智能·深度学习
MC丶科6 小时前
【SpringBoot常见报错与解决方案】端口被占用?Spring Boot 修改端口号的 3 种方法,第 3 种 90% 的人不知道!
java·linux·spring boot
江公望6 小时前
ubuntu kylin(优麒麟)和标准ubuntu的区别浅谈
linux·服务器·ubuntu·kylin
Lynnxiaowen6 小时前
今天我们开始学习python语句和模块
linux·运维·开发语言·python·学习