Env:

  • Ubuntu 20.04 with kernel version 5.13.0-30-generic

  • gcc v9.3.0

compiling kernel v5.16

编译内核

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
make menuconfig
# 保持默认,但需要打开调试信息和gdb script
# Kernel hacking ->
# Compile-time checks and compiler options ->
# [*]Compile the kernel with debug info
# [*]Provide GDB scripts for kernel debugging
#
# 关闭SYSTEM_TRUSTED_KEYS和SYSTEM_REVOCATION_KEYS
# Cryptographic API ->
# Certificates for signature checking ->
# [ ]Provide system-wide ring of revocation certificates
# ()Additional X.509 keys for default system keyring
#
# optional:关闭KASLR,否则无法添加断点;也可以在运行kernel时关闭。
# Processor type and features ->
# [ ]Randomize the address of the kernel image (KASLR)
make -j8

编译完成后的内核镜像为arch/x86_64/boot/bzImage(big zip Image).

准备根文件系统

内核需要一个根文件系统,initramfs本义是一个内存中的临时文件系统,它的主要使命就是挂载磁盘上的根文件系统,并具有完成此任务所需要的相关module。如果我们不需要持久化存储,也可以直接用initramfs作为根文件系统。

选项1:使用busybox创建initramfs

busybox将一些简化的常用UNIX工具集成进了一个可执行文件,可以用它构建最小化的rootfs.

  1. 配置和编译busybox,通过make install把busybox展开成UNIX目录结构,可以发现所有的工具都是到busybox的soft link.

    1
    2
    3
    4
    5
    6
    make menuconfig
    # 静态编译
    # Settings ->
    # [*]Build static binary (no shared libs)
    make -j8
    make install
  2. (重要)kernel默认使用/init作为一个进程,创建一个init文件,让它去调有实现的/sbin/init即可。顺便可以挂一下procfs和sysfs。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    cd _install

    cat > init << EEOOFF
    #!/bin/sh
    /bin/mount -t proc none /proc
    /bin/mount -t sysfs sysfs /sys
    echo "Your own /init is used!"
    exec /sbin/init
    EEOOFF

    chmod a+x init
  3. 准备一些文件的空实现,否则内核引导时找不到这些文件会一直报错:

    1
    2
    3
    4
    5
    mkdir proc dev sys
    mkdir -p etc/init.d
    touch etc/init.d/rcS
    chmod a+x rcS
    touch dev/{tty1,tty2,tty3,tty4}
  4. 使用cpio打包成initrd.

    1
    find . -print0 | cpio --null -ov --format=newc| gzip -9 > somewhere/initramfs.cpio.gz

这里得到的initramfs并没有承担挂载磁盘文件系统的责任,它自身就会成为kernel的根文件系统。

选项2(推荐):使用busybox创建boot floppy

