Go 語言 計算機結構

2023-03-22 15:01 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-02-arch.html


3.2 計算機結構

匯編語言是直面計算機的編程語言,因此理解計算機結構是掌握匯編語言的前提。當前流行的計算機基本采用的是馮·諾伊曼計算機體系結構(在某些特殊領域還有哈佛體系架構)。馮·諾依曼結構也稱為普林斯頓結構,采用的是一種將程序指令和數據存儲在一起的存儲結構。馮·諾伊曼計算機中的指令和數據存儲器其實指的是計算機中的內存,然后在配合 CPU 處理器就組成了一個最簡單的計算機了。

匯編語言其實是一種非常簡單的編程語言,因為它面向的計算機模型就是非常簡單的。讓人覺得匯編語言難學主要有幾個原因:不同類型的 CPU 都有自己的一套指令;即使是相同的 CPU,32 位和 64 位的運行模式依然會有差異;不同的匯編工具同樣有自己特有的匯編指令;不同的操作系統(tǒng)和高級編程語言和底層匯編的調用規(guī)范并不相同。本節(jié)將描述幾個有趣的匯編語言模型,最后精簡出一個適用于 AMD64 架構的精簡指令集,以便于 Go 匯編語言的學習。

3.2.1 圖靈機和 BF 語言

圖靈機是由圖靈提出的一種抽象計算模型。機器有一條無限長的紙帶,紙帶分成了一個一個的小方格,每個方格有不同的顏色,這類似于計算機中的內存。同時機器有一個探頭在紙帶上移來移去,類似于通過內存地址來讀寫內存上的數據。機器頭有一組內部計算狀態(tài),還有一些固定的程序(更像一個哈佛結構)。在每個時刻,機器頭都要從當前紙帶上讀入一個方格信息,然后根據自己的內部狀態(tài)和當前要執(zhí)行的程序指令將信息輸出到紙帶方格上,同時更新自己的內部狀態(tài)并進行移動。

圖靈機雖然不容易編程,但是非常容易理解。有一種極小化的 BrainFuck 計算機語言,它的工作模式和圖靈機非常相似。BrainFuck 由 Urban Müller 在 1993 年創(chuàng)建的,簡稱為 BF 語言。Müller 最初的設計目標是建立一種簡單的、可以用最小的編譯器來實現的、符合圖靈完全思想的編程語言。這種語言由八種狀態(tài)構成,早期為 Amiga 機器編寫的編譯器(第二版)只有 240 個字節(jié)大?。?/p>

就象它的名字所暗示的,brainfuck 程序很難讀懂。盡管如此,brainfuck 圖靈機一樣可以完成任何計算任務。雖然 brainfuck 的計算方式如此與眾不同,但它確實能夠正確運行。這種語言基于一個簡單的機器模型,除了指令,這個機器還包括:一個以字節(jié)為單位、被初始化為零的數組、一個指向該數組的指針(初始時指向數組的第一個字節(jié))、以及用于輸入輸出的兩個字節(jié)流。這是一種按照圖靈完備的語言,它的主要設計思路是:用最小的概念實現一種 “簡單” 的語言。BrainFuck 語言只有八種符號,所有的操作都由這八種符號的組合來完成。

下面是這八種狀態(tài)的描述,其中每個狀態(tài)由一個字符標識:

字符 C 語言類比 含義
> ++ptr; 指針加一
< --ptr; 指針減一
+ ++*ptr; 指針指向的字節(jié)的值加一
- --*ptr; 指針指向的字節(jié)的值減一
. putchar(*ptr); 輸出指針指向的單元內容(ASCⅡ 碼)
, *ptr = getch(); 輸入內容到指針指向的單元(ASCⅡ 碼)
[ while(*ptr) {} 如果指針指向的單元值為零,向后跳轉到對應的 ] 指令的次一指令處
] 如果指針指向的單元值不為零,向前跳轉到對應的 [ 指令的次一指令處

下面是一個 brainfuck 程序,向標準輸出打印 "hi" 字符串:

++++++++++[>++++++++++<-]>++++.+.

理論上我們可以將 BF 語言當作目標機器語言,將其它高級語言編譯為 BF 語言后就可以在 BF 機器上運行了。

3.2.2 人力資源機器游戲

《人力資源機器》(Human Resource Machine)是一款設計精良匯編語言編程游戲。在游戲中,玩家扮演一個職員角色,來模擬人力資源機器的運行。通過完成上司給的每一份任務來實現晉升的目標,完成任務的途徑就是用游戲提供的 11 個機器指令編寫正確的匯編程序,最終得到正確的輸出結果。人力資源機器的匯編語言可以認為是跨平臺、跨操作系統(tǒng)的通用的匯編語言,因為在 macOS、Windows、Linux 和 iOS 上該游戲的玩法都是完全一致的。

人力資源機器的機器模型非常簡單:INBOX 命令對應輸入設備,OUTBOX 對應輸出設備,玩家小人對應一個寄存器,臨時存放數據的地板對應內存,然后是數據傳輸、加減、跳轉等基本的指令??偣灿?11 個機器指令:

名稱 解釋
INBOX 從輸入通道取一個整數數據,放到手中 (寄存器)
OUTBOX 將手中(寄存器)的數據放到輸出通道,然后手中將沒有數據(此時有些指令不能運行)
COPYFROM 將地板上某個編號的格子中的數據復制到手中(手中之前的數據作廢),地板格子必須有數據
COPYTO 將手中(寄存器)的數據復制到地板上某個編號的格子中,手中的數據不變
ADD 將手中(寄存器)的數據和某個編號對應的地板格子的數據相加,新數據放到手中(手中之前的數據作廢)
SUB 將手中(寄存器)的數據和某個編號對應的地板格子的數據相減,新數據放到手中(手中之前的數據作廢)
BUMP+ 自加一
BUMP- 自減一
JUMP 跳轉
JUMP =0 為零條件跳轉
JUMP <0 為負條件跳轉

除了機器指令外,游戲中有些環(huán)節(jié)還提供類似寄存器的場所,用于存放臨時的數據。人力資源機器游戲的機器指令主要分為以下幾類:

  • 輸入/輸出 (?INBOX?/?OUTBOX?): 輸入后手中將只有 1 份新拿到的數據, 輸出后手中將沒有數據。
  • 數據傳輸指令 (?COPYFROM?/?COPYTO?): 主要用于僅有的 1 個寄存器(手中)和內存之間的數據傳輸,傳輸時要確保源數據是有效的
  • 算術相關 (?ADD?/?SUB?/?BUMP+?/?BUMP-?)
  • 跳轉指令: 如果是條件跳轉,寄存器中必須要有數據

主流的處理器也有類似的指令。除了基本的算術和邏輯運算指令外,再配合有條件跳轉指令就可以實現分支、循環(huán)等常見控制流結構了。

下圖是某一層的任務:將輸入數據的 0 剔除,非 0 的數據依次輸出,右邊部分是解決方案。


圖 3-1 人力資源機器

整個程序只有一個輸入指令、一個輸出指令和兩個跳轉指令共四個指令:

LOOP:
    INBOX
    JUMP-if-zero LOOP
    OUTBOX
    JUMP LOOP

首先通過 INBOX 指令讀取一個數據包;然后判斷包裹的數據是否為 0,如果是 0 的話就跳轉到開頭繼續(xù)讀取下一個數據包;否則將輸出數據包,然后再跳轉到開頭。以此循環(huán)無休止地處理數據包裹,直到任務完成晉升到更高一級的崗位,然后處理類似的但更復雜的任務。

3.2.3 X86-64 體系結構

X86 其實是是 80X86 的簡稱(后面三個字母),包括 Intel 8086、80286、80386 以及 80486 等指令集合,因此其架構被稱為 x86 架構。x86-64 是 AMD 公司于 1999 年設計的 x86 架構的 64 位拓展,向后兼容于 16 位及 32 位的 x86 架構。X86-64 目前正式名稱為 AMD64,也就是 Go 語言中 GOARCH 環(huán)境變量指定的 AMD64。如果沒有特殊說明的話,本章中的匯編程序都是針對 64 位的 X86-64 環(huán)境。

在使用匯編語言之前必須要了解對應的 CPU 體系結構。下面是 X86/AMD 架構圖:


圖 3-2 AMD64 架構

左邊是內存部分是常見的內存布局。其中 text 一般對應代碼段,用于存儲要執(zhí)行指令數據,代碼段一般是只讀的。然后是 rodata 和 data 數據段,數據段一般用于存放全局的數據,其中 rodata 是只讀的數據段。而 heap 段則用于管理動態(tài)的數據,stack 段用于管理每個函數調用時相關的數據。在匯編語言中一般重點關注 text 代碼段和 data 數據段,因此 Go 匯編語言中專門提供了對應 TEXT 和 DATA 命令用于定義代碼和數據。

中間是 X86 提供的寄存器。寄存器是 CPU 中最重要的資源,每個要處理的內存數據原則上需要先放到寄存器中才能由 CPU 處理,同時寄存器中處理完的結果需要再存入內存。X86 中除了狀態(tài)寄存器 FLAGS 和指令寄存器 IP 兩個特殊的寄存器外,還有 AX、BX、CX、DX、SI、DI、BP、SP 幾個通用寄存器。在 X86-64 中又增加了八個以 R8-R15 方式命名的通用寄存器。因為歷史的原因 R0-R7 并不是通用寄存器,它們只是 X87 開始引入的 MMX 指令專有的寄存器。在通用寄存器中 BP 和 SP 是兩個比較特殊的寄存器:其中 BP 用于記錄當前函數幀的開始位置,和函數調用相關的指令會隱式地影響 BP 的值;SP 則對應當前棧指針的位置,和棧相關的指令會隱式地影響 SP 的值;而某些調試工具需要 BP 寄存器才能正常工作。

右邊是 X86 的指令集。CPU 是由指令和寄存器組成,指令是每個 CPU 內置的算法,指令處理的對象就是全部的寄存器和內存。我們可以將每個指令看作是 CPU 內置標準庫中提供的一個個函數,然后基于這些函數構造更復雜的程序的過程就是用匯編語言編程的過程。

3.2.4 Go 匯編中的偽寄存器

Go 匯編為了簡化匯編代碼的編寫,引入了 PC、FP、SP、SB 四個偽寄存器。四個偽寄存器加其它的通用寄存器就是 Go 匯編語言對 CPU 的重新抽象,該抽象的結構也適用于其它非 X86 類型的體系結構。

四個偽寄存器和 X86/AMD64 的內存和寄存器的相互關系如下圖:


圖 3-3 Go 匯編的偽寄存器

在 AMD64 環(huán)境,偽 PC 寄存器其實是 IP 指令計數器寄存器的別名。偽 FP 寄存器對應的是函數的幀指針,一般用來訪問函數的參數和返回值。偽 SP 棧指針對應的是當前函數棧幀的底部(不包括參數和返回值部分),一般用于定位局部變量。偽 SP 是一個比較特殊的寄存器,因為還存在一個同名的 SP 真寄存器。真 SP 寄存器對應的是棧的頂部,一般用于定位調用其它函數的參數和返回值。

當需要區(qū)分偽寄存器和真寄存器的時候只需要記住一點:偽寄存器一般需要一個標識符和偏移量為前綴,如果沒有標識符前綴則是真寄存器。比如 (SP)+8(SP) 沒有標識符前綴為真 SP 寄存器,而 a(SP)b+8(SP) 有標識符為前綴表示偽寄存器。

3.2.5 X86-64 指令集

很多匯編語言的教程都會強調匯編語言是不可移植的。嚴格來說匯編語言是在不同的 CPU 類型、或不同的操作系統(tǒng)環(huán)境、或不同的匯編工具鏈下是不可移植的,而在同一種 CPU 中運行的機器指令是完全一樣的。匯編語言這種不可移植性正是其普及的一個極大的障礙。雖然 CPU 指令集的差異是導致不好移植的較大因素,但是匯編語言的相關工具鏈對此也有不可推卸的責任。而源自 Plan9 的 Go 匯編語言對此做了一定的改進:首先 Go 匯編語言在相同 CPU 架構上是完全一致的,也就是屏蔽了操作系統(tǒng)的差異;同時 Go 匯編語言將一些基礎并且類似的指令抽象為相同名字的偽指令,從而減少不同 CPU 架構下匯編代碼的差異(寄存器名字和數量的差異是一直存在的)。本節(jié)的目的也是找出一個較小的精簡指令集,以簡化 Go 匯編語言的學習。

X86 是一個極其復雜的系統(tǒng),有人統(tǒng)計 x86-64 中指令有將近一千個之多。不僅僅如此,X86 中的很多單個指令的功能也非常強大,比如有論文證明了僅僅一個 MOV 指令就可以構成一個圖靈完備的系統(tǒng)。以上這是兩種極端情況,太多的指令和太少的指令都不利于匯編程序的編寫,但是也從側面體現了 MOV 指令的重要性。

通用的基礎機器指令大概可以分為數據傳輸指令、算術運算和邏輯運算指令、控制流指令和其它指令等幾類。因此我們可以嘗試精簡出一個 X86-64 指令集,以便于 Go 匯編語言的學習。

因此我們先看看重要的 MOV 指令。其中 MOV 指令可以用于將字面值移動到寄存器、字面值移到內存、寄存器之間的數據傳輸、寄存器和內存之間的數據傳輸。需要注意的是,MOV 傳輸指令的內存操作數只能有一個,可以通過某個臨時寄存器達到類似目的。最簡單的是忽略符號位的數據傳輸操作,386 和 AMD64 指令一樣,不同的 1、2、4 和 8 字節(jié)寬度有不同的指令:

Data Type 386/AMD64 Comment
[1]byte MOVB B => Byte
[2]byte MOVW W => Word
[4]byte MOVL L => Long
[8]byte MOVQ Q => Quadword

MOV 指令它不僅僅用于在寄存器和內存之間傳輸數據,而且還可以用于處理數據的擴展和截斷操作。當數據寬度和寄存器的寬度不同又需要處理符號位時,386 和 AMD64 有各自不同的指令:

Data Type 386 AMD64 Comment
int8 MOVBLSX MOVBQSX sign extend
uint8 MOVBLZX MOVBQZX zero extend
int16 MOVWLSX MOVWQSX sign extend
uint16 MOVWLZX MOVWQZX zero extend

比如當需要將一個 int64 類型的數據轉為 bool 類型時,則需要使用 MOVBQZX 指令處理。

基礎算術指令有 ADD、SUBMUL、DIV 等指令。其中 ADD、SUBMUL、DIV 用于加、減、乘、除運算,最終結果存入目標寄存器。基礎的邏輯運算指令有 AND、OR 和 NOT 等幾個指令,對應邏輯與、或和取反等幾個指令。

名稱 解釋
ADD 加法
SUB 減法
MUL 乘法
DIV 除法
AND 邏輯與
OR 邏輯或
NOT 邏輯取反

其中算術和邏輯指令是順序編程的基礎。通過邏輯比較影響狀態(tài)寄存器,再結合有條件跳轉指令就可以實現更復雜的分支或循環(huán)結構。需要注意的是 MUL 和 DIV 等乘除法指令可能隱含使用了某些寄存器,指令細節(jié)請查閱相關手冊。

控制流指令有 CMPJMP-if-x、JMPCALL、RET 等指令。CMP 指令用于兩個操作數做減法,根據比較結果設置狀態(tài)寄存器的符號位和零位,可以用于有條件跳轉的跳轉條件。JMP-if-x 是一組有條件跳轉指令,常用的有 JL、JLZ、JEJNE、JG、JGE 等指令,對應小于、小于等于、等于、不等于、大于和大于等于等條件時跳轉。JMP 指令則對應無條件跳轉,將要跳轉的地址設置到 IP 指令寄存器就實現了跳轉。而 CALL 和 RET 指令分別為調用函數和函數返回指令。

名稱 解釋
JMP 無條件跳轉
JMP-if-x 有條件跳轉,JL、JLZJE、JNEJG、JGE
CALL 調用函數
RET 函數返回

無條件和有條件調整指令是實現分支和循環(huán)控制流的基礎指令。理論上,我們也可以通過跳轉指令實現函數的調用和返回功能。不過因為目前函數已經是現代計算機中的一個最基礎的抽象,因此大部分的 CPU 都針對函數的調用和返回提供了專有的指令和寄存器。

其它比較重要的指令有 LEA、PUSHPOP 等幾個。其中 LEA 指令將標準參數格式中的內存地址加載到寄存器(而不是加載內存位置的內容)。PUSH 和 POP 分別是壓棧和出棧指令,通用寄存器中的 SP 為棧指針,棧是向低地址方向增長的。

名稱 解釋
LEA 取地址
PUSH 壓棧
POP 出棧

當需要通過間接索引的方式訪問數組或結構體等某些成員對應的內存時,可以用 LEA 指令先對目前內存取地址,然后在操作對應內存的數據。而棧指令則可以用于函數調整自己的棧空間大小。

最后需要說明的是,Go 匯編語言可能并沒有支持全部的 CPU 指令。如果遇到沒有支持的 CPU 指令,可以通過 Go 匯編語言提供的 BYTE 命令將真實的 CPU 指令對應的機器碼填充到對應的位置。完整的 X86 指令在 https://github.com/golang/arch/blob/master/x86/x86.csv 文件定義。同時 Go 匯編還正對一些指令定義了別名,具體可以參考這里 https://golang.org/src/cmd/internal/obj/x86/anames.go



以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號