
构建Linux系统这个事听起来像是大佬闲着没事折腾的东西但只要你完整走一遍那些“开机到底发生了什么”的模糊感基本会消失。这篇文章是系列的第一篇目标拆得很小在虚拟机里从一块完全空白的磁盘开始编译一个内核、搭起最小用户态、把引导装上最后得到一个能独立启动、能敲命令、能练手的小Linux系统。这里说的“最小可运行”不包含桌面、不包含网络、不包含图形界面它就是一台最朴素的机器——能引导、能挂载根文件系统、能拉起init进程、能给你一个可交互的shell。整个构建过程适合两类人一类是刚学完Linux基础命令、想搞清楚系统开机链路的后端同学另一类是用了很久发行版、但对内核和用户态边界一直模模糊糊的开发者。全程在QEMU里跑不影响当前工作环境错了就快照回滚随便折腾。1. 整体思路拆解一个最小的Linux系统到底包含什么1.1 先说清楚“最小可运行”的标准一个Linux系统能从按下电源键到出现登录提示符本质上是一场接力固件加载引导程序引导程序把内核放进内存内核完成硬件初始化、建立内存管理基础之后把根文件系统接管过来然后执行第一个用户态进程。从这一刻起内核就把舞台交给了用户态之后看到的所有服务、登录界面、网络状态都是用户态程序搞出来的。所以“最小可运行”可以压缩成四个条件内核能引导、根文件系统能挂载、第一个用户态进程能跑起来、用户能获得一个交互环境。第一个用户态进程叫init现在系统里常见的systemd也是init的一种实现只是做得非常重。最小系统里没必要用systemd一个能在inittab里拉起脚本和getty的简单init就够用。把这套流程映射到构建任务上需要准备的东西很清楚一个编译好的内核、一个包含基础命令的根文件系统、一个init程序、一套基本目录和设备节点、一个引导程序。这就是我们这次要建的全部内容。听上去不多但每一样背后都有大量选择和坑。1.2 构件选型与理由这次构建采用一套经过验证的组合内核源码来自官方内核站点的LTS版本用户态工具使用BusyBox引导程序使用GRUB运行环境用QEMU。为什么用BusyBox因为它能用静态编译方式生成一个能独立运行的可执行文件包含sh、ls、cat、mount、getty等几百个常用命令的集合体积一般只有几兆。静态编译意味着它不需要依赖宿主上的动态链接库构建时省掉了一整类“动态库找不到”的问题。这一点对最小系统特别友好内核起来后直接执行/bin/busybox不用先把libc和动态加载器逐个拷进目标系统。为什么不用发行版的deb或rpm包直接装一个根文件系统那条路当然也能得到一个系统但这属于“用包管理器拼积木”依赖关系和安装脚本都是别人写好的结束后你可能还是不确定系统怎么从零变成能登录的。手动构建的核心价值在于“每一行配置、每一个目录你都知道它为什么存在”。QEMU则是整个实验的安全网。它可以给虚拟磁盘做快照启动参数写错了也不会把真机搞挂调试完直接把镜像删掉重来。相比拿一块U盘在真实机器上反复插拔QEMU更适合初学者系统性地理解启动过程。2. 动手前的准备磁盘规划与构建环境2.1 宿主需要哪些工具构建过程需要一个能编译内核和BusyBox的Linux宿主环境。推荐用常见发行版以Debian系为例需要安装一组基础工具build-essential提供gcc、make等、bc、libncurses-dev内核menuconfig的文本界面依赖它、bison、flex、qemu-system-x86用来验证目标系统、dosfstools和mtools后续做引导盘会用以及parted或fdisk。宿主机本身不用太高配双核2G内存就能跑动这次实验编译时间差别而已。装工具时我习惯一次性把内核编译和QEMU验证相关的都装好免得编译到一半发现缺了flex或bison再回头装中断思路。可以用以下命令sudo apt install build-essential bc bison flex libncurses-dev qemu-system-x86 parted dosfstools mtools2.2 创建虚拟磁盘并格式化为ext4目标系统跑在QEMU里所以要给它准备一块虚拟磁盘。最简单的做法是用qemu-img创建一个raw格式镜像文件qemu-img create -f raw disk.raw 3Graw格式没有额外容器开销后续可以用环回设备直接挂载到宿主机上操作非常方便。创建一个3G的磁盘对基本系统来说绰绰有余内核源码和编译中间文件不会放进这个镜像所以3G足够留给根文件系统使用。接着把这个raw文件挂载成环回设备然后分区、格式化。这里我采用单分区方案整块磁盘就是一个ext4根分区引导程序直接写到磁盘的MBR。单分区的好处是变量少不用管/boot独立分区、不用纠结多个分区的挂载顺序GRUB和内核都在同一棵目录树里排查问题时少一个维度。sudo losetup -fP disk.raw # 假设分配的是 /dev/loop0请以实际输出为准 sudo mkfs.ext4 /dev/loop0 sudo mkdir -p /mnt/mini sudo mount /dev/loop0 /mnt/mini注意losetup的-P参数会让内核扫描分区表因为我们是整个磁盘做一个文件系统不走分区表所以直接用/dev/loop0即可。执行前务必确认loop设备没有被其他镜像占用比如先ls /dev/loop*看一遍否则一个不留神就格式化错了盘。为什么不搞/boot独立分区因为本次核心是“能跑起来”。等后面做双系统引导、多内核切换、或想给/boot做加密时再引入独立引导分区也不迟。单分区方案里内核镜像放在/boot/vmlinuzGRUB按路径找到它一样标准。3. 编译内核整个系统的发动机3.1 获取内核源码与准备编译目录内核源码从官方站点下载LTS版本现阶段建议选长期维护版不要追最新主线。LTS版本经过大量设备环境验证资料也多遇到问题容易搜到答案。下载后解压wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.x.y.tar.xz tar xf linux-6.x.y.tar.xz cd linux-6.x.y编译内核前需要明确一点通用发行版内核几乎把所有功能编成模块用一个庞大的initramfs动画般地把系统拉起来。我们不走那条路而是手动把必需项直接编进内核。这样做出来的内核体积小也没有“模块在根文件系统里、根文件系统需要模块才能挂载”的死锁问题。3.2 必须打开的配置项与原因进入配置界面后大部分选项可以保持默认但下面这些必须手工确认。最关键的心得是根文件系统依赖的驱动必须编译为“内置”而不是“模块”因为内核挂载根分区发生在任何用户态程序、任何模块加载工具运行之前。配置项说明CONFIG_EXT4_FSy根文件系统是ext4必须内置CONFIG_DEVTMPFSy让内核自动维护/dev下的设备节点CONFIG_PROC_FSy提供/proc虚拟文件系统很多命令依赖CONFIG_SYSFSy提供/sys设备模型的基础CONFIG_TMPFSy内存文件系统/tmp和部分工作目录常用CONFIG_VIRTIO_PCIyQEMU里virtio设备基于PCI总线CONFIG_VIRTIO_BLKy使用virtio磁盘驱动时必需CONFIG_SERIAL_8250y串口控制台支持CONFIG_SERIAL_8250_CONSOLEy允许内核把日志输出到串口CONFIG_VTyVGA等字符终端支持配置界面操作方式运行make menuconfig后在菜单里搜索搜索快捷键是先按“/”输入选项名能直接定位到位置。逐项确认后保存退出。这一步别图省事用默认的defconfig一把梭默认配置不会专门为你的虚拟磁盘场景打开virtio驱动跑起来极可能卡在“无法挂载根文件系统”这一步。为什么串口相关选项也要内置因为我们要在QEMU里用串口看内核日志。如果没有串口控制台内核启动信息会全部输出到虚拟显卡上用-nographic模式时几乎看不到报错内容调试体验很差。把8250串口驱动编进内核并设置consolettyS0后所有启动日志都能直接打到终端。3.3 编译与安装到目标目录配置完成后开始编译。先编译内核镜像和模块再把模块安装到目标根文件系统里make -j$(nproc) bzImage make -j$(nproc) modules sudo make INSTALL_MOD_PATH/mnt/mini modules_install sudo cp arch/x86/boot/bzImage /mnt/mini/boot/vmlinuz第一行生成的内核镜像在arch/x86/boot/bzImage第二行编译模块第三行把模块装到/mnt/mini下的lib/modules目录第四行把这个系列需要的内核镜像复制到目标系统的/boot/vmlinuz。这里必须解释一下“为什么用INSTALL_MOD_PATH而不是直接make install”。默认的make install会把内核和模块安装到当前系统即宿主机的/boot和/lib/modules那会污染正在运行的环境。INSTALL_MOD_PATH指定了一个前缀让模块安装到/mnt/mini/lib/modules之后目标系统启动时就能在这里找到内核模块。这个设计与交叉编译工具链里的sysroot概念同源手动构建时几乎每步都在往目标目录里“假装安装”。3.4 版本选择和备份的小心得内核版本上我建议把源码包和编译产物分开保存。编译完成后的linux源码目录不用扔留着以后想换配置重新编时能省下载时间。给内核镜像起名时也可以带上版本号比如vmlinuz-6.x.y后面同时实验两个内核时不会认错。这个习惯在真实项目里尤其有用生产内核升级后回滚靠的就是boot目录里几个完整的内核镜像文件。4. 搭建最小用户态BusyBox、init与引导4.1 编译BusyBox并安装到目标目录用户态工具我用BusyBox它把常用的Unix命令装进一个可执行文件。下载源码后先做默认配置再进menuconfig把静态编译选项打开wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2 tar xf busybox-1.36.1.tar.bz2 cd busybox-1.36.1 make defconfig make menuconfig在menuconfig里进入Settings菜单找到“Build static binary (no shared libs)”这一项选中然后退出保存。之后执行make -j$(nproc) make CONFIG_PREFIX/mnt/mini install第二行不是把BusyBox装到/sbin而是把编译好的可执行文件连同几百个命令的符号链接一起装到/mnt/mini目录下对应的bin和sbin目录。装完之后/mnt/mini/bin/busybox是本体/mnt/mini/bin/ls、/mnt/mini/bin/cat等都是链接到它的软链。静态链接能够成立的关键是编译时打开了静态选项。你可以用file /mnt/mini/bin/busybox验证输出里会看到“statically linked”。如果显示“dynamic linked”说明没开成功目标系统启动后大概率报“No such file or directory”或“cant load library”一类的错。4.2 目录骨架、设备节点与基础配置根文件系统需要一套基本目录按FHS惯例创建。内核并没有强制要求这些目录但几乎所有用户态工具都假定它们存在。我一般一次性创建出来cd /mnt/mini mkdir -p bin sbin lib etc home root tmp var/log usr/bin usr/sbin proc sys dev run chmod 1777 tmptmp目录给1777权限允许所有用户在里面建临时文件。root目录拷入的rcS脚本可能要放东西设成普通755就够了。设备节点方面虽然CONFIG_DEVTMPFS会让内核启动后自动维护/dev但在极早期、devtmpfs还没挂载起来之前内核要打开控制台设备输出日志所以基本的console和null节点最好提前手动创建mknod -m 600 dev/console c 5 1 mknod -m 666 dev/null c 1 3console设备是5:1null是1:3字符设备类型用c标识这些数字来自Linux设备号的约定暂时不需要背知道它对应内核里的设备驱动就行。如果不建这两个节点有些内核版本会在启动时抱怨“no console”或“failed to open /dev/null”行为很诡异排查起来也费时间。4.3 init、inittab与rcS脚本接下来是系统上电后内核执行的第一个用户态程序。在最小系统里这个程序就是/bin/busybox但内核约定从/sbin/init找它所以需要建立软链ln -s /bin/busybox /mnt/mini/sbin/initBusyBox作为init运行时会读取/etc/inittab文件。这个文件告诉init去哪找后台初始化脚本、去哪拉起登录会话。一个最小且够用的inittab如下::sysinit:/etc/rcS ::restart:/sbin/init tty1::respawn:/sbin/getty 38400 tty1 ::shutdown:/bin/umount -a -r解释一下第一行意味着init启动后先执行/etc/rcS完成系统初始化第二行是init自身收到重启信号时重新加载自己第三行在tty1上启动getty等待登录第四行关机时自动卸载所有文件系统。如果后续想禁用串口登录或增加更多终端改这一行即可。然后写初始化脚本/etc/rcS。这个脚本在获得shell之前运行负责把虚拟文件系统挂载起来#!/bin/sh mount -t proc proc /proc mount -t sysfs sysfs /sys mount -t devtmpfs devtmpfs /dev hostname mini export PATH/bin:/sbin:/usr/bin:/usr/sbin挂载proc和sysfs是必须的很多命令会尝试读取/proc和/sys下的信息不挂载它们会出现“ls /proc时没有内容”之类的奇怪现象而devtmpfs挂载后/dev会动态出现设备节点。写完脚本记得chmod x /mnt/mini/etc/rcS因为没有执行权限init会在运行脚本时报告Permission denied然后直接回退到重启循环这种问题属于低级错误但特别容易犯。4.4 GRUB引导安装与配置内核和根文件系统准备好之后还差最后一步让机器能从磁盘引导。GRUB是这里的标准选择支持MBR、GPT、多种文件系统。先把GRUB写入目标磁盘sudo grub-install --targeti386-pc --boot-directory/mnt/mini/boot --recheck /dev/loop0这里用i386-pc目标表示生成传统BIOS引导代码即使宿主机本身是UEFI启动也不影响。--boot-directory指定了GRUB文件要放到/mnt/mini/boot目录所以它的模块会被写到/mnt/mini/boot/grub而不是宿主的/boot/grub。如果你把它漏了grub-install会一上头就把宿主机的/boot/grub给挤掉那就很尴尬了。随后创建/boot/grub/grub.cfgset default0 set timeout3 menuentry mini system { linux /boot/vmlinuz root/dev/sda1 ro consolettyS0,115200n8 }重点说说root/dev/sda1。QEMU默认的IDE磁盘在虚拟机里会被识别成/dev/sda因为我们整张盘做了一个没有分区的文件系统所以设备其实应该写/dev/sda。但如果你把整块盘划分出了分区表那么第一个分区的名字才是/dev/sda1两者不能混用。这里我统一按单分区整盘方式处理可以写root/dev/sda同时GRUB里用(hd0,msdos1)来定位文件的位置会更准确但因为我们的grub语法直接通过路径加载文件实际会由BIOS磁盘检索逻辑来处理。稳妥起见如果你在宿主上给镜像做了MBR分区表并建了第一个分区就该写root/dev/sda1如果整盘直接mkfs就写root/dev/sda。我建议做实验时保持“整盘一个文件系统”的方式这是最容易成功的组合。consolettyS0,115200n8参数配合QEMU的-nographic模式能把内核日志和登录提示打到串口上。没有这个参数即使内核引导成功也可能在虚拟显卡那边显示而你在终端里什么都看不到只能对着黑屏发呆。4.5 第一次启动前的地毯式检查启动之前花两分钟把关键路径过一遍能省掉后面很多无谓的调试。我在实际装机时一定会检查这几项ls -l /mnt/mini/boot/vmlinuz ls -l /mnt/mini/sbin/init file /mnt/mini/bin/busybox ls -l /mnt/mini/etc/rcS ls -l /mnt/mini/boot/grub看vmlinuz是否为普通文件且大小正常init软链是否指向busyboxbusybox是否静态链接rcS是否有执行权限boot/grub下有没有grub.cfg和模块目录。这五项没问题启动环节大概率能过。5. 启动验证与排查实录5.1 启动命令与预期效果目标磁盘已经写好现在是见证时刻。启动QEMU直接从raw镜像启动qemu-system-x86_64 -drive filedisk.raw,formatraw,ifide -m 512 -nographic-m 512给512M内存最小系统用不到更多。-nographic让QEMU把串口接到当前终端配合grub.cfg里的consolettyS0你会依次看到GRUB倒计时、内核日志滚动、然后进入rcS脚本的挂载输出最后出现登录提示。输入root直接登录因为根用户还没有密码策略busybox默认会放行。登录后可以ls、cat /proc/version一个基本可运行的Linux系统就算正式建成了。如果需要退出QEMU按CtrlA然后按X。这个快捷键组合在文本模式下很好用不会和shell里的组合键冲突。5.2 常见启动故障速查表真正的手工构建不会一次成功这几乎是必然的。我把最常见的五类启动故障整理成一个速查表遇到类似报错时可以直接对号入座。启动报错或现象最可能原因排查方法Kernel panic: VFS: Unable to mount root fs on unknown-block(0,0)内核缺少ext4或磁盘驱动且该驱动被编成模块确认CONFIG_EXT4_FSy如果用了IDE磁盘确认CONFIG_ATAy和CONFIG_ATA_PIIXy重新编译内核Kernel panic: not syncing: Attempted to kill initinit二进制不存在或无法执行确认/sbin/init是否存在用file检查busybox是否静态链接检查rcS脚本是否有可执行权限登录后提示“cant access tty; job control turned off”inittab里getty配置错误或缺少tty设备确认inittab中tty1行存在确认CONFIG_VTy检查/dev/console节点存在启动后完全黑屏、没有任何输出没有配置串口控制台或VIDEO输出不兼容确认grub.cfg里带consolettyS0或把-nographic换成-serial stdio能进shell但ls/cat等命令报not foundPATH环境变量未设置或busybox软链缺失在rcS里export PATH确认make CONFIG_PREFIX/mnt/mini install执行成功上述问题里第一类最典型。很多人一看到“VFS: Unable to mount root fs”就以为根文件系统格式坏了其实绝大多数情况是内核里没有把对应的磁盘驱动编进去。我们当前用IDE磁盘所以至少需要内置ATA、IDE相关的驱动。如果你某天切换到virtio磁盘还要确保CONFIG_VIRTIO_BLKy否则设备名变成了/dev/vda但内核完全不认识它。5.3 定位问题的一般思路如果报错不在速查表里也别慌定位思路其实很规律。第一步看内核日志最关键的最后几行内核panic时通常会把“找不到谁”写得很清楚。第二步判断问题发生在挂载根文件系统之前还是之后之前的问题几乎都是内核配置不够之后的问题多是用户态脚本或权限问题。我惯用的调试手法是在grub.cfg的内核命令行里临时加一个init参数比如把init改成/bin/sh让内核直接进入shell而不是执行系统的inittab流程。如果改成/bin/sh之后能进入shell说明内核和根文件系统都没问题问题出在init配置或rcS脚本上如果连sh都进不去那就回头查内核配置和busybox安装。这个二分定位法对解决启动问题非常高效建议优先尝试。另一个实用技巧是在rcS脚本里加上set -x这样脚本执行时会把每条命令先打印出来。最小系统没有复杂日志工具但这种“把现场摊开看”的做法往往比到处翻日志更直接。6. 从这里继续扩展6.1 先给系统加个网络基本系统能进shell之后第一步扩展通常是加网络。这需要两块准备内核里编入网卡驱动QEMU启动时给虚拟机挂一块虚拟网卡。常见组合是内核开启CONFIG_E1000yQEMU启动命令里加-device e1000,netdevnet0 -netdev user,idnet0进入系统后用busybox自带的ifconfig和ping就能验证连通性。user模式网络让虚拟机通过宿主上网配置简单但限制也明显比如外部访问不到虚拟机适合先练练手。想玩更真实的网络拓扑后面可以换tap或bridge模式那就是另一篇文章的内容了。6.2 把BusyBox换成完整工具链BusyBox的命令实现精简很多参数是阉割版日常调试够用真要编译C程序或跑复杂服务肯定不够。下一步可以尝试在系统里引入真正的glibc、coreutils和bash或者干脆用buildroot把一套完整的用户态打包出来。这条路要处理动态库依赖、configure检查、工具链选择等问题复杂度上了一个台阶但每解决一个问题你对“软件包怎么从源码变成可运行程序”的理解就深刻一层。6.3 理解initramfs与现代系统的关系这次我们的系统把根文件系统驱动编进了内核所以没有使用initramfs。但现代发行版几乎都离不开initramfs因为内核为了保持通用性把大量文件系统驱动和硬件驱动编成了模块启动时必须先由initramfs把这些模块加载齐全再切换真正的根文件系统。搞明白这个机制后再回头看开机时那几秒钟的进度条你会清楚它到底在等什么。6.4 最后分享一点个人实操体会手动构建过一遍系统之后再看任何发行版的启动流程都像拆过玩具再看到同款玩具一样少了神秘感多了掌控感。我建议你完成这篇的步骤后刻意去制造几个故障再修好它比如把内核配置里的ext4暂时关掉看看报错是不是预期的那一条或者把rcS的挂载语句注释掉观察登录后/proc下是不是空的。这种“主动破坏再修复”的过程比单纯照文档做一遍学到的东西多得多。下一篇我计划写如何在这个最小系统上加入网络和包管理基本系统的地基打好了上层建筑才有得聊。