上一期把闭环控制跑起来之后,我盯着串口看了整整一个下午。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寸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;保存流程是:
- 新参数填入
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(在菜单里快速调成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参数。之前同样的事,我要烧录十五次固件,可能要花两天。这期内容没有高深算法,但如果你是一个正在做嵌入式控制方向的人,这可能是你这周最值得的投资。
过程中如果你遇到按键抖动、参数丢失、菜单逻辑混乱这些问题,欢迎留言交流。我踩过的坑,大概率也会是你的坑。