天天看點

用gdb分析coredump的一些技巧

前幾天我們正在營運的一款産品發生了崩潰,我花了兩天嘗試用 gdb 分析了 coredump ,雖然最後還是沒能找到 bug ,但還是覺得應該做一些總結。

産品是基于 skynet 開發的,由于曆史原因,它基于的是 skynet 1.0 之前 2015 年中的一個版本,由于這兩年一直沒出過什麼問題,是以維護人員懈怠而沒有更新。

崩潰的時候,關于 lua 部分的代碼缺少調試符号資訊,這加大了分析難度。現在的 skynet 在編譯 lua 時,加入了 -g 選項,這應該可以幫助未來出現類似問題時更好的定位問題。

用gdb分析coredump的一些技巧

導緻代碼崩潰的直接原因是 rip 指向了一個資料段的位址,準确的說,跳轉到了目前工作線程擁有的 lua 虛拟機的主線程 l 那裡。

發現這條線索很容易,skynet 的其它部分是有調試符号的,可以在崩潰的調用棧上看到,服務的 callback 函數的 ud

和崩潰位址一緻,而 lua 服務的 ud 正是 l 。用 gdb 的 p ( lua_state *)位址

檢視這個結構,也能觀察到這個資料結構的内容正是一個 lua_state 。

由于用 bt 檢視的調用棧是不正常的,是以可以斷定在函數調用鍊的過程中應該是發生了某種錯誤改寫了 c 棧的内容。在這種情況下,gdb 多半靠猜測來重建調用鍊(就是用 bt 看到的那些)。

現代編譯器經過優化代碼之後, c 棧上已經沒有 stack frame 的基位址了,是以現在不能簡單的看堆棧的資料内容來推測 stack

frame 。也就是經過優化的代碼不一定适用 rbp 來儲存 stack frame ,它也不一定入棧。對于 gcc ,這個優化政策是通過

-fomit-frame-pointer 開啟的,隻要用 -o 編譯,就一定打開的。在 stack 本身出問題時,gdb

的猜測很可能不準确,人工來猜或手工補全或許更靠譜一些。方法就是先用 x/40xg $rsp 列印出 c stack 的内容,然後觀察确定

stack 上的哪些資料落在代碼段上。所有有函數調用的地方,一定有處于代碼段上的某處傳回位址指針。

主程式的代碼段一般都位址偏低,動态鍊入的代碼段可以用 info sharedlibrary 來檢視。傳回位址肯定是落在函數代碼的内部,而肯定不會是函數入口,而這些位址除了函數調用外,都不可能用正常的 c 代碼生成出來,是以識别性很強,不會有歧義。

如果覺得某個指針是函數傳回位址,可以用 x/10i 位址 來反彙編确認。

但是需要注意的是,即使在 c stack

上發現一個函數傳回位址,并不說明這個函數調用尚未傳回。它隻能說明這個函數至少被調用過。這是因為,彙編在 call

一個函數時,會把目前調用處的位址壓棧。而調用結束後,ret 指令傳回隻是修改了 rsp 這個棧指針,而資料本身是殘留在棧上的。這也是為什麼

gdb 有時候也會猜錯。

在這次的案例裡,崩潰發生在執行跳轉到了資料段,這種情況多半是因為 call 指令調用的是一個間接引用,在 c

層面來看,就是調用了一個函數指針。這種情況下,跳轉位址肯定還在寄存器裡。用 info registers 可以檢視。(注:在 64bit

平台下,檢視寄存器内容非常重要,因為 64bit 下,函數調用的前四個參數是通過寄存器 rdi rsi 傳遞而非堆棧,往往需要結合 disass

反彙編看代碼去推算。)

當然,按 lua 自己的正常邏輯,是不可能把 l 作為一個函數指針來調用的。按我的猜測,這裡出錯比較大的可能是 longjmp

的時候資料出錯,恢複了錯誤的寄存器。btw, setjmp 在生成 jmp_buf 時,對于 rsp rbp 這類很可能用于位址的寄存器,crt

做了變形(mangling)處理,是以很難簡單的靠寫越界寫出一個巧合的錯誤值。

對于調試崩潰在 lua 内部的情況,比較關鍵的線索通常是 l 本身的狀态。因為業務的主流程其實是用 lua vm 驅動的,l 的 callinfo 也就是 lua 的 stack frame 資訊更多。

對于 skynet ,在正常運作的時候通常會有兩個活動的 l 。一個是主線程,用來分發消息;但消息本身是在一個獨立的 coroutine

中進行的。以上可以确定主線程,而子線程的 l 可以在寄存器和 stack frame

裡找。由于沒有調試符号,是以可以靠猜來尋找,這并不算太麻煩。要确定一個位址是否是 l ,隻需要檢視 l->l_g 看是否和前面找到的主線程

l 的對應值是否相同。

