Хабрахабр

[Перевод] Загрузка ядра Linux. Часть 1

От загрузчика к ядру

Я написал несколько статей о программировании на ассемблере для x86_64 Linux и в то же время начал погружаться в исходный код ядра Linux. Если вы читали предыдущие статьи, то знаете о моём новом увлечении низкоуровневым программированием.

Итак, я решил написать еще одну серию статей о ядре Linux для архитектуры x86_64. Мне очень интересно разобраться, как работают низкоуровневые штуки: как программы запускаются на моём компьютере, как они расположены в памяти, как ядро управляет процессами и памятью, как работает сетевой стек на низком уровне и многое другое.

Это всего лишь хобби. Обратите внимание, что я не профессиональный разработчик ядра и не пишу код ядра на работе. Поэтому если заметите какую-то путаницу или появилятся вопросы/замечания, свяжитесь со мной в твиттере, по почте или просто создайте тикет. Мне просто нравятся низкоуровневые вещи и интересно в них копаться. Буду благодарен.
Все статьи публикуются в репозитории GitHub, и если что-то не так с моим английским или содержанием статьи, не стесняйтесь отправить пулл-реквест.

Обратите внимание, что это не официальная документация, а просто обучение и обмен знаниями.

Необходимые знания

  • Понимание кода на C
  • Понимание кода ассемблера (синтаксис AT&T)

Во всяком случае, если вы только начинаете изучать такие инструменты, я постараюсь что-то объяснить в этой и следующих статьях. Окей, с вступлением закончили, пора погрузиться в ядро Linux и низкоуровневые вещи.

18, и многое могло измениться с тех пор. Я начал писать эту книгу во времена ядра Linux 3. Если есть изменения, я буду соответственно обновлять статьи.

Хотя это статьи о ядре Linux, мы пока не дошли до него — по крайней мере, в этом параграфе. Как только вы нажмете волшебную кнопку питания на своём ноутбуке или настольном компьютере, он начинает работать. Материнская плата посылает сигнал к блоку питания. После получения сигнала он обеспечивает компьютеру необходимое количество электроэнергии. Как только материнская плата получает сигнал «Питание в норме», то пытается запустить CPU. Тот сбрасывает все оставшиеся данные в своих регистрах и устанавливает предопределённые значения для каждого из них.

У процессоров 80386 и более поздних версий после перезагрузки должны быть такие значения в регистрах CPU:

IP 0xfff0
CS selector 0xf000
CS base 0xffff0000

Процессор начинает работать в реальном режиме. Давайте немного вернемся назад и попытаемся понять сегментацию памяти в этом режиме. Реальный режим поддерживается на всех x86-совместимых процессорах: от 8086 до современных 64-разрядных процессоров Intel. В процессоре 8086 используется 20-битная шина адресов, то есть он может работать с адресным пространством 0-0xFFFFF или 1 мегабайт. Но у него есть только 16-битные регистры с максимальным адресом 2^16-1 или 0xffff (64 килобайта).

Вся память делится на небольшие сегменты фиксированного размера по 65536 байт (64 КБ). Сегментация памяти нужна для использования всего доступного адресного пространства. Поскольку с 16-битными регистрами мы не можем обратиться к памяти выше 64 КБ, был разработан альтернативный метод.

В реальном режиме базовым адресом селектора сегмента является селектор сегмента * 16. Адрес состоит из двух частей: 1) селектор сегмента с базовым адресом; 2) смещение от базового адреса. Таким образом, чтобы получить физический адрес в памяти, нужно умножить часть селектора сегмента на 16 и добавить к нему смещение:

Физический адрес = Селектор сегмента * 16 + Смещение

Например, если у регистра CS:IP значение 0x2000:0x0010, то соответствующий физический адрес будет таким:

>>> hex((0x2000 << 4) + 0x0010) '0x20010'

Но если взять селектор наибольшего сегмента и смещение 0xffff:0xffff, то получается адрес:

>>> hex((0xffff << 4) + 0xffff) '0x10ffef'

то есть 65520 байт после первого мегабайта. Поскольку в реальном режиме доступен только один мегабайт, 0x10ffef становится 0x00ffef с отключенной линией A20.

Вернемся к обсуждению значений регистров после сброса. Хорошо, теперь мы немного знаем о реальном режиме и адресации памяти в этом режиме.

Хотя базовый адрес обычно формируется путём умножения значения селектора сегмента на 16, но во время аппаратного сброса селектор сегмента в регистре CS получает значение 0xf000, а базовый адрес — 0xffff0000. Регистр CS состоит из двух частей: видимого селектора сегментов и скрытого базового адреса. Процессор использует этот специальный базовый адрес, пока не изменится CS.

Начальный адрес формируется добавлением базового адреса к значению в регистре EIP:

>>> 0xffff0000 + 0xfff0 '0xfffffff0'

Мы получаем 0xfffffff0, что на 16 байт ниже 4 ГБ. Эта точка называется вектором сброса. Это расположение в памяти, где CPU ждёт первую инструкцию для выполнения после сброса: операцию перехода (jmp), которая обычно указывает на точку входа BIOS. Например, если посмотреть исходный код coreboot (src/cpu/x86/16bit/reset16.inc), мы увидим:

.section ".reset", "ax", %progbits .code16
.globl _start
_start: .byte 0xe9 .int _start16bit - ( . + 2 ) ...

Здесь мы видим код операции (опкод) jmp, а именно 0xe9, и адрес назначения _start16bit - ( . + 2).

Мы также видим, что раздел reset составляет 16 байт, и он компилируется для запуска с адреса 0xfffff0 (src/cpu/x86/16bit/reset16.ld):

SECTIONS
}

Теперь запускается BIOS; после инициализации и проверки оборудования BIOS необходимо найти загрузочное устройство. Порядок загрузки сохраняется в конфигурации BIOS. При попытке загрузки с жёсткого диска BIOS пытается найти загрузочный сектор. На дисках с разметкой разделов MBR загрузочный сектор хранится в первых 446 байтах первого сектора, где каждый сектор равен 512 байтам. Последние два байта первого сектора — 0x55 и 0xaa. Они показывают BIOS, что это загрузочное устройство.

Например:

;
; Примечание: этот пример написан в синтаксисе ассемблера Intel x86
;
[BITS 16] 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

Так как двоичный файл, сгенерированный выше, удовлетворяет требованиям загрузочного сектора (начало в 0x7c00 и завершение магической последовательностью), то QEMU будет рассматривать двоичный файл как главную загрузочную запись (MBR) образа диска. QEMU получает команду использовать двоичный файл boot, который мы только что создали как образ диска.

Вы увидите:

После запуска он вызывает прерывание 0x10, которое просто печатает символ !; заполняет оставшиеся 510 байт нулями и заканчивается двумя волшебными байтами 0xaa и 0x55. В этом примере мы видим, что код выполняется в 16-битном реальном режиме и начинается с адреса 0x7c00 в памяти.

Двоичный дамп можно посмотреть утилитой 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 МБ.

В целом адресация памяти реального режима выглядит следующим образом:

0x00000000 - 0x000003FF – таблица векторов прерываний реального режима
0x00000400 - 0x000004FF - область данных BIOS
0x00000500 - 0x00007BFF - не используется
0x00007C00 - 0x00007DFF - наш загрузчик
0x00007E00 - 0x0009FFFF - не используется
0x000A0000 - 0x000BFFFF - память Video RAM (VRAM) 0x000B0000 - 0x000B7777 - видеопамять монохромного режима
0x000B8000 - 0x000BFFFF - видеопамять цветного режима
0x000C0000 - 0x000C7FFF - Video ROM BIOS
0x000C8000 - 0x000EFFFF - теневая область (BIOS Shadow)
0x000F0000 - 0x000FFFFF - системный BIOS

В начале статьи написано, что первая инструкция для процессора находится по адресу 0xFFFFFFF0, что намного больше 0xFFFFF (1 МБ). Как CPU получить доступ к этому адресу в реальном режиме? Ответ в документации coreboot:

0xFFFE_0000 - 0xFFFF_FFFF: 128 килобайт ROM транслируются в адресное пространство

В начале выполнения BIOS находится не в RAM, а в ROM.

Ядро Linux можно загружать разными загрузчиками, такими как GRUB 2 и syslinux. В ядре есть протокол загрузки, который определяет требования к загрузчику для реализации поддержки Linux. В данном примере мы работаем с GRUB 2.

Из-за ограниченного объёма это очень простой код. Продолжая процесс загрузки, BIOS выбрал загрузочное устройство и передал управление загрузочному сектору, выполнение начинается с boot.img. Тот начинается с diskboot.img и обычно хранится сразу после первого сектора в неиспользуемом пространстве перед первым разделом. Он содержит указатель для перехода к основному образу GRUB 2. После этого выполняется функция grub_main. Приведённый выше код загружает в память остальную часть образа, который содержит ядро GRUB 2 и драйверы для обработки файловых систем.

В конце выполнения она переводит grub в нормальный режим. Функция grub_main инициализирует консоль, возвращает базовый адрес для модулей, устанавливает корневое устройство, загружает/парсит конфигурационный файл grub, загружает модули и т.д. Когда мы выбираем один из пунктов меню grub, запускается функция grub_menu_execute_entry, которая выполняет команду grub boot и загружает выбранную ОС. Функция grub_normal_execute (из исходного файла grub-core/normal/main.c) завершает последние приготовления и показывает меню для выбора операционной системы.

Это смещение указано в скрипте линкера. Как указано в протоколе загрузки ядра, загрузчик должен прочитать и заполнить некоторые поля заголовка установки ядра, который начинается со смещения 0x01f1 от кода установки ядра. S начинается с: Заголовок ядра arch/x86/boot/header.

.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 был собственный загрузчик. На первый взгляд это может показаться немного странным, так как перед ним есть несколько инструкций. Теперь же если запустить, например,

18-generic qemu-system-x86_64 vmlinuz-3.

вы увидите:

S начинается с магического числа MZ (см. Собственно, файл header. скриншот дампа выше), текста сообщения об ошибке и заголовка PE:

#ifdef CONFIG_EFI_STUB
# "MZ", MS-DOS header
.byte 0x4d
.byte 0x5a
#endif
...
...
...
pe_header: .ascii "PE" .word 0

Он нужен для загрузки операционной системы с поддержкой UEFI. Его устройство рассмотрим в следующих главах.

Фактическая точка входа для установки ядра:

// header.S line 292
.globl _start
_start:

Загрузчик (grub2 и другие) знает об этой точке (смещение 0x200 от MZ) и переходит прямо к ней, хотя header.S начинается с раздела .bstext, где находится текст сообщения об ошибке:

//
// arch/x86/boot/setup.ld
//
. = 0; // current position
.bstext : { *(.bstext) } // put .bstext section to position 0
.bsdata : { *(.bsdata) }

Точка входа установки ядра:

.globl _start
_start: .byte 0xeb .byte start_of_setup-1f
1: // // rest of the header //

Здесь мы видим код операции jmp (0xeb), который переходит к точке start_of_setup-1f. В нотации Nf, например, 2f ссылается на локальную метку 2:. В нашем случае это метка 1, которая присутствует сразу после перехода, и она содержит остальную часть заголовка setup. Сразу после заголовка установки мы видим раздел .entrytext, который начинается с метки start_of_setup.

После того, как часть установки ядра получает управление от загрузчика, первая инструкция 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;

В нашем случае ядро загружается по адресу 0x10000. Это означает, что после запуска установки ядра регистры сегментов будут иметь следующие значения:

gs = fs = es = ds = ss = 0x10000
cs = 0x10200

После перехода к start_of_setup ядро должно сделать следующее:

  • Убедиться, что все значения регистров сегментов одинаковы
  • При необходимости настроить правильный стек
  • Настроить bss
  • Перейти к коду C в arch/x86/boot/main.с

Посмотрим, как это реализовано.
Прежде всего ядро проверяет, что регистры сегментов ds и es указывают на один и тот же адрес. Затем очищает флаг направления с помощью инструкции cld:

movw %ds, %ax movw %ax, %es cld

Как я писал ранее, grub2 по умолчанию загружает код установки ядра по адресу 0x10000, а cs по адресу 0x10200, потому что выполнение начинается не с начала файла, а с перехода сюда:

_start: .byte 0xeb .byte start_of_setup-1f

Это смещение на 512 байт от 4d 5a. Также необходимо выровнять cs с 0x10200 до 0x10000, как и все остальные регистры сегментов. После этого устанавливаем стек:

pushw %ds pushw $6f lretw

Эта инструкция помещает на стек значение ds, за ним следуют адрес метки 6 и инструкция lretw, которая загружает адрес метки 6 в регистр счётчика команд и загружает 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 байтам и проверяем на нуль. Если он равен нулю, то помещаем в dx значение 0xfffc (выровненный по 4 байтам адрес перед максимальным размером сегмента 64 КБ). Если он не равен нулю, то продолжаем использовать значение sp, заданное загрузчиком (0xf7f4 в нашем случае). Затем помещаем значение ax в ss, что сохраняет правильный адрес сегмента 0x1000 и устанавливает правильный sp. Теперь у нас есть правильный стек:

  • Во втором сценарии ss != ds. Сначала помещаем значение _end (адрес конца кода установки) в dx и проверяем поле заголовка loadflags, используя инструкцию testb, чтобы проверить, можно ли использовать кучу. 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 допустимо.

После этого переходим к метке 2 (как в предыдущем случае) и делаем правильный стек. Если установлен бит CAN_USE_HEAP, то в dx ставим значение heap_end_ptr (которое указывает на _end) и добавляем к нему STACK_SIZE (минимальный размер стека 1024 байта).

  • Если CAN_USE_HEAP не установлен, просто используем минимальный стек от _end до _end + STACK_SIZE:


Нужны ещё два шага, прежде чем перейти к основному коду C: это настройка области BSS и проверка «волшебной» подписи. Сначала проверка подписи:

cmpl $0x5a5aaa55, setup_sig jne setup_bad

Инструкция просто сравнивает setup_sig с магическим числом 0x5a5aaa55. Если они не равны, сообщается о неустранимой ошибке.

Если магическое число совпадает и у нас есть набор правильных регистров сегментов и стек, то осталось лишь настроить раздел BSS перед переходом к коду C.

Linux тщательно проверяет, что эта область памяти обнулилась: Раздел BSS используется для хранения статически выделенных неинициализированных данных.

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 делится на четыре (размер «слова») и многократно используется инструкция stosl, сохраняя значение еах (нуль) в адрес, указывающий на di, автоматически увеличивая di на четыре и повторяя это до тех пор, пока сх не достигнет нуля). Чистый эффект этого кода заключается в том, что нули записываются во все слова в памяти от __bss_start до _end:

Вот и всё: у нас есть стек и BSS, так что можно перейти к функции main() C:

calll main

Функция main() находится в arch/x86/boot/main.c. О ней поговорим в следующей части.
Это конец первой части об устройстве ядра Linux. Если у вас есть вопросы или предложения, свяжитесь со мной в твиттере, по почте или просто создайте тикет. В следующей части мы увидим первый код на C, который выполняется при установке ядра Linux, реализацию подпрограмм памяти, таких как memset, memcpy, earlyprintk, раннюю реализацию и инициализацию консоли и многое другое.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть