简介:这是一份基于 Python 3 实现的 UDS(ISO-14229)统一诊断服务协议源码包,面向汽车电子、CAN 总线及车载诊断相关开发者。项目源自 GitHub 开源库 pylessard/python-udsoncan,遵循 MIT 许可证,提供了完整的 UDS 客户端封装、服务定义、异常处理与连接管理能力,可配合 isotp 库用于 CAN 总线上的诊断通信开发与测试。资源包共 124 个文件,约 202KB,其中包含 90 个 Python 源码文件、11 个 reStructuredText 文档文件,以及配置文件、测试脚本、Dockerfile 等辅助内容,便于本地搭建环境、阅读源码和运行示例。已有 48 人学习浏览,适合正在学习车载诊断协议或需要快速集成 UDS 功能的 Python 工程师。通过该资源,读者可掌握 UDS 服务调用流程、常见异常处理方式,并借助自带的测试用例与文档快速上手实际项目。
1. UDS(ISO-14229)为什么值得用 Python 重写一套诊断栈
汽车电子诊断是一个被低估的领域:一台上汽通用研究院的试验车,一套产线刷写工具,一家独立维修店的 OBD 诊断仪,背后往往是同一条 UDS 诊断链路。ISO-14229 定义了统一诊断服务(Unified Diagnostic Services),它把「读故障码、读写数据、刷写 ECU、安全解锁」这些操作抽象成了一套请求-响应式协议,任何主流车厂的 ECU 都遵循这套规则。问题是:传统的诊断工具多基于 C/C++ 或专有脚本,想快速验证一个 19 服务的响应、想在 CI 里跑一次回归、想给 OTA 升级脚本做数据比对,用 Python 是最直接的路径。
这里要说的不是把 C 代码翻译成 Python,而是「用 Python 重新实现 UDS 协议栈」这件事本身:从字节帧怎么拼、NRC 怎么判,到传输层 ISO-TP 怎么分包,再到用成熟库把诊断会话跑通。你能得到一套不依赖硬件也能测试的协议栈;接上 CAN 卡就能连真实 ECU;接上脚本就是自动化诊断工具。适合做车载测试、ECU 开发、产线工具链的工程师,也适合想理解诊断协议底层机制的嵌入式开发者。
2. UDS 协议核心:服务 ID、NRC 与寻址模式对 Python 代码的影响
2.1 从诊断会话到字节流:UDS 报文的最小结构
UDS 报文本身没有校验和外层封装,它依赖下层传输协议(通常是 ISO-TP over CAN)做分包和重组。一个完整的 UDS 请求由三个部分组成:服务 ID(SID)、子功能或数据参数、数据体。0x22 0xF1 0x90这条三个字节的请求,0x22是 ReadDataByIdentifier,0xF1 0x90是数据标识符(DID),合起来的含义是「读取 DID 0xF190 指向的数据」。
Python 里构造这类报文非常直接:
def build_uds_request(sid: int, sub_func: int | None, data: bytes = b"") -> bytes: payload = bytearray() payload.append(sid) if sub_func is not None: payload.append(sub_func) payload.extend(data) return bytes(payload) # 诊断会话控制:切换到扩展会话(0x03) print(build_uds_request(0x10, 0x03)) # 输出: b'\x10\x03' # 读取 DID 0xF190 print(build_uds_request(0x22, None, bytes([0xF1, 0x90]))) # 输出: b'\x22\xf1\x90'这段代码拆开看有两个设计决策:sub_func作为可选参数是因为 22 服务没有子功能,而 10、27、31 这类服务的第一字节是子功能;数据区用 bytes 而不只是 int,是为了兼容多字节内容。注意 10 服务带子功能,22 服务不带,这是 UDS 最容易写错的点。
2.2 服务 ID 映射与 Python 枚举设计
在实现诊断栈时,我一般会用枚举把服务 ID 和负响应码(NRC)固定下来。枚举比裸整型好使,因为读到响应时能直接按名字定位。参考 ISO-14229-1 的 2020 版,常用服务如下:
| SID | 服务名 | 子功能/参数 | 典型用途 |
|---|---|---|---|
| 0x10 | DiagnosticSessionControl | 0x01/0x02/0x03 | 切换默认、编程、扩展会话 |
| 0x11 | ECUReset | 0x01/0x02/0x03 | 硬复位、键控下电再上电 |
| 0x19 | ReadDTCInformation | 0x02/0x04/0x0A | 读故障码状态、快照 |
| 0x22 | ReadDataByIdentifier | DID | 读 VIN、软件版本号 |
| 0x27 | SecurityAccess | 0x01/0x02/0x03 | 种子请求/密钥发送 |
| 0x2E | WriteDataByIdentifier | DID + 数据 | 写配置项 |
| 0x31 | RoutineControl | 0x01/0x02/0x03 | 启动/停止例程 |
| 0x34 | RequestDownload | 地址+长度 | 刷写前置请求 |
| 0x36 | TransferData | 块序列号+数据 | 刷写数据块 |
| 0x37 | RequestTransferExit | 无 | 刷写结束确认 |
| 0x3E | TesterPresent | 0x00 | 保持会话激活 |
Python 里这样建模:
class UdsService(IntEnum): DiagnosticSessionControl = 0x10 ECUReset = 0x11 ReadDTCInformation = 0x19 ReadDataByIdentifier = 0x22 SecurityAccess = 0x27 WriteDataByIdentifier = 0x2E RoutineControl = 0x31 RequestDownload = 0x34 TransferData = 0x36 RequestTransferExit = 0x37 TesterPresent = 0x3E class Nrc(IntEnum): GeneralReject = 0x10 ServiceNotSupported = 0x11 SubFunctionNotSupported = 0x12 IncorrectMessageLength = 0x13 ConditionNotCorrect = 0x22 RequestSequenceError = 0x24 RequestOutOfRange = 0x31 SecurityAccessDenied = 0x33 InvalidKey = 0x35 ExceededAttempts = 0x36 RequiredTimeDelayNotExpired = 0x37响应解析的规则藏在 SID 的 bit 6 里:ECU 返回的正响应会把请求 SID 的 bit 6 置 1,即response_sid = request_sid | 0x40;如果返回0x7F,后面跟两个字节,第一字节是被拒绝的 SID,第二字节是 NRC。这个位运算在后续帧解析时直接决定分支走向。
2.3 物理寻址与功能寻址:Python 要处理的地址分层
UDS 在 CAN 上通常用 29 位扩展帧,标准帧也能承载但少见。物理寻址是一对一,功能寻址是一对多,后者的典型例子是0x18DB33F1这个功能寻址 ID,发送到该 ID 的报文车上所有 ECU 都会收。物理寻址的请求 ID 一般是功能地址 + 发送端地址的固定组合,比如测试仪0xF1到 ECU0x0E,常用请求 ID 是0x18DAF10E,响应 ID 是0x18DA0EF1。
Python 层不需要自己拼 CAN ID,但时序上要注意:物理响应要在请求后 50ms 内发回,ISO-TP 连续帧之间也有时间约束。做诊断工具时,如果发现「发请求没响应」,先确认 CAN ID 是否反了,这是比查协议更频繁的错误。
3. Python 实现 ISO-TP 传输层:单帧与多帧数据组装原理
3.1 为什么不能直接把 UDS 帧塞进 CAN
CAN 帧数据域最多 8 字节(CAN FD 是 64 字节),一条RequestDownload指令带上地址和长度信息轻松超过 8 字节。ISO 15765-2(ISO-TP)定义了把长报文拆成单帧、首帧、连续帧、流控帧的机制。一个 UDS 请求超过 7 字节(标准帧的 8 字节减去 1 字节协议控制字 PCI)时,必须走多帧发送。
多帧发送的 PCI 前缀规则如下:
- 单帧(SF):
0x0N,N 为数据长度(0-7) - 首帧(FF):
0x10 N,N 为 12 位总长度 - 连续帧(CF):
0x2N,N 为 4 位序列号,从 1 开始循环到 0xF - 流控帧(FC):
0x3N,N 为流控状态,0x00表示可继续发送
用 Python 实现最底层的单帧/多帧拆分逻辑时,核心是「在数据里插入 PCI 前缀并维护发送序列号」:
def build_multi_frame(payload: bytes, max_payload: int = 7) -> list[bytes]: if len(payload) <= max_payload: return [bytes([0x00 | len(payload)]) + payload] frames = [] total_len = len(payload) ff_data = payload[:max_payload - 1] frames.append(bytes([0x10 | ((total_len >> 8) & 0x0F), total_len & 0xFF]) + ff_data) idx = max_payload - 1 seq = 1 while idx < len(payload): chunk = payload[idx:idx + max_payload] frames.append(bytes([0x20 | (seq & 0x0F)]) + chunk) seq = (seq + 1) & 0x0F idx += max_payload return frames # 构造一个长 UDS 请求:请求下载 0x34 + 数据格式 + 地址长度 rq = build_uds_request(0x34, 0x00, bytes([0x44, 0x00, 0x00, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x01, 0xFF])) can_frames = build_multi_frame(rq) for i, f in enumerate(can_frames): print(f"frame {i}: {f.hex()}")拆帧逻辑的要点:首帧只占 6 字节数据,PCI 头 2 字节;连续帧每帧占 7 字节数据,PCI 头 1 字节;序列号从 1 开始,不能用 0,0x2F之后回到0x21。如果你的诊断工具连接的是 CAN FD,max_payload可以调到 62,但流控帧的语义在 CAN FD 下略有变化,实测前务必确认 ECU 支持哪种类型。
3.2 组装接收方向:把连续帧拼回完整响应
接收方向的逆过程同样需要状态机。收到的第一帧判断 PCI 高位:0开头是单帧直接取数据;1开头是首帧,从两个字节中提取总长度并为后续帧分配缓冲区;2开头是连续帧,按序列号顺序追加数据。
class IsoTpReceiver: def __init__(self): self.buffer = bytearray() self.expected_len = 0 self.next_seq = 1 def feed(self, can_data: bytes) -> bytes | None: pci = can_data[0] >> 4 if pci == 0: # 单帧 data_len = can_data[0] & 0x0F return can_data[1:1 + data_len] if pci == 1: # 首帧 self.expected_len = ((can_data[0] & 0x0F) << 8) | can_data[1] self.buffer = bytearray(can_data[2:]) self.next_seq = 1 return None if pci == 2: # 连续帧 seq = can_data[0] & 0x0F if seq != self.next_seq: raise ValueError(f"sequence error: got {seq}, expect {self.next_seq}") self.buffer.extend(can_data[1:]) self.next_seq = (self.next_seq + 1) & 0x0F if len(self.buffer) >= self.expected_len: return bytes(self.buffer[:self.expected_len]) return None注意接收端的长度截断:最后一帧可能填充了无关字节,所以要用expected_len截断而不是直接返回整个buffer。序列号校验必须做,很多诊断仪和 ECU 的兼容性问题都出在这里:ECU 的连续帧时序紧密时,测试工具可能因为处理延迟丢帧,最终表现为响应超时。
3.3 流控帧与发送节奏:Python 线程间怎么协调
多帧发送不是一次性把数据全丢进总线,而是要等 ECU 发流控帧(FC)授权。流控帧第三个字节叫 Separation Time(STmin),表示两帧连续帧之间的最小间隔,单位是毫秒;但要注意0xF1到0xF9的特殊含义表示 100us 到 900us。Python 实现里要把 STmin 解析出来并作用到发送线程的延时上,简单处理是time.sleep(stmin_ms / 1000),精度不够可以用busy sleep或绑定发送线程的定时器。
4. 用 udsoncan 库跑通 READ DATA BY IDENTIFIER 诊断流
4.1 依赖选型:udsoncan、isotp、python-can 的分工
自己逐字节实现传输层适合教学和理解,但生产工具建议直接使用成熟库,社区里常见的组合是udsoncan + isotp + python-can。python-can负责抽象 CAN 硬件接口,isotp实现 ISO-TP 的状态机和收发,udsoncan在上层做 UDS 服务的编码解码。这个三层结构和 C 语言诊断栈的分层一致:硬件驱动 → 传输协议 → 应用协议。
安装时的坑集中在python-can的后端上:Windows 用canalystii或pcan后端需要额外装驱动;Linux 下socketcan后端需要内核支持can模块,且注意vcan虚拟网卡调试时要用ip link add dev vcan0 type vcan创建。安装命令常规做法是:
pip install udsoncan isotp python-can如果是在 Linux 下纯本机验证,不需要真硬件:
sudo modprobe vcan sudo ip link add dev vcan0 type vcan sudo ip link set up vcan04.2 最小客户端代码:物理寻址读 VIN
下面的代码用 vcan0 虚拟接口跑通一次完整的 UDS 请求-响应。注意isotp层的地址参数:txid是 CAN 总线上测试仪发给 ECU 的 CAN ID,rxid是 ECU 回复的 CAN ID,这两个 ID 不同,因为 ISO-TP 是两个方向独立寻址。
import can import isotp import udsoncan from udsoncan.connections import PythonIsoTpConnection from udsoncan.client import Client import udsoncan.services as services # 1. 配置 CAN 总线 bus = can.interface.Bus(bustype='socketcan', channel='vcan0', bitrate=500000) # 2. 配置 ISO-TP 层 tp_params = isotp.params.Params( stmin=0x00, blocksize=8, wftmax=0, tx_padding=0x00, rx_padding=0x00 ) stack = isotp.CanStack(bus=bus, params=tp_params, txid=0x18DAF10E, rxid=0x18DA0EF1) # 3. 用 udsoncan 客户端包裹 ISO-TP conn = PythonIsoTpConnection(stack) with Client(conn, request_timeout=2, response_timeout=2, t3_server_address=0xF1) as client: # 先切到扩展会话,部分 ECU 只在非默认会话下允许读数据 client.change_session(uds_enum=services.DiagnosticSessionControl.Session.extendedDataLink) resp = client.read_data_by_identifier(0xF190) print("DID F190:", resp.data) # 4. 关闭总线 bus.shutdown()逐段说明:第三步里request_timeout是发送请求后的超时,response_timeout是 ISO-TP 层等待连续帧的超时,这两个值单位是秒,诊断实测中建议把request_timeout设到 2 秒以上,因为 ECU 在非默认会话下的响应可能慢;read_data_by_identifier接受 DID 作为参数,ECU 返回的数据在resp.data里,udsoncan已经按 ISO-14229 的格式解包了正响应,不需要手动剥离0x62前缀。
change_session切换会话这一步不是必须的,但 0xF190 这类厂商自定义 DID 经常要求扩展会话或安全访问才可读。如果直接读报ConditionNotCorrect或SecurityAccessDenied,第一件事就是检查当前会话模式。
4.3 响应解析与 NRC 异常处理
udsoncan的异常模型把 NRC 封装成了NegativeResponseException和更细的SecurityAccessDenied、ServiceNotSupported等子类。捕获异常后拿到的e.response.code就是 NRC 枚举值。相比手动比对字节流,这种异常模型的好处是排查问题时可读性高很多。
from udsoncan.exceptions import NegativeResponseException, TimeoutException try: resp = client.read_data_by_identifier(0xF190) except NegativeResponseException as e: print(f"NRC: {e.response.code.name} (0x{e.response.code.value:02X})") if e.response.code == udsoncan.Nrc.SecurityAccessDenied: print("先执行安全访问流程再读数据") except TimeoutException: print("no response, check physical addressing and ISO-TP config")这里有个容易误用的地方:收到 NRC 时udsoncan不会返回一个空的响应对象,而是直接抛异常。新手经常先写判断if resp is None,这拦不住异常;异常需要在except里处理,而且NegativeResponseException和TimeoutException是兄弟关系,先后顺序没有影响,但两个分支必须考虑。NRC 为0x31(RequestOutOfRange)时最常犯的错是 DID 拼错或当前会话不支持该 DID,而不是协议栈问题。
4.4 真实硬件与虚拟链路的差异
vcan0 的回环测试只能验证自己写的协议栈逻辑对不对,真实 ECU 有更多变量:CAN 收发器延迟、ECU 的 STmin 强制值、ISO-TP 流控帧的阻塞时间、总线仲裁引起的帧延迟。实测时有一个稳定的判断技巧:先发一个单帧 UDS 请求(比如0x3E 0x00)确认基础通信通,再切扩展会话,再读 DID,分层定位问题。
5. 安全访问种子密钥与 .zip 交付的 Python 工具链
5.1 27 服务的种子-密钥实现要点
安全访问(SecurityAccess)是刷写流程绕不开的环节。ISO-14229 只规定流程:测试仪发27 01请求种子,ECU 返回67 01加上 N 字节种子;测试仪按厂商算法生成密钥,发27 02加密钥;ECU 校验通过后进入解锁状态。标准不规定算法本身,所以 Python 实现的核心是写一个可替换的seed_key回调函数。
def seed_key(seed: bytes, level: int) -> bytes: # 常见算法:字节循环左移 3 位 + 固定异或掩码 key = bytearray(seed) mask = 0x5A for i in range(len(key)): rotated = ((key[i] << 3) | (key[i] >> 5)) & 0xFF key[i] = rotated ^ mask return bytes(key) def unlock_ecu(client, seed_rq: int = 0x01, key_rq: int = 0x02): resp = client.security_access( services.SecurityAccess.RequestSeed(seed_rq), services.SecurityAccess.SendKey(key_rq, seed_key) ) return resp在udsoncan中,security_access方法的SendKey参数直接接收一个可调用对象,库内部会先求种子,调用你的函数算出密钥,再发27 02。这个设计把算法隔离得很好,换车型只换函数体,不换调用逻辑。写算法时注意:种子和密钥的字节长度由 ECU 决定,常见是 4 字节,但有些安全芯片用 8 字节甚至 16 字节,超出算法预设长度时先补零再计算,否则密钥始终错误且会触发尝试次数锁定。
5.2 把整个工具做成 .zip 交付物
标题里的.zip的落实方式:Python 项目打包成 zip 有两种常见路径,一种是pip install可安装的 wheel,另一种是免安装的绿色压缩包。诊断工程师常在产线电脑上跑脚本,那类机器没有外网甚至没有完整 Python 环境,所以绿色 zip 往往更实用。打包时用zipapp或直接压缩源码加依赖目录均可,但要注意python-can的底层驱动 DLL 路径问题:Windows 后端(如 PCAN、CANalyst-II)的动态库必须以库能搜索到的相对路径存在,否则解压后必现DLL load failed。
一个稳妥做法是目录结构固定并在入口脚本里显式插入依赖路径:
import os, sys BASE = os.path.dirname(os.path.abspath(__file__)) for sub in ["libs", "libs/site-packages"]: p = os.path.join(BASE, sub) if p not in sys.path: sys.path.insert(0, p)这样把pip install -t libs -r requirements.txt安装下来的第三方库一同打进 zip,目标机解压后直接运行,不需要配置PYTHONPATH。zip 交付还有一个隐藏坑:压缩包解压后可能丢失文件权限位,Linux 下如果是可执行脚本要重新chmod +x;Windows 下则要留意长路径,udsoncan的模块路径过长时,建议解压到盘符根目录下的短目录。
5.3 验证与收尾技巧
交付一个诊断工具 zip 前,至少做三件事:用python -m py_compile检查所有.py文件的语法;在干净环境(无任何依赖)里解压并运行一次冒烟测试,确认路径注入逻辑有效;对 31 服务或 19 服务做一次完整的 DTC 读写回归,确认 NRC 异常分支没有被提前吞掉。会话状态机也是容易漏测的点:27解锁失败后立即重试会触发ExceededAttempts(0x36),必须先等 ECU 的延迟时间过期,或者做一次ECUReset恢复。生产环境里这一步最常见,也最容易被忽略。
最后留一个排查手法:用isotp的log_raw_frames或python-can的Notifier把原始 CAN 帧打出来,对比 ECU 实际返回的连续帧序列是否与预期一致。所有 UDS 工具链的疑难杂症,最终都能在这个帧日志里找到答案。
本文还有配套的精品资源,点击获取