ARTICLE DETAIL

资讯详情

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

STM32+emWin+Modbus RTU打造工业HMI:界面与通信协作实战

STM32+emWin+Modbus RTU打造工业HMI:界面与通信协作实战 去年接到一个温控柜改造的活儿客户要求把用了十几年的数码管仪表和旋钮面板换成一块带触摸的液晶屏。底层设备网络不动整条总线上挂着温控器、变频器和电量表清一色Modbus RTU协议。我选了STM32F407加emWin自己画板子从零搭了一套带界面的显示控制终端。这个项目做完之后最大的感触是emWin和Modbus通信单独学都不难难的是让图形界面和报文收发在同一个嵌入式系统里高效协作不卡界面、不丢帧、不乱码。这篇文章就把整个开发过程、方案取舍和实测踩坑记录下来给准备做类似HMI项目的朋友一个参考。1. 项目背景与需求界定先搞清楚屏到底是主还是从动手写代码之前我花了不少时间跟客户确认需求。很多HMI项目翻车的根源不是技术而是在一开始没想清楚这块屏在整个系统里的角色。1.1 这个屏要干什么活客户现场的情况是每个温控柜里有若干台设备全部挂在一条RS485总线上主机是柜内原有的控制PLC。客户希望新加的触摸屏不改变原有PLC的从站逻辑直接以Modbus主站的身份去轮询这些设备把温度、湿度、电流、频率显示出来同时允许操作员在屏上修改温控目标值、启停阀门。我把需求归纳成三条硬指标界面能实时显示8个从站的关键运行参数刷新周期不超过1秒。触摸设定操作必须立等可见按下按键后界面不能卡死。屏端通信故障时要有明确提示但不能让整段总线瘫痪。第三条特别关键。屏是后加的第三方主站如果它疯狂发无效报文占着总线不放会影响原有PLC的正常通信。所以屏端Modbus主站必须要有严格的超时和重试逻辑一次轮询失败后按策略退避而不是无脑怼。1.2 自己画板不买工业HMI的原因这项目一开始也考虑过直接用威纶通或者昆仑通态的触摸屏它们自带Modbus驱动组态一下就能用。但客户有两个要求第一设备外观和开机画面要深度定制和他们的产品系列统一风格第二以后要接自定义协议的变频器现成HMI的驱动列表不一定覆盖。算了一笔账一体化HMI单价千元左右但每台都要额外做协议调试遇到不支持的协议基本无解。自研方案的物料成本大概三百块主要贵在开模和调试时间上但软件积累下来是可以复用的。所以决定自己画板硬件选型如下。部件型号/规格备注主控STM32F407VET6168MHz512KB Flash192KB RAM屏幕4.3寸 480x272 RGB接口带GT911电容触摸通信USART2 SP3485RS485EMI滤波和TVS保护GUI库STemWin 5.44SEGGER授权给ST的版本开发环境Keil MDK-ARM 5AC5编译器优化等级-O2这里补充一个选型逻辑没有用F103是因为要挂RGB屏幕F103没有LTDC控制器外接RGB屏会占用大量IO和内存刷新性能也不好。F4系列自带硬件LCD控制器和DMA2DemWin跑起来流畅得多。1.3 裸机还是RTOS这个决定影响后面所有代码项目规模不大界面加协议总共几千行代码裸机主循环完全够用。但我在设计数据流的时候就按以后随时能上RTOS的思路做共享变量全部用结构体封装不在中断里做业务逻辑。实际开发也证明裸机模式下调试Modbus时序比多线程直观得多逻辑分析仪抓出来的波形和代码执行顺序一一对应排查问题快不少。2. emWin界面层先把窗口框架立住通信层留着接口等对接我的开发顺序是先把界面跑起来用假数据填充再写Modbus驱动。这样界面和通信可以各自独立调试最后对接的时候只需要在定时器回调里把假数据换成真实缓冲区即可。2.1 移植emWin的几个配置点STemWin在STM32F407上的移植不算麻烦官方都提供了现成的驱动文件但有几个配置直接决定稳定性。GUI内存池大小我用GUI_ALLOC_AssignMemory分配了48KB。64KB当然更宽裕但F407的192KB RAM还要留给串口DMA、显示缓冲和业务数据综合平衡后48KB是底线。低于32KB会出现创建控件失败或者花屏的诡异现象排查起来非常痛苦。触摸适配GT911走I2C接口注意它默认地址是0x5D但有些模块上拉到0x14。我在GT911驱动初始化时做了地址探测避免焊接批次不同导致触摸失灵。显示屏配置RGB接口屏的时序参数必须和屏幕手册一致包括前肩、后肩、同步脉宽和像素时钟。F407的LTDC对时钟要求比较严格我用逻辑分析仪验证了实际刷新率和屏参确保PLL配置正确。2.2 窗口结构和刷新策略决定了流畅度界面分三个页面设备总览页、单设备详情页、参数设置页。用的是emWin的框架窗口通过WM_CreateWindow创建一个主窗口内部用对话框资源表生成子控件通过MULTIPAGE或PROGBAR这类容器切换。刷新策略我一开始走了弯路。第一版图省事收到新数据就WM_InvalidateWindow整个窗口500ms一次全局重绘结果屏幕上所有控件瞬间闪一下看久了眼花。后来改成只更新数值变化的TEXT控件用WM_InvalidateRect局部无效刷新闪烁问题立刻消失。实际上emWin的TEXT控件文本内容没变的时候调用WM_SetText不会产生重绘动作所以我只需要判断新旧数据是否一致再决定要不要更新控件。核心代码模式如下static void update_text_from_buffer(void) { char buf[16]; for (int i 0; i CTRL_ITEM_COUNT; i) { float new_val get_ctrl_value(i); if (fabs(new_val - g_disp_cache[i]) 0.01f) { sprintf(buf, %.1f, new_val); WM_SetText(WM_GetDialogItem(g_hDlg, ctrl_id_of(i)), buf); g_disp_cache[i] new_val; } } }缓存数组g_disp_cache专门用来存上一次的显示值只有变化才刷新控件。这招对降低CPU占用和维护视觉稳定性都很有效。2.3 控件ID到Modbus寄存器的映射表这是整个项目里我认为最漂亮的设计。界面上每个需要显示或写入的控件都对应一条映射记录包含控件ID、从站地址、寄存器地址、寄存器类型和显示比例系数。typedef struct { uint16_t ctrl_id; // emWin控件ID uint8_t slave_addr; // Modbus从站地址 uint16_t reg_addr; // 寄存器地址 uint8_t reg_type; // 0保持寄存器 1输入寄存器 float scale; // 原始值乘以scale得到显示值 } ctrl_reg_map_t; static const ctrl_reg_map_t s_ctrl_map[] { { ID_TEMP_1, 0x01, 0x0000, 0, 0.1f }, { ID_HUMI_1, 0x01, 0x0001, 0, 0.1f }, { ID_CUR_1, 0x02, 0x0003, 0, 0.01f }, { ID_FREQ_1, 0x03, 0x0000, 1, 0.01f }, };界面添加新参数或者调整布局时只改这张表通信层一行代码不用动。反过来如果某个寄存器定义变了只需要改表里的地址或系数。这种解耦方式在多页面对多从站时优势很明显。3. Modbus RTU通信层把协议栈做成一个纯粹的哑巴驱动通信层我坚持一个原则不掺入任何界面逻辑只提供读写接口和数据回调。它就是一个哑巴你告诉它读哪个从站哪个寄存器它按Modbus RTU要求发报文、收应答、把数据写进缓冲区然后继续干下一件事。3.1 帧格式和从站时间特性Modbus RTU是主从半双工协议串口参数8N1速率这里用了9600bps。客户现场总线长度在几百米9600是稳妥选择丢包率远低于115200。RTU帧格式很简单一张表就能说清。字段长度说明地址码1字节从站地址1到247功能码1字节0x03读保持寄存器0x06写单个寄存器0x10写多个寄存器数据区N字节起始地址、寄存器数量或寄存器值CRC162字节CRC校验低字节在前读从站1的保持寄存器0x0000开始的8个寄存器请求帧是01 03 00 00 00 08 CRC_L CRC_H从站正常应答时第一个字节是从站地址第二个字节是功能码第三字节是字节数后面跟着数据最后是CRC。3.2 接收侧中断只负责收字节解析全部放到主循环串口中断里只做一件事把收到的字节塞进环形缓冲区。F407的USART2开启了接收中断每收一个字节进入中断一次。不要在中断里判断帧类型、解析CRC这些操作耗时且容易导致下一个字节溢出。读取和解析放在10ms周期的主循环任务里用状态机把环形缓冲区里的数据组成一帧。static rx_state_t rx_parse_byte(uint8_t b) { switch (g_rx_state) { case RX_WAIT_ADDR: if (b host_addr) { g_rx_state RX_WAIT_FUNC; return RX_GOING; } break; case RX_WAIT_FUNC: if (b req_func()) { g_rx_state RX_WAIT_DATA; return RX_GOING; } break; case RX_WAIT_DATA: g_rx_buf[g_rx_len] b; if (g_rx_len expected_len()) { g_rx_state RX_WAIT_CRC_L; } return RX_GOING; case RX_WAIT_CRC_L: g_rx_crc b; g_rx_state RX_WAIT_CRC_H; return RX_GOING; case RX_WAIT_CRC_H: g_rx_crc | (uint16_t)b 8; if (verify_crc(g_rx_buf, g_rx_len, g_rx_crc)) { g_rx_state RX_IDLE; return RX_DONE; } g_rx_state RX_WAIT_ADDR; return RX_ERROR; } return RX_GOING; }帧界定的关键在于超时判断。Modbus RTU规定帧内字符间隔不能超过3.5个字符时间9600bps下约4ms。我开了一个硬件定时器每1ms计一次数只要超过4ms没收到新字节就认为当前帧结束。这样不管是粘包还是半包都能正确切开。3.3 发送侧RS485方向切换是门玄学RS485是半双工总线使用SP3485时DE引脚控制收发方向。方向切换的时机如果处理不好会出现两类诡异现象一类是请求帧发不完整另一类是回答帧首字节被吞掉。我最初用延时解决方向切换但延时不够稳定后来改成发送完成中断加一个字节时间延时void modbus_send_frame(uint8_t *buf, uint16_t len) { RS485_DE(1); HAL_UART_Transmit_DMA(huart2, buf, len); // 在DMA发送完成中断里等待TC然后再延时一个字节时间 }关键在发送完成中断的处理不能一进中断立刻拉低DE。要等USART移位寄存器里的最后一个字节真正移出完毕再额外延时一个字节时间然后才切回接收方向。如果切换太快总线上最后一个停止位会被截断从站可能直接忽略整帧如果切换太慢从站回帧超时又会被当成通信故障。用一个位时间换算公式单字节时间波特率倒数乘109600bps下大约1.04ms。我做了个delay_us延时bit_time1042微秒实测稳定。为什么要在发送完成中断里做而不在发送函数末尾做因为用了DMA发送时发送函数的返回值只代表DMA把数据搬到了外设寄存器不代表字节真正从总线上发完必须等TC标志再延时。3.4 主站轮询调度表屏作为Modbus主站要周期性读取挂在总线上的多个从站数据。我维护了一个轮询表每项定义一个读操作。typedef struct { uint8_t slave_addr; uint8_t func; uint16_t start_reg; uint16_t reg_count; uint16_t timeout_ms; uint8_t retry_cnt; } poll_entry_t; static const poll_entry_t s_poll_table[] { { 0x01, 0x03, 0x0000, 0x0008, 200, 2 }, { 0x02, 0x03, 0x0000, 0x0004, 150, 2 }, };主循环每10ms调用一次modbus_poll_tick()它内部维护一个简单的状态机空闲时从轮询表取下一项发请求等待应答时检测超时超时后重试次数用完就跳到下一个从站。这样做的好处是某个站掉线不影响其他站的轮询周期。4. 通信层和GUI的接合共享缓冲区是界面的数据后台emWin界面和Modbus通信本质上是两个并行的处理流程它们交集在一个共享数据结构上。这个接缝处设计得好整个项目就会很顺。4.1 为什么不能直接在窗口回调函数里做收发很多新人在第一次写emWin加Modbus时会想在按钮的WM_NOTIFY回调里直接调Modbus写函数等返回结果后再弹个消息框。这种做法在单独的通信测试里没问题但在带GUI的工程里基本必卡。原因是emWin的窗口回调函数是在GUI_Delay或者GUI_Exec内部被调用的它们期望回调能快速返回让GUI系统及时处理消息队列和重绘请求。如果回调里发起一个Modbus写请求并等待应答串口阻塞几百毫秒到几秒界面所有控件都停在那里看起来就是死机。我采用的模式是按钮回调不做通信只是把要写哪个寄存器、写什么值记录到一个全局请求结构体然后给主循环发一个标志。主循环的下一个时间片发现标志后执行真正的串口写操作和等待应答。typedef struct { volatile uint8_t pending; // 是否有待处理写请求 uint8_t slave_addr; uint16_t reg_addr; uint16_t value; } write_request_t; volatile write_request_t g_write_req; // BUTTON回调里只做这几步 void OnBtnSetClicked(WM_HWIN hDlg) { uint16_t new_val (uint16_t)EDIT_GetValue(WM_GetDialogItem(hDlg, ID_EDIT_TARGET)); if (g_write_req.pending 0) { // 上一个请求已处理完才允许写入 g_write_req.pending 1; g_write_req.slave_addr 0x01; g_write_req.reg_addr 0x0100; g_write_req.value new_val; } }主循环里周期性检查这个结构体发现pending置位就先处理写操作处理完了清掉标志。界面始终是非阻塞的。4.2 共享数据结构的并发安全这是项目里最容易被忽略的部分。裸机环境下主循环是唯一执行业务代码的地方中断只往环形缓冲区塞字节因此Modbus解析完成后更新共享数据、GUI定时器读取共享数据这些动作都顺序发生在主循环里不存在并发竞争问题不需要加锁。但我要强调一个细节如果你的工程里引入了RTOS或者你在定时器中断里直接修改了显示数据那共享数据结构就必须加保护。我的做法是提前在数据块定义里加了volatile和序列号字段这样哪天迁移到RTOS只需要在读写处各加一个临界区操作数据结构本身不用改。过程数据缓冲区示例typedef struct { volatile uint32_t update_cnt; // 每次收到有效帧后自增GUI用来判断是否有新数据 float temp[8]; float humi[8]; uint16_t fw_speed[8]; uint8_t dev_status[8]; } process_data_t; process_data_t g_proc;Modbus解析完一帧合法数据后把原始寄存器的值乘上映射表的scale系数填到g_proc里最后g_proc.update_cnt。GUI定时器每次触发时对比update_cnt如果没变化就跳过刷新这比定时刷新更高效。4.3 多页面下的数据分发策略如果界面有多个页面需要在页面切换时按需加载对应的显示数据。我用的方法是在页面框架的WM_SHOWWINDOW回调里调用一次强制刷新把当前页面涉及的全部控件更新一遍。这样做避免了后台页面持续刷控件造成的无效开销。emWin的WM_TIMER定时器我设置为500ms触发一次。为什么不是100ms因为界面是给人看的人眼对温湿度的变化感知大约0.3秒100ms刷新反而会觉得闪烁。500ms是个既流畅又省CPU的取值。如果你要做实时波形曲线这种场景可以单独用更高的刷新频率刷新GRAPH控件其他静态数据仍然500ms刷一次两者互不影响。5. 联调实测踩过的坑调一次才能发现的五个问题跟现场设备联调之前我在工作台上用Modbus模拟从站软件把整个系统测了一遍一切正常。到了现场才发现一堆模拟环境完全暴露不出来的问题这里挑几个印象最深的记录。5.1 emWin内存碎片导致运行一段时间后花屏现象是运行十几分钟后屏幕上某些控件区域出现黑色方块或者TEXT控件文字变成乱码。第一次遇到时我怀疑是内存池太小直接把GUI_NUMBYTES调到64KB结果问题依旧。排查过程通过调试器查看GUI_ALLOC失败次数发现不断有GUI_ALLOC_FAILED。根因是参数设置页每次打开时用对话框创建了一堆EDIT、BUTTON控件关闭时销毁反复操作导致emWin堆内存产生大量小碎片。老版本STemWin的分配策略对这些碎片处理得不够好最终分配不出连续内存块控件创建失败显示就花了。解决方法是改控件生命周期管理启动时就把所有页面和控件一次性创建好页面切换只做隐藏和显示不再反复创建销毁。同时设置#define GUI_SUPPORT_MEMDEV 0MEMDEV虽然能减少闪烁但在F407这种内存有限的MCU上它会吃掉大量RAM关闭后依赖局部无效刷新也能达到不错的效果。5.2 点写入按钮后界面卡死几秒的排查现象点击写入设定值后界面所有控件都失去响应大约3秒后恢复有时候还会弹出通信超时提示。排查链路我记录一下方便你遇到类似问题时按这个思路走第一步在按键回调入口和出口打了两个调试断点发现入口能进去出口等了很久才出来问题锁定在回调内部。第二步把回调内部的Modbus写函数注释掉界面立刻不卡了确认是阻塞式通信导致。第三步用示波器挂在RS485总线上发现写请求发出后从站没有响应屏端一直在等3次超时每次1秒。这期间回调没有返回GUI消息队列无法处理界面假死。最终我改成异步写请求结构体模式界面先更新为写入中的状态提示通信层在后台完成写操作并更新结果状态。用户感受到的是按完按钮界面立即反馈正在写入1秒后变成写入成功体验比原来干等3秒好得多。这个改动涉及两个任务之间的异步协作用上面提到的write_request_t就可以实现。5.3 多从站轮询时一两个站的数据偶尔不更新现象8个从站中有两个站的数据每隔几秒就卡住不刷新其他站正常。一开始怀疑是那两个站的硬件问题但用Modbus调试工具去读两个站响应都正常速度非常快。仔细看时序才发现问题出在从站响应时间不一致上。现场不同品牌的温控器响应速度从8ms到60ms不等。如果屏端对每个从站都用固定的20ms超时响应慢的站就会频繁超时重试重试期间又占用总线形成恶性循环。正确的做法是每个轮询条目单独配置超时时间。我在轮询表里加了timeout_ms字段对现场已知的响应慢的设备放宽到200ms快速设备保持50ms。同时主站轮询的帧间延时不能低于Modbus规范要求的3.5字符间隔否则慢设备还没来得及回帧下一帧广播又把总线占据。5.4 RS485方向切换导致丢首字节这是调试中最隐蔽的问题之一。现象是屏发完读请求后偶尔收不到应答但用示波器看总线从站明明回帧了只是第一个字节被撕掉一半。根因是DE方向切换和USART接收使能之间的竞争。屏幕在发送完读请求后立刻把DE拉低切换为接收模式但从站响应非常快在屏端串口外设尚未稳定切换到接收方向时第一个字节已经到达总线SP3485输出端还没有完全导通首字节的起始位被吃掉。这个现象在高波特率下更明显。解决办法就是在发送完成中断里加入一个字节时间的延时再拉低DE给RS485芯片一个稳定的切换窗口。改完之后一整天的联调再没出现过丢首字节的情况。别小看这一个延时它是在现场抓了几小时波形才定位出来的。5.5 模拟器正常而真机触摸漂移还有一个小坑现场环境电磁干扰较强GT911触摸屏的校准参数在真机上漂移点击控件位置偏移。解决方法是跑一遍emWin的触摸校准流程把校准参数写入外部Flash启动时优先读取外部参数不去用默认的三点校准数据。另外触摸中断引脚上接了RC滤波降低误触概率。6. 后续扩展方向和一些个人体会这个项目交付之后我把它沉淀成了一个可复用的HMI样板工程。后续如果客户提出更复杂的需求扩展路径也比较清晰界面复杂度上来之后可以平滑迁移到带RTOS的架构。数据缓冲区已经用结构体封装好了加互斥锁即可。通信轮询可以放到独立任务GUI任务只做界面两者之间用队列传递消息。RTOS的好处是Modbus解析和GUI重绘互不拖延坏处是调试复杂度提升一个量级不建议小项目盲目上。如果要做历史曲线和故障记录可以在外部加一颗SPI NOR Flash利用emWin自带的存储设备或文件系统组件把采样数据定时写入。这个扩展对现有架构是透明的因为数据流最终都经过共享缓冲区。最后说一点个人体会嵌入式HMI项目里最花时间的永远不是点亮屏幕或者把一个寄存器读出来而是这两个子系统之间的衔接。我在寄存器映射表和控制ID之间加的一层映射表以及回调只建请求、主循环执行通信的异步模式这两个设计决定性地降低了联调阶段的痛苦程度。如果你也在做类似的项目建议一开始就把这两个结构定好哪怕第一版内容简单一点也千万不要图省事在回调里写串口阻塞收发。这算是这个项目里最能少掉头发的经验了。
返回列表