How to Write Cross-Platform UEFI Applications

Writing platform-independent Unified Extensible Firmware Interface (UEFI) applications requires adhering strictly to the UEFI specification's hardware abstraction layer while avoiding processor-specific operations. This article covers the fundamental strategies for developing portable UEFI software across architectures like x86-64, AArch64, ARM, and RISC-V. By leveraging standard UEFI protocols, avoiding inline assembly, adhering to portable data types, and using build systems like EDK II, developers can maintain a single codebase that compiles cleanly for any supported CPU architecture.

Rely Strictly on UEFI Services and Protocols

The core philosophy of UEFI is hardware abstraction. To ensure an application runs across different processor types, developers must never bypass the firmware layer to interact directly with hardware.

  • Use Boot Services and Runtime Services: Never access hardware timers, interrupt controllers, or platform registers directly. Rely on gBS->CreateEvent(), gBS->SetTimer(), and memory allocation services like gBS->AllocatePool() instead of custom hardware loops or processor-specific instructions.
  • Leverage Standard Protocols: Use standard protocols such as EFI_SIMPLE_TEXT_INPUT_PROTOCOL, EFI_BLOCK_IO_PROTOCOL, or EFI_GRAPHICS_OUTPUT_PROTOCOL (GOP) for I/O and display operations. These protocols are implemented by the platform firmware to abstract the underlying chipset and GPU differences.

Eliminate Architecture-Specific Assembly

Inline assembly is the primary cause of architectural lock-in. A portable UEFI application must be written entirely in standard C.

  • Avoid Direct Port I/O: Standard x86 instructions like in and out do not exist on ARM or RISC-V architectures. If low-level bus access is required, rely on the EFI_PCI_ROOT_BRIDGE_IO_PROTOCOL or EFI_CPU_IO2_PROTOCOL, which abstract register reading and writing across memory-mapped I/O (MMIO) and port I/O systems.
  • Avoid CPU-Specific Feature Probing: Do not use instructions like cpuid on x86 or direct system register reads on ARM. If system capabilities must be inspected, query the platform configuration tables or device paths provided in the UEFI System Table.

Use Standardized UEFI Data Types

Data width and alignment differ between architectures. Using native C types like int, long, or raw pointers without abstraction leads to memory faults and undefined behavior.

  • Adopt Specification Types: Always use UEFI-defined fixed-width integer types such as UINT8, UINT16, UINT32, and UINT64.
  • Use Architecture-Neutral Pointers: Use UINTN and INTN for integers that match the native pointer size of the target platform (32-bit on 32-bit systems, 64-bit on 64-bit systems).
  • Respect Memory Alignment: Many RISC and ARM processors enforce strict alignment rules and will generate alignment faults if unaligned memory accesses occur. Always use unaligned access helper functions (such as ReadUnaligned32() and WriteUnaligned32() from EDK II) when parsing packed binary data or external file formats.

Maintain Endianness and Structure Portability

While the most common UEFI implementations are little-endian, writing strictly portable code requires avoiding endian-dependent assumptions.

  • Structure Packing: Avoid compiler-specific packing pragmas where possible, or use standard macros provided by the development framework (such as PACKED in EDK II) to ensure structures remain binary-compatible across different compilers and targets.
  • Explicit Byte Swapping: If reading data formats that use network byte order (big-endian), utilize explicit conversion functions to preserve correctness on both little-endian and big-endian systems.

Leverage EDK II for Multi-Architecture Builds

The TianoCore EDK II framework provides the standard build infrastructure for cross-platform UEFI development.

  • Use MdePkg: Base your application on the standard MdePkg (Module Development Environment Package). It provides architecture-independent headers, standard library equivalents, and wrappers for basic operations.
  • Configure Target Architectures: Configure the application build description (.inf) file to list all supported architectures in the VALID_ARCHITECTURES field (e.g., IA32 X64 ARM AARCH64 RISCV64).
  • Avoid Conditional Compilation: While #ifdef macros for specific architectures (like MDE_CPU_X64 or MDE_CPU_AARCH64) exist, keep them isolated to platform-specific drivers. The top-level application logic should remain free of architectural branches.

Distribute via Standard Directory Structures

Because UEFI binaries are compiled as architecture-specific PE32+ executables, running on multiple platforms requires distributing separate binaries for each target architecture. Follow the standard EFI System Partition (ESP) layout by deploying binaries to their recognized default paths, such as \EFI\BOOT\BOOTX64.EFI, \EFI\BOOT\BOOTAA64.EFI, or \EFI\BOOT\BOOTRISCV64.EFI, ensuring the firmware automatically loads the appropriate binary for the host CPU.