Linux 启动全解:从固件到登录界面
在任何 systemd 机器上打开终端,运行:
systemd-analyze
在我写这篇文章用的笔记本上,输出是:
Startup finished in 5.855s (firmware) + 8.469s (loader) + 3.106s (kernel)
+ 12.181s (userspace) = 29.613s
graphical.target reached after 12.175s in userspace
把这四个数加起来是 29.611,而不是最后一行显示的 29.613。这个差距真实存在,也不是 bug:systemd-analyze 显示时对每个阶段做了截断,但求和时用的是底层微秒级的精确值,所以有几毫秒藏在舍入误差里。这是件小事,但它能帮你省下一个晚上——省得你去找一个根本不存在的 bug。
四个数字,而大多数人的认知模型只覆盖其中一个:内核花了三秒。它之前的 bootloader 花了八秒半,再之前的固件花了将近六秒。这台机器的启动过程中,有整整十四秒是在 Linux 还没开始运行时消耗掉的,而且其中相当一部分还是我自己无意中选出来的。
本文会完整追踪一次真实的 Linux 启动过程,从固件把控制权交给 bootloader,一直讲到屏幕上出现登录提示符。文中所有内容你都可以自己动手验证,而且几乎都不需要 root 权限。
Table of Contents
你需要什么
任何运行 systemd 的 Linux 机器就行——Ubuntu、Debian、Fedora、Arch,以及 2026 年大多数人装的各种发行版都算在内。再加一个终端。齐了。
我测的这台是 Ubuntu 22.04.5 LTS,内核 5.15.0-190-generic,UEFI 模式启动,NVMe 磁盘,ext4 根文件系统,systemd 249 充当 PID 1。你的配置肯定不同,有时差别很大,我会指出哪些地方会有差异。
有一条命令需要 root 权限,有一个文件在很多系统上是受限的。到时候我会标出来。
Linux 启动是四次接力,不是一次
"boot"(启动)这个词让人以为是一个单一流程。实际上是四个阶段,彼此之间几乎互不了解。
最先跑的是固件,从主板上的芯片里执行,职责是找到可引导的东西并启动它。
然后固件把控制权交给 bootloader,自己就停了。bootloader 的工作是找到内核,把它连同初始文件系统(initramfs)一起加载进内存,然后跳转过去。它也停了。
内核负责初始化硬件、挂载根文件系统,然后启动唯一一个用户态进程。之后它就不再主导流程了,但会永远为这个进程提供系统调用服务。
这个第一个进程——PID 1——负责拉起其余一切。
每次交接都是单向的。固件不会留在 Linux 底下等着帮忙,引导加载器也已从内存中消失。记住这一点,它能解释为什么 systemd-analyze 里的四个数字由不同来源测得、含义也各不相同。
固件:Linux 永远看不到的那部分
归因于固件的 5.855 秒是这里唯一一个不是 Linux 自己测出来的数字,你可以直接读到它的确切来源:
cat /sys/firmware/acpi/fpdt/boot/bootloader_launch_ns
5855973785
实际值是 5.855973785 秒,systemd-analyze 显示时截断成了 5.855。固件在移交控制权之前,把这个值写进了一张叫 FPDT(Firmware Performance Data Table)的 ACPI 表,内核把该表的字段暴露在上面的目录下。它的兄弟文件补全了剩下的部分:
cat /sys/firmware/acpi/fpdt/boot/exitbootservice_end_ns
这台机器上读出来是 14325507185,即 14.325 秒,等于固件加引导加载器的合计,与前面两个阶段相加吻合。
容易让人踩坑的地方在于:关键门槛不是 UEFI,而是你的固件到底有没有发布 FPDT。不少 UEFI 机器并不发布,虚拟机尤其如此。在这些机器上,systemd-analyze 不会报告固件阶段,也不会报告引导加载器阶段,计时直接从内核开始。要检查的是那张表,而不是 UEFI:
ls /sys/firmware/acpi/fpdt/boot/ 2>/dev/null || echo "no FPDT, so no firmware timing"
如果什么都没输出,说明你的固件没有上报,在 Linux 内部也没有办法找回这个数字。
将近六秒,相当可观,而且在 Linux 内部你几乎无能为力。这段时间花在内存训练和设备枚举上,外加厂商决定在交出控制权之前运行的那些东西。在配备高速存储的笔记本上,这往往是整个启动过程中最耗时的一个阶段——这让很多人感到意外,毕竟他们把优化精力都花在系统服务上了。
引导加载器,以及你亲手选定的五秒钟
有一个数字会彻底改变你阅读整个输出的方式。loader 阶段花了 8.469 秒,几乎是内核阶段的三倍。而这其中几乎没有任何实际工作。
grep ^GRUB_TIMEOUT /etc/default/grub
在这台机器上:
GRUB_TIMEOUT="5"
8.469 秒里有 5 秒是 GRUB 在倒计时,等着一个永远不会到来的按键。这是一个配置选项,通常由安装程序一次性设定,之后就再没人动过。如果你曾纳闷为什么硬件不差、开机却感觉慢,第一个该查的就是这里——这也是全文里成本最低的优化。
GRUB 在等待的同时,其实已经想好接下来要做什么了。它传下去的指令可以读到:
cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-5.15.0-190-generic root=UUID=b4d0343e-9df4-40a7-be97-dcd51bdbf553
ro splash intel_iommu=on vt.handoff=7
这一行就是引导加载器和内核之间的全部约定。BOOT_IMAGE 指明加载的是哪个内核。root=UUID=... 指定要挂载的文件系统,用 UUID 而不是设备名,这样磁盘重新编号也不受影响。ro 表示先以只读方式挂载。splash 要求显示图形化开机画面,而不是滚动的文字。intel_iommu=on 启用 IOMMU,vt.handoff=7 则是 Ubuntu 把虚拟终端无缝移交给图形栈,避免画面闪烁。
GRUB 把两个文件加载进内存:内核本体,以及一个初始文件系统镜像(稍后细说)。然后它跳转到内核,自己就此退出历史舞台。
内核阶段:三秒让机器可用
现在 Linux 已经跑起来了。内核先解压自身,然后建立内存管理、启动各个 CPU、初始化驱动,最后寻找根文件系统。
可以用时间戳观察这个过程(从内核启动那一刻开始计时):
journalctl -k -b -o short-monotonic | head
[ 0.000000] devils-dell kernel: microcode: microcode updated early to revision 0x100
[ 0.000000] devils-dell kernel: Linux version 5.15.0-190-generic
[ 0.000000] devils-dell kernel: Command line: BOOT_IMAGE=/boot/vmlinuz-5.15.0-190-generic
[ 0.000000] devils-dell kernel: KERNEL supported cpus:
这里的"0"是内核开始执行的时刻,不是你按下电源键的时刻。在此之前的一切——固件和 bootloader 的整个十四秒——都在这块时钟的范围之外。理解内核启动时间戳,首先要搞懂这一点,这也是为什么 dmesg 的输出会让某些机器看起来比实际快得多。
你可能刚才试着跑了 dmesg,结果被拒了:
dmesg: read kernel buffer failed: Operation not permitted
这是有意为之,你可以确认:
sysctl kernel.dmesg_restrict
Ubuntu 将 kernel.dmesg_restrict 设为 1,把内核环形缓冲区限制为仅 root 可读,因为其中泄露的内核地址对攻击者有价值。改用 journalctl -k,它通过 journal 读取同样的消息,普通用户即可使用。
/proc/cmdline 里还有一个细节,解释了一件很多人注意到却很少深究的事。内核被指示以只读(ro)方式挂载根文件系统,但你当前正在用的系统显然在往磁盘上写东西。看看现在的状态:
findmnt -n -o SOURCE,FSTYPE,OPTIONS /
/dev/nvme0n1p2 ext4 rw,relatime,errors=remount-ro
现在是读写模式了,说明有东西改过它。根文件系统先以只读方式挂载,是为了让文件系统检查能安全地跑——在进程还在写入时检查文件系统,小问题就会变成大问题。
检查通过后,用户空间会将同一个文件系统原地重新挂载为读写。你在那行输出里看到的 errors=remount-ro 选项就是反向承诺:如果内核日后遇到文件系统错误,它会回退到只读,而不是继续往一个自己不再信任的文件系统里写数据。
现在找到内核不再"独自运行"的那一刻:
journalctl -b -o short-monotonic | grep -m1 'systemd\[1\]'
[ 3.152109] devils-dell systemd[1]: Inserted module 'autofs4'
3.15 秒时,PID 1 写下了第一行日志。这与 systemd-analyze 归因于内核的 3.106 秒吻合,标志着交接时刻。从此刻起,内核不再主动做任何事。它只响应系统调用,接下来跑什么、怎么跑,决定权全部归用户空间。
什么是 initramfs,以及它解决的那个"先有鸡还是先有蛋"问题
那三秒里藏着一个步骤,也是整个 Linux 启动过程中最让人困惑的部分。
内核需要挂载你的根文件系统。为此,它需要存储控制器驱动、文件系统驱动,可能还需要组建 RAID 阵列、打开加密卷或激活 LVM 的代码。这些驱动以模块形式存在,模块存放在根文件系统上——而根文件系统,内核此刻还挂不上。
出路是一个小型文件系统,由引导加载器在内核加载的同时载入内存,自身完整可用。来看看它:
ls -l /boot/initrd.img-$(uname -r)
lsinitramfs /boot/initrd.img-$(uname -r) | wc -l
lsinitramfs /boot/initrd.img-$(uname -r) | grep -c '\.ko'
上面的路径和工具是 Debian 和 Ubuntu 的惯例。Fedora 上镜像位于 /boot/initramfs-$(uname -r).img,查看工具是 lsinitrd。Arch 上通常是 /boot/initramfs-linux.img,用 lsinitcpio 查看。
这台机器上的镜像有 79,945,887 字节,约 76 MB,包含 2,318 个文件,其中 1,386 个是内核模块。它是一个真实、可运行(虽然极简)的 Linux 系统,存在的意义就一件事:找到并挂载真正的根文件系统。
一旦成功,它会做一件不寻常的事:它从不退出。它把真正的根文件系统换上来,把自己挪到一边,然后直接执行真正的 /sbin/init,全程不启动新的进程树。PID 1 在运行中途换了身份,进程号却保持不变。
如果你用 Arch 或精简版 Fedora,initramfs 可能只有这个大小的十分之一。Ubuntu 构建的是一个通用镜像,塞进了你机器上根本没有的硬件驱动,目的是让同一份镜像能在任何机器上启动。这是体积换便携性的刻意取舍,用 lsinitramfs 就能看到你到底带着什么。
PID 1,以及另外十二秒花在了哪里
内核在 3.1 秒完成,登录界面出现在用户空间计时 12.175 秒。中间发生了什么?
systemctl get-default
systemctl list-unit-files --no-legend | wc -l
systemctl list-units --type=service --state=running --no-legend | wc -l
graphical.target
472
44
systemd 的思路是:你只需指定一个目标,它会自己算出启动顺序。这里的目标是 graphical.target,它依赖 multi-user.target,后者又依赖网络、文件系统、日志等几十项基础服务。
这台机器上装了 472 个 unit 文件,但实际运行的只有 44 个服务。systemd 会根据这些文件构建依赖图,能并行的就并行启动,只在真正存在依赖关系的地方等待。
正是这种并行机制,让启动分析比看上去难得多。几十件事同时发生,总耗时并不等于各项耗时的简单相加。
为什么 systemd-analyze blame 会误导你对启动时间的判断
接下来你很可能想到的那条命令恰恰是错的,这里不妨故意踩一次坑:
systemd-analyze blame | head -5
3min 48.947s fstrim.service
44.548s plocate-updatedb.service
13.953s apt-daily.service
4.875s docker.service
4.106s NetworkManager-wait-online.service
拿这个结果和总启动时间对比一下。用户态启动只花了 12.181 秒,而榜首那个条目却声称耗时三分四十九秒。两个数字都没错,矛盾之处正是问题所在。
blame 列出的是每个 unit 的启动耗时,而且是 systemd 启动过的所有 unit、不论它是什么时候启动的。它完全不会告诉你这个 unit 是否在你进入登录界面的路径上。看看排前三的几个:
systemctl show fstrim.service -p TriggeredBy -p WantedBy
systemctl list-timers fstrim.timer
TriggeredBy=fstrim.timer
WantedBy=
NEXT LEFT LAST PASSED
Mon 2026-09-14 00:59:36 IST 4 days left Mon 2026-09-07 00:33:35 IST 2 days ago
WantedBy 是空的,说明没有任何东西会在启动时拉起它。它是被一个 timer 触发的:两天前刚运行过,四天后才会再运行。
plocate-updatedb.service 和 apt-daily.service 也是同样情况,都由各自的 timer 触发。
也就是说,blame 排名前三的条目——表面上有近五分钟的启动耗时——对你等待登录提示符的时间贡献是零。
真正落在启动关键路径上的第一条是 docker.service,排在第四位,耗时 4.875 秒。
比起记住这个具体数字,我更希望你带走一个通用教训:如果一个度量的覆盖范围超出了你真正关心的部分,那其中无关的比例就会等比地误导你。blame 本身没问题,只是它回答的和人们想问的不是同一个问题。
解读关键链
能回答真正问题的命令是这个:
systemd-analyze critical-chain
graphical.target @12.175s
└─multi-user.target @12.175s
└─docker.service @7.297s +4.875s
└─network-online.target @7.295s
└─NetworkManager-wait-online.service @3.188s +4.106s
└─NetworkManager.service @3.151s +35ms
└─network-pre.target @3.150s
└─netfilter-persistent.service @1.377s +1.772s
└─local-fs.target @1.374s
@ 后面的时间是单元变为活跃的时刻,+ 后面的是它花了多久。现在这十二秒就说得通了:docker.service 占 4.875 秒,NetworkManager-wait-online.service 占 4.106 秒,两者合计接近九秒,而且它们是串行执行的——Docker 要求网络就绪后才启动。
在大多数桌面环境里,NetworkManager-wait-online 是第一个该盯着看的。它做的事就是字面意思:阻塞等待网络真正可用。在笔记本连 Wi-Fi 的场景下,这可能意味着好几秒纯等待。它存在的意义,是保证需要网络的服务不会在网络就绪前就启动。你到底需不需要这个保证,是个有实际答案的问题,取决于你跑的是什么服务。
关于这份输出有两点要注意。它只展示一条链,而非所有慢的地方,所以那些虽然慢但不在关键路径上的单元根本不会出现。另外,@ 时间戳并不是可以自上而下读的因果序列。我自己的输出里,一个 Docker 网络挂载的时间戳是 11 秒,却嵌套在一个 650 毫秒就完成的目标下面,乍看像是搞错了——直到你意识到这棵树画的是依赖边,而非事件顺序。看 + 值了解开销,看结构了解顺序约束,别把嵌套层级当成时间线。
为什么你的 Linux 启动时间各不相同
上面讲的是某台机器的一次启动,具体数字没那么大意义,方法才重要。在拿自己的数据跟上面做对比之前,先搞清楚哪些环节会浮动、为什么浮动。
Firmware 耗时是各环节中浮动最大的,而且跟 Linux 基本没关系。一台内存大、接了一堆 USB 设备的台式机,可能要花十五秒,而这台笔记本只需六秒。如果你的固件有快速启动选项,它的意思就是字面意思:跳过设备枚举,代价是插上新硬件时系统不会发现。
Loader 耗时基本就是你的超时时间,所以主要取决于你自己的设置。Kernel 耗时取决于你的硬件数量和 initramfs 需要解压、搜索的内容量。这就是为什么 Ubuntu 那种通用 initramfs 比专门为你这台机器构建的耗时更多。如果根文件系统是加密的,看起来像 kernel 耗时的部分其实是你正在输入密码。
Userspace 耗时是你我的机器差异最大的地方,因为它取决于你装了什么,而不是你买了什么。我机器上的 Docker 要花将近五秒,你要是没装就一点不花。
多跑几次再下结论。同一台机器每次启动耗时都有波动,单次读数的参考价值比你想的少得多。
登录界面,以及交接给你
最后一步就是你能看到的那步:
systemctl status display-manager --no-pager | head -1
loginctl show-session $(loginctl | awk 'NR==2{print $1}') -p Type -p Class
● lightdm.service - Light Display Manager
Type=x11
Class=user
这台机器上,显示管理器是 LightDM,作为 graphical.target 的一部分启动。它在虚拟终端上打开一个会话,绘制登录界面,然后等待输入。
在它背后,systemd 已经为即将登录的用户预留了 seat 和会话槽位。你输入密码后,显示管理器通过 PAM 完成认证,systemd 分配会话,桌面环境作为用户进程启动。
内核命令行里那个 vt.handoff=7 在这里发挥作用:它让虚拟终端直接移交给图形栈,不需要黑屏重绘,这就是流畅启动和闪烁启动的区别。
从这里开始,内核做的是它一直在做的事——响应系统调用。如果你想深入了解接下来发生了什么,我详细写过这个边界的工作机制。
结语
现在你可以用具体的数字、而不是凭猜测,逐个阶段地解释自己的启动过程。以我这台笔记本为例,29.6 秒可以拆解为:约六秒的固件(你无法控制),八个半秒的 bootloader(大部分是可以删掉的倒计时),三秒的内核,以及十二秒的用户态——其中两个服务等网络就占了大头。
更有价值的是,你获得了一种区分真实测量和看似合理的数据的方法。systemd-analyze blame 看起来很有权威性,回答的却是个没人关心的问题。关键链回答的是正确的问题,但仍然需要谨慎解读,因为它的树状结构表示的是依赖关系,而不是时间先后。
接下来可以尝试几个方向。把 GRUB_TIMEOUT=1 设置好并重新生成配置——Debian 和 Ubuntu 上用 sudo update-grub,Fedora 上用 sudo grub2-mkconfig -o /boot/grub2/grub.cfg——然后重启,看五秒钟凭空消失。运行 systemd-analyze plot > boot.svg 并用浏览器打开,可以看到文本输出所掩盖的并行执行视图。或者看看 NetworkManager-wait-online.service 在你的机器上是否配得上它那四秒钟——对大多数桌面系统来说,它不配。
后记
我研究这一切的起因是,我正在构建一个采用 Android 式权限模型的 Linux 发行版:程序只获得其 manifest 中声明的权限,而不是其用户碰巧拥有的全部权限。这样一来,启动流程就变成了一个安全问题。所有在你登录之前启动的进程,拥有的权限都比之后启动的任何程序更大。在我能叫出每个进程的名字、说清它为什么存在之前,我根本没有办法论证它们中间谁配得上这些权限。
我的更多文章可以在 thechris.in 找到。