1 CAPL 概述

CAPL(CAN Application Programming Language)是由 Vector Informatik 开发的一种专用于汽车网络仿真与测试的脚本语言。CAPL 主要运行于 Vector 的网络分析与仿真工具(如 CANoe、CANalyzer)中,用于实现网络节点仿真、报文控制、信号处理及自动化测试逻辑。

1.1 CAPL 简介

CAPL 的语法风格类似于 C 语言,但其设计目标并非通用软件开发,而是针对车载通信网络的测试与验证场景进行专门优化。它与数据库文件(DBC、LDF、FIBEX 等)深度集成,能够直接操作网络中的报文(Message)与信号(Signal)。

从工程角度来看,CAPL 是一种面向事件驱动的测试脚本语言。

1.2 CAPL 设计目标

CAPL 的核心设计目标可以概括为以下几个方面:

1.2.1 网络节点仿真

在 ECU 尚未开发完成或无法参与联调时,可通过 CAPL 构建虚拟节点,实现:

  • 报文周期发送

  • 信号值动态修改

  • 故障场景模拟

  • 总线负载控制

这种方式可支持整车网络的早期联调与系统验证。

1.2.2 自动化测试实现

CAPL 可用于编写自动化测试逻辑,包括:

  • 报文触发条件验证

  • 信号边界值测试

  • 协议一致性检查

  • 故障恢复逻辑验证

结合 CANoe Test Feature Set(TFS),CAPL 可以实现结构化测试用例、测试报告生成及回归测试执行。

1.2.3 报文与信号控制

CAPL 与通信数据库绑定后,可直接操作:

  • CAN/LIN/FlexRay/Ethernet 报文

  • 信号物理值与原始值

  • 网络管理状态

  • 诊断会话流程

这种能力使其成为网络层验证的重要工具。

2 CAPL 程序结构与执行

第一章介绍了 CAPL 的定位。本章回答一个更关键的问题:

CAPL 程序究竟是如何运行的?

很多工程师第一次编写 CAPL 时,会下意识寻找 main 函数 。但在 CAPL 中,这种概念并不存在。理解这一点,是写好 CAPL 的分水岭。

2.1 CAPL 的整体结构

一个标准 CAPL 脚本通常由以下几个部分构成:

  • 头文件区(includes)

  • 变量声明区(variables)

  • 事件处理函数(event procedures)

  • 用户自定义函数(user functions)

基本结构示例如下:

includes {
}

variables {
}

// 用户自定义函数

// 事件处理函数(on start / on message / on timer / testcase 等)

从语法形式看,它类似 C 语言,但从执行方式看,它完全不同。

CAPL 的核心是:

事件驱动(Event-Driven Execution Model)

程序不会自上而下顺序执行。它始终处于等待状态,当某个事件发生时,才执行对应的代码块。

2.2 工程示例结构解析

以下结构在实际项目中非常常见:

  • CRC 算法函数

  • 报文封装函数

  • 主循环控制函数

void MainTest () {
  for(;;) {
    BCMPower_Off_Ign();
    VehSpd();
    if(@MemoryTest::TotalSwitch == 1) {
      IVISetSts_Send();
    }
    testWaitForTimeout(50);
  }
}

从逻辑上看,它像一个典型的主循环。

但这里必须严谨说明:

CAPL 不会自动执行 MainTest()。

除非它被某个事件或测试框架调用,否则该函数永远不会运行。

2.3 CAPL 的真实入口

2.3.1 Simulation Node

在普通仿真节点中,必须通过事件触发:

on start {
  MainTest();
}

但需要注意,在仿真节点中,不推荐使用 for(;;) 死循环。

原因是:

  • CAPL 运行在单线程事件调度模型下

  • 长时间阻塞会影响报文接收与其他事件响应

更推荐的方式是使用定时器驱动:

msTimer MainTimer;

on start {
  setTimer(MainTimer, 50);
}

on timer MainTimer {
  BCMPower_Off_Ign();
  VehSpd();
  setTimer(MainTimer, 50);
}

这种方式更符合 CAPL 的设计思想。

2.3.2 Test Module

如果程序运行在 CANoe Test Module 中,则结构不同。

此时可以使用:

testcase MainTest() {
  while(1) {
    BCMPower_Off_Ign();
    VehSpd();
    testWaitForTimeout(50);
  }
}

在测试模块中,testcase 才是执行入口。

此时使用 testWaitForTimeout() 是合理的,因为测试框架本身管理执行流程。

2.4 典型工程结构分层

结合实际项目,推荐采用如下结构分层:

2.4.1 算法工具函数

byte Crc_CalculateCRC8_1(){

}

