十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

嵌入式PID整定效率低?固件集成串口屏HMI的可视化调试方案

嵌入式PID整定效率低?固件集成串口屏HMI的可视化调试方案 干了这么多年嵌入式我越来越觉得一个朴素的道理是对的你在固件里调试一个东西如果看不到它内部的状态那这个调试就是盲人摸象。就拿PID整定来说。早先我做一个恒温控制模块用的STM32F103核心逻辑就一百来行——读温度、算偏差、跑PID、输出PWM。听起来挺简单真调起来能把人逼疯。每改一个Kp都要改宏定义、重新编译、烧录、上电、等温度爬升、看串口日志一套流程下来五六分钟。来回几十趟一天就没了。后来我想明白了问题不在PID算法而在缺一个能让我“看见”和“操作”的人机界面。所以这个系列到第7期我决定停下写算法的脚步先折腾一件看起来不太“核心”的事把一个人机界面塞进固件里。界面本身不参与控制但它能让整定这件事从“猜”变成“试”。这篇就把我完整做下来的方案、代码思路、还有踩过的坑一次说清楚。1. 整定不是玄学是“盲调”与“看见”的差距1.1 没界面之前我是怎么调PID的大部分入门教程教PID都是先给你一个传递函数然后教你怎么用临界比例法、衰减曲线法去算Kp、Ki、Kd。理论上没毛病但到了实际项目里被控对象往往说不清楚是几阶系统——比如加热模块有热惯性、有环境温度扰动、供电电压还会波动。这时候用理论公式硬算算完也得靠实测修正。我之前的流程是这样的在代码里定义#define KP 8.0f烧进去看温度曲线超调大了就改小Kp上升慢了就调大Ki一次只改一个参数然后重复“编译—烧录—观察—记录”。一个下午能试二三十次真正有效的数据没几条大部分时间浪费在等系统稳定上。后来我做过一个统计真正花在思考参数为什么不对的时间不到总调试时间的三分之一剩下全是在做机械性的重复劳动。这个体验太糟糕了。1.2 一个能改参数、能看波形的界面把整定从“猜”变成“试”后来我给固件加了一个简单的人机界面说白了三件事**实时显示温度、设定值和输出占空比能在屏幕上直接改PID三个参数能看到最近几十秒的温度变化曲线。**就这三条把调试效率拉高了一个量级。以前改Kp要重烧固件现在在屏幕上按两下就改完了系统还在跑PID参数即时生效。以前看超调量要靠串口记录曲线再导进Excel画图现在屏幕上直接画一条滚动曲线眼睛一扫就知道该往哪个方向调。以前改完参数总担心记不住哪组效果好现在界面里存了好几组参数随时切回去对比。这种感觉怎么说呢就像你原来是蒙着眼睛调一台机器手摸到哪里算哪里界面一出来机器内部的状态全摆在眼前你动一个旋钮马上能看到它怎么响应。整定的本质就是观察系统对不同参数的响应所以你观察手段越强整定效率越高。1.3 这期内容适合谁、要用到什么硬件这期内容主要面向的读者是手头有一个单片机控制的物理系统加热、电机、温箱、电源等准备做PID闭环但还没有一个方便调参的工具或者你已经在调了但每次靠改代码烧录实在调不动了。我用的硬件组合比较常见你手头的板子大概率也能跑主控STM32F103C8T6Blue Pill5块钱一片的经典货屏幕淘了一个3.2寸USART串口屏电阻触摸版花了大概六十块传感器DS18B20温度传感器量程够用精度一般但做演示够了执行器固态继电器控制一个60W加热片调试口一个USB转TTL模块CH340的就行串口屏是我故意选的原因后面细说。如果你手头只有OLED我也给了一套裸屏方案代码思路是一样的只是绘制部分要换一下。这篇的侧重点不是某个具体屏幕的教程而是“怎么在固件和界面之间把事情架构好”。2. 给固件选一款能“长出”界面的方案2.1 方案一串口屏适合快速开发和验证串口屏USART HMI屏是我这次的主力方案。它的核心思路是屏幕本身内置一颗MCU自己跑界面逻辑主控单片机只通过串口收发指令告诉屏幕“你要显示什么”“哪个按钮被按下了”。这个方案的优点很实际主控负担极小。界面刷新、触摸检测、控件渲染全部由屏幕内部MCU完成主控只处理字符串指令对实时控制几乎没有影响。开发速度快。用厂商提供的上位机软件拖拽控件画按钮、文本框、曲线图十几分钟能做出一个像样的界面然后在固件里写串口协议就行。适合调试场景。PID整定过程中要反复修改界面布局和显示内容界面上改完直接下载到屏幕里固件那边只要协议不变完全不用动。它的缺点也存在屏幕成本比裸屏高一点当然现在国产串口屏已经很便宜另外触摸响应和刷新速率取决于屏幕内部MCU做不了高动态的复杂动画。2.2 方案二本地小屏OLED/TFT更适合产品化如果你做的是最终产品不想塞一块昂贵的串口屏进去那本地小屏方案就值得考虑。比如0.96寸I2C OLED或者1.8寸TFT主控直接驱动。优点是成本低、体积小、完全受控缺点就是所有绘制、刷新、触摸如果有都要自己写开发周期明显拉长而且刷新逻辑写不好会吃掉大量CPU时间影响控制回路。我用过OLED做过一次类似的界面128x64分辨率写菜单、画实时曲线勉强够用但显示内容非常有限PID参数一页只能放三行。TFT会好很多但裸TFT加上触摸驱动、中文字库工作量直接翻倍。2.3 方案三蓝牙/WiFi上位机调试功能更强第三种路子是走无线主控通过蓝牙或者WiFi把数据发给手机或电脑上位机你在上位机上改参数、看曲线。这个方案调试功能最强图表可以做得很丰富也不占嵌入式屏幕的成本和空间。但代价也是明显的需要额外的无线模块和协议栈比如ESP32做透传或者自己调蓝牙GATT服务。整定过程依赖上位机在线一旦通信断了你连参数都看不了万一系统失控你都不知道发生了什么。上位机本身要写PC版用Qt、C#手机版还要学Android/iOS门槛高不少。2.4 我的选择串口屏为主预留本地OLED这次我选了串口屏当主力原因就俩一是整定场景下调试效率最优先开发快改得快二是主控压力小PID控制周期不被打扰。与此同时我在代码里保留了一个弱化的显示模块抽象层——同样的数据更新函数既发串口屏指令也能发I2C给OLED。以后产品化要把串口屏换掉OLED接口已经预留好了。这里有一个选型原则想分享**调试工具和产品形态不一定要用同一个东西。**很多人一上来就想把最终产品的显示方案定下来然后拿它做调试结果调试效率被显示方案拖垮。反过来调PID这种阶段选一个能让你最快看到数据的工具比什么都重要。3. 固件里长出界面的核心实现3.1 数据通道控制算法与界面之间要有一条干净的路很多人给固件加界面最容易犯的错是界面的代码和控制算法搅在一起按键一触发就直接改PID变量数据显示直接写在控制中断里。这种写法能跑但后面改起来非常难受。我的习惯是定义一套共享数据结构界面只通过这套结构来交换数据不直接访问控制算法的内部变量。/* hmi_data.h */ typedef struct { float target_temp; // 目标温度界面可修改 float current_temp; // 当前温度只读 float kp; // 比例系数界面可修改 float ki; // 积分系数界面可修改 float kd; // 微分系数界面可修改 float output; // PID输出占空比只读单位% uint8_t run_state; // 运行状态0停止 1运行界面可修改 uint8_t param_valid; // 参数合法性标志 uint8_t save_request; // 请求保存参数到Flash } hmi_data_t; extern volatile hmi_data_t g_hmi;控制算法在定时器中断里读取g_hmi.kp、g_hmi.ki、g_hmi.kd来计算每秒把温度、输出回写到结构体里。界面模块在主循环里检测结构体变化再决定要不要刷新屏幕或发送串口指令。这样做的核心好处是解耦。控制算法不知道屏幕是什么型号界面也不知道PID算法是怎么算的两者通过一个结构体沟通。将来你想把串口屏换成OLED控制算法一行不用动。3.2 菜单状态机界面切换不要用if堆串口屏虽然做界面但界面逻辑还是要由主控整理。比如屏幕会通知我“编号3的按钮被按下”我固件里得知道“编号3的按钮”是哪个页面上的、按下去应该干什么。一开始我用了一堆if-else去判断页面和按钮写了100多行就开始乱。后来我改用状态机思路把整个界面划分成几个明确状态MENU_MAIN主菜单显示温度、PID参数概览MENU_TARGET目标温度设置MENU_KP、MENU_KI、MENU_KD三个PID参数的独立设置页MENU_CURVE实时曲线页MENU_SAVE参数保存确认页typedef enum { MENU_MAIN, MENU_TARGET, MENU_KP, MENU_KI, MENU_KD, MENU_CURVE, MENU_SAVE } menu_state_t; void hmi_handle_event(uint8_t page_id, uint8_t btn_id) { switch (g_menu_state) { case MENU_MAIN: if (btn_id 1) g_menu_state MENU_TARGET; else if (btn_id 2) g_menu_state MENU_KP; /* ... */ break; case MENU_KP: if (btn_id 1) g_hmi.kp 0.1f; // 加 else if (btn_id 2) g_hmi.kp - 0.1f; // 减 else if (btn_id 3) g_menu_state MENU_MAIN; // 返回 break; /* ... */ } }状态机的好处是不管界面多复杂逻辑都是平铺的每个状态只处理自己的事件不容易互相干扰。后面加页面只需要加一个枚举值和一个case分支。3.3 参数修改与掉电保存界面能改参数还只算一半参数必须能掉电保存否则每次上电都回到默认值整定过程中积累的好参数就全丢了。STM32F103内部有小容量Flash可以直接拿一个扇区来存参数。#define PARAM_SAVE_ADDR 0x0807F800 /* 最后一页Flash1KB */ typedef struct { float kp; float ki; float kd; float target_temp; uint32_t magic; /* 校验魔数防止读到垃圾数据 */ } param_block_t; void param_save_to_flash(void) { param_block_t block; block.kp g_hmi.kp; block.ki g_hmi.ki; block.kd g_hmi.kd; block.target_temp g_hmi.target_temp; block.magic 0xA5A5A5A5; FLASH_Unlock(); FLASH_ErasePage(PARAM_SAVE_ADDR); uint32_t *src (uint32_t *)block; uint32_t *dst (uint32_t *)PARAM_SAVE_ADDR; for (int i 0; i sizeof(param_block_t) / 4; i) { FLASH_ProgramWord((uint32_t)(dst i), src[i]); } FLASH_Lock(); } void param_load_from_flash(void) { param_block_t *block (param_block_t *)PARAM_SAVE_ADDR; if (block-magic ! 0xA5A5A5A5) { /* 首次运行或参数损坏使用默认值 */ g_hmi.kp 10.0f; g_hmi.ki 1.0f; g_hmi.kd 0.0f; return; } g_hmi.kp block-kp; g_hmi.ki block-ki; g_hmi.kd block-kd; g_hmi.target_temp block-target_temp; }有几个细节值得注意。第一**Flash写入前必须擦除而且是整页擦除。**上面代码保存时先FLASH_ErasePage再逐个写顺序不能反过来。第二Flash写入次数有限STM32F103的Flash寿命大约1万次擦写所以不要每次改参数都存最好做成手动触发——界面上放一个“保存参数”按钮确认之后才写Flash。第三加了magic校验防止芯片初次上电时Flash里是随机值被当成合法参数加载那系统可能直接就飞了。3.4 界面上的实时曲线怎么做PID整定最需要看的是变化趋势。串口屏厂商的上位机软件一般都提供“曲线控件”主控通过串口不断往屏幕发新数据点屏幕自己滚动绘制。这个过程对主控来说非常轻。我的做法是控制任务每200ms往g_hmi结构体里更新一次当前温度。界面任务每200ms读一次如果数值有变化就拼一条串口指令发出去。/* 曲线数据发送printf通过串口1输出到串口屏 */ void hmi_update_curve(void) { static uint8_t seq 0; /* 曲线控件ID6数据来源channel 0 */ printf(add 6,0,%d\r\n, (int)(g_hmi.current_temp * 10)); seq; }曲线数据不是每次必发的否则屏幕端数据点会过密、曲线拉不开。我实际测试下来200ms一个点屏幕显示30秒左右的滚动窗口看起来最舒服。太密了曲线糊成一团太疏了动态趋势看不出来。如果将来用裸屏自己画曲线思路也差不多维护一个环形缓冲区存最近N秒的温度点屏幕刷新时依次取出画成折线。区别只是绘图要自己做别的都一样。4. 让界面不拖后腿刷新策略与性能优化4.1 刷屏频率设计HMI一加进去最容易出问题的就是它开始抢占控制任务的CPU时间。我最早一版代码在串口屏和温度采集共用同一个定时器中断回调结果温度采样被拉长PID指令输出抖动系统吼得更欢了。后来我定了两个原则控制任务不动PID计算仍然放在1kHz定时器中断里优先级最高什么都不准碰它。界面任务靠后所有串口屏通信、曲线发送、按键处理全部放到主循环里通过状态标志位触发。主循环典型写法大概是int main(void) { /* 初始化... */ while (1) { if (hmi_has_event()) { hmi_handle_event(); /* 处理触摸按键事件 */ } if (hmi_tick_100ms()) { hmi_update_text(); /* 每100ms刷新一次数值显示 */ } if (hmi_tick_200ms()) { hmi_update_curve(); /* 每200ms追加一个曲线点 */ } } }这样的调度方式保证了一件事——就算屏幕卡了、串口堵了、触摸没反应PID控制永远不会被打断系统不会因为界面卡死而失控。4.2 局部刷新与分时任务串口屏自身会处理整个页面的绘制但主控发数据时如果你一股脑全发也会带来两个问题一是串口带宽被占满真正重要的数据无法插入二是屏幕频繁整页刷新肉眼看会闪烁。我的做法是分时刷新。比如每100ms的周期里这次刷温度值下次刷输出占空比再下次刷PID参数。每条数据都是独立的更新指令不整页重发。这样串口负载非常低屏幕也不会闪。static uint8_t display_slot 0; void hmi_update_text(void) { switch (display_slot) { case 0: printf(t1.txt\%.1f\\r\n, g_hmi.current_temp); break; case 1: printf(t2.txt\%.1f\\r\n, g_hmi.target_temp); break; case 2: printf(t3.txt\%.1f\\r\n, g_hmi.output); break; case 3: printf(t4.txt\%.2f\\r\n, g_hmi.kp); break; /* ... */ } display_slot (display_slot 1) % 4; }串口屏的指令格式一般是控件名.txt值不同厂商略有差异但基本框架就是主控发文本指令、屏幕解析执行这部分看手册改一下即可。4.3 数据类型与字符串格式化的坑这期在串口通信上踩得最深的一个坑是printf的浮点数格式化和串口波特率之间的博弈。STM32的printf默认不支持浮点数输出需要重定向fputc并且要在编译选项里开启--printf_fp具体看你用的工具链。如果没开启浮点支持你printf(%.2f, kp)打出去可能是空字符串或者乱码。int fputc(int ch, FILE *f) { while (!(USART1-SR USART_SR_TXE)); USART1-DR ch; return ch; }另外我工程里最终没直接用printf来做串口屏通信而是封装了一个hmi_send()用于字符串发送再配合sprintf拼好指令。这样更稳一点因为printf的重定向目标随时可能被调试log占用。真到项目化阶段建议把底层发送函数统一管理别让多个模块同时抢串口。void hmi_send(const char *cmd) { while (*cmd) { while (!(USART1-SR USART_SR_TXE)); USART1-DR *cmd; } } void hmi_set_text(const char *ctrl, const char *value) { char buf[32]; sprintf(buf, %s.txt\%s\\r\n, ctrl, value); hmi_send(buf); }波特率方面我用的串口屏最高支持921600但实际用115200最稳定。整定阶段数据量不大115200完全够用。再高波特率在劣质杜邦线下容易出错屏幕显示出现随机字符排查起来很费劲。5. 实测中踩过的坑与排查技巧实录5.1 常见问题速查表这一节把我在实际过程中遇到的问题整理成了表基本都是真实发生过的按出现频率排序。现象原因解决办法屏幕花屏、显示乱码波特率不匹配或接线不良先统一115200杜邦线尽量短检查RX/TX交叉触摸按钮没反应触摸屏校准丢失串口屏上位机里重新校准触摸再下载固件上电参数异常系统暴走Flash读到垃圾数据加magic校验首次启动强制默认值改了参数但PID输出没变化PID定时器读到的是旧参数确认共享结构体声明为volatile检查访问位置曲线数据乱了点不在图内数据发送频率和显示范围不匹配调慢周期曲线范围要覆盖你实际输出范围屏幕卡住过一会又恢复串口发送缓冲区堵塞降低发送频率不要每条数据都整页刷新温度显示跳变DS18B20时序有问题或供电不足加4.7k上拉电阻数据线远离电源线发热失控温度一直涨PID输出计算错误或参数过大先设P0I0D0手动给输出验证电路再逐步加参数5.2 一个印象深刻的排查案例参数改了不生效这期碰到的怪问题特别想拿出来讲。现象是界面上把Kp从8改到20屏幕也显示了20但温度曲线纹丝不动跟没改一样。我先查了控制算法发现PID在1kHz中断里用的是g_hmi.kp界面修改的也是这个变量按理说应该生效。后来一步一步排查发现问题出在编译优化上——我给TI定时器中断里加了一个static float local_kp在初始化时赋值了一次之后就没再更新。界面改了g_hmi.kp中断里用的还是旧的局部副本自然不生效。这个坑的根因是我自己的代码风格问题为了减少中断里的计算量我把参数复制到局部变量却忘了每次循环重新读取。修正后改成直接在中断里引用g_hmi.kp问题消失。像这种问题最好的排查方法不是看代码而是先确认数据到底有没有到控制函数。你知道界面改了变量但控制函数读到的值是多少要有手段打印出来。我就是在中断外用串口debug打印了一次g_hmi.kp发现一直是8才顺着定位到局部副本的问题。5.3 实操心得界面做好之后整定的方式也要跟着变一旦界面好用了我发现整定的思路也要调整。以前我习惯一次只改一个参数然后等好几分钟看结果。现在界面能秒改秒看我反而是快速试几个极端值先摸清系统的边界。比如我会先设一个特别大的Kp比如直接设成50看系统会不会震荡再设一个特别小的Kp比如1看系统是不是慢得像蜗牛。通过几个极端点的观察很快能确定Kp的有效范围大概在哪个量级然后再在有效范围内做细调。这种做法比“从默认值小幅逼近”快得多。另外界面保存参数的功能不只是省事它还能帮你做参数对比实验。我把A组参数存成一份B组参数存一份同一工况下切换对比直接看曲线上谁的超调小、谁到达稳态快。这个功能在传统“改代码烧录”法里根本不可能实现因为切换成本太高了。5.4 给下一期的铺垫界面做好了PID整定的下一步就是真正动手调了。下一期我准备用这期做好的界面完整调一遍这个恒温系统把从零开始确定Kp、Ki、Kd的全过程记录下来顺便验证一下那几条“经验公式”在实操中到底靠不靠谱。如果你也准备给固件加界面然后认真做整定建议先把这期的串口协议和数据结构跑通别急着让界面有多炫能用、稳定、不干扰控制才是这期的核心目标。最后再分享一个小技巧串口屏的界面文件其实可以反复改你完全可以在整定过程中不断往屏幕上加“临时调试按钮”比如“强制100%输出”“冻结当前设定值”之类的。这些按钮在产品里不会保留但在调试期能帮你制造各种边界条件测试控制器的反应。调试工具勇敢一点思路开阔一点比死磕算法代码有用多了。
返回列表