CoffeeCatch 原理分析

coffeecatch 是一款可以用于crash捕捉的C++庫

它只有兩個文件,實現(xiàn)原理比較簡單。

coffeecatch.h 和coffeecatch.c

一、coffeecatch的基本使用

它的用法類似于try catch結(jié)構(gòu),將可能會發(fā)生crash的代碼 放到try{}塊中,發(fā)生crash后,在catch 塊中提取crash信息

extern "C"
JNIEXPORT void JNICALL
Java_com_example_testunwind2_MainActivity_go2CrashCoffeeCatch(JNIEnv *env, jobject instance) {
    
 COFFEE_TRY(){
     go2Crash4();
 }COFFEE_CATCH(){
        const char*const message = coffeecatch_get_message();
        ALOGD("feifei----- enter COFFEE_CATCH :%s",message);
 }COFFEE_END();

}

int getCrash2(){
    int i = 0;
    int j = 10/i;
}

void go2Crash3(){
    getCrash2();
}

void go2Crash4(){
    go2Crash3();
}

crash時 的堆棧輸出:

2020-03-06 13:50:04.163 15876-15876/com.example.testunwind2 D/feifei_native: feifei----- enter COFFEE_CATCH :signal 5 (Process breakpoint)  
[at /data/app/com.example.testunwind2-6-w04Pe1a6eBOR6xeZpnpw==/lib/arm64/libnative-lib.so:0x10770 (_Z9getCrash2v+0x18)] 
[at /data/app/com.example.testunwind2-6-w04Pe1a6eBOR6xeZpnpw==/lib/arm64/libnative-lib.so:0x14ad8] 
[at /data/app/com.example.testunwind2-6-w04Pe1a6eBOR6xeZpnpw==/lib/arm64/libnative-lib.so:0x1451c] 
[at /data/app/com.example.testunwind2-6-w04Pe1a6eBOR6xeZpnpw==/lib/arm64/libnative-lib.so:0x14264] 
[at [vdso]:0x7e66ce468c] 
[at /data/app/com.example.testunwind2-6-w04Pe1a6eBOR6xeZpnpw==/lib/arm64/libnative-lib.so:0x1076c (_Z9getCrash2v+0x14)]  
[at /data/app/com.example.testunwind2-6-w04Pe1a6eBOR6xeZpnpw==/lib/arm64/libnative-lib.so:0x1077c (_Z9go2Crash3v+0x8)]  
[at /data/app/com.example.testunwind2-6-w04Pe1a6eBOR6xeZpnpw==/lib/arm64/libnative-lib.so:0x10790 (_Z9go2Crash4v+0x8)] 
[at /data/app/com.example.testunwind2-6-w04Pe1a6eBOR6xeZpnpw==/lib/arm64/libnative-lib.so:0x10a94 (Java_com_example_testunwind2_MainActivity_go2CrashCof

二、原理分析

在coffeeCatch.h 中有這樣一段宏定義。

#define COFFEE_TRY()                                \
  if (coffeecatch_inside() || \
      (coffeecatch_setup() == 0 \
       && sigsetjmp(*coffeecatch_get_ctx(), 1) == 0))
#define COFFEE_CATCH() else
#define COFFEE_END() coffeecatch_cleanup()
/** End of internal functions & definitions. **/

#ifdef __cplusplus
}
#endif

#endif


上面的try catch塊實際是執(zhí)行了如下操作:

  if (coffeecatch_inside() || \
      (coffeecatch_setup() == 0 \
       && sigsetjmp(*coffeecatch_get_ctx(), 1) == 0)){
        go2Crash4();
    }else{
        const char*const message = coffeecatch_get_message();
        ALOGD("feifei----- enter COFFEE_CATCH :%s",message);
    }coffeecatch_cleanup();

coffeecatch_inside()的作用主要是判斷是否已經(jīng)初始化了coffeecatch的環(huán)境,第一次運行返回false,我們暫且不看。

1、coffeecatch_setup

首先看看coffeecatch_setup做了什么。
從注釋中可以看到 主要是初始化了一個crash handler。為context 做了一個標記,表示已經(jīng)調(diào)用過coffeecatch_handler_setup() 。

/**
 * Calls coffeecatch_handler_setup(1) to setup a crash handler, mark the
 * context as valid, and return 0 upon success.
 */
int coffeecatch_setup() {
  if (coffeecatch_handler_setup(1) == 0) {
    native_code_handler_struct *const t = coffeecatch_get();
    assert(t != NULL);
    assert(t->reenter == 0);
    t->reenter = 1;
    t->ctx_is_set = 1;
    return 0;
  } else {
    return -1;
  }
}

我們繼續(xù)看 coffeecatch_handler_setup做了什么事情。

/**
 * Acquire the crash handler for the current thread.
 * The coffeecatch_handler_cleanup() must be called to release allocated
 * resources.
 **/
static int coffeecatch_handler_setup(int setup_thread) {
  int code;

  ALOGD("coffeecatch_handler_setup\n");

  /* Initialize globals. */
  if (pthread_mutex_lock(&native_code_g.mutex) != 0) {
    return -1;
  }
  ALOGD("coffeecatch_handler_setup_global\n");
  //(1) 初始化信號處理函數(shù)
  code = coffeecatch_handler_setup_global();
  if (pthread_mutex_unlock(&native_code_g.mutex) != 0) {
    return -1;
  }

  /* Global initialization failed. */
  if (code != 0) {
    return -1;
  }

  /* Initialize locals. */
  if (setup_thread && coffeecatch_get() == NULL) {
      //(2)初始化了native_code_handler_struct 對象。
    native_code_handler_struct *const t =
      coffeecatch_native_code_handler_struct_init();

    if (t == NULL) {
      return -1;
    }

    ALOGD("installing thread alternative stack  2222 \n");

    //(3)將native_code_handler_struct 指針保存到線程獨享變量中。
    /* Set thread-specific value. */
    if (pthread_setspecific(native_code_thread, t) != 0) {
      coffeecatch_native_code_handler_struct_free(t);
      return -1;
    }

    ALOGD("installed thread alternative stack\n");
  }

  /* OK. */
  return 0;
}

它主要做了兩件事情:

(1)coffeecatch_handler_setup_global() 注冊信號量和信號處理函數(shù)

/* Initialize globals. */
  if (pthread_mutex_lock(&native_code_g.mutex) != 0) {
    return -1;
  }
  ALOGD("coffeecatch_handler_setup_global\n");
  //(1) 初始化信號處理函數(shù)
  code = coffeecatch_handler_setup_global();
  if (pthread_mutex_unlock(&native_code_g.mutex) != 0) {
    return -1;
  }

(2)創(chuàng)建了native_code_handler_struct結(jié)構(gòu)體,然后將其保存在了線程獨有Key中

coffeecatch_native_code_handler_struct_init 初始化native_code_handler_struct結(jié)構(gòu)體
pthread_setspecific(native_code_thread, t) != 0

2、我們繼續(xù)看信號量是如何被處理的

/* Internal globals initialization. */
static int coffeecatch_handler_setup_global(void) {


  if (native_code_g.initialized++ == 0) {//保證是首次調(diào)用
    size_t i;
    //(1)聲明兩個sigaction 用于處理信號事件,sa_abort用戶處理abort信號,sa_pass用于處理其他信號。
    struct sigaction sa_abort;
    struct sigaction sa_pass;

    ALOGD("installing global signal handlers\n");

    /* Setup handler structure. */
    memset(&sa_abort, 0, sizeof(sa_abort));
    sigemptyset(&sa_abort.sa_mask);
    sa_abort.sa_sigaction = coffeecatch_signal_abort;
    sa_abort.sa_flags = SA_SIGINFO | SA_ONSTACK;
    //(2)注意此處的flags參數(shù): SA_SIGINFO 指定使用sa_sigaction (而非sa_handler)作為信號處理函數(shù),SA_ONSTACK 表示開啟備用棧,信號處理函數(shù)在備用棧上運行

    memset(&sa_pass, 0, sizeof(sa_pass));
    sigemptyset(&sa_pass.sa_mask);
    sa_pass.sa_sigaction = coffeecatch_signal_pass;
    sa_pass.sa_flags = SA_SIGINFO | SA_ONSTACK;

    /* Allocate */ // (3)native_code_g.sa_old 用于保存 該信號之前安裝的信號處理函數(shù).
    native_code_g.sa_old = calloc(sizeof(struct sigaction), SIG_NUMBER_MAX);
    if (native_code_g.sa_old == NULL) {
      return -1;
    }

    /**
     *  SIGABRT, SIGILL, SIGTRAP, SIGBUS, SIGFPE, SIGSEGV, SIGSTKFLT 總共支持了7種信號量,sigabrt使用coffeecatch_signal_abort函數(shù)來處理,其他使用coffeecatch_signal_pass來處理。
     */
    /* Setup signal handlers for SIGABRT (Java calls abort()) and others. **/
    for (i = 0; native_sig_catch[i] != 0; i++) {
      const int sig = native_sig_catch[i];
      const struct sigaction * const action =
          sig == SIGABRT ? &sa_abort : &sa_pass;
      assert(sig < SIG_NUMBER_MAX);

      ALOGD("coffeecatch_handler_setup_global - install signal:%d",sig);
      //(4)調(diào)用sigaction函數(shù) 為信號量安裝處理函數(shù)
      if (sigaction(sig, action, &native_code_g.sa_old[sig]) != 0) {
        return -1;
      }
    }

    //(5)初始化一個線程變量
    /* Initialize thread var. */
    if (pthread_key_create(&native_code_thread, NULL) != 0) {
      return -1;
    }

    ALOGD("install signal handler success\n");
  }

  /* OK. */
  return 0;
}

函數(shù)運行在用戶態(tài),當(dāng)遇到系統(tǒng)調(diào)用、中斷或是異常(包括crash)的情況時,內(nèi)核會接收到對應(yīng)的信號,然將其放到對應(yīng)進程的信號隊列中,由對應(yīng)的進程的信號處理函數(shù)來處理該信號。native crash的捕捉也就是在信號處理函數(shù)完成的。
信號機制和Android natvie crash捕捉

腦補sigaction()函數(shù)和sigaction結(jié)構(gòu)體

#include <signal.h>
     
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);


struct sigaction {
    void (*sa_handler)(int); //默認信號處理函數(shù)
    void (*sa_sigaction)(int, siginfo_t *, void *); //可以發(fā)送附加信息的信號處理函數(shù),sa_flag設(shè)置了SA_SIGINFO使用其處理
    sigset_t sa_mask;//在此信號集中的信號在信號處理函數(shù)運行中會被屏蔽,函數(shù)處理完后才處理該信號
    int sa_flags;//可設(shè)參數(shù)很多
    void (*sa_restorer)(void);//在man手冊里才發(fā)現(xiàn)有這玩意,還不知道啥用
};


coffeecatch中 共處理的7種信號量:
SIGABRT, SIGILL, SIGTRAP, SIGBUS, SIGFPE, SIGSEGV, SIGSTKFLT

聲明了兩個sigaction結(jié)構(gòu)體,sa_abort和sa_pass

sigabrt使用coffeecatch_signal_abort函數(shù)來處理,其他信號量使用coffeecatch_signal_pass來處理。

  /* Setup handler structure. */
    memset(&sa_abort, 0, sizeof(sa_abort));
    sigemptyset(&sa_abort.sa_mask);
    sa_abort.sa_sigaction = coffeecatch_signal_abort; //指定信號處理函數(shù)
    sa_abort.sa_flags = SA_SIGINFO | SA_ONSTACK;
    //(2)注意此處的flags參數(shù): SA_SIGINFO 指定使用sa_sigaction (而非sa_handler)作為信號處理函數(shù),SA_ONSTACK 表示開啟備用棧,信號處理函數(shù)在備用棧上運行

  • sa_abort.sa_sigaction 捕捉到信號量之后的信號處理函數(shù)
  • sa_abort.sa_flags SA_SIGINFO 指定使用sa_sigaction (而非sa_handler)作為信號處理函數(shù)
  • sa_abort.sa_flags ,SA_ONSTACK 表示開啟備用棧,信號處理函數(shù)在備用棧上運行,而不是運行在系統(tǒng)原有的棧結(jié)構(gòu)上(因為發(fā)生crash時也許系統(tǒng)的棧已經(jīng)溢出,如果繼續(xù)再系統(tǒng)棧上運行可能會引起二次崩潰。

pthread_key_create 又是做了什么呢?

線程私有變量腦補

C 語言中有一種線程獨有數(shù)據(jù)的方式,即只有當(dāng)前線程中可以訪問當(dāng)前線程聲明的變量,其他線程訪問該變量得到的是一個新的值。就相當(dāng)于JAVA 中的ThreadLocal線程獨有變量。


int pthread_key_create(pthread_key_t *key, void (*destructor)(void*))
第一個參數(shù)為指向一個鍵值的指針,第二個參數(shù)指明了一個destructor函數(shù),如果這個參數(shù)不為空,那么當(dāng)每個線程結(jié)束時,系統(tǒng)將調(diào)用這個函數(shù)來釋放綁定在這個鍵上的內(nèi)存塊。


int pthread_setspecific(pthread_key_t key,const void *pointer));
void *pthread_getspecific(pthread_key_t key);
set是把一個變量的地址告訴key,一般放在變量定義之后,get會把這個地址讀出來,然后你自己轉(zhuǎn)義成相應(yīng)的類型再去操作,注意變量的有效期。


