非原創。
原作者( ZX_WING(xing5820@163.com)寫得很好,加上之前的確遇到過很多信號問題,產生了很多疑問,原創的兩張圖失效了,轉貼補充之。
我們引用wiki上的一段話來回答這個問題。
A segmentation fault (often shortened to SIGSEGV) is a particular error condition that can occur during the operation of computer software. A segmentation fault occurs when a program attempts to access a memory location that it is not allowed to access, or attempts to access a memory location in a way that is not allowed (for example, attempting to write to a read-only location, or to overwrite part of the operating system).
Segmentation is one approach to memory management and protection in the operating system. It has been superseded by paging for most purposes, but much of the terminology of segmentation is still used, "segmentation fault" being an example. Some operating systems still have segmentation at some logical level although paging is used as the main memory management policy.
On Unix-like operating systems, a process that accesses an invalid memory address receives the SIGSEGV signal. On Microsoft Windows, a process that accesses invalid memory receives the STATUS_ACCESS_VIOLATION exception.
上述文字沒有給出SIGSEGV的定義,僅僅說它是“計算機軟件操作過程中的一種錯誤情況”。文字描述了SIGSEGV在何時發生,即“當程序試圖訪問不被允許訪問的內存區域(比如,嘗試寫一塊屬於操作系統的內存),或以錯誤的類型訪問內存區域(比如,嘗試寫一塊只讀內存)。這個描述是准確的。為了加深理解,我們再更加詳細的概括一下SIGSEGV。
Ø SIGSEGV是在訪問內存時發生的錯誤,它屬於內存管理的范疇
Ø SIGSEGV是一個用戶態的概念,是操作系統在用戶態程序錯誤訪問內存時所做出的處理。
Ø 當用戶態程序訪問(訪問表示讀、寫或執行)不允許訪問的內存時,產生SIGSEGV。
Ø 當用戶態程序以錯誤的方式訪問允許訪問的內存時,產生SIGSEGV。
從用戶態程序開發的角度,我們並不需要理解操作系統復雜的內存管理機制,這是和硬件平台相關的。但是,了解內核發送SIGSEGV信號的流程,對我們理解SIGSEGV是很有幫助的。在《Understanding Linux Kernel Edition 3》和《Understanding the Linux Virtual Memory Manager》相關章節都有一幅總圖對此描述,對比之下,筆者認為ULK的圖更為直觀。
圖1. SIGSEGV Overview
圖1紅色部分展示了內核發送SIGSEGV信號給用戶態程序的總體流程。當用戶態程序訪問一個會引發SIGSEGV的地址時,硬件首先產生一個page fault,即“缺頁異常”。在內核的page fault處理函數中,首先判斷該地址是否屬於用戶態程序的地址空間[*]。以Intel的32bit IA32架構的CPU為例,用戶態程序的地址空間為[0,3G],內核地址空間為[3G,4G]。如果該地址屬於用戶態地址空間,檢查訪問的類型是否和該內存區域的類型是否匹配,不匹配,則發送SIGSEGV信號;如果該地址不屬於用戶態地址空間,檢查訪問該地址的操作是否發生在用戶態,如果是,發送SIGSEGV信號。
[*]這里的用戶態程序地址空間,特指程序可以訪問的地址空間范圍。如果廣義的說,一個進程的地址空間應該包括內核空間部分,只是它不能訪問而已。
圖2更為詳細的描繪了內核發送SIGSEGV信號的流程。在這里我們不再累述圖中流程,在后面章節的例子中,筆者會結合實際,描述具體的流程。
圖2 SIGSEGV detailed flow
經常看到有帖子把兩者混淆,而這兩者的關系也確實微妙。在此,我們把指針運算(加減)引起的越界、野指針、空指針都歸為指針越界。SIGSEGV在很多時候是由於指針越界引起的,但並不是所有的指針越界都會引發SIGSEGV。一個越界的指針,如果不解引用它,是不會引起SIGSEGV的。而即使解引用了一個越界的指針,也不一定會引起SIGSEGV。這聽上去讓人發瘋,而實際情況確實如此。SIGSEGV涉及到操作系統、C庫、編譯器、鏈接器各方面的內容,我們以一些具體的例子來說明。
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 int main() { 5 char* s = "hello world"; 6 7 s[1] = 'H'; 8 }
這是最常見的一個例子。想當年俺對C語言懵懂的時候,也在校內的BBS上發帖問過,當時還以為這是指針和數組的區別。此例中,”hello world”作為一個常量字符串,在編譯后會被放在.rodata節(GCC),最后鏈接生成目標程序時.rodata節會被合並到text segment與代碼段放在一起,故其所處內存區域是只讀的。這就是錯誤的訪問類型引起的SIGSEGV。
其在圖2中的順序為:
1 -> 3 -> 4 -> 6 -> 8 -> 11 ->10
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 int main() { 5 int* p = (int*)0xC0000fff; 6 7 *p = 10; 8 }
在這個例子中,我們訪問了一個屬於內核的地址(IA32,32bit)。當然,很少會有人這樣寫程序,但你的程序可能在不經意的情況下做出這樣的行為(這個不經意的行為在后面討論)。此例在圖2的流程:
1 -> 2 -> 11 -> 10
最常見的情況不外乎解引用空指針了,如:
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 int main () { 5 int *a = NULL; 6 7 *a = 1; 8 }
在實際情況中,此例中的空指針可能指向用戶態地址空間,但其所指向的頁面實際不存在。其產生SIGSEGV在圖2中的流程為:
1 -> 3 -> 4 -> 5 -> 11 ->10
這也是CU常見的一個月經貼。大部分C語言教材都會告訴你,當從一個函數返回后,該函數棧上的內容會被自動“釋放”。“釋放”給大多數初學者的印象是free(),似乎這塊內存不存在了,於是當他訪問這塊應該不存在的內存時,發現一切都好,便陷入了深深的疑惑。
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 int* foo() { 5 int a = 10; 6 7 return &a; 8 } 9 10 int main() { 11 int* b; 12 13 b = foo(); 14 printf ("%d\n", *b); 15 }
當你編譯這個程序時,會看到“warning: function returns address of local variable”,GCC已經在警告你棧溢的可能了。實際運行結果一切正常。原因是操作系統通常以“頁”的粒度來管理內存,Linux中典型的頁大小為4K,內核為進程棧分配內存也是以4K為粒度的。故當棧溢的幅度小於頁的大小時,不會產生SIGSEGV。那是否說棧溢出超過4K,就會產生SIGSEGV呢?看下面這個例子:
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 char* foo() { 5 char buf[8192]; 6 7 memset (buf, 0x55, sizeof(buf)); 8 return buf; 9 } 10 11 int main() { 12 char* c; 13 14 c = foo(); 15 printf ("%#x\n", c[5000]); 16 }
雖然我們的棧溢已經超出了4K大小,可運行仍然正常。這是因為C教程中提到的“棧自動釋放”實際上是改變棧指針,而其指向的內存,並不是在函數返回時就被回收了。在我們的例子中,所訪問的棧溢處內存仍然存在。無效的棧內存(即棧指針范圍外未被回收的棧內存)是由操作系統在需要時回收的,這是無法預測的,也就無法預測何時訪問非法的棧內容會引發SIGSEGV。
好了,在上面的例子中,我們的棧溢例子,無論是大於一個頁尺寸還是小於一個頁尺寸,訪問的都是已分配而未回收的棧內存。那么訪問未分配的棧內存,是否就一定會引發SIGSEGV呢?答案是否定的。
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 int main() { 5 char* c; 6 7 c = (char*)&c – 8192 *2; 8 *c = 'a'; 9 printf ("%c\n", *c); 10 }
在IA32平台上,棧默認是向下增長的,我們棧溢16K,訪問一塊未分配的棧區域(至少從我們的程序來看,此處是未分配的)。選用16K這個值,是要讓我們的溢出范圍足夠大,大過內核為進程分配的初始棧大小(初始大小為4K或8K)。按理說,我們應該看到期望的SIGSEGV,但結果卻非如此,一切正常。
答案藏在內核的page fault處理函數中:
if (error_code & PF_USER) { /* * Accessing the stack below %sp is always a bug. * The large cushion allows instructions like enter * and pusha to work. ("enter $65535,$31" pushes * 32 pointers and then decrements %sp by 65535.) */ if (address + 65536 + 32 * sizeof(unsigned long) < regs->sp) goto bad_area; } if (expand_stack(vma, address)) goto bad_area;
內核為enter[*]這樣的指令留下了空間,從代碼來看,理論上棧溢小於64K左右都是沒問題的,棧會自動擴展。令人迷惑的是,筆者用下面這個例子來測試棧溢的閾值,得到的確是70K ~ 80K這個區間,而不是預料中的65K ~ 66K。
[*]關於enter指令的詳細介紹,請參考《Intel(R) 64 and IA-32 Architectures Software Developer Manual Volume 1》6.5節“PROCEDURE CALLS FOR BLOCK-STRUCTURED LANGUAGES”
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 #define GET_ESP(esp) do { \ 5 asm volatile ("movl %%esp, %0\n\t" : "=m" (esp)); \ 6 } while (0) 7 8 9 #define K 1024 10 int main() { 11 char* c; 12 int i = 0; 13 unsigned long esp; 14 15 GET_ESP (esp); 16 printf ("Current stack pointer is %#x\n", esp); 17 while (1) { 18 c = (char*)esp - i * K; 19 *c = 'a'; 20 GET_ESP (esp); 21 printf ("esp = %#x, overflow %dK\n", esp, i); 22 i ++; 23 } 24 }
筆者目前也不能解釋其中的魔術,這神奇的程序啊!上例中發生SIGSEGV時,在圖2中的流程是:
1 -> 3 -> 4 -> 5 -> 11 -> 10 (注意,發生SIGSEGV時,該地址已經不屬於用戶態棧了,所以是5 à 11 而不是 5 -à 6)
到這里,我們至少能夠知道SIGSEGV和操作系統(棧的分配和回收),編譯器(誰知道它會不會使用enter這樣的指令呢)有着密切的聯系,而不像教科書中“函數返回后其使用的棧自動回收”那樣簡單。
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 #define K 1024 5 int main () { 6 char* c; 7 int i = 0; 8 9 c = malloc (1); 10 while (1) { 11 c += i*K; 12 *c = 'a'; 13 printf ("overflow %dK\n", i); 14 i ++; 15 } 16 }
看了棧的例子,舉一反三就能知道,SIGSEGV和堆的關系取決於你的內存分配器,通常這意味着取決於C庫的實現。
上面這個例子在筆者機器上於15K時產生SIGSEGV。讓我們改變初次malloc的內存大小,當初次分配16M時,SIGSEGV推遲到了溢出180K;當初次分配160M時,SIGSEGV推遲到了溢出571K。我們知道內存分配器在分配不同大小的內存時通常有不同的機制,這個例子從某種角度證明了這點。此例SIGSEGV在圖2中的流程為:
1 -> 3 -> 4 -> 5 -> 11 -> 10
用一個野指針在堆里胡亂訪問很少見,更多被問起的是“為什么我訪問一塊free()后的內存卻沒發生SIGSEGV”,比如下面這個例子:
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 #define K 1024 5 int main () { 6 int* a; 7 8 a = malloc (sizeof(int)); 9 *a = 100; 10 printf ("%d\n", *a); 11 free (a); 12 printf ("%d\n", *a); 13 }
SIGSEGV沒有發生,但free()后a指向的內存被清零了,一個合理的解釋是為了安全。相信不會再有人問SIGSEGV沒發生的原因。是的,free()后的內存不一定就立即歸還給了操作系統,在真正的歸還發生前,它一直在那兒。
看了上面兩個例子,我覺得這實在沒什么好講的。
這也是產生SIGSEGV的常見原因,來看下面的例子:
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 5 void foo () { 6 char c; 7 8 memset (&c, 0x55, 128); 9 } 10 11 int main () { 12 foo(); 13 }
通過棧溢出,我們將函數foo的返回地址覆蓋成了0x55555555,函數跳轉到了一個非法地址執行,最終引發SIGSEGV。非法地址執行,在圖2中的流程中的可能性就太多了,從1->3 ->4 -> … ->10,從4到10之間,幾乎每條路徑都可能出現。當然對於此例,0x55555555所指向的頁面並不在內存之中,其在圖2的流程為:
1->3 ->4 ->5-->11->10
如果非法地址對應的頁面(頁面屬於用戶態地址空間)存在於內存中,它又是可執行的[*],則程序會執行一大堆隨機的指令。在這些指令執行過程中一旦訪問內存,其產生SIGSEGV的流程幾乎就無法追蹤了(除非你用調試工具跟進)。看到這里,一個很合理的問題是:為什么程序在非法地址中執行的是隨機指令,而不是非法指令呢?在一塊未知的內存上執行,遇到非法指令可能性比較大吧,這樣應該收到SIGILL信號啊?
[*]如果不用段寄存器的type checking,只用頁表保護,傳統32bit IA32可讀即可執行。在NX技術出現后頁級也可以控制是否可以執行。
事實並非如此,我們的IA32架構使用了如此復雜的指令集,以至於找到一條非法指令的編碼還真不容易。在下例子中:
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 int main() { 5 char buf[128] = "asdfaowerqoweurqwuroahfoasdbaoseur20 234123akfhasbfqower53453"; 6 sleep(1); 7 }
筆者在buf中隨機的敲入了一些字符,反匯編其內容得到的結果是:

0xbffa9e00: popa 0xbffa9e01: jae 0xbffa9e67 0xbffa9e03: popaw 0xbffa9e05: outsl %ds:(%esi),(%dx) 0xbffa9e06: ja 0xbffa9e6d 0xbffa9e08: jb 0xbffa9e7b 0xbffa9e0a: outsl %ds:(%esi),(%dx) 0xbffa9e0b: ja 0xbffa9e72 0xbffa9e0d: jne 0xbffa9e81 0xbffa9e0f: jno 0xbffa9e88 0xbffa9e11: jne 0xbffa9e85 0xbffa9e13: outsl %ds:(%esi),(%dx) 0xbffa9e14: popa 0xbffa9e15: push $0x73616f66 0xbffa9e1a: bound %esp,%fs:0x6f(%ecx) 0xbffa9e1e: jae 0xbffa9e85 0xbffa9e20: jne 0xbffa9e94 0xbffa9e22: xor (%eax),%dh 0xbffa9e24: and %ah,(%eax) 0xbffa9e26: and %dh,(%edx) 0xbffa9e28: xor (%ecx,%esi,1),%esi 0xbffa9e2b: xor (%ebx),%dh 0xbffa9e2d: popa 0xbffa9e2e: imul $0x61,0x68(%esi),%esp 0xbffa9e32: jae 0xbffa9e96 0xbffa9e34: data16 0xbffa9e35: jno 0xbffa9ea6 0xbffa9e37: ja 0xbffa9e9e 0xbffa9e39: jb 0xbffa9e70 0xbffa9e3b: xor 0x33(,%esi,1),%esi 0xbffa9e42: add %al,(%eax) 0xbffa9e44: add %al,(%eax) 0xbffa9e46: add %al,(%eax) 0xbffa9e48: add %al,(%eax) 0xbffa9e4a: add %al,(%eax) 0xbffa9e4c: add %al,(%eax) 0xbffa9e4e: add %al,(%eax) 0xbffa9e50: add %al,(%eax) 0xbffa9e52: add %al,(%eax) 0xbffa9e54: add %al,(%eax) 0xbffa9e56: add %al,(%eax) 0xbffa9e58: add %al,(%eax) 0xbffa9e5a: add %al,(%eax) 0xbffa9e5c: add %al,(%eax) 0xbffa9e5e: add %al,(%eax)
…………………………………………………………………………
一條非法指令都沒有!大家也可以自己構造一些隨機內容試試,看能得到多少非法指令。故在實際情況中,函數跳轉到非法地址執行時,遇到SIGSEGV的概率是遠遠大於SIGILL的。
我們來構造一個遭遇SIGILL的情況,如下例:
#include <stdio.h> #include <stdlib.h> #include <string.h> #define GET_EBP(ebp) \ do { \ asm volatile ("movl %%ebp, %0\n\t" : "=m" (ebp)); \ } while (0) char buf[128]; void foo () { printf ("Hello world\n"); } void build_ill_func() { int i = 0; memcpy (buf, foo, sizeof(buf)); while (1) { /* * Find *call* instruction and replace it with * *ud2a* to generate a #UD exception */ if ( buf[i] == 0xffffffe8 ) { buf[i] = 0x0f; buf[i+1] = 0x0b; break; } i ++; } } void overflow_ret_address () { unsigned long ebp; unsigned long addr = (unsigned long)buf; int i; GET_EBP (ebp); for ( i=0; i<16; i++ ) memcpy ((void*)(ebp + i*sizeof(addr)), &addr, sizeof(addr)); printf ("ebp = %#x\n", ebp); } int main() { printf ("%p\n", buf); build_ill_func (); overflow_ret_address (); }
我們在一塊全局的buf里填充了一些指令,其中有一條是ud2a,它是IA32指令集中用來構造一個非法指令陷阱。在overflow_ret_address()中,我們通過棧溢出覆蓋函數的返回地址,使得函數返回時跳轉到buf執行,最終執行到ud2a指令產生一個SIGILL信號。注意此例使用了ebp框架指針寄存器,在編譯時不能使用-fomit-frame-pointer參數,否則得不到期望的結果。
這是一種較為特殊的情況。特殊是指前面的例子訪問非法內存都發生在用戶態。而此例中,對非法內存的訪問卻發生在內核態。通常是執行copy_from_user()或copy_to_user()時。其流程在圖2中為:
1 -> …. -> 11 -> 12 -> 13
內核使用fixup[*]的技巧來處理在處理此類錯誤。ULK說通常的處理是發送一個SIGSEGV信號,但實際大多數系統調用都可以返回EFAULT(bad address)碼,從而避免用戶態程序被終結。這種情況就不舉例了,筆者一時間想不出哪個系統調用可以模擬此種情況而不返回EFAULT錯誤。
[*]關於fixup的技巧可以參考筆者另一篇文章《Linker Script in Linux》,http://linux.chinaunix.net/bbs/viewthread.php?tid=1032711&extra=page%3D2%26amp%3Bfilter%3Ddigest
我們已經總結了產生SIGSEGV的大多數情況,在實際編程中,即使現象不一樣,最終發生SIGSEGV的原因都可以歸到上述幾類。掌握了這些基本例子,我們可以避免大多數的SIGSEGV。
良好的編程習慣永遠是最好的預防方法。良好的習慣包括:
盡量按照C標准寫程序。之所以說是盡量,是因為C標准有太多平台相關和無定義的行為,而其中一些實際上已經有既成事實的標准了。例如C標准中,一個越界的指針導致的是無定義的行為,而在實際情況中,一個越界而未解引用的指針是不會帶來災難后果的。借用CU的一個例子,如下:
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 int main () { 5 char a[] = "hello"; 6 char* p; 7 8 for ( p = a+5; p>=a; p-- ) 9 printf ("%c\n", *p); 10 11 }
雖然循環結束后,p指向了數組a前一個元素,在C標准中這是一個無定義的行為,但實際上程序卻是安全的,沒有必要為了不讓p成為一個野指針而把程序改寫為:
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 int main () { 5 char a[] = "hello"; 6 char* p; 7 8 for ( p = a+5; p!=a; p-- ) { 9 printf ("%c\n", *p); 10 } 11 printf ("%c\n", *p); 12 }
當然,或許世界上真有編譯器會對“越界但未解引用”的野指針進行處理,例如引發一個SIGSEGV。筆者無法100%保證,所以大家在實踐中還是各自斟酌吧。
徹底的懂得你的程序。和其它程序員不同的是,C程序員需要對自己的程序完全了解,做到精確控制。尤其在內存的分配和釋放方面。在操作每一個指針前,你都應該清楚它所指向內存的出處(棧、堆、全局區),並清楚此內存的生存周期。只有明白的使用內存,才能最大限度的避免SIGSEGV的產生。
大量使用assert。筆者偏好在程序中使用大量的assert,凡是有認為不該出現的情況,筆者就會加入一個assert做檢查。雖然assert無法直接避免SIGSEGV,但它卻能盡早的拋出錯誤。離錯誤越近,就越容易root cause。很多時候出現SIGSEGV時,程序已經跑飛很遠了。
打開-Wall –Werror編譯選項。如果程序是自己寫的,0 warning應該始終是一項指標(0 warning不包括因為編譯器版本不同而引起的warning)。一種常見的SIGSEGV來源於向函數傳入了錯誤的參數類型。例如:
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 5 int main () { 6 char buf[12]; 7 int buff; 8 9 strcpy (buff, "hello"); 10 11 }
這個例子中,本意是要向buf拷貝一個字符串,但由於有一個和buf名稱很相近的buff變量,由於一個筆誤(這個筆誤很可能就來自你編輯器的自動補全,例如vim的ctrl – p, ctrl – n),strcpy如願的引發了SIGSEGV。實際在編譯期間,編譯器就提示我們warning: passing argument 1 of `strcpy' makes pointer from integer without a cast,但我們忽略了。
這就進一步要求我們盡量使用編譯器的類型檢查功能,包括多用函數少用宏(特別是完成復雜功能的宏),函數參數多用帶類型的指針,少用void*指針等。此例就是我們在2.2節提到的不經意的行為。
少用奇技淫巧,多用標准方法。好的程序應該邏輯清楚,干凈整潔,像一篇朗朗上口的文章,讓人一讀就懂。那種充滿晦澀語法、怪異招數的試驗作品,是不受歡迎的。很多人喜歡把性能問題做為使用不標准方法的借口,實際上他們根本不知道對性能的影響如何,拿不出具體指標,全是想當然爾。筆者曾經在項目中,將一個執行頻繁的異常處理函數用匯編重寫,使該函數的執行周期從2000多個機器周期下降到40多個。滿心歡喜的提交了一個patch給該項目的maintainer,得到的答復是:“張,你具體測試過你的patch能帶來多大的性能提升嗎?如果沒有明顯的數據,我是不願意將優雅的C代碼替換成這晦澀的匯編的。”於是我做了一個內核編譯來測試patch,耗時15分鍾,我的patch帶來的整體性能提升大約為0.1%。所以,盡量寫清楚明白的代碼,不僅有利於避免SIGSEGV,也利於在出現SIGSEGV后進行調試。
當你的一個需求,標准的方法不能滿足時,只有兩種可能:1.從一開始的設計就錯了,才會導致錯誤的需求;2.你讀過的代碼太少,不知道業界解決該問題的標准方法是什么。計算機已經發展了幾十年,如果你不是在做前沿研究,遇到一定得用非標准方法解決的問題的機會實在太小了。正如我們經常用gdb跟蹤發現SIGSEGV發生在C庫里,不要嚷嚷說C庫有bug,大部情況是一開始你傳入的參數就錯了。
無論如何我們應該感謝SIGSEGV,是它讓我們能在不重啟機器的情況下調試程序。相比那些由於內存使用錯誤而不得不一次又一次重啟機器來debug的內核工程師,SIGSEGV讓我們的生活變得輕松。理解SIGSEGV同時,我們也更加理解程序。希望這篇文檔對初學C語言的同志有些許幫助。
EOF