使用GDB调试Linux内核

GDB(GNU Debugger)是Linux上的调试程序,可用于C/C++GoRust等多种语言。GDB可以让你在被调试程序执行时看到它的”内部“情况,观察程序在特定断点上的状态,并逐行运行代码。

GDB还提供了“远程”模式,使用GDB协议通过网络或串行设备与被调试程序进行通信。程序需要链接GDB提供的stub,这个stub实现了GDB协议。或者可以使用GDBserver,这时程序不需要进行任何更改。

类似的,Linux内核开发者可以使用GDB的远程模式,与调试应用程序几乎相同的方式来调试Linux内核。KGDB是Linux内核的源代码级调试器,你可以使用GDB作为KGDB的前端,在我们熟悉且功能强大的GDB调试界面中调试内核。

使用KGDB需要两台机器,一台作为开发机,另一台是目标机器,要调试的内核在目标机器上运行。在开发机上使用gdb运行包含符号信息的vmlinux,然后通过指定网络地址和端口,连接到目标机器的KGDB。我们也可以使用QEMU/KVM虚拟机作为目标机器,让待调试的内核运行在虚拟机中,然后在宿主机上运行gdb,连接到虚拟机中的KGDB

kgdb.png

本文将介绍如何在本机搭建Linux内核调试环境,步骤比较繁琐,还会涉及到编译内核。作为内核小白,我会尽量写的详细些,毕竟我折腾了很久才成功。

# 本机环境

我使用的Ubuntu 20.04.2 LTSgdb版本为9.2。

# 安装QEMU/KVM和Virsh

VirshVirtual Shell的缩写,是一个用于管理虚拟机的命令行工具。你可以使用Virsh创建、编辑、启动、停止、关闭和删除VMVirsh目前支持KVMLXCXenQEMUOpenVZVirtualBoxVMware ESX。这里我们使用Virsh管理QEMU/KVM虚拟机。

在安装之前,首先要确认你的CPU是否支持虚拟化技术。使用grep查看cpuinfo是否有"vmx"(Intel-VT 技术)或"svm"(AMD-V 支持)输出:

1
egrep "(svm|vmx)" /proc/cpuinfo

某些CPU型号在默认情况下,BIOS中可能禁用了VT支持。我们需要再检查BIOS设置是否启用了VT的支持。使用kvm-ok命令进行检查:

1
2
$ sudo apt install cpu-checker
$ kvm-ok

如果输出为:

1
2
INFO: /dev/kvm exists
KVM acceleration can be used

证明CPU的虚拟化支持已经在BIOS中启用。

运行下面的命令安装QEMU/KVMVirsh

1
$ sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils virtinst virt-manager

检查libvirt守护程序是否已经启动:

1
2
$ sudo systemctl is-active libvirtd
active

如果没有输出active,运行下面的命令启动libvertd服务:

1
2
$ sudo systemctl enable libvirtd
$ sudo systemctl start libvirtd

# 创建虚拟机镜像

创建一个虚拟机镜像,大小为40G,qcow2 格式为动态分配磁盘占用空间。

1
qemu-img create -f qcow2 ubuntutest.img 40G

# 创建虚拟机,安装操作系统

使用下面的命令启动虚拟机,-cdrom参数为虚拟机挂载了Ubuntu的安装光盘:

1
qemu-system-x86_64 -enable-kvm -name ubuntutest  -m 4096 -hda ubuntutest.img -cdrom ubuntu-20.04.2-live-server-amd64.iso -boot d -vnc :19

我们使用VNC客户端连接进虚拟机,完成Ubuntu的安装。注意上面的命令通过-vnc :19设置了虚拟机的VNC监听端口为5919

connection.png

vnc-viewer.png

我使用的VNC客户端是VNC Viewer,支持Windows、macOS和Linux等主流平台。按照正常步骤,完成Ubuntu在虚拟机上的安装。

安装完成后,可以用ctrl+c退出qemu-system-x86_64命令的执行来停止虚拟机。再次启动虚拟机,需要把 -cdrom 参数去掉。

1
qemu-system-x86_64 -enable-kvm -name ubuntutest  -m 4096 -hda ubuntutest.img -boot d -vnc :19

# 配置虚拟机网络

为了让虚拟机能访问外部网络,我们需要形成下面的结构:

networking.png

在宿主机上创建网桥br0,并设置一个IP地址:

1
2
3
$ sudo brctl addbr br0
$ sudo ip link set br0 up
$ sudo ifconfig br0 192.168.57.1/24

编辑宿主机的/etc/sysctl.conf文件,设置IP转发生效:

1
net.ipv4.ip_forward=1

使用sysctl -p重新加载sysctl.conf配置使其生效。

在宿主机上增加SNAT规则。

1
sudo iptables -t nat -A POSTROUTING -o wlp2s0 -j MASQUERADE

虚拟机的IP地址外部并不认识,如果它要访问外网,需要在数据包离开前将源地址替换为宿主机的IP,这样外部主机才能用宿主机的IP作为目的地址发回响应。

上面的命令的含义是:在nat表的POSTROUTING链增加规则,出口设备为wlp2s0时,就执行MASQUERADE动作。MASQUERADE是一种源地址转换动作,它会动态选择宿主机的一个IP做源地址转换。

注意上面命令中的 -o 参数,指定了数据包的出口设备为wlp2s0。你需要使用ip link命令在你的机器上查看具体设备的名称:

ip_link.png

如果想进一步了解iptables,可以参见我的另一篇文章《Docker单机网络模型动手实验》

接着我们需要将虚拟机的网卡连接到网桥br0。后面我们使用libvirt来管理QEMU/KVM虚拟机,这样可以把虚拟机的配置参数记录在XML文件中,易于维护。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<domain type='kvm' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
  <name>ubuntutest</name>
  <uuid>0f0806ab-531d-6134-5def-c5b4955292aa</uuid>
  <memory unit='GiB'>4</memory>
  <currentMemory unit='GiB'>4</currentMemory>
  <vcpu placement='static'>2</vcpu>
  <os>
    <type arch='x86_64' machine='pc-i440fx-trusty'>hvm</type>
    <boot dev='hd'/>
  </os>
  <features>
    <acpi/>
    <apic/>
    <pae/>
  </features>
  <clock offset='utc'/>
  <on_poweroff>destroy</on_poweroff>
  <on_reboot>restart</on_reboot>
  <on_crash>restart</on_crash>
 <devices>
    <emulator>/usr/bin/kvm</emulator>
    <disk type='file' device='disk'>
      <driver name='qemu' type='qcow2'/>
      <source file='/home/mazhen/works/ubuntutest.img'/>
      <target dev='vda' bus='virtio'/>
    </disk>
    <controller type='pci' index='0' model='pci-root'/>
    <interface type='bridge'>
      <mac address='fa:16:3e:6e:89:ce'/>
      <source bridge='br0'/>
      <target dev='tap1'/>
      <model type='virtio'/>
    </interface>
    <serial type='pty'>
      <target port='0'/>
    </serial>
    <console type='pty'>
      <target type='serial' port='0'/>
    </console>
    <graphics type='vnc' port='5919' autoport='no' listen='0.0.0.0'>
      <listen type='address' address='0.0.0.0'/>
    </graphics>
    <video>
      <model type='cirrus'/>
    </video>
  </devices>
  <qemu:commandline>
    <qemu:arg value='-s'/>
  </qemu:commandline>
</domain>

我们可以看到,source file指定的文件/home/mazhen/works/ubuntutest.img就是虚拟机镜像。devices中的interface定义了虚拟网卡,br0是我们前面创建的网桥,libvirt帮我们创建的虚拟网卡会连接到网桥br0上。

XML文件保存为domain.xml,然后在libvirt定义虚拟机:

1
$ virsh define domain.xml

接着我们可以使用virsh list --all查看虚拟机列表:

1
2
3
4
$ virsh list --all
 Id   Name         State
-----------------------------
 -    ubuntutest   shut off

使用命令virsh start ubuntutest启动虚拟机:

1
2
3
4
5
6
7
$ virsh start ubuntutest
Domain ubuntutest started

$ virsh list
 Id   Name         State
----------------------------
 1    ubuntutest   running

这时我们使用VNC Viewer连接进行虚拟机,为虚拟机配置IP地址。虚拟机安装的是ubuntu-20.04.2,编辑/etc/netplan/00-installer-config.yaml文件配置IP地址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
network:
  ethernets:
    ens3:
            addresses: [192.168.57.100/24]
            gateway4: 192.168.57.1
            dhcp4: no
            nameservers:
                    addresses: [114.114.114.114]
            optional: true
  version: 2

我们可以看到,网关配置的就是br0IP地址。然后,使用命令 netplan apply让配置生效。这样,虚拟机的网络就配置好了,可以在虚拟机里访问到外网。这时我们就可以在宿主机上使用ssh登录虚拟机,这样比使用VNC Viewer操作更方便一些。

# 下载Linux内核源码

