BPF内核实现之TRAMPOLINE

一、FTRACE概述

BPF中的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

2.1 基本流程

按照以上的分析,我们可以看出来可以将BPF应用到这个机制上来:将nop指令替换成调用BPF程序的指令。这是一种比较简单的方式,但是存在一定的问题:当有多个BPF程序跟踪同一个内核函数的时候怎么办?因此这里需要一种更加灵活的机制,这就引入了BPF的TRAMPOLINE机制,其基本原理如下图所示:

在这里插入图片描述

可以看出来,这里为我们要跟踪的函数生成了一个TRAMPOLINE函数,在这个函数里面会调用我们加载的BPF程序。TRAMPOLINE的出现使得BPF程序获得了极大的灵活性,主要体现在以下几方面:

  • 可以以一种更简洁的方式来获取被跟踪的函数的参数。在以前的基于kprobe的BPF中,我们只能获取到kprobe保存下来的寄存器信息,并从寄存器上手动地获取函数参数值,很不方便。
  • 可以在跟踪函数返回值的同时,获取函数的参数。在以前的基于kretprobe的BPF中,我们只能获取到函数的返回值,不能获取到函数参数,这对于一些场景下的内核trace很不方便。
  • 可以修改被跟踪函数的返回值,实现类似热补丁的功能。

下面我们就来揭开TRAMPOLINE的面纱,看看它到底是如何实现以上的那些功能的,这里我们以BPF_PROG_TYPE_TRACING类型的BPF程序为例来进行探索。关于该类型的BPF程序的介绍,可以查看我的另一篇博客的介绍:eBPF内核实现之TRACINGTRAMPOLINE的本质是动态生成的可执行指令(机器码),对于TRACING类型的BPF程序,其是在以下的代码路径中生成的:

link_create → bpf_tracing_prog_attach → bpf_trampoline_link_prog → bpf_trampoline_update → arch_prepare_bpf_trampoline

bpf_tracing_prog_attach函数中,内核会尝试查找或者创建当前目标函数对应的bpf_trampoline实例,这个是在bpf_trampoline_get函数中实现的。所有的bpf_trampoline实例都存在在一个全局哈希表中,通过目标函数来进行哈希查找。该实例上以链表的形式保存了加载在当前目标函数上的所有的BPF实例,在tr->progs_hlist字段中。

在这里插入图片描述

每个bfp_trampoline实例上又会分配一个image实例,该实例就是用来存放动态生成的trampoline可执行指令的,在bpf_trampoline_update函数中会进行image的创建和内存分配。

2.2 函数调用

在此之前我们先简单回顾一下x86_64的函数调用和函数栈帧的基本格式吧。这里我们假设发生了A → B的函数调用,那么在函数A中会执行call B的指令,如下图所示:

在这里插入图片描述

call B这条指令会将当前rip指令中的值压入到栈中,同时跳转到B函数的第一条指令开始执行。其中,rip寄存器中存储的是A函数中call B后一条指令的地址。在B函数中,会先将rbp寄存器入栈保护起来,然后将rsp寄存器保存到rbp寄存器中。这里可以看出来,rbp寄存器中存放的是当前函数栈帧的栈顶地址。随后,根据需要进行栈空间的分配,比如为函数的实参分配空间。在B函数执行完成后执行leave指令,该指令的效果等同于下面的两条指令:

mov rsp, rbp
pop rbp

最后调用ret指令,该指令会pop rip并继续执行A中后面的指令。这个是通过事先将rip寄存器保存到栈中实现的,这个过程中A/B函数的栈帧如下图所示:

在这里插入图片描述

2.3 指令范例

BPF TRAMPOLINE的最终生成是在arch_prepare_bpf_trampoline函数中的,这是个架构相关的函数,因为生成出来的是在当前架构、当前机器上可以直接运行的机器码。这里我们以x86_64为例来具体看看是怎么生成对应的指令的。在该函数的注释中,官方已经为我们提供了一个案例,该案例以跟踪eth_type_trans(struct sk_buff *skb, struct net_device *dev)函数为例讲解了对应的trampoline指令。假如我们使用FENTRY类型的BPF程序跟踪了这个内核函数,那么为其生成的TRAMPOLINE指令如下所示:

push rbp
mov rbp, rsp
sub rsp, 16                     // 用于存放函数参数的栈空间
push rbx                        // 保存rbx寄存器
mov qword ptr [rbp - 16], rdi   // 将参数1(skb)保存到栈中
mov qword ptr [rbp - 8], rsi    // 将参数2(dev)保存到栈中
call __bpf_prog_enter           // 调用__bpf_prog_enter进行一些准备工作,包括rcu加锁和关抢占
mov rbx, rax                    // 将__bpf_prog_enter的返回值保存到rbx寄存器中
lea rdi, [rbp - 16]             // 将[rbp-16]作为ctx传递给BPF程序
call addr_of_jited_FENTRY_prog  // 调用BPF程序
movabsq rdi, 64bit_addr_of_struct_bpf_prog
mov rsi, rbx                    // 将__bpf_prog_enter的返回值作为参数传递给__bpf_prog_exit
call __bpf_prog_exit            // 调用__bpf_prog_exit
mov rdi, qword ptr [rbp - 16]   // 将参数1恢复到寄存器中
mov rsi, qword ptr [rbp - 8]    // 将参数2恢复到寄存器中
pop rbx
leave
ret                             // 返回到eth_type_trans()函数体中

这个指令相对还算简单,它首先根据需要计算出要使用的栈空间。随后,将一些信息保存到栈中,包括函数的实参。紧接着,它将存放函数参数的栈的地址作为参数传递给了BPF程序。这样,在FENTRY中就可以将ctx作为一个u64 []来获取到被跟踪函数的参数了,相比于要从pt_regs中获取参数的kprobe要方便的多。下面我们还是以该函数为例,来讲解一下对于FEXIT类型的BPF所生成的TRAMPOLINE函数。

push rbp
mov rbp, rsp
sub rsp, 24                     // 用于存放函数参数的栈空间
push rbx                        // 保存rbx寄存器
mov qword ptr [rbp - 24], rdi   // 将参数1(skb)保存到栈中
mov qword ptr [rbp - 16], rsi   // 将参数2(dev)保存到栈中
call __bpf_prog_enter           // 调用__bpf_prog_enter进行一些准备工作,包括rcu加锁和关抢占
mov rbx, rax                    // 将__bpf_prog_enter的返回值保存到rbx寄存器中
lea rdi, [rbp - 24]             // 将[rbp-16]作为ctx传递给BPF程序
call addr_of_jited_FENTRY_prog  // 调用BPF程序
movabsq rdi, 64bit_addr_of_struct_bpf_prog  // unused if bpf stats are off
mov rsi, rbx                    // 将__bpf_prog_enter的返回值作为参数传递给__bpf_prog_exit
call __bpf_prog_exit            // 调用__bpf_prog_exit
mov rdi, qword ptr [rbp - 24]   // 将参数1恢复到寄存器中
mov rsi, qword ptr [rbp - 16]   // 将参数2恢复到寄存器中
call eth_type_trans+5           // 调用eth_type_trans的函数体。
mov qword ptr [rbp - 8], rax    // 保存eth_type_trans的返回值
call __bpf_prog_enter           // rcu_read_lock and preempt_disable
mov rbx, rax                    // remember start time in bpf stats are enabled
lea rdi, [rbp - 24]             // R1==ctx of bpf prog
call addr_of_jited_FEXIT_prog   // 调用FEXIT类型的BPF程序
movabsq rdi, 64bit_addr_of_struct_bpf_prog  // unused if bpf stats are off
mov rsi, rbx                    // prog start time
call __bpf_prog_exit            // rcu_read_unlock, preempt_enable and stats math
mov rax, qword ptr [rbp - 8]    // 将eth_type_trans函数的返回值放到rax寄存器中,作为TRAMPOLINE的返回值
pop rbx
leave
add rsp, 8                      // 跳过eth_type_trans函数的栈帧
ret                             // 直接返回到eth_type_trans的调用者那里

相比于FENTRY,这里的TRAMPOLINE有两点不一样的地方:

  1. 在TRAMPOLINE函数里会调用目标函数(eth_type_trans)的函数体。eth_type_trans函数的第一个指令是5个字节的nop指令,那么eth_type_trans+5就是函数体的地址。这里会直接在TRAMPOLINE里调用函数体,并将函数的返回值保存到栈中,连同函数参数传递给FEXIT类型的BPF程序。这也是为什么在FEXIT类型的BPF程序里可以同时获取到被跟踪函数的返回值和函数的参数。
  2. 在最后,通过将rsp寄存器加8个字节,也就是将栈顶往上(栈是自顶向下)移动8个字节,来跳过了目标函数eth_type_trans的栈帧。通过这种方式,可以直接返回到eth_type_trans的调用者那里,而不是返回到eth_type_trans中继续执行函数体。这个过程中,调用栈的栈帧情况如下图所示。可以看出来,只需要将栈顶上移8字节即可跳过eth_type_trans的栈帧。

