Skip to content

ARM 架构与体系结构基础

说实话,如果你一直在 PC 上写 C/C++,大概率从来没关心过处理器到底怎么把一行代码变成电信号的——x86 那套东西太抽象了,编译器和操作系统替你挡住了几乎所有底层细节。但一旦踏入嵌入式这个坑,尤其是面对 ARM Cortex-M 系列的 MCU,这些知识就不再是加分项了,而是写出正确代码的前提。笔者见过太多人直接上手 STM32,结果连处理器模式、异常向量表是什么都说不清,遇到 HardFault 只能对着寄存器发呆。

其他语言比如 Python 或 Java 的开发者基本上不需要关心这些——虚拟机或解释器已经帮你把硬件抽象得干干净净。但 C/C++ 不一样,它们的设计哲学就是"贴近硬件",编译器生成的机器码和你写的源代码之间只隔了一层薄薄的抽象。而 ARM 架构作为当今嵌入式领域的绝对主流,理解它的体系结构,就是理解你写的每一行 C 代码在芯片上到底发生了什么。至于 C++ 的关联就更大了——对象布局、缓存友好设计、异常处理开销,每一个话题都和 ARM 的硬件特性直接挂钩。

这篇教程我们要从体系结构的角度把 ARM 处理器拆开来看一遍,搞清楚它的存储架构、指令集、寄存器文件、异常机制和处理器模式。这不是为了让你去写汇编,而是让你写 C/C++ 的时候,脑子里能对底层发生的事有一个清晰的模型——当你用 volatile 修饰一个寄存器的时候,你知道为什么;当你调试一个栈溢出导致的 HardFault 的时候,你能快速定位。

学习目标

完成本章后,你将能够:

  • [ ] 区分冯·诺伊曼架构、哈佛架构和改进型哈佛架构
  • [ ] 解释 ARM/Thumb/Thumb-2 指令集的区别和适用场景
  • [ ] 说出 R0-R15 各寄存器的角色和 AAPCS 调用约定
  • [ ] 描述 Cortex-M 的异常向量表结构和入栈/出栈机制
  • [ ] 理解 Thread/Handler 模式和特权级别的划分

环境说明

本篇内容偏理论但紧贴实际硬件,所有代码示例均可在 ARM 工具链下验证。

text
平台:ARM Cortex-M3/M4(代表芯片:STM32F1/F4 系列)
工具链:GCC ARM Embedded(arm-none-eabi-gcc)>= 10.x
       或 STM32CubeIDE / PlatformIO(底层同一套)
标准:-std=c11(C 部分)/ -std=c++17(C++ 对比部分)
硬件:阅读过程不需要开发板,有 STM32F103 或 STM32F407 可对照更佳
参考架构:ARMv7-M(Cortex-M3/M4),穿插 ARMv7-A(Cortex-A 系列)对比

第一步——搞清楚处理器怎么访问存储器

我们要讨论的第一件事是处理器的存储架构,也就是 CPU 怎么和存储器打交道。这个问题看似基础,但它直接决定了很多日常现象——比如为什么有些芯片上运行代码比另一些快,为什么 DMA 总是需要特别配置地址区域。

冯·诺伊曼架构——一条总线走天下

冯·诺伊曼架构的核心特征是:指令和数据共享同一条总线、同一个存储空间。CPU 通过一套地址总线去访问内存,不管你读的是代码还是数据,走的是同一条路。你可以把它想象成一条单车道——指令和数据排着队依次通过,没法并排走。好处是硬件简单——只需要一套总线、一个存储器,成本就下来了。早期的 8051 单片机以及大多数通用计算机的核心理念都源自于此。

问题也很明显:因为指令和数据挤在同一条总线上,CPU 不可能同时取指令和读写数据。在实际表现上就是性能受限——你想在执行一条加法的同时把结果写回内存?对不起,总线忙着取下一条指令呢,你得排队。这就是所谓的"冯·诺伊曼瓶颈"。

