最近两年,很多从 STM32F103/F407 入门的老开发者,第一次认真研究 STM32H747 时,心态都是“这东西不就是主频高一点的 MCU 吗”。等真正拿到板子打开 CubeMX,看到界面里同时出现 Cortex-M7 和 Cortex-M4 两颗核,才发现事情没那么简单:代码该放哪颗核、flash 地址怎么分、M4 为什么不动、共享变量为什么读了半天是旧值,个个都是以前单核项目没遇到过的坑。
这篇文章不打算复述芯片手册。我只会讲实际做双核项目时绕不过去的几个技术点:双核架构与电源域、启动顺序、存储/Cache 一致性、核间通信、双核调试。读完你会知道一个 H747 硬核项目在动手写业务代码之前,应该先想清楚哪些事情。
1. STM32H747 到底强在哪:不是单纯“主频高”
很多人会把 STM32H747 理解成一颗“Cortex-M7 主频更高的 H743”,这是最大的误解。H747 的真正卖点是异构双核:一颗 Cortex-M7 负责高性能计算,一颗 Cortex-M4 负责实时控制或低功耗采集,两颗核封装在同一颗芯片里,共享 Flash、共享 SRAM、共享大部分外设,但又分别拥有独立的 NVIC 中断控制器和独立的调试接口。
从项目角度看,这颗芯片解决了单核 MCU 在高复杂度系统里的几个实际痛点:
- 单核既要跑 1 kHz 实时控制环,又要处理通信协议栈、显示刷新、日志存储,中断抖动和任务切换开销很难控制;
- 用外置 MPU 加 RTOS 虽然能扛住负载,但成本和硬件复杂度明显上升;
- 需要“实时控制”和“复杂应用”分离时,单核只能靠任务优先级硬切,出了偶发问题极难复现。
双核方案把这两类负载物理隔开,实时性、稳定性、可维护性都更容易保证。
这颗芯片的典型硬件配置如下,具体数值以你手上的数据手册为准:
| 项目 | 典型参数 | 说明 |
|---|---|---|
| Cortex-M7 | 最高约 480 MHz | 带 I-Cache / D-Cache,适合高性能算法与主控逻辑 |
| Cortex-M4 | 最高约 240 MHz | 负责实时采集、控制、通信等确定性任务 |
| Flash | 通常 2 MB | 双核工程需要按地址拆分 |
| SRAM | 约 1 MB 级 | 分布在多个总线域,不是一整块连续内存 |
| 电源域 | D1 / D2 / D3 | 不同域的外设与内存归属不同 |
读到这里你应当有一个基本判断:H747 的难点不在某一颗核的性能,而在两颗核如何正确共享一颗芯片的资源。这也是标题里“硬核实战”四个字的含义。
2. 从单核思维切换到异构双核思维
嵌入式开发里经常听到 AMP、SMP 这两个词。STM32H747 属于典型的 AMP,也就是非对称多处理,两颗核跑的系统可以完全不同:M7 上跑 RTOS,M4 上跑裸机或者另一个 RTOS,各自独立调度。而 SMP 是两个核共享同一个操作系统,由内核统一调度,这在 MCU 领域不是主流。
用 AMP 思维去设计 H747 项目,第一件事就是“分活”。
2.1 什么任务适合放 M7
Cortex-M7 的优势是高频加缓存,适合算力需求明确的场景:
- 音频编解码、FFT、FIR 滤波等 DSP 类算法;
- 图形界面渲染、字符串处理、文件系统、网络协议栈;
- 需要大缓存配合的批量数据搬运;
- 机器学习推理、传感器融合等需要“跑得动”的算法。
放 M7 不是因为 M7 一定比 M4 强多少,而是这些任务通常会访问大块内存,需要缓存来提升效率,同时不介意偶尔被 OS 调度打断。
2.2 什么任务适合放 M4
Cortex-M4 的主频虽然低于 M7,但在“确定性”这件事上反而更友好:
- 1 kHz 甚至 10 kHz 的电流环、速度环控制;
- 电机换相、PWM 脉冲产生;
- 低速但必须稳定的传感器采集;
- 独立的看门狗、硬件安全逻辑;
- 低功耗模式下仍需运行的后台任务。
这类任务的特点是周期固定、不允许被高优先级任务长时间抢占、代码逻辑相对简单,不需要动不动访问几 MB 数据。
2.3 一个典型任务分配实例
假设你做一个双轴伺服驱动器加本地 HMI 的项目,可以这样分配:
| 功能模块 | 建议放置核 | 原因 |
|---|---|---|
| 电流环 / 速度环 | M4 | 周期固定,要求低抖动 |
| 位置规划与插补 | M4 | 与伺服周期强相关 |
| EtherCAT / Modbus 协议栈 | M7 | 协议解析需要缓存和较多内存 |
| HMI 显示与触摸 | M7 | 图形刷新任务重,被抢占影响小 |
| 本地数据记录 | M7 | 文件系统 + Flash 写入复杂 |
| 故障保护逻辑 | 双核同时保留 | 安全相关需要冗余 |
分完任务之后再谈内存和外设分配,才有依据。
3. 双核项目的启动顺序:CM7 先走,CM4 等“释放”
用 H747 做双核项目,新手最容易遇到的第一个现象是:M4 工程的代码明明烧进去了,上电后却不执行。
这不是代码写错了,而是 H747 的默认启动行为决定的。芯片复位后,Cortex-M7 作为主导核先从 Flash 起始地址启动,Cortex-M4 默认处于保持状态,需要 M7 侧程序主动把 M4“放出来”,M4 才会开始取指执行。
整体启动流程大致如下:
- 芯片上电复位,M7 从 0x08000000 取出向量表,设置栈指针和复位向量;
- M7 执行系统时钟初始化、MPU 配置、Cache 使能;
- M7 使能 M4 所在 D2 域必要的时钟和 SRAM;
- M7 设置 M4 的启动地址相关的 SYSCFG 控制位;
- M7 释放 M4 的保持位,M4 从自己的启动地址开始运行;
- M4 侧代码执行 SystemInit、时钟配置、应用初始化,完成双核握手。
从工程角度更要留意“两个核的代码放在同一颗 Flash”这个事实。最常见的分区方式是前半段 1 MB 放 M7,后半段 1 MB 放 M4。也就是说,M7 代码链接到 0x08000000,M4 代码链接到 0x08100000,M4 的向量表偏移也需要设置到对应地址。
在 STM32CubeMX/STM32CubeIDE 里操作双核工程时,通常要为两个核分别生成工程。M7 工程链接地址使用 0x08000000 起始,M4 工程使用 0x08100000 起始。M4 工程里还需要在系统初始化代码中设置SCB->VTOR,让 M4 知道自己的中断向量表在哪里。
M7 侧释放 M4 的示意代码如下。不同 HAL 版本的寄存器封装名称可能不同,真正重要的是理解“先配置再释放”的顺序。
// CM7 工程中释放 Cortex-M4 void CM4_Release(void) { // 1. 使能 M4 内核区域使用的 SRAM 时钟 // STM32CubeMX 会在 SystemClock_Config 中自动处理大部分时钟 // 2. 等待 D2 域 SRAM 可访问 while (READ_BIT(RCC->CFGR, RCC_CFGR_xxx) == 0) { } // 3. 配置 M4 启动地址相关控制位(名称以实际芯片头文件为准) // 4. 清除 CM4 保持位,让 M4 从复位状态释放 // 典型操作是修改 SYSCFG->CBR 中对应位 MODIFY_REG(SYSCFG->CBR, SYSCFG_CBR_M4_HOLD, 0); // 5. 等待 M4 侧完成初始化后置位握手标志 // 例如等待共享内存里 magic 字段变成预期值 }这段代码最核心的动作是第 4 步“清除保持位”。如果这里没有执行,M4 永远停在复位状态,你在调试器里附加 M4 时会发现 PC 不变化。
还要提醒一点:如果 M4 工程是在 M7 已经运行之后才通过烧录器单独下载,往往需要先让 M7 跑起来并释放 M4,调试过程才完整。想要纯靠调试器从零开始同时调试两颗核,需要专门的 dual-core 调试配置,这会在后面单独讲。
4. 内存划分、Cache 与 DMA 一致性:最容易翻车的区域
STM32H747 的内存并不是一整块大 RAM。它的内存分布在多个总线域上,分别服务不同核心和不同外设。以常见型号为例,主要地址段大致如下:
| 地址范围 | 内存 | 大小典型值 | 主要归属 |
|---|---|---|---|
| 0x00000000 | ITCM | 约 64 KB | M7 指令紧耦合内存 |
| 0x20000000 | DTCM | 约 128 KB | M7 数据紧耦合内存 |
| 0x24000000 | AXI SRAM | 约 512 KB | D1 域,主频高,容量大 |
| 0x30000000 | SRAM1 | 约 128 KB | D2 域,M4 可直接访问 |
| 0x30020000 | SRAM2 | 约 128 KB | D2 域 |
| 0x30040000 | SRAM3 | 约 32 KB | D2 域 |
| 0x38800000 | SRAM4 | 约 64 KB | D3 域,低功耗场景常用 |
| 0x08000000 | Flash | 约 2 MB | 双核代码分区存放 |
这里要特别解释两个概念:ITCM/DTCM 是 M7 私有的紧耦合内存,访问延迟最低,但不是“共享内存”的合理选择。M4 与 M7 做核间通信时,通常优先选择 D2 域 SRAM1/SRAM2,或者选择 D3 域 SRAM4,因为这些内存从总线结构上能被双方都访问。
但内存能访问,不代表数据就是一致的。这就是 H7 项目中最经典的 Cache 一致性问题。
4.1 什么是 Cache 一致性问题
Cortex-M7 带有 D-Cache。当 M7 读写普通 SRAM 时,数据可能先停留在 Cache 里,而不是立刻写回物理内存。这带来一个后果:
- M7 往共享缓冲区写完数据,没有执行 Cache clean 操作;
- M4 去读内存,读到的可能是旧值,因为 M7 的新数据还“在路上”或者还在 Cache 里;
- M4 往共享缓冲区写数据,如果 M7 的 Cache 里缓存了这个地址的旧数据,M7 去读时可能直接命中 Cache,永远看不到 M4 写入的新值。
这种 Bug 是最难查的一类:单步调试时数据是对的,全速运行时数据是错的;用调试器读内存是新的,程序读到的却是旧的。
解决思路通常有两种。第一种是把共享内存区域配置成 non-cacheable,也就是不走 Cache,直接用 MPU 把这个区域标记为不可缓存。第二种是保留 Cache,但在每次访问共享内存前后手动执行 clean/invalidate 操作。实际工程里,核间高频通信的数据区强烈建议直接做成非缓存区域,省心且可控。
以下是一个典型的 MPU 配置代码,把 D2 域某段共享 SRAM 配置为不可缓存、不可缓冲、可共享:
// 文件路径:CM7_Project/Core/Src/main.c 或 mpu.c #include "main.h" #define SHARED_MEM_BASE 0x30000000UL // D2 SRAM1 起始地址 #define SHARED_MEM_SIZE MPU_REGION_SIZE_128KB static void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct = {0}; HAL_MPU_Disable(); MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress = SHARED_MEM_BASE; MPU_InitStruct.Size = SHARED_MEM_SIZE; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE; MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsShareable = MPU_ACCESS_SHAREABLE; MPU_InitStruct.Number = MPU_REGION_NUMBER0; MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0; MPU_InitStruct.SubRegionDisable = 0x00; MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_ENABLE; HAL_MPU_ConfigRegion(&MPU_InitStruct); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); }如果在部分场景下无法关闭 Cache,那么至少要在共享数据交换点做显式 Cache 维护:
// 写共享缓冲区之后,把脏数据刷回物理内存 SCB_CleanDCache_by_Addr((uint32_t *)shared_buf, sizeof(shared_buf)); // 读共享缓冲区之前,让 Cache 里的旧数据失效 SCB_InvalidateDCache_by_Addr((uint32_t *)shared_buf, sizeof(shared_buf));需要牢记:clean 是把 Cache 写回内存,invalidate 是丢弃 Cache 重新从内存加载。写数据后使用 clean,读数据前使用 invalidate。顺序反了,数据就会出错。
5. 核间通信:共享内存 + 硬件信号量的正确姿势
双核系统最难设计的不是单核内部的任务通信,而是两个核之间的通信。H747 没有复杂的操作系统级 IPC,工程上最稳定、最常用的组合是:
- 共享内存:承载实际数据;
- 硬件信号量 HSEM:保护临界区和多核互斥;
- 软件标志位或中断:通知对方数据已就绪。
5.1 为什么不用全局变量直接通信
单核系统里,全局变量加中断屏蔽就能做线程间通信。双核系统里,两颗核是真正并行执行的,中断屏蔽只能屏蔽本地核,无法阻止另一颗核同时访问同一段内存。如果两个核同时读写同一个结构体,轻则读到半新半旧的数据,重则破坏数据一致性。
因此,双核共享变量必须满足三个条件:内存对齐、访问原子、共享区缓存语义一致。最简单的可靠方式是使用一个无锁环形缓冲区,配合共享区非缓存属性来实现。
下面是一个双核共用的环形缓冲区实现。它的特点是设计为单生产者单消费者模型,M7 只写,M4 只读,这样就不需要复杂的锁,只要保证读写指针的访问顺序正确。
// 文件路径:shared_ring.h(两个工程共用同一份定义) #ifndef SHARED_RING_H #define SHARED_RING_H #include <stdint.h> #define RING_LEN 512 #define RING_MASK (RING_LEN - 1) typedef struct { volatile uint32_t wr; volatile uint32_t rd; uint32_t data[RING_LEN]; } RingBuf_t; void Ring_Write(RingBuf_t *ring, uint32_t value); int Ring_Read(RingBuf_t *ring, uint32_t *value); #endif// 文件路径:shared_ring.c #include "shared_ring.h" void Ring_Write(RingBuf_t *ring, uint32_t value) { uint32_t next = (ring->wr + 1) & RING_MASK; while (next == ring->rd) { // 缓冲区满,等待消费者读走数据 } ring->data[ring->wr] = value; __DSB(); // 确保数据写入完成 ring->wr = next; } int Ring_Read(RingBuf_t *ring, uint32_t *value) { if (ring->wr == ring->rd) { return 0; // 缓冲区空 } *value = ring->data[ring->rd]; __DSB(); // 确保读取完成 ring->rd = (ring->rd + 1) & RING_MASK; return 1; }这段代码的关键在于分配角色:M7 作为生产者只调用Ring_Write,M4 作为消费者只调用Ring_Read。由于读写位置只有一个生产者一个消费者在更新,单生产单消费模型天然无锁安全。
这里还需要强调__DSB()的作用。它是一条数据同步屏障指令,作用是确保前面的内存访问真正完成之后再执行后面的指令。在双核共享内存通信里,这一条指令能防止编译器或 CPU 乱序访问导致“先更新了 wr 指针,再写入 data”这样的错误顺序。
5.2 什么时候用 HSEM
如果两个核需要对同一个资源做“读改写”,光靠环形缓冲区不够,比如共享配置结构体、共享统计计数、双核访问同一个外设寄存器,就需要硬件信号量 HSEM。
STM32CubeH7 固件库提供HAL_HSEM_FastTake和HAL_HSEM_Release,用法如下:
#define SHARED_HSEM_ID 0 void SharedSection_Enter(void) { while (HAL_HSEM_FastTake(HSEM, SHARED_HSEM_ID) != HAL_OK) { // 获取失败则等待 } } void SharedSection_Exit(void) { HAL_HSEM_Release(HSEM, SHARED_HSEM_ID); }HSEM 的优势是信号量状态在硬件里,两颗核都能看到,不会被某颗核的软件异常卡死。如果一颗核在持锁过程中崩溃,另一颗核可能无限等待,所以持锁临界区要尽量短,不要把复杂逻辑和函数调用放在锁内。
5.3 消息通知:轮询还是中断
上面的环形缓冲区例子用的是轮询方式,M4 循环检查wr != rd。轮询实现简单、出错概率低,但有两个缺点:一是 CPU 空转耗电;二是数据从写入到被对方发现存在延迟。
轮询适合数据量小、周期短、对功耗不敏感的场景。如果希望 M4 在数据到达时立刻被唤醒,可以用中断通知。具体做法有两种:M7 在写完wr指针后,通过 GPIO 或内部事件触发 M4 的一个 EXTI 中断;或者利用共享标志位加 M4 侧定时器定期检查,相当于低频轮询加中断补偿。
工程上我建议第一版双核通信先用轮询跑通,验证共享内存地址、缓存属性、数据格式都没问题之后,再换中断通知优化实时性。不要一上来就两个核互相发中断,否则出现问题时分不清是方向错误、数据错误还是时序错误。
6. 双核外设分配:不是所有外设都能随意共用
H747 的外设资源非常丰富,但这不代表可以把同一个外设同时给两个核用。设计双核项目时,外设要按归属划分清楚。
一个实用的划分原则是:每个外设只归属于一个核,由该核做初始化、中断处理和关闭操作。如果对方核需要使用该外设的数据,通过前面定义的核间通信通道转发,而不是直接操作外设寄存器。
为什么要这样做?举例说明:假设 USART1 的接收中断被配置给了 M7,但 M4 侧的代码误操作了 USART1 的中断使能位,两颗核的中断控制器同时响应同一外设中断,轻则造成重复处理,重则导致两个核互相踩寄存器配置,最终通信错乱。
从实际项目角度,建议按功能域划分外设:
| 外设类别 | 建议归属 | 典型功能 |
|---|---|---|
| 高级定时器 | M4 | 电机 PWM、编码器接口 |
| ADC 高速采样 | M4 | 电流/电压采样 |
| 以太网 MAC | M7 | 工业以太网协议 |
| USB |