0x01 前言
这篇文章会通过各种示例,来演示如何使用 qemu 运行和调试我们自己构建的 linux 内核。
不过在做具体的演示之前,我们先要做一些准备工作。
我们先要有一个 linux 环境,并且安装好了 qemu,然后下载 内核源码 到 ~/linux 目录。
接着进入该目录,使用对应的 make 命令,构建一个新的 linux 内核。
也就是生成 arch/x86/boot/bzImage 文件:
在做完这些准备工作之后,我们就可以开始演示了。
0x02 qemu 运行 linux内核
在终端执行以下命令:
qemu-system-x86_64 \ -machine q35 \ -cpu host \ -accel kvm \ -smp 4 \ -m 4G \ -kernel ~/linux/arch/x86/boot/bzImage \ -append "console=ttyS0" \ -nographic
上面命令中各参数的意义:
-machine q35 # 使用更新的 q35 机器类型,而非默认的 i440fx 机器类型 -cpu host # 指定要模拟的 cpu 类型以及该 cpu 支持的特性,host 表示和本机 cpu 一样 -accel kvm # 使用 kvm 加速 -smp 4 # 指定 cpu 个数 -m 4G # 指定内存大小 -kernel ~/linux/arch/x86/boot/bzImage # 指定要运行的 linux 内核,这个是我们上面构建好的 -append "console=ttyS0" # 指定内核参数 -nographic # 使用命令行界面,而非图形化界面
在执行完该命令后,内核就开始启动,它会在终端输出各种日志。
但在启动后期,内核会发生 panic:
该 panic 产生的原因,是内核尝试挂载根文件系统,但是却没有找到,因为我们根本就没有为其指定根文件系统。
修复这个 panic 的方式也很简单,就是创建一个根文件系统,然后用 root 参数,告诉内核这个根文件系统在哪里。
0x03 创建根文件系统
内核挂载根文件系统的目的,是为了执行它里面的 init 程序,init 程序执行成功,就表示内核的启动流程结束,用户态启动流程开始。
所以,我们创建根文件系统的最终目标,就是在它内部的正确位置,安装一个 init 程序。
这个 init 程序可以是 linux 下的任何可执行文件,比如它可以是一个 shell 脚本,可以是一个我们自己写的程序,也可以是当前各 linux 发行版正在使用的 systemd。
不过这里有一点需要注意,就是程序一般都是有运行时依赖的,比如 linux 下的绝大部分程序,在运行时都依赖 glibc 这个库。
所以,如果我们想要安装在根文件系统的 init 程序正常执行,我们除了在正确位置安装 init 程序外,还要安装它的运行时依赖。
这个安装过程,如果用手工来完成的话,是非常麻烦的。
那怎么办呢?
我们可以用 linux 发行版的包管理工具来安装 init 程序。
包管理工具可以让我们非常方便的把 init 程序,以及它的运行时依赖,以及这些依赖的依赖,都安装到我们指定的根文件系统里,或者说是安装到指定根文件系统所在的硬盘里。
下面来演示下。
我们先创建一个文件,用来模拟一块硬盘,然后再把它格式化为 ext4 文件系统,此硬盘上的文件系统,就是我们一直说的根文件系统。
相关命令如下:
上图中,我先使用 qemu-img create 命令,创建一个 root.img 文件,用来模拟一块 20 GiB 大小的硬盘。
然后,我又使用 qemu-img info 命令,以及 ls 命令,查看 root.img 实际占用的本地硬盘大小,其实只有 4 KiB。
这就说明,root.img 文件占用的本地硬盘大小,和其模拟的硬盘大小无关,而是和该文件中存储的数据大小有关。
基于这个特性,我们可以把这块模拟的硬盘设置的大一点,这样防止以后空间不够用。
在创建好这块硬盘,并格式化好根文件系统之后,我们就可以往这块硬盘里安装各种东西了,其中最主要的,就是 init 程序。
因为我们要使用 linux 发行版的包管理工具来安装软件,所以我们先要有相应的系统环境。
下面我们就以 archlinux 发行版为例,讲一下如何快速搭建一个 archlinux 环境,以及如何使用 archlinux 的包管理工具,向指定的根文件系统安装软件。
注意,如果你本机已经是 archlinux 了,那搭建环境这一步,就可以跳过。
我们先从 archlinux 官方 下载最新的 iso 镜像,然后使用以下命令,从这个 iso 镜像启动一个 archlinux 环境:
qemu-system-x86_64 \ -machine q35 \ -cpu host \ -accel kvm \ -smp 4 \ -m 4G \ -drive file=./root.img,if=virtio,format=raw \ -cdrom ./archlinux-2025.01.01-x86_64.iso \ -boot order=d
上面的命令中,有三个新的参数,它们的意义分别是:
-drive file=./root.img,if=virtio,format=raw # 将刚创建的 root.img 文件当作硬盘挂载到虚拟机上 -cdrom ./archlinux-2025.01.01-x86_64.iso # 将刚下载的 iso 文件当作光盘挂载到虚拟机上 -boot order=d # 告诉虚拟机从光盘启动
通过以上命令,我们就进入到了 iso 文件提供的 archlinux 环境:
有了这个环境,我们就可以使用 archlinux 的包管理工具,向 root.img 对应的硬盘里安装软件了。
具体操作如下:
# root.img 对应的硬盘为 /dev/vda,我们如果想要访问该硬盘,就必须先把它挂载到当前系统的某个目录上 # 这里挂载的目录是 /mnt mount /dev/vda /mnt # 然后我们就可以使用以下命令,向 /mnt 目录,也就是 /dev/vda 硬盘对应的根文件系统,安装软件了 # base 软件包里就包含了 systemd,也就是 init 程序,helix 是一个编辑器 pacstrap -K /mnt base helix
执行完上述命令后,我们看下 /mnt 目录里的内容:
是不是就和一般 linux 机器上的根目录一样了。
在安装完所需的软件之后,我们还要为这个新的根文件系统里的 root 账户设置一个密码,这样后面我们才能登录:
# 把 /mnt 目录设置为新的根目录 arch-chroot /mnt # 为当前用户,也就是 root 用户设置密码 passwd # 退回到 iso 环境,或者是你自己的 archlinux 环境 exit # 如果当前是从 iso 启动的 archlinux 环境,我们就可以执行以下命令,关闭这个环境了 shutdown now
通过以上步骤,我们就制作好了一个新的根文件系统 root.img,以及安装好了必要软件,其实就是准备好了一个可启动的用户态 linux 环境。
下面我们就可以告知 linux 内核,让其启动这个 linux 环境了。
0x04 qemu 运行 linux 内核并指定根文件系统
再次执行之前的 qemu 命令,运行我们自己构建的内核,不过这次指定了根文件系统:
qemu-system-x86_64 \ -machine q35 \ -cpu host \ -accel kvm \ -smp 4 \ -m 4G \ -drive file=./root.img,if=virtio,format=raw \ -kernel ~/linux/arch/x86/boot/bzImage \ -append "root=/dev/vda rw console=ttyS0" \ -nographic
这次新增了 -drive 参数,用于把 root.img 文件,当成硬盘挂载到虚拟机上。
同时在 -append 参数里,新增了 root=/dev/vda 内核参数,用于告诉内核,根文件系统所在硬盘位置,以及 rw 参数,表示该硬盘是可读写的。
通过执行上述命令,我们就可以进入到,我们上面安装的 linux 环境了:
此时可以通过 uname 命令,确定一下当前运行的内核,就是我们自己构建的内核。
0x05 以 uefi 的方式启动内核
qemu 默认情况下是以 bios 的方式启动 linux 内核的,但目前我们使用的绝大部分真实机器,都是以 uefi 的方式启动内核的,那如何让 qemu 也用 uefi 方式启动内核呢?
通过 ovmf,下面我们来说下具体步骤。
因为 ovmf 是一个独立的软件,所以要先安装。
nixos 安装 OVMF, archlinux 安装 edk2-ovmf,其他 linux 发行版根据相关文档自行安装。
安装 ovmf 的目的,就是为了获取 OVMF_CODE.fd 和 OVMF_VARS.fd 这两个文件。
比如下图就是在我自己的 nixos 系统上,安装 OVMF 后的相关文件:
注意,在不同的 linux 发行版上,这两个文件的名字可能略微不同,比如 archlinux 上这两个文件名就分别是 OVMF_CODE.4m.fd 和 OVMF_VARS.4m.fd。
OVMF_CODE.fd 文件用于存储 uefi 固件代码,OMVF_VARS.fd 文件用于存储 uefi 变量。
OVMF_CODE.fd 是只读的,且是可以在多个虚拟机之间共享的。
OVMF_VARS.fd 要是可读写的,且每个虚拟机都应该独有一份该文件。
上图中,除了这两个文件之外,还有另外一个文件 OVMF.fd,该文件是 OVMF_CODE.fd 和 OVMF_VARS.fd 的合集,也就是说,它既包含了 uefi 固件代码,又可以用来存储 uefi 变量。
在安装完 ovmf 之后,执行如下命令:
也就是把 OVMF_CODE.fd 和 OVMF_VARS.fd 这两个文件,拷贝到我们自己创建的 uefi 目录下。
然后,再执行以下命令,告诉 qemu 以 uefi 的方式启动内核:
qemu-system-x86_64 \ -machine q35 \ -cpu host \ -accel kvm \ -smp 4 \ -m 4G \ -drive file=./uefi/OVMF_CODE.fd,if=pflash,format=raw,readonly=on \ -drive file=./uefi/OVMF_VARS.fd,if=pflash,format=raw \ -drive file=./root.img,if=virtio,format=raw \ -kernel ~/linux/arch/x86/boot/bzImage \ -append "root=/dev/vda rw console=ttyS0" \ -nographic
这次新增的就是两个 -drive 参数,用于指定 OVMF_CODE.fd 和 OVMF_VARS.fd 文件的位置。
在执行完上面的命令后,qemu 就会以 uefi 的方式,来启动我们自己构建的 linux 内核了。
在内核启动成功之后,我们可以通过以下命令,来验证内核是以 uefi 方式启动的:
当然,我们也可以通过内核启动日志来验证:
另外说明一下。
之前我们把 OVMF_CODE.fd 和 OVMF_VARS.fd 都拷贝到了 uefi 目录下,这个其实对于一般的 linux 发行版来说,是不必要的。
对于一般的 linux 发行版,比如 archlinux,我们只用拷贝 OVMF_VARS.fd 这个文件就行了。
qemu 在启动时要指定 OVMF_CODE.fd 路径,直接使用它的安装路径就好了。
这样也更符合我们上面说的,OVMF_CODE.fd 是只读且多虚拟机共享,OVMF_VARS.fd 是可读写且各虚拟机独有一份该文件,这个要求。
另外这样做还有一个好处,就是当我们更新了 ovmf 包之后,使用这种方式启动 qemu,就可以直接使用最新的 OVMF_CODE.fd 文件了,就不用再次拷贝了。
所以,这种方式是比较推荐的。
但因为 nixos 系统独特的包管理方式,我上面是把这两个文件都拷贝到了 uefi 目录。
0x06 以 numa 的方式启动内核
qemu 默认是以 SMP 的方式,或者也可以叫作 UMA 的方式,启动 linux 内核。
但有时候,我们想让 qemu 以 NUMA 的方式启动内核,这样方便我们调试相关的内核代码。
此时就可以用以下命令:
qemu-system-x86_64 \ -machine q35 \ -cpu host \ -accel kvm \ -smp sockets=2,cores=2,threads=1 \ -m 4G \ -object memory-backend-ram,size=1G,id=m0 \ -object memory-backend-ram,size=1G,id=m1 \ -object memory-backend-ram,size=1G,id=m2 \ -object memory-backend-ram,size=1G,id=m3 \ -numa node,memdev=m0,nodeid=0 \ -numa node,memdev=m1,nodeid=1 \ -numa node,memdev=m2,nodeid=2 \ -numa node,memdev=m3,nodeid=3 \ -numa cpu,node-id=0,socket-id=0,core-id=0,thread-id=0 \ -numa cpu,node-id=1,socket-id=0,core-id=1,thread-id=0 \ -numa cpu,node-id=2,socket-id=1,core-id=0,thread-id=0 \ -numa cpu,node-id=3,socket-id=1,core-id=1,thread-id=0 \ -drive file=./uefi/OVMF_CODE.fd,if=pflash,format=raw,readonly=on \ -drive file=./uefi/OVMF_VARS.fd,if=pflash,format=raw \ -drive file=./root.img,if=virtio,format=raw \ -kernel ~/linux/arch/x86/boot/bzImage \ -append "root=/dev/vda rw console=ttyS0" \ -nographic
该命令新增参数的意义:
-smp sockets=2,cores=2,threads=1 # 用这种方式设置设置 cpu 个数,是为了方便后续为 cpu 指定其所属节点 -object memory-backend-ram,size=1G,id=m0 # 将内存分成4份,也是为了后续为每份内存指定其所属节点 -numa node,memdev=m0,nodeid=0 # 声明一个numa节点,同时指定其本地内存 -numa cpu,node-id=0,socket-id=0,core-id=0,thread-id=0 # 为 cpu 指定其所属的 numa 节点
0x07 虚拟机和主机之间共享文件
假设在当前目录有个 mnt 目录,我想把该目录里的文件和虚拟机共享,此时就可以使用下面的命令:
qemu-system-x86_64 \ -machine q35 \ -cpu host \ -accel kvm \ -smp sockets=2,cores=2,threads=1 \ -m 4G \ -object memory-backend-ram,size=1G,id=m0 \ -object memory-backend-ram,size=1G,id=m1 \ -object memory-backend-ram,size=1G,id=m2 \ -object memory-backend-ram,size=1G,id=m3 \ -numa node,memdev=m0,nodeid=0 \ -numa node,memdev=m1,nodeid=1 \ -numa node,memdev=m2,nodeid=2 \ -numa node,memdev=m3,nodeid=3 \ -numa cpu,node-id=0,socket-id=0,core-id=0,thread-id=0 \ -numa cpu,node-id=1,socket-id=0,core-id=1,thread-id=0 \ -numa cpu,node-id=2,socket-id=1,core-id=0,thread-id=0 \ -numa cpu,node-id=3,socket-id=1,core-id=1,thread-id=0 \ -virtfs local,path=./mnt,mount_tag=mnt,security_model=none \ -drive file=./uefi/OVMF_CODE.fd,if=pflash,format=raw,readonly=on \ -drive file=./uefi/OVMF_VARS.fd,if=pflash,format=raw \ -drive file=./root.img,if=virtio,format=raw \ -kernel ~/linux/arch/x86/boot/bzImage \ -append "root=/dev/vda rw console=ttyS0" \ -nographic
该命令新增了 -virtfs 参数,在该参数值中,path 用于指定要共享哪个目录给虚拟机,mount_tag 用于指定在虚拟机中,通过什么名字来访问这个共享目录。
执行完上述命令,内核启动成功后会进入到 shell 环境,在 shell 环境里,再执行下面的命令:
mount -t 9p -o trans=virtio mnt /mnt
该命令会把主机共享的名为 mnt 的目录,挂载到虚拟机的 /mnt 目录下。
执行完此命令后,我们就可以通过虚拟机的 /mnt 目录,访问主机上 ./mnt 目录里的文件了。
演示看下:
0x08 内核调试
使用 qemu 和 gdb 可以对我们自己构建的内核进行调试,不过前提是要先构建一个 debug 版本的内核。
也就是说,在配置内核时,要启用这一项:
有关如何配置内核,可以看 linux内核各种配置方式的区别 这篇文章。
在构建完 debug 版的内核之后,执行以下命令:
qemu-system-x86_64 \ -machine q35 \ -cpu host \ -accel kvm \ -smp sockets=2,cores=2,threads=1 \ -m 4G \ -object memory-backend-ram,size=1G,id=m0 \ -object memory-backend-ram,size=1G,id=m1 \ -object memory-backend-ram,size=1G,id=m2 \ -object memory-backend-ram,size=1G,id=m3 \ -numa node,memdev=m0,nodeid=0 \ -numa node,memdev=m1,nodeid=1 \ -numa node,memdev=m2,nodeid=2 \ -numa node,memdev=m3,nodeid=3 \ -numa cpu,node-id=0,socket-id=0,core-id=0,thread-id=0 \ -numa cpu,node-id=1,socket-id=0,core-id=1,thread-id=0 \ -numa cpu,node-id=2,socket-id=1,core-id=0,thread-id=0 \ -numa cpu,node-id=3,socket-id=1,core-id=1,thread-id=0 \ -virtfs local,path=./mnt,mount_tag=mnt,security_model=none \ -drive file=./uefi/OVMF_CODE.fd,if=pflash,format=raw,readonly=on \ -drive file=./uefi/OVMF_VARS.fd,if=pflash,format=raw \ -drive file=./root.img,if=virtio,format=raw \ -kernel ~/linux/arch/x86/boot/bzImage \ -append "root=/dev/vda rw console=ttyS0 nokaslr" \ -nographic \ -s \ -S
该命令新增了两个参数,它们的作用是:
-s # 开启一个 gdbserver,监听 tcp 端口 1234 -S # 虚拟机启动后不执行任何代码,等待 gdb 的指令
另外,在 -append 参数里还新增了一个 nokaslr 内核参数,它是为了告诉内核,不要做地址随机。
在执行完上述命令后,我们在内核构建目录执行以下命令:
上图就是在使用 gdb 对内核进行断点调试,在 gdb 的命令行里,可以执行任意 gdb 命令。
有关内核调试的更多信息,请看 linux 内核官方文档 Debugging kernel and modules via gdb,qemu 官方文档 GDB usage,以及 gdb 的官方文档 Debugging with GDB。
0x09 最终命令
以下是我最终的 qemu 环境:
其中 start.sh 脚本里会执行 qemu 命令,以运行我们自己构建的 linux 内核:
#!/usr/bin/env bash # Change to the directory where the script resides cd "$(dirname "$0")" qemu-system-x86_64 \ -machine q35 \ -cpu host \ -accel kvm \ -smp sockets=2,cores=2,threads=1 \ -m 4G \ -object memory-backend-ram,size=1G,id=m0 \ -object memory-backend-ram,size=1G,id=m1 \ -object memory-backend-ram,size=1G,id=m2 \ -object memory-backend-ram,size=1G,id=m3 \ -numa node,memdev=m0,nodeid=0 \ -numa node,memdev=m1,nodeid=1 \ -numa node,memdev=m2,nodeid=2 \ -numa node,memdev=m3,nodeid=3 \ -numa cpu,node-id=0,socket-id=0,core-id=0,thread-id=0 \ -numa cpu,node-id=1,socket-id=0,core-id=1,thread-id=0 \ -numa cpu,node-id=2,socket-id=1,core-id=0,thread-id=0 \ -numa cpu,node-id=3,socket-id=1,core-id=1,thread-id=0 \ -virtfs local,path=./mnt,mount_tag=mnt,security_model=none \ -drive file=./uefi/OVMF_CODE.fd,if=pflash,format=raw,readonly=on \ -drive file=./uefi/OVMF_VARS.fd,if=pflash,format=raw \ -drive file=./root.img,if=virtio,format=raw \ -kernel ~/linux/arch/x86/boot/bzImage \ -append "root=/dev/vda rw console=ttyS0 nokaslr $*" \ -nographic \ # -s \ # -S \
执行该脚本时,如果指定了其他参数,那这些参数会作为内核参数,传递给内核。
比如以下命令,会开启内核的 debug 级别日志:
~/qemu/start.sh debug
0x0a 注意事项
qemu 启动一个虚拟机时,会默认为它分配 128 MiB 大小的内存。
但这个内存太小了,必须把它调大一点,否则在 qemu 运行时,很可能会遇到各种各样奇奇怪怪的现象,比如在启动过程中自动重启。
0x0b 相关文档
本篇文章介绍了如何使用 qemu 来运行和调试 linux 内核,同时也结合各种使用场景,讲解了 qemu 的各种参数。
但限于篇幅原因,很多细节没法讲到。
大家如果在使用 qemu 的过程中遇到问题,可以先查阅 qemu 的官方文档,如果问题还是解决不了,可以扫描右侧二维码,加我微信,我们一起讨论下。
0x0c 其他
我开设了一门 linux内核启动流程源码分析 课程,有对内核源码感兴趣的,可以扫描右侧的二维码,加我微信报名。
当然,如果不想报名课程,也可以加我微信,我经常会分享一些 linux 内核相关的东西,欢迎关注。