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

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

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

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

على الرغم من استخدام 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 تُحتسب رقم فيبوناتشي:

    #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'd).

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

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

الكأس المقدسة: تجميع مكتبة بلغة 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>

وسنرى رقم إصدار التصحيح في الناتج:

لقطة شاشة لوحدة تحكم &quot;أدوات مطوري البرامج&quot; يظهر فيها رقم الإصدار الصحيح.

الحصول على صورة من 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 وآخر يحرره مرة أخرى:

    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. لإثبات نجاحنا، يمكننا تحويل المخزن المؤقت للنتائج إلى كائن ثنائي كبير (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 الرائعة.

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