1. 基本規則

除了Hello World這種極簡單的程序之外,一般的程序都是由多個源檔案編譯連結而成的,這些源檔案的處理步驟通常用Makefile來管理。Makefile起什麼作用呢?我們先看一個例子,這個例子由例 12.3 “用深度優先搜索解迷宮問題”改寫而成:

/* main.c */
#include <stdio.h>
#include "main.h"
#include "stack.h"
#include "maze.h"

struct point predecessor[MAX_ROW][MAX_COL] = {
	{{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}},
	{{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}},
	{{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}},
	{{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}},
	{{-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}, {-1,-1}},
};

void visit(int row, int col, struct point pre)
{
	struct point visit_point = { row, col };
	maze[row][col] = 2;
	predecessor[row][col] = pre;
	push(visit_point);
}

int main(void)
{
	struct point p = { 0, 0 };

	maze[p.row][p.col] = 2;
	push(p);	
	
	while (!is_empty()) {
		p = pop();
		if (p.row == MAX_ROW - 1  /* goal */
		    && p.col == MAX_COL - 1)
			break;
		if (p.col+1 < MAX_COL     /* right */
		    && maze[p.row][p.col+1] == 0)
			visit(p.row, p.col+1, p);
		if (p.row+1 < MAX_ROW     /* down */
		    && maze[p.row+1][p.col] == 0)
			visit(p.row+1, p.col, p);
		if (p.col-1 >= 0          /* left */
		    && maze[p.row][p.col-1] == 0)
			visit(p.row, p.col-1, p);
		if (p.row-1 >= 0          /* up */
		    && maze[p.row-1][p.col] == 0)
			visit(p.row-1, p.col, p);
		print_maze();
	}
	if (p.row == MAX_ROW - 1 && p.col == MAX_COL - 1) {
		printf("(%d, %d)\n", p.row, p.col);
		while (predecessor[p.row][p.col].row != -1) {
			p = predecessor[p.row][p.col];
			printf("(%d, %d)\n", p.row, p.col);
		}
	} else
		printf("No path!\n");

	return 0;
}

我們把堆棧和迷宮的代碼分別轉移到模組stack.cmaze.c中,main.c包含它們提供的標頭檔stack.hmaze.h

/* main.h */
#ifndef MAIN_H
#define MAIN_H

typedef struct point { int row, col; } item_t;

#define MAX_ROW 5
#define MAX_COL 5

#endif

main.h中定義了一個類型和兩個常量,main.cstack.cmaze.c都要用到這些定義,都要包含這個標頭檔。

/* stack.c */
#include "stack.h"

static item_t stack[512];
static int top = 0;

void push(item_t p)
{
	stack[top++] = p;
}

item_t pop(void)
{
	return stack[--top];
}

int is_empty(void)
{
	return top == 0;
}
/* stack.h */
#ifndef STACK_H
#define STACK_H

#include "main.h" /* provides definition for item_t */

extern void push(item_t);
extern item_t pop(void);
extern int is_empty(void);

#endif

例 12.3 “用深度優先搜索解迷宮問題”中的堆棧規定死了只能放char型數據,現在我們做進一步抽象,堆棧中放item_t類型的數據,item_t可以定義為任意類型,只要它能夠通過函數的參數和返回值傳遞並且支持賦值操作就行。這也是一種避免硬編碼的策略,stack.c中多次使用item_t類型,要改變它的定義只需改變main.h中的一行代碼。

/* maze.c */
#include <stdio.h>
#include "maze.h"

int maze[MAX_ROW][MAX_COL] = {
	0, 1, 0, 0, 0,
	0, 1, 0, 1, 0,
	0, 0, 0, 0, 0,
	0, 1, 1, 1, 0,
	0, 0, 0, 1, 0,
};

void print_maze(void)
{
	int i, j;
	for (i = 0; i < MAX_ROW; i++) {
		for (j = 0; j < MAX_COL; j++)
			printf("%d ", maze[i][j]);
		putchar('\n');
	}
	printf("*********\n");
}
/* maze.h */
#ifndef MAZE_H
#define MAZE_H

#include "main.h" /* provides defintion for MAX_ROW and MAX_COL */

extern int maze[MAX_ROW][MAX_COL];
void print_maze(void);

#endif

maze.c中定義了一個maze數組和一個print_maze函數,需要在標頭檔maze.h中聲明,以便提供給main.c使用,注意print_maze的聲明可以不加extern,而maze的聲明必須加extern

這些源檔案可以這樣編譯:

$ gcc main.c stack.c maze.c -o main

