نوشتن یک کتابخانه C در Wasm

گاهی اوقات می خواهید از کتابخانه ای استفاده کنید که فقط به صورت کد C یا C++ در دسترس است. به طور سنتی، اینجا جایی است که شما تسلیم می شوید. خوب، دیگر نه، زیرا اکنون Emscripten و WebAssembly (یا Wasm) را داریم!

زنجیره ابزار

هدف من این است که چگونه برخی از کدهای C موجود را در Wasm کامپایل کنم. سر و صدایی در اطراف LLVM 's Wasm وجود دارد، بنابراین من شروع به بررسی آن کردم. در حالی که می توانید برنامه های ساده ای را برای کامپایل به این روش دریافت کنید ، دومی که می خواهید از کتابخانه استاندارد C استفاده کنید یا حتی چندین فایل را کامپایل کنید، احتمالاً با مشکل مواجه خواهید شد. این من را به درسی اصلی که یاد گرفتم هدایت کرد:

در حالی که Emscripten قبلاً یک کامپایلر C-to-asm.js بود، از آن زمان به بلوغ رسید و Wasm را هدف قرار داد و در حال تغییر به باطن رسمی LLVM در داخل است. Emscripten همچنین یک پیاده سازی سازگار با Wasm از کتابخانه استاندارد C را ارائه می دهد. از Emscripten استفاده کنید . کارهای پنهان زیادی را انجام می‌دهد ، یک سیستم فایل را شبیه‌سازی می‌کند، مدیریت حافظه را ارائه می‌دهد، OpenGL را با WebGL می‌پیچد - بسیاری از چیزهایی که واقعاً نیازی به توسعه آنها ندارید.

در حالی که ممکن است به نظر برسد که باید نگران نفخ باشید - من مطمئناً نگران بودم - کامپایلر Emscripten همه چیزهایی را که لازم نیست حذف می کند. در آزمایش‌های من، ماژول‌های Wasm به‌دست‌آمده برای منطقی که دارند اندازه مناسبی دارند و تیم‌های Emscripten و WebAssembly روی کوچک‌تر کردن آنها در آینده کار می‌کنند.

شما می توانید Emscripten را با دنبال کردن دستورالعمل های وب سایت آنها یا استفاده از Homebrew دریافت کنید. اگر مانند من از طرفداران دستورات dockerized هستید و نمی خواهید چیزهایی را روی سیستم خود نصب کنید تا فقط با 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 را نمی‌دانید اما جاوا اسکریپت را می‌دانید، امیدواریم بتوانید بفهمید اینجا چه خبر است.

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 می گوید که به جای فایل asm.js یک فایل Wasm به ما بدهد. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' به کامپایلر می‌گوید که تابع cwrap() را در فایل جاوا اسکریپت در دسترس بگذارد - در ادامه درباره این تابع بیشتر توضیح خواهیم داد. -O3 به کامپایلر می گوید که به صورت تهاجمی بهینه سازی کند. شما می توانید اعداد کمتری را برای کاهش زمان ساخت انتخاب کنید، اما این باعث می شود باندل های به دست آمده بزرگتر شوند زیرا ممکن است کامپایلر کدهای استفاده نشده را حذف نکند.

پس از اجرای دستور، باید یک فایل جاوا اسکریپت به نام a.out.js و یک فایل WebAssembly به نام a.out.wasm داشته باشید. فایل Wasm (یا "ماژول") حاوی کد C کامپایل شده ما است و باید نسبتاً کوچک باشد. فایل جاوا اسکریپت بارگیری و مقداردهی اولیه ماژول Wasm و ارائه یک API زیباتر را انجام می دهد. در صورت نیاز، به تنظیم پشته، پشته و سایر قابلیت‌هایی که معمولاً انتظار می‌رود توسط سیستم عامل هنگام نوشتن کد C ارائه شود، نیز رسیدگی می‌کند. به این ترتیب، فایل جاوا اسکریپت کمی بزرگتر است و 19 کیلوبایت (~5 کیلوبایت gzip'd) وزن دارد.

اجرای یک چیز ساده

ساده ترین راه برای بارگذاری و اجرای ماژول استفاده از فایل جاوا اسکریپت تولید شده است. هنگامی که آن فایل را بارگیری کردید، یک Module global در اختیار خواهید داشت. از cwrap برای ایجاد یک تابع بومی جاوا اسکریپت استفاده کنید که مراقب تبدیل پارامترها به چیزی C-friendly و فراخوانی تابع پیچیده است. 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 و همچنین برخی از اسناد API گسترده موجود است. این یک نقطه شروع بسیار خوب است.

    $ git clone https://github.com/webmproject/libwebp

برای شروع ساده، بیایید سعی کنیم با نوشتن یک فایل C به نام webp.c WebPGetEncoderVersion() از encode.h در جاوا اسکریپت قرار دهیم:

    #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 و جاوا اسکریپت نیاز داریم تا ماژول جدید درخشان خود را بارگیری کنیم:

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

و شماره نسخه اصلاحی را در خروجی خواهیم دید:

تصویری از کنسول DevTools که نسخه صحیح را نشان می دهد شماره

یک تصویر از جاوا اسکریپت به Wasm دریافت کنید

دریافت شماره نسخه رمزگذار بسیار عالی است، اما رمزگذاری یک تصویر واقعی تاثیرگذارتر خواهد بود، درست است؟ پس بیایید این کار را انجام دهیم.

اولین سوالی که باید به آن پاسخ دهیم این است: چگونه تصویر را وارد سرزمین Wasm کنیم؟ با نگاهی به API رمزگذاری 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);
}

اکنون "فقط" موضوع کپی کردن داده ها از سرزمین جاوا اسکریپت در سرزمین 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() آدرس اولین سلول حافظه آن بافر است. هنگامی که اشاره گر به زمین جاوا اسکریپت برگردانده می شود، فقط به عنوان یک عدد در نظر گرفته می شود. پس از قرار دادن تابع در جاوا اسکریپت با استفاده از 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 در دسترس است. وقت آن است که با رمزگذار WebP تماس بگیرید تا کار خود را انجام دهد! با نگاهی به مستندات WebP ، WebPEncodeRGBA مناسب به نظر می رسد. این تابع یک اشاره گر به تصویر ورودی و ابعاد آن و همچنین یک گزینه کیفیت بین 0 تا 100 می گیرد. همچنین یک بافر خروجی را برای ما تخصیص می دهد که پس از اتمام کار با تصویر WebP باید با استفاده از WebPFree() آن را آزاد کنیم. .

نتیجه عملیات رمزگذاری یک بافر خروجی و طول آن است. از آنجا که توابع در 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];
    }

اکنون با وجود همه این موارد، می‌توانیم تابع رمزگذاری را فراخوانی کنیم، نشانگر و اندازه تصویر را بگیریم، آن را در بافر زمین جاوا اسکریپت خودمان قرار دهیم و تمام بافرهای 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 مواجه شوید که در آن Wasm نمی تواند حافظه را به اندازه کافی افزایش دهد تا هم تصویر ورودی و هم خروجی را در خود جای دهد:

تصویری از کنسول DevTools که خطا را نشان می دهد.

خوشبختانه راه حل این مشکل در پیغام خطا است! فقط باید -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 می تواند ابزار فوق العاده مفیدی باشد.

محتوای پاداش: اجرای یک چیز ساده به روش سخت

اگر می خواهید سعی کنید و از فایل جاوا اسکریپت تولید شده اجتناب کنید، ممکن است بتوانید. بیایید به مثال فیبوناچی برگردیم. برای بارگذاری و اجرای آن خودمان، می توانیم موارد زیر را انجام دهیم:

<!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 می تواند به همه چیز در داخل شی import دسترسی داشته باشد، اما به هیچ چیز خارج از آن دسترسی ندارد. طبق قرارداد، ماژول‌های کامپایل‌شده توسط Emscripting انتظار چند چیز را از محیط بارگذاری جاوا اسکریپت دارند:

  • در مرحله اول، env.memory وجود دارد. ماژول Wasm به اصطلاح از دنیای بیرون بی اطلاع است، بنابراین باید مقداری حافظه برای کار با آن در اختیار داشته باشد. WebAssembly.Memory وارد کنید. این یک قطعه (اختیاری قابل رشد) از حافظه خطی را نشان می دهد. پارامترهای اندازه بر حسب "واحد صفحات WebAssembly" هستند، به این معنی که کد بالا 1 صفحه حافظه را اختصاص می دهد که هر صفحه دارای اندازه 64 کیلوبایت است. بدون ارائه maximum گزینه، حافظه از نظر تئوری رشد نامحدودی دارد (کروم در حال حاضر محدودیت سختی 2 گیگابایتی دارد). اکثر ماژول های WebAssembly نیازی به تنظیم حداکثر ندارند.
  • env.STACKTOP مشخص می کند که قرار است پشته از کجا شروع به رشد کند. پشته برای فراخوانی تابع و تخصیص حافظه برای متغیرهای محلی مورد نیاز است. از آنجایی که ما در برنامه کوچک فیبوناچی خود هیچ گونه ابهام مدیریت حافظه پویا انجام نمی دهیم، می توانیم از کل حافظه به عنوان پشته استفاده کنیم، بنابراین STACKTOP = 0 .