简易内核开发环境

近期尝试了一下写 Linux 内核代码,于是把折腾过程记录一下,方便以后参考。本文默认使用 x86_64 架构以及 Linux 作为宿主系统。

使用 QEMU 运行 Linux 内核

在开始写代码之前,我们需要先准备好模拟器用于运行 Linux。我这里用了QEMU,你也可以用 VirtualBox 之类的虚拟机。一般来说,我们需要一个内核可执行文件(内核本体)和一个 initramfs 镜像(提供用户态程序)。当内核被加载后,它会自动载入 initramfs 镜像,并执行其中的/init程序。由于这一节的重点是 QEMU 配置,所以我直接使用宿主机的内核和 initramfs。以我的 Archlinux 系统为例,内核文件是/boot/vmlinuz-linux,initramfs 文件是/boot/initramfs-linux.img。使用以下命令启动 QEMU,按Ctrl-A x退出。

1
2
3
4
5
6
7
qemu-system-x86_64 \
-kernel /boot/vmlinuz-linux \
-initrd /boot/initramfs-linux.img \
-nographic -append "console=ttyS0" \
-m 512 \
--enable-kvm \
-cpu host
  • -kernel 指定内核可执行文件。
  • -initrd 指定 initramfs disk 镜像文件。
  • -nographic -append "console=ttyS0" 禁用视频输出并使用串口作为终端。
  • -m 512 设定内存。
  • --enable-kvm 使用KVM。
  • -cpu host 使用宿主机的 CPU 特性。

你应该能看到一些系统启动的信息以及无法挂载根分区的报错,这是正常现象,因为我们没有提供任何磁盘文件。你应该可以进入一个紧急修复 Shell, 执行一些简单的如 ls cd 之类的命令。

创建自己的 initramfs

确保 QEMU 能够正常工作后,我们来创建自己 initramfs 替换掉上一步中的/boot/initramfs-linux.img。这一节的内容主要来自 Creating a initramfs image from scratch 一文。initramfs 镜像本质上是一个经过压缩的 cpio 归档文件(cpio 约等于 tar)。根据内核版本的不同,你可以使用不同的压缩算法。我的内核比较新,所以可以使用lz4压缩。同时,我们直接下载使用编译好的busybox可执行文件作为我们的 Shell。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
mkdir initramfs_root  # 创建initramfs的根目录
cd initramfs_root

mkdir bin dev etc lib mnt proc sbin sys tmp var # 创建一系列必要的目录
pushd bin
wget 'https://busybox.net/downloads/binaries/1.31.0-defconfig-multiarch-musl/busybox-x86_64' -Obusybox # 下载busybox
chmod +x busybox
popd

# /init 是一个脚本,由busybox解释执行
cat << 'EOF' > init
#!/bin/busybox sh

# 挂载必要的文件系统目录
/bin/busybox mount -t devtmpfs devtmpfs /dev
/bin/busybox mount -t proc proc /proc
/bin/busybox mount -t sysfs sysfs /sys
/bin/busybox mount -t tmpfs tmpfs /tmp

# 打印一些欢迎信息
/bin/busybox echo Hello from initramfs

# 执行shell,将控制权交给用户
/bin/busybox sh
EOF
chmod +x init

# find . 列举需要压缩的文件
# cpio -o 执行归档
# -H newc 指定归档文件格式
# lz4 -l 使用内核可以识别的压缩格式
find . | cpio -ov -H newc | lz4 -l -9 > ../initramfs.cpio.lz4

这样,一个自定义的initramfs.cpio.lz4文件就构建好了。用其替换掉/boot/initramfs-linux.img

1
2
3
4
5
6
7
qemu-system-x86_64 \
-kernel /boot/vmlinuz-linux \
-initrd initramfs.cpio.lz4 \
-nographic -append "console=ttyS0" \
-m 512 \
--enable-kvm \
-cpu host

这样你应该就能得到一条欢迎信息和一个 Shell。由于我们没有创建常用命令到busybox的软链接,所以执行指令时需要加上busybox的前缀。以下是我的运行结果。

Hello from initramfs
sh: can't access tty; job control turned off
/ # busybox ls
bin   dev   etc   init  lib   mnt   proc  root  sbin  sys   tmp   var
/ #

编译自己的内核

替换掉 initramfs 镜像后,我们接着来替换内核。自己编译内核大概分三步:下载代码;配置内核;编译内核。Linux 的内核源代码可以在 https://www.kernel.org/ 下载。下载并解压到某个文件夹,比方说linux-5.5-rc5。通常来说手动配置内核是个苦差事,于是我们直接使用默认配置就好。默认配置文件位于arch/x86/configs/x86_64_defconfig

linux-5.5-rc5$ make x86_64_defconfig  # 使用64位默认配置
...此处省略若干行...
linux-5.5-rc5$ make -j10  # 请根据你电脑的核心数量调整
...此处省略若干行...
Kernel: arch/x86/boot/bzImage is ready  (#1)

依葫芦画瓢,用这个编译好的bzImage替换掉/boot/vmlinuz-linux,你应该得到和之前一样的欢迎信息和 Shell。

Hello world! (不含 glibc)

使用 C 语言写 Hello world! 是一码事,不用 glibc 写 Hello world! 是另一码事。在我们创建的 initramfs 中,我们没有包含任何类似glibc的 C 标准库,因此我们不能使用诸如printf()之类的方便函数,需要直接使用系统调用进行输出。从汇编层面来说,要进行系统调用,只需要将系统调用号以及参数统统塞入一系列寄存器,然后执行syscall指令即可。更详细的我在几年前的一篇《Programming with PTRACE, Part4 - 系统调用进阶》里有讲到。那么直接贴代码:

helloworld.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#define STDOUT 1
#define SYS_write 1
#define SYS_exit 60

void sys_write(long fd, const void* addr, long len) {
asm("mov %0, %%rax;"
"mov %1, %%rdi;"
"mov %2, %%rsi;"
"mov %3, %%rdx;"
"syscall"

:: "i"(SYS_write),
"r"(fd),
"r"(addr),
"r"(len)

: "%rax",
"%rdi",
"%rsi",
"%rdx"
);
}

void sys_exit(long return_code) {
asm("mov %0, %%rax;"
"mov %1, %%rdi;"
"syscall"

:: "i"(SYS_exit),
"r"(return_code)

: "%rax",
"%rdi"
);
}

long strlen(const char* str) {
long len = 0;
while (str[len] != '\0') len++;
return len;
}

void _start() {
const char* str = "Hello world!\n";
sys_write(STDOUT, str, strlen(str));
sys_exit(0);
}

两个值得注意的点:使用_start()而不是main()exit()系统调用不能省。另外这个程序编译的时候也需要一些特殊的技巧,需要静态链接并且不需要标准库支持:

gcc -nostdlib -static helloworld.c -fno-stack-protector -o helloworld

将编译出的文件放入initramfs_root并重新打包,然后在 QEMU 中运行就能看到效果了。

Hello from initramfs
sh: can't access tty; job control turned off
/ # /bin/helloworld
Hello world!

Challenge (Hello world from kernel space)

至此,我们已经知道了如何在 QEMU 中启动一个包含内核和用户空间程序的 Linux 系统,并且知道了如何利用系统调用让用户态程序输出字符串。有兴趣的读者可以尝试给内核添加一个新的系统调用,当被调用时向 kernel log 输出Hello kernel world!。就像这样:

/ # /bin/helloworld_syscall
[    4.621241] Hello kernel world!