将新的 C/C++ 模块编译为 WebAssembly

当您使用 C/C++ 等语言编写了一个新的代码模块后,可以使用 Emscripten 等工具将其编译为 WebAssembly。让我们看看它是如何工作的。

Emscripten 环境设置

首先,让我们设置必要的开发环境。

预备知识

按照以下说明获取 Emscripten SDK:https://emscripten.cn/docs/getting_started/downloads.html

编译示例

环境设置完成后,让我们看看如何使用它将 C 示例编译为 Wasm。Emscripten 编译时有许多可用选项,但我们将介绍的两个主要场景是:

  • 编译为 Wasm 并创建 HTML 来运行我们的代码,以及运行 Wasm 所需的所有 JavaScript “胶水”代码。
  • 编译为 Wasm 并仅创建 JavaScript。

我们将在下面介绍这两种情况。

创建 HTML 和 JavaScript

这是我们将介绍的最简单的情况,即让 emscripten 生成您在浏览器中以 WebAssembly 形式运行代码所需的一切。

  1. 首先,我们需要一个示例来编译。复制以下简单的 C 示例,并将其保存在本地驱动器上的新目录中的一个名为 hello.c 的文件中。

    c
    #include <stdio.h>
    
    int main() {
        printf("Hello World\n");
        return 0;
    }
    
  2. 现在,使用您用于进入 Emscripten 编译器环境的终端窗口,导航到与 hello.c 文件相同的目录,然后运行以下命令:

    bash
    emcc hello.c -o hello.html
    

我们通过命令传递的选项如下:

  • -o hello.html — 指定我们希望 Emscripten 生成一个 HTML 页面来运行我们的代码(并指定一个文件名),以及 Wasm 模块和 JavaScript “胶水”代码,以便在 Web 环境中编译和实例化 Wasm 以便使用。

此时,在您的源目录中应该有:

  • 二进制 Wasm 模块代码 (hello.wasm)
  • 一个 JavaScript 文件,包含用于在原生 C 函数、JavaScript/Wasm 之间进行转换的胶水代码 (hello.js)
  • 一个 HTML 文件,用于加载、编译、实例化您的 Wasm 代码,并在浏览器中显示其输出 (hello.html)

运行您的示例

现在,您只需在支持 WebAssembly 的浏览器中加载生成的 hello.html 即可。从 Firefox 52、Chrome 57、Edge 57、Opera 44 开始,它默认启用。

注意: 如果您尝试直接从本地硬盘打开生成的 HTML 文件(hello.html)(例如,file://your_path/hello.html),您将收到一条错误消息,大致内容是 *both async and sync fetching of the wasm failed*。您需要通过 HTTP 服务器(http://)运行 HTML 文件 — 有关更多信息,请参阅如何设置本地测试服务器?

如果一切按计划进行,您应该会在网页上出现的 Emscripten 控制台以及浏览器的 JavaScript 控制台中看到“Hello world”的输出。恭喜,您刚刚将 C 编译为 WebAssembly 并在浏览器中运行了它! image

使用自定义 HTML 模板

有时您会想使用自定义 HTML 模板。让我们看看如何做到这一点。

  1. 首先,将以下 C 代码保存在一个名为 hello2.c 的文件中,放在一个新的目录中。

    c
    #include <stdio.h>
    
    int main() {
        printf("Hello World\n");
        return 0;
    }
    
  2. 在您的 emsdk 仓库中搜索 shell_minimal.html 文件。将其复制到您的新目录中的一个名为 html_template 的子目录中。

  3. 现在导航到您的新目录(同样,在您的 Emscripten 编译器环境终端窗口中),然后运行以下命令:

    bash
    emcc -o hello2.html hello2.c -O3 --shell-file html_template/shell_minimal.html
    

    这次我们传递的选项略有不同。

    • 我们指定了 -o hello2.html,这意味着编译器将仍然输出 JavaScript 胶水代码和 .html 文件。
    • 我们指定了 -O3,它用于优化代码。Emcc 与任何其他 C 编译器一样具有优化级别,包括:-O0(无优化)、-O1-O2-Os-Oz-Og-O3-O3 是发布版本的好设置。
    • 我们还指定了 --shell-file html_template/shell_minimal.html — 这提供了您想用于创建 HTML 的 HTML 模板的路径,您将通过该 HTML 运行您的示例。
  4. 现在让我们运行这个示例。上面的命令将生成 hello2.html,它将与模板具有大致相同的内容,并添加了一些胶水代码来加载生成的 Wasm,运行它等。在浏览器中打开它,您将看到与上一个示例大致相同的输出。

编译为 JavaScript 模块

您可以指定仅输出 JavaScript “胶水”文件(Emscripten 需要大量 JavaScript “胶水”代码来处理内存分配、内存泄漏以及许多其他问题),而不是完整的 HTML,只需在 -o 标志中指定一个 .js 文件而不是 HTML 文件,如下所示:

bash
emcc -o hello.js hello.c -O3

然后,您可以将此 JavaScript 文件合并到您的程序中,这在使用打包器并且不直接处理 HTML 时特别有用。例如,您可以导入生成的 JavaScript 胶水文件,使其作为副作用运行。在您的应用程序的入口模块中,添加:

js
import "./hello.js";

或者,您可以生成一个工厂模块,它允许您创建模块的多个实例(默认情况下,胶水代码会全局加载模块,导致多个实例发生冲突)。

bash
emcc -o hello.mjs hello.c -O3 -sMODULARIZE

注意: 如果您的输出文件扩展名是 .js 而不是 .mjs,那么您必须添加 -sEXPORT_ES6 设置来输出 JavaScript 模块。

然后,在您的代码中导入工厂并调用它:

js
import createModule from "./hello.mjs";

createModule().then((Module) => {
  console.log("Wasm ready", Module);
});

调用 C 中定义的自定义函数

如果您想从 JavaScript 调用 C 代码中定义的函数,您可以使用 Emscripten 的 ccall() 函数和 EMSCRIPTEN_KEEPALIVE 声明,它会将您的函数添加到已导出函数的列表中(请参阅为什么我 C/C++ 源代码中的函数在编译到 JavaScript 时会消失,以及/或者我收到“没有要处理的函数”?)。让我们看看它是如何工作的。

  1. 首先,将以下代码保存为 hello3.c 并放在一个新的目录中:

    c
    #include <stdio.h>
    #include <emscripten/emscripten.h>
    
    int main() {
        printf("Hello World\n");
        return 0;
    }
    
    #ifdef __cplusplus
    #define EXTERN extern "C"
    #else
    #define EXTERN
    #endif
    
    EXTERN EMSCRIPTEN_KEEPALIVE void myFunction(int argc, char ** argv) {
        printf("MyFunction Called\n");
    }
    

    默认情况下,Emscripten 生成的代码总是只调用 main() 函数,其他函数会被视为死代码而被删除。在函数名前面加上 EMSCRIPTEN_KEEPALIVE 可以阻止这种情况发生。您还需要包含 emscripten.h 库才能使用 EMSCRIPTEN_KEEPALIVE

    注意: 我们包含了 #ifdef 块,以便如果您尝试将其包含在 C++ 代码中,示例仍然可以工作。由于 C 和 C++ 的名称修饰规则,这通常会导致失败,但在这里我们将其设置为当使用 C++ 时,它被视为外部 C 函数。

  2. 现在,为了方便起见,也将 html_template/shell_minimal.html{{{ SCRIPT }}} 内容一起添加到这个新目录中(在您的真实开发环境中,您显然会将其放在一个中心位置)。

  3. 现在让我们再次运行编译步骤。从您的最后一个目录中(同时在您的 Emscripten 编译器环境终端窗口中),使用以下命令编译您的 C 代码。请注意,我们需要使用 NO_EXIT_RUNTIME 进行编译:否则,当 main() 退出时,运行时将关闭,此时调用已编译的代码将无效。这对正确的 C 模拟是必需的:例如,要确保调用 atexit() 函数。

    bash
    emcc -o hello3.html hello3.c --shell-file html_template/shell_minimal.html -s NO_EXIT_RUNTIME=1 -s "EXPORTED_RUNTIME_METHODS=['ccall']"
    
  4. 如果您再次在浏览器中加载示例,您会看到与之前相同的结果!

  5. 现在我们需要从 JavaScript 运行我们新的 myFunction() 函数。首先,在文本编辑器中打开您的 hello3.html 文件。

  6. 将一个 <button> 元素添加到第一个打开的 <script type="text/javascript"> 标签上方,如下所示。

    html
    <button id="my-button">Run myFunction</button>
    
  7. 现在,在第一个 <script> 元素的末尾添加以下代码:

    js
    document.getElementById("my-button").addEventListener("click", () => {
      alert("check console");
      const result = Module.ccall(
        "myFunction", // name of C function
        null, // return type
        null, // argument types
        null, // arguments
      );
    });
    

这说明了如何使用 ccall() 来调用已导出的函数。

另见