Операционные системы Загрузка системы: BIOS и UEFI, MBR/GPT, GRUB, systemd-boot, initramfs
0%

Загрузка системы: BIOS и UEFI, MBR/GPT, GRUB, systemd-boot, initramfs

Загрузка системы: BIOS и UEFI, MBR/GPT, GRUB, systemd-boot, initramfs

Есть момент, когда операционной системы ещё нет. Процессор включился, регистры в неопределённом состоянии, оперативная память — электрический шум, диск существует как физическое устройство, но никто не знает, что такое «файл». И из этого состояния за несколько секунд надо прийти к работающему ядру, смонтированной файловой системе и процессу с PID 1.

Загрузка — это единственная часть операционной системы, которая строится снизу вверх, каждый следующий уровень абстракции создаётся руками предыдущего. Всё остальное в ОС можно изучать сверху: вы вызываете open(), и где-то там VFS. Здесь наоборот — сначала 512 байт машинного кода, которые умеют ровно одну вещь: прочитать следующие 30 килобайт.

Зачем это прикладному программисту

Соблазн сказать «загрузка — дело админа» велик, но на практике эта тема прорастает в работу разработчика в пяти местах.

Образы и иммутабельная инфраструктура. Вы собираете AMI, qcow2 или образ для bare metal. Всё, что в нём есть до /sbin/init — ваша зона ответственности: разметка, ESP, initramfs с нужными драйверами, параметры ядра. Ошибка здесь не даёт стектрейса, она даёт чёрный экран.

Отладка «оно не поднялось». Инстанс не пришёл в сеть. Причина может быть в чём угодно от порчи GPT после ресайза диска до отсутствия модуля virtio_blk в initramfs. Умение прочитать вывод serial console и понять, на каком этапе всё встало, экономит часы.

Параметры ядра как рычаг производительности. mitigations=off, isolcpus, hugepages, intel_iommu=on, nohz_full — всё это задаётся только на этапе загрузки и меняет поведение вашего кода на десятки процентов. Как эти строки попадают в /proc/cmdline — сюжет этой статьи.

Скорость запуска. Serverless, автоскейлинг, CI-раннеры: если инстанс поднимается 40 секунд вместо 8, это прямые деньги. Половина этого времени обычно тратится до старта пользовательского кода.

Безопасность цепочки поставки. Secure Boot, measured boot, TPM-attestation, unattended дешифровка дисков — все они опираются на то, кто и что подписал на этапе загрузки. Если вы делаете систему, которая должна доказать «я запущена на неизменённой платформе», вам сюда.

Про то, что происходит после старта PID 1, — соседняя статья Системы инициализации; про то, что ядро делает с памятью после того, как получит управление, — Управление памятью.

Карта: две принципиально разные цепочки

Прежде чем нырять в детали, важно увидеть, что на x86 сегодня сосуществуют две несовместимые модели загрузки. Они отличаются не «форматом таблицы разделов», а тем, что прошивка умеет делать.

Ключевая мысль: в legacy-модели прошивка не умеет ничего, кроме «прочитай мне сектор номер N». Она не знает, что такое файловая система, раздел или файл. Поэтому вся сложность переехала в загрузчик, а загрузчик пришлось резать на стадии, потому что первая стадия обязана поместиться в 440 байт. В UEFI-модели прошивка — это по сути маленькая ОС с драйверами FAT32, сети, видео и таймеров, поэтому «загрузчик» может быть обычным исполняемым файлом, а иногда его вообще нет.

Этап нулевой: до того, как вы что-либо контролируете

Разработчику полезно знать, что даже самый первый байт вашего кода — далеко не первый байт, исполненный в системе.

На x86 после снятия сигнала RESET процессор начинает выполнение по reset vector 0xFFFFFFF0 — за 16 байт до конца 4-гигабайтного адресного пространства, в реальном режиме, но с «скрытой» базой сегмента, указывающей в ПЗУ. Там стоит jmp на настоящий код прошивки.

Но и это не начало. На современных платформах Intel до пробуждения основного ядра стартует Intel Management Engine — отдельный процессор со своей ОС (MINIX-подобный ME 11+), который инициализирует часть платформы и проверяет подписи. У AMD аналог — Platform Security Processor. Отсюда важный практический вывод: «полный контроль над машиной с первой инструкции» на современном x86 — иллюзия. Проекты вроде coreboot и Heads борются за то, чтобы уменьшить долю проприетарного кода, но полностью исключить его нельзя.

Микрокод. Процессор исполняет x86-инструкции не напрямую, а через микрокод, который можно обновлять. Обновление грузится дважды: прошивкой на раннем этапе и повторно ядром, чтобы не зависеть от версии BIOS. В Linux второй способ реализован как early cpio — несжатый архив в самом начале initramfs, который ядро распаковывает до инициализации CPU. Вот он на реальной машине:

$ lsinitramfs /boot/initrd.img-6.8.0-136-generic | head -9
.
kernel
kernel/x86
kernel/x86/microcode
kernel/x86/microcode/AuthenticAMD.bin
kernel
kernel/x86
kernel/x86/microcode
kernel/x86/microcode/GenuineIntel.bin

Обратите внимание: два независимых cpio-архива склеены конкатенацией, поэтому . и kernel встречаются дважды. Это легальный формат — ядро читает их последовательно.

Legacy BIOS: 440 байт, в которые надо уложить жизнь

Разберём на живом примере. Машина ниже загружается через BIOS, диск размечен под MBR:

$ sudo fdisk -l /dev/sda
Disk /dev/sda: 240 GiB, 257698037760 bytes, 503316480 sectors
Disklabel type: dos
Disk identifier: 0xb3ed5c69

Device     Boot   Start       End   Sectors  Size Id Type
/dev/sda1  *       2048   4194303   4192256    2G 83 Linux
/dev/sda2       4194304 503316479 499122176  238G 83 Linux

Прочитаем первый сектор напрямую — это и есть весь «BIOS-загрузчик»:

$ sudo dd if=/dev/sda bs=512 count=1 | xxd | head -3
00000000: eb63 9010 8ed0 bc00 b0b8 0000 8ed8 8ec0  .c..............
00000010: fbbe 007c bf00 06b9 0002 f3a4 ea21 0600  ...|.........!..
00000020: 00be be07 3804 750b 83c6 1081 fefe 0775  ....8.u........u

$ sudo dd if=/dev/sda bs=512 count=1 | xxd | tail -5
00000180: 7de8 2e00 cd18 ebfe 4752 5542 2000 4765  }.......GRUB .Ge
00000190: 6f6d 0048 6172 6420 4469 736b 0052 6561  om.Hard Disk.Rea
000001a0: 6400 2045 7272 6f72 0d0a 00bb 0100 b40e  d. Error........
000001b0: cd10 ac3c 0075 f4c3 695c edb3 0000 8004  ...<.u..i\......
000001f0: 0000 0000 0000 0000 0000 0000 0000 55aa  ..............U.

Читается почти как исходник. eb 63 — короткий jmp +0x63 через область данных. be 00 7c bf 00 06 b9 00 02 f3 a4 — классика: mov si, 0x7C00; mov di, 0x0600; mov cx, 0x200; rep movsb, то есть код переносит сам себя с 0x7C00 на 0x0600, чтобы освободить 0x7C00 для следующей стадии. Дальше ea 21 06 00 00 — дальний прыжок на новую копию. В конце — три строки на весь запас диагностики: GRUB, Geom, Hard Disk, Read, Error. Если вы когда-нибудь видели загадочное GRUB Read Error без единой подробности — вот почему: на подробности не было байтов. И финальные 55 aa.

Байты 80 04 01 04 83 fe c2 ff ... начиная с 0x1BE — таблица разделов: 80 = активный, дальше CHS-адрес (мёртвая архаика), 83 = тип «Linux».

Пространственная картина того, куда всё это ложится, и почему без переключения режимов дальше не уехать:

Карта первого мегабайта памяти в момент передачи управления MBR и лестница режимов x86

Здесь спрятана суть проблемы: ядро весит 15 МиБ, а адресуемой памяти в реальном режиме — один мегабайт, из которого свободно около 630 КиБ. Поэтому загрузчик обязан:

  1. поднять линию A20 (исторический костыль совместимости с 8086);
  2. построить GDT и переключиться в защищённый режим;
  3. читать ядро с диска через int 0x13 в реальном режиме кусками, каждый раз возвращаясь назад в реальный режим и обратно (unreal mode или переключения туда-сюда);
  4. собрать boot_params и прыгнуть в точку входа ядра.

Отсюда трёхстадийная архитектура GRUB. boot.img — те самые 440 байт, знающие только LBA следующей стадии. core.img — 25–30 КиБ, лежит в MBR gap (сектора 1–2047), содержит модули для чтения ext4, LVM, RAID, зашифрованных томов. И только потом GRUB может открыть файл /boot/grub/grub.cfg как файл.

Типичная авария. MBR gap ничем не защищён — это просто «ничья» область диска. Если разметить диск с первым разделом на секторе 1 (старые утилиты так делали) или поставить поверх что-то, использующее начало диска, core.img затирается. Симптом: error: unknown filesystem и приглашение grub rescue>. Именно поэтому современные утилиты выравнивают первый раздел на 1 МиБ (сектор 2048) — заодно это даёт правильное выравнивание под SSD.

MBR против GPT: не только про размер диска

MBR-таблица родилась в 1983 году в PC DOS 2.0 и несёт всю тяжесть возраста: четыре раздела, 32-битные номера секторов, никаких контрольных сумм. Ограничение в 2 ТиБ — прямое следствие 2^32 × 512 байт. «Расширенные разделы» — надстройка-костыль: один из четырёх слотов объявляется контейнером и содержит связный список описателей, что делает операции над ними хрупкими.

GPT из спецификации UEFI решает всё это по-инженерному аккуратно:

Раскладка диска: MBR против GPT с зеркалом метаданных в конце диска

# Посмотреть GPT (на GPT-диске)
$ sudo gdisk -l /dev/nvme0n1
Partition table scan:
  MBR: protective
  GPT: present

Number  Start (sector)    End (sector)  Size       Code  Name
   1            2048         1050623   512.0 MiB   EF00  EFI system partition
   2         1050624      1000214527   476.4 GiB   8300  Linux filesystem

# Типы разделов — это GUID, а не байт. Полезные:
#   EF00 C12A7328-F81F-11D2-BA4B-00A0C93EC93B  EFI System Partition
#   EF02 21686148-6449-6E6F-744E-656564454649  BIOS boot (для core.img на GPT!)
#   8304 4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709  Linux x86-64 root (для discoverable partitions)

Три вещи, которые стоит запомнить практику:

Protective MBR. В LBA 0 GPT-диска лежит фальшивый MBR с одним разделом типа 0xEE, занимающим весь диск. Это не совместимость, а защита: старая утилита увидит «диск целиком занят неизвестным разделом» и не станет его радостно переразмечать.

Backup GPT в конце диска. Красиво и надёжно ровно до момента, когда вы делаете dd if=disk.img of=/dev/sdb на диск другого размера или расширяете виртуальный диск. Зеркало оказывается не в конце — GPT считается повреждённой. Лечится одной командой:

$ sudo sgdisk -e /dev/sda       # move backup GPT to end of disk
$ sudo partprobe /dev/sda