一般的處理流程如下:
1、創(chuàng)建一個鍵

2、為一個鍵設(shè)置線程私有數(shù)據(jù)

3、從一個鍵讀取線程私有數(shù)據(jù)void *pthread_getspecific(pthread_key_t key);

4、線程退出(退出時,會調(diào)用destructor釋放分配的緩存,參數(shù)是key所關(guān)聯(lián)的數(shù)據(jù))

5、刪除一個鍵

由此可知 pthread_key_create 創(chuàng)建了一個key native_code_thread 用于維護線程私有變量

pthread_key_create(&native_code_thread, NULL)

3、 coffeecatch_native_code_handler_struct_init

我們來看coffeecatch_native_code_handler_struct_init中到底做了什么事情:

/**
 * Create a native_code_handler_struct structure.
 **/
static native_code_handler_struct* coffeecatch_native_code_handler_struct_init(void) {
  stack_t stack;
  //構(gòu)造(1)native_code_handler_struct 結(jié)構(gòu)體
  native_code_handler_struct *const t =
    calloc(sizeof(native_code_handler_struct), 1);

  if (t == NULL) {
    return NULL;
  }

  ALOGD("installing thread alternative stack 111 \n");

  /* Initialize structure *///(2)賦值buffersize,申請buffer
  t->stack_buffer_size = SIG_STACK_BUFFER_SIZE;
  t->stack_buffer = malloc(t->stack_buffer_size);
  if (t->stack_buffer == NULL) {
    coffeecatch_native_code_handler_struct_free(t);
    return NULL;
  }

  //(2)初始化一個備用棧
  /* Setup alternative stack. */
  memset(&stack, 0, sizeof(stack));
  stack.ss_sp = t->stack_buffer;
  stack.ss_size = t->stack_buffer_size;
  stack.ss_flags = 0;

#ifndef NO_USE_SIGALTSTACK
  /* Install alternative stack. This is thread-safe */
  ALOGD("sigaltstack was called!");
  //(3)安裝上面定義的備用棧(告訴系統(tǒng)此備用棧的存在),如果之前存在備用棧,則將備用棧保存在t->stack_old
  if (sigaltstack(&stack, &t->stack_old) != 0) {
#ifndef USE_SILENT_SIGALTSTACK
    coffeecatch_native_code_handler_struct_free(t);
    return NULL;
#endif
  }
#endif

  return t;
}

