1. 程式人生 > >【C】《C專家程式設計》核心知識點總結

【C】《C專家程式設計》核心知識點總結

1、穿越時空的迷霧

這裡寫圖片描述

這裡寫圖片描述

編譯器設計者的金科玉律:效率幾乎就是一切,這包括兩個方面,編譯效率和執行效率,而後者起決定性作用。有很多編譯優化措施會延長編譯時間,但卻能縮短執行時間;還有一些優化措施如清除誤用程式碼和忽略執行時檢查等,既能縮短編譯時間,又能減少執行時間,同時還能減少記憶體的使用量,但有利就有弊,在於使用者如何權衡。

早期C語言的許多特性是為了方便編譯器設計者而建立的,如型別系統、陣列下標從0而不是1開始、基本資料型別直接與底層硬體相對應、預設的變數記憶體分配模式關鍵字auto、表示式中的陣列名可以看作是指標、float被自動擴充套件為double、不執行函式內部包含另一個函式的定義、變數存放到暫存器的關鍵字register等。

C前處理器是編譯器的第一個環節,主要功能是展開include的標頭檔案(不侷限於標頭檔案)以及用到的巨集,需要注意的是,巨集的引數不作型別檢查,巨集的擴充套件受空格影響。

標準是一個很重要的東西,目前的C語言標準為ISO/IEC 9899:2011即C11,最初由ANSI C演變而來。整型變數在作數學運算時,如果型別不同,會進行型別提升即小整型提升為大整型,無符號型別還會轉換為有符號型別,這必須引起重視,否則很可能造成嚴重的問題卻又很難發現。關鍵字const用於宣告變數是隻讀的,不可被修改,然而與指標一起使用時容易引起混淆,位置不同,作用不同,可能表示指標本身只讀,也可能表示指標指向的內容只讀。

    int foo = 100;
    int foo2 = 101;
    const int * bar = &foo; // 指標指向的內容只讀
    int const * bar2 = &foo; // 指標指向的內容只讀
    int * const bar3 = &foo; // 指標本身只讀
    bar = &foo2;
    bar2 = &foo2;
    //bar3 = &foo2; // error
    //*bar = foo2; // error
    //*bar2 = foo2; // error
    *bar3 = foo2;

2、這不是Bug而是語言特性

這裡寫圖片描述

這裡寫圖片描述

程式語言缺陷歸於三類,多做之過、少做之過和誤做之過,防止掉入語言缺陷和陷進的最好辦法就是熟悉語言特性,下面總結了需要熟記的C語言特性——
(1)NUL用於結束一個ASCII字串,NULL用於表示空指標。
(2)合理使用語句塊即一對大括號,限制變數作用域。
(3)const變數不是常量,而是隻讀變數。
(4)switch-case語句,注意break、default。
(5)續行可以使用反斜槓,但同一行的相鄰字串或者相鄰行的字串可以自動合併為一個字串。
(6)函式中定義的static變數只有在第一次執行時會走到。
(7)函式定義時,預設儲存型別是全域性可見的,等同於在函式名前使用表示外部可見的關鍵字extern,如果只限於在當前檔案內可見,則使用關鍵字static。
(8)注意各操作符的優先順序及結合性,適當使用括號。
(9)正確使用庫函式,如gets與fgets,兩者功能類似,但後者限制了字元數,沒有前者造成的緩衝區溢位風險。
(10)注意空格的使用,區別功能需要和程式碼可讀性需要。
(11)函式的返回型別為指標時,注意不能返回指向區域性變數的指標。

3、分析C語言的宣告

C語言宣告,涉及如下幾類符號——
基本型別說明符:void char short int long signed unsigned float double
結構型別說明符:struct
列舉型別說明符:enum
聯合型別說明符:union
儲存型別說明符:extern static register auto
型別限定符:const volatile
其它:typedef define

