1. 程式人生 > >一篇文章理解計算機最基本的運行原理(學C語言之前必懂)

一篇文章理解計算機最基本的運行原理(學C語言之前必懂)

能夠 個數 定義 判斷 程序 設置 如果 註意 導致

本文的主要說明對象是CPU和內存。為什麽學C語言之前必懂呢,因為C語言是非常貼近底層原理的語言,明白了CPU和內存的原理,對學C語言有很大幫助。

其實我個人是比較主張計算機專業本科應該先學計算機組成原理然後再學C語言的,不過好像沒有這麽幹的,而且學C語言之前並不需要學完整個計算機組成原理才能學C,對於想快速入門的來說,理解了這篇文章足夠了。

先說說內存,其實就只是一個存數的工具,容量可以相當大,例如現在常見的4GB容量的內存,有40億個內存單元,或說40億個字節(字節用B表示),每個內存單元可以放一個8位二進制數。計算機裏什麽都是二進制,至於為什麽是8位,人家就是這麽規定的。

(事實上字節B本身就是一個容量單位,1KB=1000或1024B,1MB=1000或1024KB,1GB=1000或1024MB,具體是1000還是1024其實。。。我也是恨死了最初讓這個進率為1000的家夥)

內存的基本操作只有讀和寫兩種(對有的CPU還有運算等操作,但咱們只討論讀和寫,因為有這兩個足夠了)。有這麽多個內存單元,咱讀或寫的時候總得告訴人家咱要的是哪個內存單元吧,這時就要通過編號了,對於4GB內存,編號就是從0到40億-1(註意是從0開始,在計算機或C語言裏很多東西都是從0開始的),這個編號就是地址。讀的話就是把某個地址裏的數讀進CPU,寫的話就是把CPU裏某個數寫進某個內存地址處(這個地址之前的數就沒有了)。

另外要說明的是,每次讀或寫,可以讀寫2個,4個,甚至可能8個內存單元。32位CPU最常見的是一次讀4個字節(因為每個字節是8位,32位就是4個字節了),一般來說,要求地址必須是4的倍數,例如讀4004開始的4字節,那麽這時候會把4004、4005、4006、4007這4個單元的數全部讀出來,組成一個32位二進制數。這裏有一個問題,怎麽組?是從高位到低位分別為4004到4007,還是4007到4004?其實取決於CPU了,兩種都有。寫也是類似的。

下面說說CPU。CPU有一個重要的概念是寄存器,例如32位MIPS有r0到r31的32個寄存器(實際上不只這些,但其它的寄存器都有他們自己的特殊性,後面討論),每個寄存器可以放一個32位二進制數。寄存器也是用來存數的(類比內存),但是就這麽點兒寄存器,沒有內存根本不夠。而且CPU要工作必須有內存(後面會說原因)。與內存不同的是,寄存器裏的數據可以直接做運算,例如MIPS我可以讓CPU做這麽件事:把r12和r17裏的數相加,加法結果存入r1。結果也可以存入r12或r17。通過這個例子應該能看明白寄存器是用來幹什麽的了。而內存裏的數,不能直接做運算(其實不是所有的CPU都不能,但是那些咱們就不討論了),只能把某個地址的數讀到某個寄存器來,或者把某個寄存器裏的數存到內存某個地址處。另外要指出的是,內存取數的操作是非常慢的(相比寄存器運算等操作,不過跟外存磁盤等相比內存真的很快很快),當然現在有一些辦法能一定程度上解決這個問題,但常用的數據要盡可能放在寄存器中。

內存裏的數讀入寄存器的典型操作比如把r14裏的數加上12(常數),這個加法結果作為地址,把這個內存地址的數存進r6。看起來好像這個加上12有點奇怪?如果你想把r14裏的數直接做地址的話,加上0就可以了,不過實際上這個先加上一個常數再作為地址的操作很常見,所以才會有這個看起來有點奇怪的操作。這裏有三個要告訴CPU的東西,地址放在哪個寄存器,地址加的常數,以及把數讀進哪個寄存器。寫內存的操作也是類似。對於32位MIPS,一般都是直接把4個字節讀進來或寫出去,但是也可以寫1字節或2字節,與寄存器的位數不一樣了,就會產生一個問題:多出的位怎麽辦?不過我也不打算在這裏討論這個問題了。

32位MIPS所有的寄存器都是32位的,讀寫內存時,能表示的地址只有2^32個,所以如果內存容量超過2^32字節(就是4GB,但是進率是1024),再大也沒用,CPU訪問不到它們。

