☰
DSP28335远程升级方案:Bootloader设计、Flash分区与踩坑实录
2026/10/2 9:41:52 网站建设 项目流程

DSP28335远程升级这事儿,我愿把它称为“原理十分钟,联调一礼拜”。Bootloader放哪、Flash擦写时CPU到底该在哪跑、中断向量表怎么搬、跳转前哪些寄存器必须清零,随便一个环节没伺候好,板子就是砖头一块。这段时间我把整套流程从头到尾啃了一遍,踩的坑比过去一年加起来还多,最后终于跑通了一条相对稳的链路。这篇不整虚的,直接把方案选型、代码框架、踩坑记录全部分享出来,给正在搞变频器、伺服驱动器、电源类产品在线固件升级,或者想让28335具备远程升级能力的兄弟做个参考。

1. 远程升级方案怎么定?先把Flash分区想明白

1.1 为什么非要自己搞Bootloader

很多人第一反应是:不就用个烧录器吗?开发阶段确实如此,JTAG一插,CCS里点个Load Program,完事。但设备一旦装到现场,不管是电梯井里的变频器还是户外机柜里的电源模块,要升级固件就得派人过去拆机、开壳、接仿真器。这已经不是技术问题,是运维成本和响应速度的问题。

远程升级的本质,就是让设备自己具备“接收固件包、写入Flash、校验跳转”的能力。28335没有以太网口,也没有USB,最常见的落地方式就是走现有通信口:串口RS485、CAN,或者通过上位机中转的以太网网关。原理都一样:上位机把固化好的镜像分包发下去,Bootloader收完写进Flash,最后跳转到新程序。

你可以在Bootloader里集成一个简单的通信协议,也可以做得更重,比如支持A/B双镜像、加密、断点续传。但第一步永远是分区规划和引导流程设计,这个没想清楚,后面全是坑。

1.2 28335的Flash分区:Boot区、App区、参数区

28335的片内Flash总共512KB,物理上被分成多个扇区,擦除的最小单位是“扇区”。我们做分区规划时,最重要的一件事就是:所有区域边界必须卡在物理扇区边界上,否则擦除一个扇区会把这个扇区里其他区域的内容一起抹掉。这是第一个大坑,后面踩坑实录里会细说。

我们项目里用的典型分区方式是“Boot + App + 参数”三段式。Boot区放在Flash起始地址,因为芯片上电复位后,如果配置为Boot to Flash模式,CPU会从0x300000开始取指,Bootloader必须住在那里。App区从Boot区之后开始,参数区单独留一小块,用来存升级标志、版本号、校准参数这类掉电不能丢的数据。

这里给一段简化的CMD文件,体现分层思路,实际地址大家要按自己芯片手册里的扇区尺寸来定:

MEMORY { PAGE 0: BOOT_FLASH : origin = 0x300000, length = 0x004000 APP_FLASH : origin = 0x304000, length = 0x7B800 PARAM_FLASH : origin = 0x3FF800, length = 0x000400 } SECTIONS { codestart : > BOOT_FLASH, PAGE = 0 .text : > APP_FLASH, PAGE = 0 .cinit : > APP_FLASH, PAGE = 0 .switch : > APP_FLASH, PAGE = 0 updateFlag : > PARAM_FLASH, PAGE = 0 }

Boot区一般16KB左右足够放通信驱动和擦写逻辑。App区不要用到Flash最后那一段,因为那里还涉及CSM密钥和一些保留空间,留一点余量永远不是坏事。参数区可以单独用一个小扇区,但要注意这个扇区的擦写寿命和擦除粒度,不能频繁写。

分区定完之后,引导流程就清晰了:上电 → 系统初始化 → 关中断 → 等待升级握手 → 没握手就跳转App → 收到握手就进入下载模式。整个过程不依赖外部存储器,全部在片内完成。

2. 通信协议与Bootloader代码落地

2.1 帧格式定成这样,上位机和DSP都不遭罪

远程升级的通信链路,说白了就是上位机往DSP发一堆数据包。链路可以是SCI、CAN或者走网关的以太网,但无论底层是什么,建议在应用层自己定一套简单的帧格式。协议要满足三件事:能判断帧边界、能发现数据错误、能处理丢包重传。

