ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CW32L012外部SPI Flash下载方案:从原理到实战

CW32L012外部SPI Flash下载方案:从原理到实战 前段时间做低功耗采集项目程序越写越大CW32L012片内Flash眼看就要被协议栈、字库表和一些算法参数占满。手上这个型号引脚和封装都定死了换更大Flash的MCU等于重新画板、重新过测试时间完全不允许。后来我花了两个晚上把一部分代码和全部静态数据挪到了外部串行Flash配套的下载、启动流程也整体切了过去才算把这个项目从崩溃边缘救回来。如果你也在用CW32L012或者你手里的MCU同样卡在片内存储容量上这套串行Flash下载方案可以帮你少走很多弯路。这篇文章不只讲接线和下载步骤我尽量把背后原理、两种落地路线、实际烧录验证、容易翻车的地方都讲透最后会补上怎么把这套东西扩展成OTA升级和量产批量烧录。适合正在评估外部Flash方案、以及被片内空间逼到不得不扩展存储的嵌入式工程师。1. 为什么需要一套串行Flash下载方案1.1 CW32L012的片内Flash容量与真实项目冲突CW32L012是Cortex-M0内核的低功耗MCU这颗料的优势在低功耗表现和外设丰富度上但片内Flash容量整体属于“够用但不宽裕”的水平。具体数值不同封装和型号有差异实际拿到选型手册一看就清楚了。单独跑一个小逻辑、采集几个传感器数据片内Flash确实够用一旦涉及到显示字库、中英文语言包、波形记录、蓝牙协议栈或者稍微完整一点的Bootloader加App结构空间立刻捉襟见肘。我做采集项目时就遇到这个情况Bootloader占了4KB主程序加上协议栈后已经接近片内Flash上限客户又突然要加一个中文字库和两个月的数据记录功能。字库按最小子集算也要几百KB片内这点地方根本腾不出来。当时摆在面前的选项只有三个换大容量MCU、压缩功能、外挂Flash。前两个要么改板要么砍需求都不好跟客户交代所以只剩第三条路。1.2 外挂SPI NOR Flash能解决哪些场景外挂串行Flash这个思路本质上是把MCU片内Flash当作“要塞”把大量不经常改动的数据和应用代码“屯”在外面的SPI NOR Flash里。最常见的场景有这么几类大容量静态资源存储字库、图片、音频片段、配置文件、历史数据、日志这类数据量大但对实时性要求不高非常适合放外部Flash。应用程序扩容把一部分相对独立的功能模块编译成可执行代码放外部Flash由片内Bootloader启动时读出来拷贝到RAM中执行。OTA升级暂存区无线或者串口收到的升级包先写到外部Flash的缓存分区校验通过后再固化到运行分区断点续传也更好做。参数与掉电保存数据虽然片内也有EEPROM模拟区但容量和擦写次数都有限外部Flash可以扛更大的数据量和更频繁的写入。1.3 这里说的“下载方案”到底指什么很多人一听“串行Flash下载”会以为只是用SPI接口读写Flash其实在MCU项目里这个词有两层含义。第一层是开发阶段的下载动作怎么把编译好的固件通过调试器或者烧录工具写进外部SPI Flash并且像写片内Flash一样方便最好在Keil里点一下Download就完成。第二层是运行阶段的启动动作上电后MCU怎么把外部Flash里的程序正确加载起来保证App能跑。这两层缺一不可。只解决下载不解决启动程序写进去了也跑不起来只做启动不支持开发期下载每次改代码都要用专用烧录器效率太低。整套方案要同时打通这两层才叫一个完整可用的串行Flash下载方案。2. 底层协议先想明白SPI NOR Flash的命令与读写2.1 常用命令和状态轮询机制外接Flash我习惯用W25Q系列比如W25Q64、W25Q128指令集基本就是SPI NOR Flash的通用标准换其他家芯片也大差不差。写代码前先把最常用的几个命令背下来功能命令字说明JEDEC ID读取0x9F返回3字节厂商和设备ID用于识别Flash型号Read Data0x03最常用的读取命令地址24位支持连续读Write Enable0x06写/擦除操作前必须发否则指令不生效Page Program0x02页编程一次最多写256字节Sector Erase0x20擦除一个4KB扇区擦完内容全为0xFFBlock Erase0xD8擦除64KB块批量擦除时速度快一些Read Status Register0x05读状态寄存器bit0是WIP忙标志写操作和擦除操作都是异步的发出指令后Flash内部在忙这时必须不断读0x05命令返回的状态寄存器等WIP位变成0才能进行下一步。我在Bootloader和下载工具里都写了统一的等待函数核心逻辑就是循环读状态寄存器加一个超时保护防止Flash异常时卡死。2.2 SPI模式配置与CW32L012外设细节SPI NOR Flash最常用的传输模式是Mode 0和Mode 3区别在于时钟极性CPOL和时钟相位CPHA。跟W25Q系列通信我统一用Mode 0CPOL0CPHA0也就是空闲时时钟为低电平、数据在上升沿采样。这个配置同时兼容绝大多数SPI Flash省得到时候怀疑时序有问题。CW32L012的SPI外设配置本身不复杂需要注意两点一是引脚复用要选对我用的引脚是PA4做CS、PA5做CLK、PA6做MISO、PA7做MOSI具体每个封装可能不一样动手前一定对着数据手册的复用功能表查一遍二是时钟极性极性和相位别记反CPOL和CPHA两个参数在CubeMX类的图形配置工具里直接选Mode 0就行如果手写寄存器就得仔细看参考手册里的时序图。刚开始通信不顺利时建议把SPI时钟先降到1MHz以下测能正常读写再往上提。我遇到过时钟调到8MHz偶尔读取错误的情况后来定位发现是杜邦线太长、信号质量不行降到4MHz就稳了。PCB上走线短的话跑高点没问题调试阶段不用急着追求高速。2.3 给固件做分区规划别等下载完才后悔烧录之前先把外部Flash的分区想清楚。别急着一股脑从地址0开始写后面做OTA或者加日志功能时再挪分区会牵连Bootloader和App同时改动非常痛苦。我的习惯是画一张分区表分区名地址范围大小用途App_Active0x000000 - 0x01FFFF128KB当前运行的应用程序Bootloader启动时读取App_Download0x020000 - 0x03FFFF128KBOTA升级包暂存区Font/Res0x040000 - 0x07FFFF256KB字库、图片等静态资源Param/Log0x080000 - 0x0FFFFF512KB参数保存、运行日志、采集数据一张2MB的W25Q16被拆成了四个用途明确的区域。App区放在Flash开头的好处是下载时地址好记Bootloader里读固定的起始地址就行。资源区独立出来的原因是这里的字库和图片几乎不会更新OTA升级时不用跟着App一起搬来搬去。参数区放最后虽然W25Q系列扇区擦除寿命普遍在10万次级别但日志类数据建议用循环覆盖写的方式不要总是擦同一个扇区能延长整体寿命。3. 两条落地路线调试器下载算法 vs Bootloader自举3.1 路线A把外部Flash做成IDE可写的下载算法开发调试期最理想的状态是点一下Keil的Download按钮代码就自动写进外部Flash断点调试、单步执行跟写片内Flash一样顺滑。要做到这一点靠的是CMSIS-Pack里定义的Flash下载算法文件也就是常说的FLM文件。FLM文件的本质是一段会被调试器临时加载到RAM里运行的小程序里面定义了一组标准接口Init、UnInit、EraseChip、EraseSector、ProgramPage、Verify。调试器要写外部Flash时就把这个算法加载进去调用它的ProgramPage函数通过SPI外设把数据写进Flash。对CW32L012来说最省事的方法是看厂商提供的Pack包里是否带了外部SPI Flash的FLM算法如果带了直接配置使用。如果没带就得自己基于官方例程改一个主要工作是实现上述几个接口函数把SPI初始化、擦除、页编程逻辑填进去然后用CMSIS-Pack工具打包生成FLM。这条路对新手来说有一定门槛但一劳永逸尤其是后面每次编译下载都能省下大量时间。3.2 路线B片内Bootloader引导先从SPI Flash搬运再执行运行阶段怎么把外部Flash里的程序跑起来这是整个方案的关键。Cortex-M0没有把SPI地址空间直接映射成执行区域的能力所以不能像某些Cortex-M系列芯片那样直接从外部NOR Flash取指执行。常用的做法是Bootloader放在片内Flash上电后由Bootloader先初始化SPI外设从外部Flash的App_Active分区读出固件拷贝到RAM里然后跳转执行。Bootloader这个搬运流程可以拆成几个步骤关中断、初始化SPI和GPIO、读取外部Flash的App头部信息、按固定长度分块读出全部代码、把固件拷贝到RAM指定地址、重新设置向量表、设置主栈指针、跳转到App的复位处理函数。注意向量表重映射这里有个关键细节。Cortex-M0内核如果带VTOR寄存器直接把VTOR指向RAM里的新向量表地址就行。如果内核不带VTOR需要在RAM的起始位置做一个向量表重定向的兜底处理。CW32L012用的是M0核正常情况下VTOR是有的但程序里还是要做条件判断防止换型号时踩坑。App工程这边也不是什么都不用改。因为程序最终在RAM里跑链接脚本要改RO段和RW段都要加载到RAM里起始地址留出向量表和栈空间整个App的链接地址改成RAM地址而不是片内Flash地址。具体偏移量取决于Bootloader占用的片内空间大小以及RAM的总容量编译前需要仔细算好确保App的代码段加上数据段不超过RAM可用空间。这一点是自举路线里最容易出问题的地方后面的实操章节还会提到。3.3 什么时候选A什么时候选B这两条路线不冲突实际工程里通常是配合使用。开发阶段用A把编译好的程序直接下载到外部Flash改代码验证都方便。发布阶段用B量产设备里Bootloader已经固化在片内外部Flash里预置好App上电自动加载执行。如果项目还在打样和功能验证阶段只做A就够了先用调试器把外部Flash当普通存储用验证读写正常。如果目标是压缩片内Flash占用、让程序能在外部Flash里跑起来B就必须做。两条路线一起搭好之后整个开发节奏非常顺畅写代码、编译、Download到外部Flash、复位、Bootloader搬运、运行一气呵成。4. 分步实操把固件真正下进串行Flash4.1 硬件接线和物料清单一套最小验证系统需要的东西不多一块CW32L012开发板或者自己画的板子、一个SPI NOR Flash芯片或者模块、排针杜邦线、一个调试下载器。我用的调试器是DAP-LinkJ-Link也可以只要能通过SWD连接CW32L012就行。CW32L012引脚功能W25Q64引脚PA4SPI CSCS#1脚PA5SPI CLKCLK6脚PA6SPI MISODO2脚PA7SPI MOSIDI5脚3.3V电源VCC8脚GND地GND4脚Flash芯片的CS#最好再加一个10kΩ上拉电阻到3.3V防止MCU复位期间引脚悬空导致Flash误片选。WP#和HOLD#这两个引脚如果不用的设备写保护直接上拉到VCC。我这里接了硬件复位相关的保护电路省得程序跑飞时把Flash锁死。4.2 识别Flash ID与整体擦除市面上SPI NOR Flash的引脚定义都兼容但不同厂家的ID差异很大。下载前第一步永远是读JEDEC ID把读回来的3个字节跟数据手册比对确认连接正确、芯片型号符合预期。读ID的代码用SPI发送0x9F命令然后连续读3个字节即可。确认芯片ID正常后先执行一次全片擦除。擦除过程比较久W25Q64这种8MB Flash全片擦除要几十秒。擦除的时候要不断读状态寄存器的WIP位同时做一个超时计数比如超过60秒还没擦完就直接报错。擦除完成后可以随机读几个扇区确认读出内容全部是0xFF这样初始状态才是干净的。这里有一个经验不要每次下载都全片擦除。如果用的分区方案每次只擦App_Active分区对应的扇区就够了全片擦除不仅慢还会把参数区和日志区一起抹掉。开发期为了方便可以全片擦进入稳定阶段后改成按区擦除。4.3 固件文件格式与地址偏移处理调试器下载算法模式下直接在Keil工程里配置好FLM文件和RAM加载地址点下载工具会自动处理bin文件的地址映射基本不用关心格式问题。但Bootloader自举模式下我给外部Flash编程的固件一般用bin文件因为bin没有地址信息所有内容从指定基地址连续存放结构最简单。编译工具链默认生成的是hex文件里面每一行都带有地址信息。写下载脚本的时候可以用工具把hex转成bin转完要注意地址是否跟自己规划的分区起始地址一致。比如我在Bootloader里固定从外部Flash的0x000000地址读App那烧写时就要确保App的bin内容放在0x000000偏移处不要在文件前面加任何头。如果需要在App bin前面加自定义头部信息比如版本号、长度、CRC校验值那Bootloader读取时也要按相同格式解析两侧协议要严格保持一致。4.4 下载后的回读校验与启动验证固件写完之后别急着复位跑程序。先做一次回读校验把外部Flash里刚写入的内容读出来跟源bin文件逐字节比对。一般调试器和烧录工具都有回读校验功能开启后会自动做这一步。最容易被忽略的是启动验证环节。回读校验只证明数据写进去了但无法证明Bootloader能把程序搬起来。正确的启动验证方式是把调试器断开板子完全断电重新上电让Bootloader从外部Flash引导App。引导成功后通过串口打印或者指示灯闪烁观察运行状态。我见过有人只在仿真器连接状态下验证程序看起来一切正常实际上数据可能还在调试器加载的RAM里没写进Flash断电之后整块板子变砖。所以务必做一次完整断电冷启动测试。4.5 Bootloader搬运App的核心代码模板这里给一段我在Bootloader里实际用的加载核心逻辑注释写清楚每一步在干什么方便按自己的平台移植#define APP_HEADER_ADDR 0x00000000 #define APP_LOAD_ADDR 0x20001000 /* RAM运行地址注意避开Bootloader使用的RAM */ #define APP_MAX_SIZE 0x00020000 /* 128KB */ typedef void (*AppResetHandler)(void); static void bootloader_jump_to_app(void) { uint32_t app_stack; uint32_t app_reset; AppResetHandler do_reset; /* 1. 关闭全局中断避免搬运过程中被打断 */ __disable_irq(); /* 2. 从外部Flash头部读出App的初始栈顶地址 */ app_stack spi_flash_read_word(APP_HEADER_ADDR); /* 3. 读出App的复位向量地址 */ app_reset spi_flash_read_word(APP_HEADER_ADDR 4); /* 4. 将外部Flash的App区内容拷贝到RAM运行地址 */ spi_flash_read_buffer(APP_HEADER_ADDR, (uint8_t *)APP_LOAD_ADDR, APP_MAX_SIZE); /* 5. 重映射向量表到RAM */ SCB-VTOR APP_LOAD_ADDR 0xFFFFFF80; /* 6. 设置主栈指针并跳转 */ __set_MSP(app_stack); do_reset (AppResetHandler)app_reset; do_reset(); }要注意spi_flash_read_buffer函数内部要按外部Flash的页大小做拆分读取如果Flash页大小是256字节那就一块一块读不能在栈上申请128KB的临时缓冲区。直接按固定块读、直接写入RAM对应地址就行边读边搬。5. 实际项目中容易翻车的几个细节5.1 SPI模式对不上读回全是0xFF刚把下载流程跑通时遇到读回全0xFF的问题第一反应是Flash坏了或者接线错误排查半天发现就是SPI模式配置成了Mode 3。Mode 0和Mode 3在空闲电平和采样沿上都有区别如果控制器和Flash两侧模式不匹配数据线出来的内容就是错的。遇到任何“读数据全是0xFF但ID能读出来”的情况先检查CPOL和CPHA配置这个坑比坏芯片概率大十倍。另外0xFF本身也是Flash擦除后的默认值如果读出来的区域从来没写过或者刚被擦除读回0xFF是正常的。所以判断问题时一定要明确是“读到了空数据”还是“写进去的数据被读成了0xFF”这两个结论的排查方向完全不同。5.2 擦除是写操作的绝对前提页编程不能跨页SPI NOR Flash的特点是写入只能把1变成0把0变成1必须先擦除。所以向某个地址写数据之前必须确保这个地址所在的扇区已经被擦除过。很多新手上来直接调页编程发现写不进去原因就是扇区里还有旧数据。页编程一次最多写256字节而且不能跨页。也就是说如果当前页还剩10个字节你非要一次写20个字节后面10个字节会跑到下一页去结果不可预期。正确做法是每次写入前判断剩余页空间如果数据长度超过当前页剩余空间就先写完当前页再从下一页的起始地址继续写。我写了一个包装函数内部自动做页拆分对外只提供一个“任意地址任意长度写入”的接口省得每次调页编程都写一堆边界判断。5.3 片选上拉与复位时序上电瞬间误触发这个坑比较隐蔽。MCU刚上电时GPIO在复位完成前处于不确定状态如果CS引脚恰好被拉低外部Flash会以为主机要发命令但此时SPI时钟线上大概率存在毛刺Flash容易误进入某种半死不活的状态。解决办法就是前面说的CS#引脚加10kΩ上拉电阻到VCC确保MCU复位期间CS处于高电平。另一个时序问题是MCU进入低功耗模式时如果SPI引脚不处理也可能让Flash处于不必要的活跃状态。我在进入睡眠之前会把CS拉高、SPI外设关掉Flash的WP#和HOLD#保持上拉这样整板功耗才能落到数据手册标称的低功耗值上。低功耗方案如果发现功耗偏高很多情况下不是MCU的问题而是外设引脚悬空导致的漏电。5.4 外部Flash为空时Bootloader卡死与恢复手段Bootloader从外部Flash搬运代码时如果Flash是空的或者App区数据被擦掉读出来的内容全是0xFF这时跳转过去执行0xFFFFFFFE之类的地址必定进入HardFault死循环。这个问题在开发阶段几乎天天遇到所以Bootloader里必须做启动有效性检查。最简单的检查方式是给App bin头部加一个固定魔数比如0xC5A5A55A。Bootloader读App头部前4字节比对魔数是否匹配不匹配就进入串口下载模式等待接收新固件而不是傻傻去跳转。复杂一点的做法还会加版本号和CRC32校验CRC校验能保证固件传输过程中数据没有损坏。恢复手段也要提前设计好。Bootloader里留一个串口ISP升级入口或者检测某个按键引脚在启动时被拉低就强制进入下载模式都是实用的做法。千万不能让设备陷入“外部Flash没程序就永远起不来”的死局批量生产时第一批板子还没写程序连不上调试器会很尴尬。5.5 低功耗模式下Flash掉电与唤醒策略低功耗项目中外部Flash往往是整板上功耗大头之一。Flash在正常读状态电流就有几毫安深功耗模式下尤其注意不能让它持续工作。常规做法是在进入睡眠前把CS拉高确认Flash不在内部写/擦除状态然后SPI时钟停止。如果应用场景有更深的睡眠模式可以考虑给Flash设计独立的电源开关睡眠时直接断电唤醒后再等上电稳定重新初始化SPI和Flash。资源数据放在外部Flash意味着唤醒后首次访问会有一段额外延迟这个延迟在UI刷新或者日志记录时能接受但如果是实时性要求很高的控制逻辑就不适合放在外部Flash。评估方案时要把这个时延算进系统实时性预算里别等测试时才发现掉帧。6. 从“能下载”到“好用”OTA升级与批量烧录的扩展6.1 基于外部Flash做OTA缓存区、校验与回滚外部Flash天然适合做OTA升级分区规划里预留的App_Download区域就是干这个的。整个升级流程可以做成多步的状态机接收升级包、写入缓存区、校验完整性、标记待生效分区、复位进入Bootloader、Bootloader搬运新固件、App启动后上报版本号。升级包最好带版本号和CRC32或SHA256校验值。下载过程中每接收一包数据就计算一次校验全部接收完再整体校验一次确认无误后写入“待升级标志”。Bootloader启动时发现这个标志才去搬运新固件否则继续跑老固件。回滚逻辑更简单Bootloader搬运完新固件后App启动时上报成功Bootloader才把“待升级标志”清掉如果App没在超时时间内上报成功Bootloader下次启动自动搬回旧分区。这套双分区加标志位的做法在资源有限的M0平台上完全跑得动关键是要把状态机的每个分支都想清楚。6.2 量产方案预置Bootloader 流水线烧录SPI Flash量产时不可能每台设备都用Keil手动下载效率太低。我常用的量产流程是分两段走第一段通过SWD接口用量产工具把Bootloader烧进MCU片内Flash这一步可以一拖多并行烧录第二段是App固件的写入根据产线条件二选一。如果产品是单板出货建议用一个支持串行Flash编程的烧录器比如通用SPI Flash编程器先把App写进外部Flash再贴片或者先贴片后用适配器从下载口接出来通过一个专用烧录底座完成App写入。如果产品本身有串口或者无线接口更灵活的做法是设备出厂前只烧Bootloader产线用串口批量下发App固件Bootloader接收后写到外部Flash再启动。这种方式对固件后续现场升级也友好不需要额外改硬件。6.3 可靠性设计读保护、写保护与坏块处理外部Flash存代码和资源数据最怕两种情况误写和坏块。误写主要来自程序跑飞后SPI时序错乱意外往Flash里发了写命令。硬件上把WP#引脚拉低可以防止状态寄存器被改但挡不住正常写命令被误触发。软件层面把SPI写操作集中在少数几个接口里其他模块不允许直接调底层写函数能大幅降低误写概率。坏块问题在NOR Flash上比NAND好很多基本可以认为正常寿命内不会出现制造级坏块。但长期擦写后个别扇区可能出现擦除时间变长或者写入校验失败所以下载工具和Bootloader里都要对写结果做校验失败则标记该扇区并重新映射到备用区。日志类数据建议做区域磨损均衡不要每次都写同一个扇区均摊擦写寿命。这些可靠性的东西前期不考虑等产品批量出货后在现场出问题维护成本会非常高。最后分享一条我个人的经验。现在凡是基于CW32L012的新项目不管初期用不用外部Flash原理图阶段我都会预留一组SPI Flash的焊盘、把CS#上拉电阻放上、下载口引出标准的SWD接口。哪怕这批产品用不到后期要加OTA或者扩展存储时板上稍微补几个料就能切过去。这个习惯已经帮我挡掉了好几次需求突变的麻烦你也可以直接抄作业。
返回列表