前面一直說,我們要告訴CPU做什麽操作,那麽怎麽“告訴”呢?通過指令。前面說的,把r12和r17裏的數相加,加法結果存入r1,這就可以寫成一條指令(對於32位MIPS)。把r14裏的數加上12作為地址,把這個內存地址的數存進r6,這也是一條指令。對於32位MIPS,所有的指令都是用32位二進制數表示的。沒錯,又是32位二進制數。這32位裏,有些表示了指令的類型(做加法還是減法,還是讀寫內存什麽的),有些表示了寄存器編號等。反正只要知道指令能用二進制數表示就可以了。對於x86的CPU,每條指令長度並不固定,有的指令8位,有的很長。

好了,指令寫完了,怎麽發給CPU?其實指令都是按順序放在內存裏的。這就是CPU離開內存無法正常運行的原因。CPU需要把指令從內存中取到寄存器中才能執行,這個寄存器往往就跟前面說的r0到r31不一樣了,是專門做特殊用途的寄存器。另外,還有一個寄存器PC(在MIPS中叫PC)用來指示取指令的地址,也不在r0到r31中。CPU一直在循環做這麽幾件事:先把PC裏的值作為地址,上內存裏把指令取出來,再把PC改成下一條指令的地址(對於32位MIPS一般是把PC的值加上4,因為每條指令32位4字節),接下來就去執行這條指令了。然後再重復這個過程。

這裏要插一句,C語言寫的程序,要先轉化成這樣一條一條的指令之後,才能運行,當然這個轉化的過程不是我們自己做的。C語言裏很多操作都能直接對應到這些指令的操作。因此了解這些指令對學習C語言很有幫助。

條件執行指令?先判斷一個條件是否成立,如果成立就執行這一段,不成立就執行下一段?能不能做到呢?當然可以,改PC就可以了!當然,MIPS指令裏不能直接做某種運算把一個數存進PC,不過MIPS有其它的指令,像無條件跳轉,就可以改PC。在ARM裏可以隨便改PC像改其它寄存器那樣。還有時候是條件跳轉,先判斷一個條件是否成立。一般條件有哪些呢?一般是判斷一個數是否大於0,是否等於0,等等,其實在指令層面這些都很容易實現,在這裏不展開說了。總之跳轉的方法就是改PC。

有一種特殊的跳轉指令要重點說,這條指令能在跳轉前把舊的PC保存下來(指向跳轉指令的下一條指令)。對於MIPS,它會被保存到一個寄存器裏(就是r31)。對於x86,則保存到了棧裏(後面會說什麽是棧)。跳轉之後,執行一段代碼,完了還可以跳回來,因為保存了舊的PC。如果有好幾個地方都想跳到這段代碼執行一下然後再回到原來的地方,這種指令就很重要了。例如,有一段代碼的功能是,根據r4和r5的值進行某種操作(比如計算r4+r5的值,把r4存入r5地址的內存,或者什麽更復雜的操作),把某個值(比如r4+r5的值)寫進r2。然後就隨時都可以把r4和r5設置好之後就用這種跳轉進來執行,當把r2算出來後再跳回去,之後就可以使用r2的值了。這就叫做過程調用,或者在C語言裏叫函數調用。當然如果只是把r4和r5相加這麽簡單的話,是沒必要寫這麽一段代碼用過程調用來實現的,但有時候這個操作很復雜,在每個地方都寫一遍是不值得的,這時候就可以用過程調用。

不過這又會引出新的問題,比如,執行完那段代碼之後,除了r2、r4、r5,其它的寄存器會不會變化?真的有可能變的,尤其是程序復雜的時候。一個比較容易想到的辦法是,在跳到那段代碼之後,如果要用到其它寄存器,就先把它保存到內存裏,等事情幹完了,從內存裏把數讀回來,之後再跳回去,就可以保證其它的寄存器值不被破壞了。後面會詳細討論寄存器如何保存。

但是,32個寄存器,並不一定每一個都存著很重要、不可以丟的數據,往往並沒有必要保存它們全部。訪問內存可是一個很慢的操作。所以,32位MIPS給了一個約定,約定了在過程調用中,哪些寄存器必須保證不變,哪些可以改變。這個約定的具體內容在這不展開說了。對於前者,如果過程中要用這些寄存器,就必須把它們原來的值保存到內存,用完了要恢復。對於後者,過程中可以改變,不需要保存。這也就意味著,如果用了這種跳轉指令,那麽回來之後,將無法保證後者的寄存器中的值不被破壞。其中典型的就是r31,只要執行了這個跳轉指令,r31立馬被破壞(成為舊PC的值)。因此,跳轉之前必須保證這些寄存器裏沒有重要的數據還沒保存。

