Đô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++. Thông thường, đây là lúc bạn nên từ bỏ. Giờ đây, bạn không cần phải làm như vậy nữa vì chúng ta có Emscripten và WebAssembly (hay 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ó thành 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ể tải các chương trình đơn giản để biên dịch theo cách này, nhưng nếu bạn muốn sử dụng thư viện chuẩn của C hoặc thậm chí là biên dịch nhiều tệp thì lần thứ hai bạn muốn sử dụng thư viện chuẩn, có thể bạn sẽ gặp sự cố. Điều này đã giúp tôi rút ra bài học chính mà tôi đã học được:
Mặc dù Emscripten được sử dụng làm trình biên dịch C-to-asm.js, nhưng Emscripten đã 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 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, đồng thời các nhóm Emscripten và WebAssembly đang nỗ lực làm cho các mô-đun này trở nên nhỏ hơn 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 ứng dụng này hoặc qua Homebrew. Nếu bạn là người hâm mộ các lệnh được dựng theo loại lệnh như tôi và không muốn cài đặt nội dung trên hệ thống của mình 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 để 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 xem ví dụ chuẩn gần nhất 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, hàm này không nên quá bất ngờ. 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 nó để có quyền truy cập vào macro EMSCRIPTEN_KEEPALIVE
, nhưng 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ẻ như 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 đó – vì không ai sử dụng hàm đó 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. Đế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ạnh mẽ. Bạn có thể chọn các số thấp hơn để giảm thời gian xây 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, bạn sẽ nhận đượ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 và phải khá nhỏ. Tệp JavaScript đảm nhận việc tải và khởi chạy mô-đun Wasm, đồng thời cung cấp một API đẹp hơn. Nếu cần, thao tác này cũng sẽ đảm nhận việc 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 lớn hơn một chút, có trọng lượng là 19KB (~5KB gzip).
Chạy một điều đơ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ó thể tuỳ ý sử dụng Module
chung. 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, kiểu dữ liệ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.
Khuyến mãi: 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 yếu tố 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 thành 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 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 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 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 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 sửa lỗi trong kết quả:
Đưa 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, Canvas API 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. Do đó, chúng ta cần hiển thị 2 hàm bổ sung. 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 đ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);
Phần kết: Mã hoá hình ảnh
Hình ảnh hiện có trong vùng đất Wasm. Đã đến lúc gọi bộ mã hoá WebP để thực hiện công việc! Theo tài liệu về WebP, có vẻ như WebPEncodeRGBA
hoàn toàn phù hợp. Hàm này đưa con trỏ đến hình ảnh đầu vào và các kích thước của hình ảnh đó, cũng như một lựa chọn chất lượng trong khoảng từ 0 đến 100. Nền tảng này cũng phân bổ một vùng đệm đầu ra cho chúng tôi. Chúng ta cần giải phóng bằ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à 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ế, nó dựa vào con trỏ Wasm rộng 32 bit), nhưng để đơn giản, tôi nghĩ đây là một lối tắt công bằng.
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:
May mắn thay, 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!
Kết luận
Bạn không phải đi bộ trong công viên để 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 luồng dữ liệu hoạt động, mọi thứ 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 cho việc xử lý, xử lý 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ừ phi bạn cung cấp bộ nhớ cho những mô-đun đó. Bạn có thể cung cấp bất kỳ thứ gì cho mô-đun Wasm 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 có gì khác bên ngoài đối tượng đó. Theo quy ước, các mô-đun do Emscripting biên dịch sẽ yêu cầu một số điều từ môi trường JavaScript đang tải:
- Đầu tiên là
env.memory
. Nói cách khác, 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
. 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. Tham số kích thước được viết 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. Nếu không cung cấp tuỳ chọnmaximum
, thì về mặt lý thuyết, bộ nhớ sẽ không bị giới hạn trong mức tăng trưởng (Chrome hiện có giới hạn cố định là 2 GB). 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 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ỳ thủ thuật 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
.