ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cortex-M与Cortex-A运行模式及寄存器对比:从异常机制到实战应用

Cortex-M与Cortex-A运行模式及寄存器对比:从异常机制到实战应用

做嵌入式这几年,Cortex-M 和 Cortex-A 我两边都摸过。被问得最多的问题就是:同样挂着 ARM 的招牌,M 系列和 A 系列到底差在哪?很多新手拿 M 核的经验去碰 A 核,或者反过来,一开始都容易懵。代码层面可能差异不大,但真正一涉及中断嵌套、任务切换和异常现场恢复,你就会发现两者的运行模式和寄存器组是完全不同的两套思路。今天就把这块掰开揉碎,重点对比它们各自有哪些运行模式、寄存器是怎么组织的,以及在开发中怎么利用这些差异。不管你是跑裸机、RTOS,还是正在上手大型操作系统的移植,搞清楚这些底层机制,远比背几个主频和缓存参数更重要。

1. 先看定位:Cortex-M 和 Cortex-A 到底在争什么

1.1 一个追求确定性,一个追求吞吐量

Cortex-M 系列主要面向微控制器领域,强调“体积小、功耗低、中断延迟短”。它一般不带 MMU(内存管理单元),即使部分核配了 MPU(内存保护单元),也主要用来做访问权限限制,而不是地址映射。常见场景是传感器节点、电机控制、物联网终端这类对实时性要求高、逻辑相对固定的设备。这类设备代码量不大,跑裸机或者 FreeRTOS、RT-Thread 这类轻量级 RTOS 就够,追求的是每次中断能在确定时间内完成响应。

Cortex-A 系列则是应用处理器路线,核心关键词是性能、多任务、虚拟内存。它标配 MMU,可以跑大型操作系统,多核调度、Cache 一致性、虚拟化扩展都属于它的基础能力。你可以把它理解成一台缩小的“电脑 CPU”,处理复杂的计算任务时,它更关注整体吞吐量和多任务公平性,而不是某一个中断是否足够快。

所以第一个要纠正的误区是:不能说 A 系列一定比 M 系列“高级”。它们是在解决不同的问题。M 系列为了把中断响应做到足够快,宁可放弃一些复杂特性;A 系列为了承载操作系统和复杂计算,愿意用更复杂的异常模型和更大的功耗换取整体性能。项目选型时,如果只需要传感器采集和控制几个电机,硬上一个 A 核高端芯片,反而会带来电源、成本和启动时间的负担;反过来,如果想在上面跑神经网络推理和图形界面,M 核的性能天花板又非常明显。

1.2 运行模式和寄存器组,才是差异的本质

有些人喜欢用“能跑大型操作系统的是 A,不能跑的是 M”来区分,这只是一个表象。真正把这两个体系分开的,是它们对异常和特权的组织方式。M 系列的异常模型很像“自动挡”,中断来了,硬件帮你把关键的寄存器压栈,你只需要写一个短小的处理函数;A 系列则像“手动挡”,异常来了,硬件只帮你切换到一个新模式的专属窗口,剩下的现场保存要靠软件自己完成。

这种差异最后都会落在两个具体问题上:

  1. 发生中断或异常后,哪些寄存器由硬件保存,哪些由软件保存?
  2. 不同优先级、不同模式之间切换,是共用一套寄存器,还是各自有独立副本?

这就是本文要对比的两条主线。把这两条线捋清楚,你在写汇编启动代码、移植 RTOS 上下文切换、排查 HardFault 或段错误时,会少走很多弯路。反过来说,如果这两条线不清不楚,你可能会花大量时间去调试一个其实已经被硬件处理得很明白的“灵异问题”。

1.3 别急着套用:M 的经验无法直接搬给 A

我见过不少从 M 转到 A 的开发者,第一反应是去找现成的启动文件,然后把 M 的向量表和中断服务函数写法套上去,结果很容易出问题。M 核的向量表在芯片内部,每个中断一个入口,用 C 语言写一个函数名就能注册;A 核的异常向量表往往是跳板,每个入口只有一条指令,后面需要用户自己组装处理流程。更关键的是,A 核有 CPSR 和 SPSR 的概念,M 核只有 xPSR;M 核一个 CONTROL 寄存器就能选堆栈,A 核则是每个特权异常模式都有自己的 SP。这些不是语法差异,是设计哲学差异。如果一开始不建立正确的模型,后面每个异常处理都会踩坑。

