Accessing BIOS and UEFI System Tables in C

Writing a bootloader in C requires interfacing directly with system firmware to initialize hardware and load the operating system kernel. While modern UEFI environments natively support the C calling convention by passing a direct pointer to the system table entry point, legacy BIOS systems operate in 16-bit real mode and rely on interrupt vectors and fixed memory locations rather than structured tables. This article outlines the architectural differences, entry point signatures, and implementation techniques required to access these low-level firmware interfaces using the C programming language.

Accessing the UEFI System Table

Unified Extensible Firmware Interface (UEFI) runs in 32-bit or 64-bit protected or long mode with identity-mapped virtual memory, which aligns naturally with standard C compilers. The UEFI specification explicitly defines a standard entry point for executables formatted as PE32+.

The efi_main Function Signature

When the UEFI firmware loads a bootloader, it calls the entry point defined in the executable's header. Under the standard UEFI calling convention (typically Microsoft x64 ABI), the firmware passes two primary arguments:

typedef void *EFI_HANDLE;

typedef struct {
    char _pad[44];
    void *ConOut;
    char _pad2[16];
    void *BootServices;
    void *RuntimeServices;
} EFI_SYSTEM_TABLE;

long efi_main(EFI_HANDLE ImageHandle, EFI_SYSTEM_TABLE *SystemTable) {
    // SystemTable contains the entry point pointers to firmware APIs
    return 0;
}

The SystemTable pointer provides access to all fundamental firmware interfaces:

  • Boot Services (BootServices): Handles memory allocation, device paths, protocol lookups, and timer events. These services become unavailable once the bootloader terminates firmware control using ExitBootServices().
  • Runtime Services (RuntimeServices): Remains accessible even after the OS boots, providing functions for resetting the system and reading or writing NVRAM variables.
  • Console I/O (ConIn, ConOut, StdErr): Provides simple text input and output mechanisms for early error reporting.

Developers compile this code using tools such as Clang or GCC with -target x86_64-unknown-windows or using cross-development libraries like GNU-EFI and EDK II, allowing the generated binary to match the required PE/COFF format.


Interfacing with Legacy BIOS

Legacy BIOS does not pass a pointer to a system table upon execution. Instead, the BIOS loads the first 512-byte sector of the boot drive (the Master Boot Record) to memory address 0x7C00 and transfers execution in 16-bit Real Mode via a direct jump (jmp 0000:7C00h).

Because standard C compilers generate code for 32-bit or 64-bit protected environments, developers targeting BIOS in C must either use a 16-bit compiler (such as Open Watcom or GCC with the -m16 directive) or execute a minimal assembly stub to initialize stack registers and switch to protected mode.

Accessing BIOS Services via Interrupts

BIOS does not use function pointers; instead, it establishes an Interrupt Vector Table (IVT) located at physical address 0x0000:0x0000. Bootloaders invoke BIOS services by loading CPU registers with arguments and executing software interrupts:

// Example using GCC inline assembly to invoke BIOS Video Services (INT 0x10)
void bios_putchar(char c) {
    __asm__ __volatile__ (
        "movb $0x0E, %%ah\n"  // Teletype output function
        "movb %0, %%al\n"     // Character to print
        "int $0x10\n"         // Video interrupt
        :
        : "r"(c)
        : "ax"
    );
}

Common BIOS interrupt entry points include:

  • INT 0x10: Video services for text output and screen mode setting.
  • INT 0x13: Low-level disk access for reading sectors into memory.
  • INT 0x15: System services, most notably E820h for memory map detection.

Fixed Memory Entry Points

In addition to interrupts, legacy BIOS exposes status information through the BIOS Data Area (BDA), situated at fixed memory segment 0x0400:0x0000. C developers can access this table directly by casting absolute memory addresses to pointers:

#define BDA_BASE 0x0400

// Read the base I/O port address for COM1 from BDA (Offset 0x0000)
unsigned short get_com1_port(void) {
    volatile unsigned short *com1 = (volatile unsigned short *)(BDA_BASE + 0x00);
    return *com1;
}

Summary of Architectural Differences

Feature Legacy BIOS UEFI
Execution Mode 16-bit Real Mode 32-bit or 64-bit Protected/Long Mode
Interface Style Software Interrupts (IVT) Function Pointers via SystemTable
Entry Arguments None (Registers undefined) ImageHandle, EFI_SYSTEM_TABLE*
Memory Access Direct physical addressing Identity-mapped virtual addressing
C Compatibility Requires assembly wrappers/-m16 Native support via standard C ABI