1. 从一次刷写失败说起ECU自刷新到底在解决什么问题第一次接触ECU自刷新是在一个台架项目上当时需要给一台控制器更新应用层程序手头只有一块板子和一根调试线没有专用的刷写工装。按照老办法得把芯片拆下来用编程器烧录一来一回至少半小时还容易把引脚搞坏。后来带我的前辈丢过来一个Bootloader的工程包说“你试试用CAN刷进去”那是我第一次意识到ECU自己就能完成程序更新这件事根本不需要把芯片从板子上请下来。所谓ECU自刷新说白了就是让控制器在不依赖外部编程器的前提下通过车载总线CAN、CAN FD、LIN、以太网等接收新的程序数据自己把Flash里的内容擦掉再写进去。这套机制的核心就是Bootloader——一段常驻在Flash里的引导程序它负责在正常应用App启动之前接管CPU判断是跳转到App还是进入刷写模式。你可以把它理解成电脑的BIOS加PE系统的合体平时它安静地躲在角落一旦收到刷写指令就出来主持大局。这套东西解决的核心痛点有三个。第一是产线与售后的效率整车下线或者4S店升级时不可能每台车都拆控制器自刷新让升级变成插上诊断仪点几下的事。第二是功能迭代的灵活性现在整车OTA越来越普遍App层动辄几十上百KB的更新没有Bootloader根本玩不转。第三是故障恢复能力App刷坏了或者跑飞了只要Bootloader还在就能重新刷回来不至于让控制器变砖。适合看这份笔记的人我大致分三类。一类是刚入行的嵌入式软件工程师手上接了Bootloader的开发任务但不知道从哪下手一类是做诊断或测试的同行需要理解刷写流程来设计测试用例还有一类是整车电子架构相关的从业者想搞清楚自刷新在整车上是怎么串起来的。不管你属于哪一类下面这些从实际项目里抠出来的细节应该都能对上你的胃口。2. 整体方案怎么搭先想清楚分区、通信和跳转三件事2.1 Flash分区规划给Bootloader和App划好地盘动手写代码之前第一件事是把Flash的地址空间分清楚。我见过不少新手上来就写擦写函数结果把Bootloader自己给擦了板子直接变砖。分区规划的核心原则是Bootloader区域必须独立且受保护App区域要留出足够的余量参数区要单独隔开。以一个常见的32位MCU为例假设Flash总共512KB我一般会这么分区域起始地址大小用途Bootloader0x0800000032KB引导程序含刷写逻辑App0x08008000448KB应用层程序参数区0x0807800016KB刷写标志、版本号、校验值备份区可选0x0807C00016KB旧版本备份用于回滚这里有几个数字不是随便定的。Bootloader给32KB是因为刷写逻辑本身不大但如果你要加CRC校验、加通信协议栈、加日志记录空间会紧张我建议至少留24KB以上32KB比较稳妥。App的起始地址必须和Bootloader里配置的跳转地址严格一致差一个字节都跳不过去。参数区单独隔开是为了避免App运行时误擦刷写标志、App有效标志这些关键信息都放这里。注意分区地址一定要和链接脚本.ld文件或分散加载文件里的配置对齐Bootloader工程和App工程各有一套链接脚本两边的地址必须严丝合缝。我踩过的坑是App工程忘了改链接脚本编译出来的程序起始地址还是0x08000000一刷进去就把Bootloader覆盖了。2.2 通信协议选型CAN还是串口看场景说话刷写通道的选择直接决定了Bootloader的复杂度。车载场景下CAN是绝对主流原因很简单整车上到处是CAN总线诊断仪插上OBD口就能通到目标ECU不需要额外接线。但CAN的带宽有限经典CAN一帧最多8字节数据刷一个100KB的App算上握手、应答、擦除等待实际耗时在几分钟量级。如果你的场景是台架调试或者产线工装串口UART其实更省事波特率拉到115200甚至921600刷写速度比CAN快得多协议也好写。我个人的经验是量产项目走CAN研发阶段留一路串口做备份通道两套协议共用同一套刷写状态机只是底层收发接口不同。协议层我一般基于UDS统一诊断服务来设计核心用到这几个服务0x10会话控制进编程会话、0x27安全访问解锁刷写权限、0x31例程控制擦除Flash、0x34/0x36/0x37请求下载/传输数据/请求退出传输。这套服务的好处是诊断仪端有成熟的支持不用自己造轮子。如果项目规模小、不想上UDS也可以自定义一套简单的帧格式比如“帧头命令长度数据校验”但安全访问和擦写保护这两块逻辑不能省。2.3 跳转机制从Bootloader到App的那一脚跳转是整个Bootloader里最容易出问题的地方也是新手最常卡住的地方。跳转的本质是把CPU的控制权从Bootloader交给App具体要做三件事关中断、设栈指针、跳复位向量。关中断很好理解跳转过程中如果有中断进来中断向量表还指向Bootloader的区域App还没起来很容易跑飞。设栈指针是从App的向量表第一个字初始栈顶地址读出来赋给MSP。跳复位向量是从向量表第二个字读出App的Reset_Handler地址然后跳过去。typedef void (*pFunction)(void); pFunction JumpToApp; uint32_t appStackAddr *(volatile uint32_t*)APP_START_ADDR; uint32_t appResetAddr *(volatile uint32_t*)(APP_START_ADDR 4); __disable_irq(); __set_MSP(appStackAddr); JumpToApp (pFunction)appResetAddr; JumpToApp();这段代码看着简单但有几个细节必须注意。第一跳转前要把所有外设恢复到复位状态尤其是CAN、定时器这些否则App初始化时可能因为外设状态残留而出错。第二App工程里要重新配置向量表偏移SCB-VTOR APP_START_ADDR否则中断还是往Bootloader的向量表跑。第三跳转前最好把刷写标志清掉或者置成“App有效”避免下次上电又进刷写模式。实操心得调试跳转时我习惯在跳转前后各点一个LED或者打一条串口日志确认代码确实执行到了跳转那一步。很多时候“跳不过去”其实是前面的条件判断没通过比如App有效标志没置位程序压根没走到跳转函数。3. 核心细节拆解擦写、校验与安全访问的门道3.1 Flash擦写页对齐和等待时间是两个硬约束Flash的擦写操作有几个物理特性绕不开擦除必须以页或扇区为单位写入必须以字或半字为单位且写之前必须先擦。这意味着你收到的数据流不能直接往Flash里怼得先攒够一页的数据擦掉对应页再整页写进去。以常见的2KB页大小为例刷写流程是这样的收到下载请求后先根据数据总长度算出需要擦除多少页逐页擦除然后每收满2KB数据就调用一次写函数。这里有个坑擦除一页的耗时远大于写一页某些MCU擦一页要几十毫秒如果每收一帧数据就擦一次刷写速度会被拖垮。正确做法是进入编程会话后先把所有目标页一次性擦完再开始传数据。// 擦除示例伪代码具体寄存器操作看芯片手册 for (page startPage; page endPage; page) { FLASH_ErasePage(page); while (FLASH_GetStatus() ! FLASH_READY); // 等待擦除完成 }等待时间这块我建议在擦除和写入之后都加超时判断别用死循环等。曾经有个项目因为Flash控制器时钟配置错了擦除一直不完成程序卡在while里看门狗复位后又重新进刷写形成死循环。后来加了超时计数超时就报错退出问题一下就定位到了。还有一个容易被忽略的点写Flash时CPU取指会被暂停。如果你的Bootloader代码和要擦写的Flash在同一块Bank里擦写期间CPU没法取指只能靠RAM里运行的代码或者硬件自动等待。多数MCU的Flash控制器会自动处理这个等待但如果你在中断里做擦写就要格外小心。我的做法是刷写期间关掉所有非必要中断只留通信接收用DMA或者轮询。3.2 完整性校验CRC不是可选项是必选项数据传完了不代表刷成功了必须做完整性校验。我见过太多“刷写成功但程序跑飞”的案例追到最后都是校验没做或者做得不严。校验分两层传输层校验和整包校验。传输层校验针对每一帧数据常用的是帧内累加和或者CRC8作用是发现单帧传输错误错了就请求重传。整包校验针对整个App镜像常用CRC32在传输开始前由上位机算好总校验值ECU在接收完所有数据后自己再算一遍两边比对。只有整包校验通过才把App有效标志置位否则标记为无效下次上电继续进刷写模式。// 整包CRC32校验查表法速度快 uint32_t crc32_calc(uint8_t *data, uint32_t len) { uint32_t crc 0xFFFFFFFF; for (uint32_t i 0; i len; i) { crc crc_table[(crc ^ data[i]) 0xFF] ^ (crc 8); } return crc ^ 0xFFFFFFFF; }校验值存哪里也有讲究。我一般把App的CRC32、版本号、刷写时间戳一起存在参数区App启动时Bootloader先读出来比对一致才跳转。参数区本身也要做备份主备两份写入时先写备份再写主份读取时两份比对不一致就用备份恢复。这套机制听起来啰嗦但真遇到参数区被干扰的时候能救命。3.3 安全访问别让谁都能刷你的ECU安全访问这块很多小项目图省事直接跳过我觉得这是大忌。刷写权限不设防意味着任何人接上总线就能改你的程序轻则功能异常重则安全隐患。UDS的0x27服务是标准做法诊断仪先请求种子SeedECU返回一个随机数诊断仪用约定算法算出密钥Key发回来ECU校验通过才解锁。算法本身不用太复杂常见的是种子做几次移位异或再加个固定常数关键是种子要随机每次会话不一样防止重放攻击。我一般用ADC采一路悬空引脚的噪声做随机源或者用内部RNG外设。密钥校验通过后设一个解锁标志后续的擦除、写入操作都检查这个标志没解锁一律拒绝。注意安全访问的种子和密钥算法属于项目保密内容不同项目要用不同算法别一套算法用遍所有产品。另外解锁状态在会话切换或者超时后要自动清除不能一直有效。4. 实操全流程从握手到跳转的每一步4.1 上电判断逻辑进App还是进刷写ECU每次上电Bootloader先跑第一件事是判断这次该干嘛。判断依据通常有三个刷写请求标志、App有效标志、App完整性。逻辑顺序我一般这么排检查参数区是否有有效的刷写请求标志比如诊断仪发了进编程会话的命令或者某个特定引脚被拉低。有则进刷写模式。检查App有效标志是否为“有效”。无效则进刷写模式。对App区域做一次快速CRC校验或者只校验头部和关键段全量校验太慢。校验失败则进刷写模式。以上都通过跳转到App。这个顺序里第3步的全量CRC在每次上电都做的话启动时间会变长。我的折中方案是正常上电只校验App头部的几个关键字段栈顶地址是否在合法RAM范围、复位向量是否在App区域内全量CRC只在刷写完成后做一次。这样既保证了启动速度又保证了刷写质量。bool app_is_valid(void) { uint32_t sp *(volatile uint32_t*)APP_START_ADDR; uint32_t pc *(volatile uint32_t*)(APP_START_ADDR 4); // 栈顶必须在RAM范围内复位向量必须在App区域内 if (sp RAM_START || sp RAM_END) return false; if (pc APP_START_ADDR || pc APP_END_ADDR) return false; return true; }4.2 刷写会话的完整时序一次完整的刷写会话从诊断仪的角度看是这样的时序进扩展会话0x10 0x03ECU回复肯定响应。进编程会话0x10 0x02ECU切到Bootloader的刷写状态机回复肯定响应。安全访问0x27 0x01请求种子0x27 0x02发送密钥解锁成功。写指纹可选0x2E写诊断仪信息记录谁刷的、什么时候刷的。擦除Flash0x31 0x01例程ECU按请求的地址范围擦除回复擦除结果。请求下载0x34告诉ECU要传多少字节、什么格式ECU回复每帧最大数据长度。传输数据0x36循环发送数据块每块带序号ECU逐块应答。请求退出传输0x37ECU做整包CRC校验回复校验结果。检查依赖/复位0x31 0x02或0x11 0x01ECU复位重新上电后跳转到新App。这个时序里第5步擦除和第8步校验是两个耗时点诊断仪端要设置足够的超时时间。我遇到过诊断仪默认超时2秒但擦除448KB Flash花了5秒结果诊断仪以为ECU没响应直接报错。后来把P2超时ECU响应超时和P2*超时ECU延迟响应超时都调大了才解决。4.3 数据接收与写入的缓冲策略数据接收这块核心矛盾是通信帧小、Flash页大。CAN一帧8字节Flash一页2KB中间差了256倍。我的做法是在RAM里开一个页大小的缓冲区收到的数据先攒着攒满一页就触发一次写Flash。#define PAGE_SIZE 2048 uint8_t pageBuf[PAGE_SIZE]; uint32_t bufIndex 0; uint32_t writeAddr APP_START_ADDR; void on_data_received(uint8_t *data, uint32_t len) { for (uint32_t i 0; i len; i) { pageBuf[bufIndex] data[i]; if (bufIndex PAGE_SIZE) { FLASH_WritePage(writeAddr, pageBuf, PAGE_SIZE); writeAddr PAGE_SIZE; bufIndex 0; } } }缓冲区大小要和Flash页大小对齐别搞个1000字节的缓冲区写的时候还得拆页徒增复杂度。另外最后一页如果不满要补0xFFFlash擦除后的默认值再写不能只写实际数据长度否则那一页剩余部分状态不确定。实操心得写Flash的函数一定要先判断目标地址是否已经擦除过。有些MCU允许重复写同一地址只要还是1变0但有些会报错。保险起见擦除和写入严格配对擦完立刻写别隔太久。4.4 刷写失败的回滚与重试刷写过程中断是常态线束松动、电源波动、诊断仪死机都可能发生。Bootloader必须能处理“刷到一半断了”的情况。我的设计原则是App有效标志只在整包校验通过后才置位刷写开始前先把它清掉。这样即使刷到一半断电下次上电发现标志无效自动进刷写模式重新刷一遍就行。如果项目要求更高可以加备份区做回滚刷写前把旧App拷贝到备份区新App校验失败时从备份区恢复。但备份区会占用额外Flash空间拷贝也耗时小项目一般不做。我的经验是只要保证“刷坏了能重新刷”回滚不是必需品但刷写标志的清除和置位时序必须严格。5. 踩过的坑与排查实录5.1 常见问题速查表现象可能原因排查方向跳转后程序不跑向量表偏移没设、栈指针不对检查App的VTOR配置和链接脚本刷写中途报错超时设置太短、擦除耗时超预期调大P2/P2*超时实测擦除时间刷完校验失败缓冲区未对齐、末页未补0xFF检查写入地址和长度上电反复进刷写App有效标志没置位、CRC算错读参数区标志核对CRC算法CAN通信无响应波特率不匹配、终端电阻缺失示波器看波形查总线终端擦除后读回全0擦除未完成就读取加状态轮询和超时判断5.2 几个印象深刻的排查案例有一次台架上刷写总是到80%左右失败诊断仪报“传输中断”。查了半天通信日志发现是ECU端接收缓冲区溢出——上位机发得太快ECU还在写Flash没来得及收下一帧。解决办法是在写Flash期间让通信外设的接收FIFO继续缓存或者上位机端加流控收到应答再发下一块。这个问题的根子是写Flash时CPU被占用通信处理不及时属于设计阶段就该考虑的事。还有一次刷写完成后App能跑但CAN通信不正常。查到最后是Bootloader跳转前没把CAN外设复位App初始化时CAN控制器还处于之前的配置状态重新初始化失败。后来在跳转函数里加了一段外设复位代码把所有用到的外设寄存器恢复默认值问题消失。这个坑告诉我跳转不只是跳代码还要跳环境。5.3 独家避坑技巧第一个技巧在Bootloader里留一个“强制刷写”的硬件入口比如某个引脚上电时拉低就进刷写模式。软件标志可能因为各种原因失效硬件入口是最后的保险。产线和售后特别需要这个。第二个技巧刷写日志存到参数区记录每次刷写的时间、结果、失败原因。出问题时读出来一看就知道上次刷写发生了什么比猜强一百倍。第三个技巧用RAM调试法验证跳转。把App的二进制通过调试器直接加载到RAM里跑确认App本身没问题再排查Bootloader的跳转逻辑。这样能把问题范围缩小一半。6. 关于工具链和测试的一点个人做法工具链这块编译器用项目指定的就行关键是链接脚本要维护好两套Bootloader一套、App一套地址严格对应。我习惯在编译后加一个脚本自动检查生成的bin文件大小和起始地址超了或者偏了直接报错防止手滑。测试方面我一般分三步走。第一步在RAM里模拟刷写流程把擦写函数替换成内存拷贝验证状态机和协议逻辑。第二步在真实Flash上刷但先用一个小镜像比如几KB的测试程序跑通全流程。第三步刷完整App做断电重启、重复刷写、异常中断等压力测试。每一步都记录耗时和结果形成基线后续改代码时对比着看。最后分享一个我常用的调试手段在Bootloader里实现一个简单的命令行通过串口能查看当前刷写状态、参数区内容、Flash指定地址的数据。这个东西花不了多少时间但排查问题时比接调试器还方便尤其是现场没有调试器的时候。这个内容后续还可以往两个方向扩展一是多节点并行刷写整车上多个ECU同时升级的调度策略二是差分升级只传变化的部分减少刷写数据量。这两个方向在实际项目里都很有价值等后面有机会再整理。
阅读完成 · 觉得有帮助?