42 KiB
Процесс загрузки ядра. Часть 1.
От загрузчика к ядру
От автора:
Если вы читали предыдущие статьи моего блога, то могли заметить, что некоторое время назад я начал увлекаться низкоуровневым программированием. Я написал несколько статей о программировании под x86_64 в Linux. В то же время я начал "погружаться" в исходный код Linux. Я имею большой интерес к пониманию того, как работают низкоуровневые вещи, как запускаются программы на моем компьютере, как они расположены в памяти, как ядро управляет процессами и памятью, как работает сетевой стек на низком уровне и многие другие вещи. Поэтому я решил написать ещё одну серию статей о ядре Linux для x86_64.
Замечу, что я не профессиональный хакер ядра и не пишу под него код на работе. Это просто хобби. Мне просто нравятся низкоуровневые вещи и мне интересно наблюдать за тем, как они работают. Так что, если вас что-то будет смущать или у вас появятся вопросы или замечания, пишите мне в твиттер 0xAX, присылайте письма на email или просто создавайте issue. Я ценю это.
Все статьи также будут доступны в репозитории Github, и, если вы обнаружите какую-нибудь ошибку в содержимом статьи, не стесняйтесь присылать pull request.
Заметьте, что это не официальная документация, а просто материал для обучения и обмена знаниями.
Требуемые знания
- Понимание кода на языке C
- Понимание кода на языке ассемблера (AT&T синтаксис)
В любом случае, если вы только начинаете изучать подобные инструменты, я постараюсь объяснить некоторые детали в этой и последующих частях. Ладно, простое введение закончилось, и теперь можно начать "погружение" в ядро и низкоуровневые вещи.
Я начал писать эту книгу, когда актуальной версией ядра Linux была 3.18, и с этого момента многое могло измениться. При возникновении изменений я буду соответствующим образом обновлять статьи.
Магическая кнопка включения, что происходит дальше?
Несмотря на то, что это серия статей о ядре Linux, мы не будем начинать непосредственно с его исходного кода (по крайней мере в этом параграфе). Как только вы нажмёте магическую кнопку включения на вашем ноутбуке или настольном компьютере, он начинает работать. Материнская плата посылает сигнал к источнику питания. После получения сигнала, источник питания обеспечивает компьютер надлежащим количеством электричества. После того как материнская плата получает сигнал "питание в норме" (Power good signal), она пытается запустить CPU. CPU сбрасывает все остаточные данные в регистрах и записывает предустановленные значения каждого из них.
CPU серии Intel 80386 и старше после перезапуска компьютера заполняют регистры следующими предустановленными значениями:
IP 0xfff0
CS selector 0xf000
CS base 0xffff0000
Процессор начинает свою работу в режиме реальных адресов. Давайте немного задержимся и попытаемся понять сегментацию памяти в этом режиме. Режим реальных адресов поддерживается всеми x86-совместимыми процессорами, от 8086 до самых новых 64-битных CPU Intel. Процессор 8086 имел 20-битную шину адреса, т.е. он мог работать с адресным пространством в диапазоне 0-0xFFFFF (1 мегабайт). Но регистры у него были только 16-битные, а в таком случае максимальный размер адресуемой памяти составляет 2^16 - 1
или 0xffff
(64 килобайта).
Сегментация памяти используется для того, чтобы задействовать всё доступное адресное пространство. Вся память делится на небольшие, фиксированного размера сегменты по 65536
байт (64 Кб) каждый. Поскольку мы не можем адресовать память свыше 64 Кб
с помощью 16-битных регистров, был придуман альтернативный метод.
Адрес состоит из двух частей: селектора сегмента, который содержит базовый адрес, и смещение от этого базового адреса. В режиме реальных адресов базовый адрес селектора сегмента это Селектор Сегмента * 16
. Таким образом, чтобы получить физический адрес в памяти, нужно умножить селектор сегмента на 16
и прибавить к нему смещение:
Физический адрес = Селектор сегмента * 16 + Смещение
Например, если CS:IP
содержит 0x2000:0x0010
, то соответствующий физический адрес будет:
>>> hex((0x2000 << 4) + 0x0010)
'0x20010'
Но если мы возьмём максимально доступный селектор сегментов и смещение: 0xffff:0xffff
, то итоговый адрес будет:
>>> hex((0xffff << 4) + 0xffff)
'0x10ffef'
что больше первого мегабайта на 65520 байт. Поскольку в режиме реальных адресов доступен только один мегабайт, с отключённой адресной линией A20 0x10ffef
становится 0x00ffef
.
Хорошо, теперь мы немного знаем о режиме реальных адресов и адресации памяти в этом режиме. Давайте вернёмся к обсуждению значений регистров после сброса.
Регистр CS
состоит из двух частей: видимый селектор сегмента и скрытый базовый адрес.
В то время как базовый адрес, как правило, формируется путём умножения значения селектора сегмента на 16, во время аппаратного перезапуска в селектор сегмента в регистре CS
записывается 0xf000
, а в базовый адрес - 0xffff0000
. Процессор использует этот специальный базовый адрес, пока регистр CS
не изменится.
Начальный адрес формируется путём добавления базового адреса к значению в регистре EIP
:
>>> 0xffff0000 + 0xfff0
'0xfffffff0'
Мы получили 0xfffffff0
, т.е. 16 байт ниже 4 Гб. По этому адресу располагается так называемый вектор прерываний. Это область памяти, в которой CPU ожидает найти первую инструкцию для выполнения после сброса. Она содержит инструкцию jump (jmp
), которая обычно указывает на точку входа в BIOS. Например, если мы взглянем на исходный код coreboot (src/cpu/x86/16bit/reset16.inc
), то увидим следующее:
.section ".reset"
.code16
.globl reset_vector
reset_vector:
.byte 0xe9
.int _start - ( . + 2 )
...
Здесь мы можем видеть опкод инструкции jmp - 0xe9
, и его адрес назначения _start - ( . + 2)
.
Мы также можем видеть, что секция reset
занимает 16
байт и начинается с 0xfffffff0
(src/cpu/x86/16bit/reset16.lds
):
SECTIONS {
_ROMTOP = 0xfffffff0;
. = _ROMTOP;
.reset . : {
*(.reset)
. = 15 ;
BYTE(0x00);
}
}
Теперь запускается BIOS; после инициализации и проверки оборудования, BIOS нужно найти загрузочное устройство. Порядок загрузки хранится в конфигурации BIOS, которая определяет, с каких устройств BIOS пытается загрузиться. При попытке загрузиться с жёсткого диска, BIOS пытается найти загрузочный сектор. На размеченных жёстких дисках со схемой разделов MBR, загрузочный сектор расположен в первых 446 байтах первого сектора, размер которого 512 байт. Последние два байта первого сектора - 0x55
и 0xaa
, которые оповещают BIOS о том, что устройство является загрузочным.
Например:
;
; Замечание: этот пример написан с использованием Intel синтаксиса
;
[BITS 16]
[ORG 0x7c00]
boot:
mov al, '!'
mov ah, 0x0e
mov bh, 0x00
mov bl, 0x07
int 0x10
jmp $
times 510-($-$$) db 0
db 0x55
db 0xaa
Собрать и запустить этот код можно таким образом:
nasm -f bin boot.nasm && qemu-system-x86_64 boot
Команда оповещает эмулятор QEMU о необходимости использовать в качестве образа диска созданный только что бинарный файл. Пока последний проверяет, удовлетворяет ли загрузочный сектор всем необходимым требованиям (в origin записывается 0x7c00
, а в конце магическая последовательность), QEMU будет работать с бинарным файлом как с главной загрузочной записью (MBR) образа диска.
Вы увидите:
В этом примере мы можем видеть, что код будет выполнен в 16-битном режиме реальных адресов и начнёт выполнение с адреса 0x7c00
. После запуска он вызывает прерывание 0x10, которое просто печатает символ !
. Оставшиеся 510 байт заполняются нулями, и код заканчивается двумя магическими байтами 0xaa
и 0x55
.
Вы можете увидеть бинарный дамп с помощью утилиты objdump
:
nasm -f bin boot.nasm
objdump -D -b binary -mi386 -Maddr16,data16,intel boot
Реальный загрузочный сектор имеет код для продолжения процесса загрузки и таблицу разделов вместо кучи нулей и восклицательного знака :) С этого момента BIOS передаёт управление загрузчику.
ЗАМЕЧАНИЕ: Как уже было упомянуто ранее, CPU находится в режиме реальных адресов; в этом режиме вычисление физического адреса в памяти выполняется следующим образом:
Физический адрес = Селектор сегмента * 16 + Смещение
так же, как было упомянуто выше. У нас есть только 16-битные регистры общего назначения; максимальное значение 16-битного регистра - 0xffff
. Поэтому, если мы возьмём максимальное возможное значение, то результат будет следующий:
>>> hex((0xffff * 16) + 0xffff)
'0x10ffef'
где 0x10ffef
равен 1 Мб + 64 Кб - 16 байт
. Процессор 8086 (который был первым процессором с режимом реальных адресов), в отличии от этого, имеет 20-битную шину адресации. Поскольку 2^20 = 1048576
это 1 Мб, получается, что фактический объём доступной памяти составляет 1 Мб.
Основная карта разделов в режиме реальных адресов выглядит следующим образом:
0x00000000 - 0x000003FF - Таблица векторов прерываний
0x00000400 - 0x000004FF - Данные BIOS
0x00000500 - 0x00007BFF - Не используется
0x00007C00 - 0x00007DFF - Наш загрузчик
0x00007E00 - 0x0009FFFF - Не используется
0x000A0000 - 0x000BFFFF - RAM (VRAM) видеопамять
0x000B0000 - 0x000B7777 - Память для монохромного видео
0x000B8000 - 0x000BFFFF - Память для цветного видео
0x000C0000 - 0x000C7FFF - BIOS ROM видеопамяти
0x000C8000 - 0x000EFFFF - Скрытая область BIOS
0x000F0000 - 0x000FFFFF - Системная BIOS
В начале статьи я написал, что первая инструкция, выполняемая CPU, расположена по адресу 0xFFFFFFF0
, значение которого намного больше, чем 0xFFFFF
(1 Мб). Каким образом CPU получает доступ к этому участку в режиме реальных адресов? Ответ на этот вопрос находится в документации coreboot:
0xFFFE_0000 - 0xFFFF_FFFF: 128 Кб ROM отображаются на адресное пространство
В начале выполнения BIOS находится не в RAM, а в ROM.
Загрузчик
Существует несколько загрузчиков Linux, такие как GRUB 2 и syslinux. Ядро Linux имеет протокол загрузки, который определяет требования к загрузчику для реализации поддержки Linux. В этом примере будет описан GRUB 2.
Теперь, когда BIOS выбрал загрузочное устройство и передал контроль управления коду в загрузочном секторе, начинается выполнение boot.img. Этот код очень простой в связи с ограниченным количеством свободного пространства и содержит указатель, который используется для перехода к основному образу GRUB 2. Основной образ начинается с diskboot.img, который обычно располагается сразу после первого сектора в неиспользуемой области перед первым разделом. Приведённый выше код загружает оставшуюся часть основного образа, который содержит ядро и драйверы GRUB 2 для управления файловой системой. После загрузки остальной части основного образа, код выполняет функция grub_main.
Функция grub_main
инициализирует консоль, получает базовый адрес для модулей, устанавливает корневое устройство, загружает/обрабатывает файл настроек grub, загружает модули и т.д. В конце выполнения, grub_main
переводит grub обратно в нормальный режим. Функция grub_normal_execute
(из grub-core/normal/main.c
) завершает последние приготовления и отображает меню выбора операционной системы. Когда мы выбираем один из пунктов меню, запускается функция grub_menu_execute_entry
, которая в свою очередь запускает команду grub boot
, загружающую выбранную операционную систему.
Из протокола загрузки видно, что загрузчик должен читать и заполнять некоторые поля в заголовке ядра, который начинается со смещения 0x01f1
в коде настроек. Вы можете посмотреть загрузочный скрипт компоновщика, чтобы убедиться в этом. Заголовок ядра arch/x86/boot/header.S начинается с:
.globl hdr
hdr:
setup_sects: .byte 0
root_flags: .word ROOT_RDONLY
syssize: .long 0
ram_size: .word 0
vid_mode: .word SVGA_MODE
root_dev: .word 0
boot_flag: .word 0xAA55
Загрузчик должен заполнить этот и другие заголовки (которые помеченные как тип write
в протоколе загрузки Linux, например, в данном примере) значениями, которые он получил из командной строки или значениями, вычисленными во время загрузки. (Мы не будет вдаваться в подробности и описывать все поля заголовка ядра, но, когда он будет их использовать, вернёмся к этому; тем не менее вы можете найти полное описание всех полей в протоколе загрузки.)
Как мы видим из протокола, после загрузки ядра карта распределения памяти будет выглядеть следующим образом:
| Ядро защищённого режима |
100000 +--------------------------+
| Память I/O |
0A0000 +--------------------------+
| Резерв для BIOS | Оставлен максимально допустимый размер
~ ~
| Командная строка | (Может быть ниже X+10000)
X+10000 +--------------------------+
| Стек/куча | Используется кодом ядра в режиме реальных адресов
X+08000 +--------------------------+
| Настройки ядра | Код ядра режима реальных адресов.
| Загрузочный сектор ядра | Унаследованный загрузочный сектор ядра.
X +--------------------------+
| Загрузчик | <- Точка входа загрузочного сектора 0x7C00
001000 +--------------------------+
| Резерв для MBR/BIOS |
000800 +--------------------------+
| Обычно используется MBR |
000600 +--------------------------+
| Используется только BIOS |
000000 +--------------------------+
Итак, когда загрузчик передаёт управление ядру, он запускается с:
X + sizeof(KernelBootSector) + 1
где X
- это адрес загруженного сектора загрузки ядра. В моем случае X
это 0x10000
, как мы можем видеть в дампе памяти:
Сейчас загрузчик поместил ядро Linux в память, заполнил поля заголовка, а затем переключился на него. Теперь мы можем перейти непосредственно к коду настройки ядра.
Запуск настройки ядра
Наконец, мы находимся в ядре! Технически, ядро ещё не работает; во-первых, часть ядра, отвественная за настройку, должна подготовить такие вещи как декомпрессор, вещи связанные с управлением памятью и т.д. После всех подготовок код настройки ядра должен распаковывать фактическое ядро и совершить переход на него. Выполнение настройки начинается в файле arch/x86/boot/header.S, начиная с метки _start. Это немного странно на первый взгляд, так как перед этим есть ещё несколько инструкций.
Давным-давно у Linux был свой загрузчик, но сейчас, если вы запустите, например:
qemu-system-x86_64 vmlinuz-3.18-generic
то увидите:
На самом деле, header.S
начинается с MZ (см. картинку выше), вывода сообщения об ошибке и PE заголовка:
#ifdef CONFIG_EFI_STUB
# "MZ", MS-DOS header
.byte 0x4d
.byte 0x5a
#endif
...
...
...
pe_header:
.ascii "PE"
.word 0
Это нужно, чтобы загрузить операционную систему с поддержкой UEFI. Мы не будем рассматривать его внутреннюю работу прямо сейчас; мы сделаем это в одной из следующих глав.
Настоящая настройка ядра начинается с:
// header.S строка 292
.globl _start
_start:
Загрузчик (grub2 или другой) знает об этой метке (смещение 0x200
от MZ
) и сразу переходит на неё, несмотря на то, что header.S
начинается с секции .bstext
, который выводит сообщение об ошибке:
//
// arch/x86/boot/setup.ld
//
. = 0; // текущая позиция
.bstext : { *(.bstext) } // поместить секцию .bstext в позицию 0
.bsdata : { *(.bsdata) }
Точка входа настройки ядра:
.globl _start
_start:
.byte 0xeb
.byte start_of_setup-1f
1:
//
// остальная часть заголовка
//
Здесь мы можем видеть опкод инструкции jmp
(0xeb
) к метке start_of_setup-1f
. Нотация Nf
означает, что 2f
ссылается на следующую локальную метку 2:
; в нашем случае это метка 1
, которая расположена сразу после инструкции jump и содержит оставшуюся часть заголовка. Сразу после заголовка настроек мы видим секцию .entrytext
, которая начинается с метки start_of_setup
.
Это первый код, который на самом деле запускается (отдельно от предыдущей инструкции jump, конечно). После того как настройщик ядра получил управление от загрузчика, первая инструкция jmp
располагаетсяь по смещению 0x200
от начала реальных адресов, т.е после первых 512 байт. Об этом можно узнать из протокола загрузки ядра Linux, а также увидеть в исходном коде grub2:
segment = grub_linux_real_target >> 4;
state.gs = state.fs = state.es = state.ds = state.ss = segment;
state.cs = segment + 0x20;
Это означает, что после начала настройки ядра регистры сегмента будут иметь следующие значения:
gs = fs = es = ds = ss = 0x1000
cs = 0x1020
В моём случае, ядро загружается по адресу 0x10000
.
После перехода на метку start_of_setup
, необходимо соблюсти следующие условия:
- Убедиться, что все значения всех сегментных регистров равны
- Правильно настроить стек, если это необходимо
- Настроить BSS
- Перейти к C-коду в main.c
Давайте посмотрим, как эти условия выполняются.
Выравнивание сегментных регистров
Прежде всего, ядро гарантирует, что сегментные регистры ds
и es
указывают на один и тот же адрес. Затем оно сбрасывает флаг направления с помощью инструкции cld
:
movw %ds, %ax
movw %ax, %es
cld
Как я уже писал ранее, grub2
загружает код настройки ядра по адресу 0x1000
(адрес по умолчанию) и cs
по адресу 0x1020
, потому что выполнение не начинается с начала файла, а с инструкции jump
:
_start:
.byte 0xeb
.byte start_of_setup-1f
расположеной по смещению в 512
байт от 4d 5a. Также необходимо выровнять cs
с 0x1020
на 0x1000
, а также остальные сегментные регистры. После этого мы настраиваем стек:
pushw %ds
pushw $6f
lretw
кладём значение ds
в стек по адресу метки 6 и выполняем инструкцию lretw
. Когда мы вызываем lretw
, она загружает адрес метки 6
в регистр счётчика команд (IP), и загружает cs
со значением ds
. После этого ds
и cs
будут иметь одинаковые значения.
Настройка стека
Почти весь код настройки - это подготовка для среды языка C в режиме реальных адресов. Следующим шагом является проверка значения регистра ss
и создание корректного стека, если значение ss
неверно:
movw %ss, %dx
cmpw %ax, %dx
movw %sp, %dx
je 2f
Это может привести к трём различны сценариям:
ss
имеет верное значение0x1000
(как и все остальные сегментные регистры рядом сcs
)ss
является некорректным и установлен флагCAN_USE_HEAP
(см. ниже)ss
является некорректным и флагCAN_USE_HEAP
не установлен (см. ниже)
Давайте рассмотрим все три сценария:
ss
имеет верный адрес (0x1000
). В этом случае мы переходим на метку 2:
2: andw $~3, %dx
jnz 3f
movw $0xfffc, %dx
3: movw %ax, %ss
movzwl %dx, %esp
sti
Здесь мы видим выравнивание сегмента dx
(содержащего значение sp
, полученное загрузчиком) до 4 байт и проверку - является ли полученное значение нулём. Если ноль, то помещаем 0xfffx
(выровненный до 4
байт адрес до максимального значения сегмента в 64 Кб) в dx
. Если не ноль, продолжаем использовать sp
, полученный от загрузчика (в моём случае 0xf7f4
). После этого мы помещаем значение ax
в ss
, который хранит корректный адрес сегмента 0x1000
и устанавливает корректное значение sp
. Теперь мы имеем корректный стек:
- Второй сценарий (когда
ss
!=ds
). Во-первых, помещаем значение _end (адрес окончания кода настройки) вdx
и проверяем поле заголовкаloadflags
инструкциейtestb
, чтобы понять, можем ли мы использовать кучу (heap). loadflags является заголовком с битовой маской, который определён как:
#define LOADED_HIGH (1<<0)
#define QUIET_FLAG (1<<5)
#define KEEP_SEGMENTS (1<<6)
#define CAN_USE_HEAP (1<<7)
и, как мы можем узнать из протокола загрузки:
Имя поля: loadflags
Данное поле является битовой маской.
Бит 7 (запись): CAN_USE_HEAP
Бит, установленный в 1, указывает на корректность heap_end_ptr.
Если поле очищено, то некоторый функционал кода настройки будет отключен.
Если бит CAN_USE_HEAP
установлен, мы помещаем heap_end_ptr
в dx
(который указывает на _end
) и добавляем к нему STACK_SIZE
(минимальный размер стека, 512
байт). После этого, если dx
без переноса (будет без переноса, поскольку dx = _end + 512
), переходим на метку 2
(как в предыдущем случае) и создаём корректный стек.
- Если флаг
CAN_USE_HEAP
не установлен, мы просто используем минимальный стек от_end
до_end + STACK_SIZE
:
Настройка BSS
Последние два шага, которые нужно выполнить перед тем, как мы сможем перейти к основному коду на C, это настройка BSS и проверка "магических" чисел. Сначала проверка чисел:
cmpl $0x5a5aaa55, setup_sig
jne setup_bad
Это простое сравнение setup_sig с магическим числом 0x5a5aaa55
. Если они не равны, сообщается о фатальной ошибке.
Если магические числа совпадают, зная, что у нас есть набор правильно настроенных сегментных регистров и стек, нам всего лишь нужно настроить BSS.
Секция BSS используется для хранения статически выделенных, неинициализированных данных. Linux тщательно обнуляет эту область памяти, используя следующий код:
movw $__bss_start, %di
movw $_end+3, %cx
xorl %eax, %eax
subw %di, %cx
shrw $2, %cx
rep; stosl
Во-первых, адрес __bss_start помещается в di
. Далее, адрес _end + 3
(+3 - выравнивает до 4 байт) помещается в cx
. Регистр eax
очищается (с помощью инструкции xor
), а размер секции BSS (cx
-di
) вычисляется и помещается в cx
. Затем cx
делится на 4 (размер 'слова' (англ. word)), а инструкция stosl
используется повторно, сохраняя значение eax
(ноль) в адрес, на который указывает di
, автоматически увеличивая di
на 4 (это продолжается до тех пор, пока cx
не достигнет нуля). Эффект от этого кода в том, что теперь все 'слова' в памяти от __bss_start
до _end
заполнены нулями:
Переход к основному коду
Вот и все, теперь у нас есть стек и BSS, поэтому мы можем перейти к C-функции main()
:
calll main
Функция main()
находится в файле arch/x86/boot/main.c. О том, что она делает, вы сможете узнать в следующей части.
Заключение
Это конец первой части о внутренностях ядра Linux. В следующей части мы увидим первый код на языке C, который выполняется при настройке ядра Linux, реализацию процедур для работы с памятью, таких как memset
, memcpy
, earlyprintk
, инициализацию консоли и многое другое.
От переводчика: пожалуйста, имейте в виду, что английский - не мой родной язык, и я очень извиняюсь за возможные неудобства. Если вы найдёте какие-либо ошибки или неточности в переводе, пожалуйста, пришлите pull request в linux-insides-ru.