Kompilieren von C in WebAssembly ohne Emscripten

Der Compiler ist Teil von Emscripten . Aber was ist, wenn Sie alle Pfeifen entfernen und nur sie lassen?

Emscripten ist erforderlich, um C / C ++ in WebAssembly zu kompilieren. Dies ist jedoch viel mehr als nur ein Compiler. Das Ziel von Emscripten ist es, Ihren C / C ++ - Compiler vollständig zu ersetzen und Code im Web auszuführen, der ursprünglich nicht für das Web entwickelt wurde. Zu diesem Zweck emuliert Emscripten das gesamte POSIX-Betriebssystem. Wenn das Programm fopen () verwendet , stellt Emscripten die Dateisystememulation bereit. Wenn OpenGL verwendet wird, stellt Emscripten einen C-kompatiblen GL-Kontext bereit, der von WebGL unterstützt wird. Dies ist viel Arbeit und viel Code, der im endgültigen Paket implementiert werden muss. Aber können Sie es einfach ... entfernen?

Der eigentliche Compiler im Emscripten-Toolkit ist LLVM. Er ist es, der den C-Code in WebAssembly-Bytecode übersetzt. Dies ist ein moderner modularer Rahmen für die Analyse, Transformation und Optimierung von Programmen. LLVM ist modular in dem Sinne, dass es niemals direkt in Maschinencode kompiliert wird. Stattdessen generiert der integrierte Front-End-Compiler eine Zwischendarstellung (IR). Diese Zwischendarstellung wird in der Tat als LLVM bezeichnet, eine Abkürzung für Low-Level Virtual Machine, daher der Name des Projekts.

Der Backend-Compiler übersetzt dann die IR in den Code des Host-Computers. Der Vorteil dieser strengen Trennung besteht darin, dass neue Architekturen durch das „einfache“ Hinzufügen eines neuen Compilers unterstützt werden. In diesem Sinne ist WebAssembly nur eines von vielen Kompilierungszielen, die LLVM unterstützt, und seit einiger Zeit wird es mit einem speziellen Flag aktiviert. Ab LLVM 8 ist das WebAssembly-Kompilierungsziel standardmäßig verfügbar.

Unter MacOS können Sie LLVM mit Homebrew installieren:

$ brew install llvm $ brew link --force llvm 