BIOS boot partition. На GPT-диске нет MBR gap — сразу за таблицей начинаются разделы. Поэтому для BIOS-загрузки с GPT нужен отдельный крошечный раздел типа EF02 (1 МиБ), куда GRUB положит core.img. Забыть про него — классическая ошибка при ручной разметке: grub-install честно скажет embedding is not possible... GRUB can only be installed in this setup by using blocklists.

Отдельно стоит знать про Discoverable Partitions Specification — соглашение systemd, по которому GUID типа раздела однозначно определяет точку монтирования. Раздел с GUID 4f68bce3-… монтируется как / без всякого fstab, 933ac7e1-… — как /home. Это то, на чём строятся современные иммутабельные дистрибутивы и образы для облака: uapi-group.org/specifications/specs/discoverable_partitions_specification.

UEFI: прошивка как маленькая операционная система

UEFI (наследник Intel EFI из эпохи Itanium, ныне управляется UEFI Forum) устроен принципиально иначе. Это не «BIOS с мышкой», а платформенный фреймворк с собственным ABI, драйверной моделью и системой сервисов.

Что даёт UEFI загрузчику:

  • Boot Services — доступны до вызова ExitBootServices(): аллокация памяти, загрузка образов, работа с протоколами, файловый ввод-вывод, таймеры. Это среда исполнения.
  • Runtime Services — остаются доступны ядру после загрузки: чтение и запись NVRAM-переменных, часы реального времени, ResetSystem(). Linux использует их через efivarfs.
  • Протоколы — интерфейсы в стиле COM: SIMPLE_FILE_SYSTEM_PROTOCOL, GRAPHICS_OUTPUT_PROTOCOL (тот самый GOP, из которого получается фреймбуфер), LOADED_IMAGE_PROTOCOL.

Порядок загрузки живёт не на диске, а в NVRAM материнской платы:

$ efibootmgr -v
BootCurrent: 0000
Timeout: 1 seconds
BootOrder: 0000,0002,0001
Boot0000* ubuntu    HD(1,GPT,8f2a...,0x800,0x100000)/File(\EFI\ubuntu\shimx64.efi)
Boot0001* UEFI: PXEv4 (MAC:001A2B3C4D5E)  PciRoot(0x0)/Pci(0x1c,0x4)/MAC(001a2b3c4d5e,0)
Boot0002* Windows Boot Manager  HD(1,GPT,8f2a...)/File(\EFI\Microsoft\Boot\bootmgfw.efi)

# Добавить свою запись руками
$ sudo efibootmgr --create --disk /dev/sda --part 1 \
    --label "My Kernel" --loader '\EFI\custom\vmlinuz.efi' \
    --unicode 'root=UUID=c219... ro quiet'

# Загрузиться ОДИН раз с другой записи (бесценно при отладке ядра по SSH)
$ sudo efibootmgr --bootnext 0002 && sudo reboot

Переменные видны как файлы:

$ ls /sys/firmware/efi/efivars/ | head -4
BootCurrent-8be4df61-93ca-11d2-aa0d-00e098032b8c
BootOrder-8be4df61-93ca-11d2-aa0d-00e098032b8c
SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c
dbx-d719b2cb-3d3a-4596-a3bc-dad00e67656f

# Первые 4 байта — атрибуты, дальше значение
$ od -An -t u1 /sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c
    6   0   0   0   1
#                   ^ Secure Boot включён

Осторожно. Файлы в efivars имеют атрибут immutable и защищены не просто так: на некоторых прошивках (печально известный баг с rm -rf / на Samsung и MSI в 2016 году) удаление переменных превращает материнскую плату в кирпич. Никогда не делайте рекурсивных операций по /sys/firmware/efi/efivars/.

Проверить, в каком режиме загружена текущая система, — одна строка:

$ [ -d /sys/firmware/efi ] && echo "UEFI" || echo "Legacy BIOS"
Legacy BIOS
$ cat /sys/firmware/efi/fw_platform_size    # 64 или 32 — разрядность прошивки, не ядра!

Разрядность прошивки — отдельная ловушка: существуют планшеты с 64-битным CPU и 32-битным UEFI, где нужен bootia32.efi. И обратное правило: 32-битная прошивка не может запустить 64-битный EFI-образ, режимы должны совпадать.

Secure Boot и measured boot: две разные гарантии

Их постоянно путают, хотя они отвечают на разные вопросы.

Secure Boot отвечает на вопрос «можно ли это запускать?» и работает как политика: прошивка проверяет цифровую подпись каждого исполняемого образа по базам ключей в NVRAM — PK (Platform Key, владелец платформы), KEK (кто может обновлять базы), db (разрешённые подписи и хэши), dbx (явно отозванные). Не прошёл проверку — не запустился.

Measured boot отвечает на вопрос «что именно было запущено?» и ничего не блокирует. Каждый компонент перед передачей управления считает хэш следующего и «расширяет» им регистр TPM: PCR[n] = SHA256(PCR[n] || hash). Операция необратима и не позволяет подделать промежуточное состояние. Потом ключ шифрования диска можно запечатать (seal) на конкретные значения PCR — TPM отдаст его только если система загрузилась ровно так же.

Почему в цепочке есть shim? Ключ Microsoft есть в db практически любой материнской платы, а получить туда ключ каждого дистрибутива нереально. Поэтому Canonical, Red Hat, SUSE подписывают у Microsoft маленькую программу-прокладку shim, а уже она проверяет следующий компонент своим ключом или пользовательским из MOK (Machine Owner Key).

Практический сценарий, который встречает почти каждый: вы собрали out-of-tree модуль (NVIDIA, VirtualBox, DKMS), и он не грузится с Required key not available.

# Создать собственный ключ и зарегистрировать в MOK
$ openssl req -new -x509 -newkey rsa:2048 -keyout MOK.priv -outform DER \
    -out MOK.der -nodes -days 3650 -subj "/CN=my module signing key/"
