i13os: Video Frame Buffer

Adrian Macal
Level Up Coding
Published in
8 min readFeb 25, 2024

--

Is there still a hidden spot for VGA in our UEFI-focused world, or is it completely out of the game?

Some inventions persist in IT longer than expected. You’re likely familiar with CSV files, which were probably created before 1972 and are still in use today. Why do tools and people continue to use them? The answer is simplicity! Often they serve as a low-effort integration to verify if something functions as anticipated. Introducing other variables in troubleshooting could complicate matters. Almost all data platforms support CSV, even if it’s not the preferred choice for production data pipelines.

Similarly, VGA mode has outlasted expectations. Introduced in 1987 by IBM, the VGA 80x25 text mode was embraced by BIOS and became a favorite for early kernel initialization code. By 2000, VGA was considered not optimal, but it was still maintained for backward compatibility for many years. Eventually, UEFI presented some alternatives, and now, in 2024, the EFI Graphics Output Protocol is the recommended method for early display during kernel development.

In this story, I will delve into booting a kernel from UEFI with a received GOP context to greet you after exiting UEFI Boot Services.

UEFI is a very advanced pre-OS environment and offers a sophisticated palette of various so-called protocols. A protocol seems to be kind of an instance of some component from the OOP world. There are some well-known protocols for console I/O, some protocols related to devices, networking, or memory management. We will try to combine a few of them to let the user choose a video resolution to load a minimalistic kernel. The kernel will receive a Video Frame Buffer to say hello. For me, it already sounds like a lot of fun!

Let’s imagine our bootloader will execute the following minimum amount of steps to give full control to the kernel:

  1. Find Graphics Output Protocol
  2. Load Kernel binary
  3. Get Memory Map
  4. Exit Boot Services
  5. Start Kernel Entry Point

When the Kernel is started, it will not rely on UEFI services because we will exit them. The Kernel will be aware only of the pointer and size of the received Video Frame Buffer to draw colorful bands as a demonstration.

You may think about UEFI protocols as discoverable. They are well documented and each of them has a unique identifier. They are registered in a central place which you can query using Boot Services.

EFI_STATUS find_graphics(EFI_BOOT_SERVICES *bs, EFI_GRAPHICS_OUTPUT_PROTOCOL **gop)
{
EFI_STATUS status;
EFI_HANDLE *handleBuffer;
UINTN handleCount, handleIndex;

*gop = NULL;
handleCount = 0;

status = uefi_call_wrapper(
bs->LocateHandleBuffer, 5,
ByProtocol, &gEfiGraphicsOutputProtocolGuid,
NULL, &handleCount, &handleBuffer);

if (EFI_ERROR(status))
return print_status(status, L"LocateHandleBuffer", 10);

for (handleIndex = 0; handleIndex < handleCount; handleIndex++)
{
status = uefi_call_wrapper(
bs->HandleProtocol, 3,
handleBuffer[handleIndex],
&gEfiGraphicsOutputProtocolGuid, gop);

if (EFI_ERROR(status))
continue;

Print(
L"Found Graphics at %d %dx%d at 0x%08x @ %d\n", handleIndex,
(*gop)->Mode->Info->HorizontalResolution,
(*gop)->Mode->Info->VerticalResolution,
(*gop)->Mode->FrameBufferBase,
(*gop)->Mode->FrameBufferSize);

break;
}

FreePool(handleBuffer);
return EFI_SUCCESS;
}

The snippet above finds all available handles for a Graphics Output Protocol and stores them in a newly allocated array. An instance of the protocol is being created and the first working protocol is returned. Additionally, basic info about the current resolution and Frame Buffer layout is printed out.

Knowing the GOP, we can also try to show a user all available resolutions. You may have noticed each device boots in its preferred mode which is not always what you want to work with. To list all modes, we can use the following code snippet:

void print_gop_modes(EFI_GRAPHICS_OUTPUT_PROTOCOL *gop)
{
EFI_STATUS status;
UINTN modeIndex, size;
EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *info;

for (modeIndex = 0; modeIndex < gop->Mode->MaxMode; ++modeIndex)
{
status = uefi_call_wrapper(
gop->QueryMode, 4,
gop, modeIndex, &size, &info);

if (EFI_ERROR(status))
continue;

Print(
L"GOP Mode %d: %dx%d %s\n", modeIndex,
info->HorizontalResolution,
info->VerticalResolution,
gop->Mode->Mode == modeIndex ? L" | Active " : L"");
}
}

The code iterates over all modes to query each of them and present to the user, also indicating which mode is currently active. It will be mostly 0, but each hardware is different.

Once the user has seen all available modes, he selects one with a single numeric keyboard key. It requires waiting for a key stroke event:

EFI_STATUS set_graphics(EFI_GRAPHICS_OUTPUT_PROTOCOL *gop, UINTN mode)
{
EFI_STATUS status;

status = uefi_call_wrapper(gop->SetMode, 2, gop, mode);
if (EFI_ERROR(status))
return status;

Print(L"GOP Mode %d\n", mode);
return EFI_SUCCESS;
}

EFI_STATUS read_keystroke(EFI_SYSTEM_TABLE *systemTable, EFI_INPUT_KEY *key)
{
EFI_STATUS status;
UINTN eventIndex;

status = uefi_call_wrapper(
systemTable->BootServices->WaitForEvent, 3,
1, &systemTable->ConIn->WaitForKey, &eventIndex);

if (EFI_ERROR(status))
return print_status(status, L"WaitForEvent", 0);

status = uefi_call_wrapper(
systemTable->ConIn->ReadKeyStroke, 2, systemTable->ConIn, &key);

if (EFI_ERROR(status))
return print_status(status, L"ReadKeyStroke", 0);

return EFI_SUCCESS;
}

The first function passes a new mode to GOP and prints its success. The second one reads a keystroke by waiting first for a single key event and when it happens, it takes the value of the pressed key.

If the user is already OK with the desired resolution, the Kernel code has to be loaded. The bootloader is physically separated from the Kernel and both of them lie on disk next to each other, typically:

  • /efi/boot/bootx64.efi
  • /efi/boot/kernel.bin

It would mean we need to find a relative file. In UEFI, it is not as simple as in POSIX. The following code snippet does the trick:

EFI_STATUS
load_kernel(EFI_HANDLE imageHandle, EFI_BOOT_SERVICES *bs, void **kernel, UINT64 *kernelSize)
{
EFI_STATUS status;
EFI_LOADED_IMAGE_PROTOCOL *loaded;
EFI_SIMPLE_FILE_SYSTEM_PROTOCOL *fs;
EFI_FILE_PROTOCOL *root, *file;
EFI_FILE_INFO *info;
UINT64 size, bufferSize;
void *buffer;

status = uefi_call_wrapper(bs->HandleProtocol, 3, imageHandle, &gEfiLoadedImageProtocolGuid, &loaded);
if (EFI_ERROR(status))
return print_status(status, L"HandleProtocol", 1);

status = uefi_call_wrapper(bs->HandleProtocol, 3, loaded->DeviceHandle, &gEfiSimpleFileSystemProtocolGuid, &fs);
if (EFI_ERROR(status))
return print_status(status, L"HandleProtocol", 1);

status = uefi_call_wrapper(fs->OpenVolume, 2, fs, &root);
if (EFI_ERROR(status))
return print_status(status, L"OpenVolume", 1);

status = uefi_call_wrapper(root->Open, 5, root, &file, L"efi\\boot\\kernel.bin", EFI_FILE_MODE_READ, 0);
if (EFI_ERROR(status))
return print_status(status, L"Open", 1);

info = LibFileInfo(file);
if (!info)
return EFI_BAD_BUFFER_SIZE;

size = info->FileSize;

FreePool(info);
Print(L"Loaded %d bytes of kernel.\n", size);

status = uefi_call_wrapper(bs->AllocatePool, 3, EfiLoaderCode, size, &buffer);
if (EFI_ERROR(status))
return status;

bufferSize = size;
status = uefi_call_wrapper(file->Read, 3, file, &bufferSize, buffer);
if (EFI_ERROR(status) || bufferSize != size)
{
FreePool(buffer);
return EFI_BAD_BUFFER_SIZE;
}

*kernel = buffer;
*kernelSize = bufferSize;

return status;
}

The tricky part is to find the correct device responsible for loading the current bootloader. It is doable by locating the Loaded Image Protocol, which can be found by the current Image Handle passed into our main EFI application. The returned instance contains a Device Handle that can be used to find the Simple File System Protocol, which will lead us to the File Protocol of the root volume, letting us open the absolute path of the Kernel. Then just memory allocation and reading binary data.

We almost did everything to transition to the kernel. The last step would be to Exit Boot Services. However, it is not so trivial. To call this function, we need to pass a Memory Map describing currently allocated memory:

EFI_STATUS get_memory_map(EFI_BOOT_SERVICES *bs, UINTN *mapKey)
{
EFI_STATUS status;
EFI_MEMORY_DESCRIPTOR *memoryMap = NULL;

UINT32 descriptorVersion;
UINTN memoryMapSize = 0;
UINTN descriptorSize;

status = uefi_call_wrapper(
bs->GetMemoryMap, 5,
&memoryMapSize, memoryMap, mapKey,
&descriptorSize, &descriptorVersion);

if (status != EFI_BUFFER_TOO_SMALL)
return print_status(status, L"GetMemoryMap", 0);

do
{
status = uefi_call_wrapper(
bs->AllocatePool, 3,
EfiLoaderData, memoryMapSize, &memoryMap);

if (EFI_ERROR(status))
return print_status(status, L"AllocatePool", 0);

status = uefi_call_wrapper(
bs->GetMemoryMap, 5,
&memoryMapSize, memoryMap, mapKey,
&descriptorSize, &descriptorVersion);

if (status == EFI_BUFFER_TOO_SMALL)
FreePool(memoryMap);

} while (status == EFI_BUFFER_TOO_SMALL);

if (EFI_ERROR(status))
return print_status(status, L"GetMemoryMap", 0);

return EFI_SUCCESS;
}

