零基礎學C語言——預編譯

這是一個C語言系列文章,如果是初學者的話,建議先行閱讀之前的文章。筆者也會按照章節順序發佈。

一個C語言源碼要想生成可執行程序,需要經過四個階段——預編譯、編譯、彙編、連接。在一些文檔中會將彙編歸入編譯階段。還有些文檔中會將連接寫成鏈接。

這四個階段大至做的工作分別是:

  • 預編譯:對源文件進行預處理,主要是完成預編譯指令的處理。預處理包含:
    • 展開頭文件
    • 宏替換
    • 去掉註釋
    • 條件編譯
  • 編譯:這個階段主要是將預處理後的代碼轉換為彙編代碼
  • 彙編:調用匯編器將編譯階段生成的彙編代碼轉換為二進制機器碼
  • 連接:將彙編階段生成的各個源文件對應的二進制機器碼文件合併生成可執行程序

本篇不深入討論編譯、彙編、連接的內容,僅針對預編譯部分進行說明。

除代碼註釋外,其餘預編譯指令均以井號(#)開頭。

註釋

在之前的文章中,筆者一直在使用註釋,細心的讀者可能會注意到代碼區域內的/**/和//。

<code>/*
這是一個
多行註釋
*/

//這是一個行註釋/<code>

在C中有上述兩種註釋方式。我們這個系列的第一篇 文章中提到過,我們的操作系統環境是Linux或者蘋果的OSX(也就是darwin),這二者都屬於類UNIX系統。還有一些其他的類UNIX系統,例如AIX,Solaris等,這些系統上的C編譯器可能不支持行註釋。

頭文件引入

在之前文章的很多代碼中,都會看到#include ...字樣。

include就是一個預編譯指令,這個指令用於將其後文件名指定的文件中的內容引入當前源文件中include指令所在位置。例如:

<code>/*a.h*/
int a;/<code>
<code>/*a.c*/
#include "a.h"

int main(void)
{
a = 10;
return 0;
}/<code>

這樣的寫法等價於:

<code>/*a.c*/
int a;

int main(void)
{
a = 10;
return 0;
}/<code>

C語言程序中,我們通常會按照功能將代碼劃分到不同源文件中。一個功能模塊中可能會有其他功能模塊需要用到的函數,如果每個用到這些函數的源文件都要寫一份函數、結構、類型的聲明,那麼代碼中將會有大量這樣的重複聲明,且一旦函數、結構、類型定義有改動,那麼所有引用到的文件都要跟著修改。

頭文件就是用來解決這個麻煩的,我們會將這些結構、類型、函數聲明全部放入頭文件中,然後在需要用到這些聲明的源文件中用include指令將文件引入,這樣源文件就非常整潔清晰了。


細心的讀者可能會發現,我們的代碼中存在了兩種include寫法:

<code>#include <...>
#include "..."/<code>

這兩者的差別在於:

“”的寫法會優先查找當前目錄下是否存在該頭文件,如果不存在,則去標準庫頭文件所在目錄中查找;而<>則是在標準庫頭文件所在目錄下查找指定的頭文件。這裡額外提一句,我們使用的printf就是聲明在標準庫頭文件stdio.h中的。

宏定義

在數學運算中,如果我有個標號PI,且它等價於3.14,那麼我在寫公式時可以寫成:

PI * 2 * 2

在實際運算時,會將PI替換成3.14:

3.14 * 2 * 2

此時,PI就相當於C語言中的一個宏。

這個例子看似與常量很相似,但是常量是在編譯階段被處理的,而宏則是在預編譯階段被處理的,且宏的用法更加複雜。

定義一個宏,用到了define指令,我們看一個例子:

<code>#define PI 3.14
float foo(float r)
{
return r * r * PI;
}/<code>

這段代碼在預編譯階段(編譯階段之前),會被處理成:

<code>float foo(float r)
{
return r * r * 3.14;
}/<code>

當然,這個處理結果並未生成文件,因此無法直觀看到。

宏定義的一般形式

<code>#define 新寫法 等價的寫法/<code>

再舉一個例子:

<code>#define add(a, b) a+b
//我們定義了一個宏 add(a, b) 它等價於 a + b
//例如:
add(1, 2) //等價於1 + 2
add(3.1, 1.7) //等價於3.1 + 1.7
add(1+1, 3+2) //等價於1+1 + 3+2/<code>

這裡宏配合()時有些類似一個函數,此時括號內用逗號分隔的每一個片段都是一個參數,本例中a和b就是參數。

可以看到,這相當於將a和b原樣不動地完全替換到a+b中。

那麼這樣的宏會有什麼坑呢?例如:

<code>add(1, 2) * add(3, 4)/<code>

我原本期望是3 * 7,但是原樣不動替換的含義則是 1 + 2 * 3 + 4,最終等於11。

解決方案很簡單:

<code>#define add(a, b) (a+b)
add(1, 2) * add(3, 4) //將被展開成 (1+2) * (3+4) = 21/<code>

有時,宏定義中等價的寫法部分內容比較長,因此可能會想換一行寫。如果直接換行,編譯器是無法正常處理的。因此需要再換行前加入一個延續運算符(\\),這個運算符僅用於預編譯階段,所以不在我們的 文章中給出。例如:

<code>#define add4(a, b) \\
add(a, b)+add(a, b)+add(a, b)+add(a, b)/<code>

再換行前加入一個反斜槓,這樣下一行也將被作為宏定義的一部分繼續處理。並且從這個例子中可以看到,宏是可以嵌套使用的,這裡的add就是上面定義的add宏。


有時,我們可能在一些情況下要刪除掉我們定義的宏,這時就要使用

undef指令了,例如:

<code>#undef add4(a, b)
//刪除掉add4(a, b)這個宏
#undef PI
//刪除掉PI/<code>

預定義宏

前一小節說明了如何定義宏,但有些宏,編譯器已經預先定義好了。

  • __DATE__:當前日期,一個以 "MMM DD YYYY" 格式表示的字符數組
  • __TIME__:當前時間,一個以 "HH:MM:SS" 格式表示的字符數組
  • __FILE__:當前文件名,一個字符數組(即所謂的字符串)
  • __LINE__:當前行號,一個十進制整數
  • __STDC__:當編譯器以 ANSI 標準編譯時,其值為1
<code>#include <stdio.h>

int main(void)
{
printf("date:[%s]\\ntime:[%s]\\nfile:[%s]\\nline:[%d]\\nstdc:[%d]\\n", __DATE__, __TIME__, __FILE__, __LINE__, __STDC__);
return 0;
}/<stdio.h>/<code>

運行結果如下:

<code>date:[Feb  6 2020]
time:[14:59:19]

file:[a.c]
line:[5]
stdc:[1]/<code>

條件編譯

既然可以定義宏,也可以刪除宏,那麼當我們在寫程序時就有可能需要判斷一個宏是否存在,如果存在則啟用某段代碼,如果不存在則啟用另一段代碼。這樣的處理就稱作條件編譯

我們將使用如下指令來完成條件編譯:

  • ifdef:如果宏存在,則真值判斷為真,否則為假
  • ifndef:如果宏不存在則為真,否則為假
  • if:如果給定條件為真,則對真值區代碼進行編譯,if還支持邏輯的與(&&)或(||)非(!)。
  • else:配合if使用,如果if條件失敗,則將else區域代碼進行編譯
  • elif
    :配合if使用,如果滿足給定條件,則對本條件所對應的真值區代碼進行編譯
  • endif:表示if區域或者ifdef、ifndef區域結束
  • defined()運算符:判斷一個宏是否被定義,定義了為真,否則為假

下面簡單看一個綜合使用的例子:

<code>#include <stdio.h>

#define A 1
#define B 2

int main(void)
{
#ifdef A
printf("A is defined.\\n");
#endif
#ifndef C
printf("C is not defined.\\n");
#endif

#if defined(C)
printf("C is defined.\\n");
#elif defined(B) && !defined(C)
printf("B is defined, C is not defined.\\n");
#else
printf("Shouldn't be here.\\n");
#endif
return 0;
}/<stdio.h>/<code>

這個例子經過條件編譯處理後的結果大致如下:

<code>#include <stdio.h> 


int main(void)
{
printf("A is defined.\\n");
printf("C is not defined.\\n");
printf("B is defined, C is not defined.\\n");
return 0;
}/<stdio.h>/<code>

因此,輸出結果為:

<code>A is defined.
C is not defined.
B is defined, C is not defined./<code>

宏中字符串常量

我們來看這樣一個需求,我對一週7天做了宏定義,如下:

<code>#define Mon 0
#define Tue 1
#define Wed 2
#define Thu 3
#define Fri 4
#define Sat 5
#define Sun 6/<code>

此時,我期望輸出結果如下:

<code>Mon:0
Tue:1
Wed:2
Thu:3
Fri:4
Sat:5
Sun:6/<code>

我希望在不額外手工定義任何變量或常量的情況下滿足我的輸出期望。

下面我們來看一個解決方法:

<code>#define print_week_day(day) printf("%s:%d\\n", #day, day)/<code>

此時,我們在main中使用宏:

<code>int main(void)
{
print_week_day(Mon);
print_week_day(Tue);
print_week_day(Wed);
print_week_day(Thu);
print_week_day(Fri);
print_week_day(Sat);
print_week_day(Sun);
return 0;
}/<code>

這裡,我們在宏print_week_day中,第一個day的位置前加了#,這個符號會將day的部分作為一個字符數組常量,而不是替換成day所指代的宏的值。

自定義錯誤信息

有時,我們雖然可以利用條件編譯判斷一個宏是否存在,但是如果宏不存在時我們希望編譯器能拋出一段我們自定義的錯誤提示來終結掉編譯。這裡就要用到error指令了。

error指令的一般形式

<code>#error 一段自定義文字/<code>

例如:

<code>int main(void)
{
#ifndef TEST
#error TEST not existent
#endif
return 0;
}/<code>

TEST本就不存在,因此編譯這段代碼時,編譯器會報錯,error後的內容將作為報錯信息給出:

<code>a.c:4:6: error: TEST not existent
#error TEST not existent
^/<code>

粘貼運算符

我們來看這樣一個需求:我有若干源文件,我想在每個源文件中定義一組全局變量(注意不是靜態全局變量,因此名字不可重複),且每一個文件內的全局變量的類型與其他文件的都一樣只是名字不同。

一般的解決方案是,在每一個源文件中直接拷貝,如果全局變量的個數少也沒什麼問題,但是如果全局變量有幾十個,且如果需要修改某一個全局變量的類型是,其餘文件的對應變量類型也要跟著修改,那似乎會很“爽”。

有一種簡單的解決方案,這裡用到了粘貼運算符(##):

<code>/*common.h*/ 

#define group_var(prefix) \\
int prefix##_a; \\
float prefix##_b; \\
char prefix##_c;/<code>

這裡,##會將prefix指代的內容與其右側的_a拼接在一起。此時,在源文件中我們可以寫:

<code>/*a.c*/
int main(void)
{
}/<code>

可以看到,group_var的prefix為a,則我們就有了全局變量a_a,a_b,a_c。如果此時有另一個源文件b.c,我們也可以直接寫group_var(b),就定義了b_a,b_b,b_c。

pragma

這是一個特殊的預編譯指令,用於指示編譯器完成一些特定的動作。

pragma指令的一般形式

<code>#pragma 指示字 參數/<code>

其中,指示字包含:

  • message:自定義編譯信息
  • once:保證頭文件只被編譯一次
  • pack:用於指定內存對齊

不同C編譯器所支持的指示字有所不同,因此pragma指令的部分指示字是不可移植的。

下面給出上述三個指示字的用例:

message:

<code>#pragma message "This is a message"
int main(void)
{
return 0;
}/<code>

編譯時會輸出:

<code>a.c:1:9: warning: This is a message [-W#pragma-messages]
#pragma message "This is a message"
^/<code>

注意,這段信息不是報錯,而是以警告的形式給出的。

once:

<code>/* a.h */
#pragma once
#include "a.h"

int a;/<code>
<code>/* a.c */
#include "a.h"
int main(void)
{
return 0;
}/<code>

可以看到,a.h中又include了自己,因此如果不加pragma once編譯器會給出如下報錯:

<code>./a.h:1:10: error: #include nested too deeply
#include "a.h"
^/<code>

加了之後,因為只會被處理一次,所以順利完成編譯。

pack:

這個指示字一般常用於結構體中,在 一文中捎帶提起過,結構體存在對齊規則。默認情況下,編譯32位程序,編譯器會讓結構體中成員向4字節對齊,64位程序則向8字節對齊。但是這個對齊是可以通過pragma pack進行修改的。我們看一個例子,這個例子是在64位操作系統下生成的64位程序:

<code>#include <stdio.h>

struct test {
char a;
long b;
int c;
};

int main(void)
{
printf("%lu\\n", sizeof(struct test));
return 0;
}/<stdio.h>/<code>

這個程序的輸出是24。這是因為a只有1字節,而b有8字節,因此中間額外填充了7字節,來湊8字節對齊。此時a、b都已向8字節對齊,共16字節。c為4字節,其後也沒有小於等於4字節的成員,因此需要補充4字節,湊8字節對齊。

如果不希望結構體做對齊(也就是1字節對齊)呢?看下例:

<code>#include <stdio.h>

#pragma pack(1)
struct test {
char a;
long b;
int c;
};
#pragma pack()

int main(void)
{
printf("%lu\\n", sizeof(struct test));
return 0;
}/<stdio.h>/<code>

此時,輸出的結果為13,即1字節a+8字節b+4字節c。

pack後括號內的數就是對齊的字節數,如果括號內不寫入任何數值,則表示恢復默認。


喜歡的小夥伴可以關注碼哥,也可以給碼哥留言評論,如有建議或者意見也歡迎私信碼哥,我會第一時間回覆。


分享到:


相關文章: