«

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

image.png

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

[toc]

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


上篇中篇,本文是介绍如何在Emscripten(C/C++)中使用WebAssembly的最后一篇。

JavaScript调用C导出函数

在C语言中,我们不仅可以通过EM_ASM宏的方式内联JavaScript代码,而且还可以通过JavaScript来实现C函数。但是Emscripten存在的最大价值是利用C代码资源和性能,也就是在JavaScript中调用C函数实现的功能。我们在最开始的HelloWorld程序中,JavaScript已经通过隐式调用C语言的main()入口函数来实现字符串的输出。本节我们将详细探讨JavaScript调用C语言函数的技术细节。

调用导出函数

在C/C++中,main()函数默认是导出的函数。同时main()函数导出后对应的符号名称为_main,因此我们可以通过Module._main来调用main()函数。但是如果我们重新仿照main()函数克隆一个名为mymain的函数,我们就无法通过Module._mymain来调用了!

首先说明一点,C++为了支持参数类型不同但函数名相同的函数,在编译时会对函数的名字进行修饰,也就是将名字相同但是参数类型不同的函数修饰为不同名字的符号。这也就决定了int mymain()int mymain(int argc, char* argv[])不能映射到同一个名字为_mymain的符号!

但是在编写库的时候,我们可能依然希望不存在参数重载的int mymain()函数对应到_mymain的符号。这时候我们可以通过C++增加的extern "C"语法来修饰函数,要求它采用C语言的名字修饰规则,将mymain()函数对应到_mymain符号。

下面是C++中实现的mymain()函数,对应C语言的修饰规则:

// in C++
extern "C" int mymain() {  
  printf("hello, world\n");
  return 0;
}

其他的C++文件如果需要引用该函数,则需要以下的代码来声明该函数:

// in C++
extern "C" int mymain();  

但是如果是其他的C语言文件要引用,则需要去掉extern "C"部分的语法为:

// in C
int mymain();  

对公用库的作者来说,这种导出函数的声明信息一般是放在头文件之中的。但是同一个头文件,对于C语言和C++语言的用户似乎不能很好地兼容。

我们一般可以通过__cplusplus来区别对待C语言和C++语言的用户:

// mymain.h

#if defined(__cplusplus)
extern "C" {  
#endif

int mymain();

#if defined(__cplusplus)
}
#endif

这样的话,我们就可以通过Module._mymain符号来调用C/C++中的mymain()函数了。

但是风险依然存在。当我们使用-O2来优化生成代码的性能的时候,可能会出现无法找到_mymain符号之类的错误了。这次不是名字修复的错误,而是因为mymain()函数没有被main()函数直接或间接引用而被编译器优化掉了。

我们可以通过<emscripten.h>头文件中提供的EMSCRIPTEN_KEEPALIVE宏来阻止emcc编译器对函数或变量的优化。新的写法如下:

// in C
int EMSCRIPTEN_KEEPALIVE mymain();

// in C++
extern "C" int EMSCRIPTEN_KEEPALIVE mymain();  

我们可以通过__EMSCRIPTEN__宏来识别是否是Emscripten环境。结合__cplusplus我们可以新创建一个CAPI_EXPORT,来用于导出C函数的声明和定义:

#ifndef CAPI_EXPORT
#  if defined(__EMSCRIPTEN__)
#    include <emscripten.h>
#    if defined(__cplusplus)
#      define CAPI_EXPORT(rettype) extern "C" rettype EMSCRIPTEN_KEEPALIVE
#    else
#      define CAPI_EXPORT(rettype) rettype EMSCRIPTEN_KEEPALIVE
#    endif
#  else
#    if defined(__cplusplus)
#      define CAPI_EXPORT(rettype) extern "C" rettype
#    else
#      define CAPI_EXPORT(rettype) rettype
#    endif
#  endif
#endif

通过CAPI_EXPORT宏,我们可以这样定义mymain()函数:

// in C/C++
CAPI_EXPORT(int) mymain() {  
  printf("hello, world\n");
  return 0;
}

头文件中的声明语句可以这样写:

// mymain.h
// for C/C++
CAPI_EXPORT(int) mymain();  

