调试内核

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

从initramfs到根文件系统

操作系统内核仅仅用于提供对硬件资源的管理,以及进程调度、内存管理等最基本的能力, 只有一个裸内核用户也没有办法进行任何的交互使用,所以我们起码需要一些软件环境才能够使用。

在Linux内核被加载到内存并运行后,内核进程最终需要切换到用户的进程来使用计算机,而用户进程的可执行文件一般保存在磁盘等外部存储设备上,这个存储设备也被称为 linux 根文件系统

对于磁盘来说,想要读取其中的文件内容需要内核安装对应的磁盘驱动,以及对应的文件系统模块(比如ext4),但是内核源码在编译时大部分驱动都是以模块的方式[M]编译的,而这些驱动程序保存在存储设备上(/lib/modules/$(uname -r)/kernel/)。那么出现了一个鸡生蛋蛋生鸡的问题,要访问 / 目录就需要先加载驱动程序,但是没有驱动又没办法访问访问 /

[!NOTE] NOTE

在早期的linux系统中,一般只有硬盘或者软盘被用来作为linux根文件系统的存储设备,因此也就很容易把这些设备的驱动程序集成到内核中。但是现在的嵌入式系统中可能将根文件系统保存到各种存储设备上,包括scsi、sata,u-disk等等。因此把这些设备的驱动代码全部编译到内核中显然就不是很方便。

因此我们需要initramfs, initramfs 是一个很小很精简的内存文件系统, 所有的数据和文件都保存在内存中(ramfs,掉电消失),它包括一些基本的工具和驱动程序, 比如说 ls cat mkdir 等等, 以便在系统启动后能够挂载更完整的文件系统。initramfs 通过一个临时的内存文件系统提供了早期的用户空间,可以做内核在引导过程中自己无法轻松完成的事情。 比如说:

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

此外,一旦根文件系统启动出错(比如找不到/dev/sda,内核配置有问题,libc 等关键库被误删除导致等等),系统会回退到 initramfs 的状态,这个模式也被称为救援模式(rescue mode),用于在系统启动失败时还有一个可用的环境进行救火(参见安装内核和救火)。

首先需要说明的是, 使用 initramfs 是可选的。我们当然可以把驱动程序直接编译进内核(不以模块形式保存到文件),这样就可以直接挂载指定的根分区, init 系统加载其他模块并启动服务。对于一些嵌入式 linux 设备(比如路由器等)就是这么做的。只不过大部分 linux 发行版都采用的这种 initramfs 的两步启动模式。

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

[!TIP] TIP

initramfs 到根文件系统的启动演示参考视频:18-Linux 操作系统 (initramfs; 最小 Linux 世界) [南京大学2024操作系统]

[!NOTE] NOTE

参考阅读

下面我们来完成一个简易但是完善的 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
[!TIP] TIP

如果编译时出现报错 "'TC_CBQ_MAXPRIO' undeclared" "'TCA_CBQ_OVL_STRATEGY' undeclared"

这是一个 Busybox 社区中的已知问题,核心原因是 CBQ support 在 commit 被移除

修复这个错误只需要删除 rm networking/tc.c 后直接编译即可

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

创建一些文件内容初始化

touch Makefile
mkdir -p initramfs/bin
touch initramfs/init
chmod +x initramfs/init

然后将 _install/bin/busybox 拷贝到 bin/ 目录下,将 arch/x86_64/boot/bzImage 拷贝到 workspace 下,此时的目录结构:

.
 Makefile
 bzImage
 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 $SHELL

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

poweroff -d 0  -f

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

[!TIP] TIP

事实上即使不去挂载 /sys /proc 系统也可以正常启动运行,但是这些文件系统是用户空间和内核交互的关键接口,不挂载的话ps、top、free 全部失败,其他 init 脚本或者守护进程也可能会失败

