前阵子整理了一套基于TMS320F28335(下面直接叫DSP28335)的CAN在线升级方案,bootloader、上位机、通信协议和辅助脚本全部自主开发。起因是手头一个电机控制器项目,设备装到现场之后要升级固件,拆壳插仿真器根本不现实,客户提的要求很直白:通过现成的CAN口把新程序刷进去,而且刷写工具要能对接他们的产线流程。
这套方案从协议定义、Flash分区、Bootloader代码到上位机界面,前后改了四五个版本,中间还经历过升级过程中掉电导致板子起不来的险情。我把它整理成这篇文字,重点讲方案是怎么搭起来的、协议为什么这么定、Flash资源怎么分配、工程文件怎么组织,以及实测下来遇到的那些坑。给正在折腾DSP28335或者其他C2000系列CAN升级的朋友一个完整可参考的范本。
1. 为什么放弃仿真器烧写,坚持自己搭CAN升级
1.1 现场升级的真实场景
我手头的项目是车载电机控制器,设备装到整车或者设备机柜里之后,外壳是密封的,电气接口只引出电源、CAN、旋变等接插件。以前改程序要拆盖子、找调试口、接仿真器,一次至少半小时,而且生产装机后基本留不出调试口。客户的要求是能不能不做任何拆解,通过现成的CAN口把程序换了。这就是CAN在线升级方案的核心动力。
这种情况在工业设备里特别常见。设备已经交付到客户现场,固件有问题或者要加功能,不可能让客户把设备寄回来,也不可能派工程师带着仿真器出差。CAN口是控制器最常见的通信接口,只要协议和引导程序设计得当,它就是一个天然的升级通道。
1.2 全自主开发到底图什么
一开始我也考虑过用芯片原厂的ROM引导,或者买第三方的CAN刷写工具。后来发现都不理想。原厂ROM的CAN引导功能虽然存在,但帧格式固定、校验简单、没有应用层应答重传机制,对接产线自动化流程很吃力;第三方刷写工具则是黑盒,协议不开放,固件格式受限,还涉及授权费用。
自主开发虽然前期投入不小,但协议自己定、上位机自己写,想要什么功能都能往上加。我后面陆续加了批量升级、掉线续刷、和诊断服务集成,这些在项目验收和售后阶段都是加分项。对需要长期迭代的控制器产品来说,这个投入非常划算。
1.3 方案适用范围
这套方案适合这几类场景:DSP28335/C2000系列芯片的板子,产品已经预留CAN接口,需要频繁升级固件,或者希望在产线和售后都用同一套刷写工具。只要Bootloader和上位机是同一套代码体系,后期移植到其他C2000型号也只是改改Flash地址、调调寄存器配置。
2. 升级链路全景:一次CAN刷写的完整流程
2.1 系统组成和角色分工
整套系统由四个部分组成:
- 上位机(PC端程序):负责解析固件文件、组帧、发送命令、显示进度。
- USB-CAN适配器:把PC的USB数据转成CAN帧,市面常见适配器SDK接口大同小异。
- 目标板上的Bootloader:常驻Flash,负责擦写App区、接收固件、校验并跳转。
- 目标板上的App:业务程序,收到特定命令可软复位进入Bootloader。
四个角色里最重要的是上位机和Bootloader这对关系,它们共同遵守一套通信协议。适配器只是透明的转发层,协议设计时不需要为它做特殊考虑。
2.2 一次升级的时序步骤
一次完整升级大致走下面这几步:
- 上位机打开固件文件,解析出连续数据,计算CRC32和总帧数。
- 上位机通过适配器发送握手命令,带一个随机数作为会话标识。
- Bootloader应答,回报自己的版本号、Flash剩余空间、最大支持载荷长度。
- 上位机发送擦除命令,Bootloader擦除App区,擦完回报成功。
- 上位机按帧序号连续发送数据帧,每帧带5字节有效载荷。
- Bootloader每收一帧回一个ACK;上位机超时未收到ACK则重传该帧。
- 全部数据发完后,上位机发校验命令,Bootloader对写入区重新读回并计算CRC32,与上位机比对。
- 校验通过则上位机发送跳转命令,Bootloader恢复现场并跳进App。
- App启动后跑一个自检,确认运行正常后再回报上位机“升级成功”。
这个流程看着不复杂,但每一步的细节都决定了升级的可靠性。后面我会把Bootloader和协议的部分单独拆开讲。
2.3 Flash分区规划
F28335片内Flash一共256K×16bit,分成8个Sector,每个32K,地址范围0x300000到0x33FFFF。我的分区是这样的:
| 区域 | 地址范围 | 大小 | 用途 |
|---|---|---|---|
| Sector H~C | 0x300000~0x31FFFF | 128K | App主程序 |
| Sector B | 0x320000~0x32FFFF | 32K | App扩展/参数备份区 |
| Sector A | 0x330000~0x33FFFF | 32K | Bootloader程序 |
Sector A的末尾0x33FFF6处放置一条跳转指令,这是芯片上电从Flash启动时的入口。Bootloader启动后先运行一小段自检,然后决定继续待在Bootloader还是跳去App。
提示:不要把Bootloader和App混在同一个Sector里。一旦擦错,Bootloader也会被一起抹掉,板子就只能拆壳上仿真器恢复了。
2.4 进入Bootloader的三种方式
我在实际项目中同时保留了三种进入方式:
- 上电主动进入:按下升级按钮后,上位机在目标板上电前就持续发送握手命令,Bootloader启动后发现CAN口有合法握手命令,就待命升级。
- App软命令进入:App收到特定诊断命令后,把一个标志位写入RAM,然后软复位。Bootloader检测到这个标志就不跳App。
- 启动保护进入:Bootloader先检查App头部魔数和CRC,不合格就直接停在Bootloader等待升级。
三种方式互相配合,既能主动刷写,又能防止板子变砖。
3. Bootloader端实现:入口、驱动与Flash操作
3.1 从0x33FFF6说起的程序入口
F28335的Boot ROM在上电后根据GPIO状态选择启动方式。配置为“Boot to Flash”时,芯片会跳到0x33FFF6读取一条分支指令。所以链接脚本里必须保证0x33FFF6这个地址放的是跳转到Bootloader主函数的指令。我通常用一个位于Flash Sector A末尾的跳转函数来实现:
// 放置在Flash Sector A的末尾区域 __interrupt void BootEntry(void) { asm(" LB _mainBoot"); // 跳转到Bootloader主函数 }链接脚本里要给这个函数安排固定地址,同时保证0x33FFF6就是这条分支指令的存放位置。这个位置错了,Bootloader根本跑不起来,上电就进非法指令。
3.2 eCAN初始化和500kbps波特率计算
Bootloader里的CAN驱动要尽量精简,不需要像App那样跑完整的协议栈,能用标准帧收发即可。波特率我用的是500kbps,这是工业CAN最常用的速率。
F28335的eCAN模块时钟来自系统时钟SYSCLKOUT。如果SYSCLK是150MHz,要让最终位时间等于500kbps,需要满足:
位时间 = (BRP + 1) × (1 + TSEG1 + TSEG2) BRP + 1 = 15,1 + TSEG1 + TSEG2 = 20 位时间 = 15 × 20 = 300个时钟周期 150MHz / 300 = 500kHz对应寄存器设置:
ECanaRegs.CANBTC.bit.BRP = 14; // 分频系数15 ECanaRegs.CANBTC.bit.TSEG1 = 12; // 时间段1 ECanaRegs.CANBTC.bit.TSEG2 = 6; // 时间段2 ECanaRegs.CANBTC.bit.SJW = 2;如果你的板子系统时钟不是150MHz,把公式代进去重算即可。收发双方波特率必须一致,差一点都会导致大量错误帧,最典型的现象就是握手命令发出去完全没回应。
3.3 Flash擦写为什么必须搬到RAM里执行
这是初学者最容易踩的坑。F28335的Flash不能一边擦写一边取指执行,芯片手册明确要求:Flash编程和擦除操作必须在RAM中执行。我一开始图省事,直接把Flash API函数放在Flash里调用,结果每次擦写到这里程序就飞了。
正确做法是把Flash操作函数放到RAM段里运行。在链接脚本里单独建一个RAM运行段,比如放在L0~L4这块RAM里:
ramfuncs : LOAD = FLASH_BOOT, RUN = RAM_L4, LOAD_START(_RamfuncsLoadStart), RUN_START(_RamfuncsRunStart), SIZE(_RamfuncsLoadSize)配合启动代码把这几个函数从Flash复制到RAM,并在调用Flash API之前把时钟和等待状态配好。F28335在150MHz下必须配置对应数量的Flash等待周期,否则读Flash也会出错。
3.4 升级过程中的保护策略
Bootloader里必须处理三个问题:看门狗、掉电、非法写入。
看门狗的问题很现实:擦除和写入Flash需要几秒,如果看门狗一直在跑,烧到一半就会复位。我的做法是Bootloader启动后直接关闭看门狗,整个升级期间不喂狗,升级完成跳转前再重新初始化。前提是Bootloader本身的逻辑足够可靠,不能有死等分支。
掉电保护靠分区和标志位配合。升级前先把App区开头若干字写成“升级中”的标志,只有收到完整固件并校验通过后,才把“升级完成”的标志写进去。这样即使中途掉电,下次上电Bootloader也能识别出App不完整,不会盲目跳转。
非法写入防护主要面向地址越界。Bootloader必须对每个数据帧的目标地址做范围判断,超出App区直接回错误应答并中止升级。
4. 通信协议设计:帧格式、序号管理和容错机制
4.1 消息ID与数据域布局
CAN标准帧一次最多8个字节数据,必须精打细算。我用了两个CAN ID:0x101作为上位机发给目标的命令帧,0x102作为目标回报给上位机的应答帧。
数据域第一个字节是命令字,后续字节按命令不同解释。命令字定义如下表:
| 命令字 | 含义 | 数据域剩余字节说明 |
|---|---|---|
| 0x01 | 握手请求 | 会话种子、上位机版本号 |
| 0x02 | 握手应答 | Bootloader版本、最大载荷、Flash容量 |
| 0x03 | 擦除请求 | 起始扇区索引 |
| 0x04 | 擦除应答 | 擦除结果 |
| 0x05 | 数据写入 | 帧序号(1) + 有效长度(1) + 数据(5) |
| 0x06 | 写入应答 | 帧序号 + 写入结果 |
| 0x07 | 校验请求 | 无 |
| 0x08 | 校验应答 | 本机CRC32(4字节) |
| 0x09 | 跳转请求 | App起始地址 |
| 0x0A | 跳转应答 | 跳转结果 |
应答帧的数据域里固定包含“命令回声”和“状态码”。状态码0表示成功,1表示校验错误,2表示地址非法,3表示序号不连续,4表示Flash操作失败。上位机拿到状态码就知道下一步该怎么处理。
4.2 数据分帧有效载荷
一帧CAN数据最多8字节,扣掉命令字、序号、长度,留给实际固件数据的空间就是5字节。我一开始也纠结过要不要省掉长度字段多塞两字节数据,后来还是保留了。
长度字段很有用:最后一帧往往不满5字节,Bootloader必须知道哪些字节是有效的。而且以后协议要扩展,有了长度字段兼容性会好很多。按5字节一帧算,100KB固件大概是20480帧,在500kbps下数据传送阶段约20秒,这个时间完全够用。
地址不用在帧里传,因为数据是顺序发送的。Bootloader内部按“基地址 + 帧序号×5”计算写入地址,上一帧校验通过后这个地址就是确定的,不会乱。
4.3 ACK/NACK与超时重传
CAN本身只有硬件层的错误检测和重发,不提供应用层确认。所以应用层必须自己做应答。我的策略是:
- 握手时选择随机数作为会话ID,之后所有数据帧都带会话ID,防止把总线上的其他消息误收进来。
- 上位机每发一帧数据,等待ACK最多200ms;超时未收到就重传,最多重传3次;3次都失败就停止并发提示。
- Bootloader收到数据帧后先判断序号是否等于期望序号,不等就回状态码3的NACK,上位机收到NACK主动重发。
- 发送过程中连续10秒没收到任何命令,Bootloader判定上位机已掉线,放弃升级并进入等待或跳转App。
重传逻辑一定要用状态机实现,不要用阻塞延时,否则上位机界面卡死,轮询式等待还会丢引用帧。
4.4 固件完整性校验
完整性的最后一道闸门是CRC32。固件bin在上位机侧预先算出CRC32,随校验命令下传。Bootloader写完所有数据后,从Flash读回全部App区,重新计算CRC32,两个值相等才允许跳转。
读回校验比写完直接跳多花一点时间,但能挡住绝大多数写入异常。实测100KB固件读回校验大约1秒,这个成本完全值得。
5. 上位机自主开发:从HEX解析到进度管理
5.1 技术选型与模块划分
上位机我最终用的是C# WinForms,开发快、调试方便、对接适配器SDK也简单。如果你更熟悉Python,用带CAN支持的库也可以,但在产线部署和代码保护上不如C#方便。无论用哪种语言,模块划分是通用的:
- 固件解析模块:读入Intel HEX或纯bin,输出按地址排好的二进制数据。
- 协议层:负责组帧、解帧、维护序号、超时重传。
- 适配器驱动:封装USB-CAN适配器的DLL调用。
- 界面层:进度条、日志、按钮。
协议层和界面层严格分离,这样以后换个适配器,或者想改成串口升级,只需要改驱动模块。
5.2 Intel HEX解析
编译环境生成的一般是COFF格式的.out文件,通过转换工具可以输出Intel HEX。上位机要做的就是把HEX解析成连续的地址加数据序列。
HEX记录的关键处理逻辑:
- 记录类型00是数据记录,把地址和数据拼接起来。
- 记录类型04是扩展线性地址,高16位地址以它为准。
- 记录类型01是文件结束,停止解析。
- 解析后按地址从低到高排序,检查连续区间的空洞。补0x00还是补0xFF要看硬件要求,DSP的Flash擦除后是0xFFFF,所以补0xFF更稳妥。
我在上位机里还做了一件事:解析结束后立即和工程导出的bin做一次字节级对比,防止HEX与实际烧录内容不一致。
5.3 发送状态机设计
上位机的发送逻辑不要写成顺序执行的循环,而是设计一个状态机:
- 空闲态:选择文件、计算CRC、显示固件信息。
- 握手态:发送0x01,收到0x02后校验会话ID,进入就绪态。
- 擦除态:发送0x03,等待0x04应答,超时重发。
- 传送态:按帧序号循环发送数据帧,每帧等待ACK,出现NACK按错误码处理。
- 校验态:发送0x07,等待0x08并比对CRC32。
- 跳转态:发送0x09,收到0x0A后提示“升级完成,目标机正在启动”。
每个状态都要有超时处理,也要允许用户手动取消。测试阶段这个功能特别有用。
5.4 日志与界面
日志是升级方案里最容易被低估的部分。我要求每次升级都生成一个带时间戳的日志文件,记录每条命令的收发时间、状态码、重传次数。产线上如果有一块板子刷不进去,拿着日志排查比现场猜快得多。
界面进度条不要按帧序号做线性,因为后面还有校验和跳转阶段。我的做法是把整个升级过程按工作量拆成三段:擦除占10%,数据传送占80%,校验跳转占10%,每一段内部再按帧粒度更新。
6. 工程文件结构与说明
6.1 目录总体布局
这套方案我一直用“按角色分目录、文档和代码同库”的方式组织,整套工程结构大致如下:
DSP28335_CAN_Upgrade/ ├── 01_Bootloader/ │ ├── src/ │ │ ├── main_boot.c │ │ ├── can_boot.c │ │ ├── flash_boot.c │ │ ├── protocol_boot.c │ │ ├── jump_boot.c │ │ └── BootEntry.asm │ ├── include/ │ │ └── boot.h │ ├── cmd/ │ │ ├── Flash_SectorA_lnk.cmd │ │ └── RAM_lnk.cmd │ └── proj/ ├── 02_App/ │ ├── src/ │ ├── include/ │ └── cmd/ ├── 03_Host/ │ ├── HostApp/ # 上位机源码 │ ├── HostTool/ # 固件打包工具 │ └── output/ ├── 04_Docs/ │ ├── 通信协议_V1.0.md │ ├── CAN升级总体设计.md │ └── 升级操作手册.md └── 05_Tools/ ├── hex2bin.py ├── crc32_tool.py └── batch_upgrade.bat6.2 Bootloader工程文件
Bootloader源码按职责拆成几个文件,职责划分一定要清楚:
- main_boot.c:上电流程、升级标志判断、状态机主循环。
- can_boot.c:eCAN初始化、邮箱收发、波特率配置。
- flash_boot.c:封装Flash擦除、写入、读回校验,内部调用原厂Flash API。
- protocol_boot.c:协议帧解析、命令分发、应答生成。
- jump_boot.c:处理全局变量恢复、设置App入口并跳转。
- BootEntry.asm:启动入口跳转表,保证0x33FFF6正确。
cmd目录下最值得注意的是Flash_SectorA_lnk.cmd。Bootloader工程必须限制自己的代码只落在Sector A,App的入口和中断向量表由App工程自己管理。两块地址千万不要有交集,否则App写进去就把Bootloader覆盖了。
6.3 上位机与脚本
03_Host目录下的HostApp是完整的上位机工程,HostTool里是我写的辅助工具:把编译产出的固件打包成带版本头和CRC的升级包。升级包格式是自己定的:
[固件头 16字节] 魔数(2) + 版本号(2) + 目标地址(4) + 总长度(4) + CRC32(4) [固件体] 对齐后的bin数据05_Tools目录下的脚本主要用于产线:hex2bin.py负责格式转换,crc32_tool.py负责单独计算校验值,batch_upgrade.bat可以循环对一批设备执行升级。
6.4 固件生成流程
一个App从源码到可以CAN升级,走的是这个流程:
- 在App工程里编译生成.out。
- 用编译链自带的HEX转换工具把.out转成Intel HEX。
- 上位机加载HEX后自行转成bin,并计算CRC32填进升级包头。
- 库内固化“加载HEX→解析→计算CRC→待发”流程,产线操作人员只需要选文件和点开始。
建议把“生成升级包”做成脚本固化,不要在产线上临时手工操作,减少人为出错。
7. 实测数据与踩坑记录
7.1 实测性能
以我这个项目为例,App固件大约96KB,Bootloader代码约6KB,运行在150MHz系统时钟、500kbps CAN波特率下,实测升级耗时如下:
| 阶段 | 耗时 |
|---|---|
| 握手+擦除 | 约2~3秒 |
| 数据传送(约19700帧) | 约20秒 |
| 读回校验 | 约1秒 |
| 跳转启动 | 小于1秒 |
整包刷完大约25秒。这个时间对产线批量升级和售后现场升级都能接受。如果想更快,可以把波特率提到1Mbps,或者把数据帧的有效载荷从5字节加到7字节,代价是协议要跟着改。
7.2 坑点清单
这套方案从头到尾踩过的坑不少,挑几个最有代表性的:
- Flash API放在Flash里执行,一调用就飞。解决办法前面说了,必须把Flash操作搬到RAM。
- 擦除时看门狗复位。升级期间关狗,但不是关掉就不管了,主状态机的每个超时都要有明确出口,不然板子会静默死掉。
- 中途掉电变砖。最严重的一次,现场刷到一半断电,重新上电后App和Bootloader都起不来。这就是分区和启动保护没做好的教训。
- 适配器DLL线程模型问题。某些适配器的接收回调在独立线程里,直接往界面控件写数据会闪退。处理办法是回调只进队列,UI线程定时取。
- CRC算错但程序没报错。这个问题最隐蔽,原因是HEX解析时补位策略不对,导致bin中间有空洞被补成了0x00,而Bootloader校验收到的是上位机直接发送的CRC,自洽但和实际固件不一致。所以一定要在上位机里冗余核对源文件。
7.3 几个提高成功率的细节
最后分享几个我实测下来对稳定性帮助很大的细节:
- 升级开始前让Bootloader上报Flash最大可写地址,上位机发送前先校验固件总长度,避免写一半才发现放不下。
- 数据帧序号用8位循环计数完全够用,因为重传机制保证了不乱序,序号只是为了检测丢帧,不是为了寻址。
- CAN收发器一定选带唤醒功能的型号,板子上120欧终端电阻尽量靠近连接器。
- 升级完成后让Bootloader先软复位再跳App,比直接跳转更干净,能把外设寄存器彻底复位干净。
这套方案从协议到代码都是自己磨出来的,过程中交了不少学费,但换来的是产线和现场升级都握在自己手里。如果你也正在做DSP28335的CAN升级,先把协议和Flash分区想清楚再动手写代码,方向上少走弯路,后面会省很多事。