泰晓科技 -- 聚焦 Linux - 追本溯源,见微知著!
网站地址:https://tinylab.org

基于泰晓RISC-V实验箱的Linux公开课
请稍侯

一文解读 Linux Kprobes 好处、用法和工作原理

Wu Daemon 创作于 2021/02/25

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(&current_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_hookkprobes_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 实例。如果 pcur 同时不为空,则属于 kprobe 重入情况,即在处理 kprobe 回调函数时又触发了 kprobe,p 不为空但 cur 为空,则不属于 kprobe 重入情况,大部分情况属于这种。

我们重点分析这种情况,首先设置 kprobe_statusKPROBE_HIT_ACTIVE,表示开始处理 kprobe,然后判断 kprobe 结构体中的 pre_handler 是否存在,如果注册了则执行用户注册的回调接口,就是上一节 “kp.pre_handler = handler_pre” 中的 handler_pre 这个处理函数 ,紧接着更新 kprobe_statusKPROBE_HIT_SS 表示要单步执行注册了的指令,接着判断 post_hander 是否注册,注册好了则更新 kprobe_statusKPROBE_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 等做了更多便利性的封装。



Read Album:

Read Related:

Read Latest: