笨辦法學C 練習17:堆和棧的記憶體分配

weixin_33936401發表於2016-06-01

練習17:堆和棧的記憶體分配

原文:Exercise 17: Heap And Stack Memory Allocation

譯者:飛龍

在這個練習中,你會在難度上做一個大的跳躍,並且建立出用於管理資料庫的完整的小型系統。這個資料庫並不實用也儲存不了太多東西,然而它展示了大多數到目前為止你學到的東西。它也以更加正規的方法介紹了記憶體分配,以及帶領你熟悉檔案處理。我們實用了一些檔案IO函式,但是我並不想過多解釋它們,你可以先試著自己理解。

像通常一樣,輸入下面整個程式,並且使之正常工作,之後我們會進行討論:

#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>

#define MAX_DATA 512
#define MAX_ROWS 100

struct Address {
    int id;
    int set;
    char name[MAX_DATA];
    char email[MAX_DATA];
};

struct Database {
    struct Address rows[MAX_ROWS];
};

struct Connection {
    FILE *file;
    struct Database *db;
};

void die(const char *message)
{
    if(errno) {
        perror(message);
    } else {
        printf("ERROR: %s\n", message);
    }

    exit(1);
}

void Address_print(struct Address *addr)
{
    printf("%d %s %s\n",
            addr->id, addr->name, addr->email);
}

void Database_load(struct Connection *conn)
{
    int rc = fread(conn->db, sizeof(struct Database), 1, conn->file);
    if(rc != 1) die("Failed to load database.");
}

struct Connection *Database_open(const char *filename, char mode)
{
    struct Connection *conn = malloc(sizeof(struct Connection));
    if(!conn) die("Memory error");

    conn->db = malloc(sizeof(struct Database));
    if(!conn->db) die("Memory error");

    if(mode == 'c') {
        conn->file = fopen(filename, "w");
    } else {
        conn->file = fopen(filename, "r+");

        if(conn->file) {
            Database_load(conn);
        }
    }

    if(!conn->file) die("Failed to open the file");

    return conn;
}

void Database_close(struct Connection *conn)
{
    if(conn) {
        if(conn->file) fclose(conn->file);
        if(conn->db) free(conn->db);
        free(conn);
    }
}

void Database_write(struct Connection *conn)
{
    rewind(conn->file);

    int rc = fwrite(conn->db, sizeof(struct Database), 1, conn->file);
    if(rc != 1) die("Failed to write database.");

    rc = fflush(conn->file);
    if(rc == -1) die("Cannot flush database.");
}

void Database_create(struct Connection *conn)
{
    int i = 0;

    for(i = 0; i < MAX_ROWS; i++) {
        // make a prototype to initialize it
        struct Address addr = {.id = i, .set = 0};
        // then just assign it
        conn->db->rows[i] = addr;
    }
}

void Database_set(struct Connection *conn, int id, const char *name, const char *email)
{
    struct Address *addr = &conn->db->rows[id];
    if(addr->set) die("Already set, delete it first");

    addr->set = 1;
    // WARNING: bug, read the "How To Break It" and fix this
    char *res = strncpy(addr->name, name, MAX_DATA);
    // demonstrate the strncpy bug
    if(!res) die("Name copy failed");

    res = strncpy(addr->email, email, MAX_DATA);
    if(!res) die("Email copy failed");
}

void Database_get(struct Connection *conn, int id)
{
    struct Address *addr = &conn->db->rows[id];

    if(addr->set) {
        Address_print(addr);
    } else {
        die("ID is not set");
    }
}

void Database_delete(struct Connection *conn, int id)
{
    struct Address addr = {.id = id, .set = 0};
    conn->db->rows[id] = addr;
}

void Database_list(struct Connection *conn)
{
    int i = 0;
    struct Database *db = conn->db;

    for(i = 0; i < MAX_ROWS; i++) {
        struct Address *cur = &db->rows[i];

        if(cur->set) {
            Address_print(cur);
        }
    }
}

int main(int argc, char *argv[])
{
    if(argc < 3) die("USAGE: ex17 <dbfile> <action> [action params]");

    char *filename = argv[1];
    char action = argv[2][0];
    struct Connection *conn = Database_open(filename, action);
    int id = 0;

    if(argc > 3) id = atoi(argv[3]);
    if(id >= MAX_ROWS) die("There's not that many records.");

    switch(action) {
        case 'c':
            Database_create(conn);
            Database_write(conn);
            break;

        case 'g':
            if(argc != 4) die("Need an id to get");

            Database_get(conn, id);
            break;

        case 's':
            if(argc != 6) die("Need id, name, email to set");

            Database_set(conn, id, argv[4], argv[5]);
            Database_write(conn);
            break;

        case 'd':
            if(argc != 4) die("Need id to delete");

            Database_delete(conn, id);
            Database_write(conn);
            break;

        case 'l':
            Database_list(conn);
            break;
        default:
            die("Invalid action, only: c=create, g=get, s=set, d=del, l=list");
    }

    Database_close(conn);

    return 0;
}

在這個程式中我使用了一系列的結構來建立用於地址薄的小型資料庫。其中,我是用了一些你從來沒見過的東西,所以你應該逐行瀏覽這段程式碼,解釋每一行做了什麼,並且查詢你不認識的任何函式。下面是你需要注意的幾個關鍵部分:

#define 常量

我使用了“C前處理器”的另外一部分,來建立MAX_DATAMAX_ROWS的設定常量。我之後會更多地講解前處理器的功能,不過這是一個建立可靠的常量的簡易方法。除此之外還有另一種方法,但是在特定場景下並不適用。

定長結構體

Address結構體接著使用這些常量來建立資料,這些資料是定長的,它們並不高效,但是便於儲存和讀取。Database結構體也是定長的,因為它有一個定長的Address結構體陣列。這樣你就可以稍後把整個資料一步寫到磁碟。

出現錯誤時終止的die函式

在像這樣的小型程式中,你可以編寫一個單個函式在出現錯誤時殺掉程式。我把它叫做die。而且在任何失敗的函式呼叫,或錯誤輸出之後,它會呼叫exit帶著錯誤退出程式。

用於錯誤報告的 errnoperror

當函式返回了一個錯誤時,它通常設定一個叫做errno的“外部”變數,來描述發生了什麼錯誤。它們知識數字,所以你可以使用peeror來“列印出錯誤資訊”。

檔案函式

我使用了一些新的函式,比如fopenfreadfclose,和rewind來處理檔案。這些函式中每個都作用於FILE結構體上,就像你的結構體似的,但是它由C標準庫定義。

巢狀結構體指標

你應該學習這裡的巢狀結構器和獲取陣列元素地址的用法,它讀作“讀取db中的conn中的rows的第i個元素,並返回地址(&)”。

譯者注:這裡有個更簡便的寫法是db->conn->row + i

結構體原型的複製

它在Database_delete中體現得最清楚,你可以看到我是用了臨時的區域性Address變數,初始化了它的idset欄位,接著通過把它賦值給rows陣列中的元素,簡單地複製到陣列中。這個小技巧確保了所有除了setid的欄位都初始化為0,而且很容易編寫。順便說一句,你不應該在這種陣列複製操作中使用memcpy。現代C語言中你可以只是將一個賦值給另一個,它會自動幫你處理複製。

處理複雜引數

我執行了一些更復雜的引數解析,但是這不是處理它們的最好方法。在這本書的後面我們將會了解一些用於解析的更好方法。

將字串轉換為整數

我使用了atoi函式在命令列中接受作為id的字串並把它轉換為int id變數。去查詢這個函式以及相似的函式。

在堆上分配大塊資料

這個程式的要點就是在我建立Database的時候,我使用了malloc來向OS請求一塊大容量的記憶體。稍後我會講得更細緻一些。

NULL就是0,所以可轉成布林值

在許多檢查中,我簡單地通過if(!ptr) die("fail!")檢測了一個指標是不是NULL。這是有效的,因為NULL會被計算成假。在一些少見的系統中,NULL會儲存在計算機中,並且表示為一些不是0的東西。但在C標準中,你可以把它當成0來編寫程式碼。到目前為止,當我說“NULL就是0”的時候,我都是對一些迂腐的人說的。

你會看到什麼

你應該為此花費大量時間,知道你可以測試它能正常工作了。並且你應當用Valgrind來確保你在所有地方都正確使用記憶體。下面是我的測試記錄,並且隨後使用了Valgrind來檢查操作:

$ make ex17
cc -Wall -g    ex17.c   -o ex17
$ ./ex17 db.dat c
$ ./ex17 db.dat s 1 zed zed@zedshaw.com
$ ./ex17 db.dat s 2 frank frank@zedshaw.com
$ ./ex17 db.dat s 3 joe joe@zedshaw.com
$
$ ./ex17 db.dat l
1 zed zed@zedshaw.com
2 frank frank@zedshaw.com
3 joe joe@zedshaw.com
$ ./ex17 db.dat d 3
$ ./ex17 db.dat l
1 zed zed@zedshaw.com
2 frank frank@zedshaw.com
$ ./ex17 db.dat g 2
2 frank frank@zedshaw.com
$
$ valgrind --leak-check=yes ./ex17 db.dat g 2
# cut valgrind output...
$

Valgrind實際的輸出沒有顯式,因為你應該能夠發現它。

Vagrind可以報告出你洩露的小塊記憶體,但是它有時會過度報告OSX內部的API。如果你發現它顯示了不屬於你程式碼中的洩露,可以忽略它們。

堆和棧的記憶體分配