(1)首先構(gòu)造了native_code_handler_struct結(jié)構(gòu)體
(2)申請了一個buffer內(nèi)存 t->stack_buffer
(3)創(chuàng)建了一個棧結(jié)構(gòu) stack_t 注冊到了系統(tǒng)中。當(dāng)sigaction.flags 指定了SA_ONSTACK 標志時,才會使用這個備用棧

后面通過pthread_setspecific將native_code_handler_struct結(jié)構(gòu)體保存在了線程獨有的native_code_thread中。以供后面提取。

pthread_setspecific(native_code_thread, t) != 0)

4、 我們繼續(xù)看發(fā)生cash時 coffeecatch_signal_pass函數(shù)中是如何處理信號的

/* Internal signal pass-through. Allows to peek the "real" crash before
 * calling the Java handler. Remember than Java needs many of the signals
 * (for the JIT, for test-free NullPointerException handling, etc.)
 * We record the siginfo_t context in this function each time it is being
 * called, to be able to know what error caused an issue.
 */
static void coffeecatch_signal_pass(const int code, siginfo_t *const si,
                                    void *const sc) {
  native_code_handler_struct *t;

  /* Ensure we do not deadlock. Default of ALRM is to die.
   * (signal() and alarm() are signal-safe) */
  
  //(1)首先將發(fā)生crash的信號 恢復(fù)成默認的行為
  signal(code, SIG_DFL);
  ALOGD("signal(%d)",code);
  
  //(2)創(chuàng)建一個定時器
  coffeecatch_start_alarm();

  /* Available context ? */
  //(3)提取出存儲在線程中的上下文結(jié)構(gòu)體:native_code_handler_struct
  t = coffeecatch_get();
  ALOGD("coffeecatch_get():%d",t != NULL);
  if (t != NULL) {
    /* An alarm() call was triggered. */
//    ALOGD("coffeecatch_mark_alarm()");
    coffeecatch_mark_alarm(t);

    /* Take note of the signal. */
    coffeecatch_copy_context(t, code, si, sc);

    /* Back to the future. */
    coffeecatch_try_jump_userland(t, code, si, sc);
    
  }

  /* Nope. (abort() is signal-safe) */
  ALOGD("calling abort()\n");
  signal(SIGABRT, SIG_DFL);
  abort();
}

