
简介面向汽车电子诊断开发、ECU 测试与 OBD2 学习者的 Python 库聚焦 ISO-14229UDS统一诊断服务标准可用于诊断请求与响应交互、ISO-TP 连接、车载总线实验及诊断工具原型验证适合具备一定 Python 基础并希望理解 UDS 协议实现方式的初中级开发者。压缩包共 91 个文件、约 160KB主体为 71 个 Python 源文件涉及 client、services、connections、exceptions、J2534 和测试模块另有 11 个 rst 文档及少量 txt、conf、yml 等配置文件便于查阅接口说明、运行测试和二次开发。已有 3814 人学习下载说明其在车载诊断学习场景中具有一定参考价值。借助源码、文档与测试样例读者可梳理 UDS 服务调用流程、诊断连接封装、异常处理、请求响应校验思路及底层传输细节也能借鉴项目模块划分、测试组织方式和文档结构较快搭建 Python 诊断脚本原型开展协议验证与功能排查。1. 台架上读不出 VIN 的那个下午台架上摆着一块 ECU你只想读个 VIN、清个故障码商业诊断仪点不到你想看的那一层厂商上位机又不给脚本接口。这时候最省事的路子是在 Python 里直接说 UDSISO 14229会话切换、DID 读写、DTC、例程控制都是标准服务传输层交给 ISO-TPISO 15765-2去分包你只管拼请求。python-udsoncan 干的正是这件事——把 ISO 14229 应用层服务封装成 Python 方法底层通过 can-isotp 和 python-can 跑在 CAN 总线上。适合做 ECU 台架测试、产线 EOL 工位、售后诊断工具和车端数据采集的开发者前提是你手上有一路能发诊断帧的 CAN 硬件并且知道目标 ECU 的诊断 ID。2. 链路先通再谈业务从 python-can 到 ISO-TP 的连接层诊断脚本写不出来九成不是服务用错了是链路根本没通。UDS 只定义应用层真正把 0x22 请求切成首帧、连续帧、流控帧再在对面拼回来的是 ISO-TP。udsoncan 自己不碰 CAN 驱动它把这一层完全交给 can-isotpcan-isotp 又把收发交给 python-can。理解这三层的关系比背 API 重要得多。2.1 三个包、一份版本账装的时候一条命令就够udsoncan 会把 can-isotp 和 python-can 作为依赖拉下来pip install udsoncan can-isotp python-can但我不建议就这么放着跑。can-isotp 在 2.x 到 3.x 之间动过TransportLayer的寻址参数名早几年的教程里写addressing新版本是addressing_mode直接抄会得到一个TypeError。装完先在 REPL 里看一眼真实签名比对着任何文章猜都快import inspect, isotp print(inspect.signature(isotp.TransportLayer.__init__))参数说明inspect.signature打印的是当前装的那个版本的真实入参注意看addressing_mode、rxid、txid、params、ll_data_length这几个名字是否都在。看不到ll_data_length说明版本偏老CAN FD 那套就用不了。项目里我一般会把依赖锁在requirements.txt里台架机和工位机各锁一份避免「昨天还能跑今天就不行」的玄学。python-can 这一侧要确认的是接口名。Linux 上用 socketcanWindows 上多半是适配器厂商提供的 python-can 插件macOS 又是另一套。接口名写错的表现是can.Bus()直接抛异常这一步错了根本轮不到 udsoncan 出场。2.2 站在 tester 视角拼一个 TransportLayer诊断 ID 是最容易搞反的地方。以最常见的 11 位寻址为例 tester 发给 ECU 用 0x7E0ECU 回给 tester 用 0x7E8。在 can-isotp 里txid是「我发出去的 ID」rxid是「我等回来的 ID」所以站在 tester 角度就是 txid0x7E0、rxid0x7E8别按 ECU 视角填。import can import isotp from udsoncan.connections import PythonIsoTpConnection # 1. 打开 CAN 通道500k 是乘用车诊断最常见的速率 bus can.Bus(interfacesocketcan, channelcan0, bitrate500000) # 2. 建 ISO-TP 传输层负责分段、流控、重组 tp isotp.TransportLayer( busbus, addressing_modeisotp.AddressingMode.Normal_11bits, # 11 位标准寻址 rxid0x7E8, # ECU - tester txid0x7E0, # tester - ECU params{ blocksize: 0, # 0 表示不限块收到流控就继续发 stmin: 0, # 连续帧最小间隔0 尽量快 }, ) # 3. 把传输层包成 udsoncan 认识的连接对象 conn PythonIsoTpConnection(tp)逻辑说明params里的blocksize和stmin是 ISO-TP 流控参数最终由 ECU 的流控帧说了算这里填的是「我作为发送方愿意接受的上限」。有些老 ECU 对 stmin0 处理不好会在连续帧中间丢帧表现是刷写跑到一半 NRC 或者超时把它改成 510 毫秒往往就顺了。29 位寻址的 ECU 要换isotp.AddressingMode.Normal_29bitsID 形如 0x18DA10F1tester→ECU和 0x18DAF110ECU→tester其中 0x10 和 0xF1 是源地址和目标地址。搞不清方向的时候把两个 ID 对调试一次是最快的验证方式——通了就是通了不通也不会弄坏什么。扩展寻址Extended addressing还要在数据首字节塞目标地址udsoncan 侧对应config[server_address]。这类细节不同厂商差异很大别指望一份配置通吃。2.3 三种连接方式按你的工程形态选udsoncan 把连接抽象成了一个很薄的接口官方给了几个实现选错会导致后面很难受连接类底层适用场景注意点PythonIsoTpConnectioncan-isotp 用户态实现跨平台、单进程脚本、台架调试最常见收发都在 Python 进程内SocketConnectioncan-isotp 的 socket 接口需要多进程共享一条诊断链路依赖系统或库的 socket 支持QueueConnection你自己塞帧的队列已有 CAN 收发主循环诊断只是其中一路自己负责分发别把非诊断帧塞进去QueueConnection这个用法值得单独说一句。很多车端采集程序本来就在一个can.Notifier循环里收全总线报文这时候再开一个can.Bus去独占通道在某些驱动上会冲突。用QueueConnection把匹配到诊断 ID 的帧转发进来udsoncan 照样工作等于把它嵌进了已有的收发体系。2.4 最小可跑读一个 VIN链路铺好之后第一个脚本别急着刷写先读 VIN。VIN 的 DID 是 0xF190定长 17 字节 ASCII成功率高最适合验证「请求发得出去、响应收得回来、解码没毛病」这条完整链路。import udsoncan from udsoncan.client import Client # 从默认配置复制一份绝不直接改全局默认 config dict(udsoncan.configs.default_client_config) config[p2_timeout] 0.05 # 单位是秒不是毫秒 config[p2_star_timeout] 5.0 # 收到 0x78 之后的等待时间 config[request_timeout] 2.0 # 整体兜底 config[data_identifiers] { 0xF190: udsoncan.AsciiCodec(17), # VIN17 字节 ASCII } with Client(conn, configconfig) as client: client.change_session( udsoncan.services.DiagnosticSessionControl.Session.extendedDiagnosticSession ) vin client.read_data_by_identifier_first(0xF190) print(VIN:, vin)逻辑说明with进入时调用open()启动连接退出时close()异常退出也会走清理比手写 try/finally 稳。change_session先切到扩展会话0x10 03因为很多 ECU 在默认会话下不允许读某些 DID。read_data_by_identifier_first是单 DID 的便捷方法直接返回解码后的值多 DID 用read_data_by_identifier([...])拿到的是Response对象。参数说明p2_timeout这个 0.05 是整篇里最值得记住的一个数字。udsoncan 默认给的是 5 秒而 ISO 14229 里 P2Server 通常是几十毫秒量级默认值偏大意味着链路真断了你要等 5 秒才知道。反过来把它设成 0.005 也会翻车后面避坑那章细说。3. Client、Config、Response三个对象撑起整个库把 udsoncan 用顺其实就是把这三个东西摸清。Client 是会话载体Config 决定行为边界Response 是唯一的返回形态。剩下几十个服务方法全都是这三者的组合。3.1 Client 的生命周期与配置合并Client(conn, config...)在构造时会把传入的 config 和默认配置合并。问题在于这个合并是浅层的data_identifiers这种嵌套字典如果你只做了dict(default_client_config)改它等于改全局默认值——同一个进程里第二个 Client 会莫名其妙继承上一个的 codec这种 bug 查起来极其难受。import copy import udsoncan # deepcopy 最省心代价可以忽略 config copy.deepcopy(udsoncan.configs.default_client_config) # 也可以手动分层拷贝效果一样 # config dict(udsoncan.configs.default_client_config) # config[data_identifiers] dict(config[data_identifiers]) config[data_identifiers][0xF190] udsoncan.AsciiCodec(17) config[data_identifiers][0xF187] udsoncan.AsciiCodec(11) # 零件号逻辑说明每个 Client 一份独立 config是写多车型诊断工具的基本纪律。我一般把 config 做成函数build_config(profile)输入车型档案名输出一份新 dict调用方永远拿不到可被别的模块改坏的引用。Client 还提供open()、close()、is_open()需要长连接常驻的场景比如工位程序常驻等扫码就别用with自己管生命周期进程退出时统一 close。3.2 三个超时参数单位是秒udsoncan 的超时全部以秒为单位这一条能劝退不少人。ISO 文档里写的是毫秒很多人顺手把 50 填进p2_timeout结果每次请求都傻等 50 秒才抛异常还以为是 ECU 不响应。参数含义常见取值填错的后果p2_timeout发出请求到收到首帧响应的等待0.050.1太大故障发现慢太小随机超时p2_star_timeout收到 NRC 0x78 后的延长等待530按 ECU 最慢操作定太小擦 Flash 时误判超时request_timeout单次请求的总兜底210不设某些异常下会卡死线程p2_star_timeout要根据业务定。读个 DID 给 5 秒绰绰有余擦除 Flash、写指纹这类操作ECU 内部可能要跑十几秒给 5 秒必然翻车得按目标 ECU 的手册放宽到 20 秒甚至更久。3.3 Response 与 NegativeResponseExceptionconfig[exceptions] True默认时负响应直接抛NegativeResponseException正响应返回Response。关掉之后所有响应都从返回值出来自己判resp.positive。两种风格都行团队里统一一种别混着写。from udsoncan.exceptions import ( NegativeResponseException, TimeoutException, InvalidResponseException, ) try: resp client.read_data_by_identifier([0xF190]) print(resp.service_data.values[0xF190]) # values 是 {did: 解码值} 的字典 except NegativeResponseException as e: # request_id 是被拒绝的服务 IDresponse_code 是 NRC nrc e.response.service_data.response_code print(fNRC: {nrc}) except TimeoutException: print(没等到响应先查链路) except InvalidResponseException: print(响应收到了但格式不对多半是 codec 长度对不上)逻辑说明这三类异常对应三种完全不同的排查方向。TimeoutException指向物理层和 ID 配置NegativeResponseException说明链路是通的、ECU 明确拒绝了请求通常是会话不对、安全未解锁、条件不满足InvalidResponseException说明请求成功但数据解析炸了问题在 codec。分不清这三者就会在一堆无关的方向上浪费时间。InvalidResponseException那条尤其值得记ECU 回了正响应但返回的字节数和你声明的 codec 长度不一致udsoncan 解不出来就抛这个。第一次接新 ECU一律先用BytesCodec()原样拿 bytes 看长度比什么猜测都快。3.4 服务返回的 service_data 各不相同所有服务方法的返回都是Response但service_data的结构按服务定义没有统一字段。这个设计合理但需要记方法service_data 关键字段change_sessionsession当前会话类型ecu_resetpower_down_time复位前倒计时read_data_by_identifiervalues字典read_dtc_informationdtcsDTC 列表routine_controlroutine_status_record字节串request_downloadmax_length单次传输上限request_transfer_exit通常为空拿不准的时候直接print(resp.service_data)ndr 风格的结构打印出来一目了然比翻文档快。4. 常用服务逐个落地会话、DID、DTC、例程与刷写这一章是真正干活的部分。每个服务我都按「什么时候用、代码怎么写、参数怎么改」的顺序讲照着抄能跑跑不通再回头对参数。4.1 会话切换与 TesterPresent 保活扩展会话有个 S3 定时器通常 5 秒内没有新请求ECU 自动掉回默认会话。掉了之后所有需要扩展会话的服务全部回 NRC 0x7F日志里看起来像「刚才还好好的突然全挂」这是最常见的翻车现场之一。import threading class KeepAlive(threading.Thread): 后台定时发 0x3E 80维持非默认会话 def __init__(self, client, interval2.0): super().__init__(daemonTrue) self.client client self.interval interval self._stop threading.Event() def run(self): # wait 返回 True 表示收到停止信号顺便当 sleep 用 while not self._stop.wait(self.interval): try: self.client.tester_present() except Exception as e: print(keep-alive 失败:, e) def stop(self): self._stop.set() self.join(timeout2)逻辑说明间隔取 S3 的一半左右2 秒是常见值。daemonTrue保证主程序退出时不会挂住。这里刻意没让异常中断线程——偶发一次握手失败不该杀掉保活但打印出来方便事后看频率。参数说明tester_present()默认带抑制正响应位0x3E 80ECU 不回响应省带宽也省解析。有些 ECU 对抑制位支持不好那就发 0x3E 00 等一个正响应代价是链路上多一半流量。保活线程要在切会话之后启动在切回默认会话或断开之前停掉。我见过在默认会话下也一直发 0x3E 的脚本虽然不出错但会让总线负载上去同一条总线上别的节点可能受影响。4.2 DID 读写与 DidCodecDID 是日常用得最多的服务本质是按 ID 读取或写入一段字节。麻烦的地方全在编解码同样的 2 个字节可能是无符号整数、可能是两个独立的开关位、也可能是带 0.1 倍率的物理量。import udsoncan # 场景一VIN定长 17 字节 ASCII config[data_identifiers][0xF190] udsoncan.AsciiCodec(17) # 场景二电瓶电压1 字节倍率 0.1 class VoltageCodec(udsoncan.DidCodec): def encode(self, value): return bytes([int(round(value * 10)) 0xFF]) def decode(self, payload): return payload[0] / 10.0 def __len__(self): # 必须实现udsoncan 用它做长度校验 return 1 config[data_identifiers][0x0101] VoltageCodec() # 场景三4 字节大端无符号比如累计里程 config[data_identifiers][0x0102] udsoncan.StructCodec(I)逻辑说明AsciiCodec、BytesCodec、StructCodec覆盖了大部分场景剩下不规则的自己继承DidCodec。三个方法里__len__最容易漏漏了之后encode阶段会出问题报错信息还不一定指向这里。参数说明StructCodec的格式串就是标准struct语法I表示大端 4 字节无符号。车载数据基本都是大端别用本机字节序的I在 x86 上碰巧对换平台就错。写 DID 之前先确认会话和安全等级很多 ECU 的写 DID 要求扩展会话加安全解锁缺一个就回 NRC 0x33 或 0x7Fclient.change_session( udsoncan.services.DiagnosticSessionControl.Session.extendedDiagnosticSession ) client.write_data_by_identifier(0xF190, LSVAA1234567890123)write_data_by_identifier会走你注册的 codec 做 encode所以 codec 必须是双向可用的。只实现了decode的自定义 codec 在读的时候没问题写的时候直接抛NotImplementedError。4.3 DTC 读取与清除读故障码是诊断工具的门面功能。udsoncan 把 0x19 服务的各种子功能都铺开了最常用的是按状态掩码读全部。resp client.read_dtc_information( udsoncan.services.ReadDTCInformation.ReportType.all_dtc_by_status_mask, dtc_status_mask0xFF, # 所有状态的 DTC 都要 ) for dtc in resp.service_data.dtcs: # dtc.id 是 3 字节整数不是字符串 print(fDTC 0x{dtc.id:06X} status0x{dtc.status.get_byte():02X})逻辑说明dtc.id是原始 3 字节值要显示成 P0301 这种 SAE 格式得自己做映射高两位字节决定系统P/C/B/U后面对应数字。这一步 udsoncan 不管属于业务层的事网上有成熟的换算表自己写十行也能搞定。参数说明dtc_status_mask的位定义来自 ISO 14229bit0 是 testFailedbit3 是 confirmedDTCbit7 是 warningIndicatorRequested。想看「当前故障」用 0x01想看「已确认故障」用 0x08想看全部就给 0xFF。给错了不是报错是结果为空特别容易被误判成「这车没故障」。清除用一句client.clear_dtc_information()注意清除通常要求扩展会话部分 ECU 还要求先满足「故障已不存在」的条件否则回 NRC 0x22 conditionsNotCorrect。台架上刷完新固件清不掉旧码多半是这个原因。4.4 安全访问seed 与 key 的那点事0x27 服务是所有「写」操作的前置。流程是 tester 请求 seed奇数子功能ECU 返回随机数tester 用密钥算法算出 key用偶数子功能发回去。# 密钥算法由 ECU 决定这里只是示意每个字节加 0x20 def my_security_algo(level, seed): return bytes([(b 0x20) 0xFF for b in seed]) config[security_algo] my_security_algo # level 传奇数udsoncan 内部自动用偶数发 key client.unlock_security_access(0x11)逻辑说明security_algo的签名是(level, seed) - keylevel 就是你传进去的那个奇数。udsoncan 拿到 key 之后自动组装第二个请求你不用管 0x12/0x14 这些偶数子功能怎么拼。参数说明返回的 key 长度必须和 ECU 期望完全一致多一个字节少一个字节都是 NRC 0x35 invalidKey。连续失败若干次 ECU 会进入延时锁定短则十几秒长则十几分钟所以脚本里一定要做失败熔断——连续两次 key 被拒就停下别再试第三次。5. 避坑记录五类让诊断脚本当场翻车的情况下面这些每一条我都真实遇到过按「现象 → 原因 → 解决」写对照着排查能省掉大量怀疑人生的时间。5.1 超时与负响应类现象同一个脚本十次里有两三次在change_session抛TimeoutException换台电脑或者换根线又好了。原因p2_timeout贴着 ECU 实际响应时间设的比如设 0.02。CAN 帧收发本身有微秒级抖动USB-CAN 适配器还有几毫秒缓冲再叠加 Python 的 GIL 调度20 毫秒的窗口根本不够稳。解决p2_timeout给到 0.050.1request_timeout兜底 2 秒业务层对可重试的请求加两次重试。别为了「响应快」把窗口压到极限诊断的稳定性比那几十毫秒重要得多。现象刷写过程中 ECU 先回了一个负响应脚本立刻判失败退出但 ECU 其实还在干活。原因那个负响应的 NRC 是 0x78responsePending意思是「我收到了还在处理等会儿给你最终结果」。这是正常流程不是失败。解决确认p2_star_timeout能覆盖 ECU 最慢的那次操作。udsoncan 默认会等 0x78 之后的最终响应但如果你自己在外层套了「收到负响应就抛」的逻辑就会把它误杀。日志里看到 0x78 别慌那是 ECU 在告诉你它在忙。5.2 寻址与物理链路类现象总线接好了发什么都没回应TimeoutException一路到底。原因可能有三处rxid/txid 填反了、11 位和 29 位寻址模式不匹配、波特率不是目标值。还有一个容易被忽略的CAN 终端电阻没接或者只接了一端。解决先别上 udsoncan用 python-can 裸收一遍import can bus can.Bus(interfacesocketcan, channelcan0, bitrate500000) for msg in bus: if msg.arbitration_id in (0x7E0, 0x7E8): print(hex(msg.arbitration_id), msg.data.hex())能看到 0x7E8 的帧再回去上 udsoncan。这一步能排掉八成「连不上」的问题剩下的才是协议层的事。现象29 位寻址的 ECU用Normal_11bits死活不通。原因诊断 ID 本身就是 29 位的比如 0x18DA10F1物理层寻址模式必须跟着换。解决addressing_modeisotp.AddressingMode.Normal_29bitsrxid 和 txid 按 0x18DAF1xx / 0x18DAxxF1 的规则算xx 是 tester 地址。算错方向时把两个 ID 对调试一次这是最快的验证法。5.3 数据解析与配置类现象读出来的 VIN 后面拖了一串\x00或者干脆乱码。原因codec 声明长度和 ECU 实际返回长度不一致。AsciiCodec(17)只适用于严格返回 17 字节的 DID有的 ECU 会多补几个填充字节。解决第一次接新 ECU全部先用udsoncan.BytesCodec()原样拿 bytesprint出来数长度确认了再换精确 codec。多花的这五分钟比后面查「为什么解析出来是空的」划算。现象pip install完之后 import 就报参数错误或者TransportLayer初始化直接TypeError。原因can-isotp 大版本之间改过构造参数名addressing和addressing_mode是两个时代的写法udsoncan 的版本要求也绑在一起。解决用inspect.signature现场看真实签名或者干脆在requirements.txt里把两个包都锁死版本。台架机升级依赖前先跑一遍回归脚本这条规矩能挡住大部分「环境变了」的锅。现象多线程环境下日志里的请求和响应串了A 线程收到了 B 线程的响应。原因一个Client实例不是线程安全的多个线程复用一个连接同时发请求响应回来的顺序无法保证。解决一个连接配一个 Client多线程访问加锁串行化或者每个线程独立建连接。诊断链路本身就是串行的强行并发不会变快只会变乱。6. 二次开发把它接进自己的诊断工具里单脚本跑通之后真正要花时间的是工程化。我一般会把 Client 再包一层 SessionManager把「切会话、保活、失败重试、会话掉了自动恢复」这四件事收进去业务代码只写sm.call(lambda c: c.read_data_by_identifier_first(0xF190))这种调用。class SessionManager: def __init__(self, client, session, retries2): self.client client self.session session self.retries retries self.keepalive None def __enter__(self): self.enter_session() return self def enter_session(self): self.client.change_session(self.session) self.keepalive KeepAlive(self.client) self.keepalive.start() def call(self, fn): for attempt in range(self.retries 1): try: return fn(self.client) except NegativeResponseException as e: # 只有「会话丢失 / 条件不满足」才重建会话重试 if e.response.service_data.response_code in (0x7F, 0x22): self.keepalive.stop() self.enter_session() continue raise raise RuntimeError(重试耗尽) def __exit__(self, *exc): if self.keepalive: self.keepalive.stop() self.client.change_session( udsoncan.services.DiagnosticSessionControl.Session.defaultSession )这段的关键在于「只对特定 NRC 重试」。NRC 0x7F 表示当前会话不支持该服务多半是保活漏了导致掉回默认会话重建会话有意义NRC 0x22 是条件不满足重建一次也可能恢复。而 0x31requestOutOfRange、0x35invalidKey这类重试一百次也没用直接往上抛。多车型适配我一般用配置表驱动把每个车型的 DID 定义、安全算法、诊断 ID、超时参数写进一个 dict 或 YAMLbuild_config(profile)动态生成 Client 配置。新增一个车型只需要加一段配置不用动主流程代码。PROFILES { bench_a: { rxid: 0x7E8, txid: 0x7E0, p2_timeout: 0.05, dids: { 0xF190: udsoncan.AsciiCodec(17), 0x0101: VoltageCodec(), }, }, # 新车型往这里加一段就行 }验证方法也很朴素每加一个车型先跑三条冒烟用例——切扩展会话、读 VIN、读全部 DTC。这三条过了说明链路、会话、编解码、DTC 解析四条主路径都正常剩下的服务再逐步加。有一次我在工位上排查一个「偶发读不到 VIN」的问题查了大半天链路最后发现是保活线程用的 2 秒间隔而那块 ECU 的 S3 只有 1.5 秒会话反复掉。从那以后我每次接新 ECU第一步都是用秒表量一遍 S3再把保活间隔定成它的三分之一这条习惯再没让我栽过。希望帮到你。本文还有配套的精品资源点击获取