40 KiB
Процесс загрузки ядра. Часть 3.
Инициализация видеорежима и переход в защищённый режим
Это третья часть серии Процесса загрузки ядра
. В предыдущей части мы остановились прямо перед вызовом функции set_video
из main.c. В этой части мы увидим:
- Инициализацию видеорежима в коде настройки ядра,
- подготовку перед переключением в защищённый режим,
- переход в защищённый режим
ПРИМЕЧАНИЕ: если вы ничего не знаете о защищённом режиме, вы можете найти некоторую информацию о нём в предыдущей части. Также есть несколько ссылок, которые могут вам помочь.
Как я уже писал ранее, мы начнём с функции set_video
, которая определена в arch/x86/boot/video.c. Как мы можем видеть, она начинает работу с получения видеорежима из структуры boot_params.hdr
:
u16 mode = boot_params.hdr.vid_mode;
которую мы заполнили в функции copy_boot_params
(вы можете прочитать об этом в предыдущем посте). vid_mode
является обязательным полем, которое заполняется загрузчиком. Вы можете найти информацию об этом в протоколе загрузки ядра:
Offset Proto Name Meaning
/Size
01FA/2 ALL vid_mode Video mode control
Как мы можем прочесть из протокола загрузки ядра Linux:
vga=<mode>
<mode> может быть либо целочисленным значением (в C-нотации,
десятичной, восьмеричной или шестнадцатеричной), либо одной из строк:
"normal" (означает 0xFFFF), "ext" (означает 0xFFFE) или "ask"
(означает 0xFFFD). Это значение должно быть введено в поле
vid_mode field, так как оно используется ядром до
парсинга командной строки.
Таким образом, мы можем добавить параметр vga
в конфигурационный файл GRUB или любого другого загрузчика и он передаст его в командную строку ядра. Как говорится в описании, этот параметр может иметь разные значения. Например, это может быть целым числом 0xFFFD
или ask
. Если передать ask
в vga
, вы увидите примерно такое меню:
которое попросит выбрать видеорежим. Мы посмотрим на его реализацию, но перед этим рассмотрим некоторые другие вещи.
Типы данных ядра
Ранее мы видели определения различных типов данных в коде настройки ядра, таких как u16
и т.д. Давайте взглянем на несколько типов данных, предоставляемых ядром:
Тип | char | short | int | long | u8 | u16 | u32 | u64 |
---|---|---|---|---|---|---|---|---|
Размер | 1 | 2 | 4 | 8 | 1 | 2 | 4 | 8 |
Во время чтения исходного кода ядра вы будете часто встречать эти типы, так что было бы неплохо запомнить их.
API кучи
После того как мы получим vid_mode
из boot_params.hdr
в функции set_video
, мы можем видеть вызов RESET_HEAP
. RESET_HEAP
представляет собой макрос, определённый в boot.h:
#define RESET_HEAP() ((void *)( HEAP = _end ))
Если вы читали вторую часть, то помните, что мы инициализировали кучу с помощью функции init_heap
. У нас есть несколько полезных функций для кучи, которые определены в boot.h
:
#define RESET_HEAP()
Как мы видели чуть выше, он сбрасывает кучу, установив переменную HEAP
в _end
, где _end
просто extern char _end[];
Следующий макрос - GET_HEAP
:
#define GET_HEAP(type, n) \
((type *)__get_heap(sizeof(type),__alignof__(type),(n)))
предназначен для выделения кучи. Он вызывает внутреннюю функцию __get_heap
с тремя параметрами:
- размер типа в байтах, который должен быть выделен
__alignof__(type)
показывает, как переменные этого типа выровненыn
говорит о том, сколько элементов нужно выделить
Реализация __get_heap
:
static inline char *__get_heap(size_t s, size_t a, size_t n)
{
char *tmp;
HEAP = (char *)(((size_t)HEAP+(a-1)) & ~(a-1));
tmp = HEAP;
HEAP += s*n;
return tmp;
}
В дальнейшем мы увидим её использование, что-то вроде:
saved.data = GET_HEAP(u16, saved.x * saved.y);
Давайте попробуем понять принцип работы __get_heap
. Мы видим, что HEAP
(который равен _end
после RESET_HEAP()
) является адресом выровненной памяти в соответствии с параметром a
. После этого мы сохраняем адрес памяти HEAP
в переменную tmp
, перемещаем HEAP
в конец выделенного блока и возвращаем tmp
, которая является начальным адресом выделенной памяти.
И последняя функция:
static inline bool heap_free(size_t n)
{
return (int)(heap_end - HEAP) >= (int)n;
}
которая вычитает значение HEAP
из heap_end
(мы вычисляли это в предыдущей части) и возвращает 1, если имеется достаточно памяти для n
.
На этом всё. Теперь у нас есть простой API для кучи и можем перейти к настройке видеорежима.
Настройка видеорежима
Теперь мы можем перейти непосредственно к инициализации видеорежима. Мы остановились на вызове RESET_HEAP()
в функции set_video
. Далее идёт вызов функции store_mode_params
, которая сохраняет параметры видеорежима в структуре boot_params.screen_info
, определённой в include/uapi/linux/screen_info.h.
Если мы посмотрим на функцию store_mode_params
, то увидим, что она начинается с вызова store_cursor_position
. Как вы можете понять из названия функции, она получает информацию о курсоре и сохраняет её.
В первую очередь store_cursor_position
инициализирует две переменные, которые имеют тип biosregs
с AH = 0x3
, и вызывает BIOS прерывание 0x10
. После того как прерывание успешно выполнено, она возвращает строку и столбец в регистрах DL
и DH
. Строка и столбец будут сохранены в полях orig_x
и orig_y
структуры boot_params.screen_info
.
После выполнения store_cursor_position
вызывается функция store_video_mode
. Она просто получает текущий видеорежим и сохраняет его в boot_params.screen_info.orig_video_mode
.
Далее она проверяет текущий видеорежим и устанавливает video_segment
. После того как BIOS передаёт контроль в загрузочный сектор, для видеопамяти выделяются следующие адреса:
0xB000:0x0000 32 Кб Видеопамять для монохромного текста
0xB800:0x0000 32 Кб Видеопамять для цветного текста
Таким образом, мы устанавливаем переменную video_segment
в 0xB000
, если текущий видеорежим MDA, HGC, или VGA в монохромном режиме, и в 0xB800
, если текущий видеорежим цветной. После настройки адреса видеофрагмента, размер шрифта должен быть сохранён в boot_params.screen_info.orig_video_points
:
set_fs(0);
font_size = rdfs16(0x485);
boot_params.screen_info.orig_video_points = font_size;
В первую очередь мы устанавливаем регистр FS
в 0 с помощью функции set_fs
. В предыдущей части мы уже видели такие функции, как set_fs
. Все они определены в boot.h. Далее мы читаем значение, которое находится по адресу 0x485
(эта область памяти используется для получения размера шрифта) и сохраняет размер шрифта в boot_params.screen_info.orig_video_points
.
x = rdfs16(0x44a);
y = (adapter == ADAPTER_CGA) ? 25 : rdfs8(0x484)+1;
Далее мы получаем количество столбцов по адресу 0x44a
, и строк по адресу 0x484
и сохраняем их в boot_params.screen_info.orig_video_cols
и boot_params.screen_info.orig_video_lines
. После этого выполнение store_mode_params
завершается.
Далее мы видим функцию save_screen
, которая просто сохраняет содержимое экрана в куче. Эта функция собирает все данные, которые мы получили в предыдущей функции, такие как количество строк и столбцов и т.д, и сохраняет их в структуре saved_screen
:
static struct saved_screen {
int x, y;
int curx, cury;
u16 *data;
} saved;
Затем она проверяет, есть ли свободное место в куче:
if (!heap_free(saved.x*saved.y*sizeof(u16)+512))
return;
и если места в куче достаточно, выделяет его и сохраняет в нём saved_screen
.
Следующий вызов - probe_cards(0)
из arch/x86/boot/video-mode.c. Она проходит по всем video_cards и собирает количество режимов, предоставляемых картой. И вот здесь интересный момент. Мы можем видеть цикл:
for (card = video_cards; card < video_cards_end; card++) {
/* Здесь собираем количество режимов */
}
но video_cards
нигде не объявлен. Ответ прост: каждый видеорежим, представленный в x86-коде настройки ядра, определён следующим образом:
static __videocard video_vga = {
.card_name = "VGA",
.probe = vga_probe,
.set_mode = vga_set_mode,
};
где __videocard
- макрос:
#define __videocard struct card_info __attribute__((used,section(".videocards")))
который определяет структуру card_info
:
struct card_info {
const char *card_name;
int (*set_mode)(struct mode_info *mode);
int (*probe)(void);
struct mode_info *modes;
int nmodes;
int unsafe;
u16 xmode_first;
u16 xmode_n;
};
которая находится в сегменте .videocards
. Давайте посмотрим на скрипт компоновщика arch/x86/boot/setup.ld, в котором мы можем найти:
.videocards : {
video_cards = .;
*(.videocards)
video_cards_end = .;
}
Это значит, что video_cards
- просто адрес в памяти, и все структуры card_info
размещаются в этом сегменте. Это также означает, что все структуры card_info
размещаются между video_cards
и video_cards_end
, и мы можем воспользоваться этим, чтобы пройтись по ним в цикле. После выполнения probe_cards
у нас есть все структуры static __videocard video_vga
с заполненными nmodes
(число видеорежимов).
После завершения выполнения probe_cards
, мы переходим в главный цикл функции set_video
. Это бесконечный цикл, который пытается установить видеорежим с помощью функции set_mode
и выводит меню, если установлен флаг vid_mode=ask
командной строки ядра или если видеорежим не определён.
Функция set_mode
определена в video-mode.c и принимает только один параметр - mode
, который определяет количество видеорежимов (мы получили его из меню или в начале setup_video
, из заголовка настройки ядра).
set_mode
проверяет mode
и вызывает функцию raw_set_mode
. raw_set_mode
вызывает set_mode
для выбранной карты, т.е. card->set_mode(struct mode_info*)
. Мы можем получить доступ к этой функции из структуры card_info
. Каждый видеорежим определяет эту структуру со значениями, заполненными в зависимости от режима видео (например, для vga
это функция video_vga.set_mode
. См. выше пример структуры card_info
для vga
). video_vga.set_mode
является vga_set_mode
, который проверяет VGA-режим и вызывает соответствующую функцию:
static int vga_set_mode(struct mode_info *mode)
{
vga_set_basic_mode();
force_x = mode->x;
force_y = mode->y;
switch (mode->mode) {
case VIDEO_80x25:
break;
case VIDEO_8POINT:
vga_set_8font();
break;
case VIDEO_80x43:
vga_set_80x43();
break;
case VIDEO_80x28:
vga_set_14font();
break;
case VIDEO_80x30:
vga_set_80x30();
break;
case VIDEO_80x34:
vga_set_80x34();
break;
case VIDEO_80x60:
vga_set_80x60();
break;
}
return 0;
}
Каждая функция, которая устанавливает видеорежим, просто вызывает BIOS прерывание 0x10
с определённым значением в регистре AH
.
После того как мы установили видеорежим, мы передаём его в boot_params.hdr.vid_mode
.
Далее вызывается vesa_store_edid
. Эта функция сохраняет информацию о EDID (Extended Display Identification Data) для использования ядром. После этого снова вызывается store_mode_params
. И наконец, если установлен do_restore
, экран восстанавливается в предыдущее состояние.
Теперь, когда видеорежим установлен, мы можем переключится в защищённый режим.
Последняя подготовка перед переходом в защищённый режим
Мы можем видеть последний вызов функции - go_to_protected_mode
- в main.c. Как говорится в комментарии: Do the last things and invoke protected mode
, так что давайте посмотрим на эти последние вещи и перейдём в защищённый режим.
Функция go_to_protected_mode
определена в arch/x86/boot/pm.c. Она содержит функции, которые совершают последние приготовления, прежде чем мы сможем перейти в защищённый режим, так что давайте посмотрим на них и попытаться понять, что они делают и как это работает.
Во-первых, это вызов функции realmode_switch_hook
в go_to_protected_mode
. Эта функция вызывает хук переключения режима реальных адресов, если он присутствует, и выключает NMI. Хуки используются, если загрузчик работает во "враждебной" среде. Вы можете прочитать больше о хуках в протоколе загрузки (см. ADVANCED BOOT LOADER HOOKS).
Хук realmode_switch
представляет собой указатель на 16-битную удалённую подпрограмму режима реальных адресов, которая отключает немаскируемые прерывания. После проверки хука realmode_switch
, происходит выключение Non-Maskable Interrupts (NMI):
asm volatile("cli");
outb(0x80, 0x70); /* Выключение NMI */
io_delay();
Первой вызывается ассемблерная инструкция cli
, которая очищает флаг прерывания (IF
). После этого внешние прерывания отключены. Следующая строка отключает NMI (немаскируемые прерывания).
Прерывание является сигналом, который отправляется CPU от аппаратного или программного обеспечения. После получения сигнала, CPU приостанавливает текущую последовательность команд, сохраняет своё состояние и передаёт управление обработчику прерываний. После того как обработчик прерывания закончил свою работу, он передаёт управление прерванной инструкции. Немаскируемые прерывания (NMI) - это прерывания, которые обрабатываются всегда, независимо от запретов на другие прерывания. Их нельзя игнорировать, и, как правило, они используются для подачи сигнала о невосстанавливаемых аппаратных ошибок. Сейчас мы не будем погружаться в детали прерываний, но обсудим это в следующих постах.
Давайте вернёмся к коду. Мы видим, что вторая строка пишет байт 0x80
(отключённый бит) в 0x70
(регистр CMOS Address). После этого происходит вызов функции io_delay
. io_delay
вызывает небольшую задержку и выглядит следующим образом:
static inline void io_delay(void)
{
const u16 DELAY_PORT = 0x80;
asm volatile("outb %%al,%0" : : "dN" (DELAY_PORT));
}
Для вывода любого байта в порт 0x80
необходима задержка в 1 мкс. Таким образом, мы можем записать любое значение (в нашем случае значение из регистра AL
) в порт 0x80
. После задержки, функция realmode_switch_hook
завершает выполнение и мы можем перейти к следующей функции.
Следующая функция - enable_a20
- включает линию A20. Она определена в arch/x86/boot/a20.c и совершает попытку включения шлюза адресной линии A20 различными методами. Первым из них является функция a20_test_short
, которая проверят, является ли A20 включённой или нет с помощью функции a20_test
:
static int a20_test(int loops)
{
int ok = 0;
int saved, ctr;
set_fs(0x0000);
set_gs(0xffff);
saved = ctr = rdfs32(A20_TEST_ADDR);
while (loops--) {
wrfs32(++ctr, A20_TEST_ADDR);
io_delay(); /* Serialize and make delay constant */
ok = rdgs32(A20_TEST_ADDR+0x10) ^ ctr;
if (ok)
break;
}
wrfs32(saved, A20_TEST_ADDR);
return ok;
}
В первую очередь мы устанавливаем регистр FS
в 0x0000
и регистр GS
в 0xffff
. Далее мы читаем значение по адресу A20_TEST_ADDR
(0x200
) и сохраняем его в переменную saved
и ctr
.
После этого мы записываем обновлённое значение ctr
в fs:gs
с помощью функции wrfs32
, совершаем задержку в 1 мс, а затем читаем значение из регистра GS
по адресу A20_TEST_ADDR+0x10
. Если это не ноль, то линия A20 уже включена. Если линия A20 отключена, мы пытаемся включить её с помощью других методов, которые вы можете найти в a20.c
. Например, с помощью вызова BIOS прерывания 0x15
с AH=0x2041
и т.д.
Если функция enabled_a20
завершается неудачей, выводится сообщение об ошибке и вызывается функция die
. Вы можете вспомнить её из первого файла исходного кода, откуда мы начали - arch/x86/boot/header.S:
die:
hlt
jmp die
.size die, .-die
После того как шлюз линии A20 успешно включён, вызывается функция reset_coprocessor
:
outb(0, 0xf0);
outb(0, 0xf1);
Она очищает математический сопроцессор путём записи 0
в 0xf0
, а затем сбрасывает его при помощи записи 0
в 0xf1
.
После этого вызывается функция mask_all_interrupts
:
outb(0xff, 0xa1); /* Маскирует все прерывания на вторичном PIC */
outb(0xfb, 0x21); /* Маскирует все, кроме каскада на первичном PIC */
Она маскирует все прерывания на вторичном PIC (программируемый контроллер прерываний) и первичном PIC, за исключением IRQ2 на первичном PIC.
И теперь, после всех приготовлений, мы можем увидеть фактический переход в защищённый режим.
Настройка таблицы векторов прерываний
Теперь мы настраиваем таблицу векторов прерываний (IDT). Функция setup_idt
:
static void setup_idt(void)
{
static const struct gdt_ptr null_idt = {0, 0};
asm volatile("lidtl %0" : : "m" (null_idt));
}
настраивает таблицу векторов прерываний (описывает обработчики прерываний и т.д). В настоящее время IDT не установлена (мы увидим это позже), сейчас мы просто загрузили IDT инструкцией lidtl
. null_idt
содержит адрес и размер IDT, но сейчас они равны нулю. null_idt
является структурой gdt_ptr
и определена следующим образом:
struct gdt_ptr {
u16 len;
u32 ptr;
} __attribute__((packed));
где мы можем видеть 16-битную длину (len
) IDT и 32-битный указатель на неё (более подробно о IDT и прерываниях вы увидите в следующих постах). __attribute__((packed))
означает, что размер gdt_ptr
является минимальным требуемым размером. Таким образом, размер gdt_ptr
должен быть равен 6 байтам или 48 битам. (Далее мы будем загружать указатель на gdt_ptr
в регистр GDTR
и вы, возможно, помните из предыдущего поста, что это 48-битный регистр).
Настройка глобальной таблицы дескрипторов
Далее идёт настройка глобальной таблицы дескрипторов (GDT). Мы можем видеть функцию setup_gdt
, которая настраивает GDT (вы можете прочитать про это в посте Процесс загрузки ядра. Часть 2.). В этой функции определён массив boot_gdt
, который содержит определение трёх сегментов:
static const u64 boot_gdt[] __attribute__((aligned(16))) = {
[GDT_ENTRY_BOOT_CS] = GDT_ENTRY(0xc09b, 0, 0xfffff),
[GDT_ENTRY_BOOT_DS] = GDT_ENTRY(0xc093, 0, 0xfffff),
[GDT_ENTRY_BOOT_TSS] = GDT_ENTRY(0x0089, 4096, 103),
};
для получения кода, данных и TSS (Task State Segment, сегмент состояния задачи). В данный момент мы не будем использовать сегмент состояния задачи. Как мы можем видеть в строке комментария, он был добавлен специально для Intel VT (здесь вы можете найти коммит, который описывает его). Давайте посмотри на boot_gdt
. Прежде всего отметим, что она имеет атрибут __attribute__((aligned(16)))
. Это означает, что структура будет выровнена по 16 байтам. Давайте посмотри на простой пример:
#include <stdio.h>
struct aligned {
int a;
}__attribute__((aligned(16)));
struct nonaligned {
int b;
};
int main(void)
{
struct aligned a;
struct nonaligned na;
printf("Not aligned - %zu \n", sizeof(na));
printf("Aligned - %zu \n", sizeof(a));
return 0;
}
Технически, структура, которая содержит одно поле типа int
, должна иметь размер 4 байта, но так как это aligned
структура, она будет иметь размер 16 байт:
$ gcc test.c -o test && test
Not aligned - 4
Aligned - 16
Здесь GDT_ENTRY_BOOT_CS
имеет индекс - 2, GDT_ENTRY_BOOT_DS
является GDT_ENTRY_BOOT_CS + 1
и т.д. Он начинается с 2, поскольку первый является обязательным нулевым дескриптором (индекс - 0), а второй не используется (индекс - 1).
GDT_ENTRY
- это макрос, который принимает флаги, базовый адрес, предел и создаёт запись в GDT. Для примера посмотрим на запись сегмента кода. GDT_ENTRY
принимает следующие значения:
- базовый адрес - 0
- предел - 0xfffff
- флаги - 0xc09b
Что это значит? Базовый адрес сегмента равен 0, а предел (размер сегмента) равен 0xffff
(1 Мб). Давайте посмотрим на флаги. В двоичном виде значение 0xc09b
будет выглядеть следующим образом:
1100 0000 1001 1011
Попробуем понять, что означает каждый бит. Мы пройдёмся по всем битам слева направо
- 1 - (G) бит гранулярности
- 1 - (D) если равен 0 - 16-битный сегмент; 1 - 32-битный сегмент
- 0 - (L) если 1 - выполняется в 64-битном режиме
- 0 - (AVL) доступен для использования системным ПО
- 0000 - 4 бита предела в 19:16 бит в дескрипторе
- 1 - (P) присутствие сегмента в памяти
- 00 - (DPL) - уровень привилегий, 0 является высшей привилегией
- 1 - (S) сегмент кода или данных, не системный сегмент
- 101 - тип сегмента и виды доступа к нему (чтение, выполнение)
- 1 - бит обращения
Вы можете прочитать больше о каждом бите в предыдущем посте или в документации для разработчиков ПО на архитектуре Intel® 64 и IA-32.
После этого мы получаем длину GDT:
gdt.len = sizeof(boot_gdt)-1;
Здесь мы получаем размер boot_gdt
и вычитаем 1 (последний действительный адрес в GDT).
Далее получаем указатель на GDT:
gdt.ptr = (u32)&boot_gdt + (ds() << 4);
Здесь мы просто получаем адрес boot_gdt
и добавляем его к адресу сегмента данных, сдвинутого влево на 4 бита (не забывайте, что сейчас мы находимся в режиме реальных адресов).
И наконец, мы выполняем инструкцию lgdtl
для загрузки GDT в регистр GDTR:
asm volatile("lgdtl %0" : : "m" (gdt));
Фактический переход в защищённый режим
Это конец функции go_to_protected_mode
. Мы загрузили IDT, GDT, отключили прерывания и теперь можем переключить CPU в защищённый режим. Последний шаг - вызов функции protected_mode_jump
с двумя параметрами:
protected_mode_jump(boot_params.hdr.code32_start, (u32)&boot_params + (ds() << 4));
которая определена в arch/x86/boot/pmjump.S. Она получает два параметра:
- адрес точки входа в защищённый режим
- адрес
boot_params
Давайте заглянем внутрь protected_mode_jump
. Как я уже писал выше, вы можете найти его в arch/x86/boot/pmjump.S
. Первый параметр находится в регистре eax
, второй в edx
.
В первую очередь мы помещаем адрес boot_params
в регистр esi
и адрес регистра сегмента кода cs
(0x1000) в bx
. Далее мы сдвигаем bx
на 4 бита и добавляем к нему адрес метки 2
(после этого мы будем иметь физический адрес метки 2
в bx
) и переходим на метку 1
. Далее мы помещаем сегмент данных и сегмент состояния задачи в регистры cs
и di
:
movw $__BOOT_DS, %cx
movw $__BOOT_TSS, %di
Как вы можете прочесть выше, GDT_ENTRY_BOOT_CS
имеет индекс 2 и каждая запись GDT имеет размер 8 байт, поэтому CS
будет 2 * 8 = 16
, __BOOT_DS
равен 24 и т.д.
Далее мы устанавливаем бит PE
(Protection Enable) в регистре управления CR0
:
movl %cr0, %edx
orb $X86_CR0_PE, %dl
movl %edx, %cr0
и совершаем длинный переход в защищённый режим:
.byte 0x66, 0xea
2: .long in_pm32
.word __BOOT_CS
где
0x66
- префикс размера операнда, который позволяет смешивать как 16-битный, так и 32-битный код,0xea
- опкод инструкции перехода,in_pm32
- смещение сегмента__BOOT_CS
- сегмент кода.
После этого мы наконец-то в защищённом режиме:
.code32
.section ".text32","ax"
Давайте посмотрим на первые шаги в защищённом режиме. Прежде всего мы устанавливаем сегмент данных следующим образом:
movl %ecx, %ds
movl %ecx, %es
movl %ecx, %fs
movl %ecx, %gs
movl %ecx, %ss
Если вы обратили внимание, то можете вспомнить, что мы сохраняли $__BOOT_DS
в регистре cx
. Теперь мы заполнили все сегментные регистры, кроме cs
(cs
уже __BOOT_CS
). Далее мы обнуляем все регистры общего назначения, кроме eax
:
xorl %ecx, %ecx
xorl %edx, %edx
xorl %ebx, %ebx
xorl %ebp, %ebp
xorl %edi, %edi
И в конце переходим к 32-битной точке входа:
jmpl *%eax
Как вы помните, eax
содержит адрес 32-битной записи (мы передали его как первый параметр в protected_mode_jump
).
На этом всё. Теперь мы находимся в защищённом режиме и останавливаемся на этой точке входа. Что произойдёт дальше, мы увидим в следующей части.
Заключение
Это конец третьей части о внутренностях ядра Linux. В следующей части мы рассмотрим первые шаги в защищённом режиме и переход в long mode.
От переводчика: пожалуйста, имейте в виду, что английский - не мой родной язык, и я очень извиняюсь за возможные неудобства. Если вы найдёте какие-либо ошибки или неточности в переводе, пожалуйста, пришлите pull request в linux-insides-ru.