百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

基础为零?如何将 C++ 编译成 WebAssembly

itomcoil 2024-12-29 03:53 15 浏览

WebAssembly 是一个 W3C 推出的二进制指令格式,近日它的 1.0 版本也正式定稿成为了规范,关于它的基本概念这里不再展开介绍了,网上已经有很多文章了,大家可以自行了解,推荐阅读官方文档①、spec 仓库②、MDN 的教程③、以及 Lin Clark 的文章④,其他随意。

能编译成 wasm 的语言有很多,C++ 和 Rust 是其中两个比较成熟而且大量被使用的,本文以 C++ 为例,一步一步介绍 如何把 C++ 代码编译成 wasm 并且运行起来。例子很简单,相信不了解 C/C++ 开发的同学也能看懂。

说是 C++ 其实本文用到的代码都是纯 C 的。

文章用到的源码和编译脚本都在: https://github.com/Hanks10100/cpp2wasm

Hello World!

首先,我们来编译一个 C 语言的 Hello World,创建一个 hello.c 文件:

复制代码

#include <stdio.h> int main() {  printf("Hello World!\n");  return 0;}

编译成可执行文件

代码就是输出了一句 Hello World! ,使用 clang 或 gcc 或很多工具都可以把这段代码编译成可执行的二进制,找不到命令的话,可以在网上找教程配置一下。以 clang 为例:

复制代码

clang hello.c -O3 -o out/hello

-O3 表示了优化级别, 生成的可执行文件是 hello ,但是这个文件只能在特定平台上执行,在 windows 上编译出来的文件没办法跑在 mac 上(不绝对),在 32 位系统编译出来的文件无法跑在 64 位系统上。

然而如果把它编译成 wasm 就可以跨平台分发了,这也是 wasm 的一大优势。只需要编译一次,同一个 wasm 包,可以运行在浏览器中、Node.js 中、各种独立的 runtime 里,但是要求目标平台具备执行 wasm 包的能力,而且符合规范。

WebAssembly 的编译和运行流程

在编译 WebAssembly 之前先了解一下它基本的编译和运行流程,想要以何种方式运行 wasm 的包,决定了以何种方式来编译它。

目前来看,大部分使用 WebAssembly 的例子都是运行在浏览器中的,有一部分运行在 Node.js 里,和 JS 的渊源很深,因为在标准里定义了一套 JS API 来编译、实例化 wasm 文件,这部分 API 已经被 JS 引擎实现了,功能已经稳定可用。因此,wasm 最常见的是搭配 js 一起使用,这种场景下用 Emscripten ⑤ 可以搞定,它在编译 wasm 包的同时也会生成一份 js “glue” 代码,把 wasm 包的初始化接口导入导出都封装在 js 里了,使用时引入这个 js 文件即可。

Emscripten 也支持编译成独立的 wasm 包(不含 JS),但是想要运行这个 wasm 包需要宿主环境给它注入很多基础的 API,而且这些 API 是非标准的。如果想在 JS 环境里运行独立 wasm 包的话,要用 JS 实现这些 API。

其实 WebAssembly 本质上和 JS 无关,完全可以运行在独立的沙箱环境里,通过标准化的 API (wasi ⑥) 来调用系统能力。现在已经有不少 wasm 的独立运行时了,如 Wasmtime ⑦ 和 wasm-micro-runtime ⑧,它们都可以加载并独立执行 wasm 文件,并且实现了一致的 wasi 接口。

关于 wasi,推荐阅读《Standardizing WASI: A system interface to run WebAssembly outside the web》 https://hacks.mozilla.org/2019/03/standardizing-wasi-a-webassembly-system-interface/

如上图所示,面对自己的 C/C++ 代码,想要把它运行在浏览器或 Node.js 中,就使用 Emscripten 把它编译成 wasm + js 文件;想要把它运行在独立的运行时里,就使用 wasi-sdk ⑨ 进行编译,生成单独的 wasm 包。(此结论简单粗暴,为了方便理解,并不严谨)

使用 Emscripten 编译

