内容


用于 Power 体系结构的汇编语言,第 2 部分

PowerPC 上加载和存储的艺术

将数据准确放到所需内存位置处的技术

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 用于 Power 体系结构的汇编语言,第 2 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:用于 Power 体系结构的汇编语言,第 2 部分

敬请期待该系列的后续内容。

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

在开始讨论寻址模式之前,让我们首先来回顾一下计算机内存的概念。您可能已经了解了关于内存和编程的一些事实,但是由于现代编程语言正试图淡化计算机中的一些物理概念,因此复习一下相关内容是很有用的:

  • 主存中的每个位置都使用连续的数字地址 编号,内存位置就使用这个地址来引用。
  • 每个主存位置的长度都是一个字节。
  • 较大的数据类型可以通过简单地将多个字节当作一个单位实现(例如,将两个内存位置放到一起作为一个 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)

仔细观察,您就可以看出在有一种在指令开头指定的 “基址操作码”,随后是几个修饰符。ls 用于 “load(加载)” 和 “store(存储)” 指令。b 表示一个字节,h 表示一个双字节(16 位)。w 表示一个字(32 位), d 表示一个双字节(64 位)。然后对于加载指令来说,az 修饰符说明在将数据加载到寄存器中时,该值是符号扩展的,还是简单进行零填充的。最后,还可以附加上一个 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 是数据传输的目标(或源)寄存器,regaregb 是用来计算地址所使用的两个寄存器。

下面给出几个使用 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 代码的集成问题。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux
ArticleID=184303
ArticleTitle=用于 Power 体系结构的汇编语言,第 2 部分: PowerPC 上加载和存储的艺术
publish-date=12182006