كتابة مكتبة C إلى Wasm

في بعض الأحيان، تريد استخدام مكتبة متاحة فقط كرمز C أو C++. عادةً، هذا هو الوقت الذي تستسلم فيه. حسنًا، لم يعُد الأمر كذلك، لأنّه يتوفّر لدينا الآن Emscripten وWebAssembly (أو Wasm)!

سلسلة الأدوات

حدّدتُ لنفسي هدفًا وهو معرفة كيفية تجميع بعض رموز C البرمجية الحالية إلى Wasm. لقد سمعت بعض الأخبار حول LLVM، لذا بدأت البحث في هذا الموضوع. مع أنّ بإمكانك الحصول على برامج بسيطة لتجميعها بهذه الطريقة، إلا أنّك ستواجه على الأرجح مشاكل عند محاولة استخدام مكتبة C العادية أو حتى تجميع ملفات متعددة. وقد أدّى ذلك إلى استخلاص الدرس الأهم:

كانت Emscripten تُستخدم سابقًا كمترجم من C إلى asm.js، ولكنها تطوّرت منذ ذلك الحين لتصبح متوافقة مع Wasm، وهي في طور التبديل إلى الواجهة الخلفية الرسمية لـ LLVM داخليًا. توفر Emscripten أيضًا عملية تنفيذ متوافقة مع WebAssembly لمكتبة C العادية. استخدام 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 وتهيئتها وتوفير واجهة برمجة تطبيقات أفضل. وسيتولّى أيضًا، عند الحاجة، إعداد الحزمة والكومة وغيرها من الوظائف التي يُتوقّع عادةً أن يوفّرها نظام التشغيل عند كتابة رمز C البرمجي. وبالتالي، يكون ملف JavaScript أكبر قليلاً، حيث يبلغ حجمه 19 كيلوبايت (حوالي 5 كيلوبايت بعد ضغطه باستخدام gzip).

تشغيل تطبيق بسيط

أسهل طريقة لتحميل الوحدة وتشغيلها هي استخدام ملف JavaScript الذي تم إنشاؤه. بعد تحميل هذا الملف، سيكون لديك Module عام في متناول يدك. استخدِم 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" في وحدة التحكّم، وهو رقم فيبوناتشي الثاني عشر.

الهدف الأسمى: تجميع مكتبة C

حتى الآن، تمت كتابة رمز C الذي كتبناه مع أخذ Wasm في الاعتبار. أحد أهم حالات استخدام WebAssembly هو الاستفادة من منظومة C المتكاملة الحالية من المكتبات والسماح للمطوّرين باستخدامها على الويب. وتعتمد هذه المكتبات غالبًا على مكتبة C العادية ونظام التشغيل ونظام الملفات وغير ذلك. توفّر Emscripten معظم هذه الميزات، ولكن هناك بعض القيود.

لنرجع إلى هدفي الأصلي: تجميع برنامج ترميز WebP إلى Wasm. تمت كتابة رمز برنامج ترميز WebP بلغة C، وهو متاح على GitHub، بالإضافة إلى بعض مستندات واجهة برمجة التطبيقات الشاملة. هذه نقطة بداية جيدة جدًا.

    $ 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>

وسيظهر رقم الإصدار المصحَّح في الناتج:

لقطة شاشة لوحدة تحكّم DevTools تعرض رقم الإصدار الصحيح

الحصول على صورة من JavaScript إلى Wasm

الحصول على رقم إصدار برنامج الترميز أمر رائع، ولكن ترميز صورة فعلية سيكون أكثر إثارة للإعجاب، أليس كذلك؟ لنبدأ إذًا.

أول سؤال علينا الإجابة عنه هو: كيف يمكننا إدخال الصورة إلى Wasm؟ بالنظر إلى واجهة برمجة التطبيقات الخاصة بالترميز في 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. حان الوقت لاستدعاء برنامج ترميز WebP لتنفيذ مهمته. بعد الاطّلاع على مستندات WebP، تبيّن أنّ WebPEncodeRGBA هي الخيار الأنسب. تأخذ الدالة مؤشرًا إلى الصورة المدخلة وأبعادها، بالإضافة إلى خيار الجودة بين 0 و100. ويخصّص أيضًا مخزنًا مؤقتًا للإخراج، وعلينا تحريره باستخدام WebPFree() بعد الانتهاء من استخدام صورة WebP.

نتيجة عملية الترميز هي مخزن مؤقت للناتج وطوله. بما أنّ الدوال في C لا يمكن أن تحتوي على مصفوفات كأنواع إرجاع (إلا إذا خصّصنا الذاكرة بشكل ديناميكي)، فقد لجأت إلى مصفوفة عمومية ثابتة. أعلم أنّ هذا ليس رمز C نظيفًا (في الواقع، يعتمد على أنّ مؤشرات WebAssembly تبلغ 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، ثم تحرير جميع المخازن المؤقتة التي خصّصناها في 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 زيادة حجم الذاكرة بما يكفي لاستيعاب كلّ من صورة الإدخال والإخراج:

لقطة شاشة لوحدة تحكّم &quot;أدوات مطوّري البرامج&quot; تعرض خطأً

لحسن الحظ، يتضمّن رسالة الخطأ الحلّ لهذه المشكلة. ما علينا سوى إضافة -s ALLOW_MEMORY_GROWTH=1 إلى أمر التجميع.

وإلى هنا، فقد تحققت رغبتك! لقد جمعنا برنامج ترميز WebP وحوّلنا صورة JPEG إلى WebP. لإثبات نجاح العملية، يمكننا تحويل مخزن النتائج المؤقت إلى كائن ثنائي كبير واستخدامه في عنصر <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 الجديدة!

لوحة الشبكة في DevTools والصورة التي تم إنشاؤها

الخاتمة

قد يكون من الصعب تشغيل مكتبة 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 حاليًا حدًا أقصى يبلغ 2 غيغابايت). لا تحتاج معظم وحدات WebAssembly إلى ضبط حد أقصى.
  • تحدّد env.STACKTOP المكان الذي من المفترض أن يبدأ فيه نمو الحزمة. يجب توفّر حزمة لتنفيذ استدعاءات الدوال وتخصيص الذاكرة للمتغيرات المحلية. بما أنّنا لا نستخدم أي حيل لإدارة الذاكرة الديناميكية في برنامجنا الصغير الخاص بسلسلة فيبوناتشي، يمكننا استخدام الذاكرة بأكملها كمكدّس، وبالتالي STACKTOP = 0.