emscripten & protobuf踩坑之旅

WebAssembly是一种运行在浏览器虚拟机中的新的机器码指令格式,简称wasm。emscripten就是可以将C/C++,JAVA等高级语言代码库编译为wasm的一种编译工具。为了能够在前端高效应用大量现存的C/C++库完成音视频,游戏,机器视觉等计算密集型的功能。简单的说,因为浏览器只能跑js代码,wasm的出现就是为了其他语言的代码也可以在浏览器里运行,emscripten就是可以将其他语言编译成wasm的编译工具。

之前接到一个需求,将服务器的一部分逻辑放到客户端,但是这部分代码有涉及使用protobuf。于是在emcc编译时要链接到对应的lib库,而之前用make编译好的libprotobuf并不能用。

解决方案

这里说下处理方式:

  1. 找到protobuf源码包用emscripten编译,不要直接用protobuf官方github的源码包!网上看到3.9.1有一个人为emscripten加了patch,我用的这个。这一步是为了编译出emcc可以链接的libprotobuf和include头文件。参考地址,相关指令:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    git clone https://github.com/protocolbuffers/protobuf protobuf-wasm
    cd protobuf-wasm
    git checkout v3.9.1
    git clone https://github.com/kwonoj/protobuf-wasm wasm-patches
    cd wasm-patches && git checkout 4bba8b2f38b5004f87489642b6ca4525ae72fe7f && cd ..
    git apply wasm-patches/*.patch
    ./autogen.sh
    emconfigure ./configure CXXFLAGS="-O3"
    emmake make
  2. 从protobuf官方的github下载3.9.1(为了和上面版本对齐)的源码包,正常用make编译出protoc,这一步是为了用protoc将proto文件编成xxx.h和xxx.cc

  3. 编译项目,链接时指定相关目录,pb相关的include头文件用第一步生成的,lib也是。比如我的编译指令(注意链接lib库前面不加参数!):

    em++ src/handler/.cpp src/module/.cpp src/util/.cpp src/gen_protocol/.cc src/story_line/StoryLine/src/.cpp src/story_line/StoryLine/src/loader/.cpp -I ./src/ -I ./src/story_line/StoryLine/include/ -I ./src/story_line/StoryLine/src/ -I ./src/story_line/StoryLine/src/loader/ -I ./src/handler/ -I ./src/module/ -I ./src/util/ -I ./src/story_line/thirdparty/ -I /home/chauncyli/protobuf-wasm/src/ /home/chauncyli/protobuf-wasm/src/.libs/libprotobuf.a -std=c++11 -o 2130test.js –embed-file res -s WASM=1 –bind -g3

坑点

编译报错

  1. 有无数的undefined reference的报错,检查下用到的包是不是因为一个是用的c++编译器,一个用的c编译器,c++编译器是会改变原来函数/变量名的(加前后缀)。通过报错信息可以很清晰的看出来
  2. protoc编译出现:

    google/protobuf/descriptor.proto: File not found.
    illusion.proto: Import “google/protobuf/descriptor.proto” was not found or had errors.
    illusion.proto:8:8: “google.protobuf.MessageOptions” is not defined.
    illusion.proto: “google.protobuf.MessageOptions” is not defined.
    illusion.proto: “google.protobuf.MessageOptions” is not defined.
    illusion.proto: “google.protobuf.MessageOptions” is not defined.
    illusion.proto: “google.protobuf.MessageOptions” is not defined.
    illusion.proto: “google.protobuf.MessageOptions” is not defined.
    illusion.proto: “google.protobuf.MessageOptions” is not defined.
    illusion.proto: “google.protobuf.MessageOptions” is not defined.
    illusion.proto: “google.protobuf.MessageOptions” is not defined.
    illusion.proto:33:8: “google.protobuf.FieldOptions” is not defined.
    illusion.proto: “google.protobuf.FieldOptions” is not defined.
    illusion.proto: “google.protobuf.FieldOptions” is not defined.
    illusion.proto: “google.protobuf.FieldOptions” is not defined.
    illusion.proto: “google.protobuf.FieldOptions” is not defined.
    illusion.proto: “google.protobuf.FieldOptions” is not defined.
    illusion.proto: “google.protobuf.FieldOptions” is not defined

    是因为protoc文件与其include下的google目录不在同一个目录下,可以拷贝protoc到与google同一级目录下

  3. protoc编译出现:

    –proto_path passed empty directory name. (Use “.” for current directory.)

    需要cd到proto文件的目录下,然后执行protoc编译!有点搓,即使在其他目录下-I指定proto文件cd也貌似没用

文件加载。

  • C/C++代码中有读取文件的话,emscripten也是支持的,且C/C++中不需要修改代码。emscripten的文件加载有两种方式:preload 和 embed(–preload-file,–embed-file),embed是将文件序列化后嵌入到生成的js代码中;preload是将文件分别进行打包到emscripten自己的virtual file system中,C/C++使用层无感知,js也无感知。embed的效率远不及preload,所以embed比较适用于少量的小文件。

  • 使用–preload-file打包文件的话生成的除了js胶水代码文件和wasm字节码文件,还会有一个xxx.data,这个就是我们打包的文件,如果打包的是一个文件夹,则该data文件内容就是文件夹下所有的文件内容拼接到一起。可以使用@符合改变文件路径的映射,比如打包当前目录下的res1/, C/C++中读取路径是”/res2/“,编译时参数可以为--preload-file res1/@/res2/

返回值

  • C/C++和js通信如果用的是二进制还有点不同,C/C++用char数组来进行存储,js侧用的是uint数组,如此一来,如果C/C++返回一个二进制字符串,则js侧接收到如果中间有’\0’会进行一个裁剪,后面的内容会丢弃(可以加len验证),这样再进行反序列化就会出错,目前我的做法是在C/C++层将char数组遍历挨个减去’0’转成uint,然后将uint数组传给js,这样可以正常反序列化,可能还有别的更好的方式,目前想到的是这种方法。
  • 返回自定义对象。函数可以返回自定义的class/struct,需要在EMSCRIPTEN_BINDINGS中进行注册.比如返回一个struct:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    typedef struct Res {
    Res() {
    succeed = true;
    value = "";
    error_msg = "";
    ivalue.clear();
    value_len = 0;
    }
    bool succeed;
    std::string value;
    std::string error_msg;
    std::vector<uint32_t> ivalue;
    uint32_t value_len;
    } Res;

    EMSCRIPTEN_BINDINGS中需要注册该struct的所有成员:

    1
    2
    3
    4
    5
    6
    7
    emscripten::class_<Res>("Res")
    .constructor()
    .property("succeed", &Res::succeed)
    .property("error_msg", &Res::error_msg)
    .property("value", &Res::value)
    .property("value_len", &Res::value_len)
    .property("ivalue", &Res::ivalue);

    但是返回值中不可以有数组或者指针,不管直接返回还是在struct中都不可以。

  • 返回vector/map。这两种类型emscripten提供注册函数可以注册,js中即可以直接使用,参考地址
    1
    2
    3
    4
    5
    6
    7
    8
    9
      emscripten::register_vector<uint32_t>("vector<uint32_t>");
    emscripten::register_map<uint32_t, int>("map<uint32_t, int>");
    ```

    #### 参数
    - C/C++中有参数引用,函数中可以直接修改这个引用的值,这样函数返回时可以减少一次值拷贝,效率比较高。但是可能js不支持,经验证,所有涉及函数修改参数引用的值时编译都会报错`internal::BindingType<Args>::fromWireType(args)`,只能是const的引用来对该参数只读操作。指针类似,涉及修改参数的都会报错。

    #### 使用
    我是将C/C++代码写到一个类中,让客户端童鞋可以用js通过我定义的类的一些接口实现数据交互。这里需要注册一下类以及对应的接口,比如:

emscripten::class_(“CSStorylineHandler”)
.constructor()
.function(“InitPlayer”, &CSStorylineHandler::InitPlayer, emscripten::allow_raw_pointers())
.function(“GetParseString”, &CSStorylineHandler::GetParseString, emscripten::allow_raw_pointers())
.function(“GetCurrentPath”, &CSStorylineHandler::GetCurrentPath, emscripten::allow_raw_pointers());

1
这样在客户端通过定义这个类的对象就可以访问这里注册的接口了:

var obj = new Module.CSStorylineHandler()
obj.InitPlayer()
var res = obj.GetParseString(arg1, arg2)
var path = obj.GetCurrentPath(arg1, arg2)

1
2
3

## 最后
为了避免再碰到环境搭建的问题,我已经将搭建好的环境编成docker上传。各位大佬只需要pull下就可以开箱即用了

docker pull chauncyli/emscripten_ci:0.0.1
docker container run image_id -it /bin/bash

#记得source一下emsdk
source emsdk/emsdk_env.sh
```
感觉emscripten应用前景很广,比如一些库有C/C++实现,如果JS的性能不好完全可以用emscripten将C/C++的库编译成对应的wasm,然后在浏览器中直接用js代码调用对应的C/C++接口(参考地址)就可以达到接近C/C++原生的运行速度。

参考资料

WebAssembly 不完全指北
编译打包
注册,返回值
使用emscripten编译protobuf