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

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

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

على الرغم من أنّ Emscripten كان في السابق مُجمِّعًا من C إلى asm.js، إلا أنّه تم تطويره منذ ذلك الحين ليعمل على استهداف Wasm وهو في مرحلة التبديل إلى الخلفية الرسمية لـ LLVM داخليًا. يوفّر Emscripten أيضًا تنفيذًا متوافقًا مع Wasm للمكتبة العادية لـ 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 تُحتسب رقم فيبوناتشي:

    #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 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" في وحدة التحكّم، وهو الرقم 12 في تسلسل فيبوناتشي.

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

نتيجة عملية الترميز هي مخزن إخراج وطول هذا المخزن. بما أنّه لا يمكن للدوالّ في 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، و إخلاء جميع ذاكرات التخزين المؤقت في 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 إلى أمر الترجمة.

وإلى هنا، فقد تحققت رغبتك! لقد جمعنا برنامج ترميز 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 الوصول إلى كل شيء داخل كائن imports، ولكن لا يمكنها الوصول إلى أي شيء آخر خارج هذا الكائن. وفقًا للعرف، تتوقع الوحدات التي تم تجميعها بواسطة Emscripting شيئين من بيئة loading JavaScript:

  • أولاً، هناك env.memory. لا تعرف وحدة Wasm عن العالم الخارجي، لذا تحتاج إلى الحصول على بعض الذاكرة للعمل بها. أدخِل WebAssembly.Memory. وهي تمثّل جزءًا من الذاكرة الخطية (قابل للزيادة اختياريًا). يتم تحديد مَعلمات تحديد الحجم "بوحدات صفحات WebAssembly"، ما يعني أنّ الرمز أعلاه يخصّص صفحة ذاكرة واحدة، وحجم كل صفحة هو 64 KiB. في حال عدم توفير خيار maximum ، يمكن نظريًا زيادة حجم الذاكرة بلا حدود (يحدّ Chrome حاليًا بسعة 2 غيغابايت). من المفترض ألا تحتاج معظم وحدات WebAssembly إلى ضبط قيمة قصوى.
  • env.STACKTOP تحدِّد المكان الذي من المفترض أن تبدأ فيه الحزمة بالنمو. ملف الأركان الأساسية يُستخدَم لإجراء مكالمات الدوالّ وتخصيص ذاكرة للمتغيّرات المحلية. بما أنّنا لا نُجري أي عمليات خداع لإدارة الذاكرة الديناميكية في برنامج ملفّنا الصغير Fibonacci، يمكننا استخدام الذاكرة بالكامل كمجموعة، وبالتالي STACKTOP = 0.