C 語言中三塊難啃的硬骨頭

C 語言在嵌入式學習中是必備的知識,審覈大部分操作都要圍繞 C 語言進行,而其中有三塊 “難啃的硬骨頭” 幾乎是公認級別的。

0x01 指針

指針公認最難理解的概念,也是讓很多初學者選擇放棄的直接原因。

指針之所以難理解,因爲指針本身就是一個變量,是一個非常特殊的變量,專門存放地址的變量,這個地址需要給申請空間才能裝東西,而且因爲是個變量可以中間賦值,這麼一倒騰很多人就開始犯暈了,繞不開彎了。C 語言之所以被很多高手所喜歡,就是指針的魅力,中間可以靈活的切換,執行效率超高,這點也是讓小白暈菜的地方。

指針是學習繞不過去的知識點,而且學完 C 語言,下一步緊接着切換到數據結構和算法,指針是切換的重點,指針搞不定下一步進行起來就很難,會讓很多人放棄繼續學習的勇氣。

指針直接對接內存結構,常見的 C 語言裏面的指針亂指,數組越界根本原因就是內存問題。在指針這個點有無窮無盡的發揮空間。很多編程的技巧都在此集結。

指針還涉及如何申請釋放內存,如果釋放不及時就會出現內存泄露的情況,指針是高效好用,但不徹底搞明白對於有些人來說簡直就是噩夢。

在概念方面問題可以參見此前推文《對於 C 語言指針最詳盡的講解》,那麼在指針方面可以參見一下大神的經驗:

複雜類型說明

要了解指針,多多少少會出現一些比較複雜的類型。所以先介紹一下如何完全理解一個複雜類型。

要理解複雜類型其實很簡單,一個類型裏會出現很多運算符,他們也像普通的表達式一樣,有優先級,其優先級和運算優先級一樣。

所以筆者總結了一下其原則:從變量名處起,根據運算符優先級結合,一步一步分析。

下面讓我們先從簡單的類型開始慢慢分析吧。

int p;

這是一個普通的整型變量

int p;

首先從 P 處開始,先與結合,所以說明 P 是一個指針。然後再與 int 結合,說明指針所指向的內容的類型爲 int 型,所以 P 是一個返回整型數據的指針

int p[3];

首先從 P 處開始,先與 [] 結合,說明 P 是一個數組。然後與 int 結合,說明數組裏的元素是整型的,所以 P 是一個由整型數據組成的數組。

int *p[3];

首先從 P 處開始,先與 [] 結合,因爲其優先級比高,所以 P 是一個數組。然後再與結合,說明數組裏的元素是指針類型。之後再與 int 結合,說明指針所指向的內容的類型是整型的,所以 P 是一個由返回整型數據的指針所組成的數組。

int (*p)[3];

首先從 P 處開始,先與結合,說明 P 是一個指針。然後再與 [] 結合(與 "()" 這步可以忽略,只是爲了改變優先級),說明指針所指向的內容是一個數組。之後再與 int 結合,說明數組裏的元素是整型的。所以 P 是一個指向由整型數據組成 3 個整數的指針。

int **p;

首先從 P 開始,先與_結合,說明 P 是一個指針。然後再與_結合,說明指針所指向的元素是指針。之後再與 int 結合,說明該指針所指向的元素是整型數據。由於二級指針以及更高級的指針極少用在複雜的類型中,所以後面更復雜的類型我們就不考慮多級指針了,最多隻考慮一級指針。

int p(int);

從 P 處起,先與 () 結合,說明 P 是一個函數。然後進入 () 裏分析,說明該函數有一個整型變量的參數,之後再與外面的 int 結合,說明函數的返回值是一個整型數據。

int (*p)(int);

從 P 處開始,先與指針結合,說明 P 是一個指針。然後與 () 結合,說明指針指向的是一個函數。之後再與 () 裏的 int 結合,說明函數有一個 int 型的參數,再與最外層的 int 結合,說明函數的返回類型是整型,所以 P 是一個指向有一個整型參數且返回類型爲整型的函數的指針。

int (p(int))[3];

可以先跳過,不看這個類型,過於複雜。從 P 開始,先與 () 結合,說明 P 是一個函數。然後進入 () 裏面,與 int 結合,說明函數有一個整型變量參數。然後再與外面的結合,說明函數返回的是一個指針。之後到最外面一層,先與 [] 結合,說明返回的指針指向的是一個數組。接着再與結合,說明數組裏的元素是指針,最後再與 int 結合,說明指針指向的內容是整型數據。所以 P 是一個參數爲一個整數據且返回一個指向由整型指針變量組成的數組的指針變量的函數。

說到這裏也就差不多了。理解了這幾個類型,其它的類型對我們來說也是小菜了。不過一般不會用太複雜的類型,那樣會大大減小程序的可讀性,請慎用。這上面的幾種類型已經足夠我們用了。

細說指針

指針是一個特殊的變量,它裏面存儲的數值被解釋成爲內存裏的一個地址。

要搞清一個指針需要搞清指針的四方面的內容:指針的類型、指針所指向的類型、指針的值或者叫指針所指向的內存區、指針本身所佔據的內存區。讓我們分別說明。

先聲明幾個指針放着做例子:

(1)int*ptr;

(2)char*ptr;

(3)int**ptr;

(4)int(*ptr)[3];

(5)int*(*ptr)[4];

指針的類型

從語法的角度看,小夥伴們只要把指針聲明語句裏的指針名字去掉,剩下的部分就是這個指針的類型。這是指針本身所具有的類型。

讓我們看看上述例子中各個指針的類型:

(1)intptr;//指針的類型是int

(2)charptr;//指針的類型是char

(3)intptr;//指針的類型是int

(4)int(ptr)[3];//指針的類型是int()[3]

(5)int*(ptr)[4];//指針的類型是int(*)[4]

怎麼樣?找出指針的類型的方法是不是很簡單?

指針所指向的類型

當通過指針來訪問指針所指向的內存區時,指針所指向的類型決定了編譯器將把那片內存區裏的內容當做什麼來看待。

從語法上看,小夥伴們只需把指針聲明語句中的指針名字和名字左邊的指針聲明符 * 去掉,剩下的就是指針所指向的類型。

上述例子中各個指針所指向的類型:

(1)intptr; //指針所指向的類型是int

(2)char*ptr; //指針所指向的的類型是char*

(3)int*ptr; //指針所指向的的類型是int*

(4)int(*ptr)[3]; //指針所指向的的類型是int(*)[3]

(5)int*(*ptr)[4]; //指針所指向的的類型是int*(*)[4]

在指針的算術運算中,指針所指向的類型有很大的作用。

指針的類型 (即指針本身的類型) 和指針所指向的類型是兩個概念。當小夥伴們對 C 越來越熟悉時,就會發現,把與指針攪和在一起的 "類型" 這個概念分成 "指針的類型" 和 "指針所指向的類型" 兩個概念,是精通指針的關鍵點之一。

筆者看了不少書,發現有些寫得差的書中,就把指針的這兩個概念攪在一起了,所以看起書來前後矛盾,越看越糊塗。

指針的值

即指針所指向的內存區或地址。

指針的值是指針本身存儲的數值,這個值將被編譯器當作一個地址,而不是一個一般的數值。

在 32 位程序裏,所有類型的指針的值都是一個 32 位整數,因爲 32 位程序裏內存地址全都是 32 位長。指針所指向的內存區就是從指針的值所代表的那個內存地址開始,長度爲 si zeof(指針所指向的類型) 的一片內存區。

以後,我們說一個指針的值是 XX,就相當於說該指針指向了以 XX 爲首地址的一片內存區域;我們說一個指針指向了某塊內存區域,就相當於說該指針的值是這塊內存區域的首地址。

指針所指向的內存區和指針所指向的類型是兩個完全不同的概念。在例一中,指針所指向的類型已經有了,但由於指針還未初始化,所以它所指向的內存區是不存在的,或者說是無意義的。

