4. 存储器系统与MPU:存储器映射、MPU配置与TCM实战

大家好,欢迎来到第四章。这一章我们聊聊Cortex-M的存储器系统。说实话,很多工程师写了几年嵌入式代码,对存储器映射的理解还停留在“反正能跑就行”。但一旦遇到诡异的死机、数据被莫名篡改、或者ADC采集值跳变,你才会发现——嗯,不懂存储器系统,排查问题就像蒙着眼找路。

我个人习惯,拿到一个新平台,第一件事就是看它的存储器映射表。这比看时钟树还重要。为什么?因为你的代码、数据、外设寄存器、堆栈,全都在这个地址空间里布局。搞错了,轻则跑飞,重则烧片。

4.1 Cortex-M存储器映射概览

Cortex-M使用统一的4GB地址空间(0x00000000 ~ 0xFFFFFFFF)。ARM把这片空间划分成了几个固定的区域。不同系列(M0/M3/M4/M7)的细节略有差异,但大框架是一样的。

地址范围 区域名称 典型用途
0x00000000 - 0x1FFFFFFF Code区 程序代码、向量表、可配置为Boot ROM或Flash
0x20000000 - 0x3FFFFFFF SRAM区 片内RAM、位带区(Bit-banding)
0x40000000 - 0x5FFFFFFF Peripheral区 所有外设寄存器(GPIO、ADC、UART等)
0x60000000 - 0x9FFFFFFF External RAM区 外部存储器接口(FSMC/FMC)
0xE0000000 - 0xE00FFFFF System区 系统控制块(SCB)、NVIC、MPU、FPU等

这里有个容易踩的坑:Code区不仅仅放代码。我记得有一次调试一个启动异常的问题,折腾了两天,最后发现是向量表地址配置错了。Cortex-M上电后从0x00000000取栈指针,从0x00000004取复位向量。如果你的代码在0x08000000(内部Flash),必须通过硬件映射或SCB->VTOR重定向向量表。不然,一上电就飞。

警告: 千万不要把外设寄存器地址映射到Code区或SRAM区去操作。Cortex-M对不同区域的访问属性和总线接口是不同的。强行访问轻则触发HardFault,重则导致总线死锁。

4.2 位带操作(Bit-banding)

位带是Cortex-M3/M4/M7的一个特色功能。说白了,就是把一个32位字的某一位,映射到另一个地址空间的一个32位字。你操作那个映射地址,就等于直接操作原地址的某一位。这在GPIO输出、标志位设置时非常有用,而且原子操作,不会被中断打断。

SRAM区的位带区域:0x20000000 ~ 0x200FFFFF(1MB)
Peripheral区的位带区域:0x40000000 ~ 0x400FFFFF(1MB)

计算公式:

// 位带别名地址 = 位带基址 + (字节偏移 * 32) + (位号 * 4)
// 以SRAM为例:
#define BITBAND_SRAM_BASE    0x22000000
#define BITBAND_PERIPH_BASE  0x42000000

// 宏定义:将某个地址的某一位转换为位带别名地址
#define BITBAND_ADDR(reg, bit) \
    ((uint32_t)(((uint32_t)&(reg) - 0x20000000) * 32 + (bit) * 4 + 0x22000000))

我在项目中用过位带操作来优化一个高速PWM的占空比更新。传统读-改-写方式需要关中断,用位带直接写,省了三条指令,延迟降低了将近1微秒。对于高频控制来说,这很关键。

4.3 MPU内存保护单元实战

MPU(Memory Protection Unit)是Cortex-M3及以上内核才有的。M0/M0+没有。它的作用,说白了就是给不同的内存区域设置访问权限——谁可以读、谁可以写、谁可以执行。这在RTOS和多任务系统中尤其重要。

我曾经在一个产品中遇到一个bug:两个任务共享一个缓冲区,任务A写数据时被任务B抢占,B把缓冲区内容改了,A回来继续写,结果数据全乱套。加了MPU保护后,任务B运行时根本写不了那个缓冲区,一写就触发MemManage Fault,问题立刻暴露。

4.3.1 MPU的配置步骤

  1. 禁用MPU:写MPU_CTRL寄存器,清ENABLE位。
  2. 配置区域:设置每个区域的基址、大小、属性(读/写/执行/权限)。
  3. 使能MPU:重新设置MPU_CTRL,使能MPU,并选择是否使能HFNMI和PRIVDEF。
  4. 数据同步屏障:执行DSB和ISB指令,确保配置生效。
// 示例:配置MPU保护SRAM中的两个区域
// 区域0:任务A的私有数据区(0x20001000 ~ 0x20001FFF),只允许特权模式读写
// 区域1:任务B的私有数据区(0x20002000 ~ 0x20002FFF),只允许特权模式读写

void MPU_Config(void)
{
    // 1. 禁用MPU
    MPU->CTRL = 0;
    
    // 2. 配置区域0
    MPU->RNR  = 0;                     // 选择区域0
    MPU->RBAR = 0x20001000;            // 基址
    MPU->RASR = (0x0C << 1) |         // 大小:4KB (2^(0C+1) = 4096)
                 (0x01 << 16) |        // 使能该区域
                 (0x03 << 24) |        // 完全访问(特权+用户读写)
                 (0x00 << 28);         // 禁止执行(XN=0表示可执行)
    
    // 3. 配置区域1
    MPU->RNR  = 1;
    MPU->RBAR = 0x20002000;
    MPU->RASR = (0x0C << 1) |
                 (0x01 << 16) |
                 (0x03 << 24) |
                 (0x00 << 28);
    
    // 4. 使能MPU,同时使能HFNMI和PRIVDEF
    MPU->CTRL = (0x01 << 0) |   // ENABLE
                 (0x01 << 1) |   // HFNMIE:在NMI和HardFault下也启用MPU
                 (0x01 << 2);    // PRIVDEF:特权模式默认访问所有区域
    
    // 5. 同步屏障
    __DSB();
    __ISB();
}
技巧: 调试MPU配置时,我习惯先只配一个区域,测试通过后再加其他区域。MPU的优先级规则是:区域号越大,优先级越高。如果两个区域重叠,高编号区域的配置覆盖低编号区域。

