一、入栈与出栈
栈(stack)是一种后进先出的数据存储结构。栈通常用来保存以下内容。
- 临时存储的数据,例如局部变量等。
- 参数。在函数调用过程中,如果传递的参数少于或等于 8 个,那么使用 X0~X7 通用寄存器来传递。当参数多于 8 个时,则需要使用栈来传递。
通常,栈是一种从高地址往低地址扩展(生长)的数据存储结构。栈的起始地址称为栈底,栈从高地址往低地址延伸到某个点, 这个点称为栈顶。 栈需要一个指针来指向栈最新分配的地址,即指向栈顶。这个指针是栈指针(Stack Pointer,SP) 。把数据往栈里存储称为入栈,从栈中移除数据称为出栈。当数据入栈时,SP 减小,栈空间扩大;当数据出栈时,SP 增大,栈空间缩小。
栈在函数调用过程中起到非常重要的作用,包括存储函数使用的局部变量、传递参数等。在函数调用过程中,栈是逐步生成的。为单个函数分配的栈空间,即从该函数栈底(高地址)到栈顶(低地址)这段空间,称为栈帧(stack frame) 。
A32 指令集提供了 PUSH 与 POP 指令来实现入栈和出栈操作,不过, A64 指令集已经去掉了 PUSH 和 POP 指令。本节内容介绍如何使用加载与存储指令来实现入栈和出栈操作。
为什么 A64 指令集去掉了 PUSH 和 POP 指令呢?AI 的回答如下,仅供参考:
一、寄存器资源的大幅扩展
A64 架构将通用寄存器从 A32 的 16 个(含 PC)扩展至 31 个(X0-X30),其中 X30 作为专用链接寄存器(LR)。这一变化显著降低了对栈操作的依赖。
- 函数调用优化:A64 的 ABI(如 AAPCS64)规定,前 8 个参数通过寄存器 X0-X7 传递,而 A32 仅支持 4 个参数寄存器。更多的寄存器使得函数可以在寄存器中保存更多临时变量和状态,减少了需要入栈保存的寄存器数量。
- 减少栈操作频率:寄存器数量的增加直接减少了为保存 / 恢复寄存器状态而使用 PUSH/POP 的需求。例如,在 A32 中常见的
PUSH {r4, lr}
操作,在 A64 中可通过寄存器分配避免或简化。
二、指令集的简化与正交性设计
A64 的指令集设计强调单一功能原则和正交性,避免专用指令的冗余:
- 移除批量操作指令:A64 取消了 A32 中的 LDM/STM、PUSH/POP 等批量寄存器操作指令,转而使用 LDP/STP 指令操作一对寄存器。例如,A32 的
PUSH {r0, r1}
可替换为 A64 的STP x0, x1, [sp, #-16]!
,这种显式操作更灵活,且避免了隐式栈指针调整带来的复杂性。 - 提高编译器优化空间:正交的指令设计(如统一使用 STR/LDR 访问内存)使编译器能更自由地生成高效代码。例如,现代编译器可通过寄存器重命名和指令调度优化 STR/LDR 序列,其性能可能优于固定的 PUSH/POP 指令。
三、架构性能与编码效率的优化
A64 的指令集设计目标之一是提升硬件执行效率和代码密度:
- 指令编码空间的节省:A64 的指令固定为 32 位编码,移除 PUSH/POP 等专用指令可为其他功能(如更大的立即数范围、新的 SIMD 指令)腾出编码空间。
- 减少指令依赖:PUSH/POP 指令的多寄存器操作可能引入流水线阻塞(如多个寄存器同时写入),而 STR/LDR 的单寄存器操作更易于硬件调度,提升指令级并行性。
- PC 相对寻址优化:A64 的 PC 相对偏移范围扩大至 ±1MB(条件分支)和 ±128MB(无条件分支),减少了对文本池的依赖,进一步优化了代码密度和缓存利用率。
四、兼容性与未来扩展的考量
A64 作为全新设计的 64 位指令集,需与 A32/T32 指令集明确区分:
- 避免历史包袱:A32 的 PUSH/POP 指令在设计时需兼容早期 ARM 架构的限制(如寄存器数量少、栈对齐规则),而 A64 无需继承这些历史约束,可从头优化栈操作模型。
- 支持混合执行状态:ARMv8 允许 AArch64 和 AArch32 状态在异常边界切换,但 A64 代码无法直接调用 A32 的 PUSH/POP 指令,避免了指令集混淆带来的兼容性问题。
下面的代码片段使用加载与存储指令来实现入栈和出栈操作。
.globalmain
main:
/*栈往下扩展 16字节*/
stp x29, x30, [sp, #-16]!
/*把栈继续往下扩展 8字节*/
add sp, sp, #-8
mov x8, #1
/*x8保存到 SP指向的位置上*/
str x8, [sp]
/*释放刚才扩展的 8字节的栈空间*/
add sp, sp, #8
/*main函数返回 0*/
mov w0, 0
/*恢复 x29和 x30寄存器的值,使 SP指向原位置*/
ldp x29, x30, [sp], #16
ret
上述 main 汇编函数演示了入栈和出栈的过程。
在第 2 行中,栈还没有申请空间,如下图所示:
在第 4 行中,这里使用前变基模式的 STP 指令,首先 SP 寄存器的值减去 16,相当于把栈空间往下扩展 16 字节,然后把 X29 和 X30 寄存器的值压入栈,其中 X29 寄存器的值保存到 SP 指向的地址中,X30 寄存器的值保存到 SP 指向的值加 8 对应的内存地址中,如下图所示:
在第 7 行中,把 SP 寄存器的值减去 8,相当于把栈的空间继续往下扩展 8 字节,如下图所示:
在第 12 行中,把 X8 寄存器的值保存到 SP 指向的地址中,如下图所示。此时,已经把 X29、X30 以及 X8 寄存器的值全部压入栈,完成了入栈操作。
接下来是出栈操作了。
在第 15 行中,使 SP 指向的值加 8,相当于把栈空间缩小,也就是释放了刚才申请的 8 字节空间的栈,这样把 X8 寄存器的值弹出栈,如下图所示:
在第 21 行中,使用 LDP 指令把 X29 和 X30 寄存器中的值弹出栈。这是一条后变基模式的加载指令,加载完成之后会修改 SP 指向的值,让 SP 指向的值加上 16 从而释放栈空间,如下图所示:
二、MOV指令
MOV 指令常常用于寄存器之间的搬移和立即数搬移。
用于寄存器之间搬移的 MOV 指令格式如下。
MOV <Xd|SP>, <Xn|SP>
用于立即数搬移的 MOV 指令格式如下。
MOV <Xd>, #<imm>
这里能搬移的立即数只有两种:
- 16 位立即数;
- 16 位立即数左移 16 位、32 位或者 48 位后的立即数。
下面的代码是错误的。因为0x1ABCD 不是一个有效的 16 位立即数,汇编器会报错。
mov x0, 0x1abcd
三、加法与减法指令
3.1、ADD 指令
普通的加法指令有下面几种用法。
- 使用立即数的加法。
- 使用寄存器的加法。
- 使用移位操作的加法。
3.1.1、使用立即数的加法指令
使用立即数的加法指令格式如下。
ADD <Xd|SP>, <Xn|SP>, #<imm>{, <shift>}
它的作用是把 Xn/SP 寄存器的值再加上立即数 imm,把结果写入 Xd/SP 寄存器里。其中 imm 是一个无符号的立即数,取值范围为 0~4095。shift 为可选项,用来表示算术左移操作,且左移的位数只能是0和12。
下面是正确的用法。
//把 x1寄存器的值加上立即数 1,结果写入 x0寄存器中
add x0, x1, #1
//把立即数 1算术左移 12位,然后再加上 x1寄存器的值,结果写入 x0寄存器中
add x0, x1, #1,LSL 12
下面是错误的用法。
// 立即数超过了范围
add x0, x1, #4096
// 左移的位数只能是 0 或者 12。
add x0, x1, #1,LSL 1
3.1.2、使用寄存器的加法指令
使用寄存器的加法指令格式如下。
ADD <Xd|SP>, <Xn|SP>, <R><m>{, <extend> {#<amount>}}
这条指令的作用是先把 Rm 寄存器做一些扩展,例如左移操作,然后再加上 Xn/SP 寄存器的值,把结果写入 Xd/SP 寄存器中。其中extend为可选项,有如下取值:
- UXTB 表示对 8 位的数据进行无符号扩展。
- UXTB 表示对 16 位的数据进行无符号扩展。
- UXTW 表示对 32 位的数据进行无符号扩展。
- UXTX|LSL UXTX 表示对 64 位数据进行无符号扩展, LSL 表示逻辑左移(可选)。
- SXTB 表示对 8 位的数据进行有符号扩展。
- SXTH 表示对 16 位的数据进行有符号扩展。
- SXTW 表示对 32 位的数据进行有符号扩展。
- SXTX 表示对 64 位的数据进行有符号扩展。
使用寄存器的加法指令如下。
//x0 = x1 + x2
add x0, x1, x2
//x0 = x1 + x2 << 2
add x0, x1, x2, LSL 2
下面也是使用寄存器的加法指令。
mov x1, #1
mov x2, #0x108a
add x0, x1, x2, UXTB
add x0, x1, x2, SXTB
上面的示例代码中,第 3 行的运行结果为 0x8B,因为 UXTB 对 X2 寄存器的低 8 位数据进行无符号扩展,结果为 0x8A,然后再加上 X1 寄存器的值,最终结果为 0x8B。
在第 4 行中, SXTB 对 X2 寄存器的低 8 位数据进行有符号扩展,结果为 0xFFFFFFFFFFFFFF8A,然后再加上 X1 寄存器的值,最终结果为 0xFFFFFFFFFFFFFF8B。
3.1.3、使用移位操作的加法指令
使用移位操作的加法指令的格式如下。
ADD <Xd>, <Xn>, <Xm>{, <shift> #<amount>}
这条指令的作用是先把 Xm 寄存器做一些移位操作,然后再加上 Xn 寄存器的值,结果写入 Xd 寄存器中。其中 shift 表示移位操作,有如下取值:
- LSL:逻辑左移。
- LSR:逻辑右移。
- ASR:循环右移。
amount 表示移位的数量,取值范围是 0~63。
如下代码用于实现移位操作加法。
//x0 = x1 + x2 << 3
add x0, x1, x2, LSL 3
下面的代码是错误的。因为 amount 参数已经超过了取值范围,汇编器会报错。
add x0, x1, x2, LSL 64
3.2、ADDS 指令
ADDS 指令是 ADD 指令的变种,唯一的区别是指令执行结果会影响 PSTATE 寄存器的 N、Z、C、V 标志位,例如当计算结果发生无符号数溢出时,C=1。
下面的代码使用了 ADDS 指令。
mov x1, 0xffffffffffffffff
adds x0, x1, #2
mrs x2, nzcv
X1 的值(0xFFFFFFFFFFFFFFFF)加上立即数 2 一定会触发无符号数溢出,最终 X0 寄存器的值为 1,同时还设置 PSTATE 寄存器的 C 标志位为 1。我们可以通过读取 NZCV 寄存器来判断,最终 X2 寄存器的值为 0x20000000,说明第 29 位的 C 字段置 1,如下图所示:
3.3、ADC 指令
ADC 是进位的加法指令,最终的计算结果需要考虑 PSTATE 寄存器的 C 标志位。ADC 指令的格式如下。
ADC <Xd>, <Xn>, <Xm>
Xd 寄存器的值等于 Xn 寄存器的值加上 Xm 寄存器的值加上 C,其中 C 表示 PSTATE 寄存器的 C 标志位。
如下代码使用了 ADC 指令。
mov x1, 0xffffffffffffffff
mov x2, #2
adc x0, x1, x2
mrs x3, nzcv
ADC 指令的计算过程是 0xFFFFFFFFFFFFFFFF + 2 + C,因为 0xFFFFFFFFFFFFFFFF + 2 的过程中已经触发了无符号数溢出, C=1,所以最终计算 X0 寄存器的值为 2。若读取 NZCV 寄存器,我们发现 C 标志位也被置位了。
3.4、SUB 指令
普通的减法指令与加法指令类似,也有下面几种用法。
- 使用立即数的减法。
- 使用寄存器的减法。
- 使用移位操作的减法。
3.4.1、使用立即数的减法指令
使用立即数的减法指令格式如下。
SUB <Xd|SP>, <Xn|SP>, #<imm>{, <shift>}
它的作用是把 Xn/SP 寄存器的值减去立即数 imm,结果写入 Xd/SP 寄存器里。其中 imm 是一个无符号的立即数,取值范围为 0~4095。shift 为可选项,用来表示算术左移操作,且左移的位数只能是0和12。
如下代码使用了 SUB 指令。
//把 x1 寄存器的值减去立即数 1,结果写入 x0 寄存器
sub x0, x1, #1
//把立即数 1 算术左移 12 位,然后把 x1 寄存器中的值减去立即数 1 的结果算术左移
//12 位的值写入 x0 寄存器中
sub x0, x1, #1,LSL 12
3.4.2、使用寄存器的减法指令
使用寄存器的减法指令格式如下。
SUB <Xd|SP>, <Xn|SP>, <R><m>{, <extend> {#<amount>}}
这条指令的作用是先对 Rm 寄存器做一些扩展,例如左移操作,然后 Xn/SP 寄存器的值减 Rm 寄存器的值,把结果写入 Xd/SP 寄存器中。其中 extend 为可选项,用于对第二个源操作数进行扩展计算,取值参考 3.1.2 小节。
如下代码使用了寄存器的减法指令。
//x0 = x1 - x2
sub x0, x1, x2
//x0 = x1 - x2 << 2
sub x0, x1, x2, LSL 2
下面的代码也使用了寄存器的减法指令。
mov x1, #1
mov x2, #0x108a
sub x0, x1, x2, UXTB
sub x0, x1, x2, SXTB
上面的示例代码中,UXTB 对 X2 寄存器的低 8 位数据进行无符号扩展,结果为 0x8A,然后再计算 1 − 0x8A 的值,最终结果为 0xFFFFFFFFFFFFFF77。
在第 4 行中, SXTB 对 X2 寄存器的低 8 位数据进行有符号扩展,结果为 0xFFFFFFFFFFFFFF8A,然后再计算 1 − 0xFFFFFFFFFFFFFF8A,最终结果为 0x77。
3.4.3、使用移位操作的减法指令
使用移位操作的减法指令的格式如下。
SUB <Xd>, <Xn>, <Xm>{, <shift> #<amount>}
这条指令的作用是先把 Xm 寄存器做一些移位操作,然后使 Xn 寄存器中的值减去 Xm 寄存器中的值,把结果写入 Xd 寄存器中。其中 shift 表示移位操作,有如下取值:
- LSL:逻辑左移。
- LSR:逻辑右移。
- ASR:循环右移。
amount 表示移位的数量,取值范围是 0~63。
下面的代码用于实现移位操作减法。
//x0 = x1 - x2 << 3
add x0, x1, x2, LSL 3
3.5、SUBS 指令
SUBS 指令是 SUB 指令的变种,唯一的区别是指令执行结果会影响 PSTATE 寄存器的 N、Z、C、V 标志位。 SUBS 指令判断是否影响 N、Z、C、V 标志位的方法比较特别,对应的伪代码如下。
operand2 = NOT(imm);
(result, nzcv) = AddWithCarry(operand1, operand2, '1');
PSTATE.<N,Z,C,V> = nzcv;
首先,把第二个操作数做取反操作。然后,根据以下公式计算。
operand1 + NOT(operand2) + 1
NOT(operand2)表示把 operand2 按位取反。在这个计算过程中要考虑是否影响 N、Z、 C、V 标志位。当计算结果发生无符号数溢出时,C=1;当计算结果为负数时,N=1。
如下代码会导致 C 标志位为 1。
mov x1, 0x3
mov x2, 0x1
subs x0, x1, x2
mrs x3, nzcv
SUBS 指令仅仅执行“3 − 1”的操作,为什么会发生无符号溢出呢?
第二个操作数为 X2 寄存器的值,对应值为 1,按位取反之后为 0xFFFFFFFFFFFFFFFE。根据计算公式,计算 3 + 0xFFFFFFFFFFFFFFFE + 1,这个过程会发生无符号数溢出,因此 4 个标志位中的 C=1,最终计算结果为 2。 因此,最后一行读取 NZCV 寄存器的值——0x20000000。
如下代码会导致 C 和 Z 标志位都置 1。
mov x1, 0x3
mov x2, 0x3
subs x0, x1, x2
mrs x3, nzcv
第二个操作数为 X2 寄存器的值,该值为 3,按位取反之后为 0xFFFFFFFFFFFFFFFC。根据公式计算 3 + 0xFFFFFFFFFFFFFFFC + 1 的过程中会发生无符号数溢出,因此 C=1。另外,最终结果为 0,所以 Z=1。
3.6、SBC 指令
SBC 是进位的减法指令,也就是最终的计算结果需要考虑 PSTATE 寄存器的 C 标志位。 SBC 指令的格式如下。
SBC <Xd>, <Xn>, <Xm>
下面是 SBC 指令中对应的伪代码。
operand2 = NOT(operand2);
(result, -) = AddWithCarry(operand1, operand2, PSTATE.C);
X[d] = result;
所以,SBC 指令的计算过程是,首先对第二个操作数做取反操作,然后把第一个操作数、第二个操作数相加,这个过程会影响 PSTATE 寄存器的 C 标志位,最后把 C 标志位加上。
综上所述,SBC 指令的计算公式为
Xd = Xn + NOT(Xm) + C
如下代码使用了 SBC 指令。
mov x1, #3
mov x2, #1
sbc x0, x1, x2
mrs x3, nzcv
SBC 指令的计算过程是 3 + NOT(1) + C。NOT(1)表示对立即数 1 按位取反,结果为 0xFFFFFFFFFFFFFFFE。那么,计算 3 + 0xFFFFFFFFFFFFFFFE 的过程中会发生无符号数溢出,C=1,再加上 C 标志位,最后计算结果为2。