轉載:Segmentation fault到底是何方妖孽

轉載一篇很不錯的分析Linux “Segmentation fault”報錯的文章
   Linux上開發時最惱火的就是遇到“Segmetation Fault”錯誤。爲什麼這麼說,很多人看到這個錯誤後心裏第一反應是程序訪問的非法的內存,導致其被操作系統強行終止。這固然沒錯,可這裏有個比較模糊的概念了:什麼叫“非法”的內存?

   程序運行時,每個進程都有自己的虛擬地址,理論上說進程應該可以隨便使用纔對,爲什麼還會出現這個錯誤呢?這裏就涉及到程序的裝載過程及原理。
   先澄清幾個概念:
   程序:一般是一組CPU指令的集合構成的文件,靜態存儲在諸如硬盤之類的存儲設備上。
   進程:當一個程序要被計算機運行時,就是在內存中產生該程序的一個運行時實例,我們就把這個實例叫做進程。
   裝載:上述從硬盤上的靜態“程序”到內存中動態的“進程”之間的轉變過程就叫做裝載。往通俗裏講,就是啓動一個進程。

   本文的主要目的是在簡單瞭解進程的內存佈局的情況下,從裝載的過程入手,深入瞭解一下Segmetation Fault在操作系統層面是如何產生的,以及程序開發過程中應該如何避免這樣的錯誤。
   衆所周知Linux中可執行文件的格式是ELF,其實編譯過程中的中間文件*.o文件、動態共享庫*.so文件也是ELF格式的。在鏈接器看來,當它通過*.o或者配合*.so文件來生成可執行文件時,它對ELF格式的文件以鏈接視圖(Linking View)進行看待。也就是說鏈接器以Section的形式來對待和處理ELF文件,諸如我們常見說的代碼段(.text)、數據段(.data和.bss)等待概念。當程序最終需要被裝載成進程時,裝載器就出場了,裝載器將可執行文件以裝載視圖(Executive View)進行看待。裝載器將以Segment的形式來處理ELF文件。網上很多教程也是這樣說的,大家可能還是理解的不是很明白,後面我們通過實例的方式將進一步向大家來澄清這兩者的區別。

   既然*.o、*.so和可執行文件都是ELF格式,那麼鏈接器和裝載器是如何區分它們的呢?
   看一個簡單的例子:


    readelf –h命令能夠可以查看一個EFL文件的頭部信息。因爲viewobj.o是編譯時的中間臨時文件,所以它的“Start of pgrogram headers”和“Number of program headers”都爲0,說明他不是一個可執行文件。取而代之的是它有9個section,所以它有“Start of section headers”和“Number of section headers”都有數據。
   再看一下動態共享庫:

    
   在Linux下動態共享庫被當作可執行文件來處理,雖然它不能單獨執行,但某些應用程序的運行離不了它。
   最後是可執行文件,這個就不用多說了,看圖:

    
   所以,我們可以得到這樣一個結論:一個具體的ELF文件,其文件頭部中的某些屬性值,指明瞭它到底是可執行文件還是可重定位文件(o和.so的統稱)。這樣,鏈接器和裝載器通過分析ELF文件頭部就可以知道它該怎麼處理該文件了。用比較直觀的、方便理解的圖來表示它們的區別就是:

    
   也就是說鏈接的時候Program Header Table是可選的,但Section Header Table是必須有的。例如*.o就沒有Program Header Table,而*.so就有。裝載的時候Program Header Table必須有,但Section Header Table是可選的,但即使有Section Header Table,裝載器也不會鳥它。

   那麼,裝載器爲什麼要採取和鏈接器不同的處理策略呢?最主要的原因是爲了提高內存的利用率。現代操作系統在裝載程序時都充分利用程序的局部性原理,那就是,當進程運行時,並不需要一下子將程序的所有代碼和數據都裝載到內存裏,而是先裝載程序的一部分到內存裏運行。當進程將要執行的指令不在內存裏的話,CPU便會觸發一個缺頁異常,操作系統捕獲到這樣的異常後便接管進程,然後將需要的指令“弄”到內存裏,再將執行權限還給進程。

   進程運行的時候,它虛擬地址空間的佈局和它所佔用的物理內存到底是什麼樣子呢?虛擬地址空間我們還比較好理解,可實際物理地址並不是我們能直接訪問到的。一般是通過一個集成在CPU內部的叫做MMU的內存管理單元完成了從進程虛擬地址到物理地址之間的映射。對這個映射過程感興趣的童鞋可以去拜讀Bean_lee兄的“Linux 從虛擬地址到物理地址”文章,那是相當之精彩。如果看不懂,就隨時諮詢他老人家。不過據我所知,他最近有點忙,忙得不亦樂乎,呵呵。OK,回到我們的話題上來。既然進程虛擬地址空間的任何地址,在使用前都必須通過MMU將其映射到物理內存上一個實實在在的存儲單元上。那麼對於任何沒有經過MMU映射過的虛擬空間的地址,不管進程是執行寫操作還是讀操作,操作系統都會捕捉到這個錯誤的非法訪問,然後輸出一個“Segmetation Fault”的錯誤提示信息並強行終止進程。

   換句話說,一個進程虛擬空間裏的任何地址,在進程訪問它之前必須要經過MMU轉換,將它映射到物理內存的某個具體的存儲位置上纔是合法有效的,不然操作系統就會用“Segmetation Fault”對你的進程進行宣判,然後將其kill掉。那麼,問題又來了,到底哪些地址纔是合法有效的呢?看一個簡單的進程虛擬地址空間的佈局:

    
   上圖是很多資料上說的Linux進程虛擬地址空間的佈局結構圖,其中0x0804800爲進程運行時的地址入口。注意,這裏的入口地址是指你的程序的第一條指令的入口地址,但是當進程運行時,進程環境空間的初始化工作,包括建立程序虛擬地址空間和物理內存的映射、加載動態庫等等操作都已經完成了。當所有準備工作就緒之後纔會跳到這個地址執行我們程序裏的第一條指令。這個0x0804800一般由鏈接器在生成可執行文件時就已經固定了,通常無需我們來更改。如果你對鏈接的過程和原理瞭如指掌,那麼你肯定也知道如何修改它了。上圖中,當用戶的程序直接訪問0x084800以前的地址、0xC0000000以後的地址或者free空間裏的地址都會觸發“Segmetation Fault”。原因如下:

