4. ARM Cortex-M4/M7移植详解:中断向量表重定向、PendSV和SysTick异常处理、FPU寄存器上下文保存与恢复、MPU配置要点

好,咱们进入正题。Cortex-M4和M7,这两个核在基站设备里太常见了。M7性能更强,带FPU和Cache,M4则是性价比之王。移植RTOS,说白了就是让操作系统能管好这两个核的“脾气”。今天我把几个关键点掰开揉碎了讲,都是我在项目里踩过坑的地方。

4.1 中断向量表重定向:别让系统跑飞了

中断向量表,就是一张“谁来了该找谁”的地址表。芯片上电默认从Flash启动,向量表在Flash开头。但RTOS运行时,有时候需要把向量表搬到RAM里,为什么?因为动态修改中断入口啊。

我个人习惯,在系统初始化时就把向量表重定向到SRAM。Cortex-M系列有个寄存器叫VTOR(Vector Table Offset Register),写它就完事了。

/* 重定向向量表到SRAM */
#define SRAM_VECTOR_TABLE_BASE  0x20000000

void vector_table_relocate(void)
{
    /* 1. 先把Flash里的向量表复制到SRAM */
    memcpy((void*)SRAM_VECTOR_TABLE_BASE, 
           (void*)SCB->VTOR, 
           sizeof(interrupt_vector_table));
    
    /* 2. 设置VTOR寄存器 */
    SCB->VTOR = SRAM_VECTOR_TABLE_BASE;
    
    /* 3. 数据同步屏障,确保写操作完成 */
    __DSB();
    __ISB();
}
⚠️ 注意: 我曾经在M7上吃过亏——M7有L1 Cache,如果向量表在SRAM,而SRAM被Cache了,中断来了CPU读到的可能是旧数据。解决办法:把向量表所在的SRAM区域配置为“不可缓存”或“强序”属性。后面讲MPU时会细说。

嗯,这里有个小细节:复制向量表时,要连主栈指针(MSP)的初始值一起复制。否则复位后SP指向错误地址,系统直接HardFault。你想想看,这个坑我踩过不止一次。

4.2 PendSV和SysTick异常处理:RTOS的“心脏”

RTOS的上下文切换,靠的就是PendSV和SysTick。SysTick负责产生周期性滴答中断,PendSV则负责“延迟”执行上下文切换。

为什么要延迟?因为如果在SysTick中断里直接切任务,万一此时正在响应其他高优先级中断,就会出问题。PendSV的优先级可以设到最低,等所有中断都处理完了,它才执行。

4.2.1 SysTick配置

void systick_init(uint32_t ticks)
{
    /* 重装载值 = 系统时钟 / 调度频率 - 1 */
    SysTick->LOAD = ticks - 1;
    
    /* 清零当前值 */
    SysTick->VAL = 0;
    
    /* 使能SysTick,使能中断,使用处理器时钟 */
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk |
                     SysTick_CTRL_TICKINT_Msk   |
                     SysTick_CTRL_ENABLE_Msk;
    
    /* 设置PendSV和SysTick优先级 */
    NVIC_SetPriority(PendSV_IRQn, 0xFF);  /* 最低优先级 */
    NVIC_SetPriority(SysTick_IRQn, 0xF0); /* 比PendSV高一点 */
}

SysTick中断服务函数里,我一般只做两件事:滴答计数器加1触发PendSV。千万别在SysTick里干重活,否则会影响系统实时性。

void SysTick_Handler(void)
{
    /* 更新系统滴答 */
    os_tick_increment();
    
    /* 触发PendSV,请求上下文切换 */
    SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}

4.2.2 PendSV处理:上下文切换的核心

PendSV_Handler是RTOS的灵魂。它负责保存当前任务的寄存器,恢复下一个任务的寄存器。说白了,就是“换人干活”。

__asm void PendSV_Handler(void)
{
    /* 保存当前任务上下文 */
    MRS     R0, PSP             ; 获取当前任务栈指针
    STMDB   R0!, {R4-R11}       ; 保存R4~R11到任务栈
    
    /* 保存FPU寄存器(如果有) */
    TST     R14, #0x10          ; 检查EXC_RETURN的bit4
    IT      EQ
    VSTMDBEQ R0!, {S16-S31}     ; 如果使用了FPU,保存S16~S31
    
    /* 更新当前任务控制块的栈指针 */
    LDR     R1, =current_tcb
    STR     R0, [R1]
    
    /* 切换到下一个任务 */
    BL      os_schedule_next
    
    /* 恢复下一个任务上下文 */
    LDR     R1, =current_tcb
    LDR     R0, [R1]            ; 获取新任务的栈指针
    
    /* 恢复FPU寄存器 */
    TST     R14, #0x10
    IT      EQ
    VLDMIAEQ R0!, {S16-S31}
    
    /* 恢复R4~R11 */
    LDMIA   R0!, {R4-R11}
    
    /* 设置PSP并返回 */
    MSR     PSP, R0
    ORR     LR, LR, #0x04       ; 确保返回后使用PSP
    BX      LR
}
💡 技巧: 注意看代码里的 TST R14, #0x10。这是检查异常返回时是否使用了FPU。如果当前任务用了浮点运算,硬件会自动把S0~S15压栈,但S16~S31需要软件保存。这个判断逻辑,我当年调试了整整两天才搞明白。

4.3 FPU寄存器上下文保存与恢复:浮点运算的“隐形杀手”

M4和M7都带单精度FPU,M7还支持双精度。FPU寄存器(S0~S31)在任务切换时必须保存,否则任务A算到一半,切到任务B,B把FPU寄存器改了,再切回A,A的结果就错了。

