调试内核

本文默认读者已经通过 linux 源码编译得到内核镜像了, 即用于调试的 vmlinux 和 用于启动的 bzImage

概述

操作系统内核仅仅用于提供管理硬件 CPU/内存/磁盘 等最基本的能力, 只有操作系统内核是远远不够的, 裸内核仍然需要一些软件环境才能够使用. 因此需要一个根文件系统(root file system)来提供文件系统的支持.

initramfs 包含了一个最小的文件系统,它包括一些基本的工具和驱动程序, 比如说 ls cat mkdir 等等, 以便在系统启动后能够挂载更完整的文件系统. initramfs 可以分为两部分来理解

操作系统启动过程中为什么需要 initramfs 呢?

首先需要说明的是, 使用 initramfs 是可选的. 默认情况下,内核使用内置驱动程序初始化硬件,挂载指定的根分区,加载已安装的 Linux 发行版的 init 系统.然后,init 系统加载其他模块并启动服务,直到它最终允许您登录.这是一个很好的默认行为,对许多用户来说已经足够了

initramfs 通过一个临时的内存文件系统提供了早期的用户空间,可以做内核在引导过程中自己无法轻松完成的事情. 比如说:

迁移到早期用户空间是必要的,因为查找和安装真正的根设备很复杂.根分区可以跨多个设备(raid 或单独的日志).它们可以在网络上(需要dhcp,设置特定的MAC地址,登录服务器等).它们可以存在于可移动媒体上,具有动态分配的主要/次要数字和持续的命名问题,需要完整的 udev 实现来解决.它们可以被压缩、加密、写入时复制、环回装载、奇怪的分区等. 此外内核的核心模块放置于 /lib/modules/$(uname -r)/kernel/ 当中, 这些模块必须要根目录(/) 被挂载时才能够被读取.但是如果核心本身不具备磁碟的驱动程序时, 当然无法挂载根目录,也就没有办法取得驱动程序,因此造成两难的地步

比如 Ubuntu18.04 mini.iso 只有 70MB, 只包含了一个图形化的 Ubuntu 安装流程, 所有的软件包/内核都是从网络上下载的. 这种复杂性(不可避免地包括策略)在内核空间不好处理也不应该处理, 很适合在用户空间中完成相关的配置操作

正常的发行版启动都会经过两步

下面我们来完成一个简易但是完善的 linux 发行版的启动与调试过程

qemu

调试内核首先需要一个虚拟化的硬件模拟器, QEMU 是一个非常健壮的模拟器和仿真器, 对虚拟化技术的支持很好, 可以直接使用 apt 安装

sudo apt install qemu qemu-system qemu-kvm

也可以选择手动编译安装最新版的 qemu

qemu download 找到当前最新版本, 笔者目前最新版本为 9.0.1

sudo apt-get install git libglib2.0-dev libfdt-dev libpixman-1-dev zlib1g-dev ninja-build

下面的 --prefix 为最终安装的路径, 可以选择安装安装的位置, 笔者这里直接安装在 ~/qemu 下了

wget https://download.qemu.org/qemu-9.0.1.tar.xz
tar xvJf qemu-9.0.1.tar.xz
cd qemu-9.0.1
./configure --prefix=~/qemu --enable-kvm  --target-list=x86_64-softmmu
make -j`nproc`
sudo make install

Busybox

BusyBox 是一个轻量级的 Unix 工具箱,它集成了许多标准 Unix 工具的功能,并且可以运行在资源受限的系统上,例如嵌入式设备和网络路由器等.BusyBox 能够代替大多数标准 Unix 工具集的实现,从而减少了系统空间和资源的需求.

BusyBox 的工具集包括了数百个 Unix 工具,如 ls/cp/cat/grep/tar/gzip/awk/sed/vi/ping/telnet 等,它们都被打包成一个可执行文件.BusyBox 本身只有一个可执行文件,但它包含了大量的 Unix 工具,并且可以通过命令行参数来指定使用哪些工具.

busybox 在当系统出现故障没有办法正常登录解决, 特别是在关键组件被误删或损坏时(比如误删 GLIBC), 可以找到你的根文件系统所在的分区并挂载到一个目录, 然后将准备好的 BusyBox 可执行文件复制到挂载的根文件系统中, 在 chroot 环境中,创建一些符号链接,以便 BusyBox 能够模拟常见的 UNIX 工具, 然后就可以使用 BusyBox 提供的基本工具进行系统修复.

wget https://busybox.net/downloads/busybox-1.36.0.tar.bz2
tar xf busybox-1.36.0.tar.bz2
cd busybox-1.36.0/
make menuconfig

配置选项中勾选 Build Static Lib, 因为这一步只是制作一个简单的根文件系统, 此时还没有 glibc 等重要的 C 运行库, 所以没办法动态链接的运行, 暂时用静态链接将 glibc 打包进可执行文件

-> Settings
   -> [*] Build static binary (no shared libs)

20230314191937

如果要编译 32 位的 busybox, 需要在 settings 中为 CFLAGS LDFLAGS 添加 -m32 选项, 并且安装 32 位编译环境

20240306183920

sudo apt-get install gcc-multilib
make -j$(nproc)
make install

make 结束的时候会有如下的警告, 忽略即可

Static linking against glibc, can't use --gc-sections
Trying libraries: crypt m resolv rt
 Library crypt is not needed, excluding it
 Library m is needed, can't exclude it (yet)
 Library resolv is needed, can't exclude it (yet)
 Library rt is not needed, excluding it
 Library m is needed, can't exclude it (yet)
 Library resolv is needed, can't exclude it (yet)
Final link with: m resolv

编译得到的可执行文件保存在 _install/ 目录下, 我们可以进入这个目录的bin文件夹下使用 ll 看到一个busybox可执行文件和众多链接文件, 这个 busybox 就是之后需要使用的工具

20230314192159

制作initramfs

新建一个文件夹(workspace)用于后续的工作区

mkdir workspace
cd workspace

将之前的 linux kernel中的 arch/x86/boot/bzImage_install/bin/busybox 拷贝到此目录下

前者可以创建软链接, 后者最好直接复制过来

此时的目录结构:

workspace/
 bzImage
 busybox

新建 initramfs 目录用于后续的磁盘启动

mkdir initramfs
cd initramfs
mkdir -p bin
mv ../busybox bin/
touch init
chmod +x init
cd ..

此时的文件结构如下所示, 我们将 busybox 移动到 /bin 目录下, 另外创建了一个带有执行权限的 init 文件

workspace/
 initramfs
    bin
       busybox
    init

在 initramfs/ 中新建 init 文件用于启动, 将下面的内容复制到init中, 它的作用是设置一些基本的环境和挂载文件系统,并最终执行一个shell, 以便于在引导过程中提供一个临时的操作环境,以便进行一些必要的操作或调试

#!/bin/busybox sh

# initrd, only busybox and /init
BB=/bin/busybox

$BB --install -s /bin

mkdir -p dev etc lib mnt proc sbin sys tmp var

mount -t devtmpfs  devtmpfs  /dev
mount -t proc      proc      /proc
mount -t sysfs     sysfs     /sys
mount -t tmpfs     tmpfs     /tmp

exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console

echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
setsid cttyhack setuidgid 1000 sh

poweroff -d 0  -f

脚本中的命令简单解释一下:

initd(或者叫init)是系统引导过程的第一个用户空间进程,负责启动和管理系统中的各个服务和进程.它通常由操作系统提供,并且具有更复杂的功能和管理能力,如根据不同的运行级别加载和管理服务.initd通常使用一系列的启动脚本来管理服务的启动/停止和重启等操作.给出的脚本不是initd进程本身,而是一个在引导过程中执行的自定义脚本,它可能具有特定的目的或需求,但不具备initd进程的全部功能和特性

现代 linux 发行版一般用的都是 systemd 作为守护进程

在根目录新建 Makefile, 复制如下内容

