关于linux内核调试的实现

linux提供了很多调试工具,比如我喜欢用的Systemtap,用起来很方便,几乎不用大动干戈就可以洞察到内核的一些很重要的行为,这一切是怎么做到的呢?本文带你在内核调试接口的冰山下面走一遭。
很多人都知道,所谓的调试技术无非就两种,一个是下断点,另一个是单步。一般都是在断点的位置开始单步的,这二者十分容易被混淆,很多人认为下了断点就是单步,实际上不是这样的。x86提供了丰富的功能来支持调试,对于断点的支持就是一个编码为0xcc的指令,也就是汇编码:int3,实际上是个中断,当 程序执行过程中遇到0xcc的操作码时,那么它就会触发一个中断,最终由中断处理程序来解决,而中断处理程序往往就是给调试器发信号,并且挂起当前线程,由调试器开始执行,而调试器的行为大家就都知道了,这主要用于用户进程/线程调试,具体看一下代码:
当int3发生时,陷入内核执行到do_int3

void __kprobes do_int3(struct pt_regs *regs, long error_code)

{

         trace_hardirqs_fixup();

         //以下就是调试内核相关的了,一会说内核,现在先说用户空间的0xcc

         if (notify_die(DIE_INT3, "int3", regs, error_code, 3, SIGTRAP)

                         == NOTIFY_STOP)

                 return;

         /*

          * This is an interrupt gate, because kprobes wants interrupts

          * disabled. Normal trap handlers don't.

          */

         restore_interrupts(regs);

         //关键操作,在这个do_trap里给自己发送一个SIGTRAP信号,在返回用户空间的路径上处理该信号,并且调用ptrace_notify来将控制权交给它的调试器。

        do_trap(3, SIGTRAP, "int3", 1, regs, error_code, NULL);

}

在handle_signal中,返回用户处理程序之前要调用以下代码,从而不等该进程返回用户空间就将其挂起,开始调试器的调试。

if (test_thread_flag(TIF_SINGLESTEP))
ptrace_notify(SIGTRAP);
不光是上面的单步检查,在get_signal_to_deliver中有个ptrace_signal才是真正的查点,所以说只要一个进程进入了int3中断处理,那么它就别指望回来了,马上调试器就要接管它了。也就是说只要用户代码中被插入了0xcc,那么就会进入do_int3,然后就会给自己发送一个信号,这样在do_int3返回的时候要执行do_signal。在其中会转入到调试器去执行。
以上说的就是用户的断点操作,那么单步是什么呢?单步实际上也是硬件的机制,只不过它不再是简单一条代码指令的问题
了,在硬件中有个flags标志寄存器,其中TF标志代表单步执行,那么cpu在每执行完一条指令的时候都会检查这个标志位,一旦设置了这个标志位,那么cpu当即触发一个1号中
断就是int1,这样cpu转入到中断处理,在linux中就是debug程序,就是在
初始化的时候设置的:set_intr_gate(1, &debug);,那debug函数在哪里呢?在entry_32.S里面:

KPROBE_ENTRY(debug)

         RING0_INT_FRAME

         cmpl $ia32_sysenter_target,(%esp)

         jne debug_stack_correct

         FIX_STACK(12, debug_stack_correct, debug_esp_fix_insn)

debug_stack_correct:

         pushl $-1                       # mark this as an int

         CFI_ADJUST_CFA_OFFSET 4

         SAVE_ALL

xorl %edx,%edx                  # error code 0

movl %esp,%eax                  # pt_regs pointer

         call do_debug

         jmp ret_from_exception

         CFI_ENDPROC

KPROBE_END(debug)

实际执行的是do_debug函数,它本质上也是一个向自己发送信号,然后在返回的途中被调试器拦截,从而进入调试器执行的一个过程,需要注意的是,在进入单步以后,一般要清除被中断的线程的单步寄存器标志位,否则就会无休止的进入单步中断处理这是不希望的,一般情况就是把控制权交给调试器,由调试器来做决定。
经过上面的叙述,很能看过的人就知道断点和单步的区别了,但是为何要有单步呢?你在某条指令下断,然后调试器执行,接着调试器再在下一条指令下断,不就完了吗?但是要知道,一条指令的执行分为pre,in,post三个阶段,分别为指令执行前,执行中,执行后,一般的调试器都要在前后两个位置安放自己的钩子函数,0xcc只能实现pre钩子,post却无法实现,那么这时就需要int1单步来帮忙了,在一条指令处下断,然后进入调试器,然后在调试器中设置单步,之后恢复原始指令的执行,这条原始指令执行完毕之后因为单步就会进入int1的do_debug进行处理,之后尽可能执行事先安置好的post钩子。用户线程的调试器比如gdb如果不方便看的话,我们下面就来看一下内核的调试器,专门调试内核的,用的就是上面我所叙述的方法,只不过在内核中没有所谓的调试进程,而是内核本身处理这发生的一切。linux内核中的调试接口就是一个叫kprobe的结构,该结构如下:

struct kprobe {

         struct hlist_node hlist;

         /* list of kprobes for multi-handler support */

         struct list_head list;

         /* Indicates that the corresponding module has been ref counted */

         unsigned int mod_refcounted;

         /*count the number of times this probe was temporarily disarmed */

         unsigned long nmissed;

         /* location of the probe point */

         kprobe_opcode_t *addr;

         /* Allow user to indicate symbol name of the probe point */

         const char *symbol_name;

         /* Offset into the symbol */

         unsigned int offset;

         /* Called before addr is executed. */

         kprobe_pre_handler_t pre_handler;

