3、存储器系统:存储器映射、位带操作、MPU原理与配置

大家好,欢迎来到第三章。这一章我们聊聊Cortex-M的存储器系统。说实话,很多工程师写了几年嵌入式代码,对存储器映射还是一知半解。我刚开始做项目时也踩过不少坑,比如位带操作没用好导致代码可读性极差,或者MPU配置不当让系统莫名其妙跑飞。今天咱们就把这些硬骨头啃下来。

3.1 存储器映射——Cortex-M的“地址地图”

Cortex-M系列内核采用统一的存储器映射。说白了,就是芯片上所有东西——Flash、SRAM、外设寄存器——都映射到同一个4GB地址空间里。你想想看,这就像一张城市地图,每个区域都有固定用途。

Cortex-M3/M4/M7的存储器映射基本一致,分为以下几个大区:

地址范围 区域 用途
0x0000 0000 - 0x1FFF FFFF Code区 程序代码、异常向量表、常量数据
0x2000 0000 - 0x3FFF FFFF SRAM区 堆栈、全局变量、位带别名区
0x4000 0000 - 0x5FFF FFFF 外设区 片上外设寄存器、位带别名区
0x6000 0000 - 0x9FFF FFFF 外部RAM区 扩展存储器(如SDRAM)
0xA000 0000 - 0xDFFF FFFF 外部设备区 外部外设映射
0xE000 0000 - 0xFFFF FFFF 系统区 NVIC、MPU、FPU等内核外设

嗯,这里要注意:系统区(0xE000 0000以上)是ARM Cortex-M内核私有的,不同厂商的芯片这部分完全一样。这也是为什么你的STM32代码和NXP的LPC代码,在NVIC配置上可以共用一套逻辑。

核心要点:复位后,CPU从0x0000 0000地址读取栈顶指针,从0x0000 0004读取复位向量。所以你的中断向量表必须放在Code区起始位置。

3.2 位带操作——用“魔法”操作单个比特

位带操作是Cortex-M3/M4/M7的一个特色功能。我个人习惯叫它“比特的快捷方式”。为什么这么说?

传统方式操作一个GPIO引脚,你得先读寄存器,再修改某一位,最后写回去。这个过程不是原子操作,如果被中断打断,很容易出问题。位带操作直接把一个32位的字映射到位带区的一个比特上。你往这个别名地址写1,对应的比特就置1;写0,就清0。

位带区有两个:

  • SRAM位带区:0x2000 0000 - 0x200F FFFF(1MB),别名区在0x2200 0000 - 0x23FF FFFF
  • 外设位带区:0x4000 0000 - 0x400F FFFF(1MB),别名区在0x4200 0000 - 0x43FF FFFF

地址转换公式其实很简单:

别名地址 = 位带区基地址 + (字节偏移 * 32) + (位号 * 4)

举个例子,你想操作GPIOA的ODR寄存器第5位。假设GPIOA的基地址是0x4002 0000,ODR偏移是0x0C。那么:

// 计算目标字节地址
目标地址 = 0x4002 0000 + 0x0C = 0x4002 000C

// 计算位带别名地址
别名地址 = 0x4200 0000 + (0x0002 000C * 32) + (5 * 4)
        = 0x4200 0000 + 0x0040 0180 + 0x14
        = 0x4240 0194

// 直接操作
*(volatile uint32_t *)0x42400194 = 1;  // 置位
*(volatile uint32_t *)0x42400194 = 0;  // 清零

我的经验:在实际项目中,我一般用宏定义封装位带操作。比如:#define BITBAND(addr, bit) (*(volatile uint32_t *)(0x42000000 + ((uint32_t)(addr) - 0x40000000) * 32 + (bit) * 4))。这样代码看起来清爽多了。

我曾经在一个电机控制项目里用过位带操作。当时需要快速切换PWM的极性,用传统方式每次都要读-改-写,延迟很大。换成位带操作后,一个赋值语句搞定,时序也稳了。不过要注意,位带操作只适用于Cortex-M3/M4/M7,M0/M0+不支持。

