[置顶] 泰晓 RISC-V 实验箱,配套 30+ 讲嵌入式 Linux 系统开发公开课
一文解读 Linux Kprobes 好处、用法和工作原理
By Wu Daemon of TinyLab.org 2020/12/14
Kprobes 解决了什么问题
在 Linux Kernel 中需要 debug,需要知道内核函数变量的值,比较简单的做法是在内核代码对应的函数中插入 log 信息,但这种方式往往需要重新编译内核,然后启动设备,这往往比较繁琐而且改变内核运行过程。
而利用 Kprobes (Kernel Probes) 这种动态探测技术,可以弥补上述缺点。用户可以自定义 Kprobes 的回调函数,然后指定探测点,当 Kernel 执行到探测点,在回调函数中获取 CPU 寄存器的值,同时内核返回原来的执行流程。
Kprobes 提供了三类回调实现外部探测
Kprobes 技术提供了探测点的调用前、调用后和内存访问出错 3 种回调方式,分别是:
pre_handler
post_handler
fault_handler
其中 pre_handler
是执行到探测点前的回调函数,post_handler
是在执行到探测点后的回调函数,fault_handler
会在内存访问出错时的回调函数。
Kprobes 使用案例:探测 sys_exec 中的 bprm 变量值
我们使用一个实例来说明 Kprobes 动态探测技术。
Linux 运行每一个可执行文件都会调用 sys_exec
,通过这个系统调用陷入内核态,然后执行 do_execveat_common
(Linux 4.4 版本,其他版本未验证)加载可执行新文件,其实现位于 fs/exec.c,其部分代码实现如下:
static int do_execveat_common(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp,
int flags)
{
char *pathbuf = NULL;
struct linux_binprm *bprm;
struct file *file;
struct files_struct *displaced;
int retval;
if (IS_ERR(filename))
return PTR_ERR(filename);
if ((current->flags & PF_NPROC_EXCEEDED) &&
atomic_read(¤t_user()->processes) > rlimit(RLIMIT_NPROC)) {
retval = -EAGAIN;
goto out_ret;
}
current->flags &= ~PF_NPROC_EXCEEDED;
retval = unshare_files(&displaced);
if (retval)
goto out_ret;
retval = -ENOMEM;
bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
if (!bprm)
goto out_files;
retval = prepare_bprm_creds(bprm);
if (retval)
goto out_free;
check_unsafe_exec(bprm);
current->in_execve = 1;
file = do_open_execat(fd, filename, flags);
retval = PTR_ERR(file);
if (IS_ERR(file))
goto out_unmark;
sched_exec();
... ...
}
假设在运行时需要要获取 bprm
这个变量的值,最简单的方式是使用 printk
打印出来,然而缺点是需要修改内核,破坏内核结构。这种情况下,我们恰好可以使用 Kprobes 探测工具在不修改内核结构就能动态获取这个变量的值,获取步骤如下:
1) 使用 objdump
反汇编工具得到 do_execveat_common
这个段的汇编,其中省去了部分不重要的汇编码
wu@ubuntu:~/work/tools$ ./objdump_func.sh vmlinux do_execveat_common
start-address: 0xc029e5e4, end-address: 0xc029ec88
vmlinux: file format elf32-littlearm
Disassembly of section .text:
c029e5e4 <do_execveat_common>:
c029e5e4: e1a0c00d mov ip, sp
c029e5e8: e92dd800 push {fp, ip, lr, pc}
c029e5ec: e24cb004 sub fp, ip, #4
c029e5f0: e24dd080 sub sp, sp, #128 ; 0x80
c029e5f4: e52de004 push {lr} ; (str lr, [sp, #-4]!)
c029e5f8: ebf619ea bl c0024da8 <__gnu_mcount_nc>
c029e5fc: e50b0080 str r0, [fp, #-128] ; 0xffffff80
c029e600: e50b1084 str r1, [fp, #-132] ; 0xffffff7c
c029e604: e50b2088 str r2, [fp, #-136] ; 0xffffff78
c029e608: e50b308c str r3, [fp, #-140] ; 0xffffff74
c029e60c: e3a03000 mov r3, #0
c029e610: e50b3010 str r3, [fp, #-16]
c029e614: e51b3084 ldr r3, [fp, #-132] ; 0xffffff7c
c029e618: e50b303c str r3, [fp, #-60] ; 0xffffffc4
c029e61c: e51b303c ldr r3, [fp, #-60] ; 0xffffffc4
c029e620: e3730a01 cmn r3, #4096 ; 0x1000
...
c029e778: e51b0058 ldr r0, [fp, #-88] ; 0xffffffa8
c029e77c: ebff6a04 bl c0278f94 <__kmalloc>
c029e780: e1a03000 mov r3, r0
c029e784: e50b3024 str r3, [fp, #-36] ; 0xffffffdc
c029e788: e51b3024 ldr r3, [fp, #-36] ; 0xffffffdc
c029e78c: e3530000 cmp r3, #0
c029e790: 0a00012d beq c029ec4c <do_execveat_common+0x668>
c029e794: e51b1024 ldr r1, [fp, #-36] ; 0xffffffdc
c029e798: e30c016c movw r0, #49516 ; 0xc16c
c029e79c: e34c00c0 movt r0, #49344 ; 0xc0c0
c029e7a0: ebf8f34d bl c00db4dc <printk>
c029e7a4: e51b0024 ldr r0, [fp, #-36] ; 0xffffffdc
c029e7a8: ebfffb6d bl c029d564 <prepare_bprm_creds>
c029e7ac: e50b0014 str r0, [fp, #-20] ; 0xffffffec
c029e7b0: e51b3014 ldr r3, [fp, #-20] ; 0xffffffec
c029e7b4: e3530000 cmp r3, #0
c029e7b8: 1a00011d bne c029ec34 <do_execveat_common+0x650>
c029e7bc: e51b0024 ldr r0, [fp, #-36] ; 0xffffffdc
其中 kzalloc
是内联函数,因此其汇编码直接展开到了 do_execveat_common
,展开后直接跳转到 __kmalloc
,根据 ARM32 的 ATPCS 规则,函数返回值保存在 r0 寄存器中,__kmalloc
执行完成后返回值保存到了 r3,也就是 bprm 变量的值,接下来使用 cmp 比较 r3 是否等于0,正好对应 fs/exec.c 这段代码,0xc029e780
绝对地址相对于 do_execveat_common
偏移 0x19c 字节:
$ echo "obase=16;$((0xc029e780 - 0xc029e5e4))" | bc
19C
我们待探测的指令就是这个。
c029e780: e1a03000 mov r3, r0
2) 确定好了待跟踪的指令后,我们编写一个module,可以参看内核目录 sample/kprobes
下的代码 kprobe_example.c
其中 struct kprobe
结构体中的 symbol_name
为需要探测的函数,offset
就是需要探测的指令的相对偏移量,这里指定为上面计算出来的偏移:0x19c,然后注册这个结构体到内核中,当内核执行到探测位置时,我们在探测指令前执行 handler_pre
获取 CPU 寄存器的值,r0 就是要获取的值:
/* For each probe you need to allocate a kprobe structure */
static struct kprobe kp = {
.symbol_name = "do_execveat_common",
.offset = 0x19c,
};
// probe 执行前被调用 N_INFO 和 栈
/* kprobe pre_handler: called just before the probed instruction is executed */
static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
printk("********************hander_pre************************\n");
printk("PC is at %pS\n", (void *)instruction_pointer(regs));
printk("LR is at %pS\n", (void *)regs->ARM_lr);
printk("pc : [<%08lx>] lr : [<%08lx>] psr: %08lx\n",regs->ARM_pc, regs->ARM_lr, regs->ARM_cpsr);
printk("sp : %08lx ip : %08lx fp : %08lx\n",regs->ARM_sp, regs->ARM_ip, regs->ARM_fp);
printk("r10: %08lx r9 : %08lx r8 : %08lx\n",regs->ARM_r10, regs->ARM_r9,regs->ARM_r8);
printk("r7 : %08lx r6 : %08lx r5 : %08lx r4 : %08lx\n",
regs->ARM_r7, regs->ARM_r6,
regs->ARM_r5, regs->ARM_r4);
printk("r3 : %08lx r2 : %08lx r1 : %08lx r0 : %08lx\n",
regs->ARM_r3, regs->ARM_r2,
regs->ARM_r1, regs->ARM_r0);
return 0;
}
/* kprobe post_handler: called after the probed instruction is executed */
static void handler_post(struct kprobe *p, struct pt_regs *regs,
unsigned long flags)
{
printk("**************handler_post*****************\n");
printk("PC is at %pS\n", (void *)instruction_pointer(regs));
printk("LR is at %pS\n", (void *)regs->ARM_lr);
printk("pc : [<%08lx>] lr : [<%08lx>] psr: %08lx\n",regs->ARM_pc, regs->ARM_lr, regs->ARM_cpsr);
printk("sp : %08lx ip : %08lx fp : %08lx\n",regs->ARM_sp, regs->ARM_ip, regs->ARM_fp);
printk("r10: %08lx r9 : %08lx r8 : %08lx\n",regs->ARM_r10, regs->ARM_r9,regs->ARM_r8);
printk("r7 : %08lx r6 : %08lx r5 : %08lx r4 : %08lx\n",
regs->ARM_r7, regs->ARM_r6,
regs->ARM_r5, regs->ARM_r4);
printk("r3 : %08lx r2 : %08lx r1 : %08lx r0 : %08lx\n",
regs->ARM_r3, regs->ARM_r2,
regs->ARM_r1, regs->ARM_r0);
}
/*
* fault_handler: this is called if an exception is generated for any
* instruction within the pre- or post-handler, or when Kprobes
* single-steps the probed instruction.
*/
static int handler_fault(struct kprobe *p, struct pt_regs *regs, int trapnr)
{
printk(KERN_INFO "fault_handler: p->addr = 0x%p, trap #%dn",
p->addr, trapnr);
/* Return 0 because we don't handle the fault. */
return 0;
}
static int __init kprobe_init(void)
{
int ret;
kp.pre_handler = handler_pre;
kp.post_handler = handler_post;
kp.fault_handler = handler_fault;
ret = register_kprobe(&kp);
if (ret < 0) {
printk(KERN_INFO "register_kprobe failed, returned %d\n", ret);
return ret;
}
printk(KERN_INFO "Planted kprobe at %p\n", kp.addr);
return 0;
}
static void __exit kprobe_exit(void)
{
unregister_kprobe(&kp);
printk(KERN_INFO "kprobe at %p unregistered\n", kp.addr);
}
3) 编译该模块,在文件系统加载该模块后,打印出的 r0 寄存器就是 bprm
的值。可以添加个 log 直接打印该变量验证下。
/mnt # uname -r
********************hander_pre************************
PC is at do_execveat_common+0x19c/0x6a4
LR is at _raw_write_unlock_irqrestore+0x84/0xa0
pc : [<c029e780>] lr : [<c0bd9f50>] psr: 60000013
sp : c4c0beb8 ip : c4c0bb38 fp : c4c0bf44
r10: 00000000 r9 : c4c08000 r8 : c0015f84
r7 : 0000000b r6 : 001ec763 r5 : 0021ca90 r4 : 0021cac4
r3 : c3e62540 r2 : 000001c0 r1 : c029e780 r0 : c3e62540
**************handler_post*****************
PC is at do_execveat_common+0x1a0/0x6a4
LR is at _raw_write_unlock_irqrestore+0x84/0xa0
pc : [<c029e784>] lr : [<c0bd9f50>] psr: 60000013
sp : c4c0beb8 ip : c4c0bb38 fp : c4c0bf44
r10: 00000000 r9 : c4c08000 r8 : c0015f84
r7 : 0000000b r6 : 001ec763 r5 : 0021ca90 r4 : 0021cac4
r3 : c3e62540 r2 : 000001c0 r1 : c029e780 r0 : c3e62540
bprm=c3e62540
4.0.0+
通过上述例子可以看出 Kprobes 动态探测技术的优势,在不改变内核运行流程的基础上,我们可以获得内核内部变量的信息。
Kprobes 工作原理:探测点处替换为 breakpoint 指令触发异常处理
在上述内核模块的 pre_hander
中插入 dump_stack
可获得调用栈如下:
[<c00265b8>] (unwind_backtrace) from [<c001e570>] (show_stack+0x3c/0x48)
[<c001e570>] (show_stack) from [<c05ec744>] (__dump_stack+0x24/0x2c)
[<c05ec744>] (__dump_stack) from [<c05ec824>] (dump_stack+0xd8/0x100)
[<c05ec824>] (dump_stack) from [<bf000048>] (handler_pre+0x24/0x144 [kprobe_example])
[<bf000048>] (handler_pre [kprobe_example]) from [<c01498c0>] )
...
[<c0b93580>] (kprobe_handler) from [<c0b937b0>] (kprobe_trap_handler+0x40/0x94)
[<c0b937b0>] (kprobe_trap_handler) from [<c001ef30>] (call_undef_hook+0xf8/0x110)
[<c001ef30>] (call_undef_hook) from [<c00083ec>] (do_undefinstr+0x16c/0x254)
[<c00083ec>] (do_undefinstr) from [<c0b91010>] (__und_svc_finish+0x0/0x30)
Exception stack(0xc359be30 to 0xc359be78)
be20: c3f7cc40 c0291124 000001c0 c3f7cc40
be40: 0021ca9c 0021ca68 001ec763 0000000b c0015664 c3598000 00000000 c359bf44
be60: c359bb40 c359beb8 c0b90778 c0291124 60000013 ffffffff
[<c0b91010>] (__und_svc_finish) from [<c0291124>] (do_execveat_common+0x19c/0x694)
[<c0291124>] (do_execveat_common) from [<c029166c>] (do_execve+0x50/0x60)
[<c029166c>] (do_execve) from [<c02919b4>] (SyS_execve+0x58/0x70)
[<c02919b4>] (SyS_execve) from [<c00154a0>] (ret_fast_syscall+0x0/0x4c)
通过调用栈分析,知道触发了未定义指令中断 do_undefinstr
,是此类异常发生的 C 语言入口,我们就不具体分析异常的来龙去脉了。
当使用 register_kprobe
把 Kprobes 结构体注册时,用一个未定义指令(实际为 breakpoint,详见 kprobes)替换要检测的指令,当执行到该指令时,会执行 do_undefinstr
,进而执行 call_undef_hook
,kprobes_arm_break_hook->fn
会被依次调用,这个 fn
就是 kprobe_trap_handler
,进而执行 kprobe_handle
。
asmlinkage void __exception do_undefinstr(struct pt_regs *regs)
{
unsigned int instr;
siginfo_t info;
void __user *pc;
pc = (void __user *)instruction_pointer(regs);
if (processor_mode(regs) == SVC_MODE) {
instr = __mem_to_opcode_arm(*(u32 *) pc);
} else if (thumb_mode(regs)) {
if (get_user(instr, (u16 __user *)pc))
goto die_sig;
instr = __mem_to_opcode_thumb16(instr);
if (is_wide_instruction(instr)) {
unsigned int instr2;
if (get_user(instr2, (u16 __user *)pc+1))
goto die_sig;
instr2 = __mem_to_opcode_thumb16(instr2);
instr = __opcode_thumb32_compose(instr, instr2);
}
} else {
if (get_user(instr, (u32 __user *)pc))
goto die_sig;
instr = __mem_to_opcode_arm(instr);
}
if (call_undef_hook(regs, instr) == 0) //处理函数 如果fn为空,则进入oops,不为空这里进入kprobe_trap_handler
return;
die_sig:
info.si_signo = SIGILL;
info.si_errno = 0;
info.si_code = ILL_ILLOPC;
info.si_addr = pc;
arm_notify_die("Oops - undefined instruction", regs, &info, 0, 6);
}
static int call_undef_hook(struct pt_regs *regs, unsigned int instr)
{
struct undef_hook *hook;
unsigned long flags;
int (*fn)(struct pt_regs *regs, unsigned int instr) = NULL;
list_for_each_entry(hook, &undef_hook, node) //遍历链表undef_hook中的每一个hook
if ((instr & hook->instr_mask) == hook->instr_val &&
(regs->ARM_cpsr & hook->cpsr_mask) == hook->cpsr_val)
fn = hook->fn; //匹配成功吧hook->fn赋值给
return fn ? fn(regs, instr) : 1; //fn不为空 则执行 这里fn 即为 kprobe_trap_handler
}
再来看看 kprobes_arm_break_hook
这个钩子怎么初始化并注册到内核中的,它使用 register_undef_hook
注册到内核,这样在触发指令 KPROBE_ARM_BREAKPOINT_INSTRUCTION
将会调用 undef_hook
结构体中的 fn
字段 kprobe_trap_handler
:
static struct undef_hook kprobes_arm_break_hook = {
.instr_mask = 0x0fffffff,
.instr_val = KPROBE_ARM_BREAKPOINT_INSTRUCTION,
.cpsr_mask = MODE_MASK,
.cpsr_val = SVC_MODE,
.fn = kprobe_trap_handler, //handler
};
int __init arch_init_kprobes()
{
arm_probes_decode_init();
register_undef_hook(&kprobes_arm_break_hook); //注册一个指令异常处理的钩子
return 0;
}
kprobe_trap_handler
会调用 kprobe_handler
,形参保存了发生异常时刻的 CPU 寄存器的值,其中 kprobe_ctlblk
结构体的 kprobe_status
字段保存了 kprobe 的状态信息。
kprobe_handler
首先通过 kprobe_running
获取当前正在处理的 kprobes 实例,然后通过 get_kprobe
获取待处理 kprobe 实例。如果 p
和 cur
同时不为空,则属于 kprobe 重入情况,即在处理 kprobe 回调函数时又触发了 kprobe,p
不为空但 cur
为空,则不属于 kprobe 重入情况,大部分情况属于这种。
我们重点分析这种情况,首先设置 kprobe_status
为 KPROBE_HIT_ACTIVE
,表示开始处理 kprobe,然后判断 kprobe 结构体中的 pre_handler
是否存在,如果注册了则执行用户注册的回调接口,就是上一节 “kp.pre_handler = handler_pre” 中的 handler_pre 这个处理函数 ,紧接着更新 kprobe_status
为 KPROBE_HIT_SS
表示要单步执行注册了的指令,接着判断 post_hander
是否注册,注册好了则更新 kprobe_status
为 KPROBE_HIT_SSDONE
,表示单步执行结束,最后调用 post_handler
,是指令执行完成后的回调:
// kprobe_status settings
#define KPROBE_HIT_ACTIVE 0x00000001 //开始处理kprobe
#define KPROBE_HIT_SS 0x00000002 //单步执行
#define KPROBE_REENTER 0x00000004 //重复触发kprobe
#define KPROBE_HIT_SSDONE 0x00000008 //kprobe单步执行结束
void __kprobes kprobe_handler(struct pt_regs *regs)
{
struct kprobe *p, *cur;
struct kprobe_ctlblk *kcb;
kcb = get_kprobe_ctlblk();
cur = kprobe_running();
p = get_kprobe((kprobe_opcode_t *)regs->ARM_pc); //根据pc值获取 kprobe
if (p) {
if (cur) { // p和cur的kprobe实例同时存在 在运行kprobe回调时又触发了kprobe。对于重入只处理前一次kprobe执行的回调
/* Kprobe is pending, so we're recursing. */
switch (kcb->kprobe_status) {
case KPROBE_HIT_ACTIVE:
case KPROBE_HIT_SSDONE: ////kprobe单步执行结束
/* A pre- or post-handler probe got us here. */
kprobes_inc_nmissed_count(p);
save_previous_kprobe(kcb);
set_current_kprobe(p);
kcb->kprobe_status = KPROBE_REENTER;
singlestep(p, regs, kcb);
restore_previous_kprobe(kcb);
break;
default:
/* impossible cases */
BUG();
}
} else if (p->ainsn.insn_check_cc(regs->ARM_cpsr)) { //p存在但cur不存在
set_current_kprobe(p);
kcb->kprobe_status = KPROBE_HIT_ACTIVE;
if (!p->pre_handler || !p->pre_handler(p, regs)) { //判断pre_hander是否注册,注册好了就调用pre_handler
kcb->kprobe_status = KPROBE_HIT_SS;
singlestep(p, regs, kcb); //单步执行
if (p->post_handler) { //判断post_hander是否注册
kcb->kprobe_status = KPROBE_HIT_SSDONE;
p->post_handler(p, regs, 0); //调用post_handler
}
reset_current_kprobe();
}
} else {
singlestep_skip(p, regs);
}
} else if (cur) {
if (cur->break_handler && cur->break_handler(cur, regs)) {
kcb->kprobe_status = KPROBE_HIT_SS;
singlestep(cur, regs, kcb);
if (cur->post_handler) {
kcb->kprobe_status = KPROBE_HIT_SSDONE;
cur->post_handler(cur, regs, 0);
}
}
reset_current_kprobe();
} else {
}
}
小结
(本节由编辑补充)
本文从最底层的视角详细解释了 Kprobes 的用法、原理和好处。
Kprobes 通过在目标位置替换为 breakpoint 指令的方式实现了任意位置的运行时探测,不过其使用不是特别灵活,所以在 Kprobes 之上,perf probe 和 eBPF 等做了更多便利性的封装。
猜你喜欢:
- 我要投稿:发表原创技术文章,收获福利、挚友与行业影响力
- 知识星球:独家 Linux 实战经验与技巧,订阅「Linux知识星球」
- 视频频道:泰晓学院,B 站,发布各类 Linux 视频课
- 开源小店:欢迎光临泰晓科技自营店,购物支持泰晓原创
- 技术交流:Linux 用户技术交流微信群,联系微信号:tinylab
支付宝打赏 ¥9.68元 | 微信打赏 ¥9.68元 | |
请作者喝杯咖啡吧 |