欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

第 10 章 WebAssembly 调试原理和方法介绍

最编程 2024-03-01 20:03:57
...

作者:杨文明

1. 前言

在 WebAssembly 社区蓬勃发展的当下,或出于对 JavaScript 等动态语言面对计算密集型任务时改善性能的愿望(如 Ammo.js),或源自将桌面表现出色的软件搬上 Web 环境的想法(如 AutoCAD),或希望在服务端利用沙箱来尽可能保证安全(如 Shopify-Serverless),越来越多的开发者选择 WebAssembly 技术。

而对于一项技术而言,围绕这项技术的开发工具矩阵是否完备,是否足够强大和易用,以及给开发者们带来的体验好坏,则是决定开发者们在尝试之后能否成为拥趸的关键因素。通常来说,一段代码的生命周期,包括编写、测试、交付与部署、上线生效、问题定位与修复等环节。在问题出现之后,对代码的源码调试(Source Code Debugging)往往是定位问题最高效的手段。提供高效的调试工具,帮助开发者迅速解决问题,是助推 WebAssembly 技术社区发展壮大的一个重要手段。

在本文中,我们将主要围绕 WebAssembly 的源码调试,阐述若干相关的问题。

2. 浅谈调试原理

当我们在阅读经典调试器 LLDB 的手册时,通过它提供的各种指令,可以发现调试一段程序主要包括两方面的任务:一是控制程序的运行,包括 step instep overbreakfinishcontinue 等,用于决定程序以什么方式执行、暂停和结束;二是反映程序的运行状态,包括 print some_variablebacktraceregister 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, &regs);
// 读取寄存器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 调试步骤基本一致,所有控制台的功能都可以正常使用,体验非常丝滑。

10-1.gif

图 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 环境以复现这类问题。

10-2.gif

图 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='.'

10-3.gif

图 3. Chrome/Devtool调试C/C++产物

详细使用教程可以参考[2].

3.2.5 独立工具WasmInspect

这个工具来自于一个开源项目[3],它提供了基本的调试功能,类似于 lldb+wasmtime,可以基于 WASM-DWARF 针对独立的 WebAssembly 模块进行源码调试。基本的调试功能如断点、单步、跳入等控制动作均可使用,另外还提供了完整的 WASI 支持,并且还能够进行线性内存转储分析。

不足的是,这个项目最近的更新是2020年5月份,长时间未进行更新。推测该工具后续没有人力进行维护,不能跟随 WebAssembly 规范进行迭代。

10-4.gif

图 4. WasmInspect 调试

3.2.6 优劣比较

调试方式 优势 不足
Native
  • 可使用成熟的调试工具
  • 工具熟悉、零学习成本
  • 无法复现 wasm 强相关的问题
  • 需要维护原生产物的编译路径
  • lldb+wasmtime
  • 类似于 Native+lldb 的体验
  • wasm 环境下进行调试
  • 变量信息还不支持打印, 只能用日志
  • 断点之后显示信息无效
  • lldb+iwasm
  • 类似于 Native+lldb 的体验
  • wasm 环境下进行调试
  • 需要自行编译 lldb 和 iwasm
  • 编译模式下的调试步骤较为复杂
  • Chrome Devtool & DWARF插件
  • 调试功能完善
  • wasm 环境中调试
  • 需要使用 emcc 等工具
  • 引入了 html/js, 增加了复杂度
  • wasi 只能用浏览器的类似接口模拟, 行为可能不一致
  • WasmInspect
  • 独立工具,开箱即用
  • 功能不完备
  • 项目长时间无更新, 不跟随 wasm 规范迭代
  • 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…

    推荐阅读