第2章:ARM Cortex-M架构基础

大家好,欢迎来到第二章。这一章我们聊聊Cortex-M的架构基础。说实话,这部分内容看起来有点枯燥,但它是后面所有开发工作的根基。我见过不少工程师,上来就写代码,结果遇到中断不响应、内存访问异常,折腾好几天才发现是架构层面的问题。

所以,咱们把基础打牢。我会结合自己踩过的坑来讲,希望能帮你少走弯路。

2.1 寄存器组:你的“手边工具”

Cortex-M的寄存器组,说白了就是CPU内部的一小块高速存储。你写的每条指令,操作的数据基本都要先放到寄存器里。

先看通用寄存器:R0-R12。这13个寄存器是“通用”的,啥都能干。但我个人习惯,R0-R3用来传参数,R4-R11用来保存局部变量。为什么?因为AAPCS(ARM过程调用标准)就是这么规定的。你写C函数时,编译器会自动遵守这个约定。

然后是特殊寄存器:

  • R13(SP):栈指针。分MSP(主栈指针)和PSP(进程栈指针)。嗯,这里要注意,中断里用的是MSP,任务里用的是PSP。搞混了,程序就飞了。
  • R14(LR):链接寄存器。保存函数返回地址。我在调试一个递归函数时,发现LR被意外修改了,结果函数返回到了奇怪的地方。从那以后,我每次写汇编函数都会先压栈LR。
  • R15(PC):程序计数器。指向当前正在执行的指令地址。你没法直接写PC,但可以通过跳转指令间接修改它。

还有几个特殊功能寄存器:

寄存器全称作用
PSR程序状态寄存器包含条件标志(N/Z/C/V)、中断号等
PRIMASK优先级屏蔽寄存器置1后屏蔽所有可屏蔽中断
FAULTMASK错误屏蔽寄存器置1后连HardFault都屏蔽(慎用!)
BASEPRI基础优先级寄存器屏蔽低于某优先级的所有中断
CONTROL控制寄存器选择栈指针、决定特权级
我的小技巧:调试中断优先级问题时,我习惯先检查BASEPRI的值。有一次系统莫名其妙不响应某个中断,查了半天,发现是某段代码把BASEPRI设成了0x80,屏蔽了所有优先级低于0x80的中断。嗯,这种bug很难复现,但一旦遇到,你会感谢这个寄存器。

2.2 操作模式与特权级

Cortex-M有两种操作模式:线程模式处理模式。两种特权级:特权级用户级

你想想看,为什么要有这些区分?说白了,就是为了安全。操作系统内核跑在特权级,应用程序跑在用户级。用户程序不能随便访问内核的数据,也不能随意开关中断。

  • 线程模式 + 特权级:复位后的默认状态。可以访问所有资源。
  • 线程模式 + 用户级:受限模式。不能访问系统控制寄存器,不能执行某些指令(如CPSID)。
  • 处理模式 + 特权级:进入异常/中断时自动切换。处理完返回线程模式。

我曾经在一个项目中,把用户程序误放在了特权级运行。结果用户程序一个非法指令就把系统搞挂了。后来我强制把用户程序切换到用户级,再配合MPU(内存保护单元),系统稳定多了。

避坑指南:在用户级下,你不能直接修改CONTROL寄存器来切换栈指针。必须先触发一个异常,在异常处理里切换。我曾经在这里卡了整整一个下午。

2.3 异常与中断向量表

异常和中断,是Cortex-M的灵魂。没有它们,你的SoC就是个“傻大个”。

先看异常向量表。它放在Flash的最开头,地址0x00000000。第一个字是初始栈指针(MSP),第二个字是复位向量(程序入口)。

为什么这么设计?因为CPU上电后,第一件事就是读这两个值。如果向量表放错了位置,CPU就不知道从哪里开始跑。我记得第一次移植RTOS时,忘了修改向量表偏移寄存器(VTOR),结果中断全跑偏了。嗯,那次调试了整整两天。

常见的异常类型:

异常号名称优先级说明
1Reset-3(最高)系统复位
2NMI-2不可屏蔽中断
3HardFault-1硬错误,所有错误的总入口
4-10MemManage/BusFault/UsageFault等可配置可配置的异常
11SVCall可配置系统服务调用(RTOS常用)
12DebugMonitor可配置调试监控
14PendSV可配置可挂起的系统调用(RTOS任务切换用)
15SysTick可配置系统滴答定时器
16+外部中断可配置外设产生的中断
重点:PendSV和SysTick是RTOS的“左膀右臂”。SysTick提供时间基准,PendSV负责任务切换。我建议你先把这两个异常搞清楚,RTOS移植就成功了一半。

2.4 存储器映射

Cortex-M的存储器映射是固定的。4GB地址空间被划分成几个区域:

  • 0x00000000 - 0x1FFFFFFF:代码区(Flash)。通常放程序代码和向量表。
  • 0x20000000 - 0x3FFFFFFF:SRAM区。放变量、堆栈。
  • 0x40000000 - 0x5FFFFFFF:外设区。放GPIO、UART、TIM等寄存器。
  • 0x60000000 - 0x9FFFFFFF:外部RAM区。扩展内存用。
  • 0xE0000000 - 0xE00FFFFF:系统控制区(SCB、NVIC等)。

为什么地址空间要这么分?因为不同区域的访问特性不同。代码区可以预取指,SRAM区支持位带操作,外设区不支持缓存。你想想看,如果把外设寄存器映射到代码区,CPU去预取指,结果读到的是不断变化的外设状态,那不乱套了?

我个人习惯,在链接脚本里明确指定每个段的加载地址和运行地址。比如:

/* 链接脚本片段 */
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K

SECTIONS
{
    .text : { *(.text*) } > FLASH
    .data : { *(.data*) } > RAM AT > FLASH
    .bss  : { *(.bss*) } > RAM
}
注意:.data段要同时指定加载地址(LMA)和运行地址(VMA)。因为初始值在Flash里,但运行时在RAM里。启动代码必须把.data从Flash拷贝到RAM。我见过有人忘了这一步,结果全局变量全是0,程序跑得莫名其妙。

2.5 汇编基础:LDR/STR/BL

虽然现在大部分代码用C写,但有些场景必须用汇编:启动代码、中断上下文切换、RTOS任务切换。所以,掌握几条核心指令就够了。

LDR:加载数据

LDR可以从内存加载数据到寄存器。有两种用法:

LDR R0, =0x40020000   ; 加载立即数(伪指令,实际会被转换成PC相对加载)
LDR R1, [R0]          ; 从R0指向的地址加载一个字到R1
LDR R2, [R0, #4]      ; 从R0+4地址加载一个字到R2

嗯,这里要注意,LDR伪指令(带等号)和LDR指令(带方括号)是两回事。伪指令是编译器帮你生成一条PC相对加载指令,用来加载任意32位立即数。而指令是真正的内存访问。

STR:存储数据

STR和LDR相反,把寄存器的值存到内存:

STR R1, [R0]          ; 把R1的值存到R0指向的地址
STR R2, [R0, #4]!     ; 先存,再更新R0 = R0 + 4(前索引)
STR R3, [R0], #4      ; 先存,再更新R0 = R0 + 4(后索引)

我曾经在调试一个DMA传输时,发现数据总是错位。查了半天,原来是STR的地址模式用错了。前索引和后索引的区别,在循环里特别容易搞混。

BL:带链接的跳转

BL用于调用函数。它会自动把返回地址保存到LR寄存器:

BL my_function        ; 调用my_function,返回地址存入LR
; 函数返回时执行 BX LR

为什么用BL而不是B?因为B是纯跳转,不保存返回地址。你跳过去就回不来了。除非你手动管理返回地址,但那太麻烦了。

我的习惯:写汇编函数时,开头先压栈LR和可能用到的寄存器。结尾再出栈并返回。比如:
my_function:
    PUSH {LR, R4-R7}    ; 保存现场
    ; ... 函数体 ...
    POP {LR, R4-R7}     ; 恢复现场
    BX LR               ; 返回
这样即使函数内部调用了其他函数,LR也不会被覆盖。

小结

这一章的内容比较多,但都是硬核知识。寄存器组是你操作CPU的“手”,操作模式和特权级是“规矩”,异常向量表是“事件处理机制”,存储器映射是“地址布局”,汇编指令是“沟通语言”。

我个人建议,学完这一章后,打开你的开发板例程,找到启动文件(startup_xxx.s),对照着看一遍。你会发现,原来那些“神秘”的代码,其实就是我们刚讲的内容。下一章,我们开始实战,写一个真正的裸机程序。