但有个问题:不是所有任务都用FPU。如果每个任务都保存FPU寄存器,浪费栈空间和切换时间。怎么办?

我的做法是:懒加载(Lazy Stacking)。Cortex-M4/M7支持FPU的自动延迟压栈。通过设置FPCCR寄存器的LSPEN位,硬件只在真正使用FPU指令时才自动保存FPU上下文。

/* 使能FPU懒加载 */
void fpu_lazy_stacking_enable(void)
{
    uint32_t fpccr = FPU->FPCCR;
    
    /* 设置LSPEN=1,使能懒保存 */
    fpccr |= FPU_FPCCR_LSPEN_Msk;
    
    /* 设置ASPEN=1,自动保存 */
    fpccr |= FPU_FPCCR_ASPEN_Msk;
    
    FPU->FPCCR = fpccr;
}

关键点: 懒加载模式下,任务第一次使用浮点指令时,会触发一个UsageFault?不对,实际上是硬件自动插入一个“浮点上下文保存”操作。但如果你在中断里用了浮点,而中断优先级高于某个任务,可能会造成FPU上下文嵌套保存。我曾经在项目中遇到这个问题,最后解决办法是:中断服务函数里禁用浮点运算,或者把中断优先级分组设好。

另外,M7的双精度FPU有32个双精度寄存器(D0~D31),每个占64位。保存时要注意对齐。我建议统一用8字节对齐的栈指针,避免未对齐访问导致异常。

4.4 MPU配置要点:内存保护的“防火墙”

MPU(内存保护单元)在基站里太重要了。任务A不能乱写任务B的内存,内核态和用户态要隔离。M4和M7的MPU基本一样,最多支持8个或16个区域(看具体芯片)。

配置MPU,我总结了四个步骤:

  1. 禁用MPU:配置前先关掉,防止配置过程中产生异常。
  2. 设置区域属性:基地址、大小、权限、缓存策略。
  3. 使能区域:每个区域单独使能。
  4. 使能MPU:最后全局使能,并设置PRIVDEFENA等选项。
void mpu_configure(void)
{
    /* 1. 禁用MPU */
    MPU->CTRL = 0;
    
    /* 2. 配置区域0:Flash(代码区),只读,不可缓存 */
    MPU->RNR  = 0;
    MPU->RBAR = (0x08000000) |                    /* 基地址 */
                 MPU_RBAR_VALID_Msk |              /* 区域有效 */
                 (0 << MPU_RBAR_REGION_Pos);       /* 区域号 */
    MPU->RASR = (MPU_RASR_XN_Msk) |               /* 禁止执行?不,代码区要执行 */
                 (0x1B << MPU_RASR_AP_Pos) |       /* 特权读写,用户只读 */
                 (0x04 << MPU_RASR_TEX_Pos) |      /* 不可缓存 */
                 (0x00 << MPU_RASR_C_Pos) |
                 (0x00 << MPU_RASR_B_Pos) |
                 (0x19 << MPU_RASR_SIZE_Pos) |     /* 512KB */
                 MPU_RASR_ENABLE_Msk;
    
    /* 3. 配置区域1:SRAM(数据区),可读写,可缓存 */
    MPU->RNR  = 1;
    MPU->RBAR = (0x20000000) |
                 MPU_RBAR_VALID_Msk |
                 (1 << MPU_RBAR_REGION_Pos);
    MPU->RASR = (0x03 << MPU_RASR_AP_Pos) |       /* 特权/用户均可读写 */
                 (0x01 << MPU_RASR_TEX_Pos) |      /* 写通(Write-Through) */
                 (0x01 << MPU_RASR_C_Pos) |
                 (0x00 << MPU_RASR_B_Pos) |
                 (0x13 << MPU_RASR_SIZE_Pos) |     /* 128KB */
                 MPU_RASR_ENABLE_Msk;
    
    /* 4. 使能MPU,启用特权默认访问 */
    MPU->CTRL = MPU_CTRL_PRIVDEFENA_Msk | 
                 MPU_CTRL_ENABLE_Msk;
    
    /* 数据同步 */
    __DSB();
    __ISB();
}
⚠️ 避坑指南: 我曾经在M7上配置MPU时,忘了给向量表所在的SRAM区域设置“不可缓存”属性。结果中断来了,CPU从Cache里读向量表,而Cache里的数据是旧的(因为DMA更新了SRAM但没更新Cache)。系统随机性死机,查了三天才找到原因。所以,中断向量表、DMA缓冲区、外设寄存器映射区,一定要配成“强序”或“不可缓存”

另外,M7的L1 Cache(I-Cache和D-Cache)和MPU配合使用时,要注意Cache策略。比如,D-Cache用“写回(Write-Back)”模式性能好,但需要定期Clean和Invalidate。而MPU的TEX、C、B位就是用来控制这些的。我一般把代码区配成“不可缓存”或“只缓存指令”,数据区配成“写通”或“写回”看具体需求。

4.5 总结一下

好,今天的内容就这些。总结几个要点:

  • 向量表重定向:别忘了Cache一致性问题。
  • PendSV和SysTick:SysTick只做轻量工作,PendSV负责切换。
  • FPU上下文:懒加载是好东西,但要注意中断嵌套。
  • MPU配置:向量表和DMA区域要配成不可缓存。

这些经验,都是我在基站项目里一点一点磨出来的。你想想看,一个基站跑着几十个任务,如果上下文切换出问题,整个基站就挂了。所以,移植RTOS时,这些细节一定要抠死。

下一章,咱们聊聊RISC-V架构的移植。RISC-V和ARM差别挺大,尤其是中断和特权级,到时候再细说。