The EDK II Wall
What it is actually like to start writing UEFI firmware with EDK II: the documentation, the missing C library, the build system, and the moment it finally clicks.
EDK II has a reputation. People who write firmware talk about it the way climbers talk about a particular pitch: not impossible, but steep, and the first attempt humbles you. When I started writing UEFI modules, I expected the hard part to be the low-level concepts. It was not. The hard part was simply finding out how anything was supposed to work.
The wall is the documentation
The concepts behind UEFI are reasonable once you see them. Getting there is the problem. The reference material is vast, scattered across the specification, the Tianocore wiki, header comments, and the source of other packages, and it is written in a dense, formal register that assumes you already know the shape of the thing it is describing.
I am fluent in English, but I am not a native speaker, and EDK II is where that gap was most expensive. It was not the vocabulary. It was the way ideas were phrased: long sentences defining terms with other undefined terms, documents that describe a mechanism without ever telling you why you would reach for it. I would read a page three times and still not know whether it applied to my case.
A "hello world" UEFI application took me about two hours. For a program that prints one line, that number says everything about the slope of the wall.
It is C, but not the C you know
The first concrete shock: there is no standard C library. EDK II is a
freestanding environment. #include <string.h> does not exist. malloc does
not exist. Instead there is a parallel universe of library classes, with
functions like AllocatePool, FreePool, CopyMem, and AsciiStrLen.
This becomes very real the moment you try to reuse existing C code. I needed a
QR code generator and reached for a well-known single-file library. It would not
compile, because it leaned on stdlib.h and string.h. The fix was to write a
small shim layer mapping the standard calls onto their EDK II equivalents, so
the library believed it was in an ordinary C environment while actually running
inside firmware. Useful to know in advance: most portable C is portable right up
until it meets a world with no libc.
The build system is a religion
A module is described by three files before a single line of logic matters:
- a
.decdeclaring the package and its GUIDs, - a
.dscdescribing how to build it, - an
.infdeclaring one module, its sources, and its library dependencies.
Then you source the environment and build against a toolchain tag:
. edksetup.sh
build -p MyPkg/MyPkg.dsc -a X64 -t GCC5Getting a .efi out is only half of it. To actually run a driver you integrate
it into OVMF for QEMU by editing the platform .dsc and .fdf, or, for an
application, you register it as a boot option from the UEFI shell:
bcfg boot add 0 FS0:\EFI\Boot\MyApp.efi "My App"None of this is hard once you know it. The cost is entirely in not knowing it, and in the documentation rarely showing you the whole path end to end.
Where does my code even run?
UEFI boots in phases: SEC, PEI, DXE, then BDS. This is not trivia; it decides how your code is even loaded. A DXE driver is dispatched automatically by the DXE core during initialization. A UEFI application runs later, launched as a boot option in the BDS phase. Putting code in the wrong category means it never runs, and nothing tells you why. Understanding the phase model was the difference between "my driver does nothing" and "my driver loads."
Debugging without a debugger
There is no comfortable source-level debugger in the normal loop. You instrument with debug macros that print over serial:
DEBUG ((DEBUG_INFO, "PicoWifiDxe: found device on handle %p\n", Handle));Then you rebuild, boot QEMU with OVMF, read the serial log, and repeat. The iteration loop is slow enough that it changes how you write: you think harder before each run because each run is expensive.
Everything is a protocol on a handle
This was the idea that, once it clicked, made the rest fall into place. EDK II is
built around handles and protocols. A protocol is just a GUID-identified struct
of function pointers and data. You install a protocol on a handle, and other
code finds it by locating that GUID. Drivers publish capabilities this way;
applications consume them. Persistent state lives in NVRAM variables, addressed
by a name and a vendor GUID through GetVariable and SetVariable.
Once you internalize "capabilities are protocols, you publish and locate them by GUID, and state is a variable under a GUID," the architecture stops feeling alien and starts feeling consistent.
It only goes up from the first step
What got me over the wall was not the official material. It was a random blog post, one I did not even think to bookmark, that explained handles and protocols with small concrete examples instead of formal definitions. That was the moment the model became legible. After that first step, it was only up: still work, but work with a map.
If you are starting EDK II, that is the thing to know. The wall is real, but it is almost entirely front-loaded. Find one explanation that uses examples, get a single module to build and run, and understand that everything is a protocol on a handle. The slope eases quickly after that.