Überprüfen Sie die WebAssembly-Unterstützung:

 $ llc --version LLVM (http://llvm.org/): LLVM version 8.0.0 Optimized build. Default target: x86_64-apple-darwin18.5.0 Host CPU: skylake Registered Targets: # …,  … systemz - SystemZ thumb - Thumb thumbeb - Thumb (big endian) wasm32 - WebAssembly 32-bit # ! ! ! wasm64 - WebAssembly 64-bit x86 - 32-bit X86: Pentium-Pro and above x86-64 - 64-bit X86: EM64T and AMD64 xcore - XCore 

Es scheint, wir sind bereit!

C auf die harte Tour kompilieren


Hinweis: Hier finden Sie einige einfache RAW-WebAssembly-Formate. Wenn Sie es schwer zu verstehen finden, ist dies normal. Für eine gute Verwendung von WebAssembly ist kein Verständnis des gesamten Textes in diesem Artikel erforderlich. Wenn Sie nach Code zum Einfügen von Kopien suchen, lesen Sie den Aufruf des Compilers im Abschnitt Optimierung . Aber wenn Sie interessiert sind, lesen Sie weiter! Ich habe zuvor eine Einführung in reine Webassembly und WAT geschrieben: Dies sind die Grundlagen, die zum Verständnis dieses Beitrags erforderlich sind.
Warnung: Ich werde geringfügig vom Standard abweichen und versuchen, bei jedem Schritt (soweit möglich) für Menschen lesbare Formate zu verwenden. Unser Programm hier wird sehr einfach sein, um Grenzsituationen zu vermeiden und nicht abgelenkt zu werden:

 // Filename: add.c int add(int a, int b) { return a*a + b; } 

Was für eine großartige technische Leistung! Vor allem, weil das Programm add heißt, aber in Wirklichkeit nichts hinzufügt (nicht hinzufügt). Noch wichtiger: Das Programm verwendet nicht die Standardbibliothek und von den hier genannten Typen nur 'int'.

C in eine interne LLVM-Ansicht verwandeln


Der erste Schritt besteht darin, unser C-Programm in LLVM IR umzuwandeln. Dies ist die Aufgabe des clang Frontend-Compilers, der mit LLVM installiert wird:

 clang \ --target=wasm32 \ # Target WebAssembly -emit-llvm \ # Emit LLVM IR (instead of host machine code) -c \ # Only compile, no linking just yet -S \ # Emit human-readable assembly rather than binary add.c 

Als Ergebnis erhalten wir add.ll mit einer internen Darstellung von LLVM IR. Ich zeige es nur der Vollständigkeit halber . Wenn Sie mit WebAssembly arbeiten oder sogar klirren, werden Sie als C-Entwickler niemals mit LLVM IR in Kontakt kommen.

 ; ModuleID = 'add.c' source_filename = "add.c" target datalayout = "em:ep:32:32-i64:64-n32:64-S128" target triple = "wasm32" ; Function Attrs: norecurse nounwind readnone define hidden i32 @add(i32, i32) local_unnamed_addr #0 { %3 = mul nsw i32 %0, %0 %4 = add nsw i32 %3, %1 ret i32 %4 } attributes #0 = { norecurse nounwind readnone "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-frame-pointer-elim"="false" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="generic" "unsafe-fp-math"="false" "use-soft-float"="false" } !llvm.module.flags = !{!0} !llvm.ident = !{!1} !0 = !{i32 1, !"wchar_size", i32 4} !1 = !{!"clang version 8.0.0 (tags/RELEASE_800/final)"} 

LLVM IR ist voll von zusätzlichen Metadaten und Anmerkungen, die es dem Compiler ermöglichen, fundiertere Entscheidungen beim Generieren von Maschinencode zu treffen.

Verwandeln Sie LLVM IR in Objektdateien


Der nächste Schritt besteht darin, den llc Backend-Compiler llc , um aus der internen Darstellung eine Objektdatei zu erstellen.

Die add.o ist bereits ein gültiges WebAssembly-Modul, das den gesamten kompilierten Code unserer C-Datei enthält. In der Regel können Sie jedoch keine Objektdateien ausführen, da ihnen wesentliche Teile fehlen.

Wenn wir -filetype=obj im Befehl -filetype=obj , erhalten wir den LLVM-Assembler für WebAssembly, ein für Menschen lesbares Format, das WAT etwas ähnlich ist. Das llvm-mc Tool zum Arbeiten mit solchen Dateien unterstützt das Format jedoch noch nicht vollständig und kann häufig keine Dateien verarbeiten. Daher zerlegen wir die Objektdateien nachträglich. Zum Überprüfen dieser Objektdateien wird ein spezielles Tool benötigt. Im Fall von WebAssembly war es wasm-objdump , Teil des WebAssembly Binary Toolkit oder kurz wabt.

 $ brew install wabt # in case you haven't $ wasm-objdump -x add.o add.o: file format wasm 0x1 Section Details: Type[1]: - type[0] (i32, i32) -> i32 Import[3]: - memory[0] pages: initial=0 <- env.__linear_memory - table[0] elem_type=funcref init=0 max=0 <- env.__indirect_function_table - global[0] i32 mutable=1 <- env.__stack_pointer Function[1]: - func[0] sig=0 <add> Code[1]: - func[0] size=75 <add> Custom: - name: "linking" - symbol table [count=2] - 0: F <add> func=0 binding=global vis=hidden - 1: G <env.__stack_pointer> global=0 undefined binding=global vis=default Custom: - name: "reloc.CODE" - relocations for section: 3 (Code) [1] R_WASM_GLOBAL_INDEX_LEB offset=0x000006(file=0x000080) symbol=1 <env.__stack_pointer> 

Die Ausgabe zeigt, dass sich unsere Funktion add () in diesem Modul befindet, enthält jedoch auch benutzerdefinierte Abschnitte mit Metadaten und überraschenderweise mehrere Importe. In der nächsten Phase der Verknüpfung werden benutzerdefinierte Abschnitte analysiert und gelöscht, und der Linker (Linker) übernimmt den Import.

Layout


Traditionell besteht die Aufgabe des Linkers darin, mehrere Objektdateien zu einer ausführbaren Datei zusammenzufügen. Der LLVM-Linker heißt lld und wird mit dem lld aufgerufen. Für WebAssembly war dies wasm-ld .

 wasm-ld \ --no-entry \ # We don't have an entry function --export-all \ # Export everything (for now) -o add.wasm \ add.o 

Das Ergebnis ist ein WebAssembly-Modul mit einer Größe von 262 Byte.

Starten


Das Wichtigste ist natürlich, dass alles wirklich funktioniert. Wie im letzten Artikel können Sie dieses WebAssembly-Modul mit einigen Zeilen eingebetteten JavaScript laden und ausführen.

 <!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); console.log(instance.exports.add(4, 1)); } init(); </script> 

Wenn alles in Ordnung ist, sehen Sie die Nummer 17 in der DevTool-Konsole. Wir haben gerade C erfolgreich in WebAssembly kompiliert, ohne Emscripten zu berühren. Es ist auch erwähnenswert, dass es keine Middleware zum Konfigurieren und Laden des WebAssembly-Moduls gibt.

Das Kompilieren von C ist etwas einfacher


Um C in WebAssembly zu kompilieren, haben wir viele Schritte unternommen. Wie gesagt, wir haben zu Bildungszwecken alle Phasen im Detail untersucht. Überspringen wir für Menschen lesbare Zwischenformate und wenden den C-Compiler sofort als Schweizer Taschenmesser an, wie er entwickelt wurde:

 clang \ --target=wasm32 \ -nostdlib \ # Don't try and link against a standard library -Wl,--no-entry \ # Flags passed to the linker -Wl,--export-all \ -o add.wasm \ add.c 

Hier erhalten wir die gleiche .wasm Datei, jedoch mit einem Befehl.

Optimierung


Schauen Sie sich das WAT unseres WebAssembly-Moduls an, indem Sie wasm2wat :

 (module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) (local i32 i32 i32 i32 i32 i32 i32 i32) global.get 0 local.set 2 i32.const 16 local.set 3 local.get 2 local.get 3 i32.sub local.set 4 local.get 4 local.get 0 i32.store offset=12 local.get 4 local.get 1 i32.store offset=8 local.get 4 i32.load offset=12 local.set 5 local.get 4 i32.load offset=12 local.set 6 local.get 5 local.get 6 i32.mul local.set 7 local.get 4 i32.load offset=8 local.set 8 local.get 7 local.get 8 i32.add local.set 9 local.get 9 return) (table (;0;) 1 1 anyfunc) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 66560)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "__heap_base" (global 1)) (export "__data_end" (global 2)) (export "__dso_handle" (global 3)) (export "add" (func $add))) 

Wow, was für ein großartiger Code. Zu meiner Überraschung verwendet das Modul Speicher (wie aus den i32.store i32.load und i32.store ), acht lokale und mehrere globale Variablen. Wahrscheinlich können Sie manuell eine präzisere Version schreiben. Dieses Programm ist so groß, weil wir keine Optimierungen vorgenommen haben. Lass es uns tun:

 clang \ --target=wasm32 \ + -O3 \ # Agressive optimizations + -flto \ # Add metadata for link-time optimizations -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \ + -Wl,--lto-O3 \ # Aggressive link-time optimizations -o add.wasm \ add.c 

Hinweis: Technisch gesehen bietet die Layoutoptimierung (LTO) keine Vorteile, da wir nur eine Datei erstellen. In großen Projekten hilft LTO dabei, die Dateigröße erheblich zu reduzieren.
Nach dem Ausführen dieser Befehle wurde die .wasm Datei von 262 auf 197 Byte verringert, und WAT wurde auch viel einfacher:

 (module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) local.get 0 local.get 0 i32.mul local.get 1 i32.add) (table (;0;) 1 1 anyfunc) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 66560)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "__heap_base" (global 1)) (export "__data_end" (global 2)) (export "__dso_handle" (global 3)) (export "add" (func $add))) 

Rufen Sie die Standardbibliothek auf