4.3.2 常见MPU配置错误

  • 区域大小不对齐:MPU要求区域基址必须对齐到区域大小。比如4KB区域,基址必须是0x...000。不对齐的话,配置写进去也不生效。
  • 忘记使能PRIVDEF:如果不使能PRIVDEF,特权模式访问未配置的区域也会触发Fault。很多新手在这里卡住。
  • 中断服务函数里访问被保护区域:如果中断优先级高于MPU的HFNMIE配置,中断里访问受保护区域会直接HardFault。

4.4 Cache与TCM紧耦合内存

这部分主要针对Cortex-M7。M7有L1 Cache(指令Cache和数据Cache),还有TCM(Tightly Coupled Memory)。TCM和Cache的区别,我打个比方:Cache是自动管理的临时缓存,CPU觉得你常用就帮你存一份;TCM是你自己指定的专用内存,CPU访问它不需要经过Cache,延迟固定,一个周期就能拿到数据。

4.4.1 TCM的配置

M7的TCM分为ITCM(指令TCM)和DTCM(数据TCM)。ITCM通常用来放中断服务函数或实时性要求高的代码。DTCM用来放堆栈、关键变量。

// TCM基址配置(在系统初始化时设置)
// ITCM:0x00000000 ~ 0x0003FFFF(256KB,可配置)
// DTCM:0x20000000 ~ 0x2003FFFF(256KB,可配置)

// 通过SCB->ITCMCR和SCB->DTCMCR寄存器使能TCM
SCB->ITCMCR = (0x01 << 0);   // EN:使能ITCM
SCB->DTCMCR = (0x01 << 0) |  // EN:使能DTCM
               (0x03 << 2);    // RMW:读-修改-写使能

我记得在一个音频处理项目中,把FFT运算的中间缓冲区放在DTCM里,运算速度提升了将近30%。因为DTCM没有Cache的延迟和刷新开销,数据访问时间完全确定。

4.4.2 Cache的一致性问题

Cache好用,但有个大坑——一致性问题。当DMA或外设直接往内存写数据时,如果CPU的Cache里还存着旧数据,CPU读到的就是脏数据。反过来,CPU改了Cache里的数据但还没写回内存,DMA去拿就是错的。

解决方案:

  • 使用非Cacheable区域:把DMA缓冲区所在的MPU区域配置为Non-Cacheable。
  • 手动维护Cache:调用SCB的Cache维护函数,比如SCB_CleanInvalidateDCache()。
  • 使用TCM:TCM天然没有Cache一致性问题,适合放DMA缓冲区。
核心原则: 凡是CPU和外设(DMA、ADC、以太网MAC等)共享的数据缓冲区,要么放在Non-Cacheable区域,要么放在TCM里。否则,你一定会遇到“数据明明写进去了,读出来却是错的”这种诡异问题。

4.5 本章知识体系图

下面这张图总结了Cortex-M存储器系统的核心结构,以及MPU、Cache、TCM之间的关系。建议你保存下来,调试时对照着看。

Cortex-M存储器系统与MPU结构图 Cortex-M存储器系统与MPU结构图 4GB 统一地址空间 (0x00000000 ~ 0xFFFFFFFF) Code区 0x00000000 - 0x1FFFFFFF 程序代码 / 向量表 / ITCM SRAM区 0x20000000 - 0x3FFFFFFF 片内RAM / 位带区 / DTCM Peripheral区 0x40000000 - 0x5FFFFFFF 外设寄存器 / 位带区 External RAM区 0x60000000 - 0x9FFFFFFF 外部存储器接口 System区 0xE0000000 - 0xE00FFFFF NVIC / MPU / SCB / FPU MPU内存保护单元 区域配置 / 访问权限 特权/用户 / 可执行/不可执行 关键说明 MPU可对Code/SRAM/Peripheral/External RAM区域分别设置访问权限 TCM(ITCM/DTCM)位于Code区和SRAM区,访问延迟固定,无Cache一致性问题

4.6 避坑指南与调试建议

最后,分享几个我这些年积累的实战经验:

  • 调试MPU时,先开一个区域测试:我曾经一次性配了8个区域,结果系统直接死机。后来一个个配,发现是区域大小写错了。
  • ADC采集数据异常,先查Cache:如果ADC用DMA传输数据到内存,而那块内存是Cacheable的,你读到的永远是Cache里的旧值。解决办法:把DMA缓冲区放在Non-Cacheable区域或DTCM里。
  • 向量表一定要放在可执行区域:如果MPU把Code区设为不可执行(XN=1),CPU取指时会触发Fault。别问我怎么知道的——有一次我调试到凌晨三点才发现这个低级错误。
  • 使用位带操作时注意地址对齐:位带别名地址必须是32位对齐的。不对齐的话,写操作可能无效。
调试小工具: 我习惯在工程里加一个MPU状态打印函数,把当前所有区域的配置读出来,通过串口输出。这样配置有没有生效,一眼就能看出来。代码很简单,就是读MPU->RBAR和MPU->RASR寄存器。