18|找出記憶體洩漏
相關主題: 控制記憶體的必備指令
只限 C|CPU 時間上限 1 秒|計算 heap 用量,有記憶體洩漏就不通過
這個資料夾裡有一個 buggy.c,是一個「單字統計」程式。它的輸出完全正確,但是有 3 個記憶體洩漏(配置了、卻到程式結束都沒有釋放的記憶體),而且 3 個的成因都不一樣。
你的工作:
- 把
buggy.c複製成main.c(buggy.c留著不要改,方便對照)。 - 找出並修好所有記憶體洩漏。
- 輸出必須和原本完全一樣。
不需要重寫程式,只要在正確的地方補上 free(或稍微調整程式碼的順序)。
這個程式在做什麼
Section titled “這個程式在做什麼”讀入多行英文文字,統計每個單字出現幾次:
- 空行(只有空白字元的行)和註解行(第一個非空白字元是
#的行)會被跳過。 - 其他的行用空白、Tab 和標點符號
. , ! ? ; : " ( )切成一個個單字,英文字母一律轉成小寫再統計。所以The和THE算同一個單字;It's是一個單字it's;出現在行中間的#不是註解,會被當成單字(或單字的一部分)。
第 1 步:看記憶體報告。 在終端機輸入(一行):
run exercises\3-memory\18-find-the-leak\buggy.c --mem --input exercises\3-memory\18-find-the-leak\tests\01.in--mem 會關掉 Sanitizer、改用記憶體記帳器執行,--input 讓程式從測試檔讀輸入。也可以在 VS Code 開著 buggy.c,按 Ctrl+Shift+P → Tasks: Run Task → 「執行並顯示記憶體報告」,再自己打輸入(打完按 Ctrl+Z 再按 Enter 表示輸入結束)。
程式的輸出後面會接著一份報告,最後幾行長這樣:
⚠ 記憶體洩漏:程式結束時還有 N 塊、共 M bytes 沒有釋放: buggy.c:行號(函式名稱):多少 bytes(幾塊) …每一行的意思是:「在 buggy.c 的這一行(這個函式裡)配置的記憶體,有這麼多塊到程式結束都沒有釋放」。
第 2 步:沿著指標追。 報告告訴你的是記憶體在哪裡配置,不是應該在哪裡釋放。對每一個位置,問自己:
- 配置出來的指標被存到哪個變數、傳給了誰?
- 最後一次用到它是在哪裡?那裡之後就應該
free。 - 從配置到
free之間,有沒有哪條路(continue、break、return)會跳過free?
第 3 步:修改 main.c,再看一次報告。 把上面指令裡的 buggy.c 換成 main.c 再執行,直到報告顯示「✔ 程式結束時所有配置的記憶體都已經釋放」。也可以換別組測試(tests\04.in 等)多試幾次。
注意:
- 平常的
run和 Ctrl+Shift+B 用的 AddressSanitizer 在 Windows 上不會檢查記憶體洩漏(它抓的是越界、use-after-free、double free),所以這一題要用--mem的記憶體報告。 - 修的時候小心不要「修過頭」:釋放之後又使用(use-after-free)、或同一塊釋放兩次(double free),檢查的第一階段(Sanitizer)會直接報錯,並指出是哪一行。
- 在題目資料夾裡,開著
main.c按 Ctrl+Shift+B 是檢查答案;開著buggy.c按 Ctrl+Shift+B 則是直接執行buggy.c(Sanitizer 模式,不會顯示記憶體報告,要看報告請用上面的--mem指令)。
buggy.c 要處理的輸入:
- 多行英文文字,最多 5000 行,每行最多 1000 個字元。
- 只包含英文字母、數字、空白、Tab 和標點符號。
- 不同的單字最多 1000 個。
- 第一行
lines L:有內容的行數(不含空行和註解行;只有標點符號的行也算一行)。 - 第二行
words W:單字的總數。 - 第三行
distinct D:不同單字的個數。 - 接下來 D 行,每行
單字 次數,依照次數由多到少排列;次數相同時依照字母順序(strcmp的順序)。
輸入:
# a tiny exampleThe cat saw the dog.
the DOG saw a cat!# end輸出:
lines 2words 10distinct 5the 3cat 2dog 2saw 2a 1C:
- 誰配置、誰釋放:函式回傳一塊
malloc來的記憶體,就等於把「釋放的責任」交給呼叫端。寫這種函式時,在註解寫清楚「回傳新配置的字串,呼叫端要 free」 - 提早離開的路徑:
continue、break、return會跳過後面的程式碼,包括後面的free。每加一條提早離開的路,都要想一次「現在手上有哪些還沒釋放的記憶體?」 - 結構裡的指標:
free(p)只釋放p指向的那一塊,不會順便釋放那一塊裡面的指標指向的東西。要先釋放裡面的,再釋放外面的 - 為什麼要 free,程式結束時作業系統不是會回收嗎? 這個程式很快就結束所以看不出影響,但伺服器、遊戲這類長時間執行的程式,在迴圈裡每次漏一點,記憶體就會一直增加直到用完。養成「每個 malloc 都有對應的 free」的習慣,也讓記憶體工具的報告保持乾淨,真正的問題才不會被淹沒
- 記帳器追蹤的是
malloc/calloc/realloc/free(C++ 還有new/delete)
C++ 對照:
std::string、std::vector和智慧指標std::unique_ptr在離開範圍時會自動釋放自己管理的記憶體(RAII),這一題的三種洩漏在 C++ 裡大多可以靠它們避免。第 19 題會練習這個觀念
提示 1:先把報告的每一行對應到原因
報告會列出 3 個位置。看「塊數」可以幫助你猜原因:用 tests\01.in(範例)時,
- 哪一個位置的塊數,剛好等於空行加註解行的數量?
- 哪一個位置的塊數,剛好等於不同單字的數量?(有兩個都是!)
再看看這些位置分別在哪個函式裡、那個函式是誰呼叫的。
提示 2:lower_copy 和 format_entry
這兩個函式都回傳新配置的字串,呼叫它們的都是 main:
lower_copy的結果在迴圈最後有free(text)……但每一行都會走到那裡嗎?format_entry的結果拿去puts之後就再也沒用了,有人釋放它嗎?
提示 3:add_word 裡配置的字串
add_word 把每個新單字複製一份,存到 table.items[i].word。程式最後的 free(table.items) 釋放的是「放 Entry 的那個陣列」,陣列裡每個 word 指向的字串是另外配置的,不會跟著被釋放。
所以在 free(table.items) 之前,要先用迴圈把每個 table.items[i].word 釋放掉。順序不能反過來:先釋放 table.items 的話,之後再讀 table.items[i].word 就是 use-after-free。