ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UDS 0x19服务groupOfDTC详解:三字节分组过滤原理与踩坑

UDS 0x19服务groupOfDTC详解:三字节分组过滤原理与踩坑

上周做一台电机控制器的诊断联调,测试仪想读"动力总成组里当前已失败且已确认"的故障码,于是发了一条19 02 09 01 00 00,结果 ECU 一直在回7F 19 31。排查了大半天,最后发现并不是服务没实现,而是测试仪侧把 groupOfDTC 的三字节顺序填反了。这类问题其实很常见,凡是第一次和 UDS 0x19 服务打交道的开发者,基本都会在 groupOfDTC 上卡一次:它到底代表什么,为什么要三个字节,又该怎么用?这篇文章就把这几个问题一次讲清楚,顺带把我几个量产项目上踩过的坑也一起列出来。

文章主要适合正在做 UDS 诊断栈、诊断测试、标定支持的工程师,也适合刚接触汽车电子、想搞懂诊断协议的同学。下面的报文和分组表都以一套自造的模拟规则为例,真实项目务必以整车厂或供应商下发的诊断规范为准。

1. 在 19 服务里,按状态筛故障码为什么还不够

1.1 DTC 编号与状态掩码,先统一黑话

UDS 诊断里,故障码叫 DTC(Diagnostic Trouble Code),一个 DTC 在报文中通常用 3 个字节表示编号,例如01 00 21。按 14229-1 的分类,编号前一部分代表系统归属,后一部分代表具体故障点,但这个编号的具体排布是由标准和整车厂共同决定的,这里不展开。

除了编号,每个 DTC 还带一个 1 字节的状态,8 个 bit 各代表一种诊断状态。常见的位定义如下:

DTCStatusMask 位数值含义
bit00x01testFailed(当前测试失败)
bit10x02testFailedThisOperationCycle(本操作循环内测试失败)
bit20x04pendingDTC(待确认故障)
bit30x08confirmedDTC(已确认故障)
bit40x10testNotCompletedSinceLastClear(自上次清除后未完成测试)
bit50x20testFailedSinceLastClear(自上次清除后测试失败过)
bit60x40testNotCompletedThisOperationCycle(本操作循环内未完成测试)
bit70x80warningIndicatorRequested(请求点亮警告灯)

读取故障码的服务是 0x19。最常用的两个子功能:19 01是 "按状态掩码统计故障数"(request:19 01 DTCStatusMask),19 02是 "按状态掩码上报故障码列表"(request:19 02 DTCStatusMask)。响应里返回的不只有 DTC 编号,还有 DTCFormatIdentifier(DTC 格式号)和 DTCStatusAvailabilityMask(ECU 支持哪些状态位),再跟着一串DTC编号 + DTC状态。这就是读故障码的底层框架,搞懂这段黑话,才好理解 groupOfDTC 插在哪里。

1.2 只按状态过滤,查不出"到底是哪一块"

问题在于,DTCStatusMask 只能按"状态"筛数据,解决的是"这个故障现在确认了没有、是不是还在报"这类问题。它筛不了"这个故障属于动力系统还是车身系统"。

举个例子:一个整车控制器可能存了三四百条 DTC,从电机过温、电池继电器粘连,到外部灯控制单元通信丢失,全混在同一个 DTC 表里。如果只想确认"电机控制器当前有没有已确认故障",用19 02 08(confirmedDTC)会把整车三五百条已确认 DTC 全部拉回来。响应可能长达上千字节,在经典 CAN 上要分包成很多帧,占用总线时间,测试仪还要自己过滤掉不关心的内容,链路调试也费劲。

更重要的是,有些 DTC 并不是给所有测试仪看的。排放相关的那组 DTC 要和 OBD 检查工具对接,售后诊断仪才需要看所有的整车厂自定义故障码。这不是安全访问那种权限隔离,但至少在数据组织上需要"按块取数"。所以协议里必须有一个维度,能按 DTC 编号的归属把表切成若干块,groupOfDTC 就是干这个的。

