ARTICLE DETAIL

资讯详情

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

UDS $27服务DLL生成实战:从Seed Key算法到CANoe集成排查

UDS $27服务DLL生成实战:从Seed  Key算法到CANoe集成排查 做车载诊断测试的朋友十有八九都绕不开安全访问这道坎。不管是产线刷写、售后标定还是台架测试只要想往ECU里写数据UDS协议里的$27服务基本都会出现在流程里。很多刚接触这块的人第一次看到“生成$27服务DLL”这个需求会有点懵DLL我知道$27我也能说上几句但为什么要把安全解锁算法塞进一个DLL文件这东西到底怎么生成、怎么用、报错之后又怎么查这篇文章我就围绕UDS $27服务DLL的生成从协议时序一路讲到VS工程创建、算法实现、DLL编译再到CANoe里集成验证最后把常见的DLL加载失败和NRC报错整理成一张排查表。适合诊断测试工程师、ECU软件工程师以及刚入行车载软件、被安全访问卡了好几天的朋友。所有步骤都是我实际做过、跑通过的你可以直接照着抄。1. $27服务到底是什么为什么需要DLL1.1 安全访问在UDS协议里的位置UDSUnified Diagnostic Services统一诊断服务是ISO 14229定义的一套汽车ECU诊断协议。$27服务在协议里叫SecurityAccess也就是安全访问。它的作用很直白给ECU的敏感操作加一把锁。平时我们用$22读数据、$2E写数据一般不需要解锁或者只解锁低权限等级。但像Bootloader刷写、标定写入、防盗匹配这类高风险操作ECU不会让你直接干你必须先证明“我是被授权的人”。怎么证明就是走一遍Seed Key种子和密钥校验。你可以把它理解成进保险柜之前的密码验证。$27服务就是那道验证流程密码对了ECU才愿意把你当成可信诊断仪后续的写操作才会被放行。密码错了ECU直接回一个负响应而且很多ECU还有尝试次数限制错几次就得等延时这是为了防暴力破解。1.2 Seed Key的完整交互时序$27服务的时序在协议上看着不复杂但实际报文里有不少细节。我按最常见的Level 0x01/0x02给你拆一下诊断仪发送请求种子02 27 01其中02是长度27是SID01是子功能表示申请Level 1的种子。ECU响应06 67 01 AA BB CC DD67是SID加0x40的响应01是子功能后面4字节就是Seed。诊断仪拿到Seed后在本地用算法算出Key然后发送密钥06 27 02 10 20 30 40。ECU校验通过返回02 67 02解锁成功。如果校验失败ECU会回7F 27 350x35就是Invalid Key。如果还没要种子就直接发密钥会回7F 27 24requestSequenceError请求顺序不对。如果尝试次数超限会回0x36如果还在等待延时会回0x37。注意一个细节很多ECU不止一个安全等级。比如Level 0x01/0x02是普通标定Level 0x03/0x04是BootloaderLevel 0x05/0x06可能是更高级的出厂配置。DLL接口里通常带一个level参数就是为了兼容这种多等级场景。1.3 为什么要把算法封装成DLL既然就是算个Key为什么不能直接把算法写死在诊断工具里这就要说到产业的现实情况了。ECU内部的安全解锁算法对整车厂和Tier1来说都是核心机密。诊断仪和刷写工具是第三方或者售后设备厂商不可能把算法源码直接交付出去更不可能让每家供应商的工具都内置一套所有车型的算法。DLL在这里起两个作用第一是保密。算法编译进DLL别人拿到DLL也只能调用接口反编译虽然存在但成本高比直接给源码安全得多。第二是适配。不同ECU、不同车型、不同供应商算法千奇百怪有做异或查表的有跑AES的有走CMAC的。工具通过动态加载不同的DLL文件来适配不同车型就像手机装App一样需要刷哪个车就加载哪个DLL不用重新编译上位机。所以在CANoe、PEAK、PCAN等诊断工具链里普遍的做法就是约定一组DLL导出接口由ECU供应商提供符合接口的DLL工具侧统一调用。这也是“UDS $27服务DLL文件生成”这个需求的真正来源。2. 动手前的准备接口规范与工具链选型2.1 诊断工具期望的DLL接口长什么样这里要先泼一盆冷水DLL接口并没有全行业绝对统一的标准。不同工具、不同诊断描述文件比如CDD、ODX对接DLL的方式都不一样。有的工具要求你按Vector定义的插件接口导出有的则按ISO 22900-2的D-PDU API走有的干脆只约定一个CalculateKey函数。不过从实际工程经验看不管是哪种工具最终都会被封装成一个非常接近下面形式的调用关系uint32_t CalculateKey( uint32_t level, // 安全等级对应27 01里的01 const uint8_t* seed, // ECU返回的种子 uint32_t seedLen, // 种子长度 uint8_t* key, // 计算出的密钥输出 uint32_t* keyLen // 密钥长度输入输出 );区别只是函数名不同比如有的叫SecurityAccess_CalculateKey有的叫GetKey有的工具还会要求单独导出一个GetLastError来拿错误信息。我建议你在动手写DLL之前一定要先找到目标工具提供的头文件或示例工程照着它来定义导出函数不要自己凭空发明接口名。宁可多花半天把文档看明白也不要闷头写完最后发现工具根本不识别。2.2 用C、C#还是Python这是我自己被问得最多的问题。我的建议很明确优先用C/C尤其是要和CANoe这类工具链对接的时候。方案优点缺点适用场景C/C能直接导出C语言接口工具兼容性最好性能高开发周期稍长内存管理要小心量产工具、诊断仪插件、CANoe集成C#算法逻辑写起来快调试方便导出标准C接口麻烦需要COM注册或C/CLI桥接部署容易踩依赖坑公司内部原型、自研工具Python写算法最快适合做研究很难生成标准DLLCython/Nuitka链路太折腾不推荐量产算法验证、离线计算Key你可能看网上有人用C#做过seedkey DLL并且成功了确实能做但它要求目标工具用.NET运行时去加载而且32位/64位、.NET版本、COM注册这些坑一个接一个。C写DLL虽然原始但胜在稳定、可控、不挑宿主。咱们做车载诊断的稳定压倒一切。2.3 32位和64位别在这个坑里反复横跳DLL的位数必须和加载它的进程一致。CANoe是32位就用32位DLLCANoe是64位就用64位DLL绝对不能混用。这个错误太常见了。尤其在2020年之后的电脑上大家习惯性用VS编译默认的x64结果CANoe还是老版本32位的加载DLL时直接失败报错信息还往往比较隐晦。你可能会在事件管理器里看到“模块加载失败”或者“找不到指定的模块”之类其实根本不是文件缺失就是位数不匹配。我现在的习惯是每次新建DLL工程时第一步就去配置管理器里把活动解决方案平台选成x64同时确认目标工具到底是哪个位数。如果你手里只有一个DLL又不知道工具是多少位的可以用dumpbin /headers查看DLL的PE头里面有machine (x86)还是machine (x64)的信息。3. 核心实现从零生成一个$27服务DLL3.1 创建Visual Studio DLL工程我以Visual Studio 2022为例步骤很简单打开VS选择“创建新项目”。搜索“动态链接库”选C的DLL项目模板。如果模板列表里没有就创建“Windows桌面向导”然后在应用程序类型里选“动态链接库”并勾选“空项目”。项目名称建议带清晰语义比如SecurityAccess_CMAC_AES128别叫什么test1、dll_new这种后面你自己都会忘。创建完成后在解决方案配置管理器里确认平台是x64还是x86按目标工具位数选。一个DLL工程里最核心的就三样东西导出函数的声明、算法实现、以及一个DllMain入口。DllMain是DLL的生命周期入口DLL被加载和卸载时会触发但它也是很多崩溃问题的源头后面我会重点讲。3.2 一个能直接编译的示例DLL下面是一个最简版示例接口用了我自己习惯的SecurityAccess_CalculateKey命名算法是种子字节倒序后异或0xA5。这个算法本身没有任何安全性但用来跑通整个链路足够了你可以在拿到真实算法后替换内部实现。// SecurityAccessDLL.cpp #include windows.h #include stdint.h #include string.h #define DLL_EXPORT extern C __declspec(dllexport) // 演示算法seed字节倒序后异或0xA5 static void GenerateKey( const uint8_t* seed, uint32_t seedLen, uint8_t* key, uint32_t* keyLen) { uint32_t len seedLen *keyLen ? seedLen : *keyLen; for (uint32_t i 0; i len; i) { key[i] seed[seedLen - 1 - i] ^ 0xA5; } *keyLen len; } DLL_EXPORT uint32_t SecurityAccess_CalculateKey( uint32_t level, const uint8_t* seed, uint32_t seedLen, uint8_t* key, uint32_t* keyLen) { if (seed nullptr || key nullptr || keyLen nullptr) { return 0x01; // 参数错误 } // 这里可以根据level区分不同等级的算法 *keyLen seedLen; GenerateKey(seed, seedLen, key, keyLen); return 0; // 成功 } DLL_EXPORT uint32_t SecurityAccess_GetLastError( char* buffer, uint32_t bufferLen) { const char* msg no error; if (buffer ! nullptr bufferLen 0) { strncpy(buffer, msg, bufferLen - 1); buffer[bufferLen - 1] \0; } return 0; } BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) { // 不要在DllMain里做复杂初始化、弹窗、创建线程 return TRUE; }这段代码里有几个关键点我说一下为什么这么写第一extern C必须加。C编译器默认会对函数名做名称修饰name mangling导出后函数名会变成一串乱码。工具侧如果你按SecurityAccess_CalculateKey去查找根本找不到加了extern C之后才会导出标准的C符号名。第二__declspec(dllexport)是告诉链接器这个函数要放进导出表。你也可以用.def文件来声明导出函数效果类似但用__declspec(dllexport)更直白适合小工程。第三DllMain里不要干重活。Windows文档里写得清楚DllMain里只能做简单的初始化如果你在里面创建线程、加载其他DLL、弹对话框很容易死锁或者崩溃。你看到很多“DLL初始化例程失败”的报错十有三四是DllMain里写了不该写的东西。3.3 把示例算法升级到AES-128/CMAC真实ECU里用的算法已经很少能看到纯异或查表这种了现在主流的方案是AES-128加密或者CMAC-AES128。以AES-128的CMAC为例它的输入是Seed密钥是厂商内置在DLL里的一个固定128位Key输出就是128位的MAC值截取前多少字节作为最终的Key。CMAC的好处是它有密码学上的完整性保护比单纯AES ECB模式安全得多。你要是问我为什么很多ECU选CMAC而不是直接AES解密我的理解是CMAC天然适合“验证一段数据的完整性”这个场景Seed就是一段随机数据ECU和DLL共享同一个密钥DLL对Seed算MACECU再对收到的Key算MAC两边一致就通过。实现上你可以直接用开源的tiny-AES或者OpenSSL。用OpenSSL的话代码会短很多#include openssl/cmac.h static int ComputeCMAC_AES128( const uint8_t* seed, uint32_t seedLen, uint8_t* key, uint32_t* keyLen) { const uint8_t aesKey[16] { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F }; CMAC_CTX* ctx CMAC_CTX_new(); if (ctx nullptr) return 0x02; if (!CMAC_Init(ctx, aesKey, sizeof(aesKey), EVP_aes_128_cbc(), nullptr)) { CMAC_CTX_free(ctx); return 0x02; } CMAC_Update(ctx, seed, seedLen); size_t macLen 0; CMAC_Final(ctx, key, macLen); CMAC_CTX_free(ctx); *keyLen (uint32_t)macLen; return 0; }注意这里我把AES密钥硬编码在示例里了。真实项目你肯定不能这么干密钥通常会被加密存储或者从配置文件中读取至少也要在DLL内部做一层混淆。你要是直接把真实的128位密钥明文写在代码里DLL一旦泄露整个ECU的安全体系就等于报废。如果在你的内部工程里不方便引入OpenSSL也可以用纯C的tiny-AES库整个库就一个.c和一个.h集成非常容易。但要注意CMAC是MAC算法tiny-AES只提供AES基础运算CMAC需要在AES之上自己按RFC4493的步骤实现。如果嫌麻烦直接用OpenSSL最省事但部署时要记得把对应版本的libcrypto DLL一起带上这又是一个容易踩的依赖坑。3.4 自测写一个控制台程序验证DLLDLL写完先别急着丢进CANoe先自己写个小程序验证一把。这一步能帮你把算法逻辑问题和工具集成问题分开极大节省联调时间。在同一个解决方案里新建一个控制台应用写下面这样的代码#include windows.h #include stdint.h #include cstdio typedef uint32_t(__cdecl* CalculateKeyFn)( uint32_t level, const uint8_t* seed, uint32_t seedLen, uint8_t* key, uint32_t* keyLen); int main() { HMODULE dll LoadLibraryA(SecurityAccess_CMAC_AES128.dll); if (dll nullptr) { printf(LoadLibrary failed, error%lu\n, GetLastError()); return -1; } auto fn (CalculateKeyFn)GetProcAddress(dll, SecurityAccess_CalculateKey); if (fn nullptr) { printf(GetProcAddress failed, error%lu\n, GetLastError()); return -1; } uint8_t seed[8] { 0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44 }; uint8_t key[16] { 0 }; uint32_t keyLen sizeof(key); uint32_t ret fn(1, seed, sizeof(seed), key, keyLen); printf(calculate ret0x%08X, keyLen%u\n, ret, keyLen); for (uint32_t i 0; i keyLen; i) { printf(%02X , key[i]); } printf(\n); FreeLibrary(dll); return 0; }这里我强调一个细节GetProcAddress返回后一定要强转成函数指针类型并且这个函数指针的调用约定必须和DLL里完全一致。C/C默认调用约定是__cdecl但有些工具接口用的可能是__stdcall不匹配的话轻则跑飞重则崩溃。你写DLL时心里要清楚工具到底按什么调用约定来找你的函数。自测通过后再把DLL拿到真实工具里集成。如果自测都过不了别怀疑工具问题一定在你的DLL或者算法里。4. 在CANoe等诊断工具中集成与验证4.1 在诊断工程中指定DLL路径不同版本的CANoe、不同插件DLL加载入口的位置不太一样但大体逻辑共通。一般在Diagnostics诊断或Security Access相关配置页面里会有一个选择DLL文件的选项你只需要把编译好的DLL路径填进去。这里有几个实际经验要分享第一路径里尽量不要有中文和空格。Windows下中文路径不是不能用但CANoe这类工具偶尔会在某些版本里抽风为了省事把DLL放到一个纯英文目录。第二DLL依赖的其他库比如libcrypto、libcurl要么放在系统PATH里要么放在和DLL相同的目录下。Windows搜索DLL的顺序是应用程序目录、系统目录、环境变量PATH。CANoe加载你的DLL时如果找不到依赖库会报一个很模糊的错。把依赖库和DLL放一起是最稳的。第三DLL加载不一定要重启CANoe。有些版本改了路径之后热生效有的需要重启诊断工程。如果改了路径仍然感觉没生效果断重启一遍工程别在界面上傻等。4.2 通过诊断控制台触发$27服务集成完毕在CANoe的诊断控制台或者CAPL脚本里可以直接对ECU发送$27服务。你大概率会看到两种呈现方式一种是工具已经根据CDD文件把$27封装成了语义化的服务你只要填一个level值和Seed工具自动调用DLL算Key并完成后续发送。另一种是需要你在CAPL里手动调用DLL导出函数把算出来的Key填到报文里发送。我建议你把两种都试一遍。先手动调DLL函数确认CANoe进程加载的DLL和自测时是同一个文件再走自动化服务确认工具层的封装没有“夹带私货”。比如有的CDD文件本身可能配置了一个内置的算法设置了DLL之后要确认工具优先用的是DLL结果而不是CDD里自带的种子表否则你会看到Key的值跟你算的完全对不上。4.3 结合刷写流程一起验证$27服务本身不是最终目的解锁是为了让后续刷写、标定能通过。所以最后一步一定要在完整流程里验证。以UDS刷写为例标准流程通常是扩展会话10 03安全访问27 01-27 02编程会话10 02请求下载34 00 44 ...传输数据36 01 ...循环请求退出传输37例程控制检查编程完整性31 01 02 02复位ECU11 01这里面有个技巧做完整刷写之前先用一个只读操作来验证$27解锁是否生效。比如解锁成功之后发一个$22读某个受保护的数据或者$31执行一个需要高权限的例程。如果这个操作能成功说明$27服务本身已经通了再去刷写问题就更容易定位在其他环节比如刷写文件不对、地址范围错误、传输数据超时等。另外提醒一句刷写流程一旦出现网络层超时不要第一时间怀疑DLL。先看ECU有没有回负响应回的是哪个NRC。如果ECU根本就没响应那是CAN通信或者地址分配的问题如果回0x31请求超出范围那往往是请求下载的地址/长度不对如果回0x33安全访问被拒绝那才回头检查$27解锁。5. 高频报错与排查实录5.1 常见DLL加载/DLL自身报错排查下面这张表我根据实际排查经验整理了一下基本覆盖了DLL相关的大部分报错场景。现象可能原因排查方向加载失败提示“找不到指定的模块”路径错误、系统缺少VC运行库、依赖DLL缺失先用Dependencies工具扫描DLL依赖确认所有依赖都在初始化例程失败OSError 1114、LoadLibrary失败DllMain里面做了复杂初始化、静态对象构造崩溃、位数不匹配简化DllMain确认x64/x86与宿主进程一致导出函数找不到没有加extern C导出名被C修饰用dumpbin /exports查看实际导出函数名DLL一加载就崩溃静态全局对象初始化时序问题内存访问越界用VS附加到宿主进程调试开“本机代码调试”刷写时报“target dll has been cancelled”DLL被宿主进程取消加载、连接被中断、工具在等待DLL响应时超时被用户中止查看工具日志确认DLL路径和依赖别频繁点击取消这里单独讲一下oserror: [winerror 1114]这类报错很多人看到“动态链接库初始化例程失败”就慌了。其实它往往指向的不是算法错误而是DLL在被LoadLibrary加载时内部初始化没走完。最常见的原因有几个DllMain里创建了线程、加载了其他DLL、或者执行了需要交互的逻辑DLL依赖了另一个DLL但那个DLL不在搜索路径里CRT运行库版本不对比如用新版本VS编的DLL放到没有对应运行库的机器上。排查方法也简单先用Dependencies工具看DLL是否缺依赖再用VS的调试器附加到宿主进程在DllMain第一行下断点。断点能进说明加载没问题是在初始化中间崩的断点进不了说明DLL在加载早期就被系统拒绝了先查位数和依赖。5.2 诊断NRC相关问题排查DLL算出来的Key对不对最终是通过ECU的负响应来判断的。下面这几类NRC你一定会遇到NRC含义排查方向0x12子功能不支持确认安全等级Level是否在ECU支持范围内可能只支持01/02你却发了030x22条件不满足确认ECU是否处于允许执行$27的会话有些ECU要求先进入扩展会话才能安全访问0x24请求顺序错误确认是否先发了27 01取种子拿到种子之前不能直接发27 020x31请求超出范围Seed或Key长度与ECU预期不一致核对长度和填充方式0x35密钥错误算法算出的Key不对检查密钥、字节序、算法版本、Level参数0x36尝试次数超限ECU已锁定等延时或重新上电0x37延时未到等够时间再试或者检查每条请求之间间隔是否太短我最常踩的是0x35也就是Key错误。很多人会下意识认为是算法有问题但根据我的经验有一半的情况是“数据在某个环节被转换了格式”。比如ECU返回的Seed是十六进制字符串你在上位机里把它转成了字节数组字节序转反了或者工具在处理Key时把它当ASCII字符串发送而不是字节数组。所以排查0x35时第一件事不是重写算法而是把ECU回的Seed、你传给DLL的Seed、DLL算出的Key、ECU实际收到的Key这四组数据全部打印出来一条条对比。5.3 我的几个实操习惯最后分享几个我个人的习惯虽然不是什么高深技术但能省下很多沟通成本。只要不是一次性的临时工具我都在DLL里留一个“测试模式”的开关。实现方式很简单DLL启动时读一个环境变量比如SAS_DEBUG1开启后把每次CalculateKey的入参和出参写到日志文件里。量产的时候不设这个环境变量就行不会影响性能。有了日志出了问题不用每次都被动等现场复现自己拿一条Seed就能复现。每次编译完成后我会手动跑一遍dumpbin /exports看一下导出表。这个习惯帮我抓出过好几次“忘了加extern C”的低级错误。命令很简单dumpbin /exports SecurityAccess_CMAC_AES128.dll如果导出表里显示的是?SecurityAccess_CalculateKeyYAK...这种名字那说明工具肯定找不到函数直接改代码加extern C。在VS里我还会设置一个生成后事件把编译好的DLL自动拷贝到CANoe的DLL目录或者一个专门的“交付目录”。命令就一行copy /Y $(TargetPath) D:\diag_dlls\这样每次编译完不用手动拖文件也避免因为拷错版本排查半天。最后一条是关于密钥管理的。不管你的DLL是给第三方工具用的还是自己工程内部用的不要把真实密钥直接硬编码在代码里再发给别人。我见过太多DLL里明文放着16字节AES密钥结果DLL在某个供应商那里流转一圈密钥就没有秘密可言了。可以作为参考的做法是DLL内部对密钥做一层白盒加密或者从单独的配置文件读取受保护的密钥。这个话题展开又是另一篇文章但希望你在生成DLL那一刻就想清楚这个DLL以后要发给谁密钥泄露出去了要承担什么后果。
返回列表