對於現在你們這些年輕人來說,程式設計簡直太容易了。如果你玩玩Ruby或者Python的話,只要建立物件或變數就好了,不用管它們存放在哪裡。你並不關心它們是否存放在棧上或堆上。你的程式語言甚至完全不會把變數放在棧上,它們都在堆上,並且你也不知道是否是這樣。

然而C完全不一樣,因為它使用了CPU真實的機制來完成工作,這涉及到RAM中的一塊叫做棧的區域,以及另外一塊叫做堆的區域。它們的差異取決於取得儲存空間的位置。

堆更容易解釋,因為它就是你電腦中的剩餘記憶體,你可以通過malloc訪問它來獲取更多記憶體,OS會使用內部函式為你註冊一塊記憶體區域,並且返回指向它的指標。當你使用完這片區域時,你應該使用free把它交還給OS,使之能被其它程式複用。如果你不這樣做就會導致程式“洩露”記憶體,但是Valgrind會幫你監測這些記憶體洩露。

棧是一個特殊的記憶體區域,它儲存了每個函式的建立的臨時變數,它們對於該函式為區域性變數。它的工作機制是,函式的每個函式都會“壓入”棧中,並且可在函式內部使用。它是一個真正的棧資料結構,所以是後進先出的。這對於main中所有類似char sectionint id的區域性變數也是相同的。使用棧的優點是,當函式退出時C編譯器會從棧中“彈出”所有變數來清理。這非常簡單,也防止了棧上變數的記憶體洩露。

理清記憶體的最簡單的方式是遵守這條原則:如果你的變數並不是從malloc中獲取的,也不是從一個從malloc獲取的函式中獲取的,那麼它在棧上。

下面是三個值得關注的關於棧和堆的主要問題:

  • 如果你從malloc獲取了一塊記憶體,並且把指標放在了棧上,那麼當函式退出時,指標會被彈出而丟失。
  • 如果你在棧上存放了大量資料(比如大結構體和陣列),那麼會產生“棧溢位”並且程式會中止。這種情況下應該通過malloc放在堆上。
  • 如果你獲取了指向棧上變數的指標,並且將它用於傳參或從函式返回,接收它的函式會產生“段錯誤”。因為實際的資料被彈出而消失,指標也會指向被釋放的記憶體。

這就是我在程式中使用Database_open來分配記憶體或退出的原因,相應的Database_close用於釋放記憶體。如果你建立了一個“建立”函式,它建立了一些東西,那麼一個“銷燬”函式可以安全地清理這些東西。這樣會更容易理清記憶體。

最後,當一個程式退出時,OS會為你清理所有的資源,但是有時不會立即執行。一個慣用法(也是本次練習中用到的)是立即終止並且讓OS清理錯誤。

如何使它崩潰

這個程式有很多可以使之崩潰的地方,嘗試下面這些東西,同時也想出自己的辦法。

  • 最經典的方法是移除一些安全檢查,你就可以傳入任意資料。例如,第160行的檢查防止你傳入任何記錄序號。
  • 你也可以嘗試弄亂資料檔案。使用任何編輯器開啟它並且隨機修改幾個位元組並關閉。
  • 你也可以尋找在執行中向程式傳遞非法引數的辦法。例如將檔案引數放到動作後面,就會建立一個以動作命名的檔案,並且按照檔名的第一個字元執行動作。
  • 這個程式中有個bug,因為strncpy有設計缺陷。查詢strncpy的相關資料,然後試著弄清楚如果name或者address超過512個位元組會發生什麼。可以通過簡單把最後一個字元設定成'\0'來修復它,你應該無論如何都這樣做(這也是函式原本應該做的)。
  • 在附加題中我會讓你傳遞引數來建立任意大小的資料庫。在你造成程式退出或malloc的記憶體不足之前,嘗試找出最大的資料庫尺寸是多少。

附加題

  • die函式需要接收conn變數作為引數,以便執行清理並關閉它。
  • 修改程式碼,使其接收引數作為MAX_DATAMAX_ROWS,將它們儲存在Database結構體中,並且將它們寫到檔案。這樣就可以建立任意大小的資料庫。
  • 向資料庫新增更多操作,比如find
  • 查詢C如何打包結構體,並且試著弄清楚為什麼你的檔案是相應的大小。看看你是否可以計算出結構體新增一些欄位之後的新大小。
  • Address新增一些欄位,使它們可被搜尋。
  • 編寫一個shell指令碼來通過以正確順序執行命令執行自動化測試。提示:在bash頂端使用使用set -e,使之在任何命令發生錯誤時退出。

    譯者注:使用Python編寫多行指令碼或許更方便一些。

  • 嘗試重構程式,使用單一的全域性變數來儲存資料庫連線。這個新版本和舊版本比起來如何?
  • 搜尋“棧資料結構”,並且在你最喜歡的語言中實現它,然後嘗試在C中實現。

相關文章