最近在给一个纯电动项目的VCU升级工具做方案选型,客户指定要用LabVIEW配合CAN卡来实现ECU的UDS刷写。折腾了小两周,从一穷二白到最终把Bootloader到App的整条刷写链路跑通,踩了不少坑,也积累了一些实打实的经验。这篇文章就是把这套基于图莫斯CAN卡的LabVIEW版UDS升级上位机的搭建过程完整复盘一遍,从硬件接线、CAN驱动封装,到ISO-TP组包、诊断服务流程,再到HEX文件解析和刷写状态机,尽量把关键细节和常见问题都讲透。
这套工具最终实现的功能很简单:通过CAN总线给ECU刷写固件,支持UDS标准诊断服务,能读取固件、校验数据、查看NRC错误码,带基本的进度和错误日志界面。但越是看起来简单的工具,真正做起来越容易在时序、组包、状态切换这些地方翻车。这篇文章适合正在做或者准备做ECU刷写工具的工程师看,也适合刚接触CAN和UDS协议、想搞明白刷写全流程的新手。废话不多说,下面按我实际搭建的顺序来讲。
1. 项目背景与方案选型
1.1 为什么选LabVIEW做刷写工具
有人可能会问,现在市面上做上位机的方案太多了,C#、Python、Qt都能干,为什么要用LabVIEW?这个问题我在项目启动前也纠结过。
核心原因有三个。第一是现场的调试习惯,我们团队很多人用LabVIEW做测试台架和产线设备,有一套现成的UI控件库和报表工具,刷写工具需要做成一个供产线工人操作的傻瓜界面,LabVIEW的界面拖拽式开发比C#写XAML快太多了。第二是LabVIEW对NI硬件和各种第三方CAN卡的支持还不错,图莫斯这类国产CAN卡基本上都提供LabVIEW调用的DLL,不需要自己去写驱动程序。第三是调试周期短,LabVIEW图形化开发不用编译,改完直接跑,在量产前调试阶段能省不少时间。
当然LabVIEW也有它的短板,比如对复杂状态机的管理容易乱、代码复用性不如文本语言、版本兼容性问题多,这些在我后面做协议层封装时都遇到了,标准做法是用队列+状态机的架构来管理收发包,避免界面卡死和数据丢失。
1.2 整体架构与数据流
这个工具的整体架构用一句话概括就是:LabVIEW界面层 + 状态机逻辑层 + CAN驱动封装层 + 图莫斯硬件层。
最上层是人机交互界面,负责显示刷写进度、当前诊断服务、NRC错误码、文件信息等;中间是核心逻辑层,跑一个UDS刷写状态机,按顺序执行诊断会话切换、安全解锁、请求下载、数据传输、退出传输、复位等步骤;再往下是CAN驱动封装层,也就是把图莫斯CAN卡的DLL接口封装成LV里好用的收发函数;最底层就是CAN卡和总线物理链路。
数据流也很好理解:上位机要发送的UDS请求,经过ISO-TP层组包,变成CAN帧通过CAN卡发到总线上;ECU的响应帧同样经过CAN卡收回来,拆包成完整的UDS响应,再交给状态机判断下一步动作。注意这里有个特别容易踩坑的地方,就是ISO-TP的多帧重组和流控时序,很多刷写失败都是这一层出了问题。
1.3 硬件选型与接线准备
硬件方面,我用的CAN卡是图莫斯的USBCAN系列,这个系列在市面上很常见,稳定性对于刷写工具来说是够用的。接到电脑上会自动识别为一个USB转CAN设备,驱动装好后在设备管理器里能看到对应的COM口或者设备节点。
接线就三根:CAN_H、CAN_L、CAN_GND。CAN_H接CAN_H,CAN_L接CAN_L,GND最好也接上,否则共模电压可能会带来一些奇怪的通信问题。总线两端各接一个120Ω终端电阻,这是CAN总线的基本要求,我记得有个项目没接终端电阻,结果远端的一台ECU怎么都通信不上,后来发现就是阻抗不匹配导致的反射问题。
还要确认一件事:ECU的CAN波特率。市面上绝大多数车规ECU用的都是500 kbps,但有的老平台是250 kbps或者125 kbps,配置不对连总线都上不去。所以第一步一定要跟BMS/VCU的供应商确认好波特率,别等写了一半代码才发现总线压根没通。
2. CAN通信底层搭建
2.1 图莫斯CAN卡初始化与通道配置
图莫斯CAN卡在LabVIEW里的调用方式,本质上就是调用它的DLL接口。打开驱动库后能看到几个核心函数:打开设备、初始化CAN通道、发送报文、接收报文、关闭设备。
在LabVIEW里我用的是“调用库函数节点”的方式,把DLL里的接口一个个导入,然后封装成自己的子VI。这里有一个很重要的经验:DLL导入时参数类型一定要核对清楚。图莫斯的接口很多参数是UINT类型,LabVIEW默认导入时可能会把它识别成I32,虽然大多数情况下不影响,但在某些边界值上会出问题。稳妥的做法是每个DLL参数都手动确认一下数据类型。
初始化通道的代码逻辑大致是这样的:打开设备后,对指定通道设置波特率、工作模式(正常模式还是环回模式)、滤波方式。我习惯第一次调试时先用环回模式验证收发链路,确认驱动封装没有问题了再切到正常模式。环回模式下发的报文自己不经过总线,能快速定位问题是出在驱动层还是ECU侧。
2.2 CAN报文收发与ID规划
CAN通讯的核心就是报文ID和数据场。刷写UDS时用的CAN ID一般是标准帧的11位ID,常见配置是物理请求ID为0x7E0、物理响应ID为0x7E8,功能寻址ID为0x7DF。这个不是固定的,具体要看ECU的CAN矩阵定义,有的项目用扩展帧29位ID,有的自定义了更复杂的ID分配。总之拿到项目后第一件事就是找诊断规范,确认物理寻址和功能寻址的ID。
在LabVIEW实现收发的时候,我设计了一个生产者-消费者架构。生产循环里开了一个接收线程,不停从CAN卡驱动读取总线上的帧,然后按ID做过滤,把属于本工具的响应帧丢进队列;消费者循环从队列里取数据,传给上层协议解析。这样做的目的是防止UI线程阻塞导致CAN接收缓冲区溢出丢帧,尤其是刷写过程中大量连续帧涌入的时候。
发送侧要注意CAN卡发送函数的返回不要忽略。图莫斯的发送接口会返回发送是否成功,如果总线上有其他节点持续占用仲裁,发送就会失败。我起初没做这个判断,导致刷写跑到一半停下来,查了半天才发现在某些总线负载高的情况下会有偶发发送失败。
2.3 发送超时与总线错误处理
CAN总线和普通串口不一样,它本身有完善的错误检测机制,但作为上位机,我们还是得处理两类异常:一类是发送超时,CAN卡内部有发送缓冲区,如果缓冲区满了或者总线错误,发送接口会返回超时;另一类是总线错误帧,ECU侧如果上电不正常或者波特率不匹配,总线上会出现大量错误帧。
我在工具里加了一个简单的总线状态监控模块。每次收发完帧后,查询一下CAN卡驱动的错误计数器和状态寄存器,如果连续多次查询都发现总线处于Bus-Off状态,就弹窗提示用户检查ECU供电和波特率配置,同时在日志面板输出当前的错误状态码。
这一类看似不起眼的功能,在实际产线和现场调试时非常实用。有一次现场怎么都进不了刷写流程,我打开总线状态面板发现错误计数器一直在增长,排查后发现是CAN_L线出现了接触不良,导线压到了端子屏蔽层,换了一根线就好了。如果没有这个监控模块,估计得拿着示波器在总线上测老半天。
3. UDS协议与ISO-TP传输实现
3.1 ISO-TP单帧与多帧组包
UDS诊断协议本身跑在ISO-TP传输层上,LabVIEW里做UDS第一个硬骨头就是ISO-TP的组包和拆包。
ISO-TP的帧类型有四种:单帧(SF)、首帧(FF)、连续帧(CF)和流控帧(FC)。单帧最多携带7个字节的数据;如果UDS请求或响应的数据长度超过7字节,就要拆成首帧加连续帧发送,接收方回复流控帧来控制后续连续帧的发送节奏。
举个例子,发送34 00 00 00 00 00 10 00 10 00这个下载请求,数据长度是10字节,超过了单帧能力。首帧的第一个字节就是0x10加上后续总长度,格式为:首帧的第一个字节高4位是0x1,低4位是总数据的长度高4位,第二个字节存放的是长度低8位;从第三个字节开始放实际数据。后续所有字节都放到连续帧里,连续帧的第一个字节高4位是0x2,低4位是帧序号,从1开始循环,到0xF后再回到0。
在LabVIEW里,我把ISO-TP的收发逻辑封装成了两个子VI:一个叫ISO-TP_Send,输入UDS请求字节数组,输出发送的结果和实际发出的CAN帧数;另一个叫ISO-TP_Receive,输入接收缓冲区,输出重组后的UDS响应字节数组。这两个VI内部用循环和移位寄存器处理多帧的状态,重点是把流控帧的BS(块大小)、STmin(最小间隔时间)两个参数处理好。
BS表示允许发送方在收到下一个流控帧之前最多发送的连续帧数量,STmin表示两个连续帧之间的最小时间间隔。这两个参数的取值直接影响刷写速度:如果ECU设置的STmin是0毫秒,那上位机就可以全速发帧,刷写速度会快很多;如果ECU比较保守,STmin是10毫秒甚至更久,那整个刷写过程就会非常慢。实际解码ECU返回的流控帧后,上位机必须严格按照STmin来发送连续帧,否则ECU那边会因为数据接收不及时而回复NRC 0x73。
3.2 常用诊断服务与NRC应答
UDS协议里和刷写相关的常用服务我在工具里都做了封装,用表格列一下:
| 服务ID | 服务名称 | 用途 |
|---|---|---|
| 0x10 | 诊断会话控制 | 切换默认、编程、扩展会话 |
| 0x27 | 安全访问 | Seed & Key解锁 |
| 0x34 | 请求下载 | 告知ECU要下载的地址和长度 |
| 0x36 | 数据传输 | 分块发送固件数据 |
| 0x37 | 请求传输退出 | 结束下载 |
| 0x31 | 例程控制 | 执行擦除、校验等例程 |
| 0x22 | 按ID读数据 | 读取ECU版本信息 |
| 0x11 | ECU复位 | 刷写完成后复位ECU |
每一个服务都有相应的正响应和负响应。负响应的格式是固定的:第一个字节是0x7F,第二个字节是请求的服务ID,第三个字节是NRC码。
NRC码是刷写调试中最需要熟悉的东西,常见的几个:
0x11表示服务不支持,通常是这个ECU型号没有实现当前服务;0x12是子功能不支持,比如0x27服务里的解锁参数不对;0x22是条件不满足,常见的场景是没进编程会话就尝试擦除Flash;0x31是请求超出范围,比如地址越界、长度不合法;0x33是安全访问被拒绝,说明Seed或Key算错了;0x36的0x73是块序列号错误,大概率是上位机发送的帧序号和ECU期望的不一致。
我把这些NRC码整理成了一个枚举常量,在做状态机的时候,收到负响应就查表输出对应的描述信息,同时把原始请求和响应都记录到日志里,问题定位效率提高了不少。
3.3 定时参数与超时策略
刷写UDS还有一个特别容易忽略但特别关键的地方是定时参数。ISO 14229里定义了P2、P3等多个定时参数,P2是ECU对请求给出响应的最大等待时间,通常是25毫秒到50毫秒,P2*是服务器需要额外时间处理时的扩展时间,最多可达5秒;P3是会话层请求之间的最小间隔,通常为5秒。
在LabVIEW里,这些定时参数如果做成固定延时,效率会非常低。最好的做法是做一个超时定时器:发送请求后记录当前时间戳,然后在循环里轮询接收队列,如果在P2时间内收到了正响应就继续下一步,如果收到了0x78(响应待处理)的P2*指示,就切换到P3等待时间,一直等到最终响应到达。
实际调试时我遇到过一个和定时相关的问题:刷写过程中ECU擦除Flash需要大概4秒钟,这段时间ECU会先回复0x78响应待处理,然后才返回真正的正响应。第一次做的时候我把超时时间写成了P2的50毫秒,结果就是刷写永远在第一步超时失败。后来看了半天规范才发现0x78的存在。
4. 刷写流程与核心逻辑
4.1 刷写前置流程
完整的一次UDS刷写,从ECU的角度看大致有这几个阶段:建立通信、切换到编程会话、安全解锁、擦除Flash(通过例程控制)、请求下载、传输数据、退出传输、复位ECU。
在LabVIEW里,我用一个枚举定义了这个状态机的各个状态,每一个状态对应一个UDS服务请求,发送完请求后根据响应决定是跳转到下一个状态还是停留在当前状态重试。状态机的好处是逻辑清晰,出现错误时可以随时回滚到上一个状态。
建立通信阶段首先要发送一个0x10 02进入编程会话,这是一个功能寻址请求。进入编程会话后,ECU可能还会要求先执行一个会话切换等待时间,有些ECU需要等一小段时间才能开始安全解锁,这时候需要在状态机里加一个延时,太急的话ECU会回复0x22条件不满足。
安全解锁是刷写流程里最容易出问题的环节。0x27服务的流程是:上位机发送0x27 01请求Seed,ECU返回Seed;上位机根据Seed用特定算法计算出Key,通过0x27 02发送给ECU;ECU验证Key正确后返回正响应,解锁成功。问题在于不同ECU厂商的Seed-Key算法五花八门,有些是简单的异或叠加,有些是查表变换,有些加入了实时计数器防止重放攻击。
我们的做法是把Seed-Key算法做成一个单独的子VI,每个项目只需要改这个子VI即可。第一个版本接的VCU算法是一个用了LFSR(线性反馈移位寄存器)的变种,我拿到算法文档后花了半天时间在LabVIEW里用移位数组实现了LFSR的计算,调试的时候还遇到了字节序的问题,文档里写的是大端序,ECU实际跑的是小端序,导致算出来的Key总是对不上。
刷写前置流程补充:例程控制擦除
安全解锁成功后,一般需要先执行擦除操作。这个是通过0x31例程控制服务的01子功能实现的,例程ID通常是FF00(擦除Flash)。擦除期间ECU会忙活一段时间,典型的响应序列是:先回复0x7F 31 78(响应待处理),过几秒钟后再返回0x71 01 FF 00的正响应。
我在状态机里专门针对这种情况做了处理:收到0x78后进入一个独立的长等待子状态,每100毫秒轮询一次接收队列,直到收到最终响应或超时。超时时间我通常设置成10秒,对于大容量Flash可能需要更久。
4.2 HEX文件解析与固件数据准备
刷写用的固件文件常见格式有HEX和S19两种,我们项目用的是Intel HEX格式。HEX文件每一行都是一个记录,结构是:冒号开头,紧接着是数据长度、地址、类型、数据、校验和。
解析HEX文件的几个关键点:一是记录类型判断,0x00是数据记录,0x01是文件结束记录,0x04是扩展线性地址记录;二是地址计算,当碰到扩展线性地址记录时,需要把地址的高16位更新,后续的数据记录地址需要和这个扩展地址组合成完整的32位地址;三是校验和计算,每一行的校验和是前面所有字节(长度、地址、类型、数据)累加后取反再加1,也就是对整体按字节求和结果为0。
LabVIEW里做文件解析比文本语言繁琐一些,我用的方法是用“读取分隔符文件”把整个文件按行读成一个字符串数组,然后对每一行用“字符串截取”和“十六进制字符串至数值转换”依次解析。解析完成后把固件数据按地址顺序存进一个二维数组或者簇数组,数组元素包括地址和数据长度。
在刷写前,工具会做一次简单的完整性校验:检查文件结束记录是否存在、总数据长度是否超过ECU的Flash容量、HEX文件里是否存在地址空洞。地址空洞是指文件里某些区域没有数据,这在HEX里是合法的,但对于目标Flash来说可能意味着风险,我通常的做法是提示用户确认,而不是直接自动填充0xFF。
4.3 34/36/37下载流程实现
进入了具体的下载流程,第一件事是发送0x34请求下载。请求的数据参数包括数据格式标识符、地址长度格式标识符、内存地址、内存大小。以我们的VCU为例,地址长度是4字节,大小是4字节,所以0x34请求的组成是:
34 [数据格式标识符] [地址长度格式: 0x44表示4字节地址+4字节大小] [4字节地址] [4字节大小] [可选:加密算法标识符]
数据格式标识符是一个很有意思的参数,它本身由低半字节和高半字节组成。很多国产ECU会在0x34请求里加上压缩和加密标识,对于不加密不压缩的普通刷写,这个字节通常直接填0x00。
0x34的正响应里会返回最大传输块长度,表示ECU每次通过0x36最多能接收多少字节。这个值直接决定了后面0x36分块的块大小。比如ECU返回的最大块长度是4096字节,那么每次0x36请求最多可以发4096字节的数据。
然而注意,ISO-TP层的连续帧虽然可以传输4095字节,但CAN底层每帧只有8字节数据场。4096字节的数据在CAN总线上意味着要拆成几百帧连续帧来发。所以我做了一个自适应分块逻辑:优先用0x34返回的最大块长度,但是如果ECU的流控帧里STmin比较大,为了不超时我会适当减小块大小,保证整个0x36请求在P3时间内能完成。
发送0x36数据时,最关键的是块序列号。块序列号从1开始,每发一个0x36请求加1,到0xFF后回到0循环。ECU会检查正在接收的数据块的序列号是否和期望值一致,不一致就回复NRC 0x73。我碰到过一种情况,一个0x36请求里包含多个连续帧,图莫斯CAN卡发送本身没有异常,但LabVIEW状态机里处理响应的时候,偶尔会把上一次的0x36正响应误判成当前0x36的正响应,导致序列号提前自增,ECU那边就报0x73了。后来我在每个0x36请求前先清空接收队列,才彻底解决这个问题。
所有数据发送完后,发送0x37请求传输退出。ECU收到后会做一次内部校验,返回正响应后执行0x31例程控制做Flash校验,最后发0x11 ECU复位结束整个刷写流程。
下面我给出一段关键的刷写状态机伪代码,便于理解整个逻辑:
当前状态 = 初始化 循环直到 刷写完成 或 错误发生: 根据 当前状态 发送对应的UDS请求 等待响应(处理0x78) 如果 收到正响应: 更新状态到下一步 更新进度条 否则如果 收到负响应: 记录NRC码 按策略决定重试还是放弃 否则 超时: 记录超时 重试当前状态(最多3次)在LabVIEW里,这就是一个事件结构+状态机搭配的实现,界面上的按钮触发刷写开始,然后状态循环不断驱动刷写进度。
4.4 读回与备份功能
除了刷写,我还顺手在工具里加了一个读回备份功能。操作流程和刷写相反:先进入编程会话并完成安全解锁,再发送0x23按地址读数据或者0x31例程控制配合0x34/0x36/0x37实现读Flash,ECU返回的数据通过ISO-TP拆包组包逐段读出,最终拼成完整的固件文件保存在本机。
这个功能看起来简单,但实用价值极高。有一次客户给的固件版本和新ECU不匹配,需要确认旧ECU里实际跑的到底是哪个版本,就是用这个功能把Flash内容读回来,和HEX文件对比后立刻确定了差异区域,省去了拆壳用编程器读芯片的麻烦。
5. 实操踩坑与问题排查
5.1 高频问题速查表
因为是真实项目总结,我整理了刷写工具调试中遇到的几个高频问题,做成速查表方便大家参考:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| CAN总线通信不上 | 波特率不匹配、终端电阻缺失 | 确认波特率,检查120Ω电阻 |
| 0x10会话切换失败 | 未按ECU要求先进入扩展会话 | 先发0x10 03再切0x10 02 |
| 0x27安全解锁拒绝 | Seed-Key算法错误、字节序不对 | 核对算法文档,打印Seed和Key |
| 0x34请求下载NRC 0x31 | 地址或长度越界 | 核对Flash地址映射表 |
| 0x36传输NRC 0x73 | 序列号错乱、接收队列残留 | 清空队列,检查序号处理逻辑 |
| 刷写过程中超时 | P2/P3定时设置不当、0x78处理不完善 | 检查定时参数和0x78分支 |
| 速度很慢 | STmin太大或BS为0 | 调整流控参数,优化发送节奏 |
5.2 时序和流控的坑
前文提到过0x78响应待处理的问题,这里再展开讲一下。在UDS中,如果ECU处理时间超过P2,它会在P2时间内先回复一个0x7F xx 78表示"还在处理",然后在P2*时间内给出最终响应。这个机制在擦除Flash时几乎必然触发。
我在LabVIEW里的处理是:发送请求后,进入一个超时循环,循环内部先等待50毫秒(P2),如果收到0x78就切到等待最终响应的模式,超时上限设为5000毫秒(P2*);如果P2内没收到任何响应,直接报超时。注意0x78本身不是一个正响应,它只是一个中间状态,不能把它当作刷写成功信号去推进状态机。
另外流控帧的处理有一个非常隐蔽的坑:如果ECU回复的流控帧里STmin是0xF0或者更大的值,表示的是毫秒级还是微秒级要根据协议标定来换算,有的ECU供应商在这个参数的实现上不太规范,会出现标称和实际不符的情况。遇到这种情况我一般直接用逻辑分析仪抓一次完整的多帧交互,看ECU实际有没有按标称的STmin接收数据,然后微调上位机的发送间隔。
5.3 文件解析与固件准备的坑
HEX文件解析的坑主要在地址空洞和地址重叠上。地址空洞有时候是编译器生成的,有时候是文件被截断导致的。我在工具里加了一个统计功能,解析完HEX后显示有效数据的起始地址、结束地址、总长度、区块条数,如果地址不连续会标黄提示。刷写前用0x34请求时的内存大小我直接按最后一个有效地址加块大小来算,而不是文件里所有数据的累计长度,这样能避免少写尾部数据。
还有一个数据对齐的问题。有些ECU要求0x34请求的内存地址必须按4字节或者8字节对齐,如果HEX文件里的起始地址不对齐,就要在上位机里做偏移处理。我当时遇到的情况是HEX文件在某一次编译后起始地址变成了0x08008000,而ECU要求对齐到4字节,本身倒是对齐的,但如果重新编译后起始地址变了,就可能不对齐。所以我在文件解析时增加了一个对齐检查,不对齐就自动在数据前面补0xFF。
5.4 LabVIEW版本与CAN驱动兼容性
最后说说LabVIEW环境本身的坑。图莫斯提供的DLL通常支持32位和64位,LabVIEW也有32位和64位版本。如果LabVIEW是64位的,但DLL只有32位版本,调用就会失败。我的经验是直接用32位LabVIEW + 32位DLL这一套最稳,中间遇到的“调用库函数节点加载DLL失败”问题基本都跟位数不匹配有关。
LabVIEW版本兼容性也是一个隐患,我用的是LabVIEW 2018,图莫斯的DLL接口是基于C语言封装的,在2018上导入没有问题。但是如果换到LabVIEW新版本,建议先在属性里检查DLL导入路径是否有效,有些情况下需要重新导入一次接口。
另外,LabVIEW的安装路径千万不要带中文,否则调用DLL时容易报错找不到模块,这一点很多人会忽略。装LabVIEW时也建议选择自定义安装,把NI-CAN、VISA这些模块一并装好,后续做CAN通信调试会有帮助。
6. 一些个人的实操体会
这套工具从零开始搭建到最终稳定用于产线,前后改动最大的部分其实不是协议逻辑,而是状态机的健壮性和日志的记录完整度。早期版本刷写失败后只能看到一个NRC码,要花很长时间去复现和分析;后期我把每一次请求、每一次响应、每一个时间戳都记录到一个文本日志文件里,工程师只需把日志发给我就能定位问题出在哪一步。
如果让我现在重新做一遍,我依然会选择LabVIEW,但会在一开始就把状态机、ISO-TP收发、文件解析这三个模块彻底分开,各自内部保持独立,千万不要都堆在一个大VI里。LabVIEW虽然上手容易,但项目复杂后,模块划分不清的话,维护成本会急剧上升。
另外一个小建议,不论用LabVIEW做什么工具,界面上的错误提示一定要做得清晰直白,给产线操作人员看的信息不能是0x7F 0x36 0x73这种十六进制,而是"数据块序号错误,请重新刷写"这类可以理解的中文描述。工具最好用的状态是让一个完全不了解CAN和UDS的人也能顺利操作,这才是工程工具的最终价值。