写点什么

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

作者:阿里技术
  • 2023-03-17
    浙江
  • 本文字数:15462 字

    阅读完需:约 51 分钟

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

作者:靳倡荣


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


一、不讲“武德”的崩溃


1.1 查看崩溃调用栈


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


(gdb) bt#0  0x0000000078432d68 in asl::LooperObserverMan::notifyIdle (this=<optimized out>, 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.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) disasDump 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 <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+92>   0x0000000078432d44 <+20>:  mov x22, x1   0x0000000078432d48 <+24>:  mov w21, w2   0x0000000078432d4c <+28>:  adrp  x20, 0x786b0000   0x0000000078432d50 <+32>:  b 0x78432d60 <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+48>   0x0000000078432d54 <+36>:  nop   0x0000000078432d58 <+40>:  ldr x19, [x19,#8]   0x0000000078432d5c <+44>:  cbz x19, 0x78432d8c <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+92>   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 <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+40>   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 <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+48>   0x0000000078432d8c <+92>:  ldp x21, x22, [sp,#16]   0x0000000078432d90 <+96>:  ldr x30, [sp,#32]   0x0000000078432d94 <+100>: ldp x19, x20, [sp],#48   0x0000000078432d98 <+104>: retEnd 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 x0x0             0x2e002e 3014702(gdb) x 0x2e002e0x2e002e: 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 x19x19            0x17bb988e0  6370724064(gdb) x 0x17bb988e00x17bb988e0:  0x7998e758(gdb) x 0x7998e7580x7998e758: 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 x19x19            0x17bb988e0  6370724064(gdb) x 0x17bb988e00x17bb988e0:  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 0x79989f400x79989f40: 0x7990c9e0  0x7990c9f00x79989f50: 0x78411698 <asl::IMessageLooper::Observer::onLooperStart(asl::IMessageLooper*, int, int)> 0x799095980x79989f60: 0x799097d8  0x799099d00x79989f70: 0x784116b8 <asl::IMessageLooper::Observer::onLooperBusy(asl::IMessageLooper*)>  0x79909bd80x79989f80: 0x784116c8 <asl::IMessageLooper::Observer::onLooperQuit(asl::IMessageLooper*)>  0x784116d0 <asl::IMessageLooper::Observer::onLooperDestroy(asl::IMessageLooper*)>0x79989f90: 0x784116d8 <asl::IMessageLooper::Observer::onLooperCancelMsg(asl::IMessageLooper*, asl::Message*, unsigned long, unsigned long)>  0x7990c9880x79989fa0: 0x7990c990  0x7990c9980x79989fb0: 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 <iostream>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 <thread>#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 代码的汇编如下。



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


其中 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 平台



发现 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 平台



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;}
复制代码


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



  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<Base>(d);
复制代码


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


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


5.1.3 子类虚表编译优化


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


(gdb) x /16a 0x79989f400x79989f40: 0x7990c9e0  0x7990c9f00x79989f50: 0x78411698 <asl::IMessageLooper::Observer::onLooperStart(asl::IMessageLooper*, int, int)> 0x799095980x79989f60: 0x799097d8  0x799099d00x79989f70: 0x784116b8 <asl::IMessageLooper::Observer::onLooperBusy(asl::IMessageLooper*)>  0x79909bd80x79989f80: 0x784116c8 <asl::IMessageLooper::Observer::onLooperQuit(asl::IMessageLooper*)>  0x784116d0 <asl::IMessageLooper::Observer::onLooperDestroy(asl::IMessageLooper*)>0x79989f90: 0x784116d8 <asl::IMessageLooper::Observer::onLooperCancelMsg(asl::IMessageLooper*, asl::Message*, unsigned long, unsigned long)>  0x7990c9880x79989fa0: 0x7990c990  0x7990c9980x79989fb0: 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 <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+92>   0x0000000078432d44 <+20>:  mov x22, x1   0x0000000078432d48 <+24>:  mov w21, w2   0x0000000078432d4c <+28>:  adrp  x20, 0x786b0000   0x0000000078432d50 <+32>:  b 0x78432d60 <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+48>   0x0000000078432d54 <+36>:  nop   0x0000000078432d58 <+40>:  ldr x19, [x19,#8]   0x0000000078432d5c <+44>:  cbz x19, 0x78432d8c <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+92>   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 <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+40>   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 <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+48>   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 x1x1             0x784116c0 2017531584(gdb) x 0x784116c00x784116c0 <asl::IMessageLooper::Observer::onLooperIdle(asl::IMessageLooper*, int)>:  0xd503201fd65f03c0(gdb) info symbol 0x784116c0asl::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 x19x19            0x17bb988e0  6370724064(gdb) x 0x17bb988e00x17bb988e0:  0x7998e758(gdb) x 0x7998e7580x7998e758: 0x79989f40 // 虚表地址(gdb) p *node->observer$6 = {_vptr.Observer = 0x79989f40}
复制代码


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


(gdb) x /16a 0x79989f400x79989f40: 0x7990c9e0  0x7990c9f00x79989f50: 0x78411698 <asl::IMessageLooper::Observer::onLooperStart(asl::IMessageLooper*, int, int)> 0x799095980x79989f60: 0x799097d8  0x799099d00x79989f70: 0x784116b8 <asl::IMessageLooper::Observer::onLooperBusy(asl::IMessageLooper*)>  0x79909bd80x79989f80: 0x784116c8 <asl::IMessageLooper::Observer::onLooperQuit(asl::IMessageLooper*)>  0x784116d0 <asl::IMessageLooper::Observer::onLooperDestroy(asl::IMessageLooper*)>0x79989f90: 0x784116d8 <asl::IMessageLooper::Observer::onLooperCancelMsg(asl::IMessageLooper*, asl::Message*, unsigned long, unsigned long)>  0x7990c9880x79989fa0: 0x7990c990  0x7990c9980x79989fb0: 0x7990c9a0  0x7990c9a8
复制代码


虽然虚表没有打印出来这个地址对应的函数指针,但是可以确认是函数 onLooperBusy 后面声明的那个虚函数,即 onLooperIdle()与 notifyIdle 的源码得以对应。这之后汇编码中做了比较 cmp x3 x1,当 x3 和 x1 相等则跳转 b.eq0x78432d58 <asl::LooperObserverMan::notifyIdle(asl::IMessageLooper*, int)+40>,而<+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 <vtable for A+16>}(gdb) x /16a 0x400d300x400d30 <_ZTV1A+16>: 0x400ab6 <A::~A()>  0x400ae4 <A::~A()>0x400d40 <_ZTV1A+32>: 0x400b0a <A::func1()> 0x400b34 <A::func2()>0x400d50 <_ZTV1A+48>: 0x400b5e <A::func3(int, int)> 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 感悟


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



发布于: 2023-03-17阅读数: 14
用户头像

阿里技术

关注

专注分享阿里技术的丰富实践和前沿创新。 2022-05-24 加入

阿里技术的官方号,专注分享阿里技术的丰富实践、前沿洞察、技术创新、技术人成长经验。阿里技术,与技术人一起创造成长与成就。

评论

发布
暂无评论
抽丝剥茧还原真相,记一次神奇的崩溃_debug_阿里技术_InfoQ写作社区