使用 Sanitizer 除錯

未定義行為 Sanitizer

Clang 的未定義行為 Sanitizer (UBSan) 可用於 Emscripten。這使得更容易捕捉程式碼中的錯誤。

若要使用 UBSan,只需將 -fsanitize=undefined 傳遞給 emccem++。請注意,您需要在編譯和連結階段都傳遞此選項,因為它會影響程式碼產生和系統函式庫。

捕捉空指標解參照

預設情況下,使用 Emscripten 時,解參照空指標不會像傳統平台一樣立即導致區段錯誤,因為 0 只是 WebAssembly 記憶體中的一個正常位址。0 在 JavaScript Typed Array 中也是一個正常位置,這在 WebAssembly (執行階段支援程式碼、JS 函式庫方法、EM_ASM/EM_JS 等) 旁邊的 JavaScript 中是一個問題,如果您使用 -sWASM=0 進行建置,對於編譯後的程式碼也是一個問題。

在啟用 ASSERTIONS 的建置中,會在程式執行結束時檢查儲存在位址 0 的魔術 Cookie。也就是說,如果程式執行時有任何東西寫入該位置,它會通知您。這只會偵測寫入,不會偵測讀取,而且無助於找出錯誤寫入的實際位置。

請考慮以下程式 null-assign.c

int main(void) {
    int *a = 0;
    *a = 0;
}

如果沒有 UBSan,程式結束時會出現錯誤

$ emcc null-assign.c
$ node a.out.js
Runtime error: The application has corrupted its heap memory area (address zero)!

使用 UBSan,您可以取得發生此錯誤的確切行號

$ emcc -fsanitize=undefined null-assign.c
$ node a.out.js
null-assign.c:3:5: runtime error: store to null pointer of type 'int'
Runtime error: The application has corrupted its heap memory area (address zero)!

請考慮以下程式 null-read.c

int main(void) {
    int *a = 0, b;
    b = *a;
}

如果沒有 UBSan,則沒有任何回饋

$ emcc null-read.c
$ node a.out.js
$

使用 UBSan,您可以取得發生此錯誤的確切行號

$ emcc -fsanitize=undefined null-assign.c
$ node a.out.js
null-read.c:3:9: runtime error: load of null pointer of type 'int'

最小執行階段

UBSan 的執行階段並非微不足道,且使用它可能會不必要地增加攻擊面。因此,有一個針對生產用途設計的最小 UBSan 執行階段。

Emscripten 支援最小執行階段。若要使用它,除了 -fsanitize 旗標之外,還需要傳遞 -fsanitize-minimal-runtime 旗標。

$ emcc -fsanitize=null -fsanitize-minimal-runtime null-read.c
$ node a.out.js
ubsan: type-mismatch
$ emcc -fsanitize=null -fsanitize-minimal-runtime null-assign.c
$ node a.out.js
ubsan: type-mismatch
Runtime error: The application has corrupted its heap memory area (address zero)!

位址 Sanitizer

Clang 的位址 Sanitizer (ASan) 也可用於 Emscripten。這使得更容易捕捉程式碼中的緩衝區溢位、記憶體洩漏和其他相關錯誤。

若要使用 ASan,只需將 -fsanitize=address 傳遞給 emccem++。與 UBSan 相同,您需要在編譯和連結階段都傳遞此選項,因為它會影響程式碼產生和系統函式庫。

您可能需要將 INITIAL_MEMORY 至少增加到 64 MB,或設定 ALLOW_MEMORY_GROWTH,讓 ASan 有足夠的記憶體可以啟動。否則,您會收到類似以下的錯誤訊息

無法將記憶體陣列擴展至 55152640 位元組 (OOM)。請 (1) 使用 -sINITIAL_MEMORY=X 進行編譯,其中 X 高於目前的值 50331648,(2) 使用 -sALLOW_MEMORY_GROWTH 進行編譯,這允許在執行階段增加大小,或 (3) 如果您希望 malloc 傳回 NULL (0) 而不是中止,請使用 -sABORTING_MALLOC=0 進行編譯

ASan 完全支援多執行緒環境。ASan 也會在 JS 支援程式碼上運作,也就是說,如果 JS 嘗試從無效的記憶體位址讀取,它會被捕獲,就像該存取來自 Wasm 一樣。

範例

以下是一些如何使用 AddressSanitizer 來協助尋找錯誤的範例。

緩衝區溢位

請考慮 buffer_overflow.c

#include <string.h>

int main(void) {
  char x[10];
  memset(x, 0, 11);
}
$ emcc -gsource-map -fsanitize=address -sALLOW_MEMORY_GROWTH buffer_overflow.c
$ node a.out.js
=================================================================
==42==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x02965e5a at pc 0x000015f0 bp 0x02965a30 sp 0x02965a30
WRITE of size 11 at 0x02965e5a thread T0
    #0 0x15f0 in __asan_memset+0x15f0 (a.out.wasm+0x15f0)
    #1 0xc46 in __original_main stack_buffer_overflow.c:5:3
    #2 0xcbc in main+0xcbc (a.out.wasm+0xcbc)
    #3 0x800019bc in Object.Module._main a.out.js:6588:32
    #4 0x80001aeb in Object.callMain a.out.js:6891:30
    #5 0x80001b25 in doRun a.out.js:6949:60
    #6 0x80001b33 in run a.out.js:6963:5
    #7 0x80001ad6 in runCaller a.out.js:6870:29

Address 0x02965e5a is located in stack of thread T0 at offset 26 in frame
    #0 0x11  (a.out.wasm+0x11)

  This frame has 1 object(s):
    [16, 26) 'x' (line 4) <== Memory access at offset 26 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (a.out.wasm+0x15ef)
...

釋放後使用

請考慮 use_after_free.cpp

int main() {
  int *array = new int[100];
  delete [] array;
  return array[0];
}
$ em++ -gsource-map -fsanitize=address -sALLOW_MEMORY_GROWTH use_after_free.cpp
$ node a.out.js
=================================================================
==42==ERROR: AddressSanitizer: heap-use-after-free on address 0x03203e40 at pc 0x00000c1b bp 0x02965e70 sp 0x02965e7c
READ of size 4 at 0x03203e40 thread T0
    #0 0xc1b in __original_main use_after_free.cpp:4:10
    #1 0xc48 in main+0xc48 (a.out.wasm+0xc48)

0x03203e40 is located 0 bytes inside of 400-byte region [0x03203e40,0x03203fd0)
freed by thread T0 here:
    #0 0x5fe8 in operator delete[](void*)+0x5fe8 (a.out.wasm+0x5fe8)
    #1 0xb76 in __original_main use_after_free.cpp:3:3
    #2 0xc48 in main+0xc48 (a.out.wasm+0xc48)
    #3 0x800019b5 in Object.Module._main a.out.js:6581:32
    #4 0x80001ade in Object.callMain a.out.js:6878:30
    #5 0x80001b18 in doRun a.out.js:6936:60
    #6 0x80001b26 in run a.out.js:6950:5
    #7 0x80001ac9 in runCaller a.out.js:6857:29

previously allocated by thread T0 here:
    #0 0x5db4 in operator new[](unsigned long)+0x5db4 (a.out.wasm+0x5db4)
    #1 0xb41 in __original_main use_after_free.cpp:2:16
    #2 0xc48 in main+0xc48 (a.out.wasm+0xc48)
    #3 0x800019b5 in Object.Module._main a.out.js:6581:32
    #4 0x80001ade in Object.callMain a.out.js:6878:30
    #5 0x80001b18 in doRun a.out.js:6936:60
    #6 0x80001b26 in run a.out.js:6950:5
    #7 0x80001ac9 in runCaller a.out.js:6857:29

SUMMARY: AddressSanitizer: heap-use-after-free (a.out.wasm+0xc1a)
...

記憶體洩漏

請考慮 leak.cpp

