Пишем свой загрузчик

Материал из SynapseOS wiki
Версия от 15:25, 30 декабря 2024; Vi Chapman (обсуждение | вклад)
(разн.) ← Предыдущая версия | Текущая версия (разн.) | Следующая версия → (разн.)
Перейти к навигации Перейти к поиску

Наша цель - написать простой загрузчик для UEFI систем, который сможет запускать ELF-ядра. Для этого мы будем использовать язык C (gcc) и библиотеку GNU-EFI. Для начала, скачаем GNU-EFI:

git clone https://tvoygit.ru/vi_is_lonely/gnu-efi.git --depth 1  # Скачиваем исходный код библиотеки
cd gnu-efi  # переходим в директорию библиотеки
make  # компилируем библиотеку

Теперь у нас есть такие файлы, как crt0-efi-x86_64.o, libefi.a и libgnuefi.a. Для удобства предлагаю создать в папке проекта папку lib и поместить эти файлы в неё. Так же создадим папку src с нашим исходным кодом. В папку lds поместим скрипт компоновщика из поставки GNU-EFI, под названием elf_x86_64_efi.lds. В папке inc у нас будут заголовки, а в папку gnu-efi переместим содержимое папки gnu-efi/inc. Так же скачайте OVMF и поместить файлы OVMF_CODE.4m.fd и OVMF_VARS.4m.fd в папку ovmf. Напишем простой Makefile:

