[monitor] 8. Linux kprobe(内核探针)

1、kprobe概念

kprobe是一个动态地收集调试和性能信息的工具,它从Dprobe项目派生而来,是一种非破坏性工具,用户用它几乎可以跟踪任何函数或被执行的指令以及一些异步事件(如timer)。它的基本工作机制是:用户指定一个探测点,并把一个用户定义的处理函数关联到该探测点,当内核执行到该探测点时,相应的关联函数被执行,然后继续执行正常的代码路径。

kprobe实现了三种类型的探测点: kprobes, jprobes和kretprobes (也叫返回探测点)。 kprobes是可以被插入到内核的任何指令位置的探测点,jprobes则只能被插入到一个内核函数的入口,而kretprobes则是在指定的内核函数返回时才被执行。

一般,使用kprobe的程序实现作一个内核模块,模块的初始化函数来负责安装探测点,退出函数卸载那些被安装的探测点。kprobe提供了接口函数(APIs)来安装或卸载探测点。目前kprobe支持如下架构:i386、x86_64、ppc64、ia64(不支持对slot1指令的探测)、sparc64 (返回探测还没有实现)。

2、实现原理

2.1、kprobe

Kprobe的实现利用了cpu的两个异常机制:断点异常和单步异常。

Kprobe把探测点的指令替换成断点指令BREAKPOINT,执行到探测点以后,系统会陷入断点异常int3,在int3中执行pre_handler函数,然后把cpu设置为单步模式继续执行探测点原有的指令,原有指令执行完成以后又会陷入单步异常int1,在int1中调用post_handler,并恢复单步模式到正常模式,然后返回继续执行探测点后续的指令。

所以理解int3和int1的异常处理流程是理解kprobe实现原理的关键。

  • kprobe的注册流程为:备份探测点原有的指令代码,把probe结构加入处理链表,替换探测点的指令为断点指令0xcc。
  • Kprobe的执行过程为:执行到探测点的断点指令陷入int3异常处理,在int3中找到探测点地址对应的kprobe结构,执行pre_handler函数,执行完以后将cpu的标志设置为单步模式,在单步模式下执行备份的探测点原指令,执行完单步指令后陷入int1异常,在int1的梳理函数do_debug中继续调用post_handler,并恢复单步模式到正常模式,然后返回继续执行探测点后续的指令。

1

2.1.1、断点异常(int3)

Cpu有一个断点指令,x86下的断点指令码为“0xcc”,执行断点指令会陷入int3异常。gdb和一些调试程序利用断点指令来设置程序断点插入自己的操作。Kprobe利用断点程序来在指令之前插入自己的操作,即post_handler函数。

2
3
4
5

2.1.2、单步异常(int1)

CPU中有个flags标志寄存器,其中TF标志代表单步执行,那么cpu在每执行完一条指令的时候都会检查这个标志位,一旦设置了这个标志位,那么cpu当即触发一个1号异常就是int1。Krpobe置上cpu的单步标志,再去运行探测点的指令,探测点指令执行完以后就陷入int1异常,Kprobe利用断点程序来在指令之后插入自己的操作,即post_handler函数。

6
7
8

2.1.3、init_kprobes()

9
10
11

2.1.4、register_kprobe()

12
13
14
15
16
17
18
19

2.1.5、kprobe_handler()

20
21
22
23

2.1.6、post_kprobe_handler()

24

2.1.7、kprobe_fault_handler()

2.2、jprobe

jprobe是在kprobe基础上实现的进一步的机制,kprobe是可以在任意的地址注册探测函数,jprobe只支持对函数进行探测。Jprobe的处理函数应当和被探测函数有同样的原型,jprobe函数返回必须调用jprobe_return()。

Kprobe允许在同一地方注册多个kprobe,而jprobe指允许在一个地方注册一个jprobe。

jprobe的执行过程为:探测点执行到BREAKPOINT指令第一次陷入int3异常,执行jprobe的默认pre_handle函数setjmp_pre_handler(),在setjmp_pre_handler()中备份寄存器备份堆栈信息并把异常返回地址设置为jprobe的探测函数,setjmp_pre_handler()会返回1造成int3不会设置单步模式,从int3返回jprobe探测函数继续运行,在运行完jprobe注册的探测函数后调用jprobe_return返回,jprobe_return手动调用int3指令,系统第二次陷入int3异常处理,这时会调用kprobe的break_handler函数longjmp_break_handler(),longjmp_break_handler()恢复寄存器和堆栈并返回0,系统设置单步模式并返回原来的探测点,整个kprobe的流程继续得到执行。

25

2.2.1、register_jprobe()

26
27

2.2.2、setjmp_pre_handler()

28

2.2.3、jprobe_return()

29
30
31
32

2.2.4、longjmp_break_handler()

33

2.3、kretprobe

kretprobe也使用了kprobes来实现,当用户调用register_kretprobe()时,kprobe在被探测函数的入口建立了一个探测点,当执行到探测点时,kprobe保存了被探测函数的返回地址并取代返回地址为一个trampoline的地址,kprobe在初始化时定义了该trampoline并且为该trampoline注册了一个kprobe,当被探测函数执行它的返回指令时,控制传递到该trampoline,因此kprobe已经注册的对应于trampoline的处理函数将被执行,而该处理函数会调用用户关联到该kretprobe上的处理函数,处理完毕后,设置指令寄存器指向已经备份的函数返回地址,因而原来的函数返回被正常执行。

被探测函数的返回地址保存在类型为kretprobe_instance的变量中,结构kretprobe的maxactive字段指定了被探测函数可以被同时探测的实例数,函数register_kretprobe()将预分配指定数量的kretprobe_instance。如果被探测函数是非递归的并且调用时已经保持了自旋锁(spinlock),那么maxactive为1就足够了; 如果被探测函数是非递归的且运行时是抢占失效的,那么maxactive为NR_CPUS就可以了;如果maxactive被设置为小于等于0, 它被设置到缺省值(如果抢占使能, 即配置了 CONFIG_PREEMPT,缺省值为10和2*NR_CPUS中的最大值,否则缺省值为NR_CPUS)。

如果maxactive被设置的太小了,一些探测点的执行可能被丢失,但是不影响系统的正常运行,在结构kretprobe中nmissed字段将记录被丢失的探测点执行数,它在返回探测点被注册时设置为0,每次当执行探测函数而没有kretprobe_instance可用时,它就加1。

2.3.1、register_kretprobe()

34
35

2.3.2、pre_handler_kretprobe()

36
37

2.3.3、kretprobe_trampoline

系统使用kretprobe_trampoline替换了探测点原有的返回地址,并且预先为kretprobe_trampoline注册了另一个kprobe,当函数返回执行到kretprobe_trampoline会触发另一个kprobe。

38

2.3.4、trampoline_p

系统预先对kretprobe_trampoline注册了一个kprobe。

39
40
41

3、注意事项

  • kprobe允许在同一地址注册多个kprobes,但是不能同时在该地址上有多个jprobes。

  • 通常,用户可以在内核的任何位置注册探测点,特别是可以对中断处理函数注册探测点,但是也有一些例外。如果用户尝试在实现kprobe的代码(包括kernel/kprobes.c和arch/*/kernel/kprobes.c以及do_page_fault和notifier_call_chain)中注册探测点,register_*probe将返回-EINVAL.

  • 如果为一个内联(inline)函数注册探测点,kprobe无法保证对该函数的所有实例都注册探测点,因为gcc可能隐式地内联一个函数。因此,要记住,用户可能看不到预期的探测点的执行。

  • 一个探测点处理函数能够修改被探测函数的上下文,如修改内核数据结构,寄存器等。因此,kprobe可以用来安装bug解决代码或注入一些错误或测试代码。

  • 如果一个探测处理函数调用了另一个探测点,该探测点的处理函数不将运行,但是它的nmissed数将加1。多个探测点处理函数或同一处理函数的多个实例能够在不同的CPU上同时运行。

  • 除了注册和卸载,kprobe不会使用mutexe或分配内存。

  • 探测点处理函数在运行时是失效抢占的,依赖于特定的架构,探测点处理函数运行时也可能是中断失效的。因此,对于任何探测点处理函数,不要使用导致睡眠或进程调度的任何内核函数(如尝试获得semaphore)。

  • kretprobe是通过取代返回地址为预定义的trampoline的地址来实现的,因此栈回溯和gcc内嵌函数__builtin_return_address()调用将返回trampoline的地址而不是真正的被探测函数的返回地址。

  • 如果一个函数的调用次数与它的返回次数不相同,那么在该函数上注册的kretprobe探测点可能产生无法预料的结果(do_exit()就是一个典型的例子,但do_execve() 和 do_fork()没有问题)。

  • 当进入或退出一个函数时,如果CPU正运行在一个非当前任务所有的栈上,那么该函数的kretprobe探测可能产生无法预料的结果,因此kprobe并不支持在x86_64上对__switch_to()的返回探测,如果用户对它注册探测点,注册函数将返回-EINVAL。