1. JavaScript与C/C++混合编程的技术

JavaScript与C++混合编程可以实现两种语言的优势结合,C++的程序性能很高且支持强大的系统调用能力,JavaScript则生态丰富且开发效率高。

JavaScript与C++混合编程常见的技术手段主要有以下几种:

  • Node.js的C++扩展: 常用于Node.js实现的后端服务代码。在WebAssembly出现之前,Node.js的服务器代码通常以这种方式调用C/C++的模块。
  • JsAPI和Native API: 常应用于包含WebView的客户端。将Native端的代码封装成Web接口(JsAPI)供前端调用,将Web端的代码封装成Native接口供Native调用。像Electron、CEF技术栈的客户端均采用了此种方式。
  • WebAssembly: 主要用于浏览器上运行的前端页面,Node.js从8.0开始也支持WebAssembly,因此也可用于服务端开发。

本章所讲的内容是基于WebAssembly的混合编程技术。

2. 什么是WebAssembly?

WebAssembly是一种新的编码方式,是一种为web设计的高效、低级字节码格式。我们可以将C/C++、Rust等低级语言编写的代码编译成WebAssembly字节码,现代的Web浏览器可以加载WebAssembly,并与JavaScript协同运行。从而使得WebAssembly成为JavaScript与C/C++混合编程并在Web上运行的最有效机制。C/C++编译成的WebAssembly能够以接近原生语言的效率在浏览器上运行。

2.1. 支持WebAssembly的浏览器

支持WebAssembly的常用浏览器及版本:

  • Chrome 57及以上版本。
  • Firefox 52及以上版本。
  • Edge 16(基于Chromium的版本)及以上。
  • Safari 11及以上版本。
  • Opera 44及以上版本。

参考信息: https://caniuse.com/wasm

此外,Node.js从8.0版本也开始支持WebAssembly,WebAssembly目前已经成了W3C的Web标准之一。

2.2. WebAssembly的编译器

除了C/C++外,WebAssembly还支持多种其他计算机语言编译成.wasm,常见的语言和编译器如下:

  • C/C++: emscripten编译工具链可以将 C/C++ 编译成 WebAssembly。
  • Rust: wasm-pack:工具可以将 Rust 编译成 WebAssembly。
  • Go: Go语言的官方工具就链支持将 Go 编译成 WebAssembly。

3. 开发环境搭建

  1. emscripten官方文档: https://emscripten.org/docs/getting_started/downloads.html
  2. 依赖的环境准备

    • git
    • Python3.6或更新版本(Windows)
  3. 安装步骤

    # 1. 从Github上克隆emsdk仓库
    # emsdk即Emscripten SDK,是将C/C++编译成WebAssembly的工具
    git clone https://github.com/emscripten-core/emsdk.git
    
    # 2. 进入emsdk目录
    cd emsdk
    
    # 3. 下载和安装最新的SDK tools(包括node.js、emscripten等)
    # Linux/macOS:
    ./emsdk install latest
    # Windows:
    ./emsdk.bat install latest
    # (安装大概需要十几分钟的时间,可以去喝杯茶休息一下了)
    # 会将相关的工具安装在以下三个目录
    # emsdk/node
    # emsdk/upstream
    # emsdk/python (Windows才有,会安装nuget)
    
    # 4. 为当前用户设置latest版本为当前激活的工具
    # Linux/macOS:
    ./emsdk activate latest
    # Windows:
    ./emsdk.bat activate latest
    
    # 5. 为当前命令终端设置环境变量
    # Linux/macOS:
    source ./emsdk_env.sh
    # Windows:
    ./emsdk_env.bat
    
    # 6. 验证是否安装成功
    emcc -v
    # (如果有显示正常的版本信息,则说明安装成功)

    以上示例基于3.1.72版本的emscripten。

4. Hello World程序

我们从一个Hello World程序开始,了解WebAssembly程序的开发、编译、运行的大致流程。

  1. 新建一个测试目录hello_world和源码文件hello.cpp

    // hello_world/hello.cpp
    #include <iostream>
    
    int main()
    {
        std::cout << "Hello World from C++" << std::endl;
        return 0;
    }
  2. 执行以下命令编译为WebAssembly

    emcc hello.cpp -o hello.html

    编译后会生成如下三个文件:

    ./hello_world
    ├── hello.html # emscripten的测试页面,用来展示输出内容的HTML页面。
    ├── hello.js   # 是Emscripten生成的胶水代码,其中包含了Emscripten的运行环境和.wasm文件的封装。
    └── hello.wasm # 二进制的字节码文件
  3. 在当前Demo目录下启动一个http-server服务,可以用python或node.js工具。

    # 进入目录
    cd hello_world
    
    # 在当前目录启动http-server服务
    # Python3的用法
    python -m http.server
    # Python2的用法
    python -m SimpleHTTPServer
  4. 在支持WebAssembly的浏览器中打开http://localhost:8000/hello.html页面,正常情况下就可以看到输出内容(Hello World from C++)了。

    file

5. emscripten常见用法

5.1. emscripten编译流程

Emscripten的诞生早于WebAssembly。WebAssembly出现之前,Emscripten的编译目标时asm.js,即Emscripten的主要功能是将C/C++代码编译成JavaScript代码。Emscripten在1.37.3开始正式支持WebAssembly,可以根据编译选项设置编译目标为asm.js或WebAssembly。

Emscripten的编译流程如下:

file

  • C/C++代码先通过Clang编译为LLVM的字节码,然后再根据设置的不同目标编译为asm.js或WebAssembly(.wasm)。
  • 可以通过-s WASM=1-s WASM=0来设置编译目标,Emscripten自v1.38.1开始,默认的缺省编译选项为WASM=1,之前的版本默认为WASM=0
  • 相比于asm.js,.wasm具有体积小、执行效率高的优势,因此一般会优先选择.wasm作为编译目标。当然,在实际的项目中,为了增加程序的兼容性,可能会同时构建两个编译目标,在支持WebAssembly的浏览器中加载.wasm,不支持WebAssembly的老版本浏览器中降级为asm.js。

5.2. emsdk常见用法

emsdk是emscripten工具链最核心的部分,emsdk是将C/C++编译成WebAssembly的编译工具,其用法与Clang/GCC有点相似。

1. 最简单用法。

  • 编译指令emcc ./hello.cpp
  • 结果文件

    • a.out.wasm: 为C/C++源文件编译后形成的WebAssembly汇编文件,是一个二进制的字节码文件。
    • a.out.js: 是Emscripten生成的胶水代码,其中包含了Emscripten的运行环境和.wasm文件的封装,导入a.out.js即可自动完成.wasm文件的载入、编译、实例化、运行时初始化等繁杂的工作。

2. -o选项

-o选项可以指定输出的文件名和文件类型。

【demo1】

  • 编译指令: emcc ./hello.cpp -o hello.js
  • 结果文件:

    • hello.wasm: 与a.out.wasm文件相同。
    • hello.js: 与a.out.js文件相同。

【demo2】

  • 编译指令: emcc ./hello.cpp -o hello2.html
  • 结果文件:

    • hello2.wasm: 与a.out.wasm文件相同。
    • hello2.js: 与a.out.js文件相同。
    • hello2.html: emscripten的测试页面,用来展示输出内容的HTML页面。

3. -s选项。

-s选项是一个用于设置编译目标和编译属性的重要选项:

  • -s WASM=1: 指定输出为WebAssembly 式,这能提升执行性能,WASM=1是默认的缺省参数,此选项会生成XXX.wasmXXX.js文件。
  • -s WASM=0: 指定输出为asm.js格式,此选项只会生成XXX.js文件,不会生成XXX.wasm文件。
  • -s EXPORTED_RUNTIME_METHODS=['ccall','cwrap']: 指定导出运行时方法ccallcwrapccall/cwrap辅助函数默认没有导出,在编译时需要通过此选项显示导出。
  • -s MODULARIZE=1:使输出的 JavaScript 代码成为一个模块化的形式,便于在不同的环境下使用。
  • -s EXPORT_NAME='myModule':自定义导出的模块名称。
  • -s ALLOW_MEMORY_GROWTH=1:允许动态扩展内存,适用于需要可变内存的应用场景。

4. --bind选项

--bind选项表示使用embind模块。embind模块可以将C++类和函数绑定到JavaScript环境中,后文将讲解此部分内容。

5. --js-library选项

--js-library选项可以指定一个JavaScript文件作为JS库,参与C/C++的编译过程。后文将进一步讲解此相关内容。

5.3. 导出一个函数

Hello World程序中,我们在HTML页面中加载并调用了C++的main函数。main函数是C/C++程序的入口函数,实际项目中,底层的C/C++模块通常希望通过接口来提供特定功能,而不是直接调用main函数作为单一入口。

emscripten中C/C++要导出一个接口,有两个关键的点:

  • extern "C"以C的方式导出接口,避免C++的函数在编译后会对函数名称进行重整,在《导出接口的定义》一章中已介绍过相关内容。
  • EMSCRIPTEN_KEEPALIVE宏告知编译器后续函数在优化时必须保留,并且该函数将被导出至JavaScript环境。EMSCRIPTEN_KEEPALIVE是emscripten编译器内置的预编译宏,在<emscripten.h>头文件中定义了该宏。

函数定义:

extern "C" EMSCRIPTEN_KEEPALIVE int32_t add(int32_t a, int32_t b)
{
    return a + b;
}

C++代码:

为了代码编写方便,我们可以定义一个宏来简化代码,如下代码(export_function.cpp)。

#include <cstdint>
#include <emscripten.h>

#define DECL_API(rettype) extern "C" EMSCRIPTEN_KEEPALIVE rettype

DECL_API(int32_t) add(int32_t a, int32_t b)
{
    return a + b;
}

DECL_API(int32_t) sub(int32_t a, int32_t b)
{
    return a - b;
}

编译指令:

通过以下指令编译代码。

emcc ./export_function.cpp -o ./export_function.js

HTML代码:

编写html测试页面(test.html)如下。

<html>
  <head>
    <meta charset="utf-8" />
    <title>Emscripten</title>
  </head>

  <body>
    <h2>Emscripten:你好,世界!</h2>
    <script>
      Module = {};
      Module.onRuntimeInitialized = function () {
        let r1 = Module._add(3, 2);
        console.log("add(3, 2) = " + r1);
        let r2 = Module._sub(3, 2);
        console.log("sub(3, 2) = " + r2);
      };
    </script>
    <script src="export_function.js"></script>
  </body>
</html>

运行结果:

浏览器打开该页面,可以看到控制台输出了add(3, 2) = 5sub(3, 2) = 1

file

代码说明:

WebAssembly模块是异步加载的,这意味着JS加载完成后emscripten的运行时环境可能并未准备好,我们要等待emscripten的运行时环境准备就绪后再调用WebAssembly模块的代码。而onRuntimeInitialized()就是emscripten的运行时环境准备就绪后的一个回调函数,因此可在该函数内安全的调用WebAssembly模块相关的代码。在无特殊说明(不产生歧义)的情况下,后续文章的测试代码将不再列出该回调函数的完整代码。

Module.onRuntimeInitialized = function () {
    <!-- TODO -->
};

历史文章推荐:

01. 什么是SDK

02. SDK的设计目标

03. 接口设计与规范

04. 接口注释与接口文档

05. 原理篇:字符集与字符编码(一)

06. 原理篇:字符集与字符编码(二)

07. 原理篇:多字节字符与宽字节字符

08. 原理篇:静态库、动态库与运行库

09. 跨平台:C++标准的版本

10. 跨平台:源码的保存格式与中文乱码问题

11. 跨平台:宏定义隔离平台差异

12. 跨平台:基础数据类型的定义

13. 跨平台:文件系统的操作

14. 跨平台:头文件包含的差异

15. 跨平台:导出接口的定义

16. 跨平台:字节序大端与小端

17. 跨平台:内存和资源管理

18. 工程篇:C/C++常用编译器

19. 工程篇:用VSCode搭建C++开发环境

20. 工程篇:CMake实现跨平台构建

21. 工程篇:VSCode中使用CMake插件运行和调试程序

22. 跨语言:跨语言的混合编程

23. 跨语言:C++接口设计和代码实现

24. 跨语言:C语言接口设计和代码实现

25. 跨语言:C/C++与Python混合编程(一)

26. 跨语言:C/C++与Python混合编程(二)

27. 跨语言:C/C++与Python混合编程(三)

附录A-计算机术语中成对出现的单词

附录B: 计算机术语中常见的单词缩写


大家好,我是陌尘。

IT从业10年+, 北漂过也深漂过,目前暂定居于杭州,未来不知还会飘向何方。

搞了8年C++,也干过2年前端;用Python写过书,也玩过一点PHP,未来还会折腾更多东西,不死不休。

感谢大家的关注,期待与你一起成长。


SunLogging


陌尘
6 声望7 粉丝

花名(网名): 陌尘