Czasami chcesz użyć biblioteki, która jest dostępna tylko jako kod C lub C++. Zwykle w tym momencie się poddajesz. Teraz mamy już Emscripten i WebAssembly (Wasm).
Łańcuch narzędzi
Postawiłem sobie za cel dowiedzieć się, jak skompilować istniejący kod C do Wasm. Ostatnio sporo się mówi o backendzie Wasm w LLVM, więc postanowiłem się temu przyjrzeć. Chociaż w ten sposób można skompilować proste programy, to gdy tylko zechcesz użyć standardowej biblioteki C lub skompilować wiele plików, prawdopodobnie napotkasz problemy. To doprowadziło mnie do najważniejszego wniosku:
Emscripten był kompilatorem C-to-asm.js, ale od tego czasu został dostosowany do Wasm i jest w trakcie przechodzenia na oficjalny backend LLVM. Emscripten udostępnia też zgodną z Wasm implementację standardowej biblioteki języka C. Użyj Emscripten. Wykonuje wiele ukrytych zadań, emuluje system plików, zapewnia zarządzanie pamięcią, opakowuje OpenGL za pomocą WebGL – wiele rzeczy, których nie musisz doświadczać podczas samodzielnego programowania.
Może się wydawać, że trzeba się martwić o rozmiar – ja się martwiłem – ale kompilator Emscripten usuwa wszystko, co nie jest potrzebne. W moich eksperymentach wynikowe moduły Wasm mają rozmiar odpowiedni do zawartej w nich logiki, a zespoły Emscripten i WebAssembly pracują nad tym, aby w przyszłości były jeszcze mniejsze.
Emscripten możesz pobrać, postępując zgodnie z instrukcjami na stronie internetowej lub korzystając z Homebrew. Jeśli tak jak ja jesteś fanem poleceń Dockera i nie chcesz instalować niczego w systemie tylko po to, aby pobawić się WebAssembly, możesz użyć dobrze utrzymanego obrazu Dockera:
$ docker pull trzeci/emscripten
$ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>
Kompilowanie czegoś prostego
Weźmy niemal kanoniczny przykład napisania funkcji w języku C, która oblicza n-tąth liczbę Fibonacciego:
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int fib(int n) {
if(n <= 0){
return 0;
}
int i, t, a = 0, b = 1;
for (i = 1; i < n; i++) {
t = a + b;
a = b;
b = t;
}
return b;
}
Jeśli znasz język C, sama funkcja nie powinna być dla Ciebie zaskoczeniem. Nawet jeśli nie znasz języka C, ale znasz JavaScript, mam nadzieję, że zrozumiesz, o co chodzi.
emscripten.h to plik nagłówka dostarczany przez Emscripten. Potrzebujemy go tylko po to, aby mieć dostęp do makra EMSCRIPTEN_KEEPALIVE, ale zapewnia on znacznie więcej funkcji.
To makro informuje kompilator, aby nie usuwał funkcji, nawet jeśli wydaje się, że nie jest używana. Gdybyśmy pominęli to makro, kompilator zoptymalizowałby funkcję, ponieważ nikt jej nie używa.
Zapiszmy to wszystko w pliku o nazwie fib.c. Aby przekształcić go w plik .wasm, musimy użyć polecenia kompilatora Emscripten emcc:
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c
Przeanalizujmy to polecenie. emcc to kompilator Emscripten. fib.c to nasz plik C. Idzie Ci doskonale. -s WASM=1 informuje Emscripten, że ma nam przekazać plik Wasm zamiast pliku asm.js.
-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' informuje kompilator, że funkcja cwrap() ma być dostępna w pliku JavaScript – więcej o tej funkcji później. -O3 informuje kompilator, że ma przeprowadzić agresywną optymalizację. Możesz wybrać mniejsze liczby, aby skrócić czas kompilacji, ale spowoduje to również zwiększenie rozmiaru pakietów wynikowych, ponieważ kompilator może nie usunąć nieużywanego kodu.
Po uruchomieniu polecenia powinien pojawić się plik JavaScript o nazwie a.out.js i plik WebAssembly o nazwie a.out.wasm. Plik Wasm (lub „moduł”) zawiera skompilowany kod C i powinien być dość mały. Plik JavaScriptu zajmuje się wczytywaniem i inicjowaniem modułu Wasm oraz udostępnianiem lepszego interfejsu API. W razie potrzeby zajmie się też skonfigurowaniem stosu, sterty i innych funkcji, które zwykle są dostarczane przez system operacyjny podczas pisania kodu w języku C. Dlatego plik JavaScriptu jest nieco większy i ma rozmiar 19 KB (ok. 5 KB po skompresowaniu).
Uruchamianie czegoś prostego
Najprostszym sposobem na wczytanie i uruchomienie modułu jest użycie wygenerowanego pliku JavaScript. Po wczytaniu tego pliku będziesz mieć do dyspozycji zmienną globalną Module. Użyj cwrap, aby utworzyć natywną funkcję JavaScriptu, która zajmie się konwertowaniem parametrów na format zgodny z C i wywoływaniem funkcji opakowanej. cwrap przyjmuje jako argumenty nazwę funkcji, zwracany typ i typy argumentów w tej kolejności:
<script src="a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
const fib = Module.cwrap('fib', 'number', ['number']);
console.log(fib(12));
};
</script>
Jeśli uruchomisz ten kod, w konsoli powinna pojawić się liczba „144”, czyli 12 liczba Fibonacciego.
Święty Graal: kompilowanie biblioteki C
Do tej pory kod C, który napisaliśmy, był pisany z myślą o Wasm. Jednak głównym przypadkiem użycia WebAssembly jest wykorzystanie istniejącego ekosystemu bibliotek C i umożliwienie deweloperom korzystania z nich w internecie. Te biblioteki często korzystają z biblioteki standardowej języka C, systemu operacyjnego, systemu plików i innych elementów. Emscripten zapewnia większość tych funkcji, ale istnieją pewne ograniczenia.
Wróćmy do mojego pierwotnego celu: skompilowania kodera WebP do Wasm. Kod źródłowy kodeka WebP jest napisany w języku C i dostępny na GitHub. Dostępna jest też obszerna dokumentacja API. To całkiem dobry punkt wyjścia.
$ git clone https://github.com/webmproject/libwebp
Zacznijmy od czegoś prostego. Spróbujmy udostępnić WebPGetEncoderVersion() z encode.h w JavaScript, pisząc plik C o nazwie webp.c:
#include "emscripten.h"
#include "src/webp/encode.h"
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetEncoderVersion();
}
To dobry, prosty program do sprawdzenia, czy możemy skompilować kod źródłowy libwebp, ponieważ nie potrzebujemy żadnych parametrów ani złożonych struktur danych, aby wywołać tę funkcję.
Aby skompilować ten program, musimy poinformować kompilator, gdzie może znaleźć pliki nagłówka libwebp, używając flagi -I, a także przekazać mu wszystkie potrzebne pliki C biblioteki libwebp. Będę szczery: po prostu przekazałem mu wszystkie pliki C, które udało mi się znaleźć, i liczyłem na to, że kompilator usunie wszystko, co niepotrzebne. Wygląda na to, że to zadziałało.
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-I libwebp \
webp.c \
libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c
Teraz potrzebujemy tylko kodu HTML i JavaScript, aby wczytać nasz nowy moduł:
<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = async (_) => {
const api = {
version: Module.cwrap('version', 'number', []),
};
console.log(api.version());
};
</script>
W danych wyjściowych zobaczysz numer wersji:

Przenoszenie obrazu z JavaScriptu do Wasm
Uzyskanie numeru wersji kodera jest świetne, ale zakodowanie rzeczywistego obrazu byłoby bardziej imponujące, prawda? Zróbmy to.
Pierwsze pytanie, na które musimy odpowiedzieć, brzmi: jak przenieść obraz do środowiska Wasm?
Jeśli spojrzymy na interfejs API kodowania libwebp, oczekuje on tablicy bajtów w formacie RGB, RGBA, BGR lub BGRA. Na szczęście interfejs Canvas API ma metodę getImageData(), która zwraca tablicę Uint8ClampedArray zawierającą dane obrazu w formacie RGBA:
async function loadImage(src) {
// Load image
const imgBlob = await fetch(src).then((resp) => resp.blob());
const img = await createImageBitmap(imgBlob);
// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
Teraz „tylko” trzeba skopiować dane z JavaScriptu do Wasm. W tym celu musimy udostępnić 2 dodatkowe funkcje. Jedna, która przydziela pamięć dla obrazu w środowisku Wasm, i druga, która ją zwalnia:
EMSCRIPTEN_KEEPALIVE
uint8_t* create_buffer(int width, int height) {
return malloc(width * height * 4 * sizeof(uint8_t));
}
EMSCRIPTEN_KEEPALIVE
void destroy_buffer(uint8_t* p) {
free(p);
}
create_buffer przydziela bufor dla obrazu RGBA, czyli 4 bajty na piksel.
Wskaźnik zwracany przez funkcję malloc() to adres pierwszej komórki pamięci tego bufora. Gdy wskaźnik wraca do JavaScriptu, jest traktowany jako zwykła liczba. Po udostępnieniu funkcji w języku JavaScript za pomocą cwrap możemy użyć tej liczby, aby znaleźć początek bufora i skopiować dane obrazu.
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);
Wielki finał: zakoduj obraz
Obraz jest teraz dostępny w środowisku Wasm. Czas wywołać koder WebP, aby wykonał swoje zadanie. Zgodnie z dokumentacją WebP WebPEncodeRGBA wydaje się idealnym rozwiązaniem. Funkcja przyjmuje wskaźnik do obrazu wejściowego i jego wymiary, a także opcję jakości od 0 do 100. Przydziela też bufor wyjściowy, który musimy zwolnić za pomocą funkcji WebPFree(), gdy skończymy pracę z obrazem WebP.
Wynikiem operacji kodowania jest bufor wyjściowy i jego długość. Ponieważ funkcje w C nie mogą mieć tablic jako zwracanych typów (chyba że pamięć jest przydzielana dynamicznie), użyłem statycznej tablicy globalnej. Wiem, że nie jest to czysty język C (w rzeczywistości opiera się na tym, że wskaźniki Wasm mają szerokość 32 bitów), ale dla uproszczenia uważam, że jest to dobre rozwiązanie.
int result[2];
EMSCRIPTEN_KEEPALIVE
void encode(uint8_t* img_in, int width, int height, float quality) {
uint8_t* img_out;
size_t size;
size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);
result[0] = (int)img_out;
result[1] = size;
}
EMSCRIPTEN_KEEPALIVE
void free_result(uint8_t* result) {
WebPFree(result);
}
EMSCRIPTEN_KEEPALIVE
int get_result_pointer() {
return result[0];
}
EMSCRIPTEN_KEEPALIVE
int get_result_size() {
return result[1];
}
Teraz, gdy wszystko jest już gotowe, możemy wywołać funkcję kodowania, pobrać wskaźnik i rozmiar obrazu, umieścić go we własnym buforze JavaScriptu i zwolnić wszystkie buforu Wasm przydzielone w tym procesie.
api.encode(p, image.width, image.height, 100);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
api.free_result(resultPointer);
W zależności od rozmiaru obrazu może wystąpić błąd, gdy Wasm nie będzie w stanie zwiększyć pamięci na tyle, aby pomieścić zarówno obraz wejściowy, jak i wyjściowy:

