如何快速定位程序Core?


導讀: 程序core是指應用程序無法保持正常running狀態而發生的崩潰行爲。程序core時會生成相關的core-dump文件,是程序崩潰時程序狀態的數據備份。core-dump文件中包含內存、處理器、寄存器、程序計數器、棧指針等狀態信息。本文將介紹一些利用core-dump文件定位程序core原因的方法和技巧。

程序Core定義及分類

程序core是指應用程序無法保持正常running狀態而發生的崩潰行爲。程序core時會生成相關的core-dump文件,core-dump文件是程序崩潰時程序狀態的狀態數據備份。core-dump文件包含內存、處理器、寄存器、程序計數器、棧指針等狀態信息。我們可以藉助core-dump文件來分析定位程序Core的原因。

這裏我們從三個方面對程序Core進行分類:機器、資源、程序Bug。下表對常見的Core原因進行了分類:



函數棧介紹


當我們打開core文件時,首先關注的是程序崩潰時的函數調用棧狀態,爲了方便理解後續定位core的一些技巧,這裏先簡單介紹一下函數棧。

寄存器介紹

目前生產環境都爲64位機,這裏只介紹64位機的寄存器,如下:



對於x86-64架構,共有16個64位寄存器,每個寄存器的用途並不單一,如%rax通常保存函數返回結果,但也被應用於imul和idiv指令。這裏重點關注%rsp(棧頂指針寄存器)、%rbp(棧底指針寄存器)、%rdi、%rsi、%rdx、%rcx、%r8、%r9(分別對應第1~6函數參數)。

Callee Save說明是否需要被調用者保存寄存器的值。

函數調用


調用函數棧幀:

在調用一個函數時首先進行的是參數壓棧,參數壓棧的順序跟參數定義的順序相反。注意,並不是參數一定會壓棧,在x86-64架構中會針對可以使用寄存器傳遞的變量,直接通過寄存器傳值,如數字、指針、引用等。

接着是返回地址壓棧返回地址爲被調用函數執行完後,調用函數執行的下一個指令地址。這裏牢記返回地址的位置,後續章節會利用到這個返回地址的特性。

針對上面的介紹舉個例子說明:



如上圖,在main函數中調用了foo函數,首先對參數壓棧,三個參數都可以直接用寄存器傳遞(分別對應%edi、%esi、%edx),然後call指令將下一個指令壓棧。

被調用函數棧幀:

被調用函數首先會將上一個函數的棧底指針(%rbp)保存,即%rbp壓棧。然後再保存需要被保存的寄存器值,即Callee Save爲True的寄存器。接着爲臨時變量、局部變量申請棧空間。


針對被調用函數,舉個例子說明


如上圖,在foo函數執行時,先對main函數的%rbp壓棧,再把寄存器中的參數值存放到局部變量(a, b, c)中。

總結

通過對函數調用的簡單介紹,我們可以發現函數棧是一個縝密且脆弱的結構,內存結構必須按照嚴格的方式被訪問,如稍有不慎就可能導致程序崩潰。


GDB定位Core


這一節將介紹從core文件打開到定位全流程中可能會遇到的問題以及解決技巧。

Core文件

core文件在哪裏?

查看“/proc/sys/kernel/core_pattern”確定core文件生成規則。


變量打印

程序debug過程中常常要查看各種變量(內存、寄存器、函數表等)的值是否正確,維持單獨用一節介紹下常用的變量打印方法以及一些冷門小技巧。

print命令

  
  
  
print [Expression]print $[Previous value number]print {[Type]}[Address]print [First element]@[Element count]print /[Format] [Expression]

Format格式:o - 8進制x - 16進制u - 無符號十進制t - 二進制f - 浮點數a - 地址c - 字符s - 字符串

x命令

  
  
  
x /<n/f/u>  <addr>n:是正整數,表示需要顯示的內存單元的個數,即從當前地址向後顯示n個內存單元的內容,一個內存單元的大小由第三個參數u定義。
f:表示addr指向的內存內容的輸出格式,s對應輸出字符串,此處需特別注意輸出整型數據的格式: x 按十六進制格式顯示變量. d 按十進制格式顯示變量。 u 按十進制格式顯示無符號整型。 o 按八進制格式顯示變量。 t 按二進制格式顯示變量。 a 按十六進制格式顯示變量。 c 按字符格式顯示變量。 f 按浮點數格式顯示變量。
u:就是指以多少個字節作爲一個內存單元-unit,默認爲4。u還可以用被一些字符表示: 如b=1 byte, h=2 bytes,w=4 bytes,g=8 bytes.
<addr>:表示內存地址。

