1. 程式人生 > >C語言之資料結構

C語言之資料結構

C語言中的基本結構體以及記憶體之間的關係,我們經常用到,所以我們今天來學習一下這些內容

記憶體

記憶體是什麼,和資料結構有什麼關係?

記憶體從哪裡來?

記憶體是程式執行的活動之地,程式需要放在記憶體中執行的,程式執行時需要記憶體來儲存一些臨時變數資料。

記憶體在物理上本身是一個硬體器件,由硬體系統提供,記憶體在使用的時候需要由作業系統來統一管理,作業系統為了方便合理的管理記憶體,作業系統提供了多種機制來讓應用程式使用記憶體,這些機制彼此不同,程式根據自己的情況來獲取,使用和釋放記憶體。

程式中,申請記憶體的方法有三種: 堆,棧,資料區;

棧記憶體

棧是在執行時自動分配和自動回收的,不需要程式設計者手動申請和釋放。區域性變數就是通過棧來實現的,使用起來比較方便簡單。

反覆使用

棧記憶體可以反覆使用,在程式中可以反覆使用棧的記憶體空間,每個程序被作業系統賦予一塊棧記憶體,在程序執行的時候,使用棧的資料按照先進後出的方式進行棧的使用,作業系統來維護棧指標來反覆使用這塊棧記憶體。

髒記憶體

棧記憶體屬於髒記憶體,也就是說當從棧中分配一塊記憶體的時候,此時這個記憶體中的資料是隨機的,並不是全0,上次使用該塊記憶體的資料還會在這個記憶體中,C語言中定義一個區域性變數但沒有初始化時,它的值是隨機的,因為這個區域性變數就是位於棧記憶體中的。

臨時性

棧記憶體的資料是臨時性的,宣告週期較短,只存在於一小段時間就使用完畢,彈出棧外了,C語言中,函式不能反悔一個棧變數的指標,就是這個原因,因為一旦函式執行完畢,其中的區域性變數就彈棧了,該指標就會指向另外一個變數,就不是原來的那個變量了。

棧溢位

作業系統會事先給程序分配棧的大小,如果在函式中不停的分配區域性變數但不釋放,就會迅速導致棧記憶體空間被用完,導致棧記憶體空間溢位,例如我們常用的函式遞迴呼叫,就很容易導致棧溢位。

堆記憶體

和棧記憶體不同,作業系統使用堆管理器來管理,堆管理器屬於作業系統的一個模組。

大塊記憶體

堆記憶體可以分配大塊的記憶體區域,多個程序使用同一個堆管理器來進行記憶體的申請和釋放,管理器可以進行靈活管理,各程序按需分配使用並釋放。

手動申請釋放

堆管理器需要程式設計師編寫程式碼來申請和釋放,使用malloc和free函式進行操作。

髒記憶體

堆記憶體也是反覆使用的,使用者使用完畢之後不會進行清除,所以堆記憶體也是髒記憶體,所以申請了記憶體之後,應該進行初始化之後再使用。

臨時性

堆記憶體只在malloc和free之間屬於該程序,程序可以對該堆記憶體進行操作,其他期間都不能訪問,否則會發生錯誤。

malloc函式

malloc函式的原型為void *malloc(size_t size),返回值是void ,void 是一個指標型別,該指標型別可以轉換為任何其他型別的指標,所以可以適應任何型別的資料分配。void 實際上就是一個記憶體地址,指向的所分配的記憶體空間的起始地址,這段空間將來用於儲存什麼型別的元素,就把void 轉換為那種型別的指標即可。

malloc申請成功返回記憶體空間的地址,申請失敗返回NULL,我們在實際使用中,一定要對malloc的返回值進行NULL判斷,以防止記憶體申請失敗時導致程式發生異常。

free函式

