所謂模塊化編程,就是指一個程序包含多個源文件(.c 文件和 .h 文件),每個 .c 文件可以被稱為一個模塊。
本章將會帶你了解多文件編程,教你學會如果有效的組織各個文件,如何將各個文件聯系起來。
1. C語言多文件編譯、鏈接的原理
在講解 extern 和 static 關鍵字的時候,我們已經給出了幾個簡單的多文件編程的例子,現在不妨再看一個例子。
main.c 源碼:
1 #include <stdio.h> 2 #include <conio.h> 3 // 也可以不寫 extern;為了程序可讀性,建議寫上 4 extern long sum(int, int); 5 // 必須寫 extern 6 extern char* OS; 7 int main() 8 { 9 int n1 = 1, n2 = 100; 10 printf("從%d加到%d的和為%ld [By %s]", n1, n2, sum(n1, n2), OS); 11 getch(); 12 return 0; 13 }
module.c 源碼:
1 #include <stdio.h> 2 // 當前操作系統 3 char *OS = "Windows 7"; 4 long sum(int fromNum, int endNum) 5 { 6 int i; 7 long result = 0; 8 // 參數不符合規則,返回 -1 9 if(fromNum<0 || endNum<0 || endNum<fromNum) 10 { 11 return -1; 12 } 13 for(i=fromNum; i<=endNum; i++) 14 { 15 result += i; 16 } 17 // 返回大於等於0的值 18 return result; 19 }
運行結果:
從1加到100的和為5050 [By Windows 7]
這個程序,我們按照“編譯 --> 鏈接 --> 運行”的步驟來生成,不要按 F5 或者 F7(對於 Visual C++)直接生成 exe。如果已經生成,可以清理掉相關文件。
注意:一個程序有且只能有一個 main() 函數,即使它有多個源文件。main() 函數是程序的入口函數,雙擊運行程序時就從這里開始執行。
編譯和鏈接的原理
對於C,首先要把源文件編譯(Compile)成目標文件(Object File),也就是Windows下的 .obj 文件。然后再把單個或多個 Object File 合並成可執行文件,也就是Windows下是 .exe 文件,這個動作叫作鏈接(Link)。
編譯時,編譯器需要檢查語法是否正確,函數、變量的聲明是否正確;只有函數、變量的聲明但沒有定義是完全正確的。函數聲明是告訴編譯器該函數已經存在,但是入口地址還未確定,暫時在此做個標記,鏈接時編譯器會找到函數入口地址,並將標記替換掉。
這些都校驗通過,編譯器就可以編譯出中間目標文件。一般來說,編譯是針對單個源文件的,多個源文件需要編譯多次,每個源文件都會生成一個對應的目標文件。
編譯產生的 .obj 文件已經是二進制文件,與 .exe 的組織形式類似,只是有些函數的入口地址還未找到,程序不能執行。鏈接的作用就是找到函數入口地址,將所有的源文件組織成一個可以執行的二進制文件。
鏈接時,主要是鏈接函數和全局變量。鏈接器並不管函數或變量所在的源文件,只管中間目標文件(Object File)。
總結一下:源文件首先會生成中間目標文件,再由中間目標文件生成可執行文件。在編譯時,編譯器只檢測程序語法、函數聲明、變量聲明是否正確。如果函數未被聲明,編譯器會給出一個警告,但可以生成Object File。而在鏈接程序時,鏈接器會在所有的Object File中找尋函數的實現,如果找不到,那到就會報鏈接錯誤碼(Linker Error),在VC下,這種錯誤一般是 Link 2001 錯誤,意思就是說,鏈接器未能找到函數的實現。你需要指定函數的Object File。
一步一步生成 exe 文件
知道了編譯鏈接的原理,接下來我們使用VC一步步的生成 .exe 文件。
首先切換到 main.c 面板,按下 Ctrl+F7 鍵(編譯),打開項目目錄下的 Release 或 Debug 文件夾,可以看到多出了一個 main.obj 文件,這就是 main.c 生成的目標文件。
再切換到 module.c 面板,進行同樣的操作,會生成 module.obj 文件,至此編譯完畢(所有的源文件都編譯過了)。
最后,按下 F7 鍵(鏈接),就生成了 main.exe,雙擊運行就可以看到上面的輸出結果了。
2. C語言模塊化編程中的頭文件
上節我們編寫了 main.c 和 module.c 兩個源文件,並在 module.c 中定義了一個函數和一個全局變量,然后在 main.c 中進行了聲明。
不過實際開發中很少這樣做,一般是將函數和變量的聲明放到頭文件,再在當前的源文件中 #include 進來。而且,全局變量最好聲明為 static,只在當前文件中可見,不要對外暴露;如果必須對外暴露,可以使用宏定義代替,請看下面的代碼。
main.c 源碼:
1 #include <stdio.h> 2 #include <conio.h> 3 #include "module.h" 4 int main() 5 { 6 int n1 = 1, n2 = 100; 7 printf("從%d加到%d的和為%ld [By %s]", n1, n2, sum(n1, n2), OS); 8 getch(); 9 return 0; 10 }
module.c 源碼:
1 #include <stdio.h> 2 long sum(int fromNum, int endNum) 3 { 4 int i; 5 long result = 0; 6 // 參數不符合規則,返回 -1 7 if(fromNum<0 || endNum<0 || endNum<fromNum) 8 { 9 return -1; 10 } 11 for(i=fromNum; i<=endNum; i++) 12 { 13 result += i; 14 } 15 // 返回大於等於0的值 16 return result; 17 }
module.h 源碼:
1 // 用宏定義來代替全局變量 2 #define OS "Windows 7" 3 // 也可以省略 extern;不過為了程序可讀性,建議寫上 4 extern long sum(int, int);
運行結果:
從1加到100的和為5050 [By Windows 7]
與上節中的例子相比,我們用宏定義代替了全局變量,將函數聲明和宏定義都放在了頭文件 module.h,這樣在 main.c 中只需要將 module.h 包含就來就可以。
.c 文件和 .h 文件都是源文件,除了后綴不一樣便於區分外和管理外,其他的幾乎相同,在 .c 中編寫的代碼同樣也可以寫在 .h 中,例如函數定義、預處理等。
但是 .h 文件和 .c 文件在項目中承擔的角色不一樣:.c 文件主要負責實現,也就是定義函數;.h 文件主要負責聲明,比如函數聲明、宏定義等。這些不是C語法規定的內容,而是約定成俗的規范。
下面是關於頭文件的事實標准:
- 可以聲明函數,但不可以定義函數。
- 可以聲明常量,但不可以定義變量。
- 可以“定義”一個宏函數。注意:宏函數很像函數,但卻不是函數。其實還是一個聲明。
- 結構的定義、自定義數據類型一般也放在頭文件中。
- 除了主文件(有 main() 函數的文件),其他的 .c 文件一般只定義函數,並向外暴露(可以使用 extern,也可以不使用)。
- 可以將一個或多個相關的函數定義在一個 .c 文件。
在很多場合,為了商業目的,項目的源代碼不便(或不准)向用戶公布,必須編譯成 .obj 文件(二進制庫)。我們只要向用戶提供庫文件和頭文件就可以,用戶會按照頭文件中的函數的聲明來調用庫中的函數,而不需要知道函數是怎么實現的,這就很好的保護了我們的版權。
3. C語言標准庫以及標准頭文件
源文件通過編譯可以生成 .obj 文件(二進制庫文件),並提供一個頭文件向外暴露接口,除了保護版權,還可以將散亂的文件打包,便於發布和使用。
實際上我們一般不直接向用戶提供 .obj 文件,而是將多個 .obj 文件打包成 .lib 文件(靜態庫)或 .dll 文件(動態庫)。
.obj 打包成 .lib 或 .dll 也要經過鏈接的過程來找到函數入口、變量聲明等,在VC中可以直接創建相應的工程來生成(與創建 Win32 Console Application 類似,后續會講解)。
.lib 和 .dll 可以看成是一堆 .obj 的集合,雖然有入口函數,但不能直接運行,必須被鏈接到 .exe 或被 .exe 調用。
C語言在發布時已經將常用的函數、宏、類型定義等打包到了靜態庫,並提供了相應的頭文件。如果你使用的是VC,那么在安裝目錄下的 \VC98\Include\ 文件夾中會看到很多頭文件,包括我們常用的 stdio.h、stdlib.h 等;在 \VC98\Lib\ 文件夾中有很多 .lib 文件,這就是我們鏈接時要用到的靜態庫。
例如我的 VC6.0 安裝在 C:\Program Files\Microsoft Visual Studio\ 目錄,那么 VC6.0 附帶的所有頭文件都在 C:\Program Files\Microsoft Visual Studio\VC98\Include\ 目錄下,所有 .lib 文件都在 C:\Program Files\Microsoft Visual Studio\VC98\Include\Lib\ 目錄下。
如果忘記 VC6.0 的安裝目錄或者頭文件在不在安裝目錄下,可以通過以下方式找到:
1) 在工具欄中點擊“工具”按鈕
2) 在二級菜單中選擇“選項”
3) 在彈出的對話框中選擇“目錄”標簽
4) 然后選擇名字為“目錄”的下拉菜單中的“Include files”一項,如下圖所示:
ANSI C 規范共定義了 15 個頭文件,稱為“C標准庫”,所有的編譯器都必須支持,如何正確並熟練的使用這些標准庫,可以反映出一個程序員的水平:
- 合格程序員:<stdio.h>、<ctype.h>、<stdlib.h>、<string.h>
- 熟練程序員:<assert.h>、<limits.h>、<stddef.h>、<time.h>
- 優秀程序員:<float.h>、<math.h>、<error.h>、<locale.h>、<setjmp.h>、<signal.h>、<stdarg.h>
除了C標准庫,編譯器一般也會附帶自己的庫,以增加功能,方便用戶開發,爭奪市場份額。這些庫中的每一個函數都在對應的頭文件中聲明,可以通過 #include 預處理命令導入,編譯時會被合並到當前文件。
注意:引入編譯器自帶的頭文件(包括標准頭文件)用尖括號,引入自定義頭文件用雙引號,例如:
- #include <stdio.h>
- #include "myFile.h"
4. C語言頭文件的特性和規范
頭文件通過 #include 命令包含到當前文件,效果與直接復制頭文件的內容相同;編譯器在預處理階段實際上也是這樣做的。
不管是標准頭文件還是我們自己編寫的頭文件,都應該遵循等冪性:可以多次包含相同的頭文件,但效果與只包含一次相同。
等冪性很容易實現,對於大多數的頭文件可以使用宏保護。例如,在 stdio.h 中可以有如下的宏定義:
- #ifndef _STDIO_H
- #define _STDIO_H
- /* 聲明部分 */
- #endif
第一次包含頭文件,會定義宏 _STDIO_H,並執行聲明部分的代碼;第二次因為已經定義了宏 _STDIO_H,不會重復執行聲明部分的代碼。也就是說,頭文件只在第一次包含時起作用,再次包含無效。
C標准庫的頭文件,也具有相互獨立性:任何標准頭文件的正常工作都不需要以包含其他標准頭文件為前提,也沒有任何標准頭文件包含了其他標准頭文件。
在C程序員中所達成的一個約定是:C源文件的開頭部分要包含所有要用到的頭文件。在 #include 指令之前只能有一句注釋語句。引入的頭文件可以按任意順序排列。
如果我們自己編寫的頭文件(例如a.h)會用到其他頭文件(例如 b.h)中的定義或聲明,也可以在 a.h 的開頭 #include “b.h”。這樣,就不會在 .c 文件中忘記包含 b.h,也不會有順序問題。這正是利用了頭文件的等冪性。
5. C語言頭文件的路徑
引入編譯器自帶的頭文件(包括標准頭文件)用尖括號,引入自定義頭文件用雙引號,例如:
- #include <stdio.h>
- #include "myFile.h"
這是由頭文件的路徑決定的。理論上,你可以將頭文件放在磁盤上的任何位置,只要帶路徑包含進來就可以。下面我們以 VC 6.0 為例進行講解。
VC 6.0 在安裝時會自動添加多個環境變量。例如我的 VC 6.0 安裝在 C:\Program Files\Microsoft Visual Studio 目錄下,那么:
- 環境變量 include 中會包含 C:\Program Files\Microsoft Visual Studio\VC98\include,用以指向頭文件所在的目錄(編譯時需要頭文件);
- 環境變量 lib 中會包含 C:\Program Files\Microsoft Visual Studio\VC98\lib,用以指向靜態庫所在的目錄(鏈接時需要靜態庫)。
使用尖括號,編譯器會到環境變量 include 指定的目錄去查找頭文件。
使用雙引號,編譯器首先會在當前目錄查找,找到就包含就來,找不到再到環境變量 include 指定的目錄去查找。
注意:這里說的當前目錄不是指當前工程的根目錄,而是要編譯的文件所在的目錄。
例如我們通過 VC 6.0 創建了一個工程,命名為 demo,保存在 E:\demo(工程根目錄)。現在 E:\demo 目錄下有 main.c,E:\demo\include 下有 func.h,E:\demo\module 下有 func.c,如下圖所示:
如果 main.c 需要包含 func.h,就要:
- #include "include/func.h"
如果 func.c 需要包含 func.h,就要:
- #include "../include/func.h" /* ../ 表示上級目錄 */
注意,包含頭文件時你可以使用正斜杠(/),也可以使用反斜杠(\),它們都可以表示目錄的層次。
上面說的是相對路徑,以當前文件所在的目錄為起點開始查找。一般情況下頭文件都在工程目錄,使用相對路徑比較方便。
如果頭文件不在工程目錄並且離當前文件比較遠,你也可以使用絕對路徑,例如:
- #include "E:\demo\include\func.h"
絕對路徑以分區(盤符)為起點開始查找。
6. C語言多文件編程的例子
這一節向大家展示一個比較規范的多文件編程的例子,將前面幾節的知識運用起來。
通過 VC 6.0 創建一個工程,保存到 E:\demo 目錄,工程文件有:
main.c 源碼:
1 #include <stdio.h> 2 #include <conio.h> 3 #include "include/func.h" 4 int main() 5 { 6 int n1 = 1, n2 = 10; 7 printf("從%d加到%d的和為%ld\n", n1, n2, sum(n1, n2)); 8 printf("從%d乘到%d的積為%ld\n", n1, n2, mult(n1, n2)); 9 printf("OS:%s\n",OS); 10 printf("Power By %s(%s)", getWebName(), getWebURL()); 11 getch(); 12 return 0; 13 }
math.c 源碼:
1 // 沒有使用到 func.h 中的函數聲明或宏定義,也可以不包含進來 2 #include "../include/func.h" 3 // 從 fromNum 加到 endNum 4 long sum(int fromNum, int endNum) 5 { 6 int i; 7 long result = 0; 8 // 參數不符合規則,返回 -1 9 if(fromNum<0 || endNum<0 || endNum<fromNum) 10 { 11 return -1; 12 } 13 for(i=fromNum; i<=endNum; i++) 14 { 15 result += i; 16 } 17 // 返回大於等於0的值 18 return result; 19 } 20 // 從 fromNum 乘到 endNum 21 long mult(int fromNum, int endNum) 22 { 23 int i; 24 long result = 1; 25 // 參數不符合規則,返回 -1 26 if(fromNum<0 || endNum<0 || endNum<fromNum) 27 { 28 return -1; 29 } 30 for(i=fromNum; i<=endNum; i++) 31 { 32 result *= i; 33 } 34 // 返回大於等於0的值 35 return result; 36 }
web.c 源碼:
1 // 使用到了 func.h 中的宏定義,必須包含進來,否則編譯錯誤 2 #include "../include/func.h" 3 char* getWebName() 4 { 5 return WEB_NAME; 6 } 7 char* getWebURL() 8 { 9 return WEB_URL; 10 }
func.h 源碼:
1 #ifndef _FUNC_H 2 #define _FUNC_H 3 // 用宏定義來代替全局變量 4 #define OS "Windows 7" 5 #define WEB_URL "http://www.baidu.com" 6 #define WEB_NAME "百度" 7 // 也可以省略 extern,不過為了程序可讀性,建議都寫上 8 extern long sum(int, int); 9 extern long mult(int, int); 10 extern char* getWebName(); 11 extern char* getWebURL(); 12 #endif
運行結果:
從1加到10的和為55
從1乘到10的積為3628800
OS:Windows 7
Power By 百度(http://www.baidu.com)