简介:一款基于Qt 4.8.5编写的Simulink风格仿真界面小程序,在Visual Studio 2010环境下构建,适合C++ GUI开发、控制系统仿真或Qt图形框架学习者。工程用QGraphicsView、Scene与Item搭建可拖放编辑的仿真画布,配合绘图控件呈现闭环控制回路,展示数值计算与界面交互结合的可行方案。包内共122个文件,以26个cpp源码、18个h头文件、6个ui界面文件为主体,含sln/vcxproj工程配置、编译日志及示例图片,整体约21.78MB,源码与配置齐全,便于直接打开研读。当前已有3631人学习下载,读者可从中拆解Qt图形场景的事件处理机制、仿真元件自定义绘制、VS2010下Qt工程组织方式,对理解类似Simulink建模工具实现原理、积累实战经验都有直接帮助。
1. 拖拽连线跑仿真的桌面工具:Qt重写Simulink工作台,到底在解决什么问题
说到仿真工作台,大多数工程师第一反应是Simulink。而这个用Qt加C++写成的桌面小程序,把Simulink那套“拖模块、拉线、点运行”的仿真工作方式搬到了轻量级桌面端,不依赖Matlab运行时,双击打开就能拖拽搭模型。我拿到源码后,先搭了一个正弦源加增益再接示波器的最小链路,从拖模块到看到波形图只花了几分钟。它的定位很明确:给做控制系统仿真、信号处理验证的工程师一个轻量编辑环境,也顺带给想在Qt里实现节点式编辑器与数据流仿真的开发者当参考骨架。源码规模不大,但图形界面与仿真引擎边界分得很干净,改造起来不会牵一发动全身——它没有停在“画得像Simulink”的层面,而是真的把数据流跑通了,这是它值得拆开讲的原因。
2. 技术选型与工程骨架:为什么C++加Qt Widgets比QML更适合这张画布
2.1 选型逻辑:Widgets加Graphics View,而不是QML
节点式编辑器常见的实现路径有两条:一条在QML里自己堆Canvas,另一条在Qt Widgets上使用Graphics View Framework。这个项目选的是后者,理由很实际。仿真模块的evaluate在C++侧直接读写double数组,信号槽只负责界面通知,整个数据链路不跨语言、不做序列化,性能和可调试性都占优势。Graphics View那套QGraphicsScene/QGraphicsItem自带场景坐标变换与事件分发,缩放、平移、框选这类画布操作不用自己造轮子,而这恰好是QML方案里最费劲的部分。
如果执意用QML,也不是不行,动画和样式会漂亮很多,但节点式编辑器本质是强交互的桌面工具,图元数量、命中检测、拖拽性能这些指标的优先级远高于皮肤效果。常见做法是把每个模块映射成一个QML组件,用Canvas自己画连线,样式灵活,但事件处理和坐标映射都要从头写。对比下来,Widgets方案在这个场景下的维护成本更低,这也是很多开源节点编辑器宁可放弃QML的原因。
另一个候选方案是直接引入现成的节点编辑器库,但这类库通常自带数据模型和序列化格式。这个项目需要把节点注册表、仿真调度、波形存储都绑在SimNode基类上,用现成库反而要写适配层,最后适配层比业务代码还难维护。所以这里选择自己实现一套轻量Item体系,实测几千个Item在场景里拖拽时帧率依然稳定,说明这一层选型没有踩性能坑。
2.2 工程目录:每个目录只负责一件明确的事
源码的目录结构是看这类项目最先看的部分,因为它直接反映作者对职责边界的理解。我把核心目录整理了出来:
simulink-like-tool/ ├── CMakeLists.txt # 顶层构建脚本,依赖Qt6套件 ├── src/ │ ├── app/ # 程序入口、主窗口、菜单工具栏 │ ├── core/ # 数据模型:工程文件、节点注册表、参数表 │ ├── editor/ # 图形编辑器:NodeItem、ConnectionItem、Scene、View │ ├── simulator/ # 仿真引擎:拓扑排序、步进调度、缓存管理 │ ├── blocks/ # 内置模块库:源、运算、存储、显示四类 │ └── widgets/ # 通用界面件:属性面板、波形控件 ├── examples/ # 示例工程,双击可加载 └── tests/ # 引擎层单元测试,不依赖GUIcore与editor、simulator三者是单向依赖。core放纯数据结构,editor从core读模型并渲染,simulator运行时不碰editor,只吃模型快照。这样以后把仿真引擎抽成命令行工具或后台服务,只需要替换app层。app层就是常说的“很薄的一层”,它只负责把三个模块拼起来。注意blocks目录放在src下而不是examples下,说明内置模块是框架功能的一部分,不是示例代码,二次开发新增模块时参照这个目录里的写法即可。
目录与职责的对应关系可以列成一张表,后续改东西时查表定位很快:
| 目录 | 关键类 | 职责 |
|---|---|---|
| core | ProjectModel、NodeRegistry | 节点类型注册、参数表、模型快照 |
| editor | EditorScene、NodeItem、ConnectionItem | 画布交互、图元绘制、拖放处理 |
| simulator | Simulator、Solver | 拓扑排序、步进调度、波形缓存 |
| blocks | SineSource、GainNode、ScopeNode | 内置模块的算法实现 |
| widgets | PropertyPanel、ScopeWidget | 参数编辑、波形显示 |
这份结构传达的信息比README更准确:新增一个模块,要动src/blocks;改交互,动src/editor;改求解算法,动src/simulator。模块之间几乎不需要交叉修改,这是长期维护时最有价值的一点。
2.3 运行时架构:场景、视图、引擎三方的协作时序
用户看到的是View,View里包着一个Scene,Scene里摆着NodeItem和ConnectionItem。绘制和交互都在这一层完成,但计算不在这一层。我梳理了完整的运行时协作流程,拆分后大概是这样的:
用户在模块库面板选择模块,拖到画布。View收到拖放事件后,去core层的注册表按类型ID创建对应的SimNode,并把它加入Scene,这时界面出现一个节点,模型里也登记了它。拖动节点时,NodeItem会发出几何变化信号,editor层把这个变化同步到core的模型缓存,同时通知所有与它相连的ConnectionItem重算端点。点下运行后,app层把当前模型的节点列表、连线关系、参数表打包成一份快照交给simulator。simulator先用拓扑排序确定执行顺序,然后按固定步长循环调用每个节点的evaluate。计算过程中产生的波形数据写进一块环形缓冲,波形控件通过信号拿到新数据再重绘。
信号槽在这里的角色值得单独说。core与editor之间不应该互相直接调用,一旦Scene里的Item被删除,core可能还持有它的指针。这个项目统一约定:editor只通过信号把变化上报给core,core再广播数据变化,实例的所有权归core层统一管理。这套约定是项目里踩了几次悬空指针换来的经验,后面讲删除节点的雷区时我会再展开。
还有一个取舍:节点计算和界面刷新没有走后台线程,而是在界面线程里分时完成。常见做法是让simulator跑在QtConcurrent后台线程,算完一批再发信号刷新界面。这个项目为了保持逻辑简单,把仿真循环放在界面线程,步长足够小、节点数在几十个以内时不会有问题;如果以后要跑大规模模型,改造点就在simulator与显示之间的数据通道,这个边界已经留出来了。
3. 核心模块拆解:一条仿真数据从模块端口到示波器的完整链路
3.1 节点基类:把算法与外观解耦
仿真节点是这个系统的最小单元,它同时承担两件事:长得像什么样子,算什么结果。如果这两个职责混在一个类里,每加一个模块都要重写paint,代码很快失控。这个项目的SimNode基类把接口切得非常干净:
class SimNode : public QGraphicsObject { Q_OBJECT public: explicit SimNode(const QString& typeId, QGraphicsItem* parent = nullptr); virtual ~SimNode() = default; // 算法入口:仿真引擎每个步长调用一次 virtual void evaluate() = 0; // 声明端口:只描述方向、名字与数据类型,不持有数值 virtual QList<PortDef> portDefs() const = 0; // 参数读写,属性面板通过这两个接口操作 void setParam(const QString& key, const QVariant& value); QVariant param(const QString& key) const; // Qt图形Item标准接口 QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override; protected: QVector<double> inputBuf_; // 当前步的输入值,由连线写入 QVector<double> outputBuf_; // 当前步的计算结果,供下游读取 private: QString typeId_; QHash<QString, QVariant> params_; QList<PortDef> ports_; };这段代码是整个框架的枢纽。evaluate是纯虚函数,新模块只需要实现它和portDefs,外观由基类的paint统一绘制,这对保持画风一致很有用。inputBuf_和outputBuf_是double数组,因为在这个仿真场景里信号就是一维数值;如果要做多维信号,把buffer换成QVariantList或者自研的Signal类型即可,但接口不需要变。typeId_用来在注册表里找创建函数,这套工厂机制让模块库具备插件化能力。
端口布局做个简单说明:portDefs里声明了输入端口在节点左侧、输出在右侧,paint按照端口在列表中的序号从上到下排列,端口本身用子Item实现,节点移动时端口跟着走,不需要额外同步坐标。这也是为什么后面缩放出现问题时要回头检查坐标系的换算方式。
3.2 连线的创建:从端口命中到路径生成
连线是数据流的通道。在UI上,用户从输出端口拖到输入端口,程序要做三件事:命中检测、方向校验、生成路径。命中检测最容易出问题的是热区太小,这个项目里的PortItem重写了shape(),返回一个比图形外扩6个像素的QPainterPath,这样鼠标不用精确点中那一个圆点也能粘连。方向校验是防止把线接到输出端上,事件处理里先拿到两端的PortItem,再对比PortDef的方向,失败就取消本次操作。
路径生成最省事的做法是直接用QGraphicsPathItem,起点到终点画一条贝塞尔曲线,曲线的控制点根据两个端口的位置计算,保证水平拖拽时曲线平滑:
QPainterPath ConnectionItem::makePath(const QPointF& from, const QPointF& to) const { QPainterPath path(from); qreal dx = qMax(40.0, qAbs(to.x() - from.x()) / 2.0); path.cubicTo(from.x() + dx, from.y(), to.x() - dx, to.y(), to.x(), to.y()); return path; }dx的作用很关键:它让曲线在起点和终点处保持水平切线,视觉上更接近Simulink连线的风格。dx小于40时强制取40,避免两个端口贴很近时曲线出现怪异的回折。如果以后要支持直角连线,只需要把cubicTo换成lineTo加一个转折点计算函数,不影响数据通路。
连线的数据侧逻辑简单但有讲究:每根ConnectionItem都持有源PortItem和目的PortItem的指针,仿真引擎在步进时不会遍历连线去找数据,而是由上游写完outputBuf_后,直接通知所有下游节点把数据拷进自己的inputBuf_。这个“推送式”数据流跟Simulink的拉取式不同,但在这个同步调度框架里实现成本最低,也不会出现谁先算谁后算的歧义,因为执行顺序已经被拓扑排序固定好了。
3.3 仿真调度:拓扑排序加步进传播
仿真引擎的核心是调度逻辑,它决定了每个模块在每一步里按什么顺序执行。乱序执行会产生错误结果:比如增益模块引用了源模块的上一步输出,而源模块在增益之后才计算,那这一步的增益输出就是错的。这个项目采用Kahn算法做拓扑排序,先准备好执行顺序,再循环步进:
bool Simulator::prepare() { // 节点数为 n,构建入度表与邻接表 QMap<QString, int> indegree; QMultiMap<QString, QString> adj; for (const auto& conn : connections_) { adj.insert(conn.fromNode, conn.toNode); indegree[conn.toNode]++; } QQueue<QString> zero; for (auto node : nodes_) if (indegree[node] == 0) zero.enqueue(node); while (!zero.isEmpty()) { QString cur = zero.dequeue(); execOrder_.append(cur); for (auto next : adj.values(cur)) if (--indegree[next] == 0) zero.enqueue(next); } return execOrder_.size() == nodes_.size(); // 不相等说明有环 } void Simulator::step(double dt) { for (const auto& id : execOrder_) { SimNode* node = nodes_[id]; node->evaluate(); // 读 inputBuf_,算结果,写 outputBuf_ } }prepare返回false时,界面会弹框提示“模型存在循环依赖”,并且在画布上用红色高亮出构成环的那几根连线,这是调试代数环最直观的手段。step里的执行顺序与连线顺序无关,完全由提前排好的execOrder_决定。dt在这里是全局固定步长,所有源模块都按同一个dt推进,整张模型是同步离散系统。
步进传播还有个细节:evaluate执行过程中可能会读写同一份缓冲,如果直接用inputBuf_输出,再用它当输入,会造成数据覆盖。所以每个节点在evaluate开头会把inputBuf_拷贝一份快照,计算全程引用快照,这样即使节点自己有两个输入互相有耦合也不会出错。这个快照成本在几个double的规模下可以忽略,换来的是所有模块接口统一。
3.4 波形存储与示波器刷新
示波器模块比较特殊,它不参与数值传播,只把输入数据追加进自己的环形缓冲。环形缓冲的大小在创建模块时设置,默认存最近2048个点,超过后覆盖最旧数据。波形控件重写paintEvent,从缓冲里取出数据画折线,再画两条参考网格线。为了提高刷新效率,控件只在仿真步进推进后触发update(),而不是每次paint都重新读全量数据,界面线程在几十个模块时CPU占用可以压在个位数百分比。
数据链路全貌可以概括成这样:正弦源模块的evaluate根据当前时间t和频率算出值写入outputBuf_,连线把输出传给增益模块的inputBuf_,增益模块的evaluate将输入乘以系数再写自己的outputBuf_,示波器模块把输入追加到环形缓冲,波形控件重绘时画出最新一段曲线。这一整条链路里,图形Item只负责呈现和交互,数值传播全部发生在simulator的调度循环中,边界非常清晰。
4. 编译与首跑:从克隆代码到拖出第一个正弦波示波器画面
4.1 构建环境准备:Qt版本、CMake与三种编译路径
这个项目的依赖很克制。我整理了一份本地搭环境的版本组合:
| 工具 | 版本要求 | 说明 |
|---|---|---|
| Qt | 6.2及以上 | 只用Core、Gui、Widgets三个模块 |
| CMake | 3.16及以上 | 顶层CMakeLists用到了较新的Qt命令 |
| 编译器 | MSVC 2019+ / GCC 11+ | Windows下建议MSVC,Linux下GCC或Clang都可以 |
拿到源码后第一步是确认Qt安装路径,下面的命令以Linux下Qt 6.5.0为例:
git clone <仓库地址> sim-tool cd sim-tool cmake -S . -B build -DCMAKE_PREFIX_PATH=/opt/Qt/6.5.0/gcc_64 cmake --build build -j8 ./build/bin/sim_toolCMAKE_PREFIX_PATH指向Qt的安装前缀,CMake靠它找到Qt6Config.cmake。路径写错最常见的报错是找不到Qt6的配置文件,这时候把路径改成自己机器上的Qt安装目录再重新配置即可。Windows下用Visual Studio打开项目有两种方式:一种是在VS里选择“打开CMake工程”并指定CMakeLists.txt,另一种是先执行cmake -G "Visual Studio 17 2022" -A x64生成sln再编译。两种方式本质都是调用同一份CMake脚本,区别只是生成器不同。
不想碰命令行的读者,直接用Qt Creator打开CMakeLists.txt,在Kit选择里挑配置好的Qt 6套件,点构建运行就能出来主窗口。如果本地同时装了Qt 5和Qt 6,常见翻车点是CMake自动找到了Qt 5导致MOC版本错乱。最稳妥的写法是在CMakeLists里显式find_package(Qt6 COMPONENTS Core Gui Widgets REQUIRED),并在命令行把CMAKE_PREFIX_PATH指到Qt 6的目录,避免靠环境变量去猜。
4.2 界面三件套:模块库、画布与属性面板
程序启动后主窗口分三块:左侧是模块库,中间是仿真画布,右侧是属性面板。模块库用一个QListWidget实现,每个条目身上挂着MIME类型,拖到画布时通过typeId找到对应的SimNode工厂函数创建实例。这个设计和Simulink的Library Browser思路一致,但实现轻量很多,模块以代码注册而不是以XML配置。
画布是自定义的EditorView,继承QGraphicsView之后重写了wheelEvent做滚轮缩放、重写dragEnterEvent和dropEvent接受模块库的拖放。中键拖动平移画布也是在这一层实现。属性面板是一个QTableView加一行一个参数项的简单实现,选中节点时editor层发出selectionChanged信号,面板显示该节点的参数表;双击参数单元格即可修改,修改结果通过setParam写回节点。
运行控制放在顶部工具栏:一个运行按钮、一个暂停按钮、一个步长输入框、一个仿真时长设置。设置的是全局固定步长,默认10毫秒,仿真时长默认10秒。点击运行按钮后,app层先调用simulator的setModel接口传入模型快照,然后执行prepare做拓扑排序,排序通过后启动一个QTimer,按时长把step循环拆成定时触发,每步推进一个dt。暂停只是停掉定时器,重新运行则从头开始。这段逻辑放在app层而不是simulator内部,好处是引擎本身无状态,单元测试可以直接调step,不用起界面。
4.3 第一个仿真工程的四步操作
我拿到源码后做了这样一个最小验证:正弦源模块、增益模块、示波器模块,三个节点连成一条链。操作流程拆开是四步:
第一步,从模块库的“源”分类里拖出SineSource,双击它,在属性面板把频率设为1Hz、幅值设为1。第二步,从“运算”分类拖出Gain,增益系数设为2。第三步,从“显示”分类拖出Scope,采样点数保持默认2048。第四步,从SineSource的右侧输出端口拖线到Gain的左侧输入端口,再从Gain输出拖到Scope输入,设置步长10ms、时长2s,点击运行。
运行后的现象是:Scope弹出一个独立小窗,画出一条从0开始、峰值2的完整正弦曲线,曲线随时间推进滚动更新。这个验证虽然简单,但它同时证明了拖放、连线命中、端口方向校验、拓扑排序、步进计算、波形刷新这一整条链路的正确性。如果这里任何一环断了,最小链路会立刻暴露问题。如果你拖完线点运行后波形窗口一片空白,优先检查连线是否真的连到了输入端——线悬空时引擎不会报错,只是数据没传进去。
5. 避坑与排查:缩放偏移、命中失灵、死循环与崩溃的五个翻车现场
5.1 缩放之后连线端点对不上端口
现象:把视图放大到150%再拖动节点,连线端点离开端口位置,画面上出现“线悬空”。
原因:ConnectionItem保存的端点坐标是在连接建立瞬间从端口mapToScene得到的,之后节点移动时端口坐标变了,但连线没有收到通知。缩放本身没有改变场景坐标,但它放大了视觉误差,让悬空看起来格外明显。
解决:让SimNode在itemChange事件里检测ItemPositionHasChanged,发送一个节点移动信号;ConnectionItem监听自己两端节点的移动信号,重新取两端端口的scene坐标并调用setPath。这里要注意在itemChange里不要直接改自己的pos,否则会循环触发,正确做法是把坐标变化发信号出去,由连线去重算。验证方式:缩放后拖拽节点,连线端点始终黏在端口中心不分离。
5.2 端口图形只有几个像素,鼠标点不准
现象:端口看起来是一个8像素直径的圆,鼠标要精确卡在圆心上才能拉开一条线,体验非常差。
原因:PortItem默认的boundingRect就是圆的边界,命中检测走shape(),Qt对QGraphicsEllipseItem的默认命中区域只有圆本身,没有为交互留余量。
解决:重写PortItem的shape()方法,返回一个把圆外扩6到8像素的QPainterPath:
QPainterPath PortItem::shape() const { QPainterPath p; p.addEllipse(boundingRect().adjusted(-6, -6, 6, 6)); return p; }调整之后鼠标在端口周围一小片区域都能触发拖线,误点率明显下降。同样的技巧可以迁移到NodeItem的选中热区上。验证方式:从端口外围大约一个光标宽度的位置起手拖线,能正常触发连线创建。
5.3 点运行后界面卡死,CPU占用拉满
现象:两个节点首尾相连形成环,点运行后程序直接无响应,任务管理器里进程CPU占用接近100%。
原因:早期版本没有做环检测,调度器在prepare里如果拓扑排序排不满所有节点,会陷入一个while循环等待空节点,或者把execOrder_留空后step里空转,界面线程被占满。
解决:在prepare里把拓扑排序返回false作为错误处理,界面弹框提示“模型存在循环依赖:A到B再到A”,同时把构成环的连线颜色改成红色。排查时也可以看调度日志:如果execOrder_的size小于nodes_的size,优先检查哪几个节点没被排进去,它们大概率就在环上。验证方式:构造两个节点互相连接的模型,点运行时弹出提示而不是卡死。
5.4 Visual Studio编译报MOC错误或找不到Qt6模块
现象:在VS里打开CMake工程编译,报错“Unknown module(s) in QT: core gui”,或者MOC生成的moc_xxx.cpp里的类名和目标类对不上。
原因:最常见的有两个来源。一是CMAKE_PREFIX_PATH没指向Qt 6目录,CMake沿系统PATH找到老版本Qt;二是增量编译时改了头文件里的Q_OBJECT宏,但依赖项没有触发重新运行MOC。
解决:在CMakeLists里显式写find_package(Qt6 ...),并把CMAKE_PREFIX_PATH用Qt自带的qt-cmake包装脚本设置;改了带Q_OBJECT的类之后,删掉build目录重新配置一次,MOC的问题九成能解决。这个“改头文件先清理build”的习惯,我后面验证新模块时会强制走一遍。验证方式:清理build目录后重新构建,MOC相关报错消失。
5.5 删除一个有连线的节点,程序立即崩溃
现象:选中节点按Delete,程序崩溃,调用栈指向某个ConnectionItem的paint函数。
原因:删除NodeItem时,相连的ConnectionItem仍然持有两个PortItem的指针,其中一个PortItem随父节点析构,ConnectionItem重绘时访问了悬空指针。这是节点式编辑器最容易踩的内存雷。
解决:删除操作不直接调用scene的removeItem,而是封装成一个removeNode流程:先拿到该节点所有连接的ConnectionItem列表,逐一从场景移除并delete,再删除NodeItem;同时让core层从模型里移除对应节点条目,保证scene、model两边一致。PortItem作为NodeItem的子Item,随父析构即可,不需要手动delete,但外部保存的裸指针必须在删除前清空。后来我检查每一处指针时,规定所有跨模块指针都只用于“查询”、不用于“持有”,配合模型层统一所有权,崩溃率显著下降。验证方式:连三根线的节点直接删除,程序不崩,剩余连线全部消失。
6. 二次开发指南:把你的自定义PID算法块接进这个框架
6.1 三步注册一个新模块
以最典型的PID控制器为例。第一步,在src/blocks下新建PidNode类,继承SimNode,实现portDefs声明两个输入(目标值、反馈值)和一个输出。第二步,实现evaluate,按位置式PID计算:
void PidNode::evaluate() { double target = inputBuf_[0]; double feedback = inputBuf_[1]; double err = target - feedback; integral_ += err * dt_; outputBuf_[0] = kp_ * err + ki_ * integral_ + kd_ * (err - lastErr_) / dt_; lastErr_ = err; }dt_通过setParam传入,kp_、ki_、kd_由属性面板设置。第三步,在模块注册表里登记构造工厂:
NodeRegistry::instance().registerBuilder("control/PID", [](QGraphicsItem* parent) -> SimNode* { return new PidNode(parent); });注册完成后,模块库面板会自动出现新条目,拖拽创建、连线、仿真都不需要再改其他代码。这就是插件式架构的好处:注册表加一个条目,整个编辑器自动感知。
6.2 从固定步长到变步长:改造的切入点
当前实现是全局固定步长,每个节点在step里自己按dt推进,这隐含了一个限制:连续系统模块只能用欧拉法近似,步长必须足够小才稳定。要支持变步长,我建议的改造方向是把Simulator的step循环改成一个求解器接口,让节点不再自己持有dt_,而是通过一个外部传入的SolverContext获取当前时间和当前步长,算法模块只做端到端的增量计算,收敛判断交给上层求解器。这个改动会动到simulator目录和所有内置模块,但正是当初把算法与外观解耦省下来的接口红利——evaluate的输入输出缓冲不用变,只换时间推进方式。
我最初写这类仿真器时,习惯把内部状态直接塞进Node类,等要接外部ODE求解器时发现接口完全反了,被迫重构了整个模块体系。从那以后,我每写一个仿真引擎,都会先定义数据所有权和求解边界,再写界面,这个顺序至少让我少走一半弯路。希望帮到你。
本文还有配套的精品资源,点击获取