(1)signal(code, SIG_DFL);

將發(fā)生crash的信號量 恢復(fù)為默認處理行為

C 庫函數(shù) void (*signal(int sig, void (*func)(int)))(int) 設(shè)置一個函數(shù)來處理信號,即帶有 sig 參數(shù)的信號處理程序
void (*signal(int sig, void (*func)(int)))(int)

sig -- 在信號處理程序中作為變量使用的信號碼。
func -- 一個指向函數(shù)的指針。它可以是一個由程序定義的函數(shù),也可以是下面預(yù)定義函數(shù)之一:
SIG_DFL - 默認的信號處理程序。
SIG_IGN - 忽視信號。
(2) coffeecatch_start_alarm();

創(chuàng)建一個定時器 30秒后 終止當(dāng)前進程

static void coffeecatch_start_alarm(void) {
  /* Ensure we do not deadlock. Default of ALRM is to die.
   * (signal() and alarm() are signal-safe) */
  ALOGD("coffeecatch_start_alarm");
  (void) alarm(30);
}

alarm函數(shù)腦補:

alarm也稱為鬧鐘函數(shù),它可以在進程中設(shè)置一個定時器,當(dāng)定時器指定的時間到時,它向進程發(fā)送SIGALRM信號??梢栽O(shè)置忽略或者不捕獲此信號,如果采用默認方式其動作是終止調(diào)用該alarm函數(shù)的進程。
(3) t = coffeecatch_get();

取出當(dāng)前線程中的native_code_handler_struct結(jié)構(gòu)體

/* Return the thread-specific native_code_handler_struct structure, or
 * @c null if no such structure is available. */
static native_code_handler_struct* coffeecatch_get() {
  return (native_code_handler_struct*)
      pthread_getspecific(native_code_thread);
}
(4) coffeecatch_mark_alarm(t);

僅僅是做一個標記,表示已經(jīng)開啟了定時器

static void coffeecatch_mark_alarm(native_code_handler_struct *const t) {
  t->alarm = 1;
}

(5)coffeecatch_copy_context(t, code, si, sc)

提取crash相關(guān)的信息保存在native_code_handler_struct結(jié)構(gòu)體中。
提取的信息包括:

  • signal number
  • signal code
  • 發(fā)生crash的 pc
  • crash的堆棧信息
(6)coffeecatch_try_jump_userland(t, code, si, sc);

做了兩件事情:

  • coffeecatch_revert_alternate_stack();指定不使用備用棧。

  • siglongjmp,跳轉(zhuǎn)回發(fā)生crash的pc地址

