WebAssembly原理与核心技术

第一部分 概述

1. wasm介绍

js从玩具脚本到web开发语言的变化,离不开语言的标准化过程(特别是ECMAScript 2015,即ES6)和各大浏览器对js引擎的不断改进(特别是JIT技术),但是js的性能依然不能和本地应用比,所以几个主要浏览器厂商联合发布了WebAssembly

1.1 wasm简史

和go,rust等其他项目一样,wasm也起源于一个业余时间项目.2010年,Alon Zakai想把自己以前开发的游戏引擎移植到浏览器上允许,他认为js执行速度足够,就在业余时间编写编译器,把c++代码(通过LLVM IR)编译成js代码,这个业余项目就是Emscripten

2011年底,项目取得很大进展,Mozilla成立研究团队.由于js太过灵活,JIT编译器很难再激进的优化,于是就在2013年,由Luke Wagner,David Herman等人提出asm.js规范,asm.js是js的严格子集,减少了很多动态特性,并添加类型提示,从而帮助浏览器提升js优化空间.asm.js更适合作为编译器目标语言

asm.js代码可以跨浏览器,支持asm.js的浏览器就启用激进的JIT优化,甚至是AOT编译,而不能支持的浏览器也可以当作普通的js代码允许

但是asm.js也有缺点,那就是不够底层,如代码依然是文本、编写受限于js语法、浏览器仍然需要完成解释脚本、解释执行、收集性能指标、JIT编译等一系列步骤,如果是二进制格式的文件,可以缩小体积、减少网络传输和解析时间、选用更接近机器的字节码、AOT/JIT编译器实现起来更轻松

谷歌chrome团队则从另一个方向出发,他们的解决方案是NaCl(google native client)和PNaCl(Portable NaCl),在沙箱环境里直接执行本地代码,asm.js和NaCl互相补充,两个团队经常合作

Mozilla和Google结合两个项目的长处,合作开发了一种基于字节码的技术,WebAssembly

2017年,谷歌决定放弃PNaCl,Mozilla也基本放弃asm.js,转向支持wasm,本书完稿时,wasm已经发布了1.1版本

1.2 wasm简介

一开始,都是用机器语言写程序,后来为了提高开发效率,出现了汇编语言和高级语言,但是性能越来越低(相比机器语言)

高级语言要通过编译器编译成机器码或通过解释器直接解释执行,前者可以理解为AOP预先编译,后者可以在执行时将部分热代码即时编译成机器码执行,以提升性能(JIT)

现代编译器一般使用模块化方式设计,典型的就是分成前端、中端和后端,前端负责预处理、词法分析、语法分析、语义分析,生成便于后续处理的中间表示(IR),中端对IR进行分析和优化(如常量折叠、死代码消除、函数内联),后端生成目标代码,把IR转为平台相关的汇编代码,然后汇编器编译为机器码.

现代编译器工作原理示意图

webassembly就是web汇编,是为web浏览器定制的汇编语言,应该满足:

  1. 层次低,尽量接近机器语言,这样浏览器才更容易进行AOT/JIT编译
  2. 要适合作为目标代码,由其他高级语言编译器生成
  3. 要在浏览器运行,必须安全可控,不能像真正的汇编代码一样可以执行任意操作,并且必须是平台无关的,才能跨浏览器执行

从高级语言编译器的角度看,WASM是目标代码,从浏览器角度看,WASM更像IR,由AOP/JIT编译器编译成平台相关的机器码.所以,WASM采用了虚拟机/字节码技术,并且定义了紧凑的二进制格式

wasm特点:

  1. 规范 目前有三分规范,《核心规范描述了wasm模块的结构和语义》,这些是平台无关的,任何wasm实现都应该满足这些语义.《JavaScript API规范》和《Web API规范》,则是平台相关的.

  2. 模块 是wasm程序编译、传输和加载的单位.wasm规范定义了两种模块格式: 二进制格式和文本格式.如果和汇编语言做类比,二进制格式相当于目标文件或可执行文件格式,文本格式则相当于汇编语言.使用汇编器可以将文本格式编译为二进制格式,使用反汇编器可以将二进制格式反编译为文本格式
    (1)二进制格式: wasm模块的主要编码格式,文件一般以.wasm为后缀.
    (2)文本格式: 主要是为了开发者方便理解wasm模块,或者编写小型测试代码,文本格式可以简写为WAT(webassembly text),文件以.wat为后缀

  3. 指令集 采用栈式虚拟机和字节码

  4. 验证 wasm模块必须安全可靠,不能有恶意行为.为了保证这一点,wasm模块包含大量类型信息,绝大多数问题通过静态分析在代码执行前被发现,少数推迟到运行时检查

wasm还有另一种格式,wasm实现(如编译器)通常会把二进制模块解码为内部形式(即内存格式,如c/c++/go结构体),然后再进行后续处理

从二进制到最终执行分为3阶段: 解码、验证、执行.解码阶段将二进制模块解码为内存格式;验证阶段对模块进行静态分析,确保模块的结构满足规范要求,且函数的字节码没有不良行为;执行阶段分为实例化和函数调用两个阶段.

1.3 准备工作

下载源代码

http://github.com/zxh0/wasmgo-book

本书主要涉及GO、WAT、Rust、JavaScript,go是用来实现那WASM的

安装go

go1.11推荐安装1.14

安装rust

# 添加wasm编译目标
rustup target add wasm32-unknown-unknown

安装WABT

是wasm二进制工具箱,提供很多处理wasm二进制格式的工具.

https://www.cnblogs.com/tonghaolang/p/9253734.html

1.4 你好 Wasm

#![no_std]
#![no_main]

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

extern "C" {
    fn print_char(c: u8);
}

#[no_mangle]
pub extern "C" fn main() {  
    unsafe {
        let s = "Hello, world!\n";
        for c in s.as_bytes() { 
            print_char(*c);
        }
    }
}

编译rust为wasm

cargo build --target wasm32-unknown-unknown
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script>
        var str = ""
        var importObj = {
            env: {
                print_char: (c) => {
                    str += String.fromCharCode(c);
                    if (c == 10) {
                        alert(str);
                    }
                }
            }
        };
        fetch('ch01hw.wasm')
            .then(res => res.arrayBuffer())
            .then(buf => WebAssembly.instantiate(buf, importObj))
            .then(({ module, instance }) => instance.exports.main())
    </script>

</body>

</html>

这里是在rust里声明,然后在js里定义print_char()函数

相要看到效果,需要把wasm和html文件放到web服务器(我这里是使用Five Server,一个vscode插件)

1.5 本章小结

wasm让c、c++、rust、go也可以运行到浏览器上了,而且是接近本地程序运行的速度

第二部分 二进制和文本格式

2. 二进制格式

2.1 二进制格式介绍

2.1.2 Wasm二进制格式总体结构

wasm和java类文件、lua二进制块一样,格式也是以魔数和版本号开头.在魔数和版本号后面是模块的主体内容,这些内容被分别放在不同的端(section/segment).段中可能包含多个项目,wasm规范一共定义了12种段,并给每种段分配了ID(0-11),除了自定义段,其他每种段都最多只能出现一次,而且必须按照ID递增的顺序出现

假设将下面这段go语言编译成wasm

package main
import "fmt"

const  PI float32 = 3.14

type type0 = func(a,b int32) int32
type type1 = func()
type type2 = func(ptr, len int32)

func Add(a,b int32) int32 { return a+b }
func Sub(a,b int32) int32 { return a-b }
func Mul(a,b int32) int32 { return a*b }
func Div(a,b int32) int32 { return a/b }

func Main(){
    fmt.Println("Hello, world!")
}
2.1.2.1 类型段(ID 1)

该段列出所有wasm中用到的所有函数类型(又叫函数签名,或函数原型).上面的go代码里有3种不同签名的函数(加减乘除,main,println),所以类型段中包含3个函数签名,前两个应该是(int32,int32)->(int32)和()->()

2.1.2.2 函数段和代码段(ID 2和ID 7)

这两个段列出模块所有的导入项和导出项,多个模块可以通过导入导出链接起来.上面的go代码有1个导入项(fmt.println)和5个导出项(首字母大写的加减乘除函数和常量PI),因此导出段有一个全局变量和4个函数

2.1.2.3 函数段和代码段(ID 3和ID 10)

内部函数信息被分开存储在两个段中,函数段放的是代码段对应的签名索引,代码段放的是内部函数的局部变量信息和字节码,上面的go代码函数段内容应该是[0,0,0,0,1],分别指向加减乘除和main函数(不包含导入的函数)

2.1.2.4 表段和元素段(ID 4和ID 9)

表段列出模块内定义的所有表,元素段列出表初始化数据.wasm规范规定模块最多只能导入或定义一张表,即使模块有表段,里面也只能有一个项目.表主要和间接函数调用有关.

2.1.2.5 内存段和数据段(ID 5和11)


 上一篇
前端跨界开发指南 前端跨界开发指南
基础篇1. Mock.js: 如何与后端潇洒分手 现在的开发基本上都是前后端分离,除了在性能要求较高的场景中才可能选择 SSR 服务端渲染,前端在前后端分离的情况下,专注渲染逻辑和样式和性能等,开发依赖数据驱动,如果后端还没写好数据,则需要
2023-06-17
下一篇 
spring揭秘 spring揭秘
1. 掀起spring的盖头来 EJB1和EJB2过于厚重,导致spring轻量级框架的出现 1.1 spring之崛起 EJB需要引入拥有EJB Container的应用服务器 EJB使应用程序的部署和测试更加困难 EJB在分布式场景中
2023-06-17
  目录