1. 程式人生 > >程式設計機制探析之執行棧與記憶體定址

程式設計機制探析之執行棧與記憶體定址

《程式設計機制探析》第四章 執行棧與記憶體定址

計算機啟動之後,作業系統程式首先從硬碟進入記憶體條,成為最先執行起來的一批程序。這一批作業系統程序可了不得,它們規定了CPU工作的總流程。CPU工作的時候,必須嚴格遵守作業系統程序定義的工作流程。
為了滿足人類使用者的需求,現代的作業系統都是帶有圖形介面的多工(多程序)系統。在計算機執行期間,記憶體裡總是會跑著多個程序。這一點,我們可以在工作管理員已經看到了。
在這種工作模式下,CPU不得不在記憶體中的多份工作流程(即程序)之間來回穿梭忙碌,每件事都是做了一會兒就放下,趕緊去做另一件事。這就有了一個問題。CPU在放下手頭工作之前,必須先把手邊的一攤子工作找個地方暫存起來,以便一會兒回來接著幹。那麼,手頭這攤子工作存在哪兒呢?當然是存在記憶體裡。
CPU按照作業系統程序規定的工作流程,會為每一個程序在記憶體中開闢一塊空間,叫做程序空間。
我們可以想象一下,在記憶體那個巨大的木架上,有無數的小格子。CPU在把程式從硬碟中調入到記憶體中的時候,就會給每個程序都分配一些小格子,作為程序空間。
程序空間裡面首先放進去的東西,自然是程序本身定義的工作流程。除此之外,程序空間中還放了一些CPU在按章辦事過程中開啟的其他資源。總之,與該程序的工作流程相關的一切資源都記錄在程序空間中。CPU在進行工作切換之前,手頭的一攤子工作也要暫存到程序空間中的某一塊地方。那塊存放當前工作狀態的空間有一個特殊的學名,叫做“執行棧”。
“棧”這個詞翻譯於英文Stack,是資料結構中的概念。“棧”是一種非常簡單的資料結構,很容易理解。它的特性是“先進後出,後進先出”,即,你先放進去的東西壓在最底下,你最後才能拿出來。你最後放進去的東西在最上面,你可以最先拿出來。
執行棧,顧名思義,就是CPU在執行程序時,需要的一個棧結構。CPU在執行時,需要一個空間存放當時執行狀態,這一點不難理解。但是,這塊空間為什麼要是“棧”結構的,這一點就不那麼容易理解了。為了理解這一點,我們必須深入探討CPU在執行程序時的執行機制。
一份程序就是一份工作流程,但這份工作流程的結構並不簡單,很有可能包含很多分支工作流程。這就像人類社會中的相互參照的各種條款一樣,一份條款的內容很可能引用到其他條款中。比如,網上流傳著這麼一份膾炙人口、含義雋永的婚姻協議:
第一條,老婆永遠是對的。
第二條,如有不同意見,請參照第一條。
在上述兩個條款中,第二條就引用了第一條。程序的情況也是如此,一份主工作流程中經常包含很多分支流程,不僅主工作流程經常引用分支流程,分支流程之間也經常相互引用。當CPU遇到引用分支流程的情況,就會暫停本流程的執行,先跳轉到被引用的分支流程,執行完那個分支流程之後,才回到之前的流程繼續執行。那麼,之前那個暫停的流程的當前工作狀態存放在哪裡呢?沒錯,就是我們前面講過的執行棧。
CPU先把之前流程的當前工作狀態存放到執行棧中,然後跳轉到一個分支流程,開始執行。CPU在執行當前這個分支流程的過程中,也使用同一個執行棧來存放當前工作狀態,而且是放在之前那個工作流程的工作狀態的上面。當完成當前分支流程之後,CPU就會移走執行棧中當前分支流程的工作狀態,這時候,上一個沒完成的工作流程的工作狀態就浮出水面,出現在執行棧的最頂層。CPU正好就接著上次未完成的工作狀態繼續進行。
我們可以看到,執行棧這種“先進後出,後進先出”的特點,恰好就是“棧”這個資料結構的特點,因而得名“執行棧”。
如果你有過除錯程式的經驗,幸運的話,你可能會遇到這樣一個錯誤——Stack Overflow(棧溢位)。這裡的Stack(棧),指的就是執行棧。
關於“Stack”這個英文名詞的譯法,還有些說道。在一些技術書籍裡,Stack被翻譯成“堆疊”。這種譯法還挺常見。但我認為,“堆疊”這種說法是不準確的。因為,“堆”和“棧”是兩種不同的資料結構。
“堆”這種資料結構主要用於記憶體的分配、組織、管理,結構比“棧”結構複雜得多,本書不會展開詳述,因為對於應用程式設計師來說,並不需要掌握“堆”這個結構的具體原理。不過,應用程式設計師還是應該掌握一些記憶體管理的基本概念。
我們可以把記憶體想象成一個巨大無比的木架,上面有無數的大小相同的格子。那些格子就是記憶體單元。如同信箱一樣,每一個小格子(記憶體單元)都有自己的地址編號,叫做記憶體地址,由作業系統程序統一管理和編制。
小格子的數量就是記憶體容量。同樣,作業系統程序所管理的虛擬記憶體容量並不一定和記憶體卡的實體記憶體容量一致。作業系統程序有可能在硬碟上開闢一塊空間,作為虛擬記憶體的備用空間,當記憶體卡的實體記憶體容量不夠時,就把記憶體中一些暫時不用的內容暫存道硬碟上,然後把需要的內容匯入騰出的記憶體空間。這種技術叫做虛擬記憶體置換。
為了便於討論,避免歧義,在本書後面提到“記憶體”的時候,不再指實體記憶體卡,而是指作業系統管理的“虛擬記憶體”。
在“虛擬記憶體”這個巨大的木架上,每一個小格子的大小都是完全一致的,每個小格子都有自己唯一的記憶體地址。我們可以把各種資料存放到小格子裡面。如果資料尺寸足夠小的話,自然沒問題。如果資料尺寸超過了小格子的大小怎麼辦?不用擔心,相鄰的小格子之間都是相通的,我們可以把大尺寸的資料放在相鄰的多個小格子裡面。
乍看起來,一個數據放在一個小格子裡面和多個小格子裡面,並沒有太大的區別。但是,在某些情況下,卻會產生微妙的差別,甚至會對我們的程式設計產生影響。
CPU工作的時候,經常需要把資料從記憶體這個大木架中取到自己的“暫存器”工作臺上。當資料存放在一個小格子裡面的時候,CPU只需要取一次就夠了。這種操作叫做原子操作,即不會被打斷的最小工作步驟。
在物理學中,原子,這個詞的含義就是最本原的粒子,不可能再被分割。當然,後來物理學家又發現了更小的粒子。但原子這個詞的本意卻是不可分割的。原子操作也是這個意思,即不可分割的操作。
當資料存放在多個小格子裡面的時候,CPU有可能需要分幾次從記憶體中取出資料,這樣就分成了幾個步驟,中間有可能被打斷,在某些特殊的情況下,可能發生不可預知的後果,這種操作就叫做非原子操作。
從程式設計的角度來講,原子操作自然是比非原子操作安全的。因此,我們在設計程式時,腦子裡應該有這個意識,儘量避免引起的非原子操作。這類非原子操作通常由長資料型別引起。至於資料型別是什麼,什麼又是“長”資料型別,非原子操作又可能產生怎麼樣的意外,後面會有專門的章節講解這方面的內容,我們現在不必關心。
從這裡我們看出,作業系統的記憶體單元的尺寸對於原子操作的意義。記憶體單元越大,就能夠容納更大的資料,就越容易保證原子操作。
我們常聽到,32位作業系統或64位作業系統之類的說法。這裡的32位或者64位的說法,指的就是CPU的工作臺(暫存器)的位數。
64位作業系統的記憶體單元32位作業系統大了一倍,那麼,原子操作能夠容納的資料尺寸也大了一倍。這意味著,在取用某些“長”資料型別的時候,CPU按照64位作業系統的規則,只需要取一次,就可以把資料取到暫存器中。而CPU按照32位作業系統的規則,卻分兩次把資料取到暫存器中。因此,從處理長資料型別的速度上來說,64位作業系統是優於32位作業系統的。
記憶體單元是作業系統定義的,原子操作自然也是作業系統來保證的,同時也需要CPU的相應支援。至少,CPU的“暫存器”工作臺尺寸不能小於記憶體單元,CPU才能一次就把一個記憶體單元中的資料取到暫存器中。現代的CPU已經進入多核時代,都已經支援64位寬度的記憶體單元,從而支援64位作業系統。
我們已經屢次提到32位作業系統和64位作業系統。那麼,這個“位”到底是什麼呢?
要理解這個概念,我們必須首先理解什麼是二進位制。
我們在日常生活中計算數目用的都是十進位制,滿十進位。據說是因為我們人類有十個手指頭,每次算數的時候都會掰手指頭,掰到十個的時候,就沒得掰了,就開始進位。這種說法並非空穴來風。英文Digit就是十進位制數字的意思(從0到9之間的個位數字),同時還有手指頭或者腳趾頭的含義(腳趾頭也是十個)。所幸當年的人類先祖並沒有把手指頭和腳趾頭一起數,否則,我們今天用的就是二十進位制了。
另外,十二進位制也是日常生活經常見到的進位制。比如,十二個就是一打(Dozen),十二個月就是一年。同時,人類的時間計數也採用各種其他的進位制。比如,七天是一週,六十秒是一分鐘,六十分鐘是一小時,二十四小時就是一天。不管是怎樣的進位制,能夠表達同樣的數量。不同的進位制之間是可以相互轉換的。比如,一年兩個月,這種表達是十二進位制,轉換成十進位制表達,就是十四個月。
底層的計算機硬體只識得“0”和“1”這兩個數字,因此,它自然而然就採用了二進位制,逢二進一。
注意,這裡我們說,計算機只識得“0”和“1”,並非計算機本身的能力所限,而是我們人類特意這麼設計的。
原因很簡單,二進位制的表達只需要兩個數字——0和1,那麼,我們只需要讓計算機硬體識別兩個不同的狀態就可以了。
十進位制的表達則需要十個數字——0到9。如果我們想讓計算機硬體實現十進位制的話,那麼計算機硬體就必須能夠識別十個狀態。這樣的實現難度將成幾何級數成長。而這樣的設計是完全沒有必要的。因為,二進位制的表達能力與十進位制是完全一致的,所有的十進位制數字都可以和二進位制數字之間自由轉換。它們只是兩種不同的數量表達方式。下面給出十進位制數字與二進位制數字之間的相互對應。
十進位制 二進位制
0 0
1 1
2 10
3 11
4 100
5 101
6 110
7 111
8 1000
9 1001
為了更好地理解這種轉換,我們來看一個更加形象化的例子——八卦圖。八卦,顧名思義,總共有8個卦象。如果用二進位制來表示,那麼,需要最少的數字位數是幾呢?從上面的表格可以看出,0到7恰好是八個數字,其中7對應的二級制數字111是3位。再往上一個數字,就是8,對應的二進位制數字是1000,就是4位數了。因此,0到7這個八個數字,恰好用完了三位二進位制數字的所有容量。
如果用組合原理來表達的話,這個問題可以表述為,現在我們有n個位置,每個位置有0和1兩種狀態,現在,我們需要表達8個狀態。請問n最小是幾?
運用組合原理來求解的話,2的3次方恰好就是8,這就是說,n = 3。我們需要3個位置,來表達8個狀態。
進位制轉換和組合原理都是很有趣、很有用的主題。不過,本書是關於計算機原理的書籍,而不是一本數學科普讀物。因此,請讀者自行查閱和彌補這兩方面的知識。這些最基本的數學知識對於程式設計師,或者非程式設計師來說,都是非常重要的。
做了上述理論準備之後,我們就可以來看真正的八卦圖了。

我們可以看到,八卦圖的表現也是一種二進位制,最基本的表達只有兩種狀態:一個連續的長橫線,和一根雙線段組成的斷橫線。
我們可以把長橫線看做0,把斷橫線看做1。那麼,上面的八卦恰好就是0到7的二進位制表達:000,001,010,011,100,101,110,111。可見,中國人很久之前就開始使用二進位制了。
我從各方面舉出各種例子,希望能夠幫助讀者更好地、更感性地、更貼近實際地理解二進位制。如果這些例子還是不足以說明問題的話,那不是讀者的問題,是我的表達和組織的問題。讀者可以去查閱一些關於二進位制的更好的、更清晰易懂的資料。
我們前面提到的32位作業系統和64位作業系統,其中的“位”的意思就是一個二進位制數字。32位就表示一個位數為32的二進位制數字,表達的最大數量是2的32次方。64位就表示一個位數為64的二進位制數字,表達的最大數量是2的64次方。
“位”這個詞,對應的英文單詞是“bit”。這個詞經常被音譯為“位元”。比如,數字訊號的傳輸速率就經常被譯成“位元率”。
我個人十分討厭這種譯法。因為這種譯法極容易與英文中另一個重要的計算機詞彙“Byte”弄混。事實上,也確實有很多人弄混,造成了不必要的困擾和混淆。
英文“Byte”一般意譯成“位元組”。我喜歡這種翻譯方式,因為不會引起同音混淆。
但是,在有些技術資料甚至一些應用軟體中,卻把“Byte”音譯成“位元”,這很容易與“Bit”(位)弄混。
現在,我們這裡澄清一下“Bit”(位)和“Byte”(位元組)之間的區別。
Bit就是一位二進位制數字,要麼是0,要麼是1,只能表達兩個狀態。
Byte(位元組)則是一個位數為8的二進位制數字,能夠表達的狀態數量達到2的8次方,即256個狀態。Byte和Bit之間足足差了2的7次方的倍數,即128倍。
Bit一般用來表述數字訊號傳輸率,而Byte一般用來表示計算機中檔案的大小或者儲存介質的容量。在網路傳輸的速度計量中,這兩種計量單位經常被混用。尤其是區域網速度與網際網路速度相差巨大的情況下。有時候,我甚至都覺得,這種混用是不是故意造成的,其目的是為造成使用者的誤判。讀者在判斷網速的時候,要特別注意一下這兩個計量單位的區別。
Bit(位)這個單位太小,一般在硬體底層通訊開發中用到。在一般的應用軟體開發中,我們只需要關心Byte(位元組)這個單位就夠了。
我們經常用“Byte”(位元組)這個單位來表達資料的尺寸(有時候,也叫寬度)。
一個Byte(位元組)的位數是8,那麼,32位就是4個位元組,64位就是8個位元組。以前還有16位的作業系統,記憶體單元就是兩個位元組。
與記憶體單元的尺寸規格相對應的,是CPU的“暫存器”工作臺的尺寸規格。作為計算機整個體系結構中的核心部件,CPU得到了最多資源的支援。CPU並非只有一個“暫存器”工作臺,它有好幾種尺寸規格的工作臺,有可能是一個位元組,兩個位元組,四個位元組,八個位元組,等等。有時候,大的暫存器工作臺是由兩個小的暫存器工作臺拼起來的。不管怎麼說,每種尺寸規格的工作臺都有好幾個,以備CPU不時之需。
CPU的所有暫存器加起來,有可能達到幾十個之多。這些暫存器根據尺寸規格和功用,分成好幾個組。
同記憶體單元一樣,每個暫存器都有自己的地址編號。當然,由於暫存器的個數實在太少,它們的地址編號並不需要以數字的方式來表達,直接給每個暫存器取一個名字就好了。暫存器的名字通常都與其功用及尺寸相關。
比如,AX, AH, AL等,表示不同尺寸規格的加法器。A是英文Add的首字母。其他的暫存器的名稱也都代表了各自的功用或者尺寸規格。
暫存器裡可以放置什麼樣的資料呢?答案是,任何資料,只要暫存器夠大。
比如,暫存器中可以直接放入一個用於數學計算的數字。這種含義普通的資料在組合語言中有一個專用名詞,叫做“立即數”。
除了“立即數”之外,暫存器中還可以放入一種特殊的資料——地址資料。這類資料是專門用來計算記憶體地址的。
用來放置“地址資料”的暫存器,是一類特殊的暫存器,叫做“定址”暫存器。故名思意,這類暫存器的功用,是為了尋找記憶體地址。這類暫存器裡面放置的資料,都是用來計算記憶體地址的“地址資料”。定址暫存器可以細分為更小的組,比如,“基址”暫存器,“變址”暫存器等。這些定址暫存器的用法也很簡單,就是把不同定址暫存器中的地址資料或者地址偏移資料加在一起,就可以得到最終的記憶體地址。
暫存器這個概念,在組合語言中大量用到。但是,在我們的日常程式設計工作中,組合語言並不是一門廣泛應用的程式語言,我們更多地使用高階語言,以便更輕鬆、更高效地完成程式設計工作。而高階語言中,並沒有暫存器這個概念。因此,本書不打算深入講解組合語言語法和暫存器概念。但是,記憶體地址這個概念,在高階程式語言中,尤其在指令式程式設計語言匯中,記憶體地址是一個極其重要的概念。可以這麼說,所有的指令式程式設計語言,全都是基於“記憶體地址”這個核心概念來程式設計的。因此,記憶體地址這個概念怎麼強調都不為過,必須大講特講。
當然,在一些極力標榜“高階語法特性”、極力隔離硬體底層實現的命令式語言中,你並不會直接看到“記憶體地址”這個概念。那些語言會用一種極其蹩腳的方式,把“記憶體地址”這個概念改頭換面,換成“變數”(Variable)、“指標”(pointer)、“物件引用”(Object Reference)、“陣列下標”(Array Index)、“物件成員”(Object Member)等貌似高階的概念。如果你對這些眼花繚亂的名詞術語感到頭暈的話,不要著急。這些都是表象,本書會逐漸揭開這些表象下面的共同本質——記憶體地址。
對於使用高階語言的程式設計師來說,並不需要直接碰觸到暫存器這個概念。為了簡化起見,我們可以簡單地把暫存器當做記憶體中的延伸部分。即,我們可以把每個暫存器都理解為一個特殊的記憶體地址。這樣,概念模型上就統一了。
在結束本章之前,我們玩一個尋寶遊戲。這個遊戲是這樣玩的。藏寶人把一個小禮物藏在屋子裡面的某個地方,並提供給尋寶人一系列的尋寶線索。尋寶人則根據藏寶人提供的尋寶線索,一步步按圖索驥,順藤摸瓜,最終找到藏寶地點。
尋寶線索通常是一系列小紙條組成的。比如,第一張小紙條上寫著,“請開啟書桌第一個抽屜。”尋寶人就會按照這個線索去開啟書桌第一個抽屜,結果看到裡面放著另一個小紙條,上面寫著,“請開啟梳妝檯上的小盒子”。於是,尋寶人就去梳妝檯,找到一個小盒子,開啟,裡面又是一張小紙條,“請去廚房,開啟櫥櫃第三個格子”……就這樣,尋寶人根據小紙條寫的方位地址,一步步順藤摸瓜,最終找到藏寶地點。
我們可以看到,在這個藏寶遊戲中,線索都藏在某個具體的方位地址中,而且,該方位地址裡面的內容,又是另一個方位地址。這個過程,很類似於“記憶體定址”的過程。下面,我們就在記憶體中來模擬這個尋寶遊戲。
首先,我們要在記憶體中定義一個起始地址。我們假設該地址編號是0001。我們可以在腦海中想象一個大書櫃,裡面全都是小格子,第一個每個小格子都有一個編號。其中一個編號就是0001。我們給可以0001這個地址編號標註的格子起一個名字,叫做“尋寶起點”。為了更加形象,我們可以想象,自己在0001編號的小格子的邊框上貼了一個標籤,上面寫著“尋寶起點”。
然後,我們在“尋寶起點”這個小格子裡面放一張紙條,上面寫著“請開啟書桌第一個抽屜。記憶體地址是1001”。
於是,我們迅速移動到1001編號的小格子前,一看,果然,那個小格子的邊框上已經貼了一個標籤,上面寫著“書桌第一個抽屜”。我們再看小格子裡面,那裡也放了一張小紙條,上面寫著,“請去廚房,開啟櫥櫃第三個格子。地址編號是2001”。
於是,我們迅速移動到2001編號的小格子前,一看,果然,那個小格子的邊框上已經貼了一個標籤,上面寫著“櫥櫃第三個格子”。我們再看小格子裡面,那裡也放了一張小紙條,上面寫著,“……”
好吧,遊戲到此結束,Game Over,讓我們進入下一章。