在我們申請到記憶體之後,使用一個指標進行儲存,但是要切記的是,在沒有呼叫free函式對該指標進行釋放時,千萬不能把該指標指向另外一個記憶體地址或者置為NULL,否則的話,申請的這塊記憶體就丟失掉了,雖然在作業系統的堆管理器中當前程序還持有這塊記憶體,但是程序已經失去了和該塊記憶體聯絡的紐帶,這也就是我們常說的記憶體洩露。丟掉的記憶體空間直到程序結束之後,才會被徹底釋放。

使用free釋放記憶體之後,這段記憶體空間就不能再使用了,否則會造成程式執行出錯。

資料段

程式分為程式碼段,資料段,bss段等,編譯器在編譯程式的時候,將程式中的所有元素分成了不同的型別段,段是可執行程式的組成部分。

程式碼段,資料段和bss段

程式碼段是程式中可執行的部分,直觀理解程式碼段就是由函式堆疊組成的,表示程式的動作。

資料段又稱為資料區,靜態資料區或者靜態區,表示程式中的資料,就是C語言程式中的全域性變數。

bss段又稱為zi段,特點就是會被初始化為0,本質上也是屬於資料段,但是指的是那些被初始化為0的資料段,也就是那些被初始化為0的全域性變數。需要我們知道的是,語言中沒有進行初始化的全域性變數預設會被初始化為0,因為他們位於bss段。

程式碼段上的資料

在C語言中,定義一個類似於char *p = "linux";的常量字串,其中的字元創實際上被分配在程式碼段上。

資料段的內容

使用static修飾的區域性變數,會被分配在資料段上。所以我們現在知道,顯示初始化為非0的全域性變數和使用static修飾的區域性變數,都是位於資料段的。

C語言中,使用堆,棧以及資料段都可以為程式提供可用記憶體,都可以提供給程式來定義變數,只不過在各自的使用和特性上有些許差別。

  • 棧只能用於C語言中的區域性變數,不能用於其他變數,棧的管理是自動的,由編譯器和作業系統自動完成服務,程式設計師無法手動控制
  • 堆記憶體是獨立於程式的,由專門的堆管理器負責對各程序提供堆服務,需要程式設計師手動的向堆管理器申請和釋放。
  • 資料段對應於C中的全域性變數和靜態區域性變數,維護和管理和棧一樣是自動的

字串

C語言沒有原生的字串型別,本質上字串都是由字元陣列拼接而來的,通過字元指標來簡介實現的。

C語言中通過char *p = "linux";的方式來定義字串,這樣實際上定義了一個字元指標,指向一段儲存了字串的記憶體地址,p本身就是指標,只不過指向了字串的地址而已。

字串本質上就是一串字元,而字元就是char型別的變數,C中使用ASCII對字元進行編碼,將多個字元打包一起,就共同組成了一個字串,字串在記憶體中本質上是多個字元連續分佈構成的

特點

C語言中的字串有三個特點

  • 指標指向字串的頭
  • 尾部固定,總是以“\0”來結尾
  • 組成字串的各個字元地址彼此相連

字串的指標指向的是字串的頭,也就是其中第一個字元的地址,‘\0’其實就是編碼為0的ASCII字元,表示空字元。

要注意的是,指向字串的指標,和字串本身是兩個東西,上面的程式碼中,p本身是個指標,佔4個位元組,而”linux”被分配在程式碼段,帶上”\0”佔用6個位元組,所以定義如上一個字串,佔用了10個位元組。

除了使用字串可以儲存多個連續字元,我們還可以直接使用字元陣列來儲存。

字串和字元陣列

使用strlen可以測試一個字串的長度,但是這個函式返回的結果是不包含字串結尾符”\0”的。

sizeiof可以得到陣列中元素的個數,和陣列的初始化狀態沒有關係,但是strlen是用來計算字串長度的,不能傳非字串進去。

如果定義陣列沒有明確給出陣列大小,則需要同時將該陣列進行初始化,以便編譯器計算陣列的大小。

