显示标签为“kernel”的博文。显示所有博文
显示标签为“kernel”的博文。显示所有博文

2013年12月25日星期三

Kprobes源码分析----kprobe的注册

    kprobes是一个动态地收集调试和性能信息的工具,使用它几乎可以跟踪任何函数或被执行的指令。它的机制也很简单,就是将被探测的位置的指令替换为断点指令(不考虑jmp优化),断点指令被执行后会通过notifier_call_chain机制来通知kprobes,kprobes会首先调用用户指定的pre_handler接口。执行pre_handler接口后会单步执行原始的指令,如果用户也指定了post_handler接口,会在调用post_handler接口后结束处理。
    基本的处理过程如下图所示:

    如果你没接触过kprobes,上面提到的pre_handler接口和post_handler接口可能让你疑惑,所以首先我们来介绍下和这些接口相关的kprobe结构。

    1.kprobe结构

    当你要探测一个函数时,需要首先分配一个kprobe实例,然后设置要探测的函数名称或地址,以及自己的处理函数。kprobe结构描述了探测点的位置以及要做的处理操作,其结构定义如下:
struct kprobe {
    struct hlist_node hlist;
    /* list of kprobes for multi-handler support */
    struct list_head list;
    /*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;
    /*
     * Indicates various status flags.
     * Protected by kprobe_mutex after this kprobe is registered.
     */

    u32 flags;
};
    其成员说明如下:
    hlist:所有注册的kprobe都会添加到kprobe_table哈希表中,hlist成员用来链接到某个槽位中。
    list:如果在同一个位置注册了多个kprobe,这些kprobe会形成一个队列,队首是一个特殊的kprobe实例,list成员用来用来链接到这个队列中。当探测点被触发时,队首的kprobe实例中注册的handler会逐个遍历队列中注册的handler。
    nmissed:记录当前的probe没有被处理的次数。
    addr:这个成员有两个作用,一个是用户在注册前指定探测点的基地址(加上偏移得到真实的地址),另一个是在注册后保存探测点的实际地址。在注册前,这个可以不指定,由kprobes来初始化。如果没有指定,必须指定探测的位置的符号信息,例如函数名。
    symbol_name:探测点的符号名称。名称和地址不能同时指定,否则注册时会返回EINVAL错误。
    offset:探测点相对于addr地址的偏移
    pre_handler:这个接口在断点异常触发之后,开始单步执行原始的指令之前被调用
    post_handler:在单步执行原始的指令后会被调用
    fault_handler:如果执行过程中出错,则调用该接口。如果返回1,则表示错误由kprobes处理,否则由内核来处理。
    break_handler:在调用probe的处理函数(比如pre_handler接口)时触发了断点异常会调用该接口,。断点异常是通过中断门来处理的(参见trap_init()),在调用相应的处理函数前会自动关闭中断。关中断的情况下虽然不会接收可屏蔽的中断,但是CPU引发的异常或者NMI还是会接收到,所以有可能会发生断点异常处理嵌套,jprobes的实现就用到了这点。
    opcode:原始指令,在被替换为断点指令(X86下是int 3指令)前保存。
    ainsn:保存了探测点原始指令的拷贝。这里拷贝的指令要比opcode中存储的指令多,拷贝的大小为MAX_INSN_SIZE * sizeof(kprobe_opcode_t)。
    flags:探测点的标志,可取的值为KPROBE_FLAG_GONE和​KPROBE_FLAG_DISABLED。如果设置了KPROBE_FLAG_GONE标志,表示断点指令被移除;如果设置了 KPROBE_FLAG_DISABLED,则表示只注册probe,但是并不启用它,也就是说在断点异常触发时并不会调用该probe的接口。

    2.探测点的注册----register_kprobe()

    2.1 探测点地址的计算
register_kprobe()用来在指定的位置注册探测点。它会首先调用kprobe_addr()计算需要插入探测点的地址,源码如下所示:
int __kprobes register_kprobe(struct kprobe *p)
{
    int ret = 0;
    struct kprobe *old_p;
    struct module *probed_mod;
    kprobe_opcode_t *addr;

    addr = kprobe_addr(p);
    if (!addr)
        return -EINVAL;
    p->addr = addr;
    ......
}
    kprobe_addr()会首先检查用户是否设置探测点的地址,如果指定了地址,并且指定了符号信息(symbol_name成员也设置了),它会直接返回NULL,不会去检查符号信息和指定的地址是否一致。通过这里的代码不难发现,如果同时指定了符号信息和地址,register_kprobe()会返回EINVAL错误。
   如果没有指定探测点的地址,而是指定了符号信息,kprobe_addr()会调用kprobe_lookup_name()在内核符号表中查找符号对应的地址,在找到对应的符号地址后,加上偏移就得到探测点的实际位置。
  如果只指定了探测点的地址,kprobe_addr()会将这个地址直接加上偏移返回。
  kprobe_addr()返回的地址会被设置到kprobe的addr成员中,注册后通过这个成员就可以拿到被探测位置的地址。利用这个特性,你可以通过kprobe来获取内核中某一个函数的运行时地址。假设你在编写一个内核模块,但是你要使用函数内核没有导出,这个特性就很有用了。
    2.2 检查探测点地址
   计算探测点的地址后,接着就是要检查这个地址是否可以被探测,源码如下所示:
    ......
    preempt_disable();
    if (!kernel_text_address((unsigned long) p->addr) ||
        in_kprobes_functions((unsigned long) p->addr)) {
        preempt_enable();
        return -EINVAL;
    }

    /* User can pass only KPROBE_FLAG_DISABLED to register_kprobe */
    p->flags &= KPROBE_FLAG_DISABLED;

    /*
     * Check if are we probing a module.
     */

    probed_mod = __module_text_address((unsigned long) p->addr);
    if (probed_mod) {
        /*
         * We must hold a refcount of the probed module while updating
         * its code to prohibit unexpected unloading.
         */

        if (unlikely(!try_module_get(probed_mod))) {
            preempt_enable();
            return -EINVAL;
        }
        /*
         * If the module freed .init.text, we couldn't insert
         * kprobes in there.
         */

        if (within_module_init((unsigned long)p->addr, probed_mod) &&
            probed_mod->state != MODULE_STATE_COMING) {
            module_put(probed_mod);
            preempt_enable();
            return -EINVAL;
        }
    }
    preempt_enable();
    ......
    kprobes只能用作内核函数的探测,所以在注册前必须检查探测点的地址是否是在内核地址空间中。探测点的地址要么是在内核映像中(_stext和_etext之间,如果是在系统启动阶段,要在_sinittext和_einittext之间),要么是是在某个内核模块的地址空间中。具体的判断是在kernel_text_address()函数中处理的。
    实现kprobes的内核函数或者一些特殊的函数是不能探测的,所以还要进一步检测探测点的地址是否在这些限制空间内,这是由in_kprobes_functions()函数来处理的。kprobes把所有不能探测的函数都放在.kprobes.text section(使用__kprobes宏)中,这个段的起始地址是__kprobes_text_start和__kprobes_text_end。如果探测的地址在__kprobes_text_start和__kprobes_text_end之间,会返回EINVAL错误。某些函数也是不能探测的,但是已经放在其他section了,这些函数会放在kprobe_blacklist数组中。如果用户探测的函数在这个黑名单中,也会返回EINVAL错误。2.6.32中只有preempt_schedule()函数在kprobe_blacklist数组中,3.12中增加了native_get_debugreg()、irq_entries_start()、common_interrupt()、mcount()。
    如果探测点的地址是在一个内核模块中,需要增加对该模块的引用,以免模块提前卸载。但是如果模块已经开始卸载,此时也是不能注册探测点的,同样会返回EINVAL错误。模块中.init.text section占用的内存,在模块加载后,如果不再使用是可以被释放掉的。如果探测点在模块的.init.text section中,而该section已经被释放,此时是不能注册探测点的,同样会返回EINVAL错误。
    前面说了一大堆会返回EINVAL错误的情况,是时候要总结一下了。在以下情况中,会注册失败,返回EINVAL错误:
      1)    函数被__kprobes宏修饰的函数,这些函数会被放在.kprobes.text section中。这种类型大多数是实现kprobes的代码
      2)    kprobe_blacklist数组中列出的函数。2.6.32中只有preempt_schedule()函数,最新的3.12内核中又增加了native_get_debugreg()、irq_entries_start()、    
             common_interrupt()、mcount()。
      3)    要探测的地址位于一个模块的.init.text section,但是这个section已经被释放。
2.3 注册kprobe
    经过前面的处理后,可以开始执行真正的注册操作了,源码如下所示:
    p->nmissed = 0;
    INIT_LIST_HEAD(&p->list);
    mutex_lock(&kprobe_mutex);
    old_p = get_kprobe(p->addr);
    if (old_p) {
        ret = register_aggr_kprobe(old_p, p);
        goto out;
    }

    mutex_lock(&text_mutex);
    ret = arch_prepare_kprobe(p);
    if (ret)
        goto out_unlock_text;

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

    if (!kprobes_all_disarmed && !kprobe_disabled(p))
        arch_arm_kprobe(p);

out_unlock_text:
    mutex_unlock(&text_mutex);
out:
    mutex_unlock(&kprobe_mutex);
    在初始化kprobe的相关成员后,首先要获取kprobe_mutex互斥锁,这个互斥锁用来保护kprobe_table哈希表,系统中所有已注册的kprobe实例都保存在这个哈希表中。
    kprobes允许在同一个探测点注册多个kprobe,如果调用get_kprobe()能找到一个kprobe实例,说明已经在当前的探测点注册了一个kprobe,这种情况下会调用register_aggr_kprobe()来处理。如果已注册的kprobe只有一个,register_aggr_kprobe()会分配一个新的kprobe实例,将已注册的kprobe中的内容拷贝到新分配的kprobe中,然后把已注册的kprobe的hanlder设置为kprobes提供的特殊的hanlder,这些handler在异常触发时会逐个遍历用户注册的kprobe中的handler。接着会将新分配的kprobe(不是当前调用register_kprobe()注册的那个)链接到前面已经注册的kprobe,形成一个队列。之后如果还有新的kprobe注册到当前的探测点,会直接添加到队列的末尾。注意,前面描述的register_aggr_kprobe()过程中还没有操作当前要注册的kprobe实例,在前面的操作都做完之后,就直接把新注册的kprobe实例添加到队尾,然后返回。这个过程中结构的变化如下图所示:

    如果在当前探测点没有注册过kprobe,则调用arch_prepare_kprobe()将被探测位置的指令保存到kprobe结构的ainsn成员中,并且被探测位置的第一条指令保存到opcode成员中。
    接着就可以将krobe实例添加到kprobe_table哈希表中,这个操作必须在将探测点的指令替换成断点指令前完成,否则有可能在注册完成前断点异常已经发生了。
    经过前面的处理,现在就剩下最后一步了,将探测点的指令替换为断点指令。这是通过调用text_poke()来完成,它可以将替换指定位置的指令。不过在调用text_poke()替换指令前,必须要获取text_mutex互斥锁。text_poke()真是太好用了,可以做很多事情.......
  至此,整个注册过程就完成了。

2013年12月18日星期三

Linux CFS调度系统----进程优先级和权重

Linux CFS调度系统----进程优先级和权重     调度器的一般原理是,根据所能分配的计算能力,向系统中的每个进程提供最大的公平性。调度器分配的资源就是CPU的时间,尽量保证每个进程都获得相同的CPU时间。Linux的CFS调度系统不同于O(1)调度器,不需要时间片概念,至少不需要传统的时间片。CFS调度系统只考虑进程的等待时间,即进程在就绪队列中已经等待了多长时间。但是并非系统上的所有进程都同样重要,调度器也要保证一些重要的进程优先执行,或者要获得更多的CPU时间。对于应用层来说,进程的重要性就是通过优先级来标识的,而CFS调度器在计算进程的虚拟运行时间或者调度延迟时都是使用的权重,下面我们来看一下这两者是如何计算和转换的。
1. 进程优先级
    普通进程的优先级由task_struct结构中的prio、static_prio和normal_prio三个成员来描述。static_prio是静态优先级,默认值是在进程创建时从父进程继承过来的,可以使用nice()、sched_setscheduler()或者setpriority()修改。normal_prio是普通优先级,是根据进程的静态优先级和调度策略计算出来的优先级。prio是动态优先级,调度器会根据该成员表示的优先级来给进程分配CPU时间。由于在某些情况下会暂时提高进程的优先级,因此需要3个成员来表示进程的优先级,在提高进程优先级运行的持续时间中,普通和静态优先级的值是不变的。这三个成员的值越低,优先级越高。
    实时进程的优先级是由task_struct结构中的rt_priority成员来描述的,该成员保存的值不会代替上面的三个成员。该成员的值越大,优先级越高,实时进程的优先级计算方法和普通进程不同,后面我们会看到。
