高级编程与低级编程的对比
大多数编程语言都与处理器保持着相当程度的独立性。但都有一些特殊特性依赖于处理器的某些功能,它们更有可能是特定于操作系统的,而不是特定于处理器的。构建高级编程语言的目的是在程序员和硬件体系结构间搭建起一座桥梁。这样做有多方面的原因。尽管可移植性是原因之一,但更重要的一点或许是提供一种更友好的模型,这种模型的建立方式更接近程序员的思考方式,而不是芯片的连线方式。
然而,在汇编语言编程中,您要直接应对处理器的指令集。这意味着您看系统的方式与硬件相同。这也有可能使汇编语言编程变得更为困难,因为编程模型的建立更倾向于使硬件工作,而不是密切反映问题域。这样做的好处在于您可以更轻松地完成系统级任务、执行那些与处理器相关性很强的优化任务。而缺点是您必须在那个级别上进行思考,依赖于一种特定的处理器系列,往往还必须完成许多额外的工作以准确地建模问题域。
关于汇编语言,很多人未想到的一个好处就是它非常具体。在高级语言中,对每个表达式都要进行许多处理。您有时不得不担忧幕后到底发生了哪些事情。在汇编语言编程中,您可以完全精确地掌控硬件的行为。您可以逐步处理硬件级更改。
汇编语言基础
在了解指令集本身之前,有两项关于汇编语言的关键内容需要理解,也就是内存模型和获取-执行周期。
内存模型非常简单。内存只存储一种东西 —— 固定范围内的数字,也称为字节(在大多数计算机上,这是一个 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 中。