Android遠(yuǎn)程調(diào)試的探索與實(shí)現(xiàn)

文章來(lái)源:美團(tuán)點(diǎn)評(píng)技術(shù)團(tuán)隊(duì)

作為移動(dòng)開發(fā)者,最頭疼的莫過(guò)于遇到產(chǎn)品上線以后出現(xiàn)了bug,但是本地開發(fā)環(huán)境又無(wú)法復(fù)現(xiàn)的情況。常見(jiàn)的調(diào)查線上棘手問(wèn)題方式大概如下:

方法優(yōu)點(diǎn)缺點(diǎn)

聯(lián)系用戶安裝已添加測(cè)試日志的APK方便定位問(wèn)題需要用戶積極配合,如果日志添加不全面還需要反復(fù)重試

提前在一些關(guān)鍵路徑設(shè)置埋點(diǎn),在用戶出現(xiàn)問(wèn)題以后上報(bào)日志進(jìn)而定位問(wèn)題不需要用戶深度配合關(guān)鍵路徑不好預(yù)測(cè)

以上兩種方法在之前調(diào)查線上問(wèn)題時(shí)都有使用,但因?yàn)槎叨加忻黠@的缺點(diǎn),所以效果不是特別理想。

能否開發(fā)一種工具,既不需要用戶深度配合也不需要提前埋點(diǎn)就能方便、快速地定位線上問(wèn)題?

作為程序員,查bug一般使用下面幾種方式:閱讀源碼、記錄日志或調(diào)試程序。一般本地?zé)o法復(fù)現(xiàn)的問(wèn)題通過(guò)閱讀源碼很難找到原因,而且大多數(shù)情況都和用戶本地環(huán)境有關(guān)。記錄日志的缺點(diǎn)之前講過(guò)了,同樣不予考慮,那能否像調(diào)試本地程序一樣調(diào)試已經(jīng)發(fā)布出去的程序呢?我們對(duì)此做了一些嘗試和探索。

調(diào)試原理

先看下調(diào)試原理,這里以Java為例(通過(guò)IDE調(diào)試Android程序也基于此原理)。Java(Android)程序都是運(yùn)行在Java(Dalvik\ART)虛擬機(jī)上的,要調(diào)試Java程序,就需要向Java虛擬機(jī)請(qǐng)求當(dāng)前程序運(yùn)行狀態(tài),并對(duì)虛擬機(jī)發(fā)送一定的指令,設(shè)置一些回調(diào)等等。Java的調(diào)試體系,就是虛擬機(jī)的一套用于調(diào)試的工具和接口。Java SE從1.2.2版本以后推出了JPDA框架(Java Platform Debugger Architecture,Java平臺(tái)調(diào)試體系結(jié)構(gòu))。

JPDA框架

JPDA定義了一套獨(dú)立且完整的調(diào)試體系,它由三個(gè)相對(duì)獨(dú)立的模塊組成,分別為:

JVM TI:Java虛擬機(jī)工具接口(被調(diào)試者)。

JDWP:Java Debug Wire Protocol,Java調(diào)試協(xié)議(通道)。

JDI:Java Debug Interface,Java調(diào)試接口(調(diào)試者)。

這三個(gè)模塊把調(diào)試過(guò)程分解成了三個(gè)自然的概念:

被調(diào)試者運(yùn)行在我們想要調(diào)試的虛擬機(jī)上,它可以通過(guò)JVM TI這個(gè)標(biāo)準(zhǔn)接口監(jiān)控當(dāng)前虛擬機(jī)的信息。

調(diào)試者定義了用戶可以使用的調(diào)試接口,用戶可以通過(guò)這些接口對(duì)被調(diào)試虛擬機(jī)發(fā)送調(diào)試命令,同時(shí)顯示調(diào)試結(jié)果。

在調(diào)試者和被調(diào)試者之間,通過(guò)JDWP傳輸層傳輸消息。

整個(gè)過(guò)程如下:

Components? ? ? ? ? ? ? ? ? ? ? ? Debugger Interfaces

/? ? |--------------|

/? ? |? ? VM? ? ? |