以後,每遇到一個指針,都應該問問:這個指針的類型是什麼?指針指的類型是什麼?該指針指向了哪裏?

指針本身所佔據的內存區

指針本身佔了多大的內存?只要用函數 sizeof(指針的類型) 測一下就知道了。在 32 位平臺裏,指針本身佔據 4 個字節的長度。指針本身佔據的內存這個概念在判斷一個指針表達式是否是左值時很有用。

0x02 函數

面向過程對象模塊的基本單位,以及對應各種組合,函數指針,指針函數

一個函數就是一個業務邏輯塊,是面向過程,單元模塊的最小單元,而且在函數的執行過程中,形參,實參如何交換數據,如何將數據傳遞出去,如何設計一個合理的函數,不單單是解決一個功能,還要看是不是能夠複用,避免重複造輪子。

函數指針和指針函數,表面是兩個字面意思的互換實際上含義截然不同,指針函數比較好理解,就是返回指針的一個函數,函數指針這個主要用在回調函數,很多人覺得函數都沒還搞明白,回調函數更暈菜了。其實可以通俗的理解指向函數的指針,本身是一個指針變量,只不過在初始化的時候指向了函數,這又回到了指針層面。沒搞明白指針再次深入的向前走特別難。

C 語言的開發者們爲後來的開發者做了一些省力氣的事情,他們編寫了大量代碼,將常見的基本功能都完成了,可以讓別人直接拿來使用。但是那麼多代碼,如何從中找到自己需要的呢?將所有代碼都拿來顯然是不太現實。

但是這些代碼,早已被早期的開發者們分門別類地放在了不同的文件中,並且每一段代碼都有唯一的名字。所以其實學習 C 語言並沒有那麼難,尤其是可以在動手鍛鍊做項目中進行。使用代碼時,只要在對應的名字後面加上 ( ) 就可以。這樣的一段代碼就是函數,函數能夠獨立地完成某個功能,一次編寫完成後可以多次使用。

很多初學者可能都會把 C 語言中的函數和數學中的函數概念搞混淆。其實真相併沒有那麼複雜,C 語言中的函數是有規律可循跡的,只要搞清楚了概念你會發現還挺有意思的。函數的英文名稱是 Function,對應翻譯過來的中文還有 “功能” 的意思。C 語言中的函數也跟功能有着密切的關係。

我們來看一小段 C 語言代碼:

#include<stdio.h>
int main()
{
puts("Hello World");
return 0;
}

把目光放在第 4 行代碼上,這行代碼會在顯示器上輸出 “Hello World”。前面我們已經講過,puts 後面要帶(),字符串也要放在() 中。

在 C 語言中,有的語句使用時不能帶括號,有的語句必須帶括號。帶括號的就是函數(Function)。

C 語言提供了很多功能,我們只需要一句簡單的代碼就能夠使用。但是這些功能的底層都比較複雜,通常是軟件和硬件的結合,還要要考慮很多細節和邊界,如果將這些功能都交給程序員去完成,那將極大增加程序員的學習成本,降低編程效率。

有了函數之後,C 語言的編程效率就好像有了神器一樣,開發者們只需要隨時調用就可以了,像進程函數、操作函數、時間日期函數等都可以幫助我們直接實現 C 語言本身的功能。

C 語言函數是可以重複使用的

函數的一個明顯特徵就是使用時必須帶括號 (),必要的話,括號中還可以包含待處理的數據。例如 puts("果果小師弟") 就使用了一段具有輸出功能的代碼,這段代碼的名字是 puts,"尚觀科技" 是要交給這段代碼處理的數據。使用函數在編程中有專業的稱呼,叫做函數調用(Function Call)。

如果函數需要處理多個數據,那麼它們之間使用逗號, 分隔,例如:

pow(10, 2);

該函數用來求 10 的 2 次方。

好了,看到這裏你有沒有覺得其實 C 語言函數還是比較有意思的,而且並沒有那麼複雜困難。以後再遇到菜鳥小白的時候,你一口一個 C 語言的函數,說不定就能當場引來無數膜拜的目光。

0x03 結構體、遞歸

很多在大學學習 C 語言的,很多課程都沒學完,結構體都沒學到,因爲從章節的安排來看好像,結構體學習放在教材的後半部分了,弄得很多學生覺得結構體不重要,如果只是應付學校的考試,或者就是爲了混個畢業證,的確學的意義不大。

如果想從事編程這個行業,對這個概念還不瞭解,基本上無法構造數據模型,沒有一個業務體是完全使用原生數據類型來完成的,很多高手在設計數據模型的時候,一般先把頭文件中的結構體數據整理出來。然後設計好功能函數的參數,以及名字,然後才真正開始寫 c 源碼。

如果從節省空間考慮結構體裏面的數據放的順序不一樣在內存中佔用的空間也不一樣,結構體與結構體之間賦值,結構體存在指針那麼賦值要特別注意,需要進行深度的賦值。

遞歸一般用於從頭到位統計或者羅列一些數據,在使用的時候很多初學者都覺得彆扭,怎麼還能自己調用自己?而且在使用的時候,一定設置好跳出的條件,不然無休止的進行下去,真就成無線死循環了。

對於結構體方面的知識,可以參見此前推送的文章《C 語言結構體(struct)最全的講解(萬字乾貨)》。具體也可以參見大佬的經驗:

相信大家對於結構體都不陌生。在此,分享出本人對 C 語言結構體的研究和學習的總結。如果你發現這個總結中有你以前所未掌握的,那本文也算是有點價值了。當然,水平有限,若發現不足之處懇請指出。代碼文件 test.c 我放在下面。在此,我會圍繞以下 2 個問題來分析和應用 C 語言結構體:

  1. C 語言中的結構體有何作用

  2. 結構體成員變量內存對齊有何講究 (重點)

對於一些概念的說明,我就不把 C 語言教材上的定義搬上來。我們坐下來慢慢聊吧。

  1. 結構體有何作用

三個月前,教研室裏一個學長在華爲南京研究院的面試中就遇到這個問題。當然,這只是面試中最基礎的問題。如果問你你怎麼回答?我的理解是這樣的,C 語言中結構體至少有以下三個作用:

(1) 有機地組織了對象的屬性。

比如,在 STM32 的 RTC 開發中,我們需要數據來表示日期和時間,這些數據通常是年、月、日、時、分、秒。如果我們不用結構體,那麼就需要定義 6 個變量來表示。這樣的話程序的數據結構是鬆散的,我們的數據結構最好是 “高內聚,低耦合” 的。所以,用一個結構體來表示更好,無論是從程序的可讀性還是可移植性還是可維護性皆是:

typedef struct //公曆日期和時間結構體
{
vu16 year;
vu8 month;
vu8 date;
vu8 hour;
vu8 min;
vu8 sec;
}_calendar_obj;
_calendar_obj calendar; //定義結構體變量

(2) 以修改結構體成員變量的方法代替了函數 (入口參數) 的重新定義。

如果說結構體有機地組織了對象的屬性表示結構體 “中看”,那麼以修改結構體成員變量的方法代替函數(入口參數) 的重新定義就表示了結構體“中用”。繼續以上面的結構體爲例子,我們來分析。假如現在我有如下函數來顯示日期和時間:

void DsipDateTime( _calendar_obj DateTimeVal)

那麼我們只要將一個_calendar_obj 這個結構體類型的變量作爲實參調用 DsipDateTime() 即可,DsipDateTime() 通過 DateTimeVal 的成變量來實現內容的顯示。如果不用結構體,我們很可能需要寫這樣的一個函數:

void DsipDateTime( vu16 year,vu8 month,vu8 date,vu8 hour,vu8 min,vu8 sec)

