【终南山.内核问道】Linux故障分析方法(下)

【终南山.内核问道】Linux故障分析方法(上)

【终南山.内核问道】Linux故障分析方法(中)

故障现象

某单板在客户现场,出现watchdog线程异常。导致用户无法上网故障。

故障定位

故障日志较多,但是第一现场是在__mmdrop函数中触发BUG_ON。
通过故障现场可以看出,是在schedule函数中,进入了__mmdrop函数, __mmdrop函数第一句是BUG_ON(mm == &init_mm);即不允许释放init进程的mm_struct

软件版本全部是内核态线程,所以任务都共用init进程的init_mm。无论什么情况下,init_mm的引用计数都不会减到0。因为至少有init进程在引用这个结构。因此,初步判断是init_mm的引用计数出现问题。
Linux稳定性还是很高的,一般不至于将线程引用计数管理出错。我们重点还是怀疑自己的代码出了问题。因此重点走查相关代码。
通过走查代码发现,我们为了提高进程切换性能,修改了schedule函数:

标准内核的代码是:

if (!prev->mm) {
            prev->active_mm = NULL;
            mmdrop(oldmm);/*这句被修改了*/
    }

    对PPC和ARM体系来说,我们将这段代码改成了:
if (!prev->mm) {
            prev->active_mm = NULL;
            /**
              * 标准内核的mmdrop会原子的将counter引用计数值减一,如果发现已经减
             * 到0,就调用__mmdrop。
              * 这样改的原因是:在ARM平台上,原子加减操作需要开关中断,而当前
             * 上下文已经处于关中断状态。
             * 所以不用关中断,可以直接对引用计数值进行加减。
             * 这样就会减少开关中断的时间。
              */
            if(!(--((atomic_t *)(&oldmm->mm_count)->counter)))              
                      __mmdrop(oldmm);
    }

初看这段代码,似乎没有问题,但是根据直觉,我们认为相应的代码一定有什么不对的地方。通过反汇编发现,编译后的结果是:

