深入理解 ARMv8-A|Application Binary Interface (ABI)

1、概述

  ARM 架构的应用二进制接口(ABI)为所有原生可执行代码模块制定了一套必须遵循的基础规则,以确保它们能够正确地进行协同工作。在此基础上,针对特定的编程语言(如 C++),ABI 还会提供额外的补充规范。此外,不同的操作系统或运行环境(例如 Linux)为了满足自身的特定需求,也可能会在 ARM ABI 基础规范之上,进一步定义附加的规则。

  就 AArch64 架构而言,其 ABI 主要由以下几个核心组件构成:

ELF(可执行与可链接格式)

AArch64 架构的 ELF 规范(ELF for the ARM 64-bit Architecture)专门定义了该架构下的目标文件(Object file)和可执行文件的二进制格式。

PCS(过程调用标准)

AArch64 ABI 的过程调用标准(AAPCS64)规定了子程序如何被独立编写、编译和汇编,并最终实现无缝协同工作。它明确了调用方与被调用方之间的"契约",或者说是例程与其运行环境之间的交互规则。例如,发起函数调用时必须履行的义务,以及栈内存的具体布局方式等。

DWARF(调试信息格式)

DWARF 是一种广泛使用的标准化调试数据格式。AArch64 的 DWARF 规范基于 DWARF 3.0 版本,但增加了一些针对 ARM 架构的附加规则。(更多细节请参阅 DWARF for the ARM 64-bit Architecture (AArch64) 官方文档)。

C/C++ 库

ARM C/C++ 编译器及浮点支持用户指南(ARM Compiler ARM C and C++ Libraries and Floating-Point Support User Guide)详细描述了 ARM 提供的标准 C/C++ 运行时库及其底层支持。

C++ ABI

AArch64 架构的 C++ 应用二进制接口标准(C++ Application Binary Interface Standard for the ARM 64-bit Architecture)定义了通用的 C++ ABI 规范,确保不同编译器生成的 C++ 代码能够正确链接和交互。

这篇文章主要关注 AAPCS64,因为它和性能分析、汇编阅读、跨语言互调、异常处理、任务切换都直接相关。。

2、AArch64 过程调用标准中的寄存器使用规范

  深入理解寄存器使用的底层规范是非常有价值的。只有掌握了参数是如何在寄存器间传递和分配的,你才能:

  • 编写出性能更优、更贴近硬件特性的 C/C++ 代码。
  • 在阅读反汇编代码时游刃有余,快速理清程序逻辑。
  • 亲自编写或优化底层的汇编语言代码。
  • 实现跨语言的无缝互调(例如在 C 语言中正确调用汇编或 Rust 编写的函数)。

2.1 通用寄存器(General-Purpose Registers)中的参数传递

对于函数调用(function call)而言,通用寄存器(general-purpose registers)被划分为四类:


参数寄存器 (Argument registers, X0-X7)

这些寄存器用于:

  • 向函数传递参数
  • 返回函数结果

它们也可以作为:

  • scratch registers(临时工作寄存器)
  • caller-saved register variables(调用者保存的寄存器变量------和后面的 Caller-saved temporary registers 一个意思)

用于在一个函数内部、以及对其他函数调用之间保存中间值(intermediate values)。

由于提供了 8 个参数寄存器,因此相比 AArch32,大幅减少了为了传递参数而将数据 spill 到 stack 的需求。


Caller-saved 临时寄存器 (Caller-saved temporary registers, X9-X15)

如果调用者(caller)希望这些寄存器中的值在调用其他函数后仍然保持不变,那么调用者必须在自己的 stack frame 中保存这些寄存器。

这些寄存器可以被被调用函数(called subroutine)自由修改,并且无需在返回调用者之前执行 save/restore 操作。

换句话说:

X9-X15 属于 caller-saved registers,被调用函数可以直接覆盖(clobber)它们。


Callee-saved 寄存器 (Callee-saved registers, X19-X29)

这些寄存器由被调用函数栈帧(callee frame) 保存。

被调用函数可以修改这些寄存器,但前提是:

  • 在使用之前先保存(save)
  • 在返回之前恢复(restore)

从 ABI 的角度来说:

被调用函数必须保证 X19-X29 在函数返回时保持与进入函数时相同的值。


具有特殊用途的寄存器 (Registers with a special purpose: X8, X16-X18, X29, X30)

  • X8:Indirect result register(间接结果寄存器)。用于传递间接结果的地址位置,例如当函数需要返回一个大型结构体时。
  • X16 和 X17:IP0 和 IP1,即 Intra-procedure-call temporary registers(过程内调用临时寄存器)。它们可被 Call veneers(调用跳板代码)及类似代码使用,或作为子程序调用之间的中间值临时寄存器。它们会被函数修改(Corruptible)。Veneers 是由链接器自动插入的小段代码,例如当分支目标超出了 Branch instruction(分支指令)的范围时。
  • X18:Platform register(平台寄存器),专为 Platform ABIs 保留。在未对其赋予特殊含义的平台上,它可以作为一个额外的临时寄存器使用。
  • X29:Frame pointer register (FP),即 Frame pointer(帧指针寄存器)。
  • X30:Link register (LR),即 Link register(链接寄存器)。

图 9-1 显示了 64 位 X 寄存器。有关寄存器的更多信息,请参阅 深入理解 ARMv8-A|处理器模式与寄存器

2.2 间接结果位置 X8

再次说明,X8(XR)寄存器用于传递间接结果的存储位置。以下是部分代码:

c 复制代码
struct struct_A
{
	int i0;
	int i1;
	double d0;
	double d1;
} AA;

struct struct_A foo(int i0, int i1, double d0, double d1)
{
	struct struct_A A1;
	
	A1.i0 = i0;
	A1.i1 = i1;
	A1.d0 = d0;
	A1.d1 = d1;
	
	return A1;
}

void bar()
{
	AA = foo(0, 1, 1.0, 2.0);
}

可以使用如下命令编译、反汇编:

c 复制代码
~/Desktop/ARM_ToolChin/gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu/bin/test$ ls
test.c
~/Desktop/ARM_ToolChin/gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu/bin/test$ ../aarch64-linux-gnu-gcc -c test.c
~/Desktop/ARM_ToolChin/gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu/bin/test$ ls
test.c  test.o
~/Desktop/ARM_ToolChin/gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu/bin/test$ ../aarch64-linux-gnu-objdump -S test.o

test.o:     file format elf64-littleaarch64


Disassembly of section .text:

0000000000000000 <foo>:
	// 函数 foo 开始:返回一个较大的结构体
    // 在 ARM64 中,当返回的结构体过大无法放入寄存器时,
    // 调用者会在栈上分配空间,并将该空间的地址通过 x8 寄存器隐式传递给被调用者
   0:	d10103ff 	sub	sp, sp, #0x40	// 为当前函数分配 64 字节 (0x40) 的栈空间
   4:	aa0803e2 	mov	x2, x8			// 将 x8 中隐式传入的"返回值内存地址"保存到 x2 中
   8:	b9001fe0 	str	w0, [sp, #28]	// 将第1个参数 i0 (w0) 存入当前栈帧
   c:	b9001be1 	str	w1, [sp, #24]	// 将第2个参数 i1 (w1) 存入当前栈帧
  10:	fd000be0 	str	d0, [sp, #16]	// 将第3个参数 d0 (d0) 存入当前栈帧
  14:	fd0007e1 	str	d1, [sp, #8]	// 将第4个参数 d1 (d1) 存入当前栈帧
  
  // 开始组装结构体 A1,并直接写入调用者提供的内存地址 (x2)
  18:	b9401fe0 	ldr	w0, [sp, #28]	// 读取参数 i0
  1c:	b9002be0 	str	w0, [sp, #40]	// 暂存到栈上 (编译器生成的临时变量/局部变量)
  20:	b9401be0 	ldr	w0, [sp, #24]	// 读取参数 i1
  24:	b9002fe0 	str	w0, [sp, #44]	// 暂存到栈上
  28:	fd400be0 	ldr	d0, [sp, #16]	// 读取参数 d0
  2c:	fd001be0 	str	d0, [sp, #48]	// 暂存到栈上
  30:	fd4007e0 	ldr	d0, [sp, #8]	// 读取参数 d1
  34:	fd001fe0 	str	d0, [sp, #56]	// 暂存到栈上
  
  38:	aa0203e3 	mov	x3, x2			// 将目标返回地址 (x2) 复制到 x3,准备进行内存拷贝
  3c:	9100a3e2 	add	x2, sp, #0x28	// x2 指向栈上暂存的结构体数据的起始地址 (sp + 40)
  40:	a9400440 	ldp	x0, x1, [x2]	// 从栈上加载前 16 字节 (i0, i1, d0的前半部分) 到 x0, x1
  44:	a9000460 	stp	x0, x1, [x3]	// 将这 16 字节存入目标返回地址 (x3)
  48:	f9400840 	ldr	x0, [x2, #16]	// 从栈上加载剩余的 8 字节 (d1) 到 x0
  4c:	f9000860 	str	x0, [x3, #16]	// 将这 8 字节存入目标返回地址的偏移 16 处
  
  50:	910103ff 	add	sp, sp, #0x40	// 销毁当前栈帧,释放 64 字节栈空间
  54:	d65f03c0 	ret

0000000000000058 <bar>:
  // 函数 bar 开始:调用 foo 并接收结构体返回值
  58:	a9bc7bfd 	stp	x29, x30, [sp, #-64]!	// 保存上一级函数的帧指针(x29)和返回地址(x30),并分配 64 字节栈空间
  5c:	910003fd 	mov	x29, sp					// 更新当前栈帧指针 x29 指向当前栈顶 
  60:	f9000bf3 	str	x19, [sp, #16]			// 保存被调用者保存寄存器 x19 (x19 后面会用于保存全局变量 AA 的地址)
  // 注意,这里·不会保存所有的 Callee-saved 寄存器,只会保存,本函数后面可能破坏的 Callee-saved 寄存器

  64:	90000000 	adrp	x0, 18 <foo+0x18>	// 获取全局变量 AA 所在内存页的基地址
  68:	91000013 	add	x19, x0, #0x0			// 计算全局变量 AA 的精确地址,并保存到 x19 中
    	//从这里就知道,不管后面怎么进行函数调用,调用者 <bar> 认为 x19 寄存器都是不会被破坏的,所以放心的用来存储自己想要的值
  
  6c:	910083a0 	add	x0, x29, #0x20			// 在当前栈帧中分配 32 字节作为 foo 的临时返回值接收区,x0 指向该区域
  70:	aa0003e8 	mov	x8, x0					// 【关键】将临时接收区的地址放入 x8,作为隐式参数传给 foo
  
  74:	1e601001 	fmov d1, #2.0				// 准备第4个参数 d1 = 2.0
  78:	1e6e1000 	fmov d0, #1.0				// 准备第3个参数 d0 = 1.0
  7c:	52800021 	mov	w1, #0x1 				// 准备第2个参数 i1 = 1
  80:	52800000 	mov	w0, #0x0				// 准备第1个参数 i0 = 0
  
  84:	94000000 	bl	0 <foo>					// 调用 foo 函数
  
  // foo 返回后,将结构体数据从栈上的临时接收区拷贝到全局变量 AA 中
  88:	aa1303e3 	mov	x3, x19					// 将全局变量 AA 的地址 (x19) 放入 x3 作为拷贝目标
  8c:	910083a2 	add	x2, x29, #0x20			// x2 指向栈上 foo 返回的临时结构体数据起始地址
  90:	a9400440 	ldp	x0, x1, [x2]			// 从栈上加载前 16 字节
  94:	a9000460 	stp	x0, x1, [x3]			// 写入全局变量 AA
  98:	f9400840 	ldr	x0, [x2, #16]			// 从栈上加载剩余的 8 字节
  9c:	f9000860 	str	x0, [x3, #16]			// 写入全局变量 AA 的剩余部分
  a0:	d503201f 	nop
  
  a4:	f9400bf3 	ldr	x19, [sp, #16]			// 恢复之前保存的 x19 寄存器
  a8:	a8c47bfd 	ldp	x29, x30, [sp], #64		// 恢复上一级函数的帧指针和返回地址,并释放 64 字节栈空间
  ac:	d65f03c0 	ret							// 返回到调用 bar 的地方

在本例中,由于结构体大小(24字节)超出了 AAPCS64 规范的 16 字节寄存器直接返回阈值,编译器采用了一套"隐式指针传参 + 内存拷贝"的底层机制,其精妙之处体现在:

  • X8 寄存器的隐式传参:bar() 在栈上为返回值预分配内存,并将其地址通过 X8 传给 foo()。X8 作为专用的"间接结果寄存器",不会挤占 X0-X7 的常规参数传递通道,保证了参数传递的可用性。
  • 防御性的寄存器转移:由于 X8 属于通用寄存器,foo() 内部随时可能将其作为临时变量而"损坏"。因此 foo() 在入口处第一时间将 X8 转移至 X2 寄存器进行保护,而 bar() 则直接通过 SP 寻址访问返回结构,确保绝对安全。
  • 跨调用的寄存器复用(X19):bar() 需要获取全局变量 AA 的地址并在调用 foo() 后继续使用。编译器巧妙地选用了 callee-saved 寄存器 X19 来保存该地址,既避免了被 foo() 破坏,又避免了使用 caller-saved 寄存器带来的额外压栈开销。
  • 零拷贝的内存搬运:foo() 组装完结构体后,利用 ARM64 强大的内存操作指令(ldp/stp 和 ldr/str),以 16 字节和 8 字节为单位,将数据按块批量拷贝到目标内存,极大减少了 CPU 指令周期。
  • foo 函数之所以没有保存 FP(x29)和 LR(x30),是因为它在函数调用链中属于 "叶子函数(Leaf Function)"

  图 9-2 展示了一个 AAPCS64 栈帧的结构。帧指针(X29)应指向保存在栈上的上一级帧指针,且保存的链接寄存器(LR,即 X30)应紧随其后存储。栈帧链中的最后一个(即最底层的)帧指针应被设置为 0。此外,栈指针(SP)必须始终保持 16 字节对齐。栈帧的具体布局可能会存在一定差异,特别是在处理可变参数函数或无栈帧函数(frameless functions)时。如需了解详细信息,请参阅 AAPCS64 规范文档。

注意:AAPCS(ARM 架构过程调用标准)仅规定了 FP 和 LR 保存块的布局,以及这些块之间如何链接。至于图 9-2 中的其他所有内容(包括两个函数栈帧之间边界的确切位置),规范均未作限定,编译器可以自由选择其布局方式。

2.3、NEON 和浮点寄存器中的参数

  ARM64 架构同样拥有 32 个寄存器(v0-v31),它们可供 NEON 和浮点运算使用。访问这些寄存器时,使用的名称会随之改变,以指示当前操作的位宽大小。

  与 AArch32 架构不同,在 AArch64 中,NEON 和浮点寄存器的 128-bit 视图和 64-bit 视图并不会跨越多个寄存器。因此,q1、d1 和 s1 实际上指向的是寄存器组(register bank)中的同一个物理寄存器。

在 AAPCS64 规范中,这些浮点/SIMD 寄存器的使用规则如下:

  • V0-V7:用于向子程序传递参数值,以及从函数返回结果值。它们也可用于在例程内部保存中间计算值(但通常仅在两次子程序调用之间有效)。
  • V8-V15:属于 callee-saved 寄存器,被调用者(callee)在子程序调用期间必须保留它们。不过,只需保留这些寄存器中存储值的低 64 bits(即 d8-d15)即可。
  • V16-V31:属于 caller-saved 寄存器,无需被调用者保留(如果需要跨调用保存,则由调用者 caller 自行负责)。

3、从 ABI 走到内核实现:SylixOS 上下文切换

  到这里为止,我们说的都还是"普通函数调用"的 ABI。内核调度和异常处理会更进一步:它们要把"寄存器里的执行现场"保存到内存里,再在未来某个时刻恢复回来。

  这时就不只是 AAPCS64 了,还会叠加一层由操作系统自己定义的"上下文 ABI"。

3.1 软件上下文长什么样

arch_regs.h 里定义了 ARCH_REG_CTX

c 复制代码
typedef struct {
    ARCH_REG_T REG_ulSmallCtx;
    ARCH_REG_T REG_ulReg[31];
    ARCH_REG_T REG_ulSp;
    ARCH_REG_T REG_ulPc;
    ARCH_REG_T REG_ulPstate;
    ARCH_REG_T REG_ulTPIDR_EL0;
} ARCH_REG_CTX;

它表达了两层含义:

  • REG_ulReg[] + SP + PC + PSTATE:这是一个"可恢复执行流"的最小集合
  • REG_ulSmallCtx:标记当前保存的是 small context 还是 big context

在这套实现里:

  • REG_ulSmallCtx == 1:small context
  • REG_ulSmallCtx == 0:big context

注意,REG_ulPstate 在这里保存的并不是"完整 PSTATE 的所有位",而是实现真正关心的那部分状态。比如 SAVE_PSTATE 宏里实际抓的是 NZCVDAIF,恢复时再拼上 EL1h 所需模式位写回 SPSR_EL1

3.2 任务第一次启动:small context 从何而来

arm64Context.c 里的 archTaskCtxCreate() 会为新任务构造初始上下文:

c 复制代码
PLW_STACK  archTaskCtxCreate (ARCH_REG_CTX          *pregctx,
                              PTHREAD_START_ROUTINE  pfuncTask,
                              PVOID                  pvArg,
                              PLW_CLASS_TCB          ptcb,
                              PLW_STACK              pstkTop, 
                              ULONG                  ulOpt)
{
......
pstkTop = (PLW_STACK)ROUND_DOWN(pstkTop, ARCH_STK_ALIGN_SIZE);

pregctx->REG_ulSmallCtx = 1;
pregctx->REG_ulReg[0]   = (ARCH_REG_T)pvArg;
pregctx->REG_ulLr       = (ARCH_REG_T)pfuncTask;
pregctx->REG_ulPc       = (ARCH_REG_T)pfuncTask;
pregctx->REG_ulSp       = (ARCH_REG_T)pfpctx;
......
}

这段代码很值得细看:

  • 新任务一开始就是 small context
  • x0 预置为任务入口参数
  • LRPC 都被设置为任务入口函数
  • SP 指向已经对齐并准备好的初始栈顶

后续 archTaskCtxStart() 只需要根据 CTX_TYPE 选择恢复 small 或 big context,然后通过 ERET 进入该任务即可。

asm 复制代码
FUNC_DEF(archTaskCtxStart)
    LDR     X8, [X0]
    LDR     X9, [X8, #CTX_TYPE_OFFSET]
    CMP     X9, #0
    B.NE    _RestoreSmallCtx

    RESTORE_BIG_REG_CTX

这里有一个容易让人停顿的细节:为什么 RESTORE_SMALL_REG_CTX 会执行 LDP X0, X1, [X8, #XGREG_OFFSET(0)],而 SAVE_SMALL_REG_CTX 却没有去保存全部易失寄存器?

关键点在于,small context 同时服务于两条路径:

  • 普通任务切换
  • 新任务首启

对"新任务首启"来说,x0 是有明确语义的,它在 archTaskCtxCreate() 里被预填为任务入口参数;而 x1 在这条路径上通常没有额外的 ABI 语义要求,它只是因为恢复宏采用了成对装载指令 ldp,顺手和 x0 一起被加载出来。

换句话说,这里真正需要关心的是:

  • x0 必须正确,因为它承载任务入口参数
  • x1 被一起恢复,更多是实现上的统一和指令配对便利,并不表示 small context 必须像 big context 一样严肃地保存全部 caller-saved 现场

3.3 为什么正常任务切换只保存 small context

arm64ContextAsm.S

asm 复制代码
FUNC_DEF(archTaskCtxSwitch)
    LDR     X8, [X0]
    SAVE_SMALL_REG_CTX

    MOV     X19, X0
    BL      _SchedSwp

    LDR     X8, [X19]
    LDR     X9, [X8, #CTX_TYPE_OFFSET]
    CMP     X9, #0
    B.NE    _RestoreSmallCtx

    RESTORE_BIG_REG_CTX

SAVE_SMALL_REG_CTX 的核心内容是:

asm 复制代码
MOV     X9, #1
STR     X9, [X8, #CTX_TYPE_OFFSET]

STP     X19, X20, [X8, #XGREG_OFFSET(19)]
STP     X21, X22, [X8, #XGREG_OFFSET(21)]
STP     X23, X24, [X8, #XGREG_OFFSET(23)]
STP     X25, X26, [X8, #XGREG_OFFSET(25)]
STP     X27, X28, [X8, #XGREG_OFFSET(27)]
STP     X29, LR,  [X8, #XGREG_OFFSET(29)]

SAVE_PSTATE
MOV     X9, SP
STR     X9, [X8, #XSP_OFFSET]
STR     LR, [X8, #XPC_OFFSET]

为什么这里只存 x19-x29LRSP 和必要状态?

因为任务切换发生在一个普通函数调用边界上。当前任务主动进入调度器时,它遵守的就是 AAPCS64:

  • caller-saved 寄存器本来就不保证跨调用保留
  • callee-saved 寄存器必须恢复

这里顺手解释一下术语:ABI 语境里的 volatile / non-volatile 说的是"跨一次函数调用后,寄存器中的值是否允许被破坏"。

  • volatile register:可以被被调用者直接覆盖,通常对应 caller-saved
  • non-volatile register:被调用者若要使用,必须先保存、返回前再恢复,通常对应 callee-saved

它和 C 语言里的 volatile 关键字不是一回事。

所以对"从一个普通执行点切到调度器,再从调度器切到另一个任务"这件事来说,保存 non-volatile 集合已经足够。

这正是 ABI 对操作系统最直接的帮助:调度器不需要在每一次线程切换时都无脑保存全部寄存器。

不过要注意两点:

  1. small context 不是"只含 x19-x29"

    • 实现里还保存了恢复控制流必需的 SPPC/LRPSTATE
    • 某些配置下还会保存 x18TPIDR_EL0
  2. RESTORE_SMALL_REG_CTX 用的是 ERET,不是 ret

    • 这说明这里恢复的不只是"函数返回地址"
    • 还包括异常返回寄存器 ELR_EL1/SPSR_EL1

也就是说,这套线程恢复路径虽然借用了 AAPCS64 的寄存器保存责任,但最终落地成的是"异常返回式"的线程切入。

对应的恢复代码如下,注意它不仅恢复 x19-x29,还会恢复 SPELR_EL1SPSR_EL1,最后走 ERET

asm 复制代码
MACRO_DEF(RESTORE_SMALL_REG_CTX)
    LDR     X1, [X8, #XSP_OFFSET]
    MOV     SP, X1

    RESTORE_PSTATE

    LDR     X1, [X8, #XPC_OFFSET]
    MSR     ELR_EL1, X1

    LDP     X0,  X1,  [X8, #XGREG_OFFSET(0)]
    LDP     X19, X20, [X8, #XGREG_OFFSET(19)]
    LDP     X21, X22, [X8, #XGREG_OFFSET(21)]
    LDP     X23, X24, [X8, #XGREG_OFFSET(23)]
    LDP     X25, X26, [X8, #XGREG_OFFSET(25)]
    LDP     X27, X28, [X8, #XGREG_OFFSET(27)]
    LDP     X29, LR,  [X8, #XGREG_OFFSET(29)]

    ERET

3.4 为什么异常入口必须保存 big context

再看 arm64ExcAsm.S 的 IRQ 入口:

asm 复制代码
FUNC_DEF(archEL1IrqEntry)
    EXC_SAVE_VOLATILE
    BL      API_InterEnter
    CMP     X0, #1
    BNE     1f

    BL      API_ThreadTcbInter
    EXC_COPY_VOLATILE
    EXC_SAVE_NON_VOLATILE

    BL      API_InterStackBaseGet
    MOV     SP, X0

2:
    BL      bspIntHandle
    BL      API_InterExit
    MOV     X8, SP
    RESTORE_BIG_REG_CTX

EXC_SAVE_VOLATILEEXC_SAVE_NON_VOLATILE 分别做两件事:

asm 复制代码
MACRO_DEF(EXC_SAVE_VOLATILE)
    MRS     X18, TPIDR_EL1
    SUB     X18, X18, ARCH_REG_CTX_SIZE
    ...
    STP     X0,  X1,  [X18, #XGREG_OFFSET(0)]
    ...
    STP     X16, X17, [X18, #XGREG_OFFSET(16)]
    STP     X29, LR,  [X18, #XGREG_OFFSET(29)]

    MRS     X2, SPSR_EL1
    STR     X2, [X18, #XPSTATE_OFFSET]
    MOV     X2, SP
    STR     X2, [X18, #XSP_OFFSET]
    MRS     X2, ELR_EL1
    STR     X2, [X18, #XPC_OFFSET]
    MOV     SP, X18
asm 复制代码
MACRO_DEF(EXC_COPY_VOLATILE)
    LDP     X9,  X10, [SP, #XGREG_OFFSET(0)]
    STP     X9,  X10, [X0, #XGREG_OFFSET(0)]
    ...
    LDP     X9,  X10, [SP, #XSP_OFFSET]
    STP     X9,  X10, [X0, #XSP_OFFSET]
    LDR     X9,       [SP, #XPSTATE_OFFSET]
    STR     X9,       [X0, #XPSTATE_OFFSET]

MACRO_DEF(EXC_SAVE_NON_VOLATILE)
    MOV     X1, #0
    STR     X1, [X0, #CTX_TYPE_OFFSET]
    STP     X19, X20, [X0, #XGREG_OFFSET(19)]
    STP     X21, X22, [X0, #XGREG_OFFSET(21)]
    STP     X23, X24, [X0, #XGREG_OFFSET(23)]
    STP     X25, X26, [X0, #XGREG_OFFSET(25)]
    STP     X27, X28, [X0, #XGREG_OFFSET(27)]

这三段代码合起来,刚好对应了异常入口的三个阶段:

  1. EXC_SAVE_VOLATILE:先把 x0-x17x29/LRSP/PC/PSTATE 抢救到异常临时栈
  2. EXC_COPY_VOLATILE:再把这份临时现场拷贝到最终的 ARCH_REG_CTX
  3. EXC_SAVE_NON_VOLATILE:补齐 x19-x28,并把 CTX_TYPE 标为 big context

这里要特别强调:EXC_SAVE_VOLATILEEXC_COPY_VOLATILE 不是重复保存,而是两个不同阶段。

  • EXC_SAVE_VOLATILE 的目标是立刻抢救现场

    • 异常一进来,处理代码马上就要执行 BL API_InterEnter
    • 一旦发生 BLx0-x17 这类 caller-saved / volatile 寄存器就可能被后续函数合法覆盖
    • 所以必须先把这批最容易丢失的寄存器,连同 ELR_EL1SPSR_EL1、原始 SP 一起,先存到一个绝对安全的临时位置
  • EXC_COPY_VOLATILE 的目标是把刚才抢救下来的快照搬到最终归宿

    • 对第一次进入中断的情况,最终归宿是当前线程的 ARCH_REG_CTX
    • 对中断嵌套的情况,最终归宿是异常栈上新开出来的那块上下文区
    • 它拷贝的是"刚才保存在临时区里的旧现场",不是再次从 CPU 当前寄存器里重新取一遍值

换句话说,这里的设计不是"保存两次同一份活数据",而是:

  1. 先在异常入口第一时间把易失寄存器抢下来
  2. 等代码拿到最终目的地址之后,再把这份快照搬过去

之所以只有 volatile 部分需要这样两阶段处理,是因为 x19-x28 这类 non-volatile 寄存器在 AAPCS64 下本来就要求被调用者保持不变,所以即使异常入口先执行了几个 BL,它们的值按约定仍然应该保持为"被打断现场"的值,稍后再执行 EXC_SAVE_NON_VOLATILE 仍然来得及。

这和任务切换完全不是一个场景。

异常是异步打断。CPU 可能在以下任意时刻被中断:

  • 函数尚未建立完整栈帧
  • caller-saved 寄存器里还放着关键中间值
  • 参数寄存器 x0-x7 仍然承载着当前计算现场
  • ELR_EL1/SPSR_EL1 刚刚被硬件写入,等待软件接管

所以异常入口不能依赖"caller-saved 反正可以丢"这套函数调用思维。它要做的是:

  1. 先把易失部分尽快搬到安全区
  2. 再补齐 non-volatile 部分
  3. 最终形成一份可完整恢复的 big context

这也是 CTX_TYPE 在异常路径里被设置为 0 的原因。

3.5 异常退出时为什么仍然先看 CTX_TYPE

异常退出的加载入口是 archIntCtxLoad()

asm 复制代码
FUNC_DEF(archIntCtxLoad)
    LDR     X8, [X0]
    LDR     X9, [X8, #CTX_TYPE_OFFSET]
    CMP     X9, #0
    B.NE    _RestoreSmallCtx

    RESTORE_BIG_REG_CTX

这里最关键的一点是:异常退出时,准备恢复的"目标任务"并不一定就是刚才被中断打断的那个任务。

一次中断或异常处理过程中,调度器完全可能重新选择当前任务。因此当 archIntCtxLoad() 被调用时,它面对的是"当前 CPU 最终决定要继续运行的那个 TCB",而这个 TCB 里的上下文来源可能有几种:

  • 它可能就是刚才被异常打断的当前任务

    这种情况下,它的现场是在异常入口通过 EXC_SAVE_VOLATILE + EXC_SAVE_NON_VOLATILE 保存下来的,因此是 big context

  • 它也可能是另一个早就处于就绪态、但之前是通过普通任务切换被换下去的任务

    这种情况下,它保存下来的通常是 small context

  • 它甚至可能是一个刚创建、还没有真正跑起来过的新任务

    这种情况下,archTaskCtxCreate() 预先构造的也是 small context

所以,archIntCtxLoad() 这里判断 CTX_TYPE,本质上不是在问:

  • "我是不是正在从异常返回?"

而是在问:

  • "我即将恢复的这个目标任务,它手里的那份上下文,到底是按 small context 保存的,还是按 big context 保存的?"

只有先回答这个问题,后面才能决定:

  • 是调用 RESTORE_SMALL_REG_CTX
  • 还是调用 RESTORE_BIG_REG_CTX

换句话说,异常退出路径是统一入口,但目标任务的上下文来源并不统一 。这就是它即使发生在"异常返回"阶段,仍然必须先看 CTX_TYPE 的根本原因。

RESTORE_BIG_REG_CTX 的关键骨架如下:

asm 复制代码
MACRO_DEF(RESTORE_BIG_REG_CTX)
    LDR     X1, [X8, #XSP_OFFSET]
    MOV     SP, X1

    RESTORE_PSTATE

    LDR     X1, [X8, #XPC_OFFSET]
    MSR     ELR_EL1, X1

    LDP     X0,  X1,  [X8, #XGREG_OFFSET(0)]
    LDP     X2,  X3,  [X8, #XGREG_OFFSET(2)]
    LDP     X4,  X5,  [X8, #XGREG_OFFSET(4)]
    LDP     X6,  X7,  [X8, #XGREG_OFFSET(6)]
    ...
    LDP     X29, LR,  [X8, #XGREG_OFFSET(29)]
    LDR     X8,       [X8, #XGREG_OFFSET(8)]

    ERET

恢复宏的关键动作有三类:

  1. 恢复软件上下文中的通用寄存器
  2. 把保存下来的 PC 写回 ELR_EL1
  3. 把保存下来的状态写回 SPSR_EL1
  4. 最后执行 ERET

这说明异常返回不是普通的 ret

  • ret 只跳到 LR
  • eret 同时恢复异常级返回地址和处理器状态

因此,只要你在反汇编里看到 ELR_EL1SPSR_EL1ERET 这一组组合,就应该立刻意识到:现在谈论的已经不是普通函数 ABI,而是异常级控制流恢复。

相关推荐
lcj25111 小时前
【stack、queue、deque、priority_queue】C++ 栈 / 队列 / 优先级队列全解析!手撕实现 + 二叉树层序遍历(附源码)
开发语言·c++·笔记
zhangakirn1 小时前
Systems Biology Part 1学习笔记
笔记·学习
say_fall1 小时前
模拟量输入输出技术超详细知识点总结
linux·开发语言·嵌入式硬件·学习·php
armwind1 小时前
openISP学习7-CCM — Color Correction Matrix(色彩校正矩阵)
python·学习·矩阵
济6171 小时前
ROS2 Humble 开发专栏---ROS2 三维视觉应用(1)---RGB-D 相机三维点云数据获取实验---适配Ubuntu 22.04
ubuntu·嵌入式·ros2·机器人开发·机器人方向
2301_789015621 小时前
Linux基础开发工具一:软件包管理器、vim编辑器
linux·服务器·c语言·汇编·c++·编辑器·vim
玖玥拾1 小时前
C/C++ 基础笔记(十)
c语言·c++
hanlin032 小时前
基于OpenHarmony 5.0的CAN驱动移植步骤
linux·c语言·华为·can·openharmony·t527
聆风吟º2 小时前
【C标准库】深入理解C语言 atoi 函数:字符串转换为整数
c语言·开发语言·库函数·atoi