☰
汽车UDS诊断协议学习笔记PDF版:从协议分层到Python实战
2026/10/15 10:19:47 网站建设 项目流程

简介:这份PDF版学习笔记面向汽车电子、嵌入式测试及诊断开发方向的初学者与工程师,系统梳理ISO 14229标准下的UDS统一诊断服务协议,帮助读者建立从服务分类到请求响应机制的整体认知。内容按功能单元组织,涵盖诊断与通信管理(0x10会话控制、0x11复位、0x27安全访问、0x3E保持连接等)、数据传输(0x22、0x2E、0x23、0x3D等)、已存储数据传输(0x14、0x19)、输入输出控制(0x2F)、例程控制(0x31)以及上传下载(0x34至0x37)等常用服务,并说明“问-答”模式下请求与响应的对应关系。资源包为1个PDF文件,大小约10.49MB,便于离线阅读与查阅。目前已有1911人学习下载,适合作为诊断协议入门速查与项目开发参考,帮助读者快速定位服务功能、理解会话切换与安全访问流程,为后续ECU诊断开发与测试打下基础。

1. 汽车UDS诊断协议学习笔记PDF版:从“看不懂”到“能上手”的一条路

很多人第一次接触汽车UDS诊断协议,都是被一份PDF学习笔记带进门的。但翻了几页就发现,ISO 14229、0x10、0x27、0x22这些词像天书一样堆在一起,看的时候好像懂了,真拿CANoe或者诊断仪去连车,又完全不知道从哪下手。这份“汽车UDS诊断协议学习笔记PDF版”真正要解决的,不是让你背下所有服务号,而是帮你建立一条从协议分层、服务分类到实际报文收发的完整链路。它适合三类人:刚入行的汽车电子测试工程师、想从应用层往底层走的嵌入式开发者,以及需要对接诊断需求的售后诊断工具开发者。如果你手里已经有一份笔记但读不下去,问题往往不在笔记本身,而在于你缺一个“边看边发报文验证”的抓手。接下来我按自己带新人的顺序,把这份笔记里最该吃透的部分拆开讲。

2. UDS协议栈到底分了几层:别把应用层和传输层搅在一起

2.1 从ISO 14229到ISO 15765:每一层各管什么

UDS全称Unified Diagnostic Services,统一诊断服务,核心标准是ISO 14229。但你在PDF笔记里看到的“UDS协议”其实只是应用层,它下面还压着好几层。最常见的车载场景是UDS over CAN,也就是ISO 14229应用层跑在ISO 15765-2传输层之上,再往下是CAN控制器和收发器。很多新手翻车就翻在这里:看到0x7F否定响应,第一反应是服务不支持,实际上可能是传输层的流控帧没配好,多帧传输根本没完成。

我一般会拿一张表把分层关系钉死,笔记里如果没画,自己补一张:

层级标准负责内容典型参数
应用层ISO 14229-1服务ID、子功能、否定响应码0x10、0x27、0x22
传输层ISO 15765-2单帧/首帧/连续帧/流控帧BS、STmin、FF_DL
数据链路层ISO 11898-1CAN帧格式、仲裁、CRC标准帧/扩展帧、波特率
物理层ISO 11898-2差分电平、终端电阻120Ω、CAN_H/CAN_L

这张表的价值在于:当你抓到一个报文,能立刻判断问题出在哪一层。比如收到03 7F 22 31,03是单帧长度,7F是否定响应,22是服务ID,31是requestOutOfRange。这说明应用层收到了请求,但参数越界,跟传输层无关。如果连7F都收不到,那就要先查物理层接线和波特率。

2.2 寻址方式:物理寻址和功能寻址别用混

UDS诊断有两种寻址方式,物理寻址是点对点,功能寻址是一对多。PDF笔记里通常会写“功能寻址用于广播”,但不会告诉你实际用的时候坑在哪。功能寻址的CAN ID通常是0x7DF,所有ECU都会监听,但只有支持该服务的ECU才会响应。问题在于:多个ECU同时响应时,总线上的报文会撞在一起,你抓到的响应可能是不完整的。

我一般建议新手先用物理寻址调通单个ECU,确认服务能正常收发,再切功能寻址做批量扫描。物理寻址的请求ID和响应ID是成对的,常见分配是0x7E0/0x7E8、0x7E1/0x7E9,每对对应一个ECU。如果你用0x7DF发0x10 03,收到多个0x7E8、0x7E9的响应,别慌,这是正常的,但你的诊断工具要能处理多响应。

# 用python-can发一条物理寻址的UDS会话切换请求 import can bus = can.interface.Bus(channel='can0', bustype='socketcan', bitrate=500000) # 0x7E0是请求ID,数据长度8字节,02表示单帧且有效数据2字节 msg = can.Message(arbitration_id=0x7E0, data=[0x02, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00], is_extended_id=False) bus.send(msg) # 收响应,0x7E8是响应ID response = bus.recv(timeout=1.0) if response: print(f"ID: {hex(response.arbitration_id)}, Data: {response.data.hex()}")

这段代码里,0x02是ISO-TP单帧的长度标识,表示后面有2个有效字节0x10 0x03。0x10是DiagnosticSessionControl服务,0x03是扩展会话子功能。如果你把0x02写成0x03,传输层会以为有3个有效字节,多出来的0x00会被当成参数,ECU可能返回0x7F 0x10 0x13(incorrectMessageLengthOrInvalidFormat)。这个细节在PDF笔记里往往一笔带过,但实际调试时能卡你半天。

2.3 会话与安全访问:0x10和0x27的配合逻辑

UDS里很多服务不是随时能用的,必须先切到扩展会话,再通过安全访问解锁。典型流程是:默认会话下发0x10 0x03切扩展会话,然后发0x27 0x01请求种子,ECU返回种子,你根据种子算出密钥,再发0x27 0x02送密钥,解锁成功后才能发0x2E写数据或0x31例程控制。

PDF笔记里通常会写“0x27用于安全访问”,但不会告诉你种子和密钥的算法是OEM自定义的,标准里不规定。这意味着你拿到的笔记如果没有对应OEM的算法说明,0x27这一关是过不去的。我一般会建议:先确认你手头的ECU用的是哪套算法,常见的有固定密钥、简单异或、AES加密几种。如果是固定密钥,笔记里可能会直接写;如果是AES,你需要拿到密钥和IV。

# 安全访问的伪代码流程,算法部分用占位函数表示 def request_seed(bus, request_id, response_id): # 发送0x27 0x01请求种子 msg = can.Message(arbitration_id=request_id, data=[0x02, 0x27, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]) bus.send(msg) resp = bus.recv(timeout=1.0) # 响应格式:0x06 0x67 0x01 [seed1 seed2 seed3 seed4] if resp and resp.data[1] == 0x67: seed = resp.data[3:7] return seed return None def send_key(bus, request_id, key): # 发送0x27 0x02送密钥 data = [0x06, 0x27, 0x02] + list(key) + [0x00] msg = can.Message(arbitration_id=request_id, data=data) bus.send(msg) resp = bus.recv(timeout=1.0) # 成功响应:0x02 0x67 0x02 return resp and resp.data[1] == 0x67 and resp.data[2] == 0x02

这里的关键参数是0x06,表示单帧有6个有效字节。0x67是0x27的肯定响应ID,规则是服务ID加0x40。种子长度通常是4字节,但有些OEM用8字节,所以你的代码不能写死,要从响应长度里动态取。如果送密钥后收到0x7F 0x27 0x35,说明密钥错误,invalidKey。这时候别急着怀疑算法,先确认种子是不是每次请求都变,有些ECU的种子是固定值,有些是随机数,随机种子必须用同一次请求的种子去算密钥,不能跨请求复用。

3. 用Python把UDS诊断跑通:从发单帧到处理多帧响应

3.1 环境准备:CAN接口和python-can的选型

要复现UDS诊断,你需要一个CAN接口。常见的有PCAN、Kvaser、Vector VN系列,以及便宜的USB-CAN分析仪。如果你只是学习,用socketcan加一个USB-CAN适配器就够了,Linux下直接ip link set can0 up type can bitrate 500000就能起。Windows下可以用PCAN的python-can后端,或者用CANable之类的开源硬件。

python-can的安装很简单:pip install python-can。但要注意,不同接口的bustype参数不一样。socketcan用socketcan,PCAN用pcan,Vector用vector。如果你不确定,先用can.interfaces.BACKENDS列一下支持的接口。

# Linux下启用socketcan,波特率500k sudo ip link set can0 up type can bitrate 500000 # 确认接口状态 ip -details link show can0

这段命令里,bitrate 500000是CAN总线的波特率,必须和ECU一致。如果你设成250k,ECU是500k,你会看到大量错误帧,或者根本收不到响应。ip -details link show can0会显示bitrate 500000和state ERROR-ACTIVE,如果state是BUS-OFF,说明总线有问题,先查终端电阻和接线。

