Разработка 64битного графического UEFI приложения в Visual Studio

Зимой в блоге RUVDS было несколько статей о написании загрузчиков для «классического» BIOS, в т.ч. симпатичная графическая демка, целиком помещающаяся в загрузочном секторе и работающая в VGA-режиме 320×200. Комментаторы справедливо отмечали, что в наступившем 2021 г. нет смысла осваивать это лютое легаси; «а вот статей про «Hello, World» на UEFI да с графикой действительно не хватает. Больше того — я таких вообще не припомню.» (MinimumLaw) Под катом мы пошагово перепишем ту бутсекторную демку под UEFI, и она будет работать в полноцветном видеорежиме с высоким разрешением. С другой стороны, вместо 512 байт она будет занимать несколько десятков КБ.

vcgiphkyui3gbkqbb-6hzht5gik.jpeg

▍ 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


Список изменений в шаблоне
  1. Копия edk2 заменена ссылкой (git submodule) на официальный репозиторий;
  2. Добавлены бинарники Python 3.5, и сборочные скрипты внутри NT32.vcxproj задают переменную PYTHON_HOME, нужную для edk2;
  3. Захардкоженный путь C:\FW\ в файлах vcxproj заменён на $(SolutionDir)\..\;
  4. Скрипту 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:

image-loader.svg

Модуль 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:

image-loader.svg

Сразу заметны три проблемы, унаследованные из бутсекторной демки:

  1. Экран вначале очищается вызовом gST->ConOut->ClearScreen, а затем на нём по одному зажигаются пиксели спирали-ёлочки. Вызываемое этим мерцание заметно даже на гифке. Чтобы от него избавиться, ёлочку надо отрисовывать в невидимый буфер, а затем вызовом Blt отправлять на экран одним целым. Мы же объявим мерцание воображаемых гирлянд тёплой и ламповой фичей демки, и оставим его как есть.
  2. Единственный способ выйти из демки — перезагрузка. Для загрузчика в режиме Legacy BIOS это нормально (куда ж из него выходить?), но из UEFI-приложения хотелось бы иметь возможность выйти обратно в UEFI Shell. В конце цикла, после задержки — проверим, была ли нажата какая-нибудь клавиша:
        EFI_INPUT_KEY Key;
        efiStatus = gST->ConIn->ReadKeyStroke(gST->ConIn, &Key);
        if (!EFI_ERROR(efiStatus)) {
            return efiStatus;
        }
  3. В режиме 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;
        }


image-loader.svg

▍ 4. Графические ресурсы


Изображение, выводимое рядом со спиралью-ёлочкой в бутсекторной демке, захардкожено прямо в ассемблерном исходнике простынью из определений db. Это неудобно: как такое изображение редактировать? Гораздо удобнее работать с BMP-файлом. Такую возможность даёт HII Database (Human Interface Infrastructure), вскользь упомянутая в туториале от DarkTiger как хранилище шрифтов, доступных UEFI-приложениям. Изображения, как и шрифты, включаются в PE-образ приложения как ресурсы. Для того, чтобы добавить в ресурсы BMP-изображение, нужно несколько неочевидных шагов:

  1. Кладём файл ruvds.bmp в каталог проекта;
  2. Там же создаём файл ruvds.idf из одной строчки #image IMG_LOGO ruvds.bmp — она задаёт идентификатор ресурса, под которым изображение будет доступно в коде;
  3. В файле HelloWorld.inf в секцию [Sources] дописываем оба файла ruvds.idf и ruvds.bmp, и удаляем из неё HelloWorldStr.uni: особенности сборочных скриптов edk2 не позволяют иметь в одном проекте и BMP, и локализованные строки;
  4. В конец файла 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);


image-loader.svg

▍ 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; и вуаля!

image-loader.svg

© Habrahabr.ru