SOURCES = $(shell find src/*.c)  # список всех исходных файлов
OBJECTS = $(patsubst src/%.c,/tmp/build-obj/%.o,$(SOURCES))  # будем хранить объектные файлы в ОЗУ для более быстрой сборки
OPTLEVEL = -O2 -s  # предлагаю вынести эти флаги в отдельную переменную
CFLAGS = $(OPTLEVEL) -Iinc -Ignu-efi -fpic -ffreestanding -c \
		  -fno-stack-protector -fno-stack-check -Wno-pointer-arith -Wno-volatile \
		  -Wno-return-type -Wno-narrowing -Wno-multichar \
		  -fshort-wchar -mno-red-zone -maccumulate-outgoing-args \
		  -mno-red-zone -maccumulate-outgoing-args -fno-exceptions -fno-rtti -std=gnu23
LDFLAGS = -shared -Bsymbolic -L ./lib -flto -s -T lds/elf_x86_64_efi.lds lib/crt0-efi-x86_64.o
OCFLAGS = -j .text -j .sdata -j .data -j .rodata \  # флаги для конвертации ELF в EFI Application (PE)
		  -j .dynamic -j .dynsym -j .rel -j .rela \
		  -j .rel.* -j .rela.* -j .reloc --target \
		  efi-app-x86_64 --subsystem=10
IMG = /tmp/viboot-image  # здесь будет располагаться папка, имитирующая наш образ
OVMF_FW = ./ovmf/OVMF_CODE.4m.fd
OVMF_VARS = ./ovmf/OVMF_VARS.4m.fd
QEMUFLAGS = -nodefaults -vga std -m 96M -smp 2 -serial stdio -monitor vc:800x600 \  # флаги для виртуальной машины QEmu
			-drive if=pflash,format=raw,readonly=on,file=${OVMF_FW} \
        	-drive if=pflash,format=raw,file=${OVMF_VARS} \
			-drive format=raw,file=fat:rw:${IMG}
all: build image qemu
build: clean bootx64.efi
bootx64.efi: $(OBJECTS)
	@echo "  * Linking"
	@ld $(LDFLAGS) $^ -o /tmp/boot.so -lefi -lgnuefi
	@strip -s /tmp/bootx64.so
	@echo "  * Objcopying"
	@objcopy $(OCFLAGS) /tmp/boot.so $@
	@strip -s $@
	@echo "  * Cleaning up"
	@rm -rf $(OBJECTS)
/tmp/build-obj/%.o: src/%.cc
	@echo "  [$$] Compiling $<"
	@mkdir -p $(dir $@) || true
	@gcc $(CFLAGS) -I$(GNUEFIINC) -o $@ $^
clean:
	@echo "  [$$] Cleaning up"
	@rm -rf $(OBJECTS) bootx64.efi $(IMG) || true
image:
	@echo "  [$$] Creating image"
	@rm -rf $(IMG) || true
	@mkdir -p $(IMG)/EFI/BOOT
	@cp bootx64.efi $(IMG)
qemu:
	@echo "  [$$] Running QEMU"
	@qemu-system-x86_64 $(QEMUFLAGS)

Так же, вам ничто не мешает добавлять в цель image копирование любых других файлов, в том числе ядра. давайте обусловимся, что ваше ядро - статический ELF64, позиционезависимый (-fpic). Итак, напишем основу для нашего загрузчика:

#include <efi.h>
#include <efilib.h>

EFI_STATUS EFIAPI efi_main(EFI_HANDLE Image, EFI_SYSTEM_TABLE *SysTab) {
    InitializeLib(Image, SysTab);
    Print(L"Hello, world!\n");
    return EFI_SUCCESS;
}

Для вызова любой функции UEFI, вы можете использовать макрос uefi_call_wrapper(указатель_на_функцию, количество_аргументов, аргументы...), однако это может быть немного неудобно, поэтому можете взять мой собственный макрос, оборачивающий уже предоставленный:

#define EFI(f, vargs...) uefi_call_wrapper((void*)f, __VA_NARG__(vargs), vargs)
// использоваие: EFI(функция, аргументы...)

При этом, если вы используете С++ вместо С, с этим макросом у вас не будет возникать предупреждений permissive.

Советы и рекомендации

Помимо функций UEFI, библиотека GNU-EFI поставляет собственный набор функций, которые оборачивают UEFI. Например, если вы хотите вывести на экран строку, то вот так это будет выгядеть с использованием функций UEFI:

EFI_STATUS EFIAPI efi_main(EFI_HANDLE Image, EFI_SYSTEM_TABLE *SysTab) {
    InitializeLib(Image, SysTab);
    EFI(SysTab->ConOut->OutputString, SysTab->ConOut, L"Hello, World!\r\n");
    return EFI_SUCCESS;
}

Как вы могли заметить, в предыдущем примере уже используется оборачивающая функция Print. Зачастую лучше использовать их, поскольку становится меньше шанс ошибки в коде и сам код становится чище и лаконичнее.

Так же, я сделал набор простых, но удобных макросов. Вот они:

/// @brief обёртка вызова UEFI
#define EFI(f, vargs...) uefi_call_wrapper((void*)f, __VA_NARG__(vargs), vargs)
/// @brief ассерт UEFI
#define EFI_ASSERT(cond, fmt, ...) if (!(cond)) EFI_FAT(fmt __VA_OPT__(,) __VA_ARGS__)
/// @brief аварийная остановка работы
#define EFI_STOP() asm volatile ("cli\nhlt\njmp . - 2")

#if EFI_DEBUG
/// @brief отладочный вывод UEFI
#define EFI_DBG(fmt, ...) Print(L" <*> " fmt L"\r\n" __VA_OPT__(,) __VA_ARGS__)
/// @brief вывод об ошибке UEFI
#define EFI_ERR(fmt, ...) Print(L" <!> " fmt L"\r\n" __VA_OPT__(,) __VA_ARGS__)
/// @brief вывод о фатальной ошибке UEFI и аварийная остановка работы
#define EFI_FAT(fmt, ...) do { Print(L" <X> " fmt L"\r\n" __VA_OPT__(,) __VA_ARGS__); asm volatile ("cli\nhlt\njmp . - 2"); } while(0)
/// @brief вывод о предупреждении
#define EFI_WRN(fmt, ...) Print(L" <#> " fmt L"\r\n" __VA_OPT__(,) __VA_ARGS__)
/// @brief простой вывод UEFI
#define EFI_PRN(fmt, ...) Print(fmt __VA_OPT__(,) __VA_ARGS__)
/// @brief вывод UEFI с переносом строки
#define EFI_LN(fmt, ...) Print(fmt L"\r\n" __VA_OPT__(,) __VA_ARGS__)
#else
#define EFI_DBG(fmt, ...)
#define EFI_ERR(fmt, ...)
#define EFI_FAT(fmt, ...) asm volatile ("cli\nhlt\njmp . - 2")
#define EFI_WRN(fmt, ...)
#define EFI_PRN(fmt, ...)
#define EFI_LN(fmt, ...)
#endif

Среди всего прочего могу показать функции-обёртки, упрощающие работу с файловой системой в среде UEFI:

/// @brief корневой раздел EFI
static EFI_FILE_HANDLE g_RD;
/// @brief Загрузить файл в ОЗУ
/// @param FileName Путь к файлу для загрузки
/// @param FileData Указатель на указатель на буфер. В него будет выведен адрес буфера
/// @param FileDataLength Указатель на длину файла. В него будет выведен размер буфера
/// @return статус завершения функции
EFI_STATUS LoadFile(const CHAR16 *FileName, CHAR8 **FileData, UINTN *FileDataLength) {
    EFI_STATUS status;
    EFI_FILE_HANDLE FileHandle;
    EFI_FILE_INFO *FileInfo;
    UINT64 ReadSize;
    UINTN BufferSize;
    UINT8 *Buffer = NULL;

    EFI_ASSERT(g_RD || FileName, L"LoadFile: Неверные параметры");

    status = EFI(g_RD->Open, g_RD, &FileHandle, FileName,
        EFI_FILE_MODE_READ, EFI_FILE_READ_ONLY | EFI_FILE_HIDDEN | EFI_FILE_SYSTEM);
    EFI_ASSERT(!status, L"Open: %r", status);
    FileInfo = LibFileInfo(FileHandle);
    if (FileInfo == NULL) {
        EFI(FileHandle->Close, FileHandle);
        EFI_FAT(L"LibFileInfo failed");
    }
    ReadSize = FileInfo->FileSize;
    // 16 MiB максимальный размер файла (рекомендовано, можно закомментировать для отключения лимита
    if (ReadSize > 16*1024*1024) ReadSize = 16*1024*1024;
    FreePool(FileInfo);

    // Вычисление размера буфера в страницах
    BufferSize = (un)((ReadSize+PAGESIZE-1)/PAGESIZE);
    status = EFI(BS->AllocatePages, 0, 2, BufferSize, (u64*)&Buffer);
    if (EFI_ERROR(status) || Buffer == NULL) {
        EFI(FileHandle->Close, FileHandle);
        EFI_FAT(L"AllocatePages провалилась: %r", status);
    }
    status = EFI(FileHandle->Read, FileHandle, &ReadSize, Buffer);
    EFI(FileHandle->Close, FileHandle);
    if (EFI_ERROR(status)) {
        EFI(BS->FreePages, (u64)(Buffer), BufferSize);
        EFI_FAT(L"ReadFile провалилась: %r", status);
    }

    *FileData = Buffer;
    *FileDataLength = ReadSize;
    return EFI_SUCCESS;
}

/// @brief Записать данные в файл
/// @param FileName путь к файлу
/// @param FileData данные для записи в файл
/// @param WriteSize размер данных
/// @return статус завершения функции
EFI_STATUS WriteFile(const CHAR16 *FileName, CHAR8 *FileData, UINTN WriteSize) {
    EFI_STATUS status;
    EFI_FILE_HANDLE FileHandle;
    EFI_FILE_INFO *FileInfo;
    UINTN BufferSize;
    CHAR8 *Buffer = NULL;

    EFI_ASSERT(g_RD || FileName, L"LoadFile: неверные параметры");

    status = EFI(g_RD->Open, g_RD, &FileHandle, FileName, EFI_FILE_MODE_WRITE, EFI_FILE_HIDDEN | EFI_FILE_SYSTEM);
    EFI_ASSERT(!status, L"Open error: %r", status);
    
    // 16 MiB максимальный размер файла (рекомендовано, можно закомментировать для отключения лимита
    if (WriteSize > 16*1024*1024) WriteSize = 16*1024*1024;

    // запись в файл
    status = EFI(FileHandle->Write, FileHandle, &WriteSize, Buffer);
    EFI(FileHandle->Close, FileHandle);
    EFI_ASSERT(!status, L"LoadFile провалилась: %r", status);
    return EFI_SUCCESS;
}

// Чтобы они работали, нужно инициализировать RootDir:
EFI_STATUS InitFS(EFI_SYSTEM_TABLE *SysTab) {
    EFI_STATUS status = EFI_SUCCESS;
    EFI_LOADED_IMAGE_PROTOCOL *g_LIP = NULL;
    EFI_GUID LIPguid = EFI_LOADED_IMAGE_PROTOCOL_GUID;
    status = EFI(g_BS->HandleProtocol, ImageHandle, &LIPguid, (void**)&g_LIP);
    EFI_ASSERT(!status || !g_LIP, L"LocateProtocol провалилась: %r\n", status);
    g_RD = LibOpenRoot(g_LIP->DeviceHandle);
    EFI_ASSERT(!status || !g_RD, L"LibOpenRoot провалилась: %r\n", status);
}

Так же, UEFI использует кодировку unicode (16 бит на символ), вместо ASCII (7 бит на символ), поэтому так же полезной будет данная функция:

CHAR16 *a2u(CHAR8 *str) {
    static CHAR16 mem[4096]; int i;
    for (i = 0; str[i]; ++i) mem[i] = (CHAR16)str[i];
    mem[i] = 0; return mem;
}

Ссылки