前段时间同事扔给我一份刷写日志,整整 4 万多行,让我帮忙看下为什么刷到 60% 的时候整车控制器报错。我一开始也头大:文件里全是十六进制字节,CAN ID 混在一起,乍一看就是天书。后来把 ISO-TP 层和 UDS 层一层层剥开,用我自己维护的一款开源 UDS/ISO-TP 刷写日志离线分析工具跑了一遍,几十秒内就定位到了一条 0x31 例程控制返回 0x72 的负响应。这件事让我想认真聊聊这类工具背后的分析方法和实现思路。
所谓刷写日志离线分析工具,就是不依赖诊断仪、不连接实车,纯粹通过解析抓取的 CAN 总线日志文件,把复杂的刷写过程还原成人类能看懂的时间线、服务序列和错误码报告。它适合三类人:做嵌入式 Bootloader 开发的工程师、负责产线 EOL 或售后刷写问题的测试工程师,以及经常要帮客户排查刷写失败问题的技术支持。接下来我会从协议分层、解析实现、时序重建、工具选型到实战避坑,把整个思路完整讲一遍。
1. 刷写日志到底难在哪:先搞清楚我们要解决什么问题
1.1 刷写不是一个“传文件”动作
很多人以为 ECU 刷写就是把一个 bin 文件通过总线发过去,像 U 盘拷贝一样。实际上,一次完整的 UDS 刷写是一个有状态的多阶段会话:先切换会话模式,关闭相关通信,确认编程条件,然后解锁安全等级,写入指纹信息,通过 34/36/37 服务传输数据,最后执行例程校验并复位 ECU。任何一个环节失败,都会导致整个刷写流程中断。
理解这一点非常重要,因为离线分析工具的解析逻辑完全围绕刷写阶段来组织。如果你只盯着数据帧,不看前后状态,就无法判断一条 0x7F 负响应到底是安全等级不够,还是数据校验失败,亦或是服务不被支持。所以我在设计工具时,第一件事不是写解析器,而是把刷写流程拆成可识别的事件序列,然后让每个 UDS 服务都映射到对应的阶段和状态。
1.2 日志文件形态各异,统一格式是第一道坎
实际工作中拿到的日志远不止一种格式。Vector 的 ASC 和 BLF 最常见,但不同的采集设备导出的 ASC 文件字段顺序都可能不一样;PCAN 导出的是 CSV 或 TXT;还有一些 OEM 的工具会输出带时间戳的私有文本格式,甚至把多个 ECU 的日志混在一个文件里。
这就带来一个很现实的问题:分析工具不能只支持一种格式。我在项目里做了一层格式抽象,把 ASC、CSV、TXT 甚至部分 BLF 都统一转换成内部 RawFrame 对象,每个对象只保留时间戳、通道、CAN ID、数据长度和数据字节。后续的 ISO-TP 和 UDS 解析完全不关心原始文件来自哪里,这样每新增一种日志格式,只需要写一个很小的 parser。
1.3 离线分析的真正价值:可复现、可批量、可自动化
在线诊断工具当然也能看服务响应,但它要求实车、诊断仪、供电环境都就位,出了问题还得现场抓包,效率很低。离线分析工具的价值在于,你可以把现场抓到的日志拿回来慢慢看,定位问题后再回到环境里复现;也可以一次性批量扫描几十份日志,把 NRC 出现频次、刷写时长、失败阶段统计出来,形成自动化回归测试。
我在实际项目中就遇到过这样的情况:同一款 ECU,产线上偶尔出现刷写失败,但问题不是必现的。用离线工具批跑了一周积累的日志,才发现失败总是集中在某个特定的软件版本里,而且都发生在 34 服务扩展地址之后。这个结论在线下反复测试都复现不出来,但离线分析给了很明确的指向。
2. ISO-TP 层:把裸总线字节流还原成完整报文
2.1 为什么绕不开分段机制
标准 CAN 数据帧的数据场最长只有 8 字节,而实际有效负载还要去掉 1 字节的 PCI(协议控制信息),所以单帧最多携带 7 字节应用数据。可 UDS 的服务动辄几十上百字节,比如 34 服务请求带地址和长度信息,36 服务一次只能发 7 字节,0x31 例程控制的参数也可能超过 7 字节。所以 ISO-TP(ISO 15765-2)必须把长报文拆成多帧传输。
很多做应用层的人容易忽略这一点:刷写日志里看到的一连串相同 CAN ID 的帧,并不是重复报文,而是同一个 UDS 消息的分段。如果不按照 ISO-TP 的规则重组,你看到的就是一堆无法解读的碎片。离线分析工具的第一层核心任务,就是把这些碎片重新拼成完整的 ISO-TP 报文。
ISO-TP 定义了四种帧类型:单帧(SF)、首帧(FF)、流控帧(FC)和连续帧(CF)。单帧里 1 字节 PCI 高四位为 0,剩余 7 字节直接承载应用数据;首帧 PCI 高四位为 1,后跟 12 位总长度,最多表示 4095 字节的大报文;流控帧由接收方发出,用于告诉发送方“我准备好了,你可以继续发”,其中包含块大小(BS)和最小间隔时间(STmin);连续帧则按顺序携带剩余数据。
2.2 重组算法:不能只按 CAN ID 简单拼接
重组 ISO-TP 报文的核心逻辑不复杂,但细节很多。每个逻辑连接通常由源地址、目标地址和地址模式(物理寻址或功能寻址)共同标识。收到首帧后,创建重组缓冲区,记录期望的总长度;收到连续帧后,按顺序填入缓冲区,并校验帧序号。
这里最容易被坑的是连续帧的序号。连续帧 PCI 的低四位是计数器,从 1 开始,循环使用 0~15;也就是说 1、2、3...15、0、1、2...这样递增。如果解析器用简单的累加,遇到回绕就会出错。另外,流控帧的 BS 表示发送方最多连续发送多少个 CF 后要等待新的流控帧;STmin 表示两个连续帧之间的最小间隔时间。离线分析时虽然不需要真正控制节奏,但要解析这些参数,因为刷写工具经常因为流控参数配置不合理导致总线拥塞或超时。
我在工具里实现了一个IsoTpReassembler类,以 (channel, tx_id, rx_id) 为 key 维护多个重组上下文。收到 SF 直接产出消息;收到 FF 则初始化缓冲区;收到 FC 更新发送状态;收到 CF 填充数据,填满后按照协议计算完整 payload 并产出。每个上下文还记录首帧时间戳和最后一片时间戳,方便后续算传输耗时。
2.3 日志时间戳:微秒还是毫秒,差距很大
CAN 日志的时间戳通常来自抓包设备的硬件时钟,精度可以到微秒,但不同工具导出的单位不一样。ASC 文件一般是秒和微秒分开的字段,CSV 可能是浮点秒,有的厂商文本格式竟然是毫秒。如果解析器把单位搞错,重组的时序会彻底乱掉,尤其影响流控超时判断。
我的做法是在格式解析阶段就把所有时间戳统一转换成微秒整型,并保留硬件时间戳与系统时间戳的映射。后面做超时分析、时序重建时,全部基于微秒计算。建议大家在写解析器时,任何时间相关字段都显式标注单位,不要用无单位浮点数,否则后续维护一定会踩坑。
3. UDS 服务层翻译:从 SID 到可读诊断动作
3.1 刷写相关的核心服务
ISO-TP 重组出来的是一个个完整的 UDS 消息,接下来要做的是解析 SID、子功能、数据参数,并翻译成可读的诊断动作。UDS 服务号很多,但刷写场景里高频出现的其实就十几个。
| SID | 服务名称 | 刷写中的典型用途 |
|---|---|---|
| 0x10 | 诊断会话控制 | 切换默认/编程/扩展会话 |
| 0x11 | ECU 复位 | 刷写完成后复位 |
| 0x14 | 清除诊断信息 | 刷写前清 DTC |
| 0x19 | 读取诊断信息 | 读 DTC 快照 |
| 0x22 | 按 ID 读数据 | 读软件版本/硬件版本 |
| 0x27 | 安全访问 | 种子与密钥解锁 |
| 0x28 | 通信控制 | 关闭/开启通信 |
| 0x2E | 按 ID 写数据 | 写 VIN、写指纹 |
| 0x31 | 例程控制 | 启动编程条件检查、校验 |
| 0x34 | 请求下载 | 开始数据传输 |
| 0x36 | 传输数据 | 按块传输固件内容 |
| 0x37 | 请求传输退出 | 结束数据传输 |
| 0x3E | 测试器在线 | 保持会话活跃 |
| 0x85 | 控制 DTC 设置 | 刷写期间关闭 DTC |
表格只是索引,真正重要的是理解服务之间的依赖关系。比如 34 服务必须在编程会话下,通常还要先通过 27 服务解锁,否则 ECU 会返回 0x33(安全访问被拒绝)。离线分析工具会把每个服务的响应状态记录下来,形成服务链,方便快速定位是哪一环断了。
3.2 负响应结构和 NRC 解读
当 ECU 不认可某个请求时,会返回 0x7F + 请求的 SID + 1 字节 NRC。NRC 是刷写失败分析里最有价值的信息。很多新手只看“返回 7F 就是失败”,但没去查 NRC 具体含义,导致问题定位不准。
| NRC | 含义 | 常见触发场景 |
|---|---|---|
| 0x10 | 一般拒绝 | 请求条件不满足 |
| 0x12 | 子功能不支持 | 会话模式不对 |
| 0x13 | 消息长度错误 | 参数数量或格式不对 |
| 0x22 | 条件不满足 | 未满足刷写前置条件 |
| 0x24 | 请求序列错误 | 没走正常顺序 |
| 0x31 | 请求超出范围 | 地址、长度超出 Flash 范围 |
| 0x33 | 安全访问被拒绝 | 密钥错误或未解锁 |
| 0x35 | 密钥无效 | 安全等级不匹配 |
| 0x36 | 超过重试次数 | 解锁尝试次数过多 |
| 0x37 | 请求时间延迟超时 | 超时未收到后续请求 |
| 0x70 | 上传下载不可接受 | 编程会话未激活 |
| 0x71 | 传输数据暂停 | 接收缓冲区满 |
| 0x72 | 编程错误 | Flash 擦写失败、校验失败 |
| 0x73 | 块序号错误 | 36 服务块计数不对 |
| 0x78 | 请求被接收但仍在处理 | 需要等待后续响应 |
0x72 是刷写日志里最常见的失败码之一,也是最容易被误判的。它可能表示 Flash 驱动问题、写入地址越界、校验失败、甚至供电不稳。单看 NRC 很难确定根因,但配合前后文就能缩小范围:如果 0x72 出现在 36 服务发送了大量数据之后,大概率是写入或校验阶段出错;如果出现在 31 服务启动例程时,可能是例程执行超时或依赖条件不满足。
3.3 解析服务参数时的细节
每个服务都有各自的参数布局,解析时不能只看 SID。比如 34 服务请求的格式是34 + 数据格式标识符 + 地址与长度格式标识符 + 内存地址 + 内存大小,地址和长度的字节数是根据格式标识符动态解析的。OEM 变体很多,有的是 4 字节地址、4 字节长度,有的是 2 字节地址、2 字节长度。如果工具写死了偏移位置,换一个 ECU 就会报错。
36 服务则是36 + 块序列计数器 + 数据块,块计数从 1 开始,每块加 1。我在解析时会把块序号转换成长度信息,并且检查序号是否连续。如果日志中出现跳号,往往说明有丢帧或发送方逻辑异常。
另外,0x2E 写数据服务经常被用来写指纹信息,其2E + DID + 数据的格式中 DID 是两字节,不同 OEM 对 DID 的定义完全不同。离线工具可以提供 DID 映射配置,让用户自己定义哪些 DID 代表软件版本、硬件版本、序列号、刷写时间等,这样报告里就能直接显示可读信息。
4. 时序重建:把几十万行日志拼成一眼能看懂的刷写流程
4.1 按服务特征识别刷写阶段
重组并翻译出所有 UDS 消息后,下一步是重建刷写流程的时序。这里我用的是“阶段识别”的思路:根据服务特征和状态机,把整个刷写过程切成预编程、编程、后编程三个阶段,然后进一步识别出每次会话切换、安全解锁、数据传输等关键事件。
预编程阶段通常包含 10 01(切到默认会话)、10 03(切到扩展会话)、85 02(关闭 DTC)、28 03(关闭非诊断通信)、3E 00(保持在线)等。进入编程阶段前会连续出现 27 服务的安全访问交互,之后是 2E 写指纹、34/36/37 传输固件、31 01 02 03 等例程控制。后编程阶段则是 10 02(切到编程会话后的复位)或 10 01(回默认会话)、14 清 DTC、19 读 DTC、11 01 复位等。
工具识别出这些阶段后,会生成一张时间线,把每个阶段用不同的标记展示。比如预编程是灰色,传输数据是蓝色,例程校验是橙色,负响应是红色。一旦刷写失败,红色的 7F 节点会非常显眼,基本可以做到“一眼定位”。
4.2 服务链与超时窗口
单纯按时间排 UDS 消息还不够,还需要把请求和响应配对,形成服务链。物理寻址通常是请求 0x7E0、响应 0x7E8 这种一对一的配对关系;功能寻址则是请求发到 0x7DF,多个 ECU 各自响应,这种情况下不能按“收到一个响应就匹配完成”来处理,而应该等待一段窗口期。
超时窗口的判断也很关键。UDS 协议里,ECU 收到请求后默认要在 50ms 内给出响应,某些慢服务允许扩展延迟,响应前会先发 0x7F + SID + 0x78(请求被接收但仍在处理)。离线分析工具要识别这种 0x78 中间响应,并在最终响应之前不判定超时。很多第三方工具在这里做得不好,把 0x78 当成普通负响应,导致刷写流程分析错误。
我在实现阶段识别时,维护了一个状态机:当前会话模式、安全等级、传输状态(是否已请求下载、当前块计数)、例程状态。每收到一条 UDS 请求或响应,就根据状态机更新状态并打上阶段标签。这样即便日志里包含多个 ECU 的消息,也能按逻辑连接分别维护各自的状态机。
4.3 失败归因:从 NRC 往前推 100ms
定位刷写失败有一套很实用的经验法则:先找到最近一条 0x7F 负响应,确认 NRC;然后往前找 100ms 到 500ms 窗口内的所有相关帧,看失败前最后一条成功的服务是什么;再判断是该服务本身失败,还是前序服务没完成导致条件不满足。
举个例子,有一次日志里反复出现31 01 02 03启动例程返回 0x22(条件不满足),从表面看是例程没通过。但往前翻了几百条记录,发现刷写工具根本没发 27 服务的安全解锁请求,只是在广播会话切换后直接尝试启动例程。这种情况下 NRC 是 0x22 而不是 0x33,很容易误导人。工具如果能把“安全等级状态”自动关联进报告,就能直接提示“当前安全等级未解锁,不建议启动该例程”。这类关联分析才是离线工具的真正价值。
5. 开源工具链选型:不是每个环节都要自己造轮子
5.1 CAN 日志解析层的选择
开源社区里已经有不少现成的轮子,没必要全部从零写。CAN 日志解析层面,can-utils提供了candump等命令行工具,但它主要面向在线抓取和简单文本输出;Wireshark自带的 ISO-TP dissector 可以解析 ASC 和 pcap 文件,还能图形化查看 ISO-TP 重组,但批量分析和生成自定义报告的能力比较弱。
我在项目里最初尝试过直接调 Wireshark 的tshark命令行导出解析结果,优点是省力,缺点是日志格式兼容性受限于 Wireshark 的 dissector,而且对私有格式的支持很差。后来决定自己写解析层,但参考了 Wireshark 的协议字段设计思路,保证后续可以对接。如果你想快速验证一段日志内容,用tshark -r file.asc -V看 ISO-TP 的解码结果是很好的起步方式,但如果你想做自动化的批量分析工具,还是需要自建一层解析。
5.2 Python 生态:python-can 与 udsoncan 的边界
python-can是 Python 生态里最常用的 CAN 工具库,支持读取 ASC、BLF 等格式,也支持多种硬件接口。但要注意,python-can的 BLF 读取能力依赖canio或者原生支持,某些老的日志版本会读取失败。我建议将它作为日志读取的“可选后端”之一,而不是唯一路径。
udsoncan是一个非常完整的 UDS 在线诊断库,支持发包、收包、服务封装,但它针对的是在线交互场景,并没有为离线日志分析做设计。你不能直接拿udsoncan去解析一份日志文件,因为它默认所有服务都是一问一答的在线对话。不过可以借鉴它对服务的参数定义和编解码逻辑,把其中的消息类抽出来复用,避免自己重写一套 34/36/37 服务的参数编解码。
我的最终架构是:格式解析层用自研代码加python-can兜底;ISO-TP 重组层完全自研;UDS 服务编解码参考udsoncan的参数定义;报告生成层用纯 Python 生成 JSON、CSV 和独立 HTML 文件。整个项目以 CLI 方式运行,也提供了一组可导入的 Python API,方便接入其他自动化测试框架。
5.3 中间数据模型的设计
离线分析工具最容易犯的错误,是把“日志格式解析”和“业务分析”耦合在一起。如果 RawFrame 直接塞给报告模块,后面每扩展一种日志格式或分析方法都会牵一发动全身。我设计了四层数据模型:
RawFrame:原始帧,包含时间戳、通道、CAN ID、数据。IsoTpMessage:重组后的完整消息,包含源地址、目标地址、时间戳、payload。UdsMessage:UDS 层的请求或响应,包含 SID、子功能、参数解析结果、NRC。SessionEvent:刷写阶段和高层语义事件,包含阶段标签、状态机快照、关联 UDS 消息。
每一层只消费上一层的输出。比如 UDS 层不关心原始 CAN 帧的 DLC 是多少,ISO-TP 层不关心 SID 的含义。这样每一层都可以独立测试,也方便别人基于这个项目扩展自己的分析规则。
5.4 二次开发的扩展点
开源工具最终能不能被大家用起来,取决于扩展点设计得好不好。我在项目里预留了几个关键扩展点:日志格式解析器注册表、DID 映射表、NRC 补充说明字典、服务序列阶段识别规则。每一个都是简单的 Python 字典或函数注册,用户加一个厂商私有协议时,不用改动核心代码,只要新增一个解析规则文件就行。
举个例子,某厂商在 34 服务之前多加了一个 2E 写刷写使能标志的步骤,未做使能直接发 34 会返回 0x24。默认的阶段识别规则识别不到这个私有服务,但通过配置,用户可以把该 2E 请求标记为“刷写使能”,并设定它必须在 34 服务之前完成。这样报告里就能自动标出“刷写使能未设置”这类提示。
6. 实测避坑录:我在解析真实刷写日志时踩过的坑
6.1 时间戳单位混用导致时序乱掉
我第一次拿真实 BLF 日志做回归测试时,发现报告的时序完全对不上,有些响应竟然出现在请求之前。排查了半天,问题出在 BLF 内部的 time stamp 是以 10 纳秒为单位的 tick,我用脚本读取时当成微秒直接除以 1000,导致所有时间戳缩放了 100 倍。后来我统一在格式解析层做一次时间基准归一化,并且针对不同格式分别写单测。
这件事给我的教训是:任何来源的日志都要先打印前几帧的时间戳间隔,肉眼确认数量级是否合理。比如两个连续 CAN 帧之间通常间隔几毫秒,如果看到间隔是几微秒或几百秒,大概率是单位解析错了。
6.2 多 ECU 日志混杂与同一 CAN ID 不同节点的干扰
产线日志经常是一个文件包含多个 ECU 甚至多个通道的数据,不同 ECU 可能使用相同的 CAN ID 范围,尤其是在功能寻址广播时,一个请求会带出好几个响应。如果解析器只按 CAN ID 分组,必然会把不同节点的数据混在一起。
解决思路是引入“逻辑节点”概念:每个 ECU 节点由通道号和诊断物理请求/响应 ID 对唯一标识。比如节点 A 是ch1 0x7E0/0x7E8,节点 B 是ch2 0x7E0/0x7E8,虽然 CAN ID 相同,但通道不同,视为不同节点。功能寻址响应则通过响应 ID 的不同来区分节点。这样时间线就能按节点分层展示,避免互相干扰。
6.3 协议变体和私有服务的兼容
不同 OEM 的刷写时序差异非常大,有的在 34 服务前需要先发特定 DID 长度信息,有的把 31 例程的例程 ID 定义成私有值。如果你的工具只按标准协议解析,碰到私有服务就会漏报或误报。最稳妥的做法是:未知服务不强行解析,只输出原始 hex 和 SID 号,同时标出“未知服务”,并允许用户通过配置补充说明。
我在项目里增加了“协议配置文件”机制,用户可以针对特定 ECU 定义一个 JSON,里面写明私有 DID、私有例程 ID、允许的服务序列等。分析报告会优先使用配置文件里的解释,未匹配的才回退到标准定义。实测下来,这种方式能覆盖绝大多数 OEM 变体,同时保持核心代码简洁。
6.4 性能优化与超大日志处理
一份完整的产线刷写日志动辄几万帧,有些使用 CAN FD 加高波特率抓取的文件甚至超过 20 万帧。如果解析时把全部帧一次性载入内存再做重组,内存占用会非常夸张,而且分析速度慢得让人抓狂。
我的优化思路是:使用生成器按帧读取,边读边喂给 ISO-TP 重组器;重组器完成一个完整消息就立刻产出,交给 UDS 解析器处理,处理完的消息直接写入中间结果文件。这样任何时刻内存里只保留当前正在重组的少量上下文,几万帧的日志处理时间可以压到几秒内。另外,多通道日志可以用多进程按通道并行解析,最后再合并报告。
时间戳排序也是一个隐藏性能点。某些日志文件里帧并不严格按时间递增,比如导出工具合并多个通道时会出现小范围的乱序。我做分析前会先做一次稳定排序,同时对时间戳做去重和单调化处理,避免报告中出现类似“上一帧时间晚于下一帧”的诡异现象。
6.5 异常流控和丢帧场景
离线日志里经常能看到只有请求没有响应的情况,原因可能是总线丢帧、ECU 掉电、或者日志采集本身丢包。分析工具不能遇到这种场景就崩溃,而应该明确标记为“无响应”并继续解析后续内容。另外,流控帧缺失但连续帧仍然出现的情况也见过,这通常说明抓包工具漏采了部分帧,重组器要能容忍这种缺失并给出警告,而不是静默丢弃。
这类异常场景正是离线分析工具比人肉看日志强的地方:它能自动统计丢帧率、缺失响应数量、超时次数,然后在报告开头给一个健康度总览。我后来给项目加了一个“异常摘要”模块,专门把这些问题用列表打出来,测试同事看到摘要就能决定是否重新抓包,省下大量沟通时间。
说句实在话,这个工具本身并不复杂,最难的始终是把各家的日志格式、私有协议和千奇百怪的刷写时序都兼容进来。我后来把常见日志格式的解析器做成了插件式,谁在项目中遇到新格式,按模板补一个解析函数就能解决。如果你也想做类似的工具,我建议不要急着堆功能,先把 ISO-TP 重组和 NRC 关联这两块做扎实,再逐步扩展厂商私有协议。
最后再分享一个小技巧:分析刷写失败日志,永远先看最近一条 0x7F,然后再看它前面 100ms 内到底发生了什么。大部分刷写失败都不是“突然失败”,而是前置条件没满足。把这条经验写成规则放进工具里,比任何复杂的算法都管用。