ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32H743移植MicroPython:外扩SDRAM与QSPI Flash实战

STM32H743移植MicroPython:外扩SDRAM与QSPI Flash实战

简介:这份源码面向已具备STM32与MicroPython基础的嵌入式开发者,解决STM32H743开发板在移植MicroPython时外扩32M SQPI Flash与32M SDRAM的板级适配问题。资源包共5个文件,约4KB,以C源文件、头文件为主,辅以引脚映射CSV与编译配置mk文件,分别承担板级初始化、HAL配置、引脚定义与构建参数设定,放入micropython/ports/stm32/boards/目录即可参与固件编译。目前已有1366人学习下载,说明该移植方案在同类开发板中具有一定参考价值。读者可从中获得H743平台存储外扩的板级配置思路、SQPI Flash与SDRAM的初始化参考,以及可直接复用的编译工程骨架,便于对照自身硬件快速搭建MicroPython运行环境并排查移植中的配置问题。

1. 一块 H743 开发板跑起 MicroPython,到底值不值得折腾

手里有块 STM32H743 的开发板,480MHz 主频、1MB SRAM,性能放在 MCU 里算相当能打。但裸机写 C 调外设,改一个参数就得重新编译烧录,迭代一次几十秒就没了。有人把 MicroPython 移植到这块板子上,还外扩了 32MB 的 SQPI Flash 和 32MB 的 SDRAM,等于给 Python 解释器配了一块能装文件系统的大硬盘和一块能跑大数组的内存。这份源码解决的就是「让 H743 变成一个能跑 Python 脚本的嵌入式开发平台」这件事。适合谁?适合想用 Python 快速验证外设逻辑、又不想放弃 H7 性能的开发者,也适合拿它做教学板或原型验证的团队。下面从目录结构一路拆到二次开发,把能跑起来和能改得动这两件事讲透。

2. 工程结构与启动链路:从复位向量到 Python 解释器

2.1 目录骨架与各层职责

拿到源码包,先别急着打开 Keil 或 CubeIDE 编译。花十分钟把目录结构摸清楚,后面改配置的时候能省掉大量翻文件的时间。这类 H7 移植工程通常按「芯片启动层 → 板级支持层 → MicroPython 内核层 → 用户脚本层」四层组织,目录大致长这样:

project/ ├── Core/ │ ├── Inc/ # 板级头文件:时钟、引脚、外设句柄声明 │ └── Src/ │ ├── main.c # 入口,初始化时钟和外设后跳进 MicroPython │ ├── stm32h7xx_it.c # 中断向量实现 │ └── system_stm32h7xx.c# 系统时钟配置 ├── Drivers/ │ ├── CMSIS/ # 内核头文件与启动汇编 │ └── STM32H7xx_HAL_Driver/ # HAL 库 ├── micropython/ │ ├── ports/stm32/ # 移植核心:mpconfigboard.h、board 目录 │ ├── py/ # 解释器内核 │ └── extmod/ # 扩展模块(文件系统、网络等) ├── mpy-cross/ # 交叉编译器,生成 .mpy 字节码 └── firmware/ # 编译产物与烧录脚本

Core/Src/main.c是上电后第一个跑起来的用户代码,它负责把 H7 的时钟树拉到 480MHz、初始化 SDRAM 控制器和 QSPI 接口,然后调用 MicroPython 的入口。micropython/ports/stm32/是整个移植的重心,板级差异几乎都收敛在这里的mpconfigboard.h和board/子目录里。mpy-cross是宿主机上的工具,用来把.py预编译成.mpy,减少板子上的解析开销。理解这个分层之后,改引脚、改 Flash 大小、加自定义模块,都能快速定位到该动哪个文件。

2.2 启动流程:SDRAM 和 QSPI 必须在解释器之前就绪

H7 的启动链路比 F4 复杂,因为外扩的 SDRAM 和 QSPI Flash 都需要在 MicroPython 堆初始化之前完成配置。顺序错了,表现就是 HardFault 或者文件系统挂载失败。典型流程是:复位 → SystemInit 设基础时钟 → main 里配 MPU 和 Cache → 初始化 FMC/SDRAM → 初始化 QSPI → 初始化 MicroPython 堆 → 挂载文件系统 → 进 REPL。

关键在 MPU 配置。H7 带 D-Cache,SDRAM 映射的地址区间如果不设成 non-cacheable 或者 write-through,DMA 和外设访问会出现数据不一致,这种问题排查起来非常折磨人。常见做法是把 SDRAM 区域配成 Normal, Non-cacheable,牺牲一点性能换确定性。代码大致如下:

/* MPU 配置:SDRAM 区域设为 non-cacheable,避免 DMA 数据不一致 */ static void MPU_Config(void) { MPU_Region_InitTypeDef init = {0}; HAL_MPU_Disable(); init.Enable = MPU_REGION_ENABLE; init.Number = MPU_REGION_NUMBER0; init.BaseAddress = 0xC0000000; /* SDRAM 起始地址 */ init.Size = MPU_REGION_SIZE_32MB; init.SubRegionDisable = 0x00; init.TypeExtField = MPU_TEX_LEVEL1; init.AccessPermission = MPU_REGION_FULL_ACCESS; init.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE; init.IsShareable = MPU_ACCESS_NOT_SHAREABLE; init.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; /* 关键:不缓存 */ init.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE; HAL_MPU_ConfigRegion(&init); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); }

BaseAddress和Size必须和实际焊接的 SDRAM 容量、FMC 地址映射一致,写错了不会报错,只会在访问越界时随机死机。IsCacheable设成NOT_CACHEABLE是血泪经验,早期设成 cacheable 之后,LCD 刷屏偶尔出现半屏花屏,查了两天才定位到 Cache 和 DMA 打架。QSPI 那边则要注意内存映射模式(memory-mapped mode)的配置,只有进了这个模式,Flash 才能像普通内存一样被 XIP 执行和读取,MicroPython 的文件系统才有地方放。

2.3 板级配置:mpconfigboard.h 里改什么

micropython/ports/stm32/boards/下每个板子一个目录,核心文件是mpconfigboard.h和mpconfigboard.mk。前者定义引脚、外设、堆大小,后者决定编译哪些模块。外扩 32MB SDRAM 之后,最该改的是堆的起始地址和大小:

/* mpconfigboard.h 片段:把 MicroPython 堆放到 SDRAM */ #define MICROPY_HW_BOARD_NAME "H743-SQPI-SDRAM" #define MICROPY_HW_MCU_NAME "STM32H743" /* 堆起点指向 SDRAM,大小留出文件系统缓存余量 */ #define MICROPY_HEAP_START ((void *)0xC0000000) #define MICROPY_HEAP_END ((void *)(0xC0000000 + 24 * 1024 * 1024)) /* QSPI Flash 作为文件系统载体 */ #define MICROPY_HW_SPIFLASH_SIZE_BITS (32 * 1024 * 1024) #define MICROPY_HW_SPIFLASH_CS pin_B6 #define MICROPY_HW_SPIFLASH_SCK pin_B2

MICROPY_HEAP_START指向 SDRAM 基址,MICROPY_HEAP_END留了 8MB 给其他用途,这个比例不是死的,跑大数组就多给堆,跑文件多就多留缓存。MICROPY_HW_SPIFLASH_*这几个宏要和原理图上 QSPI 的片选、时钟引脚对上,接错了文件系统直接挂不上,串口只会打印一行 mount failed。改完这些,mpconfigboard.mk里再把FROZEN_MANIFEST指向自己的模块清单,就能把常用 Python 模块烧进固件,省掉板子上传文件的步骤。

3. 编译与烧录:把固件跑起来的完整操作

3.1 工具链准备与编译命令

这类工程一般支持两种编译方式:一是用 Makefile 走 arm-none-eabi-gcc,二是用 CubeIDE/Keil 打开工程文件。源码包如果带了micropython/ports/stm32/Makefile,优先走命令行,可复现性最好。宿主机上需要装arm-none-eabi-gcc、make、python3,以及dfu-util或st-flash用于烧录。编译前先编mpy-cross,因为固件构建过程会调用它:

# 1. 编译宿主机工具 mpy-cross cd mpy-cross && make -j$(nproc) && cd .. # 2. 进入移植目录,指定板子名编译 cd micropython/ports/stm32 make BOARD=H743_SQPI_SDRAM -j$(nproc) # 3. 产物在 build-H743_SQPI_SDRAM/ 下 ls build-H743_SQPI_SDRAM/firmware.*

BOARD的值必须和boards/下的目录名完全一致,大小写敏感。-j$(nproc)是并行编译,H7 的 HAL 库文件多,单线程编一次要几分钟。如果报arm-none-eabi-gcc: command not found,检查工具链是否加进了 PATH;如果报某个board目录找不到,说明板级目录名拼错了。编译成功后产物通常是.elf、.bin、.hex三件套,.bin用于 DFU 或外部烧录器,.hex给 ST-Link Utility 用。

3.2 烧录方式与首次上电验证

烧录有两条路:ST-Link 走 SWD,或者 USB DFU。开发板如果板载了 ST-Link,直接st-flash write firmware.bin 0x08000000最省事。走 DFU 的话,先把 BOOT0 拉高复位进 DFU 模式,然后:

# DFU 方式烧录,注意地址是内部 Flash 起始 dfu-util -a 0 -s 0x08000000:leave -D build-H743_SQPI_SDRAM/firmware.bin

-s 0x08000000:leave里的:leave表示烧完自动跳转运行,不加的话板子会停在 DFU 模式不动。首次上电后接串口(通常是 USART3 或 USB CDC),波特率 115200,应该能看到 MicroPython 的 REPL 提示符>>>。如果没有任何输出,先量晶振起振没有,再看main.c里串口初始化的引脚和波特率对不对。能进 REPL 之后,敲import os; os.listdir('/')验证 QSPI 文件系统是否挂载成功,敲import gc; gc.mem_free()看堆是不是落在 SDRAM 区间(数值应该是二十多 MB 级别,而不是内部 SRAM 的几百 KB)。

3.3 文件系统与脚本下发

QSPI Flash 挂载成文件系统之后,往板子上传脚本有几种方式。最直接的是 REPL 里用Ctrl-E进粘贴模式,把脚本贴进去执行;长期使用建议用mpremote或ampy通过串口传文件:

# 用 mpremote 拷贝脚本到板子文件系统 mpremote connect /dev/ttyUSB0 fs cp main.py :main.py # 直接运行板子上的脚本 mpremote connect /dev/ttyUSB0 run main.py

fs cp的源路径是宿主机,冒号后面是板子上的路径。传大文件时注意 QSPI 写入速度,32MB 全写满要几十秒,别以为卡死了就拔线。boot.py会在上电时自动执行,适合放初始化代码;main.py在boot.py之后跑,放业务逻辑。这两个文件名是 MicroPython 的约定,改了就不会自动执行,这点和桌面 Python 的习惯不一样,新手容易在这里翻车。

4. 核心模块拆解:SDRAM、QSPI 与解释器的衔接

4.1 SDRAM 初始化:FMC 时序参数怎么算

外扩 SDRAM 能不能稳定跑,全看 FMC 的时序参数。H7 的 FMC 时钟来自 HCLK3,480MHz 主频下通常是 240MHz 或者分频后的值。SDRAM 芯片手册里给的是纳秒级的 tRCD、tRP、tRAS,要换算成 FMC 的时钟周期数。以常见的 166MHz 颗粒为例,tRCD=18ns,在 240MHz 下就是 18ns × 240MHz ≈ 4.3,向上取整填 5。这类换算错一个数,表现是读写偶发错误,跑内存测试能过但跑一会儿就崩。

/* FMC SDRAM 时序配置,数值需按颗粒手册换算 */ hsdram1.Init.ReadBurst = FMC_SDRAM_RBURST_ENABLE; hsdram1.Init.ReadPipeDelay = FMC_SDRAM_RPIPE_DELAY_1; hsdram1.Init.CASLatency = FMC_SDRAM_CAS_LATENCY_3; /* CL=3 */ hsdram1.Init.WriteProtection = FMC_SDRAM_WRITE_PROTECTION_DISABLE; hsdram1.Init.SDBank = FMC_SDRAM_BANK1; hsdram1.Init.ColumnBitsNumber = FMC_SDRAM_COLUMN_BITS_NUM_9; hsdram1.Init.RowBitsNumber = FMC_SDRAM_ROW_BITS_NUM_13; hsdram1.Init.MemoryDataWidth = FMC_SDRAM_MEM_BUS_WIDTH_16; hsdram1.Init.InternalBankNumber = FMC_SDRAM_INTERN_BANKS_NUM_4;

CASLatency要和颗粒支持的 CL 值匹配,设高了能跑但浪费周期,设低了直接读错。ColumnBitsNumber和RowBitsNumber决定容量,9 列 13 行 4 bank 16 位宽,算下来正好 32MB,和标题里的容量对上。初始化完成后建议跑一遍写满再读回的测试,别信「能进 REPL 就没事」,SDRAM 的间歇性错误往往在跑大数组时才暴露。

4.2 QSPI Flash 映射:文件系统如何落到 32MB 空间

QSPI Flash 在 MicroPython 里通常走stm32port 的spiflash驱动,配置成块设备后挂到 littlefs 或 FAT 上。关键是内存映射模式的地址窗口,H7 的 QSPI 映射区一般在0x90000000开始。文件系统读写走的是间接模式(indirect mode),只有 XIP 执行才用映射模式,两者切换时要注意先等总线空闲。

/* QSPI 进入内存映射模式,供 XIP 和直接读取使用 */ QSPI_CommandTypeDef cmd = {0}; cmd.Instruction = 0xEB; /* 快速读四线指令 */ cmd.AddressMode = QSPI_ADDRESS_4_LINES; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.AlternateByteMode = QSPI_ALTERNATE_BYTES_4_LINES; cmd.AlternateBytes = 0x00; cmd.DataMode = QSPI_DATA_4_LINES; cmd.DummyCycles = 6; /* 按颗粒手册填 */ cmd.NbData = 0; HAL_QSPI_Command(&hqspi, &cmd, HAL_QSPI_TIMEOUT_DEFAULT_VALUE); HAL_QSPI_MemoryMapped(&hqspi, &s_mem_mapped_cfg);

DummyCycles是最容易填错的一个参数,不同厂家的颗粒要求不一样,填少了读出来全是 0xFF 或者乱码。AlternateBytes在四线模式下通常配成 0,具体看颗粒是否支持连续读模式。文件系统大小通过MICROPY_HW_SPIFLASH_SIZE_BITS声明,驱动会按这个值划分块设备,改大了超出实际容量会在写满时出错,改小了浪费空间,按实际颗粒容量填最稳。

4.3 解释器堆与 GC:大内存下的行为差异

MicroPython 的 GC 是标记清除式,堆大了之后单次 GC 的扫描时间会变长。内部 SRAM 只有几百 KB 时感觉不到,堆挪到 24MB 的 SDRAM 之后,一次 full GC 可能占用几十毫秒,对实时性有要求的场景要留意。可以在关键循环里调gc.collect()的时机,或者用gc.threshold()控制自动回收的触发点。

import gc # 堆变大后,适当提高阈值减少 GC 频次 gc.threshold(512 * 1024) # 手动回收并观察剩余 gc.collect() print("free:", gc.mem_free(), "alloc:", gc.mem_alloc())

gc.threshold的单位是字节,设太小 GC 频繁拖慢执行,设太大内存涨得快。SDRAM 堆的另一个坑是访问速度比内部 SRAM 慢,中断里如果碰了堆上的对象,延迟会明显上升。常见做法是把中断服务程序里用到的缓冲区放内部 SRAM,通过链接脚本的 section 属性指定,别让高频中断去碰 SDRAM。

5. 避坑与排查:五个真实会卡住你的问题

5.1 上电无输出,串口一片安静

现象是烧录成功、复位后串口没有任何字符。原因通常有三个:时钟没配到 480MHz 导致串口波特率偏差过大、串口引脚被复用成了别的功能、或者启动文件里的堆栈大小不够在进 main 之前就崩了。解决顺序是先量晶振,再用调试器单步看SystemClock_Config返回值,最后检查stm32h7xx_it.c里 HardFault 有没有被触发。我一般会在main第一行点一个 GPIO 翻转,示波器一看就知道代码到底跑没跑起来。