         /* Called after addr is executed, unless... */

         kprobe_post_handler_t post_handler;

         /* ... called if executing addr causes a fault (eg. page fault).

          * Return 1 if it handled fault, otherwise kernel will see it. */

         kprobe_fault_handler_t fault_handler;

         /* ... called if breakpoint trap occurs in probe handler.

          * Return 1 if it handled break, otherwise kernel will see it. */

         kprobe_break_handler_t break_handler;

         /* Saved opcode (which has been replaced with breakpoint) */

         kprobe_opcode_t opcode;

         /* copy of the original instruction */

         struct arch_specific_insn ainsn;

};

可 以看出,上面的结构包含一切调试内核所需要的信息,可以说就是一个调试器了,它起码包含有pre,post两个回调函数,作为钩子函数调用,实际过程就是 先注册一个kprobe,然后将需要调试的地址的前面改称0xcc,在int3处理函数中回调pre钩子函数,同时使能单步,之后恢复原始地址的指令执行,执行完后因为单步的原因进入了int1,在do_debug中执行post函数,执行完毕后冻结单步,恢复原始执行流,看似复杂,实际上和用户空间的 病毒是一样的道理,就是先跳入自己的钩子,等待执行完毕后再恢复原来的执行流,呵呵,可别得罪写内核的这帮家伙,他们发彪了能黑掉全世界的电脑...
本文不分析代码,只是简要讲述了调试执行的流程,可以自行研究代码的实现。实际上想要彻底理解调试器的原理,必须做的两件事就是:1.理解硬件调试寄存器 的功能和配置;2.理解代码。其实真正的接口是硬件提供的,代码只是实现了一个现在很时髦的“业务流程”。下面简要介绍一下代码中关键的部分:
1.注册:

static int __kprobes __register_kprobe(struct kprobe *p,

         unsigned long called_from)

{

         int ret = 0;

         struct kprobe *old_p;

         struct module *probed_mod;

         kprobe_opcode_t *addr;

         addr = kprobe_addr(p);//根据p的symbol_name和offset找到对应的内核空间地址

         if (!addr)

                 return -EINVAL;

         p->addr = addr;

         if (!kernel_text_address((unsigned long) p->addr) ||

             in_kprobes_functions((unsigned long) p->addr))

                 return -EINVAL;

         p->mod_refcounted = 0;

...//我们忽略内核模块的调试

         p->nmissed = 0;

         INIT_LIST_HEAD(&p->list);

         mutex_lock(&kprobe_mutex);

...//我们忽略重复调试

         ret = arch_prepare_kprobe(p);//备份原来的指令到一个地方,以便钩子执行完后恢复,是不是和病毒很像。

         if (ret)

                 goto out;

         INIT_HLIST_NODE(&p->hlist);

         hlist_add_head_rcu(&p->hlist,

                        &kprobe_table[hash_ptr(p->addr, KPROBE_HASH_BITS)]);

         if (kprobe_enabled)

                 arch_arm_kprobe(p);//下断点,就是将指令的前两字节改为0xcc

out:

         mutex_unlock(&kprobe_mutex);

         if (ret && probed_mod)

                 module_put(probed_mod);

         return ret;

}

2.触发int3:
这里用到了linux内核的通知链技术,在kprobe初始化的时候在die_chain通知链上注册了一个函数,每当函数执行die_notify的时 候就会调用这个函数,我想不通调试就调试呗,为啥注册这么恐怖一个通知链上,还die,吓谁呢?在int3处理的一开始就调用了die_notify,这 样就执行了下面的函数 :

static int __kprobes kprobe_handler(struct pt_regs *regs)

{

         kprobe_opcode_t *addr;

         struct kprobe *p;

         struct kprobe_ctlblk *kcb;

         addr = (kprobe_opcode_t *)(regs->ip - sizeof(kprobe_opcode_t));

         if (*addr != BREAKPOINT_INSTRUCTION) {//这种情况说明断点已经被移出了

                 regs->ip = (unsigned long)addr;

                 return 1;

         }

         preempt_disable();

         kcb = get_kprobe_ctlblk();

         p = get_kprobe(addr);

         if (p) {

                 if (kprobe_running()) {

                         if (reenter_kprobe(p, regs, kcb))

                                 return 1;

                 } else {

                         set_current_kprobe(p, regs, kcb);

                         kcb->kprobe_status = KPROBE_HIT_ACTIVE;

                         if (!p->pre_handler || !p->pre_handler(p, regs))

                                 setup_singlestep(p, regs, kcb);//设置单步,具体就是设置regs的eflags

                         return 1;

                 }

         } else if (kprobe_running()) {

                 p = __get_cpu_var(current_kprobe);

                 if (p->break_handler && p->break_handler(p, regs)) {

                         setup_singlestep(p, regs, kcb);

                         return 1;

                 }

         } /* else: not a kprobe fault; let the kernel handle it */

         preempt_enable_no_resched();

         return 0;

}

3.触发int1
由于int3中设置了单步,那么int1在执行完一条指令后就要执行了,在int1的处理中主要就是恢复原始执行流,并且恢复之前调用post钩子,逻辑 上就是这么简单,代码就不分析了。今天十分不想写东西,但是还是将自己的想法写了出来,脑子越来越不好使,记忆力啊!

原文链接: https://blog.csdn.net/dog250/article/details/5303228

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍;

也有高质量的技术群,里面有嵌入式、搜广推等BAT大佬

    关于linux内核调试的实现

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/410485

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年4月26日 下午12:03
下一篇 2023年4月26日 下午12:03

相关推荐