鍍金池/ 教程/ C/ 練習(xí)17:堆和棧的內(nèi)存分配
練習(xí)9:數(shù)組和字符串
練習(xí)6:變量類型
練習(xí)3:格式化輸出
練習(xí)4:Valgrind 介紹
練習(xí)28:Makefile 進(jìn)階
練習(xí)14:編寫并使用函數(shù)
練習(xí)21:高級(jí)數(shù)據(jù)類型和控制結(jié)構(gòu)
練習(xí)20:Zed的強(qiáng)大的調(diào)試宏
練習(xí)18:函數(shù)指針
練習(xí)0:準(zhǔn)備
練習(xí)15:指針,可怕的指針
練習(xí)27:創(chuàng)造性和防御性編程
練習(xí)22:棧、作用域和全局
練習(xí)10:字符串?dāng)?shù)組和循環(huán)
練習(xí)8:大小和數(shù)組
練習(xí)16:結(jié)構(gòu)體和指向它們的指針
練習(xí)7:更多變量和一些算術(shù)
練習(xí)23:認(rèn)識(shí)達(dá)夫設(shè)備
練習(xí)12:If,Else If,Else
練習(xí)2:用Make來(lái)代替Python
練習(xí)1:?jiǎn)⒂镁幾g器
練習(xí)11:While循環(huán)和布爾表達(dá)式
練習(xí)5:一個(gè)C程序的結(jié)構(gòu)
練習(xí)24:輸入輸出和文件
練習(xí)25:變參函數(shù)
練習(xí)13:Switch語(yǔ)句
練習(xí)19:一個(gè)簡(jiǎn)單的對(duì)象系統(tǒng)
練習(xí)26:編寫第一個(gè)真正的程序
導(dǎo)言:C的笛卡爾之夢(mèng)
練習(xí)17:堆和棧的內(nèi)存分配

練習(xí)17:堆和棧的內(nèi)存分配

在這個(gè)練習(xí)中,你會(huì)在難度上做一個(gè)大的跳躍,并且創(chuàng)建出用于管理數(shù)據(jù)庫(kù)的完整的小型系統(tǒng)。這個(gè)數(shù)據(jù)庫(kù)并不實(shí)用也存儲(chǔ)不了太多東西,然而它展示了大多數(shù)到目前為止你學(xué)到的東西。它也以更加正規(guī)的方法介紹了內(nèi)存分配,以及帶領(lǐng)你熟悉文件處理。我們實(shí)用了一些文件IO函數(shù),但是我并不想過(guò)多解釋它們,你可以先試著自己理解。

像通常一樣,輸入下面整個(gè)程序,并且使之正常工作,之后我們會(huì)進(jìn)行討論:

#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;
}

在這個(gè)程序中我使用了一系列的結(jié)構(gòu)來(lái)創(chuàng)建用于地址薄的小型數(shù)據(jù)庫(kù)。其中,我是用了一些你從來(lái)沒(méi)見(jiàn)過(guò)的東西,所以你應(yīng)該逐行瀏覽這段代碼,解釋每一行做了什么,并且查詢你不認(rèn)識(shí)的任何函數(shù)。下面是你需要注意的幾個(gè)關(guān)鍵部分:

#define 常量

我使用了“C預(yù)處理器”的另外一部分,來(lái)創(chuàng)建MAX_DATAMAX_ROWS的設(shè)置常量。我之后會(huì)更多地講解預(yù)處理器的功能,不過(guò)這是一個(gè)創(chuàng)建可靠的常量的簡(jiǎn)易方法。除此之外還有另一種方法,但是在特定場(chǎng)景下并不適用。

定長(zhǎng)結(jié)構(gòu)體

Address結(jié)構(gòu)體接著使用這些常量來(lái)創(chuàng)建數(shù)據(jù),這些數(shù)據(jù)是定長(zhǎng)的,它們并不高效,但是便于存儲(chǔ)和讀取。Database結(jié)構(gòu)體也是定長(zhǎng)的,因?yàn)樗幸粋€(gè)定長(zhǎng)的Address結(jié)構(gòu)體數(shù)組。這樣你就可以稍后把整個(gè)數(shù)據(jù)一步寫到磁盤。

出現(xiàn)錯(cuò)誤時(shí)終止的die函數(shù)

在像這樣的小型程序中,你可以編寫一個(gè)單個(gè)函數(shù)在出現(xiàn)錯(cuò)誤時(shí)殺掉程序。我把它叫做die。而且在任何失敗的函數(shù)調(diào)用,或錯(cuò)誤輸出之后,它會(huì)調(diào)用exit帶著錯(cuò)誤退出程序。

用于錯(cuò)誤報(bào)告的 errnoperror

當(dāng)函數(shù)返回了一個(gè)錯(cuò)誤時(shí),它通常設(shè)置一個(gè)叫做errno的“外部”變量,來(lái)描述發(fā)生了什么錯(cuò)誤。它們知識(shí)數(shù)字,所以你可以使用peeror來(lái)“打印出錯(cuò)誤信息”。

文件函數(shù)

我使用了一些新的函數(shù),比如fopenfread,fclose,和rewind來(lái)處理文件。這些函數(shù)中每個(gè)都作用于FILE結(jié)構(gòu)體上,就像你的結(jié)構(gòu)體似的,但是它由C標(biāo)準(zhǔn)庫(kù)定義。

嵌套結(jié)構(gòu)體指針

你應(yīng)該學(xué)習(xí)這里的嵌套結(jié)構(gòu)器和獲取數(shù)組元素地址的用法,它讀作“讀取db中的conn中的rows的第i個(gè)元素,并返回地址(&)”。

譯者注:這里有個(gè)更簡(jiǎn)便的寫法是db->conn->row + i。

結(jié)構(gòu)體原型的復(fù)制

它在Database_delete中體現(xiàn)得最清楚,你可以看到我是用了臨時(shí)的局部Address變量,初始化了它的idset字段,接著通過(guò)把它賦值給rows數(shù)組中的元素,簡(jiǎn)單地復(fù)制到數(shù)組中。這個(gè)小技巧確保了所有除了setid的字段都初始化為0,而且很容易編寫。順便說(shuō)一句,你不應(yīng)該在這種數(shù)組復(fù)制操作中使用memcpy?,F(xiàn)代C語(yǔ)言中你可以只是將一個(gè)賦值給另一個(gè),它會(huì)自動(dòng)幫你處理復(fù)制。

處理復(fù)雜參數(shù)

我執(zhí)行了一些更復(fù)雜的參數(shù)解析,但是這不是處理它們的最好方法。在這本書的后面我們將會(huì)了解一些用于解析的更好方法。

將字符串轉(zhuǎn)換為整數(shù)

我使用了atoi函數(shù)在命令行中接受作為id的字符串并把它轉(zhuǎn)換為int id變量。去查詢這個(gè)函數(shù)以及相似的函數(shù)。

在堆上分配大塊數(shù)據(jù)

這個(gè)程序的要點(diǎn)就是在我創(chuàng)建Database的時(shí)候,我使用了malloc來(lái)向OS請(qǐng)求一塊大容量的內(nèi)存。稍后我會(huì)講得更細(xì)致一些。

NULL就是0,所以可轉(zhuǎn)成布爾值

在許多檢查中,我簡(jiǎn)單地通過(guò)if(!ptr) die("fail!")檢測(cè)了一個(gè)指針是不是NULL。這是有效的,因?yàn)?code>NULL會(huì)被計(jì)算成假。在一些少見(jiàn)的系統(tǒng)中,NULL會(huì)儲(chǔ)存在計(jì)算機(jī)中,并且表示為一些不是0的東西。但在C標(biāo)準(zhǔn)中,你可以把它當(dāng)成0來(lái)編寫代碼。到目前為止,當(dāng)我說(shuō)“NULL就是0”的時(shí)候,我都是對(duì)一些迂腐的人說(shuō)的。

你會(huì)看到什么