$ sudo mokutil --import MOK.der          # спросит пароль, применится при следующей перезагрузке
$ sudo /usr/src/linux-headers-$(uname -r)/scripts/sign-file sha256 MOK.priv MOK.der my_module.ko
$ mokutil --sb-state
SecureBoot enabled

Ещё один важный побочный эффект Secure Boot в Linux — lockdown mode. Ядро, загруженное с включённым Secure Boot, ограничивает даже root: запрещены /dev/mem, kexec без подписи, запись в MSR, некоторые kprobe. Это регулярно ломает профилировщики и отладчики; проверить можно так:

$ cat /sys/kernel/security/lockdown
none [integrity] confidentiality

GRUB2: универсальный, монструозный, повсеместный

GRUB2 — не столько загрузчик, сколько миниатюрная ОС: у него свой шелл, скриптовый язык, модульная система (около 250 модулей), драйверы полутора десятков файловых систем, LVM, mdraid, LUKS, сети и даже поддержка графических тем.

Разберём реальный menuentry с нашей машины:

menuentry 'Ubuntu' --class ubuntu --class gnu-linux --class os $menuentry_id_option 'gnulinux-simple-082bd3af-…' {
	recordfail
	load_video
	gfxmode $linux_gfx_mode
	insmod gzio
	insmod part_msdos
	insmod ext2
	set root='hd0,msdos1'
	search --no-floppy --fs-uuid --set=root --hint-bios=hd0,msdos1 c21942c4-feaa-4713-98f0-32266838934b
	linux	/vmlinuz-6.8.0-136-generic root=/dev/mapper/vg68719-root ro
	initrd	/initrd.img-6.8.0-136-generic
}

Читается построчно как программа. insmod part_msdos и insmod ext2 — GRUB подгружает собственные драйверы (модуль ext2 обслуживает и ext3/ext4). search --fs-uuid — вместо хрупкого hd0,msdos1 находит раздел по UUID файловой системы: диски могут переименоваться при добавлении контроллера, UUID — нет. linux загружает ядро и формирует boot_params, initrd — грузит образ в память и записывает его адрес туда же.

Важнее всего понимать два уровня «root», которые постоянно путают:

  • set root='hd0,msdos1' — это корень для GRUB, раздел, откуда он читает файлы. У нас это отдельный /boot (/dev/sda1), поэтому путь к ядру — /vmlinuz-…, а не /boot/vmlinuz-….
  • root=/dev/mapper/vg68719-root в строке linux — параметр для ядра, где искать настоящую корневую ФС. Ядро само это устройство собрать не может (это LVM) — этим займётся initramfs.

Никогда не редактируйте /boot/grub/grub.cfg руками. Он генерируется:

# Что реально настраивают:
$ cat /etc/default/grub
GRUB_DEFAULT=0
GRUB_TIMEOUT=5
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"
GRUB_CMDLINE_LINUX=""
GRUB_DISABLE_OS_PROBER=false

# Скрипты-генераторы, выполняются по порядку номеров:
$ ls /etc/grub.d/
00_header  05_debian_theme  10_linux  20_linux_xen  30_os-prober  40_custom  41_custom

$ sudo update-grub          # Debian/Ubuntu, обёртка над grub-mkconfig
$ sudo grub-mkconfig -o /boot/grub/grub.cfg    # везде остальное
$ sudo grub2-mkconfig -o /boot/grub2/grub.cfg  # RHEL/Fedora, другое имя каталога

Установка стадий на диск — отдельная операция, её забывают после замены диска:

$ sudo grub-install /dev/sda                       # BIOS: пишет boot.img в MBR, core.img в gap
$ sudo grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=ubuntu

Аварийный режим. Если система не грузится, в меню GRUB нажмите e — вы попадёте в редактор строки запуска, изменения одноразовые. Или c — полноценная консоль:

grub> ls                                  # (hd0) (hd0,msdos1) (hd0,msdos2)
grub> ls (hd0,msdos1)/                    # посмотреть содержимое
grub> set root=(hd0,msdos1)
grub> linux /vmlinuz-6.8.0-136-generic root=/dev/mapper/vg68719-root ro
grub> initrd /initrd.img-6.8.0-136-generic
grub> boot

Этих пяти команд достаточно, чтобы поднять систему с испорченным grub.cfg. Стоит запомнить.

systemd-boot, UKI и минимализм

Против «GRUB как вторая ОС» есть противоположная философия: если UEFI уже умеет читать FAT32 и запускать PE-образы, зачем нужен большой загрузчик? Ядро Linux с 2011 года умеет собираться как EFI stub — то есть vmlinuz сам является валидным EFI-приложением, и прошивка может запустить его напрямую.

systemd-boot (бывший gummiboot) — это ~40 КиБ кода, единственная задача которых: показать меню и запустить выбранный образ. Никаких драйверов ФС — всё читается сервисами UEFI.

$ sudo bootctl install         # ставит systemd-bootx64.efi и создаёт запись в NVRAM
$ bootctl status
System:
     Firmware: UEFI 2.70 (American Megatrends 5.17)
  Secure Boot: enabled (user)
 Boot into FW: supported
Current Boot Loader:
      Product: systemd-boot 255.4

# Конфигурация — плоские текстовые файлы, никакой генерации:
$ cat /boot/efi/loader/loader.conf
default  arch.conf
timeout  3
console-mode max

$ cat /boot/efi/loader/entries/arch.conf
title   Arch Linux
linux   /vmlinuz-linux
initrd  /intel-ucode.img
initrd  /initramfs-linux.img
options root=UUID=b2c3... rw quiet

Формат loader/entries/*.conf — это Boot Loader Specification (BLS), общий стандарт; его же понимают GRUB в Fedora и rEFInd. Смысл: добавление ядра — это создание текстового файла, а не запуск генератора конфигов.

Unified Kernel Image (UKI) — следующий шаг и то, куда идёт индустрия. Ядро, initramfs, командная строка, микрокод и splash-картинка склеиваются в один PE-файл, который подписывается целиком:

$ ukify build --linux=/boot/vmlinuz-6.8.0 --initrd=/boot/initrd.img-6.8.0 \
    --cmdline="root=PARTUUID=… ro quiet" \
    --signtool=sbsign --secureboot-private-key=db.key --secureboot-certificate=db.crt \
    --output=/boot/efi/EFI/Linux/linux-6.8.0.efi

# Секции внутри PE:
$ objdump -h linux-6.8.0.efi | grep -E '\.linux|\.initrd|\.cmdline|\.osrel'
  .osrel   .cmdline   .initrd   .linux   .uname

Зачем это нужно на практике: при обычной схеме подписывается только ядро, а initramfs и cmdline не подписаны — атакующий с доступом к ESP может подменить initramfs и получить ключи от LUKS. UKI закрывает эту дыру: подпись покрывает всё сразу, и хэш измеряется в PCR11, на который удобно запечатывать ключи TPM. Это фундамент systemd-cryptenroll с TPM и иммутабельных систем вроде Fedora Silverblue и Flatcar.

Стоит знать и других игроков: rEFInd (удобный графический менеджер с автопоиском ОС), Limine (популярен в хобби-разработке ОС), Petitboot (загрузчик на базе Linux+kexec в POWER-серверах), iPXE (сетевая загрузка по HTTP/iSCSI), U-Boot (де-факто стандарт в embedded ARM, со своим bootcmd и device tree).

Как загрузчик разговаривает с ядром Linux

Между «загрузчик прочитал файл» и «ядро запустилось» лежит документированный протокол: Documentation/arch/x86/boot.rst.

Файл bzImage — не просто сжатое ядро, а составной образ:

Часть Что это
boot sector 512 байт, при прямом запуске с дискеты печатают «use a boot loader»
setup код реального режима, setup_header со смещения 0x1F1
payload сжатый vmlinux + декомпрессор arch/x86/boot/compressed/

setup_header — это ABI. Загрузчик заполняет структуру boot_params (ту самую «zero page»): адрес и размер initrd (ramdisk_image, ramdisk_size), указатель на командную строку (cmd_line_ptr), карту памяти от BIOS/UEFI (e820_table), параметры видеорежима. Дальше прыгает в точку входа. Посмотреть заголовок можно инструментом из дерева ядра:

$ file /boot/vmlinuz-6.8.0-136-generic
Linux kernel x86 boot executable bzImage, version 6.8.0-136-generic (buildd@lcy02-amd64),
RO-rootFS, swap_dev 0XA, Normal VGA

$ extract-vmlinux /boot/vmlinuz-6.8.0-136-generic > /tmp/vmlinux   # из scripts/ ядра
$ file /tmp/vmlinux
/tmp/vmlinux: ELF 64-bit LSB executable, x86-64, statically linked, BuildID[sha1]=

Дальше внутри ядра порядок такой: код в arch/x86/boot/compressed/head_64.S включает long mode, применяет KASLR (сдвигает ядро на случайный адрес), распаковывает vmlinux и передаёт управление в start_kernel() (init/main.c). Оттуда — инициализация планировщика, памяти, драйверов, и в конце rest_init() создаёт поток, который выполняет kernel_init()run_init_process(). Именно эта функция ищет /init:

/* init/main.c, упрощённо: порядок поиска первого пользовательского процесса */
if (ramdisk_execute_command) {              /* обычно "/init" из initramfs */
        ret = run_init_process(ramdisk_execute_command);
        if (!ret) return 0;
}
if (execute_command) {                      /* если передали init=... в cmdline */
        ret = run_init_process(execute_command);
        if (!ret) return 0;
        panic("Requested init %s failed (error %d).", execute_command, ret);
}
/* Дальше — исторический список кандидатов */
if (!try_to_run_init_process("/sbin/init") ||
    !try_to_run_init_process("/etc/init")  ||
    !try_to_run_init_process("/bin/init")  ||
    !try_to_run_init_process("/bin/sh"))
        return 0;
panic("No working init found.");

Эта функция объясняет два известных трюка: init=/bin/bash в cmdline даёт корневой шелл без пароля (почему физический доступ к машине без шифрования диска = полный доступ), а panic: No working init found означает, что корень смонтирован не тот или не смонтирован вообще.

initramfs: решение проблемы курицы и яйца

Ядро должно смонтировать корневую файловую систему. Но она может лежать на LVM поверх mdraid поверх LUKS поверх NVMe, для которого нужен модуль драйвера, — а модули лежат в /lib/modules, то есть на той самой ещё не смонтированной ФС.

Решение: загрузчик кладёт в память архив с минимальной пользовательской средой. Ядро распаковывает его в tmpfs (это initramfs; старый initrd был именно образом блочного устройства и работал иначе) и запускает оттуда /init. Тот делает всё сложное — собирает RAID, спрашивает пароль LUKS, активирует LVM, ждёт появления устройств — и только потом отдаёт управление настоящей системе.

Посмотрим на реальный образ:

$ ls -lh /boot/initrd.img-6.8.0-136-generic
-rw-r--r-- 1 root root 83M  initrd.img-6.8.0-136-generic

$ lsinitramfs /boot/initrd.img-6.8.0-136-generic | wc -l
2139

$ sudo unmkinitramfs /boot/initrd.img-6.8.0-136-generic /tmp/ir && ls /tmp/ir
early  early2  early3  main

Четыре секции: три несжатых early-cpio (микрокод AMD, микрокод Intel, ACPI-таблицы) и основной архив, сжатый zstd. 83 МиБ — это много, и вот почему:

$ cat /etc/initramfs-tools/initramfs.conf
MODULES=most        # <- вот причина: "все драйверы дисков и ФС", а не только нужные
BUSYBOX=auto
COMPRESS=zstd
RUNSIZE=10%
FSTYPE=auto

MODULES=most — стратегия дистрибутива: образ должен грузиться на любом железе, потому что диск могут переставить в другую машину. Для собственного образа под конкретную виртуалку меняйте на MODULES=dep — размер падает до 10–15 МиБ, а время загрузки заметно сокращается (меньше распаковывать и меньше modprobe). Расплата: перенесёте образ на другое железо — не загрузится.

Внутри main — привычная иерархия и скриптовый каркас:

$ ls /tmp/ir/main/scripts/
functions  local  nfs
init-top/  init-premount/  init-bottom/
local-top/ local-block/ local-premount/ local-bottom/
panic/

Жизненный цикл /init из initramfs-tools удобно смотреть как автомат состояний — именно в эти точки вставляются хуки, и именно на них система обычно и виснет:

Финальный шаг заслуживает отдельного разбора, потому что вокруг него много путаницы:

# Примерно то, чем заканчивается /init:
mount -n -o move /sys  ${rootmnt}/sys
mount -n -o move /proc ${rootmnt}/proc
mount -n -o move /dev  ${rootmnt}/dev
exec switch_root ${rootmnt} ${init} "$@"

switch_root не является chroot. Он: (1) удаляет всё содержимое текущего rootfs, освобождая память tmpfs, (2) делает mount --move нового корня в /, (3) chroot ., (4) exec нового init — сохраняя PID 1. Отличие от pivot_root: тот сохраняет старый корень доступным в подкаталоге и используется в контейнерах, тогда как switch_root рассчитан именно на одноразовый initramfs и требует, чтобы вызывающий был PID 1 на rootfs. Ключевое слово exec критично: без него шелл остался бы PID 1 и всё бы сломалось.

Отладка initramfs — самый полезный навык в этой статье

# Остановиться в конкретной фазе (initramfs-tools): добавить в cmdline GRUB
break=premount        # также: top, modules, mount, mountroot, bottom, init
# Получите (initramfs) busybox-шелл ровно перед выбранным этапом:
(initramfs) cat /proc/cmdline
(initramfs) ls /dev/mapper/
(initramfs) lvm vgchange -ay
(initramfs) blkid
(initramfs) modprobe virtio_blk
(initramfs) exit          # продолжить загрузку

# В dracut синтаксис другой:
rd.break=pre-mount rd.debug rd.udev.debug

# Убрать quiet, чтобы видеть всё:
#   в GRUB нажать 'e', удалить "quiet splash", добавить:
#   loglevel=7 console=ttyS0,115200 earlyprintk=serial systemd.log_level=debug

Три инструмента initramfs в дикой природе стоит различать:

initramfs-tools dracut mkinitcpio
Где Debian, Ubuntu RHEL, Fedora, SUSE, Gentoo Arch
Конфиг /etc/initramfs-tools/ /etc/dracut.conf.d/ /etc/mkinitcpio.conf
Расширение shell-скрипты в hooks/, scripts/ модули в /usr/lib/dracut/modules.d/ HOOKS=(base udev …)
Внутри busybox или klibc + свои скрипты часто systemd в initramfs busybox / systemd
Пересборка update-initramfs -u -k all dracut -f --kver … mkinitcpio -P

Отдельно отметьте строку про systemd: dracut по умолчанию запускает в initramfs полноценный systemd со своими юнитами и .mount-зависимостями. Это даёт параллелизм и нормальные таймауты вместо sleep в цикле, но переносит в раннюю фазу всю сложность юнит-системы. Подробнее — в статье про системы инициализации.

Как это устроено в других семействах ОС

Различия здесь не косметические — они отражают разную философию.

FreeBSD. Классическая многостадийная схема, но идеологически иная: последняя стадия — /boot/loader, полноценный интерпретатор Forth (FICL), а с FreeBSD 12 — альтернативно Lua. Загрузчик умеет читать UFS и ZFS, включая boot environments — снимки корня, между которыми можно переключаться прямо в меню загрузки. Настройка декларативна:

# /boot/loader.conf
zfs_load="YES"
vfs.root.mountfrom="zfs:zroot/ROOT/default"
kern.ipc.shmseg=1024
hw.vmm.topology.threads_per_core=2

# В работающей системе те же переменные видны через kenv(1):
$ kenv | grep smbios.system.product
smbios.system.product="VirtualBox"

Ключевое отличие от Linux: FreeBSD обычно не использует initramfs. Драйверы либо в ядре, либо loader подгружает .ko-модули сам, ещё до старта ядра. Модель проще, но требует, чтобы загрузчик знал вашу файловую систему.

OpenBSD. Демонстративный минимализм: biosboot (первая стадия) → /boot (вторая) → ядро. Загрузчик читает FFS и понимает единственный конфиг /etc/boot.conf. Ничего похожего на модули и скрипты. Зато OpenBSD раньше всех сделала обязательным KARL — Kernel Address Randomized Link: при каждой загрузке ядро пересобирается из объектных файлов в случайном порядке, так что бинарь физически уникален на каждой машине и после каждого reboot. Плюс bsd.rd — ядро с встроенным ramdisk и установщиком, аналог rescue-режима.

NetBSD. Похожая двухстадийная схема, но с акцентом на переносимость: один и тот же код bootxx адаптирован под десятки архитектур, а boot.cfg даёт меню. Уникальная черта — rump kernels, позволяющие запускать драйверы NetBSD как обычные пользовательские процессы, что сильно меняет представление о том, что должно быть «в ядре при загрузке».

macOS и XNU. На Intel: UEFI → boot.efiprelinkedkernel / kernelcache — ядро с уже слинкованными kext’ами, чтобы не искать драйверы на диске. На Apple Silicon схема принципиально другая и ближе к iOS: Boot ROM → LLBiBoot → kernelcache, вся цепочка проверяется Secure Enclave, а «уровень безопасности» выбирается в recoveryOS (Full / Reduced / Permissive). Конфигурация — не текстовый файл, а NVRAM:

