☰
UDS 0x10会话控制与P2/P2*、0x78超时机制解析
2026/11/2 9:22:02 网站建设 项目流程

1. 先搞清楚 0x10 在诊断链路里的位置

我做诊断协议实现有几年了,每次带新人,第一个让他们啃的服务一定是 0x10 诊断会话控制(DiagnosticSessionControl)。原因很简单,这个服务的报文只有两个字节,逻辑看似最笨,但它背后牵扯到整个 ECU 诊断状态机的门禁体系,而它的正响应里又藏着 P2 和 P2* 这两个时间参数,往后所有涉及长耗时操作的服务(0x27 安全访问、0x31 例程控制、0x34/0x36 数据传输)都绕不开它们。把 0x10 吃透,后面的诊断实现基本就是一马平川;吃不透,就会在刷写流程里反复卡在偶发超时上,查三天也查不出所以然。

这篇文章我打算从三个层面把这件事讲完整:一是 0x10 服务本身的语义,会话到底是什么东西,切换会话时 ECU 内部要动哪些状态;二是 P2 和 P2* 这两个定时器的真实含义,计时起点在哪、终点在哪、单位陷阱在哪;三是 ECU 侧怎么落地实现、上位机侧怎么验证、实际项目里最容易踩的坑有哪些。内容偏实战,代码给的是可以照着改的骨架,测试用例给的是能直接抄的表格,适合刚接触 UDS 诊断的工程师,也适合已经写过 0x10 但没深究定时器的老手对照复盘。

需要说明一句:本文涉及的时长参数、时序规范是基于 ISO 14229 系列标准的通用实践,具体项目里每个 ECU 的配置值可能不同,最终以你们项目的诊断规范(Diagnostic Specification)为准。下面讲的默认值只是行业里最常见的配置,不代表任何特定项目的强制要求。

1.1 会话不是开关,是 ECU 的状态上下文

很多人第一反应会把诊断会话理解成一个"权限开关"——默认会话权限低,扩展会话权限高,编程会话权限最高。这个理解不算错,但太浅了,会误导实现思路。更准确的说法是:会话是 ECU 内部的一整套状态上下文,切换会话意味着这一整套上下文要跟着换。

举几个具体的例子你就明白了。在默认会话下,0x2E(按标识符写数据)通常是禁止的,返回 NRC 0x7F(当前会话不支持该服务);切到扩展会话后,0x2E 才被放行,但往往还要求先做过 0x27 安全访问解锁,否则返回 NRC 0x33(安全访问被拒绝)。再比如 0x2F(输入输出控制),在默认会话下不允许,扩展会话下允许,但一旦会话回退到默认,所有正在被强制的 IO 引脚必须立刻恢复到正常控制逻辑,否则电机可能一直转、继电器可能一直吸合,这是实打实的安全问题。

而编程会话更特殊,它一般只允许 0x10、0x11、0x27、0x31、0x34、0x36、0x37、0x3E 这几个服务,其他服务一律拒绝。很多 ECU 在进入编程会话之后,还会要求先执行 0x85(控制 DTC 设置)关闭故障码存储,避免刷写过程中的擦写操作把 Flash 里的 DTC 区域搞乱。

所以 0x10 的实现绝不能只写一句g_session = newSession;就完事。正确做法是:每次会话发生变更(包括主动切换和 S3 超时被动回退),都要触发一个统一的会话变更处理函数,把该复位的复位、该关闭的关闭、该恢复的恢复。我在项目里习惯把它叫Dcm_OnSessionChange(newSession, oldSession),所有跟会话强绑定的状态清理都挂在这个钩子上,不允许散落在各处。

还有一点容易被忽略:重复请求当前会话,必须返回肯定响应。比如当前已经在扩展会话(0x03),上位机又发了一次10 03,ECU 不能返回 NRC,得老老实实回50 03 xx xx yy yy。这在诊断测试规范里是有明确用例的,属于一致性测试的必测项,实现时别为了"省事"加个"目标会话等于当前会话就直接返回错误"的短路逻辑。

1.2 一个 0x10 请求的完整报文切片

先把报文摊开看。请求报文固定两个字节:

10 03 │ └── 子功能参数(Sub-function) └── 服务标识符 SID = 0x10

子功能参数这一个字节里其实塞了两样东西。最高位 bit7 是抑制肯定响应位(suppressPosRspMsgIndicationBit),低 7 位才是真正的会话类型。也就是说0x03和0x83在会话类型上是同一个东西,都是扩展会话,区别只在于0x83表示"如果成功了你就别回我了,失败了你照常回否定响应"。这个位在刷写流程里用得很多,可以减少总线负载,但它只抑制肯定响应,否定响应是抑制不掉的,这点务必记牢。

会话类型的取值,标准里定义的是这几个:

子功能值会话类型典型用途
0x01默认会话(default)常规读数据、读故障码
0x02编程会话(programming)刷写、擦除、校验
0x03扩展会话(extended)写数据、IO 控制、例程控制
0x04安全系统会话安全气囊等特殊场景,用得少
0x40–0x5F车辆制造商自定义各家自己的特殊会话
0x60–0x7E供应商自定义Tier1 内部使用

肯定响应的格式是 6 个字节,这也是本文的重点:

50 03 00 32 01 F4 │ │ └────┘ └────┘ │ │ │ └── P2*Server_max,2 字节,单位 10ms/bit → 0x01F4 = 500 × 10 = 5000ms │ │ └───────── P2Server_max,2 字节,单位 1ms/bit → 0x0032 = 50ms │ └────────────── 子功能回显(抑制位强制清 0) └───────────────── 0x50 = 0x10 + 0x40

否定响应的格式是三字节,7F 10 NRC。0x10 服务常见的 NRC 我整理了一下,这几个在实测里出现频率最高:

NRC含义常见触发原因
0x11服务不支持该 ECU 压根没实现 0x10,或者走了功能寻址
0x12子功能参数不支持子功能值非法,比如发了10 05
0x13报文长度或格式错误请求不是正好 2 字节
0x22条件不满足车速不为 0、发动机在转,不允许切会话
0x7E当前会话不支持该服务编程会话下发了不该发的服务
0x7F当前会话不支持该子功能编程会话下不允许直接切回扩展会话

这里要专门提一个高频踩坑点:0x10 通常不支持功能寻址。功能寻址是广播式的,一发出去所有 ECU 都收到,如果允许广播切会话,总线上几十个 ECU 同时进入扩展会话,安全访问状态全乱,这是不可接受的。所以绝大多数项目里,0x10 只接受物理寻址,收到功能寻址的 0x10 请求时要么直接不响应,要么返回 NRC 0x11。我见过有项目在功能寻址下回了肯定响应,结果测试台架上所有节点集体切会话,排查了半天才发现是这里漏了判断。

判断寻址方式在 CAN 驱动层就能拿到,收到报文时看一眼 CAN ID 是物理 ID 还是功能 ID,把标志位一路传到诊断层,进 0x10 处理函数的第一件事就是校验这个标志。这个判断成本极低,但漏了就是事故。

2. P2 与 P2*:藏正响应里的两个定时器

正响应里那四个字节,是我见过被误解最多的部分。新人拿到50 03 00 32 01 F4,第一反应通常是"后面这四位是版本号吧"或者"这是 ECU 的某种标识",然后就把它当成固定常量抄进代码里,再也没管过。等到项目后期要做大数据块刷写,上位机频繁报超时,才开始回头翻这四个字节到底什么意思。

简单说:P2 和 P2* 是服务器(ECU)向客户端(诊断仪)承诺的响应时间上限。你发了请求,我保证在 P2 时间内给你首个响应;如果我确实需要更长时间,我会先回一个"我收到了,正在处理"的中间响应(NRC 0x78),然后你就可以按 P2* 这个更宽松的时间等我了。两个参数一前一后,构成了 ECU 响应时效的完整契约。

2.1 那四个字节到底怎么读

先把单位说清楚,这是最容易出错的地方,我列个对照表:

参数字节位置单位取值范围常见值换算
P2Server_max第 3–4 字节1ms/bit0–65535ms0x003250ms
P2*Server_max第 5–6 字节10ms/bit0–655350ms0x01F45000ms

单位不一样!P2 是 1 毫秒一个刻度,P2* 是 10 毫秒一个刻度。这个设计有它的道理:P2 通常在几十毫秒量级,需要精细刻度;P2* 通常在几秒量级,10ms 的精度足够,还能把表示范围拉到 655 秒,省一个字节。

但就是这个不对称的设计,坑了无数人。我整理了几个实测中见过的错误配置:

想配的值正确写法错误写法错误写法实际生效
P2 = 50ms00 3232(少一个字节)报文长度错,返回 NRC 0x13
P2* = 5000ms01 F413 885000 × 10 = 50000ms,慢了十倍
P2* = 2000ms00 C807 D02000 × 10 = 20000ms,慢了十倍
P2* = 50000ms13 8801 F4只有 5000ms,刷写必超时

第二行和第四行是同一个错误的两个方向,我在两个不同项目里各见过一次,都是把 P2* 当成 1ms 单位在算。第一次是擦除例程(0x31 01 FF00)配置了 50000ms 的实际需求,代码里写了0x01F4,结果上位机 5 秒就报超时,查了两天才发现是单位搞错;第二次反过来,配 5000ms 写成了0x1388,ECU 老老实实等了 50 秒才超时,看起来"很稳",实际上响应时效已经完全不符合规范了,一致性测试一跑就挂。

实操心得:我现在的习惯是在代码里统一用毫秒做内部表示,只在与报文交互的编解码函数里做单位换算,并且把换算写成两个独立的宏:

/* 编解码统一入口,避免散落各处手写移位 */ #define DCM_MS_TO_P2_BYTES(ms) ((uint16_t)(ms)) /* 1ms/bit */ #define DCM_MS_TO_P2STAR_BYTES(ms) ((uint16_t)((ms) / 10U)) /* 10ms/bit */ #define DCM_P2_BYTES_TO_MS(b) ((uint32_t)(b)) #define DCM_P2STAR_BYTES_TO_MS(b) ((uint32_t)(b) * 10U)

所有涉及这四个字节的地方,一律走宏,禁止手写>> 8和& 0xFF再顺手除个 10。手写一次就可能错一次,宏写一次就永远对。

还有一个规范层面的点要提醒:P2*Server_max 的取值不应小于 P2Server_max,标准里推荐不低于 5000ms。有些项目为了"响应快",把 P2* 配成 1000ms,看起来敏捷,实际上意味着 ECU 在处理长耗时操作时只有 1 秒的缓冲,稍微复杂点的 Flash 擦除就会超时。P2* 的本质是给 ECU 留的宽容度,不是给客户端省时间用的,配得太小属于自己给自己上枷锁。

2.2 计时起点与终点:一次请求的完整时间轴

光知道数值没用,得知道这两个定时器什么时候启、什么时候停。这块我画不了图,就用文字把时间轴捋一遍,你照着在代码里对照。

P2Server 的计时窗口:起点是 ECU完整接收到请求报文的最后一个字节的那一刻,终点是 ECU开始发送响应报文的第一个字节的那一刻。注意两个细节:一是起点是"完整接收",如果请求是多帧传输(比如 0x2E 写一大块数据,超过 7 字节要走多帧),那 P2 从最后一帧收完才开始算,前面收帧的过程由网络层的 N_Bs、N_Cr 这些定时器管,不占用 P2;二是终点是"开始发送",不是"发送完成",只要第一个字节上了总线,P2 就算履约了。

P2*Server 的计时窗口:这个定时器只有在 ECU 发出了 NRC 0x78 之后才生效。起点是0x78 这个响应报文的最后一个字节发完的那一刻,终点是最终响应(肯定或否定)的第一个字节开始发送。0x78 可以发多次,每发一次都会重置 P2* 的起点,相当于告诉客户端"再等等,我又续了一段时间"。

这两条时间轴合起来,一次典型的长耗时请求是这样的:

客户端 ECU |---- 31 01 FF00 (擦除例程) ------------------->| | | 收到完整请求,启动 P2 (50ms) |<--- 7F 31 78 (收到,处理中) ------------------| 12ms 时发出,P2 停止,启动 P2* (5000ms) | | 正在擦 Flash... |<--- 7F 31 78 (还在处理) ----------------------| 3000ms 时再发一次,P2* 重置 | | 继续擦... |<--- 71 01 FF00 00 (擦除完成) -----------------| 6500ms 时发出最终响应

这里有个非常关键的实现细节,也是我踩过的坑:发 0x78 的时候必须把 P2 定时器停掉,同时启动 P2*。听起来是废话,但实际代码里很容易写成"发完 0x78 之后继续留着 P2 在跑",因为很多人的定时器管理是"发响应就停 P2"这一条规则,而 0x78 严格来说也是一种响应,规则是吻合的。问题出在那些把 0x78 单独走一条发送路径的实现上——主响应走Dcm_SendResponse(),0x78 走Dcm_SendPending(),两个函数各管各的,结果Dcm_SendPending()里忘了停 P2。跑起来的表现是:ECU 明明已经回了 0x78,上位机也切到 P2* 在等了,但 ECU 内部 P2 超时后把自己的诊断请求上下文给清了,等擦除结束想发最终响应的时候,上下文已经没了,最终响应发不出去,上位机在 P2* 时间到了之后报超时。这个 bug 极其隐蔽,因为单看 ECU 日志,0x78 发得好好的,单看上位机日志,也确实收到了 0x78,两边都没错,错在中间那个没人管的定时器。

实操心得:我给 0x78 定了一条铁律——0x78 的发送函数里必须同时完成三件事:发报文、停 P2、重启 P2*。三件事写在一个函数里,不允许调用方在外面补。函数返回失败(比如发送缓冲区满)的时候,P2 不能停,因为 0x78 实际没发出去,客户端根本没收到,这时候还得靠 P2 兜底。

2.3 0x78 与 P2* 的联动规则

0x78 这个 NRC 有点特殊,它不是"错误",而是"进度通知"。客户端收到 0x78 不能当失败处理,必须继续等待。正因为它是进度通知,就有了几个绕不开的规则问题。

规则一:0x78 只能在 P2 时间内发出。如果 ECU 收到请求,磨蹭了 60ms 才想起来要发 0x78,而 P2 只有 50ms,那客户端在 50ms 的时候就已经判超时了,后面这个 0x78 收不收得到都不重要了,整个请求已经废了。所以 0x78 的触发时机必须卡在 P2 到期之前,我一般留出 30%–50% 的余量,50ms 的 P2 就在 20–25ms 时发 0x78,绝不贴着截止线发。

规则二:0x78 的发送次数要有上限。标准层面没有硬性规定次数,但客户端普遍会配一个"最多容忍 N 次 0x78"或者"总等待时间上限"。我见过的实现里,上限从 3 次到 20 次都有,也有直接用 P2* × N 来算总超时的。ECU 侧必须自己也设个上限,不能无限续期,否则一个死循环的处理逻辑会让总线一直挂着 0x78,把整个诊断通道占死。我一般配 10 次,10 × 5000ms = 50 秒,够绝大多数擦写操作了。

规则三:0x78 的报文是固定三字节7F <SID> 78。这里的 SID 是触发 0x78 的那个服务的 SID,不是 0x10。比如擦除例程 0x31 触发 0x78,就是7F 31 78;安全访问 0x27 触发就是7F 27 78。有些实现图省事,写死了7F 10 78,客户端解析的时候会对不上请求上下文,直接判协议错误。

规则四:抑制肯定响应位对 0x78 无效。请求里带了抑制位,只意味着最终的成功响应可以不发,但 0x78 必须照发。因为 0x78 属于否定响应范畴,抑制位管不着它。我见过一个实现,判断到抑制位为 1,就把整个响应通道关了,包括 0x78,结果上位机在 P2 到期后直接超时,而 ECU 那边其实还在正常干活,最终响应发出来也没人要了。

规则五:P2* 只在 0x78 之后生效,不能提前用。有些实现为了"宽松",收到请求就直接按 P2* 计时,等于 P2 形同虚设。这会导致一个后果:正常应该在 50ms 内快速返回的请求(比如读个 DID),ECU 慢吞吞地 4 秒才回,上位机也认,因为按 P2* 算没超时。这在开发阶段很"友好",掩盖了大量性能问题,到了量产车上就是诊断仪操作卡顿、刷写流程不稳。P2 该严就严。

3. 在 ECU 里把定时器跑起来

前两节讲的都是"应该是什么样",这一节讲"代码怎么写"。我拿一个典型的分层结构来说:诊断层(Dcm)负责服务分发和会话管理,传输层负责报文的收发和定时器触发,中间通过回调打通。下面给的代码是精简后的骨架,能看出逻辑,实际的工程代码还要加上很多防御性判断。

3.1 时间基准与定时器资源

先说时间基准选型,这个决定了很多后续实现细节。常见的有三种做法:

做法一,直接用系统毫秒计数器做差值比较。就是每次进主函数读一下当前 tick,跟记录的开始 tick 做减法。这种做法最省资源,不需要额外的硬件定时器,代码也最简单。缺点是精度取决于主函数调度周期,如果主函数是 10ms 跑一次,那 P2 的 50ms 判定实际可能在 40–60ms 之间摆动,误差 20%。对于 P2 = 50ms 这个量级,20% 的误差意味着实际可能 60ms 才判超时,勉强能接受,但如果 P2 配到 20ms,误差就致命了。

做法二,用专门的硬件定时器中断。精度高,但占用硬件资源,而且诊断模块如果依赖某一个特定的硬件定时器,移植起来很麻烦。

做法三,用系统 tick 但提高主函数调度频率。比如主函数 1ms 跑一次,定时器判定精度就是 1ms。这是我目前最推荐的做法,兼顾了精度和可移植性。大部分车规 MCU 跑 1ms 主循环完全没压力。

我的选择是做法三,理由是 P2 这个参数本身就要求毫秒级精度,用 10ms 调度的方案去做 50ms 的判定,属于用错工具。而且诊断模块的主函数(Dcm_MainFunction)本来就应该高频调度,因为它还要处理其他定时逻辑,比如 S3Server 的 5 秒判超时、响应挂起队列的轮询。

定时器数据结构我习惯用一个简单的结构体,把"是否运行""起始时刻""超时阈值"打包在一起,避免散落的全局变量:

typedef struct { uint32_t startTick; uint32_t timeoutMs; bool running; } Dcm_TimerType; static Dcm_TimerType g_p2Timer; static Dcm_TimerType g_p2StarTimer; static Dcm_TimerType g_s3Timer; static inline void Dcm_TimerStart(Dcm_TimerType *t, uint32_t timeoutMs) { t->startTick = GetSystemTickMs(); t->timeoutMs = timeoutMs; t->running = true; } static inline void Dcm_TimerStop(Dcm_TimerType *t) { t->running = false; } static inline bool Dcm_TimerExpired(const Dcm_TimerType *t, uint32_t now) { return t->running && ((now - t->startTick) >= t->timeoutMs); }

注意这里用的是(now - startTick) >= timeout而不是now >= startTick + timeout。前者在 tick 计数器回绕的时候依然正确,后者会在回绕点附近出现灾难性的判定错误。32 位毫秒计数器 49 天回绕一次,看着很远,但车规产品要求在车上跑十几年,回绕是必然要处理的。这个细节在很多开源实现里都是错的。

3.2 状态机落地的代码骨架

请求到达的处理入口,核心是四件事:校验、重置 S3、启动 P2、分发。

