c/c++指標從淺入深介紹——基於資料記憶體分配的理解(上)

眼前有座山發表於2023-03-13

c/c++指標從淺入深介紹——基於資料記憶體分配的理解(上)

  本文是對自我學習的一個總結以及回顧,文章內容主要是針對程式碼中的資料在記憶體中的儲存情況以及儲存中數值的變化來對指標進行介紹,是對指標以及資料在記憶體中數值是如何變化的,為什麼需要使用到指標,為什麼有時候使用指標很容易會報錯,怎麼去使用指標才能讓錯誤儘可能的減少等知識做一個初步的介紹以及分析。文章中的每個知識點都會有相應的案例程式碼及其程式碼資料所佔記憶體的變化情況分析,然而言語的抽象表達能力是比較晦澀的,所以最好是結合文中影像示例來對其進行理解。

1.資料的儲存

  前提說明:本節是瞭解作業系統如何儲存c/c++語言中的資料,只要知曉這部分即可,其他不懂的地方在後文有詳細介紹。

  首先我們需要了解資料在機器中是如何儲存的,在計算機中,所有的命令和資料都是被分配了唯一的地址,每種不同型別的資料(以及命令)所佔的儲存空間不同,但是所有儲存都以位元組為單位;而每種型別在計算機中所佔的位元組數因作業系統不同而有所不同,為了便於理解,我們統一預設常用的幾個型別資料所佔的位元組數:int所佔儲存空間為4個位元組,chat為1個位元組,指標為4個位元組。

  由此我們可以得到下圖所示:

  首先最左邊是簡單的三句程式碼,a是int型別,其值為1,因為a為int型別,所以a所佔的地址是2020-2023(但是為了方便說明,我們一般把a的地址當成是2020,預設其是含有2020-2023四個位元組!);b是char型別,其值是’a’,所佔地址是2029(變數的地址是作業系統隨機分配的,並不固定)上圖是變數a和變數b在記憶體地址中所佔位元組及其數值的表述,1)是使用十進位制來表達,2)是使用二進位制來表達(字元的二進位制使用ASCII碼進行對應)。

  對於其他型別資料的儲存,與上述int和chat的儲存一致,但是其所佔位元組的大小及其儲存的內容不盡相同。

  這裡多提一點,比如說a所佔的地址是四個位元組,那麼四個位元組中的二進位制合起來才會是a,但是當用指標取出a所佔位元組內部的任一一個位元組,並且列印出來都會有其對應的數值;比如說用字元指標獲得地址2022這一個位元組,再用十進位制列印其值,就會是0;如果用指標獲得地址2020用十進位制列印其值,就會是1(後面會有比較具體的介紹)

  然後我們需要對編輯器儲存資料有一個大概的認識,在我們編寫的程式碼中,資料會被儲存在以下四個區域:

 

  分別為Head,Stack,Static/Global,和Code區域。

  其中Head是我們手動申請儲存空間時編輯器給我們分配的儲存空間,c語言可以使用malloc,remalloc,calloc來申請空間,c++則使用new來分配空間;該區域除非手動銷燬空間,不然會一直儲存到程式結束。

 

  如圖所示,左邊分別為c程式碼和c++程式碼在Heap中申請空間,正如第一小節所描述的一般,int型別資料在記憶體中所佔的是四個位元組,所以這裡的整數10是佔了四個位元組的儲存空間,但是這裡僅用2020(首位元組)來表示其所佔的地址。

  Stack空間是作業系統按照一定的順序自動分配和釋放空間,即給函式分配了空間,函式一旦結束,函式所佔用的空間都會被釋放,比如我們有如下一段程式碼:

 

  在main函式執行時,作業系統會開闢相應的空間來儲存main函式內部的變數,此時變數a在stack空間內分配4個位元組來儲存其值。

  當main函式執行到執行Add函式時,會在暫停main函式,再在main函式地址的上面分配空間給Add,同樣的也是給Add函式中的變數分配空間

  當Add函式執行完畢後,會自動銷燬給其分配的空間(如下圖所示)然後繼續執行main函式,直到main函式結束,退出程式。這裡需要注意,傳入Add函式的不是a,而是Add函式中的變數b複製了a的數值;因為我們可以在圖中看到,a是在main函式中分配的地址,所以不可能把a直接放入Add函式了(其他資料型別也一樣,在什麼函式里面被定義,就在什麼函式里面分配空間)

  這裡還需要強調一下,從main函式傳入Add函式的變數a不是其本身,而是a變數的副本,相當於重新在Add函式中建立了一個該型別的變數b,並且把a的數值賦予給b;而當Add函式結束時,b就會被銷燬(所以當我們想呼叫函式但是不想傳入變數進函式時,我們一定要傳入該變數的地址!想修改整型變數的值就傳入該變數的地址,想改變指標所指向的物件就要傳入該指標的地址!這個點很重要,尤其是對後面各種不同指標在函式間的傳遞)

  如果是執行多個函式語句,作業系統會按照先後順序一個個的往stack空間裡面“堆”起來;而只有最上面一個執行完後,才會執行下一個,並且執行完的函式所佔有的空間會被釋放;比如下面是一個遞迴函式,遞迴函式會一次次在stack上面“堆”該函式,直到遇到中止條件,或者是stack洩露從而程式報錯,這裡因為沒有中止條件,所有print函式會不斷遞迴,列印1的語句不會被執行,而且因為Stack記憶體溢位了,程式會報錯停止(遞迴函式很危險!一定要有返回的語句)

  Static/Global是儲存帶有關鍵字static和global變數的空間,也就是說單獨定義的全域性變數和靜態變數都會在函式中單獨拿出來,獨立於其他變數儲存;其他所有的函式都能訪問他們,並且也只會在程式結束時被釋放(全域性變數可以簡單的認為是定義在所有函式以外的變數,Static變數,即靜態變數可以理解為在任何地方定義的變數的前面加上static即是靜態變數)。

  以上所有的變數定義都會針對其型別分配儲存空間,所以所有的變數都會有一個地址,這裡沒有一一把上面所有變數的地址標明出來,僅為強調Static/Global的作用。

  Code區域則是存放相應程式語句的空間,即我們所執行的命令都會儲存在此。

  對於以上四個儲存空間我們需要了解他們是儲存什麼的,什麼時候會被使用,什麼時候會被銷燬,這樣對我們理解程式碼的執行有很大的幫助。後面對指標的分析也會一一使用到上述的儲存空間來進行分析講解,在這裡我們需要對它們有一種熟悉感覺,知道他們分別是用來儲存什麼的即可。

  而且十分重要的一點,我們要想真的理解我們所編寫的程式,那麼我們應該對所寫的每一句程式碼都需要明白它是儲存在哪,有什麼用;定義宣告的變數儲存在哪,什麼情況下可以改變它,什麼情況下無法改變它,諸如此類。(開始可能有點麻煩和瑣碎,但是習慣以後會使得我們對程式的理解有很大的幫助)

  對以上我們使用一段完整且簡單的程式碼來進行一個簡單的總結:

  對以上一段c++的程式碼,我們可以看到我們在定義了全域性變數b且初始值為0,則編輯器會給它在global區域中分配空間儲存,可以讓所有變數或者函式題進行訪問;然後我們定義了Add函式以及main函式。程式剛開始執行時,會在stack局域分配給main函式內變數的空間,程式是從上述程式碼第10行開始執行,會在stack上給main函式分配儲存空間,用來儲存其定義的變數a;當執行到第13行時,會暫停main函式跳轉到Add函式,stack給Add函式分配空間,儲存其定義的變數,如下圖所示。

  當執行完第7行時,如下圖所示:

  Add函式中a的數值變成了11,全域性變數b的數值變成了1,雖然b沒有在Add函式所佔有的儲存中,但是global變數是允許其訪問並且修改其內部變數的值!這裡注意,main函式里面的a沒有發生變化,因為main函式是暫停了的,我們還在Add函式內執行。

  當Add函式執行結束,分配給Add函式的空間被釋放,如下圖所示:

 

  因為Add函式執行結束,所有我們到了第14行;此時main內部a的數值還是10,沒有發生任何變化,但是全域性變數b現在的值為1。之後使用cout列印變數a和變數b;分別會得到:a=10  b=1

  所以要想讓Add函式改變main函式中的變數,就必須在Add函式結束前將其值返回給main函式中的變數;或者是使用指標,把要改變的變數的地址傳入函式,即便Add函式不返回其值,也可以對main函式中的變數進行改變。而使用指標將變數的地址傳入其他函式,與變數傳入其他函式一樣,具體的分析我們在介紹完指標後進行介紹。

  下面我們對指標進行介紹。

 

