Learning Objectives
- 從 /proc/self/maps 和教學核心的記憶體面板,認出程式碼、資料、堆積、堆疊各在哪裡
- 說明堆疊為什麼會溢位,以及區域變數在函數結束後為什麼不能再用
- 說明 malloc 和 brk 的關係,以及 free 之後記憶體為什麼沒有還給作業系統
- 認出懸置指標、重複釋放、記憶體洩漏這三種錯誤,並把它們修好
- 比較 first-fit 和 best-fit,解釋外部碎片和合併
你的程式以為自己有 128 TB 的記憶體
這支程式印出幾個東西的位址,再把作業系統記錄的「這個行程的記憶體配置」整份印出來:
main() 0x401196
"hello" 0x402004
global_var 0x404050
heap_var 0x1b3492a0
local_var 0x7ffc907584f4
/proc/self/maps:
00400000-00401000 r--p output.s
00401000-00402000 r-xp output.s
00402000-00403000 r--p output.s
00403000-00404000 r--p output.s
00404000-00405000 rw-p output.s
1b349000-1b36a000 rw-p [heap]
7a8f84200000-7a8f84228000 r--p libc.so.6
7a8f84228000-7a8f843b1000 r-xp libc.so.6
7a8f843b1000-7a8f84400000 r--p libc.so.6
7a8f84400000-7a8f84404000 r--p libc.so.6
7a8f84404000-7a8f84406000 rw-p libc.so.6
7a8f84406000-7a8f84413000 rw-p
7a8f84499000-7a8f8449e000 rw-p
7a8f8449e000-7a8f844a2000 r--p [vvar]
7a8f844a2000-7a8f844a4000 r--p [vvar_vclock]
7a8f844a4000-7a8f844a6000 r-xp [vdso]
7a8f844a6000-7a8f844a7000 r--p ld-linux-x86-64.so.2
7a8f844a7000-7a8f844d2000 r-xp ld-linux-x86-64.so.2
7a8f844d2000-7a8f844dc000 r--p ld-linux-x86-64.so.2
7a8f844dc000-7a8f844de000 r--p ld-linux-x86-64.so.2
7a8f844de000-7a8f844e0000 rw-p ld-linux-x86-64.so.2
7ffc90739000-7ffc9075a000 rw-p [stack]
ffffffffff600000-ffffffffff601000 --xp [vsyscall]
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
local_var 的位址開頭是 0x7ffc...,換算下來大約在 128 TB 的位置,這台機器的記憶體只有 16 GB。這些是虛擬位址:每個行程都看到一整片自己的空間,叫位址空間(address space)。怎麼對應到實體記憶體是下一課的內容,這一課先看這片空間怎麼規劃。
/proc/self/maps 每一行是一段連續的區域(真正的檔案還有位移、裝置、inode 三欄,上面的程式為了好讀沒有印):
| 欄位 | 意思 |
|---|---|
00401000-00402000 |
位址範圍 |
r-xp |
權限:可讀、不可寫、可執行;最後的 p 是私有(private),s 是共用(shared) |
| 最右邊 | 內容的來源:執行檔(這裡叫 output.s)、函式庫、[heap]、[stack];空白是匿名的記憶體 |
中間多出來的 libc.so.6、ld-linux 是 C 標準函式庫和動態連結器,它們也住在這個行程的位址空間裡。
每個區段放什麼
| 區段 | 放什麼 | 權限 | 誰決定大小 |
|---|---|---|---|
| 程式碼(text) | 編譯好的機器碼 | r-x |
編譯時固定 |
| 唯讀資料(rodata) | 字串常數,例如 "hello" |
r-- |
編譯時固定 |
| 資料(data、bss) | 全域變數;有初值的在 data,沒有初值的在 bss,執行時才補零 | rw- |
編譯時固定 |
| 堆積(heap) | malloc 拿到的記憶體 |
rw- |
執行時往高位址長 |
| 記憶體映射區 | 函式庫、mmap、執行緒的堆疊 |
看內容 | 執行時增減 |
| 堆疊(stack) | 區域變數、函數的返回位址 | rw- |
執行時往低位址長 |
堆積和堆疊從兩頭往中間長,中間留著一大片沒有對應任何東西的位址。碰到那片空白就是 SIGSEGV。
如果你不常寫 C,這一課只會用到幾個語法:
&x:取變數x的位址。int *p:p是指標,裡面存的是一個位址;*p是到那個位址去讀寫。malloc(n):要n個位元組的記憶體,回傳位址;free(p):還回去。printf的%p:把位址印成十六進位。
想多練這些語法可以到 C++ 軌。
教學核心跑的是 C 的子集,機器指令是簡化過的(load、add、store 這種,不是真的 x86)。它把位址固定下來、沒有做隨機化,每次跑都一樣;真實系統每次的堆積與堆疊位址都不同,上面印出來的值也不會和教學核心相同。
堆疊往低位址長,用完就是 SIGSEGV
每呼叫一次函數,堆疊就多一塊空間(叫堆疊框,stack frame),放這次呼叫的區域變數與返回位址;函數結束時整塊收回。這是一支遞迴三層的程式在 Linux 上的結果:
stack limit: 8388608 bytes
depth 1: local at 0x7ffcf87cf0cc
depth 2: local at 0x7ffcf87cf09c
depth 3: local at 0x7ffcf87cf06c
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
位址越來越小,所以堆疊是往低位址長。第一行是這個行程的堆疊上限:8 MB(ulimit -s 可以看,每台機器的設定可能不同)。
兩個直接的後果:
- 函數回傳後,它的區域變數就不能用了。 回傳一個區域變數的位址(
return &x;)在語法上會通過,但那塊空間下一次呼叫就被別人用掉。 - 遞迴太深或區域陣列太大會溢位。 超過上限再往下存取,碰到的是沒有對應的位址,核心送 SIGSEGV。所以大陣列要用
malloc,不要宣告成區域變數。
malloc 不是系統呼叫
malloc 是 C 函式庫裡的函數。它一次跟核心批發一大塊,再零售給你,所以大部分呼叫根本不會進到核心:
first malloc grew the heap by 135168 bytes
second malloc grew the heap by 0 bytes
distance from a to b: 112 bytes
1 MB malloc grew the heap by 0 bytes
1 MB block is outside the heap
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
- 第一次
malloc(100):函式庫用brk系統呼叫把堆積的尾端往上推了 135168 位元組,也就是 132 KB。 - 第二次:批發來的還夠,沒有系統呼叫。
- 兩塊相距 112 位元組,比 100 多:每塊前面有配置器自己用的標頭,大小還要對齊。
malloc1 MB:堆積完全沒長,那一塊在堆積外面。依malloc(3)手冊,超過MMAP_THRESHOLD(預設 128 KB)的要求改用mmap另外要一段,free時可以直接還給核心。
教學核心的
malloc一律用brk,沒有mmap這條路;第一次擴充同樣是 132 KB,100 位元組的兩塊同樣相距 112。
free 只是把區塊標成可用,放回配置器的清單,下次 malloc 優先拿來用。除了 mmap 來的大區塊,記憶體通常留在行程裡,所以 top 看到的用量不會因為你 free 了就馬上下降。
三種記憶體錯誤
C 不會檢查你有沒有好好用這些記憶體,錯誤常常不會立刻爆炸:
| 錯誤 | 寫法 | 後果 |
|---|---|---|
| 懸置指標(dangling pointer),使用已釋放的記憶體 | free(p); printf("%s", p); |
未定義行為:可能讀到舊資料、別人的資料或垃圾 |
| 重複釋放(double free) | free(p); free(p); |
未定義行為:配置器的清單可能被破壞 |
| 記憶體洩漏(memory leak) | 配置了卻再也找不到指標、也沒有 free |
當下沒事;程式跑越久用越多,長時間執行的服務最後可能被核心終止 |
同一種寫法在真實 Linux 上的結果:
first byte after free: 119 (was 65)
same address: yes
name = Bob
other = Bob
child killed by signal 6 (SIGABRT is 6)
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
free之後第一個位元組就不是原來的A(65)了:glibc 把剛釋放的區塊拿來記自己的清單。教學核心不會動釋放的區塊,內容會原封不動留著;兩邊都是未定義行為,不能依賴它讀到什麼。- 下一次
malloc拿到同一塊,name看到的是other的內容。 - 第二次
free(name):glibc 這次偵測到了,印出free(): double free detected in tcache 2並用 SIGABRT 結束行程。它不是每次都偵測得到。
行程結束時,核心會收回它全部的記憶體,所以短命的小程式洩漏一點不會留下後果。要找這些錯誤可以用 Valgrind 或編譯時加
-fsanitize=address。
配置器要回答的問題:放哪一個洞
程式隨意配置、隨意釋放之後,堆積會變成使用中和可用的區塊交錯。下一次 malloc(n) 要從可用區塊裡挑一個:
- First-fit:從低位址找,第一個夠大的就用。找得快。
- Best-fit:看過全部可用區塊,挑夠大裡面最小的。切剩的最少,但要全部看過。
切剩的小塊可能再也用不上。可用的記憶體加起來夠,卻沒有一塊連續的夠大,這叫外部碎片(external fragmentation)。C 的配置器沒辦法搬動使用中的區塊,因為你手上的指標都指著舊位址,所以只能靠挑選策略和合併(coalesce,釋放時把相鄰的可用區塊併成一塊)來減輕。
真實的配置器(例如 glibc)依大小分成很多條清單,比這兩個策略複雜得多,要解決的是同一個問題。
用程式寫 first-fit
這是情境一釋放之後的狀態:三個洞分別是 10、6、20。注意最後那一次:可用總量還有 20,卻找不到連續的 20。
實作:把它改成 best-fit
補完 best_fit:看過全部區塊,挑夠大裡面最小的;一樣大挑前面的。跑完的結果可以和上面「情境一」的 Best-fit 對答案:三個要求全部成功,什麼都不剩。
確認一下
下面這個函數有什麼問題?
int *make(void) {
int x = 5;
return &x;
}
一支迴圈裡呼叫 100 萬次 malloc(16) 再 free 的程式,大約會產生幾次 brk 系統呼叫?
程式 free(p) 之後,再讀 *p 剛好得到原本的值。這代表什麼?
可用記憶體總共 15 KB,分散在 5 KB 和 10 KB 兩塊,malloc(12 KB) 會怎樣(假設配置器不跟核心要更多)?
為什麼大陣列不建議寫成區域變數 char buf[100000000];?
重點整理
- 每個行程有自己的位址空間:程式碼與唯讀資料、全域變數、往上長的堆積、往下長的堆疊,中間是沒有對應的空白。
/proc/self/maps看得到實際的配置。 - 堆疊框隨函數呼叫建立與收回,所以不能回傳區域變數的位址;超過上限是 SIGSEGV。
malloc是函式庫函數,跟核心批發(brk,大區塊用mmap)再零售;free只是放回清單,不代表還給核心。- 懸置指標、重複釋放是未定義行為,常常不會當場出事;洩漏是越跑越多。
- First-fit 快、Best-fit 切剩的小但要全部看過,沒有哪個永遠比較好。外部碎片靠合併和策略減輕,C 的配置器不能搬動使用中的區塊。
延伸閱讀:OSTEP(作者免費公開)的 Address Spaces、Memory API、Free Space Management 幾章。
下一課回答這一課留下的問題:這些虛擬位址怎麼變成實體位址,每次都查表不會太慢嗎?