抽丝剥茧还原真相,记一次神奇的崩溃

作者:靳倡荣

本文详细回放了一个崩溃案例的分析过程。回顾了C++多态和类内存布局、pc指针与芯片异常处理、内存屏障的相关知识。

一、不讲“武德”的崩溃

1.1 查看崩溃调用栈

客户反馈了一个崩溃问题,并提供了core dump文件,查看崩溃调用栈如下:

(gdb) bt
#0  0x0000000078432d68 in asl::LooperObserverMan::notifyIdle (this=, looper=0x160eebd40, delay_queue_size=0)
    at ../../../../src/asl_message_framework/src/BaseMessageLooper.cpp:371
#1  0x00000000784928e4 in asl::MessageQueue::fetchNext (this=this@entry=0x160eedfc0, timing=@0xf4e9f60: 0)
    at ../../../../src/asl_message_framework/src/MessageQueue.cpp:83
#2  0x0000000078492b24 in asl::MessageQueue::next (this=0x160eedfc0, timing=@0xf4e9f60: 0) at ../../../../src/asl_message_framework/src/MessageQueue.cpp:60
#3  0x000000007832036c in asl::Looper::loop (this=0x160eebd40) at ../../../../src/asl_message_framework/src/Looper.cpp:107
#4  0x0000000078495ee0 in asl::MessageThread::run (this=0x7998e678) at ../../../../src/asl_message_framework/src/MessageThread.cpp:56
#5  0x000000007851cc70 in asl::Thread::runCallback (param=0x7998e678) at ../../../../src/asl_message_framework/src/Thread.cpp:183
#6  0x00000000010314e0 in ?? ()

显然,崩溃发生在了asl::LooperObserverMan::notifyIdle()函数中,BaseMessageLooper.cpp文件的第371行,源码如下:

抽丝剥茧还原真相,记一次神奇的崩溃_第1张图片

1.2 段错误位置不符合预期

崩溃时提示segment fault,通常就是非法地址访问,结合源码我们有理由怀疑node->observer指针异常(空指针或者野指针)导致这行发生了崩溃,或者node虽然非空但是可能是个野指针导致崩溃。查看node和node->observer:

(gdb) p node
$8 = (asl::LooperObserverMan::ObserverNode *) 0x17bb988e0
(gdb) p node->observer
$10 = (asl::IMessageLooper::Observer *) 0x7998e758

结果大大出乎意料,这两个指针居然可以正常访问。

至此,问题的分析陷入了僵局,这个崩溃看起来毫无道理,简直不讲武德,一个这么合法正常的内存访问居然导致了段错误。

二、汇编之下,纤毫毕现

都说“源码面前,了无秘密”,现在源码就摆在眼前,nodifyIdle函数一共7行,但是计算机却在我们面前变了“魔术”。其实计算机也很委屈,因为人眼看的“源码”并非机器看到的“源码”,机器看到的是二进制呀!这时候人也委屈了,机器看的0101二进制我人脑也很难处理呀!那么大家各退一步,在高级语言和机器二进制码之间的不就是汇编么?

2.1 用汇编“放大”源码

一行C++代码可以转换成多条汇编指令,汇编码就是高级语言源码的放大版。那么我们就来看看崩溃时的汇编吧。

(gdb) disas
Dump of assembler code for function asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int):
   0x0000000078432d30 <+0>: stp x19, x20, [sp,#-48]!
   0x0000000078432d34 <+4>: stp x21, x22, [sp,#16]
   0x0000000078432d38 <+8>: str x30, [sp,#32]
   0x0000000078432d3c <+12>:  ldr x19, [x0]
   0x0000000078432d40 <+16>:  cbz x19, 0x78432d8c 
   0x0000000078432d44 <+20>:  mov x22, x1
   0x0000000078432d48 <+24>:  mov w21, w2
   0x0000000078432d4c <+28>:  adrp  x20, 0x786b0000
   0x0000000078432d50 <+32>:  b 0x78432d60 
   0x0000000078432d54 <+36>:  nop
   0x0000000078432d58 <+40>:  ldr x19, [x19,#8]
   0x0000000078432d5c <+44>:  cbz x19, 0x78432d8c 
   0x0000000078432d60 <+48>:  ldr x0, [x19]
   0x0000000078432d64 <+52>:  ldr x1, [x20,#1160]
=> 0x0000000078432d68 <+56>:  ldr x2, [x0]
   0x0000000078432d6c <+60>:  ldr x3, [x2,#56]
   0x0000000078432d70 <+64>:  cmp x3, x1
   0x0000000078432d74 <+68>:  b.eq  0x78432d58 
   0x0000000078432d78 <+72>:  mov w2, w21
   0x0000000078432d7c <+76>:  mov x1, x22
   0x0000000078432d80 <+80>:  blr x3
   0x0000000078432d84 <+84>:  ldr x19, [x19,#8]
   0x0000000078432d88 <+88>:  cbnz  x19, 0x78432d60 
   0x0000000078432d8c <+92>:  ldp x21, x22, [sp,#16]
   0x0000000078432d90 <+96>:  ldr x30, [sp,#32]
   0x0000000078432d94 <+100>: ldp x19, x20, [sp],#48
   0x0000000078432d98 <+104>: ret
End of assembler dump.

使用gdb的disas指令查看当前栈顶函数的反汇编,确实将notifyIdle的7行C++代码变成了27行汇编指令,让我们得以看到更多细节。

2.2 发现直接原因

注意上图中箭头所示指令,即:

=> 0x0000000078432d68 <+56>:  ldr x2, [x0]

这个0x0000000078432d68就是当前pc寄存器的值,崩溃就发生在这一条ldr指令。该指令的含义是将x0寄存器中存的值作为内存地址,将内存中该地址存储的值load到x2寄存器中:

(gdb) i register x0
x0             0x2e002e 3014702
(gdb) x 0x2e002e
0x2e002e: Cannot access memory at address 0x2e002e

查看x0寄存器中存的是0x2e002e(后面的3014702是0x2e002e的十进制),我们尝试取该地址的内存数据时果然发生了错误。

至此,崩溃的直接原因找到了,机器终于“沉冤得雪”。说明它确实是遇到了无法访问的内存,因此才触发的段错误异常中断。

三、抽丝剥茧,详细分析

3.1 分析汇编,发现端倪

查看崩溃前的三条汇编指令:

   0x0000000078432d60 <+48>:  ldr x0, [x19]
   0x0000000078432d64 <+52>:  ldr x1, [x20,#1160]
=> 0x0000000078432d68 <+56>:  ldr x2, [x0]

这三条指令是依次执行的,没有其它跳转指令打断他们。x0的值是从x19指向的内存load的,查看相关寄存器和内存:

(gdb) i register x19
x19            0x17bb988e0  6370724064
(gdb) x 0x17bb988e0
0x17bb988e0:  0x7998e758
(gdb) x 0x7998e758
0x7998e758: 0x79989f40

可以看到x19中存的是0x17bb988e0,对这个地址取内容得到0x7998e758,正常这个值应该存入x0,但实际上x0中存储的却是非法地址0x2e002e,而0x7998e758是一个合法地址,可以正常取到它内容0x79989f40。

3.2 疑似原因一:踩内存

问题就发生在这三行汇编指令之间,首先我们怀疑是否是一个踩内存问题。

x0中存储的是x19中存储的值作为地址,该地址中的内存,崩溃时看到的是最终形态,虽然最终x19指向的内存可以被访问,但是否有可能ldr x0 [x19]时这块内存的值还是0x2e002e?另外,虽然x19指向的内存最终可以访问,但是可以访问未必代表符合预期,这块内存会不会是乱的?

3.2.1 链表节点指针内存符合预期

首先我们来确认下第二个问题,看看最终崩溃时x19存的地址对应的内存是什么:

(gdb) i register x19
x19            0x17bb988e0  6370724064
(gdb) x 0x17bb988e0
0x17bb988e0:  0x7998e758
(gdb) p node
$2 = (asl::LooperObserverMan::ObserverNode *) 0x17bb988e0
(gdb) p node->observer
$3 = (asl::IMessageLooper::Observer *) 0x7998e758

发现x19中存的是node的地址,对它取内容正是node->observer的地址,符合预期,observer正是node的第一个成员:

struct ObserverNode {
           IMessageLooper::Observer * observer;
           ObserverNode * next;
};

3.2.2 类内存布局符合预期

进一步查看observer内容:

(gdb) p *(node->observer)
$4 = {_vptr.Observer = 0x79989f40}

可见,Observer类的虚表地址为0x79989f40,进一步查看虚表内容是否符合预期

(gdb) x /16a 0x79989f40
0x79989f40: 0x7990c9e0  0x7990c9f0
0x79989f50: 0x78411698  0x79909598
0x79989f60: 0x799097d8  0x799099d0
0x79989f70: 0x784116b8   0x79909bd8
0x79989f80: 0x784116c8   0x784116d0 
0x79989f90: 0x784116d8   0x7990c988
0x79989fa0: 0x7990c990  0x7990c998
0x79989fb0: 0x7990c9a0  0x7990c9a8

可以看到虚表中各个函数指针,发现node和node->observer指向的内存符合预期。

3.2.3 排除踩内存的可能性

再来看第一个问题:x0中存储的是x19中存储的地址指向的内存,崩溃时看到的是最终形态,虽然最终x19指向的内存可以被访问,但是否有可能ldr x0 [x19]时这块内存的值还是0x2e002e?由于内存被踩踏导致x0的值与[x19]最终值不一致?

回头来看崩溃前的三行指令:

   0x0000000078432d60 <+48>:  ldr x0, [x19]
   0x0000000078432d64 <+52>:  ldr x1, [x20,#1160]
=> 0x0000000078432d68 <+56>:  ldr x2, [x0]

刚才已经确认最终崩溃时x19指向的内存正常,但是x0内容不正常,如果是踩内存,则需要在ldr x0 [x19]时将x19指向的内存踩坏,在崩溃时将其恢复正常,因此第一种假设不太可能。

3.3 疑似原因二:未初始化变量访问

原因猜测:x19指向的内存一开始是野指针(0x2e002e)该值赋给了x0,但是后来(异步线程)进行了正确赋值,导致崩溃最终现场x19指向的内存布局正常,但是x0中存入的是野指针地址触发崩溃。

3.3.1 业务源码分析

针对该假设则需要进一步查看源码,这三条指令已经进入了asl::LooperObserverMan::notifyIdle()函数的while循环中,即node不为空,那么是否存在node不为空,但是node->observer为野指针的时间空档,正好进入while(node)后ldr x0 [x19]将还没有初始化的node->observer地址给了x0呢?

查看给node->observer赋值的源码:

bool LooperObserverMan::addObserver(IMessageLooper::Observer * observer) {
        if(observer == NULL)
            return false;
...      
        ObserverNode * new_node = new ObserverNode();
        new_node->next = NULL;
        new_node->observer = observer;
        
        if(node == NULL)
            _observers = new_node;
        else
            node->next = new_node;
        
        return true;
}

可以看到node得到赋值之前已经提前对它的observer分量进行了赋值(new_node->observer = observer;)。

3.3.2 排除未初始化变量访问

如果notifyIdle()在addObserver之前调用,则ObserverNode * node = _observers;中的_observers的初值为NULL,在其所属类的构造函数中进行了初始化:

LooperObserverMan::LooperObserverMan() : _observers(NULL) {
}

而3.3.1的源码显示_observers赋值成new_node之前,new_node->observer已经完成赋值。

因此,node不为空时,x19指向的node->observer内存未初始化时load到x0的假设也不成立。

3.4 初步分析结论

综上,最终x0内容不符合预期则更可能是由于系统级别的稳定性问题导致了。

例如中断或进程抢占导致ldr x0 [x19]后当前任务被打断,等到恢复上下文回到当前任务继续执行时x0寄存器值没有得到正确恢复,导致崩溃。

当然,是否真的如此当前证据已经不足了,需要整机dump才能进一步分析。

3.5 问题复现,闪电再次劈中?

3.5.1 相同崩溃栈复现

不久前另一个客户也报了相同的问题,客户反馈的崩溃调用栈是一样的。如果说是硬件或系统级别的问题,那么这就是被闪电劈中了两次,基本可以排除系统级别或硬件的问题。我们应该更多地审视为啥这块(用户态)代码被劈中。

3.5.2 崩溃原因再讨论

重新审视之前的分析。发现3.3中我们排除疑点二的一个重要依据为变量_observers初值为NULL,后续赋值顺序为:

new_node->observer = xxxx;
_observers = new_node;

即,另一个线程读的时候,_observers要么是NULL,要么是成员变量(new_node->observer)已经赋好值的new_node。

拆分出来就是两个依据:

1)指针_observers赋值是原子的,读线程要么读到NULL,要么读到好的_observers;

2)new_node->observer的赋值在_observers赋值之前进行。

3.5.3 指针赋值原子性讨论

对此大家产生了分歧。

一种观点认为:指针、int等基础类型的赋值不是原子的,否则C++为什么还要搞std::atomic来保障基础类型读写原子性。

另一种观点认为:在同一个cacheline中的操作是原子的(inter手册中有相关表述,arm的还没找到),而本例中的指针没做特殊对齐限制,所以地址是cacheline size(64bit系统为8字节)对齐的,因此是原子的。

3.5.4 赋值顺序讨论

再次看这两个赋值语句:

new_node->observer = xxxx;
_observers = new_node;

发现,其实这两个赋值是没有依赖的,即交换顺序后结果是不变的。那么就存在被编译器以及CPU reorder的可能,而此处并没有设置内存屏障来保障内存序。

因此存在这样一种可能性:写线程由于reoder的存在,先执行了_observers = new_node,与此同时读线程判空逻辑命中,并将此时尚未初始化的_observers->observerload到了寄存器x0中,这之后写线程完成_observers->observer的赋值,读线程走到x0内存的访问,发生崩溃。

3.6 show me the code,demo验证

3.6.1 demo构造

首先将addObserver代码原封不动从基础库复制过来:

bool LooperObserverMan::addObserver(Observer * observer) {
    if(observer == NULL)
        return false;
    
    ObserverNode * node = _observers;
    while(node) {
        if(node->observer == observer)
            return false;
        
        if(node->next == NULL)
            break;
        
        node = node->next;
    }
    
    ObserverNode * new_node = new ObserverNode();
    new_node->next = NULL;
    new_node->observer = observer;
    if(node == NULL)
        _observers = new_node;
    else
        node->next = new_node;
​
    return true;
}

然后将读线程调用的notifyIdle函数稍作改造,去掉更深层次的调用实现,便于debug:

bool LooperObserverMan::notifyIdle(Observer * observer) {
    ObserverNode * node = _observers;
    while(node) {
        if (observer != node->observer) {
            std::cout << "error: observer not match!!!" << std::endl;
            std::cout << "observer: " << observer << ", node->observer: " << node->observer << std::endl;
        }
        node->observer->onLooperIdle();
        node = node->next;
        return true;
    }
    return false;
}

LooperObserverMan的构造函数中保证成员变量_observers初值为NULL:

LooperObserverMan::LooperObserverMan() : _observers(NULL) {
}

头文件内容如下:

#include 
class Observer {
public:
    virtual ~Observer() {}
    virtual void onLooperIdle() {
        std::cout << "onLooperIdle()" << std::endl;
    };
};
​
class LooperObserverMan {
public:
    struct ObserverNode {
        Observer * observer;
        ObserverNode * next;
    };
​
    LooperObserverMan();
    ~LooperObserverMan();
    
    bool addObserver(Observer * observer);
    bool notifyIdle(Observer * observer);
​
   private:
       ObserverNode * _observers;
};

在main函数中做如下测试,构造与高精SDK中类似的只add一个observer的场景

#include 
#include "LooperObserverMan.h"
​
int main()
{
    Observer ob;
    LooperObserverMan* looper = new LooperObserverMan();
​
    std::thread t = std::thread([&]() {
        looper->addObserver(&ob);
    });
    while (1) {
        if (looper->notifyIdle(&ob)) {
            break;
        }
    }
​
    t.join();
    delete looper;
    return 0;
}

此处我们起了一个线程调用addObserver,将变量Observer ob的地址作为实参传入,主线程则调用notifyIdle()接口,notifyIdle()的实现中,会判断node为空则return false,node不为空则比较node->observer的值,并调用node->observer->onLooperIdle()接口。只要notifyIdle()返回一次true,main函数就会结束。notifyIdle()的入参也是变量Observer ob的地址,正常内存序下,如果node不为空,则node->observer已经完成了赋值,其值与变量Observer ob的地址应该相等。异常时将会打印出相关error日志。

使用脚本进行压测,模拟每次只添加一个observer的场景,反复启动测试进程test_reorder,shell脚本如下,

num=0;
while true; do sleep 1; date; ./test_reorder; num=`expr $num + 1`; echo $num; done

3.6.2 压测结果

在客户环境下压测了217258次,出现了10次error日志,如下所示,

Sun Feb 15 09:20:29 GMT 1970

error: observer not match!!!

observer: 100c7878, node->observer: 100c7878

onLooperIdle()

191229

说明存在万分之0.5的概率当node值不为空时,node->observer != &ob。但是日志打印时node->observer的值跟变量ob的地址已经相等了。

3.6.3 demo压测结果分析

从结果中看,3.5.2中我们提炼的第2点依据被推翻。实际情况下,node不为空时,指令乱序可能导致node->observer还未赋值。

指令乱序分为硬件和软件两个层面,我们重点排查软件层面,即编译器优化。正如3.5.4所分析,node和node->observer的赋值是不存在相互依赖的,因此满足指令乱序优化条件,是否进行了编译优化我们只需要查看汇编即可。查看demo代码的汇编如下。

抽丝剥茧还原真相,记一次神奇的崩溃_第2张图片

为了减少看汇编码的成本,我们直接看逆向工具根据汇编生成的反编译代码即可,如上图右侧窗口所示。

其中pOVar1 = this->observers;即将LooperObserverMan的成员变量_observers赋值给pOVar1,因为我们只压测插入第一个节点的场景,因此只需关注pOVar1为空的分支,即:

  pOVar1 = (ObserverNode *)operator.new(0x10); // new_node = new ObserverNode();
  this->_observers = pOVar1; // _observers = new_node;
  pOVar1->observer = observer; // new_node->observer = observer;
  pOVar1->next = (ObserverNode *)0x0; // new_node->next = NULL;

发现这里将operator.new分配的内存地址赋值给了pOVar1,对应源码的ObserverNode * new_node = new ObserverNode();但此处把new分配的地址赋值给pOVar1后紧接着把pOVar1赋值给了成员变量_observers,即this->_observers = pOVar,这之后才对pOVar1->observer这个分量进行赋值。对比源码:

bool LooperObserverMan::addObserver(Observer * observer) {
...
    ObserverNode * new_node = new ObserverNode();
    new_node->next = NULL;
    new_node->observer = observer;
    if(node == NULL)
        _observers = new_node;
    else
        node->next = new_node;
...
}

可以看到将_observers = new_node做的事情提前到了new_node->observer = observer之前,说明确实进行了reorder!那么当读线程判断_observers不为空就立刻使用_observers->observer时,就存在_observers->observer尚未初始化的情况,导致崩溃。

3.6.4 其他平台编译结果对比

相同代码编译其他平台可执行程序,对比汇编内容。

Android平台

抽丝剥茧还原真相,记一次神奇的崩溃_第3张图片

发现android平台的编译结果并没有将_observers和_observers->observer赋值做reorder的优化(只是将new_node->next和new_node->observer这两个赋值语句做了reorder),几行核心反编译代码如下:

    ppOVar3 = (Observer **)operator_new(8); // new_node = new ObserverNode();
    *ppOVar3 = param_1; // new_node->observer = observer;
    ppOVar3[1] = (Observer *)0x0; // new_node->next = NULL;
    if (bVar1) {
      *(Observer ***)this = ppOVar3; // _observers = new_node;
    }

将new分配的内存地址赋值给变量ppOVar3,*ppOVar3表示struct ObserverNode的第一个成员observer, 因此*ppOVar3 = param_1表示将入参&ob赋值给ppOvar3->observer;接着ppOVar3[1]表示struct ObserverNode的第二个成员next指针,ppOVar3[1] = (Observer*)0x0,表示ppOVar3->next = NULL。因此变量ppOVar3就是addObserver源码中的new_node变量。这之后*(Observer ***)this = ppOVar3对应的就是将成员变量_observers赋值成ppOVar3。因此android平台的赋值顺序是没有被优化的。

Mac平台

抽丝剥茧还原真相,记一次神奇的崩溃_第4张图片

mac平台同样没有优化,反编译得到的变量pauVar3就是源码中的new_node变量。

备注:

1)即使是相同的平台不同的编译选项结果也不同,例如-O3和-O0

2)struct ObserverNode定义如下:

struct ObserverNode {
        Observer * observer;
        ObserverNode * next;
};

3.6.5 增加内存屏障

既然是编译器进行了reorder优化,我们就可以使用内存屏障禁止编译器相关优化,可以在addObserver代码中插入一行表示内存屏障的汇编__asm__ __volatile__("":::"memory")进行测试:

bool LooperObserverMan::addObserver(Observer * observer) {
... 
    ObserverNode * new_node = new ObserverNode();
    new_node->next = NULL;
    new_node->observer = observer;
    __asm__ __volatile__("":::"memory"); // 插入内存屏障
    if(node == NULL)
        _observers = new_node;
    else
        node->next = new_node;
​
    return true;
}

查看增加内存屏障后编译结果的汇编:

抽丝剥茧还原真相,记一次神奇的崩溃_第5张图片

  pOVar1 = (ObserverNode *)operator.new(0x10);
  pOVar1->observer = observer;
  pOVar1->next = (ObserverNode *)0x0;
  if (pOVar2 == (ObserverNode *)0x0) {
    this->_observers = pOVar1;
  }

可以看到增加内存屏障后编译器已经不再进行相关优化了,new分配的内存赋值给pOVar1,pOVar1->observer完成赋值后才会将this->_observers赋值成pOVar1。赋值顺序得到了保障。

四、水落石出,最终结论

至此,终于水落石出。崩溃的直接原因是非法内存访问,非法的内存为结构体变量node的分量:node->observer。有两个线程分别对该变量进行读和写操作,其中读线程对node进行判空后使用了node->observer分量的内存。其内部逻辑认为node不为空时node->observer一定合法;而写线程代码中对临时变量new_node分配内存后对其分量new_node->observer进行赋值,然后将new_node赋值给node,即new_node->observer = xxx; node = new_node;想利用这样的设计保障读线程判断node不为空时读到合法的node->observer。但实际上qnx平台编译结果的汇编指出,编译器在此处进行了内存序优化,调整了这两个赋值语句的顺序,打破了上述假设。导致读线程判断node不为空后调用node->observer->onLooperIdle()接口时由于node->observer变量还未初始化导致崩溃。

一句话总结:编译器reorder优化导致指令顺序改变,进而导致异步读线程使用了未初始化的变量触发崩溃。

优化方案:

方案1:基础库addObserver中增加内存屏障。

方案2:业务封装的TimerCtrl,将addObserver操作绑定到消息队列回调函数(notifyIdle)的线程上,避免读写异步。

五、知识点回顾

本次崩溃问题分析中,用到了很多以前书本上学习的知识,例如我们查看虚表内存其实就是C++多态实现机制和类内存布局相关知识。这些知识点让我们更加精准的看到了代码的内部,也帮助我们印证了一些推断。

5.1 C++多态实现&类内存布局

5.1.1 C++虚函数多态原理

这里说的多态特指C++的动态多态,虚函数。虚函数的多态实现离不开虚函数表(后面简称虚表),虚表不属于类的对象,它属于整个类,是一个全局变量,是编译时就生成在data段的一张表,表里面就是各个虚函数的函数指针,这些指针指向各个函数的代码段。

类对象构造时编译器生成vptr指针指向虚表(相同类的所有对象指向全局唯一虚表)。虚表内容为各个虚函数的函数指针。子类则会拷贝一张虚表,并将自己override的接口替换成override后函数的指针。这就是多态实现的关键。当我们取一个Base的指针指向子类对象时:

Base *p = new Driver();

new Driver()构造的是子类对象,因此生成的vptr指向的是子类的虚表,这样当使用指针p调用子类override的函数时就能从虚表中找到override后的函数指针了。

5.1.2 多态必须使用指针或引用的原因

我们使用C++多态时通常是使用父类指针指向子类对象,或者父类引用(Base&)子类对象,但是直接对象赋值则无法调用到子类方法,例如:

Base b;
Driver d;
b = static_cast(d);

原因是这种强转赋值时vptr指针并不会做拷贝动作,因此赋值后对象b中的vptr还是指向的Base类的虚表,因此无法调用子类方法,即无法达到多态的效果的。

关于C++多态实现的相关资料很多,此处不再赘述。

5.1.3 子类虚表编译优化

本次分析问题时我们查看observer类的虚表内容如下:

(gdb) x /16a 0x79989f40
0x79989f40: 0x7990c9e0  0x7990c9f0
0x79989f50: 0x78411698  0x79909598
0x79989f60: 0x799097d8  0x799099d0
0x79989f70: 0x784116b8   0x79909bd8
0x79989f80: 0x784116c8   0x784116d0 
0x79989f90: 0x784116d8   0x7990c988
0x79989fa0: 0x7990c990  0x7990c998
0x79989fb0: 0x7990c9a0  0x7990c9a8

但是看class Observer源码虚函数不止上面虚表内存中显示的5个:

class Observer {
public:
    virtual ~Observer() {}
            
    virtual void onLooperStart(IMessageLooper * looper, int queue_size, int delay_queue_size) {};
    virtual void onLooperPostMsg(IMessageLooper * looper, Message * msg, uint32_t delay) {};
    virtual void onLooperStartMsg(IMessageLooper * looper, Message * msg, uint64_t timing, uint64_t now) {};
    virtual void onLooperEndMsg(IMessageLooper * looper, Message * msg, uint64_t timing, uint64_t now, uint32_t duration) {};
    virtual void onLooperBusy(IMessageLooper * looper) {};
    virtual void onLooperIdle(IMessageLooper * looper, int delay_queue_size) {};
    virtual void onLooperQuit(IMessageLooper * looper) {};
    virtual void onLooperDestroy(IMessageLooper * looper) {};
    virtual void onLooperCancelMsg(IMessageLooper * looper, Message * msg, uint64_t timing, uint64_t now) {}
};

实际上我们打印的node->observer指向的是classTimerMessageObserver 对象,它是asl::IMessageLooper::Observer的子类,而虚表中显示的几个函数指针都是这个子类没有override的函数。此处可能是编译器的优化。这一点可以从notifyIdle函数的汇编中看出一些端倪。

   0x0000000078432d40 <+16>:  cbz x19, 0x78432d8c 
   0x0000000078432d44 <+20>:  mov x22, x1
   0x0000000078432d48 <+24>:  mov w21, w2
   0x0000000078432d4c <+28>:  adrp  x20, 0x786b0000
   0x0000000078432d50 <+32>:  b 0x78432d60 
   0x0000000078432d54 <+36>:  nop
   0x0000000078432d58 <+40>:  ldr x19, [x19,#8]
   0x0000000078432d5c <+44>:  cbz x19, 0x78432d8c 
   0x0000000078432d60 <+48>:  ldr x0, [x19]
   0x0000000078432d64 <+52>:  ldr x1, [x20,#1160]
=> 0x0000000078432d68 <+56>:  ldr x2, [x0]
   0x0000000078432d6c <+60>:  ldr x3, [x2,#56]
   0x0000000078432d70 <+64>:  cmp x3, x1
   0x0000000078432d74 <+68>:  b.eq  0x78432d58 
   0x0000000078432d78 <+72>:  mov w2, w21
   0x0000000078432d7c <+76>:  mov x1, x22
   0x0000000078432d80 <+80>:  blr x3
   0x0000000078432d84 <+84>:  ldr x19, [x19,#8]
   0x0000000078432d88 <+88>:  cbnz  x19, 0x78432d60 
   0x0000000078432d8c <+92>:  ldp x21, x22, [sp,#16]
   0x0000000078432d90 <+96>:  ldr x30, [sp,#32]
   0x0000000078432d94 <+100>: ldp x19, x20, [sp],#48
   0x0000000078432d98 <+104>: ret

0x0000000078432d40 <+16>这一行的cbz x19, 0x78432d8c是x19为空则跳转到0x78432d8c的意思,x19就是node的地址,即while(node)判断node为空则跳转到0x0000000078432d8c <+92>行,这一行其实就是弹出函数栈中备份的寄存器,然后返回,即while结束,函数return。

adrpx20, 0x786b0000表示取0x786b0000所在4KB内存页首地址存入x20,这之后跳转到0x0000000078432d60 <+48>,注意到x1的内容如下:

(gdb) i register x1
x1             0x784116c0 2017531584
(gdb) x 0x784116c0
0x784116c0 :  0xd503201fd65f03c0
(gdb) info symbol 0x784116c0
asl::IMessageLooper::Observer::onLooperIdle(asl::IMessageLooper*, int) in section .text of libbase_utils.so

即x1是通过x20找到的Observer::onLooperIdle函数指针,但是这个函数是libbase_utils.so的符号,即父类的虚函数指针(子类classTimerMessageObserver定义在libGAdasUtils.so中)。

0x0000000078432d68 <+56>: ldr x2, [x0]

此处实际上取到了Observer的this指针,即子类对象的this指针,它指向的就是子类的虚表:

(gdb) i register x19
x19            0x17bb988e0  6370724064
(gdb) x 0x17bb988e0
0x17bb988e0:  0x7998e758
(gdb) x 0x7998e758
0x7998e758: 0x79989f40 // 虚表地址
(gdb) p *node->observer
$6 = {_vptr.Observer = 0x79989f40}

这之后0x0000000078432d6c <+60>:ldrx3, [x2,#56]即this指针偏移56字节后取内容存入x3,虚表地址偏移56字节就是0x79989f78:

(gdb) x /16a 0x79989f40
0x79989f40: 0x7990c9e0  0x7990c9f0
0x79989f50: 0x78411698  0x79909598
0x79989f60: 0x799097d8  0x799099d0
0x79989f70: 0x784116b8   0x79909bd8
0x79989f80: 0x784116c8   0x784116d0 
0x79989f90: 0x784116d8   0x7990c988
0x79989fa0: 0x7990c990  0x7990c998
0x79989fb0: 0x7990c9a0  0x7990c9a8

虽然虚表没有打印出来这个地址对应的函数指针,但是可以确认是函数onLooperBusy后面声明的那个虚函数,即onLooperIdle()与notifyIdle的源码得以对应。这之后汇编码中做了比较cmp x3 x1,当x3和x1相等则跳转b.eq0x78432d58 ,而<+40>行中直接开始load node偏移8字节的内存了ldrx19, [x19,#8],相当于直接取node->next却不执行任何函数,显然我们这里observer指向的是子类对象,因此这个cmp指令结果是false的,不会跳转,会继续执行到0x0000000078432d80 <+80>:blrx3,跳转到x3指向的函数指针执行完该函数后才执行的0x0000000078432d84 <+84>:ldrx19, [x19,#8],即node = node->next继续循环。

5.1.4 虚表中函数指针分布

虚表中的函数指针是按照虚函数声明顺序排列的,但此处有一个小疑问,按照虚函数声明顺序,算上析构函数onLooperIdle()是第7个声明的虚函数,应该是虚表偏移6*8 = 48个字节才对,为什么这里差一个?我们找一个没有编译优化的demo看一下虚表内存布局:

(gdb) p *pa
$1 = {_vptr.A = 0x400d30 }
(gdb) x /16a 0x400d30
0x400d30 <_ZTV1A+16>: 0x400ab6   0x400ae4 
0x400d40 <_ZTV1A+32>: 0x400b0a  0x400b34 
0x400d50 <_ZTV1A+48>: 0x400b5e  0x4231

可以看到这个虚表中有两个析构函数A::~A(),这是因为gcc实现了两个虚析构函数(msvc只有一个)。许多编译器为一个类生成两个不同的析构函数:一个用于销毁动态分配的对象,另一个用于销毁非动态对象(静态对象、局部对象、基子对象或成员子对象,称为complete object destructor)。前者从内部调用operator delete,后者则不调用。有些编译器通过向一个析构函数添加隐藏参数来实现这一点(较老版本的GCC是这样做的,msvc++是这样做的),有些编译器只是生成两个独立的析构函数(较新版本的GCC是这样做的)。

至此,多偏移的8字节就合理了。

5.2 理解pc指针与芯片异常处理

本次问题组内讨论时有同学提出了一个疑问:pc指针是program counter,指向的是下一条待执行的指令,而arm指令又是三级流水线,pc指向的只是“正在取指”的指令,并不是指向的“正在执行”或“正在译码”的指令,所以崩溃处是否不是反编译后pc的位置,而是pc - 4或者pc -8处呢?

虽然本次的问题我们可以通过打印寄存器和相关内存内容确认pc - 8和pc - 4处不会发生段错误崩溃,但是这个问题还是一下子问住了我。之前无论是分析内核dump还是用户态进程dump都是默认调用栈pc指针处就是发生崩溃处,确实没有认真想过这个问题。一下子让我怀疑了人生,难道之前dump分析的都有问题?应该看pc之前的代码?可是这又跟历史经验不符,难道使用gdb单步调试时看到的pc也不是正在执行的代码吗?那我为啥能够在pc经过一行赋值语句后看到了内存赋值结果?

会不会正常执行时pc指向的是尚未执行的指令,但是发生异常时有不一样的处理呢?ARM开发文档中给出了答案。

The ELR_ELn register is used to store the return address from an exception. The value in this register is automatically written on entry to an exception and is written to the PC as one of the effects of executing the ERET instruction that is used to return from exceptions.

发生异常时,ELR_ELn寄存器中会存储异常返回后执行指令的地址,待异常返回时再将其填入PC。

ELR_ELn contains the return address, which depends upon the specific exception type. Typically, this is the address of the instruction after the one that generated the exception.

For example, when an SVC (system call) instruction is executed, you want to return to the following instruction in the application. In other cases, however, you might want to re-execute the instruction that generated the exception.

但是具体是存储触发异常的指令还是下一条待执行的指令由异常类型决定。

通常有如下规律:

  • 对于异步异常,它是中断发生时的下一条指令,或没有执行的第一条指令;

  • 对于不是system call的同步异常,它是触发同步异常的那一条指令;

  • 对于system call, 它是svc指令的下一条指令。

关于同步异常、异步异常可以参考《ARM异常处理》,常见的同步异常有:

  • 尝试访问异常等级不恰当的寄存器;

  • 尝试执行被关闭或没有定义(UNDEFINED)的指令;

  • 使用没有对齐的SP;

  • 尝试执行PC没有对齐的指令;

  • 软件产生的异常,比如执行系统调用(SVC)、HVC或SMC指令;

  • 因地址翻译或权限等导致的数据异常;

  • 因地址翻译或权限等导致的指令异常;

  • 调试导致的异常,比如断点异常、观察点异常、软件单步异常等;

我们常见的段错误其实就是“因地址翻译或权限等导致的数据异常”,属于一种数据中止的同步异常,类似的还有缺页中断,不同的是缺页中断会在中断处理函数中修复该地址,即所谓按需分配page使得该地址可用,因此这类异常返回时pc会指向触发异常的指令,重新执行相关指令或退出。

因此,我们分析段错误时直接看frame 0中pc的代码就是触发问题的地方。同理,gdb单步调试时bt命令看到的pc也是程序暂停前执行的指令。

更多参考:How to use ARM’s data-abort exception

https://www.embedded.com/how-to-use-arms-data-abort-exception/

5.3 内存乱序与内存屏障

本次问题的本质其实是一个内存乱序编译优化问题。我们的赋值语句没有强制禁止编译器优化,那么编译器就可以在满足规则的前提下性能优先,做一些reorder的优化。上文的demo代码其实就是经典的store-store乱序。相关知识有很多文章都写得比较好,此处不再赘述。

六、小结

  • 本文详细回放了一个崩溃案例的分析过程。

  • 回顾了C++多态和类内存布局相关知识,了解原理后查看内存让我们看到了更多代码内部的细节。

  • 回顾了pc指针的含义并了解了更多arm异常处理机制,解释了一些日常认为理所当然的结论背后的原理。

  • 回顾了内存屏障相关知识,并构造了demo对理论分析进行了实践验证。

6.1 启发

该案例非常经典,对我们后续分析问题和编码设计都有一定的启发。

分析问题方面

  • 汇编码是高级语言源码的放大版,当在高级语言层面看不出问题时,不妨试一下查看汇编,因为它更接近机器执行的“源码”,具有更高的“分辨率”。

编码设计方面

  • 无锁设计的代码,尤其是我们“精心”设计依赖赋值顺序的代码,不要忘记内存序优化的存在。

  • 编码设计除了coding部分,还要与编译器和谐相处,明确编译器行为,确保最终的编译产物符合设计预期,避免编译器“自由发挥”。

6.2 感悟

“学而时习之,不亦说乎。”有两种解释,一种是说:学习后经常复习很快乐。我更喜欢另一种解释:学习后在适当的时机实践、使用很快乐。强调的是学以致用。复习有什么可快乐的?真正的乐趣在于学习了书本知识后能够在实践中得以应用。

抽丝剥茧还原真相,记一次神奇的崩溃_第6张图片

你可能感兴趣的:(java,windows,开发语言)