powerpc 彙編編程參考

POWER5 處理器是一款應用廣泛的 64 位高性能處理器。具備雙核和對稱多線程功能。這使單獨一個芯片能夠同步處理 4 個線程!不僅如此,各線程在每個時鐘週期內還可執行一組指令(最多可達到 5 條)。

PowerPC 指令集廣泛應用於 IBM 和其他廠商提供的多種芯片,而不僅僅是 POWER 系列。它用在服務器、工作站和高端嵌入式環境之中(設想數字攝像機和路由器,而不是移動電話)。Gekko 芯片用在了任天堂的 GameCube 中,Xenon 則用在了 Microsoft 的 Xbox 360 中。Cell Broadband Engine 是近來嶄露頭角的一種體系結構,使用 PowerPC 指令,並且具有八個向量處理器。Sony PlayStation 3 將使用 Cell,考慮到 PlayStation 3 將用於廣泛的多媒體應用程序,因此還使用爲數衆多的其他向量。

PowerPC 指令集比 POWER 處理器系列更加有用。指令集本身可以 64 位模式操作,也可以簡化的 32 位模式操作。POWER5 處理器支持這兩種模式,POWER5 上的 Linux 發佈版支持爲 32 位和 64 位 PowerPC 指令集而編譯的應用程序。

高級編程與低級編程的對比:

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

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

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

彙編語言基礎

在瞭解指令集本身之前,有兩項關於彙編語言的關鍵內容需要理解,也就是內存模型獲取-執行週期

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

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

