WebAssembly 調試原理和方法簡介

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. 那麼如何使用它實現調試呢?主要包括以下步驟:

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 優劣比較

U1GypV

4. 總結

正所謂,“工欲善其事,必先利其器”。一門語言要爲開發者創造價值,必須要提供能夠解決程序全生命週期的各類問題的工具箱,調試器就是其中重要一環。

因此,在這篇文章中,我們提出了 WebAssembly 源碼調試這個命題。管中窺豹,可見一斑。通過探究一般程序源碼調試所面臨的一些基本問題,我們瞭解到了這項任務大致的原理和處理基本問題的常用手段。在此之後,文章聚焦於 wasm 源碼調試。wasm 的調試有其特殊性,在不同的場景下也出現了不同的解決方案——文章對這些方案也進行了簡要的介紹和演示,並且比較了原生 wasm 的各種調試方案的優劣。

總體而言, WebAssembly 在 Web 端的調試已經有較爲成熟的路徑,但是非 Web 端的調試或許還有更好的解決方案。

5. 參考文獻

[1] The AssemblyScript Book : https://www.assemblyscript.org/introduction.html
[2] Debugging WebAssembly with modern tools: https://www.youtube.com/watch?v=VBMHswhun-s
[3] Wasminspect: An Interactive Debugger for WebAssembly: https://github.com/kateinoigakukun/wasminspect
[4] How to wasm DWARF : https://lucumr.pocoo.org/2020/11/30/how-to-wasm-dwarf
[5] The pain of debugging WebAssembly : https://thenewstack.io/the-pain-of-debugging-webassembly
[6] Introduction to the DWARF Debugging Format : https://dwarfstd.org/doc/Debugging%20using%20DWARF-2012.pdf
[7] Debugging WebAssembly outside of the browser : https://hacks.mozilla.org/2019/09/debugging-webassembly-outside-of-the-browser
[8] Debugging WebAssembly with wasmtime : https://docs.wasmtime.dev/examples-debugging.html
[9] WAMR source debugging : https://github.com/bytecodealliance/wasm-micro-runtime/blob/main/doc/source_debugging.md

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/kCfe3sbUgmhzLqD2cLddjQ