«

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

image.png

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

[toc]

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


C/C++调用JavaScript函数

上文我们简单讲述了如何通过EM_ASM宏在C/C++代码中内联JavaScript代码。但EM_ASM相关宏有一定的局限性:EM_ASM宏输入参数和返回值只能支持数值类型。如果C/C++要和JavaScript代码实现完备的数据交换,必须支持字符串类型的参数和返回值。同时,对于一些常用的JavaScript函数,我们希望能以C/C++函数库的方式使用,这样便于大型工程的维护。

C语言版本的eval()函数

JavaScript语言中eval()函数可计算某个字符串,并执行其中的 JavaScript 代码。

我们虽然可以通过前面章节讲述的EM_ASM宏来执行eval()函数,但是EM_ASM宏中内联的必须是明确的JavaScript代码。如果要执行的JavaScript代码是一个动态输入的字符串的话就很难处理了,因为EM_ASM宏无法接受传入C语言的字符串参数。

不过Emscripten也为我们提供了C语言版本的eval()函数emscripten_run_script。它类似一个C语言版本的eval()函数,传入的参数是一个表达JavaScript代码的字符串,返回值也是一个字符串。

我们可以用emscripten_run_script()函数实现一个JavaScript解释器,输入的代码是动态输入的:

#include <emscripten.h>

const char* getJsCode() {  
  return "console.log('eval:abc')";
}

int main() {  
  emscripten_run_script(getJsCode());
  return 0;
}

如果想向要执行的JavaScript代码传入额外的参数的话,可以通过C语言的sprintf()函数将参数添加到JavaScript代码中:

const char* getJsCodeWithArg(int a, int b) {  
  static char jsCode[1024];
  sprintf(jsCode, "console.log('eval:', %d, %d)", a, b);
  return jsCode;
}

int main() {  
  emscripten_run_script(getJsCodeWithArg(1, 2));
  return 0;
}

另外,EM_ASM宏会受到C语言宏语法的限制,无法内联比较复杂的语句。例如,我们无法在EM_ASM宏中用function定义新的函数。但是通过emscripten_run_script()函数可以支持任意的JavaScript代码。

下面的代码定义了一个print()函数,然后用print()函数输出一个字符串:

#include <emscripten.h>

int main() {  
  emscripten_run_script("     \
    function print(s) {     \
      console.log('print:', s); \
    }              \
    print('hello');       \
  ");
  return 0;
}

因为JavaScript语句比较复杂,我们将代码分行书写了。但对于在字符串中嵌入程序代码的场景来说,采用C++11的原始字符串(raw string)写法是更好的选择(在原始字符串中可以自由使用双引号):

#include <emscripten.h>

int main() {  
  emscripten_run_script(R"(
    function print(s) {
      console.log("print:", s);
    }
    print("hello");
  )");
  return 0;
}

因为使用了C++11的原始字符串特性,所以编译时需要添加-std=c++11参数。

打造带参数的eval()函数

emscripten_run_script虽然模拟的JavaScript的eval()函数功能,但是我们无法传入额外的参数。

为了支持输入额外的参数,我们现在尝试用JavaScript重新实现一个my_run_script()函数。my_run_script()函数的功能和emscripten_run_script()函数类似,但是支持输入一个额外的int类型参数。my_run_script()函数的C语言签名如下:

extern "C" void my_run_script(const char *jsCode, int arg);  

然后创建一个package.js文件,里面包含用JavaScript实现的my_run_script()函数:

// package.js

mergeInto(LibraryManager.library, {  
  my_run_script: function(code, $arg) {
    eval(Pointer_stringify(code));
  },
});

my_run_script()函数的第一个参数是一个表示JavaScript代码的C语言字符串,也就是一个指针。要想在JavaScript环境访问C语言字符串的话,需要通过Emscripten提供的Pointer_stringify()函数将指针转为JavaScript字符串;第二个参数是int类型的参数,对应JavaScript中的number类型的参数,因此可以直接使用。

