了解bpf的童鞋都应该知道,bpf程序是可以attach到不同的probe点上来做内核级别的trace,那么对于刚入门的人来说,如何来编写一个初级的bpf程序呢?这就是本篇博文想要介绍的内容。
BPF程序我们知道它需要先使用LLVM进行编译,完成后加载到内核中去执行,那么也就是说对于BPF程序来说,它应该包含有两部分:
在编写内核程序时,有一个固定的格式,假如我们需要添加一些逻辑到一个probe点上,那么需要定义一个入口函数,这个入口函数的格式应该是怎样的呢?它应该带有什么样的参数呢?
这个格式实际是固定的,它只接受一个参数 struct pt_regs *ctx
,而我们想要trace的函数,比如系统调用sys_execve,它是有多个参数的:
asmlinkage long sys_execve(const char __user *filename,
const char __user *const __user *argv,
const char __user *const __user *envp);
那么我们如何从ctx中来获取这些参数值呢?想要获取一个函数的入口参数,那么需要的probe类型就要是kprobe 因为这种类型会在进入函数后立刻执行我们的BPF函数,那么参数值上下文都在寄存器中会直接传入到我们的入口BPF函数中,可以通过如下的函数来获取参数。内核已经预定义了bpf helper函数来获取这些参数信息。
#define PT_REGS_PARM1(x) ((x)->di)
#define PT_REGS_PARM2(x) ((x)->si)
#define PT_REGS_PARM3(x) ((x)->dx)
#define PT_REGS_PARM4(x) ((x)->cx)
#define PT_REGS_PARM5(x) ((x)->r8)
#define PT_REGS_RET(x) ((x)->sp)
#define PT_REGS_FP(x) ((x)->bp)
#define PT_REGS_RC(x) ((x)->ax)
#define PT_REGS_SP(x) ((x)->sp)
#define PT_REGS_IP(x) ((x)->ip)
那么另外一种情况,如果probe点是一个tracepoint的话,我们怎么知道入口函数中能获取什么信息呢?这里划重点!!!
比如,sys_enter_execve是内核预定义的一个tracepoint,那么如果我们BPF程序被attach到这个位置上,如何知道ctx中都保存了什么信息呢?其实可以通过:
/sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format
来查看:
# cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format
name: sys_enter_execve
ID: 663
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:int __syscall_nr; offset:8; size:4; signed:1;
field:const char * filename; offset:16; size:8; signed:0;
field:const char *const * argv; offset:24; size:8; signed:0;
field:const char *const * envp; offset:32; size:8; signed:0;
print fmt: "filename: 0x%08lx, argv: 0x%08lx, envp: 0x%08lx", ((unsigned long)(REC->filename)), ((unsigned long)(REC->argv)), ((unsigned long)(REC->envp))
最后的输出:
field:int __syscall_nr; offset:8; size:4; signed:1;
field:const char * filename; offset:16; size:8; signed:0;
field:const char *const * argv; offset:24; size:8; signed:0;
field:const char *const * envp; offset:32; size:8; signed:0;
就是这个tracepoint位置上的参数列表。可以直接定义类似的数据结构来代替struct pt_regs *ctx
传参到入口函数中,实际上是进行了一次强制类型转换。比如:
struct syscall_execve_args {
unsigned long long unsed;
long syscall_nr;
long filename_ptr;
long argv;
long envp;
}
内核中有另外一个例子:https://github.com/torvalds/linux/blob/418baf2c28f3473039f2f7377760bd8f6897ae18/samples/bpf/syscall_tp_kern.c。
借用BCC前端来写BPF程序,和纯C语言写BPF是由些许差异的,首先对于BCC来说,它把BPF程序的两个模块,内核程序和用户态程序合二为一了。
BCC前端可选python语言编写,这极大提升了开发效率。那么python它作为用户态语言,内核态程序要怎么写呢?
其实在python中,是以字符串类型来写内核BPF C语言代码,在执行时,会去做编译和加载的过程。截取我写的示例:
#!/usr/bin/python
from __future__ import print_function
from bcc import BPF
import time
# define BPF program
bpf_text = """
#include
#include
#include
#include
int syscall__execve(struct pt_regs *ctx,
const char __user *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
bpf_trace_printk("pid:%d Hello,world\\n", pid);
return 0;
}
"""
# initialize BPF
b = BPF(text=bpf_text)
execve_fnname = b.get_syscall_fnname("execve")
b.attach_kprobe(event=execve_fnname, fn_name="syscall__execve")
start_ts = time.time()
# loop with callback to print_event
while 1:
try:
time.sleep(1);
b.trace_print()
except KeyboardInterrupt:
exit()
从上面的片段可知,内核BPF代码直接作为了python中的一段string来处理的。
到这里有些人可能存在疑问了,前面不是说对应的入口函数只能有一个参数ctx吗,这里为什么传了3个参数???
实际上这是BCC库做的一些改造,为了更方便开发者使用syscall的入口参数,对于kprobe来说,BCC允许按照syscall的入口参数来传参,而在BCC底层处理时,会对这个bpf_text代码做转换操作,详细信息可以参考:https://github.com/iovisor/bcc/blob/v0.14.0/src/cc/frontends/clang/b_frontend_action.cc#L692
今天的关键点都记录到了,先写到这里吧。