哈佛架构——两条总线各管各的

哈佛架构走了另一条路:指令和数据各有各的总线、各有各的存储空间。相当于把单车道变成了双车道——取指令和读写数据可以同时进行,理论上吞吐量翻倍。大多数 DSP 芯片和很多现代微控制器都采用了纯哈佛架构或其变体。

但纯哈佛架构也不是万能的。如果你的程序需要自修改代码(嵌入式里很少见),或者你想把一块内存既当代码区又当数据区用,硬件上就没那么灵活了——你得额外设计一套机制让两条总线能互相访问对方的存储空间。

改进型哈佛架构——ARM 的实际选择

现实中的 ARM Cortex-M3/M4 很少走极端,采用的是所谓的改进型哈佛架构(Modified Harvard Architecture)。你可以把它理解为:从软件视角看,地址空间是统一的(像冯·诺伊曼),但从硬件视角看,取指和数据访问可以并行进行(像哈佛)。

具体来说,Cortex-M3/M4 有三套 AHB-Lite 总线:I-Code 总线专门从 Code 区域(0x000000000x1FFFFFFF,Flash 映射在这里)取指令,D-Code 总线负责 Code 区域的数据访问(比如从 Flash 加载常量),System 总线负责 SRAM 和外设区域的访问。I-Code 和 D-Code 可以并行工作,所以 Flash 里的代码和 Flash 里的常量数据可以被同时访问,显著提高了执行效率。

如果你去看 STM32F407 的存储器映射图,会发现地址 0x000000000x1FFFFFFF 这 512MB 空间被标记为 Code 区域,而 0x20000000 开始是 SRAM 区域。ARM 官方建议在总线仲裁时 D-Code 的优先级高于 I-Code——因为数据访问如果被阻塞,处理器没法继续往下走,而指令预取可以稍微等一等。

⚠️ 踩坑预警 虽然 Cortex-M 有多套总线,但它们并不是真的"完全并行"——如果 I-Code 和 D-Code 同时访问 Flash,最终还是要经过 Flash 控制器的仲裁。在 STM32F1 上 Flash 只有 16 位宽且没有缓存,总线并行的优势大打折扣;而 STM32F4 有 128 位宽的 Flash 接口和自适应实时缓存(ART Accelerator),差距就非常明显了。选芯片的时候别忘了看这个指标。

第二步——搞懂 ARM 的指令集怎么编码

存储架构搞清楚了,接下来看 ARM 的指令集。这一块直接影响你生成代码的大小和执行效率,在资源受限的 MCU 上尤其关键。

ARM 指令集(32 位)——表达力强但体积大

ARM 最早的指令集(A32)是 32 位定长编码的,每条指令占 4 个字节。编码空间充足,能表达丰富的操作——条件执行、桶形移位器内联移位、多寄存器传送(LDM/STM)等高级特性。32 位指令的好处是表达力强,单条指令能做的事情多,性能天花板高。代价也明显——代码体积大,在 Flash 只有几十 KB 的小 MCU 上,这个开销不可忽视。

Thumb 指令集(16 位)——体积小但功能受限

为了解决代码密度问题,ARM 在 ARMv4T 架构引入了 Thumb 指令集(T16),把大部分常用指令压缩到 16 位编码。代价是丧失了一些高级特性——Thumb 状态下大部分指令不再支持条件执行,桶形移位器的使用也受限。但换来的是代码体积通常能缩小 30% 左右,对于 Flash 紧张的应用来说是救命稻草。

Thumb-2——Cortex-M 的默认选择

Cortex-M3/M4 使用的是 Thumb-2 指令集,它是一个混合编码方案:16 位和 32 位指令混编在一起。编译器会根据每条指令的需要自动选择最合适的编码宽度——简单的操作用 16 位,复杂操作(比如大立即数加载、除法等)用 32 位。这样你既获得了接近纯 ARM 指令集的功能完整性,又保持了接近纯 Thumb 的代码密度。