int main() {
  new int[10];
}
$ em++ -gsource-map -fsanitize=address -sALLOW_MEMORY_GROWTH -sEXIT_RUNTIME leak.cpp
$ node a.out.js

=================================================================
==42==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 40 byte(s) in 1 object(s) allocated from:
    #0 0x5ce5 in operator new[](unsigned long)+0x5ce5 (a.out.wasm+0x5ce5)
    #1 0xb24 in __original_main leak.cpp:2:3
    #2 0xb3a in main+0xb3a (a.out.wasm+0xb3a)
    #3 0x800019b8 in Object.Module._main a.out.js:6584:32
    #4 0x80001ae1 in Object.callMain a.out.js:6881:30
    #5 0x80001b1b in doRun a.out.js:6939:60
    #6 0x80001b29 in run a.out.js:6953:5
    #7 0x80001acc in runCaller a.out.js:6860:29

SUMMARY: AddressSanitizer: 40 byte(s) leaked in 1 allocation(s).

請注意,由於洩漏檢查會在程式結束時進行,因此您必須使用 -sEXIT_RUNTIME,或手動叫用 __lsan_do_leak_check__lsan_do_recoverable_leak_check

您可以偵測到 AddressSanitizer 已啟用,並執行 __lsan_do_leak_check,方法是執行

#include <sanitizer/lsan_interface.h>

#if defined(__has_feature)
#if __has_feature(address_sanitizer)
  // code for ASan-enabled builds
  __lsan_do_leak_check();
#endif
#endif

如果發生記憶體洩漏,這將是致命的。若要檢查記憶體洩漏並允許程序繼續執行,請使用 __lsan_do_recoverable_leak_check

此外,如果您只想檢查記憶體洩漏,您可以使用 -fsanitize=leak 而不是 -fsanitize=address-fsanitize=leak 不會檢測所有記憶體存取,因此比 -fsanitize=address 快得多。

返回後使用

請考慮 use_after_return.c

#include <stdio.h>

const char *__asan_default_options() {
  return "detect_stack_use_after_return=1";
}

int *f() {
  int buf[10];
  return buf;
}

int main() {
  *f() = 1;
}

請注意,若要執行此檢查,您必須使用 ASan 選項 detect_stack_use_after_return。您可以透過宣告一個名為 __asan_default_options 的函式 (如範例所示) 來啟用此選項,或者您可以在產生的 JavaScript 中定義 Module['ASAN_OPTIONS'] = 'detect_stack_use_after_return=1'。在這裡 --pre-js 非常有幫助。

此選項相當昂貴,因為它會將堆疊配置轉換為堆積配置,且這些配置不會重複使用,以便未來的存取可以導致陷阱。因此,預設情況下不會啟用此選項。

$ emcc -gsource-map -fsanitize=address -sALLOW_MEMORY_GROWTH use_after_return.c
$ node a.out.js
=================================================================
==42==ERROR: AddressSanitizer: stack-use-after-return on address 0x02a95010 at pc 0x00000d90 bp 0x02965f70 sp 0x02965f7c
WRITE of size 4 at 0x02a95010 thread T0
    #0 0xd90 in __original_main use_after_return.c:13:10
    #1 0xe0a in main+0xe0a (a.out.wasm+0xe0a)

Address 0x02a95010 is located in stack of thread T0 at offset 16 in frame
    #0 0x11  (a.out.wasm+0x11)

  This frame has 1 object(s):
    [16, 56) 'buf' (line 8) <== Memory access at offset 16 is inside this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-use-after-return (a.out.wasm+0xd8f)
...

組態

可以透過 --pre-js 檔案設定 ASan

Module.ASAN_OPTIONS = 'option1=a:option2=b';

例如,將包含您選項的上述程式碼片段放入 asan_options.js,然後使用 --pre-js asan_options.js 進行編譯。

對於獨立的 LSan,請改用 Module.LSAN_OPTIONS

如需詳細了解這些旗標,請參閱 ASan 文件。請注意,大多數旗標組合都未經過測試,而且可能有效或無效。

停用 malloc/free 堆疊追蹤

在非常頻繁使用 malloc/free (或其 C++ 等效項 operator new/operator delete) 的程式中,在所有 malloc/free 的叫用中擷取堆疊追蹤可能會非常耗費資源。因此,如果您發現使用 ASan 時程式速度非常慢,您可以嘗試使用 malloc_context_size=0 選項,如下所示

Module.ASAN_OPTIONS = 'malloc_context_size=0';

這會阻止 ASan 報告記憶體洩漏的位置,或提供對基於堆積的記憶體錯誤的來源的深入瞭解,但可能會提供極大的速度提升。

SAFE_HEAP 的比較

Emscripten 提供一個 SAFE_HEAP 模式,可以透過執行 emcc 並加上 -sSAFE_HEAP 來啟用。這個模式會執行一些操作,其中某些操作與除錯工具(sanitizers)的功能重疊。

一般來說,SAFE_HEAP 著重於在以 Wasm 為目標時會出現的特定痛點。另一方面,除錯工具則著重於使用 C/C++ 等語言時會出現的特定痛點。這兩者之間的功能有所重疊,但並不完全相同。您應該使用哪一個取決於您想要尋找的問題類型。為了達到最大的覆蓋率,您可能會想使用所有除錯工具和 SAFE_HEAP 進行測試,但您可能需要為每個模式分別進行建置,因為並非所有除錯工具都彼此相容,而且也並非所有除錯工具都與 SAFE_HEAP 相容(因為除錯工具會做一些相當激進的事情!)。如果傳遞的旗標有問題,您會收到編譯器錯誤。一個合理的獨立測試建置組合可能是:ASan、UBsan 和 SAFE_HEAP

SAFE_HEAP 會針對以下特定情況產生錯誤:

  • NULL 指標(位址 0)的讀取或寫入。如前所述,這在 WebAssembly 和 JavaScript 中很麻煩,因為 0 只是個普通位址,因此您不會立即得到記憶體區段錯誤(segfault),這可能會令人困惑。

  • 未對齊的讀取或寫入。這些在 WebAssembly 中可以運作,但在某些平台上,未正確對齊的讀取或寫入可能會慢很多,而且使用 wasm2js (WASM=0) 時,它會是錯誤的,因為 JavaScript 的類型化陣列(Typed Arrays)不允許未對齊的操作。

  • 讀取或寫入超出 sbrk() 所管理的有效記憶體頂端,也就是說,讀取或寫入的記憶體並非由 malloc() 正確分配。這並非 Wasm 特有的問題,然而,在 JavaScript 中,如果位址夠大而超出類型化陣列的範圍,則會傳回 undefined,這可能會非常令人困惑,這就是添加這個檢查的原因(至少在 Wasm 中會拋出錯誤;SAFE_HEAP 仍然在 Wasm 中有所幫助,它會檢查 sbrk() 的記憶體頂端和 Wasm 記憶體末端之間的區域)。

SAFE_HEAP 透過對每個載入和儲存操作進行檢測來執行這些檢查。這會導致速度變慢,但它確實提供了找到所有此類問題的簡單保證。它也可以在編譯後對任意 Wasm 二進位檔執行,而除錯工具必須在從原始碼編譯時完成。

相比之下,UBSan 也可以找到 NULL 指標的讀取和寫入。然而,它不會檢測每個載入和儲存操作,因為它是在原始碼編譯期間完成的,因此只有在 clang 知道需要時才會添加檢查。這效率更高,但存在程式碼產生和最佳化改變某些東西,或 clang 遺漏特定位置的風險。

ASan 可以找到未分配記憶體的讀取或寫入,包括高於 sbrk() 管理的記憶體的位址。在某些情況下,它可能比 SAFE_HEAP 更有效率:雖然它也會檢查每個載入和儲存操作,但在它添加這些檢查後會執行 LLVM 最佳化器,這可以移除其中的一些檢查。