內核Panic和soft lockup分析及排錯

一、概述

       衆所周知,從事linux內核開發的工程師或多或少都會遇到內核panic,亦或者是soft lockup,前者多半是因爲內存泄露、內存互踩、訪問空地址等錯誤導致的,而後者可以肯定是因爲代碼的邏輯不當,進而導致內核進入一個死循環。問題可大可小,當問題足夠隱蔽又難以復現時通常會讓程序猿們十分抓狂,我前些日子有幸體驗了一把,足足花費了我一週時間才成功找到問題,爲了讓自己以後能從容的面對內核panic,也爲了能積累更多的經驗,還是用文字記錄下來纔是最好的形式。


       提到內核panic就不得不提kdump,這是對付內核panic的利器,kdump實際上是一個工具集,包括一個帶有調試信息的內核鏡像(捕獲內核),以及kexec、kdump、crash三個部分,kdump本質上是一個內核崩潰轉儲工具,即在內核崩潰時捕獲儘量多的信息生成內核core文件。工作原理如下:

       (1) kexec分爲內核態的kexec_load和用戶態的kexec-tools,系統啓動時將原內核加載到內存中,kexec_load將捕獲內核也一起加載到內存中,加載地址可在grub.conf中配置,默認爲auto,kexec-tools則將捕獲內核的加載地址傳給原內核;

       (2) 原內核系統崩潰的觸發點設置在die()、die_nmi()、panic(),前兩者最終都會調用panic(),發生崩潰時dump出進程信息、函數棧、寄存器、日誌等信息,並使用ELF文件格式編碼保存在內存中,調用machine_kexec(addr)啓動捕獲內核,捕獲內核最終轉儲core文件。捕獲內核通常可以採用兩種方式訪問原內核的內存信息,一種是/dev/oldmem,另一種則是/proc/vmcore;

       (3) crash工具提供一些調試命令,可以查看函數棧,寄存器,以及內存信息等,這也是分析問題的關鍵,下面列舉幾個常用命令。

       log:顯示日誌;

       bt:加參數-a時顯示各任務棧信息;

       sym:顯示虛擬地址對應的標誌符,或相反;

       ps:顯示進程信息;

       dis:反彙編函數或者虛擬地址,通過與代碼對比,結合寄存器地址找出出錯代碼中相關變量的地址;

       kmem:顯示內存地址對應的內容,或slab節點的信息,若出錯地方涉及slab,通過kmem則可看到slab的分配釋放情況;

       rd:顯示指定內存的內容;

       struct:顯示虛擬地址對應的結構體內容,-o參數顯示結構體各成員的偏移;

       下面借用一下別人畫的內核panic流程框圖:

       通常內核soft lockup問題不會導致內核崩潰,只有設置softlockup_panic纔會觸發panic,所以若未設置可在崩潰前自行查看系統日誌查找原因,如果查找不出原因,再借助人爲panic轉儲core文件,這時通過crash分析問題

       linux中藉助看門狗來檢測soft lockup問題,每個cpu對應一個看門狗進程,當進程出現死鎖或者進入死循環時看門狗進程則得不到調度,系統檢測到某進程佔用cpu超過20秒時會強制打印soft lockup警告,警告中包含佔用時長和進程名及pid。

       linux內核設置不同錯誤來觸發panic,其觸發選項均在/proc/sysy/kernel目錄下,包含sysrq、softlockup_panic、panic、panic_on_io_nmi、panic_on_io_nmi、panic_on_oops、panic_on_unrecovered_nmi、unknown_nmi_panic等。


二、panic實例分析

       涉及的代碼是處理DNS請求,在DNS請求中需要對重複出現的域名進行壓縮,以達到節約帶寬的目的,壓縮的思想很簡單,採用最長匹配算法,偏移量基於DNS頭地址。系統中每個cpu維護一個偏移鏈表,每次處理一個域名前都會到鏈表中查詢這個域名是否已經處理過,若處理過這時會使用一個偏移值。

       例如:www.baidu.com,鏈表中會存儲www.baidu.com、baidu.com、com三個節點,之後每次查詢鏈表時若查到則使用節點中的偏移,若未查到則將新出現的域名按照這個規律加到鏈表中。

這裏貼出基本的數據結構:

       struct data_buf {
           unsigned char* buf;
           u16 buflen;
           u16 pos;
       };

       struct dns_buf {
           struct data_buf databuf;
           struct sk_buff* skb;
           u32 cpuid;
       };

       代碼中靜態分配了50個節點,每處理一個域名時取一個節點掛入鏈表中,每次panic都發生在查詢節點函數get_offset_map(data_buf *dbuf, char *domain)的list_for_each()中。因此,猜測其可能是由於遍歷到空指針或非法地址導致的,原因應該是由於內存溢出導致的內存互踩,每次panic都是發生在晚上,一般會發生幾次。

       起初的辦法是在函數中加入判斷條件來調用BUG_ON()函數來提前觸發panic,通過驗證發現有時會出現list_head節點地址爲空,有時會出現遍歷節點數超過50次,用反彙編dis命令打印出錯節點的地址,最終也只看出了某個節點爲空,不過對第二種情況有了一些猜測,壓縮時所指向的域名可能出現了問題,在這個函數中徘徊了很久都沒有定論,最終得出結論:單純從這個函數入手是不可能找到原因的,只有打印出原始請求信息和響應信息才能進一步分析,但這個函數所傳入的信息很有限,最後只能把所有信息都存儲在data_buf這個結構體中,這是我能想到的最直接也是代碼改動最小的辦法,隨着問題的深入這個結構體已經達到了幾十個變量,甚至超過了2k大小,也就是超過了函數棧的大小。問題向上追溯到了pskb_expand_head()函數,在這個函數之前打印原始的skb內容,之後打印當前處理的skb內容,發現兩次skb的內容不同,當時就誤認爲問題出在pskb_expand_head()函數上,下面貼出當時的分析報告:

       從目前分析是因爲收到請求包中的edns選項部分數據不正確,幷包含大量垃圾數據,這些請求通常來自巴西(200.13.144.4),當請求累積到一定數量時可能就會導致死機,定位到出錯代碼位於expand_dnsbuf()中,這個函數中又調用了pskb_expand_head()函數,初步推測問題出在pskb_expand_head()函數中。


       出錯現象表現爲擴展前的SKB數據正確,而擴展後SKB數據已經改變,如原始數據包長度爲500(長度不定,240~500不等,正常請求不應該爲這麼長),請求中dnsid爲0x7ebf,問題區和附加區計數均爲1,請求域名爲lbs.moji.com,擴展後dnsid仍爲0x7ebf,問題區仍爲1,附加區則變爲0,請求域名則變爲zj.dcys.ksmobile.com,也就是可能另一個正常請求(不帶edns)覆蓋了當前(帶edns,幷包含大師垃圾數據)的dnsbuf,但DNSID卻未被覆蓋,同時覆蓋的域名都比原始域名長,當查詢到答案後putwired請求域名,此時原域名的'\0'被覆蓋,在壓縮請求域名時檢測不到'\0',因此導致offset數組溢出或者地址錯誤,最終死在get_offset_map()函數中。

       兩次skb內容對比如下:

       紅框中依次爲dnsid,附加區數量,請求域名('\0'),pos爲當前要寫的位置,很顯然'\0'已被覆蓋,pos之後爲edns選項,00 29正確,表示edns選項,10 00表示負載長度4096,80 00爲Z標誌,一般爲0,之後是edns長度,這裏爲0,一般爲11或者12,所以由此看來edns選項數據不正確,之後還有很多垃圾數據。


       其它問題:在本機進行構造這種數據包時並未導致服務器死機,有時有迴應,有時則無迴應,但迴應的數據包依然存在錯誤,有時是權威區中域名被更改,有時附加段的edns出現在了應答區或者權威區,如下圖:


       根本原因:因爲這是偶然現象,並且當請求量特別大時纔可能出現,在不死機的情況下很難打印信息,所以目前並未找到根本原因。


       解決辦法:對這種請求可以校驗edns選項的合法性以及檢驗數據長度,如出現這種數據包直接丟掉;或將請求域名存入數組中,必要時可以在迴應響應包時從DNS頭部重寫數據,但不知此種辦法是否會導致其它內存錯誤。


       當時基本上已經快放棄了,剛開始以爲這是因爲內存互踩導致的,所以現象應該不好復現不過後來通過發送大量帶有垃圾數據的請求已經能夠復現了,可見這不是偶爾現象,而是必然現象。當時一直認爲是內存被修改,而沒有考慮地址的指向是否錯誤,因爲響應時數據是部分出錯,而dnsid,問題區的域名只有讀的操作卻沒有寫的操作,最大的迷惑是未找到內存是在何處被修改的,所以猜測應該是在擴展skb函數中數據拷貝後被修改的,沒有辦法只能把內核中的pskb_expand_head()函數搬到模塊中,在這個函數中排錯,結果意外的發現,拷貝一直到退出這個函數後數據都沒有改變,接着又迷惑了。再後來無意中打印原始的dnsbuf地址,發現其與擴展前的ip頭地址相差很大,這時才發現dnsbuf的地址可能在某處被修改了,經過不斷的復現現象,最終定位在skb_linearize()函數中,真是突然之間柳暗花明,問題的根本原因是因爲skb線性化函數使用後沒有更改地址導致的。


        在skb_linearize()函數中

        1)如果是正常請求,一般head到end之間的線性空間就夠用,並且大多也不存在分片的情況,線性化後線性化地址和內存空間保護不變;

        2)如果請求中帶有大量垃圾數據,那麼線性空間通常會不夠用,並且存在分片內容,此時在線性化函數中會重新分配線性空間。

       第二種即爲異常情況,此時skb中head,data,iph等指針已發生變化,而原代碼中一直在使用已經被釋放的地址,若被釋放的地址沒被重新分配,此時返回的可能是部分出錯的響應包,當收到大量這種請求包時系統通常會死機。

       具體代碼如下

       在代碼中加入iph = ip_hdr(skb);這行代碼即可解決問題,小小的一行代碼害苦了好多人,當然我是受傷最重的,在這裏要牢記skb線性化後一定要重新獲取ip頭。


       總結:排錯經驗就是crash vmcore鏡像+猜測原因+多加打印信息+想辦法重現現象。先從導致內核崩潰的代碼入手,因爲是線上環境導致的,所以很難測試,因此一定要猜測哪些因素導致的,選擇好要打印的信息,並想辦法傳入最終崩潰的函數中,在崩潰的函數中加入判斷崩潰的條件,打印完信息後調用BUG_ON()讓系統崩潰生成crash文件。本來已經到了分析的瓶頸了,因爲之前一直以爲skb內存地址在某處被修改,並一度以爲是在pskb_expand_head()處導致的,後來還把內核中的函數搬到模塊中,以便輸出信息,沿着這條路最終發現是一條死路,之後只能從skb中各指針的地址,以及dnsbuf的地址,ip頭的地址開始排查,終於發現是ip頭的地址發生了改變,最終才找到問題的根本原因。另一個最大的感受就是一定要想辦法復現現象,因爲線上的環境很複雜,而且這個現象是偶爾現象,每天都是晚上才發生兩三次,這也導致問題更加難解決,最終通過構造這種數據包,並大量的發送給服務器才復現了現象。


三、soft lockup實例

       同事最近遇到一個soft lockup問題,他就藉助了dis反彙編,並與原代碼進行對比

       彙編中看出出錯的節點地址存在r10寄存器中,r10的地址爲88085076de40,接着藉助struct命令查看結構體中成員的賦值情況

       到這裏發現了原來是在遍歷鏈表時遍歷到某個節點時它的前驅和後繼都指向自己,因此發生了死循環,順着代碼的流程發現,原來是這個節點執行了兩次list_add_tail(),問題迎刃而解。


       這個案例相對上面那個panic案例的情況要簡單一些,出錯的問題是圍繞着鏈表的操作,最後發現是代碼的邏輯問題,相對來說問題不那麼隱蔽。通過這兩個案例我學到了不少排錯經驗,有一個清晰的排錯思路很關鍵,否則在煩躁中很難定位出問題的所在。


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