一、程序Core定義及分類
程序core是指應(yīng)用程序無法保持正常running狀態(tài)而發(fā)生的崩潰行為。程序core時會生成相關(guān)的core-dump文件,core-dump文件是程序崩潰時程序狀態(tài)的狀態(tài)數(shù)據(jù)備份。core-dump文件包含內(nèi)存、處理器、寄存器、程序計數(shù)器、棧指針等狀態(tài)信息。我們可以借助core-dump文件來分析定位程序Core的原因。
這里我們從三個方面對程序Core進(jìn)行分類:機(jī)器、資源、程序Bug。下表對常見的Core原因進(jìn)行了分類:

二、函數(shù)棧介紹
當(dāng)我們打開core文件時,首先關(guān)注的是程序崩潰時的函數(shù)調(diào)用棧狀態(tài),為了方便理解后續(xù)定位core的一些技巧,這里先簡單介紹一下函數(shù)棧。
2.1 寄存器介紹
目前生產(chǎn)環(huán)境都為64位機(jī),這里只介紹64位機(jī)的寄存器,如下:
對于x86-64架構(gòu),共有16個64位寄存器,每個寄存器的用途并不單一,如%rax通常保存函數(shù)返回結(jié)果,但也被應(yīng)用于imul和idiv指令。這里重點(diǎn)關(guān)注%rsp(棧頂指針寄存器)、%rbp(棧底指針寄存器)、%rdi、%rsi、%rdx、%rcx、%r8、%r9(分別對應(yīng)第1~6函數(shù)參數(shù))。
Callee Save說明是否需要被調(diào)用者保存寄存器的值。
2.2 函數(shù)調(diào)用
2.2.1 調(diào)用函數(shù)棧幀:
在調(diào)用一個函數(shù)時首先進(jìn)行的是參數(shù)壓棧,參數(shù)壓棧的順序跟參數(shù)定義的順序相反。注意,并不是參數(shù)一定會壓棧,在x86-64架構(gòu)中會針對可以使用寄存器傳遞的變量,直接通過寄存器傳值,如數(shù)字、指針、引用等。
接著是返回地址壓棧,返回地址為被調(diào)用函數(shù)執(zhí)行完后,調(diào)用函數(shù)執(zhí)行的下一個指令地址。這里牢記返回地址的位置,后續(xù)章節(jié)會利用到這個返回地址的特性。
針對上面的介紹舉個例子說明:
如上圖,在main函數(shù)中調(diào)用了foo函數(shù),首先對參數(shù)壓棧,三個參數(shù)都可以直接用寄存器傳遞(分別對應(yīng)%edi、%esi、%edx),然后call指令將下一個指令壓棧。
2.2.2 被調(diào)用函數(shù)棧幀:
被調(diào)用函數(shù)首先會將上一個函數(shù)的棧底指針(%rbp)保存,即%rbp壓棧。然后再保存需要被保存的寄存器值,即Callee Save為True的寄存器。接著為臨時變量、局部變量申請??臻g。
針對被調(diào)用函數(shù),舉個例子說明:
如上圖,在foo函數(shù)執(zhí)行時,先對main函數(shù)的%rbp壓棧,再把寄存器中的參數(shù)值存放到局部變量(a, b, c)中。
2.3 總結(jié)
通過對函數(shù)調(diào)用的簡單介紹,我們可以發(fā)現(xiàn)函數(shù)棧是一個縝密且脆弱的結(jié)構(gòu),內(nèi)存結(jié)構(gòu)必須按照嚴(yán)格的方式被訪問,如稍有不慎就可能導(dǎo)致程序崩潰。
三、GDB定位Core
這一節(jié)將介紹從core文件打開到定位全流程中可能會遇到的問題以及解決技巧。
3.1 Core文件
core文件在哪里?
查看“/proc/sys/kernel/core_pattern”確定core文件生成規(guī)則。
3.2 變量打印
程序debug過程中常常要查看各種變量(內(nèi)存、寄存器、函數(shù)表等)的值是否正確,維持單獨(dú)用一節(jié)介紹下常用的變量打印方法以及一些冷門小技巧。
3.2.1 print命令
print [Expression]
print $[Previous value number]
print {[Type]}[Address]
print [First element]@[Element count]
print /[Format] [Expression]
Format格式:
o - 8進(jìn)制
x - 16進(jìn)制
u - 無符號十進(jìn)制
t - 二進(jìn)制
f - 浮點(diǎn)數(shù)
a - 地址
c - 字符
s - 字符串
3.2.2 x命令
x /<n/f/u> <addr>
n:是正整數(shù),表示需要顯示的內(nèi)存單元的個數(shù),即從當(dāng)前地址向后顯示n個內(nèi)存單元的內(nèi)容,
一個內(nèi)存單元的大小由第三個參數(shù)u定義。
f:表示addr指向的內(nèi)存內(nèi)容的輸出格式,s對應(yīng)輸出字符串,此處需特別注意輸出整型數(shù)據(jù)的格式:
x 按十六進(jìn)制格式顯示變量.
d 按十進(jìn)制格式顯示變量。
u 按十進(jìn)制格式顯示無符號整型。
o 按八進(jìn)制格式顯示變量。
t 按二進(jìn)制格式顯示變量。
a 按十六進(jìn)制格式顯示變量。
c 按字符格式顯示變量。
f 按浮點(diǎn)數(shù)格式顯示變量。
u:就是指以多少個字節(jié)作為一個內(nèi)存單元-unit,默認(rèn)為4。u還可以用被一些字符表示:
1 byte, h=2 bytes,w=4 bytes,g=8 bytes. =
表示內(nèi)存地址。 :
3.2.3 容器對象打印
利用上面的print和x命令,再結(jié)合容器的數(shù)據(jù)結(jié)構(gòu),我們就能知道容器的詳細(xì)信息。這里舉個完整打印二進(jìn)制string的例子,string的數(shù)據(jù)結(jié)構(gòu)如下:
string為空時,_M_dataplus._M_p是指向nullptr的。當(dāng)賦值后會在堆上申請一段內(nèi)存,分為兩段,前半段是meta信息(類型為std::string::_Rep),如length、capacity、refcount,后半段為數(shù)據(jù)區(qū),_M_p指向數(shù)據(jù)區(qū)。
通常情況下非二進(jìn)制的string,直接print即可顯示數(shù)據(jù)內(nèi)容,但當(dāng)數(shù)據(jù)為二進(jìn)制時,'\0'會截斷打印內(nèi)容。因此,打印二進(jìn)制string的首要任務(wù)是確認(rèn)string的size。
string的size信息保存在std::string::_Rep結(jié)構(gòu)體中,根據(jù)上面的數(shù)據(jù)結(jié)構(gòu)可以發(fā)現(xiàn),_Rep與_M_dataplus._M_p相差一個結(jié)構(gòu)體大小,因此打印_Rep結(jié)構(gòu)體的命令為:
#先把_M_p轉(zhuǎn)成_Rep指針,再讓指針向低地址偏移一個結(jié)構(gòu)體大小
p *((std::string::_Rep*)(s._M_dataplus._M_p) - 1)
找到string的size(_M_length)后,再通過x命令打印相關(guān)的內(nèi)存區(qū)即可,命令為:
#這里的n是_Rep._M_length
x /ncb s._M_dataplus._M_p
運(yùn)行效果如下:
為了方便,這里推薦一個方便的腳本:stl-views.gdb(鏈接:https://sourceware.org/gdb/wiki/STLSupport?action=AttachFile&do=view&target=stl-views-1.0.3.gdb,直接在gdb終端source stl-views.gdb即可,支持常見的容器打印,如vector、map、list、string等。
3.2.4 靜態(tài)變量打印
程序中經(jīng)常會使用到靜態(tài)變量,有時我們需要查看某個靜態(tài)對象的值是否正確,就涉及到靜態(tài)對象的打印??慈缦吕樱?/span>
void foo() {
static std::string s_foo("foo");
}
這里可以借助nm -C ./bin | grep xx找到靜態(tài)變量的內(nèi)存地址,再通過gdb的print打印。
3.2.5 內(nèi)存dump
dump [format] memory filename start_addr end_addr
dump [format] value filename expr
format一般使用binary,其他的可以查看gdb手冊。
比如我們可以結(jié)合上面查看string內(nèi)容的例子dump整個string數(shù)據(jù)到文件中。
dump binary memory file1 s._M_dataplus._M_p s._M_dataplus._M_p + length
如果想查看文件內(nèi)容的話可把vim -b和xxd結(jié)合使用。
接上面string的例子,舉一個dump string內(nèi)存數(shù)據(jù)到文件的例子:
3.3 定位代碼行
定位core的原因,首先要定位崩潰時正在執(zhí)行的代碼行,這一節(jié)主要介紹一些定位代碼行的方法。通常情況下直接通過gdb的breaktrace即可一覽整個函數(shù)棧,但有時候函數(shù)棧信息并非如此清晰明了,這時就可利用一些小技巧來查看函數(shù)棧。
3.3.1 去編譯優(yōu)化
有時候會發(fā)現(xiàn)core的函數(shù)棧跟實(shí)際的代碼行不匹配,如果是在線下環(huán)境中,可以嘗試把編譯優(yōu)化設(shè)置成-O0,然后再重新復(fù)現(xiàn)core問題。
3.3.2 程序計數(shù)器 + addr2line
對于線上core問題,一般沒法再對程序進(jìn)行去編譯優(yōu)化操作,只能在現(xiàn)有的core文件基礎(chǔ)上進(jìn)行代碼定位。這一節(jié)我們采用一個例子來介紹如何使用程序計數(shù)器 + addr2line來定位代碼行。
從截圖可以發(fā)現(xiàn)frame 20指示的代碼行與實(shí)際的代碼行是不匹配的,定位步驟如下:
# 跳轉(zhuǎn)到第20號棧
frame 20
# 使用display命令顯示程序計數(shù)器
display /i $rip
# 使用addrline工具做地址轉(zhuǎn)換
shell /opt/compiler/gcc-8.2/bin/addr2line -e bin address
3.3.3 函數(shù)棧修復(fù)
有時候我們會發(fā)現(xiàn)函數(shù)調(diào)用棧里面會出現(xiàn)很多??的情況,這常發(fā)生于棧被寫花,某些情況下手動進(jìn)行修復(fù)。函數(shù)棧的修復(fù)利用的函數(shù)棧內(nèi)存分布知識,見第一節(jié)。
-----------------------------------
Low addresses
-----------------------------------
0(%rsp) | top of the stack frame
| (this is the same as -n(%rbp))
---------|-------------------------
-n(%rbp) | variable sized stack frame
-8(%rbp) | varied
0(%rbp) | previous stack frame address
8(%rbp) | return address
-----------------------------------
High addresses
從上面的棧示意圖可以發(fā)現(xiàn),利用%rbp寄存器即可找到上一個函數(shù)的返回地址和棧底指針,再利用addr2line命令找到對應(yīng)的代碼行。這里舉一個例子:
#首先找到當(dāng)前被調(diào)用棧上一個棧的棧底指針值和返回地址
x /2ag $rbp # 2個單位,a=十六進(jìn)制,g=8字節(jié)單元
#使用上一條命令得到的棧底指針值依次遞歸
x /2ag address
3.3.4 無規(guī)律core棧
無規(guī)律core棧問題一般發(fā)生于堆內(nèi)存寫壞。函數(shù)調(diào)用是一個非常精密的過程,任何一個位置發(fā)生非預(yù)期的讀寫都會導(dǎo)致程序崩潰。這里可以舉個小例子來說明:
int main(int argc, char* argv[]) {
std::string s("abcd");
*reinterpret_cast<uint64_t*>(&s) = 0x11;
return 0;
}
上面的例子core在string析構(gòu)上,原因是因?yàn)閟tring的_M_ptr被改寫成了0x11,析構(gòu)流程變成了非法內(nèi)存操作。
同理,由于進(jìn)程堆空間是共享的,一個線程對堆的非法操作就可能會影響另一個線程的正常操作,由于堆分配的隨機(jī)性,表現(xiàn)出來的現(xiàn)象就是無規(guī)律core棧。
針對無規(guī)律core棧最好的方式還是借助AddressSanitizer。
#設(shè)置編譯參數(shù)CXXFLAGS
CXXFLAGS="-fPIC -fsanitize=address -fno-omit-frame-pointer"
#設(shè)置鏈接參數(shù)
LDFLAGS="-lasan"
# 設(shè)置啟動環(huán)境變量
export ASAN_OPTIONS=halt_on_error=0:abort_on_error=1:disable_coredump=0
# 啟動
LD_PRELOAD=/opt/compiler/gcc-8.2/lib/libasan.so ./bin/xxx
3.3.5 總結(jié)
上面提到的幾種方法都是為了找到具體的問題代碼行,為后續(xù)分析core的具體原因提供線索。
3.4 定位Core原因
這一節(jié)主要介紹定位Core原因的方法以及一些常見原因的介紹。
3.4.1 確認(rèn)信號量
從上面的Core分類我們可以發(fā)現(xiàn)某些場景的core是由于機(jī)器故障導(dǎo)致的,如SIGBUS,因此可以先通過信號量排除掉一些core原因。
3.4.2 定位異常匯編指令
通過上面的代碼行定位我們可以大致找到程序core在哪一行,比較簡單的core直接print程序上下文即可找到core的原因。
但有些場景下,通過排查上下文無任何異常,這個時候就需要準(zhǔn)確定位具體的異常匯編指令,根據(jù)指令找原因。
查看匯編指令比較簡單的方法是使用layout asm命令,frame指向那個棧,就顯示對應(yīng)棧的匯編。這里舉個core例子,如下:
程序顯示core在start函數(shù),查看相關(guān)上下文變量均無異常。使用layout asm打開正在執(zhí)行的匯編指令,如下:
查看匯編定位到程序core在mov指令,mov指令上一個指令為sub,為棧申請了3M空間,懷疑是??臻g不足。采用frame 0的%rsp - frame N的%rbp排查為棧空間不足。
通過上面的例子,可以發(fā)現(xiàn)定位異常匯編指令位置后,我們能夠把異常點(diǎn)進(jìn)一步壓縮,定位到是哪個指令、變量、地址導(dǎo)致的core問題。
3.4.3 排查異常變量
通過上面的操作我們可以準(zhǔn)確定位到具體是哪一行代碼的哪一條指令出現(xiàn)了問題,根據(jù)異常指令我們可以排查相關(guān)的變量,確定變量值是否符合預(yù)期。
這里舉一個比較經(jīng)典的空指針例子,如下:
int main(int argc, char* argv[]) {
int* a = nullptr;
*a = 1;
return *a;
}
通過匯編指令我們可以發(fā)現(xiàn)是movl $0x1, (%rax)出現(xiàn)了問題,%rax的值來自于0x8(%rbp),x命令打印相關(guān)的地址就可以發(fā)現(xiàn)為空指針錯誤。
3.4.4 查看被優(yōu)化變量
通常情況下程序都是開啟了編譯優(yōu)化的,就會出現(xiàn)變量無法被print,提示變量被優(yōu)化,有時可利用匯編 + 寄存器的方式查看被優(yōu)化的變量。
這里舉一個例子說明下:
void foo(char const* str) {
char buf[1024] = {'\0'};
memcpy(buf, str, sizeof(buf));
}
int main(int argc, char* argv[]) {
foo("abcd");
return 0;
}
通常情況下在foo函數(shù)內(nèi)部,str變量是會直接別優(yōu)化掉的,因?yàn)榭梢灾苯永?rdi寄存器傳遞參數(shù)。為了能夠打印出str的值,這個時候我們可以借助匯編 + 寄存器的方式找到具體的變量值,如下:
首先找到main函數(shù)調(diào)用foo函數(shù)的參數(shù)壓棧匯編:mov $0x402011, %edi,這里的0x402011即為str的內(nèi)存地址,通過x命令即可顯示str的值了。
比較復(fù)雜的場景可能沒法直接找到被優(yōu)化變量,這時可以采用匯編回溯的方式找到變量。
3.4.5 異常函數(shù)地址排查
有時的core問題是因?yàn)閿?shù)據(jù)異常導(dǎo)致,有時也可能是優(yōu)化函數(shù)地址導(dǎo)致,如調(diào)用虛函數(shù)地址錯誤、函數(shù)返回地址錯誤、函數(shù)指針值錯誤。
異常函數(shù)地址排查同理于異常變量排查,根據(jù)匯編指令確認(rèn)調(diào)用是否異常即可。這里舉一個虛函數(shù)地址異常的例子,如下:
class
A {
public:
virtual ~A() = default;
virtual void foo() = 0;
};
class
B : public A {
public:
void foo() {}
};
int main(int argc, char* argv[])
{
A* a = new B;
a->foo();
A* b = new B;
*reinterpret_cast<void**>(b) = 0x0;
b->foo();
return 0;
}
從匯編指令看是core在了mov (%rax), %rax,結(jié)合指令上下文可發(fā)現(xiàn)是在虛函數(shù)地址尋址操作,對比兩個變量的虛函數(shù)表即可發(fā)現(xiàn)是函數(shù)地址load錯誤導(dǎo)致的core。
3.4.6 總結(jié)
定位core的基本流程可總結(jié)為以下幾步:
明確core的大致觸發(fā)原因。機(jī)器問題?自身程序問題? 定位代碼行。哪一行代碼出現(xiàn)了問題。 定位執(zhí)行指令。哪一行指令干了什么事。 定位異常變量。指令不會有問題,是指令操作的變量不符合預(yù)期。
參考資料:
標(biāo)準(zhǔn)GDB文檔:https://sourceware.org/gdb/current/onlinedocs/gdb/
往期推薦
一鍵三連,好運(yùn)連連,bug不見????