>
原文:Making a micro Linux distro

━━━ ━━━ ━━━
"从零开始构建 Linux 发行版"——听起来像是个疯狂的想法,对吧?毕竟现在有 Ubuntu、Debian、Arch 等上百个发行版,为什么还要自己造轮子?
答案是:理解内核、用户空间和操作系统之间那条看不见的分界线。这篇文章不走寻常路,它不是教你用 Yocto 或 Buildroot 去"配菜",而是真正从内核编译开始,手写 init 进程,看着屏幕上一个简单到几乎无用的系统启动起来,然后理解它为什么能启动。
更酷的是——作者选了 RISC-V 架构(QEMU riscv64 virt 模拟器),这意味着你完全可以在 x86 电脑上交叉编译一个 RISC-V 的微型系统跑着玩。
━━━ ━━━ ━━━
作者用一个很朴素的问题开场:在一台单核机器上,多个程序怎么"同时"运行?
答案是——内核干的。但内核干的不止这一件事:
printf,背后是 write 系统调用)一句话总结:内核是运行用户程序的底层基础设施。但它不会帮你打开 Chrome、播放 YouTube——这些事得交给上层。
━━━ ━━━ ━━━
光有内核,你连个 ls 命令都跑不了。作者给出了他的理解:
Linux 发行版 = Linux 内核 + 运行在内核之上的用户空间基础设施
这个"基础设施"包括:
内核只管最低层——驱动网卡发信号出去。至于连哪个 Wi-Fi、用不用 VPN、IP 是静态还是 DHCP……这些通通发生在用户空间。
━━━ ━━━ ━━━
内核启动完成后,会在文件系统上找一个叫 /init 的二进制文件(没找到的话再试其他几个位置),把它拉起来,分配 PID 1。
这个 init 进程是内核主动启动的唯一用户进程。之后所有进程都是 init 的"子孙"——init 启动 shell,shell 启动 ls,ls 执行完退出,shell 继续等着你的下一个命令。
一个重要的感悟:即使偶尔启动的临时工具(比如 cron 里每隔五分钟跑一次的脚本),在进程树上也是 init 的后代。
所以,一个 Linux 发行版的本质就是:内核 + 启动后拉起的那一堆进程 + 你能调用的所有工具命令。
━━━ ━━━ ━━━
好,来真的了。
# 下载 Linux 6.5.2
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.5.2.tar.xz
tar -xf linux-6.5.2.tar.xz
cd linux-6.5.2
# 配置(RISC-V 交叉编译)
make ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- defconfig
# 如果你有强迫症,可以 menuconfig 微调
make ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- menuconfig
# 编译(-j 并发加速)
make ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- -j$(nproc)输出文件:arch/riscv/boot/Image——这就是我们的内核镜像。
#include <stdio.h>
int main() {
printf("Hello world\n");
return 0;
}编译成静态链接:
riscv64-linux-gnu-gcc -static -o init init.cLinux 内核启动后,需要一个初始文件系统(initramfs)来加载 init 和其他必要工具。最小化方案:
echo init | cpio -o -H newc > initramfs.cpio这里的关键是 -H newc——Linux 内核要求 initramfs 使用 newc 格式。
qemu-system-riscv64 -machine virt \
-kernel arch/riscv/boot/Image \
-initrd /path/to/initramfs.cpio你会看到:
[ 0.351894] Run /init as init process
Hello world
[ 0.379006] Kernel panic - not syncing: Attempted to kill init! exitcode=0x00000000Kernel panic!
不过别慌—— init 打印了 "Hello world",然后 return 0 退出了。内核的规则是:PID 1 不能退出。init 挂了,系统就崩了。
解决方案:让 init 一直运行,别停。
#include <stdio.h>
#include <unistd.h>
int main() {
int count = 1;
while (1) {
printf("Hello from init! %d\n", count);
count++;
sleep(10);
}
return 0;
}重新编译打包启动,这次不会 panic 了——但你也只能看着 "Hello from init! N" 每 10 秒刷新一次。没 shell、没命令,毫无用处,但确实是一个活着的微型 Linux 系统。
━━━ ━━━ ━━━
Linux 启动子进程的标准姿势是 fork + exec:
pid_t pid = fork();
if (pid == 0) {
// 子进程,执行 shell
execl("/little_shell", "irrelevant", NULL);
}
// 父进程继续每 10 秒打印一次fork() 克隆当前进程,返回 0 表示你在子进程里。然后 execl() 告诉内核:"把我现在跑的代码替换成这个二进制的代码"。
作者决定不用 C,而是用 Go 来写这个简单 shell:
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
fmt.Println("Hello world from Go!")
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("Enter your command: ")
line, _ := reader.ReadString('\n')
fmt.Printf("Your command is: %s", line)
}
}交叉编译:
GOOS=linux GOARCH=riscv64 go build little_shell.go注意:Go 编译出来的二进制差不多 1.9MB,而 C 版的 init 只有 454KB。这是 Go 的优势(开箱即用的静态链接 + 丰富的标准库)和代价(更大的体积)。在动辄几百 GB 存储的电脑上这不是问题,但在嵌入式场景就值得权衡。
另外,Go 程序对内核特性有依赖(比如 futex),默认的 RISC-V 内核配置够用——但如果遇到 Go panic,仔细读 panic 信息就能定位缺少的内核配置。
Hello from the original init! 1
Hello world from Go!
Enter your command: mkdir hello
Your command is: mkdir hello
Enter your command: ls
Your command is: ls
Hello from the original init! 2
Enter your command: exit
Your command is: exit
Hello from the original init! 3能交互了!虽然 shell 只会"回声",不懂 mkdir 和 ls 的真正含义——但进程隔离、文件系统、标准输入输出流……一代微型 Linux 发行版诞生了。
━━━ ━━━ ━━━
作者在实现这个玩具后,回头检验内核的核心价值:
| 承诺 | 兑现情况 |
|---|---|
| 硬件抽象 | ✅ init 和 shell 完全不知道 UART 的存在,只管向文件描述符写数据 |
| 高级编程范式(文件系统) | ✅ init 通过文件路径 /little_shell 定位并加载 shell 二进制 |
| 进程隔离 | ✅ fork 后 init 和 shell 各自独立运行,内存隔离 |
| 文件描述符继承(有待理解) | ⚠️ shell 继承了 init 的标准输出,所以两者输出混在一起——这是 fork 的行为,不是 bug |
内核给了你一个扎实、可移植的基础设施,你可以在上面开发高层软件,同时很大程度忽略底层硬件的复杂性。
━━━ ━━━ ━━━
自己写工具链太累?作者推荐了 u-root 项目——一个用 Go 写的全能型用户空间工具集。
一步到位:
git clone https://github.com/u-root/u-root.git
cd u-root
GOOS=linux GOARCH=riscv64 u-root
# 输出:/tmp/initramfs.linux_riscv64.cpio (约 14MB)直接用这个 initramfs 启动:
qemu-system-riscv64 -machine virt \
-kernel arch/riscv/boot/Image \
-initrd /tmp/initramfs.linux_riscv64.cpio你会看到一个完整的 shell,支持 Tab 补全!ls、pwd、echo 全都正常。
加几个 QEMU 设备参数:
-device virtio-net-device,netdev=usernet \
-netdev user,id=usernet,hostfwd=tcp::10000-:22 \
-device virtio-rng-pci启动进入 shell 后:
# DHCP 获取 IP
dhclient -ipv6=false
# 访问 Google
wget http://google.com
cat index.html一堆混淆过的 JavaScript 跳出来——你成功地从自己手搓的微型发行版里访问了 Google!
━━━ ━━━ ━━━
你现在理解了,自制发行版其实是"整体镜像"模式——想更新一个工具?重新编译整个 initramfs,重新烧录。这不适合桌面和手机场景。包管理器就是用来打破这个模式的——增量安装、卸载、更新,按需取用。
玩具归玩具,实际上 init 是现代 Linux 系统里最举足轻重的组件之一。它不只是一个"启动 shell 然后循环打印"的程序——它要挂载文件系统、创建设备节点、配置网络、拉起各种守护进程。这也是为什么 systemd、OpenRC、runit 等 init 系统之间的争论可以打上十年。
小练习:当你的 u-root 系统启动后,ls /dev 看看有多少设备节点——大部分都是 init 帮你设置的。━━━ ━━━ ━━━
从下载内核源码,到亲手写 init,到用 Go 写一个只会"回声"的 shell,再到用 u-root 启动一个能上网的微型系统——这篇文章带着我们走了一遍 Linux 最底层也最浪漫的旅程。
作者说:"内核给了你一个扎实可移植的基础设施。" 而我更愿意说:从按下电源到看见 shell 提示符,这之间发生的一切,你都可以理解,也可以亲手构建。
这就是开源和 Linux 精神最动人的地方。
━━━ ━━━ ━━━
━━━ ━━━ ━━━
*🤖 本文翻译解读自 Uros Popovic 的技术文章 + LeisureLinux 视角重写。关注 LeisureLinux 公众号,获取更多底层技术深度解读。*
一个字符就能提权到 root?Linux 内核 CVE-2026-23111 深度剖析
Linux SLUB 分配器深度拆解:malloc 背后内核在干什么?
Arm 推进 Linux 内核适配 128 位页表条目扩展 'FEAT_D128'