$ nvram boot-args                      # параметры ядра
$ sudo nvram boot-args="-v keepsyms=1" # -v = verbose boot вместо яблока
$ bputil -d                            # Apple Silicon: показать политику безопасности

Windows NT. UEFI → bootmgfw.efi (Boot Manager) → winload.efintoskrnl.exe. Конфигурация лежит в BCD — двоичном хранилище в формате куста реестра, редактируется только утилитой:

bcdedit /enum                              # показать все записи
bcdedit /set {current} safeboot minimal    # следующая загрузка в safe mode
bcdedit /set {current} testsigning on      # разрешить неподписанные драйверы (для разработки)
bcdedit /timeout 5

Две особенности, о которые спотыкаются при dual-boot. Первая — Fast Startup: «выключение» в Windows по умолчанию не выключение, а гибернация ядра в hiberfil.sys. Из-за этого NTFS остаётся в «грязном» состоянии, и Linux монтирует раздел только для чтения либо портит его при записи. Отключается: powercfg /h off. Вторая — ELAM (Early Launch Anti-Malware): специальный драйвер антивируса стартует раньше всех прочих, что делает порядок инициализации драйверов в NT более жёстко заданным, чем в Linux.

Сводная таблица:

Linux FreeBSD OpenBSD macOS (ARM) Windows NT
Загрузчик GRUB2 / systemd-boot / EFI stub /boot/loader (Forth/Lua) /boot iBoot bootmgfw.efi
Конфиг grub.cfg / BLS / NVRAM loader.conf boot.conf NVRAM BCD (двоичный)
Ранний userspace initramfs почти всегда обычно нет нет нет нет
Драйверы для корня модули в initramfs .ko грузит loader вкомпилированы kernelcache boot-критичные в реестре
Проверка подписи Secure Boot + shim + MOK опционально нет по умолчанию обязательна, Secure Enclave Secure Boot обязателен

Загрузка в облаке и виртуализации

Внутри виртуальной машины та же цепочка, но прошивка — программная: SeaBIOS для legacy, OVMF/edk2 для UEFI. Это открывает трюки, недоступные на железе.

Прямая загрузка ядра. QEMU умеет полностью пропустить загрузчик:

qemu-system-x86_64 -m 2G -enable-kvm \
  -kernel /boot/vmlinuz-6.8.0 \
  -initrd /boot/initrd.img-6.8.0 \
  -append "root=/dev/vda1 console=ttyS0 rw" \
  -drive file=disk.qcow2,if=virtio -nographic

QEMU сам заполняет boot_params и прыгает в ядро. Это стандарт для разработки ядра и для микро-VM (Firecracker, Cloud Hypervisor вообще не имеют BIOS — только прямой boot, что даёт время старта в 100–150 мс).

Сетевая загрузка. PXE: DHCP отдаёт next-server и имя файла, клиент тянет его по TFTP. Современнее — HTTP Boot из UEFI 2.5 и iPXE, которые качают образ по HTTP(S) и умеют скриптоваться. Диагностика начинается с dhcp-boot в конфиге и tcpdump -i eth0 port 67 or 69.

Конфигурация первой загрузки. cloud-init (читает метаданные с 169.254.169.254 или с ISO с меткой cidata) и Ignition (Fedora CoreOS, работает в initramfs, до первого запуска systemd — поэтому может переразметить диски). На нашей машине cloud-init виден прямо в критической цепочке загрузки:

$ systemd-analyze critical-chain | head -12
graphical.target @7.284s
└─multi-user.target @7.284s
  └─mysql.service @3.299s +1.307s
    └─network.target @3.277s
      └─NetworkManager.service @2.478s +798ms
        └─dbus.service @2.379s +80ms
          └─basic.target @2.370s
            └─sysinit.target @2.326s
              └─cloud-init-local.service @1.876s +448ms
                └─systemd-remount-fs.service @397ms +46ms

kexec — загрузка без прошивки вообще. Ядро может загрузить другое ядро напрямую, минуя BIOS/UEFI, POST и весь discovery. Экономит десятки секунд на серверах с долгой инициализацией RAID-контроллеров:

$ sudo kexec -l /boot/vmlinuz-6.8.0-136-generic \
    --initrd=/boot/initrd.img-6.8.0-136-generic \
    --reuse-cmdline
$ sudo systemctl kexec        # корректно остановить сервисы и прыгнуть

На этом же механизме построен kdump: заранее загруженное «аварийное» ядро в зарезервированной памяти (crashkernel=512M в cmdline), которое стартует при панике и сохраняет дамп предыдущего.

Типичные заблуждения

«UEFI требует GPT, BIOS требует MBR». Неверно в обе стороны. UEFI по спецификации обязан поддерживать и MBR-диски; загрузиться в BIOS-режиме с GPT тоже можно — нужен лишь раздел EF02. Связка популярна, но не обязательна. А вот Windows действительно требует GPT для UEFI-загрузки — это ограничение Windows, а не UEFI.

«Secure Boot защищает от вирусов». Он проверяет только цепочку до старта ядра. Malware в userspace, вредоносный контейнер, скомпрометированная зависимость в вашем npm-пакете — всё это Secure Boot не видит вообще.

«initrd и initramfs — одно и то же». Разные механизмы: initrd — образ блочного устройства, монтируется через /dev/ram0, требует драйвер ФС в ядре и использует pivot_root. initramfs — cpio-архив, распаковывается в tmpfs, использует switch_root, память освобождается полностью. Файл называется initrd.img по историческим причинам — внутри почти всегда initramfs.

