文章目錄
前言
在學習C語言時,可能會用到#預處理指令,預處理是一個C語言中一個重要的知識點,本文將對預處理進行介紹,同時也會簡單介紹C語言源文件在執行過程中發生了哪些事。了解這些將會對C語言認識得更加深刻
1. 程序的翻譯環境和執行環境
在介紹預處理之前我們先簡單了解一下C語言的執行過程。首先要明白在ANSI C的任何一種實現中,存在兩個不同的環境。第1種是翻譯環境,在這個環境中源代碼被轉換為可執行的機器指令。第2種是執行環境,它用于實際執行代碼。
組成一個程序的每個源文件通過編譯過程分別轉換成目標代碼( code)。每個目標文件由鏈接器()捆綁在一起,形成一個單一而完整的可執行程序。鏈接器同時也會引入標準C函數庫中任何被該程序所用到的函數,而且它可以搜索程序員個人的程序庫,將其需要的函數也鏈接到程序中。
int add(int a, int b)
{
return a + b;
}
#include
extern int add(int, int);
int main()
{
int n = 10;
int m = 20;
int ret=add(n, m);
printf("%d\n", ret);
return 0;
}
當我們在一個工程項目中建立兩個.c源文件,一個add.c文件中聲明定義add函數,另一份源文件寫下上述第二份代碼。當我們調用main函數時程序運行成功打印30,關鍵字聲明了來自外部的符號add,在main函數中成功調用了add函數,那么是不是說明在程序運行期間會對源文件中的符號進行識別呢?識別符號是發生在運行的哪個階段呢?
其實在翻譯環境中程序的執行大致可以分為如下過程 :預處理(預編譯) 編譯 匯編 鏈接。在預處理階段會進行 頭文件的包含 注釋的刪除 預處理指令的替換。在編譯階段會將C語言代碼轉化成匯編代碼,經行相關的語法語義詞法分析和符號匯總。 在匯編階段會把匯編指令轉成二進制指令,同時形成對應的符號表 。 在鏈接階段會合并段表和符號標的合并重新定位。
剛才提到的add符號識別就是發生在編譯階段。每個函數符號會與函數地址相對應。
在圖中我使用了沒有聲明的add符號,在運行程序時報錯,觀察報錯信息LINK的意思就是鏈接,在鏈接時出現了錯誤。
介紹了翻譯環境中發生的事,接著簡單介紹一下運行環境中發生的事。程序執行的過程:1. 程序必須載入內存中。在有操作系統的環境中:一般這個由操作系統完成。在獨立的環境中,程序的載入必須由手工安排,也可能是通過可執行代碼置入只讀內存來完成。2. 程序的執行便開始。接著便調用main函數。3. 開始執行程序代碼。這個時候程序將使用一個運行時堆棧(stack),存儲函數的局部變量和返回地址。程序同時也可以使用靜態()內存,存儲于靜態內存中的變量在程序的整個執行過程一直保留他們的值。4. 終止程序。正常終止main函數;也有可能是意外終止。
關于編譯時的具體情況可以在Lunix中觀察到,有興趣的同學可以自己嘗試觀察。關與編譯鏈接只是介紹了簡單大概沒有深究,相關的具體細節可以去看《程序員的自我修養》這本書。
2.預處理指令 1.預定義符號
在介紹預處理之前,先簡單介紹語言自帶的預定義符號。
FILE //進行編譯的源文件。
LINE //文件當前的行號。
DATE //文件被編譯的日期。
TIME //文件被編譯的時間。
STDC //如果編譯器遵循ANSI C,其值為1,否則未定義。
這些符號都是這些預定義符號都是語言內置的,設置好了的
代碼示例
#include
int main()
{
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
return 0;
}
通過打印結果可以看到相關源文件相關信息都打印出來了
2.定義標識符
預處理指令可以對符號重新定義,根據使用者的要求,由使用者自己決定符號的具體含義。
代碼示例
#include
#define MAX 100
int main()