1.3 从"按状态查"到"按归属查":分组参数的引入

14229-1 在设计 0x19 服务时,把 DTC 的查询条件拆成了两个正交的维度:状态维度用 DTCStatusMask 表示,归属维度就用 groupOfDTC 表示。groupOfDTC 是一个 3 字节的请求参数,用来指定"我要查哪一组 DTC"。它和 DTCStatusMask 不冲突,两者可以同时在一条请求里出现,先按归属划范围,再按状态筛条件。

所以它不是什么高深的功能,本质就是一个"FROM 哪张 DTC 表"的过滤条件。ECU 侧收到请求后,遍历诊断事件管理模块里的 DTC 表,先判断"这条 DTC 在不在请求的分组里",再判断"状态是否满足掩码",两个条件都满足才放进响应。理解了这个逻辑,后面所有实现细节就顺了。

2. groupOfDTC 的字节定义:三字节到底按什么规则匹配

2.1 报文里的位置与长度

在带分组的19 02请求里,groupOfDTC 紧跟在 DTCStatusMask 后面,一共 3 字节。报文结构如下:

字节序号内容长度示例值说明
1ReadDTCInformation SID10x19服务标识
2子功能10x02reportDTCByStatusMask 系列
3DTCStatusMask10x09testFailed + confirmedDTC
4groupOfDTC 高字节10x01分组参数
5groupOfDTC 中字节10x00分组参数
6groupOfDTC 低字节10x00分组参数

所以一条完整的带分组请求是 6 字节还是 7 字节,取决于具体协议栈是否把 groupOfDTC 作为必选参数。这一点后面讲坑的时候还会再提,因为它直接关系到负响应 0x13。

响应结构基本不变:59 02 DTCFormatIdentifier DTCStatusAvailabilityMask [DTC编号 3字节 + DTC状态 1字节]...。只要请求里带了分组,响应里的 DTC 都是按分组过滤后的结果。

2.2 前两字节精确匹配,低字节按掩码细分

14229-1 给的是一个框架,具体匹配规则并没有被所有实现统一,但最常见的做法是:

  • groupOfDTC 的高字节要与 DTC 编号的高字节完全相等;
  • groupOfDTC 的中字节要与 DTC 编号的中字节完全相等;
  • 低字节有两种常见解释:一种是当作"掩码",即低字节里置 1 的位,DTC 编号低字节对应位必须也是 1;另一种是当作"组号",直接和 DTC 低字节做相等比较。

用伪代码表示掩码方案就是:

static bool dtc_in_group(const uint8_t dtc[3], const uint8_t group[3]) { if (dtc[0] != group[0]) return false; if (dtc[1] != group[1]) return false; /* 低字节掩码:group[2] 置 1 的位,dtc[2] 对应位必须为 1; group[2] 为 0 的位不参与比对 */ if ((dtc[2] & group[2]) != group[2]) return false; return true; }

如果你们的分组表明确规定低字节是组号,那最后一处改成dtc[2] == group[2]就行。为什么标准会留这个开放点?因为 DTC 编号本身并没有统一编码到"哪一位代表系统、哪一位代表故障等级",直接把低字节规则封死在标准里,反而会限制整车厂的分组设计。所以协议文本给了机制,把语义交给了制造商。

用一套模拟的分组表来举例,假设某整车控制器的 DTC 编号规则如下:

分组值含义匹配范围
0x000000全部 DTC(常用约定)所有 DTC
0x010000动力系统组DTC 编号为 0x01 00 xx
0x010100减速器/机械部件组DTC 编号为 0x01 01 xx
0x020000高压电池组DTC 编号为 0x02 00 xx
0x030000整车电源组DTC 编号为 0x03 00 xx

这个表说明了一个重要点:分组能不能好用,往往取决于 DTC 编号本身有没有按系统分段。如果编号乱排,分组表写起来就会非常痛苦,几乎每条 DTC 都要单独列一组。

2.3 特殊值 0x000000 与整车厂自定义分组表

很多工程师第一次看到 groupOfDTC,会默认 0x000000 是一个分组编号,但实际上在大部分量产协议里,0x000000 被约定为"不限制分组"或"全部 DTC 组"。也就是说,19 02 09 00 00 00等价于不带分组参数的19 02 09,ECU 应当返回全部满足状态掩码的 DTC。这个约定不是 14229-1 强制的,但我在多个项目里都见到过,基本成了事实标准。

剩下的分组值,尤其是像 0x010000 这类高字节有明确含义的值,全部由整车厂的诊断规范定义。标准文本里只会告诉你"分组由制造商定义",不会给你一张现成的分组表。所以接手一个项目时,第一件事就是找诊断规范里的 DTC 分组章节,或者 ODX 诊断描述文件里的 DTCGroup 定义,对照着做,别自己发明分组。

3. 分组带来的三个实际价值:短响应、二维筛选、专业分工

3.1 响应变短,避免触发 0x14 responseTooLong

这是 groupOfDTC 最直接的价值。当 ECU 的 DTC 表很大时,19 02 FF 00 00 00这类全量查询很容易把响应撑爆。经典 CAN 单帧最多 8 字节,长响应要靠传输层协议(15765-2 那套分包机制)拆成多帧,拆帧本身不复杂,但整条报文传输占用的总线时间会明显变长,而且有些旧的诊断仪在等重组时还会超时。

更重要的是,14229-1 里有个负响应码 0x14(responseTooLong),专门表示"响应长度超过传输层或接收方能力"。用分组查询后,ECU 只回一个子系统的 DTC,响应长度大幅缩短,从源头上避免了 0x14。我在一个模拟项目里算过一笔账:全量查 400 条 DTC,响应大概要 1600 多字节;按组查,一个子系统通常只有二三十条,响应不到 120 字节,在 CAN FD 上一帧甚至直接搞定。对于需要周期性扫描故障的测试台架来说,这个差异很可观。

3.2 状态掩码 × 分组 = 二维筛选

DTCStatusMask 管状态,groupOfDTC 管归属,两个条件一起用,才是一个完整的"按需取数"。

举个例子,请求19 02 09 01 00 00的含义是:

  • 0x09:testFailed(0x01)+ confirmedDTC(0x08),也就是"当前正在报且已经确认"的故障;
  • 0x010000:动力系统组。

这样 ECU 只会把动力系统相关、同时满足这两个状态的 DTC 放进响应。如果没这个能力,测试仪只能先拉全量,再在应用层自己过滤,又慢又容易出 bug。有分组参数后,过滤逻辑下沉到 ECU 端,接口语义清晰,测试脚本也更好维护。

可以类比成数据库查询:SELECT * FROM dtc_table WHERE (status & mask) <> 0 AND group_id = 0x010000。DTCStatusMask 和 groupOfDTC 就是 where 条件里的两个字段,而不是两条互斥的服务。

3.3 排放组和售后组各查各的,数据组织更健康

现代车辆里,同一块 ECU 可能同时为不同角色服务。OBD 检测工具关心的是排放相关的 DTC 组,比如催化器效率、氧传感器这一类;售后诊断仪关心的是整车厂自定义的全量 DTC,包括一些更深层的失效信息。没有分组之前,要么所有工具都看全量数据,要么 ECU 端刻意区分"公开 DTC"和"隐藏 DTC",实现都别扭。

分组参数让 ECU 可以按"块"来组织对外可见性:排放组通过 19 02 带特定 groupOfDTC 就可以被 OBD 工具读到,剩下整车厂自定义的组只对授权工具开放。这里要注意,分组不是访问控制,它不能替代 SecurityAccess。只要工具知道分组号,它照样能查;它只是让"不同场景查不同数据块"这件事在协议层面变得自然,代码里不需要写一堆 if 判断。

4. ECU 侧落地:DTC 分组表配置与 19 02 响应实现

