1. 先搞清楚 UDS 诊断协议在车上到底管什么
很多刚接触车载电子的朋友,第一次看到 UDS 诊断协议这几个字,反应通常是“是不是就是用电脑读个故障码”。我当年也是这么想的,直到第一次独立接手一个诊断相关的联调任务,才发现这个理解只覆盖了它不到一成的能力。UDS 全称是统一诊断服务(Unified Diagnostic Services),本质上是一套跑在车载总线之上的应用层问答规则:上位机(诊断仪、产线工装、售后设备、甚至车内的某个域控制器)发出一个带服务标识符的请求,目标控制器解析后回一个响应,或者回一个明确的失败原因码。读故障码只是它众多服务里的一个,真正让它有点分量的,是刷写、安全访问、例程控制、IO 控制这些能直接影响控制器行为的动作。
我先说清楚它能干什么,好让你判断这篇内容是不是你需要的。UDS 诊断协议覆盖的典型场景包括:售后用诊断仪读取和清除故障码、产线上对下线控制器做功能自检和参数写入、研发阶段在台架上验证某个执行器动作、OTA 场景下把新的应用固件写进控制器、以及整车上电自检时域控制器对其他节点的状态巡检。适合读这篇的人大致有三类:一是刚转进车载软件或测试岗、需要在一两周内看懂诊断需求文档的工程师;二是做测试脚本、需要手写报文把某个服务跑通的自动化同事;三是做上位机工具、需要理解响应超时和分帧逻辑的开发。如果你是这三类里的一类,下面的内容基本可以照着用。
我打算按我自己的理解顺序来讲:先讲清楚它在协议栈里的位置和为什么要这么分层,再拆报文结构,然后把高频服务的参数逐个说明白,最后集中讲排错。中间涉及到标准编号的地方我会给出,但更多的是讲“为什么这么设计”和“实际填参数的时候要注意什么”,因为标准文档写得很全,缺的恰恰是那些文档不写的现场经验。
1.1 它解决的到底是什么问题
在没有统一诊断协议的年代,不同控制器厂商各搞一套私有诊断命令,一个诊断仪想读三个不同供应商的控制器,可能得装三套软件、备三根线、学三套命令格式。整车层面最直接的后果是产线和售后的工具链成本极高,而且一旦某个控制器的诊断逻辑写错,排查全靠人和人之间对文档。UDS 诊断协议的价值在于把“请求-响应”这一套交互抽象成标准服务,把数据的位置抽象成标识符,把权限控制抽象成安全访问流程,把大数据传输抽象成下载上传链路。控制器供应商只要按这套规则实现,工具方就能用同一套设备对接所有节点。
这个抽象带来的一个副作用是,标准只规定“必须有这个服务、参数格式长这样、错误要这么报”,但具体每个标识符对应什么数据、每个例程做什么事、安全访问的种子密钥算法是什么,全都不在标准里,而是写在各家的诊断规范和数据文件里。这是新手最容易踩的认知偏差:以为把 UDS 学透了就能上手干活。实际上 UDS 只是语法,真正干活还得看那本“词典”。我后面讲参数的时候会反复强调这一点。
1.2 协议栈里它站在哪一层
把 UDS 想象成寄快递会比较直观。你写好的请求内容是货物,UDS 诊断协议负责规定箱子外面怎么贴面单(服务标识符、子功能、参数长度),网络层负责在规定体积内怎么拆分包裹(分帧),数据链路层和物理层负责实际运输(CAN/CAN FD 报文、以太网包)。所以 UDS 本身不关心底下是 CAN 还是以太网,它只关心请求和响应能不能完整地送到对端。
实际项目里最常见的组合是 UDS 跑在 CAN 或 CAN FD 之上,底层传输由 15765-2 这套网络层规范来干;新一点的架构会跑在车载以太网上,用 DoIP 做传输。对写诊断脚本的人来说,理解这个分层有个直接好处:当你看到一条超过 8 字节的响应没有一次收全,问题大概率不在 UDS 层,而在网络层的分帧和流控环节,排查方向完全不同。我见过有同事盯着服务标识符查了两个小时,最后发现是流控帧的块大小参数配错了。
1.3 一个判断控制器是否“活着”的最小流程
真正上手一个新的控制器,我习惯先跑一条最小链路来确认基础通路,而不是上来就调复杂服务。顺序是:先用 0x3E 心跳服务确认物理连接和基本收发正常,再用 0x10 切到扩展会话,看控制器是否接受会话切换,然后用 0x22 读一个通用的标识符(比如零件号类信息),确认数据响应格式对不对,最后用 0x19 读一次故障码。这四步走完,基本能判断通信链路、会话管理、数据读取、故障管理四条主线是否通畅。
这个顺序不是随便排的。0x3E 放第一位,是因为它在默认会话下就支持,不依赖任何前置条件,响应也短,最容易判断是“没回”还是“回错了”。0x10 放第二位,因为大部分有实际意义的服务都要求非默认会话,先确认会话能切,后面的服务才有意义。0x22 放第三位,是因为读数据通常是只读操作,风险最低,同时能验证响应里的标识符回显和长度字段。0x19 放最后,因为故障码相关的状态位在不同控制器上差异很大,放在后面才不至于一开始就被格式问题绕进去。
2. 报文长什么样:从字节到会话的完整链路
理解 UDS 诊断协议的报文结构,我的建议是先记住三个固定模板,然后所有服务都往这三个模板里套。这样比背服务列表高效得多,因为格式骨架是统一的,差异只在参数部分。
请求模板是:服务标识符 + 子功能(部分服务有)+ 参数 + 可选的数据。正响应模板是:服务标识符加 0x40 + 子功能回显(部分服务有)+ 参数 + 数据。负响应模板是:固定 0x7F + 请求的服务标识符 + 一个字节的负响应码。记住这三个之后,你拿到任何一条陌生报文,先看首字节落在这三个模式的哪一个,就能立刻判断方向。
举个具体的:请求 0x10 0x03 表示切扩展会话;正响应 0x50 0x03 加一段会话参数记录;如果控制器拒绝,回的是 0x7F 0x10 0x22,其中 0x22 表示当前条件不满足。这里 0x10 加 0x40 等于 0x50,这个偏移是硬性规则,不会变。掌握这条规则能省掉大量翻文档的时间,看到以 0x5x、0x6x、0x7x 开头的报文,基本可以直接倒推是哪个请求的响应。
2.1 服务标识符的分类逻辑
标准里的服务大致可以按风险等级和理解难度分三段。低风险段是只读类:读数据标识符、读故障码、读内存、读例程结果、心跳。这类服务在合适的会话下调用,最坏结果也只是返回错误码。中风险段是会改变状态的:切会话、复位、安全访问、IO 控制、例程控制、通信控制、故障码设置开关。这类服务一旦执行成功,控制器行为可能立刻变化,比如某个继电器被强制驱动、某路通信被关闭。高风险段是刷写链路:请求下载、传输数据、退出传输。这类服务涉及存储器擦写,中断或参数错误的后果是不可逆的。
我按这个分段来讲实操,是因为排错策略完全不一样。低风险段出了问题,一般是格式或会话不匹配,改参数就行。中风险段出了问题,除了格式,还要考虑前置条件链有没有走完整,比如安全访问没开、例程没停。高风险段出了问题,除了前面两类,还要考虑电压、看门狗、存储器生命周期这些环境因素,往往不是报文能解决的。
2.2 长报文怎么拆:分帧机制的关键参数
经典 CAN 单帧有效载荷只有 7 个字节可用于诊断数据,而一条读数据的响应经常几十字节,一条刷写数据块更是几百上千字节。网络层规范定义了四种帧类型来搬运:单帧、首帧、连续帧、流控帧。首字节的高四位就是类型标记,0x0 是单帧且低四位表示数据长度,0x1 是首帧且低四位加第二个字节表示总长度,0x2 是连续帧且低四位是序号,0x3 是流控帧。
你需要掌握的参数有三个。总长度字段决定接收方知道要收多少字节、什么时候算收完。序号从 1 开始递增,通常到 15 之后回到 0 循环,接收方发现有跳号会直接丢掉整包。流控帧里的块大小和最小间隔时间决定发送方一次能连发多少帧、两帧之间至少要停多久。台架上经常出现的“发了几帧就断了”,八成是块大小设得太小或者接收方处理不过来触发了间隔超时。
我印象很深的一次,是某个控制器在刷写时反复在第三包中断。抓报文发现发送方的块大小设成了 8,而接收方每收到 8 帧才回一次流控,中间有一个几十毫秒的处理间隔,超过了设定的间隔时间。把块大小改成 4 之后问题消失。这类问题没有任何标准文档会告诉你答案,只能靠抓报文看帧的节奏。
2.3 三个必须记住的时间参数
UDS 诊断协议里有几个超时参数,我建议直接背下来,因为它们决定了你写的脚本要不要加重试、重试间隔设多少。默认会话下,控制器收到请求后必须在 P2Server(一般 50 毫秒)内给出响应,否则视为超时;如果它暂时处理不完,会先回一个负响应码 0x78,意思是“收到了,正在处理”,这时候计时器切换到 P2StarServer(一般 5000 毫秒),这期间可以连续发多次 0x78 来续命。另外,非默认会话下如果一段时间没收到任何请求,控制器会自动退回默认会话,这个时间叫 S3Server,一般 5000 毫秒。
这三个数值对写脚本的人影响非常直接。做长耗时操作(比如擦除存储器、跑一个自检例程)时,如果上位机不处理 0x78,就会在 50 毫秒后判定超时,然后把一次正常的操作当成失败。反过来,如果在非默认会话下长时间不发心跳,会话会被悄悄踢回默认,后续服务就全部返回“当前会话不支持”。我一般会把心跳周期设成 2000 毫秒左右,留足余量,既不会给总线增加太多负担,也不至于被会话超时坑到。
3. 高频服务逐个拆:参数怎么填、坑在哪里
这一节是把前面讲的模板落到具体服务上。我不打算把标准里的服务全部列一遍,那没有意义,实际项目里高频使用的就那么十来个,把它们吃透,剩下的遇到再查完全来得及。
我按调用顺序来讲,因为真实的诊断流程本身就是有顺序的:先建立会话,再拿权限,然后读或写,最后释放或复位。这个顺序也解释了为什么很多服务在默认会话下会直接返回“当前会话不支持”,因为默认会话是留给最基础读操作的,任何有副作用的能力都被刻意关在门外。理解了设计意图,你在写脚本时就不会把“会话切换”当成一个可选的步骤,而知道它是权限模型的第一道门。
3.1 会话切换与心跳:所有流程的地基
0x10 服务只有两个参数:子功能表示目标会话,第二个字节可选,表示是否抑制正响应。会话类型里最常用的是 0x01 默认、0x02 编程、0x03 扩展。默认会话下能用的服务很有限,主要是心跳、读数据标识符、读故障码。编程会话专门用于刷写,进入它之后很多常规服务会被禁用,目的是防止刷写过程中有别的请求干扰。扩展会话是日常调试的主力,大部分中风险服务都要求在这个会话下。
这里有个细节容易被忽略:切会话的响应里通常带着一段会话参数记录,包含 P2Server 和 P2StarServer 的实际取值。不同控制器给的数值可能和标准默认值不一样,有的会把 P2StarServer 设到 10 秒甚至更长。我建议脚本里不要写死超时,而是从这条响应里解析出来动态设置,这样换一个控制器就不用改代码。另外,从编程会话退回默认会话,通常不能直接跳,得先回默认再进其他会话,有些控制器对跳跃式切换会直接拒绝。
0x3E 心跳服务的参数是子功能 0x00 加一个可选的抑制正响应位。实际使用中我一般把抑制位打开,也就是发 0x3E 0x80,这样控制器不回应答,总线负载能降一半。但要注意,抑制响应不等于不处理,控制器照样会刷新会话计时器,只是不占用发送资源。有些工具默认不开抑制位,在长流程里心跳和业务报文混在一起会让抓包看起来非常乱。
3.2 安全访问的握手细节
0x27 是我认为最需要讲清楚的服务,因为它的失败原因最隐蔽。它的流程是两步:先请求种子(子功能通常是 0x01、0x03、0x05 这种奇数),控制器回一串随机数;上位机用约定的算法算出密钥,再用子功能加一(0x02、0x04、0x06 这种偶数)把密钥发回去,控制器验证通过才解锁。
坑主要在三处。第一处是种子长度不固定,有的控制器给 4 字节,有的给 16 字节,脚本里必须按响应实际长度取,不能硬编码。第二处是错误计数,连续发错密钥会触发锁定,锁定期间再请求种子会直接返回“超出尝试次数”或“延时未到期”,这时候只能等或者断电重启,等待时间有的控制器是 10 秒,有的是 1 分钟。第三处是解锁有时效,解锁后如果长时间不执行目标操作,或者中途切了会话,权限会被收回,得重新走一遍。
我的经验是,调试阶段一定要打开请求种子的日志,把每次拿到的种子和算出的密钥都打出来,和已知正确的样例比对。如果算法是对的但一直失败,先看字节序——有些控制器给种子是大端,算密钥前需要转成小端处理,这个细节在规范文档里经常用一句话带过,但错了就是全盘失败。
3.3 读写标识符:最常见的日常操作
0x22 读数据标识符的请求是两个字节的标识符,响应是 0x62 加标识符回显加数据。0x2E 写数据标识符请求是标识符加待写数据,响应只有 0x6E 加标识符回显,不带数据。这两个服务看起来简单,但实际用起来问题不少。
第一个问题是数据长度不匹配。写的时候如果发过去的字节数和控制器期望的不一致,常见结果是返回“请求超出范围”或“报文长度非法”。有些控制器比较严格,会校验长度必须完全相等;有些会做补齐。我的做法是先在规范里确认长度,然后在脚本里加一道断言,不匹配就直接报错,不要指望控制器帮你容错。
第二个问题是写操作的生效时机。有的标识符写进去立刻生效,有的要复位后才生效,有的写到非易失存储需要额外的提交动作。这一点规范里一般会写,但经常埋在很长的一段描述里。我踩过一次,写完某个标定参数后读回来是对的,但功能没变化,查了半天才发现需要发一次复位。所以写完一个标识符后,我习惯立刻回读一次确认写入成功,再根据是否有功能变化来判断是否需要复位。
第三个问题是并发。有些标识符属于同一组,写入时必须按顺序或者必须成组写,单独写其中一个会被拒绝。这类约束通常在诊断数据库文件的依赖描述里,光看服务格式是看不出来的。
3.4 故障码的读与清
0x19 读故障码有很多子功能,最常用的是按状态掩码读,子功能 0x02,参数是 3 个字节的状态掩码。响应是 0x59 加子功能回显加一个状态可用性掩码,后面跟若干条“故障码 + 状态字节”的记录。这里的关键是理解状态字节的位含义:测试失败、本循环测试失败、待定、已确认、上电后未完成测试等等,每一位代表故障在不同判定阶段的状态。读故障码时把状态位的含义搞清楚,比记住故障码列表更重要,因为同一个故障码在不同状态位下代表完全不同的处理优先级。
0x14 清故障码的请求是 3 个字节的故障码组,全 FF 表示清全部。清完之后不一定会立刻读不到,因为有些控制器需要在下一个循环才会真正清空记录,或者清除后如果故障条件仍然存在,故障码会马上重新被置位。所以我一般清完之后等一个短暂的间隔再读一次,如果还在,先别急着怀疑清除失败,去看故障条件本身是不是还成立。
3.5 刷写链路:四个服务串起一条流水线
刷写的完整链路比单个服务复杂得多,但拆开看就是四个服务的接力。0x34 请求下载负责告诉控制器“我要给你写一段数据,目标地址在哪、总长度多少、数据格式是什么”,控制器回一个响应告诉你“每次最多给我多少字节”。0x36 传输数据负责按块发送,每块带一个从 1 开始递增的块序号,控制器按序号校验连续性。0x37 请求退出传输表示数据发完了,让控制器做完整性校验。中间可能还会穿插 0x31 例程控制来触发擦除、校验或者最终激活。
参数里最容易出错的是块序号。它从 1 开始,发到 255 之后回 0,然后在擦除或重新请求下载时要重置。如果某一块丢了或者控制器回了个“块序号错误”,很多工具会自动重发——这是对的,重发时必须用同一个序号,不能用下一个。另外,下载响应里那个“每次最大块长度”是包含服务标识符和块序号在内的总长度,实际有效数据要减掉 2 字节,新手按它当纯数据长度算,很容易在后半段被拒。
还有一点是编程会话下的权限链:进编程会话、做安全访问、可能还要先做一次例程擦除、再请求下载。这个顺序不能乱,中间任何一步缺失,后面的请求都会返回“条件不满足”或者“请求顺序错误”。我建议把这条链路画成一张检查清单贴在工位上,每次刷写失败先对着清单从第一步往下逐条确认,比盲目抓包快得多。
4. 实战排错:负响应码与时序问题速查
排错这件事,我的原则是:先看负响应码,再看前置条件,最后才怀疑物理层。因为绝大多数失败在前两层就能定位,直接跳到抓波形是浪费时间。
负响应码是控制器给的明确答案,它值得你花时间背下来。我把常用的一组整理成表,配上我的定位思路。这张表不是让你全部记住,而是让你在看到码的时候能立刻缩小范围。比如看到 0x13,就知道服务和参数都没问题,纯粹是长度写错了;看到 0x33,就知道格式全对,只是缺权限;看到 0x7E,就知道这个服务本身支持,只是当前会话不支持,切会话就能解决。这种“先归因,再解决”的路径,比逐字节排查快一个数量级。
4.1 负响应码速查表与归因思路
| 负响应码 | 含义 | 常见触发原因 | 我的处理顺序 |
|---|---|---|---|
| 0x10 | 一般拒绝 | 控制器内部逻辑拒绝,无更具体原因 | 查规范里的额外约束,再抓内部日志 |
| 0x11 | 服务不支持 | 该服务未实现 | 确认软硬件版本是否支持该服务 |
| 0x12 | 子功能不支持 | 服务有,但该子功能未实现 | 换子功能,或确认选型是否正确 |
| 0x13 | 报文长度非法 | 参数字节数与期望不符 | 逐字节数长度,注意标识符是否两字节 |
| 0x22 | 条件不满足 | 电压、转速、会话、例程状态等前置条件未满足 | 逐条对照前置条件清单 |
| 0x24 | 请求顺序错误 | 跳过了必要的前置动作 | 检查安全访问和例程是否已执行 |
| 0x31 | 请求超出范围 | 标识符不存在或参数越界 | 核对诊断数据库里是否有该标识符 |
| 0x33 | 安全访问被拒 | 未解锁或解锁已过期 | 重新走安全访问,检查是否超时 |
| 0x35 | 密钥无效 | 密钥算法或字节序错误 | 用已知样例比对算法输出 |
| 0x36 | 尝试次数超限 | 连续错误密钥 | 等待锁定期结束或断电重启 |
| 0x72 | 编程失败 | 擦写异常、电压不足、存储损坏 | 检查电源与存储器状态 |
| 0x78 | 请求已接收,响应挂起 | 正常现象,长操作中反复出现 | 延长超时,继续等待 |
| 0x7E | 当前会话不支持该子功能 | 会话等级不够 | 切到扩展或编程会话 |
表格里我想特别强调 0x78。很多人看到它不是以 0x5x 或 0x6x 开头,第一反应是“失败了”,其实它是唯一一个“好消息式”的负响应,代表控制器活着并且在干活。正确做法是把超时改成 P2StarServer 的取值,然后继续等,而不是立刻重发请求。重发会导致前一次操作被打断,反而更容易失败。
4.2 时序类问题的三种典型表现
时序问题的麻烦在于报文看起来都是对的,但交互节奏不对。我遇到过的表现大致三种。
第一种是偶发超时。同样的脚本,跑十次成功八次,失败的那两次是在固定位置超时。这种通常是超时参数卡在临界值上,比如把超时设成了刚好等于 P2Server,而控制器偶尔会因为内部任务调度多用了几毫秒。解决办法是把超时留出至少一倍的余量,而不是精确对齐标准值。
第二种是长操作中途断连。表现为发送方等不到响应,判定失败,但控制器那边其实已经执行成功了。根因是没处理 0x78 的续命逻辑,或者续命了但总时长超过了脚本里设的最大等待。我一般会给长操作单独设一个更宽松的超时上限,比如 30 秒,同时在日志里记录每次收到 0x78 的时间戳,这样出错时能直接看出是卡在哪一段。
第三种是会话静默失效。表现是流程跑到一半,突然所有服务都返回“当前会话不支持”。看时间戳能发现中间有一段超过 S3Server 的空闲,要么是脚本在做耗时的本地计算,要么是在等一个外部信号。解决办法只有一个:把心跳做成后台定时任务,不依赖主流程调用。这个改动很小,但能消掉一大类莫名其妙的失败。
4.3 几个我踩过的坑
第一个坑是字节序。多字节参数(比如内存地址、数据长度)在报文里是大端排列,高位在前。我刚开始写脚本时习惯按本机的小端处理,结果地址传错,控制器返回“请求超出范围”,查了很久才发现是字节序问题。后来我养成了一个习惯:凡是多字节字段,先在纸上按大端写一遍,再对照报文核一遍。
第二个坑是标识符的十六进制和十进制混用。诊断规范里标识符一般用十六进制写,但有些表格为了排版写成十进制,抄参数时没注意就错了一位。这个错误极其隐蔽,因为格式完全正确,只是读到了另一个看似合理的数据。我的做法是脚本里所有标识符统一用带 0x 前缀的字面量,禁止裸数字,从源头杜绝。
第三个坑是抑制正响应位。有些服务的子功能最高位是抑制位,置 1 表示不要正响应。我在调一个例程控制时误把这个位置了 1,然后一直等响应等到超时,还以为控制器不支持这个服务。这个位在文档里通常只有一句话说明,但影响的是“有没有回包”,不是“做没做成”,非常容易误判。现在我写请求前会专门检查一遍子功能字节的最高位。
第四个坑是把诊断数据库当成绝对真理。数据库文件是人维护的,会滞后于软件版本。有一次按数据库里的标识符去读,一直返回“请求超出范围”,最后确认是控制器软件新增了标识符但数据库没同步更新。所以遇到“格式完全正确却报不存在”,除了怀疑自己,也要怀疑一下手上的文档版本。
4.4 工具选型的一点实际感受
工具这块我不太想推荐具体产品,说几个选型维度更实用。第一是能不能直接看到原始报文和时间戳,这个是最基础的能力,没有它排错基本靠猜。第二是能不能方便地导入诊断数据库文件,自动生成服务调用,这能省掉大量手写参数的工夫,但也容易让人对报文字节失去感觉,我的建议是前期尽量手写,熟练了再用自动生成。第三是脚本能力,能不能把一串服务编排成自动化序列并做断言,这决定了你做回归测试的效率。第四是对分帧和流控的可配置程度,刷写场景下这一项权重很高。
如果只是临时验证某个服务,用支持发送自定义报文的通用总线工具就够了。如果要跑完整刷写流程,就需要工具能配置块大小、间隔时间、块序号管理和重传策略。我见过有人用通用工具手工发几百帧完成刷写,能成,但那种方式只适合一次性验证,不可能用于日常回归。
最后分享一个我自己一直在用的习惯:每接触一个新的控制器,先建一份“最小可用链路”的记录文件,把前面说的那四步(心跳、切会话、读一个标识符、读故障码)的实际请求和响应报文原样存下来,标注清楚时间戳和会话状态。后面任何复杂流程出问题,我都拿这份基线来对比,能很快判断是链路退化了还是新流程本身有问题。这份记录我一般还会附上当时的软件版本和总线配置,因为换版本之后行为变化是常有的事,有版本信息才能追溯。这个方法听起来笨,但它帮我在好几个项目里把排查时间从半天压缩到十几分钟,比任何技巧都实在。