本文是《WebAssembly 权威指南》系列文章第 5 篇,系列文章列表:
这篇文章是《WebAssembly 权威指南》一书的第五章,介绍了如何使用 C/C++ 和 WebAssembly 开发应用程序。文章首先讲解了 C/C++ 语言和 WebAssembly 的兼容性和互操作性,以及一些常见的问题和解决方案。文章然后介绍了几种将 C/C++ 代码编译为 WebAssembly 模块的工具和方法,如 Emscripten、Clang 和 LLVM。文章还展示了如何使用 C/C++ 编写 WebAssembly 的函数、变量、内存、表和导入导出等特性。文章最后讲解了如何在浏览器中加载和运行 WebAssembly 模块,并使用 JavaScript 与之交互。
本书的转折点来了。到目前为止,我们一直专注于 WebAssembly 相关工具和技术栈。这是探索平台的方法有用,但是作为开发工具效率低下。高级编程语言早已使我们的专业超越了低级指令集的工作细节。用句法简洁、语义丰富的语言来表达逻辑,更容易、更有效率。
要真正体会到 WebAssembly 所提供的东西,我们需要考虑可编译为 WebAssembly 的源语言。问题是,并不是每个问题都能用 JavaScript 来表达,所以可以选择使用另一种语言,因为它的性能、表达的清晰性,或者仅仅是重复使用现有的代码,都是很有吸引力的。
C 语言是世界上最重要和最广泛使用的编程语言之一。我在高中时就开始在 Atari ST 电脑上使用它。我在《计算机语言》杂志上读到过它,一个朋友给了我一本开创性的、同名的书籍 《C 编程语言 [1]》作者是 Brian Kernighan 和已故伟大的 Dennis Ritchie。
有大量的 C 语言软件可以使用,其中大部分可以简单地重新编译成 WebAssembly。我们将在第 6 章 [2] 讨论现有库的移植问题。但现在我们将学习一点 C 语言,和使用它来改进我们迄今为止所尝试的一些工作。
使用 C 语言函数
C 语言函数在很多方面与 JavaScript 函数相似。它有自己的词法结构,并不附属于像类或结构那样的大单元。它可以接受也可以不接受参数。然而,它只能返回一个单一的值,并且不支持异常,所以错误处理往往比 C++、Java 或 JavaScript 更原始一些。
在例 5-1 中,有一个 C 语言实现了我们的年龄计算函数。这个程序很简单。这个例子甚至有一些基本的错误处理方法来处理参数错误的情况,即出生年份大于当前年份的情况。除非有来自未来的穿越者出现,否则这种情况不应该发生,我们应该处理它。更高级别的语言只是更容易来表达业务逻辑。
例 5-1. 一个简单的 C 语言程序
#include <stdio.h>
int howOld( int currentYear, int yearBorn )
{
int retValue = -1;
if ( yearBorn <= currentYear )
{
retValue = currentYear - yearBorn;
}
return(retValue);
}
int main()
{
int age = howOld( 2021, 2000 );
if ( age >= 0 )
{
printf( "You are % d!\n", age );
} else { printf( "You haven't been born yet." ); }
}
不幸的是,计算机并不理解这些高级语言,所以我们需要将它们转换为二进制机器表示,以便执行。如果你只进行过 JavaScript 编程,这个过程可能略显陌生。作为一种解释性语言,你编写 JavaScript 并简单地运行它。就像所有的事情一样,都是有取舍的。对开发者来说很方便的东西,在运行时往往会明显变慢,而 C 和 C++ 在性能上长期以来一直占据着领先地位。
鉴于 C 语言的成熟度和对我们行业的重要性,有许多优秀的商业和开源的编译器。其中包括 GNU/Linux C 编译器(GCC)和 LLVM 的 Clang 编译器。我们将专注于后者,原因很快就会清楚。参考附录 [3] 来运行安装程序。即使在默认使用 Clang 的 macOS 上,如果没有安装 LLVM 的 WebAssembly 支持,也不是所有的命令都能开箱即用。
我们可以将 C 语言程序转换为可执行文件,如下所示:
brian@tweezer ~/g/w/s/ch05> clang howold.c
brian@tweezer ~/g/w/s/ch05> ls -laF
total 112
drwxr-xr-x 4 brian staff 128 Feb 14 14:35 ./
drwxr-xr-x 6 brian staff 192 Feb 14 14:32 ../
-rwxr-xr-x 1 brian staff 49456 Feb 14 14:35 a.out*
-rw-r--r-- 1 brian staff 343 Feb 14 14:36 howold.c
由于历史原因,生成的可执行文件被称为 a.out。你将在后面看到如何改变它。现在,我们可以执行该程序。
brian@tweezer ~/g/w/s/ch05> ./a.out
You are 21!
这是因为生成的可执行文件已经变成了 macOS 知道如何运行的合适格式。它是一个针对 64 位平台上的英特尔 x86 指令集的 Mach-O 可执行文件。
brian@tweezer ~/g/w/s/ch05> file a.out
a.out: Mach-O 64-bit executable x86_64
这个程序不能在 Windows 或 Linux 机器上运行。如果没有新的仿真层,它甚至不能在苹果新的基于 ARM 的机器上运行。这是因为 CPU 有一个指令集,涉及到将数值加载到寄存器中,在 CPU 上调用功能,并将结果存储在内存中。重新运行 clang 以支持 duce 汇编语言输出,而不是二进制可执行文件:
brian@tweezer ~/g/w/s/ch05> clang -S howold.c
产生的文件如例 5-2 所示。
例 5-2. 为我们的简单应用程序生成的汇编语言
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 13, 0 sdk_version 13, 1
.globl _howOld ## -- Begin function howOld
.p2align 4, 0x90
_howOld: ## @howOld
.cfi_startproc
## % bb.0:
pushq % rbp
.cfi_def_cfa_offset 16
.cfi_offset % rbp, -16
movq % rsp, % rbp
.cfi_def_cfa_register % rbp
movl % edi, -4 (% rbp)
movl % esi, -8 (% rbp)
movl $-1, -12 (% rbp)
movl -8 (% rbp), % eax
cmpl -4 (% rbp), % eax
jg LBB0_2
## % bb.1:
movl -4 (% rbp), % eax
subl -8 (% rbp), % eax
movl % eax, -12 (% rbp)
LBB0_2:
movl -12 (% rbp), % eax
popq % rbp
retq
.cfi_endproc
## -- End function
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## % bb.0:
pushq % rbp
.cfi_def_cfa_offset 16
.cfi_offset % rbp, -16
movq % rsp, % rbp
.cfi_def_cfa_register % rbp
subq $16, % rsp
movl $0, -4 (% rbp)
movl $2021, % edi ## imm = 0x7E5
movl $2000, % esi ## imm = 0x7D0
callq _howOld
movl % eax, -8 (% rbp)
cmpl $0, -8 (% rbp)
jl LBB1_2
## % bb.1:
movl -8 (% rbp), % esi
leaq L_.str (% rip), % rdi
movb $0, % al
callq _printf
jmp LBB1_3
LBB1_2:
leaq L_.str.1 (% rip), % rdi
movb $0, % al
callq _printf
LBB1_3:
movl -4 (% rbp), % eax
addq $16, % rsp
popq % rbp
retq
.cfi_endproc
## -- End function
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz "You are % d!\n"
L_.str.1: ## @.str.1
.asciz "You haven't been born yet."
.subsections_via_symbols
正如你所看到的,它比我们的 C 程序要啰嗦得多。像函数调用、循环和条件检查这样的高级结构需要许多低级别的指令来表达。我们将需要一个实际的英特尔 x86 芯片来运行,或者至少是一个仿真的芯片。然而,在某种程度上,这与我们在前几章看到的 Wat 文件在概念上是相似的。
我们将 Clang 作为 C 编译器例子的主要原因是,它有一个基于 LLVM 项目的现代、可插拔的架构。在现代世界中,越来越多的竞争性指令集(如 x86、ARM、RISC-V)、新的编程语言(如 Rust、Julia、Swift),以及无论何种源语言都希望重复使用通用的优化,这一点是非常重要的。
在图 5-1 中,你可以看到这一过程包括三个环节。源代码被一个前端处理步骤解析。这将是特定于语言的。此步骤的输出是一个中间表示(IR),一个假设的但不是真实的机器的指令集。它以一种可以被优化层操作的格式捕获了所表达的逻辑。这个过程涉及到应用一个或多个转换 ,这些转换可以使代码更快、更有效。循环可以被展开。不使用的代码可能被删除,涉及常量的表达式可能被编译器评估,因此它们不需要在运行时评估,等等。最后一步是释放出一套本地的针对一个特定的运行时的指令。对于我们的目的,这显然是 Mach-O x86 64 位架构。
这些层中的任何一个都可以被替换成其他东西。正如我所提到的,Rust、Julia 和 Swift 等语言 ,都使用 LLVM 基础设施。这使语言作者不必每次都从头开始。他们需要编写新的前端解析器,但可以利用大部分现有的优化和后端工作。编译器研究人员可以开发新的优化,并在使其可用于任意输入语言的 IR 之前对其进行隔离测试。对于我们的目的,后端是最重要的可交换层。在 Linux 或 Windows 上,可以使用相同的前两层的本地版本,但也会有一个特定机器的后端。
你通常可以通过一个被称为交叉编译的过程,生成一个与你的计算机的本地运行时不同的后端。这对于针对可能没有安装开发者工具链的嵌入式系统很有用。这在持续集成和交付系统中也很有用,你可以从同一个构建环境中针对多个平台构建。否则,你可能需要为每个目标