RP2040存储体系揭秘:ROM/SRAM/Flash分工与Flash擦写疑难解析
2026/9/8 22:24:50 网站建设 项目流程

前阵子帮一个朋友排查 Pico 项目,他碰到一个特别迷惑的现象:程序里明明已经用printf打印了地址,变量看起来也“在内存里”,但一调用某个 Flash 擦除函数,整个系统就死机。排查到最后,问题根本不是代码逻辑,而是他根本不清楚代码哪段跑在 ROM、哪段跑在 SRAM、哪段又从 Flash 里被取指执行。这其实不是个例。很多玩树莓派 Pico 的朋友,烧录固件、点灯、跑 RTOS 都很熟,但一碰到“存储底层”这四个字就发怵:ROM 是游戏机里那个 ROM 吗?SRAM 和电脑内存有什么区别?Flash 不是 U 盘里那个东西吗,怎么还能直接执行代码?这篇文章就把这三样东西在 RP2040 里到底怎么分工、怎么映射、怎么用来龙去脉彻底讲透,并且带一组可以直接上手的验证代码和调试方法。适合刚入门想进阶的 Pico 玩家,也适合被 Flash 擦写崩溃、链接脚本报错折磨过、想真正看懂 Map 文件和启动流程的开发者。

1. 三种存储介质,三种脾气:ROM/SRAM/Flash 在 Pico 里到底谁干什么

1.1 先破除一个误解:ROM、RAM、Flash 不是同一维度的东西

搜一下“ROM”,你会先看到游戏机 ROM、安卓刷机 ROM、街机模拟器 ROM。玩游戏的朋友说的 ROM,指的是一个游戏镜像文件,跟硬件存储介质没有直接关系。而在单片机领域,ROM 是一个非常具体的硬件概念:只读存储器

再看 ARM 内核的手册,碰到的则是另一套词汇:Flash、SRAM、OTP、BootROM。很多教程把这些词混着用,什么“片内 ROM”“Flash 存储”“内存映射”,新手不晕才怪。

我习惯用一个不太严谨但特别贴切的类比,把这几个角色摆清楚:

  • 把 MCU 比作一个公司。
  • SRAM是办公室桌面,工位就那么大,东西放上去能马上读写,但晚上断电保洁一打扫,桌面就空了。
  • Flash是公司的档案仓库,容量大,东西放进去断电十年还在,但存取速度比桌面慢,而且想在档案上改一个字,得先把整页甚至整本销毁重写。
  • ROM(BootROM)是贴在墙上的消防疏散图,出厂时就印死了,撕不掉也改不了,每次出事(上电复位)所有人先看它怎么走。

在 RP2040 里,这三者是同时存在的,而且地址空间上各有各的地盘。你写的 C 代码编译后,主要落在 Flash 和 SRAM 里;而 CPU 复位后执行的第一条指令,却来自那块你永远改不了的 BootROM。

1.2 RP2040 存储资源盘点:16KB BootROM、264KB SRAM、板载 Flash

树莓派 Pico 用的 RP2040 是一颗双核 Cortex-M0+ 芯片,它的存储资源比你想的要“抠门”,但结构上很有代表性:

存储类型容量掉电是否丢失访问特性在 RP2040 中映射基址
BootROM16KB只读,固化出厂0x00000000
SRAM264KB(6 banks)高速随机读写0x20000000
板载 QSPI Flash通常 2MB / 4MB可 XIP 执行,按页写、按扇区擦0x10000000
OTP8KB 左右一次性可编程,用于密钥等通过 BootROM 相关接口访问

注意两个细节。第一,RP2040 的 264KB SRAM 不是一整块,而是分成了 6 个 bank:SRAM0 到 SRAM3 每个 16KB,SRAM4 和 SRAM5 每个 100KB。这个 bank 划分直接影响后续做 DMA、双核通信时的性能,后面专门讲。

第二,Flash 虽然叫“板载”,但 RP2040 芯片本身不含 Flash,Flash 是封装在 Pico 板子上的外部颗粒,通过 QSPI 接口连接。也就是说,你买的第三方 RP2040 板子,板上 Flash 容量可能和官方 Pico 不一样。这一点如果没搞清楚,后面烧写、擦除、做 OTA 都可能踩坑。

1.3 为什么这里必须是 NOR Flash,NAND 为什么不行

你去淘宝搜 Flash 芯片,会看到两种大类型:NAND Flash 和 NOR Flash。U 盘、SSD 用的是 NAND,而 Pico、ESP32、大部分单片机开发板上用的是 NOR。

核心区别在于:

  • NOR Flash 支持随机读取,读起来像内存一样,给定地址就能读字节,所以 CPU 可以直接在 NOR Flash 上取指执行,这就是 XIP(Execute In Place)。
  • NAND Flash 只能按页读,而且读写之前要先把数据搬到内部缓冲区,读个字节需要一整套命令序列,CPU 根本没法直接在上面跑代码。
  • NOR Flash 读快写慢,擦除单位通常是 4KB 扇区;NAND 写快、容量密度高、按页写按块擦,但坏块管理复杂。

Pico 这种 MCU 的应用场景,代码量最多几十 MB,但对“上电就能直接跑”的要求极高,显然 NOR Flash 是唯一合理选择。你看到的“SPI Flash”“QSPI Flash”这些名字,指的就是“挂在 SPI/QSPI 总线上的 NOR Flash”,不是另一种东西。Pico 用的 W25Q 系列、GD25 系列、ZBIT 系列,都属于这一类。

1.4 一个容易被忽略的参数:不同 Pico 板子的 Flash 容量可能不一样

官方树莓派 Pico(第一代)出厂板载 2MB Flash,Pico W 也是 2MB,Pico 2 是 4MB。但是,第三方做的 RP2040 开发板,Flash 从 1MB 到 16MB 都有。如果你习惯性地把 Flash 容量写成 2MB,然后用flash_range_erase擦一个超过实际颗粒大小的地址范围,可能擦到不存在的地方,轻则数据错乱,重则整片失效。

更隐蔽的是 SDK 默认配置。Pico SDK 在链接时会用到PICO_FLASH_SIZE_BYTES这个宏,很多模板默认给 2MB。当你换了个 4MB 的板子,如果链接脚本和分区表还是按 2MB 算,最直观的麻烦就是固件稍微大一点就链接报错。想知道当前板子 Flash 实际多大,最靠谱的办法不是看丝印,而是直接读 Flash 的 JEDEC ID,这个操作在第 4 章会给出完整代码。

提示:拿到一块不认识的 RP2040 板子,第一件事不是跑 Hello World,而是查它的 Flash 颗粒型号和容量。这个习惯能帮你避开后面 80% 的存储相关坑。

2. 上电那一瞬间:BootROM 如何决定“接下来跑谁”

2.1 BootROM 里固化了什么,为什么说它是“出厂说明书”

RP2040 上电或者复位后,Cortex-M0+ 内核会从地址 0x00000000 取出栈顶指针,从 0x00000004 取出复位向量,然后跳转执行。而这两个入口背后,就是那颗 16KB 的 BootROM。

BootROM 是芯片出厂时用掩膜工艺写死的代码,用户无法通过任何软件手段修改。它的职责概括起来有几块:

  1. 上电后初始化基础时钟和系统配置;
  2. 检查 BOOTSEL 引脚状态;
  3. 读取外部 QSPI Flash 的开头内容,判断是否存在有效程序;
  4. 如果 Flash 为空或无有效程序,则进入 USB 或 UART 引导模式;
  5. 提供一组 ROM 函数,比如 Flash 编程接口,供用户代码调用。

很多从 STM32 转过来的朋友会天然觉得“Flash 引导是硬件自动完成的”,实际上在 RP2040 里,从 Flash 启动是一个软件行为:BootROM 代码主动去读外部 Flash,校验通过后再跳过去。这也是为什么你按住 BOOTSEL 上电,Pico 不会跑任何用户程序,而是变成一个 U 盘。

2.2 BOOTSEL 按钮背后的 USB/UF2 启动逻辑

Pico 板子上那颗 BOOTSEL 按钮,直接连着 RP2040 的专用 BOOTSEL 引脚。当你按住它再上电,BootROM 检测到引脚状态,就会跳过 Flash 启动,把自己枚举成一个 USB 大容量存储设备。这时候电脑上会弹出一个名为 “RPI-RP2” 的 U 盘,你把编译好的.uf2文件拖进去,BootROM 负责把文件内容解析后写入 Flash。

UF2 格式本身是微软发起的一种块格式,它把固件按 512 字节拆成一块块,每块带地址、长度、校验。BootROM 收到这些块,校验通过后直接调用 Flash 编程函数写入。这个设计对用户非常友好,不需要额外安装驱动和下载工具,拖文件就完事。

但如果你只停留在“拖文件烧录”这个层面,就不会理解为什么有时候明明拖了文件,程序却没更新。常见原因其实就藏在 UF2 机制里:文件被拖入后,BootROM 是一块一块写的,如果中间断电或者文件校验失败,Flash 可能只写了一半,BootROM 检测到校验不完整,下次上电就会判定“Flash 无有效程序”,再次进入 USB 模式。所以“拖文件没反应”不一定是你程序写错了,很可能是上次写了一半。

2.3 启动映像校验:BootROM 怎么判断 Flash 里的固件是否有效

BootROM 在决定是否跳转 Flash 程序之前,会做一次很简单的“体检”。它不检查整个固件的 CRC,而是读取 Flash 起始位置的向量表:

  • 第一个 word 是初始栈顶指针。BootROM 要求这个值落在合法的 SRAM 地址范围(0x20000000 到 0x20042000)内。
  • 第二个 word 是复位向量。BootROM 要求这个值落在 Flash 的 XIP 地址范围(0x10000000 之后)内。

只要这两条满足,BootROM 就认为 Flash 里“有程序”,然后跳过去执行。如果 Flash 全空、第一个 word 是 0xFFFFFFFF,或者向量表乱写一通,BootROM 就认为“没有可执行程序”,自动进入 USB/UF2 模式。

这个机制看起来简单,但它解释了为什么很多汇编玩家在写裸机 bootloader 时,明明代码逻辑没错,BootROM 就是不肯跳转。十有八九是向量表前两个 word 没有放栈顶和复位向量。SDK 默认的启动文件crt0.S已经帮你把这些做好了,但一旦你自定义链接脚本,就得特别小心。

2.4 自定义二级引导:什么时候需要自己写 bootloader

如果你做产品要 OTA 升级,或者想对固件加密、校验签名,那你大概率不会满足于 BootROM 那套“裸奔”启动逻辑。常见做法是写一个二级引导程序(Second Stage Bootloader)放在 Flash 开头,BootROM 跳转到它;二级引导再负责校验真正的应用固件、解密、搬运,最后跳转应用。

RP2040 的 BootROM 其实还内置了“二级引导”支持:它会从 Flash 读取一段小程序到 SRAM,并执行。这就是为什么有些既有工程boot2文件夹里有一个几百字节的小汇编程序,它负责初始化 QSPI Flash 控制器,然后才让 XIP 模式真正可读。只是 Pico SDK 默认把你自己的代码当成了“Flash 里的主程序”,BootROM 直接跳过去了。真要做自定义引导,建议先把pico_second_stage的机制看明白,再决定是替换第二引导还是加一层跳转。

3. 264KB SRAM 的分配真相:bank 结构、链接脚本与内存冲突

3.1 六个 SRAM bank 的前世今生:为什么分成大小不一的 bank

SRAM 是程序运行的“桌面”,全局变量、局部变量、堆、栈全在里头。RP2040 的 264KB SRAM 分成 6 个 bank,这个划分不是拍脑袋,而是为了总线仲裁的灵活性。

RC2040 内部有一条总线矩阵(Bus Fabric),多个主设备(CPU0、CPU1、DMA)通过它访问多个从设备(SRAM、Flash、外设)。不同主设备如果想在同一时刻访问不同的 bank,可以并行完成;但如果都去抢同一个 bank,就得排队。

具体 bank 划分是:

  • SRAM0 ~ SRAM3:每个 16KB,地址从 0x20000000 到 0x20003FFF 依次递增;
  • SRAM4:100KB,0x20010000 开始;
  • SRAM5:100KB,0x20029000 开始。

算一下:4×16KB + 2×100KB = 264KB,正好对上。

对普通用户来说,bank 划分最直观的影响就是:如果你有两个核要跑不同任务,最好把各自的数据结构放不同 bank;DMA 搬运一个大数组时,起点和终点如果都落在同一个 bank,性能可能会被仲裁拖累。当然,大部分小项目根本感觉不到,但理解这个结构对后面做高性能数据采集、双核通信很有帮助。

3.2 链接脚本怎么把你的变量“扔”进 SRAM

你写一句int counter;,编译器只是生成符号,真正决定它落在哪一段内存的是链接脚本。Pico SDK 默认链接脚本大致把固件分成两个大区:

区域内容加载地址与运行地址
FLASH.text(代码)、.rodata(常量)、启动数据副本XIP 地址 0x10000000
RAM.data(已初始化全局变量)、.bss(未初始化变量)、堆、栈SRAM 地址 0x20000000

CPU 上电时,BootROM 跳入 Flash 后,C 运行时启动代码(crt0)会把.data段从 Flash 复制到 SRAM,把.bss段清零,然后才调用main()。所以你写代码时感觉“全局变量天生就在内存里”,实际上它刚上电那一刻还在 Flash 里躺着,是启动代码搬过来的。

了解了这个过程,你就会明白为什么有时候改了全局变量初始值,烧录后好像没生效。极大概率是你的启动文件或链接脚本没有正确处理.data复制,比如自定义链接脚本漏掉了相关段。

3.3 用 Map 文件看看自己写的小程序实际占了多少地方

链接完成后生成的.map文件,是排查内存问题的第一现场。一个最小 Blink 工程,用arm-none-eabi-nm -n或者直接看.map,你会看到类似这样的信息:

.text 0x100003c1 0x1a08 .rodata 0x10001dc9 0x0f0c .data 0x20000000 0x0010 .bss 0x20000010 0x0230

对比一下你会发现,一个看似简单的点灯程序,代码加常量可能占了十几 KB Flash,而真正的全局变量可能只有几百字节。如果有一天链接报错 “region FLASH overflowed”,不要先去想是不是全局变量太多,先算算是不是printf一类的库函数把.text撑爆了。

Map 文件里还藏着启动时要用的几个关键符号,比如__StackTop__StackLimit。RP2040 SDK 默认给每个核的栈空间各 2KB 左右,如果你在中断里用了很大的局部数组,栈溢出不会当场报错,而是会悄悄踩掉 adjacent 的变量,表现成莫名其妙的逻辑错误。这种问题用调试器单步很难看出来,但 Map 文件能帮你确认符号布局。

3.4 多核和 DMA 场景下的 SRAM 分配避坑经验

RP2040 是双核 Cortex-M0+,两个核可以并行跑。默认情况下,SDK 的multicore_launch_core1会在 SRAM 里分配一块区域给第二个核的栈和入口地址。如果你在 core0 里定义了一个大数组,又在 core1 里频繁访问,两个核如果落在同一个 SRAM bank,总线仲裁会拖慢速度。

我自己实际遇过一个案例:一边 DMA 从 ADC 连续搬运数据到一个大 buffer,一边在另一个核里做 FFT,偶尔会出现采样数据跳变。后来把 FFT 的中间缓冲区挪到了另一个 SRAM bank,现象就消失了。原因就是两个核在抢同一个 bank 的带宽。

在代码里想手动指定变量放哪个 bank,可以用链接脚本的 section 机制,或者在变量上标注__attribute__((section(".sram4")))然后自定义段地址。SDK 也提供了__not_in_flash_func这类宏,把函数放进 SRAM 以避免 Flash 访问冲突,下一章重点讲。

4. XIP 与 Flash 管理实战:缓存、JEDEC ID、擦写寿命

4.1 XIP 缓存机制:为什么程序能直接跑在 Flash 地址上

Flash 是一种外部存储,但 CPU 却能从 0x10000000 这个内存地址直接取指执行,靠的是 XIP 和 QSPI 控制器。CPU 访问 0x10000000 附近的地址时,地址会先经过 RP2040 的 XIP 控制器,控制器把它翻译成对 QSPI NOR Flash 的读命令,再通过 QSPI 总线把数据读回来。

这个过程听起来慢,但实际没那么糟,因为 RP2040 里有一个 8KB 的 XIP cache。程序是顺序执行的,CPU 访问 Flash 取指时,cache 会预取一批数据。如果代码在同一个 cache line 内重复执行,比如循环体,命中率很高,性能损耗可以接受。

但 cache 不是万能的。如果你的程序随机跳转、查大表、或者 Flash 读取和擦写交替进行,cache 命中率会下降,执行速度会肉眼可见地变慢。这也是为什么有些对时序敏感的外设驱动(比如 WS2812 灯带、DS18B20 时序读取)推荐把相关代码放到 SRAM 里跑。

4.2 读 Flash ID:一条 0x9F 命令识别颗粒型号和容量

识别 Pico 板载 Flash 最直接的方法是读 JEDEC ID。NOR Flash 芯片几乎都支持命令0x9F:主机给 Flash 发 0x9F,然后连续读三个字节,得到厂商 ID、类型 ID、容量 ID。

在 Pico SDK 里,这个操作已经被封装成flash_get_jedec_id(),但我们仍然要理解它的底层流程:它会在 Flash 的 XIP 模式下,让 QSPI 控制器临时切回普通 SPI 命令模式,发送 0x9F,读取三个字节,然后再切回 XIP。因为 Flash 只有一个物理接口,这段操作期间 CPU 不能从 Flash 取指,所以这个函数本身必须放在 SRAM 中执行,SDK 内部已经处理好了。

返回值是个 32 位整数,低 24 位就是三个 ID 字节。比如 W25Q16 系列,典型返回0xEF40150xEF是 Winbond 厂商 ID,0x40是 SPI 类型,0x15是容量码。容量码计算方式为1 << 0x15,也就是1 << 21,即 2MB。

这个操作在产品验证中特别有用。我们做批量生产时,每块板子的 Flash 型号都可能因为供应链替换而不同,产测固件第一条就是读 JEDEC ID,然后把型号和容量打印出来存档,防止贴错料。

4.3 擦写 Flash 时为什么必须执行在 SRAM 里

Flash 编程最反直觉的一点是:你不能像写数组那样往地址写数据。NOR Flash 写入前必须先擦除,擦除单位通常是一个扇区(常见 4KB),写入则按 256 字节页来。RP2040 的 SDK 提供了flash_range_erase(offset, count)flash_range_program(offset, buffer, count),使用时有几个硬性约束:

  • erase 的offsetcount必须是 4KB 的倍数;
  • program 的offsetbuffer需要 256 字节对齐;
  • 擦写期间,Flash 的 QSPI 接口被写操作占用,无法同时响应 CPU 从 Flash 取指的数据请求。

这就引出经典死锁问题:如果flash_range_erase本身放在 Flash 里执行,那么 CPU 在取指执行这个函数的过程中,Flash 已经开始擦除了,下一步 CPU 再取 Flash 里的其他指令就取不到了。SDK 内部的安全执行机制会把这段擦写代码复制到 SRAM 运行,并要求你调用时关闭中断,避免中断服务程序在 Flash 里被触发后,去访问一个正在擦写的 Flash。

实际工程里最常见的崩溃现场是这样的:你在 Flash 里放了一个中断服务函数,擦写 Flash 过程中恰好来了一个中断,CPU 跳进中断服务函数取指,此时 Flash 正忙,总线卡死,表现为 HardFault 或者系统挂起。解决方案是把中断服务函数放进 SRAM,比如用__not_in_flash_func标记,并且在擦写期间用临界区保护。

4.4 寿命、坏块与写入保护:Flash 也不是这么“随便造”的

NOR Flash 的擦写寿命通常在 10 万次左右。10 万次听起来很多,但如果你在产品里每秒钟写一次配置参数,几天就写满了。所以保存频繁变化的数据时,不建议直接固定擦写同一个扇区,而是做“日志式”写入:数据只追加,不重复擦同一块;等整块写满,再整体擦除。这就是最简单的磨损均衡思路。

Pico 没有内部 EEPROM,但你可以用 Flash 模拟 EEPROM。具体做法是:预留一个或多个扇区,存储格式带序号和校验,写入时按页追加,读取时扫描最新的有效页。这套方案在 8 位单片机时代就很成熟,放到 RP2040 上依然适用。

另一个容易忽略的是 Flash 的写入保护。Flash 芯片有状态寄存器可以设置 WP 引脚或软件保护位。如果你做产品,出厂前把引导区设为只读,能有效防止用户程序误擦 bootloader。RP2040 的 BootROM 和一些第三方库也提供安全启动相关的 OTP 用法,OTP 是一次性可编程的,写错了无法恢复,开发阶段千万别乱试。

5. 保姆级实测:写一个程序,验证 ROM/SRAM/Flash 真实映射

5.1 最小工程搭建与关键代码

纸上谈兵再多,不如直接动手验证。下面这个工程基于 Pico SDK,功能非常简单:上电后串口打印当前程序里几类关键地址,验证它们分别落在哪个存储区域。

先看CMakeLists.txt的最小内容:

cmake_minimum_required(VERSION 3.13) include(pico_sdk_init.cmake) project(addr_scan C CXX ASM) pico_sdk_init() add_executable(addr_scan main.c ) target_link_libraries(addr_scan pico_stdlib hardware_flash) pico_add_extra_outputs(addr_scan)

main.c里,我们要扫描并打印几类信息:

#include <stdio.h> #include "pico/stdlib.h" #include "hardware/flash.h" #include "pico/flash.h" int g_data = 0x1234; int g_bss; __not_in_flash_func(void ram_func_demo)() { // 这个函数会被放到 SRAM 中执行 } int main() { stdio_init_all(); sleep_ms(2000); // 1. 验证 ROM 区:扫描 BootROM 前 256 字节,找 "RP2" 魔数 const uint8_t *rom = (const uint8_t *)0x00000000; for (int i = 0; i < 256; i++) { if (rom[i] == 'R' && rom[i+1] == 'P' && rom[i+2] == '2') { printf("ROM signature RP2 found at offset 0x%x\n", i); break; } } // 2. 验证 SRAM 区:全局变量地址 printf("&g_data = 0x%08lx\n", (unsigned long)&g_data); printf("&g_bss = 0x%08lx\n", (unsigned long)&g_bss); // 3. 验证 Flash 区:XIP 起始处的向量表 volatile uint32_t *vector_sp = (volatile uint32_t *)0x10000000; volatile uint32_t *vector_reset = (volatile uint32_t *)0x10000004; printf("Flash vector[0] SP = 0x%08lx\n", (unsigned long)*vector_sp); printf("Flash vector[1] Reset = 0x%08lx\n", (unsigned long)*vector_reset); // 4. 打印放入 SRAM 的函数地址 printf("ram_func_demo addr = 0x%08lx\n", (unsigned long)&ram_func_demo); // 5. 读 JEDEC ID uint32_t jedec = flash_get_jedec_id(); printf("JEDEC ID = 0x%06lx\n", (unsigned long)(jedec & 0xFFFFFF)); while (1) { tight_loop_contents(); } }

这段代码足够“保姆级”:把 ROM、SRAM、Flash 三类地址全打印出来,还顺手验证了 Flash ID。

5.2 验证 ROM 区:扫描 0x00000000 处的 BootROM 签名

Cortex-M0+ 可以直接从 0x00000000 读取 BootROM 区域,SDK 不会阻止这件事。上面代码里用了最稳妥的方式:扫描前 256 字节,找 ASCII 字符串 “RP2”。RP2040 的 BootROM 在固定偏移处有一组 ROM 表,里面包含魔数,前三个字节就是RP2的 ASCII。

实际串口输出大致会是:

ROM signature RP2 found at offset 0x20

看到这个输出,说明你确实以 CPU 的方式访问到了 ROM 区。“ROM”不是抽象概念,它就是一块可寻址的真存储介质,只是写不了而已。如果你用调试器读 0x00000020,也能看到对应的 ASCII 字符。

5.3 验证 SRAM 区:打印变量与链接器符号地址

程序里定义了全局变量g_datag_bss,编译器把它们分别放进.data.bss段,最终都会落在 SRAM 区。串口打印结果应该长这样:

&g_data = 0x20000000 &g_bss = 0x20000010

注意g_data在 0x20000000 附近,g_bss紧随其后。这两个地址本身会随着你新增其他全局变量而偏移,但一定在 0x20000000 到 0x20042000 之间。如果你在某个工程里看到全局变量地址跑到了 0x20000000 之前,那多半是链接脚本配置出了问题。

还可以进一步打印链接器预置的符号,比如:

