news 2026/9/6 9:31:22

给PID整定装上“仪表盘”:嵌入式人机界面的设计与实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
给PID整定装上“仪表盘”:嵌入式人机界面的设计与实践

上一期把闭环控制跑起来之后,我盯着串口看了整整一个下午。P、I、D三个参数还是硬编码在main.c里的宏定义,每次改参数就得打开Keil、改宏、重新编译、插上ST-Link烧录,然后再拔线、断电、上电、观察波形。说实话,改一次就要烧录一次固件,一天下来调不到十组参数,大量时间都花在了重复劳动上。

从那时候我就在想,整定之前,应该先给固件长出人机界面。因为参数整定这件事的天然属性就是"迭代"——试一组参数、看结果、调一下、再看结果。如果这个过程里每一次微调都要经历一遍"改代码-编译-烧录"的完整链路,你的注意力全被工具链切碎了,根本谈不上什么手感。

这一期我就把当时做的人机界面完整拆开讲一遍:为什么要在整定之前做界面,界面的交互逻辑怎么设计,代码怎么组织,以及在实际整定过程里这个东西到底给我省了多少事。内容并不高深,但都是能直接抄作业的实践。

1. 为什么整定之前,先要给固件装一块"仪表盘"

1.1 没有界面的固件,调参有多折磨人

先说一个很典型的场景。你的闭环控制基本跑通了,输出能稳定在目标值附近,但超调量偏高,或者响应太慢,这时候你就需要调参数。如果你没有一个趁手的人机界面,整个调参周期是这样的:

  1. 记下当前现象:超调20%,稳定时间18秒;
  2. 打开工程,找到#define P_GAIN 10.0f
  3. 改成12.5;
  4. 重新编译,编译时间半分钟到几分钟不等;
  5. 断电,接上ST-Link,烧录;
  6. 断电,拔线,重新上电;
  7. 观察波形/温度/转速变化,记录下来;
  8. 不满意,跳回第1步。

整个过程少说5分钟,多则10分钟。而且每一次操作都要重新跑一遍"进入系统-达到稳定"的过渡过程,系统越大越慢。我见过一个做恒温槽的朋友,单次稳定时间就要半个多小时,他调三组参数,一天就没了。

更麻烦的还不是时间成本,而是心流被打断。调参是需要"手感"的:你要边看系统响应边猜测下一步怎么调,这种连续的思考一旦被烧录流程打断,恢复起来非常痛苦。

所以我在第6期确认固件基础功能没问题之后,停下来做的第一件事不是继续调控制算法,而是先做人机界面。这看似绕了远路,实际上是在给后续所有迭代买时间。

1.2 人机界面在整定场景里到底扛什么活

我给自己定了一个原则:人机界面不需要花哨,但必须解决三个具体问题——实时观测、在线修改、掉电保存。

实时观测是指你能随时看到当前系统的运行状态。对于温度控制,就是当前温度和设定温度;对于电机调速,就是当前转速和给定转速。整定的本质就是对比"目标"和"实际"的关系,如果这两者都看不清,后面一切都无从谈起。

在线修改是指PI参数(以及目标给定值)能通过界面按键直接调整,而不是改代码重新烧录。这是整个界面系统的核心价值:把"改参数"的耗时从分钟级压缩到秒级,你才能在同一段系统运行时间里完成多组对比实验。

掉电保存则是整定场景里特别容易忽略的细节。你辛苦调出来一组效果很好的参数,如果设备一断电参数就回到默认值,等于白调。所以界面不仅要能调参数,还要把参数写进非易失存储,上电时自动恢复。

这个"仪表盘"做完之后,后续的整定工作流就完全变了:上电看主界面,按两下按键进参数菜单,边观察边微调,存好参数直接断电走人。效率至少翻十倍。

1.3 硬件选型与资源规划

我这次用的还是老搭档STM32F103C8T6,也就是大家都说的蓝丸核心板。这个芯片对于人机界面来说资源不算富余,但完全够用,关键是便宜、资料多、踩坑成本低。

显示用0.96寸OLED,SSD1306驱动,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写数据走I2C,I2C在中断里操作容易出时序问题,所以我限定所有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页大小为1KB(C8T6为64KB容量,最后一页地址0x0800FC00);
  • Flash擦写次数有限,大约1万次。

所以我设计了一个很小的存储结构:

typedef struct { uint32_t magic; // 固定标记 0xA5A5A5A5 uint32_t checksum; // 数据校验 float pid_p; float pid_i; float pid_d; float pid_sv; } ParamBlock;

保存流程是:

  1. 新参数填入ParamBlock
  2. 计算校验值,填到checksum字段;
  3. 擦除Flash页;
  4. 写入整个ParamBlock
  5. 读回并校验,失败则报错。

启动流程则是:读Flash页,检查magicchecksum,通过就把参数加载到全局变量;不通过就使用编译期默认值。

校验和的计算我通常用循环冗余校验,STM32硬件有CRC外设可以算,但我嫌软硬件对齐麻烦,直接软件实现了一个简单的CRC32变体,几十行代码搞定。你也可以用累加和,只要确保"某个字节错了能查出来"就行。这里面的核心思想是:非法参数的危害远大于参数丢失,所以宁可恢复默认也不要加载损坏数据。

如果你担心别人把固件里的参数区直接读出来,可以对存储内容做一个简单的异或变换再写Flash。这不是真正的固件加密,但能让大部分拿编程器直接读Flash的人拿到的是乱码,属于性价比比较高的防护手段。

4. 整定实战:人机界面改写了我的工作流

4.1 一次完整的菜单调参操作

我这期的测试对象是一个小型恒温台,用PWM控制加热棒,热电偶测温,目标是把工件温度稳定在设定值附近。加装人机界面后的操作流程大致如下:

  1. 上电,主界面显示PV: 23.4 / SV: 120.0 / OUT: 0%
  2. 短按OK键进入菜单,默认停在P参数;
  3. 短按加键切到SV,短按OK进入编辑;
  4. 长按加键连发,SV一路从120升到150,松手;
  5. 短按OK,界面提示"SAVED",自动返回列表;
  6. 重新按OK回到主界面,观察PV上升和稳定过程。

整个过程不到10秒就完成了,而原来需要至少5分钟的"改代码-编译-烧录"循环。更重要的是,我可以一边看着温度波动,一边按着加键微调P值,这种实时交互带来的直觉是任何离线观察都无法替代的。

有一组对比实验我记得很清楚:把P从8调到16,超调量从6℃涨到13℃,但响应时间从40秒缩短到22秒。这个过程中,我连续改了5次参数,每次间隔不到1分钟,所有现象都连续记录在脑子和串口日志里。这在过去是根本做不到的——等你烧录完,系统状态早就跳变了。

4.2 借助界面完成一组Ziegler-Nichols整定

用界面辅助整定最爽的地方,是可以大胆执行"临界比例度法"的步骤。我按经典流程走了一遍:

  • 把I和D设成0(在菜单里快速调成0);
  • P从很小开始逐步加大,直到系统出现等幅振荡;
  • 记录临界增益Ku和振荡周期Tu;
  • 根据经验公式切换到合适的PID参数。

这个流程在过去要折腾一整天,因为每一步都要烧录。现在让我崩溃的环节变成了"等系统进入等幅振荡",因为界面操作本身完全不占时间了。最后的整定结果大致是:P取Ku的0.45倍,I取Tu的1/1.2,D取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参数。之前同样的事,我要烧录十五次固件,可能要花两天。这期内容没有高深算法,但如果你是一个正在做嵌入式控制方向的人,这可能是你这周最值得的投资。

过程中如果你遇到按键抖动、参数丢失、菜单逻辑混乱这些问题,欢迎留言交流。我踩过的坑,大概率也会是你的坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/6 9:31:10

Blognami:基于Markdown的Node.js轻量级博客平台实战指南

/* 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 9:30:43

嵌入式操作系统行业全景:技术路线、应用场景与投资战略

嵌入式操作系统(EOS)这个赛道,说实话过去几年在投资圈和产业圈都处于一种“人人听说、少有深究”的状态。研究机构和券商报告年年出,标题格式几乎都是《XX行业分析与投资战略研究报告20XX~20XX年》,但真正能…

作者头像 李华
网站建设 2026/9/6 9:29:23

量子稀疏自编码器如何用于Q矩阵估计:原理、复现与评估

/* 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 9:27:53

从MP4到AI模型输入张量:解码链路与FFmpeg实战全解析

/* 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 9:27:44

usmile Y30S深度评测:大摆幅缓震与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 9:26:44

基于开源工具 qzonearchive 的QQ空间数据本地备份指南

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

作者头像 李华