在缺少調試符号的情況下,會發現 lua 下的一些内部資料結構 gdb 無法識别。這個時候可以用 add-symbol-file 來導入需要的結構資訊。方法是加上 -g 重新編譯一下 lua ,把一些包含這些結構的檔案,例如 ldo.o 加進來。

我在分析這次的問題時,寫了腳本檢視兩個 l 的 lua 調用棧,這些腳本隻要對 lstate.h 裡的 callinfo

資料結構熟悉就很容易寫出來。lua 的調試資訊很豐富,找到源檔案名和行号都很容易。另外,l 棧頂的資料是什麼也是重要的線索,可以推導出崩潰發生時

lua 的狀态。

這次我們崩潰的程式最後停在主線程的 resume 調用子線程上。子線程調用了 skynet.sleep ,也就是最後把 "sleep",

session 通過 yield 傳給了主線程。這些要傳出的量可以在子線程的 l->top 上查到。雖然 lua 本身已經把值 pop

出去了,但 pop 本身是不清空棧的,隻是調整了棧頂指針,是以在 gdb 下依然可見。主線程也接收到了傳過來的資料,資料棧上可見。

不過這次的吊詭之處在于,lua 線程間拷貝資料這個過程是在 lcorolib.c 中的 auxresume 函數中執行的,在

luab_coresume 裡還需要在結果中插入一個 boolean 。而我在 coredump 資料中發現了拷貝過程已經完成,但是

boolean 卻沒有壓入。那麼事故發生點隻可能在兩者之間。不過在 auxresume 傳回到後續 push boolean

之間隻有幾行彙編代碼,絕對不可能出錯。

唯一能解釋的就是在 lua_resume 期間,子線程運作的流程破壞了 c 的 stackframe ,讓 auxresume 沒能正确的傳回到調用它的 luab_coresume 中。但怎樣才能制造出這種情況,我暫時沒有想法。

在 c 層面制造出崩潰的可能性并不是很多,資料越界是一類常見的 bug (這次并不像);另一類是記憶體管理出錯,比如對同一個指針 free

多次,導緻記憶體管理器出錯,把同一個位址配置設定給兩個位置,導緻兩個對象位址重疊。後一類問題能幹擾到 c 的 stack frame

可能性比較小,除非有堆上的對象指針指向了棧位址,然後并引用。這次的 bug 中,最打的線索是 l->errorjmp ,也就是 lua

線程中指向恢複點的 jmp_buf ,它是在 c 棧上的。

l 中有一些相關變量可以推測 resume/yield/pcall 等的執行狀态: l->nny l->nccalls

l->ci->callstatus 等都是。我分析的結果是在 auxresume 傳回後,沒有繼續運作 luab_coresume

中的 push boolean 過程,卻又運作了新的一輪 luad_pcall ,導緻了最終的崩潰。這可以通過 l 的 errorjmp 的

status 得到一定的佐證。不過 c stack 上沒有 luab_coresume

的傳回位址比較難解釋,隻能說是可能被錯誤的運作流程覆寫掉了。

gc 會是觸發 bug 的多發點,因為 gc 是平行于主流程同步進行的。這次崩潰點的子線程的 lua 棧幀停留在 yield

函數上,在此之前也的确調用了 gc step 。但是,我們可以通過查閱 l 的 gcstate 變量檢視 gc

處于什麼階段。在這次的事發現場,可以看到 gcstate 為 gcspropagate 也就是 mark 階段,是以并不會引發任何 __gc

流程,也沒有記憶體釋放。

結論:對于 bug ,暫時沒有結論。不過對于調試 lua 編寫的程式,還是積累了一些經驗:

一定要在編譯 lua 時加 -g ,雖然 lua 本身出嚴重 bug 的可能性極低,但可以友善在出問題時用 gdb 分析。

在 gdb 中檢視 lua 的調用棧很有意義,分析 l->ci 很容易拿到調用棧資訊。

記得檢視一下 lua 的資料棧内容,包括已經 pop 出去,但還殘留在記憶體中的資料,可以幫助分析崩潰時的狀态。

記得檢視 l 中保留的 gcstate gcdebt 等 gc 相關變量,可以用于推斷 lua gc 的工作狀态。

l 中的 nny nccalls errorjmp 可以幫助确定 lua 到 c 的調用層次。注意:一個 yield 狀态的 coroutine ,errorjmp 指針應該為 null 。

另外,gdb 分析 skynet 可以從下面的線索入手:

context 對象裡能找到目前服務的位址、最後一個向外提起的請求的 session 、接收過多少條消息等。結合 log 檔案來看會有參考價值。

如果想找到記憶體中其它的服務對象(非目前線程上活動的),可以試試 p *h 。 h 是個數組,定義在 skynet_handle.c 中,裡面有所有服務的位址。

如果想找到記憶體中待喚醒的 timer ,可以試試 p *ti 。它定義在 skynet_timer.c 中。

作者:佚名

來源:51cto