UDS 0x22:DID读取与响应解析
台架上最容易被误判的一类问题是:诊断仪发送
22 F1 90,ECU 能返回62 F1 90,但一换成多个 DID,响应就变成了7F 22 31,或者长响应在第二个连续帧后被测试工具判成乱码。真正需要先确认的不是“0x22 能不能读”,而是 DID 的数据定义、访问条件、响应长度和 ISO-TP 重组是否一致。本文按嵌入式诊断工程的排查顺序,说明 ReadDataByIdentifier 的标准边界、DID 请求与响应的逐字节格式、CAN/ISO-TP 抓包、P2/
P2*时序、NRC 处理,以及一份可以作为 ECU 服务骨架的 C 语言实现。示例中的 CAN ID、DID 值、会话权限和记录长度属于演示配置,量产项目应以 ODX、诊断数据库和 ECU 诊断规范为准。
1. 先把三个层次分开
ReadDataByIdentifier 是 ISO 14229 应用层服务。它只定义 0x22、DID 和 dataRecord 的排列方式,不定义 CAN 标识符,也不定义一条 CAN 帧如何装下长响应。本例采用经典 CAN、普通寻址和 8 字节数据区;扩展寻址或 CAN FD 的 PCI/有效载荷空间要按实际传输配置重新计算。
层次: CAN 链路层
本文示例: 0x7E0、0x7E8、8 字节数据区
由谁决定: ECU 网络矩阵、寻址配置
排查时看什么: ID、仲裁、总线错误、填充字节
层次: ISO-TP 传输层
本文示例: 03、10 14、21、22、30 00 00
由谁决定: ISO 15765-2 和传输层配置
排查时看什么: PCI、长度、SN、FC、STmin、超时
层次: UDS 应用层
本文示例: 22 F1 90、62 F1 90 ...
由谁决定: ISO 14229-1 和 DID 诊断数据库
排查时看什么: SID、DID、dataRecord、NRC、访问条件
在抓包工具中,03 是单帧 PCI,不能当成 UDS 字段;10 14 的 0x14 是 UDS PDU 总长度,也不能当成 DID。只有完成 ISO-TP 重组后,服务解析器才应该看到 22 F1 90 这样的应用层数据。
报文到数据:六步分层处理
| 阶段 | 输入、处理与输出 |
|---|---|
| 01 CAN | 接收网络配置指定的 CAN ID 和原始数据区。 |
| 02 ISO-TP | 识别 PCI、长度和序号,重组完整 UDS PDU。 |
| 03 请求解析 | 22 + 一个或多个两字节 DID,没有子功能。 |
| 04 访问检查 | 筛选会话支持项;检查认证、安全和车辆条件。 |
| 05 数据读取 | 应用或 NvM 接口读取一致性快照,明确长度和编码。 |
| 06 响应发送 | 62 + DID + 数据;长响应交给 ISO-TP 分段。 |
2. 0x22 在 ISO 14229 中到底定义了什么
2.1 服务职责和 DID 边界
ISO 14229-1:2020 第 11.2 节把 0x22 定义为:客户端通过一个或多个两字节 dataIdentifier 请求服务器读取相应的数据记录。服务端把每个 DID 的标识符回显到正响应中,并在其后放置对应的 dataRecord。
标准并不要求每个 ECU 实现全部 DID。附录 C.1 对 DID 值域及部分标识用途有明确分配;在这些边界内,OEM/供应商定义实际支持项、dataRecord 的长度、编码、缩放、字节序和访问权限。VIN、软件版本、零件号、测量值和内部状态都可以作为记录,但不能随意占用标准保留或已分配的标识。
例如,附录 C.1 将 F190 分配给 VIN、F187 分配给车辆制造商备件号、F189 分配给 ECU 软件版本号。标识用途有标准依据,但 ECU 是否支持、记录如何编码仍要查项目定义;不能把 F187 当成软件版本,也不能推断所有 DID 都是 ASCII 字符串。
2.2 请求长度和重复 DID
0x22 没有 SubFunction 字节。应用层请求由一个 SID 和一个或多个 DID 组成:
| 22 DID_H DID_L [DID_H DID_L] ... |
因此请求的最小 UDS 长度为 3 字节,总长度必须满足 1 + 2 × n,也就是奇数长度。22 F1 共 2 字节,既小于最小长度,也缺少 DID 低字节。同一个 DID 可以在同一请求中出现多次,服务器应把它们当成独立参数,并在响应中按请求次数重复返回。项目通常会限制一次请求的 DID 数量,或者限制响应总长度;限制值应从诊断数据库和 ECU 配置读取。
2.3 用 DID 数据字典先解决“怎么拆响应”
一个工程上可用的 DID 字典至少要记录以下信息:
字段: DID
示例: 0xF190
用途: 识别数据记录
字段: 记录长度
示例: 17 字节
用途: 解析响应边界、计算 ISO-TP 长度
字段: 编码
示例: ASCII
用途: 转换为显示值
字段: 字节序
示例: 大端或按字节
用途: 解析多字节数值
字段: 会话
示例: 默认/扩展
用途: 服务访问门控
字段: 安全等级
示例: 未解锁/等级 1
用途: 安全访问检查
字段: 读取回调
示例: read_vin()
用途: 从应用、NvM 或状态缓存取值
dataRecord 在响应中没有统一的长度前缀。诊断客户端必须知道每个 DID 的长度,或者使用项目约定的解码器;只靠 62 后面“剩余多少字节”无法可靠拆出多个 DID。
3. 先看一组完整的 0x22 抓包
下面的例子使用物理寻址 CAN ID 0x7E0(诊断仪到 ECU)和 0x7E8(ECU 到诊断仪)。这些 ID 只是常见项目配置,不是 0x22 服务规定的值。
3.1 读取 VIN:单帧请求、ISO-TP 长响应
假定项目 DID F190 的 dataRecord 是 17 个 ASCII 字节,本文演示 VIN 为 W0L000043MB541326。
诊断仪发送请求:
| CAN ID 0x7E0 03 22 F1 90 00 00 00 00 |
其中 03 表示后面有 3 个 UDS 字节,真正的应用层请求是 22 F1 90。ECU 的 UDS 正响应包含 62 F1 90 加 17 字节 VIN,共 20 字节,所以需要 ISO-TP 多帧发送:
| CAN ID 0x7E8 10 14 62 F1 90 57 30 4C # First Frame,UDS 长度 0x14 |
| CAN ID 0x7E0 30 00 00 00 00 00 00 00 # Flow Control:Continue、BS=0、STmin=0 |
| CAN ID 0x7E8 21 30 30 30 30 34 33 4D # Consecutive Frame,SN=1 |
| CAN ID 0x7E8 22 42 35 34 31 33 32 36 # Consecutive Frame,SN=2 |
去掉 ISO-TP PCI 后,重组得到的 UDS 响应是:
| 62 F1 90 57 30 4C 30 30 30 30 34 33 4D 42 35 34 31 33 32 36 |
62 是 0x22 + 0x40,F1 90 是回显的 DID,后面的 17 字节才是 dataRecord。本例首帧带 6 字节 UDS 数据,两个连续帧各带 7 字节,正好共 20 字节,响应没有额外填充。请求帧末尾的 00 才是填充;其他长度场景的最后一帧若有填充,也必须按 ISO-TP 总长度去掉。
3.2 多个 DID 时,长度必须由字典确定
假设项目定义:0x010A 返回 11 字节测量值,0x0110 返回 1 字节电池状态。请求可以写成:
| CAN ID 0x7E0 05 22 01 0A 01 10 00 00 |
响应的应用层排列应为:
| 62 01 0A [11 字节 dataRecord] 01 10 [1 字节 dataRecord] |
客户端只有在知道 010A 的记录长度为 11 后,才能把紧随其后的 01 10 识别为第二个 DID。若 010A 实际是变长记录,项目必须提供长度字段或专用解码规则;不能把一个变长 DID 和后续 DID 直接拼接后再猜边界。
3.3 重复 DID 不是格式错误
标准允许同一 DID 重复出现。例如:
| 22 F1 90 F1 90 |
只要请求没有超过一次允许的 DID 数量、数据访问条件和响应长度限制,服务端应返回两组 F1 90 + dataRecord。内部可以复用同一份稳定快照,线上却不能擅自合并为一组;把重复 DID 当作格式错误,也不符合第 11.2.1 节的规定。
4. 请求和响应逐字节解析
4.1 请求消息
UDS 字节: 1
字段: ReadDataByIdentifier Request SID
长度: 1
说明: 固定 0x22
UDS 字节: 2
字段: DID 第 1 字节
长度: 1
说明: 16 位 DID 的高字节
UDS 字节: 3
字段: DID 第 2 字节
长度: 1
说明: 16 位 DID 的低字节
UDS 字节: 4…
字段: 后续 DID
长度: 2×(n-1)
说明: 每个 DID 都是两个字节
DID 在诊断报文中按高字节在前发送。ECU 内部把 DID 存成 uint16_t 还是拆成两个寄存器,是实现细节;不能因为 CPU 使用小端,就把线上顺序写成低字节在前。
请求检查建议按以下顺序执行:
- 检查
req != NULL、req_len >= 3和首字节是否为0x22。 - 检查
(req_len - 1)是否为偶数,并计算 DID 数量。 - 检查 DID 数量、响应长度上限和当前连接的寻址上下文。
- 按项目的访问矩阵检查会话、安全状态和车辆条件。
- 按图 20 逐个检查当前会话支持项:不支持的 DID 跳过;至少有一个支持项才继续组包。只有全部不支持时,才因这个原因返回
0x31。
4.2 正响应消息
正响应使用 0x62 作为 SID:
| 62 DID_H DID_L dataRecord... [DID_H DID_L dataRecord...] |
| 字段 | 含义 |
|---|---|
0x62 |
0x22 + 0x40 |
| DID | 与请求中的 DID 顺序一致并逐个回显 |
| dataRecord | 该 DID 的编码值,长度和格式由项目定义 |
服务器应在一个 0x22 正响应中返回本次请求在当前会话下支持的 DID 数据,保留对应顺序和重复次数。例如 22 F1 90 12 34 中只有 F190 受支持,正响应只含 F190 + VIN,不为 1234 插入空记录;全部不支持才返回 0x31。若读取过程需要异步等待,可以先按通用诊断时序发送 7F 22 78,最终仍要发送正响应或最终负响应;不能在发送过 0x78 后悄悄结束本次请求。
4.3 负响应和排查顺序
ISO 14229-1:2020 第 11.2.4 节列出了 0x22 需要支持的服务级 NRC:
NRC: 0x13
名称: incorrectMessageLengthOrInvalidFormat
标准触发条件: 请求长度无效,或请求 DID 数量超过一次允许的最大数量
工程排查方向: DID 是否成对;请求数量上限;是否误带 PCI
NRC: 0x14
名称: responseTooLong
标准触发条件: 总响应长度超出可用响应/底层传输能力
工程排查方向: 计算全部支持记录的总长度;区分传输上限和临时缓存不足
NRC: 0x22
名称: conditionsNotCorrect
标准触发条件: 服务器当前运行条件不满足
工程排查方向: 电源模式、车速、点火状态、应用数据是否准备好
NRC: 0x31
名称: requestOutOfRange
标准触发条件: 全部 DID 在设备/当前会话下不支持,或请求的动态 DID 尚未分配
工程排查方向: DID 字典、会话配置、动态 DID 生命周期
NRC: 0x33
名称: securityAccessDenied
标准触发条件: 至少一个 DID 需要解锁,而服务器仍处于锁定状态
工程排查方向: 0x27 的等级、密钥状态和 DID 安全矩阵
负响应格式固定为:
| 7F 22 NRC |
0x13 和 0x14 必须分开:超过允许的请求 DID 数量返回 0x13;请求格式和数量合法,但支持记录合计响应过长,才进入 0x14 路径。单帧装不下,应首先由 ISO-TP 分段;某个局部工作数组太小,也不能直接冒充底层传输极限。图 20 还包含认证检查失败的 0x34 authenticationRequired 分支,采用认证访问控制的项目需要在诊断框架中实现;它与 0x27 的安全等级检查不是同一件事。
排查时按“长度格式 → DID 是否存在 → 会话/安全 → 车辆条件 → 数据读取回调 → 响应长度”顺序逐层收敛,通常比反复修改 DID 值更快。
5. 会话、安全访问和数据一致性
5.1 0x22 没有统一的“必须先做 0x10/0x27”规则
ReadDataByIdentifier 服务本身不规定所有 DID 必须在扩展会话或安全解锁后才能读取。常见做法是:公开的版本号、VIN 允许在默认会话读取;标定数据、密钥相关状态或受保护的生产数据则要求扩展会话和指定安全等级。
下面的表格是项目访问矩阵示例,不是 ISO 14229 的通用值:
DID: F190
默认会话: 允许
扩展会话: 允许
安全等级: 无
数据来源: NvM 中的 VIN
DID: F189
默认会话: 允许
扩展会话: 允许
安全等级: 无
数据来源: 软件版本常量
DID: 010A
默认会话: 允许
扩展会话: 允许
安全等级: 无
数据来源: DEM/应用实时快照
DID: 1234(演示项目 DID)
默认会话: 拒绝
扩展会话: 允许
安全等级: 等级 1
数据来源: 受保护应用数据
对于当前会话支持的 DID,安全检查失败应按图 20 返回 0x33;不能把受保护 DID 静默过滤后返回其他公开数据。与之不同,设备或当前会话不支持的 DID 会被跳过。测试用例要把“不支持”和“支持但被安全锁定”分开。
5.2 读取实时量时不要长时间持有应用锁
DID 的读取回调可能访问应用任务快照、DEM 状态或 NvM 缓存。推荐先在数据提供任务中建立一致性快照,再由 DCM/DSL/DSP 读取快照;不要在持有 NVM 写锁或应用互斥锁的情况下等待 ISO-TP 连续帧。
对于跨多个任务更新的数据,可以采用版本号或双缓冲:读取开始时记录版本,复制记录后再次比较版本;若版本变化,返回 0x22、重新采样,或按项目定义发送 0x78 后重试。不能把半更新的 16 位数值拆成两个字节直接发出去。
6. P2/P2*、ISO-TP 和服务状态机
6.1 时序边界
服务端 P2Server 从完整请求接收后计时,约束到响应开始发送,不能从第一帧 CAN 到达就简单计时。无法在当前窗口内开始最终响应时,框架需及时安排 7F 22 78,随后以 P2*Server 监督下一响应开始。客户端在请求发送完成后装载 P2Client,收到合法 0x78 后转入 P2*Client;再次收到 0x78 时重新装载扩展等待窗口,期间不重发请求。服务器数值通常来自 0x10 会话参数,客户端还要加入传输与调度裕量,不能把工具默认的 50 ms 或 5 s 写成统一标准值。
正响应的发送还要经过 ISO-TP:长响应先由传输层发送 First Frame,等待客户端 Flow Control,再发送 Consecutive Frame。首帧在应用时限内开始,不要求所有连续帧都在 P2 内收完。N_Bs、N_Cr 等 N 系列计时器监督传输等待,STmin 约束连续帧最小间隔;STmin 本身不是超时计时器。测试脚本应把这些事件分别记录。
6.2 推荐的状态机
服务状态与响应决策
| 状态 | 完成条件或响应动作 |
|---|---|
| 接收和校验 | 完整请求长度、DID 配对和数量上限;无效返回 7F 22 13。 |
| 逐 DID 门控 | 跳过当前会话不支持项;支持项需通过认证、安全和条件检查。 |
| 组包前检查 | 全部不支持返回 31;合计响应超过连接能力返回 14。 |
| 读取稳定记录 | 数据未就绪,保留事务并由框架在当前窗口内安排 78。 |
| 异步继续 | 继续监督 P2*,不覆盖原请求;完成后发送最终响应。 |
| 发送和释放 | 62 正响应或最终 NRC,经 ISO-TP 发送;取消/断链也释放上下文。 |
服务状态机应绑定一个诊断连接和一个请求上下文。新的物理寻址请求到来时,不能覆盖尚未完成的 0x22 上下文;功能寻址下如果项目允许多个 ECU 响应,还要在测试端按 CAN ID 分开维护状态。
7. C 语言实现:先规划响应,再读取数据
在 AUTOSAR 中,CanTp 负责 ISO-TP;DCM 的 DSL 管理连接/时序,DSD 分发服务,DSP 处理 DID 读取。应用或 NvM 提供记录;DEM 只有在该 DID 确实映射到故障数据时才参与。下面是项目接口骨架,接口名不代表 AUTOSAR 标准 API。
代码只演示固定长度静态 DID。诊断框架先完成连接准入与认证门控,每个 DID 再检查会话、安全和条件。DID 数量、会话位图、响应容量都是本例的资源配置。
7.1 数据字典和内部结果
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#define RDB_MAX_DIDS 8u
typedef enum { READ_OK, READ_BUSY, READ_ERROR } ReadRc;
typedef enum {
RDB_READY, RDB_PENDING, RDB_INTERNAL, RDB_ARGUMENT
} RdbRc;
/* snapshot 属于当前事务,回调必须不阻塞且不越界。
* 多次调用应读取同一稳定版本;READ_BUSY 时由框架稍后重试。 */
typedef ReadRc (*ReadDid)(void *snapshot, uint8_t *dst,
size_t cap, size_t *written);
typedef bool (*CheckDid)(void *snapshot);
typedef struct {
uint16_t did;
size_t length;
uint8_t session_mask;
bool secured;
CheckDid condition; /* NULL 表示无额外条件 */
ReadDid read;
} DidEntry;
typedef struct {
uint8_t session_bit; /* 例如 1、2;不是线上会话号 */
bool unlocked;
void *snapshot;
} ReadEnv;
static const DidEntry *FindDid(const DidEntry *table, size_t count,
uint16_t did)
{
for (size_t i = 0u; i < count; ++i)
if (table[i].did == did) return &table[i];
return NULL;
}
static RdbRc Negative22(uint8_t *p, size_t *n, uint8_t nrc)
{
p[0] = 0x7Fu; p[1] = 0x22u; p[2] = nrc;
*n = 3u;
return RDB_READY;
}
RDB_PENDING 是调度结果,表示这次调用尚未构造最终响应,不能直接当作“已发 0x78”。RDB_INTERNAL 表示字典或底层接口违反契约,由框架记录原因并进入明确的故障处理路径;不随意映射成清除/编程相关的 0x72。
7.2 先筛选支持项并完成访问检查
RdbRc Handle22(const uint8_t *req, size_t req_len,
uint8_t *rsp, size_t cap, size_t *rsp_len,
const DidEntry *table, size_t table_count,
const ReadEnv *env)
{
const DidEntry *plan[RDB_MAX_DIDS];
size_t used = 0u, total = 1u;
if (rsp_len != NULL) *rsp_len = 0u;
if ((req == NULL) || (rsp == NULL) || (rsp_len == NULL) ||
(table == NULL) || (env == NULL) || (cap < 3u))
return RDB_ARGUMENT;
if ((req_len == 0u) || (req[0] != 0x22u))
return RDB_ARGUMENT; /* 由服务分发器保证 SID */
if ((req_len < 3u) || (((req_len - 1u) % 2u) != 0u))
return Negative22(rsp, rsp_len, 0x13u);
size_t requested = (req_len - 1u) / 2u;
if (requested > RDB_MAX_DIDS)
return Negative22(rsp, rsp_len, 0x13u);
for (size_t i = 0u; i < requested; ++i) {
size_t at = 1u + 2u * i;
uint16_t did = ((uint16_t)req[at] << 8u) | req[at + 1u];
const DidEntry *e = FindDid(table, table_count, did);
if ((e == NULL) || ((e->session_mask & env->session_bit) == 0u))
continue; /* 当前会话不支持,跳过这个 DID */
if (e->secured && !env->unlocked)
return Negative22(rsp, rsp_len, 0x33u);
if ((e->condition != NULL) && !e->condition(env->snapshot))
return Negative22(rsp, rsp_len, 0x22u);
if ((e->read == NULL) || (e->length == 0u)) return RDB_INTERNAL;
plan[used++] = e; /* 保留请求次序和重复次数 */
}
if (used == 0u) return Negative22(rsp, rsp_len, 0x31u);
7.3 长度预检后才允许写数据
下面的代码接在 Handle22 内。cap 在本例代表该连接可完成发送的配置响应上限,缓冲区至少有这么大。若量产实现使用分页或流式发送,就需要独立计算传输上限,不能把临时页缓冲区大小当成 0x14 的条件。
/* 续接上面的 Handle22 函数 */
for (size_t i = 0u; i < used; ++i) {
if (((cap - total) < 2u) ||
(plan[i]->length > (cap - total - 2u)))
return Negative22(rsp, rsp_len, 0x14u);
total += 2u + plan[i]->length;
}
rsp[0] = 0x62u;
size_t pos = 1u;
for (size_t i = 0u; i < used; ++i) {
const DidEntry *e = plan[i];
size_t written = 0u;
ReadRc rc = e->read(env->snapshot, &rsp[pos + 2u],
e->length, &written);
if (rc == READ_BUSY) return RDB_PENDING;
if ((rc != READ_OK) || (written != e->length)) return RDB_INTERNAL;
rsp[pos] = (uint8_t)(e->did >> 8u);
rsp[pos + 1u] = (uint8_t)e->did;
pos += 2u + written;
}
*rsp_len = pos;
return RDB_READY;
}
只有 RDB_READY 才发送 rsp_len 字节。返回 PENDING/INTERNAL 时长度仍为 0,前面已经写入的临时数据不能被发送。底层回调必须遵守容量契约;返回后检查 written 无法补救已经发生的越界写入。
异步重试必须保留请求、稳定快照和连接归属,避免把前半段旧数据和后半段新数据拼起来。框架在 P2Server/P2*Server 截止前安排挂起响应,并在取消、断链、复位和 ISO-TP 发送失败时释放上下文。同步读取不能在服务函数里自旋等 NvM。
7.4 客户端按预期 DID 顺序和长度解析
以下 expected 是本次请求在当前会话下应返回的支持项列表,可以含重复 DID。需要检查不支持项是否被正确跳过时,测试脚本先按诊断数据库构造这个列表;未知长度的响应不能直接猜着拆。
typedef struct { uint16_t did; size_t length; } ExpectedDid;
typedef struct {
uint16_t did;
const uint8_t *data;
size_t length;
} DidView;
typedef enum {
RDB_OK, RDB_ARG, RDB_PROTOCOL, RDB_OUTPUT_FULL,
RDB_TRANSPORT, RDB_P2_TIMEOUT, RDB_P2STAR_TIMEOUT,
RDB_TOTAL_TIMEOUT, RDB_NEGATIVE
} ClientRc;
static ClientRc Parse22(const uint8_t *p, size_t n,
const ExpectedDid *expected, size_t count,
DidView *views, size_t view_cap, size_t *view_count)
{
if (view_count != NULL) *view_count = 0u;
if ((p == NULL) || (expected == NULL) || (views == NULL) ||
(view_count == NULL) || (count == 0u)) return RDB_ARG;
if ((n < 4u) || (p[0] != 0x62u)) return RDB_PROTOCOL;
if (count > view_cap) return RDB_OUTPUT_FULL;
size_t pos = 1u;
for (size_t i = 0u; i < count; ++i) {
if (((n - pos) < 2u) || (expected[i].length == 0u))
return RDB_PROTOCOL;
uint16_t did = ((uint16_t)p[pos] << 8u) | p[pos + 1u];
pos += 2u;
if ((did != expected[i].did) || (expected[i].length > (n - pos)))
return RDB_PROTOCOL;
views[i] = (DidView){did, &p[pos], expected[i].length};
pos += expected[i].length;
}
if (pos != n) return RDB_PROTOCOL;
*view_count = count;
return RDB_OK;
}
解析器严格检查顺序、长度、重复次数和尾部剩余字节。失败时 view_count 保持 0,调用者不能消费部分结果;视图引用响应缓冲区,缓冲区失效后也必须停止使用。变长 DID 要换成显式长度解码回调。
7.5 P2/P2* 等待不混进字段解析器
这层针对物理寻址,一条连接同时只允许一个事务。receive 收取完整 UDS PDU,等待参数约束响应开始;多帧开始后由 ISO-TP 的 N 系列计时器监督。适配层必须保证有限时间返回,并同时遵守传入的总事务剩余预算。
typedef enum { RX_PDU, RX_TIMEOUT, RX_TOO_LARGE, RX_ERROR } RxRc;
typedef struct {
void *ctx;
uint32_t (*now_ms)(void *);
RxRc (*receive)(void *, uint8_t *, size_t, size_t *,
uint32_t start_wait_ms, uint32_t total_left_ms);
} ReadIo;
typedef struct {
uint32_t p2_ms, p2star_ms, total_ms;
} ReadTiming;
static ClientRc Wait22(const ReadIo *io, const ReadTiming *t,
uint8_t *p, size_t cap, size_t *length, uint8_t *nrc)
{
if (length != NULL) *length = 0u;
if ((io == NULL) || (t == NULL) || (p == NULL) || (length == NULL) ||
(nrc == NULL) || (io->now_ms == NULL) || (io->receive == NULL) ||
(cap < 3u) || (t->p2_ms == 0u) || (t->p2star_ms == 0u) ||
(t->total_ms == 0u) || (t->p2_ms >= UINT32_C(0x80000000)) ||
(t->p2star_ms >= UINT32_C(0x80000000)) ||
(t->total_ms >= UINT32_C(0x80000000))) return RDB_ARG;
*nrc = 0u;
uint32_t start = io->now_ms(io->ctx);
uint32_t stage = start, window = t->p2_ms;
bool pending = false;
for (;;) {
uint32_t now = io->now_ms(io->ctx);
uint32_t elapsed = now - start, waited = now - stage;
if (elapsed >= t->total_ms) return RDB_TOTAL_TIMEOUT;
if (waited >= window)
return pending ? RDB_P2STAR_TIMEOUT : RDB_P2_TIMEOUT;
uint32_t left = t->total_ms - elapsed;
uint32_t wait = window - waited;
if (wait > left) wait = left;
size_t n = 0u;
RxRc rc = io->receive(io->ctx, p, cap, &n, wait, left);
if ((io->now_ms(io->ctx) - start) >= t->total_ms)
return RDB_TOTAL_TIMEOUT;
if (rc == RX_TIMEOUT)
return pending ? RDB_P2STAR_TIMEOUT : RDB_P2_TIMEOUT;
if (rc == RX_TOO_LARGE) return RDB_OUTPUT_FULL;
if (rc != RX_PDU) return RDB_TRANSPORT;
if ((n == 0u) || (n > cap)) return RDB_PROTOCOL;
if (p[0] == 0x7Fu) {
if ((n != 3u) || (p[1] != 0x22u)) return RDB_PROTOCOL;
if (p[2] != 0x78u) {
*nrc = p[2];
return RDB_NEGATIVE;
}
pending = true;
stage = io->now_ms(io->ctx);
window = t->p2star_ms;
continue;
}
if (p[0] != 0x62u) return RDB_PROTOCOL;
*length = n;
return RDB_OK; /* 随后交给 Parse22 做完整字段校验 */
}
}
调用顺序是:独占连接 → ISO-TP 完成请求发送 → Wait22 → Parse22 → 释放连接并清理残留接收状态。发送失败不要调用等待器;任一路径返回后都要释放资源。total_ms 是项目防止无限挂起的总期限;32 位单调毫秒计数允许一次回绕,本例预算均限制在半个计数周期内。
0x22 没有子功能,因此不能把 DID 高字节 bit 7 当成抑制正响应位。功能寻址时还要执行第 8.7 节的通用负响应抑制规则:例如没有发过 pending 的 0x31 通常不在线上返回;不能因为某个 ECU 静默就判断它的 DID 读到了空值。需要完整长响应时,优先物理寻址逐 ECU 查询,并按网络配置验证 FF/FC/CF 通道。
8. 掉电、并发和非易失性存储
0x22 是读服务,但“读到什么”仍然可能受 NvM 和应用任务时序影响:
- VIN、软件版本等静态数据通常从 NvM 启动加载到 RAM,再由 DID 回调读取。
- 车辆电压、转速、温度等实时值可能来自周期任务快照,而不是直接访问外设寄存器。
- 故障状态或生产配置在 NvM 写入尚未完成时,读取结果可能处于旧版本、过渡版本或“数据未准备好”状态。
RAM 快照可以采用临界区复制、不可变缓冲区或带版本的同步方案;NvM 块再按项目要求使用 CRC 和恢复策略。读取前后比较版本只是一部分:对共享内存还必须满足平台内存序和同步要求,volatile 不能代替互斥或原子操作。版本不一致时重试或按项目条件路径结束,不能发送半更新数据。不要把“读取函数返回成功”理解为数据一定已经落盘。
服务端还要考虑以下并发场景:
- 同一诊断连接上一个 0x22 尚未结束时,新的请求是否排队、覆盖还是返回忙碌错误。
- DTC 监控任务更新状态时,0x22 读取的快照是否允许短暂不一致。
- 多个诊断连接同时读取大块 DID 时,是否共享响应缓冲区,是否会互相覆盖。
- ISO-TP 发送长响应时,是否释放应用锁,避免连续帧等待阻塞 NvM 或监控任务。
9. 测试用例和验证方法
编号: TC-01
场景: 单 DID 短数据
请求/前置条件: 22 F1 89,默认会话
预期结果: 62 F1 89 ...
验证重点: SID、DID 回显、记录长度
编号: TC-02
场景: VIN 长响应
请求/前置条件: 22 F1 90
预期结果: ISO-TP FF/FC/CF,最终 62 F1 90 + 17 字节
验证重点: 首帧长度、SN、填充字节
编号: TC-03
场景: 多 DID
请求/前置条件: 22 01 0A 01 10
预期结果: 一条正响应按字典顺序返回两组记录
验证重点: DID 边界和总长度
编号: TC-04
场景: 重复 DID
请求/前置条件: 22 F1 90 F1 90,数量/容量合法
预期结果: 返回两组 DID 记录,不能静默去重
验证重点: 顺序、重复次数和稳定快照
编号: TC-05
场景: 缺少 DID 低字节
请求/前置条件: 22 F1
预期结果: 7F 22 13
验证重点: 不把缺失的 DID 低字节当成 0
编号: TC-06
场景: 未知 DID
请求/前置条件: 22 FF FF
预期结果: 7F 22 31
验证重点: 诊断数据库和会话映射
编号: TC-07
场景: 安全未解锁
请求/前置条件: 请求受保护 DID
预期结果: 7F 22 33
验证重点: 0x27 等级、锁定状态
编号: TC-08
场景: 条件不满足
请求/前置条件: 车辆数据快照无效
预期结果: 7F 22 22 或项目规定的等待流程
验证重点: 有效标志和 P2/P2*
编号: TC-09
场景: 响应超长
请求/前置条件: 请求数量合法,合计响应超过连接能力
预期结果: 7F 22 14
验证重点: 完整长度计算,不返回截断成功报文
编号: TC-10
场景: Flow Control 异常
请求/前置条件: 客户端不发 FC、CF 丢失或序号错误
预期结果: 传输层超时,服务上下文释放
验证重点: N_Bs/N_Cr、重入恢复
编号: TC-11
场景: 版本切换并发
请求/前置条件: 应用任务更新快照时读取
预期结果: 稳定快照或明确 0x22
验证重点: 版本号、锁和双缓冲
编号: TC-12
场景: 掉电恢复
请求/前置条件: NvM 写入后立即复位,再读取 DID
预期结果: 结果符合项目 NvM 恢复策略
验证重点: CRC、有效标志和旧版本回退
编号: TC-13
场景: DID 数量超限
请求/前置条件: 一次请求 9 个 DID,本例上限 8
预期结果: 7F 22 13
验证重点: 与响应过长分开验证
编号: TC-14
场景: 部分不支持
请求/前置条件: 支持 F190,不支持 1234;22 F1 90 12 34
预期结果: 只返回 F190 记录
验证重点: 无空记录、无静默安全过滤
编号: TC-15
场景: pending 后成功
请求/前置条件: 多次 7F 22 78 后 62 ...
预期结果: 客户端不重发,最终严格解析
验证重点: P2* 重装载、总期限
编号: TC-16
场景: 异常正响应
请求/前置条件: DID 错序、尾部多字节、记录截断
预期结果: 客户端协议错误
验证重点: 不输出部分结果
抓包验证时,建议同时记录三份日志:原始 CAN 帧、ISO-TP 重组后的 UDS PDU、按 DID 解码后的业务值。这样能快速判断问题位于总线、传输层还是数据字典,而不是只看诊断工具最后显示的一行字符串。
10. 工程边界和常见误区
- 把
03、10 14这样的 ISO-TP PCI 当成 0x22 的 DID 或数据。 - 把 CAN ID
0x7E0/0x7E8写成 ISO 14229 对 0x22 的固定要求。 - 认为所有 DID 都是两字节整数,忽略 ASCII、缩放值、枚举、位域和变长记录。
- 只判断正响应 SID
0x62,不检查 DID 回显、记录长度和字节序。 - 把
F190、010A等常见示例值域直接复制到另一个 ECU,而没有核对 ODX 和诊断数据库。 - 收到一个受保护 DID 后,客户端反复重试请求,却没有先确认会话和 0x27 安全等级。
- 把
0x14 responseTooLong当作“单帧装不下”的同义词,忽略 DID 数量、服务器缓存和 ISO-TP 配置。 - 在读取回调中同步等待 NvM 或应用锁,导致 P2 超时,随后又把 ISO-TP 超时误判为 ECU 不支持 0x22。
- 用最大记录长度盲目切分响应,导致一个变长 DID 的尾部被错当成下一个 DID。
- 在 ECU 返回
7F 22 78后立即结束测试,不等待最终响应,也没有记录P2*内的重复 pending 行为。
11. 结语
ReadDataByIdentifier 的服务号只有一个字节,工程难点却集中在 DID 数据字典和分层边界。先用 ISO-TP 重组出完整 UDS PDU,再按 DID 的访问矩阵和记录长度解析;遇到负响应时按长度、权限、条件和响应容量逐层排查。对实时数据建立一致性快照,对长响应分别验证 P2/P2* 和 ISO-TP 超时,才能让 0x22 的抓包、代码和测试结果彼此对应。
12. 标准与验证依据
- ISO 14229-1:2020,第 11.2 节、表 186~190:0x22 请求、响应与服务级 NRC。
- ISO 14229-1:2020,图 20:多 DID 的会话支持、认证、安全、条件与响应长度检查顺序。
- ISO 14229-1:2020,第 8.7 节、附录 C.1:通用请求/响应行为和 DID 定义边界。
- CAN 承载和传输参数另按项目适用版本的 ISO 14229-3、ISO 15765-2 及网络矩阵配置核对。



