first commit
This commit is contained in:
149
专栏/WebAssembly入门课/00开篇词我们为什么要了解WebAssembly?.md
Normal file
149
专栏/WebAssembly入门课/00开篇词我们为什么要了解WebAssembly?.md
Normal file
@ -0,0 +1,149 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 我们为什么要了解 WebAssembly?
|
||||
你好,我是于航,欢迎来到我的 WebAssembly 入门课!
|
||||
|
||||
提起 WebAssembly,你可能对它的印象只是一个看上去比较“抽象”的新技术。它不像一个新推出的框架或者库,功能定位十分清晰,并且可以直观地通过代码来展示。
|
||||
|
||||
如果别人问我:“什么是 WebAssembly?”从一个十分严谨的角度来看,我可能会回答说:“WebAssembly是基于栈式虚拟机的虚拟二进制指令集(V-ISA),它被设计为高级编程语言的可移植编译目标”。
|
||||
|
||||
不知道你听到这样一段对 “WebAssembly是什么?” 这个问题的解答之后,会有怎样的感想。可能大多数人的第一直觉会感到疑惑,这跟它名字中的 “Web” 一词有什么关系?还有人可能会觉得,所谓指令集是属于 CPU 那一层次的,跟我的实际工作应该没有什么关系吧。
|
||||
|
||||
没错,所有这些真实的顾虑和疑惑,实际上都成为了阻碍 WebAssembly 这项技术被社区开发者广泛接纳的“绊脚石”。
|
||||
|
||||
那为何我想要开设这样一门课程,来专门给你讲解这门技术呢?为什么我会如此痴迷这门技术?它到底有什么值得我们开发者去学习和使用的呢?
|
||||
|
||||
我和 WebAssembly 那些事儿
|
||||
|
||||
说起来,WebAssembly(为了方便书写,后面我们简称为 Wasm)这门新技术,自 2015 年诞生至今,已经过去了将近 5 年的时间。而自 2017 年,我开始接触这门技术以来,便一直不断地在为它投入热情。2018年,我出版了国内第一本介绍该技术的书籍《深入浅出 WebAssembly》。
|
||||
|
||||
期间,我也为 Emscripten 编译工具链贡献了部分代码。2019 年底,为了能够更进一步地为 Wasm 生态做出贡献,我开始研发自己的轻量级 Wasm 虚拟机 — TWVM,旨在打造一个具有高性能、高可移植性以及更加轻量的 Wasm 虚拟机。并同时能够基于此,来针对国内的众多本地化场景进行一些试验。
|
||||
|
||||
我认真回想了下,我对 Wasm 的热爱,其实始源于 2017 年末参与的一次 Wasm 线上研讨会。
|
||||
|
||||
这个研讨会是由 WCG(WebAssembly Community Group)定期举办的。会议会召集 WCG 成员一起讨论后期 WebAssembly 标准的制定方向和实现细节。每一次研讨会,都会由 WCG 主席亲自进行主持。
|
||||
|
||||
让我印象最为深刻的,不是每一次线上会议的参与人数有多少,也不是讨论有多激烈,更不是会议开了多长时间,而是在每次会议结束后,都会有完整的讨论记录存档。
|
||||
|
||||
这些会议产出的结果更会以“肉眼可见”的速度被快速地采纳、标准化,最后被 V8 团队“率先”实现在 Chrome 浏览器中。而早期的快速实现则可以让 Wasm 在初期以“小步快跑”的方式接触前线的开发者,在不断地迭代中快速“试错”。同时这也为其后续的社区推广起到了积极的作用。
|
||||
|
||||
|
||||
|
||||
其实,一个团队能够有多少凝聚力、在某个事情上能够有多少投入和产出,从这整个过程中便可以窥见。而被注入如此多精力的事情,也一定会充满着激情与活力。这也是我下定决心,想要把自己的大部分精力都投入到 Wasm 这门技术的一个起点。
|
||||
|
||||
随着 Wasm 这门技术的不断发展,我也开始不断地参加各种社区和公司举办的技术沙龙,来向更多人布道 Wasm 这门技术。
|
||||
|
||||
2018年,为了能够让 Wasm 被更多人所了解,我开始写书。写书的过程其实特别消耗精力,尤其是当你要写一本国内外都没有人写过的书时,你没有任何可以参考的资料。每当深夜写稿没有灵感、烦躁、郁闷的时候,我就会想起自己对 Wasm 的热爱,会想让更多人知道这门技术,应用这门技术。也正是这份热情与执着带我挺过了那段最难熬的日子。
|
||||
|
||||
写书其实是我想去普及 Wasm 这门技术的一个新起点。因为我在写书之前就发现,虽然大家或多或少都听说过 Wasm,但是一些对于 Wasm 的常见认知错误,逐渐让 Wasm 在社区的发展方现出现了偏差。而从现阶段来看,网上关于 Wasm 的中文文章大多以企业或个人的实践介绍为主,对于想要完整理解 Wasm 及其相关生态来说可能还不够全面,而官网的文档和规范又显然不适合直接作为“入门教材”。
|
||||
|
||||
在这个互联网时代,大家似乎对出版的纸质读物已然没有了太多兴趣,我从 2018 年末出书到现在,Wasm 一直在不断地更新发展,我对 Wasm 也有了很多新的想法和理解。同样的,我也一直在寻找一个更加适合初学者去了解、运用 Wasm 的方式,去普及 Wasm 这门技术。最后发现专栏的形式会更活泼,也更通俗易懂一些。
|
||||
|
||||
在正式为你介绍 Wasm 这项技术之前,我想先带你看一张前端技术的生命发展周期图。
|
||||
|
||||
|
||||
|
||||
从图里你能够很清楚地看到,Wasm 正在逐渐从一个“创新者”变成一个“早期采用者”。从论文中的思想理论走向生产实践的前沿。甚至从它的起源地出发,从 Web 平台逐渐走向各类场景。
|
||||
|
||||
实际上,Web 前端正变得越来越开放。如今,我们不仅能够直接使用 HTML、JavaScript 来编写各类跨端应用程序,Wasm的出现更能够让我们直接在 Web平台上,使用那些业界已存在许久的众多优秀的C/C++代码库。
|
||||
|
||||
除此之外,Wasm还能让Web 应用具有更高的性能,甚至让 Web 应用能够与原生应用展开竞争。不仅如此,走出 Web,WASI 的出现更是为 Wasm 提供了更大的舞台。Wasm 有着非常巨大的潜在影响力,而且现在已经初露锋芒。
|
||||
|
||||
让我们来一块看看自 2015 年 Wasm 一路走来,这期间都经历了哪些重要的发展节点。
|
||||
|
||||
|
||||
2015 年 4 月,WebAssembly Community Group 成立;
|
||||
|
||||
2015 年 6 月,WebAssembly 第一次以 WCG 的官方名义向外界公布;
|
||||
|
||||
2016 年 8 月,WebAssembly 开始进入了漫长的 “Browser Preview” 阶段;
|
||||
|
||||
2017 年 2 月,WebAssembly 官方 LOGO 在 Github 上的众多讨论中被最终确定;
|
||||
|
||||
同年同月,一个历史性的阶段,四大浏览器(FireFox、Chrome、Edge、WebKit)在 WebAssembly 的 MVP(最小可用版本)标准实现上达成共识,这意味着 WebAssembly 在其 MVP 标准上的 “Brower Preview” 阶段已经结束;
|
||||
|
||||
2017 年 8 月,W3C WebAssembly Working Group 成立,意味着 WebAssembly 正式成为 W3C 众多技术标准中的一员。
|
||||
|
||||
|
||||
自此之后,还有更多令人激动的“历史性节点”不断出现,比如 WASI 概念的提出和标准化、Byte Alliance 组织的成立、第一届 WebAssembly Summit 全球峰会的成功召开等等;而其中值得一提的是:2019 年 12 月,W3C 正式宣布,Wasm 将成为除现有的 HTML、CSS 以及 JavaScript 之外的第四种,W3C 官方推荐在 Web 平台上使用的“语言”。
|
||||
|
||||
所有上面提到的这些事情,无不都在见证着 Wasm 的不断发展,我相信你也能够体会到 Wasm 在社区推广如此困难的情况下,其相关生态还能够做到这种程度,这背后其实就是核心团队的一次“赌注”。因为大家都坚定地相信,在未来的 3~5 年里,Wasm一定能够被逐渐广泛地应用到包括 Web 在内的各个平台和业务领域中。
|
||||
|
||||
学习 Wasm,我们到底要学什么?
|
||||
|
||||
那么对于这样一门抽象的技术,我们应该以怎样的方式去了解它呢?在学习本课程前,我希望你已经拥有了一定的 C/C++ 编码基础,以及 Web 前端(JavaScript / CSS / HTML)编码基础。如果你还没有相关经验,或者相关的知识忘得差不多了,也不用担心,我为你准备了一节基础课,会带你学习、回顾一些在课程中涉及到的,相关领域的基础知识。
|
||||
|
||||
首先,在本课程中,我们不会介绍以下内容:
|
||||
|
||||
|
||||
每一个 Wasm 虚拟指令的语义和用法;
|
||||
虚拟机实现相关的内容,比如“如何构建一个 Wasm 虚拟机”。
|
||||
|
||||
|
||||
我们将会介绍的内容:
|
||||
|
||||
|
||||
|
||||
|
||||
Wasm 的历史和起源;
|
||||
Wasm 采用的计算模型和体系结构;
|
||||
Wasm 模块的内部结构;
|
||||
Wasm 相关的实战和应用。
|
||||
|
||||
|
||||
总而言之,我们不会介绍与虚拟机实现相关的、过于细节的一些内容。而是会从整体来看,把 下面这些问题给你一一解释清楚。比如:Wasm 是什么?它能做什么?别人已经做了什么?它是怎么来的?它未来会如何发展?……
|
||||
|
||||
为了能够从整体的角度直观地了解整个 Wasm 生态的全貌,我特意做了如下这张知识地图。你可以通过它来了解有关 Wasm 的任意一个方面的内容。相信随着时间的推移,这张图上的分支会变得越来越多,体系会变得越来越庞大。期待未来的 Wasm 能够在各个分支领域内“开花结果”。
|
||||
|
||||
|
||||
|
||||
学习 Wasm,我们应该怎么学?
|
||||
|
||||
为了能够把课程内容更加直观地展示出来,我把课程整体分为了三大模块。每一个模块下都由若干个子问题组成。由于 Wasm 整个知识体系的内容非常庞大,且较为碎片化,因此,带着一个个问题来了解 Wasm 可能会是一种更加合适的学习方式。
|
||||
|
||||
第一个模块是核心原理模块。在核心原理模块中,我将会给你介绍与 “Wasm 是什么?” 这个问题有关的几个方面的知识点。我会从最基本的 Stack Machine 计算模型开始介绍,逐渐带你从外部体系深入到 Wasm 的二进制世界。
|
||||
|
||||
在这里,我会给你介绍 Wasm 使用的二进制编码方式和基本数据类型、模块内部的组织结构以及 Wasm 的可读文本格式 — WAT。最后,我会介绍与 Wasm 紧密相关的另一项技术 — WASI。以及 Wasm 与 Web 平台的互操作性 — Wasm MVP 标准下的 JavaScript API 与 Web API。
|
||||
|
||||
第二个模块是应用模块。在应用模块里,我将首先为你解答目前被讨论最多的一个问题 —— “我们是否能够使用 Wasm 这门技术来优化现代的 Web 前端框架?”。然后我将给你介绍,目前业界有哪些已经投入到生产环境中使用的 Wasm 案例。以及在这些案例中,各个公司都是如何借助 Wasm 技术来优化其产品体验的。
|
||||
|
||||
接下来,我将介绍 Wasm 在物联网、多媒体以及云技术等方面的一些,业界已经正在进行的尝试。然后,我们将会一起浏览目前业界最优秀的一些 Wasm 底层基础设施,这些基础设施都有着其各自不同的特点与使用场景,为构建稳定安全的 Wasm 应用提供了强大的底层支持。
|
||||
|
||||
最后,我将为你讲解另外一个不得不聊的话题 — Wasm 与 LLVM。借助于 LLVM,我们将能够为“任意”的、基于 LLVM 构建的编程语言支持,将 Wasm 设置为其最后的编译目标。然后,我们将再次回到 Wasm 标准,来看看正在行进中的 Post-MVP 标准中有哪些“新鲜事”。
|
||||
|
||||
第三个模块是实战模块。在这个模块中,我将手把手带你一步一步地完成一个线上多媒体 Web 应用。在这个应用中,我们将会使用 Wasm 来优化多媒体资源的处理过程,同时我们将介绍与 Wasm 有关的一些 API 以及编译工具链,在实际项目中的使用方式。
|
||||
|
||||
而在这之后,我们也将讨论如何调试和分析一个 Wasm 应用。最后,我们将会介绍 WASI 及其相关生态的基本应用方式,并尝试在目前最流行的 Wasm 运行时中使用它们。
|
||||
|
||||
这里,我想和你分享一下,我从 2017~2020 年这将近三年的时间里,所亲眼见到或实际接触过的一些 Wasm 应用场景。
|
||||
|
||||
|
||||
|
||||
除此之外,你还要知道,目前已经有多达几十种编程语言(C/C++、Rust、Go、Kotlin 等)的代码,可以在相关基础设施的帮助下被编译为 Wasm 二进制格式,Wasm 已经逐渐成为编程语言不可或缺的一个重要的编译目标。
|
||||
|
||||
上面我分享的这些实际应用场景,还仅仅是依赖于 Wasm 的 MVP 版本标准所提供的功能特性实现的。相信随着 Post-MVP 标准的逐渐确定和相应基础设施的完善,以及 WASI 标准的进一步发展,Wasm 的可用领域将会变得更加广泛。
|
||||
|
||||
Docker 的创始人 Solomon Hykes 曾在 Twitter 上说过这样一段话:
|
||||
|
||||
|
||||
|
||||
翻译过来的大致意思是:
|
||||
|
||||
|
||||
如果 WASM 和 WASI 早在 2008 年就存在,那么我们就不需要创建 Docker。可见 Wasm 是多么重要。服务器上的 WebAssembly 将会是“计算”的未来模式。而现在的问题是缺少标准化的系统接口。希望 WASI 能够胜任这项工作!
|
||||
|
||||
|
||||
不用我多说,相信你能够体会到 Wasm 出现的意义,以及它在未来巨大的可能性。作为一项关注于底层的变革性技术,我相信 Wasm 能够催生出更多建立于它之上的新型技术解决方案。可能在未来某一天, Wasm 将会成为每一个互联网工程师的必备技能。
|
||||
|
||||
以上,就是这门课我想分享给你的全部内容了。总的来说,我希望通过这门课你能够弄清以下三个问题:Wasm 究竟是什么?Wasm 可以怎么用?Wasm 的现在和以后能够给我们带来什么?
|
||||
|
||||
我虽然无法用短短十几篇文章,把整个 Wasm 生态的所有内容全部都概括到。但我希望你能够保持一颗持续学习和乐于探索的心,带着这样的信念去了解和挖掘新兴技术,相信你能够收获到与他人不一样的宝藏。
|
||||
|
||||
Wasm 核心团队当初在 GitHub 上的官方 Organization 中,以这样一句话描述了他们的所在位置 —— “The Web!”。Wasm 虽然起源于 Web,但如今,它已经不止于 Web。那就让我们来一起探索 Wasm 带来的,那个拥有无限可能的世界吧!
|
||||
|
||||
|
||||
|
||||
|
276
专栏/WebAssembly入门课/01基础篇:学习此课程你需要了解哪些基础知识?.md
Normal file
276
专栏/WebAssembly入门课/01基础篇:学习此课程你需要了解哪些基础知识?.md
Normal file
@ -0,0 +1,276 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 基础篇:学习此课程你需要了解哪些基础知识?
|
||||
你好,我是于航。
|
||||
|
||||
在我们正式进入到 WebAssembly 的学习之前,为了帮助你更好地理解课程内容,我为你准备了一节基础课。
|
||||
|
||||
在这一节基础课中,我将与编程语言及计算机基础相关的一些概念,按照其各自所属的领域进行了分类,供你进行本课程的预习与巩固。
|
||||
|
||||
这些概念大多都相互独立,因此你可以根据自己的实际情况选择性学习。在后面的课程中,我将会直接使用这些概念或术语,不再过多介绍。当然,如果你对这些知识足够熟悉,可以直接跳过这节课。
|
||||
|
||||
JavaScript
|
||||
|
||||
接下来,我将介绍有关 JavaScript 的一些概念。其中包括 ECMAScript 语言规范中提及的一些特性,以及一些经常在 Web 应用开发中使用到的 JavaScript Web API。
|
||||
|
||||
window.requestAnimationFrame
|
||||
|
||||
window.requestAnimationFrame 这个 Web API ,主要用来替代曾经的 window.setInterval 和 window.setTimeout 函数,以专门用于处理需要进行“动画绘制”的场景。
|
||||
|
||||
该方法接受一个回调函数作为参数,该回调函数将会在下一次浏览器尝试重新绘制当前帧动画时被调用。因此,我们便需要在回调函数里再次调用 window.requestAnimationFrame 函数,以确保浏览器能够正确地绘制下一帧动画。
|
||||
|
||||
这个 API 一个简单的用法如下所示。
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
div {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background-color: red;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div></div>
|
||||
</body>
|
||||
<script>
|
||||
let start = null;
|
||||
let element = document.querySelector('div');
|
||||
|
||||
const step = (timestamp) => {
|
||||
if (!start) start = timestamp;
|
||||
let progress = timestamp - start;
|
||||
element.style.left = Math.min(progress / 10, 200) + 'px';
|
||||
if (progress < 2000) {
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(step);
|
||||
</script>
|
||||
</html>
|
||||
|
||||
|
||||
在这段代码中为了便于展示,我们直接连同 CSS 样式、HTML 标签以及 JavaScript 代码全部以“内嵌”的方式,整合到同一个 HTML 文件中。
|
||||
|
||||
页面元素部分,我们使用
|
||||
|
||||
标签绘制了一个背景色为红色,长宽分别为 100 像素的矩形。并且该矩形元素的 position 属性被设置为了 “absolute”,这样我们便可以通过为其添加 “left” 属性的方式,来改变当前矩形在页面中的位置。
|
||||
|
||||
在 JavaScript 代码部分,我们首先通过 “document.querySelector” 的方式获取到了该矩形对应的 DOM 元素对象。并编写了一个用于绘制动画的函数 “step”。
|
||||
|
||||
在这个函数定义的最后,我们调用了 window.requestAnimationFrame 方法,来触发对动画下一帧的绘制过程。由此便构成了一个间接递归,动画便可以持续不断地绘制下去,直到 “progress < 2000” 这个条件不再成立。
|
||||
|
||||
对于这段动画的实际播放效果,你可以参考下面这张动图
|
||||
|
||||
|
||||
|
||||
此时,整个矩形也被移动到了距离页面最左侧边界 200 像素的位置。这里你可以思考一下,整个动画从开始到结束一共持续了多长时间呢?
|
||||
|
||||
Performance API
|
||||
|
||||
相信单从名字上,你就能够猜测出这个 Web API 的主要功能了。没错,借助于 Performance API,我们可以非常方便地获得当前网页中与性能相关的一些信息。比如其中最常用的一个应用场景 —— “测量一段 JavaScript 代码的执行时间”。
|
||||
|
||||
我们可以使用名为 Performance.now 的 API 来达到这个目的。一段示例代码如下所示。
|
||||
|
||||
let start = performance.now();
|
||||
|
||||
for (let i = 0; i < 10e7; ++i) {}
|
||||
|
||||
// Time Span: 97.4949998781085 ms.
|
||||
console.log(`Time Span: ${performance.now() - start} ms`);
|
||||
|
||||
|
||||
这段代码十分简单。首先我们调用 performance.now(),来获得当前时刻距离 “time origin” 所经过的毫秒数。这里你可以把 “time origin” 简单理解为当前页面创建的那个时刻。
|
||||
|
||||
然后我们执行了一千万次的空循环结构,主要用于模拟耗时的待测量 JavaScript 逻辑。在代码的最后,我们通过 “performance.now() - start” 便可以得到,当前时刻与上一次在 start 处所测量的时刻,两者相差的时间间隔。这段时间便是一千万次空循环结构所消耗的时间。
|
||||
|
||||
TypedArray
|
||||
|
||||
顾名思义,TypedArray 便是指“带有类型的数组”,我们一般简称其为“类型数组”。
|
||||
|
||||
我们都知道,在默认情况下,出现在 JavaScript 代码中的所有数字值,都是以“双精度浮点”的格式进行存储的。
|
||||
|
||||
也就是说,假设我们有如下所示的一个普通 JavaScript 数组,对于数组内部的每一个元素,我们都可以重新将其赋值为双精度浮点类型所能表示值范围内的,任意一个值。
|
||||
|
||||
你可以试着将该数组的第一个元素的值设置为 “Number.MAX_VALUE”。该值表示在 JavaScript 中所能表示的最大数值,在我本机上的结果为 “1.7976931348623157e+308”。
|
||||
|
||||
let arr = [1, 2, 3, 4];
|
||||
|
||||
|
||||
而 TypedArray 则不同于传统的 JavaScript 数组。TypedArray 为内部的元素指定了具体的数据类型,比如 Int8 表示的 8 位有符号整型数值、Float32 表示的 32 位单精度浮点数值,以及 Uint32 表示的 32 位无符号整型数值等等。
|
||||
|
||||
TypedArray 实际上构建于底层的“二进制数据缓冲区”,在 JavaScript 中可以由 ArrayBuffer 对象来生成。ArrayBuffer 描述了一个字节数组,用于表示通用的、固定长度的原始二进制数据缓冲区。
|
||||
|
||||
由于 ArrayBuffer 中的数据是以“字节”为单位进行表示的,因此我们无法直接通过 ArrayBuffer 对象来操作其内部的数据,而是要通过 TypedArray 以某个固定的“类型视图”,按照某个具体的“数据单位量度”来操作其内部数据。
|
||||
|
||||
如下代码所示,我们可以通过几种常见的方式来使用 TypedArray。
|
||||
|
||||
const DEFAULT_INDEX = 0;
|
||||
// Way one:
|
||||
const int8Arr = new Int8Array(10);
|
||||
int8Arr[DEFAULT_INDEX] = 16;
|
||||
console.log(int8Arr); // Int8Array [16, 0, 0, 0, 0, 0, 0, 0, 0, 0].
|
||||
|
||||
// Way two:
|
||||
const int32Arr = new Int32Array(new ArrayBuffer(16));
|
||||
int32Arr.set([1, 2, 3], 0);
|
||||
console.log(int32Arr); // Int32Array [1, 2, 3, 0].
|
||||
|
||||
|
||||
这里我列出了两种 TypedArray 的使用方式。第一种,我们可以直接通过相应类型的 TypedArray 构造函数来构造一个类型数组。比如这里我们使用的 Int8Array,其构造函数的参数为该数组可以容纳的元素个数。然后,我们修改了数组中第一个元素的值,并将整个数组的内容“打印”了出来。
|
||||
|
||||
第二种使用方式其实与第一种十分类似,唯一的不同是我们选用了另一种 TypedArray 的构造函数类型。该构造函数接受一个 ArrayBuffer 对象作为其参数,生成的 TypedArray 数组将会以该 ArrayBuffer 对象作为其底层的二进制数据缓冲区。
|
||||
|
||||
但需要注意的是,由于 ArrayBuffer 的构造函数其参数指定了该 ArrayBuffer 所能够存放的单字节数量,因此在“转换到”对应的 TypedArray 时,一定要确保 ArrayBuffer 的大小是 TypedArray 元素类型所对应字节大小的整数倍。
|
||||
|
||||
另一个需要关注的点是,在方法二中,我们使用了 TypedArray.prototype.set 方法将一个普通 JavaScript 数组中的元素,存放到了刚刚生成的,名为 int32Arr 的类型数组中。
|
||||
|
||||
该方法接受两个参数,第一个参数为将要进行数据读取的 JavaScript 普通数组;第二个参数为将要存放在类型数组中的元素偏移位置。这里我们指定了第二个参数为 0,因此会从 int32Arr 的第一个元素位置开始存放。
|
||||
|
||||
C/C++
|
||||
|
||||
在这个部分中,我将介绍有关 C/C++ 语言的一些概念。其中包括在编写 C/C++ 代码时可以使用到的特殊语法结构,以及在编译 C/C++ 源代码时的特殊编译器行为和选项。
|
||||
|
||||
extern “C” {}
|
||||
|
||||
通常我们在编译一段 C++ 源代码时,由于 C++ 天生支持的“函数重载”特性,因此需要一种能够在最终生成的可执行文件中,区别出源代码中定义的同名函数的机制。编译器通常会使用名为 “Name Mangling” 的机制来解决这个问题。
|
||||
|
||||
Name Mangling 会将 C++ 源代码中的函数名,在编译时进行一定的变换。这样,重载的同名函数便可以在可执行文件中被区分开。一般的实现方式通常是将函数名所对应函数的实际函数签名,以某种形式拼接在原有的函数名中。举个例子,假设我们有如下这段 C++ 代码。
|
||||
|
||||
int add(int x, int y) {
|
||||
return x + y;
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
int x = add(0, 1);
|
||||
std::cout << x;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
经过编译,我们可以使用诸如 readelf / objdump / nm 等命令行工具,来查看生成的可执行文件其内部的符号列表。然后你会发现我们在源代码中定义的那个函数 “add”,名称在经过 Name Mangling 处理后变成了 “_Z3addii”。
|
||||
|
||||
而 “extern “C” {}” 这个特殊的语法结构便可以解决这个问题。我们按照以下方式改写上述代码。
|
||||
|
||||
#include <iostream>
|
||||
extern "C" {
|
||||
int add(int x, int y) {
|
||||
return x + y;
|
||||
}
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
int x = add(0, 1);
|
||||
std::cout << x;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
在经过编译后,以同样的方式查看编译器生成的可执行文件内的符号信息,你会发现我们在源代码中定义的函数 “add” 其名称被保留了下来。
|
||||
|
||||
之所以会产生这样效果,是由于在这个特殊的结构中,C++ 编译器会强制以 C 语言的语法规则,来编译放置在这个作用域内的所有 C++ 源代码。而在 C 语言的规范中,没有“函数重载”这类特性,因此也不会对函数名进行 “Name Mangling” 的处理。
|
||||
|
||||
DCE(Dead Code Elimination)
|
||||
|
||||
在编译器理论中,DCE 是一种编译优化技术,将其翻译成中文即“死码消除(没有业界统一的中文叫法)”。从名字上你可以理解为,通过 DCE 这种技术,编译器可以将源代码中没有使用到的代码从最后的目标产物中移除,以便优化其最终大小及执行效率。
|
||||
|
||||
但实际上 DCE 会更进一步,它消除的是那些对程序最后运行结果没有任何影响的代码,而不仅仅是没有用到的代码。
|
||||
|
||||
同样的,我们来举个例子。比如对于下面这段 C/C++ 代码,编译器会怎样进行 DCE 优化呢?
|
||||
|
||||
int foo() {
|
||||
int a = 24;
|
||||
int b = 25; // 没有被使用到的变量 b;
|
||||
int c;
|
||||
c = a << 2; // 变量值无关乎外部输入;
|
||||
return c;
|
||||
b = 24;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
一般来说,我们可以得到如下与汇编代码等价的 C/C++ 代码优化结果。
|
||||
|
||||
int foo() {
|
||||
return 96;
|
||||
}
|
||||
|
||||
|
||||
这里你可以按照我在代码中给出的注释信息,来尝试思考一下,编译器是如何优化我们之前那段 C/C++ 代码的,相信这一定不会难住你。
|
||||
|
||||
-O0 / -O1 / -O2 等优化编译选项
|
||||
|
||||
在诸如 Clang / GCC 等编译器中,我们通常可以为编译器指定一些有关编译优化的标记,以让编译器可以通过使用不同等级的优化策略来优化目标代码的生成。而诸如 -O0 / -O1 / -O2 一直到 -Os 与 -O4 等选项,便是这些优化标记中的一部分。
|
||||
|
||||
在通常情况下,编译器会使用 “-O0” 来作为默认的编译优化等级。在该等级下,编译器一般不会进行任何优化,因此可以在最大程度上降低编译时间,保留最多的调式性信息。此模式一般用于对应用程序进行调试,亦可作为默认的本地开发时编译选项。
|
||||
|
||||
相反,诸如 “-O3” 与 “-O4” 等标记,一般用于对“生产版本”进行深入的优化。所谓“生产版本”是指即将发布给用户使用的二进制版本。对于这些版本,我们需要使用较高的优化等级,以尽量提升可执行程序的运行性能。
|
||||
|
||||
在这些编译优化等级下,编译器会启用多种优化策略来优化输入代码。相对的,这些选项通常也会提升编译时间,并且使得编译结果难以进行调试。所以实际上,不同的优化编译选项,其实对应着不同的使用场景。
|
||||
|
||||
计算机基础知识
|
||||
|
||||
在这个部分中,我将给你介绍几个计算机基础知识中的常见概念。
|
||||
|
||||
原码、反码和补码
|
||||
|
||||
我们知道,在计算机科学中,数字一共有三种表示方式,即“原码”、“反码”和“补码”。但实际上,计算机在存储数字值时会采用“补码”的形式。由于浮点数通常会采用 IEEE-754 标准进行编码,因此这里我们不讨论浮点数的补码形式,仅讨论整数。
|
||||
|
||||
这里我以有符号数 “-10” 为例,来给你介绍一下它从原码到反码,最后再到补码的具体转换过程。
|
||||
|
||||
首先,对于原码来说,其最高位会被用来当做符号位,该位为 “0” 表示正数,“1” 则表示负数。假设这里我们使用一个 1 字节(8 位)大小的 “signed char” 有符号整数类型变量来存储该数字,则 “-10” 所对应的原码如下。
|
||||
|
||||
1000 1010
|
||||
|
||||
|
||||
而要将原码转换为对应的反码,我们需要把上述二进制数字的最高位符号位保持不变,而将其他位取反。也就是把 “1” 变 “0”,“0” 变 “1”。得到的反码如下所示。
|
||||
|
||||
1111 0101
|
||||
|
||||
|
||||
最后,为了将反码再转换为补码,我们只需要为上述二进制数字再加上“一”即可。
|
||||
|
||||
1111 0110
|
||||
|
||||
|
||||
对于无符号数而言,由于它没有符号位,因此变量对应的所有数据位都可以用来存放它的值。并且它的原码、反码以及补码三种形式均完全相同。也就是说,无符号数的反码和补码,与其原码保持一致。
|
||||
|
||||
ACL(Access Control List)
|
||||
|
||||
ACL 翻译成中文即“访问控制列表“,它负责告诉计算机操作系统,每一个用户对特定的系统对象(比如某个文件)具有哪些访问权限。在 ACL 中,每一个条目都包含有权限相关的主体与相应可以执行的操作。在类 Unix 系统中,最为直观的一个 ACL 的体现便是 “ls -l” 命令的输出结果,如下图所示。
|
||||
|
||||
|
||||
|
||||
在这张图中,“ls -l” 命令打印出了当前位置下的所有文件与文件夹信息。附带的,还有针对每一个文件或文件夹的权限及所有者信息。比如以 “rwx” 形式表示的,针对不同种类用户分配的,对于这些文件或文件夹所能够执行的操作信息(可读、可写、可执行)。以及文件或文件夹所有者的名字及其所在组的信息。总而言之,这便是 ACL 在 Unix 中的一类直观的表现形式。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在本节基础课中,我主要给你介绍了三部分内容。这些内容分别涉及 JavaScript 语言和相关 API 的概念及用法、C/C++ 相关的一些语言及编译时特性,以及其他的一些计算机基础知识。
|
||||
|
||||
其中,JavaScript 方面,我们介绍了专用于制作 JavaScript 动画的 window.requestAnimationFrame API 的简单用法,以及用于测量网页性能数据的 Performance API 的简单用法。
|
||||
|
||||
C/C++ 方面,我们主要介绍了 “extern “C” {}” 结构的基本用法,该结构可用于“停用” Name Mangling 机制。定义在该结构内的函数在经过编译后,其名称不会被改变。
|
||||
|
||||
除此之外,DCE 作为一种编译器常用的优化技术,将会帮助我们在最终输出的二进制文件内,移除对源代码功能没有影响的代码部分,以优化可执行文件的性能。而 “-O0”、“-O1” 与 “-O2” 等优化编译选项,则将会影响 DCE 的具体“功效”。
|
||||
|
||||
最后在计算机基础知识方面,我们介绍了 “原码、反码、补码”,以及 ACL 的概念。前者主要通过不同的形式来表示计算机中的“数字”,当实际存储时,计算机会采用“补码”的形式。而 ACL 通常是计算机权限控制系统的一个重要组成部分,它代表了一系列通过“访问控制列表“来管理系统权限的模式。
|
||||
|
||||
希望这节基础课能够为你在接下来的 WebAssembly 学习之旅中,提供一些帮助。
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
这个问题引申自我们在本节课中介绍的“原码”、“反码”以及“补码”的概念。你知道在计算机中,有符号数之间的减法操作(比如 “10 - 3”)是如何进行运算的吗?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
151
专栏/WebAssembly入门课/02历史篇:为什么会有WebAssembly这样一门技术?.md
Normal file
151
专栏/WebAssembly入门课/02历史篇:为什么会有WebAssembly这样一门技术?.md
Normal file
@ -0,0 +1,151 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 历史篇:为什么会有 WebAssembly 这样一门技术?
|
||||
你好,我是于航。
|
||||
|
||||
在开始真正学习 Wasm 这门技术之前,我想先来问你一个问题:你有没有思考过,在 Web 技术的历史发展长河中,为什么会出现 Wasm 这样一门技术?现有的这些 Web 技术,又存在着哪些无法解决的问题?
|
||||
|
||||
要知道,所有新兴技术的诞生都一定有它存在的意义,或者要去解决的问题。比如 jQuery 之于浏览器的兼容性、Vue.js / React.js 之于 Web 应用的构建模式。
|
||||
|
||||
虽然用前端框架和库来类比 Wasm 不算十分合适,但我想阐述的是,Wasm 的出现也并非偶然。在这节课的内容中,我们就来一起看看 Wasm 诞生背后的那些故事。相信在学习完本课程后,你会对 Wasm 有了一些新的了解。而这些了解有时可能比一项技术本身更加重要。
|
||||
|
||||
JavaScript 的发展和困境
|
||||
|
||||
1995 年末,Brendan Eich 仅用了 10 天时间便发明出了 JavaScript 编程语言,而在随后的二十多年中,JavaScript 已经成为了不可动摇的,用于开发 Web 前端应用的必备编程语言之一。
|
||||
|
||||
不仅如此,随着后来诸如 React Native、Electron 以及 Vue.js 等各类框架的不断涌现,JavaScript 曾经一度成为 GitHub 语言排行榜的年度冠军。JavaScript 也因此被广泛应用到了各行各业、各个领域的各类项目中。
|
||||
|
||||
虽说 JavaScript 的应用场景如此广泛,但也会有它自己的烦恼。下面我们就从 Web 应用层面以及 JavaScript 语言本身,来看看它究竟在愁些什么。
|
||||
|
||||
Web 应用规模的急速增长
|
||||
|
||||
随着移动互联网的发展和各种形式经济活动的不断展开,运行在浏览器中的各类 Web 应用,它们的体积与复杂性随着时间的推移在不断发展。为了能够在浏览器中高效运行这些不断“变大”的 Web 应用,浏览器厂商们也在不断地寻求着各种“黑科技”来优化浏览器的性能。
|
||||
|
||||
但与日益庞大和复杂化的 Web 应用相比,浏览器对自身性能的优化可谓是举步维艰。不难预见,当“复杂化”与“性能优化”的速度之比不断变大时,迟早有一天,浏览器会再也无法支撑起这些庞大 Web 应用的运行。
|
||||
|
||||
据相关数据统计,截止 2019 年底,全世界一共有约 16 亿个可索引网页,而其中的 95% 都在使用 JavaScript。在这些网页中,大约有 45% 的网页创建于最近 5 年。而 2015 年,ECMAScript 2015 (ES6) 诞生,也标志着 JavaScript 开始进入了标准一年一更新的节奏中。
|
||||
|
||||
现代的大多数网页,都会使用较新的 JavaScript 语法标准进行开发,然后在发布时使用诸如 Babel 等工具,将这些新的 JavaScript 语法转换为对应的 ES5 旧版本语法,来兼容旧版本浏览器。但这样做,产生的各类 Polyfill 代码,会极大地增加整个 Web 应用的体积。
|
||||
|
||||
同时,在 Web 应用的实际运行过程中,大量的 JavaScript 代码也会降低应用的整体运行效率。Twitter 曾尝试直接以 ES6+ 版本代码的形式,来发布整个 Web 应用。通过这种方式所减少的 Polyfill Bundle 文件的大小,竟然可以达到应用所使用的全部 JavaScript 代码的 83%。
|
||||
|
||||
JavaScript 的弱类型之殇
|
||||
|
||||
除了上面我们讲到的,浏览器性能优化与 Web 应用规模日益增大,这两者行进速度的“不协调”所可能带来的问题之外,JavaScript 语言本身也有着其自身的“弱点”。而由于这些“弱点”所带来的妥协,使得浏览器在面对庞大的 Web 应用时,也会显得力不从心。
|
||||
|
||||
可以说,JavaScript 是一个“动态类型”的编程语言。在实际编码过程中,我们不需要为每一个变量指定对应类型。变量具体类型的推导过程,会被推迟到代码的实际运行时再进行。JavaScript 这种动态类型语言所独有的特性,在某种程度上相较于静态类型语言而言,会带来额外的运行时性能开销。
|
||||
|
||||
下面我们来一起想象一下, JavaScript 引擎在执行表达式 “x + y” 时的具体流程。这里 x 与 y 分别是在一段 JavaScript 代码中定义的两个变量,当引擎执行到 “x + y” 时,对于运算符 “+” 来说,位于其左右两侧的操作数可以是 JavaScript 中任何有效类型的组合,比如 “{} + []”、“[] + null”、“1 + 2” 等等。因此,引擎在对 “+” 运算符表达式进行求值时,会根据 ECMAScript 标准中规定的 “+” 运算符的语义,来对表达式进行求值。
|
||||
|
||||
通过下图你可以看到, 在 ECMAScript 标准中定义的,“+” 运算符的运行时求值流程,实际上十分复杂和繁琐。这也是相对于静态语言来说,JavaScript 很少能够进行优化的地方。
|
||||
|
||||
在现代的 JavaScript 引擎中,尽管可以使用诸如 JIT 等技术来提高代码的执行效率,但在实际使用中,如果代码执行没有遵守 JIT 优化路径中特定 Guard 的要求,“去优化”的过程,也同样会影响引擎的整体执行效率。而这些影响都是由于 JavaScript 的“动态性”导致的。
|
||||
|
||||
|
||||
|
||||
最初的尝试 —— NaCl 与 PNaCl
|
||||
|
||||
JavaScript 的发展困境在逐渐显现,人们对 Web 性能的担忧也在与日俱增,人们永远没有停下优化的脚步。NaCl 是由 Google 在 2011 年于 Chrome 浏览器中发布的一项技术,该技术旨在提供一个沙盒环境,可以让基于 C/C++ 语言编写的 Native 应用,安全地运行在浏览器中。NaCl 的全称 “Native Client” 也暗示了这一点。
|
||||
|
||||
如下图所示,一个标准 NaCl 应用的组成结构,与普通的 JavaScript Web 应用十分类似。NaCl 模块作为应用的一部分,主要用来进行复杂的数据处理和运算,JavaScript 则负责处理应用与外部用户的交互逻辑。NaCl 实例与 JavaScript 代码之间可以通过“订阅/发布”模型,来互相传递消息。
|
||||
|
||||
|
||||
|
||||
理想虽好,但现实却存在着很多问题。通常,一个 NaCl 模块文件需要在开发者本地进行编译,然后才能够在浏览器中使用。而本地编译的模块文件通常仅含有架构相关(architecture-dependent)的代码,因此没有办法直接在其他类型的系统中使用。
|
||||
|
||||
一个完整的 NaCl 应用,在分发时需要提供支持多个架构平台(X86_32 / X86_64 / ARM 等)的模块文件。浏览器在实际使用时,会根据当前系统的具体架构类型,来动态地选择,对应合适的模块文件进行使用。
|
||||
|
||||
不仅如此,由于 NaCl 模块“平台依赖”的特殊性,因此 NaCl 模块进行分发的过程,仅能够在 Chrome Web Store 中进行。 另一方面,如果你想要将已经存在的 C/C++ 代码库编译至 NaCl,并在浏览器中使用,你还需要通过名为 Pepper 的库来对这些代码进行重写。
|
||||
|
||||
Pepper 提供了很多包装类型,以及用于和浏览器进行交互的 API,比如 “PP_Bool” 等。这些 API 和特殊类型可以便于整合传统 C/C++ 代码与 Web 浏览器的沙盒环境。
|
||||
|
||||
鉴于 NaCl 存在的“平台依赖”问题,Google 在后期又推出了名为 PNaCl 的技术。这里名字中多出来的 “P” 代表着 “Portable”,也就是“可移植”的意思。
|
||||
|
||||
PNaCl 采用了不一样的生命周期,参考下图我们可以看到,相较于 NaCl 模块直接包含有平台架构相关的代码,PNaCl 将源 C/C++ 代码编译到一种中间代码。这些中间代码会在浏览器实际加载这个 PNaCl 模块时,再被转换为对应的平台相关代码。因此,对于 PNaCl 模块而言,分发的过程变得更加简单,且不用担心移植性的问题。
|
||||
|
||||
|
||||
|
||||
不过,即使是对于 PNaCl 这类“可移植性”已经不再成为问题的技术而言,它们的面前还有很多“大山”难以逾越。比如:“需要使用 Pepper 重写 C/C++ 代码,标准较为封闭、仅 Chrome 浏览器支持”等等。
|
||||
|
||||
总而言之,无论是 NaCl 还是 PNaCl,它们都已经成为过去。现在,如果你再次回到 NaCl / PNaCl 在 Google 的官方文档网站,你会发现如下这样一段声明。Wasm 将会作为新一代的技术,接替并继续传承 Google 赋予给 NaCl / PNaCl 的使命。
|
||||
|
||||
|
||||
|
||||
Wasm 的前身 —— ASM.js
|
||||
|
||||
除了 NaCl 与 PNaCl,另一个不可不提的技术便是 Mozilla 于 2013 提出的 ASM.js。同前两者一样,ASM.js 的设计目标也是为了能够在 JavaScript 语言之外,为“构建更高性能的 Web 应用”这个目标,提供另外一种实现的可能。
|
||||
|
||||
“ASM.js 是 JavaScript 的一个严格子集。它是一种可用于编译器的目标语言,低层次且高效。该目标语言有效地为内存不安全语言(如 C/C++),描述了一个沙盒虚拟机运行环境。静态和动态验证相结合的方式,使得 JavaScript 引擎能够使用 AOT 等优化编译策略来验证 ASM.js 代码”。这是 Mozilla 官方给出的关于 “ASM.js 是什么?” 这个问题的解答。
|
||||
|
||||
乍一看这段解释,可能会有点抽象和复杂。但实际上,我们只需要知道两件事情。
|
||||
|
||||
第一,ASM.js 是 JavaScript 的严格子集。这也就意味着,对于一段 ASM.js 代码,JavaScript 引擎可以将它视作普通的 JavaScript 代码来执行,这便保障了 ASM.js 在旧版本浏览器上的可移植性。
|
||||
|
||||
第二,ASM.js 使用了 “Annotation(注解)” 的方式来标记代码中包括:函数参数、局部/全局变量,以及函数返回值在内的各类值的实际类型。
|
||||
|
||||
当 JavaScript 引擎满足一定条件后,便会通过 AOT 静态编译的方式,将这些被 Annotation 标记的 ASM.js 代码,编译成对应的机器码并加以保存。当 JavaScript 引擎再次执行(甚至在第一次执行)这段 ASM.js 代码时,便会直接使用先前已经存储好的机器码版本。因此,引擎的性能会得到大幅的提升。
|
||||
|
||||
对于一段标准 ASM.js 代码的具体组成形式,你可以参考下面给出的这段代码,以便有一个更加直观的印象。
|
||||
|
||||
function asm (stdin, foreign, heap) {
|
||||
"use asm";
|
||||
|
||||
function add (x, y) {
|
||||
x = x|0; // 变量 x 存储了 int 类型值;
|
||||
y = y|0; // 变量 y 存储了 int 类型值;
|
||||
var addend = 1.0, sum = 0.0; // 变量 addend 和 sum 默认存放了"双精度浮点"类型值;
|
||||
sum = sum + x + y;
|
||||
return +sum; // 函数返回值为"双精度浮点"类型;
|
||||
}
|
||||
return { add: add };
|
||||
}
|
||||
|
||||
|
||||
|
||||
在这段 JavaScript 代码中,最为重要的是函数 “asm” 在其函数体定义开头处使用的 “use asm” 指令。这个指令将会在代码执行过程中“告诉” JavaScript 引擎,当前这个函数体内的代码可以按照 ASM.js 代码,来进行相应的优化和处理。
|
||||
|
||||
实际上,上述这样的一个 JavaScript 函数,便定义了一个标准的 ASM.js 模块。模块内部可以通过 return 的方式,导出包含有若干内联方法的对象。这些方法可以在外部的 JavaScript 代码中进行调用。
|
||||
|
||||
在上述 asm 模块内定义的内联函数 add 中,我们在其开头的前两行代码通过 “x|0” 和 “y|0” 的方式,分别对变量 x 与 y 的值类型进行了标记。而这种方式便是我们之前提到的 ASM.js 所使用的 Annotation。
|
||||
|
||||
当 JavaScript 引擎在编译这段 ASM.js 代码时,便会将这里的变量 x 与 y 的类型视为 int 整型。同样的,还有我们对函数返回值的处理 “+sum”。通过这样的 Annotation,引擎会将变量 sum 的值视为双精度浮点类型。类似的,ASM.js 在标准中还规定了其他的诸多 Annotation 形式,可以将变量值标记为不同的类型,甚至对值类型进行转换。
|
||||
|
||||
为了确保上述的这样一个 JavaScript 函数,能够被当做一个标准的 ASM.js 模块进行必要的优化处理,JavaScript 引擎通常会在实际编译加载这些模块前,进行很多必要的检查验证工作。
|
||||
|
||||
因此,并不是说只要为函数添加了 “use asm” 指令,并且为使用到的变量添加 Annotation 之后,JavaScript 引擎就会通过 AOT 的方式来优化代码的执行。所以这也是为什么我们先前提到的,ASM.js 通常被作为一种可用于编译器的,低层次且高效的目标语言,而不是用于手写。
|
||||
|
||||
从过去到未来
|
||||
|
||||
时间来到2015年5月。Chrome 团队的 Ben 正在为 V8 设计一种新的 Prototype(原型),而另一位团队成员 Rosbery ,正在为这种 Prototype 设计对应的字节码格式。实际上,这个 Prototype 和对应的字节码格式,便是如今 Wasm 所分别对应的 WAT 可读文本格式与二进制字节码格式。在当时的谷歌内部,这两部分暂时被称为 ml-proto 与 v8-native-prototype。
|
||||
|
||||
随着 V8 团队对 ml-proto 与 v8-native-prototype 的不断修改和优化,它们最终便成为了 Wasm 早期标准的一部分。与此同期出现的,还有一个名为 “sexpr-wasm” 的内部工具 ,在当时这个工具用于对这两种格式进行相互转换。随着 Wasm 的标准化,它也同样成为了 Wasm 常用调试工具的一部分,这也就是我们所熟知的 —— WABT。
|
||||
|
||||
Chrome V8 团队作为参与过 PNaCL 与 ASM.js 这两个标准制定的团队,在设计和实现 Wasm 时也同样参考了很多从这两种技术中总结下来的优缺点。而这些经验也将会帮助 Wasm 做好准备,避开那些曾经走过的坑。最后,这些经验使得 Wasm 能够以一种更好的方式,展现在人们的面前。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
实际上在 Wasm 真正出现之前,人们就已经开始尝试探索各类新型技术以赋予 Web 应用更高的运行效率。
|
||||
|
||||
从 NaCl、PNaCl 到 ASM.js,它们主要有三点共同特征:-
|
||||
1.源码中都使用了类型明确的变量;-
|
||||
2.应用都拥有独立的运行时环境,并且与原有的 JavaScript 运行时环境分离;-
|
||||
3.支持将原有的 C/C++ 应用通过某种方式转换到基于这些技术的实现,并可以直接运行在 Web 浏览器中。
|
||||
|
||||
Wasm 这项技术的设计与实现,离不开从这些“前辈”们身上学习到的经验。从表面上来看,互联网技术迭代飞快。但实际上,当稍微深入和总结之后,你就会发现其实它们都有着基本相同的,想要去解决的目标问题,比如对于性能的执著要求。以及十分类似的技术解决方案,比如尽最大可能去确定那些能够确定、不会发生变化的部分(比如类型),然后再以此为基础进行优化。 Wasm 也不例外。
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
你觉得就目前的 Web 技术领域而言,存在着哪些困境?或者说需要去解决和优化的地方?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
163
专栏/WebAssembly入门课/03WebAssembly是一门新的编程语言吗?.md
Normal file
163
专栏/WebAssembly入门课/03WebAssembly是一门新的编程语言吗?.md
Normal file
@ -0,0 +1,163 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 WebAssembly 是一门新的编程语言吗?
|
||||
你好,我是于航。
|
||||
|
||||
“WebAssembly(缩写为 Wasm)是一种基于堆栈式虚拟机的二进制指令集。Wasm 被设计成为一种编程语言的可移植编译目标,并且可以通过将其部署在 Web 平台上,以便为客户端及服务端应用程序提供服务”。这是 Wasm 官网给出的一段,对 “Wasm 是什么?” 这个问题的解答。
|
||||
|
||||
其实,在开设这门课程之前,我曾在国内的各类博客和资讯网站上查阅过很多有关 Wasm 的相关资料。发现大多数文章都会声称 “Wasm 是一种新型的编程语言”。但事实真的是这样的吗?希望本篇文章的内容,能够给你心中的这个问题一个更加明确的答案。要想了解 Wasm 究竟是什么,我们还要先从“堆栈机模型”开始说起。
|
||||
|
||||
堆栈机模型
|
||||
|
||||
堆栈机,全称为“堆栈结构机器”,即英文的 “Stack Machine”。堆栈机本身是一种常见的计算模型。换句话说,基于堆栈机模型实现的计算机,无论是虚拟机还是实体计算机,都会使用“栈”这种结构来实现数据的存储和交换过程。栈是一种“后进先出(LIFO)”的数据结构,即最后被放入栈容器中的数据可以被最先取出。
|
||||
|
||||
接下来,我们将尝试模拟堆栈机的实际运行流程。在这个过程中,我们会使用到一些简单的指令,比如 “push”,“pop” 与 “add” 等等。这里你可以把它们想象成一种汇编指令。
|
||||
|
||||
大多数指令在执行时,都会从堆栈机的栈容器中取出若干个所需的操作数,然后根据指令所对应的功能,堆栈机会对取出的操作数进行一定的运算和处理。当这个过程结束后,若指令有需要返回的计算结果,这个值会被重新压入到栈容器中。
|
||||
|
||||
假设此时我们需要计算表达式 “1 + 2” 的值,那么通过栈机,这句表达式会以怎样的方式来执行呢?我们前面提到过,堆栈机中的栈容器,主要是作为程序执行时的数据存储和交换场所。那么对于上述表达式,编译器在实际进行编译时,假设在没有使用任何优化策略的情况下,通常会生成类似如下的这样几条指令。
|
||||
|
||||
|
||||
|
||||
如上图所示,这里我们将编译器生成的指令集合,按照指令从上到下的执行顺序放在左侧。堆栈机中栈容器的当前状态放置在右侧。可以看到,此时的栈容器为空,内部没有任何数据。下面,堆栈机开始执行第一条指令 “push 1”。push 指令会将紧随其后出现的操作数直接压入栈中。当该指令执行完毕后,此时栈容器的状态如下图所示。
|
||||
|
||||
|
||||
|
||||
我们将已经执行完毕的指令用红色进行标记。此时,栈容器的栈底存放着通过第一条 push 指令压入的操作数 “1”。以同样的方式,堆栈机继续执行第二条指令 “push 2”。该条指令执行完毕后,栈容器的状态如下图所示。
|
||||
|
||||
|
||||
|
||||
可以看到,目前栈容器中存放有通过前两条 push 指令压入的操作数 “1” 和 “2”。接下来,堆栈机继续执行第三条 “add” 指令。
|
||||
|
||||
执行这条指令需要两个操作数,因此在执行指令时,堆栈机会首先检查当前的栈容器,看其中存放的元素数量是否满足“大于或等于 2 个”。如果这个条件成立,堆栈机会直接从栈容器的顶部取出两个操作数,然后将它们直接相加,所得到的结果会被再次压入到栈容器中。当最后一条 add 指令执行完毕后,此时栈容器的状态如下图所示。
|
||||
|
||||
|
||||
|
||||
当全部指令执行完毕后,在栈容器中,会存放有表达式 “1 + 2” 在经过堆栈机求值后的结果值。
|
||||
|
||||
寄存器机与累加器机
|
||||
|
||||
刚刚我们通过一个简单的例子,来大致了解了堆栈机模型是什么,以及堆栈机中栈容器与指令间的交互关系。但实际上,除了堆栈机模型以外,还有另外两种曾经使用过,或现在也仍然在广泛使用的计算模型,即“寄存器机”与“累加器机”模型。
|
||||
|
||||
累加器机
|
||||
|
||||
顾名思义,累加器机是使用“累加器”,来作为指令操作数的交换场所。累加器机实际上是一种较为古老的计算模型,它仅能够使用可存放单一值的累加器寄存器(后简称“累加器”)单元,来作为指令操作数的暂存场所。因此,基于累加器机模型设计的指令一般都仅支持一个操作数。
|
||||
|
||||
不仅如此,由于累加器的存储容量有限,因此对于一些需要进行暂存的中间数据,通常都只能够被存放到机器的线性内存中。又由于访问线性内存的速度,一般远远低于访问寄存器的速度,因此从某种程度上来讲,累加器机的指令整体执行效率会相对较低。
|
||||
|
||||
比如,对同样的表达式 “1 + 2” 进行求值,在累加器机中,对应的指令和执行情况,可以大致用如下图示来进行概括。
|
||||
|
||||
|
||||
|
||||
初始状态时,累加器中没有任何数据。接下来,指令按照类似从上到下的顺序开始执行。第一条指令 “load” 会将其后面跟随的立即数(根据指令设计不同,后面也可能会跟随一个线性内存的地址)放到累加器中。当该条指令执行完毕后,累加器机的整体状态如下图所示。
|
||||
|
||||
|
||||
|
||||
此时,累加器中保存的数值为 1。继续,机器执行第二条指令 “add 2”。该条指令会将其后面跟随的立即数,累加到机器的累加器单元中。当最后一条指令执行完毕后,累加器机的终态将如下图所示。此时,累加器中便存放着表达式 “1 + 2” 的计算终值 “3”。
|
||||
|
||||
|
||||
|
||||
以上呢,便是累加器机模型下的指令设计特征,以及机器的整体运作模式。
|
||||
|
||||
寄存器机
|
||||
|
||||
另一种常用的计算模型被称为“寄存器机”。顾名思义,基于这种计算模型的机器,将使用特定的CPU 寄存器组,来作为指令执行过程中数据的存储和交换容器。
|
||||
|
||||
在寄存器机中,由于每一条参与到数据交换和处理的指令,都需要显式地标记操作数所在的寄存器(比如通过别名的方式),因此相较于堆栈机和累加器机,寄存器机模型下的指令相对更长。但相对地,数据的交换过程也变得更加灵活。
|
||||
|
||||
还是拿对表达式 “1 + 2” 进行求值这个例子,我们来看一看寄存器机在执行这句表达式时的具体流程。
|
||||
|
||||
如下图所示,假设在这个机器的 CPU 中,有 ”r0“ 与 ”r1“ 两个通用寄存器。在初始情况下,这两个寄存器中没有存放任何内容。
|
||||
|
||||
|
||||
|
||||
第一条指令 ”load r0, 1“。load 指令将接受两个操作数。第一个为目标寄存器的别名,第二个为一个立即数。当指令执行时,作为第二个操作数的立即数,将会被存放到由第一个操作数指定的寄存器中。该指令执行完毕时,对应的寄存器机整体状态如下图所示。此时,寄存器 r0 中存放有数值 1,而寄存器 r1 中没有存放任何内容。
|
||||
|
||||
|
||||
|
||||
接下来第二条指令。与第一条指令类似,我相信你已经能够猜测出它的作用。这条 “add” 指令会将作为第二个操作数的立即数累加到,由第一个操作数所指定的寄存器中。当指令全部执行完毕后,对应的寄存器机终态将如下图所示。此时,寄存器 r0 中存放有表达式 “1 + 2” 的计算终值 “3”,而寄存器 r1 中仍然没有存放任何内容。
|
||||
|
||||
|
||||
|
||||
在上述整个指令的执行流程中,我们全程都没有使用到寄存器 r1,这也是寄存器机的一个优点。对于某些复杂的计算流程,寄存器机可以对执行流程进行优化。而优化策略的实施便得益于其拥有的众多数据暂存容器,也就是寄存器。
|
||||
|
||||
三种计算模型的比较
|
||||
|
||||
总的来看,包括“堆栈机”、“累加器机”以及“寄存器机”在内的三种计算模型,它们都拥有其各自的特点与使用场景。
|
||||
|
||||
|
||||
堆栈机使用栈结构作为数据的存储与交换容器,由于其“后进先出”的特性,使得我们无法直接对位于栈底的数据进行操作。因此在某些情况下,机器会使用额外的指令来进行栈数据的交换过程,从而损失了一定的执行效率。但另一方面,堆栈机模型最为简单且易于实现,对应生成的指令代码长短大小适中。
|
||||
|
||||
累加器机由于其内部只有一个累加器寄存器可用于暂存数据,因此在指令的执行过程中,可能会频繁请求机器的线性内存,从而导致一定的性能损耗。但另一方面,由于累加器模型下的指令最多只能有一个操作数,因此对应的指令较为精简。
|
||||
|
||||
寄存器机内大多数与数据操作相关的指令,都需要在执行时指定目标寄存器,这无疑增加了指令的长度。过于灵活的数据操作,也意味着寄存器的分配和使用规则变得复杂。但相对的,众多的数据暂存容器,给予了寄存器机更大的优化空间。因此,通常对于同样的一段计算逻辑,基于寄存器机模型,可以生成更为高效的指令执行结构。
|
||||
|
||||
|
||||
ISA 与 V-ISA
|
||||
|
||||
我们前面介绍了三种不同的计算模型,总体来看你会发现,对应于每一种计算模型的指令,都有着不同的基本结构。比如指令可以接受的操作数个数、可操作数据所存放的位置,以及指令与指令之间交互方式的细微差别等等。
|
||||
|
||||
通常来说,对于可以应用在诸如 i386、X86-64 等实际存在的物理系统架构上的指令集,我们一般称之为 ISA(Instruction Set Architecture,指令集架构)。而对另外一种使用在虚拟架构体系中的指令集,我们通常称之为 V-ISA,也就是 Virtual(虚拟)的 ISA。
|
||||
|
||||
对这些 V-ISA 的设计,大多都是基于堆栈机模型进行的。而 Wasm 就是这样的一种 V-ISA。
|
||||
|
||||
Wasm 之所以会选择堆栈机模型来进行指令的设计,其主要原因是由于堆栈机本身的设计与实现较为简单。快速的原型实现可以为 Wasm 的未来发展预先试错。
|
||||
|
||||
另一个重要原因是,借助于堆栈机模型的栈容器特征,可以使得 Wasm 模块的指令代码验证过程变得更加简单。
|
||||
|
||||
简单的实现易于 Wasm 引擎与浏览器的集成。基于堆栈机的结构化控制流,通过对 Wasm 指令进行 SSA(Static Single Assignment Form,静态单赋值形式)变换,可以保证即使是在堆栈机模型下,Wasm 代码也能够有着较好的执行性能。而堆栈机模型本身长短适中的指令长度,确保了 Wasm 二进制模块能够在相同体积下,拥有着更高密度的指令代码。
|
||||
|
||||
Wasm 虚拟指令集
|
||||
|
||||
到这里,我们已经知道了 Wasm 是一种基于堆栈机模型设计的 V-ISA 指令集。那下面就让我们来一起看看它的真实面目。如下所示,是一段标准的 Wasm 指令。这段指令的功能与我们之前在介绍三种计算模型时所使用的例子一样。
|
||||
|
||||
i32.const 1
|
||||
i32.const 2
|
||||
i32.add
|
||||
|
||||
|
||||
前两条指令使用了 “i32.const”,这个指令会将紧随其后的立即数作为一个 i32 类型,也就是 32 位整数类型的值,压入到堆栈机的栈容器中。
|
||||
|
||||
最后一条指令 “i32.add”,会取出位于栈容器顶部的两个 i32 类型的值,并相加,然后再将计算结果重新放回到栈容器中。同样的,堆栈机在实际执行这条指令前,也会首先检查当前的栈容器顶部是否含有至少两个 i32 类型的值。
|
||||
|
||||
可以看到,上述这段 Wasm 指令的执行方式,与我们在介绍堆栈机模型时,所采用的那个案例中的指令执行流程完全一样。相信此时的你,一定会对本文开头 “Wasm 是什么?” 这个问题的答案有了新的认识。
|
||||
|
||||
另外要提到的是,类比汇编语言与机器码。这里我们看到的诸如 “i32.const” 与 “i32.add” ,其实都是 Wasm 这个 V-ISA 指令集中,各个指令所对应的文本助记符(mnemonic)。实际当这些助记符被编译到 Wasm 二进制模块中时,会使用助记符所对应的二进制字节码(一般被称为 OpCode,你可以简单地将其理解为一些二进制数字),并配合一些编码算法来压缩整个二进制模块文件的体积。
|
||||
|
||||
最后一点需要你知道的是,Wasm 虽然有着类似汇编语言的这种“助记符”形式,但在大多数情况下,它仅被作为诸如 C/C++ 等高级编程语言的最终编译目标。编译器会自动处理从这些高级语言源代码到 Wasm 二进制指令的转换过程。而这也正如我们在开头所提到的那样,官方声称的 ”Wasm 被设计成为一种编程语言的可移植编译目标“。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
本文开篇我们介绍了三种常见的计算模型,分别是“堆栈机模型”、“累加器机模型”以及“寄存器机模型”。我们把在这三种不同计算模型下,对表达式 “1 + 2” 进行求值时,所使用的对应不同类型的指令与数据存储方式,进行了对比。
|
||||
|
||||
接下来我们讲解了 ISA 与 V-ISA 的区别,即:前者一般指应用在实际物理架构上的指令集,而后者通常指应用于虚拟架构体系的指令集。 Wasm 便是一种基于堆栈机设计的 V-ISA 指令集。包括 Wasm 在内的 ISA 与 V-ISA 指令集,它们都有着指令集所相对应的助记符形式,以及实际用于物理机器,或虚拟机执行的对应二进制字节码形式。
|
||||
|
||||
最后,我们再回到本文的题目。那么你觉得 WebAssembly 是一门新的编程语言吗?对我来说,它不是一门编程语言。因为它完全不同于我们常见的高级程序设计语言,我们通常仅将其用作编译器的一种新的编译目标。但它又可以是一门“编程语言”,因为我们可以通过助记符的形式来直接进行 Wasm 指令集程序的编写。相较于汇编语言来讲,你也可以将 Wasm 看作是一门低级的编程语言。那么对你来说,答案是什么呢?
|
||||
|
||||
课后练习
|
||||
|
||||
最后,我们来做一个练习题吧。
|
||||
|
||||
结合我们之前介绍的堆栈机指令的执行规则和流程,你来猜一猜当下面这段 Wasm 指令执行完毕时,堆栈机的栈容器中会剩余几个值?它们的值分别是多少呢?
|
||||
|
||||
关于这些指令的具体执行规则,你可以在这里进行查找。
|
||||
|
||||
i32.const 1
|
||||
i32.const 1
|
||||
i32.eq
|
||||
i32.const 10
|
||||
i32.const 10
|
||||
i32.add
|
||||
i32.mul
|
||||
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
200
专栏/WebAssembly入门课/04WebAssembly模块的基本组成结构到底有多简单?.md
Normal file
200
专栏/WebAssembly入门课/04WebAssembly模块的基本组成结构到底有多简单?.md
Normal file
@ -0,0 +1,200 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 WebAssembly 模块的基本组成结构到底有多简单?
|
||||
你好,我是于航。今天我来和你聊一聊 Wasm 模块的基本组成结构与字节码分析。
|
||||
|
||||
在之前的课程中,我们介绍了 Wasm 其实是一种基于“堆栈机模型” 设计的 V-ISA 指令集。在这节课中,我们将深入 Wasm 模块的字节码结构,探究它在二进制层面的基本布局,以及内部各个结构之间的协作方式。
|
||||
|
||||
那为什么要探究 Wasm 在二进制层面的基本布局呢?因为在日常的开发实践中,我们通常只是作为应用者,直接将编译好的 Wasm 二进制模块文件,放到工程中使用就完事了,却很少会去关注 Wasm 在二进制层面的具体组成结构。
|
||||
|
||||
但其实只有在真正了解 Wasm 模块的二进制组成结构之后,你才能够知道浏览器引擎在处理和使用一个 Wasm 模块时究竟发生了什么。所以今天我们就将深入到这一部分内容中,透过现象看本质,为你揭开 Wasm 模块内部组成的真实面目 —— Section。相信通过这一讲,你能够从另一个角度看到 Wasm 的不同面貌。
|
||||
|
||||
Section 概览
|
||||
|
||||
从整体上来看,同 ELF 二进制文件类似,Wasm 模块的二进制数据也是以 Section 的形式被安排和存放的。Section 翻译成中文是“段”,但为了保证讲解的严谨性,以及你在理解上的准确性,后文我会直接使用它的英文名词 Section。
|
||||
|
||||
对于 Section,你可以直接把它想象成,一个个具有特定功能的一簇二进制数据。通常,为了能够更好地组织模块内的二进制数据,我们需要把具有相同功能,或者相关联的那部分二进制数据摆放到一起。而这些被摆放在一起,具有一定相关性的数据,便组成了一个个 Section。
|
||||
|
||||
换句话说,每一个不同的 Section 都描述了关于这个 Wasm 模块的一部分信息。而模块内的所有 Section 放在一起,便描述了整个模块在二进制层面的组成结构。在一个标准的 Wasm 模块内,以现阶段的 MVP 标准为参考,可用的 Section 有如下几种。
|
||||
|
||||
|
||||
|
||||
要注意的是,在我们接下来将要讲解的这些 Section 中,除了其中名为 “Custom Secton”,也就是“自定义段”这个 Section 之外,其他的 Section 均需要按照每个 Section 所专有的 Section ID,按照这个 ID 从小到大的顺序,在模块的低地址位到高地址位方向依次进行“摆放”。下面我来分别讲解一下这些基本 Section 的作用和结构。
|
||||
|
||||
单体 Section
|
||||
|
||||
首先我们来讲解的这部分 Section 被我划分到了“单体 Section”这一类别。也就是说,这一类 Section 一般可以独自描述整个模块的一部分特征(或者说是功能),同时也可以与其他 Section 一起配合起来使用。
|
||||
|
||||
当然,这里要强调的是,这样的划分规则只是来源于我自己的设计,希望能够给你在理解 Section 如何相互协作这部分内容时提供一些帮助。这种划分规则并非来源于标准或者官方,你对此有一个概念就好。
|
||||
|
||||
Type Section
|
||||
|
||||
|
||||
|
||||
首先,第一个出现在模块中的 Section 是 “Type Section”。顾名思义,这个 Section 用来存放与“类型”相关的东西。而这里的类型,主要是指“函数类型”。
|
||||
|
||||
“函数”作为编程语言的基本代码封装单位,无论是在 C/C++ 这类高级编程语言,还是汇编语言(一般被称为 routine、例程,但也可以理解为函数或者方法)这类低级语言中,都有它的身影,而 Wasm 也不例外。在后面的课程中,我们将会再次详细讲解,如何在浏览器中使用这些被定义在 Wasm 模块内,同时又被标记导出的函数方法,现在你只要先了解这些就可以了。
|
||||
|
||||
与大部分编程语言类似,函数类型一般由函数的参数和返回值两部分组成。而只要知道了这两部分,我们就能够确定在函数调用前后,栈上数据的变化情况。因此,对于“函数类型“,你也可以将其直接理解为我们更加常见的一个概念 —— “函数签名”。
|
||||
|
||||
接下来我们试着更进一步,来看看这个 Section 在二进制层面的具体组成方式。我们可以将 Type Section 的组成内容分为如下两个部分,分别是:所有 Section 都具有的通用“头部”结构,以及各个 Section 所专有的、不同的有效载荷部分。
|
||||
|
||||
从整体上来看,每一个 Section 都由有着相同结构的“头部”作为起始,在这部分结构中描述了这个 Section 的一些属性字段,比如不同类型 Section 所专有的 ID、Section 的有效载荷长度。除此之外还有一些可选字段,比如当前 Section 的名称与长度信息等等。关于这部分通用头部结构的具体字段组成,你可以参考下面这张表。
|
||||
|
||||
|
||||
|
||||
对于表中第二列给出的一些类型,你目前只需要将它们理解为一种特定的编码方式就可以了,关于这些编码方式和数据类型的具体信息,我会在下一节课中进行讲解。“字段”这一列中的 “name_len” 与 “name” 两个字段主要用于 Custom Section,用来存放这个 Section 名字的长度,以及名字所对应的字符串数据。
|
||||
|
||||
对于 Type Section 来说,它的专有 ID 是 1。紧接着排在“头部”后面的便是这个 Section 相关的有效载荷信息(payload_data)。注意,每个不同类型的 Section 其有效载荷的结构都不相同。比如,Type Section 的有效载荷部分组成如下表所示。
|
||||
|
||||
|
||||
|
||||
可以看到,Type Section 的有效载荷部分是由一个 count 字段和多个 entries 字段数据组合而成的。其中要注意的是 entries 字段对应的 func_type 类型,该类型是一个复合类型,其具体的二进制组成结构又通过另外的一些字段来描述,具体你可以继续参考我下面这张表。
|
||||
|
||||
|
||||
|
||||
关于表中各个字段的具体说明,你可以参考表格中最后一列的“描述”信息来进行理解。因为其解读方式与上述的 Section 头部十分类似。更详细的信息,你可以按照需求直接参考官方文档来进行查阅。
|
||||
|
||||
Start Section
|
||||
|
||||
|
||||
|
||||
Start Section 的 ID 为 8。通过这个 Section,我们可以为模块指定在其初始化过程完成后,需要首先被宿主环境执行的函数。
|
||||
|
||||
所谓的“初始化完成后”是指:模块实例内部的线性内存和 Table,已经通过相应的 Data Section 和 Element Section 填充好相应的数据,但导出函数还无法被宿主环境调用的这个时刻。关于 Data Section 和 Element Section,我们会在下文给你讲解,这里你只需要对它们有一个大致的概念就可以了。
|
||||
|
||||
对于 Start Section 来说,有一些限制是需要注意的,比如:一个 Wasm 模块只能拥有一个 Start Section,也就是说只能调用一个函数。并且调用的函数也不能拥有任何参数,同时也不能有任何的返回值。
|
||||
|
||||
Global Section
|
||||
|
||||
|
||||
|
||||
Global Section 的 ID 为 6。同样地,从名字我们也可以猜到,这个 Section 中主要存放了整个模块中使用到的全局数据(变量)信息。这些全局变量信息可以用来控制整个模块的状态,你可以直接把它们类比为我们在 C/C++ 代码中使用的全局变量。
|
||||
|
||||
在这个 Section 中,对于每一个全局数据,我们都需要标记出它的值类型、可变性(也就是指这个值是否可以被更改)以及值对应的初始化表达式(指定了该全局变量的初始值)。
|
||||
|
||||
Custom Section
|
||||
|
||||
Custom Section 的 ID 为 0。这个 Section 主要用来存放一些与模块本身主体结构无关的数据,比如调试信息、source-map 信息等等。VM(Virtual Machine,虚拟机)在实例化并执行一个 Wasm 二进制模块中的指令时,对于可以识别的 Custom Section,将会以特定的方式为其提供相应的功能。而 VM 对于无法识别的 Custom Section 则会选择直接忽略。
|
||||
|
||||
VM 对于 Custom Section 的识别,主要是通过它 “头部”信息中的 “name” 字段来进行。在目前的 MVP 标准中,有且仅有一个标准中明确定义的 Custom Section,也就是 “Name Section”。这个 Section 对应的头部信息中,“name” 字段的值即为字符串 “name”。在这个 Section 中存放了有关模块定义中“可打印名称”的一些信息。
|
||||
|
||||
互补 Section
|
||||
|
||||
接下来要讲解的这些 Section 被划分到了“互补 Section”这一类别,也就是说,每一组的两个 Section 共同协作,一同描述了整个 Wasm 模块的某方面特征。
|
||||
|
||||
Import Section 和 Export Section
|
||||
|
||||
|
||||
|
||||
为了方便理解,我给你画了张图,你可以通过它来直观地了解这两个 Section 的具体功能。
|
||||
|
||||
首先是 Import Section,它的 ID 为 2。Import Section 主要用于作为 Wasm 模块的“输入接口”。在这个 Section 中,定义了所有从外界宿主环境导入到模块对象中的资源,这些资源将会在模块的内部被使用。
|
||||
|
||||
允许被导入到 Wasm 模块中的资源包括:函数(Function)、全局数据(Global)、线性内存对象(Memory)以及 Table 对象(Table)。那为什么要设计 Import Section 呢?其实就是希望能够在 Wasm 模块之间,以及 Wasm 模块与宿主环境之间共享代码和数据。我将在实战篇中给你详细讲解,如何在浏览器内向一个正在实例化中的 Wasm 模块,导入这些外部数据。
|
||||
|
||||
与 Import Section 类似,既然我们可以将资源导入到模块,那么同样地,我们也可以反向地将资源从当前模块导出到外部宿主环境中。
|
||||
|
||||
为此,我们便可以利用名为 “Export Section” 的 Section 结构。Export Section 的 ID 为 7,通过它,我们可以将一些资源导出到虚拟机所在的宿主环境中。允许被导出的资源类型同 Import Section 的可导入资源一致。而导出的资源应该如何被表达及处理,则需要由宿主环境运行时的具体实现来决定。
|
||||
|
||||
Function Section 和 Code Section
|
||||
|
||||
|
||||
|
||||
关于 Function Section 与 Code Section 之间的关系,你可以先参考上图,以便有一个直观的印象。Function Section 的 ID 为 3,我想你一定认为,在这个 Section 中存放的是函数体的代码,但事实并非如此。Function Section 中其实存放了这个模块中所有函数对应的函数类型信息。
|
||||
|
||||
在 Wasm 标准中,所有模块内使用到的函数都会通过整型的 indicies 来进行索引并调用。你可以想象这样一个数组,在这个数组中的每一个单元格内都存放有一个函数指针,当你需要调用某个函数时,通过“指定数组下标”的方式来进行索引就可以了。
|
||||
|
||||
而 Function Section 便描述了在这个数组中,从索引 0 开始,一直到数组末尾所有单元格内函数,所分别对应的函数类型信息。这些类型信息是由我们先前介绍的 Type Section 来描述的。
|
||||
|
||||
Type Section 存放了 Wasm 模块使用到的所有函数类型(签名);Function Section 存放了模块内每个函数对应的函数类型,即具体的函数与类型对应关系;而在 Code Section 中存放的则是每个函数的具体定义,也就是实现部分。
|
||||
|
||||
Code Section 的 ID 为 10。Code Section 的组织结构从宏观上来看,你同样可以将它理解成一个数组结构,这个数组中的每个单元格都存放着某个函数的具体定义,也就是函数体对应的一簇 Wasm 指令集合。
|
||||
|
||||
每个 Code Section 中的单元格都对应着 Function Section 这个“数组”结构在相同索引位置的单元格。也就是说举个例子,Code Section 的 0 号单元格中存放着 Function Section 的 0 号单元格中所描述函数类型对应的具体实现。
|
||||
|
||||
当然,上述我们提到的各种“数组”结构,其实并不一定真的是由编程语言中的数组来实现的。只是从各个 Section 概念上的协作和数据引用方式来看,我们可以通过数组来模拟这样的交互流程。具体实现需要依以各个 VM 为准。
|
||||
|
||||
Table Section 和 Element Section
|
||||
|
||||
|
||||
|
||||
同样的,Table Section 与 Element Section 之间的关系,你也可以从上图直观地感受到。Table Section 的 ID 为 4。
|
||||
|
||||
在 MVP 标准中,Table Section 的作用并不大,你只需要知道我们可以在其对应的 Table 结构中存放类型为 “anyfunc” 的函数指针,并且还可以通过指令 “call_indirect” 来调用这些函数指针所指向的函数,这就可以了。Table Section 的结构与 Function Section 类似,也都是由“一个个小格子”按顺序排列而成的,你可以用数组的结构来类比着进行理解。
|
||||
|
||||
值得说的一点是,在实际的 VM 实现中,虚拟机会将模块的 Table 结构,初始化在独立于模块线性内存的区域中,这个区域无法被模块本身直接访问。因此 Table 中这些“小格子”内具体存放的值,对于 Wasm 模块本身来说是不可见的。
|
||||
|
||||
所以在使用 call_indirect 指令时,我们只能通过 indicies,也就是“索引”的方式,来指定和访问这些“小格子”中的内容。这在某种程度上,保证了 Table 中数据的安全性。
|
||||
|
||||
在默认情况下,Table Section 是没有与任何内容相关联的,也就是说从二进制角度来看,在Table Section 中,只存放了用于描述某个 Table 属性的一些元信息。比如:Table 中可以存放哪种类型的数据?Table 的大小信息?等等。
|
||||
|
||||
那为了给 Table Section 所描述的 Table 对象填充实际的数据,我们还需要使用名为 Element Section 的 Section 结构。Element Section 的 ID 为 9,通过这个 Section,我们便可以为 Table 内部填充实际的数据。
|
||||
|
||||
Memory Section 和 Data Section
|
||||
|
||||
|
||||
|
||||
Memory Section 的 ID 为 5。同样,从这个 Section 的名字中我们就基本能够猜到它的用途。同 Table Section 的结构类似,借助 Memory Section,我们可以描述一个 Wasm 模块内所使用的线性内存段的基本情况,比如这段内存的初始大小、以及最大可用大小等等。
|
||||
|
||||
Wasm 模块内的线性内存结构,主要用来以二进制字节的形式,存放各类模块可能使用到的数据,比如一段字符串、一些数字值等等。
|
||||
|
||||
通过浏览器等宿主环境提供的比如 WebAssembly.Memory 对象,我们可以直接将一个 Wasm 模块内部使用的线性内存结构,以“对象”的形式从模块实例中导出。而被导出的内存对象,可以根据宿主环境的要求,做任何形式的变换和处理,或者也可以直接通过 Import Section ,再次导入给其他的 Wasm 模块来进行使用。
|
||||
|
||||
同样地,在 Memory Section 中,也只是存放了描述模块线性内存属性的一些元信息,如果要为线性内存段填充实际的二进制数据,我们还需要使用另外的 Data Section。Data Section 的 ID 为 11。
|
||||
|
||||
魔数和版本号
|
||||
|
||||
到这里呢,我们就已经大致分析完在 MVP 标准下,Wasm 模块内 Section 的二进制组成结构。但少侠且慢,Section 信息固然十分重要,但另一个更重要的问题是:我们如何识别一个二进制文件是不是一个合法有效的 Wasm 模块文件呢?其实同 ELF 二进制文件一样,Wasm 也同样使用“魔数”来标记其二进制文件类型。所谓魔数,你可以简单地将它理解为具有特定含义/功能的一串数字。
|
||||
|
||||
一个标准 Wasm 二进制模块文件的头部数据是由具有特殊含义的字节组成的。其中开头的前四个字节分别为 “(高地址)0x6d 0x73 0x61 0x0(低地址)”,这四个字节对应的 ASCII 可见字符为 “asm”(第一个为空字符,不可见)。
|
||||
|
||||
接下来的四个字节,用来表示当前 Wasm 二进制文件所使用的 Wasm 标准版本号。就目前来说,所有 Wasm 模块该四个字节的值均为 “(高地址)0x0 0x0 0x0 0x1(低地址)”,即表示版本 1。在实际解析执行 Wasm 模块文件时,VM 也会通过这几个字节来判断,当前正在解析的二进制文件是否是一个合法的 Wasm 二进制模块文件。
|
||||
|
||||
在这节课的最后,我们一起来分析一个简单的 Wasm 模块文件的二进制组成结构。这里为了方便你理解,我简化了一下分析流程。我们将使用以下 C/C++ 代码所对应生成的 Wasm 二进制字节码来作为例子进行讲解:
|
||||
|
||||
int add (int a, int b) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,我们定义了一个简单的函数 “add”。这个函数接收两个 int 类型的参数,并返回这两个参数的和。我们使用一个线上的名为 WasmFiddle 的在线 Wasm 编译工具,将上述代码编译成对应的 Wasm 二进制文件,并将它下载到本地。然后,我们可以使用 “hexdump” 命令来查看这个二进制文件的字节码内容。对于这个命令的实际运行结果,你可以参考下面的这张图。
|
||||
|
||||
|
||||
|
||||
你可以看到,最开始红色方框内的前八个字节 “0x0 0x61 0x73 0x6d 0x1 0x0 0x0 0x0” 便是我们之前介绍的, Wasm 模块文件开头的“魔数”和版本号。这里需要注意地址增长的方向是从左向右。
|
||||
|
||||
接下来的 “0x1” 是 Section 头部结构中的 “id” 字段,这里的值为 “0x1”,表明接下来的数据属于模块的 Type Section。紧接着绿色方框内的五个十六进制数字 “0x87 0x80 0x80 0x80 0x0” 是由 varuint32 编码的 “payload_len” 字段信息,经过解码,它的值为 “0x7”,表明这个 Section 的有效载荷长度为 7 个字节(关于编解码的具体过程我们会在下一节课中进行讲解)。
|
||||
|
||||
根据这节课一开始我们对 Type Section 结构的介绍,你可以知道,Type Section 的有效载荷是由一个 “count” 字段和多个 “entries” 类型数据组成的。因此我们可以进一步推断出,接下来的字节 “0x1” 便代表着,当前 Section 中接下来存在的 “entries” 类型实体的个数为 1 个。
|
||||
|
||||
根据同样的分析过程,你可以知道,紧接着紫色方框内的六个十六进制数字序列 “0x60 0x2 0x7f 0x7f 0x1 0x7f” 便代表着“一个接受两个 i32 类型参数,并返回一个 i32 类型值的函数类型”。同样的分析过程,也适用于接下来的其他类型 Section,你可以试着结合官方文档给出的各 Section 的详细组成结构,来将剩下的字节分别对应到模块的不同 Section 结构中。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这里,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
今天我们主要介绍了一个 Wasm 模块在二进制层面的具体组成结构。每一个 Wasm 模块都是由多个不同种类的 Section 组成的,这些 Section 按照其专有 ID 从小到大的顺序被依次摆放着。
|
||||
|
||||
其中的一些 Section 可以独自描述 Wasm 模块某个方面的特性,而另外的 Section 则需要与其他类型的 Section 一同协作,来完成对模块其他特性的完整定义。
|
||||
|
||||
除了这些专有 Section,模块还可以通过 Custom Section 来支持一些自定义功能。这个 Section 一般可以用于提供一些 VM 专有的、而可能又没有被定义在 Wasm 标准中的功能,比如一些与调试相关的特性等等。
|
||||
|
||||
最后,我们还介绍了整个 Wasm 模块中最为重要的,位于模块二进制代码最开始位置的“魔数”以及“版本号”。这两个字段主要会被 VM 用于对 Wasm 模块的类型进行识别,当 VM 检测到二进制文件中的某一个字段不符合规范时,则会立即终止对该模块的初始化和后续处理。这里我放了一张脑图,你可以通过这张图,对 Wasm 模块的整体结构有个更直观的认识。
|
||||
|
||||
|
||||
|
||||
课后思考
|
||||
|
||||
本节课最后,我来给你留一个思考题:
|
||||
|
||||
尝试去了解一下 ELF 格式的 Section 结构,并谈谈它与 Wasm Section 在设计上的异同之处?
|
||||
|
||||
好,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
236
专栏/WebAssembly入门课/05二进制编码:WebAssembly微观世界的基本数据规则是什么?.md
Normal file
236
专栏/WebAssembly入门课/05二进制编码:WebAssembly微观世界的基本数据规则是什么?.md
Normal file
@ -0,0 +1,236 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 二进制编码:WebAssembly 微观世界的基本数据规则是什么?
|
||||
你好,我是于航。
|
||||
|
||||
在上节课的最后,我举了一个简单的例子,来帮助你理解了 Wasm 二进制模块内部字节码的基本结构。在这短短的几十个十六进制数字中,我们看到了组成 Wasm 模块所不可或缺的“魔数”与“版本号”编码,以及组成了各个 Section 结构的专有编码。
|
||||
|
||||
在这些字节码中,Wasm 会使用不同的编码方案来处理不同的字段数据。比如对于 Section 的通用头部结构来说,Wasm 会用名为 “varuint7” 的编码方式,来编码各个 Section 的专有 ID。
|
||||
|
||||
除此之外,对于字符串以及浮点数,Wasm 也会分别通过 UTF-8 以及 IEEE-754 编码来将这些字面量值转换为对应的二进制编码,并存储到最终的 Wasm 二进制模块文件中。
|
||||
|
||||
那么本节课,我们就来一起看看 Wasm 所使用的这些数据编码方式,它们决定了 Wasm 在二进制层面的具体数据存储规则。
|
||||
|
||||
字节序
|
||||
|
||||
首先,作为字节码组成方式最为重要的一个特征,我们来聊一聊与具体编码方案无关的另外一个话题 —— 字节序。
|
||||
|
||||
那么什么是“字节序”呢?相信仅从字面上理解,你就能够略知一二。字节序也就是指“字节的排列顺序”。在计算机中,数据是以最原始的二进制 0 和 1 的方式被存储的。在大多数现代计算机体系架构中,计算机的最小可寻址数据为 8 位(bit),即 1 个字节(byte)。
|
||||
|
||||
因此,我们通常将 1 字节定义为一个存储单元的大小。对于连续占用了多个存储单元的数据,我们通常称之为“多字节数据”,组成这段数据的每个字节都会地址连续地进行存放。
|
||||
|
||||
比如,在 C/C++ 中,一个 short 类型的变量便是一个多字节数据。假设我们有一个该类型的变量,其值为 1000。如下图所示,我们将该值在内存中的实际二进制存放形式展示如下。
|
||||
|
||||
|
||||
|
||||
对于一个多字节数据,我们会将其二进制形式下,用于组成该数字值的最低有效数字位与最高有效数字位,分别称为这个数据的“最低有效位(LSB,Least Significant Bit)”和“最高有效位(MSB,Most Significant Bit)”。如上图我们所标记出的那样。
|
||||
|
||||
而当计算机将这个多字节数据存放到物理内存中时,一个对于存储方式的不同抉择便出现了。
|
||||
|
||||
我们是应该选择将多字节数据的 LSB 位,存放到物理内存的低地址段(也就是相应地把 MSB 位存放到高地址段);还是相反地,应该将多字节数据的 LSB 位,存放到物理内存的高地址段(即将 MSB 位相应地存放到低地址段)呢?实际上这两种方式均有被业界所使用,它们分别被称为“小端模式”与“大端模式”。
|
||||
|
||||
小端模式(Little-Endian)
|
||||
|
||||
小端模式即“将多字节数据的 LSB 位存放到内存的低地址位,相应地将 MSB 位存放到内存的高地址位”。
|
||||
|
||||
为了能够让你对这个概念有一个更加直观的理解,你可以参考下面的这张图。这张图是之前我们提到的,那个存储着值 1000 的 short 类型变量,在以“小端模式”进行存放时的内存结构图。
|
||||
|
||||
|
||||
|
||||
你可以看到,这个 short 类型变量值的 LSB 位所对应的低 8 位数据(0xe8),被存放到了内存的低地址位单元(a+1)中。 MSB 位对应的高 8 位数据(0x3)则被存放到了内存的高地址单元(a+2)中。而这便是“小端模式”所独有的特征。
|
||||
|
||||
大端模式(Big-Endian)
|
||||
|
||||
相信当你理解了小端模式后,对于“大端模式”便可以举一反三。与小端模式相反,在大端模式下,多字节数据的 LSB 位所对应部分会被存放到内存的高地址位,而 MSB 对应的部分则会被存放到内存的低地址位。也就是说,将上图内存中两个存储单元所存放的数据 0x3 与 0xe8 的位置相互调换后,便是大端模式下的数据存储方式。
|
||||
|
||||
实际上,大端模式与小端模式两者并没有优劣之分,这两种模式均被广泛地应用在基于不同处理器架构的计算机和一些特殊的应用场景中。在本文接下来的内容中,我们将会讲解 Wasm 二进制数据编码与字节序的一些关系。
|
||||
|
||||
LEB-128 整数编码
|
||||
|
||||
LEB-128 的全称为 “Little Endian Base 128”,是一种用于整数的、基于小端模式的可变长编码。所谓“可变长编码”,是指源数据在经过编码后,所得到的目标编码结果长度并不固定。依据不同的输入数据会得到不同长度的编码结果。
|
||||
|
||||
LEB-128 编码通常可以被分为两种更为具体的形式,即 “Unsigned LEB-128” 与 “Signed LEB-128”。其中前者仅用于编码无符号整数,后者主要用于编码有符号整数。
|
||||
|
||||
在无符号整数中,没有符号位,也就是说在该类型所对应大小范围内的所有比特位,都可以用来保存整数值的一部分。相反,在有符号整数中,类型首位会被用作符号位。
|
||||
|
||||
接下来,我们将分别讲解这两种 LEB-128 编码方式的具体编码规则。
|
||||
|
||||
Unsigned LEB-128
|
||||
|
||||
假设这里我们使用 Unsigned LEB-128 来编码一个正整数 123456。编码的具体步骤如下所示。
|
||||
|
||||
第一步:首先将该十进制数转换为对应原码(与补码相同)的二进制表示方式。
|
||||
|
||||
11110001001000000
|
||||
|
||||
|
||||
第二步:将该二进制数用额外的 “0” 位进行填充,直至其总位数达到最近的一个 7 的倍数。注意这里我们只能够在该数字最高位的左侧进行填充,这样才不会影响数字原本的值。这种为无符号数进行位数扩展的方式我们一般称之为“零扩展”。
|
||||
|
||||
000011110001001000000
|
||||
|
||||
|
||||
第三步:将该二进制数以每 7 个二进制位为一组进行分组,每组之间以空格进行区分。
|
||||
|
||||
0000111 1000100 1000000
|
||||
|
||||
|
||||
第四步:在最高有效位所在分组的左侧填充一个值为 “0” 的二进制位。而在其他分组的最高位左侧填充一个值为 “1” 的二进制位。
|
||||
|
||||
00000111 11000100 11000000
|
||||
|
||||
|
||||
第五步:将上述二进制位分组以每组为单位,转换成对应的十六进制值,即为编码所得结果。
|
||||
|
||||
0x7 0xc4 0xc0
|
||||
|
||||
|
||||
到这里,一次对无符号(Unsigned)整数进行的 LEB-128 编码过程便完成了。对于 Unsigned LEB-128 编码的解码过程,实质上与编码过程完全相反,你可以试着自己去推导看看,能不能从 “0x7 0xc4 0xc0” 这三个十六进制数字解码到原先的无符号整数值 123456。
|
||||
|
||||
Signed LEB-128
|
||||
|
||||
Signed LEB-128 的编码过程,实质上与 Unsigned LEB-128 十分类似。假设我们用它来编码一个有符号的负整数 -123456。编码的具体流程如下所示。
|
||||
|
||||
第一步:首先,我们需要将该数字转换为对应的二进制表示形式。这里需要注意的是,由于 -123456 为一个有符号数,因此在编码时我们需要使用它的补码形式。在下面这段二进制编码中,第一位是符号位,这里的 “1” 表示该二进制序列所对应的十进制数是一个负数。
|
||||
|
||||
100001110111000000
|
||||
|
||||
|
||||
第二步:在这一步中,我们需要对这个有符号数进行“符号扩展”操作。所谓“符号扩展”是指对二进制数的最高位,也就是符号位,其左侧填充指定的二进制位来增加整个有符号数的总位数,并同时保证该二进制数本身的值不会被改变。
|
||||
|
||||
因此,对于负整数来说,我们需要为其填充 “1”,而正整数则填充 “0”。与 Unsigned LEB-128 类似,这里我们要对其进行符号扩展,直到这个二进制数的总位数达到最近的一个 7 的倍数。
|
||||
|
||||
111100001110111000000
|
||||
|
||||
|
||||
第三步:将这个二进制数以每 7 个二进制位为一组进行分组,每组之间以空格进行区分。
|
||||
|
||||
1111000 0111011 1000000
|
||||
|
||||
|
||||
第四步:同样地,在最高有效位所在分组的左侧填充一个值为 “0” 的二进制位。而在其他分组的最高位左侧填充一个值为 “1” 的二进制位。
|
||||
|
||||
01111000 10111011 11000000
|
||||
|
||||
|
||||
第五步:将上述二进制分组以每组为单位,转换成对应的十六进制值,即为编码所得结果。
|
||||
|
||||
0x78 0xbb 0xc0
|
||||
|
||||
|
||||
你可以看到,Signed LEB-128 与 Unsigned LEB-128 在编码规则上的不同,仅体现在整个编码 流程的前两步。这两步的不同主要是由于无符号数与有符号数在计算机内的实际存储方式不同。
|
||||
|
||||
另外还需要注意的是,我们经过编码计算所得的结果,需要按照“小端模式”的方式存放在内存中,这也是 LEB-128 编码的一个重要特征。不仅如此,当在实际应用 LEB-128 编码时,有时由于所编码数字有着固定的大小(比如 64 位),因此会导致实际的编码结果中可能会含有特殊的“填充字节”,比如 “0x80” 与 “0xff”。
|
||||
|
||||
IEEE-754 浮点数编码
|
||||
|
||||
IEEE-754 是一种用于进行浮点数编码的行业标准。你几乎可以在任何与浮点数编码有关的应用场景中看到它的存在。在这一节中,我将以 IEEE-754-1985(后面简称为 IEEE-754)标准为例,来给你介绍浮点数编码的具体方式。
|
||||
|
||||
在 IEEE-754 标准中规定,一个浮点数由三个不同的部分组成,即“符号位”、“指数位”与“小数位”。这里我们以 32 位浮点数 “1234.567” 为例,来介绍它在 IEEE-754 下的实际编码结构。
|
||||
|
||||
首先,32 位的最高位,也就是其 MSB 位会被符号位占用,以标记该浮点数的正负性。同整数一样,该位为 “0” 表示正数,为 “1” 则表示负数。因此对于 “1234.567” 来说,该位的值为 0。
|
||||
|
||||
紧接着符号位的是长度为 8 位的“指数位”。该位用来记录的是,当以“科学计数法”形式表示一个浮点数时,表示法中底数所对应的幂次值。这里我们需要将小数编码成对应的二进制形式,因此所使用科学计数法的底数为 “2”。
|
||||
|
||||
指数位采用了一种名为“移码”的值存储方法,以便能支持负数次幂。当我们计算该位的实际值时,会将从上一步中得到的幂次值与 127 相加,以得到该位的最终结果。对于 “1234.567”,我们可以按照如下步骤来计算对应的指数位值。
|
||||
|
||||
第一步,将浮点数按照整数位和小数位,分别转换成对应的二进制表示形式(对于小数部分,这里我们采用“循环乘 2”的方式,来将其展开成二进制形式)。
|
||||
|
||||
10011010010.10010001001001...
|
||||
|
||||
|
||||
第二步,将从上一步得到的二进制小数,以“科学计数法”的形式进行表示。
|
||||
|
||||
1.001101001010010001001001... * 2^10
|
||||
|
||||
|
||||
第三步,计算指数位对应的十进制数值。即将上述 2 的幂次值 10,再加上 127,得到 137。换算成二进制序列即 “10001001”。
|
||||
|
||||
这样,我们就计算出了浮点数 1234.567 在 IEEE-754 编码下,其组成部分中指数位对应的二进制序列。
|
||||
|
||||
紧接着指数位的是剩下 23 位的“小数位”,该位主要用于存放浮点数在二进制科学计数法形式下,对应的小数部分序列(也就是在上述第二步我们得到的二进制序列中,小数点后面的那部分)。但要注意的是,这部分只有 23 位大小,对于溢出的部分将会被直接截断。
|
||||
|
||||
最后,我们可以得到浮点数 1234.567 在 IEEE-754 编码下的完整组成形式,如下图所示。
|
||||
|
||||
|
||||
|
||||
实际上,在 Wasm 模块中,所有以字面量形式出现的浮点数值,都会通过 IEEE-754 进行编码。而经过编码生成的二进制序列,也将成为 Wasm 二进制模块组成的一部分。
|
||||
|
||||
UTF-8 字符串编码
|
||||
|
||||
对于 UTF-8 编码,你应该是再熟悉不过了。与 LEB-128 类似,UTF-8 也是一种可变长编码,即随着被编码内容的不同,实际产生的编码结果其长度也各不相同。如下图所示,UTF-8 的编码结果值可能会有着从最少 1 个字节到最多 4 个字节不等的长度。
|
||||
|
||||
|
||||
|
||||
UTF-8 的编码过程是基于 Unicode 字符集进行的。在 Unicode 字符集中,每一个字符都有其对应的码位值。比如对于汉字 “极”,它在 Unicode 字符集中的码位值为 “26497”,换算为十六进制即 “0x6781”。因此,我们说,汉字“极”对应的 Unicode 码位值便为 “U+6781”。
|
||||
|
||||
Unicode 虽然规定了各个字符对应的码位值,但却没有规定这些值应该以怎样的格式被计算机存储。 UTF-8 作为众多 Unicode 编码方式中的常用一种,通过上面这种方式巧妙地解决了这个问题。下面我们仍以汉字“极”为例,来介绍 UTF-8 编码的具体过程。
|
||||
|
||||
第一步,我们先将该汉字对应的码位值展开成二进制序列的形式。
|
||||
|
||||
01100111 10000001
|
||||
|
||||
|
||||
第二步,根据上图中第三行对应的规则(码位值位于 [U+0800, U+FFFF] 之间),替换出 UTF-8 编码对应的三个字节。在替换时,你需要将从上一步获得的二进制序列中的各个二进制位,按照从左到右的顺序依次替换掉 UTF-8 编码中用于占位的 “x”。
|
||||
|
||||
11100110 10011110 10000001
|
||||
|
||||
|
||||
第三步,将替换结果转换为对应的十六进制形式,即为 UTF-8 编码的最终结果。
|
||||
|
||||
0xe6 0x9e 0x81
|
||||
|
||||
|
||||
Wasm 数字类型
|
||||
|
||||
到这里,我们已经介绍了在 Wasm 二进制模块中,可能会使用到的所有二进制编码方案。而对于整数的编码,Wasm 并没有“直接使用” LEB-128,而是在其基础之上又做了进一步的约束。
|
||||
|
||||
Wasm 将其模块内部所使用到的数字值分为以下三种类型:
|
||||
|
||||
|
||||
uintN(N = 8 / 16 / 32)
|
||||
|
||||
|
||||
该类型表示了一个占用 N 个 bit 的无符号整数。该整数由 N/8 个字节组成,并以小端模式进行存储。N 的可取值为 8、16 或 32。
|
||||
|
||||
|
||||
varuintN(N = 1 / 7 / 32)
|
||||
|
||||
|
||||
该类型表示一个使用 Unsigned LEB-128 编码,具有 N 个 bit 长度的可变长无符号整数。N 的可取值为 1、7 或 32,对应各类型的可取值范围为 [0, 2^N-1]。需要注意的是,当使用较大数据类型(比如 N 取 32)来存放较小的值,比如 12 时,在经过 Unsigned LEB-128 编码后的二进制序列中,可能会存在用于占位的字节 “0x80”。
|
||||
|
||||
|
||||
varintN(N = 7 / 32 / 64)
|
||||
|
||||
|
||||
该类型与上述的 varuintN 类似,只不过表示的是使用 Signed LEB-128 编码,具有 N 个 bit 长度的可变长有符号整数。N 的可取值为 7、32 或 64,对应各类型的取值范围为 [-2^(N-1), +2^(N-1)-1]。同样地,当在使用一个较大类型(比如 N 取 64)保存较小的整数值时,经过 Signed LEB-128 编码后的二进制序列中,可能会存在用于占位的字节 “0x80” 或 “0xff”。
|
||||
|
||||
还记得我们在上节课介绍 Wasm 模块内部 Section 组成结构时曾提到的,用于组成 Section 通用头部信息的字段中,id 字段对应的数据类型便为 varuint7。其他的还有诸如 payload_len 字段所对应的 varuint32 类型。希望这种联系能够帮助你加深和巩固 Wasm 的知识体系。
|
||||
|
||||
最后需要注意的是,上述类型只是规定了对应类型的字段其可取值范围,但并不代表对应的字段值需要以一个固定的长度来进行编码。比如对于一个类型为 varint32 的字段值,虽然这里的 N 取值为 32,但实际编码时并不需要把数字值先扩展为 32 位。当然,以扩展后的 32 位值来进行编码,结果也是一个有效的编码值。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这里,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
Wasm 使用了不同的编码方式来编码其内部使用到的各类字面量数据,比如整数值、浮点数值,以及字符串值。这些字面量值可能被使用在包括“指令立即数”、“指令 OpCode” 以及 “Section 组成结构”等组成 Wasm 二进制模块的各个部分中。
|
||||
|
||||
对于整数,Wasm 使用 LEB-128 编码方式来编码具有不同长度(N),以及具有不同符号性(Signed / Unsigned)的字面量整数值;对于浮点数,Wasm 使用了业界最常用的 IEEE-754 标准进行编码;而对于字符串,Wasm 也同样采用了业界的一贯选择 —— UTF8 编码。
|
||||
|
||||
通过编码,我们能够确保各数字值类型按照其最为合适的格式,被“摆放”在 Wasm 的二进制字节码序列中。其中用于字符串的 UTF-8 以及用于浮点数的 IEEE-754 编码标准,是我们在日常开发中最为常见的两种编码方式。
|
||||
|
||||
基于 LEB-128 的可变长编码,也可以对整型数值类型有一个很好的二进制表示方式(一个趣事:事实上,在 MVP 标准正式发布初期,社区也曾讨论过使用 Google 的 PrefixVarint 编码来代替 LEB-128,因为某种程度上 PrefixVarint 编解码速度更快。但事实是,由于 LEB-128 更为人所知,因此成为了 MVP 的最终选择)。
|
||||
|
||||
课后练习
|
||||
|
||||
本节课最后,我来给你留一个练习题:
|
||||
|
||||
请你尝试计算一下,有符号数 “-654321” 在 varint32 类型下的可能编码值是多少呢?
|
||||
|
||||
好,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
197
专栏/WebAssembly入门课/06WAT:如何让一个WebAssembly二进制模块的内容易于解读?.md
Normal file
197
专栏/WebAssembly入门课/06WAT:如何让一个WebAssembly二进制模块的内容易于解读?.md
Normal file
@ -0,0 +1,197 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 WAT:如何让一个 WebAssembly 二进制模块的内容易于解读?
|
||||
你好,我是于航。
|
||||
|
||||
在前面的两节课中,我们分别讲解了 Wasm 模块在二进制层面的基本组成结构与数据编码方式。在 04 的结尾,我们还通过一个简单的例子,逐个字节地分析了定义在 C/C++ 源代码中的函数,在被编译到 Wasm 之后所对应的字节码组成结构。
|
||||
|
||||
比如字节码 “0x60 0x2 0x7f 0x7f 0x1 0x7f” ,便表示了 Type Section 中定义的一个函数类型(签名)。而该函数类型为 “接受两个 i32 类型参数,并返回一个 i32 类型值”。
|
||||
|
||||
我相信,无论你对 Wasm 的字节码组成结构、V-ISA 指令集中的各种指令使用方式有多么熟悉,在仅通过二进制字节码来分析一个 Wasm 模块时,都会觉得无从入手。那感觉仿佛是在上古时期时,直接面对着机器码来调试应用程序。那么,有没有一种更为简单、更具有可读性的方式来解读一个 Wasm 模块的内容呢?答案,就在 WAT。
|
||||
|
||||
WAT(WebAssembly Text Format)
|
||||
|
||||
首先,我们来直观地感受一下 WAT 的“样貌”。假设我们有如下这样一段 C/C++ 源代码,在这段代码中,我们定义了一个函数 factorial,该函数接受一个 int 类型的整数 n,然后返回该整数所对应的阶乘。现在,我们来将它编译成对应的 WAT 代码。
|
||||
|
||||
int factorial(int n) {
|
||||
if (n == 0) {
|
||||
return 1;
|
||||
} else {
|
||||
return n * factorial(n-1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
经过编译和转换后,该函数对应的 WAT 文本代码如下所示。
|
||||
|
||||
(func $factorial (; 0 ;) (param $0 i32) (result i32)
|
||||
(local $1 i32)
|
||||
(local $2 i32)
|
||||
(block $label$0
|
||||
(br_if $label$0
|
||||
(i32.eqz
|
||||
(get_local $0)
|
||||
)
|
||||
)
|
||||
(set_local $2
|
||||
(i32.const 1)
|
||||
)
|
||||
(loop $label$1
|
||||
(set_local $2
|
||||
(i32.mul
|
||||
(get_local $0)
|
||||
(get_local $2)
|
||||
)
|
||||
)
|
||||
(set_local $0
|
||||
(tee_local $1
|
||||
(i32.add
|
||||
(get_local $0)
|
||||
(i32.const -1)
|
||||
)
|
||||
)
|
||||
)
|
||||
(br_if $label$1
|
||||
(get_local $1)
|
||||
)
|
||||
)
|
||||
(return
|
||||
(get_local $2)
|
||||
)
|
||||
)
|
||||
(i32.const 1)
|
||||
)
|
||||
|
||||
|
||||
WAT 的全称 “WebAssembly Text Format”,我们一般称其为 “WebAssembly 可读文本格式”。它是一种与 Wasm 字节码格式完全等价,可用于编码 Wasm 模块及其相关定义的文本格式。
|
||||
|
||||
这种格式使用 “S-表达式” 的形式来表达 Wasm 模块及其定义,将组成模块各部分的字节码用一种更加线性的、可读的方式进行表达。
|
||||
|
||||
这种文本格式可以被 Wasm 相关的编译工具直接使用,比如 WAVM 虚拟机、Binaryen 调试工具等。不仅如此,Web 浏览器还会在 Wasm 模块没有与之对应的 source-map 数据时(即无法显示模块对应的源语言代码,比如 C/C++ 代码),使用对应的 WAT 可读文本格式代码来作为代替,以方便开发者进行调试。
|
||||
|
||||
OK,既然我们之前提到,WAT 使用了 “S-表达式” 的形式来表达 Wasm 模块及其相关定义,那么接下来,我们就来看看这个 “S-表达式” 究竟是什么?
|
||||
|
||||
S-表达式(S-Expression)
|
||||
|
||||
“S-表达式”,又被称为 “S-Expression”,或者简写为 “sexpr”,它是一种用于表达树形结构化数据的记号方式。最初,S-表达式被用于 Lisp 语言,表达其源代码以及所使用到的字面量数据。比如,在 Common Lisp 这个 Lisp 方言中,我们可以有如下形式的一段代码。
|
||||
|
||||
(print
|
||||
(* 2 (+ 3 4))
|
||||
)
|
||||
|
||||
|
||||
不知道你有没有感受到,这段 Lisp 代码与之前我们生成的函数 factorial 所对应 WAT 可读文本代码,在结构上有着些许的相似。在这段代码中,我们调用了名为 print 的方法,将一个简单数学表达式 “2 * (3 + 4)” 的计算结果值,打印到了系统的标准输出流(stdout)中。
|
||||
|
||||
在 “S-表达式” 中,我们使用一对小括号 “()” 来定义每一个表达式的结构。而表达式之间的相互嵌套关系则表达了一定的语义规则。比如在上面的 Lisp 代码中,子表达式 “(* 2 (+ 3 4))” 的值直接作为了 print 函数的输入参数。而对于这个子表达式本身,也通过内部嵌套的括号表达式及运算符,规定了求值的具体顺序和规则。
|
||||
|
||||
不仅如此,每一个表达式在求值时,都会将该表达式将要执行的“操作”,作为括号结构的第一个元素,而对应该操作的具体操作“内容”则紧跟其后。
|
||||
|
||||
这里我将“操作”和“内容”都加上了引号,因为 “S-表达式” 可以被应用于多种不同的场景中,所以这里的操作可能是指一个函数、一个 V-ISA 中的指令,甚至是标识一个结构的标识符。而所对应的“内容”也可以是不同类型的元素或结构。因此,这里你只要了解这种通过括号划分出的所属关系就可以了。
|
||||
|
||||
对一个 “S-表达式” 的求值会从最内层的括号表达式开始。比如对于上述的 Lisp 代码,我们会首先计算其最内层表达式 “(+ 3 4)” 的值。计算完毕后,该括号表达式的位置会由该表达式的计算结果进行替换。以此类推,从内到外,最后计算出整个表达式的值。当然,除了求值,对于诸如 print 函数来说,也会产生一些如“与操作系统 IO 进行交互”之类的副作用(Side Effect)。
|
||||
|
||||
你可以参考下面这张图来理解 “S-表达式” 的组成结构与求值方式(以上述 Lisp 代码为例)。
|
||||
|
||||
|
||||
|
||||
我们再把目光移回到 WAT 身上。既然我们说,WAT 具有与 Wasm 字节码完全等价的表达能力,可以完全表达通过 Wasm 字节码定义的 Wasm 模块内容。那么从高级语言源代码,到 Wasm 模块字节码、再到对应的 WAT 可读文本代码,这三者是如何做到一一对应的呢?
|
||||
|
||||
源码、字节码与 Flat-WAT
|
||||
|
||||
为了能够让你更加直观地看清楚从源代码、Wasm 字节码再到 WAT 三者之间的对应关系,首先我们要做的第一件事就是将对应的 WAT 代码 “拍平(flatten)”,将其变成 “Flat-WAT”。这里还是以“factorial” 函数对应生成的 WAT 可读文本代码为例。
|
||||
|
||||
“拍平”的过程十分简单。正常在通过 “S-表达式” 形式表达的 WAT 代码中,我们通过“嵌套”与“小括号”的方式指定了各个表达式的求值顺序。而 “拍平” 的过程就是将这些嵌套以及括号结构去掉,以“从上到下”的先后顺序,来表达整个程序的执行流程。
|
||||
|
||||
上述 WAT 代码在被“拍平”之后,我们可以得到如下所示的 Flat-WAT 代码(这里我们只列出函数体所对应的部分)。
|
||||
|
||||
(func $factorial (param $0 i32) (result i32)
|
||||
block $label$0
|
||||
local.get $0
|
||||
i32.eqz
|
||||
br_if $label$0
|
||||
local.get $0
|
||||
i32.const 255
|
||||
i32.add
|
||||
i32.const 255
|
||||
i32.and
|
||||
call $factorial
|
||||
local.get $0
|
||||
i32.mul
|
||||
i32.const 255
|
||||
i32.and
|
||||
return
|
||||
end
|
||||
i32.const 1)
|
||||
|
||||
|
||||
然后我们再将对应 “factorial” 函数的 C/C++ 源代码、Wasm 字节码以及上述 WAT 经过转换生成的 Flat-WAT 代码放到一起,相信你会有一个更加直观的感受。如下图所示,你可以看到 Flat-WAT 代码与 Wasm 字节码会有着直观的“一对一”关系。
|
||||
|
||||
|
||||
|
||||
模块结构与 WAT
|
||||
|
||||
除了我们前面看到的,WAT 可以通过“S-表达式”的形式,来描述一个定义在 Wasm 模块内的函数定义以外,WAT 还可以描述与 Wasm 模块定义相关的其他部分,比如模块中各个 Section 的具体结构。如下所示,这是用于构成一个完整 Wasm 模块定义的其他字节码组成部分,所对应的 WAT 可读文本代码。
|
||||
|
||||
(module
|
||||
(table 0 anyfunc)
|
||||
(memory $0 1)
|
||||
(export "memory" (memory $0))
|
||||
(export "factorial" (func $factorial))
|
||||
...
|
||||
)
|
||||
|
||||
|
||||
在这里,我们仍然使用 “S-表达式” 的形式,通过为子表达式指定不同的“操作”关键字,进而赋予每个表达式不同的含义。
|
||||
|
||||
比如带有 “table” 关键字的子表达式,定义了 Table Section 的结构。其中的 “0” 表示该 Section 的初始大小为 0,随后紧跟的 “anyfunc” 表示该 Section 可以容纳的元素类型为函数指针类型。其他的诸如 “memory” 表达式定义了 Memory Section,“export” 表达式定义了 Export Section,以此类推。
|
||||
|
||||
WAT 与 WAST
|
||||
|
||||
在 Wasm 的发展初期,曾出现过一种以 “.wast” 为后缀的文本文件格式,这种文本文件经常被用来存放类似 WAT 的代码内容。
|
||||
|
||||
但实际上,以 “.wast” 为后缀的文本文件通常表示着 “.wat” 的一个超集。也就是说,在该文件中可能会包含有一些,基于 WAT 可读文本格式代码标准扩展而来的其他语法结构。比如一些与“断言”和“测试”有关的代码,而这部分语法结构并不属于 Wasm 标准的一部分。
|
||||
|
||||
相反的,以 “.wat” 为后缀结尾的文本文件,通常只能够包含有 Wasm 标准语法所对应的 WAT 可读文本代码。并且在一个文本文件中,我们也只能够定义单一的 Wasm 模块结构。
|
||||
|
||||
因此,在日常的 Wasm 学习、开发和调试过程中,我更推荐你使用 “.wat” 这个后缀,来作为包含有 WAT 代码的文本文件扩展名。这样可以保障该文件能够具有足够高的兼容性,能够适配大多数的编译工具,甚至是浏览器来进行识别和解析。
|
||||
|
||||
WAT 相关工具
|
||||
|
||||
在这节课的最后,我们来看看与 WAT 相关的编译工具。为了使用下面这些工具,你需要安装名为 WABT(The WebAssembly Binary Toolkit)的 Wasm 工具集。关于如何进行安装,你可以在这里找到答案。安装完毕后,我们便可以使用如下这些工具来进行 WAT 代码的相关处理。
|
||||
|
||||
|
||||
wasm2wat:该工具主要用于将指定文件内的 Wasm 二进制代码转译为对应的 WAT 可读文本代码。
|
||||
wat2wasm:该工具的作用恰好与 wasm2wat 相反。它可以将输入文件内的 WAT 可读文本代码转译为对应的 Wasm 二进制代码。
|
||||
wat-desugar:该工具主要用于将输入文件内的,基于 “S-表达式” 形式表达的 WAT 可读文本代码“拍平”成对应的 Flat-WAT 代码。
|
||||
|
||||
|
||||
上述这三个工具的用法十分简单,默认情况下,转译生成的目标代码将被输出到操作系统的标准输出流中。当然,你也可以通过 “-o” 参数来指定输出结果的保存文件。更详细的信息,你可以直接参考该项目在 Github 上的帮助文档。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
本节课我们主要讲解了 WAT,这是一种可以将 Wasm 二进制字节码基于 “S-表达式” 的结构,用“人类可读”的方式展现出来的文本代码格式。
|
||||
|
||||
WAT 使用嵌套的“括号表达式”结构来表达 Wasm 字节码的内容,表达式由“操作”关键字与相应的“内容”两部分组成。Wasm 字节码与 WAT 可读文本代码两者之间是完全等价的。
|
||||
|
||||
WAT 还有与之相对应的 Flat-WAT 形式的代码。在这个类型的代码中,WAT 内部嵌套的表达式结构(主要是指函数定义部分)将由按顺序平铺开的,由上至下的指令执行结构作为代替。
|
||||
|
||||
除此之外,我们还讲解了 “.wast” 与 “.wat” 两种文本文件格式之间的区别。其中,前者为后者的超集,其内部可能会含有与“测试”和“断言”相关的扩展性语法结构;而后者仅包含有与 Wasm 标准相关的可读文本代码结构。因此,在日常编写 WAT 的过程中,建议你以 “.wat” 作为保存 WAT 代码的文本文件后缀。
|
||||
|
||||
最后,我们还介绍了几个可以用来与 WAT 格式打交道的工具。这几个工具均来自于名为 WABT 的 Wasm 二进制格式工具集,它们的用法都十分简单,相信你可以快速上手。
|
||||
|
||||
课后练习
|
||||
|
||||
最后,我们来做一个小练习吧。
|
||||
|
||||
尝试使用 C/C++ 编写一个“计算第 n 项斐波那契数列值”的函数 fibonacci,然后在 WasmFiddle 上编译你的函数,并查看对应生成的 WAT 可读文本代码。
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
154
专栏/WebAssembly入门课/07WASI:你听说过WebAssembly操作系统接口吗?.md
Normal file
154
专栏/WebAssembly入门课/07WASI:你听说过WebAssembly操作系统接口吗?.md
Normal file
@ -0,0 +1,154 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 WASI:你听说过 WebAssembly 操作系统接口吗?
|
||||
你好,我是于航。
|
||||
|
||||
相信你在刚刚接触到 WebAssembly 这门技术的时候一定有所发现,WebAssembly 这个单词实际上是由两部分组成,也就是 “Web” 和 “Assembly”。
|
||||
|
||||
“Web” 表明了 Wasm 的出身,也就是说它发明并最早应用于 Web 浏览器中, “Assembly” 则表明了 Wasm 的本质,这个词翻译过来的意思是 “汇编”,也就是指代它的 V-ISA 属性。
|
||||
|
||||
鉴于 Wasm 所拥有“可移植”、“安全”及“高效”等特性,Wasm 也被逐渐应用在 Web 领域之外的一些其他场景中。今天我们将要讲解的,便是可以用于将 Wasm 应用到 out-of-web 环境中的一项新的标准 —— WASI(WebAssembly System Interface,Wasm 操作系统接口)。通过这项标准,Wasm 将可以直接与操作系统打交道。
|
||||
|
||||
在正式讲解 WASI 之前,我们先来学习几个与它息息相关的重要概念。在了解了这些概念之后,相信甚至不用我过多介绍,你也能够感受到 WASI 是什么,以及它是如何与 Wasm 紧密结合的。
|
||||
|
||||
Capability-based Security
|
||||
|
||||
第一个我们要讲解的,是一个在“计算机安全”领域中十分重要的概念 —— “Capability-based Security”,翻译过来为“基于能力的安全”。由于业界没有一个相对惯用的中文表达方式,因此我还是保持了原有的英文表达来作为本节的标题,在后面的内容中,我也将直接使用它的英文表达方式,以保证内容的严谨性。
|
||||
|
||||
Capability-based Security 是一种已知的、常用的安全模型。通常来讲,在计算机领域中,我们所提及的 capability 可以指代如 Token、令牌等概念。capability 是一种用于表示某种权限的标记,它可以在用户之间进行传递且无法被伪造。
|
||||
|
||||
在一个使用了 Capability-based Security 安全模型的操作系统中,任何用户对计算机资源的访问,都需要通过一个具体的 capability 来进行。
|
||||
|
||||
Capability-based Security 同时也指代了一种规范用户程序的原则。比如这些用户程序可以根据“最小特权原则”(该原则要求计算环境中的各个模块仅能够访问当下所必需的信息或资源)来彼此直接共享 capability,这样可以使得操作系统仅分配用户程序需要使用的权限,并且可以做到“一次分配,多次使用”。
|
||||
|
||||
Capability-based Security 这个安全模型,通常会跟另外的一种基于“分级保护域”方式实现的安全模型形成对比。
|
||||
|
||||
基于“分级保护域”实现的安全模型,被广泛应用于类 Unix 的各类操作系统中,比如下图所示的操作系统 Ring0 层和 Ring3 层(Ring1 / Ring2 一般不会被使用)便是“分级保护域”的一种具体实现形式。
|
||||
|
||||
|
||||
|
||||
在传统意义上,Ring0 层拥有着最高权限,一般用于内核模式;而 Ring3 层的权限则会被稍加限制,一般用于运行用户程序。当一个运行在 Ring3 层的用户程序,试图去调用只有 Ring0 层进程才有权限使用的指令时,操作系统会阻止调用。这就是“分级保护域”的大致概念。
|
||||
|
||||
反观 Capability-based Security,capability 通过替换在分级保护域中使用的“引用”,来达到提升系统安全性的目的。这里的“引用”是指用于访问资源的一类“定位符”,比如用于访问某个文件资源的“文件路径字符串”便是一个引用。
|
||||
|
||||
引用本身并没有指定实际对应资源的权限信息,以及哪些用户程序可以拥有这个引用。因此,每一次尝试通过该引用来访问实际资源的操作,都会经由操作系统来进行基于“分级保护域”的权限验证。比如验证发起访问的用户是否有权限持有该资源,这种方式便十分适合早期计算机系统的“多用户”特征(每个用户有不同的权限)。
|
||||
|
||||
在具有 capability 概念的操作系统中,只要用户程序拥有了这个 capability,那它就拥有足够的权限去访问对应的资源。从理论上来讲,基于 Capability-based Security 的操作系统,甚至不需要如“权限控制列表(ACL)”这类的传统权限控制机制。
|
||||
|
||||
当然,为了实现上述我们提到的 capability 的能力,每一个 capability 不再是单一的由“字符串”组成的简单数据结构。并且我们还需要保障,capability 的内部结构不会被用户程序直接访问和修改,以防止 capability 本身被伪造。
|
||||
|
||||
相对应的,用户程序只能够通过 capability 暴露出的特定“入口”,来访问对应的系统资源。我们可以用操作系统中常见的一个概念 —— “文件描述符(File Descriptor)”来类比 capability 的概念。如下图所示。
|
||||
|
||||
|
||||
|
||||
我们可以将文件描述符类比为 capability。举个例子,当应用程序在通过 C 标准库中的 “fopen” 函数去打开一个文件时,函数会返回一个非负整数,来表示一个特定文件资源对应的文件描述符。
|
||||
|
||||
在拥有了这个描述符后,应用程序便可以按照在调用 “fopen” 函数时所指定的操作(比如 “w”),来相应地对这个文件资源进行处理。当函数返回负整数时,则表示无法获得该资源。在这些返回的错误代码中,就包含有与“权限不足”相关的调用错误信息。
|
||||
|
||||
最为重要的一点是,拥有某个 capability 的用户程序,可以“任意地”处理这个 capability。比如,可以访问其对应的系统资源、可以将其传递给其他的应用程序来进行使用,或者也可以选择直接将这个 capability 删除。操作系统有义务确保某个特定的 capability 只能够对应系统中的某个特定的资源或操作,以保证安全策略的完备性。
|
||||
|
||||
系统调用(System Call)
|
||||
|
||||
第二个我们要讲解的概念叫做 “System Call”,翻译成中文即“系统调用”(或者也可称为 “操作系统调用”,这里我们使用简称 “系统调用”)。
|
||||
|
||||
还是回到我们在上一小节中曾提到过的一个场景:“使用 C 标准库中的 fopen 函数,来打开一个计算机本地文件”。请试想,当我们在调用这个 fopen 函数打开某个文件时,实际上发生了什么?fopen 函数是如何访问操作系统的文件资源的呢?带着这两个问题,我们一步步来看。
|
||||
|
||||
既然我们说 fopen 函数是 C 标准库中定义的一个函数,那么我们就从某个特定的 C 标准库实现所对应的源代码入手,来看看 fopen 函数的具体实现细节。这里我们以 musl 这个 libc 的实现为例。在它的源代码中,我们可以找到如下这段对 fopen 函数的定义代码(这里只列出了关键的部分)。
|
||||
|
||||
FILE *fopen(const char *restrict filename, const char *restrict mode) {
|
||||
...
|
||||
/* Compute the flags to pass to open() */
|
||||
flags = __fmodeflags(mode);
|
||||
|
||||
fd = sys_open(filename, flags, 0666);
|
||||
if (fd < 0) return 0;
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
代码的具体实现流程和细节我们不做深究,唯一你需要注意的,就是这句函数调用语句 “_fd = sys_open(filename, flags, 0666);_”。在这行语句中,musl 调用了一个名为 “sys_open” 的函数,而在这个函数的背后,就是我们本节内容的主角 —— “系统调用”。
|
||||
|
||||
实际上,任何其他需要与操作系统资源打交道的 C ,甚至是 C++ 标准库函数(包括 fopen 函数在内),都需要通过 “系统调用” 来间接访问和使用这些系统资源。sys_open 函数其实是对系统调用进行了封装,在函数内部会使用内联的汇编代码,去实际调用某个具体的“系统调用”。这里 sys_open 对应的,便是指“用于打开本地文件资源”的那个系统调用。
|
||||
|
||||
每一个系统调用,都对应着需要与操作系统打交道的某个特定功能,并且有着唯一的“系统调用 ID” 与之相对应。在不同的操作系统中,对应同一系统调用的系统调用 ID 可能会发生变化。
|
||||
|
||||
而 C/C++ 标准库的作用,便是为我们提供了一个统一、稳定的编程接口。让我们的程序可以做到“一次编写,到处编译”。从某种程度上来讲,标准库的出现为应用程序源代码提供了“可移植性”。比如让我们不再需要随着操作系统类型的变化,而硬编码不同的系统调用 ID。
|
||||
|
||||
除此之外,标准库还会帮助我们处理系统调用前后需要做的一些事情,比如简化函数参数的传递、对各种异常情况进行处理,以及“关闭文件”之类的“善后”工作。关于用户应用程序与操作系统调用之间的关系,你可以参考我下面绘制的这幅图。
|
||||
|
||||
|
||||
|
||||
WebAssembly 操作系统接口(WASI)
|
||||
|
||||
好了,在讲解完 “Capability-based Security” 以及“系统调用”这两个概念之后,我们再把目光移回到今天的主角 —— WASI。其实从 WASI 对应的全称中,我想你能够猜测到,它肯定与我们在上一节中介绍的“系统调用”有着某种联系(System Call 与 System Interface)。没错,那么接下来我们就一起看看 WASI 究竟是什么。
|
||||
|
||||
我们从“如何在 Web 场景之外使用 Wasm?”这个问题开始说起。我们都知道,Wasm 是一套新的 V-ISA(也就是“虚拟指令集架构”),其中的这些虚拟指令便无法被真实的物理 CPU 硬件直接执行。
|
||||
|
||||
所以如果我们想要在浏览器之外使用 Wasm,就需要提供一种基础设施,来解释并执行这些被存放在 Wasm 二进制模块中的虚拟指令。对于这样的基础设施,我们通常称之为“虚拟机(Virtual Machine)”,或者是 “运行时引擎(Runtime Engine)”。
|
||||
|
||||
OK,假设此时我们已经有了这样的一个虚拟机,可以用于执行 Wasm 的虚拟字节码指令。然后我们希望将这样一段 C/C++ 代码经过编译后,以 Wasm 的形式在这个虚拟机中运行。在这段 C/C++ 代码中,我们使用到了之前提到的 fopen 函数。
|
||||
|
||||
但是问题来了。我们知道,在如 musl 这类 C 标准库的实现中,类似 fopen 这样的函数,最后会被编译为对某个特定平台(IA32、X86-64 等)系统调用的调用过程。这对于 Wasm 来说,会使自己丧失“天生自带”的可移植性。
|
||||
|
||||
单纯对于某一个 Wasm 模块来讲,由于我们并不知道这个模块将会被运行在什么类型的操作系统上,因此我们无法将平台相关的具体信息放到 Wasm 模块中。那如何解决这个问题呢?WASI 给了我们答案。
|
||||
|
||||
WASI 在 Wasm 字节码与虚拟机之间,增加了一层“系统调用抽象层”。比如对于在 C/C++ 源码中使用的 fopen 函数,当我们将这部分源代码与专为 WASI 实现的 C 标准库 “wasi-libc” 进行编译时,源码中对 fopen 的函数调用过程,其内部会间接通过调用名为 “__wasi_path_open” 的函数来实现。这个 __wasi_path_open 函数,便是对实际系统调用的一个抽象。
|
||||
|
||||
__wasi_path_open 函数的具体实现细节会交由各个虚拟机自行处理。也就是说,虚拟机需要在其 Runtime 运行时环境中提供,对 Wasm 模块字节码所使用到的 __wasi_path_open 函数的解析和执行能力的支持。而虚拟机在实际实现这些系统调用抽象层接口时,也需要通过实际的系统调用来进行。只不过这些细节上的处理,对于 Wasm 二进制模块来讲,是完全透明的。
|
||||
|
||||
我们可以将上述提到的 wasi-libc、Wasm 二进制模块、WASI 系统调用抽象层,以及虚拟机基础设施之间的关系,通过下图来直观地展示。
|
||||
|
||||
|
||||
|
||||
实际上,类似 __wasi_path_open 的这类以 “__wasi” 开头的,用于抽象实际系统调用的函数,便是 WASI 的核心组成部分。WASI 根据不同系统调用所提供的不同功能,将这些系统调用对应的 WASI 抽象函数接口,分别划分到了不同的子集合中。
|
||||
|
||||
如下图所示,一个名为 “wasi-core” 的 WASI 标准子集合,包含有对应于“文件操作”与“网络操作”等相关系统调用的 WASI 抽象函数接口。其他如 “crypto”、“multimedia” 等子集合,甚至可以包含与实际系统调用无关的一系列 WASI 抽象系统调用接口。你可以理解为 WASI 所描述的抽象系统调用,是针对 Wasm V-ISA 描述的抽象机器而言。针对这部分抽象系统的具体实现,则会依赖一部分实际的系统调用。
|
||||
|
||||
|
||||
|
||||
WASI 在设计和实现时,需要遵守 Wasm 的“可移植性”及“安全性”这两个基本原则。那下面我们来分别看一看, WASI 及其相关的运行时/虚拟机基础设施,是如何确保能够在设计和实现时满足这两个基本原则的。
|
||||
|
||||
可移植性(Portability)
|
||||
|
||||
对于“可移植性”,其实我们已经在讲解 WASI 时给出了答案。WASI 通过在 Wasm 二进制字节码与虚拟机基础设施之间,提供统一的“系统调用抽象层”来保证 Wasm 模块的可移植性。这样一来,上层的 Wasm 模块可以不用考虑平台相关的调用细节,统一将对实际系统调用的调用过程,转换为对“抽象系统调用”的调用过程。
|
||||
|
||||
而“抽象系统调用”的实现细节,则由下层的相关基础设施来负责处理。基础设施会根据其所在操作系统类型的不同,将对应的抽象系统调用映射到真实的系统调用上。当然,并不是所有的抽象系统调用都需要被映射到真实的系统调用上,因为对于某些抽象系统调用而言,基础设施只是负责提供相应的实现即可。
|
||||
|
||||
这样,一个经过编译生成的 Wasm 二进制模块便可以在浏览器之外也同样保证其可移植性。真正做到“一次编译,到处运行”,“抽象”便是解决这个问题的关键。
|
||||
|
||||
安全性(Security)
|
||||
|
||||
对于“安全性”,我们需要再次回到开头介绍的 “Capability-based Security”。
|
||||
|
||||
实际上,基础设施在真正实现 WASI 标准时,便会采用 “Capability-based Security” 的方式来控制每一个 Wasm 模块实例所拥有的 capability。
|
||||
|
||||
举个例子,假设一个 Wasm 模块想要打开一个计算机本地文件,而且这个模块还是由使用了 fopen 函数的 C/C++ 源代码编译而来,那对应的虚拟机在实例化该 Wasm 模块时,便会将 fopen 对应的 WASI 系统调用抽象函数 “__wasi_path_open” 以某种方式(比如通过包装后的函数指针),当做一个 capability 从模块的 Import Section 传递给该模块进行使用。
|
||||
|
||||
通过这种方式,基础设施掌握了主动权。它可以决定是否要将某个 capability 提供给 Wasm 模块进行使用。若某个 Wasm 模块偷偷使用了一些不为开发者知情的系统调用,那么当该模块在虚拟机中进行实例化时,便会露出马脚。掌握这样的主动权,正适合如今我们基于众多不知来源的第三方库进行代码开发的现状。
|
||||
|
||||
对于没有经过基础设施授权的 capability 调用过程,将会被基础设施拦截。通过相应的日志系统进行收集,这些“隐藏的小伎俩”便会在第一时间被开发者/用户感知,并进行相应的处理。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
本节课,我们主要讲解了什么是 WASI。WASI 通过增加“抽象层”的方式,解决了 Wasm 抽象机器(V-ISA)与实际操作系统调用之间的可移植性问题,这可以保证我们基于 WASI 编写的 Wasm 应用(模块)真正做到“一次编译,到处运行”。抽象出的“Wasm 系统调用层”将交由具体的底层基础设施(虚拟机/运行时)来提供实现和支持。
|
||||
|
||||
不仅如此,基于 Capability-based Security 模型,WASI 得以在最大程度上保证 Wasm 模块的运行时安全。通过配合 Wasm 模块的 Import Section 与 Export Section,运行时便可以细粒度地控制模块实例所能够使用的系统资源,这相较于传统的“分级保护域”模型来说,无疑会更加灵活和安全。每一个 Wasm 模块在运行时都仅能够使用被授权的 capability,而 WASI 中定义的这些系统调用抽象接口便属于众多 capability 中的一种。
|
||||
|
||||
另外你还需要知道的一点是,无论是 Capability-based Security 模型,还是“分级保护域”模型,两者都是如今被广泛使用的安全模型。只不过相对来说,“最小特权原则” 使得 Capability-based Security 模型对权限的控制力度会更加精细,而“分级保护域”模型则是操作系统中广泛使用的一种安全策略。
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
你还能举出哪些场景,是通过增加“抽象层”来解决了某个实际问题的?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
259
专栏/WebAssembly入门课/08API:在WebAssemblyMVP标准下你能做到哪些事?.md
Normal file
259
专栏/WebAssembly入门课/08API:在WebAssemblyMVP标准下你能做到哪些事?.md
Normal file
@ -0,0 +1,259 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 API:在 WebAssembly MVP 标准下你能做到哪些事?
|
||||
你好,我是于航。
|
||||
|
||||
在目前与 Wasm 相关的一系列标准中,我们可以将这些标准主要分为两个部分:“Wasm 核心标准(Core Interfaces)”以及“嵌入接口标准(Embedding interfaces)”。
|
||||
|
||||
其中,“Wasm 核心标准”主要定义了与 “Wasm 字节码”、“Wasm 模块结构”、“WAT 可读文本格式”以及模块验证与指令执行细节等相关的内容。关于这部分标准中的内容,我在前面几节课中,已经有选择性地为你挑选了部分重点进行解读。
|
||||
|
||||
而另一个标准“嵌入接口标准”,则定义了有关 Wasm 在 Web 平台上,在与浏览器进行交互时所需要使用的相关 Web 接口以及 JavaScript 接口。在本节课里,我们将讨论有关于这些 API 接口的内容。相信在学完本节课后你便会知道,在当前的 MVP 标准下,我们能够使用 Wasm 在 Web 平台上做些什么?哪些又是 Wasm 暂时无法做到的?
|
||||
|
||||
Wasm 浏览器加载流程
|
||||
|
||||
那在开始真正讲解这些 API 之前,我们先来看一看,一个 Wasm 二进制模块需要经过怎样的流程,才能够最终在 Web 浏览器中被使用。你可以参考一下我画的这张图,这些流程可以被粗略地划分为以下四个阶段。
|
||||
|
||||
|
||||
|
||||
首先是 “Fetch” 阶段。作为一个客户端 Web 应用,在这个阶段中,我们需要将被使用到的 Wasm 二进制模块,从网络上的某个位置通过 HTTP 请求的方式,加载到浏览器中。
|
||||
|
||||
这个 Wasm 二进制模块的加载过程,同我们日常开发的 Web 应用在浏览器中加载 JavaScript 脚本文件等静态资源的过程,没有任何区别。对于 Wasm 模块,你也可以选择将它放置到 CDN 中,或者经由 Service Worker 缓存,以加速资源的下载和后续使用过程。
|
||||
|
||||
接下来是 “Compile” 阶段。在这个阶段中,浏览器会将从远程位置获取到的 Wasm 模块二进制代码,编译为可执行的平台相关代码和数据结构。这些代码可以通过 “postMessage()” 方法,在各个 Worker 线程中进行分发,以让 Worker 线程来使用这些模块,进而防止主线程被阻塞。此时,浏览器引擎只是将 Wasm 的字节码编译为平台相关的代码,而这些代码还并没有开始执行。
|
||||
|
||||
紧接着便是最为关键的 “Instantiate” 阶段。在这个阶段中,浏览器引擎开始执行在上一步中生成的代码。在前面的几节课中我们曾介绍过,Wasm 模块可以通过定义 “Import Section” 来使用外界宿主环境中的一些资源。
|
||||
|
||||
在这一阶段中,浏览器引擎在执行 Wasm 模块对应的代码时,会将那些 Wasm 模块规定需要从外界宿主环境中导入的资源,导入到正在实例化中的模块,以完成最后的实例化过程。这一阶段完成后,我们便可以得到一个动态的、保存有状态信息的 Wasm 模块实例对象。
|
||||
|
||||
最后一步便是 “Call”。顾名思义,在这一步中,我们便可以直接通过上一阶段生成的动态 Wasm 模块对象,来调用从 Wasm 模块内导出的方法。
|
||||
|
||||
接下来,我们将围绕上述流程中的第二步 “Compile 编译” 与第三步 “Instantiate 实例化”,来分别介绍与这两个阶段相关的一些 JavaScript API 与 Web API。
|
||||
|
||||
Wasm JavaScript API
|
||||
|
||||
模块对象
|
||||
|
||||
映入眼帘的第一个问题就是,我们如何在 JavaScript 环境中表示刚刚说过的 “Compile 编译” 与 “Instantiate 实例化” 这两个阶段的“产物”?为此,Wasm 在 JavaScript API 标准中为我们提供了如下两个对象与之分别对应:
|
||||
|
||||
|
||||
WebAssembly.Module
|
||||
WebAssembly.Instance
|
||||
|
||||
|
||||
不仅如此,上面这两个 JavaScript 对象本身也可以被作为类型构造函数使用,以用来直接构造对应类型的对象。也就是说,我们可以通过 “new” 的方式并传入相关参数,来构造这些类型的某个具体对象。比如,可以按照以下方式来生成一个 WebAssembly.Module 对象:
|
||||
|
||||
// "..." 为有效的 Wasm 字节码数据;
|
||||
bufferSource = new Int8Array([...]);
|
||||
let module = new WebAssembly.Module(bufferSource);
|
||||
|
||||
|
||||
这里的 WebAssembly.Module 构造函数接受一个包含有效 Wasm 二进制字节码的 ArrayBuffer 或者 TypedArray 对象。
|
||||
|
||||
WebAssembly.Instance 构造函数的用法与 WebAssembly.Module 类似,只不过是构造函数的参数有所区别。更详细的 API 使用信息,你可以点击这里进行参考。
|
||||
|
||||
导入对象
|
||||
|
||||
我们曾在之前的课程中介绍过 Wasm 二进制模块内部 “Import Section” 的作用。通过这个 Section,模块便可以在实例化时接收并使用来自宿主环境中的数据。
|
||||
|
||||
Web 浏览器作为 Wasm 模块运行时的一个宿主环境,通过 JavaScript 的形式提供了可以被导入到 Wasm 模块中使用的数据类型,这些数据类型包括函数(Function)、全局数据(Global)、线性内存对象(Memory)以及 Table 对象(Table)。其中除“函数”类型外,其他数据类型分别对应着以下由 JavaScript 对象表示的包装类型:
|
||||
|
||||
|
||||
WebAssembly.Global
|
||||
WebAssembly.Memory
|
||||
WebAssembly.Table
|
||||
|
||||
|
||||
而对于函数类型,我们可以直接使用 JavaScript 语言中的“函数”来作为代替。
|
||||
|
||||
同理,我们也可以通过“直接构造”的方式来创建上述这些 JavaScript 对象。以 “WebAssembly.Memory” 为例,我们可以通过如下方式,来创建一个 WebAssembly.Memory 对象:
|
||||
|
||||
let memory = new WebAssembly.Memory({
|
||||
initial:10,
|
||||
maximum:100,
|
||||
});
|
||||
|
||||
|
||||
|
||||
这里我们通过为构造函数传递参数的方式,指定了所生成 WebAssembly.Memory 对象的一些属性。比如该对象所表示的 Wasm 线性内存其初始大小为 10 页,其最大可分配大小为 100 页。
|
||||
|
||||
需要注意的是,Wasm 线性内存的大小必须是 “Wasm 页” 大小的整数倍,而一个 “Wasm 页” 的大小在 MVP 标准中被定义为了 “64KiB”(注意和 64 KB 的区别。KiB 为 1024 字节,而 KB 为 1000 字节)。
|
||||
|
||||
关于另外的 WebAssembly.Global 与 WebAssembly.Table 这两个类型所对应构造函数的具体使用方式,你可以点击这里进行参考。
|
||||
|
||||
错误对象
|
||||
|
||||
除了上述我们介绍的几个比较重要的 JavaScript WebAssembly 对象之外,还有另外几个与 “Error” 有关的表示某种错误的 “错误对象”。这些错误对象用以表示在整个 Wasm 加载、编译、实例化及函数执行流程中,在其各个阶段中所发生的错误。这些错误对象分别是:
|
||||
|
||||
|
||||
WebAssembly.CompileError 表示在 Wasm 模块编译阶段(Compile)发生的错误,比如模块的字节码编码格式错误、魔数不匹配
|
||||
WebAssembly.LinkError 表示在 Wasm 模块实例化阶段(Instantiate)发生的错误,比如导入到 Wasm 模块实例 Import Section 的内容不正确
|
||||
WebAssembly.RuntimeError 表示在 Wasm 模块运行时阶段(Call)发生的错误,比如常见的“除零异常”
|
||||
|
||||
|
||||
上面这些错误对象也都有对应的构造函数,可以用来构造对应的错误对象。(同样,如果有需要,你可以点击这里进入 MDN 网站参考一下)
|
||||
|
||||
模块实例化方法
|
||||
|
||||
最后一个需要重点介绍的 JavaScript API 主要用来实例化一个 Wasm 模块对象。该方法的原型如下所示:
|
||||
|
||||
|
||||
WebAssembly.instantiate(bufferSource, importObject)
|
||||
|
||||
|
||||
这个方法接受一个包含有效 Wasm 模块二进制字节码的 ArrayBuffer 或 TypedArray 对象,然后返回一个将被解析为 WebAssembly.Module 的 Promise 对象。就像我上面讲的那样,这里返回的 WebAssembly.Module 对象,代表着一个被编译完成的 Wasm 静态模块对象。
|
||||
|
||||
整个方法接受两个参数。除第一个参数对应的 ArrayBuffer 或 TypedArray 类型外,第二个参数为一个 JavaScript 对象,在其中包含有需要被导入到 Wasm 模块实例中的数据,这些数据将通过 Wasm 模块的 “Import Section” 被导入到模块实例中使用。
|
||||
|
||||
方法在调用完成后会返回一个将被解析为 ResultObject 的 Promise 对象。ResultObject 对象包含有两个字段 ,分别是 “module” 以及 “instance”。
|
||||
|
||||
其中 module 表示一个被编译好的 WebAssembly.Module 静态对象;instance 表示一个已经完成实例化的 WebAssembly.Instance 动态对象。所有从 Wasm 模块中导出的方法,都被“挂载”在这个 ResultObject 对象上。
|
||||
|
||||
基于这个方法实现的 Wasm 模块初始化流程如下图所示。你可以看到,整个流程是完全串行的。
|
||||
|
||||
|
||||
|
||||
需要注意的是,WebAssembly.instantiate 方法还有另外的一个重载形式,也就是其第一个参数类型从含有 Wasm 模块字节码数据的 bufferSource,转变为已经编译好的静态 WebAssembly.Module 对象。这种重载形式通常用于 WebAssembly.Module 对象已经被提前编译好的情况。
|
||||
|
||||
模块编译方法
|
||||
|
||||
上面讲到的 WebAssembly.instantiate 方法,主要用于从 Wasm 字节码中一次性进行 Wasm 模块的编译和实例化过程,而这通常是我们经常使用的一种形式。当然你也以将编译和实例化两个步骤分开来进行。比如单独对于编译阶段,你可以使用下面这个 JavaScript API:
|
||||
|
||||
|
||||
WebAssembly.compile(bufferSource)
|
||||
|
||||
|
||||
该方法接收一个含有有效 Wasm 字节码数据的 bufferSource,也就是 ArrayBuffer 或者 TypedArray 对象。返回的 Promise 对象在 Resolve 后,会返回一个编译好的静态 WebAssembly.Module 对象。
|
||||
|
||||
Wasm Web API
|
||||
|
||||
Wasm 的 JavaScript API 标准,主要定义了一些与 Wasm 相关的类型和操作,这些类型和操作与具体的平台无关。为了能够在最大程度上利用 Web 平台的一些特性,来加速 Wasm 模块对象的编译和实例化过程,Wasm 标准又通过添加 Wasm Web API 的形式,为 Web 平台上的 Wasm 相关操作提供了新的、高性能的编译和实例化接口。
|
||||
|
||||
模块流式实例化方法
|
||||
|
||||
不同于 JavaScript API 中的 WebAssembly.instantiate 方法,Web API 中定义的“流式接口”可以让我们提前开始对 Wasm 模块进行编译和实例化过程,你也可以称此方式为“流式编译”。比如下面这个 API 便对应着 Wasm 模块的“流式实例化”接口:
|
||||
|
||||
|
||||
WebAssembly.instantiateStreaming(source, importObject)
|
||||
|
||||
|
||||
为了能够支持“流式编译”,该方法的第一个参数,将不再需要已经从远程加载好的完整 Wasm 模块二进制数据(bufferSource)。取而代之的,是一个尚未 Resolve 的 Response 对象。
|
||||
|
||||
Response 对象(window.fetch 调用后的返回结果)是 Fetch API 的重要组成部分,这个对象代表了某个远程 HTTP 请求的响应数据。而该方法中第二个参数所使用的 Response 对象,则必须代表着对某个位于远程位置上的 Wasm 模块文件的请求响应数据。
|
||||
|
||||
通过这种方式,Web 浏览器可以在从远程位置开始加载 Wasm 模块文件数据的同时,也一并启动对 Wasm 模块的编译和初始化工作。相较于上一个 JavaScript API 需要在完全获取 Wasm 模块文件二进制数据后,才能够开始进行编译和实例化流程的方式,流式编译无疑在某种程度上提升了 Web 端运行 Wasm 应用的整体效率。
|
||||
|
||||
基于流式编译进行的 Wasm 模块初始化流程如下图所示。可以看到,与之前 API 有所不同的是,Wasm 模块的编译和初始化可以提前开始,而不用再等待模块的远程加载完全结束。因此应用的整体初始化时间也会有所减少。
|
||||
|
||||
|
||||
|
||||
模块流式编译方法
|
||||
|
||||
那么既然存在着模块的“流式实例化方法”,便也存在着“流式编译方法”。如下所示:
|
||||
|
||||
|
||||
WebAssembly.compileStreaming(source)
|
||||
|
||||
|
||||
该 API 的使用方式与 WebAssembly.instantiateStreaming 类似,第一个参数为 Fetch API 中的 Response 对象。API 调用后返回的 Promise 对象在 Resolve 之后,会返回一个编译好的静态 WebAssembly.Module 对象。
|
||||
|
||||
同 Wasm 模块的“流式实例化方法”一样,“流式编译方法”也可以在浏览器加载 Wasm 二进制模块文件的同时,提前开始对模块对象的编译过程。
|
||||
|
||||
Wasm 运行时(Runtime)
|
||||
|
||||
这里提到的“运行时”呢,主要存在于我们开头流程图中的 “Call” 阶段。在这个阶段中,我们可以调用从 Wasm 模块对象中导出的函数。每一个经过实例化的 Wasm 模块对象,都会在运行时维护自己唯一的“调用栈”。
|
||||
|
||||
所有模块导出函数的实际调用过程,都会影响着栈容器中存放的数据,这些数据代表着每条 Wasm 指令的执行结果。当然,这些结果也同样可以被作为导出函数的返回值。
|
||||
|
||||
调用栈一般是“不透明”的。也就是说,我们无法通过任何 API 或者方法直接接触到栈容器中存放的数据。因此,这也是 Wasm 保证执行安全的众多因素之一。
|
||||
|
||||
除了调用栈,每一个实例化的 Wasm 模块对象都有着自己的(在 MVP 下只能有一个)线性内存段。在这个内存段中,以二进制形式存放着 Wasm 模块可以使用的所有数据资源。
|
||||
|
||||
这些资源可以是来自于对 Wasm 模块导出方法调用后的结果,即通过 Wasm 模块内的相关指令对线性内存中的数据进行读写操作;也可以是在进行模块实例化时,我们将预先填充好的二进制数据资源以 WebAssembly.Memory 导入对象的形式,提前导入到模块实例中进行使用。
|
||||
|
||||
浏览器在为 Wasm 模块对象分配线性内存时,会将这部分内存与 JavaScript 现有的内存区域进行隔离,并单独管理,你可以参考我下面给你画的这张图。在以往的 JavaScript Memory 中,我们可以存放 JavaScript 中的一些数据类型,这些数据同时也可以被相应的 JavaScript / Web API 直接访问。而当数据不再使用时,它们便会被 JavaScript 引擎的 GC 进行垃圾回收。
|
||||
|
||||
|
||||
|
||||
相反,图中绿色部分的 WebAssembly Memory 则有所不同。这部分内存可以被 Wasm 模块内部诸如 “i32.load” 与 “i32.store” 等指令直接使用,而外部浏览器宿主中的 JavaScript / Web API 则无法直接进行访问。不仅如此,分配在这部分内存区域中的数据,受限于 MVP 中尚无 GC 相关的标准,因此需要 Wasm 模块自行进行清理和回收。
|
||||
|
||||
Wasm 的内存访问安全性是众多人关心的一个话题。事实上你并不用担心太多,因为当浏览器在执行 “i32.load” 与 “i32.store” 这些内存访问指令时,会首先检查指令所引用的内存地址偏移,是否超出了 Wasm 模块实例所拥有的内存地址范围。若引用地址不在上图中绿色范围以内,则会终止指令的执行,并抛出相应的异常。这个检查过程我们一般称之为 “Bound Check”。
|
||||
|
||||
那么,接下来我们再把目光移到 WebAssembly Memory 身上,来看一看它是如何与“浏览器”这个 Web 宿主环境中的 JavaScript API 进行交互的。
|
||||
|
||||
Wasm 内存模型
|
||||
|
||||
根据之前课程所讲的内容,我们知道,每一个 Wasm 模块实例都有着自己对应的线性内存段。准确来讲,也就是由 “Memory Section” 和 “Data Section” 共同“描述”的一个线性内存区域。在这个区域中,以二进制形式存放着模块所使用到的各种数据资源。
|
||||
|
||||
事实上,每一个 Wasm 实例所能够合法访问的线性内存范围,仅限于我们上面讲到的这一部分内存段。对于宿主环境中的任何变量数据,如果 Wasm 模块实例想要使用,一般可以通过以下两种常见的方式:
|
||||
|
||||
|
||||
对于简单(字符 \ 数字值等)数据类型,可以选择将其视为全局数据,通过 “Import Section” 导入到模块中使用;
|
||||
对于复杂数据,需要将其以“字节”的形式,拷贝到模块实例的线性内存段中来使用。
|
||||
|
||||
|
||||
在 Web 浏览器这个宿主环境中,一个内存实例通常可以由 JavaScript 中的 ArrayBuffer 类型来进行表示。ArrayBuffer 中存放的是原始二进制数据,因此在需要读写这段数据时,我们必须指定一个“操作视图(View)”。你可以把“操作视图”理解为,在对这些二进制数据进行读写操作时,数据的“解读方式”。
|
||||
|
||||
举个例子,假设我们想要将字符串 “Hello, world!” ,按照逐个字符的方式写入到线性内存段中,那么在进行写操作时,我们如何知道一个字符所应该占用的数据大小呢?
|
||||
|
||||
根据实际需要,一个字符可能会占用 1 个字节到多个字节不等的大小。而这个“占用大小”便是我们之前提到的数据“解读方式”。在 JavaScript 中,我们可以使用 TypedArray 以某个具体类型作为视图,来操作 ArrayBuffer 中的数据。
|
||||
|
||||
你可以通过下面这张图,来理解一下我们刚刚说的 Wasm 模块线性内存与 Web 浏览器宿主环境,或者说与 JavaScript 之间的互操作关系。
|
||||
|
||||
|
||||
|
||||
当我们拥有了填充好数据的 ArrayBuffer 或 TypedArray 对象时,便可以构造自己的 WebAssembly.Memory 导入对象。然后在 Wasm 模块进行实例化时,将该对象导入到模块中,来作为模块实例的线性内存段进行使用。
|
||||
|
||||
局限性
|
||||
|
||||
一切看起来好像都还不错,但我们现在再来回味一下 MVP 的全称。MVP 全称为 “Minimum Viable Product”,翻译过来是“最小可用产品”。那既然是“最小可用”,当然也就意味着它还有很多的不足。我给你总结了一下,目前可以观测到的“局限性”主要集中在以下几个方面:
|
||||
|
||||
|
||||
无法直接引用 DOM
|
||||
|
||||
|
||||
在 MVP 标准下,我们无法直接在 Wasm 二进制模块内引用外部宿主环境中的“不透明”(即数据内部的实际结构和组成方式未知)数据类型,比如 DOM 元素。
|
||||
|
||||
因此目前通常的一种间接实现方式是使用 JavaScript 函数来封装相应的 DOM 操作逻辑,然后将该函数作为导入对象,导入到模块中,由模块在特定时机再进行间接调用来使用。但相对来说,这种借助 JavaScript 的间接调用方式,在某种程度上还是会产生无法弥补的性能损耗。
|
||||
|
||||
|
||||
复杂数据类型需要进行编解码
|
||||
|
||||
|
||||
还是类似的问题,对于除“数字值”以外的“透明”数据类型(比如字符串、字符),当我们想要将它们传递到 Wasm 模块中进行使用时,需要首先对这些数据进行编码(比如 UTF-8)。然后再将编码后的结果以二进制数据的形式存放到 Wasm 的线性内存段中。模块内部指令在实际使用时,再将这些数据进行解码。
|
||||
|
||||
因此我们说,就目前 MVP 标准而言,Wasm 模块的线性内存段是与外部宿主环境进行直接信息交换的最重要“场所”。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在本节课中,我们主要讲解了 Wasm MVP 相关标准中的 JavaScript API 与 Web API。借助这些 API,我们可以在 Web 平台上通过 JavaScript 代码来与 Wasm 模块进行一系列的交互。
|
||||
|
||||
我们可以用一句话来总结目前 Wasm MVP 标准在 Web 浏览器上的能力:凡是能够使用 Wasm 来实现的功能,现阶段都可以通过 JavaScript 来实现;而能够使用 JavaScript 来实现的功能,其中部分还无法直接通过 Wasm 实现(比如调用 Web API)。
|
||||
|
||||
JavaScript API 提供了众多的包装类型,这样便能够在 JavaScript 环境中表示 Wasm 模块的不同组成部分。比如 WebAssembly.Moulde 对应的 Wasm 模块对象、WebAssembly.Memory 对应的 Wasm 线性内存对象等等。
|
||||
|
||||
除此之外,JavaScript API 中还提供了诸如 WebAssembly.Compile 以及 WebAssembly.instantiate 方法,以用于编译及实例化一个 Wasm 模块对象。
|
||||
|
||||
相对的,Web API 则提供了与 Web 平台相关的一些特殊方法。比如 WebAssembly.compileStreaming 与 WebAssembly.instantiateStreaming。借助这两个 API,我们可以更加高效地完成对 Wasm 模块对象的编译和实例化过程。
|
||||
|
||||
除此之外,我们还讲解了 Wasm 模块在运行时的一些特征,比如“内存模型”。以及目前在 MVP 标准下应用 Wasm 时的一些局限性等等。相信学完本次课程,你可以对 “Wasm 目前在 Web 平台上能够做些什么,哪些事情暂时还无法做到?” 这个问题,有着一个更加深刻的认识。
|
||||
|
||||
最后,我绘制一个 Wasm JavaScript API 脑图,可以供你参考以及回顾本节课的内容。
|
||||
|
||||
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
如果你是曾经使用过 Wasm 的同学,那么你觉得在目前的 MVP 标准下,Wasm 还有着哪些局限性亟待解决?如果你还没有使用过 Wasm,那么你最期待 Wasm 能够支持哪些新的特性呢?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
236
专栏/WebAssembly入门课/09WebAssembly能够为Web前端框架赋能吗?.md
Normal file
236
专栏/WebAssembly入门课/09WebAssembly能够为Web前端框架赋能吗?.md
Normal file
@ -0,0 +1,236 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 WebAssembly 能够为 Web 前端框架赋能吗?
|
||||
你好,我是于航。
|
||||
|
||||
相信现在你已经知道,“WebAssembly” 是由 “Web” 与 “Assembly” 两个单词组成的。前面的 “Web” 代指 Web 平台;后面的 “Assembly” 在我们所熟悉的编程语言体系中,可以理解为“汇编”。
|
||||
|
||||
通常来说,汇编语言给人的第一感觉便是“底层,外加高性能”。而这,也正是第一次听说 Wasm 这门技术的开发者们的第一感受。
|
||||
|
||||
说到 Web 开发,那我们不得不提到层出不穷的 Web 前端开发框架。以 React、Vue.js 及 Angular 为代表的三大框架的出现,使得 Web 前端应用的开发模式,自 2013 年后便开始逐渐从“旧时代”的 jQuery、Prototype.js 走向了以 “MVVM” 框架为主的“新时代”。
|
||||
|
||||
既然我们说 Wasm 起源于 Web,并且它的出现会给未来的 Web 应用开发模式,带来一系列变化。那么,对于这些现阶段在我们日常工作中承担“主力”角色的 Web 前端框架来说,Wasm 会给它们带来怎样的变化呢?未来的 Web 前端开发框架会以怎样的方式与 Wasm 紧密融合呢?
|
||||
|
||||
相信这些问题,是每一个 Web 前端开发同学在接触 Wasm 这项技术之后,都会存在的疑问。今天,我们就来看一看,在如今的 Wasm MVP 标准下,对于这些基于 JavaScript 编写的现代 Web 前端框架我们能够做些什么。
|
||||
|
||||
几种方案的思考
|
||||
|
||||
在上一章的“核心原理篇”中,我们从不同的角度讲解了 Wasm 究竟是什么。那这里我们还是用最精简的方式来概括一下:“Wasm 是一种基于堆栈式虚拟机的二进制指令集,它被设计成为编程语言的可移植编译目标。借助 Web 平台提供的相关接口,我们可以在 Web 浏览器中高效地调用从 Wasm 模块中导出的函数”。
|
||||
|
||||
那我们就根据 Wasm 现阶段所具有的这些能力,来讨论一下 Wasm 对现代 Web 前端开发框架可以产生怎样的影响。我将会根据 Wasm 与框架之间的可能融合程度,来从不同的角度加以讨论。相应的,我们可以总结出如下四种方案:
|
||||
|
||||
|
||||
使用 Wasm 完全重写现有框架
|
||||
使用 Wasm 重写现有框架的核心逻辑
|
||||
使用 Wasm 配合框架增强应用的部分功能
|
||||
使用其他语言构建 Web 前端框架
|
||||
|
||||
|
||||
接下来,我将依次和你讨论上面的这四种情况。
|
||||
|
||||
使用 Wasm 完全重写现有框架
|
||||
|
||||
在这个方案下,我们将使用 Wasm 完全重写现有的 Web 前端框架。而这就需要我们通过 JavaScript 之外的诸如 C/C++、Rust 等第三方静态类型语言,将框架的逻辑全部重写。先不谈能否实现,我们先来看看在这种方案下,前端框架的使用方式会发生怎样的改变。
|
||||
|
||||
在此之前,Web 前端框架的使用方式可以通过如下图来大致描述。
|
||||
|
||||
|
||||
|
||||
你可以看到,除去样式文件(CSS)以外,我们的 Web 应用程序仅由“框架代码”和“应用程序代码”两部分组成。这两部分代码全部由 JavaScript 语言进行编写。HTML 文件负责将这些 JavaScript 代码整合在一起,并确保在页面加载时执行它们。
|
||||
|
||||
当我们将 Web 前端框架使用 Wasm 完全重写后,事情又会变成另外一幅景象。此时的 Web 应用组成结构将如下图所示。
|
||||
|
||||
|
||||
|
||||
除了使用 JavaScript 编写的“应用程序代码”,以及经过编译生成的 Wasm 字节码格式的框架代码以外,我们的项目中还会多出来一部分用作 “Glue Code”(胶水代码)的 JavaScript 代码。那这部分 Glue Code 主要用来做什么呢?这就要从现阶段的 Wasm 标准与 Web 浏览器的可交互性开始说起了。
|
||||
|
||||
无法剥离的 JavaScript 代码
|
||||
|
||||
在现阶段 Wasm 的 MVP 标准中,我们需要通过各类 JavaScript API 与 Web API 来在 Web 平台上与 Wasm 代码(模块)进行交互。这些 API 均只能够通过 JavaScript 代码来进行调用。而所有这些需要与 Wasm 模块直接进行的交互(互操作),都是由包含有 API 调用的 Glue Code 代码完成的。
|
||||
|
||||
恰巧在目前 Wasm 的 MVP 标准中,我们也同样无法直接在 Wasm 字节码中操作 HTML 页面上的 DOM 元素。因此,对于这部分 Web 框架最核心的功能,便也是需要通过借助 Glue Code 调用 Web API 来帮助我们完成的。
|
||||
|
||||
为了达到这个目的,我们需要将 DOM 操作相关的逻辑封装成 JavaScript 函数,然后再通过 Wasm 模块的 Import Section 导入到模块中供其使用。
|
||||
|
||||
因此,框架代码与 Glue Code 之间的协作方式可能会类似如下代码形式。首先是框架对应的 C++ 代码。
|
||||
|
||||
// framework.cpp
|
||||
extern void createEmptyDivElement();
|
||||
int main(int argc, char** argv) {
|
||||
createEmptyDivElement(); // 创建一个空的 "div" 标签;
|
||||
createEmptyDivElement();
|
||||
...
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
然后下面是 Glue Code 对应的 JavaScript 代码。
|
||||
|
||||
// glue.js
|
||||
...
|
||||
WebAssembly.instantiateStreaming(wasmBytes, {
|
||||
env: {
|
||||
// 将函数导入到 Wasm 模块中;
|
||||
createEmptyDivElement: () => document.createElement('div'),
|
||||
...
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
可以看到,在 Glue Code 代码中,我们将封装好的用于调用 “document.createElement” 这个 Web API 去创建空 div 标签的 JavaScript 函数 “createEmptyDivElement”,传递给了用于实例化 Wasm 模块的 WebAssembly.instantiateStreaming 方法。
|
||||
|
||||
在框架所对应的 C++ 代码中,我们使用了这个从 JavaScript 环境导入到 Wasm 模块中的 “createEmptyDivElement” 函数。这里在代码中,所有通过 “extern” 指定的外部函数,都将会在编译至 Wasm 二进制模块后,从模块对应的 Import Section 中获取实际的函数体。
|
||||
|
||||
关于上述的代码示例,你大致有一个印象即可。我们会在“实战篇”中详细介绍一个 Wasm 项目从 0 到 1 的完整构建流程。
|
||||
|
||||
跨上下文频繁调用的开销
|
||||
|
||||
除了上面提到的,即使将 Web 前端框架完全重写并编译至 Wasm,我们也无法在完全脱离 JavaScript Glue Code 的情况下使用框架。另一个由此带来的问题在某些情况下可能会显得更加“致命”,那就是 “Wasm 与 JavaScript 两个上下文环境之间的函数调用开销”。
|
||||
|
||||
在早期的 Firefox 浏览器(版本 62 以前)上,由于实现问题,导致不管是使用 JavaScript 调用从 Wasm 模块中导出的函数,还是在 Wasm 模块内调用从 Web 浏览器导入到模块内的 JavaScript 函数,这两种方式的函数调用成本都十分高昂。在某些情况下,同样的函数调用过程会比 JavaScript 之间的函数调用过程慢约 20 倍。
|
||||
|
||||
但好在 Firefox 在 62 之后的版本中修复了这个问题。并着重优化了 JavaScript 与 Wasm 之间的函数调用效率。甚至在某些情况下,JavaScript 与 Wasm 之间的函数调用效率要高于 JavaScript 之间的函数效率。
|
||||
|
||||
虽然这个问题在 Firefox 上得到了修复,但不可否认的是,在其他浏览器厂商的 Wasm 实现中,也可能会出现类似的性能问题。
|
||||
|
||||
Web 前端框架作为一个需要与 DOM 元素,以及相关 Web API 强相互依赖的技术产品,可想而知其在实际使用过程中,必然会通过 Glue Code 去完成 Wasm 与 JavaScript 之间的频繁函数调用。而以性能为重的 Web 前端框架,则无法忽视这些由于频繁函数调用带来的性能损耗。
|
||||
|
||||
使用 Wasm 重写现有框架的核心逻辑
|
||||
|
||||
在第二种方案下,我们将使用 Wasm 重写 Web 前端框架的核心逻辑,但并非全部。
|
||||
|
||||
如下图所示,在这种情况下,Web 应用的主要组成结构与上一种方案类似,唯一的不同是增加了 Web 框架所对应的 JavaScript 代码实现部分。
|
||||
|
||||
|
||||
|
||||
相较于将整个框架都通过 Wasm 来实现,仅实现框架的核心逻辑部分,可以说更具有现实意义,而这也是现阶段大多数的框架开发者都在实践的方向。
|
||||
|
||||
所谓“核心逻辑”,其实依框架的具体实现不同,无法通过统一、准确的描述来定义。但可以遵循的原则是,这部分逻辑不会涉及与 DOM 或者 Web API 的频繁交互,但其本身却又是“计算密集(compute-intensive)”的。
|
||||
|
||||
这里的“计算密集”可以理解为:包含有大量的纯数学计算逻辑。我们知道,Wasm 十分擅长处理这样的计算密集型逻辑。一个很具有代表性的,可以被 Wasm 重写的组件便是 React Fiber 架构中的 Reconciler(主要用来计算 React 中 VDOM 之间的差异)。
|
||||
|
||||
使用 Wasm 配合框架增强应用的部分功能
|
||||
|
||||
我们继续逐渐递减 Wasm 与框架的“耦合”程度。
|
||||
|
||||
在第三种方案中,从本质上来看,框架本身的代码不会有任何的变化。而 Wasm 也不再着重于优化框架本身的性能。相对地,框架与 Wasm 将会配合起来使用,以优化整个应用的某一部分功能。下面这张图是在这个方案下,一个 Web 应用的基本组成结构。
|
||||
|
||||
|
||||
|
||||
可以看到,这里 Wasm 本身只是作为一个模块,用于优化应用的某方面功能。而 Web 框架本身的源代码组成形式不会发生任何改变,应用仍然还是使用 JavaScript 来构建其主体结构。
|
||||
|
||||
事实上,这是 Wasm 在 Web 上的一种最为典型和常见的应用方式。Wasm 并不尝试取代 JavaScript,而是通过利用其优势来补足或者加以提升 Web 应用在某方面的短板。一个最为常见的例子便是前端的“数据编解码”。
|
||||
|
||||
我们都知道,“编解码”实际上是十分单纯的数学计算,那么这便是 Wasm 能够大显身手的地方。通过替换 Web 应用中原有的基于 JavaScript 实现的编解码逻辑,使用 Wasm 来实现这部分逻辑则会有着明显的性能提升。而且由于这个过程不涉及与 Web API 的频繁交互,Wasm 所能够带来的性能提升程度更是显而易见的。
|
||||
|
||||
使用其他语言构建 Web 前端框架
|
||||
|
||||
最后一种方案相较于之前的几种可能会稍显激进,但随着 Wasm 发展而不断出现的,一批又一批基于此方案实现的 Web 前端框架,值得让我们重新重视起来。
|
||||
|
||||
在此方案下,我们将使用诸如 C++ 和 Rust 等静态类型语言来实现 Web 前端框架。不仅如此,我们也同样需要使用这些语言来编写我们的 Web 应用。类似的框架有基于 Rust 语言的 Yew、Seed,以及基于 Go 语言 Vugu 等等。
|
||||
|
||||
以相对较为“流行”的 Yew 框架为例,我们使用它来编写 Web 前端应用的大致思路,与 React 和 Vue.js 等传统 JavaScript Web 前端框架的形式十分类似。以下代码展示了如何使用 Rust 语言基于 Yew 框架,来构建一个基本的 Web 前端应用。
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
pub struct App {}
|
||||
pub enum Msg {}
|
||||
|
||||
impl Component for App {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
// 应用创建时执行的生命周期函数;
|
||||
fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
|
||||
App {}
|
||||
}
|
||||
// 应用视图更新时执行的生命周期函数;
|
||||
fn update(&mut self, _msg: Self::Message) -> ShouldRender {
|
||||
true
|
||||
}
|
||||
// 定义应用视图结构;
|
||||
fn view(&self) -> Html {
|
||||
html! {
|
||||
<p>{ "Hello, world!" }</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
相信即使你不懂 Rust,但如果你熟悉 React,仍然可以发现基于 Yew 构建的 Web 前端应用,它的代码组织结构与 React 十分类似,整个应用也同样被划分为不同的“生命周期”。
|
||||
|
||||
比如在上面的代码中,“create” 方法对应应用的创建时刻;update 方法对应应用的状态更新时刻,以及最后用于渲染应用 UI 的 view 方法等等。不仅如此,在 Yew 中也同样拥有组件的概念,使用方式与 React 类似。
|
||||
|
||||
相对来说,抛开语言本身带来的成本不谈,单从性能来看,在目前 Wasm 的 MVP 标准下,Yew 这类框架的潜力还没有实际的显露出来。Yew 希望能够借助 Wasm 的能力,将视图(VDOM)差异的计算过程以更高性能的方式进行实现。但鉴于目前 MVP 标准下的一些限制,实际上在最后的编译产物中,Glue Code 执行时所带来的成本则会与 Wasm 带来的性能提升相互抵消。
|
||||
|
||||
不仅如此,考虑到目前 JavaScript 在构建 Web 应用时的丰富生态和资源,单从性能角度进行考量而使用 Yew 等框架也不是一个实际可行的方案。因此,未来这类“跨语言” Web 前端框架的生态会变得如何,也只能够让我们拭目以待了。
|
||||
|
||||
已有的讨论
|
||||
|
||||
在介绍了上述四种,Wasm 可能与 Web 前端框架相互结合的方案后。我们再回过头来,看一看目前仍然流行的几种 JavaScript Web 前端框架有没有进行与 Wasm 结合的相关尝试。这里我选择了 React、Vue.js 以及 Ember.js 这三种 Web 框架。
|
||||
|
||||
React
|
||||
|
||||
作为目前 Web 前端开发领域中最流行的框架之一。React 暂时还没有计划进行任何与 Wasm 相关的尝试。如下图所示,虽然社区中曾有人提议使用 Wasm 重写 React Fiber 架构中的 Reconciler 组件,但由于目前 Wasm 还无法直接操作 DOM 元素等标准上的限制,导致我们可预见,现阶段即使用 Wasm 重写 React 的 Fiber 算法,框架在实际处理 UI 更新时,可能也不会有着显著的性能提升。因此,对于 React 团队来说,投入产出比是一个值得考量的因素。
|
||||
|
||||
|
||||
|
||||
Vue.js
|
||||
|
||||
同 React 类似,Vue.js 的社区内也曾有过类似的讨论,如下图所示。
|
||||
|
||||
|
||||
|
||||
但与 React 所不同的是,Vue.js 与 Wasm 的“结合”方式根据框架的具体实现细节,可能有着更多的可能。不过一个不可否认的事实是,Wasm 仍然处在快速的发展阶段。同样的,基于 Wasm 构建的各类应用也同样处在不稳定的维护状态中(比如,上述帖子中提到的 Walt 实际上于 2019 年便不再继续更新)。而目前,正是一个“百花齐放”的时代。
|
||||
|
||||
Ember.js
|
||||
|
||||
最后我们要来讲讲 Ember.js。
|
||||
|
||||
Ember.js 的用户虽然没有 React 与 Vue.js 那么多,但它却是第一个宣布尝试与 Wasm 进行“深度整合”的 Web 前端框架,Ember.js 在内部使用了名为 Glimmer VM 的渲染引擎。与 React 通过使用 Reconciler 组件计算 VDOM 差异来更新 UI 的策略有所不同,Glimmer VM 通过将模板的构建过程分解为独立的虚拟机 “OpCode” 操作,来对 UI 中呈现的动态值进行细粒度更新。
|
||||
|
||||
|
||||
|
||||
在 EmberConf 2018 年的技术会议上,来自 Ember.js 团队的 Yehuda Katz 向我们介绍了 Glimmer VM 与 Wasm 的整合情况。你通过上图可以看到,除了 OpCode 模块相关的部分逻辑仍然在使用 JavaScript 构建以外,整个 VM 的大部分功能都已经完成到 Wasm 的迁移。并且该 Wasm 版本的 Glimmer VM 也已经通过了所有的测试集 Case。
|
||||
|
||||
但计划赶不上变化,回到 2020 年,我们再来看 Glimmer VM,关于它与 Wasm 整合的消息貌似已经没有了太多的声音。
|
||||
|
||||
从 Ember.js 官方论坛中我们可以看到,Ember.js 在与 Wasm 进行整合的过程中,其实遇到了很多问题,比如不支持 GC 导致 Wasm 线性内存中使用的资源无法被及时清理。Glimmer VM 还在继续为将来能够完全移植到 Wasm 做着准备。
|
||||
|
||||
但无论如何,这都不失为一次非常有意义的尝试。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在这节课里呢,我主要给你介绍了 Wasm 与 Web 前端框架的一些“故事”。
|
||||
|
||||
“Wasm 能否影响,或者说会如何影响现有的、基于 JavaScript 构建的现代 Web 前端框架呢?”这是一个被很多 Web 前端工程师所提及的问题。在这节课中,我尝试按照 Wasm 与 Web 前端框架的“整合程度”不同,将两者能够相互结合的可能方式大致分为了四种方案。
|
||||
|
||||
在第一种方案中,我们尝试将整个 Web 框架的全部功能,使用同样的 Wasm 版本进行代替,而应用代码仍然使用 JavaScript 进行编写。但由于现阶段 Wasm MVP 标准的限制,在这种方案下,我们不得不借助 JavaScript Glue Code 的帮助来实现框架的部分功能。
|
||||
|
||||
而当 Glue Code 的代码越来越多时,JavaScript 函数与 Wasm 导出函数之间的相互调用会更加频繁,在某些情况下,这可能会产生严重的性能损耗。因此结合现实情况来看,整个方案的可用性并不高。
|
||||
|
||||
在第二种方案中,我们尝试仅使用 Wasm 来重写框架的核心部分,比如 React Fiber 架构中的 Reconciler 组件。这类组件通常并不含有过多需要与 Web API 打交道的地方,相对纯粹的计算逻辑更易于 Wasm 能力的发挥。同时这种方案也是现阶段大多数 Web 框架正在尝试的,与 Wasm 进行交互的“常规”方式。
|
||||
|
||||
在第三种方案中,我们仅使用 Wasm 来作为 Web 框架的辅助,以优化 Web 应用的某一方面功能。在这种方案中,框架本身的代码结构不会有任何的变化。实际上,这种方案也是传统 Web 应用在利用 Wasm 时的最常规方式。
|
||||
|
||||
在最后一个方案中,我们介绍了一种更为激进的方式。在这种方案下,包括 Web 框架和应用代码本身,都会由除 JavaScript 以外的,如 Rust、C++ 和 Go 等静态语言来编写。
|
||||
|
||||
但同样受限于现阶段 Wasm MVP 标准的限制,框架本身仍然离不开 JavaScript Glue Code 的帮助。同时考虑到实际的语言使用成本以及 JavaScript 生态的舍弃,这种方案的实际可行性仍有待时间的验证。
|
||||
|
||||
无论如何,相信随着 Wasm Post-MVP 标准的不断实现,上述各方案中使用的 Glue Code 代码量将会逐渐减少。随之提升的,便是 Web 框架以及应用的整体运行性能。
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
除了我们今天介绍的这四种 Web 框架与 Wasm 的结合使用方式,你还有哪些奇思妙想呢?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
171
专栏/WebAssembly入门课/10有哪些已经投入生产的WebAssembly真实案例?.md
Normal file
171
专栏/WebAssembly入门课/10有哪些已经投入生产的WebAssembly真实案例?.md
Normal file
@ -0,0 +1,171 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 有哪些已经投入生产的 WebAssembly 真实案例?
|
||||
你好,我是于航。
|
||||
|
||||
本节课,我们将不再“拘泥”于 Wasm 的实现细节,而是要从技术标准走向生产实践。作为应用篇中的第二节课,我们将一起来看看从 2017 年 Wasm MVP 标准的确定,直到如今 WASI 出现,使得 Wasm 走出 Web 的这几年时间里,现实世界中有哪些已经投入生产的 Wasm 真实案例?而这些案例又是怎样利用 Wasm,解决了哪方面实际问题的呢?(这节课里介绍的几个案例,均由我总结于网络上相关公司发布的文章或视频分享。)
|
||||
|
||||
eBay - Barcode Scanner
|
||||
|
||||
第一个我们要介绍的实际案例来自于 eBay 在 Wasm 上的一次尝试。
|
||||
|
||||
eBay 是一家知名的线上拍卖与购物网站,人们可以通过 eBay 来在线出售自己的商品。作为一家知名的购物网站,为了优化用户录入待售商品的操作流程,eBay 在自家的 iOS 与 Android 原生应用中提供了“条形码扫描”功能。
|
||||
|
||||
通过这个功能,应用可以利用移动设备的摄像头扫描产品的 UPC 条形码,然后在后台数据库中查找是否有已经提交过的类似商品。若存在,则自动填写“商品录入清单”中与该物品相关的一些信息,从而简化用户流程,优化用户体验。
|
||||
|
||||
问题所在
|
||||
|
||||
在 iOS 与 Android 原生应用中,eBay 借助了自研的、使用 C++ 编写的条形码扫描库,来支持 UPC 条形码的扫描功能。而这对于诸如 iOS 与 Android 等 Native 平台来说,条形码的实际扫描性能得到了不错的保障,应用表现良好。
|
||||
|
||||
但是随着 eBay HTML5 应用的使用人数越来越多,为了能够使用户的商品录入流程与 Native 应用保持一致,“如何为 HTML5 应用添加高效的条形码扫描功能?”便成为了 eBay 工程师团队亟待解决的一个问题。
|
||||
|
||||
初期,技术团队使用了 GitHub 上的开源 JavaScript 版本条形码扫描器,来为 HTML5 应用支持 UPC 条形码的解析功能。但随着不断收到的用户反馈,团队发现 JavaScript 版本的条形码扫描器仅能够在 20% 的时间里表现良好,而在剩下 80% 的时间中,条形码的实际解析效率却不尽如人意,用户的每一次扫码过程都无法得到一致、流畅的用户体验。
|
||||
|
||||
出现这种问题的一个最为重要的原因,便是由于 JavaScript 引擎在实际优化代码执行的过程中,无法确保用户的每一次扫描过程都能够得到 JIT 的优化。JavaScript 引擎采用的“启发式”代码执行和优化策略,通常会首先通过 Profiling 来判断出“热代码”的具体执行路径,然后再调用 JIT 引擎来优化这段代码。而实际上,究竟哪段代码能够被优化,谁也无从得知。
|
||||
|
||||
可能的解决方案
|
||||
|
||||
那么,如何解决这个问题?其中的一个选择是等待 WICG(Web Incubator Community Group,Web 孵化社区群组)曾提出的 “Shape Detection API” 提案。这个提案提出了一系列的 API,可以让 Web 平台应用直接利用硬件加速或者系统相关的资源,来支持如人脸识别、条形码识别等功能。但该提案目前仍处于起步阶段,要实现跨浏览器的兼容性还有很多路要走。
|
||||
|
||||
eBay 技术团队所想到的另外一个方案,便是 Wasm。从下图所示的 V8 引擎编译管道中你可以看出。相较于 JavaScript 而言,浏览器引擎在执行 Wasm 字节码时不需要经过诸如“生成 AST”、“生成 Bytecode 字节码”、“生成 IR” 以及“收集运行时信息”等多个步骤。JavaScript 引擎的优化编译器后端可以直接将 Wasm 字节码转换为经过优化的机器码,进而以接近 Native 代码的效率来执行。
|
||||
|
||||
|
||||
|
||||
不仅如此,Wasm 字节码在实际的执行过程中,也不会存在类似 JavaScript 代码的“去优化”过程,因此性能表现会更加稳定。
|
||||
|
||||
另一方面,借助于 Wasm 相关编译工具链的帮助,eBay 技术团队可以直接使用曾经为 Native 平台设计开发的 C++ 条形码扫描库。总的来说,eBay 技术团队不需要为 Wasm 重新编写这部分功能,而仅需要对已有的代码库进行少量改动即可。
|
||||
|
||||
项目架构
|
||||
|
||||
当方案确定之后,条形码扫描功能的具体工作流程如下所示。
|
||||
|
||||
|
||||
使用 Web Worker API 从主线程创建一个工作线程(Worker Thread),用于通过 JavaScript 胶水代码来加载和实例化 Wasm 模块;
|
||||
主线程将从摄像头获得到的视频流数据传递给工作线程,工作线程将会调用从 Wasm 模块实例中导出的特定函数,来处理这些视频流像素。函数在调用完成后,会返回识别出的 UPC 字符串或者返回空字符串,以表示没有检测到有效的条形码内容;
|
||||
应用在运行时会通过设置“阈值时间”的方式,来检测是否读取到有效的条形码信息。当扫描时间超过这个阈值时,应用会弹出提示信息以让用户重试,或选择手动输入二维码序列。当然,阈值超时可能意味着两种情况:一种是用户没有扫描到有效的条形码;第二种是读取到的二维码视频流无法被应用使用的算法正确解析。
|
||||
|
||||
|
||||
项目中使用到的 Wasm 模块以及 JavaScript 胶水代码,均是通过 Emscripten 工具链编译已有的 C++ 条形码扫描库得来的。整个方案的工作流程如下图所示。
|
||||
|
||||
|
||||
|
||||
一致化的编译管道
|
||||
|
||||
作为工程化的一部分,如何将 Wasm 模块的开发和编译流程,也一并整合到现有的 Web 前端项目开发流程中,是每个实际生产项目都需要考虑的事情。
|
||||
|
||||
一个 Wasm 模块,或者说是 Wasm Web 应用的完整开发流程涉及到多个部分。除了组成应用最基本的 HTML、CSS 以及 JavaScript 代码外,对于 Wasm 模块的开发和编译,我们还需使用到由 Rust 和 C++ 等系统级编程语言编写的模块源文件、相关的标准库,以及用于编译这些源代码的编译工具链,比如 Emscripten。
|
||||
|
||||
为了确保每次都能够在一个一致的环境中来编译和生成 Wasm 模块,同时简化整个项目中 Wasm 相关开发编译环境的部署流程。eBay 技术团队尝试采用了 Docker 来构建统一的 Wasm 编译管道。这样在每次编译 Wasm 模块时,Docker 都会启动一个具有相同环境的容器,来进行模块的编译流程,从而磨平了不同开发环境下可能带来的编译结果差异。
|
||||
|
||||
不仅如此,通过结合 NPM 下 “package.json” 文件中的自定义脚本命令,我们还可以让 Wasm 模块的开发与编译流程,与现有的 Web 前端应用开发编译流程,更加无缝地进行整合。举个例子,比如我们可以按照如下形式来组织 “package.json” 文件中的应用编译命令。
|
||||
|
||||
{
|
||||
"name": "my-wasm-app",
|
||||
"scripts": {
|
||||
"build:emscripten": "docker run --rm -v $(pwd)/src:/src trzeci/emscripten ./build.sh",
|
||||
"build:app": "webpack .",
|
||||
"build": "npm run build:emscripten && npm run build:app",
|
||||
// ...
|
||||
},
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
其中,命令 “build:emscripten” 主要用于启动一个带有完整 Emscripten 工具链开发环境的 Docker 容器。并且在容器启动后,通过执行脚本 “./build.sh” ,来编译当前目录下 “src” 文件夹内的源代码到对应的 Wasm 二进制模块。“build:app” 命令则用于编译原有 Web 应用的 JavaScript 代码。最后我们将两部分再进行整合,便得到了最终的 “build” 命令。
|
||||
|
||||
并不理想
|
||||
|
||||
以上基于 Wasm 的方案看起来十分理想。但经过实际测试后,eBay 技术团队发现,虽然基于 Wasm 的实现可以在 1 秒的时间内处理多达 50 帧的画面,但实际的识别成功率却只有 60%。剩下 40% 的失败情况大多是因为采样的画面角度不好,进而使得条形码的拍摄图像质量不高。产生问题的关键点,在于当前应用使用的是自研的 C++ 条形码扫描库。
|
||||
|
||||
自研的 C++ 条形码扫描库其一大特征为条形码的识别解析算法效率高,但仅适用于条形码成像质量较高的情况下。因此,急需一种方式来弥补在成像质量偏低时的条形码识别。
|
||||
|
||||
此时,团队将目光锁定到了另外一个业界十分有名的、基于 C 语言编写的开源条形码扫描库 —— ZBar。通过实验发现,当使用 ZBar 作为条形码扫描库时,在所设置的阈值时间范围内,整个应用的扫描成功率提高到了 80%。
|
||||
|
||||
但 80% 的成功率对于产品的用户体验来说仍然不够。团队继续对 ZBar 和自研的 C++ 条形码扫描库进行测试。在经过一段时间后,他们发现在某些 ZBar 超时的情况下,自研的 C++ 库却能够快速地得到扫描结果。显然,基于不同的条形码图像质量,这两个库的执行情况有所不同。
|
||||
|
||||
竞争取胜
|
||||
|
||||
为了能够同时利用 ZBar 和自研的 C++ 库,eBay 技术团队选择了一个“特殊的方案”。我想你肯定也能够猜到方案的大致内容。
|
||||
|
||||
在这个方案中,应用会启动两个工作线程,一个用于 ZBar,另一个用于自研的 C++ 库,两者同时对接收到的视频流进行处理。当主线程接收到有效的识别结果时,便结束所有工作线程的执行。若超时,则显示错误信息。
|
||||
|
||||
经过测试,条形码在不同模拟测试场景中的识别成功率,可以提高到 95%。
|
||||
|
||||
无独有偶的是,当尝试把 JavaScript 版本的条形码扫描器实现同样作为工作线程,加入到竞争“队列”中时,整个应用的条形码扫描识别成功率达到了将近 100%。这样的结果让人感到惊喜。应用的最终架构可以通过下图很好地进行展示。
|
||||
|
||||
|
||||
|
||||
产品上线后的最终效果如下图所示。
|
||||
|
||||
|
||||
|
||||
产品在上线使用了一段时间后,eBay 技术团队对应用的条形码扫描情况进行了统计,结果发现有 53% 的成功扫描来自于 ZBar;34% 来自于自研的 C++ 库。剩下的 13% 则来自于第三方的 JavaScript 库实现。可见,其中通过 Wasm 实现(自研 C++ 库、Zbar)得到的扫描结果占据了总成功次数的 87%。
|
||||
|
||||
虽然文章中没有提及,但实际上,设备对 Wasm 的兼容性也是需要考量的一个因素。你可以思考一下,我们怎样做可以在上述方案的基础上,来同时兼容旧设备上的条码扫描功能。
|
||||
|
||||
AutoCAD Web
|
||||
|
||||
第二个我们要介绍的案例来自于一个有着将近 40 年历史的知名设计软件 —— AutoCAD。
|
||||
|
||||
AutoCAD 是一款由 Autodesk 公司设计研发的,用于进行 2D 绘图设计的应用软件,它被广泛地用于土木建筑、装饰装潢、工业制图等多个领域中。相信大部分的工科同学,也一定在大学本科期间参与过 AutoCAD 的课程与相关考试。如下图所示,是该应用桌面端版本的运行截图。
|
||||
|
||||
|
||||
|
||||
历史负担
|
||||
|
||||
AutoCAD 桌面端软件的发展有着将近 40 年的历史。而随着应用的不断发展,随之而来便是逐渐变大的代码库体积,以及不断复杂化的软件架构。截止 2018 年,AutoCAD 桌面端应用已经有着超过 1500 万行的 C/C++ 代码,并且仍然在以肉眼可见的速度增长着。
|
||||
|
||||
移动互联网浪潮
|
||||
|
||||
随着 2008 年移动互联网浪潮的逐渐兴起,越来越多的用户开始使用 PC 甚至是移动设备上的 Web 浏览器,来完成日常工作的一部分内容。感知到趋势的 Autodesk ,便开始着手将自家的 AutoCAD 应用从 PC 端的原生应用逐渐向 Web 应用进行移植。
|
||||
|
||||
初期,由于 AutoCAD 原生应用本身的代码库过于庞大,AutoCAD 团队决定从头开始编写 AutoCAD 的 Web 版应用。在当时那个年代,HTML5 刚刚标准化,浏览器在功能特性上的支持还不够全面,并且跨浏览器的兼容性也很难得到保障。因此,AutoCAD 移植 Web 应用的第一版本便是基于 Adobe Flash 重新编写的,这个应用发布于 2010 年。
|
||||
|
||||
为了能够进一步利用 Web 标准,来优化 AutoCAD Web 应用的性能,并使得整个 Web 应用的技术架构更加贴近基于 JavaScript 构建的 Web 应用标准,AutoCAD 团队于 2013 开始着手进行 AutoCAD 标准 Web 应用的移植工作。并且此时的 AutoCAD 团队还有着更大的“野心”。
|
||||
|
||||
他们首先基于 C++ ,重写了为 iOS 移动端 Native 应用准备的轻量版代码库。然后通过交叉编译(Tangible)的方式,将这些 C++ 代码编译为了 Java 代码供 Android 设备使用。最后,在 Google Web Toolkit(一个 Google 开发的可以使用 Java 语言开发 Web 应用的工具集)的帮助下,又将这些 Java 代码转译为了 Web 平台可用的 JavaScript 代码。
|
||||
|
||||
但事实上,由于 GWT 本身作为转译工具,会产生很多额外的胶水代码,并且经由 C++ 交叉编译而来的 Java 代码本身质量也并不高,因此这导致了最后生成的 Web 应用代码库十分庞大,且在浏览器中的运行性能并不可观。这个“粗糙版”的 Web 应用发布于 2014 年。
|
||||
|
||||
时间来到 2015 年,彼时 ASM.js 作为 Wasm 的“前辈”正展露着头角。AutoCAD 团队借此机会,在 Emscripten 工具链的帮助下,直接从 AutoCAD PC 版原有的 C++ 代码库中移植了一部分主要功能到 Web 平台上,ASM.js 所带来的性能提升,让团队对 AutoCAD Web 应用的进一步发展充满了期待。
|
||||
|
||||
2018年3月,基于 Wasm 构建的 AutoCAD Web 应用诞生。
|
||||
|
||||
|
||||
|
||||
应用架构
|
||||
|
||||
整个 AutoCAD Web 应用的组成结构你可以参考下面这张图。在应用的右侧是绘图区域,该区域由 HTML 中的 Canvas 元素与相关 Web API 进行渲染,运行在独立工作线程中的 Wasm 模块实例则负责控制这部分区域的实际绘图效果。
|
||||
|
||||
左侧的 UI 控制区域由 TypeScript 基于 React 框架进行构建,基于组件化的构建方式与我们日常开发的 Web 前端应用项目基本保持一致。UI 部分的交互操作则会通过 “postMessage” 等 Web API 通知到工作线程中的 Wasm 实例,并对输出到 Canvas 中的画面进行实时处理。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在这节课里,我们举了两个比较有代表性、在现实生活中的 Wasm 生产实践案例。第一个是 eBay 在其 Web H5 应用中添加的条形码扫描功能。eBay 技术团队在初期使用了第三方的 JavaScript 版本条形码识别库,来进行条形码的识别,但无奈识别成功率较低。
|
||||
|
||||
而随着后期 ASM.js 与 Wasm 的出现和普及,eBay 技术团队选择将自研的,原先被应用于 Native 平台的 C++ 识别库编译到 Wasm ,并整合到 Web 应用中使用。此时虽然识别成功率有所上升,但在某些成像质量较差的场景下,条形码仍然无法被正确识别。
|
||||
|
||||
为了解决这个问题,团队成员又以同样的方式,将基于 C 语言开发的知名第三方条形码识别库 ZBar 编译到了 Wasm。并通过多个工作线程“竞争”的方式,尝试同时整合 JavaScript 版本实现、ZBar 与自研的 C++ 识别库,让应用的整体识别成功率有了一个质的提高。
|
||||
|
||||
在第二个案例中,我们介绍了 AutoCAD 在移动互联网浪潮兴起的这十年时间里,不断尝试将其 Native 应用移植到 Web 平台所使用的一些方式。而在这些众多的方案中,基于 Wasm 的方案给予了 AutoCAD 能够在 Web 平台上流程运行的可能。
|
||||
|
||||
最后,希望这些真实的案例能够给予你对 Wasm 更多的信心和思考。
|
||||
|
||||
课后练习
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
你觉得将 Native 应用移植到 Web 应用时可能会存在哪些问题呢?或者说 Native 应用与 Web 应用在执行流程或组成方式上有哪些区别呢?欢迎大家各抒己见。
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
155
专栏/WebAssembly入门课/11WebAssembly在物联网、多媒体与云技术方面有哪些创新实践?.md
Normal file
155
专栏/WebAssembly入门课/11WebAssembly在物联网、多媒体与云技术方面有哪些创新实践?.md
Normal file
@ -0,0 +1,155 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 WebAssembly 在物联网、多媒体与云技术方面有哪些创新实践?
|
||||
你好,我是于航。
|
||||
|
||||
我们继续接着上节课的内容,来一块看看 Wasm 在应用实践领域有哪些“新鲜事”。今天我们要来聊的是 Wasm 在物联网、多媒体与云技术领域内的一些创新性实践。我们一直说 Wasm 虽然“出身”于 Web,但实际上却也可以 out-of-web。
|
||||
|
||||
Wasm 本身作为一种新的虚拟机字节码格式,其使用场景从来都不会被仅仅限制在某个领域。鉴于 Wasm 在这些领域内的相关实践数量众多,因此在本节课里,我们仅挑选一些比较典型且具有一定现实意义的创新性实践来进行介绍。同时也欢迎你在评论区和我进行互动,补充一下你所知道的 Wasm 在这些或者其他领域内的相关实践。
|
||||
|
||||
物联网(IoT)
|
||||
|
||||
物联网(Internet of Thing),我们一般简称为 IoT。是指相对于传统的手机、笔记本电脑等大型电子设备来说,其可使用资源被有所限制(比如单核的 CPU、仅有几百 KB 的内存和硬盘容量、有限的网络上传速度,或仅需纽扣电池进行供电等)的小型嵌入式设备。
|
||||
|
||||
因此,相较于为传统 PC计算机等大型电子设备开发应用程序而言,为嵌入式设备开发程序则需要特殊的编程实践方法,以用来应对有限的软硬件资源。
|
||||
|
||||
统一的编程接口
|
||||
|
||||
在 IoT 刚刚走入人们视野的最初几年,人们通常只能够使用 C/C++ 甚至是汇编语言,来为这些物联网嵌入式设备编写应用程序。
|
||||
|
||||
后来随着互联网技术的不断发展,以及从易用性、流行程度、生态系统等其他多方面进行考虑,诸如 JavaScript、Lua 以及 Python 等高抽象层次的脚本语言,也被逐渐应用在嵌入式设备上,“性能”已经不再成为人们选择嵌入式设备编程语言时所要考虑的第一要素。
|
||||
|
||||
但现实情况是,并非所有的嵌入式设备,都可以直接满足这些高级编程语言的使用要求。由于不同语言的运行时差异性,并且考虑到实现成本,嵌入式设备无法独立地为每种语言运行时都提供单独的编程接口(一般为 C/C++),以供其与嵌入式设备进行交互。此时 Wasm 字节码的高密度、高性能以及可移植性,便使得人们有了可尝试的新选择。
|
||||
|
||||
如下图所示,通过将 Wasm 字节码作为嵌入式设备的中间媒介表示形式(IR),来向所有的外部高级编程语言宿主运行时,提供统一的基于 Wasm 的编程接口。对相关 Wasm 编程接口的调用,将会由嵌入式设备上的独立 Wasm 运行时来执行。
|
||||
|
||||
|
||||
|
||||
这样,我们不仅可以直接利用已有的 C/C++ 编程接口(编译到 Wasm 字节码),同时还能够向外界的宿主运行时提供统一的编程接口调用方式。关于这两者之间的具体交互方式,你可以通过 Web 浏览器中 JavaScript 与 Wasm 二进制模块之间的交互方式来进行类比。当然,细节的实现依据不同的编程语言将会有所不同。
|
||||
|
||||
微内核 - Unikernel
|
||||
|
||||
另一个比较有意思的想法源自于一个曾在 IoT ,或者说嵌入式领域比较火的概念 — “微内核”。
|
||||
|
||||
如下图左侧所示,在传统的操作系统内核架构中,有着用来支持各类功能的底层驱动、框架、接口以及组件库。实际上对于具有某一特定功能的嵌入式设备来说,其中的大部分内核底层功能都没有存在的必要性,但却仍然占用着一部分的硬件资源。
|
||||
|
||||
是否可以只把整个嵌入式硬件需要使用的内核底层组件单独提取出来,使其成为一个面向某一类特定功能或应用的专有内核呢?答案是当然可以,这就是“微内核”的概念。
|
||||
|
||||
|
||||
|
||||
相较于传统的类 Unix 操作系统内核(一般称之为宏内核),微内核有着许多的优势,比如:更快的启动速度、更小的 ROM 体积,以及更高的硬件资源使用率。 Unikraft 便是这样一款可以用来制作微内核的系统工具。
|
||||
|
||||
关于 Unikraft 的更多信息,你可以点击上一段文字中的超链接进行参考。你现在需要知道的就是,通过使用 Unikraft,我们可以构建一个基于 Wasm 运行时的操作系统微内核。
|
||||
|
||||
相较于其他基于 JavaScript 等高级编程语言运行时(比如 V8)构建的微内核而言,基于 Wasm 的微内核将有着更高的程序执行效率、更少的硬件资源占用率,以及更快的操作系统冷启动速度。这都是源自于 Wasm 本身作为一种 V-ISA 所带来的优势。
|
||||
|
||||
比如我们可以为“树莓派(一种嵌入式开发板)”,构建一个拥有如下“服务栈”的 Wasm 微内核。在这个微内核中,除了最下层必要的内核组件以外,我们还为其添加了用于支持“图形界面”、“多线程”、“网络通信”以及“C 标准库”等功能的必要组件。
|
||||
|
||||
位于最上层的便是由 Wasm 虚拟机(WAMR)构建出的“执行层”。在这里,我们可以通过提供 Wasm 字节码的方式,来与整个微内核的其他组件功能进行交互。
|
||||
|
||||
|
||||
|
||||
在这样的一个架构中,整个微内核的大小只有 468KB。当上述的 Wasm 微内核被运行在一个普通的树莓派开发板上时,整个内核的启动时间仅需要 20 毫秒。并且内存资源的使用率以及应用程序代码的大小,也都处在一个对于嵌入式设备来说十分可观的量级下。这样的系统冷启动速度以及资源使用率,对于 Serverless 相关的应用领域来说,不得不引起关注。
|
||||
|
||||
多媒体(Multimedia)
|
||||
|
||||
Wasm 在“多媒体”领域内的实践可谓是数不胜数。可以说,“音视频及图像的在线处理”是 Wasm 在基于现阶段 MVP 标准的情况下,其可以大显身手的一个重要场景。因为对多媒体资源的处理始终离不开“编解码”的需求,编解码过程本身又是一个“计算密集”的数据处理过程。
|
||||
|
||||
就目前的大多数 Web 浏览器实现而言,当 Wasm 不再需要频繁地与 JavaScript 环境之间传递大量数据时,JavaScript 引擎便可以按照“最优”的策略来执行 Wasm 代码,从而减少在两个上下文环境间相互切换时所产生的性能损耗。这便给予了 Wasm 在“终端密集计算”这个场景中以机会,于是基于 Wasm 的 Web 端音视频处理方案便如雨后春笋般涌现。
|
||||
|
||||
ogv.js
|
||||
|
||||
ogv.js 是一个由“维基百科”技术团队开发的,可以在 Web 浏览器中使用的多媒体播放器。它能够播放如 “Ogg”、“WebM” 以及 “AV1” 等多种音视频格式。如下图所示为其整体架构设计。
|
||||
|
||||
|
||||
|
||||
可以看到,位于主线程中的 Demuxer 作为整个播放器的核心组件,主要用于解码并提取各类型媒体文件中的音视频内容。位于各个工作线程中的音视频解码过程,也同样属于整个播放器的核心逻辑。因此,这两部分计算密集的逻辑便交由 Wasm 来进行处理。
|
||||
|
||||
同时为了保证性能和兼容性,ogv.js 还使用了 ASM.js 实现来作为 Wasm 的一个兼容性补偿,以便在一些不支持 Wasm 的浏览器中,通过 ASM.js 来进行加速。在最不济的情况下,ogv.js 便可以直接退回到 JavaScript 的方案(将 ASM.js 代码视作普通 JavaScript 来执行)。
|
||||
|
||||
不仅如此,ogv.js 还可以同时利用浏览器支持的 “Multi-Cores Worker” 特性(每一个工作线程都使用 CPU 上的一个独立核心),来对整个解码过程进行加速。与此同时,随着 Wasm 最新的 SIMD 标准被越来越多的浏览器实现,ogv.js 在处理视频像素矩阵以及各类相关编解码工作时,还可以利用该特性来做到进一步的加速。
|
||||
|
||||
另一个值得讲的便是 ogv.js 对第三方编解码库(libogg、libvorbis、libtheora 等等)的复用。ogv.js 在构建时,直接使用了已有的一些 C/C++ 编解码库来完成对音视频流的编解码过程,而没有选择自己从头开始编写这部分功能。因此,得益于 Emscripten 提供的对 C/C++ 代码到 ASM.js / Wasm 代码的转译功能,ogv.js 的整个开发过程变得更加方便快速。
|
||||
|
||||
WXInlinePlayer
|
||||
|
||||
同 ogv.js 类似,WXInlinePlayer 也是一个 Web 端的音视频播放器。不过相较于 ogv.js 本身基于“维基百科”自身业务需求的出身而言,WXInlinePlayer 的出身则显得更加“有趣”。
|
||||
|
||||
国内的大多数移动端浏览器厂商(或提供者)通常会在其自家浏览器内,对 HTML5 网页中基于<video>标签进行的视频播放行为,采用很多的 “魔改”。比如:使用单独的窗口来播放视频、视频播放完毕后推送广告信息等等。
|
||||
|
||||
在某些视频无法正常播放的情况下,开发者甚至无法捕捉到任何的异常信息。而这便为那些需要提供一致性用户体验的产品设置了阻碍。WXInlinePlayer 的诞生便源于对此的迫切需求。
|
||||
|
||||
同大多数的<video>标签替代方案一样,WXInlinePlayer 会自行解码收到的 FLV 视频流,然后再通过 WebGL 与 WebAudio 的方式,来将视频画面与音频播放出来。
|
||||
|
||||
在实际的解码过程中,WXInlinePlayer 便会利用 Wasm 进行加速。同样地,它也使用了 ASM.js 作为 Wasm 的降级方案。在解码时,也同样利用了诸如 “de265” 等现有的 C/C++ 解码库。
|
||||
|
||||
相较于大多数公司采用的“直接将 FFmpeg 编译到 Wasm 来进行视频解码”的解决方案,WXInlinePlayer 会相对更加轻量,且在某种程度上解码的效率更高。
|
||||
|
||||
FFmpeg 由于其自身的代码库体积过大,导致 Web 浏览器在实际加载和实例化 Wasm 模块时,会消耗更多的时间。并且 FFmpeg 天生并非针对 Web 场景,因此其内部的很多优化策略可能无法直接在 Web 平台上使用。而 WXInlinePlayer 仅把涉及到解码和 Remux 的部分单独提取出来,因此可以有效地针对某个解码方案进行优化。
|
||||
|
||||
你可以通过下图来参考一下, WXInlinePlayer 与其他同类型方案的视频播放性能对比结果。
|
||||
|
||||
|
||||
|
||||
云(Cloud)
|
||||
|
||||
最后,我们来看看 Wasm 在云技术领域的一些实践。
|
||||
|
||||
注:由于我对云原生领域并不是特别熟悉,但我还是很想和你分享一些在该领域内比较重要的 Wasm 尝试。关于这些创新项目的更多内容,你可以参考 GitHub 上的相关文档,也欢迎你在评论区和我一块探讨。
|
||||
|
||||
Krustlet
|
||||
|
||||
Kubernetes 是目前云原生领域中,最常用的一种容器编排引擎。Kubernetes 由 Google 开源,通过它我们可以方便地管理云平台上众多物理主机中运行的容器化应用。Kubernetes 使容器化应用的部署和管理变得更加简单和高效。
|
||||
|
||||
而 Krustlet 旨在作为一个 Kubernetes Kubelet ,以运行在整个 Kuberneters 集群中的各个服务节点上。它的设计与 Virtual Kubelet 十分类似,会在 Kubernetes API 事件流上监听新的 Pods。
|
||||
|
||||
根据指定的 Kubernetes Toleration,Kubernetes API 能够将特定的 Pod 调度到 Krustlet 上,然后将它们运行在基于 WASI 的 Wasm 运行时上。因此借助于 Krustlet,我们可以方便快捷地在 Kubernetes 集群中部署 Wasm 应用。
|
||||
|
||||
Embly
|
||||
|
||||
Embly 是一个基于 Wasm 的 Serverless 框架。它使得我们可以在服务器上执行 Wasm 字节码(函数),并访问完成任务所需要的网络和系统资源。Embly 通过一种“声明式”的配置文件来定义不同的 Wasm 服务。配置文件中包含有对服务函数的编译、部署以及声明所需依赖等多种任务流程的配置项。
|
||||
|
||||
不仅如此,Embly 还实现了 Actor 模型(一种并发计算模型),这样可以允许一个 Wasm 函数生成另一个 Wasm 函数,并且函数之间可以相互传递数据。通过这种方式,Embly 足以应对更加复杂的计算模式。同时,基于 Wasm 模块的沙箱机制也使得 Embly 可以支持多租户的特性。
|
||||
|
||||
一个简单的 Embly 示例配置文件如下所示。
|
||||
|
||||
function "hello" {
|
||||
runtime = "rust"
|
||||
path = "./hello"
|
||||
}
|
||||
|
||||
gateway {
|
||||
type = "http"
|
||||
port = 8765
|
||||
route "/" {
|
||||
function = "${function.hello}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这个文件中,我们定义了一个名为 “hello” 的函数,以及一个 HTTP 类型的网关。在网关的配置项中通过 “route” 字段,我们可以声明所有通过端口 8765 发送进来的,对根目录(“/”)的HTTP 请求,都会被转发到函数 “hello” 的对应实现中进行处理。
|
||||
|
||||
在 Embly 中使用到的所有 Wasm 函数,均需要由 Rust 语言进行编写。在函数定义中,我们可以使用由 Embly 提供的用于访问各类系统资源(包括 HTTP 对应的请求和响应)的数据类型。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在本节课中,我们主要介绍了 Wasm 在“物联网”、“多媒体”以及“云”这三个技术领域内的一些创新性实践。当然,限于篇幅和我所掌握的知识,我无法将每个领域内的每个 Wasm 实践项目都解释得十分清楚。
|
||||
|
||||
但无论如何,我希望通过这节课,能够给你这样一种认知,即:随着将近 5 年时间的发展,对 Wasm 的应用实践已经不再单单局限于 Web 平台,而是已经开始向各种各样的其他领域进军。虽然其中的大部分实践项目都还处于“实验性”阶段,但我相信,距离它们可以被真正使用在生产环境的那一天并不遥远。
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
你最期待 Wasm 能够在哪个应用/技术领域内有所创新?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
152
专栏/WebAssembly入门课/12有哪些优秀的WebAssembly编译器与运行时?.md
Normal file
152
专栏/WebAssembly入门课/12有哪些优秀的WebAssembly编译器与运行时?.md
Normal file
@ -0,0 +1,152 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 有哪些优秀的 WebAssembly 编译器与运行时?
|
||||
你好,我是于航。
|
||||
|
||||
本节课我们来一起看看,目前业界有哪些优秀的 Wasm 运行时。这些运行时都是 Wasm 可以在 out-of-web 领域大显身手的最基本保障。它们其中有些支持 WASI 抽象系统接口,有些支持 Wasm Post-MVP 标准中的部分提案,还有一些可以被专门用在诸如嵌入式、IOT 物联网以及甚至云,AI 和区块链等特殊的领域和场景中。
|
||||
|
||||
不仅如此,还有一些更具创新性的尝试,比如 “Wasm 包管理”。这一概念类比 npm 之于 JavaScript,PyPi 之于 Python,crates.io 之于 Rust,相信这一定可以为 Wasm 生态添砖加瓦。
|
||||
|
||||
这一切,我们都要先从“字节码联盟”这个特殊的组织开始说起。
|
||||
|
||||
字节码联盟(Bytecode Alliance)
|
||||
|
||||
“字节码联盟”成立于2019年末,是一个由个人和公司组成的团体。最初的一批创始成员为 Mozilla、Fastly、Intel 以及 Red Hat。联盟旨在通过协作的方式,来共同实现 Wasm 及 WASI 相关标准,并通过提出新标准的方式来共同打造 Wasm 在浏览器之外的未来。
|
||||
|
||||
对于开发者来说,联盟希望能够为开发者提供健全的、基于各类安全策略构建的成熟开发工具链(虚拟机、编译器以及底层库)生态。这样开发者便可以将目光更多地专注于应用本身的设计与研发上,同时可以在各类环境中,快速地构建可运行在浏览器之外的 Wasm 应用,并且不用考虑安全性等基本问题。
|
||||
|
||||
背景 —— 问题所在
|
||||
|
||||
回顾 20 年前,各大互联网公司在软件开发的过程中,对于跨应用的代码复用能力是非常弱的。但 20 年之后,诸如 NPM、PyPi 等代码包管理平台,让我们不再需要从头到尾完全“独立”地开发一个完整应用。
|
||||
|
||||
模块化的软件开发模式,让我们可以大量重用社区中现存的、已经十分成熟的第三方代码库。但在方便地利用这些代码库的同时,我们也不得不面对的另外一个问题,那就是随着第三方代码而来的代码安全性问题。
|
||||
|
||||
一个应用在运行时会依赖第三方代码库所提供的功能,因此在基于传统软件的开发模式中(譬如 Node.js 应用),第三方代码同样共享着应用程序所拥有的各类系统接口权限(如 Socket 网络通信、File 文件系统等),以及资源访问(内存、硬盘等)权限。
|
||||
|
||||
不仅如此,如下图所示,由于模块化的开发方式,使得代码的整体依赖成为了树状关系,因此整颗依赖树上的所有模块代码,都会共享同样的代码权限。这种共享无疑大大降低了应用整体的安全性,给第三方代码中所可能包含的恶意代码或漏洞以可乘之机。
|
||||
|
||||
|
||||
|
||||
通过对一个真实的,第三方模块恶意代码窃取用户数字货币的案例进行总结,我们可以发现攻击者通常会按照以下时间顺序(仅选择了关键节点)来对终端用户逐步发起攻击。
|
||||
|
||||
|
||||
第 0 天:攻击者创建了一个模块
|
||||
第 2 天:攻击者将该模块作为可复用的第三方底层依赖库
|
||||
第 17 天:攻击者为模块添加恶意代码
|
||||
第 42-66 天:目标应用通过依赖升级而引入了恶意代码
|
||||
第 90 天:攻击被用户察觉
|
||||
|
||||
|
||||
通常来说,以上述案例为例,恶意代码需要同时具备以下两类权限才能够对终端用户成功地发起攻击:
|
||||
|
||||
|
||||
操作系统资源的访问权限。包括用于存放诸如“秘钥”等敏感数据的内存资源、用于发送窃取数据的 Socket 资源等;
|
||||
操作系统接口的调用权限。包括对文件资源的读写权限,以及 Socket 的操作权限。
|
||||
|
||||
|
||||
据 GitHub 官方调查统计,自 2017 年到 2019 年,NPM 上包含有恶意代码的模块数量逐年增加,并且攻击者的恶意代码攻击目标逐渐向具体的某类终端用户聚焦。攻击实施者更加具有耐心,企图进行可以暗中实施的、经过精心策划的攻击。
|
||||
|
||||
先抛开经由第三方模块主动发起的恶意代码攻击不谈,应用自有的代码漏洞也同样为攻击者提供了可乘之机,比如“经典”的 ZipSlip 任意文件覆盖漏洞。
|
||||
|
||||
ZipSlip 没有对解压缩文件时的目标地址进行校验,而是直接进行拼接。因此当遇到包含有恶意代码文件的压缩包时,这些文件便可经由此漏洞被解压到整个文件系统的任意位置。当然前提是应用的运行者拥有这些文件目录的写权限。
|
||||
|
||||
而在 NPM 中,具有类似漏洞的第三方代码库只有 59% 被修复。另外有超过 40% 的代码库依赖于拥有至少包含一个已知漏洞的 NPM 第三方模块。从现实的情况来看,此类“恶意代码”或者“代码漏洞”问题无法被完全避免,因此我们需要考虑其他的方式来保证应用的运行时安全。
|
||||
|
||||
究其根源,发生类似的安全性问题其主要原因在于,恶意代码拥有了本不该拥有的系统资源和系统接口访问权限。我们不能够 100% 地相信代码本身的行为方式,能够完全满足我们对安全性的要求。但基于 Wasm,我们可以在一定程度上解决这个问题。
|
||||
|
||||
类比于操作系统上每个原生应用在运行时的独立进程,实际上,每一个 Wasm 模块在 out-of-web 环境中实例化运行时,也都有着自己独立的运行时沙盒环境,并且对应着独立的可用内存资源以及调用栈。但 Wasm 模块之间的隔离却不一定需要通过独立进程的方式来实现,因此从运行模型上来看,Wasm 的方式会更加轻量且高效。
|
||||
|
||||
不仅如此,正如我们在讲解 WASI 时所介绍的那样,与传统操作系统中的“进程”不同,每一个实例化的 Wasm 模块,都只能够在实例化时使用被主动分配的系统资源(内存)与接口能力(系统调用),因此对于模块实例所拥有权限的控制力度会更为细腻。
|
||||
|
||||
而且,相对于传统进程需要通过“序列化”与“反序列化”才能够在进程间传递信息(IPC)的方式不同,Wasm 实例之间的消息传递可以通过更加轻量的方式来完成。
|
||||
|
||||
解决方案 —— 纳米进程(Nano-Process)
|
||||
|
||||
根据上面讲过的 Wasm 在资源及权限控制上的相对优势,我们可以提出一种新的 Wasm 应用构建模式 —— “Wasm Nanoprocess”。
|
||||
|
||||
一般来说,一个完整的大型 Wasm 应用,可能会同时包含有多个相互依赖的底层 Wasm 模块。由于每一个模块实例都拥有着自己独立的数据资源及可用权限,因此我们可以称每一个实例化的模块为一个独立的 “nanoprocess”,翻译过来也就是“纳米进程”。
|
||||
|
||||
当一个含有恶意代码的 Wasm 模块被“链接”到整个应用的依赖树中时,应用各依赖模块所能够使用的资源及系统接口权限,便全部来自于最上层的调用者。也就是说需要在应用运行的入口模块中被指定,然后再由该模块向下层依赖模块进行分发。
|
||||
|
||||
当恶意模块的内部代码需要使用某种未经授权的额外资源或能力时,整个模块依赖树的 “Import Section” 签名便会发生错误,这个错误会在运行时向上层用户抛出对应异常,提示某个模块的某些特定资源或者权限没有被导入。在这种情况下,特殊的权限调用便会引起人们的注意。
|
||||
|
||||
即便恶意代码获得了特定操作系统接口的执行权限,但恶意代码想要从其他应用依赖模块的实例中,获取对应内存段中的敏感信息,也并非易事。
|
||||
|
||||
由于每个 Wasm 模块实例都拥有独立、隔离的线性内存段来存储数据,因此只有在模块主动向外部暴露(通过 “Export Section” )特定数据,或者直接调用(动态链接)目标模块内的方法时,才能够将自身内存段中的数据传递过去。
|
||||
|
||||
如下图所示,通过限制恶意代码对数据以及系统接口权限的访问和使用,“Wasm Nanoprocess” 这种应用构建模式,可以在最大程度上保证 Wasm 应用及其所依赖第三方模块的运行时安全性。
|
||||
|
||||
|
||||
|
||||
虚拟机运行时
|
||||
|
||||
我们为了能够基于 “Nanoprocess” 模式来构建安全可靠的 Wasm 应用,一定少不了在 out-of-web 领域提供 Wasm 字节码解析和执行能力的基础设施。并且在一定程度上,我们还需要它们为我们提供的 WASI 系统接口的访问能力。
|
||||
|
||||
而“字节码联盟”便负责培养和发展这样一批,能够提供这些能力的优秀基础设施及相关组件。它们主要包括:Wasm 运行时(虚拟机)、Wasm 运行时组件(实现)以及 Wasm 语言相关的工具。
|
||||
|
||||
下面我便挑选其中一些具有代表性的运行时虚拟机,来给你进行介绍。当然,在字节码联盟之外,也有一批十分优秀的开源 Wasm 底层基础设施正在以惊人的速度蓬勃发展,这里我会同时选择一些有代表性的项目和你分享。
|
||||
|
||||
Wasmtime
|
||||
|
||||
Wamtime 是字节码联盟旗下的一个独立的 Wasm 运行时,它可以被独立作为 CLI 命令行工具进行使用,或者是被嵌入到其他的应用程序或系统中。Wamtime 具有很高的可配置性和可扩展性,因此可以被应用到很多的场景中,譬如 IoT 与云原生领域。
|
||||
|
||||
Wasmtime 基于优化的 Cranelift 引擎构建,因此它可以在运行时快速地生成高质量的机器码。Cranelift 是一个低层次的、可重定向的代码生成器。它可以将与目标无关的中间代码表示形式(IR)转换为可执行的机器代码。
|
||||
|
||||
除此之外,Wasmtime 还支持部分的 WASI 系统接口以及 Wasm Post-MVP 提案,以及对于诸如 C 和 Python 等语言的运行时绑定。这样你便可以在这些语言的代码中,直接使用 Wasmtime 所提供的能力。关于它的更多信息可以在这里查看。
|
||||
|
||||
|
||||
|
||||
WAMR
|
||||
|
||||
WAMR(WebAssembly Micro Runtime)同样是一款字节码联盟旗下的独立 Wasm 运行时,它基于 C 语言开发。不过相较于 Wasmtime,它更倾向于被应用在诸如 IoT、嵌入式芯片等对功耗和硬件资源要求较为严格的 Wasm 场景中。
|
||||
|
||||
WAMR 支持多种 Wasm 字节码的运行时“翻译”模式,比如 JIT 模式、AOT 模式以及解释器模式。其中在解释器模式下,整个运行时的大小仅有 85KB。在 AOT 模式下,仅有 50KB。不仅如此,它可以在将近 100 微秒的时间内启动应用,并在最小 100KB 的内存资源下,便可以启动一个 Wasm 实例。
|
||||
|
||||
WAMR 也同样支持 WASI 以及部分的 Wasm Post-MVP 提案。同时附带地,它还提供了一个用于快速构建 Wasm 应用的 WAMR 应用框架。关于它的更多信息可以在这里查看。
|
||||
|
||||
|
||||
|
||||
Wasmer
|
||||
|
||||
Wasmer 是另外一款独立于字节码联盟优秀的 Wasm 运行时。
|
||||
|
||||
不同于 Wasmtime 与 WAMR,Wasmer 基于 Rust 编写,它在支持 Wasm 核心标准、部分 WASI 系统接口以及部分 Wasm Post-MVP 标准的基础之上,还同时提供了对多达数十种编程语言的 Wasm 运行时绑定支持。这意味着,你可以在其他编程语言中使用 Wasmer 的能力来解析和执行 Wasm 字节码。
|
||||
|
||||
除此之外有一个很有趣的尝试, Wasmer 同时提供和维护 Wasm 包管理平台 —— Wapm。通过这个平台,你可以发布新的或直接使用已有的 Wasm 包。这些包都借助于 WASI 抽象操作系统接口,提供了与本地应用相同的系统资源访问能力。
|
||||
|
||||
关于它的更多信息可以查看这里。
|
||||
|
||||
|
||||
|
||||
SSVM
|
||||
|
||||
最后一个要介绍的 Wasm 运行时是 SSVM。它是一个专门针对云、AI 以及区块链应用程序设计的高性能、可扩展且经过硬件优化的 Wasm 虚拟机。
|
||||
|
||||
SSVM 的 Wasm 运行时针对 ONNX AI 模型进行了硬件优化。同时也可以作为区块链平台的智能合约运行时引擎。关于它的更多信息可以查看这里。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在本节课程中,我首先给你介绍了伴随 Wasm 发展而出现的一个新的组织 —— “字节码联盟”。字节码联盟由 Mozilla 等一批科技公司作为创始成员,通过协作的方式来共同打造 Wasm 在 out-of-web 领域的未来发展。
|
||||
|
||||
紧接着,我们讲解了字节码联盟出现的背景,也就是当前传统应用构建模式在安全性上的不足。基于 Wasm 与 WASI 的天然安全特性,我们可以按照 “Nanoprocess” 纳米进程模型,来构建更加安全的 Wasm 应用。字节码联盟出现的目的之一,便是为我们提供、培养一系列必备的底层基础设施与相关组件,这样我们可以在未来轻松便捷地构建这类安全应用。
|
||||
|
||||
在这节课的最后,我们一起快速浏览了一些当前在字节码联盟旗下,以及其他优秀的、开源的 Wasm 运行时。这些运行时都支持 Wasm 的 MVP 标准以及部分 Post-MVP 标准中的提案。不仅如此,这些运行时还都有选择性地支持了部分的 WASI 操作系统接口,并以不同的实现方式擅长于一个或多个不同的实际应用场景,比如嵌入式、IoT、AI 甚至是云计算领域。
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
你觉得当前传统的软件构建模式还存在着哪些安全性问题?欢迎大家各抒己见。
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
129
专栏/WebAssembly入门课/13LLVM:如何将自定义的语言编译到WebAssembly?.md
Normal file
129
专栏/WebAssembly入门课/13LLVM:如何将自定义的语言编译到WebAssembly?.md
Normal file
@ -0,0 +1,129 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 LLVM:如何将自定义的语言编译到 WebAssembly?
|
||||
你好,我是于航。
|
||||
|
||||
应用 Wasm 的常见方式有几种类型,一种方式是通过 Web 浏览器提供的 JavaScript API 与 Web API ,来在 Web 应用中调用从 Wasm 模块中导出的函数。通过这种方式,我们可以充分利用 Wasm 的安全、高效及可移植性等优势。
|
||||
|
||||
另一种方式是通过 WASI 抽象系统调用接口,以便在 out-of-web 应用中使用 Wasm。这种使用方式与 Web 端大同小异,不过区别是可以借助底层运行时的能力,使得我们构建出的 Wasm 应用可以在 Web 浏览器外的 Native 环境中与操作系统打交道,并同样享受着 Wasm 本身所带来的安全、高效及可移植性。
|
||||
|
||||
而今天我们要介绍的另外一个 Wasm 的应用场景,则相对有些特殊。在大多数时候,我们都是将由诸如 C/C++ 以及 Rust 等语言编写的源代码,编译至 Wasm 字节码格式来使用。假设此时我们想要设计开发一款自定义的静态编程语言,那么怎样才能够方便快捷地为它的编译器添加一个能力,可以让编译器支持将 Wasm 作为编译目标呢?
|
||||
|
||||
关于这个问题,我们要先从传统的编译器链路开始说起。
|
||||
|
||||
传统编译器链路
|
||||
|
||||
对于传统的静态语言编译器来说,通常会采用较为流行的“三段式”链路结构。如下图所示,三段式结构分别对应着整个编译器链路中三个最为重要的组成部分:编译器前端(Compiler Frontend)、中间代码优化器(Optimizer),以及编译器后端(Compiler Backend)。
|
||||
|
||||
|
||||
|
||||
其中,“编译器前端”主要用于对输入的源代码进行诸如:词法、语法及语义分析,并生成其对应的 AST 抽象语法树,然后再根据 AST 来生成编译器内部的中间代码表示形式(IR)。
|
||||
|
||||
“中间代码优化器”则主要用于对这些 IR 代码进行一定的优化,以减小最后生成的二进制文件大小,并同时提高二进制代码的执行效率。
|
||||
|
||||
最后的“编译器后端”则负责进行与本地架构平台相关的代码生成工作,主要会根据优化后的 IR 代码来进行寄存器分配和调优之类的工作,并生成对应的机器码,存储在构建出的二进制可执行文件中。当然,流程的细节根据具体编程语言实现可能有所不同。
|
||||
|
||||
这种分段式编译器链路的优势在于,当我们想要为其添加多种源语言或目标编译平台的支持时,我们只需要重新编写其中的一个“分段”,便可以很轻松地复用整个编译链路中的其他部分。你可以形象地通过下图来感受这种关系。
|
||||
|
||||
|
||||
|
||||
比如当我们需要为编译器添加对另外一种源语言的支持时,我们只需要编写整个链路中的“编译器前端”部分即可。
|
||||
|
||||
但是满足这种“链路可分离”要求的一个前提,需要整个链路中用于对接各个阶段的“中间产物(IR)”,其存在形式必须是确定且不变的。编译器前端“输送”给中间优化器的 IR 代码格式,必须对所有为各种源语言设计的编译器前端保持一致。同理,从中间优化器输入到编译器后端的“中间产物”也是如此。
|
||||
|
||||
然而一个现实的情况是,实际上在 LLVM 出现之前,在各类编程语言的编译器链路中,并没有采用完全统一的中间产物表示形式(包括 IR、AST 等在内)。因此如果想要对编译器链路中的某一部分进行重用,这个过程仍然会十分困难。
|
||||
|
||||
这就造成了每当人们需要重新设计一款编程语言时,需要将整个编译器的编译链路重新编写。但实际上编译器针对不同编程语言变化的部分,可能就只有编译器前端而已。
|
||||
|
||||
编译器链路的分段模式还有另外的一个好处,它可以让编译器开发者的分工更加明确。比如擅长编译器前端的开发者,便可以更加专注地来实现编译器的前端逻辑,为编译器提供针对新源语言的前端,而不用去考虑优化以及编译器后端的逻辑(对于这部分功能可以直接复用已有的编译器链路)。这对于需要投入到商业化运作中的编译器来说,十分有利。
|
||||
|
||||
LLVM
|
||||
|
||||
LLVM 的全称为 “Low Level Virtual Machine”,翻译成中文即“低层次虚拟机”。最初的 LLVM 是 Chris Lattner 和 Vikram Adve 两人于2000年12月研发的一套综合性的软件工具链。在这套工具链中,包含了众多可用于开发者使用的相关组件,这些组件包括语言编译器、链接器、调试器等操作系统底层基础构建工具。
|
||||
|
||||
LLVM 在开发初期,被定位为一套具有良好接口定义的可重用组件库。这意味着,我们可以在所开发的第三方应用程序中,使用由 LLVM 提供的众多成熟高效的编译链路解决方案。大到“中间代码优化器”,小到代码生成器中的一个 “SelectionDAG 图生成组件”。这些方案以“组件化”的形式被管理在整套 LLVM 工具集中,可用于支持整个编译链路中各个阶段遇到的各种问题。
|
||||
|
||||
除此之外,LLVM 还提供了众多可以直接使用的命令行工具。通过这些工具(如 llvm-as、llc、llvm-dis 等等),我们也可以快速地对经由 LLVM 组件生成的中间表示产物,进行一定的变换和处理,这极大地方便了我们的应用开发和调试流程。
|
||||
|
||||
LLVM-IR
|
||||
|
||||
在整个 LLVM 工具链体系中,最重要的组成部分,便是其统一的,用于表示编译器中间状态的代码格式 —— LLVM-IR。在一个基于 LLVM 实现的编译器链路中,位于链路中间的优化器将会使用 LLVM-IR 来作为统一的输入与输出中间代码格式。
|
||||
|
||||
在整个 LLVM 项目中,扮演着重要角色的 LLVM-IR 被定义成为一类具有明确语义的轻量级、低层次的类汇编语言,其具有足够强的表现力和较好的可扩展性。通过更加贴近底层硬件的语义表达方式,它可以将高级语言的语法清晰地映射到其自身。不仅如此,通过语义中提供的明确变量类型信息,优化器还可以对 LLVM-IR 代码进行更进一步的深度优化。
|
||||
|
||||
因此,通过将 LLVM-IR 作为连接编译器链路各个组成部分的重要中间代码格式,开发者便可以以此为纽带,来利用整个 LLVM 工具集中的任何组件。唯一的要求是所接入的源语言需要被转换为 LLVM-IR 的格式(编译器前端)。同样,对任何新目标平台的支持,也都需要从 LLVM-IR 格式开始,再转换成具体的某种机器码(编译器后端)。
|
||||
|
||||
在 LLVM-IR 的基础上,我们上面所讲的分段式编译链路可以被描绘成下图的形式。
|
||||
|
||||
|
||||
|
||||
命令行:基于 LLVM 生成 Wasm 字节码
|
||||
|
||||
既然基于 LLVM-IR,我们可以方便快捷地为整个编译链路添加新的前端源语言,或者是后端目标平台。因此 Wasm 也同样可以作为一种目标平台,被实现在 LLVM 中(Wasm 作为一种 V-ISA,其实本身与 I386、X86-64 等架构平台没有太大的区别)。
|
||||
|
||||
无独有偶的是,在 LLVM 中,已经存在了可用于 Wasm 目标平台的编译器后端。接下来,我们将尝试把一段 C/C++ 代码通过 LLVM 转换为 Wasm 字节码。
|
||||
|
||||
这里为了能够完成整个编译流程,我们将使用到 LLVM 工具集中的一个 CLI 命令行工具 —— llc,以及用于将 C/C++ 源代码编译为 LLVM-IR 中间代码的编译器 Clang。Clang 是一个业界知名的,基于 LLVM 构建的编译器,可用于编译 C/C++ 以及 Objective-C 等语言代码。
|
||||
|
||||
首先,我们给出如下这段 C/C++ 代码。
|
||||
|
||||
// add.cc
|
||||
extern "C" {
|
||||
int add (int a, int b) {
|
||||
return a + b;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
接下来,我们通过下面这个命令,将上面的代码编译为 LLVM-IR 中间代码对应的文本格式。
|
||||
|
||||
clang -S -emit-llvm add.cc
|
||||
|
||||
|
||||
其中我们通过指定 “-S” 与 “-emit-llvm” 两个参数,使 Clang 在编译源代码时生成对应的 LLVM-IR 文本格式。命令执行完毕后,我们可以得到一个名为 “add.ll” 的文件。通过文本编辑器打开这个文件,你可以看到如下截图所示的 LLVM-IR 内容。
|
||||
|
||||
|
||||
|
||||
关于 LLVM-IR 的具体内容,你对它有一个大致的概念即可。接下来,我们继续使用 “llc” 工具,来将上面这部分 LLVM-IR 中间代码转换为对应的 Wasm 字节码。
|
||||
|
||||
“llc” 是 LLVM 的静态编译器,它可以将输入的 LLVM-IR 代码编译到平台相关的机器码。我们可以通过命令 “llc –version” 来查看它所支持的编译目标平台。如下图所示,我们可以看到其支持名为 “wasm32” 与 “wasm64” 两种 Wasm 的目标平台,这里我们使用第一个 “wasm32”。
|
||||
|
||||
|
||||
|
||||
通过如下命令行,我们便可以将上述生成的 LLVM-IR 代码编译为最终的 Wasm 字节码。
|
||||
|
||||
llc add.ll -filetype=obj -mtriple=wasm32 -o add.wasm
|
||||
|
||||
|
||||
组件库:Wasm 编译器后端
|
||||
|
||||
在上面这个小实践环节中,我们通过使用 LLVM 工具链提供的命令行工具,将基于 C/C++ 代码编写的函数 “add” 编译成了对应的 Wasm 字节码格式。
|
||||
|
||||
那相对的,既然 LLVM 中存在着命令行工具可以进行类似的转换,那么在代码层面,便也存在着相应的组件库,能够实现从 LLVM-IR 到 Wasm 字节码的转换过程。
|
||||
|
||||
在实际的编码过程中,你可以通过 “llvm::TargetRegistry::lookupTarget” 这个 API 来设置和使用 Wasm 对应的目标编译器后端,以编译中间的 LLVM-IR 格式代码。关于这部分内容,你可以参考 LLVM 的官方文档来查阅更多的细节信息。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在本节课中,我们主要介绍了传统“三段式”编译器链路的一些特点,即分段式的结构更易于编译链路中对各重要组件的复用。同时,“三段式”也能够让编译链路的扩展变得更加轻松。 LLVM 的出现,使分段式编译链路的优点被发挥利用到最大。
|
||||
|
||||
LLVM 是一套综合性的软件工具链。内部提供了一系列基于 LLVM-IR、可用于构建编译相关系统工具的各类组件,比如代码优化器、生成器等等。不仅如此,LLVM 还为我们提供了诸如 “llc” 等命令行工具,可用于方便地对 LLVM-IR 等格式进行转换和编译。
|
||||
|
||||
最后,LLVM 也同样整合了可用于编译 LLVM-IR 到 Wasm 字节码的编译器后端,因此这对于我们来说,只要能够将我们自定义的编程语言代码编译到 LLVM-IR,那么我们就可以直接利用 LLVM 已有的 Wasm 后端,来将这些 IR 编译到 Wasm 字节码格式。不仅如此,我们还能够直接复用 LLVM 已有的优化器组件,来优化我们的生成代码,进而简化整个编译器的开发工作。
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
你知道在上面 “llc” 的帮助信息中,所支持平台 “wasm32” 与 “wasm64” 两者有什么区别吗?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
195
专栏/WebAssembly入门课/14有哪些正在行进中的WebAssemblyPost-MVP提案?.md
Normal file
195
专栏/WebAssembly入门课/14有哪些正在行进中的WebAssemblyPost-MVP提案?.md
Normal file
@ -0,0 +1,195 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 有哪些正在行进中的 WebAssembly Post-MVP 提案?
|
||||
你好,我是于航。
|
||||
|
||||
作为“应用篇”的最后一节课,我们来一起看看自 Wasm MVP 标准发布之后(2017年3月)的这三年时间里,Wasm 还有哪些行进中的后续标准正在被设计和实现?这些标准将会分别影响整个 Wasm 生态的哪些组成部分?借助于这些新的标准,现有的 Wasm 应用能否被进一步得到优化?Wasm 这项技术能否被应用到更多、更广的领域中呢?相信在学习完这节课后,对于上面这些问题,你会有着进一步的感悟。
|
||||
|
||||
实际上,在我们之前课程里所讲到的那些 Wasm 案例,均是在现有 MVP 标准所提供能力的基础上进行构建的。但 MVP 标准并不代表着 Wasm 的最终版本,相反,它正是标志着 Wasm 从枯燥的技术理论走向生产实践的一个起点。
|
||||
|
||||
MVP
|
||||
|
||||
MVP(Minimum Viable Product)的全称为“最小可行产品”,这个我们之前也提到过。既然是“最小可行产品”,那就意味着在这个版本中,包含有能够支持该产品正常使用的最少,同时也是最重要的组成部分。对于 Wasm 来说,便是我们之前在“核心原理”篇中介绍的那些内容。
|
||||
|
||||
那在这里,让我先来总结一下,Wasm 在 MVP 标准中都定义了哪些“功能”?
|
||||
|
||||
可编译目标
|
||||
|
||||
在本课程的第 03 讲中,我们曾介绍过,Wasm 实际上是一种新的 V-ISA 标准。“ISA” 我们都知道,翻译过来即“指令集架构”。同 X86、ARM 等其他常见的物理指令集架构类似,这意味着我们可以将诸如 C/C++ 等高级静态编程语言的代码,编译为对应这些 (V)ISA 的机器代码。
|
||||
|
||||
这里 ISA 的前缀 “V” ,代表着它是一种“虚拟的”指令集架构。也就是说,不同于我们上面提到的 X86 和 ARM,Wasm 指令集架构中的指令并不是为真实的物理硬件设计的。相反,这些虚拟指令被设计和应用于一种“概念性”的机器。而对于这个概念性机器的具体实现细节,则交由各个 VM 虚拟机以及 Runtime 运行时来负责。
|
||||
|
||||
而这便是 MVP 标准“赋予” Wasm 的第一个能力 —— 可编译目标。
|
||||
|
||||
作为一种指令集架构,MVP 标准下的 Wasm 仅提供了包括:“分支指令”、“内存操作指令”、“数学运算指令”以及“类型转换指令”等几种最为常用的指令类型。因此我们说,Wasm 这项技术在当前 MVP 标准下的能力是十分有限的,而“数学密集计算”这个场景便是它暂时所能够很好支持的几个重要的实践场景之一。
|
||||
|
||||
|
||||
|
||||
字节码格式
|
||||
|
||||
在定义好 Wasm 是什么(V-ISA)之后,此时我们已经有了这样一套新的 V-ISA 标准,在其中有着各种各样的虚拟指令。下一个需要在 MVP 标准中确定的部分就是,我们应该如何在计算机中表示这些指令的一个集合呢?
|
||||
|
||||
或者说同我们常见的 Windows 上的 “.exe” 可执行文件,以及 Linux 下的 ELF 可执行文件类似,一个包含有虚拟指令的 Wasm 文件,它的内部组成结构应该是怎样的?
|
||||
|
||||
关于这部分内容,我在第 04 和 05 讲中为你给出了答案。同样,这也是 Wasm MVP 标准中最为重要的一部分定义,即 “Wasm 字节码组成结构”。在其中定义了 Wasm 以 “Section” 为单元的模块内部组成结构,以及这些结构在二进制层面的具体编码方式等。
|
||||
|
||||
|
||||
|
||||
Web 可交互性
|
||||
|
||||
在定义好 “Wasm 是什么?”以及“如何在计算机中表示?”这两个问题之后,接下来便是“从理论到实践的最后一公里”,即“如何使用 Wasm?” 这个问题。这里 MVP 标准便为我们提供了相应的 JavaScript API 与 Web API 以用于实现 Wasm 与 Web 的可交互性,这部分内容我们曾在第 08 讲中介绍过。
|
||||
|
||||
但 MVP 标准中所定义的“可交互性”,仅满足了 Web 与 Wasm 之间的最简单“交流方式”。在这种交流方式下,JavaScript 环境与 Wasm 环境之间仅能够传递最基本的数字值。
|
||||
|
||||
而对于复杂数据类型的传递,则需要通过 Wasm 线性内存段进行中转。不仅如此,对于诸如 JavaScript 引擎等宿主环境中的“不透明数据“,我们也无法直接在 Wasm 模块中使用。而这便是 MVP 标准暂时所欠缺的部分。
|
||||
|
||||
|
||||
|
||||
Post-MVP
|
||||
|
||||
至此,我们知道对于 Wasm 的 MVP 版本标准来说,其实它并不在于想要一次性提供一个大而完整的新技术体系。相反,它希望能够在人们的实际生产实践中,去逐渐验证 Wasm 这项新技术是否真的有效,是否真的可以解决现阶段开发者遇到的问题,然后再同时根据这些来自实际生产实践的反馈,与开发者共同制定 Wasm 的未来发展方向。
|
||||
|
||||
那话不多说,让我们来看看在 MVP 标准发布之后的这几年时间里,Wasm 又发展出了哪些新的提案,而这些提案目前都处于怎样的进展中。
|
||||
|
||||
多线程与原子操作
|
||||
|
||||
顾名思义,”多线程与原子操作”提案为 Wasm 提供了多线程与原子内存操作相关的能力。从细节来看,该提案为 Wasm 标准提供了一个新的“共享内存模型”,以及众多的“内存原子操作指令”。这使得我们可以方便地在 Web 端构建出 Wasm 的多线程应用。如下图所示为基于此提案构建的 Wasm Web 多线程应用其基本结构。
|
||||
|
||||
|
||||
|
||||
“共享内存模型”你可以简单地理解为,它是一块可以同时被多个线程共享的线性内存段。你可以再看一下上面这张图。在 Web 平台中,SharedArrayBuffer 对象便被用来作为这样的一个“共享内存对象”,以便支持在多个 Worker 线程之间数据共享能力。
|
||||
|
||||
多线程模式的一个特征就是,每个 Worker 线程都将会实例化自己独有的 Wasm 对象,并且每个 Wasm 对象也都将拥有自己独立的栈容器用来存储操作数据。
|
||||
|
||||
如果再配合浏览器的 “Multi-Cores Worker” 特性,我们便能够真正地做到基于多个 CPU 核心的 Wasm 多线程,而到那个时候 Wasm 应用的数据处理能力便会有着更进一步的提升。
|
||||
|
||||
对于“原子内存操作”你可以把它简单理解为,当你在多个线程中通过这些原子内存操作指令来同时访问同一块内存中的数据时,不会发生“数据竞争”的问题。
|
||||
|
||||
每一个操作都是独立的事务,无法被中途打断,而这就是“原子”的概念。不仅如此,通过这些原子内存操作,我们还能够实现诸如“互斥锁”,“自旋锁”等各类并发锁结构。
|
||||
|
||||
目前,该提案已经可以在 Chrome 和 Firefox 的最新版本中使用。关于该提案的更多信息可以点击这里。
|
||||
|
||||
SIMD
|
||||
|
||||
SIMD 的全称为 “Single Instruction, Multiple Data”,即“单指令多数据流”。SIMD 是一种可以通过单一指令,对一组向量数据同时进行操作的一种并行性技术。你可以通过下图来直观地了解 SIMD 下的乘法与普通标量乘法运算之间的区别。
|
||||
|
||||
|
||||
|
||||
在左侧的“标量乘法运算”中,针对每一个乘法操作(An x Bn),我们都需要使用一条独立的乘法操作指令来执行,因此对于这四组操作,我们便需要使用四条指令。
|
||||
|
||||
而在右侧的 SIMD 版本中,针对 A1 到 A4 这四组乘法运算,我们可以仅通过一条 SIMD 指令,就能够同时完成针对这四组数字的对应乘法运算。相较于普通的标量乘法运算来说,SIMD 会使用特殊的寄存器来存储一个向量中的一簇数据,然后再以整个“向量”为单位进行运算。因此,相较于传统的标量计算,SIMD 的性能会有着成倍的增长。
|
||||
|
||||
在 Wasm Post-MVP 标准中便提供了这样的一系列指令,可以让 Wasm 利用硬件的 SIMD 特性来对特定的向量计算过程进行加速。可想而知,这对于需要同时对大量数据单元(如像素点)进行相同计算过程的应用场景(如“静态图像及视频处理”),会有着十分明显的性能提升。
|
||||
|
||||
不过遗憾的是,目前该提案还暂时无法在任何的浏览器中使用。 Chrome 和 Firefox 仍然在努力地实现中。关于该提案的更多信息你可以点击这里。
|
||||
|
||||
Wasm64
|
||||
|
||||
在目前的 MVP 标准中所有关于内存操作的相关指令,都仅能够使用 32 位长度的“偏移地址”,来访问 Wasm 模块线性内存中某个位置上的数据。而这便意味着,我们能够在 Wasm 实例中使用的内存资源最多仅能有 4GiB。因此我们一般会将 MVP 标准下的 Wasm 模型称为 “wasm32”。
|
||||
|
||||
而随着 Post-MVP 的不断发展,Wasm 也将开始支持 64 位长度的内存指针(偏移),来访问大于 4GiB 的内存资源。相信更多更加庞大和复杂化的 Wasm Web 应用,也将会伴随着 “wasm64” 模型的出现而逐渐涌现。Web 端的能力将逐渐与 Native 平台靠拢。
|
||||
|
||||
现阶段,该提案还没有被任何浏览器实现。关于该提案的更多信息你可以点击这里。
|
||||
|
||||
|
||||
|
||||
Wasm ES Module
|
||||
|
||||
相信 “ES Module” 对 Web 前端开发的同学来说,可谓是再熟悉不过了。作为一种官方的 JavaScript 模块化方案,“ES Module” 使得我们能够通过 “export” 与 “import” 两个关键字,来定义一个 JavaScript 模块所需要导入,以及可以公开导出给外部使用的资源。
|
||||
|
||||
那么试想一下,我们是否也可以为 Wasm 提供类似的能力呢?借助于该提案,我们可以简化一个 Wasm 模块的加载、解析与实例化过程。并且可以通过与 JavaScript 一致的方式,来使用从 Wasm 模块中导出的资源。
|
||||
|
||||
import { add } from "./util.wasm";
|
||||
console.log(add(1, 2)); // 3;
|
||||
|
||||
|
||||
可以看到在上面的代码中,相较于我们之前介绍的通过 JavaScript API 来加载和实例化 Wasm 模块的方式,使用 import 的方式会相对更加简洁。不仅如此,在该提案下,我们也可以通过 <script type="module">标签的方式来加载和使用一个 Wasm 模块。
|
||||
|
||||
现阶段,该提案还没有被任何浏览器实现。关于该提案的更多信息可以点击这里。
|
||||
|
||||
Interface Type
|
||||
|
||||
我们知道在目前的 Wasm MVP 标准中,Wasm 与宿主环境(比如 JavaScript)之间只能够互相传递“数字值”类型的数据。而对于诸如“字符串”、“对象”等复杂、特殊的数据类型,则需要通过编解码的方式来在两个不同环境之间传递和使用。这无疑增加了应用的整体执行成本和开发者的负担。而 “Interface Type” 提案的出现便可以在一定程度上解决这个问题。
|
||||
|
||||
该提案通过在宿主环境与 Wasm 模块之间添加“接口适配层”,来满足从 Wasm 模块的“低层次”数据类型,到外界宿主环境“高层次”数据类型之间的相互转换过程。借助于这个提案,Wasm 模块与宿主环境之间的可交换数据类型将会变得更加丰富,同时数据的交换成本也会变得更低。
|
||||
|
||||
当然,目前该提案仍然处在不断地修改和变化当中,能够支持的高层次数据类型还有待最终的确定。现阶段,该提案还没有被任何浏览器实现。关于该提案的更多信息你可以点击这里。
|
||||
|
||||
|
||||
|
||||
Wasm W3C 提案流程
|
||||
|
||||
事实上,同 TC39 对 ECMAScript 的提案流程一样,自 Wasm 成为 W3C 的一项“官方”标准之后,核心团队对 Wasm Post-MVP 提案的发布也有了相应的标准化流程。这个流程与 TC39 所使用的 “Stage0-4” 的“分阶段式”提案发布流程大同小异。
|
||||
|
||||
六个流程
|
||||
|
||||
一项新的 Wasm 提案从想法的诞生到最后被正式加入标准,一共需要经历如下的六个阶段:
|
||||
|
||||
0. Pre-Proposal [Individual Contributor]-
|
||||
1.Feature Proposal [Community Group]-
|
||||
2. Proposed Spec Text Available [Community + Woking Group]-
|
||||
3.Implementation Phase [Community + Working Group]-
|
||||
4.Standardize the Feature [Working Group]-
|
||||
5.The Feature is Standardized [Working Group]
|
||||
|
||||
关于这六个阶段的“进入条件”,以及每个阶段要做的事情,你可以点击这里进行查看。当然,Wasm CG(Community Group)社区是完全开放和透明的,只要你有合适的想法,能够提升或改善 Wasm 在某一方面的能力,那就可以加入到提案的流程中来。
|
||||
|
||||
提案总览
|
||||
|
||||
最后,我在这里将目前处在各个阶段的 Wasm 后续提案全部罗列出来,如果你对其中的某个提案感兴趣,可以点击对应的链接了解详情。甚至更进一步,你可以提出对这些提案的想法,帮助 CG 和 WG 改善提案的相关细节,一起共建 Wasm 的未来!
|
||||
|
||||
|
||||
Phase 4:Reference Types
|
||||
Phase 4:Bulk Memory Operations
|
||||
Phase 3:Tail Call
|
||||
Phase 3:Fixed-width SIMD
|
||||
Phase 3:Multiple Memories
|
||||
Phase 3:Custom Annotation Syntax in the Text Format
|
||||
Phase 2:Threads
|
||||
Phase 2:ECMAScript Module Integration
|
||||
Phase 2:Exception Handling
|
||||
Phase 2:Typed Function References
|
||||
Phase 2:Type Reflection for WebAssembly JavaScript API
|
||||
Phase 1:Type Imports
|
||||
Phase 1:Garbage Collection
|
||||
Phase 1:Interface Types
|
||||
Phase 1:WebAssembly C and C++ API
|
||||
Phase 1:Conditional Sections
|
||||
Phase 1:Extended Name Section
|
||||
Phase 1:Memory64
|
||||
Phase 1:Flexible Vectors
|
||||
Phase 1:Numeric Values in WAT Data Segments
|
||||
Phase 1:Instrument and Tracing Technology
|
||||
Phase 1:Call Tags
|
||||
Phase 0:Web Content Security Policy
|
||||
Phase 0:Funclets: Flexible Intraprocedural Control Flow
|
||||
Phase 0:Module Types
|
||||
Phase 0:Constant Time
|
||||
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在今天的课程中,我们主要介绍了 Wasm 从 MVP 标准到 Post-MVP 标准所经历的变化。在 MVP 标准中,主要定义了关于 Wasm 的一些核心基础性概念,比如 Wasm 作为 V-ISA 时的一些基本指令,Wasm 作为二进制模块时的文件内部组成结构及数据编码规则,以及用于支持 Wasm 与 Web 可交互性的一些基本 API 等。
|
||||
|
||||
对于 Post-MVP 的众多提案,则将会在 MVP 的基础之上再进一步拓展 Wasm 的能力。这里我选择了五个比较重要且易于理解的提案给你介绍。关于其他后续提案的更多信息,你可以参考我在本文最后放置的列表。在这个列表中,我给你整理了目前正在行进中的、处在各个发展阶段的 Wasm Post-MVP 提案。
|
||||
|
||||
当然,你需要知道的是,尽管其中的大部分提案看起来都十分复杂,但 Wasm 被作为一种高级语言的最终编译目标,无论是对于 MVP 还是 Post-MVP 标准中的大多数提案,它们都不会对我们日常使用 Wasm 的方式产生任何改变。
|
||||
|
||||
这些提案所带来的新特性或者优化机制,将由各个编译器和虚拟机来为我们实现。对于我们来说,甚至可以在不进行任何代码变更的情况下,便直接享受到这些由 Post-MVP 标准带来的好处。
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
你觉得 Wasm 的提案流程与 ECMAScript 的提案流程有哪些异同之处?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
222
专栏/WebAssembly入门课/15如何实现一个WebAssembly在线多媒体处理应用(一)?.md
Normal file
222
专栏/WebAssembly入门课/15如何实现一个WebAssembly在线多媒体处理应用(一)?.md
Normal file
@ -0,0 +1,222 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 如何实现一个 WebAssembly 在线多媒体处理应用(一)?
|
||||
你好,我是于航。
|
||||
|
||||
在之前两个章节的内容中,我们讲解了 Wasm 相关的核心原理,并介绍了 Wasm 在实际各个应用领域内的一些实践情况。从这一讲开始,我们将进入“实战篇”。作为第一个实战应用,我会手把手教你从零构建一个完整的 Wasm Web 应用。
|
||||
|
||||
具体是什么应用呢?你应该还记得,前面我们曾讲过一节课,题目是 “WebAssembly 在物联网、多媒体与云技术方面有哪些创新实践?” 。在那节课中,我们曾谈到过 Wasm 在 Web 多媒体资源处理领域所具有的极大优势。因此,接下来我们将一起尝试构建的应用,便是这样一个基于 Wasm 的在线 DIP 应用。
|
||||
|
||||
我把这个构建 Wasm Web 应用的完整过程,分成了上中下三讲。希望在你学完这三讲之后,能够游刃有余地了解一个 Wasm Web 应用从 0 到 1 的完整构建过程。我会在课程中尽量覆盖到足够多的实现细节,这样你可以通过细节去结合各方面的知识,不会在学习的过程中出现“断层”。
|
||||
|
||||
那接下来我们就直接进入主题,先来了解下这个 DIP 应用的概况。
|
||||
|
||||
DIP 应用概览
|
||||
|
||||
DIP 的全称为 “Digital Image Processing”,即“数字图像处理”。在我们将要构建的这个 Web 应用中,我们将会为在线播放的流媒体资源,去添加一个特定的实时“图像处理滤镜”,以改变视频本身的播放显示效果。
|
||||
|
||||
由于添加实时滤镜需要在视频播放时,同步地对当前某一帧画面上的所有像素点,进行一定的像素值的数学处理变换,因此整个应用从某个角度来说,是一个“计算密集型”应用。
|
||||
|
||||
首先,我们来看下这个应用在实际运行时的样子,你可以先看看下面给出的这张图。
|
||||
|
||||
|
||||
|
||||
根据这张图,我们可以将整个应用的运行界面划分为三个部分。其中最为明显就是上方的视频显示区域。在这个矩形的区域中,我们将循环播放一段视频,并根据用户是否选择添加滤镜,来实时动态地改变这里的视频显示效果。
|
||||
|
||||
紧接着视频下方的区域用来显示当前视频的实时播放帧率。通过显示播放帧率,我们将能够在应用运行时明显地看出,当在分别采用 JavaScript 以及 Wasm 为视频资源“添加”滤镜时,两者在计算性能上的差异。
|
||||
|
||||
再往下的一部分,便是整个应用的控制区域。在这个区域中,我们可以控制是否选择为视频添加滤镜效果。以及是使用 JavaScript 还是 Wasm 来处理滤镜的添加过程。当我们选择好某一项设置后,可以直接点击下方的“确定”按钮,使当前设置立即生效。
|
||||
|
||||
介绍完这个应用的 UI 布局之后,我们来总体看一看整个应用的结构图,如下所示。在后面的内容中,我们将会根据这个结构图,一步一步地来构建这个 Web 应用。
|
||||
|
||||
|
||||
|
||||
应用被划分为几个部分。首先,为了能够实时地处理视频数据,我们需要将 HTML <video>标签所承载视频的每一帧,都绘制在一个 Canvas 对象上,并通过 Web API — “requestAnimationFrame” 来让这些帧“动起来”。
|
||||
|
||||
然后这些数据将会根据用户所选择的设置,分别传递给 Wasm 模块或 JavaScript 进行相应的滤镜处理。这里 JavaScript 还同时兼具控制整个应用 UI 交互的作用,比如处理“点击事件”,处理用户所选择的设置等等。
|
||||
|
||||
滤镜的基本原理
|
||||
|
||||
在正式开始编写应用之前,我们还需要介绍几个在应用开发过程中会涉及到的概念和工具。首先,当我们说到“为视频添加滤镜”时,这一操作里的“滤镜”,主要是指什么?只有当你真正了解这个概念之后,你才能够知道相应的 JavaScript 代码,或者是 Wasm 代码需要做些什么。
|
||||
|
||||
为了了解什么是滤镜,我们需要先学习 DIP 领域中的一个基本概念 —— “卷积”。从一个直观的角度来看,对图像进行卷积的过程,其实就是通过一个具有固定尺寸的矩阵(也可以理解为二维数组),来对该图像中的每一个像素点的值进行重新计算的过程。
|
||||
|
||||
这个过程通常也被称为“滤波”。而我们上面介绍的固定尺寸的矩阵,一般被称为“卷积核”。每一种类型的卷积核都会对图像产生不同的滤镜效果。卷积的计算过程也十分简单,主要分为以下几个步骤。
|
||||
|
||||
|
||||
首先将卷积核矩阵翻转 180 度。
|
||||
然后将图像上除最外一圈(考虑到“边缘效应”,我们选择直接忽略边缘像素)的其他各像素点的灰度值,与对应的卷积核矩阵上的数值相乘,然后对所有相乘后得到的值求和,并将结果作为卷积核中间像素点对应图像上像素的灰度值。(这里提到的“灰度值”也可以由每个像素点的各 RGB 分量值来进行代替)。
|
||||
重复上述步骤,直至图像中所有其他像素点均完成此计算过程。
|
||||
|
||||
|
||||
为了加深你对上面计算过程的理解,这里我们来举个例子。首先,我们给出一个 3 x 3 大小的卷积核矩阵:
|
||||
|
||||
|
||||
|
||||
按照步骤,第一步我们需要对该矩阵进行 180 度的旋转,但由于该矩阵是中心对称的,因此经过旋转后的矩阵与原矩阵相比,没有任何变化。接下来,我们给出如下一个 4 x 4 像素大小的图像,并使用上述卷积核来对此图像进行滤波操作。该图像中各像素点的 RGB 分量值如下所示:
|
||||
|
||||
|
||||
|
||||
按照规则,对于 3 x 3 大小的卷积核矩阵,我们可以直接忽略图像最外层像素的卷积处理。相应地,我们需要从第二行第二列的像素点开始进行卷积计算。
|
||||
|
||||
首先,将之前翻转后的卷积核矩阵中心,与第二行第二列位置的这个像素点对齐,然后你会发现,卷积核矩阵中的各个单元,正好与图像左上角 3 x 3 范围内的像素一一对应。这就是我们可以忽略对图像最外一层像素进行卷积处理的原因。
|
||||
|
||||
因为在对这些像素点进行卷积计算时,卷积核矩阵覆盖的像素范围会超过图像的边界。你可以参考文中下面这张图来帮助理解我们现在所进行的步骤。
|
||||
|
||||
|
||||
|
||||
接着,我们开始计算。计算过程很简单。首先,我们把卷积核矩阵对应的 9 个单元格内,各像素点的 RGB 分量值与对应单元内的数值相乘,然后将这九个值进行求和。得到的结果值就是在卷积核矩阵中心单元格内,所对应像素的 RGB 分量值的卷积结果值。对于其他分量的卷积计算过程可以以此类推。
|
||||
|
||||
可能这么说,你还是有些不好理解。我以图像中第二行第二列的像素点为例,给你计算一下这个像素点 R 分量的卷积结果 R(c) :
|
||||
|
||||
R(c) = 2 x 0 + -1 x 0 + 2 x 0 + -1 x 0 + 2 x 10 + -1 x 255 + 2 x 0 + -1 x 0 + 2 x 100 = -35
|
||||
|
||||
可以看到,这个分量值在经过卷积计算后的结果值为 -35。但别急,相信你已经发现了不对的地方。一个 RGB 分量的有效取值范围为 [0, 255],而负数则明显是不正确的。
|
||||
|
||||
因此,在实际的卷积计算过程中,我们还需增加另外一个规则,也就是:对于小于 0 的计算结果,用 0 代替,大于 255 的计算结果,则用 255 进行代替。按照这个规则,该像素值经过卷积计算后的实际结果值应该为 0。
|
||||
|
||||
而在本次实践中,我们将会使用下面这个同样 3 x 3 大小的卷积核:
|
||||
|
||||
|
||||
|
||||
Emscripten 的基本用法
|
||||
|
||||
接下来,我们将讲解一下,本次实践所需要使用到的编译工具 — Emscripten(版本 1.39.19)。简单来说,Emscripten 是一个“源到源”语言编译器工具集,这个工具集可以将 C/C++ 代码编译成对应 JavaScript 代码。
|
||||
|
||||
既然是工具集,它便不是由单一的二进制可执行文件组成的,除了最为重要的编译器组件 emcc 以外,Emscripten 还同时为我们提供了包含有特定功能宏定义的 C/C++ 头文件、一些 Python 脚本以及其他的附属命令行工具等。其中,emcc 的基本组成结构如下图所示:
|
||||
|
||||
|
||||
|
||||
可以看到,emcc 能够将输入的 C/C++ 代码,编译成对应的 JavaScript 代码以及用于组成 Web 应用的 HTML 文件。
|
||||
|
||||
起初,Emscripten 主要用于将 C/C++ 代码编译成对应的 ASM.js 代码,而随着后来 Wasm 的逐渐发展和流行,Emscripten 也开始支持将这些输入代码编译成 Wasm 二进制代码。这部分代码的转换功能,主要依赖于 LLVM 为支持 Wasm 而特意添加的编译器后端。
|
||||
|
||||
因此,整个转换的大致流程可以简单归结为:C/C++ 源代码 -> LLVM IR -> Wasm。
|
||||
|
||||
emcc 直接使用了 Clang 编译器的前端,把编译输入的 C/C++ 源代码转换到 LLVM-IR 中间代码。这些中间形式的代码有利于编译器进行特殊的优化,以便生成更加优质的目标代码。
|
||||
|
||||
需要注意的一点是,在上图中你可以看到一个名为 “Fastcomp” 的组件,这个组件是 Emscripten 在旧版本中用于生成 ASM.js 代码的编译器后端,当然它也兼有生成 Wasm 代码的功能。
|
||||
|
||||
但是在最近的版本中,Emscripten 已经完全使用 LLVM 的后端,来代替 Fastcomp 生成 Wasm 二进制代码。从某种程度上来看,使用 LLVM 的 Wasm 编译器后端,将逐渐成为在主流静态编译器中,将 Wasm 作为编译目标的首选实现方式。
|
||||
|
||||
关于 Emscripten 的具体安装过程你可以参考官方文档。安装完成后,我们便可以来小试身手。接下来,我们将尝试使用 Emscripten 编译如下这段 C/C++ 代码(文件名为 main.cc):
|
||||
|
||||
#include <iostream>
|
||||
#include <emscripten.h>
|
||||
|
||||
extern "C" {
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int add(int x, int y) {
|
||||
return x + y;
|
||||
}
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
std::cout << add(10, 20) << std::endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,我们声明了一个函数 “add”,该函数接收两个整型参数,并返回这两个参数的算数和。整个函数的定义被放置在 extern “C” {} 结构中,以防止函数名被 C++ Name Mangling 改变。这样做的目的主要在于,我们可以确保当在宿主环境(比如浏览器)中调用该函数时,可以用基本与 C/C++ 源代码中保持一致的函数名,来直接调用这个函数。
|
||||
|
||||
这里需要注意的一个点是,我们使用了名为 “EMSCRIPTEN_KEEPALIVE” 的宏标记了该函数。这个宏定义在头文件 “emscripten.h” 中,通过使用它,我们能够确保被“标记”的函数不会在编译器的编译过程中,被 DCE(Dead Code Elimination)过程处理掉。紧接着,我们定义了主函数 main,并在其中调用了该函数。最后通过 std::cout 将该函数的调用结果输出到 stdout。
|
||||
|
||||
在代码编写完成后,我们可以使用下面的命令行来编译这段代码:
|
||||
|
||||
emcc main.cc -s WASM=1 -O3 -o main.html
|
||||
|
||||
|
||||
这里我们通过 “-s” 参数,为 emcc 指定了编译时选项 “WASM=1”。该选项可以让 emcc 将输入的 C/C++ 源码编译为对应的 Wasm 格式目标代码。同时,我们还指定了产出文件的格式为 “.html”,这样 Emscripten 便会为我们生成一个可以直接在浏览中使用的 Web 应用。
|
||||
|
||||
在这个应用中,包含了所有我们需要使用到的 Wasm 模块文件、JavaScript 代码以及 HTML 代码。为了能够在本地运行这个简单的 Web 应用,我们还需要准备一个简单的 Web 服务器,这里我们直接使用 Node.js 创建了一个简易的版本。代码如下所示:
|
||||
|
||||
const http = require('http');
|
||||
const url = require('url');
|
||||
const fs = require('fs');
|
||||
const path =require('path');
|
||||
|
||||
const PORT = 8888; // 服务器监听的端口号;
|
||||
|
||||
const mime = {
|
||||
"html": "text/html;charset=UTF-8",
|
||||
"wasm": "application/wasm" // 当遇到对 ".wasm" 格式文件的请求时,返回特定的 MIME 头;
|
||||
};
|
||||
|
||||
http.createServer((req, res) => {
|
||||
let realPath = path.join(__dirname, `.${url.parse(req.url).pathname}`);
|
||||
// 检查所访问文件是否存在,且是否可读;
|
||||
fs.access(realPath, fs.constants.R_OK, err => {
|
||||
if (err) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end();
|
||||
} else {
|
||||
fs.readFile(realPath, "binary", (err, file) => {
|
||||
if (err) {
|
||||
// 文件读取失败时返回 500;
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end();
|
||||
} else {
|
||||
// 根据请求的文件返回相应的文件内容;
|
||||
let ext = path.extname(realPath);
|
||||
ext = ext ? ext.slice(1) : 'unknown';
|
||||
let contentType = mime[ext] || "text/plain";
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.write(file, "binary");
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}).listen(PORT);
|
||||
console.log("Server is runing at port: " + PORT + ".");
|
||||
|
||||
|
||||
关于代码的实现细节,主要部分我都以注释的形式进行了标注。其中最为重要的一个地方就是对 “.wasm” 格式文件请求的处理。可以看到,这里当服务器收到一个对 “.wasm” 格式文件的 HTTP 请求时,会返回特殊的 MIME 类型 “application/wasm”。
|
||||
|
||||
通过这种方式,我们可以明确告诉浏览器,这个文件是一个 Wasm 格式的文件,进而浏览器便可以允许应用使用针对 Wasm 文件的“流式编译”方式(也就是我们在之前文章中介绍的 WebAssembly.instantiateStreaming 这个 Web API),来加载和解析该文件,这种方式在加载的处理大体积 Wasm 文件时会有着非常明显的效率优势。
|
||||
|
||||
接着,启动这个本地服务器后,我们便可以在浏览器中通过 8888 端口来访问刚刚由 Emscripten 编译生成的 main.html 文件。如果你顺利地到达了这里,那么恭喜,你将可以看到如下界面:
|
||||
|
||||
|
||||
|
||||
仔细观察,你会发现我们之前在 C/C++ 代码的 main 函数中,通过 std::cout 打印的,函数 add 的调用结果被显示在了下方的黑色区域中。
|
||||
|
||||
我们都知道,在 C/C++ 代码中,可以通过标准库提供的一系列 API 来直接访问主机上的文件,甚至也可以通过调用本地主机的系统调用来使用多线程等功能。那么,这部分代码是如何被编译到 Wasm 里,并允许在 Web 浏览器中使用的呢?这一切,都要归功于 Emscripten 为我们提供的一个虚拟主机运行时环境。
|
||||
|
||||
如下面这张图所示,通常一个完整的 Wasm Web 应用,都会由三部分组成,即:Wasm 模块代码、JavaScript 胶水代码以及一些对 Web API 的调用。
|
||||
|
||||
|
||||
|
||||
为了能够支持在 Web 浏览器中“使用”诸如 std::fopen 等 C/C++ 语言中,用于访问本机文件资源的标准库函数,Emscripten 会使用诸如 LocalStorage 之类的浏览器特性,来模拟完整的 POSIX 文件操作和相关的数据结构。当然,只不过这一切都是使用 JavaScript 来模拟实现的。
|
||||
|
||||
同样,在我们这个例子中,对于发送到 stdout 的数据,Emscripten 会通过 JavaScript 直接映射并输出到页面上的指定的 textarea 区域中。类似的,对于多线程甚至 TCP 网络访问(POSIX Socket),Emscripten 也会相应地通过浏览器上的 Web Worker 以及 Web Socket 等方式来进行模拟。
|
||||
|
||||
在上面的例子中,我们尝试了 Emscripten 最基本、最简单的,用于构建 Wasm Web 应用的一种方式。但该方法的弊端在于由 Emscripten 自动生成的“胶水代码”中,包含有通过 JavaScript 模拟出的 POSIX 运行时环境的完整代码,因此在某些情况下,应用整体的体积可能还是稍显过大。在极端网络环境的情况下,Web 应用可能会显得力不从心。
|
||||
|
||||
是否有方法可以让 Emscripten 仅生成 C/C++ 代码对应的 Wasm 模块,而对于 JS Glue 和 Web API 这两部分的代码则由我们自行编写呢?在接下来的两节课里,我将为你解答这个疑问。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
今天我们主要讲解了与实战项目有关的一些概念,比如“什么是滤镜?”,“为图片添加滤镜的具体步骤?”,以及 “什么是 Emscripten?”,“Emscripten 的基础用法?”等等。提前对这些概念有所了解可以加深我们对整个实战项目的组成结构与实现细节的把握。
|
||||
|
||||
其中,我希望你能够明确了解 Emscripten 在构建 Wasm Web 应用时,其所充当的一个编译器的角色,它可以将源 C/C++ 代码编译到 JavaScript 代码(甚至包括相应的 HTML 文件)。
|
||||
|
||||
另外,你需要重点了解的是,当 Emscripten 作为工具链使用时,它“以 emcc 、多种具有特定功能宏定义的 C/C++ 头文件为主,其他脚本和命令行工具为辅”的整体组成结构。
|
||||
|
||||
作为首个成功帮助 Wasm 在 Web 浏览器中进行生产实践的工具链,Emscripten 还有着众多的特性和功能等待着你去探索。
|
||||
|
||||
课后练习
|
||||
|
||||
最后,我们来做一个小练习吧。
|
||||
|
||||
还记得在今天的 Emscripten 实例中,我们使用到了名为 “EMSCRIPTEN_KEEPALIVE” 的宏,来确保被标记的函数不会被编译器优化掉。那么,你知道它具体是怎样实现的吗?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
315
专栏/WebAssembly入门课/16如何实现一个WebAssembly在线多媒体处理应用(二)?.md
Normal file
315
专栏/WebAssembly入门课/16如何实现一个WebAssembly在线多媒体处理应用(二)?.md
Normal file
@ -0,0 +1,315 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 如何实现一个 WebAssembly 在线多媒体处理应用(二)?
|
||||
你好,我是于航。
|
||||
|
||||
在上一节课中,我们介绍了本次实践项目在代码层面的大体组成结构,着重给你讲解了需要了解的一些基础性知识,比如“滤镜的基本原理及实现方法”以及“Emscripten 的基本用法”等等。而在这节课中,我们将继续构建这个基于 Wasm 实现的多媒体 Web 应用。
|
||||
|
||||
HTML
|
||||
|
||||
首先,我们来构建这个 Web 应用所对应的 HTML 部分。这部分代码如下所示:
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>DIP-DEMO</title>
|
||||
<style>
|
||||
* { font-family: "Arial,sans-serif"; }
|
||||
.fps-num { font-size: 50px; }
|
||||
.video { display: none; }
|
||||
.operation { margin: 20px; }
|
||||
button {
|
||||
width: 150px;
|
||||
height: 30px;
|
||||
margin-top: 10px;
|
||||
border: solid 1px #999;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.radio-text { font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas class="canvas"></canvas>
|
||||
<div class="operation">
|
||||
<h2>帧率:<span class="fps-num">NaN</span> FPS</h2>
|
||||
<input name="options" value="0" type="radio" checked="checked"/>
|
||||
<span class="radio-text">不开启渲染.</span> <br/>
|
||||
<input name="options" value="1" type="radio"/>
|
||||
<span class="radio-text">使用 <b>[JavaScript]</b> 渲染.</span>
|
||||
<br/>
|
||||
<input name="options" value="2" type="radio"/>
|
||||
<span class="radio-text">使用 <b>[WebAssembly]</b> 渲染.</span>
|
||||
<br/>
|
||||
<button>确认</button>
|
||||
</div>
|
||||
<video class="video" type="video/mp4"
|
||||
muted="muted"
|
||||
loop="true"
|
||||
autoplay="true"
|
||||
src="media/video.mp4">
|
||||
</body>
|
||||
<script src='./dip.js'></script>
|
||||
</html>
|
||||
|
||||
|
||||
为了便于演示,HTML 代码部分我们尽量从简,并且直接将 CSS 样式内联到 HTML 头部。
|
||||
|
||||
其中最为重要的两个部分为 “<canvas>” 标签和 “<video>” 标签。<canvas> 将用于展示对应 <video> 标签所加载外部视频资源的画面数据;而这些帧数据在被渲染到<canvas>之前,将会根据用户的设置,有选择性地被 JavaScript 代码或者 Wasm 模块进行处理。
|
||||
|
||||
还有一点需要注意的是,可以看到我们为<video> 标签添加了名为 “muted”、“loop” 以及 “autoplay” 的三个属性。这三个属性分别把这个视频资源设置为“静音播放”、“循环播放”以及“自动播放”。
|
||||
|
||||
实际上,根据 Chrome 官方给出的 “Autoplay Policy” 政策,我们并不能够直接依赖其中的 “autoplay” 属性,来让视频在用户打开网页时立即自动播放。稍后你会看到,在应用实际加载时,我们仍会通过调用 <video> 标签所对应的 play() 方法,来确保视频资源可以在网页加载完毕后,直接自动播放。
|
||||
|
||||
最后,在 HTML 代码的末尾处,我使用 <script> 标签加载了同目录下名为 “dip.js” 的 JavaScript 文件。在这个文件中,我们将完成该 Web 应用的所有控制逻辑,包括:视频流的控制与显示逻辑、用户与网页的交互逻辑、JavaScript 版滤镜的实现、Wasm 版滤镜实现对应的模块加载、初始化与调用逻辑,以及实时帧率的计算逻辑等。
|
||||
|
||||
JavaScript
|
||||
|
||||
趁热打铁,我们接着来编写整个 Web 应用组成中,最为重要的 JavaScript 代码部分。
|
||||
|
||||
视频流的控制与显示逻辑
|
||||
|
||||
第一步,我们要实现的是将 <video> 标签所加载的视频资源,实时渲染到 <canvas> 标签所代表的画布对象上。这一步的具体实现方式,你可以参考下面这张示意图。
|
||||
|
||||
|
||||
|
||||
其中的核心逻辑是,我们需要通过名为 “CanvasRenderingContext2D.drawImage()” 的 Web API ,来将 <video> 标签所承载视频的当前帧内容,绘制到 <canvas> 上。这里我们使用到的 drawImage() 方法,支持设置多种类型的图像源,<video> 标签所对应的 “HTMLVideoElement” 便是其中的一种。
|
||||
|
||||
CanvasRenderingContext2D 接口是 Web API 中, Canvas API 的一部分。通过这个接口,我们能够获得一个,可以在对应 Canvas 上进行 2D 绘图的“渲染上下文”。稍后在代码中你会看到,我们将通过 <canvas> 对象上名为 “getContext” 的方法,来获得这个上下文对象。
|
||||
|
||||
我们之前曾提到,drawImage() 方法只能够绘制 <video> 标签对应视频流的“当前帧”内容,因此随着视频的播放,“当前帧”内容也会随之发生改变。
|
||||
|
||||
为了能够让绘制到 <canvas> 上的画面可以随着视频的播放来实时更新,这里我们将使用名为 “window.requestAnimationFrame” 的 Web API, 来实时更新绘制在 <canvas> 上的画面内容(如果你对这个 API 不太熟悉,可以点击这里回到“基础课”进行复习)。
|
||||
|
||||
下面我们给出这部分功能对应的代码实现:
|
||||
|
||||
// 获取相关的 HTML 元素;
|
||||
let video = document.querySelector('.video');
|
||||
let canvas = document.querySelector('.canvas');
|
||||
|
||||
// 使用 getContext 方法获取 <canvas> 标签对应的一个 CanvasRenderingContext2D 接口;
|
||||
let context = canvas.getContext('2d');
|
||||
|
||||
// 自动播放 <video> 载入的视频;
|
||||
let promise = video.play();
|
||||
if (promise !== undefined) {
|
||||
promise.catch(error => {
|
||||
console.error("The video can not autoplay!")
|
||||
});
|
||||
}
|
||||
// 定义绘制函数;
|
||||
function draw() {
|
||||
// 调用 drawImage 函数绘制图像到 <canvas>;
|
||||
context.drawImage(video, 0, 0);
|
||||
// 获得 <canvas> 上当前帧对应画面的像素数组;
|
||||
pixels = context.getImageData(0, 0, video.videoWidth, video.videoHeight);
|
||||
// ...
|
||||
// 更新下一帧画面;
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
// <video> 视频资源加载完毕后执行;
|
||||
video.addEventListener("loadeddata", () => {
|
||||
// 根据 <video> 载入视频大小调整对应的 <canvas> 尺寸;
|
||||
canvas.setAttribute('height', video.videoHeight);
|
||||
canvas.setAttribute('width', video.videoWidth);
|
||||
// 绘制函数入口;
|
||||
draw(context);
|
||||
});
|
||||
|
||||
|
||||
关于代码中每一行的具体功能,你可以参考附加到相应代码行前的注释加以理解。首先,我们需要获得相应的 HTML 元素,这里主要是 <canvas> 和 <video> 这两个标签对应的元素对象,然后我们获取了 <canvas> 标签对应的 2D 绘图上下文。
|
||||
|
||||
紧接着,我们处理了 <video> 标签所加载视频自动播放的问题,这里我们直接调用了 <video> 元素的 play 方法。该方法会返回一个 Promise,针对 reject 的情况,我们做出了相应的处理。
|
||||
|
||||
然后,我们在 <video> 元素的加载回调完成事件 “loadeddata” 中,根据所加载视频的尺寸相应地调整了 <canvas> 元素的大小,以确保它可以完整地显示出视频的画面内容。同时在这里,我们调用了自定义的 draw 方法,来把视频的首帧内容更新到 <canvas> 画布上。
|
||||
|
||||
在 draw 方法中,我们调用了 drawImage 方法来更新 <canvas> 画布的显示内容。该方法在这里接受三个参数,第一个为图像源,也就是 <video> 元素对应的 HTMLVideoElement 对象;第二个为待绘制图像的起点在 <canvas> 上 X 轴的偏移;第三个参数与第二个类似,相应地为在 Y 轴上的偏移。这里对于最后两个参数,我们均设置为 0。
|
||||
|
||||
然后,我们使用了名为 “CanvasRenderingContext2D.getImageData()” 的方法(下文简称 “getImageData”)来获得 <canvas> 上当前帧对应画面的像素数组。
|
||||
|
||||
getImageData 方法接受四个参数。前两个参数指定想要获取像素的帧画面,在当前帧画面 x 轴和 y 轴上的偏移范围。最后两个参数指定这个范围的长和宽。
|
||||
|
||||
四个参数共同指定了画面上的一个矩形位置,在对应该矩形的范围内,所有像素序列将会被返回。我们会在后面来使用和处理这些返回的像素数据。
|
||||
|
||||
最后,我们通过 requestAnimationFrame 方法,以 60Hz 的频率来更新 <canvas> 上的画面。
|
||||
|
||||
在上述这部分代码实现后,我们的 Web 应用便可在用户打开网页时,直接将 <video> 加载播放的视频,实时地绘制在 <canvas> 对应的画布中。
|
||||
|
||||
用户与网页的交互逻辑
|
||||
|
||||
接下来,我们继续实现 JavaScript 代码中,与“处理用户交互逻辑”这部分功能有关的代码。
|
||||
|
||||
这部分代码比较简单,主要流程就是监听用户做出的更改,然后将这些更改后的值保存起来。这里为了实现简单,我们直接以“全局变量”的方式来保存这些设置项的值。这部分代码如下所示:
|
||||
|
||||
// 全局状态;
|
||||
const STATUS = ['STOP', 'JS', 'WASM'];
|
||||
// 当前状态;
|
||||
let globalStatus = 'STOP';
|
||||
// 监听用户点击事件;
|
||||
document.querySelector("button").addEventListener('click', () => {
|
||||
globalStatus = STATUS[
|
||||
Number(
|
||||
document.querySelector("input[name='options']:checked").value
|
||||
)
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
这里我们需要维护应用的三种不同状态,即:不使用滤镜(STOP)、使用 JavaScript 实现滤镜(JS)、使用 Wasm 实现滤镜(WASM)。全局变量 globalStatus 维护了当前应用的状态,在后续的代码中,我们也将使用这个变量的值,来调用不同的滤镜实现,或者选择关闭滤镜。
|
||||
|
||||
实时帧率的计算逻辑
|
||||
|
||||
作为开始真正构建 JavaScript 版滤镜函数前的最后一步,我们先来实现帧率的实时计算逻辑,然后观察在不开启任何滤镜效果时的 <canvas> 渲染帧率情况。
|
||||
|
||||
帧率的一个粗糙计算公式如下图所示。对于帧率,我们可以将其简单理解为在 1s 时间内屏幕上画面能够刷新的次数。比如若 1s 时间内画面能够更新 60 次,那我们就可以说它的帧率为 60 赫兹(Hz)。
|
||||
|
||||
|
||||
|
||||
因此,一个简单的帧率计算逻辑便可以这样来实现:首先,把每一次从对画面像素开始进行处理,直到真正绘制到 <canvas>这整个流程所耗费的时间,以毫秒为单位进行计算;然后用 1000 除以这个数值,即可得到一个估计的,在 1s 时间所内能够渲染的画面次数,也就是帧率。
|
||||
|
||||
这部分逻辑的 JavaScript 实现代码如下所示:
|
||||
|
||||
function calcFPS (vector) {
|
||||
// 提取容器中的前 20 个元素来计算平均值;
|
||||
const AVERAGE_RECORDS_COUNT = 20;
|
||||
if (vector.length > AVERAGE_RECORDS_COUNT) {
|
||||
vector.shift(-1); // 维护容器大小;
|
||||
} else {
|
||||
return 'NaN';
|
||||
}
|
||||
// 计算平均每帧在绘制过程中所消耗的时间;
|
||||
let averageTime = (vector.reduce((pre, item) => {
|
||||
return pre + item;
|
||||
}, 0) / Math.abs(AVERAGE_RECORDS_COUNT));
|
||||
// 估算出 1s 内能够绘制的帧数;
|
||||
return (1000 / averageTime).toFixed(2);
|
||||
}
|
||||
|
||||
|
||||
这里,为了能够让帧率的估算更加准确,我们为 JavaScript 和 Wasm 这两个版本的滤镜实现,分别单独准备了用来保存每帧计算时延的全局数组。这些数组会保存着在最近 20 帧里,每一帧计算渲染时所花费的时间。
|
||||
|
||||
然后,在上面代码中的函数 calcFPS 内,我们会通过对这 20 个帧时延记录取平均值,来求得一个更加稳定、相对准确的平均帧时延。最后,使用 1000 来除以这个平均帧时延,你就能够得到一个估算出的,在 1s 时间内能够绘制的帧数,也就是帧率。
|
||||
|
||||
上面代码中的语句 vector.shift(-1) 其主要作用是,当保存最近帧时延的全局数组内元素个数超过 20 个时,会移除其中最老的一个元素。这样,我们可以保证整个数组的大小维持在 20 及以内,不会随着应用的运行而产生 OOM(Out-of-memory)的问题。
|
||||
|
||||
我们将前面讲解的这些代码稍微整合一下,并添加上对应需要使用到的一些全局变量。然后尝试在浏览器中运行这个 Web 应用。在不开启任何滤镜的情况下,你可得到如下的画面实时渲染帧率(这里我们使用 Chrome 进行测试,不同的浏览器和版本结果会有所差异)。
|
||||
|
||||
|
||||
|
||||
JavaScript 滤镜方法的实现
|
||||
|
||||
接下来,我们将编写整个 Web 应用的核心组成之一 —— JavaScript 滤镜函数。关于这个函数的具体实现步骤,你可以参考在上一节课中介绍的“滤镜基本原理”。
|
||||
|
||||
首先,根据规则,我们需要准备一个 3x3 大小的二维数组,来容纳“卷积核”矩阵。然后将该矩阵进行 180 度的翻转。最后得到的结果矩阵,将会在后续直接参与到各个像素点的滤镜计算过程。这部分功能对应的 JavaScript 代码实现如下所示:
|
||||
|
||||
// 矩阵翻转函数;
|
||||
function flipKernel(kernel) {
|
||||
const h = kernel.length;
|
||||
const half = Math.floor(h / 2);
|
||||
// 按中心对称的方式将矩阵中的数字上下、左右进行互换;
|
||||
for (let i = 0; i < half; ++i) {
|
||||
for (let j = 0; j < h; ++j) {
|
||||
let _t = kernel[i][j];
|
||||
kernel[i][j] = kernel[h - i - 1][h - j - 1];
|
||||
kernel[h - i - 1][h - j - 1] = _t;
|
||||
}
|
||||
}
|
||||
// 处理矩阵行数为奇数的情况;
|
||||
if (h & 1) {
|
||||
// 将中间行左右两侧对称位置的数进行互换;
|
||||
for (let j = 0; j < half; ++j) {
|
||||
let _t = kernel[half][j];
|
||||
kernel[half][j] = kernel[half][h - j - 1];
|
||||
kernel[half][h - j - 1] = _t;
|
||||
}
|
||||
}
|
||||
return kernel;
|
||||
}
|
||||
// 得到经过翻转 180 度后的卷积核矩阵;
|
||||
const kernel = flipKernel([
|
||||
[-1, -1, 1],
|
||||
[-1, 14, -1],
|
||||
[1, -1, -1]
|
||||
]);
|
||||
|
||||
|
||||
关于“如何将矩阵数组进行 180 度翻转”的实现细节,你可以参考代码中给出的注释来加以理解。
|
||||
|
||||
在一切准备就绪后,我们来编写核心的 JavaScript 滤镜处理函数 jsConvFilter。该处理函数一共接受四个参数。第一个参数是通过 getImageData 方法,从 <canvas> 对象上获取的当前帧画面的像素数组数据。
|
||||
|
||||
getImageData 在执行完毕后会返回一个 ImageData 类型的对象,在该对象中有一个名为 data 的属性。data 属性实际上是一个 Uint8ClampedArray 类型的 “Typed Array”,其中便存放着所有像素点按顺序排放的 RGBA 分量值。你可以借助下面这张图来帮助理解上面我们描述的,各个方法与返回值之间的对应关系。
|
||||
|
||||
|
||||
|
||||
jsConvFilter 处理函数的第二和第三个参数为视频帧画面的宽和高;最后一个参数为所应用滤镜对应的“卷积核”矩阵数组。至此,我们可以构造如下的 JavaScript 版本“滤镜函数”:
|
||||
|
||||
function jsConvFilter(data, width, height, kernel) {
|
||||
const divisor = 4; // 分量调节参数;
|
||||
const h = kernel.length, w = h; // 保存卷积核数组的宽和高;
|
||||
const half = Math.floor(h / 2);
|
||||
// 根据卷积核的大小来忽略对边缘像素的处理;
|
||||
for (let y = half; y < height - half; ++y) {
|
||||
for (let x = half; x < width - half; ++x) {
|
||||
// 每个像素点在像素分量数组中的起始位置;
|
||||
const px = (y * width + x) * 4;
|
||||
let r = 0, g = 0, b = 0;
|
||||
// 与卷积核矩阵数组进行运算;
|
||||
for (let cy = 0; cy < h; ++cy) {
|
||||
for (let cx = 0; cx < w; ++cx) {
|
||||
// 获取卷积核矩阵所覆盖位置的每一个像素的起始偏移位置;
|
||||
const cpx = ((y + (cy - half)) * width + (x + (cx - half))) * 4;
|
||||
// 对卷积核中心像素点的 RGB 各分量进行卷积计算(累加);
|
||||
r += data[cpx + 0] * kernel[cy][cx];
|
||||
g += data[cpx + 1] * kernel[cy][cx];
|
||||
b += data[cpx + 2] * kernel[cy][cx];
|
||||
}
|
||||
}
|
||||
// 处理 RGB 三个分量的卷积结果;
|
||||
data[px + 0] = ((r / divisor) > 255) ? 255 : ((r / divisor) < 0) ? 0 : r / divisor;
|
||||
data[px + 1] = ((g / divisor) > 255) ? 255 : ((g / divisor) < 0) ? 0 : g / divisor;
|
||||
data[px + 2] = ((b / divisor) > 255) ? 255 : ((b / divisor) < 0) ? 0 : b / divisor;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
你可以借助代码中的注释来了解整个卷积过程的实现细节。其中有这样几个点需要注意:
|
||||
|
||||
在整个方法的实现过程中,我们使用了名为 divisor 的变量,来控制滤镜对视频帧画面产生的效果强度。divisor 的值越大,滤镜的效果就越弱。
|
||||
|
||||
在遍历整个帧画面的像素序列时(最外层的两个循环体),我们将循环控制变量 y 和 x 的初始值,设置为 Math.floor(h / 2),这样可以直接忽略对帧画面边缘像素的处理,进而也不用考虑图像卷积产生的“边缘效应”。
|
||||
|
||||
所谓“边缘效应”,其实就是指当我们在处理帧画面的边缘像素时,由于卷积核其范围内的一部分“单元格”无法找到与之相对应的像素点,导致边缘像素实际上没有经过“完整”的滤镜计算过程,会产生与预期不符的滤镜处理效果。而这里为了简化流程,我们选择了直接忽略对边缘像素的处理过程。
|
||||
|
||||
最后,在得到经过卷积累加计算的 RGB 分量值后,我们需要判断对应值是否在 [0, 255] 这个有效区间内。若没有,我们就将这个值,直接置为对应的最大有效值或最小有效值。
|
||||
|
||||
现在,我们将前面的所有代码功能加以整合,然后试着在浏览器中再次运行这个 Web 应用。你会看到类似下图的结果。相较于不开启滤镜,使用滤镜后的画面渲染帧率明显下降了。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
今天我们主要讲解了本次实践项目中与 JavaScript 代码相关的几个重要功能的实现思路,以及实现细节。
|
||||
|
||||
JavaScript 代码作为当前用来构建 Web 应用所必不可少的一个重要组成部分,它负责构建整个应用与用户进行交互的逻辑处理部分。不仅如此,我们还使用 JavaScript 代码实现了一个滤镜处理函数,并用该函数处理了 <canvas> 上的帧画面像素数据,然后再将这些数据重新绘制到 <canvas> 上。
|
||||
|
||||
在下一节课里,你将会看到我们实现的 Wasm 滤镜处理函数,与 JavaScript 版滤镜函数在图像处理效率上的差异。
|
||||
|
||||
课后练习
|
||||
|
||||
最后,我们来做一个练习题吧。
|
||||
|
||||
你可以试着更改我们在 JavaScript 滤镜函数中所使用的卷积核矩阵(更改矩阵中元素的值,或者改变矩阵的大小),来看看不同的卷积核矩阵会产生怎样不同的滤镜效果。
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
201
专栏/WebAssembly入门课/17如何实现一个WebAssembly在线多媒体处理应用(三)?.md
Normal file
201
专栏/WebAssembly入门课/17如何实现一个WebAssembly在线多媒体处理应用(三)?.md
Normal file
@ -0,0 +1,201 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 如何实现一个 WebAssembly 在线多媒体处理应用(三)?
|
||||
你好,我是于航。
|
||||
|
||||
在上一节课中,我们已经完成了本次实践项目的其中一个核心部分,也就是由 JavaScript 实现的滤镜函数。并且还同时完成了整个 Web 应用与用户的 UI 交互控制部分、视频图像的渲染和绘制逻辑,以及帧率计算逻辑及显示逻辑。
|
||||
|
||||
在这节课里,我们将一起来完成整个应用的另外一个核心部分,同时也是整个实践的主角。让我们来看看,相较于 JavaScript 滤镜函数,由 Wasm 实现的同版本滤镜函数会带来怎样的性能提升呢?
|
||||
|
||||
编写 C/C++ 函数源码
|
||||
|
||||
首先,为了能够得到对应 Wasm 字节码格式的函数实现,我们需要首先准备由 C/C++ 等高级语言编写的源代码,然后再通过 Emscripten 将其编译到 Wasm 格式。这部分代码的主要逻辑,与上一篇中的 JavaScript 版本滤镜函数其实现逻辑基本相同。代码如下所示:
|
||||
|
||||
// dip.cc
|
||||
// 引入必要的头文件;
|
||||
#include <emscripten.h>
|
||||
#include <cmath>
|
||||
// 宏常量定义,表示卷积核矩阵的高和宽;
|
||||
#define KH 3
|
||||
#define KW 3
|
||||
// 声明两个数组,分别用于存放卷积核数据与每一帧对应的像素点数据;
|
||||
char kernel[KH][KW];
|
||||
unsigned char data[921600];
|
||||
// 将被导出的函数,放置在 extern "C" 中防止 Name Mangling;
|
||||
extern "C" {
|
||||
// 获取卷积核数组的首地址;
|
||||
EMSCRIPTEN_KEEPALIVE auto* cppGetkernelPtr() { return kernel; }
|
||||
// 获取帧像素数组的首地址;
|
||||
EMSCRIPTEN_KEEPALIVE auto* cppGetDataPtr() { return data; }
|
||||
// 滤镜函数;
|
||||
EMSCRIPTEN_KEEPALIVE void cppConvFilter(
|
||||
int width,
|
||||
int height,
|
||||
int divisor) {
|
||||
const int half = std::floor(KH / 2);
|
||||
for (int y = half; y < height - half; ++y) {
|
||||
for (int x = half; x < width - half; ++x) {
|
||||
int px = (y * width + x) * 4;
|
||||
int r = 0, g = 0, b = 0;
|
||||
for (int cy = 0; cy < KH; ++cy) {
|
||||
for (int cx = 0; cx < KW; ++cx) {
|
||||
const int cpx = ((y + (cy - half)) * width + (x + (cx - half))) * 4;
|
||||
r += data[cpx + 0] * kernel[cy][cx];
|
||||
g += data[cpx + 1] * kernel[cy][cx];
|
||||
b += data[cpx + 2] * kernel[cy][cx];
|
||||
}
|
||||
}
|
||||
data[px + 0] = ((r / divisor) > 255) ? 255 : ((r / divisor) < 0) ? 0 : r / divisor;
|
||||
data[px + 1] = ((g / divisor) > 255) ? 255 : ((g / divisor) < 0) ? 0 : g / divisor;
|
||||
data[px + 2] = ((b / divisor) > 255) ? 255 : ((b / divisor) < 0) ? 0 : b / divisor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,我们将定义的所有函数均以 “cpp” 作为其前缀来命名,表明这个函数的实际定义来自于对应的 C/C++ 代码实现。其中,“cppConvFilter” 函数为主要的滤镜计算函数。在该函数中,我们保持着几乎与上一节课中,JavaScript 版滤镜函数同样的实现逻辑。
|
||||
|
||||
在代码的开始,我们首先以 “#include” 的方式,包含了很多需要使用到的 C/C++ 头文件。其中 “emscripten.h” 头文件便由 Emscripten 工具链提供,其中包含着众多与 Wasm 编译相关的宏和函数定义。
|
||||
|
||||
另外的 “cmath” 头文件,是原始 C 标准库中的 “math.h” 头文件在 C++ 中的对应。两者在所提供函数的功能上基本没有区别。我们将使用该头文件中提供的 “std::floor” 函数,去参与滤镜的计算过程。
|
||||
|
||||
接下来,我们使用 “#define” 定义了两个宏常量 “KH” 与 “KW”,分别表示卷积核的“高”与“宽”。并同时使用这两个常量,定义了用来存放实际卷积核矩阵数据的二维数组 “kernel”。类似的,我们还定义了用来存放每一帧对应像素数据的一维数组 “data”。
|
||||
|
||||
这里要注意的是,由于在 C/C++ 中,无法声明全局的动态大小数组,因此我们需要提前计算出,由 Web API “CanvasRenderingContext2D.getImageData” 所返回的,存放有每一帧对应像素数据的那个 Uint8ClampedArray 数组,在 C/C++ 中对应到 unsigned char 类型数组时的大小。
|
||||
|
||||
由于这两个数组所存储的单个元素其类型完全相同,因此我们直接使用这个得到的 Uint8ClampedArray 数组的大小,来作为对应 C/C++ 中 “data” 数组的大小。经过实践,我们得到的数组大小为 “921600”。
|
||||
|
||||
在 extern "C" {} 结构中,我们声明了所有需要导出到外部宿主环境(这里为浏览器的 JavaScript 环境)中使用的函数。其中除了 cppConvFilter 函数以外,还有另外的 cppGetkernelPtr 和 cppGetDataPtr 函数。这两个函数主要用来获取先前声明的数组 kernel 与 data 的首地址。通过这种方式,我们便可以在外部的 JavaScript 环境中,向定义在 C/C++ 中的这两个数组结构填充实际的运行时数据了。
|
||||
|
||||
使用 Emscripten 进行编译
|
||||
|
||||
当 C/C++ 源代码准备完毕后,我们便可以使用 Emscripten 来进行编译。与我们在实践项目的第一节课中介绍的 Emscripten 编译方式不同,这次我们不需要它帮助我们生成 JavaScript 胶水文件以及 HTML 文件,我们需要的仅是一个根据 C/C++ 代码生成的 Wasm 二进制模块文件,对于其他部分,我们将基于之前已经构建好的 JavaScript 和 HTML 代码来进行开发。
|
||||
|
||||
相较于 Emscripten 之前同时生成 JavaScript 胶水文件和 HTML 文件的方式,这种仅生成 Wasm 模块文件的方式,我们通常将其称为 “Standalone 模式”。对应的编译命令如下所示:
|
||||
|
||||
emcc dip.cc -s WASM=1 -O3 --no-entry -o dip.wasm
|
||||
|
||||
|
||||
相比于之前的编译命令,这里我们做了两个更改。首先,我们将 “-o” 参数所指定的输出文件格式由原来 “.html” 变更为 “.wasm”。这样,我们可以告诉 Emscripten 我们希望以 “Standalone” 的方式来编译输入的 C/C++ 源码。“–no-entry” 参数告诉编译器,我们的 Wasm 模块没有声明 “main” 函数,因此不需要与 CRT(C Runtime Library)相关的功能进行交互。
|
||||
|
||||
在上述命令行执行完毕后,我们将会得到一个名为 “dip.wasm” 的 Wasm 二进制模块文件。
|
||||
|
||||
整合上下文资源
|
||||
|
||||
至此,我们便可以将这个通过 Emscripten 编译得到的名为 “dip.wasm” 的 Wasm 模块文件,整合到现阶段项目的 JavaScript 代码中。这里我们将使用 “WebAssembly.instantiate” 的方式来加载这个模块文件。对应的代码如下所示:
|
||||
|
||||
let bytes = await (await fetch('./dip.wasm')).arrayBuffer();
|
||||
let { instance, module } = await WebAssembly.instantiate(bytes);
|
||||
let {
|
||||
cppConvFilter,
|
||||
cppGetkernelPtr,
|
||||
cppGetDataPtr,
|
||||
memory } = instance.exports;
|
||||
|
||||
|
||||
可以看到,通过 fetch 方法返回的 Respose 对象上的 arrayBuffer 函数,会将请求返回的内容解析为对应的 ArrayBuffer 形式。而这个 ArrayBuffer ,随后便会作为 WebAssembly.instantiate 方法的实际调用参数。
|
||||
|
||||
函数返回的 Promise 对象在被 resolve 之后,我们可以得到对应的 WebAssembly.Instance 实例对象和 WebAssembly.Module 模块对象(这里分别对应到名为 instance 和 module 的属性上)。然后在名为 instance 的变量中,我们便可以获得从 Wasm 模块导出的所有方法。
|
||||
|
||||
眼尖的你一定发现了,上面的代码除了从 instance.exports 对象中导出了定义在 Wasm 模块内的函数以外,还有另一个名为 memory 的对象。这个 memory 对象便代表着模块实例所使用到的线性内存段。线性内存段在 JavaScript 中的表示形式,也就是我们上文中提到的,是一个 ArrayBuffer 对象。
|
||||
|
||||
当然,这里 memory 实际上是一个名为 WebAssembly.Memory 的包装类对象,而该对象上的 “buffer” 属性中,便实际存放着对应模块线性内存的 ArrayBuffer 对象。
|
||||
|
||||
下面,我们便可以通过调用相应的方法来完成 Wasm 滤镜函数与 Web 应用的整合。
|
||||
|
||||
首先,我们需要将在 JavaScript 代码中获得到的卷积核矩阵数据,以及每一帧所对应的画面像素数据,填充到我们之前在 C/C++ 代码中定义的相应数组中。为了完成这一步,我们需要首先调用从模块实例中导出的 “cppGetDataPtr” 和 “cppGetkernelPtr” 两个方法,来分别获得这两个数组的首地址,也就是在模块实例线性内存段中的具体偏移位置。
|
||||
|
||||
然后,我们将使用 “Uint8Array” 与 “Int8Array” 这两个 TypedArray 类型来作为模块线性内存的操作视图,并向其中写入数据。
|
||||
|
||||
待数据填充完毕后,我们便可以调用从模块中导出的 “cppConvFilter” 方法来为原始的像素数据添加滤镜。
|
||||
|
||||
待方法调用完毕后,我们将通过 TypedArray 的 subarray 方法来返回一个,包含有已处理完毕像素数据的新的 TypedArray,这些数据随后将会通过名为 CanvasRenderingContext2D.putImageData() 的 API 被重新绘制在 <canvas> 对象上,以实现画面的更新。
|
||||
|
||||
这部分功能对应的代码如下所示:
|
||||
|
||||
// 获取 C/C++ 中存有卷积核矩阵和帧像素数据的数组,在 Wasm 线性内存段中的偏移位置;
|
||||
const dataOffset = cppGetDataPtr();
|
||||
const kernOffset = cppGetkernelPtr();
|
||||
// 扁平化卷积核的二维数组到一位数组,以方便数据的填充;
|
||||
const flatKernel = kernel.reduce((acc, cur) => acc.concat(cur), []);
|
||||
// 为 Wasm 模块的线性内存段设置两个用于进行数据操作的视图,分别对应卷积核矩阵和帧像素数据;
|
||||
let Uint8View = new Uint8Array(memory.buffer);
|
||||
let Int8View = new Int8Array(memory.buffer);
|
||||
// 填充卷积核矩阵数据;
|
||||
Int8View.set(flatKernel, kernOffset);
|
||||
// 封装的 Wasm 滤镜处理函数;
|
||||
function filterWASM (pixelData, width, height) {
|
||||
const arLen = pixelData.length;
|
||||
// 填充当前帧画面的像素数据;
|
||||
Uint8View.set(pixelData, dataOffset);
|
||||
// 调用滤镜处理函数;
|
||||
cppConvFilter(width, height, 4);
|
||||
// 返回经过处理的数据;
|
||||
return Uint8View.subarray(dataOffset, dataOffset + arLen);
|
||||
}
|
||||
|
||||
|
||||
这里需要注意的是,我们之前在 JavaScript 中使用的卷积核矩阵数组,实际上是以二维数组的形式存在的。而为了能够方便地将这部分数据填充到 Wasm 线性内存中,这里我们将其扁平化成了一维数组,并存放到变量 flatKernel 中。
|
||||
|
||||
另外,我们仅将那些在视频播放过程中可能会发生变化的部分(这里主要是指每一帧需要填充到 Wasm 模块实例线性内存的像素数据),都单独整和到了名为 filterWasm 的函数中,这样在动画的播放过程中,可以减少不必要的数据传递过程。
|
||||
|
||||
性能对比
|
||||
|
||||
最后我们选择了如下两款市面上最为常见的浏览器,来分别测量我们构建的这个 DIP Web 应用在 JavaScript 滤镜和 Wasm 滤镜这两个选项下的视频播放帧率。
|
||||
|
||||
|
||||
Chrome Version 84.0.4147.89 (Official Build) (64-bit)
|
||||
Firefox Version 79.0
|
||||
|
||||
|
||||
实际测试结果的截图如下所示。其中左侧为 JavaScript 版本滤镜函数,右侧为对应的 Wasm 版本滤镜函数。
|
||||
|
||||
首先是 Chrome:
|
||||
|
||||
|
||||
|
||||
然后是 Firefox:
|
||||
|
||||
|
||||
|
||||
可以看到,同样逻辑的滤镜函数,在对应的 JavaScript 实现版本和 Wasm 实现版本下有着极大的性能差异。Wasm 版本函数的帧画面实时处理效率几乎是对应 JavaScript 版本函数的一倍之多。当然,上述的性能对比结果仅供参考,应用的实际表现是一个综合性的结果,与浏览器版本、代码实现细节、编译器版本甚至操作系统版本都有着密切的关系。
|
||||
|
||||
如果再通过 Chrome 的 Performance 工具来查看,jsConvFilter 与 cppConvFilter 这两个分别对应的 JavaScript 滤镜实现与 Wasm 滤镜实现函数的运行耗时,你可以发现如下所示的结果:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
可以看到,JavaScript 滤镜函数实现的运行耗时是 Wasm 版本的将近 3 倍。但由于 getImageData 函数在应用实际运行时也会占用一部分时间,因此使得在每一帧画面的刷新和滤镜渲染过程中,整个 Wasm 滤镜处理过程的耗时只能被优化到对应 JavaScript 版本的一半时间左右。同样的,Wasm 实现下通过 Uint8View.set 向 Wasm 实例线性内存段中填充像素数据的过程也同样会占用一定的额外耗时,但这部分的比例相对很小。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
通过完整的三节课,我们讲解了如何从第一行代码开始着手编写一个完整的 Wasm Web 应用。在构建应用的过程中,我们知道了如何使用 Emscripten 来直接编译输入的 C/C++ 代码到一个完整的、可以直接运行的 Web 应用;或者是基于 “Standalone 模式”来仅仅输出源代码对应的 Wasm 二进制模块文件。
|
||||
|
||||
不仅如此,我们还知道了 Emscripten 在被作为工具链使用时,它还为我们提供了诸如 EMSCRIPTEN_KEEPALIVE 等宏函数,以支持编译过程的正常进行。Emscripten 为我们提供了极其强大的宏函数支持以及对 Web API 的无缝整合。
|
||||
|
||||
甚至你可以直接将基于 OpenGL 编写的 C/C++ 应用编译成 Wasm Web 应用,而无需做任何源代码上的修改。Emscripten 会通过相应的 JavaScript 胶水代码来处理好 OpenGL 与 WebGL 的调用映射关系,让你真正地做到“无痛迁移”。
|
||||
|
||||
在编译完成后,我们还学习了如何通过 Web API 和 JavaScript API 来加载并实例化一个 Wasm 模块对象。WebAssembly.instantiate 与 WebAssembly.instantiateStreaming 这两个主要用来实例化 Wasm 对象的 Web API 在模块实例化效率上的不同。基于“流式编译”的特性,后者往往通常会有着更高的模块实例化性能。
|
||||
|
||||
最后,你应该知道了如何通过 TypedArray 向 Wasm 模块实例的线性内存段中填充数据,以及如何从中读取数据。在本文这个实例中,我们分别使用了 set 与 subarray 这两个 TypedArray 对象上的方法来分别达到这两个目的。
|
||||
|
||||
通过本次实践,我们在最后的性能对比中,也清楚地看到了 Wasm 所带来的 Web 应用的性能提升。希望你也能够通过这次实践,亲身体会到 Wasm 在不久的将来,所能够带给 Web 应用的一系列变革。
|
||||
|
||||
课后练习
|
||||
|
||||
最后,我们来做一个小练习吧。
|
||||
|
||||
你可以尝试在其他的,诸如 Edge 和 Safari 浏览器中运行这个 Wasm Web 应用, 并查看在这些浏览器中,当分别使用 JavaScript 版滤镜函数和 Wasm 滤镜函数时的画面实时处理帧率。
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
149
专栏/WebAssembly入门课/18如何进行Wasm应用的调试与分析?.md
Normal file
149
专栏/WebAssembly入门课/18如何进行Wasm应用的调试与分析?.md
Normal file
@ -0,0 +1,149 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 如何进行 Wasm 应用的调试与分析?
|
||||
你好,我是于航。
|
||||
|
||||
所有正在应用 Wasm 这门技术的开发者,都会被频繁问到这样一个问题 —— “如何能够以最正确的方式来对一个 Wasm 模块(应用)进行调试?”
|
||||
|
||||
实际上,针对 Wasm 模块的调试方案与相应的工具链,暂时还没有统一的“事实标准”。而又由于 Wasm 本身可以被应用在诸如 Web 与 out-of-web 等多种场景中,这便使得对 Wasm 模块或应用的调试过程,变得十分复杂。
|
||||
|
||||
在本节课里我将为你总结现阶段所能够使用的一些,针对于独立 Wasm 模块或者说 Wasm 应用的调试方案。这些方案本身并没有优劣之分,你可以根据自己的实际情况和应用场景来挑选合适的 Wasm 调试方式。
|
||||
|
||||
这些方案将会基于不同的工具实现来展开,而关于工具本身的安装过程,你可以参考我在这节课里给出的相关链接。
|
||||
|
||||
总的来说,我们可以将这些方案划分为 Web 与 out-of-web 两种场景。前者对应于运行在 Web 浏览器中的 Wasm 应用,这些应用仅使用到了 Wasm 核心标准中的特性;而后者则对应于运行在如 Wasmtime 等 Wasm 运行时中的 Wasm 应用,这部分应用还将会使用到除 Wasm 核心标准之外的 WASI 抽象操作系统接口标准。
|
||||
|
||||
编译时调试
|
||||
|
||||
作为开发 Wasm 应用的一个必不可少的流程,“编译”是一个无论如何也要跨过去的“槛”。但总是有着各种各样的原因,导致你的应用在编译阶段会出现问题。所以我们先来看看如何调试应用在编译期发生的错误。
|
||||
|
||||
Emscripten
|
||||
|
||||
Emscripten 作为构建可运行于 Web 浏览器上的 Wasm 应用的首选编译工具之一,它为我们提供了众多的调试选项,可以在编译过程中输出详细的调试信息以供排错之用。
|
||||
|
||||
EMCC_DEBUG
|
||||
|
||||
以我们上节课从零构建的 Wasm DIP 应用为例,在实际使用 emcc(Emscripten 提供的编译器)编译该项目时,我们可以通过为编译命令添加 “EMCC_DEBUG” 环境变量的方式,来让 emcc 以“调试模式”的方式来编译我们的项目,修改后的编译命令如下所示。
|
||||
|
||||
注:此命令行形式仅适用于 Linux / MacOS 系统,对于 Windows 则会有所区别。你可以参考 Emscripten 官方文档来查看相关细节。
|
||||
|
||||
EMCC_DEBUG=1 emcc dip.cc
|
||||
-s WASM=1
|
||||
-O3
|
||||
--no-entry
|
||||
-o dip.wasm
|
||||
|
||||
|
||||
这里命令行中设置的环境变量 “EMCC_DEBUG” 支持三个值:0、1 与 2。其中 “0” 表示关闭调试模式,也就是默认不加该环境变量时的情况;“1” 表示输出编译时的调试性信息,同时生成包含有编译器各个阶段运行信息的中间文件。这些输出信息和文件可用于对整个 emcc 编译流程的各个步骤进行调试。以下为 emcc 的编译输出信息及所生成中间文件的截图。
|
||||
|
||||
|
||||
|
||||
在编译时输出的调试性信息中,包含有 emcc 在实际编译源代码时其各个编译阶段所实际调用的命令行信息(命令+参数)。比如在编译阶段调用的 clang++、链接阶段调用的 wasm-ld,甚至在优化阶段还会调用的 node 等等。
|
||||
|
||||
通过这些输出的详细命令行参数,你就能够知道 emcc 在对源代码的实际编译过程中,使用了哪些编译器参数,以及哪些缺少或错误添加的参数会影响源代码的编译流程。通过这种方式,能够在一定程度上辅助你找到项目编译失败的“根源”。
|
||||
|
||||
|
||||
|
||||
而当为 “EMCC_DEBUG” 设置的值为 “2” 时,emcc 会生成更多的包含有中间调试性信息的文件,在这些文件中将包含有与 JavaScript 优化器相关的编译时信息。
|
||||
|
||||
-s [DEBUGGER_FLAG=VALUE]
|
||||
|
||||
除了我们上述介绍的 “EMCC_DEBUG” 之外,emcc 还有很多针对特定场景的编译器调试选项可以使用。而这些选项都需要以 “emcc -s [DEBUGGER_FLAG=VALUE]” 的方式,来将其应用到编译命令行中。
|
||||
|
||||
比如 “ASSERTIONS” 选项。该选项可用于启用 emcc 对常见内存分配错误的运行时断言检查。其值可以被设置为 “0”,“1” 或 “2”。其中,“0” 表示禁用该选项,另外的 “1” 和 “2” 随着数字的逐渐增大,表示所启用相关测试集的增多。
|
||||
|
||||
类似的,还有其他如 “SAFE_HEAP” 等编译器调试选项可以使用。而关于这些可以在 emcc 中使用的调试器选项信息,你可以参考这里进行了解。
|
||||
|
||||
运行时调试
|
||||
|
||||
相较于“编译时调试”,“运行时调试”意味着我们已经成功地编译了 Wasm 应用,但是却在实际运行时发生了错误。那本节我们来看看,如何调试那些在运行时发生异常的 Wasm 应用。
|
||||
|
||||
Emscripten
|
||||
|
||||
为了能够调试运行在 Web 浏览器中的 Wasm 应用,我们需要在通过 Emscripten 编译应用时,为编译命令指定特殊的“调试参数”,以保留这些与调试相关的信息。而这个参数就是 “-g”。
|
||||
|
||||
“-g” 参数控制了 emcc 的编译调试等级,每一个调试等级都会保留更多的相应调试性信息。整个等级体系被分为 0-4 五个级别。在其中 “-g4” 级别会保留最多的调试性信息。
|
||||
|
||||
不仅如此,在 “-g4” 这个级别下,emcc 还会为我们生成可用于在 Web 浏览器中进行“源码级”调试的特殊 DWARF 信息。通过这些特殊格式的信息,我们便可以直接在 Web 浏览器中对 Wasm 模块编译之前的源代码进行诸如“设置断点”、“单步跟踪”等调试手段。如下所示,假设此时我们使用该参数重新编译上一节课中的 DIP Web 应用。
|
||||
|
||||
emcc dip.cc
|
||||
-g4
|
||||
-s WASM=1
|
||||
-O3
|
||||
--no-entry
|
||||
-o dip.wasm
|
||||
|
||||
|
||||
可以看到,这里在命令行中,我们使用了参数 “-g4”。编译完成后,我们便可以使用浏览器来加载这个 Web 应用。在此同时并打开“开发者面板”,来尝试直接通过“操作” C/C++ 源代码的方式,来为应用所使用的 Wasm 模块设置断点。具体你可以参考下面这张图(这里我们使用的是 Chrome 浏览器)。
|
||||
|
||||
|
||||
|
||||
通过这种方式,我们可以方便地在 Wasm Web 应用的实际运行过程中,来调试那些发生在 Wasm 模块内部(C/C++)的“源码级”错误。
|
||||
|
||||
但目前这项调试功能还不是十分完善。我们仅能够在 Web 浏览器中为 C/C++ 等源代码设置断点、进行单步跟踪调试,或者是查看当前的调用栈信息。而比如“查看源代码中的变量值和类型信息”、“跟踪观察变量或表达式的值变化”等更加实用的功能,暂时还无法使用。
|
||||
|
||||
对于使用 Rust 语言编写的 Wasm 模块来说,我们可以通过类似地为 rustc 添加 “-g” 参数的方式,来让编译器将 DWARF 调试信息加入到生成的 Wasm 模块中。而对于直接使用 cargo 编译的 Wasm 项目来说,调试信息将会自动被默认加入到生成的模块中。
|
||||
|
||||
Wasmtime
|
||||
|
||||
对于 out-of-web 领域中的 Wasm 应用,根据第 07 讲中的介绍,我们知道借助于 WASI,这些应用可以在保证良好可移植性的情况下,进一步与各类操作系统资源打交道。而为了能够在 Web 浏览器之外的环境中执行 Wasm 模块中的字节码,则需要诸如 Wasmtime、Lucet 等各类 Wasm 运行时的支持。
|
||||
|
||||
对比于在 Native 环境中直接编译而成的可执行文件来说,这些基于 WASI 构建的 Wasm 模块可以具有与这些原生可执行程序同等的能力,前提是只要 WASI 标准中支持相应的操作系统调用抽象接口即可。
|
||||
|
||||
能力虽然可以相同,但两者的运行时环境却完全不同。对于原生可执行程序来说,它们的实际执行过程会交由操作系统来统一负责。而对于 Wasm 模块来说,无论是运行在 Web 平台之上,还是应用于 out-of-web 领域中的 Wasm 字节码,它们都需要通过 Wasm 运行时(引擎)来提供字节码的实际执行能力。这也就造成了两者在调试过程和方法上的区别。
|
||||
|
||||
为了能够尽量使两者的调试方式保持一致,Wasmtime(一个 Wasm 运行时)便为我们提供了这样的一种能力,让我们可以使用诸如 LLDB 与 GDB 等专用于原生可执行程序的调试工具,来直接调试 Wasm 的二进制模块文件。不过需要注意的是,为了能够确保这个特性具有最大的可用性,我们需要使用最新版的 LLDB、GDB 以及 Wasmtime。
|
||||
|
||||
在此基础之上,我们便可以仅通过如下这行命令,来在 LLDB 中调试我们的 Wasm 字节码(假设这里我们要调试的 Wasm 模块文件名为 “app.wasm”)。
|
||||
|
||||
lldb -- wasmtime -g app.wasm
|
||||
|
||||
|
||||
当然,现实的情况是,如果想要使用这种针对 Wasm 字节码的 out-of-web 调试方式,你需要重新编译整个 LLDB 或 GDB 调试工具链,并确保本机的 Wasmtime 已经被更新到最近的版本。其中,前者要花费不少的精力,而后者还没有发布正式的版本。因此这种调试方式所能够支持的调试功能仍有着一定的限制。更多的信息你可以参考这里的链接。
|
||||
|
||||
|
||||
|
||||
其他调试工具
|
||||
|
||||
对于其他的 Wasm 相关调试工具,这里主要推荐你使用 “WABT” 。关于这个工具集,我在文章 06 中曾提到过。WABT 内置了众多可以直接对 Wasm 字节码或者 WAT 可读文本代码进行转换和分析的工具。比如用于将 WAT 格式转换为 Wasm 字节码的工具 “wat2wasm”、用于将 WAT 转换为 Flat-WAT 的工具 “wat-desugar” 等等。
|
||||
|
||||
除此之外,还有一些可以针对 Wasm 字节码进行“反编译”的工具,比如 “wasm-decompile” 工具可以将 Wasm 字节码反向转换为“类 C 语法格式” 的可读代码。其可读性相较于 WAT 来说可以说是又更近了一步。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在今天这节课中,我们主要讲解了有关 Wasm 应用调试的一些现阶段可用的方案。Wasm 应用的构建和使用主要被分为“编译”与“运行”两个阶段,而相应的调试方案便也需要针对这两个阶段分别进行处理。
|
||||
|
||||
对于“编译”阶段,我们主要介绍了在通过 Emscripten 构建 Wasm 应用时,可以在编译命令行中使用的一些调试参数。其中,“EMCC_DEBUG” 参数可以让 emcc 在编译过程中输出更多的信息以用于支持应用的编译时调试。相应地,通过 “-s” 参数,我们也可以为 emcc 指定一些针对某些特定场景的调试参数,这些参数可以让 emcc 在编译过程中检查某些特定问题。
|
||||
|
||||
对于“运行”阶段,我们首先介绍了如何通过为 Emscripten 的编译命令添加 “-g” 参数,来让我们可以直接在 Web 浏览器中针对 Wasm 模块的编译前源代码进行调试。但就目前而言,我们能够在 Web 浏览器中获得的调试性信息,还不足以让我们可以直接高效地在浏览器中解决相应的运行时问题。
|
||||
|
||||
最后,我们还介绍了如何在 out-of-web 环境中调试 Wasm 字节码。这里我们依赖于 Wasmtime 所提供的支持,使得我们可以直接在诸如 LLDB、GDB 等传统调试器中调试 Wasm 字节码对应的编译前源代码。但一个重要的前提是,你需要事先安装这些调试器与 Wasmtime 的最新版本,这在某种程度上来说,可能对你也是一种负担。
|
||||
|
||||
总的来说,就现阶段的 Wasm 应用调试而言,无论是在 Web 平台上,还是 out-of-web 环境中,都没有一个成熟、稳定、可靠的“一站式”调试解决方案。但好在 Wasm CG 旗下的 “WebAssembly Debugging Subgroup” 正在努力解决这个问题。相信在不久的将来,针对 Wasm 应用的调试不会再成为一个令开发者“望而生畏”的难题。
|
||||
|
||||
更新(2020-12-11):
|
||||
|
||||
在今年的 Chrome 2020 开发者峰会上,Chrome 团队推出了一款新的 Chrome 扩展,可以帮助我们增强浏览器上的 Wasm 应用调试体验。相较于之前在浏览器中直接调试 Wasm 模块对应 C/C++ 源代码的体验,在这款扩展的帮助下,我们还可以做到诸如:查看原始 C/C++ 源代码中变量的值、对 C/C++ 源代码进行单步跟踪,甚至直接跟踪观察某个变量值的变化等等。下图所示为在借助这款插件后,对同一个项目的调试体验差异。
|
||||
|
||||
|
||||
|
||||
关于这款插件的具体使用方式和更多信息,你可以点击“这里”进行查看。
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
现阶段针对 Wasm 的调试过程虽然没有成熟的“一站式”解决方案,但各种小的调试技巧和方法却层出不穷。那么你在日常的开发工作中是怎样调试这些 Wasm 应用的呢?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
157
专栏/WebAssembly入门课/19如何应用WASI及其相关生态?.md
Normal file
157
专栏/WebAssembly入门课/19如何应用WASI及其相关生态?.md
Normal file
@ -0,0 +1,157 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 如何应用 WASI 及其相关生态?
|
||||
你好,我是于航。
|
||||
|
||||
作为“实践篇”的最后一课,今天我们来一起看看“如何从零构建一个 WASI 应用?”。在实践篇的前三节课里,我花了大量的篇幅来介绍如何基于 Emscripten ,去构建一个可以运行在 Web 浏览器中的 Wasm 应用。而基于 WASI 构建的 Wasm 应用将会被运行在浏览器之外的 Native 环境中,因此其构建和使用方式与基于 Emscripten 的 Wasm 应用有所不同。
|
||||
|
||||
但也正如我们在第 07 讲中介绍的那样,WASI 本身作为一种抽象的操作系统调用接口,对上层的应用开发者来说,没有较为直接的影响。
|
||||
|
||||
甚至对于同样的一段可以被编译为本地可执行应用程序的代码来说,我们只需要适当调整编译器的相关设置,就可以在不做任何代码更改的情况下,编译出所对应的 WASI 版本代码(也就是 Wasm 字节码)。然后再配合相应的 Wasm 虚拟机,我们就能够以“另一种方式”来执行这些代码了。
|
||||
|
||||
总的来说你可以看到,相较于传统的可执行文件,WASI 应用程序的整个“生命周期”基本上只有“编译”与“运行”两个阶段会有所不同。在接下来的内容中,我们将以一段 C/C++ 代码入手,来从编码、编译,再到运行,一步步带你完成这个 WASI 应用。
|
||||
|
||||
编码
|
||||
|
||||
首先,我们先来编写应用对应的 C/C++ 代码,这部分内容如下所示。
|
||||
|
||||
// wasi-app.c
|
||||
#include <stdio.h>
|
||||
#define BUF_SIZE 1024
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
size_t counter = 0;
|
||||
char buf[BUF_SIZE];
|
||||
int c;
|
||||
while ((c = getchar()) != '\n') {
|
||||
buf[counter++] = c;
|
||||
}
|
||||
if (counter > 0) {
|
||||
printf("The input content is: %s\n", buf);
|
||||
// write content to local file.
|
||||
FILE* fd;
|
||||
if ((fd = fopen("wasi-static.txt", "w"))) {
|
||||
fwrite(buf, sizeof(char), counter, fd);
|
||||
} else {
|
||||
perror("Open static file failed!");
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
这段代码所对应的功能是这样的:程序在实际运行时,会首先要求用户输入一些随机的文本字符,而当用户输入“回车键(\n)”后,之前输入的所有内容都将会被回显到命令行中。
|
||||
|
||||
除此之外,这些输入的文本字符也会被同时保存到当前目录下名为 “wasi-static.txt” 的文本文件中。而无论在程序运行时该文本文件是否存在,应用都将会重新创建或清空该文件,并写入新的内容。
|
||||
|
||||
这里为了完成上面的功能,在代码中我们使用了诸如 “fopen” 与 “fwrite” 等用于操作系统文件资源的 C 标准库函数。这些函数在被编译至 Native 可执行文件后,会通过间接调用“操作系统调用”的方式,来实现与文件等系统资源的实际交互过程。
|
||||
|
||||
Native 可执行程序
|
||||
|
||||
接下来,我们尝试将上述这段代码编译为本地可执行文件,并尝试运行这个程序以观察应用的实际运行效果。对应的编译和运行命令如下所示:
|
||||
|
||||
clang wasi-app.c -o wasi-app && ./wasi-app
|
||||
|
||||
|
||||
在上述命令执行完毕后,我们可以按照下图所示的方式来与这个应用程序进行交互。
|
||||
|
||||
|
||||
|
||||
可以看到,应用的实际运行效果与我们之前所描述的保持一致。
|
||||
|
||||
接下来,我们将尝试把上面这段 C/C++ 代码编译成对应的 Wasm 字节码,并使用 Wasmtime 来执行它。而为了完成这个工作,我们首先需要了解整个编译链路的基本情况。
|
||||
|
||||
交叉编译
|
||||
|
||||
我们曾在第 13 讲中介绍过,LLVM 工具链已经具备可以将 LLVM-IR 编译为 Wasm 字节码的编译器后端能力。因此,基于 LLVM 构建的编译器 Clang,便也可以同样享受这个能力。
|
||||
|
||||
那么按照这样的思路,我们是否可以直接使用 Clang 来编译 C/C++ 代码到 Wasm 字节码呢?事实上确实是可以的。而这里我们需要做的就是借助 Clang 来进行针对 WASI 的“交叉编译”。
|
||||
|
||||
那为什么说是“交叉编译(Cross-Compilation)”呢?你可以按照这样的方式来简单理解:其实我们说的无论是 Wasm32 还是 Wasm64,它们都是指一种“指令集架构”,也就是 “(V)ISA”。而 ISA 本身只是规定了与指令相关的一些信息,比如:有哪些指令?指令的用法和作用?以及这些指令对应的 OpCode 编码是什么?等等。
|
||||
|
||||
但回到 “WASI”。它是一种基于 Wasm 指令集的“平台类型”。所谓“平台”,你可以用诸如 Linux、Windows 等各种不同的操作系统类型来进行类比。WASI 指定了一种自己独有的操作系统接口使用方式,那就如同 Linux 与 Windows 都有其各自不同的操作系统调用号一样。这将会影响着我们的 C/C++ 代码应该如何与对应平台的不同操作系统调用进行编译整合。
|
||||
|
||||
当然,这种类比方式并不严谨,但对于帮助我们理解上面的问题是完全足够的。
|
||||
|
||||
基于 Clang 的编译管道
|
||||
|
||||
既然我们想要借助 Clang 来进行针对 WASI 的交叉编译,那么在开始真正动手编译之前,我们需要准备哪些其他必要的组件呢?通常在 Clang 中,一个大致的交叉编译流程如下图所示。
|
||||
|
||||
|
||||
|
||||
可以看到,其实同正常的编译流程十分类似,输入到编译器的 C/C++ 源代码会通过适用于对应目标平台的头文件,来引用诸如 “C 标准库” 中的函数。
|
||||
|
||||
而在编译链路中,应用本身对应的对象文件将会与标准库对应的动态或静态库文件再进行链接,以提取所引用函数的实际定义部分。最后,再根据所指定的平台类型,将编译输出的内容转换为对应的平台代码格式。
|
||||
|
||||
在上面的流程中,输入到编译链路的源代码,以及针对 WASI 平台适用的标准库头文件、静态库以及动态库则将需要由我们自行提供。在 Clang 中,我们将通过 “–sysroot” 参数来指定这些标准库相关文件的所在位置;参数 “–target” 则负责指定交叉编译的目标平台。
|
||||
|
||||
接下来,我们将通过 WASI SDK 的帮助来简化上述流程。
|
||||
|
||||
WASI SDK
|
||||
|
||||
顾名思义,“WASI SDK” 是一套能够帮助我们简化 WASI 交叉编译的“开发工具集”。但与其说它是开发工具集,不如说它为我们整合了用于支持 WASI 交叉编译的一切文件和工具资源,其中包括:基于 “wasi-libc” 编译构建的适用于 WASI 平台的 C 标准库、可用于支持 WASI 交叉编译的最新版 Clang 编译器,以及其他的相关必要配置信息等等。
|
||||
|
||||
它的安装过程十分简单,只需要将其下载到本地,然后解压缩即可,详情你可以参考这里。假设此时我们已经将 WASI SDK 下载到当前目录,并得到了解压缩后的文件夹(wasi-sdk-11.0)。
|
||||
|
||||
下面我们将正式开始进入编译流程。首先我们来看看对应的交叉编译命令是怎样的。
|
||||
|
||||
./wasi-sdk-11.0/bin/clang \
|
||||
--target=wasm32-wasi \
|
||||
--sysroot=./wasi-sdk-11.0/share/wasi-sysroot \
|
||||
wasi-app.c -o wasi-app.wasm
|
||||
|
||||
|
||||
你可以参考上面这行命令。同我们之前所介绍的一样,这里我们直接使用了由 WASI SDK 提供的 Clang 编译器来进行这次交叉编译。然后我们使用了 “–sysroot” 参数来指定适用于 WASI 的标准库相关文件其所在目录。这里可以看到,我们通过参数 “–target” 所指定的平台类型 “wasm32-wasi” 便是 LLVM 所支持的、针对于 WASI 的平台编译类型。
|
||||
|
||||
编译完成后,我们便可以得到一个 Wasm 文件 “wasi-app.wasm”。最后,我们将使用 Wasmtime 来运行这个 Wasm 模块。如果一切顺利,我们可以看到同 Native 可执行程序一样的运行结果。(关于 Wasmtime 的安装过程可以参考这里)
|
||||
|
||||
Wasmtime
|
||||
|
||||
按照正常的思路,我们可能会通过下面的方式来尝试运行这个 Wasm 文件。
|
||||
|
||||
wasmtime wasi-app.wasm
|
||||
|
||||
|
||||
而当命令实际执行时,你会发现 Wasmtime 却给出了我们这样的一条错误提示:“Capabilities insufficient”,这便是 “Capability-based Security” 在 WASI 身上的体现。
|
||||
|
||||
Wasmtime 在实际执行 “wasi-app.wasm” 文件中的字节码时,发现这个 WASI 应用使用到了文件操作相关的操作系统接口,而对于一个普通的 WASI 应用来说,这些接口在正常情况下是无法被直接使用的。换句话说,默认情况下的 WASI 应用是不具备“文件操作”相关的 Capability 的。这些 Capability 需要我们在实际运行应用时主动“授予”给应用,方式如下所示。
|
||||
|
||||
wasmtime wasi-app.wasm --dir=.
|
||||
|
||||
|
||||
这里我们在通过 Wasmtime 运行 WASI 应用时,为其指定了一个额外的 “–dir=.” 参数。通过该参数,Wasmtime 可以将其所指定的文件路径(.)“映射”到 WASI 应用中,以供其使用。
|
||||
|
||||
这样,我们便可以使用 Wasmtime 来成功运行这个 WASI 应用了,如下图所示。
|
||||
|
||||
|
||||
|
||||
当然,对于其他的支持 WASI 的 Wasm 运行时来说,它们也会以类似的方式来实现 Capability-based Security 这一 WASI 最为重要的安全模型。而这一模型也是 WASI+Wasm 能够在一定程度上“取代” Docker 进行应用沙盒化的基础。
|
||||
|
||||
总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在本节课中,我们主要讲解了如何从零构建一个 WASI 应用。
|
||||
|
||||
我们要知道的是,构建 WASI 应用的过程,其实是一个“交叉编译”的过程。我们需要在各自的宿主机器(Linux、MacOS、Windows 等等)上构建“以 Wasm 字节码为 ISA 架构,WASI 作为平台类型”的这样一种应用。而应用的实际运行将交由支持 WASI 的 Wasm 运行时来负责。
|
||||
|
||||
目前,我们可以直接借助 “WASI SDK” 来简化整个交叉编译的实施难度。WASI SDK 为我们整合了在编译 WASI 应用时需要使用的所有工具与组件。其中包含有可以支持 “wasm32-wasi” 这一平台类型的 Clang 编译器、WASI 适用的相关标准库头文件与库文件等等。
|
||||
|
||||
在执行 WASI 应用时,我们也需要注意 WASI 本身所基于的 “Capability-based Security” 这一安全模型。这意味着,在实际执行 WASI 应用时,我们需要主动“告知”运行时引擎当前 WASI 应用所需要使用到的 Capability。否则,即使当前用户拥有对某一个系统资源的访问权限,但也无法通过 Wasm 运行时来隐式地让所执行的 WASI 应用访问这些资源。
|
||||
|
||||
而这便是 Capability-based Security 模型与 Protection Ring 模型两者之间的区别。
|
||||
|
||||
课后思考
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
你可以尝试使用另外名为 “Lucet” 的 Wasm 运行时来运行上述这个 WASI 应用,来看看 Lucet 会以怎样的“交互方式”来支持 Capability-based Security 这一安全模型呢?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
83
专栏/WebAssembly入门课/20总结与答疑.md
Normal file
83
专栏/WebAssembly入门课/20总结与答疑.md
Normal file
@ -0,0 +1,83 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 总结与答疑
|
||||
你好,我是于航。
|
||||
|
||||
在这节课里,我将和你总结一下自开课这段时间以来,各位同学在各个渠道提出的有关 Wasm 的一些问题。限于篇幅,这里我优先选择了 8 个相对比较有代表性的问题,来给你进行解答。对于其中一些已经回复过的问题,我也会给你做进一步的解析。如果你有其他的问题,也欢迎在评论区和我进一步交流。
|
||||
|
||||
问题1:Wasm 就像 Node.js 源于 Web 但又不止于 Web 吗?
|
||||
|
||||
两者并不一样。对于 Node.js 本身来说,它只是一个可以在 Web 浏览器之外运行 JavaScript 代码的 Runtime 运行时,同时它还提供了一些特殊的 API 可以让我们使用 JavaScript 代码来与操作系统相关的资源打交道,比如文件资源、网络资源等等。因此,我们说 Node.js 是一种实现。
|
||||
|
||||
而反观 Wasm,正如我们在第 03 讲中介绍的那样,它是一种新的 V-ISA 标准,而非实现。如果实在想要去类比的话,你可以将 Wasm 类比为 JavaScript 的所在位置(编程语言),但实际上 Wasm 更加底层,类比到 Java 字节码可能会更加恰当。
|
||||
|
||||
因此总结来看,Node.js 为在 Web 浏览器之外执行 JavaScript 代码提供了可能,而 Wasmtime 等虚拟机为在 Web 浏览器之外执行 Wasm 字节码提供了可能。但 Wasm 本身一般被作为高级语言的编译目标,其标准更加贴近底层(汇编),与 JavaScript(高级语言)并不在一个层面上。
|
||||
|
||||
问题2:Wasm 能够与系统底层进行通信吗?
|
||||
|
||||
Wasm 是标准而非实现。同上一个问题类似,Wasm 本身只是一个新的 V-ISA 标准,而非实现。因此,能否与底层系统进行通信完全取决于用来执行它的 Runtime 运行时环境。
|
||||
|
||||
比如在 Web 浏览器中,我们便无法通过 Wasm 来访问操作系统的底层资源。而在通过诸如 Wasmtime、Lucet 等运行时环境,来在 Web 浏览器之外执行 Wasm 字节码时,便可以在 WASI 标准的基础之上来访问操作系统的相关资源了,这正如我们在第 19 讲中介绍的实例那样。
|
||||
|
||||
而至于访问的到底是不是“系统底层资源”,就要看你如何定义“底层”这个词了。但无论如何,只要 WASI 抽象操作系统接口标准能够覆盖所有操作系统实际提供的接口,那么,我们实际上就拥有了完全的操作系统控制能力。我想这个时候是不是底层资源,就已经不那么 重要了。
|
||||
|
||||
问题3:TypeScript 可以设置参数类型,但是最后 TypeScript 代码也会被编译成 JavaScript,所以 TypeScript 是不是只是规范程序员写代码,对于应用的性能其实没有什么帮助?
|
||||
|
||||
从流程上来看,由于 TypeScript 代码最终会被编译为 JavaScript 代码,因此事实上对应用整体性能的影响可以说是微乎其微(TypeScript 编译器在编译过程可能会进行一些优化)。因此,大部分使用 TypeScript 的场景,在我看来主要还是为了利用其“静态类型检查”的特性,来保障应用在多人团队协作时,其各个组成部分的接口使用能够准确无误,以防止意外的 BUG 产生。
|
||||
|
||||
但从另外一个角度来看,既然 TypeScript 中有着变量“类型”的概念,那是不是也可以将它的代码转换为 Wasm 字节码呢?实际上,一个名为 AssemblyScript 的项目便正在尝试这样的事情。通过这个项目,你可以使用 TypeScript 的语法来编写代码,然后再将这些代码转换为 Wasm 字节码。当然,受限于 TypeScript 本身的语言特性,为了能够支持 Wasm 中如“内存操作”等相关的指令,AssemblyScript 还进行了一些其他的扩展,详情你可以点击这里进行了解。
|
||||
|
||||
|
||||
|
||||
问题4:如果 ES6 等后续 JavaScript 版本解决了浏览器兼容性问题,不再需要“编译”回老版本代码,从而获得一定的性能保障。这是否会成为 Wasm 发展的重大阻碍呢?
|
||||
|
||||
有很多企业都在尝试直接在浏览器中使用 ES6 代码,以提升应用的性能。比如 Twitter 曾在今年八月初宣布其 Web App 将在所有现代浏览器中直接使用 ES6+ 的代码。而这一举动使得其 Web 应用的代码库体积,从原先的 16.6KB 大小下降到了 2.7KB,整整减小 83%。但除开 Twitter 外的其他企业大多都还比较保守,仍然处在观望阶段。
|
||||
|
||||
但无论如何,直接使用 ES6+ 代码所带来的应用性能提升是显著的。比如更小的网络资源开销,更少的需要执行的代码等等。但如果我们换一个角度来看,对浏览器引擎来说,只要执行的是 JavaScript 代码,那就一定少不了生成 AST、Profiling、生成 IR 代码、优化以及去优化等过程。而这些过程才是相较于 Wasm 来说,真正花时间的地方。
|
||||
|
||||
因此,如果我们不考虑“直接使用 ES6+ 代码”这一方案的可实施性,光从现代 JavaScript 语言和 Wasm 两者之间来看,JavaScript 作为一种高级动态语言,其执行性能还是无法跟 Wasm 这类 V-ISA 直接相比的,这个比较过程就如同拿 JavaScript 来与 X86 汇编进行比较。
|
||||
|
||||
当然,你也需要注意的是,性能只是 Wasm 众多发展目标中的一个,并且相对好的性能也是由于其 V-ISA 的本质决定的。除此之外,Wasm 希望能够通过提供一种新的、通用的虚拟字节码格式,来统一各个语言之间的差异,并且借助于 Capability-based Security 安全模型来为现代应用提供更好的安全保护策略。可以说 Wasm 是起源于 Web,但志不仅仅在 Web。
|
||||
|
||||
问题5:感觉 Flat-WAT,比 WAT 看着好懂,Wasm 为啥不直接使用 Flat-WAT?
|
||||
|
||||
这个主要是由于 Wasm 核心团队初期在设计 Wasm 可读文本格式时,对“S-表达式”这种代码表达方式的选择。而为什么会选择“S-表达式”则是出于对以下这样几个因素的考虑:
|
||||
|
||||
|
||||
尽量不自行创建新的格式,而是直接利用现有的、常用的、成熟的格式规范;
|
||||
这种格式可以表达 Wasm 模块的内部结构,并且可以与字节码一一对应;
|
||||
这种格式可以“转换”为方便人们阅读的形式。
|
||||
|
||||
|
||||
因此,出于对这样几个因素的考虑,核心团队便选择了“S-表达式”来作为 Wasm 可读文本 WAT 的一种表达方式。而对于编译器和相关工具来说,这种“S-表达式”可以被现有的很多代码实现直接解析和使用,不需要重新造轮子,进而减轻了 Wasm 早期发展时的难度和负担。
|
||||
|
||||
而同时“S-表达式”也可以被转换为相应的 “Linear Representation” 的形式,也就是 “Flat-WAT” 这种格式。所以这里的因果关系是先有“S-表达式”形式的 WAT,才有其对应的 Flat-WAT。
|
||||
|
||||
问题6:什么时候用 Clang(LLVM) 编译 Wasm?而什么时候又该用 Emscripten 编译 Wasm?
|
||||
|
||||
这个区分其实很简单,需要在 Web 浏览器中运行的 Wasm 应用,一定要使用 Emscripten 来进行编译;而需要在 out-of-web 环境中使用的 Wasm(WASI) 应用,可以使用 Clang 来编译。
|
||||
|
||||
当然,Clang 与 Emscripten 两者在可编译和生成 Wasm 字节码这个能力上,有着一定的重叠。毕竟 Emscripten 就是借助了 LLVM 的后端来生成 Wasm 格式的。但不同的是,Emscripten 会在编译过程中,为所编译代码在 Web 平台上的功能适配性进行一定的调整。比如 OpenGL 到 WebGL 的适配、文件操作到虚拟文件操作的适配等等。
|
||||
|
||||
而使用 Clang 编译 Wasm 应用,不会进行上述这些针对 Web 平台的适配。因此仅在编译 WASI 应用时选择使用 Clang 来进行交叉编译。大多数时候,你的最佳选择仍然是 Emscripten。
|
||||
|
||||
问题7:对于使用 Webpack 的 Web 前端项目,如何优雅地引入第三方的 Wasm 组件?
|
||||
|
||||
就目前来看,大多数的第三方 Wasm 库都是以 JavaScript 函数来作为库的实际使用入口的,而并没有直接暴露出一个或多个 Wasm 模块文件给到用户。因为一个 Wasm 模块在实例化时,可能还需要使用到很多不同的外部“导入性”信息(通过 Import Section)。而这些信息则属于这个库本身组成的一部分,这部分内容不应该全部由用户来提供。
|
||||
|
||||
因此,在实际使用时可以直接通过 “import” 的方式来导入对应的库即可。唯一要注意的是,对于 Webpack 可能需要设置相应的 Wasm Loader,具体可以参考实际项目的使用说明。
|
||||
|
||||
问题8:我想知道我伟大的大不列颠太阳永不落 PHP 同志是否可以被编译成 Wasm?
|
||||
|
||||
答案当然是可以的。不过由于 PHP 是一种动态类型的语言,因此我们只能把 PHP 的运行时编译成 Wasm,然后将其运行在其他的宿主环境中。这里可以参考一个名为 “pib” 的项目, 链接在这里。
|
||||
|
||||
除此之外,目前 Wasm 已经支持多达几十种编程语言,它们都会以不同的方式(本身被编译为 Wasm,或其运行时被编译为 Wasm)来与 Wasm 产生交集。我们先不说这些项目都是否有着其实际的应用价值,但无论如何,这都从侧面说明了人们对 Wasm 的未来期望。
|
||||
|
||||
好了,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
|
||||
|
61
专栏/WebAssembly入门课/结束语WebAssembly,未来已来.md
Normal file
61
专栏/WebAssembly入门课/结束语WebAssembly,未来已来.md
Normal file
@ -0,0 +1,61 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 WebAssembly,未来已来
|
||||
你好,我是于航。
|
||||
|
||||
首先,感谢你这一路的陪伴。我本以为就现阶段来说,Wasm 作为一个相对小众的技术领域而言,大家可能更多地是抱着一种“看热闹”的心态,在我的预想里反馈可能不多。但最后结果大大超出我的预期。同学们都在持续不断地学习,并且给出了你们对 Wasm 这门技术的反馈。这让我看到了大多数开发者对 Wasm 所抱有的疑问和期待,相信这也是整个互联网技术圈子对 Wasm 认知的一个缩影。
|
||||
|
||||
从七月初开始准备,到十月中旬的这最后一节课,历时三个多月的时间,《WebAssembly 入门课》专栏终于迎来了它的“尾声”。但专栏的结束却并不意味着学习之路的结束,Wasm 仍然在以惊人的速度不断发展,每两周一次的 CG 会议也在如期举行。相信时过半年或一年后,你再回顾来时的路,会发现这一切都已经有了新的变化。
|
||||
|
||||
其实早在 2017 年末我决定开始写书并进行选题策划时,就遇到过这样一个问题。“对于 Wasm 这类正处在技术成长期的开始阶段,并且还在不断快速发展中的技术,我应该怎样选题才能够保障书籍内容的最长时效性?”这是传统纸质出版书籍的一个弊端。对于如今日新月异的互联网技术来说,用传统的纸质出版去呈现会显得有些沉重和单调。当然这里我并不是一概而论,但这种现象是着实存在的。
|
||||
|
||||
因此,在《深入浅出 WebAssembly》出版后的第二年,我觉得有必要来跟大家再“交代”一下。Wasm 自 2018 年末到 2020 年这两年时间内发生的变化太多了,因此,我选择了线上专栏的形式来承载这些内容。这样,当日后 Wasm 再有哪些技术更新的时候,我也能够快速、方便地更新专栏的内容,甚至再根据大家的学习反馈来补充一些“加餐”。
|
||||
|
||||
技术领域融合
|
||||
|
||||
不知道你在第一次听说 “WebAssembly” 这个词的时候有没有这样的感觉,其中,Web 代表着这是一门 Web 技术,而 Assembly 又表明这门技术跟“汇编”有着密切的联系。因此,“Wasm 这门技术适合哪类同学来学习?”便也成为了一个被开发者们广泛聊起的话题。
|
||||
|
||||
事实上,不知道你有没有发现,自上世纪 90 年代以来,伴随着 Web 浏览器大战以及 JavaScript 语言的诞生,在经过这二十几年的发展后,Open Web 开始逐渐“侵蚀”互联网技术的各个角落。因此,一条被称为 Atwood 定律的名言便出现了,那就是:“凡是可以用 JavaScript 来写的应用,最终都会用 JavaScript 来写”。
|
||||
|
||||
这条定律是由 Jeff Atwood 于 2007 年提出的,Jeff Atwood 是 StackOverflow.com 的创始人之一,也是《程序员的修炼 —— 从优秀到卓越》和《高效能程序员的修炼》这两本书的作者。这里我们不谈及这条名言本身的意义和价值,但可以从侧面看出的是,Atwood 对 JavaScript 语言,甚至是对未来 Web 技术的信心。
|
||||
|
||||
而时至今日,相信你也能够明确地感受到,基于 Web 技术的“技术领域融合”正在不断发生。我们不再受限于 C#、Java、Swift 和 Kotlin 等语言,可以直接使用 JavaScript 来编写横跨三端(Web、Mobile 和 Native)的应用。不仅如此,我们也可以使用 JavaScript 来基于 Node.js 开发后端应用。甚至某种程度上,使用 JavaScript 开发 IOT 应用程序也成为了可能。
|
||||
|
||||
随着 Web 前端技术逐渐被渗透到其他各个技术领域,你会发现部分前端开发者们也开始逐渐接触那些原来与 Web 前端根本毫不沾边的其他技术体系,比如“编译技术”“自动化脚本” 甚至是 “沙盒技术”。而这一切都源于 Web 前端技术被应用的场景越来越广泛、其工程越来越复杂,特殊的 case 也越来越多。
|
||||
|
||||
我们再回到之前那个问题,到底谁应该来学习 Wasm 这门技术呢?
|
||||
|
||||
我的答案是“只要你感到好奇,就可以来学习”。Wasm 诞生于 Web,但却不止于 Web。相信跟随课程一路过来的你肯定深有体会,Wasm 这项技术本身所涉及到的大部分知识领域,都完全超出了 Web 前端体系的知识范畴。但是于你而言,对于理解下面三个问题来说,实际上并不困难。
|
||||
|
||||
|
||||
Wasm 是什么?
|
||||
Wasm 现阶段和未来能做什么?
|
||||
Wasm 怎么简单应用?
|
||||
|
||||
|
||||
因此,我的建议是,对于暂时没有实际工作需求的你,只需要去了解关于 Wasm 的这三个问题即可。通过这三个问题,你能够做到心中有数,在日常工作中便会多一份选择。而在真正找到适合的应用场景后,再根据方向逐一进行深入。
|
||||
|
||||
当然,不仅是对 Wasm 这类新兴技术,对待其他的技术领域也应该如此。T 字型人才的一个最重要的特点就是“一专多能”。其中的“一专”是指你能够在一个技术方向深入下去,拥有自己的产出。而“多能”则是在如今这个互联网技术快速融合的时代,能够紧跟变化的一项重要的个人能力。每个人所拥有的时间都是固定的,在知识海洋中怎样选择必要的部分便显得尤为重要。
|
||||
|
||||
横向与纵向
|
||||
|
||||
在专栏上线后的一段时间里,曾有同学私信过我一个问题,我觉得这个问题很典型,因此我想在这里和你谈谈我的看法。这个问题是这样的:“老师你好,我是一名前端开发,现在感觉很迷茫,前端技术没有做到专精,杂七杂八的东西倒是接触了不少,有种什么都想学,却又能力不够的感觉。我是不是应该在前端领域让自己沉下去或者在某个细分领域钻研下去?”
|
||||
|
||||
这个问题的答案也正如我刚刚讲的那样,技术体系下的 T 字型人才是一种最理想的状态。但现实的情况是,我们总是会处在一个交叉路口,往左走是回归原始和基础,往右走是各类新奇的事物。如果选择往左走就会被时代发展落下,而选择往右走却又仿佛会丢失原本最重要的东西。
|
||||
|
||||
对我而言,我可能会选择往左走,当然这只是我个人的答案,仅供你的参考。
|
||||
|
||||
其实对于大部分的新技术来说,你会发现它们早在几年或者十几年前就已经有了类似方案,无非是应用的方式和场景不太相同而已,而技术的本质并没有任何变化。就拿 ASM.js 和 Wasm 为例,这两种技术无非都是想通过减少程序运行时的类型判断,来提升代码的执行性能。只是前者通过 Annotation 的方式来实现,后者则是创造了一种新的 V-ISA 来实现,但本质上这两种方式并不新奇,新奇的点只是它们被应用在了 Web 浏览器中。
|
||||
|
||||
包括我们之前介绍的 WASI 的 Capability-based Security 安全模型,也是早在十几年前就已经出现了,只是没有人以 WASI 这种方式来应用而已。说了这么多,我想表达的意思就是说:“在互联网技术体系中,创新性技术大多都是在基础性理论之上构建而来”。而这就意味着,当你把基础打牢后,对于新技术的理解会变得更加容易,甚至你自己会有着更加创新的想法。
|
||||
|
||||
5年,Wasm 从脑海中的一个想法,变成了如今被众人关注的开创性技术。这一路走来我们看到了它的成长,但这还远远不够。未来的 5 年,希望 Wasm 不再只是一个小众技术。“纸上得来终觉浅,绝知此事要躬行”,也希望此刻的你能够在真正的实践中体会到 Wasm 的力量,而 Wasm 的未来发展,将需要你们的支持。
|
||||
|
||||
最后,感谢你的一路同行。我还为你准备了一个毕业问卷,希望你用2分钟填写一下,和我分享一下你对这门课的建议以及你对 Wasm 的想法。希望未来的 Wasm 之路上,还会看到你的身影!
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user