作者:杨文明
1. 前言
在 WebAssembly 社区蓬勃发展的当下,或出于对 JavaScript 等动态语言面对计算密集型任务时改善性能的愿望(如 Ammo.js),或源自将桌面表现出色的软件搬上 Web 环境的想法(如 AutoCAD),或希望在服务端利用沙箱来尽可能保证安全(如 Shopify-Serverless),越来越多的开发者选择 WebAssembly 技术。
而对于一项技术而言,围绕这项技术的开发工具矩阵是否完备,是否足够强大和易用,以及给开发者们带来的体验好坏,则是决定开发者们在尝试之后能否成为拥趸的关键因素。通常来说,一段代码的生命周期,包括编写、测试、交付与部署、上线生效、问题定位与修复等环节。在问题出现之后,对代码的源码调试(Source Code Debugging)往往是定位问题最高效的手段。提供高效的调试工具,帮助开发者迅速解决问题,是助推 WebAssembly 技术社区发展壮大的一个重要手段。
在本文中,我们将主要围绕 WebAssembly 的源码调试,阐述若干相关的问题。
2. 浅谈调试原理
当我们在阅读经典调试器 LLDB 的手册时,通过它提供的各种指令,可以发现调试一段程序主要包括两方面的任务:一是控制程序的运行,包括 step in
、step over
、break
、finish
、continue
等,用于决定程序以什么方式执行、暂停和结束;二是反映程序的运行状态,包括 print some_variable
、backtrace
、register read
等,用于获取程序运行中的变量值、执行堆栈、内存映像等各类信息,帮助使用者理解程序的状态。
由此,可以得知调试的本质即以某种方式控制目标程序的运行,并且获取运行过程中的各类信息,从而帮助使用者达成自身的目标。其中,按照目标程序的种类不同,调试又可以分为原生程序的调试、托管语言程序的调试。两者的底层实现方式存在较大区别,但是基本的思想大同小异。
2.1 调试原生程序
想要对一个原生程序进行调试,一般而言,我们需要一个调试器和一个目标程序。调试器将创建一个子进程,并且根据 OS 提供的系统调用与子进程进行通信,并控制子程序。
以 Linux 为例,常用于调试的系统调用就是大名鼎鼎的 ptrace
. 那么如何使用它实现调试呢?主要包括以下步骤:
-
调试器启动之后,
fork
一个子进程,并等待子进程的信号 -
子进程启动之后,请求父进程(调试器进程)跟踪自己,并准备执行目标程序
-
子进程执行目标程序时,发出信号并被已经阻塞的父进程接收
-
父进程得到信号,并使用
ptrace
执行各类调试动作
ptrace
支持的调试动作非常丰富,举例如下:
// 子进程通知父进程请求跟踪自己
ptrace(PTRACE_TRACEME, 0, 0, 0);
// 单步执行
ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0);
// 获取所有寄存器内容
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
// 读取寄存器eip数据
ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0);
// 替换某地址的内容
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap);
比如上例中的断点。在 x86 架构中,在处理器层面,断点的支持由调试器中断指令即 int 3
指令提供,执行该指令时将发出一个中断信号被调试器进程捕获。设置断点时,开发者输入的文件:行号、函数名等断点目标的信息,将根据调试信息被转换成具体的指令地址,调试器通过上述的 ptrace(PTRACE_POKETEXT, ...)
用中断指令替换目标地址原来的指令。这样一来,执行到断点位置时就可以实现暂停程序运行。
至于如何恢复运行、保留或取消断点,就留作思考,感兴趣的同学可以深入研究。
2.2 调试托管语言程序
相比于原生程序的调试,托管语言程序的调试器一般来说会局限在用户程序层面,其核心不涉及陷入内核的系统调用与进程间通信。因为托管语言的程序不能够直接运行在物理机器上,而是由虚拟机/运行时提供运行环境。所以,这类程序的调试需要依托虚拟机实现。由于虚拟机内部完全了解托管语言程序的栈帧组织方式,因此解析栈帧以获得相关执行信息并不存在障碍。
比如在 V8 中,引擎维护了一个 hook_on_function_call
的标志,并在执行函数调用的时候检查这个标志,如果为真就进入调试的执行模式,否则正常执行该函数。当然在调试执行之前,一些准备工作是必要的,包括确认脚本类型、确认断点位置、对已优化的函数进行逆优化等等,不一而足。调试执行时,要根据来自前端的指令决定下一步动作,比如设置断点、单步执行、继续执行等。值得一提的是,为了实现与调试器前端的交互,接受命令并返回信息,一套调试协议是不可或缺的,比如 V8 的 Inspector 协议。
除上述方式以外,托管语言程序的调试也可以采用原生程序的调试方式。比如著名 WebAssembly 引擎 wasmtime 提供的调试解决方案,就是使用 LLDB 对 WebAssembly 程序进行调试。由于 wasmtime 对 wasm 程序进行 JIT 编译,原来的托管语言程序也转换成了原生程序,可直接运行在物理机器上。但是这种调试方式并不纯粹,通过 backtrace
指令我们可以看到 wasmtime 的执行栈和 wasm 程序的栈帧堆在一起,它相当于对运行时和目标程序进行混合调试。这样一来,使用者在调试自己的 wasm 代码时,其实有可能捕获盘旋在 wasmtime 头顶的小飞虫。
2.3 调试信息
在我们以上关于两种程序的调试中,还存在一个关键问题,那就是如何将程序执行中的各类信息与源码对应起来,便于开发者对照源码进行调试,这个问题的解决就牵涉到调试信息。
一方面,对于 JavaScript 这类脚本语言来说,由于不经过编译也能够执行,想要实现源码调试,额外的调试信息并不是必要的。但是,在生产环境中,出于减少网络延迟和安全等考虑,JavaScript 程序会经过压缩、混淆和拼接等过程。这种情况下,用一种格式记录最终产物与 JavaScript 源程序的对应关系也是不可或缺的,其业界标准是 SourceMap.
另一方面,C/C++ 等编译型语言需要经过编译,生成二进制可执行文件才能运行。在编译的过程中,可阅读的源码中的大量信息会被丢弃,最后变成处理器可理解的一串简单操作符、寄存器、内存地址和二进制数值。为了达到更高的执行效率,编译器会对程序中的语句、表达式和变量进行重组、消除与合并等操作。这样一来,对于开发者来说,越是高效的二进制产物,就很可能越难以理解。因此,为了支持源码调试,用一种格式记录源码与可执行程序的关系同样是必要的。其中,业界的调试信息格式包括 COFF,PECOFF,OMF,IEEE695 以及更为常见的 DWARF.
3. WebAssembly 调试
WebAssembly 的运行需要虚拟机的支持,因此它也属于托管语言。作为一种面向场景广泛的编译目标,wasm 的调试呈现了诸多鲜明的特点。
一、调试信息多样:当前常用的 wasm 调试信息格式包括 SourceMap 和 Wasm-DWARF. Web 开发者们使用 AssemblyScript 这类技术生成 wasm 产物时,附带的调试信息就是 SourceMap;原生程序的开发者,使用如 C/C++、Rust 语言编译得到 wasm 产物时,通常会产生 DWARF 格式的调试信息。
二、使用场景多变:Web 内外场景区别较大,技术栈、开发环境、交付方式等各方面都存在一定的差异,这也是存在两种调试信息格式的原因之一。
三、运行环境开放:实际场景中,与其他托管语言和原生程序都不同, WebAssembly 程序不会在一个封闭的环境中运行,而是要通过 import/export 与宿主进行交互以完成自己的功能。
结合 WebAssembly 的特点,社区也出现了几种源码调试解决方案。对于使用 AssemblyScript 开发的程序员来说,Chrome/Devtool 可以提供完整的调试能力和足以媲美 JavaScript 的调试体验。而针对原生开发者,现在社区也有五种方案,它们各有千秋,都可以实现基本的源码调试,但也仍然存在着各自的不足。
下文将对这几种方式逐一介绍:
3.1 使用 Chrome 调试 AssemblyScript
使用 AssemblyScript 进行开发并生成 WebAssembly 产物,可以参考 AssemblyScript 的开发手册[1].
将编译选项中 sourceMap
置 true 之后,编译时会同步生成 .sourcemap
文件。之后可以使用 Chrome/Devtool 进行调试,跟 JavaScript 调试步骤基本一致,所有控制台的功能都可以正常使用,体验非常丝滑。
图 1. Chrome Devtool调试AssemblyScript
3.2 原生 wasm 模块的五种调试方式
3.2.1 原生调试
这种调试方式将忽略 WebAssembly ,要求在调试时将目标产物编译为原来的原生产物(如 C/C++ 的二进制产物),而不再将 wasm 作为编译目标,之后使用原有的调试工具进行调试。
使用这种方式,可以回到开发者熟悉的调试路径,如使用 LLDB/GDB 调试 C/C++ 的二进制产物。使用原有的成熟调试器,不仅可以降低开发者的学习成本,而且可以充分利用已经非常完善的调试功能。对于单纯程序逻辑相关、不涉及具体 WebAssembly 特性的问题的调试,这种方法尤其合适。
3.2.2 lldb+wasmtime 调试
这种调试方式借助 lldb 和 wasmtime 的能力,将 wasm 的调试信息在JIT编译时同步转换到 native 格式,可以获得非常接近于原生调试的体验。如前文所述,其特点是将 wasmtime 运行时和 JIT 编译后的 WebAssembly 程序作为整体调试。相比于原生调试,针对 wasm 强相关的问题,这种方式可以建立一个完整的 wasm 环境以复现这类问题。
图 2. wasmtime 调试 WebAssembly
3.2.3 lldb+iwasm 调试
在前期的关于常见 wasm 引擎的文章中,我们介绍过 wasm-micro-runtime(简称 wamr),iwasm 就是 wamr 提供的命令行工具。针对 WebAssembly 的源码调试,wamr 团队做了很多杰出的工作,给出了可行的解决方案。对应于 wamr 执行 wasm 程序的两种方式,iwasm 可以在解释或编译模式下进行调试。
解释模式调试
这种方式下,iwasm 将启动一个 server 并等待 lldb 与其建立 socket 连接。连接建立后,lldb 与 iwasm 通过 socket 进行信息收发。为此,wamr 团队针对原有的 GDB 远程调试协议进行扩展,支持了 WebAssembly 的相关特性。
在具体的操作中,开发者需要基于 LLVM 的源代码和 wamr 提供的补丁,构建支持 WebAssembly 调试的 lldb. 随后,使用构建所得的 lldb 与 iwasm 连接进行调试。
iwasm -g=127.0.0.1:1234 test.wasm
lldb
(lldb) process connect -p wasm connect://127.0.0.1:1234
编译模式调试
与上述第2种 lldb+wasmtime 的方式原理相同,wamr 提供的编译模式下的调试方案使用 lldb,将 wasm 的运行时系统和目标 wasm 模块作为一个整体程序进行调试。它要求先把 wasm 文件编译为 aot 文件,这需要用到 wamr 提供的 AOT 编译工具 wamrc.
wamrc -o test.aot test.wasm
lldb iwasm -- test.aot
(lldb) target create "iwasm"
Current executable set to 'iwasm' (x86_64).
(lldb) settings set -- target.run-args "test.aot"
(lldb) settings set plugin.jit-loader.gdb.enable on
(lldb) b main
3.2.4 Chrome/Devtool + C/C++ 插件调试
这种方式与 AssemblyScript 一样需要使用 Chrome 的调试能力,并且暂时只支持 C/C++ 编译得到的 WebAssembly 模块。由于 wasm 产物由 C/C++ 源程序编译所得,还需要一款名为 C/C++ DevTools Support (DWARF)的插件来支持 WASM-DWARF 信息的解析。
要在浏览器上进行调试,一般需要用到 HTML/JavaScript 来装载被调试的 wasm 模块,因此使用 emcc 作为编译工具链最为便捷。
举个简单的例子:
// debug.c
#include <stdio.h>
int fibo(int i) {
if (i < 2) return 1;
return fibo(i - 1) + fibo(i - 2);
}
int main(int argc, char const *argv[])
{
printf("fibo(10): %d\n", fibo(10));
return 0;
}
使用如下的编译命令,可以得到 html、js、wasm 和 debug.wasm 等产物。之后,在 Chrome 开发者工具中可以加载源码,然后可以跟 JavaScript 一样进行包括查看变量、断点、单步等调试操作。
emcc test_debug.c -o test_debug.html -g -fdebug-compilation-dir='.'
图 3. Chrome/Devtool调试C/C++产物
详细使用教程可以参考[2].
3.2.5 独立工具WasmInspect
这个工具来自于一个开源项目[3],它提供了基本的调试功能,类似于 lldb+wasmtime,可以基于 WASM-DWARF 针对独立的 WebAssembly 模块进行源码调试。基本的调试功能如断点、单步、跳入等控制动作均可使用,另外还提供了完整的 WASI 支持,并且还能够进行线性内存转储分析。
不足的是,这个项目最近的更新是2020年5月份,长时间未进行更新。推测该工具后续没有人力进行维护,不能跟随 WebAssembly 规范进行迭代。
图 4. WasmInspect 调试
3.2.6 优劣比较
调试方式 | 优势 | 不足 |
Native | ||
lldb+wasmtime | ||
lldb+iwasm | ||
Chrome Devtool & DWARF插件 | ||
WasmInspect |
4. 总结
正所谓,“工欲善其事,必先利其器”。一门语言要为开发者创造价值,必须要提供能够解决程序全生命周期的各类问题的工具箱,调试器就是其中重要一环。
因此,在这篇文章中,我们提出了 WebAssembly 源码调试这个命题。管中窥豹,可见一斑。通过探究一般程序源码调试所面临的一些基本问题,我们了解到了这项任务大致的原理和处理基本问题的常用手段。在此之后,文章聚焦于 wasm 源码调试。 wasm 的调试有其特殊性,在不同的场景下也出现了不同的解决方案——文章对这些方案也进行了简要的介绍和演示,并且比较了原生 wasm 的各种调试方案的优劣。
总体而言, WebAssembly 在 Web 端的调试已经有较为成熟的路径,但是非 Web 端的调试或许还有更好的解决方案.
5. 参考文献
[1] The AssemblyScript Book : www.assemblyscript.org/introductio…
[2] Debugging WebAssembly with modern tools: www.youtube.com/watch?v=VBM…
[3] Wasminspect: An Interactive Debugger for WebAssembly: github.com/kateinoigak…
[4] How to wasm DWARF : lucumr.pocoo.org/2020/11/30/…
[5] The pain of debugging WebAssembly : thenewstack.io/the-pain-of…
[6] Introduction to the DWARF Debugging Format : dwarfstd.org/doc/Debuggi…
[7] Debugging WebAssembly outside of the browser : hacks.mozilla.org/2019/09/deb…
[8] Debugging WebAssembly with wasmtime : docs.wasmtime.dev/examples-de…
[9] WAMR source debugging : github.com/bytecodeall…
扫码关注公众号 👆 追更不迷路