给PID整定装上“仪表盘”:嵌入式人机界面的设计与实践 上一期把闭环控制跑起来之后我盯着串口看了整整一个下午。P、I、D三个参数还是硬编码在main.c里的宏定义每次改参数就得打开Keil、改宏、重新编译、插上ST-Link烧录然后再拔线、断电、上电、观察波形。说实话改一次就要烧录一次固件一天下来调不到十组参数大量时间都花在了重复劳动上。从那时候我就在想整定之前应该先给固件长出人机界面。因为参数整定这件事的天然属性就是迭代——试一组参数、看结果、调一下、再看结果。如果这个过程里每一次微调都要经历一遍改代码-编译-烧录的完整链路你的注意力全被工具链切碎了根本谈不上什么手感。这一期我就把当时做的人机界面完整拆开讲一遍为什么要在整定之前做界面界面的交互逻辑怎么设计代码怎么组织以及在实际整定过程里这个东西到底给我省了多少事。内容并不高深但都是能直接抄作业的实践。1. 为什么整定之前先要给固件装一块仪表盘1.1 没有界面的固件调参有多折磨人先说一个很典型的场景。你的闭环控制基本跑通了输出能稳定在目标值附近但超调量偏高或者响应太慢这时候你就需要调参数。如果你没有一个趁手的人机界面整个调参周期是这样的记下当前现象超调20%稳定时间18秒打开工程找到#define P_GAIN 10.0f改成12.5重新编译编译时间半分钟到几分钟不等断电接上ST-Link烧录断电拔线重新上电观察波形/温度/转速变化记录下来不满意跳回第1步。整个过程少说5分钟多则10分钟。而且每一次操作都要重新跑一遍进入系统-达到稳定的过渡过程系统越大越慢。我见过一个做恒温槽的朋友单次稳定时间就要半个多小时他调三组参数一天就没了。更麻烦的还不是时间成本而是心流被打断。调参是需要手感的你要边看系统响应边猜测下一步怎么调这种连续的思考一旦被烧录流程打断恢复起来非常痛苦。所以我在第6期确认固件基础功能没问题之后停下来做的第一件事不是继续调控制算法而是先做人机界面。这看似绕了远路实际上是在给后续所有迭代买时间。1.2 人机界面在整定场景里到底扛什么活我给自己定了一个原则人机界面不需要花哨但必须解决三个具体问题——实时观测、在线修改、掉电保存。实时观测是指你能随时看到当前系统的运行状态。对于温度控制就是当前温度和设定温度对于电机调速就是当前转速和给定转速。整定的本质就是对比目标和实际的关系如果这两者都看不清后面一切都无从谈起。在线修改是指PI参数以及目标给定值能通过界面按键直接调整而不是改代码重新烧录。这是整个界面系统的核心价值把改参数的耗时从分钟级压缩到秒级你才能在同一段系统运行时间里完成多组对比实验。掉电保存则是整定场景里特别容易忽略的细节。你辛苦调出来一组效果很好的参数如果设备一断电参数就回到默认值等于白调。所以界面不仅要能调参数还要把参数写进非易失存储上电时自动恢复。这个仪表盘做完之后后续的整定工作流就完全变了上电看主界面按两下按键进参数菜单边观察边微调存好参数直接断电走人。效率至少翻十倍。1.3 硬件选型与资源规划我这次用的还是老搭档STM32F103C8T6也就是大家都说的蓝丸核心板。这个芯片对于人机界面来说资源不算富余但完全够用关键是便宜、资料多、踩坑成本低。显示用0.96寸OLEDSSD1306驱动I2C接口。选择它的理由只有三条引脚占用少、显示效果好、代码库成熟。SPI版本的TFT屏效果更好但占用的引脚和刷新逻辑会复杂不少对整定这件事来说有点过重了。如果你已经用着串口屏那更简单甚至不用自己画UI直接发协议指令就行。按键我用三个OK键、加键、减键。这是最精简又能完成所有交互的组合。硬件上每个按键接一个GPIO外部加上下拉电阻我习惯是按下接地内部上拉模式这样只需要复用引脚即可。存储方面因为SMT32F103内部有Flash我不额外接EEPROM芯片。把参数存到内部Flash最后一个页面省一颗芯片也省一条I2C总线的地址冲突问题。C8T6的Flash大小是64KB最后一页落在0x0800FC00正好1KB存几十个参数都绰绰有余。还有一点规划要提前做整个HMI不要占用CPU太多时间。OLED刷新放到主循环按键扫描用定时器中断控制算法继续用原来的中断或者主循环高频段。不能让界面拖累控制节拍否则整定出来的参数都是假的。2. 界面交互设计三个按键搞定一切2.1 从使用场景反推交互逻辑做嵌入式人机界面最常见的错误是一上来就想做复杂的菜单树。什么右键二级菜单、长按进入设置、组合快捷键……加到最后你自己都搞不清怎么操作了。整定这个场景里操作路径其实非常短看一眼实时数据改一个参数再看一眼实时数据。仅此而已。所以我最终只设计了三个界面状态主界面显示当前值、目标值、输出占空比不做任何按键响应或短按OK直接进菜单菜单列表页显示参数列表P、I、D、SV通过加/减键在参数之间移动短按OK进入某个参数的编辑参数编辑页显示参数名和当前值加/减调整参数数值短按OK保存并返回列表长按OK放弃修改并返回。这个设计还有一个隐藏的好处新使用者不需要说明书。因为每个界面只有一条信息主线按键逻辑高度一致——加/减是移动或者调整OK是进入/确认/返回。即使是完全没用过你设备的人给他三十秒探索也能上手。这一点对调试和交付都特别有用因为你永远不知道现场操作的人是谁。2.2 菜单数据结构怎么组织菜单要撑起不同的参数类型尽量做通用结构不要为每个参数写一堆重复代码。我用的结构体如下typedef struct { const char *label; // 参数名 float *value; // 指向实际参数变量的指针 float min; // 最小值 float max; // 最大值 float step; // 步进 } MenuItem;用这个结构体定义数组菜单系统统一遍历static float pid_p 10.0f; static float pid_i 0.5f; static float pid_d 50.0f; static float pid_sv 120.0f; MenuItem menu_items[] { { P, pid_p, 0.0f, 100.0f, 0.5f }, { I, pid_i, 0.0f, 10.0f, 0.05f }, { D, pid_d, 0.0f, 500.0f, 5.0f }, { SV, pid_sv, 0.0f, 300.0f, 1.0f }, };这样以后想加参数只需要往数组里追加一行不需要改动任何菜单逻辑。因为PID算法的每次执行都会实时读这些全局变量所以界面修改数值后控制算法下一次执行周期自然就会使用新值不需要额外的信号通知机制。菜单状态我用一个枚举管理typedef enum { UI_MAIN, UI_MENU_LIST, UI_MENU_EDIT } UiState;配合一个记录当前菜单索引的变量ui_index就能串起整个交互流程。2.3 按键扫描消抖和长按一个都不能省按键处理是HMI里最容易被低估的部分也是我这次踩坑最多的地方。整定过程中你的注意力全在系统状态上手指按按键的姿势往往比较随意如果没做好消抖就会出现明明按了一下界面却跳了两个菜单的情况。我这里的关键思路是把按键扫描放到一个10ms的定时器中断里扫描到有效操作只置标志位主循环再根据标志位执行动作。这样既不会丢按键也不会阻塞控制逻辑。软件消抖的做法很简单采样连续两次都为稳定电平才认为有效。typedef enum { KEY_IDLE, KEY_PRESSED, KEY_HOLD, } KeyState; void Key_Scan_10ms(void) { uint8_t ok GPIO_ReadBit(KEY_OK_PORT, KEY_OK_PIN); uint8_t plus GPIO_ReadBit(KEY_PLUS_PORT, KEY_PLUS_PIN); uint8_t minus GPIO_ReadBit(KEY_MINUS_PORT, KEY_MINUS_PIN); // 简化处理ok、plus、minus分别做同样的状态机 if (ok 0) { // 按下 if (key_ok_state KEY_IDLE) { key_ok_state KEY_PRESSED; key_ok_event | KEY_EVENT_CLICK; } else if (key_ok_state KEY_PRESSED) { key_ok_state KEY_HOLD; key_ok_event | KEY_EVENT_HOLD; } } else { key_ok_state KEY_IDLE; } }长按连续加参数这个功能编辑界面里特别好用。比如要把P从10改到80你手动按加键要按140下0.5步进如果没有长按连发手都要断了。所以我在长按状态下会每200ms自动触发一次加操作长按大概1秒后开始连发这样大范围调整才快。显示刷新方面我尽量控制在10Hz上下因为温度、转速这类物理量变化没有快到需要每秒刷几十次的程度。刷新太快反而会出现OLED残影和闪烁纯属给自己找麻烦。另外OLED写数据走I2CI2C在中断里操作容易出时序问题所以我限定所有I2C访问都发生在主循环里这一点后文还会再展开。3. 核心代码实现从驱动到参数存储3.1 OLED显示驱动调用接口要够简单SSD1306的驱动库网上遍地都是我不打算逐行贴库代码但一定要讲讲怎么封装不然别人抄过去还是不知道怎么用。我通常只往上封装三层第一层是最底层的写命令/写数据函数负责I2C收发第二层是绘图原语比如OLED_DrawChar()、OLED_DrawString()、OLED_DrawLine()第三层是应用层的显示函数比如UI_ShowMainScreen()、UI_ShowMenuList()这一层直接对接菜单状态机。第三层才是你需要自己发挥的地方。以一个主界面为例void UI_ShowMainScreen(void) { char buf[32]; OLED_Clear(); OLED_DrawString(0, 0, PV: 120.5 C); OLED_DrawString(0, 2, SV: 120.0 C); sprintf(buf, OUT: %3d %%, (int)pid_out_percent); OLED_DrawString(0, 4, buf); OLED_Refresh(); }OLED这种小屏一行8个像素点0.96寸能显示8行16列字符获取主界面的关键信息绰绰有余。如果你喜欢更直观一点可以用一个简单的条形图显示输出占空比用OLED_DrawRect画个10像素高的矩形宽度对应占空比。这块我建议按需添加重心还是放在整定信息的展示上。3.2 菜单状态机的转移逻辑状态机是这个HMI的骨架。我把代码组织成三块状态机迁移、列表更新、编辑更新。void UI_Process(void) { switch (ui_state) { case UI_MAIN: if (key_event KEY_EVENT_CLICK) { ui_index 0; ui_state UI_MENU_LIST; } break; case UI_MENU_LIST: if (key_event KEY_EVENT_CLICK) { ui_state UI_MENU_EDIT; } else if (key_event KEY_EVENT_PLUS) { ui_index (ui_index 1) % menu_count; UI_ShowMenuList(); } else if (key_event KEY_EVENT_MINUS) { ui_index (ui_index menu_count - 1) % menu_count; UI_ShowMenuList(); } break; case UI_MENU_EDIT: if (key_event KEY_EVENT_CLICK) { // 保存成功提示 Param_SaveToFlash(); ui_state UI_MENU_LIST; UI_ShowMenuList(); } else if (key_event KEY_EVENT_PLUS) { *menu_items[ui_index].value menu_items[ui_index].step; *menu_items[ui_index].value Clamp(...); UI_ShowEditScreen(); } else if (key_event KEY_EVENT_MINUS) { // 减操作类似 } break; } }这段代码的逻辑非常直白每个状态下对按键事件的响应都是互斥的不会出现同时处理了列表切换和参数修改的混乱。菜单切换的时候显示函数被立即调用出来的效果就是按键按下画面马上变化体感响应很迅速。一个小细节是CLICK事件和HOLD事件要做区分处理。短按OK是确认长按OK在编辑态里我设计为放弃修改直接返回。因为调参的时候难免手抖多调了两下这时候一个取消功能能省不少事。3.3 参数存储写Flash之前先想清楚Feisen参数保存是我认为本期最需要静下心处理的部分。STM32F103的Flash写入有几个限制写入前必须先擦除整个页典型FLASH页大小为1KBC8T6为64KB容量最后一页地址0x0800FC00Flash擦写次数有限大约1万次。所以我设计了一个很小的存储结构typedef struct { uint32_t magic; // 固定标记 0xA5A5A5A5 uint32_t checksum; // 数据校验 float pid_p; float pid_i; float pid_d; float pid_sv; } ParamBlock;保存流程是新参数填入ParamBlock计算校验值填到checksum字段擦除Flash页写入整个ParamBlock读回并校验失败则报错。启动流程则是读Flash页检查magic和checksum通过就把参数加载到全局变量不通过就使用编译期默认值。校验和的计算我通常用循环冗余校验STM32硬件有CRC外设可以算但我嫌软硬件对齐麻烦直接软件实现了一个简单的CRC32变体几十行代码搞定。你也可以用累加和只要确保某个字节错了能查出来就行。这里面的核心思想是非法参数的危害远大于参数丢失所以宁可恢复默认也不要加载损坏数据。如果你担心别人把固件里的参数区直接读出来可以对存储内容做一个简单的异或变换再写Flash。这不是真正的固件加密但能让大部分拿编程器直接读Flash的人拿到的是乱码属于性价比比较高的防护手段。4. 整定实战人机界面改写了我的工作流4.1 一次完整的菜单调参操作我这期的测试对象是一个小型恒温台用PWM控制加热棒热电偶测温目标是把工件温度稳定在设定值附近。加装人机界面后的操作流程大致如下上电主界面显示PV: 23.4 / SV: 120.0 / OUT: 0%短按OK键进入菜单默认停在P参数短按加键切到SV短按OK进入编辑长按加键连发SV一路从120升到150松手短按OK界面提示SAVED自动返回列表重新按OK回到主界面观察PV上升和稳定过程。整个过程不到10秒就完成了而原来需要至少5分钟的改代码-编译-烧录循环。更重要的是我可以一边看着温度波动一边按着加键微调P值这种实时交互带来的直觉是任何离线观察都无法替代的。有一组对比实验我记得很清楚把P从8调到16超调量从6℃涨到13℃但响应时间从40秒缩短到22秒。这个过程中我连续改了5次参数每次间隔不到1分钟所有现象都连续记录在脑子和串口日志里。这在过去是根本做不到的——等你烧录完系统状态早就跳变了。4.2 借助界面完成一组Ziegler-Nichols整定用界面辅助整定最爽的地方是可以大胆执行临界比例度法的步骤。我按经典流程走了一遍把I和D设成0在菜单里快速调成0P从很小开始逐步加大直到系统出现等幅振荡记录临界增益Ku和振荡周期Tu根据经验公式切换到合适的PID参数。这个流程在过去要折腾一整天因为每一步都要烧录。现在让我崩溃的环节变成了等系统进入等幅振荡因为界面操作本身完全不占时间了。最后的整定结果大致是P取Ku的0.45倍I取Tu的1/1.2D取Tu的0.125整个系统获得了比较理想的阶跃响应。还有人会问既然我们有OLED小屏要不要顺手画个微型趋势图我的建议是不画。0.96寸OLED画趋势图能显示的数据点太少看不出完整的动态过程。你要的趋势分析应该靠串口把数据发给PC用Python或者串口示波器软件画曲线。人机界面的职责永远是现场调整和状态确认曲线分析交给上位机各司其职效率最高。我当时的配套做法是在固件里加一个周期性的串口打印把时间戳、PV、SV、OUT各参数用逗号分隔输出PC端用简单的pySerial脚本接收再转存成CSV后用Excel或者matplotlib画图。整个流程非常廉价但效果极好。4.3 参数分组避免整定和运行被混在一起在实际使用中我还发现一个值得优化的点不是所有参数都需要在菜单里暴露出来。比如PID的采样周期、PWM频率、温度补偿系数这些底层参数整定过程中基本不会动但放在菜单里会增加误操作的概率。所以我后来把参数结构体拆成了两组公共运行参数SV、P、I、D和高级配置参数采样周期、滤波器系数等。菜单默认只显示公共运行参数组通过按住OK键3秒以上的特殊操作才进入高级配置组。这个设计很便宜但对防误操作非常有效——别人拿你的设备不会因为乱按按键把底层的采样周期改了这相当于给固件加了一层最简单的访问权限控制。5. 典型问题与排查心得5.1 按键鬼跳菜单自己乱跑这是我个人遇到的最常见问题。现象是按键按一下菜单跳两下或者编辑参数时数值连续跳好几单位。排查方向主要有三个硬件没接上拉电阻引脚悬空干扰电平导致误触。用内部上拉后一般能解决消抖时间太短10ms不够就把消抖周期提到20ms反正整定场景的按键操作不追求极限速度按键事件没有清标志位中断里置位的标志被主循环多次处理。我就在处理函数末尾统一key_event 0一次性消费所有事件问题彻底消失。5.2 参数存不进Flash或者上电后恢复默认这个问题出现的概率非常高尤其第一次用STM32片内Flash做存储的时候。我当时踩的坑是忘记擦除页面就直接写结果写进去的数据和旧数据做按位与完全不对。STM32的Flash写入只能把1变成0不能把0变成1所以写之前必须先擦除整页变成0xFF。还有一个低级错误是地址算错了。C8T6的Flash从0x08000000开始64KB延伸到0x0800FFFF最后一页是0x0800FC00。如果你用的是ZET6或者其他容量更大的芯片地址完全不同要在芯片手册里确认页大小和页数量。最简单的办法是在程序里加一个断言地址范围违反直接编译报错。另外我建议保存操作做个保存中的提示动画哪怕只是一个闪烁的字符。因为Flash擦写需要几毫秒到几十毫秒这期间如果用户继续按键很容易误操作。我在写入期间关闭按键事件并且主界面显示SAVING...等写入完成再恢复正常交互。5.3 显示闪烁OLED看起来在喘气OLED刷新频率太低或者刷新过程中被其他任务打断都会导致显示不均匀。我遇到过主循环被I2C操作占住然后控制中断又修改了显示缓冲区造成画面撕裂的情况。解决方法是给显示缓冲区加一个简单的互斥保护比如用全局变量标记正在刷新控制逻辑要修改显示数据时先检查标记或者统一在刷新流程里更新数据。再一个经验是把显示刷新分时进行不要一帧全刷。0.96寸OLED全屏128*64像素数据量不大但每次全屏刷新也要几百毫秒时间。我改成局部刷新策略只有数值变化后才重新绘制那一行静止菜单不重绘。这样刷新频率可以提到20Hz显示效果也更细腻。5.4 关于固件升级和参数迁移的一点提醒人机界面带来的参数存储看似简单但它会影响后续的固件升级策略。如果固件版本升级后数据结构变了旧参数区和新固件不兼容启动时校验会失败系统自动回退默认值。这个行为其实是安全的但用户会困惑我调的参数怎么没了我的做法是在ParamBlock里加一个版本号字段版本不符的时候尝试做一次简单的字段迁移迁移不了才用默认值。如果你做的是量产设备这个细节非常关键。还有就是把参数区固定放在Flash末尾的独立页面并确保每次固件升级不会覆盖这个区域。写在最后的体会做完这个人机界面之后我最大的感受是人机界面不是控制系统的花架子它是整定工作效率的分水岭。在此之前调参是被工具链绑架的在此之后调参变成了一个纯粹的思考过程——看数据、拧旋钮、再看数据所有注意力都放在系统本身。如果你也正卡在改代码-烧录-观察-再改代码的循环里我强烈建议你花上两三天时间先停下来把HMI补上。驱动代码用现成的库菜单状态机自己写几十行参数存储用内部Flash硬件成本可能只是多加一块几块钱的OLED和三个按键。我第一次做完这个界面后当天下午调试了大概十五组PID参数。之前同样的事我要烧录十五次固件可能要花两天。这期内容没有高深算法但如果你是一个正在做嵌入式控制方向的人这可能是你这周最值得的投资。过程中如果你遇到按键抖动、参数丢失、菜单逻辑混乱这些问题欢迎留言交流。我踩过的坑大概率也会是你的坑。

相关新闻

最新新闻

深入解析mbed OS:从HAL层到RTOS内核的嵌入式系统源码剖析

深入解析mbed OS:从HAL层到RTOS内核的嵌入式系统源码剖析

1. 这个项目到底在看什么1.1 先搞清楚 mbed OS 是什么,以及为什么要读它的源码mbed OS 是 Arm 官方推出的物联网嵌入式操作系统,面向 Cortex-M 系列微控制器,内置了实时操作系统内核、HAL 硬件抽象层、设备驱动框架和完整的测试体系。简单说&…

2026/9/6 10:11:30
无sudo环境下用RIOT OS native模式跑通网络吞吐测试

无sudo环境下用RIOT OS native模式跑通网络吞吐测试

1. 为什么会在没有 sudo 的环境里折腾 RIOT1.1 受管 Linux 环境下的真实痛点先说背景。我手头这台 Ubuntu 机器不是自己的实验室主机,而是公司统一运维的受管服务器,账号本身在sudo组里,但每次执行sudo apt install都会弹出“该操作需管理员审…

2026/9/6 10:11:30
Linux Platform总线机制与设备树匹配:i.MX6ULL驱动开发核心解析

Linux Platform总线机制与设备树匹配:i.MX6ULL驱动开发核心解析

1. Platform总线机制到底解决了什么问题做嵌入式Linux驱动开发,绕不开i.MX6ULL这颗芯片。它是NXP(原Freescale)的Cortex-A7系列处理器,在工业控制、物联网网关、教学开发板这些场景里出镜率极高。用这颗芯片写驱动,最常…

2026/9/6 10:11:30
tmux 会话管理实战:让 AI 编程长任务不再因断线而丢失

tmux 会话管理实战:让 AI 编程长任务不再因断线而丢失

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/6 10:11:30
i.MX6ULL设备树与Platform驱动匹配机制详解

i.MX6ULL设备树与Platform驱动匹配机制详解

上周帮朋友调一块 i.MX6ULL 的板子,遇到一个很典型的问题:驱动代码 insmod 进去之后,dmesg 干干净净,probe 根本没跑。查了半天,最终问题出在设备树节点 compatible 跟 of_match_table 里写的不一致。这类问题在嵌入式…

2026/9/6 10:11:30
非科班转行工程师:从自学到入职的完整路径与实战经验

非科班转行工程师:从自学到入职的完整路径与实战经验

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/6 10:06:29