void Dcm_OnRequestIndication(const uint8_t *req, uint16_t len, bool isFunctional) { /* 1. 0x10 不接受功能寻址,直接丢弃 */ if (req[0] == 0x10U && isFunctional) { return; } /* 2. 除 0x10 外的任何有效请求都重置 S3 会话保持定时器 */ if (req[0] != 0x10U) { Dcm_TimerStart(&g_s3Timer, DCM_S3SERVER_MS); /* 5000ms */ } /* 3. 启动 P2,起点是完整请求接收完成的这一刻 */ Dcm_TimerStart(&g_p2Timer, g_P2ServerMax_ms); Dcm_TimerStop(&g_p2StarTimer); g_pendingRespCount = 0U; /* 4. 分发到具体服务处理 */ Dcm_Dispatch(req, len); }

会话切换的处理函数,这是整个 0x10 实现的核心:

Std_ReturnType Dcm_ProcessSessionControl(const uint8_t *req, uint16_t len) { uint8_t subFunc; uint8_t targetSession; bool suppressPosRsp; if (len != 2U) { return Dcm_SendNrc(0x10U, 0x13U); /* 长度错误 */ } suppressPosRsp = ((req[1] & 0x80U) != 0U); targetSession = (uint8_t)(req[1] & 0x7FU); /* 会话值合法性 + 当前会话是否允许切到目标会话 */ if (Dcm_SessionSupported(targetSession) == false) { return Dcm_SendNrc(0x10U, 0x12U); /* 子功能不支持 */ } if (Dcm_SessionTransitionAllowed(g_curSession, targetSession) == false) { return Dcm_SendNrc(0x10U, 0x7FU); /* 当前会话不支持该子功能 */ } if (Dcm_SessionPreConditionOk(targetSession) == false) { return Dcm_SendNrc(0x10U, 0x22U); /* 条件不满足 */ } /* 参数先存到待生效区,等响应发完再真正切 */ g_pendingSession = targetSession; if (suppressPosRsp) { Dcm_ApplyPendingSession(); /* 抑制响应也要切会话 */ return E_OK; } return Dcm_SendSessionPositiveResponse(targetSession); }

这里我特意把"切会话"和"发响应"分开成两步,因为顺序有讲究。会话参数的切换必须发生在正响应报文发送完成之后,不能提前。原因在于:如果 ECU 在发响应之前就把 P2 改成新会话的值(比如从扩展会话切编程会话,编程会话的 P2* 可能配得更长),那这一条 0x10 响应本身的计时判断就会被新参数影响。虽然实践中影响不大(0x10 响应本来就很快),但严格来说这是错的,而且一旦项目里两个会话的 P2 配置差异巨大(比如默认会话 50ms、编程会话 500ms),就会出现 0x10 响应偶尔被判超时的诡异现象。客户端在收到正响应之前,用的还是旧会话的参数,ECU 也必须用旧参数来履约。

发送响应的函数,把四个时间参数填进去:

Std_ReturnType Dcm_SendSessionPositiveResponse(uint8_t session) { uint8_t resp[6]; uint16_t p2Bytes, p2StarBytes; /* 取出目标会话对应的 P2 / P2* 配置 */ Dcm_GetSessionTiming(session, &p2Bytes, &p2StarBytes); resp[0] = 0x50U; resp[1] = session; /* 抑制位强制清 0 */ resp[2] = (uint8_t)(p2Bytes >> 8); resp[3] = (uint8_t)(p2Bytes & 0xFFU); resp[4] = (uint8_t)(p2StarBytes >> 8); resp[5] = (uint8_t)(p2StarBytes & 0xFFU); Dcm_TimerStop(&g_p2Timer); if (CanTp_Send(0x7E8U, resp, 6U) != E_OK) { return E_NOT_OK; } /* 响应已发出,现在才真正切换会话上下文 */ Dcm_ApplyPendingSession(); return E_OK; }

0x78 的发送,前面说的三件事必须在一个函数里做完:

Std_ReturnType Dcm_SendPendingResponse(uint8_t sid) { uint8_t nrc[3] = {0x7FU, sid, 0x78U}; if (g_pendingRespCount >= DCM_MAX_PENDING_CNT) { /* 超过次数上限,放弃本次请求,不再续期 */ Dcm_AbortCurrentRequest(); return E_NOT_OK; } if (CanTp_Send(0x7E8U, nrc, 3U) != E_OK) { /* 发送失败,P2 保持运行,不要停 */ return E_NOT_OK; } g_pendingRespCount++; Dcm_TimerStop(&g_p2Timer); /* 停 P2 */ Dcm_TimerStart(&g_p2StarTimer, g_P2starServerMax_ms); /* 启 P2* */ return E_OK; }

主函数里做超时判定:

void Dcm_MainFunction(void) { uint32_t now = GetSystemTickMs(); /* P2 超时:没来得及发 0x78,也没发最终响应 */ if (Dcm_TimerExpired(&g_p2Timer, now)) { Dcm_TimerStop(&g_p2Timer); Dcm_AbortCurrentRequest(); } /* P2* 超时:发了 0x78 但最终响应没按时出来 */ if (Dcm_TimerExpired(&g_p2StarTimer, now)) { Dcm_TimerStop(&g_p2StarTimer); Dcm_AbortCurrentRequest(); } /* S3 超时:自动回默认会话 */ if (Dcm_TimerExpired(&g_s3Timer, now)) { Dcm_TimerStop(&g_s3Timer); Dcm_ReturnToDefaultSession(); } }

