背景

最近的工作中涉及到一些需要魔改 Linux Kernel 代码和加载一些自己的 kernel module 的工作。 之前每次编译完 kernel 以后,都会把新的 kernel 装到自己在云上的开发机里,每次都很是担惊受怕。 每次都要“关机-备份-安装-重启”。前几天还因为一些奇怪行为,直接把文件系统给玩坏了,导致丢了几天的草稿代码。

之后就一直想着,是不是应该把我的魔改 kernel 放到一个相对隔离起来的环境里。 至少玩坏了以后,不要把我的主力开发机反复弄死。所以就研究了一下怎么用 QEMU 来加载我自己的 kernel。

QEMU 很贴心的提供直接从 bzImage 启动的功能: Direct Linux Boot

但是要直接从bzImage启动,QEMU还需要用户提供一个-hda 或者-initrd

这篇文章主要包含以下几个部分:

  • 安装QEMU
  • 编译 bzImage
  • 准备一个基于 Arch Linux 的磁盘映像
  • QEMU,启动!

安装 QEMU

我的系统是跑在 WSL 里面的 Ubuntu。安装直接 apt 就完事了。

1
sudo apt install qemu-system-x86

因为我暂时不太关心别的架构(譬如说ARM64)的情况,所以只装了-x86的版本。如果需要别的 arch 支持, 装别的版本就可以了。

也有人推荐下载最新的源码,从头编译。(你都编译一遍 kernel 了,再编译一个 QEMU 算什么) 只能说开心就好。

编译 Kernel

编译 Kernel 的几个步骤也很简单:

  • 下载代码
  • 准备编译依赖
  • 准备config
  • 开始编译

下载 Kernel 代码

Linux Kernel 的代码可以从 www.kernel.org 下载,也可以从 GitHub 下载。 地址分别是: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/ 或者 https://github.com/torvalds/linux

1
2
cd ~
git clone https://github.com/torvalds/linux

不要问什么为什么用git下载,不直接用tar包。问就是不会用tar1

安装依赖

1
sudo apt install build-essential flex bison libssl-dev libelf-dev libncurses-dev

具体每个依赖是做什么的,参考 https://docs.kernel.org/process/changes.html, 根据config的不同,不一定都用得上。

特别说一下 ncurses。 编译 Kernel 的时候不需要。但是如果跑 menuconfig2 的话,会依赖这个。

准备config

一般 distro 都会把当前 Kernel 在编译时用的 .config 放到 /boot/ 下面。 但是因为 WSL 不是一个标准的 Linux,他的 /boot 下面是空的。

所以比较方便的方式还是从网上找一个别的 distro 正在用的 config。

譬如说 WSL 的 config 在这里: https://github.com/microsoft/WSL2-Linux-Kernel/blob/linux-msft-wsl-5.15.y/arch/x86/configs/config-wsl

而 Arch Linux 的 config 在这里: https://gitlab.archlinux.org/archlinux/packaging/packages/linux/-/blob/main/config

不过不要用 WSL 的config,会变得不幸。几乎什么驱动都没有配置。

我选的是我们隔壁 Azure Linux 团队在维护的一个config: https://github.com/microsoft/CBL-Mariner/blob/2.0/SPECS/kernel/config

1
2
3
4
mkdir ~/build-linux
cd ~/build-linux
curl https://github.com/microsoft/CBL-Mariner/blob/2.0/SPECS/kernel/config -o mariner-config
cp mariner-config .config

编译内核

1
2
3
4
5
cd ~/linux
make help
make O=~/build-linux olddefconfig
make O=~/build-linux menuconfig
make O=~/build-linux -j16

这里的 O=... 可以参考 https://docs.kernel.org/kbuild/kbuild.html

menuconfig可以参考 https://docs.kernel.org/kbuild/kconfig.html#menuconfig

在 menuconfig 的时候,记得把 E1000 的驱动选上,后面会用。

编译成功以后,会在输出的最后面看到一行类似这样的输出

1
Kernel: arch/x86/boot/bzImage is ready

这个bzImage就是 QEMU 需要的内核文件

准备磁盘映像文件

这里的内容主要修改自 Stefan Koch 的 https://blog.stefan-koch.name/2020/05/31/automation-archlinux-qemu-installation

主要的区别是在 Stefan 的文章中,他的目标是准备一个完整的磁盘文件,然后直接从磁盘启动。 这里就涉及了 syslinux, initramfs 等和 boot loader 相关的东西。 因为我们是用 QEMU 直接启动 kernel,这些东西都不需要了。

主要步骤包括

  • 准备磁盘的 Raw Image3
  • 通过 arch-chroot 来安装用户态需要的程序
  • 准备磁盘 qcow2 Image4