extern char __data_start__; extern char __StackTop; printf("__data_start__ = 0x%08lx\n", (unsigned long)&__data_start__); printf("__StackTop = 0x%08lx\n", (unsigned long)&__StackTop);

这些符号能告诉你运行时数据段和栈顶的真实位置,排查栈溢出时特别有用。

5.4 验证 Flash 区:读取 0x10000000 处的栈顶和复位向量

程序运行时,0x10000000 就是 Flash 的 XIP 映射起点,也就是固件向量表所在位置。0x10000000这个地址处的 4 个字节,是本固件的初始栈顶;0x10000004是复位向量。

对着第 2 章讲的 BootROM 校验规则,再看这两个值,就能理解为什么 BootROM 会认为这个固件“有效”:栈顶落在 0x20000000 区域的 SRAM 内,复位向量落在 0x10000000 之后的 Flash 区域。现场执行会看到:

Flash vector[0] SP = 0x20042000 Flash vector[1] Reset = 0x100003c5

0x20042000是典型的默认栈顶位置,也就是 SRAM 最高地址;0x100003c5则是 Flash 里复位函数的入口地址。这个数字会随代码变化,但它一定落在 0x10000000 之后。

5.5 进阶:把一个函数搬进 SRAM 跑并测性能

程序里的ram_func_demo用了__not_in_flash_func宏,编译后这个函数会从 Flash 区挪到 SRAM 区。串口打印它的地址,你会看到类似:

ram_func_demo addr = 0x20040020

或者某个 SRAM 地址。这个宏的源码实现并不神秘,它就是为函数设置了一个特定的 section,然后链接脚本把该 section 放在 SRAM 区域里。

真正有价值的操作是把函数搬到 SRAM 后做一个执行时间对比。比如用 GPIO 翻转一个引脚,分别在 Flash 里跑一段for循环和 SRAM 里跑同一段循环,用逻辑分析仪测高电平持续时间。实测下来,在 CPU 频率相同时,SRAM 执行的循环通常稳定快一点,尤其是在代码没有被 XIP cache 完全覆盖、或者 Flash 被擦写操作占用的情况下,差异会被放大。这就是为什么时序要求高的驱动库都喜欢用__not_in_flash_func

6. 存储层踩坑实录:下载失败、HardFault、内存不足的排查链路

6.1 “Flash download failed - target dll has been cancelled”到底啥意思

很多人在 VS Code 里配 Pico 调试环境时,会遇到这么一串报错:

Error: flash download failed - target DLL has been cancelled

这个提示的英文直译是“Flash 下载失败,目标 DLL 已被取消”,第一次见确实很懵。这其实是调试器(通常是 OpenOCD 或 pyOCD)在烧写阶段和目标芯片通信失败时给出的笼统报错,并不是说你的 DLL 文件坏了。

结合我自己的经验,出现这个报错的排查顺序应该是:

现象可能原因排查方法
按下调试后立刻报错调试器没识别到目标芯片检查 SWD 接线,确认 Pico 的 SWDIO/SWCLK/GND 连接正确
报错后目标板无反应目标板供电不足换 USB 口,外接 5V 供电,不用电脑 USB 口直接供电
之前能下载,突然不行Flash 里的程序占用了 SWD 引脚或睡眠了按住 BOOTSEL 上电进入 USB 模式,再重新下载
换板子后开始出现Flash 容量或颗粒型号不兼容查 JEDEC ID,更新 SDK 和调试器版本
与超频相关CPU 超频过高导致调试不稳恢复默认频率,完成下载后再尝试超频

很多时候,点一下“擦除整个 Flash”再下载就能恢复。因为如果 Flash 里烧了一个把 SWD 引脚复用掉的程序,调试器就没法和芯片正常通信了,但按住 BOOTSEL 上电能强制进入 ROM 引导模式,让调试器重新接管。

6.2 中断里擦写 Flash 导致的随机崩溃

这是所有 Pico 存储问题里最隐蔽的一类。程序平时跑得好好的,但只要触发某次 Flash 擦写操作,系统就在随机时间点崩掉。坑在于崩的地点可能离擦写代码很远,看起来毫无关联。

