【梳理】計算機組成與設計 第2章 指令(內附文檔高清截圖)

配套教材:
Computer Organization and Design: The Hardware / Software Interface (5th Edition)
這是專業必修課《計算機組成原理》的複習指引。建議將本複習指導與博客中的《簡明操作系統原理》配合複習。
在本文的最後附有複習指導的高清截圖。需要掌握的概念在文檔截圖中以藍色標識,並用可讀性更好的字體顯示 Linux 命令和代碼。代碼部分語法高亮。
計算機組成原理不是語言課,本複習指導對用到的編程語言的語法的講解也不會很細緻。如果不知道代碼中的一些關鍵字、指令或函數的具體用法,你應該自行查找相關資料。


第二章 指令

1、指令集(instruction set)包含了芯片支持的全部指令。
指令相當於計算機語言的詞庫。但是計算機語言不像不同國家的語言那樣區別那麼大,它們更像一個國家的不同地區的方言。如果你掌握了一門編程語言或者一種ISA的指令,那麼掌握其它編程語言或其它ISA的指令的難度就要降低很多。

2、MIPS是最早商用的指令集,始於1980年代。ARMv7指令集和MIPS比較接近,是廣泛應用的指令集之一。ARMv8誕生於2013年,是ARMv7的64位版本。但ARMv8似乎同樣更接近MIPS而不是ARMv7。
獲得了ARM公司的授權的合作伙伴在2019Q4總共出貨了大約64億顆ARM芯片,其中約42億芯片是Cortex-M系列微控制器。微控制器廣泛應用於嵌入式市場和消費級產品。ARM授權給其它公司的常見芯片設計主要分爲Cortex-A、Cortex-R和Cortex-M三個系列(ARM自己不直接銷售芯片,而是將設計的架構和指令集授權給其它公司使用或改進,並將芯片成品出售)。它們的指令集有些許不同,採用ARMv7指令集的分別稱爲ARMv7-A、ARMv7-R和ARMv7-M,採用ARMv8指令集的分別稱爲ARMv8-A、ARMv8-R、ARMv8-M。一些服務器CPU也開始使用ARMv8指令集。2020年5月,ARM還推出了Cortex-X系列,首個CPU設計爲Cortex-X1。
高通的Krait、Kryo架構和三星的Mongoose,NVIDIA的Denver、Carmel,以及蘋果的Swift、Cyclone、Typhoon、Twister、Hurricane、Monsoon / Mistral、Vortex / Tempest,Lightning / Thunder都是基於ARM指令集自主研發的CPU架構。
龍芯是中國科學院計算技術研究所研發的基於MIPS指令集的CPU。飛騰CPU則由國防科技大學計算機學院研製,原先基於SPARC,後續產品則基於ARMv8指令集,現服役於“天河”系列超級計算機。申威CPU由總參謀部第五十六研究所(江南計算技術研究所)研製,其“申威64”指令集來源於DEC Alpha 21164,現服役於“神威”系列超級計算機。
Intel / AMD的x86 CPU則具有x86指令集,它也是一系列指令集的集合。x86指令集及具備x86指令集的CPU主要應用於PC、工作站、服務器和超級計算機。

3、許多指令集具有不能忽略的相似性,因爲所有的計算機基於的底層硬件技術和原理都很接近;最基本的操作,如算術運算、移位、邏輯運算、數據傳輸(如:讀寫)、控制(調用、跳轉、……)等指令,也是所有計算機都必須支持的。例如,一個MIPS CPU支持的部分彙編指令和操作數如下:

計算機中,大多數指令都是無操作數(operand)(參與一個運算(操作,operation)的元素個數)、一個操作數、兩個操作數或三個操作數的指令(一些指令集中的指令支持更多的操作數),而不是可變的。理由很簡單:如果把指令設計成支持可變數量操作數的,那麼硬件實現就很複雜。
基於這點事實,我們引出設計的第一個原則:規整的設計更簡單。

4、Java、Python、Perl、SQL等語言使用解釋器(interpreter)。Java解釋器將Java語句解釋爲字節碼(bytecode),字節碼再被轉換爲機器指令。採用解釋器的語言對跨平臺(跨操作系統)的支持通常更好。

5、彙編指令是直接對應機器語言的低級指令。高級語言對操作數的數量沒有限制,但彙編指令有。操作數保存在內存中或寄存器(register)中。寄存器是用於臨時存儲操作數(包括運算結果)的部件。寄存器的速度要快於高速緩存,但非常昂貴,因此數量很少。8086的寄存器是16位的,MIPS32 CPU的寄存器是32位的,MIPS64 CPU的寄存器是64位的。MMX指令集用到的寄存器有8個,長度爲64位:mm0到mm7,都是浮點單元(Float point unit,FPU)的80位寄存器的低64位。SSE指令集中的指令專用的寄存器包括8個128位的xmm0到xmm7,x86-64還有額外的8個寄存器xmm8到xmm15。此外還有一個32位的控制 / 狀態寄存器。AVX指令集使用的寄存器則是128位的xmm0到xmm15(x86-64模式下還有xmm16到xmm31)。AVX2指令集將操作數的寬度擴展到了256位,寄存器代號從ymm0到ymm15(x86-64模式下還有ymm16到ymm31),並可以輸入後綴相同的xmm寄存器來取得低128位。AVX-512指令集將操作數的寬度進一步擴展到了512位,在x86-64下,寄存器代號從zmm0到zmm31,並可以輸入後綴相同的xmm、ymm寄存器來分別取得低256位、低128位。
一個字(Word)通常是16位的。但在MIPS CPU中,一個字的長度是32位。

6、設計的第二個原則:更小的部件更快。如果把寄存器做得很多、緩存做得很大,那麼它們的速度就會變慢。因爲很多時候電信號要傳遞更遠的距離。當然,這個並不是絕對的。例如MIPS CPU中,如果寄存器的總數是31個而不是32個,那麼性能未必更好。

7、寄存器的數量實在太少,因此無法保存較多的數據或較複雜的數據結構。於是這些數據被保存在內存中。所有的CPU都包含數據傳輸指令(數據傳送指令、數據搬移指令),用於在內存與寄存器之間搬運數據。如果需要訪問內存中的內容,那麼指令的其中一個操作數就需要是內存地址(address)。地址刻畫了數據在內存中的位置。內存可以看成一個很大的一維數組,地址從0開始編號,並且每個字節的地址可以看成這個數組的下標。

8、把數據從內存讀入寄存器的指令,稱爲讀取(加載、裝入,load)指令。Load指令的操作數自然是寄存器和內存地址。在MIPS架構的CPU中,任何變量的起始地址必須是4的倍數。這稱爲內存對齊(memory alignment)。如果嘗試訪問沒有對齊的數據,會報錯。x86架構則支持非對齊訪問其實現機制是將非對齊訪問指令拆分成多條指令執行,結合拼接(或者拆分)指令獲取數據。缺點是犧牲性能。ARMv5不支持非對齊訪問,ARMv6的部分指令支持,而ARMv7、ARMv8架構的對齊檢查可以手動開啓或關閉。如果使能(啓用)對齊檢查,那麼任何指令的非對齊訪問均會觸發非對齊異常(exception)。注意:採用ARMv8指令集的CPU中,A64指令集的部分指令的非對齊訪問在關閉對齊檢查的情況下仍然會產生非對齊異常。也可以用軟件等效實現非對齊訪問,但會降低性能。
在編譯時,可以通過調整編譯選項來啓用或關閉非對齊訪問。
把數據從寄存器寫入內存的指令,稱爲存儲(store)指令。MIPS的Load / Store(L / S)指令一次只讀一個數據、寫一個數據,不進行運算。進行算術(arithmetic)時,一般都在寄存器中進行,使得運算更快。

9、內存中的數據有大端(big endian)和小端(little endian)兩種讀寫模式。採用哪種模式是CPU架構決定的。MIPS是大端陣營的,一個變量的高位保存在低地址中,低位保存在高地址中;小端模式則相反。大端和小端分別也稱“高尾端”和“低尾端”,其含義很清晰:大端即高尾端,意味着變量的末尾比變量的頭部的地址更高;小端則相反。x86 CPU一般採用小端,IBM、Sun的CPU(SPARC)一般採用大端。有的CPU既能工作於小端也能工作於大端,如ARM、Alpha、Power PC。

10、編譯器負責將最常用的變量保存到寄存器中。爲了使寄存器儘可能提升性能,編譯器需要正確利用寄存器,而且寄存器的數量不能太少也不能太多。許多ISA都包含16個或32個通用寄存器,以及其它一些專用的寄存器。

11、立即數(immediate operand,或immediate)是常量或表達式的結果。彙編器在將彙編語言轉換爲機器語言時,將立即數編碼到指令中,這樣就避免了執行包含立即數的指令時總是要從內存中讀取常量。

12、現代計算機一律採用二進制。事實上,最早的商用計算機是進行十進制計算的,但是效率很低。因此,後來的計算機全部採用二進制進行運算,只在輸入、輸出時根據需要進行二進制與十進制的互相轉換。

13、以前的計算機採用額外的位來記錄符號位(sign bit),這導致運算的時候要額外花費一步來設置符號位,而且還會出現正0和負0,導致一些問題。後來,經過大量的研究,最終將內置類型中的最高位指定爲符號位,0爲正,1爲負。於是一個int型的數據的取值範圍是這樣的:

這種表示法叫做二補數(two’s complement)表示法。一個n位的二進制無符號數x與其按位取反的數~x的和是2n。也就是說x的二補數是2n – x。類似地,有一補數(one’s complement)表示法:一個數x的一補數是x(代表按位取非,見第19點),即2n – x – 1。對一個32位的有符號數,如果用一補數表示法,那麼0x80000000到0xFFFFFFFF表示-2147483647到-0。如果把數採用一補數表示法表示,那麼需要多花費一步來減一個數。如果採用二補數表示法表示數,不但不需要多花費這一步,還可以把加減法統一按加法運算,提升性能。所以二補數表示法很快成爲當今全部計算機採用的表示法。
硬件在判斷一個數是否爲負時,只需要讀取最高位(符號位)。
以int型數據爲例,一個數x = x31x30x29…x1x0可以表示成:

有符號數取相反數的公式是:–x = ~x + 1。

14、溢出(overflow)是指運算結果(無論是中間結果還是最後結果)的絕對值部分大於字長能表示的最大絕對值的現象。兩個正數相加,結果大於機器的字長能表示的最大正數,稱爲正溢。兩個負數相加,結果小於機器的字長能表示的最大負數,稱爲負溢。

15、將字節數更少的變量讀入寄存器時,高位要填充。對無符號數,直接填零。對有符號數,則進行帶符號擴展(sign extension)。在C / C++中,進行類型轉換(cast)時也是如此。一個有符號數轉爲佔用字節數更多的數,則高位填充原數的符號位。字節數較少的有符號數轉換爲字節數較多的有符號數時,值不變;但是不同字節數的有符號數和無符號數互相轉換時,數值就可能發生改變了。不過,當變量或常量表示地址(即變量爲指針)時,沒有符號位。地址總是非負的。

16、指令是具有一定的規範的,稱爲指令格式(instruction format)。MIPS的每條指令都是32位長,而x86的指令是變長的,平均長度約3個字節。這些指令是從彙編指令轉換來的,對應的語言稱爲機器語言(machine language)。一長串這樣的機器指令,也叫機器碼(machine code)。

17、設計原則3:好的設計要求好的折中。
MIPS的32位機器指令分成了6個區域:

op:操作碼,指定了操作的種類(如:加法)。
rs:第一個操作數(寄存器)。
rt:第二個操作數(寄存器)。
rd:目標操作數(寄存器)。
shamt:移位數量(這個區域不常用)。
funct:函數(函數碼),選擇操作碼對應的操作的變體。
到這裏大家可以看出來,一個操作數只有5個bit,也就是說只能表示32種數。想表示更多的數的時候怎麼辦呢?MIPS的設計者們又設計了另外一種格式(I-type或I-format,I = immediate),區別於上述格式(R-type或R-format,R = register):

這種格式的指令可以表示-32768到32767這65536(216)個常數(立即數),也可以表示該範圍內的偏移地址。
雖然支持多種指令格式會使得硬件變得複雜,但是這兩種格式的很多地方是相近的,比如前3個區域的功能和邊界都一樣,I型的最後一個區域的長度正好是R型的後三個區域的長度,所以實際上沒有增加太多的複雜度。至於採用哪種格式,則由第一個區域的操作碼決定。

18、邏輯運算是所有計算機必須支持的運算。部分C、Java的運算符與MIPS指令的對應關係如下表:

第一種邏輯運算是位移(shift)。位移分爲左移(left shift,shift left)和右移(right shift,shift right)兩種。左移把所有二進制位向左移動若干位,低位補零。如果數據類型是定長的,那麼超出範圍的高位自動丟失。右移把所有二進制位向右移動若干位,越過最低位的部分自動丟失。位移分爲邏輯位移(logical shift)和算術位移(arithmetic shift)兩種。邏輯左移和算術左移的規則是一樣的;邏輯右移把空出的高位補0,算術位移把空出的高位補符號位。
MIPS的邏輯左移和邏輯右移指令分別是sll(shift left logical)和srl(shift right logical)。以左移指令爲例:
sll t2,t2,s0,4 # reg $t2 = reg $s0 << 4 bits
將其轉換爲機器指令是這樣的:

shamt存放了位移位數。rs區域無意義。
向左位移一位,相當於把原數乘以2;向右位移一位,相當於把原數除以2(整除)。類比一下:把十進制數左移一位相當於乘以10;把十進制數右移一位相當於除以10(整除)。

19、AND或&運算符稱爲按位與。如果這些運算符所在的段落採用拉丁字母(英文字母)書寫,那麼把這些運算符大寫,以免與英文連詞混淆。AND對兩個位數相同的數做運算,只有兩個數對應的位都爲1時,結果的這一位才爲1:

AND可以用兩個數強制把指定的位設爲0,因此有時候把其中一個操作數稱爲mask:mask“隱藏”了一些值爲1的位。
注意:如果將兩個位數不同的數做邏輯運算,那麼位數較少的數的高位自動補0直到位數相同(無符號擴展,也稱零擴展(zero extension)),而不作帶符號擴展(sign extension)。
OR或|運算符稱爲按位或。類似地,兩個位數相同的數,對應的位只要有一個爲1,那麼結果的對應位就爲1。
NOT或~運算符是一元的,稱爲按位非(按位反、按位取反)。輸入一個數,結果中對應的位與這個數中對應的位取值相反(0變1或1變0)。
XOR(exclusive or)或^運算符稱爲按位異或。輸入兩個位數相同的數,只有輸入的兩數的對應位不同時,結果的對應位才爲1。

20、所有的計算機及計算機語言都支持if條件語句。MIPS中,if語句對應的彙編指令可能是:beq、bne,等等。兩個指令分別表示branch if equal、branch if not equal。類似地,x86彙編中也有je jne jb ja jnb jna jbe jae jnbe jnae jg jng jge jnge jl jnl jle jnle等指令。其中b = below(無符號小於),a = above(無符號大於),n = not,e = equal,j = jump,g = greater(有符號大於),l = less(有符號小於)。這些語句稱爲條件分支(conditional branch),用於判斷輸入並根據輸入情況執行指定語句。
條件分支的指令格式是I型的。後16個bit存儲的是條件滿足時跳轉到的偏移地址(offset address)。基本上,所有的條件語句需要跳轉的位置都離當前指令不太遠,所以16個bit一般足夠。MIPS的每個數據的內存地址都按4的整數倍對齊,所以這16個bit可以覆蓋的跳轉範圍實際上達到218。

21、一個基本塊(basic block)是一段沒有分支(除了結束時)、沒有標號(除了開始時)的彙編語句。編譯的一個早期步驟就是要把程序分成若干個基本塊。

22、slt t0,t0,s3,$s4 # $t0 = 1 if $s3 < s4sltsetonlessthansltiMIPSsltsltibeqbne0s4 slt表示set on less than。該指令也有立即數版本slti。MIPS用slt、slti、beq、bne這四個指令和代表立即數0的寄存器zero來實現全部條件語句中的比較符號。MIPS並沒有branch on less than指令,因爲實現太複雜,而且勢必增加時鐘週期的長度(指令太慢),否則一條指令就需要額外的週期來執行(單條指令耗費的週期數必須是整數)。
比較指令必須支持有符號數的比較與無符號數的比較。slt、slti具有針對無符號數比較的變體sltu、sltiu。

23、很多計算機語言除了支持if-else語句以外還支持switch-case語句。實現switch-case語句的一個最簡單的辦法是把它們轉換成if-else語句。但還有一個更高效的方法:構造跳轉地址表(jump address table),也稱跳轉表(jump table)。跳轉表是一個線性表,裏面保存了一系列地址,地址與彙編語言中的標號(label)對應。程序把對應的地址讀入寄存器,在跳轉的時候,就跳至寄存器保存的地址。MIPS中的jr指令代表jump register,代表無條件跳轉至寄存器指定的地址。

24、過程(procedure)是程序中的一段具有特定用途的子程序,根據參數來執行。有的編程語言把過程也視作函數(function)。過程的引入使得編程變得結構化,並允許程序員在一段時間內把精力集中在程序中的範圍更小的部分或特定的層次,減小出錯機率,增加方便程度和程序的可讀性。
簡單來講,執行一個過程的步驟有6步:
(1)把參數放到過程可以訪問的位置(壓入棧中)。
(2)把控制權移交給過程。
(3)請求獲得執行過程需要的資源。
(4)開始執行。
(5)把返回值放到調用該過程的程序可以訪問的位置(壓入棧或放入寄存器)。
(6)把控制權交回調用該過程的程序。
MIPS用四個寄存器a0a0到a3傳遞參數,兩個寄存器v0v0、v1保存返回值,並用寄存器$ra保存返回地址(return address,指向調用過程的指令的下一條指令或該指令本身)。MIPS用指令jal調用一個過程。jal代表jump-and-link。link指的是把返回地址與過程關聯起來。

25、程序計數器(program counter,PC),或者把這個歷史遺留的名稱換成指令地址寄存器(instruction address register),一般指向下一條要執行的指令在內存中的位置(機器指令是保存在內存中的)。

26、在執行完過程以後,我們當然希望從調用過程之前的進度開始繼續執行。於是計算機引入了一個常用的數據結構——棧(stack)。棧是一種先進後出(last-in-first-out,LIFO)的數據結構,後被入棧(壓棧,push)的數據比後被先入棧的數據先出棧(pop)。棧指針(stack pointer)總是指向棧頂,即指向最後入棧的元素。由於歷史原因,棧總是從高地址向低地址生長(擴大)。也就是說當往棧中壓入數據時,棧指針的地址減小(向0x0方向,0x爲十六進制前綴)。在執行一個過程之前,需要把執行過程用到的寄存器原有的值壓入棧中,執行完畢後再恢復,於是程序得以從已有進度繼續。當參數較多以至於寄存器不能全部放下時,剩餘的參數就會放到棧中。

27、有的過程在執行中還會調用其它過程,甚至調用它自己,稱爲遞歸(recursion)。爲了使各個過程都能正確返回,在過程裏調用過程時,同樣要保存現場,也就是把被調用的過程需要用到的寄存器原有的值先壓入棧中,等這個深層的過程返回了,再恢復這些寄存器的值,繼續執行本層的過程。這些被保存的全體變量合成一個過程幀(procedure frame),也稱棧幀(stack frame)、活動記錄(activation record)。幀指針(frame pointer)指向一個過程保存在棧中的棧幀的第一個字。MIPS的幀指針保存在寄存器$fp中,而x86的幀指針是bp、ebp、rbp(16位 / 32位 / 64位下)。BP = base pointer。棧指針在過程的執行中可能會變化(新建局部變量(本地變量,local variable,也稱自動變量(automatic variable))、調用另外的過程),而新的棧幀被壓入棧時,這個棧幀保存了上一個棧幀的幀指針。從深層的過程返回後,這個幀指針要被恢復到寄存器中。幀指針和棧指針之間的範圍(包括指針本身所指的數據)就是一個過程的棧幀,包含了這個過程的局部變量、參數、返回值和返回地址。出棧時,棧指針不能超過幀指針,就保證了不會錯誤彈出本層以外的過程保存的數據。當然,並不是所有時候都有幀指針。例如MIPS提供的C編譯器不使用幀指針,GNU MIPS C編譯器則相反。

28、將遞歸的代碼改爲循環,可以提升性能。尾調用(tail call)是指一個過程(函數)的最後一個語句是調用一個過程(函數)的情形。如果最後的調用是調用了自己,則稱爲尾遞歸(tail recursion)。編譯器會針對尾調用,尤其是尾遞歸,進行深度優化,簡化了函數調用棧的結構,提升性能(降低時空複雜度(complexity))。尾遞歸會被編譯器優化成循環,也就是把調用的指令(例如call)和相應的返回指令直接改成跳轉指令(例如jmp)。當然,如果過程含有參數或者局部變量,就要額外做相應的調整,必須確保被調用函數的函數幀在跳過去之前已設置好。意即:若是調用棧除了返回位置以外還有參數或本地變量,編譯器需要輸出調整調用棧的相關指令。
下面是尾調用優化的一個例子:
void f() {
return a();
}
翻譯成x86彙編的結果大致是:
f:
call a
ret
消除尾部調用以後,就變成了:
f:
jmp a
在a函數完成的時候,它會直接返回到f的返回地址,省去了不必要的ret指令,也不用執行第24點中說的那幾步,在時間和空間上都表現得更優秀。

29、MIPS支持對半字(halfword)進行操作。不過MIPS的棧要求對齊,因此一個單字節或半字的數據在棧內仍然佔據4字節的空間。

30、MIPS的所有指令都是32位的。但是,如果需要參與運算的常數或地址也是32位的,指令就放不下。lui指令(load upper immediate)可以把常量的高16位放入寄存器中,跟隨lui指令的指令中包含該常量的低16位。

31、MIPS還有一種指令格式叫做J型(J-type)。J型指令前6個bit是操作域,而後26個bit是地址域:

使用該種格式的指令主要是跳轉指令j。條件語句需要跳轉的位置通常都不很遠,但是過程調用則不一定。可能需要跳轉到較遠的地方時,就用這種指令格式。MIPS的每個數據的內存地址都按4的整數倍對齊,所以這26個bit可以覆蓋的跳轉範圍實際上達到228。如果需要跳至更遠的範圍,就應該使用jr指令(跳至寄存器中指定的地址)了。
當條件分支需要跳轉到更遠的地方時,跳轉的目標地址就不能包含在一條指令裏。這時候,MIPS編譯器會把觸發分支的條件變爲相反條件,然後在分支指令之後添加一個無條件跳轉語句來實現更遠的跳轉:
beq s0,s0,s1,L1
這個指令表示當寄存器s0s0和s1相等時跳轉到標號L1處。如果L1距離當前指令所在的內存地址太遠,編譯器會把這條指令轉換成:
bne s0,s0,s1,L2
j L1
L2: # …
於是,當寄存器s0s0和s1不相等時,就跳轉到標號L2處,等效於轉換之前s0s0和s1不相等而不跳轉到L1處;如果s0s0和s1相等,就通過下一條j指令跳轉到L1處,也與原來等效。

32、反彙編(disassembly),指的是將機器語言轉換成彙編代碼的過程。

33、在併發編程中,常用的機制是互斥鎖(mutual exclusion,mutex)。互斥鎖被用來構建臨界區(critical section)。對應的鎖上鎖後,臨界區只能被一個線程訪問,以免不同的線程同時讀寫臨界區造成出錯。上鎖與解鎖操作是原子的(atomic),不能被調度器打斷。也就是說在執行原子操作期間,調度器不能進行上下文切換選擇其它線程繼續運行。

34、實現正確的併發也有其它機制。例如指令對load-linked和store-conditional。MIPS提供這一對指令,並且sc必須在ll前使用。這對指令還能用於實現其它機制,比如compare-and-swap和fetch-and-increment。
這兩個指令之間可以根據不同的要求插入不同的指令,但應該儘量少插入,防止失敗次數增多導致重試次數增多。而且在中間插入指令時要防止死鎖(deadlock)的產生。

35、一般而言,C / C++代碼會先翻譯爲彙編語言,再翻譯爲目標模塊,然後由鏈接器把目標模塊混合,生成計算機可以執行的代碼。有的編譯器會直接生成目標模塊而不先生成彙編,而有的系統使用鏈接加載器,在運行時進行鏈接並將程序載入內存。

36、僞指令(pseudoinstructions)是彙編語言中的一種指令。它們沒有對應的機器指令,只能被彙編器識別。僞指令爲編程帶來了方便,並且也能簡化彙編器的翻譯過程。
MIPS會將僞指令move轉換成add,將blt(branch on less than)轉換成slt和bne。類似的例子還有bgt、bge和ble。x86彙編的常見僞指令有assume, segment, ends, end。

37、彙編器首先生成目標文件(object file)。目標文件包含一系列機器指令、數據和一些爲了將程序正確放在內存而需要的信息。彙編器在彙編過程中需要把源程序中的標號(例如分支和跳轉指令中出現的標號或代表一段數據的地址的標號)及其地址等內容收集並記錄爲符號表(symbol table)。
UNIX的目標文件一般包含六部分:
(1)目標文件頭(object file header),表述目標文件其它部分的大小與位置。
(2)文本段(text segment),包含機器碼。
(3)靜態數據段(static data segment),包含伴隨程序的整個生命過程的數據。
(4)重定位信息(relocation information),指示依賴絕對地址的指令和數據在內存中的位置。
(5)符號表(symbol table),包含未定義的剩餘標號,例如對外部文件的內容的引用。
(6)調試信息(debugging information),包含的信息指示了這些模塊是如何編譯的,方便調試器將機器指令與C / C++的源文件關聯起來。
以下是一個目標文件的示例。爲了方便閱讀,機器指令在這張表中被寫成彙編指令。實際上對應位置存儲的應該是機器指令的二進制碼而不是助記符(彙編語句)。過程A需要確定lw指令需要用到的X的地址,還需要找到jal指令需要用到的過程B的地址。

38、有時候我們只是將程序中的少量語句簡單修改了。如果將整個程序重新編譯,是非常麻煩、非常浪費計算資源和時間的。一個可行的替代方案是:只重新編譯改變的語句所在的過程。爲了實現這個方案,需要用到鏈接器,全名是鏈接編輯器(link editor)。
鏈接器鏈接目標文件主要分三步:
(1)將代碼和數據模塊符號化並存入內存。
(2)確定數據和指令標號的地址。
(3)處理內部引用和外部引用。
解決外部引用後,鏈接器就要確定每個模塊在內存中佔據的位置。當把模塊裝入內存時,就要確定其絕對地址。

39、加載器(loader)負責將準備運行的程序放入內存。UNIX的加載器將程序載入內存時需要執行如下6步:
(1)讀取可執行文件的文件頭,確定文本段和數據段的大小。
(2)創建一個足夠大的地址空間。
(3)將指令和數據複製到內存中。
(4)將入口函數的參數複製到棧中。
(5)初始化機器的寄存器,設定棧指針。
(6)跳至入口地址開始執行。當執行完畢後,通過系統調用exit來終止程序。

40、如果在生成可執行文件的過程中,將代碼需要用到的庫都鏈接好,雖然可以讓對庫的調用很快,但也有如下缺點:
(1)庫成爲了代碼的一部分。如果調用的庫更新了,最終編譯好的應用程序使用的庫仍然是舊版本。
(2)即使只用到一個庫中的少量內容,也需要把整個庫鏈接起來,導致最終生成的可執行文件很大。例如:將整個C標準庫(standard C library)都鏈接到程序中,程序就要多出2.5 MB。如果把計算機裏的這麼多個可執行文件都鏈接C標準庫,增加的大小可想而知。
爲了解決這些問題,動態鏈接庫(dynamically linked library,DLL)誕生了。DLL要配合動態鏈接器使用。當程序運行時,才使用可執行文件和庫裏的額外信息,將需要用到的庫鏈接起來。最開始的DLL依然一次性將所有的可能需要用到的庫與運行的程序鏈接。後來的DLL在程序運行時才進行鏈接。
當庫函數被調用時,正在運行的程序先調用位於程序末端的“假入口”(用於進行間接跳轉),跳至一段代碼。這段代碼將用於標識調用的庫函數的值放入寄存器,然後跳至動態鏈接器。動態鏈接器找到需要調用的庫函數,將其重映射(以此避免直接將整個庫函數複製),然後將跳轉位置指向庫函數,再跳至庫函數並開始執行。當庫函數完成後,返回調用庫函數的位置繼續執行剩餘的代碼。如果在程序的本次運行期間再度調用這個庫函數,就可以直接跳至已經找到的庫函數的位置了。
Windows高度依賴DLL,UNIX系統亦然。

41、以C / C++爲代表的傳統計算機語言及計算機程序的運行模型注重高速,是高度針對特定的ISA甚至高度針對使用該ISA的計算機的。而Java等語言則不同。Java的一個主要的設計初衷是在不同的計算機上都能安全運行,即使是爲此降低運行速度。
Java編寫的程序在運行之前不編譯爲目標計算機的彙編語言,而是先編譯成Java字節碼(Java bytecode)指令集。這套指令集使得最後的編譯過程變得簡化。與C / C++編譯器一樣,Java編譯器在編譯時也會檢查數據類型,並根據數據類型正確選擇與數據類型匹配的操作。Java虛擬機(Java Virtual Machine,JVM)是軟件解釋器,用於執行字節碼。解釋器能模擬一套ISA。特定ISA的CPU的模擬器,例如MIPS模擬器,也是一種解釋器。
用解釋器執行代碼的一個優勢是可移植性(portability)。如今,手機和瀏覽器都要運行Java,運行Java的設備數需要以億爲單位來統計。但是使用解釋器來執行會降低性能,因爲代碼並沒有針對ISA進行編譯,也就是說沒有進行非常充分的優化。因此在高性能計算(High performance computing,HPC)等領域,C / C++等編譯型語言依然佔有絕對優勢。
爲了讓Java的速率有所提高,Java代碼中的一些常用的方法(函數)會被即時編譯器(Just in time compiler,JIT)在運行時編譯爲機器指令。運行結束後,這些編譯好的指令會被保存下來,以提升以後運行該程序的性能。

42、C / C++編譯器提供了不同的優化等級。常用的優化級別爲-O2。-O3雖然優化得更快,但是生成的可執行文件比較大,而且可能存在較多的Bug。-O2優化相比不優化,有時候可以令程序的總體性能提升超過100%。-Os則針對可執行文件的大小進行優化。

43、在訪問數組的時候,有兩種方法:一種是通過數組名稱(代表首地址)和下標訪問,一種是通過指針訪問。例如下面這兩個函數:
clear1(int array[], int size) {
int i;
for (i = 0; i < size; i += 1)
array[i] = 0;
}
clear2(int* array, int size) {
int* p;
for (p = &array[0]; p < &array[size]; p = p + 1)
*p = 0;
}
如果採用第一種方法,訪問數組時需要根據下標來計算地址,理論上每次循環需要執行的指令數更多。所以程序員們曾被建議:“多用指針,即使代碼變得讓你看不懂。”當然,隨着計算機的發展,較新的編譯器已經能對下標訪問數組進行充分優化,使得最終的性能不比使用指針訪問數組差多少。而且,計算機性能的提高讓這兩種方法的性能差距在一般情況下可以忽略不計。但是,在一些極端情況下,比如數組的維數較多,或者循環次數非常大,那麼訪問數組中的元素時也許有必要使用指針訪問來代替下標訪問。在對性能要求極高的場合(比如高頻交易和高性能計算),一些細微的優化步驟不可以省略。

44、ARM指令集架構和MIPS很像,主要的區別是MIPS擁有更多寄存器,而ARM擁有更多的尋址方式(addressing mode)。ARMv7的幾乎所有指令的高4位都是條件碼(condition code),用於決定每條指令在何種條件下執行。因此ARMv7沒有專門的用於實現程序的分支結構的指令。(下表中,GPR代表通用寄存器(general purpose register))

當只用12-bit表示立即數時,ARM處理器能夠接受的立即數的範圍仍然是0到232 – 1。編譯時會進行檢查,如果輸入的立即數不能用低8位循環右移(rotate right)高4位表示的數的兩倍得到,就認爲這個立即數非法,直接報錯。循環右移會將右移過程中越過最低位的數重新放到最高位。ARMv7處理器沒有循環左移(rotate left)指令。ARMv7這種表示方法正好能夠讓2的0到31次冪都是合法的立即數。

ARMv7還具有按塊取(block load)和按塊存(block store)指令,允許用一條指令同時讀寫多個寄存器。這兩種指令非常有用,不但可以用於過程的開始和結束之前將一些寄存器一同入棧或出棧,還可以用於內存數據的成塊複製。

45、下面梳理x86 ISA的發展歷程上的幾個重大事件:
·1978年,Intel推出了8086 CPU。它是16位的,用於接替8位CPU——8080,兼容彙編語言。但與MIPS不同,8086的許多寄存器都有專門的用途,因此8086的架構不被視爲通用寄存器架構。
·1980年,Intel推出了8087浮點協處理器(coprocessor)。8087用於爲8086擴展60條浮點指令。但8087不具備寄存器,而使用棧。
·1982年,8086的繼任者80286上市了。其支持的地址空間(address space)範圍提升到了24-bit,並通過一個精心設計的內存映射保護模型實現內存保護機制。80286也具備與這個模型相關的指令集。
·1985年,80386接替了80286。這是Intel的首個32位處理器,寄存器和地址空間都是32位的。80386提供了新的運算和尋址方式。新的指令讓80386接近通用寄存器架構。除了段(segment)機制,80386還支持內存的分頁(paging)機制。當然,80386提供專門的模式用於執行8086時期的程序,而無需程序員對代碼做修改。80386只有8個通用寄存器,MIPS有32個,而ARMv7有16個。
·1989年到1995年,Intel又在1989年推出了80486,亦於1992、1995年分別推出了奔騰(Pentium)和奔騰Pro(Pentium Pro)系列處理器。在用戶可見的指令範圍內,新處理器新增了4條指令:3條用於輔助多道程序處理(multiprocessing),1條用於條件移動(move)指令。
·1997年,Pentium和Pentium Pro系列的新處理器添加了MMX(Multi Media Extension,多媒體擴展)指令集。MMX指令集擁有57條新指令,通過浮點棧來加速多媒體應用程序。MMX一般同時運算多個數據,支持這種運算的架構稱爲單指令多數據(SIMD)架構,將在第6章進一步學習。後來的Pentium II未新增任何指令。
·1999年,Intel添加了70條新指令,歸爲SSE(流式SIMD擴展,Streaming SIMD Extensions)指令集。這是Pentium III的一部分,最大改變是增加了8個新的128-bit專用寄存器(xmm0到xmm7,詳見第5點),所以一次最多可以做4個32-bit的浮點數參與的運算(每個運算的每個操作數佔用寄存器的1 / 4空間)。爲了提升內存性能,SSE還包括緩存預讀指令和流式存儲指令,可以繞過緩存直接寫入內存。
·2001年,Intel推出了新增144條指令的SSE2指令集。SSE2是針對雙精度計算的,允許每週期同時執行2個64-bit浮點運算。幾乎所有新增的指令都是已有的MMX和SSE指令集中進行雙精度浮點運算的並行版本。SSE2不但爲多媒體應用提升了更多性能,編譯器在編譯時還可選用8個SSE寄存器作爲浮點寄存器,與CPU中的其它寄存器一起使用。SSE2指令集使得首次使用它的Pentium 4的浮點性能顯著提升。
·2003年,AMD發佈了一組新的架構擴展,將地址空間和寄存器寬度擴大到64位,該架構稱爲AMD64。64位架構具有翻倍數量(16個)的SSE寄存器。AMD64的主要改進是使所有x86指令都能使用64-bit的地址和數據。爲了定位更多數量的寄存器,AMD64爲指令增加了新前綴,以及4條新指令,並棄用27條舊指令。PC(程序計數器)相對的數據定址是另一項新引入的擴展。AMD64支持讓64位操作系統中的程序運行在32位的環境。這種方式使得從32位到64位的過度比Intel的IA-64更平滑。
·2004年,Intel在IA-64架構的Itanium(安騰,不兼容原有的x86)上持續失利,最後選擇引入AMD64架構,Intel將其稱爲EM64T(Extended memory 64 technology)。但AMD64和EM64T的一個主要不同是:EM64T增加了128-bit的compare-and-swap原子指令。這一年,Intel也發佈了SSE3指令集。SSE3增加了13條指令,用於支持複數計算、在數組上進行的圖形運算、視頻編碼、浮點轉換和線程同步。AMD後來也支持了SSE3和compare-and-swap原子指令,這使得AMD和Intel的處理器在二進制層面上保持兼容。
·2006年,Intel發佈了添加了54條新指令的SSE4指令集。新的指令主要加速計算絕對值差的和(sum of absolute difference,SAD)、點積、無 / 帶符號擴展,以及虛擬機。
·2007年,AMD發佈新增170條指令的SSE5指令集,其中46條指令支持三操作數。
·2008年,Intel推出了Larrabee架構的GPGPU(通用GPU,general-purpose GPU),支持新的AVX指令集(Advanced vector extensions,高級向量擴展),將專用寄存器寬度從SSE時代的128位提升至256位(僅增加對256位浮點的SIMD支持),重寫了約250條指令,新增128條指令。後來AMD放棄支持SSE5,轉而支持AVX。
·2011年,Intel發佈AVX2指令集。AVX2增加了整數SIMD的256位支持。Intel同年發佈了Sandy Bridge架構的CPU,支持AVX;2013年起,Intel陸續推出Haswell架構的的CPU,支持AVX2。
·2013年,Intel發佈了AVX-512指令集。2017年的Skylake-SP服務器CPU支持AVX-512,而桌面級(消費級)CPU依然只支持到AVX2。
如今,雖然x86芯片的出貨量遠遠不及ARM,但是x86 CPU仍然在服務器、雲與高性能計算領域佔有壟斷性地位。當然,越來越多的巨頭選擇了自主研發ARM架構的服務器CPU。較新的ARM CPU也可以支持類似AVX的指令集(由獲得相應架構的授權的廠商決定),它被稱爲SVE(Scalable Vector Extensions)。

46、如果你的學校的彙編語言課程使用x86的CPU作爲教學平臺,你應當發現:x86架構的指令中,雙操作數的指令的其中一個寄存器既是源寄存器又是目標寄存器,而且其中一個操作數可以不是寄存器而來自內存。

這兩幅圖顯示了80386的指令中操作數的類型限制。對二元運算,其中一個數可以是內存中的數或立即數,兩個數也可以都不來自寄存器,但不允許兩個數都來自內存。
80386的指令支持的尋址方式包括:
(1)寄存器間接尋址。地址保存在寄存器中,直接取用。
(2)基址+偏移。實際地址是基址+偏移地址。
(3)基址+放大的索引。根據數據類型(長度),數據的首地址爲基址+下標×某個倍數。這是訪問數組的尋址方式。
(4)基址+放大的索引+偏移。偏移和索引的意義分別與(2)(3)中的相同。
80386借鑑了8086的設計思路,通過爲指令添加前綴來改變指令的具體行爲。例如不同的指令前綴在80386中代表對不同的數據類型進行運算。
x86整數操作一般分爲以下四類:
(1)數據傳輸指令,如賦值、進棧、出棧。
(2)算術與邏輯指令。例如整數和浮點運算、與或非、移位。
(3)控制指令,例如條件分支、無條件跳轉、調用和返回。
(4)字符串指令,例如字符串的賦值與比較。
x86也像ARM那樣具有條件碼,又稱標誌(flag)。條件碼主要用於刻畫運算的副作用,例如結果的正、零或負,是否發生了進位或溢出等。條件語句通過讀取標誌寄存器中相應的位來判斷條件是否成立。兩個數比較的結果也會被存入標誌寄存器,條件跳轉指令可以通過讀取標誌寄存器來判斷是否達到跳轉條件。
x86的指令是變長的,不像ARMv7和MIPS那樣固定爲32位長(ARMv7的A32指令集是定長的,但T32指令集是變長的,有的指令是16位):

變長指令使得x86指令的格式繁多,編碼困難。雖然80386是1985年的產品,其設計的複雜度不可與當今的處理器相提並論,但它的指令長度仍然可以橫跨1 Byte到15 Bytes。操作碼刻畫了指令的類型和其它必要信息,比如操作數是8-bit、16-bit還是32-bit,尋址方式,寄存器名稱,等等。下圖列舉了幾個常用指令的編碼格式。

47、x86是最早將32-bit擴展到64-bit的,一個重要原因就是16-bit或32-bit的CPU支持的內存容量太小。這點改進遠遠早於它的競爭對手。1977年的Apple II計算機採用的是MOStek 6502,其地址線只有16位,也就是說採用這個CPU的計算機的內存最大不超過64 KB。雖然Apple II作爲首臺上市的個人計算機非常成功,但由於支持的內存容量太小,Apple II很快被掃進了歷史的垃圾堆。
ARM從2007年開始設計64-bit版本的ARM CPU,最終於2013年正式發佈,它們的指令集爲ARMv8。與x86從32位邁向64位不同,ARMv7到ARMv8是一次大改。
首先,ARMv8指令放棄瞭如下的特性:
(1)指令中的高4位條件碼。
(2)立即數是簡單的12-bit(或由這12-bit左移而得),不再像ARMv7那樣需要將這12-bit通過循環右移轉換爲一個合法的立即數。當然,有的指令留給立即數的位數更多。
(3)不具有讀寫多個寄存器的Load Multiple和Store Multiple指令。
(4)PC不再是寄存器。如果嘗試寫入到PC,會發生未定義的結果。
其次,ARMv8也添加了類似MIPS的新特性:
(1)ARMv8 CPU具有32個通用寄存器,並具有一個專門的寄存器代表0這個數值。
(2)尋址支持不同的數據類型。
(3)ARMv8增加了專門的除法指令。ARMv7是沒有除法指令的,除法被做成一個函數。
(4)提供了MIPS中存在的branch if equal和branch if not equal指令。
ARMv8很接近MIPS,因此我們說ARMv7和ARMv8的相似點大概只有名字了。

48、功能越強的指令不一定性能越高。例如有兩條向內存中寫數據的指令(先把數據讀入寄存器再複製到內存中的另一個位置)。現在用循環執行這一條指令若干次,速度就沒有將循環展開更快。因爲執行循環相關的指令(例如loop)本身也是要耗時的。不過,較新的CPU也許會使用比較寬的寄存器同時傳送多個數據,這樣就更快了。

49、使用匯編語言編寫代碼不一定能實現最高性能。以前,編譯器生成的彙編代碼與手寫的彙編代碼的性能確實存在較大差距;但是隨着編譯器的飛速發展,許多時候編譯器生成的代碼反而比手寫的彙編要快。C / C++中的register修飾符令用戶可以手動指定哪些變量要放入寄存器。以往的編譯器不能很好地將合適的變量放入寄存器來發揮最佳性能,但是現在的編譯器很多都能做到了。所以大量的編譯器會直接忽略這個修飾。
必須承認,許多底層代碼,例如驅動(driver)和操作系統的部分代碼,依然有人用匯編語言書寫,但是這樣的人已經很少了。使用匯編語言實現同樣的功能花費的時間遠遠長於使用高級語言。而且彙編語言是高度針對一個平臺的,不像高級語言那樣,只要使用的基本上是跨平臺性好的代碼,僅通過選擇不同平臺(x86 / ARM / MIPS等)的編譯器就可以生成跨平臺的應用程序。而且,程序發佈以後,隨着硬件的更迭,有必要對代碼進行新的調整;發現新的Bug時,也需要修復。如果程序是彙編語言編寫的,維護的工作就非常繁重。

50、爲了在商業上保持對舊機器的兼容性,成功的指令集不一定不作任何修改。下表告訴我們,x86指令集在發佈後的35年以來,平均每個月添加超過1條新指令。

直到今天,x86仍然在增加新的指令。例如改進多線程的TSX-NI,以及深度學習常用的BFloat16數據類型相關的指令集。非常古老的指令集也有被棄用的,例如3DNow!指令集在新的Intel CPU中已經不再支持。也許MMX也差不多完成了它的使命,不再於數年後的新CPU中提供。

51、注意:下一個字,或者下一個數據在內存中的首地址,不一定是當前數據的地址+1。很多程序員都踩過這個坑。

52、避免在局部變量的作用範圍外定義指向局部變量的指針。因爲過程返回後局部變量會被釋放(清除),此時外部的指向原有局部變量的指針成爲了野指針,對野指針訪問會出錯。
在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述
在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章