1. 程式人生 > 其它 >18.【C語言進階】程式的編譯

18.【C語言進階】程式的編譯

程式的翻譯環境和執行環境

在ANSIC的任何一種實現中,存在兩個不同的環境。

第1種是翻譯環境,在這個環境中原始碼被轉換為可執行的機器指令。

第2種是執行環境,它用於實際執行程式碼。

詳解編譯+連結

翻譯環境

  • 組成一個程式的每個原始檔通過編譯過程分別轉換成目的碼(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;
}

函式只有宣告的時候,在test.c中是沒有給 _ sum函式分配地址的,在_sum函式中定義的sum.c中才記錄有 _ sum 函式的地址,在連結時,會有符號表的合併和符號表的重定位,這也是為什麼即使我們沒有宣告函式直接呼叫定義在另一個原始檔中的函式,也仍能夠跑起來,並且只是彈出警告的原因。

接下來我們在linux下一步一步看發生了什麼

預處理

執行如上簡單程式。

可以看到當前目錄下只有test.c檔案

使用gcc -E test.c -o test.i預處理命令

並將產生的的結果放在test.i中,可以看到我們本目錄下多了一個test.i檔案,我們來看看其中的內容。

檔案內容略長,我們僅僅看開頭和結尾,開頭是標頭檔案的包含內容,包括連結庫的路徑等等。

結尾就是我們所寫的函數了可以看到被處理的僅僅是預處理指定 #include

編譯

使用gcc -S test.i -o test.s 編譯命令

並且將結果輸出到test.s檔案

接下來看看這個檔案是什麼?

嗯~這裡已經沒有那麼多的檔案內容了,不懂沒關係,大致能看出來這是彙編指令了。

那麼說明這裡是將程式編譯為組合語言。

彙編

同樣的執行gcc -c test.s -o test.o彙編指令

彙編完成之後就停下來,結果儲存在test.o中,看到test.o已經存在了。

繼續進入test.o中檢視:

這下誰也看不懂了,因為彙編會將組合語言程式設計機器二進位制語言,而我們使用的是文字編輯器,所以都是亂碼。

那麼這個test.o可以直接執行嗎,試一下:

沒有許可權,並且這被當作是一個普通檔案,並不是可執行檔案。

使用gcc -o test test.o命令,繼續看看,我們通過test.o生成了一個test可執行檔案。

執行結果:

我們的hello linux!就輸出到我們的螢幕上了。

執行環境

程式執行的過程:

  1. 程式必須載入記憶體中。在有作業系統的環境中:一般這個由作業系統完成。在獨立的環境中,程式的載入必須由手工安排,也可能是通過可執行程式碼置入只讀記憶體來完成。
  2. 程式的執行便開始。接著便呼叫main函式
  3. 開始執行程式程式碼。這個時候程式將使用一個執行時堆疊(stack),儲存函式的區域性變數和返回地址。程式同時也可以使用靜態(static)記憶體,儲存於靜態記憶體中的變數在程式的整個執行過程一直保留他們的值。
  4. 終止程式。正常終止main函式;也有可能是意外終止。

程式載入記憶體中可以簡單想象為將變數資料將函式指令等,載入記憶體中某些特定的位置,並記錄這個地址,讓我們執行程式時可以根據需要訪問這些地址上儲存的指令或者資料。

預處理詳解

預處理符號

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

這些預定義符號都是語言內建的。

例子:

printf("file:%s line:%d\n", __FILE__, __LINE__);

所在的檔案,以及檔案當前的行號就被打印出來了。

#define

#define定義識別符號

例子:

#define MAX 1000 //預處理過程中會將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 MAX 1000;
#define MAX 1000

建議不要加上 ; ,這樣容易導致問題。

例如:

if(condition)
	max = MAX;
else
	max = 0;

我們知道MAX在預處理後會被替換為 1000;,那麼就變成了下面:

if(condition)
	max = 1000;
	;
else
	max = 0;

由於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( 2 );

置於程式中,那麼這個表示式會被替換為 5 * 5.

替換嘛,總是存在一些問題,通常會因為優先順序的問題,導致實際結果和我們預估的不太一樣。

例如:

#define SQUARE(X) X * X

int main()
{
	int i = 2
	printf("%d", SQUARE(i + 1));
	return 0;
}

不注意看可能就認為會輸出 9 了,事實上它會在螢幕上輸出 5 。

為什麼?

替換文字時,引數x被替換成a + 1,所以這條語句實際上變成了:printf ("%d\n",a + 1 * a + 1 );

可以看到,該表示式執行的運算的優先順序與我們想的並不同,解決這個問題,在巨集定義上加上兩個括號就行。

#define SQUARE(X) (X) * (X)

這樣在預處理替換後,程式碼就如下:

#define SQUARE(X) (X) * (X)

int main()
{
	int i = 2
	printf("%d", (i + 1) * (i + 1));
	return 0;
}

當前問題算是解決了。

這裡還有一個巨集定義:

#define DOUBLE(x) (x) + (x)

定義中我們使用了括號,想避免之前的問題,但是這個巨集可能會出現新的錯誤。

如下:

int a = 5;
printf("%d\n" ,10 * DOUBLE(a));

我們本想用DOUBLE(a),來完成乘 2 的操作,結果再和 10 相乘,這樣看來答案是 100 .

事實上這裡將會打印出 55。

仍然檢視替換後的程式碼:

int a = 5;
printf("%d\n" ,10 * (a) + (a));

乘法的優先順序高於加法,所以出現了不可預料的答案。

這個問題的解決辦法是在巨集定義表示式兩邊加上一對括號就可以了。

#define DOUBLE( x)  ( ( x ) + ( x ) )

所以用於對數值表示式進行求值的巨集定義都應該用這種方式加上括號,避免在使用巨集時由於引數中的操作符或鄰近操作符之間不可預料的相互作用

#define替換規則

在程式中擴充套件#define定義符號和巨集時,需要涉及幾個步驟。

  1. 在呼叫巨集時,首先對引數進行檢查,看看是否包含任何由#define定義的符號。如果是,它們首先被替換。
  2. 替換文字隨後被插入到程式中原來文字的位置。對於巨集,引數名被他們的值所替換。
  3. 最後,再次對結果檔案進行掃描,看看它是否包含任何由#define定義的符號。如果是,就重複上述處理過程。

注意:

  1. 巨集引數和#define 定義中可以出現其他#define定義的符號。但是對於巨集,不能出現遞迴。
  2. 當前處理器搜尋#define定義的符號的時候,字串常量的內容並不被搜尋。
## 和 #

如何把引數插入到字串中?

首先我們看看這樣的程式碼:

char* p = "hello ""sx\n";
printf("hello"," sx\n");
printf(p);//直接將地址作為引數,這和下面的方式是一樣的,都是去訪問地址上的資料
printf("%s", p);

這裡輸出的是不是
hello sx

答案是確定的:是。
我們發現字串輸出是有自動連線的特點的。

  1. 那我們是不是可以寫這樣的程式碼?:
#define PRINT(FORMAT, VALUE)\
	printf("the value is "FORMAT"\n", VALUE);
PRINT("%d", 10);

這裡只有當字串作為巨集引數的時候才可以把字串放在字串中。

  1. 另外一個技巧是:使用 # ,把一個巨集引數變成對應的字串。

例如:

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.

實際上執行的是:

sum和num合併為一個識別符號:sum5

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);//輸出的結果是什麼?

我們將程式碼中的巨集進行替換:

z = ( (x++) > (y++) ? (x++) : (y++));

輸出:

x=6 y=10 z=9

要避免寫出這樣的程式碼,巨集是無法除錯的,一旦沒搞清楚,比較麻煩。

巨集和函式對比

巨集通常被應用於執行簡單的運算。

比如在兩個數中找出較大的一個。

#define MAX(a, b) ((a)>(b)?(a):(b))

那為什麼不用函式來完成這個任務?

原因有二:

  1. 用於呼叫函式和從函式返回的程式碼可能比實際執行這個小型計算工作所需要的時間更多。所以巨集比函式在程式的規模和速度方面更勝一籌。
  2. 更為重要的是函式的引數必須宣告為特定的型別。所以函式只能在型別合適的表示式上使用。反之這個巨集怎可以適用於整形、長整型、浮點型等可以用於來比較的型別。巨集是型別無關的。

巨集的缺點:當然和函式相比巨集也有劣勢的地方:

  1. 每次使用巨集的時候,一份巨集定義的程式碼將插入到程式中。除非巨集比較短,否則可能大幅度增加程式的長度。

  2. 巨集是沒法除錯的。

  3. 巨集由於型別無關,也就不夠嚴謹。

  4. 巨集可能會帶來運算子優先順序的問題,導致程容易出現錯。

如果一個程式中多次重複地使用一個功能,那麼還是儘量避免使用巨集來實現,這樣子會造成程式碼膨脹,尤其是程式碼量比較多地巨集,更應避免使用,巨集地最佳使用場景就是一些簡單功能地實現。

