使用 ftrace 挂钩到函数

编写版本:4.14

简介

ftrace 基础设施最初创建的目的是在函数头部附加回调函数,以便记录和追踪内核的执行流。但函数起始处的回调也可以用于其他场景,例如内核热补丁(live kernel patching)或安全监控。本文档将介绍如何使用 ftrace 实现自定义的函数回调。

ftrace 上下文

警告

为内核中几乎任何函数添加回调的能力伴随着风险。回调函数可能会在任何上下文中被调用(正常上下文、softirq、irq 以及 NMI)。此外,回调也可能在进入空闲状态(idle)之前、CPU 上线(bring up)和下线(takedown)期间、或者在进入用户空间时被调用。这就需要格外注意在回调函数内部能够执行哪些操作。回调甚至可能会在 RCU 保护范围之外被调用。

存在一些辅助函数用于防范递归并确保 RCU 处于监视状态。这些内容将在下文详细说明。

ftrace_ops 结构体

要注册一个函数回调,需要使用 ftrace_ops 结构体。该结构体用于告知 ftrace 应该调用哪个函数作为回调,以及该回调将执行哪些保护措施(从而不需要 ftrace 来处理)。

在向 ftrace 注册 ftrace_ops 时,只有一个字段是必须设置的

struct ftrace_ops ops = {
      .func                    = my_callback_func,
      .flags                   = MY_FTRACE_FLAGS
      .private                 = any_private_data_structure,
};

.flags 和 .private 都是可选的,只有 .func 是必需的。

要启用追踪,请调用

register_ftrace_function(&ops);

要禁用追踪,请调用

unregister_ftrace_function(&ops);

上述内容通过包含以下头文件来定义

#include <linux/ftrace.h>

注册的回调函数会在调用 register_ftrace_function() 之后、且在其返回之前的某个时间点开始被调用。回调开始被调用的确切时间取决于体系结构以及服务的调度。如果回调必须在一个精确的时刻开始执行,则其自身必须处理相关的同步问题。

unregister_ftrace_function() 可以保证在它返回之后,相关函数将不再调用该回调。请注意,为了提供此保证,unregister_ftrace_function() 可能需要耗费一些时间才能完成。

回调函数

回调函数的原型如下(自 v4.14 起)

void callback_func(unsigned long ip, unsigned long parent_ip,
                   struct ftrace_ops *op, struct pt_regs *regs);
@ip

这是被追踪函数的指令指针(即函数中 fentry 或 mcount 所在的位置)。

@parent_ip

这是调用被追踪函数的上级函数的指令指针(即发生函数调用的位置)。

@op

这是用于注册该回调的 ftrace_ops 指针。可以通过私有指针(private pointer)向回调传递数据。

@regs

如果在 ftrace_ops 结构体中设置了 FTRACE_OPS_FL_SAVE_REGS 或 FTRACE_OPS_FL_SAVE_REGS_IF_SUPPORTED 标志,那么该参数将指向 pt_regs 结构体,其效果类似于在 ftrace 正在追踪的函数起始处放置了一个断点。否则,它包含的可能是无效数据(垃圾数据)或者 NULL。

保护您的回调函数

由于函数可能被任意调用,且回调函数所调用的函数也可能被追踪并再次调用该回调,因此必须使用递归保护机制。为此,有两个辅助函数可以提供帮助。如果你的代码以如下方式开始

int bit;

bit = ftrace_test_recursion_trylock(ip, parent_ip);
if (bit < 0)
        return;

并且以如下方式结束

ftrace_test_recursion_unlock(bit);

夹在中间的代码将可以安全使用,即使它最终调用了该回调正在追踪的函数。请注意,如果成功,ftrace_test_recursion_trylock() 将禁用抢占,而 ftrace_test_recursion_unlock() 将重新启用抢占(前提是之前处于启用状态)。指令指针(ip)及其父指针(parent_ip)会被传递给 ftrace_test_recursion_trylock(),以便记录递归发生的位置(前提是启用了 CONFIG_FTRACE_RECORD_RECURSION)。

另外,如果在 ftrace_ops 上设置了 FTRACE_OPS_FL_RECURSION 标志(如下所述),则会使用一个辅助内核跳转桩(trampoline)来为该回调检测递归,这样就无需手动进行递归测试了。不过,这会带来由于多了一次函数调用而产生的一点额外开销。

如果你的回调函数访问了需要 RCU 保护的任何数据或临界区,最好确保 RCU 处于“监视中(watching)”,否则这些数据或临界区将无法按预期受到保护。此时请添加

if (!rcu_is_watching())
        return;

或者,如果在 ftrace_ops 上设置了 FTRACE_OPS_FL_RCU 标志(如下所述),则会使用一个辅助跳转桩来为该回调检测 rcu_is_watching,而无需进行其他测试。不过,这同样会带来由于多了一次函数调用而产生的一点额外开销。

ftrace 标志(FLAGS)

ftrace_ops 的标志都在 include/linux/ftrace.h 中定义并进行了文档化。其中一些标志用于 ftrace 的内部基础设施,但用户需要了解的是以下几个

FTRACE_OPS_FL_SAVE_REGS

如果回调需要读取或修改传递给它的 pt_regs,则必须设置此标志。如果在不支持向回调传递 pt_regs 的体系结构上注册设置了此标志的 ftrace_ops,将会失败。

FTRACE_OPS_FL_SAVE_REGS_IF_SUPPORTED

类似于 SAVE_REGS,但在不支持传递寄存器(regs)的体系结构上,设置了此标志的 ftrace_ops 注册时不会失败。不过,回调必须检查 regs 是否为 NULL,以确定该体系结构是否支持它。

