Linux kprobes调试技术是内核开发者们专门为了便于跟踪内核函数执行状态所设计的一种轻量级内核调试技术。利用kprobes技术,内核开发人员可以在内核的绝大多数指定函数中动态的插入探测点来收集所需的调试状态信息而基本不影响内核原有的执行流程。kprobes技术目前提供了3种探测手段:kprobe、jprobe和kretprobe,其中jprobe和kretprobe是基于kprobe实现的,他们分别应用于不同的探测场景中。本文首先简单描述这3种探测技术的原理与区别,然后主要围绕其中的kprobe技术进行分析并给出一个简单的实例介绍如何利用kprobe进行内核函数探测,最后分析kprobe的实现过程(jprobe和kretprobe会在后续的博文中进行分析)。
内核源码:linux-4.1.15
实验环境:CentOS(x86_64)、树莓派1b
开发人员在内核或者模块的调试过程中,往往会需要要知道其中的一些函数有无被调用、何时被调用、执行是否正确以及函数的入参和返回值是什么等等。比较简单的做法是在内核代码对应的函数中添加日志打印信息,但这种方式往往需要重新编译内核或模块,重新启动设备之类的,操作较为复杂甚至可能会破坏原有的代码执行过程。
而利用kprobes技术,用户可以定义自己的回调函数,然后在内核或者模块中几乎所有的函数中(有些函数是不可探测的,例如kprobes自身的相关实现函数,后文会有详细说明)动态的插入探测点,当内核执行流程执行到指定的探测函数时,会调用该回调函数,用户即可收集所需的信息了,同时内核最后还会回到原本的正常执行流程。如果用户已经收集足够的信息,不再需要继续探测,则同样可以动态的移除探测点。因此kprobes技术具有对内核执行流程影响小和操作方便的优点。
kprobes技术包括的3种探测手段分别时kprobe、jprobe和kretprobe。首先kprobe是最基本的探测方式,是实现后两种的基础,它可以在任意的位置放置探测点(就连函数内部的某条指令处也可以),它提供了探测点的调用前、调用后和内存访问出错3种回调方式,分别是pre_handler、post_handler和fault_handler,其中pre_handler函数将在被探测指令被执行前回调,post_handler会在被探测指令执行完毕后回调(注意不是被探测函数),fault_handler会在内存访问出错时被调用;jprobe基于kprobe实现,它用于获取被探测函数的入参值;最后kretprobe从名字种就可以看出其用途了,它同样基于kprobe实现,用于获取被探测函数的返回值。
kprobes的技术原理并不仅仅包含存软件的实现方案,它也需要硬件架构提供支持。其中涉及硬件架构相关的是CPU的异常处理和单步调试技术,前者用于让程序的执行流程陷入到用户注册的回调函数中去,而后者则用于单步执行被探测点指令,因此并不是所有的架构均支持,目前kprobes技术已经支持多种架构,包括i386、x86_64、ppc64、ia64、sparc64、arm、ppc和mips(有些架构实现可能并不完全,具体可参考内核的Documentation/kprobes.txt)。
kprobes的特点与使用限制:
1、kprobes允许在同一个被被探测位置注册多个kprobe,但是目前jprobe却不可以;同时也不允许以其他的jprobe回掉函数和kprobe的post_handler回调函数作为被探测点。
2、一般情况下,可以探测内核中的任何函数,包括中断处理函数。不过在kernel/kprobes.c和arch/*/kernel/kprobes.c程序中用于实现kprobes自身的函数是不允许被探测的,另外还有do_page_fault和notifier_call_chain;
3、如果以一个内联函数为探测点,则kprobes可能无法保证对该函数的所有实例都注册探测点。由于gcc可能会自动将某些函数优化为内联函数,因此可能无法达到用户预期的探测效果;
4、一个探测点的回调函数可能会修改被探测函数运行的上下文,例如通过修改内核的数据结构或者保存与struct pt_regs结构体中的触发探测之前寄存器信息。因此kprobes可以被用来安装bug修复代码或者注入故障测试代码;
5、kprobes会避免在处理探测点函数时再次调用另一个探测点的回调函数,例如在printk()函数上注册了探测点,则在它的回调函数中可能再次调用printk函数,此时将不再触发printk探测点的回调,仅仅时增加了kprobe结构体中nmissed字段的数值;
6、在kprobes的注册和注销过程中不会使用mutex锁和动态的申请内存;
7、kprobes回调函数的运行期间是关闭内核抢占的,同时也可能在关闭中断的情况下执行,具体要视CPU架构而定。因此不论在何种情况下,在回调函数中不要调用会放弃CPU的函数(如信号量、mutex锁等);
8、kretprobe通过替换返回地址为预定义的trampoline的地址来实现,因此栈回溯和gcc内嵌函数__builtin_return_address()调用将返回trampoline的地址而不是真正的被探测函数的返回地址;
9、如果一个函数的调用此处和返回次数不相等,则在类似这样的函数上注册kretprobe将可能不会达到预期的效果,例如do_exit()函数会存在问题,而do_execve()函数和do_fork()函数不会;
10、如果当在进入和退出一个函数时,CPU运行在非当前任务所有的栈上,那么往该函数上注册kretprobe可能会导致不可预料的后果,因此,kprobes不支持在X86_64的结构下为__switch_to()函数注册kretprobe,将直接返回-EINVAL。
下面来介绍一下kprobe是如何工作的。具体流程见下图:
图1 kprobe的工作流程
1、当用户注册一个探测点后,kprobe首先备份被探测点的对应指令,然后将原始指令的入口点替换为断点指令,该指令是CPU架构相关的,如i386和x86_64是int3,arm是设置一个未定义指令(目前的x86_64架构支持一种跳转优化方案Jump Optimization,内核需开启CONFIG_OPTPROBES选项,该种方案使用跳转指令来代替断点指令);
2、当CPU流程执行到探测点的断点指令时,就触发了一个trap,在trap处理流程中会保存当前CPU的寄存器信息并调用对应的trap处理函数,该处理函数会设置kprobe的调用状态并调用用户注册的pre_handler回调函数,kprobe会向该函数传递注册的struct kprobe结构地址以及保存的CPU寄存器信息;
3、随后kprobe单步执行前面所拷贝的被探测指令,具体执行方式各个架构不尽相同,arm会在异常处理流程中使用模拟函数执行,而x86_64架构则会设置单步调试flag并回到异常触发前的流程中执行;
4、在单步执行完成后,kprobe执行用户注册的post_handler回调函数;
5、最后,执行流程回到被探测指令之后的正常流程继续执行。
在分析kprobe的实现之前先来看一下如何利用kprobe对函数进行探测,以便于让我们对kprobre所完成功能有一个比较清晰的认识。目前,使用kprobe可以通过两种方式,第一种是开发人员自行编写内核模块,向内核注册探测点,探测函数可根据需要自行定制,使用灵活方便;第二种方式是使用kprobes on ftrace,这种方式是kprobe和ftrace结合使用,即可以通过kprobe来优化ftrace来跟踪函数的调用。下面来分别介绍:
内核提供了一个struct kprobe结构体以及一系列的内核API函数接口,用户可以通过这些接口自行实现探测回调函数并实现struct kprobe结构,然后将它注册到内核的kprobes子系统中来达到探测的目的。同时在内核的samples/kprobes目录下有一个例程kprobe_example.c描述了kprobe模块最简单的编写方式,开发者可以以此为模板编写自己的探测模块。
struct kprobe结构体定义如下:
struct hlist_node hlist:被用于kprobe全局hash,索引值为被探测点的地址;
struct list_head list:用于链接同一被探测点的不同探测kprobe;
kprobe_opcode_t *addr:被探测点的地址;
const char *symbol_name:被探测函数的名字;
unsigned int offset:被探测点在函数内部的偏移,用于探测函数内部的指令,如果该值为0表示函数的入口;
kprobe_pre_handler_t pre_handler:在被探测点指令执行之前调用的回调函数;
kprobe_post_handler_t post_handler:在被探测指令执行之后调用的回调函数;
kprobe_fault_handler_t fault_handler:在执行pre_handler、post_handler或单步执行被探测指令时出现内存异常则会调用该回调函数;
kprobe_break_handler_t break_handler:在执行某一kprobe过程中触发了断点指令后会调用该函数,用于实现jprobe;
kprobe_opcode_t opcode:保存的被探测点原始指令;
struct arch_specific_insn ainsn:被复制的被探测点的原始指令,用于单步执行,架构强相关(可能包含指令模拟函数);
u32 flags:状态标记。
涉及的API函数接口如下:
该用例函数非常简单,它实现了内核函数do_fork的探测,该函数会在fork系统调用或者内核kernel_thread函数创建进程时被调用,触发也十分的频繁。下面来分析一下用例代码:
下面将它编译成模块在我的x86(CentOS 3.10)环境下进行演示,首先确保架构和内核已经支持kprobes,开启以下选项(一般都是默认开启的):
Symbol: KPROBES [=y]
Type : boolean
Prompt: Kprobes
Location:
(3) -> General setup
Defined at arch/Kconfig:37
Depends on: MODULES [=y] && HAVE_KPROBES [=y]
Selects: KALLSYMS [=y]
Symbol: HAVE_KPROBES [=y]
Type : boolean
Defined at arch/Kconfig:174
Selected by: X86 [=y]
然后使用以下Makefile单独编译kprobe_example.ko模块:
<6>pre_handler: p->addr = 0xc0439cc0, ip = c0439cc1, flags = 0x246
<6>post_handler: p->addr = 0xc0439cc0, flags = 0x246
<6>pre_handler: p->addr = 0xc0439cc0, ip = c0439cc1, flags = 0x246
<6>post_handler: p->addr = 0xc0439cc0, flags = 0x246
<6>pre_handler: p->addr = 0xc0439cc0, ip = c0439cc1, flags = 0x246
<6>post_handler: p->addr = 0xc0439cc0, flags = 0x246
可以看到被探测点的地址为0xc0439cc0,用以下命令确定这个地址就是do_fork的入口地址。
[root@apple kprobes]# cat /proc/kallsyms | grep do_fork
c0439cc0 T do_fork
这种方式用户通过/sys/kernel/debug/tracing/目录下的trace等属性文件来探测用户指定的函数,用户可添加kprobe支持的任意函数并设置探测格式与过滤条件,无需再编写内核模块,使用更为简便,但需要内核的debugfs和ftrace功能的支持。
首先,在使用前需要保证开启以下内核选项:
Symbol: FTRACE [=y]
Type : boolean
Prompt: Tracers
Location:
(5) -> Kernel hacking
Defined at kernel/trace/Kconfig:132
Depends on: TRACING_SUPPORT [=y]
Symbol: KPROBE_EVENT [=y]
Type : boolean
Prompt: Enable kprobes-based dynamic events
Location:
-> Kernel hacking
(1) -> Tracers (FTRACE [=y])
Defined at kernel/trace/Kconfig:405
Depends on: TRACING_SUPPORT [=y] && FTRACE [=y] && KPROBES [=y] && HAVE_REGS_AND_STACK_ACCESS_API [=y]
Selects: TRACING [=y] && PROBE_EVENTS [=y]
Symbol: HAVE_KPROBES_ON_FTRACE [=y]
Type : boolean
Defined at arch/Kconfig:183
Selected by: X86 [=y]
Symbol: KPROBES_ON_FTRACE [=y]
Type : boolean
Defined at arch/Kconfig:79
Depends on: KPROBES [=y] && HAVE_KPROBES_ON_FTRACE [=y] && DYNAMIC_FTRACE_WITH_REGS [=y]
然后需要将debugfs文件系统挂在到/sys/kernel/debug/目录下:
# mount -t debugfs nodev /sys/kernel/debug/
此时/sys/kernel/debug/tracing目录下就出现了若干个文件和目录用于用户设置要跟踪的函数以及过滤条件等等,这里我主要关注以下几个文件:
1、配置属性文件:kprobe_events
2、查询属性文件:trace和trace_pipe
3、使能属性文件:events/kprobes/
4、过滤属性文件:events/kprobes/
5、格式查询属性文件:events/kprobes/
6、事件统计属性文件:kprobe_profile
其中配置属性文件用于用户配置要探测的函数以及探测的方式与参数,在配置完成后,会在events/kprobes/目录下生成对应的目录;其中会生成enabled、format、filter和id这4个文件,其中的enable属性文件用于控制探测的开启或关闭,filter用于设置过滤条件,format可以查看当前的输出格式,最后id可以查看当前probe event的ID号。然后若被探测函数被执行流程触发调用,用户可以通过trace属性文件进行查看。最后通过kprobe_profile属性文件可以查看探测命中次数和丢失次数(probe hits and probe miss-hits)。
下面来看看各个属性文件的常用操作方式(其中具体格式和参数方面的细节可以查看内核的Documentation/trace/kprobetrace.txt文件,描述非常详细):
1、kprobe_events
该属性文件支持3中格式的输入:
p[:[GRP/]EVENT] [MOD:]SYM[+offs]|MEMADDR [FETCHARGS]——设置一个probe探测点
r[:[GRP/]EVENT] [MOD:]SYM[+0] [FETCHARGS] ——设置一个return probe探测点
-:[GRP/]EVENT ——删除一个探测点
各个字段的含义如下:
GRP : Group name. If omitted, use "kprobes" for it. ——指定后会在events/kprobes目录下生成对应名字的目录,一般不设
EVENT : Event name. If omitted, the event name is generated based on SYM+offs or MEMADDR. ——指定后会在events/kprobes/
MOD : Module name which has given SYM. ——模块名,一般不设
SYM[+offs] : Symbol+offset where the probe is inserted. ——指定被探测函数和偏移
MEMADDR : Address where the probe is inserted. ——指定被探测的内存绝对地址
FETCHARGS : Arguments. Each probe can have up to 128 args. ——指定要获取的参数信息
%REG : Fetch register REG ——获取指定寄存器值
@ADDR : Fetch memory at ADDR (ADDR should be in kernel) ——获取指定内存地址的值
@SYM[+|-offs] : Fetch memory at SYM +|- offs (SYM should be a data symbol) ——获取全局变量的值
$stackN : Fetch Nth entry of stack (N >= 0) ——获取指定栈空间值,即sp寄存器+N后的位置值
$stack : Fetch stack address. ——获取sp寄存器值
$retval : Fetch return value.(*) ——获取返回值,仅用于return probe
+|-offs(FETCHARG) : Fetch memory at FETCHARG +|- offs address.(**) ——以下可以由于获取指定地址的结构体参数内容,可以设定具体的参数名和偏移地址
NAME=FETCHARG : Set NAME as the argument name of FETCHARG.
FETCHARG:TYPE : Set TYPE as the type of FETCHARG. Currently, basic types ——设置参数的类型,可以支持字符串和比特类型
(u8/u16/u32/u64/s8/s16/s32/s64), "string" and bitfield
are supported.
2、events/kprobes/
暂停探测:echo 0 > events/kprobes/
3、events/kprobes/
该属性文件用于设置过滤条件,可以减少trace中输出的信息,它支持的格式和C语言的表达式类似,支持 ==,!=,>,<,>=,<=判断,并且支持与&&,或||,还有()。
下面还是以do_fork()函数为例来举例看一下具体如何使用(实验环境:树莓派1b):
1、设置配置属性
首先添加配置探测点:
root@apple:~# echo 'p:myprobe do_fork clone_flags=%r0 stack_start=%r1 stack_size=%r2 parent_tidptr=%r3 child_tidptr=+0($stack)' > /sys/kernel/debug/tracing/kprobe_events
root@apple:~# echo 'r:myretprobe do_fork $retval' >> /sys/kernel/debug/tracing/kprobe_events
这里注册probe和retprobe,其中probe中设定了获取do_fork()函数的入参值(注意这里的参数信息根据不同CPU架构的函数参数传递规则强相关,根据ARM遵守的ATPCS规则,函数入参1~4通过r0~r3寄存器传递,多余的参数通过栈传递),由于入参为5个,所以前4个通过寄存器获取,最后一个通过栈获取。
现可通过format文件查看探测的输出格式:
root@apple:/sys/kernel/debug/tracing# cat events/kprobes/myprobe/format
name: myprobe
ID: 1211
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:unsigned long __probe_ip; offset:8; size:4; signed:0;
field:u32 clone_flags; offset:12; size:4; signed:0;
field:u32 stack_start; offset:16; size:4; signed:0;
field:u32 stack_size; offset:20; size:4; signed:0;
field:u32 parent_tidptr; offset:24; size:4; signed:0;
field:u32 child_tidptr; offset:28; size:4; signed:0;
print fmt: "(%lx) clone_flags=0x%x stack_start=0x%x stack_size=0x%x parent_tidptr=0x%x child_tidptr=0x%x", REC->__probe_ip, REC->clone_flags, REC->stack_start, REC->stack_size, REC->parent_tidptr, REC->child_tidptr
root@apple:/sys/kernel/debug/tracing# cat events/kprobes/myretprobe/format
name: myretprobe
ID: 1212
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:unsigned long __probe_func; offset:8; size:4; signed:0;
field:unsigned long __probe_ret_ip; offset:12; size:4; signed:0;
field:u32 arg1; offset:16; size:4; signed:0;
print fmt: "(%lx <- %lx) arg1=0x%x", REC->__probe_func, REC->__probe_ret_ip, REC->arg1
2、开启探测并触发函数调用
往对应的enable函数中写入1用以开启探测功能:
root@apple:/sys/kernel/debug/tracing# echo 1 > events/kprobes/myprobe/enable
root@apple:/sys/kernel/debug/tracing# echo 1 > events/kprobes/myretprobe/enable
然后在终端上敲几条命令和建立一个ssh链接触发进程创建do_fork函数调用,并通过trace属性文件获取函数调用时的探测情况
root@apple:/sys/kernel/debug/tracing# cat trace
# tracer: nop从输出中可以看到do_fork函数由bash(PID=513) 和sshd(PID=520)进程调用,同时执行的CPU为0,调用do_fork函数是入参值分别是stack_start=0x0 stack_size=0x0 parent_tidptr=0x0 child_tidptr=0xbxxxxxxx,同时输出函数返回上层SyS_clone系统调用的nr值。
如果输出太多了,想要清除就向trace中写0即可
root@apple:/sys/kernel/debug/tracing# echo 0 > trace
3、使用filter进行过滤
例如想要把前面列出的PID为513调用信息的给过滤掉,则向filter中写入如下的命令即可:
root@apple:/sys/kernel/debug/tracing# echo common_pid!=513 > events/kprobes/myprobe/filter
root@apple:/sys/kernel/debug/tracing# cat trace
# tracer: nop
......
bash-513 [000] d... 24456.536804: myretprobe: (SyS_clone+0x2c/0x34 <- do_fork) arg1=0x245
kthreadd-2 [000] d... 24598.655935: myprobe: (do_fork+0x0/0x380) clone_flags=0x800711 stack_start=0xc003d69c stack_size=0xc58982a0 parent_tidptr=0x0 child_tidptr=0x0
kthreadd-2 [000] d... 24598.656133: myretprobe: (kernel_thread+0x38/0x40 <- do_fork) arg1=0x246
bash-513 [000] d... 24667.676717: myretprobe: (SyS_clone+0x2c/0x34 <- do_fork) arg1=0x247
如此就不会在打印PID为513的进程调用信息了,这里的参数可以参考前面的format中输出的,例如想指定输出特定clone_flags值,则可以输入clone_flags=xxx即可。
最后补充一点,若此时需要查看函数调用的栈信息(stacktrace),可以使用如下命令激活stacktrace输出:
root@apple:/sys/kernel/debug/tracing# echo stacktrace > trace_options
root@apple:/sys/kernel/debug/tracing# cat trace
......
bash-508 [000] d... 449.276093: myprobe: (do_fork+0x0/0x380) clone_flags=0x1200011 stack_start=0x0 stack_size=0x0 parent_tidptr=0x0 child_tidptr=0xb6f86278
bash-508 [000] d... 449.276126:
=> do_fork
在了解了kprobe的基本原理和使用后,现在从源码的角度来详细分析它是如何实现的。主要包括kprobes的初始化、注册kprobe和触发kprobe(包括arm结构和x86_64架构的回调函数和single-step单步执行)。
图 2 kprobes初始化流程
kprobes作为一个模块,其初始化函数为init_kprobes,代码路径kernel/kprobes.c
回到init_kprobes函数中继续分析,接下来的片段是kretprobe相关的代码,用来核对kretprobe_blacklist中定义的函数是否存在,这里kretprobe_blacklist_size变量默认为0;接下来初始化3个全局变量,kprobes_all_disarmed用于表示是否启用kprobe机制,这里默认设置为启用;随后调用arch_init_kprobes进行架构相关的初始化,x86架构的实现为空,arm架构的实现如下:
再次回到init_kprobes函数,接下来分别注册die和module的内核通知链kprobe_exceptions_nb和kprobe_module_nb:
最后init_kprobes函数置位kprobes_initialized标识,初始化完成。
kprobe探测模块调用register_kprobe向kprobe子系统注册一个kprobe探测点实例,代码路径kernel/kprobes.c
图 3 kprobe注册流程
kprobe_addr首先对入参进行检查,不允许函数名和地址同时设置或同时为空的情况;如果用户指定被探测函数名则调用kallsyms_lookup_name函数根据函数名查找其运行的虚拟地址;最后加上指定的探测偏移值作为最终的被探测地址。当然在绝大多数的情况下,offset值被用户设置为0,即用户探测指定函数的入口,但是也不排除用户想要探测某一函数内部的某一条指令。
回到register_kprobe函数中,下面调用check_kprobe_rereg函数防止同一个kprobe实例被重复注册,其中check_kprobe_rereg->__get_valid_kprobe调用流程将根据addr地址值搜索全局hash表并查看是否有同样的kprobe实例已经在表中了。
随后register_kprobe函数继续初始化kprobe的flags、nmissed字段和list链表(flag只允许用户传递KPROBE_FLAG_DISABLED,表示注册的kprobe默认是不启用的),然后调用check_kprobe_address_safe函数检测被探测地址是否可探测:
被探测的函数当然要在内核的text(_stext ~ _etext)段中,由于非内核启动时刻,不包括init text段;然后模块的text段和init text段也都可以,最后如果在ftrace动态分配的trampoline地址空间中也是满足的。
其中第二点中的blacklist黑名单指的是实现kprobes的关键代码路径,只有不在该黑名单中的函数才可以被探测:
主要包含两个方面,一是架构相关的kprobe关键代码路径,他们被保存在__kprobes_text_start~__kprobes_text_end段中,二是kprobe_blacklist链表,该链表前面在kprobe初始化过程中已经看到了。
首先__kprobes_text_start和__kprobes_text_end被定义在include/asm-generic/Vmlinux.lds.h中,使用宏__kprobes标记的函数被归入该.kprobes.text段:
回到check_kprobe_address_safe函数中,若满足了以上三点,接下来判断被探测地址是否属于某一个内核模块的init_text段或core_text段:
以上判断都通过之后重新打开内核抢占并解锁,回到register_kprobe函数继续注册流程。接下来尝试从全局hash表中查找是否之前已经为同一个被探测地址注册了kprobe探测点,若已注册则调用register_aggr_kprobe函数继续注册流程,该流程稍后再分析。现假设是初次注册,则调用prepare_kprobe函数,该函数会根据被探测地址是否已经被ftrace了而进入不同的流程,这里假设没有启用ftrace,则直接调用arch_prepare_kprobe函数进入架构相关的注册流程,先看一下x86架构的实现:
该arm_probes_decode_insn调用流程会对kprobe->ainsn结构进行初始化(该结构架构相关),其中函数指针insn_singlestep初始化为arm_singlestep,它用于kprobe触发后的单步执行,而函数insn_check_cc初始化为probes_condition_checks[insn>>28],它是一个函数指针数组,以指令的高4位为索引,用于kprobe触发后进行条件异常检测。
现以do_fork函数为例,来看一下这里的insn_check_cc函数指针初始化为那个函数了:
反汇编vmlinux后找到do_fork,对应的入口地址为0xc0022798,汇编指令为mov,机器码为e1a0c00d,计算后值为0xe=15,因此选中的条件异常检测处理函数为__check_al;
如果用户探测的并不是函数的入口地址,而是函数内部的某一条指令,则可能会选中其他的检测函数,例如movne指令选中的就是__check_ne,moveq指令选中的就是__check_eq等等。
回到arm_probes_decode_insn函数中,然后调用probes_decode_insn函数判断指令的类型并初始化单步执行函数指针insn_handler,最后返回INSN_REJECTED、INSN_GOOD和INSN_GOOD_NO_SLOT这三种类型(如果是INSN_GOOD还会拷贝指令填充ainsn.insn字段)。该函数的注释中对其描述的已经比较详细了,对于诸如某些会修改处理器工作状态的指令会返回INSN_REJECTED表示不支持,另外INSN_GOOD是需要slot的指令,INSN_GOOD_NO_SLOT是不需要slot的指令。回到arch_prepare_kprobe函数中,会对返回的指令类型做不同的处理,若是INSN_GOOD类型则同x86类似,调用get_insn_slot申请内存空间并将前面存放在tmp_insn中的指令拷贝到kprobe->ainsn.insn中,然后flush icache。
如此被探测点指令就被拷贝保存起来了。架构相关的初始化完成以后,接下来register_kprobe函数初始化kprobe的hlist字段并将它添加到全局的hash表中。然后判断如果kprobes_all_disarmed为false并且kprobe没有被disable(在kprobe的初始化函数中该kprobes_all_disarmed值默认为false),则调用arm_kprobe函数,它会把触发trap的指令写到被探测点处替换原始指令。
arm架构的实现中替换的指令为KPROBE_ARM_BREAKPOINT_INSTRUCTION(机器码是0x07f001f8),然后还会根据被替换指令做一定的调整,最后调用patch_text函数执行替换动作。继续以kprobe_example例程中的do_fork函数为例,从前文中反汇编可知,地址0xc0022798处的“mov ip, sp”指令被替换KPROBE_ARM_BREAKPOINT_INSTRUCTION指令,可从pre_handler回调函数中打印的地址得到印证:
<6>[ 57.386132] [do_fork] pre_handler: p->addr = 0xc0022798, pc = 0xc0022798, cpsr = 0x80000013
<6>[ 57.386167] [do_fork] post_handler: p->addr = 0xc0022798, cpsr = 0x80000013
前文中看到KPROBE_ARM_BREAKPOINT_INSTRUCTION指令在init_kprobes函数的执行流程中已经为它注册了一个异常处理函数kprobe_trap_handler,因此当正常执行流程执行到KPROBE_ARM_BREAKPOINT_INSTRUCTION指令后将触发异常,进而调用kprobe_trap_handler开始回调流程。
至此kprobe的注册流程分析完毕,再回头分析对一个已经被注册过kprobe的探测点注册新的kprobe的执行流程,即register_aggr_kprobe函数:
回到register_aggr_kprobe函数中,如果本次是第二次以上向同一地址注册kprobe实例,则此时的orig_p已经是aggr kprobe了,则会调用kprobe_unused函数判断该kprobe是否为被使用,若是则调用reuse_unused_kprobe函数重新启用,但是对于没有开启CONFIG_OPTPROBES选项的情况,逻辑上是不存在这种情况的,因此reuse_unused_kprobe函数的实现仅仅是一段打印后就立即触发BUG_ON。
继续往下分析,下面来讨论aggr kprobe被kill掉的情况,显然只有在第三次及以上注册同一地址可能会出现这样的情况。针对这一种情况,这里同初次注册kprobe的调用流程类似,首先调用arch_prepare_kprobe做架构相关初始化,保存被探测地址的机器指令,然后调用prepare_optimized_kprobe启用optimized_kprobe,最后清除KPROBE_FLAG_GONE的标记。
接下来调用再次copy_kprobe将aggr kprobe中保存的指令opcode和ainsn字段拷贝到本次要注册的kprobe的对应字段中,然后调用add_new_kprobe函数将新注册的kprobe链入到aggr kprobe的list链表中:
回到register_aggr_kprobe函数,在out标号处继续执行,下面会进入if条件判断,启用aggr kprobe,然后调用前文中分析过的arm_kprobe函数替换被探测地址的机器指令为BREAKPOINT_INSTRUCTION指令。
至此整个kprobe注册流程分析结束,下面来分析以上注册的探测回调函数是如何被执行的以及被探测指令是如何被单步执行的。
前文中,从register_kprobe函数注册kprobe的流程已经看到,用户指定的被探测函数入口地址处的指令已经被替换成架构相关的BREAKPOINT_INSTRUCTION指令,若是正常的代码流程执行到该指令,将会触发异常,进入架构相关的异常处理函数,kprobe注册的回调函数及被探测函数的单步执行流程均在该流程中执行。由于不同架构实现存在差别,下面分别来分析,首先先分析arm架构的执行流程:
前文中已经分析了内核已经为KPROBE_ARM_BREAKPOINT_INSTRUCTION指令注册了异常处理回调函数kprobe_trap_handler,因此在执行这条指令时会触发以下调用流程:__und_svc->__und_svc_fault->__und_fault->do_undefinstr()->call_undef_hook():
kprobe_handler函数的实现比较长,分段来看:
而prev_kprobe则是用于在kprobe重入情况下保存当前正在处理的kprobe实例和状态的。内核为每个cpu都定义了一个该类型全局变量。然后调用kprobe_running函数获取当前cpu上正在处理的kprobe:
1、p和cur的kprobe实例同时存在
注意,以上重入的处理流程仅仅是单步执行了被探测的函数,并不会调用kprobe的pre_handle回调函数(递增nmissed字段的原因就在此),因此用户并不会感知到kprobe被实际触发了。
2、p存在但cur不存在
这种情况就是最为一般的情况,即当前kprobe是首次触发,前面并没有其他的kprobe流程正在处理。这里会首先调用p->ainsn.insn_check_cc注册函数来进行条件异常检测,这个函数在前文注册kprobe的流程中已经看到根据不同的被探测指令被注册成不同的函数了,入参是触发异常时的cpsr程序状态寄存器值。
对于前文中看到的do_fork函数入口汇编指令mov设置的__check_al检测函数来说,它将永远返回true,而movne指令的__check_ne检测函数则会对cpsr进行判断:
(1)如果条件异常检测通过,那也同样调用set_current_kprobe函数设置当前正在处理的kprobe并更新kprobe状态标识为KPROBE_HIT_ACTIVE,表明开始处理该kprobe。接下来就到关键的回调和单步执行流程了,首先判断kprobe的pre_handler函数是否被注册,在注册的情况下调用它。对于单kprobe注册的情况很简单了,直接调用注册函数即可(这样前面kprobe_example中handler_pre函数就在此调用),但是对于前文中分析的多kprobe注册的情况(aggr kprobe),则会调用到aggr_pre_handler函数:
这里可能会存在这样的疑问,为什么kcb->kprobe_status = KPROBE_HIT_SSDONE;这条状态赋值语句会放在条件判断内部,而不是在单步执行完以后?其实对于当前的上下文逻辑来看效果是一样的,因为若没有注册post_handler,就会立即执行reset_current_kprobe函数解除kprobe的绑定,因此不会对逻辑产生影响。
(2)如果条件异常检测不通过则调用singlestep_skip函数跳过当前的指令,继续执行后面的指令,就像什么都没有发生过一样
3、p不存在但cur存在
4、p和cur都不存在
至此arm架构的kprobe触发及处理整体流程就分析完了。下面分析x86_64架构的实现,总体大同小异,其中的相同之处就不再分析了。
本地中断在处理kprobe期间依然被禁止,同时调用user_mode函数确保本处理函数处理的int3中断是在内核态执行流程期间被触发的(因为kprobe不会从用户态触发),这里之所以要做这么一个判断是因为同arm定义特殊未处理指令回调函数不同,这里的do_int3要通用的多,并不是单独为kprobe所设计的。然后获取被探测指令的地址保存到addr中(对于int3中断,其被Intel定义为trap,那么异常发生时EIP寄存器内指向的为异常指令的后一条指令),同时会禁用内核抢占,注释中说明在reenter_kprobe和单步执行时会有选择的重新开启内核抢占。接下来下面同arm一样获取当前cpu的kprobe_ctlblk控制结构体和本次要处理的kprobe实例p,然后根据不同的情况进行不同分支的处理。在继续分析前先来看一下x86_64架构kprobe_ctlblk结构体的定义
下面回到函数中根据不同的情况分别分析:
1、p存在且curent_kprobe存在
对于kprobe重入的情况,调用reenter_kprobe函数单独处理:
2、p存在但curent_kprobe不存在
这是一般最通用的kprobe执行流程,首先调用set_current_kprobe绑定p为当前正在处理的kprobe:
3、p不存在且被探测地址的指令也不是BREAKPOINT_INSTRUCTION
这种情况表示kprobe可能已经被其他CPU注销了,则让他执行原始指令即可,因此这里设置regs->ip值为addr并重新开启内核抢占返回1。
4、p不存在但curent_kprobe存在
这种情况一般用于实现jprobe,因此会调用curent_kprobe的break_handler回调函数,然后在break_handler返回非0的情况下执行单步执行,最后返回1。具体在jprobe实现中再详细分析。
以上x86_64架构的kprobe触发及回调整体流程分析完毕,可以看到基本的触发条件和处理流程和arm架构的实现还是差不多的,和架构相关的一些细节有所不同。同时也并没有看到post_handle的回调流程和kprobe的解绑定流程,由于实现同arm不同,以上遗留的两点会在后文分析。接下来分析被探测指令的单步执行过程。
单步执行其实就是执行被探测点的原始指令,涉及的主要函数即前文中分析kprobe触发及处理流程时遗留的singlestep函数(arm)和setup_singlestep函数(x86),它们的实现原理完全不同,其中会涉及许多cpu架构相关的知识,因此会比较晦涩。下面从原理角度逐一分析,并不涉及太多架构相关的细节:
以上arm架构下实现同原始指令同样效果的单步执行就分析完了,在kprobe流程执行完成后,恢复到regs中保存的上下文后就会从ARM_pc处继续取指执行了。这里虽然只分析了mov指令的单步执行,但其他的指令的处理流程类似,若想要了解个中细节可以通过ftrace工具进行跟踪。
x86_64架构的单步执行函数与arm架构的原理不同,其主要原理是:当程序执行到某条想要单独执行CPU指令时,在执行之前产生一次CPU异常,此时把异常返回时的CPU的EFLAGS寄存器的TF(调试位)位置为1,把IF(中断屏蔽位)标志位置为0,然后把EIP指向单步执行的指令。当单步指令执行完成后,CPU会自动产生一次调试异常(由于TF被置位)。此时,Kprobes会利用debug异常,执行post_handler()。下面来简单看一下:
接下来考试准备单步执行,首先设置regs->flags中的TF位并清空IF位,同时把int3异常返回的指令寄存器地址改为前面保存的被探测指令,当int3异常返回时这些设置就会生效,即立即执行保存的原始指令(注意这里是在触发int3之前原来的上下文中执行,因此直接执行原始指令即可,无需特别的模拟操作)。该函数返回后do_int3函数立即返回,由于cpu的标识寄存器被设置,在单步执行完被探测指令后立即触发debug异常,进入debug异常处理函数do_debug。
至此,kprobe的一般处理流程就分析完了,最后分析一下剩下的最后一个回调函数fault_handler。
出错会调函数fault_handler会在执行pre_handler、single_step和post_handler期间触发内存异常时被调用,对应的调用函数为kprobe_fault_handler,它同样时架构相关的,分别来看一下:
5.1、arm调用流程
do_page_fault->notify_page_fault
5.2、x86_64调用流程
1、do_page_fault->__do_page_fault->kprobes_fault
2、do_general_protection->notify_die->kprobe_exceptions_notify
以上fault_handler回调函数分析完毕。
kprobes内核探测技术作为一种内核代码的跟踪及调试手段,开发人员可以动态的跟踪内核函数的执行,相较与传统的添加内核日志等调试手段,它具有操作简单,使用灵活,对原始代码破坏小等多方面优势。本文首先介绍了kprobes的技术背景,然后介绍了其中kprobe技术使用方法并且通过源代码详细分析了arm架构和x86_64架构的原理和实现方式。下一篇博文将介绍基于kprobe实现的jprobe内核跟踪技术。
参考文献:1、http://blog.chinaunix.NET/uid-20662820-id-3795534.html
2、http://blog.csdn.net/panfengyun12345/article/details/19480567
3、Documentation/kprobes.txt
4、Documentation/trace/kprobetrace.txt