在Linux上編寫運行C語言程序,經常會遇到程序崩潰、卡死等異常的情況。程序崩潰時最常見的就是程序運行終止,報告Segmentation fault (core dumped)錯誤。而程序卡死一般來源於代碼邏輯的缺陷,導致了死循環、死鎖等問題。總的來看,常見的程序異常問題一般可以分為非法內存訪問和資源訪問沖突兩大類。

- 非法內存訪問(讀/寫):非法指針、多線程共享數據訪問沖突、內存訪問越界、緩沖區溢出等。
- 資源訪問沖突:棧內存溢出、堆內存溢出、死鎖等。
一、非法內存訪問
非法內存訪問是最常見的程序異樣原因,可能開發者看的“表象”不盡相同,但是很多情況下都是由於非法內存訪問引起的。
1. 非法指針
非法指針是最典型的非法內存訪問案例,空指針、指向非法地址的指針是代碼中最常出現的錯誤。

示例代碼如下:
long *ptr;
*ptr = 0; // 空指針
ptr = (long *)0x12345678;
*ptr = 100; // 非法地址訪問
無論是訪問地址為0的空指針,還是用戶態無效的地址,都會導致非法指針訪問錯誤。實際編程過程中,強制類型轉換一不小心就會產生非法指針,因此做強制類型轉換時要格外注意,最好事先做好類型檢查,以避免該問題。
2. 多線程共享數據訪問沖突
在多線程程序中,非法指針的產生可能就沒那么容易發現了。一般情況下,多個線程對共享的數據同時寫,或者一寫多讀時,如果不加鎖保證共享數據的同步訪問,則會很容易導致數據訪問沖突,繼而引發非法指針、產生錯誤數據,甚至影響執行邏輯。
示例代碼如下:
// 全局變量
long *ptr = (long *)malloc(sizeof(long));
// 線程1
if (ptr) {
*ptr = 100; // 潛在的非法地址訪問
}
// 線程2
free(ptr);
ptr = NULL;
上述代碼中,全局初始化了指針ptr,線程1會判斷該指針不為NULL時進行寫100操作,而線程2會釋放ptr指向的內存,並將ptr置為NULL。雖然線程1做了判斷處理,但是多線程環境下,則會出現線程2剛調用完free操作,還未來得及將ptr設為NULL
時,發生線程上下文切換,轉而執行線程1的寫100操作,從而引發非法地址訪問。

解決並發數據訪問沖突的方案是使用鎖同步線程。針對圖中的線程同步問題,只需要在線程1和線程2的處理邏輯前,使用讀寫鎖同步即可。操作系統或者gcc的庫函數內也存在很多線程不安全的API,在使用這些API時,一定要仔細閱讀相關的API文檔,使用線程鎖進行同步訪問。
3. 內存訪問越界
內存訪問越界經常出現在對數組處理的過程中。本身C語言並未有對數組邊界的檢查機制,因此在越界訪問數組內存時並不一定會產生運行時錯誤,但是因為越界訪問繼而引發的連鎖反應就無法避免了。
示例代碼如下:
void out_of_bound() {
long *ptr;
long buffer[] = {0};
ptr = buffer;
buffer[1] = 0; // 越界訪問導致ptr被覆蓋
ptr[0]++;
}
示例代碼在函數out_of_bound內定義了兩個變量:指針ptr和數組buffer。指針ptr指向buffer其實地址,正常情況下使用ptr[0]可以訪問訪問到buffer的第一個元素。然而對buffer[1]的越界寫操作會直接覆蓋ptr的值為0,從而導致ptr為空指針。

