做嵌入式的人大概都有过这种经历:调一个PID环路,参数写在代码里,改一次Kp要重新编译、烧录、上电,观察波形不对,再回头改代码。一个下午就在“改参数-编译-烧录-看波形”的循环里消耗掉了。等终于试出一组差不多的参数,你已经不太确定刚才那组数据到底是在哪一轮编译里试出来的了。
所以我一直觉得,做控制类固件开发,人机界面不是锦上添花的东西,它应该是整定工作的基础设施。这期内容就聊聊我在一个实际项目里,如何在动手整定PID参数之前,先给固件“长出”一套可用的交互界面,让参数调整从“改代码”变成“拧旋钮”。
这套思路不只适用于PID整定,温度控制、电机调速、传感器标定,但凡固件里存在“需要反复调节的参数”,都可以用同样的方式去构建设备端的人机交互层。整个方案的硬件成本可以压到非常低,核心就是一块单片机加一块小屏幕加一个编码器。
1. 整定前的核心痛点:参数在代码里,手感在设备外
1.1 传统整定方法的问题到底出在哪
PID整定的本质是在寻找一组合适的控制参数,让系统的响应特性满足要求。工程上常用的方法无非几种:经验试凑法、Ziegler-Nichols整定法、基于模型的计算法。说来惭愧,这些年我在实际项目里用最多的还是试凑法,不是因为理论学得不好,而是因为被控对象的数学模型在工程现场往往拿不到——负载变化、摩擦非线性、温度漂移,这些东西塞进理论公式里,算出来的参数通常只能当初始值用,最后还是得现场微调。
但试凑法的效率瓶颈不在算法本身,而在参数修改的路径太长了。典型流程是:打开工程文件在代码里找到那一组Kp、Ki、Kd的宏定义,改掉一个数值,重新编译,烧录进单片机,按下复位键,然后观察被控对象的响应曲线。改一个参数,三分钟起步。改完了发现响应曲线有超调,想回调一点,又是三分钟。这种方式的致命问题在于:系统状态无法在修改参数后快速复位到一致的初始条件,所以你很难评判“上一轮修改到底变好了还是变差了”。
更麻烦的是,很多设备的安装位置不方便插调试器。电机驱动板装在机箱里,温控模块嵌在设备深处,每次想改参数都得拆壳子、插线、连电脑。我甚至见过有些现场调试是在设备通电运行的情况下进行的,拔插下载器本身就带着短路风险。
1.2 人机界面改变了什么
给固件加上人机界面之后,整定这件事从“编译-烧录-复位”变成了“旋钮-按键-观察”。参数修改直接作用于运行中的固件,无需中断控制回路,被控对象始终处于闭环状态,你能实时看到修改参数后系统响应的连续变化。
这种工作方式上的转变,带来的不只是时间上的节省。它改变了整定过程中的信息密度:屏幕可以同时显示当前参数值、目标值、反馈值、输出占空比,甚至可以通过简单曲线把最近几十个采样周期的历史数据画出来。控制系统的行为特征——超调量、振荡频率、稳态误差——一眼就能看出来。
另外,设备的现场交付体验也会完全不一样。以前客户觉得你卖的是一个黑盒子,参数只存在代码里,任何调整都要找你。现在界面上直接可以调,客户自己就能根据负载变化微调参数,售后压力小很多。
1.3 “先长出人机界面”这个顺序为什么重要
这期标题说的是“整定之前,先给固件长出人机界面”,这个顺序是有讲究的。很多人会习惯性地先把PID算法调通、参数存到一个临时变量里,然后盘算着“等有空了再做个界面”。问题是,一旦控制主体能跑了,测试压力就来了,然后你会发现永远没有“有空了”的时候。你的时间会被一波接一波的控制效果验证占满,界面这件事就无限期延后了。
反过来,如果在一开始就花半天到一天时间,把人机界面这个基础设施搭好,后续所有调试工作都会受益。哪怕你用的是STM32CubeMX里自动生成的代码框架,只加一个OLED屏幕驱动、三个按键的扫描逻辑和一个简单的菜单状态机,工程量也没有想象中大。但从此以后,每改一个参数,省下的都是编译烧录的轮转时间。
2. 方案选型:嵌入式人机界面到底该用什么搭
2.1 屏幕怎么选:OLED优先,TFT备选
嵌入式人机界面可选的方案很多:数码管、LCD1602、OLED、TFT彩屏、串口屏。我个人的偏好是:除非空间特别受限,否则优先用0.96寸的I2C接口OLED屏幕,SSD1306驱动芯片那种。理由很实在:
- I2C接口只占两个IO口,和编码器、按键的引脚分配完全不冲突
- 四线连接(VCC、GND、SCL、SDA),焊接和走线都非常简单
- 库函数成熟,SSD1306的驱动代码网上到处都是,基本不用自己从头写
- 功耗低,工作电流在20mA左右,不会给电源带来额外负担
如果项目需要显示曲线走势,0.96寸的128x64分辨率其实也能勉强画个迷你波形图,但信息密度确实有限。要求更高的话可以上1.3寸或2.4寸的TFT屏,但驱动复杂度会上升,不一定划算。
串口屏(比如Nextion或者中景园的USART HMI系列)是另一个思路,界面设计在电脑上完成,单片机通过串口发指令控制显示。这种方式做出来的界面漂亮,开发也快,但有一个隐藏成本:串口屏本身也是一个MCU系统,上电初始化需要时间,有时候你数据都发了它还没准备好,需要做握手处理。另一个问题是成本,串口屏的价格通常比裸屏加驱动高不少,而且项目量大了之后,串口屏的供货和定制灵活性都差一些。
2.2 输入设备的选择:编码器还是按键
界面没有输入设备就是一块电子相框。人机交互的输入侧,我强烈推荐旋转编码器(EC11系列),而不是多个独立按键。原因在于:编码器天然支持“选择”和“确认”两种操作——旋转是移动光标或修改值,按下是确认或进入下一级菜单。它占用IO少,一个编码器加一个按键功能(通常编码器本体就带按键)只需要三个GPIO,却可以实现完整的菜单导航。
和独立按键方案比,编码器的操作效率高太多了。你想把Kp从1.5调到2.0,用按键得一下一下按,手指头都按酸了。用编码器,一转就到。从人机工程学的角度来说,旋转编码器的输入连续性和手感的直觉性,都远优于离散的按键。
选编码器的时候有几个细节需要注意。第一,要买带定位档位的型号,就是转起来有“咔哒咔哒”手感的那种,步进感明确,方便数档位。第二,最好选带按压开关功能的,省一个按键。第三,注意编码器的引脚定义,不同厂家的型号A、B相定义可能不同,接反了表现就是“正转反而数值减小”,这个在代码里做个方向判断就能处理。
2.3 主控平台的选取:不要为了界面换主控
人机界面是功能层,不是决定平台的核心因素。你在用什么主控芯片,就把人机界面做在什么芯片上,除非那个芯片的资源实在紧张到连一个OLED驱动都跑不动。
STM32F103这种级别的芯片跑SSD1306 OLED完全没问题,I2C时序可以用硬件I2C也可以用软件模拟,两种方式我都用过。硬件I2C的优点是省CPU,但STM32F1的硬件I2C偶尔有Bug,某些情况下会卡死在busy状态,要加超时处理。软件模拟I2C在代码上更可控,缺点是得占用两个IO口并且要留意时序延迟。我个人倾向于在资源允许的情况下用硬件I2C加超时机制,代码更优雅,CPU占用也更低。
如果用的是ESP32或者ESP8266这类带WiFi的芯片,方案就更灵活了。屏幕只是本地交互,还可以通过WiFi做一个网页端的人机界面,手机浏览器直接访问设备IP就能调参。不过这不属于本期“轻量级人机界面”的范畴,是另外一个值得单独展开的话题。
2.4 成本与开发周期的平衡
把整套方案的成本列一下:0.96寸OLED一片大约10-15元,EC11编码器一个2-3元,加上一些电容电阻,总物料成本不到20元。开发周期方面,如果驱动代码都是现成的,从零开始搭一个支持参数修改的完整菜单系统,一个工作日内基本能完成。
这20块钱和一天时间,换来的是一劳永逸的调试基础设施。从此以后没有任何一次参数修改需要重新编译工程。这在产品开发迭代中带来的是一个难以量化的时间复利。如果你手上有一套正在反复调参的固件项目,我建议今天就把这套界面加上去,它会是你这个项目里回报率最高的一次投入。
3. 菜单系统的架构设计:不能只做一个“能显示参数”的界面
3.1 菜单系统的最小功能集
一个真正能用于整定场景的固件人机界面,至少需要满足几个功能:查看当前参数、修改参数值、保存参数到非易失存储、恢复默认参数。对应的菜单结构并不复杂:
主菜单 ├── 参数调整 │ ├── Kp │ ├── Ki │ ├── Kd │ └── 目标值 ├── 运行状态 │ ├── 当前反馈值 │ ├── 输出值 │ └── 控制模式 └── 系统设置 ├── 保存参数 ├── 恢复默认 └── 关于这个结构看起来简单,但它覆盖了整定场景的全部核心操作路径。调试时最常用的路径是“进入参数调整-选中Kp-旋转修改数值-按键确认-切到运行状态观察响应”,全程不需要连接电脑。
3.2 状态机:菜单系统的主干
菜单系统本质上是一个有限状态机。每个菜单项是一个状态,旋转编码器在“同级菜单项”之间切换,按一下编码器是在“当前状态”和“子状态”之间跳转。
我在实际项目里用的是一个相对简化的状态模型,定义三个基本状态:MENU_NAV(菜单导航)、PARAM_EDIT(参数编辑)、SUB_PAGE(子页面显示,比如运行状态页)。三个状态之间的转换逻辑只有三条:
MENU_NAV+ 按键按下 → 如果当前菜单项有子菜单则进入MENU_NAV的子层,如果当前菜单项是参数则进入PARAM_EDITPARAM_EDIT+ 按键按下 → 保存当前修改值,返回MENU_NAVPARAM_EDIT+ 编码器旋转 → 修改当前参数值(带上下限钳位)
这套状态机的实现方式用了一个结构体数组来存储菜单项——每个菜单项有它自己的名称、类型、关联的变量指针、取值范围、步进值。用指向变量的指针把菜单和数据解耦,这样不管固件里有多少参数,走一遍表格就能生成全部菜单,新增一个可调参数只需要往数组里加一行。
3.3 菜单表格驱动的设计模式
菜单表格驱动是整个界面代码可维护性的关键。不要用一堆switch-case去硬编码菜单逻辑,那样做菜单多了以后代码会膨胀到无法维护。
一个典型的菜单项结构体大概是这样的:
typedef struct { const char* name; // 菜单显示名称 uint8_t type; // 菜单项类型:子菜单 / 参数 / 只读状态 void* value_ptr; // 指向参数变量的指针 float min_val; // 参数最小值 float max_val; // 参数最大值 float step; // 参数步进值 struct MenuItem* parent; // 父菜单指针 struct MenuItem* child; // 子菜单指针 } MenuItem;在代码初始化阶段,用一个静态数组把整个菜单树定义出来。这样菜单变成了一张“表”,驱动逻辑和具体的菜单内容完全分离。以后要增加一个参数,就是在表里加一项,主循环的代码一行都不用改。
结构体里用void* value_ptr指向参数变量,这是C语言里实现“任意类型参数统一修改”比较常用的做法——在修改时根据type的值把指针转换为对应的数据类型来读写。这样做有轻微的类型安全风险,但配合统一的参数范围约束,实际出问题的概率很低。
3.4 UI更新策略:不要闪烁,不要卡顿
小屏幕的UI更新是最容易翻车的地方。OLED屏全屏刷新一次大约要几十毫秒,如果每次旋转编码器都全屏重绘,会产生明显的闪烁,操作手感会非常差。
我的做法是采用“分区域刷新”策略:屏幕上的每个区域独立维护自己的“脏标记”。编码器转动时,只更新数值变化的那一个区域;页面切换时才做全屏重绘。这样既保证了响应速度,又避免了闪烁。
对于状态数据的实时显示(比如反馈值、输出值),用一个定时器周期性刷新,刷新频率控制在10Hz左右就够了——毕竟人眼对数值的敏感度没那么高,刷新太快反而会闪烁,而且数字跳动太快根本来不及看。
4. 实操过程:从零搭建一个可用的参数整定界面
4.1 硬件连接与引脚规划
这个项目我用的是一块STM32F103C8T6核心板,0.96寸OLED接I2C1(PB6-SCL,PB7-SDA),EC11编码器接三个GPIO——A相接PA0,B相接PA1,按键接PA2。三个引脚都配置为上拉输入模式,EC11模块本身也需要接上拉电阻,如果模块上已经带了就省了。
编码器输出的是正交信号,读编码器的旋转方向用状态机法或者查表法,不要用简单的“A相上升沿时B相是高电平还是低电平”这种简化判断,那种方式在快速旋转时容易漏脉冲。我比较常用的是查表法,把AB两相的电平组合编码成状态值,每变化一次查一次表,往哪个方向转一目了然。
// 编码器状态查表法 const int8_t ENC_STATES[] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0}; int8_t read_encoder(void) { static uint8_t last_state = 0; uint8_t current_state = (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) << 1) | GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1); uint8_t combined = (last_state << 2) | current_state; last_state = current_state; return ENC_STATES[combined & 0x0F]; }这个查表法用了格雷码的相邻状态转移特性,AB两相在任一时刻只有一相电平变化,所以通过状态转移表可以准确识别旋转方向。相比在中断里做边沿判断,查表法更简洁,而且不容易漏步。
编码器的A、B相可以用外部中断触发,也可以在主循环里轮询。考虑到主循环的工作量不大,轮询就够了,但轮询周期必须足够短——不能超过编码器最快旋转时两个脉冲之间的时间间隔,否则会丢步。我一般放在1ms的定时器中断里读取,绝对稳。
4.2 OLED驱动与菜单绘制
SSD1306的驱动代码网上很多,不过建议自己把核心的几行理一遍。屏幕的本质是一个128x64的像素矩阵,显存需要1024个字节,SSD1306内部自带一块GRAM(显存),你的单片机要做的事情就是把要显示的内容写入屏幕的GRAM。
我的显示层分了两个模块:底层是驱动API,提供OLED_Clear()、OLED_DrawPixel()、OLED_ShowString()这些原语;上层是UI组件层,负责把菜单项的文本、参数值、状态图标等画到正确的位置。两层之间做隔离,以后换屏幕驱动甚至换彩色屏,UI层代码可以完全复用。
绘制菜单时我的排版逻辑很简单:一屏显示四个菜单项,当前选中的那一项用反色显示(白色底黑字),这样视觉焦点非常明确。编码器每转动一格,选中项高亮下移或上移一位,到达边界时菜单内容滚动一页。
4.3 参数变化生效:界面与控制的纽带
界面上修改的参数,如何无缝进入控制算法,这是很多人卡住的地方。有人把参数直接定义在控制算法文件里,界面模块包含控制模块的头文件,直接访问内部变量。这种做法让两个模块耦合在一起,修改起来牵一发动全身。
我的做法是给每个可调参数定义一个带约束的写入接口:
typedef struct { float current_value; float min; float max; float step; void (*on_change)(float new_val); // 参数变化时的回调函数 } ParamHandle; void Param_SetValue(ParamHandle* handle, float new_val) { if (new_val > handle->max) new_val = handle->max; if (new_val < handle->min) new_val = handle->min; if (handle->current_value != new_val) { handle->current_value = new_val; if (handle->on_change) { handle->on_change(new_val); } } }控制算法那边只需要注册一个on_change回调,在回调里把新的参数值应用到控制器的计算链路中。这样界面层完全不关心控制算法内部实现,控制层也不依赖UI层,中间只隔着一层回调接口。哪怕有一天把PID换成了模糊控制算法,界面代码一行都不用改。
这里有一个值得强调的设计细节:整定过程中修改参数,要不要“热加载”?我的做法是区分两类参数:一类是可以在运行中实时修改的(比如Kp、Ki、Kd),修改后立即生效,方便观察系统响应的连续变化;另一类是修改后需要重启才能生效的(比如传感器量程、通信地址),这类参数修改后标记一个“待保存”状态,提示用户重启生效。区分标准就是看参数修改是否涉及安全边界或初始化流程,实操中这个判断并不难。
4.4 参数保存:掉电不丢失
调试好的参数如果一断电就丢,那这个界面等于白做。参数保存用Flash来存储,STM32F103的Flash是128KB的,在最后的一个页(Page Size是1KB)划分出一块区域存放参数结构体。
保存策略上有一个关键的坑:Flash的擦写寿命大约是一万次,如果每次调整参数都立即写入Flash,用不了多久就把Flash写穿了。我的策略是“手动保存”和“自动保存”结合:菜单里提供一个“保存参数”选项,调试过程中参数随便试,确认满意了再手动保存一次。另外做了一个兜底逻辑——如果连续30分钟没有任何参数变化,就自动保存一次,防止忘了保存导致调试成果丢失。
写入Flash之前要先把待写入区域擦除,这是Flash的特性决定的,不像EEPROM可以按字节改写。所以我的做法是:先读回原数据,擦除整页,把新数据写入,再回读校验。整个过程耗时也就几十毫秒,用户按键后等一下就完成了。
4.5 一个完整的参数修改流程演示
把这套界面用在真实的整定场景中是什么体验?以我手头的一个温控项目为例:被控对象是一个加热模块,传感器是热电偶,执行器是PWM控制的固态继电器,控制目标是把温度稳定在设定值附近。
开机进入主菜单,旋转编码器选中“参数调整”,按下进入。再旋转到Kp这一项,按下进入编辑状态,这时候旋转编码器,屏幕上的数字从1.0开始以0.1的步进值变化。我把Kp从1.0缓缓调到2.5,退出编辑状态,回到“运行状态”页面,屏幕上实时显示当前温度、目标温度和输出功率百分比。升温过程有超调,我就再进入参数调整把Kp回调到2.0,又把Ki从0.05调到0.02。整个过程也就两三分钟,没有编译一次代码。
这个交互闭环带来的调试效率提升是质变级别的。以前这类温控项目调试一个参数组合,一个下午的实验时间,现在一个上午能试完四五组PID参数组合。而且因为整个过程中的被控对象始终在闭环运行,你观察到的每个响应曲线都是在真实工况下得到的,数据可信度很高。
5. 常见问题与坑:给后来者避避雷
5.1 编码器方向反了怎么办
这是做编码器最常遇到的问题:正转的时候数值反而减小。解决方式有两种:硬件上对调A、B两相接线;软件上把编码器的方向判断结果取反。我倾向于软件解决,因为改代码比改接线快。实测下来直接在主循环里dir = -read_encoder()就行,不用想得太复杂。
需要注意的另一个编码器问题是“抖动”——旋转时偶尔出现数值跳变两次或者来回跳动。这个往往是没有消抖或者引脚接触不良。软件方案是在代码里对编码器的相邻两次采样做小时间窗口滤波,只有两次采样间隔超过一定时间才认为是有效旋转。硬件方案是接一个小电容(104)在编码器的输出和地之间,滤除毛刺。
5.2 OLED不亮或者花屏
OLED不亮最常见的三个原因:I2C地址不对(SSD1306常见0x3C或0x3D,看模块的硬件配置)、SDA和SCL接反了、电平不匹配(3.3V模块接到5V系统上)。
花屏(显示乱码或雪花点)通常是I2C时序问题,可能是I2C速率设置太高导致通信不稳定。SSD1306实际支持的最高I2C速率是400kHz,但有些便宜的模块在400kHz下就不稳定了,把速率降到100kHz试试通常就正常了。
还有一个隐藏问题:和SD卡、AT24C02这类器件共用I2C总线时,地址冲突或者总线竞争会导致莫名其妙地显示异常。解决方式是让OLED独占一条I2C总线,其他I2C器件放另一条,省心很多。
5.3 菜单层级太深,操作效率反而低了
菜单系统做一个三层结构,程序上完全没问题。但实际调试过程中你会发现,每次调整一个参数要“进入-进入-修改-返回-返回”,操作路径太长,手感会变差。
我的应对方案:在参数编辑状态里,当光标停在一个参数名上时,直接旋转编码器就能修改值,不需要再按一下进入子状态。这样“选中即编辑”,路径比“选中-按下-编辑-按下-确认”少了一半的操作步骤。另外还提供了一种“快速模式”:在运行状态页长按编码器按键,直接跳到最近修改过的那个参数,省去从头导航的时间。
5.4 参数修改后控制系统突然不稳定了怎么办
界面让参数修改变得太容易,也会带来一个副作用:你很容易调出一个让系统发散的值,然后发现系统开始剧烈振荡或者输出饱和。
应对办法是在参数写入回调里做合法性校验,不仅仅是范围限制,还要做相邻两次修改的增量限制——单次修改幅度不能超过某个值,超了就需要二次确认。另外修改的时候确保设备处于安全状态,高温加热设备建议在修改参数前自动暂停输出,等参数确认稳定后再恢复。这些“安全护栏”是嵌入设备人机界面非常重要的设计原则,代码上就是几行判断的事,但关键时刻能救命。
5.5 固件加密与参数保护
部分量产设备不希望用户随意改动内部参数。如果界面做的足够好,用户连上就能改参数,那产品层面的参数保护就成问题了。
我处理这个问题的方式是分层权限:普通用户模式只能查看运行状态,不能修改任何参数;工程师模式需要按压编码器三秒以上再配合特定档位旋转序列,才能解锁参数编辑权限。更进一步的需求可以加一个简单的密码校验,密码通过按键序列输入,和固件的加密校验逻辑配合。这类设计不复杂,但需要在一开始就考虑到,不然界面做完了再往回加权限控制,改动量会大不少。
从“改代码-编译-烧录”到“旋钮-按键-观察”,是我这两年在嵌入式调试效率上做的最大的一笔投资。如果你也正在被频繁的参数试凑折磨,我建议你花一天时间把这套人机界面搭起来。硬件的成本可以压到20块钱以内,代码的工作量取决于你手上的驱动库成熟度,而回报是之后每一次调试都能省下的时间和精力。
最后分享一个我自己的习惯:参数保存到Flash之后,我会顺手把界面里新增的“固件版本号”和“参数组编号”一起存进去。这样样机在外头跑了好几天,拿回来我一看版本号和参数组编号,就能确定它跑的是哪个固件版本、哪一组参数。这个习惯帮我排查过好几次“现场和实验室表现不一致”的诡异问题,算是意外之喜了。