從記憶體分佈來講,使用字串來初始化一個字元陣列時,此時該字元陣列也就相當於是一個字串,長度是原生字元陣列+1,該字元陣列實際上和使用字元指標定義字串是一樣的,但是使用字元指標來進行字串初始化的時候,是定義了一個指標和一個字串。

結構體

結構體是一種自定義型別,使用時需要先定義型別,然後再使用型別來定義變數,或者在定義結構體型別的同事定義結構體變數。

陣列和結構體

陣列有兩個明顯的缺陷,首先是定義的時候必須明確的給出大小,並且之後不能更該陣列的大小,其次,陣列要求所有的元素型別必須一致,這點不夠靈活。結構體解決了陣列的第二個缺陷,結構體中的元素型別可以不同。

結構體元素的訪問

結構體通過’.’或者’->’來訪問結構體中的元素,其本質實際上都是一樣的,使用‘.’號和’->’來訪問的實質,就是用指標來進行訪問。所以說這些方式實質上都是一樣的。

結構體對齊

結構體要考慮元素的對其訪問,每個元素實際佔用位元組數和自身型別所佔用的位元組數不一定完全一樣,例如char型別的元素,實際佔用的位元組數有多種可能。

一般情況下如果使用’.’的方式進行結構體元素訪問的時候不需要考慮結構體對齊的問題,但是如果使用指標的方式來訪問的話,就需要考慮這個問題了。

結構體元素對齊訪問的一個主要原因是為了配合硬體,硬體本身有物理上的限制,如果對齊訪問可以很大的提高訪問效率,我們知道,記憶體本身是物理器件,在設計時有一定的侷限性,導致每次訪問記憶體時如果對齊訪問效率是最高的,如果不對齊訪問,效率會下降很多,另外,Cache的一些特性,以及其他特性,也都要求記憶體需要對其訪問。

記憶體對齊指令

編譯器本身是可以設定記憶體對齊的規則的, 在32位系統中,一般預設按照4位元組對齊,編譯器設定為4位元組對齊時,結構體整體本身必須安置在4位元組對齊處,結構體對齊後的大小必須是4的倍數,設定為8位元組對齊時,則必須安置在8位元組對齊處,大小是8的倍數。

GCC中,常用的對齊指令有: attribute(packed)和attribute(aligned),其中第一個指定按照多少個位元組對齊,第二個指令表示不對齊。

offsetof

我們通過架構體變數來訪問其中的元素,本質上其實是通過指標來訪問的,底層實質上是編譯器幫我們自動計算了偏移量

offsetof巨集的作用就是來計算結構體中某個元素和結構體首地址的偏移量,其實質還是通過編譯器來自動計算的,offsetof的定義和使用是這樣的:

#define offsetof(TYPE,MEMBER) ((int) &((TYPE *)0) -> MEMBER)

struct mystruct {
    char a;
    int b;
    short c;
}

...
int offseta = offsetof(struct mystruct, a);

在這個巨集中,(TYPE )0表示的是把0地址強制型別轉換為一個指標,該指標指向一個TYPE型別的結構體變數,實際上這個結構體變數可能不存在。由於這個指標型別是TYPE ,所指向的是TYPE型別的變數,值為0,可以通過該指標來訪問結構體變數中的某個成員MEMBER,然後通過&來獲取MEMBER的地址,由於我們變數首地址為0,所以此時MEMBER的地址也就是相對於結構體首地址的偏移量。

container_of

container_of巨集的作用是通過結構體變數中某個元素的指標,反過來推出該結構體變數的指標。

在某些場景下,這種操作是非常必要的,這個巨集的定義是這樣的:

#define container_of(ptr, type, member) ({
const typeof(((type *)0)->member) * __mptr = (ptr);
(type *)((char *)__mptr - offsetof(type, member)); 
})

