NO IMAGE

 

高階程式設計與低階程式設計的對比

大多數程式語言都與處理器保持著相當程度的獨立性。但都有一些特殊特性依賴於處理器的某些功能,它們更有可能是特定於作業系統的,而不是特定於處理器的。構建高階程式語言的目的是在程式設計師和硬體體系結構間搭建起一座橋樑。這樣做有多方面的原因。儘管可移植性是原因之一,但更重要的一點或許是提供一種更友好的模型,這種模型的建立方式更接近程式設計師的思考方式,而不是晶片的連線方式。

然而,在組合語言程式設計中,您要直接應對處理器的指令集。這意味著您看系統的方式與硬體相同。這也有可能使組合語言程式設計變得更為困難,因為程式設計模型的建立更傾向於使硬體工作,而不是密切反映問題域。這樣做的好處在於您可以更輕鬆地完成系統級任務、執行那些與處理器相關性很強的優化任務。而缺點是您必須在那個級別上進行思考,依賴於一種特定的處理器系列,往往還必須完成許多額外的工作以準確地建模問題域。

關於組合語言,很多人未想到的一個好處就是它非常具體。在高階語言中,對每個表示式都要進行許多處理。您有時不得不擔憂幕後到底發生了哪些事情。在組合語言程式設計中,您可以完全精確地掌控硬體的行為。您可以逐步處理硬體級更改。

組合語言基礎

在瞭解指令集本身之前,有兩項關於組合語言的關鍵內容需要理解,也就是記憶體模型和獲取-執行週期。

記憶體模型非常簡單。記憶體只儲存一種東西 —— 固定範圍內的數字,也稱為位元組(在大多數計算機上,這是一個 0 到 255 之間的數字)。每個儲存單元都使用一個有序地址定位。設想一個龐大的空間,其中有許多信箱。每個信箱都有編號,且大小相同。這是計算機能夠儲存的惟一 內容。因此,所有一切最終都必須儲存為固定範圍內的數字。幸運的是,大多數處理器都能夠將多個位元組結合成一個單元來處理大數和具有不同取值範圍的數字(例如浮點數)。但特定指令處理一塊記憶體的方式與這樣一個事實無關:每個儲存單元都以完全相同的方式儲存。除了記憶體按有序地址定位之外,處理器還維護著一組暫存器,這是容納被操縱的資料或配置開關的臨時位置。

控制處理器的基本過程就上獲取-執行週期。處理器有一個稱為程式計數器的暫存器,容納要執行的下一條指令的地址。獲取-執行的工作方式如下:

讀程式計數器,從其中列出的地址處讀取指令
更新程式計數器,使之指向下一條指令
解碼指令
載入處理該指令所需的全部記憶體項
處理計算
儲存結果
完成這一切的實際原理極其複雜,特別是 POWER5 處理器可同步處理多達 5 條的指令。但上述介紹對於構思模型來說已足夠。

PowerPC 體系結構按特徵可表述為載入/儲存 體系結構。這也就意味著,所有的計算都是在暫存器中完成的,而不是主儲存器中。在將資料載入暫存器以及將暫存器中的資料存入記憶體時的記憶體訪問非常簡單。這與 x86 體系結構(比如說)不同,其中幾乎每條指令都可對記憶體、暫存器或兩者同時進行操作。載入/儲存體系結構通常具有許多通用的暫存器。PowerPC 具有 32 個通用暫存器和 32 個浮點暫存器,每個暫存器都有編號(與 x86 完全不同,x86 為暫存器命名而不是編號)。作業系統的 ABI(應用程式二進位制介面)可能主要使用通用暫存器。還有一些專用暫存器用於容納狀態資訊並返回地址。管理級應用程式還可使用其他一些專用暫存器,但這些內容不在本文討論之列。通用暫存器在
32 位體系結構中是 32 位的,在 64 位體系結構中則是 64 位的。本文主要關注 64 位體系結構。

組合語言中的指令非常低階 —— 它們一次只能執行一項(有時可能是為數不多的幾項)操作。例如,在 C 語言中可以寫 d = a b c – d some_function(e, f – g),但在組合語言中,每一次加、減和函式呼叫操作都必須使用自己的指令,實際上函式呼叫可能需要使用幾條指令。有時這看上去冗長麻煩。但有三個重要的優點。第一,簡單瞭解組合語言能夠幫助您編寫出更好的高階程式碼,因為這樣您就可以瞭解較低的級別上究竟發生了什麼。第二,能夠處理組合語言中的所有細節這一事實意味著您能夠優化速度關鍵型迴圈,而且比編譯器做得更出色。編譯器十分擅長程式碼優化。但瞭解組合語言可幫助您理解編譯器進行的優化(在
gcc 中使用 -S 開關將使編譯器生成彙編程式碼而不是物件程式碼),並且還能幫您找到編譯器遺漏的地方。第三,您能夠充分利用 PowerPC 晶片的強大力量,實際上這往往會使您的程式碼比高階語言中的程式碼更為簡潔。

這裡不再進一步解釋,接下來讓我們開始研究 PowerPC 指令集。下面給出了一些對新手很有幫助的 PowerPC 指令:

li REG, VALUE
載入暫存器 REG,數字為 VALUE

add REGA, REGB, REGC
將 REGB 與 REGC 相加,並將結果儲存在 REGA 中

addi REGA, REGB, VALUE
將數字 VALUE 與 REGB 相加,並將結果儲存在 REGA 中

mr REGA, REGB
將 REGB 中的值複製到 REGA 中

or REGA, REGB, REGC
對 REGB 和 REGC 執行邏輯 “或” 運算,並將結果儲存在 REGA 中

ori REGA, REGB, VALUE
對 REGB 和 VALUE 執行邏輯 “或” 運算,並將結果儲存在 REGA 中

and, andi, xor, xori, nand, nand, and nor
其他所有此類邏輯運算都遵循與 “or” 或 “ori” 相同的模式

ld REGA, 0(REGB)
使用 REGB 的內容作為要載入 REGA 的值的記憶體地址

lbz, lhz, and lwz
它們均採用相同的格式,但分別操作位元組、半字和字(“z” 表示它們還會清除該暫存器中的其他內容)

b ADDRESS
跳轉(或轉移)到地址 ADDRESS 處的指令

bl ADDRESS
對地址 ADDRESS 的子例程呼叫

cmpd REGA, REGB
比較 REGA 和 REGB 的內容,並恰當地設定狀態暫存器的各位

beq ADDRESS
若之前比較過的暫存器內容等同,則跳轉到 ADDRESS

bne, blt, bgt, ble, and bge
它們均採用相同的形式,但分別檢查不等、小於、大於、小於等於和大於等於

std REGA, 0(REGB)
使用 REGB 的地址作為儲存 REGA 的值的記憶體地址

stb, sth, and stw
它們均採用相同的格式,但分別操作位元組、半字和字

sc
對核心進行系統呼叫

注意到,所有計算值的指令均以第一個運算元作為目標暫存器。在所有這些指令中,暫存器都僅用數字指定。例如,將數字 12 載入暫存器 5 的指令是 li 5, 12。我們知道,5 表示一個暫存器,12 表示數字 12,原因在於指令格式 —— 沒有其他指示符。

每條 PowerPC 指令的長度都是 32 位。前 6 位確定具體指令,其他各位根據指令的不同而具有不同功能。指令長度固定這一事實使處理器更夠更有效地處理指令。但 32 位這一限制可能會帶來一些麻煩,後文中您將會看到。大多數此類麻煩的解決方法將在本系列的第 2 部分中討論。

上述指令中有許多都利用了 PowerPC 的擴充套件記憶法。也就是說,它們實際上是一條更為通用的指令的特殊形式。例如,上述所有條件跳轉指令實際上都是 bc(branch conditional)指令的特殊形式。bc 指令的形式是 bc MODE, CBIT, ADDRESS。CBIT 是條件暫存器要測試的位。MODE 有許多有趣的用途,但為簡化使用,若您希望在條件位得到設定時跳轉,則將其設定為 12;若希望在條件位未得到設定時跳轉,則將其設定為 4。部分重要的條件暫存器位包括:表示小於的 8、表示大於的 9、表示相等的
10。因此,指令 beq ADDRESS 實際上就是 bc 12, 10 ADDRESS。類似地,li 是 addi 的特殊形式,mr 是 or 的特殊形式。這些擴充套件的記憶法有助於使 PowerPC 組合語言程式更具可讀性,並且能夠編寫出更簡單的程式,同時也不會抵消更高階的程式和程式設計師可以利用的強大能力。

您的第一個 POWER5 程式

現在我們來看實際程式碼。我們編寫的第一個程式僅僅載入兩個值、將其相加並退出,將結果作為狀態程式碼,除此之外沒有其他功能。將一個檔案命名為 sum.s,在其中輸入如下程式碼:

清單 1. 您的第一個 POWER5 程式

#Data sections holds writable memory declarations
.data
.align 3 #align to 8-byte boundary

#This is where we will load our first value from
first_value:
        #”quad” actually emits 8-byte entities
        .quad 1
second_value:
        .quad 2

#Write the “official procedure descriptor” in its own section
.section “.opd”,”aw”
.align 3 #align to 8-byte boundary

#procedure description for ._start
.global _start
#Note that the description is named _start,
# and the beginning of the code is labeled ._start
_start:
        .quad ._start, [email protected], 0

#Switch to “.text” section for program code
.text
._start:
        #Use register 7 to load in an address
        #64-bit addresses must be loaded in 16-bit pieces

        #Load in the high-order pieces of the address
        lis 7, [email protected]
        ori   7, 7, [email protected]
        #Shift these up to the high-order bits
        rldicr 7, 7, 32, 31
        #Load in the low-order pieces of the address
        oris 7, 7, [email protected]
        ori 7, 7, [email protected]

        #Load in first value to register 4, from the address we just loaded
        ld 4, 0(7)

        #Load in the address of the second value
        lis 7, [email protected]
        ori 7, 7, [email protected]
        rldicr 7, 7, 32, 31
        oris 7, 7, [email protected]
        ori 7, 7, [email protected]

        #Load in the second value to register 5, from the address we just loaded
        ld 5, 0(7)

        #Calculate the value and store into register 6
        add 6, 4, 5

        #Exit with the status
        li 0, 1    #system call is in register 0
        mr 3, 6    #Move result into register 3 for the system call

        sc

討論程式本身之前,先構建並執行它。構建此程式的第一步是彙編 它:

as -m64 sum.s -o sum.o

這會生成一個名為 sum.o 的檔案,其中包含物件程式碼,這是彙編程式碼的機器語言版,還為聯結器增加了一些附加資訊。“-m64” 開關告訴彙編程式您正在使用 64 位 ABI 和 64 位指令。所生成的物件程式碼是此程式碼的機器語言形式,但無法直接按原樣執行,還需要進行連線,之後作業系統才能載入並執行它。連線的方法如下:

ld -melf64ppc sum.o -o sum

這將生成可執行的 sum。要執行此程式,按如下方法操作:

./sum
echo $?

這將輸入 “3”,也就是最終結果。現在我們來看看這段程式碼的實際工作方式。

由於組合語言程式碼的工作方式非常接近作業系統的級別,因此組織方式與它將生成的物件和可執行檔案也很相近。那麼,為了理解程式碼,我們首先需要理解物件檔案。

物件和可執行檔案劃分為 “節”。程式執行時,每一節都會載入地址空間內的不同位置。它們都具有不同的保護和目的。我們需要關注的主要幾節包括:

.data
包含用於該程式的預初始化資料

.text
包含實際程式碼(過去稱為程式文字)

.opd
包含 “正式過程宣告”,它用於輔助連線函式和指定程式的入口點(入口點就是要執行的程式碼中的第一條指令)

我們的程式做的第一件事就是切換到 .data 節,並將對齊量設定為 8 位元組的邊界(.align 3 會將彙編程式的內部地址計數器對齊為 2^3 的倍數)。

first_value: 這一行是一個符號宣告。它將建立一個稱為 first_value 的符號,與彙編程式中列出的下一條宣告或指令的地址同義。請注意,first_value 本身是一個常量 而不是變數,儘管它所引用的儲存地址可能是可更新的。first_value 只是引用記憶體中特定地址的一種簡化方法。

下一條偽指令 .quad 1 建立一個 8 位元組的資料值,容納值 1。

之後,我們使用類似的一組偽指令定義地址 second_value,容納 8 位元組資料項,值為 2。

.section “.opd”, “aw” 為我們的過程描述符建立一個 “.opd” 節。強制這一節對齊到 8 位元組邊界。然後將符號 _start 宣告為全域性符號,也就是說它在連線後不會被丟棄。然後宣告 _start 腹稿本身( .globl 彙編程式未定義 _start,它只是使其在定義後成為全域性符號)。接下來生成的三個資料項是過程描述符,本系列後續文章中將討論相關內容。

現在轉到實際程式程式碼。.text 偽指令告訴彙編程式我們將切換到 “text” 一節。之後就是 ._start 的定義。

第一組指令載入第一個值的地址,而非值本身。由於 PowerPC 指令僅有 32 位長,指令內僅有 16 位可用於載入常量值(切記,address of first_value 是常量)。由於地址最多可達到 64 位,因此我們必須採用每次一段的方式載入地址(本系列的第 2 部分將介紹如何避免這樣做)。彙編程式中的 @ 符號指示彙編程式給出一個符號值的特殊處理形式。這裡使用了以下幾項:

@highest
表示一個常量的第 48-63 位

@higher
表示一個常量的第 32-47 位

@h
表示一個常量的第 16-31 位

@l
表示一個常量的第 0-15 位

所用的第一條指令表示 “載入即時移位(load immediate shifted)”。這會在最右端(first_value 的第 48-63 位)載入值,將數字移位到左邊的 16 位,然後將結果儲存到暫存器 7 中。暫存器 7 的第 16-31 位現包含地址的第 48-63 位。接下來我們使用 “or immediate” 指令對暫存器 7 和右端的值(first_value 的第 32-47 位)執行邏輯或運算,將結果儲存到暫存器 7 中。現在地址的第 32-47 位儲存到了暫存器的第 0-15 位中。暫存器
7 現左移 32 位,0-31 位將清空,結果儲存在暫存器 7 中。現在暫存器 7 的第 32-63 位包含我們所載入的地址的第 32-63 位。下兩條指令使用了 “or immediate” 和 “or immediate shifted” 指令,以類似的方式載入第 0-31 位。

僅僅是要載入一個 64 位值就要做許多工作。這也就是為什麼 PowerPC 晶片上的大多數操作都通過暫存器完成,而不通過立即值 —— 暫存器操作可一次使用全部 64 位,而不僅限於指令的長度。下一期文章將介紹簡化這一任務的定址模式。

現在只要記住,這隻會載入我們想載入的值的地址。現在我們希望將值本身載入暫存器。為此,將使用暫存器 7 去告訴處理器希望從哪個地址處載入值。在圓括號中填入 “7” 即可指出這一點。指令 ld 4, 0(7) 將暫存器 7 中地址處的值載入暫存器 4(0 表示向該地址加零)。現在暫存器 4 是第一個值。

使用類似的過程將第二個值載入暫存器 5。

載入暫存器之後,即可將數字相加了。指令 add 6, 4, 5 將暫存器 4 的內容與暫存器 5 的內容相加,並將結果儲存在暫存器 6(暫存器 4 和暫存器 5 不受影響)。

既然已經計算出了所需值,接下來就要將這個值作為程式的返回/退出值了。在組合語言中退出一個程式的方法就是發起一次系統呼叫(使用 exit 系統呼叫退出)。每個系統呼叫都有一個相關聯的數字。這個數字會在實現呼叫前儲存在暫存器 0 中。從暫存器 3 開始儲存其餘引數,系統呼叫需要多少引數就使用多少暫存器。然後 sc 指令使核心接收並響應請求。exit 的系統呼叫號是 1。因此,我們需要首先將數字 1 移動到暫存器 0 中。

在 PowerPC 機器上,這是通過加法完成的。addi 指令將一個暫存器與一個數字相加,並將結果儲存在一個暫存器中。在某些指令中(包括 addi),如果指定的暫存器是暫存器 0,則根本不會加上暫存器,而是使用數字 0。這看上去有些令人糊塗,但這樣做的原因在於使 PowerPC 能夠為相加和載入使用相同的指令。

退出系統呼叫接收一個引數 —— 退出值。它儲存在暫存器 3 中。因此,我們需要將我們的應答從暫存器 6 移動到暫存器 3 中。“register move” 指令 rm 3, 6 執行所需的移動操作。現在我們就可以告訴作業系統已經準備好接受它的處理了。

呼叫作業系統的指令就是 sc,表示 “system call”。這將呼叫作業系統,作業系統將讀取我們置於暫存器 0 和暫存器 3 中的內容,然後退出,以暫存器 3 的內容作為返回值。在命令列中可使用命令 echo $? 檢索該值。

需要指出,這些指令中許多都是多餘的,目的僅在於教學。例如,first_value 和 second_value 實際上是常量,因此我們完全可以直接載入它們,跳過資料節。同樣,我們也能一開始就將結果儲存在暫存器 3 中(而不是暫存器 6),這樣就可以免除一次暫存器移動操作。實際上,可以將暫存器同時 作為源暫存器和目標暫存器。所以,如果想使其儘可能地簡潔,可將其寫為如下形式:

清單 2. 第一個程式的簡化版本

.section “.opd”, “aw”
.align 3
.global _start
_start:
.quad ._start, [email protected], 0
.text
li 3, 1   #load “1” into register 3
li 4, 2   #load “2” into register 4
add 3, 3, 4    #add register 3 to register 4 and store the result in register 3
li 0, 1   #load “1” into register 0 for the system call
sc

回頁首

查詢最大值

我們的下一個程式將提供更多一點的功能 —— 查詢一組值中的最大值,退出並返回結果。

在名為 max.s 的檔案中鍵入如下程式碼:

清單 3. 查詢最大值

###PROGRAM DATA###
.data
.align 3
#value_list is the address of the beginning of the list
value_list:
        .quad 23, 50, 95, 96, 37, 85
#value_list_end is the address immediately after the list
value_list_end:

###STANDARD ENTRY POINT DECLARATION###
.section “opd”, “aw”
.global _start
.align 3
_start:
        .quad ._start, [email protected], 0

###ACTUAL CODE###
.text
._start:

        #REGISTER USE DOCUMENTATION
        #register 3 — current maximum
        #register 4 — current value address
        #register 5 — stop value address
        #register 6 — current value

        #load the address of value_list into register 4
        lis 4, [email protected]
        ori 4, 4, [email protected]
        rldicr 4, 4, 32, 31
        oris 4, 4, [email protected]
        ori 4, 4, [email protected]

        #load the address of value_list_end into register 5
        lis 5, [email protected]
        ori 5, 5, [email protected]
        rldicr 5, 5, 32, 31
        oris 5, 5, [email protected]
        ori 5, 5, [email protected]

        #initialize register 3 to 0
        li 3, 0

        #MAIN LOOP
loop:
        #compare register 4 to 5
        cmpd 4, 5
        #if equal branch to end
        beq end

        #load the next value
        ld 6, 0(4)

        #compare register 6 (current value) to register 3 (current maximum)
        cmpd 6, 3
        #if reg. 6 is not greater than reg. 3 then branch to loop_end
        ble loop_end

        #otherwise, move register 6 (current) to register 3 (current max)
        mr 3, 6

loop_end:
        #advance pointer to next value (advances by 8-bytes)
        addi 4, 4, 8
        #go back to beginning of loop
        b loop

end:
        #set the system call number
        li 0, 1
        #register 3 already has the value to exit with
        #signal the system call
        sc

為彙編、連線和執行程式,執行:

as -a64 max.s -o max.o
ld -melf64ppc max.o -o max
./max
echo $?

您之前已體驗了一個 PowerPC 程式,也瞭解了一些指令,那麼應該可以看懂部分程式碼。資料節與上一個程式基本相同,差別只是在 value_list 宣告後有幾個值。注意,這不會改變 value_list —— 它依然是指向緊接其後的第一個資料項地址的常量。對於之後的資料,每個值使用 64 位(通過 .quad 表示)。入口點宣告與前一程式相同。

對於程式本身,需要注意的一點就是我們記錄了各暫存器的用途。這一實踐將很好地幫助您跟蹤程式碼。暫存器 3 儲存當前最大值,初始設定為 0。暫存器 4 包含要載入的下個值的地址。最初是 value_list,每次遍歷前進 8 位。暫存器 5 包含緊接 value_list 中資料之後的地址。這使您可以輕鬆比較暫存器 4 和暫存器 5,以便了解是否到達了列表末端,並瞭解何時需要跳轉到 end。暫存器 6 包含從暫存器 4 指向的位置處載入的當前值。每次遍歷時,它都會與暫存器 3(當前最大值)比較,如果暫存器 6
較大,則用它取代暫存器 3。

注意,我們為每個跳轉點標記了其自己的符號化標籤,這使我們能夠將這些標籤作為跳轉指令的目標。例如,beq end 跳轉到這段程式碼中緊接 end 符號定義之後的程式碼處。

要注意的另外一條指令是 ld 6, 0(4)。它使用暫存器 4 中的內容作為儲存地址來檢索一個值,此值隨後儲存到暫存器 6 中。