Sao chép thư viện C thành wasm

Đôi khi, bạn muốn sử dụng thư viện chỉ có sẵn dưới dạng mã C hoặc C++. Thông thường, đây là lúc bạn nên từ bỏ. À, không còn nữa, vì bây giờ chúng tôi có EmscriptenWebAssembly (hoặc Wasm)!

Chuỗi công cụ

Tôi đặt mục tiêu cho bản thân là tìm ra cách biên dịch một số mã C hiện có để Wasm. Có một số tiếng ồn xung quanh phần phụ trợ Wasm của LLVM, vì vậy Tôi bắt đầu tìm hiểu sâu về điều đó. Trong khi bạn có thể tải các chương trình đơn giản để biên dịch theo cách này, lần thứ hai bạn muốn sử dụng thư viện chuẩn của C hoặc thậm chí biên dịch nhiều tệp, bạn có thể sẽ gặp sự cố. Điều này đưa tôi đến bài học tôi đã học được:

Mặc dù Emscripten được dùng làm trình biên dịch C-to-asm.js, nhưng kể từ đó Emscripten đã phát triển thành mục tiêu Wasm và là trong quá trình chuyển đổi vào phần phụ trợ LLVM chính thức trong nội bộ. Emscripten cũng cung cấp Cách triển khai tương thích với Wasm của thư viện chuẩn của C. Sử dụng Emscripten. Nó thực hiện rất nhiều công việc ẩn giấu, mô phỏng hệ thống tệp, cung cấp tính năng quản lý bộ nhớ, kết hợp OpenGL với WebGL — một nhiều thứ mà bạn thực sự không cần trải nghiệm khi phát triển cho chính mình.

Mặc dù bạn có vẻ phải lo lắng về việc cồng kềnh nhưng chắc chắn tôi cũng lo lắng — trình biên dịch Emscripten sẽ xoá mọi thứ không cần thiết. Trong các thử nghiệm, các mô-đun Wasm thu được sẽ có kích thước phù hợp cho logic mà chúng chứa, cũng như các nhóm Emscripten và WebAssembly đang nỗ lực tạo ra nhỏ hơn nữa trong tương lai.

Bạn có thể tải Emscripten bằng cách làm theo hướng dẫn trên trang web hoặc thông qua Homebrew. Nếu bạn là người hâm mộ của các lệnh được lưu trữ như tôi và không muốn cài đặt mọi thứ trên hệ thống của bạn chơi với WebAssembly, có một ứng dụng Hình ảnh Docker mà bạn có thể sử dụng thay vào đó:

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

Biên dịch một nội dung đơn giản

Hãy xem ví dụ gần như chuẩn hoá về việc viết một hàm trong C mà tính số fibonacci thứ 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;
    }

Nếu bạn biết C, hàm này không nên quá bất ngờ. Ngay cả khi bạn không biết C nhưng biết JavaScript, hy vọng bạn sẽ có thể hiểu điều gì đang xảy ra ở đây.

emscripten.h là tệp tiêu đề do Emscripten cung cấp. Chúng tôi chỉ cần vậy nên chúng tôi có quyền truy cập vào macro EMSCRIPTEN_KEEPALIVE, nhưng nó cung cấp nhiều chức năng hơn. Macro này yêu cầu trình biên dịch không xoá một hàm ngay cả khi hàm đó xuất hiện không sử dụng. Nếu chúng ta bỏ qua macro đó, trình biên dịch sẽ tối ưu hoá hàm đó — rốt cuộc thì không có ai sử dụng nó.

Hãy lưu tất cả những dữ liệu đó trong một tệp có tên là fib.c. Để chuyển thành tệp .wasm, chúng ta cần chuyển sang lệnh trình biên dịch emcc của Emscripten:

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

Hãy cùng phân tích lệnh này. emcc là trình biên dịch của Emscripten. fib.c là C của chúng ta tệp. Đến giờ thì mọi thứ vẫn ổn! -s WASM=1 yêu cầu Emscripten cung cấp cho chúng ta một tệp Wasm thay vì tệp asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' yêu cầu trình biên dịch để lại Hàm cwrap() có sẵn trong tệp JavaScript — nội dung khác về hàm này sau. -O3 yêu cầu trình biên dịch tối ưu hoá một cách linh hoạt. Bạn có thể chọn thấp hơn các số liệu để giảm thời gian xây dựng, nhưng điều đó cũng sẽ khiến các gói kết quả lớn hơn vì trình biên dịch có thể không xoá mã không dùng đến.

Sau khi chạy lệnh, bạn sẽ kết thúc với một tệp JavaScript có tên a.out.js và một tệp WebAssembly có tên a.out.wasm. Tệp Wasm (hoặc "mô-đun") chứa mã C được biên dịch và có kích thước khá nhỏ. Chiến lược phát hành đĩa đơn Tệp JavaScript đảm nhận việc tải và khởi tạo mô-đun Wasm và cung cấp API đẹp hơn. Nếu cần, hệ thống cũng sẽ đảm nhận việc thiết lập ngăn xếp, bộ nhớ khối xếp và các chức năng khác thường được cung cấp bởi hệ điều hành khi viết mã C. Do đó, tệp JavaScript có lớn hơn, có trọng lượng 19KB (~5KB gzip).

Chạy một điều đơn giản

Cách dễ nhất để tải và chạy mô-đun của bạn là sử dụng JavaScript đã tạo tệp. Sau khi tải tệp đó, bạn sẽ có Module toàn cầu theo ý bạn. Sử dụng cwrap để tạo hàm gốc JavaScript đảm nhận việc chuyển đổi các tham số thành tên nào đó thân thiện với C và gọi hàm được bao bọc. cwrap sẽ đảm nhận tên hàm, kiểu dữ liệu trả về và kiểu đối số dưới dạng đối số, theo thứ tự đó:

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

Nếu bạn chạy mã này, bạn sẽ thấy "144" trong bảng điều khiển, tức là số Fibonacci thứ 12.

Khuyến mãi: Biên dịch thư viện C

Cho đến bây giờ, mã C mà chúng tôi viết luôn chú trọng đến Wasm. Lõi trường hợp sử dụng của WebAssembly là lấy hệ sinh thái C hiện có thư viện và cho phép nhà phát triển sử dụng chúng trên web. Những thư viện này thường dựa vào thư viện chuẩn của C, hệ điều hành, hệ thống tệp và các của bạn. Emscripten cung cấp hầu hết các tính năng này, mặc dù có một số hạn chế.

Hãy quay lại mục tiêu ban đầu của tôi: biên dịch bộ mã hoá cho WebP thành Wasm. Chiến lược phát hành đĩa đơn mã nguồn của bộ mã hoá và giải mã WebP được viết bằng C và có trên GitHub cũng như một số nguồn Tài liệu API. Đó là một xuất phát điểm khá tốt.

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

Để bắt đầu, hãy thử hiển thị WebPGetEncoderVersion() từ encode.h sang JavaScript bằng cách ghi một tệp C có tên webp.c:

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

Đây là một chương trình đơn giản tốt để kiểm tra xem chúng tôi có thể lấy được mã nguồn của libwebp hay không để biên dịch, vì chúng tôi không yêu cầu bất kỳ tham số hoặc cấu trúc dữ liệu phức tạp nào để gọi hàm này.

Để biên dịch chương trình này, chúng ta cần cho trình biên dịch biết nơi có thể tìm thấy các tệp tiêu đề của libwebp bằng cách sử dụng cờ -I và cũng chuyển vào đó tất cả các tệp C của libwebp mà ứng dụng cần. Tôi thành thật: Tôi chỉ trao tất cả chữ C các tệp mà tôi có thể tìm thấy và dựa vào trình biên dịch để loại bỏ mọi tệp không cần thiết. Có vẻ như nó đã hoạt động rất hiệu quả!

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

Giờ đây, chúng ta chỉ cần một số HTML và JavaScript để tải mô-đun mới nổi bật của mình:

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

Và chúng ta sẽ thấy số phiên bản chỉnh sửa trong đầu ra:

Ảnh chụp màn hình bảng điều khiển Công cụ cho nhà phát triển cho thấy đúng phiên bản
số.

Đưa hình ảnh từ JavaScript vào Wasm

Việc lấy số phiên bản của bộ mã hoá rất hữu ích, nhưng việc mã hoá phiên bản hình ảnh sẽ ấn tượng hơn, đúng không? Vậy chúng ta bắt đầu tìm hiểu nhé.

Câu hỏi đầu tiên chúng ta phải trả lời là: Làm cách nào để đưa hình ảnh vào Vùng đất Wasm? Nhìn vào mã hoá API của libwebp, dự kiến một mảng byte theo RGB, RGBA, BGR hoặc BGRA. May mắn thay, Canvas API đã có getImageData()! đem lại cho chúng tôi Uint8ClampedArray chứa dữ liệu hình ảnh theo 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);
}

Giờ đây, giá trị này sẽ "chỉ" vấn đề sao chép dữ liệu từ vùng đất JavaScript vào Wasm đất liền. Do đó, chúng ta cần hiển thị 2 hàm bổ sung. Một chiến dịch phân bổ cho hình ảnh bên trong Wasm land và một bộ nhớ đã giải phóng hình ảnh đó một lần nữa:

    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 phân bổ một vùng đệm cho hình ảnh RGBA — do đó, 4 byte cho mỗi pixel. Con trỏ do malloc() trả về là địa chỉ ô bộ nhớ đầu tiên của vùng đệm đó. Khi con trỏ được trả về vùng JavaScript, nó sẽ được coi là chỉ một con số. Sau khi hiển thị hàm cho JavaScript bằng cwrap, chúng ta có thể sử dụng số đó để tìm điểm bắt đầu của vùng đệm và sao chép dữ liệu hình ảnh.

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

Chung cuộc: Mã hoá hình ảnh

Hình ảnh hiện đã có ở Vùng đất Wasm. Đã đến lúc gọi bộ mã hoá WebP để phát huy tác dụng! Nhìn vào Tài liệu WebP, WebPEncodeRGBA có vẻ hoàn toàn phù hợp. Hàm này đưa con trỏ đến hình ảnh đầu vào và kích thước, cũng như tuỳ chọn chất lượng từ 0 đến 100. Chiến lược này cũng phân bổ vùng đệm đầu ra mà chúng tôi cần giải phóng bằng WebPFree() sau khi thực hiện xong với hình ảnh WebP.

Kết quả của thao tác mã hoá là vùng đệm đầu ra và độ dài của vùng đệm đó. Bởi vì hàm trong C không thể chứa các mảng làm kiểu dữ liệu trả về (trừ phi chúng ta phân bổ bộ nhớ động), tôi đã sử dụng một mảng toàn cục tĩnh. Tôi biết, không phải C sạch (thực tế là nó dựa trên thực tế là con trỏ Wasm có chiều rộng 32 bit), nhưng để giữ mọi thứ đơn giản, tôi nghĩ đây là một lối tắt hợp lý.

    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];
    }

Bây giờ, với tất cả những điều đó, chúng ta có thể gọi hàm mã hoá, lấy giá trị con trỏ và kích thước hình ảnh, đặt nó vào vùng đệm JavaScript của riêng chúng tôi và giải phóng tất cả vùng đệm Wasm-land mà chúng tôi đã phân bổ trong quá trình này.

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

Tuỳ thuộc vào kích thước hình ảnh, bạn có thể gặp lỗi khi Wasm không thể tăng bộ nhớ đủ để chứa cả hình ảnh đầu vào và đầu ra:

Ảnh chụp màn hình bảng điều khiển Công cụ cho nhà phát triển cho thấy lỗi.

May mắn thay, giải pháp cho vấn đề này nằm trong thông báo lỗi! Chúng tôi chỉ cần thêm -s ALLOW_MEMORY_GROWTH=1 vào lệnh biên dịch.

Vậy là xong! Chúng tôi biên dịch một bộ mã hoá WebP và chuyển mã hình ảnh JPEG thành WebP. Để chứng minh rằng cách làm này hoạt động, chúng ta có thể chuyển vùng đệm kết quả thành một blob và sử dụng trên phần tử <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);

Xin giới thiệu vô cùng lợi ích của hình ảnh WebP mới!

Bảng điều khiển mạng của Công cụ cho nhà phát triển và hình ảnh đã tạo.

Kết luận

Để thư viện C hoạt động trong trình duyệt không phải là đi bộ trong công viên, mà một khi hiểu được toàn bộ quy trình và cách luồng dữ liệu hoạt động. dễ dàng hơn và kết quả có thể rất đáng kinh ngạc.

WebAssembly mở ra nhiều khả năng mới trên web cho việc xử lý, số lượng chơi game và kiểm tra kiến thức. Hãy nhớ rằng Wasm không phải là một giải pháp hoàn hảo được áp dụng cho mọi thứ, nhưng khi bạn gặp phải một trong những nút thắt cổ chai đó, Wasm có thể một công cụ cực kỳ hữu ích.

Nội dung bổ trợ: Chạy điều gì đó đơn giản nhưng khó khăn

Nếu muốn thử và tránh tệp JavaScript đã tạo, bạn có thể sang. Hãy quay lại ví dụ về Fibonacci. Để tự tải và chạy chương trình này, chúng tôi có thể hãy làm như sau:

<!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>

Các mô-đun WebAssembly do Emscripten tạo không có bộ nhớ để hoạt động trừ phi bạn cung cấp bộ nhớ cho chúng. Cách cung cấp mô-đun Wasm bất cứ thứ gì đều bằng cách sử dụng đối tượng imports — tham số thứ hai của instantiateStreaming. Mô-đun Wasm có thể truy cập vào mọi nội dung bên trong đối tượng nhập, nhưng không có gì khác bên ngoài đối tượng đó. Theo quy ước, mô-đun được biên dịch bởi Emscripting mong đợi một số điều từ JavaScript đang tải môi trường:

  • Đầu tiên là env.memory. Mô-đun Wasm không nhận biết được bên ngoài thế giới xung quanh nên cần phải có một số bộ nhớ để hoạt động. Vào cảnh WebAssembly.Memory. Lớp này đại diện cho một phần (không bắt buộc) của bộ nhớ tuyến tính. Kích thước các thông số được tính bằng "tính bằng đơn vị của trang WebAssembly", nghĩa là mã ở trên phân bổ 1 trang bộ nhớ, với mỗi trang có kích thước là 64 KiB. Không cung cấp maximum về mặt lý thuyết, bộ nhớ không bị giới hạn trong sự tăng trưởng (Chrome hiện tại giới hạn cố định là 2 GB). Hầu hết các mô-đun WebAssembly không cần phải đặt tối đa.
  • env.STACKTOP xác định vị trí ngăn xếp bắt đầu phát triển. Nhóm ảnh là cần thiết để thực hiện lệnh gọi hàm và phân bổ bộ nhớ cho các biến cục bộ. Vì chúng tôi không thực hiện bất kỳ hành vi quản lý bộ nhớ linh động nào trong Fibonacci, chúng ta có thể chỉ sử dụng toàn bộ bộ nhớ làm ngăn xếp, do đó STACKTOP = 0.