segment fault異常及常見定位手段
背景
最近boot中遇到個用戶態程序的segment fault異常,除了一句“Segment fault”打印外無其他任何打印。該問題復現概率較低,定位起來比較棘手。我們的boot是個經過裁剪的最小linux系統,由於bootflash大小的限制,加上在boot階段也沒有掛載其他儲存設備,所以沒有沒法放gdb、動態庫等體積較大的調試工具。本文以linux 3.10內核和mips cpu小系統為基礎,記錄下對這個問題的研究總結。
segment fault 異常處理流程
用戶態程序由於系統調用或異常等原因,系統陷入內核,並伴隨著CPU特權的切換和從用戶態棧到內核態棧的切換,內核調用SAVE_ALL保存陷入內核前的現場(即pt_regs結構)到內核棧上,然後內核通過查找異常跳轉表或系統調用跳轉表獲得相應的處理程序入口,處理完成後,給用戶態程序發送SIGSEGV信號,並通過pt_regs恢復現場返回到用戶態程序,用戶態程序收到SIGSEGV信號並進行處理。至此,完成全部處理流程。
可見異常前的現場信息,即pt_regs是個很重要的信息,其具體定義如下,包括CPU通用寄存器、error pc、error cause、bad address等信息。
在linux kernel中,如下場景都會觸發pt_regs壓棧動作:
- tlb異常
- NMI中斷
- 中斷
- 異常
- 系統調用
struct pt_regs { #ifdef CONFIG_32BIT /* Pad bytes for argument save space on the stack. */ unsigned long pad0[6]; #endif /* Saved main processor registers. */ unsigned long regs[32]; /* Saved special registers. */ unsigned long cp0_status; unsigned long hi; unsigned long lo; #ifdef CONFIG_CPU_HAS_SMARTMIPS unsigned long acx; #endif unsigned long cp0_badvaddr; unsigned long cp0_cause; unsigned long cp0_epc; #ifdef CONFIG_MIPS_MT_SMTC unsigned long cp0_tcstatus; #endif /* CONFIG_MIPS_MT_SMTC */ } __attribute__ ((aligned (8)));
segment fault 常見觸發源
內核會依據下列條件來判斷是否發生了用戶態段錯誤,並上報SIGSEGV信息給用戶態task:
- 用戶態數據段的地址越界
- 用戶態代碼段的指令讀取異常
- 訪問操作與所訪問的內存頁面權限不匹配
- 非對齊訪問(一般是上報SIGBUS,但mips會上報SIGSEGV)
導致段錯誤的常見編程範式有:
- 使用未初始化變量
- 使用已釋放的內存
- 數組越界
- 多進程下使用不可重入函數
- 內存被踩(如棧被踩導致pc或數據尋址錯誤等)
segment fault 常用定位手段
最佳的定位手段是能直接定位到產生異常的代碼,差一點的,至少能提供相關信息,通過分析能間接定位到異常代碼。segment fault的定位手段還是比較豐富的,但也各有優缺點,需要根據具體場景進行選用。
gdb
gdb的優點是調試手段豐富,可以逐步跟蹤調試,適用於穩定復現的故障。缺點是故障必須能必現。
coredump
coredump的優點是對於偶現的段錯誤故障,內核會導出一個coredump文件,然後可以用gdb離線調試coredump文件來定位。缺點是如果環境對段錯誤等異常有重啟保護,coredump文件需要有地方存儲。
用戶態backtrace
glibc的execinfo庫提供一套接口:backtrace、backtrace_symbols,可以通過這套接口,捕獲到SIGSEGV異常後打印異常發生時的backtrace。缺點是依賴glibc的excinfo,而各CPU對其實現支持情況不一。
內核態backtrace
內核態call trace打印一般通過stack_dump來打印,由於linux的內核態棧和用戶態棧是獨立分開了,所以stack_dump並不支持用戶態call trace打印。但內核提供了save_stack_trace_user/print_stack_trace接口,可以在異常處理程序中打印用戶態進程的調用鏈。缺點是這套接口在arch下實現,而各CPU對其實現支持情況不一。
catchsegv
catchsegv是libc提供的支持段錯誤back trace打印腳本,可以在發生SIGSEGV時直接打印出異常點的backtrace。缺點是依賴libc的libSegFault.so和addr2line工具。
pt_regs
如前文所說,pt_regs對象提供了異常發生時的error pc、error cause、bad address等信息,反匯編用戶程序後,通過error pc等信息可以找到具體的異常匯編指令和函數,分析匯編代碼找到對應的C代碼。缺點是需要人工分析匯編代碼。
回到本文一開始的問題,由於bootflash大小的限制,加上在boot階段也沒有掛載其他儲存設備,gdb、coredump、catchsegv都沒法用;libc對mipc arch下的backtrace實現有問題,用戶態backtrace也沒法用;mips arch內核沒有實現內核態backtrace的接口,所以也沒法用。所以只剩下打印pt_regs這一條路了,在上報SIGSEGV前,調用打印即可。雖然mips arch也沒有實現打印方法,不過實現很簡單,具體實現如下:
static inline void show_signal_msg(struct pt_regs *regs, unsigned long error_code, unsigned long address, struct task_struct *tsk) { unsigned long sp = regs->regs[29]; unsigned long pc = regs->cp0_epc; if (!unhandled_signal(tsk, SIGSEGV)) return; if (!printk_ratelimit()) return; printk("%s%s[%d]: segfault at %lx ip %p sp %p error %lx", task_pid_nr(tsk) > 1 ? KERN_INFO : KERN_EMERG, tsk->comm, task_pid_nr(tsk), address, (void *)pc, (void *)sp, error_code); print_vma_addr(KERN_CONT " in ", pc); printk(KERN_CONT "\n"); }
實例演示:
模擬segv:
const char* p = "abcd";
*(char*)p = ‘a‘;
內核打印:
4:<6>lxImage[1813]: segfault at 1200e5e98 ip 0000000120012628 sp 000000ffffb08140 error 1 4:<c> in lxImage[120000000+124000] 4:<c>
反匯編獲知在 BSP_RstReason_Print 函數中sb v1,0(v0) 指令對地址1200e5e98寫操作引起segment fault異常
00000001200125a0 <BSP_RstReason_Print>:
...
120012628: a0430000 sb v1,0(v0)
segment fault異常及常見定位手段