1. 整定还没开始,先想想界面到底替你省了什么
1.1 一次编译一整天的日子,我不想再来一遍
做嵌入式控制类项目的朋友应该都有过这种经历:控制器跑起来,动平衡、响应速度、稳定性不对劲,需要调P、调I、调D,或者调某个限幅阈值。改一个参数,改完编译、烧录、断电、上电、复位,然后盯着波形看几分钟,发现还是不行,再回去改。如果编译器够快、下载器够稳,这个循环一次也得两三分钟;要是工程比较大,一次编译加烧录十几分钟也是常事。
这个项目一开始我就被这个节奏折磨得够呛。控制对象本身是个不太容易伺候的负载,参数稍微偏一点,输出就开始抖。每次想试一组新参数,都要走一遍“改代码—编译—烧录—复位—观察”的流程。一天下来,真正花在观察控制行为上的时间,可能不到三分之一,剩下的全耗在反复编译和烧录里了。
后来我把这套东西拆开想了想,问题不在于参数整定本身,而在于参数被写死在了固件里。固件一旦烧进去,想改任何数值都得重新来一遍编译烧录的完整链路。所以有个想法很自然就冒出来了:在整定之前,先给固件“长出”一个人机界面,让参数在运行现场就能直接改、直接看、直接存。这就是今天这篇的主题,也是这个系列第八期内容的前置基础。
1.2 “界面先行”不是什么流程洁癖,是效率账
很多人觉得人机界面是产品化阶段才考虑的事,前期验证算法时弄个串口助手敲命令就够了。串口确实能用,但它的体验和我想要的整定流程差得很远:串口需要接电脑,需要开着上位机,需要记命令格式。现场调设备时,我经常是蹲在设备旁边,手边根本没有一个方便敲键盘的位置。
界面先行真正解决的是参数变更闭环的问题。把界面做进去之后,修改参数从“改代码重新烧录”变成了“按键几次直接生效”,一个参数从想法到验证,时间从十几分钟压缩到十几秒。这个效率提升是压倒性的。
而且界面先行的另一个隐性收益是:它会逼着你把固件里的参数管理做得规范化。参数不能散落在各个模块的全局变量里,必须收拢成一张参数表,带着范围、精度、默认值、存储地址这些信息。这个工作越早做越划算,等到代码膨胀之后再回头梳理,成本会高得多。
这一期的思路围绕STM32和ESP32这两类常见平台展开,但方法论是通用的:你用什么MCU、什么屏幕、什么按键方案,都不影响这套“界面先长出来”的底层逻辑。
2. 把界面写成数据,而不是写成if-else瀑布
2.1 菜单树声明
很多人在固件里做菜单界面时,第一反应是用一个巨大的switch-case或者if-else套来套去。初版代码确实能跑,菜单项一旦多了,加一个条目就要改好几个地方,字体大小、选中状态、值域范围、回调函数全搅在一起,改一处漏一处。
我在这个项目里采用的做法是:把菜单定义成一张结构体数组。每个菜单项用数据描述清楚,代码里只有一个统一的菜单遍历引擎,不管增加多少条目,引擎代码一行都不用动。
比如基础结构可以这样定义:
typedef struct menu_item { const char *label; // 显示名称 int16_t *value_ptr; // 指向目标参数 int16_t min_val; // 参数下限 int16_t max_val; // 参数上限 int16_t step; // 步进值 void (*on_change)(void); // 数值变化后的回调 const struct menu_item *children; // 子菜单,NULL表示叶子节点 uint8_t child_count; } menu_item_t;参数P、I、D,速度限幅、加速度、滤波系数,全部声明成一张表:
static int16_t pid_kp = 120; static int16_t pid_ki = 8; static int16_t pid_kd = 30; static int16_t vel_limit = 800; static const menu_item_t menu_pid[] = { {"Kp", &pid_kp, 0, 500, 1, on_pid_changed, NULL, 0}, {"Ki", &pid_ki, 0, 200, 1, on_pid_changed, NULL, 0}, {"Kd", &pid_kd, 0, 200, 1, on_pid_changed, NULL, 0}, }; static const menu_item_t menu_main[] = { {"PID", NULL, 0, 0, 0, NULL, menu_pid, 3}, {"限幅", NULL, 0, 0, 0, NULL, menu_vel, 2}, {"保存", NULL, 0, 0, 0, on_save_config, NULL, 0}, };这样做的核心好处是:加菜单项就是加一行数组元素,不需要动遍历逻辑,也不容易出现某个分支漏写的低级错误。菜单的层级、顺序、参数范围一眼就能看全,比翻switch-case清晰太多。
2.2 渲染层和业务层必须拆开
界面代码最容易腐烂的地方,是把显示刷新和业务逻辑写在同一层。比如有人在按键处理函数里直接调屏幕绘制,按一下键整个屏幕重画一遍,闪烁严重不说,逻辑还特别难复用。
我的分层方式是三层:输入层负责扫描按键或编码器,输出一个“用户意图”事件;菜单引擎层根据当前状态响应事件,更新“当前选中项”和“参数值”;渲染层只做一件事,就是把菜单引擎的当前状态画到屏幕上。
typedef enum { EV_UP, EV_DOWN, EV_ENTER, EV_BACK, EV_NONE } ui_event_t; ui_event_t read_user_input(void); // 输入层 void menu_handle_event(ui_event_t ev); // 菜单引擎层 void render_all(void); // 渲染层主循环只需要三步:
ui_event_t ev = read_user_input(); if (ev != EV_NONE) { menu_handle_event(ev); } render_all();渲染层每次全量绘制也行,但更省资源的做法是维护一个“脏标记”,只有发生变化的区域才重绘。纯文本菜单用全量重绘就够,OLED这类屏幕全量刷新一次也就几十毫秒,不会有什么体感问题。但如果你用了带局部刷新功能的LCD,把脏标记机制加上,效果会更好。
这层拆开之后有个很实际的收益:屏幕型号变了,只需要重写渲染层,菜单逻辑和参数表完全不用动。我在这个项目里从OLED换到TFT,整个迁移就改了一个文件。
3. 屏、按键、编码器:现场实操过的搭配方案
3.1 做整定界面,我优先选编码器+OLED
人机界面的硬件方案,常见的有这么几种:按键加OLED、旋转编码器加OLED、触摸屏、段式LCD加按键。
我个人的倾向很明确:只要空间允许,优先选旋转编码器加OLED。原因有三点。
第一,整定参数需要频繁做“加一点、减一点”的操作,编码器的旋转手感天然适合这种场景。拨一下动一格,连续拨就能快速跨越大范围,定位精度和速度都能兼顾。按键加减参数虽然也能用,但连续按几十下才能从0调到500,手指先受不了。
第二,编码器自带按键开关,按下就是“确认”,这样上下选择、进入子菜单、参数加减、确认保存,全都能用一个编码器完成。整机只需要一个交互元件,结构设计省了很多事。
第三,OLED在强光下的可视性比普通LCD好,而且显示黑色背景、白色文字,参数数值的对比度很高。整定现场常常是设备旁边光线复杂的地方,这种可读性优势很实用。
硬件接线也简单,编码器的A、B相各接一个GPIO,按键接一个GPIO,全部开内部上拉。OLED走I2C或者SPI,I2C只占两根线,对引脚紧张的小板子比较友好。
3.2 输入消抖和长按短按,代码里这种坑最深
界面硬件上最容易翻车的不是选型,是消抖和状态识别。
机械编码器旋转时,A、B两相的波形边沿上会有大量毛刺。如果直接在中断里对边沿计数,转一格可能计出两三格的数。我踩过这个坑之后,总结出一套相对稳的消抖逻辑:
- 把编码器接入定时器输入捕获或者外部中断,但不要在中断里做复杂处理,只记录事件标志。
- 在主循环里以1到5毫秒的间隔采样A、B相电平,识别出“有效跳变”才算一次步进。
- 每次步进后加一个很小的死区延时,比如2毫秒,过滤掉按键抖动带来的误触发。
按键也是同样思路。GPIO读到低电平之后,延时20毫秒再读一次,确认稳定才认作一次按下。长按和短按的区分,靠的是按下持续时间的计时:
#define KEY_DEBOUNCE_MS 20 #define KEY_LONG_PRESS_MS 800 uint32_t press_start = 0; uint8_t key_state = 0; void key_scan(void) { uint8_t level = gpio_get_level(KEY_GPIO); switch (key_state) { case 0: // 待机 if (level == 0) { key_state = 1; press_start = get_tick_ms(); } break; case 1: // 已按下,等待消抖 if (get_tick_ms() - press_start >= KEY_DEBOUNCE_MS) { if (level == 0) { key_state = 2; } else { key_state = 0; } } break; case 2: // 确认按下,区分长按短按 if (level == 1) { key_state = 0; // 短按事件 post_event(EV_ENTER); } else if (get_tick_ms() - press_start >= KEY_LONG_PRESS_MS) { key_state = 3; // 长按事件 post_event(EV_BACK); } break; case 3: // 长按释放等待 if (level == 1) key_state = 0; break; } }长按返回上级菜单、短按确认进入,这个交互逻辑在做多级菜单时特别好用,操作路径短,误触率也低。代码看起来啰嗦,但状态机的写法比直接在中断里做延时可靠得多。
4. 参数存储:掉电别丢,上电能救
4.1 用片上Flash还是外挂EEPROM,按项目阶段定
界面能改参数只是第一步,参数改完得能存住。这期项目里我同时试过STM32的片上Flash和I2C外挂EEPROM两种方案,各有利弊。
外挂EEPROM(比如AT24C02/AT24C64)的好处是按字节擦写、寿命长、操作简单,I2C时序写好了基本不会出错。缺点是多一颗物料、多占两个引脚。片上Flash的好处是省物料,但擦写以扇区为单位,寿命也短一些,频繁保存参数时要做磨损均衡。
我的建议是:原型验证阶段外挂EEPROM,产品阶段再评估片上Flash。原型阶段求稳,EEPROM随便写,坏了就换一颗,价格也就几毛钱;产品阶段如果能控制好保存频率和磨损均衡,片上Flash也能用得很稳。
保存频率这块特别提醒一下:别在参数每次变化时都写存储。比如按下按键参数从10变成11,立刻就写一次Flash,一天调试下来就可能把扇区寿命耗掉大半。更合理的做法是,界面里设置一个“保存”菜单项,调完一组参数后手动触发保存;或者做自动保存的版本,也是当参数连续3到5秒没有变化时再落盘一次。
4.2 写坏一个扇区,比写错一个参数更难修
参数存储区最怕的不是写错一个值,而是写到一半掉电,导致整个扇区数据错乱。上电加载时如果没做校验,固件会拿到一堆莫名其妙的垃圾值,控制参数直接变成乱数,设备跑起来的行为谁也无法预测。
所以无论用什么存储介质,我都建议做校验和双区备份。具体做法:
- 把参数区划分成两个槽位,A槽和B槽,轮流写入。
- 每个槽位开头放参数版本号,结尾放CRC32校验值。
- 写入时先写B槽,再写A槽;每次启动优先读A槽,如果A槽校验失败,就回退读B槽。
- 两个槽都失败,使用编译期内置的默认参数,并在界面上提示“参数区异常,已恢复默认”。
CRC算法用软件实现就行,STM32和ESP32都跑得动,参数表几百字节的数据做一次CRC32校验也就是几毫秒的事。
一个具体的布局示例:
typedef struct { uint32_t magic; // 0xA5A5A5A5,识别数据有效 uint32_t version; // 参数版本,结构变化时递增 int16_t pid_kp; int16_t pid_ki; int16_t pid_kd; int16_t vel_limit; uint32_t crc32; // 对整个结构做CRC } param_block_t; param_block_t param_slot_a; param_block_t param_slot_b;数据加载函数的核心逻辑:
param_block_t *load_params(void) { read_slot(&slot_a, SLOT_A_ADDR); if (validate_slot(&slot_a)) return &slot_a; read_slot(&slot_b, SLOT_B_ADDR); if (validate_slot(&slot_b)) return &slot_b; // 两个槽都坏了,用默认参数 memcpy(&slot_a, &default_params, sizeof(param_block_t)); return &slot_a; }这套机制看着简单,但在现场救过我很多次。尤其是反复整定、反复上下电的过程中,偶尔一次断电时机不好,没有备份机制的话,参数区就废了,整个下午的调试成果全部归零。
5. 真正开始“整定”时,界面该怎么配合你
5.1 一边跑控制,一边改参数,还是停下来改?
界面做好了,参数能改能存了,接下来才是重头戏:怎么把它用到整定流程里。
控制系统的整定,按我的习惯分两种情况。一种是离线整定:系统先停车,参数改完再启动,观察响应。另一种是在线整定:设备正在运行,一边观察被控量的波形、声音、温度,一边微调参数,实时看系统的反应。
离线整定相对简单,界面上的数值改好,保存,重新启动就行。在线整定对界面的要求高一些,因为改参数的动作不能干扰控制回路的实时性。
我把界面的参数修改动作设计成“复制-修改-生效”三步:菜单里调整的是参数缓冲区,只有按确认键才真正写入运行变量。这样即使界面在刷新、按键在扫描,控制中断里读到的运行变量始终是完整一致的,不会出现改到一半的中间值被控制任务拿去用的情况。
volatile int16_t runtime_pid_kp; static int16_t edit_pid_kp; // 确认键回调:把编辑值一次性提交给运行变量 void on_pid_kp_confirm(void) { runtime_pid_kp = edit_pid_kp; }这个细节很重要。如果直接让控制任务读菜单里的编辑值,用户在调整过程中看到的参数数值可能是不稳定的中间状态,控制器也会被带偏。多个参数同时修改时,一致性更是关键。
5.2 多组参数快照,调坏了能一键回去
整定过程的另一个痛点是:试了一组参数,效果不好,想回到上一组,但之前那组的具体数值已经忘了。靠脑子记容易记混,靠纸笔记又麻烦。
所以在界面里多做了一个“参数快照”功能:把当前整组参数复制到某个空槽位,随时可以覆盖保存,也可以把任何一组快照恢复为当前运行参数。
操作逻辑是这样的:
- 界面菜单里有一个“快照”分组,下面列着快照1到快照8。
- “保存到快照N”,把当前参数复制过去。
- “从快照N恢复”,把快照内容复制回当前参数区,同时写入运行变量并保存到Flash。
这个功能在整定过程中相当于一个存档点。试新参数之前先存一个快照,效果不好,恢复快照,几秒就回到原来的稳定状态。比起一遍遍重新调,效率提升非常明显。
实现上也不复杂,快照区就是一组参数块数组:
param_block_t snapshots[8];保存和恢复都只是内存拷贝加一次CRC重算。核心点在于,恢复快照时必须同时更新运行变量和Flash存储区,否则下次上电又变回旧值,界面和实际行为不一致,整定会被误导。
5.3 整定过程中,界面还要给你“反馈”
参数能改只是一半,界面还得能把系统的状态反馈出来。最基本的几个量:当前转速/温度/电压、目标值、输出占空比、是否处于限幅状态。
我在界面上加了一个简单的“监控页”,每100毫秒刷新一次这些运行量。整定时主要看监控页来判断系统在什么状态,然后进入参数菜单修改,改完再回到监控页观察变化。这样“看状态—改参数—看状态”的循环在同一个界面上就能完成,不用接上位机,不用开电脑。
有条件的话,还能在这个监控页上画一条简单的实时曲线。OLED画曲线帧率低一些,TFT或者带缓冲的LCD就流畅很多。曲线的价值在于,肉眼对数值跳动的敏感度远不如对形态变化的敏感度,一条衰减振荡的曲线,比一屏数字直观得多。
6. 固件安全这件事,和界面有关系
6.1 出厂固件加密与调试口的拉扯
界面做出来之后,固件越来越完整,这时候绕不开一个话题:固件的安全和保护。
很多做产品的朋友会遇到这个场景:板子上的固件即将交付,客户那边想要一个稳定版本,不希望现场被轻易读取和修改。MCU的调试口(SWD/JTAG)默认是开放的,接上烧录器就能读Flash,固件一旦被读走,代码和参数算法都可能泄漏。
STM32和ESP32都提供了读保护机制。STM32上叫RDP(Read Protection),分几个等级:等级1禁止通过调试口读Flash,等级2直接永久锁死调试口。ESP32有efuse熔断机制,可以烧断某些控制位,限制后续读取。
但这里有个容易踩的坑:开启读保护之前,一定要确认固件里留有可靠的升级通道。如果读了保护,现场又想升级固件,就得依赖IAP(在应用编程)或者OTA功能。如果固件里没有做OTA,升级通道又被读保护堵死,板子就只能返厂,那代价就大了。
我的建议是分阶段处理:开发整定阶段不设读保护,方便烧录调试;产品发布前的版本,再把读保护打开,同时把OTA功能先做好验证。
6.2 校验参数区,别让脏数据把整定带沟里
前面提到的CRC校验,本质上就是数据完整性保护。这几年我也见过一些“固件分析器”之类的工具,它们通过关键字匹配的方式来检查固件里是否存在异常字符串或可疑逻辑。这类工具主要用在安全分析场景,对判断固件是否被篡改有一定作用。它的思路很简单:把固件镜像拆开,按字符串、函数特征、已知漏洞模式做比对。
这个思路反过来对我们写固件也有启发:你的参数区里不应该出现不可预料的“关键字”。CRC校验就是给参数区的“关键字”——也就是数据完整性——上了锁。没有这把锁,参数区被干扰或半写入,整定一开始就建立在错误的数据上,后面做再多分析都是白费。
另外,界面上的参数输入也要做范围检查。用户或者现场操作员可能不小心把Kp从120改成1200,如果内核只用传入的参数不做校验,系统可能直接发散,轻则设备报警,重则机械结构损坏。参数表里定义min和max不是摆设,代码里在写入运行变量前必须做一次钳位:
int16_t clamp_value(int16_t val, int16_t min, int16_t max) { if (val < min) return min; if (val > max) return max; return val; }把这一步放在“确认”回调里,能让所有进入控制器的参数都经过安全边界过滤。这也是界面层对控制层的保护,价值不亚于固件加密。
7. 这次做完之后,我对“界面”这件事的几个新认识
界面做完、参数整定流程也顺了之后,回头再看这个项目,有一些体会想单独说一下。
界面不只是一个显示工具,它实际上是开发流程的一部分。过去我习惯先写核心算法,再考虑调试手段,最后才补界面。这次反着来,先做了一个足够用的菜单系统,再开始写控制逻辑,整个开发过程的调试成本明显下来了。算法哪里不对,当场改参数验证,不需要中断思路去处理编译烧录的事。这种体验上的差异,用过一次就很难回去了。
编码器加OLED这套组合,看起来简单,实际用下来的顺手程度超出我的预期。一个编码器完成全部导航,设备堆料少,故障点也少。如果是多点触控的大屏,视觉上确实高级,但在油腻的车间环境里,触摸屏的可靠性远不如一颗带键的编码器。
参数快照这个功能,我建议所有做整定类固件的人都加上。它的代码量不大,也就是几个结构体加几个拷贝函数,但使用频率极高。每次试新参数前按两下存个快照,相当于给整定过程加了个低成本的时光机,试错了能随时随地后悔。
关于安全,我给自己的原则是:调试口该锁的时候锁,但锁之前OTA必须是通的。参数区的CRC校验和双区备份一定不能省,它保护的不是你的代码,而是你在现场一整天调试出来的劳动成果。
这次分享到这里,下一期我打算接着写固件界面和控制核心之间的数据交换细节,包括多字节参数的原子读、中断上下文里的参数访问,以及怎么把这些机制扩展到更多的控制通道上。如果你也正在做类似的固件项目,希望这期内容能帮你少走几步弯路。