简介:这是一份面向Qt与C++初学者、控制系统仿真爱好者的实战型项目源码,围绕「用Qt做出类似Simulink的可视化仿真界面」展开,适合想打通GUI设计、图形场景管理与数值仿真三条技能线、并希望参照完整可编译工程练手的读者。压缩包共122个文件、约21.78MB,以cpp与h源文件为主体,配合obj中间产物、ui界面文件、qrc资源与qm翻译、ts语言包、sln与vcxproj工程配置及exe可执行文件,完整保留了VS2010加Qt 4.8.5的构建痕迹,便于直接打开工程、追踪编译配置与资源组织方式。内容涉及QGraphicsItem、QGraphicsView与QGraphicsScene搭建可拖拽的仿真元素,PID模块与连线图元的自定义绘制,场景交互与对话框编辑,以及把控制回路用离散数值方法跑起来的基本思路。已有3633人学习,适合按模块拆读源码、对照复现界面与仿真流程。
1. 先看界面像不像,再看数据能不能走通
很多人第一次打开这类 Qt 仿真界面,会先看模块图标像不像、拖拽顺不顺,但真正决定它能不能用的是连线数据和仿真推进。Qt 编写的类似 Simulink 仿真界面的小程序,核心是一张可编辑的计算图:模块是节点,端口是插槽,连线是数据依赖。它适合课程设计、算法流程演示、控制逻辑验证,也适合 C++ 开发者拿来做不依赖重型商业工具的框图仿真沙盒。关键词落在 Qt、Simulink 式框图、仿真和 VS C++ 工具链上,落到工程里就是画布、端口、拓扑排序和固定步长循环。界面只是壳,计算图能不能保存、能不能排序、能不能按步推进,才是这套小程序值不值得拆的地方。
2. 拆开画布:QGraphicsScene 里把模块拖拽和缩放做稳
拿到这类 Qt 工程,我一般先不急着改模块的 paint,而是把画布层跑通:场景坐标、视图变换、图元选中、拖拽边界这四件事稳了,后面的端口和连线才有意义。框图式仿真界面最怕“看起来能拖,一缩放就偏”,根因多半是把视图坐标和场景坐标混着用。下面按选型、工程骨架、模块图元、拖拽缩放四步走,每一步都能直接对照到代码。
2.1 为什么用 QGraphicsScene 而不是自己摆 QWidget
用 QWidget 绝对定位也能摆出模块,但模块一多就会遇到几个硬问题:Z 序管理靠 raise() 手工维护,缩放要自己乘坐标,框选和碰撞检测要手写,连线这种需要自定义绘制的图元更别扭。QGraphicsView 体系把这些问题拆开了:QGraphicsScene 管图元和空间关系,QGraphicsView 管视口和变换,QGraphicsItem 管绘制和命中。模块拖拽、画布缩放、连线命中、选中态描边都能落在图元层,不用把业务逻辑塞进主窗口。
我一般会设置三个视图参数:场景矩形给一个足够大的固定范围,避免图元拖到负坐标后滚动条跳;渲染开抗锯齿和文本抗锯齿,连线看起来不毛糙;拖拽模式用 RubberBandDrag,框选多个模块时不用按 Ctrl。缩放锚点用 AnchorUnderMouse,滚轮缩放时鼠标下的点不跑,这是后续连线不飘的基础。注意场景矩形不是越大越好,太大时滚动条比例会失真,常见做法是按初始画布 4000×3000 给,模块超出后再扩。
2.2 工程骨架:CMake 把 Qt Widgets 和 C++17 接起来
这类工程常见做法是用 CMake 组织,Qt Creator 和 VS C++ 工具链都能打开。下面是一个最小可跑的骨架,核心目标是把 Widgets 模块、C++17、自动 MOC 接上。代码里没有写具体业务类,先把编译链跑通,后面再加 ModuleItem 和 ConnectionItem。
cmake_minimum_required(VERSION 3.16) project(QtBlockSim LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 有 Q_OBJECT 的类必须开 set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) find_package(Qt6 COMPONENTS Widgets REQUIRED) # 如果本机是 Qt5,把上面换成 find_package(Qt5 COMPONENTS Widgets REQUIRED) add_executable(QtBlockSim src/main.cpp src/MainWindow.cpp src/ModuleItem.cpp src/ConnectionItem.cpp ) target_link_libraries(QtBlockSim PRIVATE Qt6::Widgets) # Qt5 对应 Qt5::Widgets这段的逻辑是把源码、MOC、链接一次配齐。参数上,CMAKE_AUTOMOC 必须开,否则带 Q_OBJECT 的图元类会报 vtable 未定义;C++17 是为了后面用结构化绑定和 std::optional 做端口查找。VS C++ 工具链下如果调试时提示找不到 Qt 平台插件,常见做法是把 Qt 的 bin 目录加到调试环境 PATH,或者在 CMake 里用 windeployqt 做部署,不要手工拷 dll 到输出目录后忘了插件目录。
2.3 模块图元基类:端口位置、选中态和边界矩形
模块图元不要一上来就写死某个算法模块,先抽一个基类:它知道自己的标题、宽高、输入端口和输出端口的相对位置,知道选中时怎么画描边。下面这个类只做三件事:给出边界矩形、画圆角矩形和端口、在移动时通知连线更新。端口先按上下左右固定分布,后面再扩展成可配置。
class ModuleItem : public QGraphicsObject { Q_OBJECT public: explicit ModuleItem(QString title, QGraphicsItem* parent = nullptr) : QGraphicsObject(parent), m_title(std::move(title)) { setFlags(ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges); setCacheMode(DeviceCoordinateCache); // 拖动时减少重绘开销 } QRectF boundingRect() const override { return QRectF(0, 0, m_width, m_height).adjusted(-2, -2, 2, 2); } void paint(QPainter* p, const QStyleOptionGraphicsItem*, QWidget*) override { QRectF r(0, 0, m_width, m_height); p->setRenderHint(QPainter::Antialiasing, true); p->setPen(QPen(isSelected() ? Qt::blue : Qt::darkGray, isSelected() ? 2 : 1)); p->setBrush(QColor(250, 250, 250)); p->drawRoundedRect(r, 6, 6); p->setPen(Qt::black); p->drawText(r.adjusted(8, 6, -8, 0), Qt::AlignLeft | Qt::AlignTop, m_title); // 输入端口在左侧,输出端口在右侧,半径固定 5 p->setBrush(Qt::darkGreen); for (int i = 0; i < m_inputCount; ++i) { QPointF c(0, portY(i, m_inputCount)); p->drawEllipse(c, 5, 5); } p->setBrush(Qt::darkRed); for (int i = 0; i < m_outputCount; ++i) { QPointF c(m_width, portY(i, m_outputCount)); p->drawEllipse(c, 5, 5); } } QPointF inputPortPos(int index) const { return mapToScene(QPointF(0, portY(index, m_inputCount))); } QPointF outputPortPos(int index) const { return mapToScene(QPointF(m_width, portY(index, m_outputCount))); } signals: void positionChanged(ModuleItem* item); protected: QVariant itemChange(GraphicsItemChange change, const QVariant& value) override { if (change == ItemPositionHasChanged) emit positionChanged(this); return QGraphicsObject::itemChange(change, value); } private: static qreal portY(int i, int n) { return 30 + (i + 1) * (60.0 / (n + 1)); // 端口在标题下方均匀分布 } QString m_title; qreal m_width = 120; qreal m_height = 64; int m_inputCount = 1; int m_outputCount = 1; };逻辑上,boundingRect 比实际矩形多留 2 像素,是为了选中描边不被裁掉;端口位置用 mapToScene 转成场景坐标,连线图元只认场景坐标,不认模块内部坐标。参数上,端口半径 5 是视觉大小,后面命中区域会单独放大;端口 Y 从 30 开始,是为了避开标题行;缓存模式用 DeviceCoordinateCache,模块多时拖动更顺,但缩放倍率变化大时要记得失效缓存,否则文字会糊。
2.4 拖拽和缩放:把视图变换与场景坐标分开
拖拽本身不用写太多代码,ItemIsMovable 已经给了基础行为。真正要管的是缩放:滚轮事件里改的是视图的变换矩阵,不是图元坐标。下面这段放在自定义 QGraphicsView 里,缩放因子按 1.15 走,限制在 0.2 到 5 之间,避免缩到看不见或放大到内存吃紧。
void BlockView::wheelEvent(QWheelEvent* e) { if (!(e->modifiers() & Qt::ControlModifier)) { QGraphicsView::wheelEvent(e); // 不按 Ctrl 时保留滚动 return; } const qreal factor = e->angleDelta().y() > 0 ? 1.15 : 1.0 / 1.15; const qreal next = m_zoom * factor; if (next < 0.2 || next > 5.0) return; // 缩放边界 m_zoom = next; setTransformationAnchor(QGraphicsView::AnchorUnderMouse); scale(factor, factor); e->accept(); }这段的逻辑是把缩放锚点放在鼠标下,连线端点和模块端口的相对位置不会因为缩放而错位。参数上,1.15 是手感比较稳的步进,太小要滚很多次,太大容易晕;0.2 到 5 的边界是按常见 1080p 画布估的,模块特别多时可以降到 0.1,但文字会先糊。注意不要在 wheelEvent 里同时改场景矩形,缩放时改场景矩形会让滚动条跳,血泪经验是场景矩形只在模块拖出边界时扩,不在缩放时动。
3. 端口与连线:命中测试、路径重绘和方向约束
框图仿真界面的“手感”八成来自连线:点端口能不能起线、拖拽时临时线跟不跟手、松手后连线是不是落在正确的端口上、模块移动时连线端点会不会飘。这里的核心不是画一条好看的贝塞尔曲线,而是把端口命中、连线命中、方向约束拆成三层。下面按端口建模、连线图元、创建流程、跟随更新四步写,代码都能直接往工程里塞。
3.1 端口用纯绘制还是独立图元
端口可以做成独立的 QGraphicsEllipseItem,也可以像上一章那样在模块里画圆。独立图元的好处是命中测试直接走场景,坏处是模块移动时要同步一堆子图元,删除模块还要管端口。我一般选纯绘制加手动命中:端口只是视觉,命中逻辑放在模块和视图的鼠标事件里,用距离判断。这样端口数量变化时不用动态建图元,序列化也只存端口索引。
命中半径不要等于视觉半径。视觉半径 5,命中半径给 10 到 12,鼠标不用精确点中圆点。判断时把鼠标场景坐标和端口场景坐标做欧氏距离,小于命中半径就算命中。注意要优先判断输出端口再判断输入端口,因为起线通常从输出端开始;如果允许反向连线,再按模式切换。这个顺序不写清楚,用户点靠近模块边缘时会误起线。
3.2 连线图元:贝塞尔路径和加宽命中区
连线用 QGraphicsPathItem 派生的类,paint 里画贝塞尔曲线,shape 里返回加宽后的路径。QGraphicsPathItem 默认的命中区域是按线宽算的,线宽 2 时几乎点不中,必须用 QPainterPathStroker 把命中区加到 8 到 10 像素。下面这段是核心,起点终点都从端口场景坐标来,控制点按水平方向偏移。
class ConnectionItem : public QGraphicsPathItem { public: ConnectionItem(QPointF from, QPointF to, QGraphicsItem* parent = nullptr) : QGraphicsPathItem(parent), m_from(from), m_to(to) { setZValue(-1); // 连线压在模块下面,避免挡住端口 setPen(QPen(Qt::darkCyan, 2)); rebuild(); } void setEndpoints(QPointF from, QPointF to) { prepareGeometryChange(); m_from = from; m_to = to; rebuild(); } QPainterPath shape() const override { QPainterPathStroker stroker; stroker.setWidth(10); // 命中宽度,不要跟视觉线宽混 return stroker.createStroke(path()); } QRectF boundingRect() const override { return shape().boundingRect().adjusted(-2, -2, 2, 2); } private: void rebuild() { QPainterPath p(m_from); const qreal dx = std::max(40.0, std::abs(m_to.x() - m_from.x()) * 0.5); p.cubicTo(m_from + QPointF(dx, 0), m_to - QPointF(dx, 0), m_to); setPath(p); } QPointF m_from; QPointF m_to; };逻辑说明:控制点水平偏移取端点水平距离的一半,最小 40,这样短连线不会卷成死结,长连线也不会太直。参数上,命中宽度 10 是手感和误触的折中,触控板用户可到 12;Z 值 -1 让连线在模块下方,端口圆点仍然可见。注意 prepareGeometryChange 必须在改路径前调用,否则场景索引不更新,移动模块时会出现残影,这是很常见的翻车点。
3.3 连线创建流程:按下起线、移动预览、释放落点
连线创建不要塞进模块的 mousePressEvent,放在视图层更好管:视图知道当前鼠标下的场景坐标,也知道有没有临时线。流程是按下时找最近输出端口,找到就建一条临时 ConnectionItem,移动时更新终点,释放时找最近输入端口,合法就固化成正式连线,不合法就删临时线。下面这段是简化版,核心在端口查找和方向校验。
void BlockView::mousePressEvent(QMouseEvent* e) { if (e->button() == Qt::LeftButton) { const QPointF scenePos = mapToScene(e->pos()); if (auto* port = findOutputPort(scenePos)) { m_pendingFrom = port->scenePos(); m_pendingItem = new ConnectionItem(m_pendingFrom, m_pendingFrom); scene()->addItem(m_pendingItem); e->accept(); return; } } QGraphicsView::mousePressEvent(e); } void BlockView::mouseMoveEvent(QMouseEvent* e) { if (m_pendingItem) { m_pendingItem->setEndpoints(m_pendingFrom, mapToScene(e->pos())); e->accept(); return; } QGraphicsView::mouseMoveEvent(e); } void BlockView::mouseReleaseEvent(QMouseEvent* e) { if (m_pendingItem) { const QPointF scenePos = mapToScene(e->pos()); if (auto* target = findInputPort(scenePos)) { m_pendingItem->setEndpoints(m_pendingFrom, target->scenePos()); m_pendingItem->setData(0, target->moduleId()); // 存目标模块 id,序列化用 m_connections.push_back(m_pendingItem); } else { scene()->removeItem(m_pendingItem); delete m_pendingItem; } m_pendingItem = nullptr; e->accept(); return; } QGraphicsView::mouseReleaseEvent(e); }逻辑上,临时线和正式线是同一个类,释放时只决定保留还是删除。参数上,findOutputPort 的命中半径给 12,findInputPort 给 12,两个都从端口场景坐标算距离;如果多个端口都在半径内,取距离最小的。注意释放时不要直接拿鼠标坐标当终点,要用目标端口坐标,否则连线看起来落在端口上,保存后重开会有几像素偏移,这种偏移在放大后就明显了。
3.4 模块移动时连线跟随:别用信号槽绕圈
模块移动后连线要更新端点,常见做法是模块在 itemChange 里发 positionChanged,视图收到后遍历相关连线重设端点。这里要防的是循环:连线更新不要反过来触发模块移动,也不要在 itemChange 里做 scene()->update() 全量刷新。下面这段是视图侧的槽,按模块 id 找连线,只更新起点或终点。
void BlockView::onModuleMoved(ModuleItem* item) { for (auto* conn : m_connections) { if (conn->sourceModuleId() == item->id()) { conn->setEndpoints(item->outputPortPos(conn->sourcePort()), conn->endPoint()); } if (conn->targetModuleId() == item->id()) { conn->setEndpoints(conn->startPoint(), item->inputPortPos(conn->targetPort())); } } }逻辑是只改受影响的连线,不做全场景重绘。参数上,模块 id 用递增整数或 QUuid,序列化时一起存;端口索引也要存,不然重开后不知道连的是第几个端口。注意不要在 itemChange 的 ItemPositionChange 阶段发信号,那时坐标还没落定,要等 ItemPositionHasChanged;如果连线数量上千,遍历全量会卡,常见做法是给每个模块维护一个连线索引表,按需查。
4. 仿真推进:拓扑排序、固定步长和线程边界
界面能连起来不代表能算。框图仿真的计算顺序由连线决定,不是按模块创建顺序跑。这里要有三层:数据结构把模块和连接存成图,拓扑排序给出推进顺序并检测环,固定步长循环按 dt 推进时间。再往上才是界面刷新。下面按数据结构、拓扑排序、仿真循环、刷新边界四步写,参数给的是常见起步值。
4.1 计算图数据结构:节点、端口和连接表
不要直接拿 QGraphicsItem 当仿真模型。界面图元负责画,仿真模型负责算,两边用 id 映射。下面这个结构够小,能表达模块类型、参数、输入输出连接。输入连接用 map[端口索引] = 源模块/源端口,输出连接可以反向查,也可以单独存。
struct ModuleNode { int id = -1; QString type; // "Gain" / "Sum" / "Integrator" QHash<QString, double> params; // 例如 {"gain": 2.0} QHash<int, int> inputs; // 输入端口 -> 源模块 id QHash<int, int> inputPorts; // 输入端口 -> 源端口索引 QVector<double> inputValues; QVector<double> outputValues; }; struct GraphModel { QHash<int, ModuleNode> nodes; QHash<int, QVector<int>> outgoing; // 源模块 -> 目标模块列表,用于拓扑排序 };逻辑上,nodes 存模块,outgoing 存依赖,inputs 存端口级连接。参数上,outputValues 按输出端口数开,inputValues 按输入端口数开;Gain 这类单入单出模块可以只用一个 double,但为了统一,还是用 vector。注意模块 id 不要用图元的指针地址,保存重开后指针会变,id 才是稳定键。
4.2 拓扑排序:Kahn 算法和环检测
拓扑排序用 Kahn 算法最直观:先算每个节点入度,入度为 0 的进队列,出队后把下游入度减 1,减到 0 再入队。最后如果输出节点数小于总节点数,说明有环。下面这段是核心,入度按“输入连接数”算,不按 outgoing 算,因为一个模块可能多个输入来自同一个源。
std::optional<QVector<int>> topoSort(const GraphModel& g) { QHash<int, int> indegree; for (auto it = g.nodes.begin(); it != g.nodes.end(); ++it) { indegree[it.key()] = it.value().inputs.size(); // 输入连接数即入度 } QQueue<int> q; for (auto it = indegree.begin(); it != indegree.end(); ++it) { if (it.value() == 0) q.enqueue(it.key()); } QVector<int> order; while (!q.isEmpty()) { const int id = q.dequeue(); order.push_back(id); for (int next : g.outgoing.value(id)) { if (--indegree[next] == 0) q.enqueue(next); } } if (order.size() != g.nodes.size()) return std::nullopt; // 有环 return order; }逻辑说明:返回 std::nullopt 表示存在循环依赖,界面层要弹提示并高亮环上的模块,而不是继续跑。参数上,入度用 inputs.size(),如果一个模块有多个输入端口但只连了一部分,未连的端口按 0 处理,不要算进入度。注意自环也要算环,源和目标相同会直接让入度减不干净,Kahn 结果数量不够,能检测出来。
4.3 固定步长仿真循环:dt、时间和最大步数
仿真循环按拓扑顺序逐模块计算:先读输入,再按类型算输出,最后写回。固定步长 dt 常见起步 0.01 秒,最大步数按 10 秒仿真给 1000 步。下面这段是简化引擎,Gain 和 Sum 两种类型够演示推进逻辑,Integrator 需要上一拍状态,单独存。
class SimulationEngine { public: void reset(double dt) { m_t = 0; m_dt = dt; m_step = 0; } bool step(GraphModel& g, const QVector<int>& order) { for (int id : order) { ModuleNode& n = g.nodes[id]; n.inputValues.fill(0.0, n.inputs.size()); for (auto it = n.inputs.begin(); it != n.inputs.end(); ++it) { const int srcId = it.value(); const int srcPort = n.inputPorts.value(it.key()); n.inputValues[it.key()] = g.nodes[srcId].outputValues.value(srcPort, 0.0); } if (n.type == "Gain") { n.outputValues[0] = n.inputValues.value(0) * n.params.value("gain", 1.0); } else if (n.type == "Sum") { double s = 0; for (double v : n.inputValues) s += v; n.outputValues[0] = s; } else if (n.type == "Integrator") { n.outputValues[0] += n.inputValues.value(0) * m_dt; // 前向欧拉 } } m_t += m_dt; ++m_step; return m_step < m_maxSteps; } private: double m_t = 0; double m_dt = 0.01; int m_step = 0; int m_maxSteps = 10000; };逻辑上,每个模块先统一读输入,再算输出,避免同一拍里下游读到上游的新值造成顺序依赖。参数上,dt 越小精度越高但步数越多,0.01 对演示够用,0.001 要看模块数量;Integrator 用前向欧拉,步长太大时会飘,这是算法边界,不是界面 bug。注意 outputValues 要按端口数预分配,空 vector 直接下标会崩,.value() 给默认值能兜底但会掩盖配置错误。
4.4 界面刷新和线程边界:QTimer 驱动,别跨线程碰图元
仿真跑在界面线程时,用 QTimer 每 16 毫秒推进一批步,不要每步都 update 场景。模块多时把一批步算完再统一刷新,否则 paint 会把 CPU 吃满。下面这段是常见接法,定时器间隔 16 毫秒,每次推进 5 步,具体步数按 dt 和实时倍率调。
m_timer = new QTimer(this); m_timer->setInterval(16); // 约 60 FPS 的刷新节奏 connect(m_timer, &QTimer::timeout, this, [this] { for (int i = 0; i < 5 && m_running; ++i) { m_running = m_engine.step(m_graph, m_order); } updateScope(); // 只刷新示波器/曲线,不整场景重绘 }); m_timer->start();逻辑是把计算批量和界面刷新分开,参数上 16 毫秒是刷新节奏,5 步是每帧推进量,二者乘积决定仿真速度。注意仿真线程如果另开,绝对不要在子线程里调 QGraphicsItem 的 setPos 或 update,Qt 的图元不是线程安全的;常见做法是子线程算数据,发信号回主线程再刷曲线。这个边界不划清,偶发崩溃很难查。
5. 避坑与排查:五个让仿真界面“看起来能跑”的坑
这类小程序最容易出现“界面能拖、线能连、一跑就露馅”的情况。下面五条是我拆同类工程时反复遇到的,按现象、原因、解决写,排查时按顺序看,能省不少时间。每条都不是孤例,尤其是坐标和刷新相关的坑,改一处可能带出另一处,先用最小模块图复现再动代码。
5.1 连线总是点不中
现象:鼠标明明点在连线上,选不中,点模块边缘反而选中了模块。原因:ConnectionItem 的 shape 返回的是原始路径,线宽只有 2,命中区域太窄;同时模块的 Z 值高于连线,鼠标落点被模块抢走。解决:用 QPainterPathStroker 把命中宽度加到 10,连线 Z 值设 -1,模块端口区域单独提高命中优先级。排查时可以先临时把线宽改成 10 看能不能选中,能选中就说明是命中区问题,不是坐标问题。
5.2 仿真结果不刷新
现象:引擎在跑,时间在走,曲线不动。原因:常见是 updateScope 里只改了内部 vector,没有调用 QGraphicsItem::update 或图表控件的 replot;也可能是定时器在跑但 m_running 被某处置 false,循环没进。解决:先在 step 后打日志看输出值有没有变,值变了再查刷新调用;图表控件要确认数据追加后触发了重绘。注意不要在主线程里 sleep 等仿真,界面会假死,看起来像不刷新。
5.3 保存后重开连线错位
现象:保存前连线端点精准落在端口上,重开后偏几像素,放大后更明显。原因:保存时存了连线的场景坐标,没有存源/目标模块 id 和端口索引;重开时模块位置按布局重算,连线还按旧坐标画。解决:序列化只存模块 id、端口索引和模块位置,连线端点重开时用端口场景坐标重新算。参数上,模块位置存场景坐标,不存视图坐标;如果画布有缩放,保存前不要用视图坐标反算。
5.4 循环依赖导致死循环
现象:点运行后界面卡死,或者拓扑排序一直不返回。原因:模块 A 输出连到 B,B 输出又连回 A,Kahn 算法入度减不到 0,队列空了但还有节点;如果代码里用 while 等所有节点出队,就会死循环。解决:拓扑排序返回空结果时立即停止仿真,弹提示并高亮参与环的模块;不要用递归 DFS 做排序,环检测和排序混在一起更难排查。排查时先只留两个模块加一条反向线,能稳定复现再查连接表。
5.5 步长改小反而更卡
现象:dt 从 0.01 改到 0.001,仿真精度没感觉提升,界面先卡了。原因:固定步长下步数按 1/dt 增长,每步都刷界面时绘制次数翻十倍;模块多时拓扑排序和 QHash 查找也会放大。解决:计算和刷新解耦,定时器每帧推进固定批次数,不要每步 update;dt 按被积模块的时间常数选,不要盲目追小。参数上,先保证 16 毫秒内能算完一批,再谈精度。
6. 进阶验证:导出 CSV 并与手算结果对表
界面跑通后,我习惯先做一个最小验证:Gain 输入阶跃,输出应该是输入乘增益;Integrator 输入常数,输出应该是线性上升。把每步时间、模块输出导成 CSV,和手算对三五个点,比盯着曲线看靠谱。下面这段导出代码放在仿真引擎里,每步追加一行,参数上只导关心的端口,避免文件太大。
void exportCsv(const QString& path, const QVector<QVector<double>>& rows) { QFile f(path); if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) return; QTextStream out(&f); out << "t,module,port,value\n"; for (const auto& r : rows) { out << r[0] << "," << r[1] << "," << r[2] << "," << r[3] << "\n"; } }对表时看三个东西:稳态值、上升斜率、第一拍输出。稳态值对不上,多半是增益或求和端口符号错;斜率对不上,查 dt 和积分器初值;第一拍对不上,查输入端口默认值和拓扑顺序。容差按 double 给 1e-6 起步,涉及积分给 1e-3。血泪经验是别用界面显示的曲线当唯一证据,导出 CSV 后用脚本对一遍,能少走很多“看起来对了”的弯路。希望帮到你。
本文还有配套的精品资源,点击获取