深入解构iOS系统下的全局对象和初始化函数

神奇的崩溃事件

事件源于接入了一个第三方库导致应用出现了大量的crash记录,很奇怪的是这么多的crash居然没有收到用户的反馈信息! 在这个过程中每个崩溃栈的信息都明确的指向了是那个第三方库的某个工作线程产生的崩溃。这个问题第三方提供者一直无法复现,而且我们的RD、PM、QA同学在调试和测试过程中都没有出现过这个问题。后来再经过仔细检查分析,发现每次崩溃时的各线程的调用栈都大概是如下的情况:

Hardware Model:      iPhone7,2
Code Type:       ARM-64
Parent Process:  ? [1]
Date/Time:       2018-05-10 10:22:32.000 +0800
OS Version:      iOS 10.3.3 (14G60)
Report Version:  104
Exception Type:  EXC_BAD_ACCESS (SIGBUS)
Exception Codes: 0x00000000 at 0xbadd0c44f948beb5
Crashed Thread:  33

//并非崩溃在主线程,而是用户执行了杀掉应用的操作。下面主线程的调用栈可以看出是用户主动杀死的进程。
Thread 0:
0   xxxx                            xxxx::Threads::Synchronization::AppMutex::~AppMutex() (xxxx.cpp:58)
1   libsystem_c.dylib               __cxa_finalize_ranges + 384
2   libsystem_c.dylib               exit + 24
3   UIKit                           +[_UIAlertManager hideAlertsForTermination] + 0
4   UIKit                           __102-[UIApplication _handleApplicationDeactivationWithScene:shouldForceExit:transitionContext:completion:]_block_invoke.2093 + 792
5   UIKit                           _runAfterCACommitDeferredBlocks + 292
6   UIKit                           _cleanUpAfterCAFlushAndRunDeferredBlocks + 528
7   UIKit                           _afterCACommitHandler + 132
8   CoreFoundation                  __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 32
9   CoreFoundation                  __CFRunLoopDoObservers + 372
10  CoreFoundation                  __CFRunLoopRun + 956
11  CoreFoundation                  CFRunLoopRunSpecific + 424
12  GraphicsServices                GSEventRunModal + 100
13  UIKit                           UIApplicationMain + 208
14  xxxx                            main (main.m:36)
15  libdyld.dylib                   start + 4

/*
     崩溃的线程调用栈,出现崩溃的机器指令段如下:
     0x106bee318<+180>: add x8, x0, #0x8
     0x106bee31c<+184>: ldaxr w9,[x8]    
    注意看下面的x0,x8寄存器中的值已经是异常的数字了,这里对异常地址进行读取操作产生了崩溃
*/
Thread 33 name:  xxxx
Thread 33 Crashed:
0   xxxx                            xxxx::Message::recycle() + 184
1   xxxx                            xxxx::Message::recycle() + 176
2   xxxx                            xxxx::BaseMessageLooper::onProcMessage(xxxx::Message*) + 192
3   xxxx                            xxxx::Looper::loop() + 60
4   xxxx                            xxxx::MessageThread::run() + 96
5   xxxx                            xxxx::Thread::runCallback(void*) + 108
6   libsystem_pthread.dylib         _pthread_body + 240
7   libsystem_pthread.dylib         _pthread_body + 0

Thread 33 crashed with ARM-64 Thread State:
  cpsr: 0x00000000a0000000     fp: 0x000000017102ae60     lr: 0x00000001018744c4     pc: 0x00000001018744cc 
    sp: 0x000000017102ae40     x0: 0xbadd0c44f948bead     x1: 0x0000000000000000    x10: 0x0000000000000000 
   x11: 0x0000000102a50178    x12: 0x000000000000002c    x13: 0x000000000000002c    x14: 0x0000000102a502d8 
   x15: 0x0000000000000000    x16: 0x0000000190f3fa1c    x17: 0x010bcb01010bcb00    x18: 0x0000000000000000 
   x19: 0x00000001744a2460     x2: 0x0000000000000008    x20: 0x00000001027da5b8    x21: 0x0000000000000000 
   x22: 0x0000000000000001    x23: 0x0000000000025903    x24: 0x0000000000000000    x25: 0x0000000000000000 
   x26: 0x0000000000000000    x27: 0x0000000000000000    x28: 0x0000000000000000    x29: 0x000000017102ae60 
    x3: 0x0000000190f4f2c0     x4: 0x0000000000000002     x5: 0x0000000000000008     x6: 0x0000000000000000 
    x7: 0x00000000010bcb01     x8: 0xbadd0c44f948beb5     x9: 0x0000000000000000 

从上面主线程的调用栈可以看出里面有执行exit函数,而exit是一个执行进程结束的函数,因此从调用栈来看其实这正是用户在主动杀掉我们的App应用进程时主线程会执行的逻辑。也就是说出现崩溃的时机就是在主动杀掉我们的应用的时刻发生的!

这真的是一个非常神奇的时刻,当我们主动杀掉应用时产生了崩溃,所以整个事件就出现了上面的场景:没有用户反馈异常、我们自身也很难复现出崩溃的场景(非连机运行时)。

问题复现

分析出原因后为了验证问题,通过不停的执行手动杀进程的测试,在一个偶然的机会下终于复现了问题:在主线程执行exit的时机,那个第三方库的工作线程的某处出现非法地址访问,而停止了执行:

系统出现崩溃时的调用指令

奔溃时的各寄存器的值

这个来之不易的崩溃信息起了非常大的作用,根据汇编代码按图索骥,并和对方进行交流定位到了对应的源代码。第三方库的一个线程是一个常驻线程,它会周期性并且高频的访问一个全局C++对象实例的数据,出现奔溃的原因就是这个全局C++对象的类的构造函数中从堆里面分配了一块内存,而当进程被终止这个过程中,这个全局对象被析构,析构函数中会将分配的堆内存进行回收。但是那个常驻线程因为此刻还没有被终止,它还像往常一样继续访问这个已经被析构了的全局对象的堆内存,从而导致了上面图中的内存地址访问非法的问题。下面就是问题发生的过程:

程序运行崩溃图

C++全局对象

可以肯定一点的就是那个第三方库由于对全局C++对象的使用不当而产生了问题。我们知道每个C++对象在创建时都会调用对应的构造函数,而对象销毁时则会调用对应的析构函数。构造和析构函数都是一段代码,对象的创建和销毁一般都是在某个函数中进行,这时候对象的构造/析构函数也是在那个调用者函数中执行,比如下面的代码:

class CA{
public:
    CA(){
       printf("CA::CA()");
     }

   void ~CA(){
     printf("CA::~CA()");
     }
};

CA b; //定义一个全局变量

int main()
{
     CA  a;   //函数内建立一个对象
     printf("hello");
     return 0;
}

系统在编译C++代码时会进行一些特定的处理(这里以C语言的形式来描述):

//定义结构体
struct CA{
};

//CA类名称被重新修饰了的构造函数
void __ZN2CAC1Ev(CA *  const this)
{
    printf("CA::CA()");
}

//CA类名称被重新修饰了的析构函数
void __ZN2CAD1Ev(CA * const this)
{
     printf("CA::~CA()");
}

//?? b对象的构造和析构又是在哪里被调用执行的呢?因为找不到执行的上下文。
struct CA b;

int main()
{
     struct CA a;
     __ZN2CAC1Ev(&a);   //局部对象在对象创建后调用构造函数
     printf("hello");
     __ZN2CAD1Ev(&a);   //这里调用析构函数
     return 0;
}

上面的源代码中b这个全局对象并不是在某个函数或者方法内部定义, 所以它并没有执行构造函数以及析构函数的上下文环境,那么是否创建一个全局对象时它的构造函数以及析构函数就无法被执行呢了?答案是否定的。只要任何一个C++类定义了构造函数或者析构函数,那么在对象创建时总是会调用构造函数,并且在对象销毁时会调用对应的析构函数。那么全局对象的构造函数和析构函数又是在什么时候被调用执行的呢?

+load方法

在一个Objective-C类中,可以定义一个+load方法,这个+load方法会在所有OC对象创建前被执行,同时也会在main函数调用前被执行。一般情况下我们会在类的+load方法中实现一些全局初始化的逻辑。OC类的方法也是要求一定的上下文环境下才能被执行,那么+load方法又是在什么时候被调用执行的呢?

全局构造/析构C函数

除了建立C++全局对象、实现OC类的+load方法来进行一些全局的初始化逻辑外,我们还可以定义带有特殊标志的C函数来实现main函数执行前以及main函数执行完毕后的处理逻辑。

//main函数执行前被执行的函数
void __attribute__ ((constructor)) beginfunc()
{
    printf("beginfunc\n");
}

//main函数执行完毕后被执行的函数
void __attribute__ ((destructor)) endfunc()
{
    printf("endfunc\n");
}

int main()
{
    printf("main\n");
    return 0;
}

//程序运行时分别输出
// beginfunc
//  main
// endfunc

上面的代码中可以看出,我们并没有显式的调用beginfunc和endfunc函数的情况下,函数依然被调用执行。那么这些函数又是如何被调用执行的呢?

main函数执行前发生了什么?

操作系统在启动一个程序时,内核会为程序创建一个进程空间,并且会为进程创建一个主线程,主线程会执行各种初始化操作,完成后才开始执行我们在程序中定义的main函数。也就是说main函数其实并不是主线程最开始执行的函数,在main函数执行前其实还发生了很多的事情:操作系统内核为可执行程序创建进程空间后,会分别将可执行程序文件以及可执行程序所依赖的动态库文件中的内容加载到进程的虚拟内存地址空间。可执行程序以及动态库文件中的内容是符合苹果操作系统ABI规则的mach-o格式的二进制数据,我们必须要将这些数据加载到内存中,对应的代码才能被执行以及变量才能被访问。我们称每个映射到内存空间中的可执行文件以及动态库文件的副本为image(映像)。注意此时只是将文件加载到内存中去并没有执行任何用户进程的代码,也没有调用库中的任意初始化函数。当所有image加载完毕后,内核会为进程创建一个主线程,并将可执行程序的image在内存中的地址做为参数压入用户态的堆栈中,把dyld.dylib库中的_dyld_start函数作为主线程执行的入口函数。这时候内核将控制权交给用户,系统由核心态转化为用户态,dyld库来实现进程在用户态下的可执行文件以及所有动态库的加载和初始化的逻辑。可见一个程序运行时可执行文件以及所有依赖的动态库其实是经历过了两次的加载过程:核心态下的image的加载,以及用户态下的二次加载以及初始化操作。 dyld库接管进程后,进程的主线程将从__dyld_start处开始所有用户态下代码的执行。

dyld.dylib库最新版本的开源源代码以及_dyld_start函数的代码可以从苹果的开源站点:https://opensource.apple.com/source/dyld/dyld-519.2.2/处获取到。你也可以打开URL:https://opensource.apple.com/source/ 来浏览所有苹果已经开源了的系统库。还有一点需要注意的就是开源的代码不一定是最新的代码,而且有可能和运行时的代码有差异,所以如果想了解真实的实现原理,最好是配合调试时的汇编代码来一起分析和阅读。

我们可以在dyldStartup.s中看到__dyld_start函数的各种平台下的实现,下面是一段arm64架构下的汇编代码,函数的定义大体如下:

#if __arm64__
    .data
    .align 3
__dso_static: 
    .quad   ___dso_handle

    .text
    .align 2
    .globl __dyld_start
