От загрузчика к ядру
Если вы читали предыдущие статьи, то знаете о моём новом увлечении низкоуровневым программированием. Я написал несколько статей о программировании на ассемблере для x86_64
Linux и в то же время начал погружаться в исходный код ядра Linux.
Мне очень интересно разобраться, как работают низкоуровневые штуки: как программы запускаются на моём компьютере, как они расположены в памяти, как ядро управляет процессами и памятью, как работает сетевой стек на низком уровне и многое другое. Итак, я решил написать еще одну серию статей о ядре Linux для архитектуры x86_64.
Обратите внимание, что я не профессиональный разработчик ядра и не пишу код ядра на работе. Это всего лишь хобби. Мне просто нравятся низкоуровневые вещи и интересно в них копаться. Поэтому если заметите какую-то путаницу или появилятся вопросы/замечания, свяжитесь со мной в твиттере, по почте или просто создайте тикет. Буду благодарен.
Все статьи публикуются в репозитории GitHub, и если что-то не так с моим английским или содержанием статьи, не стесняйтесь отправить пулл-реквест.
Обратите внимание, что это не официальная документация, а просто обучение и обмен знаниями.
Необходимые знания
- Понимание кода на C
- Понимание кода ассемблера (синтаксис AT&T)
Во всяком случае, если вы только начинаете изучать такие инструменты, я постараюсь что-то объяснить в этой и следующих статьях. Окей, с вступлением закончили, пора погрузиться в ядро Linux и низкоуровневые вещи.
Я начал писать эту книгу во времена ядра Linux 3.18, и многое могло измениться с тех пор. Если есть изменения, я буду соответственно обновлять статьи.
Волшебная кнопка включения, что дальше?
Хотя это статьи о ядре 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 КБ, был разработан альтернативный метод.
Адрес состоит из двух частей: 1) селектор сегмента с базовым адресом; 2) смещение от базового адреса. В реальном режиме базовым адресом селектора сегмента является селектор сегмента * 16
. Таким образом, чтобы получить физический адрес в памяти, нужно умножить часть селектора сегмента на 16 и добавить к нему смещение:
Физический адрес = Селектор сегмента * 16 + Смещение
Например, если у регистра CS:IP
значение 0x2000:0x0010
, то соответствующий физический адрес будет таким:
>>> hex((0x2000 << 4) + 0x0010)
'0x20010'
Но если взять селектор наибольшего сегмента и смещение 0xffff:0xffff
, то получается адрес:
>>> hex((0xffff << 4) + 0xffff)
'0x10ffef'
то есть 65520
байт после первого мегабайта. Поскольку в реальном режиме доступен только один мегабайт, 0x10ffef
становится 0x00ffef
с отключенной линией A20.
Хорошо, теперь мы немного знаем о реальном режиме и адресации памяти в этом режиме. Вернемся к обсуждению значений регистров после сброса.
Регистр CS
состоит из двух частей: видимого селектора сегментов и скрытого базового адреса. Хотя базовый адрес обычно формируется путём умножения значения селектора сегмента на 16, но во время аппаратного сброса селектор сегмента в регистре CS получает значение 0xf000
, а базовый адрес — 0xffff0000
. Процессор использует этот специальный базовый адрес, пока не изменится 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 {
/* Trigger an error if I have an unuseable start address */
_bogus = ASSERT(_start16bit >= 0xffff0000, "_start16bit too low. Please report.");
_ROMTOP = 0xfffffff0;
. = _ROMTOP;
.reset . : {
*(.reset);
. = 15;
BYTE(0x00);
}
}
Теперь запускается 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
QEMU получает команду использовать двоичный файл boot
, который мы только что создали как образ диска. Так как двоичный файл, сгенерированный выше, удовлетворяет требованиям загрузочного сектора (начало в 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 МБ.
В целом адресация памяти реального режима выглядит следующим образом:
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. Из-за ограниченного объёма это очень простой код. Он содержит указатель для перехода к основному образу GRUB 2. Тот начинается с diskboot.img и обычно хранится сразу после первого сектора в неиспользуемом пространстве перед первым разделом. Приведённый выше код загружает в память остальную часть образа, который содержит ядро GRUB 2 и драйверы для обработки файловых систем. После этого выполняется функция grub_main.
Функция grub_main
инициализирует консоль, возвращает базовый адрес для модулей, устанавливает корневое устройство, загружает/парсит конфигурационный файл grub, загружает модули и т.д. В конце выполнения она переводит grub в нормальный режим. Функция grub_normal_execute
(из исходного файла grub-core/normal/main.c
) завершает последние приготовления и показывает меню для выбора операционной системы. Когда мы выбираем один из пунктов меню grub, запускается функция 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 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 допустимо. Если это поле пусто, будет отключена
часть функциональности установки.
Если установлен бит CAN_USE_HEAP
, то в dx
ставим значение heap_end_ptr
(которое указывает на _end
) и добавляем к нему STACK_SIZE
(минимальный размер стека 1024
байта). После этого переходим к метке 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
делится на четыре (размер «слова») и многократно используется инструкция stosl
, сохраняя значение еах
(нуль) в адрес, указывающий на di
, автоматически увеличивая di
на четыре и повторяя это до тех пор, пока сх
не достигнет нуля). Чистый эффект этого кода заключается в том, что нули записываются во все слова в памяти от __bss_start
до _end
:
Переход к main
Вот и всё: у нас есть стек и BSS, так что можно перейти к функции main()
C:
calll main
Функция main()
находится в arch/x86/boot/main.c. О ней поговорим в следующей части.
Вывод
Это конец первой части об устройстве ядра Linux. Если у вас есть вопросы или предложения, свяжитесь со мной в твиттере, по почте или просто создайте тикет. В следующей части мы увидим первый код на C, который выполняется при установке ядра Linux, реализацию подпрограмм памяти, таких как memset
, memcpy
, earlyprintk
, раннюю реализацию и инициализацию консоли и многое другое.
Ссылки
- Intel 80386, справочник программиста, 1986 г.
- Минимальный загрузчик для архитектуры Intel
- 8086
- 80386
- Вектор сброса
- Реальный режим
- Протокол загрузки ядра Linux
- Coreboot, руководство разработчика
- Список прерываний Ральфа Брауна
- Источник питания
- Сигнал «Питание в норме»
Автор: m1rko