Разработка 64битного графического UEFI приложения в Visual Studio
Зимой в блоге RUVDS было несколько статей о написании загрузчиков для «классического» BIOS, в т.ч. симпатичная графическая демка, целиком помещающаяся в загрузочном секторе и работающая в VGA-режиме 320×200. Комментаторы справедливо отмечали, что в наступившем 2021 г. нет смысла осваивать это лютое легаси; «а вот статей про «Hello, World» на UEFI да с графикой действительно не хватает. Больше того — я таких вообще не припомню.» (MinimumLaw) Под катом мы пошагово перепишем ту бутсекторную демку под UEFI, и она будет работать в полноцветном видеорежиме с высоким разрешением. С другой стороны, вместо 512 байт она будет занимать несколько десятков КБ.
▍ 1. Подготовка среды
Четыре года назад DarkTiger постил туториал о разработке под UEFI в Visual Studio, и даже опубликовал шаблон среды, позволяющий начать разработку, не ломая голову над настройками edk2: «Достаточно дать команду git clone ...
в корневом каталоге диска, и это на самом деле все, среда будет полностью установлена и готова к работе.» Шаблон этот был жёстко привязан не только к корневому каталогу, но и к Visual Studio 2015, к древней версии edk2, и к 32-битной компиляции. Чтобы работать в Visual Studio 2019, поддержка которой появилась в более новых версиях edk2, шаблон понадобится осовременить. Ещё одно изменение за эти четыре года — то, что для сборки в edk2 стал необходим Python.
Осовремененный шаблон (170 МБ трафика, 600 МБ на диске) развёртывается командой:
git clone --depth 1 --recursive --shallow-submodules https://github.com/tyomitch/uefi
- Копия edk2 заменена ссылкой (git submodule) на официальный репозиторий;
- Добавлены бинарники Python 3.5, и сборочные скрипты внутри NT32.vcxproj задают переменную
PYTHON_HOME
, нужную для edk2; - Захардкоженный путь
C:\FW\
в файлах vcxproj заменён на$(SolutionDir)\..\;
- Скрипту edksetup.bat вместо ставшего неактуальным параметра
--nt32
передаютсяRebuild VS2019
: первый указывает на необходимость скомпилироватьedk2\BaseTools\Bin\Win32
(несмотря на название параметра, компиляция выполняется инкрементально); второй — на используемый тулчейн.
Конфигурация проекта по-прежнему называется «Win32»; на целевую платформу, фактически используемую при компиляции, это никак не влияет.
Перед сборкой нужно поменять внутри edk2\BaseTools\Conf\target.template
значения TARGET_ARCH
на X64
и TOOL_CHAIN_TAG
на VS2019
; после этого можно открывать VS\NT32.sln
, жать F5, и всё скомпилируется и запустится. Чтобы удостовериться, что среда полноценно работает, введите в UEFI Shell команду fs0:HelloWorld.efi
:
Модуль HelloWorld мы и возьмём за основу для нашей демки.
▍ 2. Тригонометрия
Отрисовываемая демкой линия — это вертикально растянутая архимедова спираль, и для расчёта координат её точек Chris Fallin использовал инструкции fcos
и fsin
. Увы, но тригонометрические функции в Си не встроены: они относятся к стандартной библиотеке, а её из edk2 исключили. Нет в MSVC и инлайн-ассемблера для x64, так что функцию SinCos
придётся реализовывать в отдельном ассемблерном файле, следуя примеру Benjamin Kietzman. Поместите его файл SinCos.asm рядом с исходником HelloWorld.c в каталоге edk2\MdeModulePkg\Application\HelloWorld
, а в файл проекта HelloWorld.inf допишите секцию:
[Sources.X64]
SinCos.asm
.code
PUBLIC SinCos
; void SinCos(double AngleInRadians, double *pSinAns, double *pCosAns);
angle_on_stack$ = 8
SinCos PROC
movsd QWORD PTR angle_on_stack$[rsp], xmm0 ; argument angle is in xmm0, move it to the stack
fld QWORD PTR angle_on_stack$[rsp] ; push angle onto the FPU stack
fsincos
fstp QWORD PTR [r8] ; store/pop cosine output argument
fstp QWORD PTR [rdx] ; store/pop sine output argument
ret 0
SinCos ENDP
END
Попутно можете удалить из HelloWorld.inf ненужные нам секции [FeaturePcd]
, [Pcd]
, [UserExtensions.TianoCore."ExtraFiles"]
, и упоминания HelloWorldStr.uni и PcdLib. Все эти штуки, связанные с локализацией строк и настройками (PCD — это Platform Configuration Database), нам не помешают, но и не пригодятся. Поэтому же можно удалить и #include
, и определение mStringHelpTokenId
из HelloWorld.c; в начало этого файла надо добавить объявления:
#include
#define M_PI 3.14159265358979323846
extern void SinCos(double AngleInRadians, double *pSinAns, double *pCosAns);
int _fltused;
Символ _fltused
должен быть объявлен в каждой MSVC-программе, использующей double
. Обычно его экспортирует стандартная библиотека, но нам его приходится объявлять вручную.
▍ 3. Пиксельная графика
Стандартный для UEFI графический API называется GOP (Graphics Output Protocol), и он крайне прост: поддерживается ровно одна функция вывода, Blt
, копирующая прямоугольный блок пикселей между памятью и экраном. Кроме этого, может быть доступен прямой доступ к видеопамяти, и тогда отображение пикселя на экране — это просто запись UINT32
по нужному адресу. Спираль-ёлочку удобно рисовать попиксельно, напрямую в видеопамять:
EFI_STATUS
EFIAPI
UefiMain (
IN EFI_HANDLE ImageHandle,
IN EFI_SYSTEM_TABLE *SystemTable
)
{
EFI_STATUS efiStatus;
EFI_GUID gopGuid = EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID;
EFI_GRAPHICS_OUTPUT_PROTOCOL *gop;
double sin, cos;
// получим указатель на протокол
efiStatus = gBS->LocateProtocol(&gopGuid, NULL, (void**)&gop);
if (EFI_ERROR(efiStatus)) {
Print(L"Unable to locate GOP\n");
return EFI_NOT_STARTED;
}
UINT32 *video = (UINT32*)(UINTN)gop->Mode->FrameBufferBase;
// отключим мерцающий курсор
gST->ConOut->EnableCursor(gST->ConOut, FALSE);
double tree_height_factor = gop->Mode->Info->VerticalResolution * .8;
double tree_width_factor = gop->Mode->Info->VerticalResolution * .5;
double tree_width_base = gop->Mode->Info->HorizontalResolution * .5;
// код отрисовки в точности соответствует ассемблерной версии от Chris Fallin
for (UINTN tick = 0; ; tick++) {
gST->ConOut->ClearScreen(gST->ConOut);
for (double t = 0; t < 1; t += .001) {
double width = t * tree_width_factor;
double w = 2 * M_PI * 5;
double p = 2 * M_PI * tick / 36; // (0.5 revs / sec)
double angle = w * t + p;
SinCos(angle, &sin, &cos);
double x = width * cos + tree_width_base;
double z = width * sin;
double y = t * tree_height_factor;
UINTN x_disp = (UINTN)(x + z * .5);
UINTN y_disp = (UINTN)(y + z * .25);
if (y_disp < gop->Mode->Info->VerticalResolution) {
UINTN coord = gop->Mode->Info->PixelsPerScanLine * y_disp + x_disp;
UINT32 pixel = ((int)(t * 1000) % 2) ? 0x00FF00 : 0xFF0000;
video[coord] = pixel;
}
}
gBS->Stall((UINTN)(65536 / (105. / 88.))); // ~55 ms, to match IBM PC timer
}
}
Увы, включённый в состав edk2 эмулятор не поддерживает прямой доступ к видеопамяти, так что в поле gop->Mode->FrameBufferBase
будет NULL
. Чтобы тестировать код, требующий прямого доступа к видеопамяти, можно использовать улучшенную версию эмулятора, собранную Alex Ionescu: запускается он командой:
qemu.exe -drive file=OVMF_CODE-need-smm.fd,if=pflash,format=raw,unit=0,readonly=on -drive file=OVMF_VARS-need-smm.fd,if=pflash,format=raw,unit=1 -drive file=fat:rw:…\edk2\Build\EmulatorX64\DEBUG_VS2019\X64,media=disk,if=virtio,format=raw -drive file=UefiShell.iso,format=raw -m 512 -machine q35,smm=on -nodefaults -vga std -global driver=cfi.pflash01,property=secure,value=on -global ICH9-LPC.disable_s3=1
— и каталог с результатами сборки будет подмонтирован как fs1:
Сразу заметны три проблемы, унаследованные из бутсекторной демки:
- Экран вначале очищается вызовом
gST->ConOut->ClearScreen
, а затем на нём по одному зажигаются пиксели спирали-ёлочки. Вызываемое этим мерцание заметно даже на гифке. Чтобы от него избавиться, ёлочку надо отрисовывать в невидимый буфер, а затем вызовомBlt
отправлять на экран одним целым. Мы же объявим мерцание воображаемых гирлянд тёплой и ламповой фичей демки, и оставим его как есть. - Единственный способ выйти из демки — перезагрузка. Для загрузчика в режиме Legacy BIOS это нормально (куда ж из него выходить?), но из UEFI-приложения хотелось бы иметь возможность выйти обратно в UEFI Shell. В конце цикла, после задержки — проверим, была ли нажата какая-нибудь клавиша:
EFI_INPUT_KEY Key; efiStatus = gST->ConIn->ReadKeyStroke(gST->ConIn, &Key); if (!EFI_ERROR(efiStatus)) { return efiStatus; }
- В режиме 320×200 одиночные пиксели двух цветов выглядят нормально; в высоком разрешении — очень бледно: и в прямом, и в переносном смысле. Во-первых, сделаем их жирнее — каждую точку спирали будем отрисовывать «плюсиком» из пяти соседних пикселей; во-вторых, воспользуемся полноцветностью GOP, и будем менять цвет от зелёного к красному плавным градиентом, повторяющимся пятьдесят раз на протяжении спирали.
if (y_disp < gop->Mode->Info->VerticalResolution) { UINTN coord = gop->Mode->Info->PixelsPerScanLine * y_disp + x_disp; double color = t * 50; color -= (INTN)color; UINT32 pixel = (UINT32)(color * 255) << 8; pixel |= (UINT32)((1. - color) * 255) << 16; video[coord] = pixel; video[coord - 1] = pixel; video[coord + 1] = pixel; if (y_disp > 0) video[coord - gop->Mode->Info->PixelsPerScanLine] = pixel; if (y_disp < gop->Mode->Info->VerticalResolution - 1) video[coord + gop->Mode->Info->PixelsPerScanLine] = pixel; }
▍ 4. Графические ресурсы
Изображение, выводимое рядом со спиралью-ёлочкой в бутсекторной демке, захардкожено прямо в ассемблерном исходнике простынью из определений db
. Это неудобно: как такое изображение редактировать? Гораздо удобнее работать с BMP-файлом. Такую возможность даёт HII Database (Human Interface Infrastructure), вскользь упомянутая в туториале от DarkTiger как хранилище шрифтов, доступных UEFI-приложениям. Изображения, как и шрифты, включаются в PE-образ приложения как ресурсы. Для того, чтобы добавить в ресурсы BMP-изображение, нужно несколько неочевидных шагов:
- Кладём файл ruvds.bmp в каталог проекта;
- Там же создаём файл ruvds.idf из одной строчки
#image IMG_LOGO ruvds.bmp
— она задаёт идентификатор ресурса, под которым изображение будет доступно в коде; - В файле HelloWorld.inf в секцию
[Sources]
дописываем оба файла ruvds.idf и ruvds.bmp, и удаляем из неё HelloWorldStr.uni: особенности сборочных скриптов edk2 не позволяют иметь в одном проекте и BMP, и локализованные строки; - В конец файла HelloWorld.inf дописываем новую секцию:
[Protocols] gEfiHiiDatabaseProtocolGuid ## CONSUMES gEfiHiiImageProtocolGuid ## CONSUMES gEfiHiiPackageListProtocolGuid ## CONSUMES
Наконец, в начало файла HelloWorld.c дописываем:
#include
#include
#include
А в начало UefiMain
— код для загрузки изображения:
EFI_HII_DATABASE_PROTOCOL *HiiDatabase;
EFI_HII_IMAGE_PROTOCOL *HiiImage;
EFI_HII_PACKAGE_LIST_HEADER *PackageList;
EFI_HII_HANDLE HiiHandle;
EFI_IMAGE_OUTPUT Output;
efiStatus = gBS->LocateProtocol(&gEfiHiiDatabaseProtocolGuid, NULL, (VOID**)&HiiDatabase);
if (EFI_ERROR(efiStatus)) {
Print(L"Unable to locate HII Database\n");
return EFI_NOT_STARTED;
}
efiStatus = gBS->LocateProtocol(&gEfiHiiImageProtocolGuid, NULL, (VOID**)&HiiImage);
if (EFI_ERROR(efiStatus)) {
Print(L"Unable to locate HII Image\n");
return EFI_NOT_STARTED;
}
efiStatus = gBS->OpenProtocol(ImageHandle, &gEfiHiiPackageListProtocolGuid,
(VOID**)&PackageList, ImageHandle, NULL, EFI_OPEN_PROTOCOL_GET_PROTOCOL);
if (EFI_ERROR (efiStatus)) {
Print(L"HII Image Package not found in PE/COFF resource section\n");
return efiStatus;
}
efiStatus = HiiDatabase->NewPackageList(HiiDatabase, PackageList, NULL, &HiiHandle);
if (EFI_ERROR(efiStatus)) {
Print(L"Unable to register HII Package\n");
return EFI_NOT_STARTED;
}
После получения указателя на GOP можно проинициализировать структуру Output
:
Output.Width = (UINT16)gop->Mode->Info->HorizontalResolution;
Output.Height = (UINT16)gop->Mode->Info->VerticalResolution;
Output.Image.Screen = gop;
UINTN logo_offset = 38;
И теперь внутри цикла, сразу после очистки экрана, выведем это изображение:
EFI_IMAGE_OUTPUT *pOutput = &Output;
HiiImage->DrawImageId(HiiImage, EFI_HII_DIRECT_TO_SCREEN, HiiHandle,
IMAGE_TOKEN(IMG_LOGO), &pOutput, (UINTN)tree_width_base - logo_offset, 0);
▍ 5. Работа с Blt
DrawImageId
шлёт изображение из ресурсов напрямую на экран; нам же для эффекта мигающего логотипа понадобится буфер в памяти, где мы будем плавно менять яркость пикселей перед отрисовкой. Для работы с памятью в начало файла надо добавить:
#include
Теперь удалим объявление структуры Output
и её инициализацию, и вместо этого создадим буфер:
EFI_IMAGE_INPUT Image;
EFI_PHYSICAL_ADDRESS Buffer;
efiStatus = HiiImage->GetImage(HiiImage, HiiHandle, IMAGE_TOKEN(IMG_LOGO), &Image);
if (EFI_ERROR(efiStatus)) {
Print(L"Unable to locate IMG_LOGO\n");
return EFI_NOT_STARTED;
}
UINTN size = Image.Height * Image.Width * sizeof(EFI_GRAPHICS_OUTPUT_BLT_PIXEL);
efiStatus = gBS->AllocatePages(AllocateAnyPages,
EfiLoaderData, EFI_SIZE_TO_PAGES(size), &Buffer);
if (EFI_ERROR(efiStatus)) {
Print(L"Unable to allocate Buffer\n");
return EFI_NOT_STARTED;
}
EFI_GRAPHICS_OUTPUT_BLT_PIXEL *logo = (EFI_GRAPHICS_OUTPUT_BLT_PIXEL*)(UINTN)Buffer;
CopyMem(logo, Image.Bitmap, size);
В начале цикла нет надобности очищать весь экран: вместо этого вызовом Blt(EfiBltVideoFill)
очистим лишь ту часть, где рисуется спираль-ёлочка.
gop->Blt(gop, logo, EfiBltVideoFill, 0, 0,
(UINTN)(tree_width_base - 1.2 * tree_width_factor), Image.Height,
(UINTN)(2.4 * tree_width_factor),
gop->Mode->Info->VerticalResolution - Image.Height, 0);
Саму спираль сместим вниз на высоту логотипа:
UINTN y_disp = (UINTN)(y + z * .25) + Image.Height;
И в завершение художества — в конце цикла, перед задержкой, рассчитываем и отрисовываем вызовом Blt(EfiBltBufferToVideo)
плавно мигающий логотип:
for (UINTN i = 0; i < Image.Width * Image.Height; i++) {
if (Image.Bitmap[i].Blue > Image.Bitmap[i].Red) {
if (sin > 0) {
// blend with (0,0,0)
logo[i].Red = (UINT8)(Image.Bitmap[i].Red * (1 - sin));
logo[i].Green = (UINT8)(Image.Bitmap[i].Green * (1 - sin));
logo[i].Blue = (UINT8)(Image.Bitmap[i].Blue * (1 - sin));
}
else {
// blend with (175, 224, 250)
logo[i].Red = (UINT8)(175 - (175 - Image.Bitmap[i].Red) * (1 + sin));
logo[i].Green = (UINT8)(224 - (224 - Image.Bitmap[i].Green) * (1 + sin));
logo[i].Blue = (UINT8)(250 - (250 - Image.Bitmap[i].Blue) * (1 + sin));
}
}
}
gop->Blt(gop, logo, EfiBltBufferToVideo,
0, 0, (UINTN)tree_width_base - logo_offset, 0,
Image.Width, Image.Height, Image.Width * sizeof(EFI_GRAPHICS_OUTPUT_BLT_PIXEL));
Окончательный вариант кода лежит в репозитории в каталоге HelloWorld, а его работа показана на ролике в начале и конце статьи.
Когда UEFI-приложение отлажено под эмулятором, то его можно запустить вживую, без UEFI Shell — для этого надо взять флешку, отформатированную как FAT; положить HelloWorld.efi по пути \EFI\BOOT\bootx64.efi
; в настройках BIOS отключить Secure Boot; и вуаля!