做车载诊断开发的同事应该都有同感:一台样车上电之后,最先想去读的两个数据,一个是VIN号,另一个是跟日期相关的信息。VIN是这辆车的身份证,产线管控、售后追溯、远程诊断绑定都靠它;日期信息则往往藏在整车的制造日期、软件刷写日期里,用来判断版本是否老化、是否需要升级。最近我在整理一段诊断测试脚本,就从“VIN号及日期读取”这个最基础的功能说起,把代码、报文结构、踩过的坑一次说清楚。
我用的环境是Python加上python-can库,直连或者通过转接盒接CAN总线。代码本身不复杂,但里面涉及到UDS协议、ISO-TP多帧处理、DID定义和ASCII/BCD解析,一不注意就会读到乱七八糟的数据。这篇文章适合刚入门的诊断开发工程师,也适合测试人员想自己写个小工具加速日常验证。读完你至少能照着把VIN和生产日期从一个ECU里读出来,还能知道读到错误结果时该怎么排查。
1. 为什么读VIN和日期如此重要?——先理清业务场景
1.1 从产线到售后,VIN和日期都是第一手数据
我最早接触VIN读取是在产线下线检测工位。每一台整车下线的时候,扫码枪会扫风挡玻璃上的VIN条码,然后诊断仪也会通过OBD口去读车身控制器或者网关里存的VIN,两边比对一致才允许放行。这个动作看起来简单,但工厂节拍通常在60秒以内,诊断仪如果连着几次读超时,产线就停线了,压力全在开发这边。
日期信息的场景更隐蔽一些。比如某款ECU刷了新软件,诊断仪需要读一下软件刷新日期,确认刷写成功;又比如整车厂做售后数据统计时,需要读生产日期来判断某批零件是否属于召回范围。很多时候日期不是单独存的,而是和其他车辆数据一起放在出厂信息DID里,所以读DID时要注意数据段里既有VIN,又有日期、配置码、校验码,不能只盯着前几个字节。
从技术角度看,VIN和日期都有一个共同点:它们的读取都依赖UDS的ReadDataByIdentifier(0x22)服务。也就是说,只要把0x22服务搞透了,这两个数据就是顺手的事。但实际开发中,不少人卡在了“明明报文发对了,为什么回的是否定响应”或者“读出来VIN为什么后面带一串空格”这种细节上。后面我会一一展开。
1.2 诊断协议栈里的对应关系
在车用诊断里,我们通常按OSI模型的层级来说。CAN物理层是底层,上面是数据链路层,再往上是ISO-TP传输协议(ISO 15765-2),它负责把一条UDS消息拆成多帧CAN报文发送,或者把多帧拼回来。UDS本身是应用层协议(ISO 14229),定义了各种服务,比如0x22读数据、0x2E写数据、0x31例程控制等。
VIN号在常见协议里通常通过DID 0xF190来读,部分厂家会用0xF187或自定义DID。生产日期一般没有统一DID,常见的是0xF18C,但每家OEM可能不同。这就意味着代码里DID不能写死,要设计成可配置的参数。我习惯在脚本开头维护一个配置表,把不同车型的DID映射都放在一起,换车型时改一下字典就行。
再说一遍ISO-TP为什么要单拎出来。一个DID返回数据如果不超过6字节,比如日期“2024-05-15”这种ASCII字符串,可能只有10字节,超过了单帧最大6字节(CAN FD是64字节,但传统CAN是8字节),就必须拆成首帧+连续帧。VIN是17字节,铁定多帧。所以代码里处理多帧是绕不开的。
2. 代码的整体设计与协议基础:先看懂UDS的读写动作
2.1 ReadDataByIdentifier(0x22)的动作原理
0x22发出去之后,ECU如果支持,会返回正响应0x62,然后把请求的DID和对应数据一起带回来。如果ECU不支持或者禁止访问,就返回0x7F,后面跟上服务ID和否定响应码(NRC)。比如NRC 0x11表示服务不支持,0x12表示子功能不存在,0x13表示报文长度不对,0x22表示条件不满足,0x31表示请求数据越界,0x33是安全访问被拒绝。
读VIN这类安全等级不高的数据,一般不需要先做安全解锁,但有些厂商为了防止随意篡改,会把VIN的写服务锁住,读取本身不锁。所以如果你读VIN收到0x33,先别怀疑协议写错了,看看是不是你访问的DID恰好被归到受限访问区域了。
0x22请求的标准单帧结构是这样的:
- PCI字节:0x03(单帧,后续长度3)
- SID:0x22
- DID高字节
- DID低字节
也就是CAN数据域是03 22 F1 90,DLC为4。注意有些ECU要求DLC填满8,在一帧的最后4个字节补0xAA或者0xCC,否则不回。这个不是标准要求,但我在某些日系控制器上确实遇到过,如果你发的报文数据只有4个字节ECU不响应,试着补全DLC。
2.2 报文结构解析:PCI、SID、DID
PCI全称是Protocol Control Information,在传统CAN的ISO-TP单帧中,PCI的第一个字节高四位表示帧类型,低四位表示后续数据字节长度。0x03就是单帧,后面跟着3个字节。连续帧的PCI高四位是0x01?不对,连续帧高四位是0x2。
我再把帧类型整理清楚:
| 帧类型 | 高四位 | 低四位(或后续字节) | 说明 |
|---|---|---|---|
| 单帧(SF) | 0x0 | 数据长度(0~7,CAN FD可更大) | 单条CAN报文即完整PDU |
| 首帧(FF) | 0x1 | 后面两个字节表示总长度(高字节低4位+次字节) | 多帧第一帧,表示总数据长度 |
| 连续帧(CF) | 0x2 | 低四位是连续帧序号,从1递增 | 首帧之后的所有数据帧 |
| 流控帧(FC) | 0x3 | 后续字节包含流控状态、块大小、STmin | 接收方回复发送方,允许继续发送 |
所以你在总线上看到10 0B 62 F1 90 ...,这个0x10就代表首帧,总长度0x0B(11字节),后面跟着响应数据的开头。再看到21 ...、22 ...,这些就是连续帧。诊断仪作为接收方,收到首帧之后要回复一个流控帧30 00 00,告诉ECU可以继续发送,否则ECU会挂起。这是很多入门脚本容易忽略的一步,后面代码里我会实现。
2.3 代码框架设计思路
我设计这段代码时,刻意保持了最小闭环:初始化CAN总线、发送0x22请求、接收ISO-TP多帧、拼接数据、解析VIN和日期。模块分得越清楚,后面扩展到0x2E写VIN或者0x31例程就越容易。
具体的数据流是:
- 构建请求数据
[0x03, 0x22, did_hi, did_lo] - 通过CAN总线发送,CAN ID用诊断请求ID(比如0x7E0)
- 监听返回的CAN ID(比如0x7E8),同时保有一个接收状态机
- 如果是单帧,直接返回数据段
- 如果是首帧,保存总长度和初始数据,并发送流控帧,然后继续收连续帧
- 收齐之后,把数据按DID的数据格式解析
这段逻辑在不同工具里都有成熟实现,但自己写一遍能彻底理解协议交互时序。下面我贴核心代码,并逐段解释。
3. 实际代码:VIN号读取与日期读取的实现细节
3.1 物理寻址与CAN接口初始化
我用的是python-can库,它支持各种CAN接口。代码里我抽象出bus对象,实际跑的时候根据你的硬件选择对应接口即可。
import can import time # 诊断请求/响应CAN ID,不同车辆可能不同 DIAG_REQ_ID = 0x7E0 # 诊断仪 -> ECU DIAG_RESP_ID = 0x7E8 # ECU -> 诊断仪 def init_bus(channel='can0', bitrate=500000): bus = can.Bus(interface='socketcan', channel=channel, bitrate=bitrate) return bus这里要说明两点。第一,bitrate一般就是500k或250k,整车OBD口在标准模式下是500k,但某些总线是250k,必须先确认好,不然发几条错误帧后总线会bus-off。第二,response ID不一定就是0x7E8,有些ECU应答ID是0x7EA或者0x7E9,具体看EcuAddress。我建议代码启动时不要假设ID写死,而是通过一条TesterPresent(0x10服务?不对,是0x3E)去探测,或者通过带寻址的CANID配置来获得。
3.2 发送读取请求与接收ISO-TP帧
先实现一个发送并接收单条UDS数据的函数。为了可读性,我把多帧状态机也合并进去:
def uds_read_did(bus, req_id, resp_id, did, timeout=1.0): # 组包:单帧UDS读DID请求 req_data = [0x03, 0x22, (did >> 8) & 0xFF, did & 0xFF] req_msg = can.Message(arbitration_id=req_id, data=req_data, is_extended_id=False) bus.send(req_msg) # 第一帧接收 msg = bus.recv(timeout=timeout) if msg is None: raise TimeoutError("等待响应超时") if msg.arbitration_id != resp_id: # 过滤掉其他CAN报文,继续等 while msg.arbitration_id != resp_id: msg = bus.recv(timeout=timeout) if msg.data[0] == 0x7F: raise RuntimeError(f"ECU否定响应: NRC=0x{msg.data[2]:02X}") pci = msg.data[0] if (pci & 0xF0) == 0x00: # 单帧 length = pci & 0x0F return list(msg.data[1:1 + length]) elif (pci & 0xF0) == 0x10: # 首帧,msg.data[1]是总长度低字节,高字节在pci低四位 total_len = ((pci & 0x0F) << 8) | msg.data[1] payload = list(msg.data[2:]) # 首帧携带的数据前缀 # 发送流控帧,允许ECU继续发送 fc_msg = can.Message(arbitration_id=req_id, data=[0x03, 0x30, 0x00, 0x00], is_extended_id=False) bus.send(fc_msg) while len(payload) < total_len: cf = bus.recv(timeout=timeout) if cf is None: raise TimeoutError("等待连续帧超时") if cf.arbitration_id != resp_id: continue cf_pci = cf.data[0] if (cf_pci & 0xF0) == 0x20: # 连续帧,去掉PCI字节,追加数据 seq = cf_pci & 0x0F # 这里可以校验seq递增,但先简化 payload.extend(cf.data[1:]) return payload[:total_len] else: raise RuntimeError(f"未知PCI类型: 0x{pci:02X}")这段代码有几个处理是很有讲究的。
首帧总长度计算公式:total_len = ((pci & 0x0F) << 8) | msg.data[1]。因为首帧PCI的格式是1LLL LLLL?不对,标准是:首帧PCI第一个字节的高四位固定为0x1,低四位是总长度的最高4位,第二个字节是总长度的低8位。所以总长度是12位。我写的(pci & 0x0F)取的就是低四位。实测没问题。
流控帧的CAN ID用的是req_id而不是resp_id,这是因为流控帧是诊断仪发给ECU的,仲裁方向跟请求一致。如果你用响应ID去发,ECU根本认不出来。
连续帧接收时,我之所以没有严格检查序号,是为了代码简洁,但实际工作中序号错乱可能会导致拼错帧。稳健的写法是维护一个seq变量,每收一帧比较序号是否等于上一帧+1,如果乱序就报错或者丢弃。
3.3 VIN解析:DID F190与字符编码
有了上面的uds_read_did,读VIN就简单了:
def read_vin(bus, req_id=DIAG_REQ_ID, resp_id=DIAG_RESP_ID, did=0xF190): data = uds_read_did(bus, req_id, resp_id, did) if len(data) < 17: raise ValueError("VIN数据长度不足17字节") vin = bytes(data[:17]).decode('ascii') return vin.strip() # 去掉可能存在的填充空格 vin = read_vin(bus) print("VIN:", vin)这个函数我在很多项目里直接复用。值得注意的有两点:
一、VIN标准长度是17字节,但有些ECU返回的数据可能带2字节的DID回显,比如返回数据前两字节是F1 90,然后才是17字节VIN。是否带DID回显因ECU实现而异。如果是带DID回显,你需要先把前2字节去掉。我的做法是在解析函数里加一个开关,根据实测数据格式判断。你可以打印出data的hex裸数据,一眼就能分辨。
二、VIN字符全是ASCII码,正常从'A'到'Z'和数字'0'到'9',不包含I、O、Q这三个字母。如果你解析出来有非法字符,很可能数据位移或者波特率不对。另外有些ECU在VIN不足17位时用0x20(空格)填充,甚至用0x00填充,直接.strip()只去掉前导和尾部的空格,如果中间夹了非法字符还得进一步清洗。
对于日期读取,很多DID返回的是BCD码,比如4个字节:20 24 05 15代表2024年5月15日。这种解析要单独写:
def decode_bcd_date(data): # 假设data是4字节BCD或6字节ASCII包装后的日期 year = (data[0] >> 4) * 10 + (data[0] & 0x0F) * 10 # 实际要按BCD规则 # 更稳妥的写法: year = (data[0] >> 4) * 1000 + (data[0] & 0x0F) * 100 + (data[1] >> 4) * 10 + (data[1] & 0x0F) month = (data[2] >> 4) * 10 + (data[2] & 0x0F) day = (data[3] >> 4) * 10 + (data[3] & 0x0F) return year, month, day但日期DID的格式比VIN还乱。有的厂商返回ASCII字符串"20240515",有的返回BCD,有的返回Unix时间戳,还有的只返回年和月。所以最好先看一眼原始hex再选择解析方式。我在下面给一个实用读取函数,兼容常见ASCII和BCD:
def read_date(bus, req_id=DIAG_REQ_ID, resp_id=DIAG_RESP_ID, did=0xF18C): data = uds_read_did(bus, req_id, resp_id, did) # 打印原始数据方便调试 print("Raw date data:", data.hex()) # data是bytes # 解析逻辑根据实际情况调整 if len(data) == 4: # 尝试BCD: 如 20 24 05 15 year = (data[0] >> 4) * 1000 + (data[0] & 0x0F) * 100 + (data[1] >> 4) * 10 + (data[1] & 0x0F) month = (data[2] >> 4) * 10 + (data[2] & 0x0F) day = (data[3] >> 4) * 10 + (data[3] & 0x0F) return f"{year:04d}-{month:02d}-{day:02d}" elif len(data) >= 8: # 尝试ASCII字符串 text = bytes(data).decode('ascii').strip() return text else: raise ValueError("无法识别的日期格式")这里我特意先打印原始hex,因为诊断开发里“靠经验猜格式”是常态。我第一次拿到某ECU的日期DID,返回数据是31 36 30 39 32 30 36 31,打印出来才知道是ASCII字符串"16092061"。
3.4 日期读取:DID F18C与NRC响应判断
日期DID不一定叫F18C,不同车型不一样。在我的配置表里,通常有三列:
| 车型 | VIN DID | 生产日期DID | 日期格式 |
|---|---|---|---|
| A型 | 0xF190 | 0xF18C | BCD 4字节 |
| B型 | 0xF187 | 0xF18D | ASCII 8字节 |
| C型 | 0xF190 | 0xF191 | 压缩BCD 3字节 |
所以读日期前先要用一个DID_CACHE字典,不要把DID写死在业务代码里。
还有一个关键点:不是所有ECU都实现日期DID。如果收到的NRC是0x31(请求超出范围),说明这个DID在当前ECU上不存在;如果NRC是0x22(条件不满足),可能是需要先做某些子系统初始化。我在脚本里把这些NRC场景打出来供排查。
try: date_str = read_date(bus, did=0xF18C) print("日期:", date_str) except RuntimeError as e: if "NRC" in str(e): print("读取日期失败,检查DID定义或访问条件")实际开发时,日期读取失败不必太紧张,读不到日期并不影响VIN读出来。诊断仪主流程里应该把单项数据读取包在独立的try里,避免一个ECU没有日期DID导致整个读取中断。
4. 我在实际排查中踩过的坑(附问题速查表)
4.1 VIN数据不完整或全是0x20空格
有段时间我读VIN总是只读出来前6个字符,后面全是空格。一开始以为是DID不对,后来发现是ECU返回数据里VIN的结尾填充了0x20,我的代码用strip()把空格去掉了,但前面的部分正常。再后来发现另一个ECU的VIN是倒序存储的,需要做reverse。诊断数据就是这样,同一标准下千奇百怪,一定要先统一日志打印原始数据。
排查方法:在解析函数里先把data.hex()打出来,对照ASCII表手工分析前几个字符。如果看到规律性的空格,多半是填充;如果看到长度刚好17且字符全合法,才是正确的VIN。
4.2 日期DID返回否定响应
我遇到过最典型的是0x31。同一个DID,在某款车上能读,换一款车就不行。原因就是DID没有标准化。OEM完全可以自己定义日期挂载哪个DID上。解决方式就是查该车型的诊断规范,或者发一个0x22 + DID范围去探测?并没有枚举DID的手段,只能靠规范。
另外也有NRC 0x22,通常意味着ECU当前状态不允许读日期。比如车辆休眠状态下电源模式未唤醒,某些ECU会拒绝诊断访问。这时候先发一条0x10 02(进入编程会话?)或者0x10 01(默认会话),再读就可能成功。
4.3 VIN年份解码错位的坑
如果你需要从VIN第10位解码出车辆年份,比如"LSVAA41T79Axxxxxx"中第10位的"7"代表2007年还是2037年?这是个经典问题。VIN的年份字符是每30年循环一次,规则是:
- 1980年=A, 1981年=B, 1982年=C ... 1990年=K(跳过I、O、Q),1993年=P,1994年=R,1995年=S,1996年=T,1997年=V,1998年=W,1999年=X,2000年=Y,2001年=1,2002年=2,..., 2009年=9,2010年=A,2011年=B,以此类推。
所以同一个字符可能对应两个年份,相差30年。比如字符"A"可能是1980年、2010年、2040年。实际使用时需要结合车辆已知信息来选。如果你拿到的是2024年的车,VIN第10位是字符"P"?其实2024年对应K?我们按表算一下,并不需要死记。推荐写一个函数,传入字符和一个参考年份区间,返回最接近的年份:
def decode_vin_year(char, reference_year=2025): chars = "ABCDEFGHJKLMNPRSTVWXYZ123456789" if char not in chars: raise ValueError("非法年份字符") base_index = chars.index(char) # 每30年一轮,以1980年为起点,但这个符号表不是从A开始连续递增,而是跳过了I, O, Q year = 1980 + base_index # 调整到参考年份附近 while year < reference_year - 15: year += 30 while year > reference_year + 14: year -= 30 return year字符表顺序我是按照标准排列的,注意跳过了I、O、Q,字母R后面是S。实际应用时你可以先打印出来对照。这个函数并不复杂,但能避免在分析车辆信息时把年份算错。
4.4 总线干扰与超时问题
CAN总线一旦有干扰或者波特率不匹配,诊断请求可能石沉大海。我的超时设置一般是1秒,多帧接收也要给足时间,有些ECU响应慢,连续帧间隔可能大于100ms。若超时,先换一个USB-CAN设备再试,排除接口硬件问题。其次,检查总线上是否还有其他诊断仪在同时发包,多个测试工具抢占诊断ID会导致响应丢失。
另一类是DLC问题。我说的前面提到的DLC填满问题,还有些ECU会检查报文长度,你发单帧4字节,它却认为该请求必须8字节,所以不回响应。这个很难从协议标准里预判,只能通过抓包工具看正常诊断仪发出的报文格式。我一般是先用原厂诊断仪抓一个正常交互流程保存下来,再模拟它的DLC和数据填充。
下面整理成速查表:
| 现象 | 常见原因 | 处置 |
|---|---|---|
| 发送请求后一直超时 | CAN ID错误、波特率不对、ECU未上电、DLC不对 | 抓总线报文,比对正常诊断仪 |
| 收到0x7F 0x22 0x31 | DID不存在或非法 | 查诊断规范确认DID |
| 收到0x7F 0x22 0x33 | 需要安全访问 | 先做28/27服务解锁,或检查访问权限 |
| VIN多帧接收不完整 | 漏了流控帧 | 确认收到首帧后发送FC帧 |
| 连续帧拼错 | 没有校验seq | 加序号递增校验 |
| VIN日期乱码 | 使用了错误的DID或字节序 | 打印原始hex,确定格式 |
| 日期解析越界 | 月份/日期为0x00 | 可能该ECU日期字段未写,需置空处理 |
5. 代码复用与扩展方向
5.1 把读取函数封装成类
上面给出的函数适合直接复制测试,但真要放进工程里,最好封装成诊断客户端类,把总线生命周期、会话管理、DID配置都包进去。我曾经在一个项目里写了个极简版本:
class DiagTool: def __init__(self, channel, req_id, resp_id): self.bus = init_bus(channel) self.req_id = req_id self.resp_id = resp_id def read_did(self, did): return uds_read_did(self.bus, self.req_id, self.resp_id, did) def read_vin(self): return self.read_did(0xF190) def read_prod_date(self): return self.read_did(0xF18C)这样做的好处是业务脚本里不用关心CAN细节,换车型时只需要换DID字典。而且如果未来要支持CAN FD,只需要改ISO-TP层的数据长度和PCI逻辑,不用动上层。
5.2 从VIN年份验证生产日期的一致性
如果某个ECU没有日期DID,但我们又想知道生产日期,可以通过VIN解码年份做粗略校验。注意只是年份,没有月和日。比如VIN第10位为"T",根据表它是1996年或2026年,结合当前年份可判断是2026年。再用读到的生产日期年份去对比,能发现VIN和存储日期矛盾的问题。这在二手车检测或者车辆档案校对场景很有用。
我之前就遇到过一台车,VIN里年份是2022,但日期DID读出来是2019,后来确认是某个ECU被更换过,导致内部存储日期与整车VIN不符。所以这种跨DID一致性校验可以作为诊断工具的加分项。
5.3 其他DID的读取扩展
既然0x22服务是通用的,那么读VIN的代码稍加改动,就能读ECU零件号、软件版本号、硬件版本号、生产序列号。通常这些DID定义在诊断规范的“数据标识符表”中。你只要把DID和解析函数映射起来,做成配置驱动即可:
- 软件版本号:ASCII字符串,直接解码
- 零件号:混合数字+字母,可能需要去掉空格
- 序列号:数字字符串,可能带校验码
- 初始化状态:单个字节位标志,需要按位解析
我的常用做法是维护一个DID_HANDLERS字典,key是DID,value是解析函数。这样新增一个DID只需写一个函数,不需要改主流程。
另外,如果你想读取的是多ECU的VIN,比如网关、发动机、ESP都存了VIN,那么只需要改CAN ID和DID即可。启动时按顺序向不同ECU发请求,每个ECU的响应ID不同。这也体现了把请求/响应ID作为参数传参的好处。
最后再分享一个小技巧。写代码时,不用急着追求一次性成功,先在调试工具里手动发一条0x22读DID请求,观察自动生成的响应,确认数据格式再改代码。我自己就是先拿起诊断仪抓包,把返回的原始数据粘贴到Excel里逐字节分析,确认字节含义后,再回头写解析代码。这种方式能省掉很多猜来猜去的冤枉路。诊断开发没有什么玄学,一切问题都能从总线上找到答案。