做单片机和运动控制的朋友应该都经历过这个场景:板子焊好了、电机转起来了、编码器读数也正常了,然后到了最磨人的一步——调PID。第5期我把速度环跑通,第6期把位置环搭上,到了第6期末尾,我已经预感到第7期要面对什么:一遍遍地改Kp、Ki、Kd,编译烧录,上电看波波,不行再改,再来一次。一个参数试十几次很正常,每次烧录小一分钟,一天下来大半时间都耗在“编译-烧录-观察”这条流水线上。
后来我停下来想了想,问题的根源不是PID算法没写好,而是固件本身没有一个趁手的“观测和修改窗口”。你在电脑上改参数,烧进MCU里,运行起来之后如果想再改一个数,整个流程又重来一遍。这就像开车调座椅,每次调完都要下车重新点火,谁也受不了。
所以第7期的主题就定为:在正式整定之前,先给固件做一个人机界面。本期的目标很朴素——实现在线浏览参数、修改PID系数、掉电保存,调参的时候不再碰编译器。
如果你也正在做电机控制、电源调环、温控之类的项目,正被“烧录-看效果-再烧录”折腾得头疼,这期内容可以直接抄作业。我不讲大而全的GUI框架,就用一块几块钱的OLED屏加三个按键,搭一个真正够用的嵌入式HMI。整定之前,先给固件长出人机界面——这是本期的核心思路。
1. 整定之前,先想清楚人机界面到底解决什么问题
1.1 没有界面时,整定为什么这么痛苦
先说说我为什么要坚持在整定前把界面加上。没有界面的传统调参流程,往往是这样的:
- 在心里估算一组PID参数,或者按经验公式算一组初始值。
- 把参数硬编码进固件,比如
float Kp = 0.8f;,然后编译。 - 烧录,上电,用示波器看响应曲线,或者靠听电机声音、摸发热来粗判效果。
- 觉得超调大了,回到第一步,把Kp改成0.5,再编译、再烧录。
一轮操作下来,真正花在“思考参数该怎么调”上的时间,其实只占一小部分,大部分时间全耗在工具链环节。要是中间再碰上烧录器接触不良、编译告警、固件配置不对,节奏全乱。
更麻烦的是,整定过程中你往往需要同时改多个参数,比如先粗调Kp,再微调Ki,同时观察积分饱和现象。没有实时界面时,你只能一次改一个变量,然后烧录观察,效率非常低。而且你没法在系统运行的过程中“在线”改参数,也就没法做A/B对比——同一个工况下,参数A和参数B到底差多少,纯靠记忆和感觉,不严谨。
1.2 有了界面之后,整定流程变成什么样
加了人机界面之后,整套逻辑完全是另一码事。还是调PID,但流程变成了:
- 上电后,通过菜单直接进入PID参数页。
- 用按键把Kp从0.8改成0.5,确认保存。
- 系统还在运行,新参数立刻生效。
- 观察响应曲线,不满意,再进菜单,继续改Ki。
整个过程不重新编译、不烧录,甚至不用把控制回路停下来。一台设备,既能当被控对象,又能当调参台,这才是整定该有的节奏。我实测下来,加了界面之后,整定一轮参数的时间至少缩短到原来的三分之一,而且因为有实时参数显示,很多微妙的变化(比如积分项饱和、微分项放大噪声)能更快发现。
1.3 人机界面的方案选型,为什么我选了OLED+按键
人机界面的实现方式有很多种,我给不同方案做个对比,方便你按项目情况选。
| 方案 | 成本 | 开发难度 | 现场体验 | 适用场景 |
|---|---|---|---|---|
| LCD1602/LCD2004字符屏 | 低 | 低 | 只能显示字符,信息量有限 | 简单参数显示 |
| SSD1306 OLED(I2C/SPI) | 低 | 中 | 像素级控制,可以画简单曲线 | 嵌入式HMI首选 |
| 串口终端菜单 | 零硬件成本 | 低 | 需要连电脑,不利于现场调试 | 开发前期、服务器式设备 |
| 手机/WiFi/蓝牙远程调参 | 中 | 高 | 无线,方便,但要写APP或小程序 | 产品化、远程运维 |
| TFT彩屏+LVGL/emWin | 中高 | 高 | 效果好,控件丰富,但吃资源 | 高端产品、人机交互复杂 |
项目做到第7期,我的原则是用最小成本优先解决核心痛点。串口终端菜单虽然零成本,但无法摆脱电脑;TFT+LVGL效果好,但对STM32F103来说资源吃紧,且开发周期长。OLED+三个按键是最平衡的方案:SSD1306驱动成熟,像素可控,显示Kp、Ki、Kd和状态信息绰绰有余,还能顺便画一条小型实时波形。
2. 固件人机界面的整体设计:状态机是一切的核心
2.1 界面系统的三层结构
一个嵌入式人机界面,哪怕再简单,也必须有清晰的分层。我习惯把它分成三层:输入层、逻辑层、显示层。
- 输入层:负责读取按键事件,消抖,产生有效的单击、长按、旋转等动作。
- 逻辑层:维护当前菜单状态,根据按键事件完成状态切换、参数修改、保存等操作。这一层是核心,我用状态机来实现。
- 显示层:根据当前状态刷新OLED内容。只负责“画”,不负责“想”。
这三层之间用事件驱动的方式衔接。按键产生事件,事件传给逻辑层,逻辑层更新状态并通知显示层刷新。这样的好处是一个界面可以承载多个页面,每页都有自己的绘制函数,代码结构不至于变成一堆if-else堆成的意大利面。
2.2 菜单结构设计:让参数一览无余
PID整定过程中,需要频繁切换查看和修改的参数有:目标速度/位置、当前实际值、Kp、Ki、Kd、输出上限、运行模式(速度环/位置环/直接开环)。我把菜单设计成三层树形结构:
- 主页面:实时显示运行状态,包括当前模式、目标值、实际值、输出占空比。这是开机默认页面,也是日常观察的主界面。
- 参数页:列出全部可调参数,通过按键上下浏览。
- 编辑页:进入某个参数后,选中某一位或整个步进值,进行加减修改,确认后返回参数页。
这套结构做出来之后,整定过程的动线很顺:开机在主页面看运行状态,发现响应不行,按一下进参数页,选到Kp,进入编辑,改完退出,系统已经在用新参数跑了。
2.3 按键交互逻辑:单击、长按与快捷保存
硬件上我只预留了三个按键:上、下、确认。三个按键如何覆盖所有操作?核心在于给每个按键定义不同事件,并通过“长按”扩展功能:
- 短按上/下:在菜单中移动光标;在编辑页中增加/减少参数值。
- 短按确认:进入子菜单;进入编辑状态;保存当前修改并退出编辑。
- 长按确认(1秒以上):返回上级菜单。
- 长按上/下(0.5秒以上):快速连续增加/减少参数值,用于大幅度调整。
这里有个细节容易踩坑:单片机的按键扫描通常放在主循环或定时器中断里,事件生成后要通过标志位或消息队列传给逻辑层,不要在中断里直接做菜单切换或Flash写入。我见过有人为了省事直接在按键中断里改参数、写Flash,结果一个毛刺就把参数写坏了,教训相当深刻。
3. 核心实现:在STM32F103上把界面跑起来
3.1 硬件准备与接线
本期我用的是STM32F103C8T6最小系统板,搭配0.96寸SPI接口的SSD1306 OLED,以及三个轻触按键。接线很简单:
- OLED SCK -> PA5(SPI1_SCK)
- OLED MOSI -> PA7(SPI1_MOSI)
- OLED CS -> PA4(片选,软件控制)
- OLED DC -> PA3(数据/命令选择)
- OLED RES -> PA2(复位)
- 三个按键 -> PB0、PB1、PB10,均配置为内部上拉输入,按下为低电平
之所以选SPI接口而不是I2C接口的OLED,是因为SPI刷新率更高,后续如果我想在界面上画实时波形,SPI能顶住更高的帧率。I2C的OLED在纯显示文本时足够,但画曲线时会有明显的闪烁。
3.2 按键扫描:从硬件电平到可靠事件
按键扫描是界面交互的地基,地基不稳,上层全白搭。我的做法是:10ms定时器中断里扫描一次按键电平,判断按下状态,配合计数实现消抖和长按识别。
按键宏定义可以用题目中的风格来定义,我再补上完整的按键事件生成逻辑:
#include "stm32f10x.h" #include <string.h> #include <stdio.h> #include <stdlib.h> /* ======================= 硬件配置 ======================= */ #define KEY_UP_PORT GPIOB #define KEY_UP_PIN GPIO_Pin_0 #define KEY_DOWN_PORT GPIOB #define KEY_DOWN_PIN GPIO_Pin_1 #define KEY_OK_PORT GPIOB #define KEY_OK_PIN GPIO_Pin_10 typedef enum { KEY_NONE = 0, KEY_UP_SHORT, KEY_DOWN_SHORT, KEY_OK_SHORT, KEY_UP_LONG, KEY_DOWN_LONG, KEY_OK_LONG } KeyEvent; static uint8_t key_up_cnt = 0; static uint8_t key_down_cnt = 0; static uint8_t key_ok_cnt = 0; /* 10ms调用一次 */ void Key_ScanTask(void) { uint8_t up_level, down_level, ok_level; static uint8_t up_last, down_last, ok_last; static uint8_t up_long_pressed, down_long_pressed, ok_long_pressed; up_level = GPIO_ReadInputDataBit(KEY_UP_PORT, KEY_UP_PIN); down_level = GPIO_ReadInputDataBit(KEY_DOWN_PORT, KEY_DOWN_PIN); ok_level = GPIO_ReadInputDataBit(KEY_OK_PORT, KEY_OK_PIN); /* 处理上键 */ if (up_level == 0) { if (key_up_cnt < 255) key_up_cnt++; if (key_up_cnt >= 50 && !up_long_pressed) { /* 500ms记为长按 */ Key_EventPut(KEY_UP_LONG); up_long_pressed = 1; } } else { if (key_up_cnt > 0) { if (key_up_cnt < 50 && !up_last) { /* 小于500ms的按下是短按 */ Key_EventPut(KEY_UP_SHORT); } key_up_cnt = 0; up_long_pressed = 0; } } up_last = (up_level == 0) ? 1 : 0; /* 下键和确认键处理逻辑同上,代码略 */ }这段代码里最关键的是“短按”和“长按”的区分:按下时间小于500ms抬起为短按,大于等于500ms触发一次长按事件。实际使用时可以根据手感调整计数阈值,比如我习惯把小按键的短按窗口设为300ms。
Key_EventPut是一个环形队列的写入函数,主循环里用Key_EventGet取事件,这样按键处理不会阻塞主流程,也不会丢失快速连按的事件。
3.3 OLED驱动:让参数“看得见”
SSD1306的驱动代码网上很多,我不再全文照搬,只讲两个关键点。
第一是初始化序列必须规范。SSD1306上电后默认处于关闭状态,必须先发初始化命令序列,包括开关电荷泵、设置显示时钟、设置复用比、设置偏移、开启显示等。如果初始化顺序不对,屏幕可能不亮、花屏或者显示偏移。这个坑我踩过,一开始照抄了一个精简初始化序列,屏幕能亮但显示内容整体偏移,折腾半天才发现漏了0xD3, 0x02这个偏移设置命令。
第二是显示缓冲区的规划。SSD1306的分辨率是128x64,存储格式是每8行一个页(Page),共8页。我习惯在内存中维护一个uint8_t disp_buf[128][8]的缓冲区,所有绘制操作先写缓冲区,最后一次性把整帧推送到屏幕。这样能有效避免画面闪烁,也能方便地实现局部刷新。
绘制菜单时,我封装了几个基础函数:清屏、显示字符串(需要内置ASCII字库)、显示整数/浮点数、绘制水平线/矩形框、绘制小尺寸波形。用这些基础函数拼菜单界面,比直接操作像素点要快得多,也更好维护。
3.4 菜单状态机:界面的“大脑”
菜单逻辑我用一个简单的枚举状态机来管理。状态定义大致如下:
typedef enum { PAGE_MAIN = 0, /* 主状态页,显示运行参数 */ PAGE_MENU, /* 参数列表页 */ PAGE_EDIT, /* 参数编辑页 */ PAGE_ABOUT /* 关于/版本信息页 */ } MenuPage; typedef struct { MenuPage current_page; uint8_t menu_index; /* 当前选中的参数项索引 */ uint8_t edit_digit; /* 编辑状态下正在调整的数字位 */ uint8_t refresh; /* 标志位:需要刷屏 */ uint8_t param_count; /* 参数个数 */ } MenuManager;主循环里的处理逻辑就是不断检查事件队列,然后分发给不同页面的处理函数。比如在PAGE_MAIN下,按确认键进入PAGE_MENU;在PAGE_MENU下,按上下键移动menu_index,按确认键进入对应参数的PAGE_EDIT;在PAGE_EDIT下,按上下键修改数值,按确认键保存并返回。
这里有个设计细节值得多说一句:参数修改应该采用“当前值 + 步进调整”还是“逐位编辑”?我两种都试过:
- 步进调整:每次按一下,数值加上或减去一个固定步长(比如0.1)。逻辑简单,但调整范围大时按到手酸。
- 逐位编辑:进入编辑后,先选定位(个位、十分位、百分位),再对选中位加减。精准高效,但交互步骤多一点。
我的做法是折中:默认步进调整,步长为0.1;如果长按上下键,步进自动变成1.0(快速粗调)。这样从0.1到10.0的调整,最快只要十几下按键就能完成,实战中非常顺手。
3.5 参数掉电保存:整定结果不能每次重来
整定半天的最终成果,必须能掉电保存。STM32F103内部有Flash,可以模拟EEPROM使用,也可以用外部I2C EEPROM。我的选择是直接用内部Flash:在外设Flash的末尾扇区划出一块区域存放参数结构体,写入时先擦除整个扇区,再写入新数据。
参数结构体和保存函数如下:
typedef struct { float target_value; /* 目标值 */ float Kp; float Ki; float Kd; float output_limit; uint8_t ctrl_mode; /* 0-开环 1-速度环 2-位置环 */ uint16_t crc16; /* 校验和 */ } SystemParams; #define PARAM_FLASH_ADDR 0x0807F000 /* 最后一个扇区起始地址 */ Status Param_Save(SystemParams *params) { uint16_t crc = CRC16_Calc((uint8_t *)params, sizeof(SystemParams) - 2); params->crc16 = crc; FLASH_Unlock(); FLASH_ErasePage(PARAM_FLASH_ADDR); uint32_t *p = (uint32_t *)params; uint32_t addr = PARAM_FLASH_ADDR; for (uint8_t i = 0; i < sizeof(SystemParams) / 4; i++) { FLASH_ProgramWord(addr, *p); addr += 4; p++; } FLASH_Lock(); return SUCCESS; } void Param_Load(SystemParams *params) { memcpy(params, (uint8_t *)PARAM_FLASH_ADDR, sizeof(SystemParams)); uint16_t crc = CRC16_Calc((uint8_t *)params, sizeof(SystemParams) - 2); if (crc != params->crc16) { /* 校验失败说明Flash里没有有效参数,恢复默认值 */ Param_SetDefault(params); Param_Save(params); } }写Flash的坑主要有两个:
一个是对齐问题。STM32F103的Flash按16位字(Half Word)编程,地址必须偶对齐。所以最好以32位为单位写入,避免跨边界。我就是把参数结构体按4字节对齐,整个转发成uint32_t数组来操作。
另一个是擦写寿命。内部Flash擦写次数一般标称1万次,虽然整定过程不至于频繁保存,但我在代码里仍做了保护:只有确认键按下且参数确实被修改过,才触发保存;保存成功后OLED上会闪一下“SAVED”提示。这样既避免误操作,也避免每次都擦写Flash。
4. 从界面到整定:在线修改参数的完整闭环
4.1 让PID参数实时生效
界面上的参数改了之后,控制算法怎么知道?我用的方法是:控制循环里不直接操作局部变量,而是通过一个全局的SystemParams结构体引用参数。当界面修改并保存参数时,结构体的内容更新,控制循环下一次周期自然使用新参数。
示意图大致是:PID计算函数读g_params.Kp、g_params.Ki、g_params.Kd,界面编辑函数写g_params.Kp,两者共享同一个结构体。这个思路简单直接,但要注意并发问题:如果控制循环在定时器中断里运行,而界面函数在主循环里运行,理论上有“读到半个更新”的风险。实践中因为修改参数本身是一次赋值操作,且我们不在控制中断里写Flash,所以风险很低。如果追求严谨,可以在主循环里先关中断再更新结构体,更新完再开中断,代码就万无一失。
4.2 在OLED上画实时响应曲线
整定PID,只看数字是不行的,得看到动态响应过程。OLED的128x64分辨率画一条折线没问题。我在主页面下方开辟了一个64像素高的波形区域,把当前温度/转速/位置采样值实时映射成坐标点,屏上滚动显示最近128个采样周期的曲线。
这样整定的时候,目标值设成一个阶跃信号,屏幕直接画出响应曲线:超调多少、上升时间多长、有没有振荡,一目了然。这比串口波形助手还方便,因为数据不经过电脑,纯粹在MCU内部完成,零延迟零丢包。
画波形的核心代码其实就是一个环形缓冲区和简单的映射:
#define WAVE_BUF_LEN 96 static int16_t wave_buf[WAVE_BUF_LEN]; static uint8_t wave_count = 0; void Wave_AddSample(int16_t value) { wave_buf[wave_count % WAVE_BUF_LEN] = value; wave_count++; } void Wave_Draw(void) { uint8_t x = 0; int16_t last_y = 0; for (uint8_t i = 0; i < WAVE_BUF_LEN && i < wave_count; i++) { int16_t y = 63 - (wave_buf[i] + 900) * 40 / 900; /* 映射示例,按量程调整 */ if (i > 0) OLED_DrawLine(x - 1, last_y, x, y); last_y = y; x++; } }这里要注意映射关系的数值范围,不同的传感器量程差异很大。更好的做法是先定义一个统一的归一化接口,把物理量先映射到0-100,再换算成屏幕坐标,这样改传感器量程时不会把绘图逻辑也带乱。
4.3 配合界面做一次完整的PID整定
界面跑通后,我实际做了一次位置环的整定,方法用的还是经典的临界比例法(Ziegler-Nichols)。流程大致是:
- 先把Ki、Kd设为0,只留Kp。
- 从很小的Kp开始(比如0.05),通过界面上调,每次增加0.02,观察波形。
- 当曲线出现等幅振荡时,记下此时的临界增益Kc和振荡周期Tc。
- 按经验公式算出Kp=0.6Kc、Ki=1.2Kc/Tc、Kd=3Kc*Tc/40,输入界面。
- 做一次阶跃响应,根据超调量和整定时间再做微调。
整个过程我没有重新编译过一次固件。Kp从0.02调到临界值,全靠按键在界面上蹦,配合屏幕上的波形图,边看边改,不到二十分钟就拿到了满意的动态响应。要是放在以前,这个进度至少得烧录十几次。
有一点必须提醒:在线调参虽然方便,但改Kp到临界振荡的过程中,系统是处于不稳定状态的。做实验时一定要把输出限幅设得比较保守(比如最大占空比限制在30%),防止电机飞车或者执行器剧烈动作。安全永远排在第一位。
5. 常见问题与排查技巧实录
5.1 OLED不亮或花屏
OLED上电不亮,先查供电,3.3V电源要稳。再查复位引脚,很多基线代码依赖MCU的GPIO拉低再拉高复位,如果复位引脚悬空,屏幕就是黑屏。花屏的情况多半是SPI时序不对,尝试降低SPI时钟频率(比如从18MHz降到4.5MHz),很多杂牌OLED在高速SPI下就是不稳定。
5.2 按键不灵或连按
按键不灵先看消抖代码,没有软件消抖的按键,在强干扰环境下会抖到怀疑人生。我的建议是不管什么项目,按键扫描一律用定时器中断+状态机,不要用HAL_Delay在循环里傻等。连按的问题通常是事件队列里积压了太多短按事件,长按和短按的判定阈值没分清楚。我踩过一次:长按确认键返回时,抬起瞬间又触发了一个短按确认,页面刚返回又进去了。解决办法是长按事件触发后,设置一个“长按已触发”的标志,抬起检测短按时先看这个标志,触发了就不再抛短按事件。
5.3 参数保存后重启丢失
这个问题我遇到过两种原因。一种是Flash写入没有解锁就调用FLASH_ProgramWord,返回错误但是代码没检查,结果参数根本没写进去。Flash编程前一定要FLASH_Unlock(),写完再FLASH_Lock()。另一种是CRC校验计算的范围和保存的范围不一致,结构体字节对齐导致sizeof算出来的尺寸带上填充字节,读出来之后校验老是不对。解决方法是写死校验范围,或者把参数结构体定义成__packed,不要依赖编译器对齐。
5.4 界面卡死或主循环响应慢
OLED整屏刷新116字节数据,用SPI在4.5MHz下大约需要25毫秒。如果每帧都全屏刷新,主循环会明显变慢,按键响应迟钝。我的解决办法是把显示刷新拆成两种:文本和菜单变化时全量刷新;曲线区域每50ms刷新一次即可。另外,菜单状态机里所有分支都要有明确的break和默认处理,否则事件处理逻辑里漏掉某个状态组合,就会出现“按下没反应”的卡死假象。
针对这些坑,我整理了一个速查表,方便你排查问题。
| 现象 | 排查方向 | 解决建议 |
|---|---|---|
| OLED黑屏无反应 | 供电、复位引脚、初始化序列 | 检查3.3V,拉低再拉高复位脚,补齐初始化命令 |
| OLED花屏或偏移 | SPI速率、初始化顺序 | 降SPI时钟,检查D3偏移命令 |
| 按键按下无反应 | 消抖、GPIO上下拉配置 | 确认内部上拉已开启,检查事件队列是否溢出 |
| 按键一次触发两次 | 长按标志未复位 | 长按触发后置位标志,抬起时区分短按/长按 |
| 参数重启恢复默认 | Flash写入失败、CRC范围错误 | 检查FLASH_Unlock,固定CRC计算范围 |
| 显示刷新卡顿 | 全屏刷新太频繁 | 曲线区与文本区拆分刷新,不要每帧全刷 |
写在最后的经验
这个界面做完之后,我最大的感受是:嵌入式开发里很多时候效率低,不是因为你不够勤快,而是工具链的反馈链路太长。给固件加一个人机界面,本质上是缩短“决策-执行-反馈”的环路,让调参这件事从“编译-烧录-重启”变成“按键-观察-调整”。
如果你也想在自己的项目里做类似的东西,我建议别一开始就想着上豪华GUI库,先把最核心的“看参数、改参数、存参数”跑通,就像本期这样,一块OLED加三颗按键就够用。后续如果觉得界面不够炫,再往LVGL方向扩展也不迟。
另外说个小技巧:做界面的时候不妨把“关于页”也加上,里面放固件版本号和编译日期。调参调懵的时候,看一眼版本号能立刻确认自己手上是不是最新的固件,避免拿旧固件调了半天参数,最后发现改的代码压根没烧进去。这种事我干过一次,之后所有固件都老老实实标版本号。
本期的内容到这儿就差不多了。下一期我打算把界面上那条波形做得更丰富一些,加上双通道对比和缩放功能,让整定过程更直观。如果你们在实际操作中遇到什么奇怪的问题,欢迎在评论区交流,踩过的坑大多能互相救命。