注意下面的 Makefile 代码复制过去之后还需要手动改一下空格为 \t, 不然会出错

.PHONY: init qemu clean debug

KERNEL = bzImage
INITRAMFS = initramfs.img
QEMU = qemu-system-x86_64
# 如果是自己编译的 qemu 可以使用自己的路径
# QEMU = ~/qemu/bin/qemu-system-x86_64

init:
    cd ./initramfs && find . | cpio -ov --format=newc | gzip -9 > ../$(INITRAMFS)

qemu:
    $(QEMU) \
          -kernel $(KERNEL) \
          -initrd $(INITRAMFS) \
          -m 1G \
          -nographic \
          -append "earlyprintk=serial,ttyS0 console=ttyS0"

debug:
    $(QEMU) \
          -kernel $(KERNEL) \
          -initrd $(INITRAMFS) \
          -m 1G \
          -nographic \
          -append "earlyprintk=serial,ttyS0 console=ttyS0" \
          -S \
          -s

clean:
    rm $(INITRAMFS)

简单解释一下这里的命令的含义

最终的文件结构如下所示

20230314202620

QEMU 启动

制作initramfs

make init

如果报错 cpio 找不到的话先下载 sudo apt install cpio

执行之后会得到 initramfs.img

使用qemu开始模拟

make qemu

成功启动后按下enter进入命令行, 使用ls查看目录结构, 也可以在这个命令行中测试一些内容, 这些常用命令都是 busybox 提供的支持

20230518133704

退出 QEMU 模拟: 使用 ctrl + A 然后按下 x

Vscode + gdb

启用调试内核只需要

make debug

"-S"选项表示在启动时暂停虚拟机的执行.它使得虚拟机在启动后立即进入调试模式,并等待调试器连接. "-s"选项表示在启动时打开一个GDB服务器,监听本地的1234端口.这个选项与"-S"选项一起使用,用于配合调试器进行调试

如果没有冲突的话默认使用 1234 端口即可, 否则添加 -gdb tcp::12345 进行端口调整

当然, 这种方法很原始, 笔者更倾向于使用带 GUI 的更加方便的vscode来进行调试

生成源码标签

首先阅读代码的时候没有什么智能提示和补全, 这是因为需要生成智能补全的头文件, 高版本的内核提供了一个脚本就可以直接得到 compile_commands.json

linux 默认提供了 make gtags 生成源码标签, 我们不使用

python3 ./scripts/clang-tools/gen_compile_commands.py

# 如果是老版本linux这个文件的位置在
python3 ./scripts/gen_compile_commands.py

等待一段时间运行结束之后得到 compile_commands.json

如果没有找到这个脚本可以使用 Bear 来在编译时生成脚本

sudo apt install bear
bear -- <之前的 make 命令>
# bear -- make -j`nproc`

默认的 Vscode C++ 插件都可以索引到根目录下的 compile_commands.json, 但可能还是会有一些报错如下所示, 这是因为 compile_commands.json 中的编译选项有一些无法识别的东西

20230518140756

笔者目前的解决办法是手动将这个文件中的这几个编译选项删除

-mpreferred-stack-boundary=3
-mindirect-branch=thunk-extern
-mindirect-branch-register
-mindirect-branch-cs-prefix
-fno-allow-store-data-races
-fconserve-stack

使用如下命令直接修改 compile_commands.json

sed -i 's/\(-mpreferred-stack-boundary=3\|-mindirect-branch=thunk-extern\|-mindirect-branch-register\|-mindirect-branch-cs-prefix\|-fno-allow-store-data-races\|-fconserve-stack\)//g' compile_commands.json

然后就没有报错了, 整个代码看起来很清爽

20230518224144

考虑到其他内核版本可能使用了不同的编译选项, 读者可以根据 clangd 的提示信息从中删除或修改对应的内容

sed -i 's/your warnning command//g' compile_commands.json