但這不是個好辦法,如果編譯之後又對maze.c做了修改,又要把所有源檔案編譯一遍,即使main.cstack.c和那些標頭檔都沒有修改也要跟着重新編譯。一個大型的軟件項目往往由上千個源檔案組成,全部編譯一遍需要幾個小時,只改一個源檔案就要求全部重新編譯肯定是不合理的。

這樣編譯也許更好一些:

$ gcc -c main.c
$ gcc -c stack.c
$ gcc -c maze.c
$ gcc main.o stack.o maze.o -o main

如果編譯之後又對maze.c做了修改,要重新編譯只需要做兩步:

$ gcc -c maze.c
$ gcc main.o stack.o maze.o -o main

這樣又有一個問題,每次編譯敲的命令都不一樣,很容易出錯,比如我修改了三個源檔案,可能有一個忘了重新編譯,結果編譯完了修改沒生效,運行時出了Bug還滿世界找原因呢。更複雜的問題是,假如我改了main.h怎麼辦?所有包含main.h的源檔案都需要重新編譯,我得挨個找哪些源檔案包含了main.h,有的還很不明顯,例如stack.c包含了stack.h,而後者包含了main.h。可見手動處理這些問題非常容易出錯,那有沒有自動的解決辦法呢?有,就是寫一個Makefile檔案和原始碼放在同一個目錄下:

main: main.o stack.o maze.o
	gcc main.o stack.o maze.o -o main

main.o: main.c main.h stack.h maze.h
	gcc -c main.c

stack.o: stack.c stack.h main.h
	gcc -c stack.c

maze.o: maze.c maze.h main.h
	gcc -c maze.c

然後在這個目錄下運行make編譯:

$ make
gcc -c main.c
gcc -c stack.c
gcc -c maze.c
gcc main.o stack.o maze.o -o main

make命令會自動讀取當前目錄下的Makefile檔案[33],完成相應的編譯步驟。Makefile由一組規則(Rule)組成,每條規則的格式是:

target ... : prerequisites ... 
	command1
	command2
	...

例如:

main: main.o stack.o maze.o
	gcc main.o stack.o maze.o -o main

main是這條規則的目標(Target)main.ostack.omaze.o是這條規則的條件(Prerequisite)。目標和條件之間的關係是:欲更新目標,必須首先更新它的所有條件;所有條件中只要有一個條件被更新了,目標也必須隨之被更新。所謂“更新”就是執行一遍規則中的命令列表,命令列表中的每條命令必須以一個Tab開頭,注意不能是空格,Makefile的格式不像C語言的縮進那麼隨意,對於Makefile中的每個以Tab開頭的命令,make會創建一個Shell進程去執行它。

對於上面這個例子,make執行如下步驟:

  1. 嘗試更新Makefile中第一條規則的目標main,第一條規則的目標稱為預設目標,只要預設目標更新了就算完成任務了,其它工作都是為這個目的而做的。由於我們是第一次編譯,main檔案還沒生成,顯然需要更新,但規則說必須先更新了main.ostack.omaze.o這三個條件,然後才能更新main

  2. 所以make會進一步查找以這三個條件為目標的規則,這些目標檔案也沒有生成,也需要更新,所以執行相應的命令(gcc -c main.cgcc -c stack.cgcc -c maze.c)更新它們。

  3. 最後執行gcc main.o stack.o maze.o -o main更新main

如果沒有做任何改動,再次運行make

$ make
make: `main' is up to date.

make會提示預設目標已經是最新的了,不需要執行任何命令更新它。再做個實驗,如果修改了maze.h(比如加個無關痛癢的空格)再運行make

$ make
gcc -c main.c
gcc -c maze.c
gcc main.o stack.o maze.o -o main

make會自動選擇那些受影響的源檔案重新編譯,不受影響的源檔案則不重新編譯,這是怎麼做到的呢?

  1. make仍然嘗試更新預設目標,首先檢查目標main是否需要更新,這就要檢查三個條件main.ostack.omaze.o是否需要更新。

  2. make會進一步查找以這三個條件為目標的規則,然後發現main.omaze.o需要更新,因為它們都有一個條件是maze.h,而這個檔案的修改時間比main.omaze.o晚,所以執行相應的命令更新main.omaze.o

  3. 既然main的三個條件中有兩個被更新過了,那麼main也需要更新,所以執行命令gcc main.o stack.o maze.o -o main更新main

現在總結一下Makefile的規則,請讀者結合上面的例子理解。如果一條規則的目標屬於以下情況之一,就稱為需要更新:

在一條規則被執行之前,規則的條件可能處于以下三種狀態之一:

執行一條規則A的步驟如下:

  1. 檢查它的每個條件P:

    • 如果P需要更新,就執行以P為目標的規則B。之後,無論是否生成檔案P,都認為P已被更新。

    • 如果找不到規則B,並且檔案P已存在,表示P不需要更新。

    • 如果找不到規則B,並且檔案P不存在,則報錯退出。

  2. 在檢查完規則A的所有條件後,檢查它的目標T,如果屬於以下情況之一,就執行它的命令列表:

    • 檔案T不存在。

    • 檔案T存在,但是某個條件的修改時間比它晚。

    • 某個條件P已被更新(並不一定生成檔案P)。

通常Makefile都會有一個clean規則,用於清除編譯過程中產生的二進制檔案,保留源檔案:

clean:
	@echo "cleanning project"
	-rm main *.o
	@echo "clean completed"

把這條規則添加到我們的Makefile末尾,然後執行這條規則:

$ make clean 
cleanning project
rm main *.o
clean completed

如果在make的命令行中指定一個目標(例如clean),則更新這個目標,如果不指定目標則更新Makefile中第一條規則的目標(預設目標)。

和前面介紹的規則不同,clean目標不依賴于任何條件,並且執行它的命令列表不會生成clean這個檔案,剛纔說過,只要執行了命令列表就算更新了目標,即使目標並沒有生成也算。在這個例子還演示了命令前面加@-字元的效果:如果make執行的命令前面加了@字元,則不顯示命令本身而只顯示它的結果;通常make執行的命令如果出錯(該命令的退出狀態非0)就立刻終止,不再執行後續命令,但如果命令前面加了-號,即使這條命令出錯,make也會繼續執行後續命令。通常rm命令和mkdir命令前面要加-號,因為rm要刪除的檔案可能不存在,mkdir要創建的目錄可能已存在,這兩個命令都有可能出錯,但這種錯誤是應該忽略的。例如上面已經執行過一遍make clean,再執行一遍就沒有檔案可刪了,這時rm會報錯,但make忽略這一錯誤,繼續執行後面的echo命令:

$ make clean 
cleanning project
rm main *.o
rm: cannot remove `main': No such file or directory
rm: cannot remove `*.o': No such file or directory
make: [clean] Error 1 (ignored)
clean completed

讀者可以把命令前面的@-去掉再試試,對比一下結果有何不同。這裡還有一個問題,如果當前目錄下存在一個檔案叫clean會怎麼樣呢?

$ touch clean
$ make clean
make: `clean' is up to date.

如果存在clean這個檔案,clean目標又不依賴于任何條件,make就認為它不需要更新了。而我們希望把clean當作一個特殊的名字使用,不管它存在不存在都要更新,可以添一條特殊規則,把clean聲明為一個偽目標:

.PHONY: clean

這條規則沒有命令列表。類似.PHONY這種make內建的特殊目標還有很多,各有不同的用途,詳見[GNUmake]。在C語言中要求變數和函數先聲明後使用,而Makefile不太一樣,這條規則寫在clean:規則的後面也行,也能起到聲明clean是偽目標的作用:

clean:
	@echo "cleanning project"
	-rm main *.o
	@echo "clean completed"

.PHONY: clean

當然寫在前面也行。gcc處理一個C程序分為預處理和編譯兩個階段,類似地,make處理Makefile的過程也分為兩個階段:

  1. 首先從前到後讀取所有規則,建立起一個完整的依賴關係圖,例如:

    圖 22.1. Makefile的依賴關係圖

    Makefile的依賴關係圖

  2. 然後從預設目標或者命令行指定的目標開始,根據依賴關係圖選擇適當的規則執行,執行Makefile中的規則和執行C代碼不一樣,並不是從前到後按順序執行,也不是所有規則都要執行一遍,例如make預設目標時不會更新clean目標,因為從上圖可以看出,它跟預設目標沒有任何依賴關係。

clean目標是一個約定俗成的名字,在所有軟件項目的Makefile中都表示清除編譯生成的檔案,類似這樣的約定俗成的目標名字有:



[33] 只要符合本章所描述的語法的檔案我們都叫它Makefile,而它的檔案名則不一定是Makefile。事實上,執行make命令時,是按照GNUmakefilemakefileMakefile的順序找到第一個存在的檔案並執行它,不過還是建議使用Makefile做檔案名。除了GNU make,有些UNIX系統的make命令不是GNU make,不會查找GNUmakefile這個檔案名,如果你寫的Makefile包含GNU make的特殊語法,可以起名為GNUmakefile,否則不建議用這個檔案名。