在 Raspberry Pi 上用 Arm6 汇编语言编程

汇编语言提供了对机器如何工作以及如何对其进行编程的特殊见解。
76 位读者喜欢这篇文章。
An intersection of pipes.

Opensource.com

Arm 网站 宣称该处理器底层架构是“世界最大计算生态系统的基石”,考虑到具有 Arm 处理器的手持设备和嵌入式设备的数量,这是合理的。 Arm 处理器在物联网 (IoT) 中很普遍,但它们也用于台式机、服务器,甚至高性能计算机,例如 Fugaku HPC。 但是,为什么要通过汇编语言的角度来看待 Arm 机器呢?

汇编语言是机器代码之上的符号语言,因此提供了对机器如何工作以及如何有效地对其进行编程的特殊见解。 在本文中,我希望使用运行 Debian 的 Raspberry Pi 4 迷你台式机,用 Arm6 架构来说明这一点。

Arm6 系列处理器支持两个指令集

  • Arm 集,所有指令都是 32 位。
  • Thumb 集,混合了 16 位和 32 位指令。

本文中的示例使用 Arm 指令集。 Arm 汇编代码采用小写字母,为了对比,伪汇编代码采用大写字母。

加载-存储机器

在比较 Arm 系列和 Intel x86 系列处理器时,通常会看到 RISC/CISC 的区别,这两者都是市场上竞争的商业产品。 术语 RISC(精简指令集计算机)和 CISC(复杂指令集计算机)起源于 20 世纪 80 年代中期。 即使那时,这些术语也具有误导性,因为 RISC(例如,MIPS)和 CISC(例如,Intel)处理器在其指令集中都大约有 300 条指令; 今天,Arm 和 Intel 机器中的核心指令数量接近,尽管两种类型的机器都扩展了它们的指令集。 Arm 和 Intel 机器之间更明显的区别在于指令数量之外的架构特性。

指令集架构 (ISA) 是计算机器的抽象模型。 来自 Arm 和 Intel 的处理器实现了不同的 ISA:Arm 处理器实现加载-存储 ISA,而它们的 Intel 对应产品则实现寄存器-内存 ISA。 ISA 的差异可以描述为

  • 在加载-存储机器中,只有两个指令在 CPU 和内存子系统之间移动数据
    • 加载指令将位从内存复制到 CPU 寄存器。
    • 存储指令将位从 CPU 寄存器复制到内存。
  • 其他指令,特别是用于算术逻辑运算的指令,仅使用 CPU 寄存器作为源和目标操作数。 例如,这是加载-存储机器上的伪汇编代码,用于添加最初在内存中的两个数字,并将它们的总和存储回内存中(注释以 ## 开头)
    ## R0 is a CPU register, RAM[32] is a memory location
    LOAD R0, RAM[32]   ## R0 = RAM[32]
    LOAD R1, RAM[64]   ## R1 = RAM[64]
    ADD R2, R0, R1     ## R2 = R0 + R1
    STORE R2, RAM[32]  ## RAM[32] = R2

    该任务需要四个指令:两个 LOAD,一个 ADD 和一个 STORE。

  • 相比之下,寄存器-内存机器允许算术逻辑指令的操作数是寄存器或内存位置,通常以任何组合形式。 例如,这是寄存器-内存机器上的伪汇编代码,用于添加内存中的两个数字
    ADD RAM[32], RAM[32], RAM[64] ## RAM[32] += RAM[64]

    该任务可以用一个指令完成,尽管要添加的位仍然必须从内存中获取到 CPU,然后将总和复制回内存位置 RAM[32]。

任何 ISA 都带有权衡。 如上例所示,加载-存储 ISA 具有架构师所说的“低指令密度”:可能需要相对较多的指令才能执行一项任务。 寄存器-内存机器具有高指令密度,这是一个优势。 加载-存储 ISA 也有优势。

加载-存储设计是一种简化架构的尝试。 例如,考虑寄存器-内存机器具有混合操作数的指令的情况

COPY R2, RAM[64]          ## R2 = RAM[64]
ADD RAM[32], RAM[32], R2  ## RAM[32] = RAM[32] + R2

执行 ADD 指令很棘手,因为要添加的数字的访问时间不同——如果内存操作数恰好仅在主内存中而不是也在其缓存中,则可能存在显着差异。 加载-存储机器避免了算术逻辑运算中混合访问时间的问题:所有操作数(作为寄存器)都具有相同的访问时间。

此外,加载-存储架构强调固定大小的指令(例如,每个 32 位)、有限的格式(例如,每个指令一个、两个或三个字段)和相对较少的寻址模式。 这些设计约束意味着可以简化处理器的控制单元 (CU) 和算术逻辑单元 (ALU):更少的晶体管和电线,更少的所需功率和产生的热量等等。 加载-存储机器的设计在架构上是稀疏的。

我的目的不是介入关于加载-存储与寄存器-内存机器的争论,而是要在加载-存储 Arm6 架构中建立一个代码示例。 首先了解加载-存储有助于解释下面的代码。 这两个程序(一个是 C 语言,一个是 Arm6 汇编语言)可以在 我的网站 上找到。

C 语言中的 hstone 程序

在我最喜欢的简短代码示例中,有一个冰雹函数,该函数接受一个正整数作为参数。 (我在一篇关于 WebAssembly 的早期文章中使用了这个例子。)这个函数足够丰富,可以突出显示重要的汇编语言细节。 该函数定义为

             3N+1 if N is odd
hstone(N) =
             N/2 if N is even

例如,hstone(12) 的值为 6,而 hstone(11) 的值为 34。 如果 N 是奇数,则 3N+1 是偶数;但是如果 N 是偶数,则 N/2 可能是偶数(例如,4/2 = 2)或奇数(例如,6/2 = 3)。

可以通过传递返回的值作为下一个参数来迭代使用 hstone 函数。 结果是冰雹序列,例如以下这个,它以 24 作为原始参数开始,以返回值 12 作为下一个参数开始,依此类推

24,12,6,3,10,5,16,8,4,2,1,4,2,1,...

该序列需要 10 步才能收敛到 1,此时 4、2、1 的序列无限重复:(3x1)+1 是 4,将其减半得到 2,再将其减半得到 1,依此类推。 有关为什么“冰雹”似乎是此类序列的合适名称的解释,请参见“数学之谜:冰雹序列”。

请注意,2 的幂收敛很快:2N 只需要 N 次除以 2 即可达到 1。例如,32 = 25 的收敛长度为 5,而 512 = 29 的收敛长度为 9。 如果冰雹函数返回 2 的任何幂,则序列收敛到 1。 这里感兴趣的是从初始参数到第一次出现 1 的序列长度。

考拉兹猜想是,无论初始参数 N > 0 是什么,冰雹序列都会收敛到 1。 既没有找到反例,也没有找到证明。 猜想虽然可以用程序来说明,但仍然是数论中一个非常具有挑战性的问题。

以下是 hstoneC 程序的 C 源代码,该程序计算冰雹序列的长度,该序列的起始值作为用户输入给出。 在概述 Arm6 基础知识之后,提供了程序的汇编语言版本 (hstoneS)。 为了清楚起见,这两个程序在结构上相似。

以下是 C 源代码

#include <stdio.h>

/* Compute steps from n to 1.
   -- update an odd n to (3 * n) + 1
   -- update an even n to (n / 2) */
unsigned hstone(unsigned n) {
  unsigned len = 0; /* counter */
  while (1) {
    if (1 == n) break;           
    n = (0 == (n & 1)) ? n / 2 : (3 * n) + 1; 
    len++;                       
  }
  return len;
}

int main() {
  printf("Integer > 0: ");
  unsigned num;
  scanf("%u", &num);
  printf("Steps from %u to 1: %u\n", num, hstone(num));
  return 0;
}

当程序以 9 作为输入运行时,输出为

Steps from 9 to 1: 19

hstoneC 程序具有简单的结构。 main 函数提示用户输入 N(一个整数 > 0),然后使用此输入作为参数调用 hstone 函数。 hstone 函数循环直到来自 N 的序列到达第一个 1,返回所需的步数。

程序中最复杂的语句涉及 C 的条件运算符,该运算符用于更新 N

n = (0 == (n & 1)) ? n / 2 : (3 * n) + 1;

这是 if-then 结构的简洁形式。 测试 (0 == (n & 1)) 检查 C 变量 n (表示 N)是偶数还是奇数,具体取决于 N 和 1 的按位与是否为零:整数值只有在其最低有效位(最右边的位)为零时才是偶数。 如果 N 是偶数,则 N/2 成为新值; 否则,3N+1 成为新值。 程序的汇编语言版本 (hstoneS) 同样避免了在更新 N 的实现中显式的 if-else 结构。

我的 Arm6 迷你台式机包括 GNU C 工具集,它可以生成汇编语言中的相应代码。 以 % 作为命令行提示符,命令是

% gcc -S hstoneC.c ## -S flag produces and saves assembly code

这将生成文件 hstoneC.s,它大约有 120 行汇编语言源代码,包括一个 nop(“无操作”)指令。 编译器生成的汇编通常难以阅读,并且可能存在效率低下的问题,例如 nop。 手工制作的版本(例如,下面的 hstoneS.s)更容易理解,甚至可以显着缩短(例如,hstoneS.s 有大约 50 行代码)。

汇编语言基础

像大多数现代架构一样,Arm6 是字节可寻址的:内存地址是一个字节的地址,即使寻址的项目(例如,32 位指令)由多个字节组成。 指令以小 方式寻址:地址是低位字节的地址。 数据项默认以小端方式寻址,但可以更改为大端,以便多字节数据项的地址指向高位字节。 按照惯例,低位字节被描述为最右边的字节,高位字节被描述为最左边的字节:

high-order    low-order
    /             /
+----+----+----+----+
| b1 | b2 | b3 | b4 | ## 4 bytes = 32 bits
+----+----+----+----+

地址的大小为 32 位,数据项有三种标准大小

  • 字节的大小为 8 位。
  • 半字的大小为 16 位。
  • 的大小为 32 位。

支持字节、半字和字的聚合(例如,数组和结构)。 CPU 寄存器的大小为 32 位。

一般来说,汇编语言有三个关键特性,它们的语法非常接近,有时甚至相同

  • 在 Arm6 和 Intel 汇编中,指令都以句点开头。 这是两个 Arm6 示例,它们也可以在 Intel 中工作

    .data      
    .align 4

    第一条指令表明以下部分包含的是数据项,而不是代码。.align 4 指令指定数据项在内存中应按照 4 字节边界对齐,这在现代架构中很常见。顾名思义,指令(directive)为转换器(即“汇编器”)在工作时提供方向。



    相比之下,此指令表示的是代码段而不是数据段。

    .text

    术语“text”是传统的,在此上下文中,其含义是“只读”:在程序执行期间,代码是只读的,而数据可以读取和写入。

  • 无论是近期的 Arm 汇编还是 Intel 汇编,标签都以冒号结尾。标签是数据项(例如,变量)或代码块(例如,函数)的内存地址。通常,汇编语言严重依赖地址,这意味着操作指针(特别是,解引用它们以获取它们指向的值)在汇编语言编程中占据重要地位。以下是 hstoneS 程序中的两个标签:

    collatz:	 /* label */
         mov r0, #0  /* instruction */   
    loop_start:      /* label */
         ...

    第一个标签标记了 collatz 函数的开始,该函数的第一个指令将零值 (#0) 复制到寄存器 r0 中。(mov,即“move”的缩写,这种操作码在各种汇编语言中都很常见,但实际上意味着“复制”。)第二个标签,loop_start:,是计算冰雹序列长度的循环的地址。寄存器 r0 用作序列计数器。

  • 指令(通常由对汇编敏感的编辑器以及指令一起缩进)指定要执行的操作(例如,mov),以及操作数(在本例中为 r0#0)。有些指令没有操作数,而另一些指令则有多个操作数。

上面的 mov 指令并未违反关于内存访问的加载-存储原则。通常,加载指令(Arm6 中的 ldr)将内存内容加载到寄存器中。相比之下,mov 指令可用于将“立即数”(例如,整数常量)复制到寄存器中。

mov r0, #0 /* copy zero into r0 */

mov 指令也可用于将一个寄存器的内容复制到另一个寄存器中。

mov r1, r0 /* r1 = r0 */

在上述两种情况下,加载操作码 ldr 都不适用,因为没有涉及内存位置。Arm6 的 ldr(“load register”,加载寄存器)和 str(“store register”,存储寄存器)指令的示例将在后面给出。

Arm6 架构有 16 个主 CPU 寄存器(每个 32 位),混合了通用和专用寄存器。表 1 给出了摘要,列出了除临时存储之外的特殊功能和用途。

表 1. 主 CPU 寄存器

寄存器 特殊功能
r0 库函数的第一个参数,返回值
r1 库函数的第二个参数
r2 库函数的第三个参数
r3 库函数的第四个参数
r4 被调用者保存
r5 被调用者保存
r6 被调用者保存
r7 被调用者保存,系统调用
r8 被调用者保存
r9 被调用者保存
r10 被调用者保存
r11 被调用者保存,帧指针
r12 过程内
r13 堆栈指针
r14 链接寄存器
r15 程序计数器

通常,CPU 寄存器用作堆栈的备份,堆栈是主内存的一个区域,为传递给函数的参数以及在函数和其他代码块(例如,循环体)中使用的局部变量提供可重用的临时存储。鉴于 CPU 寄存器与 CPU 位于同一芯片上,因此访问速度很快。访问堆栈的速度明显较慢,具体取决于系统的特殊性。但是,寄存器是稀缺的。在 Arm6 的情况下,只有 16 个主 CPU 寄存器,其中一些寄存器除了用作临时存储之外,还有特殊用途。

前四个寄存器 r0r3,用于临时存储,但也用于将参数传递给库函数。例如,调用诸如 printf 之类的库函数(在 hstoneC 和 hstoneS 程序中使用)需要将预期的参数放在预期的寄存器中。printf 函数至少接受一个参数(格式字符串),但通常也接受其他参数(要格式化的值)。格式字符串的地址必须位于寄存器 r0 中,调用才能成功。当然,程序员定义的函数可以实现自己的寄存器策略,但在 Arm6 编程中使用前四个寄存器作为函数参数是很常见的。

寄存器 r0 也有特殊用途。例如,它通常保存从函数返回的值,如 hstoneS 程序的 collatz 函数中所示。如果程序调用 syscall 函数(用于调用系统函数,如 readwrite),则寄存器 r0 保存要调用的系统函数的整数标识符(例如,函数 write 的标识符为 4)。在这方面,寄存器 r0 在用途上类似于寄存器 r7,当使用函数 svc(“supervisor call”,管理程序调用)而不是 syscall 时,寄存器 r7 保存这样的标识符。

寄存器 r4r11 是通用寄存器,并且是“被调用者保存的”(也称为“非易失性”或“调用保留”)。考虑以下情况:函数 F1 调用函数 F2,并使用寄存器将参数传递给 F2。寄存器 r0r3 是“调用者保存的”(也称为“易失性”或“调用破坏”),例如,被调用的函数 F2 可能会调用其他函数 F3,并使用与 F1 完全相同的寄存器,但其中包含新值。

  27  13    191  437
   \   \      \   \
   r0, r1     r0, r1
F1-------->F2-------->F3

在 F1 调用 F2 之后,寄存器 r0r1 的内容会因 F2 对 F3 的调用而发生更改。因此,F1 不得假定其在 r0r1 中的值(分别为 27 和 13)已被保留;相反,这些值已被新值 191 和 437 覆盖 - 被破坏。由于前四个寄存器不是“被调用者保存的”,因此被调用的函数 F2 不负责保存并在以后恢复 F1 设置的寄存器中的值。

被调用者保存的寄存器给被调用的函数带来了责任。例如,如果 F1 在调用 F2 时使用了被调用者保存的寄存器 r4r5,则 F2 将负责保存这些寄存器的内容(通常在堆栈上),然后在返回到 F1 之前恢复这些值。然后,F2 的代码可能会如下开始和结束:

push {r4, r5}  /* save r4 and r5 values on the stack */
...            /* reuse r4 and r5 for some other task */
pop {r4, r5}   /* restore r4 and r5 values */

push 操作将 r4r5 中的值保存到堆栈中。然后,匹配的 pop 操作从堆栈中恢复这些值,并将它们放入 r4r5 中。

表 1 中的其他寄存器可以用作临时存储,但有些寄存器也有特殊用途。如前所述,寄存器 r7 可用于进行系统调用(例如,调用函数 write),后面的示例将详细说明。在 svc 指令中,特定系统函数的整数标识符必须位于寄存器 r7 中(例如,4 用于标识 write 函数)。

寄存器 r11 别名为 fp,表示“帧指针”,它指向当前调用帧的开始。当一个函数调用另一个函数时,被调用的函数会获得自己的堆栈区域(调用帧),以用作临时存储。与下面描述的堆栈指针不同,帧指针通常保持固定,直到被调用的函数返回。

寄存器 r12,也称为 ip(“过程内”),由动态链接器使用。但是,在对动态链接库函数进行调用之间,程序可以使用此寄存器作为临时存储。

寄存器 r13,别名为 sp(“堆栈指针”),指向堆栈的顶部,并通过 pushpop 操作自动更新。堆栈指针也可以用作带有偏移量的基地址;例如,sp - #4 指向 sp 指向的位置下方 4 个字节。Arm6 堆栈(与其 Intel 对应物一样)从高地址向低地址增长。(因此,一些作者将堆栈指针描述为指向堆栈的底部而不是顶部。)

寄存器 r14,别名为 lr,用作“链接寄存器”,用于保存函数的返回地址。但是,被调用的函数可以使用 bl(“带链接的分支”)或 bx(“带交换的分支”)指令调用另一个函数,从而破坏 lr 寄存器的内容。例如,在 hstoneS 程序中,函数 main 调用了其他四个函数。因此,函数 main 将其调用者的 lr 保存在堆栈上,并在以后恢复该值。这种模式在 Arm6 汇编语言中经常出现。

push {lr} /* save caller's lr */
...       /* call some functions */
pop {lr}  /* restore caller's lr */

寄存器 r15 也是 pc(“程序计数器”)。在大多数架构中,程序计数器指向要执行的“下一个”指令。由于历史原因,Arm6 pc 指向当前指令之后的 *两个* 指令。可以直接操作 pc(例如,调用函数),但推荐的方法是使用诸如 bl 之类的指令来操作链接寄存器。

Arm6 具有用于算术(例如,加法、减法、乘法、除法)、逻辑(例如,比较、移位)、控制(例如,分支、退出)和输入/输出(例如,读取、写入)的常用指令集。比较和其他操作的结果保存在专用寄存器 cpsr(“当前处理器状态寄存器”)中。例如,此寄存器记录加法是否导致溢出,或者两个比较的整数值是否相等。

值得重申的是,Arm6 只有两个基本的数据移动指令:ldr 用于将内存内容加载到寄存器中,str 用于将寄存器内容存储到内存中。Arm6 包括基本 ldrstr 指令的变体,但寄存器和内存之间移动数据的加载-存储模式保持不变。

代码示例将使这些架构细节栩栩如生。下一节将介绍汇编语言中的冰雹程序。

Arm6 汇编中的 hstone 程序

以上对 Arm6 汇编的概述足以介绍 hstoneS 的完整代码示例。为了清晰起见,汇编语言程序 hstoneS 具有与 C 程序 hstoneC 基本相同的结构:两个函数 maincollatz,以及每个函数中主要是直线代码执行。这两个程序的行为相同。

以下是 hstoneS 的源代码:

	.data        /* data versus code */
	.balign 4    /* alignment on 4-byte boundaries */

/* labels (addresses) for user input, formatters, etc. */
num:	.int	0               /* 4-byte integer */
steps:  .int    0               /* another for the result */
prompt:	.asciz	"Integer > 0: " /* zero-terminated ASCII string */
format: .asciz 	"%u"            /* %u for "unsigned" */
report: .asciz 	"From %u to 1 takes %u steps.\n"
	
	.text          /* code: 'text' in the sense of 'read only' */
	.global main   /* program's entry point must be global */
	.extern printf /* library function */
	.extern scanf  /* ditto */

collatz:	         /** collatz function **/
	mov r0, #0       /* r0 is the step counter */
loop_start:              /** collatz loop **/
	cmp r1, #1       /* are we done? (num == 1?) */	
	beq collatz_end  /* if so, return to main */ 	
	
	and r2, r1, #1            /* odd-even test for r1 (num) */
	cmp r2, #0                /* even? */
	moveq r1, r1, LSR #1      /* even: divide by 2 via a 1-bit right shift */
	addne r1, r1, r1, LSL #1  /* odd: multiply by adding and 1-bit left shift */
	addne r1, #1	          /* odd: add the 1 for (3 * num) + 1 */

	add r0, r0, #1            /* increment counter by 1 */
	b loop_start	          /* loop again */
collatz_end:
	bx lr                     /* return to caller (main) */

main:  
	push {lr}              /* save link register to stack */

        /* prompt for and read user input */
	ldr r0, =prompt	       /* format string's address into r0 */
	bl  printf             /* call printf, with r0 as only argument */

	ldr r0, =format	       /* format string for scanf */
	ldr r1, =num	       /* address of num into r1 */
	bl  scanf	       /* call scanf */

	ldr r1, =num	       /* address of num into r1 */
	ldr r1, [r1]           /* value at the address into r1 */
	bl  collatz            /* call collatz with r1 as the argument */

	/* demo a store */
	ldr r3, =steps         /* load memory address into r3 */
	str r0, [r3]           /* store hailstone steps at mem[r3] */

        /* setup report */
	mov r2, r0             /* r0 holds hailstone steps: copy into r2 */
	ldr r1, =num           /* get user's input again */
	ldr r1, [r1]           /* dereference address to get value */
	ldr r0, =report        /* format string for report into r0 */
	bl  printf             /* print report */

	pop {lr}               /* return to caller */

Arm6 汇编支持 C 风格的文档(此处使用的斜杠星号和星号斜杠语法)或由 @ 符号引入的单行注释。hstoneS 程序与其 C 对应程序一样,有两个函数:

  • 程序的入口点是 main 函数,该函数由标签 main: 标识;此标签标记了找到该函数的第一个指令的位置。在 Arm6 汇编中,必须将入口点声明为全局的:

    .global main

    在 C 中,函数的 *名称* 是构成函数主体的代码块的地址,并且 C 函数默认是 extern(全局的)。C 语言和汇编语言非常相似,这并不奇怪;事实上,C 是一种可移植的汇编语言。

  • collatz 函数需要一个参数,该参数通过寄存器 r1 实现,用于保存用户输入的无符号整数值(例如,9)。此函数会更新寄存器 r1,直到其等于 1,同时使用寄存器 r0 记录所涉及的步骤数,从而作为函数的返回值。

main 中一个早期且有趣的代码段涉及对库函数 scanf 的调用。scanf 是一个高级输入函数,它从标准输入(默认情况下是键盘)扫描一个值,并将其转换为所需的数据类型,在本例中为 4 字节的无符号整数。以下是完整的代码段:

ldr r0, =format	 /* address of format string into r0 */
ldr r1, =num	 /* address of num into r1 */
bl  scanf	 /* call scanf (bl = branch with link) */

ldr r1, =num	 /* address of num into r1 */
ldr r1, [r1]     /* value at the address into r1 */
bl  collatz      /* call collatz with r1 as the argument */

涉及两个标签(地址):formatnum,它们都在程序顶部的 .data 段中定义。

num:	.int	0       
format: .asciz 	"%u"    

标签 num: 是一个 4 字节整数值的内存地址,初始化为零;标签 format: 指向一个以 null 结尾的字符串(“asciz”中的“z”表示零)"%u",它指定扫描的输入应转换为无符号整数。因此,代码段中的前两条指令将格式字符串的地址 (=format) 加载到寄存器 r0 中,并将扫描的数字的地址 (=num) 加载到寄存器 r1 中。请注意,每个标签现在都以等号(“赋值地址”)开头,并且每个标签的末尾的冒号都被删除。库函数 scanf 可以接受任意数量的参数,但第一个参数(scanf 期望在寄存器 r0 中)应该是格式字符串的“地址”。在此示例中,scanf 的第二个参数是将扫描的整数保存到的“地址”。

代码段中的最后三个指令突出了重要的汇编细节。第一条 ldr 指令将基于内存的整数的 *地址* (=num) 加载到寄存器 r1 中。但是,collatz 函数期望的是 *值* 保存在此地址,而不是地址本身;因此,该地址被解引用以获取值。

ldr r1, =num /* load address into r1 */
ldr r1, [r1] /* dereference to get value */

方括号指定内存,而 r1 保存一个内存地址。因此,表达式 [r1] 的求值结果为存储在地址 r1 的内存中的 *值*。该示例强调了寄存器可以保存地址以及存储在地址中的值:寄存器 r1 首先保存一个地址,然后保存存储在该地址中的值。

collatz 函数返回到 main 时,此函数首先执行存储操作。

ldr r3, =steps /* steps is a memory address */
str r0, [r3]   /* store r0 value at mem[r3] */

标签 steps: 来自 .data 段,寄存器 r0 保存 collatz 函数中计算的步数。因此,str 指令将冰雹序列的长度保存到内存中。在 ldr 指令中,第一个操作数(一个寄存器)是加载的 *目标*;但在 str 操作中,第一个操作数(也是一个寄存器)是存储的 *源*。在这两种情况下,第二个操作数都是一个内存位置。

main 中的一些额外工作设置了最终报告。

mov r2, r0      /* save count in r2 */
ldr r1, =num    /* recover user input */
ldr r1, [r1]    /* dereference r1 */  
ldr r0, =report /* r0 points to format string */
bl  printf      /* print report */ 

collatz 函数中,寄存器 r0 跟踪从用户输入到达 1 需要的步数,但库函数 printf 期望其第一个参数(格式字符串的地址)存储在寄存器 r0 中。因此,寄存器 r0 中的返回值使用 mov 指令复制到寄存器 r2 中。然后,printf 的格式字符串的地址存储在寄存器 r0 中。

collatz 函数的参数是扫描的输入,它存储在寄存器 r1 中;但是,除非该值在一开始恰好是 1,否则此寄存器会在 collatz 循环中更新。因此,地址 num: 再次复制到 r1 中,然后解引用以获取用户的原始输入。此值成为 printf 的第二个参数,即冰雹序列的起始值。完成此设置后,main 使用 bl(“带链接的分支”)指令调用 printf

collatz 循环的最开始,程序会检查序列是否已到达 1。

cmp r1, #1       
beq collatz_end  

如果寄存器 r1 的值为 1,则有一个分支(beq 表示“如果相等则分支”)到 collatz 函数的末尾,这意味着返回到其调用者 main,寄存器 r0 作为返回值——冰雹序列中的步数。

collatz 函数引入了新特性和操作码,这些特性和操作码说明了汇编代码可以多么高效。汇编代码就像 C 代码一样,检查 N 的奇偶性,其中寄存器 r1 作为 N。

and r2, r1, #1 /* r2 = r1 & 1 */          
cmp r2, #0     /* is the result even? */ 

对寄存器 r1 和 1 进行按位与运算的结果存储在寄存器 r2 中。如果寄存器 r2 的最低有效位(最右边)为 1,则 N(寄存器 r1)为奇数,否则为偶数。此比较的结果(保存在专用寄存器 cpsr 中)会自动用于即将到来的指令,例如 moveq(“如果相等则移动”)和 addne(“如果不相等则添加”)。

汇编代码就像 C 代码一样,现在避免了显式的 if-else 结构。此代码段与 if-else 测试具有相同的效果,但该代码效率更高,因为不涉及分支——代码以直线方式执行,因为条件测试内置于指令操作码中。

moveq r1, r1, LSR #1     /* right-shift 1 bit if even */
addne r1, r1, r1, LSL #1 /* left-shift 1 bit and add otherwise */
addne r1, #1	         /* add 1 for the + 1 in N = 3N + 1 */

moveqeq 表示“如果相等”)指令检查先前 cmp 测试的结果,该测试确定寄存器 r1 (N) 的当前值是偶数还是奇数。如果寄存器 r1 中的值为偶数,则必须将其更新为其一半,这通过 1 位右移 (LSR #1) 完成。通常,右移整数比显式将其除以 2 更有效。例如,假设寄存器 r1 当前保存 4,其最低有效四位是

...0100 ## 4 in binary

右移 1 位产生

...0010 ## 2 in binary

LSR 代表“逻辑右移”,与 ASR 代表的“算术右移”形成对比。算术移位保留符号(负数为 1 的最高有效位,非负数为 0),而逻辑移位则不保留符号,但冰雹程序专门处理 *无符号*(因此为非负)值。在逻辑移位中,移位的位被零替换。

如果寄存器 r1 保存具有奇数奇偶校验的值,则会发生类似的直线代码

addne r1, r1, r1, LSL #1  /* r1 = r1 * 3 */
addne r1, #1	          /* r1 = r1 + 1 */

只有在先前对奇偶校验的检查表明该值为奇数时,才会执行两个 addne 指令(ne 表示“如果不相等”)。第一个 addne 指令通过 1 位左移和加法进行乘法运算。通常,移位和加法比显式相乘更有效。然后,第二个 addne 将 1 添加到寄存器 r1,以便将更新从 N 变为 3N+1。

汇编 hstoneS 程序

hstoneS 程序的汇编源代码需要被翻译(“汇编”)成二进制 *目标模块*,然后将其与适当的库链接以变为可执行文件。最简单的方法是使用 GNU C 编译器,就像用于编译 C 程序(例如 hstoneC)一样

% gcc -o hstoneS hstoneS.s

此命令执行汇编和链接。

稍微更有效的方法是使用 GNU 工具集附带的 *as* 实用程序。此方法将汇编和链接分开。以下是汇编步骤

% as -o hstoneS.o hstoneS.s ## assemble

扩展名 .o 是目标模块的传统扩展名。然后可以使用系统实用程序 *ld* 进行链接,但更简单且同样有效的方法是恢复为 *gcc* 命令

% gcc -o hstoneS hstoneS.o ## link

此方法再次突出显示了 C 编译器可以处理任何 C 和汇编的混合,无论是源文件还是目标模块。

使用显式系统调用结束

这两个冰雹程序使用高级输入/输出函数 scanfprintf。这些函数是高级的,因为它们处理格式化的类型(在本例中为无符号整数),而不是原始字节。但是,在嵌入式系统中,这些函数可能不可用;然后,将使用最终实现其高级对应程序的低级输入/输出函数 readwrite。这两个系统函数是低级的,因为它们处理原始字节。

在 Arm6 汇编中,程序以间接方式显式调用系统函数(例如 write)——例如,通过调用上述函数 svcsyscall 之一

        calls      calls
program------->svc------->write

特定系统函数的整数标识符(例如,4 标识 write)进入适当的寄存器(svc 为寄存器 r7syscall 为寄存器 r0)。下面的代码段说明了这一点,首先是 svc,然后是 syscall

这两个代码段将传统的问候语写入标准输出,默认情况下它是屏幕。标准输出有一个 *文件描述符*,这是一个标识它的非负整数值。三个预定义的描述符是

standard input:  0 (keyboard by default)
standard output: 1 (screen by default)
standard error:  2 (screen by default)

以下是使用 svc 的示例系统调用的代码段

msg: .asciz "Hello, world!\n" /* greeting */
...
mov r0, #1    /* 1 = standard output */
ldr r1, =msg  /* address of bytes to write */
mov r2, #14   /* message length (in bytes) */
mov r7, #4    /* write has 4 as an id */
svc #0        /* system call to write */

函数 write 接受三个参数,并且当通过 svc 调用时,该函数的参数进入以下寄存器

  • r0 保存写入操作的目标,在本例中为标准输出 (#1)。
  • r1 具有要写入的字节的地址 (=msg)。
  • r2 指定要写入的字节数 (#14)。

对于 svc 指令,寄存器 r7 使用非负整数(在本例中为 #4)标识要调用的系统函数。svc 调用返回零 (#0) 以表示成功,但通常返回负值以表示错误。

syscallsvc 函数在细节上有所不同,但使用任一函数来调用系统函数都需要相同的两个步骤

  • 指定要调用的系统函数(svcr7syscallr0)。
  • 将系统函数的参数放入适当的寄存器中,svcsyscall 变体之间的寄存器有所不同。

以下是调用 write 函数的 syscall 示例

msg: .asciz "Hello, world!\n" /* greeting */
...
mov r1, #1   /* standard output */
ldr r2, =msg /* address of message */
mov r3, #14  /* byte count */
mov r0, #4   /* identifier for write */
syscall

C 不仅为 syscall 函数提供了一个薄封装器,而且还为系统函数 readwrite 提供了一个薄封装器。C 对 syscall 的封装器从高层次上给出了要点

syscall(SYS_write,         /* 4 is the id for write */
	STDOUT_FILENO,     /* 1 is the standard output */
	"Hello, world!\n", /* message */
	14);               /* byte length */

C 中的直接方法使用系统 write 函数的封装器

write(STDOUT_FILENO, "Hello, world!\n", 14);

 

接下来阅读什么
标签
User profile image.
我是一名计算机科学领域的学者(德保罗大学计算与数字媒体学院),在软件开发方面拥有丰富的经验,主要是在生产计划和调度(钢铁行业)以及产品配置(卡车和巴士制造)方面。有关书籍和其他出版物的详细信息,请访问

评论已关闭。

知识共享许可协议本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。
© . All rights reserved.