2.指標介紹

  1)指標

    指標是c/c++中的一種資料型別,它的主要目的是為了直接呼叫地址並且對其進行操作。就像之前所說的一樣,在函式內部定義的變數,會在stack中分配儲存空間;全域性變數,會在global區域分配空間,如果是自己手動申請的就會在heap分配儲存空間。只要是分配了儲存空間,那麼在程式結束前,變數都有一個唯一的地址,而指標就是去獲得變數地址的資料型別,至於為什麼要用指標,指標具體是什麼,本文簡單的舉一個例子。

    比如你在自己的電腦上編寫了一個部落格,部落格的頁面、內容等都包裝好放在了電腦裡面;此時如果你不上傳到網上,那麼別人要看你部落格時都需要去你的電腦上檢視,或者是你把別人需要看的內容複製一份發給別人;顯然這樣十分浪費時間和儲存空間,要是檢視一個部落格需要把內容全部下載下來,電腦需要十分龐大的儲存空間。

    所以此時你把部落格內容上傳到了網上,變成了一個網頁,那麼以後只要有人需要檢視你的部落格,你就可以把你的部落格網址發給別人,別人就只需要透過部落格網址就能直接訪問,而不需要複製一份。

    在上面例子中,部落格的內容就是我們在程式內定義的變數,而部落格地址就是指標;只要指標指向了一個變數,那麼我們只需要透過指標就能訪問,而不是從新複製一份,(也許對一個資料量小的程式來說,並不能起到很大的方便,但是對資料量大的專案來說,則能大大的節省儲存空間,並且使得程式碼變得更加簡便)

    可能看上去有一些繞,但是我們先記住幾點:指標儲存的是變數的地址,並且指標也是一種資料型別,當然指標自身也有地址(只要是在程式碼中定義的變數一定都有一個地址來儲存)。

    我們用程式碼和記憶體分配來看一個具體的例子。

    如下圖所示,我們定義一個指標,並且讓他指向相同型別的變數:

 

    我們先定義了整型資料a=10,那麼在stack空間上main函式內會分配a的儲存空間,並且我們這裡假設其地址為2020(地址是自動隨機分配的),那麼此時我們定義一個指標p(如上述程式碼第6行所示:int *p),並且讓其等於a的地址(如上述程式碼第7行所示:p=&a)。這裡我們可以得到如下關係(如圖片右下角所示)

      p=&a=2020

      *p=a=10

    這裡指標為p,其型別是int型別,所以p可以儲存int型別變數的地址;且p儲存的是地址,*p是p儲存的地址的數值。也就是說指標p儲存了一個地址,我們使用*p來去得到該地址裡面的數值。

    此時p是int指標型別,所以你可以隨便讓*p等於任何一個int型別的資料,但是要記住的是,*p改變,a的數值也發生改變。

    因為p是儲存a的地址,a的數值儲存在其地址裡面,*p是去得到p儲存的地址裡面所儲存的值,也就是和a繫結在了一起,所以改變*p相當於改變了a。

    如果實在有點繞,那麼記住,只要p一直是儲存的a的地址,把*p看成a就行,*p就是a,改變*p就是改變a,改變a就是改變*p。

    對上述舉一個小例子:

    a原本是賦值為10,但是*p=11,表示p儲存的地址2020裡面的值變成11;而a的地址是2020,所以a從原本的10變成了11。(也就是*p和a繫結在了一起)而後續程式碼中a被重新賦值為12,那麼*p此時也是數值12.

    注意,我們並沒有對p也就是2020進行改變,*p的改變是會去改變的是p儲存的地址也就是2020所儲存的數值。

    上述程式碼中,&是引用變數地址,也就是得到儲存該變數的地址;*是解引用,也就是得到指標所儲存地址中的數值,比如這裡p儲存了a的地址,所以*p就是找到p儲存的地址,然後得到該地址裡面的數值。(所有的變數一定是要儲存在相應的地址中,並且每個地址是唯一的)

    以上所有的話都說為了說明對與一個指標p:

        p是儲存的地址;

        *p是p儲存的地址裡面儲存的數值!

  2)指標運算

    對於指標p我們可以做出下面簡單的運算:p+1

    這條語句並非是完整的表述,因為p的型別是int*(或者指標p的型別是int,為了便於理解下面都用這樣的表述),所以完整的表述應該是:p+1*(sizeof(int)),如果指標p的型別是char,那麼p+1的完整形式是p+1*(sizeof(char));但是為了簡便,我們直接都寫成p+1,然後由編輯器自動的去識別p來加上相應的數值。

    sizeof(p)表示得到變數p所佔的位元組數,sizeof(int)表示得到一個int型別所佔的位元組數。

    p所儲存的地址是2020,其型別指標型別是int,所以當p=p+1時,p所指向的地址為2024;如果我們沒有在地址2024裡面儲存有數值,那麼這時去得到該地址所儲存的數值會得到一些垃圾資料,這是作業系統隨意給地址分配的數值。

    下面我們用第一小節的例子來詳細講述一下指標p的變化:

 

    上圖中p儲存的地址是2020,那麼當我們進行加1時,即p+1的地址則為2024;如下圖所示:

 

    在1)中時a的十進位制表達,在2)中是a的二進位制表達;int型別a所佔位元組為4個位元組,指標指向的是a的首位元組,p=p+1(即p+1*sizeof(int))的地址時從2024開始到2027,(因為p的指標型別是int,所以佔四個位元組長度)

    要注意,我們並沒有給2024地址賦予任何值,所以如果列印*(p+1)則會得到一些垃圾數值!

    此時,我們如果對p做一個型別轉換,比如說我們把p賦值給一個char*的指標q,那麼q等於多少?q+1等於多少?*q等於多少?*(q+1)又等於多少?

    我們可以對照下面的圖進行思考:

 

    q也是指標,因為上文說過,指標指向的是一個變數的首地址,所以用指標q去獲得p,那麼就是p把其指向的地址賦予q,即q=2020;那麼q+1相當於q+sizeof(char),所以q+1的數值是2021(如果q的型別也為int型別,那麼q+1的地址是2024,其他型別同理)

    注意了,這裡p和q的數值表示為地址,也就是他們儲存著的地址;*p和*q就是我們去得到該地址儲存的數值;那麼*(p+1)和*(q+1)也就是得到地址2024和2021裡面儲存的數值,而在最開始我們說過,位元組儲存的是二進位制資料,int型別是該值轉化為二進位制存然後分別儲存進四個位元組中,所以2020到2024四個位元組合在一起是10的二進位制。

    故*(q+1)就是去獲得q+1儲存的地址也就是2021裡面的數值,並且用十進位制的形式列印出來時其值為0;同理*(q+2)就是獲得2022地址裡面的數值;值得注意的是,*(q+4)也就是2024地址內我們沒有分配數值,所以裡面是垃圾值!

    練習:如果a的數值不是10,而是100或者1000,*q和*(q+1)分別又是多少呢?

    對於以上我們需要記住:不管指標指向的變數型別是什麼,該變數佔了多少位元組,指標一直是指向其首地址,而其移動則是該型別所佔位元組的倍數!

    現在我們知道了指標,我們可以直接把指向一個變數的指標傳遞給一個函式內部,讓其對指標所指向的地址裡面的數值進行修改,這樣就不需要把變數賦值到函式里面,從而節約記憶體空間,例子如下所示:

    程式執行結束時a的數值應該是多少?

    讓我們繼續想起那四個儲存區域,分析一下程式變數分別儲存在什麼地方,如下圖所示:

    當我們從main函式開始執行到13行程式碼是,在stack中,分別給a和p分配的相應的空間來儲存空間(我們假設a的地址時2020),值得強調的一點是,不管指標是什麼型別,其所佔位元組是固定的!比如這裡假設其所佔四個位元組,那麼不管指標儲存char*型別還是int*型別還是其他什麼型別,其所佔空間都是四個位元組。

    當執行到Add函式時,stack會給指標q分配儲存空間,如上文所說,函式外面來的變數都是複製一份後,在再函式記憶體操作;這裡就是q複製了p的數值,也就是q的值為地址2020;然後我們對q儲存的地址2020裡面的值重新賦值12,也就是a重新賦值為了12;注意的是,Add函式是不知道a這個變數的存在(因為a所佔有的空間在main函式內),它只是透過a的地址來修改了a的數值。

    最後當Add函式執行結束時,其分配的空間被銷燬,也就是指標q被銷燬;此時a的數值還是12,因為a的儲存空間在main函式中,其他函式沒有對a的地址進行操作的話就不會改變a的變數。

    當main函式結束後,記憶體被釋放銷燬。

    以上便是指標的一個十分重要的作用,透過操作一個變數的地址來修改其值,這樣就不需要去複製該變數,從而極大的節省空間。(想象一下,如果一個資料所佔位元組上萬,那麼複製一份該資料的儲存成本太大了,但是使用指標就僅需要四個位元組即可)

    對於上述例子,說明一點:上述例子是一個很簡單的指標使用,但是即便是複雜的,比如二級指標,三級指標之類,也可以去分析其儲存情況來方便我們看懂程式碼;就算p是二級指標或者指標數值之類的,其傳入函式也後是被q所複製一份,也是該在什麼地方分配空間就在什麼地方分配空間,但是如果傳入的是地址,就算是其他該型別的指標得到了了地址的數值,只要透過指標對該地址進行了修改,其原本的變數的值也會修改(變數的數值就是儲存在它的地址中);之後文會對複雜的指標傳參進行一個介紹和說明。

 

  最後我們對上述的內容做一個小總結:

    1.我們在編輯器裡面寫的所有東西都是被編輯器分配記憶體儲存的,可以大致的分為四個區域:Head,Stack,Static/Global,和Code。所以在編寫程式碼時,我們尤為要注意,我們所定義的變數記憶體是分配在哪個地方,這樣方便我們去理解程式裡面變數的各種變化!

    2.我們在函式內部定義的變數,只要不手動分配空間其都是在stack中分配儲存空間進行儲存,並且每次函式執行結束後,其記憶體都會被釋放,所以有函式外的變數和其函式內部有數值互動時,一定要注意變數數值的變化。

    3.如果把我們定義的變數比作是本地的部落格檔案,那麼指標就是可以訪問我們本地部落格內容的網路連結;指標儲存的是變數的地址,我們可以透過指標獲得該地址裡面的數值並進行修改。

    4.對一個所定義的指標int *p=&a;其p是a的地址,*p是a的數值;我們對*p操作,就相當於對a的數值進行該變;如果對p進行操作,就會讓p會獲得其他地址,此時*p就不在是a了;

    5.指標也是一個資料型別,其所佔據的記憶體是一定的,因作業系統而不同;比如當指標所佔位元組是4個時,那麼不管它指向的型別佔有多少位元組,指標本身只佔有四個位元組,並且其儲存的地址是該型別所佔地址的首地址。

    6.一個程式的執行,本質上就是不斷的從儲存的位元組中獲得相應的二進位制數值,然後一直到最後一個結束字元(命令也是變成二進位制資料儲存在位元組中)。也就是說把程式所有程式碼變成二進位制儲存在記憶體中,然後找到最開始的main函式的儲存位元組,然後得到裡面的資料,如果該資料是給變數賦值那麼就是讓main函式里面相應的位元組儲存該值的二進位制,然後繼續得到下一個位元組的數值,一直這樣執行到結束字元。所以不懂程式碼是什麼意思時,尤其是程式碼執行邏輯上沒錯但是有各種問題時,可以想想它的儲存空間在哪以及位元組儲存的數值是如何變化的。

    以上是對指標的一個簡單介紹,主要是為後文的知識建立一個基礎。在之後的文章中,會分別對指標與陣列,指標與函式等等更加豐富的指標操作進行介紹與分析。但是不管指標如何在程式碼中被複雜的使用,我們都要知道指標在程式碼中指向(儲存)的是什麼型別資料的地址,這樣才能更好的利用指標來編寫程式。

 

相關文章