没有DIVA?CAPL手写UDS自动化诊断测试框架实战

📅 发布时间:2026/10/21 16:51:06
没有DIVA?CAPL手写UDS自动化诊断测试框架实战
先说一个在场很多人都有过的处境CANoe装好了DIVA没买但需求单上写着“基于UDS的自动化回归测试”。如果只会手动发报文、肉眼对NRC几十条用例还能扛上百条谁都受不了。我后来用CAPL在CANoe里自建了一套基于ISO 14229的UDS自动化测试脚本不依赖DIVA照样跑完整个项目的诊断回归。这篇文章就把这套方案的思路、框架、代码骨架和踩坑记录完整放出来适合正在用CANoe做ECU诊断测试、手里却没有DIVA授权的朋友参考。1. 为什么没有DIVA时我坚持自己写CAPL测试脚本1.1 没有DIVA不等于没有自动化手段先聊清楚DIVA到底帮你做了什么。DIVA是CANoe里专门做诊断自动化验证的插件导入诊断描述文件之后它能自动生成一套覆盖UDS常用服务的测试序列跑完自动出报告。功能确实强但现实问题是它属于额外授权不是每个CANoe安装包都带而且它的用例生成流程偏向“开箱即用”对测试细节的可控性反而没那么高。我认识一位同事项目上只买了基础CANoeDIVA没配诊断测试却一点没少最后就是靠CAPL脚本一条条把用例写出来的。CAPL本身就是CANoe的标准组成部分只要能写CAPL就相当于免费拿到了一个可以自定义的测试框架。你不需要额外申请工具授权不需要等供应商给你配置测试序列直接在一台装了CANoe的电脑上就能开工。这套方案的核心价值不是“替代DIVA”而是“在无DIVA的情况下把自动化测试跑起来”。从实际工作量看UDS自动化测试里最高频的场景是有数的会话切换、安全访问、读写数据、例程控制、DTC操作、保活。这些服务的请求格式固定、响应结构可预期非常适合用CAPL封装成通用函数然后按用例组合。只要把这几个能力封装好后面的测试用例就像搭积木一样写一条新用例的成本会非常低。1.2 自建CAPL的边界与适用场景自己写脚本不意味着所有场景都硬扛。我给自己定的判断标准是用例量在一百条上下、诊断服务相对固定、需要深度定制断言和报告逻辑的项目自建CAPL是性价比最高的路线。反过来如果你要做的是整个OEM全量诊断描述验证几百上千条服务、复杂的DID/子功能矩阵那种规模还是老实买商业诊断自动化工具。还有一点自建CAPL对协议理解的要求更高。你至少需要知道请求帧的字节是怎么组织的、NRC在哪个字节、多帧怎么拼。但也正因为如此脚本出问题的时候你能直接看到字节级现场排查起来比黑盒工具透明得多。我后来做故障复现时经常是直接在脚本里加一行日志把请求和响应的原始字节打出来对照14229协议文档就能定位问题。2. 动手前先把14229和CANoe寻址这两块理清2.1 UDS请求/响应结构与NRCUDS的报文结构很清晰请求是一个SID加若干参数正响应的SID是请求SID加0x40负响应则是固定的三个字节0x7F、请求SID、NRC。自动化脚本里所有的断言逻辑本质就是在校验这三件事响应SID是不是预期值子功能或参数区对不对有没有NRC。常用的服务我列一下脚本里基本都会用到功能描述请求SID正响应SID典型子功能/参数诊断会话控制0x100x500x01默认、0x02编程、0x03扩展安全访问0x270x670x01请求种子、0x02发送密钥按DID读数据0x220x62DID低两字节按DID写数据0x2E0x6EDID低两字节数据例程控制0x310x710x01启动、0x02停止、0x03查询结果读取DTC信息0x190x590x01按状态掩码读清除DTC0x140x54DTC三字节保活0x3E0x7E0x00/0x80负响应的NRC需要单独维护一张表。常见的有0x12子功能不支持0x13长度错误0x22条件不满足0x31请求超出范围0x33安全访问失败0x35会话不允许0x36安全访问被延迟。测试脚本里如果收到负响应我习惯把NRC单独存到一个全局变量断言失败时打印出来这样报告里可以直接看到“为什么没通过”。2.2 物理寻址、功能寻址与CAN ID约定在CANoe里写脚本之前先搞清寻址模式。UDS测试常用两种功能寻址和物理寻址。功能寻址是发给总线上所有ECU的典型CAN ID是0x7DF响应不一定有因为每个ECU都可能回。物理寻址是点对点发给某一个ECU比如请求用0x7E0对应ECU的响应用0x7E8。这一个节点一个ID多ECU时就是0x7E1/0x7E9依次往后排。这个基础如果搞错最常见的现象就是脚本发出去一个请求on message里死活等不到响应。你以为是波特率问题、报文周期问题其实只是ID方向配反了请求发到了0x7E8响应却回了0x7E0跟我预期完全是反的。注意这些ID并不是14229强制规定的而是CAN总线诊断应用里的常规约定。不同OEM可能改ID所以写脚本前一定要先确认诊断规范里的地址配置。2.3 没有DIVACDD/ODX还要不要加载很多朋友以为“没有DIVA”就等于“没有诊断描述文件”其实这是两码事。DIVA是自动化测试执行和报告生成的工具而CDD/ODX是诊断数据库描述ECU支持哪些服务、DID、DTC、安全访问参数。CANoe工程加载了CDD之后CAPL里可以用诊断对象来发请求ISO-TP多帧、寻址和定时参数都交给工具处理脚本会干净很多。如果连CDD都没有那就只能走裸CAN帧路线自己组装CAN message自己处理单帧/多帧判断响应也要自己解析字节。这两条路我在后面都会给出思路。我的建议是能拿到CDD就先加载CDD没有CDD再上裸帧但要做好多帧处理的准备。3. 一个不依赖DIVA的CAPL诊断测试框架怎么搭3.1 工程层面的准备工作CANoe工程的配置不复杂但有几个点会影响脚本运行。建工程时选对CAN通道把波特率配成和被测ECU一致如果网络里挂了好几个ECU测试脚本只关心目标ECU通道上最好单独连那个节点的模拟部分。然后定义两条报文一条作为诊断请求ID设为物理请求ID一条作为诊断响应ID设为目标ECU的响应ID。有DBC就直接在数据库里建没有DBC也可以在CAPL里直接使用ID常量。另外强烈建议在工程里单独建一个CAPL Test Module而不是把逻辑全堆在on key按键事件里。Test Module自带测试用例管理、报告生成、测试分组写起来更接近一个真正的自动化测试工程。按键脚本适合临时验证不适合回归回归测试一定要走Test Module。3.2 裸CAN方式请求发送与响应接收的核心封装在没有CDD的情况下我最常用的做法是封装两个基础能力一个函数负责发请求一个on message事件负责收响应。发请求函数byte gRespData[4096]; dword gRespLen; byte gRespSID; byte gNRC; void SendUDSRequest(byte data[], dword len) { message *req; req.id 0x7E0; // 物理请求ID按项目改 req.DLC len; int i; for (i 0; i len; i) { req.byte(i) data[i]; } output(req); } on message 0x7E8 // 物理响应ID按项目改 { gRespLen this.DLC; gRespSID 0x00; gNRC 0xFF; int i; for (i 0; i this.DLC; i) { gRespData[i] this.byte(i); } if (gRespLen 3 gRespData[1] 0x7F) { gNRC gRespData[2]; // 负响应码 } else if (gRespLen 1) { gRespSID gRespData[0]; // 正响应SID } }这段代码把“发送”和“接收”解耦了。业务逻辑只关心三个全局变量gRespData存放响应原始字节gRespLen是响应长度gNRC在没有负响应时保持0xFF。后面的测试用例都基于这三个变量做断言非常直接。这里有个容易被忽略的点请求和响应之间要留出足够的时间。ECU处理诊断请求不是瞬时的有的服务还要擦写Flash、跑校验算法几十毫秒到几百毫秒都可能。我用TestWaitForTimeout并传入比ECU超时时间略大的等待值比如150到300毫秒。3.3 ISO-TP多帧处理的简化策略上面的封装默认请求和响应都在单帧内完成。单帧的特点是第一个字节是PCI高四位是0x00到0x0F表示长度所以长度不超过7字节时一帧就能装下。很多UDS请求都满足这个条件。但响应可能超长比如0x22读VIN加一堆车辆信息、0x19读一长串DTC这时ECU会走ISO-TP多帧。多帧的第一帧PCI是0x10开头后面跟着总长度和第一个数据块后续帧以0x21、0x22……开头每帧最多7个字节数据。如果在裸CAN方式下解析多帧我需要维护一个接收状态机用on message事件接收后按PCI头判定是第一帧还是后续帧然后把负载按序拼进gRespData。这个逻辑不算难但纯手写容易在后续帧序号、超时复位、长度越界这几个地方出bug。所以我的实际建议是如果工程能加载CDD就用诊断层对象让CANoe自己完成ISO-TP拼帧如果实在没有CDD再手写状态机。3.4 测试用例、断言与报告组织测试用例的组织方式我用的是Test Module加testcasetestcase TC_10_02_ProgrammingSession() { byte req[2]; req[0] 0x10; req[1] 0x02; TestStep(Session, Send 10 02); SendUDSRequest(req, 2); TestWaitForTimeout(300); if (gRespLen 2 gRespData[0] 0x50 gRespData[1] 0x02) { TestStep(Session, Positive response, session switched); TestPass(); } else { char buf[128]; snprintf(buf, elcount(buf), Unexpected response, NRC0x%02X, gNRC); TestFail(buf); } }每个testcase里有几个固定动作发请求、等待、断言、通过或失败标记。TestPass和TestFail的输出会自动进入CANoe的测试报告报告里能区分哪些用例通过、哪些失败、失败时打的是什么日志。报告要能真正辅助排查问题就一定要把请求和响应的原始字节打印进去。我习惯用一个工具函数把gRespData[0..gRespLen-1]整体转成十六进制字符串再在断言失败时打出来。否则报告里只有“响应不对”四个字事后还要重现现场效率很低。4. 六个高频UDS用例的CAPL实现示例4.1 0x10会话切换会话切换是UDS测试里最基础的服务很多功能只在扩展会话或编程会话下才允许执行所以自动化脚本的第一步通常是切到目标会话。testcase TC_10_01_DefaultSession() { byte req[2] {0x10, 0x01}; SendUDSRequest(req, 2); TestWaitForTimeout(200); if (gRespLen 2 gRespData[0] 0x50 gRespData[1] 0x01) { TestPass(); } else { char buf[100]; snprintf(buf, elcount(buf), Session switch failed, NRC0x%02X, gNRC); TestFail(buf); } }会话切换的断言很简单正响应SID加0x50子功能原样返回。要额外注意一点部分ECU在编程会话下会关闭CAN通信或调整波特率切换前要确认测试环境能承受这种状态变化。4.2 0x27安全访问安全访问是UDS里最典型的流程型测试先发0x27 0x01拿种子然后计算密钥再发0x27 0x02把密钥送过去。种子长度和密钥算法完全取决于ECU项目协议本身不规定。我的做法是把密钥算法单独封装成一个函数测试脚本不关心算法细节只关心结果。testcase TC_27_01_SecurityAccess() { byte req[2] {0x27, 0x01}; SendUDSRequest(req, 2); TestWaitForTimeout(300); if (gRespLen 4 || gRespData[0] ! 0x67) { TestFail(Seed request failed); return; } byte seed[8]; dword seedLen gRespLen - 2; // 去掉0x67和子功能 int i; for (i 0; i seedLen; i) { seed[i] gRespData[i 2]; } byte key[8]; dword keyLen 0; SecurityKeyGen(seed, seedLen, key, keyLen); // 项目相关算法 byte keyReq[10]; keyReq[0] 0x27; keyReq[1] 0x02; for (i 0; i keyLen; i) { keyReq[2 i] key[i]; } SendUDSRequest(keyReq, 2 keyLen); TestWaitForTimeout(300); if (gRespLen 2 gRespData[0] 0x67 gRespData[1] 0x02) { TestPass(); } else { char buf[100]; snprintf(buf, elcount(buf), Security access failed, NRC0x%02X, gNRC); TestFail(buf); } }安全访问测起来有几个特点种子一般是4到8字节响应常常走多帧密钥算法错了往往返回0x13或者0x33排查时先分清是算法问题还是报文长度问题连续失败几次后ECU可能进入延迟状态返回0x36所以安全访问用例最好独立跑不要排在频繁失败的用例后面。4.3 0x22按DID读数据读数据在回归测试里主要干两件事验证某个DID的值符合预期配合写服务做“写后读回”校验。以读VIN为例VIN的DID是0xF190请求22 F1 90正响应是62 F1 90后面跟17个ASCII字符。testcase TC_22_F190_ReadVIN() { byte req[3] {0x22, 0xF1, 0x90}; SendUDSRequest(req, 3); TestWaitForTimeout(300); if (gRespLen ! 20 || gRespData[0] ! 0x62) { char buf[120]; snprintf(buf, elcount(buf), Unexpected VIN response, len%d, gRespLen); TestFail(buf); return; } if (gRespData[1] 0xF1 gRespData[2] 0x90) { TestStep(VIN, DID OK); TestPass(); } else { TestFail(VIN DID mismatch); } }如果项目里已经定义了CDD这个用例甚至可以更简洁CAPL诊断对象可以自动把DID解析成结构化字段。但裸CAN方式下DID就是两个字节别把DID的字节序搞反有的ECU高位在前有的低位在前写死之前先抓一条参考报文核对。4.4 0x2E写数据后再读回写数据测试比单纯读数据多一层风险如果DID是保护性参数比如标定值、配置字写错可能让ECU进入异常状态。因此我强烈建议“写后读回”作为标准动作写进去什么再读出来就应该是什么。testcase TC_2E_F192_WriteAndReadBack() { byte writeReq[6] {0x2E, 0xF1, 0x92, 0x01, 0x02, 0x03}; SendUDSRequest(writeReq, 6); TestWaitForTimeout(300); if (gRespLen 3 || gRespData[0] ! 0x6E) { char buf[100]; snprintf(buf, elcount(buf), Write failed, NRC0x%02X, gNRC); TestFail(buf); return; } // 读回同样的DID byte readReq[3] {0x22, 0xF1, 0x92}; SendUDSRequest(readReq, 3); TestWaitForTimeout(300); if (gRespLen 6 gRespData[0] 0x62 gRespData[3] 0x01 gRespData[4] 0x02 gRespData[5] 0x03) { TestPass(); } else { TestFail(Write back mismatch); } }这种用例很适合做成参数化框架把DID、写入数据、期望读回数据放到一个数组表里循环跑减少重复代码。但注意不要无限制扩展参数表否则用例失败时定位到具体哪一行会比较费劲。4.5 0x31例程控制例程控制的典型场景是运行一个自检例程或擦写例程。请求格式是31 01 RID_HI RID_LO [参数]正响应是71 01 RID_HI RID_LO加状态字节。有些例程是异步的启动后要等一段时间才能查询结果脚本不能只等一个响应就收工。testcase TC_31_01_StartRoutine() { byte req[5] {0x31, 0x01, 0xFF, 0x00, 0x00}; SendUDSRequest(req, 5); TestWaitForTimeout(300); if (gRespLen 5 || gRespData[0] ! 0x71 || gRespData[3] ! 0xFF) { char buf[100]; snprintf(buf, elcount(buf), Routine start failed, NRC0x%02X, gNRC); TestFail(buf); return; } // 例程执行结果状态字节为0x01表示通过 if (gRespData[4] 0x01) { TestPass(); } else { TestFail(Routine result abnormal); } }例程测试最需要注意的地方RID的含义每个ECU都不同例程执行时间和是否自动终止也差异很大。跑大面积回归前先人工跑一遍例程搞清楚返回的状态字节含义否则脚本断言会写得和实际行为对不上。4.6 0x3E保活与0x19读DTC保活请求本身很简单3E 00或3E 80正响应7E 00或7E 80。但保活在测试里通常是辅助手段比如长时间待在扩展会话里做后续测试脚本要按ECU的会话超时时间周期性地发。比较稳妥的做法是用一个单独的CAPL定时器每2秒发一次Timer的周期不能大于ECU的T_P2_Session超时时间。DTC读取稍微复杂一点。19 01 FF是读所有DTC的状态正响应的数据区可能很长多帧拼接后才能解析。裸CAN处理多帧时要把DTC数量、状态掩码、DTC三字节逐项解析。我的实测经验是这一块用CDD加载会让CAPL自动完成测试代码只需要校验0x59开头的正响应然后比对DTC原始列表即可。5. 调试实录这几个坑DIVA替你踩不了5.1 响应一直收不到这是我见过最多的问题。现象很统一脚本发完请求gRespLen一直是0或者on message根本没有进。排查顺序我建议固定如下先确认CAN通道、波特率和总线负载正常用CANoe的Trace窗口抓总线看请求帧到底有没有发出去。再确认响应ID对不对。物理寻址方向反了是最常见的低级错误。然后看DLC长度和字节填充。有些ECU对DLC有严格要求多发了填充字节会直接回NRC。最后检查有没有其他节点也在发同样的响应帧抢占了你脚本的监听。5.2 NRC反复出现0x13或0x310x13在UDS里叫报文长度错误0x31是请求超出范围。这两个NRC在写脚本时特别常见原因通常是请求长度没有算对。比如0x2E写数据时请求长度应当是4字节SIDDID数据多一个少一个都会触发0x13。0x31多见于DID不在当前会话支持列表里或者安全访问还没做就访问了受限DID。我的做法是在框架里加一个配置文件把每个服务允许的最小/最大长度写清楚发请求前先自检一遍能挡掉一半以上的低级错误。5.3 多帧响应数据被截断裸CAN解析多帧时最容易出的问题是第一帧里的总长度字段没拿对或者后续帧序号从0x21开始递增代码里却写成了统一的递增方式。还有一个很隐蔽的坑多帧接收过程中如果超时ECU侧可能已经终止传输了但脚本这边状态机还停在“等后续帧”导致下一次响应来了被当成上一帧的数据继续拼。我最终是靠每次发送新请求前主动清空响应状态和长度缓存来解决这个问题的。5.4 用例失败但报告里看不到关键信息测试报告里只有一句“失败”是最让人无语的。我的整改措施是在框架里加了统一的日志点每个testcase开头打印请求字节断言处打印响应字节和NRC。后续排查任何失败先看这两行日志就能圈定大概范围不需要重新接调试器复现。把DIVA方案和自建CAPL方案放在一起看各有各的定位维度商业诊断自动化插件自建CAPL脚本额外成本需要购买授权CANoe已具备零额外成本用例生成速度快但依赖诊断描述质量慢但完全可控失败排查透明度中黑盒居多高可看到字节级现场多帧/协议封装全封装有CDD可用诊断层无CDD需手写最适用的项目规模大规模全量诊断验证百条以内回归和专项验证5.5 关于加载CDD后的CAPL诊断对象写法如果你手头有CDD我建议不要把时间花在手写ISO-TP上。CANoe加载CDD后CAPL测试模块里可以通过诊断请求对象发UDS服务工具会自动处理寻址、多帧、定时参数。典型的流程是创建诊断请求对象填充SID和子功能调用发送接口然后等待响应。这种方式下代码量更少协议稳定性更高也更容易阅读。但即便用了诊断层对象脚本的整体框架仍然离不开testcase、断言和报告组织。换句话说CDD解决的是协议传输问题CAPL解决的是测试逻辑和报告问题两者不冲突可以组合使用。最后再分享一个小经验这套无DIVA的CAPL脚本方案我实际跑过完整的一轮项目迭代覆盖了会话、安全访问、写读回、例程和DTC等核心服务。最大的体会是不要一上来就追求覆盖所有服务先从一个高频服务打通比如把0x10会话切换做成完整闭环再扩展成安全访问和读写框架自然就成型了。所有无论做多么简单的用例请求和响应的十六进制字节都要留在报告里这是后期排查故障最值钱的信息。没有DIVA不是问题真正的问题是你有没有一套自己能看懂的测试框架。