1、0x084800以前的地址、0xC0000000以後的地址:由於權限的問題,不允許進程直接訪問,操作系統對其進行保護。所以用戶進程如何訪問它們的話就會觸發“Segmetation Fault”的錯誤。前面幾篇博文有如何訪問0xC0000000以後地址的博文,也就是用戶空間和內核空間的通信問題。

2、free地址段的空間就是前面說的,由於沒有經過MMU將其映射到物理內存的實際存儲單元上,當程序訪問System break(也就是常說的brk)之後的地址就出引發段錯誤。brk一般是進程空間結束的地方。那麼,我們如何知道當前進程的brk在什麼地方呢?答案就是通過一個C庫函數sbrk()來獲取。另外還有一個系統調用brk()用來設置System break的位置,其實sbrk()也可以設置,它只不是對brk()系統調用的一個封裝而已。關於這兩個函數的更多用法可以參考man手冊。

   爲了不影響我們的測試效果,我們需要將內核的隨機地址保護模式關掉。爲了方式溢出攻擊,現代很多操作系統都做了這樣的隨機地址保護。就是,當程序運行時,代碼段、堆棧段的裝載起始地址並不是固定不變的,而是每次運行進程時都會加上一個隨機的偏移量,這會影響我們的測試效果。關閉它的方法很簡單:

    [root@localhost ~]#echo “0” > /proc/sys/kernel/randomize_va_space

    如果/proc/sys/kernel/randomize_va_space爲0則表示,進程每次啓動運行時,其虛擬地址空間裏的值就是它在ELF文件裏所指定的值;如果爲1,則每次啓動時只有棧的裝載地址做隨機保護;如果爲2,表示進程每次啓動時,進程的裝載地址、brk和堆棧地址都會隨機變化。看個例子,這是網上流傳比較多的一段代碼,很具有代表性,這裏我又站在前人的肩膀上了:

    
   由於全局變量bssvar未初始化,所以當程序運行時它會被放置在.bss段,佔4字節。sbrk(0)會返回當前brk的值。爲了便於觀察,我們用了sleep(8)。下面用readelf看一下可執行文件被裝載時,Segement的情況將會是什麼樣子:


    另一方面,內存分配時是以頁爲單位,一般頁大小爲4096字節,所以從0x08048000開始是代碼段,共佔內存0x00628,即1576個字節,不足一個頁,但必須以頁爲單位,所以下一個頁,也就是數據頁必須從0x0804900開始。但上面顯示卻說數據頁從0x08049628開始,但注意最後一列Allign,指明瞭對其方式,正好是4096字節。驗證一下:



   這裏我們看到操作系統確實是以頁(4096字節)爲單位進行內存分配。有些人可能覺得奇怪,既然stack都已經有了,爲什麼沒有heap呢?原因是,默認情況,.bss段結束地址就是heap的開始地址。當源代碼中沒有諸如malloc()之類的動態內存分配函數時,在查看進程的內存映射時是看不到heap的。此時的進程空間的佈局應該如下:


    我們可以知道,當程序訪問0x0848000~0x0849FFF之間的所有數據都是OK的,當訪問到0x084A000及其之後的地址就會報“Segmetation Fault”,因爲我們的brk剛好到這裏。不信??好吧,把上面程序簡單調整一下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int bssvar;