如果巨集比較複雜,且多次在程式中替換,那麼就會有大量地重複程式碼,而函式永遠是解決程式碼複用的最佳工具,巨集被替換到棧幀中,就算這個巨集的功能已經完成,但是由於當前棧幀仍需使用,巨集的指令變會一直存在與棧幀空間中,直到棧幀被釋放。

而函式在呼叫時會進行壓棧,開闢新的棧幀空間,當它的使命完成後,就會返回上一層棧幀並自動銷燬,這樣的方式很好的解決了巨集的缺陷,即——指令結束使命後 “ 佔著茅坑不拉屎 “ 的問題。

巨集有時候可以做函式做不到的事情。比如:巨集的引數可以出現型別,但是函式做不到。

#define MALLOC(num, type)\
(type *)malloc(num * sizeof(type))
...
//使用
MALLOC(10, int);//型別作為引數
//前處理器替換之後:
(int *)malloc(10 * sizeof(int));

這確實是一個很厲害的一點,由於巨集替換的性質,巨集的引數可以是任何識別符號,數字,或者是其他東西,有點來者不拒的感覺,但這同時也是它的缺點,沒有型別檢查,好比人人都有你家鑰匙,指不定哪天就出問題了。

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

巨集無法遞迴,因為在定義巨集的時候寫出自身巨集的時候,是無法識別的,巨集只有被完整的定義之後,才被編譯器當作可識別的巨集。

例如:

#define SUM(n) (n > 0 ? (n + SUM(n - 1)) : 0)

這個巨集還沒定義完呢,就又想使用這個巨集,顯然不太合理。

命名約定

一般來講函式的巨集的使用語法很相似。所以語言本身沒法幫我們區分二者。
那我們平時的一個習慣是:

把巨集名全部大寫

函式名不要全部大寫

undef

這條指令用於移除一個巨集定義。

#undef NAME
//如果現存的一個名字需要被重新定義,那麼它的舊名字首先要被移除。

例如:

#define MAX 10
#undef MAX
int a = MAX;

這裡的程式就出現了錯誤,在編譯器看來,MAX是個未定義的識別符號。

命令列定義

許多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 環境演示
gcc -D ARRAY_SIZE=10 programe.c
條件編譯

在編譯一個程式的時候我們如果要將一條語句(一組語句)編譯或者放棄是很方便的。因為我們有條件編譯指令。

比如說:

除錯性的程式碼,食之無味,棄之可惜,所以我們可以選擇性的編譯。

#include <stdio.h>
#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 //__DEBUG__
	}
	return 0;
}

常見的條件編譯指令:

1.
#if 常量表達式
	//...
#endif
//常量表達式由前處理器求值。
如:
#define __DEBUG__ 1
#if __DEBUG__
	//..
#endif
2.多個分支的條件編譯
#if 常量表達式
	//...
#elif 常量表達式
	//...
#else
	//...
#endif
3.判斷是否被定義
#if defined(symbol)
#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次。

標頭檔案被包含的方式

  • 本地標頭檔案包含
#include "filename"

查詢策略:先在原始檔所在根目錄下查詢,如果該標頭檔案未找到,編譯器就像查詢庫函式標頭檔案一樣在標準位置查詢標頭檔案。

如果找不到就提示編譯錯誤。

Linux環境的標準標頭檔案的路徑:

VS環境的標準標頭檔案的路徑:

C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include

庫檔案包含

#include<filename.h>

查詢標頭檔案直接去標準路徑下去查詢,如果找不到就提示編譯錯誤。這樣是不是可以說,對於庫檔案也可以使用 “ ” 的形式包含?

答案是肯定的,可以。

但是這樣做查詢的效率就低些,當然這樣也不容易區分是庫檔案還是本地檔案了。

巢狀檔案包含

如果出現這樣的場景:

conmm.hconmm.c是公共模組。
add.hadd.c使用了公共模組。
multi.hmulti.c使用了公共模組。
test.htest.c使用了add模組和multi模組。
這樣最終程式中就會出現兩份comm.h的內容。這樣就造成了檔案內容的重複。

如何解決這個問題?

答案:條件編譯。

每個標頭檔案的開頭寫:

#ifndef __TEST_H__
#define __TEST_H__
//標頭檔案的內容
#endif  //__TEST_H__

或者

#pragma once

就可以避免標頭檔案的重複包含

1. 標頭檔案中的 ifndef/define/endif是幹什麼用的?
2. #include <filename.h> 和 #include "filename.h"有什麼區別?
  1. 避免標頭檔案重複引入
  2. 一個是從標準路徑下查詢,若找不到則編譯錯誤,第二個是先在當前路徑下查詢,若找不到,再到標準路徑下查詢,還是找不到則編譯錯誤。

其他預處理指令

#error
#pragma
#line
#pragma pack()