首先安装官方文档安装 Emscripten ( https://emscripten.org/ ) ,安装完成后命令行环境里会有 emcc 命令,使用方式和 gcc 差不多,执行如下代码就可以生成 wasm 的包:

复制代码

emcc hello.c -O3 -o out/hello-emcc.wasm

但是,上面这个命令隐含了 -s STANDALONE_WASM 的配置 ,实际上触发的是 WebAssembly Standalone build ⑩,只生成了一个 wasm 的包,需要自己写 loader 加载和执行。如果不想费这个劲,就可以使用如下命令直接生成 wasm + js 文件:

复制代码

emcc hello.c -O3 -o out/hello-emcc.js

该命令除了生成 js 文件以外,还会生成同名的 hello-emcc.wasm 文件,可以使用 WABT ? (WebAssembly Binary Toolkit) 提供的小工具把 wasm 文件转成对等的文本格式,方便阅读。

复制代码

wasm2wat out/hello-emcc.wasm -o out/hello-emcc.wat

代码比较短,但是生成出来的 wasm 文件有 2.1KB,js 文件 16KB,主要是因为 stdio.h 头文件里有很多依赖,在运行时是由 js 代码来实现的。用 wasm 做 io 本身也不是个好的用法。

最后,直接在 Node.js 环境里执行这个 js 文件就行了,可以看到控制台输出了 Hello World! 。

复制代码

node out/hello-emcc.js

使用 wasi-sdk 编译

首先根据自己的系统下载相应的 wasi-sdk ,配置好环境变量之后,就可以调用其中自带的 clang 工具编译生成 wasm 文件:

复制代码

clang hello.c -O3 -o out/hello-wasi.wasm

大概率跑不通…… 因为要配各种环境变量还要指定 sysroot 才行。假如你下载的是 8.0 版本,放到了个人目录之下,可以用下面这个命令编译代码,不需要配置环境变量:

复制代码

如果是 Mac 电脑,遇到安全提示,在【系统偏好设置】-【安全与隐私】-【通用】里,找到“允许以下位置下载的 App”的配置,下方应该有提示信息,点击允许就可以了。

打出来包之后,可以用 file out/hello-wasi.wasm 命令检查一下生成的包格式对不对,有如下输出才是正确的,否则你打出来的很可能是个原生的二进制文件。

复制代码

hello-wasi.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)

同样,可以用 wasm2wat 工具生成可读的文本格式,方便看代码:

复制代码

wasm2wat out/hello-wasi.wasm -o out/hello-wasi.wat

想要运行这个 wasm 包,需要有独立的运行时,理论上讲,所有实现了标准 wasi 接口的 runtime 都可以执行这个包。以 Wasmtime ( https://wasmtime.dev/ ) 为例,安装好后,用下方命令就可以执行这个 wasm 文件,会看到控制台有 Hello World! 的输出:

复制代码

wasmtime out/hello-wasi.wasm

另外,如果不想在电脑上装这些独立运行时,还有个神奇的网站( https://wasi.dev/polyfill/ )可以在线运行基于 wasi 接口的 wasm 包。这个网站在浏览器环境里实现了一份 polyfill,对等实现了原生 wasm 运行时的一部分功能。把刚编译好的 hello-wasi.wasm 文件传上去,也可以看到 Hello World! 的输出。

编译独立模块

然而在实际情况中,并不是所有包都想自执行,不一定都有 main 函数,大部分 wasm 包是想提供一些 api 供外部调用。自己打印 Hello World 没有任何意义,要和宿主环境有交互才行。

下面以斐波那契数列为例,介绍如何编译一个独立的 wasm 模块。C 语言代码如下:

复制代码

int fib (int n) {  if (n <= 0) return 0;  if (n <= 2) return 1;  return fib(n - 2) + fib(n - 1);}

使用 Emscripten 编译

这次代码里没了 main 函数,只有一个 fib 函数,而 Emscripten 默认只导出 main 函数,所以在编译时加上 EXPORTED_FUNCTIONS 的配置指定导出的接口,其他同上:

复制代码

emcc fib.c -s EXPORTED_FUNCTIONS='["_fib"]' -O3 -o out/fib-emcc.wasm

编译 C/C++ 的时候函数名会默认加上 _ 前缀,所以导出的接口名是 _fib 而不是 fib 。

这次生成的包很小,把它转成文本格式后只有 27 行,代码如下:

可以看到这个包只导出一个了 _fib 函数,函数接受 i32 数字为参数,返回一个 i32 数字。想要在 JS 环境里运行起来这个包,需要用 js 代码来加载执行这个包,可以封装如下函数:

复制代码

// 编译并实例化 wasm 模块,返回导出的接口async function loadWebAssembly (filename, env) {  const filePath = path.resolve(__dirname, filename)   // 读入 wasm 文件的二进制代码  const buffer = fs.readFileSync(filePath)   // 将 wasm 包实例化并传入外部接口,因为没有外部依赖,不传 env 也可以的  const results = await WebAssembly.instantiate(buffer, {    env: Object.assign({      '__memory_base': 0,      '__table_base': 0,      memory: new WebAssembly.Memory({ initial: 256, maximum: 256 }),      table: new WebAssembly.Table({ initial: 0, maximum: 128, element: 'anyfunc' })    }, env)  })   // 返回实例化好之后的接口  if (results && results.instance) {    return results.instance.exports  }}

然后使用这个函数加载 wasm 文件:

复制代码

  console.log(apis._fib(13))  // 输出 233})

完整代码: https://github.com/Hanks10100/cpp2wasm/blob/master/loader.js

使用 wasi-sdk 编译

这次 wasi-sdk 的编译选项稍微复杂了一些:

复制代码

~/wasi-sdk-8.0/bin/clang --sysroot ~/wasi-sdk-8.0/share/wasi-sysroot fib.c \  -nostartfiles -fvisibility=hidden -Wl,--no-entry,--export=fib \  -O3 -o out/fib-wasi.wasm

第一行和第三行其实没变,只是加了第二行指定导出的接口并添加其他的优化编译选项,具体每个字段的含义(我也不懂)我就不解释了,感兴趣的话自行搜索吧。

生成的包也很小,转成文本格式后代码如下:

同样是内部定义了 fib 函数并 export 出来,同时还定义了 table 和 memory 而且把 memory 导出,table 是用来存放间接调用的函数表,memory 定义了初始内存大小,这个例子里并没用到,可删掉。

运行这个包的方式和上面一样,加上 --invoke 可以指定调用的接口,可以传递参数:

复制代码

wasmtime out/fib-wasi.wasm --invoke fib 7

上面的命令会输出 13 。

另外,因为这个例子没有外部依赖,所以生成的包对环境没什么要求,上面用 Emscripten 生成的 fib-emcc.wasm 这个包,也是可以用 Wasmtime 来执行的,调用方法一致:

复制代码

wasmtime out/fib-emcc.wasm --invoke _fib 13

简单分析

一个简单的 hello world 为什么编译出来这么多代码?编译工具到底干了啥?为什么还要有个 js 文件?

这些问题与 WebAssembly 的技术特点有关。WebAssembly 本质上讲就是一种二进制格式而已,一个 wasm 文件可以认为就是一个独立的模块,模块的包格式如下:

开头是个固定的硬编码,然后是各种 section ?,所有 section 都是可选的,其中 type section 是声明函数类型的,还有 function, code, data 这些 section 都是封装内部逻辑的,start section 声明了自执行的函数,另外还有 table, global, memory, element 等。最需要外部关注的,与外界环境交互的是 import section 和 export section,分别定义了导入和导出的接口。

简单粗暴点讲 WebAssembly 只定义了导入和导出的接口和内部的运算逻辑,想要使用到宿主环境的能力,只能声明出依赖的接口,然后由宿主环境来注入。例如发送网络请求、读写文件、打日志等等,不同宿主环境中的接口是不一样的,wasm 包里声明了一套自己想要的接口,宿主环境在实例化 wasm 模块的时候,按照 wasm 自己定义的格式,把当前环境的真实接口传递给它。

以 hello.c 为例,它有对 <stdio.h> 头文件的依赖,虽然代码没有读写文件,但是头文件里包含了这类接口,所以生成的 wasm 包里声明了需要导入 io 相关的接口。下面是 Emscripten 生成的 wasm 包对应的文本描述(不含 -O3 优化),hello-emcc.wat 文件开头的一部分:

可以看到它依赖宿主环境注册大量 env 接口,只有正确注入了这些接口才能确保 wasm 包可以正确的运行起来。在 Emscripten 同时生成的那个 js 文件里,就包含了这些接口的实现,在实例化 wasm 的时候自动注入进来,是它的内部逻辑,外部使用的时候不必关心。

这些接口都是什么玩意儿……?看起来像非标准的东西, __syscall140 和 __syscall6 分别是干啥的?不知道函数功能也不知道参数含义,不用 Emscripten 生成的 js 文件,完全不知道该怎么实例化这个 wasm 包,所以在运行 wasm 的时候就必须带上一份厚重的“js glue”。

再来看一下 wasi-sdk 生成的文件,需要导入的接口就可读多了:

里面的 proc_exit 和 fd_write 等接口,就是 wasi 定义的标准接口 ?,只要宿主环境按照规范实现了这些接口,就可以运行这个 wasm 包。而且这些 wasi 接口也不是用 js 实现的,性能更好一些,也完全不依赖 js 引擎。

其实在 Emscripten 里生成的 __syscall140 就是要查找文件,基本等价于 fd_seek , __syscall6 基本等价于 fd_close ,但是前者没有语义而且非标准,强依赖 Emscripten 生成的 js 文件才能运行,而 wasi 接口就具备了更好的性能和跨平台能力。这就是标准化的力量,也是 WebAssembly 的一个发展方向。

性能对比

就不到十行代码还好意思做性能对比…… 我觉得低于 200 行代码跑出来的性能测试都不太靠谱。而且执行 js 和执行 wasm 的链路不一样,编译工具的优化程度不一样,编译出来的包依赖的接口也不一样,太多不确定性,测出的数据里都是噪声。在之后的文章里,我会用复杂的例子来测试 WebAssembly 的性能。(挖坑)

接下来干什么

文章写的很浅显,目的是让不懂 C/C++ 不懂 WebAssembly 可以快速入门。我觉得 WebAssembly 目前的一个问题是没有很明确、很具体的使用场景,大部分人都或多或少了解这个技术,知道整体的发展方向,但是觉得无从下手,最多是在某个环节中做小规模尝试。

我也尝试着把一个完整的 C++ 项目(约 2W+ 行代码)编译成了 WebAssembly,并且能在浏览器和 Node.js 环境里跑起来,只是为了深入研究 WebAssembly 这项技术,未必是一个很适合使用 WebAssembly 的场景。写 demo 和真的把 WebAssembly 用起来,中间的差距还是很大的,这篇文章是一个引子,我在下一篇文章里详细介绍一下我在过程中遇到的问题和解决方案。

文中链接:

1 https://webassembly.org/

2 https://github.com/WebAssembly/spec

3 https://developer.mozilla.org/en-US/docs/WebAssembly

4 https://hacks.mozilla.org/author/lclarkmozilla-com/

5 https://emscripten.org/index.html

6 https://wasi.dev/

7 https://github.com/bytecodealliance/wasmtime

8 https://github.com/bytecodealliance/wasm-micro-runtime

9 https://github.com/CraneStation/wasi-sdk

10 https://github.com/emscripten-core/emscripten/wiki/WebAssembly-Standalone

11 https://github.com/WebAssembly/wabt

12 http://webassembly.github.io/spec/core/binary/modules.html#sections

13 https://github.com/bytecodealliance/wasmtime/blob/master/docs/WASI-api.md

相关推荐

PS小技巧 调整命令,让人物肤色变得更加白皙 #后期修图

我们来看一下如何去将人物的皮肤变得更加的白皙。·首先选中图层,Ctrl键加J键复制一层。·打开这里的属性面板,选择快速操作删除背景,这样就会将人物进行单独的抠取。·接下来在上方去添加一个黑白调整图层,...

把人物肤色提亮的方法和技巧

PS后期调白肤色提亮照片的方法。一白遮百丑,所以对于Photoshop后期来说把人物肤色调白是一项非常重要的任务。就拿这张素材图片来说,这张素材图片人脸的肤色主要偏红、偏黄,也不够白皙,该怎样对它进行...

《Photoshop教程》把美女图片调成清爽色彩及润肤技巧

关注PS精品教程,每天不断更新~~室内人物图片一般会偏暗,人物脸部、肤色及背景会出现一些杂点。处理之前需要认真的给人物磨皮及美白,然后再整体润色。最终效果原图一、用修补工具及图章工具简单去除大一点的黑...

PS后期对皮肤进行美白的技巧

PS后期进行皮肤美白的技巧。PS后期对皮肤进行美白的技巧:·打开素材图片之后直接复制原图。·接下来直接点击上方的图像,选择应用图像命令。·在通道这里直接选择红通道,混合这里直接选择柔光,然后点击确定。...

493 [PS调色]调模特通透肤色

效果对比:效果图吧:1、光位图:2、拍摄参数:·快门:160;光圈:8;ISO:1003、步骤分解图:用曲线调整图层调出基本色调。用可选颜色调整图层调整红色、黄色、白色和灰色4种颜色的混合比例。用色彩...

先选肤色再涂面部,卡戴珊的摄影师透露:为明星拍完照后怎么修图

据英国媒体12月17日报道,真人秀明星金·卡戴珊终于承认,她把女儿小北P进了家族的圣诞贺卡,怪不得粉丝们都表示这张贺卡照得非常失败。上周,这位39岁的女星遭到了一些粉丝针对这张照片的批评,她于当地时间...

如何在PS中运用曲线复制另一张照片的色调

怎样把另一张作品的外观感觉,套用到自己的照片上?单靠肉眼来猜,可能很不容易,而来自BenSecret的教学,关键是在PS使用了两个工具,让你可以准确比较两张照片的曝光、色调与饱和度,方便你调整及复制...

PS在LAB模式下调出水嫩肤色的美女

本PS教程主要使用Photoshop使用LAB模式调出水嫩肤色的美女,教程调色比较独特。作者比较注重图片高光部分的颜色,增加质感及肤色调红润等都是在高光区域完成。尤其在Lab模式下,用高光选区调色后图...

在Photoshop图像后期处理中如何将人物皮肤处理得白皙通透

我们在人像后期处理中,需要将人物皮肤处理的白皙通透,处理方法很多,大多数都喜欢使用曲线、磨皮等进行调整,可以达到亮但是不透,最终效果往往不是很好,今天就教大家一种如何将任务皮肤处理得白皙通透,希望能帮...

PS调色自学教程:宝宝照片快速调通透,简单实用!

PS调色自学教程:宝宝照片快速调通透。·首先复制图层,然后选择进入ACR滤镜,选择曲线锁定照片的亮部,也就高光位置,其他部位补亮一点,尤其是阴影的部位补亮多一些,让画面的层次均匀一点。·然后回到基本项...

【干货】如何利用PS进行人物美化

人物图像美化在Photoshop中非常常用,Photoshop作为一款功能强大的图像处理软件,不仅可以对人像进行基本的调色、美化和修复等处理,还可以改变人物的线条和幅度,如调整脸部器官和脸型的大小、调...

教大家一种可以快速把肤色处理均匀的方法@抖音短视频

快速把肤色处理均匀的方法。今天教大家一种可以快速把肤色处理均匀的方法。像这张照片整体肤色走紫红色,但是局部偏黄缘处理起来非常的麻烦。其实我们只需要新建空白图层,图层混合模式更改为颜色,再选择画笔工具把...

PS调色教程 利用RAW调出干净通透的肤色

要么不发,要么干货。后期教程来噜~用RAW调出干净通透的肤色。这次终于不会原片比PS后好看了吧。如果你依然这么觉得,请不要残忍的告诉我这个事实,泪谢TAT)附送拍摄花絮,感谢各位的支持更多风格请关注m...

photoshop后期皮肤变白的技巧

PS后期皮肤变白的技巧。1.PS后期让皮肤变白的方法有很多种,接下来教你一种非常简单容易上手的方法。2.打开素材图片之后,直接在小太极下拉框的位置添加一个纯色调整图层,颜色设置一个纯白色,点击...

Photoshop调出人物的淡雅粉嫩肤色教程

本教程主要使用Photoshop调出人物的淡雅粉嫩肤色教程,最终的效果非常的通透迷人,下面让我们一起来学习.出自:86ps效果图:原图:1、打开原图复制一层。2、用Topaz滤镜磨皮(点此下载)。3、...