int main(int argc, char* argv[])
{
    void *ptr = NULL;

    printf("main start = %p\n", main);
    printf("bss end =  %p\n", (long)&bssvar+4);
    ptr = sbrk(0);
    printf("current brk = %p\n", (long*)ptr);
    sleep(8);

    int i=0x08049628;
    for(;;i++)
        printf("At:0x%x-0x%x\n",i,*((char*)i));
    return 0;
}
    重新編譯運行memlayout,最後出現“Segmetation Fault”時應該是下面這個樣子:


    當你的源代碼中有用到諸如malloc()之類的動態內存申請函數時,brk的值會被相應的往高端內存的位置進行調整,這樣調整出來的一段內存就被所謂的內存管理器,也就是著名的buddy system納入管理範圍了。這樣當我們再訪問這些地址時,就不會報“Segmetation Fault”了。其實如果你看過Glibc源碼你就會驚奇的發現,malloc()最終也是通過調用brk()系統掉用來實現堆的管理。所以,如果我們把上述代碼再做一下簡單修改:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int bssvar;

int main(int argc, char* argv[])
{
    void *ptr = NULL;

    printf("main start = %p\n", main);
    printf("bss end =  %p\n", (long)&bssvar+4);
    ptr = sbrk(0);
    printf("current brk = %p\n", (long*)ptr);
    sleep(8);

    int i=0x08049628;
    brk((char*)0x804A123); //注意這行代碼
    for(;;i++)
        printf("At:0x%x-0x%x\n",i,*((char*)i));
    return 0;
}
   我們用brk()系統調用,手動把brk調整到0x804A123處,再編譯運行,你就會得到下面這樣的結果:

    
   至於是爲什麼不在0x804A123處報“Segmetation Fault”而是要跑到0x804B000處才報,原因已經不止一次的強調了,腦袋犯迷糊的童鞋還是從頭再認真看一遍吧。

   又到了該總結的時候了,可能有些童鞋都忘了這篇博文是要討論什麼話題了:
   程序之所以會時不時的出現“Segmetation Fault”的根本原因是進程訪問到了沒有訪問權限的地方,諸如內核區域或者其0x08048000之前的地方,或者由於要訪問的內存沒有經MMU進行映射所導致。而這種問題比較多的是出在malloc()之類的動態內存申請函數申請完內存,釋放後,沒有將指針設置爲NULL,而其他地方在繼續用先前申請的那塊內存時,由於內存管理系統已經將其收回,所以纔會出現這樣的問題。良好的關於指針的使用習慣是,使用之前先判斷其是否爲NULL,所有已經歸還給操作系統的內存,其訪問指針都要及時置爲NULL,防止所謂的“野指針”到處飛的情況,不然在大型項目裏,光是圍剿“Segmetation Fault”就要耗費不少兵力。

原文鏈接: http://m.blog.csdn.net/article/details?id=51550147

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