UDS CAN诊断:DynamicDefineDataIdentifier(0x2C)
台架脚本希望把三个信号合并读取。第一次发送定义请求,ECU 返回了 6C;脚本随后又向同一个动态 DID 发送了一次定义,读取结果却多出了一组重复数据。另一个版本在追加请求被拒绝后,连第一次定义成功的数据也读不到了。
这两个现象都要回到同一个问题:动态 DID 保存的是怎样一份描述表,一次请求到底对它做了什么?本文从报文出发,把定义、追加、清除和后续读取串起来,再落到 ECU 的提交边界、客户端超时处理以及 C 语言实现。
0x2C 保存的是数据描述,0x22 才负责读取
DynamicallyDefineDataIdentifier 允许客户端在 ECU 内定义一个动态数据标识符。它把若干源数据按约定顺序组合成新的数据记录,便于后续集中读取。
源数据可以通过源 DID 引用,也可以通过地址和长度引用。同一个目标动态 DID 可以经过多次请求完成定义,并且可以混合这两种来源。这里保存的是“以后去哪里取、取多少字节”的描述,定义时返回的 6C 不包含信号当前值。
| 服务 | 本次事务做什么 | 成功后得到什么 |
|---|---|---|
| 0x2C | 定义、追加或清除动态 DID | 定义操作的确认 |
| 0x22 | 读取指定 DID 的数据记录 | 本次读取的数据 |
| 0x2A | 启动、调整或停止周期传输 | 初始确认,以及随后独立传送的周期数据 |
因此,“收到 6C”和“读到三个信号”是两个不同的验收点。先检查定义有没有被接受,再检查后续读取服务是否支持该 DID、当前权限是否满足、源数据是否可用。
ISO 14229-1:2020 附录 C.1 将 F200~F2FF 分配给周期数据标识符,它们可以静态定义或动态定义;F300~F3FF 用于动态定义数据标识符。0x2C 的请求表使用 F2xx/F3xx 作为目标范围,但具体支持哪些编号,仍要核对 ECU 配置。
需要通过 0x2A 周期发送时,目标应位于受支持的 F2xx 范围,PDID 是其低字节。F301 可以是动态 DID,但不能仅取低字节 01,就声称 2A 03 01 会读取它。
0x2C 不为组合记录附带换算、单位或字段类型。客户端要保留每次定义的顺序、片段长度和源 DID 数据定义;动态 DID 的数据布局随这份定义变化。
定义与数据读取的职责
| 路径 | 内容 |
|---|---|
| 01 定义清单 | 客户端记录目标、来源、起始位置和各片段长度。 |
| 02 配置 | 0x2C 将清单写入 ECU 动态 DID 描述,确认不携带当前信号值。 |
| 03 按需读取 | 0x22 使用描述访问源 DID 或受控内存读取接口。 |
| 04 周期读取 | 0x2A 使用受支持 F2xx 定义,在周期通道中传送数据。 |
| 05 数据解释 | 按成功定义顺序连接记录,客户端用已保存的定义解释输出。 |
先完成一次定义,再读回结果
假设 ECU 支持以下源数据。这里的编号和字节值沿用标准第 11.6.5 节示例的部分内容,只用于解释拼接关系,不表示所有 ECU 都用这些 DID。
| 源 DID | 数据记录 | 本例取用方式 |
|---|---|---|
| 1234 | 4C 36,2 字节 | 从第 1 字节开始,取 2 字节 |
| 5678 | 4D,1 字节 | 从第 1 字节开始,取 1 字节 |
在 F301 尚未定义的前提下,一次请求定义两项:
定义、读取与清除的应用层报文
| 序号 | 报文或约束 |
|---|---|
| 1 | 定义请求:2C 01 F3 01 12 34 01 02 56 78 01 01 |
| 2 | 定义确认:6C 01 F3 01 |
| 3 | 读取请求:22 F3 01 |
| 4 | 读取响应:62 F3 01 4C 36 4D |
| 5 | 清除请求:2C 03 F3 01 |
| 6 | 清除确认:6C 03 F3 01 |
62 F3 01 之后只有数据,不会再次插入源 DID 1234、5678,也不会插入两个片段的长度。客户端必须根据自己保存的定义解释后面的 4C 36 4D。
动态 DID 定义后,各次读取的数值仍可能变化。0x2C 没有把定义时的三个数据字节冻结下来,也没有承诺各个源数据在同一个采样时刻取得。
0x01:按源 DID 定义,位置从数据记录的第 1 字节算起
defineByIdentifier 的请求由 4 字节头部和一个或多个 4 字节源片段构成。
| UDS 字节位置,从 1 开始 | 字段 | 本例 | 解析要求 |
|---|---|---|---|
| 1 | Request SID | 2C | 固定服务标识 |
| 2 | SubFunction | 01 | 低 7 位为定义方式,bit7 为正响应抑制位 |
| 3~4 | 目标动态 DID | F3 01 | 高字节在前 |
| 5~6 | 第一个源 DID | 12 34 | 高字节在前 |
| 7 | positionInSourceDataRecord | 01 | 第一个数据字节的位置为 1 |
| 8 | memorySize | 02 | 取 2 个数据字节 |
| 9~12 | 第二个源片段 | 56 78 01 01 | 结构与第一项相同 |
对包含 m 个源片段的请求,UDS PDU 长度为 4 + 4 × m,且 m ≥ 1。长度不满足这个结构时先按格式错误处理;不能先把缺失的尾部字节当成 00 补齐。
position **
不包含 0x62,也不包含源 DID
假设一个源 DID 的读取响应为 62 12 34 4C 36,position=1 指向 4C,而不是 62、12 或 34。
设源数据记录长度为 L、起始位置为 p、长度为 s,边界检查可以写成:
源数据片段的边界检查
| 序号 | 报文或约束 |
|---|---|
| 1 | p >= 1 |
| 2 | s 在该 ECU 接受的有效范围内 |
| 3 | p <= L |
| 4 | s <= L - (p - 1) |
后一种减法检查便于避免不受控的加法溢出。执行检查前,必须已经确认 p ≥ 1、p ≤ L。
对只有一个完整数据元素的源 DID,标准要求位置设为 1,并覆盖完整元素。对包含多个元素的复合数据记录,可以选择其中的完整元素。只取一个 16 位转速的低字节,即使没有超出缓冲区,也不满足保持完整数据元素的要求。
手头这份 2020 文档有一处长度表述不一致
本地 PDF 的表 240 续表写着 memorySize shall be greater than 1,但表 236 的值域从 01 开始,表 247、251 等示例也实际使用 memorySize=01。这不是字节序问题,而是该文档内部的文字与表格、示例不一致。
本文的一字节片段示例与请求表和示例表保持一致,后面的客户端代码允许非零长度。项目符合性评审应记录这处差异,核对项目采用的标准修订、勘误及 OEM 诊断规范,再确定验收口径;不能把这处冲突悄悄改写成一条没有例外的通用要求。
0x02:地址定义中的 ALFID 只出现一次
defineByMemoryAddress 使用同一个 ALFID 描述该请求中所有地址/长度对的编码宽度。
按地址定义的请求字段顺序
| 序号 | 报文或约束 |
|---|---|
| 1 | 2C 02 TargetDID_H TargetDID_L ALFID |
| 2 | Address1 Size1 |
| 3 | Address2 Size2 ... |
| ALFID 部分 | 含义 | 0x14 对应值 |
|---|---|---|
| 高半字节 bit7~bit4 | 每个 memorySize 字段占几个字节 | 1 字节长度 |
| 低半字节 bit3~bit0 | 每个 memoryAddress 字段占几个字节 | 4 字节地址 |
下面是一个项目地址示例,假设 ECU 明确开放了相应只读 RAM 窗口:
按地址定义的完整请求与确认
| 序号 | 报文或约束 |
|---|---|
| 1 | 2C 02 F3 02 14 20 00 00 10 02 20 00 00 20 04 |
| 2 | 6C 02 F3 02 |
| 段 | 地址 | 字节数 |
|---|---|---|
| 第 1 段 | 0x20000010 | 2 |
| 第 2 段 | 0x20000020 | 4 |
地址宽度 a=4,长度宽度 s=1,请求包含两项,因此 UDS 长度为 5 + 2 × (4 + 1) = 15 字节。ALFID 不是每个地址前都重复一次。
两个多字节字段均按高有效字节在前传输。标准允许地址的最高有效字节部分用于 memory identifier,因此诊断地址不一定等于 MCU 的原生指针。驱动适配时要先完成地址空间解释,再做白名单、长度及端点检查。
标准第 11.6.1 节建议绝对地址方式仅用于服务器开发阶段。量产项目若支持它,应在 ECU 诊断规范中明确开放条件和区域,使用受控读取接口;读取内存映射寄存器还可能产生副作用,不能统一按普通 RAM 处理。
地址宽度、长度宽度和实际读出的内容长度是三件事。ALFID=14 不表示读 14 字节,也不表示每段读 4 字节。
对同一个 DID 再定义一次,含义是追加
这是 0x2C 实现和测试中最值得单独检查的行为。
ISO 14229-1:2020 第 11.6.1 节规定,可以通过一次或多次请求定义同一个动态 DID,服务器按定义顺序连接各项。要重新定义,应先清除原定义,再建立新的定义。
例如以下三步,第二步会在第一步之后追加:
| 步骤 | 请求 | F301 的描述 |
|---|---|---|
| 初次定义 | 2C 01 F3 01 12 34 01 02 | 1234 的 2 字节 |
| 追加 | 2C 01 F3 01 56 78 01 01 | 上述 2 字节,再接 5678 的 1 字节 |
| 再读 | 22 F3 01 | 返回共 3 字节的组合数据 |
这里的顺序既包括单次请求内的顺序,也包括多次成功请求之间的顺序。0x01 与 0x02 混合使用时同样保留这个关系。
追加失败,之前的定义必须继续有效
标准特别指出,多次定义过程中,如果后续请求使动态 DID 的总数据量超出服务器上限,原定义应保持为请求之前的状态。
因此 ECU 实现适合采用以下提交过程:
- 读取当前定义及其版本;
- 将当前定义复制到候选描述表;
- 在候选表尾部追加本请求的所有片段;
- 核对源数据、权限、片段数、总长度和周期传输约束;
- 在同步保护下确认旧版本未变化,再一次提交候选表。
这个候选表过程属于实现方法;“按序连接”和“超出容量时保留旧定义”则是标准行为。不要将一个只覆盖旧表的赋值函数命名为“追加”,也不要边解析边改动正在使用的描述表。
超时后的重发可能把同一片段再追加一遍
假设 ECU 已提交追加,但 6C 响应在总线上丢失。客户端超时后原样重发,服务器可能把同一段再次追加。0x2C 没有客户端事务编号,不能仅凭相同的请求字节获得“只执行一次”的保证。
客户端应记录不确定结果,先按项目约定停止相关采集、核实定义或清除目标后重新构建。清除后重建会影响使用该 DID 的其他诊断连接,必须先取得项目层面的配置使用权。重发、清除和重建都不应藏在通用“超时重试三次”函数中。
0x03:清除不存在的有效 DID,也要正确确认
clearDynamicallyDefinedDataIdentifier 有两种请求长度:
| 操作 | 请求 | 正响应 |
|---|---|---|
| 清除指定 DID | 2C 03 F3 01 | 6C 03 F3 01 |
| 清除全部动态定义 | 2C 03 | 6C 03 |
对于服务器所支持的有效动态 DID 编号,即使当前没有该定义,清除请求也应得到正响应。编号本身不受支持或无效,与“有效编号当前未定义”是不同情况,前者要按对应错误条件处理。
清除全部时,正响应没有两个 DID 字节。客户端若一律要求 4 字节响应,会把正确的 6C 03 判成长度错误。
当目标正在被周期发送时,表 239 要求先停止这个动态标识符的周期发送,再清除定义。项目应将停止、待发队列处理和描述表回收衔接起来,避免调度任务继续引用失效的定义。清除全部也要处理相关的周期任务,不能只对描述数组执行一次 memset。
SubFunction bit7 为正响应抑制位,例如 2C 83 F3 01 仍表示清除 F301,不能被当成新的子功能 83。抑制正响应不等于跳过错误检查,也不等于不执行操作。已发送 0x78 后的最终响应行为应按第 8.7 节通用规则处理。
0x6C 只回显定义方式和目标,NRC 要回到具体边界
0x2C + 0x40 = 0x6C。正响应回显 SubFunction 的低 7 位;请求存在目标 DID 时回显相同的 DID,请求省略目标 DID 时响应也省略。
定义确认不会返回源 DID 列表,也不会报告组合数据的总长度。客户端要在接收确认前已经保存请求内容,再校验回显是否匹配。
| NRC | 名称 | 本服务的错误条件 | 工程检查点 |
|---|---|---|---|
| 12 | subFunctionNotSupported | 子功能不受支持 | 先屏蔽 bit7,再检查低 7 位 |
| 13 | incorrectMessageLengthOrInvalidFormat | 消息长度错误 | DID 片段是否为完整四字节组,地址段是否完整 |
| 22 | conditionsNotCorrect | 运行条件不满足 | 车辆条件、资源状态及项目定义的操作条件 |
| 31 | requestOutOfRange | 目标/源 DID、地址、位置、长度、ALFID 或容量不合法 | 数据库、访问区间、组合总长和周期帧预算 |
| 33 | securityAccessDenied | 受保护的 DID 或内存尚未解锁 | 目标、各个源及地址的访问权限 |
上表依据第 11.6.4 节表 243。ALFID 值不被接受是 0x31 的明确适用情况;完整字段组被截断则要检查长度错误。两者不要混为“ALFID 相关,所以都返回 13”。
会话门控、安全访问、认证及通用服务检查由诊断框架共同处理。0x22 在这里表示运行条件不正确,不能作为所有“当前会话不开放”的替代码;服务或子功能的会话错误应按通用规则区分。
7F 2C 78 表示请求已接收但尚未完成。客户端进入 P2* 等待,不把它当作最终拒绝,也不在等待期间追加发送同一请求。
CAN 抓包:12 字节请求不能伪装成两条单帧
以下示例采用经典 CAN、11 位 ID、ISO-TP 普通寻址、8 字节 CAN 数据区,填充值为 00。0x7E0/0x7E8 仅是本文地址示例,并非所有 UDS 项目的固定地址。
定义两个片段的完整传输
UDS PDU 为 2C 01 F3 01 12 34 01 02 56 78 01 01,一共 12 字节:
| 顺序 | 方向 / CAN ID | CAN 数据区 | 解释 |
|---|---|---|---|
| 1 | 客户端 → ECU,7E0 | 10 0C 2C 01 F3 01 12 34 | FF:UDS 总长 0x00C,带前 6 字节 |
| 2 | ECU → 客户端,7E8 | 30 00 05 00 00 00 00 00 | FC:CTS,BS=0,STmin=5 ms |
| 3 | 客户端 → ECU,7E0 | 21 01 02 56 78 01 01 00 | CF:序号 1,带剩余 6 字节,末尾 00 填充 |
| 4 | ECU → 客户端,7E8 | 04 6C 01 F3 01 00 00 00 | SF:正响应 UDS 长度为 4 |
从 FF 与 CF 取出的有效应用数据恰好为 6 + 6 = 12 字节。FC 的 30 不是响应 SID,CF 的 21 不是 0x21 诊断服务。
普通寻址经典 CAN 的单帧应用载荷最多 7 字节;即便只定义一个四字节源片段,请求长度也已经是 4 + 4 = 8 字节,需要多帧传输。不能把 08 2C ... 当成这种帧格式下合法的单帧。
清除、读取和负响应
| 方向 | CAN 数据区 | UDS 内容 |
|---|---|---|
| 客户端 → ECU | 04 2C 03 F3 01 00 00 00 | 清除指定 DID,4 字节 |
| ECU → 客户端 | 04 6C 03 F3 01 00 00 00 | 指定清除确认,4 字节 |
| 客户端 → ECU | 02 2C 03 00 00 00 00 00 | 清除全部,2 字节 |
| ECU → 客户端 | 02 6C 03 00 00 00 00 00 | 全部清除确认,2 字节 |
| 客户端 → ECU | 03 22 F3 01 00 00 00 00 | 读取动态 DID,3 字节 |
| ECU → 客户端 | 06 62 F3 01 4C 36 4D 00 | 0x22 响应,6 字节 |
| ECU → 客户端 | 03 7F 2C 31 00 00 00 00 | 0x2C 负响应,3 字节 |
这些行是独立格式示例,读取行需要目标仍处于已定义状态;执行清除后不能直接期待同样的读取结果。
CAN FD 的单帧编码和有效载荷容量不同,扩展寻址或混合寻址还会占用地址字节。检查报文前先确认链路配置,再确定 PCI、UDS PDU 和填充的边界。这里的 FF/CF 规则不要直接套到所有 CAN FD 帧上。
物理寻址适合明确地配置某一个 ECU。功能寻址是否支持、请求长度限制及负响应抑制,应分别依据服务支持矩阵、ISO 14229-3/ISO-TP 配置和第 8.7 节核对,不能假设这个多帧定义请求可原样广播到全车。
P2 从请求发完开始,P2* 不代表无限等待
0x2C 可以即时完成,也可能因项目资源检查或异步处理而发送 0x78。客户端需要分别记录请求传输、首次响应等待和扩展等待。
| 时间段 | 由谁监督 | 处理要点 |
|---|---|---|
| 请求 FF/CF 传输 | ISO-TP 与客户端发送管理 | N 系列计时器、FC、序号、发送确认 |
| 完整请求发送完成后等待响应 | 客户端 P2 | 使用当前会话参数和传输裕量 |
| 收到 7F 2C 78 后 | 客户端 P2* | 等最终响应,后续合法 78 更新等待窗口 |
| 整个事务的项目期限 | 客户端应用 | 防止无限挂起,终止后清理连接状态 |
服务端的 P2/P2* 与客户端的 P2Client/P2*
Client **
要按诊断时序规范对应,不能只把 ECU 在 0x10 中回传的数值当作无裕量的客户端截止值。具体定时关系还需结合 ISO 14229-2 及所用传输映射确认。
定义、挂起与读取时序
| 阶段 | 交互及状态 |
|---|---|
| 01 请求 | ISO-TP 发送完整 2C 请求,ECU 收到完整 PDU。 |
| 02 发送确认 | 客户端得到完整请求发送确认,开始 P2Client。 |
| 03 检查 | ECU 复制旧定义,检查包含本次追加的候选表。 |
| 04 异步等待 | 确需等待时 ECU 发送 7F 2C 78;客户端进入 P2*,总期限继续。 |
| 05 完成 | 候选表检查及提交完成,返回 6C + 子功能 + 目标 DID。 |
| 06 读取 | 客户端随后发送 22 + 目标 DID;ECU 返回 62、目标和当前数据。 |
这个时序图将 ISO-TP 发送确认作为客户端事件表示;实际接口和事件到达顺序由传输适配层实现。不能在仅把请求塞进发送队列时,就假定多帧传输已经结束。
ECU 端落地:定义、读取和周期调度使用同一份版本
在 AUTOSAR 项目中,CanTp 完成传输重组,DCM 的 DSL 管理连接和时序、DSD 分发服务、DSP 处理服务参数,再调用配置及
数据接口 **
。动态 DID 描述的具体管理方式取决于所用栈。源 DID 如需读取事件统计,可通过项目数据接口访问 DEM;持久化才涉及 NvM、EEPROM 或 Flash。
这些模块名称属于软件架构,不是 ISO 14229 强制要求。非 AUTOSAR 项目仍应保持传输、诊断分发、定义管理和
数据读取
的职责边界。
保留旧版本的追加提交
| 步骤或分支 | 处理结果 |
|---|---|
| 01 准入 | 完整请求携带连接上下文,检查格式、会话、权限及运行条件。 |
| 02 候选表 | 读取旧定义及版本,将本次全部片段追加到候选表。 |
| 03 验证 | 核对源数据、长度、容量和周期链路预算;失败则丢弃候选表,旧版本继续有效。 |
| 04 冲突检查 | 提交前检查旧版本是否变化;变化时重检或按项目规则结束,避免丢失并发更新。 |
| 05 提交 | 在同步保护下切换现行定义,返回 6C 或按通用规则抑制正响应。 |
定义时检查一次,读取时还要处理上下文变化
0x2C 定义时要核对目标、源 DID 和地址的访问权限。之后可能发生会话迁移、安全重新锁定、源数据配置变化或 ECU 复位。项目应明确动态定义保留与失效规则,并让 0x22/0x2A 按当前访问上下文处理,防止组合 DID 成为绕过源权限的入口。
定义阶段通常保存描述,数据阶段才真正读取。不要在处理地址定义时直接执行不受控的指针解引用;也不要把当时读出的数值复制到描述中,随后一直返回同一份旧值。
标准没有禁止动态 DID 引用另一个动态 DID,但明确不推荐,因为被引用定义被删除后会造成一致性问题。项目可以限定来源、维护依赖或限制引用深度;应将策略写入支持矩阵,而不是宣称标准一律禁止嵌套。
周期数据的上限按实际链路映射计算
表 243 将“动态周期数据的总长度放不进所用链路的单帧”列为 0x31 条件。应先计算组合数据长度,再加入 PDID 和实际传输映射开销。
0x22 响应可以多帧,不代表同一份动态周期数据也能用 FF/CF 发出去。周期发送的链路格式和容量要单独核对;不能用本文普通 0x22 单帧的 7 字节预算,替代项目周期通道的容量计算。
C 语言客户端:构造请求、确认回显、监督超时
下面实现物理连接上的单事务客户端。它提供按 DID 定义、按地址定义、清除三种构造函数,并处理发送确认、0x78、最终响应、取消与超时。CAN 和 ISO-TP 留给可替换适配接口。
本例主动请求正响应,构造的 SubFunction bit7 固定为 0,DdStart 也拒绝带抑制位的请求。这是客户端接口选择;标准允许的抑制行为已经在前文说明。
类型、资源上限与大端编码
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#define DD_REQUEST_CAP 128u
typedef enum {
DD_IDLE, DD_RUNNING, DD_OK, DD_NEGATIVE, DD_ARGUMENT,
DD_BUSY, DD_NO_SPACE, DD_PROTOCOL, DD_TX_ERROR,
DD_TX_TIMEOUT, DD_P2_TIMEOUT, DD_P2STAR_TIMEOUT,
DD_TOTAL_TIMEOUT, DD_TP_ERROR, DD_CANCELLED
} DdResult;
typedef struct { uint16_t did; uint8_t position, size; } DdSlice;
typedef struct { uint32_t address, size; } DdMemory;
typedef struct { uint32_t tx_ms, p2_ms, p2star_ms, total_ms; } DdTiming;
typedef struct {
void *ctx;
bool (*send)(void *, const uint8_t *, size_t);
void (*finish)(void *, bool discard_rx);
} DdIo;
typedef struct {
DdIo io;
DdTiming timing;
uint8_t request[DD_REQUEST_CAP];
size_t request_len;
uint32_t started, stage;
DdResult result;
uint8_t nrc;
bool active, waiting, pending;
} DdClient;
static bool DdDynamic(uint16_t did)
{
return (did >= 0xF200u) && (did <= 0xF3FFu);
}
static void DdPutBe(uint8_t *p, uint32_t value, size_t bytes)
{
while (bytes != 0u) {
p[--bytes] = (uint8_t)value;
value >>= 8;
}
}
static bool DdFits(uint32_t value, size_t bytes)
{
return (bytes == 4u) ||
((bytes >= 1u) && (bytes < 4u) &&
(value < (UINT32_C(1) << (8u * bytes))));
}
AI代码解释
cpp
运行
DD_REQUEST_CAP=128 是本例资源上限,不是标准上限。地址与长度构造函数使用 uint32_t,支持 1~4 字节字段;需要更宽地址或带更长 memory identifier 的项目,应扩展类型及编码函数,不能静默截断。
DdClient 使用 {0} 初始化。对同一实例的开始、发送确认、接收、轮询和取消事件,统一在一个任务上下文中按顺序调用;中断回调可先投递事件。布尔成员不是锁。
三种报文构造函数
DdResult DdByDid(uint8_t *out, size_t cap, size_t *length,
uint16_t target, const DdSlice *slices, size_t count)
{
if (length == NULL) return DD_ARGUMENT;
*length = 0u;
if ((out == NULL) || (slices == NULL) || (count == 0u) ||
!DdDynamic(target)) return DD_ARGUMENT;
if ((cap < 4u) || (count > (cap - 4u) / 4u)) return DD_NO_SPACE;
for (size_t i = 0u; i < count; ++i) {
if ((slices[i].position == 0u) || (slices[i].size == 0u))
return DD_ARGUMENT;
}
out[0] = 0x2Cu; out[1] = 0x01u;
DdPutBe(out + 2u, target, 2u);
for (size_t i = 0u; i < count; ++i) {
size_t offset = 4u + i * 4u;
DdPutBe(out + offset, slices[i].did, 2u);
out[offset + 2u] = slices[i].position;
out[offset + 3u] = slices[i].size;
}
*length = 4u + 4u * count;
return DD_OK;
}
DdResult DdByMemory(uint8_t *out, size_t cap, size_t *length,
uint16_t target, uint8_t alfid,
const DdMemory *items, size_t count)
{
size_t a = alfid & 0x0Fu, s = alfid >> 4;
if (length == NULL) return DD_ARGUMENT;
*length = 0u;
if ((out == NULL) || (items == NULL) || (count == 0u) ||
!DdDynamic(target) || (a == 0u) || (a > 4u) ||
(s == 0u) || (s > 4u)) return DD_ARGUMENT;
if ((cap < 5u) || (count > (cap - 5u) / (a + s)))
return DD_NO_SPACE;
for (size_t i = 0u; i < count; ++i) {
if ((items[i].size == 0u) || !DdFits(items[i].address, a) ||
!DdFits(items[i].size, s)) return DD_ARGUMENT;
}
out[0] = 0x2Cu; out[1] = 0x02u; out[4] = alfid;
DdPutBe(out + 2u, target, 2u);
for (size_t i = 0u; i < count; ++i) {
size_t offset = 5u + i * (a + s);
DdPutBe(out + offset, items[i].address, a);
DdPutBe(out + offset + a, items[i].size, s);
}
*length = 5u + count * (a + s);
return DD_OK;
}
DdResult DdClear(uint8_t *out, size_t cap, size_t *length,
bool all, uint16_t target)
{
if (length == NULL) return DD_ARGUMENT;
*length = 0u;
if ((out == NULL) || (!all && !DdDynamic(target))) return DD_ARGUMENT;
if (cap < (all ? 2u : 4u)) return DD_NO_SPACE;
out[0] = 0x2Cu; out[1] = 0x03u;
if (!all) DdPutBe(out + 2u, target, 2u);
*length = all ? 2u : 4u;
return DD_OK;
}
AI代码解释
cpp
运行
长度计算先用除法检查容量,避免先计算 count × 单项长度 导致溢出。内存地址和长度先检查是否能装入指定字段宽度,所有条目通过后才写入输出。
这些构造函数检查本地报文形状,不掌握 ECU 源 DID 的真实长度、源元素边界或内存权限。服务器仍需逐项检查。输入描述数组与输出缓冲区应为不同的存储区域。
发送确认与三类超时
static void DdEnd(DdClient *c, DdResult result, bool discard)
{
c->active = false;
c->result = result;
c->io.finish(c->io.ctx, discard);
}
void DdPoll(DdClient *c, uint32_t now)
{
if ((c == NULL) || !c->active) return;
if ((uint32_t)(now - c->started) >= c->timing.total_ms) {
DdEnd(c, DD_TOTAL_TIMEOUT, true);
} else if (!c->waiting) {
if ((uint32_t)(now - c->stage) >= c->timing.tx_ms)
DdEnd(c, DD_TX_TIMEOUT, true);
} else {
uint32_t window = c->pending ? c->timing.p2star_ms
: c->timing.p2_ms;
if ((uint32_t)(now - c->stage) >= window)
DdEnd(c, c->pending ? DD_P2STAR_TIMEOUT : DD_P2_TIMEOUT,
true);
}
}
void DdTxDone(DdClient *c, bool success, uint32_t now)
{
if ((c == NULL) || !c->active || c->waiting) return;
DdPoll(c, now);
if (!c->active) return;
if (!success) {
DdEnd(c, DD_TX_ERROR, true);
} else {
c->waiting = true;
c->stage = now; /* 完整请求发送确认后,开始 P2Client。 */
}
}
void DdTransportError(DdClient *c)
{
if ((c != NULL) && c->active) DdEnd(c, DD_TP_ERROR, true);
}
void DdCancel(DdClient *c)
{
if ((c != NULL) && c->active) DdEnd(c, DD_CANCELLED, true);
}
AI代码解释
cpp
运行
send 只负责启动完整 PDU 的 ISO-TP 发送。传输适配层必须在完整请求发送完成时调用 DdTxDone,在失败时报告失败;接收事件在该发送确认之后交付。接口回调不重入本客户端。
finish(ctx, discard_rx) 负责释放本事务占用。discard_rx=true 时还需取消未完成传输并按项目策略清理、隔离迟到响应,适配层在清理完成前不允许新事务进入同一连接。
DdCancel 取消的是客户端等待与传输上下文,不能撤销 ECU 已经提交的定义。
开始事务前再校验一次输入
static bool DdValidRequest(const uint8_t *p, size_t n)
{
if ((p == NULL) || (n < 2u) || (n > DD_REQUEST_CAP) ||
(p[0] != 0x2Cu) || ((p[1] & 0x80u) != 0u)) return false;
if ((p[1] == 0x03u) && (n == 2u)) return true;
if ((n < 4u) ||
!DdDynamic((uint16_t)(((uint16_t)p[2] << 8) | p[3])))
return false;
if (p[1] == 0x01u) {
if ((n < 8u) || (((n - 4u) % 4u) != 0u)) return false;
for (size_t i = 4u; i < n; i += 4u)
if ((p[i + 2u] == 0u) || (p[i + 3u] == 0u))
return false;
return true;
}
if (p[1] == 0x02u) {
if (n < 7u) return false;
size_t a = p[4] & 0x0Fu, s = p[4] >> 4;
if ((a == 0u) || (a > 4u) || (s == 0u) || (s > 4u) ||
(((n - 5u) % (a + s)) != 0u)) return false;
for (size_t i = 5u; i < n; i += a + s) {
bool nonzero = false;
for (size_t j = 0u; j < s; ++j)
nonzero = nonzero || (p[i + a + j] != 0u);
if (!nonzero) return false;
}
return true;
}
return (p[1] == 0x03u) && (n == 4u);
}
static bool DdBudgetValid(uint32_t value)
{
return (value != 0u) && (value < UINT32_C(0x80000000));
}
DdResult DdStart(DdClient *c, const DdIo *io, const DdTiming *timing,
const uint8_t *request, size_t length, uint32_t now)
{
if (c == NULL) return DD_ARGUMENT;
if (c->active) return DD_BUSY;
if ((io == NULL) || (timing == NULL) || (io->send == NULL) ||
(io->finish == NULL) || !DdValidRequest(request, length) ||
!DdBudgetValid(timing->tx_ms) || !DdBudgetValid(timing->p2_ms) ||
!DdBudgetValid(timing->p2star_ms) ||
!DdBudgetValid(timing->total_ms)) return DD_ARGUMENT;
memmove(c->request, request, length);
c->io = *io; c->timing = *timing; c->request_len = length;
c->started = now; c->stage = now;
c->nrc = 0u; c->pending = false; c->waiting = false;
c->result = DD_RUNNING; c->active = true;
if (!c->io.send(c->io.ctx, c->request, length)) {
DdEnd(c, DD_TX_ERROR, true);
}
return c->result;
}
AI代码解释
cpp
运行
DdStart 将请求复制到实例自己的缓冲区,避免调用方的局部数组在异步发送期间失效。已存在事务时返回 DD_BUSY,不清空原请求或原计时器。
tx_ms 是项目发送期限,total_ms 是项目总期限;P2 和 P2* 从参数传入。所有预算非零且小于半个 32 位计数周期,使用单调毫秒计数并保证事件处理、轮询足够频繁,才能按无符号减法处理一次回绕。
收到 0x78 后只更新等待窗口
void DdOnPdu(DdClient *c, const uint8_t *p, size_t n, uint32_t rx_time)
{
if ((c == NULL) || !c->active || !c->waiting) return;
DdPoll(c, rx_time);
if (!c->active) return;
if ((p == NULL) || (n < 2u) || (n > 4u)) {
DdEnd(c, DD_PROTOCOL, true);
return;
}
if (p[0] == 0x7Fu) {
if ((n != 3u) || (p[1] != 0x2Cu) || (p[2] == 0u)) {
DdEnd(c, DD_PROTOCOL, true);
} else if (p[2] == 0x78u) {
c->pending = true;
c->stage = rx_time; /* 重启 P2*,总事务期限不重启。 */
} else {
c->nrc = p[2];
DdEnd(c, DD_NEGATIVE, false);
}
return;
}
size_t expected = c->request_len == 2u ? 2u : 4u;
if ((n != expected) || (p[0] != 0x6Cu) ||
(p[1] != c->request[1]) ||
((expected == 4u) &&
((p[2] != c->request[2]) || (p[3] != c->request[3])))) {
DdEnd(c, DD_PROTOCOL, true);
} else {
DdEnd(c, DD_OK, false);
}
}
AI代码解释
cpp
运行
合法 0x2C 最终响应是 2 或 4 字节,负响应是 3 字节,在本文 CAN 寻址配置下均使用 ISO-TP 单帧。适配层传入去掉 PCI 和填充的 UDS PDU,以及该帧的接收时间戳。这里以接收事件时间核对 P2;请求多帧传输仍由 ISO-TP 自己监督。
如果把这段逻辑扩展到可能有多帧响应的其他服务,要增加“响应开始/响应重组”状态,P2 检查响应开始,后续重组由传输定时器监督,不能直接拿完整响应交付时间判 P2 超时。
收到 78 时不调用 send,也不重启总期限。最终正响应同时检查 SID、子功能、DID 和长度;最终负响应保留实际 NRC,与 P2 超时、传输错误分别返回。
0x2C 没有足够的事务标识来区分两次相同请求的迟到 6C。清空本地队列只能解决一部分问题;超时后还应执行项目规定的等待隔离和状态恢复,再建立下一事务。
一个接入调用
DdResult ConfigureExample(DdClient *client, const DdIo *io,
uint32_t now_ms)
{
const DdSlice slices[] = {
{0x1234u, 1u, 2u}, {0x5678u, 1u, 1u}
};
const DdTiming timing = {500u, 100u, 5000u, 15000u};
uint8_t request[12];
size_t length = 0u;
DdResult rc = DdByDid(request, sizeof(request), &length,
0xF301u, slices, 2u);
if (rc != DD_OK) return rc;
/* 本组定时参数只是项目示例,需按实际会话和链路配置。 */
return DdStart(client, io, &timing, request, length, now_ms);
}
AI代码解释
cpp
运行
将前述代码按出现顺序放入同一个 .c 文件即可编译。事件循环在合适周期调用 DdPoll;ISO-TP 适配层分别投递发送完成、接收 PDU 和传输错误事件。DD_OK 后才进入 0x22 读取事务;DD_NEGATIVE 时记录 NRC,其他终止结果要按不确定执行状态处理。
掉电与并发:描述表的一致性和数据采样的一致性分开验证
标准要求定义保持到显式清除,或车辆制造商规定的失效事件,例如会话迁移、掉电。它没有要求所有动态定义必须写入 Flash,也没有保证所有 ECU 复位后仍保留定义。
| 场景 | 项目应明确的行为 | 验证方法 |
|---|---|---|
| 会话切换或安全重新锁定 | 保留哪些定义,哪些读取/周期任务失效 | 迁移前后分别执行 0x22、检查周期发送 |
| 两个诊断连接追加同一目标 | 描述是全局还是连接相关,谁持有配置使用权 | 并发追加及清除,检查是否丢项或重复 |
| 读取与追加并发 | 一次响应使用完整旧版本或完整新版本 | 记录定义版本、响应长度和输出顺序 |
| 清除与周期发送并发 | 停止调度后再安全回收描述 | 清除前后抓取周期通道并检查待发队列 |
| RAM 定义遇到掉电 | 按项目生命周期失效,客户端重新配置 | 上电后先确认定义状态,避免套用旧布局 |
| 需持久化的项目 | 描述格式版本、CRC、提交标记与恢复策略 | 在存储提交各阶段断电,核对恢复版本 |
读取任务宜先取得一份稳定的描述快照,再通过受控数据接口读取,不应持有全局配置锁等待 ISO-TP 发送结束。双缓冲、短临界区或互斥方式均需满足目标处理器和运行环境的内存一致性要求,volatile 不能代替同步。
描述快照稳定,也不等于各个信号来自同一个采样时刻。 若应用需要转速、扭矩、状态字的同周期一致性,应由源数据接口提供统一快照或版本约束,再基于这份源快照组包。
掉电策略也不能承诺“客户端没收到响应,所以 ECU 一定没修改”。响应丢失与定义提交是不同事件。涉及 NvM 的实现,应在项目中定义成功响应与可恢复提交之间的关系,并测试对应断电窗口。
用抓包和故障注入验证,不只看一条 6C
| 用例 | 输入或操作 | 预期检查 |
|---|---|---|
| 按 DID 定义 | 两个完整源片段 | 6C 01 + 目标;读取顺序、长度正确 |
| 按地址定义 | ALFID=14,两个地址/长度对 | ALFID 只解析一次,大小端及组合长度正确 |
| 追加定义 | 对同一目标连续定义两次 | 保留第一次数据,第二次追加在末尾 |
| 追加超限 | 有效旧定义后追加到服务器上限之外 | 7F 2C 31,再次读取仍为旧版本 |
| 源片段越界 | position 为 0,或范围超出源记录 | 按表 243 对应条件拒绝,无部分提交 |
| 截断请求 | 缺一个源片段字节或一段地址尾部 | 长度错误,不用填充补参数 |
| ALFID 不被接受 | 使用服务器不支持的宽度 | 7F 2C 31,与字段被截断区分 |
| 安全条件不满足 | 目标、某一源或地址受保护 | 7F 2C 33,不存在间接访问绕过 |
| 清除有效但不存在的目标 | 指定服务器支持范围内的空目标 | 正响应,回显子功能和 DID |
| 全部清除 | 2C 03 | 6C 03,只有 2 字节 |
| 正响应抑制 | 合法带 bit7 请求 | 正常执行,响应按通用抑制规则处理 |
| 初次响应超时 | ECU 未在 P2 窗口响应 | 独立报告 P2 超时,不直接重发 |
| 重复响应挂起 | 7F 2C 78 多次到达 | 更新 P2*,不发送重复请求,总期限继续 |
| 回显错误 | 6C 后子功能、DID 或长度错误 | 客户端报告协议错误 |
| 发送失败 | 请求 CF 未完成或传输中断 | 不假定定义成功,清理连接上下文 |
| 响应丢失后恢复 | ECU 提交后丢弃 6C | 检查重复追加风险和项目恢复流程 |
| 并发与掉电 | 按上一节的各个窗口注入 | 描述版本、源快照与恢复结果一致 |
本文客户端代码按 C11 严格告警编译;主机回归覆盖大小端构造、容量、回显、重复 78、发送确认、P2/P2*/总期限、取消、迟到事件与计数回绕。这些检查验证客户端核心,不能替代 ECU 端的标准符合性、ISO-TP 一致性和实车时序测试。
建议一起保留原始 CAN 帧、重组后的 UDS PDU、客户端状态变化、ECU 描述版本及源数据版本。仅保存 “6C 成功” 的日志,无法区分传输已完成、定义已提交和后续读取成功这几个阶段。
回到开头的两种故障
出现重复数据,先核对客户端是否把追加请求当成覆盖,或者在响应丢失后直接重发。追加失败后旧数据也消失,重点检查 ECU 是否边解析边改现行表,以及容量失败时有没有保留旧定义。
把这两条路径验证清楚,再补齐目标值域、源数据边界、权限、周期帧预算和会话生命周期,0x2C 的问题通常就能定位到具体一层。最终验收应同时看到正确的定义响应,以及符合当前定义版本的读取或周期数据。
标准依据与项目约定
- ISO 14229-1:2020 第 11.6.1 节:数据来源、顺序连接、容量失败保留原定义和生命周期;第 11.6.2 节、表 236~240:请求格式与参数。
- 第 11.6.3、11.6.4 节、表 241~243:正响应回显和服务级 NRC;第 11.6.5 节:定义、读取、清除和混合追加示例。
- 附录 C.1:周期与动态 DID 值域;第 8.7 节:请求和响应通用行为;第 11.2、11.5 节:后续
0x22/0x2A访问。 - CAN ID、填充、示例 RAM 地址、客户端资源和期限属于本文工程示例;源 DID、会话、安全、诊断数据库、存储恢复及周期通道映射,以实际 ECU/OEM 项目规范为准。
表 240 与一字节长度示例的差异已在正文单独说明。P2/P2* 的完整定时关系和 CAN/ISO-TP 映射,需要同时核对项目采用的 ISO 14229-2、ISO 14229-3 与 ISO 15765-2,不能仅由 ISO 14229-1 的服务表推导全部链路行为。



