15.5 LLVM、WebAssembly 与可移植后端
为每种 CPU 独立实现指令选择(instruction selection)、寄存器分配(register allocation)、目标代码发射(object emission)与底层优化(optimization)的研发成本是极高的。可复用的编译后端(portable backends)允许高级语言的前端(frontend)直接降低(lower)到定义良好的统一中间表示(intermediate representation/IR),从而高效共享成熟的目标机器后端架构(target machinery)。LLVM 是极具开销价值的原生语言编译器基础设施;WebAssembly 是目前广泛采用的可移植二进制指令格式(binary instruction format)与执行沙箱。它们解决了可移植性问题的不同层次,并且可以在各大编译器架构中完美配合。
LLVM IR 是带类型、基于 SSA,且感知目标平台特性(target-aware)的可移植 IR,既可以自如表达极低级的底层硬件操作,同时又不强绑定到具体的某一厂商机器指令集上。前端主要负责发射出符合 LLVM 规格的函数(functions)、全局变量(globals)、控制流结构(control flow)、内存读写(memory operations)、函数调用(calls)、属性标签(attributes)、元数据(metadata)以及目标数据布局(target data layout)。LLVM 则执行全面的分析与变换优化(analysis/transformation),把结果经过特定后端的机器中间表示(machine IR)降低,随后进行指令选择、寄存器分配、代码调度,最后发射出特定平台的汇编代码或目标二进制文件(assembly/object files)。
可复用后端仍需要精确的前端契约
考虑源语言中一次简单的数组访问(array access)。前端编译器必须在 IR 生成阶段决定:索引值(index)是否有符号、是否会发生溢出(overflow)、边界越界故障(bounds failure)是直接触发硬件陷阱中断(trap)还是抛出高级语言级别的异常(throw)、元素在内存中的布局方式,以及可以使用哪些别名分析规则(aliasing rules)。LLVM 本身在纯图层面是无法单凭一条 add 与 load IR 底层指令就安全地反向推断出这些高级语言契约的。任何在 IR 层面错误配置或滥用了诸如 nsw(无符号溢出未定义)、nuw、nonnull、dereferenceable 标记、对齐属性(alignment)或指针别名元数据(alias metadata)的行为,都会驱使底层优化器进行完全合法、但在当前高级语言看来会误编译正确源程序的灾难性代码变换。
目标平台的数据布局契约(data layout)详细描述了指针大小(pointer size)、整数与向量的对齐方式(integer/vector alignment)、字节序(byte order)与合法的内存地址空间。高级语言前端在降解结构体时计算的内存结构布局必须与此完全一致。目标平台三元组(Target triple)精确选择了底层目标 CPU 架构、销售厂商、操作系统以及底层的 ABI 环境。诸如函数调用约定(Calling conventions)、异常处理个性(exception personality)、线程局部存储(TLS)、原子指令形式与生成的目标二进制文件格式,都必须在 Chapter 14 所述的运行时与链接器(runtime/linker)中保持绝对一致。
对于带有自动垃圾回收(GC)的托管语言,后端还需要额外配合。移动式垃圾回收器(Moving collector)必须在每一个执行安全点(safepoint)能精确扫描到所有的活跃托管引用,这需要前端合理生成状态点(statepoints)、栈映射(stack maps)、间接句柄(handles)或配置专用的自定义优化降低(custom lowering)。异常处理(Exception)需要着陆区(landing pad)与特殊的异常判定例程。诸如高级语言自带的协程(Coroutine)、动态多分发与反优化(deoptimization)也都需要明确的底层运行时接口(runtime interface)支持;仅仅引入一套开源的 LLVM 并不等同于在本地自动创造了这些复杂的基建设施。
WebAssembly 定义经过验证的可移植机器
WebAssembly(Wasm)模块(module)包含带类型的函数(typed functions)、表格(tables)、全局变量(globals)、线性内存(linear memories)、导入(imports)、导出(exports)及可选的自定义段(custom sections)。核心指令使用结构化控制流与栈式类型规约。在执行前,宿主环境(host)会严格验证指令类型、分支深度、索引、功能特性使用及栈状态是否全部合法。验证(Validation)机制使后续的代码编译极其安全,同时也支持流式加载(streaming):运行时(runtime)可以在二进制字节源源不断到达的同时,逐个对函数体(function body)进行解码与编译。
线性内存(Linear memory)是由 Wasm 代码直接寻址且带有界限检查的字节数组。除非嵌入环境显式与之共享特定的系统能力(capability),否则这块内存与宿主环境中的普通垃圾回收对象是完全隔离的。模块不能仅凭其内部包含相关指令,就擅自打开本地文件、访问 DOM 树或直接发起网络连接——核心 Wasm 规范(Core Wasm)中并没有这些环境及系统操作(ambient operations)。宿主环境负责显示地提供导入的函数、内存、表格或全局变量。这种面向能力的隔离边界(capability-oriented boundary)正是 Wasm 虚拟沙箱的核心。
WASI(WebAssembly 系统接口)为非浏览器环境规范了多组底层的系统接口(system interfaces),但具体的可用性与安全性约定仍深度受控于宿主的系统策略(policy)。浏览器中的 JavaScript API 使用不同的适配器(adapter)。诸如 SIMD 拓扑指令、多线程、引用类型(reference types)、垃圾回收(GC)、异常处理、尾调用与组件模型接口等新特性(features)都需要通过复杂的协商约定(negotiation)支持;产生端(producer)不能盲目假设每一个执行引擎都启用了所有的草案提案(proposal)。
可移植性也包含运行时与部署行为
前端(Frontend)编译器不仅可以利用 LLVM 为 x86-64、AArch64 与 RISC-V 架构生成原生机器二进制文件(native binary),同时也可用 LLVM 的 Wasm 目标平台(target)直接编译生成 wasm32 或 wasm64 模块。尽管这能无缝复用大部分的目标无关优化,但在底层,由于架构特性导致的差异仍然存在。Wasm 采用结构化分支并采用栈操作来表达虚拟寄存器;而原生目标平台则直接暴露物理寄存器堆(register file)以及平台 ABI。其指针宽度、原子操作、SIMD 通道、异常处理策略与调用边界(calling boundary)的迥异,都会使得最终生成的指令降低(lowering)有巨大的区别。
可移植的执行还需要稳健统一的模块接口(module interface)。原始的 Wasm 数字类型导入非常低级;在传输诸如字符串(string)、结构记录(record)、外部资源句柄(resource)与错误类型(error)时,编译各方需要提前约定高级的复合 ABI 或一套显式的接口描述语言。组件模型及绑定语言生成工具(Component/binding)可以跨语言自动适配这些值,但在底层的对象所有权与生命周期上,仍然必须保持显式的声明。JavaScript 到 Wasm 的互操作调用往往比模块内部调用要昂贵得多,因此必须仔细测量这些细粒度跨边界调用流量(boundary traffic)对底层的拖慢效应。
安全保护在任何时候都需要深度防御策略(defense in depth)。Wasm 字节码校验与界限检查能强制约束恶意模块的行为,而宿主环境(host)还需要进一步最小化主动导入(imports)的服务、严格限制内存/执行资源配额、校验不可信的输入(input),并在系统外部彻底隔离原生系统扩展。运行时 JIT 执行引擎自身仍要以 W^X(写或执行,不可并存)保护动态生成的代码内存页(generated-code pages),并需要防范编译器层面的实现缺陷或漏洞(compiler implementation)。虚拟沙箱永远无法让被有意甚至恶意授权的越权写文件等系统函数自动变得安全。
可移植后端的质量应当通过多维度的矩阵测试来进行系统性考量,测试覆盖:目标三元组(target triple)、各个级别的优化系数、受支持的字节序、不同的指针宽度、新旧特性组合、运行时(runtime)内核与宿主适配器。测试流程应当应用 IR 验证校验(IR verification)、目标二进制代码检查(object inspection)、Wasm 校验器验证(Wasm validation)、差分执行测试(differential execution)、确定性编译(deterministic build)以及语言规范一致性测试套件(conformance suite)。可移植性从来不单单意味着“在所有平台使用完全相同的一个 binary 字节”,而是在底层运行能力和运行成本存在天然差异的后端平台上,完美保留并交付已声明的精确语言行为契约。