一、基本原理
今天来重点说一下ftrace子系统在trampoline这块的具体实现。但是在展开之前,我们还是需要先来说一下ftrace功能的整个实现流程,不然会看的不明所以。这里直接借鉴之前也得一篇文章中的描述。照使用方式的不同,ftrace分为静态trace和动态trace(DYNAMIC FTRACE)。其中,静态的ftrace一般指的就是内核中定义好的tracepoint,因为需要主动地去定义那些tracepoint实例以及调用对应的trace函数,因此被称为静态trace。关于静态trace如何使用BPF,可以参考我的这篇文章:BPF内核实现之TRACING。
tracepoint有着其独特的优势,比如可以在特定的代码路径中(函数中间的某个位置)被调用,能够根据需要来在定义的时候采集特定的数据等。但是它的劣势也很明显:需要主动地定义和调用。内核函数这么多,不可能为每个函数都定义一个对应的tracepoint,这里就引入了动态trace的功能。
这里再简单介绍一下ftrace的基本使用。ftrace是通过挂载debugfs到/sys/kernel/debug/来使用的,在/sys/kernel/debug/tracing/目录中存在着很多相关的文件接口。其中,在events目录下存放的就是内核中所有定义的tracepoint。一般来说,如果我们想使用ftrace来跟踪某个内核函数,基本步骤如下:
echo function > current_tracer // 设置跟踪模式为内核函数
echo netif_receive_skb_core > set_ftrace_filter // 设置要跟踪的内核函数
echo 1 > tracing_on // 开启ftrace
通过这样的设置,我们就可以去跟踪内核函数netif_receive_skb_core是否被调用了。那么问题来了,既然我们没有为netif_receive_skb_core函数定义对应的tracepoint,那么是如何能够跟踪这个函数的呢?这里使用的就是动态ftrace的功能,下面我们来简单说一下其原理。为了便于表述,这里将我们要跟踪的函数称为callee,调用该函数的函数为caller。在编译器的支持下,内核函数可以被编译成以下形式:

可以看出来,编译器在callee函数原始的汇编指令开始的地方插入了一个call __fentry__指令。这个是通过编译器的特性来实现的,比如对于gcc编译器,可以通过增加选项-mfentry来实现。实际上,编译器会给所有的内核函数开始的地方增加一条该指令,通过为内核函数增加notrace属性可以避免该行为。
在内核中,__fentry__函数被定义为一个直接return的函数。虽然函数很简单,但是一次函数调用还是会产生一定的性能开销。为了降低性能影响,内核在初始化阶段会将所有的call __fentry__指令替换成nop指令。ftrace的具体内容这里就不具体展开了,这个过程的代码调用链为:
ftrace_init → ftrace_process_locs → ftrace_update_code → ftrace_nop_initialize
对于x86_64架构,该指令的长度为5个字节。而动态ftrace的原理就是利用这个nop指令,将其替换成函数调用指令。当我们执行echo function > current_tracer命令的时候,内核中会发生以下的函数调用:
register_ftrace_function → ftrace_startup → ftrace_startup_enable → ftrace_run_update_code → arch_ftrace_update_code → ftrace_modify_all_code → ftrace_replace_code
这个过程中,会遍历所有的支持动态trace的内核函数,并根据其状态(是否要被跟踪等)来更新、替换其nop指令,将其替换成调用ftrace中的处理函数:

二、trampoline
在ftrace子系统中,这里的trampoline其实指的就是ftrace_caller或者ftrace_regs_caller。为什么叫他们trampoline呢?因为这两个函数是使用汇编来实现的用于跳转到真正的ftrace处理逻辑的功能,也是直接被调用的函数。通过直接的汇编指令,他们可以实现普通函数所不能实现的功能。这里我们首先来分析一下ftrace_regs_caller函数,它和ftrace_caller没多大区别。具体的功能实现如下面的代码注释,这里我们来分步骤来解析一下这个trampoline的具体实现。
2.1 参数准备
按照步骤,首先是寄存器保存的逻辑,这里分为两步走。第一步是调用save_mcount_regs来保存参数寄存器,这个步骤在ftrace_caller里也会被调用。这里保存的时候,是按照struct pt_regs结构体的存储位置来进行保存到栈里的。除了保存参数,这里还会清空pt_regs->origin_rax,这个在进行ftrace direct call的时候会有作用,这里就不展开了。除此之外,还会将callee rip保存到pt_regs->rip中;将caller rip保存到rsi,也就是第二个传参寄存器中,这个在下面会有作用。
在ftrace_regs_caller中,除了上面那些关键的寄存器的保存,它还会保存额外的寄存器信息到pt_regs中,像r10-r15、flags等。还会将caller rip在栈中的地址(注意不是caller rip本身)保存到pt_regs->rsp中,这个在ftrace graph这种需要跟踪函数退出的场景中很有作用,这个在下面也会详细展开。
经过上面的准备工作,我们来看一下目前的传参寄存器(即要传递给callback函数的参数)中的内容都是什么。参数1是目标函数的地址;参数2是caller rip;参数3是当前的ftrace_ops地址,这个是通过指令修正的方式来写进去的,也是为什么我们可以在callback函数里面获取到当前的callback函数所属于的ftrace_ops;第4个参数就是我们在栈中准备好的pt_regs的地址。
/* 基于regs的TRAMPOLINE。这种trampoline不会被直接使用,而是会在注册ftrace_ops的时候
* 为每个ops动态创建一个trampoline,并将这个trampoline里的一些信息修正为这个ops上的
* 信息。当某个函数上存在多个ops的时候,就会启用ftrace_ops_list这个callback。这是一种
* fallback的低效的处理方式,因为它会编译所有的ops,并比对当前的函数的ip和每个ops对应的
* ip_hash,在命中的时候再执行对应的ops。
*/
SYM_FUNC_START(ftrace_regs_caller)
ANNOTATE_NOENDBR
/* 这一段是保存一些ftrace本身的信息 */
/* Save the current flags before any operations that can change them */
pushfq
/* added 8 bytes to save flags */
/* 将寄存器中按照ptrace-abi.h中的格式保存到栈中。这里的8代表着,当前栈帧
* 中的数据,这个宏需要这个信息来拿到返回地址的值。
*
* 当前栈帧的状态为:
* rip - 8,caller的下一条指令的地址,caller rip
* ------------------------------------
* rip - 8,目标函数地址+4,callee rip
* flag - 8
* pt_regs - FRAME_SIZE(168)
*/
save_mcount_regs 8
/* save_mcount_regs fills in first two parameters */
CALL_DEPTH_ACCOUNT
SYM_INNER_LABEL(ftrace_regs_caller_op_ptr, SYM_L_GLOBAL)
/* 这个宏像是一个编译器注释宏,注释这个函数不能被trace,以及没有endbr */
ANNOTATE_NOENDBR
/* Load the ftrace_ops into the 3rd parameter */
/* 把callback对应的ftrace_ops放到第三个传参寄存器中。这个指令也是会在runtime
* 被替换的。
*/
movq function_trace_op(%rip), %rdx
/* 将寄存器中的值按照pt_regs的格式保存到栈中,rsp是pt_regs的起始地址。
* 这一端是做调用ops前的准备工作,把各种参数都准备好,然后调用ops。这里
* 保存的是那些callee不需要保存的寄存器。
*/
/* Save the rest of pt_regs */
movq %r15, R15(%rsp)
movq %r14, R14(%rsp)
movq %r13, R13(%rsp)
movq %r12, R12(%rsp)
movq %r11, R11(%rsp)
movq %r10, R10(%rsp)
movq %rbx, RBX(%rsp)
/* 把我们push到栈里的寄存器flags保存到pt_regs中 */
movq MCOUNT_REG_SIZE(%rsp), %rcx
movq %rcx, EFLAGS(%rsp)
/* 把内核Kernel segments保存到pt_regs中的SS字段中(常量,有啥用?) */
movq $__KERNEL_DS, %rcx
movq %rcx, SS(%rsp)
movq $__KERNEL_CS, %rcx
/* 类似SS */
movq %rcx, CS(%rsp)
/* Stack - skipping return address and flags */
/* 把caller的地址放到了pt_regs的rsp寄存器中 */
leaq MCOUNT_REG_SIZE+8*2(%rsp), %rcx
movq %rcx, RSP(%rsp)
ENCODE_FRAME_POINTER
/* regs go into 4th parameter */
/* 把pt_regs放到第四个传参寄存器中 */
leaq (%rsp), %rcx
/* Account for the function call below */
CALL_DEPTH_ACCOUNT
2.2 函数调用
参数准备好后,下面就可以调用ftrace真正的回调函数了。每个ftrace_ops实例都会有自己的callback函数,其定义如下所示:
struct ftrace_ops {
/* ftrace的回调函数,即会在trampoline里面被调用的函数 */
ftrace_func_t func;
struct ftrace_ops __rcu *next;
unsigned long flags;
void *private;
ftrace_func_t saved_func;
#ifdef CONFIG_DYNAMIC_FTRACE
struct ftrace_ops_hash local_hash;
struct ftrace_ops_hash *func_hash;
struct ftrace_ops_hash old_hash;
unsigned long trampoline;
unsigned long trampoline_size;
struct list_head list;
struct list_head subop_list;
ftrace_ops_func_t ops_func;
struct ftrace_ops *managed;
#ifdef CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS
unsigned long direct_call;
#endif
#endif
};
下面的汇编代码是ftrace_regs_caller进行函数调用的部分,这里可以看出来它调用的是一个叫ftrace_stub的固定的钩子函数。这里其实是因为这个ftrace_regs_caller函数不会被直接调用,而是每个ftrace_ops实例都会基于这个函数(或者ftrace_caller)来创建一个trampoline,具体的代码路径为:
ftrace_startup -> __register_ftrace_function -> ftrace_update_trampoline -> arch_ftrace_update_trampoline -> create_trampoline
他会先拷贝一个这个函数的副本,然后根据特定的ftrace_ops上的属性来修改副本的一些指令,其中就包括将下面的那个call ftrace_stub替换为call func,这里的func指的是当前的ftrace_ops->func:
SYM_INNER_LABEL(ftrace_regs_call, SYM_L_GLOBAL)
/* 这一段是真正的要调用ops函数的地方 */
ANNOTATE_NOENDBR
/* 这里的ftrace_stub会被动态地修改为真正的callback对应的处理函数,上面的
* 那四个参数会被传递给这个callback函数。
*/
call ftrace_stub
/* Copy flags back to SS, to restore them */
movq EFLAGS(%rsp), %rax
movq %rax, MCOUNT_REG_SIZE(%rsp)
2.3 热补丁
我们都知道热补丁是基于ftrace来实现的。加入内核函数foo需要被热补丁给覆盖,那么ftrace是怎么做到的呢?很简单,只需要在ftrace_ops->func里将pt_regs->rip修改为新的foo函数的地址,就能使其取代原来的foo函数,具体实现原理如下所示:
/* Handlers can change the RIP */
/* 经过处理后,将pt_regs中的rip保存下来,放到当前栈帧的return ip的位置。
* 通过这种方式,可以实现当前handler处理完成后,直接跳转到目标指令的作
* 用,从而可以跳过origin函数。这也是热补丁的实现方式,即可以在callback
* 中来设置要返回的地址,不基于direct call的方式来实现origin call。
*/
movq RIP(%rsp), %rax
/* 把pt_regs中的rip存到了栈底的rip的位置。这里,MCOUNT_REG_SIZE+8的偏移
* 就是它的位置。
*
* 这里也是热补丁实现的地方,它会将pt_regs->rip放到callee rip的位置,这样
* 在return的时候,就会直接跳转到新的函数上,而不是原来的目标函数上。如果
* callback函数没有修改pt_regs->rip,那么它的值应该本来就是callee rip,
* 这里就相当于啥也没做。
*/
movq %rax, MCOUNT_REG_SIZE+8(%rsp)
可以看出来,一旦pt_regs->rip被修改了,那么它就会被放置到栈里面的callee rip的位置,从而导致在ftrace执行完后,不会跳回到原来的foo函数,而是跳到新的热补丁中实现的函数中。
2.4 参数恢复
ftrace的trampoline和BPF的trampoline不一样的地方在于,ftrace是不会进行origin call的,这代表着ftrace的trampoline在执行完后必定会返回到原来的函数(或者新的函数)继续执行。这意味着,在trampoline退出之前,必定需要将所有的寄存器进行恢复。下面的指令就是实现这个功能的:
/* restore the rest of pt_regs */
movq R15(%rsp), %r15
movq R14(%rsp), %r14
movq R13(%rsp), %r13
movq R12(%rsp), %r12
movq R10(%rsp), %r10
movq RBX(%rsp), %rbx
movq ORIG_RAX(%rsp), %rax
/* 这里的MCOUNT_REG_SIZE-8是最后一个寄存器的位置,也就是SS寄存器(干啥的?)。
* 这里把origin_rax保存到了ss里面。
*/
movq %rax, MCOUNT_REG_SIZE-8(%rsp)
/* 如果ORIG_RAX寄存器里面有值,那么就进行跳转,并调用其中的值作为函数。 */
testq %rax, %rax
SYM_INNER_LABEL(ftrace_regs_caller_jmp, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
jnz 1f
/* 这里是不进行direct call的地方,这里会进行寄存器的恢复,然后直接返回。 */
restore_mcount_regs
/* Restore flags */
popfq
/*
* The trampoline will add the return.
*/
SYM_INNER_LABEL(ftrace_regs_caller_end, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
RET
2.5 direct call
这里需要讲一下direct call的机制。在上面的指令中有这么一段:
movq ORIG_RAX(%rsp), %rax
/* 这里的MCOUNT_REG_SIZE-8是最后一个寄存器的位置,也就是SS寄存器(干啥的?)。
* 这里把origin_rax保存到了ss里面。
*/
movq %rax, MCOUNT_REG_SIZE-8(%rsp)
/* 如果ORIG_RAX寄存器里面有值,那么就进行跳转,并调用其中的值作为函数。 */
testq %rax, %rax
SYM_INNER_LABEL(ftrace_regs_caller_jmp, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
jnz 1f
在callback执行完成后,它会检查pt_regs->origin_rax有没有被修改,即里面的值是不是NULL。先说一下direct call的场景吧,这里我们的ftrace的trampoline通过HOOK目标函数的__fentry__可以实现对目标函数的完全控制,包括获取参数、返回到新的函数上等。那么,如果有一个别的子系统也行要做相同的事情该怎么办呢?也就是函数foo已经被ftrace的trampoline给HOOK了,另外一个子系统也想做相同的事情。
direct call的作用就是用于这个场景的,这也是ftrace能够托管BPF的trampoline的原因。ftrace子系统是允许一个目标函数被多个ftrace_ops给HOOK的,当这种情况发生后,链表ftrace_ops会被使用。这里简单介绍一下链表ftrace_ops吧。
从上面我们可以看出来,每个ftrace_ops的trampoline都是在ftrace_caller或者ftrace_regs_caller的基础上拷贝一份并且修改而来的。但其实,ftrace_caller或者ftrace_regs_caller未经修改也是可以直接使用的,而他们默认的ftrace_ops就是链表ops。具体来说,其ftrace_ops对象是function_trace_op,这个从汇编指令里面也可以看出来的。其回调函数是ftrace_ops_list_func(这个和arch_ftrace_ops_list_func是同一个函数)。内核中所有的ftrace_ops都会被放到一个全局链表中,这个链表就是function_trace_op。当某个目标函数被多个ftrace_ops所HOOK,那么目标函数的ops就会变成function_trace_op。在回调函数ftrace_ops_list_func中,它会遍历链表中所有的ftrace_ops,然后判断当前的函数有没有在这个ftrace_ops的filter哈希表中。如果在的话,就调用这个ftrace_ops->func函数。
通过这种方式,其实现了多个ftrace_ops来HOOK同一个函数的目的。那么direct call的实现就比较好理解了。如果一个ftrace_ops是direct call类型的,那么它会回调函数会在register_ftrace_direct中被设置为call_direct_funcs。而这个回调函数会把真正要执行的函数的地址放到pt_regs->origin_rax中。在上面的汇编代码中,如果pt_regs->origin_rax不为NULL,那么就会跳转到下面的汇编指令上:
/* 在调用完handler处理且恢复了所有的寄存器的状态后,尝试执行direct call */
/* 这里就是direct call实现的地方。先把保存起来的寄存器的值恢复,再将保存
* 的flags等信息恢复,
*/
/* Swap the flags with orig_rax */
/* MCOUNT_REG_SIZE是flags寄存器的偏移,这里把flags取出来,并放到了ss寄存器
* 的位置,然后把origin_rax放到了flags的位置?骚操作,这里是把origin_rax
* 放到了flags的位置,从而被当做了rip(return address)。
*/
1: movq MCOUNT_REG_SIZE(%rsp), %rdi
movq %rdi, MCOUNT_REG_SIZE-8(%rsp)
movq %rax, MCOUNT_REG_SIZE(%rsp)
/* 恢复寄存器的状态。这里恢复的时候,会保留最后一个寄存器,使其留在栈中。下面的popfq
* 实际上弹出的是我们上面修整过的flags,构造的rip并不会被弹出。
*/
restore_mcount_regs 8
/* Restore flags */
popfq
UNWIND_HINT_FUNC
/*
* The above left an extra return value on the stack; effectively
* doing a tail-call without using a register. This PUSH;RET
* pattern unbalances the RSB, inject a pointless CALL to rebalance.
*/
ANNOTATE_INTRA_FUNCTION_CALL
/* 这个时候的栈的情况是:
* rip - 8,caller的下一条指令的地址
* ------------------------------------
* rip - 8,目标函数地址+4
* rip - 8,direct call的地址
*
* 这里本来是可以直接ret的,就会跳转到direct call的地址,但是这里
* 直接ret的话,会导致栈的情况不对,所以这里需要调用一个函数来修正栈的
* 情况。下面的call就单纯的是为了平衡RSB,使得call的数量和ret的数量是一致的,
* 从而保证分支预测的正确性。
*/
CALL .Ldo_rebalance
int3
.Ldo_rebalance:
/* 这个代码应该是跳过当前的栈帧,然后直接jump到目标函数开始执行。可是为什么
* 这里没有看到jump的指令呢?
*/
add $8, %rsp
/* 经过上面的add之后,现在的栈的情况是:
* rip - 8,caller的下一条指令的地址
* ------------------------------------
* rip - 8,目标函数地址+4
* rip - 8,direct call的地址
*
* 所以这里return的话,会直接跳到direct call的地方,且栈的情况和直接在目标函数
* 的fentry处调用direct call函数是相同的效果。
*/
ALTERNATIVE __stringify(RET), \
__stringify(ANNOTATE_UNRET_SAFE; ret; int3), \
X86_FEATURE_CALL_DEPTH
上面的注解可能有点迷惑性,简单来解释一下这里是如何实现direct call的吧。首先,它恢复了所有的寄存器的值。然后当前栈里的数据本来是这样的:
* rip - 8,caller的下一条指令的地址,caller rip
* ------------------------------------
* rip - 8,目标函数地址+4,callee rip
* flag - 8
* pt_regs - FRAME_SIZE(168)
但是它将pt_regs->origin_rax中的值放到了flag的位置,栈里的东西现在变成了这样:
* rip - 8,caller的下一条指令的地址,caller rip
* ------------------------------------
* rip - 8,目标函数地址+4,callee rip
* origin_rax - 8
* pt_regs - FRAME_SIZE(168)
再然后,它修改了当前的rsp寄存器,使其指向了栈中origin_rax的位置。注意,这个操作之后,origin_rax就处于栈顶的位置了。此时,再执行一个return指令,origin_rax就会被当做rip弹出,然后执行。而由于origin_rax中存放的是要进行direct call的函数的地址,因此它会直接跳转到direct call的地方。
这里我们可以看出来,它是通过在栈中伪造了一个新的rip,再进行return的方式来跳转到direct call的地方的,没有进行任何的call或者jmp指令。上面的汇编比我这里讲的要复杂,因为它要考虑RSB,即分支预测的情况,保证每个return指令都有一个对应的call指令。因为我们伪造了一个rip来进行return,上面的指令同时伪造了一个call,来保持平衡,确保分支预测的性能。
2.6 fgraph
在HOOK层面上来说,function graph相比于function trace,区别在于它会对目标函数的exit进行HOOK跟踪。函数entry的HOOK可以通过__fentry__来实现,那么如何HOOK函数的exit呢?这里是通过一个shadow stack来实现的,总体来说操作还是比较骚的。
在上面的2.1章节中我们可以看到,trampoline将caller rip在栈中的地址放到了pt_regs->rsp中。这意味着,我们是可以在callback里面直接修改栈中的caller rip的,fgraph就是这样来实现的。fgraph的callback函数为ftrace_graph_func,在该函数里面它会将caller rip修改为return_hooker函数:
void ftrace_graph_func(unsigned long ip, unsigned long parent_ip,
struct ftrace_ops *op, struct ftrace_regs *fregs)
{
struct pt_regs *regs = &arch_ftrace_regs(fregs)->regs;
unsigned long *stack = (unsigned long *)kernel_stack_pointer(regs);
unsigned long return_hooker = (unsigned long)&return_to_handler;
unsigned long *parent = (unsigned long *)stack;
if (unlikely(skip_ftrace_return()))
return;
if (!function_graph_enter_regs(*parent, ip, 0, parent, fregs))
*parent = return_hooker;
}
这样,在fgraph的callback执行完后,当前函数的栈帧就会变成这样:
* return_hooker - 8,caller的下一条指令的地址,caller rip
* ------------------------------------
* rip - 8,目标函数地址+4,callee rip
在callback执行完后,会return到目标函数继续执行。在目标函数callee执行完后,并不会return到caller上继续执行,因为栈里面的caller rip被改成了return_hooker函数。通过这种方式,ftrace实现了对函数exit的截获。return_hook的定义如下:
SYM_CODE_START(return_to_handler)
UNWIND_HINT_UNDEFINED
ANNOTATE_NOENDBR
/* Save ftrace_regs for function exit context */
subq $(FRAME_SIZE), %rsp
movq %rax, RAX(%rsp)
movq %rdx, RDX(%rsp)
movq %rbp, RBP(%rsp)
movq %rsp, %rdi
call ftrace_return_to_handler
通过修改caller rip的方式,ftrace实现了对callee的函数exit的截获。那么如何返回到caller rip继续执行呢?上面我们已经把栈里的caller rip信息给覆盖了。这里简单来说一下fgraph的shadow stack的概念。简单来说,就是每个进程上都用一个数组维护了一个栈,定义如下:
#ifdef CONFIG_FUNCTION_GRAPH_TRACER
/* Index of current stored address in ret_stack: */
int curr_ret_stack;
int curr_ret_depth;
/* Stack of return addresses for return function tracing: */
unsigned long *ret_stack;
/* Timestamp for last schedule: */
unsigned long long ftrace_timestamp;
unsigned long long ftrace_sleeptime;
/*
* Number of functions that haven't been traced
* because of depth overrun:
*/
atomic_t trace_overrun;
/* Pause tracing: */
atomic_t tracing_graph_pause;
#endif
ret_stack是一个unsigned long类型的数组。fgraph的callback在修改栈中的caller rip的时候,其实还做了一件事,就是把原来的caller rip保存在了这个ret_stack中。在return_to_handler -> ftrace_return_to_handler中,它会弹出ret_stack顶部的数据,并返回。可以看出来,ftrace_return_to_handler的返回值就是原始的caller rip。那么它下面要做的事情就简单了,只需要像direct call的时候一样,在当前的栈里使用ftrace_return_to_handler的返回值伪造一个rip并return,就可以恢复caller的继续执行了。之所以叫ret_stack为shadow stack,是因为它和函数的栈帧很像,并保持同步的:在函数被调用的时候,会进行入栈;函数返回的时候,进行出栈。
/* 把ftrace_return_to_handler的返回值保存到rdi寄存器中,并恢复rax和
* rdx寄存器,然后恢复栈帧到一开始的状态,即栈顶是rip的状态。随后,调用
* Ldo_rop,把返回值放到栈顶。这里能跳转到return_to_handler,是因为之前
* 把caller rip替换为了return_to_handler。
* caller rip <-> return_to_handler ip
* 在callee执行完毕后,会跳转到return_to_handler,这里会执行一次call,让
* 它的栈再次变成:
* caller rip
* 也就是说ftrace_return_to_handler返回的是保存的caller rip,这样程序
* 在return的时候就会跳转到caller rip,实现恢复。
*/
movq %rax, %rdi
movq RDX(%rsp), %rdx
movq RAX(%rsp), %rax
addq $(FRAME_SIZE), %rsp
/*
* Jump back to the old return address. This cannot be JMP_NOSPEC rdi
* since IBT would demand that contain ENDBR, which simply isn't so for
* return addresses. Use a retpoline here to keep the RSB balanced.
*/
ANNOTATE_INTRA_FUNCTION_CALL
call .Ldo_rop
int3
.Ldo_rop:
mov %rdi, (%rsp)
ALTERNATIVE __stringify(RET), \
__stringify(ANNOTATE_UNRET_SAFE; ret; int3), \
X86_FEATURE_CALL_DEPTH
SYM_CODE_END(return_to_handler)
三、注册流程
3.1 哈希表修改
上面的流程介绍了一个内核函数被ftrace给HOOK后的处理流程,这里我们再来看一下ftrace是如何注册到系统以及对内核函数进行HOOK的。每个ftrace_ops上都有几个哈希表,包括:
- filter_hash:当前的ftrace_ops要进行HOOK的内核函数都会在这个哈希表里,如果哈希表为空,说明是要HOOK所有的内核函数
- notrace_hash:当前的ftrace_ops不进行HOOK的内核函数
因此一个ftrace_ops在被创建出来后,需要先使用ftrace_set_filter_ips()接口来设置它要HOOK的内核函数。实际上,这个函数在ftrace_ops被注册前、后都可以被调用。如果还没注册,那么它只会更新ftrace的filter hash表;如果已经注册了,那么它还会调用ftrace_run_modify_code进行record的更新,也就是会触发__fentry__指令的修改。下面我们先来看一下还没有注册的情况吧。
ftrace_set_filter_ips会调用ftrace_set_addr -> ftrace_set_hash,所以我们直接看ftrace_set_hash的实现就行:
static int
ftrace_set_hash(struct ftrace_ops *ops, unsigned char *buf, int len,
unsigned long *ips, unsigned int cnt,
int remove, int reset, int enable, char *mod)
{
struct ftrace_hash **orig_hash;
struct ftrace_hash *hash;
int ret;
/* 这个函数的几个参数的含义分别为:
* remove: 将ips从ops的filter hash中移除
* reset: 将ops的filter hash重置为ips
* enable: 要操作的是filter hash还是notrace hash
*
* remove和reset都是0,enable为1,代表将ips加入到ops的filter hash
*/
if (unlikely(ftrace_disabled))
return -ENODEV;
mutex_lock(&ops->func_hash->regex_lock);
if (enable)
orig_hash = &ops->func_hash->filter_hash;
else
orig_hash = &ops->func_hash->notrace_hash;
/* 如果是reset,就分配一个新的哈希表;不是的话,就从原来的哈希表上拷贝一份。 */
if (reset)
hash = alloc_ftrace_hash(FTRACE_HASH_DEFAULT_BITS);
else
hash = alloc_and_copy_ftrace_hash(FTRACE_HASH_DEFAULT_BITS, *orig_hash);
if (!hash) {
ret = -ENOMEM;
goto out_regex_unlock;
}
/* 这里是通过字符串的方式来指定目标函数,字符串可以使用通配符等。 */
if (buf && !match_records(hash, buf, len, mod)) {
/* If this was for a module and nothing was enabled, flag it */
if (mod)
(*orig_hash)->flags |= FTRACE_HASH_FL_MOD;
/*
* Even if it is a mod, return error to let caller know
* nothing was added
*/
ret = -EINVAL;
goto out_regex_unlock;
}
/* 这里是通过函数地址数组的方式来指定要操作的函数列表。 */
if (ips) {
ret = ftrace_match_addr(hash, ips, cnt, remove);
if (ret < 0)
goto out_regex_unlock;
}
mutex_lock(&ftrace_lock);
ret = ftrace_hash_move_and_update_ops(ops, orig_hash, hash, enable);
mutex_unlock(&ftrace_lock);
out_regex_unlock:
mutex_unlock(&ops->func_hash->regex_lock);
free_ftrace_hash(hash);
return ret;
}
这个函数其实挺简单的,它会遍历ips数组,根据里面的ip找到对应的ftrace location(函数的__fentry__的地址不一定的函数地址,因为可能存在endbr指令),然后将location加入到哈希表中。
unsigned long ftrace_location(unsigned long ip)
{
unsigned long loc;
unsigned long offset;
unsigned long size;
/* 这里的ip分为两种情况,一种是基于mcount的(-pg),一种是基于__fentry__的
* (-mfentry)。
*
* 根据ip来从mcount record中进行对应实例的查找。这里先根据ip地址进行精确
* 查找,这里是根据mcount call指令的地址进行查找的。如果没找到,那么就按照
* 范围来查找,即查找处于当前的ip所属于的函数范围内的record,这里是根据
* ip是__fentry__来查找的。
*/
loc = ftrace_location_range(ip, ip);
if (!loc) {
if (!kallsyms_lookup_size_offset(ip, &size, &offset))
return 0;
/* map sym+0 to __fentry__ */
if (!offset)
loc = ftrace_location_range(ip, ip + size - 1);
}
return loc;
}
3.2 注册
ftrace_ops的注册是通过register_ftrace_function函数来实现的:
int register_ftrace_function(struct ftrace_ops *ops)
{
int ret;
lock_direct_mutex();
ret = prepare_direct_functions_for_ipmodify(ops);
if (ret < 0)
goto out_unlock;
ret = register_ftrace_function_nolock(ops);
out_unlock:
unlock_direct_mutex();
return ret;
}
这个函数会先调用prepare_direct_functions_for_ipmodify来检查是否存在ipmodify冲突的情况:
static int prepare_direct_functions_for_ipmodify(struct ftrace_ops *ops)
{
struct ftrace_func_entry *entry;
struct ftrace_hash *hash;
struct ftrace_ops *op;
int size, i, ret;
lockdep_assert_held_once(&direct_mutex);
/* 这里是针对像热补丁这种需要对callee rip进行修改的场景,避免它和direct call
* 产生冲突。设想一下,热补丁修改了栈上的callee rip想要调用新的函数,而
* BPF的trampoline(direct call)采用origin call的方式调用了原来老的函数,
* 这里就是为了避免这种冲突。
*
* 当这种情况发生后,它会调用bpf_tramp_ftrace_ops_func来进行这种情况的适配,
* 具体的适配逻辑这里不再赘述。
*/
if (!(ops->flags & FTRACE_OPS_FL_IPMODIFY))
return 0;
hash = ops->func_hash->filter_hash;
size = 1 << hash->size_bits;
for (i = 0; i < size; i++) {
hlist_for_each_entry(entry, &hash->buckets[i], hlist) {
unsigned long ip = entry->ip;
bool found_op = false;
mutex_lock(&ftrace_lock);
do_for_each_ftrace_op(op, ftrace_ops_list) {
if (!(op->flags & FTRACE_OPS_FL_DIRECT))
continue;
if (ops_references_ip(op, ip)) {
found_op = true;
break;
}
} while_for_each_ftrace_op(op);
mutex_unlock(&ftrace_lock);
if (found_op) {
if (!op->ops_func)
return -EBUSY;
ret = op->ops_func(op, FTRACE_OPS_CMD_ENABLE_SHARE_IPMODIFY_PEER);
if (ret)
return ret;
}
}
}
return 0;
}
检查没问题后,再调用ftrace_startup继续注册:
int ftrace_startup(struct ftrace_ops *ops, int command)
{
int ret;
/* 这里的ftrace_disabled只有在ftrace系统异常的时候,才会置位 */
if (unlikely(ftrace_disabled))
return -ENODEV;
/* 将当前的ops加入到全局链表ftrace_ops_list中;为当前的ops生成对应的ftrace的
* trampoline;根据目前所有的ops的情况,来更新变量ftrace_trace_function,
* 这个变量指向了全局(默认)的callback函数。这个变量后面会被使用。
*/
ret = __register_ftrace_function(ops);
if (ret)
return ret;
ftrace_start_up++;
/*
* Note that ftrace probes uses this to start up
* and modify functions it will probe. But we still
* set the ADDING flag for modification, as probes
* do not have trampolines. If they add them in the
* future, then the probes will need to distinguish
* between adding and updating probes.
*/
ops->flags |= FTRACE_OPS_FL_ENABLED | FTRACE_OPS_FL_ADDING;
/* 检查当前的ops在ipmodify和direct call方面和每个record是否兼容,并更新
* 对应的record上的IPMODIFY标志。
*/
ret = ftrace_hash_ipmodify_enable(ops);
if (ret < 0) {
/* Rollback registration process */
__unregister_ftrace_function(ops);
ftrace_start_up--;
ops->flags &= ~FTRACE_OPS_FL_ENABLED;
if (ops->flags & FTRACE_OPS_FL_DYNAMIC)
ftrace_trampoline_free(ops);
return ret;
}
/* 利用当前的ops来更新对应的目标函数的record。如果ops的filter为空,那么
* 针对的目标就是所有的内核函数。这一步很重要,因为最后对内核函数进行HOOK的时候,
* 是直接遍历系统中所有的record来进行的,那个时候已经和当前的这个ops没关系了。
*/
if (ftrace_hash_rec_enable(ops))
command |= FTRACE_UPDATE_CALLS;
/* 遍历所有的record,根据其状态进行刷新。 */
ftrace_startup_enable(command);
/*
* If ftrace is in an undefined state, we just remove ops from list
* to prevent the NULL pointer, instead of totally rolling it back and
* free trampoline, because those actions could cause further damage.
*/
if (unlikely(ftrace_disabled)) {
__unregister_ftrace_function(ops);
return -ENODEV;
}
ops->flags &= ~FTRACE_OPS_FL_ADDING;
return 0;
}
3.3 更新record
record的更新是通过上面的函数的ftrace_hash_rec_enable -> __ftrace_hash_rec_update来进行的,其做的事情主要包括:
static bool __ftrace_hash_rec_update(struct ftrace_ops *ops,
bool inc)
{
struct ftrace_hash *hash;
struct ftrace_hash *notrace_hash;
struct ftrace_page *pg;
struct dyn_ftrace *rec;
bool update = false;
int count = 0;
int all = false;
/* 使用当前的ops来更新rec。这里是通过遍历所有的rec,并判断其是否在ops的哈希表
* 中来实现的。
*
* 这里做的事情包括,对于新增的record:
* - 如果当前的ops是direct call,那么就给record加上FTRACE_FL_DIRECT的标志
* - 如果当前的record上面的ops只有一个,且这个ops存在trampoline,那就就给
* record加上FTRACE_FL_TRAMP的标志。这个标志会使得被HOOK的函数直接调用
* trampoline,而不是链表trampoline。否则,删除这个标志。
*
* 对于删除的record,不再赘述,逻辑和上面是反着的,即清除一些标志。
*/
/* Only update if the ops has been registered */
if (!(ops->flags & FTRACE_OPS_FL_ENABLED))
return false;
......
}
3.4 应用record
record更新完后,下面就是开始进行内核函数的HOOK了,具体的代码为ftrace_startup_enable -> ftrace_run_update_code -> arch_ftrace_update_code -> ftrace_modify_all_code。这个函数首先会调用update_ftrace_func来更新全局(默认,链表)callback函数,具体来说就是修改ftrace_caller和ftrace_regs_caller两个函数调用callback的地方,使其调用ftrace_ops_list_func所保存的函数。这里是直接进行的代码指令修改。
void ftrace_modify_all_code(int command)
{
int update = command & FTRACE_UPDATE_TRACE_FUNC;
int mod_flags = 0;
int err = 0;
if (command & FTRACE_MAY_SLEEP)
mod_flags = FTRACE_MODIFY_MAY_SLEEP_FL;
/*
* If the ftrace_caller calls a ftrace_ops func directly,
* we need to make sure that it only traces functions it
* expects to trace. When doing the switch of functions,
* we need to update to the ftrace_ops_list_func first
* before the transition between old and new calls are set,
* as the ftrace_ops_list_func will check the ops hashes
* to make sure the ops are having the right functions
* traced.
*/
/* 这里用于修改全局(默认)ftrace_caller & ftrace_regs_caller中的callback
* 函数,将其修改为链表ops。
*/
if (update) {
err = update_ftrace_func(ftrace_ops_list_func);
if (FTRACE_WARN_ON(err))
return;
}
if (command & FTRACE_UPDATE_CALLS)
/* 正常情况下,是会走这个路径的 */
ftrace_replace_code(mod_flags | FTRACE_MODIFY_ENABLE_FL);
else if (command & FTRACE_DISABLE_CALLS)
ftrace_replace_code(mod_flags);
/* 这是一个更新操作,但是ftrace_trace_function中存储的不是链表ops(可能是
* ftrace_stub),那么就用ftrace_trace_function来进行更新。
*/
if (update && ftrace_trace_function != ftrace_ops_list_func) {
function_trace_op = set_function_trace_op;
smp_wmb();
/* If irqs are disabled, we are in stop machine */
if (!irqs_disabled())
smp_call_function(ftrace_sync_ipi, NULL, 1);
err = update_ftrace_func(ftrace_trace_function);
if (FTRACE_WARN_ON(err))
return;
}
if (command & FTRACE_START_FUNC_RET)
err = ftrace_enable_ftrace_graph_caller();
else if (command & FTRACE_STOP_FUNC_RET)
err = ftrace_disable_ftrace_graph_caller();
FTRACE_WARN_ON(err);
}
随后,会调用ftrace_replace_code来遍历所有的内核record,再调用__ftrace_replace_code进行内核函数的HOOK:
static int
__ftrace_replace_code(struct dyn_ftrace *rec, bool enable)
{
unsigned long ftrace_old_addr;
unsigned long ftrace_addr;
int ret;
/* 获取当前的record将要使用的处理函数,这里指的是直接将nop替换为call xxx的函数,
* 可能是ftrace的trampoline,也可能是ftrace托管的BPF的trampoline(direct call)。
*/
ftrace_addr = ftrace_get_addr_new(rec);
/* This needs to be done before we call ftrace_update_record */
ftrace_old_addr = ftrace_get_addr_curr(rec);
/* 通过检查record当前已经应用的flags(有EN后缀)和将要设置的flag,来判断需要对
* 这个record进行哪种操作,包括:
*
* FTRACE_UPDATE_MAKE_CALL:原来没有进行过HOOK,现在要进行HOOK
* FTRACE_UPDATE_MAKE_NOP:原来进行了HOOK,现在要取消HOOK
* FTRACE_UPDATE_MODIFY_CALL:原来进行了HOOK,现在要修改处理函数
*
* 具体的指令修改比较简单,就是进行指令替换,这里不再赘述。
*/
ret = ftrace_update_record(rec, enable);
ftrace_bug_type = FTRACE_BUG_UNKNOWN;
switch (ret) {
case FTRACE_UPDATE_IGNORE:
return 0;
case FTRACE_UPDATE_MAKE_CALL:
ftrace_bug_type = FTRACE_BUG_CALL;
return ftrace_make_call(rec, ftrace_addr);
case FTRACE_UPDATE_MAKE_NOP:
ftrace_bug_type = FTRACE_BUG_NOP;
return ftrace_make_nop(NULL, rec, ftrace_old_addr);
case FTRACE_UPDATE_MODIFY_CALL:
ftrace_bug_type = FTRACE_BUG_UPDATE;
return ftrace_modify_call(rec, ftrace_old_addr, ftrace_addr);
}
return -1; /* unknown ftrace bug */
}
对于什么时候使用链表ops(性能较低),什么时候直接调用ftrace_ops的trampoline,这个要看ftrace_get_addr_new的处理逻辑:
unsigned long ftrace_get_addr_new(struct dyn_ftrace *rec)
{
struct ftrace_ops *ops;
unsigned long addr;
/* 查找当前record将要使用的处理函数。如果当前的函数注册了direct_call,并且只有
* 一个处理函数,那么这里会直接将call这个函数作为nop指令的替换。
*/
if ((rec->flags & FTRACE_FL_DIRECT) &&
(ftrace_rec_count(rec) == 1)) {
addr = ftrace_find_rec_direct(rec->ip);
if (addr)
return addr;
WARN_ON_ONCE(1);
}
/* 只有一个ops且存在trampoline,那么就直接调用这个ops的trampoline。 */
if (rec->flags & FTRACE_FL_TRAMP) {
ops = ftrace_find_tramp_ops_new(rec);
if (FTRACE_WARN_ON(!ops || !ops->trampoline)) {
pr_warn("Bad trampoline accounting at: %p (%pS) (%lx)\n",
(void *)rec->ip, (void *)rec->ip, rec->flags);
/* Ftrace is shutting down, return anything */
return (unsigned long)FTRACE_ADDR;
}
return ops->trampoline;
}
/* 这里应该是全局性质的,调用的是list_ops的形式。所以,只有三种情况:
* 直接调用direct
* 直接调用trampoline
* 调用list_ops
*/
if (rec->flags & FTRACE_FL_REGS)
return (unsigned long)FTRACE_REGS_ADDR;
else
return (unsigned long)FTRACE_ADDR;
}

5037

被折叠的 条评论
为什么被折叠?



