Всем привет. В рамках проекта от компании Acronis со студентами Университета Иннополис (подробнее о проекте мы уже описали это тут и тут) мы изучали последовательность загрузки операционной системы Windows. Появилась идея исполнять логику даже до загрузки самой ОС. Следовательно, мы попробовали написать что-нибудь для общего развития, для плавного погружения в UEFI. В этой статье мы пройдем по теории и попрактикуемся с чтением и записью на диск в pre-OS среде.
В компании Acronis команд, которые занимаются UEFI, не так много, поэтому я решил разобраться в вопросе самостоятельно. К тому же есть проверенный способ получить огромное количество точных советов совершенно бесплатно и свободно — просто начать делать что-либо и выложить это в интернет. Поэтому комментарии и рекомендации под этим постом очень приветствуются! Вторая цель данного поста собрать небольшой дайджест статей о UEFI и помочь двигаться в этом направлении.
Полезные ссылки
Для начала хочу перечислить список источников, которые мне очень помогли. Возможно вам они тоже помогут и ответят на ваши вопросы.
- В первую очередь это UEFI and EDK II Learning and Development от tianocore. Прекрасно структурированный и иллюстрированный курс, который поможет понять что же происходит в момент загрузки и что такое UEFI. Если вы ищите точную теоретическую информацию по теме, вам туда. Если хочется поскорее перейти к написанию UEFI драйверов, то сразу в Lesson 3.
- Статьи на хабре раз, два и три. Автору низкий поклон. Это отличное практическое руководство без лишних сложностей для начинающих. Частично в данной статье я буду цитировать эти шедевры, хоть и с небольшими изменениями. Без этих публикаций, было бы значительно тяжелее начать.
- Для продолжающих рекомендую эту статью и другие этого же автора.
- Так как мы планируем писать драйвер, очень поможет официальный гайдлайн по написанию драйвера. Наиболее правильные советы будут именно там.
- Ну и на крайний случай спецификация UEFI.
Немного теории
Хочу напомнить требования и цели проекта Active Restore. Мы планируем приоритизировать файлы в системе для более эффективного восстановления. Для этого нужно запуститься на максимально раннем этапе загрузки ОС. Для понимания наших возможностей в мире UEFI стоит немного углубиться в теорию о том как проходит цикл загрузки. Информация для этой части полностью взята из этого источника, который я постараюсь популярно пересказать.
UEFI
UEFI или Unified Extensible Firmware Interface стал эволюцией Legacy BIOS. В модели UEFI тоже есть базовая система ввода-вывода для взаимодействия с железом, хотя процесс загрузки системы и стал отличаться. UEFI использует GPT (Guid partition table). GPT тесно связана со спецификацией и является более продвинутой моделью для хранения информации о разделах диска. Изменился процесс, но задачи остались прежними: инициализация устройств ввода-вывода и передача управления в код операционной системы. UEFI не только заменяет бoльшую часть функций BIOS, но также предоставляет широкий спектр возможности для разработки в pre-OS среде. Хорошее сравнение Legacy BIOS и UEFI есть тут.
В данном представлении BIOS это компонент, обеспечивающий непосредственное общение с железом и является firmware. UEFI это унификация интерфейса железа для операционной системы, что существенно облегчает жизнь разработчикам.
В мире UEFI мы можем разрабатывать драйвера или приложения. Есть специальный подтип приложений — загрузчики. Разница лишь в том, что эти приложения не завершаются привычным нам образом. Завершаются они вызовом функции ExitBootServices() и передают управление в операционную систему. Чтобы принять решение какой же драйвер нужен вам, рекомендую заглянуть сюда, чтобы расширить понимание о протоколах и рекомендациях по их использованию.
Dev kits
Небольшой список того, что мы будем использовать в нашей практике:
- EDKII (Extensible Firmware Interface Development Kit) — является свободно распространяемым проектом для разработки UEFI приложений и драйверов, которую и мы будем использовать, по началу не сильно в неё углубляясь.
- VisualUEFI — проект облегчающий разработку в Visual Studio. Больше не нужно заморачиваться с .inf файлами и ковыряться в 100500 скриптах на Python. Все это уже сделано за вас. Внутри можно найти QEMU для запуска нашего кода. В проекте представлены примеры приложения и драйверов.
- Coreboot — комплексный проект для firmware. Его задача — помочь разработать решение для старта железа и передачи управления в payload (например UEFI или GRUB), который в свою очередь загрузит операционную систему. В данной статье мы не будем затрагивать coreboot. Оставим его для будущих экспериментов, когда набью руку с EDKII. Возможно правильным вектором развития будет Coreboot + Tianocore UEFI + Windows 7 x64.
Последовательность загрузки
Коротко разберем через какие стадии проходит наша машина, прежде чем мы видим заветный логотип операционной системы. Для этого рассмотрим следующую диаграмму:
Процесс с момента нажатия на кнопку питания на корпусе и до полной готовности UEFI интерфейса называется Platform Initialization и делится он на несколько фаз:
- Security (SEC) — зависит от платформы и процессора, обычно реализована ассемблерными командами, проводит первоначальную инициализацию временной памяти, проверку остальной части платформы на безопасность различными способами.
- Pre EFI Initialization (PEI) — в данной фазе уже начинается работа EFI кода, главная задача — загрузка DXE Foundation который будет стартовать DXE драйверов на следующей фазе. На самом деле, тут происходит еще очень много всего, но то, что мы планируем разрабатывать, сюда не пролезет, так что двигаемся дальше.
- Driver Execution Environment (DXE) — на данном этапе начинают стартовать драйвера. Наиболее важная для нас фаза, потому, что наш драйвер тоже будет запущен тут. Данная среда исполнения драйверов и является основным преимуществом над Legacy BIOS. Тут код начинает исполняться параллельно. DXE ведет себя на манер операционной системы. Это позволяет различным компаниям имплементировать свои драйвера. DXE Foundation, развернутый на предыдущей фазе, поочередно находит драйвера, библиотеки и приложения, разворачивает их памяти и исполняет.
- После этой фазы эстафету принимает Boot Device Selection (BDS). Вы наверняка лично видели данную фазу. Тут происходит выбор на каком устройстве искать приложение — загрузчик операционной системы. После выбора начинается переход к операционной системе. DXE boot драйвера начинают выгружаться из памяти. Загрузчик операционной системы наоборот загружается в память с помощью блочного протокола ввода — вывода BLOCK_IO. Здесь не все DXE драйвера завершают свою работу. Существуют так называемые runtime драйвера. Им придется на понятной для загруженной операционной системе нотации разметить память, которую они занимают. Иными словами виртуализировать свои адреса в адресное пространство Windows, когда произойдет вызов функции SetVirtualAddressMap(). Как только среда будет готова, “Main” функция ядра ОС начнет исполнение, а фаза EFI завершится вызовом ExitBootServices(). Контроль полностью передан в операционную систему. Дальше Windows будет решать какие и откуда загрузить дайвера, как читать и писать на диск и что за файловую систему использовать. Картинка обобщающая вышеуказанную последовательность:
Классный рассказ о этапах загрузки есть тут.
Подготовка проекта
Пришло время поставить перед собой простую задачу. Мы можем загрузить наш драйвер в DXE фазе, открыть файл на диске и записать в него какие — нибудь данные. Задача достаточно простая, чтобы потренироваться.
Как я уже упоминал мы воспользуемся проектом VisualUEFI, однако рекомендую также попробовать способы описанные тут, хотя бы потому, что использовать дебагер легче в описанном по ссылке способе.
Допускаю, что у вас уже есть Visual Studio. В моем случае у меня Visual Studio 2019. Для начала клонируем себе проект VisualUEFI:
git clone --recurse-submodules -j8 https://github.com/ionescu007/VisualUefi.git
Нам понадобится NASM (https://www.nasm.us/pub/nasm/releasebuilds/2.15.02/win64/). Переходим и скачиваем. На момент написания статьи актуальной версией является 2.15.02. После установки убедитесь, что в переменных средах у вас есть NASM_PREFIX, который указывает на папку, в которую был установлен NASM. В моем случае это C:\Program Files\NASM\.
Соберем EDKII. Для этого открываем EDK-II.sln из \VisualUefi\EDK-II, и просто жмем build на решении. Все проекты в решении должны успешно собраться, и можно переходить к уже готовым примерам. Открываем samples.sln из \VisualUefi\samples. Жмем build на приложении и драйвере, после чего можно запускать QEMU простым нажатием F5.
Проверяем наш UefiDriver и UefiApplication, именно так называются примеры в решении samples.sln.
Shell> fs1:
FS1:\> load UefiDriver.efi
Отлично, драйвер не только собрался, но и успешно загрузился. Выполнив команду drivers, мы даже увидим его в списке.
Если бы в коде мы не возвращали EFI_ACCESS_DENIED в функции UefiUnload, мы бы даже смогли выгрузить наш драйвер, выполнив команду:
FS1:\> unload BA
Теперь вызовем наше приложение:
FS1:\> UefiApplication.efi
Написание кода
Рассмотрим код предоставленного нам драйвера. Все начинается с функции UefiMain, которая находится в файле drvmain.c. Мы бы могли назвать точку входа и другим именем, если бы писали драйвер “с нуля”, указать это можно было бы в .inf файле.
EFI_STATUS
EFIAPI
UefiUnload (
IN EFI_HANDLE ImageHandle
)
{
//
// Do not allow unload
//
return EFI_ACCESS_DENIED;
}
EFI_STATUS
EFIAPI
UefiMain (
IN EFI_HANDLE ImageHandle,
IN EFI_SYSTEM_TABLE *SystemTable
)
{
EFI_STATUS efiStatus;
//
// Install required driver binding components
//
efiStatus = EfiLibInstallDriverBindingComponentName2(ImageHandle,
SystemTable,
&gDriverBindingProtocol,
ImageHandle,
&gComponentNameProtocol,
&gComponentName2Protocol);
return efiStatus;
}
В проекте от нас не требуют регистрировать Unload функцию, так как VisualUEFI это и так уже делает “под капотом”, нужно просто её объявить. В примере она в этом же файле и называется UefiUnload. В этой функции мы можем написать код, который освободит все занятые нами ресурсы, так как она будет вызвана при выгрузке драйвера. Регистрация Unload функции в проекте VisualUEFI происходит в файле DriverEntryPoint.c, в функции _ModuleEntryPoint.
// _DriverUnloadHandler manages to call UefiUnload
Status = gBS->HandleProtocol (
ImageHandle,
&gEfiLoadedImageProtocolGuid,
(VOID **)&LoadedImage
);
ASSERT_EFI_ERROR (Status);
LoadedImage->Unload = _DriverUnloadHandler;
В нашем примере, в функции UefiMain, происходит вызов функции EfiLibInstallDriverBindingComponentName2, которая регистрирует имя нашего драйвера и Driver Binding Protocol. Согласно модели драйверов UEFI, все драйвера устройств должны регистрировать этот протокол для предоставления контроллеру функций Support, Start, Stop. Функция Support отвечает, может ли наш драйвер работать с данным контроллером. Если да, то вызывается функция Start. Подробнее об этом хорошо описано в спецификации (раздел Protocols — UEFI Driver Model). В нашем примере функции Support, Start и Stop устанавливают наш кастомный протокол. Его реализация в файле drvpnp.c:
//
// EFI Driver Binding Protocol
//
EFI_DRIVER_BINDING_PROTOCOL gDriverBindingProtocol =
{
SampleDriverSupported,
SampleDriverStart,
SampleDriverStop,
10,
NULL,
NULL
};
…
//
// Install our custom protocol on top of a new device handle
//
efiStatus = gBS->InstallMultipleProtocolInterfaces(&deviceExtension->DeviceHandle,
&gEfiSampleDriverProtocolGuid,
&deviceExtension->DeviceProtocol,
NULL);
//
// Bind the PCI I/O protocol between our new device handle and the controller
//
efiStatus = gBS->OpenProtocol(Controller,
&gEfiPciIoProtocolGuid,
(VOID**)&childPciIo,
This->DriverBindingHandle,
deviceExtension->DeviceHandle,
EFI_OPEN_PROTOCOL_BY_CHILD_CONTROLLER);
Фукнция EfiLibInstallDriverBindingComponentName2 реализована в файле UefiDriverModel.c, и, на самом деле, очень простая. Она вызывает InstallMultipleProtocolInterfaces из Boot Services (см. Спецификацию стр 210). Данная функция связывает handle (в нашем случае ImageHandle, который мы получили на точке входа) и протокол.
// install component name and binding
Status = gBS->InstallMultipleProtocolInterfaces (
&DriverBinding->DriverBindingHandle,
&gEfiDriverBindingProtocolGuid, DriverBinding,
&gEfiComponentNameProtocolGuid, ComponentName,
&gEfiComponentName2ProtocolGuid, ComponentName2,
NULL
);
Соответственно, можно, и нужно, в момент выгрузки драйвера, удалить установленные компоненты. Мы сделаем это в нашей функции Unload. Теперь наш драйвер можно будет выгружать по команде unload , или перед передачей управления в операционную систему.
EFI_STATUS
EFIAPI
UefiUnload (
IN EFI_HANDLE ImageHandle
)
{
gBS->UninstallMultipleProtocolInterfaces(
ImageHandle,
&gEfiDriverBindingProtocolGuid, &gDriverBindingProtocol,
&gEfiComponentNameProtocolGuid, &gComponentNameProtocol,
&gEfiComponentName2ProtocolGuid, &gComponentName2Protocol,
NULL
);
//
// Changed from access denied in order to unload in boot
//
return EFI_SUCCESS;
}
Как вы могли заметить, в нашем коде мы взаимодействуем с UEFI через глобальное поле gBS (global Boot Services). Также, существует gRT (global Runtime Services), а вместе они являются частью структуры System Table. Источник.
gST = *SystemTable;
gBS = gST->BootServices;
gRT = gST->RuntimeServices;
Для работы с файлами нам понадобится Simple File System Protocol (см. Спецификацию стр 504). Вызвав функцию LocateProtocol, можно получить на него указатель, хотя более правильный способ перечислить все handles на устройства файловой системы с помощью функции LocateHandleBuffer, и, перебрав все протоколы Simple File System, выбрать подходящий, который позволит нам писать и читать в файл. Пример такого кода тут. А мы же воспользуемся способом проще. У протокола есть всего одна функция, которая позволит нам открыть том.
EFI_STATUS
OpenVolume(
OUT EFI_FILE_PROTOCOL** Volume
)
{
EFI_SIMPLE_FILE_SYSTEM_PROTOCOL* fsProto = NULL;
EFI_STATUS status;
*Volume = NULL;
// get file system protocol
status = gBS->LocateProtocol(
&gEfiSimpleFileSystemProtocolGuid,
NULL,
(VOID**)&fsProto
);
if (EFI_ERROR(status))
{
return status;
}
status = fsProto->OpenVolume(
fsProto,
Volume
);
return status;
}
Далее, нам необходимо уметь создавать файл и закрывать его. Воспользуемся EFI_FILE_PROTOCOL, в котором есть функции для работы с файловой системой (см. Спецификацию стр 506).
EFI_STATUS
OpenFile(
IN EFI_FILE_PROTOCOL* Volume,
OUT EFI_FILE_PROTOCOL** File,
IN CHAR16* Path
)
{
EFI_STATUS status;
*File = NULL;
// from root file we open file specified by path
status = Volume->Open(
Volume,
File,
Path,
EFI_FILE_MODE_CREATE |
EFI_FILE_MODE_WRITE |
EFI_FILE_MODE_READ,
0
);
return status;
}
EFI_STATUS
CloseFile(
IN EFI_FILE_PROTOCOL* File
)
{
// flush unwritten data
File->Flush(File);
// close file
File->Close(File);
return EFI_SUCCESS;
}
Для записи в файл нам придется вручную двигать каретку. Для этого будем спрашивать размер файла с помощью функции GetInfo.
EFI_STATUS
WriteDataToFile(
IN VOID* Buffer,
IN UINTN BufferSize,
IN EFI_FILE_PROTOCOL* File
)
{
UINTN infoBufferSize = 0;
EFI_FILE_INFO* fileInfo = NULL;
// retrieve file info to know it size
EFI_STATUS status = File->GetInfo(
File,
&gEfiFileInfoGuid,
&infoBufferSize,
(VOID*)fileInfo
);
if (EFI_BUFFER_TOO_SMALL != status)
{
return status;
}
fileInfo = AllocatePool(infoBufferSize);
if (NULL == fileInfo)
{
status = EFI_OUT_OF_RESOURCES;
return status;
}
// we need to know file size
status = File->GetInfo(
File,
&gEfiFileInfoGuid,
&infoBufferSize,
(VOID*)fileInfo
);
if (EFI_ERROR(status))
{
goto FINALLY;
}
// we move carriage to the end of the file
status = File->SetPosition(
File,
fileInfo->FileSize
);
if (EFI_ERROR(status))
{
goto FINALLY;
}
// write buffer
status = File->Write(
File,
&BufferSize,
Buffer
);
if (EFI_ERROR(status))
{
goto FINALLY;
}
// flush data
status = File->Flush(File);
FINALLY:
if (NULL != fileInfo)
{
FreePool(fileInfo);
}
return status;
}
Вызываем наши функции и пишем случайные данные в наш файл:
EFI_STATUS
WriteToFile(
VOID
)
{
CHAR16 path[] = L"\\example.txt";
EFI_FILE_PROTOCOL* file = NULL;
EFI_FILE_PROTOCOL* volume = NULL;
CHAR16 something[] = L"Hello from UEFI driver";
//
// Open file
//
EFI_STATUS status = OpenVolume(&volume);
if (EFI_ERROR(status))
{
return status;
}
status = OpenFile(volume, &file, path);
if (EFI_ERROR(status))
{
CloseFile(volume);
return status;
}
status = WriteDataToFile(something, sizeof(something), file);
CloseFile(file);
CloseFile(volume);
return status;
}
Есть альтернативный способ выполнить нашу задачу. В проекте VisualUEFI уже реализовано то, что мы написали выше. Мы можем просто подключить заголовочный файл ShellLib.h и вызвать в самом начале функцию ShellInitialize. Все необходимые протоколы для работы с файловой системой будут открыты, а функции ShellOpenFileByName, ShellWrite и ShellRead реализованы почти так же, как и у нас.
#include <Library/ShellLib.h>
EFI_STATUS
WriteToFile2(
VOID
)
{
SHELL_FILE_HANDLE fileHandle = NULL;
CHAR16 path[] = L"fs1:\\example2.txt";
CHAR16 something[] = L"Hello from UEFI driver";
UINTN writeSize = sizeof(something);
EFI_STATUS status = ShellInitialize();
if (EFI_ERROR(status))
{
return status;
}
status = ShellOpenFileByName(path,
&fileHandle,
EFI_FILE_MODE_CREATE |
EFI_FILE_MODE_WRITE |
EFI_FILE_MODE_READ,
0);
if (EFI_ERROR(status))
{
return status;
}
status = ShellWriteFile(fileHandle, &writeSize, something);
ShellCloseFile(&fileHandle);
return status;
}
Результат:
> Код этого примера на github
Если мы хотим перейти в VMWare, то наиболее правильным будет модификация firmware с помощью UEFITool. Например тут демонстрируется как добавляют NTFS драйвер в UEFI.
Выводы
Усложнить идею нашего драйвера и ближе подвести его под требования проекта Active Restore можно следующим образом: открыть протокол BLOCK_IO, заменить функции чтения на диск нашими функциями, которые запишут данные, читаемые с диска в лог и затем вызовут оригинальные функции. Сделать это можно следующим образом:
// just pseudo code
...
// open protocol to replace callbacks
gBS->OpenProtocol(
Controller,
Guid,
(VOID**)&protocol,
DriverBindingHandle,
Controller,
EFI_OPEN_PROTOCOL_GET_PROTOCOL
);
// raise Task Priority Level to max avaliable
gBS->RaiseTPL(TPL_NOTIFY);
VOID** protocolBase = EFI_FIELD_BY_OFFSET(VOID**, FilterContainer, 0);
VOID** oldCallback = EFI_FIELD_BY_OFFSET(VOID**, *protocolBase, oldCallbackOffset);
VOID** originalCallback = EFI_FIELD_BY_OFFSET(VOID**, FilterContainer, originalCallbackOffset);
// yes, I know that it is not super obvious
// but if first and third is equal (placeholder and function)
// then the first one is not the function it is offset!
// and function itself is by offset of third one
if ((UINTN) newCallback == originalCallbackOffset)
{
newCallback = *originalCallback;
}
PRINT_DEBUG(DEBUG_INFO, L"[UefiMonitor] 0x%x -> 0x%x\n", *oldCallback, newCallback);
//saving original functions
*originalCallback = *oldCallback;
//replacing them by filter function
*oldCallback = newCallback;
// restore TPL
gBS->RestoreTPL(oldTpl);
Нужно будет не забыть подписаться на ExitBootServices(), чтобы вернуть указатели на место. После того, как фильтр файловой системы в Windows будет готов, минифильтр продолжит логировать чтение с диска.
// event on exit
gBS->CreateEvent(
EVT_SIGNAL_EXIT_BOOT_SERVICES,
TPL_NOTIFY,
ExitBootServicesNotifyCallback,
NULL,
&mExitBootServicesEvent
);
Но это это уже идеи для будущих статей. Спасибо за внимание.
VioletGiraffe
Подключение EDK я не осилил (хотя их руководства, наверняка, стоят внимания — я не видел этих материалов, когда изучал тему программирования под UEFI). Может быть, кому-то пригодится мой репозиторий с хедерами для работы UEFI и пример UEFI приложения c использованием этих хедеров (там есть и работающий проект для VS2019).