ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UDS $27服务DLL封装与CANoe CAPL调用实战指南

UDS $27服务DLL封装与CANoe CAPL调用实战指南 干过几年UDS诊断开发的同行对这个场景肯定不陌生整车上电诊断仪连上准备刷写或者做标定结果ECU直接回了NRC 0x33安全访问被拒绝。这时候你才想起来$27服务的解锁算法还没做好。很多工程师第一次接触UDS诊断时$27都是绕不过去的一道坎而到了Canoe这种工具链里最麻烦的就是怎么把算法封装成DLL文件再让CAPL脚本调用。这篇文章就专门讲这个事$27服务是什么原理为什么要用DLL而不是直接写CAPL以及如何用Visual Studio生成一个C/C版本的DLL再拿到Vector Canoe里去调用。整个过程我会按实际开发流程来写包括工程怎么搭、导出函数怎么设计、CAPL怎么声明和调用、常见报错怎么排查。适合刚接手诊断开发、手上只有一份Seed/Key算法文档、却被要求在诊断脚本里实现安全解锁的工程师也适合那些准备把安全算法从测试脚本里剥离出来、做独立模块的朋友参考。1. 理解$27服务的工作原理与安全解锁业务流程1.1 先搞懂$27服务的握手流程$27服务在UDS协议里的官方名称是SecurityAccess也就是安全访问。它的作用很直接ECU内部有些关键操作比如写入Flash、修改标定参数、读取敏感数据不允许任何人随便执行必须验证一下操作者有没有权限。验证方式就是经典的挑战-应答模式分两步走。第一步诊断仪发送$27 01请求种子SeedECU收到后返回一个随机数或者由内部算法生成的种子数据。第二步诊断仪使用约定好的算法把这个Seed计算成对应的Key再发$27 02发送密钥Key。ECU收到Key后自己也算一遍如果两边结果一致就返回正响应并且把安全级别置为“已解锁”。之后在有效时间窗口内刷写、标定这类功能就可以正常访问了。这里有几个容易被忽视的细节。首先$27服务有一个子功能号的概念01到05是奇数代表请求种子偶数02、04、06代表发送密钥这种设计是为了让开发人员能快速通过子功能的奇偶性判断当前处于握手流程的哪个阶段。其次ECU返回的正响应里种子数据通常会整包发送有些ECU会刻意把Seed和响应中出现的一些无关字节混在一起用于增加被逆向的难度。第三安全解锁有尝试次数限制连续输错Key或者超时没有完成第二步ECU会把诊断仪锁住一段时间这段时间内无论你怎么发$27都是NRC 0x36或者0x37。1.2 Seed和Key的字节序最容易搞反的地方很多同事第一次做$27算法移植费了很大劲把C代码写对了CAPL调用也通了结果Key就是验证不过。排查到最后百分之七八十是字节序问题。ECU发回来的Seed是字节流比如4字节的Seed: 12 34 56 78。你的算法代码里如果用的是整数运算就必须明确是高位在前还是低位在前。ISO 14229的多字节参数默认是大端Big-Endian但不少ECU厂商在实现内部算法时用的是小端。这个没有统一标准只能看算法文档或者用真实ECU去验证。我在实际项目里的做法是在DLL接口这一层就把字节序固定下来对外只接收字节数组不接收整数。这样CAPL传给DLL的就是原始字节流DLL内部再去决定怎么解析。如果发现算法结果不对就优先检查是算法解释字节流的方向反了还是Key回填给CAPL时字节序没还原。避免在CAPL脚本层做一堆移位拼接运算既容易错又难排查。1.3 安全解锁在UDS刷写流程中的位置刷写是$27服务最典型的应用场景。一次完整的UDS刷写流程包括预编程确保通信环境适合刷写、下载请求$34、传输数据$36、传输结束$37以及编程完成后的例程控制$31。但不管刷写方案怎么变化$27都会出现在会话切换之后、$34之前。原因很简单没解锁的话ECU根本不会理睬传输数据的请求直接给你回NRC 0x33。除了刷写之外$27还用在很多别的场景防盗匹配时读取安全等级通常需要更高等级解锁、标定数据写入、以及一些售后诊断功能比如更换部件后的编码。不同功能可能对应不同的安全级别比如级别1能读标定级别2才能写标定设计DLL导出接口时也要考虑到这个需求保证调用方能自由指定安全级别。2. 为什么用DLL以及开发前的工程规划2.1 CAPL直接实现安全算法为什么不够用有些项目简单算法就几行异或加加加减直接写在CAPL里也不是不行。但一旦算法稍微复杂一点比如包含查表、循环展开、与时间戳相关的动态因子CAPL这种类C脚本语言的表达能力就非常捉急了。CAPL不支持指针没有位操作的一些快捷方式调试也很痛苦而且每一次改动都要重新编译整个测试工程版本管理不方便。还有一个更关键的场景安全算法往往是整车厂的核心资产或者由零件供应商提供负责写CAPL的工程师不一定允许看到算法的明文逻辑。这时候用DLL隔离就非常明智算法方把编译好的DLL给测试方测试方只需要知道接口函数怎么调用完全接触不到算法内部实现。既保护了核心代码又方便多个项目复用同一个算法模块。另外性能也是DLL的优势。Canoe里通过CAPL调用DLL函数的开销非常小尤其是某些ECU要求从发送Seed到收到Key必须在极短时间内完成有些算法还涉及外部硬件加密狗纯CAPL解释执行可能拖慢时序DLL则能保证实时性。2.2 用VS C/C创建DLL工程的基础姿势我习惯用Visual Studio来编DLL语音选C注意不是C这样能最大限度避免C的名字修饰Name Mangling带来的导出符号问题。C语言编译出的DLL导出函数名就是源码里的名字而C会生成一堆带修饰符的奇怪符号Canoe的CAPL脚本声明函数时还得猜实际符号名非常麻烦。创建工程的步骤很简单新建项目时选“动态链接库(DLL)”语言选择CVS里没有纯C项目模板但我们可以把源文件后缀改成.c这样编译器就会以C模式编译然后把默认生成的那几个示例文件清理掉添加自己的源文件和头文件。如果追求更干净我建议直接用.def文件来声明导出函数而不是靠__declspec(dllexport)。.def文件的好处是导出符号完全由你控制不会因为编译器版本或者项目配置变化导致符号被改动。一个简单的.def文件长这样LIBRARY UdsSecurity EXPORTS UdsCalcKey4 1 UdsCalcKey8 2还要注意平台架构的选择。如果Canoe是32位模式DLL必须编译成32位如果Canoe是64位DLL必须编译成64位。不同位的模块混用会导致加载失败运行时报错提示也特别容易误导人。后面我会单独讲这个问题。3. 核心导出函数设计与代码实现3.1 函数签名怎么设计才够通用DLL给CAPL调用时函数签名设计直接决定CAPL脚本端的声明复杂程度。我踩过几次坑后总结出一个比较通用的签名模式__declspec(dllexport) int UdsCalcKey( unsigned char securityLevel, /* 安全级别如 01level1, 02level2 */ const unsigned char* pSeed, /* Seed字节流指针 */ int seedLen, /* Seed长度 */ unsigned char* pKey, /* Key输出缓冲区指针 */ int keyLen /* Key长度 */ );返回int表示函数执行结果0表示成功非0表示失败或参数错误。这样一个函数可以覆盖不同安全级别、不同Seed/Key长度的需求。CAPL那边不需要关心DLL内部怎么分配内存只需要在调用前准备好一个足够大的byte数组来接收Key。有人可能会想为什么不直接在函数里把Key结果作为返回值返回这样设计的问题在于Seed/Key往往不是单字节而C/C函数的返回值要是一个简单类型或一个指针。如果返回指针就要在DLL内部静态分配内存多个ECU连着调用时容易互相覆盖还容易造成内存管理混乱。用输出缓冲区是最稳妥的做法。3.2 演示一个实际安全算法的实现全过程下面拿出一个比较有代表性的演示算法来说事。注意这个算法只是为了展示DLL开发流程真实项目里的算法通常是OEM或者Tier1提供的不会这么简单。算法思路是把4字节Seed按大端拼成一个32位整数先循环左移3位再和一个固定密钥做异或之后加一个由安全级别决定的偏移量最后拆成4字节作为Key。#include windows.h #define SEED_LEN 4 #define KEY_LEN 4 static unsigned int rol32(unsigned int value, int bits) { return (value bits) | (value (32 - bits)); } __declspec(dllexport) int UdsCalcKey( unsigned char securityLevel, const unsigned char* pSeed, int seedLen, unsigned char* pKey, int keyLen) { unsigned int seed 0; unsigned int key 0; int i 0; /* 参数保护 */ if ((pSeed NULL) || (pKey NULL)) return -1; if ((seedLen ! SEED_LEN) || (keyLen ! KEY_LEN)) return -2; /* 字节流按大端拼成32位整数 */ for (i 0; i SEED_LEN; i) { seed (seed 8) | pSeed[i]; } /* 第一步循环左移3位 */ key rol32(seed, 3); /* 第二步与固定掩码做异或 */ key ^ 0x5A5A5A5A; /* 第三步根据安全级别加偏移 */ key securityLevel * 0x01020304; /* 拆成4字节回填这里选择小端序输出 */ for (i 0; i KEY_LEN; i) { pKey[i] (unsigned char)(key (i * 8)); } return 0; }这里故意演示了“大端读入、小端写出”的情况就是为了提醒大家Seed和Key的端序可以独立设置两者没有必然关系。很多ECU的算法文档不会明说这一点需要拿台架实测来确认。要注意的是函数开头做了参数保护。DLL被CAPL调用时如果传入的数组长度不够可能造成内存越界轻则数据错乱重则Canoe直接崩溃。我在导出函数里永远会先检查参数长度和指针有效性这也是DLL模块能被多个测试脚本长期复用的前提。3.3 编译时容易忽略的运行时库配置生成DLL时VS默认会链接动态C运行时库/MD生成的DLL在安装有对应版本VC_REDIST的机器上才能跑。Canoe自己一般会带一部分运行库但版本不一定匹配。我建议直接改成静态链接/MT这样DLL体积会大一点但是部署到任意一台装了Canoe的电脑上都不会缺依赖。具体操作项目属性 - C/C - 代码生成 - 运行库选择“多线程(/MT)”。Debug版本选“多线程调试(/MTd)”也可以但不建议把Debug版DLL用于正式测试性能差且依赖更多。如果担心导出符号靠记忆维护容易漏可以在编译完成后用Visual Studio自带的Dumpbin工具检查一下dumpbin /exports UdsSecurity.dll如果导出列表里能看到UdsCalcKey这个名字说明导出正常。如果看到一堆符号或者名字被截断要么是C名字修饰问题要么是.def文件没起作用需要回去检查项目配置。4. 通过CAPL在Vector Canoe等工具中加载并调用DLL4.1 CAPL中声明DLL函数Canoe通过CAPL脚本调用外部DLL时只需要在脚本头部声明函数原型不需要额外的配置界面。语法是在函数名前加dll关键字之后像普通函数一样调用。上面那个UdsCalcKey的声明可以写成这样dll int UdsCalcKey(byte securityLevel, byte seed[], int seedLen, byte key[], int keyLen);CAPL中数组参数在调用时直接传数组名即可。注意CAPL的byte类型对应C语言中的unsigned charint类型对应32位带符号整数这点不会因为平台架构不同而改变。如果DLL里函数名和你CAPL声明的函数名不一致比如DLL导出的是UdsCalcKeyA但你想在CAPL里叫UdsCalcKey可以在声明时用别名语法。不过我一般不建议这样绕保持两边一致出了问题容易查。4.2 完整的安全解锁流程实现写一个相对完整的CAPL流程模拟诊断仪向ECU请求Seed、计算Key、发送Key并检查响应的过程。这里利用了Canoe里诊断控制相关函数但更通用的是直接用底层发送和接收函数/* 定义一个全局变量来保存会话相关的状态 */ dword g_session; /* 声明DLL外部函数 */ dll int UdsCalcKey(byte securityLevel, byte seed[], int seedLen, byte key[], int keyLen); byte SendSecurityAccess(byte level, byte seed[], int seedLen, byte key[], int keyLen) { byte response[64]; byte keyFrame[8]; int i; /* 第1步发送请求种子指令 */ keyFrame[0] 0x27; keyFrame[1] level; /* 比如 0x01 */ CanTpSendData(0x7E0, keyFrame, 2); /* 等待ECU响应这里为了方便演示省略了超时和重试处理 */ if (CanTpReceiveData(0x7E8, response, 64) 0) { return 0x10; /* 超时 */ } /* 判断NRC */ if (response[0] 0x7F) { return response[2]; } /* 第2步从正响应里提取Seed */ /* 响应格式: 67 [level1] seed... */ if ((response[0] ! 0x67) || (response[1] ! (level 1))) { return 0x20; /* 响应格式错误 */ } for (i 0; i seedLen; i) { seed[i] response[2 i]; } /* 第3步调用DLL计算Key */ if (UdsCalcKey(level, seed, seedLen, key, keyLen) ! 0) { return 0x30; /* DLL内部计算失败 */ } /* 第4步发送Key */ keyFrame[0] 0x27; keyFrame[1] level 1; for (i 0; i keyLen; i) { keyFrame[2 i] key[i]; } CanTpSendData(0x7E0, keyFrame, 2 keyLen); if (CanTpReceiveData(0x7E8, response, 64) 0) { return 0x40; /* 发送Key后超时 */ } if (response[0] 0x7F) { return response[2]; } return 0x00; /* 解锁成功 */ }这段代码去掉了诊断通信中特别多的细节比如定时器、重试次数、会话切换等但核心逻辑是完整的。实际项目里要在第1步之前先切换非默认会话有些ECU还需要先做$28通信控制停止非诊断报文才能保证诊断响应不被其他报文干扰。这些都是写脚本时要考虑的不属于DLL本身范畴但会让整体流程变得健壮。调用这个函数时要注意数组长度和DLL内部预期的seedLen/keyLen要保持一致。比如4字节的Seed/Key代码里就传4给seedLen和keyLenbyte seed[4]; byte key[4]; byte ret; ret SendSecurityAccess(0x01, seed, 4, key, 4);如果DLL内部检查长度不匹配会返回错误码CAPL这边就应该把错误码打出来方便定位。调试阶段建议在DLL返回非0的时候用Write函数把错误码和Seed值都输出到Canoe的Write窗口能省很多时间。4.3 多个ECU、多安全等级的广播式调用实际台架上可能同时挂好几个ECU比如网关、发动机控制器、电池管理单元每个ECU的安全算法可能不一样。DLL可以合并成一个大的动态库也可以拆成多个DLL。我建议按ECU类型拆库每个库里实现一个或几个进入函数由CAPL根据当前测试对象动态选择。对于多安全等级可以在DLL里做一个内部状态机甚至先用filter函数判断一下当前输入的等级是否支持。不好的做法是在CAPL里用if-else堆十几个调用分支维护成本太高。保持CAPL层面对DLL的统一接口把差异全收到DLL内部是最舒服的维护方式。5. 常见报错与调试排查实录5.1 DLL加载失败的几种典型原因Canoe在CAPL脚本开始运行时如果没法加载DLL通常会在Write窗口弹错误。最常见的是这个找不到指定的模块。DLL没有放在Canoe的搜索路径里。把DLL放到和Canoe工程文件.cfg同一个目录或者放到系统PATH下的目录一般就能解决。%1 不是有效的 Win32 应用程序。这个报错几乎都是位数不匹配。Canoe是32位DLL编译成了64位或者反过来。解决方式是确认Canoe的安装架构然后重新编译对应架构的DLL。无法定位程序输入点。说明DLL里没有CAPL声明所对应的那个导出函数。用dumpbin检查一下实际导出符号看看是不是名字拼错了、有没有C名字修饰。还有一个特别隐蔽的情况DLL依赖了某个C运行时库但目标机器上没有装对应的VC Redistributable。虽然前面说了用/MT静态链接能规避大部分这个问题但如果你引入了第三方库第三方库可能又动态依赖其他DLL那就得把那一整套依赖都带过去。用Dependency Walker这类工具可以检查出DLL的依赖树不过新版本的Dependency Walker对64位程序支持不太好也可以用Visual Studio自带的功能查看模块依赖。5.2 Key计算不对到底该从哪里查起调通加载之后最头疼的问题就是DLL能跑起来CAPL也能拿到Key但ECU就是不认。这时候排查顺序很重要千万别一上来就怀疑算法本身那样容易走弯路。第一步确认ECU回复的Seed有没有被字节错位。有些诊断仪驱动的接收缓存会带上一些额外字节比如带TesterPresent的响应或者填充字节如果你取的下标偏了一位后面所有字节全是错的。把实际收到的原始响应打印出来和你预期格式对比一下。第二步检查Seed和Key长度。有些ECU的Seed/Key不是固定长度根据安全级别不同会发生变化比如Level 1用4字节Level 2用8字节。如果DLL里写死了4字节那Level 2肯定失败。第三步检查字节序。这个前面重点说过无论是Seed的读取还是Key的写入两端都有可能颠倒。可以在CAPL里写个小函数反转字节序做交叉验证但如果DLL内部已经实现了算法那就只能改DLL的输入输出格式了。第四步检查算法中的时间窗口。少数ECU的Seed会带有时间戳信息比如种子里的某几位是当前时间计数器值从请求Seed到发送Key如果超过规定时间ECU那边已经判定超时再对也对不上。这种问题在纯DLL层面看不出来需要从CAPL侧保证时序紧凑避免在中间加太多调试延时。5.3 经验总结把DLL调试做得更顺的小技巧我在交付这类DLL模块时总会额外导出一个调试用函数名字就叫UdsCalcKeyDebug。这个函数除了正常计算Key之外还会把算法计算的中间步骤拼成一个字符串返回到输出缓冲区里。CAPL调用它之后可以直接Write出来对确认算法阶段是否正确非常有用。发布给客户时再把这个调试函数从导出表里去掉或者在代码里用宏控制是否编译。另外一定要做多实例兼容。Canoe可能同时打开多个工程有时一个测试环境里会加载同一个DLL两次。普通C函数没有全局依赖的话不受影响但如果你为了图省事在DLL里用了全局变量存中间状态多实例下来就可能出诡异问题。能用局部变量就别用全局变量这是C开发的基本功但在写DLL时尤其重要。还有一个细节是DLL的版本管理。建议在DLL里导出一个GetVersion函数返回一个版本号字符串方便在CANoe里脚本启动时检查有没有用错老库。写在最后根据我个人的实际经验这种“离线算Key”的DLL方案在售后诊断、产线EOL、以及测试台架上都非常好用一个项目一旦把DLL封装好后续换测试工具、换脚本框架DLL都能无缝迁移。而真正踩坑最多的并不是算法本身反而是DLL工程配置、调用约定、字节序这些外围工程细节。写这篇文章时我把几个最容易劝退新人的地方重点铺开讲了希望能帮你节省三五天摸索时间。如果你现在正卡在“DLL能加载但算不对Key”这个阶段我可以再给一个排查建议找一台真实的ECU先在示波器或者总线分析仪上抓一下诊断仪正常解锁时的完整总线报文把Seed和Key的原始数据对出来然后用一个最简单的算法比如原样返回Seed去测DLL链路通不通。链路通了再逐段替换成真实算法逻辑。这个思路看起来很笨但比对着算法文档瞎猜高效得多。
返回列表