The flow is quite typical when you don’t know the size of the returned data, but you are expected to pre-allocate space for it. First, we try to get the Memory Map to see the expected Buffer Too Small error. It will also return the expected size of the buffer. We can allocate it now and try again. It may work or not. Imagine, something may have been allocated in the meantime and the size is no longer big enough. We need to attempt it a few times in a loop to hit the good timing.

All mentioned functions are integrated into our EFI app. You noticed that the loaded Kernel is cast to a function pointer and just called with the previously received Video Frame Buffer. We never expect the function will return.

EFI_STATUS
EFIAPI
efi_main(EFI_HANDLE imageHandle, EFI_SYSTEM_TABLE *systemTable)
{
EFI_STATUS status;
EFI_GRAPHICS_OUTPUT_PROTOCOL *gop;
EFI_INPUT_KEY key;
UINTN mapKey;

UINT64 kernelSize;
void *kernel;
void (*kernel_entry)(void *, UINTN);

InitializeLib(imageHandle, systemTable);

status = find_graphics(systemTable->BootServices, &gop);
if (EFI_ERROR(status))
return print_status(status, L"FindGraphics", 10);

print_header(systemTable);
print_gop_modes(gop);

status = read_keystroke(systemTable, &key);
if (EFI_ERROR(status))
return print_status(status, L"ReadKeyStroke", 10);

status = set_graphics(gop, key.UnicodeChar - '0');
if (EFI_ERROR(status))
return print_status(status, L"SetGraphics", 10);

status = load_kernel(imageHandle, systemTable->BootServices, &kernel, &kernelSize);
if (EFI_ERROR(status))
return print_status(status, L"LoadKernel", 10);

do
{
status = get_memory_map(systemTable->BootServices, &mapKey);
if (EFI_ERROR(status))
return print_status(status, L"GetMemoryMap", 10);

status = uefi_call_wrapper(BS->ExitBootServices, 2, imageHandle, mapKey);
if (EFI_ERROR(status))
Print(L"%d %d\n", imageHandle, mapKey);

} while (EFI_ERROR(status));

kernel_entry = (void (*)(void *, UINTN))kernel;
kernel_entry((void *)(gop->Mode->FrameBufferBase), gop->Mode->FrameBufferSize);

return EFI_SUCCESS;
}

One extra thing worth mentioning. You probably noticed a loop around fetching the Memory Map and calling Exit Boot Services. Again, the same reason as in the Memory Map function. This time Exit Boot Services may complain that the passed Memory Map is not the latest one. We need to try till we succeed.

What actually can we do now in our Kernel? We cannot do a lot. We cannot use Boot Services because we exited them. We cannot use simple VGA text mode to print some text, because it’s not available anymore. We ended up with a Video Frame Buffer in one hand, so we need to draw pixels.

typedef unsigned int uint32;
typedef unsigned long uint64;

void _start(uint32 *fb, uint64 size)
{
uint64 offset;
uint64 pixels;

pixels = size / 4;

for (offset = 0; offset < pixels; offset++)
{
*(fb + offset) = (offset % 256);
*(fb + offset) += ((offset / 2) % 256) << 8;
*(fb + offset) += ((offset / 4) % 256) << 16;
}

for (;;)
;
}

The above code is our simplest demonstration of the Kernel. It iterates pixel by pixel and sets a color building kind of colorful bands if the horizontal resolution is 1024. Otherwise, it doesn’t look good.

Building a kernel binary requires abandoning ELF totally. Once we built an .o file and linked it into an .elf file, we can easily just extract the .text section to fully function as a kernel in 212 bytes.

kernel.bin: src/kernel.c
$(CC) -ffreestanding -fPIC -c src/kernel.c -o src/kernel.o
ld -nostdlib -pie src/kernel.o -o src/kernel.elf
objcopy -O binary -j .text src/kernel.elf src/kernel.bin

This story is an adventure I had over the last few days. It gave me a lot of fun and let me understand how much low-level programming has evolved since I experienced it 25 years ago. It looks like I need to catch up and go deeper into this topic, even if the entire world is going somewhere else.

The code can be found here: https://github.com/amacal/i13os
UEFI Specifications: https://uefi.org/specifications

--

--

Software Developer, Data Engineer with solid knowledge of Business Intelligence. Passionate about programming.