用Python重写UDS诊断协议栈:从ISO-TP到安全访问
2026/9/16 1:12:21 网站建设 项目流程

简介:这是一份基于 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服务名子功能/参数典型用途
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 帧塞进 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),表示两帧连续帧之间的最小间隔,单位是毫秒;但要注意0xF10xF9的特殊含义表示 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-canpython-can负责抽象 CAN 硬件接口,isotp实现 ISO-TP 的状态机和收发,udsoncan在上层做 UDS 服务的编码解码。这个三层结构和 C 语言诊断栈的分层一致:硬件驱动 → 传输协议 → 应用协议。

安装时的坑集中在python-can的后端上:Windows 用canalystiipcan后端需要额外装驱动;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 vcan0

4.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 经常要求扩展会话或安全访问才可读。如果直接读报ConditionNotCorrectSecurityAccessDenied,第一件事就是检查当前会话模式。

4.3 响应解析与 NRC 异常处理

udsoncan的异常模型把 NRC 封装成了NegativeResponseException和更细的SecurityAccessDeniedServiceNotSupported等子类。捕获异常后拿到的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里处理,而且NegativeResponseExceptionTimeoutException是兄弟关系,先后顺序没有影响,但两个分支必须考虑。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恢复。生产环境里这一步最常见,也最容易被忽略。

最后留一个排查手法:用isotplog_raw_framespython-canNotifier把原始 CAN 帧打出来,对比 ECU 实际返回的连续帧序列是否与预期一致。所有 UDS 工具链的疑难杂症,最终都能在这个帧日志里找到答案。

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

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

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

立即咨询