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.akompiliert. - Der (Assembler basierte)
Startup Code kommt in die statische
libgnuefi.a. - Die eigene EFI-App wird als
shared librarygebaut, die gegen die beiden vorherigen mit ein paar speziellen Flags linkt. - Das Tool
objcopyextrahiert die Code und Datenbereiche aus dershared libund 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
CMakeBeispiel)
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_mainauf.-T /path/to/gnuefi/elf_x86_64_efi.ldsPfad 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.