使用Qemu和GDB对Linux内核进行调试

使用Qemu对Linux内核进行调试是一种较为便捷的方式,近日进行了一番实践,并将大致步骤与其中一些小坑记录了下来。

环境

由于放长假赋闲在家,所以手头只有一台装有MacOS的MBP可用,而Linux内核的开发与调试使用Linux环境下会比较方便,所以就使用VMware Fusion创建了一台安装有Ubuntu 18.04系统的虚拟机。由于编译Linux内核及相关软件需要的资源较多,所以为虚拟机配置了双核CPU、2GB内存和20GB磁盘空间(笔记本本身资源有限),但实际使用(特别是物理内存和硬盘)捉襟见肘,于是又在系统中添加了3GB的SWAP内存并扩容了20GB的磁盘空间(其实还是不太够)才解决问题。

编译Linux内核

首先,尝试对内核进行编译,在编译前需要使用通过KConfig启动内核的调试配置。

下载内核源码

由于Linux内核代码量非常大,且由于国内网络大家都懂的原因,所以的下载内核源码是一项较为复杂的体力活动。

第一种方法是直接Clone Linux源码的Git仓库,当前,其仓库大约为3.7GB。在通过内核官网(https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/)或者GitHub(https://github.com/torvalds/linux)进行Clone的过程中,经常会遇到连接断开的情况,非常捉急。而如果通过国内镜像源,如清华Kernel Git镜像进行Clone的时候,最开始速度飞快,但是后面速度会越来越慢。因此,如果不像我这样头铁的话,不建议使用这样的方式下载Kernel的源码。

另一种较为简单的方式是下载特定版本的源码,这些源码的tarball包可以从内核官网或者镜像站获得。我在实验中使用的内核版本为4.19,gz压缩包的大小约为150MB。

配置内核

如果是使用Git Clone的方式获取的内核源码,需要通过git checkout v4.19将内核源码置位4.19版本。

在编译之前,首先需要安装相关的依赖(如果提示缺少其它依赖按需安装即可)

1
sudo apt install libncurses5-dev libssl-dev bison flex libelf-dev gcc make openssl libc6-dev

在编译之前,需要使用KConfig对内核编译选项进行配置,在内核文件夹下,使用make menuconfig(命令行界面)或make gconfig(基于gtk的图形化界面)对内核进行配置。在配置时,需要打开如下选项:

1
2
3
Kernel hacking -> Kernel debugging
Kernel hacking -> KGDB:kernel debugger
Kernel hacking -> Compile time checks and compiler options -> Provide GDB scripts for kernel debugging

并保证如下选项没有开启:

1
Kernel hacking -> Compile time checks and compiler options -> Reduce debugging information

在退出配置后,可以发现内核目录中生成了一个名为.config的配置文件。

编译内核

配置完成后,就可以使用make编译内核,在多核CPU中可以使用make -jx启动多线程编译(x为启动的线程数)。

如果一切正常,在漫长的等待后,内核将编译完成。编译会在内核根目录下生成vmlinux 文件,它是编译出的原始内核文件(含有调试信息),而会在arch/x86/boot/bzImage目录下生成压缩后的内核文件(当然是在编译的体系结构为x86的情况下)。

编译安装GDB和Qemu

由于内核调试所需的GDB和Qemu版本可能会比apt源中的版本高,所以,最好自行编译安装这些软件。

编译安装GDB

首先,从官网(http://www.gnu.org/software/gdb/download/)下载GDB的源码并解压(这里使用的是官网中最新的GDB 9.1),需要注意的是,网上有些博客中提到需要修改GDB的源码,其实是不必要的,报错的原因是没有自动检测到目标体系结构的类型,所以只需设置该类型即可。

解压后进入GDB文件夹,执行下列指令,即可完成编译安装:

1
2
3
4
5
mkdir build 
cd build
../configure
make -j4
sudo make install

最后,通过使用gdb -v确定gdb的版本是否为9.1,如果是,则说明安装成功。

编译安装Qemu

首先,从官网下载(https://www.qemu.org/download/#source)Qemu的源码并解压(这里使用的是Qemu 5.0.0)。

由于在Ubuntu GUI中使用Qemu还需要多媒体图形库SDL,所以需要首先使用apt安装sdl:

1
sudo apt install libsdl2-2.0-0 libsdl2-dev libsdl2-gfx-1.0-0 libsdl2-gfx-dev libsdl2-image-2.0-0 libsdl2-image-dev 

进入Qemu目录后,执行./configure检查系统配置并生成Makefile,需要注意检查的时候是否检测到了SDL的支持,其输出的部分内容如下所示:

1
2
3
4
5
6
7
8
profiler          no
static build no
SDL support yes (2.0.8)
SDL image support yes
GTK support no
GTK GL support no
VTE support no
TLS priority NORMAL

然后执行make && make install即可完成Qemu的编译与安装。

在安装完Qemu后,会生成如qemu-xxxqemu-system-xxx的一系列命令,用于仿真不同体系结构的用户态应用和操作系统,可以通过如qemu-system-x86_64 --version命令确认Qemu是否安装成功。

制作ROOTFS

在内核启动后需要一个带有init程序的rootfs,所以在调试内核前需要制作一个rootfs。

构建基于initrd的rootfs

initrd是一种位于内存的根文件系统,它可以在硬盘被驱动之前载入系统。这里为了方便,只将一个简单的程序写入initrd,并将其作为init程序(即系统启动后的第一个用户态进程)。除此之外,也可以使用busybox作为initrd中的init程序。

创建一下简单的c程序,命名为fakeinit.c

1
2
3
4
5
6
7
8
9
10
11
#include <stdio>
int main()
{
printf("hello world!");
printf("hello linux!");
printf("hello world!");
printf("hello linux!");
fflush(stdout);
while(1);
return 0;
}

然后使用gcc编译这段代码,在编译的时候需要使用静态链接,并且如果如果在配置内核的时候没有启用64位支持(64-bit kernel),则需要将代码编译为32位程序,方法是在gcc命令行中添加-m32选项。

编译命令如下:

1
2
gcc --static -o fakeinit fakeinit.c
gcc --static -o fakeinit fakeinit.c -m32 (编译为32位可执行程序)

在编译后,使用cpio程序进行打包:

1
echo fakeinit | cpio -o --format=newc > initrd_rootfs.img

这样,一个基于initrd的rootfs即制作完成。

构建基于硬盘镜像的rootfs

这里使用busybox构建基于硬盘镜像的rootfs。其中,busybox是一个集成了数百个Linux常用命令和工具的单个软件,在对内核进行测试的时候非常方便,号称“The Swiss Army Knife of Embedded Linux”。

下载编译busybox

首先,从官网(https://busybox.net/downloads/)下载busybox的源码并解压(这里使用的是最新的busybox-1.31.1)。

在解压并进入busybox文件夹后,首先使用make gconfigmake menuconfig对其进行配置,需要启用如下选项:

1
Settings -> Build Options -> Build static binary (no shared libs)

如果需要将其编译为32位版本,则需要将-m32命令填入如下选项:

1
2
Settings -> Build Options -> Additional CFLAGS
Settings -> Build Options -> Additional LDFLAGS

与内核相同,在退出后,会在目录中生成一个名为.config的配置文件。

然后,使用make命令编译busybox。

使用busybox创建rootfs

首先,创建一个空的磁盘镜像文件,然后将其格式化:

1
2
dd if=/dev/zero of=./busybox_rootfs.img bs=1M count=10
mkfs.ext3 ./busybox_rootfs.img

然后,挂载刚刚创建的磁盘镜像(需要使用loop设备):

1
2
mkdir rootfs_mount
sudo mount -t ext3 -o loop ./busybox_rootfs.img ./rootfs_mount

接着,在busybox源码目录中,将编译好的busybox目标文件安装到rootfs文件夹:

1
make install CONFIG_PREFIX=/path/to/rootfs_mount/

最后,配置busybox的init,并卸载rootfs:

1
2
3
4
5
mkdir /path/to/rootfs_mount/proc
mkdir /path/to/rootfs_mount/dev
mkdir /path/to/rootfs_mount/etc
cp busybox-source-code/examples/bootfloppy/* /path/to/rootfs_mount/etc/
sudo umount /path/to/rootfs_mount

现在,一个基于busybox的rootfs磁盘镜像就制作成功了。

使用Qemu和GDB调试内核

使用Qemu启动内核

由于编译的内核体系结构为x86,所以使用qemu-system-x86_64程序来载入并启动内核。

如果使用intird作为rootfs,则具体命令为:

1
2
3
4
5
6
qemu-system-x86_64 \
-kernel ./linux/arch/x86/boot/bzImage \ # 指定编译好的内核镜像
-initrd ./rootfs/initrd_rootfs.img \ # 指定rootfs
-serial stdio \ #指定使用stdio作为输入输出
-append "root=/dev/ram rdinit=/fakeinit console=ttyS0 nokaslr" \ # 内核参数,指定使用initrd作为rootfs,禁止地址空间布局随机化
-s -S # 指定Qemu在启动时暂停并启动gdb server,等待gdb的连入(端口默认为1234)

如果使用磁盘镜像作为rootfs,则具体命令为:

1
2
3
4
5
6
qemu-system-x86_64 \
-kernel ./linux/arch/x86/boot/bzImage \
-hda ./rootfs/busybox_rootfs.img \ # 指定磁盘镜像
-serial stdio \
-append "root=/dev/sda console=ttyS0 nokaslr" \ # 内核参数,指定root磁盘,禁止地址空间布局随机化
-s -S

使用GDB调试内核

最后一步,由于刚刚Qemu开启了远程调试,所以只需要将gdb通过连入即可:

1
gdb ./linux/vmlinux # 指定调试文件为包含调试信息的内核文件

如果此时直接在gdb调试器中使用target remote:1234连入Qemu的gdb server,则会出现报错Remote ‘g’ packet reply is too long,这是由于gdb没有正确识别调试目标的体系结构造成的(有些博客认为需要修改源代码屏蔽这个错误,实际上是不必要的),所以只需要在远程attach之前使用set arch i386:x86-64:intel设置目标体系结构即可。

例如,你希望在start_kernel函数设置断点进行调试,则在启动Qemu后,gdb的命令如下:

1
2
3
4
5
6
gdb ~/linux/vmlinux
(gdb) set arch i386:x86-64:intel
(gdb) add-auto-load-safe-path ~/linux
(gdb) target remote:1234
(gdb) b start_kernel
(gdb) c

可以发现,内核在启动后被中断在start_kernel函数上。

后记

内核文档

在内核的文档中,有一篇详细讲解了如何使用GDB调试内核。

该文档的最新版本可见于内核的官网:https://www.kernel.org/doc/html/latest/dev-tools/gdb-kernel-debugging.html

而具体的版本就需要在内核源码中编译文档了,例如html版本的文档可以使用make htmldocs进行编译,在启动HTTP服务器后,可以在浏览器中进行访问,例如,http://127.0.0.1:8000/dev-tools/gdb-kernel-debugging.html

参考来源

本文参考了两篇较为优质的博客: