从源码构建Linux

从源码构建Linux内核

本文基于WSL2,Debian13发行版。

环境准备

WSL配置

请确保您使用了Windows11或Windows10的较高版本,以保证对WSL2有完善的支持。

1
wsl --install Debian

在本文编写时(2026-05-17),该命令会下载Debian 13版本。若很久后Debian出更高版本,理论上不会对本文内容造成影响。

清理PATH环境变量

默认的WSL会继承Windows的PATH环境变量,这本意是让用户在WSL环境下也可以轻松执行Windows内的命令行工具,但也会影响路径搜索,为避免该问题,需要禁止WSL继承Windows PATH。

使用任意文本编辑器编辑/etc/wsl.conf,在下方追加:

1
2
[interop]
appendWindowsPath = false

追加后,回到Windows环境,使用wsl --shutdown关闭当前Linux,重新进入。

Debian换源

对于国内Linux用户,访问Debian官方源可能会很慢,推荐使用清华源。换源方法在此不过多赘述,但需要注意的是,Debian13使用了新的DEB822格式,若使用传统方式(即编辑/etc/apt/sources.list)换源,需要再执行以下命令,避免原有的Debian源卡住apt update

1
2
mv /etc/apt/sources.list.d/0000debian.sources \
/etc/apt/sources.list.d/0000debian.sources.bak

源码下载

可以去内核归档网站(kernel.org) 下载内核源代码,我这里用的是7.0.8最新的稳定版内核,当然也可以选择其他版本的内核。进入网站后,点击下载你想要版本的tarball即可。

注意:通常,在Windows的终端中输入wsl会进入当前所在的Windows目录,强烈不建议在该目录内进行内核编译等操作,这会严重拖慢编译速度。

下载后,将内核的tar文件复制到Linux的用户目录下。

1
2
3
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语言编译器及二进制文件操作工具。
  • flex & bison 用于配置时期的代码生成
  • bc 高精度计算器
  • ncurses-devel 使用make menuconfig需要的图形库
  • libelf-dev elf头文件支持

执行下面脚本安装必要依赖。

1
2
3
sudo apt install make gcc \
flex bison bc libelf-dev \
libncurses-dev -y

安装好环境,就可以开始内核配置了。

内核配置

使用make tinyconfig执行最小配置,这将会产生一个可以编译出最小可执行内核的配置。

image-20260517234140348

执行后,配置文件将会写入.config文件,可以使用less .config查看配置,可以看到,以#开头的是注释,有大量选项被配置为y。这里我们不过多纠结具体内容,只需要了解大体格式即可。

拥有.config文件后,就可以执行make -j$(nproc)来进行内核的编译了,稍微等待几分钟就会编译完成,此时我们就有了一个最小可运行的Linux内核了。不过现在的内核由于缺少一些驱动,还不能实际使用。

编译出的内核文件有多个,在当前目录下有一个vmlinux,这是以ELF格式存放的Linux内核镜像,但我们实际使用的是存放于arch/x86/boot/bzImage中压缩后的Linux内核镜像。

image-20260517234927135

运行内核

运行内核需要一个虚拟机,而Linux上调试内核最常见的虚拟机则是qemu,使用apt安装qemu-system,这可以让qemu模拟一台上古时期的x86电脑。

1
sudo apt install qemu-system

安装需要一点时间,安装完成后,就可以使用qemu模拟器执行你的第一个Linux内核了。

1
qemu-system-x86_64 -kernel arch/x86/boot/bzImage -nographic

这里的-kernel指定了内核镜像,而-nographic则让qemu启动一个没有图形界面的电脑,我们现在还不需要图形界面。

运行后你会看到黑乎乎的一片,什么都没有,这就代表基础内核已经成功加载了,但是由于没有驱动,它无法与我们交互。同样它也无法真正执行关机,所以只有一片黑乎乎在这里卡着。

按下Ctrl + A后,按X退出QEMU,推荐退出后再使用命令reset重置一下窗口内容,否则多行文本渲染可能有bug。

回忆一下计算机体系结构

如今我们的主流芯片都是冯诺依曼结构,它由五个部分:运算器、控制器、存储器、输入设备和输出设备组成。在这个最简Linux内核中,只有运算器、控制器和存储器(即CPU和内存),没有输入输出设备,无法与用户交互,只能执行确定性的过程。

逐步构建一个真正可用的内核

启用内核日志

在最初的内核里,我们什么也看不到,但我们希望至少内核可以告诉我们发生了什么,否则一片漆黑中,根本无法继续探索。

使用make menuconfig来进入图形化的Linux配置界面。

image-20260518000956491

我们要启用几个关键选项:printk支持、TTY支持、串口驱动。

启用printk后,我们就可以看到内核的日志打印,这至少能让我们知道此时发生什么了。

General setup —>

  • [ * ] Configure standard kernel features (expert users) —>
    • [ * ] Enable support for printk

启用TTY支持,让内核的printk输出到TTY终端,我们会在qemu的输出中看到内核的日志。

Device Drivers —>

  • Character devices —>

    • [ * ] Enable TTY
  • Serial drivers —>

    • [ * ] 8250/16550 and compatible serial support
    • [ * ] Console on 8250/16550 and compatible serial port

重新编译,然后使用下面的命令执行内核。

1
qemu-system-x86_64 -kernel arch/x86/boot/bzImage -append "console=ttyS0" -nographic

这里的-append是内核的启动参数,通过使用该参数,内核将信息输出到console,而console的内容被导向ttyS0,而qemu中,ttyS0会被打印到标准输出中,此时就可以看到启动日志了。

tty:代表 Teletypewriter(电传打字机)。在计算机早期,人们使用电传打字机作为输入输出设备。虽然现在技术早就更新换代了,但 Linux 依然沿用了 tty 这个词来表示所有的终端设备

S:代表 Serial(串行)。这意味着它是一个串行通信接口。

0:代表编号。在计算机世界里,计数通常从 0 开始。所以 ttyS0 是第一个串口(COM1),ttyS1 是第二个串口(COM2),以此类推。

image-20260518003554489

可以看到,这次内核成功打印出了信息,在最后发生了内核恐慌:内核要找到一个初始化进程作为用户态的第一个进程(PID 1),它尝试寻找了/sbin/init/etc/init/bin/init,直到/bin/sh,都没有找到可运行的进程,不得已内核只能恐慌退出。

编写自己的init进程

Linux内核在启动完成后,就会启动系统中的第一个用户态进程,为PID1,是所有其他用户态进程的祖先。作为所有进程的祖先,它有几个特殊点。

  1. 它总是以root身份启动(但容器中的PID1并非如此)。
  2. 任何孤儿进程都会变为PID1的子进程。
  3. 一旦PID1崩溃或退出,内核会立刻陷入恐慌,无法继续工作。

如今,几乎所有发行版都会使用systemd作为init进程,不过作为教程,本文会从头编写一个最简单的初始化进程。

不过在正式编写前,我们需要解决一些问题,以让我们的程序可以在自己的内核里正常工作。

架构匹配:64位内核

在进入用户态之前,我们必须确保内核的位宽与我们即将编写的程序架构相匹配。默认情况下,编译器会编译出本平台的程序,在WSL2下,通常就是x86_64架构的程序。

[*] 64-bit kernel

由于我们运行在现代的 x86_64 平台上,我们需要在内核选项里开启它。它决定了内核将运行在 64 位长模式(Long Mode)下,能够寻址超过 4GB 的内存,并使用 64 位的通用寄存器。如果关闭它,内核将编译为 32 位(i386)内核。

运行核心:ELF支持

Linux 下绝大多数可执行程序、动态链接库和核心转储文件都是 ELF(Executable and Linkable Format) 格式。内核必须懂得如何解析这种格式,才能将编译好的程序加载到内存中执行。在最小配置下,内核并没有配置ELF的解析能力,需要我们手动配置。

Executable file formats —>

  • [*] Kernel support for ELF binaries

如果缺少 ELF 支持,内核在尝试启动用户态程序时,会因为“无法识别的文件格式”而直接抛出 Exec format error。由于1号进程也无法执行,这会引起内核恐慌退出。

虽然ELF是Linux世界的标准格式,但是Linux内核并不必须要求能解析ELF文件,也存在一些比ELF更简单的文件格式。

动态链接与静态链接

在编写我们自己的 init 进程前,必须理解程序是如何运行的:

  • 动态链接(Dynamic Linking):程序在编译时并不包含库(如 glibc)的代码,而是在运行时依赖系统中的 .so 动态链接库。这种方式能节省磁盘和内存,但要求系统里必须有一套完整的动态链接加载器和基础库。
  • 静态链接(Static Linking):编译时把所有需要的库函数直接“打包”进最终的二进制文件中。生成的程序体积极大,但不依赖任何外部环境,放到任何相同架构的裸机上都能直接运行。

由于我们现在这台设备上除了内核,什么库都没有,所以必须使用静态链接的方式编译,否则会因为缺少动态库与装载器而无法运行。

现在,我们创建一个目录_root,在其中新建一个init.c,写入如下内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdbool.h>
#include <unistd.h>
#include <string.h>

#define STDIN 0
#define STDOUT 1

int main() {
write(STDOUT, "init process stared\n", sizeof("init process stared\n") - 1);
while (true) {
char buf[256] = {0};
ssize_t readSize = read(STDIN, buf, sizeof(buf) - 1);
if (readSize < 0) {
write(STDOUT, "read error", sizeof("read error") - 1);
return 1;
}
if (strcmp(buf, "exit\n") == 0) {
return 2;
}
write(STDOUT, "You typed: ", sizeof("You typed: ") - 1);
write(STDOUT, buf, readSize);
}
}

使用如下命令编译。

1
gcc init.c -static -o init

其中,-static指的就是静态编译。可以使用ldd命令查看一个程序是否是静态链接程序。

1
2
jeffy:~/linux-7.0.8/_root$ ldd init
not a dynamic executable

也可以使用chroot命令切换根,看看该程序是否可以在无库无加载器的情况下执行。

注意chroot命令需要特权才能执行。

1
2
3
4
5
6
7
8
jeffy:~/linux-7.0.8/_root$ sudo chroot . /init
init process stared
aaa
You typed: aaa
bbb
You typed: bbb
exit
jeffy:~/linux-7.0.8/_root$

initramfs与cpio

现在我们的精简内核没有任何文件,即使编译出了自己的init进程,也无法塞到内核里,所以,我们需要启动Linux内核的initramfs功能。initramfs通过内核的tmpfs机制为我们提供了一个在内存中的文件系统,无需提供实际的磁盘镜像。

General setup —>

  • [ * ] Initial RAM filesystem and RAM disk (initramfs/initrd) support

image-20260518225253045

在更新配置,重新编译内核后,我们要将我们的init文件打包为内核可识别的cpio文件格式。

1
2
cd _root
find | cpio -H newc -o > ../root.cpio

使用以下命令重新启动新编译的Linux内核。

1
qemu-system-x86_64 -initrd root.cpio -kernel arch/x86/boot/bzImage -append "console=ttyS0" -nographic

-initrd为qemu的选项,该选项会将文件填入特定位置,Linux内核将会从该位置读取文件,初始化tmpfs。

image-20260520231323990

可以看到,当输入其他字符时,该程序会回显,而当输入exit时,程序会退出,而由于1号程序的退出,内核陷入恐慌退出。

原始系统调用与X86_64汇编

如果我们看我们自己写的init程序,会发现非常简单的一个功能,却占用了几百K的空间,这正是静态链接的问题之一:它会打包大量的无关的内容到程序里,但是实际上,我们只需要输入、回显和输出,以及最后的退出能力。

事实上,在计算机的抽象上,一个普通的用户程序和操作系统所做的事情没有区别:获取输入、经过运算、产生输出。它们之间最主要的区别是普通程序并不需要直接和硬件打交道,普通程序通过系统调用来和系统打交道,由操作系统统一到硬件的接口,这也是C语言程序可以在不同CPU架构上移植的核心原因之一。

1
2
3
4
5
jeffy:~/linux-7.0.8/_root$ ls -lh
total 748K
-rwxr-xr-x 1 jeffy jeffy 741K May 20 23:12 init
-rw-r--r-- 1 jeffy jeffy 529 May 20 23:12 init.c
jeffy:~/linux-7.0.8/_root$

我们可以尝试使用原生系统调用来减少对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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#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);
}

[[noreturn]] void exit(int status) {
(void)syscall(60, status, 0, 0, 0, 0, 0);
__builtin_unreachable();
}

[[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"
);

使用以下命令编译,将会编译出一个仅有必要的核心内容的程序。

1
gcc -fno-builtin -static -nostdlib -O2 init.c -o init

这样就可以编译出一个核心ELF,仅有9K,并且不再需要静态链接libc。

image-20260521001144953

image-20260521001042529

9K其实仍然有很多空位和可优化项,理论上可以把它优化到1K以内,不过本文主要讨论的点并非这里,所以此处略过。

busybox:Linux世界的瑞士军刀

如果每一个 Linux 命令(如 ls, cd, mkdir, sh)都要我们手动去写,那工作量太恐怖了。好在开源世界有 BusyBox。 BusyBox 将几百个常用标准 Linux 命令的精简版全部打包到了同一个可执行二进制文件中。它会根据你调用它时的“名字”(通过创建软链接,比如把 ls 链接到 busybox),来决定执行什么功能。在构建嵌入式系统或像我们这种精简内核时,BusyBox 是不二之选,它能帮我们一键生成最基础的用户态环境。

下载busybox

你可以从下载源码从源码构建,也可以下载busybox的预构建版本。

创建初始化脚本

伪文件系统

编译并添加glibc

装载器:程序的基石

用户态的世界

制作可启动的磁盘镜像

第一个可运行的完整Linux

挂载

单根与多根

网络支持

复用包管理器

内核自举

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2019-2026 Ytyan

请我喝杯咖啡吧~