第一次真正啃UDS 诊断协议,是因为台架上的一台控制器死活进不了扩展会话。诊断工具发出去10 03,回来的永远是7F 10 22。手边只有一份两百多页的规范文档和一堆抓包数据,没人告诉我0x22这个否定响应码到底在抱怨什么。折腾了整整两天,最后发现是整车电源状态没满足前置条件,跟协议本身没有半点关系。这件事让我记住了两件事:UDS 不是背一张服务列表,而是理解一套对话规则;以及,绝大多数诊断问题最后都落在“条件不满足”这四个字上。
UDS(Unified Diagnostic Services,统一诊断服务)是一套跑在车载网络上的应用层协议,由 14229 系列标准定义。它规定了诊断设备和控制器之间“说什么、怎么说、说错了怎么回”。往小了说,它支撑着产线检测、售后读故障码、软件刷写;往大了说,整车下线的每一台车、每一次 OTA 升级、每一次 4S 店插上诊断头,背后都是这套协议在跑。这篇内容适合三类人看:刚接触车载诊断、被服务列表绕晕的新人;需要自己写诊断脚本或测试用例的工程师;以及想搞清楚“为什么诊断仪发一条命令就报错”的实操派。我会从设计逻辑讲到报文结构,再讲到刷写流程和时序参数,中间穿插大量实操细节和踩坑记录。
1. 为什么车上需要一套统一的诊断语言
1.1 从各说各话到一套公共语法
早期的车载诊断是典型的“各家自扫门前雪”。每个控制器供应商都有一套自己的命令集,诊断设备要适配不同车型,就得把几十套私有方言全背下来。那个年代做诊断测试,最痛苦的不是分析问题,而是先搞清楚手上这台控制器到底认哪套指令。后来行业逐步收敛,从 K 线时代的早期标准,一路演进到基于 CAN 的 KWP2000,再到今天的 UDS,核心诉求始终只有一个:用一套公共语法,让不同厂商、不同车型、不同控制器都能被同一种方式访问。
这个收敛带来的直接好处是工具链的复用。一台诊断设备写好了 UDS 的解析层,换一个车型只要换数据库文件(诊断描述文件),底层通信逻辑几乎不用动。对测试工程师来说更实际:你写的一套自动化脚本,稍作适配就能跑在另一个项目上。我在几个不同项目之间来回切换的时候,最直观的感受就是——UDS 让“迁移成本”从“重写一遍”降到了“改几个 DID 和 RID”。
但这里有个常见误解需要先破掉:UDS 只规定应用层。它管的是“这条命令叫什么、参数怎么排、响应长什么样”,完全不管“这些字节怎么在总线上传输”。这个分工是刻意设计的,也是理解后面所有内容的前提。
1.2 UDS 不负责怎么发,只负责说什么
把通信协议想成寄快递就很好理解。UDS 是快递单上填的内容规范——收件人怎么写、物品名称怎么填、保价金额填在哪一栏。而 CAN 总线、CAN FD、车载以太网这些,是实际承运的物流公司,负责把包裹从 A 运到 B。14229 系列标准把这个分层切得很清楚:应用层定义服务,下面还有一层专门负责把长报文拆包、组包、加流控,通常由 15765-2 这套传输协议承担。
为什么要单独拆一层?因为 UDS 的报文经常超过一帧能装下的长度。经典 CAN 一帧只有 8 字节有效数据,一条带 17 字节 VIN 的读取响应根本塞不进去。传输层就负责把它切成首帧、连续帧,接收方再拼回来。实际抓包时你看到的10 14 62 F1 90 ...里,开头的10是首帧的控制字节,不是 UDS 的服务号;真正的服务号62在后面。新人最容易在这里翻车,把传输层的字节当成服务号去查表,怎么查都对不上。
提示:抓包分析时先分辨这一帧是单帧、首帧还是连续帧,再去看 UDS 载荷。单帧的第一个字节低四位是长度,首帧的前两个字节是长度信息,连续帧只有一个序号字节。
这套分层还有一个好处:同一套 UDS 服务可以跑在不同总线上。基于 CAN 的实现、基于 CAN FD 的实现、基于车载以太网的实现,应用层完全一致,只是下面的传输层换了。这意味着你的诊断逻辑代码可以做到与物理层解耦,换总线不用重写业务。
1.3 服务地图:按用途把几十个服务分成六组
规范里的服务号从0x10一路排到0x87,直接硬背必然崩溃。按用途分组之后会清晰很多,我自己习惯分成六类:
| 分组 | 典型服务 | 主要用途 |
|---|---|---|
| 诊断与通信管理 | 0x10、0x11、0x27、0x28、0x3E、0x85 | 控制会话、复位、解锁、通信开关、保活 |
| 数据传输 | 0x22、0x2E、0x23、0x3D、0x2A、0x2C | 按标识符或地址读写数据 |
| 故障码相关 | 0x19、0x14 | 读取和清除诊断故障码 |
| 存储传输 | 0x34、0x35、0x36、0x37、0x38 | 软件刷写、文件上传下载 |
| 例程与IO控制 | 0x31、0x2F | 触发自检、执行擦除、控制执行器 |
| 其他 | 0x83、0x84、0x86、0x87 | 时序参数、安全传输、事件响应、链路控制 |
日常工作中真正高频使用的其实就那么七八个:0x10切会话、0x27解锁、0x22读数据、0x2E写数据、0x19读故障码、0x14清故障码、0x31跑例程、0x3E保活。刷写场景再加上0x34、0x36、0x37这三兄弟。把这张表记熟,日常调试能解决八成问题。
2. 一条 UDS 报文到底长什么样
2.1 请求、正响应、负响应的三段式结构
UDS 的报文格式极其规整,规整到有点枯燥,但正是这种规整让解析变得容易。任何一个请求都是“服务标识符 + 子功能(可选)+ 参数”的结构。正响应的服务标识符统一是请求值加0x40,参数部分按服务各自定义。负响应则固定三字节起步:0x7F+ 原始服务号 + 否定响应码。
拿会话切换举例,完整对话是这样的:
请求: 10 03 正响应:50 03 00 32 01 F4 负响应:7F 10 22请求里的10是服务号,03是子功能——切到扩展会话。正响应里50就是0x10 + 0x40,回显子功能03,后面四个字节是两个时序参数:00 32是 P2 服务时间,单位毫秒,换算下来 50 毫秒;01 F4是 P2 扩展时间,单位 10 毫秒,换算下来 5000 毫秒。这两个参数是控制器主动告诉诊断设备的:“我最快 50 毫秒内给你回话,如果需要更久,我会先发个占位响应,然后最多再拖 5 秒。”
负响应里的22是否定响应码,含义是“条件不满足”。回到开头那个故事,控制器就是在说:我知道你想切扩展会话,但现在整车状态不允许。
2.2 子功能最高位的隐藏开关
子功能字节的最高位(bit7)是个很实用但经常被忽略的设计:抑制正响应位。把它置 1,控制器处理完就不回正响应了,只在出错时回负响应。最典型的用法是保活服务:
3E 80 // 发送保活,但不要求回复 3E 00 // 发送保活,要求回复 7E 00为什么要有这个开关?因为保活报文是周期性发送的,通常每两秒一次,如果每次都要求回复,总线上会多出一倍的流量。在总线负载本来就紧张的项目里,这个细节能省下不少带宽。我见过有项目因为保活报文没抑制响应,导致总线负载率在诊断期间飙高,最后靠改这一个字节解决。
写测试用例的时候要特别注意:抑制了正响应不代表不会回负响应。如果控制器这时候状态不对,它照样会回7F 3E xx。所以脚本里不能因为设了抑制位就完全不等响应,得区分处理。
2.3 否定响应码速查与常见误判
否定响应码是排查问题的第一手线索,但很多人只记住了0x78和0x33。下面这张表是我实际调试中碰到频率最高的几个:
| NRC | 名称 | 实际含义与常见诱因 |
|---|---|---|
| 0x10 | 通用拒绝 | 控制器知道请求但就是不干,通常有隐藏条件 |
| 0x11 | 服务不支持 | 服务号写错,或该控制器根本没实现这个服务 |
| 0x12 | 子功能不支持 | 服务支持但子功能不支持,或者参数长度不对 |
| 0x13 | 报文长度错误 | 参数字节数不匹配,最常见于手工拼报文 |
| 0x22 | 条件不满足 | 会话、电压、车速、点火状态等前置条件不满足 |
| 0x31 | 请求超出范围 | 地址或标识符越界,刷写时地址填错常报这个 |
| 0x33 | 安全访问被拒 | 没解锁就调用受保护的服务 |
| 0x35 | 密钥无效 | 安全算法算错了,或者种子用错了 |
| 0x36 | 尝试次数超限 | 连续发错密钥触发锁定 |
| 0x37 | 延时未到 | 上次失败后还在冷却期,得等够时间 |
| 0x78 | 响应挂起 | 不是错误,是“我还在处理,别急” |
| 0x7E | 当前会话不支持子功能 | 服务支持、子功能也支持,但当前会话下不允许 |
| 0x7F | 当前会话不支持该服务 | 需要先切到更高权限的会话 |
这里有个高频误判:把0x78当成失败。0x78是过程性响应,控制器在告诉你“请求收到了,但处理需要时间,我会在 P2 扩展时间内给最终结果”。正确做法是继续等,而不是立刻判定超时。我见过有上位机软件把0x78直接记为失败,导致刷写过程中明明一切正常却反复报错。
反过来,0x7E和0x7F这一对也常被混淆。0x7F说的是服务本身在当前会话不可用,0x7E说的是服务可用但你要的这个子功能在当前会话不可用。区分清楚能省下大量瞎猜的时间。
3. 会话、解锁与保活:诊断的前置三件套
3.1 诊断会话控制:权限的分层设计
默认会话、编程会话、扩展会话,这三个是最基础的。默认会话是控制器上电后的初始状态,权限最低,只能做最基础的读取。扩展会话放开大部分服务和数据访问。编程会话专门用于刷写,进入后应用层程序通常停止运行,只保留引导程序。
为什么要分层?本质上是安全边界。默认会话下随便谁插个诊断设备都能改参数、刷程序,风险太大。分层相当于给不同操作划分了不同权限等级,高权限操作必须显式申请。这个设计思路和操作系统里的用户权限模型是一回事。
不同会话下服务可用性差异很大,实际项目里我一般会整理成一张矩阵表放在手边:
| 服务 | 默认会话 | 扩展会话 | 编程会话 |
|---|---|---|---|
| 0x22 读数据 | 部分 DID | 全部 DID | 少量 |
| 0x2E 写数据 | 不支持 | 支持(需解锁) | 不支持 |
| 0x27 安全访问 | 不支持 | 支持 | 支持 |
| 0x31 例程控制 | 少量 | 支持 | 支持 |
| 0x34 请求下载 | 不支持 | 部分支持 | 支持 |
| 0x11 复位 | 支持 | 支持 | 支持 |
注意:会话切换不是“只能往上走”。从扩展会话可以直接切回默认会话,也可以直接切到编程会话。但有些控制器从编程会话回默认会话必须经过一次复位,这个差异要在测试用例里区别对待。
3.2 安全访问:种子与密钥的握手逻辑
0x27的机制说穿了很简单:诊断设备先要一个随机数(种子),控制器给一个;诊断设备用约定的算法算出密钥发回去,控制器自己也算一遍,对上了就解锁。整个过程明文传输,安全性完全依赖算法本身的保密性。
子功能号的规则要记牢:奇数是请求种子,偶数是发送密钥。0x27 0x01请求种子,0x27 0x02发送密钥,这是第一级;0x27 0x03和0x27 0x04是第二级,以此类推。不同级别对应不同的保护范围,有些控制器刷写需要两级解锁。
种子的一个特殊情况:如果返回的种子全是 0,说明当前已经解锁,不需要再发密钥。这个判断条件必须写进脚本,否则会白白多走一轮握手,有些控制器还会因此判定为异常。
密钥算法是最麻烦的部分,因为它通常是供应商私有实现,不会出现在公开文档里。做测试的时候如果拿不到算法,常见的做法是让供应商提供算法库或者加密服务接口。自己反推也不是不行,但需要大量的种子密钥对做分析,成本很高。我在一个项目里见过算法是查表加异或的组合,逆向花了差不多一周。
发送密钥失败的处理也要提前设计。连续错误会触发0x36尝试次数超限,之后进入冷却期,再发就是0x37。冷却时间长短因控制器而异,有的几秒,有的几十秒。所以脚本里一定要限制重试次数,别写成死循环,否则越试越锁。
3.3 保活机制:S3 超时与会话掉线
非默认会话下有个隐藏计时器在跑:如果超过 S3 时间(典型值 5 秒)没有收到任何诊断请求,控制器自动退回默认会话。这个设计是为了防止诊断设备异常断开后,控制器一直停在高权限状态。
对应地,诊断设备必须周期性发送保活报文3E 80。间隔一般取 S3 的一半左右,也就是 2 秒左右比较稳妥。为什么不取 4.9 秒卡着点发?因为总线上有延迟,控制器内部也有处理抖动,卡太紧容易翻车。
实际调试中最典型的翻车场景是:测试脚本在某个步骤里做了耗时操作,把保活线程阻塞了。比如擦除 flash 的例程本身要跑几十秒,如果此时保活停了,控制器中途退回默认会话,例程执行到一半就断了。解决方式是把保活放在独立线程里,或者让控制器在例程执行期间自己挂起 S3 计时。这两种做法在项目里都常见,具体用哪种要看控制器实现,问清楚再动手。
4. 读写数据与例程控制:日常用得最多的三个服务
4.1 0x22 读数据:DID 是数据的门牌号
DID(数据标识符)是两字节的编号,控制器内部维护一张 DID 到数据内容的映射表。诊断设备只要发22 F1 90,控制器就回62 F1 90加上 17 字节的车身识别码。这个服务几乎所有场景都会用到,读软件版本、读序列号、读传感器原始值、读内部状态变量,全走它。
响应长度是可变的,取决于 DID 定义的数据长度。所以解析时不能写死偏移,得先看总长度。我习惯的做法是建立一个 DID 配置表,把每个 DID 的名称、长度、数据类型、换算公式都写进去,脚本根据表自动解析。这样换车型时只要换表,代码一行不用改。
有个细节容易遗漏:某些 DID 只在特定会话下可读。在默认会话下读会得到7F 22 7F,切到扩展会话就能读了。碰到这种响应先别怀疑 DID 写错了,先确认会话对不对。
4.2 0x2E 写数据:解锁是硬门槛
写数据的门槛比读高得多。典型的前置条件是:切到扩展会话、完成安全访问解锁、满足整车状态条件(比如车速为零、电源电压在窗口内)。三个条件缺一个,返回的就是0x22或者0x33。
写数据的响应格式很简洁:6E+ DID,不带数据回显。所以脚本里判断成功只能靠服务号和 DID 匹配,不像读数据还能校验内容。写完之后通常要再读一次做回读验证,这是工程实践里必须加的步骤,别嫌麻烦。
提示:写操作前先把原值读出来存一份,出问题时能快速回滚。这个习惯我在好几个项目里救过场,尤其是改配置类 DID 的时候。
4.3 0x31 例程控制:把控制器的内置功能当接口调
例程控制是 UDS 里最灵活的服务。控制器内部预置了一批例程,每个例程有个 RID 标识,诊断设备可以启动、停止、查询结果。启动和停止的子功能分别是0x01和0x02,查询结果是0x03。
常见的例程包括:擦除 flash 存储区、校验程序完整性、执行传感器自检、触发执行器动作、恢复出厂设置。刷写流程里最常见的组合是“先启动擦除例程(RID 通常是FF00这类),等擦除完成,再开始传数据,最后启动校验例程”。
例程执行时间可能很长,这时候控制器会在正响应里先返回,然后通过0x78挂着。脚本必须支持这种“多次 0x78 后给最终结果”的模式。有些控制器擦除 flash 会连续发十几个0x78,累计耗时几十秒,如果超时时间设得太短,一定会误判。
5. 读取故障码:0x19 与状态字节的八个开关
5.1 0x19 的子功能选择
0x19的子功能很多,日常最常用的是0x02(按状态掩码报告故障码)。请求格式是19 02 FF,FF就是状态掩码,表示“把各种状态下的故障码都报给我”。响应格式是59 02加上一个可用状态掩码,后面跟着若干个四字节的故障码记录,每条记录前三字节是故障码本身,第四字节是状态。
子功能0x0A用于查询控制器支持的故障码,0x04用于读快照数据,0x06用于读扩展数据。不同子功能返回格式差异很大,解析逻辑要分开写。
5.2 状态字节的位定义
那第四字节的八位每一位都有明确含义,这是排查故障时最有价值的信息:
| bit | 含义 |
|---|---|
| 0 | 本次测试失败 |
| 1 | 本操作循环内测试失败 |
| 2 | 待定故障码 |
| 3 | 已确认故障码 |
| 4 | 自上次清除后测试未完成 |
| 5 | 自上次清除后测试失败过 |
| 6 | 本操作循环内测试未完成 |
| 7 | 请求点亮警告指示 |
实际排查时,bit3(已确认)是最关键的判断依据。一个故障码如果只有 bit2 没有 bit3,说明是偶发待定状态,还没被确认。如果 bit0 和 bit3 都置位,说明当前正在失效且已经确认。只看有没有故障码而不看状态位,很容易把偶发问题当成长久故障处理,方向就偏了。
5.3 清除故障码的连锁影响
0x14清除故障码看似简单,发14 FF FF FF就完事,但它会连带清除冻结帧、就绪状态、部分学习值。有些控制器清完之后需要重新跑一遍自学习流程,否则会表现异常。
我的做法是:清除之前先把所有故障码和冻结帧完整读一遍存档,清完之后再读一次确认真的清干净了。有些故障码在当前故障未消除时会被立刻重新置位,这时候清完再读会看到它又回来了,这反而能帮助定位当前故障。
6. 软件刷写:从请求下载到复位生效
6.1 完整的刷写时序链路
刷写是 UDS 里环节最多、最容易出问题的场景。完整流程大致是这样:
1. 10 03 切扩展会话 2. 27 01 / 27 02 安全访问解锁 3. 31 01 FF00 ... 启动擦除例程,等完成 4. 10 02 切编程会话 5. 27 01 / 27 02 编程会话下再次解锁 6. 34 ... 请求下载,声明地址与长度 7. 36 ... 循环传输数据块 8. 37 请求退出传输 9. 31 01 0202 ... 启动完整性校验 10. 11 01 硬复位,新程序生效注意第 3 步和第 4 步的顺序,有些实现是先切编程会话再擦除,有些是反过来。这个必须严格按控制器的规范来,顺序错了会直接被拒绝。
6.2 块序号与传输长度的参数计算
0x36传输数据里有个关键字段:块序号计数器。它从0x01开始,每传一帧加一,加到0xFF之后回到0x00继续循环。控制器靠它检测丢帧和乱序。如果诊断设备发了序号跳变的帧,控制器会返回0x73(块序号错误)并终止传输。
另一个关键参数是0x34响应里返回的最大块长度。假设控制器返回的最大块长度是 0x0402 = 1026 字节,那么在经典 CAN 上,考虑到传输层每帧要占掉几个字节的头,实际每帧能带的 UDS 数据大约 6 字节左右,一帧一帧累加直到凑够。而在 CAN FD 上,单帧能带几十字节,效率提升非常明显。
传输总长度的计算要精确:请求下载时声明的长度必须和实际要传的数据量一致。声明少了,后面的数据传不进去;声明多了,控制器会一直等剩下的字节,最后超时失败。
6.3 刷写中途失败的恢复策略
刷写失败最怕的是把控制器刷成“半截状态”。好在现代控制器的引导程序通常有保护机制:应用区校验失败时会停留在编程模式,允许重新刷写。但如果连引导程序都被破坏,那就只能上台架或者换件了。
所以刷写脚本必须做好几件事:每传完一批数据校验一次响应;记录已经成功传输到的块序号,失败后能从中断处续传;设置合理的重试次数和退避策略;整个流程加日志,每一步的请求响应都落盘。这些看起来是工程细节,但真出事的时候,日志是唯一能救命的东西。
我在一个项目里遇到过刷写到 98% 时失败的情况,靠日志里记录的块序号定位到是某个特定地址段的数据传输异常,最后发现是控制器在那个扇区擦除不彻底。如果没有分块日志,这个问题基本无解。
7. 时序参数:P2、P2 扩展与 S3 到底怎么用
7.1 三个时间参数的分工
P2 是控制器从收到请求到给出首个响应的最大时间,典型值 50 毫秒。P2 扩展是在发出0x78之后,给最终响应的最大时间,典型值 5000 毫秒。S3 是非默认会话的保持时间,典型值也是 5000 毫秒。这三个参数在会话切换的正响应里会一起下发,诊断设备必须按控制器给的值调整超时,不能写死。
为什么不能写死?因为不同控制器的能力差异很大。有的控制器 P2 只有 20 毫秒,有的给 100 毫秒。如果脚本里把超时写死成 50 毫秒,碰到给 100 毫秒的控制器就会频繁误判超时。
7.2 0x78 的正确处理姿势
收到0x78的正确处理是:重置等待计时器,继续等 P2 扩展时间。同时要有个总次数上限,防止控制器无休止地发0x78。常见实现里次数上限是 10 到 20 次,超过就判定失败。
还有个细节:0x78本身是负响应的格式(7F xx 78),但它代表的是“进行中”。有些协议栈实现会把它和其他负响应一起处理,导致误判。写解析逻辑时要单独把这个码拎出来。
7.3 会话超时的边界情况
S3 计时是从最后一次成功收到请求开始算的。注意是“成功收到”,如果报文格式错误被丢弃,不重置计时器。这一点在做压力测试时很有用:故意发错误报文,观察控制器是否按时退回默认会话,可以验证协议栈实现是否规范。
另外,当一个耗时例程正在执行时,有些控制器会自动挂起 S3 计时,有些不会。这个差异必须在项目早期确认清楚,否则会出现“例程跑到一半会话掉了”的诡异现象。
8. 实操中踩过的坑与排查思路
8.1 常见问题速查
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 一直收不到任何响应 | 物理层不通、CAN ID 配错、控制器未上电 | 先用示波器或总线工具确认物理层有波形 |
| 收到 7F xx 11 | 服务不支持 | 确认控制器是否实现了该服务,查诊断数据库 |
| 收到 7F xx 7F | 会话权限不够 | 先切扩展或编程会话 |
| 收到 7F 27 35 | 密钥算错或种子用错 | 核对算法输入,确认种子没被重复使用 |
| 收到 7F 27 36 | 尝试次数超限 | 停止重试,等待冷却期结束 |
| 多帧传输中断 | 流控帧参数不匹配 | 检查块大小和间隔时间设置 |
| 刷写卡在某一块 | 块序号跳变或数据校验失败 | 对比日志中的块序号连续性 |
| 例程执行中途失败 | 保活中断导致会话掉线 | 检查保活线程是否被阻塞 |
8.2 安全访问失败的排查路径
安全访问失败是最高频的问题之一,排查要有顺序。先看是不是根本没进扩展会话,再看种子是不是全零(已解锁),然后核对密钥算法。算法核对时最有效的方法是拿一组已知的种子密钥对做单元测试,确认算法实现本身没问题,再去怀疑传输环节。
如果算法确认没问题还是失败,就要怀疑种子是否被复用。有些控制器要求每次解锁用新的种子,如果脚本缓存了旧种子,算出来的密钥必然不对。还有一种情况是种子在传输过程中被截断或拼错,尤其是多帧传输时,要确认拼包逻辑正确。
8.3 刷写失败的现场保护
刷写前必须做三件事:确认电源稳定(电压跌落是刷写失败的头号原因)、确认总线负载在合理范围、确认诊断设备和控制器之间没有其他节点干扰。刷写过程中要禁止其他诊断请求混入,否则会打断时序。
如果真的失败了,第一件事不是重试,而是读日志确认失败在哪一步。如果是擦除阶段失败,重试擦除通常有效;如果是传输阶段失败,要看块序号定位断点;如果是校验阶段失败,说明数据传完了但内容不对,得从头再来,同时怀疑数据源本身有问题。
9. 我个人的几条实操建议
写诊断脚本的时候,我习惯把协议层和业务层彻底分开。协议层只负责收发、拆包、超时判断、0x78处理这些通用逻辑;业务层只关心“切会话、解锁、读某个 DID”这样的语义动作。这么分层之后,换一个项目只需要重写业务层的用例,协议层基本能原样搬过去。
另一个习惯是给每条报文都打时间戳和上下文标签。抓包工具给的日志是原始的,但你自己脚本里打出来的日志能带上“这是第几次重试、当前处于哪个步骤、上一步的响应是什么”,排查效率完全不是一个量级。
最后一点,别迷信文档。规范文档写的是“应该怎样”,实际控制器实现的是“实际怎样”。两者有出入是常态,遇到矛盾时以实测为准,同时把差异记录下来反馈给供应商。我手上现在攒了一份自己整理的“实现差异清单”,每次新项目开局都先翻一遍,能提前避开不少坑。