查看源代码 调试 NIF 和端口驱动程序

能力越大,责任越大

NIF 和端口驱动程序代码在 Erlang VM 操作系统进程(“Beam”)内部运行。为了最大化性能,代码由执行 Erlang beam 代码的同一线程直接调用,并具有对操作系统进程所有内存的完全访问权限。因此,有错误的 NIF/驱动程序可能会通过破坏内存造成严重损害。

在最佳情况下,这种内存损坏会被立即检测到,导致 Beam 崩溃并生成一个核心转储文件,可以对其进行分析以查找错误。然而,内存损坏错误通常不会在发生错误写入时立即被检测到,而是在稍后,例如在调用 Erlang 进程进行垃圾回收时。当发生这种情况时,通过分析核心转储来找到内存损坏的根本原因会非常困难。可能表明导致损坏的特定错误 NIF/驱动程序的任何痕迹可能早已消失。

另一种难以发现的错误是内存泄漏。它们可能在很长一段时间内不被注意,直到部署的系统运行了很长时间才会出现问题。

以下部分描述了一些工具,这些工具可以更容易地检测和找到此类错误的根本原因。这些工具在 Erlang 运行时系统本身的开发、测试和故障排除过程中被积极使用。

调试模拟器

使调试更容易的一种方法是运行以 debug 为目标构建的模拟器。它会

  • 提高及早发现错误的可能性。它包含更多运行时检查,以确保正确使用内部接口和数据结构。
  • 生成更易于分析的核心转储。编译器优化被关闭,这会阻止编译器“优化掉”变量,从而更容易/可以检查其状态。
  • 检测锁顺序违规。运行时锁检查器将验证 erl_niferl_driver API 中的锁是否以一致的顺序获取,从而不会导致死锁错误。

实际上,我们建议在 NIF 和驱动程序的开发过程中默认使用调试模拟器,无论您是否正在排除错误。一些细微的错误可能不会被普通模拟器检测到,只是碰巧可以正常工作。但是,另一个版本的模拟器,甚至同一模拟器中的不同情况,都可能导致该错误稍后引发各种问题。

debug 模拟器的主要缺点是其性能降低。额外的运行时检查和缺乏编译器优化可能会导致速度降低两倍或更多,具体取决于负载。内存占用应该大致相同。

如果 debug 模拟器是 Erlang/OTP 安装的一部分,可以使用 -emu_type 选项启动它。

> erl -emu_type debug
Erlang/OTP 25 [erts-13.0.2] ... [type-assertions] [debug-compiled] [lock-checking]

Eshell V13.0.2  (abort with ^G)
1>

如果 debug 模拟器不是安装的一部分,则需要从 Erlang/OTP 源代码构建它。从源代码构建后,您可以进行 Erlang/OTP 安装,也可以使用 cerl 脚本直接在源代码树中运行调试模拟器

> $ERL_TOP/bin/cerl -debug
Erlang/OTP 25 [erts-13.0.2] ... [type-assertions] [debug-compiled] [lock-checking]

Eshell V13.0.2  (abort with ^G)
1>

cerl 脚本还可以方便地启动调试器 gdb 以进行核心转储分析

> $ERL_TOP/bin/cerl -debug -core core.12345
or
> $ERL_TOP/bin/cerl -debug -rcore core.12345

第一个变体启动 Emacs 并在其中运行 gdb,而另一个 -rcore 则直接在终端中运行 gdb。除了使用正确的 beam.debug.smp 可执行文件启动 gdb 之外,它还会读取文件 $ERL_TOP/erts/etc/unix/etp-commands,其中包含许多用于检查 beam 核心转储的 gdb 命令。例如,命令 etp 将以纯 Erlang 语法打印 Erlang 项 (Eterm) 的内容。

地址消毒器

AddressSanitizer (asan) 是一种开源编程工具,可检测内存损坏错误,例如缓冲区溢出、使用后释放和内存泄漏。AddressSanitizer 基于编译器插桩,并受 gcc 和 clang 的支持。

debug 模拟器类似,asan 模拟器的运行速度比正常情况慢,大约慢 2-3 倍。但是,它也具有更大的内存占用,大约比正常情况多 3 倍内存。

为了获得完整的效果,您应该使用 AddressSanitizer 插桩编译您自己的 NIF/驱动程序代码以及 Erlang 模拟器。通过将选项 -fsanitize=address 传递给 gcc 或 clang 来编译您自己的代码。其他推荐的选项(可改进故障识别)包括 -fno-common-fno-omit-frame-pointer

通过使用与调试模拟器相同的过程来构建和运行支持 AddressSanitizer 的模拟器,但使用 asan 构建目标而不是 debug

  • 在源代码树中运行 - 如果您使用 cerl 脚本直接在源代码树中运行 asan 模拟器,则只需将环境变量 ASAN_LOG_DIR 设置为将生成错误日志文件的目录。

    > export ASAN_LOG_DIR=/my/asan/log/dir
    > $ERL_TOP/bin/cerl -asan
    Erlang/OTP 25 [erts-13.0.2] ... [address-sanitizer]
    
    Eshell V13.0.2  (abort with ^G)
    1>

    但是,如果您希望在检测到错误时崩溃模拟器,您可能还想设置 ASAN_OPTIONS="halt_on_error=true"

  • 运行已安装的 Erlang/OTP - 如果您在已安装的 Erlang/OTP 中使用 erl -emu_type asan 运行 asan 模拟器,则需要使用以下方法设置错误日志文件的路径

    > export ASAN_OPTIONS="log_path=/my/asan/log/file"

    为了避免模拟器本身出现误报的内存泄漏报告,请设置 LSAN_OPTIONS (LSAN=LeakSanitizer)

    > export LSAN_OPTIONS="suppressions=$ERL_TOP/erts/emulator/asan/suppress"

    suppress 文件当前未安装,但可以从源代码树手动复制到您想要的任何位置。

AddressSanitizer 会在发生内存损坏错误时报告它们,但默认情况下,只有在模拟器终止时才会检查和报告内存泄漏。

Valgrind

更重量级的调试工具是 Valgrind。它还可以找到与 asan 类似的内存损坏错误和内存泄漏。Valgrind 在处理缓冲区溢出错误方面不如 asan,但它会发现未定义数据的使用,这是 asan 无法检测到的一种错误类型。

Valgrind 比 asan 慢得多,并且无法利用 CPU 多核处理。因此,我们建议在尝试 Valgrind 之前首先选择 asan

Valgrind 本身作为虚拟机运行,模拟硬件机器指令的执行。这意味着您几乎可以在 Valgrind 上不变地运行任何程序。但是,我们发现 beam 可执行文件可以通过使用特殊的调整进行编译来在 Valgrind 上运行。

使用与 debugasan 相同的方式构建 valgrind 目标模拟器。请注意,在开始构建之前,需要在机器上安装 valgrind

使用 cerl 脚本直接在源代码树中运行 valgrind 模拟器。将环境变量 VALGRIND_LOG_DIR 设置为将生成错误日志文件的目录。

> export VALGRIND_LOG_DIR=/my/valgrind/log/dir
> $ERL_TOP/bin/cerl -valgrind
Erlang/OTP 25 [erts-13.0.2] ... [valgrind-compiled]

Eshell V13.0.2  (abort with ^G)
1>

rr - 记录和回放

最后但并非最不重要的是,由 Mozilla 作为开源开发的出色交互式调试工具 rrrr 代表记录和回放。当核心转储仅表示 OS 进程崩溃时的静态快照时,使用 rr,您可以改为记录整个会话,从 OS 进程的开始到结束(崩溃)。然后,您可以在 gdb 中重放该会话。单步执行、设置断点和监视点,甚至向后执行

考虑到其强大的实用性,rr 非常轻巧。它可以在任何合理的现代 x86 CPU 的 Linux 上运行。在记录模式下执行时,可能会减慢两倍。最大的弱点是它无法利用 CPU 多核处理。如果错误是并发运行的线程之间的竞争条件,则可能很难使用 rr 重现。

rr 不需要任何特殊的插桩编译。但是,如果可能,请将其与 debug 模拟器一起运行,因为这将产生更好的调试体验。您可以使用 cerl 脚本在源代码树中运行 rr

这是一个典型会话的示例。首先,我们在 rr 记录会话中捕获崩溃

> $ERL_TOP/bin/cerl -debug -rr
rr: Saving execution to trace directory /home/foobar/.local/share/rr/beam.debug.smp-1.
Erlang/OTP 25 [erts-13.0.2]

Eshell V13.0.2  (abort with ^G)
1> mymod:buggy_nif().
Segmentation fault

现在,我们可以使用 rr replay 重放该会话

> rr replay
GNU gdb (Ubuntu 9.2-0ubuntu1~20.04.1) 9.2
:
(rr) continue
:
Thread 2 received signal SIGSEGV, Segmentation fault.
(rr) backtrace

您可以在崩溃时获得调用堆栈。不幸的是,它在 beam 的垃圾回收深处。但是,您设法弄清楚变量 hp 指向一个损坏的 Erlang 项。

在该内存位置设置一个监视点,然后向后恢复执行。然后,调试器将停止在写入该内存位置 *hp 的确切位置。

(rr) watch -l *hp
Hardware watchpoint 1: -location *hp
(rr) reverse-continue
Continuing.

Thread 2 received signal SIGSEGV, Segmentation fault.

这是一个需要注意的怪癖。我们首先向前执行,直到它以 SIGSEGV 崩溃。我们现在从该点向后执行,因此我们再次从另一个方向命中相同的 SIGSEGV。只需再次向后继续即可跳过它。

(rr) reverse-continue
Continuing.

Thread 2 hit Hardware watchpoint 1: -location *hp

Old value = 42
New value = 0

现在我们来到了有人在进程堆上写入损坏项的位置。请注意,当我们向后执行时,“旧值”和“新值”是相反的。在这种情况下,值 42 被写入堆上。让我们看看谁是罪魁祸首

(rr) backtrace