news 2026/9/5 10:06:50

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
嵌入式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闭环,但还没有一个方便调参的工具;或者你已经在调了,但每次靠改代码烧录,实在调不动了。

我用的硬件组合比较常见,你手头的板子大概率也能跑:

  • 主控:STM32F103C8T6(Blue Pill),5块钱一片的经典货
  • 屏幕:淘了一个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.kpg_hmi.kig_hmi.kd来计算,每秒把温度、输出回写到结构体里。界面模块在主循环里检测结构体变化,再决定要不要刷新屏幕或发送串口指令。

这样做的核心好处是解耦。控制算法不知道屏幕是什么型号,界面也不知道PID算法是怎么算的,两者通过一个结构体沟通。将来你想把串口屏换成OLED,控制算法一行不用动。

3.2 菜单状态机:界面切换不要用if堆

串口屏虽然做界面,但界面逻辑还是要由主控整理。比如屏幕会通知我“编号3的按钮被按下”,我固件里得知道“编号3的按钮”是哪个页面上的、按下去应该干什么。

一开始我用了一堆if-else去判断页面和按钮,写了100多行就开始乱。后来我改用状态机思路,把整个界面划分成几个明确状态:

  • MENU_MAIN:主菜单,显示温度、PID参数概览
  • MENU_TARGET:目标温度设置
  • MENU_KPMENU_KIMENU_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 /* 最后一页Flash,1KB */ 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; /* 曲线控件ID=6,数据来源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输出计算错误或参数过大先设P=0,I=0,D=0,手动给输出验证电路,再逐步加参数

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%输出”“冻结当前设定值”之类的。这些按钮在产品里不会保留,但在调试期能帮你制造各种边界条件,测试控制器的反应。调试工具勇敢一点,思路开阔一点,比死磕算法代码有用多了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 10:04:07

全模态实时交互驱动全身移动操作:技术原理、挑战与工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 10:01:57

全局状态管理:从范式起源到Zustand与WebSocket的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 9:52:17

DeepSeek-Harness 调用第三方兼容 API:从配置到多路由排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 9:52:13

E103-W02 WiFi串口透传模块实战:从AT指令到驱动代码详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 9:49:22

脱敏卫士合同 AI 审查前置流程:本地脱敏、人工复核与安全副本

脱敏卫士合同 AI 审查前置流程&#xff1a;本地脱敏、人工复核与安全副本把合同交给 AI 审查&#xff0c;真正需要发送的是条款、权利义务、履行条件和风险分配。客户姓名、交易对手全称、联系方式、证件号码、开户地址等真实身份信息&#xff0c;通常不是模型判断违约责任或付…

作者头像 李华