FTRACE内核实现浅析

一、基本原理

今天来重点说一下ftrace子系统在trampoline这块的具体实现。但是在展开之前,我们还是需要先来说一下ftrace功能的整个实现流程,不然会看的不明所以。这里直接借鉴之前也得一篇文章中的描述。照使用方式的不同,ftrace分为静态trace和动态traceDYNAMIC 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;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值