顯然這樣的形參很不可觀,數據結構管理起來也很繁瑣。如果某個函數的返回值得是一個表示日期和時間的數據,那就更復雜了。這只是一方面。

另一方面,如果用戶需要表示日期和時間的數據中還要包含星期 (周),這個時候,如果之前沒有用機構體,那麼應該在 DsipDateTime() 函數中在增加一個形參 vu8 week:

void DsipDateTime( vu16 year,vu8 month,vu8 date,vu8 week,vu8 hour,vu8 min,vu8 sec)

可見這種方法來傳遞參數非常繁瑣。所以以結構體作爲函數的入口參數的好處之一就是函數的聲明 void DsipDateTime(_calendar_obj DateTimeVal) 不需要改變,只需要增加結構體的成員變量,然後在函數的內部實現上對 calendar.week 作相應的處理即可。這樣,在程序的修改、維護方面作用顯著。

typedef struct //公曆日期和時間結構體
{
vu16 year;
vu8 month;
vu8 date;
vu8 week;
vu8 hour;
vu8 min;
vu8 sec;
}_calendar_obj;
_calendar_obj calendar; //定義結構體變量

(3) 結構體的內存對齊原則可以提高 CPU 對內存的訪問速度 (以空間換取時間)。

並且,結構體成員變量的地址可以根據基地址 (以偏移量 offset) 計算。我們先來看看下面的一段簡單的程序,對於此程序的分析會在第 2 部分結構體成員變量內存對齊中詳細說明。

#include<stdio.h>

int main()
{
    struct    //聲明結構體char_short_long
    {
        char  c;
        short s;
        long  l;
    }char_short_long;

    struct    //聲明結構體long_short_char
    {
        long  l;
        short s;
        char  c;
    }long_short_char;

    struct    //聲明結構體char_long_short
    {
        char  c;
        long  l;
        short s;
    }char_long_short;

printf(" \n");
printf(" Size of char   = %d bytes\n",sizeof(char));
printf(" Size of shrot  = %d bytes\n",sizeof(short));
printf(" Size of long   = %d bytes\n",sizeof(long));
printf(" \n");  //char_short_long
printf(" Size of char_short_long       = %d bytes\n",sizeof(char_short_long));
printf("     Addr of char_short_long.c = 0x%p (10進制:%d)\n",&char_short_long.c,&char_short_long.c);
printf("     Addr of char_short_long.s = 0x%p (10進制:%d)\n",&char_short_long.s,&char_short_long.s);
printf("     Addr of char_short_long.l = 0x%p (10進制:%d)\n",&char_short_long.l,&char_short_long.l);
printf(" \n");

printf(" \n");  //long_short_char
printf(" Size of long_short_char       = %d bytes\n",sizeof(long_short_char));
printf("     Addr of long_short_char.l = 0x%p (10進制:%d)\n",&long_short_char.l,&long_short_char.l);
printf("     Addr of long_short_char.s = 0x%p (10進制:%d)\n",&long_short_char.s,&long_short_char.s);
printf("     Addr of long_short_char.c = 0x%p (10進制:%d)\n",&long_short_char.c,&long_short_char.c);
printf(" \n");

printf(" \n");  //char_long_short
printf(" Size of char_long_short       = %d bytes\n",sizeof(char_long_short));
printf("     Addr of char_long_short.c = 0x%p (10進制:%d)\n",&char_long_short.c,&char_long_short.c);
printf("     Addr of char_long_short.l = 0x%p (10進制:%d)\n",&char_long_short.l,&char_long_short.l);
printf("     Addr of char_long_short.s = 0x%p (10進制:%d)\n",&char_long_short.s,&char_long_short.s);
printf(" \n");
return 0;
}

程序的運行結果如下 (注意:括號內的數據是成員變量的地址的十進制形式):

  1. 結構體成員變量內存對齊

首先,我們來分析一下上面程序的運行結果。前三行說明在我的程序中,char 型佔 1 個字節,short 型佔 2 個字節,long 型佔 4 個字節。char_short_long、long_short_char 和 char_long_short 是三個結構體成員相同但是成員變量的排列順序不同。並且從程序的運行結果來看,

