練習17:堆和棧的內存分配
譯者:飛龍
在這個練習中,你會在難度上做一個大的跳躍,并且創建出用于管理數據庫的完整的小型系統。這個數據庫并不實用也存儲不了太多東西,然而它展示了大多數到目前為止你學到的東西。它也以更加正規的方法介紹了內存分配,以及帶領你熟悉文件處理。我們實用了一些文件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_DATA
和MAX_ROWS
的設置常量。我之后會更多地講解預處理器的功能,不過這是一個創建可靠的常量的簡易方法。除此之外還有另一種方法,但是在特定場景下并不適用。
定長結構體
Address
結構體接著使用這些常量來創建數據,這些數據是定長的,它們并不高效,但是便于存儲和讀取。Database
結構體也是定長的,因為它有一個定長的Address
結構體數組。這樣你就可以稍后把整個數據一步寫到磁盤。
出現錯誤時終止的die
函數
在像這樣的小型程序中,你可以編寫一個單個函數在出現錯誤時殺掉程序。我把它叫做die
。而且在任何失敗的函數調用,或錯誤輸出之后,它會調用exit
帶著錯誤退出程序。
用于錯誤報告的 errno
和perror
當函數返回了一個錯誤時,它通常設置一個叫做errno
的“外部”變量,來描述發生了什么錯誤。它們知識數字,所以你可以使用peeror
來“打印出錯誤信息”。
文件函數
我使用了一些新的函數,比如fopen
,fread
,fclose
,和rewind
來處理文件。這些函數中每個都作用于FILE
結構體上,就像你的結構體似的,但是它由C標準庫定義。
嵌套結構體指針
你應該學習這里的嵌套結構器和獲取數組元素地址的用法,它讀作“讀取db
中的conn
中的rows
的第i
個元素,并返回地址(&
)”。
譯者注:這里有個更簡便的寫法是
db->conn->row + i
。
結構體原型的復制
它在Database_delete
中體現得最清楚,你可以看到我是用了臨時的局部Address
變量,初始化了它的id
和set
字段,接著通過把它賦值給rows
數組中的元素,簡單地復制到數組中。這個小技巧確保了所有除了set
和id
的字段都初始化為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 section
和int id
的局部變量也是相同的。使用棧的優點是,當函數退出時C編譯器會從棧中“彈出”所有變量來清理。這非常簡單,也防止了棧上變量的內存泄露。
理清內存的最簡單的方式是遵守這條原則:如果你的變量并不是從malloc
中獲取的,也不是從一個從malloc
獲取的函數中獲取的,那么它在棧上。
下面是三個值得關注的關于棧和堆的主要問題:
- 如果你從
malloc
獲取了一塊內存,并且把指針放在了棧上,那么當函數退出時,指針會被彈出而丟失。 - 如果你在棧上存放了大量數據(比如大結構體和數組),那么會產生“棧溢出”并且程序會中止。這種情況下應該通過
malloc
放在堆上。 - 如果你獲取了指向棧上變量的指針,并且將它用于傳參或從函數返回,接收它的函數會產生“段錯誤”。因為實際的數據被彈出而消失,指針也會指向被釋放的內存。
這就是我在程序中使用Database_open
來分配內存或退出的原因,相應的Database_close
用于釋放內存。如果你創建了一個“創建”函數,它創建了一些東西,那么一個“銷毀”函數可以安全地清理這些東西。這樣會更容易理清內存。
最后,當一個程序退出時,OS會為你清理所有的資源,但是有時不會立即執行。一個慣用法(也是本次練習中用到的)是立即終止并且讓OS清理錯誤。
如何使它崩潰
這個程序有很多可以使之崩潰的地方,嘗試下面這些東西,同時也想出自己的辦法。
- 最經典的方法是移除一些安全檢查,你就可以傳入任意數據。例如,第160行的檢查防止你傳入任何記錄序號。
- 你也可以嘗試弄亂數據文件。使用任何編輯器打開它并且隨機修改幾個字節并關閉。
- 你也可以尋找在運行中向程序傳遞非法參數的辦法。例如將文件參數放到動作后面,就會創建一個以動作命名的文件,并且按照文件名的第一個字符執行動作。
- 這個程序中有個bug,因為
strncpy
有設計缺陷。查詢strncpy
的相關資料,然后試著弄清楚如果name
或者address
超過512個字節會發生什么。可以通過簡單把最后一個字符設置成'\0'
來修復它,你應該無論如何都這樣做(這也是函數原本應該做的)。 - 在附加題中我會讓你傳遞參數來創建任意大小的數據庫。在你造成程序退出或
malloc
的內存不足之前,嘗試找出最大的數據庫尺寸是多少。
附加題
-
die
函數需要接收conn
變量作為參數,以便執行清理并關閉它。 - 修改代碼,使其接收參數作為
MAX_DATA
和MAX_ROWS
,將它們儲存在Database
結構體中,并且將它們寫到文件。這樣就可以創建任意大小的數據庫。 - 向數據庫添加更多操作,比如
find
。 - 查詢C如何打包結構體,并且試著弄清楚為什么你的文件是相應的大小。看看你是否可以計算出結構體添加一些字段之后的新大小。
- 向
Address
添加一些字段,使它們可被搜索。 - 編寫一個shell腳本來通過以正確順序運行命令執行自動化測試。提示:在
bash
頂端使用使用set -e
,使之在任何命令發生錯誤時退出。譯者注:使用Python編寫多行腳本或許更方便一些。
- 嘗試重構程序,使用單一的全局變量來儲存數據庫連接。這個新版本和舊版本比起來如何?
- 搜索“棧數據結構”,并且在你最喜歡的語言中實現它,然后嘗試在C中實現。