1. 从“调参只能靠猜”到“参数曲线同屏可见”
去年年中我接了一个电机闭环控制的项目,用的是一块ARM Cortex-M7内核的板子,电机带增量式编码器,要求转速稳态误差在正负5转以内。板子跑起来不难,真正让我头疼的是调PID参数。每次改一组Kp、Ki、Kd,都要重新编译、烧录、看串口打印的波形,再打开MATLAB或者Excel画图,效率低到让人怀疑人生。
那阵子天天跟串口终端打交道,屏幕上全是密密麻麻的数字。转速目标值是1200转,实际值从900冲到1400再慢慢收敛,光看数字完全没法直观判断超调量、调节时间、稳态误差这些指标。我当时的想法特别朴素:要是能把参数曲线实时画出来,再做个旋钮边调边看效果,该多爽。
于是就有了这个项目——用Qt/C++手写一个PID实时调参可视化控件。不依赖Qwt、QCustomPlot这类第三方绘图库,波形区域、旋钮控件、参数面板全部自己绘制,PID运算也直接在控件内部实现,支持串口和共享内存两种方式对接真实设备数据,离线时还能用内置的仿真模型跑闭环。
从动手写到基本能用,前后花了大概两个周末。代码量不大,核心控件类不到1000行,但踩的坑不少。这篇文章把我整个设计思路、实现细节、调参经验和踩坑过程完整记录下来,想自己写调参工具的朋友可以直接参考。
2. PID闭环控制的关键概念与离散化实现
2.1 增量式PID比位置式更合适
在动手画界面之前,先得把PID运算这件事想清楚。PID控制器的连续形式是:
u(t) = Kp * e(t) + Ki * ∫e(t)dt + Kd * de(t)/dt
这个公式是教科书上的标准形式,但放在MCU或者上位机里,必须离散化。离散化有两种常见做法:位置式和增量式。
位置式PID直接计算执行机构的绝对位置,输出u(k)和过去所有误差的累积有关,一旦积分项出问题,输出可能跳变很大。增量式PID计算的是输出的增量Δu(k),只跟最近三次误差有关:
Δu(k) = Kp * [e(k) - e(k-1)] + Ki * e(k) + Kd * [e(k) - 2e(k-1) + e(k-2)]
实际输出u(k) = u(k-1) + Δu(k)。这种方式的好处是执行器只需要叠加增量,误动作影响小,而且不需要累加所有历史误差,积分饱和问题好处理。
我在控件里做的是通用PID计算模块,位置式和增量式都实现了,通过一个枚举类型切换。跟设备通信时按下发的是增量式的结果,因为我们的电机驱动板接收的就是PWM增量。
2.2 离散化公式里的时间基准
这里有个特别容易忽视的细节:Kd项和Ki项跟控制周期Ts是耦合的。同一个Kd值,Ts如果是1ms和10ms,微分作用完全不同。
我最初犯过这个错误,直接在代码里写死Kd=0.1,结果在仿真模型上表现很好,接到实际设备上剧烈振荡,检查了半天才发现是采样周期不一致导致的。
正确做法是把Ts显式放进离散化公式:
位置式:
- 比例项:Kp * e(k)
- 积分项:Ki * Ts * Σe(i)
- 微分项:Kd / Ts * [e(k) - e(k-1)]
增量式:
- Δu(k) = Kp * [e(k) - e(k-1)] + Ki * Ts * e(k) + Kd / Ts * [e(k) - 2e(k-1) + e(k-2)]
控件里我给PID参数配了一个配套的控制周期设置项,默认1ms,用户可以改。参数下发时把Ts也一并发给设备端,避免两边对不上。这是调参工具最容易被忽略、但实际影响最大的一个设计点。
2.3 积分限幅和微分滤波是必须的
光有基础公式还不够,真实现场里积分饱和和微分噪声能把人折磨疯。我做了一个二阶惯性环节做微分滤波,同时给积分项加了限幅。
积分限幅的实现很简单:
integral += Ki * Ts * error; if (integral > integralLimit) integral = integralLimit; if (integral < -integralLimit) integral = -integralLimit;微分滤波我用的是一阶低通:
filteredDerivative = alpha * derivative + (1 - alpha) * previousFilteredDerivative;alpha一般取0.1到0.3之间,太小反应慢,太大滤不掉噪声。实际调试中发现,加了滤波之后Kd能比原来调大50%而不振荡,效果非常明显。
3. 控件架构设计:先搭骨架再画皮肤
3.1 用QWidget还是QQuickPaintedItem
第一步要确定控件跑在什么载体上。我做了两个版本,一个基于QWidget,一个基于QQuickPaintedItem,后者是为了以后集成到QML界面里预留的。核心绘图代码两头共用,上层只是一个适配壳。
Qt的QWidget在Windows和Linux桌面环境下表现稳定,开发调试效率高,新手也好上手,所以我主推QWidget版本。控件的继承体系是这样:
class PidTuningWidget : public QWidget { Q_OBJECT public: explicit PidTuningWidget(QWidget *parent = nullptr); ~PidTuningWidget(); // 数据源配置 void setDataSource(DataSourceType type, const QVariantMap &config); void setControlPeriod(double periodMs); // PID参数设置与获取 void setPidParams(double kp, double ki, double kd); PidParams pidParams() const; // 运行控制 void start(); void stop(); void reset(); // 目标值设置 void setTargetValue(double target); signals: void pidParamsChanged(double kp, double ki, double kd); void targetValueChanged(double target); void actualValueUpdated(double actual); void dataSourceError(const QString &message); protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void wheelEvent(QWheelEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private: PidTuningWidgetPrivate *d; };接口设计上,数据源是独立于控件之外的抽象层。控件本身只负责三件事:接收目标值、接收实际值、显示PID参数调节结果。至于数据从哪来——串口、共享内存、还是内置仿真——都由外部设置。
3.2 代码模块划分
整个项目我分成了这么几块:
| 模块 | 文件 | 职责 |
|---|---|---|
| 主控件 | PidTuningWidget.cpp/h | 绘制、事件、参数管理 |
| PID计算 | PidController.cpp/h | 位置式/增量式PID运算 |
| 数据源抽象 | DataSource.h | 统一数据接口定义 |
| 串口数据源 | SerialDataSource.cpp/h | 串口读写与协议解析 |
| 共享内存数据源 | SharedMemoryDataSource.cpp/h | 跨进程数据交换 |
| 仿真数据源 | SimulatedPlant.cpp/h | 内置被控对象模型 |
| 应用窗口 | MainWindow.cpp/h | 布局、菜单、状态栏 |
PidTuningWidget内部用了Qt的d-pointer模式,把私有成员都放到一个Private类里,这样头文件干净,二进制兼容性也好。虽然是单人项目,但这个习惯我一直保留着,后面如果要把控件做成商业库或者给别人用,这个设计能省不少事。
3.3 绘制渲染用QPainter还是OpenGL
绘图方案我评估过两条路:QPainter软件渲染和QOpenGLWidget硬件加速。
QPainter画线条、文字、填充扇区非常方便,性能在普通PC上画几千个点完全够用,Windows下用自带的Raster引擎,Linux下走X11或Wayland,都能稳定跑到60帧。OpenGL性能上限高很多,但写起来复杂,文字渲染、DPI适配、跨平台兼容全是坑,尤其在高DPI屏幕上,OpenGL的文本渲染要自己做缩放处理,很麻烦。
结论:先用QPainter把功能跑通,如果后面要画上万点的密集波形或者做3D效果再迁移OpenGL。实际测试下来QPainter在1080p窗口下画5000个点的波形图,单帧耗时大概4到6毫秒,完全够用。
这里有一个我先说为敬的教训:不要一上来就追求高大上的渲染方案,先把数据链路跑通、把交互逻辑调对,效果比用什么渲染API重要得多。
4. 每帧画面上有什么:波形区、仪表区、状态栏
4.1 界面布局与坐标换算
控件整体分三个区域。左侧是主波形区,占宽度的70%,垂直方向占满;右侧上半部分是PID参数旋钮区,右侧下半部分是目标值、实际值、偏差的显示区和启停按钮。
波形区画两条曲线:目标值曲线(绿色)和实际值曲线(黄色),背景是深色网格,这是我做示波器类界面一贯的配色习惯,绿黄在深灰背景上的对比度高,长时间盯着看不累。
绘图第一步是坐标换算。波形区内部有一个数据矩形区域,叫plotRect,控件本身的尺寸变化时,plotRect跟随变化,但排除掉坐标轴标签和留白的尺寸。横向坐标是时间,单位是秒;纵向坐标是数值,范围可以自动跟随数据变化,也可以手动锁定。
换算逻辑很简单:
int x = plotRect.left() + (time - timeStart) / timeSpan * plotRect.width(); int y = plotRect.bottom() - (value - valueMin) / (valueMax - valueMin) * plotRect.height();这里的timeStart和timeSpan控制时间窗,比如显示最近10秒的数据。valueMin和valueMax控制纵轴范围。自动模式下,取当前缓存里所有数据的最大值和最小值,再上下各留10%的余量,防止曲线顶到边界。
4.2 波形区数据缓存与动态缩放
波形数据不是来一点画一点,那样屏幕刷新时没法做平移缩放。我用了一个环形缓冲区,固定存最近60000个点(10秒@1kHz采样的话就是10万点,我用的是10秒@100Hz采样,60000点足够)。
绘图时不是每帧都重新扫描所有数据点,而是基于Qt的QPixmap做两级缓存。数据区维护一个QImage,新的数据点到达时增量绘制,只有缩放、平移、清屏时才整体重绘。这个优化很关键,否则每帧都画几万个点,CPU占用率直接拉满。
动态缩放我用鼠标滚轮实现。滚轮向上放大,向下缩小,缩放中心是鼠标当前所在的时间点。逻辑不复杂:
void PidTuningWidget::wheelEvent(QWheelEvent *event) { double factor = event->angleDelta().y() > 0 ? 0.8 : 1.25; double mouseTime = timeStart + (event->position().x() - plotRect.left()) / plotRect.width() * timeSpan; timeSpan *= factor; if (timeSpan < 0.5) timeSpan = 0.5; if (timeSpan > 120) timeSpan = 120; timeStart = mouseTime - (mouseTime - timeStart) * factor; update(); }这里的0.8和1.25是互倒数,保证放大缩小对称,体验比较跟手。左右平移用鼠标拖拽实现,鼠标按下时记录基准点和当时的timeStart,移动时计算偏移量换算成时间偏移。
4.3 仪表区显示哪些数据
右侧上部我用四个自绘旋钮控件,分别控制Kp、Ki、Kd和Target(目标值)。旋钮下面是三行文本显示:目标值、当前实际值、偏差百分比。
偏差百分比我按(实际值 - 目标值) / 目标值 * 100%计算,如果目标值为0就显示横杠。这个指标比绝对值直观,因为不同设备的量纲不一样,百分比能通用比较。
仪表区下面是一个启停按钮和一个重置按钮。启停按钮控制数据源是否向PID模块输送数据,重置按钮清空波形缓存并把积分项清零。这个reset功能在调参过程中非常常用,每次修改参数后建议先重置再跑,否则旧的积分项残留会干扰判断。
4.4 自绘网格与抗锯齿的平衡
绘制波形区网格时,如果每条线都开抗锯齿,性能会下降。我的做法是:主网格线(带刻度标签)用抗锯齿绘制,次网格线(密集的辅助线)关闭抗锯齿。人眼对密集细线的锯齿不敏感,但线条一多、抗锯齿一开,CPU消耗翻倍,肉眼却几乎看不出差异。
这个取舍实测下来效果很好。在4K分辨率下,主网格线8条、次网格线40条,关掉次网格的抗锯齿后,整个控件的绘制时间从8毫秒降到了3毫秒。
5. 旋钮、键盘与参数限幅:调参交互设计
5.1 从零绘制一个旋钮控件
虽然Qt自带QDial,但它的样式灵活性有限,我想做一个带角度指示和数值刻度的旋钮,干脆自己画。
旋钮控件的绘制核心是三部分:底部圆盘、指示线、中心圆点。圆盘是一个渐变色圆环,指示线从圆心延伸到圆环边缘,数值显示在圆盘中心下方。
旋钮的输入方式:鼠标在旋钮上垂直拖动,向上增大、向下减小;鼠标滚轮每格调整一个步长;双击旋钮弹出输入框直接输入数值。
鼠标调节的关键是把鼠标位移映射到数值变化。我的实现是记录鼠标按下时的Y坐标和当时的参数值,拖动时用位移差乘以灵敏度系数:
void KnobWidget::mouseMoveEvent(QMouseEvent *event) { if (!dragging) return; double deltaY = pressPos.y() - event->position().y(); double newValue = pressValue + deltaY * sensitivity; setValue(clamp(newValue, minimum, maximum)); update(); }这里有个体验细节:灵敏度系数不是固定值,而是根据参数的取值范围动态计算。Kp的取值范围如果是0到10,灵敏度设为0.05(即每像素0.05);Target取值范围如果是0到2000,灵敏度设为5。这样不管哪个参数,拖满整个控件高度都能覆盖全部取值范围,操作手感一致。
5.2 旋钮参数范围与默认值
调参控件的参数范围不能乱设,得贴合实际设备。我做过这么多闭环项目,Kp、Ki、Kd的大致范围可以根据被控对象特性分几类:
| 被控对象 | Kp范围 | Ki范围 | Kd范围 |
|---|---|---|---|
| 直流电机转速 | 0.1 ~ 10 | 0.01 ~ 1 | 0 ~ 0.5 |
| 温控系统 | 10 ~ 500 | 0.1 ~ 10 | 0 ~ 50 |
| 四轴飞行器姿态 | 1 ~ 50 | 0.1 ~ 5 | 0.1 ~ 2 |
| 舵机角度 | 0.5 ~ 20 | 0 ~ 2 | 0 ~ 0.1 |
控件里这几个值默认按电机转速场景设置,用户可以手工修改。取值范围不设死,只做软限幅,超范围时颜色变红提示但允许强制设置。
5.3 属性绑定:参数变化信号与设备下发联动
旋钮数值变化后,要触发两件事:立即更新PID计算模块的参数,以及把参数打包下发到设备端。
这里的下发策略有个讲究:如果每次旋钮动一下都发一帧数据,串口压力大不说,设备端的指令队列也容易堆积。我做的策略是:拖动过程中不实时下发,等鼠标释放时才发送一次最终参数;滚轮和双击输入则立即发送。
用Qt的信号槽实现:
connect(knobKp, &KnobWidget::valueChanged, this, [this](double value) { pidController->setKp(value); paramsDirty = true; }); connect(knobKp, &KnobWidget::editingFinished, this, [this]() { sendPidParamsToDevice(); });这个设计避免了下发风暴,实测效果很好。拖动时本地计算实时更新,波形立即响应,松手后参数同步到设备端,体验流畅且不会把设备端串口缓冲区打爆。
5.4 键盘快捷键与焦点处理
用鼠标拖动旋钮虽然方便,但精细调节时不如键盘精准。我给旋钮加了键盘支持:选中某个旋钮后(点击选中),按上下方向键调整一个步长,按PageUp/PageDown调整十个步长,按Ctrl+上下方向键调整0.1个步长。
实现方式是重写keyPressEvent,按键事件先给当前焦点控件处理,处理不了再冒泡到主控件。这里有个Qt的细节:QWidget默认不接收键盘事件,必须调用setFocusPolicy(Qt::StrongFocus),否则按方向键没反应。
为了让用户知道当前焦点在哪个旋钮上,选中状态我用一个橙色光晕绘制在旋钮外圈,和未选中的灰色边框区分明显,实测用起来非常顺手。
6. 实时数据怎么进控件:串口、共享内存与文件回放
6.1 串口数据源设计与协议
串口数据源是调参工具最常见的接入方式。硬件端发送的数据帧格式我设计了简单的文本协议,方便调试:
$DATA,<目标值>,<实际值>,<时间戳>\r\n例如:
$DATA,1200,1187,123456\r\n之所以用文本协议而不是二进制,是因为调试阶段用串口助手就能直接看数据内容,排查问题方便很多。等整个系统稳定了,需要提高吞吐量时再考虑切到二进制协议。
Qt串口通信用QSerialPort类。关键配置是波特率115200、8数据位、1停止位、无校验。读取方式用readyRead信号驱动,每次读取缓冲区里的所有数据,按行分割,解析以$DATA开头的行。
有个血泪教训:串口数据是流式的,数据帧可能被截断或粘包。必须用累积缓冲区加换行符分割的方式解析,不能指望每次readyRead恰好收到一帧完整数据。我的解析器是这样:
void SerialDataSource::onReadyRead() { buffer.append(serial->readAll()); int newlineIndex; while ((newlineIndex = buffer.indexOf('\n')) != -1) { QString line = buffer.left(newlineIndex).trimmed(); buffer.remove(0, newlineIndex + 1); parseLine(line); } }这里还有个坑:QSerialPort在数据持续高速到达时,如果主线程处理不过来,缓冲区会被填满导致数据丢失。我的解决方法是在读取端用一个QByteArray环形缓冲,解析放在QtConcurrent::run的线程池里做,解析完成后再通过信号回到主线程更新UI。实测115200波特率下完全不会丢数据。
6.2 共享内存数据源
共享内存是我给场景准备的。有时候设备端和上位机跑在同一台工控机上,设备软件是C++写的,调参工具也是本地的,走串口还得虚拟串口,麻烦。Qt的QSharedMemory类可以直接把数据写到共享内存段里,控件这边定时读取。
共享内存里的数据布局我定义成一个结构体:
#pragma pack(push, 1) struct SharedData { quint32 magic; // 魔数,0x50494431 quint64 timestamp; // 时间戳 float target; // 目标值 float actual; // 实际值 float kp; // 当前下发到设备的Kp float ki; float kd; }; #pragma pack(pop)写入端往共享内存塞这个结构体,读取端用QSharedMemory::lock()、memcpy、unlock()的方式读取。注意QSharedMemory的lock是进程间互斥量,操作要快,不能在锁内做耗时操作。
用共享内存有个好处是零拷贝、低延迟,但调试起来比串口麻烦,因为看不到原始数据内容。我通常先用串口把功能调通,再切共享内存。
6.3 文件回放:调试神器
这个功能是我被逼出来的。有几次在设备旁边调参,参数还没调好就得撤了,回到工位上想继续分析数据,发现现场数据没保存。
于是我给控件加了个离线回放功能。串口数据源在接收数据的同时,把所有原始帧写入一个文本文件。需要回放时,选择文件、设定回放速度,控件就按原始时间戳重新模拟数据到达。
回放的文件格式和串口协议一致,每一行是$DATA,<目标值>,<实际值>,<时间戳>,这样文件可以直接用文本编辑器查看,也能用脚本做后处理。回放速度可调:1倍速、2倍速、4倍速。
这个功能看似简单,实际非常实用。现在我做现场调试时,数据源一直是打开的,每次调参过程都有完整记录,回去可以复盘哪个参数对应什么波形,效率提升非常明显。
7. 100ms还是16ms:渲染性能优化经验
7.1 两个拖垮性能的坑
第一个坑是文字渲染。最开始我每帧都用QPainter直接绘制所有刻度标签和数值,画得多了发现CPU占用率到40%。这是因为Qt的文字绘制要经过FreeType的字体解析和形状计算,非常耗时。
优化方案:把所有静态文本渲染到一个QPixmap上,比如纵轴刻度标签、横轴时间标签,只有当缩放比例变化时才重新渲染,每帧绘制时直接drawPixmap拷贝,几乎不消耗CPU。
第二个坑是update()的调用频率。一开始图省事,用QTimer每隔10毫秒强制刷新一次,结果波形区一直在重绘,CPU占用率居高不下。实际上波形更新时间取决于数据源的采样率,如果数据是100Hz采样,50毫秒刷一次就够了,设置为40毫秒的刷新定时器,既能保证波形更新不卡顿,CPU占用率又低很多。如果数据源采样率更低,比如10Hz,刷新频率可以降到100毫秒,完全不影响视觉体验。
7.2 性能实测数据
| 场景 | 绘制耗时 | CPU占用率(四核) | 备注 |
|---|---|---|---|
| 5000点波形+无旋钮 | 3.2ms | 8% | 100Hz数据 |
| 5000点波形+4个旋钮 | 3.5ms | 9% | 旋钮静态 |
| 5000点波形+旋钮拖动 | 6.1ms | 15% | 旋钮重绘 |
| 10000点波形+全区域动 | 9.8ms | 21% | 接近极限 |
在普通i5-8400处理器、1080p分辨率下,控件的性能表现足够日常使用。最高负载场景是旋钮拖拽配合波形滚动,6到10毫秒的帧耗时完全在可接受范围内。
7.3 还有余力的加速方案
如果数据量再翻倍,比如同时显示4条曲线、每帧画5万个点,QPainter就开始吃力了。这时候有两个方向:一个是把波形绘制切换到QOpenGLWidget,用GL_LINE_STRIP绘制,性能提升非常明显;另一个是数据降采样,屏幕上每像素列的多个点只取最大最小值画一条竖线,这种画法在金融K线图里很常见,视觉效果其实更好——比全点连线清晰,还能避免波形重叠。
我目前实现的是第二种降采样方案。当数据点数超过plotRect宽度两倍时,按像素列分组取最大最小值画竖线。这种方法在数据量达到10万点时依然流畅,且不影响观察波形形状。
8. 在线参数下发的几种方案对比
调参工具不仅要看波形,更重要的是把参数下发到设备端。我调研并实现了三种方式,各有适用场景。
方案一:串口直接下发
设备端开启串口接收线程,解析到$PID,<kp>,<ki>,<kd>指令就更新控制参数。这种方案实时性最好,指令延迟在毫秒级,适合现场联调。缺点是设备端需要实现指令解析和参数热更新功能,对MCU代码有要求。
方案二:共享内存下发
上位机和设备端在同一台机器时,把参数写入共享内存结构体,设备端在每个控制周期读取。这种方式延迟低,但设备端软件需要配合修改。
方案三:配置文件+设备重启
调参结果保存到一个INI或JSON配置文件里,设备启动时加载。这种方案适合参数已经调好、进入生产阶段的场景,不适合现场调参。
我在控件里三套都做了,界面上可以切换。现场联调用串口,同机调参用共享内存,调完保存到配置文件方便复现。
下发协议设计成循环冗余校验(CRC16)的二进制帧,防止参数传输出错。文本协议虽然调试方便,但线上参数传输我建议至少加个校验和,否则某个字节突变可能导致设备执行异常,实测出现过一次。
9. 调参工具的核心价值:用实时反馈代替盲调
9.1 内置仿真模型:没有硬件也能练手
控件内置了一个二阶系统仿真模型,用来模拟被控对象。模型是典型的电机转速模型:
G(s) = 1 / (0.1s^2 + 1.2s + 1)
我在代码里用双线性变换离散化,然后用PID闭环控制这个仿真模型。没有硬件时,把数据源切换到仿真模型,控件自己就能形成一个完整的闭环回路,直接在界面上调Kp、Ki、Kd看波形变化。
这个内置模型不仅方便演示,更重要的是让新手快速理解PID各个参数的作用:
- 增大Kp,响应变快,但超调变大,过大会震荡
- 增大Ki,能消除稳态误差,但过大会引入低频振荡
- 增大Kd,抑制超调,但对噪声敏感,过大会高频抖动
9.2 实际调参案例复盘
我在电机上实测了一个完整调参过程。电机目标转速1200转,空载。
初始参数Kp=0.5,Ki=0.05,Kd=0。实测响应很慢,约1.2秒才达到目标值附近,稳态误差约30转。原因是比例系数偏小,积分作用弱。把Kp逐步提高到2.0后,响应时间缩短到0.3秒,但出现约8%的超调,来回震荡了3次才稳定下来。这时把Kd从0调到0.12,超调压到2%以内,调节时间缩短到0.15秒左右,效果非常好。
如果光靠串口打印数字,这个过程至少要反复烧写程序十几次,每次烧录加复位几十秒,很耗时间。用这个控件大概5分钟就完成了,这还是因为我前前后后试了很多组参数才摸到最佳值。
9.3 一键导出波形成图片
调参结果总要留档或者写进调试报告里。控件加了截图功能,按Ctrl+S保存当前整个控件渲染结果到PNG图片,图片里包含波形区域的时间段、参数值、目标值和实际值曲线。导出时我额外叠加了当前PID参数的水印文字,后期整理报告时能直接看出哪张图对应哪组参数,不用再手动记录。
这个截图功能看似不起眼,实际用起来非常方便,调试记录再也不会丢。
10. 源码里的几个协调细节
10.1 信号槽用AutoConnection还是DirectConnection
控件里的信号跨线程传递很频繁。串口数据源跑在子线程,解析完数据后通过信号把值传给主线程的控件。这里注意必须用Qt::QueuedConnection,把跨线程的信号放到接收线程的事件循环里执行,确保UI更新在主线程完成。
我实际踩过坑:串口数据源里直接调用控件的addDataPoint方法而不是发信号,UI更新发生在子线程,结果是控件有时能更新有时不能,还偶发崩溃。后来全部改成信号槽跨线程传递,没有问题了。
10.2 定时器精度:数据到达频率和刷新频率的匹配
控件的刷新定时器频率是40毫秒,而数据源采样率可能到100Hz甚至1kHz。每次刷新时从缓存里捞出这段时间内到达的所有数据点,一次性绘制,而不是到达一个画一个。这样避免UI刷新和数据到达的频率不匹配导致的漏画。
缓存的容量设计要考虑最大数据流的持续时间。1kHz采样率下,如果缓存10万点,只能显示10秒的数据窗。实际场景中,电机转速控制的趋势观察需要至少30秒的窗口,所以我默认缓存30万点,内存占用约2.4MB(每点8字节),在PC上完全可接受。
10.3 参数持久化:重启后参数还在
PidTuningWidget支持把当前参数组合以JSON格式保存到指定文件,也支持从文件恢复。保存的内容包括Kp、Ki、Kd、目标值、时间窗大小、纵轴范围。这样下次打开工具,直接恢复到上次的调参状态,不用重新设置一遍。
持久化我用QSettings实现,保存在~/.config/PidTuner/config.ini。为什么不放在工程目录?因为发布到别的机器后,用户目录更可靠,不会因为权限问题写不进去。
11. 这个控件的边界条件和后续规划
测试过程中我发现两个边界情况需要专门处理。第一个是目标值为0时,偏差百分比的分母是0,界面显示--而不是除零错误。第二个是数据源断开连接后,控件要自动进入暂停状态,波形区顶部显示数据源断开的提示,防止用户误以为当前波形还在实时刷新。
控件后续方向我很清楚:一是增加多曲线支持,同时对比不同参数组的阶跃响应;二是支持导出数据为CSV,方便在外部工具里做频谱分析;三是触屏版调参界面,工业现场设备很多带触摸屏,旋钮改成滑杆手势操作会更顺手。
这个项目本身其实不算难,但把调参这件事从“改代码-烧录-看串口”变成“拖旋钮-看波形-存配置”,整个调试效率的提升是质变的。我印象最深的一次现场调试,客户给的时间窗口只有半小时,我用这个工具配合提前做好的参数粗调值,十来分钟就完成了闭环调试,剩下的时间全部用来验证最优参数。放在过去,光是烧写程序就得花掉一半时间。
如果你也要做类似的调参工具,我的建议是第一批先把数据链路跑通,别急着美化界面。波形能实时显示、参数能实时下发,这个闭环通了,后面加什么功能都是锦上添花。