基于UDS的CAN本地OTA升级:Bootloader固件刷写实战解析
2026/9/16 8:19:03 网站建设 项目流程

做汽车电子或者嵌入式开发的朋友,应该都有过这种经历:产品功能调通了,代码也编译过了,结果要升级固件的时候,要么拿着烧录器一台台开壳,要么让产线工人用J-Link排队怼,效率低不说,还容易把排针搞坏。我刚入行那会儿也觉得这事就该这样,直到后来把基于UDS诊断协议的CAN本地OTA升级这套流程完整跑通,才意识到之前那些折腾都是没必要的弯路。这个项目说白了就是用ISO 14229定义的UDS(统一诊断服务)协议,通过车上的CAN总线直接给ECU刷写新固件,不需要拆设备、不需要专用烧录器,一台CAN卡加一个上位机软件就能搞定。它适用于芯片Bootloader开发、应用层固件更新、产线刷写、售后维修等场景,也是远程OTA落地之前必须打通的地基。如果你正在做STM32、S32K、瑞萨这类MCU的Bootloader,或者想把手动烧录升级成规范的诊断刷写流程,这篇文章值得你花几分钟看完。

1. 整体设计思路与协议栈拆解

1.1 为什么选UDS而不是直接写Flash

很多人刚接触OTA时会有个疑问:既然就是要往Flash里写数据,那我直接在Bootloader里搞一个自定义协议不就行了?比如定义0xAA开头代表擦除、0xBB代表写数据。这么做在项目初期看起来省事,但后面扩展到产线、售后、不同供应商ECU联调时就会很痛苦。车载行业有大量现成的诊断工具和测试规范都基于UDS协议,你自研的协议工具链支持不了,测试团队也没法下手,最后还得回过头来兼容标准。

UDS的好处在于它是一套完整的状态机和管理框架,不只是“写Flash”这么简单。它定义了会话管理先切到编程会话才能刷写、安全访问先解锁才能写入、例程控制负责擦除和CRC校验、数据传输服务负责分段下发固件。每一步都有明确的请求/响应格式,出错时还有标准的NRC(否定响应码)告诉你是哪个环节出了问题。这套机制不是我拍脑袋发明的,而是ISO 14229和ISO 15765-2这两份规范沉淀了几十年的产物。所以我的建议很直接:除非你的项目极其简单且完全没有外部工具交互需求,否则走UDS标准协议是当下最稳、最不容易返工的选择。

1.2 本地OTA的协议栈构成

CAN本地OTA的核心分为三层,每个层次都有自己的职责。最上面是应用层的UDS服务,负责协商“要做什么”,比如切换会话、解锁、请求下载、传数据、复位;中间是传输层,也就是ISO 15765-2(通常简称CAN TP),负责把大块数据拆分到CAN帧里,或者把多个CAN帧拼成一个完整的UDS消息;最底层就是CAN控制器和收发器,负责在总线上实际发送接收报文。

我用一个生活化的例子来解释:UDS服务就像快递公司客服,你告诉它“我要寄一个100MB的文件”,它不会直接拿着100MB的文件去邮局,而是把文件交给分包员,分包员按邮局的纸箱规格把文件切成一个个小包裹,贴上编号,再交给邮递员骑电动车送出去。这里的“分包员”就是CAN TP,而“邮递员”就是CAN控制器。

经典CAN一帧数据场最多8字节,CAN FD可以到64字节。而一条UDS消息动不动就是几十上百字节,比如34服务的请求下载,光地址和长度就要占掉好几个字节,更别提36服务传输数据时要同时带块序号和数据。所以CAN TP是这个方案里绝对不能省的一层。如果跳过它直接操作CAN帧,你会被各种分帧、重组、流控、超时重传问题折磨死。

1.3 本地升级与远程OTA的关系

有些做应用层的朋友可能会问:现在不是流行远程OTA吗?通过4G或者以太网把固件包下载到车机,再通过网关刷到各个ECU。本地OTA还有没有价值?我的看法是,本地OTA是远程OTA的“子集”和“演习”。远程OTA的最后一公里,绝大多数还是通过CAN把固件从网关或T-Box转发到目标ECU,走的依然是UDS诊断服务。只不过在本地模式下,“上位机+CAN卡”替代了“云平台+T-Box”,数据来源是本地文件而不是网络下载。

换句话说,你先把本地刷写流程调稳,后续做远程OTA时,Bootloader侧代码几乎不用大改,只需要把数据源换成网络通道就行。这也是为什么很多OEM和Tier1在SOP之前,首先强制要求的就是把本地UDS刷写打通,因为它是所有升级方案的共同底座,绕不开也省不掉。

2. Bootloader侧程序设计与分区规划

2.1 Flash分区策略不能拍脑袋定

在做Bootloader之前,先花时间把芯片的Flash地址空间规划好。这一步如果偷懒,后面调跳转、调擦除时会非常痛苦。以常见的STM32为例,我一般建议把Flash分成至少四大块:Boot区、APP区、备份区(可选)、标志位区。Boot区存放Bootloader代码,它永远是上电后第一个执行的;APP区存放用户应用程序;备份区用来存放升级前的旧固件或者升级过程中的临时固件;标志位区则记录当前APP是否有效、是否需要进入刷写模式、固件版本号等信息。

地址分配不是随意定的,要考虑Bootloader本身的大小、APP的起始地址对齐、每个扇区/页的大小。比如某颗芯片的Flash扇区是16KB对齐,那你APP起始地址就不能选在0x08007C00这种非对齐位置,否则擦除和写入时会把Bootloader或标志位一起抹掉。下面是我在一个项目中用过的分区方案,供参考:

分区名地址范围大小用途
Bootloader区0x08000000 - 0x08007FFF32KB启动代码、UDS刷写逻辑
参数/标志区0x08008000 - 0x08008FFF4KB有效标志、版本号、刷写状态
APP区0x08009000 - 0x0803FFFF220KB应用程序代码
备份区0x08040000 - 0x0807FFFF256KB升级前备份或临时固件

注意这个方案里APP区地址不是从0x08008000开始的,因为我留了一小块独立区域给参数和标志位。更新标志位时单独擦写这个小块即可,不至于为了写一个字节把整片APP擦掉。

2.2 Flash驱动在RAM中运行的原因

写Bootloader时有个容易踩的坑:你在调用Flash擦除或写入函数时,如果这段代码本身就在Flash里,而你要擦除的扇区恰好包含这段代码所在的扇区,就会导致“自己擦自己”的经典故障。轻则擦除函数执行到一半飞掉,重则芯片直接进HardFault。解决思路是把Flash底层驱动复制到RAM中执行,从RAM里调用擦写函数。

具体做法是在链接脚本里定义一个可执行RAM段,把flash_erase、flash_write这类函数放到这个段中,并在启动阶段或者每次刷写前用memcpy把它从Flash搬运到RAM。注意RAM里的代码执行时地址要正确重定位,函数指针也要指向RAM地址。STM32的IAP例程里通常有类似__RAM_FUNC的宏定义,S32K的IDE里也可以通过section attribute来实现。这个细节看起来不起眼,但实际调试时如果发现“擦一次就死机”,九成是这个问题。

2.3 跳转APP前的关键动作

刷写完成后,Bootloader要跳转到APP并让APP正常跑起来,这一步看似简单,实际很容易翻车。首先是中断向量表的位置,在APP启动代码里必须把SCB->VTOR寄存器设置到APP区的起始地址,否则APP里任何一个中断触发都会跳到Bootloader的向量表,程序直接跑飞。

其次是关闭全局中断和外设时钟。跳转前要确保所有中断都关掉,至少要把SysTick、外设中断、CAN接收中断等全部禁用,并把用到的外设时钟复位到默认状态,避免APP初始化时读到残留配置。最后把主栈指针MSP设置为APP起始地址处存放的初始栈顶值,再通过函数指针跳转到复位向量执行APP的Reset_Handler。我习惯在跳转前把R0、R1、R2清零,并开启看门狗前先评估好跳转后APP最早喂狗的位置,防止跳转瞬间看门狗复位。

2.4 看门狗与升级包的校验策略

刷写过程中如果开了看门狗,而擦写Flash又比较耗时,极易导致看门狗超时复位。我的做法是在进入刷写模式后,把看门狗配置成“可暂停”状态或者在每次擦写循环里及时喂狗。S32K的WDOG窗口模式还要求喂狗时间不能太早也不能太晚,所以刷写循环的节奏需要仔细计算。