Na szczęście rozwiązanie tego problemu znajduje się w komunikacie o błędzie. Wystarczy, że dodamy -s ALLOW_MEMORY_GROWTH=1 do polecenia kompilacji.
I to wszystko! Skompilowaliśmy koder WebP i przekodowaliśmy obraz JPEG na WebP. Aby udowodnić, że to działa, możemy przekształcić bufor wyników w obiekt blob i użyć go w elemencie <img>:
const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);
Oto nowy obraz WebP w całej okazałości.

Podsumowanie
Uruchomienie biblioteki C w przeglądarce nie jest łatwe, ale gdy zrozumiesz cały proces i przepływ danych, stanie się to prostsze, a wyniki mogą być oszałamiające.
WebAssembly otwiera wiele nowych możliwości w internecie w zakresie przetwarzania, obliczeń i gier. Pamiętaj, że Wasm nie jest uniwersalnym rozwiązaniem, które można zastosować do wszystkiego, ale gdy napotkasz jedno z tych wąskich gardeł, może być niezwykle pomocny.
Bonus content: Running something simple the hard way
Jeśli chcesz uniknąć wygenerowanego pliku JavaScript, możesz to zrobić. Wróćmy do przykładu z ciągiem Fibonacciego. Aby załadować i uruchomić go samodzielnie, możemy wykonać te czynności:
<!DOCTYPE html>
<script>
(async function () {
const imports = {
env: {
memory: new WebAssembly.Memory({ initial: 1 }),
STACKTOP: 0,
},
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/a.out.wasm'),
imports,
);
console.log(instance.exports._fib(12));
})();
</script>
Moduły WebAssembly utworzone przez Emscripten nie mają pamięci, z którą mogłyby pracować, chyba że im ją udostępnisz. Aby przekazać modułowi Wasm dowolne dane, użyj obiektu imports, który jest drugim parametrem funkcji instantiateStreaming. Moduł Wasm ma dostęp do wszystkiego, co znajduje się w obiekcie importu, ale nie do niczego poza nim. Zgodnie z konwencją moduły skompilowane przez Emscripten oczekują od środowiska JavaScriptu, które je wczytuje, kilku rzeczy:
- Po pierwsze:
env.memory. Moduł Wasm nie ma dostępu do świata zewnętrznego, więc potrzebuje pamięci do działania. WpiszWebAssembly.Memory. Reprezentuje (opcjonalnie rozszerzalny) fragment pamięci liniowej. Parametry rozmiaru są podawane w „jednostkach stron WebAssembly”, co oznacza, że powyższy kod przydziela 1 stronę pamięci, a każda strona ma rozmiar 64 KiB. Jeśli nie podasz opcjimaximum, pamięć może teoretycznie rosnąć bez ograniczeń (Chrome ma obecnie limit 2 GB). Większość modułów WebAssembly nie musi mieć ustawionego maksimum. env.STACKTOPokreśla, gdzie ma się zaczynać stos. Stos jest potrzebny do wywoływania funkcji i przydzielania pamięci zmiennym lokalnym. Ponieważ w naszym małym programie do obliczania liczb Fibonacciego nie stosujemy żadnych sztuczek związanych z dynamicznym zarządzaniem pamięcią, możemy po prostu użyć całej pamięci jako stosu, stądSTACKTOP = 0.