{
int n = MAX;
printf("%d", n);
return 0;
}
用預處理指令將 符號MAX定義成100,凡是出現MAX的地方都會被替換成MAX。通過打印結果來看,n確實是被賦值成了100。
那我們在調用標識符時需要加上;嗎?
通過代碼我們也可以看到,100后面是沒有加上;的,因為會對相應的標識符經行替換,如果加上;出現MAX的地方都會被替換成100;這樣就可能在使用時造成程序錯誤。所以在用定義標識符時不用加上;
3.定義宏
# 機制包括了一個規定,允許把參數替換到文本中,這種實現通常稱為宏(macro)或定義宏( macro)
宏的聲明方式:# name( -list ) stuff。其中的 -list 是一個由逗號隔開的符號表,它們可能出現在stuff中。注意:參數列表的左括號必須與name緊鄰。如果兩者之間有任何空白存在,參數列表就會被解釋為stuff的一部分。
代碼示例
#include
#define MUL(x) (x*x)
int main()
{
int ret = MUL(5);
printf("%d\n", ret);
return 0;
}
定義了一個宏MUL用來計算一個數平方,打印結果是25確實是5的平方。但是實際上這個宏存在一個問題
代碼示例
#include
#define MUL(x) (x*x)
int main()
{
int ret = MUL(5+1);
printf("%d\n", ret);
return 0;
}
可以看到結果是11,但是實際上我們想算的是6的平方,為什么會這樣呢?前面提到了#會替換對應的標識符,宏也是這樣的。5+1會被替換成5+1*5+1,根據算術優先級結果就是5+5+1=11。所以需要加上括號
#define MUL(x) (x)*(x)
我們再看一段帶代碼
#include
#define ADD(x) (x)+(x)
int main()
{
printf("%d\n", 10*ADD(5));
return 0;
}
這個代碼中的宏是加入了括號,但是我們想要的結果是100,打印結果卻是55。所以還需要再加上一個括號ADD(x) ((x)+(x))
所以用于對數值表達式進行求值的宏定義都應該用這種方式加上括號,避免在使用宏時由于參數中的操作符或鄰近操作符之間不可預料的相互作用。
4.替換規則
在程序中擴展#定義符號和宏時,需要涉及幾個步驟。
1. 在調用宏時,首先對參數進行檢查,看看是否包含任何由#定義的符號。如果是,它們首先被替換。
2. 替換文本隨后被插入到程序中原來文本的位置。對于宏,參數名被他們的值所替換。
3. 最后,再次對結果文件進行掃描,看看它是否包含任何由#定義的符號。如果是,就重復上述處理過程。
要注意一點
1. 宏參數和# 定義中可以出現其他#定義的符號。但是對于宏,不能出現遞歸。
2. 當預處理器搜索#定義的符號的時候,字符串常量的內容并不被搜索。
5.#的特殊作用
在介紹#的特殊作用之前,我們看一段代碼
#include
int main()
{
char str[10] = { "abc""""efg" };
printf("%s", str);
return 0;
}
打印結果是,由此發現C語言中字符串是有自動連接的特點的。
# 有個作用,把一個宏參數變成對應的字符串
假如有這么一個場景:打印不同類型的數據。如果我們對個數據都是打印顯得很繁瑣,對于同樣的功能我們應該進行簡單的封裝,但是如果采用函數的方式封裝的話顯然不太合理,因為函數的參數類型的是固定的,有什么簡單有效的方法呢?其實,可以采用宏的方式來實現打印。
代碼如下
#define PRINT(val, format) printf("the value of "#val" is "format"\n", val)
#include
int main()
{
int a = 10;
PRINT(a, "%d");
int b = 20;
PRINT(b, "%d");
float f = 3.5f;
PRINT(f, "%f");
return 0;
}
#將參數直接轉成字符,C語言字符串有自動連接的特點,結合這些特性定義的宏PRINT就實現打印不同類型數據的功能。這是個巧妙的處理方法。如果是函數的的話顯然不能很好的處理這樣的問題。
#第二個作用:##可以把位于它兩邊的符號合成一個符號。它允許宏定義從分離的文本片段創建標識符。
代碼示例
#define CAT(a,b) a##b
#include
int main()
{
int num = 10;
printf("%d", CAT(n,um));
}
##將兩邊的字符片段合成一個字符,n和um合成為num,所以打印結果是10.但是要注意這樣的連接必須產生一個合法的標識符。否則其結果就是未定義的。
6.帶副作用的宏參數
當宏參數在宏的定義中出現超過一次的時候,如果參數帶有副作用,那么在使用這個宏的時候就可能出現危險,導致不可預測的后果。副作用就是表達式求值的時候出現的永久性效果。
代碼示例
#include
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
int main()
{
int a = 6;
int b = 5;
int ret = MAX(a++, b++);
printf("%d %d %d", a, b, ret);
return 0;

}
打印結果是8 6 7,為什么會出現這樣的結果呢?先前說了#dfine是對應符號的替換 將a和b代入宏,ret=(a++)>(b++)?(a++):(b++)。a和b先加加一次此時a=7,b=6,a大于b,執行問號后面的(a++)不執行(b++),此時a=8,b沒有執行加加就還是6。同時加加操作是后置加加,就是先使用后加加,a=7時,實際上int ret=7++,ret先被賦值成7,然后a在自增一次。對于這樣的傳參方式,要謹慎使用。
7.宏和函數的比較
宏的定義方式和函數的比較像的,但是兩者的區別還是很大的。通常宏通被應用于執行簡單的運算,因為函數在使用時是需要開辟函數棧幀的,在函數被調用完后還需要銷毀函數棧幀,存在函數的調用和返回的額外開銷,用于調用函數和從函數返回的代碼可能比實際執行這個小型計算工作所需要的時間更多。所以宏比函數在程序的規模和速度方面更勝一籌。更為重要的一點是:函數的參數必須聲明為特定的類型。所以函數只能在類型合適的表達式上使用。反之這個宏可以適用于整形、長整型、浮點型等。宏是和類型無關的。
宏的雖然有上述的優點,但是和函數相比宏也有劣勢的地方: 1.每次使用宏的時候,一份宏定義的代碼將插入到程序中。除非宏比較短,否則可能大幅度增加程序的長度(這是在程序翻譯環境中發生的事)。2. 宏是沒法調試的。3. 宏由于類型無關,也就不夠嚴謹。4. 宏可能會帶來運算符優先級的問題,導致程容易出現錯。
命名約定:一般來講函數和宏的使用語法很相似。所以語言本身沒法幫我們區分二者。那我們平時的一個習慣是:把宏名全部大寫函數名不要全部大寫。當然也有一些例外:在某些編譯器上是用宏實現而不是函數,也是宏。
補充 #undef指令用于移除宏或預定義符號。如果現存的一個名字需要被重新定義,那么它的舊名字首先要被移除
在使用undef指令之前,n=MAX并沒有報錯,當使用undef指令以后符號MAX就被移除了,編譯器無法識別這個標識符。
8. 命令行定義和條件編譯
許多C 的編譯器提供了一種能力,允許在命令行中定義符號。用于啟動編譯過程。例如:當我們根據同一個源文件要編譯出一個程序的不同版本的時候,這個特性有點用處。(假定某個程序中聲明了一個某個長度的數組,如果機器內存有限,我們需要一個很小的數組,但是另外一個機器內存大些,我們需要一個數組能夠大些。)
代碼示例
#include
int main()
{
int arr[sz];
int i = 0;
for (i = 0; i < sz; i++)
{
arr[i] = i;
}
for (i = 0; i < sz; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
在啟動編譯的過程中,我們可以對sz進行自定義的賦值,這個sz的大小由自己定義。這個特性需要在Linux系統下輸入對應的命令才能直觀的展示出來。
條件編譯:在編譯一個程序的時候我們如果要將一條語句(一組語句)編譯或者放棄是很方便的。因為我們有條件編譯指令。比如說:調試性的代碼,刪除可惜,保留又礙事,所以我們可以選擇性的編譯。
代碼示例
#include
#define __DEBUG__
int main()
{
int i = 0;
int arr[10] = { 0 };
for (i = 0; i < 10; i++)
{
arr[i] = i;
#ifdef __DEBUG__
printf("%d\n", arr[i]);//觀察數組是否賦值成功。
#endif
}
return 0;
}
這個條件編譯指令和if else 語句有點像。這段帶代碼的意思是如果這個標識符被定義了,就打印數組元素。#endif表示結束條件編譯,這個是不能省略的。只要使用了條件編譯指令在結尾處一定要加上這個。
除了這種單個條件編譯,也有多個分支的條件編譯
#include
int main()
{

#if 1<2
printf("1\n");
#elif 2<3
printf("2\n");
#else
printf("3\n");
#endif
return 0;
}
打印結果1,這個條件編譯指令和if else語句判斷是類似的,當#if條件不為真時接著判斷#elif條件是否為真,如果不為真就接著往后判斷。上述代碼中#if條件是為真,就直接打印,不在往后判斷后面語句的真假了。條件編譯是可以允許嵌套使用的,對每個條件依次判斷。
條件編譯指令和if else語句雖然是很像的,但是兩者是有區別的。預處理指令在預處理階段通過判斷將不要的代碼直接刪掉。這個不要的代碼就是不滿足條件的代碼。在實現一些跨平臺程序時,使用條件編譯指令較多。
9.文件包含
我們已經知道, # 指令可以使另外一個文件被編譯。就像這個文件實際出現在 # 指令的地方一樣。這種替換的方式很簡單:預處理器先刪除這條指令,并用包含文件的內容替換。 這樣一個源文件被包含10次,那就實際被編譯10次。
本地文件的包含
在我們寫代碼時,有時候會自己定義頭文件在工程項目路徑底下,當我們想在其他.c源文件中使用這個頭文件內容,是直接用" "來包含頭文件。這樣的方式查找策略是:先在源文件所在目錄下查找,如果該頭文件未找到,編譯器就像查找庫函數頭文件一樣在標準位置查找頭文件。如果找不到就提示編譯錯誤。
VS環境的標準頭文件的路徑:C:\ Files (x86)\ 12.0\VC\
//這是的默認路徑。
庫文件包含
庫文件的包含一般都是用 。查找策略是:查找頭文件直接去標準路徑下去查找,如果找不到就提示編譯錯誤。剛才在本地文件的包含時提到用" "包含頭文件時,是先在源文件所在目錄下查找ai保存包含鏈接文件什么意思,沒有找到再去標準路徑下查找,那么是不是說明庫文件也可以使用" "來包含。答案是肯定的,可以。但是這樣做查找的效率就低些,當然這樣也不容易區分是庫文件還是本地文件了。所以在包含文件時最好還是使用正常的方法。
嵌套文件包含
思考這樣一個場景:comm.h和comm.c是公共模塊。test1.h和test1.c使用了公共模塊。test2.h和test2.c使用了公共模塊。test.h和test.c使用了test1模塊和test2模塊。這樣最終程序中就會出現兩份comm.h的內容。這樣就造成了文件內容的重復。
在預編譯階段會進行頭文件的包含,當頭文件重復嵌套包含時,預編譯階段生成的代碼文件會重復拷貝頭文件的內容。這樣會使得在此階段生成的代碼更加冗長。為了避免重復包含有以下兩種做法。
條件編譯:每個頭文件中寫:# # //頭文件的內容。 #endif 這個符號是隨便起的,由使用者自己決定
代碼示例
#ifndef __TEST_H__
#define __TEST_H__
int test(int a, int b)
{
return 0;
}
#endif
在預處理階段會對文件進行包含也會處理一些預定義指令,條件編譯指令也在預定義指令的范疇中。所以當第一次包含文件時也會處理條件編譯指令,條件編譯指令的意思是如果 這個符號沒有預定以 就對它預定義一下。然后拷貝文件內容也就是對test函數的定義。當第二次包含這個文件時遇到條件編譯指令,發現這個符號被預定義過,就不在往下執行,直接結束了。這樣的話就只是包含一次頭文件避免了重復包含。這個符號是隨便起的,由使用者自己決定。
第二種處理方式就是在每個頭文件開頭加上# once這個預處理指令
代碼示例
#pragma once
int test(int a, int b)
{
return 0;
}
在vs中新建頭文件時,這個指令會默認加入文件中。#預處理指令不止這一中,在結構體的博客中就介紹了另一種指令# pack改變vs默認對齊數。
3.宏的簡單應用 1.簡單封裝
宏和函數相比有個重要的優勢就是參數不被數據類型所約束。思考這樣一個場景:在使用函數時,需要根據數據類型進行強制類型轉換同時還得使用分配字節或者自己計算分配的字節數,能不能直接對其進行簡單的封裝,將數據類型當作參數以數據類型為單位分配空間呢?如果使用函數的話,顯然不行。因為函數的參數不可能是數據類型,這樣的話那就思考一下嘗試用宏來解決。
代碼示例
#define MALLOC(num, type) (type *)malloc(num * sizeof(type))
#include
#include
int main()
{

int* ps = MALLOC(10, int);
free(ps);
ps = NULL;
return 0;
}
利用宏將函數進行了簡單的封裝。這樣想要分配10個整型的話,就直接傳10 int,使用起來更加方便。
2.模擬
模擬實現:是用來計算結構體成員的地址相對結構體起始地址偏移量。我們知道結構體在分配內存空間時是存在內存對齊的,結構體成員地址的偏移量就相當于結構體成員的地址減去結構體的起始地址。假如結構體的起始地址是0x123,每個結構體成員的地址減去0x123就是相對起始位置的偏移量。假如說結構體的起始地址是0那么每個成員地址的偏移量實際上就是成員的地址。因為減0,就相當于沒減。
代碼示例
#include
#define OFFSETOF(s_type, m_name) (int)&(((s_type*)0)->m_name)
struct S
{
int a;
char b;
}
int main()
{
printf("%d\n", OFFSETOF(struct S, a));
return 0;
}
這段宏代碼我們簡單分析一下。首先 S是一種自定義的數據類型。要拿到成員的地址時,肯定是要去訪問結構體成員,訪問結構體成員只能用指針變量或者是用變量名加.來訪問。因為這里是結構體數據類型本身,不存在使用變量名加.來訪問。只能用指針來訪問。當使用指針變量的時候其實就意味著為這個變量里放的是結構體地址編號。所以當以0來充當指針時,其實是相當于赤裸裸把地址編號直接拿來用了,少了一個變量符號的殼子。這個結構體類型的地址編號被強行默認成了0。然后直接拿這個地址去訪問成員。假設指針ps=0x123 ps->a不就是相當于0x123->a。但是數字要想成為地址需要強轉指針類型,不然數字就只是字面常量了。因為只是拿了0這個地址編號來用,沒有實際訪問這個編號地址中的內容所以不會造成問題。
這段宏定義代碼我想了一會才真正的理解透徹得出以上的結論
3.一個整數的二進制位的奇數位和偶數位交換
這是一道很有意思的題目,類似于這樣的題免不了涉及位運算。那么怎么思考這個問題呢?首先在做題以前先搞清楚哪些位是奇數位哪些位是偶數位。我們以32位2進制整數為例,從數值最低位開始到最高位結束,最低位為第一位,依次往后類推。想實現交換,我們可以把二進制位的每一個偶數位都往右移動一位,所有的奇數位都往左移動一位。在將兩者相加即為所求。
代碼示例
#include
#define SWAP_BIT(n) n=(((n&0xaaaaaaaa)>>1) + ((n&0x55555555)<<1))
int main()
{
int a = 10;//00000000 00000000 00000000 00001010
SWAP_BIT(a);
printf("%d\n", a);//0101 5
SWAP_BIT(a);
printf("%d\n", a);
return 0;
}
因為11 轉為16進制是 同理00 轉為16進制是。這樣的寫法簡略一點。其實這道題目3步走應該是先找對應的奇偶二進制序列再移動一位ai保存包含鏈接文件什么意思,最后相加。因為我們在思考時自己人腦自動區分了奇偶序列。所以在寫代碼要處理序列再移位。
4.總結
關于程序的編譯鏈接只是簡單的介紹了一下,預處理指令也是只是介紹了部分。如果對這些內容感興趣的同學可以去試著研究一下。同時關于宏的使用要謹慎.
這里簡單說一下和的區別:
1. 只是簡單的字符串替換,沒有類型檢查
2. 是在編譯的預處理階段起作?
3. 可以?來防?頭?件?復引?
4. 不分配內存,給出的是?即數,有多少次使?就進?多少次替換**
:
1. 有對應的數據類型,是要進?判斷的
2. 是在編譯、運?的時候起作?
3. 在靜態存儲區中分配空間,在程序運?過程中內存中只有?個拷?
以上內容如有錯誤,歡迎指正!謝謝!