1. 调试环境

使用Docker构建Linux Kernel的运行及调试环境

1.1 构建环境

如果没有一个便捷的方式运行与调试代码,那么想要阅读 Linux Kernel 的源码将是十分困难的,本文意在分享如何通过 Docker, 并基于 busybox, qemu-kvm 以及 gdb 等工具构建一个编译、运行、调试 Linux Kernel 的容器环境。

首先,我们需要一个能够编译 Linux Kernel 的 Docker 镜像。新建目录 $HOME/linux/docker:

在该目录下创建文件 build-kernel.sh 并写入如下内容:

#!/bin/bash

cd /workspace/linux-5.12.14
make O=../obj/linux/ -j$(nproc)

该文件用来编译内核,在后续”编译Kernel“一节会使用到。

在该目录下创建文件 start-gdb.sh 并写入如下内容:

#!/bin/bash

echo 'add-auto-load-safe-path /workspace/linux-5.12.14/scripts/gdb/vmlinux-gdb.py' > /root/.gdbinit # 让 gdb 能够顺利加载内核的调试脚本,如果在下一节编译 Linux Kernel 时下载的是另一版本的 Linux Kernel 代码,请修改这里的版本号
cd /workspace/obj/linux/
gdb vmlinux -ex "target remote :1234" # 启动 gdb 远程调试内核

该文件用于调试 Linux Kernel 时进入 GDB 调试器,在最后一节“调试Linux Kernel”一节中会使用到。

另外再创建文件 Dockerfile 并写入如下内容:

FROM debian:10.8-slim

RUN apt-get update
RUN apt install -y apt-transport-https ca-certificates \
    && echo 'deb https://mirrors.tuna.tsinghua.edu.cn/debian/ buster main contrib non-free \n\
    deb https://mirrors.tuna.tsinghua.edu.cn/debian/ buster-updates main contrib non-free \n\
    deb https://mirrors.tuna.tsinghua.edu.cn/debian/ buster-backports main contrib non-free \n\
    deb https://mirrors.tuna.tsinghua.edu.cn/debian-security buster/updates main contrib non-free\n'\
    > /etc/apt/sources.list \
    && apt update && apt-get install -y \
    bc \
    bison \
    build-essential \
    cpio \
    flex \
    libelf-dev \
    libncurses-dev \
    libssl-dev \
    vim-tiny \
    qemu-kvm \
    gdb
ADD ./start-gdb.sh /usr/local/bin
ADD ./build-kernel.sh /usr/local/bin
RUN chmod a+x /usr/local/bin/*.sh
WORKDIR /workspace

通过如下命令构建镜像:

docker build -t linux-builder .

为了简便,我们将运行与调试需要的工具 qemu-kvm 与 gdb 也安装到了该镜像中,这样本文涉及到的所有操作都可以使用一个镜像完成。

1.2 编译 Linux Kernel

1.2.1 下载代码

下载最新稳定版的内核代码:

cd $HOME/linux/
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.12.14.tar.xz
tar -xvJf linux-5.12.14.tar.xz

或者访问 https://kernel.org, 通过如下链接下载代码,并将代码解压至目录 $HOME/linux/

1.2.2 编译Kernel

创建编译结果的输出目录:

mkdir -p $HOME/linux/obj

进入目录 $HOME/linux/ 并运行如下命令,进入容器编译内核:

docker run -it --name linux-builder -v $HOME/linux:/workspace linux-builder

在容器内进入解压后内核源代码目录,并配置 Kernel 的编译选项:

cd /workspace/linux-5.12.14
make O=../obj/linux menuconfig

该命令会打开一个基于命令行的配置页面,如下图所示:

我们需要将调试信息编译至内核文件中,导航至 Kernel hacking ---> Compile-time checks and compiler options 并选中如下选项:

保存并退出。此时的配置信息写入到了文件 /workspace/obj/linux/.config, 也可以直接修改该文件来完成配置。例如此处我们勾选的编译选项对应的配置项为:

CONFIG_DEBUG_INFO=y

CONFIG_GDB_SCRIPTS=y

选中 CONFIG_GDB_SCRIPTS 是为了调试时能够使用内核代码中提供的 gdb 脚本,gdb 支持使用 python 来扩展命令,内核代码中提供的脚本在目录 $HOME/linux/linux-5.12.14/scripts/gdb 中。

完成配置之后,通过如下命令编译内核:

docker run -it --name linux-builder --rm -v $HOME/linux:/workspace linux-builder bash build-kernel.sh

第一次编译耗时较长,编译结果都在目录 /workspace/obj/linux 中。

将上述编译命令保存到文件 build-kernel.sh 中,并且为该文件添加可执行权限:

chmod a+x build-kernel.sh

以后每次修改了代码后,就可以运行该脚本来编译内核了。

1.3 使用 Busybox 构建 initramfs

1.3.1 编译busybox

回到宿主机,下载 busybox 到工作目录并解压:

cd $HOME/linux
wget https://busybox.net/downloads/busybox-1.33.1.tar.bz2
tar -vxjf busybox-1.33.1.tar.bz2

回到编译内核的容器 linux-builder 中,对 busybox 进行编译配置:

mkdir -p /workspace/obj/busybox # 创建 busybox 的编译输出目录
cd /workspace/busybox-1.33.1
make O=../obj/busybox menuconfig

最后一条命令会打开配置目录,选中 Settings ---> Build static binary (no shared libs), 如下图所示:

然后通过如下命令编译并安装 busybox:

cd /workspace/obj/busybox/
make -j$(nproc)
make install

1.3.2 构造 initramfs

此处我们使用 busybox 构建一个极简的 initramfs, 能引导 Linux 启动并进入一个 shell 环境就足够。在容器中回到目录 /workspace 执行如下命令:

mkdir -p /workspace/initramfs/busybox
cd !$
mkdir -p {bin,sbin,etc,proc,sys,usr/{bin,sbin}}
cp -av /workspace/obj/busybox/_install/* .

此时我们已经将 busybox 生成的可执行文件全部拷贝到了对应目录,但还缺少一个 init 程序,可以简单写一个 shell 脚本来充当 init, 将如下内容写入文件 /workspace/initramfs/busybox/init 中:

#!/bin/sh

mount -t proc none /proc
mount -t sysfs none /sys

echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"

exec /bin/sh

为文件添加可执行权限:

chmod a+x /workspace/initramfs/busybox/init

通过如下命令将所有内容打包:

cd /workspace/initramfs/busybox
find . -print0 \
    | cpio --null -ov --format=newc \
    | gzip -9 > /workspace/obj/initramfs-busybox.cpio.gz

文件 /workspace/obj/initramfs-busybox.cpio.gz 便是最终的 initramfs, 该文件会在启动内核时作为参数传递给 qemu.

1.4 运行 Linux Kernel

重新启动一个容器,在该容器中启动编译好的内核:

docker run -it --name linux-runner -v $HOME/linux:/workspace linux-builder

进入容器后运行如下命令启动内核:

qemu-system-x86_64 -kernel /workspace/obj/linux/arch/x86/boot/bzImage -initrd /workspace/obj/initramfs-busybox.cpio.gz -nographic -append "console=ttyS0"

成功启动并进入 shell:

我们将运行内核的命令整理成一个脚本保存在文件 start-kernel.sh 中:

docker run -it --name linux-builder --rm -v $HOME/linux:/workspace linux-builder qemu-system-x86_64 -kernel /workspace/obj/linux/arch/x86/boot/bzImage -initrd /workspace/obj/initramfs-busybox.cpio.gz -nographic -append "console=ttyS0"

并且为该文件添加可执行权限:

chmod a+x start-kernel.sh

然后就可以运行该脚本来启动内核了。

1.5 调试 Linux Kernel

1.5.1 GDB

使用上一节的方式启动内核,但需要为 qemu-system-x86_64 添加参数 -s -S:

-s shorthand for -gdb tcp::1234

-S freeze CPU at startup (use ’c’ to start execution)

同时还需要将参数 -append "console=ttyS0" 修改为 -append nokaslr, 否则 qemu 在启动过程不会在断点处停止。我们创建一个启动脚本 start-kernel-debugger.sh, 包含如下内容:

docker run -it --name linux-debugger --rm -v $HOME/linux:/workspace linux-builder qemu-system-x86_64 -s -S -kernel /workspace/obj/linux/arch/x86/boot/bzImage -initrd /workspace/obj/initramfs-busybox.cpio.gz -nographic -append nokaslr

此时该容器不会有任何输出,因为参数 -S 让 qemu 暂时停止执行,需要通过 gdb 发送命令让其继续。接下来我们进入当前容器,并使用脚本 /usr/local/bin/start-gdb.sh 进入GDB:

docker exec -it linux-debugger bash start-gdb.sh

进入 gdb 命令行之后,连上 qemu 暴露的端口进行远程调试,然后就可以正常调试内核代码了:

由于去掉了 -append "console=ttyS0", 所以此时 qemu 的窗口不会输出任何日志,我们可以通过内核代码中附带的命令 lx-dmesg 来查看:

内核附带的命令都有前缀 lx-, 可以通过如下命令查看所有的命令:

此时,就可以通过GDB来调试内核的所有代码了!

1.5.2 printk

GDB 的使用有一定的学习门槛,更便捷的方式是使用函数printk 打印日志来对代码进行追踪与调试,我们只需要在感兴趣的地方打印出日志,然后使用前面讨论过的方式重新编译内核并运行内核,就可以在启动日志中看到结果。

例如我们找到内核的入口函数,位于init/main.c文件中的函数start_kernel(), 在函数开头添加如下代码:

asmlinkage __visible void __init __no_sanitize_address start_kernel(void)
{
  printk("*******************start kernel*********************");
  char *command_line;
  char *after_dashes;
  
  // 省略后续所有代码
  }

然后使用脚本build-kernel.sh编译内核,这次的编译会快很多,编译完成之后执行脚本start-kernel.sh, 便可以在启动日志的最前面看到我们添加的内容:

脚本build-kernel.shstart-kernel.sh请参见前文对应章节。

可以看到第一行便是我们添加的日志。 printk 的详细用法可以参考 https://www.kernel.org/doc/html/latest/core-api/printk-basics.html

Last updated