快速上手 WebAssembly 应用开发:Emscripten 使用入门
作者 | 赵洋
策划 | 蔡芳芳
在上一篇文章《WebAssembly 如何演进成为“浏览器第二编程语言”?》中, 我们较为详细地讲述了 WebAssembly 的演变历程,通过 WebAssembly 的演变历程,我们可以对 WebAssembly 的三个优点(二进制格式、Low-Level 的编译目标、接近 Native 的执行效率)有比较深刻的理解。
在本章中我们将选取 Emscripten 及 C/C++ 语言来简要讲述 WebAssembly 相关工具链的使用,通过较为简单的例子帮助大家更快速地上手 WebAssembly 相关的应用开发。请放心,在本章中我们将避免复杂难懂的 C/C++ 语言技巧,力求相关示例简单、直接、易懂。如果你有 Rust、Golang 等支持 WebAssembly 的相关语言背景,那么可以将本章相关内容作为参考,与对应官方工具链结合学习。
关于 Emscripten
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 等其他系统,请参考官方文档中的相关章节进行操作。
- 安装
进入你自己的安装目录,执行如下命令获取到 Emscripten SDK Manager(emsdk):
> git clone https://github.com/emscripten-core/emsdk.git
- 下载
进入 emsdk 目录,并执行如下的命令进行安装操作:
> cd emsdk
> git pull
> ./emsdk install latest
需要注意的是,install 命令可以安装特定版本的 Emscripten 开发包及其依赖的所有自己工具,例如:
> ./emsdk install 1.38.45
- 激活及配置
当安装完成后,我们可以通过如下命令进行 Emscripten 的激活和配置:
> ./emsdk activate latest # or ./emsdk activate 1.38.45
> source ./emsdk_env.sh
现在让我们执行 emcc -v
命令查看相关的信息,若正确输出如下类似信息则说明 Emscripten 安装及配置成功。
emcc -v 的相关信息输出
小试身手
终于进入有趣的部分了,按照惯例,我们先以打印 Hello World!
作为我们学习 WebAssembly 的第一个程序吧!让我们先快速编写一个 C/C++ 的打印 Hello World!
代码,如下所示:
#include <stdio.h>
int main() {
printf("Hello World!n");
return 0;
}
这个程序很简单,使用相关的 GCC 等相关编译器能够很正确得到对应的输出。那么如何产出 WebAssembly 的程序呢?依靠 Emscripten 整个操作也非常简单:
> emcc main.c -o hello.html
执行完毕后你将得到三个文件代码,分别是:
- hello.html
- hello.js:相关的胶水代码,包括加载 WASM 文件并执行调用等相关逻辑
- hello.wasm:编译得到的核心 WebAssembly 执行文件
接着我们在当前目录启动一个静态服务器程序(例如 NPM 中的 static-server),然后访问 hello.html 后我们就能看到 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 解析的程序。
首先我们从 Github 中找到 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 的解析操作,代码如下:
#include <stdio.h>
#include "cJSON/cJSON.h"
int main() {
const char jsonstr[] = "{"data":"Hello World!"}";
cJSON *json = cJSON_Parse(jsonstr);
const cJSON *data = cJSON_GetObjectItem(json, "data");
printf("%sn", 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 的调试
对于开发的 WebAssembly 代码而言,我们对于调试可以使用两种方式,一种方式是通过日志的方式进行输出,另一种方式使用单步调试。使用日志的方式输出调试信息非常容易,Emscripten 能很好的支持 C/C++ 里面的相关 IO 库。而对于单步调试而言,目前最新版本的 Firefox 及 Chrome 浏览器都已经有了一定的支持,例如我们有如下代码:
#include <stdio.h>
int main() {
printf("Hello World!");
return 0;
}
然后我们使用 emcc 进行编译得到相关的文件:
> emcc -g4 main.c -o main.wasm # -g4 可生成对应的 sourcemap 信息
接着打开 Chrome 及其开发者工具,我们就可以看到对应的 main.c 文件并进行单步调试了。
使用 Chrome 进行单步调试
但值得注意的是,目前 emcmake 对于 soucemap 的生成支持并不是很好,并且浏览器的单步调试支持也仅仅支持了代码层面的映射关系,对于比较复杂的应用来说目前的单步调试能力还比较不可用,因此建议开发时还是以日志调试为主要手段。
JavaScript 调用 WASM
对于 WebAssembly 项目而言,我们经常会需要接收外部 JavaScript 传递的相关数据,难免就会涉及到互操作的问题。回到最开始的 JSON 解析例子,我们一般情况而言是需要从外部 JavaScript 中获取到 JSON 字符串,然后在 WebAssembly 代码中进行解析后做对应的业务逻辑处理,并返回对应的结果给外部 JavaScript。接下来,我们会增强 JSON 解析的相关代码,实现如下:
#include <stdio.h>
#include "cJSON/cJSON.h"
int json_parse(const char *jsonstr) {
cJSON *json = cJSON_Parse(jsonstr);
const cJSON *data = cJSON_GetObjectItem(json, "data");
printf("%sn", 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 编译即可得到对应的生成文件。
接着我们访问 sample.html,并在控制台执行如下代码完成 JavaScript 到 WebAssembly 的调用:
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++ 中的字符串是需要