关于UDS Bootloader的理论细节和具体流程,网上已有许多博主撰写了非常优秀的文章进行深入解析,本文就不再赘述。
这里我们直接长枪直入,跳过理论铺垫,专注于CANoe环境下的CAPL代码实战,手把手带你实现ECU升级的核心逻辑。
目录
[2.1 实现方法对比](#2.1 实现方法对比)
[方案三:基于定时器/Output/On Message手写TP](#方案三:基于定时器/Output/On Message手写TP)
[2.2 使用诊断函数前的配置](#2.2 使用诊断函数前的配置)
[2.3 刷写可视化面板绘制](#2.3 刷写可视化面板绘制)
[2.4 刷写可视化相关系统变量创建及绑定](#2.4 刷写可视化相关系统变量创建及绑定)
[3.1 使用到的主要诊断函数及变量类型简介](#3.1 使用到的主要诊断函数及变量类型简介)
[3.2 Step1:进入扩展会话](#3.2 Step1:进入扩展会话)
[3.3 Step2:关闭DTC检测](#3.3 Step2:关闭DTC检测)
[3.4 Step3:关闭非诊断通信](#3.4 Step3:关闭非诊断通信)
[3.5 Step4:检查预编程条件](#3.5 Step4:检查预编程条件)
[3.6 Step5:进入编程会话](#3.6 Step5:进入编程会话)
[3.7 Step6:安全等级解锁](#3.7 Step6:安全等级解锁)
[3.7.1 基于CANoe官方提供的SeedKey DLL算法生成的VS工程,自己编译一个DLL使用](#3.7.1 基于CANoe官方提供的SeedKey DLL算法生成的VS工程,自己编译一个DLL使用)
[3.7.2 CAPL代码实现安全解锁](#3.7.2 CAPL代码实现安全解锁)
[3.8 Step7:写入刷写指纹](#3.8 Step7:写入刷写指纹)
[3.9 Step8:请求下载数据(Driver)](#3.9 Step8:请求下载数据(Driver))
[3.9.1 CAPL解析Hex文件](#3.9.1 CAPL解析Hex文件)
[3.9.2 CAPL解析S19文件](#3.9.2 CAPL解析S19文件)
[3.9.3 CAPL解析Bin文件](#3.9.3 CAPL解析Bin文件)
[3.9.4 实现通用的解析器](#3.9.4 实现通用的解析器)
[3.9.5 CAPL实现34请求下载](#3.9.5 CAPL实现34请求下载)
[3.10 Step9:传输Driver数据](#3.10 Step9:传输Driver数据)
[3.11 Step10 退出Driver数据传输](#3.11 Step10 退出Driver数据传输)
[3.12 Step11 Driver数据完整性检查(CRC32)](#3.12 Step11 Driver数据完整性检查(CRC32))
[3.13 Step12 擦除APP块数据](#3.13 Step12 擦除APP块数据)
[3.14 Step13 请求下载数据(APP)](#3.14 Step13 请求下载数据(APP))
[3.15 Step14 传输APP数据](#3.15 Step14 传输APP数据)
[3.16 Step15 退出App数据传输](#3.16 Step15 退出App数据传输)
[3.17 Step16 App数据完整性检查(CRC32)](#3.17 Step16 App数据完整性检查(CRC32))
[3.18 Step17 编程依赖性检查](#3.18 Step17 编程依赖性检查)
[3.19 Step18 ECU复位](#3.19 Step18 ECU复位)
[3.20 Step19 进入扩展会话](#3.20 Step19 进入扩展会话)
[3.21 Step20 开启DTC检测](#3.21 Step20 开启DTC检测)
[3.22 Step21 开启非诊断通信](#3.22 Step21 开启非诊断通信)
[3.23 Step22 进入默认会话](#3.23 Step22 进入默认会话)
[3.24 整合执行所有刷写步骤](#3.24 整合执行所有刷写步骤)
[3.25 执行刷写,进行测试;](#3.25 执行刷写,进行测试;)
一、UDS升级的基本流程:
正式写代码前,我们先把要实现的通用流程列一下:
- 进入扩展会话 (
10 03) - 关闭DTC检测 (
85 02) - 关闭非诊断通信 (
28 03 03) - 检查预编程条件 (
31 01 02 03) - 进入编程会话 (
10 02) - 安全等级解锁 (
27 xx) - 写入刷写指纹 (
2E F1 90 XX XX...) - 请求下载数据(Driver) (
34 00 44 + 4字节起始地址 + 4字节长度) - 传输数据(Driver) (
36 + Sequence + Data) - 退出数据传输 (
37) - 完整性检查 (
31 01 F0 01 + CRC) - APP地址块擦除 (
31 01 FF 00 + 4字节起始地址 + 4字节长度) - 请求下载数据(App) (
34 00 04 + 4字节起始地址 + 4字节长度) - 传输数据(App) (
36 + Sequence + Data) - 退出数据传输 (
37) - 完整性检查 (
31 01 F0 01 + CRC) - 编程依赖性检查 (
31 01 FF 01) - ECU复位 (
11 01) - 进入扩展会话(10 03)
- 开启DTC检测 (
85 02) - 开启非诊断通信(28 00 03)
- 进入默认会话 (
10 01)
常见的升级流程大概是这样,不同的主机厂,不同的ECU可能在步骤上略有偏差,例如会增加一些读版本号机制,写零件号等...但大体的流程是一致的,学会了一类流程的代码实现之后,增删改流程是非常容易的,话不多说,下面我们直接开始实现。
二、刷写前的工程配置(演示版本,CANoe12SP6)
要想实现UDS升级,首要任务是搞定诊断数据的收发。在CANoe环境下,通常有三种主流实现路径,它们各有优劣。为了帮你理清思路,我将这三种方法的优缺点整理如下,方便对比选择:
2.1 实现方法对比
方案一:使用CANoe诊断函数
这是最直接的方式,直接调用CANoe内置的诊断API(如DiagSendRequest、DiagGetLastResponse等)。
- 优点 :
- 开发效率极高:封装完善,几行代码即可完成复杂服务的发送与接收。
- 容错性好:底层协议处理由CANoe接管,无需手动处理流控、超时等细节。
- 缺点 :
- 依赖诊断数据库:必须加载CDD/PDX/ODX等数据库文件或配置基础诊断模块,脱离了这些文件,代码寸步难行。
- 灵活性受限:难以在代码运行时动态修改诊断ID(例如切换目标ECU地址),通常需要在配置阶段写死。
方案二:使用TP层函数
利用CANoe提供的传输层接口(如osek_tp.dll),在传输层进行数据交互。
- 优点 :
- 动态性强:可以在CAPL代码中动态修改诊断对象的ID,非常适合需要动态切换刷写目标或地址的场景。
- 控制力适中:比纯手写TP简单,又比诊断函数灵活。
- 缺点 :
- 环境依赖:需要在CANoe节点的配置中显式加载VModule或DLL文件,配置过程相对繁琐。
方案三:基于定时器/Output/On Message手写TP
完全脱离CANoe的高级封装,利用底层定时器(Timer)、报文发送(Output)和接收回调(On Message)从零构建传输逻辑。
- 优点 :
- 移植性无敌:这是最接近硬件裸机开发的方式。代码逻辑与工具解耦,稍作修改即可移植到其他平台(如TsMaster/ZxDoc/Python),仅需替换底层的收发函数。
- 无外部依赖:不需要加载任何DLL或数据库,纯代码实现。
- 缺点 :
- 开发难度大:需要开发者对ISO 15765-2(TP层协议)有深刻理解,必须手动处理首帧、流控帧、连续帧的分包与重组,以及超时重传逻辑,对编码能力要求较高。
考虑到读者的水平可能高低不齐,为了通用性和简便性,本篇文章暂时选择使用第一种方案,借助诊断函数实现。后续计划会陆续发表基于osek_tp和手写tp的方法进行实现。
2.2 使用诊断函数前的配置
习惯性地,我通常会先创建一个文件夹,用于存放本工程的所有文件和数据;这里,我先创建一个一个文件夹,并出于我的习惯,会在文件夹内创建如下文件夹;

CaplIncludesFile:我通常用来存放一些可供其他节点调用的库文件,如公共的.can/.cin/.dll文件;
Databases:我通常用来存放总线数据库文件,如dbc/arxml/ldf文件等;
Diagnostic:我通常用来存放诊断数据库,如cdd/pdx/odx文件以及安全访问的dll文件等;
Nodes:我通常用来存放db中的各节点的仿真代码.can文件;
Panels:我通常用来存放工程中所用到的panel面板;
SysVariables:我通常用来存放工程所使用的系统变量文件;
TestModules:我通常用来存放测试模块的一些主体代码和测试报告/Log文件;
Logging:我通常用来存放总线日志Log文件;
随后打开CANoe,创建一个CAN 500波特率1通道的工程,并将配置文件也存储到这个文件夹内,如上图;
注意:现在创建完毕之后,开始创建诊断控制台,必须创建诊断控制台之后,才能使用CAPL的诊断函数

点击上图,打开诊断配置界面,随后在CAN处右击鼠标,有cdd/pdx/odx等诊断数据库文件的朋友,可以添加自己的数据库文件,没有的朋友,则可以像我一样,选择第二个选项,添加一个UDS基础诊断文件;


添加完毕诊断数据库或者添加基础诊断文件完毕后,就会得到上图一样的界面,注意,红框中的"ECU qualifier"这里后面显示框的名字,这个名字很关键,如果是添加的cdd等文件,这个名字会是一个其他的名字,如果是添加的基础诊断文件,就会和我的一样。

随后点击Transport Layer 在中间配置诊断的请求ID和响应ID,以及功能寻址ID,还有一些时间参数,时间参数我就不改了,这里我简单修改一下三个ID。
我这里设置请求ID为0x601,响应ID为0x701,功能寻址ID为0x7DF;大家可以根据自己的ECU实际情况设置;
要实现刷写,几乎所有的ECU都必须通过安全访问,而要通过CANoe的诊断来解锁ECU,必须添加SeedKey的dll文件,接下来在切换到Diagnostic Layer界面,在下方的Seed & Key DLL处添加自己的 安全算法dll文件(如果有的话,就添加在这里面,如果没有,后面我会教大家如何制作这个dll文件。这里我也先空着,稍后的章节讲解到如何制作dll的时候,会制作完毕之后一起添加。)
到此,我们的诊断配置就暂时结束了,接着就是按照第一节的流程,来逐步实现代码。
2.3 刷写可视化面板绘制
为了方便修改被刷写的文件,以及方便观察CAPL脚本中输出的刷写进度等信息,我们最好是创建一个Panel面板来可视化显示和修改这些数据。

在Panel处,选择New Panel;
关于Panel中各个控件的具体用法,不了解的朋友可以参考我的这篇文章:
这里我就不再讲解了;
直接添加panel面板以及控件并保存到Panels文件夹;

这里添加如下控件:
2个文件路径选择框,控件名:"Path Dialog",并设置它的过滤类型为"Driver File|*.hex;*.s19;*.bin|All Files|*.*" 这样子在点击旁边的文件夹图标时,可以过滤文件类型,只显示Hex/S19/bin文件/

另一个设置为"App File|*.hex;*.s19;*.bin|All Files|*.*" 即可让APP选择的时候,也只显示hex/s19/bin文件;
1个按钮,设置显示的文字为"开始刷写",用以点击这个按钮时,执行刷写流程;
2个进度条和2个输入输出框,对齐放在一起,用于显示刷写进度和APP传输进度(Driver文件通常传输非常快,在几百ms内,这里就不显示Driver文件的传输进度了)
1个输入输出框,用于显示刷写的结果;
2.4 刷写可视化相关系统变量创建及绑定
在第三步中,我们创建了一个Panel面板用于可视化刷写进度,刷写结果,以及设置刷写文件的路径,但这些控件并没有与任何系统变量相关联,没有绑定系统变量/信号的Panel控件是没有功能的,为了使得这些控件能够正常工作,我们还需要创建几个系统变量;关于系统变量的创建,如果朋友们还不是很会操作,可以参考我的这篇文章:这边文章主要是讲解函数的,但在其的第6部分,提到了系统变量的创建方法;

点击如图图标,打开系统变量界面,并右击创建一个系统变量;

首先,设置一个NameSpace,名为"EcuBootload",并设置这个系统变量的名称为"DriverFilePath",并设置其类型为String类型,点击OK;
紧接着,继续创建其他的变量;

这里创建了:
一个"AppFilePath",设置为String类型,用于存储APP文件的路径;
一个"DriverFilePath",设置为String类型,用于存储Driver文件的路径;
一个"ApptransmitProgress",设置为Int32类型,用于显示APP文件的传输进度;
一个"EcuFlashProgress",设置为Int32类型,用于显示当前的刷写流程进度;
一个"EcuFlashResult",设置为String类型,用于显示本次刷写的结果;
一个"StartFlashButton",设置为Int32类型,用于控制刷写流程启动;
为了保持系统变量的持久性与一致性,这里再选择一下将系统变量导出到文件中进行存储,

点击如图的图标,选择 Export All 进行系统变量的导出,我将其导出到SysVariables文件夹中,命名为SysVariable

做这一步的目的是为了方便移植,目前暂无其他用处;
创建完系统变量后,我们把这些系统变量和2.3中的Panel面板里的控件进行关联,
依次将
AppFilePath关联到APP 文件的路径选择器控件;
DriverFilePath关联到Driver 文件的路径选择器控件;
ApptransmitProgress关联到数据传输进度的进度条以及后面的输入框控件;
EcuFlashProgress关联到刷写进度的进度条以及后面的输入框控件;
EcuFlashResult关联到刷写结果的显示框控件;
StartFlashButton关联到开始刷写的按钮控件;
关联完毕后,就可以关闭Panel编辑界面了;
三、刷写代码的CAPL实现
在CANoe中编写CAPL代码,通常有三种节点类型,分别是,Simulation Step/Test Step以及Measurement Step,分别是仿真节点,测试节点和测量节点。
测量节点在Measurement Step中可以找一个方块的地方右击鼠标选择Insert Program Node插入,通常用于纯粹的测量和计算。这里不做演示,我在日常上位机开发中使用的也较少,个人认为使用频率不高。

仿真节点和测试节点,则可在Simulation Step中右击总线处进行插入,第一个Network Node就是仿真节点,而下方的三个Test Module就是测试节点,仿真节点和测试节点各有优劣,仿真节点可以触发CAPL中的任意事件,而测试节点则无法触发例如on start/on stop等事件,但仿真节点可以实现同步延时(一般可以认为是死等,可以一直等待...),这里为了简单,我选择使用测试节点,因为测试节点的延时等待机制,非常契合UDS中发生请求之后,等待到ECU响应的过程,实现刷写上位机比较容易,而仿真节点的话,要想实现刷写,需要对状态机的切换非常熟练,否则很难做到。

接下来,我们可以选择插入一个CAPL Test Module(使用起来最方便最简单)。

插入完毕之后,鼠标右击该节点,可以在Module name处给节点改个利于辨识的名字。
并且设置该节点的启动方法为系统变量启动,选择2.4中创建的StartFlashButton系统变量。
随后,正式开始我们的编程之旅;

首次点击如图框选的铅笔图标,会弹出弹窗让我们选择一个地方来存放由此节点创建的CAPL代码.can文件,这里我在先前的文件中打开TestMoudle文件夹,并且再在里面新建一个名为Bootload 的文件夹,将.can文件存储在我的Bootload文件夹内,命名为我的BootloadMain.can随后点击打开,即可打开CAPL编辑器开始代码的编写。

3.1 使用到的主要诊断函数及变量类型简介
|---|---|---|---|
| diagRequest;//诊断请求类型,本质上是一个C++的类,或者可以简单理解为是一个结构体,里面包含了要请求的uds数据数组,要请求的数据长度等信息。要通过CAPL的诊断函数发送诊断请求,则此类型是必须了解的 diagResponse;//诊断响应类型,本质上也是一个C++的类,里面同样地,包含了来自目标ECU返回的uds数据,以及返回的数据长度等信息,要通过CAPL的诊断函数获取诊断响应里的信息,这个类型也是必须要了解的。 ||||
|---|---|---|---|
| long diagSetTarget (char ecuName[]);//用于设置目标诊断ECU,也就是你的诊断请求要发到那个ECU里面去,这个函数的参数,表面上写的是ecuName,看上去是ECU的名称,实际上,应该填写第二章节中提到的诊断配置界面上的"ECU qualifier"这里后面显示框的名字,因为这各qualifier里面就包含了对应的目标ECU的Tx,Rx还有功能寻址ID。 ||||
|---|---|---|---|
| long diagSendRequest (diagRequest obj);//用于向目标ECU发送指定的诊断请求 ||||
|---|---|---|---|
| long diagGetLastResponse (diagRequest req, diagResponse respOut); long diagGetLastResponse (diagResponse respOut);//该函数有两种重载形式,所谓重载,就是给一个函数提供了可以传入不同种类和不同参数数量的方法,这里的第一种形式是2个参数的,可以获取某个指定的诊断请求的最近一次响应,而第二种形式,只能获取到最近的一次诊断响应数据,无法指定某个请求。 ||||
|---|---|---|---|
| long diagGetLastResponseCode (diagRequest req); long diagGetLastResponseCode ();//这个函数可以获取上一次的诊断响应的响应码,如果是否定响应,返回值为-1,简而言之,可以使用这个函数来判断上一次的诊断响应是不是肯定响应,同样具备两种重载,具备指定请求的能力。 ||||
|---|---|---|---|
| long TestWaitForDiagResponse (diagRequest request, dword timeout); // form 1 long TestWaitForDiagResponse (dword timeout); // form 2 这个函数可以用来判断在一段时间内诊断是否响应,如果响应了,则会返回1,否则返回0.同样地,也有重载形式,可以指定等待某个诊断请求的响应。 ||||
|---|---|---|---|
| long diagGetPrimitiveData (diagResponse obj, byte* buffer, DWORD buffersize); long diagGetPrimitiveData (diagRequest obj, byte* buffer, DWORD buffersize); long diagSetPrimitiveData (diagResponse obj, byte* buffer, DWORD buffersize); long diagSetPrimitiveData (diagRequest obj, byte* buffer, DWORD buffersize);这个函数可以用来设置一个诊断请求/响应的tp层数据,例如要把某个诊断请求的数据设置为10 03,则只要定义一个数组,数组数据填写为10 03,并调用这个函数设置给对应的诊断请求或者响应即可。 ||||
|---|---|---|---|
| long diagSetPrimitiveByte( diagRequest request, DWORD bytePos, DWORD newValue); long diagSetPrimitiveByte( diagResponse response, DWORD bytePos, DWORD newValue);//这个函数和上一个的功能一致,都是用来给指定的诊断请求/响应设置数据的,区别就在于,上一个函数是可以一次性设置所有的字节,也就是把数据全部设置完,而这个函数,一次只能设置一个指定的字节。 ||||
|---|---|---|---|
| long diagGetPrimitiveData (diagResponse obj, byte* buffer, DWORD buffersize); long diagGetPrimitiveData (diagRequest obj, byte* buffer, DWORD buffersize);//这个函数可以用来获取一个指定的诊断请求/响应的tp层数据,会把所有的数据都填充到传入的数组buffer中。 ||||
|---|---|---|---|
| long diagGetPrimitiveByte( diagRequest request, DWORD bytePos); long diagGetPrimitiveByte( diagResponse response, DWORD bytePos);//这个函数和上一个基本一致,也是获取一个指定的诊断请求/响应的tp层数据,区别在于,此函数仅能获取一个指定的字节。 ||||
|---|---|---|---|
| long DiagGetPrimitiveSize( diagRequest request); long DiagGetPrimitiveSize( diagResponse response);//这个函数用于获取一个指定的诊断请求/响应的tp层数据长度。 ||||
|---|---|---|---|
| long diagGenerateKeyFromSeed ( byte seedArray[], dword seedArraySize, dword securityLevel, char variant[], char ipOption[], byte keyArray[], dword maxKeyArraySize, dword& keyActualSizeOut); // form 1 long DiagGenerateKeyFromSeed(char ecuQualifier[], byte seedArray[] , dword seedArraySize, dword securityLevel, char variant[], char option[] , byte keyArray[], dword maxKeyArraySize, dword& keyActualSizeOut); // form 2 这个函数用于27服务计算密钥,通过传入seed数组和key数组以及对应的安全等级,会将计算出的key填充在key数组中,并以输出参数(指针(引用))的方式,将计算的密钥的字节数填充给到keyActualSizeOut这个参数; ||||
注意,以上所有的参数为DiagRequest或者DiagResponse类型的函数,除了上述这种用法之外,还可以使用诊断类型的成员函数的方法实现(也就是原本的long diagSendRequest (diagRequest obj);这种写法,当我们定义了一个DiagRequest * obj,定义了一个obj之后,也可以通过 obj.SendRequest();这样子的写法来实现,简单总结一下就是,所有的参数是诊断请求/诊断响应的函数,均可以省略掉前面的Diag,然后直接使用obj.xxxx实现)
以上就是在UDS刷写中主要使用到的诊断函数了,CAPL诊断函数库中有着非常多的函数,功能也各式各样,大家可以自行查看CAPL的帮助文档,包括上述我介绍的这些函数,我也只是做了最基本的简介,具体的用法和功能,也可以通过CAPL帮助文档了解。
3.2 Step1:进入扩展会话
这里我们创建一个函数,函数名就叫FlashStepA吧,用来表示进入第一步,后面的其他步骤就暂时以BCDEFG...等命名;
首先创建一个全局的诊断请求对象,我定义为gDiagRequest;
再定义一个全局的诊断响应对象,我定义为gDiagResponse;
然后就可以开始编写函数了,这里编写一个返回值为int的函数,函数名"FlashStepA"
用于进入扩展会话,如果进入成功(收到ECU回复的肯定响应),就返回1给调用者,否则返回0给调用者;
cpp
variables
{
diagRequest * gDiagRequest;//定义全局诊断请求
diagResponse * gDiagResponse;//定义全局诊断响应
}
/*进入扩展会话,成功返回1,失败返回0*/
int FlashStepA()
{
byte reqData[2] = {0x10,0x03};//定义诊断请求数据
const dword waitRespMaxTimeout = 1000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在进入扩展会话");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//设置诊断请求的数据为10 03
gDiagRequest.SendRequest();//发送10 03诊断请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将10 03的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
ok呀,代码编写完毕,接着我们来测试一下功能是否实现了。

再CAPL编辑器的左侧,选择Test Control,右击鼠标,新建一个MainTest;
由于我们的代码中调用了诊断函数,要使得诊断函数能够知道目标的ECU是谁(能够以正确的诊断请求ID和诊断响应进行数据的收发),我们需要先设置一下诊断的目标ECU,使用DiagSetTarget函数;设置一下我们先前提到的"ECU qualifier"的名字;
并且调用一下FlashStepA函数,通过返回值来看看,函数的功能是否实现了;
如果大家手里有真实的ECU,这一步就可以接入真实的ECU来测试一下看看了;
这里由于是教学,我就不使用真实的ECU了,防止有的朋友没有。我将增加一个节点来仿真ECU做出对应的响应,这一步友友们可以不需要关注,我的评论区下方会附带这个刷写工具的完整工程,里面就有这个仿真的ECU节点,对于没有真实节点的友友,可以使用我这个工程来进行学习和仿真,我的仿真ECU会自动回复响应。
cpp
void MainTest()
{
int ret;
diagSetTarget("BasicDiagnosticsEcu");
ret = FlashStepA();
write("FlashStepA:result:%d",ret);
}
随后,我们点击一下开始刷写/或者点击这个测试节点处的启动图标;(这里由于我是仿真,我就把总线模式由real bus调整为simulated bus了)

观察trace的报文和write窗口的打印;

这里看到,当我们的ECU接收到10 03的请求时,如果回复了肯定响应(图上是我的虚拟ECU回复的,所以报文方向是Tx,这个没关系)
在检测到肯定响应时,我们的代码打印了结果为1,说明我们的代码正确的判断到了肯定响应的出现。
紧接着,我把我的虚拟节点修改为回复负响应看看;大家有真实节点的可以试着发一个不支持的会话看ECU的回复;

这里看到,当ECU回复负响应时,代码成功检测到了非正响应,打印了0;
紧接着再测试一下不响应的情况,有真实ECU的朋友可以试着把ECU拔掉看看;

这里看到,当ECU不响应时,我们也正常的检测到了非正响应(未响应嘛,肯定就不是成功了);
到此,经过验证,我们的StepA这个写法,是可以成功的检测到步骤是否通过了的,对吧。
那么接下来,除了几个特殊的步骤(27服务需要计算种子和密码,31服务除了需要判断正响应,往往还需要判断例程结果字节(byte4),34服务需要计算单次36的数据长度,36服务需要填充传输的数据)外,其他的步骤我们的代码是不是可以直接复制了呢,复制之后改一下请求的数组内容不久ok了?答案,是的。所以接下来的几个非特殊的步骤,就可以直接复制代码了。
3.3 Step2:关闭DTC检测
cpp
/*关闭DTC检测,成功返回1,失败返回0*/
int FlashStepB()
{
byte reqData[2] = {0x85,0x02};//定义诊断请求数据
const dword waitRespMaxTimeout = 1000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在关闭DTC检测");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.4 Step3:关闭非诊断通信
cpp
/*关闭非诊断通信,成功返回1,失败返回0*/
int FlashStepC()
{
byte reqData[3] = {0x28,0x03,0x03};//定义诊断请求数据
const dword waitRespMaxTimeout = 1000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在关闭非诊断通信");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.5 Step4:检查预编程条件
这条step会稍微特殊一点,因为使用的是31服务,31服务除了需要判断是不是肯定响应以外,通常还需要判断例程的结果,也就是在byte4的位置,例如请求 31 01 02 03,回复71 01 02 03 XX,通常XX这个字节,会指示例程执行的结果,对于31服务,大部分的OEM和ECU厂商定义的都是,result == 0x00表示通过,result != 0x00表示不通过,那么,我们这里就需要对这个byte4再加以判断一下,需要是肯定响应且byte4为0x00时才返回1给调用者;
cpp
/*预编程检查,成功返回1,失败返回0*/
int FlashStepD()
{
byte reqData[4] = {0x31,0x01,0x02,0x03};//定义诊断请求数据
const dword waitRespMaxTimeout = 1000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在进行预编程检查");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1 && respData[4] == 0x00)//如果是肯定响应并且例程结果为00 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.6 Step5:进入编程会话
对于编程会话,进入编程会话之后,ECU会复位,可能时间会略长,所以我们等待响应的时间可以适当改长一些,当然了,每一步的等待时间,朋友们其实都应该根据自己的ECU和主机厂的实际情况决定,我这里只是作为演示。
cpp
/*进入编程话,成功返回1,失败返回0*/
int FlashStepE()
{
byte reqData[2] = {0x10,0x02};//定义诊断请求数据
const dword waitRespMaxTimeout = 2000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在进入编程会话");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//设置诊断请求的数据为10 02
gDiagRequest.SendRequest();//发送10 02诊断请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将10 02的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.7 Step6:安全等级解锁
安全解锁这步就稍微麻烦一些了,通过安全解锁需要先请求种子,收到种子之后,再拿接收到的种子去使用ECU端的校验算法进行计算,将计算的密码再发送给ECU,然后判断ECU的响应才知道是否解锁成功了。
那么该怎么调用解锁算法呢?这里有3种方法:
方法1:如果你有该ECU的seedKey.dll文件,那么可以根据你的CANoe程序的位数(据我所知,好像CANoe15及以上版本,运行的程序是64位的,CANoe15以下的版本,都是32位的),把对应位数的解锁算法dll加载进我在第2.2步提到的,加载安全算法dll的地方。此时,就可以在CAPL中通过计算密钥的函数 diagGenerateKeyFromSeed 直接使用了;
方法2:如果你拥有该ECU的底层算法代码,可以把这个C代码转换成CAPL的语法,写一个CAPL的函数,自己调用去计算密钥,这个也可以,但我相信大部分读者,如果不是该项目的底软开发者,是不可能拥有这种代码源码的。
方法3:如果你的dll,是基于CAPL DLL的语法进行封装过的,那么则无需加载进诊断控制台中,直接在CAPL代码中使用 #pragma library("xxx.dll")动态加载这个capl dll,调用其中的函数进行计算;但这个CAPL DLL兼容的dll编程语法,可能没几个人熟悉,不会生成。
最简单的方法呢,还是使用方法1,把dll加载进诊断控制台中
鉴于有的读者,可能有ECU的C算法源文件,想封装一个dll给同事/朋友在CANoe/TsMaster这些地方的诊断控制台调用,但不知道怎么封装这个dll文件;也可能有的朋友单纯就是为了学习ECU的刷写流程,根本没有这些文件;
现在,我将在本篇文章中,附带教大家,怎么生成这个可以在CANoe/TsMaster/ZXDoc等软件中可以在诊断控制台中调用的SeedKey.dll文件;(前提,需要安装一个Visual Studio 2013之后的任意版本都可以,并且安装C++桌面开发扩展)
3.7.1 基于CANoe官方提供的SeedKey DLL算法生成的VS工程,自己编译一个DLL使用
大家可以在自己电脑的这个路径,找到CANoe提供的官方demo
"C:\Users\Public\Documents\Vector\CANoe\Sample Configurations 12.0.221\CAN\Diagnostics\UDSSystem\SecurityAccess\Sources"
其中,Sample Configurations 12.0.221这里,不一定和我一样,这里显示的是大家安装的CANoe的版本。

这两个文件夹里面就是CANoe官方提供的生成SeedKey.dll的demo文件,哪个文件夹都可以。
这里我选择上面这个文件夹,先把它复制一份,放到我们的工程文件夹里面,然后我们在复制的上面修改,免得不小心改错了,使得demo都没法用了;

拷贝完成之后,使用VS打开这个文件,就可以开始编辑它的Demo代码了。
由于这个Demo编写时的IDE比较老了,大家使用现在的新的VS打开,会提示让更新解决方案等,大家直接更新就可以了。


打开之后我们可以看到,工程生成的文件默认是32位的(x86)Debug模式的dll,
大家如果要编译64位的dll,需要修改一下目标位数;

点击这里的下拉箭头,选择配置管理器;

选择新建,并选择平台为x64,并且选择从x86处复制设置,确定后,关闭这个界面即可。


现在我们的工程就可以选择编译32位还是64位的dll程序了,然后左边的debug大家可以下拉,改成release模式,因为debug编译的dll,依赖于一些VS的debug库,别人的电脑上可能没有,为了通用性,也是为了标准,建议选择release模式编译。
由于我的CANoe是12版本,所以我就编译x86的了(32位 release模式)

大家可以修改这个函数里面的实现,然后直接编译,就可以生成对应的dll文件了。
这里因为是演示,我就不改他的demo代码了,也就是收到种子之后,直接把种子取反然后输出出去,如果大家想修改这个算法和逻辑,可以自行修改后编译。
点击编译后,可能会提示这个报错信息,不要担心

这是因为这个工程实际生成的dll叫 SeednKey.dll,并不叫GenerateKeyExlmpl.dll导致的,这应该是官方demo配置的时候,名字忘记改了。

实际上只要你点击编译之后,就会把dll生成到这两个文件夹中,在哪里取决于你是以什么模式编译的,另外,如果按照我刚刚的配置方法,编译了64位的dll,dll也在这两个文件夹里面,注意,也在这两个文件夹里面,并不是在x64里面。(就是说,不管你是x86编译的还是x64编译的,生成的文件,都在这个文件夹里面。而且名字也是一样的)

如果大家不确定自己的dll是32位还是64位的,我教大家一个方法可以区分。
首先,需要一台安装了VS的电脑,然后再电脑的搜索界面,搜索,developer这个软件,这是VS自带的。

打开之后,输入
dumpbin /headers "C:\Users\58248\Desktop\CAPL_UdsBootload\KeyGenDll_GenerateKeyEx - 副本\Debug\SeednKey.dll"

这里的路径是你自己的dll路径,输入完毕后回车,即可看到这个dll的位数

这个地方会显示x86/x64.
至此,SeedKey.dll的生成教程就结束了,大家可以把生成的dll拷贝到我们的工程文件夹里面 Diagnostic这个文件夹中,然后在诊断控制台中加载这个dll即可。
3.7.2 CAPL代码实现安全解锁
首先,假设我们的安全等级是levelX,
那么我们需要先发送 27 levelX给ECU,当ECU回复 67 levelX YY YY YY YY...之后,YY YY YY YY...就是我们的种子,需要把这个种子存储起来,并拿去调用 diagGenerateKeyFromSeed这个函数,计算处对应的密码,然后再把密码 填充到 27 LevelX+1之后,发送 27 LevelX+1 KK KK KK KK...,并等待ECU响应,判断是否为 67 LevelX+1,来知道是否通过了安全访问。
这边我假设我的levelX为0x05;
cpp
/*安全解锁,成功返回1,失败返回0*/
int FlashStepF()
{
byte reqData[64];//定义诊断请求数据
const dword waitRespMaxTimeout = 1000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
const safeLevel = 0x05;//假设我的安全等级为0x05
byte seedArr[4],keyArr[4];
dword keySize;
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在进行安全等级解锁");
gDiagRequest.Resize(2);//设置请求的字节数为2 27 05
reqData[0] = 0x27;reqData[1] = safeLevel;
gDiagRequest.SetPrimitiveData(reqData,2);//设置诊断请求的数据为27 05
gDiagRequest.SendRequest();//发送 诊断请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将27 05的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) != -1) return 0;//种子没回复肯定响应 失败
//没有返回出去,说明种子肯定回复了肯定响应 那么我们把种子拷贝出来
memcpy_off(seedArr,0,respData,2,respByteNum - 2);//从响应数据的byte2开始拷贝,拷贝respByteNum-2 字节,放到seedArr的byte0的地方开始
//拷贝完毕,开始算密码了
DiagGenerateKeyFromSeed(seedArr,respByteNum - 2,safeLevel,"","",keyArr,elcount(keyArr),keySize);//
//把密钥拷贝到请求数据里面
memcpy_off(reqData,2,keyArr,0,keySize);
gDiagRequest.Resize(keySize + 2);//设置请求的字节数为密钥的长度+2
reqData[1] = safeLevel + 1;
gDiagRequest.SetPrimitiveData(reqData,keySize + 2);//设置诊断请求的数据为27 06 + 四字节密钥
gDiagRequest.SendRequest();//发送 诊断请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将27 06的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}

我的代码经过测试,可以看到,确实将种子取反后发送了出去。安全访问ok(算法符合dll)
3.8 Step7:写入刷写指纹
写入刷写指纹这一条,通常的ECU和主机厂,写入的都是刷写时的北京时间,方便日后查找证据的时候,知道是什么时候被刷写的,那么这里,我也实时获取当前的北京时间并且进行写入了。
cpp
/*写刷写指纹,成功返回1,失败返回0*/
int FlashStepG()
{
byte reqData[9];//定义诊断请求数据
const dword waitRespMaxTimeout = 1000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
long lt[9];
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在写入指纹");
reqData[0] = 0x2E;reqData[1] = 0xF1;reqData[2] = 0x90;//设置did
getLocalTime(lt);//获取当前北京时间
reqData[3] = lt[5] - 100;//年 lt[5]是从1900年开始计数的,所以2026他返回的就是126,那么我们就需要减去100
reqData[4] = lt[4] + 1;//月 lt[4]是从0开始计数 0表示1月 11表示12月,所以要+1
reqData[5] = lt[3];//日
reqData[6] = lt[2];//小时
reqData[7] = lt[1];//分钟
reqData[8] = lt[0];//秒
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//设置诊断请求的数据为
gDiagRequest.SendRequest();//发送诊断请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.9 Step8:请求下载数据(Driver)
本步骤需要告诉ECU,我们要传输的数据位于哪个地址,数据总共有多长,那么这些数据从哪里来呢?通常,我们会把这些数据记录在Hex/S19/Bin文件中(bin只有数据,无地址,需要手动指定地址),那么我们怎么获取这些数据呢,答案很明显,那就是去读取Hex/S19/Bin文件,并解析其中的数据,怎么解析?具体的原理我也不再说太多,网上很多博主都有对Hex/S19/Bin文件的格式详解,我们是实用派,直接上手写代码,需要了解原理的,可以阅读其他博主的讲解文章,这里我只做简单的讲解和描述。
为了使得我们这个解析文件的代码,具备解耦性(能够在任何一个文件都能编译过,减少依赖),我这里创建一个新的文件,专门用来解析文件;

在CAPL浏览器的左上角,右击Includes 选择Add Include,增加一个头文件,这里我就把这个文件放在先前创建的CaplIncludesFile文件夹内了(通用性文件,建议放在一起)


创建完毕后,CAPL文件的includes处就会出现对这个文件的包含。
这边为了保持解析函数接口通用性,我定义了一些通用的变量;
所有block的数据顺序拼接存放在一个 2MB 的一维 byte 数组中,通过索引数组记录每个block的元信息:
这样做的好处是:避免了二维大数组带来的 CAPL 内存限制问题,一个一维数组加几个小索引数组即可管理所有block。
cpp
variables
{
/* ============ 全局block存储 ============ */
byte gAllBlockData[0x200000]; // 2MB, 所有block数据的平坦存储
dword gBlockOffset[20]; // 每个block在gAllBlockData中的起始偏移
dword gBlockStartAddr[20]; // 每个block的Flash起始地址
dword gBlockDataLen[20]; // 每个block的数据长度(字节)
dword gBlockCount = 0; // 解析出的block总数
dword gTotalDataLen = 0; // gAllBlockData中已使用的总字节数
}
而且解析完成后,可以通过设计两个函数来返回任意一个block中的数据(hex/s19中可能存在不知一个block)
由于Hex文件和S19文件中,一个16进制数据是以两个字符存储的,需要把字符转换到16进制数据,所以,需要实现两个函数;
函数:hexCharToVal 用于将单个十六进制 ASCII 字符转换为 0~15 的数值。同时兼容大写 A-F 和小写 a-f。无效字符返回 0xFF。
函数:hexPairToByte 从 char 数组的指定位置取相邻两个十六进制字符,调用 hexCharToVal 分别得到高4位和低4位,拼合为一个 byte。这是 HEX 和 S19 解析中最核心的基础操作------将文本形式的十六进制字符串还原为二进制数据。
cpp
/*
* 将单个十六进制字符转换为数值(0~15)
* 参数: c - 十六进制字符 ('0'-'9', 'A'-'F', 'a'-'f')
* 返回: 0~15 成功, 0xFF 无效字符
*/
byte hexCharToVal(char c)
{
if (c >= '0' && c <= '9')
{
return (byte)(c - '0');
}
if (c >= 'A' && c <= 'F')
{
return (byte)(c - 'A' + 10);
}
if (c >= 'a' && c <= 'f')
{
return (byte)(c - 'a' + 10);
}
return 0xFF;
}
/*
* 从行缓冲区的指定位置解析两个十六进制字符为一个字节
* 参数: lineBuf - 行缓冲区, pos - 起始位置(两个hex字符中第一个的下标)
* 返回: 解析出的字节值
*/
byte hexPairToByte(char lineBuf[], int pos)
{
return (byte)((hexCharToVal(lineBuf[pos]) << 4) | hexCharToVal(lineBuf[pos + 1]));
}
第一个block的数据通过函数参数 data[]、startAddr、dataLen 直接返回给调用者
所有block(包括第一个)的数据都存储在全局变量中
调用者后续可通过 getBlockData(blockIndex, ...) 按索引提取任意block
调用者可通过 getBlockCount() 获取block总数
cpp
/*
* 获取指定block的数据
*
* 参数:
* blockIndex - block索引 (0 ~ gBlockCount-1)
* data - 足够大的byte数组, 用于接收block数据
* startAddr - [输出] 该block的起始地址
* dataLen - [输出] 该block的数据长度(字节)
*
* 返回值:
* 1 成功
* 0 失败(索引越界)
*/
long getBlockData(dword blockIndex, byte data[], dword &startAddr, dword &dataLen)
{
dword i;
dword offset;
if (blockIndex >= gBlockCount)
{
write("getBlockData: 索引%d越界, 当前共%d个block", blockIndex, gBlockCount);
startAddr = 0;
dataLen = 0;
return 0;
}
startAddr = gBlockStartAddr[blockIndex];
dataLen = gBlockDataLen[blockIndex];
offset = gBlockOffset[blockIndex];
for (i = 0; i < dataLen; i++)
{
data[i] = gAllBlockData[offset + i];
}
return 1;
}
cpp
/*
* 获取解析出的block总数
*/
dword getBlockCount()
{
return gBlockCount;
}
3.9.1 CAPL解析Hex文件
需要解析文件,首先我们肯定要知道文件的路径,所以,这个函数需要接受一个文件路径作为参数,然后我们再把解析出来的数据数组,以及起始地址,数据长度告知调用者,那么我准备为这个函数设计四个参数;
cpp
/*
* 解析Intel HEX文件
*
* 参数:
* filePath - hex文件路径
* data - 足够大的byte数组, 用于接收第一个block的数据
* startAddr - [输出] 第一个block的起始地址
* dataLen - [输出] 第一个block的数据长度(字节)
*
* 返回值:
* > 0 解析成功, 返回值为解析的总行数
* -1 文件格式错误
* -2 行数据格式错误(缺少':')
* -3 打开文件失败
* -4 block数量超限
* -5 校验和错误
*
* 说明:
* 所有block的数据均存储在全局变量中,
* 可通过 getBlockData() 获取任意block的数据,
* 通过 getBlockCount() 获取block总数.
*/
long parseHexFile(char filePath[], byte data[], dword &startAddr, dword &dataLen)
{
long fileHandle;
char lineBuf[600];
dword lineNum;
dword baseAddr; // 基地址, 由type 02/04记录设置
dword fullAddr; // 当前数据记录的完整32位地址
byte byteCount; // 当前行的数据字节数
byte recordType; // 记录类型
word lineAddr; // 行地址字段(16位)
byte calcSum; // 计算得到的校验和
byte fileSum; // 文件中的校验和
dword i;
int blockStarted; // 标记是否已开始第一个block
dword currBlockNextAddr; // 当前block期望的下一个连续地址
// 初始化全局存储
gBlockCount = 0;
gTotalDataLen = 0;
baseAddr = 0;
blockStarted = 0;
startAddr = 0;
dataLen = 0;
fileHandle = openFileRead(filePath, 0);
if (fileHandle == 0)
{
write("打开文件失败: %s", filePath);
return -3;
}
lineNum = 0;
while (fileGetString(lineBuf, elcount(lineBuf), fileHandle) != 0)
{
lineNum++;
// 验证起始标记
if (lineBuf[0] != ':')
{
write("第%d行格式错误: 缺少起始标记':'", lineNum);
fileClose(fileHandle);
return -2;
}
// 解析固定字段
byteCount = hexPairToByte(lineBuf, 1);
lineAddr = ((word)hexPairToByte(lineBuf, 3) << 8) | (word)hexPairToByte(lineBuf, 5);
recordType = hexPairToByte(lineBuf, 7);
// 校验和验证: 从byteCount到最后一个数据字节, 再加上checksum本身, 总和低8位应为0
calcSum = byteCount + (byte)(lineAddr >> 8) + (byte)(lineAddr & 0xFF) + recordType;
for (i = 0; i < byteCount; i++)
{
calcSum += hexPairToByte(lineBuf, (int)(9 + i * 2));
}
calcSum = (byte)((~calcSum) + 1);
fileSum = hexPairToByte(lineBuf, (int)(9 + byteCount * 2));
if (calcSum != fileSum)
{
write("第%d行校验和错误: 计算值=0x%02X, 文件值=0x%02X", lineNum, calcSum, fileSum);
fileClose(fileHandle);
return -5;
}
// ---- 根据记录类型分别处理 ----
if (recordType == 0x00) // 数据记录
{
fullAddr = baseAddr + (dword)lineAddr;
if (!blockStarted)
{
// 第一条数据记录 → 开始第一个block
blockStarted = 1;
currBlockNextAddr = fullAddr;
gBlockOffset[0] = 0;
gBlockStartAddr[0] = fullAddr;
gBlockDataLen[0] = 0;
gBlockCount = 1;
}
else if (fullAddr != currBlockNextAddr)
{
// 地址不连续 → 新block
if (gBlockCount >= 20)
{
write("block数量超过最大限制(20), 停止解析");
fileClose(fileHandle);
return -4;
}
gBlockOffset[gBlockCount] = gTotalDataLen;
gBlockStartAddr[gBlockCount] = fullAddr;
gBlockDataLen[gBlockCount] = 0;
gBlockCount++;
}
// 将本行数据存入全局缓冲区
for (i = 0; i < byteCount; i++)
{
gAllBlockData[gTotalDataLen] = hexPairToByte(lineBuf, (int)(9 + i * 2));
gTotalDataLen++;
gBlockDataLen[gBlockCount - 1]++;
}
currBlockNextAddr = fullAddr + (dword)byteCount;
}
else if (recordType == 0x01) // 文件结束记录
{
break;
}
else if (recordType == 0x02) // 扩展段地址记录
{
baseAddr = (((dword)hexPairToByte(lineBuf, 9) << 8) | (dword)hexPairToByte(lineBuf, 11)) << 4;
}
else if (recordType == 0x04) // 扩展线性地址记录
{
baseAddr = (((dword)hexPairToByte(lineBuf, 9) << 8) | (dword)hexPairToByte(lineBuf, 11)) << 16;
}
// type 03 (起始段地址) 和 type 05 (起始线性地址) 不影响数据, 直接跳过
}
fileClose(fileHandle);
// 将第一个block的数据拷贝给调用者
if (gBlockCount > 0)
{
startAddr = gBlockStartAddr[0];
dataLen = gBlockDataLen[0];
for (i = 0; i < gBlockDataLen[0]; i++)
{
data[i] = gAllBlockData[i]; // block 0 的 offset 始终为 0
}
}
write("HEX文件解析完成: 共%d行, %d个block", lineNum, gBlockCount);
for (i = 0; i < gBlockCount; i++)
{
write(" Block %d: 起始地址=0x%08X, 长度=%d字节", i, gBlockStartAddr[i], gBlockDataLen[i]);
}
return (long)lineNum;
}
3.9.2 CAPL解析S19文件
S19文件同样的,保持和Hex解析一样的参数形式
cpp
/*
* 解析Motorola S-record (S19/S28/S37) 文件
*
* 参数:
* filePath - S19文件路径
* data - 足够大的byte数组, 用于接收第一个block的数据
* startAddr - [输出] 第一个block的起始地址
* dataLen - [输出] 第一个block的数据长度(字节)
*
* 返回值:
* > 0 解析成功, 返回值为解析的总行数
* -2 行数据格式错误(缺少'S'或类型非法)
* -3 打开文件失败
* -4 block数量超限
* -5 校验和错误
*
* 说明:
* 支持 S0/S1/S2/S3/S5/S7/S8/S9 全部常见记录类型.
* 数据记录 S1(16位地址) / S2(24位地址) / S3(32位地址) 均可正确解析.
* 所有block的数据均存储在全局变量中,
* 可通过 getBlockData() 获取任意block的数据,
* 通过 getBlockCount() 获取block总数.
*/
long parseS19File(char filePath[], byte data[], dword &startAddr, dword &dataLen)
{
long fileHandle;
char lineBuf[600];
dword lineNum;
dword fullAddr; // 当前数据记录的完整地址
byte byteCount; // S-record中的字节计数(地址+数据+校验和)
byte recType; // 记录类型数字 (0~9)
byte addrLen; // 地址字段的字节数: 2/3/4
byte dataByteCnt; // 当前行的纯数据字节数
int dataStartPos; // 数据字段在lineBuf中的起始下标
byte calcSum; // 校验和累加
dword i;
int blockStarted; // 标记是否已开始第一个block
dword currBlockNextAddr; // 当前block期望的下一个连续地址
// 初始化全局存储
gBlockCount = 0;
gTotalDataLen = 0;
blockStarted = 0;
startAddr = 0;
dataLen = 0;
fileHandle = openFileRead(filePath, 0);
if (fileHandle == 0)
{
write("打开文件失败: %s", filePath);
return -3;
}
lineNum = 0;
while (fileGetString(lineBuf, elcount(lineBuf), fileHandle) != 0)
{
lineNum++;
// 验证起始标记
if (lineBuf[0] != 'S')
{
write("第%d行格式错误: 缺少起始标记'S'", lineNum);
fileClose(fileHandle);
return -2;
}
// 解析记录类型
if (lineBuf[1] < '0' || lineBuf[1] > '9')
{
write("第%d行格式错误: 非法记录类型'%c'", lineNum, lineBuf[1]);
fileClose(fileHandle);
return -2;
}
recType = (byte)(lineBuf[1] - '0');
// 解析字节计数 (位于lineBuf[2..3])
byteCount = hexPairToByte(lineBuf, 2);
// 根据记录类型确定地址字段长度
if (recType == 0 || recType == 1 || recType == 5 || recType == 9)
{
addrLen = 2;
}
else if (recType == 2 || recType == 8)
{
addrLen = 3;
}
else if (recType == 3 || recType == 7)
{
addrLen = 4;
}
else
{
// 未知类型, 跳过
continue;
}
// 校验和验证
// S-record校验和 = byteCount字节 + 地址字节 + 数据字节 + 校验和字节, 低8位之和应等于0xFF
calcSum = byteCount;
for (i = 0; i < (dword)byteCount; i++)
{
calcSum += hexPairToByte(lineBuf, (int)(4 + i * 2));
}
if ((byte)(calcSum & 0xFF) != 0xFF)
{
write("第%d行校验和错误: 累加结果=0x%02X, 期望0xFF", lineNum, calcSum & 0xFF);
fileClose(fileHandle);
return -5;
}
// ---- 仅处理数据记录 S1/S2/S3 ----
if (recType >= 1 && recType <= 3)
{
// 解析完整地址 (大端序, 逐字节拼接)
fullAddr = 0;
for (i = 0; i < (dword)addrLen; i++)
{
fullAddr = (fullAddr << 8) | (dword)hexPairToByte(lineBuf, (int)(4 + i * 2));
}
// 纯数据字节数 = byteCount - 地址字节数 - 1(校验和)
dataByteCnt = byteCount - addrLen - 1;
// 数据在lineBuf中的起始位置
dataStartPos = 4 + (int)addrLen * 2;
if (!blockStarted)
{
// 第一条数据记录 → 开始第一个block
blockStarted = 1;
currBlockNextAddr = fullAddr;
gBlockOffset[0] = 0;
gBlockStartAddr[0] = fullAddr;
gBlockDataLen[0] = 0;
gBlockCount = 1;
}
else if (fullAddr != currBlockNextAddr)
{
// 地址不连续 → 新block
if (gBlockCount >= 20)
{
write("block数量超过最大限制(20), 停止解析");
fileClose(fileHandle);
return -4;
}
gBlockOffset[gBlockCount] = gTotalDataLen;
gBlockStartAddr[gBlockCount] = fullAddr;
gBlockDataLen[gBlockCount] = 0;
gBlockCount++;
}
// 将本行数据存入全局缓冲区
for (i = 0; i < (dword)dataByteCnt; i++)
{
gAllBlockData[gTotalDataLen] = hexPairToByte(lineBuf, (int)(dataStartPos + i * 2));
gTotalDataLen++;
gBlockDataLen[gBlockCount - 1]++;
}
currBlockNextAddr = fullAddr + (dword)dataByteCnt;
}
else if (recType == 7 || recType == 8 || recType == 9)
{
// 结束记录 S7/S8/S9, 停止解析
break;
}
// S0(头记录) 和 S5(记录计数) 不含有效数据, 直接跳过
}
fileClose(fileHandle);
// 将第一个block的数据拷贝给调用者
if (gBlockCount > 0)
{
startAddr = gBlockStartAddr[0];
dataLen = gBlockDataLen[0];
for (i = 0; i < gBlockDataLen[0]; i++)
{
data[i] = gAllBlockData[i]; // block 0 的 offset 始终为 0
}
}
write("S19文件解析完成: 共%d行, %d个block", lineNum, gBlockCount);
for (i = 0; i < gBlockCount; i++)
{
write(" Block %d: 起始地址=0x%08X, 长度=%d字节", i, gBlockStartAddr[i], gBlockDataLen[i]);
}
return (long)lineNum;
}
3.9.3 CAPL解析Bin文件
为了保持接口一致性,即便我们的Bin文件不含地址信息,我们也为它设计四个参数吧,认为把地址传入,脚本内可以不处理或者默认处理为0,使用者解析完毕后再自行填充地址到34服务中。
cpp
/*
* 解析纯二进制(BIN)文件
*
* 参数:
* filePath - bin文件路径
* data - 足够大的byte数组, 用于接收第一个block的数据
* startAddr - [输出] 第一个block的起始地址 (bin文件固定为0)
* dataLen - [输出] 第一个block的数据长度(字节)
*
* 返回值:
* 1 解析成功
* -1 文件为空或读取失败
* -3 打开文件失败
*
* 说明:
* bin文件为纯数据流, 不含地址信息, 因此:
* - 起始地址固定输出为 0
* - 始终只有 1 个block
* 解析后同样可通过 getBlockData() / getBlockCount() 访问.
*/
long parseBinFile(char filePath[], byte data[], dword &startAddr, dword &dataLen)
{
long fileHandle;
dword readSize;
dword i;
// 初始化全局存储
gBlockCount = 0;
gTotalDataLen = 0;
startAddr = 0;
dataLen = 0;
// 以二进制模式打开文件 (第2参数=1)
fileHandle = openFileRead(filePath, 1);
if (fileHandle == 0)
{
write("打开文件失败: %s", filePath);
return -3;
}
// 一次性读取整个文件到全局缓冲区
readSize = fileGetBinaryBlock(gAllBlockData, elcount(gAllBlockData), fileHandle);
fileClose(fileHandle);
if (readSize == 0)
{
write("文件为空或读取失败: %s", filePath);
return -1;
}
// bin文件始终为1个block, 起始地址为0
gBlockCount = 1;
gTotalDataLen = readSize;
gBlockOffset[0] = 0;
gBlockStartAddr[0] = 0;
gBlockDataLen[0] = readSize;
// 拷贝到调用者的数组
startAddr = 0;
dataLen = readSize;
for (i = 0; i < readSize; i++)
{
data[i] = gAllBlockData[i];
}
write("BIN文件解析完成: 数据长度=%d字节", readSize);
write(" Block 0: 起始地址=0x%08X, 长度=%d字节", gBlockStartAddr[0], gBlockDataLen[0]);
return 1;
}
3.9.4 实现通用的解析器
由于有三种文件格式,为了可以兼容用户任意传入类型文件,这里我再做一个自适应解析器;
cpp
/*
* 统一解析接口, 根据文件后缀自动选择解析器
*
* 参数:
* filePath - 文件路径 (支持 .hex / .s19 / .s28 / .s37 / .bin)
* data - 足够大的byte数组, 用于接收第一个block的数据
* startAddr - [输出] 第一个block的起始地址
* dataLen - [输出] 第一个block的数据长度(字节)
*
* 返回值:
* 同各子解析函数的返回值
* -10 无法识别的文件后缀
*/
long parseFile(char filePath[], byte data[], dword &startAddr, dword &dataLen)
{
dword len;
char c1, c2, c3, c4;
len = strlen(filePath);
if (len < 5)
{
write("文件路径过短, 无法识别后缀: %s", filePath);
return -10;
}
// 取最后4个字符用于后缀判断
c1 = filePath[len - 4];
c2 = filePath[len - 3];
c3 = filePath[len - 2];
c4 = filePath[len - 1];
// 判断 .hex (.HEX)
if (c1 == '.'
&& (c2 == 'h' || c2 == 'H')
&& (c3 == 'e' || c3 == 'E')
&& (c4 == 'x' || c4 == 'X'))
{
write("识别为Intel HEX文件, 开始解析...");
return parseHexFile(filePath, data, startAddr, dataLen);
}
// 判断 .s19 (.S19)
if (c1 == '.'
&& (c2 == 's' || c2 == 'S')
&& c3 == '1'
&& c4 == '9')
{
write("识别为Motorola S19文件, 开始解析...");
return parseS19File(filePath, data, startAddr, dataLen);
}
// 判断 .s28 (.S28)
if (c1 == '.'
&& (c2 == 's' || c2 == 'S')
&& c3 == '2'
&& c4 == '8')
{
write("识别为Motorola S28文件, 开始解析...");
return parseS19File(filePath, data, startAddr, dataLen);
}
// 判断 .s37 (.S37)
if (c1 == '.'
&& (c2 == 's' || c2 == 'S')
&& c3 == '3'
&& c4 == '7')
{
write("识别为Motorola S37文件, 开始解析...");
return parseS19File(filePath, data, startAddr, dataLen);
}
// 判断 .bin (.BIN)
if (c1 == '.'
&& (c2 == 'b' || c2 == 'B')
&& (c3 == 'i' || c3 == 'I')
&& (c4 == 'n' || c4 == 'N'))
{
write("识别为BIN文件, 开始解析...");
return parseBinFile(filePath, data, startAddr, dataLen);
}
write("无法识别的文件后缀: %s", filePath);
return -10;
}
3.9.5 CAPL实现34请求下载
基于上述三个步骤,我们已经完成了文件的解析,可以正常的提取出hex/s19/bin文件中的数据了,出于演示,我这边就以hex文件为例了(默认一个hex文件中只有一个block,如果有多个block,则可以使用我上面的getBlockData函数,获取每一个block的数据)。
首先,我们需要明白,34请求的常用格式;34 YY MN ....
这里34是服务ID,然后YY表示的是压缩算法,这个我只见过0x00(不压缩),没见过其他的值,我就认为是00吧,MN的话,这里的M表示,我要下载的这个地址,它用几个字节来表示,N的话,表示,我要下载的这个数据的长度,用几个字节来表示。
例如 34 00 44 则表示,我要下载的这个数据的起始地址,用了四个字节来表示(不论它的地址有多小,哪怕起始地址是0x01,它也要用4字节来表示这个地址 0x00 00 00 01);要下载的数据的长度,也用了四个字节来表示(不论它到底有多长,哪怕只有10个字节的长度,它也要用4字节来表示这个长度 0x00 00 00 0A);
并且,34请求时,ECU会回复 74 0M ...
这里的M,表示ECU一次允许下载多少个字节的数据这个数量所占的长度,这个可能比较绕啊,这里简单的解释一下,例如 回复 74 02 XX XX 表示后面有2个字节的数据,这2字节用来表示一次36 传输允许传输多长的数据给到ECU,如果时74 04 XX XX XX XX的话,就表示后面有4个字节的数据,这4字节表示的是可以一次传输多长的数据给ECU,哪怕这个数字很小,也是用4个字节来表示。
ok,理论简单的介绍了一下,接下来开始实现代码。
这里我们暂时不在这个步骤里面解析文件(为了后续封装更为通用的函数),假设文件已经解析完毕了,且driver数据在block0里面。我们直接使用。
先更新之前的全局定义,增加几个全局变量
cpp
variables
{
diagRequest * gDiagRequest;//定义全局诊断请求
diagResponse * gDiagResponse;//定义全局诊断响应
byte gDriverData[0x100000];//driver数据 给个1M的缓冲区,一般的文件都不可能有这么大,完全足够了
byte gAppData[0x100000];//App数据 给个1M的缓冲区,一般的文件都不可能有这么大,完全足够了
dword gDriverStartAddress;//驱动起始地址
dword gAppStartAddress;//App起始地址
dword gDriverLength;//驱动数据长度
dword gAppLength;//App数据长度
dword gOnceTransmitBlockSize;//36服务一次可以传输的字节数,由34服务回复时获得
}
cpp
/*请求下载Driver文件,成功返回1,失败返回0*/
int FlashStepH()
{
byte reqData[11];//定义诊断请求数据
const dword waitRespMaxTimeout = 2000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
int i;
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在请求下载Driver数据");
reqData[0] = 0x34;reqData[1] = 0x00;reqData[2] = 0x44;//设置地址和长度的表示格式
for(i = 0;i < 4;i++)
{
reqData[i + 3] = (gDriverStartAddress >> ((3 - i) * 8)) & 0xFF;//设置起始地址
reqData[i + 7] = (gDriverLength >> ((3 - i) * 8)) & 0xFF;//设置长度
}
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//设置诊断请求的数据为
gDiagRequest.SendRequest();//发送诊断请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
{
byte blockSizeByteLen;
byte len;
gOnceTransmitBlockSize = 0;
blockSizeByteLen = respByteNum - 2;//长度减去2个字节,即减去74 和 xx //假设为4
for(len = 0;len < blockSizeByteLen;len++)
{
gOnceTransmitBlockSize |= respData[2 + len] << (8 * (blockSizeByteLen - len - 1));//拼凑一包可以传输的长度
}
return 1;
}
else
return 0;//返回0,表示失败;
}
3.10 Step9:传输Driver数据
36传输时,我们一个36包能够传输多少个字节的数据,是由34请求时回复的长度决定的。
而回复的这个长度,代码中记为 gOnceTransmitBlockSize ,这个size,是包含了 36 Seq这两个字节的,Seq是什么,这个有的朋友可能不知道,36传输时,第一次被传输的36包,序号为36 01,
第二次被传输的36包,序号则为36 02...如果还有数据需要传输,那么这个数字就会一直增加,这个字节就被称为Seq(Sequence序列),当这个数字到达0xFF之后,就要反转到0x00重新开始。
那么假设34请求时回复的单词可以传输的长度为4002字节,那么我们一次36可以传输的真正的hex文件的数据是多少字节呢?是4000字节,因为36 Seq这两个字节要占用掉2个,所以实际能传输的数据只有4000字节。那么,怎么知道我们需要传输多少个36才能结束呢?我们先再定义一个变量,假设就叫 onceTxHexLen(一次传输的hex数据长度),根据上面的讲解,我们已经知道了,这个数字实际上就是gOnceTransmitBlockSize - 2; 这里我们可以使用整个Hex的block块的数据长度,使用这个长度来对 onceTxHexLen 这个数据来先取整,再取余。为什么要取余和取整呢,我们上学时应该学过,如果A除以B,得到的余数为0,说明A是B的整数倍,那么这个时候,就不会有剩下的数,也就是说,如果Hex的数据长度来除以onceTxHexLen的余数是0,那么我们只要使用Hex的数据长度来对onceTxHexLen取一次整(商),得到的商是多少,就说明我们需要传输多少次36了,如果余数不是0,那么只需要在这个商的基础上,再加上1,就是我们需要传输的36的次数了。
当然,如果最后的余数不是0,说明我们剩下的数据已经不足一整个36包了,这时候,我们要发送的最后这一包36的数据长度,就要随之减小,减小到多少?减小到剩余的Hex数据+2。
明白了以上的逻辑,我们就可以实现这个36传输的代码了;
每次36传输时,根据当前正准备传输的包的序号,
由于每次36传输的数据都不一样,这里我先增加一个辅助函数,用于清空一个byte数组,届时我们每次传输前先把数组清空,然后再去hex文件里面拷贝数据,确保数据不传错。
cpp
/*清空数组*/
void initArr(byte data[])
{
dword i;
for(i = 0;i < elcount(data);i++)data[i] = 0x00;
}
cpp
/*传输Driver文件,成功返回1,失败返回0*/
int FlashStepI()
{
dword hexDataTotalLen;//文件数据总长度
int transmitTotalPackageCnt;//要传输的包数
int remainder;//余数
int quotient;//商
byte packageIndex;//包的索引;
word index;//总索引
dword onceTxHexLen;
byte respData[4096];
const dword waitRespMaxTimeout = 10000;//定义最大的响应等待时间(ms)
long respRet;
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在传输Driver数据");
onceTxHexLen = gOnceTransmitBlockSize - 2;//一包能传输的hex数据长度
packageIndex = 1;//初始化索引为1
hexDataTotalLen = gDriverLength;
remainder = hexDataTotalLen % onceTxHexLen;//取余
quotient = hexDataTotalLen / onceTxHexLen;//取整
transmitTotalPackageCnt = remainder == 0?quotient:quotient+1;//如果整除,就取商,无法整除则取商+1
for(index = 0;index < transmitTotalPackageCnt;index++)
{
byte data[0xFFF];
dword len;
initArr(data);//清空数组
data[0] = 0x36;
data[1] = packageIndex;//
if(index < transmitTotalPackageCnt - 1)//不是最后一包
{
memcpy_off(data,2,gDriverData,index * onceTxHexLen,onceTxHexLen);//将hex文件的数据拷贝过去
gDiagRequest.Resize(gOnceTransmitBlockSize);//设置要传输的数据长度
gDiagRequest.SetPrimitiveData(data,gOnceTransmitBlockSize);//设置诊断数据
gDiagRequest.SendRequest();//传输1包数据
}
else if(index == transmitTotalPackageCnt - 1)//最后一包
{
word remainingHexDataLen;//剩余的hex长度
word remainingDataLen;//剩余所有数据长度,包含数据头36 xx
remainingHexDataLen = hexDataTotalLen - index * onceTxHexLen;
remainingDataLen = remainingHexDataLen + 2;
memcpy_off(data,2,gDriverData,index * onceTxHexLen,remainingHexDataLen);//将剩余的hex文件的数据拷贝过去
gDiagRequest.Resize(remainingDataLen);//设置要传输的数据长度
gDiagRequest.SetPrimitiveData(data,remainingDataLen);//设置诊断数据
gDiagRequest.SendRequest();//传输1包数据
}
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) != -1)//如果不是肯定响应 返回0,表示失败
return 0;
if(respData[1] != data[1]) return 0;//这里说明回复的Seq和我请求的Seq不是同一个 也是错误的
packageIndex < 0xFF?packageIndex++:packageIndex = 0;//翻转Seq
}
return 1;
}
3.11 Step10 退出Driver数据传输
大多数ECU退出传输这里都比较简单,只需要判断是否是肯定响应即可;
cpp
/*退出驱动数据传输,成功返回1,失败返回0*/
int FlashStepJ()
{
byte reqData[1] = {0x37};//定义诊断请求数据
const dword waitRespMaxTimeout = 2000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在退出Driver数据传输");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.12 Step11 Driver数据完整性检查(CRC32)
通过上面的步骤,我们已经成功的把Driver文件的数据传输给了ECU,那么我们在传输的过程中,总线上可能由于电气干扰,电平不稳等出现一些问题,例如我原本要传输的字节是00,经过干扰之后,变成了01,或者就是不小心传输了错误的数据,怎么保证我的ECU对错误的数据不会进行操作呢?这里就需要使用到我们的CRC(循环冗余校验算法),这里使用的CRC通常都是CRC32,将整个传输的Block的数据通过CRC32算法,得到一个32位的整数,再把整数按照高位在前,低位在后的顺序,拆分成四个字节发给ECU,ECU收到我们发送过去的CRC32之后,就把当前已经接收到了的Driver数据也做一遍相同的CRC32算法,然后拿我们发过去的CRC32值与ECU内部自行计算出来的值进行比较,如果值一致,说明我们传输给到ECU的数据没有出错,那么ECU将会回复肯定响应表示数据完整性检查成功;
既然要计算CRC32,那么我们就需要先实现一个计算CRC32的函数,这里可能需要根据各个ECU的具体要求算法进行实现,不过大多数的ECU使用的都是标准的CRC32算法,也就是生成多项式为 0xEDB88320,CRC初值为 0xFFFFFFFF的算法;计算CRC通常有查表法和直接计算法两种方法,在资源紧张,算力有限的嵌入式平台中通常使用查表法,查表法需要预先定义出CRC表,占用一部分全局变量空间,由于我们的上位机是在PC上运行,算力强大,这里就以直接计算法进行实现了;
cpp
/*计算CRC32*/
dword Calculate_CRC32(byte buf[], dword len)
{
dword i, j;
dword crc;
const dword POLY = 0xEDB88320; // 标准 CRC32 多项式(反转形式)
crc = 0xFFFFFFFF; //初始值
for (i = 0; i < len; i++)
{
// 将当前字节与 CRC 的低8位进行异或
crc ^= buf[i];
// 逐位处理:循环8次,处理当前字节的8个位
for (j = 0; j < 8; j++)
{
if (crc & 1)
{
// 如果最低位是1,则右移并异或多项式
crc = (crc >> 1) ^ POLY;
}
else
{
// 如果最低位是0,则直接右移
crc = (crc >> 1);
}
}
}
return crc ^ 0xFFFFFFFF; // 结果取反
}
有了计算CRC32的函数之后,我们就可以实现完整性检查这个步骤了;
cpp
/*驱动完整性检查,成功返回1,失败返回0*/
int FlashStepK()
{
byte reqData[8];//定义诊断请求数据
const dword waitRespMaxTimeout = 2000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
dword crc32;
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在进行Driver数据完整性检查");
reqData[0] = 0x31;reqData[1] = 0x01;reqData[2] = 0xF0;reqData[3] = 0x01;//完整性检查的RID
//计算crc32
crc32 = Calculate_CRC32(gDriverData,gDriverLength);//使用driver数据求crc32
reqData[4] = crc32 >> 24;
reqData[5] = crc32 >> 16;
reqData[6] = crc32 >> 8;
reqData[7] = crc32 & 0xFF;//依次取出crc32的每一字节
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();//发送完整性检查请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1 && respData[4] == 0x00)//如果是肯定响应并且例程结果为00 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.13 Step12 擦除APP块数据
Driver数据传输完毕之后,通常就要开始向ECU传输APP数据了,对于ECU来说,在传输之前,APP的内存块是有数据的,有数据时我们没法直接往APP内存区下载数据,所以需要先通过诊断命令来擦除APP内存区的数据。在擦除时,需要指定擦除的地址和要擦除的数据长度。而我们的地址和长度,又可以在解析hex/s19/bin文件这些文件时获得(还是和传输Driver时一样,我们先假定APP数据已经解析完毕了,因为我准备在最后面搭建整体的流程框架的时候,在刷写最开始之前解析文件,虽然现在还没写,但我们先按照已经解析完毕了开始实现),也就是说,我们直接发送31服务擦除对应的APP地址和长度就可以了。
cpp
/*擦除APP内存,成功返回1,失败返回0*/
int FlashStepL()
{
byte reqData[12];//定义诊断请求数据
const dword waitRespMaxTimeout = 2000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
int i;
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在擦除APP内存");
reqData[0] = 0x31;reqData[1] = 0x01;reqData[2] = 0xFF;reqData[3] = 0x00;//擦内存的RID
for(i = 0;i < 4;i++)
{
reqData[4 + i] = (gAppStartAddress >> ((3 - i) * 8)) & 0xFF;//把app地址填充到请求数据
reqData[8 + i] = (gAppLength >> ((3 - i) * 8)) & 0xFF;//把app长度填充
}
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();//发送完整性检查请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1 && respData[4] == 0x00)//如果是肯定响应并且例程结果为00 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.14 Step13 请求下载数据(APP)
由于我们上面已经实现了Driver文件的下载,APP文件的下载方法和Driver就完全一致了,区别仅在于这里我们把传输的数据,数据地址,数据长度替换成APP的就可以了。
cpp
/*请求下载App文件,成功返回1,失败返回0*/
int FlashStepM()
{
byte reqData[11];//定义诊断请求数据
const dword waitRespMaxTimeout = 2000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
int i;
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在请求下载App数据");
reqData[0] = 0x34;reqData[1] = 0x00;reqData[2] = 0x44;//设置地址和长度的表示格式
for(i = 0;i < 4;i++)
{
reqData[i + 3] = (gAppStartAddress >> ((3 - i) * 8)) & 0xFF;//设置起始地址
reqData[i + 7] = (gAppLength >> ((3 - i) * 8)) & 0xFF;//设置长度
}
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//设置诊断请求的数据为
gDiagRequest.SendRequest();//发送诊断请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
{
byte blockSizeByteLen;
byte len;
gOnceTransmitBlockSize = 0;
blockSizeByteLen = respByteNum - 2;//长度减去2个字节,即减去74 和 xx //假设为4
for(len = 0;len < blockSizeByteLen;len++)
{
gOnceTransmitBlockSize |= respData[2 + len] << (8 * (blockSizeByteLen - len - 1));//拼凑一包可以传输的长度
}
return 1;
}
else
return 0;//返回0,表示失败;
}
3.15 Step14 传输APP数据
这里也是一样的,由于在传输driver数据时,我们已经讲解并且完成了数据传输的逻辑,那么这里就也一样,只需要把传输的数据替换成APP数据就可以了。
cpp
/*传输App文件,成功返回1,失败返回0*/
int FlashStepN()
{
dword hexDataTotalLen;//文件数据总长度
int transmitTotalPackageCnt;//要传输的包数
int remainder;//余数
int quotient;//商
byte packageIndex;//包的索引;
word index;//总索引
dword onceTxHexLen;
byte respData[4096];
const dword waitRespMaxTimeout = 10000;//定义最大的响应等待时间(ms)
long respRet;
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在传输App数据");
onceTxHexLen = gOnceTransmitBlockSize - 2;//一包能传输的hex数据长度
packageIndex = 1;//初始化索引为1
hexDataTotalLen = gAppLength;
remainder = hexDataTotalLen % onceTxHexLen;//取余
quotient = hexDataTotalLen / onceTxHexLen;//取整
transmitTotalPackageCnt = remainder == 0?quotient:quotient+1;//如果整除,就取商,无法整除则取商+1
for(index = 0;index < transmitTotalPackageCnt;index++)
{
byte data[0xFFF];
dword len;
initArr(data);//清空数组
data[0] = 0x36;
data[1] = packageIndex;//
if(index < transmitTotalPackageCnt - 1)//不是最后一包
{
memcpy_off(data,2,gAppData,index * onceTxHexLen,onceTxHexLen);//将hex文件的数据拷贝过去
gDiagRequest.Resize(gOnceTransmitBlockSize);//设置要传输的数据长度
gDiagRequest.SetPrimitiveData(data,gOnceTransmitBlockSize);//设置诊断数据
gDiagRequest.SendRequest();//传输1包数据
}
else if(index == transmitTotalPackageCnt - 1)//最后一包
{
word remainingHexDataLen;//剩余的hex长度
word remainingDataLen;//剩余所有数据长度,包含数据头36 xx
remainingHexDataLen = hexDataTotalLen - index * onceTxHexLen;
remainingDataLen = remainingHexDataLen + 2;
memcpy_off(data,2,gAppData,index * onceTxHexLen,remainingHexDataLen);//将剩余的hex文件的数据拷贝过去
gDiagRequest.Resize(remainingDataLen);//设置要传输的数据长度
gDiagRequest.SetPrimitiveData(data,remainingDataLen);//设置诊断数据
gDiagRequest.SendRequest();//传输1包数据
}
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) != -1)//如果不是肯定响应 返回0,表示失败
return 0;
if(respData[1] != data[1]) return 0;//这里说明回复的Seq和我请求的Seq不是同一个 也是错误的
packageIndex < 0xFF?packageIndex++:packageIndex = 0;//翻转Seq
}
return 1;
}
3.16 Step15 退出App数据传输
一样的,这里的逻辑和Driver退出时就完全一致了;
cpp
/*退出App数据传输,成功返回1,失败返回0*/
int FlashStepO()
{
byte reqData[1] = {0x37};//定义诊断请求数据
const dword waitRespMaxTimeout = 2000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在退出App数据传输");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.17 Step16 App数据完整性检查(CRC32)
这里的逻辑也和Driver时的完整性检查完全一致,区别仅在于这里发送的CRC32是通过APP文件中的数据计算出来的。
cpp
/*APP完整性检查,成功返回1,失败返回0*/
int FlashStepP()
{
byte reqData[8];//定义诊断请求数据
const dword waitRespMaxTimeout = 2000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
dword crc32;
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在进行App数据完整性检查");
reqData[0] = 0x31;reqData[1] = 0x01;reqData[2] = 0xF0;reqData[3] = 0x01;//完整性检查的RID
//计算crc32
crc32 = Calculate_CRC32(gAppData,gAppLength);//使用App数据求crc32
reqData[4] = crc32 >> 24;
reqData[5] = crc32 >> 16;
reqData[6] = crc32 >> 8;
reqData[7] = crc32 & 0xFF;//依次取出crc32的每一字节
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();//发送完整性检查请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1 && respData[4] == 0x00)//如果是肯定响应并且例程结果为00 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.18 Step17 编程依赖性检查
依赖性检查是干什么的呢?我们上面进行的完整性检查,只能证明我们传给ECU的数据,和ECU实际接收到的数据是一致的,但是并无法保证我们传输的程序的正确性,比如我们的ECU是A ECU,这时候我们把B ECU的Hex文件传输给A(把起始地址改成一样的,只是数据用B的数据),这时候如果我们的A ECU加载了B的软件,那么后果是很严重的,A可能彻底失灵,没有任何功能。依赖性检查的目的和作用就是在这里,用于检查我们发给ECU的数据是否合理。通常开发者会在Hex文件中设计几个字节的特殊编码和校验码,当我们传输给ECU的数据这些特殊编码和校验码错误时,ECU就可以知道,我们给它发送了无效的数据,它就会回复NRC,拒绝编程,防止误刷软件变砖;
cpp
/*编程依赖性检查,成功返回1,失败返回0*/
int FlashStepQ()
{
byte reqData[4] = {0x31,0x01,0xFF,0x01};//定义诊断请求数据
const dword waitRespMaxTimeout = 2000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在进行依赖性检查");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1 && respData[4] == 0x00)//如果是肯定响应并且例程结果为00 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.19 Step18 ECU复位
只要上一步的依赖性检查通过了,其实我们的ECU升级就已经完成了,后面这里只不过是完成一些收尾性的工作,复位ECU是为了使得ECU能够启动我们刚刚新刷写进去的软件。
这里和最开始一样,只需要判断是否回复肯定响应就可以了,唯一需要额外关注的就是,ECU重启的时间可能会略长,这个等待响应的时间可以稍微给久一些,我这边就给2S了;
cpp
/*ECU复位,成功返回1,失败返回0*/
int FlashStepR()
{
byte reqData[2] = {0x11,0x01};//定义诊断请求数据
const dword waitRespMaxTimeout = 2000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在进行ECU复位重启");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.20 Step19 进入扩展会话
这里继续进入扩展会话的目的,是因为我们在最开始的时候,禁止了ECU的DTC检测和非诊断通信功能,重新进入扩展会话就是为了能够发送恢复这些功能的诊断请求;
需要注意的是,这里我们最好是再增加一个函数,而不是直接使用之前最开始的StepA,因为它毕竟是两个步骤,使用两个函数更方便我们后续统计到底执行了多少步骤,计算进度等;
cpp
/*进入扩展会话,成功返回1,失败返回0*/
int FlashStepS()
{
byte reqData[2] = {0x10,0x03};//定义诊断请求数据
const dword waitRespMaxTimeout = 1000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在进入扩展会话");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//设置诊断请求的数据为10 03
gDiagRequest.SendRequest();//发送10 03诊断请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将10 03的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.21 Step20 开启DTC检测
cpp
/*开启DTC检测,成功返回1,失败返回0*/
int FlashStepT()
{
byte reqData[2] = {0x85,0x01};//定义诊断请求数据
const dword waitRespMaxTimeout = 1000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在开启DTC检测");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.22 Step21 开启非诊断通信
cpp
/*开启非诊断通信,成功返回1,失败返回0*/
int FlashStepU()
{
byte reqData[3] = {0x28,0x00,0x03};//定义诊断请求数据
const dword waitRespMaxTimeout = 1000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在开启非诊断通信");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//
gDiagRequest.SendRequest();
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.23 Step22 进入默认会话
到这里就已经结束本次的刷写了,这里的目的是为了回到默认会话,使ECU恢复默认诊断状态;
cpp
/*进入默认会话,成功返回1,失败返回0*/
int FlashStepV()
{
byte reqData[2] = {0x10,0x01};//定义诊断请求数据
const dword waitRespMaxTimeout = 1000;//定义最大的响应等待时间(ms)
long respRet;
byte respData[256];//定义存储响应数据的缓冲区(数组)
long respByteNum;//定义存储响应数据总字节长度的变量
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在进入默认会话");
gDiagRequest.Resize(elcount(reqData));//此函数用于重置(设置)指定的诊断对象的字节数
gDiagRequest.SetPrimitiveData(reqData,elcount(reqData));//设置诊断请求的数据为10 01
gDiagRequest.SendRequest();//发送10 01诊断请求
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将10 01的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) == -1)//如果是肯定响应 返回1,表示成功
return 1;
else
return 0;//返回0,表示失败;
}
3.24 整合执行所有刷写步骤
在上面的篇幅中,我们已经完成了一般刷写的22个步骤的分步case,但是,一个完整的刷写流程应该是要执行这些所有的步骤,所以,接下来,我们再实现一下完整的刷写步骤。
首先,如果让大家来完成这个综合刷写步骤,大家会怎么进行编码呢?我看到过一些网友/同事的代码,会写一个函数,在函数内部来依次执行各个分支函数,如果上一步通过了,就走下一步,否则就不执行后面的流程。这样子写出来的代码,会使得这个综合函数里面if else漫天飞,且当需要增加一个步骤/插入一个步骤、删除一个步骤时,面对漫天的连贯的if else,改动起来会非常的痛苦。虽然我这里是使用的最简单的方法来实现刷写代码,但我依旧不想使用这种综合写的方法,在日常的编码中,我们最重要的是程序架构要合理,方便修改,简单易懂等。所以,在这里,我将使用一种非常优雅的方法来实现这个功能(表驱动法),如果有做嵌入式开发的朋友,应该对这种方法非常的熟悉了,由于CAPL中无法使用指针,所以这里没法使用函数指针来实现了。我准备把当前的所有流程步骤,写一个枚举。然后再创建一个函数,在这个函数里面,对所有的枚举值和我们的具体的步骤函数增加一个一一对应关系,模拟函数指针的操作。随后再写一个函数,用来执行我们的步骤数组里的枚举值,完成我们的表驱动方法。
首先,定义一个全局的枚举,用来和每一步进行一一对应;
所以这里会对我们之前的全局变量进行一些修改;
cpp
variables
{
diagRequest * gDiagRequest;//定义全局诊断请求
diagResponse * gDiagResponse;//定义全局诊断响应
byte gDriverData[0x100000];//driver数据 给个1M的缓冲区,一般的文件都不可能有这么大,完全足够了
byte gAppData[0x100000];//App数据 给个1M的缓冲区,一般的文件都不可能有这么大,完全足够了
dword gDriverStartAddress;//驱动起始地址
dword gAppStartAddress;//App起始地址
dword gDriverLength;//驱动数据长度
dword gAppLength;//App数据长度
dword gOnceTransmitBlockSize;//36服务一次可以传输的字节数,由34服务回复时获得
enum FlashSteps{
STEP_A = 0,//进入扩展会话
STEP_B = 1,//关闭DTC检测
STEP_C = 2,//关闭非诊断通信
STEP_D = 3,//预编程检查
STEP_E = 4,//进入编程会话
STEP_F = 5,//安全等级解锁
STEP_G = 6,//写入刷写指纹
STEP_H = 7,//请求下载数据(Driver)
STEP_I = 8,//传输Driver数据
STEP_J = 9,//退出Driver传输
STEP_K = 10,//Driver完整性校验
STEP_L = 11,//擦除APP内存
STEP_M = 12,//请求下载数据(APP)
STEP_N = 13,//传输APP数据
STEP_O = 14,//退出APP数据传输
STEP_P = 15,//APP完整性校验
STEP_Q = 16,//检查编程依赖性
STEP_R = 17,//ECU复位
STEP_S = 18,//复位后重新进入扩展会话
STEP_T = 19,//开启DTC检测
STEP_U = 20,//开启非诊断通信
STEP_V = 21//进入默认会话
};//刷写步骤枚举值定义
}
然后,写一个函数来使得个个枚举值与具体的步骤函数实现一一绑定;
cpp
/* 统一的步骤处理器 */
int HandleStep(int stepEnum)
{
switch(stepEnum)
{
//将每一个枚举值绑定到对应的step函数上
case STEP_A: return FlashStepA();
case STEP_B: return FlashStepB();
case STEP_C: return FlashStepC();
case STEP_D: return FlashStepD();
case STEP_E: return FlashStepE();
case STEP_F: return FlashStepF();
case STEP_G: return FlashStepG();
case STEP_H: return FlashStepH();
case STEP_I: return FlashStepI();
case STEP_J: return FlashStepJ();
case STEP_K: return FlashStepK();
case STEP_L: return FlashStepL();
case STEP_M: return FlashStepM();
case STEP_N: return FlashStepN();
case STEP_O: return FlashStepO();
case STEP_P: return FlashStepP();
case STEP_Q: return FlashStepQ();
case STEP_R: return FlashStepR();
case STEP_S: return FlashStepS();
case STEP_T: return FlashStepT();
case STEP_U: return FlashStepU();
case STEP_V: return FlashStepV();
default:
// 假设 write 是你项目中用于日志输出的函数
write("Error: Unknown step %d", stepEnum);
return -1;
}
}
现在我们的步骤和上面的枚举值就有了一一对应关系了。
现在假设我们想执行全部的刷写步骤,那么我们可以定义一个int数组,然后把里面的元素设置为所有的Step枚举值,这时候,再写一个遍历数组的函数,遍历数组里面的所有元素,并且调用对应的Step函数即可。
所以,现在我们又要增加一下全局变量了;
cpp
const FlashStepTotalCnt = STEP_V + 1;//总步骤就是最后一个枚举值+1
int FlashStep_NomalStep[FlashStepTotalCnt] = {
STEP_A,STEP_B,STEP_C,STEP_D,STEP_E,
STEP_F,STEP_G,STEP_H,STEP_I,STEP_J,
STEP_K,STEP_L,STEP_M,STEP_N,STEP_O,
STEP_P,STEP_Q,STEP_R,STEP_S,STEP_T,
STEP_U,STEP_V
};//定义默认情况下的刷写步骤表
int gCurrFlashStep = 0;//记录当前的刷写步骤
同时,我们再定义一个变量来记录当前执行到了哪一个步骤,那么当每一个步骤执行结束完后,都可以根据这个变量来对总共的步骤进行除法运算,从而获得当前的刷写进度。
同时,如果刷写到任意一个步骤失败了,我们最好是在panle上或者什么地方再提示一下。所以,这里我再增加一个失败时可以打印失败步骤到panel上的函数,如果失败了的了话,我们的gCurrFlashStep 这个变量最后的值是几,就代表是在哪一步失败了。
最后,再增加一个函数,用来在每次开始刷写之前,先把当前panel面板上显示的一些进度啊,刷写结果啊之类的,给它清空到默认值;免得连续刷写时,,上一次的结果对当前的刷写结果产生视觉干扰;
cpp
/*打印刷写失败的步骤到panel面板*/
void PrintfFlashFailStepToPanel()
{
switch(gCurrFlashStep)
{
case STEP_A:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"进入扩展会话失败");
break;
case STEP_B:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"关闭DTC检测失败");
break;
case STEP_C:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"关闭非诊断通信失败");
break;
case STEP_D:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"预编程检查失败");
break;
case STEP_E:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"进入编程会话失败");
break;
case STEP_F:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"安全等级解锁失败");
break;
case STEP_G:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"写入刷写指纹失败");
break;
case STEP_H:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"请求下载Driver数据失败");
break;
case STEP_I:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"传输Driver数据失败");
break;
case STEP_J:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"退出Driver传输失败");
break;
case STEP_K:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"Driver完整性校验失败");
break;
case STEP_L:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"擦除APP内存失败");
break;
case STEP_M:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"请求下载APP数据失败");
break;
case STEP_N:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"传输APP数据失败");
break;
case STEP_O:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"退出APP数据传输失败");
break;
case STEP_P:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"APP完整性校验失败");
break;
case STEP_Q:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"检查编程依赖性失败");
break;
case STEP_R:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"ECU复位失败");
break;
case STEP_S:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"复位后重新进入扩展会话失败");
break;
case STEP_T:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"开启DTC检测失败");
break;
case STEP_U:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"开启非诊断通信失败");
break;
case STEP_V:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"进入默认会话失败");
break;
default:
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"未知刷写步骤失败");
break;
}
}
/* 通用流程执行引擎 */
long ExecuteFlashProcess(int steps[], int stepCount)
{
int result;
int i;
double flashProgress;
char currentStepDesc[32];
flashProgress = 0;
gCurrFlashStep = 0; // 重置步骤计数器
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在刷写");//更新panel步骤信息为正在刷写
for(i = 0; i < stepCount; i++)
{
result = HandleStep(steps[i]);//0 1 -1三种结果
flashProgress = ((double)gCurrFlashStep / (double)stepCount) * 100;//计算当前进度
sysSetVariableInt(sysvar::EcuBootload::EcuFlashProgress,flashProgress);//更新panel上显示的当前进度
if(result != 1)
{
PrintfFlashFailStepToPanel();//打印失败的步骤到panel
return result;
}
gCurrFlashStep ++; // 更新全局进度
}
flashProgress = 100;//能走到这里来,说明需要执行的所有步骤都成功了,更新进度到100%
sysSetVariableInt(sysvar::EcuBootload::EcuFlashProgress,flashProgress);//更新panel上显示的当前进度
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"刷写成功");
return 1;
}
/*清空panle上关于刷写结果的变量*/
void InitPanelFlashResult()
{
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"");
sysSetVariableInt(sysvar::EcuBootload::ApptransmitProgress,0);
sysSetVariableInt(sysvar::EcuBootload::EcuFlashProgress,0);
}
ok呀,到此,所有的准备工作都就绪了;
现在我们可以编写一个默认执行所有刷写步骤的函数了。
这个函数体将会非常的简单,只需要先清空panel上的结果,然后再获取加载的文件,解析文件,并执行所有的步骤就可以了;
这里我选择把获取panel上加载的刷写文件,并且解析文件的功能也做成一个函数,这样更加的简洁美观;
为了防止之前的路径有残留,最好是在每次获取文件前,先清空一下之前的路径。
同时加载文件时,最好判断一下文件路径是不是空的,如果是空的,就不让刷写了,返回错误代码。
cpp
/*
获取panel加载的文件路径,并且负责解析文件
返回值:
1 解析成功
-1 文件未加载
-2 解析Driver失败
-3 解析App失败
*/
int GetPanelLoadFileAndParse()
{
char driverPath[1024];//如果大家的文件路径套的非常深,数组可以再给大一点。
char appPath[1024];
long ret;
initString(driverPath);
initString(appPath);
sysGetVariableString(sysvar::EcuBootload::DriverFilePath,driverPath,elcount(driverPath));
sysGetVariableString(sysvar::EcuBootload::AppFilePath,appPath,elcount(appPath));
if(strlen(driverPath) == 0 || strlen(appPath) == 0)
{
write("刷写文件未加载完毕,请加载完毕后再开始刷写");
return -1;//文件未加载完毕;
}
ret = parseFile(driverPath,gDriverData,gDriverStartAddress,gDriverLength);
if(ret < 0)
{
write("解析Driver文件失败,无法刷写,请检查文件是否合法");
return -2;//解析driver失败
}
ret = parseFile(appPath,gAppData,gAppStartAddress,gAppLength);
if(ret < 0)
{
write("解析App文件失败,无法刷写,请检查文件是否合法");
return -3;//解析App失败
}
return 1;
}
cpp
/*执行默认的所有刷写步骤*/
int ExecuteFlashNomal()
{
InitPanelFlashResult();
if(GetPanelLoadFileAndParse() != 1)
return 0;
return ExecuteFlashProcess(FlashStep_NomalStep,elcount(FlashStep_NomalStep));
}
这样子的写法好处是什么呢?相信细心的朋友或者爱思考的朋友很快就能够发现了;
这样子的写法,要执行的步骤全部写在一个数组里。如果哪天,需要执行故障性测试,故意减少某个步骤或者跳过某个步骤,只需要修改或者新建一个新的数组,并在数组中修改里面的元素个数和对应的元素值就可以了,然后再写一个对应的故障性刷写函数,里面只需要修改 return ExecuteFlashProcess(FlashStep_NomalStep,elcount(FlashStep_NomalStep)); 这一行代码,几乎不需要改代码。大家发现了吗?
至此,本次所有的刷写步骤编程结束了;
剩下的,就是调用这个执行所有步骤的函数进行刷写了。
哦对了,我们最好是在之前的36传输APP数据时,增加一下传输数据的进度计算逻辑;这个很简单,只要拿已经传了36块数,来除以总的需要传输的36块数,再乘百分百就可以了,非常的简单。这里我已经增加完毕了。为了防止有的朋友不会增加,我直接把代码给出。大家可以替换掉地Step14那个步骤的代码;
cpp
/*传输App文件,成功返回1,失败返回0*/
int FlashStepN()
{
dword hexDataTotalLen;//文件数据总长度
int transmitTotalPackageCnt;//要传输的包数
int remainder;//余数
int quotient;//商
byte packageIndex;//包的索引;
word index;//总索引
dword onceTxHexLen;
byte respData[4096];
const dword waitRespMaxTimeout = 10000;//定义最大的响应等待时间(ms)
long respRet;
long respByteNum;//定义存储响应数据总字节长度的变量
int appTransmitProgress;//数据传输进度
sysSetVariableString(sysvar::EcuBootload::EcuFlashResult,"正在传输App数据");
onceTxHexLen = gOnceTransmitBlockSize - 2;//一包能传输的hex数据长度
packageIndex = 1;//初始化索引为1
hexDataTotalLen = gAppLength;
remainder = hexDataTotalLen % onceTxHexLen;//取余
quotient = hexDataTotalLen / onceTxHexLen;//取整
transmitTotalPackageCnt = remainder == 0?quotient:quotient+1;//如果整除,就取商,无法整除则取商+1
for(index = 0;index < transmitTotalPackageCnt;index++)
{
byte data[0xFFF];
dword len;
initArr(data);//清空数组
data[0] = 0x36;
data[1] = packageIndex;//
if(index < transmitTotalPackageCnt - 1)//不是最后一包
{
memcpy_off(data,2,gAppData,index * onceTxHexLen,onceTxHexLen);//将hex文件的数据拷贝过去
gDiagRequest.Resize(gOnceTransmitBlockSize);//设置要传输的数据长度
gDiagRequest.SetPrimitiveData(data,gOnceTransmitBlockSize);//设置诊断数据
gDiagRequest.SendRequest();//传输1包数据
}
else if(index == transmitTotalPackageCnt - 1)//最后一包
{
word remainingHexDataLen;//剩余的hex长度
word remainingDataLen;//剩余所有数据长度,包含数据头36 xx
remainingHexDataLen = hexDataTotalLen - index * onceTxHexLen;
remainingDataLen = remainingHexDataLen + 2;
memcpy_off(data,2,gAppData,index * onceTxHexLen,remainingHexDataLen);//将剩余的hex文件的数据拷贝过去
gDiagRequest.Resize(remainingDataLen);//设置要传输的数据长度
gDiagRequest.SetPrimitiveData(data,remainingDataLen);//设置诊断数据
gDiagRequest.SendRequest();//传输1包数据
}
respRet = testWaitForDiagResponse(gDiagRequest,waitRespMaxTimeout);//
if(respRet != 1)//未响应
{
return 0;
}
diagGetLastResponse(gDiagRequest,gDiagResponse);//将的响应数据获取了存到gDiagResponse里面。
respByteNum = gDiagResponse.GetPrimitiveSize();//获取响应的数据长度
gDiagResponse.GetPrimitiveData(respData,elcount(respData));//获取响应的数据
if(diagGetLastResponseCode(gDiagRequest) != -1)//如果不是肯定响应 返回0,表示失败
return 0;
if(respData[1] != data[1]) return 0;//这里说明回复的Seq和我请求的Seq不是同一个 也是错误的
appTransmitProgress = ((double)index / (double)transmitTotalPackageCnt) * 100;
sysSetVariableInt(sysvar::EcuBootload::ApptransmitProgress,appTransmitProgress);
packageIndex < 0xFF?packageIndex++:packageIndex = 0;//翻转Seq
}
appTransmitProgress = 100;
sysSetVariableInt(sysvar::EcuBootload::ApptransmitProgress,appTransmitProgress);
return 1;
}
3.25 执行刷写,进行测试;
一步一步看到这里的同学,那么恭喜你,同时也佩服你,竟然有毅力看完我写的这个文章,我自己都写的又痛苦又累了(QAQ),恭喜你,到这里,相信你已经能够基本掌握怎么使用CAPL对CAN总线的ECU进行刷写了(LIN其实也一样的流程,相似度99%,唯一的区别就是需要配置一下NAD,感兴趣的朋友可以找我交流哦,至于以太网和FlexRay总线,我目前还不太熟悉)
现在我们正式开始测试吧!
首先,我们使用的节点是CAPL Test节点,这里有个Main函数,大家还记得吗?在我们写完第一个步骤(进入编程会话)时,曾经简单的使用过它来进行测试我们的函数功能是否ok。现在,我们又要使用到他了。
cpp
/*
CAPL测试模块的主函数,所有的函数入口,任意一个函数(事件)要执行,都要从这里开始
同时,这个只有CAPL Test Module类型的测试节点能使用哦。
*/
void MainTest()
{
diagSetTarget("BasicDiagnosticsEcu");//设置目标诊断ECU
ExecuteFlashNomal();//执行默认的全部刷写步骤
}
好了,现在我们把工程运行起来,看看效果吧。
ps:由于使用的诊断控制台实现,多帧传输中连续帧的传输间隔(STmin)默认使用的是诊断数据库/基础诊断文件中的配置值,如果这个值不改,多帧会传输的很慢,可能没法在我们代码设置的10S传输超时内传输完毕1包36数据哦。
这边测试的话,建议大家可以重新打开诊断配置界面,手动把STmin改小一些(主要是博主还没研究明白怎么用代码改小这个值,哈哈,如果有知道的,麻烦告诉我一下呢);

我这里把这个值改到2ms了,可以稍微发的快一点,方便我测试。
刷写运行效果

运行效果如图和视频所示呀;
正常运行时,所有的进度会随着刷写进程的进度而增加,最终显示成功;

当出现异常时,会显示失败的步骤和信息;
以上就是全部的代码和运行效果了。(注意,我是使用仿真ECU的方式运行的,如果大家使用自己的ECU,需要在某些细节指令的地方,对指令稍作修改。)
关于我的仿真ECU,我也会一同配置在这个工程里面,上传到评论区的链接处;(时间有限,仿真ECU仅会在27服务安全访问秘钥错误、31服务完整性检查时CRC错误时抛出NRC负响应),其余时候都是默认回复正响应的,如果大家想要在什么步骤回复负响应进行代码的测试,可以自行修改仿真ECU的代码逻辑。

工程默认配置为仿真模式和虚拟ECU使能模式;
如需使用真实节点,请把总线模式改为 RealBus,并选中我的虚拟节点,随后按下空格按键,将节点变为灰色的(屏蔽仿真节点)

我在这个文件夹里面放了我的demo 驱动文件和app文件,hex/s19/bin格式各一份,供大家自行研究解析代码,也供大家仿真运行我的工程时使用;
然后 KeyGenDll_GenerateKeyEx 里面放的是VS生成SeedKey.dll的工程源文件;
四、总结与展望
本篇文章,洋洋洒洒,文字讲解和代码编写,累计写了约6万4千字,十分的庞大。本人也是耗时三四天才编写完毕,说实话有点累,由于太辛苦了,所以很多地方没有优化到最好(例如所有的步骤里面诊断的逻辑其实都基本相同,完全可以封装成函数减少代码量,例如刷写的UI界面全部是黑色的,不够美观,例如代码的讲解不足等)。
另外,由于本文章和代码,均由本人独自完成,由于作者水平有限,可能在某些地方存在描述不符,讲解出错,代码逻辑错误的地方,希望大家发现后能够即时指出,谢谢!
最后,希望大家看完我的这篇文章,能够对你们有所帮助,希望以此,让我们的汽车电子行业的爱好者,学习者,测试工程师们,软件开发工程师们都能有所收获,愿我国的汽车工业日益进步。如有想法,欢迎评论区或者私信交流,但我可能无法即时回复或者查看哦。