我用的是经典“帧头 + 命令字 + 长度 + 序号 + 数据 + CRC16”结构:

#define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define CMD_HANDSHAKE 0x01 #define CMD_START 0x02 #define CMD_DATA 0x03 #define CMD_END 0x04 #define CMD_ACK 0x10 #define CMD_NACK 0x11 #pragma pack(push, 1) typedef struct { Uint8 head1; Uint8 head2; Uint8 cmd; Uint8 len; Uint16 seq; Uint8 data[512]; Uint16 crc; } UpgradeFrame; #pragma pack(pop)

为什么帧头用两个字节?因为单字节帧头在实际链路里太容易伪同步了,尤其是RS485这种电平翻转的链路,噪声进来一个字节就可能把状态机带偏。双字节帧头加上CRC校验,基本能保证误判概率低到可以忽略。

seq序号是重传机制的基础。DSP每收到一帧数据,解析通过后回一个ACK给上位机,上位机根据ACK的seq判断这一帧是否已确认,超时没收到就重发当前帧。这套机制虽然朴素,但在115200波特率下跑512字节一帧,整包1MB固件也就十几分钟,稳定性够用。

2.2 CRC16查表实现,DSP端基本不耗时

CRC算法我建议用CRC16-Modbus,查表方式实现。1035上跑查表法非常轻松,单帧512字节的校验耗时可以忽略,而且相比异或和校验,它能发现的错误类型多得多。

Uint16 Crc16Update(Uint16 crc, Uint8 byte) { Uint16 i; crc ^= byte; for (i = 0; i < 8; i++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } return crc; } Uint16 Crc16Compute(Uint8 *buf, Uint16 len) { Uint16 crc = 0xFFFF; while (len--) { crc = Crc16Update(crc, *buf++); } return crc; }

这段代码不用查表也能跑,但如果传输量大,建议把查表版本加上,省下的时间留给其他逻辑。注意CRC的计算范围,上位机和DSP必须完全一致:我是从帧头开始算到数据段结束,然后把CRC追加在末尾,DSP收到帧后先暂存数据,计算CRC再和帧尾的CRC比对。这个约定一定要在协议文档里写死,否则两端校验永远对不上。

2.3 Bootloader主流程:从握手到跳转

Bootloader的主流程不复杂,但它决定了整个升级过程的可靠性。核心原则是:上电先把控制权交给“升级判断逻辑”,如果没有升级需求,再把控制权转给App。绝对不能一上电就直接进App,不然升级程序根本没有机会启动。

void main(void) { // 1. 基础初始化 InitSysCtrl(); InitGpio(); // 2. 关闭全局中断,清中断标志 DINT; IER = 0x0000; IFR = 0x0000; // 3. 初始化升级通信口,这里用 SCIB 作示例 InitSciB(); EnableInterrupts(); // 4. 等待握手窗口:上位机在窗口期内发 CMD_HANDSHAKE Uint32 waitMs = 0; while (waitMs < HANDSHAKE_TIMEOUT_MS) { if (ReceiveHandshake()) { // 收到升级请求,进入下载流程 DownloadFirmware(); break; } DELAY_US(1000); waitMs++; } // 5. 没有升级请求,跳转App JumpToApp(APP_FLASH_START); }

握手窗口的时间我一般给300到500ms。这个窗口的意义在于:如果没有它,Bootloader只要收不到数据就得一直等待,设备上电到正常运行的时间会被拖长。有了超时窗口,正常运行时几乎感觉不到引导延迟。如果现场设备要求上电秒级响应,还可以加一个外部GPIO跳线判断:上电时检测某个IO电平,高电平强制进升级模式,低电平直接跳App。两种方式可以组合,看具体产品需求。

下载流程内部就是完整的状态机:擦除、收包、写Flash、校验、跳转。收包状态机必须处理好超时和重传,不能一卡死就永久阻塞在那里。

3. App侧要配合的事:PIE向量表与升级入口

3.1 为什么跳转后中断会跑飞

很多人在Bootloader里辛辛苦苦把固件写进去了,结果跳转后程序要么死在原地,要么进了一个莫名其妙的中断。最常见的原因就是中断向量表没有处理好。

28335的中断系统依赖PIE(外设中断扩展)模块。PIE向量表本质上是一张ISR地址表,默认情况下所有中断都指向一条空循环或者默认ISR。Bootloader自己也有这套表,当Bootloader跳转到App时,如果PIE模块还指向Bootloader的向量表,App里任何一个中断触发,CPU都可能跳回Bootloader的Flash区域取指,那基本就是程序跑飞。

解决思路是:Bootloader跳转前关闭PIE,App启动后自己重新初始化PIE向量表,把每个中断服务函数重新挂上去。App侧做这件事的代码很标准,就是我们平时写的InitPieVectTable()+InitPieVect()。

extern void (*PieVectTable[])(void); void InitAppPieVector(void) { // 复制默认向量表到RAM中 memcpy(&PieVectTable, (void *)PIE_VECT_TABLE_RAM, sizeof(PieVectTable)); // 重新挂载自己的ISR EALLOW; PieVectTable.SCIRXINTB = &SciBRxISR; PieVectTable.TINT0 = &Timer0ISR; EDIS; }

注意PieVectTable在CMD里要放到RAM段,这样它才是可写的。TI的例程里通常已经把PieVectTable安排在0x000D00这块RAM上,我们只需要保证App的CMD文件里也包含这个定义,不要让链接器把它扔到Flash里去。

App的main函数开头,顺序应该是:关中断 → 初始化系统时钟 → 初始化PIE控制 → 重新挂向量表 → 初始化外设 → 开中断。这个顺序不能乱,尤其是向量表初始化一定要在开中断之前完成,否则第一个中断进来就又跳飞了。

3.2 App收到升级指令:把控制权交回Bootloader

远程升级的触发方式有两种:一种是上位机在设备上电时发握手命令,直接和Bootloader对话,这种适合现场有人操作、设备可以重启的场景;另一种是设备正常运行中,App通过通信口收到升级指令,先存一个升级标志到参数区,然后软复位,让Bootloader去读这个标志并进入升级模式。

第二种方式对用户体验更友好,也是我更推荐的。App侧的任务很简单:收到指令后先把关键外设停掉,电机/PWM/继电器该封的输出都封掉,然后写升级标志,最后触发软复位。

void HandleUpgradeCmd(Uint16 cmd) { if (cmd != UPGRADE_REQUEST) return; // 1. 关闭关键中断,封锁输出 DINT; IER = 0x0000; // 2. 写升级标志到参数区,防止误入App WriteParam(APP_UPDATE_FLAG_ADDR, 0xA5A5); // 3. 等待看门狗复位,或直接软件复位 EALLOW; SysCtrlRegs.WDCR = 0x68; // 使看门狗复位 EDIS; while (1); }

这里有个细节:写升级标志是在Flash参数区写一个魔数,而不是在RAM里。因为软复位之后RAM内容不会保留(准确说是不保证保留),Bootloader上电后必须能从Flash里读到这个标志,才能知道“上次是正常断电还是App主动请求升级”。如果不用看门狗复位,也可以配置一个软件复位,但原理都一样:让CPU从0x300000重新开始执行。

3.3 App的CMD文件与Bootloader的分区隔离

App侧编译时,CMD文件里Flash区域的origin必须和Bootloader分区里的APP_FLASH一致。这是纯配置层面的问题,但错一个地址,程序就能跑出花来。

App的CMD文件片段大概长这样:

MEMORY { PAGE 0: APP_FLASH : origin = 0x304000, length = 0x7B800 PAGE 1: RAML0 : origin = 0x008000, length = 0x001000 RAML1 : origin = 0x009000, length = 0x001000 }

codestart段要人为放在App起始地址,并且和Bootloader跳转的目标地址严格对应。怎么确认入口地址对不对?编译完打开生成的map文件,查看_c_int00或者codestart符号所在地址,那才是真正的入口。跳转函数里写死地址的时候,一定要拿map文件地址来核对,不能靠感觉。

4. 踩坑实录:五个能让你怀疑人生的地方

4.1 Flash擦写时必须保证CPU在RAM里跑

这是在28335上做在线升级最反直觉的一个坑。之前我按照MCU时代的经验,直接在Flash里调用擦除函数,结果程序跑着跑着就死机了。

原因在于:擦除/编程Flash期间,CPU不能从同一块Flash区域取指令。而Bootloader本身就在Flash里,它调用的Flash API函数也在Flash里,擦除操作一开始,CPU下一条指令可能就是从正在被擦除的地址取,直接导致总线错误。

解决办法有两个。第一个是把Flash API函数整体拷贝到RAM里执行,这也是TI官方推荐的方式。第二个是把Bootloader里所有涉及Flash擦写的代码段用#pragma显式指定到RAM段。我用的是第一种,因为TI官方提供的Flash API本身就支持这种用法。

// 将Flash API从Flash拷贝到RAM extern Uint16 Flash_API_Size; extern void (*Flash_API_RunAddr)(); void CopyFlashApiToRam(void) { memcpy(&Flash_API_RunAddr, &Flash_API_Start, (Uint32)&Flash_API_Size); }

擦写期间还必须把全局中断关掉。中断一进,CPU就要去PIE向量表取ISR地址、跳到ISR执行,这个过程中任何一个步骤涉及Flash操作,都会踩雷。所以我的EraseAppSectors()函数里是这么处理的:

void EraseAppSectors(void) { Uint16 status; DINT; Flash_Init(); // 指定要擦除的扇区 status = Flash_Erase(SECTOR_A | SECTOR_B, &Flash_EraseStatus); EINT; if (status != PASS) { // 擦除失败,进入等待重试 } }

4.2 跳转成功却不停复位

第一次跑通跳转时,我特别兴奋,结果板子表现为一种诡异的“活过来又死回去”的循环:App能跑一小会儿,然后立刻复位,再进Bootloader、超时、再跳转,反复重启。

排查了很久,问题出在两个地方。第一,跳转前没有关闭看门狗。Bootloader的初始化代码里使能了看门狗,跳转到App后,App可能还没跑完初始化,看门狗就开始计数,一旦App初始化耗时超过了看门狗超时时间,就直接复位了。

第二,跳转前没有彻底清理外设中断状态。PIE模块里有些中断标志位还是置位状态,App初始化时一开中断,旧的中断请求立刻进来,CPU跳到一个还没初始化好的ISR里,非法操作导致复位。

跳转函数的最终形态是把能清的都清一遍,别嫌麻烦:

void JumpToApp(Uint32 appAddr) { void (*entry)(void); // 关全局中断和PIE DINT; IER = 0x0000; IFR = 0x0000; EALLOW; PieCtrlRegs.PIECTRL.bit.ENPIE = 0; EDIS; // 复位看门狗,给App一个干净的环境 EALLOW; SysCtrlRegs.WDCR = 0x6F; // 禁用看门狗 EDIS; // 跳转 entry = (void (*)(void))appAddr; entry(); }

App侧也要在main最前面把看门狗重新配置成自己的模式,不能依赖Bootloader遗留的状态。谁初始化,谁负责到底,这个原则在跳转场景里特别重要。

4.3 Flash等待周期设置不当导致偶发写失败

这个坑比较隐蔽。28335的Flash编程需要配置等待周期,也就是Flash_CTRL寄存器里的FWAIT_CNT字段。这个值跟系统时钟频率有关,系统时钟越高,需要的等待周期越多。我一开始图省事,直接用了TI例程里的默认值,结果发现升级成功率很不稳定:有时候整包固件一次过,有时候写到一半某个扇区里的数据全是0xFFFF的残留。

后来查了手册才知道,FWAIT_CNT没配够,Flash内部电压和时序准备不足,编程操作在高速时钟下会偶发失败。这不是逻辑错误,是时序裕量不够,所以特别难复现。

解决方式很简单:按芯片最高工作频率对应的最大值去配置等待周期,宁多勿少。擦写性能差那几十个周期,对一次几分钟的升级来说完全无感。

void Flash_Init(void) { EALLOW; Flash_CTRL->FWAIT_CNT = 0x001F; // 按150MHz主频配置 Flash_CTRL->FBANK_WAIT = 0x001F; EDIS; }

4.4 通信丢包与帧超时:重传机制一定要有

升级到一半丢包,是一个看起来没有技术含量、但实际最折磨人的问题。最初我设计的是“上位机发完所有帧,DSP写完拉倒”,结果大固件传到60%左右就开始出乱码,偶尔还烧进去一个坏固件导致设备启动失败。

问题根源在于:串口链路不是无损的。485总线上干扰、上位机USB转串口的驱动延时、DSP接收缓冲区溢出,任何一个因素都会导致某帧数据丢了或者坏了。如果没有重传机制,坏数据会被当成好固件写进Flash。

后来我在协议里加了两件事:一是seq序号,每帧数据都有唯一的序号;二是ACK/NACK应答,DSP每收到一帧就回一个ACK,CRC不对就回NACK,上位机收不到ACK就重发。同时把单帧数据长度从1024字节降到512字节,单帧传输时间缩短后,出错概率下降了一大截。

bool ProcessDataFrame(UpgradeFrame *frame) { if (frame->seq != expectedSeq) { // 序号不连续,丢弃并要求重传 SendNack(frame->seq); return false; } // 校验CRC if (frame->crc != Crc16Compute((Uint8 *)frame, OFFSET_OF_CRC)) { SendNack(frame->seq); return false; } // 写入Flash ProgramFlash((Uint16 *)frame->data, frame->len / 2); SendAck(frame->seq); expectedSeq++; return true; }

这里要注意:DSP接收到完整一帧后,先做CRC校验再写Flash,千万不要边收边写。边收边写表面省时间,但一旦帧被拆包或者发生误码,写进去的就是残缺数据,后续根本没法校验。

4.5 升级中断电怎么救:Bootloader要永远可进

最后一个大坑,也是最吓人的一个:升级过程中直接断电。第一次遇到这个场景是调测时我手一抖把电源掐了,再上电,板子毫无反应,App区已经被擦了一半,Bootloader也进不去。

这逼迫我重新审视了分区设计。为什么Bootloader会进不去?因为我把Boot区和App区放得太紧了,或者更准确说,升级流程里Bootloader自己也依赖Flash里的某些数据,App擦除时破坏了Bootloader需要的上下文。

最终的解决思路是三层防护:

第一,Bootloader代码所在的Boot区在升级流程中永远不擦除,这是原则性问题。擦除范围严格限定在App区对应扇区。

第二,Bootloader上电先检测“App区是否是一个可用的完整程序”,怎么检测?在App区末尾或者参数区保存一个CRC值,上电后对整个App区做一次CRC回读,和保存值比对,不匹配就不跳转,留在引导模式等待上位机重新下载。

第三,进入App之前,把App区的有效标志写到参数区,App正常运行时也会定期刷新这个标志。Bootloader判断流程变成:先看有没有升级请求,再看App标志和CRC是否有效,都满足才跳转。这样即使升级断电导致App区变成半残状态,Bootloader也能安全引导到下载模式。

bool CheckAppValid(void) { Uint32 crc = CalculateAppCrc(); Uint32 savedCrc = ReadFromParam(APP_CRC_ADDR); return (crc == savedCrc); }

这层防护加上之后,升级断电就再也不会把设备变成彻头彻尾的砖头,最多是需要重新下载一次固件。设备的安全性底线算是保住了。

5. 联调心得和几个实用技巧

5.1 固件镜像怎么生成:out转bin才是正经事

CCS编译完默认产出的是.out文件,带调试信息和复杂段布局,不能直接作为升级固件发送。需要先用TI的hex2000工具把它转换成纯二进制镜像。

我用CCS工程里的post-build步骤,编译完自动调用hex2000生成bin:

hex2000 --bin --boot --serial8 -o App.bin App.out

然后通过一个Python上位机脚本读取bin文件,按协议分包发送。这样整个流程就串起来了:编译产出bin → Python脚本分帧发送 → Bootloader收包写Flash → 校验跳转。

这里再提一个我自己实测下来的经验:生成bin之前,建议把.text段、.cinit段都放在App区中连续地址,不要让段跨区域散布,否则bin文件里可能出现大片空洞,白白增加传输数据量。可以用map文件检查段的分布是否紧凑。

5.2 先用仿真器跑通,再烧Bootloader

顺序真的很重要。我一开始直接把Bootloader烧进Flash再联调,出了问题连断点都没法打,只能靠串口打印日志,痛苦得要命。后来总结出一个稳妥的调试路径:先用仿真器把Bootloader和App都加载到RAM里或者Flash里,在CCS里单步调试握手、擦除、写入、跳转四个阶段,确认整个逻辑没问题之后,再烧Bootloader进Flash,之后才做脱离仿真器的整包升级测试。

为什么这么麻烦也要坚持?因为Bootloader一旦烧死,想再改就得用JTAG连回来重新擦除,而现场设备不一定有JTAG口。开发阶段多花半小时,后面能省一天。

5.3 串口打印是调试的神器

Bootloader里保留一个调试用的串口输出,用最简单的方式打印状态信息,比如擦除完成、写入进度、校验结果。这个串口可以和升级通信口复用,在协议上区分调试帧;也可以用单独的UART。条件允许的话,单独一个调试口更省心,因为它不会干扰升级流程。

我的习惯是每个关键节点打印一行带标识的日志,比如[BOOT] Erase OK、[BOOT] Program 0x305000 done、[BOOT] CRC PASS, jump to App。联调时看着日志找问题,比盲猜高效太多。

5.4 升级完成后回读校验,比什么都稳

写Flash完成不代表写入成功,必须回读验证。我在DownloadFirmware的末尾加了一整遍App区CRC回读,和接收过程计算的CRC比对。如果回读CRC不对,直接报失败,让上位机重新走一遍流程,绝不会跳转到一个坏固件里。

很多方案文档里把“回读校验”一笔带过,但我的经验是:这一步是防止“假成功”的关键。擦写Flash偶发个别字写飞了,光靠写完之后的编程API返回状态是发现不了的,只有全量回读CRC才能兜底。

6. 项目落地的几个收尾建议

6.1 关于升级超时:给现场留足容错空间

设备在现场运行,通信状况千奇百怪,总线上可能同时有其他设备在通信,上位机调度会有延迟。升级协议里一定要留超时重传的窗口,不要一发完整包就当成功。我通常的做法是:Bootloader侧每收到一帧数据刷新一个喂狗计时器,超过2秒没收到合法帧就主动回到等待握手状态,而不是卡死在下载流程里。

如果设备在电站、工厂这类环境里,建议在升级前通过App侧主动上报版本号和固件大小,上位机拿到版本号后再决定发不发。这样能避免误升级、降级升级这些不必要的麻烦。

6.2 版本管理和升级记录的落地写法

代码上跑通了,现场管理也别落后。我在App里加了一个简单的固件版本结构体,放在Flash参数区,Bootloader握手时会把版本号回传给上位机。上位机软件里记录每次升级的时间、版本、包序号,这样出问题的时候能快速回退定位。

版本结构体定义也很简单:

typedef struct { Uint16 magic; Uint16 major; Uint16 minor; Uint16 build; Uint32 crc; } FirmwareVer;

magic字段用固定魔数0x5A5A,CRC字段存App区整体校验值,这样Bootloader上电后读一次就能判断App是否完整、版本是否匹配,省去现场拆开设备看标签的麻烦。

6.3 最后一个实用小技巧:升级标志就用Flash,别额外加芯片

很多方案会倾向外挂一片EEPROM或者铁电来存升级标志,理由是Flash擦写寿命有限。但如果只是存一个魔数或者版本号,频率很低,Flash剩余寿命完全够用。28335片内Flash就够,没必要增加物料和成本。

我在参数区写升级标志前,先读一下该地址当前值,如果已经是想要的魔数,就不重复擦写,减少损耗。升级完成后App启动的第一件事是把标志清掉,保证下次正常启动不会误入升级模式。

整个DSP28335远程升级方案,从分区设计、Bootloader开发到App侧配合,其实没有哪一步是“秒懂”的难度,但每一步都有能把人卡住一天的细节。上面这些代码和踩坑记录,是我最终跑通整套流程后沉淀下来的版本。个人建议是,任何一次改动,先保住Boot区不被破坏,再谈功能迭代。升级这个功能,稳定永远比花哨重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询