/* Try to jump to userland. */
static void coffeecatch_try_jump_userland(native_code_handler_struct*
                                                 const t,
                                                 const int code,
                                                 siginfo_t *const si,
                                                 void * const sc) {

  /* Valid context ? */
  if (t != NULL && t->ctx_is_set) {
    ALOGD("calling siglongjmp-----1\n");

    /* Invalidate the context */
    t->ctx_is_set = 0;

    //(1)恢復(fù)備用棧
    /* We need to revert the alternate stack before jumping. */
    coffeecatch_revert_alternate_stack();
    //(2)跳轉(zhuǎn)回crash發(fā)生時的pc地址
    siglongjmp(t->ctx, code);
  }
}

siglongjmp和sigsetjmp腦補

#include <setjmp.h>

int sigsetjmp(sigjmp_buf env, int savemask);

函數(shù)說明:sigsetjmp()會保存目前堆棧環(huán)境,然后將目前的地址作一個記號,而在程序其他地方調(diào)用siglongjmp()時便會直接跳到這個記號位置,然后還原堆棧,繼續(xù)程序的執(zhí)行。

參數(shù)env為用來保存目前堆棧環(huán)境,一般聲明為全局變量
參數(shù)savesigs若為非0則代表擱置的信號集合也會一塊保存
當(dāng)sigsetjmp()返回0時代表已經(jīng)做好記號上,若返回非0則代表由siglongjmp()跳轉(zhuǎn)回來。


void siglongjmp(sigjmp_buf env, int val);

理解此處需要結(jié)果最初sigsetjmp()的調(diào)用

  if (coffeecatch_inside() || \
      (coffeecatch_setup() == 0 \
       && sigsetjmp(*coffeecatch_get_ctx(), 1) == 0)){
        go2Crash4();
    }else{
        const char*const message = coffeecatch_get_message();
        ALOGD("feifei----- enter COFFEE_CATCH :%s",message);
    }coffeecatch_cleanup();

在try catch塊中

  • 調(diào)用sigsetjmp,保存了當(dāng)前的堆棧信息,并做了標記。返回值為0,代表正確做了標記。
  • 發(fā)生crash,處理完成之后,調(diào)用了siglongjmp 跳轉(zhuǎn)回了最初sigsetjmp()的地方。此時返回值非0,因此執(zhí)行了else分支,在else分支中提取crash的信息:coffeecatch_get_message()
(7)接下來我們再看下coffeecatch_inside()做了哪些事情:

實際上是判斷是否在當(dāng)前線程初始化了coffeecatch環(huán)境

即是否在當(dāng)前線程執(zhí)行過coffeecatch_handler_setup(1)方法
int coffeecatch_inside() {
  native_code_handler_struct *const t = coffeecatch_get();
  if (t != NULL && t->reenter > 0) {
    t->reenter++;
    ALOGD("coffeecatch_inside return 1");
    return 1;
  }
  ALOGD("coffeecatch_inside return 0");
  return 0;
}

至此 CoffeeCatch的主要調(diào)用流程已經(jīng)完成

三、測試代碼Demo

https://github.com/feifei-123/TestUnwind

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 一、信號機制 函數(shù)運行在用戶態(tài),當(dāng)遇到系統(tǒng)調(diào)用、中斷或是異常的情況時,程序會進入內(nèi)核態(tài)。信號涉及到了這兩種狀態(tài)之間...
    feifei_fly閱讀 8,699評論 1 14
  • 信號處理函數(shù) sigaction的用法 int sigaction ( int signo, const stru...
    小葉大孟閱讀 2,527評論 0 0
  • 信號本質(zhì) 軟中斷信號(signal,又簡稱為信號)用來通知進程發(fā)生了異步事件。在軟件層次上是對中斷機制的一種模擬,...
    飛揚code閱讀 841評論 0 2
  • 原文地址 如何去衡量一款應(yīng)用的質(zhì)量好壞?為了回答這一問題,APM這一目的性極強的工具向開發(fā)順應(yīng)而生。最早的APM開...
    sindri的小巢閱讀 4,991評論 2 43
  • 2019-12-18 【日精進打卡第 634 天 【知~學(xué)習(xí)】 《六項精進》大綱 4 遍共 2392 遍 《大學(xué)》...
    隨心_892b閱讀 242評論 0 0

友情鏈接更多精彩內(nèi)容