在前面的文章中,已經出現了作為Linux重要調試手段之一的crash工具的身影。在後續的文章裡,我們還會繼續用到它。是以在這裡,準備對Linux中的crash工具的原理和使用方法,做一個相對全面的介紹。
crash工具可用于從正在運作的核心擷取資訊,而更多的時候,則是用來分析産生的coredump檔案,以定位核心陷入異常的原因。coredump檔案本質上記錄的是核心crash時的記憶體資料,是以通常被譯作「核心轉儲」。
産生 - 三個前提
如果核心自己都已經崩潰了,那系統裡面還有誰能完成導出記憶體的工作呢?你們可能不知道,核心是有一個backup的(稱為"capture kernel"),而為它建立這個backup的,是一個名為"
Kdump"的service。
capture kernel的使命是導出完整的記憶體,可是其運作本身也要觸碰到實體記憶體,這勢必會破壞掉原有核心(production kernel)之前的記憶體資訊。是以呢,這個capture kernel所需使用的記憶體必須事先
預留出來。如果kdump發現系統沒有為capture kernel預留記憶體,就壓根兒不肯起來工作。
capture kernel要做的事情相對比較簡單,用不了太多記憶體,至于預留多少比較合适,可以結合系統總記憶體的大小進行指定,或者相信核心,直接設定為"auto",讓核心自己計算。
那核心“自動”算出來的到底是多少呢?從"/proc/iomem"中檢視預留記憶體的實體分布,然後算一下,嗯,是161MB。
其實也不用手動算啦,在dmesg的輸出裡面,核心已經告訴我們啦:
萬一dmesg在啟動階段的資訊被沖掉了,那也沒關系,可以換用"kdumpctl showmem"指令來檢視。
通常,産生的coredump檔案位于"/var/crash"目錄下,如果有特殊需要,可以修改""配置檔案:
檢查過記憶體是預留了的,kdump是啟動了的并且配置也沒有問題,可是怎麼系統真正crash後,"/var/crash"裡面還是空空如也?那可能是你還遺漏了對"
ulimit"的調整。用"ulimit -a"指令看一下,如果"core file size"這一項的内容為0,那麼将其設定為"unlimited"。
一般來說,coredump是記憶體經過壓縮後産生的,體積不會太大,但在一些嵌入式裝置裡,flash的容量有限,在這種情況下,可通過
網絡傳輸的方式,将coredump導出到其他位置。找到對應的配置檔案來修改傳輸方式當然是可以的,嫌麻煩的話,就借助之前介紹的cockpit管理工具:
搭配 - 調試資訊
提供給crash工具的,除了coredump檔案,還需要産生這個coredump的核心的鏡像檔案。不過,假設你直接用"extract-vmlinux"工具來解壓"/boot"目錄下的vmlinuz鏡像檔案,把得到的vmlinux喂給crash,将會得到crash的無情報錯:
提示很明顯,crash要的是包含了調試資訊的核心鏡像。如果是
自己編譯核心,那麼應該在config檔案裡設定"CONFIG_DEBUG_INFO=y"(相當于是給CFLAG加了"-g"的編譯選項)。對于發出去的release版本,不能包含debug資訊,但可以事先把debug資訊單獨提取出來,以便遇到問題時使用:
objcopy --only-keep-debug vmlinux vmlinux.debug
如果使用的是
标準Linux發行版,那麼通常從網上就能擷取到對應核心的debuginfo。以CentOS為例,可以從這個鏡像源下載下傳,安裝後的vmlinux的預設路徑為"/usr/lib/debug/lib/modules/<kernel-version>"。
這裡分享一個筆者使用crash進行線上分析時踩過的小坑。線上分析是借助"/proc/kcore"檔案來充當dumpfile的,但運作crash工具後,提示說vmlinux的版本與目前運作的核心版本不一緻:
對比了一下,明明是一樣的啊,不都是""嗎,一個字母都不差啊:
最後發現,兩者的編譯時間不一樣。同一個核心為啥編譯時間會不一樣呢?咳咳,一個是CentOS的,一個是RHEL的。看來啊,crash的檢查還是很嚴格的,差一點都不行。
分析 - 懂點彙編
因為要解析衆多的symbol資訊,crash工具的啟動時間相對還是比較長的,大概15秒左右吧。要說掌握crash那些内置指令的用法,其實并不難,借助"help <command>"或者線上的文檔,就以獲得每條指令的詳細用法和示例。而且,也不用像systemtap, bpftrace一樣,得記住一些新語言的文法和規則才能入門。
其真正的門檻隐藏在對Linux核心的重要資料結構的掌握,程序、記憶體和檔案系統的關系的了解等方面,還有就是一定的彙編基礎。目前伺服器領域還是以x86-64體系的CPU為主,不過希望大家不要因為以前教科書上介紹的x86繁雜的尋址方式,而對x86的彙編心生恐懼和抵觸情緒。
事實上,在目前普遍使用的64位系統上,segmentation機制已基本成為一具空殼,其尋址方式已經沒有那麼複雜。雖然相比于ARM,x86的彙編還是多了一些tricky的技法(比如"eax"作為隐含寄存器),但具體到使用crash工具來分析,也并不需要對其拿捏的那麼深入,了解一些基本的知識,足以應對大多數的場景。
比如最基礎的"mov"指令,将"r12"寄存器的内容複制到"rcx"寄存器,是這麼寫的:
mov %r12,%rcx (對應C語言裡"a=b")
稍微擴充一下,将"r12"寄存器存儲的位址加上8,得到一個新的位址,取這個新的記憶體位址的内容,拷貝到"rcx":
mov 0x8(%r12),%rcx (對應C語言裡"a=*(b+8)")
這麼基礎的也要講?嗯,Intel的SDM手冊裡使用的是Intel彙編文法自不用說,很多介紹x86彙編的書籍也是如此,但crash裡disassembly看到的AT&T彙編,分不清的話,怕是看個"mov"都會犯迷糊。
【函數調用和傳參】除了反彙編,crash裡還涉及到很重要的一點就是stack資訊的閱讀,而stack又跟函數的調用和傳回密切相關。上層函數(calling function/caller)通過"
callq"指令調用子函數 (called function/callee),在這個環節,主要有兩件事要做:
- 将callq的下一條指令的位址(即" return address ")壓棧(這一步相當于執行"push %rip"),當子函數執行完畢,将傳回到return address的位置繼續執行(注1)。
- 跳轉到子函數的第一條指令的位址,開始執行子函數的代碼。
子函數會首先将目前"
rbp"寄存器的值壓棧(也就是上層函數的bp),這就形成了子函數stack的第一個8位元組的内容。将"rbp"壓棧儲存,是為了在子函數的stack完全彈出後,能夠找到上層函數的stack的起始位址。
push %rbp
按照這個法則,子函數也需要通過将目前"rsp"寄存器的内容拷貝到"rbp",記錄并儲存下自己的bp值,這樣它的下一級函數才可以知道需要傳回的stack的位址。
mov %rsp %rbp
接下來"rsp"就可以自由活動了,向低位址處移動若幹位元組,以騰出要壓棧的空間,然後子函數通過push指令,将自己需要改寫的寄存器壓棧儲存(以便跳回到上層函數的時候,可以恢複這些寄存器在上層函數中的值)。
一個函數再單薄,比如scheduler(),它的stack frame也必須包含兩個部分:return address和rbp(即傳回後繼續執行的代碼位址,和繼續執行的函數的棧底指針),是以其大小也至少占據16個位元組。
在32位的x86中,寄存器數目較少,是以函數的參數都通過stack來傳遞,當進入64位的x86-64時代後,寄存器的數目有所增加,為了提高效率,函數的前面幾個參數通常使用
寄存器傳遞,多出的參數再fallback到stack傳遞的方式。
不同的OS在這一點上有不同的實作,包括Linux在内的類Unix系統會使用6個寄存器來傳遞參數(包括"rdi", "rsi", "rdx, "rcx, "r8和"r9"),而且這裡是有順序要求的,即第一個參數用rdi傳遞,第二個參數用rsi傳遞,以此類推。Windows 64采用的則是"rcx", rdx", r8"和r9"這4個寄存器(參考這篇文章)。
正由于需要用幾個指定的寄存器來傳參,是以在調用子函數之前,如果要傳遞的參數不在指定寄存器中,往往涉及到寄存器内容的騰挪轉移。
在調試的時候,寄存器傳參的方式會給直接從stack來擷取參數的值帶來困難,在後面的一篇文章中,将示範如何通過back trace,來間接地推導出函數參數的資訊。此外,還可以嘗試使用pykdump工具的fregs指令來輔助分析。
小結
得到coredump轉儲檔案,擷取debuginfo調試資訊,再裝備一些彙編知識,接下來,就勇敢地上路吧,在更多的實際案例中,抽絲剝繭,層層深入,探索crash工具在我們的“破案”過程中,那不可替代的美妙作用。
注1:某些惡意軟體會故意讓stack溢出到return address所在的棧位置,以更改return address的值,傳回的時候就會跳轉到惡意代碼所在的位置,這種攻擊手段被稱為"Buffer Overflow Attack"。
參考:原創文章,轉載請注明出處。