容器對象打印

利用上面的print和x命令,再結合容器的數據結構,我們就能知道容器的詳細信息。這裏舉個完整打印二進制string的例子,string的數據結構如下:



string爲空時,_M_dataplus._M_p是指向nullptr的。當賦值後會在堆上申請一段內存,分爲兩段,前半段是meta信息(類型爲std::string::_Rep),如length、capacity、refcount,後半段爲數據區,_M_p指向數據區。

通常情況下非二進制的string,直接print即可顯示數據內容,但當數據爲二進制時,'\0'會截斷打印內容。因此,打印二進制string的首要任務是確認string的size。

string的size信息保存在std::string::_Rep結構體中,根據上面的數據結構可以發現,_Rep與_M_dataplus._M_p相差一個結構體大小,因此打印_Rep結構體的命令爲:

#先把_M_p轉成_Rep指針,再讓指針向低地址偏移一個結構體大小p *((std::string::_Rep*)(s._M_dataplus._M_p) - 1)


找到string的size(_M_length)後,再通過x命令打印相關的內存區即可,命令爲:

#這裏的n是_Rep._M_lengthx /ncb s._M_dataplus._M_p


運行效果如下:


爲了方便,這裏推薦一個方便的腳本: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等。

靜態變量打印

程序中經常會使用到靜態變量,有時我們需要查看某個靜態對象的值是否正確,就涉及到靜態對象的打印。看如下例子:

void foo() {    static std::string s_foo("foo");}


這裏可以藉助nm -C ./bin  | grep xx找到靜態變量的內存地址,再通過gdb的print打印。

內存dump

dump [format] memory filename start_addr end_addrdump [format] value filename exprformat一般使用binary,其他的可以查看gdb手冊。
比如我們可以結合上面查看string內容的例子dump整個string數據到文件中。dump binary memory file1 s._M_dataplus._M_p s._M_dataplus._M_p + length
如果想查看文件內容的話可把vim -b和xxd結合使用。

接上面string的例子,舉一個dump string內存數據到文件的例子:

定位代碼行

定位core的原因,首先要定位崩潰時正在執行的代碼行,這一節主要介紹一些定位代碼行的方法。通常情況下直接通過gdb的breaktrace即可一覽整個函數棧,但有時候函數棧信息並非如此清晰明瞭,這時就可利用一些小技巧來查看函數棧。

去編譯優化

有時候會發現core的函數棧跟實際的代碼行不匹配,如果是在線下環境中,可以嘗試把編譯優化設置成-O0,然後再重新復現core問題。

程序計數器 + addr2line

對於線上core問題,一般沒法再對程序進行去編譯優化操作,只能在現有的core文件基礎上進行代碼定位。這一節我們採用一個例子來介紹如何使用程序計數器 + addr2line來定位代碼行。



從截圖可以發現frame 20指示的代碼行與實際的代碼行是不匹配的,定位步驟如下:


# 跳轉到第20號棧frame 20 # 使用display命令顯示程序計數器display /i $rip # 使用addrline工具做地址轉換shell /opt/compiler/gcc-8.2/bin/addr2line -e bin address


函數棧修復

有時候我們會發現函數調用棧裏面會出現很多??的情況,這常發生於棧被寫花,某些情況下手動進行修復。函數棧的修復利用的函數棧內存分佈知識,見第一節。


  
  
  
-----------------------------------Low addresses-----------------------------------0(%rsp)  | top of the stack frame          | (this is the same as -n(%rbp))---------|--------------------------n(%rbp) | variable sized stack frame-8(%rbp) | varied0(%rbp)  | previous stack frame address8(%rbp)  | return address-----------------------------------High addresses


從上面的棧示意圖可以發現,利用%rbp寄存器即可找到上一個函數的返回地址棧底指針,再利用addr2line命令找到對應的代碼行。這裏舉一個例子:


#首先找到當前被調用棧上一個棧的棧底指針值和返回地址x /2ag $rbp # 2個單位,a=十六進制,g=8字節單元  #使用上一條命令得到的棧底指針值依次遞歸x /2ag address

無規律core棧

無規律core棧問題一般發生於堆內存寫壞。函數調用是一個非常精密的過程,任何一個位置發生非預期的讀寫都會導致程序崩潰。這裏可以舉個小例子來說明:


int main(int argc, char* argv[]) {    std::string s("abcd");    *reinterpret_cast<uint64_t*>(&s) = 0x11;    return 0;}


上面的例子core在string析構上,原因是因爲string的_M_ptr被改寫成了0x11,析構流程變成了非法內存操作。

同理,由於進程堆空間是共享的,一個線程對堆的非法操作就可能會影響另一個線程的正常操作,由於堆分配的隨機性,表現出來的現象就是無規律core棧。

針對無規律core棧最好的方式還是藉助AddressSanitizer


#設置編譯參數CXXFLAGSCXXFLAGS="-fPIC -fsanitize=address  -fno-omit-frame-pointer" #設置鏈接參數LDFLAGS="-lasan" # 設置啓動環境變量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


總結

上面提到的幾種方法都是爲了找到具體的問題代碼行,爲後續分析core的具體原因提供線索。


定位Core原因

這一節主要介紹定位Core原因的方法以及一些常見原因的介紹。


確認信號量

從上面的Core分類我們可以發現某些場景的core是由於機器故障導致的,如SIGBUS,因此可以先通過信號量排除掉一些core原因。


定位異常彙編指令

通過上面的代碼行定位我們可以大致找到程序core在哪一行,比較簡單的core直接print程序上下文即可找到core的原因。

但有些場景下,通過排查上下文無任何異常,這個時候就需要準確定位具體的異常彙編指令,根據指令找原因。

查看彙編指令比較簡單的方法是使用layout asm命令,frame指向那個棧,就顯示對應棧的彙編。這裏舉個core例子,如下:



程序顯示core在start函數,查看相關上下文變量均無異常。使用layout asm打開正在執行的彙編指令,如下:

查看彙編定位到程序core在mov指令,mov指令上一個指令爲sub,爲棧申請了3M空間,懷疑是棧空間不足。採用frame 0的%rsp - frame N的%rbp排查爲棧空間不足。

通過上面的例子,可以發現定位異常彙編指令位置後,我們能夠把異常點進一步壓縮,定位到是哪個指令、變量、地址導致的core問題。


 排查異常變量

通過上面的操作我們可以準確定位到具體是哪一行代碼的哪一條指令出現了問題,根據異常指令我們可以排查相關的變量,確定變量值是否符合預期。


這裏舉一個比較經典的空指針例子,如下:

int main(int argc, char* argv[]) {    int* a = nullptr;    *a = 1;    return *a;}


通過彙編指令我們可以發現是movl $0x1, (%rax)出現了問題,%rax的值來自於0x8(%rbp)x命令打印相關的地址就可以發現爲空指針錯誤。


查看被優化變量

通常情況下程序都是開啓了編譯優化的,就會出現變量無法被print,提示變量被優化,有時可利用彙編 + 寄存器的方式查看被優化的變量。

這裏舉一個例子說明下:

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函數內部,str變量是會直接別優化掉的,因爲可以直接利用%rdi寄存器傳遞參數。爲了能夠打印出str的值,這個時候我們可以藉助彙編 + 寄存器的方式找到具體的變量值,如下:



首先找到main函數調用foo函數的參數壓棧彙編:mov $0x402011, %edi,這裏的0x402011即爲str的內存地址,通過x命令即可顯示str的值了。

比較複雜的場景可能沒法直接找到被優化變量,這時可以採用彙編回溯的方式找到變量。


 異常函數地址排查

有時的core問題是因爲數據異常導致,有時也可能是優化函數地址導致,如調用虛函數地址錯誤、函數返回地址錯誤、函數指針值錯誤。

異常函數地址排查同理於異常變量排查,根據彙編指令確認調用是否異常即可。這裏舉一個虛函數地址異常的例子,如下:

classA {public:    virtual ~A() = default;    virtual void foo() = 0;};classB : 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,結合指令上下文可發現是在虛函數地址尋址操作,對比兩個變量的虛函數表即可發現是函數地址load錯誤導致的core。


總結

定位core的基本流程可總結爲以下幾步:

  1. 明確core的大致觸發原因。機器問題?自身程序問題?
  2. 定位代碼行。哪一行代碼出現了問題。
  3. 定位執行指令。哪一行指令幹了什麼事。
  4. 定位異常變量。指令不會有問題,是指令操作的變量不符合預期。
善於利用彙編指令以及打印指令(x、print、display)可以更有效的定位Core。

參考資料:

彙編查看工具: https://godbolt.org/   https://cppinsights.io/
標準GDB文檔: https://sourceware.org/gdb/current/onlinedocs/gdb/


本文分享自微信公衆號 - 百度開發者中心(baidudev)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章