有一点特别值得注意:Cortex-M 系列处理器只支持 Thumb 指令集,不支持传统的 32 位 ARM 指令。所以你在 Cortex-M 上写的所有代码,不管是 C 编译出来的还是手写汇编,都必须是 Thumb 编码。编译器默认就是 Thumb 模式,大多数情况下不需要操心——但如果你要内嵌汇编或者手写启动文件,这一点就必须记住,否则你会收获一个非常漂亮的 Undefined Instruction 异常。

c
/// @brief 一个简单的 Thumb 函数示例
/// Cortex-M 上所有函数默认使用 Thumb 编码
int add_values(int a, int b)
{
    return a + b;
}

/// @brief 内嵌汇编示例——在 Thumb 模式下读取主栈指针(MSP)
/// 注意:实际项目中推荐用 CMSIS 的 __get_MSP() 宏
uint32_t read_msp(void)
{
    uint32_t msp_value;
    __asm__ volatile("mov %0, sp" : "=r"(msp_value));
    return msp_value;
}

⚠️ 踩坑预警 如果你在链接脚本或编译选项里手贱把 -mthumb 去掉了(或者错误地加了 -marm),在 Cortex-M 上链接会直接失败——因为 Cortex-M 的指令解码器根本不认识 32 位 ARM 编码。遇到 Undefined Instruction 异常时,先检查编译选项是不是 -mthumb

第三步——认识处理器的"工作台":寄存器文件

如果说指令集是处理器的"语言",那寄存器就是它的"工作台"——CPU 做运算的时候,数据先搬到寄存器里,然后在寄存器之间做操作,最后再把结果写回内存。理解寄存器分工是理解 ARM 运行机制的基础。

通用寄存器 R0-R15

ARMv7-M 架构定义了 16 个 32 位通用寄存器,编号 R0 到 R15。它们各有分工,并非所有寄存器都可以随便用。

R0-R3 是参数传递和返回值寄存器。按照 AAPCS(ARM Architecture Procedure Call Standard)的约定,函数调用的前四个参数通过 R0-R3 传递,返回值也放在 R0(64 位返回值则 R0 和 R1 联合使用)。你可以把它们理解为函数调用的"快递通道"——如果一个 C 函数的参数不超过四个,调用过程中完全不需要访问栈,速度非常快。但如果你写了五个参数的函数,第五个就要压栈了,多一次内存访问。

R4-R11 是被调用者保存的寄存器(callee-saved)。函数可以自由使用 R4-R11,但在返回之前必须恢复它们的原始值——也就是说,调用者可以放心地假设这些寄存器在函数调用后不会被破坏。编译器通常把这些寄存器分配给局部变量,尤其是循环计数器、经常访问的指针这类生命周期跨越函数调用的高频数据。如果你在调试时看到函数开头有一堆 PUSH {R4-R7, LR} 指令,那就是编译器在保存它打算使用的 callee-saved 寄存器。

R12(IP) 是 intra-procedure-call scratch register,名字很长但用途很简单——链接器在处理长跳转(目标地址超出跳转指令的编码范围)时会拿它做中转。平时写 C 代码基本不会直接接触。

R13(SP) 是栈指针,指向当前栈的顶部。ARM 有两个栈指针——主栈指针 MSP(Main Stack Pointer)和进程栈指针 PSP(Process Stack Pointer),通过 CONTROL 寄存器来选择当前使用哪一个。bare-metal 应用中通常只使用 MSP;如果跑了 RTOS,中断处理用 MSP,线程用 PSP,实现中断栈和线程栈的隔离。这个设计非常精妙——即使某个线程的栈溢出了,也不会破坏中断处理用的栈空间。

R14(LR) 是链接寄存器,保存函数调用的返回地址。执行 BL(Branch with Link)指令时,返回地址自动存入 LR。妙处在于:对于叶子函数(不再调用其他函数的函数),根本不需要把返回地址压栈,LR 里已经存好了,省了一次内存写操作。但如果你的函数里又调用了别的函数,LR 的值就会被覆盖,所以编译器会在函数开头把 LR 压栈保存。

R15(PC) 是程序计数器,指向当前正在执行的指令。在 ARM 上读 PC 得到的值通常是当前指令地址加 4(因为流水线预取的原因),写 PC 就相当于做了一次跳转。

c
/// @brief 演示 AAPCS 调用约定对寄存器使用的影响
/// 前 4 个参数通过 R0-R3 传递,第 5 个参数需要压栈

int fast_path(int a, int b, int c, int d)
{
    // a -> R0, b -> R1, c -> R2, d -> R3
    // 全部通过寄存器传递,无栈操作
    return a + b + c + d;
}

int slow_path(int a, int b, int c, int d, int e)
{
    // a -> R0, b -> R1, c -> R2, d -> R3
    // e -> 栈传递,多一次内存读操作
    return a + b + c + d + e;
}

arm-none-eabi-objdump -d 反汇编看一下区别:

text
; fast_path: 全部在寄存器中完成
fast_path:
    add   r0, r0, r1    ; a + b -> R0
    add   r0, r0, r2    ; + c
    add   r0, r0, r3    ; + d
    bx    lr            ; 返回

; slow_path: 第 5 个参数从栈上读取
slow_path:
    add   r0, r0, r1
    add   r0, r0, r2
    add   r0, r0, r3
    ldr   r3, [sp]      ; 从栈上读第 5 个参数
    add   r0, r0, r3
    bx    lr

可以看到 slow_path 多了一条 ldr 指令——这就是第五个参数压栈的代价。

⚠️ 踩坑预警 别为了"省参数"而把一堆不相关的变量塞进一个结构体再传指针——结构体指针本身要占一个寄存器槽位,而且通过指针间接访问多了一层解引用开销。合理的设计是:热路径函数参数不超过四个 int/float 大小的基本类型,多余的才考虑传结构体指针。

程序状态寄存器——xPSR 三兄弟

ARM 处理器的状态信息保存在程序状态寄存器中。在 Cortex-M 上它被拆分为三个子寄存器,合称 xPSR。

APSR(Application PSR) 保存算术逻辑运算的结果标志:N(Negative)、Z(Zero)、C(Carry)、V(oVerflow)、Q(饱和标志)。前四个就是我们熟悉的条件码标志,C 代码里的 if (a > b) 编译后就变成对这些标志位的判断。

EPSR(Execution PSR) 包含 Thumb 状态位(T-bit)和中断可继续指令位。Cortex-M 的 T-bit 始终为 1(因为只支持 Thumb),基本不需要手动操作。

IPSR(Interrupt PSR) 保存当前正在执行的异常编号。线程模式下 IPSR 为 0;如果正在处理某个中断,IPSR 就是该中断的编号。调试 HardFault 时特别有用——读 IPSR 就能确认当前在哪个异常上下文中。

c
/// @brief 通过 xPSR 的条件标志理解 C 代码的比较操作
/// 编译器会将条件判断转换为对 N/Z/C/V 标志的检测
int max_value(int a, int b)
{
    // 编译后:CMP R0, R1,然后检测 APSR 的标志位
    if (a > b) {
        return a;  // GT 条件:Z=0 且 N=C
    }
    return b;
}

第四步——搞明白处理器在什么"模式"下跑

ARM 处理器运行时有不同的"模式",每种模式有不同的权限级别和可访问资源。这一节是理解安全模型和异常处理的基础。

Cortex-M 的简化模型:Thread 和 Handler

Cortex-M 大幅简化了传统 ARM 的七种处理器模式,只保留了两个模式:Thread 模式(执行普通应用程序代码)和 Handler 模式(执行中断服务程序和异常处理代码)。每个模式又分特权和非特权两个级别。

上电复位后,处理器默认处于 Thread 模式 + 特权级别。如果你不主动降权(通过写 CONTROL 寄存器),整个程序都运行在特权态下——这在 bare-metal 开发中很常见,但也意味着你的代码可以"合法地"做任何事,包括写错寄存器导致外设行为异常。在跑 RTOS 的场景下,RTOS 通常会在创建用户线程时把权限降到非特权级别,这样即使线程跑飞了,也不会直接操作关键硬件寄存器。

Handler 模式永远是特权级别的——中断处理代码需要完整的硬件访问权限,这是硬性要求。当异常或中断发生时,处理器自动从 Thread 切换到 Handler 模式,处理完毕后自动切回。

⚠️ 踩坑预警 如果你在 Thread 模式下意外地降到了非特权级别,之后就再也升不回去了——只有异常/中断触发的 Handler 模式才能操作 CONTROL 寄存器提升权限。所以如果你打算使用非特权模式,一定要通过 SVC(Supervisor Call)指令触发一个系统调用来执行需要特权的操作,而不是在非特权模式下直接操作硬件寄存器。

第五步——跟着异常向量表走一遍中断处理全流程

到现在我们已经有了处理器模式和寄存器的基础知识,接下来把它们串起来——看看当异常或中断发生时,ARM 处理器到底做了什么。

异常不只是中断

在 ARM 的术语体系里,"异常"(Exception)是一个比"中断"更广泛的概念。中断只是异常的一种,其他还包括:复位(Reset)、NMI(不可屏蔽中断)、HardFault、Memory Management Fault、Bus Fault、Usage Fault、SVCall、PendSV、SysTick 等。它们共享同一套处理机制,只是优先级不同。

向量表——异常处理的"电话簿"

当异常发生时,处理器需要知道对应的处理函数在哪里。ARM 的解决方案是向量表(Vector Table)——一个存放在内存中的函数指针数组,每个异常类型对应一个表项。

在 Cortex-M 上,向量表默认从地址 0x00000000 开始(可以通过 VTOR 寄存器重定位)。第一个表项不是函数指针,而是初始栈指针(MSP)的值——这个设计很巧妙,处理器复位时会自动把这个值加载到 SP,不需要额外的初始化代码。从第二个表项开始,依次存放 Reset Handler、NMI Handler、HardFault Handler 等等。

c
/// @brief Cortex-M 向量表结构示意
typedef void (*ExceptionHandler)(void);

/// @brief 向量表布局(简化版,实际还包括更多 Fault 向量)
typedef struct {
    uint32_t         kInitialStackPointer;  // 初始 MSP 值
    ExceptionHandler reset_handler;         // 复位
    ExceptionHandler nmi_handler;           // 不可屏蔽中断
    ExceptionHandler hardfault_handler;     // 硬件错误
    ExceptionHandler memmanage_handler;     // 内存管理错误
    ExceptionHandler busfault_handler;      // 总线错误
    ExceptionHandler usagefault_handler;    // 用法错误
    // ... 省略若干保留项 ...
    ExceptionHandler svcall_handler;        // 系统服务调用
    ExceptionHandler pendsv_handler;        // 可挂起的系统调用
    ExceptionHandler systick_handler;       // 系统滴答定时器
    // 外部中断向量从此开始 ...
} VectorTable;

异常入栈——处理器自动保存的"现场"

当异常发生时,Cortex-M 处理器会自动在当前栈上保存八个寄存器的值:R0、R1、R2、R3、R12、LR、PC 和 xPSR。这个操作叫"入栈"(Stacking),是硬件自动完成的,不需要你写任何保存现场的代码。等异常处理完毕执行返回指令时,处理器又会自动从栈上恢复这八个寄存器("出栈",Unstacking)。

这个设计意味着你的中断服务函数就是一个普通的 C 函数,不需要加 __irq 之类的特殊修饰符(那是 ARM7TDMI 时代的做法了),编译器也不需要生成特殊的前后缀代码。对比一下 ARM7TDMI 那个年代你得自己写保存/恢复寄存器的代码,Cortex-M 的方案简直清爽太多。