1) 讀程序計數器,從其中列出的地址處讀取指令
2) 更新程序計數器,使之指向下一條指令
3) 解碼指令
4) 加載處理該指令所需的全部內存項
5) 處理計算
6) 儲存結果
完成這一切的實際原理極其複雜,特別是 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(== or regA regB 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 的值的內存地址

(將 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 的值的內存地址

(將 regA 中的值存儲到 regB 指向的內存地址)

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

sc(system call)
對內核進行系統調用

mflr r0(move from lr)

將lr的值存到r0中

mtlr r0(move to lr)

將r0的值存到lr中

注意到,所有計算值的指令均以第一個操作數作爲目標寄存器。在所有這些指令中,寄存器都僅用數字指定。例如,將數字 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,
.TOC.@tocbase, 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,
first_value @highest
ori 7, 7, first_value @higher
#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,
first_value @h
ori 7, 7, first_value @l

#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,
second_value @highest
ori 7, 7, second_value @higher
rldicr 7, 7, 32, 31
oris 7, 7,
second_value @h
ori 7, 7, second_value @l

#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,
.TOC.@tocbase
, 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,
.TOC.@tocbase
, 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,
value_list@highest
ori 4, 4, value_list@higher
rldicr 4, 4, 32, 31
oris 4, 4,
value_list@h
ori 4, 4, value_list@l

#load the address of value_list_end into register 5
lis 5,
value_list_end@highest
ori 5, 5, value_list_end@higher
rldicr 5, 5, 32, 31
oris 5, 5,
value_list_end@h
ori 5, 5, value_list_end@l

#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 中。

尋址模式以及尋址模式之所以重要的原因

在開始討論尋址模式之前,讓我們首先來回顧一下計算機內存的概念。您可能已經瞭解了關於內存和編程的一些事實,但是由於現代編程語言正試圖淡化計算機中的一些物理概念,因此複習一下相關內容是很有用的:

主存中的每個位置都使用連續的數字地址編號,內存位置就使用這個地址來引用。


每個主存位置的長度都是一個字節。


較大的數據類型可以通過簡單地將多個字節當作一個單位實現(例如,將兩個內存位置放到一起作爲一個 16 位的數字)。


寄存器的長度在 32 位平臺上是 4 個字節,在 64 位平臺上是 8 個字節。


每次可以將 1、2、4 或 8 個字節的內存加載到寄存器中。


非數字數據可以作爲數字數據進行存儲 —— 惟一的區別在於可以對這些數據執行哪些操作,以及如何使用這些數據。
新接觸彙編語言的程序員有時可能會對我們有多少訪問內存的方法感到驚奇。這些不同的方法就稱爲尋址模式。有些模式邏輯上是等價的,但是用途卻不同。它們之所以被視爲不同的尋址模式,原因在於它們可能根據處理器採用了不同的實現。

有兩種尋址模式實際上根本就不會訪問內存。在立即尋址模式中,要使用的數據是指令的一部分(例如 li 指令就表示 “立即加載”,這是因爲要加載的數字就是這條指令本身的一部分)。在寄存器尋址模式中,我們也不會訪問主存的內容,而是訪問寄存器。

訪問主存最顯而易見的尋址模式稱爲直接尋址模式在這種模式中,指令本身就包含了數據加載的源地址。這種模式通常用於全局變量訪問、分支以及子程序調用。稍微簡單的一種模式是相對尋址模式它會根據當前程序計數器來計算地址。這通常用於短程分支,其中目標地址距當前位置很近,因此指定一個偏移量(而不是絕對地址)會更有意義。這就像是直接尋址模式的最終地址在彙編或鏈接時就知道了一樣

索引尋址模式 對於全局變量訪問數組元素來說是最爲有效的一種方式。它包括兩個部分:一個內存地址以及一個索引寄存器。索引寄存器會與某個指定的地址相加,結果用作訪問內存時使用的地址。有些平臺(非 PowerPC)允許程序員爲索引寄存器指定一個倍數。因此,如果每個數組元素的長度都是 8 個字節,那麼我們就可以使用 8 作爲倍數。這樣就可以將索引寄存器當作數組索引來使用。否則,就必須按照數據大小來增加或減少索引寄存器了。

寄存器間接尋址模式 使用一個寄存器來指定內存訪問的整個地址。這種模式在很多情況中都會使用,包括(但不限於):

解除指針變量的引用
使用其他模式無法進行的內存訪問(地址可以通過其他方式進行計算,並存儲到寄存器中,然後就使用這個值來訪問內存)
基指針尋址模式
的工作方式與索引尋址模式非常類似(指定的數字和寄存器被加在一起得出最終地址),不過兩個元素的作用交換了。在基指針尋址模式中,寄存器中保存的是基址,數字是偏移量。這對於訪問結構中的成員是非常有用的。寄存器可以存放整個結構的地址,數字部分可以根據所訪問的結構成員進行修改。

最後,假設我們有一個包括 3 個域的結構體:第一個域是 8 個字節,第二個域是 4 個字節,最後一個域是 8 個字節。然後,假設這個結構體本身的地址在一個名爲 X 的寄存器中。如果我們希望訪問這個結構體的第二個元素,就需要在寄存器中的值上加上 8。因此,使用基指針尋址模式,我們可以指定寄存器 X 作爲基指針,8 作爲偏移量。要訪問第三個域,我們需要指定寄存器 X 作爲指針,12 作爲偏移量。要訪問第一個域,我們實際上可以使用間接尋址模式,而不用使用基指針尋址模式,因爲這裏沒有偏移量(這就是爲什麼在很多平臺上第一個結構體成員都是訪問最快的一個成員;我們可以使用更加簡單的尋址模式 —— 在 PowerPC 上這並不重要)。

最後,在索引寄存器間接尋址模式中,基址和索引都保存在寄存器中。所使用的內存地址是通過將這兩個寄存器加在一起來確定的。

指令格式的重要性

爲了解尋址模式對於 PowerPC 處理器上的加載和存儲指令是如何工作的,我們必須先要對 PowerPC 指令格式有點了解。PowerPC 使用了加載/存儲(也成爲 RISC)指令集這意味着訪問主存的惟一時機就是將內存加載到寄存器或將寄存器中的內容複製到內存中時。所有實際的處理都發生在寄存器之間(或寄存器和立即尋址模式操作數之間)。另外一種主要的處理器體系結構 CISC(x86 處理器就是一種流行的 CISC 指令集)幾乎允許在每條指令中進行內存訪問。採用加載/存儲體系架構的原因是這樣可以使處理器的其他操作更加有效。實際上,現代 CISC 處理器將自己的指令轉換成了內部使用的 RISC 格式,以實現更高的效率。

PowerPC 上的每條指令都正好是 32 位長,指令的 opcode(操作符,告訴處理器這條指令是什麼的代碼)佔據了前 6 位。這個 32 位的長度包含了所有的立即尋址模式的值、寄存器引用、顯式地址以及指令選項。這實現了非常好的壓縮。實際上,內存地址對於任何指令格式可以使用的最大長度只有 24 位!最多隻能給我們提供 16MB 的可尋址空間。不要擔心 —— 有很多方法都可以解決這個問題。這只是爲了說明爲什麼指令格式在 PowerPC 處理器上是如此重要 —— 您需要知道自己到底需要使用多少空間!

您不必記住所有的指令格式就能使用它們。然而,瞭解一些指令的基本知識可以幫助您讀懂 PowerPC 文檔,並理解 PowerPC 指令集中的通用策略和一些細微區別。PowerPC 具有 15 種不同的指令格式,很多指令格式都有幾種子格式。但只需要關心其中的幾種即可。

使用 D-Form 和 DS-Form 指令格式對內存進行尋址

D-Form 指令是主要的內存訪問指令格式之一。它看起來像下面這樣:

D-Form 指令格式

0 到 5 位
操作碼

6 到 10 位
源/目標寄存器

11 到 15 位
地址/索引寄存器/操作數

16 到 31 位
數字地址、偏移量或立即尋址模式值

這種格式用來進行加載、存儲和立即尋址模式的計算。它可以用於以下尋址模式:

立即尋址模式
直接尋址模式(通過指定地址/索引寄存器爲 0)
索引尋址模式
間接尋址模式(通過指定地址爲 0 )
基指針尋址模式
如您所見,
D-Form 指令非常靈活,可以用於任何寄存器加地址的內存訪問模式。然而,對於直接尋址和索引尋址來說,它的用處就非常有限了;這是因爲它只能使用一個 16 位的地址域。它所提供的最大尋址範圍是 64K。因此,直接和索引尋址模式都很少用來獲取或存儲內存。相反,這種格式更多用於立即尋址模式、間接尋址模式和基指針尋址模式,因爲在這些尋址模式中,64K 限制幾乎都不是什麼問題,因爲基寄存器中就可以保存完整的 64 位的範圍。

DS-Form 只在 64 位指令中使用。它與 D-Form 非常類似,不同之處在於它使用地址的最後兩位作爲擴展操作符。然而,它會在地址中 Value 部分最右邊加上兩個 0 。其範圍與 D-Form 指令相同(64K),但是卻將其限定爲 32 位對齊的內存。對於彙編程序來說,這個值是通常是指定的 —— 它會通過彙編進行濃縮。例如,如果我們希望偏移量爲 8,就仍然可以輸入 8;彙編程序會將這個值轉換成位表示 0b000000000010,而不是 0b00000000001000。如果我們輸入一個不是 4 的部署的數字,那麼彙編程序就會出錯。

注意在 D-Form 和 DS-Form 指令中,如果源寄存器被設置爲 0,而不是使用寄存器 0,那麼它就不會使用寄存器參數。

下面讓我們來看一個使用 D-Forms 和 DS-Forms 構成的指令。

立即尋址模式指定在彙編程序中是這樣指定的:
opcode dst, src, value

此處 dst 是目標寄存器,src 是源寄存器(在計算中使用),value 是所使用的立即尋址模式的值。立即尋址模式指令永遠都不會使用 DS-Form。下面是幾個立即尋址模式的指令:


清單 1. 立即尋址模式的指令

#Add the contents of register 3 to the number 25 and store in register 2
addi 2, 3, 25

#OR the contents of register 6 to the number 0b0000000000000001 and store in register 3
ori 3, 6, 0b00000000000001

#Move the number 55 into register 7
#(remember, when 0 is the second register in D-Form instructions
#it means ignore the register)
addi 7, 0, 55
#Here is the extended mnemonics for the same instruction
li 7, 55

在使用 D-Form 的非立即尋址模式中,第二個寄存器被加到這個值上來計算加載或存儲數據的內存的最終地址。這些指令的通用格式如下:

opcode dst, d(a)

在這種格式中,加載/存儲數據的地址是作爲 d(a) 指定的,其中 d 是數字地址/偏移量,而 a 是地址/偏移量所使用的寄存器的編號。它們被加在一起計算加載/存儲數據的最終有效地址。下面是幾個 D-Form/DS-Form 加載/存儲指令的例子:


清單 2. 使用 D-Form 和 DS-Form 加載/存儲指令的例子

#load a byte from the address in register 2, store it in register 3,
#and zero out the remaining bits
lbz 3, 0(2)

#store the 64-bit contents (double-word) of register 5 into the
#address 32 bits past the address specified by register 23
std 5, 32(23)

#store the low-order 32 bits (word) of register 5 into the address
#32 bits past the address specified by register 23
stw 5, 32(23)

#store the byte in the low-order 8 bits of register 30 into the
#address specified by register 4
stb 30, 0(4)

#load the 16 bits (half-word) at address 300 into register 4, and
#zero-out the remaining bits
lhz 4, 300(0)

#load the half-word (16 bits) that is 1 byte offset from the address
#in register 31 and store the result sign-extended into register 18
lha 18, 1(31)

仔細觀察,您就可以看出在有一種在指令開頭指定的 “基址操作碼”,隨後是幾個修飾符。l 和 s 用於 “load(加載)” 和 “store(存儲)” 指令。b 表示一個字節,h 表示一個雙字節(16 位)。w 表示一個字(32 位), d 表示一個雙字節(64 位)。然後對於加載指令來說,a 和 z 修飾符說明在將數據加載到寄存器中時,該值是符號擴展的,還是簡單進行零填充的。最後,還可以附加上一個 u 來告訴處理器使用這條指令的最終計算地址來更新地址計算過程中所使用的寄存器。


使用 X-Form 指令格式對內存進行尋址

X-Form 用來進行索引寄存器間接尋址模式,其中兩個寄存器中的值會被加在一起來確定加載/存儲的地址。X-Form 的格式如下:

X-Form 指令格式

0 到 5 位
操作碼

6 到 10 位
源/目標寄存器

11 到 15 位
地址計算寄存器 A

16 到 20 位
地址計算寄存器 B

21 到 30 位
擴展操作符

31 位
保留未用

操作符的格式如下:

opcode dst, rega, regb


此處 opcode 是指令的操作符,dst 是數據傳輸的目標(或源)寄存器,rega 和 regb 是用來計算地址所使用的兩個寄存器。

下面給出幾個使用 X-Form 的指令的例子:


清單 3. 使用 X-Form 尋址的例子

#Load a doubleword (64 bits) from the address specified by
#register 3 + register 20 and store the value into register 31
ldx 31, 3, 20

#Load a byte from the address specified by register 10 + register 12
#and store the value into register 15 and zero-out remaining bits
lbzx 15, 10, 12

#Load a halfword (16 bits) from the address specified by
#register 6 + register 7 and store the value into register 8,
#sign-extending the result through the remaining bits
lhax 8, 6, 7

#Take the doubleword (64 bits) in register 20 and store it in the
#address specified by register 10 + register 11
stdx 20, 10, 11

#Take the doubleword (64 bits) in register 20 and store it in the
#address specified by register 10 + register 11, and then update
#register 10 with the final address
stdux 20, 10, 11


X-Form 的優點除了非常靈活之外,還爲我們提供了非常廣泛的尋址範圍。在 D-Form 中,只有一個值 —— 寄存器 —— 可以指定一個完整的範圍。在 X-Form 中,由於我們有兩個寄存器,這兩個組件都可以根據需要指定足夠大的範圍。因此,在使用基指針尋址模式或索引尋址模式而 D-Form 固定部分的 16 位範圍太小的情況下,這些值就可以存儲到寄存器中並使用 X-Form。


編寫與位置無關的代碼

與位置無關的代碼是那些不管加載到哪部分內存中都能正常工作的代碼。爲什麼我們需要與位置無關的代碼呢?與位置無關的代碼可以讓庫加載到地址空間中的任意位置處。這就是允許庫隨機組合 —— 因爲它們都沒有被綁定到特定位置,所以就可以使用任意庫來加載,而不用擔心地址空間衝突的問題。鏈接器會負責確保每個庫都被加載到自己的地址空間中。通過使用與位置無關的代碼,庫就不用擔心自己到底被加載到什麼地方去了。

不過,最終與位置無關的代碼需要有一種方法來定位全局變量。它可以通過維護一個全局偏移量表來實現這種功能,這個表提供了函數或一組函數(在大部分情況中甚至是整個程序)訪問的所有全局內容的地址。系統保留了一個寄存器來存放指向這個表的指針。然後,所有訪問都可以通過這個表中的一個偏移量來完成。偏移量是個常量。表本身是通過程序鏈接器/加載器來設置的,它還會初始化寄存器 2 來存放全局偏移量表的指針。使用這種方法,鏈接器/加載器就可以將認爲適當的程序和數據放在一起,這只需要設置包含所有全局指針的一個全局偏移量表即可。

很容易陷於對這些問題的討論細節當中。下面讓我們來看一些代碼,並分析一下這種方法的每個步驟都在做些什麼。這是上一篇文章 中使用的 “加法” 程序,不過現在調整成了與位置無關的代碼。


清單 4. 通過全局偏移量表來訪問數據

###DATA DEFINITIONS###
.data
.align 3
first_value:
.quad 1
second_value:
.quad 2

###ENTRY POINT DECLARATION###
.section .opd, "aw"
.align 3
.globl _start
_start:
.quad ._start,
.TOC.@tocbase
, 0

###CODE###
.text
._start:
##Load values##
#Load the address of first_value into register 7 from the global offset table
ld 7,
first_value@got(2
)
#Use the address to load the value of first_value into register 4
ld 4, 0(7)
#Load the address of second_value into register 7 from the global offset table
ld 7,
second_value@got(2
)
#Use the address to load the value of second_value into register 5
ld 5, 0(7)

##Perform addition##
add 3, 4, 5

##Exit with status##
li 0, 1
sc


要彙編、連接並運行這段代碼,請按以下方法執行:


清單 5. 彙編、連接並運行代碼

#Assemble
as -a64 addnumbers.s -o addnumbers.o

#Link
ld -melf64ppc addnumbers.o -o addnumbers

#Run
./addnumbers

#View the result code (value returned from the program)
echo $?


數據定義和入口點聲明與之前的例子相同。不過,我們不用再使用 5 條指令將 first_value 的地址加載到寄存器 7 中了,現在只需要一條指令就可以了:ld 7, first_value@got(2)。正如前面介紹的一樣,連接器/加載器會將寄存器 2 設置爲全局偏移量表的地址。語法 first_value@got 會請求鏈接器不要使用 first_value 的地址,而是使用全局偏移量表中包含 first_value 地址的偏移量。

使用這種方法,大部分程序員都可以包含他們在一個全局偏移量表中使用的所有全局數據。DS-Form 從一個基址可以尋址多達 64K 的內存。注意爲了獲得 DS-Form 的整個範圍,寄存器 2 指向了全局偏移量表的中部,這樣我們就可以使用正數偏移量和負數偏移量了。由於我們正在定位的是指向數據的指針(而不是直接定位數據),因此我們可以訪問大約 8,000 個全局變量(局部變量都保存在寄存器或堆棧中,這會在本系列的第三篇文章中進行討論)。即使這還不夠,我們還有多個全局偏移量表可以使用。這種機制也會在下一篇文章中進行討論。

儘管這比上一篇文章中所使用的 5 條指令的數據加載更加簡潔,可讀性也更好,但是我們仍然可以做得更好些。在 64 位 ELF ABI 中,全局偏移量表實際上是一個更大的部分 —— 稱爲內容表(table of contents) —— 的一個子集。除了創建全局偏移量表入口之外,內容表還包含變量,它沒有包含全局數據的地址,而是包含的數據本身。這些變量的大小和個數必須很小,因爲內容表只有 64K。

要聲明一個內容表的數據項,我們需要切換到 .toc 段,並顯式地進行聲明,如下所示:

.section .toc
name:
.tc unused_name[TC], initial_value

這會創建一個內容表入口。name 是在代碼中引用它所使用的符號。initial_value 是初始化分配的一個 64 位的值。unused_name 是歷史記錄,現在在 ELF 系統上已經沒有任何用處了。我們可以不再使用它了(此處包含進來只是爲了幫助我們閱讀遺留代碼),不過 [TC] 是需要的。

要訪問內容表中直接保存的數據,我們需要使用 @toc 來引用它,而不能使用 @got。@got 仍然可以工作,不過其功能也與以前一樣 —— 返回一個指向值的指針,而不是返回值本身。下面看一下這段代碼:


清單 6. @got 和 @toc 之間的區別

### DATA ###

#Create the variable my_var in the table of contents
.section .toc
my_var:
.tc [TC], 10

### ENTRY POINT DECLARATION ###
.section .opd, "aw"
.align 3
.globl _start
_start:
.quad ._start,
.TOC.@tocbase
, 0

### CODE ###
.text
._start:
#loads the number 10 (my_var contents) into register 3
ld 3,
my_var@toc(2
)

#loads the address of my_var into register 4
ld 4,
my_var@got(2)
#loads the number 10 (my_var contents) into register 4
ld 3, 0(4)

#load the number 15 into register 5
li 5, 15

#store 15 (register 5) into my_var via ToC
std 5,
my_var@toc(2)

#store 15 (register 5) into my_var via GOT (offset already loaded into register 4)
std 5, 0(4)

#Exit with status 0
li 0, 1
li 3, 0
sc


如您所見,如果查看在 .toc 段中所定義的符號(而不是大部分數據所在的 .data 段),使用 @toc 可以提供直接到值本身的偏移量,而使用 @got 只能提供一個該值地址的偏移量。

現在看一下使用 Toc 中的值來進行加法計算的例子:


清單 7. 將 .toc 段中定義的數字相加

### PROGRAM DATA ###
#Create the values in the table of contents
.section .toc
first_value:
.tc [TC], 1
second_value:
.tc [TC], 2

### ENTRY POINT DEFINITION ###
.section .opd, "aw"
.align 3
.globl _start
_start:
.quad ._start,
.TOC.@tocbase
, 0

.text
._start:
##Load values from the table of contents ##
ld 4,
first_value@toc(2
)
ld 5,
second_value@toc(2
)

##Perform addition##
add 3, 4, 5

##Exit with status##
li 0, 1
sc


可以看到,通過使用基於 .toc 的數據,我們可以顯著減少代碼所使用的指令數量。另外,由於這個內容表通常就在緩存中,它還可以顯著減少內存的延時。我們只需要謹慎處理存儲的數據量就可以了。

加載和存儲多個值

PowerPC 還可以在一條指令中執行多個加載和存儲操作。不幸的是,這限定於字大小(32 位)的數據。這些都是非常簡單的 D-Form 指令。我們指定了基址寄存器、偏移量和起始目標寄存器。處理器然後會將數據加載到通過寄存器 31 所列出的目標寄存器開始的所有寄存器中,這會從指令所指定的地址開始,一直往前進行。此類指令包括 lmw (加載多個字)和 stmw(存儲多個字)。下面是幾個例子:


清單 8. 加載和存儲多個值

#Starting at the address specified in register ten, load
#the next 32 bytes into registers 24-31
lmw 24, 0(10)

#Starting at the address specified in register 8, load
#the next 8 bytes into registers 30-31
lmw 30, 0(8)

#Starting at the address specified in register 5, store
#the low-order 32-bits of registers 20-31 into the next
#48 bytes
stmw 20, 0(5)


下面是使用多個值的加法程序:


清單 9. 使用多個值的加法程序

### Data ###
.data
first_value:
#using "long" instead of "double" because
#the "multiple" instruction only operates
#on 32-bits
.long 1
second_value:
.long 2

### ENTRY POINT DECLARATION ###
.section .opd, "aw"
.align 3
.globl _start
_start:
.quad ._start,
.TOC.@tocbase
, 0

### CODE ###
.text
._start:
#Load the address of our data from the GOT
ld 7,
first_value@got(2
)

#Load the values of the data into registers 30 and 31
lmw 30, 0(7)

#add the values together
add 3, 30, 31

#exit
li 0, 1
sc

帶更新的模式

大多數加載/存儲指令都可以使用加載/存儲指令最終使用的有效地址來更新主地址寄存器。例如,ldu 5, 4(8) 會將寄存器 8 中指定的地址加上 4 個字節加載到寄存器 5 中,然後將計算出來的地址存回 寄存器 8 中。這稱爲帶更新的加載和存儲,這可以用來減少執行多個任務所需要的指令數。在下一篇文章中我們將更多地使用這種模式。


有效地進行加載和存儲對於編寫高效代碼來說至關重要。瞭解可用的指令格式和尋址模式可以幫助我們理解某種平臺的可能性和限制。PowerPC 上的 D-Form 和 DS-Form 指令格式對於與位置無關的代碼來說非常重要。與位置無關的代碼允許我們創建共享庫,並使用較少的指令就可以完成加載全局地址的工作。

本系列的下一篇文章將介紹分支、函數調用以及與 C 代碼的集成問題。

分支寄存器

PowerPC 中的分支利用了 3 個特殊用途的寄存器:條件寄存器、計數寄存器和鏈接寄存器。

條件寄存器

條件寄存器從概念上來說包含 7 個域(field)。域是一個 4 位長的段,用來存儲指令結果狀態信息。其中有兩個域是專用的(稍後就會介紹),其餘域是通用的。這些域的名字爲 cr0 到 cr7。

第一個域 cr0 用來保存定點計算指令的結果,它使用了非立即操作(有幾個例外)。計算的結果會與 0 進行比較,並根據結果設置適當的位(負數、零或正數)。要想在計算指令中設置 cr0,可以簡單地在指令末尾添加一個句點(.)。例如,add 4, 5, 6 這條指令是將寄存器 5 和寄存器 6 進行加法操作,並將結果保存到寄存器 4 中,而不會在 cr0 中設置任何狀態位。add. 4, 5, 6 也可以進行相同的加法操作,不過會根據所計算出來的值設置 cr0 中的位。cr0 也是比較指令上使用的默認域。

第二個域(稱爲 cr1)用於浮點指令,方法是在指令名後加上句點。浮點計算的內容超出了本文的討論範圍。

每個域都有 4 個位。這些位的用法根據所使用的指令的不同會有所不同。下面是可能的用法(下面也列出了浮點指令,不過沒有詳細介紹):

條件寄存器域位

位 記憶法 定點比較 定點計算浮點比較 浮點計算
0 lt 小於 負數 小於 異常摘要
1 gt 大於 正數 大於 啓用異常摘要
2 eq 等於 0 等於 無效操作異常摘要
3 so 摘要溢出 摘要溢出 無序 溢出異常

稍後您就會看到如何隱式或直接訪問這些域。

條件寄存器可以使用 mtcr、mtcrf 和 mfcr 加載到通用寄存器中(或從通用寄存器中進行加載)。mtcr 將一個特定的通用寄存器加載到條件寄存器中。mfcr 將條件寄存器移到通用寄存器中。mtcrf 從通用寄存器中加載條件寄存器,不過只會加載由 8 位掩碼所指定的域,即第一個操作數。

下面是幾個例子。


清單 1. 條件寄存器轉換的例子

#Copy register 4 to the condition register
mtcr 4

#Copy the condition register to register 28
mfcr 28

#Copy fields 0, 1, 2, and 7 from register 18 to the condition register
mtcrf 0b11100001, 18


計數寄存器和鏈接寄存器

鏈接寄存器(名爲 LR)是專用寄存器,其中保存了分支指令的返回地址。所有的分支指令都可以用來設置鏈接寄存器;如果進行分支,就將鏈接寄存器設置成當前指令之後緊接的那條指令的地址。分支指令通過將字母 l 附加到指令末尾來設置鏈接寄存器。舉例來說,b 是無條件的分支指令,而 bl 則是設置鏈接寄存器的一條無條件分支指令。

計數寄存器(名爲 CTR)是用來保存循環計數器的一個專用寄存器。專用分支指令可能會減少計數寄存器,並且(或者)會根據 CTR 是否達到 0 來進行條件分支跳轉。

鏈接寄存器和計數寄存器都可以用作分支目的地。bctr 分支跳轉到計數寄存器中指定的地址,blr 分支跳轉到鏈接寄存器中指定的地址。

鏈接寄存器和計數寄存器的值也可以從通用寄存器中拷貝而來,或者拷貝到通用寄存器中。對於鏈接寄存器來說,mtlr 會將給定的寄存器值拷貝到 鏈接寄存器中,mflr 則將值從 鏈接寄存器拷貝到通用寄存器中。mtctr 和 mfctr 也可以對計數寄存器實現相同的功能。


無條件分支

PowerPC 指令集中的無條件分支使用了 I-Form 指令格式:

I-Form 指令格式

0-5 位
操作碼

6-29 位
絕對或相對分支地址

30 位
絕對地址位 —— 如果這個域被置位了,那麼指令就會被解釋成絕對地址,否則就被解釋成相對地址

31 位
鏈接位 —— 如果這個域被置位了,那麼指令就會將鏈接寄存器設置爲下一條指令的地址

正如前面介紹的一樣,將字母 l 添加到分支指令後面會導致鏈接位被置位,從而使 “返回地址”(分支跳轉後的指令)存儲在鏈接寄存器中。如果您在指令末尾再加上字母 a(位於 l 之後,如果l 也同時使用的話),那麼所指定的地址就是絕對地址(通常在用戶級代碼中不會這樣用,因爲這會過多地限制分支目的地)。

清單 2 闡述了無條件分支的用法,然後退出(您可以將下面的代碼輸入到 branch_example.s 文件中):


清單 2. 無條件分支的例子

### ENTRY POINT DECLARATION ###
.section .opd, "aw"
.align 3
.globl _start
_start:
.quad ._start,
.TOC.@tocbase
, 0

### PROGRAM CODE ###
.text
#branch to target t2
._start:
b t2

t1:
#branch to target t3, setting the link register
bl t3
#This is the instruction that it returns to
b t4

t2:
#branch to target t1 as an absolute address
ba t1

t3:
#branch to the address specified in the link register
#(i.e. the return address)
blr

t4:
li 0, 1
li 3, 0
sc


對這個程序進行彙編和鏈接,然後運行,方法如下:

as -a64 branch_example.s -o branch_example.o
ld -melf64ppc branch_example.o -o branch_example
./branch_example

請注意 b 和 ba 的目標在彙編語言中是以相同的方式來指定的,儘管二者在指令中的編碼方式大不相同。彙編器和鏈接器會負責爲我們將目標地址轉換成相對地址或絕對地址。


條件分支

比較寄存器

cmp 指令用來將寄存器與其他寄存器或立即操作數進行比較,並設置條件寄存器中適當狀態位。缺省情況下,定點比較指令使用 cr0 來存儲結果,但是這個域也可以作爲一個可選的第一操作數來指定。比較指令的用法如清單 3 所示:


清單 3. 比較指令的例子

#Compare register 3 and register 4 as doublewords (64 bits)
cmpd 3, 4

#Compare register 5 and register 10 as unsigned doublewords (64 bits)
cmpld 5, 10

#Compare register 6 with the number 12 as words (32 bits)
cmpwi 6, 12

#Compare register 30 and register 31 as doublewords (64 bits)
#and store the result in cr4
cmpd cr4, 30, 31


正如您可以看到的一樣,d 指定操作數爲雙字,而 w 則指定操作數爲單字。i 說明最後一個操作數是立即值,而不是寄存器,l 告訴處理器要進行無符號(也稱爲邏輯)比較操作,而不是進行有符號比較操作。

每條指令都會設置條件寄存器中的適當位(正如本文前面介紹的一樣),這些值然後會由條件分支指令來使用。

條件分支基礎

條件分支比無條件分支更加靈活,不過它的代價是可跳轉的距離不夠大。條件分支使用了 B-Form 指令格式:

B-Form 指令格式

0-5 位
操作碼

6-10 位
指定如何對位進行測試、是否使用計數寄存器、如何使用計數寄存器,以及是否進行分支預測(稱爲 BO 域)

11-15 位
指定條件寄存器中要測試的位(稱爲 BI 域)

16-29 位
絕對或相對地址

30 位
尋址模式 —— 該位設置爲 0 時,指定的地址就被認爲是一個相對地址;當該位設置爲 1 時,指定的地址就被認爲是一個絕對地址

31 位
鏈接位 —— 當該位設置爲 1 時,鏈接寄存器被設置成當前指令的下一條指令的地址;當該位設置爲 0 時,鏈接寄存器沒有設置

正如您可以看到的一樣,我們可以使用完整的 10 位值來指定分支模式和條件,這會將地址大小限制爲只有 14 位(範圍只有 16K)。這對於函數中的短跳轉非常有用,但是對其他跳轉指令來說就沒多大用處了。要有條件地調用一個 16K 範圍之外的函數,代碼需要進行一個條件分支,跳轉到一條包含無條件分支的指令,進而跳轉到正確的位置。

條件分支的基本格式如下所示:

bc BO, BI, address
bcl BO, BI, address
bca BO, BI, address
bcla BO, BI, address

在這個基本格式中,BO 和 BI 都是數字。幸運的是,我們並不需要記住所有的數字及其意義。PowerPC 指令集的擴展記憶法(在第一篇中已經介紹過了)在這裏又可以再次派上用場了,這樣我們就不必非要記住所有的數字。與無條件分支類似,在指令名後面添加一個 l 就可以設置鏈接寄存器,在指令名後面添加一個 a 會讓指令使用絕對尋址而不是相對尋址。

對於一個簡單比較且在比較結果相等時發生跳轉的情況來說,基本格式(沒有使用擴展記憶法)如下所示:


清單 4. 條件分支的基本格式

#compare register 4 and 5
cmpd 4, 5
#branch if they are equal
bc 12, 2 address


bc 表示“條件分支(branch conditionally)”。12(BO 操作數)的意思是如果給定的條件寄存器域被置位了就跳轉,不採用分支預測,2(BI 操作數)是條件寄存器中要測試的位(是等於位)。現在,很少有人(尤其是新手)能夠記住所有的分支編號和條件寄存器位的數字編號,這也沒太大用處。擴展記憶法可以讓代碼的閱讀、編寫和調試變得更加清晰。

有幾種方法可以指定擴展記憶法。我們將着重介紹指令名和指令的 BO 操作數(指定模式)的幾種組合。最簡單的用法是 bt 和 bf。 如果條件寄存器中的給定位爲真,bt 就會進行分支跳轉;如果條件寄存器中給定位爲假,bf 就會進行分支跳轉。另外,條件寄存器位也可以使用這種記憶法來指定。如果您指定了 4*cr3+eq,這會測試 cr3 的位 2(之所以會用 4* 是因爲每個域都是 4 位寬的)。位域中的每個位的可用記憶法已經在前面對條件寄存器的介紹中給出了。如果您只指定了位,而沒有指定域,那麼指令就會缺省爲 cr0。

下面是幾個例子:


清單 5. 簡單的條件分支

#Branch if the equal bit of cr0 is set
bt eq, where_i_want_to_go

#Branch if the equal bit of cr1 is not set
bf 4*cr1+eq, where_i_want_to_go

#Branch if the negative bit (mnemonic is "lt") of cr5 is set
bt 4*cr5+lt, where_i_want_to_go


另外一組擴展記憶法組合了指令、 BO 操作數和條件位(不過沒有域)。它們多少使用了“傳統”記憶方法來表示各種常見的條件分支。例如,bne my_destination(如果不等於 my_destination 就發生跳轉)與 bf eq, my_destination(如果 eq 位爲假就跳轉到 my_destination)是等效的。要利用這種記憶法來使用不同的條件寄存器域,可以簡單地在目標地址前面的操作數中指定域,例如 bne cr4, my_destination。這些分支記憶法遵循的模式是:blt(小於)、ble(小於或等於)、beq(等於)、 bge (大於或等於)、bgt(大於)、bnl(不小於)、bne(不等於)、bng(不大於)、 bso(溢出摘要)、 bns (無溢出摘要)、 bun(無序 —— 浮點運算專用) 和 bnu(非無序 —— 浮點運算專用)。

所有的記憶法和擴展記憶法可以在指令後面附加上 l 和/或 a 來分別啓用鏈接寄存器或絕對尋址。

使用擴展記憶法可以允許採用更容易讀取和編寫的編程風格。對於更高級的條件分支來說,擴展記憶法不僅非常有用,而且非常必要。

其他條件寄存器特性

由於條件寄存器有多個域,不同的計算和比較可以使用不同的域,而邏輯操作可以用來將這些條件組合在一起。所有的邏輯操作都有如下格式:cr<opname> target_bit, operand_bit_1, operand_bit_2。例如,要對 cr2 的 eq 位和 cr7 的 lt 位進行一個 and 邏輯操作,並將結果存儲到 cr0 的 eq 位中,就可以這樣編寫代碼:crand 4*cr0+eq, 4*cr2+eq, 4*cr7+lt。

您可以使用 mcrf 來操作條件寄存器域。要將 cr4 拷貝到 cr1 中,可以這樣做:mcrf cr1, cr4。

分支指令也可以爲分支處理器進行的分支預測提供提示。在最常用的條件分支指令後面加上一個 +,就可以向分支處理器發送一個信號,說明可能會發生分支跳轉。在指令後面加上一個 -,就可以向分支處理器發送一個信號,說明不會發生分支跳轉。然而,這通常都是不必要的,因爲 POWER5 CPU 中的分支處理器可以很好地處理分支預測。


使用計數寄存器

計數寄存器是循環計數器使用的一個專用寄存器。條件分支的 BO 操作數(控制模式)也可以使用,用來指定如何測試條件寄存器位,減少並測試計數寄存器。下面是您可以對計數寄存器執行的兩個操作:

減少計數寄存器,如果爲 0 就分支跳轉
減少計數寄存器,如果非 0 就分支跳轉
這些計數寄存器操作可以單獨使用,也可以與條件寄存器測試一起使用。

在擴展記憶法中,計數寄存器的語義可以通過在 b 後面立即添加 dz 或 dnz 來指定。任何其他條件或指令修改符也都可以添加到這後面。因此,要循環 100 次,您可以將 100 加載到計數寄存器中,並使用 bdnz 來控制循環。代碼如下所示:


清單 6. 使用計數器控制循環的例子

#The count register has to be loaded through a general-purpose register
#Load register 31 with the number 100
li 31, 100
#Move it to the count register
mtctr 31

#Loop start address
loop_start:

###loop body goes here###

#Decrement count register and branch if it becomes nonzero
bdnz loop_start

#Code after loop goes here


您也可以將計數器測試與其他測試一起使用。舉例來說,循環可能需要有一個提前退出條件。下面的代碼展示了當寄存器 24 等於寄存器 28 時就會觸發的提前退出條件。


清單 7. 計數寄存器組合分支的例子

#The count register has to be loaded through a general-purpose register
#Load register 31 with the number 100
li 31, 100
#Move it to the count register
mtctr 31

#Loop start address
loop_start:

###loop body goes here###

#Check for early exit condition (reg 24 == reg 28)
cmpd 24, 28

#Decrement and branch if not zero, and also test for early exit condition
bdnzf eq, loop_start

#Code after loop goes here


因此,我們並不需要再增加一條條件分支指令,所需要做的只是將比較指令和條件指令合併爲一個循環計數器分支。


綜合

現在我們將在實踐中應用上面介紹的內容。

下面的程序利用了第一篇文章中所介紹的最大值程序,並根據我們學習到的知識進行了重新編寫。該程序的第一個版本使用了一個寄存器來保存所讀取的當前地址,並通過間接尋址加載值。這個程序要做的是使用索引間接尋址模式,使用一個寄存器作爲基地址,使用另一個寄存器作爲索引。另外,除了索引是從 0 開始並簡單增加之外,索引還會從尾到頭進行計數,用來保存額外的比較指令。減量可以隱式地設置條件寄存器(這與和 0 顯式比較不同)以供條件分支指令隨後使用。下面是最大值程序的新版本(您可以將其輸入到 max_enhanced.s 文件中):


清單 8. 最大值程序的增強版本

###PROGRAM DATA###
.data
.align 3

value_list:
.quad 23, 50, 95, 96, 37, 85
value_list_end:

#Compute a constant holding the size of the list
.equ value_list_size, value_list_end - value_list

###ENTRY POINT DECLARATION###
.section .opd, "aw"
.global _start
.align 3
_start:
.quad ._start,
.TOC.@tocbase
, 0


###CODE###
._start:
.equ DATA_SIZE, 8

#REGISTER USAGE
#Register 3 -- current maximum
#Register 4 -- list address
#Register 5 -- current index
#Register 6 -- current value
#Register 7 -- size of data (negative)

#Load the address of the list
ld 4,
value_list@got(2)
#Register 7 has data size (negative)
li 7, -DATA_SIZE
#Load the size of the list
li 5, value_list_size
#Set the "current maximum" to 0
li 3, 0

loop:
#Decrement index to the next value; set status register (in cr0)
add. 5, 5, 7

#Load value (X-Form - add register 4 + register 5 for final address)
ldx 6, 4, 5

#Unsigned comparison of current value to current maximum (use cr2)
cmpld cr2, 6, 3

#If the current one is greater, set it (sets the link register)
btl 4*cr2+gt, set_new_maximum

#Loop unless the last index decrement resulted in zero
bf eq, loop

#AFTER THE LOOP -- exit
li 0, 1
sc

set_new_maximum:
mr 3, 6
blr (return using the link register)


對這個程序進行彙編、鏈接和執行,方法如下:

as -a64 max_enhanced.s -o max_enhanced.o
ld -melf64ppc max_enhanced.o -o max_enhanced
./max_enhanced


這個程序中的循環比第一篇文章中的循環大約會快 15%,原因有兩個: (a) 主循環中減少了幾條指令,這是由於在我們減少寄存器 5 時使用了狀態寄存器來檢測列表的末尾; (b) 程序使用了不同的條件寄存器域來進行比較(因此減量的結果可以保留下來供以後使用)。

請注意在對 set_new_maximum 的調用中使用鏈接寄存器並非十分必要。即使不使用鏈接寄存器,它也可以很好地設置返回地址。不過,這個使用鏈接寄存器的例子會有助於說明鏈接寄存器的用法。


簡單函數簡介

PowerPC ABI 相當複雜,我們將在下一篇文章中繼續介紹。然而,對於那些不會調用任何其他函數並且遵循簡單規則的函數來說,PowerPC ABI 提供了相當簡單的函數調用機制。

爲了能夠使用這個簡化的 ABI,您的函數必須遵循以下規則:

不能調用任何其他函數。
只能修改寄存器 3 到 12。
只能修改條件寄存器域 cr0、cr1、cr5、cr6 和 cr7。
不能修改鏈接寄存器,除非在調用 blr 返回之前已經復原了鏈接寄存器。
當函數被調用時,參數都是在寄存器中發送的,這些參數保存在寄存器 3 到寄存器 10,需要使用多少個寄存器取決於參數的個數。當函數返回時,返回值必須保存到寄存器 3 中。

下面讓我們將原來的最大值程序作爲一個函數進行重寫,然後在 C 語言中調用這個函數。

我們應該傳遞的參數如下:指向數組的指針,這是第一個參數(寄存器 3);數組大小,這是第二個參數(寄存器 4)。之後,最大值就可以放入寄存器 3 中作爲返回值。

下面就是我們將其作爲函數改寫後的程序(將其輸入到 max_function.s 文件中):


清單 9. 函數形式的最大值程序

###ENTRY POINT DECLARATION###
#Functions require entry point declarations as well
.section .opd, "aw"
.global find_maximum_value
.align 3
find_maximum_value:
.quad .find_maximum_value,
.TOC.@tocbase
, 0

###CODE###
.text
.align 3

#size of array members
.equ DATA_SIZE, 8

#function begin
.find_maximum_value:
#REGISTER USAGE
#Register 3 -- list address
#Register 4 -- list size (elements)
#Register 5 -- current index in bytes (starts as list size in bytes)
#Register 6 -- current value
#Register 7 -- current maximum
#Register 8 -- size of data

#Register 3 and 4 are already loaded -- passed in from calling function
li 8, -DATA_SIZE

#Extend the number of elements to the size of the array
#(shifting to multiply by 8)
sldi 5, 4, 3

#Set current maximum to 0
li, 7, 0
loop:
#Go to next value; set status register (in cr0)
add. 5, 5, 8

#Load Value (X-Form - adds reg. 3 + reg. 5 to get the final address)
ldx 6, 3, 5

#Unsigned comparison of current value to current maximum (use cr7)
cmpld cr7, 6, 7

#if the current one is greater, set it
bt 4*cr7+gt, set_new_maximum
set_new_maximum_ret:

#Loop unless the last index decrement resulted in zero
bf eq, loop

#AFTER THE LOOP
#Move result to return value
mr 3, 7

#return
blr

set_new_maximum:
mr 7, 6
b set_new_maximum_ret


這和前面的版本非常類似,主要區別如下:

初始條件都是通過參數傳遞的,而不是寫死的。
函數中寄存器的使用都爲匹配所傳遞的參數的佈局進行了修改。
刪除了 set_new_maximum 對鏈接寄存器不必要的使用以保護鏈接寄存器的內容。
這個程序使用的 C 語言數據類型是 unsigned long long。這編寫起來非常麻煩,因此最好將其用 typedef 定義爲另外一個類型,例如 uint64。這樣一來,此函數的原型就會如下所示:

uint64 find_maximum_value(uint64[] value_list, uint64 num_values);


下面是測試新函數的一個簡單驅動程序(可以將其輸入到 use_max.c 中):


清單 10. 使用最大值函數的 C 程序

#include <stdio.h>

typedef unsigned long long uint64;

uint64 find_maximum_value(uint64[], uint64);

int main() {
uint64 my_values[] = {2364, 666, 7983, 456923, 555, 34};
uint64 max = find_maximum_value(my_values, 6);
printf("The maximum value is: %llu\n", max);
return 0;
}


要編譯並運行這個程序,可以簡單地執行下面的操作:

gcc -m64 use_max.c max_function.s -o maximum
./maximum


請注意由於我們實際上是在進行格式化打印,而不是將值返回到 shell 中,因此可以使用 64 位大小的全部數組元素。

簡單函數調用在性能方面的開銷非常小。簡化的函數調用 ABI 完全是標準的,更易於編寫混合語言程序,這類程序要求在其核心循環中具有定製彙編語言的速度,在其他地方具有高級語言的表述性和易用性。

瞭解分支處理器的詳細內容可以幫助我們編寫更加有效的 PowerPC 代碼。使用不同的條件寄存器域可以讓程序員按照自己感興趣的方法來保存並組合條件。使用計數寄存器可以幫助實現更加有效的代碼循環。簡單函數甚至讓新手程序員也可以編寫非常有用的彙編語言函數,並將其提供給高級語言程序使用。

ABI,或稱爲應用程序二進制接口,是一組允許使用不同語言編寫的程序或使用不同編譯器鏈接的程序相互調用彼此的函數的約定集。本文是 4 部分系列文章 的最後一部分,討論了用於 64 位 ELF (類 UNIX)系統上的 PowerPC? ABI;不管您是否使用彙編語言編寫程序,它們都可以幫助您爲 POWER5? 和其他基於 PowerPC 的處理器更加有效地編寫 64 位應用程序。32 位的 ABI 也是存在的,但在本文中並沒有介紹。

簡化的 ABI


本系列的上一篇 “使用 PowerPC 分支處理器進行編程”對 “簡化”ABI 簡要進行了討論。使用它可以花費最少的努力就能編寫出符合特定標準的函數。函數要想使用簡化 ABI 必須滿足以下標準:

不能調用其他函數。
只能修改寄存器 3 到 12(不過有一些例外,請參看下面的非易失性寄存器保存區)。
只能修改寄存器的以下域:cr0、cr1、cr5、cr6 和 cr7。
如果您的代碼還使用了 PowerPC 向量處理擴展,那就還會有幾個其他限制,不過這已經超出了本文的範圍。

有趣的是,在使用簡化 ABI 時,您並不需要以任何方式進行聲明,因爲它是常用 ABI 的一個完全兼容的子集,用於不需要堆棧幀的函數,這將在下一節中詳細討論。

在使用 PowerPC ABI 語義調用一個函數時,它會使用寄存器將自己的參數傳遞給函數。寄存器 3 裏面保存了第一個定點參數,寄存器 4 中保存的是第二個參數,依此類推,直到寄存器 10。同理,浮點值是通過浮點寄存器 1 到 13 傳遞的。當這個函數完成時,返回值會通過寄存器 3 返回,函數本身使用 blr 指令退出。

爲了展示簡化 PowerPC ABI 的用法,下面讓我們來看這樣一個函數:它接受一個參數,計算它的平方,然後返回結果。下面是使用彙編語言編寫的這個函數(請將下面的代碼輸入到 my_square.s 文件中):


清單 1. 使用簡化 ABI 計算一個數字平方的函數

###FUNCTION ENTRY POINT DECLARATION###
.section .opd, "aw"
.align 3

.global my_square
my_square: #this is the name of the function as seen
.quad .my_square,
.TOC.@tocbase
, 0
#Tell the linker that this is a function reference
.type my_square, @function

###FUNCTION CODE HERE###
.text
.my_square: #This is the label for the code itself (referenced in the "opd")
#Parameter 1 -- number to be squared -- in register 3

#Multiply it by itself, and store it back into register 3
mulld 3, 3, 3

#The return value is now in register 3, so we just need to leave
blr


之前,您一直使用 .opd 段來聲明該程序的入口,不過現在它被用來聲明一個函數。它們稱爲正式過程描述符(official procedure descriptor),其中包含了鏈接器將不同共享對象中與位置無關的代碼組合在一起所需要的信息。最重要的一個域是第一個域,它是過程的起點代碼的地址。第二個域是這個函數使用的 TOC 指針。第三個域是語言的一個環境指針(如果該語言使用了環境指針的話),不過通常都會被設置爲 0。注意只有全局導出的符號定義纔是正式過程描述符。

這個函數的 C 語言原型如下所示:


清單 2. 計算數字平方的函數的 C 原型

typedef long long int64;
int64 my_square(int64 val);


下面是使用這個函數的 C 代碼(請將下面的代碼輸入到 my_square_tester.c 文件中):


清單 3. 調用 my_square 函數的 C 代碼

#include <stdio.h>


typedef long long int64;

int64 my_square(int64);

int main() {
int a = 32;
printf("The square of %lld is %lld.\n", a, my_square(a));
return 0;
}


編譯並運行這段代碼的簡單方法如下所示:


清單 4. 編譯並運行 my_square_tester

gcc -m64 my_square.s my_square_tester.c -o my_square_tester
./my_square_tester


-m64 標誌告訴編譯器使用 64 位指令,使用 64 位 ABI 和庫進行編譯,使用 64 位 ABI 進行鏈接。它然後會負責處理所有的鏈接問題(通常還有幾個問題 —— 您可以在命令行後面加上 -v 來查看完整的鏈接命令)。

正如您可以看到的一樣,使用簡化的 PowerPC ABI 編寫函數非常簡單。當函數不滿足這些標準時就會出現問題。

堆棧

現在讓我們開始介紹 ABI 中更加複雜的部分。任何 ABI 中最重要的部分都是具體如何使用堆棧,即保存本地函數數據的內存區域。

對於堆棧的需要

瞭解爲什麼需要堆棧的最好方法是查看遞歸函數的情況。爲了簡單起見,讓我們瞭解一下階乘函數的遞歸實現:


清單 5. 階乘函數

typedef long long int64;
int64 factorial(int64 num) {
//BASE CASE
if (num == 0) {
return 1;
//RECURSIVE CASE
} else {
return num * factorial(num - 1);
}
}


從概念上來講,這很容易理解,不過讓我們具體看一下它是如何工作的。此處到底做了什麼?例如,如果我們要計算 4 的階乘爲多少,到底會發生什麼呢?下面讓我們來順序看一下:

首先,這個函數會被調用,num 會設置爲 4。然後,由於 num 大於 0,因此會再次調用 factorial,不過這次是計算 3 的階乘了。現在,在新一次調用 factorial 時,num 被設置爲 3。然而,儘管它們共享了相同的名字和代碼,但它所引用的內存地址與前一次不同。儘管這是相同代碼中的相同變量名,不過 num 這次不同了。這是由於每次調用一個函數時,都有一個相關活動記錄(activation record) (也稱爲 堆棧幀)。活動記錄包含了這個函數的所有與調用有關的數據,包括參數和本地變量。這就是遞歸函數是如何保證其他活動函數調用中的變量值不受影響的。每個調用都有自己的活動記錄,因此每次被調用時,變量都會在活動記錄中獲得自己的存儲空間。直到函數調用徹底完成時,活動記錄使用的空間纔會被釋放以便重用。

因此,使用 3 作爲 num 的值時,我們需要再次執行這個函數,然後使用 2、1、0 來逐一執行這個函數。然而,在使用 0 調用這個函數時,此函數就達到了自己的基線條件(base case)。基線條件就是函數終止對自身的調用並返回的條件。因此,使用 0 作爲 num 時,就返回 1 作爲結果。之前的函數會在這個函數退出時接收這個值(調用 factorial(0)),並使用結果 1 乘以 num 的值(也是 1)。然後將這個結果返回,並重新激活下一個等待的函數。這個函數用結果 1 乘以 num 的值(爲 2),結果爲 2,然後將這個結果返回。然後重新激活下一個等待的函數調用,使用前一次調用的結果乘以這個函數的 num 的值(爲 3),結果是 6。這個結果返回給原始的函數,它的 num 值爲 4。它與前一個結果的乘積爲 24。

正如您可以看到的一樣,每次一個函數調用另外一個函數時,在下一次發生調用時,它自己的值和狀態都會被掛起。這對於所有的函數來說都是這樣,而不僅僅是遞歸函數如此。如果這個函數又一次調用了其他函數,其狀態也會被掛起。當一個函數返回時,調用它的函數就會被喚醒,然後從此處繼續執行。因此,正如我們看到的一樣,“活動”函數調用在其他函數調用之上被壓入堆棧,然後在每個函數返回時從堆棧中刪除。結果看起來如下所示(factorial 縮寫爲 fac):

fac(4) [active]
fac(4) [suspended], fac(3) [active]
fac(4) [suspended], fac(3) [suspended], fac(2) [active]
fac(4) [suspended], fac(3) [suspended], fac(2) [suspended], fac(1) [active]
fac(4) [suspended], fac(3) [suspended], fac(2) [suspended], fac(1) [suspended], fac(0) [active]
fac(4) [suspended], fac(3) [suspended], fac(2) [suspended], fac(1) [active]
fac(4) [suspended], fac(3) [suspended], fac(2) [active]
fac(4) [suspended], fac(3) [active]
fac(4) [active]
正如您可以看到的一樣,
掛起的函數的活動記錄“壓入堆棧”,然後在每個函數返回時,就從堆棧中彈出。

堆棧佈局

爲了實現這種思想,每個程序都分配了一定的內存,稱爲程序堆棧(program stack)。所有的 PowerPC 程序都需要由一個指向寄存器 1 中的這個堆棧的指針啓動。在 PowerPC ABI 中,寄存器 1 通常都是指向堆棧頂部。這對函數了解自己的活動記錄在什麼地方提供了方便 —— 它們可以使用堆棧指針的形式簡單地進行定義。如果一個函數正在執行,那麼堆棧指針就會指向整個堆棧的頂部,這也是該函數活動記錄的頂部。由於活動記錄是在堆棧上實現的,它們通常也會被稱爲堆棧幀,這兩個術語是對等的。

現在,在使用 “棧頂”這個術語時,這通常都指的是概念上的說法。從物理上來說,堆棧是是向下伸展的,即從內存高地址向內存低地址伸展(物理上,棧頂也隨着壓棧慢慢下移)。因此,寄存器 1 會有一個指向堆棧的概念頂部的指針,它使用正偏移量引用的堆棧位置從概念上來說實際上是在堆棧頂部之下,負的偏移量從概念上來說反而是在堆棧頂部之上。因此, 0(1) 引用的是概念上的棧頂,4(1) 引用的是棧頂之下 4 個字節的位置(概念上的棧頂之下,物理上的棧頂之上),24(1) 從概念上來說位置更低,而 100(1) 又低一些。

現在您已經理解了堆棧在概念和物理上是如何組織的,接下來讓我們瞭解一下各個堆棧幀裏面到底保存了什麼內容。下面是 64 位 PowerPC ABI 中規定的堆棧的佈局,這是從物理內存的觀點來說的(在給出堆棧偏移量時,它所引用的是內存中這個位置的起點):


表 1. 堆棧幀佈局
包含內容 大小 起始堆棧偏移量
浮點非易失性寄存器保存區 可變的 可變的
通用非易失性寄存器保存區 可變的 可變的
VRSAVE 4 字節 可變的
對齊補齊 4 字節或 12 字節 可變的
向量非易失性寄存器保存區 可變的可變的(必須是按照 4 字對齊的)
本地變量存儲 可變的 可變的
函數調用使用的參數 可變的(至少 64 字節) 48(1)
TOC 保存區 8 40(1)
鏈接編輯器區 8 32(1)
編譯器區 8 24(1)
鏈接寄存器保存區 8 16(1)
條件寄存器保存區 8 8(1)
指向前一個堆棧幀頂部的指針 8 0(1)


我無意讓您過分關注浮點、VRSAVE、Vector 或對齊空間的主題。這些主題涉及的是浮點和向量處理,已經超出了本文的範圍。所有堆棧的值都必須是雙字(8 個字節)對齊的,整個幀應該是 4 字(16 個字節)對齊的。所有的參數都是雙字對齊的。

現在,讓我們介紹一下堆棧禎中的每個部分都實現什麼功能。
非易失性寄存器保存區

堆棧幀的第一個部分是非易失性寄存器保存區。PowerPC ABI 中的寄存器被劃分成 3 種基本類型:專用寄存器、易失性寄存器和非易失性寄存器。專用寄存器是那些有預定義的永久功能的寄存器,例如堆棧指針(寄存器 1)和 TOC 指針(寄存器 2)。寄存器 3 到 12 是易失性寄存器,這意味着任何函數都可以自由地對這些寄存器進行修改,而不用恢復這些寄存器之前的值。然而,這意味着一個函數在任何時候調用另外一個函數時,都應假設寄存器 3 到 12 均有可能被這個函數改寫。

而寄存器 13 及其之上的寄存器都是非易失性寄存器。這意味着函數可以使用這些寄存器,前提是從函數返回之前這些寄存器的值已被恢復。因此,在函數中使用非易失性寄存器之前,它的值必須保存到該函數的堆棧幀中,然後在函數返回之前恢復。類似地,函數也可以假設它給非易失性寄存器賦的值在調用其他函數時都不會被修改(至少會重新恢復)。函數可以根據需要使用這個保存區中任意大小的內存。

現在您可以看到爲什麼簡化 ABI 之前的規則要求只使用寄存器 3 到寄存器 12:其他寄存器都是非易失性的,需要堆棧空間來保存這些寄存器的值。因此,爲了使用其他寄存器,就必須將其保存到堆棧中。然而,ABI 實際上有一種方法能夠解決這個限制。函數可以自由使用 288 字節的內存,對於不調用其他函數的函數來說,這段內存物理上在堆棧指針之下。因此,使用簡化 ABI 的函數實際上可以通過從堆棧指針開始的負偏移量來保存、使用和恢復非易失性寄存器。

本地變量存儲

本地變量存儲區是用來保存函數專用數據的通用區域。通常這並不需要,因爲在 PowerPC 體系結構中有大量寄存器可以使用。然而,這些空間通常可以用於本地數組。這個區域的大小可以按照函數需要而變。

函數調用的參數

函數參數與其他本地數據的處理稍有不同。PowerPC ABI 實際上會將函數參數使用的存儲空間放入調用函數的堆棧空間中。現在,正如您之前看到的一樣,函數調用實際上是通過寄存器來傳遞參數的。然而,在需要保存值的情況下,依然需要爲參數預留空間;尤其在需要使用易失性寄存器傳遞參數時更是如此。這個空間也用來在溢出情況中使用:如果參數個數多於可用寄存器的數目,那麼它們就需要進入堆棧空間中。由於這個參數區是由從當前函數調用的所有函數共享的,因此當函數建立自己的堆棧空間時,就需要爲在函數調用中使用的參數的最大個數來預留空間。

這樣,函數就可以知道自己的參數在什麼地方,參數是從內存底部向頂部來存儲的第一個參數在 48(1) 中,第二個參數在 56(1) 中。不管參數列表區域多大,被調用的函數總可以知道每個參數的精確偏移量。記住,參數列表區是針對函數的所有調用定義的,因此可能會比任何單次函數調用需要的空間都大。

現在,由於傳遞給函數的參數的保存區實際上都位於調用函數的堆棧幀中,因此當函數建立自己的堆棧幀時,到參數列表的偏移量現在只能進行調整來適應函數自己的堆棧幀大小。讓我們假設函數 func1 使用 3 個參數來調用 func2,並且 func2 有一個 112 字節的堆棧幀。如果 func2 希望訪問自己第一個參數的內存,就可以使用 160(1) 來引用它,這是因爲它要先經過自己的堆棧幀(112 字節),然後到達最後一個幀中的第一個參數(48 字節)。

幸運的是,函數很少需要訪問自己的參數保存區,因爲大部分參數都是通過寄存器傳遞的,而不會保存在參數保存區中。然而,即使參數保存區沒有保存任何內容,也需要爲參數分配空間。函數必須假設對於自己的前 8 個參數,它們只會通過寄存器傳遞,但是如果需要在程序中對參數進行存儲,就仍然需要一個可用的保存區。這段空間也必須至少是 64 個字節。

TOC、鏈接編輯器和編譯器區

TOC 保存區、編譯器區和鏈接器區都會爲系統使用而預留出來,程序員不能對它們進行修改,但是必須要爲它們預留空間。

鏈接寄存器保存區

鏈接寄存器保存區與 ABI 的其他部分不同。當函數開始時,它實際上會將鏈接寄存器保存到調用函數的堆棧幀中,而不是自己的堆棧幀中,然後只有在需要時纔會保存它。大部分調用其他函數的函數都會需要它。

條件寄存器保存區

如果條件寄存器的其他非易失性域被修改了,那就需要條件寄存器保存區。非易失性域有 cr2、cr3 和 cr4。在對任何域進行修改之前,都應該將條件寄存器保存到堆棧的這個區域中,然後在返回之前恢復。

指向前一堆棧幀

堆棧幀中的最後一個條目是一個指向前一堆棧幀的指針,通常被稱爲後向指針(back pointer)。

編寫使用堆棧的函數

函數在函數開始過程中(稱爲函數序言(function prologue))創建堆棧幀,並在函數結束時(稱爲函數尾聲(function epilogue))銷燬它。

函數的序言通常遵循以下順序:

預留堆棧空間,並使用 stdu 1, -SIZE_OF_STACK(1)(其中 SIZE_OF_STACK 是這個函數堆棧幀的大小)保存原來的堆棧指針。這會保存原來的堆棧指針,並自動分配堆棧內存。


如果這個函數調用了另外一個函數,或者以任何方式使用了鏈接寄存器,就會由 mflr 0 指令進行保存,然後再存儲進調用這個函數的函數的鏈接寄存器保存區(使用 std 0, SIZE_OF_STACK+16(1) 指令)。


保存這個函數中使用的所有非易失性寄存器(包括條件寄存器,如果使用了該寄存器的任何非易失性域的話)。
該函數的尾聲則遵循相反的順序,恢復已經保存的值,然後使用 ld 1, 0(1) 銷燬堆棧幀,它會將前一個堆棧指針加載回堆棧指針寄存器中。

現在,讓我們回到最初未使用堆棧實現的函數上來,瞭解一下使用了堆棧的情況是怎樣的(請將下面的代碼輸入到 my_square.s 中,並按照以前的方法對其進行編譯和運行):


清單 6. 使用堆棧計算數字平方的函數

###FUNCTION ENTRY POINT DECLARATION###
.section .opd, "aw"
.align 3

.global my_square
my_square: #this is the name of the function as seen
.quad .my_square,
.TOC.@tocbase
, 0
.type my_square, @function

###FUNCTION CODE HERE###
.text
.my_square: #This is the label for the code itself (Referenced in the "opd")
##PROLOGUE##
#Set up stack frame & back pointer (112 bytes -- minimum stack)
stdu 1, -112(1)
#Save LR (optional)
mflr 0
std 0, 128(1)
#Save non-volatile registers (we don't have any)

##FUNCTION BODY##
#Parameter 1 -- number to be squared -- in register 3
mulld 3, 3, 3

#The return value is now in register 3, so we just need to leave

##EPILOGUE##
#Restore non-volatile registers (we don't have any)
#Restore LR (not needed in this function, but here anyway)
ld 0, 128(1)
mtlr 0
#Restore stack frame atomically
ld 1, 0(1)
#Return
blr


這與之前的代碼完全相同,不過增加了序言和尾聲代碼。正如前面介紹的一樣,這段代碼非常簡單,並不需要序言和尾聲代碼,使用簡化 ABI 就完全可以。不過它卻是如何建立和銷燬堆棧幀的一個很好的例子。

現在,讓我們回到階乘函數上來。這個函數,從它調用自己開始,就很好地使用了堆棧幀。讓我們來看一下階乘函數在彙編語言中是如何工作的(請將下面的代碼輸入到 factorial.s)中):


清單 7. 彙編語言中的階乘函數

###ENTRY POINT###
.section .opd, "aw"
.align 3

.global factorial
factorial:
.quad .factorial,
.TOC.@tocbase
, 0
.type factorial, @function

###CODE###
.text
.factorial:
#Prologue
#Reserve Space
#48 (save areas) + 64 (parameter area) + 8 (local variable) = 120 bytes.
#aligned to 16-byte boundary = 128 bytes
stdu 1, -128(1)
#Save Link Register
mflr 0
std 0, 144(1)

#Function body

#Base Case? (register 3 == 0)
cmpdi 3, 0
bt- eq, return_one

#Not base case - recursive call
#Save local variable
std 3, 112(1)
#NOTE - it could also have been stored in the parameter save area.
# parameter 1 would have been at 176(1)

#Subtract One
subi 3, 3, 1

#Call the function (branch and set the link register to the return address)
bl factorial
#Linker word
nop

#Restore local variable (but to a different register -
#register 3 is now the return value from the last factorial
#function)
ld 4, 112(1)
#Multiply by return value
mulld 3, 3, 4
#Result is in register 3, which is the return value register

factorial_return:
#Epilogue
#Restore Link Register
ld 0, 144(1)
mtlr 0
#Restore stack
ld 1, 0(1)
#Return
blr

return_one:
#Set return value to 1
li 3, 1
#Return
b factorial_return


要在 C 語言中對這段代碼進行測試,請輸入下面的代碼(請將下面的代碼輸入到 factorial_caller.c 文件中):


清單 8. 調用階乘函數的程序

#include <stdio.h>
typedef long long int64;
int64 factorial(int64);

int main() {
int64 a = 10;
printf("The factorial of %lld is %lld\n", factorial(a));
return 0;
}


請按照下面的方式編譯並運行這段代碼:


清單 9. 編譯並運行階乘程序

gcc -m64 factorial.s factorial_caller.c -o factorial
./factorial


這個階乘函數有幾個非常有趣的地方。首先,我們使用了本地變量存儲空間,另外,我們還會將當前參數保存到 112(1) 中。現在,由於這是一個函數參數,因此我們要保存另外一個雙字堆棧空間,並將其保存到調用函數的參數區中。

這個程序中另外一個有趣之處是函數調用之後的 nop 指令。這是 ABI 所需要的。如果在鏈接過程中需要,這條額外指令會允許鏈接器插入其他代碼。例如,如果有一個程序具有足夠多的符號可供多個 TOC 使用(TOC 在 “第 2 部分:PowerPC 上加載和存儲數據的藝術” 中進行了介紹),鏈接器就會發出一條指令(或使用分支的多條指令)來爲您在多個 TOC 之間進行切換。

最後,請注意函數調用的分支目標並不是啓動它的代碼,而是 .opd 入點描述符。鏈接器會負責將它轉換爲指向正確的代碼。然而,這可以讓鏈接器知道有關函數的其他信息,包括它使用的是哪個 TOC,這樣如果需要,就可以產生代碼來在 TOC 之間進行切換了。

創建動態庫

現在您已經知道如何創建函數了,接下來可以將它們一起放到一個庫中。實際上您並不需要編寫任何其他代碼,只需要將它們編譯在一起就可以了。要將 factorial 和 my_square 函數編譯到一個庫中(讓我們將其稱爲 libmymath.so),只需要輸入下面的內容:


清單 10. 編譯共享庫

gcc -m64 -shared factorial.s my_square.s -o libmymath.so


這會指示編譯器生成一個名爲 libmymath.so 的共享對象。要將其鏈接到可執行程序中,需要啓用編譯時鏈接器和運行時動態鏈接器來定位它。要編譯這個階乘調用函數來使用共享對象,可以按照下面的方式來編譯和鏈接:


清單 11. 使用共享庫

#-L tells what directories to search, -l tells what libraries to find
gcc -m64 factorial_caller.c -o factorial -L. -lmymath
#Tell the dynamic linker what additional directories to search
export LD_LIBRARY_PATH=.
#Run the program
./factorial


當然,如果這個庫被安裝到了一個標準的庫位置,就不用使用這些目錄標誌了。

正如在 “第 2 部分:PowerPC 上加載和存儲數據的藝術” 中介紹的一樣,應用程序的 TOC(或目錄表)只有 64KB 的空間來存儲全局數據引用。因此,當幾個共享對象都要加載到相同的應用程序空間並且目錄表太大時又該如何呢?這就是 .TOC.@tocbase 引用對正式過程描述符的用處所在。鏈接器可以在單個應用程序中管理多個 TOC。 .TOC.@tocbase 會指示鏈接器將這個函數的 TOC 的地址放到這裏。然後,當鏈接器設置對函數的引用時,就會將當前函數的 TOC 與所調用的函數的 TOC 進行比較。如果二者相同,就保留調用不變。如果二者不同,就修改代碼以切換函數調用和函數返回上的 TOC 引用。這是採用正式過程描述符的主要原因之一,也是在函數調用之後要再加上一條 nop 指令的原因之一。由於這個原因,您永遠都不用擔心會由於鏈接了太多共享對象而導致全局符號空間用光的問題。


簡化 64 位 ABI 只是程序中使用的一個很小部分,不過完整的 ABI 也並不會困難多少。最困難的部分是確定堆棧幀不同部分的不同偏移量,瞭解每個部分應該放到哪裏,以及大小應該是多少。

使用彙編語言創建可重用的庫非常迅速,也非常簡單。要將使用 64 位 ABI 的函數轉換到共享庫中,您所需要的就是另外幾個編譯器標誌,僅此而已。

希望本系列文章能夠讓您瞭解 PowerPC 編程是多麼地簡單,而功能又是多麼地強大。在您的下一個項目中,您就可以考慮通過使用彙編語言來充分利用 POWER5 芯片所提供的全部資源。

發佈了34 篇原創文章 · 獲贊 19 · 訪問量 31萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章