ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux启动流程全解析:从固件到systemd的排错与优化

Linux启动流程全解析:从固件到systemd的排错与优化

我最早真正想弄懂Linux启动流程,是在一次帮同事排查重启故障的时候——改完某个内核参数,机器卡在黑屏只剩下一个光标闪烁,怎么都进不了登录界面。当时除了重启大法几乎毫无办法,那种无力感相信不少人都体会过。后来我系统梳理了一遍从按下电源键到出现桌面的完整过程,才意识到所谓“启动流程”其实是四五个阶段的接力赛,任何一个环节断了,外在表现都差不多,但排查思路却完全不一样。这篇文章我会把整条链路拆开来讲,覆盖固件、引导、内核、用户空间四个阶段,配合可直接复用的排错方法和优化手法,适合刚接触Linux运维、或者被启动问题折磨过的读者。

1. 从按下电源到GRUB:固件阶段究竟做了什么

1.1 BIOS/MBR的旧世界:512字节里装了什么

老式主板走的是BIOS加MBR的路线。上电瞬间CPU还处于实模式,地址空间极其有限,它先执行固件里的初始化代码,做一遍POST自检,然后根据CMOS里配置的启动顺序,把排在第一个的磁盘读出来。这里有个关键点:CPU只认磁盘最前面的数据,也就是第0号扇区,通常叫MBR,一共512字节。

这512字节的布局很微妙:前446字节是引导代码,中间64字节是四组主分区表项,最后2字节必须是魔数0x55AA。固件读完MBR后检查魔数,合法就把控制权交给这一段引导代码。引导代码其实做不了太多事情,它最大的职责是去磁盘上把后续的引导程序继续加载进来。GRUB2在安装的时候,会在MBR附近写入一段精简的boot.img,再在后面的扇区放一个稍大的core.img,形成一个分阶段引导的链。因为Windows自己装出来的引导代码和GRUB不一样,所以双系统时要是你先装Linux再装Windows,Windows通常会把MBR覆盖成自己的引导器,之后就进不了Linux了。

强烈建议任何Linux机器都学会一个自救操作:插上任意一个可用的系统U盘,进到Live环境里,执行dd if=/dev/sda bs=512 count=1 | xxd看一眼MBR内容。如果结尾不是55aa,或者引导区是一堆乱码,多半就是引导链断了。

MBR时代还有一个让我头疼过的坑:分区表只有64字节,最多只能有4个主分区,超过就得用逻辑分区,逻辑分区的链式结构一旦中间某一段损坏,整个后半块盘都不好访问。所以现在新机器我几乎见不到纯MBR了,都是GPT,这个我们在下一节展开。

1.2 UEFI/GPT的新秩序:ESP分区与安全启动

UEFI不是简单把BIOS换了个界面,它把整个引导思路都改了。UEFI固件不再去读硬盘的第一个扇区,而是从主板NVRAM里保存的启动条目中,找到一条指向某个.efi文件的路径,然后直接去一块叫ESP的分区(EFI System Partition)里加载它。ESP分区固定用FAT32文件系统,它不参与日常文件存储,里面只放引导器、驱动和系统工具。GRUB装到UEFI上的产物就是类似/boot/efi/EFI/grub/grubx64.efi这样的文件,固件直接把这个efi文件当成一个小程序运行。

这个变化带来的好处非常实际:分区表换成GPT后,磁盘起始和末尾都有备份分区表,损坏恢复率高很多,分区数量理论上不受限制。更关键的是,固件可以直接加载Linux内核的efistub模式,也就是说系统里可以完全不用GRUB,UEFI直接引导内核,启动链路又短一截。不过实际生产环境里,还是大多数保留GRUB,因为它的菜单方便排错,而且支持参数临时修改。

UEFI还有一个绕不开的话题:安全启动。它的本意是防止启动链被植入恶意代码,固件只信任带有效签名的引导器。这让很多发行版默认无法直接启动第三方编译的内核模块,尤其是自己手编的NVIDIA等驱动。解决办法不是关闭系统安全功能,而是去BIOS里把Secure Boot设为允许加载自定义密钥,再用工具(如mokutil)导入自己生成的签名。我在某台工作机上就是因为安全启动开着、模块没签名,重启后内核直接拒绝加载,黑屏进不了系统。

排查UEFI启动问题,第一个要看的就是启动条目顺序。命令行下用efibootmgr -v能列出所有启动条目,如果某次系统更新后启动顺序被打乱,UEFI会退回到“第一块磁盘的ESP里的默认路径”。用efibootmgr -o 0001,0000,0002这种命令能手动调整顺序。

1.3 GRUB2:把内核抬到它该去的地方

GRUB2是整个引导链里最值得花时间研究的程序。它本身分两层:第一层是core.img,体积很小,功能也少,它的任务只在加载第二阶段的完整功能模块;第二层从/boot/grub/读取normal.mod、脚本模块和grub.cfg配置,然后按配置渲染启动菜单。

grub.cfg里面真正启动Linux的关键其实就是一串命令,核心是两条:root指定内核文件所在的分区,linux指定内核文件路径并附加启动参数。例如一个典型的菜单项会有类似这样几行:

menuentry "My Linux" { load_video set gfxpayload=keep insmod gpt insmod ext2 search --no-floppy --fs-uuid --set=root 8a9b2c3d-... linux /boot/vmlinuz-6.1.0-xxxx root=/dev/mapper/system-root ro quiet initrd /boot/initrd.img-6.1.0-xxxx }

这里每一行都有实际含义。search --fs-uuid不靠设备名,而是靠文件系统的UUID找分区,这样即使磁盘顺序变了也不会找错。root=是给内核看的参数,告诉内核真正的根文件系统在哪个设备或哪个dm卷上。ro表示启动早期以只读方式挂载根,避免还没加载完文件系统就被写坏。quiet则是压制日志输出,只把警告和错误打到屏幕上。

我调试启动问题时,几乎从来不去改grub.cfg本身,而是在GRUB菜单界面按e进入编辑模式,临时改这一行的内容。比如把quiet去掉、把root=改成另一个设备、或者加上rd.break,这些改动重启即失效,非常安全。这个操作是所有排错手段的基础,建议每个人都亲手试一次,看看去掉quiet后屏幕上会滚过什么样的日志。

GRUB一旦丢失,最典型的现象是开机直接进到一个提示符grub rescue>。这说明core.img找到了,但第二阶段模块或配置丢了。此时只要能从救援提示符找到/boot所在分区,还是能救回来:

grub rescue> insmod normal grub rescue> set prefix=(hd0,gpt2)/boot/grub grub rescue> normal

2. 内核收到指挥棒:从initramfs到PID 1

2.1 initramfs为什么存在:驱动还没就绪的“鸡生蛋”问题

内核被GRUB加载进内存后,第一步是压缩自解压、初始化,紧接着就会遇到一个矛盾:内核代码本身在内存里没问题,但它要挂载根文件系统、启动用户态程序,就必须有磁盘驱动、文件系统驱动;可是根文件系统还没挂上,驱动模块又放在根文件系统里面,形成了一个经典的死锁。initramfs就是为了解开这个环。

initramfs本质上是一个极小的临时根文件系统,被直接打进内存用。GRUB通过initrd那一行命令把它也加载进来,内核启动早期把这里当作根,然后执行里面的初始化脚本,加载真正需要的那批磁盘和文件系统驱动模块。等一切就绪后,它再找到真正的根设备并切换过去,完成使命后释放占用的内存。

你可以把一个现代Linux的initramfs理解成一个“轻量救援环境”,里面包含极简的目录结构、一堆内核模块、udev规则和几个管理工具。生成它的工具在主流发行版里通常叫dracut。自己定制的时候,可以通过dracut --list-modules查看默认打了哪些模块进去,必要时用dracut --omit-drivers剔除某些不会用到的驱动来缩小体积。

我实际处理过的典型案例:一台用LVM装了系统的机器,磁盘型号一样的一块新盘插进来后,系统更新了initramfs,然后重启就发现找不到根分区。排查到最后发现是因为新盘让磁盘设备顺序变了,而initramfs里固化了对设备名的引用。把root=改成使用UUID或者dm卷名,再重新生成initramfs,问题就解决了。这种问题内存中根本看不到画面提示,只会停在“waiting for device”或者直接panic,所以initramfs相关的排错知识必须要单独掌握。

2.2 内核初始化:从start_kernel到最后的C语言函数

内核被引导器放置到内存后,真正的入口是从汇编代码慢慢过渡到C语言的start_kernel函数。这个函数像一场大型典礼的司仪,它会依次初始化内存管理、调度器、中断系统、时间子系统,然后枚举并初始化所有它认识的硬件设备,最后调用rest_init创建两个内核线程:一个负责CPU热插拔相关的事务,一个最终会演变成所有用户态进程的祖先。

初始化到这里,内核会把自己变成一个“完全体”,然后计算并执行initramfs里的init程序。如果你在启动参数里加了rd.break,内核会在尝试挂载真正根文件系统之前停下来,给一个紧急shell,这时候可以手动检查模块、分区、甚至修复文件系统,非常实用。我第一次用rd.break进入shell时,发现里面居然还有完整的ls、mount、blkid命令,瞬间理解了“急救包”的概念。

还有一个我反复跟人强调的概念:内核在启动用户态程序之前,所有的日志都是直接输出到console的;一旦用户态接管,日志体系才交给systemd的journal等组件。这也是为什么黑屏时,你会看到一串内核日志停在半中间,后面就再也没有输出了——那不是死机,而是日志管道换了主人。搞懂这一点,看启动日志时就不会一头雾水。

2.3 交接仪式:execve("/sbin/init")之后发生了什么

内核初始化最后一步,是调用execve去执行真实的init程序。在主流的现代Linux发行版上,这个程序就是systemd,它成为整个系统里的第一个进程,也就是PID 1。如果某个内核参数或initramfs导致PID 1根本无法执行,内核就会直接抛出一个Kernel panic - not syncing: Attempted to kill init!,然后系统就彻底躺尸了。

这个环节有一个经常被忽略、但极其实用的救援技巧:在GRUB菜单里追加init=/bin/bash,可以绕过systemd直接进入一个最朴素的bash。我曾在根文件系统启动脚本配置错误、systemd完全起不来的情况下,用这招进入单用户环境,挂载根分区,把坏配置改掉。注意这种模式下文件系统是只读的,手动执行mount -o remount,rw /之后才能写。这招虽然简陋,但几乎所有环境都适用,是比进救援模式更快的一条路。

另一个常见坑是,initramfs里执行的init脚本把错误的分区当根挂上,结果systemd启动后一看根文件系统里啥都没有,接着抛出一堆依赖错误。这个现象的根源多数在root=参数或initramfs的生成阶段,而不是systemd本身。所以排查时别一看到systemd报错就去查service配置,先确认根挂载是否正常,思路能省一半的力气。

3. systemd接手后的用户空间启动逻辑

3.1 不再是脚本顺序执行,而是按依赖并行拉起

老SysV风格的启动方式是排好一串脚本,按顺序执行,每个脚本负责一部分服务。这种方式逻辑简单,但效率极差:明明两个服务互不依赖,也得排队等着。systemd把这一套彻底改了,它把所有启动对象抽象成unit,每个unit都能声明自己的依赖关系和启动顺序,最终形成一个有向无环图。

这个概念你用“多线程任务调度”来理解会特别贴切:systemd启动时把default.target的所有依赖都找出来,凡是能并行的就同时开跑,只有确实存在前后关系的任务才排队等待。这就是为什么一台机器上服务很多,但重启速度比以前快得多的根本原因。

日常管理里,最常用的入口是systemctl。systemctl list-units --failed能快速找出启动失败的服务;systemctl status 服务名能直接看到该服务的当前状态和最近的日志。我自己的习惯是系统一开机有任何异常,先跑这两个命令,往往几分钟就能定位问题,比看满屏启动日志高效得多。

3.2 target与启动收敛:从multi-user到graphical

理解systemd就不能绕过target。target不是服务,而更像一个状态分组,一个“目标”。启动过程最终会收敛到一个默认target上,比如default.target,它又通常链接到graphical.target或multi-user.target。graphical.target就是在multi-user基础上再叠加一个显示管理器,所以如果你用minimal安装、桌面环境出问题,把默认target切回multi-user就能在字符界面里修。

在排错场景里,还有两个特殊target非常关键:rescue.target是救援模式,只启动最基础的服务,适合排查依赖问题;emergency.target更极端,几乎只挂载根文件系统,适合救场。启动时临时让系统进入这些模式,只需要在GRUB的linux命令行追加:

systemd.unit=rescue.target

这比拿Live CD完全重装环境温和太多。我帮同事处理过好几回桌面环境卡死、来不及进终端的机器,就是用这个参数直接进multi-user,然后把冲突的显示服务停掉。

