לפעמים רוצים להשתמש בספרייה שזמינה רק כקוד C או C++. בדרך כלל, זה המקום שבו מוותרים. עכשיו כבר לא, כי יש לנו Emscripten ו-WebAssembly (או Wasm)!
כלי הפיתוח
הצבתי לעצמי מטרה למצוא דרך לקמפל קוד C קיים ל-Wasm. היו כמה שמועות לגבי הקצה העורפי של Wasm ב-LLVM, אז התחלתי לחקור את הנושא. אמנם אפשר להצליח לקמפל תוכנות פשוטות בדרך הזו, אבל ברגע שתשתמשו בספרייה הרגילה של C או אפילו תצטרכו לקמפל כמה קבצים, סביר להניח שתתקלו בבעיות. כך למדתי את השיעור החשוב ביותר:
בעבר, Emscripten היה מהדר מ-C ל-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 שמחשבת את מספר פיבונאצ'י הn:
#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
מורה למהדר להפעיל אופטימיזציה אגרסיבית. אפשר לבחור מספרים נמוכים יותר כדי לקצר את זמן ה-build, אבל המשמעות היא שהחבילות שייווצרו יהיו גדולות יותר, כי יכול להיות שהמקרמל לא יסיר קוד שלא בשימוש.
אחרי שמריצים את הפקודה, אמורים לקבל קובץ JavaScript בשם a.out.js
וקובץ WebAssembly בשם a.out.wasm
. קובץ ה-Wasm (או ה-'מודול') מכיל את קוד ה-C המהדר שלנו, והוא אמור להיות קטן למדי. קובץ ה-JavaScript מטפל בטעינה ובאיפוס של מודול ה-Wasm, ומספק ממשק API נוח יותר. אם יש צורך, הוא גם יטפל בהגדרת הערימה, המחסנית ופונקציות אחרות שמערכת ההפעלה אמורה לספק בדרך כלל כשכותבים קוד C. לכן, קובץ ה-JavaScript גדול יותר, והוא בגודל 19KB (5KB אחרי דחיסת 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. עם זאת, אחד מתרחישי השימוש המרכזיים של 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, המערכת מצפה למערך של בייטים בפורמט 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. לשם כך, צריך לחשוף שתי פונקציות נוספות. אחת שמקצה זיכרון לתמונה בתוך 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 land. הגיע הזמן להפעיל את המקודד של WebP כדי שהוא יעשה את העבודה שלו. לפי מסמכי התיעוד של WebP, WebPEncodeRGBA
נראה מתאים במיוחד. הפונקציה מקבלת הפניה לתמונה הקלט ולמידות שלה, וגם אפשרות איכות בין 0 ל-100. הוא גם מקצה לנו מאגר פלט, שצריך לפנות באמצעות WebPFree()
אחרי שמשלימים את העבודה עם קובץ ה-WebP.
התוצאה של פעולת הקידוד היא מאגר פלט ואורכו. מכיוון שפונקציות ב-C לא יכולות לכלול מערכי חזרה (אלא אם מקצים זיכרון באופן דינמי), השתמשתי במערך גלובלי סטטי. אני יודע, זה לא C נקי (למעשה, הוא מסתמך על העובדה ש-Wasm pointers הם ברוחב 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];
}
עכשיו, אחרי שכל זה מוכן, אנחנו יכולים להפעיל את פונקציית הקידוד, לאחוז ב-pointer ובגודל התמונה, להעביר אותם למאגר (buffer) משלהם ב-JavaScript ולשחרר את כל המאגרים ב-Wasm שהקצינו בתהליך.
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
לפקודת ה-compilation.
זהו, אתה מוכן! ערכנו קובץ קומפילציה של מקודד 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);
סיכום
לא קל לגרום לספריית 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
– הפרמטר השני של הפונקציה instantiateStreaming
. למודול Wasm יש גישה לכל מה שבתוך אובייקט הייבוא, אבל לא לשום דבר אחר מחוץ אליו. לפי הסכמה, מודולים שמתורגמים על ידי Emscripting מצפים לכמה דברים מסביבת JavaScript של הטעינה:
- ראשית, יש את
env.memory
. מודול Wasm לא מודע לעולם החיצוני, כך שהוא צריך לקבל קצת זיכרון כדי לעבוד. מזינים את הערךWebAssembly.Memory
. הוא מייצג קטע של זיכרון לינארי (שאפשר להגדיל אותו). הפרמטרים של הקצאת הגודל הם ב'יחידות של דפי WebAssembly', כלומר הקוד שלמעלה מקצה דף אחד של זיכרון, וכל דף הוא בגודל 64 KiB. בלי לציין אפשרותmaximum
, תיאורטית אין הגבלה על הגדלת הזיכרון (כרגע ב-Chrome יש מגבלה קבועה של 2GB). ברוב המודולים של WebAssembly אין צורך להגדיר ערך מקסימלי. env.STACKTOP
מגדיר איפה ה-stack אמור להתחיל לגדול. הערימה נדרשת כדי לבצע קריאות לפונקציות ולהקצות זיכרון למשתנים מקומיים. מכיוון שאנחנו לא מבצעים שום מניפולציות דינמיות לניהול זיכרון בתוכנית הקטנה שלנו לחישוב פיבונאצ'י, אנחנו יכולים פשוט להשתמש בכל הזיכרון כמקבץ, ולכןSTACKTOP = 0
.