C語言程序環境中的預處理詳解

一、翻譯環境

整個翻譯環境大致就可以畫成這樣一張圖。

下列有幾點需要說明:

1. 組成一個程序的每一個源文件通過編譯過程分別轉換成目標文件(在Linux中目標文件的後綴為.o;而在Windows中目標文件後綴為.obj)

2. 每個目標文件由鏈接器(linker)捆綁在一起,形成一個單一而完整的可執行程序

3. 鏈接器同時也會引入標準C函數庫(鏈接庫)中任何被該程序所用到的函數,而且它可以搜索程序員個人的程序庫,將其需要的函數也鏈接到程序中

接下來介紹每一步在Linux系統下整個翻譯環境的實現方法,以及每一個步驟的作用。

編譯可分為三個部分:

(1)預處理:輸入指令gcc -E test.c -o,就會將test.c文件變為test.i文件。這一步的作用是是對頭文件(#include)的包含、刪除註釋、#define定義符號的替換等文本操作(下文會對預處理這一個步驟展開詳細的介紹)

(2)編譯:輸入指令gcc -S test.i,就會將test.i文件變為test.s文件,這一步主要作用是把C語言代碼轉換成匯編代碼,其中包含4步:1. 語法分析;2. 詞法分析;3. 語義分析;4. 符號匯總

(3)匯編:輸入指令gcc -c test.s,就會將test.s文件變為test.o文件,這一步是把匯編代碼轉換成二進制的指令,這一步是會形成符號表,此時的符號表為接下來的鏈接操作做出瞭準備

多個.c文件通過編譯過程後形成.o目標文件,在要執行鏈接的時候,輸入指令gcc test.o add.o -o test,就會將.o文件變成可執行文件,這其中的操作包括合並段表和符號表的合並和重定位,這一步主要就是將多個目標文件進行連接的時候通過符號表查看來自外部的符號是否真實存在,這樣就完成瞭整個翻譯環境的操作。

二、執行環境

對於程序的執行過程可分為以下幾個步驟:

1. 程序必須載入內存中。在有操作系統的環境中:一般由操作系統完成。在獨立的環境中,程序的載入必須由手工安排,也可能是通過可執行代碼置入隻讀內存來完成

2. 程序的執行開始。之後就會調用main函數

3. 開始執行程序代碼。這個時候程序將使用一個運行時堆棧(stack),存儲函數的局部變量和返回地址;程序同時也可以使用靜態(static)內存,存儲於靜態內存中的變量在程序的整個執行過程一直保留他們的值

4. 終止程序。正在終止main函數,也有可能是意外終止的情況

三、預處理

1. 預處理符號

在C語言中,有些預處理符號是語言內置的,就比如:

__FILE__   //進行編譯的源文件
__LINE__   //文件當前的行號
__DATE__   //文件被編譯的日期
__TIME__   //文件被編譯的時間
__STDC__   //如果編譯器遵循ANSI C,其值為1,否則未定義

2. #define定義標識符

#define定義的標識符可以是常量、簡化關鍵字、一些符號等,例如:

#define M 10   //定義常量
#define reg register   //將關鍵字簡化
#define do_forever for(;;)   //用形象的符號來替換一種實現
#define CASE break;case   //在寫case語句的時候會自動地把break寫上

對於#define定義標識符來說,如果定義的東西過長,還可以分幾行來寫,除最後一行外,其他每行都加上'\',例如:

#define DEBUG_PRINT printf("file:%s\tline:%d\t \
							date:%s\ttime:%s\n",\
							__FILE__,__LINE__, \
							__DATE__,__TIME__)

3. #define定義宏

在#define定義標識符外,#define還有一個規定,就是允許把參數替換到文本中,進而就形成瞭#define定義宏。聲明的方式如下:

#define name(parament-list) stuff

這裡的parament-list是由一個逗號隔開的符號表,在實際的代碼中他們也會存在於stuff中。

其中值得註意的是:

1. 參數列表的左括號必須與name相鄰

2. 如果parament-list與stuff兩者之間有任何空白存在,參數列表就會被註釋為stuff的一部分

瞭解瞭#define定義宏是如何寫後,接下來就是#define定義宏的替換規則:

1. 在調用宏的時候,首先對參數進行檢查,看看是否包含任何由#define定義的符號。如果是,它們首先被替換

2. 替換文本隨後被插入到程序中原來的文本位置,參數名被它們的值所替換

3. 最後,再次對結果文件進行掃描,看看它是否包含任何由#define定義的符號。如果是,就重復上述處理過程

所以,總結以上規則後得出的結論就是:如果是#define定義宏用於對數值表達式進行求值的宏定義都應該加上括號,避免在使用宏時由於參數中的操作符或者鄰近操作符之間不可預料的相互作用。

當然,對於#define的使用還有幾個註意的點:

1. 宏參數和#define定義中可以出現其他#define定義的符號,但是對於宏,不能出現遞歸

2. 當預處理器搜索#define定義的符號的時候,字符串常量的內容並不被搜索

4. #和##

對於一些想要把參數插入到字符串中的情況,我們會使用#來把一個宏參數變成對應的字符串,下面舉個例子:

如果是直接打印出來的話,因為字符串是可以拼接的,所以就如這樣:

#include <stdio.h>
int main()
{
	int a = 10;
	printf("the value of ""a"" is %d\n", a);
	return 0;
}

那麼,對於定義宏參數來說,就應該這樣:

#include <stdio.h>
#define PRINT(n) printf("the value of "#n" is %d\n", n)
int main()
{
	int a = 10;
	PRINT(a);
	return 0;
}

這樣字符串中的n才會根據跟著宏參數的值變化而變化。

而##的作用是可以把位於它兩邊的符號合成一個符號。它允許宏定義從分離的文本段創建標識符。但是這樣連接必須產生一個合法的標識符,否則會報錯說未定義標識符。

5. 宏和函數的對比

宏的優勢:1. 在執行一些小型計算工作的時候,定義宏比調用函數和從函數返回的代碼執行所需要的時間會更短;2. 函數的參數必須聲明為特定的類型,二宏參數不用

宏的劣勢:1. 每次使用宏的時候,一份宏定義的代碼將插入到程序中。除非宏比較短,否則可能大幅度增加程序的長度;2. 宏是無法進行調試的,而函數可以;3. 宏由於沒有進行類型定義,所以有時候就會不夠嚴謹;4. 宏可能會帶來運算符的優先級的問題,導致程序容易出錯

屬性 #define定義宏 函數
代碼長度 每次使用時,宏代碼都會被插入到程序中,除瞭非常小的宏以外,程序的長度會大幅度增長 函數代碼隻出現於一個地方;每次使用這個函數時,都調用那個地方的同一份代碼
執行速度 更快 存在函數的使用和返回的額外開銷,所以相對慢一些
操作符優先級 宏參數的求值是在所有周圍表達式上下文環境裡,除非加上括號,否則鄰近操作符的優先級可能會產生不可預料的後果,所以建議宏在書寫的時候多些括號 函數參數隻在函數調用的時候求值一次,它的結果值傳給函數。表達式的求值結果更容易預測
帶有副作用的參數 參數可能被替換到宏體中的多個位置,所以帶有副作用的參數求值可能會產生不可預料的結果 函數參數隻在傳參的時候求值一次,結果更容易控制
參數類型 宏的參數與類型無關,隻要參數的操作是合法的,它就可以使用於任何參數類型 函數的參數是與類型有關的,如果參數類型不同,就需要不同的參數,即使他們執行的任務的不同的
調試 宏是不方便調試的 函數是可以逐語句調試的
遞歸 宏是不能遞歸的 函數是可以遞歸的

6. 條件編譯

下面列舉一些編譯指令:

1. #undef 該指令用於移除一個宏定義

2. 該指令是判斷應該執行哪一個語句塊

#if 常量表達式
    執行語塊
#elif 常量表達式
    執行語塊
#else
    執行語塊
#endif

3. 該指令是判斷是否被定義

#if define(symbol)
    如果有定義,執行此語句塊
or
#ifdef symbol
    如果有定義,執行此語句塊
or
#if !define(symbol)
    如果沒有定義,執行此語句塊
or
#ifndef symbol
    如果沒有定義,執行此語句塊

4. 對於條件編譯指令來說,其實還可以對其進行嵌套,稱為嵌套指令

7. 文件包含

我們在一些較大工程進行編譯的時候、在多人合作同一塊項目工程的時候,可能會出現頭文件重復包含的情況,如果真是這樣,則會導致整個代碼運行時的效率大大降低,所以對頭文件避免重復包含就顯得十分重要瞭。那麼,如何避免呢?下面就有一段代碼可以用來避免這種情況:

#ifndef __TEST_H__
#define __TEST_H__
    寫頭文件內容
#endif

這段代碼就可以很好地解決瞭頭文件重復包含的問題,但是實際上,如果是在VS的環境下進行編譯,會自動在最開始的地方寫上:#pragma once,這句代碼一樣也是可以解決重復包含的問題。

那麼,解決完頭文件重復包含的問題後,就來介紹兩種頭文件包含的方式:

1. 用引號包含的頭文件,例如:#include "test.h"。這種包含方式頭文件的查找策略是先在源文件所在的目錄下查找,如果該頭文件未被找到,編譯器就像查找庫函數頭文件一樣在標準位置查找頭文件,如果還找不到,則會直接報錯。

2. 用尖括號包含頭文件,例如:#include 。這種包含方式則是未有第一步,直接進行第二步。

但是不能說為瞭保證萬無一失,直接把全部頭文件的包含都用引號進行包含,這樣的話有些時候其實是用尖括號的情況而錯用引號導致程序的執行速度下降、效率下降等。

總結

本篇文章就到這裡瞭,希望能夠給你帶來幫助,也希望您能夠多多關註WalkonNet的更多內容!     

推薦閱讀: