尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

STM32嵌入式人机界面实战:OLED+按键实现PID在线调参与参数存储

发布时间:2026/9/8 6:36:43

资讯中心
01
ARTICLE

STM32嵌入式人机界面实战:OLED+按键实现PID在线调参与参数存储

STM32嵌入式人机界面实战:OLED+按键实现PID在线调参与参数存储
做单片机和运动控制的朋友应该都经历过这个场景板子焊好了、电机转起来了、编码器读数也正常了然后到了最磨人的一步——调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 OLEDI2C/SPI低中像素级控制可以画简单曲线嵌入式HMI首选串口终端菜单零硬件成本低需要连电脑不利于现场调试开发前期、服务器式设备手机/WiFi/蓝牙远程调参中高无线方便但要写APP或小程序产品化、远程运维TFT彩屏LVGL/emWin中高高效果好控件丰富但吃资源高端产品、人机交互复杂项目做到第7期我的原则是用最小成本优先解决核心痛点。串口终端菜单虽然零成本但无法摆脱电脑TFTLVGL效果好但对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 - PA5SPI1_SCKOLED MOSI - PA7SPI1_MOSIOLED 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。按经验公式算出Kp0.6Kc、Ki1.2Kc/Tc、Kd3Kc*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方向扩展也不迟。另外说个小技巧做界面的时候不妨把“关于页”也加上里面放固件版本号和编译日期。调参调懵的时候看一眼版本号能立刻确认自己手上是不是最新的固件避免拿旧固件调了半天参数最后发现改的代码压根没烧进去。这种事我干过一次之后所有固件都老老实实标版本号。本期的内容到这儿就差不多了。下一期我打算把界面上那条波形做得更丰富一些加上双通道对比和缩放功能让整定过程更直观。如果你们在实际操作中遇到什么奇怪的问题欢迎在评论区交流踩过的坑大多能互相救命。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。