说起DSP的串口升级,很多人第一反应是“不就是IAP嘛”,真上手做的时候才发现,从bootloader怎么跳转到App、Flash擦写时代码放哪儿跑、上位机怎么分包校验,每一个环节都有坑等着你。我手里这套基于F28035的串口升级方案,是在实际产品项目里反复调过的,整体拆成四块:bootloader源码、测试app工程源码、上位机源码、说明文档。四块单独看都不复杂,但串在一起就有不少细节容易翻车,尤其是跳转App那一下和Flash擦写期间代码执行的区域,这两处理解不到位,轻则升级失败,重则把整片Flash写坏返厂。这篇文章就按这套方案实际的组织方式来讲,正在给产品做串口升级、想给老项目加一个IAP通道、或者刚拿到F28035不知道Boot和App该怎么切的人,都可以直接对照着抄作业。
1. 串口升级方案到底包含哪几块,以及它适合什么场景
1.1 源码清单与实际用途
先说清楚这套方案交付到你手里是哪些东西,免得对着目录发懵。整个工程分为四块:
- bootloader工程:这是上电后最先跑的引导程序。它负责初始化CPU时钟和串口,检查有没有升级请求,有请求就通过串口接收上位机发来的固件包,写入Flash指定的App区域;没有请求就直接跳转启动已有的App程序。
- 测试app工程:一个最简单的应用示例,功能就是点灯、串口打印版本号。它的存在意义是为验证升级通道,量产前的产线测试也能直接拿它当空白App刷进去,确认bootloader和上位机的链路是通的。实际项目里你只需要把业务功能往这个App里填就行。
- 上位机源码:PC端工具,打开串口、选择固件文件、发送升级命令、分包下发、校验结果、进度显示。我这边是用C#写的,串口操作直接用系统自带的串口类,UI就是串口选择、文件选择、进度条、日志框。
- 说明文档:包含Flash分区说明、两个工程的链接文件改动点、烧录顺序、命令交互格式。
你没看错,这不是一个单文件夹的“芯片代码”,而是一个完整的闭环。我见过不少方案只给bootloader源码,上位机用串口助手手动发HEX,那样排查问题非常痛苦,因为你看不到协议走到哪一步。所以我整理这套方案时坚持把上位机和App也一并放进去,任何一环出问题都能直接定位。
1.2 为什么选SCI串口作为升级通道,而不是CAN或USB
F28035这颗芯片支持好几种通信外设,CAN、SCI、SPI、I2C都有,但我在实际项目里最终锁定了SCI串口。原因很简单,排第一的是通用性:SCI本质就是增强型UART,全双工、带FIFO,接一个USB转TTL模块就能调试,现场售后的人随便拎一根线就能连上。CAN固然抗干扰强、适合工业现场,但需要额外挂CAN收发器,协议也要自己封一层,除非你产品本身就必须走CAN总线,否则我不建议为了升级专门上CAN。
还有一个因素是F28035的Boot ROM其实自带SCI加载模式,用来烧写程序。那为什么我还要自己做一套bootloader?因为Boot ROM的加载模式有几个量产痛点:每次都要通过启动引脚配置进入SCI加载模式,产品已经装进壳子、端子焊死之后,引脚的拉法很难再改;而且它使用的是固定的私下协议,交互时序、确认机制都不够可定制。自己写bootloader的本质,就是把“升级入口”从硬件引脚改成了“软件标志”,这样可以通过App里的菜单、上位机指令、甚至远程网络指令来触发升级,完全不需要动外壳。
串口升级还有一个隐藏优势是上位机开发成本极低。C#、Python、LabVIEW里的串口库都是成熟得不能再成熟的,不需要额外装驱动(绝大多数系统自带USB转串口驱动),比起USB烧写器那种需要装SDK的开发模式要轻量得多。
1.3 Flash分区规划:升级标志、Boot区、App区怎么排
F28035内置Flash容量对于这个量级的DSC来说称得上宽裕,但分区没规划好,后面每一步都会别扭。我在这套方案里的分区逻辑是固定三段式:
| 区域 | 存放内容 | 说明 |
|---|---|---|
| Boot区 | Bootloader代码、Flash API运行副本 | 这里的代码上电即运行,一般占16KB左右 |
| App区 | 测试App/业务App、App中断服务函数 | 需要升级替换的代码全部在这里 |
| 参数区 | 升级标志、固件版本号、校验信息 | 独立留一小块,防止被App业务数据覆盖 |
可能的读者第一反应是:升级标志为什么要单独放一块?因为Flash的写操作有个特性——写入只能把1变成0,要把0恢复成1必须先整片擦除。如果你把升级标志和App数据放在同一个扇区,每次App想更新自己的运行参数,就得被迫擦除整个扇区,万一擦除过程中掉了电,升级标志也一起丢了。这个设计失误我在早期一个项目里踩过,后来所有带IAP的方案都强制切开。
分区用多大,取决于你的实际需求。Boot区我习惯给16KB,里面放了bootloader代码和Flash API库的RAM副本之后还有富余;App区就是Flash总容量减去Boot区和参数区剩下的部分;参数区哪怕只有256字节也够存标志和版本了。换算原则是:够用、留余量,别把Boot区抠得刚刚好,后面想加功能又要重新挪分区,非常麻烦。
2. Bootloader工程核心逻辑:复位后到跳转App之间发生了什么
2.1 Bootloader主流程:先看标志再决定去向
Bootloader代码初始化的动作并不复杂,主流程我用一段伪代码来解释,比看完整工程好懂:
void main(void) { // 1. 先把狗关掉,避免初始化期间被看门狗复位 DisableDog(); // 2. 初始化系统时钟、外设时钟 InitSysCtrl(); // 3. 初始化SCI串口,配置波特率、FIFO InitSci(); // 4. 读取参数区的升级标志 uint16 flag = ReadUpgradeFlag(); if (flag == UPGRADE_REQUEST) { // 5. 有升级请求,进入升级流程 UpgradeProcess(); } // 6. 没有升级请求,跳转到App JumpToApp(); }这个流程的先后顺序其实是有讲究的:看门狗必须在最开头就处理,否则稍慢一点的Flash擦写过程就会被狗咬复位;SCI初始化要在检查标志之前,因为进入升级模式后立刻就要用串口跟上位机握手;而跳转App这一步必须放在所有初始化完成之后,因为App的启动代码默认CPU时钟、外设时钟已经是配置好的状态,你跳转前就把这些初始化做完,App里就不用再做第二遍。
有一点需要特别注意:Bootloader里不要放业务逻辑,它只管三件事:判断是否升级、接收并写Flash、跳转。任何多余的功能塞进Boot区,都会压缩App可用空间,也会增大bootloader本身出错的概率。这是我在项目复盘里最想强调的纪律。
2.2 Flash擦写为什么必须跑到RAM里执行
这是F28035甚至整个C2000系列IAP最容易翻车的地方,也是很多人第一次跑通串口升级后依然提心吊胆的原因。
F28035的Flash擦除和编程过程有个硬件限制:当Flash控制器在执行擦除或编程操作时,它自己所在的Flash存储区是不能被CPU读取指令的。如果CPU此刻正从Flash取指令,就会当场抓瞎,表现为程序跑飞、进入非法指令陷阱。解决方法是把执行Flash操作的代码(官方Flash API库)先从Flash复制到RAM里,然后跳到RAM里执行擦写函数。
具体落在工程上,需要做两件事。第一,CMD链接文件里必须开辟一个Ramfuncs段,专门用来存放Flash API和相关辅助函数。第二,在启动代码里把这个段从Flash搬运到RAM,搬运完成后调用的函数指针实际指向RAM地址。别小看这个“搬运”,我见过有人直接在Flash里调用官方擦写函数,结果一执行就死机,排查了一个星期才发现原因是API没驻留RAM。
还有两个和擦写相关的坑一并说了:一是擦除长时间扇区时要把看门狗临时关掉,或者在擦写循环里喂狗,否则中间一个复位,Flash处于半擦除状态,App区域就废了;二是要正确配置Flash等待状态寄存器,F28035在60MHz主频下跑Flash,等待状态配错会导致读出来的数据不稳定,表现就是明明写进去是对的,校验却不过。
2.3 跳转App之前的“清场”动作:中断、狗、PIE向量表
Bootloader完成自己的使命后,要把完好的控制权交给App。这个“交权”过程如果做得不干净,App启动后会出现各种诡异的随机故障。
首先是关闭中断。直接操作中断使能寄存器和中断标志寄存器,把它们清零。这是必须的第一步,因为从你关中断到App完成自己的中断向量表初始化这个窗口期,任何一个外设中断进来都会让CPU不知所措。
然后是清理PIE向量表。这一点很多人会忽视:F28035的中断向量表不是像某些MCU那样放在Flash固定地址、需要重定位的,它的可编程中断向量表(PIE)在RAM里。Bootloader运行期间,SCI接收中断等可能已经把PIE向量表项指向了Bootloader自己的中断服务函数。如果你跳过清理直接跳进App,App初始化又没那么快,这时候来一个串口中断或者定时器中断,CPU就会沿着残留的向量表跳回Bootloader的ISR,那个ISR再一返回,栈已经错乱了。最终表现就是跳转前一切正常,跳转后秒死。
我的做法是在跳转前执行一个清场函数:清零中断使能、清零中断标志、把PIE向量表所有条目恢复成出厂默认值、把看门狗复位到默认配置(App启动会重新配置)。最后再用一个函数指针指向App区的入口地址,完成跳转:
typedef void (*pFunction)(void); void JumpToApp(void) { // 关闭所有中断 DINT(); IER = 0x0000; IFR = 0x0000; // 恢复PIE向量表到默认状态 ResetPieVectTable(); // 跳转到App区入口 pFunction appEntry = (pFunction)APP_START_ADDR; appEntry(); }这里的APP_START_ADDR不是你在心里想的“App的main函数地址”,而是App在链接时确定的程序入口地址。它通常是App工程里_c_int00这个C运行时启动函数的位置,一般落在App区Flash的起始地址附近。具体值看App编译生成的map文件,别拍脑袋猜。
2.4 串口帧协议设计:帧头、命令、长度、CRC
Bootloader要可靠接收固件,向上位机的通信协议必须设计得足够简单明了。我这套方案用的是定长帧头加变长数据的结构:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 固定0xAA 0x55 |
| 命令 | 1字节 | 0x01擦除请求,0x02数据帧,0x03结束校验,0x04查询版本 |
| 数据长度 | 2字节 | 大端,表示后面负载区字节数 |
| 负载 | 变长 | 根据命令不同,可能是空、固件数据、版本号 |
| CRC32 | 4字节 | 对整个帧除CRC外的所有字节做CRC32校验 |
协议设计的基本原则是:Bootloader越“笨”越好。不要让它做解析复杂格式的事,上位机负责把固件文件拆成包、算好CRC,Bootloader只做“收到校验通过的包,写进Flash指定偏移处,回一个ACK”。每收一个有效数据包,Bootloader回一字节0x80表示成功,收到校验错误就回0x81,上位机收到0x81就重发当前包,最多重发三次。
帧和帧之间的区分用超时法:上位机发完一包等ACK,Bootloader在接收状态里如果超过一定字符间隔没有新字节进来,就认为本包结束,开始解析。串口的波特率我固定用115200-8-N-1,不开流控,这个速率在产线和售后场景都足够,又不至于因为线缆质量导致误码率升高。
3. App端必须配合的改动:向量表处理、CMD分区与升级触发
3.1 F28035没有“中断向量重映射”,但要处理的是PIE向量表
做过STM32的人听到“App中断向量要重定位”往往习惯性想找一个偏移寄存器,但在F28035上这套经验反而会带偏你。F28035的C28x内核本身不采用在Flash里搬移向量表的做法,它的可编程中断向量表就是固定挂在RAM里的PIE模块。Bootloader和App其实共享同一个PIE向量表,谁启动了,谁就负责往这个表里填自己的中断服务函数地址。
所以App工程里不需要任何“把向量表搬到某个地址”的操作,正常调用初始化PIE向量表的库函数,把默认ISR载入,再把自己特有的中断服务函数挂进去即可。真正决定App能不能安全跑起来的关键,在前面已经说过——Bootloader跳转前必须把PIE向量表清干净,不要让旧的中断残留。这两个环节一配合,App启动后重新初始化PIE,中断体系立刻切换到App自己的版本。
我见过有人为了让“App能独立跑”强行给两个程序都做向量表独立偏移,结果跳转后中断乱飞。其实完全没必要,理解F28035的PIE机制之后,App端的中断处理反而比Cortex-M那套简单。
3.2 链接文件CMD怎么把App放到Boot后面的Flash区
App工程相比普通工程,改动点最核心的就是CMD链接文件。默认工程里Flash段是从整个Flash起始地址开始的,而你要让App跑在Boot区之后的地址上,就必须在CMD文件里重新声明可用的Flash区域。
一套标准的改法是:在App工程的CMD里,把MEMORY部分原先定义的Flash区起始地址,改成“Boot区结束的地址”。比如Boot区占用了Flash前16KB,那App区的起始地址就是Flash基地址加0x4000。这个值不一定是整数扇区对齐,但最好对齐到Flash扇区边界,后面做擦除操作会比较方便。
同时要注意,App的代码段、只读数据段、初始化数据段都要放在这个新定义的Flash区里,而RAM段的定义基本不用动。改完后编译,打开生成的map文件,确认入口地址落在你期望的App区起始位置附近。这一步是对照检查:如果map文件里入口地址还指向Flash的低地址,说明CMD改得不彻底,跳转过去必死。
这里还有个容易忽略的文本:App里如果用到版本号字符串、升级标志定义等,尽量安排在固定偏移的位置,或者干脆放到参数区,方便上位机读取版本。App和Boot之间通过参数区的约定字段通信,比在App内部猜偏移量要清晰得多。
3.3 App里触发升级的常用姿势:置标志加软复位
App在正常运行时怎么进入升级模式?行业里最稳妥的方案是“写标志加软复位”。具体流程:
- App收到上位机下发的“进入升级模式”的指令,或者用户在本地按键确认。
- App先把一个约定值(比如0xA5A5)写入参数区的升级标志位置。
- App触发一次系统软复位,让CPU回到复位向量,过程重新从Bootloader开始。
- Bootloader上电后读到升级标志为0xA5A5,判断有升级请求,进入接收固件流程。
有人会问:为什么不在App里直接跳转到Bootloader的入口?技术上可以,但实际不推荐。软复位的好处是整个芯片的外设、中断、栈指针全部回到出厂态,Bootloader可以在一个完全干净的起点开始工作。直接调用Bootloader入口则可能残留App设置过的外设状态,比如SCI波特率被App改过、看门狗喂狗方式不同,这些残留状态会在升级过程中埋雷。
触发软复位在F28035上最通用的办法是故意触发看门狗复位:向看门狗控制寄存器写入一个故意的错误喂狗序列,让狗超时复位。不推荐用某些芯片特有的“软复位指令”的原因很简单,不同版本芯片行为可能有差异,而看门狗复位在每颗芯片上都是必有的机制。
还有一个细节:App写升级标志前,必须先把参数区对应扇区擦除,再写入标志值。因为Flash写操作不能把0写成1,如果不擦就直接写,结果会和你预想完全不同。擦除这个操作耗时几十毫秒,App要做好提示,别让用户以为卡死了。
4. 上位机源码与固件转换:从out到bin再到烧写完成
4.1 上位机的功能划分和协议解析流程
上位机源码我按三个模块划分:串口管理模块、协议收发模块、界面控制模块。串口管理负责枚举端口、开关串口、收发字节缓冲,协议收发负责解析Bootloader回传的ACK和错误码,界面控制负责文件选择、进度更新、日志打印。
串口这块,C#里有现成的串口类,设好波特率、数据位、停止位、校验位就能用。关键点在字节缓冲处理:上位机收ACK时,很可能会把之前Bootloader还没来得及发的信息混在一批里,所以不能简单“读一字节处理一字节”,而是维护一个接收缓存,按帧头扫描、拼接、解析。我对帧的识别逻辑是:在缓存里找0xAA 0x55帧头,找到后按帧头后的命令字节解析剩余长度,取够一帧再做CRC校验,校验通过转成对应事件交给界面更新。
协议收发还有一个必须做的机制是超时重发。发送每个数据包后,上位机最多等500毫秒的ACK,收不到就重发,连续重发3次仍无响应,直接上报“通信超时”,终止升级流程。这个机制看着简单,但能从根上解决串口偶发丢包导致升级中断一半的问题。
4.2 如何把CCS工程产物转换成可以下发的bin文件
这里新手最容易卡住:使用开发环境编译出来的固件是.out格式,上位机直接按bin发过去,Bootloader写进Flash也跑不起来。原因很简单,编译工具链链接的结果是COFF格式,除了代码段,还打包了符号表、调试信息、段定位信息,并不是Flash上的纯二进制镜像。
要把.out转成纯bin文件,用官方提供的十六进制转换工具(一般叫hex2000)就能完成。命令行大致思路是:指定输入文件为out文件,指定输出格式为二进制,指定需要导出的段,然后生成一个.bin文件。具体命令参数因版本略有差异,但操作思路是通用的:
hex2000 -boot -b -o app.bin app.out其中关键的是-b参数表示输出二进制格式,而-o指定输出文件。转出来之后,你还可以用bin查看器或上位机自己的校验功能确认文件大小、头部几个字节是不是预期的入口指令,这样可以提前拦截“根本没转对”的问题。
另一种备选方案是用Python脚本解析COFF文件结构,自己提取代码段拼成bin。这种方案对我来说只有当转换工具在公司加密环境里拿不到时才值得做,正常开发直接用官方工具最省心。
4.3 一次完整升级的实测流程和预期输出
拿这套方案在板子上走一遍完整升级流程,我预期会出现下面的节奏:
- 给板子上电,Bootloader运行,因为没有升级标志,直接跳转App,App串口打印“App v1.0”,LED常亮。
- 上位机选择app.bin,点“开始升级”。上位机先发0x01擦除命令,Bootloader返回0x80表示已进入升级模式并擦除App区完成,此时App之前的代码其实已经没了,但板子还在裸跑Bootloader的串口服务。
- 上位机看到擦除ACK后,开始逐包发送固件数据,每包256字节。Bootloader每收到一包写一包,写成功后回0x80。上位机进度条不断前进,同时日志框打印“包序号/总包数”。
- 最后一包发完,上位机发送0x03结束校验命令。Bootloader对整片App区计算校验值,和上位机发来的预期值比对,一致则回0x80,并自动清除升级标志、执行软复位。
- 复位后再次从Bootloader启动,这回读到升级标志是空,直接跳转App,App串口打印“App v1.1”。升级结束。
理想情况下整个升级过程在一次串口连接里自动完成,不需要人工干预。如果哪一步没有如期走到,日志框里的“最后一次成功交互的帧类型”就是排查最关键的线索。
5. 升级失败排查表与几个我踩过的坑
5.1 常见故障现象的根因对照表
这套方案调试过程中,我整理了一份故障对照表,基本覆盖了90%的串口升级问题:
| 现象 | 可能根因 | 定位方法 | 解决思路 |
|---|---|---|---|
| 上电后一直停在Boot,不跳App | 参数区残留升级标志 | 读标志值看是否为0xA5A5 | 手动擦除参数区或用上位机发清除标志命令 |
| 跳转App后秒死/反复复位 | 入口地址错误、PIE向量表残留、App区实际没写入 | 查App的map文件、检查跳转地址、读回Flash比对 | 修正APP_START_ADDR,清理PIE后再跳 |
| 串口收发不稳定,偶发卡包 | 波特率误差、有粘包、线材质量差 | 串口助手看原始hex字节流 | 核对系统时钟配置、增加超时拆包、换短线 |
| 升级过程中写Flash卡死 | 狗没关、Flash API跑在Flash里、供电跌落 | 确认擦写前关狗、查看API是否在RAM段 | 修正CMD段分配、临时关狗、加强供电 |
| 升级显示成功但版本没变 | 发错文件、bin转换不对、包序乱 | 核对bin大小和头部、打印实际写入地址 | 重新生成bin、检查发送顺序与写入偏移 |
| 擦除后App区不敢写 | 擦除过程被打断 | 查看Flash状态寄存器 | 擦除前关中断、关狗,擦除后读取空值确认 |
这张表是我排查问题时的第一参考,因为大多数问题其实都不是“代码逻辑漏洞”,而是“环境或流程没配对”。先看现象属于哪一类,再决定往哪个方向深挖,比漫无目的地打断点强得多。
5.2 “跳转App跑飞”的完整排查链路
这里展开一次我印象最深的排障过程,供你参考排查思路。
现象:Bootloader跑通,升级也显示成功,但软复位后App跑不起来,看门狗反复复位,串口一直无输出。我第一反应是升级写入的数据不对,于是先在上位机里把固件读回来对比,发现Flash内容与bin文件完全一致,排除了写错的问题。
接着怀疑入口地址,我打开App编译生成的map文件,确认入口地址就是我JumpToApp函数里用的APP_START_ADDR。仔细核对过后发现,问题恰恰不在地址本身,而在这个地址上的指令含义。我用的跳转方式是直接调用入口地址,但App的C运行时启动代码需要一个正确的栈指针和全局状态初始化。我这边跳转前虽然关了中断,但忘了把SP(栈指针)恢复成App预期的值。Bootloader跑了一路函数调用,栈指针早就不在App启动代码期待的位置了,App一进_c_int00就把栈操作搞乱。
修复方式是:在跳转函数入口处先把栈指针切回Bootloader启动时的初始栈,或者干脆在跳转前重新调用App的复位向量流程,让App的启动代码自己完成栈设置。从那以后我再写跳转逻辑,都会刻意检查“程序入口入口指令是否是栈指针设置指令”,而不是只盯地址数值。这个坑不深入链接器层面很难发现,但排查链路其实是清晰可复现的。
5.3 串口老是不通、写一半卡死这类问题的处理
串口升级里最让人烦躁的其实是“串口握手就不通”和“写一半卡死”这类环境型问题,代码逻辑完全没毛病的状况下也能反复出现。
串口不通,我建议第一步不要看协议,先用串口助手裸收一下Bootloader的启机打印或ACK字节,确认物理链路通不通。如果裸收乱码,先看波特率误差。F28035如果用内部振荡器跑PLL,时钟精度本来就拼人品,和上位机的115200对不上很正常,换成外部晶振或者做内部振荡器校准之后,这个现象会大幅缓解。第二步确认TX/RX交叉连接,不少新手把直连和交叉接反,板子的TX接板子的TX,折腾半小时才反应过来。
写一半卡死,大半原因在前面说过的“Flash API没在RAM里跑”和“看门狗没管住”。测这个问题的笨办法是先关闭看门狗、把擦写函数使用段改成RAM段,再跑一次升级,如果能写完那大概率先前就是这两处。另外还有可能是供电问题,Flash擦除瞬间电流比运行稍大,如果板子用的是劣质USB线供电,电压一跌落Flash状态机就会异常。排查时用示波器测一下擦除瞬间的3.3V纹波,能排除掉设计隐患。
串口粘包和半包的问题,我现在的标准做法是Bootloader端用“超过4个字符时间未收到新字节”作为一帧结束的判决条件,上位机端则用500毫秒ACK超时来兜底。这样即使偶尔拆包拆错了,最多重发一次,不会累积成数据错乱。
最后再分享一个经验:这套串口升级方案真正稳定运行的前提,是把交互时序当成状态机来设计——Bootloader这边明确区分“等待帧头”“等待长度”“等待负载”“等待CRC”“回复ACK”几个状态,每个状态有超时退出,上位机那边明确记录当前发到第几包。两边都做成可观测的状态机,升级过程就再也不是玄学,而是每一步都能落到日志里的确定性流程。