QEMU i440fx 中断拦截设计
续集来了,QEMU i440fx 中断拦截设计 (外设中断)。上次我们介绍了 qemu edu 驱动的编写过程,这里我们来介绍一下如何拦截 i440fx 的中断拦截 (tcg 加速下)。
这里在 qemu 8.1.2 的基础上进行了修改,实现了一个简单的中断拦截服务,通过 tcp 接受外面的指令来决定是否拦截对应的中断。
题外话:气死了,校园网认证老是掉,本来应该 22 号写完的文章愣是拖到 24 号。花了两天现学 rust 搓了个校园网认证客户端丢路由器上了。 项目:srun-cli-client 写个定时任务自动检测重登就好啦~
环境
该拦截仅适用于 Linux 操作系统的宿主机 (因为服务直接使用了 epoll,并没有直接使用 qemu 的 iothread ),并且仅在 tcg 加速下完成测试,没有测试 kvm,但是理论上是可以的。
强烈建议安装 pwndbg 来提高调试体验
当时是做的 5.2.0 还没有 eBPF,不知道用 eBPF 是否更有搞头
- qemu 8.1.2
- 一个简单的虚拟机,这里我们使用了前一篇文章形成的
bzImage
与initramfs.cpio.gz
- 完善的构筑环境
获取源码并编译一个初版
老样子,我们拉最新的源码。
1 | wget https://download.qemu.org/qemu-8.1.2.tar.xz |
加上之前做的内核,现在的目录大概长这样:
1 | total 124156 |
老样子,写个启动脚本来方便我们后续的工作,创建一个run.sh
:
1 |
|
我们可以通过 ./run.sh
来启动 qemu,并通过增加参数来决定该从哪一步开始重新构筑 qemu。
参数 | 作用 |
---|---|
-d, –debug | 使用 gdb 启动qemu |
-r, –reconfig | 重新配置并编译 qemu |
-c, –clean | 清理编译目录并重新配置编译 |
-b, –build | 重新编译 qemu |
中断的实现
拦截中断首先要明白中断是怎么实现的,我们以 edu 设备为例,将从中断源、中断的传递、中断的处理三个方面来介绍。
我十分推荐直接通过
gdb
结合源码分析的方式来理解中断的实现,在接下来的内容中我也是这么做的
首先我们看一下对应的 callstack
中断源
直接上 edu.c
的源码:
1 | static void edu_raise_irq(EduState *edu, uint32_t val) |
我们可以看到,中断源的触发是通过 pci_set_irq
来实现的,但也有通过 msi (message signal interrupt) 来实现的,但我们使用的平台为 i440fx
,是不支持 msi 的,所以我们只需要关注 pci_set_irq
即可。
这里我们可以使用 gdb
来进行验证,使用 ./run.sh -d
启用 gdb
使用 b msi_notify
给 msi_notify
打个断点,我们发现无论我们怎么操作,这个都是不触发的。当我们在 qemu 的启动参数中加入 -M q35
使用 Q35 而不是 i440fx 时,msi_notify
的断点就被触发了。
中断的传递
我们看到了上面频繁出现了 qemu_set_irq
,不妨来看看其代码:
1 | void qemu_set_irq(qemu_irq irq, int level) |
很明显,调用了函数指针,后面的 level
为模拟的电信号的状态(0或1)。
而这个qemu_irq
的结构体为:
1 | struct IRQState { |
注意: 这里的实现为 QEMU 对 i440fx 的实现,可能与实际情况存在偏差,我没有去做验证
我们只看代码是很难看出来的,因为里面是大量的函数指针,我们不妨再看一下刚才的 callstack,我们可以大概看到这个中断是怎么传递的:
- 首先 PCI 设备调用
pci_set_irq
,“告诉” PCI 总线,我有中断了 - PCI 总线按照源设备的中断号,调用
qemu_set_irq
,“告诉” ISA 总线,有一个中断号为 xx 的中断触发了 - ISA 总线按照中断号,调用
qemu_set_irq
,“告诉” 中断控制器,有一个中断号为 xx 的中断触发了 - 这里问题来了,在中断控制器与
isa
之间连了一个gsi_handler
的函数,这里他做了一个路由
- 如果中断号在 i8259 的处理范围内,则先发给 i8259,再发给 ioapic,但实际上 i8259 后面还会连到 ioapic
- 若中断号超出了 i8259 的处理范围,但在 ioapic 的范围内,则直接发给 ioapic
- 若中断号超过了一个 ioapic,则发给第二个 ioapic
- 在 qemu 5.2.0 的时候,只有一个 ioapic,这个函数的逻辑也相对简单一些
- 通过中断控制器的处理后,中断会被写入 CPU 的状态中 (
CPUState
结构体)
中断的处理
QEMU tcg 处理方式为将代码分块进行翻译,每翻译执行完一块代码后就会看一眼是否有中断需要处理,有就处理中断。具体代码,我们可以看一下 cpu_exec_loop
,这里我们不做讨论。accel/tcg/cpu-exec.c:cpu_exec_loop
感兴趣的小伙伴可以参考 https://airbus-seclab.github.io/qemu_blog/tcg_p1.html
中断拦截的实现
拦截点的选取
为了保证能够拦截到所有外设到 CPU 的中断,我们需要在中断传递的每一个环节都进行拦截,那么我们就需要去寻找拦截点了。
那么在哪边能找到所有中断呢?很显然,我们直接杀进 CPU,可以找到 do_interrupt_all
函数,target/i386/tcg/seg_helper.c:do_interrupt_all,在这个函数里我们甚至能找到 qemu 的 中断日志相关的实现:
1 | qemu_log("%6d: v=%02x e=%04x i=%d cpl=%d IP=%04x:" TARGET_FMT_lx |
其中 is_int=1
的情况为中断源来自指令,而 is_int=0
的情况为中断源来自硬件。
虽然我们可以在这里进行拦截,但在这里进行拦截会导致 CPU 被挂起,我们不妨看一下我们这个配置下 QEMU 的线程模型:
- thread 0: iothread,协程,负责处理 I/O 事件,时钟中断等
- thread 1: rcu,线程,负责协调线程间的同步
- thread 2: vcpu,线程,负责模拟 CPU 处理指令
- thread 3: edu 线程,模拟 edu 设备的线程 (这里其实是一个刻意设立的线程,不然他大可直接在 thread 0 中处理)
为了尽可能降低干扰,我决定单独起一个线程进行拦截,这样就不会影响到 CPU 的执行了。
我们对于中断的拦截应当设立在中断传播过程或者中断源上,但对每一个中断源进行处理较为麻烦,因此比较理想的方案是在中断传播过程中进行拦截,即在 PIC 上进行拦截。上文提到了 i440fx
提供了 i8259
与 ioapic
,我们可以考虑在他们身上下手;但还有 gsi_handler
的存在,我们也可以考虑这里下手。
通过一通调试,我发现这几个东西的关系是这样的:
1 | +---------------+ |
为了验证我们的想法是没有问题的,我们不妨直接“实机调试”一下:已知所有的硬件上的目的地都是 tcg_handle_interrupt
,我们可以在这里打个断点,然后看看是不是所有的中断都会经过这里,但由于这里的中断数量太过庞大,况且还有个时钟中断不停的发送,因此我们直接编写 gdb 脚本比较现实
1 | import gdb |
这个脚本记录了所有调用到 tcg_handle_interrupt
的调用栈,并且通过禁止显示参数,使用 md5
来对调用栈进行去重,最后将结果保存到 call_stacks
中,这里只给出了一个简单的示例代码,并非当时的代码(因为后面各种调试加了太多东西了)。
通过这一通记录,我们发现还真有不少中断是不经过 gsi_handler
的,但这些不经过 gsi_handler
的中断大多都是来自 CPU 线程,看对应的调用栈应该是 CPU 访存相关的,不是外设中断。但也有部分中断是来自外设的,看调用栈是在设备初始化期间发生的,但跑起来就没有了,我猜测是虚拟机实现的问题。大体上拦截 gsi_handler
是没有问题的。
让我们来看一下 gsi_handler
的代码:
题外话: 当时大作业用的是 5.2.0,这个 gsi_handler 还没这么复杂,当时就是几行解决,没有 XEN 没有 两张 ioapic,只有一个 i8259 跟一个 ioapic,不过不影响
1 | void gsi_handler(void *opaque, int n, int level) |
拦截方案的设计
题外话: 如果你刚好跟我是校友,也选了这门课,也有幸找到了这篇 blog,麻烦帮我问问这么做有没有毛病
这样的话我们直接对着 gsi_handler
动刀就可以啦,但因为作业上还有别的要求,我就顺便做了在中断源上拦截的功能。我们设计一个模块为 intsvc
,那么他跟 qemu 硬件的关系如下:
1 | +---------------+ |
由于 qemu 中的每一个“硬件”都是信息体,本质上就是信息之间的处理,并且硬件如果名没有发生大的变动的话,他的地址也是不会改变的,因此我们可以通过单独启动一个线程来存储他的地址,在拦截放行的时候直接使用这个地址来继续他没有完成的任务。
为了保证一定的用户体验,我们不妨实现一个 http 服务,再编写一个客户端与其通讯来实现控制。
动刀
观察一下 qemu
的项目结构,使用了 meson
,调用虚拟机创建程序时命令行参数处理在 qemu_init,同目录下 meson.build
定义了编译哪些文件,我们可以在这里加入我们修改的文件,观察 qemu_init
函数对命令行的处理,我们发现他的命令行用宏定义的,真正定义命令行是在 qemu-options.hx
,在 ./configure
过程中形成 qemu-options.h
。
加入命令行参数定义
编辑 qemu-options.hx
,插入以下内容:
1 | DEF("intsvc", HAS_ARG, QEMU_OPTION_int_svc, \ |
我们不妨先编译执行一下试试,这次我们需要做一次完整的编译 ./run.sh -c
.
很好,他出现在了 --help
中,其实在这个过程中我们也通过这个宏定义了一个 Option 常量,用于代码中的命令行参数后续的处理。
我们先不急着改 qemu_init
,我们先给我们的项目开一个头文件,开一个 c
文件方便后续的使用,这里直接跟 vl.c
放到同一个目录下。
我们先看头文件,头文件丢在 include/intsvc/intsvc.h
中,内容先简单写写:
1 |
|
然后我们编写一下 intsvc.c
,softmmu/intsvc.c
:
1 |
|
顺便编辑一下 softmmu/meson.build
,将 intsvc.c
加入到构筑系统中。
现在我们可以编辑 vl.c
了,定位到 qemu_init
函数,我们往下找,很容易就找到处理命令行参数的那一段:
1 | switch(popt->index) { |
我们在一个合适的地方插入我们的代码,我看开头就很合适:
1 | switch(popt->index) { |
不要忘记在 vl.c
的开头包含头文件:
1 |
|
修改 run.sh
,把开头的 QEMU_ARGS
开头加入 "-intsvc" "0.0.0.0:4444"
,执行run.sh -b
,预期程序应该会输出如下图:
编写基本框架
其实我感觉加入到 iothread 也是一种不错的选择,但考虑到后面有可能涉及到对 iothread 进行加锁操作,可能会有不便,就单独开线程了
我们的中断拦截服务需要单独开一个线程,因此会涉及到线程的创建与释放,还会涉及到一些结构体的操作,因此我们其他模块暴露的 API 有以下:
- 服务初始化
- 服务释放
- 中断记录
- 中断拦截
因此我们简单写一下 intsvc.h
由于我是看着好几个星期前写的代码复现,可能会有点顺序上的问题,当时确实调试了好久改了很多版,很难从零开始再过一遍当时的思路了,但也剁掉了按照作业要求写的一堆智障代码
1 |
|
相应的,我们在 intsvc.c
中实现这些函数:
1 |
|
到这里,我们的中断拦截就差不多了,但是这样的话中断会传丢,我们后面总要保证中断能够手动放行,因此我们定义结构体:
1 | typedef struct int_svc_interrupt_requests // 中断请求 |
然后我们小改一下 intsvc.c
1 | // bucket 中断记录桶 |
处理对应的组件
这个框架大体 OK 了,我们现在开始给对应的组件动刀,这里主要是针对三个类型的设备动刀:两个中断源(时钟中断 i8254
,edu
设备),中断 PIC (其实不是 PIC gsi_handler
)。
时钟中断 i8254
先对 i8254
动刀,i8254
在 hw/timer/i8254.c
,由于其为时钟中断,比较特殊,这里直接做拦截,不做其放行函数即可(因为他无时无刻在产生中断)。
观察他的源码,最终敲定在 pit_irq_timer_update 上动刀。
动刀后的代码如下:
1 | expire_time = pit_get_next_transition_time(s, current_time); |
该设备不需要放行中断。
edu 设备
老样子,直接看代码,很明显是 edu_raise_irq。
对于 PCI 设备,他发起中断的方式是调用 msi_notify
或者 pci_set_irq
,只有一个可变的参数为 PCIDevice
,即 edu
设备的 EDUState
所“继承”(参考 QOM)的 PCIDevice
,因此我们只需要保存一下 edu 的首地址就没有问题了,改后代码如下:
1 | static void edu_raise_irq(EduState *edu, uint32_t val) |
中断放行方案也比较简单,直接在其他线程调用 msi_notify
或者 pci_set_irq
即可,其中 edu_msi_enabled
本质上是调用 msi_enabled
也是只用 edu->pdev
。
gsi_handler
首先观察代码,传进来的 opaque
其实是 GSIState
的指针,下面就按照中断号选择合适的设备并调用对应设备的“输入函数”,因此我们直接存住 opaque
的数值、中断号就可以。
直接在原始的 gsi_handler
上动刀,修改后源码如下:
1 | void gsi_handler(void *opaque, int n, int level) |
简单粗暴。
放行方案同样可以简单粗暴,我们直接“复制”一份“干净”的gsi_handler
,丢到 gsi_handler
下面,同时在 x86.h
中加入对应的函数原型。
服务器通讯
协议
协议使用 Type–length–value(TLV)
模式,直接定义结构体与简单的规格:
1 | /** |
服务端
下面编码服务端:
先在 intsvc.h
中定义一下用户的东西:
1 | typedef struct int_svc_client_context |
直接在 intsvc.c
中实现,贴代码:
1 | // Socket server fd |
客户端
服务端就差不多了,接下来我们就可以编写客户端了:客户端用 python 写了一个类似于 shell 的东西:
1 | import socket |
测试
到这里,我们就完成编码了,首先执行 run.sh -c
进行一次 clean build 并执行,诶这次虚拟机报错了,没起来:
不用担心,正常现象,因为我们的时钟中断被拦截了,导致了 kernel panic。
我们继续编辑 run.sh
,删除掉 -no-reboot
这个参数,继续启动,发现他陷入了重启循环,这时候我们启动客户端:
1 | py client.py |
1 | (intsvc)> ver |
可以拿到服务器的正常响应,我们看一下开关状态:
1 | (intsvc)> status |
没有拦截时钟中断,但也没允许 GSI 通过任何中断,EDU 设备也不允许通过中断。我们看一下计数器:
1 | (intsvc)> stats |
GSI 上拦截到的 0、4、8 号中断中,0 号特别多,我们放行一下 0 号中断:
1 | (intsvc)> set-int 0 0 0 |
发现画面卡在这里不动了,我们放行一下 4 号中断。
1 | (intsvc)> send 0 4 |
会发现我们完成输入后,放行中断后中断就会突然跳出一堆字,十分有趣。
老样子让我们试试 edu 设备,先放行其他中断:
1 | (intsvc)> set-int 0 4 0 |
然后我们算个阶乘:
1 | ~ # edu f 5 |
发现他卡住了,我们看一下拦截记录,尝试放行:
1 | (intsvc)> ls |
首先是 EDU 设备拦截到了,我们放行后,中断号被转换为 11 出现在了 gsi,再次被拦截,再度放行后,我们的终端输出了阶乘结果:
1 | ~ # edu f 5 |
十分好玩
附件
直接打包了:intsvc.zip
小结
当时做这个东西真是费了不少功夫,除去上课断断续续做了一个星期吧(其实是一个星期多一点)。绝大多数时间都在分析中断的传播,想方设法证明是否能在那几个中断控制器上拦截所有外设中断。
实际上我也不是特别明白做中断拦截的 point 在哪里(不过测试驱动还蛮舒服的感觉),但对一个大项目动刀我还是满喜欢的,我就单纯想挑战一下。