☰
UDS 诊断实战:python-udsoncan 三层栈与常用服务
2026/10/26 2:38:22 网站建设 项目流程

简介:这份压缩包提供统一诊断服务 UDS(ISO-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-14229(UDS)标准的 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 工具去读 PID(01 0C这类)。发是能发,但 OBD2 的 PID 语义不在它的封装范围里,得走custom_service自己解,日常读转速水温这种活儿用不着它。

2.2 最小可运行骨架:bus、isotp stack、connection、client

Linux 上先把 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(interface="socketcan", channel="can0", bitrate=500000) # 2) 传输层:ISO-TP 地址。11 位物理寻址最常见,txid 是 tester 发出去的 ID tp_addr = isotp.Address( addressing_mode=isotp.AddressingMode.Normal_11bits, txid=0x7E0, # 发给 ECU 的 CAN ID rxid=0x7E8, # ECU 回给 tester 的 CAN ID ) stack = isotp.CanStack( bus=bus, address=tp_addr, params={ "blocksize": 8, # 我回流控帧时告诉 ECU 一次能连发几帧 "stmin": 0.002, # 连续帧最小间隔,can-isotp 用秒,不是毫秒 "ll_data_length": 8, # CAN 是 8,CAN FD 填 64 }, ) # 3) 应用层:把 stack 包成 udsoncan 认识的连接 conn = PythonIsoTpConnection(stack)

txid/rxid是最容易写反的一对参数。物理寻址一般成对出现,比如0x7E0/0x7E8、0x7E1/0x7E9,功能寻址则是0x7DF发、多个 ECU 一起回。29 位寻址要换成isotp.AddressingMode.Normal_29bits,ID 形如0x18DA10F1 / 0x18DAF110,这里填错不会报错,只会一直等。

blocksize和stmin是你在回流控帧时给 ECU 立的规矩。台架上我一律把blocksize设成 8、stmin设成 2ms,比多数 ECU 的默认值宽松,先跑通再往紧里调,别一上来就stmin=0追求速度,某些老 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), # VIN,17 字符 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_output(0x2F服务要用到的 DID 编解码表)。如果手里这支 ECU 对3E 80特别敏感、收到就打断当前例程,把tester_present_msg换成3E 00的抑制正响应版本,比关掉保活安全。

2.4 第一次对话:切会话、读 VIN、看返回值长什么样

接上 Client 就能发第一条业务请求了。注意with块进入时会 open 连接、退出时会 close,别在外面又去操作同一个 stack:

from udsoncan.services import DiagnosticSessionControl with Client(conn, config=config) 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, config=config) 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, config=config) 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 # 自定义一个 Codec:BCD 编码的生产日期,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:i+2], 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, config=config) 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, config=config) as client: client.change_session(DiagnosticSessionControl.Session.extendedDiagnosticSession) client.unlock_security_access(0x01) # 01 启动例程,data 是例程自己的入参,这里传一个字节的档位 resp = client.routine_control( routine_id=0x0203, control_type=RoutineControl.ControlType.startRoutine, data=b"\x01", ) print(resp.service_data.routine_status_record) # 例程回的状态字 # 03 查结果,长例程必须轮询,不能指望启动那条请求一直挂着 while True: r = client.routine_control( routine_id=0x0203, control_type=RoutineControl.ControlType.requestRoutineResults, ) if r.service_data.routine_status_record[:1] != b"\x00": break

routine_status_record的格式完全由 ECU 定义,udsoncan 只能给你原始字节,语义要查诊断规范。这一点跟22不一样——DID 有 Codec 可配,例程结果没有,得自己在外面包一层解析函数。

顺带说0x2F(IO 控制),它同样需要一个 DID 编解码表,登记在config["input_output"]里而不是data_identifiers。这两个字典填混了不会报配置错误,只会在调用时找不到对应项。

4. 服务之外的活:自定义服务、原始帧与异常分支

4.1 标准没覆盖的部分:custom_service 怎么发、怎么解

厂商私有服务(比如某些 ECU 用0xBA传内部标定数据)不在 udsoncan 的服务表里,但连接层是通用的,custom_service就是那个出口:

with Client(conn, config=config) 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、dtcs
code原始响应码,正响应是 SID+0x40,负响应是 0x7F打日志、做协议一致性校验
code_name上面那个码的字符串名日志里比数字直观
valid响应是否符合服务定义的结构排查“ECU 回了但字段对不上”

service_data是命名元组而不是字典,resp.service_data.values这种点号访问写错了属性名会直接AttributeError,不会静默返回None。这算是好事——现场排查时最怕的就是静默。

4.3 异常分支:把 0x78 和真错误分开

UDS 的否定响应里有一个特殊值0x78(requestCorrectlyReceivedResponsePending),意思是“收到了,还在算,别急”。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 写变长 DID
0x22conditionsNotCorrect车速不为 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_id=0x7E0, data=b"\x3e\x00", is_extended_id=False)),能在抓包工具里看到,说明物理层和 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 记录)这类容易被忽略的开关

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询