尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CANoe中OSEK_TP.dll实现ISO 15765-2长帧传输与UDS刷写实践

CANoe中OSEK_TP.dll实现ISO 15765-2长帧传输与UDS刷写实践 做UDS刷写测试的时候你一定见过这种场景CANoe的Trace窗口里一条0x34 RequestDownload被拆成七八帧第一帧头上带个0x10后面跟着一串0x21、0x22、0x23……想确认数据链路长什么样还得自己拿计算器把PCI字节拼回去。我刚开始做诊断协议测试时也是这么干的直到有一次刷一个512KB的固件纯手工解析拆包把我逼疯了才开始认真研究CANoe里更高效的长帧传输方案。这篇文章想分享一套我验证过很多次的思路用OSEK_TP.dll把ISO 15765-2协议的分包、重组、流控全部接管在CAPL脚本里只需要处理完整的长帧数据再也不用跟0x10、0x20这些PCI字节纠缠。内容既覆盖协议原理也包含接入流程和排坑记录适合正在做UDS刷写、ECU仿真或CANoe诊断测试的工程师。1. 先把ISO 15765-2的四个帧类型和时序关系吃透1.1 SF/FF/FC/CF的PCI格式与各自装载能力ISO 15765-2DoCAN传输层与网络层服务解决的根本问题很朴素经典CAN单帧数据场只有8字节但UDS诊断服务经常需要一次携带十几乃至几千字节的数据。于是协议设计了四种帧类型通过第一个字节的PCIProtocol Control Information来区分。帧类型PCI首字节作用经典CAN下最大有效载荷单帧SF0x0L整条TP报文小于等于7字节时一帧搞定7字节首帧FF0x1X 0xYY宣布一条总长度为XYY12bit的长报文开始携带前部数据7字节载荷连续帧CF0x2N逐个携带后续数据N为序列号1到15循环7字节载荷流控帧FC0x3F接收方通知发送方“继续发/等一下/缓冲区溢出”无载荷FF的长度编码是个容易出错的点12位长度分散在PCI首字节的低4位和第二个PCI字节的8位中。比如要发20字节数据第一个PCI字节是0x100x1作为帧类型0作为长度高4位第二个PCI字节是0x14连起来就是0x014也就是20字节。CF的序列号计数器从1开始到15之后下一次回到0继续用而不是一直累加到16、17这是初学者最容易懵的地方。1.2 一次长帧传输的完整交互时序假设测试仪要往ECU发一条20字节的0x34 RequestDownload请求发送流程是这样的测试仪先发一个FFCAN数据场为0x10 0x14 0x34 0x00 0x44 0x00 0x08 0x00其中PCI是0x10/0x14后面是请求数据的前6个字节。ECU收到FF后如果缓冲区可用回一个FC0x30 0x00 0x0A意思是“继续发送CTSBlockSize0不限块数STmin10ms”。如果缓冲区当时不够会回0x31 ...表示等待。测试仪收到FC后按STmin的时间间隔连续发CF。第1个CF是0x21 ...第2个CF是0x22 ...直到把剩余数据全部发完。这条链路里最关键的是发送节奏由FC和STmin共同决定而不是发送方想多快就多快。FC是接收方给发送方上的“节流阀”STmin是连续帧之间的最小间隔只要这两个参数不匹配ECU接收缓冲就可能丢帧或超时。1.3 看Trace时最容易误解的PCI与CAN ID关系很多刚接触诊断的工程师会有一个错觉看到0x7E0就认为这是请求报文看到0x7E8就认为是响应报文那么长帧的FF/CF一定全部在0x7E0上FC一定在0x7E8上。这个直觉在绝大多数物理寻址场景是对的但要记住规律的本质TP层的分段不会改变CAN ID一个TP报文的FF和CF都走发送方自己的IDFC则走接收方的响应ID。反过来看Trace时如果一个ID上连续出现0x21 0x22 0x23不要条件反射地认定它们属于同一个长帧还要结合前面的FF长度和上层服务ID判断。如果总线上同时跑着多个ECU的诊断事务CF的序列号很容易看起来像是连续的但实际上分属两个不同的TP连接。这一点在后续排查“串包”问题时非常有用。2. CANoe实现TP层的三条路线以及OSEK_TP.dll的定位差异2.1 手写CAPL分包重组透明但沉重最早我也是在CAPL里自己写TP。思路是维护一个发送状态机先判断报文长度是否超过7字节超过则发FF然后开一个定时器等FC收到FC后按BlockSize和STmin连续发CF接收侧则是维护接收缓冲区和当前序列号期望值。听上去逻辑不复杂但代码一展开就不是那么回事了。我当年写过一个简化版本大概300多行这还只是单连接、经典CAN、Normal寻址的情况。一旦要支持扩展寻址、多ECU并发、节点休眠和总线唤醒状态机规模会成倍增长。CAPL本身是为总线事件处理设计的语言写复杂状态机不是它的强项尤其STmin这种毫秒级定时用CAPL的timer去做在高负载下很容易出现抖动导致时序偏差。2.2 CDD诊断数据库自动TP省事但不够灵活如果用的是CANoe诊断组件加载一个写好UDS服务的CDD/ODX文件诊断面板、Test Module甚至CAPL里的diagGetPrimitiveData都可以自动处理TP层。对纯测试脚本来说这是最省事的。可一旦你想模拟ECU行为、故意配置一个很慢的FC、或者验证发送方对异常流控帧的处理能力CDD这套方案就显得僵硬。CDD文件的创建和审阅本身也需要专门知识很多项目里CDD掌握在诊断工程师手里测试工程师想改一个STmin都未必能快速找到地方。更麻烦的是如果被测ECU的TP层时序和CDD里配置的不一致问题往往藏在诊断描述文件里排查链路比代码还长。2.3 OSEK_TP.dll把传输层状态机放进外部协议栈OSEK_TP.dll不是一个新的协议而是把ISO 15765-2的传输层实现封装成了一个动态链接库供CANoe里的CAPL节点调用。它解决的问题是“CAPL不适合写复杂状态机”把分包、重组、FC超时、STmin精确计时这些交给C语言实现并编译成DLL稳定性和执行效率都高得多。使用它的方式是典型的“分工协作”CAPL负责接收总线帧并把原始字节喂给DLL负责从DLL取出待发送的CAN帧并output到总线上DLL负责所有TP协议的内部状态转移、序列号递增、定时参数计算、流量控制。应用层看到的始终是完整的TP报文看不到帧头、序列号这些底层细节。2.4 三条路线该怎么选一张表说清根据自己的实践我把三条路线做了一个对比路线开发量灵活度时序可控性适合场景手写CAPL高极高手动逐帧控制协议学习、特殊报文构造、异常注入CDD/诊断库低一般受诊断环境限制标准UDS交互测试、回归用例OSEK_TP.dll低高参数化配置ECU仿真、刷写自动化、多节点模拟如果你只是发几个单车诊断请求用CDD很省事如果你要模拟BCM、GW等多个ECU并及时响应各种UDS长帧或者在自动化环境中频繁刷写固件我强烈建议把OSEK_TP.dll方案纳入工具链。它比CDD重一些但比手写轻很多在实际工程中是比较平衡的选择。3. OSEK_TP.dll接入CANoe工程从加载到能收发3.1 第一步版本与位数的兼容性检查拿到任何厂商的TP协议栈DLL第一件事不是读API文档而是确认位数。CANoe不同版本有32位和64位之分DLL如果位数不匹配CAPL加载时会出现“无法加载动态链接库”或“找不到指定的模块”这类报错但有时候报错信息不明显表现为节点初始化失败、函数调用后返回异常值。所以建议先打开DLL所在目录用Dependency Walker或Visual Studio自带的dumpbin查看DLL是x86还是x64再确认CANoe进程位数。这个步骤花不了两分钟但能省掉后面一整天的排查时间。另外还要注意DLL是否依赖运行时库比如某些版本需要VC运行时目标机器上没有装就会出现“无法启动此程序因为计算机中丢失VCRUNTIME140.dll”一类的错误。3.2 CAPL中用#pragma library加载DLL并声明接口以我手头某套OSEK_TP.dll 2.1版本为例它的导出接口大概是这样一组C函数long OSEKTP_Init(byte channel, dword txId, dword rxId); long OSEKTP_SetTiming(byte stMin, byte blockSize); long OSEKTP_Send(dword txId, byte payload[], long len); long OSEKTP_OnFrame(byte canData[], long len); long OSEKTP_PollTx(byte outData[], long bufferLen); long OSEKTP_GetRxData(dword rxId, byte buffer[], long bufferLen); long OSEKTP_Deinit(void);在CAPL脚本头部用#pragma library把DLL挂进来再按同样签名声明extern函数#pragma library(OSEK_TP.dll) extern long OSEKTP_Init(byte channel, dword txId, dword rxId); extern long OSEKTP_SetTiming(byte stMin, byte blockSize); extern long OSEKTP_Send(dword txId, byte payload[], long len); extern long OSEKTP_OnFrame(byte canData[], long len); extern long OSEKTP_PollTx(byte outData[], long bufferLen); extern long OSEKTP_GetRxData(dword rxId, byte buffer[], long bufferLen); extern long OSEKTP_Deinit(void);这里有几个CAPL与C交互的硬规则CAPL的byte对应C的unsigned chardword对应unsigned longlong对应int或long数组参数在底层是指针。函数名大小写必须和DLL导出名完全一致否则CAPL编译期不报错运行期调不到函数返回值全部是默认值这种情况最容易让人误以为是协议没跑起来。3.3 初始化参数请求ID、响应ID与节点角色我建议用两个节点来做演示一个测试仪节点一个ECU仿真节点。测试仪节点负责向0x7E0发送请求ECU节点监听0x7E0并在0x7E8上回响应。初始化时两个节点的角色正好相反。测试仪节点在on preStart里on preStart { // 通道1测试仪请求ID0x7E0响应ID0x7E8STmin10msBlockSize0(不限) OSEKTP_Init(1, 0x7E0, 0x7E8); OSEKTP_SetTiming(10, 0); }ECU仿真节点在on preStart里on preStart { // 通道1ECU对方请求ID0x7E0本方响应ID0x7E8 OSEKTP_Init(1, 0x7E0, 0x7E8); }很多版本DLL的参数顺序可能是(txId, rxId)或(channel, rxId, txId)建议先看文档或者用厂家自带的demo工程作为参照。初始化失败返回非0时常见原因是同一通道上已经有别的节点占用了同一个CAN ID或者通道号填错。3.4 一个最小Demo发送8字节并接收响应先做收发通路验证不涉及UDS业务逻辑。在测试仪节点里定义发送用的报文对象message 0x7E0 txMsg;用一个周期定时器调用OSEKTP_PollTx把DLL产生的CAN帧发出去on timer 2ms { byte raw[8]; long len; int i; len OSEKTP_PollTx(raw, 8); if (len 0) { txMsg.dlc len; for (i 0; i len; i) { txMsg.byte(i) raw[i]; } output(txMsg); } }同时在on message 0x7E8里把ECU回上来的所有CAN帧喂给DLLon message 0x7E8 { byte raw[8]; int i; for (i 0; i this.dlc; i) { raw[i] this.byte(i); } OSEKTP_OnFrame(raw, this.dlc); }再写一个接收完整消息的定时器轮询on timer 10ms { byte fullMsg[4096]; long len; len OSEKTP_GetRxData(0x7E8, fullMsg, elcount(fullMsg)); if (len 0) { // 此时fullMsg里是DLL重组后的完整TP报文len是报文长度 } }第一次跑通时可以故意发送一个超过7字节的payload比如8字节的{0x10,0x01,0x00,0x00,0x00,0x00,0x00,0x00}确认Trace里出现了FF和CF再去ECU侧确认重组结果正确。这样你就验证了整个链路的收发通路。4. 刷写场景实测0x34/0x36长帧怎么用OSEK_TP.dll跑通4.1 刷写流程梳理从会话进入到传输数据刷写是长帧最典型的应用场景。ECU进入Bootloader后通常需要经历0x10 02进入编程会话、0x27安全解锁、0x31擦除、0x34请求下载、多个0x36传输数据、0x37退出传输。除0x27的seed/key较短外0x34的地址加长度参数一般会超过7字节而0x36每次携带的数据块少则几十字节、多则上千字节几乎必然触发FF/CF长帧传输。这也是我当初决定把TP层交给OSEK_TP.dll的主要原因——连续刷一个几MB的固件手写状态机太容易出幺蛾子。协议栈一旦接管业务层只需要关心“我要下载哪块数据”“这块数据多大”其余全部交给DLL。4.2 发送侧拼好完整应用报文后直接交给DLL发送侧的逻辑很简单把UDS服务ID和参数拼成一个完整的字节数组然后调用OSEKTP_Send。以0x34为例假设请求下载地址为0x00080000内存大小0x1000字节byte reqDownload[11] { 0x34, // RequestDownload服务ID 0x00, // dataFormatIdentifier 0x44, // addressAndLengthFormatIdentifier地址4字节长度4字节 0x00, 0x08, 0x00, 0x00, // 起始地址0x00080000大端 0x00, 0x00, 0x10, 0x00 // 内存大小0x00001000大端 }; OSEKTP_Send(0x7E0, reqDownload, elcount(reqDownload));注意0x34请求数据是11字节很多人习惯把UDS请求在CAPL里存成word数组或int数组再传给DLL。CAPL的word是16位传给DLL时如果DLL内部按byte解析长度直接翻倍后面的字节全是乱的。正确的做法是全部用byte数组让应用层和DLL之间的类型完全对齐。0x36数据传输服务的发送代码可以封装成一个函数这样后续刷写循环里调起来更清晰long SendTransferDataRequest(byte blockSeq, byte data[], long dataLen) { byte writeBlock[1002]; int i; writeBlock[0] 0x36; // TransferData服务ID writeBlock[1] blockSeq; // blockSequenceCounter for (i 0; i dataLen; i) { writeBlock[2 i] data[i]; } return OSEKTP_Send(0x7E0, writeBlock, dataLen 2); }4.3 接收侧模拟ECU如何重组并返回响应ECU节点同样用on message 0x7E0把测试仪发来的帧喂给DLL再用定时器轮询重组结果。这里要注意响应ID的选择0x34的肯定响应是0x74参数是长度格式标识加最大块长度0x36的肯定响应是0x76后跟“下一次期望的块序列计数器”所以应用层必须记住当前写入到第几块。byte expectBlockSeq 0x01; on timer 5ms { byte rxBuf[4096]; long len; byte sid; byte resp[4]; byte resp36[2]; len OSEKTP_GetRxData(0x7E0, rxBuf, elcount(rxBuf)); if (len 0) { return; } sid rxBuf[0]; if (sid 0x34) { resp[0] 0x74; resp[1] 0x00; // dataFormatIdentifier回显 resp[2] 0x00; // 最大块长度高字节 resp[3] 0x40; // 最大块长度低字节0x40代表64字节 OSEKTP_Send(0x7E8, resp, 4); } else if (sid 0x36) { if (rxBuf[1] expectBlockSeq) { // 模拟写入把rxBuf[2..len-1]写入内存 resp36[0] 0x76; resp36[1] expectBlockSeq 1; expectBlockSeq expectBlockSeq 1; OSEKTP_Send(0x7E8, resp36, 2); } } }这里必须提醒一个细节UDS的blockSequenceCounter是1到255递增的到255之后回0不是1到15循环CF的序列号才是1到15循环。两个计数器不要搞混。4.4 如何用Trace窗口验证分包与流控行为跑起来之后在Trace窗口过滤出0x7E0和0x7E8会看到请求方向依次出现FF、若干个CF中间穿插ECU回的另一条TP报文比如0x74响应。如果CANoe版本支持协议解码Trace窗口会直接显示ISO 15765-2并标注帧类型此时不需要手动看PCI字节直接看解码结果即可。建议验证三个关键点第一FF的12位长度字段等于你调用OSEKTP_Send传入的len第二CF的序列号从1开始递增到15回0中间不间断、不跳号第三FC对CF的节奏控制与你在OSEKTP_SetTiming里配置的STmin一致可以用CANoe的统计窗口观察总线负载是否平缓。另外如果Trace窗口里ID列和Name列显示空白优先检查DBC文件是否加载成功以及该诊断报文是否在DBC中有对应定义协议解码视图和DBC解析是两套机制不要混在一起排查。5. 参数设置与排查记录STmin、BS和五个高频坑5.1 STmin和BlockSize到底该怎么配STmin和BlockSize是TP层性能最敏感的两个参数。STmin代表连续帧最小间隔时间单位是毫秒常见取值为0x01到0x7FBlockSize代表连续发多少个CF后必须等一个新的FC0表示不限制。取值逻辑要先找出ECU接收缓冲区能容纳多少帧再决定BlockSize。如果ECU的CAN控制器缓冲只有16帧深度你设BlockSize0无限发而STmin2ms总线速率500kbps时大约每不到1ms就有一帧CF瞬间就会溢出。通常的做法是先从BlockSize8、STmin10ms开始跑到稳定后再把STmin往小压到5ms、2ms同时观察ECU是否出现负响应或丢帧。我在实测中的经验是在绝大多数量产ECU上STmin5ms、BlockSize16是一个又稳又不算慢的组合刷写工具内部为了追求速率很多会配到STmin2ms甚至0ms但那是建立在ECU缓冲区足够大的前提下。如果拿不准宁可用慢一点的配置先跑通功能再逐步优化。5.2 高频坑FC丢失引发N_Bs超时现象测试仪发完FF之后迟迟不见后续CF诊断请求超时。排查链路先看Trace里有没有FC帧。如果ECU收到FF后真的回了FC问题可能在测试仪侧。确认测试仪的on message事件是否把FC帧喂给了DLL。最典型的坑是同一个节点里还有另一个CAPL程序也在处理on message 0x7E8这个事件把帧处理异常导致DLL没拿到FC。如果FC发送侧也正常检查DLL内部的N_Bs超时配置。默认超时通常是1秒但有的版本初始化后会带一个极小的默认值导致FC还没到就超时。我实际遇到过一种更隐蔽的情况FC帧的CAN数据场被DBC解析成了别的信号Trace显示的是应用层信号而非原始字节于是看起来FC内容被“改”了。排查这种问题时要切到原始Raw View模式看。5.3 高频坑FF长度字段错误导致接收方沉默现象发送方发完FF后接收方完全不回FC总线上一片安静。排查链路把FF的两个PCI字节解出来计算12位长度与发送方实际传入的len对照。如果用的是手写TP大概率是长度计算错误把“当前帧剩余字节数”当成了“整个TP报文长度”。如果用的是OSEK_TP.dll接收方静默通常不是手算问题而是发送方调用OSEKTP_Send时传入的len参数小于实际数组长度。有些协议栈会直接用len去读数据区读不到的位置就变成未初始化内存表现是重组出的报文后半段是随机值。建议在调用OSEKTP_Send前打印len和数组内容确认传入的是真正的有效数据长度而不是缓冲区容量。5.4 高频坑扩展寻址模式少装一字节现象同一个DLL和代码Normal寻址下一切正常切到扩展寻址后数据内容错位或者接收方一直收不齐。原因很直接扩展寻址要在每帧CAN数据场中额外占用一个地址字节TP载荷相应从7字节降到6字节。如果还是按7字节的边界去数数据最后一帧必然对不上。排查时看Trace每一帧第一个数据字节应当是扩展地址它之后才是PCI和数据。如果DLL不感知这个地址字节需要初始化时显式传入寻址模式参数。做法上我倾向于直接用协议栈自带的扩展寻址模式配置而不是自己在CAPL里先剥掉地址字节再喂给DLL那样既要改发送也要改接收容易漏改一边。5.5 高频坑CAN FD下仍按经典CAN分帧现象把总线切到CAN FD比如64字节数据场后刷写性能没有任何提升长帧依然被拆成大量CF。排查结论通常是DLL没有开启FD模式或者初始化时没有把CAN帧类型配置成FD。ISO 15765-2在FD下的SF/CF载荷能力大幅提升前提是协议栈内部按FD长度上限做分包。反过来如果DLL开启了FD模式但CAN通道参数没有把数据场设为64字节底层发送函数会返回错误。所以切换FD时DLL配置和CAN通道配置要一起查一个在协议栈初始化里一个在CAN通道硬件配置里漏哪一个都会出问题。5.6 高频坑CAPL与DLL数据类型不匹配现象数据能发出去但ECU侧收到的payload前几个字节是对的后面乱码或者这里正确、下个节点错乱。原因通常是CAPL侧声明和DLL实际导出类型不一致。CAPL没有指针数组传参依赖编译器做的转换一旦声明成不同的整数宽度就会出现数据对齐问题。比如DLL内部把参数当unsigned short*处理而你在CAPL里声明成了byte output[]看起来字节数差不多但DLL按16位单元读CAPL实际按8位单元组织两者解析必然错位。排查方法把CAPL侧和DLL侧的首字节、末字节都打印出来逐个对照实在找不到差异就用十六进制方式把原始字节流对比一遍。另一个建议是尽量用byte数组做“搬运缓冲区”不要混用word和dword类型。6. 把这套方案延展到刷写自动化与多ECU仿真6.1 把0x34/0x36/0x37封装成CAPL刷写函数库当TP层稳定之后下一步就是把刷写流程从“手工一条条发”变成“一个函数搞定”。我在实际项目中封装了一个FlashDownload函数输入固件文件路径、起始地址和预期大小内部依次完成发送安全解锁、擦除、请求下载、循环传输数据、请求退出传输。由于TP层已经由DLL接管函数体里看不到任何FF/CF/FC逻辑可读性和可维护性都提升了一个量级。long FlashDownload(char firmwarePath[], dword baseAddr, dword totalSize) { // 1. 读取文件按ECU支持的最大块长度分块 // 2. 调用0x34请求下载 // 3. 循环调用SendTransferDataRequest // 4. 调用0x37结束传输 // 5. 整个过程返回成功/失败以及错误步骤 return 0; }有了这个封装测试用例里写“刷写A固件并验证版本号”就只需两行代码团队里其他不熟悉TP细节的人也能上手。如果后续想做更完整的自动化还可以通过CANoe的COM接口从Python侧调用这个CAPL函数把刷写集成到CI流水线里。6.2 与seedkey安全解锁DLL组合成完整安全刷写链路刷写场景里安全解锁几乎是绕不开的。解锁请求本身是短帧但解锁算法通常被OEM封装成独立的DLL比如SeedKey.dll。把两个DLL同时挂到同一个CAPL节点是很常规的操作#pragma library(SeedKey.dll) #pragma library(OSEK_TP.dll) extern long CalculateKey(byte seed[], long seedLen, byte key[], long keyBufSize);解锁流程是这样的测试仪发0x27 03ECU回seedCAPL收到完整seed后调CalculateKey计算再把计算出的key通过0x27 04发过去。由于传输层已经由OSEK_TP.dll接管中间即使seed和key超过7字节按完整报文交给TP层即可不用管分包。这里最需要注意顺序先做TP层联调再做安全算法联调一旦出错能快速定位是协议层还是算法层的问题。6.3 多ECU并发TP连接的正确打开方式网关测试或域控制器测试中经常要求一个CAN通道上同时仿真多个ECU每个ECU各自维护一条TP连接。手写CAPL时多连接意味着多套状态机和多个缓冲区代码很容易乱。而DLL方案里每个ECU独占一个连接ID或一组初始化参数互不干扰。实际操作时我会为每个ECU分配独立的节点在各自的on preStart里调用OSEKTP_Init并传入不同的rxId/txId这样地址映射天然隔离。并发场景下最容易出现的问题是多个ECU同时响应接收侧必须确保响应帧被送到正确的DLL连接。如果DLL的OSEKTP_OnFrame只接受帧数据而没有带上CAN ID那就要在CAPL里根据this.id分流某个on message 0x7E8只把帧喂给ID为0x7E8的连接另一路喂给0x7E9的连接。这一点在仿真多个诊断地址的ECU时要格外留意。6.4 最后一点建议先手写一遍再用DLL我把这个方法用了好几个项目最大的感触是DLL不是用来取代你对协议的理解而是用来把已经理解得很透彻的部分交给更可靠的代码去执行。如果你第一次接触ISO 15765-2我仍然建议先手写一遍SF/FF/FC/CF的最小收发场景哪怕只支持一个短帧加一个长帧等你看清了FF长度怎么编码、CF序列号怎么循环、FC为什么能够节流再去用OSEK_TP.dll做个完整刷写Demo会快很多。实际上很多用DLL却查不出问题的人问题往往不在DLL本身而在于他并不清楚协议层到底发生了什么导致连排查方向都找不到。
返回列表