Đôi khi, bạn muốn sử dụng một thư viện chỉ có dưới dạng mã C hoặc C++. Theo truyền thống, đây là lúc bạn bỏ cuộc. Không còn nữa, vì giờ đây chúng ta đã có Emscripten và WebAssembly (hoặc Wasm)!
Chuỗi công cụ
Tôi đặt ra mục tiêu là tìm hiểu cách biên dịch một số mã C hiện có sang Wasm. Đã có một số thông tin về phần phụ trợ Wasm của LLVM, vì vậy tôi bắt đầu tìm hiểu về phần phụ trợ này. 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, có thể bạn sẽ gặp phải vấn đề. Điều này dẫn đến bài học lớn mà tôi đã học được:
Mặc dù Emscripten đã từng là trình biên dịch C sang 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 sang phần phụ trợ LLVM chính thức ở bên trong. Emscripten cũng cung cấp một cách triển khai tương thích với Wasm cho thư viện chuẩn của C. Sử dụng Emscripten. Nền tảng 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ớ, bao bọc OpenGL bằng WebGL – rất nhiều việc mà bạn không cần phải tự mình trải nghiệm khi phát triển.
Mặc dù điều đó có vẻ như bạn phải lo lắng về tình trạng phình to – tôi chắc chắn đã lo lắng – nhưng trình biên dịch Emscripten sẽ loại bỏ 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 để giảm kích thước của các mô-đun này 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 là người hâm mộ các lệnh được đóng gói 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 hình ảnh Docker được duy trì tốt thay thế:
$ 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 lấy ví dụ gần như là tiêu chuẩn về việc viết một hàm bằng 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 không có gì quá bất ngờ. Ngay cả khi không biết C nhưng biết JavaScript, bạn vẫn có thể hiểu được những gì đang diễn ra ở đây.
emscripten.h
là một tệp tiêu đề do Emscripten cung cấp. Chúng ta chỉ cần macro này để có quyền truy cập vào macro EMSCRIPTEN_KEEPALIVE
, nhưng macro 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 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 – rốt cuộc thì không ai dùng hàm này cả.
Hãy lưu tất cả nội dung đó vào một tệp có tên là fib.c
. Để chuyển đổi 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 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 tôi. Đế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 giữ lại hàm cwrap()
trong tệp JavaScript – sẽ có thêm thông tin 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 triệt để. Bạn có thể chọn số lượng thấp hơn để giảm thời gian dựng, nhưng điều đó cũng sẽ làm cho 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 này, bạn sẽ có một tệp JavaScript có tên là a.out.js
và một tệp WebAssembly có tên là a.out.wasm
. Tệp Wasm (hoặc "mô-đun") chứa mã C đã biên dịch của chúng ta và có kích thước khá nhỏ. Tệp JavaScript sẽ đảm nhiệm việc tải và khởi động mô-đun Wasm của chúng ta, đồng thời cung cấp một API tốt hơn. Nếu cần, nó cũng sẽ đảm nhiệm việc thiết lập ngăn xếp, heap và các chức năng khác thường được hệ điều hành cung cấp khi viết mã C. Do đó, tệp JavaScript này có kích thước lớn hơn một chút, khoảng 19 KB (~5 KB khi được nén bằng gzip).
Chạy một nội dung đơ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ầu theo ý mình. Dùng cwrap
để tạo một hàm gốc JavaScript có nhiệm vụ chuyển đổi các tham số thành một thứ gì đó thân thiện với C và gọi hàm được bao bọc. cwrap
lấy tên hàm, kiểu trả về và kiểu đố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.
Mục tiêu tối thượng: Biên dịch thư viện C
Cho đến nay, mã C mà chúng ta đã viết được viết với mục đích sử dụng Wasm. Tuy nhiên, một trường hợp sử dụng cốt lõi cho 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 chúng 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à những thứ khác. Emscripten cung cấp hầu hết các tính năng này, mặc dù vẫn còn 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 một bộ mã hoá cho WebP sang Wasm. 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 khởi đầu khá tốt.
$ git clone https://github.com/webmproject/libwebp
Để bắt đầu một cách đơn giản, hãy thử hiển thị WebPGetEncoderVersion()
từ encode.h
sang 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à phù hợp để kiểm thử xem chúng ta có thể lấy mã nguồn của libwebp để biên dịch 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
và cũng truyền tất cả cá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
Giờ đây, chúng ta chỉ cần một số HTML và JavaScript để tải mô-đun mới tinh 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:
Lấy hình ảnh từ JavaScript vào Wasm
Lấy số phiên bản của bộ mã hoá là một việc rất hay, nhưng 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 Wasm?
Khi xem xét API mã hoá của libwebp, API này dự kiến sẽ có một mảng byte ở định dạng RGB, RGBA, BGR hoặc BGRA. May mắn thay, Canvas API có getImageData()
, cho phép chúng ta có 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);
}
Giờ đây, việc cần làm "chỉ" là sao chép dữ liệu từ JavaScript vào Wasm. Để làm việc đó, chúng ta cần hiển thị thêm 2 hàm. Một hàm phân bổ bộ nhớ cho hình ảnh bên trong 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ổ một vùng đệm cho hình ảnh RGBA — do đó có 4 byte cho mỗi pixel.
Con trỏ do malloc()
trả về là địa chỉ của ô nhớ đầu tiên trong vùng đệm đó. Khi con trỏ được trả về cho JavaScript, con trỏ này chỉ được coi là một số. Sau khi hiển thị hàm cho JavaScript bằng cwrap
, chúng ta có thể 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 kết: Mã hoá hình ảnh
Giờ đây, hình ảnh này đã có trong Wasm. Đã đến lúc gọi bộ mã hoá WebP để thực hiện công việc của nó! Xem tài liệu WebP, WebPEncodeRGBA
có vẻ là lựa chọn hoàn hảo. Hàm này lấy một con trỏ đến hình ảnh đầu vào và kích thước của hình ảnh đó, cũng như một lựa chọn chất lượng từ 0 đến 100. Thư viện này cũng phân bổ một vùng đệm đầu ra cho chúng ta. Chúng ta cần giải phóng vùng đệm này bằng cách sử dụng WebPFree()
sau khi hoàn tất với hình ảnh WebP.
Kết quả của thao tác mã hoá là một 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 đã dùng đến một mảng tĩnh toàn cục. Tôi biết, không phải C sạch (trên thực tế, nó dựa vào thực tế là con trỏ Wasm có độ 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];
}
Giờ đây, khi đã có tất cả những thứ đó, 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à kích thước hình ảnh vào vùng đệm JavaScript của riêng mình và giải phóng tất cả cá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 phải lỗi khi Wasm không thể tăng đủ bộ nhớ để chứa cả hình ảnh đầu vào và đầu ra:
May mắn thay, giải pháp cho vấn đề này nằm ngay 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ã một hình ảnh JPEG sang WebP. Để chứng minh rằng việc này có hiệu quả, chúng ta có thể chuyển vùng đệm kết quả thành một blob và sử dụng blob đó 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 chiêm ngưỡng vẻ đẹp của một hình ảnh WebP mới!
Kết luận
Không dễ dàng để có được một thư viện C hoạt động trong trình duyệt, nhưng một khi bạn hiểu được quy trình tổng thể và cách hoạt động của luồng dữ liệu, thì việc này sẽ trở nên dễ dàng hơn và kết quả có thể khiến bạn kinh ngạc.
WebAssembly mở ra nhiều khả năng mới trên web để xử lý, tính toán số 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 có thể áp dụng cho mọi thứ, nhưng khi bạn gặp phải một trong những điểm tắc nghẽn đó, Wasm có thể là một công cụ vô cùng hữu ích.
Nội dung phụ: Chạy một thứ đơn giản theo cách khó khăn
Nếu muốn thử và tránh tệp JavaScript được tạo, bạn có thể làm như vậy. Hãy quay lại ví dụ về dãy Fibonacci. Để tự tải và chạy, 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 không có bộ nhớ để hoạt động, trừ phi bạn cung cấp bộ nhớ cho chúng. Cách bạn cung cấp một mô-đun Wasm bằng bất kỳ thứ gì là bằng cách 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 thể truy cập vào bất kỳ thứ gì khác bên ngoài đối tượng đó. Theo quy ước, các mô-đun do Emscripting biên dịch sẽ mong đợi một số điều từ môi trường JavaScript tải:
- Trước tiên, có
env.memory
. Có thể nói, mô-đun Wasm không nhận biết được 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ậpWebAssembly.Memory
. Đây là một phần bộ nhớ tuyến tính (có thể tăng kích thước). Các tham số định cỡ nằm trong "các đơn vị trang WebAssembly", nghĩa là đoạn 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 lựa chọnmaximum
, thì về lý thuyết, bộ nhớ sẽ tăng trưởng không giới hạn (Chrome hiện có giới hạn cứng là 2 GB). Hầu hết các mô-đun WebAssembly không cần phải đặt mức tối đa. env.STACKTOP
xác định vị trí mà ngăn xếp sẽ bắt đầu tăng. Cần có ngăn xếp để thực hiện các lệnh gọi hàm và phân bổ bộ nhớ cho các biến cục bộ. Vì chúng ta không thực hiện bất kỳ trò quản lý bộ nhớ động nào trong chương trình Fibonacci nhỏ của mình, nên chúng ta chỉ có thể sử dụng toàn bộ bộ nhớ làm ngăn xếp, do đóSTACKTOP = 0
.