我的部落格:http://blog.striveforfreedom.net
Table of Contents
1 BUG描述
最近修改一C程式,在一個結構體裡加入了幾個新的欄位,編譯完一跑竟然出現段錯誤(segmentation fault)崩潰了。用gdb查看,引發崩潰的是一條這樣的指令:mov register offset(%rsp)。
2 解決過程
從引發崩潰的指令可以看出,崩潰的原因是訪問了棧上的記憶體,然而通常來說訪問棧上記憶體是不會導致段錯誤的,因為棧上記憶體不需要程式員手動管理,一般來說很難出錯。猜測有可能是棧溢出了,需要證實這個想法。發生崩潰的機器是X86_64+Linux,用ulimit -s得知進程棧預設的soft limit是10MB,因為程式碼並沒有調用setrlimit調整過棧的soft limit,於是需要證明出現段錯誤的進程棧大於10MB了,導致崩潰的地址可以從gdb中查看,如果知道棧的起始地址(棧底),兩者之差就是棧的大小。但如何才能知道棧的起始地址呢?我們知道Linux有個proc檔案系統,系統裡每個進程在/proc下都有一個以進程ID命名的檔案夾,/proc/PID下包含的都是進程ID為PID的進程的相關資訊,比如說該進程對應的可執行檔路徑/目前的目錄/開啟的檔案等。其中/proc/PID/maps包含了該進程所有虛擬地區的起始和結束位址,包括棧(即檔案maps裡最後一個欄位為[stack]對應的那一行)。拿到了棧的起始地址之後,用起始地址減去引發崩潰的那條指令中訪問的記憶體位址(即rsp加一個位移),得到的值果然大於10MB了。至於棧溢出的原因,原有代碼在棧上定義了一個結構體的數組,而我在結構體裡面加了幾個size比較大的欄位,因此溢出了。找到原因,BUG修改起來就很簡單了,要麼在shell裡修改棧預設的soft limit,要麼在代碼裡調用setrlimit,要麼在堆上分配記憶體。
為了說明這個BUG,我寫一段測試代碼作為例子,代碼如下:
int main(int argc, char* argv[]){ const unsigned len = 10 * (1U << 20); char data[len]; data[0] = 'a'; return 0;}
編譯完一運行,出乎意料的是,進程竟然沒崩潰!這就非常奇怪了,因為我在main函數裡定義了10MB大小的數組(並且訪問了第一個元素,即最地址最小的那個),且不說環境變數所佔空間,單這個數組加上C運行庫調用序列所佔空間就超過10MB了,而棧soft limit是10MB,按理說必然崩潰。然而實際卻沒有崩潰,一開始我懷疑代碼被最佳化掉了,用objdump一看並沒有最佳化掉,接著想了很久都沒有頭緒,最後終於想起去查看核心代碼,看看核心到底是怎麼處理棧溢出的。這包含兩個方面,一是棧的soft limit是怎麼讀取出來的,二是核心怎麼檢查棧大小是否超過soft limit了。發生崩潰的機器上裝的是CentOS 5.7,用uname -r得到的核心版本是2.6.18-308.el5,這個版本號碼跟官方核心版本號碼對應不上,因為感覺應該很接近2.6.18,於是就查看了官方核心2.6.18的代碼(推薦下 lxr.linux.no ,查看某個版本核心代碼很方便,不用去下載幾十M的源碼包了)。
讀取資源限制(soft limit & hard limit)是由系統調用getrlimit完成的,getrlimit在核心中的入口是sys_getrlimit,代碼如下:
asmlinkage long sys_getrlimit(unsigned int resource, struct rlimit __user *rlim){ if (resource >= RLIM_NLIMITS) return -EINVAL; else { struct rlimit value; task_lock(current->group_leader); value = current->signal->rlim[resource]; task_unlock(current->group_leader); return copy_to_user(rlim, &value, sizeof(*rlim)) ? -EFAULT : 0; }}
這個函數很簡單,就是把當前進程的某種資源限制讀出來,並複製到使用者空間,沒有發現什麼問題。
對棧的大小限制的檢查是在頁面異常(page fault)處理中完成的,從頁面異常入口page_fault開始,查看調用序列page_fault > do_page_fault > expand_stack > acct_stack_growth,在函數acct_stack_growth中發現了對棧大小限制進行檢查的代碼,如下(省略了跟我們這個例子無關的代碼):
static int acct_stack_growth(struct vm_area_struct * vma, unsigned long size, unsigned long grow){ //... struct rlimit *rlim = current->signal->rlim; //... /* Stack limit test */ if (size > rlim[RLIMIT_STACK].rlim_cur) return -ENOMEM; //...}
其中,參數size是棧的起始地址減去當前引發頁面異常的地址並按頁大小向上對齊的,很顯然,這裡如果發現棧大小大於soft limit就返回錯誤,最終會給當前進程發送SIGSEGV訊號,導致進程出現段錯誤崩潰。上面的核心代碼說明我的想法是正確的,然而進程並未和我預料的那樣崩潰,一想可能是核心版本不對,於是又查看了官方2.6.19版的代碼,發現這兩處的代碼並沒有改過。這就很奇怪了,過了一會我突然想到,機器上裝的是CentOS,CentOS可能修改了這處的官方核心代碼,於是我下載了和我系統對應的源碼包kernel-2.6.18-308.el5.src.rpm,安裝之後,發現該版本對應的官方核心版本是2.6.18.4,CentOS的修改過的代碼放在一個patch檔案kernel-2.6.18-redhat.patch裡,運行patch之後,果然發現CentOS修改了acct_stack_growth函數,修改如下(該函數有多處修改,這裡只列出了跟我們這個BUG相關的修改):
static int acct_stack_growth(struct vm_area_struct * vma, unsigned long size, unsigned long grow){ //... struct rlimit *rlim = current->signal->rlim; //... /* Stack limit test */ if (over_stack_limit(size)) return -ENOMEM; //...}
一比較就可以發現官方核心代碼是直接比較size和棧的soft limit,而CentOS把這個比較放進了函數over_stack_limit裡,再來看函數over_stack_limit:
static int over_stack_limit(unsigned long sz){ if (sz < EXEC_STACK_BIAS) return 0; return (sz - EXEC_STACK_BIAS) > current->signal->rlim[RLIMIT_STACK].rlim_cur;}
其中EXEC_STACK_BIAS是一個整型常量,定義如下:
#define EXEC_STACK_BIAS (2*1024*1024)
很顯然,CentOS把棧大小限制從soft limit往上提高了2MB,如果棧大小超過棧的soft limit+EXEC_STACK_BIAS(在我們這個例子中為12MB)則說明棧溢出。到此真相大白,把上面的測試代碼修改一下(把數組大小改為12MB),再一運行進程果然崩潰。
3 小結
有時候接近問題的真相,但並沒有發現問題的全部,就這個問題來說,如果我不寫這篇文章,也就不會去寫上面那段測試代碼,就會想當然地認為在我的機器上棧的預設大小限制是10MB了。