手上最近在调一个低功耗传感器节点的量产项目,主控用的是CW32L012,ARM Cortex-M0+内核,主打低功耗和低成本。做到第二版固件的时候,功能越加越多,协议栈、参数配置、升级策略全塞进去,片内Flash开始吃紧,于是我把外挂串行Flash的方案完整摸了一遍,包括下载架构、Bootloader设计、上位机流程、量产烧录,踩了不少坑也总结了不少经验。这篇就把这套CW32L012串行Flash下载方案从头到尾拆开讲清楚,适合正在做低功耗产品、遇到Flash容量瓶颈、或者想给MCU加OTA升级能力的工程师参考。我会把方案选型、硬件连接、固件格式、Bootloader实现、下载流程和排障记录都覆盖到,尽量一次说透。
1. 为什么需要串行Flash下载方案
1.1 CW32L012的片内存储资源与固件膨胀的矛盾
CW32L012的片内存储配置在同类低功耗MCU里算比较常规的,Flash空间大概在几十KB量级,具体以对应型号的手册为准,RAM相对更小,普遍在几KB到十几KB之间。这个体量做简单的传感采集、电机控制、小尺寸显示面板完全够用,但一旦遇到下面几种情况,片内Flash就会变得非常紧张。
首先是通信协议栈。现在很多低功耗设备都要接入各种无线协议或者私有协议,协议栈固件往往一下子就吃掉十几KB,而且这部分代码基本没法精简。其次是参数管理,我见过不少产品把校准数据、设备序列号、运行日志、故障记录全都放片内Flash,每个扇区都算得死死的,后面要加一个功能模块就得先删掉一个旧模块。最头疼的是OTA升级需求,靠谱的升级方案至少要在片内留出双备份空间,一个跑当前固件,一个存升级固件,这对本身就不宽裕的存储空间来说是压倒性的负担。
这时候有两个方向可以走:一是直接换更大Flash的MCU型号,二是外挂一颗串行Flash来扩展存储。前者的缺点是BOM成本上升、供货风险增加,有时候为了几十KB的存储空间多花一两块钱,在产品经理那边很难交代。后者用一颗几毛钱到一块多钱的SPI NOR Flash就能额外获得从几百KB到几MB的非易失存储空间,灵活性高得多,也方便统一物料管理。
1.2 串行Flash在下载方案中的角色定位
我在这里要特别说明一下,项目标题里说的“串行flash下载方案”,核心不是教你怎么用SPI读写Flash存数据,而是把串行Flash当作程序固件的存储载体,解决MCU内置Flash不够用或者升级不方便的问题。串行Flash在整套方案中承担了两个关键角色,一个是程序运行时存储,一个是升级包暂存区。
程序运行时存储的意思是,把完整的应用程序固件放在外挂Flash里,MCU上电后由Bootloader把这段固件从串行Flash读取出来,搬运到RAM中执行,或者先写入片内Flash再从片内执行。考虑到Cortex-M0+内核一般不支持直接从外部SPI Flash执行代码(也就是常见的XIP,不过M0+多数型号不支持),所以“搬运”是M0+平台上最常见、最稳妥的做法。
升级包暂存区则更好理解,设备收到新版固件后,先把固件包完整存到外部Flash,做一遍CRC校验,确认没问题后再由Bootloader从外部Flash把固件搬到片内Flash,完成升级。这么做的好处是升级过程对片内Flash的占用极小,还能做断点续传、多版本备份这些花活。对于CW32L012这种主打低成本低功耗的芯片来说,这套方案尤其合适,既保住了芯片的性价比优势,又补上了存储空间的短板。
| 对比项 | 直接换大Flash型号 | 外挂串行Flash + 下载方案 |
|---|---|---|
| 硬件成本 | 增加明显,取决于型号跨度 | 增加一颗几毛钱到一块多的SPI Flash |
| 存储容量 | 受限于选型,不灵活 | 从128KB到16MB都能覆盖 |
| 升级灵活性 | 受限于片内空间,难以做双备份 | 可以大容量缓存升级包,方便OTA |
| 系统复杂度 | 无额外复杂度 | 需要增加Bootloader和搬运逻辑 |
| 适用场景 | 容量需求固定,后续不扩展 | 需要后续OTA、多版本、批量配置 |
2. 硬件连接与Flash选型要点
2.1 串行Flash芯片选型的关键参数
先明确一个观点,串行Flash选型不是随便挑一颗能用的就行,尤其是在低功耗产品里,几个容易被忽略的参数会直接影响整机表现。我自己的选型经验是重点看工作电压、静态功耗、擦写寿命、最高时钟频率和写保护引脚的行为。
工作电压上,CW32L012的IO电压范围要和外挂Flash匹配,建议选1.8V到3.6V宽压范围的型号,这样就算系统未来要降压到1.8V运行,Flash也能跟着工作,不用重新选型。静态功耗方面,低功耗产品待机电流动不动就要做到微安级别,Flash在Deep Sleep模式下必须能进入Standby或Power Down模式,选型时看清楚手册里的待机电流指标,尽量选几微安以内的。擦写寿命虽然SPI NOR Flash普遍号称10万次擦写,但实际和温度、电压有相关性,工业应用最好选寿命余量大的。最高时钟频率决定了固件搬运速度,常见的有50MHz、104MHz、133MHz这几档,对于Bootloader搬运几百KB固件的场景,50MHz已经足够快,但如果后续打算做在线升级和时间敏感的功能,选高一点也没问题,反正价格差异不大。
写保护引脚最容易踩坑。大多数SPI Flash都有WP(写保护)和HOLD(保持)引脚,有些芯片默认上电后WP拉低会锁住状态寄存器,导致软件擦写指令无效。我的建议是硬件设计时把WP引脚通过上拉电阻接到高电平,把HOLD引脚上拉到高电平,同时在软件初始化时主动清除保护位。另外,有些新出的Flash芯片还支持独立的非易失配置寄存器,上电后要用Reset或特殊指令解锁,这个在选型阶段就要跟原厂确认清楚。
2.2 CW32L012与Flash的电气连接与PCB设计
CW32L012的硬件SPI接口可以直接挂串行Flash,一组SPI就足够,接线非常少,总共四根信号线加上电源和地。典型连接方式是这样的,SCK接SPI时钟输出,MOSI接Flash的DI引脚,MISO接Flash的DO引脚,CS接Flash的片选。WP和HOLD两个引脚按上一节说的直接接上拉到VCC。
CW32L012一侧的引脚具体用到哪些,取决于你选的复用功能,这个查对应型号的数据手册和引脚定义表就行。有一点要提醒,SPI的CS片选建议使用独立的GPIO控制,不用硬件SPI自动管理,这样在Bootloader阶段和后续调试时都能灵活拉低拉高,遇到Flash异常时也能直接软件复位Flash(通过控制CS拉低再拉高的方式)。
PCB设计上,Flash离MCU尽量近,信号线走线短一些。SPI时序频率如果跑到几十MHz,信号完整性就要注意了,串阻加上,走线不要跨越参考平面缝隙。Flash的VCC旁边放一个100nF的陶瓷电容,电容尽量贴近Flash的电源引脚。另外,CW32L012和Flash的供电如果同一个LDO输出,在Flash擦写瞬间会有电流尖峰,去耦电容能有效抑制这个尖峰对MCU电源的干扰,实测中遇到过擦写Flash瞬间MCU复位的奇葩问题,后来查下来就是电源纹波过大导致的,加上电容后问题消失。
2.3 硬件设计中的一个隐藏注意事项
再补充一个量产时经常遇到的问题,Flash芯片的焊接和溯源。SPI NOR Flash封装小,有些是WSON8或者更小的封装,和PCB焊盘匹配不好的话容易出现虚焊,表现在下载时有时成功有时失败。这个问题在研发阶段往往不突出,因为样品数量少,到了SMT量产时批量不良率就暴露了。建议在硬件设计阶段就留出测试点,把SCK、MOSI、MISO、CS四根线引出来,方便后续在产线上做Flash读写测试。
另外就是Flash器件的采购渠道,建议尽量用正规代理或原厂渠道,不要为了省几分钱走不可控的货源。SPI Flash市场上有不少翻新片和散新片,虽然便宜,但擦写寿命和可靠性完全没有保证,我之前有个项目图省事买了一批便宜的Flash,结果老化测试时坏片率接近2%,后面全部返工,省下的钱远不够赔返工成本的。这一点在产品选型和采购阶段就要注意,不要只顾BOM表价格。
3. 下载方案的整体架构与固件格式
3.1 两种主流架构:搬运执行与升级暂存
CW32L012这种Cortex-M0+平台上做串行Flash下载方案,最核心的是想清楚固件和Flash之间怎么组织。我总结下来有两种主流架构,适用场景不同,也可以组合使用。
第一种是“上电搬运执行”架构。这种方案的思路是,完整应用固件存放在外部Flash,片内Flash只放一个精简的Bootloader,MCU上电先跑Bootloader,Bootloader初始化SPI后从外挂Flash指定地址读取固件内容,写入片内RAM的指定区域,然后跳转到RAM里的应用固件入口执行。这个架构适合固件总大小不大、片内RAM容量能够容纳固件体积的场景。CW32L012的RAM相对有限,所以这个架构更适合固件量在十几KB以里的小型应用。好处是片内Flash几乎不需要预留应用空间,坏处是RAM占用大,而且每次上电都要做一次搬运,上电到真正执行应用的延迟会长一些。
第二种是“OTA升级暂存”架构。应用固件正常存放在片内Flash运行,OTA升级时新固件先通过串口、无线等方式拉到外部Flash暂存,完整接收并校验无误后,Bootloader再在重启后把外部Flash里的固件搬运到片内Flash,完成覆盖。这个架构把外部Flash当作升级过程的“安全缓冲区”,对RAM占用很小,就一个搬运缓冲而已,而且天然支持升级失败回滚——只要保留上一版固件,搬运到一半出错了,Bootloader可以从备份区域重新把旧固件搬回来。我实际项目中采用的就是这个架构,低功耗设备的升级频率不高,但对升级安全性要求很高,宁可慢一点也不能把设备搞成砖头。
CW32L012本身RAM不大,直接执行外部固件的场景受限制比较多,所以我的建议是优先考虑第二种“升级暂存”架构,外部Flash容量大还可以顺带存设备配置、日志、升级历史等数据,利用率更高。
3.2 固件包格式的设计与CRC校验
不管用哪个架构,Bootloader都需要一个明确的固件包格式来识别和校验外部Flash里的数据。业界常见的做法是“文件头 + 固件数据 + 尾部校验”三段式,我自己的设计比较保守,文件头相对长一些,宁可多花几个字节的存储空间也要保证扩展性。下面是我在CW32L012项目里用的固件包结构体,C语言描述。
#define FLASH_PKG_MAGIC 0xA5A55A5A #define FLASH_PKG_VERSION_MAJOR 1 #define FLASH_PKG_VERSION_MINOR 0 typedef struct { uint32_t magic; /* 固定魔数,识别固件包合法性 */ uint8_t version_major; /* 主版本号 */ uint8_t version_minor; /* 次版本号 */ uint16_t reserved; /* 保留字段,对齐用 */ uint32_t total_len; /* 固件总长度(含文件头、数据、CRC) */ uint32_t app_entry; /* 应用入口地址,跳转时使用 */ uint32_t crc32; /* 针对从data[0]开始的数据区的CRC32 */ uint8_t data[]; /* 固件数据 */ } flash_pkg_header_t;有人可能会问,CRC32放在文件头里而不是放在文件尾部,接收端需要算完整包数据才能校验,放在头部反而要等收完数据才能做校验,为什么这么设计。我的理由有两点,第一,放在头部可以让Bootloader在数据区接收完成前就保留住“目标CRC”和长度信息,方便做分段接收和断点续传;第二,文件尾部如果损坏或者接收不完整,头部的CRC依然能帮助定位问题。CRC算法我建议用硬件CRC外设或者查表法,M0+平台软件算CRC32如果逐位处理会比较慢,查表法几百字节缓冲一次也能接受。实际测试下来,用查表法处理512字节缓冲的CRC计算时间在微秒级,完全不影响下载性能。
固件包内容在生成阶段由上位机完成打包,上位机读取编译生成的bin文件,拼接文件头,计算CRC,生成最终可下载的固件包。这条链路要保证和Bootloader侧的解析逻辑完全一致,否则下载成功但跳转失败,排查起来就非常痛苦。
3.3 Bootloader与App的存储空间划分
做好存储空间划分才能让架构跑起来,这块要写清楚,新手特别容易在这里出问题。CW32L012的片内Flash分区域规划,Bootloader区域、应用区域、配置参数区域各司其职。我的建议是把Bootloader放在片内Flash最低地址区,因为MCU上电默认从0x00000000附近开始执行,应用区放在Bootloader之后,预留足够的对齐空间,Flash操作通常是按扇区擦除的,应用区起始地址最好对齐到扇区边界。外部Flash的布局要看用途,如果走升级暂存架构,把外部Flash的开头区域划给升级包暂存区,后面区域划给参数存储和日志记录,中间留出一些冗余空间备用。
中断向量表的问题也要提一下。应用固件如果放在片内Flash的非零地址运行,需要在应用初始化阶段把中断向量表重映射到应用的起始地址,Cortex-M0+内核通过设置向量表偏移寄存器来实现。同时,无论Bootloader还是应用,都要在工程配置里设置正确的链接地址,Keil或者GCC都支持通过分散加载文件或者链接脚本来指定代码段的起始地址,这一步做错的话,下载进去的程序跑起来就会莫名奇妙地进HardFault或者直接不执行。
4. 实操过程:从编译到下载的完整流程
4.1 工程配置与Bootloader侧代码实现
我建议把Bootloader和应用固件分成两个独立工程,不要在一个工程里混着编,否则链接地址和启动文件很容易相互干扰。Bootloader工程的启动文件保持默认,从0x00000000开始,但不会跳转到应用,而是在main函数里初始化时钟、SPI和串口,然后检查外部Flash中的数据状态,决定是搬运还是等待下载。应用工程则要把链接起始地址改到Bootloader结束之后的地址,并且在系统初始化早期执行中断向量表重映射。
下面这段代码是Bootloader侧读取外部Flash头部、校验CRC、然后搬运到片内Flash的核心逻辑,按实际驱动接口做了简化,但流程和真实代码一致。
int boot_check_and_load(void) { flash_pkg_header_t hdr; /* 从外部Flash起始地址读取固件包头部 */ if (spi_flash_read(EXT_FLASH_FW_ADDR, (uint8_t *)&hdr, sizeof(hdr)) != OK) { return ERR_FLASH_READ; } /* 检查魔数 */ if (hdr.magic != FLASH_PKG_MAGIC) { return ERR_BAD_MAGIC; } /* 检查总长度是否合法,防止越界 */ if (hdr.total_len < sizeof(hdr) || hdr.total_len > MAX_FW_SIZE) { return ERR_BAD_LEN; } /* * CRC32计算范围是从文件头之后的数据区开始, * 长度对应hdr.total_len减去文件头本身, * 需要分块从外部Flash读取后计算。 */ if (calc_flash_crc32(EXT_FLASH_FW_ADDR + sizeof(hdr), hdr.total_len - sizeof(hdr)) != hdr.crc32) { return ERR_CRC; } /* 通过校验后,分块搬移到片内应用区 */ if (spi_flash_to_internal_flash(EXT_FLASH_FW_ADDR, APP_FLASH_START_ADDR, hdr.total_len) != OK) { return ERR_COPY; } return OK; }实际项目里,搬运函数内部用的是512字节的RAM缓冲,从外部Flash读一块就擦写片内Flash对应区域一块,而不是一次性把整个固件读进RAM,毕竟MCU RAM有限。擦写片内Flash时要注意关闭中断,防止执行Flash操作期间产生中断导致操作失败。这个细节很关键,加在注释里提醒一下。
4.2 上位机下载流程与传输协议
固件准备好之后,生产阶段和研发阶段的下载方式可以不一样。研发阶段我用的是串口下载,配合一个自定义的简易传输协议,上位机把固件包分包发送,每包256字节,发完一包等待Bootloader返回ACK,收到ACK再发下一包,超时则重发。协议虽然简单,但足够可靠,也方便在串口调试助手里手动模拟。帧格式大概是“帧头 + 包序号 + 数据长度 + 数据 + CRC16”这么一组,帧头用固定字节,包序号用来做丢包检测,CRC16校验每一包数据完整性。
下载整体流程分四步:第一步上位机发送握手命令,Bootloader收到后回复版本号和可用Flash空间;第二步上位机发送固件包总长度和CRC32摘要,Bootloader确认空间足够;第三步逐包发送固件数据,每包确认;第四步Bootloader把收到的数据写入外部Flash暂存区,完成后再从外部Flash回读整包并计算CRC32,和上位机声明的CRC比对,确认无错后写入“固件就绪”标志。最后一跳,Bootloader复位后检测到“固件就绪”标志,执行上一节说的搬运逻辑。
这里有一个经验可以分享,Bootloader在进入下载模式前要有超时判断,如果上电后3秒内没有收到上位机的握手命令,就直接跳转到现有应用运行,否则设备一旦写入了损坏的固件包,又没人来救援,就只能一直卡在Bootloader里出不来。超时设计是防止设备变成砖头的最简单也最有效的手段。
4.3 批量生产时的脱机下载方案
研发阶段用串口下载没问题,但产线上几百台设备一台台用电脑下载效率很低,建议使用脱机编程器直接把固件包写入外部Flash,或者先用脱机编程器烧录Bootloader和出厂固件到MCU片内Flash,然后再把升级用固件包写到外部Flash。脱机编程器的好处是速度快、不依赖上位机、一致性高,缺点是设备投入成本,看项目体量自行权衡。
如果产线已经有一台支持SPI Flash脱机烧录的编程器,可以先把外部Flash烧录好,再贴片焊接,也可以预留测试点,在板级测试阶段用夹具烧录。量产阶段建议把“下载方案”做成两步走的策略:第一步烧录出厂固件(含Bootloader),做基本功能测试;第二步再通过串口或无线做一次OTA验证,确保生产出来的设备具备远程升级能力。这样既能保证量产效率,也能提前发现升级链路的问题。
5. 常见问题与排查技巧实录
5.1 最容易翻车的几个坑
这个方案我前前后后调试了一个多月,踩过的坑不少,挑几个典型的拿出来说,这些细节在官方文档里基本不会写,但实际开发中很容易中招。
第一个坑是擦写Flash时看门狗复位。Bootloader在搬运固件或者擦除外部Flash时耗时较长,如果看门狗没有及时喂,整个下载过程就会被打断,表现为下载到一半设备重启。解决方案是在Bootloader的下载流程里做“节拍喂狗”,每处理完一个包或每完成一个扇区擦写就喂一次狗,同时把看门狗超时时间适当调长,确保能覆盖最慢的擦写操作。还有一点,调试阶段尽量先把看门狗关掉,等稳定后再打开,否则问题会被看门狗掩盖,排查方向完全跑偏。
第二个坑是Flash写保护位。有些Flash芯片出厂时状态寄存器里的写保护位是默认开启的,直接执行擦写指令会返回失败,但很多驱动代码不会检查状态寄存器返回值,导致问题隐藏得很深。最典型的现象是能正常读Flash,但写进去的数据一校验就错,重新上电后内容也没变化。解决办法是初始化时先读状态寄存器,清掉写保护位,再执行一次空擦除验证是否真的解锁了。硬件上把WP引脚上拉不是万能的,软件解锁才是最可靠的手段。
第三个坑是中断向量表没重映射,应用跑起来后就疯狂HardFault。我刚把应用固件从0x00000000搬到偏移地址时,忘了处理向量表,应用初始化一进中断就死,调试了好久才定位到是向量表偏移没设置。CW32L012的Cortex-M0+内核向量表偏移寄存器有对齐要求,应用起始地址需要按一定大小对齐(具体数值看参考手册),如果用链接脚本设置偏移,一定要确认地址对齐方式。
5.2 问题排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 下载到一半设备重启 | 看门狗超时、电源不稳 | 检查喂狗逻辑,给Flash加去耦电容,示波器量电源纹波 |
| Flash可以读但写不进数据 | 写保护位未解锁、WP引脚被拉低 | 读状态寄存器,检查WP上拉,软件清保护位 |
| 数据写入了但校验不一致 | 时钟频率过高、SPI时序不对 | 降低SPI时钟,加串阻,查信号质量 |
| 上电后应用没跑起来 | Bootloader超时机制误判、搬运失败 | 检查外部Flash起始地址和头部魔数,加调试串口打印 |
| 应用一进中断就HardFault | 中断向量表未重映射或对齐错误 | 检查向量表偏移寄存器配置和地址对齐 |
| 下载过程CRC总是错误 | 波特率误差高、传输不稳定 | 降低串口波特率,检查外部晶振精度,逐包打印序号定位 |
5.3 几个提升体验的优化建议
把基础流程调通之后,还可以做一些体验上的优化,这些不是必须项,但做了之后整个系统会健壮很多。第一是增加下载断点续传,上位机记下已经发送成功的包序号,重新握手后从断点继续发,大固件包在弱信号环境下尤其有用。第二是加入版本比较逻辑,Bootloader在握手阶段把当前固件版本和外部Flash中固件版本比较,如果版本相同就跳过搬运,避免重复擦写片内Flash,延长Flash寿命。第三是预留回滚区域,片内Flash如果空间允许,把旧固件保留一份,新固件搬运失败时自动切回旧版本,设备不会死掉。
还有一个小心得,Bootloader里的调试串口打印信息要在量产固件里保留,但等级调到最简,只打印关键状态码,方便售后人员拿到设备后通过串口输出快速判断问题。研发阶段打印一大堆没关系,量产固件里打印太多会影响下载速度和功耗,精简到“Boot OK”和错误码即可。
6. 结尾:这套方案在量产中的实际体会
这套CW32L012串行Flash下载方案从原型验证到量产,前后迭代了三轮固件,整体表现很稳定,量产烧录成功率在正常环境下接近100%,偶尔出现的下载失败也基本都能定位到硬件虚焊或者Flash芯片批次问题上。我个人最大的体会是,下载方案看似只是写一段Bootloader、接一颗Flash那么简单,但真正决定成败的往往是那些不起眼的细节,比如写保护位、看门狗、中断向量表对齐、电源去耦,每一个都能让整条链路翻车。如果你也正在做类似的项目,建议先按文中的架构做最小验证,把Bootloader下载链路跑通,再加入业务功能,不要一上来就堆功能,否则出了问题很难定位。最后再分享一个小技巧,所有调试信息哪怕量大,都先保留着,等系统稳定了再统一裁剪,不然排查问题时没有输出,就只能一直对着示波器怀疑人生了。