3.2 单帧请求与响应:0x22读数据的完整报文解析

0x22是ReadDataByIdentifier,按DID读数据。请求格式是0x22加两个字节的DID,比如0x22 F1 90读VIN。响应格式是0x62加DID加数据。如果数据超过6字节,就会走多帧传输。

import can import time bus = can.interface.Bus(channel='can0', bustype='socketcan', bitrate=500000) def read_did(bus, request_id, response_id, did_high, did_low): # 构造单帧请求:长度3,服务0x22,DID高低字节 msg = can.Message(arbitration_id=request_id, data=[0x03, 0x22, did_high, did_low, 0x00, 0x00, 0x00, 0x00], is_extended_id=False) bus.send(msg) # 收响应,可能有多帧 responses = [] start = time.time() while time.time() - start < 2.0: resp = bus.recv(timeout=0.5) if resp and resp.arbitration_id == response_id: responses.append(resp) # 如果是单帧,直接结束 if resp.data[0] >> 4 == 0x0: break # 如果是连续帧的最后一帧,根据实际情况判断 return responses # 读VIN,DID 0xF190 resps = read_did(bus, 0x7E0, 0x7E8, 0xF1, 0x90) for r in resps: print(r.data.hex())

这段代码里,0x03是单帧长度,表示后面3个有效字节0x22 0xF1 0x90。响应如果是单帧,第一个字节的高4位是0,低4位是长度。如果是多帧,第一个字节的高4位是1(首帧),低4位和高8位组成总长度。首帧之后ECU会等你发流控帧,再发连续帧。python-can不会自动处理ISO-TP,所以你需要自己实现流控帧的发送和连续帧的拼接。这也是为什么很多新手用python-can直接发UDS会卡住——单帧能通,多帧就断。

3.3 多帧传输:流控帧和连续帧的拼接逻辑

ISO 15765-2的多帧传输是这样的:发送方先发首帧(FF),携带总长度和前6字节数据;接收方收到后发流控帧(FC),告诉发送方可以继续发,以及发送节奏;发送方再发连续帧(CF),每帧携带7字节数据,直到发完。

def send_flow_control(bus, request_id, block_size=0, st_min=0x00): # 流控帧:0x30表示继续发送,BS和STmin msg = can.Message(arbitration_id=request_id, data=[0x30, block_size, st_min, 0x00, 0x00, 0x00, 0x00, 0x00], is_extended_id=False) bus.send(msg) def receive_multi_frame(bus, response_id, total_length): # 收首帧后发流控,再收连续帧 data = bytearray() first = bus.recv(timeout=1.0) if not first: return None # 首帧:0x1X XX,低4位和高8位是长度 ff_length = ((first.data[0] & 0x0F) << 8) | first.data[1] data.extend(first.data[2:8]) # 发流控帧,请求继续发送 send_flow_control(bus, 0x7E0) while len(data) < ff_length: cf = bus.recv(timeout=1.0) if not cf: break # 连续帧:0x2X,低4位是序号 data.extend(cf.data[1:8]) return data[:ff_length]

这里的关键参数是block_size和st_min。block_size=0表示接收方不限制发送方连续发多少帧,st_min=0x00表示连续帧之间不需要额外延时。但有些ECU要求st_min至少是0x0A(10ms),如果你发0x00,ECU可能不认。我一般会先试0x00,如果收不到连续帧,再改成0x0A或0x14。另外,流控帧的ID是请求ID,不是响应ID,因为你是接收方,要往发送方的方向发流控。这一点在PDF笔记里经常写反,导致新手发错ID,ECU一直等流控,最后超时。

4. UDS诊断学习笔记里最容易踩的五个坑

4.1 现象:发0x10 0x03没响应,但0x3E 0x00能通

原因:0x3E是TesterPresent,默认会话下就能用,不需要切会话。0x10 0x03需要ECU支持扩展会话,如果ECU在默认会话下对0x10做了限制,或者你的请求ID不对,就不会有响应。更隐蔽的原因是:有些ECU要求先发0x3E 0x00保持连接,再发0x10 0x03,否则会话切换会被拒绝。

解决:先用0x3E 0x00确认物理层和传输层通了,再发0x10 0x03。如果还是没响应,检查请求ID是不是该ECU的物理寻址ID,别用功能寻址ID去发需要响应的服务。

4.2 现象:0x27安全访问返回0x7F 0x27 0x24

