DTrace 简介
DTrace 是 FreeBSD 内置的软件跟踪框架,允许用户实时检查和修改正在运行的系统。它高度可扩展,最初为 Solaris 设计,但后来被多次移植到其他环境,如 FreeBSD、macOS、Windows 和 Linux。本文将重点介绍在 FreeBSD 上 DTrace 的用法,并提供示例,同时总结 DTrace 在 FreeBSD 领域的最新发展。
DTrace 概述
操作系统是极其复杂的软件,包含众多组件。单一的跟踪系统若试图支持几乎整个操作系统的跟踪,可能会因其复杂性而变得难以管理。为了解决这一问题并支持未来的扩展,DTrace 引入了 provider(提供者) 的概念。
在 FreeBSD 中,provider 默认作为内核模块(kernel module)运行,并负责实现特定操作系统组件的跟踪功能。它们提供 DTrace probes(探测点),即操作系统代码中的命名位置,可通过 D 语言编写的可脚本化例程进行动态跟踪。
FreeBSD 自带的一些示例 provider 包括:
函数边界跟踪提供者(fbt.ko):负责对 内核函数 的入口和退出点进行跟踪。
性能分析提供者(profile.ko):提供基于脚本指定的固定时间间隔触发的探测点。
PID 提供者(fasttrap.ko):类似于 fbt,但适用于 用户进程及其链接的库。
虽然深入了解 DTrace 并非理解本文的必要前提,但希望深入学习 DTrace 的读者可以参考以下资料:
FreeBSD Wiki 页面(包括一些 DTrace 单行命令示例⁶)。
此外,以往的 FreeBSD Journal 也曾多次发表关于 DTrace 的相关文章⁷⁸⁹。
简单示例
DTrace 探测点 由 provider:module:function:name 四元组 指定。其中的每个字段可以使用通配符匹配多个值,或留空表示“匹配所有”。
下面是一个简单的 DTrace 监视脚本(snooper script),用于检测 用户正在运行的程序。 我们在执行时使用 -x quiet
选项,以避免 DTrace 输出额外的信息。
# dtrace -x quiet -n 'proc:::exec { printf(“user = %u, gid = %u: %s\n”, uid, gid,
stringof(args[0])); }'
user = 1001, gid = 1001: /usr/sbin/service
user = 1001, gid = 1001: /bin/kenv
user = 1001, gid = 1001: /sbin/sysctl
user = 1001, gid = 1001: /sbin/env
user = 1001, gid = 1001: /bin/env
user = 1001, gid = 1001: /usr/sbin/env
user = 1001, gid = 1001: /usr/bin/env
user = 1001, gid = 1001: /etc/rc.d/sendmail
user = 1001, gid = 1001: /bin/kenv
user = 1001, gid = 1001: /sbin/sysctl
user = 1001, gid = 1001: /bin/ls
正如我们所看到的,D 语言 在语法上与 C 语言 非常相似,但也有一些特定的特殊语法形式。与 C 语言不同,D 语言不支持循环,因此任何形式的循环都必须手动展开。
在上面的示例中,我们可以通过 uid
和 gid
这两个内置变量来访问用户 ID 和组 ID。
此外,DTrace 还支持以多种方式对跟踪结果进行聚合。例如,我们可以统计每个程序执行的系统调用次数。
# dtrace -n 'syscall:::entry { @syscall_agg[execname, pid] = count(); }'
dtrace: description 'syscall:::entry ' matched 1148 probes
sh 46569 7
sh 46570 7
syslogd 703 16
sshd 848 17
devd 501 20
ntpd 771 24
sh 46565 93
dtrace 46568 138
ps 46570 254
sshd 46564 27517
ls 46569 35755
在变量前使用 @
作为前缀,会将其定义为聚合变量。 @syscall_agg
以两个键进行索引,不过也可以继续添加更多的键。 @syscall_agg
的聚合输出应解读为:
我们的最后一个示例涉及堆栈追踪。DTrace 允许用户使用 stack()
和 ustack()
例程分别收集内核和用户空间的堆栈追踪。此外,DTrace 还可以通过语言特定的堆栈展开器进行扩展。例如,jstack()
操作可以为用户提供 Java 程序的可读回溯信息。 在我们的示例中,我们将重点关注 stack()
:
# dtrace -x quiet -n 'io:::start { @[stack()] = count(); }'
zfs.ko`zio_vdev_io_start+0x2f5
zfs.ko`zio_nowait+0x15f
zfs.ko`vdev_mirror_io_start+0xfd
zfs.ko`zio_vdev_io_start+0x1eb
zfs.ko`zio_nowait+0x15f
zfs.ko`arc_read+0x14aa
zfs.ko`dbuf_read+0xc84
zfs.ko`dmu_tx_check_ioerr+0x84
zfs.ko`dmu_tx_count_write+0x191
zfs.ko`dmu_tx_hold_write_by_dnode+0x64
zfs.ko`zfs_write+0x500
zfs.ko`zfs_freebsd_write+0x39
kernel`VOP_WRITE_APV+0x194
kernel`vn_write+0x2ce
kernel`vn_io_fault_doio+0x43
kernel`vn_io_fault1+0x163
kernel`vn_io_fault+0x1cc
kernel`dofilewrite+0x81
kernel`sys_writev+0x6e
kernel`amd64_syscall+0x12e
1
zfs.ko`zio_vdev_io_start+0x2f5
zfs.ko`zio_nowait+0x15f
zfs.ko`zil_lwb_write_done+0x360
zfs.ko`zio_done+0x10d6
zfs.ko`zio_execute+0xdf
kernel`taskqueue_run_locked+0xaa
kernel`taskqueue_thread_loop+0xc2
kernel`fork_exit+0x80
kernel`0xffffffff810a35ae
1
这个 D 脚本统计了所有导致块设备 I/O 的内核堆栈追踪。
在此脚本中,我们省略了聚合名称,因为它只包含一个聚合,并且键是 stack()
——这是一个 DTrace 内置操作,返回一个程序计数器数组,在打印结果时会解析为符号。
此外,DTrace 还可以使用 profile 提供者收集 CPU 上的堆栈追踪,从而支持生成火焰图(Flame Graphs)。
新进展
dwatch
新工具 dwatch 由 Devin Teske(dteske@freebsd.org) 开发,并在 FreeBSD 11.2 中上游合并。 dwatch 使得 DTrace 在常见使用场景下比 dtrace
命令行工具更易用。 回到我们之前的进程监视示例,用户只需运行:
即可获得比之前的简单监视器更丰富的信息,且输出经过良好过滤。
# dwatch execve
INFO Watching 'syscall:freebsd:execve:entry' ...
2022 Nov 24 18:46:53 1001.1001 sh[46565]: sudo ps auxw
2022 Nov 24 18:46:53 0.0 sudo[46920]: ps auxw
2022 Nov 24 18:46:55 1001.1001 sh[46565]: ls
2022 Nov 24 18:47:01 1001.1001 sh[46565]: ls -lapbtr
2022 Nov 24 18:47:09 1001.1001 sh[46924]: kenv -q rc.debug
2022 Nov 24 18:47:09 1001.1001 sh[46924]: /sbin/sysctl -n -q kern.boottrace.enabled
2022 Nov 24 18:47:09 1001.1001 sh[46565]: env -i -L -/daemon HOME=/ PATH=/sbin:/
bin:/usr/sbin:/usr/bin /etc/rc.d/sendmail onestop
2022 Nov 24 18:47:09 1001.1001 env[46565]: /bin/sh /etc/rc.d/sendmail onestop
2022 Nov 24 18:47:09 1001.1001 sh[46924]: kenv -q rc.debug
2022 Nov 24 18:47:09 1001.1001 sh[46924]: /sbin/sysctl -n -q kern.boottrace.enabled
此外,dwatch 支持基于 jail、用户组、进程等多种条件进行过滤,即便是经验丰富的 DTrace 用户也值得学习这款工具。演讲 All along the dwatch tower 介绍了 dwatch 并详细讲解了其功能。此外,FreeBSD 的 dwatch(1) 手册页也提供了许多优秀的示例,适合感兴趣的用户尝试。
CTFv3
紧凑 C 类型格式(CTF) 是一种用于在 FreeBSD ELF 二进制文件中编码 C 类型信息的格式。它使 DTrace 能够了解目标二进制文件(进程或内核)的 C 类型布局,以便用户编写的脚本可以引用和探索这些类型。
过去,由于 CTFv2 的实现方式,DTrace 在单个二进制文件中最多仅支持 2^15 种 C 类型。这一限制导致了 FreeBSD 中许多与 DTrace 相关的错误报告。今年 3 月,Mark Johnston(markj@freebsd.org) 提交了相关更改,使 DTrace 切换为 CTFv3,这不仅提高了 DTrace 可处理的 C 类型数量,同时还扩展了 CTF 的其他多个限制。
kinst —— 用于指令级跟踪的全新 DTrace 提供程序
在 2022 年 Google 夏季编程大赛(GSoC) 中,Christos Margiolis(christos@freebsd.org) 在 Mark Johnston(markj@freebsd.org) 的指导下成功完成了一项项目,并将 指令级跟踪(Instruction-level Tracing) 功能合并到 FreeBSD。实现该功能的提供程序被称为 kinst。
kinst 复用了 fbt 机制的部分内容,并扩展了它,使其能够对 内核函数的任意位置 进行插桩(Instrumentation),而不仅仅是入口和出口点。对于熟悉 内核开发 的读者而言,kinst 在分析某些分支的调用栈时所带来的潜力不言而喻。因此,kinst 可以帮助更快地发现和修复 FreeBSD 中的 bug 及 性能问题。
以下是一个类似 C 语言 伪代码的示例场景:
if (__predict_false(rarely_true)) {
return (slow_operation());
} else {
return (get_from_cache());
}
在这个示例中,我们聚焦于 FreeBSD 内核中的一个特定函数,其行为类似于此。该函数的简化版如下:
void
_thread_lock(struct thread *td)
{
...
if (__predict_false(LOCKSTAT_PROFILE_ENABLED(spin__acquire)))
goto slowpath_noirq;
spinlock_enter();
...
if (__predict_false(m == &blocked_lock))
goto slowpath_unlocked;
if (__predict_false(!_mtx_obtain_lock(m, tid)))
goto slowpath_unlocked;
...
_mtx_release_lock_quick(m);
slowpath_unlocked:
spinlock_exit();
slowpath_noirq:
thread_lock_flags_(td, 0, 0, 0);
}
可以立即注意到有两个慢路径:slowpath_unlocked
和 slowpath_noirq
。在这两个慢路径中,分别调用了 spinlock_exit()
或 thread_lock_flags_()
,而 _mtx_release_lock_quick()
则只是在 amd64 上执行原子比较和交换指令。为了使用 kinst
来识别最终进入慢路径的调用堆栈,我们首先需要以某种方式反汇编该函数。一种可能的方法是在 FreeBSD 中使用 kgdb(通过 pkg install gdb
安装):
# kgdb
(kgdb) disas /r _thread_lock
Dump of assembler code for function _thread_lock:
...
0xffffffff80bc7dcc <+124>: 5d pop %rbp
0xffffffff80bc7dcd <+125>: e9 4e 72 09 00 jmp 0xffffffff80c5f020
<witness_lock>
0xffffffff80bc7dd2 <+130>: 48 c7 43 18 00 00 00 00 movq $0x0,0x18(%rbx)
0xffffffff80bc7dda <+138>: e8 e1 43 4e 00 call 0xffffffff810ac1c0
<spinlock_exit>
0xffffffff80bc7ddf <+143>: 8b 75 d4 mov -0x2c(%rbp),%esi
...
0xffffffff80bc7df2 <+162>: 41 5d pop %r13
0xffffffff80bc7df4 <+164>: 41 5e pop %r14
0xffffffff80bc7df6 <+166>: 41 5f pop %r15
0xffffffff80bc7df8 <+168>: 5d pop %rbp
0xffffffff80bc7df9 <+169>: e9 82 00 00 00 jmp 0xffffffff80bc7e80
<thread_lock_flags_>
在这种情况下,我们可以取偏移量 +138 和 +169 处的指令,这些指令分别是对 spinlock_exit()
和 thread_lock_flags_()
的函数调用。使用这些偏移量,我们现在可以编写我们的 DTrace 脚本:
# dtrace -n 'kinst::_thread_lock:138,kinst::_thread_lock:169 { @[stack(),
probename] = count(); }'
...
0xcf566bb0
kernel`ipi_bitmap_handler+0x87
kernel`0xffffffff810a48b3
kernel`vm_fault_trap+0x71
kernel`trap_pfault+0x22d
kernel`trap+0x48c
kernel`0xffffffff810a2548
138 8
熟悉 DTrace 的人可能会注意到,这本可以通过使用推测性追踪而不需要使用 kinst 来轻松实现。然而,人们可以很容易地想象出一些场景,其中“慢路径”或其等效物并不是一个简单的函数调用,或者相同的函数调用可能出现在所有的分支中。kinst 对 FreeBSD 上的 DTrace 生态系统也有其他影响。历史上,使用 fbt 对内联函数的内核进行插桩存在一个问题。用于实现 kinst 的机制可以帮助扩展 fbt,以支持可靠地追踪内联函数。
正在进行的工作
DTrace 和 eBPF – 比较
Mateusz Piotrowski (0mp@FreeBSD.org) 一直在研究 FreeBSD 上 DTrace 的性能分析,以及它与 Linux 上 eBPF 的比较。今年,他在 EuroBSDcon 2022 上展示了部分结果。这项工作可能会得出有趣的结果,可以作为进一步优化 DTrace 的基础。这将使在性能关键的系统上启用插桩变得更少干扰。
HyperTrace
HyperTrace 是建立在 DTrace 之上的一个框架,允许用户使用 D 编程语言应用类似 DTrace 的追踪技术来追踪虚拟机。它源自英国剑桥大学的 CADETS 项目。作为一个简单的例子,我们来看一下最初的 snooper 脚本,并扩展它以使用 HyperTrace:
# dtrace -x quiet -En 'FreeBSD-14*:proc:::exec { printf(“%s: user = %u, gid = %u:
%s\n”, vmname, uid, gid, stringof(args[0])); }'
scylla1-webserver-0: user = 0, gid = 0: /usr/sbin/dtrace
scylla1-webserver-0: user = 0, gid = 0: /sbin/ls
scylla1-webserver-0: user = 0, gid = 0: /bin/ls
scylla1-client-0: user = 0, gid = 0: /usr/sbin/sshd
scylla1-client-0: user = 0, gid = 0: /bin/csh
scylla1-client-0: user = 0, gid = 0: /usr/bin/resizewin
scylla1-client-0: user = 0, gid = 0: /usr/sbin/iperf
scylla1-client-0: user = 0, gid = 0: /usr/bin/iperf
scylla1-client-0: user = 0, gid = 0: /usr/local/bin/iperf
host: user = 0, gid = 0: /bin/sh
host: user = 0, gid = 0: /usr/libexec/atrun
scylla1-client-0: user = 0, gid = 0: /bin/sh
scylla1-client-0: user = 0, gid = 0: /usr/libexec/atrun
scylla1-client-1: user = 0, gid = 0: /bin/sh
scylla1-client-1: user = 0, gid = 0: /usr/libexec/atrun
scylla1-client-2: user = 0, gid = 0: /bin/sh
scylla1-client-2: user = 0, gid = 0: /usr/libexec/atrun
scylla1-client-3: user = 0, gid = 0: /bin/sh
scylla1-client-3: user = 0, gid = 0: /usr/libexec/atrun
scylla1-webserver-0: user = 0, gid = 0: /bin/sh
scylla1-webserver-0: user = 0, gid = 0: /usr/libexec/atrun
我们修改了脚本,以实现两个新的功能:使用内建变量 vmname
指定每个进程执行的位置前缀,以及在探针规范中添加第五个元组条目:FreeBSD-14*
。这允许用户指定要插桩的目标机器(虚拟机),并可以通过命令行标志来控制,支持通过操作系统版本或机器的主机名进行名称解析。类似的修改也可以应用到我们的块 I/O 示例中:
# dtrace -x quiet -En 'scylla1-*:io:::start { @[vmname, immstack()] = count(); }'
...
scylla1-webserver-0
devstat_start_transacti+0x90
g_disk_start+0x316
g_io_request+0x2d7
g_part_start+0x289
g_io_request+0x2d7
g_io_request+0x2d7
ufs_strategy+0x83
VOP_STRATEGY_APV+0xd2
bufstrategy+0x3e
bufwriteL+0x80
vfs_bio_awrite+0x24f
flushbufqueues+0x52a
buf_daemon+0x1f1
fork_exit+0x80
fork_trampoline+0xe
aio_process_rw+0x10c
aio_daemon+0x285
fork_exit+0x80
fork_trampoline+0xe
fork_trampoline+0xe
10598
scylla1-client-0
g_disk_start+0x316
g_io_request+0x2d7
g_part_start+0x289
g_io_request+0x2d7
g_io_request+0x2d7
ufs_strategy+0x83
VOP_STRATEGY_APV+0x9e
bufstrategy+0x3e
bufwriteL+0x3e
vfs_bio_awrite+0x24f
flushbufqueues+0x52a
buf_daemon+0x1f1
fork_exit+0x80
fork_trampoline+0xe
fork_trampoline+0xe
aio_process_rw+0x10c
aio_daemon+0x285
fork_exit+0x80
fork_trampoline+0xe
fork_trampoline+0xe
10605
在这里,使用了一个新的 DTrace 动作 immstack()
,它与 stack()
类似,但符号解析发生在内核中,而不是在打印输出时。HyperTrace 的工作原理是旨在在主机内核上执行整个 D 脚本,而不是在客户端内部运行 DTrace,同时每个客户机负责自行插桩,并在客户机执行探针时向主机发出同步的超调用(类似于操作系统中的系统调用)。这种设计使得能够在一个地方保持跨所有客户机和主机的全局状态——提高了在追踪虚拟机时 D 的整体表现力。该工作仍在进行中,可以在 GitHub 上查看。
进一步阅读
DOMAGOJ STOLFA 是剑桥大学的研究助理,专注于虚拟化系统的动态追踪。他自 2016 年起就一直在 FreeBSD 上与 bhyve 和 DTrace 合作,贡献补丁。Domagoj 还是剑桥大学高级操作系统课程的教学助理,教授使用 FreeBSD、DTrace 和 PMC 的操作系统概念。