บางครั้งคุณอาจต้องการใช้ไลบรารีที่พร้อมใช้งานเป็นโค้ด C หรือ C++ เท่านั้น โดยปกติแล้ว คุณจะยอมแพ้ในขั้นตอนนี้ แต่ตอนนี้ไม่เป็นเช่นนั้นแล้ว เพราะเรามี Emscripten และ WebAssembly (หรือ Wasm)
The toolchain
ฉันตั้งเป้าหมายที่จะหาวิธีคอมไพล์โค้ด C ที่มีอยู่เป็น Wasm มีเสียงพูดถึงแบ็กเอนด์ Wasm ของ LLVM อยู่บ้าง ฉันจึงเริ่มเจาะลึกเรื่องนี้ แม้ว่าคุณจะสามารถใช้โปรแกรมง่ายๆ เพื่อคอมไพล์ด้วยวิธีนี้ แต่ทันทีที่คุณต้องการใช้ไลบรารีมาตรฐานของ C หรือแม้แต่คอมไพล์หลายไฟล์ คุณก็อาจพบปัญหา ซึ่งทำให้ฉันได้เรียนรู้ บทเรียนสำคัญที่ว่า
แม้ว่า Emscripten เคยเป็นคอมไพเลอร์ C-to-asm.js แต่ก็ได้รับการพัฒนาให้กำหนดเป้าหมายเป็น Wasm และอยู่ระหว่างการเปลี่ยนไปใช้แบ็กเอนด์ LLVM อย่างเป็นทางการภายใน นอกจากนี้ Emscripten ยังมีการใช้งานไลบรารีมาตรฐานของ C ที่เข้ากันได้กับ Wasm ด้วย ใช้ Emscripten โดยมีงานที่ซ่อนอยู่มากมาย เช่น จำลองระบบไฟล์ จัดการหน่วยความจำ รวม OpenGL เข้ากับ WebGL ซึ่งเป็นสิ่งต่างๆ มากมายที่คุณไม่จำเป็นต้องพัฒนาด้วยตนเอง
แม้ว่านั่นอาจฟังดูเหมือนว่าคุณต้องกังวลเกี่ยวกับความบวม (ซึ่งผมก็กังวล) แต่คอมไพเลอร์ Emscripten จะนำทุกอย่างที่ไม่จำเป็นออก ในการทดสอบของฉัน โมดูล Wasm ที่ได้มีขนาดที่เหมาะสมกับตรรกะที่มีอยู่ และทีม Emscripten และ WebAssembly กำลังพยายามทำให้โมดูลมีขนาดเล็กลงไปอีกในอนาคต
คุณดาวน์โหลด Emscripten ได้โดยทำตามวิธีการในเว็บไซต์หรือใช้ Homebrew หากคุณเป็นแฟนคำสั่งที่ใช้ Docker เหมือนฉันและไม่ต้องการติดตั้งสิ่งต่างๆ ในระบบเพียงเพื่อลองเล่น WebAssembly ก็มีอิมเมจ Docker ที่ได้รับการดูแลอย่างดีซึ่งคุณใช้แทนได้ ดังนี้
$ docker pull trzeci/emscripten
$ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>
กำลังรวบรวมข้อมูลที่เข้าใจง่าย
ลองดูตัวอย่างมาตรฐานของการเขียนฟังก์ชันในภาษา C ที่คำนวณจำนวนฟีโบนัชชีลำดับที่ nth
#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;
}
หากคุณรู้จัก C ฟังก์ชันนี้ก็ไม่น่าจะทำให้คุณแปลกใจมากนัก แม้ว่าคุณจะไม่รู้จัก C แต่รู้จัก JavaScript คุณก็น่าจะเข้าใจสิ่งที่เกิดขึ้นที่นี่ได้
emscripten.h คือไฟล์ส่วนหัวที่ Emscripten มีให้ เราต้องการเพียงเพื่อให้มีสิทธิ์เข้าถึงมาโคร EMSCRIPTEN_KEEPALIVE แต่ยังมีฟังก์ชันการทำงานอื่นๆ อีกมากมาย
มาโครนี้จะบอกคอมไพเลอร์ว่าไม่ต้องนำฟังก์ชันออกแม้ว่าจะดูเหมือนไม่ได้ใช้ก็ตาม หากเราละเว้นมาโครนั้น คอมไพเลอร์จะเพิ่มประสิทธิภาพฟังก์ชันออกไป เนื่องจากไม่มีใครใช้ฟังก์ชันนี้เลย
มาบันทึกข้อมูลทั้งหมดนั้นไว้ในไฟล์ชื่อ fib.c กัน หากต้องการเปลี่ยนเป็นไฟล์ .wasm เราต้องใช้คำสั่งคอมไพเลอร์ emcc ของ Emscripten ดังนี้
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c
มาแยกส่วนคำสั่งนี้กัน emcc คือคอมไพเลอร์ของ Emscripten fib.c คือไฟล์ C ของเรา ทุกอย่างเป็นไปด้วยดี -s WASM=1 จะบอกให้ Emscripten ให้ไฟล์ Wasm แก่เราแทนไฟล์ asm.js
-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' จะบอกให้คอมไพเลอร์ปล่อยฟังก์ชัน cwrap() ไว้ในไฟล์ JavaScript ซึ่งเราจะพูดถึงฟังก์ชันนี้เพิ่มเติมในภายหลัง -O3 บอกให้คอมไพเลอร์เพิ่มประสิทธิภาพอย่างเต็มที่ คุณสามารถเลือกตัวเลขที่ต่ำลงเพื่อลดเวลาในการบิลด์ แต่จะทำให้แพ็กเกจที่ได้มีขนาดใหญ่ขึ้นด้วย เนื่องจากคอมไพเลอร์อาจไม่นำโค้ดที่ไม่ได้ใช้ออก
หลังจากเรียกใช้คำสั่งแล้ว คุณควรได้ไฟล์ JavaScript ชื่อ a.out.js และไฟล์ WebAssembly ชื่อ a.out.wasm ไฟล์ Wasm (หรือ "โมดูล") มีโค้ด C ที่คอมไพล์แล้วและควรมีขนาดค่อนข้างเล็ก ไฟล์ JavaScript จะดูแลการโหลดและเริ่มต้นโมดูล Wasm และจัดเตรียม API ที่ดีขึ้น หากจำเป็น ระบบจะดูแลการตั้งค่าสแต็ก ฮีป และฟังก์ชันอื่นๆ ที่มักคาดว่าจะได้รับจากระบบปฏิบัติการเมื่อเขียนโค้ด C ด้วย ด้วยเหตุนี้ ไฟล์ JavaScript จึงมีขนาดใหญ่กว่าเล็กน้อย โดยมีขนาด 19 KB (ประมาณ 5 KB เมื่อบีบอัดด้วย gzip)
การจัดกิจกรรมง่ายๆ
วิธีที่ง่ายที่สุดในการโหลดและเรียกใช้โมดูลคือการใช้ไฟล์ JavaScript ที่สร้างขึ้น เมื่อโหลดไฟล์ดังกล่าวแล้ว คุณจะมี Module global พร้อมใช้งาน ใช้
cwrap
เพื่อสร้างฟังก์ชันเนทีฟ JavaScript ที่ดูแลการแปลงพารามิเตอร์เป็นสิ่งที่เหมาะกับ C และเรียกใช้ฟังก์ชันที่รวมไว้ cwrap จะใช้ชื่อฟังก์ชัน ประเภทการแสดงผล และประเภทอาร์กิวเมนต์เป็นอาร์กิวเมนต์ตามลำดับต่อไปนี้
<script src="a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
const fib = Module.cwrap('fib', 'number', ['number']);
console.log(fib(12));
};
</script>
หากคุณเรียกใช้โค้ดนี้ คุณควรเห็น "144" ในคอนโซล ซึ่งเป็นตัวเลขฟีโบนักชีลำดับที่ 12
เป้าหมายสูงสุด: การคอมไพล์ไลบรารี C
จนถึงตอนนี้ โค้ด C ที่เราเขียนขึ้นนั้นเขียนขึ้นโดยคำนึงถึง Wasm อย่างไรก็ตาม Use Case หลักของ WebAssembly คือการนำระบบนิเวศที่มีอยู่ของไลบรารี C มาใช้และอนุญาตให้นักพัฒนาซอฟต์แวร์ใช้ไลบรารีเหล่านั้นบนเว็บ ไลบรารีเหล่านี้มักจะอาศัยไลบรารีมาตรฐานของ C, ระบบปฏิบัติการ, ระบบไฟล์ และอื่นๆ Emscripten มีฟีเจอร์เหล่านี้เกือบทั้งหมด แม้ว่าจะมีข้อจำกัดบางอย่างก็ตาม
กลับไปที่เป้าหมายเดิมของฉัน นั่นคือการคอมไพล์โปรแกรมเปลี่ยนไฟล์สำหรับ WebP เป็น Wasm ซอร์สโค้ดสำหรับตัวแปลงรหัส WebP เขียนด้วยภาษา C และพร้อมใช้งานใน GitHub รวมถึงเอกสารประกอบ API ที่ครอบคลุม นั่นเป็นจุดเริ่มต้นที่ดีทีเดียว
$ git clone https://github.com/webmproject/libwebp
เพื่อเริ่มต้นแบบง่ายๆ ลองแสดง WebPGetEncoderVersion() จาก encode.h ไปยัง JavaScript โดยเขียนไฟล์ C ที่ชื่อว่า webp.c
#include "emscripten.h"
#include "src/webp/encode.h"
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetEncoderVersion();
}
นี่เป็นโปรแกรมง่ายๆ ที่ดีในการทดสอบว่าเราสามารถรับซอร์สโค้ดของ libwebp เพื่อคอมไพล์ได้หรือไม่ เนื่องจากเราไม่ต้องการพารามิเตอร์หรือโครงสร้างข้อมูลที่ซับซ้อนเพื่อเรียกใช้ฟังก์ชันนี้
ในการคอมไพล์โปรแกรมนี้ เราต้องบอกคอมไพเลอร์ว่าสามารถค้นหาไฟล์ส่วนหัวของ libwebp ได้ที่ใดโดยใช้แฟล็ก -I และส่งไฟล์ C ทั้งหมดของ libwebp ที่ต้องการด้วย พูดตามตรงคือฉันแค่ให้ไฟล์ C ทั้งหมดที่หาได้และอาศัยคอมไพเลอร์เพื่อตัดทุกอย่างที่ไม่จำเป็นออก ซึ่งดูเหมือนว่าจะได้ผลดีมาก
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-I libwebp \
webp.c \
libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c
ตอนนี้เราต้องการเพียง HTML และ JavaScript เพื่อโหลดโมดูลใหม่ที่ยอดเยี่ยมของเรา
<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = async (_) => {
const api = {
version: Module.cwrap('version', 'number', []),
};
console.log(api.version());
};
</script>
และเราจะเห็นหมายเลขเวอร์ชันที่ถูกต้องในเอาต์พุต