C語言宣告中,經常會看到一些複雜的宣告語句,特別是有指標存在的場合,這需要了解C語言宣告的優先順序規則,如下——
(A)宣告從它的名字開始讀取,然後按照優先順序順序依次讀取。
(B)優先順序從高到低依次是:
(B.1)宣告中被括號括起來的那部分
(B.2)字尾操作符:括號表示這是一個函式,而方括號表示這是一個數組。
(B.3)字首操作符:星號表示指向什麼的指標
(C)如果const、volatile關鍵字的後面緊跟型別說明符如int、long等,那麼它作用於型別說明符;在其它情況下,const、volatile作用於它左邊緊鄰的指標星號。

這裡寫圖片描述

char * const *(*next)();

首先,next是一個指標,它指向一個函式,該函式返回另一個指標,該指標指向一個型別為char的常量指標,或者說next是一個指向函式的指標,該函式返回另一個指標,該指標指向一個只讀的指向char的指標。

typedef和define都可以定義資料型別,但兩者有很大的差別,首先,可以用其它型別說明符對巨集型別名進行擴充套件,但對typedef所定義的型別名卻不能這樣做,其次,在連續幾個變數的宣告中,用typedef定義的型別能夠保證宣告中所有的變數均為同一種類型,而用define定義的型別則無法保證。

#define peach int
unsigned peach i; // 簡單直接的巨集替換 把peach替換為int ok
typedef int banana;
unsigned banana j; // typedef是個整體 不能再進行別的擴充套件 ng

#define int_ptr int *
int_ptr chalk, cheese; // int * chalk, cheese; chalk為指向int的指標型別 cheese為int型別
typedef char * char_ptr;
char_ptr Bentley, Rolls_Royce; // Bentley和Rolls_Royce都為指向char的指標型別

4、陣列和指標並不相同

這裡寫圖片描述

在C語言中,陣列和指標非常類似,但不完全相同。首先,從宣告和定義開始,定義有且只有一個,extern宣告卻可以有多個,定義是一種特殊的宣告,它建立了一個物件,而宣告簡單地說明了在其它地方建立的物件的名字,它允許你使用這個名字,也就是說,定義只能出現在一個地方,用於確定物件的型別並分配記憶體,建立新的物件,而宣告可以出現多次,用於描述物件的型別,指代其它地方定義的物件。extern物件宣告告訴編譯器物件的型別和名字,物件的記憶體分配則在別處進行,所以在陣列宣告中並未分配記憶體,並不需要提供關於陣列長度的資訊,這就給編譯器足夠的資訊產生相應的程式碼。

在許多情況下,陣列可以像指標那樣通過地址解引用訪問,而指標也可以像陣列那樣通過下標索引訪問,但兩者有內在的不同。首先,對於一個變數來說,有兩層含義,它們是變數地址和變數地址的內容,具體含義由編譯器根據上下文環境判斷,在等號左邊的代表地址,稱為左值,在編譯時可知,表示儲存結果的地方,在等號右邊的代表地址的內容,稱為右值,直到執行時才可知,左值包括可修改的左值,可修改的左值允許出現在賦值語句的左邊,這個是為了區分陣列名,陣列名也用於確定物件在記憶體中的位置,也是左值,但它不能作為賦值的物件,不是可修改的左值。

編譯器為每個變數分配一個地址即左值,這個地址在編譯時可知,而且該變數在執行時一直保存於這個地址,相反,儲存於變數中的值即它的右值,只有在執行時才可知,如果需要用到變數中儲存的值,編譯器就發出指令從指定地址讀入變數值並將它存於暫存器中。這裡的關鍵之處在於每個符號的地址在編譯時可知,所以,如果編譯器需要一個地址,可能還需要加上偏移量來執行某種操作,它就可以直接進行操作,並不需要增加指令首先取得具體的地址,相反,對於指標,必須首先在執行時取得它的當前值,然後才能對它進行解除引用操作。

extern char a[]; // 宣告1
extern char a[100]; // 宣告2
extern char *a; // 宣告3

