ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ECU自刷新实战:Bootloader分区、跳转与Flash擦写详解

ECU自刷新实战:Bootloader分区、跳转与Flash擦写详解

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服务是标准做法:诊断仪先请求种子(Seed),ECU返回一个随机数,诊断仪用约定算法算出密钥(Key)发回来,ECU校验通过才解锁。

算法本身不用太复杂,常见的是种子做几次移位异或再加个固定常数,关键是种子要随机,每次会话不一样,防止重放攻击。我一般用ADC采一路悬空引脚的噪声做随机源,或者用内部RNG外设。密钥校验通过后设一个解锁标志,后续的擦除、写入操作都检查这个标志,没解锁一律拒绝。

注意:安全访问的种子和密钥算法属于项目保密内容,不同项目要用不同算法,别一套算法用遍所有产品。另外解锁状态在会话切换或者超时后要自动清除,不能一直有效。

4. 实操全流程:从握手到跳转的每一步

4.1 上电判断逻辑:进App还是进刷写

ECU每次上电,Bootloader先跑,第一件事是判断这次该干嘛。判断依据通常有三个:刷写请求标志、App有效标志、App完整性。逻辑顺序我一般这么排:

  1. 检查参数区是否有有效的刷写请求标志(比如诊断仪发了进编程会话的命令,或者某个特定引脚被拉低)。有则进刷写模式。
  2. 检查App有效标志是否为“有效”。无效则进刷写模式。
  3. 对App区域做一次快速CRC校验(或者只校验头部和关键段,全量校验太慢)。校验失败则进刷写模式。
  4. 以上都通过,跳转到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 刷写会话的完整时序

一次完整的刷写会话,从诊断仪的角度看是这样的时序:

  1. 进扩展会话(0x10 0x03),ECU回复肯定响应。
  2. 进编程会话(0x10 0x02),ECU切到Bootloader的刷写状态机,回复肯定响应。
  3. 安全访问(0x27 0x01请求种子,0x27 0x02发送密钥),解锁成功。
  4. 写指纹(可选,0x2E写诊断仪信息),记录谁刷的、什么时候刷的。
  5. 擦除Flash(0x31 0x01例程),ECU按请求的地址范围擦除,回复擦除结果。
  6. 请求下载(0x34),告诉ECU要传多少字节、什么格式,ECU回复每帧最大数据长度。
  7. 传输数据(0x36),循环发送数据块,每块带序号,ECU逐块应答。
  8. 请求退出传输(0x37),ECU做整包CRC校验,回复校验结果。
  9. 检查依赖/复位(0x31 0x02或0x11 0x01),ECU复位,重新上电后跳转到新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字节的缓冲区,写的时候还得拆页,徒增复杂度。另外,最后一页如果不满,要补0xFF(Flash擦除后的默认值)再写,不能只写实际数据长度,否则那一页剩余部分状态不确定。

实操心得:写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同时升级的调度策略;二是差分升级,只传变化的部分,减少刷写数据量。这两个方向在实际项目里都很有价值,等后面有机会再整理。

RELATED READING

延伸阅读

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