【补课】Linux 启动流程

对于 Kernel 而言,除了“操作系统”课程中会讲的最基本的内存管理,进程管理,各种设备的驱动 (和其他内核黑科技,譬如eBPF)之外,几乎用户日常会用的东西都没有。 无论是 bash、systemd 或者 glibc,所有这些运行在用户态的东西,都是 kernel 之外,需要用户(或者说 distro)来提供的。

Kernel 在完成自己的启动以后,会把系统的控制权交给 init 程序,来完成剩下的用户态的工作。

在 Kernel 启动以前,加载 Kernel 的工作则是由 boot loader 来完成的。 相关的关键词包括了 UEFI,grub,security boot 等等。

而 QEMU 的 Direct Linux Boot 某种意义上就是起到了 boot loader 的作用,直接加载内核和磁盘。

How Linux Works 5 这本书的 “Chapter 5: How the Linux Kernel Boots” 中非常详细的讲述了 Linux 的启动流程。

准备磁盘 Raw Image

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
mkdir ~/arch-image
cd ~/arch-image

image=~/arch-image/raw.img

qemu-img create -f raw $image 20G

# Mount the image to an available loopback device, exposing the inner
# partition structure of the image (as e.g. /dev/loop0p1)
loop=$(sudo losetup --show -f -P $image)
# Create a partition and a file system on the device
sudo parted $loop mklabel msdos
sudo parted -a optimal $loop mkpart primary 0% 100%
sudo parted $loop set 1 boot on
loopp=${loop}p1
sudo mkfs.ext4 $loopp

qemu-img 创建的是一个空的磁盘文件。然后通过 losetup 把文件模拟成一个 block device。 最后用 partedmkfs 来格式化这个虚拟的磁盘。

arch-chroot

1
2
3
4
5
6
7
8
cd ~/arch-image
curl https://geo.mirror.pkgbuild.com/iso/latest/archlinux-bootstrap-x86_64.tar.gz -o archlinux-bootstrap-x86_64.tar.gz

mountpoint=/tmp/arch

mkdir -p $mountpoint
sudo mount $loopp $mountpoint
sudo tar xf archlinux-bootstrap-x86_64.tar.gz -C $mountpoint --strip-components 1

这样就把 bootstrap 包加载 /tmp/arch 下面。但是在执行arch-chroot之前,还有一件重要的事情。 因为 bootstrap 的环境里几乎什么工具都没有,主要是没有编辑器,而我又不太会用sed。 所以需要先用当前系统的编辑器改一下 pacman 的 mirror list。至于具体选哪个mirror,就是个人偏好的问题了。

1
vim /tmp/arch/etc/pacman.d/mirrorlist

在这之后就是相对标准的 Arch Linux 安装流程了,相关内容也可以参考 https://wiki.archlinux.org/title/Install_Arch_Linux_from_existing_Linux

1
2
3
4
5
6
7
8
9
sudo $mountpoint/bin/arch-chroot $mountpoint /bin/bash
pacman-key --init
pacman-key --populate

pacman -Syu --noconfirm
pacman -S base linux-firmware dhcpcd openssh

systemctl enable dhcpcd
systemctl enable sshd

因为我们这里不需要系统内核,所以只需要装baselinux-firmware 包, 而不需要 linux 包。

至于 dhcpdopenssh 则是为了后面 QEMU 启动起来以后,方便从 Host 机器通过 ssh 接入到 Guest 机器里面。不需要抱着 QEMU 的 serial console 来执行别的命令。

这个时候如果愿意,也可以装更多的包,譬如vim, sudo 之类的。不过这些东西,稍后在 QEMU 启动起来以后再安装也没差。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Standard Archlinux Setup
ln -sf /usr/share/zoneinfo/UTC /etc/localtime
hwclock --systohc

echo en_US.UTF-8 UTF-8 >> /etc/locale.gen
locale-gen
echo LANG=en_US.UTF-8 > /etc/locale.conf
echo arch-qemu > /etc/hostname
echo -e '127.0.0.1  localhost\n::1  localhost' >> /etc/hosts

# set a password for root
passwd

# create a new user
useradd -m sray
passwd sray

# enable SSH password login
# do NOT do this in production!
echo "PasswordAuthentication yes" > /etc/ssh/sshd_config

exit

除了 https://wiki.archlinux.org/title/Installation_guide#Configure_the_system 中提到的几项系统配置以外,主要就是给 root 加密码,不然 serial console 都登陆不进去。 最后添加了新用户,并且打开了 sshd 的允许密码登录的选项。