所以,上面的宣告1和宣告2是等價的,它們都提示a是一個數組,也就是一個記憶體地址,陣列內的字元可以從這個地址找到,編譯器並不需要知道陣列總共有多長,因為它只產生偏離起始地址的偏移地址,從陣列提取一個字元,只要簡單地從符號表顯示的a的地址加上下標,需要的字元就位於這個地址中,但是,宣告3告訴編譯器a是一個指標,在32位的機器裡它是個四位元組的物件,它指向的物件是一個字元,為了取得這個字元,必須得到地址a的內容,把它作為字元的地址並從這個地址中取得字元,指標的訪問要靈活的多,但需要增加一次額外的提取。

char *a= "helloworld";
char a[] = "helloworld";

進一步來說,陣列和指標的內部訪問原理不同,正確的做法就是在定義和宣告時保持一致,否則很可能會汙染程式地址空間的內容,出現莫名其妙的錯誤。陣列和指標都可以在它們的定義中用字串常量進行初始化,儘管看上去一樣,底層的機制卻是不同的,定義指標時,編譯器並不為指標所指向的物件分配空間,它只是分配指標本身的空間,除非在定義時同時賦給指標一個字串常量進行初始化,也僅僅是字串常量進行初始化的情況,而且這個字串常量是隻讀的,不能通過指標進行修改,與指標相反,由字串常量初始化的陣列是可以修改的。

5、對連結的思考

這裡寫圖片描述

這裡寫圖片描述

動態連結與靜態連結的優缺點是相對的。動態連結的優點是可執行檔案的體積可以非常小,雖然執行速度稍慢一些,但動態連結能夠更加有效地利用磁碟空間,節省虛擬記憶體,只有在需要時才被對映到程序中,所有動態連結到某個特定函式庫的可執行檔案在執行時共享該函式庫的一個單獨拷貝,提供了更好的IO和交換空間利用率,節省了實體記憶體,從而提高了系統的整體效能,而且連結-編輯階段的時間也會縮短,連結器的有些工作被推遲到載入時。在動態連結中,所有的庫符號進入輸出檔案的虛擬地址空間中,所有的符號對於連結在一起的所有檔案都是可見的,相反,對於靜態連結,它只是查詢載入器當時所知道的未定義符號,所以在編譯命令中通常把連結的函式庫放到使用者的右邊或者命令的最後,防止出現未定義符號的錯誤。

動態連結的目的之一是ABI即程式二進位制介面,把程式與它們使用的特定的函式庫版本中分離開來。動態連結是一種JIT即just-in-time連結,程式在執行時必須能夠找到它們所需要的函式庫,連結器通過把庫檔名或路徑名植入可執行檔案中來做到這一點。建立動態或者靜態的函式庫,只需簡單地編譯一些不包含main函式的程式碼,並把編譯生成的.o檔案用ld或ar工具進行處理。

使用編譯器選項可以為函式庫產生與位置無關的程式碼,保證對於任何全域性資料的訪問都是通過額外的間接方法完成的,這樣很容易對資料進行重新定位,只要簡單地修改全域性偏移量表的其中一個值就可以了,每個函式呼叫的產生就像是通過過程連結表的某個間接地址所產生的一樣,文字可以很容易地定位到任何地方,所以,當代碼在執行時被對映進來時,執行時連結器可以直接把它們放在任何空閒的地方,而程式碼本身並不需要修改。預設程式碼相關,因為程式碼無關時額外的指標解除引用操作將使程式在執行時稍稍變慢,程式碼相關的情況下所產生的程式碼會被對應到固定的地址,對於可執行檔案來說很好,但對於共享庫來說速度就要慢一點,因為每個全域性引用必須在執行時通過修改頁面安排到固定的位置,而執行時連結器總能夠安排對頁面的引用。

6、執行時資料結構

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

