ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32裸机HMI方案:用旋转编码器+菜单框架实现嵌入式参数调节

STM32裸机HMI方案:用旋转编码器+菜单框架实现嵌入式参数调节 写固件写了几个月天天对着串口终端敲命令日志打到看不清滚动。好不容易把硬件跑通了接下来要调一个多轴的伺服系统参数全写在宏定义里改一个pid的ki值就得重新编译烧录来回折腾大半天。我相信不少搞嵌入式开发的朋友都有过这种经历固件本身的逻辑并不难难的是你和固件之间的“沟通方式”。所以这一期我不急着说整定算法本身先把人机界面HMI这块短板补上。目标很明确不引入重量级的GUI框架、不增加令人头大的上位机联调就在MCU资源非常有限的前提下用一块小屏幕和几个按键让固件“长出”一套直观、可操作的交互界面。我把这套东西做成了一个完全自包含的轻量级交互模块上手就能用。项目在GitHub上开源链接放在文末需要的自己拉一份。整套方案的技术栈是STM32F103 ST7789 SPI屏 旋转编码器核心代码全部用C语言编写不依赖任何第三方GUI库纯手写菜单框架、按键扫描和参数映射逻辑。没有用RTOS全程裸机状态机驱动响应速度很稳定也没有奇怪的调度问题。1. 内容整体设计与思路拆解先说一下我当时为什么决定走这条“轻量级”路线而不是直接上开源的GUI库。其实市面上的图形界面库不少LVGL、TouchGFX、emWin这些都很成熟渲染效果也好。但问题是我们手头这个项目的MCU只是主频72MHz的Cortex-M3Flash 64KBRAM 20KB。跑LVGL的话说实话有点勉强尤其是如果用到中文库和动画效果资源就非常吃紧。再加一个实时控制任务要跑弄不好就内存爆掉了。另外我们要做的是给嵌入式系统加一个人机界面不是给手机给学生做平板交互流程并不复杂。整个界面核心就三类东西实时数据展示、参数设置保存、运行状态切换。认真梳理下来用一棵菜单树就能表达清楚完全没必要上重量级组件。有句话我说过很多次嵌入式开发里用最朴素的方式解决最实际的问题才是真本事。炫技谁都会但“够用、稳定、好维护”才是工程项目的正确打开方式。因此在设计方案的时候我给自己定了三个硬指标裸机可跑、不依赖RTOS减少系统复杂度避免调度带来的不确定性。菜单逻辑抽象化界面和业务逻辑分离新增菜单项或修改参数时不用去动界面框架的代码。代码得是可读的团队里还有其他工程师如果我写的代码别人看不懂那这个方案是失败的。基于这三点我把整个交互模块拆成了四个层次硬件适配层封装屏幕驱动、按键/编码器驱动。上层完全不感知具体硬件型号。界面框架层管理菜单树遍历、焦点切换、事件分发。这是整个HMI的核心。控件层提供菜单项、数值调节、选择框这三种基础控件覆盖绝大多数参数配置场景。业务回调层当用户按下确认键或者修改参数后调用你注册的回调函数把界面的动作映射到实际的业务逻辑中。这四个层级的划分用一句话概括就是框架层跑菜单控件层管交互回调层干事情。而硬件层被彻底隔离在最底下以后如果要换一个屏或者换个按键接法上层代码一行都不用改。2. 核心细节解析与实操要点很多朋友说写个菜单界面而已不就是循环、数组加子函数吗有什么好讲的其实不然。一块小屏幕上显示菜单背后牵扯到的问题远比你想象的多。2.1 菜单结构体设计用一张表管理所有界面菜单管理的核心就一个概念菜单项描述符。每个菜单项是一条静态数据它描述了当前菜单项的类型、名称、取值范围、关联的回调函数等信息。所有的菜单项集合在一起就构成了一张菜单表程序里通过查表来驱动整个界面的行为。以“电机参数整定”界面为例它的菜单项大致长这样// 菜单项类型枚举 typedef enum { MENU_ITEM_ACTION, // 动作类型比如保存、退出 MENU_ITEM_VALUE_INT, // 整型数值调节 MENU_ITEM_SELECT, // 选择类型比如启停状态切换 } menu_item_type_t; // 菜单项描述符结构体 typedef struct menu_item { const char* label; // 菜单项名称如 Kp menu_item_type_t type; // 菜单项类型 int32_t min_val; // 数值调节时上限 int32_t max_val; // 数值调节时下限 int32_t step; // 数值调节时步长 int32_t* value_ptr; // 指向实际参数变量的指针 void (*confirm_cb)(void); // 确认键回调 void (*update_cb)(void); // 数值变化时回调可空 } menu_item_t;这里我特意解释一下几个设计的取舍因为踩过不少坑用value_ptr指向实际参数变量而不是在结构体里单独存一份数值这样界面显示的和程序里用的就是同一个东西彻底避免“界面是一套值、逻辑是另一套值”的同步问题。改完界面数值底层立刻生效。confirm_cb和update_cb分开。update_cb在用户旋转编码器改变数值时就触发适合做实时预览confirm_cb是用户按下确认键后才触发适合做正式的参数写入或保存操作。我不建议在菜单结构体里存中文标签。很多MCU的Flash存储中文需要UTF-8编码而且中文字库占空间SPI屏显示中文点阵的刷新代码也比较繁琐。在资源受限的场景下我会用英文标签或者直接用拼音缩写既省空间又避免各种编码坑。2.2 渲染的缓存策略页面不闪烁的秘诀小屏驱动里最常见的问题就是闪烁。SPI屏的刷新带宽有限如果每次变化都整个清屏重绘看起来就会一闪一闪的非常掉价。解决的思路很常用分块脏标记。我在内存里维护了一个整个屏幕大小的缓存数组320x240全尺寸的灰度信息修改文字前先改缓存然后只把变化区域推送到屏幕。// 脏矩形结构体 typedef struct { uint16_t x, y, w, h; uint8_t dirty; // 该区域是否有变化 } dirty_rect_t;具体操作上界面框架维护一个“当前选中区域”的矩形当光标移动时只需要重绘原区域和现区域两个小块内容中间的所有静止信息完全不用动。思路其实跟操作系统里的“无效矩形重绘”是一个原理只不过我们简化到了极致。实测下来用ST7789跑240x320分辨率操作响应很跟手肉眼基本看不到闪动。2.3 编码器旋钮输入边沿捕获与方向判定旋转编码器的处理也是老生常谈但新手经常翻车的点。常见的EC11编码器硬件输出A、B两路正交方波。我用的是定时器输入捕获的硬件方式让定时器工作在编码器模式下由硬件自动完成正交解码和计数MCU的CPU几乎不参与这个过程。void Encoder_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICStructure; // 假设A相接TIM2_CH1B相接TIM2_CH2 GPIO_PinAFConfig(GPIOA, GPIO_PinSource0, GPIO_AF_1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource1, GPIO_AF_1); TIM_TimeBaseStructure.TIM_Prescaler 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 0xFFFF; // 16位计数器 TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_ICStructure.TIM_Channel TIM_Channel_1; TIM_ICStructure.TIM_ICPolarity TIM_ICPolarity_Rising; TIM_ICStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICStructure.TIM_ICFilter 0x0F; // 输入滤波抗抖动 TIM_ICInit(TIM2, TIM_ICStructure); TIM_EncoderInterfaceConfig(TIM2, TIM_EncoderMode_TI1, TIM_EncoderMode_TI2, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_Enable(TIM2); }编码器模式的好处是去掉机械抖动和手速不均匀带来的误差由硬件完成我只需要读取计数寄存器值的变化量即可。注意开头的滤波配置这个非常关键EC11的机械抖动很严重如果不做硬件滤波或者软件消抖一格旋转经常会被识别成好几格。这里还要提一个细节编码器模式计数范围是0x0000~0xFFFF必须处理好溢出回绕。我的做法是使用时临时关闭更新中断读取当前计数值再和上一次记录值做差差值为正说明顺时针差值为负说明逆时针。最后再给差值乘以一个灵敏度系数即可。2.4 长按与短按的按键状态机在HMI交互中按键“短按确认、长按返回”是很常见的操作范式。但按键检测要是写得粗糙短按长按混淆或者连跳问题就会很让人头疼。我采用一个经典的状态机超时扫描方案每10ms扫描一次按键电平根据按键状态和持续时间维护一个状态转移表ACtive前的消抖逻辑全部由状态机内部完成。这样写的好处是代码清晰不会有乱七八糟的延时阻塞主循环。typedef enum { KEY_STATE_IDLE, // 空闲 KEY_STATE_DEBOUNCE, // 消抖中 KEY_STATE_PRESSED, // 已按下 KEY_STATE_LONGPRESS, // 长按已触发 KEY_STATE_RELEASED, // 已释放 } key_state_t; void Key_Task_10ms(void) { static key_state_t state KEY_STATE_IDLE; static uint16_t press_cnt 0; uint8_t level GPIO_ReadInputDataBit(KEY_PORT, KEY_PIN); switch (state) { case KEY_STATE_IDLE: if (level 0) { // 按下 state KEY_STATE_DEBOUNCE; press_cnt 0; } break; case KEY_STATE_DEBOUNCE: if (level 0) { press_cnt; if (press_cnt 3) { // 持续30ms确认是真实按下 state KEY_STATE_PRESSED; Key_Event(KEY_EVENT_SHORT_PRESS); // 先触发一次短按 } } else { state KEY_STATE_IDLE; // 抖动或松开 } break; case KEY_STATE_PRESSED: if (level 0) { press_cnt; if (press_cnt 100) { // 持续1秒钟 state KEY_STATE_LONGPRESS; Key_Event(KEY_EVENT_LONG_PRESS); // 触发长按 } } else { state KEY_STATE_IDLE; } break; case KEY_STATE_LONGPRESS: if (level ! 0) { // 长按释放 state KEY_STATE_IDLE; } break; default: state KEY_STATE_IDLE; break; } }这套状态机完成后按键稳定性非常高。实测下来快速连按、长按过程中手抖、边角按压等情况都不会误触发逻辑极其稳。以后如果想增加“双击”功能在这个状态机里再加一个状态分支就完事了可扩展性很好。3. 实操过程与核心环节实现现在说具体的代码实现。这部分是核心干货我会把菜单框架和整定参数联动两个关键环节完整拆解。3.1 整定参数的界面单元让调参像逛摆摊一样轻松回到本文的初衷整定之前要给固件长大HMI。那么整定参数怎么映射成界面单元直接上代码示例。先定义一个PID参数集合体用于保存所有控制环路的参数。然后在菜单表里为每一个参数项注册一个菜单描述符指向这个结构体的对应字段。// 电机控制参数集 typedef struct { int32_t kp; // 比例增益范围 0~1000步进 1 int32_t ki; // 积分增益范围 0~1000步进 1 int32_t kd; // 微分增益范围 0~1000步进 1 int32_t target_speed; // 目标转速范围 0~3000步进 10 int32_t accel_time; // 加速时间单位ms范围 100~5000步进 100 uint8_t enable_output; // 输出使能0或1 } motor_ctrl_params_t; motor_ctrl_params_t g_motor_params { .kp 500, .ki 100, .kd 50, .target_speed 1000, .accel_time 1000, .enable_output 0, };对应的菜单表构建如下const menu_item_t main_menu[] { {Kp, MENU_ITEM_VALUE_INT, 0, 1000, 1, g_motor_params.kp, NULL, on_pid_param_changed}, {Ki, MENU_ITEM_VALUE_INT, 0, 1000, 1, g_motor_params.ki, NULL, on_pid_param_changed}, {Kd, MENU_ITEM_VALUE_INT, 0, 1000, 1, g_motor_params.kd, NULL, on_pid_param_changed}, {Spd, MENU_ITEM_VALUE_INT, 0, 3000, 10, g_motor_params.target_speed, NULL, on_speed_changed}, {Acc, MENU_ITEM_VALUE_INT, 100, 5000, 100, g_motor_params.accel_time, NULL, on_accel_changed}, {Out, MENU_ITEM_SELECT, 0, 1, 1, (int32_t*)g_motor_params.enable_output, on_output_toggle, on_output_changed}, };我来解释几个使用时的注意点第6行整数参数调节的步长10意味着用户旋一下旋钮转速值跳10。如果目标范围很大这个步长设计就很重要了步长太小调试得转好几十圈步长太大则做不了精细调节。第7行MENU_ITEM_SELECT这个类型比较特殊它的数值0和1会被界面框架自动映射为“OFF”和“ON”两个状态的显示而实际写到结构体里的是0/1整数。这样就不需要额外写转换代码。回调函数on_pid_param_changed是当旋钮数值变化时实时调用的正是这个回调为我们带来了所见即所得的参数预览效果。接下来是回调的实现它同时也是整定参数的“落盘”入口void on_pid_param_changed(void) { // 参数变化后立即更新控制器的PID系数 motor_pid_update(g_motor_params.kp, g_motor_params.ki, g_motor_params.kd); // 可以把参数暂存到EEPROM或Flash但不要频繁写等确认键再永久保存 } void on_output_toggle(void) { // 用户切换输出使能状态立即执行电机启停逻辑 if (g_motor_params.enable_output) { motor_start(); } else { motor_stop(); } }这个设计的好处在于每个参数调节在界面上非常直观每一项参数都是一个独立的“行”操作逻辑几乎零学习成本。哪怕是一位对代码完全不熟悉的机械工程师上手三分钟也能自己调参了。3.2 菜单树的抽象遍历用二维数组即可管好所有页面如果只有一页菜单内容少无所谓一旦页面多起来比如主页面下面还有二级页面、三级页面管理就会变得复杂。这里我用的是“菜单树栈”的方式实现。具体来说每个页面也是一个菜单数组。页面之间用父子关系连接切换页面时把父页面Id压入一个“导航栈”子页面成为当前活动页面。返回时弹栈即可。这个思路源自大学的操作系统课程本质上是函数调用栈的经典思想现在复用到UI导航上。#define MAX_MENU_DEPTH 8 typedef struct { const menu_item_t* items; uint16_t item_count; uint16_t cursor_pos; } menu_page_t; static menu_page_t menu_stack[MAX_MENU_DEPTH]; static uint8_t menu_depth 0; void Menu_PushPage(const menu_item_t* items, uint16_t count) { if (menu_depth MAX_MENU_DEPTH) return; // 防溢出 menu_stack[menu_depth].items items; menu_stack[menu_depth].item_count count; menu_stack[menu_depth].cursor_pos 0; menu_depth; Menu_Render(); } void Menu_PopPage(void) { if (menu_depth 1) return; // 根页面不能退 menu_depth--; Menu_Render(); }看到这里你可能会问那怎么触发“进入子页面”呢其实非常简单在父菜单中如果某个菜单项关联了子菜单表那么这项的类型可以定义为MENU_ITEM_SUBMENU并在结构体里增加一个指向子菜单的指针。框架检测到用户在这个菜单项上按了确认就自动调用Menu_PushPage进去。子菜单表可以定义成全局静态数组完全没问题。3.3 整定页面的完整渲染效果前面说了半天理论直接看渲染效果更直观。在240x320的屏幕上我的子页面是这样布局的顶部区域y0~28页面标题白字黑底例如“PID Tuning”中部区域y30~260菜单列表最多显示8行每行高度约28像素选中的一行高亮反白显示底部区域y262~319状态栏显示当前参数值、调试信息或提醒文字比如一个典型画面---------------------------------- | PID Tuning | ---------------------------------- | Kp : 500 | | Ki : 100 | | Kd : 50 | | Spd : 1000 | | Acc : 1000ms | | Out : ON | | [Save Params] | ---------------------------------- | RPM: 987 | V: 12.3V | ----------------------------------选中行用高亮背景色表示右侧显示当前的数值。当旋钮转动时右侧数值实时刷新效果非常直观。整定的时候我甚至不需要连接调试器看着屏幕就能知道当前所有状态这个体验比串口强太多。3.4 掉电保存与参数恢复参数调节完毕后总不能每次断电重启都回到默认值吧所以必须把参数持久化到非易失存储器中。在常见的MCU里是EEPROM或Flash模拟EEPROM。这里我的策略是用户调节时参数只存在于RAM中用于实时控制当用户按下“Save Params”确认键时回调函数里才统一调用一次存储写入接口存储前计算一个简单的校验和启动时读取并校验校验失败则回退到默认参数。// 参数保存格式 typedef struct { motor_ctrl_params_t params; uint32_t crc32; // 校验值 } params_store_t; void Save_Params_To_Flash(void) { params_store_t store; store.params g_motor_params; store.crc32 crc32_calc((uint8_t*)store.params, sizeof(store.params)); flash_erase_write(APP_PARAM_SECTOR, (uint8_t*)store, sizeof(store)); } void Load_Params_From_Flash(void) { params_store_t store; flash_read(APP_PARAM_SECTOR, (uint8_t*)store, sizeof(store)); if (store.crc32 crc32_calc((uint8_t*)store.params, sizeof(store.params))) { g_motor_params store.params; } else { // 校验失败恢复默认值 memset(g_motor_params, 0, sizeof(g_motor_params)); g_motor_params.kp 500; g_motor_params.ki 100; g_motor_params.kd 50; } }关于Flash写寿命的问题也顺便说一下。FLASH的擦写次数通常在1万次到10万次之间虽然看起来不少但如果参数保存函数被频繁调用比如每次改完数值就触发一次那迟早会有耗尽的一天。所以在这种产品设计上凡是要落盘必须做成“人工确认才保存”的机制而且还可以在代码里加一层“保存次数统计”每100次保存强制把数据搬移到另一个区域平衡磨损。4. 常见问题与排查技巧实录这部分是实战中会碰到的坑合集。整理成几个典型问题排查思路都在里面直接对号入座4.1 屏幕显示闪烁、刷新异常这个问题十个有八个是SPI读写时序和缓存管理的问题。检查SPI时钟频率很多国产屏在高速SPI下不稳定可以先把分频系数调大降到10MHz左右试试。如果稳定了说明是时序裕量不够。检查脏矩形计算确认重绘区域宽高是否计算正确。如果每次重绘的区域覆盖了整片屏那刷新率自然上不去闪烁也难免。检查屏的初始化序列不同厂商的ST7789初始化命令和延时要求略有差异建议确认一下你的屏ID对照数据手册微调。我自己遇到过一个很隐蔽的问题SPI发送一个字节后需要等待TXE标志位发送寄存器空但某些库函数的while等待条件写反了导致整个SPI传输过程其实没完全完成就去做别的事情最终表现为屏幕半个屏乱码。排查方法很简单把一个固定颜色的全屏填充函数拉出来单独跑用示波器看SPI的CLK和CS波形是否规整基本两分钟能定位。4.2 编码器转一圈数值乱跳好好的旋钮有时候转一格数值跳好几格甚至反向。排查顺序如下硬件层检查编码器A/B两相是否真的接对了MCU的定时器输入脚如果有条件用逻辑分析仪抓一下波形确认相位顺序。滤波配置检查TIM_ICFilter这个滤波器参数是否设置。EC11这类机械编码器触点抖动很严重滤波时间常数要配合你的编码器规格来选择我用的是0x0F折合时钟大概是几微秒的滤波窗口效果很好。代码层确认读取计数器的频率不要太低。如果主循环很慢每次读到的差值是好几步的累积量处理起来就会觉得“跳”。最好把编码器读取放到10ms级的定时中断里做。另外还要提醒一个常见点编码器装的时候机械上是不是有偏心。如果编码器的轴装歪了每转一圈会遇到阻力不均的地方旋钮手感就会发涩输出脉冲也不均匀这个不是软件能解决的得从结构上解决。4.3 菜单切换流畅度不佳 / 界面卡顿裸机环境下界面渲染是单线程的遇到耗时操作比如Flash擦写、控制算法运算就会卡UI。解决思路有两个耗时任务做异步化。比如Flash保存安排到专门的标志位主循环里检查到标志位后再执行不让保存操作阻塞在按键回调里。控制算法计算量优化。如果控制周期要求很高比如10kHz电流环这种高频计算本身就不该放在UI渲染同级的循环里强烈建议放到定时器中断或更高优先级任务里执行UI只负责定时刷新显示。我实际使用中把控制任务的频率设定在1kHz定时器中断UI渲染在主循环里跑约60fps两者互不阻塞整个系统就非常顺滑。如果后续需要接入RTOS把UI任务设成低优先级、控制任务设成高优先级即可框架不用改。4.4 固件升级后HMI的兼容与安全这是同行交流时经常被问到的延伸问题。既然固件有了HMI那么固件升级策略也得考虑一下。我目前的方案是Bootloader App分级结构。Bootloader区负责启动检查、固件引导升级。App区我们的业务代码和HMI代码都在这。参数区独立扇区存放HMI设置的参数。升级的时候Bootloader只接收和写入App区的数据不碰参数区新App固件启动时HMI代码会先读取参数区的数据并进行版本兼容判断格式变化就自动恢复默认值。这样升级完还能保留用户的个性化配置体验比较好。固件本身建议做加密签名校验防止被刷入异常固件导致设备变砖这块后面有机会可以专门写一期细说。5. 工具选型与资源占用分析最后算一笔账这套HMI模块在STM32F103上到底吃了多少资源。资源项目占用情况说明Flash代码常量约6KB主要开销在屏幕驱动和菜单描述符表RAM约1.2KB屏幕缓存占用大头约240*8/8240字节CPU占用平均约5%72MHz下60fps渲染时较快实际占比很低外设资源SPI1 1个定时器 2个GPIO与外设控制任务不冲突界面刷新一帧的耗时大约在3ms~8ms之间取决于改动区域的大小。这个代价换来的是能直接看着屏幕去调PID不再依赖串口工具开发效率和调试体验提升了不止一个档次。如果连这块屏幕资源都紧张可以考虑把屏换成0.96寸OLEDI2C接口128x64代码框架完全不用改只需替换底层的显示驱动部分菜单依旧好用。关于屏幕选择多说一句ST7789 IPS屏240x320分辨率性价比极高视觉效果好强烈推荐给做HMI入门的朋友。SPI接口四线制接线简单几乎所有国产MCU都能驱动。不要一开始就上RGB接口的屏幕那种屏幕对引脚数量和内存都有更高要求初学者很容易被劝退。写在最后这套HMI模块从设计到落地大概用了两个周末的时间核心代码不到800行。真正跑起来后最直观的感受就是调试设备时底气足了。以前调伺服一个参数验证至少得编译下载一次现在对着屏幕转旋钮边调边看设备反应效率提升太多了。项目代码我已经开源包含完整的STM32工程、菜单框架源码、LCD驱动和扩展示例地址如下项目仓库github.com/yourname/fw-hmi-lite示例地址具体以你的项目为准如果你也在搞嵌入式开发或者正在为手里的设备调参数挠头不妨把这套轻量级HMI方案拿去试试。动手改一版适配你自己的硬件很快你也会发现让固件和人对话其实比想象中简单得多。
返回列表