ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于英飞凌TC3xx的SOTA SWAP分区升级与回滚实战

基于英飞凌TC3xx的SOTA SWAP分区升级与回滚实战 这几年做汽车控制器软件SOTA算是被主机厂反复提起的硬需求了。只要是新平台几乎都会提一句“要支持OTA”。但真正落地的时候大家心里都清楚OTA最怕的就是“刷挂了变砖”。车在用户手里新版本跑不起来、旧版本又没了这种场景一旦发生就是售后事故。所以行业内普遍的做法是做A/B备份升级也就是SWAP机制一个ECU里同时放两套应用软件平时跑一套升级时写另一套启动时切换。我基于英飞凌TC3xx平台TC264、TC377、TC387这些AURIX 2G系列做过几个量产项目SWAP功能从零开始搭过、也填过不少坑。这篇文章我把整个设计思路、分区规划、Bootloader跳转实现、状态记录、回滚策略和排障方法一次性整理出来给准备在TC3xx上做SOTA的同行一个可落地的参考。1. 为什么SOTA必须配SWAPA/B备份升级的核心思路1.1 从整车OTA的可靠性要求说起先回到问题本身为什么OTA升级不能像开发调试那样拿着调试器直接把Flash覆盖一遍就完了因为整车OTA的使用场景是“交付给用户之后的远程操作”网络状况不可控、升级时机不可控最关键的是掉电风险不可控——用户可能在升级过程中直接熄火断电或者车辆进入低功耗模式。如果刷写流程没有容错设计一次掉电就可能让App区处于“半旧半新”的碎裂状态系统起来后不知道执行哪段代码最后只能把车拖回店里用售后设备重新刷写这就完全违背了OTA省时省力的初衷。于是“冗余升级”就成了行业共识ECU内保留两个完整的软件槽位一个槽位运行当前版本另一个槽位空闲或存放备份版本。升级时只写入非运行槽位等新版本验证没问题后再切换运行槽位。这就是SWAP机制也是SOTA功能的核心底牌。1.2 传统单分区与A/B双分区的本质差异传统刷写方案是Bootloader加一个App区升级时直接覆盖当前App。这个方案不能说不能用但安全隐患很大刷写中途掉电、网络超时、校验失败都可能导致唯一的一份App损坏。Bootloader虽然还在但它已经无法判断“该跳转的地址里到底是不是一个完整可运行的程序”只能等待再次刷写或者退化为一个被动下载器。A/B双分区方案则完全不同。两个App分区在物理和逻辑上完全独立Bootloader始终知道哪个槽是“当前可用的”哪个槽是“正在接收新版本的”。升级过程变成了四步把新固件写入非激活槽写完整固件并校验通过后在状态区做标记复位Bootloader读取标记并跳转新槽新App启动并自检正常后主动确认旧槽变为备份。这个模型下哪怕新版本起不来、时序出错、甚至刷写掉电系统都能回滚到上一个可用的槽位。用户需要做的仅仅是“等下一次升级成功”而不是“进店维修”。从工程实现的角度看这个思路并不复杂难的是TC3xx平台上的具体落地细节比如分区怎么切、状态记录怎么存、Bootloader怎么跳还有最容易被忽视的异常分支处理。下面我逐层拆开讲。2. TC3xx平台上的硬件资源与软件基础2.1 PFlash、DFlash、UCB各自扮演什么角色做SWAP之前先把TC3xx的存储资源弄清楚。TC3xx虽然叫单片机但它的存储结构比传统MCU复杂不少主要体现在Program FlashPFlash、Data FlashDFlash和User Configuration BlockUCB三者的职能划分上。PFlash是存放代码的主存储区内部按Bank组织每个Bank又有若干逻辑扇区支持扇区擦除和按页/按字写入。不同型号容量差异很大比如TC264大概有2MB多TC377有6MB级别TC387的PFlash容量更大。在做SWAP分区时我们关心的是扇区边界和基地址边界没对齐会导致擦除时误伤邻居区域。DFlash存放在相对独立的数据Flash区域适合保存标定数据、故障码、升级状态这一类需要频繁读写且掉电不丢的数据。这里要特别强调SWAP状态记录请务必放在DFlash不要图省事放在PFlash的数据段里。原因有两点一是PFlash频繁擦写寿命有限二是如果状态记录和代码在同一块区域升级代码时很容易把状态区一并覆盖导致系统彻底失忆。UCB是用户配置块保存启动配置、PFlash保护位、HSM安全相关配置等。UCB一般由启动配置工具生成量产阶段还会加上安全保护。我的经验是做SWAP功能时尽量不要依赖UCB中的自定义字段尤其是涉及安全访问或调试保护的方案一旦UCB配置和Bootloader代码不匹配轻则无法跳转重则整个芯片启动流程异常。如果你不是芯片启动固件开发者最好只在DFlash里做状态管理。2.2 Bootloader与App的协作模型SWAP功能的灵魂是Bootloader与App之间的协作两者不是“谁来拉谁一把”的关系而是围绕状态区做一整套契约。契约的核心就是状态记录。我常用的状态记录结构体如下typedef struct { uint32 magic; /* 幻数0x5A5A5A5A表示记录有效 */ uint32 sequence; /* 序号越大越新 */ uint8 activeSlot; /* 0: Slot A, 1: Slot B */ uint8 pendingSlot; /* 0xFF: 无pending升级 */ uint8 bootAttempts; /* 尝试启动新槽的次数 */ uint8 status; /* bit0: upgrade complete, bit1: rollback occurred */ uint32 crc32; /* 整个结构体的CRC32 */ } SwapStatusRecord;有了这份记录Bootloader每次启动时的工作就是“读状态、做判断、跳槽”。正常情况下App运行状态和状态记录是同步的但真正考验的是异常情况下这棵“状态树”还能不能自我恢复。协作流程拆开看是这样冷启动后Bootloader读取状态记录若无pending升级直接跳activeSlotApp执行SOTA时先在状态区写入“升级目标槽”的pending信息然后通过诊断服务接收新固件并写入目标槽固件写完且校验通过后App更新状态区pending保留标记upgrade complete然后软复位Bootloader复位后看到pending跳转新槽并把bootAttempts加一并写回状态区新App启动后完成自检如果没有致命错误在等待窗口内主动确认“我很好”Bootloader收到确认后将activeSlot切换为新槽清除pending如果新App启动失败系统看门狗复位Bootloader发现尝试次数超过阈值自动恢复activeSlot为旧槽并回滚。这套协作模型里最需要拿捏的是“确认时机”和“超时窗口”。下面一章具体讲配置步骤时我会详细展开。3. 手把手配置SWAP功能地址规划、状态记录与跳转实现3.1 分区表设计与链接脚本调整我以一个典型配置为例Bootloader占128KBSlot A和Slot B各占384KB另外留一小块DFlash作为状态区。实际容量要根据App大小调整但分区边界必须按PFlash扇区对齐这个没有商量余地。在TASKING TriCore工具链下分区是通过LSL文件描述的。我用内存块定义示意memory pflash_boot { mau 8; size 128k; type rom; map (destbus:tc0:fpi, dest_offset0x80000000, size128k, priority8); } memory pflash_slot_a { mau 8; size 384k; type rom; map (destbus:tc0:fpi, dest_offset0x80020000, size384k, priority8); } memory pflash_slot_b { mau 8; size 384k; type rom; map (destbus:tc0:fpi, dest_offset0x80080000, size384k, priority8); }基地址要根据具体芯片的PFlash起始地址和Bootloader实际占用的空间来定。比如TC377的PF0通常从0x80000000开始但不同封装和型号可能有细微差异拿到芯片手册后第一件事就是核对Book中的“Memory Map”章节。App工程这边链接脚本也要对应调整。如果你编译Slot A版本就把App的ROM起始地址设为0x80020000中断向量表跟着挪过去编译Slot B版本时改到0x80080000。为了便于管理我习惯在工程里定义两个编译宏SLOT_A_BASE和SLOT_BASE所有涉及Flash地址的代码都基于宏计算避免在代码里到处写魔法数。还有一个细节必须提醒TC3xx的App启动后中断向量表不会自动切换到当前App的位置。Bootloader跳转时虽然设置了PC但BIV寄存器中断向量表基址还是Bootloader的值。所以App的启动代码第一件事就是把自己槽位对应的中断向量表地址写入BIV寄存器否则等第一个中断到来CPU就会跑到Bootloader的向量表里去执行轻则错误重则直接HardFault。3.2 SWAP状态记录双备份加序号递增状态记录放在DFlash没问题但直接固定写同一个地址是有隐患的。DFlash虽然寿命比PFlash高但OTA是个长期功能如果每次升级都在同一个扇区上重复擦写几年下来可能磨到寿命上限。更稳妥的做法是用“环形缓冲 序号递增”的方式管理状态记录。具体做法在DFlash里划出两个或四个扇区每个扇区能容纳多条状态记录。新记录总是写到当前写指针的下一个位置序号加一。启动时遍历全部有效记录取序号最大的那条作为当前状态。如果某条记录CRC校验失败跳过它继续找前面的记录如果发现序号最大的记录不完整写了一半掉电就回退到上一条有效记录。这个机制的好处是任何时刻掉电系统都至少有一条“上一个稳定状态”的记录可用。我倾向于在Bootloader启动阶段做一次这样的恢复逻辑SwapStatusRecord record; bool recordValid false; uint32 maxSequence 0; for (uint32 addr SWAP_STATUS_START; addr SWAP_STATUS_END; addr sizeof(SwapStatusRecord)) { SwapStatusRecord tmp; readDFlash(addr, (uint8 *)tmp, sizeof(tmp)); if (tmp.magic SWAP_MAGIC tmp.sequence maxSequence crc32Check(tmp)) { record tmp; recordValid true; maxSequence tmp.sequence; } }如果recordValid为假说明状态区已经乱掉了这时候最安全的策略是跳转到默认槽一般是Slot A同时清掉所有pending标记避免因为状态异常反复尝试启动未知槽位导致永久卡死。3.3 Bootloader跳转App的实现要点跳转动作看起来简单无非是“把PC指向目标地址”但在TriCore架构上要处理的事务比想象中多。我最终用的方案是内嵌汇编小函数__attribute__((noreturn)) static void JumpToApp(uint32 appBase) { uint32 sp *(uint32_t *)(appBase); /* 栈指针从App头偏移0 */ uint32 pc *(uint32_t *)(appBase 4); /* 入口地址从App头偏移4 */ __disable_interrupt(); __asm volatile ( mov %%a0, %0\n mov %%a1, %1\n ji %%a1\n : : r(sp), r(pc) : a0, a1, memory ); while (1); }这段代码有几个关键点跳转前必须关全局中断防止跳转过程中被中断打断导致上下文错乱跳转时要同时设置栈指针和入口地址跳转完成后不会返回所以函数声明为noreturn。还有一个常被忽略的点如果启用了MPU或者TC3xx的PFlash保护机制跳转前要确认目标App所在区域的访问权限是开放的。我之前遇到过一个问题量产项目里为了防回读对PFlash加了不少保护位结果Bootloader跳转时直接被总线错误卡住。这种问题在开发阶段很难察觉因为开发环境里保护位一般没配上。所以量产前一定要做一次“完整保护配置下执行OTA全流程”的回归测试别等到批次车辆出问题才排查。3.4 App侧升级流程与确认成功机制App侧接收固件的通道通常是UDS协议走CAN/CANFD或者以太网DoIP。标准流程是诊断仪或上位机先发0x10 02进入编程会话然后通过0x34请求下载、0x36传输数据、0x37退出传输把固件写到非激活槽。这些流程在很多资料里都有我不重复了重点讲两个容易被带偏的环节。第一个环节是“写完固件之后什么时候切换”。我的做法是所有固件数据写完并做完整回读校验后先不急着切槽。只有在收到明确的“升级完成指令”可以是0x31例程控制也可以是厂商自定义的诊断服务后才更新状态记录把pendingSlot指向新槽标记upgrade complete然后执行软复位。为什么要这样设计因为如果上位机在校验完成后、发送完成指令前断开了连接ECU应该继续保持当前版本运行而不是自己擅自切换到一个“虽然写了但没收到完成信号”的槽位。这种“不主动切换”的策略能规避大量异常场景。第二个环节是新App如何确认自己“是好的”。我在量产项目里用的是“延迟确认”方案新App启动后先做基础自检包括电压、时钟、关键外设和Flash回读校验然后开启一个5秒至10秒的观察窗口。窗口内如果系统没有发生致命错误App就在窗口结束时调用确认服务把状态区里的activeSlot切换为当前运行槽并清除pending标记。Bootloader端则设一个15秒的“等待确认”超时超时后如果还没收到App的确认就认为新槽启动失败尝试次数加一。这里两个时间参数必须搭配好App观察窗口要小于Bootloader超时时间否则App还没确认Bootloader先发起了回滚整个升级逻辑就乱了。我推荐的比例是App观察窗口6秒Bootloader超时窗口15秒留足余量。这个值不是拍脑袋定的要结合App启动时间、Flash自检耗时、网络上报延迟来评估不同项目差异较大。4. 状态机、回滚策略与关键参数校准4.1 SWAP状态机的工程化设计把SWAP功能落实成代码之前先画状态机这不是多余的paperwork而是为了确保升级流程的每个分支都有明确的归宿。我的状态机大体分六个状态IDLE正常运行无升级需求DOWNLOADING正在接收新固件目标槽尚未完成写入DOWNLOAD_DONE固件写入完成并通过校验PENDING_SWAP已写pending记录系统待复位切换TRYING_NEW_SLOTBootloader已跳转新槽新App尚未确认ROLLED_BACK升级失败系统已回滚旧槽。这个状态机的关键转移条件我会写成一张配置表方便工程上调整状态触发条件动作IDLE - DOWNLOADING收到编程会话请求记录目标槽向状态区写入“下载开始”标记DOWNLOADING - DOWNLOAD_DONE所有数据块接收并CRC校验通过回读目标槽并做整槽校验DOWNLOAD_DONE - PENDING_SWAP收到升级完成指令写pendingSlot和upgrade complete标记软复位PENDING_SWAP - TRYING_NEW_SLOTBootloader复位后检查到pending跳转新槽bootAttempts1TRYING_NEW_SLOT - IDLE新App在观察窗口内确认成功切换activeSlot清除pendingTRYING_NEW_SLOT - ROLLED_BACK尝试次数超限或新槽Exception恢复旧槽记录回滚事件状态机里最容易被忽略的是“下载开始”标记。它的作用是如果App在刷写中途掉电Bootloader下次启动时通过状态记录发现存在未完成的下载有开始标记但没有完成标记就能直接判定目标槽数据不完整不去跳转它也不会因为尝试次数未超限去反复尝试启动一个坏槽。这个标记本身是一个很小的字段但价值极高它把“刷写掉电”这种最常见的异常场景从一个可能引发系统卡死的bug变成了一个能被安全绕过的普通分支。4.2 回滚阈值、看门狗与复位原因追踪回滚阈值的选择直接影响用户升级体验。如果阈值太小比如1次失败就回滚那么一些偶发的电源毛刺、通信干扰导致的启动失败也会被当成“升级失败”用户会看到明明升级成功了、过几天却自动回滚到旧版本体验非常奇怪。如果阈值太大比如5次新版本如果真有问题用户要忍受5次反复重启才能回到旧版本也可能触发车辆的故障灯。我实测下来3次是比较平衡的选择。前两次启动失败可能来自外部干扰或临时性故障第三次失败基本可以认定是固件本身的问题这时回滚是合理的。这个值我建议做成可配置参数存放在状态区的配置字段里而不是写死在代码中方便后期针对不同车型做标定调整。看门狗这块要特别注意TC3xx的Safety Watchdog。TC3xx有CPU看门狗和Safety看门狗两套机制安全看门狗的系统定时器上电后就开始跑如果App启动初始化流程太长还没来得及配置安全看门狗就会被拦腰咬复位。这类问题在OTA场景中特别容易暴露因为App在OTA刚启动时要跑自检、读状态、校验Flash时间比正常启动长很多一个疏忽就触发了安全看门狗复位。建议App启动后第一步就初始化并立即喂一次所有看门狗把“启动流程长”这个风险提前消除。复位原因寄存器也是排查问题的重要抓手。TC3xx的复位状态寄存器会记录上次复位是上电复位、软件复位、看门狗复位还是外部复位。把复位原因和SWAP状态一起写入DFlash日志排查问题时就多了双眼睛。我见过很多严重的升级问题最后都是靠“复位原因 状态记录”两侧对比定位出来的比如只有看门狗复位的场景下才触发回滚那一定是App超时管理和看门狗配置打架了。4.3 Flash擦写时间、诊断会话与上位机联调OTA刷写过程中ECU需要保持响应上位机诊断请求但Flash擦除和写入期间MCU是没法兼顾所有事务的。这里有一个现实矛盾PFlash扇区擦除时间较长从几百毫秒到几秒不等期间ECU无法及时响应诊断报文上位机如果等不到回复就直接报超时了。解决这个问题的标准做法是在开始一个耗时的Flash操作前先给上位机回复NRC 0x78response pending让诊断仪知道“ECU还活着正在处理请继续等待”。同时要把UDS会话超时参数P2Server和P2*Server配置到足够大否则即使回复了0x78上位机也会在P2超时后终止会话。另一个很容易忽略的细节TC3xx在执行PFlash擦写时如果CPU还在从PFlash取指令可能引发总线冲突或异常。虽然硬件层面做了不少处理但工程上稳妥的做法是把Flash驱动函数放到RAM中执行或者把关键擦写函数所在的代码段由链接脚本指定加载到RAM。我实际项目里就把Flash底层驱动放进了RAM执行区擦写期间CPU取指不经过PFlash稳定性和执行速度都有改善。上位机联调时建议搭建一个完整的“诊断仪-网络-ECU”环境用支持CAN/CANFD虚拟通道的刷写上位机来做全流程自动化测试。开发阶段可以用直接读写物理地址的调试器辅助比如Lauterbach TRACE32但量产验证阶段必须走真实UDS链路把OTA服务、超时处理、错误回复都跑一遍省得到整车集成阶段才发现问题。5. 常见故障现象与排查思路实录5.1 升级完成复位后一直卡在Bootloader这个现象在项目初期出现频率最高。表面看是“升级完进不了App”实际原因往往是目标槽里根本没有完整的App镜像或者跳转地址算错了。排查路径我建议按下面顺序来先用调试器读取状态记录看pendingSlot和bootAttempts是否异常增长再检查非激活槽起始地址处是否有合法的栈指针和入口地址然后检查App镜像的CRC和实际写入数据的CRC是否一致最后核对Bootloader跳转地址和App链接脚本的基地址是否一致。我遇到过一种特别隐蔽的情况App工程里使用的Flash基地址是0x80020000但Bootloader跳转时硬编码成了0x80010000两个看似都“在Flash范围内”的地址差了整整一个区段结果跳过去执行了几条无效指令后直接HardFault。这种问题光看代码很难发现必须靠反汇编确认跳转地址和App的vector table位置对得上。5.2 新App能启动几秒后看门狗复位这个现象比较典型我几乎每轮OTA测试都会遇到一两次。新App启动后界面或功能还没完全就跑起来几秒后系统无征兆地重启并且状态记录里bootAttempts持续累加。排查重点先放在看门狗初始化顺序上。TC3xx的Safety Watchdog默认配置下如果未在预期时间内被初始化会触发SMU报警并导致复位。App在OTA场景下启动流程比正常启动多了自检和状态确认逻辑耗时会明显变长如果这些额外逻辑被加在看门狗初始化之前系统就会被安全机制“误伤”。解决方法是App入口处最先完成看门狗配置把Safety Watchdog的窗口和超时参数设置到合理范围并喂一次狗然后再去做Flash校验、状态确认之类的耗时操作。还有一个细节如果App用了多个CPU核心TC3xx是多核芯片每个核的看门狗都要正确处理不能只喂主核而忽略从核的看门狗。5.3 偶发回滚重新刷一遍又好了这种“重启一次就好”的偶发问题最让人头疼因为特征不明显、复现率低。我遇到过的原因主要有三类电源不稳导致Flash写入电压异常、DMA传输被高优先级中断打断导致校验误判、目标槽未完全擦除导致残留数据污染新固件。前两类需要靠硬件和工程配置去解第三类有一个简单的工程习惯可以规避每次刷写前先把目标槽整个擦除一遍再开始写数据。不要为了省时间只擦目标覆盖区域因为PFlash按扇区擦除残留的旧版本数据会让回读校验结果变得非常诡异而这种结果往往不是每次都能复现的偶发问题。另外CRC校验操作建议放在一个不受频繁中断干扰的上下文里执行。如果必须在校验过程中响应中断可以考虑暂停调度器或临时关掉部分高优先级任务确保校验数据和结果都稳定避免因为中断时序改变导致校验结果反复横跳。5.4 用日志和远程数据定位OTA问题量产项目的OTA问题排查很多时候只能依赖车辆上报的日志而不是拿着调试器现场看。所以从第一天做SWAP功能就要规划好远程日志系统。我建议至少上报以下几类信息当前SWAP状态记录activeSlot、pendingSlot、bootAttempts、status最近多次复位的原因上电复位、看门狗复位、软复位、外部复位每次OTA的起止时间、固件版本、校验结果回滚事件的时间点和核心状态。有了这些数据即使车端问题无法复现也能从后台日志里还原出“当时状态区是怎么走的、为什么走了这个分支”。这也是我反复强调状态记录必须可靠的原因——它是整个SWAP机制的“黑匣子”也是远程排查的第一手依据。6. 写在最后的几条工程经验6.1 千万别碰UCB的默认配置除非你知道后果TC3xx的UCB区域是芯片配置的核心很多项目为了防回读、防篡改会在UCB里设置PFlash保护位。SWAP功能一旦加上就意味着Bootloader需要具备读写两个槽位的能力。如果保护位挡住了非激活槽的写入或回读OTA流程会直接失败而且失败原因很难通过普通诊断接口看出来。所以量产前一定要做一次“保护使能状态下完整OTA流程”的验证确认Bootloader和App都有权限访问对方所在的Flash区域。6.2 Bootloader与App之间的版本契约随着OTA功能越做越多App和Bootloader之间不再是简单的“你跳我”关系而是一种版本契约关系。新版本的App可能依赖Bootloader支持的某种新算法比如新的压缩协议、新的加密算法如果Bootloader版本过旧App要么在启动时主动拒绝运行要么通过诊断服务上报“需要先升级Bootloader”。我建议在状态区或固定地址放一个Bootloader版本号App启动时读取并与其自身需求的最低版本做比较避免版本不匹配导致的白屏或死机。6.3 不要迷信双区就一定安全A/B分区只是解决“软件损坏”的方案它解决不了“状态区物理损坏”的问题。如果DFlash状态记录所在的扇区因为寿命耗尽或物理损伤无法读写系统依然可能失去回滚能力。有条件的话状态区要做双份冗余一份主记录、一份备份记录每次写入都同步写入两份启动时先读主记录主记录CRC失败就读取备份记录。这样做虽然增加了写入耗时但换来的是极端场景下的从容。踩过几次坑之后我的体会是SWAP功能本身并不复杂难在把所有异常分支的时序和状态都设计到位。尤其测试阶段一定要优先把掉电场景跑透刷写中掉电、校验时掉电、确认时掉电、回滚时再掉电四个组合各跑几轮很多设计漏洞就自己现形了。以上是个人在TC3xx平台上的实战总结里面不少参数和阈值都是一步步试出来的希望你能少走点弯路。如果你们项目里已经跑通了更巧妙的状态记录方式欢迎一起交流。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进