在虚拟机上下载Linux内核源码:

1
$ sudo apt install linux-source-5.4.0

ubuntu-20.04.2对应的内核版本是5.4。可以使用uname -srm查看内核版本。

源码被下载到来/usr/src/目录下,使用下面的命令解压缩:

1
sudo tar vjxkf linux-source-5.4.0.tar.bz2 

内核源码被解压缩到了/usr/src/linux-source-5.4.0目录下。

# 编译Linux内核

首先我们需要安装编译内核用到的依赖包:

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

编译前要定义内核编译选项。进入/usr/src/linux-source-5.4.0目录,运行下面的命令,会进入内核参数配置界面:

1
$ sudo make menuconfig

menuconfig.png

为了构建能够调试的内核,我们需要配置以下几个参数。

  • CONFIG_DEBUG_INFO 在内核和内核模块中包含调试信息,这个选项在幕后为gcc使用的编译器参数增加了-g选项。

这个选项的菜单路径为:

1
2
3
Kernel hacking  --->
Compile-time checks and compiler options  ---> 
 [*] Compile the kernel with debug info   

实际上通过菜单进行设置比较麻烦。我们保存设置退出后,配置会保存在.config文件中。直接编辑这个文件会更方便一些。在.config中确认CONFIG_DEBUG_INFO的设置正确。

1
CONFIG_DEBUG_INFO=y
  • CONFIG_FRAME_POINTER 这个选项会将调用帧信息保存在寄存器或堆栈上的不同位置,使gdb在调试内核时可以更准确地构造堆栈回溯跟踪(stack back traces)。

.config中设置:

1
CONFIG_FRAME_POINTER=y
  • 启用CONFIG_GDB_SCRIPTS,但要关闭CONFIG_DEBUG_INFO_REDUCED
1
2
CONFIG_GDB_SCRIPTS=y
CONFIG_DEBUG_INFO_REDUCED=n
  • CONFIG_KGDB 启用内置的内核调试器,该调试器允许进行远程调试。
1
CONFIG_KGDB=y
  • 关闭CONFIG_RANDOMIZE_BASE设置
1
CONFIG_RANDOMIZE_BASE=n

KASLR会更改引导时放置内核代码的基地址。如果你在内核配置中启用了KASLRCONFIG_RANDOMIZE_BASE=y),则无法从gdb设置断点。

设置完必要的内核参数后,我们开始编译内核:

1
2
3
sudo make -j8 
sudo make modules_install
sudo make install

编译的过程很漫长,可能需要数小时。当编译完毕之后,新内核的选项已经增加到了grub的配置中。我们可以查看配置文件/boot/grub/grub.cfg确认:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
submenu 'Advanced options for Ubuntu' $menuentry_id_option 'gnulinux-advanced-5506d28f-c9e7-46d4-a12e-42555d491eec' {
        menuentry 'Ubuntu, with Linux 5.4.106' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-5.4.106-advanced-5506d28f-c9e7-46d4-a12e-42555d491eec' {
                recordfail
                load_video
                gfxmode $linux_gfx_mode
                insmod gzio
                if [ x$grub_platform = xxen ]; then insmod xzio; insmod lzopio; fi
                insmod part_gpt
                insmod ext2
                if [ x$feature_platform_search_hint = xy ]; then
                  search --no-floppy --fs-uuid --set=root  5506d28f-c9e7-46d4-a12e-42555d491eec
                else
                  search --no-floppy --fs-uuid --set=root 5506d28f-c9e7-46d4-a12e-42555d491eec
                fi
                echo    'Loading Linux 5.4.106 ...'
                linux   /boot/vmlinuz-5.4.106 root=UUID=5506d28f-c9e7-46d4-a12e-42555d491eec ro maybe-ubiquity
                echo    'Loading initial ramdisk ...'
                initrd  /boot/initrd.img-5.4.106
        }

vmlinuz-5.4.106就是我们新编译的内核。

重启虚拟机。在GRUB界面选择 Ubuntu 高级选项,选择第一项进去,就进入了新的内核。

# 启用gdb监听端口

QEMU有个命令行参数-s,它代表参数-gdb tcp::1234,意思是QEMU监听 1234端口,这样gdb 可以 attach 到这个端口上,调试QEMU里面的内核。

实际上在前面的domain.xml中我们已经为QEMU加了-s参数。

1
2
3
4
5
6
<domain type='kvm' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
...
  <qemu:commandline>
    <qemu:arg value='-s'/>
  </qemu:commandline>
</domain>

所以这时运行在虚拟机里的内核已经可以被调试了。

