UEFI, CMake und GCC
« | 24 Apr 2022 | »Manchmal sind es die Dinge, wo man denkt:
Ach, das sollte in 15 Minuten erledigt sein.
die einen dann den ganzen Tag (oder länger) beschäftigen.
Mir erging es mit der Einrichtung eines CMake Scripts für UEFI-Apps in einer GCC Linux Umgebung heute so.
Der MSVC hat es mir mit seinem
EFI-Applications Build-Typ recht einfach gemacht, Apps ohne Betriebssystem
zu erstellen.
Doch ein großes Problem stellen C++,
seine Exception und RTTI
dar, bei denen der MSVC Code generiert, der eine C-Runtime voraussetzt, die
an den Windows Kernel
angepasst ist … also etwas, was in UEFI nicht existiert.
Der GCC
hingegen lässt sich leichter dazu bewegen, solche Datenstrukturen
generisch zu erzeugen, und somit war mein erster Schritt schon festgelegt:
Ich möchte den EFI-App-Build in einer GCC Umgebung durchführen können.
Doch da der GCC
unter Linux das ELF-Format
einsetzt und UEFI APIs auf Microsofts COFF
Spezifikation aufsetzen, gestaltet sich das Bauen von UEFI-Apps im GCC
wesentlich schwieriger.
Makefile Analyse
Die GNU-EFI Bibliothek sieht folgende Prozedur vor:
- Die EFI-Schnittstellen werden in eine statische
libefi.a
kompiliert. - Der (Assembler basierte)
Startup Code kommt in die statische
libgnuefi.a
. - Die eigene EFI-App wird als
shared library
gebaut, die gegen die beiden vorherigen mit ein paar speziellen Flags linkt. - Das Tool
objcopy
extrahiert die Code und Datenbereiche aus dershared lib
und baut daraus ein EFI-App-Binary mit dem richtigen Target-Subsystem zusammen.
Wichtige Compiler-Optionen hierfür sind:
-ffreestanding
: Verzicht auf die übliche C-Standard-Bibliothek-fpic
: Shared-lib position independent code-fshort-wchar
: wchar_t als 16-bit wie bei Microsoft- Deaktivierung diverser Stack-Prüfungen, wie:
-fno-stack-protector
,-fno-stack-check
,-mno-red-zone
- und noch ein paar Kleinigkeiten (siehe
CMake
Beispiel)
Der Linker braucht folgende Zusätze:
-shared
: erzeugt eine temporäre dynamische Lib-nostdlib
: Verzicht die übliche C-Standard-Bibliothek zu linken-Bsymbolic
: Verhaltensanpassung, wie Symbole im Code aufgelöst werden-e_start
: Festlegung des Funktionsnamens, der beim App-Start benutzt werden soll, also_start()
, eine in Assembler geschriebene Einsprungfunktion auslibgnuefi.a
.
Sie initialisiert alles und ruft dannefi_main
auf.-T /path/to/gnuefi/elf_x86_64_efi.lds
Pfad zum Linkerscript, das wichtige Details zum Linken der EFI-App beinhaltet.
Die Makefile
Dateien, die gnu-efi
mitliefert, dokumentieren, dass
libgnuefi.a
zwar gelinkt wird, aber dass das crt0
Objekt (*.o
)
trotzdem nochmal separat zur finalen EFI-App gelinkt wird.
So wird die darin enthaltene _start
Funktion automatisch zum
Einsprungpunkt.
Bei meinen Tests, wo ich die .o
nicht mehr separat angab, musste ich
den Einsprungpunkt eben mit -e_start
festlegen, ansonsten wählte der
Compiler selbsttätig immer efi_main
aus.
CMake Umsetzung
Das Standard CMake
Setup nutzt cc
um seine Kompilate umzusetzen.
Da die originalen gnu-efi
Sourcen Makefiles
einsetzen, die explizit zwischen gcc
und ld
Aufrufen unterscheiden, muss
man beim Wechsel zu CMake
mit cc
darauf achten, welche Kommandos für den
Linker bestimmt sind.
Diese muss man dann mit -Wl,
präfixen, z.B: -Wl,-nostdlib
.
Man erhält nun eine EFI .so
Datei, die man aber noch umwanden muss.
Ein Custom-Build-Job mit dem Aufruf von objcopy
führt zum gewünschten
Ergebnis:
1objcopy -j .text -j .sdata -j .data -j .dynamic -j .dynsym -j .rel -j .rela -j .reloc --target=efi-app-x86_64 my-efi-app.so my-efi-app.efi
Die Datei my-efi-app.efi
kann nun auf einen mit FAT32
formatierten USB Stick kopiert werden.
Bootet man in die UEFI-Shell, kann man mit fs0:
(oder fsX:
wenn mehrere
Laufwerke erkannt werden) zum Stick wechseln und my-efi-app.efi
einfach
durch Eingabe des Dateinamens + ENTER
ausführen lassen
(DOS lässt grüßen).
Wer keine UEFI-Shell in seinem BIOS hat, kann die Datei auch als
/EFI/BOOT/BOOTX64.EFI
speichern und vom Stick booten.
Die EFI-App wird dann wie ein Bootloader ausgeführt … nur dass
eben kein OS geladen wird, sondern nur unser Code in efi_main()
ausgeführt wird.
Problem Calling Convention
… und dann sitze ich stundenlang vor meinem Bildschirm und stelle fest, dass die erzeugte EFI-App sofort zum Einfrieren des Systems führt.
Doch erst nach längerer Analyse der Assembler und C Sourcen wurde mir mein Fehler bewusst.
_start()
versucht zuerst dynamische Symbole korrekt auszurichten (reloc()
)
und will am Ende die Funktion efi_main
aufrufen, in der der eigene Code
startet.
Und hier hatte ich die Signatur
1EFI_STATUS EFIAPI efi_main(EFI_HANDLE ImageHandle, EFI_SYSTEM_TABLE* SystemTable)
gewählt. Und der Token EFIAPI
war des Pudels Kern.
Denn dieses Makro ist im GCC
definiert als:
1#define EFIAPI __attribute__((ms_abi))
EFIAPI
wird für die EFI-Schnittstellenfunktionen benutzt und ermahnt
den GCC
Compiler, die Aufrufe im Microsoft-Format (x86_64) durchzuführen.
Doch _start
geht davon aus, dass efi_main
eine “lokale” nicht-EFI-Funktion
ist, die sich auch nicht an die Microsoft-Konvention halten muss. Stattdessen
wird die übliche GCC x86_64 cdecl
Konvention benutzt, bei der die Register
anders belegt sind.
Microsoft’s cdecl
nutzt die Register RCX
und RDX
für die ersten beiden
Parameter und der GCC
nutzt die System-V cdecl
Spezifikation und übergibt die
gleichen Parameter in den Register RDI
und RSI
.
Und so erhielt meine efi_main
Funktion also zwei ungültige Registerwerte,
was den Crash auslöste.
Die Entfernen von EFIAPI
in efi_main
vermochte mein Problem zu lösen.
Fazit
Nun wo der C-Teil bei GCC
, CMake
und EFI durchprobiert wurde, kann ich
mich also in Zukunft mehr um die C++ Seiten kümmern.
Wie “cool” wäre es, wenn man nun auch vollständige C++ Routinen außerhalb eines OS nutzen kann?
Ich freue mich daher schon, diese neue Welt entdecken zu dürfen.