1. 从一次售后排查说起为什么0x1906值得单独拎出来讲前阵子帮一个做商用车的朋友排查一批售后问题现象很典型仪表偶发报“动力系统故障”但4S店用诊断仪读出来的故障码翻来覆去就那两三个清掉之后过几天又冒出来谁也说不清到底是哪个模块在反复“作妖”。后来我们把整车的诊断日志拉出来用0x1906服务把每个ECU的故障计数读了一遍问题一下就清楚了——某个网关模块的通信类故障计数在两周内涨了四十多次而其他模块都是个位数。顺着这个线索去查线束和接插件果然发现一处屏蔽层接地不良。这件事让我意识到很多做UDS诊断的同行对0x1906这个服务的重视程度远远不够。大家平时聊UDS张口就是0x10会话控制、0x27安全访问、0x22读数据、0x2E写数据、0x31例程控制再进阶一点聊0x19读故障码、0x14清故障码、0x85控制DTC设置。但0x1906这个“读故障计数”的服务往往被当成一个可有可无的附属功能甚至很多诊断规范里压根没定义它。实际上0x1906在ISO 14229标准里属于0x19服务的子功能之一全称是ReadDTCInformation服务下的reportDTCBySeverityMaskRecord之外的另一个子功能——准确地说0x19服务的子功能编号里0x06对应的是reportNumberOfDTCByStatusMask也就是按状态掩码统计满足条件的DTC数量。这里要特别澄清一个容易混淆的点很多人把0x1906理解成“读某个DTC的发生次数”这其实是误解。真正读“发生次数”的是0x19服务的0x04子功能reportDTCSnapshotRecordByDTCNumber配合快照记录或者0x19的0x06子功能返回的是数量统计而不是单条DTC的计数。不过在实际工程语境里大家口头说的“0x1906统计故障次数”通常指的是通过0x19服务的0x06子功能按状态掩码统计当前满足条件的DTC总数再结合周期性的读取间接实现对故障“发生频次”的监控。这个用法在整车厂和Tier1的售后诊断、下线检测EOL、以及OTA前的健康检查里非常常见。本文就围绕这个实际用法展开把协议细节、Python模拟代码、实操踩坑经验一次讲透。如果你正在做ECU诊断协议栈开发、整车诊断测试、售后诊断工具开发或者单纯想搞明白UDS里这个“统计类”服务怎么用这篇内容应该能帮你省下不少翻标准、试错的时间。下面我会从协议原理讲到代码实现再讲到实际项目里的坑尽量做到看完就能上手。2. 0x1906到底在协议里怎么定义的把ISO 14229翻到那一页2.1 0x19服务的子功能全景ISO 14229-1里0x19服务ReadDTCInformation一共定义了二十多个子功能常用的就那么几个。为了让大家有个全局观我先把和“统计”相关的几个子功能列出来对比一下子功能编号名称作用是否返回数量0x01reportNumberOfDTCByStatusMask按状态掩码统计DTC数量是0x02reportDTCByStatusMask按状态掩码返回DTC列表否返回列表0x03reportDTCSnapshotIdentification返回快照记录标识否0x04reportDTCSnapshotRecordByDTCNumber按DTC号返回快照记录否0x06reportDTCExtDataRecordByDTCNumber按DTC号返回扩展数据记录否0x07reportNumberOfDTCBySeverityMaskRecord按严重程度掩码统计数量是0x08reportDTCBySeverityMaskRecord按严重程度返回DTC否0x09reportSeverityInformationOfDTC返回DTC严重程度信息否0x0AreportSupportedDTC返回所有支持的DTC否0x0BreportFirstTestFailedDTC返回首个测试失败的DTC否0x0CreportFirstConfirmedDTC返回首个确认的DTC否0x0DreportMostRecentTestFailedDTC返回最近测试失败的DTC否0x0EreportMostRecentConfirmedDTC返回最近确认的DTC否0x0FreportMirrorMemoryDTCByStatusMask镜像内存DTC否0x10reportMirrorMemoryDTCExtDataRecordByDTCNumber镜像内存扩展数据否0x11reportNumberOfMirrorMemoryDTCByStatusMask镜像内存DTC数量是0x12reportNumberOfEmissionsOBDDTCByStatusMaskOBD排放相关DTC数量是0x13reportEmissionsOBDDTCByStatusMaskOBD排放相关DTC否0x14reportDTCFaultDetectionCounter返回DTC故障检测计数器否0x15reportDTCWithPermanentStatus返回永久性DTC否0x42reportWWHOBDDTCByMaskRecordWWH-OBD DTC否0x55reportWWHOBDDTCWithPermanentStatusWWH-OBD永久DTC否看到这里你应该明白了真正返回“数量”的子功能是0x01、0x07、0x11、0x12而0x06返回的是扩展数据记录。那为什么大家口口声声说“0x1906统计故障次数”这里有两种可能一是把子功能编号记混了把0x01记成了0x06二是在某些主机厂的诊断规范里自定义了0x06的语义让它返回某种计数。无论哪种情况在实际项目里你要做的第一件事是拿到该项目的诊断规范文档Diagnostic Specification确认0x1906在本项目里的确切定义而不是想当然地按标准去实现。2.2 请求与响应的报文结构假设我们按标准用法用0x19服务的0x01子功能来统计数量请求报文结构如下请求19 01 [StatusMask] 响应59 01 [StatusAvailabilityMask] [DTCFormatIdentifier] [DTCCount_H] [DTCCount_L]其中StatusMask是一个字节每一位代表一种DTC状态Bit名称含义0testFailed最近一次测试失败1testFailedThisOperationCycle本操作周期内测试失败2pendingDTC待定DTC3confirmedDTC已确认DTC4testNotCompletedSinceLastClear自上次清除后测试未完成5testFailedSinceLastClear自上次清除后测试失败过6testNotCompletedThisOperationCycle本操作周期测试未完成7warningIndicatorRequested请求警告指示比如你想统计“当前已确认的DTC数量”StatusMask就填0x08想统计“本操作周期内测试失败的DTC数量”填0x02想统计“所有已确认或待定的”填0x0A0x08 | 0x02。响应里的DTCCount是两个字节高字节在前所以最大能表示65535个DTC。DTCFormatIdentifier表示DTC的编码格式常见值0x01是ISO 14229-1定义的格式0x02是SAE J1939-73格式0x03是ISO 11992-4格式。如果你用的是0x06子功能按标准定义请求结构是请求19 06 [DTC_H] [DTC_M] [DTC_L] [ExtDataRecordNumber] 响应59 06 [DTC_H] [DTC_M] [DTC_L] [StatusOfDTC] [ExtDataRecordNumber] [ExtData...]这个返回的是某条DTC的扩展数据记录里面可能包含发生次数、老化计数器等信息具体内容由ECU厂商定义。这就是为什么很多项目里把0x1906当成“读故障次数”来用——因为扩展数据记录里确实可能包含一个“Occurrence Counter”字段。但这个字段的位置、长度、编码方式标准里没有强制规定完全看厂商实现。2.3 为什么这个服务在工程上重要从工程角度看0x1906或0x1901的价值在于它提供了一种轻量级的健康度量化手段。传统的0x1902返回的是DTC列表报文长度随DTC数量线性增长在CAN总线上可能触发多帧传输效率低。而数量统计只需要几个字节适合在以下场景高频调用下线检测EOL整车下线时快速判断各ECU是否有未清除的故障数量为0才放行。售后诊断维修站快速评估车辆健康状态数量异常偏高的模块优先排查。OTA前检查升级前确认目标ECU没有活跃故障避免升级过程中断。车队远程监控通过T-Box周期性读取各ECU的DTC数量上传云端做趋势分析。理解了这些场景你就明白为什么值得花时间把这个服务吃透。接下来我们进入实操部分。3. 用Python搭一套0x1906的模拟与验证环境3.1 环境准备别一上来就装一堆库很多教程一上来就让你pip install一大堆东西其实做UDS诊断模拟核心依赖就两个一个CAN通信库和一个能发原始报文的工具。如果你只是想在本地验证协议逻辑甚至不需要真实CAN硬件用Python的can库配合虚拟总线就能跑通。我的建议是分两步走第一步纯逻辑验证。用Python写一个ECU模拟器和一个诊断仪模拟器两者通过内存队列通信先把请求响应的字节流跑通。这一步不需要任何硬件也不需要装python-can用标准库就够了。第二步接入真实或虚拟CAN。装python-can配置虚拟通道或者真实硬件比如PCAN、Vector VN系列、Kvaser等。这一步再引入udsoncan这类UDS协议栈库来简化开发。先看第一步的代码。下面是一个极简的ECU模拟器实现了0x1901按状态掩码统计数量和0x1906返回扩展数据记录两个子功能import struct class ECUSimulator: def __init__(self): # 模拟几条DTC格式(DTC号, 状态字节, 发生次数) self.dtcs [ (0xP0301, 0x08, 12), # 已确认发生12次 (0xP0302, 0x0A, 5), # 已确认待定发生5次 (0xP0420, 0x02, 3), # 本周期测试失败发生3次 (0xC1001, 0x08, 1), # 已确认发生1次 ] def handle_request(self, request: bytes) - bytes: if len(request) 2: return bytes([0x7F, request[0] if request else 0x19, 0x13]) sid request[0] subfunc request[1] if sid ! 0x19: return bytes([0x7F, sid, 0x11]) # serviceNotSupported if subfunc 0x01: return self._handle_1901(request) elif subfunc 0x06: return self._handle_1906(request) else: return bytes([0x7F, sid, 0x12]) # subFunctionNotSupported def _handle_1901(self, request: bytes) - bytes: if len(request) 3: return bytes([0x7F, 0x19, 0x13]) status_mask request[2] count sum(1 for _, status, _ in self.dtcs if status status_mask) # 响应59 01 [StatusAvailabilityMask] [DTCFormatIdentifier] [Count_H] [Count_L] resp bytes([0x59, 0x01, 0xFF, 0x01]) struct.pack(H, count) return resp def _handle_1906(self, request: bytes) - bytes: if len(request) 5: return bytes([0x7F, 0x19, 0x13]) dtc (request[2] 16) | (request[3] 8) | request[4] ext_record_num request[5] if len(request) 5 else 0x01 for d, status, occ in self.dtcs: if d dtc: # 响应59 06 [DTC_H] [DTC_M] [DTC_L] [Status] [ExtRecNum] [OccurrenceCounter] resp bytes([0x59, 0x06, (dtc 16) 0xFF, (dtc 8) 0xFF, dtc 0xFF, status, ext_record_num, occ]) return resp return bytes([0x7F, 0x19, 0x31]) # requestOutOfRange这段代码里有个细节值得说StatusAvailabilityMask我填的是0xFF表示ECU支持所有8个状态位。实际项目中很多ECU只支持其中几位这个掩码必须和ECU实际能力一致否则诊断仪可能误判。DTCFormatIdentifier填0x01表示ISO 14229-1格式。3.2 诊断仪侧的请求构造与解析有了ECU模拟器诊断仪侧就简单了。核心是把请求字节流构造出来再把响应解析成人类可读的信息def build_1901_request(status_mask: int) - bytes: return bytes([0x19, 0x01, status_mask]) def parse_1901_response(resp: bytes) - dict: if resp[0] 0x7F: return {error: fNRC 0x{resp[2]:02X}} if resp[0] ! 0x59 or resp[1] ! 0x01: return {error: unexpected response} availability_mask resp[2] dtc_format resp[3] count struct.unpack(H, resp[4:6])[0] return { availability_mask: f0x{availability_mask:02X}, dtc_format: dtc_format, count: count } def build_1906_request(dtc: int, ext_record: int 0x01) - bytes: return bytes([0x19, 0x06, (dtc 16) 0xFF, (dtc 8) 0xFF, dtc 0xFF, ext_record]) def parse_1906_response(resp: bytes) - dict: if resp[0] 0x7F: return {error: fNRC 0x{resp[2]:02X}} if resp[0] ! 0x59 or resp[1] ! 0x06: return {error: unexpected response} dtc (resp[2] 16) | (resp[3] 8) | resp[4] status resp[5] ext_rec resp[6] occurrence resp[7] if len(resp) 7 else None return { dtc: f0x{dtc:06X}, status: f0x{status:02X}, ext_record: ext_rec, occurrence: occurrence }跑一下测试ecu ECUSimulator() # 统计所有已确认的DTC数量 req build_1901_request(0x08) resp ecu.handle_request(req) print(parse_1901_response(resp)) # 输出{availability_mask: 0xFF, dtc_format: 1, count: 3} # 读取某条DTC的扩展数据含发生次数 req build_1906_request(0xP0301) resp ecu.handle_request(req) print(parse_1906_response(resp)) # 输出{dtc: 0xP0301, status: 0x08, ext_record: 1, occurrence: 12}这套代码虽然简单但把协议的核心逻辑都覆盖了。你可以在此基础上扩展加入多帧传输ISO-TP、加入会话控制0x10、加入安全访问0x27逐步逼近真实ECU的行为。3.3 接入真实CAN总线时的配置要点如果你要接真实硬件python-can的配置是关键。以PCAN为例import can bus can.interface.Bus( channelPCAN_USBBUS1, bustypepcan, bitrate500000 ) msg can.Message( arbitration_id0x7E0, databuild_1901_request(0x08), is_extended_idFalse ) bus.send(msg) response bus.recv(timeout1.0) if response: print(parse_1901_response(response.data))这里有几个坑要提前说物理寻址 vs 功能寻址0x7E0是物理寻址的请求ID响应在0x7E8。如果你用功能寻址0x7DF多个ECU会同时响应需要处理总线仲裁和响应区分。ISO-TP分包如果请求或响应超过8字节必须走ISO-TP。python-can本身不处理ISO-TP需要配合isotp库或者udsoncan的IsoTPSocketConnection。超时设置ECU响应时间受P2/P2参数约束典型P2是50msP2是5000ms。超时设太短会误判设太长会拖慢测试节奏。4. 实操中踩过的坑与排查技巧实录4.1 状态掩码填错导致统计结果对不上这是最常见的坑。有一次同事反馈说用0x1901统计“已确认DTC”数量结果和用0x1902读出来的列表长度不一致。排查了半天发现他把StatusMask填成了0x09testFailed | confirmedDTC而ECU里有一条DTC是“已确认但最近一次测试通过”的状态字节是0x08不满足0x01位所以没被统计进去。经验StatusMask的每一位含义必须和ECU的DTC状态定义严格对齐。不同项目对状态位的使用可能不同有的ECU不用bit 4~6有的把bit 7用作自定义含义。拿到项目诊断规范后先确认状态位定义表再构造掩码。4.2 0x1906返回的扩展数据记录格式不统一前面说过0x1906返回的扩展数据记录内容由厂商定义。我见过三种典型实现实现方式扩展记录内容字节数方式A仅发生次数1字节1方式B发生次数2字节 老化计数器1字节3方式C发生次数2字节 老化计数器1字节 时间戳4字节7如果你的诊断仪代码写死了按方式A解析遇到方式B的ECU就会解析错位。正确做法是在诊断规范里查到扩展记录的定义或者先发一个请求把原始响应字节打印出来人工确认格式后再写解析逻辑。4.3 多帧响应下的计数解析当DTC数量超过255时0x1901的响应仍然是6字节因为计数用2字节表示最大65535。但如果ECU返回的响应里还带了其他信息比如某些厂商在响应后面追加了自定义数据就可能超过8字节触发ISO-TP多帧传输。这时候如果你用单帧解析逻辑去处理会丢掉后续帧。排查技巧在CAN分析仪上抓原始报文看响应是否以0x10首帧或0x21、0x22连续帧开头。如果是说明走了ISO-TP需要用完整的ISO-TP栈来接收。4.4 会话与安全访问的前置条件0x1901和0x1906在默认会话0x01下通常就能执行但有些ECU要求先进入扩展会话0x03甚至编程会话0x02还有的要求先通过安全访问0x27。如果你发请求后收到NRC 0x7FserviceNotSupportedInActiveSession或0x33securityAccessDenied就说明前置条件没满足。标准流程发10 03进入扩展会话发27 01请求种子根据种子计算密钥发27 02 [Key]再发19 01 [Mask]种子到密钥的算法由厂商定义常见的有固定密钥、异或、查表、AES等。这部分不在本文展开但你要知道它是绕不过去的。4.5 常见NRC速查表NRC含义可能原因0x11serviceNotSupportedSID不支持检查是否发错服务0x12subFunctionNotSupported子功能不支持检查子功能号0x13incorrectMessageLengthOrInvalidFormat报文长度不对检查请求字节数0x22conditionsNotCorrect条件不满足比如发动机未熄火0x31requestOutOfRange请求参数超范围比如DTC号不存在0x33securityAccessDenied未通过安全访问0x7FserviceNotSupportedInActiveSession当前会话不支持该服务0x78requestCorrectlyReceived-ResponsePendingECU还在处理需要继续等待0x78这个NRC特别重要。很多ECU在处理0x1906时如果扩展数据记录需要从EEPROM读取耗时会超过P2时间就会先回一个7F 19 78等处理完再回正式响应。你的诊断仪代码必须能处理这种“先挂起后响应”的模式否则会误判为超时。5. 把0x1906用出花几个进阶玩法5.1 周期性轮询做故障趋势分析单次读取只能看到当前快照但如果你每隔一段时间比如10秒读一次各ECU的DTC数量把数据存到数据库里就能画出故障增长曲线。我在一个车队项目里就是这么做的T-Box每30秒轮询一次0x1901把计数上传云端用Grafana做可视化。结果发现某批次车辆的ABS模块故障计数在行驶2000公里后开始线性增长提前预警了一批潜在问题。实现上Python侧可以用schedule库做定时任务import schedule import time def poll_ecu(): req build_1901_request(0x08) resp ecu.handle_request(req) result parse_1901_response(resp) # 存入数据库或发送到云端 print(f[{time.strftime(%H:%M:%S)}] confirmed DTC count: {result[count]}) schedule.every(10).seconds.do(poll_ecu) while True: schedule.run_pending() time.sleep(1)5.2 结合0x1902做交叉验证0x1901给数量0x1902给列表。两者结合可以做交叉验证如果数量对不上列表长度说明ECU内部状态不一致可能是DTC存储区有损坏。这种自检逻辑在EOL检测里很有用。def cross_check(ecu): count_resp ecu.handle_request(build_1901_request(0x08)) count parse_1901_response(count_resp)[count] # 假设0x1902返回列表这里简化处理 list_resp ecu.handle_request(bytes([0x19, 0x02, 0x08])) # 解析列表长度... list_len 3 # 示例值 if count ! list_len: print(fWARNING: count mismatch! 1901{count}, 1902{list_len}) else: print(Cross check passed.)5.3 在OTA前做健康检查OTA升级前主机厂通常要求目标ECU没有活跃故障。用0x1901统计“已确认待定”的DTC数量Mask0x0A如果大于0就中止升级。这个逻辑可以集成到OTA客户端里def pre_ota_check(ecu): resp ecu.handle_request(build_1901_request(0x0A)) result parse_1901_response(resp) if result[count] 0: raise RuntimeError(fECU has {result[count]} active DTCs, abort OTA) print(Pre-OTA check passed, ready to flash.)5.4 用0x1906读发生次数做老化分析如果ECU的扩展数据记录里包含发生次数你可以定期读取同一条DTC的计数观察它是否在增长。增长快说明故障在恶化增长慢或不变说明是历史遗留。这个信息对售后维修很有价值——维修站可以据此判断是“修好了”还是“暂时没复发”。6. 关于协议栈选型的一点个人建议如果你只是做测试脚本自己手写字节流完全够用代码量小、可控性强。但如果你要做产品级的诊断工具建议用成熟的UDS协议栈比如Python生态里的udsoncan。它把ISO-TP、会话管理、安全访问、DTC读取都封装好了你只需要配置连接参数和回调函数。不过udsoncan也有坑它的默认配置假设ECU遵循标准行为但很多厂商的ECU有自定义实现比如自定义NRC、非标准响应格式、特殊的超时参数。这时候你需要继承它的BaseService类来重写解析逻辑。我个人的做法是先用udsoncan快速跑通标准流程遇到不兼容的地方再针对性打补丁而不是一上来就自己造轮子。另外如果你用Vector的CANoe/CANalyzer做测试它们内置了UDS诊断描述文件CDD/ODX的支持可以直接导入厂商提供的诊断数据库自动生成测试用例。Python脚本更适合做自动化回归和CI集成两者可以互补。最后分享一个我在实际项目里总结的小技巧把0x1901的StatusMask做成可配置项而不是写死在代码里。因为不同项目、不同ECU的状态位定义可能不同写死会导致代码复用性差。用一个配置文件或者命令行参数传入掩码测试脚本就能一套代码适配多个项目。这个改动很小但省下的重复劳动很可观。