Kompiler adalah bagian dari
Emscripten . Tetapi bagaimana jika Anda menghapus semua peluit dan hanya meninggalkannya?
Emscripten diperlukan untuk mengkompilasi C / C ++ ke dalam
WebAssembly . Tapi ini lebih dari sekedar kompiler. Tujuan Emscripten adalah untuk sepenuhnya mengganti kompiler C / C ++ Anda dan menjalankan kode di web yang awalnya
tidak dirancang untuk Web. Untuk ini, Emscripten mengemulasi seluruh sistem operasi POSIX. Jika program menggunakan
fopen () , maka Emscripten akan memberikan emulasi sistem file. Jika OpenGL digunakan, Emscripten akan memberikan konteks GL yang kompatibel dengan C yang didukung oleh
WebGL . Ini banyak pekerjaan, dan banyak kode yang harus diimplementasikan dalam paket akhir. Tapi bisakah Anda ... menghapusnya?
Kompilator aktual dalam toolkit Emscripten adalah LLVM. Dialah yang menerjemahkan kode C ke dalam bytecode WebAssembly. Ini adalah kerangka kerja modular modern untuk analisis, transformasi dan optimalisasi program. LLVM bersifat modular dalam arti tidak pernah dikompilasi secara langsung ke dalam kode mesin. Alih-alih,
kompiler front-end bawaan menghasilkan
representasi perantara (IR). Representasi perantara ini, pada kenyataannya, disebut LLVM, kependekan dari Low-Level Virtual Machine, karenanya nama proyek.
Kompiler backend kemudian menerjemahkan IR ke dalam kode mesin host. Keuntungan dari pemisahan ketat ini adalah bahwa arsitektur baru didukung oleh penambahan "sederhana" dari kompiler baru. Dalam hal ini, WebAssembly hanyalah salah satu dari banyak tujuan kompilasi yang didukung LLVM, dan untuk beberapa waktu telah diaktifkan dengan flag khusus. Dimulai dengan LLVM 8, target kompilasi WebAssembly tersedia secara default.
Di MacOS, Anda dapat menginstal LLVM menggunakan
homebrew :
$ brew install llvm $ brew link --force llvm
Periksa dukungan WebAssembly:
$ 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:
Sepertinya kita sudah siap!
Kompilasi C dengan Hard Way
Catatan: berikut adalah beberapa format RAW WebAssembly tingkat rendah. Jika Anda merasa sulit untuk dipahami, ini normal. Penggunaan WebAssembly yang baik tidak memerlukan pemahaman seluruh teks dalam artikel ini. Jika Anda mencari kode untuk menempelkan salinan, lihat panggilan ke kompiler di bagian Optimasi . Tetapi jika Anda tertarik, teruslah membaca! Saya sebelumnya menulis pengantar Webassembly dan WAT murni : ini adalah dasar yang diperlukan untuk memahami posting ini.
Peringatan: Saya akan sedikit menyimpang dari standar dan mencoba menggunakan format yang dapat dibaca manusia di setiap langkah (sejauh mungkin). Program kami di sini akan sangat sederhana untuk menghindari situasi perbatasan dan tidak terganggu:
Sungguh prestasi teknik yang luar biasa! Terutama karena program ini disebut
add , tetapi pada kenyataannya ia tidak
menambahkan apa-apa (tidak menambah). Lebih penting lagi: program tidak menggunakan pustaka standar, dan dari jenis di sini, hanya 'int'.
Mengubah C menjadi Tampilan LLVM Internal
Langkah pertama adalah mengubah program C kami menjadi LLVM IR. Ini adalah tugas dari compiler frontend
clang
, yang diinstal dengan LLVM:
clang \ --target=wasm32 \
Dan sebagai hasilnya, kita mendapatkan
add.ll
dengan representasi internal dari LLVM IR.
Saya tunjukkan hanya demi kelengkapan . Saat bekerja dengan WebAssembly atau bahkan dentang, Anda sebagai pengembang C tidak
akan pernah berhubungan dengan LLVM IR.
; 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 penuh dengan metadata dan anotasi tambahan, yang memungkinkan kompiler membuat keputusan yang lebih tepat ketika membuat kode mesin.Ubah LLVM IR menjadi file objek
Langkah selanjutnya adalah memanggil kompiler backend
llc
untuk membuat file objek dari representasi internal.
add.o
keluaran
add.o
sudah menjadi modul WebAssembly yang valid yang berisi semua kode yang dikompilasi dari file C. Tetapi biasanya Anda tidak akan dapat menjalankan file objek karena mereka tidak memiliki bagian-bagian penting.
Jika kita
-filetype=obj
dalam perintah, kita akan mendapatkan assembler LLVM untuk WebAssembly, format yang dapat dibaca manusia yang agak mirip dengan WAT. Namun, alat
llvm-mc
untuk bekerja dengan file tersebut belum sepenuhnya mendukung format dan sering tidak dapat memproses file. Oleh karena itu, kami membongkar file objek setelah fakta. Diperlukan alat khusus untuk memverifikasi file objek ini. Dalam kasus WebAssembly, itu adalah
wasm-objdump
, bagian dari
WebAssembly Binary Toolkit atau singkatnya.
$ brew install wabt
Output menunjukkan bahwa fungsi add () kami ada di modul ini, tetapi juga berisi bagian
khusus dengan metadata dan, yang mengejutkan, beberapa impor. Pada tahap
penautan selanjutnya, bagian khusus akan dianalisis dan dihapus, dan penaut (penghubung) akan berurusan dengan impor.
Tata letak
Secara tradisional, tugas penghubung adalah untuk merakit beberapa file objek menjadi file yang dapat dieksekusi. Linker LLVM disebut
lld
, dan dipanggil dengan target symlink. Untuk WebAssembly, ini
wasm-ld
.
wasm-ld \ --no-entry \
Hasilnya adalah modul WebAssembly berukuran 262 byte.
Luncurkan
Tentu saja, yang paling penting adalah untuk melihat bahwa semuanya
benar-benar berfungsi. Seperti pada
artikel terakhir , Anda dapat menggunakan beberapa baris JavaScript tertanam untuk memuat dan menjalankan modul WebAssembly ini.
<!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>
Jika semuanya baik-baik saja, Anda akan melihat nomor 17 di konsol DevTool.
Kami baru saja berhasil mengkompilasi C ke dalam WebAssembly tanpa menyentuh Emscripten. Perlu juga dicatat bahwa tidak ada middleware untuk mengkonfigurasi dan memuat modul WebAssembly.
Kompilasi C sedikit lebih sederhana
Untuk mengkompilasi C di WebAssembly, kami telah mengambil banyak langkah. Seperti yang saya katakan, untuk tujuan pendidikan, kami memeriksa secara rinci semua tahapan. Mari kita lompati format perantara yang dapat dibaca manusia dan segera menerapkan kompiler C sebagai pisau tentara Swiss, seperti yang dikembangkan:
clang \ --target=wasm32 \ -nostdlib \
Di sini kita mendapatkan file
.wasm
sama, tetapi dengan satu perintah.
Optimasi
Lihatlah modul WAT WebAssembly kami dengan menjalankan
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, kode yang luar biasa. Yang mengejutkan saya, modul ini menggunakan memori (seperti yang terlihat dari operasi
i32.load
dan
i32.store
), delapan lokal dan beberapa variabel global. Mungkin, Anda dapat secara manual menulis versi yang lebih ringkas. Program ini sangat besar karena kami tidak menerapkan optimasi apa pun. Mari kita lakukan:
clang \ --target=wasm32 \ + -O3 \
Catatan: secara teknis, optimisasi tata letak (LTO) tidak memberikan keuntungan karena kami hanya membuat satu file. Dalam proyek-proyek besar, KPP akan membantu mengurangi ukuran file secara signifikan.
Setelah menjalankan perintah ini, file
.wasm
menurun dari 262 menjadi 197 byte, dan WAT juga menjadi lebih sederhana:
(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)))
Panggil perpustakaan standar
Menggunakan C tanpa perpustakaan libc standar tampaknya agak kasar. Adalah logis untuk menambahkannya, tetapi saya akan jujur: itu
tidak akan mudah.
Faktanya, kami tidak secara langsung memanggil perpustakaan libc apa pun di artikel . Ada beberapa yang cocok, terutama
glibc ,
musl , dan
dietlibc . Namun, sebagian besar pustaka ini seharusnya berjalan di sistem operasi POSIX, yang mengimplementasikan serangkaian panggilan sistem tertentu. Karena kita tidak memiliki antarmuka kernel dalam JavaScript, kita harus mengimplementasikan sendiri panggilan sistem POSIX ini, mungkin melalui JavaScript. Ini adalah tugas yang sulit dan saya tidak akan melakukannya di sini. Berita baiknya adalah
ini yang dilakukan Emscripten untuk Anda .
Tentu saja, tidak semua fungsi libc bergantung pada panggilan sistem. Fungsi-fungsi seperti
strlen()
,
sin()
atau bahkan
memset()
diimplementasikan dalam C. sederhana. Ini berarti bahwa Anda dapat menggunakan fungsi-fungsi ini atau bahkan hanya menyalin / menempel implementasinya dari beberapa perpustakaan yang disebutkan.
Memori dinamis
Tanpa libc, antarmuka dasar C seperti
malloc()
dan
free()
tidak tersedia untuk kami. Dalam WAT yang tidak dioptimalkan, kami melihat bahwa kompiler menggunakan memori jika perlu. Ini berarti bahwa kita tidak bisa hanya menggunakan memori sesuka kita, tanpa risiko merusaknya. Anda perlu memahami bagaimana ini digunakan.
Model memori LLVM
Metode segmentasi memori WebAssembly akan mengejutkan programmer berpengalaman sedikit. Pertama, di WebAssembly, alamat null diizinkan secara teknis, tetapi sering kali masih diperlakukan sebagai kesalahan. Kedua, tumpukan lebih dulu dan tumbuh turun (ke alamat yang lebih rendah), dan tumpukan muncul kemudian dan tumbuh. Alasannya adalah bahwa memori WebAssembly dapat meningkat saat runtime. Ini berarti bahwa tidak ada ujung yang tetap untuk mengakomodasi tumpukan atau tumpukan.
Berikut ini tata letak
wasm-ld
:
Tumpukan tumbuh turun, dan tumpukan tumbuh. Tumpukan dimulai dengan __data_end
, dan heap __heap_base
dengan __heap_base
. Karena tumpukan ditempatkan terlebih dahulu, ia dibatasi oleh ukuran maksimum yang ditetapkan selama kompilasi, mis. __heap_base
dikurangi __data_end
Jika kita kembali dan melihat bagian global di WAT kita, kita menemukan nilai-nilai ini:
__heap_base
diatur ke 66560, dan
__data_end
diatur ke 1024. Ini berarti bahwa tumpukan dapat tumbuh hingga maksimum 64 KiB, yang tidak banyak. Untungnya,
wasm-ld
memungkinkan Anda untuk mengubah nilai ini:
clang \ --target=wasm32 \ -O3 \ -flto \ -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \ -Wl,--lto-O3 \ + -Wl,-z,stack-size=$[8 * 1024 * 1024] \
Majelis alokator
Area tumpukan diketahui dimulai dengan
__heap_base
. Karena fungsi
malloc()
tidak ada, kami tahu bahwa area memori berikutnya dapat digunakan dengan aman. Kita dapat menempatkan data di sana sesuka kita, dan tidak perlu takut akan kerusakan memori, karena tumpukan tumbuh ke arah lain. Namun, tumpukan yang gratis untuk semua orang bisa dengan cepat tersumbat, jadi biasanya diperlukan semacam manajemen memori dinamis. Salah satu opsi adalah mengambil implementasi malloc () lengkap, seperti
implementasi malloc dari Doug Lee , yang digunakan dalam Emscripten. Ada beberapa implementasi kecil dengan berbagai trade-off.
Tapi mengapa tidak menulis
malloc()
Anda sendiri
malloc()
? Kami sangat terhambat sehingga tidak ada bedanya. Salah satu yang paling sederhana adalah pengalokasi benjolan: sangat cepat, sangat kecil dan mudah diimplementasikan. Tetapi ada kekurangannya: Anda tidak bisa membebaskan memori. Meskipun sekilas pengalokasian seperti itu tampaknya sangat tidak berguna, tetapi ketika mengembangkan
Squoosh, saya menemukan preseden di mana itu akan menjadi pilihan yang sangat baik. Konsep pengalokasi benjolan adalah bahwa kami menyimpan alamat awal memori yang tidak digunakan sebagai global. Jika program meminta
n
byte memori, kami memindahkan marker ke
n
dan mengembalikan nilai sebelumnya:
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) {
Variabel global dari WAT sebenarnya didefinisikan oleh
wasm-ld
, sehingga kita dapat mengaksesnya dari kode C sebagai variabel biasa jika kita mendeklarasikannya sebagai
extern
. Jadi,
kami hanya menulis malloc()
kami sendiri malloc()
... dalam lima baris C.Catatan: pengalokasi benjolan kami tidak sepenuhnya kompatibel dengan malloc()
dari C. Misalnya, kami tidak memberikan jaminan penyelarasan. Tapi itu bekerja dengan cukup baik, jadi ...
Penggunaan memori dinamis
Untuk menguji, mari kita membuat fungsi C, yang mengambil larik angka dengan ukuran acak dan menghitung jumlahnya. Tidak terlalu menarik, tetapi ini memaksa kita untuk menggunakan memori dinamis, karena kita tidak tahu ukuran array saat perakitan:
int sum(int a[], int len) { int sum = 0; for(int i = 0; i < len; i++) { sum += a[i]; } return sum; }
Fungsi penjumlahan (), mudah-mudahan, cukup mudah. Pertanyaan yang lebih menarik adalah bagaimana cara mengirimkan array dari JavaScript ke WebAssembly - setelah semua, WebAssembly hanya memahami angka. Gagasan umum adalah menggunakan
malloc()
dari JavaScript untuk mengalokasikan memori, menyalin nilai-nilai di sana dan meneruskan alamat (nomor!)
Di mana array berada:
<!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); const jsArray = [1, 2, 3, 4, 5]; </script>
Setelah memulai, Anda akan melihat jawaban 15 di konsol DevTools, yang sebenarnya merupakan jumlah semua angka dari 1 hingga 5.
Kesimpulan
Jadi, Anda membaca sampai akhir. Selamat! Sekali lagi, jika Anda merasa sedikit kelebihan beban, semuanya beres.
Tidak perlu membaca semua detail. Memahami mereka sepenuhnya opsional untuk pengembang web yang baik dan bahkan tidak diperlukan untuk penggunaan WebAssembly yang luar biasa . Tetapi saya ingin membagikan informasi ini, karena ini memungkinkan Anda untuk benar-benar menghargai semua pekerjaan yang dilakukan proyek seperti
Emscripten untuk Anda. Pada saat yang sama, ini memberikan pemahaman tentang betapa kecilnya modul komputasi WebAssembly yang murni. Modul Wasm untuk menjumlahkan array hanya berukuran 230 byte,
termasuk pengalokasi memori dinamis . Mengkompilasi kode yang sama dengan Emscripten akan menghasilkan 100 byte kode WebAssembly dan 11K kode tautan JavaScript. Kita harus berusaha demi hasil seperti itu, tetapi ada situasi ketika itu sepadan.