# 调试内核

在宿主机上运行gdb需要内核的二进制文件,这个文件就是在虚拟机GRUB里配置的/boot/vmlinuz-5.4.106。为了方便在调试过程中查看源代码,我们可以将虚拟机的/usr/src/linux-source-5.4.0整个目录都拷贝到宿主机上来。

1
$ scp -r mazhen@virtual-node:/usr/src/linux-source-5.4.0 ./

/usr/src/linux-source-5.4.0目录下面的vmlinux文件也是内核的二进制文件。

为了能让gdb在启动时能够加载Linux helper脚本,需要在~/.gdbinit文件中添加如下内容:

1
add-auto-load-safe-path /path/to/linux-build

/path/to/linux-build就是上面从虚拟机拷贝过来的Linux源码目录。

必要的配置完成后,就可以启动gdb了。

在宿主机的./linux-source-5.4.0目录下执行gdb vmlinux

然后在gdb的交互环境下使用target remote :1234命令attach到虚拟机的内核。

1
2
3
4
5
6
$ gdb vmlinux
...
Reading symbols from vmlinux...
(gdb) target remote :1234
Remote debugging using :1234
0xffffffff81ade35e in native_safe_halt () at ./arch/x86/include/asm/irqflags.h:60

如果我们想调试进程fork的过程,可以用b _do_fork设置断点:

1
2
(gdb) b _do_fork
Breakpoint 1 at 0xffffffff81098450: file kernel/fork.c, line 2362.

我们可以看到,断点设置成功。如果你不确认fork的具体方法名,可以使用info functions命令搜索符号表:

1
2
3
4
5
(gdb) info function do_fork
All functions matching regular expression "do_fork":

File kernel/fork.c:
2361:	long _do_fork(struct kernel_clone_args *);

使用命令c让内核继续执行:

1
2
(gdb) c
Continuing.

这时在虚拟机里执行任意命令,例如ls,断点将被触发:

1
2
3
4
5
6
(gdb) c
Continuing.

Thread 1 hit Breakpoint 1, _do_fork (args=0xffffc9000095fee0) at kernel/fork.c:2362
2362	{
(gdb) 

我们可以使用n执行下一条语句:

1
2
3
4
5
6
7
(gdb) n
2376		if (!(clone_flags & CLONE_UNTRACED)) {
(gdb) n
2377			if (clone_flags & CLONE_VFORK)
(gdb) n
2379			else if (args->exit_signal != SIGCHLD)
(gdb) 

l显示多行源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
(gdb) l
2374		 * for the type of forking is enabled.
2375		 */
2376		if (!(clone_flags & CLONE_UNTRACED)) {
2377			if (clone_flags & CLONE_VFORK)
2378				trace = PTRACE_EVENT_VFORK;
2379			else if (args->exit_signal != SIGCHLD)
2380				trace = PTRACE_EVENT_CLONE;
2381			else
2382				trace = PTRACE_EVENT_FORK;
2383	

bt查看函数调用信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(gdb) bt
#0  _do_fork (args=0xffffc9000095fee0) at kernel/fork.c:2379
#1  0xffffffff810989f4 in __do_sys_clone (tls=<optimized out>, child_tidptr=<optimized out>, parent_tidptr=<optimized out>, newsp=<optimized out>, 
    clone_flags=<optimized out>) at kernel/fork.c:2544
#2  __se_sys_clone (tls=<optimized out>, child_tidptr=<optimized out>, parent_tidptr=<optimized out>, newsp=<optimized out>, clone_flags=<optimized out>)
    at kernel/fork.c:2525
#3  __x64_sys_clone (regs=<optimized out>) at kernel/fork.c:2525
#4  0xffffffff81003fd7 in do_syscall_64 (nr=<optimized out>, regs=0xffffc9000095ff58) at arch/x86/entry/common.c:290
#5  0xffffffff81c0008c in entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:175
#6  0x00005621191e2da0 in ?? ()
#7  0x000056211a7de450 in ?? ()
#8  0x00007ffc9f31a3e0 in ?? ()
#9  0x0000000000000000 in ?? ()

p用于打印内部变量值:

1
2
(gdb) p clone_flags
$1 = 18874368

你现在可以像调试普通应用程序一样,调试Linux内核了!

# 写在最后

在本机搭建Linux内核调试环境的步骤有点繁杂,但使用GDB能调试内核,会成为我们学习内核的利器,进程管理、内存管理、文件系统,对源码有什么困惑就可以debug一下。 Enjoy it!

comments powered by Disqus