在没有Emscripten的WebAssembly中编译C

编译器是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: # …,  … systemz - SystemZ thumb - Thumb thumbeb - Thumb (big endian) wasm32 - WebAssembly 32-bit # ! ! ! wasm64 - WebAssembly 64-bit x86 - 32-bit X86: Pentium-Pro and above x86-64 - 64-bit X86: EM64T and AMD64 xcore - XCore 

看来我们已经准备好了!

艰难地编译C


注意:这是一些低级RAW WebAssembly格式。 如果您难以理解,这是正常现象。 正确使用WebAssembly不需要理解本文的全文。 如果您正在寻找用于复制粘贴的代码,请参见“优化”部分中对编译器的调用 。 但是,如果您有兴趣,请继续阅读! 之前,我曾写过一篇关于纯Webassembly和WAT的介绍:这些是理解本文的基础知识。
警告:我将略微偏离标准,并尝试在每个步骤(尽可能)中使用人类可读的格式。 我们的程序在这里将非常简单,以避免边界情况并且不会分散注意力:

 // Filename: add.c int add(int a, int b) { return a*a + b; } 

多么伟大的工程壮举! 特别是因为该程序称为add ,但实际上它不添加任何内容(不添加)。 更重要的是:该程序不使用标准库,此处的类型仅是'int'。

将C转换为内部LLVM视图


第一步是将我们的C程序转换为LLVM IR。 这是随LLVM一起安装的clang前端编译器的任务:

 clang \ --target=wasm32 \ # Target WebAssembly -emit-llvm \ # Emit LLVM IR (instead of host machine code) -c \ # Only compile, no linking just yet -S \ # Emit human-readable assembly rather than binary add.c 

结果,我们得到带有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 # in case you haven't $ wasm-objdump -x add.o add.o: file format wasm 0x1 Section Details: Type[1]: - type[0] (i32, i32) -> i32 Import[3]: - memory[0] pages: initial=0 <- env.__linear_memory - table[0] elem_type=funcref init=0 max=0 <- env.__indirect_function_table - global[0] i32 mutable=1 <- env.__stack_pointer Function[1]: - func[0] sig=0 <add> Code[1]: - func[0] size=75 <add> Custom: - name: "linking" - symbol table [count=2] - 0: F <add> func=0 binding=global vis=hidden - 1: G <env.__stack_pointer> global=0 undefined binding=global vis=default Custom: - name: "reloc.CODE" - relocations for section: 3 (Code) [1] R_WASM_GLOBAL_INDEX_LEB offset=0x000006(file=0x000080) symbol=1 <env.__stack_pointer> 

输出显示我们的add()函数在此模块中,但它还包含带有元数据的自定义节,并且令人惊讶地有几个导入。 在链接的下一阶段,将分析和删除自定义节,并且链接器(链接器)将处理导入。

布局图


传统上,链接器的任务是将几个目标文件组装成一个可执行文件。 LLVM链接器称为lld ,并使用目标符号链接进行调用。 对于WebAssembly,这是wasm-ld

 wasm-ld \ --no-entry \ # We don't have an entry function --export-all \ # Export everything (for now) -o add.wasm \ add.o 

结果是一个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 \ # Don't try and link against a standard library -Wl,--no-entry \ # Flags passed to the linker -Wl,--export-all \ -o add.wasm \ add.c 

在这里,我们获得相同的.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.loadi32.store看到),八个局部变量和几个全局变量。 可能您可以手动编写一个更简洁的版本。 这个程序很大,因为我们没有应用任何优化。 让我们做吧:

 clang \ --target=wasm32 \ + -O3 \ # Agressive optimizations + -flto \ # Add metadata for link-time optimizations -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \ + -Wl,--lto-O3 \ # Aggressive link-time optimizations -o add.wasm \ add.c 

注意:从技术上讲,布局优化(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库 。 有几种合适的方法,特别是glibcmuslDietlibc 。 但是,大多数这些库都应该在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] \ # Set maximum stack size to 8MiB -o add.wasm \ add.c 

分配器组件


已知堆区域以__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) { // lol } 

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]; // Allocate memory for 5 32-bit integers // and return get starting address. const cArrayPointer = instance.exports.malloc(jsArray.length * 4); // Turn that sequence of 32-bit integers // into a Uint32Array, starting at that address. const cArray = new Uint32Array( instance.exports.memory.buffer, cArrayPointer, jsArray.length ); // Copy the values from JS to C. cArray.set(jsArray); // Run the function, passing the starting address and length. console.log(instance.exports.sum(cArrayPointer, cArray.length)); } init(); </script> 

开始后,您应该在DevTools控制台中看到答案15,它实际上是从1到5的所有数字的总和。

结论


所以,您读到最后。 恭喜你! 同样,如果您感到有点超负荷,则一切正常。 不必阅读所有详细信息。 对于一个好的Web开发人员来说,了解它们是完全可选的,并且对于WebAssembly的出色使用甚至不是必需的 。 但是我想分享这些信息,因为它使您能够真正欣赏像Emscripten这样的项目为您所做的所有工作。 同时,这使您了解WebAssembly的纯计算模块可以有多小。 用于对数组求和的Wasm模块只有230个字节, 其中包括一个动态内存分配器 。 使用Emscripten编译相同的代码将产生100字节的WebAssembly代码和11K JavaScript链接代码。 您需要尝试这样的结果,但是在某些情况下值得这样做。

Source: https://habr.com/ru/post/zh-CN454868/


All Articles