用Python重写UDS诊断协议栈:从ISO-TP到安全访问
简介这是一份基于 Python 3 实现的 UDSISO-14229统一诊断服务协议源码包面向汽车电子、CAN 总线及车载诊断相关开发者。项目源自 GitHub 开源库 pylessard/python-udsoncan遵循 MIT 许可证提供了完整的 UDS 客户端封装、服务定义、异常处理与连接管理能力可配合 isotp 库用于 CAN 总线上的诊断通信开发与测试。资源包共 124 个文件约 202KB其中包含 90 个 Python 源码文件、11 个 reStructuredText 文档文件以及配置文件、测试脚本、Dockerfile 等辅助内容便于本地搭建环境、阅读源码和运行示例。已有 48 人学习浏览适合正在学习车载诊断协议或需要快速集成 UDS 功能的 Python 工程师。通过该资源读者可掌握 UDS 服务调用流程、常见异常处理方式并借助自带的测试用例与文档快速上手实际项目。1. UDSISO-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 请求由三个部分组成服务 IDSID、子功能或数据参数、数据体。0x22 0xF1 0x90这条三个字节的请求0x22是 ReadDataByIdentifier0xF1 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服务名子功能/参数典型用途0x10DiagnosticSessionControl0x01/0x02/0x03切换默认、编程、扩展会话0x11ECUReset0x01/0x02/0x03硬复位、键控下电再上电0x19ReadDTCInformation0x02/0x04/0x0A读故障码状态、快照0x22ReadDataByIdentifierDID读 VIN、软件版本号0x27SecurityAccess0x01/0x02/0x03种子请求/密钥发送0x2EWriteDataByIdentifierDID 数据写配置项0x31RoutineControl0x01/0x02/0x03启动/停止例程0x34RequestDownload地址长度刷写前置请求0x36TransferData块序列号数据刷写数据块0x37RequestTransferExit无刷写结束确认0x3ETesterPresent0x00保持会话激活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 帧塞进 CANCAN 帧数据域最多 8 字节CAN FD 是 64 字节一条RequestDownload指令带上地址和长度信息轻松超过 8 字节。ISO 15765-2ISO-TP定义了把长报文拆成单帧、首帧、连续帧、流控帧的机制。一个 UDS 请求超过 7 字节标准帧的 8 字节减去 1 字节协议控制字 PCI时必须走多帧发送。多帧发送的 PCI 前缀规则如下单帧SF0x0NN 为数据长度0-7首帧FF0x10 NN 为 12 位总长度连续帧CF0x2NN 为 4 位序列号从 1 开始循环到 0xF流控帧FC0x3NN 为流控状态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(fframe {i}: {f.hex()})拆帧逻辑的要点首帧只占 6 字节数据PCI 头 2 字节连续帧每帧占 7 字节数据PCI 头 1 字节序列号从 1 开始不能用 00x2F之后回到0x21。如果你的诊断工具连接的是 CAN FDmax_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(fsequence 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 TimeSTmin表示两帧连续帧之间的最小间隔单位是毫秒但要注意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 IDrxid是 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(bustypesocketcan, channelvcan0, bitrate500000) # 2. 配置 ISO-TP 层 tp_params isotp.params.Params( stmin0x00, blocksize8, wftmax0, tx_padding0x00, rx_padding0x00 ) stack isotp.CanStack(busbus, paramstp_params, txid0x18DAF10E, rxid0x18DA0EF1) # 3. 用 udsoncan 客户端包裹 ISO-TP conn PythonIsoTpConnection(stack) with Client(conn, request_timeout2, response_timeout2, t3_server_address0xF1) as client: # 先切到扩展会话部分 ECU 只在非默认会话下允许读数据 client.change_session(uds_enumservices.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(fNRC: {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 为0x31RequestOutOfRange时最常犯的错是 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 xWindows 下则要留意长路径udsoncan的模块路径过长时建议解压到盘符根目录下的短目录。5.3 验证与收尾技巧交付一个诊断工具 zip 前至少做三件事用python -m py_compile检查所有.py文件的语法在干净环境无任何依赖里解压并运行一次冒烟测试确认路径注入逻辑有效对 31 服务或 19 服务做一次完整的 DTC 读写回归确认 NRC 异常分支没有被提前吞掉。会话状态机也是容易漏测的点27解锁失败后立即重试会触发ExceededAttempts0x36必须先等 ECU 的延迟时间过期或者做一次ECUReset恢复。生产环境里这一步最常见也最容易被忽略。最后留一个排查手法用isotp的log_raw_frames或python-can的Notifier把原始 CAN 帧打出来对比 ECU 实际返回的连续帧序列是否与预期一致。所有 UDS 工具链的疑难杂症最终都能在这个帧日志里找到答案。本文还有配套的精品资源点击获取