快速上手 WebAssembly 应用开发:Emscripten 使用入门
在上一篇文章中, 我们较为详细地讲述了 WebAssembly 的演变历程,通过 WebAssembly 的演变历程,我们可以对 WebAssembly 的三个优点(二进制格式、Low-Level 的编译目标、接近 Native 的执行效率)有比较深刻的理解。
在本章中我们将选取 Emscripten 及 C/C++ 语言来简要讲述 WebAssembly 相关工具链的使用,通过较为简单的例子帮助大家更快速地上手 WebAssembly 相关的应用开发。请放心,在本章中我们将避免复杂难懂的 C/C++ 语言技巧,力求相关示例简单、直接、易懂。如果你有 Rust、Golang 等支持 WebAssembly 的相关语言背景,那么可以将本章相关内容作为参考,与对应官方工具链结合学习。
Emscripten 是 WebAssembly 工具链里重要的组成部分。从最为简单的理解来说,Emscripten 能够帮助我们将 C/C++ 代码编译为 ASM.js 以及 WebAssembly 代码,同时帮助我们生成部分所需的 JavaScript 胶水代码。
但实质上 Emscripten 与 LLVM 工具链相当接近,其包含了各种我们开发所需的 C/C++ 头文件、宏参数以及相关命令行工具。通过这些 C/C++ 头文件及宏参数,其可以指示 Emscripten 为源代码提供合适的编译流程并完成数据转换,如下图所示:
Emscripten 编译流程(来自官网)
emcc 是整个工具链的编译器入口,其能够将 C/C++ 代码转换为所需要的 LLVM-IR 代码,Clang/LLVM(Fastcomp)能够将通过 emcc 生成的 LLVM-IR 代码转换为 ASM.js 及 WebAssembly 代码,而 emsdk 及.emscripten 文件主要是用来帮助我们管理工具链内部的不同版本的子集工具及依赖关系以及相关的用户编译设置。
在我们的日常业务开发过程中,实际上并不需要太过关心 Emscripten 内部的实现细节,Emscripten 已经非常成熟且易于使用。但相关读者若想知道 Emscripten 内部的更多细节,可以访问 Emscripten 官网 以及 Github 阅读相关 WIKI 进一步了解。
在进行相关操作之前,请先确保已经安装 git 工具并能够使用基本的 git 命令,接下来我们以 Linux 系统下的操作作为示例演示如何下载、安装及配置 Emscripten。若你的操作系统为 Windows 或是 OSX 等其他系统,请参考官方文档中的相关章节进行操作。
安装
clone https://github.com/emscripten-core/emsdk.git git
下载
cd emsdk
git pull
./emsdk install latest
./emsdk install 1.38.45
激活及配置
# or ./emsdk activate 1.38.45 ./emsdk activate latest
source ./emsdk_env.sh
现在让我们执行 emcc -v
命令查看相关的信息,若正确输出如下类似信息则说明 Emscripten 安装及配置成功。
emcc -v 的相关信息输出
Hello World!
作为我们学习 WebAssembly 的第一个程序吧!让我们先快速编写一个 C/C++ 的打印
Hello World!
代码,如下所示:
int main() {
printf("Hello World!\n");
return 0;
}
> emcc main.c -o hello.html
执行完毕后你将得到三个文件代码,分别是:
hello.html
hello.js:相关的胶水代码,包括加载 WASM 文件并执行调用等相关逻辑
hello.wasm:编译得到的核心 WebAssembly 执行文件
Hello World!
在页面上正确输出了!当然,实际上 hello.html 文件并不是一定需要的,如果我们想要让 NodeJS 使用我们代码,那么直接执行:
emcc main.c
a.out.js
及
a.out.wasm
两个文件,然后我们使用 NodeJS 执行:
> node a.out.js
也能正确的得到对应的输出(你可以自行创建 html 文件并引入 a.out.js
进行浏览器环境的执行 )。
当然,在我们的日常的业务开发中相关程序是不可能如此简单的。除了我们自己的操作逻辑外,我们还会依赖于非常多商用或开源的第三方库及框架。比如在数据通信及交换中我们往往会使用到 JSON 这种轻量的数据格式。在 C/C++ 中有非常多相关的开源库能解决 JSON 解析的问题,例如cJSON
等,那么接下来我们就增加一点点复杂度,结合 cJSON
库编一个简单的 JSON 解析的程序。
cJSON
的主页,然后下载相关的源码放置在我们项目的 vendor 文件夹中。接着我们在当前项目的根目录下创建一个
CMakeList.txt
文件,并填入如下内容:
cmake_minimum_required(VERSION 3.15) # 根据你的需求进行修改
project(sample C)
set(CMAKE_C_STANDARD 11) # 根据你的 C 编译器支持情况进行修改
set(CMAKE_EXECUTABLE_SUFFIX ".html") # 编译生成.html
include_directories(vendor) # 使得我们能引用第三方库的头文件
add_subdirectory(vendor/cJSON)
add_executable(sample main.c)
# 设置 Emscripten 的编译链接参数,我们等等会讲到一些常用参数
set_target_properties(sample PROPERTIES LINK_FLAGS "-s EXIT_RUNTIME=1")
target_link_libraries(sample cjson) # 将第三方库与主程序进行链接
CMakeList.txt
呢?简单来说,
CMakeList.txt
是
CMake
的“配置文件”,
CMake
会根据
CMakeList.txt
的内容帮助我们生成跨平台的编译命令。在我们现在及之后的文章中,不会涉及非常复杂的
CMake
的使用,你完全可以把
CMakeList.txt
里的相关内容当成固定配置提供给多个项目的复用,如若需要更深入的了解
CMake
的使用,可以参考
CMake
的 官网教程及文档。好了,现在让我们在代码中引入
cJSON
然后并使用它进行 JSON 的解析操作,代码如下:
int main() {
const char jsonstr[] = "{\"data\":\"Hello World!\"}";
cJSON *json = cJSON_Parse(jsonstr);
const cJSON *data = cJSON_GetObjectItem(json, "data");
printf("%s\n", cJSON_GetStringValue(data));
cJSON_Delete(json);
return 0;
}
CMake
,因此 Emscripten 的编译命令需要有一点点修改,我们将不使用 emcc 而是使用 emcmake 及 emmake 来创建我们的相关 WebAssembly 代码,命令如下:
mkdir build
cd build
emcmake cmake ..
emmake make
我们创建了一个 build 文件夹用来存放 cmake 相关的生成文件及信息,接着进入 build 文件夹并使用 emcmake 及 emmake 命令生成对应的 WebAssembly 代码 sample.html、sample.js、sample.wasm,最后我们执行访问 sample.html 后可以看到其正确的输出了 JSON 的 data 内容。
如若你从未使用过 CMake,请不要为 CMake 的相关内容因不理解而产生沮丧或者畏难情绪。在我的日常的 WebAssembly 开发中,基本都是沿用一套
CMakeList.txt
并进行增删改,与此同时编译流程基本与上诉内容一致,你完全可以将这些内容复制在你的备忘录里,下次需要用到时直接修改即可。WASM 的调试
int main() {
printf("Hello World!");
return 0;
}
# -g4 可生成对应的 sourcemap 信息 emcc -g4 main.c -o main.wasm
接着打开 Chrome 及其开发者工具,我们就可以看到对应的 main.c 文件并进行单步调试了。
使用 Chrome 进行单步调试
但值得注意的是,目前 emcmake 对于 soucemap 的生成支持并不是很好,并且浏览器的单步调试支持也仅仅支持了代码层面的映射关系,对于比较复杂的应用来说目前的单步调试能力还比较不可用,因此建议开发时还是以日志调试为主要手段。
int json_parse(const char *jsonstr) {
cJSON *json = cJSON_Parse(jsonstr);
const cJSON *data = cJSON_GetObjectItem(json, "data");
printf("%s\n", cJSON_GetStringValue(data));
cJSON_Delete(json);
return 0;
}
json_parse
的函数之中,以便外部 JavaScript 能够顺利的调用得到此方法,接着我们修改一下
CMakeList.txt
的编译链接参数:
#....
set_target_properties(sample PROPERTIES LINK_FLAGS "\
-s EXIT_RUNTIME=1 \
-s EXPORTED_FUNCTIONS=\"['_json_parse']\"
")
EXPORTED_FUNCTIONS 配置用于设置需要暴露的执行函数,其接受一个数组。这里我们需要将 json_parse
进行暴露,因此只需要填写 _json_parse
即可。需要注意的是,这里暴露的函数方法名前面以下划线(_)开头。然后我们执行 emcmake 编译即可得到对应的生成文件。
let jsonstr = JSON.stringify({data:"Hello World!"});
jsonstr = intArrayFromString(jsonstr).concat(0);
const ptr = Module._malloc(jsonstr.length);
Module.HEAPU8.set(jsonstr, ptr);
Module._json_parse(ptr);
在这里,intArrayFromString
、Module._malloc
以及 Module.HEAPU8
等都是 Emscripten 提供给我们的方法。intArrayFromString
会将字符串转化成 UTF8 的字符串数组,由于我们知道 C/C++ 中的字符串是需要 \0
结尾的,因此我们在末尾 concat 了一个 0 作为字符串的结尾符。接着,我们使用 Module._malloc
创建了一块堆内存并使用 Module.HEAPU8.set
方法将字符串数组赋值给这块内存,最后我们调用 _json_parse
函数即可完成 WebAssembly 的调用。
const jsonstr = JSON.stringify({data:"Hello World!"});
const ptr = allocate(intArrayFromString(jsonstr), 'i8', ALLOC_NORMAL);
Module._json_parse(ptr);
CMakeList.txt
将代码编译为 ASM.js:
PROPERTIES LINK_FLAGS " \
-s WASM=0 \
-s TOTAL_MEMORY=16777216 \
-s EXIT_RUNTIME=1 \
-s EXPORTED_FUNCTIONS=\"['_json_parse']\" \
")
-s WASM=0
及
-s TOTAL_MEMORY=16777216
,然后进行相关的编译操作得到
sample.html
及
sample.js
。首先我们来了解一下
-s TOTAL_MEMORY=16777216
的作用,我们搜索
16777216
这个数字时我们可以看到如下的代码:
function updateGlobalBufferAndViews(buf) {
buffer = buf;
Module['HEAP8'] = HEAP8 = new Int8Array(buf);
Module['HEAP16'] = HEAP16 = new Int16Array(buf);
Module['HEAP32'] = HEAP32 = new Int32Array(buf);
Module['HEAPU8'] = HEAPU8 = new Uint8Array(buf);
Module['HEAPU16'] = HEAPU16 = new Uint16Array(buf);
Module['HEAPU32'] = HEAPU32 = new Uint32Array(buf);
Module['HEAPF32'] = HEAPF32 = new Float32Array(buf);
Module['HEAPF64'] = HEAPF64 = new Float64Array(buf);
}
var STATIC_BASE = 8,
STACK_BASE = 2960,
STACKTOP = STACK_BASE,
STACK_MAX = 5245840,
DYNAMIC_BASE = 5245840,
DYNAMICTOP_PTR = 2928;
// ....
var INITIAL_TOTAL_MEMORY = Module['TOTAL_MEMORY'] || 16777216;
// ....
if (Module['buffer']) {
buffer = Module['buffer'];
} else {
buffer = new ArrayBuffer(INITIAL_TOTAL_MEMORY);
}
INITIAL_TOTAL_MEMORY = buffer.byteLength;
updateGlobalBufferAndViews(buffer);
在这段代码中我们可以看到实际上 Emscripten 帮助我们使用 ArrayBuffer
开辟了一块内存,并将这块内存分为了 栈(STACK)
和 堆(DYNAMIC/HEAP)
两个区域,而这里的 TOTAL_MEMORY
实际上是指明了程序运行内存的实际可用大小(这里非常像简化版的进程内存布局)。同时我们可以看到我们在上面提及的 Module.HEAPU8
等实际上只是这块内存上的不同类型的指针类型(或者说不同的 ArrayBuffer
类型)。因此当我们在进行 Module.HEAPU8.set
的相关操作时,其本质上也是在对这块内存进行相关的操作。
_json_parse
关键字,
_json_parse
的编译后代码如下所示:
function _json_parse($jsonstr) {
$jsonstr = $jsonstr|0;
// ...
sp = STACKTOP;
STACKTOP = STACKTOP + 16|0;
// ...
$jsonstr$addr = $jsonstr;
$0 = $jsonstr$addr;
$call = (_cJSON_Parse($0)|0);
// ...
HEAP32[$vararg_buffer>>2] = $call2;
(_printf(1005,$vararg_buffer)|0);
STACKTOP = sp;return 0;
}
WebAssembly 在执行完成之后可能会需要返回部分返回值,针对这个场景其也分为两种情况:
如果返回 int、float、double 等基础类型,那么直接函数声明返回类型后返回即可;
如果需要返回数组、指针等类型,则可以通过
EM_ASM
或是Memory Copy
的方式进行处理;
EM_ASM
方式的代码如下:
int json_parse(const char *jsonstr) {
cJSON *json = cJSON_Parse(jsonstr);
cJSON *data = cJSON_GetObjectItem(json, "data");
cJSON_SetValuestring(data, "Hi!");
const char *result = cJSON_Print(json);
EM_ASM({
if(typeof window.onRspHandler == "function"){
window.onRspHandler(UTF8ToString($0))
}
}, result);
cJSON_Delete(json);
return 0;
}
EM_ASM
调用外部的
window.onRspHandler
回调方法即可完成对应需求。
EM_ASM
大括号内可以书写任意的 JavaScript 代码,并且可以对其进行传参操作。在本例中,我们将 result 传递给
EM_ASM
方法,其
$0
为传参的等价替换,若还有更多参数则可以写为
$1
、
$2
等。接着,我们编译对应代码,然后访问 sample.html,并在控制台执行如下代码完成 JavaScript 到 WebAssembly 的调用:
window.onRspHandler = (result) => {
console.log(result); // output: {"data":"Hi!"}
};
const jsonstr = JSON.stringify({data:"Hello World!"});
const ptr = allocate(intArrayFromString(jsonstr), 'i8', ALLOC_NORMAL);
Module._json_parse(ptr);
可以看到,window.onRspHandler
函数被调用并正确的进行了结果输出。实际上 Emscripten 给我们提供了非常多的 JavaScript 调用函数及宏,包括:
EM_ASM
EM_ASM_INT
emscripten_run_script
emscripten_run_script_int
emscripten_run_script_string
emscripten_async_run_script
.......
EM_ASM_*
的相关宏来进行对应的 JavaScript 调用,其原因在于
EM_ASM_*
的内容在编译中会被抽出内联为对应的 JavaScript 函数,上面的例子在编译之后实际上得到的内容如下所示:
function _json_parse($jsonstr) {
// ...
$call4 = _emscripten_asm_const_ii(0,($4|0))|0;
// ...
}
EM_ASM
的调用其实质是直接调用了
_emscripten_asm_const_ii
,而
_emscripten_asm_const_ii
函数内容如下:
var ASM_CONSTS = [function($0) {
if(typeof window.onRspHandler == "function"){
window.onRspHandler(UTF8ToString($0))
}
}];
function _emscripten_asm_const_ii(code, a0) {
return ASM_CONSTS[code](a0);
}
emscripten_run_script_*
相关函数而言,其实质是调用了
eval
来进行执行。因此两者在频繁调用的场景下会有比较大的性能差距。分析完
EM_ASM
的方式,那如果我们使用
Memory Copy
的话怎么做呢?代码如下:
int json_parse(const char *jsonstr, char *output) {
cJSON *json = cJSON_Parse(jsonstr);
cJSON *data = cJSON_GetObjectItem(json, "data");
cJSON_SetValuestring(data, "Hi!");
const char *string = cJSON_Print(json);
memcpy(output, string, strlen(string));
cJSON_Delete(json);
return 0;
}
const jsonstr = JSON.stringify({data:"Hello World!"});
const ptr = allocate(intArrayFromString(jsonstr), 'i8', ALLOC_NORMAL);
const output = Module._malloc(1024);
Module._json_parse(ptr, output);
console.log(UTF8ToString(output)); // output: {"data":"Hi!"}
如上所示,我们使用 Malloc._malloc
创建了一块堆内存,并传递给 _json_parse
函数,同时使用 UTF8ToString
方法将对应 JSON 字符串结果输出。
void downloadSucceeded(emscripten_fetch_t *fetch) {
printf("%llu %s.\n", fetch->numBytes, fetch->url);
emscripten_fetch_close(fetch);
}
void downloadFailed(emscripten_fetch_t *fetch) {
emscripten_fetch_close(fetch);
}
int main() {
emscripten_fetch_attr_t attr;
emscripten_fetch_attr_init(&attr);
strcpy(attr.requestMethod, "GET");
attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY;
attr.onsuccess = downloadSucceeded;
attr.onerror = downloadFailed;
emscripten_fetch(&attr, "http://myip.ipip.net/");
}
emscripten_fetch
相关函数来进行浏览器宿主环境 fetch 方法的调用。为了启用 Emscripten 中的 Fetch 能力,我们还需要修改编译链接参数,为其增加 -s FETCH=1:
#....
PROPERTIES LINK_FLAGS "\
-s NO_EXIT_RUNTIME=1 \
-s FETCH=1 \
")
想要了解更多的可用 API 及细节,你可以访问 Emscripten 官网阅读 API Reference 相关章节。
https://emscripten.org/docs/api_reference/index.html
在上面实践中我们使用了一些编译连接的参数,包括:
-
-g -
-s EXIT_RUNTIME -
-s EXPORTED_FUNCTIONS -
-s FETCH -
-s NO_EXIT_RUNTIME
实际上,Emscripten 包含了非常丰富的相关设置参数帮助我们在编译和链接时优化我们的代码。其中部分常用的参数包括:
-
-O1、-O2、-O3、-Oz、-Os、-g 等:编译优化,具体可参考 Emscripten 官网相关章节; -
-s ENVIRONMENT:设定编译代码的可执行环境,默认值为"web,work,node"; -
-s SINGLE_FILE:是否将 ASM.js 或 WebAssembly 代码以 Base64 的方式嵌入到 JavaScript 胶水代码中,可取值 0/1; -
-s WASM:是否编译为 WebAssembly 代码,0 编译为 ASM.js,1 编译为 WebAssembly; -
-s FETCH:是否启用 Fetch 模块,可取值 0/1; -
-s DISABLE_EXCEPTION_CATCHING:禁止生成异常捕获代码,可取值 0/1; -
-s ERROR_ON_UNDEFINED_SYMBOLS:编译时出现 Undefined Symbols 后是否退出,可取值 0/1; -
-s EXIT_RUNTIME: 执行完毕 main
函数后是否退出,可取值 0/1; -
-s FILESYSTEM:是否启用 File System 模块,可取值 0/1; -
-s INVOKE_RUN:是否执行 C/C++ 的 main
函数,可取值 0/1; -
-s ASSERTIONS:是否给运行时增加断言,可取值 0/1; -
-s TOTAL_MEMORY:总的可用内存使用数,可取以 16777216 为基数的整数值; -
-s ALLOW_MEMORY_GROWTH:当可用内存不足时,是否自动增长,可取值 0/1; -
-s EXPORTED_FUNCTIONS:暴露的函数列表名称; -
-s LEGACY_VM_SUPPORT:是否增加部分兼容函数以兼容低版本浏览器(iOS9、老版本 Chrome 等),可取值 0/1; -
-s MEM_INIT_METHOD:是否将.mem 文件以 Base64 的方式嵌入到 JavaScript 胶水代码中,可取值 0/1; -
-s ELIMINATE_DUPLICATE_FUNCTIONS:将重复函数进行自动剔除,可取值 0/1; -
--closure: 是否使用 Google Closure 进行最终代码的压缩,可取值 0/1; -
--llvm-lto:是否进行 LLVM 的链接时优化,可取值 0-3; -
--memory-init-file:同 -s MEM_INIT_METHOD; -
......
更多编译链接参数设置可以参考 emsdk/src/settings.js
文件。
在本章中我们较为详细地介绍了 Emscripten 的入门使用,关于 Emscripten 的更多内容(代码性能及体积优化、API 使用等)可以参考 Emscripten 官网 或 Github 的 WIKI。在接下来的文章中,我们会以具体需求实例为入口,帮助大家能够更好地学习 Emscripten 在实际生产中的使用。
点个在看少个 bug 👇