我们也可以不使用initramfs,而是使用busybox创建一个持久化的boot floppy供kernel使用,在busybox-source-code/examples/bootfloppy目录下有相关脚本及文档,大致步骤如下:

  1. 创建一个大小为1k*4000=4M的空镜像,并在其上构建文件系统。

    1
    2
    dd if=/dev/zero of=mybootfloppy bs=1k count=4000
    mkfs.ext4 mybootfloppy
  2. 将这个img作为回环设备挂载到文件系统,使我们可以像访问块设备一样访问它。

    1
    2
    sudo mkdir -p /mnt/mybootfloppy
    sudo mount -o loop mybootfloppy /mnt/mybootfloppy
  3. 在该img上安装(已经静态编译的)busybox.

    1
    sudo make CONFIG_PREFIX=/mnt/mybootfloppy install
  4. 创建最小文件系统所需的devproc,将busybox提供的etc下部分文件也放入img,解除挂载。

    1
    2
    3
    sudo mkdir /mnt/mybootfloppy/{dev,proc,etc}
    sudo cp -r examples/bootfloppy/etc/* /mnt/mybootfloppy/etc
    sudo umount /mnt/mybootfloppy

    etc下的文件基本都是被init程序使用的空实现。

使用QEMU运行kernel

参数说明:

  • -kernel指定内核二进制文件
  • -nographic无图形界面,以命令行运行qemu
  • -initrd指定init ram disk,即装有initramfs的img文件
  • -hda设置虚拟硬盘0,并指定装入的img及相关参数
  • -append用于向kernel传入额外的参数
  • -m指定使用的内存大小
  • -s指定qemu在某端口监听,默认1234
  • -S使虚拟cpu在执行首条指令前暂停

注意:nokaslr参数是必须的(如果编译内核时开启了kaslr),否则地址空间被随机化,gdb的断点无法生效。

基于initramfs

1
2
3
qemu-system-x86_64 -kernel arch/x86_64/boot/bzImage \
-nographic -initrd path/to/your/initramfs_img \
-append "console=ttyS0 nokaslr"

参数console=ttyS0使我们有一个终端可以观察输出。

基于hard drive

1
2
3
4
qemu-system-x86_64 -kernel arch/x86_64/boot/bzImage \
-nographic -hda path/to/your/bootfloppy_img \
-append "root=/dev/sda console=ttyS0 nokaslr" \
-m 512

额外的参数root指定了根文件系统的位置。这种启动方式不需要initramfs,似乎是因为qemu承担了挂载硬盘的责任。

当然,也可以同时添加-initrd-hda参数,如果initramfs会去加载硬盘文件系统的话(比如是由mkinitramfs创建的),也能正常进入boot floppy上的root file system.

对硬盘上的文件所做的更改会被持久化保存,不过rootfs默认会被只读挂载,可以通过mount -o remount rw /重新挂载。

使用gdb调试kernel

因为编译内核时打开了调试选项和gdb支持,源码根目录下的vmlinux文件已经包含了符号信息,此外也生成了便于调试的gdb脚本,在gdb中有很多命令可供使用,可用apropos word查找与word有关的命令。

执行gdb vmlinux会读入符号表并载入相关的script,如果被安全机制阻拦,可以在~/.gdbinit中使用add-auto-load-safe-path允许脚本执行。

令QEMU运行kernel(额外添加-s -S参数),在gdb中连接到qemu:

1
2
3
4
(gdb)target remote :1234
Remote debugging using :1234
0x000000000000fff0 in exception_stacks ()
(gdb)

设置断点并运行,qemu在断点处暂停,符合预期。

1
2
3
4
5
6
7
8
(gdb) b start_kernel
Breakpoint 1 at 0xffffffff82b44b33: file init/main.c, line 931.
(gdb) c
Continuing.

Breakpoint 1, start_kernel () at init/main.c:931
931 {
(gdb)

在vscode中使用gdb调试kernel

配置launch.json并在代码中设置断点即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "debugging kernel",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/vmlinux",
"args": [],
"stopAtEntry": true,
"cwd": "${fileDirname}",
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"miDebuggerPath": "/usr/bin/gdb",
"miDebuggerServerAddress": "localhost:1234"
}
]
}

讨论

  1. initrd/initramfs是可选而不是必需的,kernel镜像本身如果包含了所需的driver和module也可以直接从硬盘加载rootfs.[3]
  2. (未经考证)似乎基于kvm进行调试需要使用硬件断点hbreak,基于qemu使用break就足够了。[2]
  3. [5]指出mmu启动前的符号因为直接使用了物理地址,而无法被gdb定位并打断点,这个现象在xv6中也存在。目前未确定kernel被加载到的物理地址,暂未尝试给出的解决方案。

  1. QEMU + Busybox 模拟 Linux 内核环境

  2. Booting a Custom Linux Kernel in QEMU and Debugging It With GDB

  3. Is it possible to boot the Linux kernel without creating an initrd image?

  4. 用QEMU来调试内核 -- 亲身体验篇

  5. linux内存子系统 - qemu调试linux 内核启动