5.2 文件系统挂载失败,报 mount failed

现象是 REPL 能进,但os.listdir('/')报错或者返回空。原因多半是 QSPI 引脚配置和原理图对不上,或者DummyCycles填错导致读出的 ID 不对。先在 REPL 里读 Flash ID 验证通信:import stm32; print(stm32.spiflash_id())之类的接口(具体函数名看驱动实现),ID 不对就回头查引脚和时序。另一个可能是 Flash 从没格式化过,首次挂载需要先os.mkfs或者驱动里带自动格式化逻辑。

5.3 SDRAM 跑一会儿就死机

现象是刚上电正常,跑几分钟或者跑大数组时随机 HardFault。原因基本锁定在 MPU 配置和 Cache 一致性上,DMA 写 SDRAM 而 CPU 读的是 Cache 里的旧数据。解决办法是把 SDRAM 区域设成 non-cacheable,或者在 DMA 操作前后手动SCB_CleanInvalidateDCache()。前者牺牲性能但省心,后者性能好但每个 DMA 点都要记得加,漏一个就是一个玄学 bug。

5.4 编译报错找不到 board 目录

现象是make BOARD=xxx提示No such board。原因是板级目录名和BOARD参数大小写或拼写不一致,或者目录放错了层级。ports/stm32/boards/下每个目录名就是合法的 BOARD 值,进去看一眼mpconfigboard.mk是否存在,缺这个文件也会被跳过。改完目录名记得make clean,增量编译有时会缓存旧的板级配置。

5.5 脚本传上去不自动执行

现象是main.py明明传到了根目录,上电却不跑。原因是文件名不对(比如传成了Main.py或者main.py.txt),或者boot.py里有异常导致后续不执行。MicroPython 只认小写的boot.py和main.py,且必须在文件系统根目录。排查时先在 REPL 里import os; print(os.listdir('/'))确认文件名,再手动exec(open('main.py').read())看有没有语法错误。

6. 二次开发:加自定义模块和裁剪固件的实操思路

改造这块板子的第一步不是写代码,是决定「哪些逻辑放 C 层、哪些放 Python 层」。我的习惯是:高频调用的外设读写、时序敏感的操作放 C 做成machine的扩展模块;业务逻辑、状态机、协议解析放 Python,改起来不用重新烧固件。加自定义模块的入口在micropython/ports/stm32/下新建一个modxxx.c,然后在mpconfigboard.mk的SRC_C里加进去,再用MP_REGISTER_MODULE注册。

/* modboard.c:一个最小的自定义模块,暴露板子温度读取 */ #include "py/runtime.h" #include "py/obj.h" static mp_obj_t board_get_temp(void) { /* 这里换成实际的 ADC 或传感器读取 */ return mp_obj_new_float(36.5f); } static MP_DEFINE_CONST_FUN_OBJ_0(board_get_temp_obj, board_get_temp); static const mp_rom_map_elem_t board_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_get_temp), MP_ROM_PTR(&board_get_temp_obj) }, }; static MP_DEFINE_CONST_DICT(board_module_globals, board_module_globals_table); const mp_obj_module_t board_module = { .base = { &mp_type_module }, .globals = (mp_obj_dict_t *)&board_module_globals, }; MP_REGISTER_MODULE(MP_QSTR_board, board_module);

MP_REGISTER_MODULE的第二个参数是模块名,Python 里import board就能用。MP_ROM_QSTR把字符串常量塞进 ROM,省 RAM。改完重新编译烧录,REPL 里import board; board.get_temp()验证。裁剪固件则是反向操作,mpconfigboard.mk里去掉不用的SRC_C,mpconfigboard.h里关掉不用的外设宏,固件能小几百 KB,启动也快一点。

验证改造是否成功,我一般走三步:先跑一遍原有功能回归(REPL、文件系统、SDRAM 读写),再跑新模块的单元测试脚本,最后做一次断电重启确认boot.py链路没被破坏。这套流程看着笨,但能挡住大部分「改完这里崩那里」的问题。从那以后我每次动板级配置,都强制走一遍「编译 → 烧录 → 串口回归 → 断电重启」四步,不再图省事直接热复位。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进