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 usingExitBootServices(). - 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 notablyE820hfor 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 |