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:
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:
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 \
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
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 \
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 \
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 \
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] \
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) {
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]; </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.