- 文章主題:程序環境和預處理詳解🌏
- 所屬專欄:深入理解C語言📔
- 作者簡介:更新有關深入理解C語言知識的博主一枚,記錄分享自己對C語言的深入解讀。😆
- 個人主頁:[?]的個人主頁🏄🌊
程序環境和預處理詳解
- 前言
- 程序的翻譯環境和執行環境
- 詳解編譯+鏈接
- 翻譯環境
- 編譯本身也分為幾個階段:
- 運行環境
- 預處理詳解
- 預定義符號
- define
- #define 定義標識符
- #define 定義宏
- #define 的替換規則
- #和##
- 帶副作用的宏參數
- 宏和函數對比
- 命名約定
- #undef
- 命令行定義
- 條件編譯
- 文件包含
- 頭文件被包含的方式:
- 嵌套文件包含
- 其他預處理指令
- 總結
前言
程序的運行離不開起相對應的環境,其中翻譯環境中的編譯環境中的預處理環境又是我們了解甚少的一個環境,下文就是關于程序環境和預處理環境的詳解。😆
程序的翻譯環境和執行環境
在ANSI C的任何一種實現中,存在兩個不同的環境。
- 翻譯環境:在這個環境中源代碼被轉換為可執行的機器指令。
- 執行環境:它用于實際執行代碼。
詳解編譯+鏈接
翻譯環境
程序編譯過程:
- 組成一個程序的每個源文件通過編譯過程分別轉換成目標代碼(
object code
)。- 每個目標文件由鏈接器(
linker
)捆綁在一起,形成一個單一而完整的可執行程序。- 鏈接器同時也會引入標準C函數庫中任何被該程序所用到的函數,而且它可以搜索程序員個人 的程序庫,將其需要的函數也鏈接到程序中。
編譯本身也分為幾個階段:
例:
sum.c
int g_val = 2016;
void print(const char *str)
{
printf("%s\n", str);
}
test.c
#include <stdio.h>
int main()
{
extern void print(char *str);
extern int g_val;
printf("%d\n", g_val);
print("hello bit.\n");
return 0;
}
發生的編譯與鏈接:
如何查看編譯期間的每一步發生了什么呢?(vs code文本編輯器)
test.c
#include <stdio.h>
int main()
{
int i = 0;
for(i=0; i<10; i++)
{
printf("%d ", i);
}
return 0;
}
- 預處理 選項
gcc -E test.c -o test.i
預處理完成之后就停下來,預處理之后產生的結果都放在test.i文件中。- 編譯 選項
gcc -S test.c
編譯完成之后就停下來,結果保存在test.s
中。- 匯編
gcc -c test.c
匯編完成之后就停下來,結果保存在test.o
中。
運行環境
程序執行的過程:
- 程序必須載入內存中。在有操作系統的環境中:一般這個由操作系統完成。在獨立的環境中,程序的載入必須由手工安排,也可能是通過可執行代碼置入只讀內存來完成。
- 程序的執行便開始。接著便調用
main
函數。 - 開始執行程序代碼。這個時候程序將使用一個運行時堆棧(
stack
),存儲函數的局部變量和返回地址。程序同時也可以使用靜態(static
)內存,存儲于靜態內存中的變量在程序的整個執行過程一直保留他們的值。 - 終止程序。正常終止
main
函數;也有可能是意外終止。
預處理詳解
預定義符號
__FILE__ //進行編譯的源文件
__LINE__ //文件當前的行號
__DATE__ //文件被編譯的日期
__TIME__ //文件被編譯的時間
__STDC__ //如果編譯器遵循ANSI C,其值為1,否則未定義
這些預定義符號都是語言內置的。
例:
printf("file:%s line:%d\n", __FILE__, __LINE__);
define
#define 定義標識符
標識符對應的值也是文本,所以不僅是復制粘貼后會被解析成數值的數字文本,其余的文本也均可,甚至與不寫也可以(但不會像非預編譯指令中的變量那樣賦隨機值,邏輯不同,對應的也不是內存,可能其中根本就不會存在有值),因為宏定義的標識符有時候起到的作用就僅僅是標識一部文本的作用或者整個文件的作用,一般會和條件編譯語句中檢測是否用預定義指令定義了一個標識符的結構搭配在一起使用,這是標識符起到的作用就真的是檢測其是否定義的作用了,這就是什么相當于官方定義的標識符的程序創建時一開始就自帶創建和變化的預定義符號中的最后一個
__STDC__
,要么符合ANSI C標準為1.要么就為未定義的原因了,估計也是為了符合標識符這一預定義指令的風格,方便和條件編譯指令通過標識符判斷是否定義了表示的效果搭配在一起用的,該編譯器內部是否用ANSI C的標準就可以直接通過條件編譯的預處理指令把這一效果給打印出來。
語法:
#define name stuff
例:
#define MAX 1000
#define reg register //為 register這個關鍵字,創建一個簡短的名字
#define do_forever for(;;) //用更形象的符號來替換一種實現
#define CASE break;case //在寫case語句的時候自動把 break寫上。
// 如果定義的 stuff過長,可以分成幾行寫,除了最后一行外,每行的后面都加一個反斜杠(續行符)。宏和預定義多行書寫時都會用到續行符。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \date:%s\ttime:%s\n" ,\__FILE__,__LINE__ , \__DATE__,__TIME__ )
提問:
在
define
定義標識符的時候,要不要在最后加上;
?
例:
#define MAX 1000;
#define MAX 1000
建議不要加上 ;
,這樣容易導致問題,就算不產生問題也會影響程序運行的效率,是不好的習慣。
如:
if(condition)
max = MAX;
else
max = 0;
因為在C語言中else
前的語句一定得是if
,所以在這里預處理的階段經過文本替換以后else
前的語句實際上是;
,就會造成后面編譯的錯誤。
#define 定義宏
#define 機制包括了一個規定,允許把參數替換到文本中,這種實現通常稱為宏(
macro
)或定義宏(define macro
)。
下面是宏的申明方式:
#define name( parament-list ) stuff
其中的 parament-list
是一個由逗號隔開的符號表,它們可能出現在stuff
中。
注:
參數列表的左括號必須與name
緊鄰。
如果兩者之間有任何空白存在,參數列表就會被解釋為stuff的一部分。
如:
#define SQUARE( x ) x * x
這個宏接收一個參數 x
。如果在上述聲明之后,你把SQUARE( 5 );
置于程序中,預處理器就會用5 * 5
這個表達式替換上面的表達式。
但是,這個宏存在一個問題:
觀察下面的代碼段:
int a = 5;
printf("%d\n" ,SQUARE( a + 1) );
乍一看,你可能覺得這段代碼將打印36這個值。
事實上,它將打印11。
為什么?
替換文本時,參數x被替換成
a + 1
,所以這條語句實際上變成了:printf ("%d\n",a + 1 * a + 1 );
這樣就比較清晰了,由替換產生的表達式并沒有按照預想的次序進行求值。
在宏定義上加上兩個括號,這個問題便輕松的解決了:
#define SQUARE(x) (x) * (x)
這樣預處理之后就產生了預期的效果:
printf ("%d\n",(a + 1) * (a + 1) );
這里還有一個宏定義:
#define DOUBLE(x) (x) + (x)
定義中我們使用了括號,想避免之前的問題,但是這個宏可能會出現新的錯誤。
int a = 5;
printf("%d\n" ,10 * DOUBLE(a));
這將打印什么值呢?
看上去,好像打印100,但事實上打印的是55。
我們發現替換之后:
printf ("%d\n",10 * (5) + (5));
乘法運算先于宏定義的加法,所以出現了
55
。
這個問題,的解決辦法是在宏定義表達式兩邊加上一對括號就可以了。
#define DOUBLE(x) ( ( x ) + ( x ) )
提示:
所以用于針對數值表達式進行求值的宏定義都應該用這種方式加上括號,避免在使用宏時由于參數中的操作符或鄰近操作符之間不可預料的優先級之間的相互作用。
#define 的替換規則
在程序中擴展#define
定義符號和宏時,需要涉及幾個步驟。
- 在調用宏時,首先對參數進行檢查,看看是否包含任何由#define定義的符號。如果是,它們首先被替換。(指在程序中用宏時參數如果用的也是預處理時的標識符時,會先把標識符中的數據替換之后再把宏中的文本替換,因為都是在預處理時就處理的東西,所以做的到趕在它文本預處理之前就把參數中的內容趕上它預處理替換的進度給替換進去了,不是預處理處的變量就做不到這一點,只能在預處理文本替換后,才能再把變量的值在編譯之后的完整的匯編語言再在匯編過程中把匯編語言轉換成二進制語言再在很后面的運行過程中才能在內存操作中把它變量的值引用進去)
- 替換文本隨后被插入到程序中原來文本的位置。對于宏,參數名被他們的值所替換。
- 最后,再次對結果文件進行掃描,看看它是否包含任何由
#define
定義的符號。如果是,就重復上述處理過程。(可能因為一些原因沒有處理的#define
定義的符號就會在第二次做完,只會判斷兩次,如果還沒有判斷完也就不會繼續判斷了,一般也很少會出現這種情況。
注意:
- 宏參數和
#define
定義中可以出現其他#define
定義的符號。但是對于宏,不能出現遞歸。(即宏可以被嵌套(針對于同類而言,不同類不能用嵌套這種說法),但只能嵌套標識符,不能嵌套完全一樣的宏)- 當預處理器搜索
#define
定義的符號的時候,字符串常量的內容并不被搜索,當然標識符不行則也一樣可以判斷出同性質的宏參數也不行,得用后面提到的#
的知識點來解決該問題。(可以理解成不是處在同一個圖層的文本效果,字符與字符串中與宏相同的符號為避免與宏中的標識符相沖突,并不會被替換,而是字符(串)中是什么文本就依然用什么文本)
#和##
前面說過與預定義相關的所有文本都不會在字符串中的內容給檢測到,但在宏中的參數可以有一種方法讓這種參數插入到字符串中。如何把參數插入到字符串中?
首先我們看看這樣的代碼:
char* p = "hello ""bit\n";
printf("hello"" bit\n");
printf("%s", p);
這里輸出的是不是
hello bit
?
答案是確定的:是。
我們發現字符串是有自動連接的特點的。
那我們是不是可以寫這樣的代碼?:
#define PRINT(FORMAT, VALUE)\
printf("the value is "FORMAT"\n", VALUE);
...
PRINT("%d", 10);
這里只有當字符串作為宏參數的時候才可以把字符串放在字符串中。
另外一個技巧是:
使用 #
,把一個不是字符串的宏參數變成對應的字符串。
例:
int i = 10;
#define PRINT(FORMAT, VALUE)\
printf("the value of " #VALUE "is "FORMAT "\n", VALUE);
...
PRINT("%d", i+3);//產生了什么效果?
代碼中的 #VALUE
會被預處理器處理為:
"VALUE"
。
最終的輸出的結果應該是:
the value of i+3 is 13
## 的作用
##可以把位于它兩邊的符號合成一個符號。
它允許宏定義從分離的文本片段創建標識符。
#define ADD_TO_SUM(num, value) \
sum##num += value;
...
ADD_TO_SUM(5, 10);//作用是:給sum5增加10.
注:
這樣的連接必須產生一個合法的標識符。否則其結果就是未定義的。
宏參數雖然也是直接文本復制粘貼,但他還是無法直接把兩個宏參數合到一起的整個字符當做一個變量名去用,這種情況下就必須要借用##
來幫助其實現這種效果了。
帶副作用的宏參數
當宏參數在宏的定義中出現超過一次的時候,如果參數帶有副作用,那么你在使用這個宏的時候就可能出現危險,導致不可預測的后果。副作用就是表達式求值的時候出現的永久性效果(主要即指一些變量的值在參數執行一次語句后只就會因為帶有副作用發生了變化而影響后面的宏語句的情況)(因為宏是直接將文本復制粘貼的緣故副作用會被疊加多次而不是函數的形成一份賦值形參的邏輯,所以將帶有一次副作用的語句帶入一個出現多次的宏中時,參數的副作用也會隨著疊加,造成危險情況發生)。
例:
x+1;//不帶副作用
x++;//帶有副作用
MAX宏可以證明具有副作用的參數所引起的問題。
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
...
x = 5;
y = 8;
z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);//輸出的結果是什么?
所以輸出的結果是:
x=6 y=10 z=9
宏和函數對比
宏通常被應用于執行簡單的運算(書寫復雜的運算和邏輯為防止參數的作錯誤執行會比函數書寫起來復雜很多)。
比如在兩個數中找出較大的一個。
#define MAX(a, b) ((a)>(b)?(a):(b))
那為什么不用函數來完成這個任務?
原因有二:
- 用于調用函數和從函數返回的代碼可能比實際執行這個小型計算工作所需要的時間更多。
所以宏比函數在程序的規模和速度方面更勝一籌。(只是文本的復制粘貼不需要繁雜的調用返回操作) - 更為重要的是函數的參數必須聲明為特定的類型。
所以函數只能在類型合適的表達式上使用。反之這個宏則可以適用于整形、長整型、浮點型等,可以用于>來比較的類型(參數的范圍和執行參數運算與操作的范圍比函數要大得多)。
宏是類型無關的。
宏的缺點:
當然和函數相比宏也有劣勢的地方:
3. 每次使用宏的時候,一份宏定義的代碼將插入到程序中。除非宏比較短,否則可能大幅度增加程序
的長度。
4. 宏是沒法調試的(因為是在預處理階段就執行完了,且宏中可以有多個語句,也可以一個完整的語句都沒有,調試時以宏的文本來看的話,為避免顯示過于復雜,編譯器的效果就干脆處理成是直接一步就過去了,這也是為什么一般在宏中只寫簡單語句的原因,復雜的宏體出錯后難以通過調試得出問題的原因)。
5. 3. 宏由于類型無關,也就不夠嚴謹(一些對參數類型要求嚴謹的邏輯不能由宏來書寫)。
6. 宏可能會帶來運算符優先級的問題,導致程容易出現錯(且規避這種情況時括號過多也會使稍復雜的結構就繁雜不清)。
宏有時候可以做函數做不到的事情。比如:宏的參數可以出現類型(通過類型名的文本來表示意思),但是函數做不到(函數的參數不是文本,而是量,最多也只能通過參數的大小來大概的辨別類型的情況,或者字符串,但這些都遠沒有宏方便)。
#define MALLOC(num, type)\
(type *)malloc(num * sizeof(type))
...
//使用
MALLOC(10, int);//類型作為參數
//預處理器替換之后:
(int *)malloc(10 * sizeof(int));
宏和函數的一個對比
屬性 | #define定義宏 | 函數 |
---|---|---|
代碼長度 | 每次使用時,宏代碼都會被插入到程序中。除了非常小的宏之外,程序的長度會大幅度增長 | 函數代碼只出現于一個地方;每次使用這個函數時,都調用那個地方的同一份代碼 |
執行速度 | 更快 | 存在函數的調用和返回的額外開銷,所以相對慢一些 |
操作符優先級 | 宏參數的求值是在所有周圍表達式的上下文環境里,除非加上括號,否則鄰近操作符的優先級可能會產生不可預料的后果,所以建議宏在書寫的時候多些括號。 | 函數參數只在函數調用的時候求值一次(相當于將參數處的代碼當作一個表達式將其算出結果之后再將其放入到函數中對應該參數創造的形參中),它的結果值傳遞給函數。表達式的求值結果更容易預測。 |
帶有副作用的參數 | 參數可能被替換到宏體中的多個位置,所以帶有副作用的參數求值可能會產生不可預料的結果。(帶有副作用的文本參數被直接復制粘貼宏中多少次就會被執行多少次) | 函數參數只在傳參的時候求值一次,結果更容易控制。(有副作用也只會在將值帶入函數形參中被求值的那一次時在整個函數運行中造成一次副作用) |
參數類型 | 宏的參數與類型無關(因為本質就是把該參數當成是一個文本(參數的作用本質就是把其當作文本的形式復制粘貼到宏體的多個位置)),只要對參數的操作是合法的,它就可以使用于任何參數類型。 | 函數的參數是與類型有關的,如果參數的類型不同,就需要不同的函數,即使他們執行的任務是相同的。(小則警告,大則導致與變量有關的邏輯算出來的結果出錯) |
調試 | 宏是不方便調試的(調試時直接一步跳過,內部的具體邏輯是看不到的) | 函函數是可以逐語句調試的(因為是調用到那一個函數體當中了,每一步具體的邏輯都可以直接被看到) |
遞歸 | 宏是不能遞歸的(即自己嵌套自己,遞歸不行但宏可以自己嵌套標識符,此時標識符會被先執行) | 函數是可以遞歸的(可以自己嵌套自己,執行多次) |
命名約定
一般來講函數和宏的使用語法很相似。所以語言本身沒法幫我們區分二者。
那我們平時的一個習慣是:
把宏名全部大寫
函數名不要全部大寫(但有意思的是一些庫中自帶的宏基本上字母全部又都是小寫的,自己寫時稍稍注意要去大寫即可)
#undef
這條指令用于移除一個宏定義。
#undef NAME
//如果現存的一個名字需要被重新定義,那么它的舊名字首先要被移除。(或者后續不用該名字時,也可以先采用移除操作)
命令行定義
許多C 的編譯器提供了一種能力,允許在命令行中定義符號。用于啟動編譯過程。
例如:當我們根據同一個源文件要編譯出一個程序的不同版本的時候,這個特性有點用處。(假定某個程序中聲明了一個某個長度的數組,如果機器內存有限,我們需要一個很小的數組,但是另外一個機器內存大些,我們需要一個數組能夠大些。(即通過在命令行中通過命令行的語法對預定義的標識符進行一些賦值來實現較方便的不同版本的測試作用,在一些沒有這寫功能的編譯器下試圖這么做的話則會因為標識符未定義而在后續如果運用了該標識符的話出錯(在VS中會被錯誤的檢測成語法錯誤,但本質應是運行錯誤,因為你不實際在C語言語法中用這個標識符的話,就完全不會出現錯誤)))
#include <stdio.h>
int main()
{int array [ARRAY_SIZE];int i = 0;for(i = 0; i< ARRAY_SIZE; i ++){array[i] = i;}for(i = 0; i< ARRAY_SIZE; i ++){printf("%d " ,array[i]);}printf("\n" );return 0;
}
編譯指令:
//linux 環境演示(linux具有命令行預定義標識符的能力)
gcc -D ARRAY_SIZE=10 programe.c
條件編譯
在編譯一個程序的時候我們如果要將一條語句(一組語句)編譯或者放棄是很方便的。因為我們有條件編譯指令。
比如說:
調試性的代碼,刪除可惜,保留又礙事,所以我們可以選擇性的編譯。
#include <stdio.h>
#define __DEBUG__//這里就體現了標識符預定義之后不確定其代表的常量值時是完全符合語法規范的,因為既可以在條件編譯時充當#ifdef的條件,又可以在命令行中去重新賦值,只要不將其直接拿來用就行
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 //__DEBUG__
}
return 0;
}
常見的條件編譯指令:
1.
#if 常量表達式
//...
#endif
//常量表達式由預處理器求值。
如:
#define __DEBUG__ 1
#if __DEBUG__
//..
#endif
2.多個分支的條件編譯
#if 常量表達式
//...
#elif 常量表達式
//...
#else
//...
#endif
3.判斷是否被定義
#if defined(symbol)//defined(symbol)和!defined(symbol)是C語言中獨屬于條件編譯的具有真假判斷性的表達式,且與普通定義不同,括號緊挨其后(與關鍵字sizeof相同,函數和宏不同括號不一定緊挨其后但是一定要有,所有的預處理指令都是清一色的可以用續行符來達到分行寫的目的,且續行符的語法規則是續行符兩邊的空格全都可以忽略不計)在#if中還能夠被分別簡寫為#ifdef和#ifndef
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
4.嵌套指令
#if defined(OS_UNIX)
#ifdef OPTION1
unix_version_option1();
#endif
#ifdef OPTION2
unix_version_option2();
#endif
#elif defined(OS_MSDOS)
#ifdef OPTION2
msdos_version_option2();
#endif
#endif
文件包含
我們已經知道, #include
指令可以使另外一個文件被編譯。就像它實際出現于 #include
指令的地方一樣。
這種替換的方式很簡單:
預處理器先刪除這條指令,并用包含文件的內容替換。
這樣一個源文件被包含10次,那就實際被編譯10次(與函數每次調用都回到定義處的原理不同,與預定義邏輯相同的很簡單的復制粘貼邏輯,預處理指令均是預處理時對編輯后的C語言代碼文本的文本操作,所以不是復制粘貼就會是在原文本的刪除操作(即條件編譯實現選擇性編譯效果的底層邏輯))。
頭文件被包含的方式:
- 本地文件包含
#include "filename"//同源碼文件相同位置處自己寫的文件就被稱作本地,因為和寫源碼的地方完全相同
查找策略:先在源文件所在目錄下查找,如果該頭文件未找到,編譯器就像查找庫函數頭文件一樣在標準位置查找頭文件。如果找不到就提示編譯錯誤。
Linux環境的標準頭文件的路徑:
/usr/include
VS環境的標準頭文件的路徑:
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include
//這是VS2013的默認路徑
注意按照自己的安裝路徑去找,如果安裝路徑變化自然儲存的位置和編譯器查找的位置會發生變化。
- 庫文件包含
#include <filename.h>
查找頭文件直接去標準路徑下去查找,如果找不到就提示編譯錯誤。
這樣是不是可以說,對于庫文件也可以使用""
的形式包含?
答案是肯定的,可以
。
但是這樣做查找的效率就低些,當然這樣也不容易區分是庫文件還是本地文件了。
嵌套文件包含
如果出現這樣的場景:
comm.h和comm.c是公共模塊。
test1.h和test1.c使用了公共模塊。
test2.h和test2.c使用了公共模塊。
test.h和test.c使用了test1模塊和test2模塊。
這樣最終程序中就會出現兩份comm.h的內容。這樣就造成了文件內容的重復。
如何解決這個問題?
答案:條件編譯。
每個頭文件的開頭寫:
#ifndef __TEST_H__
#define __TEST_H__
//頭文件的內容
#endif //__TEST_H__
//因為如果引用了相同頭文件的文件也會因為復制粘貼具有相同的結構,__TEST_H__標識符就被定義了,防止在下一次引用時被重復嵌套了,__TEST_H__一旦被引用過就會被預定義了就一定可以通過條件編譯在預處理階段就防止相同的文本出現在C語言的文本文件中,相同的文本文件更不會因此被編譯了
或者:
#pragma once//檢測在同一程序中的引用情況,讓其在同一程序中只引用一次
就可以避免頭文件的重復引入。
筆試題:
- 頭文件中的 ifndef/define/endif是干什么用的?
- #include <filename.h> 和 #include "filename.h"有什么區別?
答:
1.ifndef屬于條件編譯指令,檢測的是標識符的未定義,如果#ifndef后的標識符未定義的話就編譯#ifndef涵蓋下的C語言文本,反之則不編譯其涵蓋下的C語言文本,如果其后仍有條件編譯語句就繼續判斷符合條件可編譯的語句,如果直接是#endif就直接沒有可編譯的語句。define屬于預處理指令中的預定義指令,用于定義標識符和宏。#endif屬于條件編譯指令,是單個條件編譯結構結束的標志
2. #include <filename.h>屬于庫文件包含,直接去標準路徑中查找頭文件,沒找到則提示編譯錯誤,#include "filename.h"屬于本地文件包含,先在源文件所在的目錄下(本地)查找,如果未找到則像庫文件查找一樣在標準位置查找頭文件,沒找到則提示編譯錯誤,其中標準庫中的頭文件也可以被本地文件包含查找到,但這樣效率更低,且不容易區分誰是庫文件誰是本地文件了。
其他預處理指令
#error
#pragma
#line
...
//不做介紹,自己去了解。
#pragma pack()//在結構體部分介紹過
#pragma pack()詳細戳這:深入理解C語言(3):自定義類型詳解
總結
以上就是博主對程序環境和預處理的詳解,😄希望對你的C語言學習有所幫助!作為剛學編程的小白,可能在一些設計邏輯方面有些不足,歡迎評論區進行指正!看都看到這了,點個小小的贊或者關注一下吧(當然三連也可以~),你的支持就是博主更新最大的動力!讓我們一起成長,共同進步!