查看和修改默认target也有成熟命令:systemctl get-default看当前值,systemctl set-default multi-user.target改成默认进纯命令行。很多刚接触Linux的同事以为改默认target就要去编辑某个脚本,其实是这里。

3.3 服务配置里最容易埋雷的地方

systemd的unit配置虽然比脚本清晰,但写错一个字段照样让启动卡死。最常见的雷区有两类:一类是After=和Requires=用反,导致启动顺序反了;另一类是Type定义错误,导致systemd误以为服务启动完成了,实际上进程还在挣扎。

举一个我真实调过的例子:某个环境里一个依赖数据库的web服务,配置写成Requires=database.service但没写After=database.service。结果systemd因为依赖关系会尝试启动数据库,却因为没声明顺序,两边实际同时启动,web服务在数据库还没监听端口时就连接失败,整个启动过程带着一个red失败状态结束,用户访问时偶尔成功偶尔失败。加上After后一行问题就解决了,这种定位往往比你想的更简单。

另外一个高频坑藏在/etc/fstab里。systemd会为每一个挂载项生成一个mount unit,如果一个挂载设备不存在,又没有加nofail选项,系统会在启动时一直等到超时,表现就是开机卡很久、甚至过了几十秒才勉强进系统。这种情况在加了新磁盘、却忘了更新fstab的机器上特别常见。排查时执行systemctl list-units --failed或systemd-analyze critical-chain,立马能看出是哪层挂载拖了后腿。

4. 启动卡住怎么定位:一套可以复用的排查路径

4.1 先知道“卡在哪一棒”:信息收集三步

我不建议上来就瞎改配置。启动问题定位的正确顺序是先收集信息,判断卡在哪个阶段。第一步看系统日志:journalctl -b -p err只会打印本次启动中的错误级别日志;dmesg则能看内核环形缓冲区的全部内容。第二步用systemd-analyze blame和systemd-analyze critical-chain分析启动链路。第三步,在启动参数里临时去掉quiet,让所有细节直接打印到屏幕上,或者加systemd.log_level=debug获得systemd自身的调试日志。

这里我特别强调一下systemd-analyze critical-chain的用法。它展示的不是所有服务的耗时列表,而是从默认target倒推出来的关键依赖路径,路径越长,启动越慢,路径上的瓶颈点也就一目了然。比如它会给出类似“graphical.target → systemd-user-sessions.service → 某服务”这样的链,你只要盯着链条上最耗时的节点去查就行。

排查启动问题时我最讨厌拿着别人的截图猜。日志和输出一定要自己跑一遍,尤其注意journalctl -b后面的参数,-b表示本次启动,-b -1表示上一次启动。机器都进不了系统时,可以在GRUB里加systemd.unit=emergency.target或者用Live盘挂载磁盘来看日志。

4.2 分阶段排查:不同表现对应不同病灶

以下是按表现判断启动阶段的一套实操判定表,基本能覆盖大部分黑屏或卡死场景。

现象病灶可能所在首要检查项
按电源后完全无画面、风扇转但不进系统固件/硬件内存、显卡、外设、启动设备顺序
显示器显示厂商Logo后直接黑屏固件→GRUB之间启动条目是否缺失、ESP分区是否损坏
出现GRUB菜单但选了系统后卡住内核/initramfs内核参数、initramfs是否完整、安全启动签名
看到一行行systemd日志后卡住不动用户空间服务依赖systemd-analyze、journal错误日志
刚出现登录界面就反复重启图形服务/内核模块冲突显示服务日志、内核模块加载

判断在哪一步的依据就是“你最后还能看到输出是什么”。屏幕上出现LOGOS进不去,和能看到GRUB菜单进不去,完全是两个世界的排查方向。我在某台测试机上遇到过“进GRUB就黑屏”,一开始怀疑内核,结果排查半天发现是ESP分区里多了一个损坏的efi启动文件,固件加载它时超时。这类问题,把启动条目里没用的删掉就正常了。

4.3 几个我实际踩过、也最容易复现的坑

第一个坑是root参数写错设备名。很多人安装系统时直接用默认root=/dev/sdaX,磁盘顺序一变就崩。治本的办法是改成root=UUID=xxx,UUID在blkid里能看到。第二个坑是initramfs与内核版本不匹配。内核升级后忘了重新生成initramfs,或者从旧系统直接拷贝新内核过去,就会在引导时找不到模块。重新生成的方式通常是一个命令的事,比如dracut --regenerate-all --force,别嫌麻烦。