FTRACE_OPS_FL_RECURSION

默认情况下,假定回调函数能够处理递归。但如果回调对开销不太敏感,可以通过设置该位来为回调添加递归保护:系统会调用一个辅助函数来执行递归检查,只有在未发生递归的情况下才会调用该回调。

注意,如果未设置此标志且发生了递归,可能会导致系统崩溃,甚至可能通过三重错误(triple fault)触发重启。

注意,如果设置了此标志,则在调用回调时总是会禁用抢占。如果未设置,则回调可能会(但不能保证)在可抢占的上下文中被调用。

FTRACE_OPS_FL_IPMODIFY

需要同时设置 FTRACE_OPS_FL_SAVE_REGS。如果回调函数旨在“劫持”被追踪的函数(即调用另一个函数来代替被追踪的函数),则必须设置此标志。这也是内核热补丁(live kernel patches)所使用的方法。如果没有此标志,将无法修改 pt_regs->ip。

注意,对于任意给定的函数,在同一时间只能注册一个设置了 FTRACE_OPS_FL_IPMODIFY 的 ftrace_ops。

FTRACE_OPS_FL_RCU

如果设置了此标志,则回调将仅由 RCU 处于“监视中”的函数调用。如果回调函数执行了任何 rcu_read_lock() 操作,则必须设置此标志。

当系统进入空闲状态(idle)、CPU 被下线又重新上线、以及在内核空间与用户空间之间切换时,RCU 会停止监视。在这些过渡期间,回调可能会被执行,且 RCU 同步机制将无法对其进行保护。

FTRACE_OPS_FL_PERMANENT

如果在任何 ftrace_ops 上设置了此标志,则无法通过向 proc sysctl 的 ftrace_enabled 写入 0 来禁用追踪。同样,如果 ftrace_enabled 为 0,也无法注册带有此标志的回调。

热补丁(Livepatch)利用这一点来避免丢失函数重定向,从而使系统保持受保护状态。

过滤要追踪的函数

如果回调仅从特定的函数中被调用,则必须设置过滤器。过滤器可以通过函数名添加,如果已知其指令指针(ip),也可以通过 ip 添加。

int ftrace_set_filter(struct ftrace_ops *ops, unsigned char *buf,
                      int len, int reset);
@ops

要设置过滤器的 ops

@buf

包含函数过滤文本的字符串。

@len

字符串的长度。

@reset

若为非零值,则在应用此过滤器之前重置所有现有的过滤器。

过滤器指定了启用追踪时应当启用哪些函数。如果 @buf 为 NULL 且设置了 reset,则将启用所有函数进行追踪。

@buf 也可以是一个通配符(glob)表达式,用于启用所有匹配特定模式的函数。

参见 ftrace - Function Tracer 中的过滤命令(Filter Commands)。

若要仅追踪 schedule 函数

ret = ftrace_set_filter(&ops, "schedule", strlen("schedule"), 0);

要添加更多函数,可以将 @reset 参数设为 0,并多次调用 ftrace_set_filter()。若要清除当前的过滤器集合,并用 @buf 定义的新函数取而代之,可将 @reset 设为非零值。

要移除所有被过滤的函数并追踪所有函数

ret = ftrace_set_filter(&ops, NULL, 0, 1);

有时可能会出现多个函数重名的情况。在这种情况下,若只想追踪某个特定的函数,可以使用 ftrace_set_filter_ip()

ret = ftrace_set_filter_ip(&ops, ip, 0, 0);

此时,ip 必须是该函数中调用 fentry 或 mcount 所在的地址。perf 和 kprobes 会使用此函数,它们从用户处获取 ip 地址(通常使用来自内核的调试信息)。

如果使用通配符(glob)来设置过滤器,还可以将某些函数添加到“不追踪(notrace)”列表中,以防止这些函数触发回调。“notrace”列表的优先级高于“filter”列表。如果这两个列表均不为空且包含了相同的函数,则该回调将不会被任何函数调用。

一个空的“notrace”列表意味着允许追踪由过滤器定义的所有函数。

int ftrace_set_notrace(struct ftrace_ops *ops, unsigned char *buf,
                       int len, int reset);

它接受与 ftrace_set_filter() 相同的参数,但会将匹配到的函数添加到不追踪列表中。这是一个独立于过滤器列表的单独列表,该函数不会修改过滤器列表。

若 @reset 为非零值,则在将匹配 @buf 的函数添加进去之前,会先清空“notrace”列表。

清空“notrace”列表的方法与清空过滤器列表相同

ret = ftrace_set_notrace(&ops, NULL, 0, 1);

过滤器和不追踪列表可以随时更改。如果仅允许一组特定的函数调用该回调,最好在注册回调之前设置好过滤器。不过,这些更改也可以在回调注册之后进行。

如果当前已有过滤器生效,且 @reset 为非零值,并且 @buf 包含了匹配函数的通配符,那么切换动作将会在调用 ftrace_set_filter() 的期间发生。在任何时候都不会出现“所有函数都调用回调”的情况。

ftrace_set_filter(&ops, "schedule", strlen("schedule"), 1);

register_ftrace_function(&ops);

msleep(10);

ftrace_set_filter(&ops, "try_to_wake_up", strlen("try_to_wake_up"), 1);

不等同于

ftrace_set_filter(&ops, "schedule", strlen("schedule"), 1);

register_ftrace_function(&ops);

msleep(10);

ftrace_set_filter(&ops, NULL, 0, 1);

ftrace_set_filter(&ops, "try_to_wake_up", strlen("try_to_wake_up"), 0);

因为后者在执行重置操作与设置新过滤器的操作之间,会有一小段时间导致所有函数都会调用该回调。