«

《WebAssembly标准入门》读书笔记之Emscripten(C/C++)(上)

image.png

—— 工欲善其站,必先懂WebAssembly。

[toc]

《WebAssembly标准入门》读书笔记系列


本文是《WebAssembly标准入门》第六章节主要内容,重点围绕Emscripten中C/C++语言程序和JavaScript交互编程展开,我们着眼于用最短的范例展示使用C/C++语言开发Web应用的必要的技术细节,因此不会详细介绍C/C++语言和JavaScript语言本身的特性。拥有一定的C/++语言和JavaScript语言知识基础对本章的阅读大有帮助。C语言教程我们推荐K&R的《C程序设计语言》,JavaScript语言教程我们推荐Stoyan Stefanov的《JavaScript面向对象编程指南》。当然,读者也可以根据自己的需要自由选择参考资料。

安装环境

Emscripten是一组工具箱,在使用前需要安装好开发环境。对于Emscripten开发人员,可以从源代码编译安装。不过对于普通的Emscripten使用人员,建议通过emsdk命令或docker环境安装。

emsdk命令安装

首先在命令行输入emsdk命令检测Emscripten环境是否已经安装。如果没有安装Emscripten环境的话,推荐安装最新的版本。

可从GitHub上下载Emscripten最新的SDK。

在1.35版本之前,Emscripten为Windows平台提供了离线安装工具,从1.36版本开始则必须通过emsdk命令安装了。emsdk命令也是macOS或Linux等系统的标准安装工具。在Windows平台emsdk命令对应emsdk.bat批处理命令。

首先下载emsdk工具压缩包,解压后命令行切换到对应目录,输入以下命令来安装和激活最新版本:

# 获取依赖工具的最新的版本,但是并不安装
# 下载的文件在 zips 目录中
./emsdk update

# 下载并安装最新的SDK
./emsdk install latest

# 激活安装的SDK
./emsdk activate latest

以上的步骤和Windows下Portable版本安装过程类似。以上命令执行过之后就不需要重复执行,除非是SDK需要更新到新的版本。

对于macOS和Linux系统,还需要在激活SDK之后运行以下的脚本:

$ source ./emsdk_env.sh

对于Windows系统,直接在命令后运行emsdk_env.bat批处理程序配置环境。

该命令用于将依赖的工具注册到PATH环境变量中,然后emcc命令就可以使用了。同时,nodejspython等工具将使用的是Emscripten自带的工具。

Docker环境安装

如果读者熟悉Docker工具,那么推荐直接Docker环境安装。Docker环境的Emscripten是完全隔离的,对宿主机环境不会造成任何的影响。Docker仓库的apiaryio/emcc镜像提供了完整的Emscripten打包。

例如,通过本地的emcc编译hello.c文件:

$ emcc hello.c

采用Docker环境后,对应以下的命令:

$ docker run --rm -it -v 'pwd':/src apiaryio/emcc

其中参数--rm表示运行结束后删除容器资源,参数-it表示定向容器的标准输入和输出到命令行环境,参数-v 'pwd':/src表示将当前目录映射到容器的/src目录。之后的apiaryio/emcc为容器对应镜像的名字,里面包含了Emscripten开发环境。最后的emcc参数表示容器中运行的命令,和本地的emcc命令是一致的。

以上命令默认获取的是latest版本,也就是最新的Emscripten版本。对于正式开发环境,我们推荐安装确定版本的Emscripten。容器镜像的全部版本可以从Docker官网查看。如果将apiaryio/emcc替换为apiaryio/emcc:1.38.11,则表示采用的是1.38.11版本的镜像。

对于国内用户,可以采用Docker官方提供的国内仓库镜像加速下载。

验证emcc命令

最重要的emcc命令用于编译C程序,还有一个em++命令以C++语法编译C/C++程序,它的用法和GCC的命令类似。

输入emcc -v可以查看版本信息。例如,在Docker环境中,对于apiaryio/emcc:1.38.11镜像输出的emcc版本信息:

$ docker run --rm -it apiaryio/emcc:1.38.11 emcc -v
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 1.38.11  
clang version 6.0.1 (emscripten 1.38.11 : 1.38.11)  
Target: x86_64-unknown-linux-gnu  
Thread model: posix  
InstalledDir: /clang/bin  
Found candidate GCC installation: /usr/lib/gcc/x86_64-linux-gnu/6  
Found candidate GCC installation: /usr/lib/gcc/x86_64-linux-gnu/6.3.0  
INFO:root:(Emscripten: Running sanity checks)  

Emscripten的1.38版本开始将WebAssembly作为默认输出格式。对于旧版本的用户,建议尽快升级到新版本。

你好,Emscripten!

通过Emscripten工具,我们可以将C/C++源代码编译为WebAssembly模块或JavaScript程序,然后在浏览器或Node.js等环境运行。在以前旧的版本中,Emscripten默认输出asm.js规格的模块。从1.38版本开始,Emscripten默认输出WebAssembly规格的模块。

HelloWorld程序是每一个严肃的C语言程序员必学的第一个程序。本章也遵循同样的传统,然后尝试用Emscripten工具来生成WebAssembly模块,最后将在Node.js和浏览器环境中测试生成的模块。

生成wasm文件

得益于JavaScript平台对Unicode的友好支持,Emscripten工具生成的代码可以直接输出中文信息。新建一个名为hello.cc的C/C++源文件,其中通过printf()函数输出“你好,Emscripten!”字符串,C/C++源文件保存为UTF-8编码格式。

C语言的代码如下:

#include <stdio.h>

int main() {  
  printf("你好,Emscripten!\n");
  return 0;
}

然后通过以下命令将C程序编译为WebAssembly模块:

$ emcc hello.c
$ node node a.out.js
你好,Emscripten!
$

emcc命令会生成一个a.out.wasm文件和一个a.out.js文件,其中a.out.wasm文件是一个WebAssembly格式的模块,而a.out.js文件则是a.out.wasm模块初始化并包装为对JavaScript更友好的模块。

Node.js执行a.out.js文件时,如果a.out.wasm模块中导出了main()函数,则运行main()函数输出字符串。

浏览器环境

Emscripten生成的模块不仅可以在Node环境运行,也可以在浏览器环境运行。创建一个index.html文件,通过script标签引用a.out.js文件:

<!DOCTYPE HTML>

<head>  
<title>Emscripten: 你好, 世界!</title>  
</head>

<body>  
<script src="a.out.js"></script>  
</body>  

然后在当前目录启动一个端口为8080的本地Web服务。用Chrome浏览器打开 http://localhost:8080/index.html 页面,同时打开开发者面板。在开发者面板窗口可以看到输出的内容如图所示。

image.png

在开发者面板中,可以看到输出了“你好,Emscripten!”信息。

自动生成HTML测试文件

Emscripten可以自动生成JavaScript文件和测试用的HTML文件,HTML文件会包含生成的JavaScript文件。

当通过emcc编译代码时,如果指定了一个html格式的输出文件,那么Emscripten将会生成一个测试页面:

$ emcc hello.c -o a.out.html

以上命令除生成a.out.html测试页面之外,还会生成一个a.out.wasm文件和一个a.out.js文件。

同样在本地启动一个端口为8080的本地Web服务。用Chrome浏览器打开 http://localhost:8080/a.out.html 页面,可以看到黑色的区域显示了输出,如图所示。

image.png

对于简单的C/C++程序,这是最便捷的运行方式。

C/C++内联JavaScript代码

作为追求性能的C/C++语言来说,很多编译器支持在语言中直接嵌入汇编语言。对于Emscripten来说,JavaScript语言也类似于一种汇编语言。我们同样可以在C/C++直接嵌入JavaScript代码,这由&lt;emscripten.h&gt;头文件提供的一组宏EM_ASM函数实现。

EM_ASM

使用EM_ASM宏是在C/C++代码中嵌入简单JavaScript代码最简洁的方式。下面的代码使用JavaScript中console.log()函数输出“你好,Emscripten!”:

#include <emscripten.h>

int main() {  
  EM_ASM(console.log("你好,Emscripten!"));
  return 0;
}

console.log()是JavaScript环境用于输出的函数,输出字符串后会自动添加一个换行符。

EM_ASM宏支持嵌入多个JavaScript语句,相邻的语句之间必须用分号分隔:

#include <emscripten.h>

int main() {  
  EM_ASM(console.log("Hello, world!");console.log("Hello, world!"));
  return 0;
}

如果嵌入多个语句的话,每行只写一个语句同时增加必要的缩进是一个很好的编码习惯:

#include <emscripten.h>

int main() {  
  EM_ASM(
    console.log("Hello, world!");
    console.log("Hello, world!");
  );
  return 0;
}

JavaScript语言本身可以省略每行末尾的分号,但是EM_ASM宏不支持这种写法。下面的代码虽然可以正常编译,但是运行时会出现错误:

#include <emscripten.h>

int main() {  
  EM_ASM(
    console.log("Hello, world!") // 省略了末尾的分号
    console.log("Hello, world!")
  );
  return 0;
}

因为EM_ASM宏会将多个语句拼接为类似下面的单行的JavaScript代码(拼接前会删除注释,因此行尾的注释是没有问题的):

function() { console.log("Hello, world!") console.log("Hello, world!") }  

这样在运行第二个console.log语句的时候就会出现错误。安全的做法是在每个JavaScript语句后面添加分号分隔符。

需要注意的是EM_ASM宏只能执行嵌入的JavaScript代码,无法传入参数或获取返回结果。

EM_ASM_

EM_ASM_宏是EM_ASM宏的增强版本,支持输入数值类型的可变参数,同时支持返回整数类型的结果。EM_ASM_宏嵌入的JavaScript代码必须放到“{”和“}”包围的代码块中,同时至少含有一个输入参数,在JavaScript代码中分别通过$0/$1等引用输入的参数。

下面的代码对于输入的两个数值类型的参数进行求和,然后返回结果:

#include <stdio.h>
#include <emscripten.h>

int main() {  
  int a = 1;
  int b = 2;
  int sum = EM_ASM_({return $0+$1}, a, b);
  printf("sum(%d, %d): %d\n", a, b, sum);
  return 0;
}

JavaScript语言不分整数和浮点数,数值统一为number,该类型对应双精度浮点数。因此无论输入的参数是整数还是浮点数,最终都会转换为number,即双精度的浮点数。

同样,通过EM_ASM_宏嵌入的JavaScript函数的返回值也只有一个number类型,但是返回到C/C++语言空间时会被截断为整数类型。因此,EM_ASM_宏是无法返回两个浮点数求和的结果的:

#include <stdio.h>
#include <emscripten.h>

int main() {  
  float sum = EM_ASM_({
    var sum = $0+$1;
    console.log("sum:", sum);
    return sum;
  }, 1.1, 2.2);
  printf("sum(1.0,2.2): %f\n", sum);
  return 0;
}

运行并输出的内容如下:

$ emcc hello.c
node a.out.js  
sum: 3.3000000000000003  
sum(1.0,2.2): 3.000000  

在JavaScript中,输出的sum值为3.3,但是返回到C/C++环境后EM_ASM_的返回值已经被截断为整数3了。

EM_ASM_*宏

前文说过EM_ASM_的返回值是整数类型的。如果需要获取浮点数的返回值,可以使用EM_ASM_DOUBLE宏。EM_ASM_DOUBLE宏除返回值为浮点数之外,其他用法和EM_ASM_宏是类似的。

我们可以用EM_ASM_DOUBLE宏实现浮点数的加法:

#include <stdio.h>
#include <emscripten.h>

int main() {  
  float sum = EM_ASM_DOUBLE({
    var sum = $0+$1;
    console.log("sum:", sum);
    return sum;
  }, 1.1, 2.2);
  printf("sum(1.0,2.2): %f\n", sum);
  return 0;
}

EM_ASM_DOUBLE宏之外,Emscripten还提供了EM_ASM_ARGSEM_ASM_INT宏。这两个宏和EM_ASM_的用法是完全一样的,返回值也是整数类型。采用不同名称的原因是,EM_ASM_ARGS宏更加强调该宏是带输入参数的,EM_ASM_INT宏则更强调返回值是整数类型。

此外,有时候我们在临时嵌入JavaScript代码时并没有输入参数,但是我们希望获取返回的结果。因为EM_ASM宏不支持返回值,所以我们需要通过EM_ASM_INTEM_ASM_DOUBLE等宏来实现:

#include <stdio.h>
#include <emscripten.h>

int main() {  
  int v = EM_ASM_INT({return 42}, 0);
  printf("%d\n", v);
  return 0;
}

其中输入的参数是一个占位符,是为了符合EM_ASM_INTEM_ASM_DOUBLE宏的语法规范,其实在嵌入的JavaScript中并不需要任何参数。

为此,Emscripten提供了不带参数但是可返回值的EM_ASM_INT_VEM_ASM_DOUBLE_V宏:

#include <stdio.h>
#include <emscripten.h>

int main() {  
  int v = EM_ASM_INT_V({return 42});
  printf("%d\n", v);
  return 0;
}

因为没有额外的参数,所以嵌入的JavaScript代码也就不再需要“{”和“}”包围:

#include <stdio.h>
#include <emscripten.h>

int main() {  
  int v = EM_ASM_INT_V(return 42);
  printf("%d\n", v);
  return 0;
}

函数参数

在前面提到的EM_ASM*宏中,它们都是支持可变参数的,我们可以通过$0/$1等方便地引用这些参数。

下面代码是输出第一个和第二个参数:

#include <emscripten.h>

int main() {  
  EM_ASM_({
    console.log("$0:", $0);
    console.log("$1:", $1);
   }, 1, 2);
  return 0;
}

虽然调用EM_ASM_时参数是确定的,但是对于嵌入的JavaScript代码来说参数的个数可能是变化的。通过$0/$1语法糖是无法提前知道参数的个数的。

在JavaScript函数中,我们可以通过arguments对象来获取动态的输入参数。arguments是一个类似只读数值的对象,对应输入的参数列表,arguments.length对应参数的个数:

function f(a, b, c){  
  for(var i = 0; i < arguments.length; i++) {
    console.log("arguments[", i, "]: ", arguments[i]);
  }
}

f(1, 2)  

我们在EM_ASM_宏中依然可以使用arguments对象,arguments[0]对应$0arguments[1]对应$1等等。

下面的代码是通过arguments对象输出动态数量的输入参数:

#include <emscripten.h>

int main() {  
  EM_ASM_({
    for(var i = 0; i < arguments.length; i++) {
      console.log("$", i, ":", arguments[i]);
    }
  }, 1, 2);
  return 0;
}

正如前文提到的,Emscripten会将EM_ASM*宏嵌入的代码展开到一个独立的JavaScript函数中:

function() { ... }  

从这个角度理解,arguments对象就是一个普通的用法了。

注意问题

因为EM_ASM*宏语法的限制,所以直接在C/C++代码中嵌入JavaScript代码时有一些需要注意的地方。

以下嵌入的JavaScript代码存在[$0, $1],会导致编译错误:

#include <emscripten.h>

int main() {  
  EM_ASM_({
    var args = [$0, $1];
    console.log(args);
   }, 1, 2);
  return 0;
}

规避的办法是将[$0, $1]放到圆括号中([$0, $1])

#include <emscripten.h>

int main() {  
  EM_ASM_({
    var args = ([$0, $1]);
    console.log(args);
   }, 1, 2);
  return 0;
}

另外,在旧版本中嵌入JavaScript代码中不能使用双引号的字符串,因此单引号的字符串是推荐的用法。

分享