本文默认读者已经通过 linux 源码编译得到内核镜像了, 即用于调试的 vmlinux 和 用于启动的 bzImage
操作系统内核仅仅用于提供对硬件资源的管理,以及进程调度、内存管理等最基本的能力, 只有一个裸内核用户也没有办法进行任何的交互使用,所以我们起码需要一些软件环境才能够使用。
在Linux内核被加载到内存并运行后,内核进程最终需要切换到用户的进程来使用计算机,而用户进程的可执行文件一般保存在磁盘等外部存储设备上,这个存储设备也被称为 linux 根文件系统。
对于磁盘来说,想要读取其中的文件内容需要内核安装对应的磁盘驱动,以及对应的文件系统模块(比如ext4),但是内核源码在编译时大部分驱动都是以模块的方式[M]编译的,而这些驱动程序保存在存储设备上(/lib/modules/$(uname -r)/kernel/)。那么出现了一个鸡生蛋蛋生鸡的问题,要访问 / 目录就需要先加载驱动程序,但是没有驱动又没办法访问访问 /。
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
initramfs 到根文件系统的启动演示参考视频:18-Linux 操作系统 (initramfs; 最小 Linux 世界) [南京大学2024操作系统]
NOTE
参考阅读
下面我们来完成一个简易但是完善的 linux 启动与调试过程
调试内核首先需要一个虚拟化的硬件模拟器, 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 installBusyBox 是一个轻量级的 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)如果要编译 32 位的 busybox, 需要在 settings 中为
CFLAGSLDFLAGS添加-m32选项, 并且安装 32 位编译环境sudo apt-get install gcc-multilib
make -j$(nproc)
make installTIP
如果编译时出现报错 "'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 就是之后需要使用的工具
新建一个文件夹(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脚本中的命令简单解释一下:
/dev /sys /proc /tmp 这些目录及其内部的文件并不是真实的磁盘文件, 而是在操作系统运行的过程中创建的文件, 它们的文件系统属于内存文件系统. 一方面为用户提供了内核相关的接口, 例如可以通过访问 /proc/<uid> 获取到某个进程的相关数据root 用户的 UID 为 0, 普通用户的 UID 从 1000 起
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)简单解释一下这里的命令的含义
find . | cpio -ov --format=newc | gzip -9 > ../initramfs.img这个命令的作用是将当前目录下的所有文件和子目录,打包成一个 cpio 归档文件,并使用 gzip 压缩成一个压缩文件,最后保存到上级目录的 initramfs.img 文件中, 用于创建初始化内存文件系统(initramfs)
find .: 查找当前目录(以及其子目录)下的所有文件和目录,输出它们的路径。cpio -ov --format=newc: 将 find 命令输出的路径列表作为输入,创建一个 cpio 归档文件。-o 表示创建归档文件,-v 表示显示详细信息,--format=newc 表示使用 newc 格式创建归档文件,该格式通常用于初始化内存文件系统(initramfs).gzip -9: 对 cpio 归档文件进行压缩,并且使用最高压缩比 -9 以达到最小化文件大小。-nographic 表示在终端中以无图形模式启动虚拟机,不使用图形界面。-append "earlyprintk=serial,ttyS0 console=ttyS0" 表示向内核传递启动参数。其中,earlyprintk=serial,ttyS0 表示启用串口输出信息,console=ttyS0 表示将控制台输出定向到串口终端(ttyS0)上。最终的文件结构如下所示
制作initramfs
make init如果报错 cpio 找不到的话先下载
sudo apt install cpio
执行之后会得到 initramfs.img
使用qemu开始模拟
make qemu成功启动后按下enter进入命令行, 使用ls查看目录结构, 也可以在这个命令行中测试一些内容, 这些常用命令都是 busybox 提供的支持
这是一个临时的内存文件系统,在这个系统上的内容不会被保存下来,可以简单尝试 echo "123" > 1.txt,重启之后发现 1.txt 消失了
退出 QEMU 模拟: 使用 ctrl + A 然后按下 x
启用调试内核只需要
make debugTIP
调试时不要使用 qemu 的 -enable-kvm, 否则无法正确的插入断点
正常执行的话不调试可以在 qemu 启动时加上
-enable-kvm -cpu host大大加速启动
"-S"选项表示在启动时暂停虚拟机的执行。它使得虚拟机在启动后立即进入调试模式,并等待调试器连接。"-s"选项表示在启动时打开一个GDB服务器,监听本地的1234端口。这个选项与"-S"选项一起使用,用于配合调试器进行调试
如果没有冲突的话默认使用 1234 端口即可, 否则添加
-gdb tcp::12345进行端口调整
make qemu, 此时运行会卡住gdb vmlinux 调试进入gdb后连接1234端口
(gdb) target remote :1234在start_kernel处打一个断点(此函数位于init/main.c), 然后继续
(gdb) b start_kernel
(gdb) c当然, 这种方法很原始, 笔者更倾向于使用带 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 中的编译选项有一些无法识别的东西
如果读者使用的是 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然后就没有报错了, 整个代码看起来很清爽
NOTE
没有报错指大部分文件都没有明显的警告了, 如果点开一些头文件还是可以看到找不到符号的问题, 这是因为有一些头文件引用不规范, 暂且搁置
这里简单介绍一下这几个编译选项的含义,这些编译选项是 GCC 独有的,clang 并不完全支持所以报错找不到
- -mpreferred-stack-boundary=3: 控制栈指针的对齐边界,表示以 2^3 = 8 字节对齐栈
- -mindirect-branch=thunk-extern:强制所有间接分支(如 jmp *%rax)不直接执行,而是跳转到“thunk”函数中执行,以防止推测执行攻击,thunk-extern 表示使用外部定义的 thunk(通常在 arch/x86/lib/retpoline.S 中定义),内核支持 retpoline(Return trampoline)技术来防止 CPU 推测攻击
- -mindirect-branch-register:也是 Spectre v2 防护的一部分,要求使用寄存器跳转时(如 jmp *%rax),使用特定的寄存器保存目标地址
- -mindirect-branch-cs-prefix:给间接分支加上 CS 段前缀,主要用于 x86 retpoline 安全补丁 的一个变体,以改变 CPU 的预测行为,使其不被误预测到不安全的路径
- -fno-allow-store-data-races:GCC 默认认为非原子变量的读写之间的竞争是“未定义行为”,但在内核中这些情况经常出现。因此这个选项告诉编译器: "不要乱优化有数据竞争的代码,我自己知道在干什么",保证内核同步原语(如 READ_ONCE, WRITE_ONCE)正确工作
- -fconserve-stack:要求编译器尽量节省栈空间,Linux 内核的栈只有 8KB(在某些架构甚至更小),编译器的递归展开或寄存器溢出到栈上可能导致栈溢出
考虑到其他内核版本可能使用了不同的编译选项, 读者可以根据 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 调试
NOTE
读者这里可能有些混乱,怎么一会儿是 vmlinux 一会儿是 bzImage。事实上 vmlinux 和 bzImage 都是Linux内核编译生成的文件,它们的主要区别在于它们的文件格式和用途。
- vmlinux 是Linux内核编译生成的未压缩的内核镜像文件
- 它是一个 ELF 文件,包含了整个内核的代码和数据,主要用来调试内核.
- 并不是“裸机镜像”,不能直接被 BIOS/UEFI/QEMU 当作可启动映像加载
- bzImage 是通过 make bzImage 从 vmlinux 包装生成的一个压缩过的可引导内核镜像
- 它是用来引导启动Linux操作系统的
- bzImage 将vmlinux压缩成一个单独的文件,并添加一些引导代码和头部信息
- 它可以直接被 BIOS/UEFI 或 QEMU 识别、加载并执行
所以在 Makefile 脚本中我们使用的是可引导的 bzImage,bzImage首先会被加载到内存中,然后被解压缩成vmlinux形式的内核映像。而 vscode 中 launch.json 是 gdb 的配置参数,gdb 需要传入 ELF 文件并进行调试信息符号解析,所以需要传入 vmlinux
更多内容详见内核镜像格式 和 Linux boot protocol
至此我们完成了一个基础的 linux kernel 调试环境, 但是目前的所有操作都只会保存在 initramfs 中, 目前可用的软件很少, 没有编译器, 没有联网, 没有 apt 包管理工具, 不能持久化存储。不过你已经可以开启你的内核调试之旅了,将编译好的软件拷贝到 initramfs/ 下然后重新打包就可以把可执行文件带进去了!
TIP
现在系统中还没有 libc 的动态链接库,包括 busybox 在内的所有文件都是静态链接得到的,记得加上参数 -static
下文我们介绍一下如何使用 qemu 模拟更加复杂的环境, 以及如何利用现有的 Linux 发行版根文件系统(比如说 Ubuntu 的基础软件包)来搭建一个可用的 linux 开发环境, 替换自己编译的内核