我复盘一下完整排查链路:

  1. 最开始怀疑是硬伤:检查电源、时序、DMA 配置,全都没问题。
  2. 加日志后发现崩溃总是在 Flash 擦写后几十毫秒内发生,但位置不固定。
  3. 对照 SDK 文档,意识到问题可能是中断服务程序在擦写期间被触发,CPU 尝试从 Flash 取指执行 ISR,而 Flash 此时正被擦写命令占用。
  4. __not_in_flash_func把可能触发的中断服务函数全部放进 SRAM,同时在擦写调用外围加了临界区保护。
  5. 再次长时间压力测试,问题消失。

代码层面的关键写法是这样的:

#include "pico/time.h" #include "hardware/flash.h" #include "hardware/irq.h" void do_flash_erase_safely(uint32_t offset, uint32_t count) { // 关闭全局中断,避免 Flash 擦写期间 ISR 取指卡死 uint32_t irq = save_and_disable_interrupts(); flash_range_erase(offset, count); restore_interrupts(irq); } // 中断服务函数放入 SRAM __not_in_flash_func(void timer_isr)() { // 这里绝对不能访问 Flash,也不能调用需要从 Flash 取指的函数 }

注意save_and_disable_interrupts只能挡当前核的中断,RP2040 是双核,另一个核如果也访问 Flash 或触发中断,需要额外处理。在双核场景下做 Flash 擦写前,最好让另一个核先暂停或者进入自旋等待状态。

6.3 栈溢出与内存不足:那些内存不足的“假性”报错

链接阶段最常见的报错是:

region `RAM' overflowed by X bytes

它表示你的 SRAM 已经放不下了。但“放不下”的原因未必是变量太多,很多时候是.rodata常量、栈空间、堆空间分配不合理。排查步骤:

  1. 打开.map文件,搜RAM段,看里面到底装了什么。
  2. 如果.bss特别大,说明你的全局数组太多了,考虑改成动态申请或减小缓冲区。
  3. 如果.data特别大,说明初始化过的全局变量太多,而且每个都带非零初值,这部分在 Flash 里也要占空间,启动时还要复制到 RAM。
  4. 如果栈顶地址__StackTop.bss末尾很近,说明留给栈的空间不够,考虑调大PICO_STACK_SIZE,或者减少中断里的局部大数组。
  5. 如果堆和栈会互相踩踏,程序会出现“偶发乱跳”“全局变量莫名被改”的现象。可以在 main 开头给一块内存填充固定模式,然后周期性检查,看有没有被改写。

这里分享一个我个人一直在用的土办法:在产品调试阶段,定义一个大字节数组当哨兵,放进.bss段的最末尾,然后每秒检查一次:

#define GUARD_SIZE 1024 uint8_t guard[GUARD_SIZE]; uint8_t guard_fill = 0; void set_guard(void) { guard_fill = 0x5A; memset(guard, 0x5A, sizeof(guard)); } int guard_ok(void) { for (int i = 0; i < GUARD_SIZE; i++) { if (guard[i] != 0x5A) return 0; } return 1; }

如果guard_ok()返回 0,说明有代码踩过了这个区域,配合 Map 文件里guard的地址,能很快定位是哪一块内存被过度使用了。这个方法不用仿真器,纯打印就能排查栈溢出。

提示:排查内存问题最忌讳没有章法地乱调参数。先把 Map 文件和链接脚本读透,再用哨兵 + 日志 + 临界区的手段一步步缩小范围,比任何调试器单步都高效。

最后再分享一个实践习惯

最近这一年我经手了不少 RP2040 相关的项目,踩过的坑大致就是上面这些。如果只让我保留一条建议,那就是:拿到新板子先跑一遍存储体检程序,把 ROM 签名、SRAM 布局、Flash ID、向量表地址全部打印出来,然后存一份档。这套“体检”脚本每次换板子、换 Flash 颗粒、换 SDK 版本时都跑一遍,能帮你排除大量隐性问题。

我自己现在的工程模板里,这套打印代码已经固化成一个独立的hardware_selftest.c,不影响主逻辑,只在调试宏开启时才编译。后续无论是排查中断异常、Flash 擦写崩溃,还是评估某个库的内存占用,都能对着这套基线数据快速定位问题。你也可以试着把这段代码扩展成自己的“板级自检工具”,它给你带来的价值,绝对不止省一晚上排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询