4.1 配置阶段:把分组表做成可审查的工程产物

ECU 里实现分组查询,第一步不是写代码,而是把 DTC 分组表整理出来。常见的做法是在诊断事件管理模块(很多工程里直接叫 DEM)里给每条 DTC 配置一个或多个分组标签。一条 DTC 可能属于多个组,比如某条高压电池温度故障,既属于 0x020000 高压电池组,又可能属于 0x020100 电池热管理细分组。

我习惯把这张表直接做成可读的配置项,而不是散落在代码里的硬编码数组。每行内容包括:DTC 编号、分组值、状态位默认值。配置工具可以根据这张表自动生成头文件或者 JSON,交给代码评审时也容易核对。实际项目里最常见的返工原因,就是 DTC 编号改版后分组表没同步,导致查询结果对不上。所以配置这一步,宁可慢一点也要做成"单一数据源"。

ODX 类诊断描述文件里也会体现分组,通常是在 DTCGroup 节点下面挂一组 DTC 引用。如果整车厂的诊断规范是用这种格式下发的,ECU 开发人员和测试仪开发人员拿到的分组定义是同一份,联调时会少很多口径问题。

4.2 匹配函数与 19 02 响应组装

服务端收到19 02 DTCStatusMask groupOfDTC后,处理流程其实很直白:遍历 DTC 表,先查分组,再查状态,把命中的 DTC 和状态拼进响应。实现框架如下:

size_t collect_dtc_by_group(uint8_t status_mask, const uint8_t group[3], dtc_entry_t *out, size_t out_cap) { size_t n = 0; for (size_t i = 0; i < dem_dtc_count; i++) { const dtc_entry_t *e = &dem_table[i]; /* 分组过滤 */ if (!dtc_in_group(e->number, group)) continue; /* 状态过滤:只要任一掩码位为 1 就上报 */ if ((e->status & status_mask) == 0) continue; if (n < out_cap) out[n++] = *e; } return n; }

响应组装时,DTCFormatIdentifier 通常填 0x01,表示 DTC 编号按 14229-1 的 3 字节格式上报;DTCStatusAvailabilityMask 一般填 ECU 实际支持的状态位,比如 0xFF 表示 8 个状态位全部支持,如果没实现 warningIndicatorRequested 这一位,就填 0x7F。

要注意的是,当分组和状态都匹配不到任何 DTC 时,不要回负响应,这属于合法结果。正确做法是返回59 02 01 FF,后面不跟任何 DTC 记录。测试仪看到的是一个空列表,而不是错误。

4.3 异常路径:不支持的组、长度错误与负响应优先级

分组查询涉及几个常见负响应码,面试和联调都容易考到:

负响应码含义典型原因
0x12subFunctionNotSupportedECU 根本不支持 19 02 这个子功能
0x13incorrectMessageLengthOrInvalidFormat请求长度不对,比如带不带 groupOfDTC 跟协议栈实现不一致
0x31requestOutOfRangegroupOfDTC 的值没有被分组表定义,或者 DTCStatusMask 非法
0x14responseTooLong响应长度超限,全量查询时容易出现

规范里对负响应优先级有要求。ECU 收到请求后,先校验子功能是否支持(不支持回 0x12),再校验消息长度(不对回 0x13),最后才校验数据值(不合法回 0x31)。千万不要在长度校验之前就去查 groupOfDTC 的合法性,否则测试仪会收到错误的 0x31,干扰排查。

还有一个容易忽略的细节:如果请求里带了分组,但 ECU 的分组表里根本没有这个值,比如发了个19 02 09 99 99 99,而 0x999999 不是任何有效组,那么 ECU 应当回7F 19 31而不是空列表。这个设计是有意义的,它让测试仪能明确知道"分组填错了",而不是"这个组里恰好没故障"。

5. 测试仪侧用法:从报文样例到联调验证清单

5.1 一段完整报文走查

我们沿用前面的模拟项目,发一条"查动力系统组、当前失败且已确认的故障码"的请求:

请求: 19 02 09 01 00 00 响应: 59 02 01 FF 01 00 21 09 01 00 22 01

逐字节拆解响应:

  • 59:0x19 的正响应 SID;
  • 02:回显子功能;
  • 01:DTCFormatIdentifier,3 字节 DTC 格式;
  • FF:DTCStatusAvailabilityMask,8 个状态位都支持;
  • 01 00 21:第一条 DTC 编号,按大端读是 0x010021;
  • 09:该 DTC 状态,bit0(testFailed)和 bit3(confirmedDTC)都为 1;
  • 01 00 22:第二条 DTC 编号;
  • 01:该 DTC 状态,只有 bit0 为 1。

可以看到,返回的 DTC 编号高字节都是 0x01,因为 groupOfDTC 的高字节就是 0x01。如果测试仪收到的列表里混进了 0x0200xx 的 DTC,那一定是 ECU 端的分组过滤实现有 bug,或者分组表配置错了。

5.2 在总线工具/诊断仪里发起分组查询的通用做法

不管用什么工具,物理层动作都一样:往诊断请求 ID(常见的是 0x7E0)发请求帧,从响应 ID(0x7E8)收响应。request 就 6 个字节,经典 CAN 单帧能装下。如果响应超过 8 字节,传输层会自动分包,你只需要按接收流程把多帧拼回来即可。

写自动化脚本时,可以封装成下面的样子:

def read_dtc_by_group(status_mask, group): # group 是 3 字节大端,例如 0x010000 -> [0x01, 0x00, 0x00] req = bytes([0x19, 0x02, status_mask, (group >> 16) & 0xFF, (group >> 8) & 0xFF, group & 0xFF]) send_diag_request(tx_id=0x7E0, data=req) resp = receive_diag_response(rx_id=0x7E8, timeout_ms=200) return resp

这里的send_diag_request和receive_diag_response是你手头 CAN 适配器库的封装,重点是把 group 拆成大端三字节填进请求。实际工具里可能还有"诊断控制台"这类手动输入界面,直接按字节序列19 02 09 01 00 00输入就行,但要注意很多工具会自动补 SID 和子功能,不要重复填。

5.3 联调验证清单:确认分组真的生效

我每次做 19 02 分组联调,都会按下面这个清单过一遍,能省掉大量来回扯皮:

  • 先用19 02 FF 00 00 00查全量,确认 ECU 的 19 02 基本功能正常;
  • 再发一个具体分组19 02 FF 01 00 00,核对返回的 DTC 编号是否全部落在 0x01 00 xx 范围内;
  • 故意发一个不存在的分组,比如19 02 FF 99 99 99,确认 ECU 回 0x31。如果它回了成功,说明 ECU 压根没做分组校验,后面所有分组测试都不可信;
  • 对同一条 DTC,分别查"只按状态"和"状态 + 分组",确认结果差异符合预期;
  • 最后用实际故障注入验证:触发一条分组内 DTC,查询能看到;清除故障后,查询结果为空;断电重启后状态变化也能对应上。

这条清单的核心思路是"用已知行为验证未知实现"。分组参数是 3 字节的,光肉眼看报文很容易错,但按这几个用例跑一遍,绝大多数问题都能暴露。

6. 实际项目里踩过的坑:字节序、版本差异和掩码语义

6.1 三字节大端顺序写反:0x010000 发成 0x000001

文章开头那个联调问题就是这么来的。诊断规范里写的是 groupOfDTC = 0x010000,测试仪脚本里填了一个整数 0x010000,但在组报文时用了小端的拆法,结果发到总线上的字节变成了00 00 01。ECU 拿高字节 0x00 去匹配,发现没有任何 DTC 编号高字节是 0x00,直接回 0x31。