__dyld_start:
    mov     x28, sp
    and     sp, x28, #~15       // force 16-byte alignment of stack
    mov x0, #0
    mov x1, #0
    stp x1, x0, [sp, #-16]! // make aligned terminating frame
    mov fp, sp          // set up fp to point to terminating frame
    sub sp, sp, #16             // make room for local variables
    ldr     x0, [x28]       // get app's mh into x0
    ldr     x1, [x28, #8]           // get argc into x1 (kernel passes 32-bit int argc as 64-bits on stack to keep alignment)
    add     x2, x28, #16        // get argv into x2
    adrp    x4,___dso_handle@page
    add     x4,x4,___dso_handle@pageoff // get dyld's mh in to x4
    adrp    x3,__dso_static@page
    ldr     x3,[x3,__dso_static@pageoff] // get unslid start of dyld
    sub     x3,x4,x3        // x3 now has slide of dyld
    mov x5,sp                   // x5 has &startGlue
    
    // call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)
    bl  __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm
    mov x16,x0                  // save entry point address in x16
    ldr     x1, [sp]
    cmp x1, #0
    b.ne    Lnew

    // LC_UNIXTHREAD way, clean up stack and jump to result
    add sp, x28, #8     // restore unaligned stack pointer without app mh
    br  x16         // jump to the program's entry point

    // LC_MAIN case, set up stack for call to main()
Lnew:   mov lr, x1          // simulate return address into _start in libdyld.dylib
    ldr     x0, [x28, #8]       // main param1 = argc
    add     x1, x28, #16        // main param2 = argv
    add x2, x1, x0, lsl #3  
    add x2, x2, #8      // main param3 = &env[0]
    mov x3, x2
Lapple: ldr x4, [x3]
    add x3, x3, #8
    cmp x4, #0
    b.ne    Lapple          // main param4 = apple
    br  x16     //调用main函数

#endif // __arm64__

将汇编代码翻译为高级语言的伪代码可以简单理解为:

void __dyld_start(const struct macho_header* appsMachHeader, int argc, char *[] argv)
{
       intptr_t slide = dyld的image在内存中的偏移量。
       const struct macho_header *dyldsMachHeader = dyld库的macho_header的地址。
       void (*startGlue)(int);   //胶水函数地址。

       //调用dyldbootstrap::start函数并返回用户的main函数的入口地址,并且最后一个参数返回一个胶水函数地址
       int (*main)(int argc, char*[] argv) = dyldbootstrap::start(appsMachHeader, argc, argv, slide, dyldsMachHeader, &startGlue);
       //执行用户定义的main函数  
       int ret = main(argc, argv);
       //执行胶水代码,内部其实就是调用了exit函数来结束进程
       startGlue(ret);
}

这里需要说明一下,上面的汇编代码并没有出现调用startGlue的地方,但是高级语言伪代码中又出现了,原因是最后的 br x16 指令只是一个简单的跳转到main函数的指令而非是函数调用指令,而dyldbootstrap::start函数的最后一个输出参数&startGlue其实是保存到栈顶sp中的,因此当main函数执行完毕并返回后就会把栈顶sp中保存的startGlue地址赋值给pc寄存器,从而实现了对startGlue函数的调用。那么dyldbootstrap::start最后一个参数返回并保存到startGlue中的又是一个什么函数地址呢?这个函数地址是libdyld.dylib(注意dyld.dylib和libdyld.dylib是两个不同的库)库中的一个静态函数start。它的实现很简单:

//注意这个函数是在libdyld.dylib中被定义,而非在dyld.dylib中定义。
   void start(int ret)
   {
        exit(ret);
   }

小知识点:当我们查看主线程的调用栈时发现调用栈的最底端的函数是libdyld库中的start函数,而非dyld库中的__dyld_start函数。同时当你切换到start函数的汇编代码处时,你会发现它并没有调用main函数的痕迹。原因就是在调用main函数之前,其实栈顶寄存器中的值保存的是start函数的地址,而非br x16的下条指令的地址 并且br指令只是跳转并不会执行压栈的动作,所以在查看主线程调用栈时您所看到的栈底函数就是start而非__dyld_start了。

__dyld_start函数的实现中可以看出它总共做了三件事:

  1. dyldbootstrap::start函数执行所有库的初始化,执行所有OC类的+load的方法,执行所有C++全局对象的构造函数,执行带有_attribute_(constructor)定义的C函数。
  2. main函数执行用户的主功能代码。
  3. startGlue函数执行exit退出程序,收回资源,结束进程。

在这里我不打算深入的去介绍dyldbootstrap::start函数的实现,详细情况大家可以去阅读源代码。

  • dyldbootstrap::start函数内部主要调用了dyld::_main函数。
  • dyld::main函数内部会根据依赖关系递归的为每个加载的动态库构建一个对应的ImageLoaderMachO对象,并添加到一个全局的数组sImageRoots中去,最后再调用dyld::initializeMainExecutable函数。
  • dyld::initializeMainExecutable函数内部的实现主要就是则遍历全局数组sImageRoots中的每个ImageLoaderMachO对象,并分别调用每个对象的runInitializers方法来执行动态库的各种初始化逻辑,最后再调用主程序的ImageLoaderMachOrunInitializers方法来执行主程序的各种初始化逻辑。
  • ImageLoaderMachO是一个C++类,类里面的runInitializers方法内部主要是调用类中的成员函数processInitializers来处理各种初始化逻辑。
  • processInitializers方法内部的实现主要调用动态库自身所依赖的其他动态库的ImageLoaderMachO对象的recursiveInitialization方法。
  • recursiveInitialization方法内部的主要实现是首先调用dyld::notifySingle函数来初始化所有objc相关的信息,比如执行这个库里面的所有类定义的+load的方法;然后再调用doInitialization方法来进一步执行初始化的动作。
  • doInitialization方法内部首先调用doImageInit来执行映像的初始化函数,也就是LC_ROUTINES_COMMAND中记录的函数(这个函数就是在构建动态库时的编译选项中指定的那个初始执行函数);然后再执行doModInitFunctions方法来执行所有库内的全局C++对象的构造函数,以及所有带有_attribute_(constructor)标志的C函数。

程序初始化时序图

自此,所有main函数之前的逻辑代码都已经被执行完毕了。可能你会问整个过程中还是没有看到关于C++全局对象构造函数是如何被执行的?关于这个问题,我们先暂停一下,而是首先来考察一下当一个进程被结束前系统到底做了什么。

进程结束时我们能做什么?

当我们双击home键然后滑动手势来终止某个进程或者手动调用exit函数时会结束进程的执行。当进程被结束时操作系统会回收进程所使用的资源,比如打开的文件、分配的内存等等。进程有可能会主动结束,也有可能被动的结束,因此操作系统提供了一系列注册进程结束回调函数的能力。在进程结束前会调用这些回调函数,因此我们可以通过进程结束回调函数来执行一些特定资源回收或者一些善后收尾的工作。注册进程结束回调函数的函数定义如下:

 #include <stdlib.h>

     //注册一个进程结束时会被调用的C函数,函数的格式为:void func()。 atexit如果注册成功返回0,否则返回负数。
     int  atexit(void (*func)(void));

    //注册一个进程结束时会被调用的block块,block块的格式为:^{}。 atexit_b如果注册成功返回0,否则返回负数。
     int  atexit_b(void (^block)(void));

    //注册一个进程结束时会被调用的C++无参数成员函数,__cxa_atexit并没有对外公开,而只是供编译器来使用,后面的C++对象的析构函数调用就要用到它!
    int __cxa_atexit(void (*func)(void *), void *arg, void *dso)

上面的三个函数分别用来注册进程结束时的标准C函数、block代码、C++函数。可以注册多个进程结束回调函数,并且系统是按照后注册先执行的后进先出的顺序来执行所有回调函数代码的。比如下面的代码:

void foo1()
{
    printf("foo1\n");
} 

void foo2()
{
    printf("foo2\n");
}

int main(int argc, char* [] argv)
{
      atexit(&foo1);
      atexit(&foo2);

     printf("main\n");
     return 0;
}


//当程序结束时显示的结果如下:
//main
//foo2
//foo1

从上面提供的三种注册方法,以及回调函数的执行顺序其实我们可以大体了解到系统是如何存储这些回调函数的,我们可以通过如下的数据结构清楚的看到:

//代码来自于:https://opensource.apple.com/source/Libc/Libc-1044.1.2/stdlib/FreeBSD/atexit.c.auto.html

//注册回调函数的类型。
#define ATEXIT_FN_EMPTY 0
#define ATEXIT_FN_STD   1
#define ATEXIT_FN_CXA   2
#define ATEXIT_FN_BLK   3

struct atexit {
    struct atexit *next;            /* next in list */
    int ind;                /* next index in this table */
    struct atexit_fn {
        int fn_type;            /* ATEXIT_? from above */
        union { //联合体中保存的是注册函数的函数地址
            void (*std_func)(void);
            void (*cxa_func)(void *);
            void (^block)(void);
        } fn_ptr;           /* function pointer */
        void *fn_arg;           /* argument for CXA callback */
        void *fn_dso;           /* shared module handle */
    } fns[ATEXIT_SIZE];         /* the table itself  ATEXIT_SIZE = 32*/
};

//系统定义的一个后进行先出的表头全局变量。
static struct atexit *__atexit;     /* points to head of LIFO stack */

struct atexit是一个链表和数组的结合体。用图形表示所有注册的函数的存储结构大体如下:

struct atexit的存储结构

从数据结构的定义以及atexit函数的描述和上面的图形我们应该可以很容易的去实现那三个注册函数。大家可以去阅读上面三个函数的实现,这里就不再列出了。

上面说了进程结束回调注册函数会在进程结束时被调用,而进程结束的函数是exit函数,因此可以很容易就想到这些回调函数的执行肯定是在exit函数内部调用的,事实也确实如此,通过汇编代码查看exit的实现如下:

libsystem_c.dylib`exit:
    0x1838a7088 <+0>:  stp    x20, x19, [sp, #-0x20]!
    0x1838a708c <+4>:  stp    x29, x30, [sp, #0x10]
    0x1838a7090 <+8>:  add    x29, sp, #0x10            ; =0x10 
    0x1838a7094 <+12>: mov    x19, x0
    0x1838a7098 <+16>: mov    x0, #0x0
    0x1838a709c <+20>: bl     0x1838fdc30               ; __cxa_finalize
    0x1838a70a0 <+24>: adrp   x8, 200782
    0x1838a70a4 <+28>: ldr    x8, [x8, #0xf20]
    0x1838a70a8 <+32>: cbz    x8, 0x1838a70b0           ; <+40>
    0x1838a70ac <+36>: blr    x8
    0x1838a70b0 <+40>: mov    x0, x19
    0x1838a70b4 <+44>: bl     0x1839702e4               ; __exit

上面的汇编翻译为高级语言伪代码大体如下:

  void exit(int ret)
  {
       __cxa_finalize(NULL);
       __exit(ret);
  }

__cxa_finalize函数字面上是用于结束所有C++对象,但实际上却负责调用所有注册了进程结束回调函数的代码。__exit函数内部则是实际的进程结束操作。 __cxa_finalize函数的源代码大体如下:

//代码来自于:https://opensource.apple.com/source/Libc/Libc-1044.1.2/stdlib/FreeBSD/atexit.c.auto.html

void __cxa_finalize(const void *dso)
{
    if (dso != NULL) {
        // Note: this should not happen as only dyld should be calling
        // this and dyld has switched to call __cxa_finalize_ranges directly.
        struct __cxa_range_t range;
        range.addr = dso;
        range.length = 1;
        __cxa_finalize_ranges(&range, 1);
    } else {
        __cxa_finalize_ranges(NULL, 0);
    }
}

//__cxa_finalize函数内部调用了__cxa_finalize_ranges函数,下面是这个函数的定义。
//这个函数和实际的函数有出入,并且为了让大家更加容易理解我把一些认为不必要的代码给删除了.

/*
 * Call handlers registered via __cxa_atexit/atexit that are in a
 * a range specified.
 * Note: rangeCount==0, means call all handlers.
 */
void
__cxa_finalize_ranges(const struct __cxa_range_t ranges[], unsigned int count)
{
    struct atexit *p;
    struct atexit_fn *fn;
    int n;

    for (p = __atexit; p; p = p->next) {
        for (n = p->ind; --n >= 0;) {
            fn = &p->fns[n];

            if (fn->fn_type == ATEXIT_FN_EMPTY) {
                continue; // already been called
            }

            // Clear the entry to indicate that this handler has been called.
            int fn_type = fn->fn_type;
            fn->fn_type = ATEXIT_FN_EMPTY;

            // Call the handler. 下面会根据不同的类型来执行不同的回调函数。
            if (fn_type == ATEXIT_FN_CXA) {
                fn->fn_ptr.cxa_func(fn->fn_arg);
            } else if (fn_type == ATEXIT_FN_STD) {
                fn->fn_ptr.std_func();
            } else if (fn_type == ATEXIT_FN_BLK) {
                fn->fn_ptr.block();
            }
        }
    }
}

三种进程结束回调函数中只有注册类型为C++的函数才带有一个参数,而其他两类函数都不带参数,这样的做的原因就是专门为调用全局C++对象的析构函数而服务的。

异常退出和abort函数

如果进程正常退出,最终都会执行exit函数。exit函数内部会调用atexit函数注册的所有回调,以便有时间进行一些资源的回收工作。而如果我们的应用出现了异常而导致进程结束则并不会激发进程结束回调函数的调用,系统异常出现时会产生中断,操作系统会接管异常,并对异常进行分析,最后将分析的结果再交给用户进程,并执行用户进程的std::terminate方法来终止进程。std::terminate方法内部会调用通过NSSetUncaughtExceptionHandler函数注册的未处理异常回调函数,来给我们机会处理产生崩溃的异常,处理完成最后再结束进程。

我们也可以调用abort函数来终止进程的执行,abort函数的内部并不会调用atexit函数注册的所有回调,也就是说通过abort函数来终止进程时,并不会给我们机会来进行任何资源的回收处理,而是简单的在内部简单粗暴的调用__pthread_kill方法来杀死主线程,并终止进程。

通过上面对main函数执行前所做的事情,以及进程结束前我们能做的事情的介绍,您是否又对程序的启动时和结束时所发生的一切有了更加深入的理解。可是这似乎离我要说的C++全局对象的构造和析构更加遥远了,当然也许你不会这么认为,因为通过我上面的介绍,你也许对C++全局对象的构造和析构的时机有了一些想法,这些都没有关系,这也是我下面将要详细介绍的。

再论C++的全局对象的构造和析构

就如本文的开始部分的一个例子,对于非全局的C++对象的构造和析构函数的调用总是在调用者的函数内部完成,这时候存在着明显的函数上下文的调用结构。但是当我们定义了一个C++全局对象时因为没有明显的可执行代码的上下文,所以我们无法很清楚的了解到全局对象的构造函数和析构函数的调用时机。为了实现全局对象的构造函数和析构函数的调用,此时我们就需要编译器来出马帮助我们做一些事情了! 我们知道其实C++编译器会在我们的源代码的基础上增加非常多的隐式代码,对于每个定义的全局对象也是如此的。

当我们在某个.mm文件或者.cpp文件中定义了全局变量时比如下面某个文件的代码:

//CA.h

class CA
{
  public:
      CA();
       void ~CA();
};

//CA.mm
#include "CA.h"

CA::CA()
{
    printf("CA::CA()\n");
}

void CA::~CA()
{
     printf("CA::~CA()\n");
}


//MyTest.cpp
#include "CA.h"

//假设这里定义了两个全局变量

CA  a;
CA  b;

当编译器在编译MyTest.cpp文件时发现其中定义了全局C++对象,那么除了会将全局对象变量保存在数据段(.data)外,还会为每个全局变量定义一个静态的全局变量初始化函数。其命名的规则如下:

     //按照全局对象在文件中定义的顺序,第一个没有数字序列,后面定义的则按数字序列递增。
     static  ___cxx_global_var_init.<数字序列>();

同时会以定义全局变量的文件名为标志定义一个如下的静态函数:

  static  void _GLOBAL__sub_I_<文件名>(int argc, char **argv, char** env, char **apple, void * programVars);

因此当编译上面的MyTest.cpp文件时,其实最真实的文件的内容是如下的:

//MyTest.cpp
#include "CA.h"


struct CA  a;
struct CA  b;

//全局对象a的初始化函数。
static void ___cxx_global_var_init()
{
      CA::CA(&a);
       //这代码很有意思,将CA类的析构函数的地址和a的地址通过__cxa_atexit函数进行注册,以便当进程结束时调用。
     __cxa_atexit(&CA::~CA(), &a, NULL);
}

//全局对象b的初始化函数。
static void ___cxx_global_var_init.1()
{
      CA::CA(&b);
     __cxa_atexit(&CA::~CA(), &b, NULL);
}

//本文件内的所有全局对象的初始化函数。
static void _GLOBAL__sub_I_MyTest.cpp(int argc, char **argv, char** env, char **apple, void * programVars)
{
      ___cxx_global_var_init();
      ___cxx_global_var_init.1();
}

从上面的代码中我们可以看出每个全局对象的初始化函数都其实是做了两件事:

  1. 调用对象类的构造函数。
  2. 通过__cxa_atexit函数来注册进程结束时的析构回调函数。

前面我曾经说过__cxa_atexit这个函数并没有对外暴露,而是留给编译器以及内部使用,这个函数接收三个参数:一个函数指针,一个对象指针,一个库指针。我们知道所有C++类定义的函数其实都是有一个隐藏的this参数的,析构函数也一样。还记得上面的__cxa_finalize_ranges函数内部是如何调用注册的C++函数的吗?

fn->fn_ptr.cxa_func(fn->fn_arg);

//因为我们注册时,注册的是类的析构函数的地址,以及全局对象本身:
__cxa_atexit(&CA::~CA(), &a, NULL);

//所以在最终进程终止时其实调用的是:
CA::~CA(&a) 方法,也就是调用的是全局对象的析构函数!!

可以看出系统采用了一个非常巧妙的方法,借助__cxa_atexit函数来实现全局对象析构函数的调用。那么问题又来了?对象的构造函数又是再哪里调用的呢?换句话说_GLOBAL__sub_I_MyTest.cpp()这个函数又是在哪里被调用的呢?

这就需要我们去了解一下mach-o文件的结构了,关于mach-o文件结构的介绍这就不再赘述,大家可以到网上去参考阅读相关的文章。

可以明确的就是当我们定义了全局对象并生成了_GLOBAL__sub_I_XXX系列的函数时或者当我们的代码中存在着attribute(constructor)声明的C函数时,系统在编译过程中为了能在进程启动时调用这些函数来初始化全局对象,会在数据段__DATA下建立一个名为__mod_init_func的section,并把所有需要在程序启动时需要执行的初始化的函数的地址保存到__mod_init_func这个section中。 我们可以从下面mach-o view这个工具中看到我们所有的注册的信息。

mach-o 文件结构

您是否还记得前面介绍的main函数执行前所执行的代码流程,在那些代码中,有一个名叫ImageLoaderMachO::doModInitFunctions的函数就是专门用来负责执行__DATA下的__mod_init_func中注册的所有函数的,我们可以来看看这段代码的实现:

void ImageLoaderMachO::doModInitFunctions(const LinkContext& context)
{
    if ( fHasInitializers ) {
        const uint32_t cmd_count = ((macho_header*)fMachOData)->ncmds;
        const struct load_command* const cmds = (struct load_command*)&fMachOData[sizeof(macho_header)];
        const struct load_command* cmd = cmds;
        for (uint32_t i = 0; i < cmd_count; ++i) {
            if ( cmd->cmd == LC_SEGMENT_COMMAND ) {
                const struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
                const struct macho_section* const sectionsStart = (struct macho_section*)((char*)seg + sizeof(struct macho_segment_command));
                const struct macho_section* const sectionsEnd = &sectionsStart[seg->nsects];
                for (const struct macho_section* sect=sectionsStart; sect < sectionsEnd; ++sect) {
                    const uint8_t type = sect->flags & SECTION_TYPE;
                    if ( type == S_MOD_INIT_FUNC_POINTERS ) {
                        Initializer* inits = (Initializer*)(sect->addr + fSlide);
                        const uint32_t count = sect->size / sizeof(uintptr_t);
                        for (uint32_t i=0; i < count; ++i) {
                            Initializer func = inits[i];
                            if ( context.verboseInit )
                                dyld::log("dyld: calling initializer function %p in %s\n", func, this->getPath());
                            //这里执行所有注册了的需要初始化就被执行的代码。
                            func(context.argc, context.argv, context.envp, context.apple, &context.programVars);
                        }
                    }
                }
                cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
            }
        }
    }
}

因此可以看出上面定义的__GLOBAL__sub_I_MyTest.cpp函数就是在doModInitFunctions函数内部被执行。

从上面的macho-view展示的图表来看,全局对象的构造函数以及声明了_attribute_(constructor)的C函数都会记录在_DATA_,_mod_init_func这个section中并且会在doModInitFunctions函数内部被执行。那么对于一个声明了_attribute_(destructor)的C函数呢?它又是如何在进程结束前被执行的呢?答案就在_DATA_,_mod_term_func这个section中,系统在编译时会将所有带_attribute_(destructor)声明的函数地址记录到这个section中。还记得上面程序启动初始化时会有一个环节调用dyld::initializeMainExecutable函数吗?

 //dyld.cpp中的代码
//为了能够看得更加清晰,这里面我会删除一些不必要的代码。
void initializeMainExecutable()
{
    //.....  其他逻辑。
    
    // register cxa_atexit() handler to run static terminators in all loaded images when this process exits
    if ( gLibSystemHelpers != NULL ) 
        (*gLibSystemHelpers->cxa_atexit)(&runAllStaticTerminators, NULL, NULL);


    //....  其他逻辑。
    
}

可以清楚的看到里面又是用了cxa_atexit方法来注册了一个进程结束时的回调函数runAllStaticTerminators。继续来跟踪函数的实现:

 //dyld.cpp中的代码

  static void runAllStaticTerminators(void* extra)
 {
    try {
        const size_t imageCount = sImageFilesNeedingTermination.size();
        for(size_t i=imageCount; i > 0; --i){
            ImageLoader* image = sImageFilesNeedingTermination[i-1];
            //这里遍历每个动态库并执行其中的doTermination方法。
            image->doTermination(gLinkContext);
        }
        sImageFilesNeedingTermination.clear();
        notifyBatch(dyld_image_state_terminated, false);
    }
    catch (const char* msg) {
        halt(msg);
    }
 }

继续来看ImageLoaderMachO::doTermination的内部实现:

//ImageLoaderMachO.cpp
void ImageLoaderMachO::doTermination(const LinkContext& context)
{
    if ( fHasTerminators ) {
        const uint32_t cmd_count = ((macho_header*)fMachOData)->ncmds;
        const struct load_command* const cmds = (struct load_command*)&fMachOData[sizeof(macho_header)];
        const struct load_command* cmd = cmds;
        for (uint32_t i = 0; i < cmd_count; ++i) {
            if ( cmd->cmd == LC_SEGMENT_COMMAND ) {
                const struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
                const struct macho_section* const sectionsStart = (struct macho_section*)((char*)seg + sizeof(struct macho_segment_command));
                const struct macho_section* const sectionsEnd = &sectionsStart[seg->nsects];
                for (const struct macho_section* sect=sectionsStart; sect < sectionsEnd; ++sect) {
                    const uint8_t type = sect->flags & SECTION_TYPE;
//type == S_MOD_TERM_FUNC_POINTERS的section就是上面说到的名为_mod_term_func的section.
                    if ( type == S_MOD_TERM_FUNC_POINTERS ) {
                        // <rdar://problem/23929217> Ensure section is within segment
                        if ( (sect->addr < seg->vmaddr) || (sect->addr+sect->size > seg->vmaddr+seg->vmsize) || (sect->addr+sect->size < sect->addr) )
                            dyld::throwf("DOF section has malformed address range for %s\n", this->getPath());
                        Terminator* terms = (Terminator*)(sect->addr + fSlide);
                        const size_t count = sect->size / sizeof(uintptr_t);
                        for (size_t j=count; j > 0; --j) {
                            Terminator func = terms[j-1];
                            // <rdar://problem/8543820&9228031> verify terminators are in image
                            if ( ! this->containsAddress((void*)func) ) {
                                dyld::throwf("termination function %p not in mapped image for %s\n", func, this->getPath());
                            }
                            if ( context.verboseInit )
                                dyld::log("dyld: calling termination function %p in %s\n", func, this->getPath());
                            func();  //这就是那些注册了的函数。
                        }
                    }
                }
            }
            cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
        }
    }
}

可见带有_attribute_(destructor)声明的函数,也是在系统初始化时通过了atexit的机制来实现进程结束时的调用的。

上面就是我要介绍的C++全局对象的构造函数和析构函数的调用以及实现的所有过程。我们从上面的章节中还可以了解到程序在启动和退出这个阶段所做的事情,以及我们所能做的事情。

最后还有一个问题需要解决:那就是我们知道所有的库的加载以及初始化操作都是通过dyld这个库来处理的。也就是一个进程在用户态最先运行的代码是dyld库中的代码,但是dyld库中本身也用到了一些全局的C++对象比如vector数组来存储所有的ImageLoaderMachO对象:

//https://opensource.apple.com/source/dyld/dyld-519.2.2/src/dyld.cpp.auto.html

 static std::vector<ImageLoader*>   sAllImages;
static std::vector<ImageLoader*>    sImageRoots;
static std::vector<ImageLoader*>    sImageFilesNeedingTermination;
static std::vector<RegisteredDOF>   sImageFilesNeedingDOFUnregistration;
static std::vector<ImageCallback>   sAddImageCallbacks;
static std::vector<ImageCallback>   sRemoveImageCallbacks;

dyld要加载所有其他的库并且调用每个库的初始化函数来构造库内定义的全局C++对象,那么dyld库本身所定义的全局C++对象的构造函数又是如何被初始化的呢?很显然我们不可能在doModInitFunctions中进行初始化操作,而是必须要将初始化全局对象的逻辑放到加载其他库之前做处理。要想回答这个问题我们可以再次考察一下dyldbootstrap::start函数的实现:

uintptr_t start(const struct macho_header* appsMachHeader, int argc, const char* argv[], 
             intptr_t slide, const struct macho_header* dyldsMachHeader,
             uintptr_t* startGlue)
{
 // if kernel had to slide dyld, we need to fix up load sensitive locations
 // we have to do this before using any global variables
 if ( slide != 0 ) {
     rebaseDyld(dyldsMachHeader, slide);
 }

 // allow dyld to use mach messaging
 mach_init();

 // kernel sets up env pointer to be just past end of agv array
 const char** envp = &argv[argc+1];
 
 // kernel sets up apple pointer to be just past end of envp array
 const char** apple = envp;
 while(*apple != NULL) { ++apple; }
 ++apple;

 // set up random value for stack canary
 __guard_setup(apple);

#if DYLD_INITIALIZER_SUPPORT
 // run all C++ initializers inside dyld
     //这句话是关键,dyld在初始化其他库之前会调用这个函数来调用库自身的所有全局C++对象的构造函数。
 runDyldInitializers(dyldsMachHeader, slide, argc, argv, envp, apple);
#endif

 // now that we are done bootstrapping dyld, call dyld's main
    //下面的代码是用来初始化可执行程序以及其所依赖的所有动态库的。
 uintptr_t appsSlide = slideOfMainExecutable(appsMachHeader);
 return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue);
}

start函数中在加载并初始化其他库之前有调用函数runDyldInitializers 下面的代码就是runDyldInitializers的实现,可以看出其他就是一个doModInitFunctions函数的简化版本的实现。

extern const Initializer  inits_start  __asm("section$start$__DATA$__mod_init_func");
extern const Initializer  inits_end    __asm("section$end$__DATA$__mod_init_func");

//
// For a regular executable, the crt code calls dyld to run the executables initializers.
// For a static executable, crt directly runs the initializers.
// dyld (should be static) but is a dynamic executable and needs this hack to run its own initializers.
// We pass argc, argv, etc in case libc.a uses those arguments
//
static void runDyldInitializers(const struct macho_header* mh, intptr_t slide, int argc, const char* argv[], const char* envp[], const char* apple[])
{
    for (const Initializer* p = &inits_start; p < &inits_end; ++p) {
        (*p)(argc, argv, envp, apple);
    }
}

小知识点:如果我们在编程时想要访问自身mach-o文件中的某个段下的某个section的数据结构时,我们就可以借助上面的汇编代码:__asm("section$start$__DATA$__mod_init_func"); 来获取section的开头和结束的地址区间。

一个疑惑的地方

整个例子中我们定义了一个C++的类,还定义了beginfunc, endfunc函数,建立了全局对象,以及一个main函数。我们可以通过nm命令来看可执行程序所有导出的符号表:

nm /Users/apple/Library/Developer/Xcode/DerivedData/cpptest1-bwxlgbiudmjsyadeqbnivxsezipu/Build/Products/Debug/cpptest1 
0000000100001c80 t __GLOBAL__sub_I_MyTest.cpp
0000000100001000 T __Z7endfuncv
0000000100000fe0 T __Z9beginfuncv
0000000100001020 t __ZN2CAC1Ev
0000000100001060 t __ZN2CAC2Ev
0000000100001040 t __ZN2CAD1Ev
0000000100001bc0 t __ZN2CAD2Ev
0000000100001c00 t ___cxx_global_var_init
0000000100001c40 t ___cxx_global_var_init.2
00000001000020f0 S _a
00000001000020f1 S _b
0000000100000fb0 T _main

上面的符号表我删除了一些其他的符号,在这里可以看到大写T标志的函数是非静态全局函数,小写t标志的函数是静态函数,S标志的符号是全局变量。可以看出程序为了支持C++的全局对象并初始化需要定义一些附加的函数来完成。这里有一个让人疑惑的地方就是:

0000000100001020 t __ZN2CAC1Ev
0000000100001060 t __ZN2CAC2Ev
0000000100001040 t __ZN2CAD1Ev
0000000100001bc0 t __ZN2CAD2Ev

这里面定义了2个CA类的构造函数和析构函数,差别只是序号的不同。根据汇编代码转化为高级语言伪代码如下:

//这个函数只是一个壳
static void __ZN2CAC1Ev(CA * const this)
{
      __ZN2CAC2Ev(this);
}

//这个是类构造函数的真实实现。
static void __ZN2CAC2Ev(CA *const this)
{
     printf("CA::CA()\n");
}

//这个函数只是一个壳
static void __ZN2CAD1Ev(CA * const this)
{
      __ZN2CAD2Ev(this);
}

//这个是类析构函数的真实实现。
static void __ZN2CAD2Ev(CA *const this)
{
     printf("CA::~CA()\n");
}

static void ___cxx_global_var_init()
{
      __ZN2CAC1Ev(&a);
     __cxa_atexit(& __ZN2CAD1Ev, &a, NULL);
}

上面的代码中可以看出,系统在编译时分别实现了2个构造函数和析构函数,而且标号为1的函数内部其实只是简单的调用了标号为2的真实函数的实现。所以当我们在调试或者查看崩溃日志时,如果问题出现在了全局对象的构造函数或者析构函数内部,我们看到的函数调用栈里面会出现两个相同的函数名字

全局对象的同名构造函数

这个实现机制非常令我迷惑!希望有高手为我答疑解惑。

后记:崩溃的修复方法

最后我想再来说说那个崩溃事件,本质的原因还是对于全局对象的使用不当导致,当进程将要被杀死时,主线程执行了exit方法的调用,exit方法内部析构了所有定义的全局C++对象,并且当主线程在执行在全局对象的析构函数时,如果我们的应用中还有其他的常驻线程还在运行时,此时那些线程还并没有销毁或者杀死,也就是一个进程的所有其他线程的终止处理其实是发生在exit函数调用结束后才会发生的,因此如果一个常驻线程一直在访问一个全局对象时就有可能存在着隐患以及不确定性。一个解决的方法就是在全局对象析构函数调用前先终止所有其他的线程;另外一个解决方案是对全局对象的访问进行加锁处理以及进行是否为空的判断处理。我们使用的那个第三方库所采用的一个解决方案是在程序启动后通过调用atexit函数来注册了一个进程结束回调函数,然后再那个回调函数里面终止了所有工作线程。因为按照atexit后进先出的规则,我们手动注册的进程结束回调函数要比C++析构的进程结束回调函数后添加,所以工作线程的终止逻辑回调函数就会比析构函数调用要早,从而可以防止问题的发生了。


欢迎大家访问我的github地址简书地址

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏前端真相

为什么数组下标是从0开始?

数组寻址——arr[i] = base_address + i * type_size(1)

33260
来自专栏丑胖侠

Zookeeper之开源客户端ZkClient

ZkClient是由Datameer的工程师开发的开源客户端,对Zookeeper的原生API进行了包装,实现了超时重连、Watcher反复注册等功能。 ZKC...

56050
来自专栏程序员宝库

PHP 中被忽略的性能优化利器:生成器

如果是做Python或者其他语言的小伙伴,对于生成器应该不陌生。但很多PHP开发者或许都不知道生成器这个功能,可能是因为生成器是PHP 5.5.0才引入的功能,...

32760
来自专栏编程

大神用Python编写虚拟机解释器

群内不定时分享干货,包括最新的python企业案例学习资料和零基础入门教程,欢迎初学和进阶中的小伙伴入群学习交流 ? 环境介绍 环境采用带桌面的Ubuntu L...

22880
来自专栏WindCoder

JVM-Java内存区域

JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,都有着各自的用途以及创建和销毁时间。包括以下几个如图所示的运行时数据区域:

27910
来自专栏owent

我们的Lua类绑定机制

最近一个人搞后台,框架底层+逻辑功能茫茫多,扛得比较辛苦,一直没抽出空来写点东西。

37210
来自专栏H2Cloud

FFLIB之FFLUA——C++嵌入Lua&扩展Lua利器

摘要: 在使用C++做服务器开发中,经常会使用到脚本技术,Lua是最优秀的嵌入式脚本之一。Lua的轻量、小巧、概念之简单,都使他变得越来越受欢迎。本人也使用过p...

75670
来自专栏Android 研究

Java虚拟机基础——3类加载机制

在这个框架图很容易大体上了解Java程序工作原理。首先当程序员写好.java文件后,需要先运行(假设该文件为demo.java)

13340
来自专栏IT可乐

JVM 运行时的内存分配

  首先我们必须要知道的是 Java 是跨平台的。而它之所以跨平台就是因为 JVM 不是跨平台的。JVM 建立了 Java 程序和操作系统之间的桥梁,JVM 是...

21980
来自专栏Java 技术分享

Struts2 转换器

13120

扫码关注云+社区

领取腾讯云代金券