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

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

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

لقد حددت نفسي هدفًا للعمل على كيفية تجميع بعض التعليمات البرمجية الموجودة في 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 لحساب رقم 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، بحاجة إلى اللجوء إلى أمر المحول البرمجي في Emscripten emcc:

    $ 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 التي يحتاجها. في النهاية، لقد أعطيتُ كل مفاتيح التي يمكنني العثور عليها والاعتماد عليها على المحول البرمجي لإزالة كل ما كان غير ضروري. يبدو أنه يعمل ببراعة!

    $ 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 وذاكرة تعمل على تحريرها مرة أخرى:

    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-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 لا يمكن زيادة الذاكرة بما يكفي لاستيعاب كل من صورة الإدخال والإخراج:

لقطة شاشة لوحدة تحكّم &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 الرائعة.

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

الخاتمة

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

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