你應(yīng)該為此花費(fèi)大量時(shí)間,知道你可以測(cè)試它能正常工作了。并且你應(yīng)當(dāng)用Valgrind來(lái)確保你在所有地方都正確使用內(nèi)存。下面是我的測(cè)試記錄,并且隨后使用了Valgrind來(lái)檢查操作:

$ 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實(shí)際的輸出沒(méi)有顯式,因?yàn)槟銘?yīng)該能夠發(fā)現(xiàn)它。

Vagrind可以報(bào)告出你泄露的小塊內(nèi)存,但是它有時(shí)會(huì)過(guò)度報(bào)告OSX內(nèi)部的API。如果你發(fā)現(xiàn)它顯示了不屬于你代碼中的泄露,可以忽略它們。

堆和棧的內(nèi)存分配

對(duì)于現(xiàn)在你們這些年輕人來(lái)說(shuō),編程簡(jiǎn)直太容易了。如果你玩玩Ruby或者Python的話,只要?jiǎng)?chuàng)建對(duì)象或變量就好了,不用管它們存放在哪里。你并不關(guān)心它們是否存放在棧上或堆上。你的編程語(yǔ)言甚至完全不會(huì)把變量放在棧上,它們都在堆上,并且你也不知道是否是這樣。

然而C完全不一樣,因?yàn)樗褂昧薈PU真實(shí)的機(jī)制來(lái)完成工作,這涉及到RAM中的一塊叫做棧的區(qū)域,以及另外一塊叫做堆的區(qū)域。它們的差異取決于取得儲(chǔ)存空間的位置。

堆更容易解釋,因?yàn)樗褪悄汶娔X中的剩余內(nèi)存,你可以通過(guò)malloc訪問(wèn)它來(lái)獲取更多內(nèi)存,OS會(huì)使用內(nèi)部函數(shù)為你注冊(cè)一塊內(nèi)存區(qū)域,并且返回指向它的指針。當(dāng)你使用完這片區(qū)域時(shí),你應(yīng)該使用free把它交還給OS,使之能被其它程序復(fù)用。如果你不這樣做就會(huì)導(dǎo)致程序“泄露”內(nèi)存,但是Valgrind會(huì)幫你監(jiān)測(cè)這些內(nèi)存泄露。

棧是一個(gè)特殊的內(nèi)存區(qū)域,它儲(chǔ)存了每個(gè)函數(shù)的創(chuàng)建的臨時(shí)變量,它們對(duì)于該函數(shù)為局部變量。它的工作機(jī)制是,函數(shù)的每個(gè)函數(shù)都會(huì)“壓入”棧中,并且可在函數(shù)內(nèi)部使用。它是一個(gè)真正的棧數(shù)據(jù)結(jié)構(gòu),所以是后進(jìn)先出的。這對(duì)于main中所有類似char sectionint id的局部變量也是相同的。使用棧的優(yōu)點(diǎn)是,當(dāng)函數(shù)退出時(shí)C編譯器會(huì)從棧中“彈出”所有變量來(lái)清理。這非常簡(jiǎn)單,也防止了棧上變量的內(nèi)存泄露。

理清內(nèi)存的最簡(jiǎn)單的方式是遵守這條原則:如果你的變量并不是從malloc中獲取的,也不是從一個(gè)從malloc獲取的函數(shù)中獲取的,那么它在棧上。

下面是三個(gè)值得關(guān)注的關(guān)于棧和堆的主要問(wèn)題:

  • 如果你從malloc獲取了一塊內(nèi)存,并且把指針?lè)旁诹藯I希敲串?dāng)函數(shù)退出時(shí),指針會(huì)被彈出而丟失。
  • 如果你在棧上存放了大量數(shù)據(jù)(比如大結(jié)構(gòu)體和數(shù)組),那么會(huì)產(chǎn)生“棧溢出”并且程序會(huì)中止。這種情況下應(yīng)該通過(guò)malloc放在堆上。
  • 如果你獲取了指向棧上變量的指針,并且將它用于傳參或從函數(shù)返回,接收它的函數(shù)會(huì)產(chǎn)生“段錯(cuò)誤”。因?yàn)閷?shí)際的數(shù)據(jù)被彈出而消失,指針也會(huì)指向被釋放的內(nèi)存。