固件包校验方面,强烈建议在APP有效标志区放一个CRC值,每次上电Bootloader读取并验证APP区的CRC。如果CRC不对,就自动进入刷写模式等待升级,否则正常跳转APP。端到端的固件完整性校验通常用CRC32或CRC64,也可以为每个数据块额外计算一个CRC与块序号一起发送,让ECU实时校验收到的数据是否有误。CRC多项式、初值、输入输出反转这些参数,上位机和ECU必须完全一致,否则烧完就变砖。

3. 刷写流程逐条拆解:从会话切换到设备复位

3.1 进入编程会话:10服务

整个刷写流程第一步是让ECU进入编程会话。UDS的10服务(DiagnosticSessionControl)带子功能0x02表示reprogramming会话,0x01是默认会话,0x03是扩展会话。进入编程会话后,ECU会禁用部分功能,禁止应用层某些操作,为接下来的解锁和Flash操作做准备。

这里有一个容易忽略的点:10 02的肯定响应里会带上P2ServerMax和P2*ServerMax两个时间参数,分别表示服务器处理请求的最长时间和增强超时时间,单位是毫秒。上位机要根据这两个值合理设置响应超时时间,不能一概用默认的500ms。如果把P2超时设得太短,ECU擦除Flash耗时较长还没回复,上位机就已经判定超时了。

另外,很多ECU在上电后自动进入Bootloader等待刷写,而有些ECU则需要先运行APP,再通过诊断指令请求进入Bootloader。这两种方式对应不同的应用场景:前者适合产线刷写,后者适合售后OTA。无论哪种方式,10 02服务都是必由之路。

3.2 安全访问与密钥算法:27服务

进入编程会话后,下一步通常是安全访问。27服务(SecurityAccess)需要先发送Seed请求,ECU收到后返回一串随机种子,上位机通过预置的密钥算法算出Key再发回,ECU对比Key是否正确,正确就解锁允许执行敏感操作。我强烈建议不要在代码里明文存储密钥明文,至少做个多字节异或或者查表混淆,产线上和研发手上用的Key算法可以由同一套上位机工具下发不同的Key参数。

安全访问有不少细节会影响联调效率。首先是失败计数,绝大多数ECU都会限制安全访问尝试次数,比如连续3次Key错误就锁定一段时间(通常10秒)。其次是种子有效时间,种子发出后30秒内不使用就失效。种子和Key都是多字节,通常2~4字节,具体长度由厂商定义,没有强制的统一标准,所以上位机和ECU的算法、长度、字节序(大端还是小端)必须保持一致,这个变量也要做成配置文件而不是写死在代码里。

3.3 请求下载与擦除:34、31服务

安全解锁后,接着就该告诉ECU“我要写多长的数据、写到哪个地址了”。这里用到的服务是34(RequestDownload),请求里包含dataFormatIdentifier、addressAndLengthFormatIdentifier、memoryAddress和memorySize四个字段。其中addressAndLengthFormatIdentifier的高四位表示地址长度,低四位表示长度长度,单位是字节,比如0x24表示地址4字节、长度4字节。memoryAddress是要写入的起始Flash地址,memorySize是固件数据的总字节数。

ECU收到34请求后,如果地址和大小合法,就会返回一个肯定响应,里面包含maxNumberOfBlockLength,告诉上位机每个块最多可以传输多少字节。上位机的分块大小应该以这个值为准,不要随意加大。很多ECU要求在正式写数据之前先擦除对应区域,常见的做法是通过31服务(RoutineControl)的例程擦除,也有的芯片在34请求到来时自动擦除。你需要在设计固件包时明确“先擦后写”的时序,上位机的状态机要跟ECU端严格对应。

3.4 数据传输:36服务的块序列号坑点

真正传数据时使用36服务(TransferData),块序列号blockSequenceCounter从1开始,每发一帧加1,到0xFF后回绕到0。ECU端会校验序列号是否连续,如果收到重复帧或乱序帧,很多ECU会直接回NRC 0x73或者忽略该帧,等待上位机重新发送当前块。

36服务的最大数据长度受34响应中maxNumberOfBlockLength的限制,经典CAN基于TP层的数据场最多也就几个字节到几十字节。我建议把36服务的有效数据长度设置成4的倍数,因为Flash编程通常按字或双字写入,长度非对齐会带来额外处理麻烦。另一个经验:如果上位机发送速度太快,ECU擦写Flash的耗时跟不上,就要靠CAN TP的流控帧来限速,或者在上位机里对每个36请求等待响应后再发下一个。

3.5 传输结束与ECU复位:37、11服务

数据全部发送完成后,发送37服务(RequestTransferExit)告知ECU传输结束。ECU收到后通常会做一次内部完整性校验,比如校验整个接收缓冲的CRC值,校验通过就返回肯定响应。此时部分Bootloader还会通过31服务触发一次全片CRC校验,或者校验APP的启动头,确保不是一份残缺的固件。

最后发11服务(ECUReset)让ECU复位执行新APP,复位类型一般是0x01硬复位或0x03软复位。发完复位指令后,上位机不能立刻断开连接,要等ECU重启后重新进入默认会话,甚至再次尝试读取版本号验证升级结果。整个流程顺序如果打乱,比如没做安全访问就发34,ECU会回NRC 0x33拒绝执行,这些都是UDS状态机约束的结果。

4. CAN TP分包粘包与超时管理实战

4.1 单帧、首帧、连续帧、流控帧怎么配合

ISO 15765-2定义了四种帧类型用于一个UDS消息的分包传输。数据小于等于7字节(经典CAN通常是7字节,因为占用1字节PCI)时用单帧SF,一帧发完。数据超过单帧容量时,第一帧用首帧FF,包含总长度信息和前2字节数据,后续用连续帧CF携带剩余数据;接收方收到首帧后必须先回一个流控帧FC,告诉发送方可以发几个连续帧、帧间最小间隔是多少。

流控帧里的参数需要认真理解。BlockSize(BS)表示允许发送的连续帧数量,如果BS=0表示不再限制,发送方可以一直发;STmin是连续帧之间的最小间隔,单位通常是毫秒,也可以0~127us。很多CAN TP协议栈实现里默认BS=0、STmin=0,但在高速传输时如果ECU处理不过来,可以通过流控帧动态降低发送速率。实际项目中我遇到过TP层“丢帧”的情况,排查后发现是STmin设置太小,ECU接收FIFO溢出导致丢帧。

4.2 大文件传输时常见的分块策略

一个固件通常几十KB甚至几百KB,而一帧CAN数据最多才几十字节,这意味着要发送几千帧。如果每帧都要等待ECU应答后再发下一帧,总耗时非常长。所以在设计固件打包和上位机状态机时,我通常把固件分成若干个块(Block),每块包含一定数量的CAN TP消息,块与块之间可以适当等待响应,块内部按TP层流控连续发送。

比如某款芯片的Flash页大小是4KB,我可以把每个块的大小设为4KB,足够数据通过TP分成若干帧,再配合块序号和API接口上层的CRC校验。举例:若固件为128KB,每个Block为4KB,则共32个Block,上位机依次调用32次“请求下载-传输-退出”过程循环,或按芯片规格一次性请求下载全部长度再逐块传输。这个块大小的选取还要考虑RAM缓冲,ECU端如果接收缓冲只有2KB,那上位机一个Block设4KB就会导致数据溢出。

4.3 P2、S3超时机制与上位机状态机

UDS协议里有几个重要定时器:P2Server是服务器处理一个请求的最大时间,P2Server是增强响应时间,S3Server是会话保持超时时间。如果上位机在P2时间内没收到响应,可以再等P2时间,仍然没有则判定超时。S3超时是指如果ECU在S3时间内没有收到任何诊断请求,就会自动从非默认会话回到默认会话,甚至退出刷写状态,所以刷写大文件时上位机的发送节奏不能太慢,否则S3超时会打断整个流程。

上位机的状态机设计相当重要。我建议把刷写流程拆成若干个阶段,比如init、session_switch、security_access、erase、download、transfer_exit、reset、verify,每个阶段严格做超时和响应码检查。不要把所有逻辑写在一个大函数里硬等响应,而是要基于状态迁移来做,这样即使某个阶段失败,上位机也能精准报出“卡在安全访问,NRC=0x33”,排查起来非常高效。

5. 常见问题与排查技巧实录

不管方案设计得多完美,联调阶段总会遇到各种问题。我整理了一份高频问题清单,每个都是实际调试中碰到过的,你可以直接拿来对照。

现象可能原因处理思路
ECU完全无响应CANH/CANL接反、波特率不一致、终端电阻缺失用示波器或CAN卡自检回环确认物理链路
能收到响应但频繁超时P2/P2*超时设置太短、TP流控参数不合适按10 02响应里的时间参数动态调整
返回NRC 0x3134请求的地址不在有效范围、地址未对齐检查分区表和对齐规则
返回NRC 0x33未做安全访问或Key错误确认27服务流程,检查Seed/Key算法
返回NRC 0x70块序列号不正确、传输长度不匹配核对36服务的连续性,数据总长度是否等于34声明
传数据过程中断或变砖看门狗复位、Flash擦写函数自身被擦、掉电在RAM运行Flash驱动,升级期间维持喂狗,加备份区
跳转APP后死机VTOR未设置、外设状态未清理、APP地址错误单步调试看PC指针是否跳到了APP的Reset_Handler
CAN卡链路提示无法打开串口驱动程序未装、端口被占用、CAN卡供电不足重置USB后重新插拔,换一个USB口,重新安装驱动

5.1 上位机模拟发现NRC 0x31的排查思路

NRC 0x31表示请求超出范围,这个错误码在刷写流程中出现频率最高。除了地址越界,还有一个常见原因是ECU端对memorySize做了严格检查,比如它要求长度必须是4的倍数,你传了12345字节就会拒绝。还有的ECU要求一次只允许请求固定大小的区域,如果超过某个阈值也会返回0x31。遇到这种情况,先查ECU端的分区表限制和对齐要求,再对照34请求的字节内容逐字段比对。

5.2 CAN波特率与采样点设置

CAN总线的物理层设置直接影响通信稳定性。常见波特率有500kbps、250kbps、125kbps,整车和零部件之间要匹配才能通信。波特率不准会导致报错帧率非常高。另外采样点位置也很关键,一般建议在70%到80%之间。对于500kbps,如果总线时钟是8MHz,预分频16,同步跳转宽度设为1,BS1设为5,BS2设为2,采样点就是(1+5)/(1+5+2)=75%,这是一个比较通用的起点。

5.3 工具的选型与测试建议

本地OTA调试需要一套趁手的工具。硬件方面,我常用的是PCAN、CANoe、周立功USBCAN这类USB转CAN适配器,都支持标准帧和扩展帧。软件方面,CANoe做全流程验证最方便,脚本语言CAPL可以用来模拟UDS刷写;也可以用Python的开源库如python-can配合cantools解析DBC和ODX,做一个轻量级上位机。先把上位机和ECU之间的流程打通,再逐步增加异常注入测试,比如故意丢帧、延迟响应、断电重启,这样固化的代码质量会高很多。

6. 实测参数与经验总结

以我最近一次基于STM32F407 + TJA1050的方案为例,CAN波特率设置为500kbps,数据场为经典CAN 8字节。Bootloader从接收到固件包到完成Flash编程并复位,全程耗时大约1分20秒(固件大小248KB)。期间36服务共传输约32000个CAN TP帧,没有出现一帧丢失,NRC错误码只有一次是人为注入的地址越界。这个速度和稳定性已经足够项目量产使用。

另外一个值得提的经验是:每次刷写前,先通过22服务读取一下当前APP的版本号,把版本号显示在上位机界面上,刷写完再读一次确认版本已更新。这一步虽然简单,但在产线上能省下大量“到底刷没刷进去”的扯皮时间。我给固件包定义了一个固定文件头,包含魔数、固件版本、目标MCU型号、固件长度、CRC32,Bootloader先验证魔数和CRC再开始刷写,从根上杜绝了拿错包刷错料的问题。

有人可能觉得本地OTA比不过远程OTA“高级”,但我的体会恰恰相反。把本地刷写流程做扎实,你实际收获的不只是一套代码,而是对整个诊断协议栈、Flash管理、错误处理体系的理解。后面再做远程OTA、多ECU刷写调度、产线自动化测试,都会顺很多。如果接下来你想继续深入,我建议重点研究UDS的34/36/37在CAN FD下的表现,以及Bootloader持A/B分区后的回滚策略,这两块是行业里真正值钱的方向。

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

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

立即咨询