QEMU edu设备驱动编写
学校开了虚拟化可信化定制这一门课,蛮有意思的,课后给了几个大作业,一个是 QEMU edu 驱动的编写,一个是 QEMU 中断拦截的定制,还有一个是磁盘访问命令的定制。我做的是中断拦截的定制,但也涉及到了 edu 驱动的编写,因此分享一下 edu 设备驱动的编写(MMIO交互),并映射成 character device。
这篇文章将带你从开发环境开始,一路杀到用户空间的程序。
写在前面: 在我写这篇 blog 的时候,我注意到一个更优秀的解决方案,可以尝试使用 Buildroot 来搭建开发环境,也许比本文提到的方案更加优美!
注意! 当前实现的驱动并 不支持 多台设备并行存在。
由于本文引用了 CC-BY-SA 4.0 协议的内容,因此本文也采用 CC-BY-SA 4.0 协议
环境
如果你在 Windows 上使用虚拟机操作的话,请准备你用起来最顺手的 SSH 客户端与支持远程开发的 IDE 比如 Visual Studio Code + Remote SSH 插件
- Archlinux (任意Linux都可以,首先确保有完整的开发环境)
- QEMU
对于提示缺少命令或者缺少某某头文件的情况下,请使用 Packages for Linux and Unix 或者使用 StackOverflow 搜索。
edu 设备简介
edu 为 QEMU 中的一个教学用设备,这个设备是一个 PCI 设备,该设备主要作用为取反、计算阶乘,主要通过 MMIO 与 CPU 进行交互,当然,这个设备也支持 DMA,但这里不讨论 DMA。哪天有心情写了我再做个 DMA 更新到本篇文章。
edu 设备的寄存器可以参考 edu.txt。
其中主要寄存器如下(不关注 DMA ):
0x04(RW)
设备存活状态检测,实际作用为对写入的值进行取反,并放回原处0x08(RW)
阶乘寄存器,写入一个数值,返回该数值的阶乘0x20(RW)
状态寄存器,其低 1 位为 只读,记录设备是否在进行阶乘操作,完成则为 0,否则为 1;其从低向高第 8 位为 读写,记录设备是否在完成阶乘操作后发起中断,发起则为 1,否则为 00x24(RO)
中断状态寄存器,记录中断状态,0x00000001
为阶乘中断,0x00000100
为 DMA 中断0x60(WO)
中断发起寄存器,用于手动发起中断,中断状态将存入0x24
寄存器0x64(WO)
中断确认寄存器,用于清除中断,将0x24
寄存器清零并阻止设备继续发出中断
环境准备
为了方便我们调试驱动,我们需要创建一个简单的内核与 initramfs,内核我们使用最小化编译,initramfs 我们使用 busybox,在完成编译后,我们直接通过 qemu 启动内核,然后通过 serial 与 qemu 进行交互。
安装工具并准备工作目录
1 | sudo pacman -Syy |
选择一个自己最喜欢的目录,然后创建一个工作目录,这里我选择了 ~/virt
。
1 | mkdir -p ~/qemu/edu-driver |
编译内核
首先我们 kernel.org 上获取一个新鲜的内核,这里我们选定了最新的 stable 6.6.2
,并把它拖回本地。
1 | cd $WORKDIR/src |
接下来我们选择以下配置:
1 | -> General setup |
开始编译 Linux:
1 | make O=$WORKDIR/build/linux -j$(nproc) |
编译 busybox
busybox 的作用就是提供一个简单的用户空间,一个简单的 shell,方便我们运行接下来的程序。首先,我们从 https://busybox.net/downloads/ 上获取一个顺眼的版本,这里我们选定了 1.36.1
,并把它拖回本地。
1 | cd $WORKDIR/src |
创建 initramfs
我们接下来要创建一个 initramfs,这个 initramfs 将包含我们的驱动、用户空间程序与 busybox。
1 | cd $WORKDIR/staging |
init
文件内容如下:
1 |
|
为了方便我们接下来的操作,我们编写一个简单的脚本来帮助我们快速制作 initramfs,并启动 qemu。
1 | cd $WORKDIR |
1 |
|
接下来我们尝试运行一下:
1 | ./run.sh |
准备基本框架
我们接下来准备程序的基本框架:
1 | cd $WORKDIR/kmod |
首先,编辑 Makefile
,内容如下:
1 | obj-m += edu.o |
然后,我们创建一个 edu.c
,内容如下:
1 | vim edu.c |
1 |
|
我们再准备一下用户空间的程序:
1 | cd $WORKDIR/program |
1 |
|
我们去掉 init
中的驱动加载注释:
1 | cd $WORKDIR |
接下来我们就可以去掉 run.sh
中的注释了,然后运行一下:
1 | vim run.sh |
执行一下 dmesg
与 edu
,看看效果:
到这里,我们的开发环境已经完成啦!
驱动编写
接下来是重头戏:驱动编写。
驱动主要包含两个部分:与设备交互的部分与与用户空间交互的部分。
与设备交互
首先我们完成与设备交互的部分,设备具体的情况可以查阅 QEMU 的文档 edu.txt
在本文撰写时,qemu 的最新 tag 为 stable-8.1,因此本文给出的
edu.txt
为 stable-8.1 的版本,如果现在查看主分支,请注意 qemu 已将文件格式改为rst
格式,但内容一致。
首先 QEMU 是一个 PCI 设备,因此我们要做以下工作:
- 定义 PCI 设备 ID
- 定义 PCI 设备的 probe 与 remove 函数
由于设备支持中断,因此我们还要做以下工作:
- 定义中断处理函数
接下来我们开始编写驱动的基本功能:
1 | cd $WORKDIR/kmod |
1 |
|
保存,执行 ./run.sh
,然后执行 dmesg
,看看效果:
那么上面的代码是怎么来的呢?
首先,Linux 内核模块是通过 module_init
与 module_exit
宏来定义初始化函数与卸载函数的,其次对于 edu 这种 PCI 设备,会通过 pci_register_driver
对设备进行注册。
这里使用的 Linux 内核为 v6.6.2,对于其他版本的 Linux 内核也许行数不一样
而 pci_register_driver
使用的参数为 pci_driver
结构体,对于这个结构体我们直接看 Linux 源码比较直观:include/linux/pci.h#L918。
这里我们会用到 name
, id_table
,probe
,remove
字段,对于其所有字段的说明,在源码的注释上都有详细的说明,具体请移步源码查看。
其中 name
为驱动的名称,id_table
为设备的 id 号,内核就是根据这个 id 号来实现对驱动的”对号入座”的,probe
函数当设备被插入或者已经存在一个没有驱动的设备时会被调用,remove
函数当设备被移除时会被调用。
很显然,我们代码中的设备相关结构体指针是一个全局变量,这是因为接下来我们将其与 character device
进行关联时会用到,这种设计也决定了 edu
设备只能有一个,除非我们将其对应的结构体指针改为数组,但这里不做讨论。
下面我们再看看 edu_probe
函数,首先我们要先启用设备,再为其请求内存区域、重新映射内存以便我们访问,最后挂上中断处理函数,在这个过程中我们也会对设备的部分寄存器进行初始化。在这个过程中我们难免会涉及到错误处理,资源必须有借有还,同样我们的初始化操作出了错误也必须回滚。
小贴士:回滚操作可以使用
goto
语句来进行,前面资源如果是 A->B->C 的顺序进行分配,那么我们后面的释放操作就要按 C->B->A 的顺序进行释放,并为其打上对应的标签。
edu_remove
函数与回滚操作一样,其实就是把 edu_probe
中的回滚操作全量执行一遍。
与用户空间交互
这里我们要将设备做成一个 character device,这样我们就可以通过 /dev/edu
来访问设备了。
我们希望用户空间程序以这样的方式与设备进行交互:
- 通过
open()
操作打开设备文件,且设备每次仅允许一个程序打开 - 通过
read()
与write()
操作来实现文件读写 - 通过
ioctl()
来实现设备模式的控制(如切换计算到阶乘/取反)
因此我们在原来代码的基础上做以下修改:
1 |
|
到这里我们的设备驱动就完成了,我们不妨编译一下,看看效果先,代码可以在这里下载到:edu.c
可以看到我们的 edu
设备出现在了 /dev
下。
用户空间程序
下面我们就可以按照我们刚才写的驱动来编写用户空间程序了。
1 | cd $WORKDIR/program |
1 |
|
编译执行,查看结果:
1 | cd $WORKDIR |
下集预告: qemu i440fx 中断拦截设计,最近突然被塞了一堆活,可能就要咕好久了