转换成 qcow2 格式

1
2
3
sudo umount $mountpoint
sudo losetup -d $loop
qemu-img convert -f raw -O qcow2 $image ~/arch-image/arch.qcow2

最后就是退出 chroot 环境,并且清理资源。然后把 raw image 给转换成 QEMU自己的 qcow2 格式

启动 QEMU

这一部分主要参考了 Chris Gioran 的 https://radiki.dev/posts/qemu-setup-for-kernel-dev-1

我这里的主要区别是,他的文章中讲了如何加载一个 Ubuntu cloudimg 的磁盘,包括如何配置cloud-init。 而我是用的前一步准备的 Arch Linux 磁盘。

我在尝试用 Ubuntu cloudimg 的时候,用的是 22.04 LTS,结果遇到了几个问题。

一个是磁盘映像里的 fstab 会尝试挂载一个 UEFI 分区,这个会导致系统启动不起来。 我后来尝试通过 virt-edit 修改了fstab,注释掉 UEFI 分区以后才能正常启动。

另一个问题是跟cloud-init相关的。Chris 是通过一个iso来初始化用户密码的。 但是我开始的 kernel 编译参数里,没有编译 CDROM 相关的驱动,导致读不了光盘。 重新编译 kernel 以后解决了这个问题。

1
2
3
4
5
6
7
8
9
sudo qemu-system-x86_64 \
  -cpu host --enable-kvm \
  -m 2048 \
  -nographic \
  -device e1000,netdev=net0 \
  -netdev user,id=net0,hostfwd=tcp::5555-:22 \
  -kernel ~/build-linux/arch/x86/boot/bzImage \
  -hda ~/arch-image/arch.qcow2 \
  -append "console=ttyS0 init=/sbin/init root=/dev/sda1 rw" 

-enable-kvm

-cpu host --enable-kvm 这一行不是必选项。主要是利用 kvm 把 Host 机器的 CPU 借给 Guest 去用,而不是用纯粹的软件模拟的CPU。跑起来会更快一点。

-m

-m 2048 就是分配给 Guest 2G 内存。

-nographic

因为系统是跑在命令行里的,不需要 QEMU 的 UI。

-device 和 -netdev

-device e1000,netdev=net0 是给机器加上了一个类型为 e1000 的,名字叫 net0 的网卡。

-netdev user,id=net0,hostfwd=tcp::5555-:22 是说这个 net0 的网卡上的 TCP 22 端口会被映射到 Host 机器的 5555 端口上。

这样机器启动之后,就可以从外面通过 SSH 来使用这个 VM 了。

-kernel 和 -hda

-kernel-hda 分别指向了之前 kernel 文件和磁盘文件。

-append

-append 里的东西,是提供给 kernel 的命令行参数。 完整的说明可以参考 https://www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html

console=ttyS0 相当于把当前的命令行界面做成了serial console, 这样可以在qemu的命令行界面里看到 kernel 的各种输出信息。 也可以通过 serial console 去登录系统。

init=/sbin/init 就是前面提到的,系统启动后把控制权交给 init 程序,里的init。 在实践中,这里会指向 systemd。但是其实这个 init 可以是任何可执行文件。 Hello-world 也可以,无非就是后面会 panic。

root=/dev/sda1 rw 是说把 /dev/sda1 给 mount 到 / 上面去,并且可读可写。

退出 QEMU

如果一切顺利的话,稍等几秒钟,命令行里刷完了启动信息以后,应该就提示登录了。 用之前的root账号或者新建的账号,都可以从 qemu 的命令行界面里登录进去了。

我之前从外面用 SSH 访问的时候,遇到了一些问题。后来发现是网卡驱动没装。 建议是把 E1000 的相关驱动做成 build-in module,主要是方便。

在测试完 kernel 以后,可能遇到的问题是:怎么从 QEMU 里退出来

答案是 ctrl-a x

更多的操作参考: https://www.qemu.org/docs/master/system/mux-chardev.html 譬如说 ctrl-a c 可以进入 QEMU 的命令模式

不知道是不是我的 QEMU 的版本的问题,在 QEMU 退出以后,我的当前命令行的有些控制字符的处理会有问题。 解决方案是执行一下tset(注意,不是test)。

更多

用 GDB 调试 kernel

https://nickdesaulniers.github.io/blog/2018/10/24/booting-a-custom-linux-kernel-in-qemu-and-debugging-it-with-gdb

加载基于 Busybox 的 initramfs

https://blog.jm233333.com/linux-kernel/build-and-run-a-tiny-linux-kernel-on-qemu

可是 Arch Linux 不支持 ARM

LFS please. Or try manjaro.