«Ядро монтирует корень само». С initramfs — нет. Корень монтирует /init в пользовательском пространстве. Ядро умеет монтировать корень напрямую (root=/dev/sda1 без initrd), но только если драйвер и ФС вкомпилированы, что в дистрибутивных ядрах не так.

«/boot/grub/grub.cfg — конфиг, который надо править». Он генерируется и будет затёрт следующим update-grub — например, при установке обновления ядра. Правьте /etc/default/grub и /etc/grub.d/40_custom.

«systemd-analyze blame показывает, что тормозит загрузку». Не совсем: он показывает длительность юнитов, включая те, что стартовали параллельно и ни на что не влияли. На нашей машине первая строка — snapd.service с 11 часами, потому что это долгоживущий сервис. Смотреть надо critical-chain, где видна именно цепочка зависимостей.

«Медленная загрузка — вина ядра». Реальный вывод той же машины:

$ systemd-analyze
Startup finished in 5.156s (kernel) + 7.743s (userspace) = 12.899s
graphical.target reached after 7.284s in userspace.

Ядро — 5 секунд, из которых бóльшая часть обычно уходит на распаковку 83-мегабайтного initramfs и probe устройств. Userspace — почти 8. Оптимизировать чаще всего нужно второе.

Практика: что делать, когда не грузится

Диагностический алгоритм, который стоит держать в голове:

  1. Экран пустой, никакого меню. Проблема до загрузчика: не найден загрузочный носитель, не та запись в BootOrder, испорчен MBR/core.img. Загрузитесь с LiveUSB, проверьте efibootmgr -v или переустановите grub-install.
  2. grub rescue> вместо меню. core.img жив, но не нашёл /boot/grub. Ищем раздел: ls, set prefix=(hd0,msdos1)/grub, set root=(hd0,msdos1), insmod normal, normal.
  3. Меню есть, ядро не стартует. Уберите quiet splash, добавьте console=ttyS0,115200, смотрите, где обрывается вывод.
  4. Kernel panic: VFS: Unable to mount root fs. Ядро загрузилось, корень не нашёлся. Почти всегда — нет драйвера в initramfs или неверный root=.
  5. Приглашение (initramfs). Вы уже в userspace. blkid, lvm vgchange -ay, cat /proc/cmdline — обычно всё видно за минуту.
  6. Emergency mode / you are in emergency shell. Корень смонтирован, systemd не смог выполнить fstab или юнит. journalctl -xb, systemctl --failed. Частая причина — строка в /etc/fstab для несуществующего устройства без опции nofail.

Универсальный ремонтный рецепт из LiveUSB:

$ sudo mount /dev/mapper/vg-root /mnt
$ sudo mount /dev/sda1 /mnt/boot
$ sudo mount /dev/sda2 /mnt/boot/efi          # если UEFI
$ for d in dev proc sys run; do sudo mount --bind /$d /mnt/$d; done
$ sudo chroot /mnt
# grub-install /dev/sda && update-grub && update-initramfs -u -k all

И небольшой набор для сокращения времени загрузки, проверенный на облачных образах:

# 1. Урезать initramfs под конкретное железо
$ sudo sed -i 's/^MODULES=most/MODULES=dep/' /etc/initramfs-tools/initramfs.conf
$ sudo update-initramfs -u -k all      # 83M → ~12M

# 2. Убрать таймаут меню GRUB (в облаке меню всё равно никто не видит)
$ sudo sed -i 's/^GRUB_TIMEOUT=.*/GRUB_TIMEOUT=0/' /etc/default/grub
$ echo 'GRUB_RECORDFAIL_TIMEOUT=0' | sudo tee -a /etc/default/grub
$ sudo update-grub

# 3. Найти реальных виновников в userspace
$ systemd-analyze critical-chain
$ systemd-analyze plot > boot.svg      # наглядная диаграмма Ганта всей загрузки
$ sudo systemctl disable NetworkManager-wait-online.service   # частый виновник +5–15 с

Мини-итог

  • Загрузка — это цепочка передач управления, где каждый уровень строит абстракции для следующего: сектор → загрузчик → ядро → userspace.
  • Legacy BIOS не умеет ничего, кроме чтения сектора, поэтому вся сложность в многостадийном загрузчике; UEFI — платформа с драйверами, поэтому загрузчик может быть тривиальным или отсутствовать вовсе (EFI stub, UKI).
  • MBR против GPT — это не только 2 ТиБ: GPT даёт CRC, зеркало метаданных, GUID типов и Discoverable Partitions, на которых строится современная иммутабельная инфраструктура.
  • Secure Boot отвечает «можно ли запускать», measured boot — «что именно было запущено». Второе гораздо интереснее для автоматической расшифровки дисков через TPM.
  • initramfs существует ровно затем, чтобы разорвать циклическую зависимость «драйвер корня лежит на корне». Ключевые навыки — break=, lsinitramfs, unmkinitramfs.
  • BSD, macOS и Windows решают те же задачи иначе: FreeBSD кладёт логику в Forth/Lua-загрузчик и обходится без initramfs, OpenBSD — в минимализм и KARL, Apple — в аппаратную цепочку доверия, Microsoft — в двоичный BCD и жёсткий порядок драйверов.
  • Измеряйте прежде чем оптимизировать: systemd-analyze critical-chain и systemd-analyze plot.

Источники и что читать дальше

Что дальше

Мы довели систему до момента, когда ядро выполняет exec первого настоящего пользовательского процесса. Всё, что происходит дальше — параллельный запуск сотен сервисов, управление зависимостями, сокет-активация, перезапуск упавших демонов, — это работа системы инициализации, и подходы здесь различаются радикально: от 200 строк shell-скриптов в runit до многокомпонентного systemd и rc.d во FreeBSD.

Системы инициализации: SysV, systemd, runit, OpenRC, launchd, rc.d в BSD

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов