作者丨碼農(nóng)的荒島求生
來(lái)源丨碼農(nóng)的荒島求生(ID:escape-it)
對(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è)。。。
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)題在于局部變量 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。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ù)類(lèi)型是 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ù)類(lèi)型的大小,因此上述代碼簡(jiǎn)單的將arr += sizeof(int)改為arr++即可。C語(yǔ)言初學(xué)者常會(huì)犯一個(gè)經(jīng)典錯(cuò)誤,那就是從標(biāo)準(zhǔn)輸入中獲取鍵盤(pán)數(shù)據(jù),代碼是這樣寫(xiě)的:很多同學(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也極難定位
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ì)包含了一些敏感信息,像密碼之類(lèi),因此出于安全考慮防止你讀取到其它進(jìn)程的信息,操作系統(tǒng)在把內(nèi)存交給你之前會(huì)將其初始化為0。
現(xiàn)在你應(yīng)該知道了吧,你不能想當(dāng)然的假定 malloc 返回給你的內(nèi)存已經(jīng)被初始化為 0,你需要自己手動(dòng)清空。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)存泄漏是一類(lèi)極為常見(jiàn)的問(wèn)題,尤其對(duì)于不支持自動(dòng)垃圾回收的語(yǔ)言來(lái)說(shuō),但并不是說(shuō)自帶垃圾回收的語(yǔ)言像 Java 等就不會(huì)有內(nèi)存泄漏,這類(lè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。幸好,有專(zhuān)門(mén)的工具來(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ì)于這類(lèi)程序即使有內(nèi)存泄漏也就是發(fā)生在短時(shí)間內(nèi),甚至你根本就察覺(jué)不出來(lái)。但是對(duì)于服務(wù)器一類(lèi)需要長(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é)了。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ù),這類(lèi)問(wèn)題可能要等程序運(yùn)行很久才會(huì)發(fā)現(xiàn),而且往往難以定位。
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è)例子類(lèi)似,如果這部分內(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)了,這類(lèi) bug 同樣難以排查。void buffer_overflow() {
char buf[32];
gets(buf);
return;
}
上面這段代碼總是假定用戶(hù)的輸入不過(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ù)返回地址等重要信息,一類(lèi)經(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ò)程如圖所示:但如果代碼中存在棧緩沖區(qū)溢出問(wèn)題,那么在黑客的精心設(shè)計(jì)下,溢出的部分會(huì)“恰好”覆蓋掉棧幀中的返回地址,將其修改為一個(gè)特定的地址,這個(gè)特定的地址中保存有黑客留下的惡意代碼,如圖所示:這樣當(dāng)該進(jìn)程運(yùn)行起來(lái)后實(shí)際上是在執(zhí)行黑客的惡意代碼,這就是利用緩沖區(qū)溢出進(jìn)行攻擊的一個(gè)經(jīng)典案例。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),加上它。內(nèi)存是計(jì)算機(jī)系統(tǒng)中至關(guān)重要的一個(gè)組成部分,C/C++這類(lèi)偏底層的語(yǔ)言在帶來(lái)高性能的同事也帶來(lái)內(nèi)存相關(guān)的無(wú)盡問(wèn)題,而這類(lèi)問(wèn)題通常難以排查,不過(guò)知彼知己,當(dāng)你理解了常見(jiàn)的內(nèi)存相關(guān)問(wèn)題后將極大減少出現(xiàn)此類(lèi)問(wèn)題的概率。希望這篇文章對(duì)大家理解內(nèi)存與指針有所幫助。-End-
最近有一些小伙伴,讓我?guī)兔φ乙恍?nbsp;面試題 資料,于是我翻遍了收藏的 5T 資料后,匯總整理出來(lái),可以說(shuō)是程序員面試必備!所有資料都整理到網(wǎng)盤(pán)了,歡迎下載!
本站僅提供存儲(chǔ)服務(wù),所有內(nèi)容均由用戶(hù)發(fā)布,如發(fā)現(xiàn)有害或侵權(quán)內(nèi)容,請(qǐng)
點(diǎn)擊舉報(bào)。