前阵子做一个温度闭环控制板,主控是STM32F103,加热端用PWM控,传感器用DS18B20。电路、底层驱动、PID核心算法都调通了,接下来是整定。当时我干了一件非常蠢的事:改一个Kp,编译,烧录,等下位机重启,盯温度曲线,肉眼判断超调,再改,再烧录。一个晚上下来板子没事,人先麻了。问题不是PID算法写得不行,而是我没有一个能“现场看数据、现场改参数”的手段。也就是从那天起我意识到,整定之前,得先给固件“长出”人机界面。这是这个系列的第七期,前面几篇把传感器采集、执行器驱动、PID运算都说完了,这一期专门记录我补上人机界面这层壳的思路和实现。重点不是把界面做得花哨,而是用最低成本换回真正的“调参自由”。
1. 为什么抢在整定之前,先把界面做出来
1.1 整定不是一个动作,而是一条观测闭环
很多人把PID整定理解为“算三个数”:按公式算出Kp、Ki、Kd,填进去,完事。真做过温控或者电机控制的人都知道,公式给的只是初值,最终组合必须靠在真实系统响应上磨出来。磨的过程说白了就是一句循环:观察系统响应,改一个参数,观察系统响应,再改一个参数。
问题在于“观察”和“修改”这两个动作如果都以重新烧录为代价,整个循环就慢得离谱。我这里随便算一笔账:改一个参数、重新编译、下载固件、等待系统重新上电、温度爬升到稳定、观察超调和稳定时间,这一整套下来五六分钟是保守的。PID参数通常要试十几个来回,还不算中途改错方向重试的情况。一晚上满打满算也就能磨出两三组参数,而且每次都烧录整片固件,Flash擦写寿命也在被白白消耗。
更隐蔽的成本是心态。反复烧录试错的时候,人很容易越调越急躁,参数越改越激进,最后根本记不清当前板上跑的到底是不是上一版,更谈不上复盘。在线调参至少让我能每次只在面板上动一个数,眼睛看着趋势反应,整个过程是连续、可复现的。这点对收敛参数帮助极大。
所以我的结论是:整定开始之前,人机界面不是锦上添花,而是整定工作的前置条件。它要把“观察”和“修改”这两个动作都压缩到秒级,让整定变成一个顺畅的交互过程。
1.2 一个能显示、能修改、能保存的界面,到底替你省了什么
设计之前,我给自己列了一份“缺了它就不能开始整定”的功能清单:
- 实时显示当前测量值、目标值、输出占空比;
- 在线修改Kp、Ki、Kd三个参数;
- 参数断电之后不丢;
- 能一键恢复默认参数;
- 最好还能用一块迷你趋势框看响应走向。
把这五条翻译成工程语言,其实就是三条链路:显示链路、输入链路、存储链路。显示链路解决“数据怎么上屏”,输入链路解决“旋转编码器和按键怎么转化成菜单操作”,存储链路解决“参数从RAM落到Flash、上电再读回来”。三条链路做完,后面的整定才谈得上有交互基础。
这里要特别强调,人机界面在这里的角色不是“产品包装”,而是“调试工具”。它能替你省下的不是屏幕钱,而是整定周期里最贵的部分——调试者的注意力和时间。我之前看过很多方案一上来就规划触摸屏、彩色LCD、云端监控,功能做了一堆,唯独没解决“顺手改一个Kp看看响应”这个最原始的需求。方向就偏了。
2. 调参界面的硬件选型:我的“刚好够用”方案
2.1 从操作流程反推功能需求
选型之前,我先想清楚了整定过程中的典型操作路径:
- 开机进主界面,看实时数据和当前参数;
- 旋转编码器,高亮选中想改的参数项;
- 按下进入编辑模式,旋转加值或减值;
- 按确认,参数生效,观察曲线若干秒;
- 继续改下一个参数;
- 觉得这组参数值得保留,再手动保存到Flash。
这个流程对应到UI上非常简单:一个主画面显示实时数据和三五行参数菜单,加一条迷你趋势框就够。整定场景不需要华丽的二级页面,也不需要复杂的图形控件。核心原则是“一次只改一个参数,改完能立刻看到响应”。基于这个原则,一目了然能选的屏幕和输入方式,其实范围很小。
2.2 为什么是OLED加编码器,而不是串口屏和矩阵按键
当时我认真对比过三种组合:0.96寸OLED加EC11编码器、3.5寸串口屏、矩阵键盘加LCD1602。直接说结论,选OLED加编码器。
| 对比项 | 0.96寸OLED + EC11编码器 | 3.5寸串口屏 | 矩阵键盘 + LCD1602 |
|---|---|---|---|
| IO占用 | OLED用I2C两线,编码器两相+按键共3个GPIO | 一个串口(UART) | 8个IO起步 |
| 界面复杂度 | 自绘菜单,轻量 | 功能强,画面漂亮 | 文本显示,不直观 |
| 成本 | 最低 | 高 | 中 |
| 二次开发量 | 需自写渲染逻辑,但代码量小 | 依赖厂商上位机组态软件 | 需自写按键映射 |
| 整定体验 | 手不离开旋钮,连续可调 | 操作有延迟感,需熟悉页面 | 按键编号难记,现场手忙脚乱 |
串口屏的优点是显示效果好,但代价也很明确:一块屏抵好几个OLED的价格,而且大部分串口屏要靠厂商提供的组态工具来画界面,固件里还要额外解析它那一套通信协议,等于把一部分维护成本转嫁给了嵌入式代码。矩阵键盘加LCD1602的问题是操作不直观,你得记着哪个按键对应哪个菜单层级,整定现场一旦分心,很容易按错,而且1602能显示的内容实在太少。
编码器在我看来是“调参界的鼠标”:旋转就是翻页和加减,按下就是确认和返回,直觉且高效。EC11只需要两个GPIO读正交信号,中间再加一个按键引脚,三个IO就解决全部输入需求。OLED走I2C,两根线搞定显示。这套组合下来,STM32F103C8T6这颗主控跑起来毫无压力,外围成本几乎可以忽略。
2.3 轻量自绘还是直接上LittlevGL
这一点容易被带偏。网上现在很多教程一上来就让接LVGL(LittlevGL),字体、控件、主题一套组合拳。但对128x64的单色OLED、主控只有20KB RAM的F103来说,跑LVGL属于杀鸡用牛刀,内存和裁剪成本都不划算。
我选择自绘,因为那个菜单结构实在太固定了:选中行反白、参数值变化、趋势框滚动。全部渲染逻辑加起来不到300行C代码,而且画面完全可控。如果你手里的硬件换成了240x320以上的TFT LCD,RAM也超过100KB,那接LVGL是完全合理的,它能省掉你大量维护自绘控件的时间。关键是先评估屏幕分辨率和RAM,再决定要不要引入GUI框架,而不是一上来就选重框架。
3. 表驱动菜单与状态机:让UI代码不像面条
3.1 用结构体数组描述整个菜单
嵌入式HMI代码写多了,最常见的坏味道就是一长串if else:判断当前在第几层菜单、当前高亮第几项、旋转编码器该加谁、按一下该进哪一页。越写越长,最后自己都不敢改。我用的是表驱动方式,把可调参数全部放到一个结构体数组里,渲染和输入逻辑只跟这张表打交道。
typedef struct { const char *name; // 菜单项名称 float *value_ptr; // 指向实际参数变量 float min_val; // 参数下限 float max_val; // 参数上限 float step; // 常规步进 float fast_step; // 快速步进 const char *unit; // 单位 void (*on_confirm)(void); // 确认回调 } menu_item_t; menu_item_t g_menu[] = { { "Kp", &g_pid_param.kp, 0.0f, 9999.0f, 1.0f, 10.0f, "", pid_param_changed }, { "Ki", &g_pid_param.ki, 0.0f, 9999.0f, 0.1f, 1.0f, "", pid_param_changed }, { "Kd", &g_pid_param.kd, 0.0f, 9999.0f, 0.1f, 1.0f, "", pid_param_changed }, { "Target", &g_target_temp, 0.0f, 300.0f, 1.0f, 10.0f, "degC", target_changed }, { "Save", NULL, 0.0f, 0.0f, 0.0f, 0.0f, "", save_params_to_flash }, { "Default",NULL, 0.0f, 0.0f, 0.0f, 0.0f, "", restore_default_params }, }; #define MENU_ITEM_COUNT (sizeof(g_menu) / sizeof(g_menu[0]))整张菜单表的核心在于:每个参数项自带变量指针、上下限、步进和确认回调。界面框架渲染的时候只需要遍历这个数组,高亮行由当前索引决定。旋转编码器修改的是value_ptr所指向的变量,并且做范围钳制。以后想加一个参数,只需要在数组里加一行,渲染和按键逻辑一行都不用改。这才是“给固件长出人机界面”的正确姿势:界面框架是固定的,参数变成了数据。
3.2 编码器、按键状态机与两层菜单
EC11编码器输出A、B两相信号,两相之间有90度相位差。旋转方向不同,两相的变换顺序也不同。我是在1ms定时器中断里对A、B两个引脚采样,做简单退抖后根据相位组合判断方向。判断逻辑不复杂:记录上一拍的AB状态,和当前状态组成一个4位数值,查一个16项的方向表就能得出正转、反转还是无效跳变。
按键单独做一个短按/长按状态机。短按用于“进入编辑”和“确认修改”,长按我设计成“退出当前修改并放弃”。菜单维护两层状态:主页面状态和编辑状态。主页面下旋转编码器负责移动高亮行,编辑状态下旋转编码器负责修改值。代码里就是用一个menu_state变量区分状态,进入编辑时把当前值存到临时缓冲区,退出编辑时根据结果决定丢弃还是提交。
快速调整还有一个小技巧:根据两次旋转脉冲的间隔判断旋转速度。间隔短说明用户想快速拉数值,就用fast_step大步进;间隔长就用step小步进。这个对Kp这种需要跨数量级调整的参数特别有用,慢调细、快调粗,不需要额外键位。
3.3 参数“确认后生效”:界面层如何温和地通知执行层
参数不是改完立刻生效就最好。比如你正在把Kp从50往30转,PID控制还在跑着,如果每次旋转都直接写到实时运行的结构体里,输出占空比可能跟着猛跳。我采用的是“编辑缓冲加确认提交”模式:界面上改的是菜单项指向的临时值,按下确认才把临时值写入真正的g_pid_param结构体,同时置一个param_updated标志。
控制任务每个周期检查这个标志,只在标志置位时刷新PID参数。实际实现里可以直接memcpy整个结构体,保证参数更新对控制循环而言是“原子操作”,不会有半新半旧的状态。这个确认机制看着多了一步,实际上避免了大量现场误触导致的执行层抖动,我一直推荐保留。
4. 从面板操作到Flash存储:整定参数的完整链路
4.1 128x64屏幕上的分区布局与局部刷新
0.96寸OLED分辨率128x64,要同时放下实时数据、参数菜单和趋势框,分区得精打细算。我当时是这么分的:
- 顶部两行:测量温度,用大字号显示;第二行放目标温度和输出占空比,小字号;
- 中间三行:参数菜单,当前选中的行反白显示;
- 底部约20像素高:迷你趋势框,每隔200ms采一个点画在最右列,整幅画面向左平移一列。
趋势框不用追求坐标精确,能看到走向就够。真正需要注意的是刷新策略。OLED全屏刷新在72MHz主频下走I2C,一帧大约要30到40毫秒,如果主循环频繁全刷,屏幕闪烁且占用总线时间。我改成局部刷新:测量值每200ms更新一次,菜单行只在索引变化时重绘,趋势框只平移一次再补一个新点。这样I2C的占用大幅下降,也为主循环留出了更多余量。
4.2 内部Flash的参数读改写
参数丢了等于没调。我用的是STM32F103内部Flash的最后一页存参数,F103C8T6整个Flash是64KB,最后一页从0x0800FC00开始,大小1KB。存一个几十字节的参数结构体绰绰有余。写入流程是:解锁Flash,擦除整页,按半字编程写入,上锁。
typedef struct { uint32_t magic; // 魔数,用于识别有效数据 uint16_t version; // 参数版本号 uint16_t crc; // 简单校验 float kp; float ki; float kd; float target; } param_store_t; #define PARAM_MAGIC 0xA5A5A5A5 #define PARAM_FLASH_ADDR 0x0800FC00 void save_params_to_flash(void) { param_store_t data; data.magic = PARAM_MAGIC; data.version = PARAM_VERSION; data.kp = g_pid_param.kp; data.ki = g_pid_param.ki; data.kd = g_pid_param.kd; data.target = g_target_temp; data.crc = calc_crc16((uint8_t *)&data, sizeof(data) - 2); FLASH_Unlock(); FLASH_ErasePage(PARAM_FLASH_ADDR); uint32_t *p = (uint32_t *)&data; for (int i = 0; i < sizeof(data) / 4; i++) { FLASH_ProgramWord(PARAM_FLASH_ADDR + i * 4, p[i]); } FLASH_Lock(); }上电加载的流程反过来:读取首地址数据,先检查魔数,再算CRC,校验通过才装载到g_pid_param,任何一步失败都回退到代码常量区的默认参数。这里的默认参数不是随便给一组,而是我之前用齐格勒-尼科尔斯整定法粗算过的,至少保证系统能稳定运行,让用户从默认值开始微调,而不是从零摸索。
4.3 一次完整的调参操作路径
实际整定时我的操作路径是这样的:上电进主界面,看到温度和参数列表。旋转编码器把高亮移到Kp这一行,按一下进入编辑,Kp的值开始闪烁。旋转调值,慢旋步进1,快旋步进10,调到感觉差不多的值,再按一下确认退出编辑。此时Kp在RAM里已经生效,但还没落Flash。接下来观察趋势框里的温度走向,决定继续改还是保留。只有当光标移到“Save”这一项并确认,参数才真正写入内部Flash。
生效和落盘分开,是我刻意保留的设计。整定过程中参数改来改去是常态,如果每次确认都写Flash,既拖慢节奏又没有意义。等一组参数确实值得留,再手动保存一次就够了。这个交互细节对整定体验影响很大。
5. 界面做完之后,想提醒你的三件事
5.1 渲染刷新必须与控制周期分离
这是我最想强调的一点。最容易踩的坑就是把OLED刷新写在主循环里,不做任务切分,结果屏幕刷新时占用的时间直接影响控制时序。我的做法很明确:1ms定时器中断里只做编码器扫描、按键状态机、PID计算和PWM占空比更新;主循环只做OLED渲染、Flash操作这类耗时事务。这样不管显示多慢,1ms的控制周期始终稳定。实测起来PID运算在72MHz的Cortex-M3上也就几微秒,跟I2C刷屏完全不是一个量级,但只要不分离,I2C等待就可能拖垮实时性。
5.2 保存要克制:Flash寿命经不起频繁写入
STM32F103内部Flash的擦写寿命标称大约一万次。整定阶段一晚上写几十次没问题,但一个设备如果长期运行,或者固件里加了周期性的自动保存逻辑,就需要认真对待了。我的策略是三重保护:只有显式选择“Save”才写Flash;保存前让菜单项闪烁两次提示确认;参数结构体里带版本号,固件升级后如果版本对不上或CRC校验失败,自动走恢复默认流程。这个机制帮我省掉了后来不少“参数莫名其妙丢失”的排查时间。
5.3 给“手滑”留后路:恢复默认与参数校验
调参调试中非常容易手一抖把参数改成离谱值。比如Kp设到9999,输出直接跑到限幅,温度猛冲。所以菜单里必须有“Default”项,而且恢复默认也要做二级确认,防止误触把辛苦调好的数据清掉。校验失败时上电自动恢复默认同样是兜底的关键环节,保证任何异常情况下设备都能以一个确定的状态启动,而不是带着半损坏的参数跑起来。
做完这套界面之后,我再也没回到“改代码、烧录、观察”的老路上去。后来在其他项目上,不同主控、不同屏幕,我复用同一个表驱动菜单框架,需要改的只有参数表和像素布局。整定效率也明显提升,一套PID参数从原来的一晚上两三个小时,压缩到二十分钟以内。我想这才是给固件“长出人机界面”最大的价值:它不只是给设备加了一块显示屏,而是把调试过程本身变成了一个肉眼可见、随手可控的交互闭环。如果现在有人问我要不要先做界面再做整定,我的答案永远是:先做,哪怕功能简陋,也远比盲调来得踏实。