另外,我们需要使用Emscripten提供的mergeInto()辅助函数将sayHello函数注入LibraryManager.library对象中,这样的话就可以被C/C++代码使用了。

在使用my_run_script()函数前需要先包含它的声明。现在我们创建main.cc文件演示my_run_script()的用法。通过额外的参数向my_run_script()函数输入42,在JavaScript脚本中通过$arg来访问额外的参数:

// main.cc
#include <emscripten.h>

extern "C" void my_run_script(const char *s, ...);

int main() {  
  my_run_script("console.log($arg)", 42);
  return 0;
}

参数$arg是在package.js中定义my_run_script()函数时使用的名字。如果my_run_script()函数中名字发生变化的话,console.log($arg)将无法输出正确的结果。最后通过JavaScript自带的eval()函数执行输入的JavaScript代码,而额外的参数$arg作为上下文存在。

更通用的做法是用JavaScript函数的arguments属性来获取函数的参数。arguments[0]对应第一个函数参数也就是JavaScript脚本字符串,arguments[1]对应第二个参数42

#include <emscripten.h>

extern "C" void my_run_script(const char *s, ...);

int main() {  
  my_run_script("console.log(arguments[1])", 42);
  return 0;
}

现在用以下的命令编译和运行代码:

emcc --js-library package.js main.cc  
node a.out.js  
42  

现在我们有了自己定制的eval()函数,而且可以携带一个额外的参数。

打造可变参数的eval()函数

在直接使用JavaScript的eval()函数时,我们可以通过当前的上下文随意传入额外的参数。通过EM_ASM_ARG内联JavaScript代码时,我们也可以根据需要传入可变的参数。为了增加实用性,我们也希望my_run_script能够支持可变参数。

JavaScript语言和C语言本身都是支持可变参数的,但是二者的函数调用规范并不相容。我们先尝试在JavaScript中解析C语言函数的可变参数部分。要想访问C语言函数的可变参数部分,必须根据C语言的函数调用规范解析出传入的参数。

我们将新的函数命名为my_run_script_args,新的函数的C语言签名如下:

extern "C" void my_run_script_args(const char *jsCode, ...);  

在package.js文件增加它的实现:

// package.js
mergeInto(LibraryManager.library, {  
  my_run_script_args: function(code) {
    eval(Pointer_stringify(code));
  },
});

my_run_script_args()函数的JavaScript实现更加简单,只有一个参数用于输入的JavaScript代码字符串。额外的参数通过JavaScript函数的arguments属性访问。

在main.cc文件增加测试代码,通过arguments输出参数的内容:

#include <emscripten.h>

extern "C" void my_run_script_args(const char *s, ...);

int main() {  
  my_run_script_args("console.log(arguments)", 1, 2, 3);
  return 0;
}

现在用以下的命令编译和运行代码:

emcc --js-library package.js main.cc  
node a.out.js  
{ '0': 2236, '1': 6264 }

我们传入了JavaScript代码和额外的3个参数,但是arguments中只有2个元素。其中第一个参数arguments[0]对应C语言字符串指针,可以通过Pointer_stringify将它转为JavaScript字符串。第二个参数arguments[1]对应的是可变参数部分在C语言函数调用栈中的起始地址。

在Emscripten中,C语言的指针是int32类型的,默认的3个参数1,2,3int类型也是对应int32类型。跳过第一个参数,额外传入的3个参数1,2,3的地址依次为arguments[1]arguments[1]+4arguments[1]+8

在生成的JavaScript代码中,HEAP对应C语言的整个内存,而HEAP8、HEAPU8、HEAP16、HEAPU16、HEAP32、HEAPU32、HEAPF32、HEAPF64则是内存在不同数据类型视角下的映射,它们和HEAP都是对应同一个内存空间。它们的工作原理类似以下的C语言代码:

void  *HEAP  = malloc(TOTAL_MEMORY);  
int8_t *HEAP8 = (int8_t *)(HEAP);  
uint8_t *HEAPU8 = (uint8_t *)(HEAP);  
int16_t *HEAP16 = (int16_t *)(HEAP);  
uint16_t *HEAPU16 = (uint16_t *)(HEAP);  
int32_t *HEAP32 = (int32_t *)(HEAP);  
uint32_t *HEAPU32 = (uint32_t *)(HEAP);  
float  *HEAPF32 = (float *)(HEAP);  
double *HEAPF64 = (double *)(HEAP);  

因此,我们在传入JavaScript脚本中可以通过HEAP32和可变参数对应的内存地址来访问这些int类型的参数:

#include <emscripten.h>

extern "C" void my_run_script_args(const char *s, ...);

int main() {  
  auto jsCode = R"(
    console.log("arg1:", HEAP32[(arguments[1]+0)/4]);
    console.log("arg2:", HEAP32[(arguments[1]+4)/4]);
    console.log("arg3:", HEAP32[(arguments[1]+8)/4]);
  )";
  my_run_script_args(jsCode, 1, 2, 3);
  return 0;
}

因为HEAP32表示的是int32类型的数组,每个元素有4 字节,因此需要将指针除以4转为HEAP32数组的下标索引。

如果传入的是字符串,那么字符串指针也会被当作int32类型的参数传入。以下代码可以打印传入的字符串参数:

#include <emscripten.h>

extern "C" void my_run_script_args(const char *s, ...);

int main() {  
  auto jsCode = R"(
    for(var i = 0; i < arguments.length; i++) {
      console.log(Pointer_stringify(HEAP32[(arguments[1]+4*i)>>2]));
    }
  )";
  my_run_script_args(jsCode, "hello", "world");
  return 0;
}

我们通过HEAP32[(arguments[1]+4*i)&gt;&gt;2]来读取第i个int类型的参数,其中用ptr&gt;&gt;2移位运算替代了除以4运算。

需要注意的是,如果传入的参数中有浮点数的话,那么根据Emscripten中C语言的调用规范,浮点数会当作double类型传入。因此,在计算浮点数可变参数地址的时候需要8 字节对齐。下面的代码使用Emscripten提供的getValue()函数,可以避免自己计算下标索引:

#include <emscripten.h>

extern "C" void my_run_script_args(const char *s, ...);

int main() {  
  auto jsCode = R"(
    console.log("arg1:", getValue(arguments[1]+8*0, "double"));
    console.log("arg2:", getValue(arguments[1]+8*1, "double"));
    console.log("arg3:", getValue(arguments[1]+8*2, "double"));
  )";
  my_run_script_args(jsCode, 1.1f, 2.2f, 3.3f);
  return 0;
}

如果参数中混合了浮点数、整数或指针类型的参数,则每个可变参数都需要确保根据对应类型所占用的内存大小进行对齐。下面的代码中,可变参数部分混合了几种不同大小的类型,每个参数的偏移地址需要小心处理:

#include <emscripten.h>

extern "C" void my_run_script_args(const char *s, ...);

int main() {  
  auto jsCode = R"(
    console.log('arg1:', Pointer_stringify(getValue(arguments[1]+0, 'i32')));
    console.log('arg2:', getValue(arguments[1]+4, 'i32'));
    console.log('arg3:', getValue(arguments[1]+8, 'double'));
    console.log('arg4:', getValue(arguments[1]+16, 'i32'));
  )";
  my_run_script_args(jsCode, "hello", 43, 3.14f, 2017);
  return 0;
}

现在,经过我们手工打造定制的eval()函数已经足够强大,相较于EM_ASM宏函数可以支持复杂的JavaScript函数,相较于emscripten_run_script()函数则支持输入额外的可变参数。

eval()函数返回字符串

在前一个my_run_script_args()实现中,我们已经支持了可变参数。现在我们将重点关注eval()返回值的处理。

eval()具体的返回值和执行的脚本有关系,可能是简单的数值类型,也可能是JavaScript字符串,甚至是复杂的JavaScript对象。对于C语言来说返回值只能接受数值类型。但是数值类型无法表达eval()复杂的返回值。我们可以取一个折中的方案:将eval()的返回值转为JavaScript字符串,然后再转为C语言的字符串返回。C语言的字符串是一个指针类型,指针实际上也是一个32位的整数。

我们重新命名一个my_run_script_string()函数,支持返回C字符串类型。在package.js文件增加它的实现:

// package.js
mergeInto(LibraryManager.library, {  
  my_run_script_string: function(code) {
    var s = eval(Pointer_stringify(code)) + '';
    var p = _malloc(s.length+1);
    stringToUTF8(s, p, 1024);
    return p;
  },
});

我们先通过将eval的结果和一个空字符串链接转为JavaScript字符串类型。然后用C语言的malloc()函数为字符串分配足够的内存空间,之后用Emscripten提供的stringToUTF8()辅助函数将JavaScript字符串写到C语言的内存空间中。因为是在JavaScript环境,所以我们需要通过_malloc的方式来访问malloc()函数,这是由C语言名字修饰规范决定的。最后返回新构建的C语言字符串的指针。

现在可以在C语言中构造一个测试的代码:

// main.cc
#include <stdlib.h>
#include <emscripten.h>

extern "C" char* my_run_script_string(const char *s, ...);

int main() {  
  char *s = my_run_script_string("'hello'");
  if(s != NULL) printf("%s\n", s);
  if(s != NULL) free(s);
  return 0;
}

为了简化我们直接返回了一个JavaScript字符串,然后用C语言的printf()函数打印返回的结果。字符串使用完之后需要手工调用free释放内存。

参考Emscripten提供的emscripten_run_script_string()函数实现(在emscripten/src/library.js文件),其内部对返回值提供了统一的管理,C语言用户不需要也不能释放返回的字符串。这虽然会需要占用一定的内存空间,但是可以简化函数的使用。

我们可以用my_run_script_string()函数对象的属性来管理返回值的C内存空间。下面是重新实现的my_run_script_string()函数:

// package.js
mergeInto(LibraryManager.library, {  
  my_run_script_string: function(code) {
    var s = eval(Pointer_stringify(code)) + '';
    var p = _my_run_script_string;
    if (!p.bufferSize || p.bufferSize < s.length+1) {
      if (p.bufferSize) _free(p.buffer);
      p.bufferSize = s.length+1;
      p.buffer = _malloc(p.bufferSize);
    }
    stringToUTF8(s, p.buffer, 1024*8);;
    return p.buffer;
  },
});

我们通过_my_run_script_string来访问最终输出代码中my_run_script_string()函数,用函数对象的属性来管理返回字符串的内存。这样可以减少不必要的全局对象,可以解决名字空间膨胀的问题,也可以降低缓存对象被其他代码无意破坏的风险。

当缓存属性存在并且空间足够时复用已有的内存,当已有的内存不足时重新分配足够的内存。为了保护内存的一致性,现在my_run_script_string返回的将是不可修改的字符串对象。

C语言中my_run_script_string的声明需要做同步的调整,现在返回的是一个const char*类型的结果。最后需要注意一点的是,每一次调用my_run_script_string函数将会导致用于保存返回字符串内存的重写,以前返回的结果也就失效了。如果需要使用多次调用的返回值,可以用C++的字符串对象生成一个本地的复制对象:

// main.cc
#include <stdlib.h>
#include <emscripten.h>

#include <string>

extern "C" const char* my_run_script_string(const char *s, ...);

int main() {  
  std::string s0 = my_run_script_string("return 'hello'");
  std::string s1 = my_run_script_string("return 'world'");
  printf("%s, %s\n", s0.c_str(), s1.c_str());
  return 0;
}

至此,我们终于实现了一个近乎完美的C语言版本的eval()函数。

分享