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

Đôi khi, bạn muốn sử dụng một thư viện chỉ có sẵn dưới dạng mã C hoặc C++. Theo truyền thống, đây là lúc bạn bỏ cuộc. Giờ đây, bạn không cần phải làm như vậy nữa vì chúng ta có EmscriptenWebAssembly (hay Wasm)!

Chuỗi công cụ

Tôi đặt mục tiêu tìm hiểu cách biên dịch một số mã C hiện có sang 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 về điều đó. Mặc dù bạn có thể biên dịch các chương trình đơn giản theo cách này, nhưng ngay khi 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ể gặp sự cố. Điều này đã dẫn đến bài học lớn mà tôi rút ra được:

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

Mặc dù điều đó có vẻ như bạn phải lo lắng về việc tăng kích thước — tôi chắc chắn là lo lắng — như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ủa tôi, các mô-đun Wasm thu được có kích thước phù hợp với logic mà chúng chứa và các nhóm Emscripten và WebAssembly đang nỗ lực để làm cho các mô-đun này 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 của họ hoặc sử dụng Homebrew. Nếu bạn là một người hâm mộ các lệnh được đóng gói trong Docker như tôi và không muốn cài đặt mọi thứ trên hệ thống chỉ để chơi với WebAssembly, thì bạn có thể sử dụng một hình ảnh Docker được duy trì tốt:

    $ 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 về cách viết một hàm trong C để 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, thì bản thân hàm này sẽ không gây ngạc nhiên cho bạn. Ngay cả khi không biết C nhưng biết JavaScript, hy vọng bạn vẫn có thể hiểu được những gì đang diễn ra ở đây.

emscripten.h là tệp tiêu đề do Emscripten cung cấp. Chúng ta chỉ cần lớp này để có quyền truy cập vào macro EMSCRIPTEN_KEEPALIVE, nhưng lớp này 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 đó có vẻ không được sử dụng. Nếu chúng ta bỏ qua macro đó, trình biên dịch sẽ tối ưu hoá hàm này – vì không ai sử dụng hàm này cả.

Hãy lưu tất cả những nội dung đó vào một tệp có tên là fib.c. Để chuyển tệp này 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à tệp C của chúng ta. Đế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() trong tệp JavaScript — chúng ta sẽ tìm hiểu thêm 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 mạnh mẽ. Bạn có thể chọn các số thấp hơn để giảm thời gian tạo bản dựng, nhưng điều đó cũng sẽ làm cho các gói thu được lớn hơn vì trình biên dịch có thể không xoá mã không sử dụng.

Sau khi chạy lệnh, bạn sẽ có 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 đã biên dịch và phải khá nhỏ. Tệp JavaScript sẽ xử lý việc tải và khởi chạy mô-đun Wasm, đồng thời cung cấp một API tốt hơn. Nếu cần, trình biên dịch này cũng sẽ giúp thiết lập ngăn xếp, vùng nhớ khối xếp và các chức năng khác thường do hệ điều hành cung cấp khi viết mã C. Do đó, tệp JavaScript sẽ lớn hơn một chút, có kích thước 19 KB (~5 KB khi nén bằng gzip).

Chạy một ứng dụng đơn giản

Cách dễ nhất để tải và chạy mô-đun là sử dụng tệp JavaScript đã tạo. Sau khi tải tệp đó, bạn sẽ có một Module toàn cục. Sử dụng cwrap để tạo một hàm gốc JavaScript giúp chuyển đổi các tham số thành một hàm thân thiện với C và gọi hàm được gói. cwrap lấy tên hàm, loại dữ liệu trả về và loại đối số làm đố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 chạy mã này, bạn sẽ thấy "144" trong bảng điều khiển, đây là số Fibonacci thứ 12.

Chén thánh: Biên dịch thư viện C

Cho đến nay, mã C mà chúng ta đã viết được viết với Wasm. Tuy nhiên, trường hợp sử dụng cốt lõi của WebAssembly là lấy hệ sinh thái hiện có của các thư viện C và cho phép nhà phát triển sử dụng các thư viện đó trên web. Các 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 thành phần khác. 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 sang Wasm. Mã nguồn cho 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ố tài liệu API mở rộng. Đó là một điểm xuất phát khá tốt.

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

Để bắt đầu đơn giản, hãy thử hiển thị WebPGetEncoderVersion() từ encode.h cho JavaScript bằng cách viết một tệp C có tên là 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 và hiệu quả để kiểm thử xem chúng ta có thể biên dịch mã nguồn của libwebp hay không, vì chúng ta 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, đồng thời truyền tất cả tệp C của libwebp mà trình biên dịch cần. Tôi sẽ thành thật: Tôi chỉ cung cấp tất cả các tệp C 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 thứ không cần thiết. Có vẻ như cách này 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

Bây giờ, chúng ta chỉ cần một số HTML và JavaScript để tải mô-đun mới 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 sửa lỗi trong kết quả:

Ảnh chụp màn hình bảng điều khiển DevTools cho thấy số phiên bản chính xác.

Lấy hình ảnh từ JavaScript vào Wasm

Việc lấy số phiên bản của bộ mã hoá là rất tuyệt, nhưng việc mã hoá một hình ảnh thực tế sẽ ấn tượng hơn, phải không? Vậy thì hãy làm như vậy.

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? Khi xem xét API mã hoá của libwebp, bạn sẽ thấy một mảng byte ở định dạng RGB, RGBA, BGR hoặc BGRA. May mắn thay, API Canvas có getImageData(), cung cấp cho chúng ta một Uint8ClampedArray chứa dữ liệu hình ảnh ở định dạng 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);
}

Bây giờ, bạn "chỉ" cần sao chép dữ liệu từ vùng đất JavaScript vào vùng đất Wasm. Để làm được điều đó, chúng ta cần hiển thị thêm hai hàm. Một hàm phân bổ bộ nhớ cho hình ảnh bên trong vùng đất Wasm và một hàm giải phóng bộ nhớ đó:

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

Kết thúc: Mã hoá hình ảnh

Hình ảnh hiện có trong vùng đất Wasm. Đã đến lúc gọi trình mã hoá WebP để thực hiện công việc! Khi xem xét tài liệu về WebP, WebPEncodeRGBA có vẻ là một lựa chọn phù hợp. Hàm này lấy con trỏ đến hình ảnh đầu vào và kích thước của hình ảnh, cũng như tuỳ chọn chất lượng từ 0 đến 100. Phương thức này cũng phân bổ cho chúng ta một vùng đệm đầu ra mà chúng ta cần giải phóng bằng WebPFree() sau khi hoàn tất 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 đó. Vì các hàm trong C không thể có mảng làm kiểu dữ liệu trả về (trừ phi chúng ta phân bổ bộ nhớ một cách linh động), nên 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ớp này dựa trên thực tế là con trỏ Wasm có chiều rộng 32 bit), nhưng để đơn giản hoá mọi thứ, 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 con trỏ và kích thước hình ảnh, đặt con trỏ đó vào vùng đệm JavaScript của riêng chúng ta và giải phóng tất cả vùng đệm Wasm mà chúng ta đã 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 trong đó 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 một lỗi.

May mắn là giải pháp cho vấn đề này nằm trong thông báo lỗi! Chúng ta 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 sang WebP. Để chứng minh rằng nó 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 vùng đệm đó 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);

Hãy xem hình ảnh WebP mới này trông tuyệt vời như thế nào!

Bảng điều khiển mạng của DevTools và hình ảnh được tạo.

Kết luận

Việc sử dụng thư viện C trong trình duyệt không phải là một việc dễ dàng, nhưng một khi bạn hiểu được toàn bộ quy trình và cách hoạt động của luồng dữ liệu, việc này sẽ trở nên 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 để xử lý, tính toán số liệu và chơi trò chơi. Xin lưu ý rằng Wasm không phải là giải pháp toàn diện nên đượ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ể là một công cụ cực kỳ hữu ích.

Nội dung bổ sung: Chạy một ứng dụng đơn giản theo cách khó

Nếu muốn thử tránh tệp JavaScript được tạo, bạn có thể làm được điều đó. Hãy quay lại ví dụ về Fibonacci. Để tự tải và chạy ứng dụng, chúng ta có thể 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 sẽ không có bộ nhớ để hoạt động trừ khi bạn cung cấp bộ nhớ cho các mô-đun đó. Cách bạn cung cấp mô-đun Wasm bằng bất kỳ nội dung nào là sử dụng đối tượng imports – tham số thứ hai của hàm instantiateStreaming. Mô-đun Wasm có thể truy cập vào mọi thứ 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, các mô-đun được biên dịch bằng Emscripting sẽ yêu cầu một số điều từ môi trường JavaScript đang tải:

  • Trước tiên, có env.memory. Mô-đun Wasm không biết đến thế giới bên ngoài, vì vậy, mô-đun này cần có một số bộ nhớ để hoạt động. Nhập WebAssembly.Memory. Nó đại diện cho một phần bộ nhớ tuyến tính (có thể tăng kích thước nếu muốn). Các tham số định cỡ ở dạng "theo đơn vị trang WebAssembly", nghĩa là mã ở trên phân bổ 1 trang bộ nhớ, mỗi trang có kích thước 64 KiB. Nếu không cung cấp tuỳ chọn maximum, bộ nhớ sẽ tăng không giới hạn về mặt lý thuyết (Chrome hiện có giới hạn cứng là 2GB). Hầu hết các mô-đun WebAssembly không cần thiết lập giới hạn tối đa.
  • env.STACKTOP xác định vị trí ngăn xếp sẽ bắt đầu phát triển. Ngăn xếp 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ì không thực hiện bất kỳ hành vi quản lý bộ nhớ động nào trong chương trình Fibonacci nhỏ, nên chúng ta chỉ có thể sử dụng toàn bộ bộ nhớ làm ngăn xếp, do đó là STACKTOP = 0.