Die Verwendung von C ohne die Standardbibliothek libc erscheint eher unhöflich. Es ist logisch, es hinzuzufügen, aber ich werde ehrlich sein: Es wird nicht einfach sein. Tatsächlich rufen wir keine libc-Bibliotheken im Artikel direkt auf . Es gibt mehrere geeignete, insbesondere Glibc , Mussl und Dietlibc . Die meisten dieser Bibliotheken sollen jedoch unter dem POSIX-Betriebssystem ausgeführt werden, das bestimmte Systemaufrufe implementiert. Da wir in JavaScript keine Kernel-Oberfläche haben, müssen wir diese POSIX-Systemaufrufe selbst implementieren, wahrscheinlich über JavaScript. Dies ist eine schwierige Aufgabe und ich werde sie hier nicht erledigen. Die gute Nachricht ist, dass Emscripten dies für Sie tut .

Natürlich sind nicht alle libc-Funktionen auf Systemaufrufe angewiesen. Funktionen wie strlen() , sin() oder sogar memset() sind in einfachem C implementiert. Dies bedeutet, dass Sie diese Funktionen verwenden oder ihre Implementierung einfach aus einer der genannten Bibliotheken kopieren / einfügen können.

Dynamischer Speicher


Ohne libc stehen uns C-Grundschnittstellen wie malloc() und free() nicht zur Verfügung. Im nicht optimierten WAT haben wir gesehen, dass der Compiler bei Bedarf Speicher verwendet. Dies bedeutet, dass wir den Speicher nicht einfach so verwenden können, wie wir möchten, ohne das Risiko einzugehen, ihn zu beschädigen. Sie müssen verstehen, wie es verwendet wird.

LLVM-Speichermodelle


Die WebAssembly-Speichersegmentierungsmethode wird erfahrene Programmierer ein wenig überraschen. Erstens ist in WebAssembly eine Nulladresse technisch zulässig, wird jedoch häufig immer noch als Fehler behandelt. Zweitens kommt der Stapel zuerst und wächst nach unten (zu niedrigeren Adressen), und der Heap erscheint später und wächst nach oben. Der Grund dafür ist, dass der Speicher von WebAssembly zur Laufzeit zunehmen kann. Dies bedeutet, dass es kein festes Ende gibt, um den Stapel oder den Heap aufzunehmen.

Hier ist das wasm-ld Layout:



Der Stapel wächst nach unten und der Haufen wächst nach oben. Der Stack beginnt mit __data_end und der Heap __heap_base mit __heap_base . Da der Stapel an erster Stelle steht, ist er durch die maximale Größe begrenzt, die während der Kompilierung festgelegt wurde, d. H. __heap_base minus __data_end

Wenn wir uns den globalen Abschnitt in unserem WAT ansehen, finden wir folgende Werte: __heap_base auf 66560 und __data_end auf 1024 festgelegt. Dies bedeutet, dass der Stapel auf maximal 64 KiB wachsen kann, was nicht viel ist. Glücklicherweise können Sie mit wasm-ld diesen Wert ändern:

 clang \ --target=wasm32 \ -O3 \ -flto \ -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \ -Wl,--lto-O3 \ + -Wl,-z,stack-size=$[8 * 1024 * 1024] \ # Set maximum stack size to 8MiB -o add.wasm \ add.c 

Allokatorbaugruppe


Es ist bekannt, dass der Heap-Bereich mit __heap_base . Da die Funktion malloc() fehlt, wissen wir, dass der nächste Speicherbereich sicher verwendet werden kann. Wir können die Daten dort platzieren, wie wir möchten, und es besteht kein Grund zur Angst vor Speicherbeschädigung, da der Stapel in die andere Richtung wächst. Ein für alle kostenloser Heap kann jedoch schnell verstopfen, sodass normalerweise eine Art dynamisches Speichermanagement erforderlich ist. Eine Möglichkeit besteht darin, eine vollständige Implementierung von malloc () zu übernehmen, beispielsweise die Malloc-Implementierung von Doug Lee , die in Emscripten verwendet wird. Es gibt mehrere weitere kleine Implementierungen mit verschiedenen Kompromissen.