3.3 MPU——内存保护单元

MPU,全称Memory Protection Unit。说白了,就是给内存区域上锁。你想想看,如果任务A不小心写到了任务B的堆栈,系统可能随时崩溃。MPU就是干这个的——它定义了哪些区域可读、可写、可执行,以及谁可以访问。

MPU支持最多8个区域(Cortex-M3/M4),每个区域可以独立配置。配置参数包括:

  • 基地址:区域的起始地址
  • 大小:从32字节到4GB,必须是2的幂
  • 权限:特权级/用户级访问控制
  • 属性:可读、可写、可执行、缓冲、缓存等

3.4 MPU配置实战

配置MPU其实不复杂,但细节很多。我建议你按以下步骤来:

  1. 关闭MPU:修改MPU_CTRL寄存器,清0 ENABLE位
  2. 配置区域:设置每个区域的基地址、大小、权限
  3. 使能MPU:设置MPU_CTRL的ENABLE位,可选PRIVDEFENA位
  4. 数据同步:执行DSB和ISB指令,确保配置生效

下面是一个典型的配置代码:

void MPU_Config(void)
{
    // 1. 关闭MPU
    MPU->CTRL = 0;
    
    // 2. 配置区域0:Flash区域(0x08000000,512KB)
    //    特权级可读写,用户级只读,可执行
    MPU->RBAR = 0x08000000 | (0 << 4);  // 区域0
    MPU->RASR = (0x1 << 28) |            // 使能
                (0x12 << 24) |            // 大小:512KB
                (0x1 << 18) |            // 可执行
                (0x3 << 16) |            // 全访问权限
                (0x0 << 8)  |            // 共享属性
                (0x0 << 4);              // 缓存属性
    
    // 3. 配置区域1:SRAM区域(0x20000000,128KB)
    //    特权级和用户级都可读写,不可执行
    MPU->RBAR = 0x20000000 | (1 << 4);  // 区域1
    MPU->RASR = (0x1 << 28) |            // 使能
                (0x10 << 24) |            // 大小:128KB
                (0x0 << 18) |            // 不可执行
                (0x3 << 16) |            // 全访问权限
                (0x0 << 8)  |
                (0x0 << 4);
    
    // 4. 使能MPU,允许特权级访问默认区域
    MPU->CTRL = (1 << 0) | (1 << 2);   // ENABLE + PRIVDEFENA
    
    // 5. 数据同步
    __DSB();
    __ISB();
}

注意:配置MPU时,区域之间不能重叠。如果两个区域有重叠,优先级高的区域(区域号小的)会覆盖优先级低的。我曾经在调试时犯过这个错,两个区域都覆盖了同一段SRAM,结果MPU行为变得不可预测。排查了半天才发现是区域重叠了。

3.5 MPU的典型应用场景

在实际项目中,MPU主要有三个用途:

  • 任务隔离:在RTOS中,每个任务分配独立的堆栈区域,MPU防止任务间越界访问。我做过一个航空电子项目,要求任务之间完全隔离,MPU就是关键。
  • 防止栈溢出:给堆栈区域设置MPU保护,一旦栈溢出触发MemManage异常,系统可以及时处理。我曾经用这个特性抓到一个隐藏了很久的栈溢出bug。
  • 代码保护:将关键代码区域设为只读,防止意外修改。比如Bootloader区域,配置为特权级只读,用户代码无法篡改。

嗯,最后说一句。MPU不是万能的,它只能检测到CPU发起的访问。DMA直接访问内存时,MPU是管不到的。这一点很多新手容易忽略。

好了,这一章的内容就到这里。存储器映射是基础,位带操作是技巧,MPU是进阶。掌握了这些,你对Cortex-M的存储器系统就有了比较完整的认识。下一章我们聊聊中断系统,那又是另一个精彩的话题。