รับรูปภาพจาก JavaScript ไปยัง Wasm
การดูหมายเลขเวอร์ชันของโปรแกรมเปลี่ยนไฟล์เป็นเรื่องที่ดี แต่การเข้ารหัสรูปภาพจริงจะน่าประทับใจกว่าใช่ไหม ถ้าอย่างนั้นก็มาเริ่มกันเลย
คำถามแรกที่เราต้องตอบคือ เราจะนำรูปภาพเข้าสู่ Wasm ได้อย่างไร
เมื่อดูที่ API การเข้ารหัสของ libwebp จะเห็นว่า API นี้ต้องการอาร์เรย์ของไบต์ในรูปแบบ RGB, RGBA, BGR หรือ BGRA โชคดีที่ Canvas API มี getImageData() ซึ่งให้ Uint8ClampedArray ที่มีข้อมูลรูปภาพใน 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);
}
ตอนนี้ก็ "เหลือแค่" การคัดลอกข้อมูลจากฝั่ง JavaScript ไปยังฝั่ง Wasm ซึ่งเราต้องแสดงฟังก์ชันเพิ่มเติมอีก 2 รายการ ฟังก์ชันหนึ่งที่จัดสรรหน่วยความจำสำหรับรูปภาพภายในพื้นที่ Wasm และอีกฟังก์ชันหนึ่งที่เพิ่มพื้นที่ว่างอีกครั้ง
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 จัดสรรบัฟเฟอร์สำหรับรูปภาพ RGBA ซึ่งเท่ากับ 4 ไบต์ต่อพิกเซล
ตัวชี้ที่ malloc() แสดงผลคือที่อยู่ของเซลล์หน่วยความจำแรกของบัฟเฟอร์นั้น เมื่อพอยน์เตอร์ถูกส่งกลับไปยัง JavaScript ระบบจะถือว่าเป็นเพียงตัวเลขเท่านั้น หลังจากแสดงฟังก์ชันต่อ JavaScript โดยใช้ cwrap เราจะใช้ตัวเลขนั้นเพื่อค้นหาจุดเริ่มต้นของบัฟเฟอร์และคัดลอกข้อมูลรูปภาพได้
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);
ตอนจบสุดอลังการ: เข้ารหัสรูปภาพ
ตอนนี้รูปภาพพร้อมใช้งานใน Wasm แล้ว ถึงเวลาเรียกใช้โปรแกรมเปลี่ยนไฟล์ WebP เพื่อทำงานแล้ว เมื่อดูเอกสารประกอบของ WebP WebPEncodeRGBA
ก็ดูเหมือนว่าจะเหมาะมาก ฟังก์ชันนี้จะใช้ตัวชี้ไปยังรูปภาพอินพุตและขนาดของรูปภาพ รวมถึงตัวเลือกคุณภาพระหว่าง 0 ถึง 100 นอกจากนี้ยังจัดสรรบัฟเฟอร์เอาต์พุตให้เรา ซึ่งเราต้องเพิ่มพื้นที่ว่างโดยใช้ WebPFree() เมื่อดำเนินการกับรูปภาพ WebP เสร็จแล้ว
ผลลัพธ์ของการดำเนินการเข้ารหัสคือบัฟเฟอร์เอาต์พุตและความยาวของบัฟเฟอร์ เนื่องจากฟังก์ชันใน C ไม่สามารถมีอาร์เรย์เป็นประเภทการแสดงผลได้ (เว้นแต่เราจะจัดสรรหน่วยความจำแบบไดนามิก) ฉันจึงใช้ Static Global Array ฉันรู้ว่านี่ไม่ใช่ C ที่สะอาด (อันที่จริงแล้ว C อาศัยข้อเท็จจริงที่ว่าตัวชี้ Wasm มีความกว้าง 32 บิต) แต่เพื่อให้สิ่งต่างๆ เรียบง่าย ฉันคิดว่านี่เป็นทางลัดที่สมเหตุสมผล
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];
}
เมื่อทุกอย่างพร้อมแล้ว เราจะเรียกใช้ฟังก์ชันการเข้ารหัส คว้าตัวชี้และขนาดรูปภาพ ใส่ไว้ในบัฟเฟอร์ JavaScript-land ของเราเอง และปล่อยบัฟเฟอร์ Wasm-land ทั้งหมดที่เราจัดสรรไว้ในกระบวนการ
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);
คุณอาจพบข้อผิดพลาดที่ Wasm ไม่สามารถเพิ่มหน่วยความจำให้เพียงพอสำหรับทั้งรูปภาพอินพุตและเอาต์พุต ทั้งนี้ขึ้นอยู่กับขนาดของรูปภาพ

โชคดีที่วิธีแก้ปัญหานี้อยู่ในข้อความแสดงข้อผิดพลาด เราแค่ต้องเพิ่ม -s ALLOW_MEMORY_GROWTH=1 ลงในคำสั่งการคอมไพล์
เพียงเท่านี้ก็เรียบร้อยแล้ว เราได้คอมไพล์โปรแกรมเปลี่ยนไฟล์ WebP และแปลงรูปภาพ JPEG เป็น WebP เพื่อพิสูจน์ว่าวิธีนี้ได้ผล เราสามารถเปลี่ยนบัฟเฟอร์ผลลัพธ์เป็น Blob และใช้กับองค์ประกอบ <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);
ดูความสวยงามของรูปภาพ WebP ใหม่

บทสรุป
การทำให้ไลบรารี C ทำงานในเบราว์เซอร์ไม่ใช่เรื่องง่าย แต่เมื่อคุณเข้าใจกระบวนการโดยรวมและวิธีการทำงานของโฟลว์ข้อมูลแล้ว ทุกอย่างจะง่ายขึ้นและผลลัพธ์ที่ได้อาจน่าทึ่งมาก
WebAssembly เปิดโอกาสใหม่ๆ มากมายบนเว็บสำหรับการประมวลผล การคำนวณตัวเลข และการเล่นเกม โปรดทราบว่า Wasm ไม่ใช่โซลูชันที่สมบูรณ์แบบที่ควรใช้กับทุกอย่าง แต่เมื่อพบปัญหาคอขวด Wasm จะเป็นเครื่องมือที่มีประโยชน์อย่างยิ่ง
เนื้อหาโบนัส: การทำสิ่งง่ายๆ ด้วยวิธีที่ยาก
หากต้องการหลีกเลี่ยงไฟล์ JavaScript ที่สร้างขึ้น คุณอาจทำได้ ลองกลับไปที่ตัวอย่างฟีโบนักชี หากต้องการโหลดและเรียกใช้ด้วยตนเอง ให้ทำดังนี้
<!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>
โมดูล WebAssembly ที่สร้างโดย Emscripten ไม่มีหน่วยความจำให้ทำงานด้วย เว้นแต่คุณจะจัดสรรหน่วยความจำให้ วิธีที่คุณจัดเตรียมโมดูล Wasm ด้วยสิ่งใดก็ตามคือการใช้ออบเจ็กต์ imports ซึ่งเป็นพารามิเตอร์ที่ 2 ของฟังก์ชัน instantiateStreaming โมดูล Wasm สามารถเข้าถึงทุกอย่างภายในออบเจ็กต์การนำเข้า แต่จะเข้าถึงสิ่งอื่นๆ นอกเหนือจากนั้นไม่ได้ ตามแบบแผน โมดูลที่คอมไพล์โดย Emscripting จะคาดหวัง 2 สิ่งจากสภาพแวดล้อม JavaScript ที่โหลด ดังนี้
- อย่างแรกคือ
env.memoryโมดูล Wasm ไม่ทราบถึงโลกภายนอก ดังนั้นจึงต้องมีหน่วยความจำเพื่อทำงาน ป้อนWebAssembly.Memoryซึ่งแสดงถึงหน่วยความจำเชิงเส้น (ที่ขยายได้ (ไม่บังคับ)) พารามิเตอร์การกำหนดขนาดจะอยู่ใน "หน่วยของหน้า WebAssembly" ซึ่งหมายความว่าโค้ดด้านบนจะจัดสรรหน่วยความจำ 1 หน้า โดยแต่ละหน้ามีขนาด 64 KiB หากไม่มีตัวเลือกmaximumหน่วยความจำจะเพิ่มขึ้นอย่างไม่มีที่สิ้นสุดในทางทฤษฎี (ปัจจุบัน Chrome มีขีดจำกัดสูงสุดอยู่ที่ 2 GB) โมดูล WebAssembly ส่วนใหญ่ไม่จำเป็นต้องตั้งค่าสูงสุด env.STACKTOPกำหนดตำแหน่งที่สแต็กควรเริ่มเพิ่มขึ้น สแต็กจำเป็นสำหรับการเรียกใช้ฟังก์ชันและจัดสรรหน่วยความจำสำหรับตัวแปรในเครื่อง เนื่องจากเราไม่ได้ใช้กลเม็ดการจัดการหน่วยความจำแบบไดนามิกในโปรแกรม Fibonacci เล็กๆ ของเรา เราจึงใช้หน่วยความจำทั้งหมดเป็นสแต็กได้ ดังนั้นจึงใช้STACKTOP = 0