2. 进程优先级的计算
    进程创建时,在copy_process()中调用的dup_task_struct()会将父进程的所有内容都拷贝到子进程的task_struct结构实例中,所以初始时父子进程中所有描述优先级的成员的值都是相同的。在初始化完成后,会调用sched_fork()和调度系统交互,将子进程添加到调度系统中。在sched_fork()中会调整子进程的优先级,相关代码如下所示:
void sched_fork(struct task_struct *p, int clone_flags)
{
......
    /*
     * Revert to default priority/policy on fork if requested.
     */

    if (unlikely(p->sched_reset_on_fork)) {
        if (p->policy == SCHED_FIFO || p->policy == SCHED_RR) {
            p->policy = SCHED_NORMAL;
            p->normal_prio = p->static_prio;
        }

        if (PRIO_TO_NICE(p->static_prio) < 0) {
            p->static_prio = NICE_TO_PRIO(0);
            p->normal_prio = p->static_prio;
            set_load_weight(p);
        }

        /*
         * We don't need the reset flag anymore after the fork. It has
         * fulfilled its duty:
         */

        p->sched_reset_on_fork = 0;
    }

    /*
     * Make sure we do not leak PI boosting priority to the child.
     */

    p->prio = current->normal_prio;
......
}
    如果父进程通过sched_setscheduler()系统调用指定了SCHED_RESET_ON_FORK策略,父进程的sched_reset_on_fork成员的值是1,由于子进程的内容是从父进程拷贝而来的,子进程的sched_reset_on_fork的值也是1,所以这里使用的子进程中的sched_reset_on_fork来判断。同理,这里使用p获取的成员的值在没有修改前,都是和父进程一致的。
    如果sched_reset_on_fork成员设置,会重新设置子进程的优先级。如果父进程是实时进程,则会将子进程的调度策略重置为SCHED_NORMAL,即普通进程所属的调度策略。普通优先级normal_prio是根据调度策略和静态优先级static_prio计算出来的,如果父进程是实时进程,其normal_prio是根据实时策略计算的,而此处修改了子进程p的调度策略,所以要重新设置子进程的普通优先级normal_prio,普通进程的normal_prio和static_prio的值是一样的,参见effective_prio()。如果父进程的静态优先级高于DEFAULT_PRIO(值为120,对应的nice值为0),会将子进程的静态优先级重置为DEFAULT_PRIO。普通优先级和权重都是根据静态优先级计算的,所以如果修改了静态优先级,也重新计算普通优先级和权重。SCHED_RESET_ON_FORK策略只作用于主动设置的进程,不会传递到子进程,所以这里会将子进程的sched_reset_on_fork初始化为0。之所以引入这样的策略,主要是出于安全考虑,避免fork炸弹,关于SCHED_RESET_ON_FORK策略的更多信息,参见这里。
    进程创建后,普通进程可以通过setpriority()和nice()系统调用来调整进程的优先级(不能用于实时进程),在内核中主要是由set_user_nice()来处理的,代码如下所示:
void set_user_nice(struct task_struct *p, long nice)
{
......
    if (TASK_NICE(p) == nice || nice < -20 || nice > 19)
        return;
......
    /*
     * The RT priorities are set via sched_setscheduler(), but we still
     * allow the 'normal' nice value to be set - but as expected
     * it wont have any effect on scheduling until the task is
     * SCHED_FIFO/SCHED_RR:
     */

    if (task_has_rt_policy(p)) {
        p->static_prio = NICE_TO_PRIO(nice);
        goto out_unlock;
    }
......
    p->static_prio = NICE_TO_PRIO(nice);
    set_load_weight(p);
    old_prio = p->prio;
    p->prio = effective_prio(p);
......
out_unlock:
    task_rq_unlock(rq, &flags);
}
    用户空间设置的时候使用的是nice值,nice值的范围是[-20,19],而内核使用的数值范围是[0,139],用来表示内部优先级。[0,99]的范围专供实时进程使用,nice值[-20,19]映射到[100,139]的范围,用于普通进程,实时进程的优先级总是比普通进程更高。这两种范围都是值越低,优先级越高。
    TASK_NICE宏将进程p的静态优先级转换为nice值,如果用户设置的nice值和当前进程的nice值相同,则直接返回。如果设置的nice不是在[-20,19]之间的返回,说明设置的值有误,直接返回。
    setpriority()和nice()系统调用不能用于实时进程,如果当前设置的进程是实时进程,会将指定的nice值对应的优先级设置到static_prio成员上,但是不会产生任何效果。如果是普通进程,设置的nice值会通过NICE_TO_PRIO宏转换为对应的内核优先级,然后设置到静态优先级static_prio。动态优先级和普通优先级都是在effective_prio()函数中计算的,动态优先级是effective_prio()的返回值,普通优先级是由effective_prio()中调用的normal_prio()返回的。如果是普通进程,并且没有将优先级提高到实时优先级,普通进程的普通优先级static_prio和动态优先级prio的值都是和静态优先级static_prio的值是一样的。
    实时进程的优先级调整要使用sched_setscheduler()系统调用来调整,该系统调用还可以设置进程的优先级。如果要将进程的调度策略设置为SCHED_NORMAL、SCHED_BATCH或者SCHED_IDLE,param参数中的sched_priority成员必须设置为0,否则内核会范围EINVAL错误,具体的描述参见man sched_setscheduler()。内核中是在__sched_setscheduler()函数中使用了一个非常巧妙的判断来做这个检查的,如下所示:
static int __sched_setscheduler(struct task_struct *p, int policy,
                struct sched_param *param, bool user)
{
......
    if (rt_policy(policy) != (param->sched_priority != 0))
        return -EINVAL;

    __setscheduler(rq, p, policy, param->sched_priority);
......
}
    真正的操作是在__setscheduler()中完成的,代码如下所示:
/* Actually do priority change: must hold rq lock. */
static void
__setscheduler(struct rq *rq, struct task_struct *p, int policy, int prio)
{
    BUG_ON(p->se.on_rq);

    p->policy = policy;
    switch (p->policy) {
    case SCHED_NORMAL:
    case SCHED_BATCH:
    case SCHED_IDLE:
        p->sched_class = &fair_sched_class;
        break;
    case SCHED_FIFO:
    case SCHED_RR:
        p->sched_class = &rt_sched_class;
        break;
    }

    p->rt_priority = prio;
    p->normal_prio = normal_prio(p);
    /* we are holding p->pi_lock already */
    p->prio = rt_mutex_getprio(p);
    set_load_weight(p);
}
    这里的操作也很简单,首先根据指定的调度策略来指定进程所属的调度类,然后将设置的优先级设置到实时优先级rt_priority成员上,并重新计算普通优先级和动态优先级。但是这里并没有设置static_prio,因为在将普通进程修改为实时进程时,并不会修改该成员。
