Пишем свой загрузчик операционной системы Linux
- пятница, 7 марта 2025 г. в 00:00:19
Меня давно интересовал вопрос, насколько сложно написать собственный загрузчик операционной системы. Я не говорю о простой программе, выводящей «Hello, World!», а о полноценном загрузчике, который передаёт управление от встроенного программного обеспечения компьютера ядру операционной системы. Современные загрузчики представляют собой сложные программы, способные загружать множество операционных систем различными способами, учитывая массу нюансов, связанных с программным и аппаратным обеспечением. Читая их исходный код, легко утонуть в деталях и потерять понимание сути и реализации.
Я решил начать изучение с максимально простого подхода, постепенно усложняя задачи, экспериментируя и получая новые знания. Если мне удалось вас заинтересовать, добро пожаловать под кат.
Рассказать о том, как написать загрузчик, — задача не из тривиальных. Это связано с тем, что затрагивается множество связанных и несвязанных тем и требуется хотя бы базовое понимание следующего:
Естественно, что полноценно охватить все эти темы в статье невозможно, но я старался изложить материал так, чтобы читатели с минимальными знаниями в этих областях могли понять основы написания загрузчика и попробовать реализовать его самостоятельно. Часть информации можно извлечь из загрузчика для учебных целей asbootsap, написанного мной. Я уверен, что можно написать более безопасный и надёжный код. Но моя цель заключалась в написании загрузчика, способного загружать Linux и содержащего минимум программного кода. Поэтому я установил для себя следующие ограничения для загрузчика:
Вас не должны пугать эти ограничения, так с этим загрузчиком можно загрузить большинство известных дистрибутивов Linux. Если вас испугали термины в начале, но есть желание разобраться, не бойтесь, по прочтению статьи, многие перестанут быть набором непонятных символов.
Надеюсь, вы получите удовольствие от собственноручно переписанного с моего примера загрузчика, а после получения новых знаний работа загрузчика не будет казаться магией.
Если вы хотите проверить всё на практике, вам необходимо будет установить какой-нибудь дистрибутив Linux.
Как происходит дальнейшая загрузка ядра Linux, я не рассматриваю, одна из целей моей статьи — изложить, как происходит передача управления от UEFI ядру операционной системы. А начну я с того, как можно загрузить Linux без использования привычного многим загрузчика, такого как, например, GRUB.
Unified Extensible Firmware Interface (UEFI) — это спецификация, разработанная Unified EFI Forum, которая описывает интерфейс между операционной системой и прошивкой (firmware) компьютера. Спецификация накладывает определённые ограничения на архитектуру разрабатываемых приложений. Хотя UEFI не зависит от языка программирования, описания структур и функций в спецификации приведены на языке C.
Как правило, для спецификации существует reference implementation, UEFI не является исключением и имеет reference implementation TianoCore EDK II. Она представляет собой среду разработки, позволяющую создавать драйвера и приложения для UEFI-систем. Разрабатывать и отлаживать UEFI-приложения удобнее в виртуальных машинах, поэтому существует специальная реализация UEFI-Firmware для виртуальных машин Qemu и KVM — OVMF.
TianoCore EDK II может быть сложной для понимания и освоения. Для разработки более простых UEFI-приложений можно использовать легковесную среду gnu-efi, которую я и буду использовать в статье.
[Спецификация UEFI] (https://uefi.org/specs/UEFI/2.10/) достаточно объёмный документ. Но я изложу суть, которая позволит вам написать свой загрузчик.
Можно сказать, что UEFI имеет объектно-ориентированную архитектуру. Основу составляют такие понятия, как Protocol, Protocol Interface, Handle, System Table, Boot Services, _Runtime Services. Для меня сложно было с первого раза понять суть Protocol, Protocol Interface, Services в UEFI, так как термины употребляются в немного другом контексте, отличном от привычного.
Исторически так сложилось, что при включении питания процессор находится в так называемом Real Mode. В этом режиме используется 16-битная сегментная адресация, и доступно только 1 Миб памяти, нет защиты памяти, нет поддержки многозадачности, все адреса являются физическими. Для работы операционной системы с 32-битным ядром необходимо переключиться в так называемый Protected Mode, а с 64-битным в Long Mode, которые лишены этих недостатков. Информация о том, как переключаться в эти режимы нам не понадобится, так как в момент передачи управления загрузчику UEFI сделает это за нас. Эта информация была необходима, когда использовалась передача управления загрузчику из более старого 16-битного BIOS. При 64-битном UEFI процессор будет находиться в Long Mode.
Исполняемый код в UEFI хранится в файлах формата PE32+/COFF. Существует 3 вида UEFI Images:
По сути UEFI OS Loaders — это подвид UEFI Application, задача которого передать управление операционной системе, предварительно вызвав ExitBootServices(). UEFI Driver мы рассматривать не будем, главное его отличие от UEFI Application, что он остается резидентным в памяти после возврата из точки входа.
В UEFI можно загрузить как UEFI Image, так и файл, и это важно различать. Когда вы загружаете UEFI Image с помощью функции LoadImage(), система автоматически выделяет оперативную память, анализирует PE-заголовок и размещает содержимое образа в соответствующих областях памяти.
Загрузка файла представляет собой более простую операцию, но от разработчика требуется больше усилий. В этом случае необходимо самостоятельно выделить память для содержимого файла, а затем использовать функции для работы с файлами, чтобы считать данные с устройства и разместить их по адресу выделенной памяти. В нашем случае устройством будет файловая система на разделе ESP.
Ранее я объяснил простыми словами, как осуществляется загрузка. Ниже приведу классический рисунок, иллюстрирующий фазы загрузки UEFI-окружения.
Для нас представляет интерес только фаза Transient System Load (TSL). В этой фазе осуществляется передача управления от встроенного программного обеспечения операционной системе.
OS Loader Image — подвид приложений UEFI, задача которого — передать управление от UEFI ядру операционной системы. Как правило, должен находиться на ESP-разделе диска, но может располагаться и в компьютерной сети или, более экзотический вариант, прошит в микросхеме материнской платы.
Как запустить OS Loader Image? Самый простой способ — назвать его BOOTX64.EFI и поместить в директорию на разделе ESP. Когда вы выберете в настройках UEFI загрузку с диска, на котором расположен этот раздел, UEFI запустит OS Loader Image. Второй способ предполагает использование EFIShell — UEFI-приложения, предоставляющего интерфейс командной строки к UEFI, напоминающий bash или другую подобную оболочку. Также можно прописать информацию о загрузчике в UEFI Boot Manager.
UEFI Image имеет точку входа. Точка входа — это функция с двумя параметрам: Handle самого UEFI-приложения и указатель на System Table. System Table является важной структурой, с помощью которой UEFI-приложение может взаимодействовать с UEFI-окружением, и содержит указатели на интерфейсы ввода/вывода, Boot Services и Runtime Services. Можно сказать, что System Table является корнем иерархии UEFI. Чтобы вам было понятнее, приведу исходный код её определения из файла efiapi.h.
typedef struct _EFI_SYSTEM_TABLE {
EFI_TABLE_HEADER Hdr;
CHAR16 *FirmwareVendor;
UINT32 FirmwareRevision;
EFI_HANDLE ConsoleInHandle;
SIMPLE_INPUT_INTERFACE *ConIn;
EFI_HANDLE ConsoleOutHandle;
SIMPLE_TEXT_OUTPUT_INTERFACE *ConOut;
EFI_HANDLE StandardErrorHandle;
SIMPLE_TEXT_OUTPUT_INTERFACE *StdErr;
EFI_RUNTIME_SERVICES *RuntimeServices;
EFI_BOOT_SERVICES *BootServices;
UINTN NumberOfTableEntries;
EFI_CONFIGURATION_TABLE *ConfigurationTable;
} EFI_SYSTEM_TABLE;
Services в UEFI немного отличаются от сервисов, которым мы привыкли в программировании веб-приложений или системном программировании под Windows. Сервисы в UEFI — это функции, выполняющие системные задачи. Например, найти Handle объекта по идентификатору поддерживаемого протокола или выделить/освободить память.
Существуют Boot Services (доступные в фазе TSL) и Runtime Services (доступные как в фазе TSL, так и после её завершения). Boot Services и Runtime Services — это ключевые компоненты инфраструктуры UEFI, предоставляющие доступ к функциональности прошивки и обеспечивающие работу драйверов и приложений.
Я не использовал в своём загрузчике Runtime Services, но при желании читатель может их также попробовать их использовать, например, добавить отображение текущего времени в загрузчике.
Boot Services и Runtime Services содержат только базовые функции UEFI, остальные, располагающиеся в приложениях и драйверах, доступны через механизм Handle/Protocol/Protocol Interface. UEFI является extensible именно благодаря ему.
Сущности, с которым мы можем взаимодействовать в UEFI, имеют уникальный идентификатор, называемый Handle. Handle находят в UEFI-окружении по идентификатору протокола при помощи функций Boot Services LocateHandle или LocateHandleBuffer. Для изменения состояния сущности следует запросить её Interface, используя функции HandleProtocol или OpenProtocol. Каждый UEFI Protocol имеет уникальный идентификатор (GUID) и определение Protocol Interface Structure. Когда вы запрашиваете Interface, вы получаете экземпляр Protocol Interface Structure, содержащей данные и указатели на функции. Работать с состоянием объекта UEFI можно, изменяя эти данные и вызывая функции.
Загрузчик или ядро операционной системы должны знать о том, как распределены адреса оперативной памяти, какую память можно использовать, а какую трогать нельзя. Эта информация хранится в структуре, называемой Memory Map. Для получения Memory Map необходимо вызвать системную функцию GetMemoryMap в случае UEFI, или инициировать программное прерывание INT 0x15 (System BIOS Services) c AX=0xE820 (Query System Address Map) в случае BIOS. Memory Map, полученная с использованием прерывания BIOS, называется E820 Memory Map. E820 Memory Map будет необходима нам для загрузки Linux, но получать мы её будем путём преобразования из EFI Memomory Map и прерывание BIOS вызывать не будем.
О процессе загрузки операционной системы много написано в книгах, статьях и интернете, снято множество видеороликов, но я хочу выделить самое важное, нужное для создания нашего загрузчика. Загрузка может немного различаться в зависимости от архитектуры компьютера, встроенного программного обеспечения материнской платы, операционной системы. Поэтому далее в статье по умолчанию будет подразумеваться архитектура x86-64, ПО материнки UEFI и операционная система Linux.
Загрузчик является промежуточным звеном между UEFI и операционной системой, так как его код выполняется до загрузки последней, он не может использовать привычные функции из операционной системы, сборка его исходного кода, а также отладка отличаются.
Можно загрузить операционную систему без него, но это менее гибкий вариант и существует ряд особенностей.
Для вас может стать открытием, что можно загрузить Linux без использования загрузчика на системах UEFI. Да, это действительно возможно, если ядро Linux скомпилировано с установленным параметром CONFIG_EFI_STUB. Хочу вас обрадовать, в современных ядрах этот параметр, как правило, включён.
Для текущего ядра Linux узнать включён этот параметр или нет, можно командой:
cat /boot/config-$(uname -r) | grep CONFIG_EFI_STUB
Для загрузки Linux необходимо следующее:
Мне известно о четырёх способах загрузки Linux без загрузчика:
\vmlinuz initrd=/initrd.img root=UUID=7023590e-426d-4087-bb7d-4bc6fd1bbc7a quiet splash
В этом файле можно прописать команду по загрузке Linux, которая используется в первом способе. Её не нужно вводить постоянно, она будет выполняться каждый раз при включении компьютера. Кодировка файла должна быть ASCII.
Переходим в корень ESP-раздела:
fs0:\
Выводим загрузочные записи:
bcfg boot dump
Добавляем загрузочную запись:
bcfg boot add 2 fs0:\vmlinuz "My Linux"
Параметры командной строки для передачи bcfg должны располагаться в файле, поэтому создаем файл options.txt:
edit options.txt
Вводим содержимое файла и сохраняем, нажав клавишу F2, а потом F3:
initrd=/initrd.img root=UUID=7023590e-426d-4087-bb7d-4bc6fd1bbc7a quiet splash
Добавляем содержимое командной строки в загрузочную запись:
bcfg boot -opt 2 fs0:\options.txt
Если хотим удалить загрузочную запись:
bcfg boot rm 2
efibootmgr позволяет добавлять и управлять загрузочными записями в UEFI NVRAM, обеспечивая возможность загрузки Linux без загрузчика.
sudo efibootmgr -c -d /dev/sda -p 1 -L "My Linux" -l '\vmlinuz' -u 'root=UUID=7023590e-426d-4087-bb7d-4bc6fd1bbc7a initrd=\initrd.img quiet splash'
efibootmgr создаёт загрузочную запись с названием My Linux на первом разделе диска /dev/sda. В приведенном выше примере UEFI загружает файл vmlinuz, находящийся в корне файловой системе раздела. В качестве параметров ядру передадутся: initrd, root, quiet splash Параметр intird указывает расположение файла начальной корневой файловой системы. Параметр root указывает на расположение корневой файловой системы. Расположение корневой файловой системы можно задать различными способами: например, указав имя блочного устройства раздела, где она располагается (/dev/sda1), а можно, указав идентификатор раздела или файловой системы (это более предпочтительный способ). Тут используется идентификатор файловой системы. Идентификатор файловой системы, расположенной на разделе, можно узнать при помощи команды:
blkid -s UUID -o value /dev/nvme0n1p1
А имена присутствующих разделов можно узнать при помощи команды:
lsblk -o name -lpn
Параметры quiet и splash можно не использовать, они нужны, если вы хотите минимизировать вывод сообщений при загрузке ядра.
Обратите внимание, что:
Я советую экспериментировать с загрузкой, используя виртуальную машину qemu. В этом вам может помочь мой небольшой проект для создания образа диска с ESP-разделом и небольшим дистрибутивом на основе Linux. Более подробно, как создавать дистрибутивы Linux я рассматривал в своей статье
Поэкспериментировав с загрузкой Linux без загрузчика, перейдём к написанию собственного.
Что делает загрузчик операционной системы Linux? Он, следуя заданным настройкам, находит доступное для загрузки ядро и соответствующий файл образа начальной файловой системы (initramfs/initrd), загружает их в оперативную память и запускает ядро, передавая ему параметры командной строки, включающие информацию о корневой файловой системе и другие настройки.
Как запустить загрузчик? В случае UEFI — загрузчик является UEFI-приложением. Запустить загрузчик вы можете:
Правила передачи управления от загрузчика к ядру операционной системы называются протоколом загрузки.
Ядро Linux представляет собой ELF-файл, однако загрузчики на архитектурах x86-64 не могут работать с этим форматом (по крайней мере, я не встречал). Ядро на архитектуре x86-64 упаковывается в файл формата bzImage. Что будет содержать файл bzImage, задается на этапе компиляции ядра. Как правило, это:
Большинство современных ядер имеют EFIStub — специальный код, который позволяет UEFI рассматривать ядро Linux как UEFI-приложение.
Для нас важно следующее:
Заголовок ядра занимает не более двух секторов (1024 байтов) и в зависимости от конфигурации ядра, заданного при его компиляции, может различаться. Например, если ядро скомпилировано с параметром CONFIG_EFI_STUB, в заголовке будут присутствовать PE и COFF заголовки.
Ядро Linux в формате bzImage имеет несколько точек входа:
Нас интересуют только последние три.
Я долгое время считал, что для загрузки Linux используется Multiboot Protocol, но это не так. На архитектуре x86-64, как правило, применяется Linux Boot Protocol. Статья c описанием Linux Boot Protocol является отправной точкой для погружения в подробности магии загрузки. Некоторые вещи, изложенные в статье по Linux Boot Protocol, для меня до сих пор остаются загадкой, а написать загрузчик, используя только информацию, изложенную там, вряд ли получится. Несмотря на это, в той статье много информации касается загрузки на устаревших системах с Legacy BIOS, она значительно помогла разобраться в теме. Я рассмотрю 64-bit Linux Boot Protocol, EFI Handover и, хотя он там не описан, самый простой в программировании протокол Chainload.
Суть протокола заключается в том, что современные ядра Linux являются валидными UEFI-приложениями. Это позволяет загрузчику загружать и выполнять UEFI-образ приложения, используя API, предоставляемые UEFI. Initramfs загружается в оперативную память не загрузчиком, а самим ядром Linux, при этом указание, какой initramfs использовать, передаётся одним из параметров командной строки Linux. В рамках данного протокола можно загружать только те ядра, которые содержат поддержку EFIStub.
Протокол EFI Handover подразумевает, что загрузчик помещает содержимое ядра и initramfs в оперативную память, предварительно разобрав заголовок файла ядра Linux в формате bzImage. Для передачи управления Linux загрузчик должен вызвать функцию, адрес которой записан в поле handover_offset заголовка. Для архитектуры x86-64 абсолютный адрес функции вычисляется следующим образом:
handover_function_address = kernel_loading_address + handover_offset + 512
Где:
Функция требует три параметра:
Протокол EFI Handover считается устаревшим, но я его привёл, так как считаю его заслуживающим рассмотрения.
64-bit Linux Boot Protocol самый сложный из трёх, из рассматриваемых. Загрузчик не только помещает ядро и initramfs в оперативную память и заполняет структуру boot_params, но и должен выполнить дополнительные действия:
boot64_function_address = kernel_loading_address + 512
Написание загрузчика подразумевает разработку программы, работающую в UEFI-окружении, использующую сервисы и протоколы UEFI и реализующую протокол загрузки ядра, требуемый операционной системой (не путать протокол загрузки ОС с протоколом UEFI).
Разрабатывать загрузчик с нуля только по спецификациям, наверное, можно, но проще подсмотреть часть кода в существующих загрузчиках с открытым исходным кодом. Программный код всё-таки лучше объясняет то, что написано в спецификации, так как исключает неоднозначности. Поэтому я поверхностно изучил программный код известных мне загрузчиков.
За основу мной был взят код EfiLinux (давно написанного примера загрузчика). Он поддерживает загрузку по протоколу EFI-Handover и Linux Boot Protocol (для старых ядер Linux). Моя модификация его кода для загрузки по протоколу Linux Boot Protocol для новых ядер не помогла, что меня зацепило, и мной был написан свой загрузчик, который был лишён этого недостатка. Очень помогли исходные коды Limine (он дал уверенность в том, что UEFI-загрузчик с использованием Linux Boot Protocol возможен) и rEFInd (я разобрался, как передавать параметры ядра при загрузке по протоколу Chainload).
Я разрабатывал загрузчик итеративно, но в статье привожу только полученный результат.
Первая версия моего простейшего загрузчика без поддержки конфигурационного файла, который как по волшебству загрузил Debian, установленный на моём ноутбуке, отсутствует, так как это менее презентабельно и удобно с точки зрения пользователя. Это немного усложнило код, но загрузчик теперь можно использовать не только в учебных целях, а и для загрузки реальных дистрибутивов Linux, хотя и с ограничениями.
К параметрам, поддерживаемым моим загрузчиком относятся:
Основной алгоритм работы загрузчика следующий:
Выше был приведен укрупнённый алгоритм работы загрузчика, без учёта граничных условий и обработки ошибок. Часть ошибок и граничных условий я учёл в своём программном коде, часть не учитывал с целью упрощения чтения кода (оставил пытливому читателю :) ). Несмотря на простоту алгоритма, каждый из шагов требует понимания основ UEFI, программирования на языке С или ассемблера. Если у вас есть базовое понимание, как С работает с памятью, и арифметики указателей, разобраться с кодом будет проще.
Создание EFI-приложений с помощью gnu-efi несложный процесс, однако нужно понимание некоторых вещей.
Мы уже рассматривали с вами, как осуществляется взаимодействие UEFI-приложения и UEFI-прошивки. Но не учитывали так называемые соглашения о вызовах функций.
Соглашение о вызове определяет, как передаются аргументы в функцию, как возвращаются результаты, кто отвечает за очистку стека и как используются регистры процессора. Эти правила обеспечивают совместимость между вызывающей стороной (caller) и вызываемой функцией (callee), особенно в случае, если они написаны на разных языках программирования или компилируются разными компиляторами. Наверное, лучше всего понять соглашение о вызове функции, если вы попытаетесь написать функцию на языке ассемблера и вызвать её из кода, написанного на си, но это не тема моей статьи.
Так исторически сложилось, что в разных операционных системах на разных архитектурах используются разные соглашения о вызовах функций. Соглашения о вызове функций являются частью Application Binary Interface (ABI). UEFI, хотя и не является операционной системой, также предъявляет требования к соглашению о вызовах функций. Для разных архитектур (Instruction Set Architecture) в UEFI используются разные соглашения о вызовах, для x86-64 — это Microsoft’s 64-bit calling convention.
Соглашения нужны для того, чтобы компилятор корректно сгенерировал объектный код для вызова функции. Интересно, но функции, внутренние для UEFI-приложения могут иметь любое соглашение о вызове. Соблюдение соглашения о вызове важно только для функций UEFI-приложения, которые вызывает UEFI-прошивка, и для функций UEFI-прошивки, которые вызывает UEFI-приложение.
Нам важно знать о соглашениях о вызовах функций, так как мы используем компилятор gcc для архитектуры x86-64, который по умолчанию использует соглашение отличное от Microsoft’s 64-bit calling convention.
При создании EFI-приложения при помощи gnu-efi добавляется ещё одна стадия — преобразование разделяемой библиотеки в файл формата .efi. Это справедливо при использовании компилятора gcc, использование clang позволяет избежать этой стадии, но я использовал gcc в учебных целях.
Разработка EFI-приложения отличается от разработки привычных пользовательских приложений, так как оно запускается не в операционной системой, а в EFI-окружении. Например:
Компоновка подразумевает, что несколько объектных файлов объединяются в библиотечный или исполняемый файл. Обычно существуют стандартные правила, как это делать, но в случае использования gnu-efi необходим пользовательский скрипт компоновки, предоставляемый gnu-efi. Я не буду рассматривать подробности, вам только важно знать, что нужно использовать этот файл при компоновке.
Пакет gnu-efi содержит в себе различные файлы, приведу те, о которых нам необходимо знать:
Пакет gnu-efi не содержит *.pc файла, а значит, не поддерживается утилитой pkg-config, поэтому расположение заголовочных файлов и библиотек нужно будет явно указывать компилятору и компоновщику. На моём дистрибутиве они располагались в /usr/include/efi и /usr/lib соответственно. Вы всегда можете найти расположение, используя команду:
find . -name <имя файла>
Ну вот, наконец, я изложил достаточно материала, и вы можете рассмотреть исходный код моего загрузчика.
Подробно рассматривать файлы с исходным кодом, не вижу смысла, так как вы можете посмотреть и изучить их сами.
Далее рассмотрим, какие параметры мы должны передать каждой из программ. Я привожу содержимое Makefile. Если вы незнакомы с Makefile-файлами и утилитой make, могу посоветовать хороший туториал по ним.
BUILD_DIR := ./build
SRC_DIRS := ./src
LIBDIR := /usr/lib
ARCH := x86_64
TARGET_NAME := asbootsap
OBJCOPY := objcopy
CC := gcc
LD := ld
FORMAT := efi-app-x86-64
SECTIONS := .text .sdata .data .dynamic .dynsym .rel .rela .reloc
CRT0 := $(shell find $(LIBDIR) -name crt0-efi-$(ARCH).o 2>/dev/null | tail -n1)
LDSCRIPT := $(shell find $(LIBDIR) -name elf_$(ARCH)_efi.lds 2>/dev/null | tail -n1)
# Note the single quotes around the * expressions. The shell will incorrectly expand these otherwise,
# but we want to send the * directly to the find command.
SRCS := $(shell find $(SRC_DIRS) -name '*.c')
SRCS_ASM := $(shell find $(SRC_DIRS) -name '*.asm')
OBJS_ASM := $(patsubst ./src/%.asm,$(BUILD_DIR)/%.o,$(SRCS_ASM))
OBJS := $(patsubst ./src/%.c,$(BUILD_DIR)/%.o,$(SRCS))
.PRECIOUS: $(OBJS)
.PRECIOUS: $(OBJS_ASM)
.PRECIOUS: $(BUILD_DIR)/%.so
# String substitution (suffix version without %).
DEPS := $(OBJS:.o=.d)
INC_DIRS := $(shell find $(SRC_DIRS) -type d)
GNU_EFI_DIRS := /usr/include/efi /usr/include/efi/$(ARCH)
INC_DIRS := $(INC_DIRS) $(GNU_EFI_DIRS)
CPPFLAGS := $(addprefix -I,$(INC_DIRS)) \
-MMD \
-MP
CFLAGS := -fshort-wchar \
-DGNU_EFI_USE_MS_ABI \
-ffreestanding \
-mno-red-zone \
-Wall \
-Werror \
-fPIC \
-O2
LDFLAGS=-T $(LDSCRIPT) \
-Bsymbolic \
-shared \
-nostdlib \
-znocombreloc \
-L$(LIBDIR) \
$(CRT0)
all: $(BUILD_DIR)/$(TARGET_NAME).efi
mkdir -p ./build
deploy: all
mkdir -p ./esp/efi/boot
cp $(BUILD_DIR)/$(TARGET_NAME).efi ./esp/efi/boot/BOOTX64.EFI
start: all deploy
./start-qemu.sh
$(BUILD_DIR)/%.efi: $(BUILD_DIR)/%.so
$(OBJCOPY) $(foreach sec,$(SECTIONS),-j $(sec)) --target=$(FORMAT) -S $< $@
$(BUILD_DIR)/%.so: $(OBJS) $(OBJS_ASM)
$(LD) $(LDFLAGS) -o $@ $^ -lgnuefi -lefi
# Build step for C source
$(BUILD_DIR)/%.o: $(SRC_DIRS)/%.c
mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
$(BUILD_DIR)/%.o: $(SRC_DIRS)/%.asm
mkdir -p $(dir $@)
nasm -g -f elf64 -l $@.lst $< -o $@
.PHONY: clean
clean:
rm -r $(BUILD_DIR)
-include $(DEPS)
Формирование списков исходных файлов на C и ассемблере:
SRCS := $(shell find $(SRC*DIRS) -name '*.c')
SRCS_ASM := $(shell find $(SRC_DIRS) -name '*.asm')
Формирование списков объектных файлов:
OBJS_ASM := $(patsubst ./src/%.asm,$(BUILD_DIR)/%.o,$(SRCS_ASM))
OBJS := $(patsubst ./src/%.c,$(BUILD_DIR)/%.o,$(SRCS))
Формирование списка директорий с заголовочными файлами:
INC_DIRS := $(shell find $(SRC_DIRS) -type d)
GNU_EFI_DIRS := /usr/include/efi /usr/include/efi/$(ARCH)
INC_DIRS := $(INC_DIRS) $(GNU_EFI_DIRS)
Формирование списка параметров для препроцессора C:
CPPFLAGS := $(addprefix -I,$(INC_DIRS)) \
-MMD \
-MP
Формирование списка параметров для компилятора C:
CFLAGS := -fshort-wchar \
-DGNU_EFI_USE_MS_ABI \
-ffreestanding \
-mno-red-zone \
-Wall \
-Werror \
-fPIC \
-O2
Правила для компиляции файлов С и ассемблера:
$(BUILD_DIR)/%.o: $(SRC_DIRS)/%.c
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
$(BUILD_DIR)/%.o: $(SRC_DIRS)/%.asm
nasm -g -f elf64 -l $@.lst $< -o $@
Расположение библиотечных файлов, кода начальной загрузки для UEFI-приложения и пользовательского скрипта компоновки:
LIBDIR := /usr/lib
CRT0 := $(shell find $(LIBDIR) -name crt0-efi-$(ARCH).o 2>/dev/null | tail -n1)
LDSCRIPT := $(shell find $(LIBDIR) -name elf_$(ARCH)\_efi.lds 2>/dev/null | tail -n1)
Формирование списка параметров для компоновщика:
LDFLAGS=-T $(LDSCRIPT) \
-Bsymbolic \
-shared \
-nostdlib \
-znocombreloc \
-L$(LIBDIR) \
$(CRT0)
Правило для компоновщика:
$(BUILD_DIR)/%.so: $(OBJS) $(OBJS_ASM)
$(LD) $(LDFLAGS) -o $@ $^ -lgnuefi -lefi
Выбор формата исполняемого файла. В нашем случае это должен быть EFI Image (PE32+/COFF)
FORMAT := efi-app-x86-64
Формирование списка секций, которые нужно скопировать в исполняемый файл:
SECTIONS := .text .sdata .data .dynamic .dynsym .rel .rela .reloc
Правило для objcopy:
$(OBJCOPY) $(foreach sec,$(SECTIONS),-j $(sec)) --target=$(FORMAT) -S $< $@
К сожалению, полностью передать те ощущения, которые получаешь, когда ты видишь, как твой код загружает Linux, в статье невозможно, но я надеюсь, что вы прошли шаги и у вас получилось запустить свой загрузчик. Ну или хотя бы откомпилировали мой, и может, попробовали внести небольшие изменения. Загрузчик обладает рядом недостатков:
Telegram-канал со скидками, розыгрышами призов и новостями IT 💻