原因:0x24是requestSequenceError,表示请求序列错误。常见于你还没切扩展会话就直接发0x27,或者你发0x27 0x02送密钥时,种子已经过期了。有些ECU的种子有效期只有几秒,你算密钥算太久,种子就失效了。

解决:确保先切扩展会话,再请求种子,种子拿到后立刻算密钥并发送。如果算法复杂,先把算法在本地跑通,再上总线。

4.3 现象:多帧响应只收到首帧,后面没了

原因:你没发流控帧,或者流控帧的ID发错了。ECU发完首帧后会等你的流控帧,超时时间通常是1秒左右。如果你没发,或者发到了响应ID上,ECU收不到,就不会发连续帧。

解决:确认流控帧发到请求ID,格式是0x30 BS STmin。如果ECU要求特定的BS和STmin,先试0x30 0x00 0x00,不行再调。

4.4 现象:0x22读DID返回0x7F 0x22 0x31

原因:0x31是requestOutOfRange,表示DID不存在或当前会话下不可读。有些DID只在扩展会话下可读,默认会话下返回0x31。有些DID需要安全访问解锁后才能读。

解决:先切扩展会话,再读DID。如果还是0x31,确认DID是否写错,或者该DID是否需要安全访问。

4.5 现象:用功能寻址发0x10 0x03,收到多个响应但数据不完整

原因:功能寻址是一对多,多个ECU同时响应,总线仲裁导致部分响应丢失或拼接错误。你的诊断工具如果没有处理多响应,就会把不同ECU的响应拼在一起,解析出乱码。

解决:功能寻址只用于不需要精确响应的场景,比如广播唤醒。需要读数据或写数据时,一律用物理寻址。如果必须用功能寻址,确保你的工具能按响应ID区分不同ECU。

5. 把学习笔记变成可复用的诊断脚本:一个进阶习惯

PDF笔记最大的问题是静态的,你看的时候觉得懂了,实际用的时候还是得翻。我自己的习惯是:每学一个服务,就写一个对应的Python函数,参数化请求ID、响应ID、DID、数据,然后拿一个真实的ECU或者模拟器去验证。验证通过后,这个函数就进了我的诊断脚本库,下次遇到同类ECU,改几个参数就能用。

比如0x2E写数据,你可以封装成write_did(bus, req_id, resp_id, did, data),内部处理会话切换、安全访问、多帧发送。0x31例程控制,封装成routine_control(bus, req_id, resp_id, routine_id, sub_function)。这样你积累的不是笔记,而是一套可执行的诊断工具。

验证方法也很直接:拿两个不同OEM的ECU,用同一套脚本去跑,看哪些服务能通,哪些返回否定响应。能通的服务,说明你的实现符合标准;不通的,去查该OEM的诊断规范,看是不是加了额外限制。这个过程比反复读PDF笔记有效得多。

# 一个可复用的UDS客户端骨架 class UdsClient: def __init__(self, bus, req_id, resp_id): self.bus = bus self.req_id = req_id self.resp_id = resp_id self.session = 0x01 # 默认会话 def send_request(self, service, sub_function=None, data=None): payload = [service] if sub_function is not None: payload.append(sub_function) if data: payload.extend(data) # 根据长度决定单帧还是多帧 if len(payload) <= 7: frame = [len(payload)] + payload + [0x00] * (7 - len(payload)) msg = can.Message(arbitration_id=self.req_id, data=frame) self.bus.send(msg) else: # 多帧发送逻辑,此处省略流控等待 pass return self.wait_response() def wait_response(self): resp = self.bus.recv(timeout=1.0) if resp and resp.arbitration_id == self.resp_id: return resp.data return None def switch_session(self, session_type): resp = self.send_request(0x10, session_type) if resp and resp[1] == 0x50: self.session = session_type return True return False

这个骨架里,send_request根据payload长度自动决定单帧还是多帧,switch_session封装了0x10服务并更新内部会话状态。你可以在此基础上加安全访问、读DID、写DID、例程控制。关键是:每加一个服务,就用真实ECU验证一次,别攒着一起调。我吃过亏,攒了五个服务一起调,结果一个0x27的种子算法写错,后面四个全挂,排查了一下午。后来改成每加一个就验一个,问题定位快得多。

最后说一个习惯:把每次调试的报文存成日志,格式是时间戳 ID 数据。出问题的时候,把日志和PDF笔记里的服务定义对照,很快就能看出是服务ID错了、子功能不支持、还是长度不对。这个习惯帮我省了很多后悔药。希望帮到你。

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

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

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

立即咨询