Appearance
从源码构建一个最小 Linux 系统
本文基于WSL2,Debian13发行版。
严格来说,Linux 只是操作系统内核。本文会先从源码编译并运行 Linux 内核,再逐步补上 init 进程、initramfs、glibc、BusyBox 等用户态组件,最终得到一个可以交互使用的最小 Linux 系统。
环境准备
WSL配置
请确保您使用了Windows11或Windows10的较高版本,以保证对WSL2有完善的支持。
powershell
wsl --install Debian在本文编写时(2026-05-17),该命令会下载Debian 13版本。若很久后Debian出更高版本,整体流程应该不会有本质差异,但部分软件版本、包名或默认配置可能需要按实际环境调整。
清理PATH环境变量
默认的WSL会继承Windows的PATH环境变量,这本意是让用户在WSL环境下也可以轻松执行Windows内的命令行工具,但也会影响路径搜索,为避免该问题,需要禁止WSL继承Windows PATH。
使用任意文本编辑器以root权限编辑/etc/wsl.conf,在下方追加:
[interop]
appendWindowsPath = false追加后,回到Windows环境,使用wsl --shutdown关闭当前Linux,重新进入。
powershell
wsl --shutdown
wslDebian换源
对于国内Linux用户,访问Debian官方源可能会很慢,推荐使用清华源。换源方法在此不过多赘述,但需要注意的是,Debian13使用了新的DEB822格式,若使用传统方式(即编辑/etc/apt/sources.list)换源,需要再执行以下命令,避免原有的Debian源卡住apt update。
bash
sudo mv /etc/apt/sources.list.d/0000debian.sources \
/etc/apt/sources.list.d/0000debian.sources.bak源码下载
可以去内核归档网站(kernel.org) 下载内核源代码。本文使用的是 linux-7.0.8,读者也可以选择相近版本的内核;如果版本跨度较大,少量配置项名称或默认值可能会变化。进入网站后,点击下载你想要版本的 tarball 即可。
IMPORTANT
通常,在Windows的终端中输入wsl会进入当前所在的Windows目录,强烈不建议在该目录内进行内核编译等操作,这会严重拖慢编译速度。应该先使用cd命令跳转到Linux内的用户根目录。
下载后,将内核的tar文件复制到Linux的用户目录下。
bash
sudo apt install xz-utils -y
tar -xf linux-7.0.8.tar.xz
cd linux-7.0.8解压完Linux后,我们需要准备编译必备的环境。
构建并执行第一个Linux内核
环境准备
Linux内核也是在Linux上完成编译的,编译Linux内核需要许多工具,本文仅会安装编译Linux所需的最少工具。
工具列表:
make & gcc核心构建工具,包括构建脚本解释器和C语言编译器。binutils汇编器、链接器、objcopy等二进制工具flex & bison用于配置时期的代码生成bc高精度计算器libncurses-dev使用make menuconfig需要的终端界面库libelf-develf头文件支持
执行下面脚本安装必要依赖。
bash
sudo apt install make gcc \
binutils flex bison bc cpio libelf-dev lbzip2 bzip2 pkg-config \
libncurses-dev -y安装好环境,就可以开始内核配置了。
内核配置
使用make tinyconfig执行最小配置,这将会产生一个可以编译出最小可执行内核的配置。

执行后,配置文件将会写入.config文件,可以使用less .config查看配置,可以看到,以#开头的是注释,大量功能默认没有启用,只有最基础的选项被配置为y。这里我们不过多纠结具体内容,只需要了解大体格式即可。
拥有.config文件后,就可以执行make -j$(nproc)来进行内核的编译了,稍微等待几分钟就会编译完成,此时我们就有了一个最小可运行的Linux内核了。不过现在的内核由于缺少一些驱动,还不能实际使用。
NOTE
如果你的设备CPU核心数较多但可用内存较低,请不要使用make -j$(nproc),编译时内存不足导致的频繁换页会严重拖慢速度,请根据实际情况将$(nproc)替换一个较小的数字。
编译出的内核文件有多个,在当前目录下有一个vmlinux,这是以ELF格式存放的Linux内核镜像,但我们实际使用的是存放于arch/x86/boot/bzImage中压缩后的Linux内核镜像。

运行内核
运行内核需要一个虚拟机,而Linux上调试内核最常见的虚拟机则是qemu,使用apt安装qemu-system,这可以让qemu模拟一台x86_64 PC。
bash
sudo apt install qemu-system安装需要一点时间,安装完成后,就可以使用qemu模拟器执行你的第一个Linux内核了。
bash
qemu-system-x86_64 -kernel arch/x86/boot/bzImage -nographic这里的-kernel指定了内核镜像,而-nographic则让qemu启动一个没有图形界面的电脑,我们现在还不需要图形界面。
运行后你会看到黑乎乎的一片,什么都没有。此时并不能直接从屏幕判断内核具体跑到了哪里,因为我们还没有把内核日志导向可见的控制台;但这也正好引出了下一步:让内核把启动过程打印出来。
IMPORTANT
按下Ctrl + A后,按X退出QEMU,推荐退出后再使用命令reset重置一下窗口内容,否则多行文本渲染可能有bug。
回忆一下计算机体系结构
如今我们的主流计算机大多可以按冯诺依曼结构理解,它由五个部分:运算器、控制器、存储器、输入设备和输出设备组成。在这个最简Linux内核中,QEMU模拟的机器当然仍然存在输入输出设备,但内核还没有启用可见的输出路径,也没有可交互的用户态程序。因此从我们的视角看,它只能默默执行启动流程,无法与用户交互。
逐步构建一个真正可用的内核
启用内核日志
在最初的内核里,我们什么也看不到,但我们希望至少内核可以告诉我们发生了什么,否则一片漆黑中,根本无法继续探索。
使用make menuconfig来进入文本菜单形式的Linux配置界面。

我们要启用几个关键选项:printk支持、TTY支持、串口驱动。
启用printk后,我们就可以看到内核的日志打印,这至少能让我们知道此时发生什么了。
text
General setup --->
[*] Configure standard kernel features (expert users) --->
[*] Enable support for printk启用TTY支持,让内核的printk输出到TTY终端,我们会在qemu的输出中看到内核的日志。
text
Device Drivers --->
Character devices --->
[*] Enable TTY
Serial drivers --->
[*] 8250/16550 and compatible serial support
[*] Console on 8250/16550 and compatible serial port重新编译,然后使用下面的命令执行内核。
bash
qemu-system-x86_64 -kernel arch/x86/boot/bzImage -append "console=ttyS0" -nographic这里的-append是内核的启动参数,通过使用该参数,内核将信息输出到console,而console的内容被导向ttyS0,而qemu中,ttyS0会被打印到标准输出中,此时就可以看到启动日志了。
TIP
tty:代表 Teletypewriter(电传打字机)。在计算机早期,人们使用电传打字机作为输入输出设备。虽然现在技术早就更新换代了,但 Linux 依然沿用了 tty 这个词来表示所有的终端设备。
S:代表 Serial(串行)。这意味着它是一个串行通信接口。
0:代表编号。在计算机世界里,计数通常从 0 开始。所以 ttyS0 是第一个串口(COM1),ttyS1 是第二个串口(COM2),以此类推。
如果不用-nographic参数,那么-append "console=ttyS0"也可以去掉。

可以看到,这次内核成功打印出了信息,在最后发生了内核panic:内核要找到一个初始化进程作为用户态的第一个进程(PID 1),它尝试寻找了/sbin/init,/etc/init,/bin/init,直到/bin/sh,都没有找到可运行的进程,不得已内核只能panic退出。
编写自己的init进程
Linux内核在启动完成后,就会启动系统中的第一个用户态进程,为PID1。对于这个从零开始构建的系统来说,它会成为其他用户态进程的祖先。作为这个特殊进程,它有几个特殊点。
- 在普通系统启动流程中,它通常以root身份启动(但容器中的PID1并非如此)。
- 没有被其他机制接管的孤儿进程通常会被托管给PID1;如果系统设置了subreaper,孤儿进程也可能先被对应的subreaper接管。
- 一旦PID1崩溃或退出,内核会立刻panic,无法继续工作。
如今,几乎所有发行版都会使用systemd作为init进程,不过作为教程,本文会从头编写一个最简单的初始化进程。
在正式编写前,我们需要解决一些问题,以让我们的程序可以在自己的内核里正常工作。
架构匹配:64位内核
在进入用户态之前,我们必须确保内核的位宽与我们即将编写的程序架构相匹配。默认情况下,编译器会编译出本平台的程序,在WSL2下,通常就是x86_64架构的程序。
text
[*] 64-bit kernel由于我们运行在现代的 x86_64 平台上,我们需要在内核选项里开启它。它决定了内核将运行在 64 位长模式(Long Mode)下,能够寻址超过 4GB 的内存,并使用 64 位的通用寄存器。如果关闭它,内核将编译为 32 位(i386)内核。
运行核心:ELF支持
Linux 下绝大多数可执行程序、动态链接库和核心转储文件都是 ELF(Executable and Linkable Format) 格式。内核必须懂得如何解析这种格式,才能将编译好的程序加载到内存中执行。在最小配置下,内核并没有配置ELF的解析能力,需要我们手动配置。
text
Executable file formats --->
[*] Kernel support for ELF binaries如果缺少 ELF 支持,内核在尝试启动用户态程序时,会因为“无法识别的文件格式”而直接抛出 Exec format error。由于1号进程也无法执行,这会引起内核panic退出。
TIP
虽然ELF是Linux世界的标准格式,但是Linux内核并不必须要求能解析ELF文件,也存在一些比ELF更简单的文件格式。
动态链接与静态链接
在编写我们自己的 init 进程前,必须理解程序是如何运行的:
- 动态链接(Dynamic Linking):程序在编译时并不包含库(如 glibc)的代码,而是在运行时依赖系统中的
.so动态链接库。这种方式能节省磁盘和内存,但要求系统里必须有一套完整的动态链接加载器和基础库。 - 静态链接(Static Linking):编译时把所有需要的库函数直接“打包”进最终的二进制文件中。生成的程序通常更大,但不依赖外部动态库和动态链接器,放到相同架构、兼容内核ABI的环境中就能运行。
由于我们现在这台设备上除了内核,什么库都没有,所以必须使用静态链接的方式编译,否则会因为缺少动态库与装载器而无法运行。
现在,我们创建一个目录_root,在其中新建一个init.c,写入如下内容。
bash
mkdir -p _root
cd _rootC
#include <stdbool.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
int main() {
printf("init process started\n");
while (true) {
char buf[256] = {0};
if (fgets(buf, sizeof(buf), stdin) == NULL) {
printf("read error");
return 1;
}
if (strcmp(buf, "exit\n") == 0) {
return 2;
}
printf("You typed: %s", buf);
}
}使用如下命令编译。
bash
gcc init.c -static -o init其中,-static指的就是静态编译。可以使用ldd命令查看一个程序是否是静态链接程序。
bash
jeffy:~/linux-7.0.8/_root$ ldd init
not a dynamic executable也可以使用chroot命令切换根,看看该程序是否可以在无库无加载器的情况下执行。
chroot全称是change root,它会让一个进程把指定目录当成新的根目录/。例如sudo chroot . /init会先把当前目录当成根目录,再在这个新根目录中执行/init。这样我们就可以在宿主Linux里模拟“系统里只有这个目录下的文件”的环境,用来检查程序是否真的不依赖外部动态库和动态加载器。
NOTE
chroot命令需要特权才能执行。
bash
jeffy:~/linux-7.0.8/_root$ sudo chroot . /init
init process started
aaa
You typed: aaa
bbb
You typed: bbb
exit
jeffy:~/linux-7.0.8/_root$initramfs与cpio
现在我们的精简内核没有任何文件,即使编译出了自己的init进程,也无法塞到内核里,所以,我们需要启动Linux内核的initramfs功能。initramfs本质上是一个cpio归档,内核会在启动早期将它解包到内存中的rootfs里,无需提供实际的磁盘镜像。
text
General setup --->
[*] Initial RAM filesystem and RAM disk (initramfs/initrd) support
在更新配置,重新编译内核后,我们要将我们的init文件打包为内核可识别的cpio文件格式。
bash
cd _root
find | cpio -H newc -o > ../root.cpio使用以下命令重新启动新编译的Linux内核。
bash
cd ..
qemu-system-x86_64 -initrd root.cpio -kernel arch/x86/boot/bzImage -append "console=ttyS0" -nographic-initrd为qemu的选项,该选项会将文件填入特定位置,Linux内核将会从该位置读取文件,并将initramfs解包到早期rootfs。

可以看到,当输入其他字符时,该程序会回显,而当输入exit时,程序会退出,而由于1号程序的退出,内核panic退出。
原始系统调用与X86_64汇编
如果我们看我们自己写的init程序,会发现非常简单的一个功能,却占用了几百K的空间,这几百K的空间是静态链接的C库带来的,它为我们提供了C语言的大量功能,比如我们看到的printf、fgets、strcmp,以及对主函数返回值到退出码的支持。通过C库提供的这些包装,我们可以更轻松的和操作系统打交道。同时,C库也帮我们提供了屏蔽操作系统差异的接口,让我们可以把一套C语言程序移植到其他操作系统中。
然而在我们这个简易的init程序中,携带这么多代码就显得无用了,所以我们可以使用原始系统调用,避免对C库的依赖,以简化我们的可执行文件。
移除C库依赖的开始,我们需要移除对函数的依赖,fgets和printf显然不能再使用了,strcmp也不能直接用了,所以需要替换掉它们。
对于strcmp,我们可以轻松使用C语言自己实现。
C
int strcmp(const char *s1, const char *s2) {
while (*s1 && (*s1 == *s2)) {
s1++;
s2++;
}
return *(unsigned char *)s1 - *(unsigned char *)s2;
}然而printf这样的函数涉及到向外部输出,我们就不得不和操作系统打交道了。printf是C库提供的打印函数,但如果直接和内核打交道,printf就无法使用了。
要想直接和内核打交道,就必须要了解系统调用。我们可以把系统调用看做一种特殊的函数调用,只不过函数调用调用的是自己写的函数或库,但系统调用要使用操作系统的能力。由于内核工作在更高的特权级,程序只能通过特定的指令来进行系统调用。在X86_64设备上,64位程序应使用syscall指令;int 0x80主要用于兼容旧的32位系统调用ABI,系统调用号和参数传递方式都不是本文使用的这套。
C语言为了保持跨平台兼容性,并不会原生提供系统调用的关键字,而是使用C库包装,所以为了使用原始系统调用,我们需要一些汇编来编写系统调用的核心代码。不过C语言为我们提供了内联汇编的能力,我们可以使用内联汇编来完成核心的功能,剩下的周边功能可以继续使用C语言。
C
#include <stdint.h>
#define STDIN 0
#define STDOUT 1
intptr_t syscall(intptr_t number, intptr_t arg1, intptr_t arg2, intptr_t arg3, intptr_t arg4, intptr_t arg5, intptr_t arg6) {
intptr_t ret;
register long r_num __asm__("rax") = number;
register long r_a1 __asm__("rdi") = arg1;
register long r_a2 __asm__("rsi") = arg2;
register long r_a3 __asm__("rdx") = arg3;
register long r_a4 __asm__("r10") = arg4;
register long r_a5 __asm__("r8") = arg5;
register long r_a6 __asm__("r9") = arg6;
__asm__ volatile (
"syscall\n\t"
: "=a"(ret)
: "r"(r_num), "r"(r_a1), "r"(r_a2), "r"(r_a3), "r"(r_a4), "r"(r_a5), "r"(r_a6)
: "rcx", "r11", "memory"
);
return ret;
}
int strcmp(const char *s1, const char *s2) {
while (*s1 && (*s1 == *s2)) {
s1++;
s2++;
}
return *(unsigned char *)s1 - *(unsigned char *)s2;
}
intptr_t read(int fd, void *buf, uintptr_t count) {
return syscall(0, fd, (intptr_t)buf, count, 0, 0, 0);
}
intptr_t write(int fd, const void *buf, uintptr_t count) {
return syscall(1, fd, (intptr_t)buf, count, 0, 0, 0);
}
__attribute__((noreturn)) void exit(int status) {
(void)syscall(60, status, 0, 0, 0, 0, 0);
__builtin_unreachable();
}
__attribute__((noreturn)) void start()
{
write(STDOUT, "init process started\n", sizeof("init process started\n") - 1);
while (1) {
char buf[256] = { 0 };
intptr_t readSize = read(STDIN, buf, sizeof(buf) - 1);
if (readSize < 0) {
write(STDOUT, "read error", sizeof("read error") - 1);
exit(1);
}
if (strcmp(buf, "exit\n") == 0) {
exit(2);
}
write(STDOUT, "You typed: ", sizeof("You typed: ") - 1);
write(STDOUT, buf, readSize);
}
}
__asm__(
".global _start\n"
"_start:\n\t"
// 清空rbp
"xorq %rbp, %rbp\n\t"
// 16字节对齐rsp,避免SIMD指令引发的对齐SIGSEGV
"andq $-16, %rsp\n\t"
"movq %rsp, %rbp\n\t"
"call start\n\t"
"int $3\n"
);使用以下命令编译,将会编译出一个仅有必要的核心内容的程序。它使用原始的系统调用直接完成功能,不带有额外的运行时开销。
bash
gcc -fno-builtin -static -nostdlib -O2 init.c -o init这样就可以编译出一个核心ELF,仅有9K。虽然命令里仍有-static,但由于同时使用了-nostdlib,最终不会链接libc;这里的-static只是避免生成需要动态链接器参与的程序。


9K其实仍然有很多空位和可优化项,理论上可以把它优化到1K以内,不过本文主要讨论的点并非这里,所以此处略过。
构建可用的Linux操作系统
当然,一个只能回显输入的程序并不能体现出操作系统的功能。真实世界中,一个操作系统要实现各种各样的功能,除了需要硬件和内核的支持,也需要用户态程序的支持。严格来说,Linux 是内核;日常所说的 Linux 系统通常还包括 C 库、Shell、基础命令、init 系统等用户态组件。所以我们仍然需要构建核心的用户态周边,以求能够实现一个可用的 Linux 系统。
glibc:Linux世界的基石
回忆一下我们静态链接的第一个init程序,为了使用printf等函数,我们不得不使用-static参数静态链接C库。事实上,大多数程序都会默认操作系统中存在一个C库实现,进而动态链接C库。并且不止C语言会使用,由于C库接口稳定,且针对常用功能做了大量优化,大多数语言的编译产物、解释型语言的解释器也会使用C库获得免费的性能提升。
TIP
常见语言中,Golang是个例外:纯Go程序默认是静态链接的,不需要任何外部库的参与。
C库并非只有一种实现,常见的C库包括GNU的glibc、Android的Bionic libc、为静态链接设计的musl libc,微软也在Windows中提供了自己的C运行时实现,在Windows中是 msvcrt.dll。而我们这次要构建的是Linux世界中最通用的glibc。
前往The GNU C Library下载glibc的源码。本文使用的是 glibc-2.43,读者也可以替换为相近版本。通过这次构建,我们将会构建出C语言的核心库及头文件支持,为我们之后在自己的内核中运行用户态程序打下基础。与Linux内核不同,glibc并不会非常频繁地更新,也没有大量可配置的编译选项。这次编译会产生大量文件,而我们的核心目标文件有两个:ld-linux和libc。下载解压后,应该能看到图的目录结构。
这里为了降低复杂度,glibc会使用当前Debian环境提供的内核头文件完成构建。更严谨的rootfs构建流程通常会先准备目标内核的headers,再让glibc基于这些headers进行配置和编译。
TIP
你也可以前往国内的镜像源下载,避免网络问题。

我们需要使用下面的命令构建libc。
bash
# Debian自带的gawk可能有些老了
sudo apt update
sudo apt install gawk -y
# 创建构建目录,构建结果将会放在这里
mkdir build && cd $_
# 进行配置,由于我们要给自己的内核安装,所以prefix需要为根目录的 /usr
../configure --prefix=/usr
make -j$(nproc)
# 创建install目录
mkdir stage
# 构建,但不能真的把libc安装在/usr下,这会替换当前系统的libc,导致无法运行。
# 所以必须使用DESTDIR=$(realpath stage)指定一个用户目录
make install DESTDIR=$(realpath stage)这次编译将会在stage目录下产生大量文件:包括标准的C库、数学计算库、加载器和头文件。这将是用户态Linux世界的基础支柱。
在构建好了C库后,我们还需要一些核心的命令实现,只有库,用户是无法直接使用的。
TIP
内核被叫做Kernel,而用户直接操作的是外面的那层壳(Shell),系统调用是连接两个世界的桥梁,而库就是上这个桥最常用的方式。
busybox:Linux世界的瑞士军刀
如果每一个 Linux 命令(如 ls, cd, mkdir, sh)都要我们手动去编写或一个个构建,那工作量太恐怖了。好在开源世界有 BusyBox。 BusyBox 将几百个常用标准 Linux 命令的精简版全部打包到了同一个可执行二进制文件中。它会根据你调用它时的“名字”(通过创建软链接,比如把 ls 链接到 busybox),来决定执行什么功能。在构建嵌入式系统或像我们这种精简内核时,BusyBox 是不二之选,它能帮我们一键生成最基础的用户态环境。
下载并构建busybox
前往Busybox下载busybox源码。本文使用的是 1.38 版本,读者也可以替换为相近版本。
IMPORTANT
如果各位下载1.37及以下版本的话,可能会出现make menuconfig失败的情况,此时需要在scripts/kconfig/lxdialog/check-lxdialog.sh的check函数中做如下修改。
C
main() {} // 改掉这里
int main() {return 0;} // 换成这个以上两种写法都是有效的C语言代码,在极早期的C语言设计(早于C89)中,函数声明可以不用写返回类型。但现代编译工具链会默认启用对这类早期写法的错误告警,导致检查失败,明明有库无法进入make menuconfig。
使用下面的代码对busybox应用默认配置。
bash
make defconfig在某些 BusyBox 版本和较新的编译工具链组合下,编译tc命令可能会报错。如果你也遇到这个问题,可以在make menuconfig中删除掉对tc的支持。
bash
make menuconfigtext
Networking Utilities --->
[ ] tc (8.3 kb) # 要把这个去掉接下来进行编译。
bash
make -j$(nproc)编译后将会在根目录出现busybox的二进制文件,我们将其复制到glibc的stage目录内。
NOTE
这里的busybox仍然是借助当前Debian环境中的编译器、头文件和默认库路径构建出来的,并不是严格意义上使用刚刚构建出的glibc作为sysroot进行交叉构建。本文当前阶段的目标是把busybox放进stage里验证最小用户态能否运行,而不是构建一套完全自举的工具链。
bash
mkdir -p ../glibc-2.43/build/stage/bin/
cp busybox ../glibc-2.43/build/stage/bin/
cd ../glibc-2.43/build/stage
lschroot进入临时环境,来看看安装效果。
bash
sudo chroot . bin/busybox sh安装busybox后,就可以使用它的applet进行Linux基础的操作了。此时还无法直接使用普通的Linux命令,需要使用/bin/busybox --install -s安装,这会自动创建busybox的软链接,此后busybox就可以根据启动的是哪个软链接来决定自己要做什么。
bash
sudo chroot . /bin/busybox --install -sIMPORTANT
使用/bin/busybox --install时,必须要用绝对路径,否则busybox会拒绝安装。这是因为busybox不能假设它能获取自身的绝对路径,文章后面的部分会解释为什么会有这种情况。
BusyBox安装applet链接后,通常会得到/linuxrc和/sbin/init。前提是当前BusyBox配置启用了对应applet;defconfig一般会启用它们,但如果之后手动裁剪配置,就需要重新确认。linuxrc主要来自早期initrd时代,而现代initramfs更常见的是/init;如果没有提供/init,内核后续会按常规init路径继续寻找/sbin/init、/etc/init、/bin/init和/bin/sh。因此在这里,即使我们还没手写/init脚本,只要/sbin/init这个busybox链接存在,内核也有机会启动它。

动态链接程序依赖什么
这里需要稍微停一下。前面我们手写的init是静态链接程序,所以只要内核能解析它的ELF文件,它就可以直接运行。而这里的 BusyBox 是借助当前 Debian 环境构建出来的,默认会动态链接宿主工具链所使用的 C 库;在本文的环境里,它对应的是 glibc。因此它能否启动,不只取决于/bin/busybox本身是否存在,还取决于动态链接器和动态库是否在正确的位置。
可以先用readelf观察busybox记录的动态链接器路径。
bash
readelf -l bin/busybox | grep interpreterreadelf输出里的interpreter就是动态链接器,x86_64 glibc系统里通常是/lib64/ld-linux-x86-64.so.2。内核加载动态链接程序时,并不会自己去解析所有.so文件,而是先根据ELF文件里的解释器路径启动动态链接器,再由动态链接器加载libc.so.6等共享库。
假设readelf输出的是/lib64/ld-linux-x86-64.so.2,就可以用下面的命令列出/bin/busybox在stage环境里实际解析到的共享库路径。
bash
sudo chroot . /lib64/ld-linux-x86-64.so.2 --list /bin/busybox这条命令直接在chroot里执行动态链接器,不依赖这个临时系统里已经存在bash或sh。如果readelf输出的interpreter路径不是/lib64/ld-linux-x86-64.so.2,则应以readelf的结果为准替换命令中的路径。打包initramfs前,要确保stage目录中同时包含busybox、动态链接器和它依赖的共享库。
自此,我们就拥有了一个真正可以使用的小型Linux环境了,可以将它安装在新的内核中试试效果。
bash
find . -print0 | cpio -H newc -0 -o --owner=0:0 > ../../../minilinux.cpio
cd ~/linux-7.0.8
qemu-system-x86_64 -initrd ../minilinux.cpio -kernel arch/x86/boot/bzImage -append "console=ttyS0" -nographic这里的--owner=0:0用于把归档中文件的所有者固定为root,Debian中的GNU cpio支持该参数。
这时你可能会发现:怎么反复重启了呢?
如果通过增加 -no-reboot检查,会发现内核最后打印了如下信息。
Memory: 12512K/130552K available (3629K kernel code, 767K rwdata, 308K rodata, 544K init, 220K bss, 117344K reserved, 0K )
clocksource: jiffies: mask: 0xffffffff max_cycles: 0xffffffff, max_idle_ns: 7645041785100000 ns
clocksource: Switched to clocksource tsc-early
platform rtc_cmos: registered platform RTC device (no PNP device found)
workingset: timestamp_bits=62 max_order=12 bucket_order=0
Unpacking initramfs...
Initramfs unpacking failed: invalid magic at start of compressed archive如果真的按照内核给出的日志来检测是不是生成的cpio有问题,很容易陷入死胡同:在本文这个环境里,生成cpio的命令和文件本身都没有问题。本文这里直接提供答案,跳过定位过程,之后有时间再写。
在本文环境里,这是因为qemu默认分配的内存只有128MB,而我们生成的cpio有足足109M,加上内核占用后几乎没有剩余内存,内核在解包cpio时由于内存不足触发CPU reset,导致虚拟机整机重启。解决方案很简单,增加qemu的内存即可。使用-m 1G将内存增加到1G。
NOTE
这里不是内核panic后的自动重启。未配置panic=等自动重启策略时,panic通常会停在原地。通过QEMU的异常日志确认,本文这次是triple fault触发了CPU reset。具体路径是:内存不足首先触发缺页异常(#PF),异常处理过程中又触发了通用保护异常(#GP),于是CPU转入double fault(#DF);在投递#DF时再次触发#GP,最终形成triple fault。CPU无法继续处理triple fault,只能复位,所以看到的现象就是虚拟机反复重启。
使用下面的命令重新启动qemu,此时就可以进入系统了。
bash
qemu-system-x86_64 -m 1G -initrd ../minilinux.cpio -kernel arch/x86/boot/bzImage -append "console=ttyS0" -nographic -no-reboot
处理缺失的tty
重新启动后,你会看到屏幕循环打印can't open /dev/tty2这样的信息,这是因为busybox的init程序默认会尝试在tty2, tty3, tty4上都启用getty,但新构建的系统中并没有这些串口设备。一个简单的解决方案是修改配置文件,调整init的行为。
bash
cd ~/glibc-2.43/build/stage/
mkdir -p etc
cat > etc/inittab <<'EOF'
::askfirst:-/bin/sh
::ctrlaltdel:/sbin/reboot
::shutdown:/bin/umount -a -r
EOF
find . -print0 | cpio -H newc -0 -o --owner=0:0 > ../../../minilinux.cpio
cd ~/linux-7.0.8
qemu-system-x86_64 -m 1G -initrd ../minilinux.cpio -kernel arch/x86/boot/bzImage -append "console=ttyS0" -nographic这时,一个最小可用的Linux系统就跑起来了。你可以用ls查看文件,用vi编辑文本,用sh执行基础的脚本。
伪文件系统
在普通的Linux系统下使用mount查看文件系统,会发现目录下已经挂载了大量设备。使用ps -ef查看进程,也能看到不少进程。然而在我们的新系统中,你会发现这些功能都不能用。

与其他操作系统不同,Linux奉行一切皆文件的法则,很多看起来需要特定接口才能实现的功能,在Linux中都被抽象为了文件操作,这其中包括查看进程信息、配置操作系统、内核调试、创建共享内存甚至直接操作硬件。这些操作大多是通过伪文件系统实现的。
顾名思义,伪文件系统就是长得像文件系统,但不是文件系统的东西。真实的文件系统要有存储设备和驱动并从该设备中获取目录树,然而伪文件系统则由内核直接提供目录树,无需外部设备参与。在Linux上,最核心的文件系统是下面几个。
devtmpfs:用于暴露内核管理的设备节点,终端、磁盘、网卡等设备通常都需要通过/dev下的节点访问。proc:用于暴露进程信息和一部分内核运行时状态,ps、top等命令会依赖它读取进程列表。sysfs:用于暴露内核设备模型、驱动、总线和内核对象,很多设备发现和配置工作都会依赖/sys。
devtmpfs
-> Device Drivers
-> Generic Driver Options
-> Maintain a devtmpfs filesystem to mount at /dev (DEVTMPFS [=y])
proc/sysfs
-> File systems
-> Pseudo filesystems
-> /proc file system support (PROC_FS [=y])
-> sysfs file system support (SYSFS [=y])启用这几个选项后,内核就为我们提供了最核心的伪文件系统支持。
编写初始化脚本
不过在Linux世界中,内核为我们做的事情真的很少。看似一开机就会出现的根目录下的几个伪文件系统(/dev, /proc, /sys)并非是内核挂载的,而是用户态程序创建的。在这个从零开始搭建的Linux中,我们必须自己完成挂载。
我们要再一次编辑 /etc/inittab,写入我们要执行的初始化脚本。
bash
cd ~/glibc-2.43/build/stage/
mkdir -p etc
cat > etc/inittab <<'EOF'
::sysinit:/etc/init.d/rcS
::askfirst:-/bin/sh
::ctrlaltdel:/sbin/reboot
::shutdown:/bin/umount -a -r
EOF
mkdir -p etc/init.d
# 编写fstab
cat > etc/fstab <<'EOF'
# 设备名 挂载点 设备类型 挂载属性 dump备份 fsck顺序
proc /proc proc nosuid,noexec,nodev 0 0
sysfs /sys sysfs nosuid,noexec,nodev 0 0
devpts /dev/pts devpts mode=0755,nosuid 0 0
tmpfs /run tmpfs mode=0755,nosuid,nodev 0 0
EOF
#编写初始化脚本
cat > etc/init.d/rcS <<'EOF'
#!/bin/sh
mkdir -p /dev /sys /proc /run
mount -t devtmpfs devtmpfs /dev
mkdir -p /dev/pts
mount -a
EOF
chmod +x etc/init.d/rcS
# 重新启动操作系统
find . -print0 | cpio -H newc -0 -o --owner=0:0 > ../../../minilinux.cpio
cd ~/linux-7.0.8
qemu-system-x86_64 -m 1G -initrd ../minilinux.cpio -kernel arch/x86/boot/bzImage -append "console=ttyS0" -nographic此时,我们的大多数功能就OK了。

网络支持
现在我们只有一个单机的Linux,为了让这个Linux能真正可用,我们需要为设备添加网络能力。
首先在首页中启用Networking support,并参考下方配置配置好协议栈支持。
General setup
[*] Configure standard kernel features (expert users) --->
[*] Networking support --->
Networking options --->
[*] Packet socket
[*] Unix domain sockets
[*] TCP/IP networking
-> Device Drivers
[*] PCI support --->
[*] Virtio drivers ---> # 要先启动它
[*] PCI driver for virtio devices
[*] Network device support --->
[*] Network core driver support
[*] Virtio network driverbash
make -j$(nproc)
qemu-system-x86_64 -m 1G \
-initrd ../minilinux.cpio \
-kernel arch/x86/boot/bzImage \
-append "console=ttyS0" \
-nographic \
-netdev user,id=net0 \
-device virtio-net-pci,netdev=net0进入系统后,确认 BusyBox 配置中已经启用了ip、udhcpc和wget这些applet。QEMU的user网络默认会提供一个虚拟DHCP服务,常见网关地址是10.0.2.2,客户机地址通常会分配到10.0.2.15。
bash
ip link set lo up
ip link set eth0 up
udhcpc -i eth0
# udhcpc: started, v1.38.0
# udhcpc: broadcasting discover
# udhcpc: broadcasting select for 10.0.2.15, server 10.0.2.2
# udhcpc: lease of 10.0.2.15 obtained from 10.0.2.2, lease time 86400
ip addr flush dev eth0
ip addr add 10.0.2.15/24 dev eth0
ip route del default 2>/dev/null
ip route add default via 10.0.2.2 dev eth0
echo "nameserver 1.1.1.1" > /etc/resolv.conf上面的udhcpc用于从QEMU的DHCP服务获取网络参数;由于我们还没有准备完整的DHCP脚本,这里又手动配置了一次IP、默认路由和DNS。DNS这里直接指定1.1.1.1,读者也可自行指定。最后使用wget命令来测试一下效果。运行下面的命令后,应该能打印出实际的网络访问情况。
bash
wget https://www.baidu.com -qO-
制作磁盘镜像
下面章节未完待续