QEMU实战:I2C设备仿真与Linux驱动开发全流程解析

📅 发布时间:2026/7/8 21:48:56 👁️ 浏览次数:
QEMU实战:I2C设备仿真与Linux驱动开发全流程解析
1. 为什么要在QEMU里仿真I2C设备从零开始的实战意义如果你正在学习Linux驱动开发或者想深入理解I2C总线的工作原理那么“在真实硬件上调试”这个想法可能会让你吃尽苦头。我刚开始接触I2C驱动时手头只有一块开发板每次修改驱动代码都要经历编译、烧录、重启、测试的漫长循环。最头疼的是如果驱动有bug导致I2C控制器锁死或者设备无响应经常需要断电重启效率极低而且很难定位到是硬件问题、时序问题还是代码逻辑问题。后来我发现用QEMU来仿真整个硬件环境特别是仿真一个I2C从设备是破解这个困境的“金钥匙”。QEMU不仅仅是一个虚拟机它更是一个高度可配置的硬件模拟器。你可以在里面模拟出一整套包含CPU、内存、I2C控制器以及你自己定义的I2C设备的虚拟开发板。这样做的好处太多了首先调试变得无比方便你可以使用GDB直接连接QEMU单步跟踪驱动代码的执行观察每一次I2C传输的细节其次环境完全可控且可重复没有硬件接触不良、电源不稳等玄学问题最后学习成本大大降低你不需要购买具体的传感器芯片就能理解I2C协议栈从上到下应用层、驱动层、总线层、设备模拟层是如何打通的。今天我就以一个最经典的I2C EEPROM芯片——AT24C02为例手把手带你走完整个流程从在QEMU源码中添加一个虚拟的AT24C02设备到为这个虚拟设备编写Linux内核驱动最后在虚拟机上编写应用程序进行读写测试。整个过程就像搭积木我们会看到每一块积木的形状和作用。当你完成这个实验你会对I2C这个“两根线的总线”有脱胎换骨的理解。2. 搭建战场准备QEMU与Linux内核开发环境工欲善其事必先利其器。我们的实验需要两个核心部分一个修改过的、支持我们自定义I2C设备的QEMU和一个可以运行在该QEMU虚拟机里的Linux内核。听起来复杂但跟着步骤一步步来其实很简单。2.1 获取并编译QEMU源码首先我们需要一个能修改的QEMU源码。建议直接从官方仓库克隆这样能获得最新的稳定代码。# 克隆QEMU源码仓库 git clone https://gitlab.com/qemu-project/qemu.git cd qemu # 切换到某个稳定版本分支例如7.2.0避免使用最新的开发中分支 git checkout v7.2.0接下来配置编译选项。我们不需要编译所有架构的支持为了加快编译速度可以只针对我们需要的ARM架构例如vexpress-a9开发板进行编译。在QEMU源码目录下执行# 创建并进入构建目录 mkdir build cd build # 配置启用调试信息方便后续GDB调试并指定目标架构 ../configure --target-listarm-softmmu --enable-debug # 开始编译使用你机器核心数*2的线程数以加快速度比如8核机器用-j16 make -j$(nproc)编译完成后在build目录下会生成qemu-system-arm可执行文件这就是我们后续要使用的虚拟机程序。你可以把它路径加入环境变量或者直接用绝对路径调用。2.2 准备Linux内核与根文件系统虚拟机有了还需要一个“操作系统”。我们需要一个ARM版本的Linux内核。可以从kernel.org下载但更简单的方法是使用已经为QEMU适配好的内核镜像。许多嵌入式Linux教程资源里都提供了预编译的zImage和设备树文件dtb。为了最简化我们甚至可以直接使用QEMU内置的virt机器平台它自带了一个通用的内核。但为了学习驱动开发我建议你准备一个自己可以配置和编译的内核源码。比如使用Linaro或芯片厂商提供的SDK中的内核。这里给出一个获取和编译通用ARM内核的参考步骤# 示例获取Linux内核源码版本可选 wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.10.tar.xz tar -xf linux-5.10.tar.xz cd linux-5.10 # 配置一个基础的ARM defconfig make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- vexpress_defconfig # 进入菜单配置确保启用I2C驱动和EEPROM驱动 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- menuconfig在menuconfig中你需要确保以下选项被启用y或mDevice Drivers - I2C support - I2C device interface(启用用户态I2C访问)Device Drivers - I2C support - I2C Hardware Bus support下的相关适配器比如PrimeCell PL031 I2C具体看你的QEMU机器型号Device Drivers - I2C support - I2C EEPROMs / RAMs / ROMs support以及EEPROM support下的Atmel AT24Cxx EEPROMs配置好后编译内核和模块make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- zImage modules dtbs -j$(nproc)编译产物arch/arm/boot/zImage就是我们的内核镜像对应的设备树文件在arch/arm/boot/dts/目录下。根文件系统可以用BusyBox制作或者直接下载一个现成的ARM架构根文件系统镜像如rootfs.ext4。有了内核镜像和根文件系统我们的基础环境就准备好了。接下来就是最核心的一步给QEMU“造”一个虚拟的I2C设备。3. 深入QEMU设备模拟亲手打造一个虚拟AT24C02 EEPROM现在进入硬核环节修改QEMU源码添加一个I2C从设备。QEMU的设备模型基于QOMQEMU Object Model理解起来有点像面向对象的编程。我们要创建一个设备“类”并实现它的一系列“方法”回调函数。3.1 设备添加定义设备的“蓝图”在QEMU源码树中I2C设备通常位于hw/i2c/或hw/nvram/目录下。我们可以参考现有的eeprom_at24c.c文件。为了教学清晰我们可以在hw/misc/目录下创建一个新文件比如my_at24c.c。但为了与原文章衔接我们直接分析eeprom_at24c.c的核心结构。首先一个设备需要有一个类型注册的入口。这就像是向系统声明“嗨我这里有这样一种类型的设备”。看下面这段代码static const TypeInfo at24c_eeprom_type { .name TYPE_AT24C_EE, // 设备类型的唯一名称字符串 .parent TYPE_I2C_SLAVE, // 父类型是I2C_SLAVE表明这是一个I2C从设备 .instance_size sizeof(EEPROMState), // 设备实例所需内存大小 .class_size sizeof(I2CSlaveClass), // 设备类大小 .class_init at24c_eeprom_class_init, // 类初始化函数指针 };type_init(at24c_eeprom_register)这个宏是关键它确保在QEMU启动时我们的设备类型注册函数at24c_eeprom_register会被自动调用从而将at24c_eeprom_type信息注册到QEMU的类型系统中。在at24c_eeprom_class_init函数中我们完成了设备“类”的初始化主要是将设备的各种行为方法与具体的函数实现绑定起来。static void at24c_eeprom_class_init(ObjectClass *klass, void *data) { DeviceClass *dc DEVICE_CLASS(klass); I2CSlaveClass *k I2C_SLAVE_CLASS(klass); dc-realize at24c_eeprom_realize; // 设备实例化realize时调用的函数 k-event at24c_eeprom_event; // I2C总线事件回调如START, STOP k-recv at24c_eeprom_recv; // 主机读从设备时从设备发送数据的函数 k-send at24c_eeprom_send; // 主机写从设备时从设备接收数据的函数 dc-props at24c_eeprom_props; // 设备的可配置属性如大小、地址 dc-reset at24c_eeprom_reset; // 设备复位函数 }这里最需要关注的是k-event,k-recv,k-send这三个回调函数。它们直接对应了I2C协议的核心交互。realize函数可以理解为设备的“构造函数”在这里分配内存、初始化状态。reset函数则在虚拟机复位时被调用将设备状态清零。3.2 灵魂所在实现I2C协议回调函数这是仿真设备最精妙的部分。QEMU的I2C核心模块会模拟I2C控制器主设备的行为它通过调用我们注册的这些回调函数来与我们的虚拟从设备通信。我们需要在这几个函数里精确地模拟一块真实EEPROM芯片的行为。首先是event函数。它处理I2C总线事件比如开始START、停止STOP、应答NACK。对于EEPROM我们主要关心I2C_START_SEND主机要写数据、I2C_START_RECV主机要读数据和I2C_FINISH传输结束。通常在I2C_FINISH事件中如果数据有更改我们会将内存中的数据“持久化”到模拟的存储后端比如一个文件。在原代码中当传输结束时如果数据有变化ee-changed为真就会将内存数据写入一个块设备blk_pwrite。其次是send函数。当主机I2C控制器向从设备我们的EEPROM发送数据时这个函数被调用。参数data就是主机发来的一个字节。EEPROM的通信协议通常是主机先发送设备地址含读写位然后发送要访问的内存地址对于AT24C02是8位最后才是要写入的数据。所以send函数需要实现一个简单的状态机static int at24c_eeprom_send(I2CSlave *s, uint8_t data) { EEPROMState *ee AT24C_EE(s); // 第一阶段接收内存地址假设是8位地址所以需要1个地址字节 if (ee-haveaddr 1) { ee-cur data; // 接收到的第一个字节就是内存地址 ee-haveaddr; DPRINTK(Set pointer %04x\n, ee-cur); } else { // 第二阶段接收要写入的数据 if (ee-writable) { DPRINTK(Send %02x\n, data); ee-mem[ee-cur] data; // 将数据写入模拟的内存数组 ee-changed true; ee-cur (ee-cur 1) % ee-rsize; // 地址自增模拟自动递增模式 } } return 0; }这段代码是简化版它清晰地展示了“先收地址再收数据”的过程。ee-mem就是一个uint8_t数组模拟了EEPROM的存储空间。最后是recv函数。当主机要从设备读取数据时这个函数被调用。它需要返回一个字节的数据。对于EEPROM读取逻辑更简单直接返回当前地址ee-cur指向的内存内容然后将地址自增。static uint8_t at24c_eeprom_recv(I2CSlave *s) { EEPROMState *ee AT24C_EE(s); uint8_t ret ee-mem[ee-cur]; // 读取当前地址的数据 ee-cur (ee-cur 1) % ee-rsize; // 地址自增 DPRINTK(Recv %02x\n, ret); return ret; }这里有一个关键点真实的I2C读操作主机在发送了设备地址和内存地址后会发起一个重复起始条件Repeated START然后切换方向进行读。但在QEMU的模型里这个复杂的流程被event回调中的I2C_START_RECV事件和recv函数的调用封装好了我们只需要关注“当主机要我发数据时我该发什么”这个核心逻辑。3.3 让设备“可见”编译与启动参数编写完设备代码后我们需要将其编译进QEMU。这需要修改QEMU源码目录下的hw/misc/meson.build或相应子系统的编译脚本添加我们的源文件。例如在meson.build中找到system_ss.add(when: CONFIG_..., if_true: files(...))这样的语句添加我们的my_at24c.c。更简单的方法是直接将我们的代码合并到现有的eeprom_at24c.c中并确保配置系统启用了该设备。通常eeprom_at24c.c已经默认被包含在CONFIG_AT24C或类似的配置中。我们可以通过检查./configure的输出或config-host.mak文件来确认。编译好新的QEMU后启动虚拟机时需要将我们的设备挂载到虚拟的I2C总线上。QEMU命令如下./qemu-system-arm -M vexpress-a9 -m 512M -kernel zImage \ -dtb vexpress-v2p-ca9.dtb \ -append root/dev/mmcblk0 consolettyAMA0 \ -drive filerootfs.ext4,formatraw,ifsd \ -device at24c-eeprom,address0x50关键就是最后这个-device参数at24c-eeprom是我们注册的设备类型名address0x50是7位的I2C从设备地址对应AT24C02的地址引脚全接地。这样在虚拟机启动后Linux内核就能在I2C总线上扫描到地址为0x50的设备了。4. 为虚拟设备编写Linux内核驱动现在虚拟硬件已经就绪。我们需要一个Linux驱动程序来与它对话。Linux的I2C设备驱动框架非常成熟我们的工作主要是实现一个符合框架的“客户端驱动”。4.1 设备树告诉内核设备在哪里在嵌入式Linux中硬件信息通常通过设备树Device Tree描述。我们需要在QEMU使用的设备树文件.dts中为我们虚拟的I2C设备添加一个节点。如果你使用的是vexpress-a9这类机器可能需要修改QEMU自带的设备树源文件或者动态加载一个覆盖层overlay。为了简化我们可以直接假设QEMU的virt机器或某些板子已经通过-device参数在内部生成了对应的设备树信息。更通用的方法是我们在Linux内核里使用i2c_new_client_device或i2c_register_board_info来静态注册设备信息。但为了更贴近真实嵌入式开发我们假设在设备树中有如下描述i2c0 { /* 假设这是虚拟的I2C控制器 */ clock-frequency 100000; // I2C总线速度100kHz status okay; eeprom: eeprom50 { // 设备节点 compatible atmel,24c02; // 用于匹配驱动的关键字符串 reg 0x50; // I2C设备地址 pagesize 16; // 可选的设备属性 }; };compatible属性是灵魂驱动代码里就是通过这个字符串来识别“这个设备归我管”。4.2 驱动代码骨架注册与匹配一个最简单的I2C设备驱动包含以下部分#include linux/module.h #include linux/i2c.h #include linux/fs.h /* 定义与设备树中匹配的兼容性字符串 */ static const struct of_device_id my_at24c_of_match[] { { .compatible atmel,24c02 }, { } }; MODULE_DEVICE_TABLE(of, my_at24c_of_match); /* 传统的i2c_device_id表用于非设备树匹配 */ static const struct i2c_device_id my_at24c_id[] { { at24c02, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, my_at24c_id); /* 探测函数当设备被匹配到时内核调用此函数 */ static int my_at24c_probe(struct i2c_client *client) { struct device *dev client-dev; printk(KERN_INFO AT24C02 detected at address 0x%02x\n, client-addr); // 这里可以进行设备初始化如分配私有数据结构、创建字符设备等 // 例如dev_set_drvdata(dev, my_private_data); return 0; // 返回0表示成功 } /* 移除函数当设备被移除或驱动卸载时调用 */ static void my_at24c_remove(struct i2c_client *client) { printk(KERN_INFO AT24C02 driver removed\n); // 清理资源 } /* 定义i2c_driver结构体这是驱动的主体 */ static struct i2c_driver my_at24c_driver { .driver { .name my_at24c, .of_match_table of_match_ptr(my_at24c_of_match), }, .probe my_at24c_probe, .remove my_at24c_remove, .id_table my_at24c_id, }; /* 模块入口注册驱动 */ static int __init my_at24c_init(void) { return i2c_add_driver(my_at24c_driver); } module_init(my_at24c_init); /* 模块出口注销驱动 */ static void __init my_at24c_exit(void) { i2c_del_driver(my_at24c_driver); } module_exit(my_at24c_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(Driver for AT24C02 EEPROM on QEMU);这个骨架驱动已经可以工作了。加载模块后如果内核在I2C总线上发现了地址0x50且兼容性字符串匹配的设备就会调用probe函数打印出检测信息。但这只是一个空壳还没有任何读写功能。4.3 实现核心读写功能我们需要在驱动中添加具体的读写函数并可能将它们暴露给用户空间。一种常见的方法是实现一个字符设备提供read、write、ioctl等操作。但更简单直接的方式是利用Linux内核现有的at24驱动drivers/misc/eeprom/at24.c。它是一个非常通用的EEPROM驱动已经实现了文件操作接口支持通过sysfs路径如/sys/bus/i2c/devices/0-0050/eeprom进行读写。我们的自定义驱动可以借鉴它的思路或者直接利用它。但为了理解原理我们手动实现基于i2c_transfer的读写。在probe函数中我们可以创建一个字符设备或者更简单地先实现几个供其他内核模块调用的API。下面是一个核心的读写函数示例它直接使用i2c_transfer进行I2C消息传输/* 从EEPROM指定地址读取多个字节 */ static int at24c_read(struct i2c_client *client, u8 reg, void *val, int len) { struct i2c_msg msg[2]; int ret; /* 消息1写入要读取的寄存器地址 */ msg[0].addr client-addr; msg[0].flags 0; // 写标志 msg[0].buf reg; msg[0].len 1; /* 消息2读取数据 */ msg[1].addr client-addr; msg[1].flags I2C_M_RD; // 读标志 msg[1].buf val; msg[1].len len; ret i2c_transfer(client-adapter, msg, 2); if (ret 2) return 0; else { dev_err(client-dev, read failed, ret%d\n, ret); return -EIO; } } /* 向EEPROM指定地址写入多个字节 */ static int at24c_write(struct i2c_client *client, u8 reg, const void *val, int len) { struct i2c_msg msg; u8 *buf; int ret; /* 分配缓冲区地址字节 数据 */ buf kmalloc(len 1, GFP_KERNEL); if (!buf) return -ENOMEM; buf[0] reg; // 第一个字节是地址 memcpy(buf[1], val, len); // 后续字节是数据 msg.addr client-addr; msg.flags 0; // 写标志 msg.buf buf; msg.len len 1; ret i2c_transfer(client-adapter, msg, 1); kfree(buf); if (ret 1) return 0; else { dev_err(client-dev, write failed, ret%d\n, ret); return -EIO; } }这两个函数实现了EEPROM的随机读写。注意对于像AT24C02这样的EEPROM写入一个字节后需要等待几毫秒的写入周期write cycle在实际驱动中需要考虑。但在QEMU仿真环境下这个延时通常可以忽略或者用mdelay()简单模拟。将这两个函数通过file_operations结构体与字符设备的read/write方法关联起来一个功能完整的驱动就诞生了。编译这个驱动模块.ko文件将其放入QEMU虚拟机的根文件系统中。5. 全链路测试从用户空间验证整个系统驱动加载成功后最激动人心的时刻到了在虚拟的Linux系统里操作我们虚拟的I2C设备。我们有多种方式可以测试。5.1 使用i2c-tools进行快速验证i2c-tools是一组用户空间工具非常适合初步测试。在虚拟机的Linux系统中安装或编译i2c-tools后可以执行以下命令# 首先探测I2C总线查看设备是否被发现 i2cdetect -y 0这条命令会扫描I2C总线0/dev/i2c-0上的所有地址。如果一切正常你应该能看到地址0x50处显示为UU或50表示有设备响应且被驱动占用。# 然后使用i2cget/i2cset进行读写测试 # 从地址0x50的设备读取一个字节从内部地址0x00读取 i2cget -y 0 0x50 0x00 # 向地址0x50的设备内部地址0x01写入值0xAB i2cset -y 0 0x50 0x01 0xAB # 再次读取0x01地址确认写入成功 i2cget -y 0 0x50 0x01如果读写成功说明从QEMU设备模拟到Linux驱动整个链路已经完全打通i2c-tools底层使用的是ioctl接口调用内核的I2C核心层最终会调用到我们驱动中实现的i2c_transfer逻辑。5.2 编写自己的用户空间测试程序为了更深入地理解我们可以写一个简单的C程序直接使用/dev/i2c-0设备文件和ioctl进行操作。这能让你清晰地看到用户态是如何一步步将请求传递到内核驱动的。程序的大致逻辑如下open(/dev/i2c-0, O_RDWR)打开I2C控制器设备文件。ioctl(fd, I2C_SLAVE, 0x50)设置要通信的从设备地址。组织数据。对于写操作构造一个缓冲区第一个字节通常是EEPROM的内部地址后面跟上要写入的数据。使用write(fd, buf, len)或read(fd, buf, len)进行传输。注意Linux的I2C字符设备接口将一次read/write调用视为一次完整的I2C消息包含起始条件、地址、数据、停止条件。对于需要先写地址再读数据的操作需要使用ioctl(fd, I2C_RDWR, ...)更底层的接口。或者使用更便捷的i2c_smbus_read_byte_data、i2c_smbus_write_byte_data等SMBus兼容函数需要包含i2c-dev.h。原文章末尾给出的i2c_usr_test程序就是一个很好的例子。它通过命令行参数接收设备文件、设备地址、读写操作和地址/数据然后调用i2c_smbus接口函数完成操作。你在虚拟机上编译并运行它输入命令如./i2c_usr_test /dev/i2c-0 0x50 r 0来读取地址0的数据就能亲眼看到数据在虚拟的I2C总线上流动。5.3 高级调试使用QEMU监控与GDBQEMU的强大之处在于其可观察性。你可以使用QEMU的监控台Monitor来查看设备状态。在启动QEMU时添加-monitor stdio参数就可以进入一个命令行监控界面。(qemu) info qtree这个命令可以打印出整个虚拟系统的设备树你可以从中找到你的I2C设备和它的状态。更进一步的你可以在QEMU源码的I2C回调函数中添加更多的调试打印DPRINTK然后在启动QEMU时通过-d i2c等参数来输出详细的I2C总线事务日志对照着驱动和应用的日志你能清晰地看到每一个START信号、地址字节、数据字节、ACK/NACK信号是如何产生和响应的这对于理解I2C协议和调试复杂问题有不可估量的价值。如果驱动代码出现问题你还可以使用GDB远程调试。在启动QEMU时加入-s -S参数启动GDB服务器并暂停CPU然后从主机用gdb vmlinux连接就可以像调试桌面程序一样在内核驱动代码中设置断点、单步执行、查看变量。这种调试能力在真实硬件上是难以企及的。走完这一整个流程从QEMU里“无中生有”地创造一个硬件设备到为它编写内核驱动最后在用户空间控制它你对I2C以及Linux设备驱动开发的理解就不再是浮于纸面了。你会真正理解“总线”、“设备”、“驱动”、“设备树”、“用户接口”这些概念是如何环环相扣共同构建起一个可工作的系统。下次当你面对一块真实的I2C传感器时你会感到前所未有的熟悉和自信因为它的灵魂你已经在这个虚拟世界里亲手塑造过一遍了。