第三个坑是安全启动与自定义模块的冲突。如果BIOS开着Secure Boot,而initramfs里加入了未签名的第三方驱动模块,内核会在加载该模块时直接把系统踢进panic或者黑屏。验证方法也很简单:临时关掉Secure Boot或者用mok工具导入自己的公钥,再重启看是否正常。第四个坑是systemd服务依赖成环。我在某内部项目里遇到过两个服务互相Requires,systemd检测到cycle后直接放弃启动,最后在unit里理清依赖、确定主从关系才解决。

这四个坑的共同点是:你登录到系统内部看,一切配置都“看起来”没问题,但只要脱离常规恢复手段,系统就是不给你进。所以我后来的习惯是,每次动了内核、GRUB、fstab、systemd服务这四类改动后,都先重启检查一次,绝不积攒改动一起验证。

5. 把启动时间压下来:我的测量与裁剪手法

5.1 先量化再动手

启动优化的前提是能看到数字的变化。systemd内置了一套分析工具,我最常用的是这三个:

systemd-analyze time systemd-analyze blame systemd-analyze critical-chain

time给一个总耗时和内核态/用户态分段:blame按耗时列出所有服务;critical-chain显示关键路径。这里要提醒一句,blame里的数字只是“该服务自身从开始到完成的时间”,不代表它独占了这么久,很多服务是并行跑的,所以别把所有数字加起来算启动总耗时,那是错的。真正的优化要看critical-chain上那些没法并行、必须串行的节点。

另外,systemd-analyze plot > boot.svg能生成一张可视化的时间轴图,服务之间的先后关系一目了然。这张图对判断“为什么某服务迟迟不启动”非常有帮助,我调过一次之后基本离不开它。优化过程一定要保留改动前的记录,对比才有意义。

5.2 找到最值得优化的三个点

大部分Linux启动变慢,其实不是内核慢,而是用户空间的等待,尤其是网络相关的等待。比如某台机器上配置了自动挂载远程目录,网络栈起来之前就想挂载,就会一直等到超时。这类问题把挂载方式改成_netdev或nofail、加上After=network-online.target就能规避大部分等待。

第二个值得动手的地方是禁用根本用不到的服务。一台服务器上面很可能开着蓝牙、打印、图形登录等一堆用不上的服务,逐个systemctl disable或mask。虽然省下的时间不长,但每一项减少几十毫秒,累积起来也不少,更重要的是减少了入侵面和故障点。

第三个点很多人忽略了:把Type=oneshot服务改成合适的Type。oneshot表示服务只执行一次就退出,如果某服务自己的启动脚本里还sleep了几秒,那么整个critical-chain都会陪着等。能用Type=notify或Type=simple就不要用oneshot。遇到启动脚本本身复杂的情况,优先优化脚本逻辑,而不是单纯加速服务启动。

5.3 用systemd特性把“等待”变成“推送”

systemd一个容易被人忽视的特性是socket激活。它可以让一个服务不在开机时启动,而是在有客户端连接时才被拉起来。比如某些打印服务、未使用的容器管理程序,用socket激活后,启动时间表里压根就不出现它们,系统整体启动自然快很多。启用方式是systemctl enable 服务名.socket,然后man一下对应服务的配置说明。

还有一个更激进的优化思路是休眠内核:不是在用户空间层面加速,而是让系统直接休眠到磁盘,下次开机时从休眠镜像恢复。这个方案对桌面机很有价值,但对服务器环境风险较高,建议只在测试机上玩。

优化完记得用systemctl mask处理那些互相干扰的单位。mask和disable的区别是:disable只是不让它们开机自启,手动还能拉起来;mask是彻底屏蔽链接,之后即使有服务依赖它,也会被systemd跳过。我之前在把图形环境从某台机器上彻底剥离的过程中就mask了显示服务,即使有别的unit意外依赖,也不至于把它重新拉起来,这个操作给我省了不少排查时间。

我在实际优化了十几台机器之后发现,启动优化和启动排错本质是同一个知识体系:你要知道每个阶段都在等谁,然后才能决定是杀掉它、延迟它、还是让它跑得更快。等到你上手把critical-chain缩到只有几个节点,你就已经对这台机器的脾气了如指掌了。启动流程从来不是背出来的一堆名词,而是一张你可以随时打开、随时按需裁剪的任务清单。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进