还记得第一次做带Checksum和Counter的自定义CAN报文时,我盯着示波器看了整整一个下午——波形没有问题,ID没有冲突,波特率也对得上,可被测的ECU就是死活不给响应。后来一台CANoe挂在总线上抓包,才看到真相:每一帧的Counter都在乱跳,Checksum算出来跟规范差了十万八千里,对方早就把这些报文当成攻击流量悄悄丢掉了。
这个场景在自动驾驶仿真测试里太常见了。摄像头、毫米波雷达、域控制器之间大量消息都走CAN总线,而这些消息里几乎都带了端到端保护(E2E)机制。所谓端到端保护,拆开来看常常就两个最基础的元素:用于检查数据有没有被篡改的Checksum,和用于检测报文有没有丢失、被重复或被重放的Counter。如果你的仿真平台——不管是用NI Veristand搭的HIL,还是纯软件环境——不能正确生成这两个字段,被测的ECU就会把仿真数据当作非法报文忽略掉,整个测试根本白做。
这篇文章我就以NI Veristand为例,把自定义CAN报文Checksum和Counter这条路的完整思路、具体实现和那些坑,一次聊透。内容会比较偏实操,但背后的“为什么”我也会讲清楚,因为做仿真最怕的不是不会操作,而是不知道为什么这么操作。
1. 仿真环境里的安全校验:为什么ECU会“扔掉”你的报文
1.1 Checksum和Counter不是“保险丝”,而是整车的信任基础
传统CAN协议在物理层和数据链路层上是没有加密和认证概念的。总线上任何一个节点,只要把CAN收发器挂上去,理论上就能发出仲裁ID合法的报文。这对实车来说是个巨大的安全隐患——假如某个节点因为干扰或者故障发出错误数据,接收方很难在第一时间判断“这个数据到底可不可信”。
于是OEM们在应用层设计了两道最基础的防线:
- Checksum(校验和):对报文中若干个字节做一道数学运算,生成一个校验值放在报文的特定字节位。接收方按同样算法重新算一遍,如果对不上,就说明发送过程中数据被干扰或者被篡改了。
- Counter(滚动计数器):一个循环递增的数值,每发一帧就加一。接收方通过观察Counter的连续性,可以判断有没有丢帧、有没有重复帧、有没有被重放进来的旧帧。
我经常用一个生活化的类比来解释这两者的区别:Checksum就像快递包裹上的重量记录,收到货先称一下,重量对不上说明运输途中被拆包或调包了;Counter就像快递单号的连续序号,如果你收到的包裹序号是1、2、5、6,那中间3、4两件大概率是丢了。
在自动驾驶的域控制器测试中,这些机制直接与功能安全等级挂钩。ISO 26262和AUTOSAR E2E规范里对这类端到端通信保护有明确要求,很多ECU在通信层发现Checksum或Counter异常时,会直接进入FAILSAFE(安全降级)状态,而不是继续使用可疑数据。
1.2 仿真时忽略它们会引发什么后果
做自动驾驶仿真,目标往往是验证域控制器的策略逻辑或故障诊断功能。很多人一开始觉得“反正我仿真里数据都是自己生成的,加不加Checksum无所谓的”,结果就掉进下面几个大坑:
第一种坑:ECU完全不响应仿真指令。比如你想通过总线给转向控制器发一个转角指令,报文ID和信号值填得都没错,但Checksum不对,控制器把帧一收,校验失败,直接丢弃。你从仿真端看只能看到“发送成功”,根本看不到数据在接收端被扔掉。
第二种坑:故障注入测试彻底失真。故障注入是自动驾驶仿真的核心需求之一,要模拟报文丢失、信号跳变、通信超时。但是如果你的基础报文本身的Counter就是乱跳的,ECU会把“正常工况”也当成“通信故障”,你注入一个丢包故障后,根本无法分辨ECU的反应到底是针对哪个故障的。
第三种坑:跨团队联调时互相甩锅。仿真团队说“报了错”,感知团队说“没有收到”,软件团队拿到日志一查,发送端和接收端的Counter根本对不上,整个下午就在那里定位是哪个环节把数据改坏了。其实大概率只是发送侧没有实现E2E保护。
所以,在仿真环境里把Checksum和Counter做对,不是“锦上添花”,而是“入场券”。它要让被测ECU相信,你仿真出来的报文和真实传感器发出的报文在通信层面没有区别。
2. Veristand与CAN报文的交互通路:动手前先搞清楚这几件事
2.1 Veristand中一条CAN报文从UUT走向总线的路径
NI Veristand是HIL测试中很常用的实时测试环境,它最大的优势是“一套环境里同时管理实时模型、硬件I/O、故障注入和测试序列”。但这也带来了一个副作用:很多人对它的CAN数据处理链路不太清楚,遇到问题不知道去哪个环节查。
一个典型的Veristand发送CAN报文链路是这样的:
信号在系统定义中赋值 ↓ 通过System Mapping映射到CAN Frame中的Signal ↓ VeriStand引擎把信号数据交给NI-XNET驱动 ↓ NI-XNET按Frame配置的周期和仲裁ID发送 ↓ CAN收发器把电平信号送上物理总线注意,Veristand本身不是一个“报文编辑器”,你不能直接在界面上手写一帧数据然后发送出去。它强调的是“信号级”的操作——你先定义好一个报文里有哪些信号,然后把信号值映射给某个Frame,至于帧头、DLC、填充、发送周期这些,都在Frame配置里管理。
理解这条链路后,你就明白自定义Checksum和Counter的核心思路了:这两个东西本质上是报文里的特殊“信号”,它们的数据源不是来自测试人员手动赋值,而是由其他信号实时计算得到的。后面所有实现方案,都是围绕“怎么把实时计算出来的值映射回报文指定的字节位置”来展开的。
2.2 DBC、Frame与Signal:三个层级不能混淆
在Veristand里操作CAN报文,最常见的入口是导入DBC文件。很多人以为“DBC导入进来,报文就配置好了”,其实不是。
DBC是Vector公司定义的一种CAN数据库描述文件,它描述的是一种“理想报文布局”:某个CAN ID下有哪些信号,每个信号起始位、长度、字节序、缩放因子是多少。Veristand导入DBC后,会把它转成自己的三层对象模型:
- Interface(接口):对应物理上的一个CAN通道,比如“CAN1”或“CAN2”。
- Frame(报文帧):对应一个CAN ID,比如0x123。配置里有DLC、发送类型(周期/事件/触发)、波特率等。
- Signal(信号):对应报文数据区里的某个字段,比如“Counter”“Checksum”“SteeringAngle”。
这里最关键的认知是:在Veristand中,Foo被抽象成一个Frame,Bar被抽象成它下面的Signal。你想修改报文的一个字节,实质上是修改该Frame下某个Signal的映射关系。
所以当你说“我要自定义一个报文的Checksum和Counter”,第一步不是去写代码,而是先确认自己在Veristand的对象模型里能不能看到这个报文Frame,以及Frame里有没有预留Counter和Checksum这两个Signal的位置。如果没有预留,要么改DBC,要么在Veristand手动新建Signal,再关联到报文对应的Byte位置。
2.3 三种自定义实现方式怎么选
在Veristand中实现Checksum和Counter,业界通常有三条路线,我按推荐程度排个序:
| 实现方式 | 灵活度 | 改动量 | 实时性 | 适用场景 |
|---|---|---|---|---|
| Calculation Function(计算通道) | 中 | 小 | 中,受执行率限制 | 无状态、逻辑简单的快速验证 |
| Simulink模型(E2E保护模块) | 高 | 中 | 高,与模型任务同步 | 带状态的滚动计数器、复杂校验算法 |
| Custom Device(自定义设备) | 极高 | 大 | 极高 | 产品级复用、需要直接访问寄存器级数据 |
很多人一看表格就会选第一条路,因为Calculation Function在Veristand里创建非常快,写个公式就能用。但我在实际项目中吃过亏——Calculation Function本质上是一个周期性刷新的无状态公式计算器,它默认不保存上一次的计算结果。而Counter恰恰是需要记忆状态的逻辑:它需要记住“上一帧是多少,然后加一”。纯Calculation实现这个会很别扭,你需要额外引入脉冲边沿检测、状态寄存器之类的辅助手段,复杂度反而上去了。
所以我的实际建议是:如果只是做简单的演示、临时验证,用Calculation Function就够;如果是要长期稳定跑HIL测试,尤其是被测对象有严格的E2E状态机,推荐把Counter和Checksum的逻辑放进Simulink模型里做成一个“E2E保护模块”,再作为Veristand的实时模型运行。这样整个算法的状态管理由模型负责,时序同步、仿真验证都更容易控制。
至于Custom Device,那是给真正追求极致性能和复用性的场景准备的。比如你在Simulink中做一个自定义的NI-XNET驱动替代品,或者需要在FPGA层面逐帧处理CAN报文,才值得投入人力去做。对大多数测试团队来说,Simulink模型方案已经是性价比最高的选择了。
3. 在Veristand中自定义Checksum和Counter的落地步骤
3.1 报文数据区布局设计示例
假设我们现在要发送一个周期为10ms的CAN报文,ID是0x123,DLC=8。为了讲清楚原理,我设计一个尽可能接近真实工程场景的报文布局:
| 字节位置 | 信号名称 | 说明 |
|---|---|---|
| Byte0 | Counter | 滚动计数器,0x00~0xFF循环递增 |
| Byte1 | SteeringAngle_H | 方向盘转角高字节 |
| Byte2 | SteeringAngle_L | 方向盘转角低字节 |
| Byte3 | BrakePosition | 制动踏板开度 |
| Byte4 | GearPos | 挡位信号 |
| Byte5 | Reserved_1 | 预留 |
| Byte6 | Reserved_2 | 预留 |
| Byte7 | Checksum | 对Byte0~Byte6的校验和 |
这里有两个设计惯例值得记住:一是Counter通常放在Byte0,因为这个字段被接收方用来做帧连续性检查,放在前面解析起来最顺手;二是Checksum通常放在报文的最后一个字节,这样计算覆盖范围是“除自己之外的所有数据字节”,符合大多数OEM的E2E规范习惯。
Checksum算法我选择常见的“累加和取反加一”,也叫二进制补码校验和,很多乘用车控制器都用它。具体计算方式是:
sum = Byte0 + Byte1 + Byte2 + Byte3 + Byte4 + Byte5 + Byte6 checksum = (256 - (sum & 0xFF)) & 0xFF为什么用取反加一而不是直接取低字节?因为取反加一的校验和有一个好处:接收端把包括Checksum在内的所有字节全部相加,如果结果是0x00,就说明数据没有错误。这样接收端做校验时只需要一个加法操作,非常高效。
3.2 在Veristand中建立信号并接入Frame
3.2.1 通过DBC导入还是手动新建Frame
如果你手里有正式的DBC文件,直接导入最省事。导入后检查三件事:
- Frame的仲裁ID是否正确(0x123);
- DLC是否等于8;
- 有没有名为Counter和Checksum的Signal,或者至少有没有信号落在Byte0和Byte7的位置。
如果DBC里没有Counter和Checksum的定义,你需要在Veristand的CAN Frame编辑界面里手动添加两个Signal,并分别设置起始位为0和7,长度为8bit,类型为Unsigned。这个小步骤很容易被忽略,但少了它,后面无论怎么算,这两个值都不会送进报文字节里。
3.2.2 在System Definition中配置发送参数
进入Veristand System Definition后,找到目标CAN Interface下的Frame,把Frame配置成周期发送模式,周期设为10ms。同时要确认波特率匹配,比如500kbps。这些基础参数一旦和实车不一致,后面所有验证都会失真。
在System Mapping中,把应用变量(比如方向盘转角、制动踏板、挡位)映射到Frame下的对应Signal。这一步新手最常犯的错误是只映射了“自己想控制的那几个信号”,忽略了Counter和Checksum需要指定数据源,结果报文里这两个字段一直是默认值0x00。
3.3 Calculation Function与Simulink模型,两种实现路径对比
3.3.1 用Calculation Function实现无状态Checksum
如果你只是想快速看一个“能跑通”的版本,可以用Veristand的Calculation Function。
在System Definition中新建一个Calculation,比如命名为CHKSUM_Calc_0x123,然后在表达式里写类似这样的伪公式:
CHKSUM = mod(256 - mod(Counter + SteeringAngle_H + SteeringAngle_L + BrakePosition + GearPos + Reserved_1 + Reserved_2, 256), 256)注意:Veristand不同版本的Calculation语法在函数命名上略有差异,实际编写时以你安装版本内置的函数名为准。关键点是:
- 参与计算的所有操作数,都必须是已经映射到Veristand的通道或变量;
- 最终结果要再映射回Frame的Checksum Signal;
- 数据类型建议统一用UInt8,避免double转uint带来的截断问题。
这种方法能解决Checksum,但解决不了Counter的状态问题。我见过很多人在这个路口卡住:Checksum算出来了,Counter却不递增。原因就是上面说的——Calculation Function是无状态的,你没法在公式里表达“上一帧的值再加一”。
3.3.2 用Simulink模型实现带状态的E2E模块
如果要长期做HIL测试,我强烈建议把E2E保护逻辑放到Simulink模型里。这里给出一个经过验证的模块设计思路:
- Counter生成子模块:使用Unit Delay保存当前计数值,每收到一个“发送使能脉冲”(或者每执行N次模型任务)就加1,通过Modulo模块模256。
- Checksum计算子模块:把Counter和Byte1~Byte6的数据用Byte Packing拼成一个8字节数组,然后经过Sum、Bitwise NOT等模块计算校验值。这比在Veristand里写一行公式更直观,也更容易在仿真阶段提前验证。
- 输出端子:把Counter和Checksum作为模型的输出端口输出。
这个模块在Simulink里跑一遍仿真,你就可以画出Counter随时间变化的阶梯曲线,确认它是0、1、2……255、0循环递增;也可以手动改一帧数据,确认Checksum输出符合预期。验证通过后,把模型编译部署到Veristand实时环境。
很多人问,为什么非要把这块逻辑搬进模型?模型跑在实时任务里,执行周期和Veristand的实时循环是严格同步的。你的CAN报文周期是10ms,模型的定时任务也是10ms(或者一个10ms的发送使能计数器),那么Counter的递增和报文的发送就天然绑定在一起,不会出现“计算通道更新率跟不上发送速率”这种同步问题。
3.4 信号回映射与发送配置:这一步漏了就前功尽弃
无论你选择用Calculation还是Simulink模型,生成的值都必须“送回”Frame的Signal,报文才能真正带上Checksum和Counter。这一环节的典型错误是:计算模块里数值已经对了,但System Mapping的连线没拉,或者拉到了别的Frame上。
以模型方案为例,需要在System Mapping中做两个映射:
E2E_Model.Counter_OUT → CAN1_Interface.CAN_Frame_0x123.Counter E2E_Model.Checksum_OUT → CAN1_Interface.CAN_Frame_0x123.Checksum映射完成后,仿真运行前还要检查一次Frame的发送方式。如果Frame配置成了“事件触发发送”,那么只有你显式触发时才发;如果配置成了“周期发送”,只要系统运行就会按10ms周期持续往外发。对于模拟传感器上游数据的场景,通常选周期发送。
另外提醒一个细节:如果你的模型里还引用了其他传感器应用信号(比如方向盘转角、制动踏板),这些信号同样需要映射到Frame。不要只顾着映射E2E相关值,结果报文里其他字节全是0,一旦接收端把这些0当成真实信号去算Checksum,一样会校验失败。
4. 验证自定义机制:一帧一帧地证明它在工作
4.1 搭建最小验证环境
在把系统接到真实ECU之前,强烈建议先搭一个最小验证环境:
- 一台运行Veristand的实时机或PXI系统;
- 一个CAN接口卡(NI-XNET系列、Vector或者PCAN都可以);
- 一台运行CAN分析工具的电脑(CANoe、PCAN-View、周立功CANTest都行);
- 两端各接一个120Ω终端电阻(有的接口卡内置了,这个要先查手册确认)。
物理连接不复杂:Veristand实时机的CAN口连到分析仪工具的CAN口,两台设备共享一个物理总线。波特率两端必须严格一致,这属于基础配置,但真有人因为一端设了500kbps、另一端设了250kbps,查了一整天才发现是波特率不匹配。
4.2 用独立脚本逐帧核对Counter与Checksum
总线分析工具能告诉你“这一帧发了什么数据”,但通常不会帮你逐帧校验Counter的连续性和Checksum的正确性。所以我习惯写一个小脚本,把CAN分析工具导出的日志读进来,脱离Veristand和ECU独立验证一次。
假设CANoe或PCAN导出的日志文件每一行格式如下:
0x123 00 64 32 28 01 00 00 A5第一列是ID,后面8列是报文数据十六进制。下面这个Python脚本可以逐帧校验:
import re def decode_line(line): parts = line.strip().split() frame_id = parts[0] data = [int(x, 16) for x in parts[1:]] return frame_id, data def calc_checksum(data): # 对 Byte0 ~ Byte6 做累加和取反加一 s = sum(data[0:7]) & 0xFF return (256 - s) & 0xFF expected_counter = None frame_count = 0 error_count = 0 with open('can_log.txt', 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue frame_id, data = decode_line(line) if frame_id.lower() != '0x123': continue frame_count += 1 actual_counter = data[0] actual_checksum = data[7] calc_checksum_val = calc_checksum(data) # 校验 Counter 连续性:允许 0xFF -> 0x00 if expected_counter is None: expected_counter = actual_counter else: if actual_counter != (expected_counter + 1) % 256: print(f"[Counter Error] frame {frame_count}: " f"expected {expected_counter:02X}, got {actual_counter:02X}") error_count += 1 expected_counter = actual_counter # 校验 Checksum if actual_checksum != calc_checksum_val: print(f"[Checksum Error] frame {frame_count}: " f"expected {calc_checksum_val:02X}, got {actual_checksum:02X}") error_count += 1 print(f"total 0x123 frames: {frame_count}, errors: {error_count}") if expected_counter is not None: print(f"final counter: {expected_counter:02X}")这个脚本有个好处:即使你手头没有CANoe这种昂贵工具,只有一份导出的纯文本日志,也能完成自动化校验。脚本逻辑很清晰——Counter连续性单独报错,Checksum错误单独报错,两类问题不会被混在一起。
4.3 把总线分析仪和信号级验证结合起来
脚本验证的是“应用层数据是否正确”,但CAN总线上的物理层问题(电平异常、位错误)从脚本里看不出来。所以你仍然需要看一眼总线分析仪上的错误帧统计功能:
- 如果错误帧数量持续上涨,说明物理层有问题,优先检查波特率、终端电阻、地电位。Checksum和Counter做得再好也救不了物理层错误。
- 如果错误帧为零,但脚本报Counter或Checksum错误,那就是应用层逻辑问题,回Veristand配置或Simulink模型里查。
这两种问题必须分开排查。我曾经在一个项目里花了大半天调E2E算法,最后发现是总线终端电阻没接好导致偶发位错误,接收端采到损坏数据,Checksum自然对不上。这不是算法问题,是物理层问题。所以验证流程一定要分层次。
5. 调试笔记:那些坑,以及对应的排查思路
5.1 Counter不递增或连续重复:多半是同步和映射的问题
Counter不递增,是仿真调试里出现频率最高的问题。按我的排查顺序,基本三步走:
第一步,看映射表。在System Mapping里检查Counter的输出源是否映射到了Frame的Counter Signal上。这个原因听着低级,但实际操作中很容易因为名字相似把Counter_OUT映射到了别的Frame上,或者被之后的映射覆盖掉了。
第二步,看计算任务周期。如果用的是Calculation Function,确认它的执行率是否高于CAN报文的发送率。比如报文周期是10ms,但Calculation执行周期是100ms,那么这100ms内的10帧报文里,Counter可能只在第一帧更新过一次,后面的9帧全部是同一个值。接收方一旦发现连续多帧Counter不变,基本都会判定为重复帧并丢弃。
第三步,看是否有多个数据源同时写入Counter。有时候你在Calculation里算了一个Counter,又在Simulink模型里算了一个Counter,两个返回结果都映射到了同一个Signal上。Veristand的映射优先级不会报错,但实际生效的是后映射的那个,很容易覆盖掉你之前调好的值。我建议一个Frame里的一个Signal只允许一个数据源,避免交叉映射。
5.2 Checksum计算范围与字节序:算法对了也可能白算
Checksum算出来不对,除了算法本身写错,还有几个隐性原因:
计算范围搞错。这是最常见的。有的OEM规范要求Checksum的覆盖范围包括报文ID,有的要求排除Counter,有的要求从Byte1开始而非Byte0。不要想当然,一定要看你们项目里那套正式的通信矩阵文档。曾经有个项目里Checksum差一个字节一直不对,最后查文献发现对方把CAN ID低8位也纳入了求和范围。
字节序问题。如果你的Checksum是多字节字段,DBC里的Intel格式和Motorola格式在起始位编号规则上完全不同,同样一个Checksum=0xA5B6,Intel格式下低字节在前,Motorola格式下高字节在前。对于单字节Checksum这种8位字段,这个问题不明显,但一旦有OEM采用16位CRC,就必须认真核对DBC的字节序。
数值类型被截断。在Veristand Calculation和Simulink模型里,double转uint8时如果没做取整和mod运算,结果可能被四舍五入到一个完全不同的值。我的习惯是:每一步运算都明确定义数据类型,在Simulink里用Data Type Conversion模块显式转换,在Veristand Calculation里把中间变量都声明为UInt8。
5.3 发送周期与模型任务周期:概念上不能划等号
这是我在项目里跟人讨论最多的问题。很多人说“我模型是10ms的任务,报文也是10ms周期,那Counter每帧加1不是理所当然吗?”——不一定。
关键在于:模型每10ms执行一次,不等于每10ms就会发出一帧报文。报文什么时候发,由Veristand的CAN调度决定;模型只是给报文提供数据。如果你的模型执行和报文发送在同一个实时循环里还好,但如果你在模型里做的是“每执行一次就Counter+1”,而报文实际发送频率是模型执行频率的一半,那就会变成每两帧Counter才加1,接收方看到的就是连续的奇偶跳变,一样会判E2E错误。
正确的做法是:让“Counter+1”的触发条件与“报文实际发送”的条件严格一致。在Simulink里,我会用一个发送使能计数器:如果报文周期是模型周期的10倍,就等模型执行满10次后,才允许Counter加1并更新输出;否则保持上一帧的输出不变。这样才能保证“发一帧,Counter加一”。
5.4 与真实ECU联调时,别忘了E2E状态机的启动和容忍窗口
当你把仿真系统接到真实ECU之后,又会发现一些单纯对总线工具联调时看不出来的问题。
首先是ECU的E2E初始化窗口。很多ECU在启动后会有一段“宽限期”,在前几帧内不会严格检查Counter的连续性,或者允许连续丢N帧后才报故障。如果你一上电就要求Counter从0x00开始严格递增,而对方规范其实是0xFF开始递减,那对接初期必然报错。这种情况下,要以对方ECU的E2E配置文档为准,而不是你以为的“标准做法”。
其次是Counter跳变的容忍阈值。不同ECU对“跳变”的定义不同,有的允许Counter从0x0F直接跳到0x11(即允许跳过一个值),有的必须严格等于1。仿真系统如果因为任务调度偶发导致一帧没发出去,Counter就会出现缺口。这种问题很难从报文日志里发现,因为单看数据似乎正常,但ECU已经把这个缺口记为一次通信故障。所以联调时,不仅要看分析仪抓到的帧,还要看被测ECU的诊断故障码里有没有E2E相关的记录。
最后是CAN FD的差异。如果项目里已经切到CAN FD,那DLC可变、数据场更大、校验算法也可能换成了CRC而不是简单的累加和。传统CAN上验证通过的Checksum和Counter实现,不能直接复制到CAN FD上。至少要把CRC多项式、覆盖范围、数据填充规则重新按规范核对一遍。
再分享一个习惯做法:每次修改完E2E相关配置,我都会先抓至少三分钟的总线日志,跑一遍校验脚本,再连真实ECU做联调。三分钟看起来不长,但对一个10ms周期的报文来说,足够覆盖18000帧数据,Counter至少循环70轮,脚本跑完基本能覆盖所有边角情况。这个习惯帮我挡掉过不少低级错误,也希望对你有效。