用于 Power 体系结构的汇编语言 收藏
第 1 部分: 编程概念和基本 PowerPC 指令
POWER5 和其他 PowerPC 处理器系列产品
POWER5™ 处理器是支持 PowerPC® 指令集的高性能处理器系列产品中的最新产品。此系列处理器中的第一款 64 位处理器就是 POWER3。Macintosh G5 处理器是 POWER4 处理器的扩展,增加了额外的向量处理单元。POWER5 处理器是最新一代的 POWER 处理器,同时具备双核和对称多线程功能。这使单独一个芯片能够同步处理 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 指令集而编译的应用程序。
访问 POWER5 处理器
目前所有的 IBM iSeries 和 pSeries 服务器都使用 POWER5 处理器,并可运行 Linux。此外,开源开发人员还可请求访问 POWER5 机器,以便通过 IBM 的 OpenPower 项目移植应用程序(相关链接请参见 参考资料 部分)。在一台 G5 Power Macintosh 上运行 PowerPC 发布版即可访问略加修改的 POWER4 处理器,它也是 64 位的。G4 和更早的版本则仅有 32 位。
Debian、Red Hat、SUSE 和 Gentoo 均有一个或多个发布版支持 POWER5 处理器,只有 Red Hat Enterprise Linux AS、SUSE Linux Enterprise Server 和 OpenSUSE 支持 IBM iSeries 服务器(其余均支持 IBM pSeries 服务器)。
高级编程与低级编程的对比
大多数编程语言都与处理器保持着相当程度的独立性。但都有一些特殊特性依赖于处理器的某些功能,它们更有可能是特定于操作系统的,而不是特定于处理器的。 构建高级编程语言的目的是在程序员和硬件体系结构间搭建起一座桥梁。这样做有多方面的原因。尽管可移植性是原因之一,但更重要的一点或许是提供一种更友好 的模型,这种模型的建立方式更接近程序员的思考方式,而不是芯片的连线方式。
然而,在汇编语言编程中,您要直接应对处理器的指令集。这意味着您看系统的方式与硬件相同。这也有可能使汇编语言编程变得更为困难,因为编程模型的建立更 倾向于使硬件工作,而不是密切反映问题域。这样做的好处在于您可以更轻松地完成系统级任务、执行那些与处理器相关性很强的优化任务。而缺点是您必须在那个 级别上进行思考,依赖于一种特定的处理器系列,往往还必须完成许多额外的工作以准确地建模问题域。
关于汇编语言,很多人未想到的一个好处就是它非常具体。在高级语言中,对每个表达式都要进行许多处理。您有时不得不担忧幕后到底发生了哪些事情。在汇编语言编程中,您可以完全精确地掌控硬件的行为。您可以逐步处理硬件级更改。
汇编语言基础
在了解指令集本身之前,有两项关于汇编语言的关键内容需要理解,也就是内存模型和获取-执行周期。
内存模型非常简单。内存只存储一种东西 —— 固定范围内的数字,也称为字节(在大多数计算机上,这是一个 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, .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 中。
结束语
如果一切顺利,您现在对 PowerPC 的汇编语言编程应有了基本的了解。指令最初看上去可能有点麻烦,但习惯总会成自然。在下一期文章中,我们将介绍 PowerPC 处理器的各种寻址模式,说明如何更有效地将它们用于 64 位编程。第 3 篇文章将更全面地介绍 ABI,讨论有哪些寄存器、分别有哪些用途、如何调用函数并从函数返回,以及关于 ABI 的其他有趣内容。
第 2 部分: PowerPC 上加载和存储的艺术
寻址模式以及寻址模式之所以重要的原因
在开始讨论寻址模式之前,让我们首先来回顾一下计算机内存的概念。您可能已经了解了关于内存和编程的一些事实,但是由于现代编程语言正试图淡化计算机中的一些物理概念,因此复习一下相关内容是很有用的:
主存中的每个位置都使用连续的数字地址 编号,内存位置就使用这个地址来引用。
每个主存位置的长度都是一个字节。
较大的数据类型可以通过简单地将多个字节当作一个单位实现(例如,将两个内存位置放到一起作为一个 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 种不同的指令格式,很多指令格式都有几种子格式。但只需要关心其中的几种即可。
<!--[if !vml]--><!--[endif]-->
使用 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 来告诉处理器使用这条指令的最终计算地址来更新地址计算过程中所使用的寄存器。
<!--[if !vml]--><!--[endif]-->
使用 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 的数据,我们可以显著减少代码所使用的指令数量。另外,由于这个内容表通常就在缓存中,它还可以显著减少内存的延时。我们只需要谨慎处理存储的数据量就可以了。
<!--[if !vml]--><!--[endif]-->
加载和存储多个值
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 中。这称为带更新 的加载和存储,这可以用来减少执行多个任务所需要的指令数。在下一篇文章中我们将更多地使用这种模式。
<!--[if !vml]--><!--[endif]-->
结束语
<!--[if !vml]--><!--[endif]-->
有 效地进行加载和存储对于编写高效代码来说至关重要。了解可用的指令格式和寻址模式可以帮助我们理解某种平台的可能性和限制。PowerPC 上的 D-Form 和 DS-Form 指令格式对于与位置无关的代码来说非常重要。与位置无关的代码允许我们创建共享库,并使用较少的指令就可以完成加载全局地址的工作。
本系列的下一篇文章将介绍分支、函数调用以及与 C 代码的集成问题。
第 3 部分: 使用 PowerPC 分支处理器进行编程
分支寄存器
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 代码。使用不同的条件寄存器域可以让程序员按照自己感兴趣的方法来保存并组合条件。使用计数寄存器可以帮助实现更加有效的代码循环。简单函数甚至让新手程 序员也可以编写非常有用的汇编语言函数,并将其提供给高级语言程序使用。
在下一篇文章中,我将介绍 PowerPC ABI 函数调用方面的内容,还会讨论堆栈在 PowerPC 平台上是如何工作的。
PowerPC 64 位 ABI创建可以与其他程序共享的函数 |
|
级别: 中级
Jonathan Bartlett (johnnyb@eskimo.com), 技术总监, New Medio
2007 年 4 月 26 日
ABI,或称为应用程序二进制接口,是一组允许使用不同语言编写的程序或使用不同编译器链接的程序相互调用彼此的函数的约定集。本文是 4 部分系列文章 的最后一部分,讨论了用于 64 位 ELF (类 UNIX)系统上的 PowerPC® ABI;不管您是否使用汇编语言编写程序,它们都可以帮助您为 POWER5™ 和其他基于 PowerPC 的处理器更加有效地编写 64 位应用程序。32 位的 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> /* make declarations easier to write */ 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 保存区、编译器区和链接器区都会为系统使用而预留出来,程序员不能对它们进行修改,但是必须要为它们预留空间。
链接寄存器保存区与 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 芯片所提供的全部资源。
From:http://www.ibm.com/developerworks/cn/linux/l-powasm1.html