c04ed454:    e5863050     str    r3, [r6, #80]
c04ed458:    e5943014     ldr    r3, [r4, #20]
c04ed45c:    e2433004     sub    r3, r3, #4    ; 0x4        //错误,应该是1
c04ed460:    e5843014     str    r3, [r4, #20]
c04ed464:    e5943014     ldr    r3, [r4, #20]
c04ed468:    e3530000     cmp    r3, #0    ; 0x0

为什么最终结果会是将引用计数值减4了呢,再细看代码,发现有if(!(--((atomic_t *)(&oldmm->mm_count)->counter)))    这句中,有一个指针转换。其本意是想将oldmm->mm_count转换成atomic_t *,但是实际情况是,它将(&oldmm->mm_count)->counter转换成指针了。这样,整句话变成了,将int型的引用计数,转换成一个指针进行减了。最终结果当然会是减4。
其最终结果是:本来该将mm_struct结构的引用计数减1,结果变成每次减4,当减为0时,故障出现。

故障原因

按理说,init_mm的引用计数初始值为1,从init进程切换到其他内核线程时,它的引用计数也不会变。如果计数减错的话,故障看起来会很快复现,但是为什么在现场需要很多天,甚至一两个月才出现呢?经过分析,我们认为原因是这样的:

当系统初始化时,引用计数为1。第一次切换后,由于代码的BUG,本来该减1的地方,减了4个计数。这样,引用计数变成-2,溢出后成了一个非常大的整数。即4294967294,经过1431655764次切换后,其值变成2,再切换一次后,其值变成-1,即4294967295,再经过1431655765次切换后,计数值才真正变成0,系统异常。在最后一次切换时,会在被切出的进程上下文产生异常。

这样,经过1431655764+1431655765=2863311529次切换后,系统异常。假设系统每毫秒产生一次调度的话,需要2863311529毫秒,约33天发生异常。

故障复现

为了复现该故障,在schedule函数中加入调试代码,当引用计数变为负数或者大于1000时,调用__mmdrop复现故障。这样,故障很快在单板上复现。

从复现现场看,与故障现场一致,也是在看门狗进程中出现异常,回溯栈也一致。紧跟第一故障现场,接着会在看门狗进程中多次出现非法地址异常。

if(!(--((atomic_t *)(&oldmm->mm_count)->counter)))    一句改成:

  if (!(--((oldmm->mm_count).counter)))

再次在单板上运行,同样的调试代码,不出现异常,说明修改后,引用计数正常。

由于这是一个线上故障,要减小故障的影响,要求速战速决。无形中会对分析人员带形成较大的压力,此时要淡定,泰然处之。并且要熟悉内核代码,眼睛要毒一点,一看到代码,就知道它是做什么的。如果不熟悉代码,就不能将错误的代码与故障现象关联起来。反汇编工具和汇编语言也要熟悉,同时要能够汇编代码与C语言关联起来。

第一个案例的经验是,一定要找准故障第一现场,重点怀疑我们自己对内核的修改,以及厂商对内核的修改。

故障现象

在不启动调试时,用户程序可以正常运行。如果启动调试,那么在单步的过程中,系统出现do_page_fault错误。

故障定位

从故障现场中可以看出:在TIMER任务中发生了trap=300的地址访问错误,该任务ID号为62。

初步观察,应用程序没有明显的空指针等导致访问出错的情况,而且,在没有启动调试时,并不会出现此错误。因此,初步排除是由于应用代码编写不当造成的地址访问错误。

接下来重点怀疑内核的问题。

继续分析OOPS现场,发生故障时,要访问的非法地址正好处于一个页面的开始处,和当前SP指针相差不远,并且处于相邻的两页。查看当时的内核编译配置文件,堆栈保护是打开的。因此认为是堆栈越界,导致堆栈保护异常。

修改内核,将Timer任务以及其他任务堆栈空间由默认的8K提高到40K后,以前每次必现的故障,现在变成5次才出现,并且出现故障的任务已经不是Timer,而是变成其他任务了。

继续加大任务堆栈。再次测试了10次,每次调试5分钟,故障没有复现。

通过在do_page_fault中临时加入一段测试代码,打印当前堆栈的所有数据、每条数据对应的函数名。继续查看事发时的堆栈内容,发现不断的有schedule、ptrace、DMA_MODE_WRITE等内容出现。由此我们怀疑:是在schedule处发生了嵌套调用,而且是从中断处理程序中进入schedule的。

为了找到被中断打断的现场,我们继续分析日志。发现被打断的程序正运行在c03e4958,该地址在_switch_to中,对应的语句是:bl      c03e4250 <__cli_end>。与_switch_to中的spin_unlock_irq(&runqueue_lock);一句对应。由此可以看出,是在_switch_to中一开中断就来了中断。

故障复现

为了复现该故障,我们编写了一个测试用例,高优先级的TakeIt任务不停的获得一个信号量,在中断中不停的释放信号量。低优先级的Play任务占用CPU。这样,TakeIt任务会不停的在中断到来时抢占Play任务。完整的代码在《测试用例.txt》中。
在单板上,我们发现,Play任务的堆栈很快就被TakeIt任务击穿。

故障原因

出故障时,系统中存在调试代理任务,它的优先级为200,被击穿的TIMER任务的优先级为156。TIMER任务为优先级最高的用户任务。

经过分析,我们认为是这种情况造成了故障:当TIMER任务被调度运行时,在关中断状态下,将堆栈切换到TIMER任务,然后,(在schedule函数中)中断被打开,由于中断被屏蔽的时间超过100微秒,在此期间调试主机发送了大量的通信包到单板,在单板上产生了中断,将TIMER任务打断。虽然系统配置了独立中断栈,但是,在切换中断栈前,中断处理程序需要保存CPU现场数据。这些数据只能保存在当前堆栈中,即TIMER任务的堆栈中。当执行到中断尾声时,中断处理程序会发现当前任务(TIMER任务)的need_resched被设置,由于是可抢占内核,因此TIMER任务会在中断返回前,调用schedule函数,导致TIMER任务被抢占,调试器任务得以运行。但是在抢占时,还没有执行中断返回操作,以前的中断现场还保存在TIMER任务的堆栈中。当调试器处理任务完成后,它会通过schedule再次切换到TIMER任务。如前所述,每次调用schedule时,代码都会在switch_to中切换堆栈,回到TIMER任务。TIMER任务准备运行,此时内核强制打开了中断,造成刚切入的TIMER任务还未执行中断恢复操作,就被再次进入的中断打断,中断处理程序再次保存中断现场。并在执行完中断处理程序后,在中断返回前又被调试任务抢占,如此周而复始导致任务堆栈在执行中断处理程序时溢出。

结论

经过以上分析,我们认为:这是系统的bug,只不过需要同时满足以下特殊条件才能暴露出来:

  • 有突发的、大量的外部中断。

  • 外部中断每次都唤醒高优先级的任务。

  • 调度过程中关中断时间较长,导致期间中断阻塞,开中断后会立刻响应外部中断。(如在调试时,schedule函数会在关中断条件下执行调试钩子函数,导致关中断时间增加100us)

  • 内核支持抢占。

  • 某任务被调度后,由于在调度过程中(任务切换完成后)又响应中断(中断中会唤醒高优先级任务),中断结束时被高优先级任务抢占,使得该任务长期处于无法运行的饥饿状态。
    由于在北研现场,以上几个条件都吻合,所以才造成了本次故障。
    备选方案

A方案:

放慢调试任务的收发包速度。每收到一定数量的通信包,就延迟1毫秒。

优点:改动小。并且不用修改内核。

缺点:治标不治本。如果在调试时有其他频繁的外部中断到来时,也会出现问题。

B方案:

如果schedule重入次数过多(如超过10次),就不强开中断,等到退回上次的schedule函数时才开中断。

优点:代码改动量小。

缺点:有缺陷,不能防止击穿非实时任务堆栈的情况。而且在schedule函数中加入太多判断会影响任务切换性能。schedule重入10次也会浪费掉线程600多个字节堆栈。

C方案:

将schedule函数中,强开和强关中断改为spin_lock_irqsave和spin_unlock_irqrestore。

优点:对schedule性能影响最小。

缺点:没有在schedule中强开中断。如果旧代码在关中断情况下调用了schedule,那么退出schedule时,中断仍然为关中断状态,而不是以前的开中断状态。不过,应用程序在关中断时调用schedule本身是不合理的。在此种方案中会在schedule入口处判断一下中断允许状态位。如果是在关中断状态下调用了schedule函数,就立刻触发内核异常,这样可挂起当前任务,方便后续定位。当然该检测功能提供了内核配置,用户可根据需要进行配置。配置后,我们还增加了命令开关,用户可在系统运行过程中,动态地打开、关闭该检测功能。

D方案:

实现一个单独的中断抢占调度函数,该函数仅在中断抢占时调用.在该函数中,置上抢占调度标志.如果一个线程是被中断抢占调度出去的.当进程切换回来后,如果再次被中断抢占,那么,抢占调度函数直接退出,退回上次中断尾声的抢占调度函数中,由该抢占调度函数判断need_schedule标志,以决定是否再次调用schedule函数.这时,pt_regs结构已经弹出,不会将被抢占线程的堆栈击穿.

优点:不修改现有schedule函数,而是在schedule函数的基础上进行再一次封装。风险小.

缺点:需要在task_struct中新增一个字段。

第二个案例是堆栈溢出故障,经验教训是进程堆栈保留了程序运行的轨迹,应当充分重视对它的分析,同时也需要熟练掌握内核代码。这就相当于一个法医去解剖尸体,然后一步步的还原那个人死之前到底做了什么事,堆栈能起到还原现场的作用。

这个故障,是在完全没有Linux内核协议栈背景时查的。当时对邻居表的概念也还比较陌生,临时上网补了一下邻居表的课。

现象描述

项目测试时发现:单板打开IP转发功能后,系统不停打印“Neighbour table overflow”。

初步分析

初步分析协议栈源码,发现内核只在rt_intern_hash一个函数中输出此错误:static int rt_intern_hash(unsigned hash, struct rtable *rt, struct rtable **rp)

{ 
    ……
    if (rt->rt_type == RTN_UNICAST || rt->fl.iif == 0) {
        int err = arp_bind_neighbour(&rt->u.dst);
        if (err) {
            if (net_ratelimit())
                printk(KERN_WARNING "Neighbour table overflow.\n");
            rt_drop(rt);
            return -ENOBUFS;
        }
    }
    ……
}

从代码来看,是arp表中条目太多,超过系统设置的门限值了。此门限值默认为1024,可通过/proc/sys/net/ipv4/neigh/default/gc_thresh3修改它。

接下来,我们试图在单板上取得故障现场的arp信息。

其中: /proc/net/arp中的内容如下:

IP address   HW type   Flags    HW address         Mask      Device                                                             
169.1.107.13   0x1    0x2      00:1D:0F:14:4D:EB     *        eth3                                                               
168.0.138.1   0x1     0x2      00:D8:00:00:01:89     *        eth1            

/proc/net/stat/arp_cache中的内容如下:

刚开始时:

00000005  000050b2 00006476 00000004  001a095f 000b58c0  0000001d  00000000 00000000  0019df0a 00000000

约十秒钟后:

0000004A  000013c9 00000000 00000000  003acf80 00002c98  00000003  00000000 00000000  00000000 00000000

约半分钟后:

000001AC  000013c9 00000000 00000000  003acf80 00002c98  00000003  00000000 00000000  00000000 00000000

打印错误信息时,红色值已经达到1024了。

但是,不论/proc/net/stat/arp_cache中的值如何变化,/proc/net/arp中的内容都只有几条。

继续分析/proc/net/arp文件的实现代码,发现它屏蔽了部分条目的
显示:

static struct neighbour *neigh_get_next(struct seq_file *seq,    struct neighbour *n,            loff_t *pos)
{
    ……
    while (1) {
        while (n) {
            if (n->nud_state & ~NUD_NOARP)
                break;
        next:
            n = n->next;
        }
……
    return n;
}

从代码上看,它不会显示状态为NUD_NOARP的条目。也不会显示状态为0的条目。

将此条件屏蔽,再查看/proc/net/arp中的内容,发现条目数量变多了,达到上千条。

因此,初步定位故障原因是:在arp表中,大量状态为0的条目占用了表空间,以至于无法再扩充arp表,导致打印错误,网络收发包功能不正常,比如telnet会失败。

问题定位
继续分析源代码,发现内核中只有两个地方会向arp表中增加条目:pneigh_lookup函数和neigh_create。在这两个函数中添加调试代码,当这两个函数执行一定次数后,强制产生异常或者调用dump_stack(),通过堆栈回溯找到程序执行路径:

[EFFC5BB0] [C0299F8C]  (unreliable)neigh_create
[EFFC5C10] [C02D1B64] arp_bind_neighbour
[EFFC5C40] [C02A8068] rt_intern_hash
[EFFC5CA0] [C02A9BB4] ip_route_input
[EFFC5D50] [C02AC444] ip_rcv
[EFFC5D80] [C0291CC8] netif_receive_skb
[EFFC5DB0] [C01C69CC] gfar_clean_rx_ring
[EFFC5DE0] [C01C8604] gfar_poll
[EFFC5E00] [C0293F40] net_rx_action
[EFFC5E40] [C002A2C4] ___do_softirq
[EFFC5E90] [C002ACB0] __do_softirq
[EFFC5EB0] [C0005BE0] do_softirq
[EFFC5EC0] [C002A3F8] irq_exit
[EFFC5ED0] [C0005CB8] do_IRQ
[EFFC5EF0] [C000FD40] ret_from_except
[EFFC5FB0] [C0008F54] cpu_idle
[EFFC5FD0] [C00111EC] start_secondary
[EFFC5FF0] [C00020E8] __secondary_start

连续测试多次,异常只在neigh_create中,而不会在pneigh_lookup中产生。

从堆栈回溯信息来看,网络中断将当前任务打断后,在软中断上下文中,处理收包数据时,由于打开了转发功能,并且入包的目的地址不是主机地址,因此需要将入包转发。在转发前,调用ip_roupt_input确定路由。ip_route_input函数调用arp_bind_neighbour向arp表中添加了一个nud_state为0的条目。

这里,要求对中断、软中断比较熟悉,体现出《深入理解Linux内核》的价值了。如果没有Linux内核基础知识,就看不懂堆栈信息,不能够快速分析陌生领域的问题。

问题在于:我们在其他单板上测试时,发现:向一个不存在的主机转发包时,nud_state会很快变成NUD_INCOMPLETE,然后变成NUD_FAILED,而不会一直处于初始状态0。

在继续分析代码前,我们先看看处理入包时,内核选择路由的流程(从《深入理解Linux网络内幕》中抄写过来的,其实那时自己还不太懂这个流程,领导要求写个总结报告,只好应付一下了):

由于故障现象仅在IP转发功能打开的情况下才会出现,因此我们重点关注其中与IP转发相关的代码:

static int ip_route_input_slow(struct sk_buff *skb, __be32 daddr, __be32 saddr,
                   u8 tos, struct net_device *dev)
{
    /**
     * 进行错误检测,以及处理广播包、本地包等。
     */
    ……
    /**
     * 以下是进行IP转发。
     * 先检测是否打开了IP转发功能。
     */
    if (!IN_DEV_FORWARD(in_dev))
        goto e_hostunreach;
    if (res.type != RTN_UNICAST)
        goto martian_destination;

    /**
     * 打开了IP转发,调用ip_mkroute_input初始化skb中的字段: 
     *     设置dst->input = ip_forward
     *     在arp中增加一个空的条目
     */
    err = ip_mkroute_input(skb, &res, &fl, in_dev, daddr, saddr, tos);

    ……
}

在arp表中增加条目后,入包处理函数还会在ip_rcv_finish中调用:

return dst_input(skb);

这句话间接调用了skb->dst->input(skb);

在ip_route_input_slow中,已经将skb->dst->input设置为ip_forward。

因此,在故障情况下,函数会运行到ip_forward中。

下面我们看看ip_forward执行了什么操作:

int ip_forward(struct sk_buff *skb)
{
……
    /**
     * 通过跟踪发现,出故障时,此判断条件为真,函数直接退出了。
     */
if (skb->pkt_type != PACKET_HOST)
        goto drop;
……
}

注意:正是由于在此句话直接退出,ip_forward没有执行到ip_forward_finish,也就没有执行ip_output。而arp条目的nud_state状态正是在ip_output中改变的。

现在需要搞清楚的是:为什么skb->pkt_type不是PACKET_HOST,通过打印我们看到,此时skb->pkt_type的值是PACKET_BROADCAST。设置pkt_type的代码在eth_type_trans:

if (is_multicast_ether_addr(eth->h_dest)) {
    if (!compare_ether_addr(eth->h_dest, dev->broadcast))
        skb->pkt_type = PACKET_BROADCAST;
    else
        skb->pkt_type = PACKET_MULTICAST;
}

可以看到:它是根据入包的mac地址来判断是否为广播或多播的。

回到ip_route_input_slow函数,它在转发前,并没有判断mac地址。因此导致了它在arp中增加一个nud_state为0的条目。而在真正执行ip_forward时,又对mac地址进行了判断,导致ip_forward中途退出。这样,就在arp表中增加一个暂时无用的条目。最终导致arp表满了,虽然这些条目最终会老化,但是在一段时间内(比如一分钟内),影响正常的网络功能。

复现方法

根据对代码的分析,我们认为:当以下条件满足时,就可能出现故障:

  • 包的目的mac地址是多播或者广播地址。

  • 包的目的IP地址与主机的IP地址在同一个网段。

  • 在一定时间内,这样的包流量特别大。并且分别向不同的IP地址广播。

  • 主机属于A类或者B类网络,IP范围超过了arp表的大小。

通过使用发包工具伪造这样的包,并让单板转发,发现arp表中,nud_state为0的条目不断增长,最终造成arp表溢出。现象和故障现场一致。

解决办法

解决方法其实很简单。调整一下标准内核的处理流程即可。但是这里有一个小插曲,我们一个很有经验的工程,在合入代码的时候,将代码合错位置,导致故障仍然存在。后来仔细检查以后,发现是合入错误。因此:小心驶得万年船。

第三个案例我的经验教训是面对不熟悉的领域,即使遇到开源故障,只要善于想办法,总是能够解决问题的。不要慌,理清思路最关键。

故障来源

本故障来源于测试小组。在freescale提供的开发评估板上配置了SMP后,运行开源测试用例lmbech导致系统死机。

CPU采用双核的e500核,其架构和8572较为类似。

故障描述

单板上通过Linux内核(配置了SMP)启动后,在单板上运行lmbech测试用例,lmbech运行到一定时间后,会导致系统死机现象。死机时串口无输出,不能通过串口输入ctrl-c中止lmbech测试用例,网卡不能响应外来的ping包请求,而且每次死机的时lmbech打印输出不尽相同。

单板上通过Linux内核(未配置SMP)启动后,在单板上运行lmbech测试用例,lmbech测试用例能运行至结束,系统正常。

单板上运行freescale提供的参考内核,lmbech测试用例运行完成,系统正常。

测试环境: freesacle提供的参考内核,内核版本2.6.28.1,内核配置中采用了SMP。

故障定位

根据前面的初步分析,我们知道运行lmbech后,会导致内核死的bug。因此,我们故障定位时可以分两条主线,一是从应用程序lmbech着手,进一步确定死机时lmbech死机时运行程序运行的具体位置。二是从内核入手,通过运行lmbech时在内核中调试(加打印等)看能否定位bug的准确位置。

在通过应用和内核这两个方面定位bug前,替换了工具链,发现bug现象依旧,那说明该bug与工具链无关。

注:这个故障最初是其他同事在分析,从用户态、工具链、内核各个方面查。一个月没有明显进展(没有快速聚焦问题)。其实最初可以ping一下单板,如果没有ping通,说明是在内核态中死机了。

从应用角度定位故障

仔细分析了下lmbech的进程切换代码,发现其测试原理实际上是创建很多父子进程,父子进程通过管道通信。父进程在管道写,子进程在管道中读取,父进程等待子进程读取完成响应,子进程等待父进程发送数据。在等待过程中,进程都在阻塞睡眠,有数据时相应的进程才会被唤醒。整个操作完成后,实际上就会发生很多次进程切换,lmbech通过进程的开始时间和结束时间来确定进程切换的性能。

在测试过程中过程中,我们发现lmbech会逐渐增加进程切换的数目。在运行进程切换时候,我们可以通过参数控制进程切换的力度和进程切换的进程数目。具体的运行参数为:

./lat_ctx [-s kbytes] processes(其中lat_ctx为进程切换主程序,-s参数表示进程切换时父子进程需要交换的数据量,processes表示产生的父子进程总数)。

在lat_ctx具体运行时,代码中有两个for循环控制进程切换的力度(交换的数据量大小)和频度(产生的父子进程个数):

其中第一个for循环控制进程切换的频度(进程个数相关的控制),第二个for循环是进程切换的力度(也就是父子进程交换数据量大小)。测试中发现,当第一个for循环的变量变成64的时候,系统就会死掉。当将i_what取值为64时,i_size取值1时候,发现也会必死。将i_what修改为32时,i_size取值为1时,测试用例能正常运行完毕。因此有如下结论:该bug不仅和进程切换有关,而且还有进程切换时进程的数目有关。不过当时知道该结论也没起多大作用,因为我不知道内核进程切换和进程的数目具体有何种联系,理论上我认为进程切换与进程的数目是无关的。

继续在lmbech中加打印,发现死的时候打印输出都不一样,也就是出现bug每次的位置都不一样,从应用角度无法得到bug的准确信息。
另外,我通过函数sched_setaffinity将测试用例绑定到某个特定的CPU上时,lmbech能运行完毕,不会导致死机。这个测试进一步确认初步分析的结论,该bug与SMP有关。

从应用的角度分析得到结论:该bug和进程切换有关,而且与进程切换时候的进程数目有关,同时还与SMP有关。也就是说,该bug应该与多CPU的进程切换有关,同时,进程的数目也对该bug有一定影响。

从内核角度定位故障

根据初步分析的结论主要做了以下几个测试:

  1. 由于该bug与SMP有关,一般SMP常见的bug和spinlock有关。CPU可能一直在试图某个spinlock,导致系统看起来假死的现象。根据其它同事的建议,将spinlock和kernel相关的debug宏打开,然后运行测试案例,未出异常警告信息。

  2. 由于在freescale提供的高版本内核上,lmbech能顺利运行完毕。因此怀疑自己在当初移植板子的时候对SMP支持有问题。仔细分析了相关SMP启动的代码,发现只有几个地方和SMP有关:start_secondary函数和mpc85xx_smp.c相关的函数。仔细对比这些函数,比较两个版本的差异地方,对照着进行了相应的修改测试,现象依旧。

  3. 根据前面的初步分析结论,该bug和进程切换有关。也就是与内核中的schedule函数有关。在schedule函数中增加了打印,出了一个怪异现象,bug又神奇的消失了。看来还与进程的切换时机有关。

  4. 根据初步分析三的结论,在所有的中断处理历程中增加打印,发现中断处理例程都能正常退出。因此初步分析三的结论不成立,中断处理例程中不存在死循环。

  5. 由于是在mpc8572的基础上移植的,测试了下freescle 提供的mpc8572代码,运行lmbech测试用例,现象依旧。拿精简后的测试用例在南研的8572板子上跑,bug现象依旧。

用code warrior定位故障

利用code warrior将环境搭好,运行lmbech测试案例。利用codewarrior定位出现故障的pc指针后,在代码中加入BUG()或者dump_stack()函数,得到死机时的函数调用堆栈:

由堆栈和code warrior工具可以看出,实际上死机时内核在调用schedule。schedule然后不断调用find_next_zero_bit,在此地方发生了死循环。

具体故障分析

在PowerPC下定义了下面几个全局变量和宏:

相关变量和宏说明:

next和prev

分别表示即将被调度入的进程和被调度出的进程。

context_mm[LAST_CONTEXT+1]

当前系统中所有进程的地址空间指针数组,数组个数为LAST_CONTEXT+1个。每个数组的成员指向某个进程的地址上下文,进程控制块的task_struct->mm_struct->mm_context_t->ID成员保存了context_mm数组的某个索引,也就是该进程的地址空间ID。进程切换时,schedule读取next任务的地址空间ID,然后通过context_mm获取该进程地址空间上下文,以达到进程地址上下文切换的目的。注意,context_mm数组成员的个数为LAST_CONTEXT+1个,实际上有效的为LAST_CONTEXT个,当进程的地址空间ID为0时表示该进程的地址上下文已经不在硬件中了(实际上是相应的TLB表项无效),需要重新装入。在E500中,LAST_CONTEXT定义为255。这意味着,如果当前系统进程超过了255个的话,context_mm所有成员都为非空,那么,在进程切换的时候需要采用某种替换策略将某个进程的地址空间释放,以便有地址空间ID给next进程使用。这实际上也就是函数steal_context完成的功能。

NO_CONTEXT

支持的地址空间个数,与硬件有关(与PowerPC中表示进程地址空间中的PID寄存器位数有关,具体参见PowerPC相关文档)。在e500中定义为256。

FIRST_CONTEXT和LAST_CONTEXT

分别定义为1和255,和context_mm的数组索引起始值和结束值相对应,分别表示对应第一个地址空间和最后一个地址空间。注意,索引0未使用,0号表示相应的进程地址空间ID为非法(0号地址空间留给内核使用)。

next_mmu_context

表示下一个合法的地址空间ID,实际上表明下一个可用的地址空间索引。创建新的进程时,会给新进程分配地址空间ID。该数字对应的context_mm为空。

nr_free_contexts

当前空闲的context_mm成员个数,也就是空间的地址空间ID个数。注意,当系统进程超过255个时,context_mm的每个成员都指向了某个进程的地址空间,nr_free_contexts此时为0。

context_map[LAST_CONTEXT / BITS_PER_LONG + 1]

context_mm数组索引的位图表示。对应的比特为1表明该数组成员被某个进程所占用,为0表示该数组成员可用,可以分配给某个进程。

进程切换时的地址空间上下文切换流程

地址空间切换属于进程切换的一部分。其对应的函数为switch_mm。具体代码如下:

从代码可以看出switch_mm首先调用get_mmu_context来获取next的地址空间上下文。然后通过函数set_context来设置next进程的地址空间上下文(实际上设置硬件中的PID寄存器,以达到切换地址空间的作用)。

我们再来看看进程是如何获取其地址空间上下文的,代码如下:

函数get_mmu_context执行流程如下:

  • 如果被切换的进程next的地址空间ID为非0,则表明其地址空间有效,直接返回。我们直接可以根据context_mm[ID]直接获得地址进程上下文。注意,什么时候该进程的地址空间上下文会为0呢?第一种情况,进程新创建的时,地址空间还未分配。第二种情况,系统进程比较多时(也就是超过255的时候),在进程切换时,某个进程的地址空间ID可能因此被释放。

  • 然后判断系统中可用的地址空间ID个数是否为0。如果非0,则表明系统中的进程数目还不是很多,有空闲的地址空间ID可用。如果为0,则调用steal_context将某个进程的地址空间进行flush,将其地址空间ID清0。这样新的地址空间ID就可以给next进程使用了。

  • 然后根据第二步得到地址空间ID(两种情况:一是本来就有空闲的进程地址空间ID,另外就是通过steal_context采用替换策略得到的ID)更新地址空间ID位图context_map。在设置位图之前,需要调用find_next_zero_bit找到对应的空闲比特位。

  • find_next_zero_bit也就是我们bug出现死循环的地方。

  • 将得到的地址空间ID和next进程相关联。并设置相应context_mm数组成员指针。地址空间获取完毕,函数返回。

再来看看steal_context函数,其代码如下:

其函数流程如下:

  • 根据next_mmu_context找到对应的地址空间。

  • 然后调用flush_tlb_mm将地址空间进行清除。实际上清除对应的TLB表项。

  • 然后调用destroy_context对将地址空间上下文相应的数据结构进程重新设置。将mm->context->ID的值重新设置为。将context_mm的指针设置为空,将context_map对应的位清0。

地址空间上下文切换小结

地址空间上下文首先会调用get_mmu_context获取被调度入任务的地址空间上下文。这个过程中需要获取进程地址空间上下文ID。如果在地址空间ID位图所有的进程地址空间ID都为非空闲,则需要调用steal_context采用某种策略获取一个新的地址空间ID。然后通过set_context设置进程地址上下文。

出现bug的情况

考虑下面得情况:

  • 进程数目比较多(超过LAST_CONTEXT个,也就是超过255个),这时候地址空间位图中所有比特都被设置为1,空间的地址空间数为0。

  • 有多个CPU同时运行,在P2020的板子上有2个CPU在运行。

  • CPU0试图调度某个进程,CPU1也在试图调度某个进程。

  • CPU0通过steal_context将原来某个进程的地址空间flush掉,然后试图使用通过steal_context获取的地址空间ID。而CPU1此时产生调度,它发现地址空间ID位图中有某位可用(实际上是CPU0通过steal_context获取的一位),那么CPU1将该地址空间ID赋值给新的调度进程,CPU1调度成功。而此时CPU0通过test_and_set_bit发现没有可用的地址空间ID,就find_next_zero_bit试图找到一个新的地址空间ID。但是此时,由于系统进程比较多,而且进程都未退出,进程地址空间ID资源不足,死循环产生了。

上述的情况也正是bug产生的情况,其出bug的根本原因就是get_mmu_context在获取地址空间ID和试图使用该地址空间ID时未用锁保护,导致其它CPU乘虚而入,将自己获取的地址空间ID拱手让给了别的CPU。

解决方案

只需要将获取地址空间ID和使用地址空间ID同时用spin lock加锁保护即可。这个bug属于开源bug,在2.6.28.1的版本上得到了修正。

这个故障,最终是通过传真器找到死循环地址后,发现是ID管理有问题,然后直接走查ID管理相关代码,找到解决办法。这是一个开源故障,相应的内核版本对PowerPC多核支持不好。要求熟悉内核同步原语。

第四个案例经验教训是在条件允许的情况下,尽量使用仿真器,同时需要深入理解内核代码,要对中断、信号处理、异常等都要熟悉,否则你拿着代码也不知道应该怎么看。

第五个案例是内存故障,经验教训是走查代码有时候是最快的办法,必要时需要手工加一些调试代码。在解决这个故障时,我通过走查代码的方法,找到了一个问题。但是并不确定已经修复了问题。然后在内存管理模块中我写了一个工具,加了一些调试代码,把分配和释放的地方都管理起来,去进行跟踪,把调动链取出来,一看正好是我走查代码以后,修改的地方,说明前期走查代码已经修复了问题。这样大家心里面就有底了,后来故障不复现了。所以对内存故障来说,走查代码是最好的,然后适当的增加一些调测手段。

我就讲到这里。

posted @ 2017-09-29 23:31  jasonactions  阅读(735)  评论(0)    收藏  举报