超时之后调Dcm_AbortCurrentRequest(),做的事情是清掉当前的请求上下文、停止正在执行的处理逻辑、释放占用的缓冲区。这里有个细节容易漏:超时之后不应该再发任何响应。因为客户端的定时器早就到期了,它已经判定这次请求失败,甚至可能已经开始发下一个请求了。这时候你突然吐出一个迟到的响应,客户端会把它归到下一个请求的上下文里,直接导致请求响应错位。我见过有项目在这里不干净,擦除操作超时后 3 秒才发出响应,上位机把它当成了后续读 DID 请求的响应,解析出一堆乱的 DID 数据,排查了半天才发现是上一个请求的迟到响应。

3.3 S3Server 与回默认会话的清理动作

S3Server 严格来说不属于 0x10 的响应参数,但它跟 0x10 是绑死的:会话切换到扩展或编程之后,靠 S3Server 来保证不会永久停在那里。默认值 5000ms,每次收到非 0x10 的诊断请求就重置。

为什么排除 0x10?因为 0x10 自己就是会话管理服务,如果它也重置 S3,会出现"连续发 0x10 就能永久保持会话"的漏洞。不过这个规则有个例外:收到 0x10 请求时,如果会话切换成功,S3 应该在新会话生效后重新启动。也就是说 0x10 不重置 S3,但会话变更会重启 S3。这两件事看起来像,实现上要分开处理。

回默认会话的清理动作,是整个 0x10 实现里最容易埋雷的地方。我列一下标准里要求、以及实际项目里必须做的动作:

清理项服务不清理的后果
安全访问状态复位为锁定0x27会话超时后仍处于解锁态,等于安全机制失效
所有 IO 控制恢复到正常控制0x2F某个引脚被强制拉高后一直保持,可能烧毁执行器
DTC 设置恢复使能0x85后续故障无法存储,售后排查无据可查
通信控制恢复正常收发0x28某些报文被禁用后不恢复,整车通信异常
进行中的例程全部终止0x31例程后台还在跑,跟新请求抢资源
大块数据传输上下文清空0x34/0x36残留的缓冲区指针悬空,下次传输直接跑飞
void Dcm_ReturnToDefaultSession(void) { if (g_curSession == DCM_SESSION_DEFAULT) { return; /* 已在默认会话,无事可做 */ } Security_ResetToLocked(); /* 1. 锁安全访问 */ IoControl_ResetAll(); /* 2. 恢复所有 IO */ Dem_EnableDtcSetting(); /* 3. 恢复 DTC 存储 */ CommControl_RestoreAll(); /* 4. 恢复通信 */ Routine_AbortAll(); /* 5. 终止例程 */ Transfer_ClearContext(); /* 6. 清传输上下文 */ g_curSession = DCM_SESSION_DEFAULT; g_P2ServerMax_ms = DCM_P2_DEFAULT_MS; /* 50 */ g_P2starServerMax_ms = DCM_P2STAR_DEFAULT_MS;/* 5000 */ Dcm_TimerStop(&g_p2Timer); Dcm_TimerStop(&g_p2StarTimer); Dcm_TimerStop(&g_s3Timer); }

这里我最想强调第 1 条和第 2 条。安全访问状态不清,等于会话超时机制形同虚设,因为攻击者只要解锁一次,之后哪怕会话回默认了,某些实现里如果安全状态判断只看"是否解锁"不看"当前会话",就能继续执行受保护操作,这是功能安全审查里的必查项。IO 控制不恢复更直接,扩展会话里把风扇强制开到 100%,会话超时回默认,风扇就一直满速转,车主第二天早上发现电瓶没电了。这类问题在测试阶段不一定能暴露,因为测试人员总是"用完就主动恢复",而真实场景里用户拔掉诊断仪就走了,全靠 S3 超时自动兜底。

4. 上位机侧验证与边界测试

ECU 代码写完只是开始,真正能证明 0x10 和 P2/P2* 实现正确,得靠上位机侧的验证。这一节讲怎么用脚本把这两个定时器压到边界上,以及测试用例怎么设计。

4.1 用脚本把 P2 边界压出来

我平时用 Python 的 udsoncan 库做快速验证,配置起来比商业工具灵活,适合反复调参数:

from udsoncan.connections import PythonIsoTpConnection from udsoncan.client import Client from udsoncan import configs, services import isotp import time # 传输层配置,注意 blocksize 和 stmin 要与 ECU 侧匹配 tp_addr = isotp.Address(isotp.AddressingMode.Normal_11bits, txid=0x7E0, rxid=0x7E8) stack = isotp.CanStack(bus=bus, address=tp_addr, params={'blocksize': 8, 'stmin': 0}) conn = PythonIsoTpConnection(stack) # 客户端侧超时要比 ECU 声明的略大,留出总线传输和调度抖动余量 cfg = configs.default_client_config cfg['p2_timeout'] = 0.050 + 0.050 # ECU 承诺 50ms,客户端等 100ms cfg['p2_star_timeout'] = 5.0 + 1.0 # ECU 承诺 5000ms,客户端等 6s with Client(conn, config=cfg) as client: # 进入扩展会话 rsp = client.change_session(services.DiagnosticSessionControl.Session.extendedDiagnosticSession) print("当前会话:", rsp.service_data.session_echo) # 主动读一下 DID,确认会话真的切了 rsp = client.read_data_by_identifier(0xF186) print("活动会话 DID:", rsp.service_data.values[0xF186])

关键在于p2_timeout的设定逻辑:客户端超时必须严格大于 ECU 声明的 P2,但也不能大太多。大太多的话,ECU 实际响应慢了你也测不出来;大太少的话,总线传输时延和任务调度抖动会让你误判。我的经验是比 ECU 声明的值大 50% 到 100%,具体看总线的负载情况。50ms 的 P2 配 100ms 客户端超时,是我用得最顺的一档。

如果要精确测 ECU 的实际响应时延,光靠 udsoncan 的日志不够,得上总线分析工具抓时间戳。我的做法是让 ECU 在响应发出的瞬间翻转一个调试引脚,用示波器同时抓 CAN_H/CAN_L 和这个引脚,就能精确到微秒级地看到从"请求最后一帧接收完"到"响应第一帧开始发"的时间差。这个手段在排查"到底是不是真的超了 50ms"这类争议时特别管用,比翻日志可靠得多。

CAPL 侧也可以做类似的事,用setTimer起一个精度更高的定时器,在on message里处理:

variables { msTimer tP2Watch; int gP2Expired = 0; } on message 0x7E0 { if (this.byte(0) == 0x10) { gP2Expired = 0; setTimer(tP2Watch, 55); // ECU 声明 50ms,这里多留 5ms 观察余量 } } on timer tP2Watch { gP2Expired = 1; write("P2 观察窗口已过,若此时仍未收到响应则判定超时"); }

CAPL 的定时器精度受 CANoe 调度影响,一般能做到几毫秒,够做粗筛,精确测量还是得上示波器或者带硬件时间戳的 CAN 卡。

4.2 边界测试用例设计

这部分是我从几个项目的诊断测试规范里归纳的,覆盖了 0x10 和 P2/P2* 最容易出问题的组合。直接抄成表格填进你的测试计划就行。

编号用例描述前置条件预期结果
TC-01正常进入扩展会话默认会话返回50 03 00 32 01 F4,DID F186 读出 0x03
TC-02重复进入当前会话已在扩展会话仍返回肯定响应,不报 NRC
TC-03非法子功能值默认会话返回7F 10 12
TC-04报文长度不足(只有 1 字节)默认会话返回7F 10 13
TC-05抑制肯定响应位默认会话无任何响应,但会话已切换
TC-06功能寻址下发 0x10默认会话无响应(或 NRC 0x11,视项目规范)
TC-07P2 边界:响应在 45ms 内返回默认会话客户端正常接收
TC-08P2 边界:响应在 80ms 返回且未发 0x78默认会话客户端按 P2 判超时
TC-09先发 0x78 再在 1s 后返回最终响应编程会话客户端正常接收
TC-100x78 后 6s 才返回最终响应编程会话客户端按 P2* 判超时
TC-11连续 12 次 0x78编程会话超过 ECU 侧上限后终止处理
TC-12扩展会话空闲 5.5s扩展会话自动回默认会话
TC-13扩展会话每 4s 发一次 3E 80扩展会话会话保持,不回退
TC-14编程会话下切回扩展会话编程会话视项目规范,多数返回 NRC 0x7F
TC-15S3 超时后检查安全访问状态扩展会话已解锁访问受保护 DID 返回 NRC 0x33

TC-08 和 TC-10 这两条是最有测试价值的。TC-08 验证的是 P2 真的生效了,没被 P2* 悄悄兜底;TC-10 验证的是 P2* 的边界,而且要注意 6 秒这个值是怎么来的——ECU 声明 5000ms,客户端等 6000ms,如果 ECU 在 5500ms 才响应,客户端会拒收,但如果客户端配成了 10 秒,就会误认为通过。所以客户端超时配置本身也是被测对象的一部分,测试规范里要写死。

TC-13 是刷写流程里必须守住的一条。我参与过的一个项目,刷写流程里有个擦除动作耗时 18 秒,中间没有任何诊断请求,结果 S3 在 5 秒时超时,ECU 自动回默认会话,擦除到一半的 Flash 区域被切断,ECU 直接变砖。后来在擦除循环里加了每 4 秒发一次3E 80,问题解决。这个坑我在后面的速查表里再提一次,因为它太典型了。

5. 踩坑记录与排查速查表

前面几节偏"应该怎么做",这一节全是"实际会出什么问题"。我挑了几个印象最深的,每个都带着排查路径。

5.1 几个值得单独讲的坑

坑一:0x78 发了,但 P2 没停,导致最终响应发不出去。前面 2.2 节详细讲过,表现是上位机收到 0x78 后等到 P2* 超时,ECU 日志显示处理逻辑其实正常完成了,就是响应没出来。排查方法:在Dcm_SendPending()里加一条打印,把 P2 和 P2* 的 running 标志打出来,一眼就能看出来。修复就是把两个定时器的操作收敛到同一个函数里。

坑二:会话切换时 P2 参数切早了,导致 0x10 响应偶尔被误判超时。表现是极低概率的 0x10 偶发超时,复现率可能千分之一。这个坑极其难查,因为大部分时候没事。根因是切会话的代码写在发响应之前,两个会话的 P2 配置差异大时才会暴露。修复就是把Dcm_ApplyPendingSession()挪到发送成功之后。这个坑我建议不管你的项目有没有出现过,都先去代码里看一眼调用顺序,成本为零。

坑三:S3 超时回默认,但安全访问状态没清。表现是会话超时后依然能执行受保护的写操作,功能安全测试挂。修复是在Dcm_ReturnToDefaultSession()里补齐清理清单。我建议把这个清理清单做成代码审查的检查项,一行一行对着勾,别凭记忆。

坑四:擦除例程耗时超过 S3,会话被自动切回默认。表现是刷写流程走到一半失败,ECU 侧报会话错误,上位机侧看就是某个 0x31 或 0x36 请求返回 NRC 0x7F。修复是刷写流程里加 3E 80 保活,每 4 秒一次。注意 3E 80 的抑制位要带上,否则每次保活都会收到一个7E 00响应,浪费总线带宽;但即使带了抑制位,3E 80 本身也仍然会重置 S3,这才是保活的本质。

坑五:多帧请求下 P2 计时起点搞错。表现是写大块数据时偶发超时。根因是有实现把 P2 的起点放在了首帧接收时,而不是完整请求接收完。首帧到末帧之间的传输时间(尤其是 blocksize 小、stmin 大的情况)被错误地算进了 P2 窗口,自然不够用。修复是在传输层加一个"请求完成"回调,P2 从这里启动。

坑六:P2* 配得比 P2 还小。表现是配了一个 30ms 的 P2*,看着"响应快",实际上一发 0x78 就立刻超时,因为 P2* 到期的瞬间就到了。这种配置在一致性测试里会被直接判错。修复没什么好说的,P2* 至少给 5000ms。

5.2 问题排查速查表

现象最可能的原因快速验证方法修复方向
发 0x10 无任何响应走了功能寻址被丢弃抓总线确认 CAN ID改用物理寻址
0x10 返回 NRC 0x12子功能值非法确认请求第二个字节检查上位机会话枚举值
0x10 返回 NRC 0x22车速/转速条件不满足读车辆状态 DID停稳车、熄火后重试
0x10 返回 NRC 0x7F编程会话下不允许切确认当前会话先切扩展再切编程
0x10 响应里 P2* 看着像 50000ms单位按 1ms 算了按 10ms 单位反算改配置值
0x78 后上位机仍超时P2* 配得太小读响应里的 P2* 字节提到 5000ms 以上
0x78 后 ECU 无最终响应P2 没停,请求上下文被清打印定时器状态发送函数里收敛定时器操作
擦除/刷写中途失败S3 超时回默认抓总线看是否 5s 无请求加 3E 80 保活
会话超时后仍能写受保护数据安全状态未复位S3 超时后读受保护 DID补齐会话变更清理
0x10 偶发超时(千分之一)P2 参数切换早于响应发送审查调用顺序响应发送后再生效
大块数据写入偶发超时P2 起点算在首帧增大 stmin 复现改为完整请求后启动
IO 控制后引脚不复位会话切换未恢复 IO会话超时后测引脚电平加 IO 复位
0x10 抑制位下会话没切实现里抑制位短路了整个处理抑制位请求后读 DID F186抑制位只抑制响应

这张表我建议放在项目 Wiki 的首页,新同事接手诊断模块先看这个,能省掉大量重复排查。表里的"快速验证方法"一列是我特意加的,因为很多问题的定位成本远高于修复成本,有了验证手段就能快速收敛。

最后再提一个容易被忽略的细节,关于 0x10 响应的子功能回显。标准要求肯定响应里回显的子功能值必须清掉抑制位,也就是说请求10 83,响应(如果有的话)里回的应该是03不是83。这个规则看着琐碎,但客户端解析的时候是拿回显值跟请求值做匹配的,回显里带着抑制位会导致匹配失败,被当成非法响应丢弃。我见过一个实现里直接用req[1]回填,抑制位原样带过去了,测试用例 TC-05 一跑就露馅。这类问题属于"标准里写了但容易看漏"的范畴,实现的时候要把每一条报文格式要求对着标准逐字节核一遍,别靠感觉。

至于 P2 和 P2* 的配置值最终怎么定,我的思路是:先看项目的诊断规范

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询