debuggee ----(? ? ? |--------------|? <------- JVM TI - Java VM Tool Interface

\? ? |? back-end? |

\? ? |--------------|

/? ? ? ? ? |

comm channel -(? ? ? ? ? ? |? <--------------- JDWP - Java Debug Wire Protocol

\? ? ? ? ? |

|--------------|

| front-end? ? |

|--------------|? <------- JDI - Java Debug Interface

|? ? ? UI? ? ? |

|--------------|

下面重點(diǎn)介紹一下JDWP協(xié)議。

JDWP協(xié)議

JDWP協(xié)議是用于調(diào)試器與目標(biāo)虛擬機(jī)之間進(jìn)行調(diào)試交互的通信協(xié)議,它的通信會(huì)話主要包含兩類數(shù)據(jù)包:

Command Packet:命令包。調(diào)試器發(fā)送給虛擬機(jī)Command,用于獲取程序狀態(tài)或控制程序執(zhí)行;虛擬機(jī)發(fā)送Command給調(diào)試器,用于通知事件觸發(fā)消息。

Reply Packet:回復(fù)包,虛擬機(jī)發(fā)送給調(diào)試者回復(fù)命令的請(qǐng)求或者執(zhí)行結(jié)果。

JDWP的數(shù)據(jù)包主要包含包頭和數(shù)據(jù)兩部分,包頭字段含義如下:

數(shù)據(jù)包部分JDWP協(xié)議按照功能分為18組命令(以Java 7為例),包含了虛擬機(jī)、引用類型、對(duì)象、線程、方法、堆棧、事件等不同類型的操作命令。

Dalvik虛擬機(jī)/ART虛擬機(jī)對(duì)JDWP協(xié)議的支持并不完整,但是大部分關(guān)鍵命令都是支持的,具體信息可以參考Dalvik-JDWPART-JDWP中所支持的消息。

Android調(diào)試原理

Android調(diào)試模型可以看作JPDA框架的具體實(shí)現(xiàn)。其中變化比較大的一個(gè)是JVM TI適配了Android設(shè)備特有的Dalvik虛擬機(jī)/ART虛擬機(jī),另一個(gè)是JDWP的實(shí)現(xiàn)支持ADB和Socket兩種通信方式(ADB全稱為Android Debug Bridge,是Android系統(tǒng)的一個(gè)很重要的調(diào)試工具)。整體的調(diào)試模型如下:

____________________________________? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? |? ? ? ? ? ? |ADBServer(host)|? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? | Debugger <---> LocalSocket <----> RemoteSocket? |? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? |? ? ? ? ? ? |___________________________||_______|? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? ? ? ? ? ? ? ? ? ? ? ? ? Transport ||(TCPforemulator - USBfordevice)||? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? ? ? ? ___________________________||_______? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? |? ? ? ? ? ? |ADBD(device)||? ? ? |? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? |Android-VM? |? ? ? ? ? ? ? ? ? ? ? ? ? ||? ? ? |JDWP-thread <====> LocalSocket <-> RemoteSocket? |? ? ? ? ? ? |? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? |? ? ? ? ? ? |____________________________________|

運(yùn)行在PC上的ADB Server和運(yùn)行在Android設(shè)備上的ADBD守護(hù)進(jìn)程之間通過(guò)USB或者無(wú)線網(wǎng)絡(luò)建立連接,分別負(fù)責(zé)Debugger和Android設(shè)備的虛擬機(jī)進(jìn)行通信。一旦連接建立起來(lái),Debugger和Android VM通過(guò)“橋梁”進(jìn)行數(shù)據(jù)的交換,ADB Server和ADBD對(duì)它們來(lái)說(shuō)是透明的。

遠(yuǎn)程調(diào)試

綜上,要實(shí)現(xiàn)遠(yuǎn)程調(diào)試,關(guān)鍵需要實(shí)現(xiàn)兩部分功能:

能夠自定義JDWP通道。

能模擬ADB和ADBD實(shí)現(xiàn)消息的轉(zhuǎn)發(fā)。

先看下如何實(shí)現(xiàn)自定義JDWP通道。

JDWP啟動(dòng)過(guò)程

我們看下Android 5.0系統(tǒng)在啟動(dòng)一個(gè)應(yīng)用時(shí)是如何啟動(dòng)JDWP Thread的。

點(diǎn)擊圖片查看大圖

通過(guò)上圖可以看到,Android在創(chuàng)建虛擬機(jī)的同時(shí)會(huì)創(chuàng)建一個(gè)JDWP-Thread,JDWP默認(rèn)有ADB和Socket兩種通信方式。要實(shí)現(xiàn)遠(yuǎn)程調(diào)試,ADB這種方式肯定不適用,所以能否實(shí)現(xiàn)一個(gè)自定義的Socket通道來(lái)實(shí)現(xiàn)JDWP的消息轉(zhuǎn)發(fā)成了問(wèn)題的關(guān)鍵。

Hack-Native-JDWP

通過(guò)閱讀JDWP啟動(dòng)源碼(Android-API-21)發(fā)現(xiàn),要想讓JDWP通過(guò)自定義的Socket通道進(jìn)行通信,需要滿足兩個(gè)條件:

能夠修改全局變量gJdwpOptions的值,使其配置為Socket模式,并指明對(duì)應(yīng)的端口號(hào)。

使用新的gJdwpOptions參數(shù)重新啟動(dòng)JDWP-Thread。

在Android中,JDWP相關(guān)代碼分別被編譯成libart.so(Art)和libdvm.so(Dalvik)。修改或調(diào)用其他so庫(kù)中的代碼需要用到動(dòng)態(tài)加載,使用動(dòng)態(tài)加載,應(yīng)用程序需要先指定要加載的庫(kù),然后將該庫(kù)作為一個(gè)可執(zhí)行程序來(lái)使用(即調(diào)用其中的函數(shù))。動(dòng)態(tài)加載API 就是為了動(dòng)態(tài)加載而存在的,它允許共享庫(kù)對(duì)用戶空間程序可用。下面表格展示了這個(gè)完整的 API:

函數(shù)描述

dlopen使對(duì)象文件可被程序訪問(wèn)

dlsym獲取執(zhí)行了dlopen函數(shù)的對(duì)象文件中的符號(hào)的地址

dlerror返回上一次出現(xiàn)錯(cuò)誤的字符串

dlclose關(guān)閉目標(biāo)文件

在介紹如何調(diào)用動(dòng)態(tài)加載功能之前,先介紹一下C/C++編譯器在編譯目標(biāo)文件時(shí)所進(jìn)行的名字修飾(符號(hào)化)。

符號(hào)化

上文提到要想自定義JDWP-Thread,首先需要修改gJdwpOptions的值,該值是在debugger.cc中通過(guò)Dbg::ParseJdwpOptions方法來(lái)設(shè)置的,所以只要用新的配置重新調(diào)用一次ParseJdwpOptions即可。

如何找到Dbg::ParseJdwpOptions這個(gè)函數(shù)地址呢?為了保證每個(gè)函數(shù)、變量名都有唯一的標(biāo)識(shí),編譯器在將源代碼編譯成目標(biāo)文件時(shí)會(huì)對(duì)變量名或函數(shù)名進(jìn)行名字修飾。

先看一個(gè)例子,下面的C++程序中兩個(gè)f()的定義:

intf(void){return1; }intf(int){return0; }voidg(void){inti = f(), j = f(0); }

這些是不同的函數(shù),除了函數(shù)名相同以外沒(méi)有任何關(guān)系。如果不做任何改變直接把它們當(dāng)成C代碼,結(jié)果將導(dǎo)致一個(gè)錯(cuò)誤:C語(yǔ)言不允許兩個(gè)函數(shù)同名。所以,C++編譯器將會(huì)把它們的類型信息編碼成符號(hào)名,結(jié)果類似下面的代碼:

int__f_v (void) {return1; }int__f_i (int)? {return0; }void__g_v (void) {inti = __f_v(), j = __f_i(0); }

可以通過(guò)nm命令查看so文件中的符號(hào)信息。

nm -D libart.so | grep ParseJdwpOptions

001778d0 T _ZN3art3Dbg16ParseJdwpOptionsERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE

這樣就得到了ParseJdwpOptions函數(shù)在動(dòng)態(tài)鏈接庫(kù)文件中符號(hào)化以后的函數(shù)名。

找到符號(hào)化了的函數(shù)名后,就可以通過(guò)調(diào)用動(dòng)態(tài)鏈接庫(kù)中的函數(shù)重新啟動(dòng)JDWP-Thread。部分代碼如下(以下代碼只針對(duì)Android-API-21和Android-API-22版本有效):

void*handler = dlopen("/system/lib/libart.so", RTLD_NOW);if(handler ==NULL){? ? ? ? LOGD(LOG_TAG,env->NewStringUTF(dlerror()));? ? }//對(duì)于debuggable false的配置,重新設(shè)置為可調(diào)試void(*allowJdwp)(bool);? ? allowJdwp = (void(*)(bool)) dlsym(handler,"_ZN3art3Dbg14SetJdwpAllowedEb");? ? allowJdwp(true);void(*pfun)();//關(guān)閉之前啟動(dòng)的jdwp-threadpfun = (void(*)()) dlsym(handler,"_ZN3art3Dbg8StopJdwpEv");? ? pfun();//重新配置gJdwpOptionsbool(*parseJdwpOptions)(conststd::string&);? ? parseJdwpOptions = (bool(*)(conststd::string&)) dlsym(handler,"_ZN3art3Dbg16ParseJdwpOptionsERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE");std::stringoptions ="transport=dt_socket,address=8000,server=y,suspend=n";? ? parseJdwpOptions(options);//重新startJdwppfun = (void(*)()) dlsym(handler,"_ZN3art3Dbg9StartJdwpEv");? ? pfun();

以上代碼關(guān)閉了之前可能存在的JDWP-Thread,同時(shí)開啟一個(gè)本地的Socket通道來(lái)進(jìn)行通信,這樣就能通過(guò)本地的Socket通道來(lái)進(jìn)行JDWP消息的傳遞。

突破7.0動(dòng)態(tài)鏈接的限制

通過(guò)上面代碼可知,實(shí)現(xiàn)自定義的JDWP通道主要是采用動(dòng)態(tài)調(diào)用libart.so/libdvm.so中的函數(shù)實(shí)現(xiàn)。但從 Android 7.0 開始,系統(tǒng)將阻止應(yīng)用動(dòng)態(tài)鏈接非公開 NDK庫(kù),詳情請(qǐng)參考《Android 7.0行為變更》,強(qiáng)制調(diào)用會(huì)產(chǎn)生如下Crash:

java.lang.UnsatisfiedLinkError: dlopen failed: library"/system/lib/libart.so"needed or dlopened by"/system/lib/libnativeloader.so"is not accessibleforthe namespace"classloader-namespace"

如何繞過(guò)這個(gè)限制來(lái)動(dòng)態(tài)調(diào)用libart.so中的方法?既然直接調(diào)用dlopen會(huì)失敗,那是不是可以模擬dlopen和dlsym的實(shí)現(xiàn)來(lái)繞過(guò)這個(gè)限制?

dlopen和dlsym分別返回動(dòng)態(tài)鏈接庫(kù)在內(nèi)存中的句柄和某個(gè)符號(hào)的地址,所以只要能找到dlopen返回的句柄并通過(guò)句柄找到dlsym符號(hào)對(duì)應(yīng)的地址,就相當(dāng)于實(shí)現(xiàn)了這兩個(gè)函數(shù)的功能。libart.so會(huì)在程序啟動(dòng)之后就被加載到內(nèi)存中,可以在/proc/self/maps找到當(dāng)前進(jìn)程中l(wèi)ibart.so在內(nèi)存中映射的地址:

vbox86p:/ # cat /proc/1665/maps | grep libart.so

e2d50000-e3473000 r-xp 00000000 08:06 1087? ? ? ? ? ? ? ? ? ? ? ? ? ? ? /system/lib/libart.so

e3474000-e347c000 r--p 00723000 08:06 1087? ? ? ? ? ? ? ? ? ? ? ? ? ? ? /system/lib/libart.so

e347c000-e347e000 rw-p 0072b000 08:06 1087? ? ? ? ? ? ? ? ? ? ? ? ? ? ? /system/lib/libart.so

這里libart.so被分成了三個(gè)連續(xù)子空間,從e2d50000開始。

如何才能在內(nèi)存中找到想要打開的函數(shù)地址?我們先看下ELF文件結(jié)構(gòu)

要實(shí)現(xiàn)dlsym,首先要保證查找的符號(hào)在動(dòng)態(tài)符號(hào)表中能找到,在ELF文件中,SHT_DYNSYM對(duì)應(yīng)的Section定義了當(dāng)前文件中的動(dòng)態(tài)符號(hào);SHT_STRTAB定義了動(dòng)態(tài)庫(kù)中所有字符串;SHT_PROGBITS則定義了動(dòng)態(tài)庫(kù)中定義的信息。如何找到這些Section:

通過(guò)內(nèi)存映射的方式把libart.so映射到內(nèi)存中;

按照ELF文件結(jié)構(gòu)解析映射到內(nèi)存中的libart.so;

解析SHT_DYNSYM,并把當(dāng)前section復(fù)制到內(nèi)存中;

解析SHT_STRTAB,并把當(dāng)前section復(fù)制到內(nèi)存中(后面需要根據(jù)SHT_STRTAB來(lái)找到特定的符號(hào));

解析SHT_PROGBITS,得到當(dāng)前內(nèi)存映射的偏移地址,這里要注意:不同進(jìn)程中相同動(dòng)態(tài)庫(kù)的同一個(gè)函數(shù)的偏移地址是一樣的。

以上邏輯的部分代碼片段如下:

fd = open(libpath, O_RDONLY);? ? size = lseek(fd,0, SEEK_END);if(size <=0) fatal("lseek() failed for %s", libpath);? ? elf = (Elf_Ehdr *) mmap(0, size, PROT_READ, MAP_SHARED, fd,0);? ? close(fd);? ? fd = -1;if(elf == MAP_FAILED) fatal("mmap() failed for %s", libpath);? ? ctx = (structctx *)calloc(1,sizeof(structctx));if(!ctx) fatal("no memory for %s", libpath);//通過(guò)/proc/self/proc 找到的libart.so的起始地址ctx->load_addr = (void*) load_addr;? ? shoff = ((char*) elf) + elf->e_shoff;for(k =0; k < elf->e_shnum; k++)? {? ? ? ? shoff = (char*)shoff + elf->e_shentsize;? ? ? ? Elf_Shdr *sh = (Elf_Shdr *) shoff;? ? ? ? log_dbg("%s: k=%d shdr=%p type=%x", __func__, k, sh, sh->sh_type);switch(sh->sh_type) {caseSHT_DYNSYM:if(ctx->dynsym) fatal("%s: duplicate DYNSYM sections", libpath);/* .dynsym */ctx->dynsym =malloc(sh->sh_size);if(!ctx->dynsym) fatal("%s: no memory for .dynsym", libpath);memcpy(ctx->dynsym, ((char*) elf) + sh->sh_offset, sh->sh_size);//ctx->nsyms 動(dòng)態(tài)符號(hào)表的個(gè)數(shù)ctx->nsyms = (sh->sh_size/sizeof(Elf_Sym)) ;break;caseSHT_STRTAB:if(ctx->dynstr)break;/* .dynstr is guaranteed to be the first STRTAB */ctx->dynstr =malloc(sh->sh_size);if(!ctx->dynstr) fatal("%s: no memory for .dynstr", libpath);memcpy(ctx->dynstr, ((char*) elf) + sh->sh_offset, sh->sh_size);break;//當(dāng)前段內(nèi)容為program defined information:程序定義區(qū)caseSHT_PROGBITS:if(!ctx->dynstr || !ctx->dynsym)break;//得到偏移地址ctx->bias = (off_t) sh->sh_addr - (off_t) sh->sh_offset;break;? ? ? ? }? ? }//關(guān)閉內(nèi)存映射munmap(elf, size);

接下來(lái)就可以根據(jù)要找的符號(hào)名在SHT_DYNSYM中對(duì)應(yīng)的位置得到具體的函數(shù)指針,部分代碼如下:

void*fake_dlsym(void*handle,constchar*name){intk;structctx *ctx = (structctx *) handle;? ? Elf_Sym *sym = (Elf_Sym *) ctx->dynsym;char*strings = (char*) ctx->dynstr;for(k =0; k < ctx->nsyms; k++, sym++)if(strcmp(strings + sym->st_name, name) ==0) {//動(dòng)態(tài)庫(kù)的基地址 + 當(dāng)前符號(hào)section地址 - 偏移地址return(char*)ctx->load_addr + sym->st_value - ctx->bias;? ? ? ? }return0;}

通過(guò)以上模擬dlopen和dlsym的邏輯,我們成功繞過(guò)了系統(tǒng)將阻止應(yīng)用動(dòng)態(tài)鏈接非公開 NDK庫(kù)的限制。

消息轉(zhuǎn)發(fā)

完成上面邏輯以后就可以通過(guò)本地Socket在虛擬機(jī)和用戶進(jìn)程之間傳遞JDWP消息。但是要實(shí)現(xiàn)遠(yuǎn)程調(diào)試,還需要遠(yuǎn)程下發(fā)虛擬機(jī)的調(diào)試指令并回傳執(zhí)行結(jié)果。我們通過(guò)App原有Push通道加上線上消息轉(zhuǎn)發(fā)服務(wù),實(shí)現(xiàn)了整個(gè)調(diào)試工具的消息轉(zhuǎn)發(fā)功能:

Proguard對(duì)調(diào)試的影響

正常發(fā)布到市場(chǎng)的項(xiàng)目都會(huì)通過(guò)Proguad進(jìn)行混淆,不同力度的混淆配置會(huì)生成不同的字節(jié)碼文件。對(duì)調(diào)試功能影響比較大的配置有兩個(gè):

LineNumberTable

LocalVariableTable

如果Proguard中沒(méi)有對(duì)這兩個(gè)屬性進(jìn)行Keep,那經(jīng)過(guò)Proguard處理的方法字節(jié)碼中會(huì)缺失這兩個(gè)模塊,對(duì)調(diào)試的影響分別是無(wú)法在方法的某一行設(shè)置斷點(diǎn)和無(wú)法獲取當(dāng)前本地變量的值(但能獲取到方法參數(shù)變量和類成員變量)。一般為了在應(yīng)用發(fā)生崩潰時(shí)能獲取到調(diào)用棧中每個(gè)函數(shù)對(duì)應(yīng)的行號(hào),需要保留LineNumberTable,同時(shí)為了減少包體積會(huì)放棄LocalVariableTable。在沒(méi)有LocalVariableTable的情況下,可以通過(guò)調(diào)用Execute命令得到一些運(yùn)行時(shí)結(jié)果間接得獲取到本地變量。

JDI的實(shí)現(xiàn)

整個(gè)消息交互流程跑通以后,接下來(lái)要做的就是根據(jù)JDI規(guī)范作進(jìn)一步的封裝。為了方便快速調(diào)試,目前調(diào)試工具的前端實(shí)現(xiàn)主要參考了LLDB的調(diào)試流程,通過(guò)設(shè)置命令的方式進(jìn)行調(diào)試,整體樣式如下圖所示:

點(diǎn)擊圖片查看大圖

總結(jié)

本文從調(diào)查線上問(wèn)題的常見(jiàn)手段入手,介紹了到店餐飲移動(dòng)團(tuán)隊(duì)在實(shí)現(xiàn)遠(yuǎn)程調(diào)試過(guò)程中的嘗試和探索。通過(guò)遠(yuǎn)程調(diào)試可以方便快捷地獲取用戶當(dāng)前App運(yùn)行時(shí)的狀態(tài),助力開發(fā)者快速定位線上問(wèn)題。

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

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

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