用Qt+QCustomPlot打造PID调试上位机,实时曲线让调参一目了然

📅 发布时间:2026/8/31 3:51:31
用Qt+QCustomPlot打造PID调试上位机,实时曲线让调参一目了然 简介这是一份面向自动化控制初学者与嵌入式/工业软件开发者的Qt PID可视化调试工具解决PID参数整定抽象难懂、缺乏实时反馈的问题适用于课程实验、小型运动控制仿真及算法验证场景。资源包共7个文件7KB含2个核心头文件.h与实现文件.cpp封装PID算法逻辑1个.ui界面文件定义控件布局1个.pro工程配置文件保障跨平台编译另含1个.def.h宏定义头及.user用户配置文件结构简洁、模块职责清晰。已有1456人学习下载可直接编译运行无需额外依赖。读者可立即获得四组并行PID曲线动态对比功能通过滑块实时调节P/I/D参数并观察响应曲线变化直观理解比例超调、积分消除稳态误差、微分抑制振荡等核心机制同时掌握Qt多线程处理PID计算、QGraphicsScene绘图与信号槽联动的典型工程实践。 调过PID的人都有过这种体验程序一跑串口助手哗哗往外刷数值一屏一屏的数字在终端里滚。眼睛跟得上脑子跟不上特别是一边看数字一边判断系统有没有收敛、有没有振荡基本靠猜。我最早调一个电机速度环的时候就是一边盯着串口打印的“目标值 实际值”一边拿计算器算误差连续调了两天也没弄明白为什么转速总在目标值附近来回晃。后来我想明白一件事真正缺的不是更快的打印速度而是一条能画出动态过程的曲线。所以我用Qt写了一个PID调试上位机下位机实时上报控制数据上位机把目标值、实际值、输出量以曲线形式画出来之后每次调整Kp、Ki、Kd我都能直接看到响应曲线的形状变化调参效率完全不是一个量级。这篇文章我把这个工具的架构、绘图选型、核心实现和PID调试方法完整梳理一遍适合正在做电机控制、温控、平衡车这类嵌入式PID调试又不想每次开Matlab/Simulink需要一个轻量上位机的开发者参考。哪怕你只是想把串口助手换成带波形显示的工具这里面的思路也能直接搬。1. 为什么调PID必须配实时曲线图1.1 光看串口数字真的看不出系统响应很多人一开始调试PID下意识就是用串口助手打印数据程序里写一句printf(sp%f fb%f out%f\n, ...)然后把波特率提到115200以为数据刷新够快就能看清趋势。实际跑起来根本不是那么回事一条速度阶跃响应整个过程可能就一两秒串口里滚过去几十行数字从“1000 850 900”变成“1000 980 1020”中间到底经过了几个振荡周期超调量峰值是多少稳态误差有没有收敛光靠眼睛根本捕捉不到。更麻烦的是数值打印只能反映某个时刻的瞬时状态它没有“过程感”。比如系统在等幅振荡你看到一串数字一会高一会低但很难判断这个振荡是在收敛还是在发散更难判断是积分项引起的低频往复还是比例项太大导致的临界振荡。你需要在脑子里把几十行数字重建成一条曲线这件事人类不擅长但电脑画出来只需要一帧。1.2 一条曲线抵得上十次串口拷贝曲线图解决的不仅仅是“看得见”它把PID调参这个从“试”变成“看”的过程。你加一个稍微大一点的Kp曲线从平滑上升变成带一个明显超调的尖峰你把Ki加了一倍曲线尾部出现了一个缓慢的起伏周期。这些变化如果写进调试笔记可能要用一大段文字描述但曲线图上就是一眼的事情。我在实际使用中体会到曲线图至少要画三条线目标值、实际值、控制输出。目标值用来标记参考输入的变化时刻实际值反映系统响应控制输出能看出执行机构是否饱和、是否在频繁抖动。很多人只画实际值不画输出量结果调Kd的时候只看到高频毛刺却不知道那是微分项放大噪声导致输出量在剧烈跳变。三条线放在同一张图上很多参数问题的特征是一眼就能锁定的。2. 上位机整体架构串口、协议、数据链路怎么分层2.1 硬件链路与软件分层这个工具典型的使用场景是下位机是一块STM32或者其它MCUPID控制算法在下位机里跑每隔固定周期比如1ms、5ms采样一次反馈值并计算控制量。上位机通过串口与下位机通信把目标值、反馈值、输出量周期上报上来再在界面上画曲线。软件上我分了四层串口通信层负责打开、关闭、读写串口使用QSerialPort处理字节流收发协议解析层把串口里读到的原始字节按照约定的帧格式解析成结构体数据数据缓存层把解析出来的数据按时间顺序存起来供绘图显示使用界面显示层定时从缓存取数据刷新曲线同时提供参数下发按钮这个分层最大的好处是各层可以独立调试。协议解析层可以先用一个离线数据文件测试不依赖真实串口绘图显示层的数据来源可以换成仿真数据方便在没有硬件的时候开发界面。2.2 数据帧协议能用二进制帧就别用字符串我看到市面上很多调试工具下位机上报数据用的是文本协议比如sp:1000,fb:980,out:200\n这样的字符串。文本协议好处是串口调试助手直接可读但缺点是解析麻烦、长度不固定、效率偏低。对于PID调试这种周期性、高频上报的场景我更推荐用二进制帧。我用的帧格式比较简单字段长度说明帧头2字节固定为0xAA 0x55帧类型1字节0x01代表数据帧0x02代表参数设置帧数据长度2字节数据区字节数小端序数据区N字节数据帧时为目标值、反馈值、输出量各4字节float校验2字节对帧头之后到数据区结束做CRC16下位机上报一条数据帧数据区是12字节加上帧头和校验总共19字节。假如控制周期是5ms那每秒上报的数据量是200帧×19字节3800字节对于115200波特率来说完全不是问题。Qt端解析的时候只依赖两个机制找帧头、等长度够了再切帧。我的伪代码如下void parseBuffer(QByteArray buf) { int idx 0; while ((idx buf.indexOf(\xAA\x55, idx)) ! -1) { if (buf.size() - idx 5) { // 帧头后面的类型和长度还不足等下一批数据 break; } quint16 len static_castquint8(buf.at(idx 3)) | (static_castquint8(buf.at(idx 4)) 8); if (buf.size() - idx len 7) { // 一帧还没凑齐继续等 break; } // 校验通过后解析数据区 parseDataFrame(buf.constData() idx 5, len); idx len 7; } buf.remove(0, idx); }这里有一个关键细节每次解析完把已经消费掉的字节从缓冲头部移除这样缓冲不会无限膨胀。如果串口数据偶尔丢了一个字节帧头查找会自动跳过垃圾字节重新同步不会影响后续帧的解析。2.3 线程边界串口读取和UI刷新不能互相卡Qt里QSerialPort用起来很顺手但有一个典型误区数据量一大直接在readyRead信号槽里做繁重处理界面会卡住。原因是QSerialPort对象默认属于创建它的线程readyRead信号在事件循环中触发槽函数里干太久UI线程就被阻塞了。PID调试场景的数据量不算大正常情况下在主线程直接读也没有问题。但如果下位机上报频率很高或者你打算在解析层做滤波、统计这类计算稳妥的做法是把串口读取和协议解析放到一个独立线程里解析完成后通过信号把整理好的结构体发给主线程主线程只负责把数据追加到缓存并触发重绘。我在实现时用的是QThread加moveToThread的方式工作线程里持有一个QSerialPort解析出来的数据用队列连接发到主线程。这样即使串口突然涌进来一大包数据UI线程也不会卡顿。另外绘图刷新用QTimer定时触发每隔50ms取一次缓存里的数据这样把数据接收和绘制解耦曲线刷新更均匀。3. Qt绘图组件三选一QCustomPlot、Qt Charts还是Qwt3.1 三款组件定位差别Qt里画曲线绕不开三个选择QCustomPlot、Qt Charts、Qwt。我最初把三个都试了一遍先直接说结论如果你跟我一样只是要一个实时刷新、能缩放拖拽、性能不拉胯的曲线优先选QCustomPlot。对比项QCustomPlotQt ChartsQwt集成方式工程里加两个文件轻量模块化.pro里加QT charts库文件编译部署稍重文档和示例官网文档详细示例丰富Qt官方文档齐全示例多但较旧实时性能好自带自适应采样数据量大时表现一般一般交互功能拖拽、缩放、选区内置需要自己写很多交互交互较少许可GPLv3或商业许可GPLv3或商业许可LGPL对闭源友好与Qt6兼容性没问题Qt6.2可用6.2可用但配置稍麻烦Qt Charts的优点是它是Qt官方模块和Qt生态整合得最好想要漂亮的默认主题、动画效果代码很省。但它的底层是QGraphicsView体系当曲线点数达到几万时平移和缩放会有明显掉帧而且绘制风格偏“报表”对工控调试这种重视极简和性能的场景反而不太合适。Qwt是老牌库功能很全但API风格比较陈旧文档偏少和现代Qt版本的兼容性也时有小坑。如果你在维护老项目或者有闭源商用需求Qwt值得考虑如果你是新写一个调试工具我建议别在Qwt上花时间。3.2 我这边的选型理由与代价我最终选了QCustomPlot原因很直接第一它把qcustomplot.h和qcustomplot.cpp两个文件扔进工程就能用不需要额外编译库第二它提供了replot、addGraph、setData这些极简API写实时曲线基本就是围绕几个方法转学习成本低第三它的性能优化做得不错数据量几千个点的时候平滑滚动毫无压力。代价当然也有。QCustomPlot的开源版本是GPLv3如果你要做闭源商业软件需要购买商业授权。对于我个人调试工具、学习项目这种场景GPLv3完全够用。另外QCustomPlot虽然功能强大但一些高级绘制效果比如渐变填充、复杂多轴需要自己组合API不会像Qt Charts那样开箱即用。如果你还没把Qt环境搞定这里顺便说一句直接用Qt 5.15.2或者Qt 6.5安装的时候把需要的模块勾上官方在线安装器用国内镜像加速一次性装好省得后面反复折腾编译环境。版本太老比如Qt 5.9以下跑新版QCustomPlot可能会有兼容问题没必要自找麻烦。4. 曲线图核心实现初始化、刷新、自动缩放与暂停4.1 初始化配色、坐标轴、交互QCustomPlot的初始化非常简单我是在界面构造函数里做的。三条曲线我分别用了三种颜色目标值黄色、实际值绿色、输出量红色。这样一眼就能区分参考输入、系统反馈和执行输出。// 三条曲线目标值、实际值、输出量 customPlot-addGraph(); customPlot-addGraph(); customPlot-addGraph(); customPlot-graph(0)-setPen(QPen(QColor(255, 193, 7))); // 目标值暖黄 customPlot-graph(1)-setPen(QPen(QColor(76, 175, 80))); // 实际值绿色 customPlot-graph(2)-setPen(QPen(QColor(244, 67, 54))); // 输出量红色 customPlot-xAxis-setLabel(时间 (s)); customPlot-yAxis-setLabel(数值); customPlot-legend-setVisible(true); customPlot-graph(0)-setName(目标值); customPlot-graph(1)-setName(实际值); customPlot-graph(2)-setName(输出量); // 开启拖拽与缩放 customPlot-setInteractions(QCP::iRangeDrag | QCP::iRangeZoom);QCP::iRangeDrag和QCP::iRangeZoom必须打开否则调试的时候想放大看某一小段曲线根本做不到。实际调试中缩放拖拽比自动范围常用得多因为有时候你需要仔细看阶跃响应那几百毫秒里的细节整体波形反而不重要。4.2 数据刷新定时器合并重绘很多人第一次用QCustomPlot恨不得每收到一帧串口数据就调用一次replot结果就是界面刷新频率跟数据频率绑在一起CPU占用飚高曲线还一闪一闪的。正确做法是数据持续进缓存UI用一个固定频率的定时器去取比如50ms刷新一次也就是20FPS肉眼看起来已经很流畅了。QTimer *timer new QTimer(this); timer-setInterval(50); connect(timer, QTimer::timeout, this, MainWindow::updatePlot); timer-start();更新曲线的时候从缓存里取出最新的N个点一次性setData给曲线。QCustomPlot的setData会把整个数据拷贝进去如果你的缓存有几十万点还全部传进去性能会崩。我的做法是只取窗口中可见的数据点比如只取最近3000个点。void MainWindow::updatePlot() { const int winSize 3000; int startIdx qMax(0, dataKeys.size() - winSize); int count dataKeys.size() - startIdx; if (count 0) return; QVectordouble keys(count), sp(count), fb(count), out(count); for (int i 0; i count; i) { int idx startIdx i; keys[i] dataKeys.at(idx); sp[i] dataSp.at(idx); fb[i] dataFb.at(idx); out[i] dataOut.at(idx); } customPlot-graph(0)-setData(keys, sp); customPlot-graph(1)-setData(keys, fb); customPlot-graph(2)-setData(keys, out); // 横轴跟随最新数据滚动窗口固定为10秒 double lastKey keys.last(); customPlot-xAxis-setRange(lastKey - 10.0, lastKey 0.5); // 纵轴范围手动计算保证三条线都在视野内 double yMin sp[0], yMax sp[0]; for (const auto v : {sp, fb, out}) { for (double val : v) { yMin qMin(yMin, val); yMax qMax(yMax, val); } } if (yMax - yMin 1e-6) { yMin - 1.0; yMax 1.0; } double margin (yMax - yMin) * 0.1; customPlot-yAxis-setRange(yMin - margin, yMax margin); customPlot-replot(QCustomPlot::rpQueuedReplot); }这里有一个我在实际开发中踩过的坑如果直接用customPlot-rescaleAxes()它会把所有历史数据都纳入范围导致你只关心最近10秒的波形但纵轴范围被十分钟前的毛刺拉得很大当前曲线被压成一条扁平的线。所以手动根据当前窗口内的数据算纵轴范围是最稳妥的。4.3 自动缩放与滚动窗口上面的代码里我已经实现了滚动窗口横轴固定显示最近10秒lastKey - 10.0到lastKey 0.5。这样每次曲线到达右边缘整条曲线会自动向左移动看起来像示波器一样滚动刷新。如果你不想用滚动窗口而是想看从启动到现在全部的历史波形那就不需要设置固定窗口直接把x轴范围设成0到lastKey然后等数据量大了再用拖拽和缩放去看细节。两种模式我建议都保留界面上放一个“自动滚动”复选框切换滚动和全局两种视图是最舒服的调试方式。4.4 暂停与单步调试PID时的关键功能这个功能是我在调试过程中发现必需的。曲线滚动的时候如果系统恰好出现一次阶跃响应你想看清楚超调量但曲线一直在动鼠标根本点不准。我在界面上加了一个“暂停/继续”按钮暂停时停止调用updatePlot里的数据追加但串口数据继续接收只是界面停在当前画面。这样我可以慢慢分析曲线形态测量超调量和调节时间。更进一步我加了一个“保持最新”模式暂停状态下如果点“继续”直接跳到最新数据停止的位置继续刷新。这样既不会丢失暂停期间的数据也不会让曲线从暂停点继续往下画导致时间轴错乱。5. 从曲线形态反推PID参数问题我的整定流程5.1 曲线特征与参数症状对照工具本身不会帮你调PID但曲线图能把问题暴露得非常清楚。我把常见曲线现象和可能的参数问题整理成了一张表每次调参的时候对着它排查能少走很多弯路曲线现象典型参数问题处理方向上升过程快顶部出现明显超调之后来回振荡多次才收敛Kp过大减小Kp曲线缓慢爬升始终达不到目标值稳态误差明显积分作用不足增大Ki或检查是否缺积分项系统在目标值附近做低频往复摆动周期较长Ki过大减小Ki反馈值上叠加高频毛刺输出量剧烈抖动Kd过大微分放大了噪声减小Kd或对反馈做低通滤波有超调但回落缓慢系统“软绵绵”的Kd不足缺少阻尼增大Kd输出量长时间打在上限或下限曲线始终追不上目标执行器饱和或参数差太远先检查执行机构和控制周期再调参数这张表不是万能公式但能作为排查起点。最关键的一点是每次只改一个参数然后让曲线稳定下来再判断。如果同时动Kp和Ki曲线变了但你根本不知道是哪个参数引起的。5.2 试凑法示例一个电机速度环的调整记录我之前调一个直流电机的速度环目标转速是1000RPM初始参数是Kp0.5Ki0Kd0。曲线显示转速缓慢上升到820左右就卡住不动了离目标差一大截典型的比例控制稳态误差。这个阶段我唯一做的就是逐步加大Kp每次加0.2观察超调。当Kp加到2.5时转速勉强接近目标但出现了明显的超调和两次振荡。然后加入积分项。我先给Ki设了一个很小的值0.005稳态误差逐渐消失但转速到达目标附近时开始有低频往复摆动的趋势。我把Ki降到0.002往复摆动基本消失稳态精度也满足了要求。最后加微分项。此时系统有小幅超调我设Kd0.02超调被削掉大半但反馈信号上出现了轻微的高频毛刺。我把Kd降到0.01毛刺基本消失响应曲线干净利落。这一轮试凑法走下来最终参数是Kp2.0Ki0.002Kd0.01。整个过程如果不看曲线单靠串口数字我估计至少要花三倍时间而且很难有把握说振荡是哪个参数引起的。有了曲线以后每次改参数后的变化都清清楚楚调试笔记也变成了“曲线截图参数”的形式复盘方便得多。除了试凑法如果你想系统一点可以先用Ziegler-Nichols临界比例度法找到一组初始参数再基于曲线微调。方法很简单把Ki和Kd设为0只保留Kp不断增大直到系统出现等幅振荡记录此时的临界增益Ku和振荡周期Tu然后按经典公式算出初始参数控制器类型KpKiKdP0.5 * Ku--PI0.45 * KuKp / (0.85 * Tu)-PID0.6 * KuKp / (0.5 * Tu)Kp * 0.125 * TuZ-N法给出的参数通常偏激进曲线很可能有比较大的超调但作为初值再手动微调比从零开始试要快很多。我最常用的组合就是“Z-N初值试凑法微调曲线验证”。6. 实操中容易踩的坑丢帧、卡顿与坐标轴错乱6.1 串口数据错位与再同步用二进制协议最怕的就是丢帧。一旦中途丢了一个字节后续所有帧的解析都会错位画出来的曲线会出现一个巨大的毛刺或者完全错乱。我一开始没有做帧头重同步机制结果下位机上电瞬间的乱码直接导致曲线第一段彻底乱掉。解决办法就是我在第二节提到的每次解析都通过indexOf找帧头而不是用一个固定的偏移量累加。这样即使中间丢了一帧只要下一帧的帧头出现解析逻辑就能自动恢复正常。另外下位机上报数据时建议在帧头前加一个固定前导字节序列我用的0xAA 0x55并且帧内带上采样序号上位机发现序号不连续时可以主动请求下位机重新同步或者至少做一次统计告警。6.2 replot频繁触发导致界面卡顿这是用QCustomPlot最容易犯的毛病。我刚开始开发时把replot直接塞到了串口每帧数据的槽函数里当时下位机上报频率跑到了200Hz界面一秒要重绘200次CPU占用直接飙到70%界面操作明显变卡。解决思路就一句话重绘频率和数据处理频率解耦。数据该收收该解析解析但是重绘统一交给50ms的定时器。即使定时器触发时数据还没攒够一帧也没关系画面不会闪烁因为QCustomPlot的replot(QCustomPlot::rpQueuedReplot)会把同一事件循环内的多次重绘请求合并成一次。另外如果曲线点数特别多可以开启customPlot-graph(0)-setAdaptiveSampling(true)QCustomPlot会自动对密集区域的点做抽稀绘制性能提升非常明显。6.3 rescaleAxes的隐式行为前面我提过手动计算纵轴范围的问题这里再展开说。QCustomPlot里rescaleAxes()默认会让所有可见的graph都参与范围计算听起来很美好但实际调试中它会把历史数据里的极端值也纳入范围。比如系统在启动瞬间有一个很大的超调尖峰之后进入稳定运行这个尖峰就会一直影响纵轴范围导致后续正常的曲线被压扁到屏幕中间一条缝里。更坑的是如果你多个graph使用不同的valueAxisrescaleAxes()会分别缩放每个轴但如果你只调用graph(1)-rescaleValueAxis()它只针对那一条曲线所在的轴进行缩放其他曲线可能直接飞出视野。我建议自己维护一个窗口内的数据极值显式地设置yAxis范围这样逻辑最清楚也能兼顾多条曲线。6.4 长时间运行内存增长调试工具开着跑一晚上曲线数据缓存如果不限制内存迟早被吃光。我在缓存层做的是只保留最近50000个采样点超过之后就删掉最旧的。这个操作在QVector里头部删除性能不理想所以我改用了一个固定大小环形缓冲区或者简单一点在追加数据时检查dataKeys.size()如果超过上限先remove(0, count - maxCount)再追加。另外QCustomPlot的graph内部也会维护自己的数据如果你每次都把所有历史数据setData进去即使缓存层限制了内存graph内部照样会膨胀。所以绘图时只把窗口中可见的数据传给它是最有效的内存控制方法。6.5 参数下发不能漏界面端的数据闭环最后再强调一个容易忽略的点调试工具不只是看曲线还得能改参数。如果每次调参都要重新编译下位机程序那曲线的价值就大打折扣。我在界面上放了一排QDoubleSpinBox分别对应Kp、Ki、Kd点击“下发参数”按钮后上位机按照第二节定义的参数设置帧格式把三个float打包发往下位机。下位机收到后更新控制参数并回一帧应答上位机在状态栏显示“参数已更新”。有了这个闭环整个调试流程就舒服了下位机不断上报曲线我在上位机改参数、下发、观察曲线几十秒就能完成一次完整的参数试凑。再配合曲线截图功能每一次试验都能留下记录这比传统拿本子记参数的方式靠谱得多。我自己用下来的体会是调试工具的界面再漂亮也不如时间轴和暂停功能实用。曲线能暂停下来仔细看比什么都重要参数下发能随时改比什么都重要。先把这两个功能做好剩下的都是锦上添花。这套思路也不只限于PID调试。你把数据源换成训练日志、传感器采集、步进电机位置跟踪绘图组件这套东西完全可以直接复用。曲线图本质上就是一个“时间序列可视化”的容器换掉数据源和协议解析层就是一个新的调试工具。如果你手头也有类似的调参需求不妨照着这个框架自己搭一个用起来比任何现成软件都顺手。本文还有配套的精品资源点击获取