2. 运行模式对比:从 M 的两种模式到 A 的七种模式

2.1 Cortex-M:线程模式与处理模式

Cortex-M 内核运行模式只有两种:线程模式(Thread mode)和处理模式(Handler mode)。线程模式是正常程序执行时的模式,也就是 main 函数、任务函数、普通用户代码所在的地方;处理模式是发生异常或中断时,处理器自动进入的模式。

这里要特别注意,“特权级”和“运行模式”是两个维度。Cortex-M 还有两个特权等级:特权级和用户级。线程模式既可以是特权级,也可以是用户级,而处理模式永远是特权级。这一点由 CONTROL 寄存器的 nPRIV 位控制:nPRIV 为 0 时线程模式是特权级,代码能直接操作绝大多数系统资源;nPRIV 为 1 时线程模式变成用户级,普通代码不能直接访问特殊寄存器、不能修改 MPU 配置,也不能随意发起某些系统控制操作。如果用户代码想干这些事,只能通过异常(比如 SVC 指令)进入处理模式,由特权代码代为完成,再返回。

为什么要把模式和特权等级拆开?因为这种设计能让“普通应用”和“内核/系统服务”在同一个 CPU 上实现轻量级分层,但又不像 A 系列那么重。RTOS 里最常见的做法是:任务跑在线程模式的用户级,内核和中断跑在处理模式的特权级。任务栈用 PSP,内核栈用 MSP,互不污染。一个任务崩溃时,至少还能靠 MPU 和特权等级保护核心系统状态,这也是 M 核在工业控制里能站稳脚跟的重要原因。

2.2 Cortex-A:七种经典异常模式

Cortex-A 在 AArch32(32位)状态下,一般有七种运行模式:

  • User mode(USR):普通用户程序模式,特权最低,不能直接修改 CPSR 的模式位和大部分系统配置。
  • FIQ mode:快速中断模式,用于高优先级快中断。
  • IRQ mode:普通中断模式。
  • Supervisor mode(SVC):管理模式,通常由 SVC 指令触发,系统内核大多跑在这里。
  • Abort mode(ABT):数据或指令中止模式,内存访问异常时进入。
  • Undefined mode(UND):未定义指令模式。
  • System mode(SYS):与 User 模式共享寄存器,但是拥有特权,常见于内核中需要访问用户寄存器组的场景。

Cortex-A 的每个异常模式都有自己的 SPSR(Saved Program Status Register),异常发生时会保存被中断模式的 CPSR;同时,异常模式也有自己独立的 SP 和 LR。比如 IRQ 中断来了,硬件会把旧的 CPSR 存入 SPSR_irq,把返回地址存入 LR_irq,然后把 CPSR 的模式位切换成 IRQ 模式。这些动作在极短时间内由硬件完成,但剩下的通用寄存器,除了 FIQ 有自己的 R8-R12,其他都要由软件入栈保存。所以 A 核的中断入口代码通常一开头就是一堆压栈指令,退出时再恢复。

如果考虑 ARMv7-A 的虚拟化扩展和 TrustZone 安全扩展,还会看到 HYP mode 和 MON mode,加上这两个,功能模式数量会更多。而到 ARMv8-A 的 AArch64 状态,开始改用 EL0、EL1、EL2、EL3 四级异常等级,不再叫“模式”。这篇文章先以经典的七模式为主线,后面会点一下 64 位下的对应关系。

2.3 为什么 M 只需要两个模式,A 却要这么多

这个问题值得多问几遍。Cortex-M 的目标是低中断延迟和低功耗,所以它把异常现场全部交给硬件压栈,软件只管写中断函数。这就不需要在硬件里给每种异常准备一套“专属寄存器窗口”,只要搞一个处理模式加两个堆栈指针就足够了。模式越少,状态切换越简单,硬件上要做的事情越少,功耗和面积成本也越低。

