第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 | 控制寄存器 | 选择栈指针、决定特权级 |
2.2 操作模式与特权级
Cortex-M有两种操作模式:线程模式和处理模式。两种特权级:特权级和用户级。
你想想看,为什么要有这些区分?说白了,就是为了安全。操作系统内核跑在特权级,应用程序跑在用户级。用户程序不能随便访问内核的数据,也不能随意开关中断。
- 线程模式 + 特权级:复位后的默认状态。可以访问所有资源。
- 线程模式 + 用户级:受限模式。不能访问系统控制寄存器,不能执行某些指令(如CPSID)。
- 处理模式 + 特权级:进入异常/中断时自动切换。处理完返回线程模式。
我曾经在一个项目中,把用户程序误放在了特权级运行。结果用户程序一个非法指令就把系统搞挂了。后来我强制把用户程序切换到用户级,再配合MPU(内存保护单元),系统稳定多了。
2.3 异常与中断向量表
异常和中断,是Cortex-M的灵魂。没有它们,你的SoC就是个“傻大个”。
先看异常向量表。它放在Flash的最开头,地址0x00000000。第一个字是初始栈指针(MSP),第二个字是复位向量(程序入口)。
为什么这么设计?因为CPU上电后,第一件事就是读这两个值。如果向量表放错了位置,CPU就不知道从哪里开始跑。我记得第一次移植RTOS时,忘了修改向量表偏移寄存器(VTOR),结果中断全跑偏了。嗯,那次调试了整整两天。
常见的异常类型:
| 异常号 | 名称 | 优先级 | 说明 |
|---|---|---|---|
| 1 | Reset | -3(最高) | 系统复位 |
| 2 | NMI | -2 | 不可屏蔽中断 |
| 3 | HardFault | -1 | 硬错误,所有错误的总入口 |
| 4-10 | MemManage/BusFault/UsageFault等 | 可配置 | 可配置的异常 |
| 11 | SVCall | 可配置 | 系统服务调用(RTOS常用) |
| 12 | DebugMonitor | 可配置 | 调试监控 |
| 14 | PendSV | 可配置 | 可挂起的系统调用(RTOS任务切换用) |
| 15 | SysTick | 可配置 | 系统滴答定时器 |
| 16+ | 外部中断 | 可配置 | 外设产生的中断 |
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
}
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是纯跳转,不保存返回地址。你跳过去就回不来了。除非你手动管理返回地址,但那太麻烦了。
my_function:
PUSH {LR, R4-R7} ; 保存现场
; ... 函数体 ...
POP {LR, R4-R7} ; 恢复现场
BX LR ; 返回
这样即使函数内部调用了其他函数,LR也不会被覆盖。
小结
这一章的内容比较多,但都是硬核知识。寄存器组是你操作CPU的“手”,操作模式和特权级是“规矩”,异常向量表是“事件处理机制”,存储器映射是“地址布局”,汇编指令是“沟通语言”。
我个人建议,学完这一章后,打开你的开发板例程,找到启动文件(startup_xxx.s),对照着看一遍。你会发现,原来那些“神秘”的代码,其实就是我们刚讲的内容。下一章,我们开始实战,写一个真正的裸机程序。