這就是我在程序中使用Database_open來(lái)分配內(nèi)存或退出的原因,相應(yīng)的Database_close用于釋放內(nèi)存。如果你創(chuàng)建了一個(gè)“創(chuàng)建”函數(shù),它創(chuàng)建了一些東西,那么一個(gè)“銷毀”函數(shù)可以安全地清理這些東西。這樣會(huì)更容易理清內(nèi)存。

最后,當(dāng)一個(gè)程序退出時(shí),OS會(huì)為你清理所有的資源,但是有時(shí)不會(huì)立即執(zhí)行。一個(gè)慣用法(也是本次練習(xí)中用到的)是立即終止并且讓OS清理錯(cuò)誤。

如何使它崩潰

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

  • 最經(jīng)典的方法是移除一些安全檢查,你就可以傳入任意數(shù)據(jù)。例如,第160行的檢查防止你傳入任何記錄序號(hào)。
  • 你也可以嘗試弄亂數(shù)據(jù)文件。使用任何編輯器打開(kāi)它并且隨機(jī)修改幾個(gè)字節(jié)并關(guān)閉。
  • 你也可以尋找在運(yùn)行中向程序傳遞非法參數(shù)的辦法。例如將文件參數(shù)放到動(dòng)作后面,就會(huì)創(chuàng)建一個(gè)以動(dòng)作命名的文件,并且按照文件名的第一個(gè)字符執(zhí)行動(dòng)作。
  • 這個(gè)程序中有個(gè)bug,因?yàn)?code>strncpy有設(shè)計(jì)缺陷。查詢strncpy的相關(guān)資料,然后試著弄清楚如果name或者address超過(guò)512個(gè)字節(jié)會(huì)發(fā)生什么??梢酝ㄟ^(guò)簡(jiǎn)單把最后一個(gè)字符設(shè)置成'\0'來(lái)修復(fù)它,你應(yīng)該無(wú)論如何都這樣做(這也是函數(shù)原本應(yīng)該做的)。
  • 在附加題中我會(huì)讓你傳遞參數(shù)來(lái)創(chuàng)建任意大小的數(shù)據(jù)庫(kù)。在你造成程序退出或malloc的內(nèi)存不足之前,嘗試找出最大的數(shù)據(jù)庫(kù)尺寸是多少。

附加題

  • die函數(shù)需要接收conn變量作為參數(shù),以便執(zhí)行清理并關(guān)閉它。
  • 修改代碼,使其接收參數(shù)作為MAX_DATAMAX_ROWS,將它們儲(chǔ)存在Database結(jié)構(gòu)體中,并且將它們寫到文件。這樣就可以創(chuàng)建任意大小的數(shù)據(jù)庫(kù)。
  • 向數(shù)據(jù)庫(kù)添加更多操作,比如find。
  • 查詢C如何打包結(jié)構(gòu)體,并且試著弄清楚為什么你的文件是相應(yīng)的大小。看看你是否可以計(jì)算出結(jié)構(gòu)體添加一些字段之后的新大小。
  • Address添加一些字段,使它們可被搜索。
  • 編寫一個(gè)shell腳本來(lái)通過(guò)以正確順序運(yùn)行命令執(zhí)行自動(dòng)化測(cè)試。提示:在bash頂端使用使用set -e,使之在任何命令發(fā)生錯(cuò)誤時(shí)退出。

    譯者注:使用Python編寫多行腳本或許更方便一些。

  • 嘗試重構(gòu)程序,使用單一的全局變量來(lái)儲(chǔ)存數(shù)據(jù)庫(kù)連接。這個(gè)新版本和舊版本比起來(lái)如何?
  • 搜索“棧數(shù)據(jù)結(jié)構(gòu)”,并且在你最喜歡的語(yǔ)言中實(shí)現(xiàn)它,然后嘗試在C中實(shí)現(xiàn)。