這個巨集由兩句組成,其中,ptr是指向結構體元素member的指標,type是結構體型別,member是結構體中一個元素的元素名,這個巨集返回的就是指向整個結構體變數的指標,型別是(type *)。typeof關鍵字作用是通過變數名得到變數的資料型別。

進一步的,我們一旦獲取到了該結構體的指標,就可以通過該結構體指標獲取其中其餘元素的指標了。

這個巨集的工作原理是這樣的,先用typeof獲取元素的型別,定義為一個指標,再使用指標減去該元素相對於整個結構體的偏移量,就得到了整個結構體變數的首地址,再強制轉換為該結構體型別的指標。

共用體union

共用體的定義,使用和結構體是非常類似的,但是共用體和結構體本質上是完全不同的。

結構體中,各個元素是獨立的,分佈在不同的記憶體單元中,只是被結構體打包成一個整體,叫做結構體,但是在共用體中,元素不是獨立的,使用同一個記憶體單元,這個記憶體空間有多種解釋方式,不同的解釋方式就是對應的不同的共用體元素型別。共用體中各個元素的地址是一樣的,對同一塊記憶體中儲存的二進位制進行不同的理解,就組成了共用體中元素型別。

共用體變數所佔用的記憶體位元組數,由於元素是共用的,所以佔用的記憶體位元組就是其中佔用記憶體最大元素的大小。共用體不涉及到記憶體對齊問題,因為其中的元素都是從同一記憶體地址開始的。共用體常用於對同一記憶體單元進行多種不同方式解析的環境。

大小端模式

大小端常指的是計算機儲存系統中的大小端,資料是按照位元組為單位進行儲存的,一個32bit的位元組的二進位制在記憶體中就有大端和小端兩種儲存模式,高位元組儲存在低地址,稱為大端模式,反之高位元組儲存在高地址,則叫做小端模式。

大小端本身沒有區分和優劣,但是要求存取都按照同樣的大小端模式進行,否則就無法進行存取,實際中,大部分CPU都採用小端模式進行存取,在程式設計中,可以用程式碼來檢測當前系統的大小端。

使用union來檢測大小端

使用共用體檢測大小端模式,程式碼如下:

union myunion {
    int a;
    char b;
}

int is_little_endian(void){
    // 大端模式返回1,小端模式返回0
    union myunion u1.a = 1;
    return u1.b;
}

當我們把u1的a設定為1,此時記憶體中的四個bit中,如果是小端模式,則是0001,大端模式是1000,用u1的b訪問的時候是訪問最低地址那個位元組,則可以得出b如果是1,則是小端模式,如果是0,則是大端模式

使用指標檢測大小端

使用指標的方式就更簡單了,我們用指標重寫上面的方法:


int is_little_endian(void){
    // 大端模式返回1,小端模式返回0
    int a = 1;
    char b = (char *)&a;
}

將a的地址轉換為char型別的指標,再進行解引用,最後就會得到最低那個位元組的內容,如果是1就是小端,如果是0就是大端。很多時候看起來可以使用位與,移位,強制型別轉換的方式來測試大小端,其實是不正確的。通訊系統中,通訊雙方都需要明確通訊的大小端,區別是先發送的是高位還是先接受的是高位,在實際中,通訊協議中會明確的說明先發高位元組還是低位元組。

列舉

列舉其實是一些符號常量的集合,都是int型別的常量,每個符號和一個常量進行繫結,編譯器對列舉的認知就是該符號常量所繫結的int型別的值。在C語言中不使用列舉是可以的,但是使用列舉符號,可以使我們的程式更加容易理解,程式設計更加直觀。

巨集定義和列舉

巨集定義和列舉基本相當,可以進行替換,但是還是有區別的:

  1. 列舉是將多個有關聯的符號封裝在一個列舉中,而巨集定義是散的,彼此之間沒有聯絡
  2. 當要定義的常量屬於一個有限集合時,適合使用列舉,否則就使用巨集定義