#!/bin/busybox sh
BB=/bin/busybox
$BB --install -s /bin
setsid cttyhack setuidgid 1000 sh
poweroff -d 0  -f

把下面的内容复制到 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

这是一个临时的内存文件系统,在这个系统上的内容不会被保存下来,可以简单尝试 echo "123" > 1.txt,重启之后发现 1.txt 消失了

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

Vscode + gdb

启用调试内核只需要

make debug
[!TIP] TIP

调试时不要使用 qemu 的 -enable-kvm, 否则无法正确的插入断点

正常执行的话不调试可以在 qemu 启动时加上 -enable-kvm -cpu host 大大加速启动

"-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

如果读者使用的是 clangd 的话可以直接设置它的配置文件, 创建 ~/.config/clangd/config.yaml 文件, 该配置的含义是忽略无法识别的编译选项, 大部分无法识别的编译参数都是以 -m -f 开头的

CompileFlags:
  Add: -Wno-unknown-warning-option
  Remove: [-m*, -f*]

如果使用的是微软的 c++ 插件的话可以手动将这个文件中的这几个编译选项删除, 使用如下命令直接修改 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
sed -i 's/-mfunction-return=thunk-extern//g' compile_commands.json
sed -i 's/-fzero-call-used-regs=used-gpr//g' compile_commands.json
sed -i 's/-fsanitize=bounds-strict//g' compile_commands.json
sed -i 's/-mrecord-mcount//g' compile_commands.json

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

20230518224144

[!NOTE] NOTE

没有报错指大部分文件都没有明显的警告了, 如果点开一些头文件还是可以看到找不到符号的问题, 这是因为有一些头文件引用不规范, 暂且搁置

这里简单介绍一下这几个编译选项的含义,这些编译选项是 GCC 独有的,clang 并不完全支持所以报错找不到

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

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

接下来配置调试程序, 新建.vscode/launch.json, 复制如下的代码, 设置调试的名称是 qemu-kernel-gdb(这个名字可以随意修改), 使用本机的1234端口(这个端口号不要修改,这是 qemu gdb 默认的映射端口)调试 vmlinux 文件

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "qemu-kernel-gdb",
            "type": "cppdbg",
            "request": "launch",
            "miDebuggerServerAddress": "127.0.0.1:1234",
            "program": "${workspaceFolder}/vmlinux",
            "targetArchitecture": "x64",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": false,
            "logging": {
                "engineLogging": false
            },
            "MIMode": "gdb",
            "setupCommands": [
                {
                    "text": "dir .",
                    "ignoreFailures": false
                },
                {
                    "text": "add-auto-load-safe-path ./",
                    "ignoreFailures": false
                },
                {
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                }
            ]
        }
    ]
}

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

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

20230518224436

20230518224610

[!NOTE] NOTE

读者这里可能有些混乱,怎么一会儿是 vmlinux 一会儿是 bzImage。事实上 vmlinux 和 bzImage 都是Linux内核编译生成的文件,它们的主要区别在于它们的文件格式和用途。

所以在 Makefile 脚本中我们使用的是可引导的 bzImage,bzImage首先会被加载到内存中,然后被解压缩成vmlinux形式的内核映像。而 vscode 中 launch.json 是 gdb 的配置参数,gdb 需要传入 ELF 文件并进行调试信息符号解析,所以需要传入 vmlinux

更多内容详见内核镜像格式Linux boot protocol

总结

至此我们完成了一个基础的 linux kernel 调试环境, 但是目前的所有操作都只会保存在 initramfs 中, 目前可用的软件很少, 没有编译器, 没有联网, 没有 apt 包管理工具, 不能持久化存储。不过你已经可以开启你的内核调试之旅了,将编译好的软件拷贝到 initramfs/ 下然后重新打包就可以把可执行文件带进去了!

[!TIP] TIP

现在系统中还没有 libc 的动态链接库,包括 busybox 在内的所有文件都是静态链接得到的,记得加上参数 -static

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

参考

zood