5 组控制器、16 台热泵、1 个 MB_Client,从“能读几台”到“稳定读写全部”,我踩遍了 Modbus TCP 的坑。这篇文章不讲协议基础,只讲现场调试中那些让你抓狂的细节,以及最终沉淀下来的正确架构。
一、引子:那个卡在第 8 台的夜晚
项目背景很简单:西门子 S7-1200 通过 Modbus TCP 采集 5 个控制器,每个控制器下挂若干热泵模块。单台测试时,读写飞快,成功率 100%。一旦 5 组同时跑,问题来了:
-
前 7 台正常,第 8 台死活读不上来;
-
写入开关机,HMI 上改了值,PLC 里纹丝不动;
-
故障复位信号发了,从站没反应;
-
程序卡在步骤 2,o_Comm_State 永远不往前走。
一开始以为是网络问题、从站问题、IP 冲突……查了一圈,发现全是程序架构的坑。
二、踩坑历程:从“能用”到“敢用”的距离
坑 1:单台轮询——简单,但重复到崩溃
最开始的方案是“一个控制器一个 FB”,每个 FB 里写死顺序轮询。优点是独立、可靠;缺点是 5 个控制器就要复制 5 份代码,改一个参数要改 5 个地方。
坑 2:多台串行——功能强大,但一颗老鼠屎坏一锅汤
后来改成“一个 FB 处理所有控制器”,用 Serial_Pointer 轮询。功能是强了,但可靠性断崖式下降:
-
一台设备超时,整个指针卡死;
-
写操作和读操作混在一个状态机里,Done_Bit 管理混乱;
-
错误处理稍有不慎,程序就卡在某个步骤出不来。
坑 3:分时调度——CPU 降下来了,读取碎片化了
为了降低扫描周期(实测 300ms+),把 5 个控制器分散到 5 个扫描周期,每个周期只执行一个。扫描周期降到了 60ms,但每个控制器的轮询被切成碎片,完成一轮要等 5 个周期,实时性变差。
坑 4:写请求检测——未被调用的 FB,输出根本不更新!
这是最隐蔽的坑。调度器想通过 Output[#i].Write_Request 判断哪个控制器有写请求,但只有被调用的 FB,其输出才会刷新。没被调用的控制器,Write_Request 保持旧值,调度器读到的是假数据。
结论:写请求检测不能依赖 FB 的输出,必须在调度器层面独立完成。
坑 5:上升沿检测丢失——信号来了,FB 却没看见
故障复位(Bit72)用上升沿触发。但调度器把 Bit72 当电平处理,等轮到该控制器时,上升沿早就过去了。FB 内部检测 FALSE→TRUE,结果 st_ClrFault_Last 已经是 TRUE,永远触发不了。
结论:要么调度器做上升沿检测并锁存请求,要么 FB 内部改成“电平触发+执行后自动复位”。
三、正确架构:调度器 + 通讯 FB 的分层设计
经过反复迭代,最终沉淀出这套架构:
text
┌─────────────────────────────────────────────────────────────┐
│ OB1 主循环 │
├─────────────────────────────────────────────────────────────┤
│ ① 调度器 FC(FC_Modbus_Scheduler) │
│ – 检测所有控制器的写请求(独立于 FB 输出) │
│ – 按顺序轮询控制器(1→2→3→…→8→1) │
│ – 有写请求时,跟随轮询顺序插入执行 │
│ – 通过 Controller_Index 驱动 OB1 选择调用哪个 FB │
├─────────────────────────────────────────────────────────────┤
│ ② 通讯 FB(FB_ModbusController) │
│ – 每个控制器一个实例,独立状态机 │
│ – 步骤1:执行写入(故障复位优先,常规写入次之) │
│ – 步骤2/3/4:读公共 Word、公共 Bit、模块数据 │
│ – 完成后输出 Round_Done / Write_Request │
└─────────────────────────────────────────────────────────────┘
3.1 调度器职责:只做“顺序”和“请求检测”
-
顺序轮询:控制器 1 完成一轮 → 切到 2 → … → 8 → 1。
-
写请求检测:不依赖 FB 输出,直接在调度器中比较 HMI 写入值与备份值(或检测 Bit72 上升沿),设置 Write_Request[#i]。
-
写请求插入:当前控制器完成一轮后,若有写请求,跳转到有请求的控制器;否则切到下一个。
-
不等待写入完成:写入由 FB 内部处理,调度器只负责“让 FB 被调用”。
3.2 通讯 FB 职责:先写后读,完成置位
-
步骤 1:如果 st_ClrFault_Trigged 或 st_Write_Pending,执行写入(等待 Done/Error/超时),完成后继续。
-
步骤 2/3/4:读公共数据、模块数据。
-
步骤 4 结束:置位 o_Data.Round_Done := TRUE,回到步骤 1。
-
写请求标志:o_Data.Write_Request := st_ClrFault_Trigged OR st_Write_Pending(供调度器参考,但调度器不依赖它做检测)。
3.3 关键代码片段:调度器状态机(简化)
pascal
// 检测写请求(独立于 FB 输出)
FOR #i := 1 TO 8 DO
IF HMI_Write[#i].Wrod001 <> Backup[#i].Wrod001 THEN
Write_Request[#i] := TRUE;
Backup[#i].Wrod001 := HMI_Write[#i].Wrod001;
END_IF;
// … 其他参数类似
END_FOR;
// 统计待处理请求
Pending_Writes := 0;
FOR #i := 1 TO 8 DO
IF Write_Request[#i] THEN
Pending_Writes := Pending_Writes + 1;
END_IF;
END_FOR;
// 轮询状态机
CASE State OF
1: // 轮询
IF Output[Active_Controller].Round_Done AND NOT Round_Done_Last THEN
IF Pending_Writes > 0 THEN
// 有写请求 → 跳到有请求的控制器
FOR #i := 1 TO 8 DO
IF Write_Request[#i] THEN
Active_Controller := #i;
EXIT;
END_IF;
END_FOR;
ELSE
// 无写请求 → 切到下一个
Active_Controller := Active_Controller + 1;
IF Active_Controller > 8 THEN Active_Controller := 1; END_IF;
END_IF;
Round_Done_Last := FALSE;
END_IF;
Round_Done_Last := Output[Active_Controller].Round_Done;
END_CASE;
3.4 关键代码片段:FB 步骤 1(故障复位优先)
pascal
1: // 写入阶段
o_Data.Round_Done := FALSE;
// 故障复位(优先级最高)
IF st_ClrFault_Trigged THEN
inst_MB_Client(REQ := st_REQ, MB_MODE := 1, MB_DATA_ADDR := 00073,
MB_DATA_PTR := st_WriteBuffer_Bit[0], …);
IF st_MB_Done THEN
st_ClrFault_Trigged := FALSE;
io_HPC_Write.Bit72 := FALSE; // 自动复位
st_Step := 2;
ELSIF st_MB_Error OR st_Polling_Time_Ton.Q THEN
// 重试或报警
END_IF;
END_IF;
// 常规写入
IF st_Write_Pending AND NOT st_ClrFault_Trigged THEN
inst_MB_Client(REQ := st_REQ, MB_MODE := 1, MB_DATA_ADDR := st_MB_Addr,
MB_DATA_PTR := st_WriteBuffer_Word[0], …);
IF st_MB_Done THEN
st_Write_Pending := FALSE;
st_Step := 2;
END_IF;
END_IF;
四、几条血泪经验(可以直接抄)
| 写请求检测不要依赖 FB 输出 | 未被调用的 FB,输出不刷新。调度器自己比较 HMI 值和备份值。 |
| 上升沿信号要锁存 | 调度器检测到上升沿 → 置位 Write_Request,FB 执行后清除。不要指望 FB 在轮询到时还能看见上升沿。 |
| 写请求跟随轮询,不抢占 | 当前控制器完成一轮后再处理写请求,避免打断读取状态。 |
| Done_Bit 和 Polling_Interval_Ton 只用于读步骤 | 写入触发只检查 st_MB_Busy 和 st_REQ,不要被读间隔定时器阻塞。 |
| 一个控制器一个 FB 实例 | 独立状态机,故障隔离。调度器只负责选谁执行。 |
| 公共数据读取错误不要重置模块索引 | 否则模块轮询被打断,永远读不到后面的设备。 |
| i_Frame_Gap 错开,i_Response_Timeout 合理 | 5 组同时跑时,间隔设为 38~42ms 错开,超时 500ms 足够。 |
| 扫描周期 > 200ms 就要分时/顺序调度 | 5 个 MB_Client 同周期执行,CPU 扛不住,通讯必挂。 |
五、总结:Modbus TCP 通讯的“正确姿势”
架构上分层:调度器管顺序和请求,FB 管读写执行。
写请求独立检测:调度器直接比较 HMI 值和备份值,不依赖 FB 输出。
写操作跟随轮询:不抢占,完成一轮后插入,保证读写互斥。
上升沿要锁存:检测到就置标志,执行后清除,避免信号丢失。
超时和间隔是保命符:i_Response_Timeout 防止卡死,i_Frame_Gap 给从站喘息。
调试靠监控:Round_Done、Write_Request、st_MB_Status、Controller_Index 这几个变量挂到 HMI 上,一眼看出卡在哪。
Modbus TCP 不难,难的是让它在多设备、多任务、长时间运行下依然稳定。 希望这篇总结能帮你少走几个我走过的弯路。


