简介
开发人员在内核或者模块的调试过程中,往往会需要要知道其中的一些函数有无被调用、何时被调用、执行是否正确以及函数的入参和返回值是什么等等。
比较简单的做法是在内核代码对应的函数中添加日志打印信息,但这种方式往往需要重新编译内核或模块,重新启动设备之类的,操作较为复杂甚至可能会破坏原有的代码执行过程。
而利用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实现,用于获取被探测函数的返回值。
原理
kprobe的实现原理是把指定地址(探测点)的指令替换成一个可以让cpu进入debug模式的指令,使执行路径暂停,跳转到probe 处理函数后收集、修改信息,再跳转回来继续执行。
X86中使用的是int3指令,ARM64中使用的是BRK指令进入debug monitor模式。
kprobe 工作原理
当一个kprobe被注册时,Kprobes会复制一个被探测的指令的副本,并将被探测指令的第一个字节替换为替换为断点指令(例如,i386和x86_64上的int3,ARM64的BRK指令)。
当CPU碰到断点指令时,就会发生一个trap,CPU的寄存器被保存起来,控制权通过 "通知器调用链 "(notifier_call_chain )传递给Kprobes。Kprobes会执行 "pre_handler",将处理程序的地址传递给 kprobe结构和保存的寄存器的地址。
接下来,kprobes会对刚刚复制的指令进行单步操作。在执行单步指令后,会调用post_handler,然后继续执行探测点之后的指令。
jprobe工作原理
jprobe是基于kprobe实现的,探测的位置在函数的入口点。它采用了一个简单的镜像原则来允许无缝访问被探测函数的参数。
jprobe程序的参数和返回值必须和被探测函数完全相同,并且必须始终以调用函数jprobe_return()
作为返回值。
它的工作原理是这样的:
当探针被命中时,Kprobes会复制一个保存的寄存器和堆栈的一部分。然后Kprobes将保存的指令指针指向jprobe的处理程序,并从trap中返回, 控制权传递给处理程序,而处理程序的寄存器和堆栈内容与被探测函数相同。
当它处理完成后,处理程序调用jprobe_return(),再次捕获以恢复原来的堆栈内容和处理器状态并切换到内容和处理器状态,并切换到被探测函数。
注意,被探测的函数的args可以在堆栈中,也可以在寄存器中。jprobe在这两种情况下都可以工作,只要处理程序的原型与被探测函数的原型一致即可。
kretprobes工作原理
当调用register_kretprobe()
时,Kprobes在函数的入口处建立了一个kprobe。当被探测的函数被调用时,Kprobes会保存一份返回地址的副本,并将返回地址替换为 "trampoline "的地址。
trampoline是一段任意的代码——通常只是一条nop指令。在启动时,Kprobes在trampoline上注册了一个kprobe。
当被探测的函数执行其返回指令时,控制权传递给trampoline。该探针被击中。Kprobes的trampoline处理程序调用用户指定的与Kretprobe相关的返回处理程序,然后将保存的指令指针设置为保存的返回地址。这就是从trap返回时恢复执行的地方。
当被探测的函数正在执行时,它的返回地址被存储在一个kretprobe_instance
类型的对象中。
在调用register_kretprobe()
之前,用户设置kretprobe
结构的maxactive
字段,以指定可以同时探测多少个指定函数的实例。register_kretprobe()
预先分配了指定数量的kretprobe_instance
对象。
例如,如果该函数是非递归的,并且在调用时持有一个自旋锁的情况下,maxactive = 1
就足够了。
如果该函数是非递归,并且永远占有CPU(例如,通过semaphore 或抢占),NR_CPUS
应该足够了。
默认值为maxactive <= 0
。如果CONFIG_PREEMPT
被启用,默认的是max(10, 2*NR_CPUS)
。否则,默认值是NR_CPUS
。
如果你把maxactive
设置得太低,也没有什么问题,只是会错过 一些probe。
在kretprobe结构中,nmissed
字段在注册返回的探针时被设置为零,并且在每次进入探针函数但没有探针的情况下,nmissed
字段都会被增加。被探测的函数被输入但没有kretprobe_instance
对象可用于建立返回探针。
Kretprobe entry-handler
Kretprobes还提供了一个可选的用户指定的处理程序,在函数输入时运行。这个处理程序是通过设置kretprobe结构的 entry_handler
字段来指定的。
每当由kretprobe放置在 函数入口处的kprobe被击中时,用户定义的entry_handler
(如果有的话)被调用。
如果entry_handler
返回0(成功),那么保证在函数返回时调用一个相应的返回处理程序。
如果entry_handler
返回一个非零的错误,那么Kprobes将返回地址保持原样。
此外,用户 也可以指定每个返回实例的私有数据作为每个~ kretprobe_instance ~对象的一部分。当在相应的用户入口和返回处理之间共享私有数据时,这一点特别有用 。每个私有数据对象的大小可以在kretprobe注册时通过设置kretprobe结构的data_size
字段指定。这些数据可以通过每个kretprobe_instance
对象的data字段来访问。
如果被探测的函数被输入,但没有可用的kretprobe_instance对象,那么除了增加nmissed计数外。entry_handler
程序的调用也会被跳过。
如何优化kprobes
如果你的内核编译选项CONFIG_OPTPROBES=y
并且 debug.kprobes_optimization
内核参数设置为1,Kprobes试图通过在每个探测点使用跳转指令而不是断点指令来减少探测命中的开销。
Init a Kprobe
当一个probe被注册时,在尝试这种优化之前,Kprobes会在指定的地址插入一个普通的、基于断点的kprobe。
因此,即使不可能对这个特定的探测点进行优化,那里也会有一个探测。
Safety Check
在优化一个probe之前,Kprobes会进行以下安全检查。
- Kprobes将会验证将被跳转指令替换的区域("优化区域")是否完全在一个函数内。(一条跳转指令是多个字节,所以可能会叠加多个 指令)。
- Kprobes分析整个函数并验证是否有 跳转到优化区域。比如:
- 该函数不包含间接跳转。
- 该函数不包含导致异常的指令(因为由异常触发的修复代码可以跳回优化区域——Kprobes检查异常表以验证这一点)。
- 没有跳转到优化区域(除了到第一个 字节)。
- 对于优化区域中的每一条指令,Kprobes都会验证该指令是否可以被越级执行。
Preparing Detour Buffer
接下来,Kprobes准备了一个"detour" buffer,其中包含以下 指令序列。
- 推进CPU寄存器的代码(模拟断点trap)
- 调用trampoline代码,调用用户的探测处理程序。
- 恢复寄存器的代码
- 执行来自优化区域的指令
- 跳回原来的执行路径。
Pre-optimization
在准备好"detour" buffer后,Kprobes会验证以下情况是否存在。
- probe是否有break_handler(即,它是一个jprobe)或apost_handler。
- 优化区域内的其他指令被探测到。
- probe被禁用。
在上述任何一种情况下,Kprobes都不会优化probe。因为这些都是暂时的情况,如果情况改变了,Kprobes会尝试重新开始优化它。
如果kprobe可以被优化,Kprobes将kprobe排到优化列表中,并启动kprobe-optimizer
的工作队列来优化它。
如果待优化的探测点在被优化前被命中,Kprobes通过将CPU的指令指针设置为"detour" buffer中复制的代码,将控制权返回到原始指令路径中——这样至少可以避免单步。
Optimization
Kprobe-optimizer
并没有立即插入跳转指令;相反,为了安全起见,它首先调用synchronize_sched()
,因为CPU有可能在执行优化区域(*)的过程中被中断。
synchronize_sched()
可以确保所有在调用synchronize_sched()
时处于活动状态的中断被完成,但前提是CONFIG_PREEMPT=n
。
所以,这个版本的kprobe优化只支持CONFIG_PREEMPT=n
的内核。
之后,Kprobe-optimizer
调用stop_machine()
,用text_poke_smp()
的跳转指令替换优化后的区域到"detour" buffer。
Unoptimization
当一个已优化的kprobe被取消注册、禁用或被另一个kprobe阻止时,它将被取消优化。
如果这种情况发生在优化完成之前,该kprobe只是从优化列表中将其删除。如果优化已经完成,通过使用text_poke_smp()
,跳转被替换成原始代码(除了第一个字节的int3断点)。
请想象一下,第2条指令被中断,然后优化器在中断处理程序运行时用跳转的地址替换了第2条指令。当中断返回到原始地址时,没有有效的指令,这就造成了一个意外的结果。
这种优化安全检查可以用ksplice用于支持CONFIG_PREEMPT=y
的stop-machine方法取代内核。
注意:
跳跃优化改变了kprobe的pre_handler
行为。在没有优化的情况下,pre_handler
可以通过改变regs->ip
并返回1来改变内核的执行路径。
然而,当probe被优化时,这种修改会被忽略。因此,如果你想调整内核的执行路径,你需要抑制优化,可以使用以下方法。
- 为kprobe的
post_handler
或break_handler
指定一个空函数。 - 执行
sysctl -w debug.kprobes_optimization=n
'。
kprobes黑名单
Kprobes可以探测除自己以外的大部分内核。这意味着有一些函数是Kprobes不能探测的。
探测这些函数可能会导致递归trap或嵌套的探测处理程序可能永远不会被调用。Kprobes将这类函数作为黑名单来管理。如果你想把一个函数加入黑名单,你只需要
- 包括
linux/kprobes.h
- 使用
NOKPROBE_SYMBOL()
宏来指定一个黑名单上的函数。Kprobes根据黑名单检查给定的探测地址,如果给定的地址在黑名单中,则拒绝注册。
架构支持
Kprobes, jprobes, and kretprobes 支持以下架构:
- i386 (Supports jump optimization)
- x86_64 (AMD-64, EM64T) (Supports jump optimization)
- ppc64
- ia64 (Does not support probes on instruction slot1.)
- sparc64 (Return probes not yet implemented.)
- arm
- ppc
- mips
- s390
配置Kprobes
代码语言:javascript复制CONFIG_KPROBES = y
CONFIG_MODULES = y
CONFIG_MODULE_UNLOAD = y
CONFIG_KALLSYMS_ALL = y
CONFIG_DEBUG_INFO = y
API参考
register_kprobe
代码语言:javascript复制#include <linux/kprobes.h>
int register_kprobe(struct kprobe *kp);
在地址kp->addr
处设置一个断点。当断点被命中时,Kprobes调用kp->pre_handler
。
在被探测的指令被单步执行后,Kprobe调用kp->post_handler
。
如果在执行kp->pre_handler
或kp->post_handler
的过程中,或者在被探测指令的单步执行过程中发生故障,Kprobes会调用kp->fault_handler
。
所有的处理程序都可以设置成NULL。如果kp->flags被
设置为KPROBE_FLAG_DISABLED
,该kp将被注册但被禁用。
因此,在调用enable_kprobe(kp)
之前,其处理程序不会被击中。
注意:
- 随着 "symbol_name "字段被引入到kprobe结构中,探测点的地址解析现在将由内核来处理了,具体如下所示:
kp.symbol_name = "symbol_name";
- 如果探测点的符号的偏移量是已知的,则可以使用kprobe结构的 "offset "字段,这个字段用于计算 探测点。
- kprobe的 "symbol_name "或 "addr"只能指定一个。如果两者都被指定,kprobe注册将以-EINVAL失败。
- 对于CISC架构(如i386和x86_64),kprobes代码 不会验证
kprobe.addr
是否在指令边界上。谨慎地使用 "offset"。
register_kprobe()
成功时返回0,否则返回负的errno。
pre-handler (kp->pre_handler)
#include <linux/kprobes.h>
#include <linux/ptrace.h>
int pre_handler(struct kprobe *p, struct pt_regs *regs);
pre-handler
被调用时,p指向与断点相关的kprobe。而regs则是指向包含了断点时保存的寄存器的结构。
post-handler (kp->post_handler):
#include <linux/kprobes.h>
#include <linux/ptrace.h>
void post_handler(struct kprobe *p, struct pt_regs *regs,unsigned long flags);
p和regs与pre_handler的描述相同。flags一般为零。
代码语言:javascript复制fault-handler (kp->fault_handler):
#include <linux/kprobes.h>
#include <linux/ptrace.h>
int fault_handler(struct kprobe *p, struct pt_regs *regs, int trapnr);
p和regs与pre_handler的描述相同。如果成功地处理了该异常,则返回1。
register_jprobe
代码语言:javascript复制#include <linux/kprobes.h>
int register_jprobe(struct jprobe *jp)
在地址jp->kp.addr
处设置一个断点,该地址必须是一个函数的第一条指令的地址。当断点被命中时,Kprobes运行地址为jp->entry
的处理程序。
处理程序应该有与被探测函数相同的参数列表和返回类型;在它返回之前,必须调用jprobe_return()
。(处理程序实际上从未返回,因为jprobe_return()
将控制权返回给Kprobes。)
如果被探测的函数被声明为amlinkage或其他影响args传递方式的东西,处理程序的声明必须与之匹配。
register_jprobe()
成功时返回0,否则返回负的errno。
register_kretprobe
代码语言:javascript复制#include <linux/kprobes.h>
int register_kretprobe(struct kretprobe *rp);
为地址为rp->kp.addr
的函数建立一个返回探针。当该函数返回时,Kprobes调用rp->handler
。你必须在调用rp->maxactive
之前适当地设置rp->maxactive
。
register_kretprobe()
成功时返回0,否则返回一个负的errno
return-probe handler (rp->handler):
#include <linux/kprobes.h>
#include <linux/ptrace.h>
int kretprobe_handler(struct kretprobe_instance *ri, struct pt_regs *regs);
regs与kprobe.pre_handler
的描述相同。ri 指针指向kretprobe_instance
对象,kretprobe_instance
包含以下字段
- ret_addr:返回地址
- rp:指向相应的kretprobe对象
- task:指向相应的任务结构
- data:指向每个返回实例的私有数据;
regs_return_value(regs)
宏提供了一个简单的抽象,用于 从适当的寄存器中提取返回值。
unregister_*probe
代码语言:javascript复制#include <linux/kprobes.h>
void unregister_kprobe(struct kprobe *kp);
void unregister_jprobe(struct jprobe *jp);
void unregister_kretprobe(struct kretprobe *rp);
移除指定的probe。取消注册函数可以在probe被注册后的任何时候被调用。
注意:
如果这些函数发现一个不正确的probe(例如,一个未注册的probe)。它们会清除probe的addr字段。
register_*probes
代码语言:javascript复制#include <linux/kprobes.h>
int register_kprobes(struct kprobe **kps, int num);
int register_kretprobes(struct kretprobe **rps, int num);
int register_jprobes(struct jprobe **jps, int num);
注册指定数组中的每一个n个probe。如果在注册过程中发生任何 错误,直到 在register_*probes
函数返回之前,数组中的所有probe,都可以被安全地取消注册。
- kps/rps/jps:指向*探针数据结构的指针阵列
- num:数组条目的数量。
在使用这些函数之前,你必须分配(或定义)一个指针数组并设置所有的数组条目。
unregister_*probes
代码语言:javascript复制#include <linux/kprobes.h>
void unregister_kprobes(struct kprobe **kps, int num);
void unregister_kretprobes(struct kretprobe **rps, int num);
void unregister_jprobes(struct jprobe **jps, int num);
一次性删除指定数组中的num 个probe 。
注意:
如果这些函数在指定的数组中发现一些不正确的probe(例如:未注册的probe),它们会清除这些不正确probe的addr字段。但是,数组中的其他probe会被正确地取消注册。
disable_*probe
代码语言:javascript复制#include <linux/kprobes.h>
int disable_kprobe(struct kprobe *kp);
int disable_kretprobe(struct kretprobe *rp);
int disable_jprobe(struct jprobe *jp);
暂时停用指定的probe(已经注册的)。你可以通过使用 enable_*probe()
再次使能。
enable_*probe
代码语言:javascript复制#include <linux/kprobes.h>
int enable_kprobe(struct kprobe *kp);
int enable_kretprobe(struct kretprobe *rp);
int enable_jprobe(struct jprobe *jp);
启用已经被disable_*probe()
禁用的*probe
(probe必须已经被注册)。
kprobes的特性和限制
Kprobes允许在同一地址有多个probe。但是,目前在同一个函数上不能同时有多个jprobes。
另外,一个有jprobe或post_handler
的探测点不能被优化。因此,如果你在一个已优化的探测点上安装一个jprobe,或者一个带有post_handler
的kprobe,那么该探测点将自动被取消优化。
一般来说,你可以在内核的任何地方安装一个探针,也可以探测中断处理程序。
如果你试图在实现Kprobes的代码中安装一个probe,register_*probe
函数将返回-EINVAL
如果你在一个可内联的函数中安装一个probe,Kprobes不会试图追寻所有内联函数的实例并在那里安装probe。gcc可能不经询问就内联一个函数,所以如果你没有看到你期望的探测结果,请记住这一点。
probe处理程序可以修改被探测函数的环境——例如,通过修改内核数据结构,或通过修改pt_regs
结构的内容(从断点返回时,这些内容会被恢复到寄存器中)。
因此,Kprobes可以被用来安装一个错误修复程序或注入故障进行测试。当然,Kprobes没有办法区分故意注入的故障和意外的故障。
Kprobes没有试图防止probe处理程序相互影响——例如,探测printk()
,然后从探测处理程序中调用printk()
。如果一个probe处理程序碰到了一个probe,第二个probe的处理程序就不会在该实例中运行,第二个probe的kprobe.nmissed
成员将被递增。
Kprobes不使用互斥,也不分配内存,除非在注册和取消注册时。
probe处理程序在运行时禁止抢占。根据架构和优化状态,处理程序也可能在禁用中断的情况下运行(例如,kretprobe处理程序和优化的kprobe处理程序在x86/x86-64上运行时没有禁用中断)。
由于return probe是通过用trampoline的地址替换返回地址来实现的,因此,堆栈回溯和调用 __builtin_return_address()
通常会得到trampoline的地址,而不是kretprobed函数的真正返回地址。
如果一个函数被调用的次数与它返回的次数不一致,在该函数上注册一个return probe可能会产生不理想的结果。在这种情况下,有一行异常信息被打印出来。有了这些信息,我们就能确定导致问题的kretprobe的确切实例。
代码语言:javascript复制kretprobe BUG!。Processing kretprobe d000000000041aa8 @ c00000000004f48c
如果在进入或退出一个函数时,CPU运行在当前任务以外的堆栈中,在该函数上注册一个return probe可能会产生不好的结果。由于这个原因,Kprobes在x86_64版本的__switch_to()
上不支持返回探针(或kprobes或jprobes)。
在x86/x86-64上,由于Kprobes的跳转优化对指令进行了广泛的修改,所以对优化有一些限制。想象一下,一个由两条2字节指令和一条3字节指令组成的3条指令序列。
代码语言:javascript复制 IA
|
[-2][-1][0][1][2][3][4][5][6][7]
[ins1][ins2][ ins3 ]
[<- DCR ->]
[<- JTPR ->]
- ins1: 第1条指令
- ins2: 第二条指令
- ins3: 第3条指令
- IA:插入地址
- JTPR: 跳跃目标禁止区
- DCR:"detour" buffer
DCR中的指令被复制到kprobe的"detour" buffer,因为DCR中的字节被一个5字节的跳转指令所取代。所以有几个限制。
- DCR中的指令必须是可重定位的。
- DCR中的指令必须不包括调用指令。
- JTPR不能成为任何跳转或调用指令的目标。
- DCR不能跨过函数之间的边界。
总之,这些限制由内核内指令解码器检查,所以你不需要担心这个。
The kprobes debugfs interface
在最近的内核(>2.6.20)中,注册的kprobes列表在/sys/kernel/debug/kprobes/
目录下(假设debugfs被安装在/sys/kernel/debug
)。
/sys/kernel/debug/kprobes/list
: 列出系统中所有注册的探针
c015d71a k vfs_read 0x0
c011a316 j do_fork 0x0
c03dedc5 r tcp_v4_rcv 0x0
第一列提供了插入探针的内核地址。
第二列是探针的类型(k-kprobe,r-kretprobe和j-jprobe),
第三列是探针的符号 offset。
如果被探测的函数属于一个模块,模块名称也被指定。下面几栏显示探针状态。
如果probe是在一个不再有效的虚拟地址(模块初始部分,模块虚拟地址对应于已经被卸载的模块)。这种探针会被标记为[GONE]。
如果probe被暂时禁用,这样的probe被标记为[DISABLED]。
如果probe被优化了,它被标记为[OPTIMIZED]。如果probe是基于ftrace的,它被标记为[FTRACE]。
/sys/kernel/debug/kprobes/enabled
。强制打开/关闭kprobes。提供一个开关来全局地强制打开或关闭注册的kprobes。默认情况下,所有的kprobes都被启用。
通过向该文件echo 0
,所有注册的probe将被解除,直到向该文件echo 1
。
注意,这个开关只是解除所有kprobes,并不改变每个probe的禁用状态。这意味着,如果你用这个开关打开所有的kprobes,被禁用的kprobes(标记为[DISABLED])将不会被启用。
The kprobes sysctl interface
/proc/sys/debug/kprobes-optimization
。打开/关闭kprobes优化。
当CONFIG_OPTPROBES=y
时,这个sysctl界面就会出现,它提供了一个全局性的、强行打开或关闭跳转优化的开关。它提供了一个开关来全局地强制打开或关闭跳转优化。默认情况下,跳转优化是允许的(ON)。
如果你在这个文件中echo 0
或者通过sysctl将debug.kprobes_optimization
设置为0,所有已优化的探针将不被优化,此后注册的任何新探针都不会被优化。
注意,这个开关改变了优化状态。这意味着已优化的probe(标记为[OPTIMIZED])将被取消优化([OPTIMIZED]标签将被删除)。如果这个开关被打开,它们将再次被优化。