a.out是個很熟悉的名字,即assembler output,怎麼是彙編輸出,不應該是連結輸出嗎?其實,一開始並不存在連結器,建立程式時,先把所有檔案連線在一起,然後進行彙編,彙編產生的彙編程式輸出儲存在a.out中,後來增加了連結器,但仍沿用a.out這個名字。編譯產生的目標檔案和可執行檔案可以有幾種不同的格式,在Linux上常見的便是ELF格式即Executable and Linking Format。

a.out中包含了若干段,在作業系統的記憶體管理術語中,段就是一片連續的虛擬地址。段中的不同section可以設定適當的屬性,讀、寫和執行,例如,文字只讀和可執行,資料只讀或者只讀寫。文字段包含程式的指令,連結器把指令直接從檔案拷貝到記憶體中,一般使用mmap系統呼叫,之後文字內容、大小都不會改變。資料段包含經過初始化的全域性和靜態變數以及它們的值,一般情況下,在任何程序中資料段是最大的段。BSS段即Block Started by Symbol,只儲存沒有值的變數,所以事實上它並不需要儲存這些變數的映像,執行時所需要的BSS段的大小記錄在可執行檔案中,但BSS段不像其它段,並不佔據目標檔案的任何空間,緊跟在資料段之後,當這個記憶體區進入程式的地址空間後全部清零。BSS段和資料段統稱為資料區。堆疊段用於儲存區域性變數、臨時資料、傳遞到函式的引數等。在程序的地址空間中,還包括堆空間,用於動態分配的記憶體。虛擬地址空間的最低部分未被對映,位於程序的地址空間內,但並未賦予實體地址,所以任何對它的引用都是非法的,一般是從零開始的幾K位元組,用於捕捉使用空指標和小整型值的指標引用記憶體的情況。

堆疊段是一塊動態記憶體區域,向下增長,也就是朝著低地址的方向增長,由系統維護,實現了一種後進先出的結構,執行時系統維護一個位於暫存器的堆疊指標sp,用於提示堆疊當前的頂部位置。堆疊段有三個主要的用途,一是為函式內部宣告的區域性變數提高儲存空間,二是儲存函式呼叫時與此有關的堆疊結構或稱為過程活動記錄,三是用作暫時儲存區如alloca函式。除了遞迴呼叫之外,堆疊並非必需,因為在編譯時可以知道區域性變數、引數和返回地址所需空間的固定大小,並可以將它們分配於BSS段。

上面提到了過程活動記錄,它是一種資料結構,用於支援過程呼叫,並記錄呼叫結束以後返回呼叫點所需要的全部資訊。執行時系統維護一個指標,常常位於暫存器中,通常成為fp,用於提示活動堆疊結構,它的值是最靠近堆疊頂部的過程活動記錄的地址。可以把過程活動記錄壓入堆疊段中,但這也不是必需的,有些架構把過程活動記錄的內容存放到暫存器中,這使得函式呼叫更快。關於過程活動記錄,setjmp和longjmp函式便是通過操縱過程活動記錄來實現的,最大的用途是錯誤恢復,C++引入了異常處理機制try-catch-throw。需要注意的地方是,保證區域性變數在longjmp過程中一直保持它的值的唯一可靠方法是把它宣告為volatile,這適用於那些值在setjmp執行和longjmp返回之間會變化的值。longjmp不同於goto,goto語句不能跳出C語言的當前函式,而longjmp可以跳得很遠,甚至可以跳到其它檔案的函式中,只要是setjmp曾經到過的地方即可,與goto類似的是,使得程式難以理解和除錯,最好避免使用。

標準的程式碼優化技巧包括:消除迴圈、函式程式碼就地擴充套件、公共子表示式消除、改進暫存器分配、省略執行時對資料邊界的檢查、迴圈不變數程式碼移動、操作符長度削減(把指數操作轉變為乘法操作,把乘法操作轉變為移位操作或加法操作)等。

7、對記憶體的思考

這裡寫圖片描述

這裡寫圖片描述

