Armv8-A 系统的逆向工程——学习 Arm 架构基础

203 阅读38分钟

Arm® Holdings 成立于 1990 年,最初以合资企业的形式出现,如今已成为 IT 行业的主导力量之一。Arm 处理器应用广泛,从智能手机和平板电脑到服务器和物联网设备无所不在。在汽车行业,Arm 处理器更是占据了主导地位。凭借其庞大的生态系统,市面上许多芯片组都基于 Arm 处理器,可见 Arm 在半导体市场中的地位之稳固。

本章内容包括:

  • Arm 架构简介
  • 寄存器
  • Arm 架构过程调用规范(AAPCS)
  • 异常等级
  • 异常

本章主要聚焦于最流行的 Armv8-A 架构——智能手机、MacBook 以及汽车系统均基于 Armv8-A 架构。Armv8-A 提供了更多特性,包括指令集架构(ISA)、应用二进制接口(ABI)、虚拟化及内存架构。本章将讲解进行逆向工程所需的 Armv8-A 基础知识。

学习资源推荐:

Arm 架构简介

从硬件角度看,Arm 处理器是由晶体管构成的半导体;而从软件工程师的角度,如何才能控制 Arm 处理器?市面上 Arm 处理器种类繁多,软件开发者常常需要根据项目需求以不同方式配置处理器。

什么是 Arm 架构?

Arm 架构是 Arm 公司从软件视角对其处理器的描述。Arm 架构的关键组成要素包括寄存器、汇编指令、异常处理以及 TrustZone®,这些都是软件开发者必须掌握的内容。

下面让我们看一张截图:

image.png

图 1.1 展示了 Arm 架构的关键要素。

  1. 汇编指令(Assembly Instruction) :显示了 Arm 处理器所能解码执行的汇编指令集。Arm 架构定义了 ISA(指令集架构),描述了处理器可执行的汇编指令的语法。
  2. 寄存器(Register) :包括一组通用寄存器和 PSTATE(程序状态寄存器)。所有汇编指令的输入输出都读写于这些寄存器中。进行逆向工程或调试时,软件工程师会检查寄存器的值。
  3. 调用栈(Callstack) :展示了进程如何进行函数调用。调用栈与 AAPCS(Arm 架构过程调用标准)密切相关,后者定义了子程序调用及参数在函数间传递的约定。

图 1.1 从软件角度简要描绘了 Arm 架构。实际中,工程师会根据需求选择不同的调试工具,如 GDB 或 TRACE32,用于单步执行汇编、检查寄存器或查看调用栈。

Cortex® 处理器与架构

Armv8-A 架构支持多款主流 Cortex® 处理器,如 Cortex-A72 和 Cortex-A73。下表列出了带有 Cortex 处理器的 Armv7-A 和 Armv8-A 架构规格:

架构Cortex 处理器
Armv7-ACortex-A5, Cortex-A7, Cortex-A9
Armv8-ACortex-A32, Cortex-A35, Cortex-A53, Cortex-A57, Cortex-A72, Cortex-A73

表 1.1:架构与处理器对应关系

每款 Cortex 处理器在硬件设计上各有差异,包括 MMU、缓存、内部时钟配置等。例如,Cortex-A73 与 Cortex-A78 的缓存配置就不同。访问 Arm 官网即可查询各处理器的详细规格。

尽管硬件实现各异,所有同属 Armv8-A 架构的处理器在汇编指令、寄存器布局、AAPCS 规范和异常处理上都是兼容的。因此,针对 Cortex-A53 编写的代码通常无需修改即可在 Cortex-A57 等其他 Armv8-A 处理器上运行。从软件开发者的视角看,掌握 Arm 架构要远比记住每款处理器的具体参数更重要。

Armv8 各 Profile

Armv8 架构分为三个 Profile:

  • Armv8-A
    这是面向操作系统和应用处理器(例如 Linux、Windows)而设计的 Profile,广泛用于智能手机、平板、服务器及物联网设备。Armv8-A 支持虚拟化,可让 Hypervisor 管理多个操作系统实例,在汽车电子等领域尤为重要。
  • Armv8-R
    这是面向实时系统的 Profile,多见于汽车、医疗、机器人等对时序和可靠性要求极高的场景。R Profile 处理器仅运行 32 位代码,且内存架构较 A Profile 更为简化。
  • Armv8-M
    这是面向微控制器(MCU)的 Profile,常用于低功耗嵌入式系统,如物联网设备、可穿戴设备、车载控制器和工业自动化。

在逆向工程时,首先要确认二进制匹配的 Arm 架构版本,因此必须熟悉上述各 Profile 及其术语。

至此,我们已了解 Arm 架构和 Cortex 系列处理器的基础概念。下一节将详细介绍 Armv8-A 的寄存器——这一架构的核心特性。

寄存器

在阅读汇编代码时,你会发现指令的输入或输出都使用了寄存器。如果不了解寄存器的用途,就很难分析汇编例程。因此,掌握寄存器的使用方法非常重要。

本节将介绍 Armv8-A 架构中寄存器的组织方式,包括:

  1. 通用寄存器
  2. 特殊寄存器
  3. 系统寄存器

在本节末尾,我们还会重点说明在逆向工程中最常用的一组寄存器。首先,让我们来了解通用寄存器。

通用寄存器

学习寄存器时,最先接触到的就是通用寄存器。下图列出了 Armv8-A 中的所有通用寄存器,并说明它们的命名方式与基本用途:

X0   X4   X8   X12  X16  X20  X24  X28  
X1   X5   X9   X13  X17  X21  X25  X29  
X2   X6   X10  X14  X18  X22  X26  X30  
X3   X7   X11  X15  X19  X23  X27

图 1.2:通用寄存器

在 Armv8-A 架构中,通用寄存器以字母 “X” 加编号命名,例如 X0、X1、…、X30。分析汇编指令时,应重点关注这些寄存器,因为它们负责存放指令的输入和输出值。从作用上来看,通用寄存器类似于函数中的局部变量。

在实际开发或调试中,也可以通过 GDB、TRACE32 等工具查看这些寄存器的当前内容。下图演示了在 TRACE32 中显示的通用寄存器面板(常用于实战调试):
image.png

了解了通用寄存器的命名与用途后,接下来我们将依次介绍特殊寄存器和系统寄存器,它们在控制流程和系统状态管理中同样至关重要。

在 TRACE32 界面中,你可以看到从 X0 到 X30 的寄存器。这些都是 Armv8-A 架构定义的通用寄存器。任何一个通用寄存器都可以在指令执行过程中用来保存临时数据。不过,有些寄存器——比如 X0 到 X7 以及 X30——通常被用来控制程序的执行流程。

如果你在汇编代码中看到的是 W0 而不是 X0,别惊讶:读取 W0 就是访问对应 X0 寄存器的低 32 位。更一般地说,读取 Wn 等同于读取对应 Xn 寄存器的低 32 位,其中 n 的取值范围是 0 到 31。
“W” 寄存器系列就是对“X”寄存器低 32 位的别名,如下图所示:

image.png

如果软件向 W0 写入数据,你会看到 X0 寄存器的第 [31:0] 位被更新。这是因为 W0 表示 X0 寄存器的低 32 位。

现在你已经了解了 Armv8-A 中的通用寄存器,接下来让我们学习一下特殊寄存器。

Armv8-A 定义了一组特殊寄存器,用于管理进程的执行流程。例如,当执行子例程调用时,SP_ELx 寄存器会更新为保存该进程当前的栈地址;当发生异常时,ELR_ELx 寄存器会被更新为异常返回地址。

image.png

从特殊寄存器列表中,我们先来了解一下 SP_ELx 寄存器。

SP_ELx

SP_ELx 寄存器(如 SP_EL0、SP_EL1、SP_EL2、SP_EL3)是各异常级别下的栈指针寄存器。名称中“SP”表示 Stack Pointer(栈指针),“ELx”表示 Exception Level x(异常级别 x),x 可为 0、1、2 或 3。

  • SP_EL0 指向运行在异常级别 EL0(用户态)的进程栈顶。
  • SP_EL1 指向运行在异常级别 EL1(内核态)的进程栈顶。

Armv8-A 为栈指针的设置提供了两种方案:

  1. 每个异常级别使用独立的栈指针(SP_EL0, SP_EL1, SP_EL2, SP_EL3)。现代操作系统(如 Linux 内核、Xen 管理程序)通常采用此方案。
  2. 所有异常级别共享同一个栈指针。

请记住,Linux 等主流操作系统都采用第一种方案。

ELR_ELx

ELR_ELx 即 Exception Link Register(异常链接寄存器),用于保存异常发生时的返回地址。

  • 当异常进入 EL1 时,ELR_EL1 会被设置为触发异常的指令地址。异常处理完毕后,执行异常返回时会跳回 ELR_EL1 保存的地址。
  • 同理,进入 EL2 时 ELR_EL2 保存返回地址。

在调试或逆向工程中,查看 ELR_ELx 可以帮助我们找出异常返回点。

PC

程序计数器(Program Counter, PC)保存当前正在执行的指令地址。PC 在 Armv8-A 中属于特殊寄存器,不能通过普通的算术指令(ADD/SUB 等)直接修改,避免被恶意利用。PC 的更新方式有:

  1. 执行完一条指令后自动加 4(或根据指令宽度)指向下一条指令。
  2. 执行跳转指令(如 BL、BLR)时,PC 被设置为跳转目标地址,同时将下一条指令地址存入 X30(LR)。
  3. 异常发生时,PC 跳到对应的异常向量表地址。
  4. 执行 RET(或 ERET)时,将 X30(EL1 下为 Link Register)或 ELR_ELx 的值加载到 PC,实现返回。

在逆向工程中,跟踪执行流程往往要查看 SP_ELx、ELR_ELx、PC 等寄存器的值,因此理解这些特殊寄存器非常重要。

程序状态寄存器

Armv8-A 提供了程序状态寄存器,包括处理器状态(PSTATE)和已保存程序状态寄存器(SPSR_ELx)。下面,我们来了解一下 PSTATE 和 SPSR_ELx 寄存器,以及它们的用途。

PSTATE

Armv8-A 中的 PSTATE 与 Armv7-A 中的 CPSR(Current Program Status Register,当前程序状态寄存器)类似。PSTATE 拥有与 CPSR 类似的位字段布局。

注意
如果你熟悉 Armv7-A 架构,就会认识到 CPSR 包含处理器模式、异常屏蔽位以及条件标志等信息。

乍看之下,你可能会认为 PSTATE 是一个寄存器,但实际上并非如此。Arm 官方文档并不将 PSTATE 定义为一个物理寄存器,而是将它描述为一个抽象的“处理器状态”集合。我们无法通过单条汇编指令直接写入 PSTATE;只能通过一系列系统寄存器来间接访问和修改它。

下面是 PSTATE 中各个标志位的含义:

image.png

如图 1.6 所示,PSTATE 标志位包括条件标志、屏蔽标志,以及模式或异常级别。
首先来看条件标志,其含义如下:

  • N:算术运算结果为负
  • Z:算术运算结果为零
  • C:算术运算有进位
  • V:算术运算有带符号溢出

当执行比较指令时,会将结果更新到这些条件标志中。条件标志的值会影响后续条件跳转指令的执行流程,因此在逆向分析汇编代码时,检查条件标志非常关键。

接着是屏蔽标志,它们用于使能或禁用各类异常:

  • D,位 [9]:调试异常屏蔽位
  • A,位 [8]:异步(SError)异常屏蔽位
  • I,位 [7]:普通中断(IRQ)屏蔽位
  • F,位 [6]:快速中断(FIQ)屏蔽位

IRQ(中断请求)来自外设,用于通知处理器有紧急事件待处理。FIQ 是一种优先级更高的 IRQ,通常用于更紧急或对时序要求更严格的场景。

在汇编或伪代码中,这些屏蔽位常以 PSTATE.<D,A,I,F> 形式出现,例如:

PSTATE.<D,A,I,F> = '1111';  

表示将所有异常屏蔽位都置为 1,即暂时禁用所有对应的异常。

注意
“屏蔽”相当于“临时禁用”。这些位可设为 0 或 1:

  • 0 表示不屏蔽(允许该异常)
  • 1 表示屏蔽(禁用该异常)

PSTATE 的位 M[3:0] 用来指示当前的异常级别(EL0–EL3)。由于安全与一致性的原因,M[3:0] 只能通过系统寄存器 CurrentEL 读取,而不能直接通过单条指令访问。

SPSR_ELx

SPSR_ELx(Saved Program Status Register)就是用来保存发生异常前的 PSTATE 状态的寄存器。它的位定义与 PSTATE 完全一致。

在异常处理过程中,CPU 会将当前的 PSTATE 自动存入相应的 SPSR_ELx 寄存器,然后跳到异常向量去执行处理程序。异常处理程序通常会读取 SPSR_ELx 以恢复到异常发生前的状态,并在返回时将该值重新写回 PSTATE。

后面“异常”一节会详细讨论 SPSR_ELx 在异常处理中的使用。

系统寄存器

在您听到“系统寄存器”这个术语时,可能会好奇它与通用寄存器有什么区别。如本章前面所述,通用寄存器在执行汇编指令时用作输入或输出;而系统寄存器则仅在进行系统配置时才会被访问。

Armv8-A 定义了一系列系统寄存器,用来提供诸如 MMU(内存管理单元)、IRQ(中断请求)、缓存和陷阱处理等系统级功能的配置接口。在配置系统寄存器时,需要注意以下两点:

  1. 系统寄存器名称末尾的后缀指出了能访问它的最低(最小)异常级别。
  2. 系统寄存器通过 MSR(Move to System Register)和 MRS(Move from System Register)指令来访问,下一小节将对此进行详细说明。

由于系统寄存器名称的后缀即表示最低异常级别,我们首先来看什么是“最低异常级别”。

最低异常级别

查看系统寄存器的名称时,您会发现它们都带有一个指示访问级别的后缀。例如,TTBR0_EL1 就是在 TTBR0 后面加上后缀 EL1

有人可能会认为,只有 EL1 级别的代码才能访问 TTBR0_EL1。但事实并非如此。下图(图 1.7)展示了可以访问 TTBR0_EL1 的最低异常级别:

image.png

如图 1.7 所示,EL1、EL2 和 EL3 均被允许访问 TTBR0_EL1。虽然 EL1 是能够访问 TTBR0_EL1 的最低异常级别,但必须注意的是,更高的异常级别(如 EL2、EL3)也可以访问该寄存器。

如果从 EL0 尝试访问 TTBR0_EL1,Arm 处理器将触发一个同步异常,因为这是一次特权违规——访问该寄存器的最低异常级别是 EL1,而 EL1 的权限高于 EL0。

下表列出了 Armv8-A 架构中常用的一些系统寄存器:

寄存器全称描述
SCTLR_ELxSystem Control Register控制内存系统关键功能,如 MMU、缓存和对齐检查
ACTLR_ELxAuxiliary Control Register管理各种辅助功能
TTBR0_ELxTranslation Table Base Register 0保存第一级页表的基地址
SCR_EL3Secure Configuration Register保存安全状态,并控制 EL3 的陷阱处理行为
HCR_EL2Hypervisor Configuration Register控制 EL2 下的虚拟化和陷阱处理
MIDR_ELxMain ID Register提供处理器主 ID 及修订信息
MPIDR_EL1Multiprocessor Affinity Register提供处理器亲和性配置信息
CTR_EL0Cache Type Register提供缓存配置详情

表 1.2: Armv8-A 架构中常见的系统寄存器

如何访问系统寄存器

若想读写系统寄存器,不能使用常规的 LDRSTR 指令。而必须使用专门的 MRS(Move from System Register)和 MSR(Move to System Register)指令。以下示例展示了它们的用法:

01  MRS  X0, TTBR1_EL1   // 将 TTBR1_EL1 的值读入 X0
02  MSR  TTBR1_EL1, X0   // 将 X0 的值写入 TTBR1_EL1

注意:
通常,诸如 VBAR_EL1 等主要系统寄存器会在引导时配置;某些系统寄存器也可以在运行时调整。写入系统寄存器后,建议立即执行一个 屏障(barrier) ,以确保该配置在所有 CPU 核心间同步生效。

与逆向工程相关的关键寄存器

既然我们已经了解了 Armv8-A 支持的各类寄存器,下面介绍在逆向工程过程中最常检查的几个寄存器。进行逆向工程时,强烈建议你注意:

  1. 检查 X30 寄存器
    因为 X30 保存着函数返回地址。
  2. 检查 X0–X7 寄存器
    这些寄存器用于向被调用函数传递参数。
  3. 检查 SP_ELx 寄存器
    它指向各个异常级别下的栈顶指针,用于跟踪函数调用和局部变量。

正如本节所述,在逆向工程中你会在各种指令中频繁遇到寄存器,因此务必理解它们在程序执行中的作用。其中,X30、X0–X7 以及 SP_ELx 在分析函数调用与返回时尤为重要,下一节我们将结合“Arm 架构过程调用标准”(AAPCS)来详细说明它们的使用。

Arm 架构过程调用标准(AAPCS)

在程序运行中,会有大量函数相互调用。你的系统中可能集成了由不同编译器生成的各种库,驱动与应用程序都依赖于一致的调用约定来正确传参、返回和管理栈帧。

以下示例展示了一个简单的 C 函数:

int add_func(int x, int y) {
    int result = x + y;
    printf("x:%d, y:%d\n", x, y);
    return result;
}
  • 任意函数 都可以调用 add_func
  • add_func 完成后,需要 返回 到调用它的那条指令;
  • 甚至在汇编里也可以直接调用这个函数。

为此,各 CPU 架构(包括 Armv7-A、Armv8-A、RISC-V)都定义了调用约定(calling convention),用以规定:

  1. 一个函数如何将参数传递给另一个函数;
  2. 调用结束后如何跳回到调用点。

在 Arm 世界里,这套约定被称为 AAPCS(Arm Architecture Procedure Call Standard),它是 Arm ABI 的一部分,也在早期文档中被称作:

  • APCS(Arm Procedure Call Standard)
  • TPCS(Thumb Procedure Call Standard)
  • ATPCS(Arm-Thumb Procedure Call Standard)

AAPCS 中的寄存器分配

在上一节我们知道,通用寄存器用于指令的输入输出。AAPCS 进一步规定了跨函数调用时各寄存器的用途:

  • X0–X7:用于传递最多 8 个参数
  • X0:还用于存放函数返回值

下面是一张示意图,展示了前 8 个参数如何被依次装入 X0 到 X7:

image.png

图 1.8 显示在 main 函数内部调用了 add_func 函数。这个函数带有两个整型参数,其传递方式如下:

  • 第一个参数存入 X0
  • 第二个参数存入 X1

在图中,还能看到 return result; 所对应的操作:函数返回值也通过 X0 寄存器传回调用者。

BL 指令详解

下面是将上述 add_func 示例编译为汇编后得到的片段:

0x10000 <main>:
    stp   x29, x30, [sp,#-16]!    // 保存上层的帧指针和返回地址
    mov   w1, #3                  // 把参数 y=3 放入 W1  
    mov   w0, #2                  // 把参数 x=2 放入 W0  
    bl    0x20000 <add_func>      // 跳到 add_func,同时把返回地址保存在 X30  
    str   w0, [sp,#8]             // 将函数返回值(在 W0 中)存到栈上  // 调用 printf 等
  • 第 5 、6 行:将常数 2 和 3 分别装入 W0、W1,它们就是要传给 add_func 的两个参数。

  • 第 7 行 bl 0x20000 <add_func>

    1. 程序计数器 PC 跳转到 add_func 入口(地址 0x20000)。
    2. 同时,硬件把下一条指令的地址(即 str w0, [sp,#8] 的地址 0x10010)写入 X30 (也叫 LR,链接寄存器),以备函数返回时使用。

在调试器(如 GDB、TRACE32)里,你能同时看到 PC 和 X30 被更新。因为 X30 保存了“从 add_func 返回的位置”,通常进入函数时要先把它压入栈中——你在栈内存里就能看到这一值。

提示
逆向工程时,很多人会查看 X30 的值,以判断当前函数是从哪个调用点进入的。

在 add_func 里如何恢复返回地址:

0x20000 <add_func>:
    stp   x19, x30, [sp,#-16]!    // 保存上一层 x19 和 X30(返回地址)
    add   w19, w0, w1             // result = x + y,结果存在 W19  
    mov   w1, w19                 // 准备调用 printf 时,将 result 传给第一个参数  
    bl    0x1fe948 <printf>       // 调用 printf  
    mov   w0, w19                 // 准备返回值:把 result 放回 W0  
    ldp   x19, x30, [sp],#16      // 恢复 x19 和 X30(从栈里弹出),并把 SP 加回 16  
    ret                           // 跳回到 X30 保存的地址
  • 第 3 行 stp x19,x30,[sp,#-16]!

    1. 把 x19 和 x30 (即返回地址)压入栈中。
    2. 栈指针 SP 减 0x10,因为 ARM 栈向低地址生长。
  • 第 13 行 ldp x19,x30,[sp],#16

    1. 从栈中弹出(恢复) x19 和 x30。
    2. 同时把 SP 加回 0x10。

注意
X30 被压入栈之前,SP 必须已为函数调用预先分配好空间——这正是启动代码(crt0)和编译器生成的函数前序代码的职责:在各异常级别上设置好初始 SP。

  • 参数传递:第 1~8 个整型/指针参数依次放入 X0…X7;返回值通过 X0 回传。
  • 函数调用bl <addr> 同时跳转到目标地址并把返回地址装入 X30。
  • 返回流程:在入口通过 stp …,X30,… 保存返回地址,函数尾用 ldp …,X30,… 恢复,最后执行 ret(相当于 br x30)返回。

熟悉这些约定后,你就能在 ARM 汇编里快速辨认出函数调用与返回的实现,并利用 X0/W0、X30、SP 等寄存器进行逆向分析。

image.png

前面这张图重点总结了我们迄今为止分析的流程:

  1. 程序执行分支跳转到 <target>,其中 <target> 是子例程的起始地址。
  2. 在跳转的同时,硬件会将返回地址存入 X30。
  3. 子例程入口指令会将当前 X30 的值压入栈中。
  4. 函数体内执行所需的任务。
  5. 函数即将退出时,会从栈中弹出保存的 X30。
  6. 执行 ret 指令时,将 X30 的值写回 PC。
  7. 程序流回到原来调用点的下一条指令。

本节介绍了 AAPCS(Arm Procedure Call Standard)的核心原则——子例程如何传参、如何保存和恢复返回地址、以及如何在汇编层面实现函数调用与返回。掌握这些约定后,即使没有源码,也能在 ARM 汇编中预测和追踪控制流。

异常级别(Exception Levels)

Armv8-A 的一个重要特性是异常级别(EL,Exception Level),它同时对应不同的特权级别(Privilege Level)。

异常级别与特权级别的对应

在 Linux 内核或 RTOS 代码中,你常会看到类似下面的系统寄存器操作:

    MSR TTBR0_EL1, X1
    ADD X0, X0, #0x800
    MSR VBAR_EL2, X1
  • TTBR0_EL1 表示 EL1 级可访问的寄存器
  • VBAR_EL2 表示 EL2 级可访问的寄存器

Armv8-A 定义了 4 个异常级别:EL0 到 EL3,对应的特权级别 PL0 到 PL3。一条简单的关系是:

EL0 ≡ PL0    (最低权限,用户态)
EL1 ≡ PL1    (内核态)
EL2 ≡ PL2    (虚拟化管理态,Hypervisor)
EL3 ≡ PL3    (安全监控态,Secure Monitor)

EL0 / PL0

  • 运行用户应用程序
  • 没有 配置中断、MMU、Cache 的权限
  • 不能 访问内核空间地址或系统寄存器
  • 如果在 EL0 下试图执行这些操作,将触发特权异常

EL1 / PL1

  • 运行操作系统内核(Linux、RTOS)
  • 拥有配置中断、MMU、Cache 等硬件资源的权限

EL2 / PL2

  • 通常运行 Hypervisor
  • 管理和监控 EL1 下的来宾操作系统
  • 能够直接访问 EL1、EL0 的代码和数据
  • 注意:文档要求必须实现 EL0 与 EL1,EL2(与 EL3)为可选实现

EL3 / PL3

  • 最高特权级别,运行 Secure Monitor 或者固件
  • 在系统启动时一般进入 EL3,执行安全引导和系统初始化
  • 初始化完成后,通常依次切换到 EL2,再到 EL1

一个典型的使用模型是:

EL0:用户态应用
EL1:操作系统内核
EL2:Hypervisor
EL3:安全监控

下图展示了不同软件在各异常级别上的运行位置:

image.png

让我们看看各个异常级别上运行的软件角色:

  • EL0 — 用户态应用
    用户应用运行在 EL0,以保证系统稳定与安全,因为它们无权直接配置中断、MMU 或缓存。如果用户程序需要访问这些硬件资源,就要通过执行 SVC (Supervisor Call)指令发出请求。SVC 在 EL0 下触发一个同步异常,CPU 跳转到 EL1,由操作系统内核处理系统调用。系统调用完成后,再返回到 EL0 的用户应用继续执行。
  • EL1 — Linux 或 RTOS 内核
    EL1 对应特权级 PL1,是操作系统内核运行的场所。内核负责管理 MMU、缓存、中断和外设等硬件资源,因此必须拥有 EL1 的权限。
  • EL2 — 虚拟机监控器(Hypervisor)
    Hypervisor 运行在 EL2(PL2),管理一个或多个来宾操作系统(EL1),为它们提供虚拟 CPU、虚拟中断等资源。当来宾操作系统需要向 Hypervisor 请求服务时,会执行 HVC (Hypervisor Call)指令,触发切换到 EL2 进行处理。
  • EL3 — 安全监控(Secure Monitor)
    EL3(PL3)具有最高特权,可配置系统的关键功能,如寄存器、内存、缓存等。系统上电时,第一阶段引导加载器(通常运行在 EL3)就在此级别初始化安全设置和平台硬件,然后再切换到 EL2 或 EL1 继续引导流程。

异常级别切换指令

要理解各异常级别之间的切换,就要知道以下指令的作用:

  • SVC (Supervisor Call):由 EL0 发起,触发同步异常切换到 EL1,执行系统调用。
  • HVC (Hypervisor Call):由 EL1 或 EL0 发起,触发异常切换到 EL2,执行虚拟化监控服务。
  • SMC (Secure Monitor Call):由 EL1 或 EL2 发起,用于切换到 EL3,执行与安全监控相关的服务。

image.png

深入了解通过特定指令在各异常级别之间切换的过程。

SVC 指令

EL0 的用户态应用运行在非特权级别,无法直接访问硬件资源。比如,它需要文件描述符(file descriptor)来操作文件,或者需要获取进程 ID(PID)。当 EL0 代码要访问内核功能时,会通过系统调用(system call)来实现——用户态程序调用标准库函数(如 writereadopenclose 等)时,实际上就是执行了 SVC 指令来切换到内核态。

  • 执行 SVC 前,系统调用号(syscall number)会被放入寄存器 X8
  • 执行 SVC 后,处理器触发同步异常,PC 跳转到 EL1 的异常向量地址(exception vector)。
  • 异常处理程序(位于该向量)首先读取 X8,根据其中的系统调用号分发到对应的内核函数。
  • Linux 内核提供了 400 多个不同的系统调用处理器,负责完成文件 I/O、进程管理、内存分配等服务。
HVC 指令

在虚拟化场景下,来宾操作系统(guest OS,运行在 EL1)需要向虚拟机监控器(hypervisor,运行在 EL2)发出请求,例如:

  1. 通知当前工作负载的状态。
  2. 获取关键的系统配置信息。

这时会执行 HVC 指令。处理器将 EL1 → EL2 切换,并跳转到 EL2 的异常向量地址,由 Hypervisor 的异常处理程序处理这些请求。使用 HVC 之前,通常要先通过 HCR_EL2(Hypervisor Configuration Register)配置允许的陷入行为。

SMC 指令

另一条用于异常级别切换的指令是 SMC(Secure Monitor Call),常用于安全扩展场景。当在 EL1 或 EL2 下执行 SMC 时,处理器会切换到 EL3,并跳转到 EL3 的异常向量,由安全监控程序(Secure Monitor)处理后续逻辑。
(关于 SMC 的详细用法与安全执行环境,见本书第十四章。)

通过这三条指令,软件可以在 EL0~EL3 之间进行有控制的切换,以便分别执行用户态、内核态、虚拟化管理态和安全监控态的任务。

如何确定当前异常级别

要让软件识别当前运行的是哪个异常级别,可以使用 MRS 指令(Move to Register from System)读取系统寄存器 CurrentEL

    MRS <Xt>, CurrentEL

执行上述指令后,CurrentEL 寄存器的值会被加载到通用寄存器 <Xt>。CurrentEL 存储了当前异常级别的二进制编码:

  • EL0:0b0000
  • EL1:0b0100
  • EL2:0b1000
  • EL3:0b1100

注:前缀 0b 表示二进制,例如 0b0100 对应十进制的 4。

示例:读取 CurrentEL

为防止特权级别被恶意代码提升——比如攻击者在 EL2 之上运行本应在 EL2 的 Hypervisor,就可能获得更高的特权——Xen Hypervisor 的启动代码在进入主循环前会检查当前级别是否为 EL2。示例如下(摘自 arch/arm/arm64/head.S):

check_cpu_mode:
    PRINT("- Current EL ")
    MRS    X5, CurrentEL
    print_reg X5
    PRINT(" -\r\n")

    /* 判断是否为 EL2 */
    CMP    X5, #PSR_MODE_EL2t
    CCMP   X5, #PSR_MODE_EL2h, #0x4, NE
    B.NE   1f       /* 不是 EL2,跳转失败 */
    RET             /* 是 EL2,继续 */

1:
    /* 非法情况,打印错误并挂起 */
    PRINT("- Xen must be entered in NS EL2 mode -\r\n")
    PRINT("- Please update the bootloader -\r\n")
    B      fail
ENDPROC(check_cpu_mode)

如果检测到不是 EL2,就会跳到 fail 标签并输出错误信息,阻止继续运行。

异常(Exceptions)

CPU 架构普遍支持多种异常(exception)机制,Armv8-A 也不例外。通常,当核心遇到某些特殊状况时,会暂停当前执行,并按以下步骤处理异常:

  1. 将程序计数器(PC)跳转到相应的异常向量地址。
  2. (如需)切换到更高的异常级别。

在异常向量地址上存放的代码称为异常处理程序(exception handler),它负责完成特定的中断或错误处理逻辑。

异步 vs 同步异常

Armv8-A 将异常分为同步异常异步异常两大类:

  • 同步异常(Synchronous) :在指令执行过程中立即触发,如

    • 执行了非法指令
    • 访问了无效地址
    • 主动执行 SVC、HVC 或 SMC 指令
    • 栈未对齐访问等
  • 异步异常(Asynchronous) :由外部事件触发,如

    • IRQ(普通外部中断)
    • FIQ(快速外部中断)
    • SError(外部内存访问异常)
类型异常触发原因
同步同步非法指令、无效地址、SVC/HVC/SMC、栈未对齐、未知原因
异步IRQ外设中断(非安全)
FIQ外设中断(安全)
SError外部内存访问失败

表 1.3:Armv8-A 中的异常类型

异常向量表(Exception Vector Table)

当异常发生时,核心需要知道跳转到哪个地址执行相应的处理程序,这由异常向量表(vector table)和异常向量基址(vector base address)共同决定。

  • 异常向量基址存放在系统寄存器 VBAR_ELx 中(在启动代码中配置)。
  • 表 1.4 列出了各异常类型在不同情况对应的偏移量(offset),偏移量需加到 VBAR_ELx 上,得到真正的向量地址。
异常触发时机 / SP 选择同步IRQ/vIRQFIQ/vFIQSError/vSError
当前 EL,使用 SP_EL00x0000x0800x1000x180
当前 EL,使用 SP_ELx(x>0)0x2000x2800x3000x380
降级至较低 EL(下一实现级别为 AArch64)0x4000x4800x5000x580
降级至较低 EL(下一实现级别为 AArch32)0x6000x6800x7000x780

表 1.4:Armv8-A 异常向量表(偏移量)

计算异常向量地址

VectorAddress = VBAR_ELx + Offset

从而读取或跳转到 VectorAddress 处的异常处理例程。

到此,我们已经理解了如何确定当前的异常级别,异步与同步异常的区别,以及异常向量表的基本原理。接下去,只需对照偏移表,就能找到对应异常的处理入口。

异常向量表详解

在前面的表格中,每列都对应一个当前异常级别。异常向量表中可用的“当前异常级别”有 EL1、EL2 和 EL3 三种。因此我们可以为这三种级别各自构建一张异常向量表。下面以“当前异常级别为 EL1”为例来说明异常向量表的含义:

异常来源同步IRQFIQSError
EL1,使用 SP_EL00x0000x0800x1000x180
EL1,使用 SP_ELx(x>0)0x2000x2800x3000x380
EL0(AArch64 模式)0x4000x4800x5000x580
EL0(AArch32 模式)0x6000x6800x7000x780

表 1.5 :当前异常级别为 EL1 时的异常向量表

下面逐行解读“当前异常级别为 EL1”时,各种场景下的向量地址如何计算。

1. EL1 with SP_EL0

该列原文为“current exception level with SP_EL0”,表示 EL1 与 EL0 共享同一个栈指针(SP_EL0)时的偏移量。

  • 同步异常:VBAR_EL1 + 0x000
  • IRQ 异常:VBAR_EL1 + 0x080
  • FIQ 异常:VBAR_EL1 + 0x100
  • SError 异常:VBAR_EL1 + 0x180

注意:现代操作系统(如 Linux 内核、Xen Hypervisor)采用这种共享 SP_EL0 的方式——通常各个异常级别会分别使用自己的 SP_ELx,以保证各自的栈空间独立。

2. EL1 (current EL1,使用 SP_EL1)

当在 EL1 级别发生异常时,偏移量为:

  • 同步异常:VBAR_EL1 + 0x200
  • IRQ 异常:VBAR_EL1 + 0x280
  • FIQ 异常:VBAR_EL1 + 0x300
  • SError 异常:VBAR_EL1 + 0x380

Linux 内核运行在 EL1,因此:

  • 若在内核中出现同步异常,就跳到 VBAR_EL1 + 0x200
  • 若出现中断,就跳到 VBAR_EL1 + 0x280

3. EL0 (AArch64 模式)

原文称“lower exception levels, where the next-lower implemented level is AArch64”。即:当前为 EL1,下一层“较低”级别是 EL0,并且 EL0 运行在 64 位模式下。此时使用的偏移是:

  • 同步异常:VBAR_EL1 + 0x400
  • IRQ 异常:VBAR_EL1 + 0x480
  • FIQ 异常:VBAR_EL1 + 0x500
  • SError 异常:VBAR_EL1 + 0x580

例如,当在 64 位用户程序(EL0)中触发同步异常,核心会将 PC 跳到 VBAR_EL1 + 0x400

注意:此时异常级别将从 EL0 自动切换到 EL1。

4. EL0 (AArch32 模式)

“lower exception levels, where the next-lower implemented level is AArch32”——即 EL0 在 32 位兼容模式下。此时偏移为:

  • 同步异常:VBAR_EL1 + 0x600
  • IRQ 异常:VBAR_EL1 + 0x680
  • FIQ 异常:VBAR_EL1 + 0x700
  • SError 异常:VBAR_EL1 + 0x780

提示:本节假设当前异常级别是 EL1。若当前为 EL2 或 EL3,则需要使用对应表格中的另一组偏移和基址,这里因涉及虚拟化或安全扩展,不在本书范围内展开。

异常生成的整体流程

图 1.12 展示了在发生异常时,各个组件如何协同工作的总体流程:

image.png

如图 1.12 所示,生成异常时共有五个步骤,可分为两大部分:

下半部分(第 1 步和第 5 步)

  • 第 1 步:指出异常的原因,如内存访问中止或外部中断。
  • 第 5 步:说明异常如何被处理。

上半部分(第 2 至 第 4 步)

  • 这几步描述了 Arm 核心在硬件层面如何生成异常。

下面逐步分析各个环节。

第 1 步:指出异常原因

常见的异常触发原因包括:

  • 内存中止:核访问了无效地址(例如指令中止、数据中止)。
  • 软件中断:执行 SVCHVC 指令。
  • 外部事件:外设中断(IRQ/FIQ)或 SError 产生。

如前所述,同步异常在执行指令时发生,而 IRQ、FIQ、SError 等异步异常则由外部事件触发。

第 2 步:更新寄存器

核硬件会更新以下寄存器:

  1. 将当前 PSTATE 保存到 SPSR_ELx 中。
  2. 将返回地址写入 ELR_ELx,以便异常处理完成后返回。
  3. 将引发异常的地址写入 FAR_ELx(Fault Address Register)。
  4. ESR_ELx (Exception Syndrome Register)的 [31:26] 位字段写入异常类别编码,表示异常的具体原因。

这些寄存器仅在对应的异常级别 ELx 可访问。调试或逆向时,检查它们能帮助我们获取:

  • 异常发生时的执行级别
  • 异常的详细原因
  • 引发异常的地址

通常,异常处理程序会读取这些寄存器以完成相应处理。

第 3 步:切换异常级别

  • 如果在 EL0 发生异常,则切换到 EL1。
  • 如果在 EL1 发生异常,则通常不再切换(在虚拟化场景下可进一步切换到 EL2,需配置 HCR_EL2)。

第 4 步:跳转到异常向量地址

核将 PC 指向相应异常的向量入口地址,计算方式为:

异常向量地址 = VBAR_EL1 + 偏移量

其中 VBAR_EL1 是向量表基地址,各异常类型偏移见向量表配置。

请注意,第 2 至 第 4 步均由硬件自动完成。

第 5 步:异常处理

当 PC 跳转到异常向量地址后,核开始执行该处的指令序列,即调用异常处理程序。不同异常类型的处理逻辑示例:

  • 同步异常:可能触发系统重置或终止进程,或执行系统调用。
  • IRQ:执行中断服务例程,响应外设中断。
  • SError:通常会触发系统重置。

本节我们详细介绍了 Armv8-A 在软硬件层面生成并处理异常的完整流程。下一节将深入探讨各种异常处理程序的具体实现。

异常处理程序如何工作

当异常发生后,系统会根据异常类型调用对应的异常处理程序。也就是说,不同类型的异常各有各自的处理方式。要彻底理解 Arm 核心如何产生异常,就需要了解每种异常在其处理程序中会经历怎样的场景。下面我们先来看同步异常。

同步异常处理

同步异常主要由两类原因触发:内存访问中止和通过 SVC 指令发起的系统调用。

  • 内存中止:如果在 EL0 发生内存中止,则通常只终止该用户进程,因为认为软件已处于致命错误状态;而在 EL1 发生内存中止,则会导致内核 panic,因为没有更高层的恢复机制。

  • 系统调用(SVC) :当执行 SVC 指令时,核会抛出同步异常,处理流程大致为:

    1. 从寄存器 X8 中读取系统调用号
    2. 根据调用号查找相应的系统调用处理函数
    3. 跳转执行该处理函数

在两种情况下,处理程序首先会读 ESR_ELx(Exception Syndrome Register)的异常类别编码,以判定是“哪一类”同步异常,再执行相应分支。

IRQ 与 FIQ 异常处理

  • IRQ(中断请求) :外设触发中断信号后,核发出 IRQ 异步异常,进入中断处理程序,执行相应的中断服务例程来响应外设。
  • FIQ(快速中断) :在 Armv7-A 中,FIQ 优先级高于普通 IRQ;而在 Armv8-A,FIQ 通常保留给安全中断,由安全操作系统或固件使用。

SError(系统错误)异常处理

SError 异常通常由外部内存子系统(如 ECC 校验失败或总线错误)触发,属于致命错误。处理程序通常会直接复位(reset)整个系统,因软件一般无法从此类错误中恢复。

注意
“实现定义”(implementation-defined)意味着某些功能或行为可由芯片厂商根据硬件设计自行决定。

了解各类异常的处理程序对于调试和逆向工程至关重要,因为它们是系统运行中非常重要的检查点。

总结

在本章中,我们学习了 Armv8-A 架构的基础概念。

  1. Arm 架构与处理器家族

    • 了解了 Arm 架构的定义,以及 Armv7-A 和 Armv8-A 等不同版本。
    • 认识了 Cortex 系列处理器(如 A53、A72、A73)与它们所对应的架构。
    • 明白了在选择调试工具和进行逆向分析时,需确认目标二进制文件所使用的处理器型号和架构版本。
  2. 寄存器

    • 掌握了通用寄存器(X0–X30/W0–W30)、专用寄存器(如 SP_ELx、ELR_ELx、PC)和系统寄存器(如 TTBR0_ELx、SCTLR_ELx、VBAR_ELx)的用途与访问方式。
    • 学习了 AAPCS(Arm 程序调用规范),了解函数参数如何通过 X0–X7 传递、返回值如何通过 X0 返回,以及 BL/RET 指令如何保存和恢复返回地址。
  3. 异常级别与异常处理

    • 了解了 EL0–EL3 四个异常(特权)级别及其对应的软件运行环境:

      • EL0:用户态应用
      • EL1:系统/内核态
      • EL2:虚拟化监控器(Hypervisor)
      • EL3:安全监控(Secure Monitor)
    • 学习了如何通过 SVC、HVC、SMC 等指令在各级别之间切换,以及如何读写 CurrentEL 寄存器来确定当前级别。

    • 掌握了同步异常(非法指令、地址错误、系统调用)、IRQ/FIQ 外部中断、SError 系统错误等异常类型的触发条件和处理流程。

    • 熟悉了异常向量表及其基址(VBAR_ELx)和各类型异常的偏移量,了解了异常发生后硬件如何保存状态寄存器并转入相应的异常处理程序。

通过本章内容,你已经具备了进行 Armv8-A 平台逆向工程所需的关键背景知识。下一章我们将深入研究 ELF 格式,以便在分析和调试二进制文件时更加得心应手。