Cortex-A 则不同,它要承载大型操作系统,进程上下文、内核上下文、中断上下文必须严格分层。User mode 给普通进程,SVC 给内核服务,IRQ/FIQ 给中断,Abort/Undef 给异常兜底。每种异常模式有自己的 SP、LR、SPSR,意味着异常发生的那一瞬间,处理器连“把现场存哪”都已经有固定答案了,不需要再去共用栈上挤来挤去。用生活类比:这就好比银行柜台,每个窗口都有自己独立的抽屉,多人同时办理业务互不干扰;M 系列更像一个小便利店,只有一个后台,但靠硬件实现高效排队。

对比维度Cortex-MCortex-A(AArch32 为主)
运行模式线程/处理两种用户+系统+异常约7/9种
特权设计特权/用户两级多种模式不同特权,另扩展异常等级
状态寄存器xPSRCPSR + SPSR_
堆栈指针MSP/PSP 两个,由 CONTROL 切换每个特权异常模式各有独立 SP
现场保存硬件自动压栈8个寄存器硬件只保存到SPSR/LR,通用寄存器软件压栈
快速中断优化自动压栈+尾链FIQ独享R8-R12

3. 寄存器组结构对比:处理器的“工位”长什么样

3.1 Cortex-M 的寄存器格局

Cortex-M 的寄存器组相对精简。R0-R12 是通用寄存器,R13 是堆栈指针 SP,R14 是链接寄存器 LR,R15 是程序计数器 PC。它内部其实有物理独立的两套堆栈指针:主堆栈指针 MSP 和处理堆栈指针 PSP。同一时刻到底用哪个,由 CONTROL 寄存器的 SPSEL 位和异常返回序列共同决定。

除了通用寄存器,Cortex-M 还有几个特殊寄存器:

  • xPSR:组合状态寄存器,分成 APSR、IPSR、EPSR。通过读 IPSR 可以看到当前正在处理的中断号。
  • PRIMASK:屏蔽除 NMI 和 HardFault 以外的所有可配置优先级异常,相当于一个总开关。
  • FAULTMASK:屏蔽除 NMI 以外的几乎所有异常,作用比 PRIMASK 更强。
  • BASEPRI:按优先级阈值屏蔽中断,比 PRIMASK 更精细,适合需要保留部分高优先级中断的场景。
  • CONTROL:控制线程模式特权等级和堆栈指针选择。

异常发生时,Cortex-M 硬件会在自动压栈阶段,把 xPSR、返回地址 PC、LR、R12、R3-R0 一共 8 个字按固定顺序压入当前栈。如果启用 FPU,还会按情况压入浮点寄存器。这种硬件自动压栈的最大意义是“确定性”:每次中断进入,栈帧布局都是一样的,RTOS 和调试器都能直接解析,不需要软件在入口处写一堆保存代码。

3.2 Cortex-A 的寄存器格局

Cortex-A 在 AArch32 状态下也有 16 个基本通用寄存器:R0-R15。其中,R15 是 PC,R14 是 LR,R13 是 SP。但不同模式下的 R13、R14 并不共用物理寄存器,而是各有副本:FIQ 模式有 R13_fiq、R14_fiq,IRQ 模式有 R13_irq、R14_irq,SVC、ABT、UND 也各自拥有自己的 SP 和 LR。FIQ 模式还额外拥有 R8_fiq-R12_fiq,这是普通模式下 R8-R12 的独立副本。这意味着,FIQ 发生时会少压很多通用寄存器,因此特别适合极低延迟的关键中断。

CPSR 是 Cortex-A 的当前程序状态寄存器,包含条件标志 N/Z/C/V、中断禁止位 I/F、Thumb 状态位 T、端序位 E,以及最低 5 位的模式位 M[4:0]。异常发生时,被中断的 CPSR 会自动保存到新模式的 SPSR 中,而不是靠软件搬。返回时通过movs pc, lr或等价指令,可以同时恢复 PC 和 CPSR。这里有个新手容易迷糊的点:M 核是通过 xPSR 在栈帧里保存状态,A 核则是通过 SPSR 保存状态;M 核没有 SPSR 这样一个一个对应异常模式的影子状态寄存器。

除了通用寄存器组,A 核还有系统控制协处理器寄存器,比如 CP15 相关寄存器,用来配置 MMU、Cache、TLB,以及异常向量表基址 VBAR。严格说,它们不算内核通用寄存器组,但在实操中经常要和模式切换一起使用。比如修改异常向量表基址,就要先切到特权模式,再操作 CP15 寄存器。

3.3 几个容易忽略的寄存器细节

第一,Cortex-M 的 LR 在异常处理模式下会被硬件改写成一个 EXC_RETURN 特殊值。常见的值包括 0xFFFFFFF9、0xFFFFFFFD,它们不是真实函数返回地址,而是一串“异常返回指令代码”。通过 LR 最低几个 bit,你可以判断返回后是进入线程模式还是继续处理模式,以及返回时应使用 MSP 还是 PSP。所以如果在 ISR 里直接拿 LR 做函数调用,或者把 LR 当成普通返回地址打印出来,你会看到一堆 0xFFFFFF 开头的怪值。这不是芯片坏了,是设计如此。

第二,Cortex-A 的 User mode 和 System mode 共享同一套寄存器,但特权等级完全不同。User mode 不能直接修改 CPSR 模式位,System mode 却可以。所以在 System 模式下访问 User 模式的寄存器视图时,不需要切换模式就能拿到同样的 R0-R12、SP、LR,这对内核调试和任务切换很有利。

第三,A 核的 SP 并不是全部都能随意改。不同异常模式有自己的 SP,但某些模式下修改 SP 会直接影响当前栈位置,一旦改错,异常返回就会从一个非法地址取数据,结果往往是死机。实操中,启动代码里设置各模式 SP 的顺序很关键,通常要先进入对应模式,设好该模式的 SP,再切到下一个模式。

3.4 用一张表记住两种体系的关键差异

维度Cortex-MCortex-A(AArch32)
通用寄存器R0-R15R0-R15(不同模式有banked副本)
状态寄存器xPSRCPSR,另有各异常模式SPSR
堆栈指针MSP/PSP 两个各特权模式独立SP
自动压栈硬件压栈8个寄存器无通用寄存器自动压栈
特权等级特权/用户两级多模式多等级,外加EL
典型代表裸机、RTOS大型操作系统、安全系统

这张表的每一行,都对应一套代码编写方式。比如在 M 核的 ISR 里,你可以比较放心地让编译器使用 R0-R3、R12、LR,因为异常进入时这些寄存器已经被硬件压栈,函数返回后现场会自动恢复;但在 A 核的 IRQ Handler 入口,如果不先保存 R0-R12,随便调用一个 C 函数就可能把现场打乱。这些差异,实际操作中全是坑。

4. 实战中的应用:把模式与寄存器差距变成手里的工具

4.1 Cortex-M 上用 PSP 跑任务,用 MSP 跑中断

RTOS 的上下文切换是 Cortex-M 运行模式最典型的应用场景。通常每个任务拥有自己的 PSP,而中断和内核始终使用 MSP。任务的第一次启动,需要先把任务初始化栈填好,栈顶放好初始 xPSR、PC、LR 等,再通过异常返回机制切到线程模式。

以 PendSV 调度为例。PendSV 异常是专门用来做上下文切换的,它本身是一个异常,所以会进入 Handler mode,并使用 MSP。在 PendSV Handler 中,一般流程是:

; 进入 PendSV Handler 时,硬件已自动压栈 xPSR, PC, LR, R12, R3-R0 ; 现在需要手动保存 R4-R11 到当前任务栈 MRS R0, PSP ; R0 = 当前任务栈指针 PSP STMFD R0!, {R4-R11} ; 手动保存 R4-R11 LDR R1, =CurrentTCB STR R0, [R1] ; 保存任务栈指针到任务控制块 ; 选择下一个任务 LDR R1, =NextTCB LDR R0, [R1] ; R0 = 新任务栈指针 LDMFD R0!, {R4-R11} ; 恢复 R4-R11 MSR PSP, R0 ; 更新 PSP BX LR ; 异常返回,硬件自动从 PSP 弹出剩余现场

这里的关键是:为什么 R4-R11 要手动保存,而 R0-R3、R12、LR、PC、xPSR 不用?因为硬件自动压栈已经处理了后面那部分,硬件不负责 R4-R11。所以调度器只需要关心编译器和调用规范里规定“必须跨函数调用保存”的寄存器即可。这也是 Cortex-M 上下文切换比很多老架构更简洁的原因。

如果想限制任务特权,你可以在任务初始化时把 CONTROL.nPRIV 设为 1,任务运行在线程模式的用户级,不能随便执行特权指令。一旦任务需要内核服务,可以通过 SVC 指令主动触发异常,切换到 Handler mode,由特权代码处理后再返回。这种模式下,任务与内核的边界非常清晰,对产品安全性很有价值。

4.2 Cortex-A 上通过 CPSR 判断和切换模式

在 Cortex-A 裸机开发中,启动代码里经常会做“切换模式”和“设置栈指针”的操作。写这些代码前,最好先把当前模式读出来:

uint32_t cpsr; __asm volatile("mrs %0, cpsr" : "=r"(cpsr)); uint32_t mode = cpsr & 0x1F; switch (mode) { case 0x10: /* USR */ break; case 0x11: /* FIQ */ break; case 0x12: /* IRQ */ break; case 0x13: /* SVC */ break; case 0x17: /* ABT */ break; case 0x1B: /* UND */ break; case 0x1F: /* SYS */ break; default: /* unknown */ break; }

Cortex-A 修改模式常用cps指令:

cps #0x13 ; 进入 SVC 模式 cps #0x12 ; 进入 IRQ 模式

但注意,User 模式下不能直接通过cps修改模式位,也不能随便msr cpsr。普通应用想进内核,必须执行 SVC 指令,由 SVC 异常处理程序完成特权切换。这种由异常机制强制进入内核的方式,是大型操作系统的系统调用基础。如果你在 User 模式里直接尝试修改受控的 CPSR 位,处理器会进入未定义指令异常,而不是“静默忽略”。

一个经典的系统调用流程是:应用代码执行SVC #0,处理器切到 SVC 模式,硬件把返回地址保存到 LR_svc,把原 CPSR 保存到 SPSR_svc。然后 SVC Handler 从 LR_svc 附近取出 SVC 指令编码,解析出系统调用号,再根据 R0-R7 里的参数完成内核服务。返程时恢复现场,回到用户模式继续跑。

4.3 利用寄存器现场定位崩溃点

调试 M 核时遇到 HardFault,最直接的方法是把当前异常栈帧找出来。因为硬件压栈布局固定,栈顶往上依次是 xPSR、PC、LR、R12、R3-R0,所以只要找到 PC 位置,基本就能定位到崩溃前行代码。配合调试器查看当前 LR 的 EXC_RETURN 值,还能判断是在线程模式还是处理模式下崩的,从而决定去检查哪个栈。

调试 A 核时遇到 Abort 或 Undefined,则要先看 SPSR_abort 或 SPSR_und,再结合 LR 值计算异常发生地址。如果是 MMU 触发的 Abort,还要去查 CP15 中的 Fault Address Register,看看是哪个地址访问触发了异常。这个流程比 M 核多出好几步,但只要记住“A 核的状态在 SPSR,M 核的状态在栈帧”,思路就不会乱。

5. 常见问题与排查技巧实录

5.1 任务栈总被莫名踩坏?先查 MSP/PSP 是否选错

症状:任务运行一段时间后,栈区数据被破坏,或者出现 HardFault。可能的原因非常多,但最典型的是 CONTROL.SPSEL 设置和当前使用的栈不匹配。比如某个任务明明应该在 PSP 上运行,初始化时却把栈指针写错到 MSP,一旦任务嵌套调用变深,就会和内核共用栈,互相踩踏。