特点:

  • 不依赖事件

  • 可复用

  • 不直接发送报文

这是纯逻辑层。

2.4.2 报文封装函数

void BCMPower_Off_Ign() {
  int i = 0;
  byte dataBuffer_1[10];
  BCMPower.dlc = 8;

  BCMPower.byte(0) = 0x02;
  BCMPower.byte(1) = 0x00;
  BCMPower.byte(2) = 0x00;
  BCMPower.byte(3) = 0x00;
  BCMPower.byte(4) = 0x00;
  BCMPower.byte(5) = 0x00;
  BCMPower.byte(6) = BCMPower_RC;

  for(i = 0; i < 7; i++) {
    dataBuffer_1[i] = BCMPower.byte(i);
  }
  BCMPower.byte(7) = Crc_CalculateCRC8_1(dataBuffer_1, 7,0x00);

  output(BCMPower);
  BCMPower_RC = (BCMPower_RC + 1) & 0x0F;
}

职责包括:

  • 信号打包

  • 物理值转原始值

  • Rolling Counter 更新

  • CRC 计算

  • output() 发送

这属于通信封装层。

2.4.3 调度层

  • 定时器驱动

msTimer MainTimer;

testcase MainTest() {
  setTimer(MainTimer, 50);
  testWaitForTimeout(100000);
}

on timer MainTimer {
  BCMPower_Off_Ign();
  VehSpd();
  
  // 重新启动 50ms 周期
  setTimer(MainTimer, 50);
}
  • 测试循环控制

testcase MainTest() {
  while(1) {
    BCMPower_Off_Ign();
    VehSpd();

    if(@MemoryTest::TotalSwitch == 1) {
      IVISetSts_Send();
    }

    testWaitForTimeout(50);
  }
}
  • 条件触发控制

if(@MemoryTest::TotalSwitch == 1) {
  IVISetSts_Send();
}

这一层决定报文何时发送。

2.5 CAPL 的执行本质

可以将 CAPL 抽象为:

  • 一个事件监听器

  • 一个调度器

  • 多个回调函数

运行过程为:

  1. 仿真启动

  2. 执行 on start

  3. 等待事件

  4. 触发对应回调

  5. 返回等待状态

程序始终围绕等待 → 触发 → 执行 → 返回循环。

这与嵌入式系统中的中断模型非常相似。

2.6 关于死循环的工程建议

在 CAPL 中:

  • 不推荐在 Simulation Node 中使用 for(;;)

  • 推荐使用定时器调度

  • 避免长时间阻塞

  • 控制单次执行时间

CAPL 并非实时操作系统,它是事件调度模型。阻塞代码会影响整体仿真精度。

3 Panel 与 CAPL 的绑定机制

在 CANoe Test Module 中,Panel 与 CAPL 的交互依赖于 System Variables(系统变量) 机制。

其基本流程包括三个步骤:

  1. 创建 System Variable

  2. 在 Panel 中绑定变量

  3. 在 CAPL 中通过 @命名空间::变量名 访问

整个过程构成了 Panel 与 CAPL 之间的数据桥梁。

3.1 创建 System Variable

首先需要在 CANoe 导航栏中进行变量创建:

在 System Variables 窗口中:

  • 新建 Namespace(例如:MemoryTest)

  • 在该命名空间下创建变量

变量名

类型

示例用途

VehSpd

double

车辆速度输入

TotalSwitch

int

总功能开关

AEBSwitch

int

AEB 功能开关

类型选择需要根据使用场景确定:

  • 开关类 → int 或 boolean

  • 数值类 → double 或 int

创建完成后,这些变量成为全局可访问变量。

3.2 Panel 组件绑定 System Variable

在创建 Panel 组件时:

  1. 插入控件(按钮 / 滑块 / 数值框等)

  2. 在控件属性中找到 Symbol 绑定选项

  3. 选择对应的 System Variable

此时,当用户在 Panel 上操作控件时:

  • System Variable 的值会实时更新

Panel 并不直接调用 CAPL。
它只负责修改系统变量。

3.3 CAPL 中访问 System Variable

在 CAPL 中,可以通过以下语法访问变量:@Namespace::VariableName

physSpd = @MemoryTest::VehSpd;

if(@MemoryTest::TotalSwitch == 1) {
  IVISetSts_Send();
}

这种访问方式是:

  • 直接读取当前变量值

  • 不需要额外声明

  • 支持读写操作

3.4 事件响应机制

CAPL 还支持监听变量变化:

on sysvar MemoryTest::TotalSwitch {
  write("Switch changed");
}

当变量发生变化时自动触发。