下面來說說這個寄存器保存的問題。要保存肯定是往內存裏保存,如果內存不夠用了,那別說保存寄存器了連程序都不用跑了,所以咱們假設內存夠大。保存到內存的什麽位置呢?舉個例子吧,程序調用了一段代碼(就是用這種特殊跳轉指令跳轉到了一段代碼中),然後這段代碼還需要再調用另一段代碼,這是過程嵌套調用。想一想,剛跳轉到過程中時,r31保存了返回地址,如果要再次調用,那麽r31會被破壞,所以再次調用之前要保存r31的值。新的調用完成之後,把r31恢復回來,就可以正常返回了。r31保存到內存的什麽位置呢?一個容易想到的方法是固定一個地址,規定在這裏保存r31時就是保存到這個地址處,之後恢復的時候也是就從這個地址恢復。這需要保證,保存之後,到恢復之前,不能再次執行到“保存”這個位置,否則之前保存的值就被破壞了。那麽這個能不能得到保證呢?保存之後,恢復之前,只要不冒出個跳轉語句跳轉到保存之前,應該就不會出現這個問題。

但事實上,有一個概念叫遞歸,就是在一個過程中(返回之前)再次調用這個過程自身。看起來好像很奇怪吧?但實際上真的能這麽幹的,也不會進死循環,這種情景經常遇到。想想,在遞歸的時候,顯然是有嵌套的過程調用的(因為不斷地嵌套調用自身),這就涉及到保存r31,然後調用,再恢復r31。r31的保存和恢復之間有沒有跳轉語句跳轉到保存之前呢?顯然有,就是那個調用本身!調用就是一種特殊的跳轉(與普通跳轉的區別就是跳轉之前會自動保存PC),並且這個跳轉直接跳轉到了過程的開始處,之後還會再執行到保存r31的語句,就會破壞之前保存的r31。

所以,如果出現了遞歸,每次都保存到同一個位置就不行了。可以想到,把保存地址直接寫在指令裏是行不通的,因為指令已經寫死了不能變,每次執行到這條指令都會保存到同一個地址。所以只能想另一個辦法,就是把保存的地址寫在一個寄存器裏,然後往這個地址保存。現在基本所有CPU都是這麽幹的,有一個專門的寄存器SP(或其它名字),比如,剛進入這個過程的時候,SP的值是1996,那麽下一次要保存寄存器的時候,先把SP減去4變成1992,然後保存到1992地址處(因為每個寄存器32位4字節)。再下一次保存時,再次把SP減去4變成1988,然後把數據保存到1988處,就一直這樣,也就是說,SP始終指向上一個保存的數的位置,要保存的時候就把SP減去4然後保存到SP地址處。這樣就有效避免了每次都保存到同一個地址導致的問題。恢復的時候,也根據SP的值到相應地址處恢復,另外在返回之前必須把SP的值恢復成1996,否則會導致SP混亂。SP是一個相當重要的值,很容易想象,如果SP的值錯了,這裏保存的一系列數據就都錯了,所以實際的CPU中這個SP寄存器絕對不能亂改,也不會出現什麽我需要用SP所以先把SP保存起來這樣,會亂掉的。當然,實際編程中SP的使用可以簡化,例如總共需要保存4個寄存器,那麽一次性把SP減去16,然後把這些數分別保存到SP+12、SP+8、SP+4、SP。在指令層面這完全可以做到的。也許你會奇怪為什麽SP是一直減而不是一直加,其實理論上都可以,但是現在很多流行的操作系統中,SP是從整個內存地址最高處往下降的,這也許是歷史原因吧,不過這樣也不錯。

簡單地說,就是靠寄存器SP來記錄現在保存的數據有多少,下次要保存到哪個位置。這其實就是棧了,棧的本質就是用一個“棧頂指針”來記錄了下一次進棧要進到什麽位置。這個“棧頂指針”就是SP。不過這個棧跟數據結構中的棧還是有點區別的。如果一個過程中,局部變量(是C語言裏的概念,先看看就好)太多,寄存器不夠用了,這時候也需要用到棧,把多的變量保存到棧中,能夠解決這個問題。只要有過程調用的地方,基本都會用到棧,棧是一個非常重要的概念。這裏我好像也沒發下一個具體的定義,不過大家能理解SP寄存器的用法就可以了。

一篇文章理解計算機最基本的運行原理(學C語言之前必懂)