1 背景
在过去很长的一段时间,分析内核代码都依赖printk和读代码。而这个过程不仅耗时长,且需要对内核本身已经有一定的了解。
一种比较好的改进方法就是利用Qemu和GDB来对内核进行调试,Qemu内置了GDB Server。通过一系列配置,就可以通过GDB,像调试用户态程序那样调试内核。在开启嵌套虚拟化的情况下,甚至还可以调试KVM代码。
本文主要描述将Qemu和GDB相结合对内核代码进行调试的环境搭建步骤,总共包含三个部分。第一个部分描述基础的调试环境搭建,基础的调试环境可以对链接到内核的代码(相对于模块代码)进行调试。第二个部分,描述如何对模块代码进行调试,第三个部分讲如何调试KVM代码。
2 基础调试环境搭建
2.1 Qemu相关
通过Qemu调试内核, Qemu本身必须支持该功能。Qemu 是否支持可以直接通过qemu-kvm的帮助信息进行判断。下图是在CentOS 8下查看Qemu帮助文档得到的打印[h1] ,有GDB相关的选项就说明当前安装的Qemu可以支持GDB调试。某些发行版可能出于性能考虑,其默认安装的Qemu裁剪了调试功能。[h2]
这种情况就需要从Qemu官网下载新版源码,重新编译安装。本文后面所用Qemu是5.0版本,经验证该版本支持GDB调试。下图截取自Qemu官网,图中展示的是qemu-5.1.0-rc1的编译方法,其他版本安装也是一样的。在config时还可以指定安装路径[h3] ,config完成之后执行make && make install即可完成编译和安装。
2.2 配置相关
Qemu安装完成后,就是启动虚机了。虚机需要运行在支持GDB调试的Qemu版本下,至于虚机是通过命令行启动的还是libvirtd启动是没有关系的。如果虚机是通过libvirtd解析xml文件启动的,则需要做如下修改
将首行
1 | <domain type='kvm' > |
改为
1 | <domain type='kvm' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'> |
在xml末尾[h4] 加入
1 | <qemu:commandline> |
当然也有可能通过命令行直接启动虚机。比如在自行编译的Qemu版本中,通过xml+libvirtd启动虚机可能需要修改较多配置项,直接通过命令行启动虚机则更为方便。下面是一个命令行启动虚机的示例,
1 | qemu-system-x86_64 -m 16384 -hda /data/path/CentOS8.raw -S -gdb tcp::1234 |
其中-S参数不是必须的,其含义是让虚机在执行第一行代码前保持停止状态。这种停止状态会保持,直到用户通过GDB连接并执行start命令,这在调试内核启动过程时比较有用。
以上配置主要是使能Qemu内置的GDB Server。tcp::1234中的1234是端口号,也可以自行指定其他值。
2.3 编译内核
外部环境都就绪后,下一步是配置目标虚机。默认的发行版几乎都启动了KASLR功能[h5] ,这是一种防止黑客攻击的功能,但这个功能会让GDB无法正确解析符号表。这一步唯一需要做的就是关闭KASLR,并重新编译安装内核。
在CentOS下,推荐通过 make rpm-pkg命令对内核进行编译,该命令默认生成内核rpm安装包。通过rpm安装包安装内核的好处是,当需要卸载内核时可以很方便的卸载。如果直接在内核源码目录下编译安装内核,则卸载时需要手动删除散碎文件。
编译好的内核安装包默认存放在~ /rpmbuild/RPMS/x86_64/下。这里推荐通过下面这个命令进行安装,该命令不会覆盖相同大版本号而不同小版本号的已安装内核。
1 | rpm –ivh kernl-xxx.rpm –force |
安装好内核后需要重启一次虚机,确保重启后的虚机是运行的刚才安装的内核。
PS:CentOS下,Host安装VM运行内核对应版本的debuginfo即可调试,需要在VM的内核参数中添加nokaslr
2.4 启动调试
接下来就可以进行调试了,调试需要上一步修改后的内核的符号表文件,也就是vmlinux文件。该文件包含在内核安装包中,虚机安装好新编译的内核后,在boot目录下有vmlinux-xxx[h6] .bz2文件。解压该bz2文件就可以得到vmlinux文件,解压后需要将该文件传输到运行GDB的机器上,本文所用的是Host机器。
同时,调试内核还需要源码。按照经验,vmliux记录的是源码绝对路径,这表示运行GDB的机器需要放置一份源码,放置路径和被调试的目标内核在编译机编译时其源码所处的绝对路径一致。比如说,虚机安装的内核安装包是在/root/kernel_source下编译的,则运行GDB的机器在/root/kernel_source也需要防止一份源码。至于虚机被调试的内核是虚机自己编译的,还是Host编译的,不影响调试。同样,运行GDB的机器是Host还是另外一台虚机,也不影响调试。当vmlinux和源码都准备好了以后就可以开始调试了,按照下图操作
红框中链接的1234是端口号,也就是前面2.2小节所设定的端口号。其中target remote : 1234完整写法应该是target remote 127.0.0.1: 1234。上图的例子省略了IP信息,当直接通过Host调试虚机时,可以省略127.0.0.1。
3 内核模块调试
3.1 已加载的模块
由于GDB调试依赖于符号表和内存地址[h7] ,GDB通过比对内存地址和符号表可以得知当前被调试程序的执行情况。而对于内核模块而言,其加载到内核的地址不是固定的。这就需要手动告知GDB需要调试的模块的符号表和模块代码的虚拟地址。
对于已经加载的模块,内核都会在sysfs中导出其各个段信息。以NVMe驱动为例,在虚机中执行以下命令就可以查看模块的指定段地址信息
然后通过find命令找到当前加载的模块,传递到运行GDB的机器。
这里找到三个nvme模块,第一个是发行版自带内核的nvme模块,下面两个是自行编译的内核的nvme模块,编译目录和安装目录各一个,这两个文件是完全一样的。因为调试的是自行编译的内核,所以可以将后面任意一个nvme.ko文件传输到Host机器。
在Host一侧,GDB调试命令栏中输入以下命令可以加载模块符号表。这里假定虚机传出的nvme.ko存放在Host机器的/home目录下
1 | add-symbol-file /home/nvme.ko -s .text 0xffffffffc0118000 -s .data 0xffffffffc0120080 -s .bss 0xffffffffc0120680 |
之后,就可以像调试链接到内核的那些代码一样调试nvme.ko的代码。
3.2 调试init 和exit或者驱动的probe
上面的方法也有局限性,那就是无法调试模块的加载过程。在某些情况下,也无法调试模块的卸载过程。这就需要在执行目标模块的初始化代码之前得到模块加载后所处的虚拟地址,而这可以通过在do_init_module函数中加断点实现。do_init_module是内核模块加载的必经函数,当某个模块的加载流程执行到该函数时,该模块在内存中的虚拟地址已全部确定,但尚未开始执行任何模块代码。查看该函数的中间变量就可以得知目标内核模块各个段的虚拟地址。
此处还是以nvme.ko模块为例,假设需要调试该驱动模块的初始化函数。
第一步,在gdb中给do_init_module加断点,然后continue执行流;
第二步,虚拟执行insmod或者modprobe,加载目标模块;
第三步,不出意外,前两步操作后执行流会停在断点处。此时需要在GDB控制台执行以下命令以查看全部段信息;
1 | print *mod->sect_attrs->attrs@mod->sect_attrs->nsections |
第四步,从打印信息中找出“.init.text”、“.exit.text”、“. text”、“.data”还有“.bss”等重要段的地址信息。将这些地址信息作为gdb add-symbol-file命令的参数,向gdb添加符号表信息。
在演示的例子中,加载模块符号表的完整命令如下所示[h8]
1 | add-symbol-file /home/nvme.ko -s .exit.text 18446744072105495066 -s .init.text 18446744072105558016 –s .text 18446744072105459712 -s .data 18446744072105525536 -s .bss 18446744072105532992 |
之后就可以开始正常的调试,对于已知初始化函数名的模块,可以通过函数名对该函数加断点。对于不知道初始化函数名的模块,则可以跟着执行流进入初始化流程。在do_init_module中,已经包含了对初始化代码的调用,经过*do_init_module()->do_one_initcall()->fn()*流程,就到了模块的初始化函数。
4 KVM代码调试
KVM代码的调试和其他内核代码的调试没有什么不同,只是KVM代码仅在启用虚机的时候才会执行。这就只能是在虚机中再启动虚机,也就是嵌套虚拟化。至于如何安装嵌套虚机,在此不多做叙述,这里仅介绍一些关键的点和可能需要的配置命令。在下面的描述中,我将第一层虚机称为L1,L1中运行的虚机称为L2。
4.1 开启host嵌套虚拟化[h10]
4.2 使能IOMMU
为了使用设备直通功能,要求Host开启了iommu功能,开启方法是为kernel添加intel_iommu=on参数
如果希望在L1中将设备直通给L2,则要求L1同样具备iommu模块。可以使用下面这个xml语句,为L1虚机添加iommu设备
1 | <qemu:commandline> |
当然,L1的kernel同样需要添加intel_iommu=on参数,当所有配置都完成后就可以将host的设备直通到L2了。在实际使用过程中,有发现L2因为iommu安全校验无法启动的情况,此时需要在L1中为iommu驱动添加如下模块参数
1 | echo "options vfio_iommu_type1 allow_unsafe_interrupts=1" > /etc/modprobe.d/iommu_unsafe_interrupts.conf |
4.3 设备直通
无论虚机是通过XML启动还是命令行启动,向虚机添加直通设备都需要知道该设备在host侧的总线信息。具体而言也就是“域:总线:设备.功能”的一个编码。该编码既可以lspci看,也可以通过lshw看
如果虚机是通过libvirtd启动的,则需要用下面的格式配置xml文件来添加设备直通
1 | <hostdev mode='subsystem' type='pci' managed='yes'> |
而如果虚机通过命令行启动,则配置设备直通需要多执行几个命令。首先通过lspci找出设备的厂商编码和设备编码。
1 | lspci -ns 0000:3d:00.0 |
然后利用总线信息将设备从Host侧解绑
1 | echo 0000:3d:00.0 > /sys/bus/pci/devices/0000:3d:00.0/driver/unbind |
然后创建新ID
1 | echo xxxx xxx > /sys/bus/pci/drivers/vfio-pci/new_id |
最后添加启动参数
1 | -device vfio-pci,host=3d:00.0,id=hostdev0 |
如果设备支持sriov,可以用下面这个命令添加VF,VF直通和真实的物理设备直通一样。
1 | echo 2 > /sys/bus/pci/devices/0000\:3d\:00.0/sriov_numvfs |
PS:CentOS下推荐用virt-manager配置设备直通
[h1]不同的发行版可能安装位置不一样
[h2]实测发现xxx默认安装的Qemu不支持GDB调试
[h3]configure –prefix=/pata/to/install
[h4]包含在<domain> </domain>内
[h5]传统的内核启动过程中,内核会被固定的物理地址处。厉害的人通过地址推算就可以得出内核的布局,这可能存在安全隐患。KASLR则是在内核默认的加载地址前加一段随机的偏移
[h6]xxx也就是内核版本号,当运行该版本内核时等同于$(uname -r)
[h7]此处,以及后面所处的内存地址都是虚拟内存地址
[h8]地址时16进制还是10进制没有影响
[h9]此处包含.exit.text段和.init.text段,这两个段是内核为了节省内存所设立的段。处于这两个段的代码,在不需要时会被内核丢弃。这里包含进来可以扩大可调式的范围
[h10]https://docs.fedoraproject.org/en-US/quick-docs/using-nested-virtualization-in-kvm/