做电机控制的第三周,我发现自己陷入了一个极其低效的死循环:改一个Kp值,编译,烧录,上电看波形,不满意,再改,再烧。一个晚上折腾下来,光是固件就烧了二十多次。到第十次左右我实在绷不住了——问题根本不在参数整定本身,而是工作流。调试参数应该是一个交互过程,我却把它做成了单机版打地鼠。那次之后我彻底想明白了一件事:在整定PID之前,第一步不是去调参数,而是先给固件长出一套能用的、为调试而设计的人机界面。
这一期就是这个主题。我会从设计初衷开始聊,讲清楚为什么界面层必须放在整定之前,再分享我自己在STM32平台上搭建的一套轻量菜单引擎和参数调试界面,包括架构设计、核心代码逻辑、以及与PID整定功能的对接方式。最后是这半个月踩坑的总结,基本覆盖了编码器抖动、Flash寿命、刷新撕裂和菜单失控这些你在文档里翻不到的实战问题。如果你也在做运动控制相关的嵌入式项目,或者正准备给自己的固件加一个显示屏,这篇文章应该能帮你少走不少弯路。
1. 反复烧录的日子,我过够了——为什么整定前必须先有界面
1.1 一次改了26遍参数的教训
我做的是一个直流有刷电机的速度闭环控制项目,MCU用的STM32F103C8T6,驱动是BTS7960,编码器是常见的霍尔增量式AB相输出。前期硬件和驱动逻辑都调通之后,我以为最难的阶段已经过去了,剩下的不过是把PID参数调好而已。结果我一晚上改了26遍参数,用掉了半包杜邦线,还把Flash给擦得有点心虚。
问题出在哪儿?当时的调试路径是这样的:修改代码里的kp、ki、kd宏定义,重新编译,用ST-Link烧录,然后打开串口看速度曲线,或者用逻辑分析仪看PWM占空比。速度不快,但每改一次参数,编译加烧录至少一分半钟。中途如果发现电机啸叫或者超调量不对,还得断电重新来。更让人崩溃的是,我只有三个现场可调的宏,但实际系统的非线性因素导致不同的负载状态下往往需要不同的参数组合。改一个参数,动全身,烧录一次只能验证一个方向,没法快速对比。
后来我算了一笔账:如果一个人要在速度环整定中完成至少十组不同参数组合的阶梯测试,按最乐观的情况,每组参数从修改到出结果至少三分钟,光是在"改参数—烧录—试验—再改"这个循环上就要花掉半个小时以上的纯机械时间。而这段时间里,真正有价值的数据观测和逻辑思考反而被压缩了。我当时就意识到:这个工作流本身是错的。
1.2 串口调试也救不了现场
当然有人会说,你可以用串口下发参数啊,不用每次烧录。这个说法理论上没错,我也试过。串口终端 + 自定义协议,把参数作为字节流发下去,固件接收后修改内存里的结构体变量。但实际用起来有几个很尴尬的问题。
第一,现场调试往往没有电脑。电机实验台在工位上还好,一旦把设备带到测试环境去跑负载,笔记本要么没带,要么电池撑不住。这时候手里只有一个开发板和一个电源,串口就无能为力了。第二,串口调试天生缺乏"实时上下文"。你发一个Kp=0.5下去,但你眼睛还要盯着另一个窗口看转速曲线,参数和结果不在同一个界面里,很容易错乱。我试过一次把Kp本来想从0.3改成0.35,结果敲成了1.35,电机直接啸叫,吓得我赶紧拍停。
所以我真正需要的不是"能远程改参数"的接口,而是一个在现场就能完成"设定参数—观察结果—调整参数"闭环的本地操作面板。换句话说,我需要一块屏幕、一个旋钮,和一个长在固件内部的人机界面。这就是整定之前先做界面的核心理由——你不可能靠烧录来完成一个本来应该靠交互来完成的过程。
1.3 界面不是为了好看,是为了调试效率
很多做嵌入式的朋友听到"人机界面"第一反应是:漂亮、触摸、酷炫。但在这个场景里,我的需求非常朴素,甚至可以说不近人情——界面要服务于整定工作流。具体来说,它必须支持三件事:
一是参数浏览和修改。我能在菜单里找到速度环的Kp、Ki,按下旋钮进入编辑状态,转动旋钮调整数值,再按一下确认并保存。整个过程步进可预期,数值清楚显示在屏幕上。
二是实时数据观察。在整定过程中,界面要能看到电机的目标速度、实际速度、PWM占空比和误差值。最好能有简单的波形趋势,哪怕只是一条滚动曲线也好。这些数据能帮我判断超调量、响应时间和稳态误差。
三是运行模式切换。能够在界面上把电机切换到开环输出模式、阶跃响应测试模式或者闭环速度模式。这个功能看起来小,实际上非常重要,因为整定过程中需要反复在这些模式之间切换来辨识系统特性。
这三点都指向同一个目标:让整定过程中的所有操作都能在设备旁边用一只手完成。界面本身不是目的,它是为了把调参周期从"分钟级"压缩到"秒级"的手段。想明白这一点之后,设计方向就非常清晰了。
2. 先画整体架构:屏幕、按键、菜单树和调试通道
2.1 硬件选型的现实考量
人机界面的硬件方案基本决定了架构的上限。我刚开始的时候纠结过是用128x64的OLED还是1.8寸的TFT,后来考虑再三,屏幕选择了0.96寸的OLED,SSD1306驱动芯片,I2C接口。为什么选它?因为功耗低、体积小、刷新简单,而且我主要显示的是文本和简单图表,彩色和更大分辨率的TFT虽然看着更舒服,但HAL库刷屏的负担和PCB占板面积在这个阶段都不太值当。
输入方面我用了EC11旋转编码器带开关。这是一个被低估的神器。编码器转动可以用于菜单项的选择和数值增减,按下内置开关可以用于确认和返回,一颗就能替代两个普通按键的大部分功能,而且天然符合"旋钮调参"的直觉操作。
整个硬件连接非常简单:OLED挂在I2C1的总线上,SCL接PB6,SDA接PB7;编码器的A相、B相分别接PA0和PA1,按下开关接PA2。三根信号线,两个外设,整个界面系统就齐了。软件开发上,这部分逻辑我放在了一个叫ui_task的模块里,与底层电机控制完全独立。
2.2 界面控制与运动控制的职责边界
在设计这个界面系统的时候,我最担心的事情是:界面的代码影响了电机控制的实时性。电机的PWM输出和编码器采样都是微秒级别的事,而屏幕刷新一帧动辄几毫秒。如果处理不好,界面一刷新,控制周期就被拉长,电机运行就会变抖。
我的解法是在架构上做严格的任务划分。整个固件分成了两个协作模块:ctrl_task和ui_task。ctrl_task负责所有与运动控制直接相关的操作——编码器计数读取、速度计算、PID计算、PWM输出,它跑在定时器中断里,中断频率设为1kHz,也就是控制周期正好是1ms。ui_task则跑在主循环的低优先级位置,负责屏幕刷新、编码器扫描、菜单状态更新和参数修改。
两个模块之间通过一个共享结构体通信,不直接互相调用:
typedef struct { volatile float target_speed; // 目标速度,单位rpm volatile float current_speed; // 当前速度反馈 volatile float pid_kp; // 速度环Kp volatile float pid_ki; // 速度环Ki volatile float pid_kd; // 速度环Kd volatile float pwm_duty; // 当前PWM占空比 volatile uint8_t run_mode; // 运行模式:开环/阶跃/闭环 volatile uint8_t param_dirty; // 参数是否有改动,需要保存 } system_shared_t;所有界面操作都只修改这个结构体里的值,不直接触碰任何控制寄存器。而控制中断里只读这个结构体来获取参数。也许有较真的读者会问:这个结构体不是原子的怎么办?比如正在编辑Kp的时候,中断刚好读了一半。这个问题在单精度float读写的场景下其实影响极其微小,因为STM32F103是32位机,32位变量的对齐读写天然是原子的。我这样设计之后实测运行很稳定,界面无论怎么刷新,电机的速度曲线都不会出现毛刺。
这个职责边界就是从架构上保证"界面不能拖垮控制"的根本手段。后续无论界面加多少功能,只要守住这条线,实时性就有保障。
2.3 参数结构标准化是一件前期就要做好的事
在写界面代码之前,我花了一点时间把参数结构彻底梳理了一遍。很多人的习惯是哪里有参数就随手定义个#define,到了要做界面的时候就傻了——参数散布在源码的各个角落,每个参数的类型、上下限、显示格式都不一样。想用菜单管理它们,简直是一场噩梦。
我在工程里专门定义了一张参数表,把所有需要在人机界面上调节的变量统一集中管理。每一条记录包含参数的维度信息:指针、显示名称、最小值、最大值、步进值、以及小数位。这个设计借鉴了上位机组态软件里"标签表"的思路,后来实践证明非常好用。
typedef struct { const char *name; // 显示名称,比如 "Kp" float *value_ptr; // 指向实际参数变量的指针 float min_val; // 最小值 float max_val; // 最大值 float step_val; // 步进值 uint8_t decimal; // 显示的小数位数 } param_item_t; static param_item_t param_table[] = { {"Kp", &shared.pid_kp, 0.0f, 10.0f, 0.01f, 2}, {"Ki", &shared.pid_ki, 0.0f, 5.0f, 0.01f, 2}, {"Kd", &shared.pid_kd, 0.0f, 1.0f, 0.001f, 3}, {"Target", &shared.target_speed, 0.0f, 3000.0f, 10.0f, 0}, // 后续可以在这里扩展新参数 };有了这张表,菜单界面就变得非常通用,它不需要知道自己正在编辑的是Kp还是Ki,只需要按照表里规定的最小值、最大值和步进值,对指针指向的变量做加减就行。参数表结构也让我省了很多事——后面我加新参数的时候,界面的代码一行都不用改,只需要往表里塞一行新条目。
这个"表驱动"的思维方式,是我整个界面系统里最得意的一个设计。它让UI逻辑和数据彻底解耦,菜单只是一个通用解释器,而不是每个页面一堆硬编码的if-else。
3. 固件里搭一个轻量菜单引擎
3.1 用状态机描述菜单导航
菜单系统听起来高大上,但在嵌入式平台上不能去跑什么重量级GUI框架。裸机环境下,我的选择是用一个有限状态机来管理菜单的层级和状态。状态不多,但每个状态的行为必须定义清楚。
菜单系统一共有五个核心状态:主界面、菜单选择、参数编辑、数据曲线、状态显示。它们之间的跳转关系是这样的:
- 默认开机进入主界面,显示当前运行模式和目标速度。
- 单击旋钮进入菜单选择界面,旋钮浏览功能项。
- 在菜单选择中选中"参数设置",按一下旋钮进入参数编辑。
- 参数编辑中用旋钮增减,长按返回上一层。
- 选中"实时曲线",进入波形显示界面。
- 在任何界面,超过一定时间没有操作,自动回到主界面。
状态机的核心结构是一个跳转表或者switch-case。我在实际工程里没用跳转表,因为状态数量少,switch-case的可读性更好。而且我在每个case里只放当前状态要处理的事情,避免"一个函数干所有事"的坏味道。
3.2 编码器输入处理与防抖
编码器是EC11,旋转时会输出相位相差90度的两路方波。常规做法是看AB相的电平组合判断方向。但在实际使用中,EC11有一个很烦人的特性:旋转到某个位置时,由于机械触点抖动,输出的脉冲会变得非常不规整,如果不做处理,一次旋转可能会被判定为两三次步进,菜单会乱跳。
我处理的方法有两层。第一层是硬件上在A、B相各接一个0.1uF的电容到地,做简单的RC低通滤波,这个基本能滤掉绝大部分的高频毛刺。第二层是软件消抖——在编码器中断服务函数里,不立刻判定方向,而是等一个稳定的电平组合出现后再更新计数。
一个常用的方式是读取当前AB相电平,与上一次的AB相电平组合,组成一个4位的格雷码索引,查表判断是正转还是反转:
const int8_t enc_lookup_table[16] = {0,-1,1,0, 1,0,0,-1, -1,0,0,1, 0,1,-1,0}; void EXTI0_IRQHandler(void) { // PA0 外部中断 uint8_t a = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0); uint8_t b = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1); uint8_t idx = (last_state << 2) | (a << 1) | b; enc_delta += enc_lookup_table[idx]; last_state = (a << 1) | b; if (enc_delta >= 4) { encoder_value++; enc_delta = 0; } else if (enc_delta <= -4) { encoder_value--; enc_delta = 0; } }这里enc_delta累积编码器的步进值,累计到4才算一次有效步进,相当于把4细分逻辑上合并成了一个步进。这样做的效果就是:即使编码器输出有抖动,只要最终弹回原位置,累计的delta会被清零,不会造成误触发。我试过,手指快速连转几格,菜单的响应非常跟手,几乎不会跳项。
3.3 参数编辑的增量逻辑
参数编辑是本系统里交互最多的场景。在参数编辑界面,旋钮每转一格,对应的参数值就按表里定义的步进来增减。但这里有个细节值得提:步进的选择不能无脑统一。
Kp这种参数,通常调节精度需要到0.01,而目标转速这种量级动辄几百上千,步进甚至可以是10rpm。如果所有参数都用同一个步进,那调Kp的时候可能要转几十格才能从1.0跨到1.5,手都酸了。
我在这张参数表里为每个参数单独定制了步进值。有一种更好的做法是"动态步进"——根据旋转的速度自动调整步进,转得快就大步进,转得慢就小步进。这个功能我还在验证中,目前的固定步进已经能覆盖大多数场景,另外还加了一个快捷方式:在参数编辑界面,长按旋钮可以快速跳到参数的最小值或最大值,用于恢复默认或者暴力设值,整定中很常用。
3.4 屏幕刷新策略:解决撕裂与闪烁
OLED的刷新是个经典的嵌入式难题。早期我把整个屏幕上所有文字都用OLED_ShowString重绘一遍,结果就是肉眼可见的闪烁。后来才意识到问题:每次全屏擦除再逐字符重绘,SSD1306内部滚动操作加上I2C的波特率限制,一帧至少要40ms以上。只要界面每40ms刷一次,人眼就会感受到明显的闪烁。
我的解决办法是"分区刷新"策略。只有值发生变化的部分才更新,而且更新的区域最小化。比如参数编辑界面,只有中间那个正在编辑的数值区域需要变化,上下两行菜单标题完全不重绘。这样在I2C上传输的字节数从全屏的1024字节降到了几十个字节,刷新率瞬间提升了一个数量级。
此外我还用了SSD1306的显存缓冲区,在内存里维护一帧1024字节的buffer,所有内容先画到buffer里,然后一次性把整块buffer通过I2C刷到屏幕上。这样的话,任何对屏幕的修改都先本地完成,避免多次I2C传输造成的不连续现象。
// 只更新一行的数据,减少I2C传输量 void oled_update_line(uint8_t page, uint8_t col_start, uint8_t *data, uint8_t len) { uint8_t cmd[] = {0x00, 0xB0 + page, 0x00 + (col_start & 0x0F), 0x10 + (col_start >> 4)}; // 先发送设置页和列的命令,再发送数据 }这个优化看起来不起眼,但实际效果是屏幕从"闪烁得没法看"变成了"非常稳"。在整定过程中看数值变化,视觉体验完全是两回事。
4. 整定最需要的两个功能:参数即时修改与波形观察
4.1 掉电保存,参数不能每次重来
人机界面上把参数改了,如果一断电就丢,那和反复烧录其实没多大区别。所以我需要把参数表里的值保存到Flash里,在设备下次上电时自动加载。STM32F103的Flash是一页1KB,这里我直接在里面划了一个别的小区域来掉电保存数据。
写Flash这件事,最关键的一点是要注意擦写寿命和写入次数。内部Flash的擦写次数通常在1万次左右,如果每次改一个值就写一次,用不了几天Flash就废了。我的方案是:只有确认某次整定完成,需要保存参数的时候,才通过菜单操作触发一次保存,而不是每次数值变化都写。参数param_dirty只有在确认保存时才置位,主循环检测到它被置位后就执行一次Flash擦写。
Flash的写入也做了一些简单的磨损处理,用了一个简单的写计数轮换地址方式,但说实话,在1万次寿命的前提下,一天只保存十几次的话,已经足够用几年了。对于大多数DIY项目来说,不需要上什么文件系统或者磨损均衡算法,适当地延迟写、减少写就够了。
4.2 实时曲线的环形缓冲区实现
波形观察是非常关键的功能,你调PID如果只能看见数字,很难判断系统是不是在震荡。我用128x64的OLED硬画了一条滚动波形曲线,显示的是目标速度与实际速度的对比。效果不算华丽,但足以让整定过程中的动态特性一目了然。
实现思路是用一个环形缓冲区存储若干采样点数据。控制中断每隔10ms往缓冲区里写入一个实际速度值,主循环里的绘图任务从缓冲区读取最近128个点,按比例换算成屏幕上的Y坐标,逐点连线显示。
#define WAVE_BUF_SIZE 128 volatile uint16_t wave_buf[WAVE_BUF_SIZE]; volatile uint8_t wave_head = 0; void ctrl_task_store_speed(void) { // 在控制中断里调用 wave_buf[wave_head] = (uint16_t)(shared.current_speed); wave_head = (wave_head + 1) % WAVE_BUF_SIZE; }绘图的时候,从wave_head往前倒数128个点,按顺序画到x=0到x=127的位置上。同心圆式的缩放我做了简单归一化,按当前显示的最大最小值映射。超调、振荡、稳态误差这些特征,在曲线上非常直观。
这里需要注意的一个坑是:如果直接在I2C刷新期间操作wave_buf,可能会出现数据被覆盖导致显示跳变。我通过在绘图过程中临时屏蔽控制中断的方式解决,或者用双缓冲,绘图读当前缓冲区,中断写另一个缓冲区,画完再交换。这个在裸机上用关中断保护一下就行,开销不大。
4.3 整定模式切换:开环、阶跃、还是闭环
整定过程中有一个非常高频的操作:切换运行模式。测系统开环特性要切开环模式,测阶跃响应要切阶跃模式,粗调完成之后又要切回闭环速度模式。如果这个切换要在菜单里翻几层才能找到,人会崩溃的。
我的设计是把运行模式直接放在主界面上,开机第一屏就是模式选择和目标速度。在非菜单子界面里,单击旋钮可以在几个模式之间快速循环切换。当然,出于安全考虑,切到闭环模式之前必须把目标速度设置到一个安全的起始值。这个安全逻辑我放在了模式切换的函数里,不做硬约束,但会弹出一个确认提示。整定现场手忙脚乱的时候,多一道确认,能少烧一个减速箱。
5. 从调试现场总结出来的避坑经验
5.1 编码器抖动千万别指望软件全兜住
前面提过软件消抖,但我想单独强调一下:不要过度依赖软件消抖。我的经验是,编码器的硬件电路一定要加滤波电容,尤其是手头的EC11品质参差不齐,部分廉价编码器在旋转到某些触点位置时会产生连续的随机抖动,软件查表法在这种情况下虽然不会误判方向,但会损失精度。
我走过的弯路是:一开始没加滤波电容,软件消抖处理得也很粗糙,结果在参数编辑界面里每转一格数值跳两三个步进,我还以为是消抖算法写得不对,排查了大半天。后来用一个100nF的贴片电容并在编码器AB脚上,整个世界瞬间安静了。所以先检查硬件,再折腾软件,这个顺序不能反。
5.2 Flash擦写别在中断里干
我在第一次设计Flash保存功能时差点犯了个错误:想在参数确认保存的那一瞬间直接在当前上下文中执行Flash擦写。如果这个过程发生在主循环里倒还好,但要命的是有些参数修改操作可以从编码器中断里触发。Flash擦写期间如果被更高级别的中断打断,长时间的总线占用可能导致控制中断被阻塞,电机控制就乱了。
我的解决方案是:任何需要保存的操作,只在主循环里执行,通过param_dirty标志来和中断上下文交互。主循环检测到标志后,先关闭所有不必要的中断,再执行擦写,完成后重新打开中断。如果你用的是带RTOS的环境,同样需要把这个操作放在一个低优先级任务里做。
这里再分享一个小技巧:STM32在擦写Flash的时候,读取Flash的代码会停止工作,所以这段擦写代码最好放在SRAM里执行,即"RAM执行"模式。我用了HAL库的HAL_FLASH_Program没有做RAM拷贝,实际测试没出问题,但如果你在擦写过程中发现程序跑飞了,优先检查这一条。
5.3 菜单层级太深是另一种灾难
很多界面系统失败不是因为功能不够,而是因为菜单太深了。如果用户要连续点七八次才能找到想调的参数,那这个界面就是个失败品。整定本来就是高频操作,功能入口必须尽量浅。
我设计的菜单只有两层:主界面直接显示运行模式和目标速度;按下进入菜单列表,第一个大项就是参数设置,旋钮选中后立即进入编辑;再按一下返回。整个操作路径最多三步。后来观察自己的使用习惯,发现80%的时间都是在主界面直接切模式和调速度,只有极少时候需要进入二级菜单去精确微调Kp、Ki。所以后来我把Kp、Ki、Kd这几个高频参数的快捷入口也做到了主界面里,双击旋钮直接跳到Kp编辑,省得每次翻菜单。
界面设计不是排版问题,是人体工学问题。
5.4 记住,界面就是一个独立的模块
最后一句经验:把界面代码封装成一个可以随时替换的模块。我做成这样之后,后来换过好几次显示方案——从OLED换到TFT,从裸机加了个简单的调度器——界面相关的代码改动都控制在ui_task.c这一个文件内部。底层控制逻辑完全不受影响。
一套好的界面层架构,除了服务当下的整定,还要为后续的“自动整定”预留能力。我目前就在计划下一步,让界面系统直接接收一整套扫频指令,在屏幕上实时显示伯德图。这不是画饼,只要界面的数据通道和菜单引擎是独立的,往上加功能就像搭积木。
整定前的这个人机界面,说白了就是用一两周的编码时间,换以后每次整定时的几十分钟。这个买卖怎么算都不亏。