了解該問題的原因需要清楚局部變量在棧內的存儲機制。在函數調用時,會將調用信息、局部變量等保存在進程的棧內。棧是從高地址到低地址增長的,因此先定義的局部變量的地址一般大於后定義的局部變量地址。上述代碼中,buffer和ptr的大小都是8Byte,因此buffer[1]實際就是ptr所在的內存。這樣對buffer[1]的寫操作會覆蓋ptr的值就不足為怪了。總之,對數組訪問的時候,做好邊界檢查是重中之重。類似的問題也出現在對字符串的操作中,包括gcc提供的字符串庫函數也存在該問題,使用時需要尤其注意。
說到邊界檢查,這里引申出一個話題。在對數組處理時,經常會遇到逆序遍歷數組后n-1個元素的情況,有些時候一不小心就會這樣實現代碼:
void backScanArray(long buffer[]) {
for(unsigned int index = sizeof(buffer) / sizeof(long) - 1;
index > 0; index--) {
printf("%ld\n", buffer[index]);
}
}
乍一看,代碼邏輯幾乎沒什么問題,可是一旦buffer長度為0時,就會觸發死循環了。舉出這個極端的例子主要是為了說明數組邊界檢查時要格外小心。
4. 緩沖區溢出
緩沖區溢出攻擊是系統安全領域常見的話題,其本質還是數組越界訪問的一個特殊例子。為了方便討論,這里仍舉緩沖區在棧內存的例子。(緩沖區溢出攻擊也可以發生的堆內存中,感興趣的讀者可閱讀《0day安全軟件漏洞分析技術》一書)
我們仍使用第三節的示例代碼,不過修改了一個字符:
void stack_over_flow() {
long *ptr;
long buffer[] = {0};
ptr = buffer;
buffer[3] = 0; // 緩沖區溢出攻擊
ptr[0]++;
}
雖然只是修改了一個字符,但是行為已經和之前的代碼完全不同了。實際在函數調用時,棧內不止保存了局部變量,還包括調用參數、調用后返回地址、調用前的rbp(棧基址寄存器)的值,俗稱棧幀。

隨着buffer越界的索引不斷增大,可以覆蓋的信息可以越來越多,甚至是上級調用的函數棧幀信息都可以被覆蓋。修改buffer[3]的值意味着stack_over_flow函數調用返回后,會跳轉到buffer[3]的值對應的地址上執行,而這個地址是0,程序會直接崩潰。試想如果將該值設置為一個惡意的代碼入口地址,那么就意味着潛在的巨大系統安全風險。緩沖求溢出攻擊的具體操作方式其實更復雜,這里只是描述了其基本思想,感興趣的讀者可以參考我之前的博文《緩沖區溢出攻擊》。
二、資源訪問沖突
1. 棧內存溢出
此處的棧內存溢出和前邊討論的棧內緩沖區溢出並不是同一個概念。操作系統為每個進程分配的最大的棧內存大小是有最大上限的,因此當函數的局部變量的大小超過一定大小后(考慮到進程本身使用了部分棧內存),進程的棧內存便不夠使用了,於是就發生了溢出。

通過Linux命令可以查看當前系統設置的進程最大棧大小(單位:KB):
$ ulimit -s
8192
如果函數內申請的數組大小超過該值(實際上比該值略小),則會引發棧內存溢出異常。
另一種觸發棧內存溢出的方式是左遞歸(無限遞歸):
void left_recursive() {
left_recursive();
}
由於每次函數調用都會開辟新棧幀保存函數調用信息,而左遞歸邏輯上是不會終止的,因此總有進程棧內存被耗盡的時候,屆時便發生了棧內存溢出。
2. 堆內存溢出
堆內存溢出與棧內存溢出是同一類概念,不過進程堆空間的大小上限,因為操作系統的分頁機制,理論上只受限於機器位長,即便物理內存和swap分區大小不足,也可以通過操作系統的配置進行擴展。鑒於堆內存大小的這些性質,一般的程序不太容易觸發堆內存溢出異常。但是長期駐留內存的服務器進程,如果因為程序邏輯的缺陷,導致程序的部分內存一直申請,而得不到釋放的話,久而久之,就會觸發堆內存溢出,從而進程被操作系統強制kill掉,這就是常說的內存泄漏問題。

C語言使用malloc/free盡享堆內存的申請和釋放,開發者編寫程序時,必須小心翼翼地控制這兩對函數的調用邏輯,以防申請和釋放不對等誘發內存泄漏問題。而實際開發過程中,人工保證這樣的准確性是十分困難的,后邊我們會介紹如何使用分析工具幫我們排查程序中潛在的內存漏洞問題。
3. 死鎖
前面講到,為了解決多線程共享數據訪問沖突的問題,需要使用線程鎖同步線程的執行邏輯。而對鎖的不正當使用,同樣會產生程序異常,即死鎖。死鎖不會導致前邊所述的直接導致程序崩潰的異常,而是會掛起進程的線程,從而導致程序的部分任務卡死,不能提供正常的服務。
最典型的死鎖產生方式,就是熟知的ABBA鎖。