这种方式适用于:

  • 即时响应场景

  • 状态切换控制

  • 日志记录

4 多周期报文调度架构设计

在实际项目中,一个 ECU 通常同时存在多个发送周期:

  • 10ms 周期报文

  • 20ms 周期报文

  • 50ms 周期报文

  • 100ms 周期报文

如果缺乏统一调度设计,代码很快会失控。因此需要构建一个清晰的多周期调度架构。

4.1 设计目标

多周期调度架构应满足:

  1. 周期清晰

  2. 易于扩展

  3. 易于维护

  4. RC 更新节奏稳定

  5. 不影响测试框架执行

核心思想是:

统一时间基准,按倍数分发。

4.2 基础时间基准法

选择一个最小时间单位,例如 10ms 作为基础 Tick。

int gTick = 0;

testcase MainTest() {
  while(1) {
    gTick++;

    /* 10ms 周期 */
    Send_10ms();

    /* 20ms 周期 */
    if(gTick % 2 == 0) {
      Send_20ms();
    }

    /* 50ms 周期 */
    if(gTick % 5 == 0) {
      Send_50ms();
    }

    /* 100ms 周期 */
    if(gTick % 10 == 0) {
      Send_100ms();
    }

    // 至少等待 10ms (并不保证绝对精确)
    testWaitForTimeout(10);
  }
}

这里的关键点是:

  • 所有周期都是 10ms 的整数倍

  • 统一时间节拍

  • 不需要多个定时器

这种方式非常稳定,适合大部分测试场景。

4.3 分层封装建议

建议按周期分组封装:

void Send_10ms() {
  BCMPower_Off_Ign();
}

void Send_50ms() {
  VehSpd();
}

不要在主循环中写具体报文逻辑。主循环只负责调度。报文函数只负责组帧。

4.4 RC 与周期一致性

Rolling Counter 必须与发送周期严格匹配。

例如:

  • 10ms 报文 RC 每 10ms +1

  • 50ms 报文 RC 每 50ms +1

如果 RC 更新节奏与周期不同步:

  • 对端 ECU 会认为报文异常

  • 即使 CRC 正确也会丢弃

因此 RC 更新必须放在报文函数内部,而不是主调度中。

// 正确方式
void VehSpd() {
  VehSpd.byte(6) = VehSpd_RC;
  VehSpd_RC = (VehSpd_RC + 1) & 0x0F;
}

// 错误方式
VehSpd_RC++;
VehSpd();

4.5 可扩展结构

当周期数量增多时,可采用表驱动思想维护一个周期配置表:

报文

周期

函数

BCMPower

10ms

Send_BCM

VehSpd

50ms

Send_VehSpd

IVISetSts

100ms

Send_IVI

虽然 CAPL 不支持复杂数据结构,但逻辑上应按此思路组织代码。

5 Rolling Counter 与 CRC 的通用封装模式设计

Rolling Counter 和 CRC 在车载网络里属于:

报文完整性保护机制(Message Integrity Protection)

在 ADAS 系统里,它们直接影响功能安全与通信可靠性。

5.1 Rolling Counter 机制设计

Rolling Counter(滚动计数器)用于检测:

  • 丢帧

  • 重复帧

  • 乱序帧

常见位宽:

  • 4bit(0~15)

  • 8bit(0~255)

不要把计数逻辑写死在报文函数里。应抽象为:

byte UpdateRollingCounter(byte current, byte max) {
  current++;
  if(current > max) {
    current = 0;
  }
  return current;
}

// 调用
msg.RC = UpdateRollingCounter(msg.RC, 15);

优势:

  • 支持不同位宽

  • 便于统一维护

  • 可做异常注入测试

5.2 CRC 机制抽象设计

CRC(Cyclic Redundancy Check)用于检测比特级错误。

常见类型:

  • CRC8 SAE-J1850

  • CRC8 H2F

  • CRC16 CCITT

CRC 设计需要抽象三个维度:

  1. 算法类型

  2. 校验覆盖范围

  3. 初始值与多项式参数

建议设计统一接口:

byte CalculateCRC(byte data[], int length, int CRC_TYPE);

其中 CRC_TYPE 定义为枚举。

enum CRC_TYPE {
  CRC8_SAE_J1850 = 0,
  CRC8_H2F = 1,
  CRC16_CCITT = 2
};

这样可以做到:

  • 算法与报文解耦

  • 支持多种平台切换

  • 统一维护入口

5.3 完整性处理统一入口设计

成熟架构应分三层:

  1. 信号赋值层

  2. 完整性处理层

  3. 调度发送层

完整性处理应统一封装:

void ApplyIntegrity(Message &msg, IntegrityConfig cfg)

// 配置结构
struct IntegrityConfig {
  byte rcMax;
  int crcType;
  int crcStart;
  int crcLength;
};

优势:

  • 报文逻辑与算法完全分离

  • 易于扩展

  • 易于统一变更

  • 支持错误注入测试

5.4 顺序规范

完整性处理顺序必须严格:

  1. 更新 Rolling Counter

  2. 清零 CRC 字段

  3. 计算 CRC

  4. 写入 CRC

顺序错误将导致系统校验失败。这是通信一致性问题,不是语法问题。

Rolling Counter 代表时间连续性。CRC 代表数据完整性。时间与正确性,是通信系统的两个基本维度。

5.5 示例

/*@!Encoding:936*/

includes {
}

variables {
  // 定义 CRC 类型常量
  const int CRC8_SAE_J1850 = 0;

  // 定义全局 Tick 计数
  int gTick = 0;

  // 定义完整性配置结构
  struct IntegrityConfig {
    byte rcMax;
    int  crcType;
    int  crcStart;
    int  crcLength;
  };

  // 为报文定义完整性规则
  IntegrityConfig VehSpdIntegrityCfg = {15, CRC8_SAE_J1850, 0, 7};
}


/* ================= Rolling Counter 统一接口 ================= */


/* 更新 Rolling Counter 并处理回绕 */
byte UpdateRollingCounter(byte current, byte max) {
  if(current >= max) {
    return 0;
  }
  else {
    return current + 1;
  }
}


/* ================= CRC 统一接口 ================= */


/* CRC8 SAE-J1850 算法实现 */
byte CRC8_SAE_J1850_Calc(byte data[], int length) {
  byte crc = 0xFF;
  byte poly = 0x1D;
  int i;
  int j;

  for(i = 0; i < length; i++) {
    crc ^= data[i];

    for(j = 0; j < 8; j++) {
      if(crc & 0x80) {
        crc = (crc << 1) ^ poly;
      }
      else {
        crc = (crc << 1);
      }
    }
  }

  return crc ^ 0xFF;
}


/* 统一 CRC 计算接口 */
byte CalculateCRC(byte data[], int length, int type) {
  switch(type) {
    case 0:
      return CRC8_SAE_J1850_Calc(data, length);

    default:
      return 0;
  }
}


/* ================= 完整性统一入口 ================= */


/* 将报文指定范围拷贝到 buffer */
void CopyMsgRange(message msg, byte buffer[], int start, int length) {
  int i;

  for(i = 0; i < length; i++) {
    buffer[i] = msg.byte(start + i);
  }
}


/* 对报文执行 RC + CRC 完整性处理 */
void ApplyIntegrity(message &msg, IntegrityConfig cfg) {
  byte buffer[64];

  // 更新 Rolling Counter
  msg.byte(6) = UpdateRollingCounter(msg.byte(6), cfg.rcMax);

  // 清零 CRC 字段
  msg.byte(7) = 0x00;

  // 复制 CRC 覆盖范围
  CopyMsgRange(msg, buffer, cfg.crcStart, cfg.crcLength);

  // 计算 CRC
  msg.byte(7) = CalculateCRC(buffer, cfg.crcLength, cfg.crcType);
}


/* ================= 报文业务层 ================= */


/* 构造并发送 VehSpd 报文 */
void Send_VehSpd() {
  int i;

  // 设置 DLC
  VehSpd.dlc = 8;

  // 清零报文数据
  for(i = 0; i < 8; i++) {
    VehSpd.byte(i) = 0x00;
  }

  // 示例业务数据填充
  VehSpd.byte(0) = 0x12;
  VehSpd.byte(1) = 0x34;

  // 执行完整性处理
  ApplyIntegrity(VehSpd, VehSpdIntegrityCfg);

  // 发送报文
  output(VehSpd);
}


/* ================= 多周期调度示例 ================= */


/* 主测试用例 */
testcase MainTest() {
  while(1) {
    // Tick 递增,多周期有实际意义,此处仅为工程规范所保留
    gTick++;

    // 10ms 周期
    Send_VehSpd();

    // 等待 10ms
    testWaitForTimeout(10);
  }
}

上述示例代码已经具备:

  1. 算法与报文解耦,CRC 算法在 CRC8_SAE_J1850_Calc() 内部报文函数不关心多项式和初始值

  1. 规则可配置,每个报文只需要一个 IntegrityConfig

  1. 统一维护入口

    1. CRC 修改 → 只改 CalculateCRC()

    2. RC 修改 → 只改 UpdateRollingCounter()

    3. 覆盖范围修改 → 只改配置

  1. 报文函数极其干净