如果只是为了简单测试,我们完全可以在一个C++文件完成。下面是全部代码(因为只考虑Emscripten和C++环境,所以简化了CAPI_EXPORT宏的处理):

// main.cc

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

#ifndef CAPI_EXPORT
#  define CAPI_EXPORT(rettype) extern "C" rettype EMSCRIPTEN_KEEPALIVE
#endif

CAPI_EXPORT(int) mymain() {  
  printf("hello, mymain\n");
  return 0;
}

int preMain = emscripten_run_script_int(R"==(  
  // 禁止main()函数的自动运行
  Module.noInitialRun = true;
  shouldRunNow = false;

  // 调用mymain()函数
  Module._mymain();
)==");

int main() {  
  printf("hello, world\n");
}

至此我们完成了由调用main()函数到调用自定义导出函数的革命——毕竟main()函数最多只有一个,而自定义导出函数则是没有限制的。

辅助函数ccall()cwrap()

当从JavaScript环境调用C语言函数时,函数的参数如果是JavaScript的number类型范围能够表达的类型(对应double类型)会采用传值操作,如果参数大于number类型能够表达的范围则必须通过C语言的栈空间传递,如果是C语言指针对象则采用int类型传递。Emscripten内建的ccall()cwrap()函数对数值和字符串和字节数组等常见类型提供了简单的支持。ccall()用于调用C语言对应函数,cwrap()ccall()的基础之上将C语言函数包装为JavaScript风格的函数。

Emscripten从1.38版本开始,运行时ccall()cwrap()等辅助函数默认没有导出。在编译时需要通过EXTRA_EXPORTED_RUNTIME_METHODS参数明确导出的函数。

例如,下列命令参数表示导出运行时的ccall()cwrap()辅助函数:

$ emcc -s "EXTRA_EXPORTED_RUNTIME_METHODS=['ccall', 'cwrap']" main.c

假设有一个名为c_add的C语言函数的签名如下:

int c_add(int a, int b);  

c_add()函数的输入参数和返回值全都是int类型,int类型对应JavaScript的number类型。在JavaScript中通过ccall我们可以这样调用c_add()函数:

var result = Module.ccall(  
  'c_add', 'number', ['number', 'number'],
  [10, 20]
);

第一个参数'c_add'表示C语言函数的名字(没有下划线“_”前缀)。第二个参数'number'表示函数的返回值类型,对应C语言的intdouble类型。第三个参数是一个数组表示,表示每个函数参数的类型。第四个参数也是一个数组,是用于调用'c_add'函数传入的参数。

需要说明的是,虽然在C语言中'c_add'函数在编译为JavaScript代码之后int类型采用JavaScript的number类型表达,但是它依然不能处理int类型之外的加法,因为在生成的JavaScript代码中会将number类型强制转型为int类型处理。

其实对应简单的int类型,我们完全没有必要通过ccall来调用。我们可以直接通过编译后的_c_add符号来调用对应的C函数:

var result1 = Module._c_add(10, 20);  
var result2 = Module._c_add.apply(null, [10, 20]);  

ccall()和cwrap()的便捷之处是对JavaScript的字符串和字节数组参数提供了简单的支持。

假设有名为sayHello的C函数,输入的参数为字符串:

void sayHello(const char* name) {  
  printf("hi %s!\n", name);
}

通过ccall()函数我们可以直接以JavaScript字符串作为参数调用sayHello()

Module.ccall(  
  'sayHello', 'null', ['string'],
  ["Emscripten"]
);

需要注意的是,ccall()函数在处理JavaScript字符串和数组时,会临时在栈上分配足够的空间,然后将栈上的地址作为C语言字符串指针参数调用sayHello()函数。如果JavaScript字符串比较大,可能会导致C语言函数栈空间的压力。同时array数组类型不支持用于函数返回值,因为JavaScript无法从一个返回的指针中获取构建数组需要的长度信息。但是string类型可以用于函数的返回值,它会将返回的C语言字符串指针转为JavaScript字符串返回。

假设有名为getVersion()的C语言函数,用于返回版本信息的字符串:

const char* getVersion() {  
  return "version-0.0.1";
}

通过ccall()函数我们可以直接调用返回JavaScript字符串:

var version = Module.ccall('getVersion', 'string');  

由于没有输入参数,因此Module.ccall()在调用getVersion()函数时省略了函数参数类型信息和输入的参数数组。

通过Module.ccall()方法调用string类型的返回值,比较适合于返回的C字符串不需要释放的情形。如果返回的字符串是动态分配的需要单独释放,那么就不能使用ccall()函数来调用了,因为它返回JavaScript字符串时已经将C语言字符串指针丢弃了。

如果需要通过free释放返回的C语言字符串,我们需要手工调用:

var p = Module._getVersion();  
var s = Module.Pointer_stringify(p);  
Module._free(p);  

直接调用Module._getVersion()函数时,返回的是C语言字符串指针。其中Pointer_stringify也是运行时函数,需要通过EXTRA_EXPORTED_RUNTIME_METHODS参数导出函数符号。通过Module.Pointer_stringify(p)函数可以将C语言空间的指针转化为JavaScript字符串。最后通过Module._free(p)来释放内存。

cwrap()函数底层采用ccall()函数实现,它可以将C函数包装为一个JavaScript函数,在以后再次使用时就可以不用再设置函数的参数信息。例如,前面的c_add()函数如果需要重复使用的话,用cwrap()包装一个JavaScript版本的cwrap()函数是一个理想的选择:

var js_add = Module.cwrap('c_add', 'number', ['number', 'number']);  
var result1 = js_add(1, 2);  
var result2 = js_add(3, 4);  

在熟悉了ccall()cwrap()的工作原理之后会发现它是一个鸡肋函数,使用起来也不是非常方便,而且存在一定的风险。不方便之处主要体现在,它只能支持numberstringarray几个类型,对于int64或结构体等稍微复杂的参数无法提供支持,同时也无法支持类似printf()等可变参数的函数。而ccall()的风险主要体现在:它强烈依赖栈空间会导致栈溢出的风险增加,在转换任何字符串时都需要分配4倍的栈空间导致内存浪费,而对应字符串类型的返回值会有内存泄漏的风险。

运行时和消息循环

本节简要介绍Emscripten的运行时和消息循环。其中命令行程序一般是指用于简单工作的可以独立运行的处理程序,程序运行时很少涉及交互,运行完成后马上退出,比较适合在Node.js环境使用。GUI程序则有一个可视化的窗口界面,一般用于游戏等需要长时间运行的程序,运行时根据用户的输入进行交互,这类应用比较适合在浏览器环境运行。最后对于一些可以高度复用的代码可以打包为库,便于在其他项目中链接使用。

Emscripten运行时

对于含有main()函数的C/C++代码,在浏览器或者Node.js加载生成的JavaScript代码之后,如果有main()函数的话默认会执行main()函数。为了便于理解,下面是Emscripten生成代码的简化版本:

Module['callMain'] = Module.callMain = function callMain(args) {  
  var argc = ...;
  var argv = ...;

  var ret = Module['_main'](argc, argv, 0);
  exit(ret, /* implicit = */ true);
}

function run(args) {  
  preRun();

  ensureInitRuntime();

  preMain();

  if (Module['_main'] && shouldRunNow) Module['callMain'](args);

  postRun();
}

// shouldRunNow refers to calling main(), not run().
var shouldRunNow = true;  
if (Module['noInitialRun']) {  
  shouldRunNow = false;
}

run();  

在Emscripten环境,C语言程序的整个执行流程如下:

run() => Module.callMain() => main() => exit(0)  

其中Module.callMain是C语言的main()函数的包装版本,主要用于将JavaScript格式的args参数转换为C语言类型的argcargv参数后调用。C语言虚拟环境的初始化工作主要在run()函数中完成。run()函数中,首先运行preRun()钩子函数,再调用ensureInitRuntime初始化C语言运行时环境,然后调用preMain()函数用于初始化一些C/C++语言全局对象,至此main()函数的上下文环境基本就绪。然后,如果C语言有main()函数并且没有shouldRunNow变量为真的话就执行main()函数,shouldRunNow根据Module['noInitialRun']状态进行初始化。Module.callMain在调用main()函数后会调用exit()函数注销整个运行环境。

run()函数中preRun()postRun()是用于main()函数运行前后执行用户注入的钩子函数。在调用emcc命令编译链接时,可以通过--pre-js参数指定在开头注入的js文件,通过--post-js参数可以指定在生成的JavaScript文件末尾注入指定的js文件。这样就可以分别注入preRun()postRun()要执行的钩子函数。同样,我们还可以在--pre-js文件中定制Module的状态参数,通过将Module.noInitialRun设置为true来禁止在加载JavaScript文件时自动运行main()函数。

我们先创建pre.js前置文件,对应--pre-js参数指定的前置钩子文件,其中只是简单地输出日志信息:

console.log("log: pre.js");  

再创建post.js后置文件,对应--post-js参数指定的后置钩子文件,其中也是简单地输出日志信息:

console.log("log: post.js");  

C语言程序是最简单的打印,文件名为hello.cc,内容如下:

int main() {  
  printf("main\n");
}

通过以下命令编译运行:

$ emcc hello.cc --pre-js pre.js --post-js post.js
$ node a.out.js
log: pre.js  
main  
log: post.js  

可以看到pre.jspost.js中的代码分别在main()函数之前和之后被执行了。需要注意的是,JavaScript中代码的先后布局顺序,并不能完全保证pre.jsmain()函数之前输出,也不能保证post.jsmain()函数之后输出。查看生成的js文件,可以发现在文件的开头和末尾可以看到pre.jspost.js的代码被插入了生成的js文件中了。

如果不希望main()函数在载入js文件的时候自动运行,我们可以在pre.js前置文件中设置Module.noInitialRuntruepre.js代码如下:

var Module;

if (typeof Module === 'undefined') Module = {};

Module.noInitialRun = true;  

我们先声明一个Module对象,如果Module对象不存在的话则创建一个新的对象。然后将Module对象的noInitialRun属性设置为true。这样main()函数就不会被自动执行了。

即使main()不被自动执行,main()函数也是有效的。根据C语言的规范,main()函数在编译后会生成一个_main符号。在Emscripten生成代码中,_main对应JavaScript版本的main()函数。

先不考虑main()函数的参数,我们可以通过Module._main()手工调用main()函数,这时没有任何参数。手工调用main()函数的代码可以放到post.js文件中。这样就可以用Node.js运行程序了。

即使C语言的main()函数声明了参数,在JavaScript调用main()函数时也可以忽略参数。如果忽略main()函数的参数,argc会用空值0代替,argv会对应一个空指针。

我们可以用Module.cwrap()函数将C语言接口的main()函数包装为JavaScript版本的main()函数:

main = Module.cwrap('main', 'number', [])  
main()  

Module.cwrap()函数的第一个参数是函数在C语言中的名字(不包含下划线前缀),第二个参数是一个值为“number”的字符串,表示C语言中函数返回值的类型是int或指针,第三个参数是一个空数组表示没有参数。

对于包含命令行参数的main()函数,我们可以用以下的代码包装:

main = Module.cwrap('main', 'number', ['number', 'number'])  

第三个参数说明部分现在是一个数字,表示有两个参数,均为int类型整数或指针。根据C语言中的main()函数定义可以知道,第二个参数对应一个char *argv[]二级指针。

在新的包装函数中,main()函数的第二个参数对应一个二级指针,指针指向的是C语言内存空间的一个字符串数组。要想使用包装后的JavaScript版本main()函数,需要先在C语言内存上构造相应内存结构的字符串数组。

消息循环

Emscripten主要面向希望运行在浏览器中前端程序,或者是将C程序转换为JavaScript供其他部分调用,以及用Node.js运行的小程序。如果是C/C++编写的服务器后台程序,一般没必要转为JavaScript后运行,因为Node.js应该是可以直接调用本地的应用的。其中C/C++编写的小程序最简单,Emscripten会自动运行其中的main()函数。如果是C/C++库,只需要导出必要的接口函数就可以了,如果含有main()函数则需要避免自动运行main()函数(如前文所说,main()函数也可当作普通函数)。相对麻烦的是main()函数需要长时间运行。

在很多窗口程序或游戏程序中,main()函数会有一个消息循环,用于处理各种交互事件和更新窗口。下面是这类程序的简化结构:

int main() {  
  init();
  while(is_game_running()) {
    do_frame();
  }
  return 0;
}