Aber warum nicht dein eigenes malloc() schreiben? Wir sind so tief verwurzelt, dass es keinen Unterschied macht. Eines der einfachsten ist ein Bump Allocator: Es ist superschnell, extrem klein und einfach zu implementieren. Es gibt jedoch einen Nachteil: Sie können keinen Speicher freigeben. Obwohl ein solcher Allokator auf den ersten Blick unglaublich nutzlos erscheint, bin ich bei der Entwicklung von Squoosh auf Präzedenzfälle gestoßen, bei denen dies eine ausgezeichnete Wahl wäre. Das Konzept eines Bump-Allokators besteht darin, dass wir die Startadresse des nicht verwendeten Speichers als global speichern. Wenn das Programm n Bytes Speicher anfordert, verschieben wir den Marker auf n und geben den vorherigen Wert zurück:

 extern unsigned char __heap_base; unsigned int bump_pointer = &__heap_base; void* malloc(int n) { unsigned int r = bump_pointer; bump_pointer += n; return (void *)r; } void free(void* p) { // lol } 

Die globalen Variablen von WAT werden tatsächlich von wasm-ld , sodass wir von unserem C-Code aus als normale Variablen darauf zugreifen können, wenn wir sie extern deklarieren. Also haben wir gerade unser eigenes malloc() ... in fünf Zeilen von C.

Hinweis: Unser Bump Allocator ist nicht vollständig mit malloc() von C kompatibel. Beispielsweise geben wir keine Ausrichtungsgarantien. Aber es funktioniert gut genug, also ...

Dynamische Speichernutzung


Zum Testen erstellen wir eine Funktion C, die ein Array von Zahlen beliebiger Größe verwendet und die Summe berechnet. Nicht sehr interessant, aber dies zwingt uns, dynamischen Speicher zu verwenden, da wir die Größe des Arrays während der Montage nicht kennen:

 int sum(int a[], int len) { int sum = 0; for(int i = 0; i < len; i++) { sum += a[i]; } return sum; } 

Die sum () -Funktion ist hoffentlich ziemlich einfach. Eine interessantere Frage ist, wie ein Array von JavaScript an WebAssembly übergeben wird. Schließlich versteht WebAssembly nur Zahlen. Die allgemeine Idee ist, mit malloc() aus JavaScript einen Speicherplatz zuzuweisen, die Werte dort zu kopieren und die Adresse (Nummer!) Zu übergeben, an der sich das Array befindet:

 <!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); const jsArray = [1, 2, 3, 4, 5]; // Allocate memory for 5 32-bit integers // and return get starting address. const cArrayPointer = instance.exports.malloc(jsArray.length * 4); // Turn that sequence of 32-bit integers // into a Uint32Array, starting at that address. const cArray = new Uint32Array( instance.exports.memory.buffer, cArrayPointer, jsArray.length ); // Copy the values from JS to C. cArray.set(jsArray); // Run the function, passing the starting address and length. console.log(instance.exports.sum(cArrayPointer, cArray.length)); } init(); </script> 

Nach dem Start sollte die Antwort 15 in der DevTools-Konsole angezeigt werden. Dies ist die Summe aller Zahlen von 1 bis 5.

Fazit


Sie lesen also bis zum Ende. Glückwunsch! Auch hier ist alles in Ordnung, wenn Sie sich etwas überlastet fühlen. Es ist nicht notwendig, alle Details zu lesen. Das Verständnis dieser Informationen ist für einen guten Webentwickler völlig optional und für die hervorragende Verwendung von WebAssembly nicht einmal erforderlich . Aber ich wollte diese Informationen teilen, weil Sie so die ganze Arbeit, die ein Projekt wie Emscripten für Sie leistet, wirklich schätzen können. Gleichzeitig erhalten Sie ein Verständnis dafür, wie klein die rein rechnerischen Module von WebAssembly sein können. Das Wasm-Modul zum Summieren des Arrays ist nur 230 Byte groß, einschließlich eines dynamischen Speicherzuweisers . Wenn Sie denselben Code mit Emscripten kompilieren, werden 100 Byte WebAssembly-Code und 11 KB JavaScript-Verknüpfungscode erzeugt. Wir müssen es für ein solches Ergebnis versuchen, aber es gibt Situationen, in denen es sich lohnt.

Source: https://habr.com/ru/post/de454868/


All Articles