第三章:ARM Cortex-M体系架构:中断向量表、异常处理、MPU与Cache基础

好,咱们今天聊聊Cortex-M的核心机制。说实话,很多做单片机开发的朋友,写了几年代码,可能都没仔细看过中断向量表长什么样。但你要想往Linux底层走,这块必须吃透。

我刚开始从8051转到ARM时,就被中断向量表的位置搞晕过。8051的中断向量是固定的,但Cortex-M不一样,它允许你重映射。嗯,这里要注意,这个特性在Bootloader设计中特别有用。

3.1 中断向量表:系统的“紧急通讯录”

中断向量表,说白了就是一张表。CPU遇到异常或中断时,就去这张表里查,该跳到哪里去处理。

这张表放在哪里?默认在地址0x00000000。但Cortex-M允许你通过VTOR(向量表偏移寄存器)把它挪到别处。我在做OTA升级时就用过这个特性——把向量表从Flash搬到RAM,运行时动态修改中断入口。

核心要点:

  • 向量表的第一项是栈顶指针,不是代码
  • 第二项是复位向量,系统上电后第一个执行的地址
  • 从第3项开始,依次是NMI、HardFault等各种异常

看个实际的向量表布局(以STM32F4为例):

; 向量表起始地址 0x08000000
0x08000000: 0x20020000    ; 栈顶指针(SRAM末尾)
0x08000004: 0x08000145    ; 复位向量(注意:地址的bit0必须是1,表示Thumb模式)
0x08000008: 0x08000149    ; NMI异常
0x0800000C: 0x0800014D    ; HardFault异常
...
0x08000058: 0x08000201    ; SysTick中断
0x0800005C: 0x08000205    ; 外部中断0

你可能会问:为什么地址要+1?这是Cortex-M的硬件要求——bit0为1表示Thumb状态。我当年第一次写Bootloader时忘了这个,结果系统一上电就进HardFault,查了两天才发现。

3.2 异常处理机制:优先级与抢占

Cortex-M的异常模型比传统单片机复杂得多。它支持最多256个中断优先级,但实际芯片通常只实现了4-16级。

我个人习惯把异常分为三类:

  • 系统异常:复位、NMI、HardFault等,优先级固定
  • 外部中断:由外设触发,优先级可编程
  • 不可屏蔽中断(NMI):最高优先级,用于紧急情况

优先级数值越小,优先级越高。这个跟很多人的直觉相反——我刚开始也搞反过。

避坑指南:

我曾经在一个电机控制项目里,把两个中断设成了相同优先级。结果高频率触发时,系统出现了不可预测的延迟。后来查手册才发现:同优先级中断不互相嵌套,必须等当前中断处理完才能响应下一个。所以,关键中断一定要设不同优先级。

异常处理还有个重要概念——咬尾中断晚到中断。这两个是Cortex-M的硬件优化:

  • 咬尾:两个中断连续发生时,省去出栈再入栈的开销
  • 晚到:高优先级中断在低优先级中断入栈时到达,直接替换处理

这些细节在实时性要求高的场景下特别重要。比如音频处理,中断延迟差几个周期都能听出爆音。

3.3 MPU:内存保护单元

MPU是Cortex-M3/M4/M7的可选组件,但在M7上几乎是标配。它的作用是什么?说白了就是给内存区域贴标签——这块能读不能写,那块只能特权模式访问。

我最早接触MPU是在一个安全关键项目里。客户要求:用户代码不能篡改系统配置区。没有MMU(那是Linux才有的),但MPU足够用了。

MPU的配置涉及几个关键寄存器:

寄存器 作用
MPU_RNR 选择要配置的区域(0-7)
MPU_RBAR 设置区域基地址
MPU_RASR 设置区域属性(大小、权限、缓存策略)
MPU_CTRL 使能/禁用MPU

配置示例(使能MPU,保护Flash区域只读):

// 配置区域0:保护0x08000000开始的512KB Flash
MPU->RNR = 0;                    // 选择区域0
MPU->RBAR = 0x08000000;          // 基地址
MPU->RASR = (0x12 << 1) |        // 大小:512KB
            (0x3 << 16) |        // 权限:特权读写,用户只读
            (1 << 28);           // 使能该区域
MPU->CTRL = MPU_CTRL_ENABLE_Msk; // 使能MPU

重要提醒:

配置MPU时,一定要先禁用MPU,再修改配置,最后重新使能。我曾经在运行中直接改MPU_RASR,结果系统立刻HardFault。因为MPU配置改变时,当前正在执行的指令可能就落在被修改的区域上。

3.4 Cache基础:速度的代价

Cache在Cortex-M7上才真正普及。M7有L1 Cache,分指令Cache(I-Cache)和数据Cache(D-Cache),各16KB或32KB。

为什么要Cache?因为CPU跑得太快,Flash跟不上。M7主频能到400MHz以上,但Flash访问通常需要几个等待周期。没有Cache,CPU就得干等着。

Cache的工作原理其实很简单:

  • CPU要读数据,先去Cache里找
  • 找到了(命中),直接返回,快
  • 没找到(未命中),去Flash或RAM里取,顺便把附近的数据也搬进Cache

但Cache有个大坑——一致性问题。DMA往内存里写数据,CPU从Cache里读,读到的可能是旧数据。我做过一个以太网项目,数据包总是错乱,查了三天才发现是Cache没刷。

Cache维护操作:

  • Clean:把Cache里修改过的数据写回内存
  • Invalidate:把Cache里的数据标记为无效,下次读取必须从内存重新加载
  • Clean & Invalidate:先写回再标记无效

代码示例(在STM32F7上使用DMA前刷Cache):

// 假设DMA要往0x20010000地址写入256字节
// 先刷掉CPU可能缓存的旧数据
SCB_CleanInvalidateDCache_by_Addr((uint32_t*)0x20010000, 256);

// 启动DMA传输
DMA_StartTransfer(...);

// 等待DMA完成
while(!DMA_IsComplete());

// 再次刷Cache,确保CPU读到的是DMA写的新数据
SCB_InvalidateDCache_by_Addr((uint32_t*)0x20010000, 256);

你想想看,如果忘了刷Cache,DMA写完了,CPU读到的还是Cache里的旧数据。这种bug特别隐蔽,因为有时候能跑对,有时候跑不对——取决于数据是否在Cache里。

3.5 实践建议:从单片机到Linux的思维转变

学完这些,你可能觉得:我一个做单片机的,搞这么底层干嘛?

其实不然。我面试过很多自称“嵌入式工程师”的人,问MPU和Cache基本一脸懵。但你要知道,Linux内核的进程隔离、内存管理,底层就是靠MMU(比MPU更强大)和Cache机制实现的。

我的建议是:

  • 先在Cortex-M上把MPU玩熟,理解“特权模式”和“用户模式”的区别
  • 再在M7上折腾Cache,搞懂一致性问题
  • 最后过渡到Linux,你会发现MMU和页表的概念似曾相识

嗯,这一章内容不少,但都是硬核知识。下一章我们开始讲Cortex-A系列,那才是Linux真正跑的地方。到时候你会发现,很多概念在M系列上已经打过底了。