Инициализация ядра. Часть 3.
Последние приготовления перед точкой входа в ядро
Это третья часть серии Инициализация ядра. В предыдущей части мы увидели начальную обработку прерываний и исключений и продолжим погружение в процесс инициализации ядра Linux в текущей части. Наша следующая точка - "точка входа в ядро" - функция start_kernel из файла init/main.c. Да, технически это не точка входа в ядро, а начало кода ядра, который не зависит от определённой архитектуры. Но прежде чем мы вызовем функцию start_kernel, мы должны совершить некоторые приготовления. Давайте продолжим.
Снова boot_params
В предыдущей части мы остановились на настройке таблицы векторов прерываний и её загрузки в регистр IDTR. На следующем шаге мы можем видеть вызов функции copy_bootdata:
copy_bootdata(__va(real_mode_data));
Эта функция принимает один аргумент - виртуальный адрес real_mode_data. Вы должны помнить, что мы передали адрес структуры boot_params из arch/x86/include/uapi/asm/bootparam.h в функцию x86_64_start_kernel как первый параметр в arch/x86/kernel/head_64.S:
/* rsi is pointer to real mode structure with interesting info.
pass it to C */
movq %rsi, %rdi
Взглянем на макрос __va. Этот макрос определён в init/main.c:
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))
где PAGE_OFFSET это __PAGE_OFFSET (0xffff880000000000 и базовый виртуальный адрес прямого отображения всей физической памяти). Таким образом, мы получаем виртуальный адрес структуры boot_params и передаём его функции copy_bootdata, в которой мы копируем real_mod_data в boot_params, объявленный в файле arch/x86/include/asm/setup.h
extern struct boot_params boot_params;
Давайте посмотрим на реализацию copy_boot_data:
static void __init copy_bootdata(char *real_mode_data)
{
char * command_line;
unsigned long cmd_line_ptr;
memcpy(&boot_params, real_mode_data, sizeof boot_params);
sanitize_boot_params(&boot_params);
cmd_line_ptr = get_cmd_line_ptr();
if (cmd_line_ptr) {
command_line = __va(cmd_line_ptr);
memcpy(boot_command_line, command_line, COMMAND_LINE_SIZE);
}
}
Прежде всего, обратите внимание на то, что эта функция объявлена с префиксом __init. Это означает, что эта функция будет использоваться только во время инициализации и используемая память будет освобождена.
Мы можем видеть объявление двух переменных для командной строки ядра и копирование real_mode_data в boot_params функцией memcpy. Далее следует вызов функции sanitize_boot_params, которая заполняет некоторые поля структуры boot_params, такие как ext_ramdisk_image и т.д, если загрузчики не инициализировал неизвестные поля в boot_params нулём. После этого мы получаем адрес командной строки вызовом функции get_cmd_line_ptr:
unsigned long cmd_line_ptr = boot_params.hdr.cmd_line_ptr;
cmd_line_ptr |= (u64)boot_params.ext_cmd_line_ptr << 32;
return cmd_line_ptr;
который получает 64-битный адрес командной строки из заголовочного файла загрузки ядра и возвращает его. На последнем шаге мы проверяем cmd_line_ptr, получаем его виртуальный адрес и копируем его в boot_command_line, который представляет собой всего лишь массив байтов:
extern char __initdata boot_command_line[];
После этого мы имеем скопированную командную строку ядра и структуру boot_params. На следующем шаге происходит вызов функции load_ucode_bsp, которая загружает процессорный микрокод, его мы здесь не увидим.
После загрузки микрокода мы можем видеть проверку функции console_loglevel и early_printk, которая печатает строку Kernel Alive. Но вы никогда не увидите этот вывод, потому что early_printk еще не инициализирован. Это небольшая ошибка в ядре, и я (0xAX, автор оригинальной книги - Прим. пер.) отправил патч - коммит, чтобы исправить её.
Перемещение по страницам инициализации
На следующем шаге, когда мы скопировали структуру boot_params, нам нужно перейти от начальных таблиц страниц к таблицам страниц для процесса инициализации. Мы уже настроили начальные таблицы страниц, вы можете прочитать об этом в предыдущей части и сбросили это всё функцией reset_early_page_tables (вы тоже можете прочитать об этом в предыдущей части) и сохранили только отображение страниц ядра. После этого мы вызываем функцию clear_page:
clear_page(init_level4_pgt);
с аргументом init_level4_pgt, который определён в файле arch/x86/kernel/head_64.S и выглядит следующим образом:
NEXT_PAGE(init_level4_pgt)
.quad level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE
.org init_level4_pgt + L4_PAGE_OFFSET*8, 0
.quad level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE
.org init_level4_pgt + L4_START_KERNEL*8, 0
.quad level3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE
Он отображает первые 2 гигабайта и 512 мегабайта для кода ядра, данных и bss. Функция clear_page определена в arch/x86/lib/clear_page_64.S. Давайте взглянем на неё:
ENTRY(clear_page)
CFI_STARTPROC
xorl %eax,%eax
movl $4096/64,%ecx
.p2align 4
.Lloop:
decl %ecx
#define PUT(x) movq %rax,x*8(%rdi)
movq %rax,(%rdi)
PUT(1)
PUT(2)
PUT(3)
PUT(4)
PUT(5)
PUT(6)
PUT(7)
leaq 64(%rdi),%rdi
jnz .Lloop
nop
ret
CFI_ENDPROC
.Lclear_page_end:
ENDPROC(clear_page)
Как вы можете понять из имени функции, она очищает или заполняет нулями таблицы страниц. Прежде всего обратите внимание, что эта функция начинается с макросов CFI_STARTPROC и CFI_ENDPROC, которые раскрываются до директив сборки GNU:
#define CFI_STARTPROC .cfi_startproc
#define CFI_ENDPROC .cfi_endproc
и используются для отладки. После макроса CFI_STARTPROC мы обнуляем регистр eax и помещаем 64 в ecx (это будет счётчик). Далее мы видим цикл, который начинается с метки .Lloop и декремента ecx. После этого мы помещаем нуль из регистра rax в rdi, который теперь содержит базовый адрес init_level4_pgt и выполняем ту же процедуру семь раз, но каждый раз перемещаем смещение rdi на 8. После этого первые 64 байта init_level4_pgt будут заполнены нулями. На следующем шаге мы снова помещаем адрес init_level4_pgt со смещением 64 байта в rdi и повторяем все операции до тех пор, пока ecx не будет равен нулю. В итоге мы получим init_level4_pgt, заполненный нулями.
После заполнения нулями init_level4_pgt, мы помещаем последнюю запись в init_level4_pgt:
init_level4_pgt[511] = early_top_pgt[511];
Вы должны помнить, что мы очистили все записи early_top_pgt функцией reset_early_page_table и сохранили только отображение ядра.
Последний шаг в функции x86_64_start_kernel заключается в вызове функции x86_64_start_reservations:
x86_64_start_reservations(real_mode_data);
с аргументов real_mode_data. Функция x86_64_start_reservations определена в том же файле исходного кода что и x86_64_start_kernel:
void __init x86_64_start_reservations(char *real_mode_data)
{
if (!boot_params.hdr.version)
copy_bootdata(__va(real_mode_data));
reserve_ebda_region();
start_kernel();
}
Это последняя функция перед входом в точку ядра - start_kernel. Давайте посмотрим, что он делает и как это работает.
Последний шаг перед точкой входа в ядро
В первую очередь мы видим проверку boot_params.hdr.version в функции x86_64_start_reservations:
if (!boot_params.hdr.version)
copy_bootdata(__va(real_mode_data));
и если он равен нулю то снова вызывается функция copy_bootdata с виртуальным адресом real_mode_data.
В следующем шаге мы видим вызов функции reserve_ebda_region, определённой в файле arch/x86/kernel/head.c. Эта функция резервирует блок памяти для EBDA или Extended BIOS Data Area. Extended BIOS Data Area расположена в верхних адресах основной области памяти (conventional memory) и содержит данные о портах, параметрах диска и т.д.
Давайте посмотрим на функцию reserve_ebda_region. Он начинается с проверки, включена ли паравиртуализация или нет:
if (paravirt_enabled())
return;
если паравиртуализация включена, мы выходим из функции reserve_ebda_region, потому что EBDA отсутствует. На следующем шаге нам нужно получить конец нижней области памяти:
lowmem = *(unsigned short *)__va(BIOS_LOWMEM_KILOBYTES);
lowmem <<= 10;
Мы получаем виртуальный адрес нижней области памяти BIOS в килобайтах и преобразуем его в байты, сдвигая его на 10 (другими словами умножаем на 1024). После этого нам нужно получить адрес EBDA:
ebda_addr = get_bios_ebda();
Функция get_bios_ebda определена в файле arch/x86/include/asm/bios_ebda.h:
static inline unsigned int get_bios_ebda(void)
{
unsigned int address = *(unsigned short *)phys_to_virt(0x40E);
address <<= 4;
return address;
}
Давайте попробуем понять, как это работает. Мы видим преобразование физического адреса 0x40E в виртуальный, где 0x0040: 0x000e - это сегмент, который содержит базовый адрес EBDA. Не беспокойтесь о том, что мы используем функцию phys_to_virt для преобразования физического адреса в виртуальный. Вы можете заметить, что ранее мы использовали макрос __va, но phys_to_virt - это то же самое:
static inline void *phys_to_virt(phys_addr_t address)
{
return __va(address);
}
только с одним отличием: мы передаем аргумент phys_addr_t, который зависит от CONFIG_PHYS_ADDR_T_64BIT:
#ifdef CONFIG_PHYS_ADDR_T_64BIT
typedef u64 phys_addr_t;
#else
typedef u32 phys_addr_t;
#endif
Мы получили виртуальный адрес сегмента, в котором хранится базовый адрес EBDA. Мы сдвигаем его на 4 и возвращаем как результат. После этого переменная ebda_addr содержит базовый адрес EBDA.
На следующем шаге мы проверяем, что адрес EBDA и нижняя область памяти не меньше, чем значение макроса INSANE_CUTOFF:
if (ebda_addr < INSANE_CUTOFF)
ebda_addr = LOWMEM_CAP;
if (lowmem < INSANE_CUTOFF)
lowmem = LOWMEM_CAP;
где INSANE_CUTOFF:
#define INSANE_CUTOFF 0x20000U
или 128 килобайт. На последнем шаге мы получаем нижнюю часть нижней области памяти и EBDA и вызываем функцию memblock_reserve, которая резервирует область памяти для EBDA между нижней областью памяти и одномегабайтной меткой:
lowmem = min(lowmem, ebda_addr);
lowmem = min(lowmem, LOWMEM_CAP);
memblock_reserve(lowmem, 0x100000 - lowmem);
функция memblock_reserve определена в mm/block.c и принимает два аргумента:
- базовый физический адрес;
- размер области памяти.
и резервирует область памяти для заданного базового адреса и размера. memblock_reserve - первая функция в этой книге из фреймворка менеджера памяти ядра Linux. Мы скоро рассмотрим менеджер памяти, но пока что посмотрим на его реализацию.
Первое знакомство с фреймворком менеджера памяти ядра Linux
В предыдущем абзаце мы остановились на вызове функции memblock_reserve и, как я уже сказал, это первая функция из фреймворка менеджера памяти. Давайте попробуем понять, как это работает. memblock_reserve просто вызывает функцию:
memblock_reserve_region(base, size, MAX_NUMNODES, 0);
и передаёт ей 4 аргумента:
- физический базовый адрес области памяти;
- размер области памяти;
- максимально число NUMA-узлов;
- флаги.
В начале тела функции memblock_reserve_region мы можем видеть определение структуры memblock_type:
struct memblock_type *_rgn = &memblock.reserved;
которая представляет тип блока памяти:
struct memblock_type {
unsigned long cnt;
unsigned long max;
phys_addr_t total_size;
struct memblock_region *regions;
};
Поскольку нам необходимо зарезервировать блок памяти для EBDA, тип текущей области памяти зарезервирован так же, где и структура memblock:
struct memblock {
bool bottom_up;
phys_addr_t current_limit;
struct memblock_type memory;
struct memblock_type reserved;
#ifdef CONFIG_HAVE_MEMBLOCK_PHYS_MAP
struct memblock_type physmem;
#endif
};
и описывает общий блок памяти. Мы инициализируем _rgn адресом memblock.reserved. memblock - глобальная переменная:
struct memblock memblock __initdata_memblock = {
.memory.regions = memblock_memory_init_regions,
.memory.cnt = 1,
.memory.max = INIT_MEMBLOCK_REGIONS,
.reserved.regions = memblock_reserved_init_regions,
.reserved.cnt = 1,
.reserved.max = INIT_MEMBLOCK_REGIONS,
#ifdef CONFIG_HAVE_MEMBLOCK_PHYS_MAP
.physmem.regions = memblock_physmem_init_regions,
.physmem.cnt = 1,
.physmem.max = INIT_PHYSMEM_REGIONS,
#endif
.bottom_up = false,
.current_limit = MEMBLOCK_ALLOC_ANYWHERE,
};
Мы не будем погружаться в детали этой переменной, но мы увидим все подробности об этом в частях о менеджере памяти. Просто отметьте, что переменная memblock определена с помощью__initdata_memblock:
#define __initdata_memblock __meminitdata
где __meminit_data:
#define __meminitdata __section(.meminit.data)
Из этого можно сделать вывод, что все блоки памяти будут в секции .meminit.data. После того как мы определили _rgn, мы печатаем информацию об этом с помощью макроса memblock_dbg. Вы можете включить его, передав memblock = debug в командную строку ядра.
После печати строк отладки следует вызов функции memblock_add_range:
memblock_add_range(_rgn, base, size, nid, flags);
которая добавляет новую область блока памяти в секцию .meminit.data. Поскольку мы не инициализируем _rgn и он содержит &memblock.reserved, мы просто заполняем переданный _rgn базовым адресом EBDA, размером этой области и флагами:
if (type->regions[0].size == 0) {
WARN_ON(type->cnt != 1 || type->total_size);
type->regions[0].base = base;
type->regions[0].size = size;
type->regions[0].flags = flags;
memblock_set_region_node(&type->regions[0], nid);
type->total_size = size;
return 0;
}
После заполнения нашей области памяти мы видим вызов функции memblock_set_region_node с двумя аргументами:
- адрес заполненной области памяти;
- id NUMA-узла.
где наши области памяти представлены структурой memblock_region:
struct memblock_region {
phys_addr_t base;
phys_addr_t size;
unsigned long flags;
#ifdef CONFIG_HAVE_MEMBLOCK_NODE_MAP
int nid;
#endif
};
Id NUMA-узла зависит от макроса MAX_NUMNODES, определённого в файле include/linux/numa.h:
#define MAX_NUMNODES (1 << NODES_SHIFT)
где NODES_SHIFT зависит от параметра конфигурации CONFIG_NODES_SHIFT:
#ifdef CONFIG_NODES_SHIFT
#define NODES_SHIFT CONFIG_NODES_SHIFT
#else
#define NODES_SHIFT 0
#endif
Функция memblock_set_region_node просто заполняет поле nid из memblock_region заданным значением:
static inline void memblock_set_region_node(struct memblock_region *r, int nid)
{
r->nid = nid;
}
После этого у нас будет первый зарезервированный memblock для EBDA в секции .meminit.data. Функция reserve_ebda_region завершила работу над этим шагом, и мы можем вернуться в arch/x86/kernel/head64.c.
Мы закончили все приготовления! Последним шагом в функции x86_64_start_reservations является вызов функции start_kernel:
start_kernel()
расположенной в init/main.c.
Заключение
Это конец третей части инициализации ядра Linux. В следующей части мы увидим первые шаги инициализации в точке входа в ядро - start_kernel. Это будет первый шаг, прежде чем мы увидим запуск первого процесса init.
От переводчика: пожалуйста, имейте в виду, что английский - не мой родной язык, и я очень извиняюсь за возможные неудобства. Если вы найдёте какие-либо ошибки или неточности в переводе, пожалуйста, пришлите pull request в linux-insides-ru.