3. 权重的计算
    我们前面看到每次在调整进程的优先级时都会调用set_load_weight()来计算进程的权重,因为这个函数是根据静态优先级或者实时优先级和调度策略来计算的,所以优先级的调整必然伴随着权重的计算。计算过程非常简单,如下所示:
static void set_load_weight(struct task_struct *p)
{
    if (task_has_rt_policy(p)) {
        p->se.load.weight = prio_to_weight[0] * 2;
        p->se.load.inv_weight = prio_to_wmult[0] >> 1;
        return;
    }

    /*
     * SCHED_IDLE tasks get minimal weight:
     */

    if (p->policy == SCHED_IDLE) {
        p->se.load.weight = WEIGHT_IDLEPRIO;
        p->se.load.inv_weight = WMULT_IDLEPRIO;
        return;
    }

    p->se.load.weight = prio_to_weight[p->static_prio - MAX_RT_PRIO];
    p->se.load.inv_weight = prio_to_wmult[p->static_prio - MAX_RT_PRIO];
}
    Linux中调度系统直接操作的对象是调度实体,由sched_entity结构描述,计算出来的权重值分别保存在sched_entity结构load成员中的weight和inv_weight,但是这两个成员是独立的,并不是要合成出一个权重值。这两个成员的值分别来自于prio_to_weight和prio_to_wmult两个数组,关于这两个数组的信息在代码注释和《深入Linux内核架构》中都有描述。prio_to_weight数组是根据根据nice值定义的,一般的概念是进程每降低一个nice值,则多获得10%的CPU时间,每升高一个nice值,则放弃10%的CPU时间。为执行该策略,内核通过这个数组将优先级转换为权重值。而prio_to_wult数组和prio_to_weight数组中相同索引的值的关系是prio_to_wult[i]=2^32 / prio_to_weight[i],之所以引入这个数组是避免在计算虚拟运行时间时执行除法。内核中使用delta_exec * (NICE_0_LOAD / weight)的公式来将实际时钟时间(delta_exec)转换为虚拟运行时间,通过prio_to_wult就可以将这个公式转换为(delta_exec * NICE_0_LOAD) * inv_weight >> 32,将除法很巧妙地转换为乘法和移位操作。

2013年12月16日星期一

Linux TCP协议栈中的预分配缓存

Linux TCP协议栈中的预分配缓存     TCP协议栈中的预分配缓存的大小由sock结构中的sk_forward_alloc成员描述,在创建套接字时该成员被初始化为0(参见sk_clone())。虽然这个成员在内核文档和《Linux内核源码剖析---TCP/IP实现中》都描述为预分配缓存的长度,但是在代码中并没有看到使用这个成员来预先分配一段内存,更多地是通过这个成员来控制TCP协议栈使用的内存。其实sk_forward_alloc更应该被描述为一个限额,就是限制当前套接字可以使用的内存数量。下面我们详细来看一下sk_forward_alloc在协议栈中具体的处理。
1、预分配缓存长度的计算
    在接收到数据包后,不管是放到接收队列还是乱序队列,都会调用tcp_try_rmem_schedule()来确认缓存是否有效,即是否允许缓存当前接收到的数据包。如果当前数据包的truesize(数据加sk_buff相关结构)小于sk_forward_alloc,则直接返回成功,表示可以缓存。truesize小于sk_forward_alloc表示已分配的限额还没有使用完成,所以就无需进一步确认。如果超过限额,则会调用__sk_mem_schedule()来重新计算k_forward_alloc,并进行全面的确认。
    在发送数据时,分配用于发送的SKB或者向sk_buff中拷贝数据都会调用sk_wmem_schedule()来确认发送缓存是否可用,即是否允许将当前的数据添加到发送队列中。和sk_rmem_schedule()类似,如果数据包的truesize小于sk_forward_alloc,则直接返回成功,否则会调用__sk_mem_schedule()来重新计算sk_forward_alloc,并进行全面的确认。
    不管是发送和接收数据包,都要通过sk_forward_alloc来查看是否可以将数据添加到缓冲区。sk_forward_alloc是在__sk_mem_schedule()中计算的,相关代码如下所示:
int __sk_mem_schedule(struct sock *sk, int size, int kind)
{
    struct proto *prot = sk->sk_prot;
    int amt = sk_mem_pages(size);
    int allocated;

    sk->sk_forward_alloc += amt * SK_MEM_QUANTUM;
    allocated = atomic_add_return(amt, prot->memory_allocated);
......
}
    上面的代码中,sk_mem_pages根据待确认的缓存长度size,向上取整获得所占用内存的页面数,所以amt必定大于等于1。SK_MEM_QUANTUM的值即为PAGE_SIZE,所以sk_forward_alloc的值在没有分配配额时为PAGE_SIZE的整数倍。如果是TCP协议,prot->memory_allocated指向全局变量tcp_memory_allocated,保存的是TCP协议栈所有预分配缓存对应的页面数(分配和释放配额时,sk_forward_alloc会变化),注意,并不是说TCP协议栈所有套接字的缓冲区占用的内存数就是tcp_memory_allocated对应的内存总和,它只是一个标识,协议栈并没有真的预先分配出了这么多内存供套接字使用。这里计算完成后,如果缓存确认失败,会取消这里的操作,缓存的确认后面再讲。