圖中仍使用兩個線程作為示例,假設線程1在申請完A鎖后,發生了上下文切換執行線程2,線程2申請B鎖成功后,再去申請A鎖就會失敗,從而導致線程2掛起。此時,上下文即便再次切換到線程1,線程1也無法成功申請到B鎖,從而線程1也會掛起。這樣,線程1和2都無法成功申請到自己想要的鎖,也無法釋放自己已經申請到的鎖給其他其他線程使用,從而導致死鎖。
解決此類死鎖的辦法就是讓每個線程申請鎖時是批量申請,要么一次性全部申請成功,要么一次性都不申請。還有一種辦法就是提前預知該情況的發生,不使用兩個鎖同步線程,這就需要人工費力地排查潛在的死鎖可能,當然,也有分析工具幫助開發者完成此類工作,稍后會作介紹。
三、程序異常解決方法
前面提到的程序異常類型,除了死循環和死鎖導致進程卡死之外,其他的異常都會導致進程崩潰,觸發Segmentation fault (core dumped)錯誤。Linux操作系統提供了允許程序core dumped時生成core dumped文件紀錄程序崩潰時的“進程快照”,以供開發者分析程序的出錯行為和原因,使用gdb就可以調試分析core dumped文件。而對於內存泄漏和死鎖,開源工具Valgrind提供了相關的分析功能(Valgrind也提供了大量的內存監測工具,可以和core dumped文件分析互補使用)。至於死循環可以通過gdb直接調試跟蹤解決,這里不再贅述。
1. CoreDumped異常分析
step 1:
讓程序運行崩潰時生成core dumped文件,需要對操作系統進行簡單的配置。
$ sudo ulimit -c unlimited
$ sudo echo core > /proc/sys/kernel/core_pattern
第一條命令是打開系統core dumped文件生成開關,第二條命令是將進程崩潰時生成的core dumped文件放在程序執行目錄下,並以core作為文件名前綴。
step 2:
接下來,以內存訪問越界的例子作為示例,完整代碼如下,源文件名為main.c。
void out_of_bound() {
long *ptr;
long buffer[] = {0};
ptr = buffer;
buffer[1] = 0; // 越界訪問導致ptr被覆蓋
ptr[0]++;
}
void main() {
out_of_bound();
}
step 3:
編譯運行main.c,編譯時使用需要使用-g選項,保留可執行文件調試信息,方便后續分析。
$ gcc main.c -o main -g
$ ./main
Segmentation fault (core dumped)
$ ls core.*
core.9251
我們看到程序崩潰后,生成了core dumped文件core.9251,其中9251為程序運行時進程的pid。
step 4:
調試core dumped文件。
$ gdb main core.9251
./x: line 4: 9251 Segmentation fault (core dumped) ./main
Reading symbols from demo...done.
[New LWP 9251]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
Core was generated by `./main'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x00000000004004d6 in out_of_bound () at main.c:6
6 ptr[0]++;
(gdb) backtrace
#0 0x00000000004004d6 in out_of_bound () at main.c:6
#1 0x00000000004004f5 in main () at main.c:10
(gdb) print ptr
$1 = (long *) 0x0
(gdb)
gdb輸出了程序崩潰時代碼的執行位置,main.c文件的第6行。使用backtrace命令可以打印當時的函數調用棧信息,以方便定位出錯的上層調用邏輯。使用print命令打印ptr指針的值,確實為0,與我們之前的討論一致。上面分析僅僅是一個非常簡單的示例,實際開發過程中遇到的出錯位置可能更隱蔽,甚至是庫函數的二進制代碼,屆時需要根據個人經驗來具體問題具體分析了。
2. 使用Valgrind進行內存泄漏和死鎖檢測
Valgrind是非常強大的內存調試、內存泄漏檢測以及性能分析工具,它可以模擬執行用戶二進制程序,幫助用戶分析潛在的內存泄漏和死鎖的可能邏輯。
step 1:
開源工具Valgrind提供了源碼tar包,需要下載、編譯、安裝使用(最新版本Valgrind如果編譯報錯,請將gcc更新到最新版本)。
$ wget http://valgrind.org/downloads/valgrind-3.12.0.tar.bz2
$ tar xf valgrind-3.12.0.tar.bz2
$ cd valgrind-3.12.0
$ ./configure --prefix=/usr/local/
$ make && sudo make install
$ valgrind --version
valgrind-3.12.0
step 2:
准備內存泄漏示例代碼。
#include <stdlib.h>
void main() {
malloc(4);
}
step 3:
使用Valgrind進行內存檢測。
$ valgrind --tool=memcheck --leak-check=full ./main
==24470== Memcheck, a memory error detector
==24470== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==24470== Using Valgrind-3.12.0 and LibVEX; rerun with -h for copyright info
==24470== Command: ./main
==24470==
==24470==
==24470== HEAP SUMMARY:
==24470== in use at exit: 4 bytes in 1 blocks
==24470== total heap usage: 1 allocs, 0 frees, 4 bytes allocated
==24470==
==24470== LEAK SUMMARY:
==24470== definitely lost: 0 bytes in 0 blocks
==24470== indirectly lost: 0 bytes in 0 blocks
==24470== possibly lost: 0 bytes in 0 blocks
==24470== still reachable: 4 bytes in 1 blocks
==24470== suppressed: 0 bytes in 0 blocks
==24470== Reachable blocks (those to which a pointer was found) are not shown.
==24470== To see them, rerun with: --leak-check=full --show-leak-kinds=all
==24470==
==24470== For counts of detected and suppressed errors, rerun with: -v
==24470== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
我們看到LEAK SUMMARY節內容中顯示程序退出時,仍有4B的內存可以訪問,也就是產生了內存泄漏。
step 4:
准備死鎖示例代碼,我們實現了前面討論的ABBA鎖的代碼。
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t lock_A = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock_B = PTHREAD_MUTEX_INITIALIZER;
void *run1(void *args)
{
pthread_mutex_lock(&lock_A);
sleep(1);
pthread_mutex_lock(&lock_B);
pthread_mutex_unlock(&lock_B);
pthread_mutex_unlock(&lock_A);
}
void *run2(void *args)
{
pthread_mutex_lock(&lock_B);
sleep(1);
pthread_mutex_lock(&lock_A);
pthread_mutex_unlock(&lock_A);
pthread_mutex_unlock(&lock_B);
}
void main()
{
pthread_t tid[2];
if (pthread_create(&tid[0], NULL, &run1, NULL) != 0)
{
exit(1);
}
if (pthread_create(&tid[1], NULL, &run2, NULL) != 0)
{
exit(1);
}
pthread_join(tid[0], NULL);
pthread_join(tid[1], NULL);
pthread_mutex_destroy(&lock_A);
pthread_mutex_destroy(&lock_B);
}
step 5:
編譯(需要鏈接pthread庫),並使用Valgrind進行死鎖檢測。
$ gcc main.c -o main -g -lpthread
$ valgrind --tool=helgrind ./main
==24652== Helgrind, a thread error detector
==24652== Copyright (C) 2007-2015, and GNU GPL'd, by OpenWorks LLP et al.
==24652== Using Valgrind-3.12.0 and LibVEX; rerun with -h for copyright info
==24652== Command: ./main
==24652==
Ctrl + C
==24652==
==24652== Process terminating with default action of signal 2 (SIGINT)
==24652== at 0x4E4568D: pthread_join (in /usr/lib/libpthread-2.24.so)
==24652== by 0x4C2EAA7: pthread_join_WRK (hg_intercepts.c:553)
==24652== by 0x4C32908: pthread_join (hg_intercepts.c:572)
==24652== by 0x400806: main (main.c:39)
==24652== ---Thread-Announcement------------------------------------------
==24652==
==24652== Thread #2 was created
==24652== at 0x51427AE: clone (in /usr/lib/libc-2.24.so)
==24652== by 0x4E431A9: create_thread (in /usr/lib/libpthread-2.24.so)
==24652== by 0x4E44C12: pthread_create@@GLIBC_2.2.5 (in /usr/lib/libpthread-2.24.so)
==24652== by 0x4C31810: pthread_create_WRK (hg_intercepts.c:427)
==24652== by 0x4C328FD: pthread_create@* (hg_intercepts.c:460)
==24652== by 0x4007BA: main (main.c:30)
==24652==
==24652== ----------------------------------------------------------------
==24652==
==24652== Thread #2: Exiting thread still holds 1 lock
==24652== at 0x4E4CF1C: __lll_lock_wait (in /usr/lib/libpthread-2.24.so)
==24652== by 0x4E46B44: pthread_mutex_lock (in /usr/lib/libpthread-2.24.so)
==24652== by 0x4C2EE18: mutex_lock_WRK (hg_intercepts.c:894)
==24652== by 0x4C32CE1: pthread_mutex_lock (hg_intercepts.c:917)
==24652== by 0x40073F: run1 (main.c:12)
==24652== by 0x4C31A04: mythread_wrapper (hg_intercepts.c:389)
==24652== by 0x4E44453: start_thread (in /usr/lib/libpthread-2.24.so)
==24652==
==24652== ---Thread-Announcement------------------------------------------
==24652==
==24652== Thread #3 was created
==24652== at 0x51427AE: clone (in /usr/lib/libc-2.24.so)
==24652== by 0x4E431A9: create_thread (in /usr/lib/libpthread-2.24.so)
==24652== by 0x4E44C12: pthread_create@@GLIBC_2.2.5 (in /usr/lib/libpthread-2.24.so)
==24652== by 0x4C31810: pthread_create_WRK (hg_intercepts.c:427)
==24652== by 0x4C328FD: pthread_create@* (hg_intercepts.c:460)
==24652== by 0x4007E7: main (main.c:34)
==24652==
==24652== ----------------------------------------------------------------
==24652==
==24652== Thread #3: Exiting thread still holds 1 lock
==24652== at 0x4E4CF1C: __lll_lock_wait (in /usr/lib/libpthread-2.24.so)
==24652== by 0x4E46B44: pthread_mutex_lock (in /usr/lib/libpthread-2.24.so)
==24652== by 0x4C2EE18: mutex_lock_WRK (hg_intercepts.c:894)
==24652== by 0x4C32CE1: pthread_mutex_lock (hg_intercepts.c:917)
==24652== by 0x400780: run2 (main.c:21)
==24652== by 0x4C31A04: mythread_wrapper (hg_intercepts.c:389)
==24652== by 0x4E44453: start_thread (in /usr/lib/libpthread-2.24.so)
==24652==
==24652==
==24652== For counts of detected and suppressed errors, rerun with: -v
==24652== Use --history-level=approx or =none to gain increased speed, at
==24652== the cost of reduced accuracy of conflicting-access information
==24652== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 1 from 1)
第8行日志,程序因為死鎖卡死,使用Ctrl+C強制退出。第27和48顯示:線程2和3(主線程編號為1)在退出時仍然格持有1個鎖,很明顯,這兩個線程相互死鎖了,與之前的討論一致。
總結
本文從Linux上C語言編程中遇到的異常開始討論,將異常大致分為非法內存訪問和資源訪問沖突兩大類,並對每類典型的案例做了解釋和說明,最后通過core dumped文件分析和Valgrind工具的測試,給讀者提供了遇到程序運行時異常時的解決方案。希望看到此文的讀者,在以后遇到程序異常時都能泰然自若,冷靜分析,順利地找到問題的根源,便不枉費筆者撰寫此文之心血。
參考資料
- coredump簡介與coredump原因總結: http://blog.csdn.net/newnewman80/article/details/8173770
- C語言申請內存時堆棧大小限制: http://blog.csdn.net/xxxxxx91116/article/details/10068555
- Linux malloc大內存的方法: http://www.cfanz.cn/index.php?c=article&a=read&id=103888
- valgrind 的使用簡介: http://blog.csdn.net/sduliulun/article/details/7732906
- 一個 Linux 上分析死鎖的簡單方法: http://www.ibm.com/developerworks/cn/linux/l-cn-deadlock/
