本文的主要說明對象是CPU和內存。為什么學C語言之前必懂呢,因為C語言是非常貼近底層原理的語言,明白了CPU和內存的原理,對學C語言有很大幫助。
其實我個人是比較主張計算機專業本科應該先學計算機組成原理然后再學C語言的,不過好像沒有這么干的,而且學C語言之前並不需要學完整個計算機組成原理才能學C,對於想快速入門的來說,理解了CPU、內存和一些相應的概念就足夠了。這就是本文存在的目的。運行一個程序,少了CPU或者內存都不行,甚至少了外存也不行,不過外存就不在我們的討論范圍內了。
1、CPU
CPU是計算機最核心的部件了,比如我們要做一次加法,那這次加法就是CPU來做的。
CPU中我們主要能感知到的就是寄存器了。以32位MIPS的CPU為例,有r0到r31,共32個普通寄存器(還有一些不普通的,后面會提到),每個寄存器可以放一個32位二進制數。(我們知道,在計算機里什么都是二進制的。32位的含義是每個普通寄存器都是32位的,至於正好有32個普通寄存器,那是個巧合而已,32位ARM只有16個寄存器。)
那CPU能干什么事呢?舉個例子吧,例如把r12里的數字和1做加法,結果存入r17。例如把r13和r7做減法,減法結果又存入r7。沒錯,之前r7里的數就沒有了。
這里面的加法和減法都是典型的算術運算,除了加減法還有別的,像邏輯運算等等。乘法和除法比較復雜,這里就不討論了。
以及發現沒,這些寄存器里的數都是整數。如果是小數怎么辦呢?計算機有個浮點數的概念,小數是浮點數的一種。浮點數有其它的寄存器和運算部件,這里也不討論了。但是要指出,整數加法和浮點數加法是用兩種不同的方式來做的,雖然在編程語言里可能都是用一個加號。
這還不是CPU的全部操作,后面講了內存,還會講到跟內存有關的操作。
2、內存
先想想儲物櫃吧,有很多很多個小櫃子,每個櫃子能放若干行李。為了區分不同的小櫃子,每個櫃子都有一個編號。
內存差不多就是個這樣的東西,里面有很多內存單元,每個單元可以存一個8位二進制數,也就是十進制的0到255。發現沒,前面講的一個32位CPU寄存器是32位的,是內存單元位數的4倍。每個內存單元也有一個編號,一般稱為地址。地址是C語言、匯編語言等底層語言里面一個非常重要的概念。地址也是二進制的,后面還會再討論。
內存的基本操作只有讀和寫兩種(有些時候還有別的,但簡單起見咱們只討論讀和寫,因為有這兩個足夠了),就像儲物櫃可以存物可以取物一樣。讀就是從內存單元讀進CPU寄存器,寫就是從CPU寄存器寫進內存單元。嗯,寄存器跟內存單元一個32位一個8位,大小不一樣,這咋辦呢?那就一次讀寫4個內存單元。當然也可以把高8位存進1個單元,或者把1個單元讀進來再擴展為32位,等等,也可以。
讀或者寫的時候,咱得告訴人家,要讀寫哪一個單元,這時就要通過內存單元的地址。例如某塊內存有40億個內存單元(別覺得很大,現在主流的內存大小可能都不只這么大了),它們的地址就是從0到40億-1(注意是從0開始,在計算機或C語言里很多東西都是從0開始的)。地址只是一個數字,不要想得太復雜。
順便說一下,一個內存單元的大小已經成了一個基本單位,因為存放數據的時候一般都需要占用整數個內存單元。於是,一個內存單元的數據大小稱為一個字節,或說1B。描述內存大小,就是有多少個內存單元,就是多少字節。描述一個文件的大小,也用字節作為單位。
數據大小的單位除了B以外,還有KB、MB、GB等,1KB=1000或1024B,1MB=1000或1024KB,1GB=1000或1024MB。至於是1000還是1024,我只能說,兩種都有,我也很無奈……
剛才說了內存里的數可以讀進CPU寄存器,寄存器里的數也可以寫進內存。下面就要來說這種典型操作。
讀(內存到寄存器)的時候,需要“匯報”兩點:讀哪個單元(即內存地址),讀進哪個寄存器。當然,如果要讀4個單元的話,這4個單元是連續的,所以只需要1個地址而不是4個。一般來說,地址必須通過寄存器提供。例如,以r10里面的數作為地址,把這個地址對應的4個單元讀進r18。不過實際用的要多一個步驟,是r10里的數再加上一個自己指定的常數(可正可負)作為地址,而不是r0直接作為地址。當然,想讓r0直接作為地址的話給個常數0就行了,不過這種先加上一個常數再作為地址的操作確實很常見。
寫(寄存器到內存)操作是類似的,例如把r20里的數加上20作為地址,把r10里的數存進這個地址的4個單元。
寄存器跟內存相比,除了數量少,還有一個局限性,就是寄存器的編號不能像內存的地址那樣放寄存器里,只能用常數。你想把寄存器編號放在r6里,把這個編號的寄存器里的數讀出來存進r7,對不起,沒有這種操作。於是遇到需要這種特點的操作,就必須用內存,哪怕實際占用的空間很小。這也是內存和寄存器的另一個區別。
跟寄存器相比,內存讀寫操作是非常慢的(不過跟外存磁盤等相比內存就真的太快了),當然現在有一些辦法能一定程度上解決這個問題,但是嘛,只要能用寄存器就盡量用寄存器就是了。
但是就這么點兒寄存器,根本不夠啊,內存必須得有的。而且CPU要工作必須有內存(后面會說原因)。
32位寄存器能表示的地址只有2^32個(事實上加上那個常數雖然有可能超過2^32但並沒有把這個作為增加地址個數的手段),那意味着什么呢,如果內存單元超過2^32個,多出來的就跟沒有一樣了。這也是為什么很多32位CPU最多只支持4GB內存的原因。
3、指令
前面一直說,我們要告訴CPU做什么操作,什么加法,什么讀寫內存,等等,那么怎么“告訴”呢?通過指令。前面說的,把r12和常數1相加,加法結果存入r17,這就可以寫成一條指令(對於32位MIPS)。把r14里的數加上12作為地址,把這個內存地址里的數讀進r6,這也是一條指令。
對於32位MIPS,所有的指令都是用32位二進制數表示的。沒錯,又是32位二進制數,不過這個應該理論上可以不是32位。這32位里,有些位表示了指令的類型(做加法還是減法,還是讀寫內存什么的),有些位表示了寄存器編號等。反正只要知道指令能用二進制數表示就可以了。相比之下,對於x86的CPU,每條指令長度並不固定,有的指令8位,有的很長。
好了,指令寫完了,怎么發給CPU?其實這個“發”字用的並不准確。指令是按順序放在內存里的,對的,又是內存。是CPU主動上內存里找指令,而不是誰把指令“發”給CPU。這就是CPU要工作必須要有內存的原因。CPU需要把指令從內存中讀進來,然后分析這條指令是干什么,是運算還是內存讀寫,還是什么別的。
要說具體的過程呢,就要說一個特殊寄存器PC(在MIPS中叫PC,在x86中叫IP,可能還有別的名字,但只要是CPU都會有一個做這種事的寄存器)。對的,PC跟前面說的32個普通寄存器不一樣了,它沒有編號,不能寫進指令里。它的作用是給出取指令的地址。CPU運行時,一直在循環做這么幾件事:先把PC里的值作為地址,上內存里把這個地址的數(也就是指令)取出來,然后把PC改成下一條指令的地址(對於32位MIPS一般是把PC的值加上4,因為每條指令32位4字節),接下來就是分析指令和執行了。然后再重復這個過程。
這里要插一句,C語言寫的程序,要先轉化成這樣一條一條的指令之后,才能運行,當然這個轉化的過程不需要我們自己來做,有軟件來做這個事(其實就是編譯器,可能還加上鏈接器、匯編器等)。C語言里很多操作都能直接對應到這些指令的操作,因此了解這些指令對學習C語言很有幫助。
4、跳轉
程序里除了順序執行以外,還有跳轉。例如判斷r10是否大於r11,如果是,就跳轉到程序另一個地方開始執行。這事能不能做到呢?可以,通過改PC的值就能做到。當然,剛才說了在MIPS里PC沒有編號,不能直接寫進指令,所以直接改肯定不行。不過MIPS有其它的指令,可以間接改PC。無條件跳轉比較簡單,可以是把PC加上某個常數(可正可負),可以是把PC直接改成另一個寄存器的值,等等。條件跳轉則是先判斷一個條件是否成立,如果成立就改PC。條件有哪些呢?一般是判斷一個數是否大於0,是否大於另一個數,是否等於0,等等,其實在指令層面這些都很容易實現,在這里不展開說了。總之跳轉的方法就是改PC。
5、過程調用
有一種特殊的跳轉指令要重點說,這種指令能先保存當前PC(指向跳轉指令之后的那一條指令)然后再改PC。這種跳轉我們叫它過程調用,或者就叫調用。C語言里有個概念叫函數調用,就是用這種指令實現的。對於MIPS,執行調用指令后,舊的PC會被保存到一個普通寄存器里(就是r31)。對於x86,舊的IP則保存到了棧里(后面會說什么是棧)。跳轉之后,執行一段代碼,執行完了還可以跳回來(因為保存了舊的PC)。如果有好幾個地方都需要跳到這段代碼執行然后再回到原來的地方,這種指令就起作用了。
方便起見,假設在主程序中,執行了一次調用指令,跳轉到了func位置。如果程序正確的話,顯然,func開始的程序,到后面一定會遇到一條類似jump r31這樣的指令,就是無條件跳轉到r31地址,這樣,就回到了主程序,而且是過程調用指令之后的那條指令。
func理論上干什么都行,簡單到做一次加法,復雜到幾百幾千行,都可以,我們用加法來舉個例子好了。例如func把r4和r5里的值相加,存入r2,然后就跳回去。那么主程序需要把r4和r5設置好,然后調用func,調用之后,要的結果自然就在r2里了。
6、過程調用中的數據保存問題
這里面有個問題:調用完了以后,除了r2是結果,其它的寄存器會怎么樣?會變嗎?有可能。func里可是什么都能干,事實上主程序和func也可能壓根就不是一個人寫的,互相不知道對方會怎么干,要是把某個重要寄存器給改了,可不行。典型的就是r31,只要一調用,r31立馬變。所以呢,主程序在調用func之前,如果r2或者r31里存着重要的數據,要先把它們保存到別的地方(別的寄存器或內存里)再調用。除了r31呢?其它的呢?
(這個說起來有點復雜啦,跟本文主要內容關系也沒那么大。這個問題需要約定,有兩種約定思路:一種是跟r2和r31一樣,主程序要保證寄存器里的重要數據都保存了以后再調用func。另一種則是func保證不改變寄存器里的數據——就是如果需要臨時修改某個寄存器,就先保存,跳轉回去之前要從內存里恢復。其實主要區別就是由主程序來保存還是由func來保存。實際采用的約定是,有些寄存器(如r2和r31等)是第一種約定,還有些是第二種約定。對於前者的寄存器自然就是,func不需要保證它們不變,所以主程序調用func之前必須保存那里面的重要數據。對於后者的寄存器,則主程序無需擔心里面的數據丟失,而func要保證不能破壞它們。要強調的一點是,前者和后者的寄存器沒有本質區別,只是人為的約定。)
另外,如果某一段程序中,變量太多了,寄存器不夠用,需要用內存來存變量,這時候遇到的本質問題就類似於寄存器保存的問題,都是要在內存中開辟一片空間。
下面是重點:在內存中保存數據時,應該保存到什么位置?
首先,最基本的要求就是,新保存的位置不能已經存在了重要的數據,否則一保存,原來的數據也沒了。這肯定不行。
於是可能會產生一個想法:讓每一段代碼對應一塊內存,例如func代碼中,有一步要保存r16,於是定死了r16保存的位置(跟其它保存地址都不能沖突)。但是,事實上,這種方式是有致命缺陷的。有一個概念叫遞歸,學編程應該都會遇到的,這里不展開說了,總之如果遞歸中遇到了這種保存方式,就會死的很慘。
7、棧
現在的主流做法是什么呢?
首先,在內存中分出一片比較大的空間,稱為棧空間,專門用來干這種保存寄存器之類的事情,不管在哪段代碼,保存寄存器都在這段空間中。最初假設這片空間是空的,然后保存第一個寄存器的時候,就存到這片空間最開始的位置。保存第二個的時候,就存到下一個位置,后面的以此類推。
可能是因為一些歷史原因吧,這片空間是從高地址往低地址開始用的,先保存的位於最高地址,然后不斷向低地址保存。
於是就需要一個專門的寄存器來標記目前這片空間用到哪了,它指示着下一次保存數據保存的地址(不完全相等,后面解釋),以及下一次恢復數據從哪里恢復。在MIPS中,寄存器r29就是干這個用的。在ARM中則是r13。這里管它叫SP。
在32位MIPS中,假設某一時刻SP的值是10000,這意味着什么呢,意味着(棧空間內)地址低於10000的還空着,10000和高於10000的已經被用了。所以下一次要保存一個寄存器(32位)的時候,先把SP改成9996(就是減去4),然后把寄存器值寫入9996的位置。如果下一步要調用func代碼了,這時,有必要要求func不得改變SP寄存器,其實這很容易做到,也很自然。調用結束回來后,因為SP還是9996,我們可以輕松從SP位置恢復出剛才保存的值。恢復完以后別忘了把SP改回10000。(當然,如果保存的寄存器不是一個是兩個,不需要每保存/恢復一個都改一次SP,可以先將SP改成9992,然后把兩個寄存器分別存進SP和SP+4的位置,因為讀寫指令中寄存器的值是可以先加上一個常數再作為地址的。恢復的時候也是類似的。)
只要有過程調用的地方,基本都會用到棧,棧是一個非常重要的概念。這里我好像也沒發下一個具體的定義,不過大家能理解SP寄存器的用法就可以了。
另外注意,這個棧跟數據結構中的棧還是有點區別的,如果你還沒學到數據結構,就學了以后再體會吧。