2.20 动态定义DID(0x2C)——自定义化验单
标准的 DID 列表就像医院的固定套餐化验单——血常规、肝功能、肾功能,各是各的。但有时候你就想在一个报告单上同时看到转氨酶、肌酐和甲状腺素。这时你不是去改医院的标准——而是开一张自定义化验单。
场景:变速箱换挡闯动,但四个关键参数散落在四个不同的DID里
下午两点半,传动系统实验室。
你面前摆着一台8速自动变速箱的台架,电机模拟发动机驱动,测功机模拟道路负载。问题是:2挡升3挡时偶尔有闯动——有时候有有时候没有,有时候轻有时候重,完全找不到规律。
你怀疑是换挡瞬间的扭矩协调出了问题。从工程直觉出发,你应该同时观察四个参数在换挡点前后的变化:
- 发动机转速 — 在 DID
0x010C中(2字节,byte0-1) - 油门踏板位置 — 在 DID
0x0131中(1字节,byte0) - 变速箱输出轴转速(车速) — 在 DID
0x010D中(2字节,byte0-1) - 变速箱油温 — 在 DID
0x0118中(1字节,byte0)
但这四个参数分别属于四个不同的 DID。如果用 0x22 读,你得连续发四次请求:
02 22 01 0C ... → 转速
02 22 01 31 ... → 油门
02 22 01 0D ... → 车速
02 22 01 18 ... → 油温
四次请求之间有时间差——可能是几毫秒,也可能因为 CAN 总线仲裁延迟几十毫秒。对于分析 2→3 换挡这个 200ms 级别的瞬态过程来说,这四个值必须来自同一时刻才有意义。相差 20ms 的转速和油门位置对不上,就像两张不同时间拍摄的X光片叠在一起看——骨骼错位了。
你或许会想:用 0x2A 周期读取行不行?可以把四个 DID 都注册上,100ms 上报一次——但每个 DID 仍然是独立上报的,它们被放在同一个 CAN 帧里不代表它们是同一时刻采样的。ECU 的软件在遍历 DID 列表时逐个读取,中间仍有微小时差。
更关键的是:你只想在这 200ms 的换挡窗口内获取高密度数据。如果你能自定义一个 DID,把四个参数的指定字节拼接在一起,让 ECU 一次性读取所有来源——就像在医院里,你不点“血常规“、“肝功能”、“甲状腺“三张化验单,而是让检验科直接把 ALT、TSH、HbA1c 三个指标合成一张报告——那你只需要发一次 0x22 请求,就能拿到一个原子快照。
这个需求在 ISO 14229-1 中的化身就是:动态定义数据标识符 (DynamicallyDefineDataIdentifier),SID 0x2C。
为什么需要0x2C?——DID命名空间的运行时扩展
UDS 的 DID 系统本质上是一个映射表:
DID (2 bytes) → 数据源描述符 → 实际数据
这个映射表在 ECU 的配置阶段就已经固化了。DID 0x010C 永远对应发动机转速的 byte0-1,DID 0x0105 永远对应冷却液温度的 byte0。这个固化有两个好处:
- 确定性:全局的 DID 定义来自 ODX/PDX 数据库,测试仪和 ECU 对同一个 DID 的理解一致
- 简单性:ECU 的实现是一个静态查找表,不需要动态内存分配
但它的代价也很明显:灵活性为零。如果你的分析需要一组参数组合,而恰好没有一个现成的 DID 包含这个组合,你就必须分多次读取——不仅低效,数据的一致性也无法保证。
0x2C 的设计者提出了一个巧妙的折中:不改变 ECU 的原始 DID 定义表,而是在它之上叠加一个运行时动态层。
┌──────────────────────┐
│ 静态 DID 表 (ROM) │
│ 0x010C → 转速 │
│ 0x0105 → 水温 │
│ 0x010D → 车速 │
│ 0x0110 → MAF │
└──────────┬───────────┘
│
┌──────────────────┼──────────────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ 直接读取层 │ │ 动态覆盖层 │
│ 0x22 0x010C │ │ 0x22 0xFE01 │
│ → 转速 │ │ → 自定义组合 │
└───────────────┘ └───────┬───────┘
│
┌───────▼───────────┐
│ 动态 DID 定义 │
│ 0xFE01 = { │
│ [字节0-1]←0x010C│
│ [字节2] ←0x0131│
│ [字节3-4]←0x010D│
│ [字节5] ←0x0118│
│ } │
└───────────────────┘
当测试仪读 0xFE01 时,ECU 先去动态表中查找——如果命中,就用动态组合逻辑拼装数据;如果未命中,再回退到静态 DID 表。动态定义的存在不影响静态 DID 的正常工作,它们是两个平行的解析路径。
这就是 0x2C 的精髓:它把 DID 命名空间从 “编译期封闭” 变成了 “运行时开放” ——不是让所有 DID 都可改,而是让测试仪有权开辟一小块属于自己的临时命名空间。
请求报文:三个子功能,两种定义方式
0x2C 的请求格式取决于子功能:
子功能 0x01:defineByIdentifier(按标识符定义)
从现有的 DID 中裁剪组合。这是一种“乐高拼接“模式——你来指定新 DID 中的每一个字节来自哪个源 DID 的哪个位置。
请求格式:
Byte 0: SID = 0x2C
Byte 1: SubFunction = 0x01
Byte 2-3: dynamicallyDefinedDataIdentifier(新 DID 编号。标准未强制指定动态DID的范围,由OEM在DID地址空间中预留一段区域,如0xE000~0xEFFF)
Byte 4: paramCountLowByte(参数定义条目数)
Byte 5+: parameterDefinitionRecord[]
每条记录 = {
positionInDynamicDID (2字节, 在新DID中的字节偏移)
sourceDataIdentifier (2字节, 源 DID 编号)
sourceByteOffset (1字节, 从源 DID 数据的第几个字节开始)
sourceByteCount (1字节, 从源 DID 复制多少个字节)
}
每条 parameterDefinitionRecord 恰好 6 个字节。所以对于一个需要拼接 4 个参数的组合 DID,定义阶段的总请求长度是:
1(SID) + 1(Sub) + 2(新DID) + 1(count) + 4×6 = 29 字节
> 29字节已超出经典CAN单帧8字节载荷。分段由ISO 15765-2传输层自动完成——诊断仪只需构造完整的29字节请求,CAN驱动层自动执行FF(首帧)→ CF(连续帧)的拆分与重组。
下面是一个具体例子——定义 DID 0xFE01 包含我们前面说的四个参数:
请求: 2C 01 FE 01 04
00 00 01 0C 00 02
00 02 01 31 00 01
00 03 01 0D 00 02
00 05 01 18 00 01
逐条解释:
| 条目 | position (新DID中) | 源 DID | 源偏移 | 长度 | 含义 |
|---|---|---|---|---|---|
| 第1条 | 0x0000 | 0x010C | 0x00 | 0x02 | 新DID字节0-1 ← 转速(byte0-1) |
| 第2条 | 0x0002 | 0x0131 | 0x00 | 0x01 | 新DID字节2 ← 油门(byte0) |
| 第3条 | 0x0003 | 0x010D | 0x00 | 0x02 | 新DID字节3-4 ← 车速(byte0-1) |
| 第4条 | 0x0005 | 0x0118 | 0x00 | 0x01 | 新DID字节5 ← 油温(byte0) |
总共 6 个字节的自定义 DID,包含四个来源的五个字段。之后你只需要:
发: 22 FE 01
收: 62 FE 01 0B 40 1E 08 50 64
解析: 转速=0x0B40=2880rpm, 油门=0x1E=30%, 车速=0x0850=2128(≈80km/h), 油温=0x64=100°C
一次请求,原子快照。时间差为零。
子功能 0x02:defineByMemoryAddress(按内存地址定义)
这是一个更底层、更危险的接口。它允许测试仪直接给出 ECU 内存中的地址和长度:
Byte 0: SID = 0x2C
Byte 1: SubFunction = 0x02
Byte 2-3: dynamicallyDefinedDataIdentifier
Byte 4: paramCountLowByte
Byte 5+: memoryAddressDefinition[]
每条记录 = {
positionInDynamicDID (2 bytes)
memoryAddress (4 bytes, 物理地址或逻辑地址)
memorySize (1 byte, 读取长度)
}
这个模式很像 0x23 (ReadMemoryByAddress),但多了一步:你先“注册“一个地址范围为一个 DID,之后就可以用 0x22 来读取它,也可以把它放进 0x2A 的周期读取列表里。
大多数 OEM 出于安全原因禁用或严格限制 defineByMemoryAddress。随便读内存地址在量产 ECU 上是不可接受的——它绕过了 DID 的数据访问控制。通常只在工程开发阶段和特定的安全解锁会话下可用。
子功能 0x03:clearDynamicallyDefinedDataIdentifier
清除一个之前定义的动态 DID:
请求: 2C 03 FE 01
响应: 6C 03
很简单——就是告诉 ECU “这个自定义报表我不要了,你可以释放资源了”。
实现侧的关键逻辑
在 ECU 的 DSP 层,处理 defineByIdentifier 需要一个临时的描述符表。一个简化的实现思路:
#define DYNAMIC_DID_MAX_COUNT 8
#define DYNAMIC_DID_PARAM_MAX 16
typedef struct {
uint16 positionInDynamic; /* 在新 DID 中的字节偏移 */
uint16 sourceDid; /* 源 DID 编号 */
uint8 sourceOffset; /* 从源 DID 数据区的偏移 */
uint8 byteCount; /* 复制字节数 */
} DynamicDidParam;
typedef struct {
uint16 dynamicDid; /* 动态 DID 编号 */
uint8 paramCount; /* 参数条目数 */
uint8 active; /* 是否激活 */
DynamicDidParam params[DYNAMIC_DID_PARAM_MAX];
} DynamicDidEntry;
static DynamicDidEntry gDynamicDidTable[DYNAMIC_DID_MAX_COUNT];
static Std_ReturnType Dsp_DynamicDid_DefineByIdentifier(
uint16 newDid,
P2CONST(uint8, AUTOMATIC, AUTOMATIC) pParamData,
uint8 paramCount)
{
int i;
DynamicDidEntry *entry = NULL;
/* 查找空闲槽位或同名 DID(覆盖旧定义) */
for (i = 0; i < DYNAMIC_DID_MAX_COUNT; i++)
{
if (gDynamicDidTable[i].active == 0x00)
{
entry = &gDynamicDidTable[i];
break;
}
if (gDynamicDidTable[i].dynamicDid == newDid)
{
entry = &gDynamicDidTable[i]; /* 覆盖已有定义 */
break;
}
}
if (entry == NULL)
return E_NOT_OK; /* 槽位满了 */
entry->dynamicDid = newDid;
entry->paramCount = paramCount;
entry->active = 0x01;
for (i = 0; i < paramCount; i++)
{
/* 从参数缓冲区逐条解析,每条 6 字节 */
entry->params[i].positionInDynamic = ((uint16)pParamData[i*6] << 8)
| pParamData[i*6 + 1];
entry->params[i].sourceDid = ((uint16)pParamData[i*6 + 2] << 8)
| pParamData[i*6 + 3];
entry->params[i].sourceOffset = pParamData[i*6 + 4];
entry->params[i].byteCount = pParamData[i*6 + 5];
}
return E_OK;
}
当后续 0x22 请求命中动态 DID 时,读取函数需要遍历参数条目,逐条调用源 DID 的读取接口,将结果拼装到响应缓冲区中。
医院隐喻:自定义化验单
去医院体检,你一般拿到的是“套餐化验单“:
- 血常规:白细胞、红细胞、血红蛋白、血小板……
- 肝功能:ALT、AST、总胆红素……
- 甲状腺功能:TSH、T3、T4……
这些套餐是标准化的——就像 ECU 的标准 DID 列表。99% 的情况够用。
但如果有一位肾内科医生正在追踪一位同时有肝功能异常的肾病患者,她只关心三个指标:血肌酐(来自肾功能)、ALT(来自肝功能)、TSH(来自甲状腺功能)。按常规流程,她需要翻三张不同的化验单——就像工程师需要发三次 0x22。能不能让检验科直接把这三个指标出在一张报告上?
能。这就是“自定义化验单“——医生不点套餐,而是指定她需要的指标列表。检验科从各自的标准检测流程中提取指定结果,拼成一张报告。
这正是 0x2C 的 defineByIdentifier 在做的事:
| 医院 | 汽车诊断 |
|---|---|
| 标准化验套餐(血常规、肝功能……) | 标准 DID(0x010C 转速、0x0105 水温……) |
| 自定义化验单(指定指标列表) | 动态定义 DID 0xFE01 |
| 检验科从各自检测线取数据拼报告 | ECU 从源 DID 数据区按偏移+长度裁剪拼装 |
| 一张报告纸包含所有关注的指标 | 一个 CAN 帧包含所有关注的参数 |
| 报告单一出就是同一时刻的血样 | 一次 0x22 读取 = 同一个软件执行片段 = 原子快照 |
defineByMemoryAddress 在医院的类比就更生猛了——不是让检验科按标准流程做检测,而是直接让你去看显微镜看细胞切片,找到特定的坐标区域观察。这显然不是普通医生能做的,属于“如有必要,主任医师权限下才能操作“的类型。
核心洞察:
0x2C 将 UDS 的 DID 系统从“固定菜单“变成了“自选配菜“。 它的设计没有去修改编译时固化的静态 DID 映射表,而是在其上叠加了一个动态查找层——既保留了标准 DID 的确定性和简单性,又赋予测试仪在运行时按需组合数据的能力。这个“双层映射“架构是嵌入式系统设计中一种值得记住的模式:不要试图让所有东西都动态化——只需要在关键位置打开一个可控的动态窗口。
它也回答了一个深层问题:协议应该如何应对“设计时无法预见的查询需求“? ISO 14229-1 没有尝试去规定所有可能的参数组合(那是不可能的),而是提供了一个元机制——让使用者自己去定义要读什么、从哪读、读多少。这种“授人以渔“的思路,比堆砌一万个预定义 DID 要高明得多。
本篇小结
- 0x2C DynamicallyDefineDataIdentifier 允许测试仪在运行时定义新的虚拟 DID,从现有数据源中按字节裁剪组合
- 三种子功能:
0x01defineByIdentifier(从源 DID 组合)、0x02defineByMemoryAddress(从内存地址读取)、0x03clear(清除动态定义) defineByIdentifier通过“position+sourceDID+offset+length“四元组描述每个字段的来源和放置位置- 动态 DID 可以实现真正的原子快照:多个参数在同一读取周期内采集,时间差为零
- 动态定义的数量受 ECU 资源限制(RAM 中预设的槽位数),清除不用的定义可以释放资源
defineByMemoryAddress功能强大但风险高,通常仅限工程模式和安全解锁后使用- 医院隐喻:自定义化验单——医生不点标准套餐,而是指定她需要的指标组合,检验科拼接输出
- 核心模式:双层映射——静态 DID 表(ROM)+ 动态覆盖表(RAM),兼顾确定性和灵活性
【下集预告】:0x2A 和 0x2C 让 ECU 获得了一定的“主动性“——可以周期上报、可以动态组合数据。但它们有一个共同的触发前提:测试仪必须先发出一个启动请求。ECU 不能在自己“觉得有事情要报告“时主动说话。
但现实生活中,病人没事不会按铃——一旦按铃,就是有事。UDS 有没有这样一种机制:让 ECU 在某些内部事件发生时(比如弹出新故障码),在测试仪早已注册过的情况下,主动推送一条消息?
下一章,我们将看到 UDS 中唯一一个“ECU 主动发起“的服务——
0x86事件响应。它就像病房里的护士呼叫铃,病人(ECU)不需要等医生(测试仪)来查房才说话。当事情发生,铃就响了。