但有一个容易踩坑的地方:如果你的栈空间不够(比如分配给某个中断的栈太小),入栈操作会触发另一次异常——而这次异常处理也需要入栈——结果就是栈溢出连锁反应,最终触发 HardFault。所以合理的栈大小规划在 Cortex-M 开发中至关重要,一般建议给主栈预留至少 512 字节,跑 RTOS 的话每个线程栈也需要 256 字节以上。

中断优先级——谁先谁后

ARM Cortex-M 支持可配置的中断优先级。每个中断源都有一个优先级寄存器,数值越小优先级越高。Cortex-M3 支持最多 256 个优先级级别(8 位宽度),但实际实现中大多数芯片只用到高 4 位——也就是说你实际可用的优先级级别可能只有 16 个(STM32F1/F4 就是这种情况)。

优先级分组把 8 位优先级寄存器分成两部分:高位是"抢占优先级"(Preemption Priority),低位是"子优先级"(Sub-priority)。抢占优先级高的中断可以打断正在处理的低优先级中断(嵌套中断),而子优先级只决定同抢占优先级的中断谁先被处理。CMSIS 提供了 NVIC_SetPriorityGrouping()NVIC_SetPriority() 来配置这些。如果你刚入门,用默认的 4 位抢占 + 0 位子优先级分组就行了,等需要精细控制的时候再去折腾。

第六步——把这些知识和写 C 代码关联起来

到这里我们把 ARM 体系结构的核心概念过了一遍。你可能会问:我写 C/C++ 代码又不用汇编,这些知识到底怎么体现在实际编程中?我们来梳理几个直接关联。

调用约定与函数设计

前面提到 AAPCS 规定了前四个参数通过 R0-R3 传递。这对 C 函数设计的直接影响是:如果你能控制函数签名,尽量让参数不超过四个,而且避免传大结构体。一个常见的做法是把频繁调用的热路径函数的参数精简到四个以内,让编译器有最大的优化空间。

volatile 与寄存器访问

嵌入式编程中 volatile 关键字几乎无处不在——每一个硬件寄存器的映射指针都要加 volatile。原因是编译器优化会假设内存值不会"自己变化",但硬件寄存器的值可以被外部事件(DMA 传输完成、外设状态变化)随时修改。volatile 告诉编译器"每次都要真正去读这个地址,不要缓存值"。

c
/// @brief 典型的寄存器映射访问模式
/// volatile 保证每次访问都真正读写硬件
#define GPIOA_ODR_ADDRESS ((volatile uint32_t*)0x40020014U)

void set_gpio_pin(int pin)
{
    // 没有 volatile,编译器可能认为连续写同一个地址是冗余操作并优化掉
    *GPIOA_ODR_ADDRESS |= (1U << pin);
}

栈的使用与内存布局意识

理解了 ARM 的入栈机制和双栈设计后,你在规划内存使用时就有据可循了。bare-metal 应用中,需要确保链接脚本给栈分配了足够的空间;RTOS 应用中,需要为每个线程分配合理的栈大小。经验值是:无浮点运算的简单线程 256 字节起步,有浮点运算或深层函数调用链的线程 512-1024 字节。如果启用了 Cortex-M4 的 FPU,异常入栈还会额外保存 16 个浮点寄存器(S0-S15)加上 FPSCR——额外 68 字节开销不能忽略。

C++ 衔接

如果你是从本教程的 C++ 部分过来的,这些底层知识和 C++ 的关系其实比想象中大得多。ARM 的硬件特性直接影响了 C++ 的很多设计决策。

缓存友好设计与数据局部性

ARM 处理器(尤其是 Cortex-A 系列)有多级缓存。理解缓存行(通常 32 或 64 字节)的大小和工作方式,直接影响 C++ 数据结构设计。把热路径上频繁访问的字段紧凑排列在结构体开头,冷数据放后面,或者用 alignas 控制对齐,都能显著提升性能——这在 C 教程阶段只需要建立意识,后续 C++ 章节会深入展开。

