
我做28377D的Bootloader已经有一段时间了。最开始被问“要不要做CAN在线升级”时心里多少有点抗拒——又不是不能插JTAG搞一套传输协议、Flash擦写、双核联动工作量不小。直到某个凌晨的生产现场教育了我设备装进IP65控制柜、客户划好作业区软件要更新只能停机开柜、拆总线上所有从站再小心翼翼接仿真器。那次之后我老老实实把基于CAN总线的28377D在线升级方案做完了做了A/B分区、断点续传、掉电恢复实测下来升级一台带双核的电机控制器从“拆盖板烧录装配验证”的一个小时变成了一条CAN命令的事。这篇文章把我踩过的坑和最终确认可行的方案完整写出来包括Flash分区、CAN协议、擦写细节、双核协同和回滚策略给正在做C2000系列Bootloader的朋友一个可以直接参考的路线。1. 从现场拧螺丝到远程刷写为什么是CAN总线1.1 传统升级方式的隐性成本先说几个我在现场遇到过的真实场景。第一类产品已经装进机柜外壳带电且空间狭小想接仿真器得先断电、开锁、拆卸挡板有些防爆场合还要办作业票整个过程最快40分钟第二类设备分布在厂区不同角落最远的一台得扛着笔记本走一公里第三类客户自己不敢操作远程支持只能靠电话指挥一次升级折腾一整天。这些看似“只是麻烦”的流程落到产线上就是停机损失很多时候比设备本身还贵。所以在线升级的核心目标不是省掉一根线而是把“需要人到现场、冒着接错线风险、长时间停机”的物理操作变成“一条远程命令、几秒钟总线传输、一次自动校验”的软件操作。1.2 为什么最终选了CAN而不是SCI或者别的28377D本身自带两个DCAN模块支持CAN 2.0B这在选型上已经是现成的硬件基础。更重要的是这些控制器在现场几乎必然已经挂在一条CAN总线上——和上位机、变频器、HMI通信都用CAN。利用现有总线做升级不需要新增任何接线不需要加PHY芯片也不改变网络拓扑。我简单列了一个对比你们感受一下升级通道额外硬件速率可靠性现场实施难度JTAG/仿真器需要仿真器、调试接口高高必须到柜内拆装繁琐SCI/UART需要引出串口115200bps左右无硬件链路校验/重传机制外壳留有防水接头才行CAN无需新增500k-1Mbps硬件CRC15总线仲裁复用现有总线即可Ethernet需要外部PHY高依赖网络配置28377D一般不带以太网控制器我实际项目里定的是500kbps和现场原总线速率保持一致不需要改其他节点的波特率。实测128KB的双核镜像加上擦除和校验整个升级窗口压在10秒上下对产线来说完全可接受。1.3 芯片ROM里自带CAN Bootloader为什么还要自己写28377D的Boot ROM默认确实支持通过CAN下载程序第一次烧写空片时很好用。但直接用它的原厂Bootloader做产品级升级至少有这么几个问题协议是死的不支持你自己的帧格式和自定义命令缺少端到端32位CRC传输过程中如果某帧被篡改不容易发现不支持A/B双镜像回滚升级失败就得重新进Boot模式再来一遍最关键的是没法联动双核——28377D的CPU1和CPU2各有一份固件如果升级过程只处理了CPU1CPU2还跑旧版本那简直是灾难。所以我的结论是空片烧录用原厂Bootloader可以产品在线升级必须自己设计一套升级协议和Bootloader。这也是后面所有内容的起点。2. 28377D升级的硬件底子双核、Flash与Boot路径动手写代码之前我先把28377D的硬件底子摸了一遍因为后面所有分区和跳转逻辑都建立在它之上。2.1 Flash布局怎么规划先说28377D的Flash底子。它有两个Flash Bank地址上整体连续每个Bank内部又分成若干SectorSector尺寸从低地址到高地址逐渐变大。一个典型的区域规划可以是功能起始地址示意长度说明Bootloader0x08000032KB包括启动引导、升级服务App A区运行区0x088000256KB当前运行版本App B区备份区0x0C8000256KB存放新固件或回滚旧版参数/标志区0x10800032KB升级标志、版本号、CRC这里的具体地址要根据芯片手册的Sector表重新确认不同批次型号可能有差异但思路是固定的Bootloader占一块独立的、不会被App覆盖的SectorApp要留两个区给双镜像切换参数区存升级状态保证掉电后状态可恢复。2.2 从Boot ROM到用户Bootloader的启动路径28377D复位后Boot ROM会先跑一小段代码根据芯片引脚状态决定从Flash、RAM、SCI还是CAN启动。我们产品固定配置成Flash启动这样复位后Boot ROM就把控制权交给存放在Flash起始位置的用户Bootloader。Bootloader拿到控制权后要做的第一件事不是赶紧跳App而是判断“该进入升级服务模式还是直接跳运行区”读取参数区的升级标志和当前有效Bank标志如果标志显示有未完成的升级任务就停在升级模式等待CAN命令如果一切正常就校验有效Bank的镜像头校验通过后跳转。2.3 链接脚本与出口地址Bootloader和App是两套独立的工程各自有cmd文件。Bootloader把.text放在0x080000App的代码段则放到0x088000起。这个边界必须要严格对齐Flash Sector边界否则擦除时会殃及邻居。App链接时还要额外注意两点第一个是镜像头结构体我会在App开头放一个固定结构体包含magic、版本号、CRC32、入口地址Bootloader跳转前先检查这个结构体第二个是PIE向量表App启动时要把PIE向量表重新装载到自己工程的中断服务函数地址而不是沿用Bootloader的。这里如果漏掉App跑起来后所有中断都会飞到错误的地方现象就是莫名其妙复位或者死机。2.4 一个容易忽视的边界问题擦除Flash是按Sector粒度的。如果你的App A区和B区之间、或者B区和参数区之间没有对齐到Sector边界那其他区域的代码和数据就可能被一起擦掉。我的建议是分区前先把Flash的Sector尺寸表打出来把每个边界都用“起始地址 % SectorSize 0”验证一遍。这个检查写成脚本自动跑别靠肉眼。分区和链接脚本解决的是“放在哪”的问题接下来要解决的是“怎么传”。3. 自定义升级协议8字节帧里装下整个固件3.1 帧ID与命令设计CAN标准帧的数据场只有8字节扩展帧ID场有29位这是我设计协议的主要空间。我的做法是把ID按“功能类型序号”拆分数据场用来放命令参数或固件数据。方向帧ID示意含义主机-设备0x10000001升级请求带版本对比主机-设备0x10000002开始传输总长度、CRC32主机-设备0x1000xxxx数据帧低16位为帧序号主机-设备0x10000003校验请求主机-设备0x10000004跳转/回滚命令设备-主机0x20000001ACK下一帧序号设备-主机0x20000002NACK错误码设备-主机0x20000003进度/状态上报数据帧里8字节全部放固件内容帧序号放在CAN扩展ID低16位。这样充分利用了每帧的载荷又不会挤占数据空间。最后一帧不足8字节时用0xFF填充接收方根据总长度判断有效字节数。3.2 块校验与帧校验的取舍一开始我做的是每帧都等ACK再发下一帧逻辑简单但500kbps下有效吞吐只有一半左右128KB跑下来要20多秒而且总线上一半是应答帧很容易被其他节点干扰。后来改成块确认每256帧2KB接收方累积算一次CRC32块校验把结果回给主机主机再对校验失败的块做重传。这样整个升级过程的重传单位是“块”而不是“单帧”效率高很多。最终的校验链是三层CAN硬件CRC15负责物理层校验块CRC32负责传输过程纠错镜像整体CRC32在全部传完后做一次端到端确认。三层各司其职哪个都不能省。特别是整体CRC防止上位机软件生成文件时出错或者块校验接口有bug导致错帧排列。3.3 通信状态机与超时设备端Bootloader的升级服务状态机大概是这样的WAIT_CMD、RECV_START、ERASE_FLASH、RECV_DATA、VERIFY_IMAGE、JUMP_APP。每个状态都有超时上限比如RECV_DATA状态如果超过5秒没收到任何有效帧就回NACK并保持等待但不清除已写的Flash跳转前如果校验失败则停留在升级模式等待重发。主机端同样要有状态机而且要对“设备离线上电重启”这类情况做处理发送数据后如果连续等不到ACK先降低发送频率再重连升级请求流程。我在现场遇到过CAN线被临时插拔的情况靠这套机制恢复连接后从断点所在块继续传不用重新擦除。3.4 关于流控的一个实用建议块确认机制下主机可以连发256帧再等ACK但这256帧在总线上是持续占用大约占0.5秒左右。如果总线上还有其他实时通信节点这个突发可能造成别人实时帧延迟。稳妥做法是每32帧插入一个短间隔1ms给其他节点让路这在小代价下对总线稳定性非常友好。协议传输解决了真正的体力活是Flash擦写。升级时我一直在心里默念千万不要在擦写的过程中出幺蛾子。4. Flash擦写与掉电安全换发动机不能熄火4.1 为什么Flash编程函数必须放到RAM执行28377D的Flash编程走的是内部Flash状态机执行编程时CPU不能从当前Flash取指令——因为正在写的可能就是控制流所在的那段代码。所以工程里必须把编程函数放到RAM的.ramfuncs段链接脚本里显式分配。实操时我会把所有Flash操作封装成一个模块直接声明成ramfuncs#pragma CODE_SECTION(flash_erase_sector, .ramfuncs) #pragma CODE_SECTION(flash_program_row, .ramfuncs) uint16_t flash_erase_sector(uint32_t sectorAddr); uint16_t flash_program_row(uint32_t flashAddr, uint16_t *dataBuf);链接脚本里加一行.ramfuncs : RAM就行。这个用原厂Flash API库也能做到库本身就带ramfuncs支持。4.2 Flash等待状态与电源裕量28377D的Flash读取和编程受系统时钟影响时钟频率越高要设置的等待状态越多。有些朋友在Bootloader里先初始化了高频率PLL但没有正确设置Flash的wait state编程偶尔会出错而且不是每次都错排查起来特别痛苦。我把时钟配置和Flash等待状态放在同一个初始化函数里每次改时钟都会对照数据手册重新确认。擦除是升级过程中功耗最高的阶段尤其擦除大Sector瞬时电流会明显抬升。如果供电链路设计得比较紧建议把“擦除多Sector”拆成一Sector一Sector地擦每擦完一个发一次进度这样既避免了长时间大电流也方便上位机做进度条。实测慢不了多少但对电源压力小很多。4.3 掉电保护的骨架升级标志区产品的升级安全底线是“断电不彻底变砖”。我的做法是在参数区固定划出一个小区域专门存升级状态结构体每次状态变更都重写。结构体大概长这样typedef struct { uint32_t magic; // 0x55AA55AA标识结构体有效 uint32_t status; // IDLE/RUNNING/DONE/FAIL uint32_t activeBank; // 当前有效BankA或B uint32_t imageCrc; // 目标镜像CRC uint32_t imageSize; // 目标镜像字节数 uint32_t version; // 目标版本号 } UpgradeState;在升级正式开始前先把status置成RUNNING并保证activeBank仍指向当前运行区所有固件写入和校验完成后再把同一结构体重写为DONE。如果中途断电Bootloader读到的status不是DONE就知道升级未完成会等待重传或者继续使用旧Bank启动不会尝试跳转一个半残的镜像。4.4 A/B双区切换让“更新”本身不会把系统打死真正让我放心的是A/B双区方案。平时系统从A区启动升级时把所有新固件写入B区全部校验通过后把activeBank改成B然后软复位Bootloader复位后看到activeBankB就校验B区、跳B区。下次升级则从B区反过来把新版本写进A区。这套逻辑最漂亮的地方在于升级失败、传输中断、甚至新固件启动后自检不通过都可以通过把activeBank切回旧区来实现秒级回滚。我的Bootloader在上电时还会记录“本次跳转是否成功启动”——App启动后正常初始化会在约定时间内把状态标志更新为OK如果Bootloader等待超时没看到这个标志自动切回旧Bank并上报错误码。这个机制在远程运维时价值极高。4.5 收到跳转命令后我为什么不直接函数指针跳跳转命令到达时我的顺序是写升级标志activeBank新值- 触发软复位。整个过程不做直接函数指针跳转。原因是Bootloader和App的外设状态差异很大直接跳过去很容易带着Bootloader的CAN过滤器、PIE向量表残留状态进App轻则外设异常重则复位循环。软复位让CPU以最干净的状态从Reset Vector重新走一遍启动流程代价只有几十毫秒收益是确定性。单核的升级理顺之后28377D特有的双核问题摆到了面前。5. 双核升级的联动CPU1和CPU2谁先跑、怎么换5.1 28377D双核的启动关系刚接触28377D的人最容易忽略的是“双核不等于两份软件简单打包”。28377D上电后CPU1会先开始启动流程CPU2的Boot模式由CPU1通过IPC控制这是芯片设计上的主从关系。我采用的方案是CPU1掌握启动主导权CPU1的Bootloader负责接收/写入两份镜像跳转时CPU1先把CPU2的启动入口告诉IPC模块然后释放CPU2的复位等CPU2启动完成后CPU1再跳转自己的App。如果反着来——CPU1先跳自己App再回头引导CPU2——一旦CPU1的新版本IPC通信逻辑和CPU2的旧版本不兼容两个核在系统里就是“各说各话”轻则功能异常重则死锁。5.2 镜像打包一个文件里装两份固件上位机生成升级文件时我会定义一个镜像包头里面包含CPU1和CPU2两份子镜像各自的偏移、大小、版本和CRC32。结构示意如下typedef struct { uint32_t headerMagic; // 0x43433832 uint32_t headerCrc; // 包头CRC uint16_t bootVersion; uint16_t appVersion; uint32_t image1Offset; // CPU1固件偏移 uint32_t image1Size; uint32_t image1Crc; uint32_t image2Offset; // CPU2固件偏移 uint32_t image2Size; uint32_t image2Crc; } ImageHeader;接收端拿到整个文件后先解析包头再按子镜像逐个写到对应Bank。这样升级时一个文件、一条命令、一次校验不会出现“CPU1升了CPU2忘了升”这种低级事故。5.3 IPC初始化顺序的实际做法跳转阶段我的实际顺序是关闭全局中断清理CAN发送/接收缓冲把CPU2镜像地址和长度配置好触发CPU2的启动引导等待CPU2通过IPC标志位上报Ready带超时如果超时则上报“双核同步失败”保持CPU1停留在Bootloader升级模式正常的话CPU1把activeBank更新、执行软复位复位后CPU1和CPU2各自进入新版本。这套顺序我在多台设备上验证过基本不会出现两个核版本不一致的问题。有一个细节是等CPU2 Ready时不能死等至少设一个500ms超时否则CPU2镜像坏了CPU1也跟着卡死连报错机会都没有。5.4 双核共享资源带来的额外注意点28377D的Flash、一些共享RAM在两个核之间是有仲裁的如果CPU1在擦写Flash时CPU2还跑着旧代码偶尔CPU2的取指也会卡住。所以升级期间我让CPU2跑一个极简循环或者直接等CPU1引导不做任何实质计算。这个小改动对升级成功率提升很明显。双核联动做完整个升级链路已经能跑通了但距离放心交付还差一道防线——万一升级失败怎么办。6. 防“变砖”的最后一道防线实测、回滚与运维经验6.1 三层校验到底防什么协议设计阶段我就在纸上写清楚了每个校验层的职责CAN硬件CRC15防物理链路误码块CRC32防批量传输过程中丢帧/错序镜像整体CRC32防源文件本身错误和接收端缓存错位。有人觉得三套CRC重复但实际上它们防的是三个完全不同层面的问题。升级现场最诡异那些复位/卡死往往来自“整体校验通过但App启动自检失败”这类问题还得靠App启动后的自检回滚兜底。6.2 版本号与设备识别运维场景里光能升级还不够还要能知道“哪台设备该升级到哪个版本”。我在Bootloader里实现了一个查询命令主机发一个帧设备帧上报bootVersion、appVersion、activeBank、升级状态、最近一次升级时间。这样远程巡检时不需要打开任何硬件直接扫一遍总线就能知道哪些设备落后了版本。版本号我比较推荐自增整数或者“主版本.次版本.修订号”按位打包尽量不要用字符串字符串比较逻辑在嵌入式里又占空间又容易写错。6.3 实测数据与参数参考拿一台实际设备做的测试App A/B两个Bank各分配256KB传输采用500kbps CANCPU1和CPU2双镜像总大小约200KB时的升级时间分布如下阶段耗时下发升级请求与握手 0.2s擦除目标Bank约3s传输块校验约8s整体CRC切换标志 0.5s软复位与App启动 1s整体压在12秒左右比最初方案少了足足一半。整个过程如果中途断线恢复后从断掉的块继续用户感知到的是一次“稍微长一点的下载”。6.4 一次差点变砖的复盘最后分享一个真实的险情。有次实验室同事测试急停功能升级刚写到三分之一他按下了设备总电源整个单元瞬间断电。恢复供电后我一度以为Flash一半是新一半是旧要返修。结果重启后Bootloader检测到activeBank还指向A区、B区status不是DONE自动留在升级模式等待重传旧App一路正常启动。重新下发一遍升级文件只花了几秒钟。这件小事让我坚定了两个设计原则一是新固件永远先写备份区二是任何状态切换都必须等到校验全部完成之后绝不能边写边切。在实际升级过几十台设备后我还发现两个容易被忽略的小点一是升级期间最好把看门狗超时设长一些避免Flash擦写长时间占用CPU导致喂狗不及时二是CAN接收邮箱要在Bootloader里配好至少要能缓存两三个完整块的数据量否则主机连发时容易溢出丢帧。把这些细节都处理好基于CAN总线的28377D升级方案才真正称得上“畅行无阻”。