编译器是
Emscripten的一部分。 但是,如果您删除所有哨子并只留下它,该怎么办?
Emscripten是将C / C ++编译成
WebAssembly所必需的。 但这不仅仅是编译器。 Emscripten的目标是完全替换您的C / C ++编译器,并在最初
不是为Web
设计的Web上运行代码。 为此,Emscripten模拟了整个POSIX操作系统。 如果程序使用
fopen() ,则Emscripten将提供文件系统仿真。 如果使用OpenGL,则Emscripten将提供
WebGL支持的C兼容GL上下文。 这是很多工作,并且很多代码都必须在最终软件包中实现。 但是您能...删除它吗?
Emscripten工具箱中的实际
编译器是LLVM。 是他将C代码转换为WebAssembly字节码。 这是用于程序分析,转换和优化的现代模块化框架。 LLVM绝对是模块化的,因为它永远不会直接编译成机器代码。 而是,内置的
前端编译器生成
中间表示 (IR)。 实际上,这种中间表示称为LLVM,是低级虚拟机的缩写,因此是项目的名称。
然后,
后端编译器将IR转换为主机代码。 这种严格分离的优势在于,通过“简单”添加新的编译器来支持新的体系结构。 从这个意义上讲,WebAssembly只是LLVM支持的众多编译目标之一,并且一段时间以来,它已经被一个特殊的标志激活了。 从LLVM 8开始,WebAssembly编译目标默认为可用。
在MacOS上,您可以使用
homebrew安装LLVM:
$ brew install llvm $ brew link --force llvm
检查WebAssembly支持:
$ llc --version LLVM (http://llvm.org/): LLVM version 8.0.0 Optimized build. Default target: x86_64-apple-darwin18.5.0 Host CPU: skylake Registered Targets:
看来我们已经准备好了!
艰难地编译C
注意:这是一些低级RAW WebAssembly格式。 如果您难以理解,这是正常现象。 正确使用WebAssembly不需要理解本文的全文。 如果您正在寻找用于复制粘贴的代码,请参见“优化”部分中对编译器的调用 。 但是,如果您有兴趣,请继续阅读! 之前,我曾写过一篇关于纯Webassembly和WAT的介绍:这些是理解本文的基础知识。
警告:我将略微偏离标准,并尝试在每个步骤(尽可能)中使用人类可读的格式。 我们的程序在这里将非常简单,以避免边界情况并且不会分散注意力:
多么伟大的工程壮举! 特别是因为该程序称为
add ,但实际上它不
添加任何内容(不添加)。 更重要的是:该程序不使用标准库,此处的类型仅是'int'。
将C转换为内部LLVM视图
第一步是将我们的C程序转换为LLVM IR。 这是随LLVM一起安装的
clang
前端编译器的任务:
clang \ --target=wasm32 \
结果,我们得到带有LLVM IR的内部表示的
add.ll
我仅出于完整性目的显示它 。 使用WebAssembly甚至clang时,作为C开发人员,您永远都不会接触LLVM IR。
; ModuleID = 'add.c' source_filename = "add.c" target datalayout = "em:ep:32:32-i64:64-n32:64-S128" target triple = "wasm32" ; Function Attrs: norecurse nounwind readnone define hidden i32 @add(i32, i32) local_unnamed_addr #0 { %3 = mul nsw i32 %0, %0 %4 = add nsw i32 %3, %1 ret i32 %4 } attributes #0 = { norecurse nounwind readnone "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-frame-pointer-elim"="false" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="generic" "unsafe-fp-math"="false" "use-soft-float"="false" } !llvm.module.flags = !{!0} !llvm.ident = !{!1} !0 = !{i32 1, !"wchar_size", i32 4} !1 = !{!"clang version 8.0.0 (tags/RELEASE_800/final)"}
LLVM IR充满了其他元数据和注释,使编译器在生成机器代码时可以做出更明智的决策。将LLVM IR转换为目标文件
下一步是调用
llc
后端编译器,以使用内部表示形式创建目标文件。
add.o
输出文件已经是一个有效的WebAssembly模块,其中包含我们C文件的所有已编译代码。但是通常,您将无法运行目标文件,因为它们缺少必要的部分。
如果在命令中省略了
-filetype=obj
,则将获得WebAssembly的LLVM汇编器,这是一种类似于WAT的人类可读格式。 但是,用于处理此类文件的
llvm-mc
工具尚未完全支持该格式,并且通常无法处理文件。 因此,事实发生后,我们将反汇编目标文件。 需要使用特定工具来验证这些目标文件。 对于WebAssembly,它是
WebAssembly Binary Toolkit的一部分,
wasm-objdump
。
$ brew install wabt
输出显示我们的add()函数在此模块中,但它还包含带有元数据的
自定义节,并且令人惊讶地有几个导入。 在
链接的下一阶段,将分析和删除自定义节,并且链接器(链接器)将处理导入。
布局图
传统上,链接器的任务是将几个目标文件组装成一个可执行文件。 LLVM链接器称为
lld
,并使用目标符号链接进行调用。 对于WebAssembly,这是
wasm-ld
。
wasm-ld \ --no-entry \
结果是一个WebAssembly模块,大小为262个字节。
发射
当然,最重要的是要看到一切都
真的有效。 与
上一篇文章一样 ,您可以使用几行嵌入式JavaScript来加载和运行此WebAssembly模块。
<!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); console.log(instance.exports.add(4, 1)); } init(); </script>
如果一切正常,您将在DevTool控制台中看到数字17,
我们已经成功地将C编译到WebAssembly中,而无需触摸Emscripten。 还值得注意的是,没有用于配置和加载WebAssembly模块的中间件。
编译C有点简单
为了在WebAssembly中编译C,我们采取了许多步骤。 正如我所说,出于教育目的,我们详细检查了所有阶段。 让我们跳过人类易于理解的中间格式,并立即将C编译器应用为开发的瑞士军刀:
clang \ --target=wasm32 \ -nostdlib \
在这里,我们获得相同的
.wasm
文件,但使用一个命令。
最佳化
通过运行
wasm2wat
看看我们的WebAssembly模块的WAT:
(module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) (local i32 i32 i32 i32 i32 i32 i32 i32) global.get 0 local.set 2 i32.const 16 local.set 3 local.get 2 local.get 3 i32.sub local.set 4 local.get 4 local.get 0 i32.store offset=12 local.get 4 local.get 1 i32.store offset=8 local.get 4 i32.load offset=12 local.set 5 local.get 4 i32.load offset=12 local.set 6 local.get 5 local.get 6 i32.mul local.set 7 local.get 4 i32.load offset=8 local.set 8 local.get 7 local.get 8 i32.add local.set 9 local.get 9 return) (table (;0;) 1 1 anyfunc) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 66560)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "__heap_base" (global 1)) (export "__data_end" (global 2)) (export "__dso_handle" (global 3)) (export "add" (func $add)))
哇,代码真棒。 令我惊讶的是,该模块使用内存(从
i32.load
和
i32.store
看到),八个局部变量和几个全局变量。 可能您可以手动编写一个更简洁的版本。 这个程序很大,因为我们没有应用任何优化。 让我们做吧:
clang \ --target=wasm32 \ + -O3 \
注意:从技术上讲,布局优化(LTO)没有任何好处,因为我们只编写一个文件。 在大型项目中,LTO将有助于大大减小文件大小。
执行以下命令后,
.wasm
文件从262字节减少到197个字节,WAT也变得更加简单:
(module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) local.get 0 local.get 0 i32.mul local.get 1 i32.add) (table (;0;) 1 1 anyfunc) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 66560)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "__heap_base" (global 1)) (export "__data_end" (global 2)) (export "__dso_handle" (global 3)) (export "add" (func $add)))
调用标准库
在没有标准libc库的情况下使用C似乎很不礼貌。 添加它是合乎逻辑的,但是老实说:这
并不容易。
实际上,我们不会在本文中直接调用任何libc库 。 有几种合适的方法,特别是
glibc ,
musl和
Dietlibc 。 但是,大多数这些库都应该在POSIX操作系统中运行,该操作系统实现了一组特定的系统调用。 由于我们在JavaScript中没有内核接口,因此我们可能必须通过JavaScript来实现这些POSIX系统调用。 这是一项艰巨的任务,我在这里不打算做。 好消息是,
这是Emscripten为您所做的 。
当然,并非所有的libc函数都依赖于系统调用。 诸如
strlen()
,
sin()
或什至
memset()
类的
memset()
都在简单的C语言中实现。这意味着您可以使用这些函数,甚至只是从提到的某些库中复制/粘贴其实现。
动态记忆
没有libc,C基本接口(例如
malloc()
和
free()
将无法使用。 在未优化的WAT中,我们看到编译器在必要时使用内存。 这意味着我们不能随便使用内存,而不必冒损坏内存的风险。 您需要了解其用法。
LLVM内存模型
WebAssembly内存分段方法将使经验丰富的程序员感到有些惊讶。 首先,在WebAssembly中,从技术上讲允许使用空地址,但通常仍将其视为错误。 其次,堆栈首先出现并向下扩展(到较低的地址),而堆随后出现并向上增长。 原因是WebAssembly的内存可能会在运行时增加。 这意味着没有固定端可以容纳堆栈或堆。
这是
wasm-ld
布局:
堆栈变小,堆变大。 堆栈以__data_end
,堆以__heap_base
。 因为堆栈放在最前面,所以它受编译期间设置的最大大小限制,即__heap_base
减去__data_end
如果回头查看WAT中的globals部分,
__heap_base
发现以下值:
__heap_base
设置为66560,而
__data_end
设置为1024。这意味着堆栈最多可以增长到64 KiB,这并不多。 幸运的是,
wasm-ld
允许您更改此值:
clang \ --target=wasm32 \ -O3 \ -flto \ -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \ -Wl,--lto-O3 \ + -Wl,-z,stack-size=$[8 * 1024 * 1024] \
分配器组件
已知堆区域以
__heap_base
。 由于缺少
malloc()
函数,因此我们可以安全地使用下一个内存区域。 我们可以根据需要将数据放置在此处,并且不必担心内存损坏,因为堆栈会朝另一个方向增长。 但是,所有人都可以免费使用的堆很快就会被阻塞,因此通常需要某种动态内存管理。 一种选择是采用malloc()的完整实现,例如Emscripten中使用的
Doug Lee的malloc实现 。 还有其他一些带有各种折衷的小型实现。
但是,为什么不编写自己的
malloc()
呢? 我们深陷泥潭,以至于没有任何区别。 最简单的一种是凹凸分配器:它超快,非常小并且易于实现。 但是有一个缺点:您不能释放内存。 尽管乍看之下,这样的分配器似乎毫无用处,但是在开发
Squoosh时,我遇到了许多先例,在这方面它是一个绝佳的选择。 凹凸分配器的概念是,我们将未使用的内存的起始地址存储为全局地址。 如果程序请求
n
个内存字节,则将标记移到
n
并返回前一个值:
extern unsigned char __heap_base; unsigned int bump_pointer = &__heap_base; void* malloc(int n) { unsigned int r = bump_pointer; bump_pointer += n; return (void *)r; } void free(void* p) {
WAT中的全局变量实际上是由
wasm-ld
定义的,因此,如果我们将它们声明为
extern
,则可以将它们作为普通变量从C代码中访问。 因此,
我们只用五行C编写了自己的malloc()
...。注意:我们的凹凸分配器与C语言中的malloc()
不完全兼容。例如,我们不提供对齐保证。 但是效果很好,所以...
动态内存使用率
为了进行测试,让我们创建一个函数C,该函数采用任意大小的数字数组并计算总和。 并不是很有趣,但这迫使我们使用动态内存,因为我们在组装过程中不知道数组的大小:
int sum(int a[], int len) { int sum = 0; for(int i = 0; i < len; i++) { sum += a[i]; } return sum; }
希望sum()函数非常清楚。 一个更有趣的问题是如何将数组从JavaScript传递到WebAssembly-毕竟,WebAssembly只理解数字。 通常的想法是使用
JavaScript中的 malloc()
分配一块内存,在其中复制值,然后传递
该数组所在的地址(数字!):
<!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); const jsArray = [1, 2, 3, 4, 5]; </script>
开始后,您应该在DevTools控制台中看到答案15,它实际上是从1到5的所有数字的总和。
结论
所以,您读到最后。 恭喜你! 同样,如果您感到有点超负荷,则一切正常。
不必阅读所有详细信息。 对于一个好的Web开发人员来说,了解它们是完全可选的,并且对于WebAssembly的出色使用甚至不是必需的 。 但是我想分享这些信息,因为它使您能够真正欣赏像
Emscripten这样的项目为您所做的所有工作。 同时,这使您了解WebAssembly的纯计算模块可以有多小。 用于对数组求和的Wasm模块只有230个字节,
其中包括一个动态内存分配器 。 使用Emscripten编译相同的代码将产生100字节的WebAssembly代码和11K JavaScript链接代码。 您需要尝试这样的结果,但是在某些情况下值得这样做。