用 Qt 写一个 ECU 模拟器:CetSimulateEcu 设计笔记

发布时间:2026/7/21 20:18:59

用 Qt 写一个 ECU 模拟器:CetSimulateEcu 设计笔记 作者CetXiyuan技术栈Qt 5.15.2 (MinGW 32-bit)平台Windows阅读时长约 18 分钟受众嵌入式/汽车电子/工控领域 Qt 开发者写在前面上一篇写了 CetCANTestTool——一个 CAN 总线报文的收发、过滤、触发工具。那篇的核心问题是怎样把 CAN 帧收下来、发出去、过滤好。这一篇聊另一个问题没有真实 ECU 怎么调试做车载 CAN 总线开发的同学都遇到过终端设备T-Box、行驶记录仪、车载导航需要和 ECU 通信才能跑通完整流程但真实 ECU 要么在车上、要么在台架上、要么只支持有限的调试指令。你需要一个能按协议回复 CAN 报文的软件——也就是 ECU 模拟器。这篇文章是我维护CetSimulateEcu多个版本的设计复盘覆盖了从 CAN 驱动抽象、锁车状态机、疲劳驾驶模拟、DoIP 诊断到 IPC 进程通信的完整架构。声明本文基于真实项目 CetSimulateEcu V5.5.0补丁包 7152发布日期 2026-07-09。所有代码片段对照真实源码部分代码为说明目的做了缩写完整实现请参考源码仓库。术语对照缩写全称含义ECUElectronic Control Unit电子控制单元发动机控制器CANController Area Network控制器局域网CANFDCAN with Flexible Data-rate可变速率 CAN单帧最大 64 字节DoIPDiagnostics over IP基于以太网的诊断协议ISO 13400-2UDSUnified Diagnostic Services统一诊断服务ISO 14229IPCInter-Process Communication进程间通信OTAOver-The-Air固件远程升级EOLEnd of Line产线下线配置一、工具定位模拟 ECU不是替代 ECU先说边界。CetSimulateEcu模拟 ECU 的 CAN 总线行为——收到特定报文后按协议回复。它不是一个完整的 ECU 固件。不做的不做硬件在环仿真HIL不接真实传感器不做 ECU 内部的控制算法喷油、点火、排放不做完整的 UDS 诊断栈只做 OTA 升级流程中的传输层交互做的功能说明CAN/CANFD 双驱动同时支持旧版 ControlCAN 和新版 zlgcan运行时可切换销贷锁车模拟11 种状态的锁车状态机覆盖 BOSCHMAN/潍柴/玉柴/云内/康明斯 5 种已实现的发动机组合锡柴/DENSO/VDO 预留疲劳驾驶模拟行驶/休息时间累计车速联动疲劳判断ECU OTA 升级模拟固件升级的 CAN 报文交互UDS/J1939-21条件触发6 组独立规则收到特定 CAN 帧自动回复预设文件DoIP 诊断ISO 13400-2 以太网诊断支持诊断器/ECU 双角色IPC 数据注入接收外部进程CetVehicleCollector的实时车速驱动疲劳驾驶逻辑转速/车速模拟基准转速 ±200rpm 范围内 14 步周期随机波动插件扩展5 个插件接口比 CetCANTestTool 多一个 CetCANInterfaceV5.5.0 升级为 CetToolPluginContext 统一管理授权安全Ed25519 签名验证 SHA256 插件完整性校验支持临时授权、停用/拒绝状态二、整体架构驱动抽象 模块化功能┌──────────────────────────────────────────────────────────────┐ │ MainWindow主窗口 │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │EcuType │ │EcuFeature│ │CanBus │ │DoIP │ │ │ │Parameter │ │Assemble │ │Operation │ │Entity │ │ │ └──────────┘ └──────────┘ └──────────┘ │Panel │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ └──────────┘ │ │ │CanBus │ │CanId │ │IPCMonitor│ │ │ │Parameter │ │Filtrator │ │Receiver │ │ │ └──────────┘ └──────────┘ └──────────┘ │ ├──────────────────────────────────────────────────────────────┤ │ 虚拟 CAN 数据抽象层VirCanBusData │ │ ┌─────────────────────┐ ┌──────────────────────┐ │ │ │ CanBusDevice │ │ CanFDBusDevice │ │ │ │ (ControlCAN 旧版) │ │ (zlgcan 新版 CANFD) │ │ │ └─────────────────────┘ └──────────────────────┘ │ ├──────────────────────────────────────────────────────────────┤ │ 插件管理层CetToolPluginContext │ │ SHA256 完整性校验 统一激活/更新/日志管理 │ ├──────────────────────────────────────────────────────────────┤ │ 插件接口层5 个 Q_DECLARE_INTERFACE │ │ License │ LogManager │ Update │ Progress │ CANInterface │ ├──────────────────────────────────────────────────────────────┤ │ 外部依赖 │ │ zlgcan.dll / ControlCAN.dll / binlog.lib / SQLite │ └──────────────────────────────────────────────────────────────┘核心模块一览模块职责VirCanBusData统一虚拟帧屏蔽新旧驱动差异CanBusParameterCAN 参数配置、转速/车速报文发送CanBusOperation手动收发、周期发送、文件回放、BLF/DBC 格式转换CanIdFiltrator每通道独立实例CAN ID 黑/白名单过滤CanBusStatus总线错误状态、流量统计EcuTypeParameterECU 品牌/发动机厂商选择EcuFeatureAssemble锁车、疲劳驾驶、OTA、条件触发、EOL 配置的功能容器DoIPEntityPanelISO 13400-2 DoIP 协议诊断器/ECU 双角色IPCMonitorReceiverQLocalServer 接收外部进程数据三、核心设计VirCanBusData——用一层抽象换十年的可维护性这是整个项目最有价值的架构决策。3.1 问题两套驱动的数据结构不兼容周立功提供了两套 CAN 驱动旧版 ControlCAN.dllControlCAN.h——时间戳单位 100μs字段用UINT新版 zlgcan.dll——时间戳单位 ms字段用枚举支持 CANFD两套驱动的数据结构字段名、类型、单位都不一样。如果让上层 9 个功能模块直接依赖具体驱动类型结果是大量if-else和重复的转换逻辑。3.2 方案适配器模式 多构造函数VirCanBusData是一个纯数据类POD核心字段统一// vircanbusdevice.h:9-144简化 class VirCanBusData { public: // CAN/CANFD 统一枚举 enum Channel { Channel_0 0, /* ... */ Channel_8, UnknownChannel -1 }; enum DataType { DataTypeCan 0, DataTypeCanFD, DataTypeCanFDAcceleration, DataTypeError }; // 关键两个适配器构造函数 explicit VirCanBusData(const CanBusData canData) // 从旧驱动构造 : canid(canData.canid) , timeStampMs(canData.timeStamp / 10) // 100μs → ms , sendType(canData.sendType) , frameType(canData.frameType) , frameFormat(canData.frameFormat) , data(canData.data) { } explicit VirCanBusData(const CanFDBusData canData) // 从新驱动构造 : canid(canData.canid) , timeStampMs(canData.timeStampMs) // 直接赋值无需转换 , dataType(canData.dataType) , sendType(canData.sendType) , frameType(canData.frameType) , frameFormat(canData.frameFormat) , data(canData.data) { } // 反向转换发送时恢复为驱动原生格式 CanBusData toCanBusData() const { /* ... */ } CanFDBusData toCanFDBusData() const { /* ... */ } // 批量转换预分配内存 static QListVirCanBusData virCanBusDatas(const QListCanBusData canDatas) { QListVirCanBusData virCanDatas; virCanDatas.reserve(canDatas.size()); foreach (const CanBusData canData, canDatas) virCanDatas.append(VirCanBusData(canData)); return virCanDatas; } // 便捷构造函数从原始参数直接构造用于模块内部生成新帧 explicit VirCanBusData(uint32_t canid, const QByteArray data, /* ... */); public: quint32 canid; quint32 timeStampMs; // 统一单位毫秒 int dataType; // CAN / CANFD / CANFD加速 / 错误 int sendType; int frameType; // 标准帧 / 扩展帧 int frameFormat; // 数据帧 / 远程帧 QByteArray data; };3.3 这层抽象的实际效果数据流变成了一条清晰链路硬件 → CanBusDevice旧 → CanBusData → VirCanBusData ─┐ 硬件 → CanFDBusDevice新→ CanFDBusData → VirCanBusData ─┤ │ ┌───────────────────────────────────────────────────┘ ▼ MainWindow::processReceivedData ├→ CanIdFiltratorID 过滤只认 VirCanBusData ├→ EcuFeatureAssemble锁车/疲劳/OTA仅 Channel 0 ├→ CanBusOperation手动收发仅 Channel 0 └→ CetCANInterface插件只认原始 canidcandata[8]发送同理业务模块构造VirCanBusData→toCanBusData()/toCanFDBusData()→ 驱动发送。代价是一次数据拷贝换来的收益是9 个功能模块零驱动依赖换驱动只需改一行配置新增第三种 CAN 设备时只需要加一个构造函数时间戳单位统一为毫秒上层不再需要*10或/103.4 代码示例从旧驱动数据构造 VirCanBusData 并转换为新驱动格式以下是一个简短的 C 代码示例展示如何从旧驱动数据构造VirCanBusData对象并调用其toCanFDBusData()方法转换为新驱动格式// 示例从旧驱动 CanBusData 构造 VirCanBusData再转换为新驱动 CanFDBusData #include vircanbusdevice.h #include canbusdevice.h void exampleConversion() { // 1. 假设从旧驱动收到一个 CAN 帧 CanBusData oldCanData; oldCanData.canid 0x123; // CAN ID oldCanData.timeStamp 10000; // 时间戳单位100μs oldCanData.sendType CanBusDevice::NormalTransmission; oldCanData.frameType CanBusDevice::ExtendedFrame; oldCanData.frameFormat CanBusDevice::DataFrame; oldCanData.data QByteArray::fromHex(1122334455667788); // 8字节数据 // 2. 使用适配器构造函数创建 VirCanBusData // 注意时间戳会自动从 100μs 转换为 ms除以10 VirCanBusData virData(oldCanData); // 3. 验证转换结果 qDebug() 原始 CAN ID: QString::number(oldCanData.canid, 16); qDebug() 转换后 CAN ID: QString::number(virData.canid, 16); qDebug() 原始时间戳 (100μs): oldCanData.timeStamp; qDebug() 统一时间戳 (ms): virData.timeStampMs; // 应为 1000 // 4. 转换为新驱动格式CANFD CanFDBusData newCanData virData.toCanFDBusData(); // 5

相关新闻