UDS 诊断实战:python-udsoncan 三层栈与常用服务

发布时间:2026/10/26 2:38:39
UDS 诊断实战:python-udsoncan 三层栈与常用服务 简介这份压缩包提供统一诊断服务 UDSISO-14229的 Python 3 实现面向汽车电子诊断、CAN/ISO-TP 通信及 OBD2 工具开发者可用于理解诊断客户端如何组织请求、解析响应、处理异常并适配不同连接方式。包内共 91 个文件以 71 个 Python 源码为核心另有 11 个 rst 文档和少量配置文件、测试及构建脚本压缩包约 160KB体量轻便目录按协议实现、连接适配、服务定义与测试组织。内容覆盖诊断服务定义、连接适配、J2534 支持、日志配置和较完整的单元测试文档可辅助梳理 UDS 服务调用流程、请求响应结构与协议细节便于将示例改造成自己的诊断脚本或测试工具。已有 3814 人学习下载适合作为 ECU 诊断开发、协议学习和二次改造的参考实现。1. 一条 22 F1 90 发出去之后UDS 到底卡在哪一层台架上发一条22 F1 90想读 VIN回来的却是10 14 62 F1 90 ...后面跟着三帧连续帧中间还夹一条流控——这活儿 python-udsoncan 替你干了。它是 ISO-14229UDS标准的 Python 实现请求构造、响应解析、P2 超时、否定响应异常全给封装好传输层交给 can-isotp。要手搓这套东西光 ISO-TP 的序号回绕和 STmin 间隔就能喝一壶。它适合做 ECU 台架自动化、产线诊断脚本、售后工具原型以及任何需要22/2E/27/31这类深度诊断、而不是只读 OBD2 排放数据的场合。2. 从 CAN 帧到 client.read_data_by_identifier()三层栈怎么接起来2.1 ISO-TP 与 UDS 的分工谁拆包谁管语义UDS 报文在 CAN 上不是一帧就装得下的。读 VIN 的响应有二十来字节得拆成首帧加若干连续帧请求侧同理。ISO 15765-2俗称 ISO-TP负责这件事首帧带总长度连续帧带一字节序号0~15 回绕接收方用流控帧告诉对方还能连发几帧、每帧最小间隔多少。ISO 14229 只关心首帧拼好之后那段字节第一字节是 SID后面是子功能和服务数据。python-udsoncan 的取舍很明确它自己不实现 ISO-TP只定义一个 Connection 抽象从里面拿“一整条 UDS 报文”。好处是不绑死在某一种总线上同一套服务代码换个 Connection 就能跑在 DoIP、串口或者你自己写的虚拟通道上代价是 can-isotp 和 python-can 得你自己接连接对象配错时它不会告诉你“总线不通”只会甩一个 TimeoutException。层标准谁来干你手上的旋钮数据链路ISO 11898python-can CAN 盒channel、bitrate、采样点传输层ISO 15765-2can-isotptxid/rxid、寻址模式、BS、STmin、padding应用层ISO 14229python-udsoncanSID、DID、会话、超时、解码 Codec一个常见误用是把 udsoncan 当 OBD2 工具去读 PID01 0C这类。发是能发但 OBD2 的 PID 语义不在它的封装范围里得走custom_service自己解日常读转速水温这种活儿用不着它。2.2 最小可运行骨架bus、isotp stack、connection、clientLinux 上先把 CAN 口拉起来这一步忘了后面所有代码都会超时而且报错信息不会提示你# 500k 是乘用车诊断口的常见速率商用车常用 250k sudo ip link set can0 up type can bitrate 500000 candump can0 # 先确认总线上确实有帧在跑然后是把三层串起来的四十行代码这也是整套东西唯一需要写死硬件参数的地方import can import isotp import udsoncan import udsoncan.configs from udsoncan.client import Client from udsoncan.connections import PythonIsoTpConnection # 1) 数据链路层python-can 拿到一条总线 bus can.interface.Bus(interfacesocketcan, channelcan0, bitrate500000) # 2) 传输层ISO-TP 地址。11 位物理寻址最常见txid 是 tester 发出去的 ID tp_addr isotp.Address( addressing_modeisotp.AddressingMode.Normal_11bits, txid0x7E0, # 发给 ECU 的 CAN ID rxid0x7E8, # ECU 回给 tester 的 CAN ID ) stack isotp.CanStack( busbus, addresstp_addr, params{ blocksize: 8, # 我回流控帧时告诉 ECU 一次能连发几帧 stmin: 0.002, # 连续帧最小间隔can-isotp 用秒不是毫秒 ll_data_length: 8, # CAN 是 8CAN FD 填 64 }, ) # 3) 应用层把 stack 包成 udsoncan 认识的连接 conn PythonIsoTpConnection(stack)txid/rxid是最容易写反的一对参数。物理寻址一般成对出现比如0x7E0/0x7E8、0x7E1/0x7E9功能寻址则是0x7DF发、多个 ECU 一起回。29 位寻址要换成isotp.AddressingMode.Normal_29bitsID 形如0x18DA10F1 / 0x18DAF110这里填错不会报错只会一直等。blocksize和stmin是你在回流控帧时给 ECU 立的规矩。台架上我一律把blocksize设成 8、stmin设成 2ms比多数 ECU 的默认值宽松先跑通再往紧里调别一上来就stmin0追求速度某些老 ECU 的接收缓冲撑不住表现是随机丢连续帧。2.3 配置字典五个真正影响成败的键Client的第二个参数是一份配置字典直接dict()拷一份默认值再改不要原地改default_client_config那是模块级共享对象改一次整个进程都受影响config dict(udsoncan.configs.default_client_config) # 单条请求的总等待时间0x78 会被自动续期但总时长超了照样抛异常 config[request_timeout] 3.0 # 关掉自动抛异常改成自己判断 NRC适合做批量扫描 config[exception_on_negative_response] False # DID - 解码器。声明了 Codec 的 DID读回来直接是业务值而不是裸 bytes config[data_identifiers] { 0xF190: udsoncan.AsciiCodec(17), # VIN17 字符 0xF187: udsoncan.AsciiCodec(10), # 备件号 0x0100: udsoncan.IntegerCodec(2, False), # 两字节无符号 } # 0x27 的种子密钥算法签名随版本变过见第 3 章 config[security_algo] None config[security_algo_params] None还有两个键常被忽略tester_present_send_period默认 1 秒设成None就关掉后台保活线程和input_output0x2F服务要用到的 DID 编解码表。如果手里这支 ECU 对3E 80特别敏感、收到就打断当前例程把tester_present_msg换成3E 00的抑制正响应版本比关掉保活安全。2.4 第一次对话切会话、读 VIN、看返回值长什么样接上 Client 就能发第一条业务请求了。注意with块进入时会 open 连接、退出时会 close别在外面又去操作同一个 stackfrom udsoncan.services import DiagnosticSessionControl with Client(conn, configconfig) as client: # 0x10 03进扩展会话。多数 22/2E/31 都要在这一层才放行 sess client.change_session(DiagnosticSessionControl.Session.extendedDiagnosticSession) print(hex(sess.service_data.session)) # 回显 0x3 # 0x22 读 VIN返回的 values 是以 DID 为键的字典 resp client.read_data_by_identifier(0xF190) print(resp.service_data.values[0xF190]) # 只读一个 DID 时可以用 first 版本直接拿值 vin client.read_data_by_identifier_first(0xF190)resp.service_data.values是字典而不是列表因为22支持一次请求多个 DID。真出现某个 DID 不存在时整条请求会返回7F 22 31而不是部分成功——所以批量读的时候要么分开读要么做好整条失败的兜底。3. 常用服务落地会话、安全访问、DID 读写与例程控制3.1 0x10 会话切换与 0x3E 保活S3 定时器不是可选项会话是有寿命的。ECU 内部有个 S3 定时器常见 5 秒超过这个时间没收到任何诊断请求就自动退回默认会话之前解锁的安全级别、打开的特殊功能全清空。表现就是脚本前半段跑得好好的中间停下来看两眼日志后面全变成7F xx 7F。python-udsoncan 的处理方式是起一个后台线程按tester_present_send_period周期发3E 00。这个线程随with Client(...)一起起停你不用管但要意识到它的存在如果你手动发了一串时序敏感的请求中间插进来的3E有可能打乱 ECU 的上一条状态机这时候把它关掉自己在一个长例程前后各补一条。# 关掉自动保活自己控制节奏 config[tester_present_send_period] None with Client(conn, configconfig) as client: client.change_session(DiagnosticSessionControl.Session.extendedDiagnosticSession) client.tester_present() # 显式发 3E 00 # ... 一串耗时操作 ... client.tester_present() # 或者反过来把周期拉到 2 秒减少对时序的干扰 config[tester_present_send_period] 2.0会话切换返回的service_data.session是 ECU 的回显拿它跟请求值比一下能提前抓出“请求 0x03 却回到 0x01”这种 ECU 不认账的情况。3.2 0x27 安全访问种子密钥回调怎么写、怎么调试0x27是两道门先27 01要种子ECU 回一串随机数再27 02把算好的密钥送回去对了才解锁。udsoncan 把这两步包在unlock_security_access里你只需要提供算法def my_seed_key_algo(client, level, seed, params): level 是奇数0x01/0x03/0x11...seed 是 ECU 回的那串字节 if seed b\x00 * len(seed): return b\x00 * len(seed) # 全 0 种子代表已解锁原样送回 secret params[secret] key bytes((b ^ secret[i % len(secret)]) for i, b in enumerate(seed)) # 实际项目里这里通常是查表、AES 或者按位循环移位写在文档里 return key config[security_algo] my_seed_key_algo config[security_algo_params] {secret: b\x5A\x3C} with Client(conn, configconfig) as client: client.change_session(DiagnosticSessionControl.Session.extendedDiagnosticSession) client.unlock_security_access(0x01) # 传的是 level不是 0x01/0x02 这对子功能security_algo的回调签名在版本之间动过早期是(level, seed, params)后来加了client作为第一个参数。拿旧代码直接跑新版本报的是TypeError: lambda() takes 3 positional arguments but 4 were given——看到这个错别怀疑算法先数一下形参。调试0x27最有效的手段不是猜是把种子打印出来对照算法文档。udsoncan.setup_logging()会把每一帧的收发都打到日志里包括原始的67 01 xx xx ...比在分析仪上翻报文快得多。3.3 0x22/0x2E 读写 DID用 Codec 把裸字节变成业务字段DID 的数据形态千奇百怪VIN 是定长 ASCII里程是 4 字节无符号故障计数是 2 字节还有一堆位域打包的。udsoncan 用 Codec 把这些差异挡在业务代码之外——配置里登记过 Codec 的 DID读回来直接是int或str不用自己struct.unpack。import udsoncan # 自定义一个 CodecBCD 编码的生产日期4 字节年/月/日/序号 class BcdDateCodec(udsoncan.DidCodec): def __init__(self): self._len 4 def __len__(self): return self._len def encode(self, value): # value 形如 240315 序号转成 BCD digits .join(value)[:8].ljust(8, 0) return bytes(int(digits[i:i2], 16) for i in range(0, 8, 2)) def decode(self, payload): return .join(f{b:02X} for b in payload) config[data_identifiers] { 0xF190: udsoncan.AsciiCodec(17), 0xF18C: BcdDateCodec(), } with Client(conn, configconfig) as client: client.change_session(DiagnosticSessionControl.Session.extendedDiagnosticSession) client.unlock_security_access(0x01) vin client.read_data_by_identifier_first(0xF190) client.write_data_by_identifier(0xF190, TESTVIN0000000000) # 17 位AsciiCodec(17)里的 17 不是可有可无的装饰。2E写下去的时候udsoncan 按这个长度校验你传的字符串短了会补、长了会截但如果 ECU 那边是变长 DID补出来的多余字节会让它回7F 2E 13长度或格式不对。遇到变长 DID 就换成udsoncan.BytesCodec之类按原样透传的写法别硬套定长。写 DID 之前先读一次是个好习惯一是确认 DID 存在、当前会话可写二是看清实际长度。有些 ECU 在非编程会话下拒绝所有2E脚本里老老实实把会话切到位。3.4 0x31 例程控制一次调用背后的三个子功能0x31在台架上用得极多擦写 Flash 前的预编程检查、传感器零点标定、DPF 再生、产线末端的功能自检背后都是它。子功能有三个——启动、停止、查结果新手最容易把“启动完就去查结果”写成死循环。from udsoncan.services import RoutineControl with Client(conn, configconfig) as client: client.change_session(DiagnosticSessionControl.Session.extendedDiagnosticSession) client.unlock_security_access(0x01) # 01 启动例程data 是例程自己的入参这里传一个字节的档位 resp client.routine_control( routine_id0x0203, control_typeRoutineControl.ControlType.startRoutine, datab\x01, ) print(resp.service_data.routine_status_record) # 例程回的状态字 # 03 查结果长例程必须轮询不能指望启动那条请求一直挂着 while True: r client.routine_control( routine_id0x0203, control_typeRoutineControl.ControlType.requestRoutineResults, ) if r.service_data.routine_status_record[:1] ! b\x00: breakroutine_status_record的格式完全由 ECU 定义udsoncan 只能给你原始字节语义要查诊断规范。这一点跟22不一样——DID 有 Codec 可配例程结果没有得自己在外面包一层解析函数。顺带说0x2FIO 控制它同样需要一个 DID 编解码表登记在config[input_output]里而不是data_identifiers。这两个字典填混了不会报配置错误只会在调用时找不到对应项。4. 服务之外的活自定义服务、原始帧与异常分支4.1 标准没覆盖的部分custom_service 怎么发、怎么解厂商私有服务比如某些 ECU 用0xBA传内部标定数据不在 udsoncan 的服务表里但连接层是通用的custom_service就是那个出口with Client(conn, configconfig) as client: client.change_session(DiagnosticSessionControl.Session.extendedDiagnosticSession) # 第一个参数是 SID第二个是 SID 之后的裸数据 resp client.custom_service(0xBA, b\x01\x02\x03) # custom_service 不解析service_data 就是 SID 之后那段字节 print(resp.positive, resp.service_data.hex()) # 需要完整原始响应含正响应 SID时把 payload 打出来看 print(resp.get_payload().hex())custom_service只做三件事把 SID 和你的数据拼成一条请求、发出去、等一条响应回来并判断正负。正响应 SID 的推算规则0xBA 0x40 0xFA它做子功能级别的解析它不做service_data里是什么就是什么。有个细节custom_service遇到否定响应照样会抛NegativeResponseException如果没关掉那个开关抛出来的e.response.code是 NRC不是 SID 的负值。这一点跟自己拼字节的脚本不一样别拿0x7F去比对。4.2 响应对象的结构positive、service_data、code 三件套不管是哪个服务返回的Response能用的一共就那几个字段记住这张表比记 API 有用字段含义什么时候看它positive布尔是否正响应关掉了自动抛异常之后靠它分支service_data已按服务定义的命名元组取业务值如session、values、dtcscode原始响应码正响应是 SID0x40负响应是 0x7F打日志、做协议一致性校验code_name上面那个码的字符串名日志里比数字直观valid响应是否符合服务定义的结构排查“ECU 回了但字段对不上”service_data是命名元组而不是字典resp.service_data.values这种点号访问写错了属性名会直接AttributeError不会静默返回None。这算是好事——现场排查时最怕的就是静默。4.3 异常分支把 0x78 和真错误分开UDS 的否定响应里有一个特殊值0x78requestCorrectlyReceivedResponsePending意思是“收到了还在算别急”。udsoncan 会自动把它吃掉收到7F xx 78之后重新等下一帧直到拿到真响应或者超过request_timeout。所以业务代码里永远不该看见0x78看见了说明你绕过了 Client 直接读连接。from udsoncan.exceptions import ( NegativeResponseException, TimeoutException, InvalidResponseException, ) try: client.change_session(DiagnosticSessionControl.Session.extendedDiagnosticSession) client.unlock_security_access(0x01) client.write_data_by_identifier(0xF190, TESTVIN0000000000) except NegativeResponseException as e: # e.response.code 是 NRC比如 0x33 / 0x35 / 0x22 print(被拒, hex(e.response.code), e.response.code_name) except TimeoutException: # 总线上没回音或者 0x78 一直续到 request_timeout 还没结束 print(超时先查链路再查 ECU 状态) except InvalidResponseException as e: # 回来了但结构不符长度不对、Codec 对不上、SID 不匹配 print(响应非法, e.response.get_payload().hex())三个异常对应三类完全不同的现场问题混在一起 catch 会把排查方向带偏。NRC 常见值的含义我整理成一张表台架上照着对就行NRC名字现场一般意味着什么0x11serviceNotSupportedSID 写错或 ECU 根本不支持该服务0x13incorrectMessageLengthOrInvalidFormat请求长度不对常见于 2E 写变长 DID0x22conditionsNotCorrect车速不为 0、发动机在转、当前状态不满足0x31requestOutOfRangeDID 或例程 ID 不存在该会话下不可用0x33securityAccessDenied没过 0x27或解锁后会话被切走0x35invalidKey密钥算错或种子取错0x7FserviceNotSupportedInActiveSession服务支持但当前会话不开放5. 避坑与排查五个把台架跑挂的现场记录5.1 请求发出去总线上连一帧都没有现象client.read_data_by_identifier()抛TimeoutException抓包工具里0x7E0上干干净净一帧都没发出去。原因连接对象根本没 open或者 CAN 口没起来ip link显示 DOWN或者channel写成了can1而线插在can0上。解决先绕开 udsoncan 验证链路——bus.send(can.Message(arbitration_id0x7E0, datab\x3e\x00, is_extended_idFalse))能在抓包工具里看到说明物理层和 python-can 都没问题问题出在 stack 或 Connection 上。这一步我每次都做比读 udsoncan 的日志快。5.2 读 DID 回 7F 22 31但分析仪上同一个 DID 能读出来现象脚本里read_data_by_identifier(0xF1A0)稳定返回requestOutOfRange换成手工诊断仪同一个 DID 正常。原因九成是会话没切到位或者解锁顺序不对。有些 ECU 的扩展会话里还要再进一次0x10 03的变体带厂商自定义子功能另一些要求会话切换后先发一条3E。解决把脚本的请求序列跟分析仪上的序列逐条对齐特别注意10 03之后有没有85 02关闭 DTC 记录这类容易被忽略的开关本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询