2、预分配缓存的分配和释放
    注意,这里的分配和释放并不是操作内存,而是操作预分配缓存的长度。预分配缓存的分配和释放分配是由sk_mem_charge()和sk_mem_uncharge()实现,这两个函数做的工作主要是在sk_forward_alloc上加上或减去当前skb的truesize或者拷贝数据的长度。
    接收数据时,如果数据包可以缓存,则会调用skb_set_owner_r()设置当前SKB所属的传输控制块,该函数会更新接收队列中缓存数据的长度,并且调用sk_mem_charge()来分配配额。前面已经调用过tcp_try_rmem_schedule()来确认缓存是否有效,所以如果执行到skb_set_owner_r(),分配配额肯定是可以成功的。在skb_set_owner_r()中还会设置SKB包的destructor接口为sock_rfree()函数,在销毁SKB包时会调用该接口。sock_rfree()中会调用sk_mem_uncharge()释放当前SKB占用的配额。
    发送数据时,配额的分配是在将创建的SKB添加到发送队列或者向SKB中拷贝数据的时候,这是直接调用sk_mem_charge()来完成的,并不是通过skb_set_owner_w()。skb_set_owner_r()操作的SKB通常是从接收队列中的SKB克隆出来的,在发送到下层协议栈后就会释放,这些SKB占用的内存并没有算到TCP协议栈的预分配缓存中。发送队列中的SKB占用的配额是在接收到对端的确认、发送超时调用sk_mem_uncharge()释放占用的配额。
    除了单个SKB数据包占用的预分配缓存的释放,在断开连接、释放传输控制块或者关闭套接字等情况下会调用sk_mem_reclaim()来释放为套接字的预分配缓存。sk_mem_reclaim()中只有在sk_forward_alloc大于SK_MEM_QUANTUM(即PAGE_SIZE)时才调用__sk_mem_reclain()进行真正的缓存回收,因为__sk_mem_reclain()中更新sk_forward_alloc和prot->memory_allocated是按照SK_MEM_QUANTUM进行的,如果sk_forward_alloc小于SK_MEM_QUANTUM,这两个成员并不会更新,代码如下所示:
void __sk_mem_reclaim(struct sock *sk)
{
    struct proto *prot = sk->sk_prot;

    atomic_sub(sk->sk_forward_alloc >> SK_MEM_QUANTUM_SHIFT,
           prot->memory_allocated);
    sk->sk_forward_alloc &= SK_MEM_QUANTUM - 1;
.......
}
    我们前面讲到过,sk_forward_alloc在计算时总是SK_MEM_QUANTUM的整数倍,如果套接字占用的预分配缓存都释放的话,sk_forward_alloc的值应该为0。如果在释放传输控制块的时候,sk_forward_alloc的值不为0,说明内核中某处可能有内存泄漏或者没有正确地更新预分配缓存的长度。
3、预分配缓存的确认
    如果预分配缓存已经用完,则调用__sk_mem_schedule()增加预分配缓存,该函数会首先计算根据新增加的数据长度计算出要新分配的页面数,然后更新到sk_forward_alloc和prot->memory_allocated。如果缓存确认失败,则撤销更新操作。
    缓存确认是否成功和sysctl_tcp_mem、sysctl_tcp_rmem、sysctl_tcp_wmem三个系统参数有关,满足下列情况之一,缓存确认会成功:
    1)所有预分配缓存(更新后的值,下同)低于低水平线,即sysctl_tcp_mem[0]的值
    2)所有预分配缓存高于低水平线,低于硬性限制线,确认的是接收缓存,并且接收队列中的数据总长度小于接收缓冲区的长度上限(即sysctl_tcp_rmem[0])
    3)所有预分配缓存高于低水平线,低于硬性限制线,确认的是发送缓存,如果套接字类型是SOCK_STREAM(tcp或者sctp等),发送队列中数据总长度小于发送缓冲区的长度上限(即sysctl_tcp_wmem[0])
    4)所有预分配缓存高于低水平线,低于硬性限制线,确认的是发送缓存,如果套接字类型不是SOCK_STREAM,为发送而分配的所有SKB数据区的总长度小于发送缓冲区的长度上限(即sysctl_tcp_wmem[0])
    5)不满足以上条件,没有进入警告状态,或者当前套接字发送队列中所有段数据总长度、接收队列中所有段数据总长度、预分配缓存长度(即sk_forward_alloc)三者之和乘以当前系统中套接字数量得到的值小于硬性限制线
    6)所有预分配缓存高于硬性限制线,确认的是发送缓存,并且套接字类型是SOCK_STREAM,会调用sk_stream_moderate_sndbuf()调整套接字的发送缓冲区。只有用户没有通过套接字选项设置发送缓冲区大小的情况下才会调整,调整为发送队列占用内存的一半,但是要大于等于2048。如果发送队列占用的内存大小和待确认长度之和大于调整后的发送缓冲区,则确认成功。在这种情况下,把size再添加到发送队列中,这样发送队列占用的内存容量就会超过发送缓冲区,在分配sk_buff前调用tcp_memory_free()就会返回失败,这样就阻止内存的占用。

2013年10月11日星期五

SYN cookies机制下连接的建立

SYN cookies机制下连接的建立
  在正常情况下,服务器端接收到客户端发送的SYN包,会分配一个连接请求块(即request_sock结构),用于保存连接请求信息,并且发送SYN+ACK包给客户端,然后将连接请求块添加到半连接队列中。客户端接收到SYN+ACK包后,会发送ACK包对服务器端的包进行确认。服务器端收到客户端的确认后,根据保存的连接信息,构建一个新的连接,放到监听套接字的连接队列中,等待用户层accept连接。这是正常的情况,但是在并发过高或者遭受SYN flood攻击的情况下,半连接队列的槽位数量很快就会耗尽,会导致丢弃新的连接请求,SYN cookies技术可以使服务器在半连接队列已满的情况下仍能处理新的SYN请求。
  如果开启了SYN cookies选项,在半连接队列满时,SYN cookies并不丢弃SYN请求,而是将源目的IP、源目的端口号、接收到的客户端初始序列号以及其他一些安全数值等信息进行hash运算,并加密后得到服务器端的初始序列号,称之为cookie。服务器端在发送初始序列号为cookie的SYN+ACK包后,会将分配的连接请求块释放。如果接收到客户端的ACK包,服务器端将客户端的ACK序列号减1得到的值,与上述要素hash运算得到的值比较,如果相等,直接完成三次握手,构建新的连接。SYN cookies机制的核心就是避免攻击造成的大量构造无用的连接请求块,导致内存耗尽,而无法处理正常的连接请求。
  启用SYN cookies是通过在启动环境中设置以下命令完成:
echo 1 > /proc/sys/net/ipv4/tcp_syncookies
  注意,即使开启该机制并不意味着所有的连接都是用SYN cookies机制来完成连接的建立,只有在半连接队列已满的情况下才会触发SYN cookies机制。由于SYN cookies机制严重违背TCP协议,不允许使用TCP扩展,可能对某些服务造成严重的性能影响(如SMTP转发),对于防御SYN flood攻击的确有效。对于没有收到攻击的高负载服务器,不要开启此选项,可以通过修改tcp_max_syn_backlog、tcp_synack_retries和tcp_abort_on_overflow系统参数来调节。
  下面来看看内核中是怎么通过SYN cookie机制来完成连接的建立。
   客户端的连接请求由tcp_v4_conn_request()函数处理。tcp_v4_conn_request()中有一个局部变量want_cookie,用来标识是否使用SYN cookies机制。want_cookie的初始值为0,如果半连接队列已满,并且开启了tcp_syncookies系统参数,则将其值设置为1,如下所示:
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
{
#ifdef CONFIG_SYN_COOKIES
    int want_cookie = 0;
#else
#define want_cookie 0 /* Argh, why doesn't gcc optimize this :( */
#endif
 
...... 
 
    /* TW buckets are converted to open requests without
     * limitations, they conserve resources and peer is
     * evidently real one.
     */

      if (inet_csk_reqsk_queue_is_full(sk) && !isn) {
#ifdef CONFIG_SYN_COOKIES
             if (sysctl_tcp_syncookies) {
            want_cookie = 1;
        } else
#endif
       
        goto drop;
    }
 
......
 
drop:
    return 0;
}
  如果没有开启SYN cookies机制,在半连接队列满时,会跳转到drop处,返回0。在调用tcp_v4_conn_request()的tcp_rcv_state_process()中会直接释放SKB包。
  我们前面提高过,造成半连接队列满有两种情况(不考虑半连接队列很小的情况),一种是负载过高,正常的连接数过多;另一种是SYN flood攻击。如果是第一种情况,此时是否继续构建连接,则要取决于连接队列的情况及半连接队列的重传情况,如下所示:
if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1)
        goto drop;
  sk_acceptq_is_full()函数很好理解,根据字面意思就可以看出,该函数是检查连接队列是否已满。inet_csk_reqsk_queue_young()函数返回半连接队列中未重传过SYN+ACK段的连接请求块数量。如果连接队列已满并且半连接队列中的连接请求块中未重传的数量大于1,则会跳转到drop处,丢弃SYN包。如果半连接队列中未重传的请求块数量大于1,则表示未来可能有2个完成的连接,这些新完成的连接要放到连接队列中,但此时连接队列已满。如果在接收到三次握手中最后的ACK后连接队列中没有空闲的位置,会忽略接收到的ACK包,连接建立会推迟,所以此时最好丢掉部分新的连接请求,空出资源以完成正在进行的连接建立过程。还要注意,这个判断并没有考虑半连接队列是否已满的问题。从这里可以看出,即使开启了SYN cookies机制并不意味着一定可以完成连接的建立。
  如果可以继续连接的建立,调用inet_reqsk_alloc()分配连接请求块,如下所示:
    req = inet_reqsk_alloc(&tcp_request_sock_ops);
    if (!req)
        goto drop;
  看到这里可能就有人疑惑,既然开启了SYN cookies机制,仍然分配连接请求块,那和正常的连接构建也没有什么区别了。这里之所以要分配连接请求块是用于发送SYN+ACK包给客户端,发送后会释放掉,并不会加入到半连接队列中。
  接下来就是计算cookie的值,由cookie_v4_init_sequence()函数完成,如下所示:
    if (want_cookie) {
#ifdef CONFIG_SYN_COOKIES
        syn_flood_warning(skb);
        req->cookie_ts = tmp_opt.tstamp_ok;
#endif
        isn = cookie_v4_init_sequence(sk, skb, &req->mss);
    }
  计算得到的cookie值会保存在连接请求块tcp_request_sock结构的snt_isn成员中,接着会调用__tcp_v4_send_synack()函数发送SYN+ACK包,然后释放前面分配的连接请求块,如下所示:
if (__tcp_v4_send_synack(sk, req, dst) || want_cookie)
        goto drop_and_free;
  在服务器端发送完SYN+ACK包后,我们看到在服务器端没有保存任何关于这个未完成连接的信息,所以在接收到客户端的ACK包后,只能根据前面发送的SYN+ACK包中的cookie值来决定是否继续构建连接。
  我们接下来看接收到ACK包后的处理情况。ACK包在tcp_v4_do_rcv()函数中调用的tcp_v4_hnd_req()中处理,如下所示:
static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb)
{
    ......
 
#ifdef CONFIG_SYN_COOKIES
    if (!th->rst && !th->syn && th->ack)
        sk = cookie_v4_check(sk, skb, &(IPCB(skb)->opt));
#endif
    return sk;
}
  由于在服务器端没有保存未完成连接的信息,所以在半连接队列或ehash散列表中都不会找到对应的sock结构。如果开启了SYN cookies机制,则会检查接收到的数据包是否是ACK包,如果是,在cookie_v4_check()中会调用cookie_check()函数检查ACK包中的cookie值是否有效。如果有效,则会分配request_sock结构,并根据ACK包初始化相应的成员,开始构建描述连接的sock结构。创建过程和正常的连接创建过程一样。

2013年9月12日星期四

Linux下可执行程序调试信息的分离及release程序的调试

  前两天在群里看到在讨论如何把debug版中的符号表加到release版本中,觉得这个非常有用,所以学习一下。
使用的工具是objcopy。
如果要生成单独的调试信息文件,命令如下:
objcopy –only-keep-debug foo foo.dbg
如果要去除调试信息(还可以使用strip命令),命令如下:
objcopy --only-keep-debug foo foo.dbg
如果要添加调试信息文件链接,命令如下:
objcopy --add-gnu-debuglink=foo.dbg foo
确认调试信息文件链接,命令如下:
objdump -s -j .gnu_debuglink foo
调试release程序前首先需要从debug版本中获取符号表信息,这个是通过objcopy这个利器,命令如下:
objcopy --only-keep-debug debug info.dbg
其中debug是带有符号表的可执行程序,info.dbg是保存符号表的文件。
在使用gdb调试release程序的时候,使用--symbol命令来加载前面生成的符号表,命令如下:

[root@CentOS_192 debug]#gdb -q --symbol=info.dbg --exec=release
Reading symbols from /root/debug/info.dbg...done.
(gdb) start
Temporary breakpoint 1 at 0x4004ef: file test.c, line 11.
Starting program: /root/debug/release
Temporary breakpoint 1, main () at test.c:11
11 return func();
Missing separate debuginfos, use: debuginfo-install glibc-2.12-1.80.el6.x86_64
(gdb)
其中--exec选项指定的release就是release版本的程序,这个程序原本是没有符号表信息的。

使用perf生成Flame Graph(火焰图)

