那些年我踩過的 Memory Leak

做嵌入式 Linux 開發那幾年,Memory Leak 大概是我遇過最多次、最難抓、最容易被忽略的問題。

桌面應用程式記憶體洩漏,頂多讓程式跑慢、被 OS 回收。但在嵌入式系統上,RAM 本來就不多,一旦洩漏,輕則效能下降,重則系統直接 OOM crash,而且往往要跑好幾個小時才會發作

這篇文章整理我這幾年踩過的幾個經典 Memory Leak 案例,每個都有點不一樣,希望能幫你少走一些彎路。

⚠️ 程式碼為簡化示意版本,重點在於呈現問題模式


案例一:最經典的忘記 free 🐣

發生在哪

在路由器韌體上開發一個設定檔解析模組

問題現象

系統運行幾天後,記憶體使用量緩慢爬升,最終 OOM。

問題程式碼

// 每次收到 HTTP 請求就解析設定
void handle_config_request(const char *raw_json) {

    // 每次都 malloc,但...
    config_t *cfg = (config_t *)malloc(sizeof(config_t));

    parse_json(raw_json, cfg);
    apply_config(cfg);

    // ← 忘記 free 了!
    // function 結束,cfg 指標消失,記憶體永遠回不來
}

根本原因

malloc 之後忘記 free,每次 HTTP 請求都洩漏一塊記憶體。

請求量不大時看不出來,但跑幾天就原形畢露。

修正方式

void handle_config_request(const char *raw_json) {
    config_t *cfg = (config_t *)malloc(sizeof(config_t));
    if (!cfg) return;  // 也要記得檢查 NULL!

    parse_json(raw_json, cfg);
    apply_config(cfg);

    free(cfg);         // ✅ 用完就還
    cfg = NULL;        // ✅ 避免 dangling pointer
}

教訓

malloc 和 free 要像括號一樣成對出現。
寫完 malloc 的下一秒,就先把 free 的位置想好。


案例二:Error Path 沒有清理 😈

發生在哪

模組的網路連線初始化流程。

問題現象

網路連線失敗的情況下,記憶體緩慢洩漏。

正常連線完全沒問題,只有在網路不穩、頻繁重連的環境才會發作。

問題程式碼

int init_network(network_ctx_t **ctx) {

    *ctx = malloc(sizeof(network_ctx_t));

    (*ctx)->buffer = malloc(BUFFER_SIZE);

    (*ctx)->socket = socket_create();
    if ((*ctx)->socket < 0) {
        return -1;  // ← Error! 但上面兩個 malloc 沒有 free!
    }

    if (connect((*ctx)->socket, ...) < 0) {
        return -1;  // ← 同樣的問題
    }

    return 0;
}

根本原因

Happy Path 寫得很好,但 Error Path 完全(忘了)沒有清理資源

這種問題特別隱蔽,因為正常測試都是走 Happy Path,CI 也過了,結果到了網路不穩的現場才爆發。

修正方式

int init_network(network_ctx_t **ctx) {

    *ctx = malloc(sizeof(network_ctx_t));
    if (!*ctx) return -1;

    (*ctx)->buffer = malloc(BUFFER_SIZE);
    if (!(*ctx)->buffer) {
        goto cleanup_ctx;   // ✅ 用 goto 統一清理
    }

    (*ctx)->socket = socket_create();
    if ((*ctx)->socket < 0) {
        goto cleanup_buffer;
    }

    if (connect((*ctx)->socket, ...) < 0) {
        goto cleanup_socket;
    }

    return 0;

// ✅ 統一的清理區塊,逆序釋放
cleanup_socket:
    socket_close((*ctx)->socket);
cleanup_buffer:
    free((*ctx)->buffer);
cleanup_ctx:
    free(*ctx);
    *ctx = NULL;
    return -1;
}

教訓

每條 Error Path 都是潛在的洩漏點。
Linux Kernel 大量使用 goto cleanup 模式,不是沒有原因的。


案例三:第三方 Library 的坑 😤

發生在哪

整合一個第三方的影像編解碼 library。

問題現象

系統跑久之後記憶體持續增長,但我把自己的程式碼翻了好幾遍,malloc/free 都是成對的,找不到問題。

追查過程

用 Valgrind 跑了一次:

valgrind --leak-check=full \
         --show-leak-kinds=all \
         --track-origins=yes \
         ./my_program

輸出結果:

==12345== LEAK SUMMARY:
==12345==    definitely lost: 2,048 bytes in 1 blocks
==12345==    indirectly lost: 65,536 bytes in 16 blocks
==12345==      possibly lost: 0 bytes in 0 blocks
==12345==    still reachable: 1,024 bytes in 4 blocks
==12345==
==12345== 2,048 bytes in 1 blocks are definitely lost
==12345==    at 0x4C2FB0F: malloc (vg_replace_malloc.c:309)
==12345==    by 0x401234: codec_init (libcodec.so)   ← 第三方 library!
==12345==    by 0x402345: my_init_function (main.c:45)

洩漏來源是 libcodec.so,不是我的程式碼。

根本原因

翻了 library 的文件,發現有一個 codec_global_cleanup() 函式,文件寫在不起眼的角落,說明程式結束前必須呼叫,否則會有資源洩漏。

// 文件上寫的,但很容易忽略:
// "Call codec_global_cleanup() before program exit to release
//  internal resources allocated during codec_init()"

// 我的程式碼裡完全沒有呼叫這個...

修正方式

int main(void) {
    codec_init();

    // ... 主程式邏輯 ...

    codec_global_cleanup();  // ✅ 補上這行
    return 0;
}

教訓

整合第三方 library,一定要把文件從頭到尾看一遍。
特別注意 init/cleanupcreate/destroyopen/close 這類成對的函式。
Valgrind 是找出「是誰洩漏」的好幫手。


案例四:迴圈裡的隱藏洩漏 🔄

發生在哪

資料處理的主迴圈。

問題現象

系統剛啟動時記憶體正常,但跑幾個小時後記憶體就滿了。

問題程式碼

void data_process_loop(void) {
    while (1) {
        // 每次迴圈都建立新的 buffer
        uint8_t *process_buf = malloc(PROCESS_BUF_SIZE);

        raw_data_t *raw = malloc(sizeof(raw_data_t));

        read_sensor_data(raw);
        process_data(raw, process_buf);
        send_to_display(process_buf);

        free(process_buf);  // ✅ 這個有 free
        // ← raw 沒有 free!每次迴圈洩漏一塊

        vTaskDelay(100 / portTICK_PERIOD_MS);
    }
}

根本原因

迴圈裡有兩個 malloc,只 free 了一個。

單次洩漏很小(幾十 bytes),但迴圈每 100ms 執行一次,一小時就洩漏幾萬次,幾個小時後記憶體就耗盡了。

修正方式

void data_process_loop(void) {
    // ✅ 固定大小的 buffer 可以拉到迴圈外面,只 malloc 一次
    uint8_t *process_buf = malloc(PROCESS_BUF_SIZE);
    raw_data_t *raw = malloc(sizeof(raw_data_t));

    if (!process_buf || !raw) {
        // 處理 malloc 失敗
        free(process_buf);
        free(raw);
        return;
    }

    while (1) {
        read_sensor_data(raw);
        process_data(raw, process_buf);
        send_to_display(process_buf);

        vTaskDelay(100 / portTICK_PERIOD_MS);
    }

    // 理論上不會到這裡,但好習慣還是寫上
    free(process_buf);
    free(raw);
}

教訓

迴圈裡的 malloc 要特別小心。
問自己:「這個 buffer 真的需要每次重新分配嗎?」
如果大小固定,拉到迴圈外面,只分配一次。


案例五:最難找的 — 間接洩漏 🕵️

發生在哪

路由器韌體,設定管理模組。

問題現象

這次最麻煩,Valgrind 報的是 indirectly lost,不是 definitely lost

問題程式碼

typedef struct {
    char *name;      // ← 指向動態分配的字串
    char *value;     // ← 同上
} config_item_t;

typedef struct {
    config_item_t *items;
    int count;
} config_list_t;

void free_config_list(config_list_t *list) {
    free(list->items);  // ← 只 free 了 items 陣列本身
    free(list);

    // ← 但 items[i].name 和 items[i].value 指向的字串沒有 free!
}

根本原因

釋放結構體時,只釋放了「外層」,沒有釋放「內層」指標指向的記憶體。

這種洩漏 Valgrind 會標記為 indirectly lost,因為指向那塊記憶體的指標已經不見了(隨著 items 陣列被 free 而消失),但記憶體本身還沒被釋放。

修正方式

void free_config_list(config_list_t *list) {
    if (!list) return;

    // ✅ 先釋放內層的動態字串
    for (int i = 0; i < list->count; i++) {
        free(list->items[i].name);
        free(list->items[i].value);
        list->items[i].name = NULL;
        list->items[i].value = NULL;
    }

    // ✅ 再釋放外層
    free(list->items);
    free(list);
}

教訓

釋放結構體之前,先想想裡面有沒有指標。
結構體越複雜,越容易漏掉內層的釋放。
建議為每個複雜結構體寫一個專用的 destroy 函式,統一管理。


我的 Memory Leak 預防清單 📋

經過這些年的教訓,我整理了一份自己在 Code Review 時會用的檢查清單:

撰寫程式碼時

  • [ ] malloc 之後,立刻想好在哪裡 free
  • [ ] 每條 Error Path 都有清理資源
  • [ ] 迴圈裡的 malloc 是否必要?能否移到迴圈外?
  • [ ] 結構體有指標成員時,free 前先釋放內層
  • [ ] 第三方 library 的 cleanup 函式有沒有呼叫?

監控記憶體趨勢(簡單腳本)

#!/bin/bash
# 監控指定 process 的記憶體使用趨勢
PID=$1
while true; do
    MEM=$(cat /proc/$PID/status | grep VmRSS | awk '{print $2}')
    echo "$(date '+%H:%M:%S') - RSS: ${MEM} kB"
    sleep 10
done

跑個幾小時,如果 RSS 持續爬升不回頭,就要開始懷疑有洩漏了。


總結

回頭看這些案例,Memory Leak 的根本原因其實就幾種:

類型 典型場景
忘記 free 菜鳥時期最常犯
Error Path 沒清理 最容易被忽略
第三方 library 文件沒看完
迴圈裡重複分配 累積效應,慢慢爆
間接洩漏 結構體指標沒處理

沒有哪個工程師能保證自己不寫出 Memory Leak,但可以透過好的編碼習慣 + 正確的工具,讓問題在開發階段就被抓出來,而不是等到客戶現場才爆發。

希望這篇文章能幫你少踩幾個坑 😄

💬 你有沒有遇過特別難找的 Memory Leak?歡迎留言分享!
說不定你的經驗比我的更精彩 😅

分類: 技術相關, 程式相關,標籤: , , , , , , , 。這篇內容的永久連結

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *