為什么遞歸會(huì)造成棧溢出?探索程序的內(nèi)存管理!
本文轉(zhuǎn)載自微信公眾號(hào)「五月君」,作者五月君。轉(zhuǎn)載本文請(qǐng)聯(lián)系五月君公眾號(hào)。
在任何編程語言中,掌握內(nèi)存管理都是很重要的,一方面對(duì)于操作系統(tǒng)而言程序內(nèi)存使用是有限制的,另外一方面內(nèi)存變化也會(huì)影響我們的程序執(zhí)行效率。
選擇基于 C 語言來學(xué)習(xí),也是因?yàn)槲覀兛梢越柚恍┕ぞ?。例如,使?gdb 方便的調(diào)試我們的程序,從每一步的調(diào)試,來看程序的運(yùn)行變化。
本節(jié)你能學(xué)到什么?
在本文開始前,先列出幾個(gè)問題,讀者朋友可以先思考下,也是本講你能學(xué)到的一些知識(shí)點(diǎn),如下:
- 我們的 32 位操作系統(tǒng)能夠管理的內(nèi)存是多大?對(duì)應(yīng) 64 位又是如何?
- 內(nèi)存空間一般劃分為哪幾個(gè)段,每個(gè)段存儲(chǔ)都存儲(chǔ)哪些東西?
- 為什么說棧是一塊連續(xù)的內(nèi)存空間?
- 為什么遞歸會(huì)造成棧溢出?
- 堆內(nèi)存怎么申請(qǐng)?
前置知識(shí)
簡單列舉一些基礎(chǔ)知識(shí)點(diǎn),這些是接下來會(huì)用到的。
- 計(jì)算機(jī)最小單位是字節(jié)(byte),1byte=8bit(翻譯為中文就是一個(gè)字節(jié)等于 8 個(gè)二進(jìn)制位)
- 計(jì)算機(jī)底層使用的二進(jìn)制,如果是用來展示通常是 10 進(jìn)制,編程用的時(shí)候會(huì)采用 16 進(jìn)制,內(nèi)存地址編碼使用的就是 16 進(jìn)制。
- 1 個(gè) 16 進(jìn)制數(shù)字就表示 4 位二進(jìn)制數(shù)字。
- 32 bit 操作系統(tǒng) 1 個(gè)指針占用 4 個(gè)字節(jié),64 bit 操作系統(tǒng) 1 個(gè)指針占用是 8 個(gè)字節(jié)(C 語言中指針變量內(nèi)存地址占有就是 8 字節(jié))。
問題解答:我們的 32 位操作系統(tǒng)能夠管理的內(nèi)存是多大?對(duì)應(yīng) 64 位又是如何?
32 位操作系統(tǒng)的地址總線是 32 位,也就是尋址空間是 32 位,因?yàn)閮?nèi)存是按照字節(jié)尋址的,每個(gè)字節(jié)可以理解成對(duì)應(yīng)一個(gè)地址編號(hào),如下所示,可以是全 0 的,也可以是全 1 的。
- 00000000 00000000 00000000 00000000
- ........ ........ ........ ........
- 11111111 11111111 11111111 11111111
32 位操作系統(tǒng)能分配的地址編號(hào)數(shù)是
個(gè)字節(jié),排列組合根據(jù)公式換算下:
最終,我們 32 位操作系統(tǒng)最多可管理的內(nèi)存是 4 GB。
注:1024Byte = 1KB | 1024KB = 1MB | 1024MB = 1GB。
內(nèi)存的訪問是比磁盤驅(qū)動(dòng)器快的多了,因此 4GB 肯定也不滿足不了需求了,隨之而來的是現(xiàn)在的 64 位操作系統(tǒng),理論上它所能管理的內(nèi)存空間為 2 的 64 次方,這個(gè)數(shù)字是很大的,這個(gè)內(nèi)存現(xiàn)在是足夠用的,通常我們是用不到這么大的。
內(nèi)存劃分
內(nèi)存是交由操作系統(tǒng)管理,它會(huì)給我們的內(nèi)存做編號(hào)、用戶內(nèi)存與操作系統(tǒng)內(nèi)存隔離。
在 64 位的操作系統(tǒng)上,我們能夠使用的是前面的 48 位,0x0000000000000000 ~ 0x00007FFFFFFFFFFF,而內(nèi)核態(tài)在用戶態(tài)最后一位上加 1 就是 0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF。
問題解答:內(nèi)存空間一般劃分為哪幾個(gè)段,每個(gè)段存儲(chǔ)都存儲(chǔ)哪些東西?
通過上圖可以清楚的看到,我們的內(nèi)存是有劃分的,一份為系統(tǒng)的內(nèi)核空間,另外一部分為用戶空間,與我們程序相關(guān)的主要看下用戶空間部分,將內(nèi)存劃分為:棧、堆、數(shù)據(jù)段、代碼段,每個(gè)里面分別存儲(chǔ)的是什么?下面會(huì)分別介紹,答案就在里面。
代碼段
代碼段保存我們代碼編譯后的機(jī)器碼,代碼段的內(nèi)存地址也是最小的,下例,以 0x4 開頭,你可以先記住這個(gè)值,在后面介紹的其它段里,可以比較下內(nèi)存大小。
- (gdb) p &swap
- $11 = (void (*)(int *, int *)) 0x40052d <swap>
- (gdb) p &main
- $12 = (int (*)()) 0x400559 <main>
數(shù)據(jù)段保存靜態(tài)變量、常量和一些全局變量,以下是一段示例,兩個(gè)函數(shù)分別定義了靜態(tài)變量 count 和執(zhí)行了全局變量 globalCount。 通過 gdb 調(diào)試看下,分別在 add 函數(shù)里打印了靜態(tài)變量 count 和全局變量 globalCount 的內(nèi)存地址。 靜態(tài)變量 count 是聲明在函數(shù)內(nèi)部的,因此兩次打印出來的地址也是不一樣的,自然兩個(gè)是不會(huì)相互影響的,全局變量可以看到內(nèi)存地址是一樣的,因此在任意一個(gè)函數(shù)里修改,值都會(huì)發(fā)生變化。 0x601038、0x60103c、0x601040 每次遞增 4 個(gè)字節(jié),可以看到它們的內(nèi)存地址是連續(xù)遞增的,數(shù)據(jù)段的內(nèi)存地址以 0x6 開頭是大于代碼段的。 數(shù)據(jù)段還有一種稱為 “BSS” 段,表示未初始化或初始化為 0 的所有全局變量或靜態(tài)變量,static int a 或全局變量 int a 稱為 “未初始化數(shù)據(jù)段”。 關(guān)于初始化數(shù)據(jù)段與未初始化數(shù)據(jù)段,這里有篇文章講的也很好,可以參考 https://zhuanlan.zhihu.com/p/62208277。 棧寄存器段,指向包含當(dāng)前程序棧的段,這些都是臨時(shí)的信息。例如:局部變量、函數(shù)參數(shù)、返回值和當(dāng)前程序運(yùn)行狀態(tài)等都存在于棧中,隨著這些臨時(shí)變量對(duì)應(yīng)的作用域完成之后,也會(huì)被彈出棧。 一個(gè)變量交換示例 以下為一段 C 語言代碼示例,通過 swap 函數(shù)交換兩個(gè)變量。 先使用 gcc 編譯我們的源代碼 gcc -g main.c -o main.out,之后使用 gdb 調(diào)試。 問題解答:為什么說棧是一塊連續(xù)的內(nèi)存空間? 在 C 語言里一個(gè)整型的數(shù)據(jù)大小為 4 個(gè)字節(jié)(指針類型另有規(guī)定,后面會(huì)講),整型變量 a 存儲(chǔ)的內(nèi)存地址為 0x7fffffffe35c 也即首地址,按照 4 Byte 推算應(yīng)該是 0x7fffffffe35c、0x7fffffffe35d、0x7fffffffe35e、0x7fffffffe35f。整型變量 b 的內(nèi)存地址為 0x7fffffffe358 同樣按照 4 Byte 推算應(yīng)該是 0x7fffffffe358、0x7fffffffe359、0x7fffffffe35a、0x7fffffffe35b 也就是加上 4 個(gè)字節(jié)正好相鄰于變量 a,因此我們還可以在確認(rèn)一個(gè)問題是:“棧是一塊連續(xù)的內(nèi)存區(qū)域”。 通過一個(gè)圖,相對(duì)直觀的感受下。 這時(shí)可能會(huì)產(chǎn)生一個(gè)疑問,為什么創(chuàng)建變量順序是 a、b 而分配的內(nèi)存地址確是遞減的順序? 這涉及到棧的存儲(chǔ)結(jié)構(gòu),棧是先進(jìn)后出的,棧頂?shù)牡刂肥怯上到y(tǒng)預(yù)先設(shè)置好的,由棧頂入棧隨后每次內(nèi)存地址呈遞減的方式依次分配,當(dāng)還有新元素時(shí)就繼續(xù)壓棧,最先入棧的最后出棧,也可理解為棧底對(duì)應(yīng)高地址、棧頂對(duì)應(yīng)低地址。 使用 gdb 調(diào)試進(jìn)入 swap 函數(shù),這兩個(gè)參數(shù) a、b 我們定義為指針類型,可以看到它的值為外層整型變量 a 和 b 的內(nèi)存地址。 swap 函數(shù)里的指針類型變量 a 與 b 也是有內(nèi)存地址的,可以打印出來看下。同樣的可以看出,這兩個(gè)內(nèi)存地址之間相差 8 個(gè)字節(jié),也就號(hào)符合指針類型的定義,在 64 位系統(tǒng)下一個(gè)指針占用 8 個(gè)字節(jié),當(dāng)然大學(xué)課本上你可能看到過 1 個(gè)指針占用 4 個(gè)字節(jié),那是針對(duì)的 32 位系統(tǒng)。 目前處于代碼的第 3 行,swap 函數(shù)里指針變量 a 存儲(chǔ)的是外層傳入的變量 a 的內(nèi)存地址,如何獲取該值呢?那么在 C 語言中通過運(yùn)算符 * 號(hào)可以取到一個(gè)內(nèi)存地址對(duì)應(yīng)的值,也就是“解引用”。 接下來執(zhí)行 2 兩步,程序停留在第 5 行,可以看到 a 的值由 2 變?yōu)榱?3,為什么 swap 函數(shù)能交換兩個(gè)變量的值,也正是因?yàn)槲覀冊(cè)谶@里通過指針修改了傳進(jìn)來的兩個(gè)變量的內(nèi)存地址。 通過 bt 可以打印當(dāng)前函數(shù)調(diào)用棧的所有信息,左側(cè)有一個(gè) #0、#1 的序號(hào),0 就是目前的棧頂,因?yàn)槲覀冞@個(gè)程序很簡單,程序入口函數(shù) main() 就是我們的棧底,而當(dāng)前執(zhí)行的 swap() 函數(shù)就是我們的棧頂,也是當(dāng)前程序所在的位置。 棧是有內(nèi)存大小限制的,Linux 或 Mac 下我們可通過 ulimit -s 命令查看,結(jié)果為:8192 # stack size (kbytes) ,Linux 下用戶默認(rèn)的棧空間大小為 8MB。 寫遞歸時(shí),通常要控制好邊界,避免出現(xiàn)無限遞歸,遞歸的層級(jí)也不要太深,盡量不要在棧上定義太大的數(shù)據(jù)。一段遞歸調(diào)用的程序如下所示: gdb 調(diào)試之后得到如下錯(cuò)誤信息: bt -n 從棧底打印 n 條信息,最下面為我們的 main 函數(shù),除此之外可以看到 call() 總共遞歸調(diào)用了 1022 次,因?yàn)樽钌厦嫘蛱?hào)是從 0 開始的。 問題解答:為什么遞歸會(huì)造成棧溢出? 當(dāng)我們遞歸一個(gè)函數(shù)時(shí),這個(gè)時(shí)候每一次的遞歸運(yùn)行都會(huì)做壓棧操作,棧是一種先進(jìn)后出的數(shù)據(jù)結(jié)構(gòu),系統(tǒng)也是有最大的空間限制的,Linux 下用戶默認(rèn)的棧空間大小為 8MB,當(dāng)棧的存放容量超出這個(gè)限制之后,通常我們的程序會(huì)得到棧溢出得到錯(cuò)誤。 留一個(gè)問題大家思考下??:通過上面我們知道了遞歸層級(jí)太深會(huì)導(dǎo)致棧溢出,這是因?yàn)橄到y(tǒng)會(huì)有??臻g大小限制的,筆者平常使用 JavaScript 相會(huì)多一些,如果是在 JavaScript 中遇到這種問題怎么解決?不知道也沒關(guān)系,筆者最近在寫一個(gè)系列文章 《JavaScript 異步編程指南》可以帶你一起深入了解這個(gè)問題。 模擬這個(gè)問題很簡單,創(chuàng)建一個(gè)過大的字符數(shù)組。 通過 gdb 調(diào)試,會(huì)得到一個(gè) “Segmentation fault” 通常也稱為段錯(cuò)誤,指的是訪問的內(nèi)存超出了系統(tǒng)給程序設(shè)定的內(nèi)存空間,一般包括:不存在的內(nèi)存地址、訪問了系統(tǒng)保護(hù)的內(nèi)存地址、訪問了只讀的內(nèi)存地址、棧溢出等。 解決這種問題,繼續(xù)往下看~ 堆段由開發(fā)者手動(dòng)申請(qǐng)分配和釋放,也稱動(dòng)態(tài)內(nèi)存分配。在 C 語言中可以使用系統(tǒng)提供的函數(shù) malloc() 和 free() 申請(qǐng)和釋放內(nèi)存。 繼續(xù)拿上面 “字符數(shù)組棧溢出” 這個(gè)示例,現(xiàn)在改成在堆中創(chuàng)建內(nèi)存,這時(shí)僅在棧中保存指針變量 str 的地址,真正數(shù)據(jù)存放于堆中,也就不會(huì)出現(xiàn)棧溢出問題了。 進(jìn)入 gdb 調(diào)試,代碼停留在第 5 行,在未分配堆內(nèi)存之前,打印 str 可以看到是沒有值的,而 &str 取的是該變量在??臻g的內(nèi)存地址 0x7fffffffe368,這不是一回事,這是該變量的值。 再次執(zhí)行,創(chuàng)建堆內(nèi)存,代碼停留在第 12 行 free(str) 打印 str 得到 0x7ffff720c010 這時(shí)候堆內(nèi)存已分配成功。 現(xiàn)在讓我們做釋放操作,代碼停留在 14 行,打印 str 可以看到值已被釋放。 本文也是筆者在之前學(xué)習(xí)過程中的總結(jié),近期又稍微整理下,發(fā)出來也是希望能與大家共同的分享、交流。 通過本文,幾個(gè)常見的知識(shí)點(diǎn):棧與堆的區(qū)別、為什么遞歸會(huì)造成棧溢出,類似于這種常見的問題,希望讀者朋友能夠掌握。數(shù)據(jù)段
棧段
查看函數(shù)堆棧
棧溢出
遞歸造成的棧溢出
字符數(shù)組造成的棧溢出
堆段
總結(jié)