From Power-On to Login Prompt
Every handoff between pressing the power button and a login prompt, with the artefact each stage passes to the next.
Booting a machine is a chain of handoffs, and every link in that chain has exactly one job: find the next piece of code, load it into memory, and jump to it — leaving behind a little more of the machine initialised than it found. Firmware doesn't know what Linux is. The boot loader doesn't know what a process is. The kernel, for its first few instructions, doesn't even know what mode the CPU is in. Each stage is narrow on purpose, and each one leaves evidence — a log line, a timestamp, a counter — that the next stage, or a human much later, can read back. Nothing about this chain is magic; it's eight ordinary handoffs, each one legible after the fact.
Firmware
Power-on runs firmware, not the kernel — code already resident in flash on the motherboard, BIOS or UEFI. It runs POST (power-on self-test) to confirm the CPU, RAM, and buses actually work, builds a map of which physical memory ranges are usable, enumerates the devices it can see, and then, following its own configuration, picks a device and a piece of code to load and jump to. Firmware doesn't know what Linux is; it only knows which sector or file to read and where to jump afterward. See Firmware, BIOS, and UEFI.
The boot loader
Control now belongs to whatever firmware loaded — typically GRUB, or systemd-boot on a UEFI system
with a simpler setup. Its job is narrow: find a kernel image on disk, find (or build) an initramfs to
go with it, assemble a kernel command line, load both into memory, and jump into the kernel's own entry
point. The boot loader does not start the operating system — that's the single most useful
correction to make here, because "boot loader starts Linux" is the sentence most people carry around
that skips the part where the kernel is the one deciding what happens next. See
Boot Loaders: GRUB and Friends.
The kernel image unpacks itself
The file the boot loader just loaded is a bzImage, and despite the name, most of its bytes aren't the
kernel that's about to run. A bzImage is a small blob of real-mode setup code, a decompression stub,
and a compressed payload holding the real kernel. So the first thing that runs after the jump is
self-extraction: arch/x86/boot/compressed/misc.c:extract_kernel() decompresses the
payload into memory and jumps to that entry point. Everything up to this instant has been preparing
to run code that, until a moment ago, didn't exist anywhere in memory in executable form. See
The Kernel Image.
Getting to C
On x86-64, the freshly decompressed kernel's own entry point, arch/x86/kernel/head_64.S:startup_64(),
still starts in a CPU mode built for compatibility, not for the kernel that's about to run in it. Getting
to the C code most engineers picture as "the kernel" takes several hand-written assembly steps: a
transition through 32-bit protected mode, a set of early page tables built before there's any allocator
to build them with, a transition into 64-bit long mode, and only then a call into the first C function,
arch/x86/kernel/head64.c:x86_64_start_kernel(). Every one of those transitions
changes what addresses mean and what instructions are legal — which is exactly why it has to happen in a
fixed order, in assembly, before any ordinary C function can safely run at all. See
Early Boot and Architecture Setup.
Bringing up the kernel
x86_64_start_kernel() does a small amount of platform setup and calls straight into
init/main.c:start_kernel() — the first function that gets to assume it's "the
kernel" the way most people picture it. It's a long, strictly ordered sequence: memory management,
scheduling, timers, interrupts, each step assuming everything before it already works. Near the end,
start_kernel() calls rest_init(), which spawns the kernel's own PID 1 as a kernel thread running
init/main.c:kernel_init() — and then start_kernel() itself never returns; the
thread that got the machine this far becomes the idle task.
kernel_init() calls init/main.c:kernel_init_freeable(), which calls
init/main.c:do_basic_setup() — and that is where the initcalls actually fire,
level by level (early, core, subsys, fs, rootfs, device, late), each level assuming every earlier one is
done. This is how the tree's drivers, filesystems, and subsystems announce themselves without
start_kernel() needing a hardcoded list of what exists. See
start_kernel() and initcalls.
Early user space
One specific initcall, at the rootfs level, is
init/initramfs.c:populate_rootfs() — the function that unpacks the boot loader's
initramfs (a cpio archive) into an in-memory filesystem the kernel calls rootfs. So "early user space"
isn't a separate stage bolted on after the kernel finishes booting; it's produced by one of the
initcalls, mid-sequence, same mechanism as everything else in that list.
Once do_basic_setup() returns, kernel_init_freeable() checks whether the unpacked filesystem has a
/init — by default, literally the string "/init", overridable with rdinit= on the kernel command
line — and if it's there, hands off to it. This is the first user-space code that runs on the machine.
Its job is narrow and temporary: load whichever kernel modules are needed to see the real root device (a
disk controller driver, a filesystem, an encryption layer — whatever this machine needs that the
generic kernel couldn't build in) and then get out of the way. See
Initramfs and Early User Space.
The kernel function kernel_init() and the initramfs script conventionally named /init are two
different things that happen to share a name. kernel_init() is the C function that becomes PID 1's
kernel thread; /init is a userspace program — often a shell script, or systemd itself on newer
initrd designs — that kernel_init() execs.
The real root, and PID 1
Early user space's /init makes the real root filesystem reachable, then replaces itself: on most
modern distributions this is the switch_root program, which moves the real root into place, discards
what's left of the initramfs, and execs the real init directly — same PID, no fork. (If a distribution's
initramfs has no /init at all, the kernel falls back to an older path,
init/do_mounts.c:prepare_namespace(), which mounts a root device directly instead
of trusting user space to do it — the mechanism behind a bare root= kernel parameter, mostly
superseded now.)
Either way, control eventually returns to kernel_init()'s own fallback logic. If the /init it tried
failed, and no explicit init= was given on the command line, it walks a fixed list —
/sbin/init, /etc/init, /bin/init, /bin/sh — via
init/main.c:run_init_process(), which wraps one call to kernel_execve().
Whichever one succeeds becomes PID 1's actual image, commonly systemd. If none of them do, the kernel
panics with "No working init found" — there is no supervisor left to fall back to. This is also why PID 1
is privileged in a way no other process is: kernel/exit.c:do_exit() checks
explicitly whether the exiting task is the global init and panics rather than let it die, because a
system with no PID 1 has no one left to reap orphans or notice anything is wrong. See
switch_root and PID 1.
systemd builds a graph
PID 1 doesn't run services from a fixed script. It parses .service, .socket, .target, and other
unit files, builds a dependency graph between them, and starts units in parallel wherever the graph
allows it — which is why boot order isn't "one thing after another." That graph eventually reaches a
getty unit, which opens a terminal and runs login, which authenticates you and execs your shell. The
prompt you're looking at is a leaf of a dependency tree, not the last line of a list. See
systemd: The Model.
What actually happens
Every stage above left a timestamp, which means "my machine boots slowly" is an answerable question, not
a feeling. systemd-analyze reports the userspace total, and systemd-analyze critical-chain walks
backward from the final target through whichever dependency chain actually determined when it was
reached — the units on that chain are the ones worth investigating; everything off it, however slow, was
running in parallel and for free. Both, unedited, from this machine:
$ systemd-analyze
Startup finished in 1.566s (userspace)
graphical.target reached after 1.565s in userspace.
$ systemd-analyze critical-chain
The time when unit became active or started is printed after the "@" character.
The time the unit took to start is printed after the "+" character.
graphical.target @1.565s
└─multi-user.target @1.563s
└─snapd.seeded.service @378ms +993ms
└─basic.target @356ms
└─sockets.target @355ms
└─snapd.socket @353ms +2ms
└─sysinit.target @343ms
└─systemd-udevd.service @269ms +73ms
└─systemd-tmpfiles-setup-dev.service @246ms +7ms
└─systemd-tmpfiles-setup-dev-early.service @229ms +14ms
└─kmod-static-nodes.service @211ms +11ms
└─systemd-journald.socket @197ms +18us
└─-.mount @171ms
└─-.slice @171ms
Read bottom to top: -.slice, the root of the whole cgroup tree, was ready at 171ms, and every unit above
it in the chain waited on the one below. The single biggest number on the chain is
snapd.seeded.service, which took 993ms of the 1.565s total — that line, not a vague sense that "boot is
slow," is where an actual investigation into this machine's boot time would start.
This machine is a WSL2/Hyper-V virtual machine, not a bare-metal boot, so systemd-analyze reports only
the userspace phase — there's no separate firmware/loader/kernel breakdown the way a real BIOS or UEFI
boot shows one. The dmesg evidence below still comes from the same kernel running the same handoffs
described above; only the firmware doing the handing-off is virtual.
dmesg, unedited, from the same machine — the first entries the kernel ever logs, while it's still
deciding what hardware it has:
[ 0.000000] Linux version 6.18.33.2-microsoft-standard-WSL2 (root@f1bbfb02316b) (gcc (GCC) 13.2.0, GNU ld (GNU Binutils) 2.41) #1 SMP PREEMPT_DYNAMIC Thu Jun 18 21:54:43 UTC 2026
[ 0.000000] Command line: initrd=\initrd.img WSL_ROOT_INIT=1 panic=-1 nr_cpus=16 hv_utils.timesync_implicit=1 console=hvc0 debug pty.legacy_count=0 WSL_ENABLE_CRASH_DUMP=1
[ 0.000000] KERNEL supported cpus:
[ 0.000000] Intel GenuineIntel
[ 0.000000] AMD AuthenticAMD
[ 0.000000] BIOS-provided physical RAM map:
[ 0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009ffff] usable
[ 0.000000] BIOS-e820: [mem 0x00000000000e0000-0x00000000000e0fff] reserved
[ 0.000000] BIOS-e820: [mem 0x0000000000100000-0x00000000001fffff] ACPI data
[ 0.000000] BIOS-e820: [mem 0x0000000000200000-0x00000000f7ffffff] usable
[ 0.000000] BIOS-e820: [mem 0x0000000100000000-0x00000003f69fffff] usable
[ 0.000000] NX (Execute Disable) protection: active
[ 0.000000] APIC: Static calls initialized
[ 0.000000] DMI not present or invalid.
[ 0.000000] Hypervisor detected: Microsoft Hyper-V
[ 0.000000] Hyper-V: privilege flags low 0xae7f, high 0x3b8030, ext 0x42, hints 0x9e4e24, misc 0xe0bed7b2
[ 0.000000] Hyper-V: Nested features: 0x3e0101
[ 0.000000] Hyper-V: LAPIC Timer Frequency: 0xc3500
[ 0.000000] Hyper-V: Using hypercall for remote TLB flush
[ 0.000000] clocksource: hyperv_clocksource_tsc_page: mask: 0xffffffffffffffff max_cycles: 0x24e6a1710, max_idle_ns: 440795202120 ns
[ 0.000000] clocksource: hyperv_clocksource_msr: mask: 0xffffffffffffffff max_cycles: 0x24e6a1710, max_idle_ns: 440795202120 ns
[ 0.000000] tsc: Detected 3686.400 MHz processor
[ 0.000038] e820: update [mem 0x00000000-0x00000fff] usable ==> reserved
[ 0.000040] e820: remove [mem 0x000a0000-0x000fffff] usable
[ 0.000043] last_pfn = 0x3f6a00 max_arch_pfn = 0x400000000
[ 0.000059] MTRR map: 5 entries (4 fixed + 1 variable; max 20), built from 8 variable MTRRs
[ 0.000061] x86/PAT: Configuration [0-7]: WB WC UC- UC WB WP UC- WT
[ 0.000088] last_pfn = 0xf8000 max_arch_pfn = 0x400000000
[ 0.000098] Using GB pages for direct mapping
[ 0.000190] RAMDISK: [mem 0x04beb000-0x04e9ffff]
The Command line: entry is exactly what the boot loader assembled and handed the kernel — here
initrd=\initrd.img names the file that will feed populate_rootfs(). The BIOS-e820 lines are the
kernel echoing back the memory map "firmware" handed it, whether that firmware was a real BIOS or, as
here, Hyper-V synthesizing one. And RAMDISK: [mem ...] is the kernel reporting exactly where in memory
it found the initramfs payload it's about to unpack — the log line for the handoff two sections back.
Eight handoffs, and the artefact each one passes to the next.
References
- The kernel's boot config documentation — the extended-command-line mechanism that sits alongside the classic kernel command line, for anyone whose boot loader needs more configuration than a single line comfortably holds.
- The x86 boot protocol — the exact contract between the
boot loader and the kernel: the header fields a
bzImagecarries, and what a boot loader is required to fill in before it jumps. man 1 systemd-analyze— the measurement tools this page's "What actually happens" evidence comes from, includingblameandcritical-chainfor finding what's actually slow versus what merely ran late.- linux-insides: Booting — a well-known,
line-by-line walk of early x86 boot. It predates the pinned kernel here (
v6.18) by many years, and some of the file names and function names it references have since moved; read it for the mental model, and verify any specific symbol against Elixir before trusting it.