9  调试 NIF 和端口驱动程序

9 调试 NIF 和端口驱动程序

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

在最佳情况下,这种内存损坏会立即被检测到,导致 Beam 崩溃并生成一个核心转储文件,该文件可以被分析以找到错误。但是,内存损坏错误在发生错误写入时并不总是立即被检测到,而是可能在很久以后,例如在调用 Erlang 进程进行垃圾回收时才被检测到。在这种情况下,通过分析核心转储很难找到内存损坏的根本原因。所有可能指示导致损坏的特定错误 NIF/驱动程序的跟踪可能早已消失。

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

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

使调试更容易的一种方法是运行一个使用目标debug构建的仿真器。它将

  • **增加早期检测错误的可能性**。它包含更多运行时检查,以确保内部接口和数据结构的正确使用。

  • **生成更容易分析的核心转储**。编译器优化被关闭,这阻止编译器“优化掉”变量,从而使其更容易/可能检查其状态。

  • **检测锁定顺序冲突**。运行时锁检查器将验证erl_niferl_driverAPI 中的锁是否以一致的顺序被获取,这种顺序不会导致死锁错误。

事实上,我们建议在 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)的内容。

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

debug仿真器类似,asan仿真器运行速度比正常速度慢,大约慢 2-3 倍。但是,它也有更大的内存占用量,大约是正常内存占用量的 3 倍。

为了获得最佳效果,您应该用地址消毒剂插桩来编译自己的 NIF/驱动程序代码以及 Erlang 仿真器。通过将选项-fsanitize=address传递给 gcc 或 clang 来编译自己的代码。其他建议的选项将改进错误识别,例如-fno-common-fno-omit-frame-pointer.

使用与调试仿真器相同的过程构建和运行具有地址消毒剂支持的仿真器,只是使用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",如果您希望仿真器在检测到错误时崩溃。

如果您使用erl -emu_type asan在已安装的 Erlang/OTP 中运行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文件目前没有安装,但可以手动从源代码树复制到您想要的任何位置。

地址消毒剂在内存损坏错误发生时报告它们,但内存泄漏默认情况下只有在仿真器终止时才会被检查和报告。

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

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

Valgrind 本身作为一个虚拟机运行,模拟硬件机器指令的执行。这意味着您可以在 valgrind 上几乎不变地运行任何程序。但是,我们发现 beam 可执行文件从为在 valgrind 上运行而编译的特殊适应中受益。

使用valgrind目标构建仿真器,就像对debugasan那样。请注意,在构建开始之前,需要在机器上安装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 开发为开源工具。rr代表记录和回放。虽然核心转储只代表操作系统进程崩溃时的静态快照,但使用rr,您可以记录整个会话,从操作系统进程开始到结束(崩溃)。然后,您可以在gdb中回放该会话。单步执行、设置断点和观察点,甚至**向后执行**。

考虑到其强大的实用性,rr非常轻便。它在 Linux 上运行,任何现代 x86 CPU 都可以使用。在记录模式下执行时,您可能会遇到两倍的减速。最大的缺点是它无法利用 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