找回密码
 会员注册
查看: 11|回复: 0

WebAssembly调试原理和方法简介_UTF_8

[复制链接]

2万

主题

0

回帖

7万

积分

超级版主

积分
75052
发表于 2024-9-30 08:08:43 | 显示全部楼层 |阅读模式
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调试AssemblyScript3.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 调试 WebAssembly3.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:1234test.wasmlldb(lldb)processconnect-pwasmconnect://127.0.0.1:1234编译模式调试与上述第 2 种 lldb+wasmtime 的方式原理相同,wamr 提供的编译模式下的调试方案使用 lldb,将 wasm 的运行时系统和目标 wasm 模块作为一个整体程序进行调试。它要求先把 wasm 文件编译为 aot 文件,这需要用到 wamr 提供的 AOT 编译工具 wamrc。wamrc-otest.aottest.wasmlldbiwasm--test.aot(lldb)targetcreate"iwasm"Currentexecutablesetto'iwasm'(x86_64).(lldb)settingsset--target.run-args"test.aot"(lldb)settingssetplugin.jit-loader.gdb.enableon(lldb)bmain3.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#includeintfibo(inti){if(i
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-15 18:36 , Processed in 0.732527 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表