
上个月把一套基于GD32L233的OTA方案搬到GD32L235上原本想着同系列芯片Bootloader和App包直接换个型号就能跑结果联调测试时连续踩了几个跟Flash读写强相关的坑。最直观的一个L233上按2KB页对齐的分区表搬过去后App跳转反复失败还有个升级中途掉电恢复的用例L233能正常回滚L235却卡在启动阶段。排查到最后问题都出在内部Flash的页大小、擦除粒度、读保护行为和等待周期配置上。这篇内容适合正在用GD32L233/235做OTA功能的人尤其是打算从L233往L235迁移、或者同系列里不同型号间复用OTA工程的同学。我会把两款芯片在Flash读写上的关键差异点逐一拆开讲包括分区规划、读操作、写擦除操作、OTA执行链路里的坑以及最后一张可以直接抄作业的迁移检查清单。1. 为什么同样的OTA流程换个型号就要重新审查Flash读写1.1 OTA对Flash读写的依赖远超“能擦能写”OTA这件事表面上只是“下载新固件、写入Flash、重启跳转”但实际健壮性几乎全部压在Flash读写的细节上。比如接收升级包时你是边收边写还是收完再写边收边写就涉及页对齐、擦除时机、写失败回滚收完再写就需要足够的外部存储或RAM缓冲。再比如写入完成后你要在Flash里记录一个“固件有效”的标记这个标记的写入位置、写入方式、掉电安全性都取决于Flash的最小擦除单位。还有启动校验Bootloader要从Flash里读出版本号、CRC、升级状态不同芯片的读取时序如果没处理好上电瞬间读到的可能是旧值或者被预取缓冲误导的值。这些在单芯片上调试时不容易暴露一换型号就全来了。很多开发者觉得Flash操作就是调几个标准库函数写之前擦一下写之后等一下能跑就行。但OTA场景里Flash读写会被放在一个长时间、多步骤、可能掉电的流程里反复执行任何一步对Flash特性的假设不成立最后都会变成现场偶发故障。比如你对页大小的假设是2KB换到4KB页的芯片后分区表里一大半地址都失去意义。1.2 同系列不同型号FMC控制器未必完全一样GD32L233和GD32L235都叫GD32L系列内核也都是Cortex-M23很多人潜意识里会把它们当成同一个东西。但Flash控制器不是光看内核就能确定的。芯片的设计目标、Flash容量、工艺调整都会让FMCFlash Memory Controller的寄存器和行为产生细节差异。最典型的就是页大小L233的页大小是2KBL235样片是4KB页这直接导致页擦除的最小单位变大地址必须按新的页边界对齐。另外等待周期数、预取缓冲大小、读保护级别、写保护寄存器每bit对应的页数都可能有变化。把这些差异全部忽略直接复制工程大概率会出现我开头说的现象编译下载都正常跑OTA就出问题。这里有个经验同一个系列的不同型号外设寄存器通常“长得像”但不一定完全一样。你在L233上用的是FMC_KEY、FMC_CTL、FMC_WS这套命名到L235上大概率还是这套命名但寄存器位定义、某些保留位、状态位的位置可能挪过。所以迁移时不能只查数据手册的容量表要把参考手册里的FMC章节从头到尾过一遍。1.3 迁移项目中最容易忽视的三个变量我自己排查下来最容易埋雷的是容量、页大小和保护机制这三项。容量决定了你能不能做双备份页大小决定了分区怎么对齐保护机制决定了调试器和Bootloader的交互方式。它们不像主频和外设信息那样醒目往往藏在参考手册的“Memory Organization”和“Flash Memory”章节里。做迁移审查时这三个变量必须逐项核对而不是只看Flash总容量够不够。后面我会把每一项展开讲并给出一张可以直接对照的检查表。2. 分区规划差异页大小与擦除粒度决定OTA分区怎么画2.1 先分清page、sector和bank再谈分区在做OTA之前必须确认FMC支持的最小擦除单位到底是什么。有些芯片叫page有些叫sector有些还有bank的概念。很多人把这三个混在一起去论坛提问说“我这芯片Flash擦除怎么不按扇区来”十有八九是没看参考手册的存储组织结构。page一般是擦除的最小单位bank是一组扇区的组合Flash控制器本身还可能分主存储区和信息区如选项字节区。对于GD32L233/L235你要关心的是主存储区的页大小、页数量、各bank起始地址。这些信息在参考手册的Flash Memory一章通常有表格一行一行列清楚。一个很容易犯的错直接把编译器里的“Flash Size”当成页大小的依据。编译器只关心总容量和地址范围它不会告诉你擦除一页要多久、写保护寄存器一个bit管几页。这些信息只有从参考手册或寄存器描述里拿。我建议拿到新芯片后先把下面这个表自己填一遍主Flash基址通常0x08000000最大容量xxKB页大小xxKB页擦除时间xx ms全片擦除时间xx ms编程最小宽度半字/字/双字写保护最小粒度每bit对应几页2.2 两款芯片在页大小上的实际差异以我手头样板为例具体数值请以你手里的数据手册为准L233的主Flash页大小是2KB整片容量从64KB到256KB不等L235样片页大小是4KB容量最大到512KB。乍一看L235容量更大是好事但页变大也有代价分区的最小粒度变大App镜像尾部可能要填充更多无效字节。更关键的是页擦除一个4KB页和擦除一个2KB页的时间不同这个后面会讲。编程操作上两款都支持按32位字编程但擦除命令、忙状态位位置如果有差异代码里的等待循环需要调整。项目GD32L233GD32L235样片影响主Flash最大容量256KB512KB是否支持双Slot页大小2KB4KB分区对齐、擦除粒度页擦除典型时间约20ms-30ms约30ms-40ms升级进度和超时编程最小宽度32位字32位字缓冲区写入逻辑写保护最小粒度每bit对应1页2KB每bit对应1页4KB临时解锁范围这个表只是思路参考不同批次、不同封装可能有差异。关键不是记住具体数值而是知道“这两个型号就是不一样”迁移时所有和页大小相关的计算都要重新推。2.3 Bootloader区、App区、升级暂存区怎么画容量足够时比较稳的OTA分区做法是Bootloader区、App当前区、App下载暂存区或A/B双区、参数区。分区时所有地址都必须是页大小的整数倍。比如L233页大小2KBBootloader如果不超过16KB可以占0x08000000到0x08003FFF也就是8页App从0x08004000开始。L235页大小4KB时Bootloader 16KB就只占4页下一区从0x08004000开始也一样。但如果Bootloader压缩到12KB以内L233上可以只占6页L235上因为最小擦除单位是4KB12KB仍然要占满4页16KB空间利用率就会差一些。分区表一旦因为页大小变化需要移动App镜像里所有绝对地址都会变包括中断向量表偏移、链接脚本里的FLASH起始地址、Bootloader里的跳转地址和版本标记地址。我这次迁移就踩了坑L233工程里App起始地址是0x08004000链接脚本里直接写死换到L235后没有同步改结果Bootloader从0x08008000跳过去App自己却按0x08004000的向量表执行中断全部错位。所以分区规划不是画一张图就完事必须把链接脚本、OTA包头、Bootloader跳转函数三个地方的地址统一。// 分区配置示例放在一个公共头文件里 #define APP_FLASH_START_ADDR 0x08004000u #define APP_FLASH_SIZE 0x0000C000u // 48KB #if defined(GD32L235) #define FLASH_PAGE_SIZE 0x1000u // 4KB #define BOOTLOADER_SIZE 0x4000u // 16KB #elif defined(GD32L233) #define FLASH_PAGE_SIZE 0x0800u // 2KB #define BOOTLOADER_SIZE 0x4000u // 16KB #endif #define APP_START_PAGE_ALIGNED (APP_FLASH_START_ADDR / FLASH_PAGE_SIZE)3. 读操作差异预取、等待周期与读保护对OTA的影响3.1 从Flash取指与预取缓冲跳转App时卡死的隐藏元凶很多人在Bootloader跳转App时遇到过“复位后卡死”或“随机进入HardFault”排查半天发现不是App代码问题而是Flash读取相关的配置残留。CPU访问Flash的等待周期数和预取缓冲使能位是在时钟初始化时配置的L233和L235支持的等待周期策略不一定相同。如果Bootloader初始化时按L233的Flash频率配置了预取和等待周期跳到L235后时钟频率不同Flash读取时序就可能不对。比较稳妥的做法是跳转前不依赖原有的预取配置先把SysTick、外设中断都关掉执行__DSB()和__ISB()再重新设置向量表和MSP。如果两款芯片的等待周期寄存器位定义不同跳转后App的SystemInit会重新初始化时钟一般能覆盖回来。但你要保证跳转前这段代码本身是稳定的不要在关中断后还访问容易出问题的外设。static void jump_to_app(uint32_t app_addr) { uint32_t msp_value *(volatile uint32_t *)app_addr; uint32_t reset_vector *(volatile uint32_t *)(app_addr 4); // 基础合法性检查 if ((msp_value 0x2FFE0000) ! 0x20000000) { return; // MSP不在RAM区放弃跳转 } if ((reset_vector 0x1FF00000) ! 0x08000000) { return; // 复位向量不在Flash区放弃跳转 } __disable_irq(); SysTick-CTRL 0; __DSB(); __ISB(); SCB-VTOR app_addr 0xFFFFF800u; __set_MSP(msp_value); __enable_irq(); ((void (*)(void))reset_vector)(); }3.2 读保护级别不同对OTA的限制也完全不同读保护一直是OTA项目里容易被忽略的一环。L233的读保护级别我印象里主要在Level0和Level1之间切换Level1下通过调试器读Flash会被限制但用户代码自己读还是允许的。L235如果支持Level2那一旦使能基本上就是永久保护任何方式都读不出来也没有办法通过软件解除。这个差异对OTA最直接的影响是出厂固件烧录流程产线可能要先用调试器烧Bootloader和首次App再开启读保护如果开成Level2后期想通过OTA把App降级回调试版本就做不到了。这里还要区分读保护和写保护读保护限制的是“读出”写保护限制的是“擦除/改写”。OTA需要Bootloader能擦写App区所以App区不能被写保护同时如果Bootloader区允许运行期改写还得小心别被异常程序把Bootloader擦掉。实际调试时如果IDE报flash download failed - target dll has been cancelled很多人第一反应是连接问题其实常见原因之一就是芯片处于Level1读保护状态调试器无法重新写入Flash。处理办法通常是先用串口或厂家的工具解除读保护或者设置IDE在连接前先做unprotect。这个坑在L233和L235上都会遇到但错误的解除方式可能导致整片Flash被擦除。3.3 写入后读回与缓存一致性的注意点内部Flash一般没有复杂的cache但像GD32L233/235这类低功耗MCU为了提高取指速度可能会带预取缓冲或指令缓存。如果带缓存Flash写入后刚写入的数据不一定立刻从数据总线读到最新值。官方库里面通常会在编程操作完成后做一次缓存无效化或者重新读取确认。如果你在OTA代码里边写边校验比如写完一个page后立刻读回比对遇到和写入值不一致的情况先不要急着怀疑Flash坏了检查一下是否需要对缓存做invalidate。我处理过不少“写进去读出来全是0xFF”的问题最后都是缓存或等待时间不够导致的而不是颗粒问题。尤其是边写边校验的场景写入后最好加一条__DSB()再读回确保CPU侧的写缓冲已经flush。如果是带指令cache的型号可能还需要调用库提供的cache reset接口。迁移到新型号时这个动作容易漏因为L233上不调用也能读对L235上就可能偶发不对。4. 写和擦除操作差异状态轮询、写保护与时间预算4.1 解锁、编程和擦除的命令流程GD32L系列的内部Flash编程和擦除需要先向FMC_KEY寄存器依次写入两个解锁键值然后操作控制寄存器里对应的位。L233和L235的基本思路一致但寄存器位定义、状态位的位置可能有变化。写一个word数据前先确认目标地址所在页是擦除状态没有擦除的Flash写操作不会得到预期值擦除后再把FMC_CTL的PG位置1执行一次32位写入。擦除页时把PER位置1写入目标页地址再置START位触发。整个过程要轮询BSY位直到操作完成。这类底层操作我不建议自己在业务逻辑里重新实现直接用官方固件库提供的函数然后确认库版本对应的是哪个型号。有些项目从L233迁移到L235时固件库文件还是老的FMC驱动编译能过但行为不对就是因为库函数内部使用的寄存器基址或者宏定义不匹配。4.2 擦写时间差异对升级进度和超时策略的影响页擦除时间看起来只有几十毫秒但在OTA里会被放大。假设App固件64KBL233每页2KB需要擦除32页如果每页30ms单擦除阶段就要约1秒L235每页4KB同样64KB只要擦除16页但单页40ms的话也要约0.64秒。关键是升级时通常还要写入写入时间按页算也要逐个等BSY。如果你的升级超时设得太紧比如从网上抄来的代码里写死“等待BSY最多100ms”或者“每包超时200ms”在页擦除时间不同的芯片上就可能偶发超时失败。我建议把Flash操作的超时设计成可配置项最好基于实际测量值再加3到5倍余量。进度条的计算也要根据页大小来不要写死“总数容量/2048”而要用PARTITION_SIZE / FLASH_PAGE_SIZE。同时擦写期间的看门狗要记得喂擦除操作虽然不长但整个升级过程中如果反复擦写几十页喂狗时机不对会直接被复位。// 等待BSY超时按实际测量值余量放大 static uint32_t flash_wait_busy(uint32_t timeout_ms) { uint32_t start get_tick_ms(); while (fmc_flag_get(FMC_FLAG_BSY) SET) { if (get_tick_ms() - start timeout_ms) { return 1; // 超时 } } return 0; }4.3 写保护的粒度与临时解除策略写保护是把双刃剑。OTA要求Bootloader能改写App区所以App区不能长期处于写保护状态否则IAP会失败。但产品出厂时又想防止App区被随意改写至少想防止调试器乱搞。这就需要在Bootloader进入升级模式后先临时解除目标区域的写保护升级完成后再重新使能。L233和L235写保护寄存器里每个bit对应的页数如果不同解除保护时的掩码计算就会不同。假设L233是每bit一页2KB你要解除32KB区域就得写16个bitL235如果每bit一页4KB同样32KB只要写8个bit。如果迁移后沿用旧的保护掩码可能出现“想保护的区域没保护上”或者“误保护了别的区域”。另外很多芯片在修改写保护选项字节时并不是改了寄存器立刻生效而往往需要系统复位甚至把整片Flash都擦掉。我个人的习惯是不要在正常业务运行时不必要地开关写保护只在进入明确的OTA升级状态时再做并且要在升级界面提示用户不要断电。5. OTA执行链路上最容易翻车的三个Flash细节5.1 擦写期间代码执行位置的选择内部Flash在擦写时Flash控制器忙同一时刻从该Flash取指可能被挂起或引入不确定延迟。如果擦写函数本身放在Flash上执行页擦除时指令预取可能会卡住轻则擦写时间变长重则看门狗复位。更稳的做法是把Flash驱动函数放到RAM里执行或者至少把擦除过程中最关键的循环体放到RAM。GD32L233/235的RAM不大但放一个几百字节的Flash驱动没问题。很多SDK里已经提供了类似的RAM执行函数如果没有就自己在链接脚本里加一个section把对应的函数声明到RAM段。这里有一点要注意如果RAM区域和Flash区域之间有总线仲裁擦写Flash时从RAM取指也可能等待但通常比从Flash取指好得多。我做过一个实测纯粹从Flash执行擦页函数偶发耗时是RAM执行的2到3倍在OTA超时临界状态下这种偶发就会变成升级失败。5.2 中断向量表重映射在L233和L235上的实现差异Cortex-M23内核提供VTOR寄存器用来设置中断向量表地址。Bootloader跳转App之前应该把VTOR设置为App区起始地址。需要注意两点。第一是对齐VTOR要求向量表地址按中断向量数对齐通常至少要按64或128字节对齐具体看参考手册分区时最好直接把App起始地址定在页边界上顺便满足VTOR对齐。第二是向量表首两个字分别是初始MSP和复位向量跳转时先关闭全局中断然后取App首字的MSP赋给主栈指针再取复位向量地址确认地址合法后跳转。L233和L235在VTOR的行为上基本一致但如果你同时开了读保护或者Bootloader区和App区有特殊映射实际跳转会受影响。比如某些型号Bootloader区可以用选项字节映射到0x00000000这个映射会改变地址解析方式跳到App前需要把映射改回来。这一部分最容易出现“在L233上能跳在L235上跳过去就跑飞”的情况。排查时在跳转函数里加几个断言检查App首字是否为合法RAM地址范围检查复位向量是否落在App区范围内检查当前MSP是否8字节对齐。5.3 掉电恢复与备份区写入顺序设计OTA升级最怕的是写固件写到一半掉电。很多人的方案是先在Flash最后找一个固定参数区写一个“升级中”标志固件写完后把标志改成“升级完成”。这个思路是对的但实现上有个坑很多MCU的Flash只能整页擦除如果你把标志位和别的参数混在同一个页里每次更新标志都要擦掉整页一旦中途掉电其他参数也跟着丢。我建议把OTA标志单独放到一个独立页或者放到两个备份页交替使用。写入顺序上优先写数据区数据全部校验完成后最后写“新固件就绪”标志。为什么要最后写因为标志本身就是一个原子提交点只要标志没写成功Bootloader就认为旧固件仍然有效可以继续启动旧App或者自动重试下载。反过来如果先写标志再写数据掉电后Bootloader认为有新固件实际数据是残缺的就得靠额外的回滚机制去恢复。这个提交点思想在做L233/L235迁移时也要保持一致只是写标志时要注意页大小变化保证标志页地址按页对齐。6. 从L233迁到L235的Flash读写代码检查清单6.1 十项自查表为了这次迁移我整理了一张检查表每次换芯片型号都拿出来对照一遍。这张表不局限于GD32L233/235任何同一系列不同子型号的迁移都能用。检查项需要确认的内容我踩过的对应问题页大小读手册Memory Organization章节确认最小擦除单位分区表对齐错误跳转失败Flash起始地址和大小确认主Flash基址与容量上限链接脚本写死旧容量编译越界编程最小宽度确认支持8/16/32位写入中的哪些缓冲区按16位写到32位芯片校验失败页擦除时间实测或查手册确认超时余量超时设太短OTA偶发失败等待周期/预取确认系统时钟下Flash等待周期配置跳转后随机卡死读保护级别确认Level0/1/2支持情况调试器连不上报flash download失败写保护粒度确认每bit对应几个页保护掩码算错误保护App区缓存一致性确认FMC是否带cache写入后是否需要invalidate写后读回全0xFF跳转前外设清理确认需要关闭的外设和中断跳转App后外设状态冲突掉电标志页确认标志页地址按新页大小对齐标志和数据混页掉电丢参数6.2 一套驱动适配两款芯片的封装思路如果不想在L233和L235之间维护两套Flash驱动可以做一个小的适配层。把差异点集中在几个宏或者配置结构体里比如FLASH_PAGE_SIZE、FLASH_PAGE_COUNT、FLASH_APP_START_ADDR、FLASH_FLAG_ADDR、等待周期配置函数指针。分区表用统一的结构体来表示Bootloader和App都引用同一份头文件。这样在迁移到L235时理论上只需要替换配置头文件里的几个宏定义再重新验证一遍Flash底层测试用例。typedef struct { uint32_t page_size; uint32_t app_start_addr; uint32_t app_max_size; uint32_t flag_addr; uint32_t (*wait_busy)(uint32_t timeout_ms); } fmc_cfg_t; const fmc_cfg_t *fmc_get_config(void) { #if defined(GD32L235) static const fmc_cfg_t cfg { 0x1000, 0x08004000u, 0x40000u, 0x0807F800u, flash_wait_busy }; return cfg; #else static const fmc_cfg_t cfg { 0x0800, 0x08004000u, 0x20000u, 0x0803F800u, flash_wait_busy }; return cfg; #endif }我建议至少写三个底层测试用例页擦除回读、跨页写入回读、擦写100次稳定性测试。迁移完成后先在开发板上跑完这三个用例再去做OTA联调。特别是稳定性测试能暴露写保护解除失败、缓存一致性这些偶发问题。6.3 最后一点实际体会这次从L233迁到L235的整个过程下来我的体会是换个芯片型号看着是小改动但Flash这类基础模块的差异影响的是整个OTA架构的判断。就算两款芯片来自同一系列、同一个厂商也要抱着“重新读一遍参考手册”的心态去处理。不要偷懒直接复用旧工程否则排查的时间会比重新适配长得多。最后再分享一个小技巧在适配层的配置头文件里放一个版本宏例如FLASH_ADAPTER_VERSION L235_1.0Bootloader和App启动时都把当前Flash参数通过串口日志打出来。这样现场拿到一台设备先看日志里的页大小和分区信息就能立刻确认固件到底跑在哪个适配参数下排查OTA问题会省很多事。