10 個(gè)內(nèi)存引發(fā)的大坑,你能躲開(kāi)幾個(gè)?
對(duì)程序員來(lái)說(shuō)內(nèi)存相關(guān)的 bug 排查難度幾乎和多線程問(wèn)題并駕齊驅(qū),當(dāng)程序出現(xiàn)運(yùn)行異常時(shí)可能距離真正有 bug 的那行代碼已經(jīng)很遠(yuǎn)了,這就導(dǎo)致問(wèn)題定位排查非常困難,這篇文章將總結(jié)涉及內(nèi)存的一些經(jīng)典 bug ,快來(lái)看看你知道幾個(gè),或者你的程序中現(xiàn)在有幾個(gè)。。。
返回局部變量地址
我們來(lái)看這樣一段代碼:
- int fun() { int a = 2; return &a;}void main() { int* p = fun(); *p = 20;}
這段代碼非常簡(jiǎn)單,func 函數(shù)返回一個(gè)指向局部變量的地址,main 函數(shù)中調(diào)用 fun 函數(shù),獲取到指針后將其設(shè)置為 20。
你能看出這段代碼有什么問(wèn)題嗎?問(wèn)題在于局部變量 a 位于 func 的棧幀中,當(dāng) func 執(zhí)行結(jié)束,其棧幀也不復(fù)存在,因此 main 函數(shù)中調(diào)用 func 函數(shù)后得到的指針指向一個(gè)不存在的變量:
盡管上述代碼仍然可以“正常”運(yùn)行,但如果后續(xù)調(diào)用其它函數(shù)比如funcB,那么指針p指向的內(nèi)容將被 funcB 函數(shù)的棧幀內(nèi)容覆蓋掉,又或者修改指針 p 實(shí)際上是在破壞 funcB 函數(shù)的棧幀,這將導(dǎo)致極其難以排查的 bug。
錯(cuò)誤的理解指針運(yùn)算
- int sum(int* arr, int len) {
- int sum = 0;
- for (int i = 0; i < len; i++) {
- sum += *arr;
- arr += sizeof(int);
- }
- return sum;
- }
這段代碼本意是想計(jì)算給定數(shù)組的和,但上述代碼并沒(méi)有理解指針運(yùn)算的本意。指針運(yùn)算中的加1并不是說(shuō)移動(dòng)一個(gè)字節(jié)而是移動(dòng)一個(gè)單位,指針指向的數(shù)據(jù)結(jié)構(gòu)大小就是一個(gè)單位。因此,如果指針指向的數(shù)據(jù)類型是 int,那么指針加 1 則移動(dòng) 4 個(gè)字節(jié)(32位),如果指針指向的是結(jié)構(gòu)體,該結(jié)構(gòu)體的大小為 1024 字節(jié),那么指針加 1 其實(shí)是移動(dòng) 1024 字節(jié)。
從這里我們可以看出,移動(dòng)指針時(shí)我們根本不需要關(guān)心指針指向的數(shù)據(jù)類型的大小,因此上述代碼簡(jiǎn)單的將arr += sizeof(int)改為arr++即可。
解引用有問(wèn)題的指針
C語(yǔ)言初學(xué)者常會(huì)犯一個(gè)經(jīng)典錯(cuò)誤,那就是從標(biāo)準(zhǔn)輸入中獲取鍵盤數(shù)據(jù),代碼是這樣寫(xiě)的:
- int a;
- scanf("%d", a);
很多同學(xué)并不知道這樣寫(xiě)會(huì)有什么問(wèn)題,因?yàn)樯鲜龃a有時(shí)并不會(huì)出現(xiàn)運(yùn)行時(shí)錯(cuò)誤。
原來(lái) scanf 會(huì)將a的值當(dāng)做地址來(lái)對(duì)待,并將從標(biāo)準(zhǔn)輸入中獲取到的數(shù)據(jù)寫(xiě)到該地址中。這時(shí)接下來(lái)程序的表現(xiàn)就取決于a的值了,而上述代碼中局部變量a的值是不確定的,那么這時(shí):
如果a的值作為指針指向代碼區(qū)或者其它不可寫(xiě)區(qū)域,操作系統(tǒng)將立刻kill掉該進(jìn)程,這是最好的情況,這時(shí)發(fā)現(xiàn)問(wèn)題還不算很難
如果a的值作為指針指向棧區(qū),那么此時(shí)恭喜你,其它函數(shù)的棧幀已經(jīng)被破壞掉了,那么程序接下來(lái)的行為將脫離掌控,這樣的 bug 極難定位
如果a的值作為指針指向堆區(qū),那么此時(shí)也恭喜你,代碼中動(dòng)態(tài)分配的內(nèi)存已經(jīng)被你破壞掉了,那么程序接下來(lái)的行為同樣脫離掌控,這樣的bug也極難定位
讀取未初始化的內(nèi)存
我們來(lái)看這樣一段代碼:
- void add() {
- int* a = (int*)malloc(sizeof(int));
- *a += 10;
- }
上述代碼的錯(cuò)誤之處在于假設(shè)從堆上動(dòng)態(tài)分配的內(nèi)存總是初始化為 0,實(shí)際上并不是這樣的。我們需要知道,當(dāng)調(diào)用 malloc 時(shí)實(shí)際上有以下兩種可能:
如果 malloc 自己維護(hù)的內(nèi)存夠用,那么 malloc 從空閑內(nèi)存中找到一塊大小合適的返回,注意,這一塊內(nèi)存可能是之前用過(guò)后釋放的。在這種情況下,這塊內(nèi)存包含了上次使用時(shí)留下的信息,因此不一定為0
如果 malloc 自己維護(hù)的內(nèi)存不夠用,那么通過(guò) brk 等系統(tǒng)調(diào)用向操作系統(tǒng)申請(qǐng)內(nèi)存,在這種情況下操作系統(tǒng)返回的內(nèi)存確實(shí)會(huì)被初始化為0。原因很簡(jiǎn)單,操作系統(tǒng)返回的這塊內(nèi)存可能之前被其它進(jìn)程使用過(guò),這里面也許會(huì)包含了一些敏感信息,像密碼之類,因此出于安全考慮防止你讀取到其它進(jìn)程的信息,操作系統(tǒng)在把內(nèi)存交給你之前會(huì)將其初始化為0。
現(xiàn)在你應(yīng)該知道了吧,你不能想當(dāng)然的假定 malloc 返回給你的內(nèi)存已經(jīng)被初始化為 0,你需要自己手動(dòng)清空。
內(nèi)存泄漏
- void memory_leak() {
- int *p = (int *)malloc(sizeof(int));
- return;
- }
上述代碼在申請(qǐng)一段內(nèi)存后直接返回,這樣申請(qǐng)到的這塊內(nèi)存在代碼中再也沒(méi)有機(jī)會(huì)釋放掉了,這就是內(nèi)存泄漏。內(nèi)存泄漏是一類極為常見(jiàn)的問(wèn)題,尤其對(duì)于不支持自動(dòng)垃圾回收的語(yǔ)言來(lái)說(shuō),但并不是說(shuō)自帶垃圾回收的語(yǔ)言像 Java 等就不會(huì)有內(nèi)存泄漏,這類語(yǔ)言同樣會(huì)遇到內(nèi)存泄漏問(wèn)題。有內(nèi)存泄漏問(wèn)題的程序會(huì)不斷的申請(qǐng)內(nèi)存,但不去釋放,這會(huì)導(dǎo)致進(jìn)程的堆區(qū)越來(lái)越大直到進(jìn)程被操作系統(tǒng) Kill 掉,在 Linux 系統(tǒng)中這就是有名的 OOM 機(jī)制,Out Of Memory Killer。
幸好,有專門的工具來(lái)檢測(cè)內(nèi)存泄漏出在了哪里,像valgrind、gperftools等。內(nèi)存泄漏是一個(gè)很有意思的問(wèn)題,對(duì)于那些運(yùn)行時(shí)間很短的程序來(lái)說(shuō),內(nèi)存泄漏根本就不是事兒,因?yàn)閷?duì)現(xiàn)代操作系統(tǒng)來(lái)說(shuō),進(jìn)程退出后操作系統(tǒng)回收其所有內(nèi)存,這就是意味著對(duì)于這類程序即使有內(nèi)存泄漏也就是發(fā)生在短時(shí)間內(nèi),甚至你根本就察覺(jué)不出來(lái)。但是對(duì)于服務(wù)器一類需要長(zhǎng)時(shí)間運(yùn)行的程序來(lái)說(shuō)內(nèi)存泄漏問(wèn)題就比較嚴(yán)重了,內(nèi)存泄漏將會(huì)影響系統(tǒng)性能最終導(dǎo)致進(jìn)程被 OOM 殺掉,對(duì)于一些關(guān)鍵的程序來(lái)說(shuō),進(jìn)程退出就意味著收入損失,特別是在節(jié)假日等重要節(jié)點(diǎn)出現(xiàn)內(nèi)存泄漏的話,那么肯定又有一批程序員要被問(wèn)責(zé)了。
引用已被釋放的內(nèi)存
- void add() {
- int* a = (int*)malloc(sizeof(int));
- ...
- free(a);
- int* b = (int*)malloc(sizeof(int));
- *b = *a;
- }
這段代碼在堆區(qū)申請(qǐng)了一塊內(nèi)存裝入整數(shù),之后釋放,可是在后續(xù)代碼中又再一次引用了被釋放的內(nèi)存塊,此時(shí)a指向的內(nèi)存保存什么內(nèi)容取決于malloc 內(nèi)部的工作狀態(tài):
指針a指向的那塊內(nèi)存釋放后沒(méi)有被 malloc 再次分配出去,那么此時(shí)a指向的值和之前一樣
指針a指向的那塊內(nèi)存已經(jīng)被 malloc分配出去了,此時(shí)a指向的內(nèi)存可能已經(jīng)被覆蓋,那么*b得到的就是一個(gè)被覆蓋掉的數(shù)據(jù),這類問(wèn)題可能要等程序運(yùn)行很久才會(huì)發(fā)現(xiàn),而且往往難以定位。
循環(huán)遍歷是0開(kāi)始的
- void init(int n) {
- int* arr = (int*)malloc(n * sizeof(int));
- for (int i = 0; i <= n; i++) {
- arr[i] = i;
- }
- }
這段代碼的本意是要初始化數(shù)組,但忘記了數(shù)組遍歷是從 0 開(kāi)始的,實(shí)際上述代碼執(zhí)行了 n+1 次賦值操作,同時(shí)將數(shù)組 arr 之后的內(nèi)存用 i 覆蓋掉了。這同樣取決于 malloc 的工作狀態(tài),如果 malloc 給到 arr 的內(nèi)存本身比n*sizeof(int)要大,那么覆蓋掉這塊內(nèi)存可能也不會(huì)有什么問(wèn)題,但如果覆蓋的這塊內(nèi)存中保存有 malloc 用于維護(hù)內(nèi)存分配信息的話,那么此舉將破壞 malloc 的工作狀態(tài)。
指針大小與指針?biāo)赶驅(qū)ο蟮拇笮〔煌?/strong>
- int **create(int n) {
- int i;
- int **M = (int **)malloc(n * sizeof(int));
- for (i = 0; i < n; i++)
- M[i] = (int *)malloc(m * sizeof(int));
- return M;
- }
這段代碼的本意是要?jiǎng)?chuàng)建一個(gè)n*n二維數(shù)組,但其錯(cuò)誤出現(xiàn)在了第3行,應(yīng)該是 sizeof(int *) 而不是sizeof(int),實(shí)際上這行代碼創(chuàng)建了一個(gè)包含有 n 個(gè) int 的數(shù)組,而不是包含 n 個(gè) int 指針的數(shù)組。但有趣的是,這行代碼在int和int*大小相同的系統(tǒng)上可以正常運(yùn)行,但是對(duì)于int指針比int要大的系統(tǒng)來(lái)說(shuō),上述代碼同樣會(huì)覆蓋掉數(shù)組M之后的一部分內(nèi)存,這里和上一個(gè)例子類似,如果這部分內(nèi)存是 malloc 用來(lái)保存內(nèi)存分配信息用的,那么也許當(dāng)釋放這段內(nèi)存時(shí)才會(huì)出現(xiàn)運(yùn)行時(shí)異常,此時(shí)可能已經(jīng)距離出現(xiàn)問(wèn)題的那行代碼很遠(yuǎn)了,這類 bug 同樣難以排查。
棧緩沖器溢出
- void buffer_overflow() {
- char buf[32];
- gets(buf);
- return;
- }
上面這段代碼總是假定用戶的輸入不過(guò)超過(guò) 32 字節(jié),一旦超過(guò)后,那么將立刻破壞棧幀中相鄰的數(shù)據(jù),破壞函數(shù)棧幀最好的結(jié)果是程序立刻crash,否則和前面的例子一樣,也許程序運(yùn)行很長(zhǎng)一段時(shí)間后才出現(xiàn)錯(cuò)誤,或者程序根本就不會(huì)有運(yùn)行時(shí)異常但是會(huì)給出錯(cuò)誤的計(jì)算結(jié)果。實(shí)際上在上面幾個(gè)例子中也會(huì)有“溢出”,不過(guò)是在堆區(qū)上的溢出,但棧緩沖器溢出更容易導(dǎo)致問(wèn)題,因?yàn)闂斜4嬗泻瘮?shù)返回地址等重要信息,一類經(jīng)典的黑客攻擊技術(shù)就是利用棧緩沖區(qū)溢出,其原理也非常簡(jiǎn)單。原來(lái),每個(gè)函數(shù)運(yùn)行時(shí)在棧區(qū)都會(huì)存在一段棧幀,棧幀中保存有函數(shù)返回地址,在正常情況下,一個(gè)函數(shù)運(yùn)行完成后會(huì)根據(jù)棧幀中保存的返回地址跳轉(zhuǎn)到上一個(gè)函數(shù),假設(shè)函數(shù)A調(diào)用函數(shù)B,那么當(dāng)函數(shù)B運(yùn)行完成后就會(huì)返回函數(shù)A,這個(gè)過(guò)程如圖所示:
你可以在《函數(shù)運(yùn)行時(shí)在內(nèi)存中是什么樣子》這篇文章中找到關(guān)于函數(shù)運(yùn)行時(shí)棧幀的詳細(xì)講解。但如果代碼中存在棧緩沖區(qū)溢出問(wèn)題,那么在黑客的精心設(shè)計(jì)下,溢出的部分會(huì)“恰好”覆蓋掉棧幀中的返回地址,將其修改為一個(gè)特定的地址,這個(gè)特定的地址中保存有黑客留下的惡意代碼,如圖所示:
這樣當(dāng)該進(jìn)程運(yùn)行起來(lái)后實(shí)際上是在執(zhí)行黑客的惡意代碼,這就是利用緩沖區(qū)溢出進(jìn)行攻擊的一個(gè)經(jīng)典案例。
操作指針?biāo)笇?duì)象而非指針本身
- void delete_one(int** arr, int* size) {
- free(arr[*size - 1]);
- *size--;
- }
arr 是一個(gè)指針數(shù)組,這段代碼的本意是要?jiǎng)h除掉數(shù)組中最后一個(gè)元素,同時(shí)將數(shù)組的大小減一。但上述代碼的問(wèn)題在于*和--有相同的優(yōu)先級(jí),該代碼實(shí)際上會(huì)將 size 指針減1而不是把 size 指向的值減1。如果你足夠幸運(yùn)的話那么上述程序運(yùn)行到*size--時(shí)立刻 crash,這樣你就有機(jī)會(huì)快速發(fā)現(xiàn)問(wèn)題。但更有可能的是上述代碼會(huì)看上去一切正常的繼續(xù)運(yùn)行并返回一個(gè)錯(cuò)誤的執(zhí)行結(jié)果,這樣的bug排查起來(lái)會(huì)讓你終生難忘,因此當(dāng)不確定優(yōu)先級(jí)時(shí)不要吝嗇括號(hào),加上它。
總結(jié)
內(nèi)存是計(jì)算機(jī)系統(tǒng)中至關(guān)重要的一個(gè)組成部分,C/C++這類偏底層的語(yǔ)言在帶來(lái)高性能的同事也帶來(lái)內(nèi)存相關(guān)的無(wú)盡問(wèn)題,而這類問(wèn)題通常難以排查,不過(guò)知彼知己,當(dāng)你理解了常見(jiàn)的內(nèi)存相關(guān)問(wèn)題后將極大減少出現(xiàn)此類問(wèn)題的概率。希望這篇文章對(duì)大家理解內(nèi)存與指針有所幫助。
本文轉(zhuǎn)載自微信公眾號(hào)「碼農(nóng)的荒島求生」,可以通過(guò)以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系碼農(nóng)的荒島求生公眾號(hào)。