cpp
// 不太友好的布局:热数据和冷数据交替排列
struct BadSensorData {
    uint32_t timestamp;   // 热
    char name[32];        // 冷——挤占了缓存行
    float value;          // 热
    int calibration_id;   // 冷
    float raw_value;      // 热
};

// 友好的布局:热数据集中在前 16 字节,一个缓存行搞定
struct GoodSensorData {
    uint32_t timestamp;   // 热
    float value;          // 热
    float raw_value;      // 热
    // --- 缓存行边界大概在这里 ---
    char name[32];        // 冷
    int calibration_id;   // 冷
};

C++ 对象的内存布局与 ABI

ARM 平台上的 C++ 对象内存布局遵循 AAPCS 的 ABI 规范:普通成员变量按声明顺序排列,虚函数表指针(vptr)放在对象开头,多重继承时可能有多个 vptr。这些布局细节在序列化、网络传输、和 C 代码交互时至关重要。如果你在 Cortex-M 上用 C++ 写面向对象的驱动框架,理解 vptr 的位置和大小能帮你精确计算一个驱动对象到底占了多少字节。

异常处理的开销

在嵌入式 ARM 平台上,C++ 异常处理机制(try/catch/throw)的运行时开销需要认真考虑。异常处理表和展开信息会显著增加二进制体积,异常抛出时的栈展开过程涉及大量内存操作。在 Flash 和 RAM 都紧张的 Cortex-M 上,很多团队选择编译时加 -fno-exceptions 完全禁用 C++ 异常,改用返回错误码的方式处理错误。这不是"不够 C++",而是对资源的合理权衡。

constexpr 与编译期计算

很多在运行时需要查表的操作(CRC 计算、位操作掩码生成),如果能在编译期通过 constexpr 函数完成,就既节省了 Flash 又节省了运行时间。在 Cortex-M0/M0+ 这种连硬件除法器都没有的低端芯片上,编译期计算的价值尤其突出。

练习题

下面几道练习题留给你们自己折腾——动手查资料、写代码、上板验证,才是真正的学习路径。

c
/// @brief 练习 1:读取 IPSR 寄存器
/// 使用 GCC 内嵌汇编读取 Cortex-M 的 IPSR 寄存器值
/// 解释在正常运行和进入中断服务函数时读到的值有什么不同
/// 提示:IPSR 是 xPSR 的一部分,可以用 MRS 指令读取
uint32_t exercise_read_ipsr(void)
{
    // TODO: 用内嵌汇编读取 IPSR
    return 0;
}
c
/// @brief 练习 2:触发并调试 HardFault
/// 对一个无效地址执行写操作,故意触发 HardFault
/// 然后在 HardFault Handler 中读取入栈的寄存器值
/// 定位导致异常的指令地址
/// 提示:HardFault Handler 的参数可以拿到栈帧指针
void exercise_trigger_hardfault(void)
{
    // TODO: 写一个无效地址来触发 HardFault
}
c
/// @brief 练习 3:分析 AAPCS 的参数传递
/// 写两个函数:一个接受 4 个 int 参数,另一个接受 6 个
/// 用 arm-none-eabi-objdump -d 反汇编对比调用序列
/// 找出编译器如何分配 R4-R11 给局部变量
int exercise_aapcs_4(int a, int b, int c, int d)
{
    // TODO: 添加局部变量和函数调用,使反汇编更有看头
    return 0;
}

int exercise_aapcs_6(int a, int b, int c, int d, int e, int f)
{
    // TODO: 同上,对比反汇编结果
    return 0;
}
c
/// @brief 练习 4(进阶):向量表重定位
/// 阅读一个 Cortex-M 启动文件(如 startup_stm32f407xx.s)
/// 画出完整的向量表布局
/// 然后修改链接脚本把向量表重定位到 RAM 中
/// 实现运行时动态修改中断向量(Bootloader 开发的基础技能)

参考资源

基于 VitePress 构建