
1. 为什么要自己动手构建Linux系统——先明确“基本可运行”的边界我正式决定从零构建Linux系统的时候最困惑的问题不是“怎么编译内核”而是“做到什么程度才算成功”。如果你也看过一些发行版的源码打包流程很容易被上千个软件包吓退。但实际上构建Linux系统的第一个里程碑没那么宏大你需要的只是一个能开机、能登录、能敲几条命令的极简系统。这个目标拆开来看无非是四样东西——一个能启动的内核、一套存放文件和程序的根文件系统、一个负责拉起用户态环境的init程序以及一个能把上述三者串起来的引导器。这篇文章就是整个构建系列的第一篇目标很明确在不使用现成发行版安装程序的前提下手工构建出一个“基本可运行的系统”。它不包含桌面环境不包含包管理器甚至不包含完整的GNU工具集但它必须满足三个基本条件按下电源键能进入登录界面输入用户名密码能获得shell在shell里能正常执行ls、mount、cat这类最基础的操作。能达到这个程度后续想往里面加什么就都有了落脚点。选择“基本可运行”作为第一阶段的分界点是有道理的。构建系统的过程会引入大量变量如果一开始就追求功能完整一旦启动失败你会面临成百上千个潜在故障点根本无从排查。反过来先把骨架立起来让整条启动链路跑通再逐步填充血肉每一轮新增内容都能被快速验证。这也是我在整个过程里最受益的一个思路不要把第一次启动当成终点要把它当成后续所有工作的地基。这篇内容适合两类人。一类是对Linux内部机制好奇、想知道“发行版到底做了什么”的开发者另一类是工作中需要用定制化系统跑特定服务的运维或嵌入式工程师。无论你属于哪一类我都建议你给自己准备一台虚拟机或者一块闲置硬盘来实操——因为构建过程中会有不少需要反复试验的环节在真机上操作即使有风险控制心理压力也会小很多。需要提前说明的是这篇文章不是某本构建手册的翻译或节选而是我亲身把这个流程完整跑过一遍之后的总结。每个步骤背后“为什么这么做”我会尽量讲清楚操作中容易踩的坑也会单独标出来。现在我们先从启动链路的全局开始。2. 一条完整的启动链路从固件到shell每个环节都在干什么如果你从来没有接触过系统引导的底层流程第一次看到内核、initramfs、根文件系统、init这些概念时很容易晕。我最初也犯过一个错误以为只要编译好内核把它放到磁盘上启动后就能直接进系统。结果当然是失败了。后来我才彻底搞明白一台机器从通电到你看到命令提示符中间其实是一条严格的流水线每一环的输出都是下一环的输入。2.1 启动的五级流水线这条流水线大致可以划分为五个阶段我用通俗的方式描述一下固件阶段主板上的固件BIOS或UEFI负责初始化硬件然后根据启动顺序找到引导设备。这个阶段和构建Linux系统关系不大但你必须知道它决定了后续引导器以何种方式加载。引导器阶段引导器比如GRUB被固件加载后它的任务是找到内核镜像把内核放进内存同时告诉内核根文件系统在哪里、init程序是哪个。GRUB本身不关心系统长什么样它只负责“交接”。内核阶段内核被解压到内存后开始自解压、初始化各个子系统然后尝试挂载根文件系统并执行init进程。这个阶段是出错的高发区因为内核需要依赖一系列参数才能找到正确的根设备。initramfs阶段这其实是内核与正式根文件系统之间的“过渡层”。它是一块很小的临时根文件系统里面存放了内核启动早期必须用到的驱动模块和工具帮助内核完成“找到并挂载正式根文件系统”这个高难度动作。init与shell阶段正式根文件系统挂载成功后内核执行其中的init程序init负责挂载虚拟文件系统、配置设备节点、最终拉起shell或者登录管理器。到这一步用户才真正和系统发生交互。这五个阶段中引导器和initramfs是最容易被初学者忽略的。很多人觉得“把内核编译出来丢到/boot里就完事了”结果启动时报错却说不出问题在哪一环。我的经验是在动手构建之前先把这个链路画在纸上然后每做一个组件就把它放到链路里对应位置上去验证。这样即使出错你也知道去查哪一环。2.2 用“装修”来理解各部件的关系我再用一个生活化的类比帮你加深印象。如果把一台电脑比作一套毛坯房那么主板固件是物业的开门系统你得先让保安放你进楼引导器GRUB是你手里的施工许可证和电梯钥匙有了它才能到达指定楼层内核是房子的主体结构承重墙、框架没有它房子就塌了initramfs是工地上搭的临时工棚里面放着几件最要紧的工具供施工队临时落脚根文件系统是装修好的正式居所所有家具程序、水电设备节点都在这里init是进驻之后的管家负责开灯、通气、把各个房间的门都打开最后把钥匙交到你手上。这个类比虽然粗糙但能回答一个非常关键的疑问为什么不能把init程序直接放在内核里省掉initramfs因为内核和根文件系统是解耦的。内核只负责执行你告诉它的“根文件系统所在位置”至于这个位置是什么设备、需要什么驱动才能访问内核本身不一定具备。比如根文件系统在NVMe固态硬盘上而NVMe驱动是编译成内核模块的内核在启动初期根本没有能力加载这个模块——它连模块文件在哪都还不知道。这就是典型的“先有鸡还是先有蛋”问题。initramfs的存在就是从外部打破这个循环它被内核直接识别并挂载里面放好了NVMe驱动拷贝内核加载驱动后才去访问真正的根文件系统。我把这一步讲得这么细是因为后续所有构建动作都受这条链路约束。接下来咱们就从实际操作的角度把环境准备好。3. 环境准备与磁盘规划宿主、目标盘与$LFS变量动手构建之前你必须先想清楚一件事你要在什么样的环境里构建目标系统常见的有两种路线。3.1 两种构建路线的取舍第一种是“交叉编译”路线在一台和最终目标机器的CPU架构相同或相近的宿主系统上直接构建所有软件然后部署到目标磁盘或分区里。这是我自己采用的方式也是构建Linux系统最经典的路线。它不需要额外购买硬件只需要一台能跑Linux的机器和一块空闲磁盘。第二种是“完全隔离构建”路线用QEMU之类的模拟器在虚拟机里跑一个临时系统在虚拟机内部完成所有构建。这种方法隔离性更好但性能损耗明显而且很多磁盘操作在模拟环境下会出现和真机不一样的行为。我强烈建议你选择第一种并且把目标盘放在一台独立的机器或者虚拟机上做实验。原因很简单交叉构建的排查链路短出了问题可以随时挂载目标分区查看文件而不用反复启动和关闭目标系统。3.2 磁盘分区与挂载的细节构建一个基本可运行的系统磁盘上至少要有一个包含根文件系统的分区和一个引导分区如果使用UEFI启动的话。我用的是传统BIOS引导方式所以只需要一个Linux分区和一个很小的BIOS boot分区但为了更通用我建议你按“一个/boot分区 一个/分区”来规划。具体操作大致如下# 假设目标磁盘是 /dev/sdb请务必确认后再操作 # 交互式分区工具 fdisk 或 gdisk fdisk /dev/sdb # 创建分区/dev/sdb1 挂载 /boot/dev/sdb2 挂载 /分区完毕之后格式化mkfs.ext2 /dev/sdb1 mkfs.ext4 /dev/sdb2接着建立挂载点并挂载。这里有一个构建初期最重要的约定我后面会反复用到把所有目标系统的挂载放在一个叫 $LFS 的环境变量指定的目录下。比如export LFS/mnt/lfs mkdir -pv $LFS mount /dev/sdb2 $LFS mkdir -pv $LFS/boot mount /dev/sdb1 $LFS/boot看到这里你可能觉得有点小题大做——不就是个变量吗但我告诉你构建过程中你会频繁执行cd $LFS/xxx或者chroot $LFS /bin/bash这样的命令。如果$LFS没有被正确设置或者在你切换终端、重启之后丢失了一个习惯性的cd $LFS就会把你带到根目录紧接着你的后续操作可能会直接污染宿主系统的文件。这个坑我踩过一次只能说后果非常麻烦。所以每次构建之前第一件事就是把$LFS重新赋值。3.3 宿主系统的依赖检查构建工具链需要你的宿主系统具备最基本的编译能力gcc、make、binutils、bash、tar 等。有些发行版默认没装开发工具集比如某些精简版所以先检查一遍which gcc make ld tar bash gcc --version这里要特别提醒宿主系统的glibc版本要足够新否则在编译新工具链时可能因为头文件不匹配而失败。我的建议是尽量使用两三年内更新过的发行版这些版本自带工具链都能满足构建要求。环境准备好之后下一步是构建工具链。这是整个流程中最难啃的部分但其实它的核心逻辑非常简单——你只是需要用宿主系统的编译器编译出一套不依赖宿主系统的“新工具链”。为什么要这样接着往下看。4. 第一遍工具链为什么编译器要构建两遍以及具体怎么执行很多第一次接触系统构建的读者看到“编译器要编译两遍”这件事都会觉得莫名其妙。我当时的第一反应是“这不是脱裤子放屁吗一次搞定不行”后来理解了其中的依赖关系才明白这句话的正确说法应该是第一遍是为了产生编译glibc所需的“支架”第二遍才是真正为了目标系统服务。4.1 两遍构建的依赖逻辑理清这个逻辑你需要明白三个组件之间的循环依赖关系glibc标准C库是所有程序运行的基础但它需要内核头文件来定义系统调用接口gcc编译器需要glibc才能生成符合目标系统ABI的程序但gcc本身也是一个程序它必须依赖一个能运行的运行库才能运行。在宿主系统里gcc依赖的是宿主系统的glibc而你希望最终构建出依赖目标系统glibc的程序。这个矛盾怎么解标准的解法就是构建两遍第一遍gcc不链接目标系统的glibc只生成一个“裸”编译器它能编译程序但生成的可执行文件还是依赖宿主系统才能跑。用这个裸编译器去编译glibc得到一个目标系统专用的glibc。第二遍gcc用新编译出来的glibc重新编译gcc这样编译出来的gcc生成的程序就完全依赖目标系统的运行库了。这一整套逻辑本质上是把“编译器”这个工具从“宿主环境”里一步步剥离出来。理解了这个循环你在执行命令的时候就不会觉得是在机械地照抄而能清楚地知道每一步在打破哪一层依赖。4.2 实际操作中的关键参数以我现在这个环境为例构建第一遍binutils的命令大致长这样mkdir -v build cd build ../configure --prefix$LFS/tools \ --with-sysroot$LFS \ --target$LFS_TGT \ --disable-nls \ --enable-gprofngno \ --disable-werror make -j$(nproc) make install第一遍gcc也类似只是额外加了一些编译选项来告诉它“你现在是个裸编译器”../configure --prefix$LFS/tools \ --build$(./config.guess) \ --host$LFS_TGT \ --target$LFS_TGT \ --with-newlib \ --without-headers \ --disable-nls \ --disable-shared \ --disable-decimal-float \ --disable-threads \ --disable-libatomic \ --disable-libgomp \ --disable-libquadmath \ --disable-libssp \ --disable-libvtv \ --disable-libstdcxx \ --disable-libunwind-exceptions \ --enable-languagesc \ --disable-multilib这些--disable-*参数很多初学者不理解以为只是多余的开关。其实它们的核心目的只有一个在还没有目标系统glibc的情况下让编译器不要尝试去生成或链接那些需要运行库才能工作的组件。比如--disable-threads和--disable-decimal-float都是因为缺少运行库支持而被迫关闭的。构建glibc之前需要先安装内核头文件。这一步很多人会漏掉或者搞错版本tar -xf linux-版本.tar.xz cd linux-版本 make mrproper make headers find usr/include -type f ! -name *.h -delete cp -rv usr/include $LFS/usr安装完内核头文件后第一遍工具链的任务基本算完成了。到这里你手里已经有了一个放在$LFS/tools下的、不依赖目标系统运行库的编译器。接下来的第二遍gcc我会在构建完glibc之后再处理因为它依赖glibc的产物。需要强调的是这部分的构建时间很长而且每个包都不能跳过中间检查。我在构建过程中吃过几次“静默失败”的亏——某个包的make命令因为缺少依赖而没有报错就退了结果后面编译glibc的时候出现了诡异的段错误。所以每完成一个包一定要看一眼最后一个安装日志或者手动跑一下版本检查命令确认无误再进下一个。5. 最小根文件系统与init脚本让系统“活过来”的第一步工具链构建完毕目标系统还是空的。现在需要把一个“最小生存环境”装进$LFS里——它能让你通过chroot进入目标系统并且在未来启动后能正常工作。rootfs的构建可以分为两大部分目录骨架和系统程序。5.1 目录骨架Linux根文件系统有一套约定俗成的目录结构构建时至少要包含下面这些/bin、/sbin存放系统启动和基本管理命令/etc存放配置文件和init脚本/proc、/sys、/dev挂载点用于暴露内核信息与设备节点/lib存放动态库/usr更多程序和库后续大部分软件都会装到这里/tmp、/var临时文件和可变数据。建立目录的命令没什么技术含量但不要漏掉mkdir -pv $LFS/{bin,sbin,etc,proc,sys,dev,lib,usr,tmp,var}5.2 准备一个可用的shell和基础工具“基本可运行的系统”不需要完整GNU工具集但至少需要sh、mount、cat、ls这些命令。最快的途径是编译一份BusyBox——它把这些常见命令全部浓缩成一个静态链接的可执行文件非常适合作为最小系统里的全能工具。tar -xf busybox-版本.tar.bz2 cd busybox-版本 make defconfig # 关键启用静态链接这样它不依赖任何动态库 sed -i s/.*CONFIG_STATIC.*/CONFIG_STATICy/ .config make -j$(nproc) make CONFIG_PREFIX$LFS install执行完make install后BusyBox会把所有命令的符号链接创建到$LFS/bin和$LFS/sbin下。到这里目标系统的 /bin 里就有sh、ls、cat、mount等基础工具了。我建议你在这一步先做一次chroot测试看看这些工具能否正常运行chroot $LFS /bin/sh如果这个sh能跑起来说明你构建的工具链和BusyBox初步兼容。如果不兼容多半是前面的工具链构建出了问题现在修比等启动时再修要容易一百倍。5.3 init脚本系统启动后执行的第一段程序内核完成根文件系统挂载后会执行用户指定的init程序。在最小系统里这个init程序完全可以用一段shell脚本实现。我写了一个非常简短的版本放在$LFS/etc/init里#!/bin/sh # 挂载虚拟文件系统让 /proc /sys /dev 可用 mount -t proc none /proc mount -t sysfs none /sys mount -t devtmpfs none /dev # 打印一条欢迎信息 echo Minimal Linux is up. # 启动一个最简shell作为登录会话 exec /bin/sh这段脚本的每一行都有讲究mount -t proc none /proc挂载procfs否则很多系统调用和ps命令会变成空壳mount -t sysfs none /syssysfs暴露内核和驱动信息很多设备管理工具依赖它mount -t devtmpfs none /devdevtmpfs是内核自动管理的设备文件系统挂载后/dev/sda这样的设备节点才会自动出现exec /bin/sh用exec把当前shell替换成/bin/sh确保init进程的PID 1由sh来担任。把脚本加上可执行权限chmod x $LFS/etc/init但到这里还差一个关键拼图内核如何知道要执行这个/etc/init这需要在内核启动参数里明确指定或者在内核配置里写死。最可靠的做法是在后续配置引导器时把init/etc/init加进内核命令行中。否则内核默认寻找/sbin/init找不到就起不来。现在最小的用户态环境已经就绪。接下来我们要做的是把“能让系统跑起来的发动机”——内核编译出来。6. 内核编译与initramfs解决“鸡生蛋蛋生鸡”的启动难题根文件系统里已经有了sh、init脚本、BusyBox理论上它已经具备“可运行”的资格。但内核在挂载这个根文件系统之前还有一个绕不开的坎内核怎么访问这块磁盘如果你的根文件系统所在磁盘驱动被编译成了模块而模块又住在根文件系统里那就陷入了死循环。这就是我前面提到的“先有鸡还是先有蛋”问题。6.1 两种破解死循环的方案破解这个循环有两种完全不同的思路方案一把所有必要驱动直接编译进内核。这样内核自身就具备访问磁盘的能力不需要额外加载模块启动路径短也不需要initramfs。缺点也很明显内核镜像会膨胀而且以后每换一种硬件都要重新编译内核加上对应的驱动。方案二制作一个initramfs把启动早期需要用到的驱动模块放进去。内核先挂载initramfs从里面加载驱动再去挂载真正的根文件系统。这个方案灵活但要额外制作并维护initramfs。对于“构建基本可运行的系统”这个阶段我更推荐方案一和方案二各取一半把磁盘和文件系统驱动编译进内核同时制作一个最小的initramfs作为应急和排错通道。为什么还留着initramfs因为启动失败时initramfs里那个独立的shell就是你最后的救援窗口可以在不挂载根文件系统的情况下检查设备状态。6.2 内核配置的关键选项配置内核的核心思路是“贪多嚼不烂”只需关注几类选项文件系统支持必须把你根文件系统所在的分区格式比如ext4编译进内核而不是模块磁盘控制器驱动必须涵盖你的磁盘类型比如SATACONFIG_SATA_AHCI、NVMeCONFIG_BLK_DEV_NVMEinitramfs支持确保CONFIG_BLK_DEV_INITRDy以及CONFIG_RD_GZIPydevtmpfs支持确保CONFIG_DEVTMPFSy否则/dev不会自动填充设备节点。具体操作是tar -xf linux-版本.tar.xz cd linux-版本 make menuconfig # 按上述选项调整然后保存退出 make -j$(nproc) make modules_install INSTALL_MOD_PATH$LFS cp arch/x86/boot/bzImage $LFS/boot/vmlinuz-版本make modules_install会把内核模块安装到$LFS/lib/modules这些模块在initramfs阶段或后续系统启动时会被需要。如果你的驱动全都编译进内核了这一步可以跳过但留着总没坏处。6.3 手工制作一个最小的initramfsinitramfs的本质其实是一个cpio归档的压缩包内核会把它解压到一个临时的tmpfs里当作“最初的根目录”。手工制作的一个经典方式是mkdir -p initramfs/{bin,dev,proc,sys} cp $LFS/bin/busybox initramfs/bin/ # 为busybox创建符号链接 cd initramfs/bin for applet in sh mount cat ls; do ln -sf busybox $applet; done cd .. # 创建设备节点关键 sudo mknod -m 622 dev/console c 5 1 sudo mknod -m 666 dev/null c 1 3 cat init EOF #!/bin/sh echo Launching initramfs troubleshooting shell exec /bin/sh EOF chmod x init find . | cpio -o -H newc | gzip $LFS/boot/initramfs-版本.img这一步最容易忽略但又最关键的就是dev/console和dev/null设备节点。很多人在启动时遇到Kernel panic - not syncing: No init found日志里看不到任何有效输出排查到最后才发现是initramfs里缺了console节点导致内核没有可用的标准输入输出。我在这里卡了一整天才意识到这不是init脚本的问题而是最底层设备节点的问题。到这里内核镜像和initramfs都放到了$LFS/boot下根文件系统也准备完毕。还差最后一环——告诉机器开机时去哪个盘里找这些文件。引导器的配置就此登场。7. 安装引导器与首次启动的排错思路引导器环节的坑密度很高而且错误表现五花八门。我见过最典型的几种GRUB安装时报“找不到设备”、启动后卡在黑屏无任何输出、以及最经典的Kernel panic - not syncing: VFS: Unable to mount root fs。下面我把安装步骤和排查链路分别展开。7.1 GRUB的安装与配置首先确保GRUB已经安装到目标磁盘的MBR上。我用的命令是# 注意/dev/sdb 是目标磁盘不是正在运行的这个系统 grub-install --boot-directory$LFS/boot /dev/sdb这里有个细节--boot-directory指向的是目标磁盘上boot目录的挂载点而不是宿主机的/boot。如果你用的是新版本GRUB某些发行版会把参数改成--root-directory具体以你手里的GRUB版本为准。安装完成后检查一下$LFS/boot/grub目录是否生成如果没有说明安装失败。接着生成GRUB配置文件cat $LFS/boot/grub/grub.cfg EOF set default0 set timeout5 menuentry Minimal Linux { insmod ext2 insmod part_msdos set root(hd0,1) linux /vmlinuz-版本 root/dev/sdb2 init/etc/init initrd /initramfs-版本.img } EOF这段配置里有几个参数特别值得解释set root(hd0,1)里的hd0是GRUB对第一块磁盘的编号1表示第一个分区。这里写的是GRUB视角的磁盘编号不是Linux内核视角的/dev/sdb两者容易混淆。linux /vmlinuz-版本 root/dev/sdb2 init/etc/init中root指定根文件系统所在设备init指定init程序路径。initrd /initramfs-版本.img告诉内核启动时加载initramfs镜像。如果你的启动盘是NVMe固态或者UEFI模式还需要额外处理对应模块和分区格式但基本原理一致。7.2 首次启动的典型故障排查链路第一次启动后你大概率不会一次成功。别慌我在下面列出一张排查表每一条都是实测中遇到过的情况。症状最常见原因排查方向完全黑屏无任何输出GRUB未正确写入MBR或内核未解压检查grub-install是否成功确认/boot下有无vmlinuz文件停在GRUB菜单选择后返回菜单grub.cfg路径写错找不到内核镜像在GRUB命令行下手动ls并确认(hd0,1)下的文件VFS: Unable to mount root fsroot参数指向错误或文件系统驱动缺失检查root设备号是否正确确认相关驱动已编译进内核No init foundinitramfs中缺设备节点或init路径错误进入initramfs shell检查/dev/console和init脚本是否存在排查的时候有一个非常重要的原则不要一次性改多个变量。比如你既怀疑root参数写错又怀疑init脚本有问题那就先保持init不变只改root参数重新启动。如果两个问题同时修失败了你会无从判断到底是哪一步导致的。这样的单变量排查法虽然慢但它是最高效的。此外如果你遇到黑屏可以试着在GRUB菜单里给内核增加consolettyS0之类的参数输出到串口或者用nomodeset禁用可能出问题的显卡驱动这些都会让调试变得容易很多。7.3 “基本可运行”的第一条验证命令当你能看到登录提示符或者shell提示符时恭喜你系统已经活了。但为了确认这个系统确实“可运行”请依次执行下面的命令uname -a mount cat /proc/version ls /dev如果这些命令都能正常输出说明内核、根文件系统、init脚本、设备节点都协同工作了。这个时候你手里的系统虽然简陋但它已经是一个完整的、由你亲手构建的Linux系统了。8. 阶段里程碑验证清单与下一步构建方向的铺垫每次手工构建到“能启动”这一步我都有一种强烈的不真实感——毕竟这台机器上跑着的每一个二进制文件都是自己一条命令一条命令编译出来的。不过冷静下来之后还欠一个收尾动作把“基本可运行”的验证标准固化下来方便后面继续扩展时随时回归测试。我建议你把这四条作为本阶段的硬性验证清单能通过GRUB菜单启动到init脚本看到欢迎消息能获得一个可交互的shell执行ls、cat、mount等基础命令/proc、/sys、/dev三个虚拟文件系统都正确挂载能正常重启和关机至少能通过shell执行reboot。只要这五条都满足第一阶段的构建就算完结。接下来你可以沿着两个方向继续深入一是给系统添加真正的GNU工具集和软件包管理器让它变成一个功能完整的发行版二是给内核和initramfs做更多定制比如加入网络驱动、图形界面支持的模块等。从整个系列的角度回望这一篇我最大的感受是构建系统最难的往往不是编译本身而是“在众多环节中精准定位故障点”的能力。这套排查方法论——分阶段验证、单变量修改、善用initramfs作为救援窗口——在后期的每一步构建中都会反复用到。你现在做过的每一个chroot测试、每一次启动排错都在帮你积累这种能力。最后再分享一个我个人的小习惯每成功跑通一个里程碑我会在宿主机的~/build-notes目录里记录当时的版本号、命令摘要和遇到过的问题。这个习惯一次次救了我的命——因为构建系统是个长周期的过程隔几周再回来如果不看自己当初的记录你根本不会记得某个包为什么要用那个--disable-参数。工具链版本、内核.config的改动、以及$LFS目录的挂载状态这些都是最容易遗忘又是最致命的细节。文字记录虽然原始但非常可靠。下一篇我会沿着两条主线继续推进进入chroot环境构建完整的临时系统以及为这个系统安装真正的软件包管理器。到那时候你就拥有一个可以持续扩展、为自己定制的Linux发行版了。