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

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

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

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

على الرغم من أنّ Emscripten كانمبرمجًا برمجيًا C-to-asm.js، لم يتوقّف منذ ذلك الحين على استهداف Wasm، وأصبح بصدد التبديل إلى الواجهة الخلفية الرسمية LLVM، داخليًا. توفر Emscripten أيضًا تنفيذًا متوافقًا مع Wasm لمكتبة C القياسية. استخدام Emscripten فهو ينفِّذ الكثير من الإجراءات المخفية، يحاكي نظام ملفات، ويوفر إدارة الذاكرة، كما يدمج OpenGL مع WebGL — وهي الكثير من الأمور التي لا تحتاج إلى تجربتها في تطويرها بنفسك.

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

يمكنك الحصول على Emscripten باتّباع التعليمات الواردة على الموقع الإلكتروني أو استخدام Homebrew. إذا كنت من عشاق الأوامر الثابتة مثلي ولا تريد تثبيت أشياء على نظامك لمجرد تشغيل 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 من أداة تجميع البيانات إجراء التحسين بشكل كبير. يمكنك اختيار أرقام أقل لتقليل وقت الإصدار، ولكن هذا سيزيد أيضًا من حجم الحزم الناتجة، لأن المحول البرمجي قد لا يزيل التعليمات البرمجية غير المستخدمة.

بعد تنفيذ الأمر، يُفترَض أن يظهر لك ملف JavaScript باسم a.out.js وملف WebAssembly باسم a.out.wasm. يحتوي ملف Wasm (أو "الوحدة") على التعليمة البرمجية C المجمّعة لدينا ويجب أن يكون صغيرًا إلى حد ما. ويتولّى ملف JavaScript تحميل وحدة Wasm الخاصة بنا وإعدادها وتوفير واجهة برمجة تطبيقات أكثر أناقة. وإذا لزم الأمر، سيتولّى أيضًا إعداد الحزمة وكومة الذاكرة المؤقتة وغيرها من الوظائف التي عادةً ما يوفّرها نظام التشغيل عند كتابة الرمز C. وبالتالي، يكون حجم ملف JavaScript أكبر بعض الشيء، ويصل حجمه إلى 19 كيلوبايت (ما يعادل 5 كيلوبايت من gzip's تقريبًا).

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

أسهل طريقة لتحميل الوحدة وتشغيلها هي استخدام ملف 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 القياسية ونظام التشغيل ونظام الملفات وأشياء أخرى. يوفّر 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>

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

لقطة شاشة لوحدة تحكّم أدوات مطوّري البرامج تظهر رقم الإصدار الصحيح

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

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

السؤال الأول الذي ينبغي لنا الإجابة عنه هو: كيف يمكننا الوصول بالصورة إلى أرض Wasm؟ استنادًا إلى واجهة برمجة التطبيقات للترميز في libwebp، من المتوقّع أن تتوفر مجموعة من وحدات البايت بتنسيق RGB أو RGBA أو BGR أو BGRA. لحسن الحظ، تحتوي واجهة برمجة التطبيقات Canvas على 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 land والثاني يحررها مرة أخرى:

    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);

Grand Finale: ترميز الصورة

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

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