C編程常見的內存錯誤

1內存泄露編程

2.內存越界訪問小程序

3.野指針數組

4.訪問空指針網絡

5.引用未初始化的變量多線程

6.不清楚指針運算
併發

7.結構順序變化錯誤函數

8.結構大小變化引起的錯誤工具

9.分配/釋放不配對佈局

10.返回值指向臨時變量指針測試

11.試圖修改變量

12.誤解傳值與傳引用

13.重名符號

14.棧溢出

15.誤用sizeof

16.字節對齊

17.字節順序

18.多線程共享變量沒有用valotitle修飾

19.忘記函數返回值

o 內存泄露

    你們都知道,在堆上分配的內存,若是再也不使用了,應該把它釋放掉,以便後面其它地方能夠重用。在C/C++中,內存管理器不會幫你自動回收再也不使用的內存。若是你忘了釋放再也不使用的內存,這些內存就不能被重用了,這就形成了所謂的內存泄露。

把內存泄露列爲首位,倒並非由於它有多麼嚴重的後果,而由於它是最爲常見的一類錯誤。一兩處內存泄露一般不至於讓程序崩潰,也不會出現邏輯上的錯 誤,加上進程退出時,系統會自動釋放該進程全部相關的內存(共享內存除外),因此內存泄露的後果相對來講仍是比較溫和的。可是,量變會致使質變,一旦內存 泄露過多以至於耗盡內存,後續內存分配將會失敗,程序可能所以而崩潰。

如今PC機的內存夠大了,加上進程有獨立的內存空間,對於一些小程序來講,內存泄露已經不是太大的威脅。但對於大型軟件,特別是長時間運行的軟件,或者嵌入式系統來講,內存泄露仍然是致命的因素之一。

   無論在什麼狀況下,採起謹慎的態度,杜絕內存泄露的出現,都是可取的。相反,認爲內存有的是,對內存泄露聽任自流都不是負責的。儘管一些工具能夠幫助咱們檢查內存泄露問題,我認爲仍是應該在編程時就仔細一點,及早排除這類錯誤,工具只是用做驗證的手段。

 

o內存越界訪問

    內存越界訪問有兩種:一種是讀越界,即讀了不屬於本身的數據,若是所讀的內存地址是無效的,程度馬上就崩潰了。若是所讀內存地址是有效的,在讀的時 候不會出問題,但因爲讀到的數據是隨機的,它會產生不可預料的後果。另一種是寫越界,又叫緩衝區溢出,所寫入的數據對別人來講是隨機的,它也會產生不可 預料的後果。

內存越界訪問形成的後果很是嚴重,是程序穩定性的致命威脅之一。更麻煩的是,它形成的後果是隨機的,表現出來的症狀和時機也是隨機的,讓BUG的現象和本質看似沒有什麼聯繫,這給BUG的定位帶來極大的困難。

    一些工具能夠夠幫助檢查內存越界訪問的問題,但也不能太依賴於工具。內存越界訪問一般是動態出現的,即依賴於測試數據,在極端的狀況下才會出現,除 非精心設計測試數據,工具也無能爲力。工具自己也有一些限制,甚至在一些大型項目中,工具變得徹底不可用。比較保險的方法仍是在編程是就當心,特別是對於 外部傳入的參數要仔細檢查。

咱們來看一個例子:

#include <stdlib.h>

#include <string.h>

 

int main(int argc, char* argv[])

{

    char str[10];

    int array[10] = {0,1,2,3,4,5,6,7,8,9};

 

    int data = array[10];

    array[10] = data;

 

    if(argc == 2)

    {

        strcpy(str, argv[1]);

    }

 

    return 0;

}

這個例子中有兩個錯誤是新手常犯的:

其一:int array[10] 定義了10個元素大小的數組,因爲C語言中數組的索引是從0開始的,因此只能訪問array[0]到array[9],訪問array[10]就形成了越界錯誤。

其二:strcpy(str, argv[1]);這裏是否存在越界錯誤依賴於外部輸入的數據,這樣的寫法在正常下可能沒有問題,但受到一點惡意攻擊就完蛋了。除非你肯定輸入數據是在你 控制內的,不然不要用strcpy、strcat和sprintf之類的函數,而要用strncpy、strncat和snprintf代替。

 

o 野指針。

    野指針是指那些你已經釋放掉的內存指針。當你調用free(p)時,你真正清楚這個動做背後的內容嗎?你會說p指向的內存被釋放了。沒錯,p自己有變化嗎?答案是p自己沒有變化。它指向的內存仍然是有效的,你繼續讀寫p指向的內存,沒有人能攔得住你。

釋放掉的內存會被內存管理器從新分配,此時,野指針指向的內存已經被賦予新的意義。對野指針指向內存的訪問,不管是有意仍是無心的,都爲此會付出巨大代價,由於它形成的後果,如同越界訪問同樣是不可預料的。

釋放內存後當即把對應指針置爲空值,這是避免野指針經常使用的方法。這個方法簡單有效,只是要注意,固然指針是從函數外層傳入的時,在函數內把指針置爲 空值,對外層的指針沒有影響。好比,你在析構函數裏把this指針置爲空值,沒有任何效果,這時應該在函數外層把指針置爲空值。

 

o 訪問空指針。

    空指針在C/C++中佔有特殊的地址,一般用來判斷一個指針的有效性。空指針通常定義爲0。現代操做系統都會保留從0開始的一塊內存,至於這塊內存有多大,視不一樣的操做系統而定。一旦程序試圖訪問這塊內存,系統就會觸發一個異常/信號。

操做系統爲何要保留一塊內存,而不是僅僅保留一個字節的內存呢?緣由是:通常內存管理都是按頁進行管理的,沒法單純保留一個字節,至少要保留一個頁面。保留一塊內存也有額外的好處,能夠檢查諸如p=NULL; p[1]之類的內存錯誤。

在一些嵌入式系統(如arm7)中,從0開始的一塊內存是用來安裝中斷向量的,沒有MMU的保護,直接訪問這塊內存好像不會引起異常。不過這塊內存是代碼段的,不是程序中有效的變量地址,因此用空指針來判斷指針的有效性仍然可行。

 

o 引用未初始化的變量。

    未初始化變量的內容是隨機的(有的編譯器會在調試版本中把它們初始化爲固定值,如0xcc),使用這些數據會形成不可預料的後果,調試這樣的BUG也是很是困難的。

對於態度嚴謹的程度員來講,防止這類BUG很是容易。在聲明變量時就對它進行初始化,是一個好的編程習慣。另外也要重視編譯器的警告信息,發現有引用未初始化的變量,當即修改過來。

在下面這個例子中,全局變量g_count是肯定的,由於它在bss段中,自動初始化爲0了。臨時變量a是沒有初始化的,堆內存str是沒有初始化 的。但這個例子有點特殊,由於程序剛運行起來,不少東西是肯定的,若是你想把它們看成隨機數的種子是不行的,由於它們還不夠隨機。

#include <stdlib.h>

#include <string.h>

 

int g_count;

 

int main(int argc, char* argv[])

{

    int a;

    char* str = (char*)malloc(100);

 

    return 0;

}

 

o 不清楚指針運算。

    對於一些新手來講,指針經常讓他們犯糊塗。

好比int *p = …; p+1等於(size_t)p + 1嗎

老手天然清楚,新手可能就搞不清了。事實上, p+n 等於 (size_t)p + n * sizeof(*p)

指針是C/C++中最有力的武器,功能很是強大,不管是變量指針仍是函數指針,都應該很是熟練的掌握。只要有不肯定的地方,立刻寫個小程序驗證一下。對每個細節瞭然於胸,在編程時會省下很多時間。

 

o 結構的成員順序變化引起的錯誤。

    在初始化一個結構時,老手可能不多像新手那樣老老實實的,一個成員一個成員的爲結構初始化,而是採用快捷方式,如:

Struct s

{

    int   l;

    char* p;

};

 

int main(int argc, char* argv[])

{

    struct s s1 = {4, "abcd"};

 

    return 0;

}

以上這種方式是很是危險的,緣由在於你對結構的內存佈局做了假設。若是這個結構是第三方提供的,他極可能調整結構中成員的相對位置。而這樣的調整往 往不會在文檔中說明,你天然不多去關注。若是調整的兩個成員具備相同數據類型,編譯時不會有任何警告,而程序的邏輯可能相距十萬八千里了。

正確的初始化方法應該是(固然,一個成員一個成員的初始化也行):

struct s

{

    int   l;

    char* p;

};

 

int main(int argc, char* argv[])

{

    struct s s1 = {.l=4, .p = "abcd"};

 

    return 0;

}

(有的編譯器可能不支持新標準)

o 結構的大小變化引起的錯誤。

咱們看看下面這個例子:

struct base

{

    int n;

 

};

 

struct s

{

    struct base b;

    int m;

};

在OOP中,咱們能夠認爲第二個結構繼承了第一結構,這有什麼問題嗎?固然沒有,這是C語言中實現繼承的基本手法。

如今假設第一個結構是第三方提供的,第二個結構是你本身的。第三方提供的庫是以DLL方式分發的,DLL最大好處在於能夠獨立替換。但隨着軟件的進化,問題可能就來了。

當第三方在第一個結構中增長了一個新的成員int k;,編譯好後把DLL給你,你直接把它給了客戶了,讓他們替換掉老版本。程序加載時不會有任何問題,在運行邏輯可能徹底改變!緣由是兩個結構的內存佈局重疊了。

解決這類錯誤的惟一辦法就是從新編譯所有代碼。由此看來,動態庫並不見得能夠動態替換,若是你想了解更多相關內容,建議你閱讀《COM本質論》。

 

o 分配/釋放不配對。

    你們都知道malloc要和free配對使用,new要和delete/delete[]配對使用,重載了類new操做,應該同時重載類的delete/delete[]操做。這些都是書上反覆強調過的,除非當時暈了頭,通常不會犯這樣的低級錯誤。

而有時候咱們卻被矇在鼓裏,兩個代碼看起來都是調用的free函數,實際上卻調用了不一樣的實現。好比在Win32下,調試版與發佈版,單線程與多線 程是不一樣的運行時庫,不一樣的運行時庫使用的是不一樣的內存管理器。一不當心連接錯了庫,那你就麻煩了。程序可能動則崩潰,緣由在於在一個內存管理器中分配的 內存,在另一個內存管理器中釋放時就會出現問題。

 

o 返回指向臨時變量的指針

    你們都知道,棧裏面的變量都是臨時的。當前函數執行完成時,相關的臨時變量和參數都被清除了。不能把指向這些臨時變量的指針返回給調用者,這樣的指針指向的數據是隨機的,會給程序形成不可預料的後果。

下面是個錯誤的例子:

char* get_str(void)

{

    char str[] = {"abcd"};

 

    return str;

}

 

int main(int argc, char* argv[])

{

 

    char* p = get_str();

 

    printf("%s/n", p);

 

    return 0;

}

下面這個例子沒有問題,你們知道爲何嗎?

char* get_str(void)

{

    char* str = {"abcd"};

 

    return str;

}

 

int main(int argc, char* argv[])

{

 

    char* p = get_str();

 

    printf("%s/n", p);

 

    return 0;

}

 

"Hello world"做爲靜態字符串實際上存儲在數據區,但寫程序的人不知道這個地址,而程序自己知道。當某一函數以 
{ char p[] = "Hello world"; ...} 
方式使用此靜態字符串時,實際上至關於: 
char p[12]; 
strcpy(p, "Hello world"); 
.... 
p[12]是在棧裏臨時分配的。雖然p指向的內容是"Hello world", 可是這是複製品,不是原件。當函數結束,char p[]就被程序回收了,因此p[]的內容就再也不是"Hello world"了。 
但若是以char *p="Hello world"的方式使用,p指向的是靜態字符串存儲的位置,也就是說指向"Hello world"的原件,固然沒有問題了。 

若是想堅持用char p[]而不使用char *p, 有效方法必須是: 

static char p[]="Hello world"; 
return p; 

緣由我想很清楚了。static char []是靜態的,存儲在數據區。

 

o 試圖修改常量

     在函數參數前加上const修飾符,只是給編譯器作類型檢查用的,編譯器禁止修改這樣的變量。但這並非強制的,你徹底能夠用強制類型轉換繞過去,通常也不會出什麼錯。

而全局常量和字符串,用強制類型轉換繞過去,運行時仍然會出錯。緣由在於它們是放在.rodata裏面的,而.rodata內存頁面是不能修改的。試圖對它們修改,會引起內存錯誤。

下面這個程序在運行時會出錯:

int main(int argc, char* argv[])

{

    char* p = "abcd";

    *p = '1';

 

    return 0;

}

 

o 誤解傳值與傳引用

     在C/C++中,參數默認傳遞方式是傳值的,即在參數入棧時被拷貝一份。在函數裏修改這些參數,不會影響外面的調用者。如:

#include <stdlib.h>

#include <stdio.h>

 

void get_str(char* p)

{

 

    p = malloc(sizeof("abcd"));

 

    strcpy(p, "abcd");

 

    return;

}

 

int main(int argc, char* argv[])

{

    char* p = NULL;

 

    get_str(p);

 

    printf("p=%p/n", p);

 

    return 0;

}

在main函數裏,p的值仍然是空值。固然在函數裏修改指針指向的內容是能夠的。

 

o 重名符號。

    不管是函數名仍是變量名,若是在不一樣的做用範圍內重名,天然沒有問題。但若是兩個符號的做用域有交集,如全局變量和局部變量,全局變量與全局變量之 間,重名的現象必定要堅定避免。gcc有一些隱式規則來決定處理同名變量的方式,編譯時可能沒有任何警告和錯誤,但結果一般並不是你所指望的。

下面例子編譯時就沒有警告:

t.c

#include <stdlib.h>

#include <stdio.h>

 

int count = 0;

 

int get_count(void)

 

{

    return count;

}

 

main.c

 

#include <stdio.h>

 

extern int get_count(void);

 

int count;

 

int main(int argc, char* argv[])

{

    count = 10;

 

    printf("get_count=%d/n", get_count());

 

    return 0;

 

}

若是把main.c中的int count;修改成int count = 0;,gcc就會編輯出錯,說multiple definition of `count’。它的隱式規則比較奇妙吧,因此仍是不要依賴它爲好。

 

o 棧溢出。

    咱們在前面關於堆棧的一節講過,在PC上,普通線程的棧空間也有十幾M,一般夠用了,定義大一點的臨時變量不會有什麼問題。

而在一些嵌入式中,線程的棧空間可能只5K大小,甚至小到只有256個字節。在這樣的平臺中,棧溢出是最經常使用的錯誤之一。在編程時應該清楚本身平臺的限制,避免棧溢出的可能。

 

o 誤用sizeof。

    儘管C/C++一般是按值傳遞參數,而數組則是例外,在傳遞數組參數時,數組退化爲指針(即按引用傳遞),用sizeof是沒法取得數組的大小的。

從下面這個例子能夠看出:

void test(char str[20])

{

    printf("%s:size=%d/n", __func__, sizeof(str));

 

int main(int argc, char* argv[])

{

    char str[20]  = {0};

 

    test(str);

 

    printf("%s:size=%d/n", __func__, sizeof(str));

 

    return 0;

}

 

[root@localhost mm]# ./t.exe

test:size=4

main:size=20

 

o 字節對齊。

    字節對齊主要目的是提升內存訪問的效率。但在有的平臺(如arm7)上,就不光是效率問題了,若是不對齊,獲得的數據是錯誤的。

所幸的是,大多數狀況下,編譯會保證全局變量和臨時變量按正確的方式對齊。內存管理器會保證動態內存按正確的方式對齊。要注意的是,在不一樣類型的變量之間轉換時要當心,如把char*強制轉換爲int*時,要格外當心。

另外,字節對齊也會形成結構大小的變化,在程序內部用sizeof來取得結構的大小,這就足夠了。若數據要在不一樣的機器間傳遞時,在通訊協議中要規定對齊的方式,避免對齊方式不一致引起的問題。

 

o 字節順序。

    字節順序從來是設計跨平臺軟件時頭疼的問題。字節順序是關於數據在物理內存中的佈局的問題,最多見的字節順序有兩種:大端模式與小端模式。

大端模式是高位字節數據存放在低地址處,低位字節數據存放在高地址處。

小端模式指低位字節數據存放在內存低地址處,高位字節數據存放在內存高地址處;

在普通軟件中,字節順序問題並不引人注目。而在開發與網絡通訊和數據交換有關的軟件時,字節順序問題就要特殊注意了。

 

o 多線程共享變量沒有用valotile修飾。

關鍵字valotile的做用是告訴編譯器,不要把變量優化到寄存器裏。在開發多線程併發的軟件時,若是這些線程共享一些全局變量,這些全局變量最好用valotile修飾。這樣能夠避免由於編譯器優化而引發的錯誤,這樣的錯誤很是難查。

 

o 忘記函數的返回值

函數須要返回值,若是你忘記return語句,它仍然會返回一個值,由於在i386上,EAX用來保存返回值,若是沒有明確返回,EAX最後的內容被返回,因此EAX的內容是隨機的。

相關文章
相關標籤/搜索