10 個內存引發(fā)的大坑,你能躲開幾個?
對程序員來說內存相關的 bug 排查難度幾乎和多線程問題并駕齊驅,當程序出現運行異常時可能距離真正有 bug 的那行代碼已經很遠了,這就導致問題定位排查非常困難,這篇文章將總結涉及內存的一些經典 bug ,快來看看你知道幾個,或者你的程序中現在有幾個。。。
返回局部變量地址
我們來看這樣一段代碼:
這段代碼非常簡單,func 函數返回一個指向局部變量的地址,main 函數中調用 fun 函數,獲取到指針后將其設置為 20。
你能看出這段代碼有什么問題嗎?問題在于局部變量 a 位于 func 的棧幀中,當 func 執(zhí)行結束,其棧幀也不復存在,因此 main 函數中調用 func 函數后得到的指針指向一個不存在的變量:

盡管上述代碼仍然可以“正?!边\行,但如果后續(xù)調用其它函數比如funcB,那么指針p指向的內容將被 funcB 函數的棧幀內容覆蓋掉,又或者修改指針 p 實際上是在破壞 funcB 函數的棧幀,這將導致極其難以排查的 bug。
錯誤的理解指針運算
這段代碼本意是想計算給定數組的和,但上述代碼并沒有理解指針運算的本意。指針運算中的加1并不是說移動一個字節(jié)而是移動一個單位,指針指向的數據結構大小就是一個單位。因此,如果指針指向的數據類型是 int,那么指針加 1 則移動 4 個字節(jié)(32位),如果指針指向的是結構體,該結構體的大小為 1024 字節(jié),那么指針加 1 其實是移動 1024 字節(jié)。

從這里我們可以看出,移動指針時我們根本不需要關心指針指向的數據類型的大小,因此上述代碼簡單的將arr += sizeof(int)改為arr++即可。
【文章福利】小編推薦自己的Linux內核技術交流群:【891587639】整理了一些個人覺得比較好的學習書籍、視頻資料共享在群文件里面,有需要的可以自行添加哦?。。。ê曨l教程、電子書、實戰(zhàn)項目及代碼)? ?


解引用有問題的指針
C語言初學者常會犯一個經典錯誤,那就是從標準輸入中獲取鍵盤數據,代碼是這樣寫的:
很多同學并不知道這樣寫會有什么問題,因為上述代碼有時并不會出現運行時錯誤。
原來 scanf 會將a的值當做地址來對待,并將從標準輸入中獲取到的數據寫到該地址中。這時接下來程序的表現就取決于a的值了,而上述代碼中局部變量a的值是不確定的,那么這時:
如果a的值作為指針指向代碼區(qū)或者其它不可寫區(qū)域,操作系統(tǒng)將立刻kill掉該進程,這是最好的情況,這時發(fā)現問題還不算很難
如果a的值作為指針指向棧區(qū),那么此時恭喜你,其它函數的棧幀已經被破壞掉了,那么程序接下來的行為將脫離掌控,這樣的 bug 極難定位
如果a的值作為指針指向堆區(qū),那么此時也恭喜你,代碼中動態(tài)分配的內存已經被你破壞掉了,那么程序接下來的行為同樣脫離掌控,這樣的bug也極難定位

讀取未初始化的內存
我們來看這樣一段代碼:
上述代碼的錯誤之處在于假設從堆上動態(tài)分配的內存總是初始化為 0,實際上并不是這樣的。我們需要知道,當調用 malloc 時實際上有以下兩種可能:
如果 malloc 自己維護的內存夠用,那么 malloc 從空閑內存中找到一塊大小合適的返回,注意,這一塊內存可能是之前用過后釋放的。在這種情況下,這塊內存包含了上次使用時留下的信息,因此不一定為0
如果 malloc 自己維護的內存不夠用,那么通過 brk 等系統(tǒng)調用向操作系統(tǒng)申請內存,在這種情況下操作系統(tǒng)返回的內存確實會被初始化為0。原因很簡單,操作系統(tǒng)返回的這塊內存可能之前被其它進程使用過,這里面也許會包含了一些敏感信息,像密碼之類,因此出于安全考慮防止你讀取到其它進程的信息,操作系統(tǒng)在把內存交給你之前會將其初始化為0。
現在你應該知道了吧,你不能想當然的假定 malloc 返回給你的內存已經被初始化為 0,你需要自己手動清空。

內存泄漏
上述代碼在申請一段內存后直接返回,這樣申請到的這塊內存在代碼中再也沒有機會釋放掉了,這就是內存泄漏。內存泄漏是一類極為常見的問題,尤其對于不支持自動垃圾回收的語言來說,但并不是說自帶垃圾回收的語言像 Java 等就不會有內存泄漏,這類語言同樣會遇到內存泄漏問題。有內存泄漏問題的程序會不斷的申請內存,但不去釋放,這會導致進程的堆區(qū)越來越大直到進程被操作系統(tǒng) Kill 掉,在 Linux 系統(tǒng)中這就是有名的 OOM 機制,Out Of Memory Killer。

幸好,有專門的工具來檢測內存泄漏出在了哪里,像valgrind、gperftools等。內存泄漏是一個很有意思的問題,對于那些運行時間很短的程序來說,內存泄漏根本就不是事兒,因為對現代操作系統(tǒng)來說,進程退出后操作系統(tǒng)回收其所有內存,這就是意味著對于這類程序即使有內存泄漏也就是發(fā)生在短時間內,甚至你根本就察覺不出來。但是對于服務器一類需要長時間運行的程序來說內存泄漏問題就比較嚴重了,內存泄漏將會影響系統(tǒng)性能最終導致進程被 OOM 殺掉,對于一些關鍵的程序來說,進程退出就意味著收入損失,特別是在節(jié)假日等重要節(jié)點出現內存泄漏的話,那么肯定又有一批程序員要被問責了。
引用已被釋放的內存
這段代碼在堆區(qū)申請了一塊內存裝入整數,之后釋放,可是在后續(xù)代碼中又再一次引用了被釋放的內存塊,此時a指向的內存保存什么內容取決于malloc 內部的工作狀態(tài):
指針a指向的那塊內存釋放后沒有被 malloc 再次分配出去,那么此時a指向的值和之前一樣
指針a指向的那塊內存已經被 malloc分配出去了,此時a指向的內存可能已經被覆蓋,那么*b得到的就是一個被覆蓋掉的數據,這類問題可能要等程序運行很久才會發(fā)現,而且往往難以定位。

循環(huán)遍歷是0開始的
這段代碼的本意是要初始化數組,但忘記了數組遍歷是從 0 開始的,實際上述代碼執(zhí)行了 n+1 次賦值操作,同時將數組 arr 之后的內存用 i 覆蓋掉了。這同樣取決于 malloc 的工作狀態(tài),如果 malloc 給到 arr 的內存本身比n*sizeof(int)要大,那么覆蓋掉這塊內存可能也不會有什么問題,但如果覆蓋的這塊內存中保存有 malloc 用于維護內存分配信息的話,那么此舉將破壞 malloc 的工作狀態(tài)。

指針大小與指針所指向對象的大小不同
這段代碼的本意是要創(chuàng)建一個n*n二維數組,但其錯誤出現在了第3行,應該是 sizeof(int *) 而不是sizeof(int),實際上這行代碼創(chuàng)建了一個包含有 n 個 int 的數組,而不是包含 n 個 int 指針的數組。但有趣的是,這行代碼在int和int*大小相同的系統(tǒng)上可以正常運行,但是對于int指針比int要大的系統(tǒng)來說,上述代碼同樣會覆蓋掉數組M之后的一部分內存,這里和上一個例子類似,如果這部分內存是 malloc 用來保存內存分配信息用的,那么也許當釋放這段內存時才會出現運行時異常,此時可能已經距離出現問題的那行代碼很遠了,這類 bug 同樣難以排查。
棧緩沖器溢出
上面這段代碼總是假定用戶的輸入不過超過 32 字節(jié),一旦超過后,那么將立刻破壞棧幀中相鄰的數據,破壞函數棧幀最好的結果是程序立刻crash,否則和前面的例子一樣,也許程序運行很長一段時間后才出現錯誤,或者程序根本就不會有運行時異常但是會給出錯誤的計算結果。實際上在上面幾個例子中也會有“溢出”,不過是在堆區(qū)上的溢出,但棧緩沖器溢出更容易導致問題,因為棧幀中保存有函數返回地址等重要信息,一類經典的黑客攻擊技術就是利用棧緩沖區(qū)溢出,其原理也非常簡單。原來,每個函數運行時在棧區(qū)都會存在一段棧幀,棧幀中保存有函數返回地址,在正常情況下,一個函數運行完成后會根據棧幀中保存的返回地址跳轉到上一個函數,假設函數A調用函數B,那么當函數B運行完成后就會返回函數A,這個過程如圖所示:

但如果代碼中存在棧緩沖區(qū)溢出問題,那么在黑客的精心設計下,溢出的部分會“恰好”覆蓋掉棧幀中的返回地址,將其修改為一個特定的地址,這個特定的地址中保存有黑客留下的惡意代碼,如圖所示:

這樣當該進程運行起來后實際上是在執(zhí)行黑客的惡意代碼,這就是利用緩沖區(qū)溢出進行攻擊的一個經典案例。
操作指針所指對象而非指針本身
arr 是一個指針數組,這段代碼的本意是要刪除掉數組中最后一個元素,同時將數組的大小減一。但上述代碼的問題在于*和--有相同的優(yōu)先級,該代碼實際上會將 size 指針減1而不是把 size 指向的值減1。如果你足夠幸運的話那么上述程序運行到*size--時立刻 crash,這樣你就有機會快速發(fā)現問題。但更有可能的是上述代碼會看上去一切正常的繼續(xù)運行并返回一個錯誤的執(zhí)行結果,這樣的bug排查起來會讓你終生難忘,因此當不確定優(yōu)先級時不要吝嗇括號,加上它。
總結
內存是計算機系統(tǒng)中至關重要的一個組成部分,C/C++這類偏底層的語言在帶來高性能的同事也帶來內存相關的無盡問題,而這類問題通常難以排查,不過知彼知己,當你理解了常見的內存相關問題后將極大減少出現此類問題的概率。