今天,計算機系統結構的真正挑戰不在於記憶體的容量,而是記憶體的速度,在相同的時間內,CPU速度倍增,記憶體的容量倍增,但記憶體的速度增長緩慢,於是只能寄望Cache以及相關技術。所有現代的計算機系統,都使用了虛擬記憶體,在任一給定時刻,程式實際需要使用的虛擬記憶體區段的內容就被載入實體記憶體中,當實體記憶體中資料有一段時間未被使用,它們就可能被轉移到硬碟中,節省下來的實體記憶體空間用於載入需要使用的其它資料。通過虛擬記憶體,每個程序都以為自己擁有整個地址空間的獨家訪問權,所有程序共享機器的實體記憶體,當記憶體用完時就用磁碟儲存資料,在程序執行時,資料在磁碟和記憶體之間來回移動,記憶體管理單元MMU負責把虛擬地址翻譯為實體地址,並讓一個程序始終運行於系統的真正記憶體中。如果該程序可能不會馬上執行,可能它的優先順序低,也可能是它處於睡眠狀態,作業系統可以暫時取回所有分配給的它的實體記憶體資源,將該程序的所有相關資訊都備份到磁碟上,這樣,這個程序就被換出,在磁碟中有個特殊的交換區,用於儲存從記憶體中換出的程序,在一臺機器中,交換區的大小一般是實體記憶體的幾倍,只有使用者程序才會被換進換出。

Cache儲存器是多層儲存概念的擴充套件,位於CPU和記憶體之間,是一種極快的儲存緩衝區。所有的現代處理器都使用了Cache儲存器,當資料從記憶體讀入時,一般16或32位元組的整行的資料被裝入Cache,如果程式具有良好的地址引用區域性性,如順序瀏覽一個字串,那麼CPU以後對臨近資料的訪問就可以從快速的Cache讀取,而不用從緩慢的記憶體中讀取。Cache操作的速度與系統的週期時間相同,所以一個50MHz的處理器,其Cach的存取週期為20ns,主存的存取速度可能只有它的四分之一。

在程序的地址空間中,堆空間位於資料區之上,堆的末端有一個稱為break的指標來標識,當堆管理器需要更多記憶體時,它可以通過系統呼叫brk和sbrk來移動break指標。被分配的記憶體總是經過對齊,以適合機器上最大尺寸的原子訪問,一個malloc請求申請的記憶體大小為方便起見一般被圓整為2的乘方。堆記憶體的回收不必與它所分配的順序一致,所以無序的malloc/free最終會產生堆碎片,如果未釋放不再使用的記憶體,就會造成記憶體洩漏,如果釋放或改寫仍在使用的記憶體,就會造成記憶體破壞。記憶體破壞是致命的,而記憶體洩漏不僅僅是洩漏那麼簡單,洩漏的記憶體本身並不被引用,但它仍可能存在於頁面中,這樣就增加了程序的工作頁數量,而且洩漏的記憶體往往比忘記釋放的資料結構要大,記憶體洩漏的程序有可能被系統換出,讓別的程序執行,程序在換進換出時花費的時間也更多,最終導致速度變慢,效能下降。

常見的兩個執行時錯誤,bus error和segmentation fault,預設結果為core dumped,源於作業系統所檢測到的異常,當硬體告訴作業系統一個有問題的記憶體引用即硬體中斷時,作業系統通過向出錯的程序傳送一個訊號與之交流,訊號就是一種事件通知或者軟體中斷,可以為這些訊號設定一個訊號處理程式,用於修改程序的預設結果,但訊號是非同步發生的,程式設計和除錯都較為複雜。

union {
    char a[10];
    int i;
} u;
int *p = (int*)&(u.a[1]);
*p = 100; // bus error

bus error即匯流排錯誤,幾乎都是由於未對齊的讀或寫引起的,對齊的意思就是資料項只能儲存在地址是資料項大小的整數倍的記憶體位置上,資料項不能跨越頁面或者Cache邊界。上面例子中,陣列和int的聯合確保陣列a是按照int的四位元組對齊的,所以a+1的地址肯定未按int對齊,然後試圖往這個地址儲存4個位元組的資料,但這個訪問只是按照單位元組的char對齊,這就違反了規則,導致匯流排錯誤。編譯器通過在記憶體中自動分配和填充資料來進行對齊,一個好的編譯器發現不對齊的情況時會發出警告,但它並不能檢測到所有不對齊的情況。

int *p = 0;
*p = 100; // segmatation fault

segmatation fault即段錯誤,由MMU異常所致,如解除引用一個未初始化或非法值的指標。如果指標引用一個並不位於你的地址空間中的地址,作業系統便會對此進行干涉。一個微妙之處是,對於指標所要訪問的資料而言,如果未初始化的指標恰好具有未對齊的值,它將會產生匯流排錯誤,而不是段錯誤,因為CPU先看到地址,然後再把它傳送給MMU。以發生頻率為序,最終可能導致段錯誤的程式設計錯誤是:在指標賦值之前就用它來訪問記憶體,向庫函式傳遞一個壞指標,對指標釋放之後再訪問它的內容,越過資料邊界寫入資料,在動態分配的記憶體兩端之外寫入資料,改寫堆管理資料結構如在動態分配的記憶體之前的區域寫入資料,釋放同一個記憶體塊兩次,釋放一塊未曾使用malloc分配的記憶體,釋放仍在使用中的記憶體,釋放一個無效的指標,等等。

8、C語言中的型別提升

這裡寫圖片描述

9、再論陣列

這裡寫圖片描述

所有作為函式引數的陣列名稱總是可以通過編譯器轉換為指標,在其它所有情況下,陣列的宣告就是陣列,指標的宣告就是指標,兩者不能混淆,但在使用陣列(在語句或表示式中引用)時,陣列總是可以寫成指標的形式,兩者可以互換,陣列下標表達式總是可以改寫為帶偏移量的指標表示式,當一個數組名出現在一個表示式中時,它會被轉換為指向該陣列第一個元素的指標。C語言把陣列下標改寫成指標偏移量的根本原因是指標和偏移量是底層硬體所使用的基本模型,但隨著編譯器的優化,在處理一維陣列時,指標並不見得比陣列更快。把作為函式形參的陣列和指標等同起來是出於效率原因的考慮。

下面是陣列和指標可交換性的總結——
(1)用a[i]這樣的形式對陣列進行訪問,總是被編譯器改寫或解釋為像*(a+i)這樣的指標訪問。
(2)指標始終就是指標,它絕不可以改寫成陣列,你可以用下標形式訪問指標,一般都是指標作為函式引數時,而且你知道實際傳遞給函式的是一個數組。
(3)在特定的上下文中,也就是它作為函式的引數,也只有這種情況,一個數組的宣告可以看作是一個指標,作為函式引數的陣列,始終會被編譯器修改成指向陣列第一個元素的指標。
(4)因此,當把一個數組定義為函式的引數時,可以選擇把它定義為陣列,也可以定義指標,不管選擇哪種方法,在函式內部事實上獲得的都是一個指標。
(5)在其它所有情況下,定義和宣告必須匹配,如果定義了一個數組,在其它檔案對它進行宣告時也必須把它宣告為陣列,指標也是如此。

char carrot[10][20];
carrit[5][6] = 0;
*(*(carrit+5)+6) = 0;

C語言支援多維陣列,即陣列的陣列,以上面的二維陣列carrot為例,carrot有10個元素,每個元素是個一維陣列,這個一維陣列有20個char型別的元素,同樣可以使用下標或者指標加偏移量的形式進行訪問。在C語言的二維陣列中,最右邊的下標是最先變化的,這個約定被成為行主序,可以理解為一張行列表,但實際的記憶體佈局是線性儲存的。

10、再論指標

這裡寫圖片描述

這裡寫圖片描述

結束