Добавить в корзинуПозвонить
Найти в Дзене
Триалогия

Пишем операционную систему Триалогия - Загрузчик ELF

В прошлой части речь шла о том, как процессы обмениваются данными друг с другом. Но откуда вообще берётся процесс? На диске лежит лишь файл, ком байтов. Превратить этот ком в работающий процесс, со своим адресным пространством, загруженным кодом и первым потоком, это работа загрузчика ELF. ELF, это формат файла для исполняемых программ, и он очень хорошо продуман. Файл ELF начинается с заголовка, который несёт самые важные опорные данные. Первые четыре байта, это магия 0x7 E L F, дальше идут архитектура и тип: typedef struct { uint8_t e_ident[16]; // магия 0x7F E L F + класс (32-бит) + порядок байт uint16_t e_type; // ET_EXEC · ET_DYN (PIE) · ET_REL (модуль) uint16_t e_machine; // EM_386 uint32_t e_entry; // адрес входа uint32_t e_phoff; // смещение таблицы program header uint16_t e_phnum; // число program header /* ... */ } __attribute__((packed)) Elf32_Ehdr; Интересно становится с двумя таблицами, на которые указывает заголовок, ведь они описывают од
Оглавление
Триалогия - Пишем операционную систему
Триалогия - Пишем операционную систему

Загрузчик ELF

В прошлой части речь шла о том, как процессы обмениваются данными друг с другом. Но откуда вообще берётся процесс? На диске лежит лишь файл, ком байтов. Превратить этот ком в работающий процесс, со своим адресным пространством, загруженным кодом и первым потоком, это работа загрузчика ELF.

ELF, это формат файла для исполняемых программ, и он очень хорошо продуман.

Что сидит внутри файла ELF

Анатомия файла ELF - Триалогия
Анатомия файла ELF - Триалогия

Файл ELF начинается с заголовка, который несёт самые важные опорные данные. Первые четыре байта, это магия 0x7 E L F, дальше идут архитектура и тип:

typedef struct {

uint8_t e_ident[16]; // магия 0x7F E L F + класс (32-бит) + порядок байт

uint16_t e_type; // ET_EXEC · ET_DYN (PIE) · ET_REL (модуль)

uint16_t e_machine; // EM_386

uint32_t e_entry; // адрес входа

uint32_t e_phoff; // смещение таблицы program header

uint16_t e_phnum; // число program header

/* ... */

} __attribute__((packed)) Elf32_Ehdr;

Интересно становится с двумя таблицами, на которые указывает заголовок, ведь они описывают один и тот же файл с двух сторон. Таблица program header, это взгляд исполнения - она говорит, какие куски файла грузятся куда в память. Каждая запись типа PT_LOAD, это сегмент:

typedef struct {

uint32_t p_type; // PT_LOAD = загружаемый сегмент

uint32_t p_offset; // где он лежит в файле

uint32_t p_vaddr; // куда он должен попасть в память

uint32_t p_filesz; // сколько байт в файле

uint32_t p_memsz; // сколько байт он занимает в памяти

uint32_t p_flags; // PF_R | PF_W | PF_X (чтение / запись / исполнение)

} __attribute__((packed)) Elf32_Phdr;

Тонкая разница между p_filesz и p_memsz, это весь трюк за сегментом BSS: если в памяти больше, чем в файле, остаток просто заполняется нулями, экономя килобайты нулей в файле. Вторая таблица, section header, это взгляд линкера (.text, .data, .symtab, .rel). При обычном запуске программы она не нужна, но при загрузке модуля ядра она категорически необходима, об этом сейчас.

От файла к процессу

От файла к процессу - Триалогия
От файла к процессу - Триалогия

Загрузка обычной программы, это фиксированная последовательность. Вот elf_load_user, сильно сокращённая загрузка пользовательской программы:

Process* elf_load_user(const char* filename, const uint8_t* elf_data, uint32_t size, ...) {

if (elf_validate_header(elf_data, size) != ELF_SUCCESS) return NULL;

const Elf32_Ehdr* ehdr = elf_get_header(elf_data);

// PIE грузится перемещённым, статическая программа на фиксированные адреса

uint32_t load_bias = (ehdr->e_type == ET_DYN) ? 0x08000000 : 0;

Process* process = create_process(filename, false); // false = ring 3

process->suspend = true; // приостановлен на время сборки

fs_context_copy(get_current_process(), process); // унаследовать раздел и cwd

elf_load_segments(elf_data, size, process, load_bias, false); // PT_LOAD → память

if (ehdr->e_type == ET_DYN)

/* ... применить релокации R_386_RELATIVE (PIE) ... */;

uint32_t entry = ehdr->e_entry + load_bias;

create_thread(process, (void*)entry, 64*1024, false, /*suspended=*/true);

// ... в самом конце: process_resume() → первый поток начинает работать

}

Тут важны три вещи.

  1. приостановка на время сборки. между "процесс существует" и "всё загружено" много промежуточных шагов. И если бы поток (thread) уже работал до этого, он прыгнул бы в полуготовый код, мгновенный сбой. Поэтому он создаётся приостановленным и стартует лишь в самом конце через process_resume.
  2. отображение сегментов. На каждый PT_LOAD копируется filesz байт из файла, а остаток до memsz заполняется нулём.
  3. релокации для формата PIE.

PIE и релокации

Статический ET_EXEC имеет фиксированные адреса уже записанные в файле, тогда load_bias равен нулю. PIE (ET_DYN position independent executable) напротив, перемещаем. Грузишь его на свободно выбранную базу (здесь 0x08000000), а потом надо поправить все абсолютные указатели в коде ровно на это смещение. Эти поправки зовутся релокациями и для бинарников PIE R_386_RELATIVE это самый важный тип. Здесь он нужен мне, например, для библиотек с таблицами указателей, вроде графической библиотеки с её списком обработчиков форматов изображений, которые иначе все указывали бы в пустоту.

Три режима, три типа ELF

Три режима, три типа ELF
Три режима, три типа ELF

Один и тот же загрузчик (не путайте здесь elf_loader и bootloader) может загрузить один и тот же вид файла в три очень разных места. ELF_LOAD_USER это обычная программа, ET_EXEC или ET_DYN грузится в ring 3 в userspace, с доступом к системе только через kcall. ELF_LOAD_KERNEL_THREAD грузит ELF как поток ядра в ring 0, у меня это пока заготовка. А ELF_LOAD_MODULE грузит ET_REL, модуль (.km kernel modul) в кучу ядра выше 0x80000000. Здесь в дело вступают section header - секции грузятся, внешние символы разрешаются через resolve_symbol из таблицы символов ядра и применяются релокации (R_386_32, R_386_PC32, R_386_RELATIVE).

Именно этот третий режим самый опасный - модуль работает в ring 0 с полным доступом к системе, песочницы нет. Вредоносный или сломанный модуль может всё. Поэтому модули нельзя просто грузить, они должны быть подписаны, и это тема следующей части.

И опять правда жизни

Загрузчик elf loader, это один из тех компонентов, где ошибка в порядке даёт большой эффект. Приостановка resume звучит тривиально, но я однажды искал здесь часами, почему свежезапущенные программы просто ничего не делали. Забыл resume, поток лежал полностью загруженным и никогда не попадал в очередь выполнения. Режим потока ядра к тому же, честно говоря, пока заготовка, архитектура готова, но полной реализации нет. То для чего я планировал этот режим, ушло в userspace. Так и стоит памятником напильнику. А отсутствие песочницы для модулей, это не недосмотр, а осознанное решение, подстрахованное подписью (sign).

Что дальше

Если модуль в ring 0 может всё, то вопрос, кто решает, какой модуль вообще можно загрузить, это вопрос безопасности первой величины. Ответ, это подписанные программы. Каждый загружаемый файл несёт криптографическую подпись, которую ядро проверяет перед загрузкой. Как это работает с ECDSA и SHA-256, показывает следующая часть.

Было бы интересно увидеть ваши комментарии и улучшить статьи.

Предыдущая статья Содержание Следующая статья

*Система не стоит на месте, поэтому в дальнейшем тексты могут не совпадать с реальным положением