在这里插入图片描述

2.4 指令生成

下面我们来看一下arch_prepare_bpf_trampoline函数中是如何生成上面的指令代码的。这个函数首先做的事情是计算当前TRAMPOLINE需要用到的栈空间的大小,以便将rsp寄存器减少对应的大小。下面的代码就是用来计算栈空间大小的逻辑:

int arch_prepare_bpf_trampoline(struct bpf_tramp_image *im, void *image, void *image_end,
				const struct btf_func_model *m, u32 flags,
				struct bpf_tramp_links *tlinks,
				void *func_addr)
{
	int i, ret, nr_regs = m->nr_args, stack_size = 0;
	int regs_off, nregs_off, ip_off, run_ctx_off, arg_stack_off, rbx_off;
	struct bpf_tramp_links *fentry = &tlinks[BPF_TRAMP_FENTRY];
	struct bpf_tramp_links *fexit = &tlinks[BPF_TRAMP_FEXIT];
	struct bpf_tramp_links *fmod_ret = &tlinks[BPF_TRAMP_MODIFY_RETURN];
	void *orig_call = func_addr;
	u8 **branches = NULL;
	u8 *prog;
	bool save_ret;

	/* nr_regs是用于存储函数参数所用到的寄存器的个数。对于函数参数没有结构体的情况,
	 * 其个数与参数个数相同;否则,要考虑结构体的大小。下面的代码就是考虑在存在结构体
	 * 的情况下,需要的寄存器的个数。
	 */
	for (i = 0; i < m->nr_args; i++)
		if (m->arg_flags[i] & BTF_FMODEL_STRUCT_ARG)
			nr_regs += (m->arg_size[i] + 7) / 8 - 1;

	/* x86_64目前最多可以支持跟踪12个参数的内核函数。在参数个数超过6个时,多余的
	 * 参数需要通过栈传参的方式来进行传递。
	 */
	if (nr_regs > MAX_BPF_FUNC_ARGS)
		return -ENOTSUPP;

	/* trampoline的栈布局。这里的trampoline应该是指的BPF程序的入口函数,即
	 * 对于ftrace,用来取代nop的函数。
	 */

	/* 用于存储函数返回值的栈空间,在需要在TRAMPOLINE中调用函数体(FEXIT的情况)
	 * 的时候需要这部分的空间。
	 */
	save_ret = flags & (BPF_TRAMP_F_CALL_ORIG | BPF_TRAMP_F_RET_FENTRY_RET);
	if (save_ret)
		stack_size += 8;

	/* 预留的存储函数参数的栈空间。每个寄存器大小为8字节,因此nr_regs*8就是需要
	 * 的空间大小。
	 */
	stack_size += nr_regs * 8;
	regs_off = stack_size;

	/* 存储参数个数到栈空间,用于使用helper函数bpf_get_func_arg_cnt()来获取
	 * 目标函数的参数个数。
	 */
	stack_size += 8;
	nregs_off = stack_size;

	/* 用于存储目标函数的地址到栈空间,用于使用helper函数bpf_get_func_ip()来
	 * 获取目标函数的地址。
	 */
	if (flags & BPF_TRAMP_F_IP_ARG)
		stack_size += 8; /* room for IP address argument */

	ip_off = stack_size;

	/* 用来临时保存rbx寄存器的值 */
	stack_size += 8;
	rbx_off = stack_size;

	/* 用于存储bpf_tramp_run_ctx结构体。这个结构体会在一些函数中作为上下文被
	 * 使用到。
	 */
	stack_size += (sizeof(struct bpf_tramp_run_ctx) + 7) & ~0x7;
	run_ctx_off = stack_size;

以上的逻辑比较简单,没有什么弯弯绕绕,就是根据需要来计算当前TRAMPOLINE函数的栈帧大小。到目前为止,栈空间需要预留的内存如下图所示:

在这里插入图片描述

在进行BPF程序调用的时候,内核会把bpf ctx处的地址作为参数传递给BPF程序。通过这个地址,BPF程序即可访问到函数的入参或者函数的返回值(对于FEXIT类型的BPF程序)。下面的逻辑稍微复杂一点,这里我们需要先了解一些x86架构下寄存器传值和栈传值的知识。

在函数的形参个数不超过6个的时候,编译器会通过寄存器的方式来传参。由于x86架构下用于传参的寄存器个数只有6个,因此在参数个数多余6个的时候就要采用寄存器+栈来进行传参了。举个例子,函数bar()存在9个参数,如下所示:

extern void bar(char, char, char, char, char, char, char, char, char);
int foo(void)
{
    bar(0, 1, 2, 3, 4, 5, 6, 7, 8);
    return 0;
}

为了便于学习函数传参的过程,这里我们使用-O0的优化选项来生成函数foo()对应的x86指令:

foo:
  push rbp
  mov rbp, rsp
  sub rsp, 8
  push 8
  push 7
  push 6
  mov r9d, 5
  mov r8d, 4
  mov ecx, 3
  mov edx, 2
  mov esi, 1
  mov edi, 0
  call bar
  add rsp, 32
  mov eax, 0
  leave
  ret

可以看出来,对于前6个参数(0-5),编译器将其保存到了传参寄存器中,通过寄存器来传参;对于后三个参数,编译器通过push指令将其压入到栈中,通过栈进行传参。这里有一点需要注意,就是当存在栈传参的时候,当前函数的栈帧大小要16字节对齐。这也是上面为什么会有一条sub rsp, 8的指令,这条指令会使得当前栈帧留下8字节的未使用的空洞,从而保证了栈帧的16字节对齐。在调用函数bar()的时候,foo()函数的栈帧如下所示:

在这里插入图片描述

那么在进行栈传参的时候为什么要保持16字节对齐栈帧呢?这是因为在拷贝栈中的参数的时候,可以使用效率较高的movaps指令。该指令用于执行对齐的128位数据传输,可以一次性拷贝16字节的数据。

除了栈帧16字节对齐以外,还有个需要考虑的场景,即函数的参数类型包含结构体或者长度大于8字节的数据类型。这里有个原则,即如果剩余可用的传参寄存器可以容纳该参数的大小,那么优先使用寄存器传参;否则,使用栈传参。一个大于8字节的参数要么全部在寄存器中,要么全部在栈中。再举个例子,对于下面的bar()函数,第六个参数a会通过栈来进行传递;第7个参数会通过寄存器来进行传递(因为还剩一个空闲的传参寄存器可以容纳第7个参数)。

struct a {
    long a1;
    long a2;
};

extern void bar(char, char, char, char, char, struct a, char);
int foo(struct a a)
{
    bar(0, 1, 2, 3, 4, a, 5);
    return 0;
}

这就意味着我们不能单纯地认为寄存器中的参数永远排在栈中的参数的前面,要根据具体的函数情况来进行判断。下面我们来继续看TRAMPOLINE后面的代码逻辑。下面的代码保证了我们在TRAMPOLINE中调用函数体且需要通过栈传参的时候,当前TRAMPOLINE函数栈帧保持16字节对齐的原则。

	if (nr_regs > 6 && (flags & BPF_TRAMP_F_CALL_ORIG)) {
		/* 存在栈传参的情况,且我们需要在当前TRAMPOLINE中调用函数体,那么
		 * 我们需要将栈中的参数由上一级栈帧拷贝到当前TRAMPOLINE的栈中。
		 * 这里是为栈传参所保留的栈空间,其中get_nr_used_regs()获取的是
		 * 通过寄存器传参的参数个数。
		 */
		stack_size += (nr_regs - get_nr_used_regs(m)) * 8;
		/* 存在栈传参的情况,且我们需要在当前TRAMPOLINE中调用函数体。这种情
		 * 况下,需要保证当前TRAMPOLINE的栈帧是16字节对齐的。
		 */
		stack_size += (stack_size % 16) ? 0 : 8;
	}

	arg_stack_off = stack_size;

	/* 计算函数体的地址。通常情况下,是目标函数地址+5(nop)。 */
	if (flags & BPF_TRAMP_F_SKIP_FRAME) {
		/* skip patched call instruction and point orig_call to actual
		 * body of the kernel function.
		 */
		if (is_endbr(*(u32 *)orig_call))
			orig_call += ENDBR_INSN_SIZE;
		orig_call += X86_PATCH_SIZE;
	}

这里我们简单看一下get_nr_used_regs()函数的实现。这个函数用于获取目标函数在传参过程中使用到的传参寄存器个数,它会按照函数声明中的参数顺序来检查剩余的传参寄存器是否可以容纳当前参数,直到传参寄存器耗尽(6个用完)。

static int get_nr_used_regs(const struct btf_func_model *m)
{
	int i, arg_regs, nr_used_regs = 0;

	for (i = 0; i < min_t(int, m->nr_args, MAX_BPF_FUNC_ARGS); i++) {
		arg_regs = (m->arg_size[i] + 7) / 8;
		if (nr_used_regs + arg_regs <= 6)
			nr_used_regs += arg_regs;

		if (nr_used_regs >= 6)
			break;
	}

	return nr_used_regs;
}

至此,TRAMPOLINE栈空间的大小算是计算完成了,下面就是生成对应的机器码了。下面的代码生成的指令包括:

  1. 保存rbp寄存器,并保存当前栈帧的栈顶到rbp寄存器
  2. 按照计算出来的栈帧大小,将栈顶往下移动:sub rsp, stack_size
  3. 保存rbx寄存器到栈中
  4. 保存参数个数到栈中
  5. 需要的话,保存当前目标函数的地址到栈中
	EMIT_ENDBR();
	/*
	 * This is the direct-call trampoline, as such it needs accounting
	 * for the __fentry__ call.
	 */
	x86_call_depth_emit_accounting(&prog, NULL);
	EMIT1(0x55);		 /* push rbp */
	EMIT3(0x48, 0x89, 0xE5); /* mov rbp, rsp */
	if (!is_imm8(stack_size))
		/* sub rsp, stack_size */
		EMIT3_off32(0x48, 0x81, 0xEC, stack_size);
	else
		/* sub rsp, stack_size */
		EMIT4(0x48, 0x83, 0xEC, stack_size);
	/* mov QWORD PTR [rbp - rbx_off], rbx */
	emit_stx(&prog, BPF_DW, BPF_REG_FP, BPF_REG_6, -rbx_off);

	/* Store number of argument registers of the traced function:
	 *   mov rax, nr_regs
	 *   mov QWORD PTR [rbp - nregs_off], rax
	 */
	emit_mov_imm64(&prog, BPF_REG_0, 0, (u32) nr_regs);
	emit_stx(&prog, BPF_DW, BPF_REG_FP, BPF_REG_0, -nregs_off);

	if (flags & BPF_TRAMP_F_IP_ARG) {
		/* Store IP address of the traced function:
		 * movabsq rax, func_addr
		 * mov QWORD PTR [rbp - ip_off], rax
		 */
		emit_mov_imm64(&prog, BPF_REG_0, (long) func_addr >> 32, (u32) (long) func_addr);
		emit_stx(&prog, BPF_DW, BPF_REG_FP, BPF_REG_0, -ip_off);
	}

随后,生成对应的指令用于将所有的目标函数的参数保存到栈中regs_off的位置处。这里用于保存参数的栈的地址会作为BPF程序的入参,通过该入参BPF程序可以访问到目标函数的参数。这里我们详细看一下对应的代码逻辑。整个逻辑是在save_args()函数中实现的,这个函数会遍历目标函数的所有的参数,确定该参数是存放在寄存器中还是栈中。如果存放在传参寄存器中,那么就生成对应的指令,直接将寄存器的值保存到当前栈帧对应的位置;如果存放在栈中,那么计算出其在上一级栈帧中的位置,并生成对应的指令,将其拷贝到当前栈帧对应的位置中。

static void save_args(const struct btf_func_model *m, u8 **prog,
		      int stack_size, bool for_call_origin)
{
	int arg_regs, first_off = 0, nr_regs = 0, nr_stack_slots = 0;
	int i, j;

	/* Store function arguments to stack.
	 * For a function that accepts two pointers the sequence will be:
	 * mov QWORD PTR [rbp-0x10],rdi
	 * mov QWORD PTR [rbp-0x8],rsi
	 */
	for (i = 0; i < min_t(int, m->nr_args, MAX_BPF_FUNC_ARGS); i++) {
		arg_regs = (m->arg_size[i] + 7) / 8;

		/* According to the research of Yonghong, struct members
		 * should be all in register or all on the stack.
		 * Meanwhile, the compiler will pass the argument on regs
		 * if the remaining regs can hold the argument.
		 *
		 * Disorder of the args can happen. For example:
		 *
		 * struct foo_struct {
		 *     long a;
		 *     int b;
		 * };
		 * int foo(char, char, char, char, char, struct foo_struct,
		 *         char);
		 *
		 * the arg1-5,arg7 will be passed by regs, and arg6 will
		 * by stack.
		 */
		if (nr_regs + arg_regs > 6) {
			/* copy function arguments from origin stack frame
			 * into current stack frame.
			 *
			 * The starting address of the arguments on-stack
			 * is:
			 *   rbp + 8(push rbp) +
			 *   8(return addr of origin call) +
			 *   8(return addr of the caller)
			 * which means: rbp + 24
			 */
			for (j = 0; j < arg_regs; j++) {
				emit_ldx(prog, BPF_DW, BPF_REG_0, BPF_REG_FP,
					 nr_stack_slots * 8 + 0x18);
				emit_stx(prog, BPF_DW, BPF_REG_FP, BPF_REG_0,
					 -stack_size);

				if (!nr_stack_slots)
					first_off = stack_size;
				stack_size -= 8;
				nr_stack_slots++;
			}
		} else {
			/* Only copy the arguments on-stack to current
			 * 'stack_size' and ignore the regs, used to
			 * prepare the arguments on-stack for orign call.
			 */
			if (for_call_origin) {
				nr_regs += arg_regs;
				continue;
			}

			/* copy the arguments from regs into stack */
			for (j = 0; j < arg_regs; j++) {
				emit_stx(prog, BPF_DW, BPF_REG_FP,
					 nr_regs == 5 ? X86_REG_R9 : BPF_REG_1 + nr_regs,
					 -stack_size);
				stack_size -= 8;
				nr_regs++;
			}
		}
	}

	clean_stack_garbage(m, prog, nr_stack_slots, first_off);
}

对于需要在TRAMPOLINE中调用函数体的情况,在执行BPF程序之前先调用__bpf_tramp_enter()进行准备工作:

	if (flags & BPF_TRAMP_F_CALL_ORIG) {
		/* arg1: mov rdi, im */
		emit_mov_imm64(&prog, BPF_REG_1, (long) im >> 32, (u32) (long) im);
		if (emit_rsb_call(&prog, __bpf_tramp_enter, prog)) {
			ret = -EINVAL;
			goto cleanup;
		}
	}

遍历所有的FENTRY和MODIFY_RETURN类型的BPF程序,调用invoke_bpf_prog()函数类为每个BPF程序生成对应的调用指令。这里不再详细展开,其核心在于将rbp - regs_off的值拷贝到rdi寄存器(1号传参寄存器)中,并调用对应的BPF程序。

对于需要call origin的情况(TRAMPOLINE中调用函数体),其生成的指令包括以下几部分:

  1. regs_off偏移中恢复传参寄存器中的数据
  2. 将通过栈传递的参数保存到当前的栈顶,用于传递给目标函数体。这里也是通过save_args()函数来实现的,相比于上面的保存所有的参数到栈中,这里只需要忽略寄存器中的参数,只保存栈中的参数即可
  3. 调用目标函数体
  4. 保存返回值(在rax寄存器中)到栈中
	if (flags & BPF_TRAMP_F_CALL_ORIG) {
		restore_regs(m, &prog, regs_off);
		save_args(m, &prog, arg_stack_off, true);

		if (flags & BPF_TRAMP_F_ORIG_STACK) {
			emit_ldx(&prog, BPF_DW, BPF_REG_0, BPF_REG_FP, 8);
			EMIT2(0xff, 0xd0); /* call *rax */
		} else {
			/* call original function */
			if (emit_rsb_call(&prog, orig_call, prog)) {
				ret = -EINVAL;
				goto cleanup;
			}
		}
		/* remember return value in a stack for bpf prog to access */
		emit_stx(&prog, BPF_DW, BPF_REG_FP, BPF_REG_0, -8);
		im->ip_after_call = prog;
		memcpy(prog, x86_nops[5], X86_PATCH_SIZE);
		prog += X86_PATCH_SIZE;
	}

最后,为FEXIT类型的BPF程序生成对应的调用指令。可以看出来,此时生成的指令已经调用了目标函数的函数体,并拿到了函数的返回值。因此,在FEXIT类型的BPF程序里我们是可以同时访问到目标函数的参数和返回值的。

	if (fexit->nr_links)
		if (invoke_bpf(m, &prog, fexit, regs_off, run_ctx_off, false)) {
			ret = -EINVAL;
			goto cleanup;
		}

	/* 对于不存在FEXIT和MODIFY_RETURN类型的BPF程序的时候才会有这个标志,用于
	 * 恢复寄存器中的值。
	 */
	if (flags & BPF_TRAMP_F_RESTORE_REGS)
		restore_regs(m, &prog, regs_off);

	/* This needs to be done regardless. If there were fmod_ret programs,
	 * the return value is only updated on the stack and still needs to be
	 * restored to R0.
	 */
	if (flags & BPF_TRAMP_F_CALL_ORIG) {
		im->ip_epilogue = prog;
		/* arg1: mov rdi, im */
		emit_mov_imm64(&prog, BPF_REG_1, (long) im >> 32, (u32) (long) im);
		if (emit_rsb_call(&prog, __bpf_tramp_exit, prog)) {
			ret = -EINVAL;
			goto cleanup;
		}
	}
	/* 对于origin call的情况,保存函数体的返回值到rax寄存器,作为TRAMPOLINE的
	 * 返回值。
	 */
	if (save_ret)
		emit_ldx(&prog, BPF_DW, BPF_REG_0, BPF_REG_FP, -8);

	/* 恢复rbx寄存器 */
	emit_ldx(&prog, BPF_DW, BPF_REG_6, BPF_REG_FP, -rbx_off);
	EMIT1(0xC9); /* leave */
	/* 存在FEXIT和MODIFY_RETURN类型的BPF程序的话,会存在这个标志。通过将栈顶
	 * 往上移动8字节,可以跳过目标函数的栈帧,直接返回到调用者那里。
	 */
	if (flags & BPF_TRAMP_F_SKIP_FRAME)
		/* skip our return address and return to parent */
		EMIT4(0x48, 0x83, 0xC4, 8); /* add rsp, 8 */
	emit_return(&prog, prog);

至此,整个TRAMPOLINE函数的指令生成过程算是完成了。下面我们再总结性地看一下各个情况下TRAMPOLINE函数的栈帧情况,以便更加透彻地理解其运行过程。这里我们以FEXIT类型的BPF程序跟踪拥有10个参数的__inet_lookup_listener()内核函数为例来进行分析。在调用__inet_lookup_listener的函数体之前,TRAMPOLINE函数的栈帧情况如下所示:

在这里插入图片描述

可以看出来,在调用__inet_lookup_listener函数体之前,TRAMPOLINE函数已经将栈中的参数由caller的栈帧中拷贝到当前栈帧中。从栈中的数据布局可以看出来,rbp+24即为栈参数在caller栈帧中的地址。在调用完origin_call之后,TRAMPOLINE会把函数体的返回值存储到rbp-8的位置。从这里我们可以看出来,在BPF程序里面我们是可以通过ctx[10]来获取到函数返回值的:

SEC("fexit/__inet_lookup_listener")
int BPF_PROG(tracing_exit_lookup_listener)
{
	bpf_printk("ret=%d\n", (int)ctx[10]);
	return 0;
}

由于我们已经在TRAMPOLINE中调用了函数体,因此就不需要从TRAMPOLINE返回到__inet_lookup_listener()中来继续执行目标函数的函数体了。从上面的栈中可以看出来,在执行完leave指令后,栈顶会移动到上图中的位置1处。而位置1处的rip中存储的是__inet_lookup_listener函数的函数体的地址。这里我们只需要将栈顶rsp寄存器往上再移动8个字节,即可跳过__inet_lookup_listener函数,直接返回到caller调用者中。

三、总结

本文在简要介绍动态FTRACE的原理的背景下,详细分析了BPF TRAMPOLINE的代码实现和运行过程。通过TRAMPOLINE,BPF程序可以实现很多之前难以实现的功能,比如跟踪函数返回值(FEXIT)、修改原函数的运行逻辑(MODIFY_RETURN)等。在原先的逻辑中,TRAMPOLINE只考虑了寄存器传参的情况,导致TRACING只能跟踪不超过6个参数的内核函数。在bpf, x86: allow function arguments up to 12 for TRACING提交中笔者对其进行了完善,使其在x86架构下能够支持最多12个参数。

  • 25
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值