这种问题特别隐蔽,因为报文 trace 里看到的请求是19 02 09 00 00 01,一眼看过去"好像也是三个字节 01",实际上位置完全错了。排查办法很简单:抓总线报文,对照规范逐字节核对请求内容。如果规范里明确写了字节顺序是大端,而你的工具是自动填充参数的,检查工具里 DTC 组参数的数据类型和字节序设置,有些工具默认按小端格式输入,必须切换。

6.2 请求长度与标准版本差异:19 02 到底带不带 groupOfDTC

这是另一个高频大坑。不同版本的 14229-1 对 19 02 子功能的命名和请求参数有差异。早期版本里 0x02 被叫做 reportDTCByStatusMaskOfDTCGroup,请求里明确包含 groupOfDTC;后来的版本里 0x02 的定位有所调整,有些协议栈实现为19 02 DTCStatusMask(4 字节请求),不带分组参数,分组功能被放到了别的子功能或干脆不开放。

这就导致同一个19 02 09 01 00 00,在 A 家的 ECU 上正常返回 DTC 列表,在 B 家的 ECU 上却回7F 19 13(消息长度错误),因为 B 家实现的 19 02 只认 4 字节请求。遇到 0x13 时,不要第一时间怀疑 frame 格式或 CAN 配置,先查 ECU 的诊断栈版本和需求规格书,确认 19 02 的请求长度定义。如果 ECU 支持的是不带 groupOfDTC 的实现,那就只能退回19 02 09全量查,再在应用层过滤,或者改用规范里规定的分组相关子功能。

6.3 低字节的掩码语义和 0x000000 约定要对齐

第三个坑是低字节语义不一致。有的项目里 groupOfDTC 的低字节就是组号,ECU 端用相等比较;有的项目里低字节是掩码,ECU 端用按位与判断。两者在大部分情况下结果一样,但一旦低字节不是 0,就会分化。

举例来说,如果规范定义分组 0x010028,掩码方案下 DTC 编号 0x01002A 会被算进组(因为 0x2A & 0x28 == 0x28),相等方案下就不会。测试仪如果按掩码理解去发请求,就可能漏查某些 DTC,或者把不该算进来的算进来。所以拿到项目的第一周,就应该用一个已知 DTC 实测一次,确认 ECU 是哪种语义,然后把结论写进测试规范里。

还有 0x000000 的约定也要确认。大部分项目把它当成"全部 DTC",但也有极少数协议栈把 0xFFFFFF 当作"全部"、0x000000 当作具体组。这种差异必须要从规范里确认,不能凭习惯猜。联调时我习惯先发一条19 02 FF 00 00 00,再看 ECU 是返回全量列表还是回 0x31,一个请求就能验证出这个项目的约定到底是哪一种。

6.4 别把分组当成安全机制

最后提醒一句:groupOfDTC 只是数据组织方式,不是权限控制。测试仪只要能猜到或查到分组号,不需要过安全访问就能读到对应 DTC,因为 19 服务本身不属于受保护服务。如果业务上确实需要隐藏某些故障信息,该走 SecurityAccess 的走安全访问,该做服务权限控制的做服务权限控制,别指望靠分组参数保密。这点在分布式开发里尤其容易出问题,诊断仪供应商拿到分组表后,并不是拿不到完整访问能力。

最后分享两个调试习惯

我做了几年诊断开发,最大的体会是:协议层面的参数都不复杂,复杂的是大家都以为自己理解的是同一个语义。所以我现在拿到一份新的诊断规范,第一件事就是把 DTC 分组表、19 01/19 02 的请求模板、支持的负响应码整理成一张速查表,发给测试和标定的人,大家按同一份表联调,能少吵很多次架。

第二个习惯是在联调阶段故意"打错"一次分组。比如发一个肯定不存在的分组值,观察 ECU 是回 0x31 还是回空列表。这个小动作几秒钟就做完了,却能立刻判断出 ECU 端到底有没有做分组校验。很多所谓"分组查询不准"的疑难杂症,最后查下来都是 ECU 压根没实现分组过滤,把全量结果原样丢了出来。先验证这一层,再往下追,效率高得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进