排查看两个关键点:一是读 CONTROL 寄存器,看当前线程模式到底选了哪个栈指针;二是在异常返回时,检查 LR 中的 EXC_RETURN 值。如果返回时应该使用 PSP,但 CONTROL.SPSEL 还是 0,CPU 就会从 MSP 里找一个并不存在的任务栈帧,结果轻则返回地址错乱,重则直接 HardFault。解决办法是规范化任务创建流程:每个任务创建时,在初始化栈中提前安排好 PSP,并通过 RTOS 的上下文切换入口统一设置 CONTROL 和 PSP。

5.2 在 A 核用户模式下写 CPSR,直接进 UND

症状:程序执行到某条msr cpsr或cps指令后,没按预期进入目标模式,反而跳进了 Undefined 异常。

原因:User 模式是受限模式,不能修改 CPSR 中的模式位、中断禁止位等特权信息。处理器不允许你直接提升自己的特权等级,这是一种安全机制。如果你在无特权的 User 态尝试这些操作,硬件就会认为当前指令未定义,触发 Undefined 异常。

正确做法是:用 SVC 指令主动陷入内核,在内核态完成特权操作,再返回用户态。很多从 M 核转过来的开发者习惯在 main 函数里直接切换模式,这在 M 核没毛病,在 A 核就是大坑。记住,A 核的 User 模式像一扇上锁的门,开锁的钥匙是异常机制,不是直接写 CPSR。

5.3 中断里调用函数后,现场一片混乱

这个坑在两种核上表现不同。M 核因为在异常入口会自动压栈,只要不是手写汇编,C 编译器通常会正确处理 LR,一般不会出大问题。但如果你在 ISR 里手写汇编,并且没保存 LR 就调用子函数,那么 LR 原本保存的 EXC_RETURN 会被函数调用覆盖。返回时试图用BX LR完成异常返回,就会跳到一个被改过的“假地址”,现场当然全乱。

A 核则更隐蔽。进入异常模式后,LR 里保存的是被中断模式的下一条指令地址,还没保存到栈上。如果 Handler 里直接调用别的函数,返回地址就会被覆盖。所以 A 核的异常入口必须先把 LR 和需要保护的通用寄存器压栈,最后通过ldmfd sp!, {.., pc}^或movs pc, lr一类指令返回。这个“先压栈、后干活”的习惯,在 A 核开发里是铁律。

5.4 FIQ 不是“万能快中断”,用之前先算清楚代价

很多人以为 FIQ 全是优点:有自己的 R8-R12,不用压栈这些寄存器,中断响应很快。但代价是,FIQ 模式下,R0-R7 仍然和普通模式共用,如果中断处理程序里要修改 R0-R7,照样需要保存。而且 FIQ 拥有独立的 SP、LR、SPSR,会让启动代码和调试器需要额外处理一套寄存器视图。更关键的是,系统里所有外设中断通常都走 GIC,优先把某个外设配成 FIQ 后,它的中断上下文和普通中断隔离,如果你在 FIQ 里又调用一些依赖普通中断嵌套的代码,容易出问题。

我的建议是,默认情况用 IRQ 就够了,除非有特别明确、必须保证极低延迟的中断源,才考虑 FIQ。不要为了“看起来更快”去引入一整套新的现场维护逻辑。这个判断,和你是不是高手没关系,而是和你对系统确定性要求的理解有关。

5.5 查异常时,先摆出“模式+寄存器”再动手

最后再说一个我带人做项目时最常强调的排查习惯:遇到异常,先别急着翻代码或者猜原因,先把当前模式和寄存器现场摆出来。M 核看 CONTROL、EXC_RETURN、栈帧最上方的 xPSR;A 核看 CPSR、SPSR、LR 和异常的 Fault Address 寄存器。很多时候,问题的答案已经写在现场里了。比如 M 核 HardFault 的栈帧 PC 指向一个空指针跳转,A 核 Abort 的 Fault Address 指向一个非法地址,一眼就能看出问题,根本不用反复试跑。这比盲猜快十倍,前提是你真正理解了两套体系里寄存器和模式是怎么协作的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进