1. 整定之前,为什么非要先搞定人机界面
做嵌入式控制的人应该都有过这种经历:写好了PID算法,自以为参数估得差不多,结果一上电,被控量要么冲上天,要么抖成筛子。于是开始调参,但问题来了——参数写在固件里,每改一个Kp,就要重新编译、烧录、复位、再观察现象,一次循环少说几十秒,多则几分钟。改了几轮之后,整个人都在怀疑是不是传感器坏了、PWM极性接反了、甚至PID公式抄错了。
其实这种痛苦完全可以避免,方法就是题目里说的:整定之前,先给固件长出人机界面。所谓人机界面,不一定是触摸屏,也不一定是复杂的图形界面,哪怕只是一块OLED屏幕加一个旋转编码器,能把运行状态显示出来、能把PID参数实时调大调小、能手动切换工作模式,这套调试效率的提升就是指数级的。
我个人的感受非常深:没有界面的固件,就像一台没有仪表的锅炉,你只知道它在烧,但不知道烧到了什么程度;有了界面之后,目标值、实际值、输出量、Kp、Ki、Kd全是直观可见的,整定这件事才真正从“玄学”变成“工程”。所以这一期我打算把一个比较完整的最小化人机界面方案拆开来讲,从为什么需要、怎么选型,到菜单框架怎么搭、参数怎么存、整定流程怎么嵌进去,全程都是我这几年在固件项目里实际跑过的路子,新手可以直接抄作业,老手也能拿来当参考。
先说清楚一个概念,这个连载里说的“整定”,默认指PID参数整定,最常见的场景就是恒温控制、电机调速、恒压恒流电源这一类。人机界面在里面的作用并不是单纯为了“好看”,它要承担三件事:让运行状态可见、让参数可修改、让操作可执行。只要这三个目标达成了,人机界面的第一版就算合格了,后面再慢慢加曲线、加历史、加加密都不迟。
2. 方案选型:一套省心好上手的HMI组合
2.1 显示设备用OLED比TFT更省心
界面要“长”在固件上,第一步是选屏幕。我踩过不少屏幕方案的坑,这里直接给结论:如果是做第一版并且以调参为主要目的,选0.96寸I2C接口的OLED(SSD1306或SSD1315驱动)是最省心的方案。
原因有三点。一是驱动代码成熟,网上随便一搜就是全套的底层驱动,字符取模、反白显示、区域刷新都有人写好,花半天就能调通。二是I2C接口只占两根线,不跟PWM输出、ADC采样抢引脚资源,对STM32、ESP32、GD32这些常见主控来说,接线极其简单。三是功耗低、体积小,适合塞在各种设备外壳里,不会因为“加个屏幕”就把整个结构推倒重来。
如果你觉得OLED字太小,或者想要彩色显示,也可以考虑1.8寸TFT或者2.4寸带触摸的屏。但要注意,TFT屏幕的驱动复杂度和RAM占用会明显上升,尤其是带触摸的屏,还要处理触摸校准、手势消抖这些事情,对第一版来说投入产出比不高。我的建议是:第一版用OLED把逻辑跑通,后面如果确实需要更丰富的界面,再换TFT,菜单框架和交互逻辑基本可以复用,不会白干。
2.2 输入交互选旋转编码器最顺手
人机界面不能只有显示,还得有输入。常用的输入方式有按键、旋转编码器、触摸屏。我的个人意见是:对于“调参”这个场景,旋转编码器加一个按键的组合,使用体验远好于单纯按键。
原因在于PID调参这个动作天然是“连续变化”的。你想把Kp从10.0改成15.5,用按键去一格一格按,得按几十下;用编码器拧两下就到了,而且一转一个值、手感直观,跟调节音量旋钮的体验类似。编码器另一个好处是它不依赖绝对位置,上电后随便转到哪个位置都有意义,不像电位器需要做校准和初始化扫描。
具体型号我推荐EC11增量式旋转编码器,带按键开关的那种,性价比高、手感一致性好、市场量大,配一个几十欧姆的限流电阻就能直接接MCU的GPIO。接线方面,编码器有A、B两相输出,加上公共端、按键输出,一共五根线,软件层面做一个正交解码状态机就能同时判断转动方向和步数,代码我在下一节会给出。
2.3 这套HMI组合怎么融入现有固件
有了OLED和编码器之后,关键是它们的模块怎么跟原有的控制逻辑结合。我习惯把整份固件按“输入-控制-输出-人机”四条线来组织:
- 输入部分:温度传感器、霍尔传感器、ADC采样等,负责感知被控对象的实际状态;
- 控制部分:PID算法、PWM输出、执行器驱动,负责根据目标值和实际值计算输出;
- 输出部分:加热器、电机、阀门、继电器等执行机构;
- 人机部分:OLED显示、编码器和按键输入、参数存储,负责跟操作者交互。
人机部分跟控制部分之间,用一组全局变量或者一个参数结构体来沟通,不直接互相调用底层的寄存器操作。这套解耦方式的好处是,调试时你可以单独改界面逻辑,不会影响控制算法;反过来,控制算法优化升级时,界面只需要适配参数结构体就行,代码的复用率和维护性都会好很多。
3. 核心实现细节:从菜单框架到参数落地
3.1 用结构体数组搭一个可扩展的菜单树
菜单是整个人机界面的骨架。我以前见过很多人在写菜单的时候,用一长串switch-case来区分按键状态,结果菜单项一多,代码就乱成一团,加一个子菜单要改好几个地方。后来我改成用结构体数组描述菜单节点,每次显示和按键处理都基于这张“菜单表”来做,代码量反而少,扩展性却翻了倍。
核心思路很简单:每个菜单项都是一个节点,节点里存菜单类型、显示名称、关联变量指针、参数上下限、步进值等信息。菜单类型通常分三类:目录节点(有子菜单)、参数节点(可以修改某个变量)、动作节点(触发一个函数或状态切换)。
举个例子,一个恒温控制项目的人机菜单可以这样设计:
主菜单 ├── 状态显示 -> 目录:显示当前温度/目标温度/输出百分比 ├── 参数设置 -> 目录:Kp / Ki / Kd / 目标温度 / 采样周期 ├── 工作模式 -> 目录:手动模式 / 自动模式 / 整定模式 └── 保存与启动 -> 动作:写入Flash并启动控制用C语言来表达,大约是这样一种结构:
typedef enum { MENU_TYPE_DIR, // 目录节点 MENU_TYPE_PARAM, // 参数节点,可修改 MENU_TYPE_ACTION // 动作节点,触发函数 } menu_type_t; typedef struct menu_item { const char* name; // 菜单显示名称 menu_type_t type; // 节点类型 void* value_ptr; // 参数节点关联的变量指针 int32_t value_min; // 参数修改下限 int32_t value_max; // 参数修改上限 int32_t value_step; // 参数步进值 int16_t child_index; // 子菜单起始索引 int16_t parent_index; // 父菜单索引 void (*func)(void); // 动作节点回调函数 } menu_item_t;菜单表本身就是一个数组,每一项对应一个节点。遍历菜单时,根据当前节点的child_index和type就能决定屏幕显示什么、编码器转动时改什么、按键按下时跳到哪里去。得益于这种设计,增加菜单项只需要往数组里追加条目,不需要动逻辑代码。
3.2 编码器读取与按键处理:状态机比延时消抖靠谱
编码器读取这一块,新手最容易踩的坑是“丢步”和“抖动”。机械编码器在旋转过程中,A、B两相的电平变化并不是干净的边沿,接触抖动会产生毛刺,如果只是简单判沿或者用延时消抖,很容易多计数或者漏计数。
我的做法是用一个四状态状态机。编码器A、B两相的四种组合状态(00、01、10、11)在旋转时按固定顺序跳转,顺时针和逆时针是相反的顺序。于是我不去判断单个边沿,而是每次检测到A或B电平变化时,把当前状态跟上次状态组合成一个字节,查一张16字节的查找表,就能得到+1、-1或无效三种结果。
代码逻辑大致如下:
// 编码器状态查找表,索引为(旧状态<<2)|新状态 static const int8_t enc_lookup[16] = { 0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0 }; // 在A或B的GPIO中断或者10ms轮询中调用 int8_t encoder_step(uint8_t ab_state) { static uint8_t prev_ab = 0; int8_t dir = enc_lookup[(prev_ab << 2) | ab_state]; prev_ab = ab_state; return dir; }这个方案我从STM32用到了ESP32,一直很稳。另外按键的消抖,我不建议在中断里做延时,用一个10ms定时扫描任务,连续两次读到同一稳定电平才认为按键有效,长按和短按通过计时区分,这样逻辑干净且不会阻塞主循环。
3.3 显示刷新策略:局部刷新才是流畅的关键
OLED屏幕刷新这件事,如果处理不好,最典型的问题就是闪烁、卡顿。很多人一开始用OLED,直接在while主循环里反复全屏刷新,结果数字一跳动,屏幕就在闪,尤其是在刷新I2C屏幕的同时还要处理PID计算,导致控制周期也被拖慢。
解决这个问题有两个关键点。一是用缓存帧,先在内存数组里修改显示内容,改完后一次性把变化区域发送给屏幕,不要边改边发。二是做局部刷新,把屏幕划分成状态区、参数区、提示区等几个固定区域,哪个区域的数据变了只刷新哪个区域,不要每次全刷。
我常用的做法是维护一个“脏区域”标志,比如温度变了就只刷新温度值所在的那几十个像素,菜单项切换了就只刷新菜单那一行。整体刷新频率不用太高,状态数据刷新控制在10Hz就够人眼用了,菜单切换事件才需要立刻刷新,这样CPU占用少,I2C总线也不会整天忙个不停。
给一个参考的主循环时间片结构:
- 1ms时基:编码器扫描、按键扫描;
- 10ms时基:传感器读取、PID周期计算、PWM输出更新;
- 100ms时基:状态区数值刷新;
- 200ms时基:运行曲线采样点滚动刷新;
- 事件触发:菜单切换、参数修改、保存提示等即时刷新。
3.4 参数保存:别把Flash当成无限次写入的纸
人机界面上改了参数,断电之后总不能再丢了吧?所以参数保存是必须的。常用的方案有三种:外部EEPROM(比如AT24C02)、STM32内部Flash模拟EEPROM、或者小型的NOR Flash芯片。三种方案我都在项目里用过,各自有优缺点。
外部EEPROM是最省心的,I2C读写非常成熟,写寿命典型值100万次,掉电不丢失,适合参数频繁改写的场景。内部Flash模拟EEPROM麻烦一点,因为Flash不能直接改字节,需要“先擦后写”,而且擦除次数有限,通常几千到一万次,不适合高频写入,必须做磨损均衡。NOR Flash容量大,但小容量型号成本和电路复杂度不划算,一般用在大日志存储场景。
实操里我有个习惯:参数不是在每次修改时立即保存的,而是等操作者确认退出菜单后,才集中写入一次。这样既减少存储介质写入次数,也避免用户在调参过程中断电导致半边参数是新的、半边参数是旧的这种“混搭态”。写入时要先关中断,防止写入过程中被PID中断打断造成数据错乱,写完后最好回读校验一遍。
typedef struct { float kp; float ki; float kd; int16_t target_temp; int16_t temp_offset; uint16_t crc16; } sys_params_t; // 保存参数示例 void params_save(sys_params_t* p) { p->crc16 = crc16_calc((uint8_t*)p, sizeof(sys_params_t) - 2); __disable_irq(); eeprom_write_bytes(PARAM_ADDR, (uint8_t*)p, sizeof(sys_params_t)); __enable_irq(); }4. 实操整定:把PID调参流程“搬”进菜单里
4.1 菜单界面提供手动模式,先摸清被控对象底细
整定的第一步不是去跑PID,而是摸清被控对象的“脾气”。以恒温控制为例,你先得知道:加热器给50%输出,温度每分钟能升几度?断掉加热后,自然散热降温有多快?这套系统的滞后时间大概多长?这些信息直接决定PID参数的量级。
所以在人机界面上,我第一个要加的模式就是手动模式。在手动模式下,操作者直接设定输出百分比(占空比),固件跳过PID计算,直接把PWM输出设为指定值。这样你可以做一个阶跃实验:温度稳定在25度后,手动把输出拉到50%,观察温度变化曲线,记录上升速率、超调点、滞后时间。
菜单里对应做两个操作项:一个修改手动输出百分比,一个显示当前实际温度。通过在状态显示页面观察温度的变化趋势,心里就有了底。这一步虽然简单,但价值很高,它能让你在进入PID自动模式前,就把大方向的参数范围锁定住,而不是盲试。
4.2 临界比例法的界面引导实践
PID参数整定有很多种方法,工程上最常用的还是临界比例法,也叫齐格勒-尼科尔斯(Ziegler-Nichols)闭环整定法。这个方法操作起来很直观:先把Ki和Kd全部置零,Kp从一个较小的值开始,每改变一次就观察系统响应。如果系统没有振荡,就增大Kp;如果系统开始出现等幅振荡,记录下此时的Kp值和振荡周期Tu,然后用经验公式算出完整的PID参数。
在人机界面上,这个流程可以被“翻译”成一系列顺手的操作:
- 进入“参数设置”,把Ki设为0、Kd设为0;
- 把Kp设为一个初始值,比如根据手动阶跃实验估算的静态增益再取个1/5大小;
- 切到“自动模式”,观察实际温度是否围绕目标值收敛;
- 如果收敛慢/没有振荡,退回参数设置,把Kp增加20%到50%;
- 重复这个过程,直到实际温度呈现持续的等幅振荡;
- 记录下此时的Kp(即Ku)和振荡周期Tu(屏幕状态显示上按秒跳动,肉眼观察或通过记录曲线读取);
- 根据经典公式计算参数:
- 比例增益Kp = 0.6 * Ku
- 积分时间Ti = 0.5 * Tu
- 微分时间Td = 0.125 * Tu
注意,这里算出来的Ki在标准PID式子里是Ki = Kp / Ti,Kd = Kp * Td,不同代码库的PID实现形式不同,换算关系一定要提前搞清楚。我见过不少人拿着位置式PID公式和增量式PID公式的换算方式混着算,结果参数差了一个数量级。
4.3 运行中修改PID参数,必须注意安全切换
调试过程中,我们大多数时候都是在PID运行状态下直接改参数。这个操作看起来很平常,但实际上有一个非常容易踩的坑:输出可能会发生突跳。
举个例子,当前Kp是2,输出是60%,你把Kp改成5之后,如果PID算法里的积分项还没有跟上,一个较大的比例项突然生效,输出可能瞬间跳到90%甚至饱和,被控量直接冲过目标值,严重的时候会让执行器保护触发。更常见的是积分项的问题——如果之前的偏差积累了一大块积分值,改了Ki之后,积分作用突然变化,系统也会剧烈调整。
我的处理方式是引入“参数平滑切换”机制:界面修改的参数先放到一份影子参数(shadow parameter)区域,PID控制周期仍然使用旧参数运行,等操作者退出参数修改菜单时,再在当前PID周期开始的边界处一次性切换新参数。切换时还可以做两个额外动作:把积分项清零或按新参数重新初始化,以及对输出做限幅处理,确保切换前后输出不会突变。
代码层面可以这样示意:
volatile pid_param_t pid_shadow; // 界面可修改的“影子参数” volatile pid_param_t pid_active; // PID任务正在使用的参数 // PID任务每个周期读取 void pid_task(void) { if (pid_param_pending) { __disable_irq(); pid_active = pid_shadow; pid_integral_zero(); // 清零积分,防止突跳 __enable_irq(); pid_param_pending = 0; } // ... 正常PID计算 }这个方案牺牲了一点点“即时生效”的体感,换来的是系统运行的稳定和安全。真正的工程环境里,稳定永远比调参速度重要。
4.4 实时曲线:没有GUI也能画出有用趋势
很多人觉得画曲线一定要有图形库、要上TFT触摸屏,其实不然。在OLED这种低分辨率屏幕上,用简化波形图也能把趋势表达得很清楚。
我通常用一块固定的像素区域来画最近一段时间的历史曲线,数据用环形缓冲区存储,比如每100ms采样一次实际温度,存60个点,就是6秒的趋势。显示时把实际温度映射到像素高度,然后从左到右把这60个点连成一条线,底部画一条目标温度线作为参考。因为OLED只有64行像素高,温度映射范围不需要很精细,能看出趋势就足够指导整定了。
环形缓冲区的实现很简单:
#define SAMPLE_NUM 60 float temp_history[SAMPLE_NUM]; uint8_t temp_index = 0; // 每100ms调用一次 void temp_sample(void) { temp_history[temp_index] = get_current_temp(); temp_index = (temp_index + 1) % SAMPLE_NUM; } // 绘制曲线时,从temp_index+1位置开始往右画曲线刷新频率不用太高,200ms刷新一次足够平滑。相比看数字跳变,一条温度上升曲线能让你更快判断系统的惯性、滞后和临界振荡周期。很多老工程师判断PID调得好不好,其实就是看曲线形状一眼的事。
5. 踩坑清单:人机界面与整定联调中的常见问题
5.1 编码器数值乱跳,调参时参数自己变
这个问题太常见了。现象是手根本没碰编码器,屏幕上的参数自己就一格一格地跳,或者转动时数值忽快忽慢。
排查思路很固定。先检查硬件:A、B两相上有没有加上拉电阻,实物的公共端是否接对了地线,排线是否过长。再用示波器或逻辑分析仪看A、B波形,严重抖动就要加RC低通滤波或者50ms左右的软件防抖。另外,编码器转轴如果悬空金属裸露,人手触碰会引入共模干扰,这种情况下要注意壳体的接地和屏蔽。最后检查软件:状态机查找表是否正确,GPIO是否配置了正确的上下拉模式。
5.2 OLED屏幕闪烁、滚动卡顿,调参体验大打折扣
屏幕闪大概率是刷新策略的问题。第一,检查是否有全屏刷新的地方没有优化;第二,检查I2C时钟是不是因为中断频繁被拖慢了,可以适当降低I2C速度或改用400kHz快速模式;第三,确认驱动芯片的对比度、分屏、滚动设置没有误配置。
另外一个容易忽略的点是:OLED在显示大量字符时,逐字符发送会占用很长时间,如果这些发送操作发生在PID计算的中断回调里,就会导致控制周期不稳定。正确的做法是把显示发送放到主循环的较低优先级时间片里,确保控制实时性优先。
5.3 参数保存后上电丢失,或者数据错乱
先确认写入地址是否越界;其次养成写入后回读校验的习惯,CRC校验字段必不可少。如果用的是Flash模拟EEPROM,必须考虑使用寿命;如果只做了固定地址擦写,参数一天保存几十次,几个月Flash就会挂,表现看起来是“参数偶尔没保存成功”,实际上是那一段Flash块已经磨损坏了。解决思路是按扇区轮换写入地址,写满后再擦除旧区块,或者直接换成外部EEPROM。
5.4 运行中切换参数,系统突然“打摆子”
前文提到过,本质是参数切换没有做到原子性和缓冲。另外还要检查PID代码里的输出限幅和积分限幅是否生效。很多人在仿真时调好的参数上机就震,就是因为仿真里没有执行器饱和、没有PWM占空比上下限,实际的“积分饱和”在仿真里体现不出来。界面上显示输出量百分比,就是为了让你能一眼看到输出是否经常顶在限幅值附近,如果经常饱和,就需要限制最大输出或者做抗积分饱和处理。
我把这些问题的常见现象、原因和对照方案整理成了一张表,方便快速定位:
| 现象 | 常见原因 | 排查/解决方向 |
|---|---|---|
| 参数自己乱跳 | 编码器抖动/共模干扰 | 上拉电阻、RC滤波、软件状态机 |
| 屏幕闪烁 | 全屏刷新/等待过久 | 局部刷新、缓存帧、刷新频率分层 |
| 保存后数据丢失 | 写地址错乱/擦写寿命耗尽 | 回读校验、CRC、磨损均衡 |
| 切参后输出突跳 | 参数切换未平滑/积分项突变 | 影子参数、积分清零、输出限幅 |
| 模型仿真好上机就震 | 执行器饱和/积分饱和 | 抗积分饱和、PWM限幅提示 |
6. 一点个人思考:人机界面是整个固件调试体系的基石
做到这里你会发现,给固件加人机界面这件事,表面上是“多写了一块显示驱动、多做了几个菜单页面”,本质上其实是把调试思路从“盲人摸象”变成了“可视化、可操作、可复现”。有了这套基础,后面要做参数自整定、自动调优算法、数据记录上传,都是在同一套交互框架上生长的枝叶。
我的习惯是,在每一个控制类固件项目启动时,先不急着把控制算法写完,先把最小的人机界面框架搭起来。哪怕最初版本只有一个状态显示页和一个参数修改页,这也能让之后每一次代码改动都能实时看到效果。很多调试中的疑难杂症,恰恰是因为你能实时观察到数据和波形,才在十分钟内锁定了原因。
最后分享一个扩展方向:当人机界面的菜单框架和参数结构体稳定之后,可以采用同类的交互逻辑把串口命令、手机蓝牙配置、Wi-Fi网页配置都串进来,这样固件就不再是“只能连屏幕操作”或“只能连串口操作”的单一模式,而是可以灵活适配不同场景。我自己后来把很多项目的参数结构体定义成了统一格式,屏幕、串口、网络配置共用同一套数据结构,改一次底层,所有入口同步生效,维护成本一下子就下来了。希望这一期的思路对你有用,尤其是正在为整定调参发愁的朋友,不妨先停下来,花一天时间把人机界面补上,这个投入绝对值得。