Fixed math error because X = 0x001000+sizeof(boot loader)
40 KiB
Процесс загрузки ядра. Часть 1.
От загрузчика к ядру
Если вы читали предыдущие статьи моего блога, то могли заметить, что некоторое время назад я начал увлекаться низкоуровневым программированием. Я написал несколько статей о программировании под x86_64 в Linux. В то же время я начал "погружаться" в исходный код Linux. Я имею большой интерес к пониманию того, как работают низкоуровневые вещи, как запускаются программы на моем компьютере, как они расположены в памяти, как ядро управляет процессами и памятью, как работает сетевой стек на низком уровне и многие другие вещи. Поэтому я решил написать ещё одну серию статей о ядре Linux для x86_64.
Замечу, что я не профессиональный хакер ядра и не пишу под него код на работе. Это просто хобби. Мне просто нравятся низкоуровневые вещи и мне интересно наблюдать за тем, как они работают. Так что, если вас что-то будет смущать или у вас появятся вопросы или замечания, пишите мне в твиттер 0xAX, присылайте письма на email или просто создавайте issue. Я ценю это. Все статьи также будут доступны на странице linux-insides, и, если вы обнаружите какую-нибудь ошибку в моём английском или в содержимом статьи, присылайте pull request.
Заметьте, что это не официальная документация, а просто материал для обучения и обмена знаниями.
Требуемые знания
- Понимание кода на языке C
- Понимание кода на языке ассемблера (AT&T синтаксис)
В любом случае, если вы только начинаете изучать некоторые инструменты, я постараюсь объяснить некоторые детали этой и последующих частей. Ладно, простое введение закончилось, и теперь можно начать "погружение" в ядро и низкоуровневые вещи.
Весь код, представленный здесь, в основном для ядра версии 3.18. Если есть какие-то изменения, позже я обновлю статьи соответствующим образом.
Магическая кнопка включения, что происходит дальше?
Несмотря на то, что это серия статей о ядре Linux, мы не будем начинать с его исходного кода (по крайней мере в этом параграфе). Как только вы нажмёте магическую кнопку включения на вашем ноутбуке или настольном компьютере, он начинает работать. Материнская плата посылает сигнал к источнику питания. После получения сигнала, источник питания обеспечивает компьютер надлежащим количеством электричества. После того как материнская плата получает сигнал "питание в норме" (Power good signal), она пытается запустить ЦПУ. ЦПУ сбрасывает все остаточные данные в регистрах и записывает предустановленные значения каждого из них.
ЦПУ серии Intel 80386 и старше после перезапуска компьютера заполняют регистры следующими предустановленными значениями:
IP 0xfff0
CS selector 0xf000
CS base 0xffff0000
Процессор начинает свою работу в режиме реальных адресов. Давайте немного задержимся и попытаемся понять сегментацию памяти в этом режиме. Режим реальных адресов поддерживается всеми x86-совместимыми процессорами, от 8086 до самых новых 64-битных ЦПУ Intel. Процессор 8086 имел 20-битную шину адреса, т.е. он мог работать с адресным пространством в диапазоне 0-0x100000 (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
, т.е. 4 Гб (16 байт). По этому адресу располагается так называемый вектор прерываний. Это область памяти, в которой ЦПУ ожидает найти первую инструкцию для выполнения после сброса. Она содержит инструкцию jump (jmp
), которая обычно указывает на точку входа в BIOS. Например, если мы взглянем на исходный код coreboot, то увидим следующее:
.section ".reset"
.code16
.globl reset_vector
reset_vector:
.byte 0xe9
.int _start - ( . + 2 )
...
Здесь мы можем видеть опкод инструкции jmp - 0xe9, и его адрес назначения _start - ( . + 2)
. Мы также можем видеть, что секция reset
занимает 16 байт и начинается с 0xfffffff0
:
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 передаёт управление загрузчику.
ЗАМЕЧАНИЕ: Как уже было упомянуто ранее, ЦПУ находится в режиме реальных адресов; в этом режиме вычисление физического адреса в памяти выполняется следующим образом:
Физический адрес = Селектор сегмента * 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
В начале статьи я написал, что первая инструкция, выполняемая ЦПУ, расположена по адресу 0xFFFFFFF0
, значение которого намного больше, чем 0xFFFFF
(1 Мб). Каким образом ЦПУ получает доступ к этому участку в режиме реальных адресов? Ответ на этот вопрос находится в документации 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 загружает код настройки ядра по адресу 0x10000
и cs
по адресу 0x1020
, потому что исполнение не начинается с начала файла, а с
_start:
.byte 0xeb
.byte start_of_setup-1f
инструкции jump
, расположение которой смещено на 512 байт от 4d 5a. Также необходимо выровнять cs
с 0x10200
на 0x10000
, а также остальные сегментные регистры. После этого мы настраиваем стек:
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
имеет верное значение 0x10000 (как и все остальные сегментные регистры рядом сcs
)ss
является некорректным и установлен флагCAN_USE_HEAP
(см. ниже)ss
является некорректным и флагCAN_USE_HEAP
не установлен (см. ниже)
Давайте рассмотрим все три сценария:
ss
имеет верный адрес (0x10000). В этом случае мы переходим к метке 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
, который хранит правильный адрес сегмента 0x10000
и устанавливает правильное значение 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)
и, как мы можем узнать из протокола загрузки:
Field name: loadflags
This field is a bitmask.
Bit 7 (write): CAN_USE_HEAP
Set this bit to 1 to indicate that the value entered in the
heap_end_ptr is valid. If this field is clear, some setup code
functionality will be disabled.
Если бит 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, прежде чем перейти к C-коду.
Секция 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. Если у вас есть вопросы или предложения, пишите мне в твиттер 0xAX, на мой email или просто откройте issue. В следующей части мы увидим первый код на языке C, который выполняется при настройке ядра Linux, реализацию процедур для работы с памятью, таких как memset
, memcpy
, earlyprintk
, инициализацию консоли и многое другое.
Пожалуйста, имейте в виду, что английский - не мой родной язык, и я очень извиняюсь за возможные неудобства. Если вы найдёте какие-нибудь ошибки, пожалуйста, пришлите pull request в linux-insides.