类似init()的函数用于初始化工作,然后在循环中调用do_frame()更新每一帧场景,处理循环,通过is_game_running()类似函数判断程序是否需要退出循环。

这种结构的C/C++程序直接转为JavaScript后,是不适合在浏览器中运行的。因为,main()函数中的循环会阻塞,main()函数后面的代码无法运行,同时浏览器中的各种事件无法被正常处理,从而导致整个程序呈现假死的情形。JavaScript本身是一种自带消息循环的编程语言,改进的思路是将do_frame()函数放到JavaScript消息循环中。Emscripten为此提供了专有的emscripten_set_main_loop()emscripten_set_main_loop_arg()函数,用于向JavaScript消息循环注册处理函数,同时提供了emscripten_cancel_main_loop()用于退出主消息循环。

下面是改进后的程序结构:

#include <emscripten.h>

void do_web_frame() {  
  if(!is_game_running()) {
    emscripten_cancel_main_loop();
    return;
  }
  do_frame();
}

int main() {  
  init();
  emscripten_set_main_loop(do_web_frame, 0, 0);
  return 0;
}

emscripten_set_main_loop()依然有一个消息循环,main()函数依然不会马上退出。但是emscripten_set_main_loop()的消息循环内部会即时处理浏览器正常的消息。

如果要处理键盘等消息,我们可以在进入主消息循环之前注册相关的事件处理函数:

#include <emscripten.h>
#include <emscripten/html5.h>

int key_callback(int eventType, const EmscriptenKeyboardEvent *keyEvent, void *userData) {  
  printf("eventType: %d, %s, %s\n", eventType, keyEvent->key, keyEvent->code);
  return 0;
}

int main() {  
  emscripten_set_canvas_element_size("#canvas", 1024, 768);
  emscripten_set_keydown_callback(0, 0, 1, key_callback);

  emscripten_set_main_loop(do_web_frame, 0, 0);
  return 0;
}

其中emscripten_set_canvas_element_size()用于设置画布的尺寸,emscripten_set_keydown_callback()用于设置键盘消息的处理函数。基于这种结构,我们可以完全在C/C++环境中处理相关的消息循环,如图所示。

image.png

例如,SDL就是一个C语言开发的跨平台多媒体开发库,主要用于开发游戏、模拟器、媒体播放器等多媒体应用领域。下面是针对Emscripten环境改造的消息循环处理流程:

#include <SDL.h>
#include <emscripten.h>

SDL_Surface* screen = NULL;

void do_web_frame() {  
  SDL_Event event;
  while(SDL_PollEvent(&event)) {
    switch(event.type) {
    case SDL_QUIT:
      break;

    case SDL_MOUSEMOTION:
      printf("mouse(x, y): (%d, %d)\n", event.button.x, event.button.y);
      fflush(stdout);
      break;
    }
  }
}

int main() {  
  SDL_Init(SDL_INIT_VIDEO);
  screen = SDL_SetVideoMode(1024, 768, 32, SDL_ANYFORMAT);

  // 在 do_web_frame 中通过 SDL_PollEvent 获取输入消息
  emscripten_set_main_loop(do_web_frame, 0, 0);
  return 0;
}

通过emcc hello-sdl.cc -o index.html命令可针对该程序生成对应的网页文件和JavaScript文件。用浏览器打开index.html页面的话,上面会看到一个画布区域,下面黑色窗口用于显示printf()函数的输出。当鼠标在画布区域移动时,可以在输出窗口看到鼠标的坐标信息,如图所示。 image.png

当然,如果读者对JavaScript熟悉的话,完全可以在JavaScript处理消息循环。然后在消息循环中根据需要调用C/C++语言中的do_web_frame()函数。这种模式完全抛弃了main()入口函数,C/C++程序只是作为一个外部的库存在了,这也是最灵活的一种方式。

补充说明

Emscripten最早基于asm.js实现,为JavaScript社区带来了C/C++开源的庞大的软件资源。JavaScript社区有一句名言:任何可以用JavaScript实现的终将用JavaScript实现。其实这句话有点儿偏颇,因为目前大量的JavaScript代码并不是手写而是用工具生成的,JavaScript只是作为编译目标存在。随着WebAssembly标准的诞生,WebAssembly将会逐渐代替JavaScript的地位成为标准的目标汇编语言。

分享