接下来配置调试程序, 新建.vscode/launch.json, 复制如下的代码, 设置调试的名称是 qemu-kernel-gdb, 使用本机的1234端口映射, 调试 vmlinux 文件

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "qemu-kernel-gdb",
            "type": "cppdbg",
            "request": "launch",
            "miDebuggerServerAddress": "127.0.0.1:1234",
            "program": "${workspaceFolder}/vmlinux",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": false,
            "logging": {
                "engineLogging": false
            },
            "MIMode": "gdb"
        }
    ]
}

至此已经全部结束了, 试着在 init/main.c 中搜索 start_kernel, 在下面打一个断点试试?

在目录 workspace 下执行 make debug 开启 qemu 模拟, 并把内核的调试信息通过 1234 端口转发出去, 然后点击 vscode 的调试以连接 1234 端口使用 gdb 调试

20230518224436

20230518224610

.gdbinit

gdb 还有一些其他的配置选项, 您可以创建 .gdbinit 文件(参考 xv6) 来辅助调试

调试可执行文件

当前 gdb 调试的对象是操作系统内核的代码, 如果我们希望执行一个可执行文件, 并且在可执行文件的代码中也加入断点

以下述代码为例, 其中使用到了 fork 多进程, 代码逻辑比较简单, 就是三个进程随机打印 ABC

#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sched.h>
#include <unistd.h>

char bar[3968] = "\n";
char foo[4096] = "this is not a test\n";

void output_loop(char *str)
{
    int i;
    for (i = 0; i < 20; i++) {
        write(2, str, strlen(str));
        sched_yield();
    }
}

int main()
{
    int pid1, pid2, status;

    write(2, foo, strlen(foo));
    strcpy(foo, "you are modified\n");
    write(2, foo, strlen(foo));

    if (!(pid1 = fork())) {
        output_loop("B  ");
        exit(0);
    }

    if (!(pid2 = fork())) {
        output_loop("C  ");
        exit(0);
    }

    output_loop("A  ");
    waitpid(pid1, &status, 0);
    waitpid(pid2, &status, 0);
    write(2, "\n", 1);

    while (1)
        ;

    exit(0);
}

使用静态链接编译, 并加上调试选项 -g

gcc -static -g a.c -o a

使用 readelf 找到该文件的 .text 段的虚拟地址, 即 0x4011c0

$ readelf -S a | grep .text
  [ 7] .text             PROGBITS         00000000004011c0  000011c0

将该可执行文件拷贝到磁盘镜像当中, 比如前文的 initramfs/bin 下, 或者 mount 磁盘然后 copy 过去

修改 .gdbinit, 其中 add-symbol-file 表示添加一个文件, 然后跟一个代码段起始地址, 然后我们在 23 行(即write)的位置打一个断点

注意不要在 main 上打断点, 停不住

add-auto-load-safe-path ./scripts/gdb/vmlinux-gdb.py

add-symbol-file /home/kamilu/workspace/a 0x4011c0
b a.c:23

启动 qemu, 执行 /bin/a 即可发现程序停在 23 行的位置了

20240919162236

调试 32 位内核

调试 32 位 x86 内核的情况, 需要指定 archi 为 i386, 添加 gdb 的配置信息

# .gdbinit
set archi i386:x86-64

修改 launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "setupCommands": [
                {
                    "description": "Enable pretty-printing for gdb",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                },
                {
                    "description": "Set debuggee architecture",
                    "text": "set archi i386:x86-64",
                    "ignoreFailures": true
                }
            ]
        }
    ]
}

targetArchitecture in launch.json not working

调试模块

GDB+QEMU调试内核模块(实践篇)

总结

至此我们完成了一个基础的 linux kernel 调试环境, 但是目前的所有操作都只会保存在 initramfs 中, 目前可用的软件很少, 没有编译器, 没有联网, 没有 apt 包管理工具, 不能持久化存储

下文我们介绍一下如何使用 qemu 模拟更加复杂的环境, 以及如何利用现有的 Linux 发行版根文件系统(比如说 Ubuntu 的基础软件包)来搭建一个可用的 linux 开发环境, 替换自己编译的内核

参考

zood