Size of char_short_long = 8 bytes
Size of long_short_char = 8 bytes
Size of char_long_short = 12 bytes //比前兩種情況大4 byte !

並且,還要注意到,1 byte (char)+ 2 byte (short)+ 4 byte (long) = 7 byte,而不是 8 byte。

所以,結構體成員變量的放置順序影響着結構體所佔的內存空間的大小。一個結構體變量所佔內存的大小不一定等於其成員變量所佔空間之和。如果一個用戶程序或者操作系統 (比如 uC/OS-II) 中存在大量結構體變量時,這種內存佔用必須要進行優化,也就是說,結構體內部成員變量的排列次序是有講究的。

結構體成員變量到底是如何存放的呢?

在這裏,我就不賣關子了,直接給出如下結論,在沒有 #pragma pack 宏的情況下:

這裏,我們結合上面的程序來分析 (暫時不討論原則 3)。

先看看 char_short_long 和 long_short_char 這兩個結構體,從它們的成員變量的地址可以看出來,這兩個結構體符合原則 1 和原則 2。注意,在 char_short_long 的成員變量的地址中,char_short_long.s 的地址是 1244994,也就是說,1244993 是 “空的”,只是被“佔位” 了!

8UVceC

可見,其內存分佈圖如下,共 12 bytes:

首先,1244972 能被 1 整除,所以 char_long_short.c 放在 1244972 處沒有問題 (其實,就 char 型成員變量自身來說,其放在任何地址單元處都沒有問題),根據原則 1,在之後的 1244973~1244975 中都沒有能被 4(因爲 sizeof(long)=4bytes) 整除的,1244976 能被 4 整除,所以 char_long_short.l 應該放在 1244976 處,那麼同理,最後一個. s(sizeof(short)=2 bytes)是應該放在 1244980 處。

是不是這樣就結束了?不是,還有原則 2。根據原則 2 的要求,char_long_short 這個結構體所佔的空間大小應該是其佔內存空間最大的成員變量的大小的整數倍。如果我們到此就結束了,那麼 char_long_short 所佔的內存空間是 1244972~1244981 共計 10bytes,不符合原則 2,所以,必須在最後補齊 2 個 bytes(1244982~1244983)。

至此,一個結構體的內存佈局完成了。

下面我們按照上述原則,來驗證這樣的分析是不是正確。按上面的分析,地址單元 1244973、1244974、1244975 以及 1244982、1244983 都是空的 (至少 char_long_short 未用到,只是“佔位” 了)。如果我們的分析是正確的,那麼,定義這樣一個結構體,其所佔內存也應該是 12 bytes:

struct //聲明結構體char_long_short_new
{
char c;
char add1; //補齊空間
char add2; //補齊空間
char add3; //補齊空間
long l;
short s;
char add4; //補齊空間
char add5; //補齊空間
}char_long_short_new;

可見,我們的分析是正確的。至於原則 3,大家可以自己編程驗證,這裏就不再討論了。

所以,無論你是在 VC6.0 還是 Keil C51,還是 Keil MDK 中,當你需要定義一個結構體時,只要你稍微留心結構體成員變量內存對齊這一現象,就可以在很大程度上節約 MCU 的 RAM。這一點不僅僅應用於實際編程,在很多大型公司,比如 IBM、微軟、百度、華爲的筆試和麪試中,也是常見的。

這三大塊硬骨頭是學習 C 語言的絆腳石,下功夫拿掉基本上 C 語言的大動脈就打通了,那麼再去學習別的內容就相對比較簡單了。編程學習過程中越是痛苦的時候,學到的東西就會越多,克服過去就會自己的技能,放棄了前面的付出的時間都將清零。越是難學的語言在入門之後,在入門之後越覺得過癮,而且還容易上癮。你上癮了沒?還是放棄了?

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3l5fLYmbynOjTu_Nc5yaCw