使用SystemTap脚本制作火焰图,内存较少时,分配存储采样的数组可能失败,需要编写脚本,还要安装kernel的debuginfo包。使用perf的话,相对来说要简单一些。不过在有kernel的debuginfo包的时候,采样显示的信息要更丰富一些。
为了使用perf制作火焰图方便,我编写了下面的脚本,贴上来备忘,也方便需要的人。
脚本如下:

if [ $# -ne 1 ];then
echo "Usage: $0 seconds"
exit 1
fi

perf record -a -g -o perf.data &

PID=`ps aux | grep "perf record" | grep -v grep | awk '{print $2}'`

if [ -n "$PID" ]; then
sleep $1
kill -s INT $PID
fi

# wait until perf exite
sleep 1

perf script -i perf.data &> perf.unfold
perl stackcollapse-perf.pl perf.unfold &> perf.folded
perl flamegraph.pl perf.folded >perf.svg
将上面的脚本保存为perf_flame_graph.sh。根据上面的Usage可以看出在执行的时候,只需要指定采样的时间,单位为秒。
下面是我在测试机上制作的火焰图:

我这里制作的火焰图没有指定具体的进程或线程,如果只想关注某个具体的进程,可以在pref record命令后面加上命令或者通过-p选项指定进程ID。更多的参数和选项参见man perf-record。

2013年9月8日星期日

探究Linux下参数传递及查看和修改方法

   X86-64下有16个64位寄存器,其中%rdi、%rsi、%rdx,%rcx、%r8、%r9用作传递函数参数,分别对应第1个参数、第2个参数直到第6个参数,如下图所示(图片来自网络):

如果函数的参数个数超过6个,则超过的参数直接使用栈来传递。在被调用函数执行前,会先将寄存器中的参数压入堆栈,之后的访问会通过栈寄存器加上偏移位置来访问。下面我们结合程序及其反汇编的结果来看一看。C语言程序如下所示:
#include <stdio.h>
#include <stdlib.h>

static int func2(int i, int j)
{
int k;

k = i + j;
return k;
}

static int func(int fd, const char *ptr, int arg3, int arg4, int arg5,
int arg6, int arg7, int arg8)
{
int ret;

ret = arg7 + arg8;

func2(fd, arg3);

return ret;
}

int main(void)
{
func(12, "Hello,World!", 3, 4, 5, 6, 7, 8);

return 0;
}
将上述程序保存为m.c,使用gcc加上-g选项编译,然后使用gdb来进行调试,我们在main调用func的位置及func()和func2()函数三处加上断点,如下所示:

(gdb) b m.c:26
Breakpoint 1 at 0x4004d4: file m.c, line 26.
(gdb) b func
Breakpoint 2 at 0x4004ac: file m.c, line 17.
(gdb) b func2
Breakpoint 3 at 0x40047e: file m.c, line 8.
(gdb)
然后我们在第一个断点处停下,反汇编当前的main函数,查看参数传递方式,如下所示:
(gdb) disassemble /m main
Dump of assembler code for function main:
25 {
0x00000000004004cc <+0>: push %rbp
0x00000000004004cd <+1>: mov %rsp,%rbp
0x00000000004004d0 <+4>: sub $0x10,%rsp

26 func(12, "Hello,World!", 3, 4, 5, 6, 7, 8);
=> 0x00000000004004d4 <+8>: movl $0x8,0x8(%rsp)
0x00000000004004dc <+16>: movl $0x7,(%rsp)
0x00000000004004e3 <+23>: mov $0x6,%r9d
0x00000000004004e9 <+29>: mov $0x5,%r8d
0x00000000004004ef <+35>: mov $0x4,%ecx
0x00000000004004f4 <+40>: mov $0x3,%edx
0x00000000004004f9 <+45>: mov $0x400608,%esi
0x00000000004004fe <+50>: mov $0xc,%edi
0x0000000000400503 <+55>: callq 0x40048f <func>

27
28 return 0;
0x0000000000400508 <+60>: mov $0x0,%eax

29 }
0x000000000040050d <+65>: leaveq
0x000000000040050e <+66>: retq

End of assembler dump.
(gdb)
在func(12, "Hello,World!", 3, 4, 5, 6, 7, 8);这行下面我们可以看到在使用callq指令调用func()函数之前,会使用mov指令将前6个参数的值分别保存在edi、esi、edx、ecx、r8d、r9d这个6个寄存器中,而将第7个和第8个参数存储在栈上。这个结果和我们前面说的一致。
在进入第二个断点之前,我们先来看看当前的寄存器信息,如下所示:
(gdb) i registers
rax 0x351658ff60 228008197984
rbx 0x0 0
rcx 0x0 0
rdx 0x7fffffffe4e8 140737488348392
rsi 0x7fffffffe4d8 140737488348376
rdi 0x1 1
rbp 0x7fffffffe3f0 0x7fffffffe3f0
rsp 0x7fffffffe3e0 0x7fffffffe3e0
r8 0x351658e300 228008190720
r9 0x3515a0e9d0 227996133840
r10 0x7fffffffe240 140737488347712
r11 0x351621ebe0 228004588512
r12 0x400390 4195216
r13 0x7fffffffe4d0 140737488348368
r14 0x0 0
r15 0x0 0
rip 0x4004d4 0x4004d4 <main+8>
eflags 0x202 [ IF ]
cs 0x33 51
ss 0x2b 43
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
(gdb)
我们看到,在第一个断点处,也就是main函数中调用func()的位置,此时调用函数func()所需要的参数还没有存储到寄存器中,此时是在sub $0x10,%rsp汇编指令之后的位置。
接下来我们进入第二个断点,即设置在func()函数的断点,查看此时的寄存器信息,如下所示:
(gdb) i registers
rax 0x351658ff60 228008197984
rbx 0x0 0
rcx 0x4 4
rdx 0x3 3
rsi 0x400608 4195848
rdi 0xc 12
rbp 0x7fffffffe3d0 0x7fffffffe3d0
rsp 0x7fffffffe3a0 0x7fffffffe3a0
r8 0x5 5
r9 0x6 6
r10 0x7fffffffe240 140737488347712
r11 0x351621ebe0 228004588512
r12 0x400390 4195216
r13 0x7fffffffe4d0 140737488348368
r14 0x0 0
r15 0x0 0
rip 0x4004ac 0x4004ac <func+29>
eflags 0x206 [ PF IF ]
cs 0x33 51
ss 0x2b 43
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
(gdb)
此时在rsi、rdi等寄存器中已经可以看到我们传递的参数值,我们此时的位置是在callq 0x40048f <func>之后的位置。
反汇编func()函数,如下所示:
(gdb) disassemble /m func
Dump of assembler code for function func:
14 {
0x000000000040048f <+0>: push %rbp
0x0000000000400490 <+1>: mov %rsp,%rbp
0x0000000000400493 <+4>: sub $0x30,%rsp
0x0000000000400497 <+8>: mov %edi,-0x14(%rbp)
0x000000000040049a <+11>: mov %rsi,-0x20(%rbp)
0x000000000040049e <+15>: mov %edx,-0x24(%rbp)
0x00000000004004a1 <+18>: mov %ecx,-0x28(%rbp)
0x00000000004004a4 <+21>: mov %r8d,-0x2c(%rbp)
0x00000000004004a8 <+25>: mov %r9d,-0x30(%rbp)

15 int ret;
16
17 ret = arg7 + arg8;
=> 0x00000000004004ac <+29>: mov 0x18(%rbp),%eax
0x00000000004004af <+32>: mov 0x10(%rbp),%edx
0x00000000004004b2 <+35>: lea (%rdx,%rax,1),%eax
0x00000000004004b5 <+38>: mov %eax,-0x4(%rbp)

18
19 func2(fd, arg3);
0x00000000004004b8 <+41>: mov -0x24(%rbp),%edx
0x00000000004004bb <+44>: mov -0x14(%rbp),%eax
0x00000000004004be <+47>: mov %edx,%esi
0x00000000004004c0 <+49>: mov %eax,%edi
0x00000000004004c2 <+51>: callq 0x400474 <func2>

20
21 return ret;
0x00000000004004c7 <+56>: mov -0x4(%rbp),%eax

22 }
0x00000000004004ca <+59>: leaveq
0x00000000004004cb <+60>: retq
从上面的汇编代码可以看到,在做具体的操作之前,会将寄存器中的参数值压入到栈上,并且在此后的操作中,都是只会去操作栈上的值,而不是直接修改寄存器中的值。
现在有一个问题,我们在第二个断点处,是在将寄存器压入栈之前还是之后?如果此时打印fd,是从栈上取值还是寄存器中取值?这个问题也很好判断,直接使用p命令打印fd即可,具体过程如下所示:
(gdb) p fd
$9 = 12
(gdb) p $rdi
$10 = 12
(gdb) set $rdi=15
(gdb) p fd
$11 = 12
(gdb) set *(int *)($rbp-0x14)=15
(gdb) p fd
$12 = 15
(gdb)
开始的时候修改的是rdi寄存器,但是打印fd时仍然是12,直接修改压栈的位置为15,再次打印fd,此时的值为15.所以在打印fd时,相应的值是从栈上读取的。我们此时的断点的位置也是在将参数压栈之后的位置。fd对应的栈位置可以算出来,不过这里是根据前面的反汇编结果。
接下来尝试利用栈上的信息打印出我们的第二个参数,也就是"Hello,World!"字符串。我们知道C语言中字符串其实就是一段以空字符结尾的内存,通常使用其首地址来访问该字符串。我们这里的字符串的地址通过esi寄存器保存在栈上,位置就是$rbp-0x20,但是这个位置存储的是一个指针的地址,如果将其理解为指针,就是存储指针的指针,也就是二级指针,所以的打印的时候应该是这样:
(gdb) p *(char **)($rbp-0x20)
$6 = 0x400608 "Hello,World!"
(gdb) p (char *)($rbp-0x20)
$7 = 0x7fffffffe3b0 "\b\006@"
(gdb)
其实前面修改变量的方法显得很罗嗦也很繁琐,但是平时如果要对正在运行的程序进行变量的修改,使用gdb则很麻烦,也不够灵活,即使使用gdb脚本。做这种事情,当然是由强大的SystemTap来做,要方便的多。下面还以修改fd为例,来说明如何使用SystemTap脚本来修改。
脚本如下:
probe process("a.out").statement("func@m.c+1") {
printf("func1: %s\n", $$vars);
$fd = 15;
}

probe process("a.out").statement("func2") {
printf("edi = %d\n", register("edi"));
}
上面的a.out就是前面的C程序(保存到m.c文件)编译后生成的。
这个脚本的执行和输出如下:
[root@CentOS_190 ~]# stap -gu -c ./a.out mod_reg.stp
func1: fd=0xc ptr=0x400608 arg3=0x3 arg4=0x4 arg5=0x5 arg6=0x6 arg7=0x7 arg8=0x8 ret=0x0
edi = 15
[root@CentOS_190 ~]#
我们可以看到在第一个probe点process("a.out").statement(func@m.c+1)
将fd设置为15,在第二个probe点通过edi寄存器看到函数func2()的第一个参数i的值为15,而不是12.
细心的同学可能发现在第一个probe点加上了相对函数的偏移,在第二个probe点中使用寄存器来查看参数的信息,而不是使用$i变量。我们通过下面的脚本来说明这个问题,脚本如下:
probe process("a.out").statement("func") {
printf("func1: %s\n", $$vars);
printf("func1: edi = %d\n", register("edi"));
}

probe process("a.out").statement("func2") {
printf("func2: %s\n", $$vars);
printf("func2: edi = %d\n", register("edi"));
}
其输出结果如下:
[root@CentOS_190 ~]# stap -gu -c ./a.out test.stp
func1: fd=0x0 ptr=0x7fffa0a4bff8 arg3=0x0 arg4=0x40036b arg5=0x0 arg6=0x0 arg7=0x7 arg8=0x8 ret=0x0
func1: edi = 12
func2: i=0x0 j=0x1 k=0x35
func2: edi = 12
[root@CentOS_190 ~]#
如果没有加上函数的偏移,在probe点触发时,fd、ptr等参数的值还没有初始化,也就是寄存器中的值还没有压入栈中,所以此时直接使用fd等变量获取的值是未定义的,此时即使修改了fd等变量,在将寄存器压栈的时候也会被覆盖为原来的值。结合我们前面用gdb看到的汇编代码,这个地方理解起来就容易多了。
我们知道在SystemTap脚本中可以嵌入C代码,在嵌入的C代码中也可以使用内联汇编,但是在操作寄存器的时候要注意,在probe点触发时,程序运行时的寄存器会被保存到栈上,所以在probe的处理中修改寄存器时,修改的只是当前的寄存器,在probe点的处理完成后会恢复程序运行时的寄存器,具体细节参见内核文档kprobes.txt。
前面的脚本中不仅实现了修改变量的功能,在程序debug信息少的情况下,也可以选择一些信息动态输出,避免了修改程序及重新编译的重复操作。
再次向大家强烈推荐SystemTap!