使用Sanitizers调试

Undefined Behaviour Sanitizer

Clang的未定义行为sanitizer (UBSan) 可用于Emscripten。这使得捕捉代码中的bug变得更加容易。

要使用UBSan,只需将-fsanitize=undefined 传递给emccem++。请注意,您需要在编译和链接阶段都传递此选项,因为它会影响代码生成和系统库。

捕获空指针解引用

默认情况下,在Emscripten中,解引用空指针不会立即导致段错误,与传统平台不同,因为0在WebAssembly内存中只是一个普通地址。0在JavaScript Typed Array中也是一个正常的位置,这是一个JavaScript和WebAssembly(运行时支持代码、JS库方法、EM_ASM/EM_JS 等)之间的错误,如果使用-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)!

Address 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)
...

配置

ASan 可以通过 --pre-js 文件进行配置

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 模式,可以通过使用 -sSAFE_HEAP 运行 emcc 来激活它。这会做很多事情,其中一些与 sanitizers 重叠。

总的来说,SAFE_HEAP 专注于针对 Wasm 时出现的具体痛点。另一方面,sanitizers 专注于使用 C/C++ 等语言时所涉及的具体痛点。这两个集合相互重叠,但并不完全相同。您应该使用哪一个取决于您要查找的错误类型。您可能希望使用所有 sanitizers 和 SAFE_HEAP 进行最大范围的测试,但您可能需要分别为每种模式进行构建,因为并非所有 sanitizers 都相互兼容,并且并非所有 sanitizers 都与 SAFE_HEAP 兼容(因为 sanitizers 会执行一些非常激进的操作!)。如果您的传递的标志存在问题,您将收到一个编译器错误。一个合理的单独测试构建集可能是:ASan、UBsan 和 SAFE_HEAP

SAFE_HEAP 错误的具体内容包括

  • 空指针(地址 0)读取或写入。如前所述,这在 WebAssembly 和 JavaScript 中很烦人,因为 0 只是一个普通地址,因此您不会立即得到段错误,这可能会令人困惑。

  • 未对齐的读写操作。这些在 WebAssembly 中有效,但在某些平台上,未对齐的读写操作可能要慢得多,而使用 wasm2js (WASM=0) 则会导致错误,因为 JavaScript 类型化数组不允许未对齐操作。

  • 越过有效内存顶部的读写操作,这些内存由 sbrk() 管理,也就是说,不是由 malloc() 正确分配的内存。这并非 Wasm 独有的问题,但在 JavaScript 中,如果地址过大以至于超出了类型化数组的范围,则会返回 undefined,这可能非常令人困惑,因此添加了此功能(至少在 Wasm 中会抛出错误;SAFE_HEAP 仍然可以帮助 Wasm,通过检查 sbrk() 内存顶部和 Wasm 内存末尾之间的区域)。

SAFE_HEAP 通过对每个加载和存储操作进行检测来执行这些检查。这会减慢速度,但它确实保证可以找到所有此类问题。它也可以在编译后对任意 Wasm 二进制文件执行,而消毒器必须在从源代码编译时执行。

相比之下,UBSan 也可以找到空指针读写操作。但是,它不会对每个加载和存储操作进行检测,因为它是在源代码编译期间完成的,因此检查是在 clang 知道需要它们的地方添加的。这效率要高得多,但存在代码生成和优化可能改变某些内容,或者 clang 错过特定位置的风险。

ASan 可以找到未分配内存的读写操作,包括高于 sbrk() 管理内存的地址。在某些情况下,它可能比 SAFE_HEAP 更有效率:虽然它也检查每个加载和存储操作,但 LLVM 优化器在添加这些检查后运行,这可以消除其中的一些检查。