(* ============================================================ *) (* FB_MbMultiRead —— Modbus TCP 主站:多从站 + 多数据区 + 读/写 *) (* S7-1500 TIA Portal SCL(导入用,GB2312/ANSI 编码) *) (* 导入:项目树 -> 外部源文件 -> 添加新的外部源文件 -> 从源生成 *) (* 本文件含 2 个 PLC 数据类型 + 1 个 FB:先建 UDT_MbStation *) (* 和 UDT_MbTask 两个数据类型,再生成 FB_MbMultiRead。 *) (* 不要先手动新建同名对象。完整说明见 UTF-8 主版本。 *) (* 两张表怎么填、读写两个方向、写总闸、输出含义、排错表都在里面 *) (* 依赖系统指令 MB_CLIENT,用 8 个实例建 8 条长连接(1500)。 *) (* 与单任务版 FB_MBReadLoop 可共存,互不影响。 *) (* 版本:v3.5 2026-10-10 *) (* v3.4 修第六、第七个真 bug(现场 80A1 / 8200 循环): *) (* ⑥ 拆连接只等两拍。41→42→43 三个扫描周期只有 20ms,而 *) (* TCP 真实拆除要上百毫秒,下一帧 REQ 必然落在"正在拆除"的 *) (* 窗口里,报 16#80A1 / 16#8200 → 又失败又拆又发,OK 数恒 0。 *) (* 改成按住 DISCONNECT 等 tDiscWait 走完,再冷却 tReconn。 *) (* ⑦ 连接参数每帧重写一遍(含 LocalPort := 0),MB_CLIENT 每次 *) (* 都以为参数被改而要重连。改成"确实变了才重写"。 *) (* 另加 uConnIdBase:连接 ID 可配,若 1..8 已被占用可改号段。 *) (* v3.3 新增:UDT_MbStation 加 uUnitId(设备 ID)。注意: *) (* MB_UNIT_ID 不是 MB_CLIENT 的调用引脚,是指令的静态变量 *) (* (实例 DB 里的 MB_Unit_ID),调用前赋值。之前没写它,设备 ID *) (* 恒为默认 16#FF(255),很多设备只认 1,"连得上、一发就断"。 *) (* DB100 也要重建。 *) (* v3.2 修第五个真缺陷:段 6 门控原来写成一个复合条件,而 SCL *) (* 不短路求值——iSlot=0 的那两拍照样去读 abBusy[0],数组越界。 *) (* 现场指纹:上电进 RUN 的头两拍各一条 FB2 区域长度错误,之后 *) (* 干干净净。改成嵌套 IF:范围检查在外层,数组访问在里层。 *) (* v3.1 修的四条保留:结果段缺作业门控、REQ 重发、计时漏拍、 *) (* 段 6 结算太早(报 16#7005,真错误码没人接)。 *) (* 在线监视 byVer 应为 16#07。 *) (* ============================================================ *) TYPE "UDT_MbStation" VERSION : 0.2 STRUCT sIP : ARRAY[1..4] OF USINT; // 从站 IP 地址,如 [192,168,0,51] uPort : UINT; // 从站端口,Modbus TCP 标准 502 uUnitId : USINT := 16#FF; // 设备 ID(Modbus Unit ID)。不填就是 // MB_CLIENT 默认 16#FF(255),多数直连 // 设备只认 1 —— 采不回来先对这一项! // 经网关挂 RTU 从站的填 RTU 站号 1..247。 END_STRUCT; END_TYPE TYPE "UDT_MbTask" VERSION : 0.2 STRUCT uStaNo : UINT; // 用哪个从站:填从站表下标 1..8(同时就是连接槽位号) bWrite : BOOL; // FALSE = 读这条任务;TRUE = 写这条任务 uFunc : UINT; // 读:3 = FC03 读保持寄存器,4 = FC04 读输入寄存器 // 写:6 = FC06 写单个寄存器,16 = FC16 写多个寄存器 uAddr : UINT; // 起始寄存器地址,从 0 起(线上真实地址) uCount : UINT; // 读:要读几个,可大于 125(自动分片) // 写:要写几个,最多 123(不自动分片) uBufOff : UINT; // 数据放在 aBuf 的哪个下标开始 bEnable : BOOL; // TRUE = 本条任务参与轮询 uTrig : UINT; // 0 = 每轮都做;1 = 只在 bTrigWrite 有效时做一轮 END_STRUCT; END_TYPE FUNCTION_BLOCK "FB_MbMultiRead" { S7_Optimized_Access := 'TRUE' } VAR_INPUT bEnable : BOOL := TRUE; // TRUE=自动轮询;FALSE=暂停(连接保持不断) iInterfaceId : UINT := 64; // 本机 PROFINET 接口硬件标识符(不是从站的) uTaskCount : UINT := 1; // 任务表实际用了前几条,1..32 uMaxRegs : UINT := 125; // 单帧最多读几个寄存器,1..125(只作用于读任务) bWriteEnable : BOOL := FALSE; // 写总闸:FALSE 时所有写任务一律跳过(默认关闭) bTrigWrite : BOOL := FALSE; // 触发信号:uTrig=1 的任务在它有效时做一轮 tPollGap : TIME := T#200ms; // 一轮全部做完后的间隔 tPollFail : TIME := T#3s; // 本轮出现过失败时的间隔(退避用) tTimeout : TIME := T#3s; // 单帧请求超时(兜底,防卡死) bAutoRecover : BOOL := TRUE; // TRUE=失败后对该从站断开重连 bSkipDead : BOOL := TRUE; // TRUE=本轮内跳过已失败从站的后续任务 bReset : BOOL := FALSE; // 复位统计与故障标志 uConnIdBase : UINT := 1; // 连接 ID 起始号:槽 n 用 base+n-1(80A1/A3 自动换号) tDiscWait : TIME := T#300ms; // 拆连接时 DISCONNECT 至少按住这么久 tReconn : TIME := T#200ms; // DISCONNECT 释放后的冷却,之后才发下一帧 END_VAR VAR_IN_OUT aSta : ARRAY[1..8] OF "UDT_MbStation"; // 从站表,下标即连接槽位 1..8 aTask : ARRAY[1..32] OF "UDT_MbTask"; // 任务表,最多 32 条 aBuf : ARRAY[0..999] OF WORD; // 数据总缓冲:读的落点、写的来源都在这儿 END_VAR VAR_OUTPUT bRoundDone : BOOL; // 一轮全部做完的单周期脉冲 bBusy : BOOL; // 轮询进行中 bError : BOOL; // 最近一帧失败(下次成功自动复位) bOnline : BOOL; // TRUE=最近一帧成功 uCurTask : UINT; // 当前/最近执行的任务号 uCurSlot : USINT; // 当前/最近使用的从站槽位号 uRoundOK : UINT; // 本轮成功帧数 uRoundErr : UINT; // 本轮失败帧数 uFailRun : UINT; // 连续失败次数 dwOKCount : DWORD; // 累计成功帧数(读和写都算) dwErrCount : DWORD; // 累计失败帧数 wStatus : WORD; // 最后一帧的结果码(0=成功,锁存) wStatusLive : WORD; // MB_CLIENT 实时 STATUS(调试看它) tRoundTime : TIME; // 上一轮总耗时 iStepOut : INT; // 状态机步号(在线诊断) bTimeout : BOOL; // TRUE=最近一次失败是兜底超时 wStatusFirst : WORD; // 第一次失败的原始错误码(取证,bReset 清) uFirstErrTask: UINT; // 第一次失败发生在哪条任务(0=还没失败过) bParaFault : BOOL; // TRUE=任务表/参数里有非法项被跳过 bWriteLocked : BOOL; // TRUE=表里有写任务,但写总闸没开,被锁住了 abTaskOK : ARRAY[1..32] OF BOOL; // 每条任务最近一次执行是否成功 abTaskErr : ARRAY[1..32] OF BOOL; // 每条任务最近一次执行是否失败 awTaskStatus : ARRAY[1..32] OF WORD; // 每条任务最近一次的结果码(锁存) iCurWrite : BOOL; // TRUE=最近一帧是写任务(判断当前在读还是在写) dwWrOKCount : DWORD; // 累计写成功帧数 dwWrErrCount : DWORD; // 累计写失败帧数 byVer : BYTE; // 版本指纹:监视到 16#08 说明跑的是本版 END_VAR VAR astMB : ARRAY[1..8] OF MB_CLIENT; // 8 条独立 TCP 连接,各自保持不重连 astConn : ARRAY[1..8] OF TCON_IP_v4; // 每个槽位的连接描述 uConnIdRun : UINT := 0; // 运行中的连接 ID 基号:0=未同步(首拍取 uConnIdBase) auMode : ARRAY[1..8] OF UINT; // 各槽位当前 MB_MODE auAddr : ARRAY[1..8] OF UINT; // 各槽位当前 MB_DATA_ADDR auLen : ARRAY[1..8] OF UINT; // 各槽位当前 MB_DATA_LEN abReq : ARRAY[1..8] OF BOOL; // 各槽位 REQ abDisc : ARRAY[1..8] OF BOOL; // 各槽位 DISCONNECT abDone : ARRAY[1..8] OF BOOL; // 各槽位 DONE abBusy : ARRAY[1..8] OF BOOL; // 各槽位 BUSY abErr : ARRAY[1..8] OF BOOL; // 各槽位 ERROR awSt : ARRAY[1..8] OF WORD; // 各槽位 STATUS aChunk : ARRAY[0..124] OF WORD; // 单帧收发缓冲,MB_DATA_PTR 指向这里 abTaskDone : ARRAY[1..32] OF BOOL; // 本轮该任务是否已做完 abTaskBad : ARRAY[1..32] OF BOOL; // 该任务配置是否非法(每周期重算) abSlotDead : ARRAY[1..8] OF BOOL; // 本轮该从站是否已失败 bJobRun : BOOL; // 本槽位是否有作业在跑(结果处理的门控) iStep : INT := 0; // 状态机步号 iSlot : INT := 0; // 当前槽位,0=还没用过 iTaskNo : INT := 0; // 当前任务号,0=无 iChunkOff : INT := 0; // 当前任务已处理到第几个寄存器(分片偏移) iReqLen : INT := 0; // 本帧请求的寄存器个数 iWriteOff : INT := 0; // 本帧数据在 aBuf 里的起始下标 bFirst : BOOL := TRUE; // 首次运行标志(上电立即做一轮) bPrevEn : BOOL := FALSE; // bEnable 上周期状态(沿检测) bPrevTrig : BOOL := FALSE; // bTrigWrite 上周期状态(沿检测) bTrigLatch : BOOL := FALSE; // 触发锁存:脉冲也能等到下一个轮询窗口 tGapTmr : TON_TIME; // 轮次间隔定时器 tToTmr : TON_TIME; // 单帧超时定时器 tRoundTmr : TON_TIME; // 轮次耗时定时器 abConnInit : ARRAY[1..8] OF BOOL; // 该槽位连接参数是否已填过(填过就不再重写) tDiscTmr : TON_TIME; // 拆连接保持定时器 tReconTmr : TON_TIME; // 重连冷却定时器 byVer : BYTE := 16#08; // 版本指纹(v3.5) END_VAR VAR_TEMP i : INT; // 循环下标 iNext : INT; // 调度选出的任务号 iSlotTmp : INT; // 调度时临时存放槽位号(用前先查范围) iTaskCnt : INT; // 钳位后的任务条数 iMaxReq : INT; // 钳位后的单帧最大寄存器数(读) iChunkRemain : INT; // 当前任务还剩几个寄存器没做 iModeTmp : INT; // 组装 MB_MODE 用的中间量 tPT : TIME; // 定时器实际预设值 bEnRise : BOOL; // bEnable 上升沿 bTrigRise : BOOL; // bTrigWrite 上升沿 bTrigNow : BOOL; // 本轮触发条件是否成立 bOvertime : BOOL; // 本周期是否发生兜底超时 bThisBad : BOOL; // 体检当前任务表行的中间量 bHasWrite : BOOL; // 表里是否存在有效的写任务 bThisWr : BOOL; // 体检时:这一行是不是合法的写任务 diFuncMax : DINT; // 体检时:该功能码允许的最大数量 bRoundTmrOn : BOOL; // 轮次计时器使能(含失败重连三拍) END_VAR BEGIN (* ================== 1. 版本指纹与参数校验 =================== *) IF #uConnIdRun = 0 THEN #uConnIdRun := #uConnIdBase; // 上电首拍:运行 ID 基号对齐输入起点 END_IF; #byVer := 16#08; #bOvertime := FALSE; // VAR_TEMP 每周期都得显式清,不能指望它自己归零 #bParaFault := FALSE; #bHasWrite := FALSE; (* uTaskCount 先用 UINT 比较再转 INT —— UINT 转 INT 时大于 32767 会变负数, 如果直接 UINT_TO_INT 再判上界,填个 40000 反而会被当成合法的小数。 *) IF #uTaskCount > 32 THEN #iTaskCnt := 32; #bParaFault := TRUE; ELSE #iTaskCnt := UINT_TO_INT(#uTaskCount); END_IF; IF (#uMaxRegs < 1) OR (#uMaxRegs > 125) THEN (* 单帧读寄存器上限就是 125(Modbus 协议规定),填大了从站也接不住 *) #bParaFault := TRUE; IF #uMaxRegs > 125 THEN #iMaxReq := 125; ELSE #iMaxReq := 1; END_IF; ELSE #iMaxReq := UINT_TO_INT(#uMaxRegs); END_IF; (* 任务表合法性体检,每扫描周期都做一遍。 bParaFault 必须是**稳态**输出:如果只在轮次初始化时置一下,下一周期段首 那句清零就把它抹了,现场打开监视表看到的永远是不报警——等于白做。 (这一点是仿真跑出来的,不是想出来的。) *) FOR #i := 1 TO 32 DO #bThisBad := FALSE; #bThisWr := FALSE; IF (#i <= #iTaskCnt) AND #aTask[#i].bEnable THEN (* a. 从站号越界直接判死:uStaNo 填错就是数组越界, PLC 会进 STOP,这种保护一步都不能省 *) IF (#aTask[#i].uStaNo < 1) OR (#aTask[#i].uStaNo > 8) THEN #bThisBad := TRUE; END_IF; (* b. 方向与功能码必须自洽(白名单,不是排除法)。 写任务只认 FC06(单个)和 FC16(多个); 读任务只认 FC03 和 FC04。 填了个写功能码却把 bWrite 写成 FALSE 的话,这里会直接拦下来—— 不然 MB_CLIENT 会真的发写报文,而写模式下 MB_DATA_PTR 是数据源, 后果是悄悄把外部缓冲里的内容写给从站。 *) #diFuncMax := 0; IF #aTask[#i].bWrite THEN #bThisWr := TRUE; IF #aTask[#i].uFunc = 6 THEN #diFuncMax := 1; // FC06 只能写一个寄存器 ELSIF #aTask[#i].uFunc = 16 THEN #diFuncMax := 123; // FC16 最多 123 个,而且不分片 ELSE #bThisBad := TRUE; END_IF; ELSE IF (#aTask[#i].uFunc = 3) OR (#aTask[#i].uFunc = 4) THEN #diFuncMax := 125; // 读的单帧上限;可以分片,见下面的 c ELSE #bThisBad := TRUE; END_IF; END_IF; (* c. 数量校验 *) IF #aTask[#i].uCount = 0 THEN #bThisBad := TRUE; ELSIF #aTask[#i].bWrite AND (UINT_TO_DINT(#aTask[#i].uCount) > #diFuncMax) THEN (* 写任务**不分片**:一次写不完就直接报错,而不是偷偷拆成几帧。 写操作拆帧意味着从站会短暂处于"半新半旧"的中间状态, 真实设备上那是会出事的。要写更多就多填几条任务。 *) #bThisBad := TRUE; END_IF; (* d. 数据落点必须在 aBuf 范围内 *) IF (UINT_TO_DINT(#aTask[#i].uBufOff) + UINT_TO_DINT(#aTask[#i].uCount)) > 1000 THEN #bThisBad := TRUE; END_IF; (* e. 触发模式只认 0 和 1 *) IF #aTask[#i].uTrig > 1 THEN #bThisBad := TRUE; END_IF; IF #bThisWr AND (NOT #bThisBad) THEN #bHasWrite := TRUE; END_IF; END_IF; #abTaskBad[#i] := #bThisBad; IF #bThisBad THEN #bParaFault := TRUE; // 稳态点亮:有行被跳过,现场必须看得见 END_IF; END_FOR; (* 表里有能用的写任务,但总闸没开——这个状态必须让现场看得见, 否则只会觉得"我明明配了写任务怎么不写"。 *) #bWriteLocked := #bHasWrite AND (NOT #bWriteEnable); (* ========================= 2. 复位 ========================== *) IF #bReset THEN #dwOKCount := 0; #dwErrCount := 0; #dwWrOKCount := 0; #dwWrErrCount := 0; #uFailRun := 0; #bError := FALSE; #bOnline := FALSE; #wStatus := 0; #iStep := 0; #bJobRun := FALSE; // 复位时把"作业在跑"也清掉,不然会留一个幽灵作业 #bFirst := TRUE; // 复位后按"首次"重新启动,立即做一轮 #uConnIdRun := #uConnIdBase; // 运行 ID 基号拉回输入起点 #wStatusFirst := 0; // 取证记录一并清掉 #uFirstErrTask := 0; #bTrigLatch := FALSE; FOR #i := 1 TO 8 DO #abReq[#i] := FALSE; #abDisc[#i] := FALSE; END_FOR; FOR #i := 1 TO 32 DO #abTaskOK[#i] := FALSE; #abTaskErr[#i] := FALSE; #awTaskStatus[#i] := 0; END_FOR; END_IF; (* ====================== 3. 三个定时器 ======================= *) (* 单帧超时:只在"等结果"这一步计时,进别的步自动清零。 步 3 现在是"等 BUSY 落下",所以这个超时同时兜住了"MB_CLIENT 自 己都不回话"的情况——从站整个断电就是这个表现。 *) #tToTmr(IN := (#iStep = 3), PT := #tTimeout); (* 轮次间隔:本轮出现过失败就用 tPollFail 退避,别一直空耗通信资源 *) #tPT := #tPollGap; IF #uRoundErr > 0 THEN #tPT := #tPollFail; END_IF; #tGapTmr(IN := (#iStep = 5), PT := #tPT); (* 轮次耗时:从开始调度到本轮结束,读 ET 即得。不含轮次间隔本身。 41~43 那三拍(断开重连)**必须算在轮次里**:只盖 1~4 的话, 凡是出过错的轮次,ET 都会被那三拍清掉,现场看到的永远是 T#0MS, 这一项诊断量等于白做。 *) #bRoundTmrOn := ((#iStep >= 1) AND (#iStep <= 4)) OR ((#iStep >= 41) AND (#iStep <= 44)); #tRoundTmr(IN := #bRoundTmrOn, PT := T#1h); (* v3.4:拆连接保持 —— DISCONNECT 按住这么久,TCP 才拆得干净。 真实拆除要上百毫秒,原来只给两拍(20ms)是远远不够的。 *) #tDiscTmr(IN := (#iStep = 42), PT := #tDiscWait); (* v3.4:重连冷却 —— DISCONNECT 释放后先空这么久再发下一帧, 给 MB_CLIENT 一个干净的重建窗口。 *) #tReconTmr(IN := (#iStep = 44), PT := #tReconn); (* ======================== 4. 状态机 ========================= *) #bEnRise := #bEnable AND NOT #bPrevEn; #bPrevEn := #bEnable; #bRoundDone := FALSE; // 脉冲输出,默认清零,本周期真做完了再置起 (* 触发锁存:bTrigWrite 就算只给一个扫描周期的脉冲,也能等到下一个轮次初始化。 给的是持续电平则每一轮都算数(下面轮次初始化里用 OR 处理)。 *) #bTrigRise := #bTrigWrite AND NOT #bPrevTrig; #bPrevTrig := #bTrigWrite; IF #bTrigRise THEN #bTrigLatch := TRUE; END_IF; IF NOT #bEnable THEN #bTrigLatch := FALSE; // 停用期间攒下的触发作废 END_IF; (* 停止使能:立刻冻结轮询。连接不主动断开——保持着的连接下次使能能直接用, 断了反而要重新握手。 *) IF (NOT #bEnable) AND (#iStep >= 1) AND (#iStep <= 6) THEN #iStep := 0; #bJobRun := FALSE; // 停用即作废在跑的作业,避免它过后把结果算进来 END_IF; CASE #iStep OF 0: (* ---- 空闲 ---- *) FOR #i := 1 TO 8 DO #abReq[#i] := FALSE; // 把请求全部拉低,让 MB_CLIENT 自然收尾 END_FOR; IF #bEnable AND (#bFirst OR #bEnRise) THEN #bFirst := FALSE; #iStep := 6; END_IF; 1: (* ---- 调度:挑下一条要做的任务 ---- *) #iNext := 0; (* 先找与当前槽位相同的未做任务。同一个从站连着做完, 省掉来回切槽位的开销,也顺带把"任务表按从站分组排"这件事变成 可选的——就算读任务和写任务混着填,这里也会自动按从站归拢, 读和写天然共用那一条连接。 *) IF #iSlot >= 1 THEN FOR #i := 1 TO 32 DO IF (#i <= #iTaskCnt) AND (NOT #abTaskDone[#i]) THEN IF (#aTask[#i].uStaNo = INT_TO_UINT(#iSlot)) AND (NOT (#bSkipDead AND #abSlotDead[#iSlot])) THEN #iNext := #i; EXIT; END_IF; END_IF; END_FOR; END_IF; (* 当前从站没活儿了,换一个还没做的从站 *) IF #iNext = 0 THEN FOR #i := 1 TO 32 DO IF (#i <= #iTaskCnt) AND (NOT #abTaskDone[#i]) THEN #iSlotTmp := UINT_TO_INT(#aTask[#i].uStaNo); (* 下标范围一定要先查:uStaNo 填错就是数组越界, PLC 会直接进 STOP,这种保护不能省 *) IF (#iSlotTmp >= 1) AND (#iSlotTmp <= 8) THEN IF NOT (#bSkipDead AND #abSlotDead[#iSlotTmp]) THEN #iNext := #i; EXIT; END_IF; END_IF; END_IF; END_FOR; END_IF; IF #iNext = 0 THEN (* 本轮全部做完 *) #tRoundTime := #tRoundTmr.ET; #bRoundDone := TRUE; #iStep := 5; ELSIF #iNext <> #iTaskNo THEN (* 换了任务:分片偏移清零,从这条任务的头开始做 *) #iTaskNo := #iNext; #iSlot := UINT_TO_INT(#aTask[#iNext].uStaNo); #iChunkOff := 0; #iStep := 2; ELSE (* 还是同一条任务:说明上一片做完了,接着做下一片 *) #iStep := 2; END_IF; 2: (* ---- 组装本帧请求 ---- *) IF #aTask[#iTaskNo].bWrite THEN (* 写任务:整条一次发完,不做分片。数量在段 1 已经校验过 不会超过 123,这里不需要再跟 uMaxRegs 比——uMaxRegs 是读的上限。 *) #iReqLen := UINT_TO_INT(#aTask[#iTaskNo].uCount); ELSE (* 读任务:本帧长度 = 还没读的数量,但不超过单帧上限 *) #iChunkRemain := UINT_TO_INT(#aTask[#iTaskNo].uCount) - #iChunkOff; IF #iChunkRemain > #iMaxReq THEN #iReqLen := #iMaxReq; ELSE #iReqLen := #iChunkRemain; END_IF; END_IF; (* 组装该槽位的连接参数。串行轮询保证此刻没有别的作业在跑, 改参数是安全的。运行中改了从站 IP 或端口,下一帧自动生效。 *) (* ★ v3.4 第七个修复:连接参数只在"确实变了"时才重写。 原来每帧都照抄一遍(包括 LocalPort := 0),MB_CLIENT 每次都认为 连接参数被改动,于是重新建立连接;这一帧的请求就落在"连接还没 建好"的窗口里 —— 报 80A1(端口/连接资源不可用)或 8200(该连接 正在处理另一个请求)。现场"80A1 与 8200 循环、成功帧永远是 0" 有两条来路,这是第二条。 注意下面这些比较项都在同一个复合条件里,但没有一项是数组访问 的越界风险(下标 #iSlot 在步 1 已经查过范围),所以复合条件是 安全的 —— 拆嵌套 IF 只在"范围判断 + 数组访问"同时出现时才必须。 *) IF (NOT #abConnInit[#iSlot]) OR (#astConn[#iSlot].InterfaceId <> #iInterfaceId) OR (#astConn[#iSlot].ID <> INT_TO_WORD(UINT_TO_INT(#uConnIdRun) + #iSlot - 1)) OR (#astConn[#iSlot].RemotePort <> #aSta[#iSlot].uPort) OR (#astConn[#iSlot].RemoteAddress.Addr[1] <> #aSta[#iSlot].sIP[1]) OR (#astConn[#iSlot].RemoteAddress.Addr[2] <> #aSta[#iSlot].sIP[2]) OR (#astConn[#iSlot].RemoteAddress.Addr[3] <> #aSta[#iSlot].sIP[3]) OR (#astConn[#iSlot].RemoteAddress.Addr[4] <> #aSta[#iSlot].sIP[4]) THEN #astConn[#iSlot].InterfaceId := #iInterfaceId; #astConn[#iSlot].ID := INT_TO_WORD(UINT_TO_INT(#uConnIdRun) + #iSlot - 1); #astConn[#iSlot].ConnectionType := 16#0B; // 0x0B = TCP #astConn[#iSlot].ActiveEstablished := TRUE; // PLC 主动连(客户端) #astConn[#iSlot].RemoteAddress.Addr[1] := #aSta[#iSlot].sIP[1]; #astConn[#iSlot].RemoteAddress.Addr[2] := #aSta[#iSlot].sIP[2]; #astConn[#iSlot].RemoteAddress.Addr[3] := #aSta[#iSlot].sIP[3]; #astConn[#iSlot].RemoteAddress.Addr[4] := #aSta[#iSlot].sIP[4]; #astConn[#iSlot].RemotePort := #aSta[#iSlot].uPort; #astConn[#iSlot].LocalPort := 0; // 0 = 本机端口自动分配 #abConnInit[#iSlot] := TRUE; END_IF; (* 直连功能码法:MB_MODE = 100 + 功能码。 3 -> 103 读保持寄存器,4 -> 104 读输入寄存器, 6 -> 106 写单个寄存器,16 -> 116 写多个寄存器。 走这条路 uAddr 必须是从 0 起的线上真实地址,别填 40001。 *) #iModeTmp := UINT_TO_INT(#aTask[#iTaskNo].uFunc); #auMode[#iSlot] := INT_TO_UINT(100 + #iModeTmp); (* 分片地址 = 任务起始地址 + 已做偏移;写任务 iChunkOff 恒为 0 *) #auAddr[#iSlot] := #aTask[#iTaskNo].uAddr + INT_TO_UINT(#iChunkOff); #auLen[#iSlot] := INT_TO_UINT(#iReqLen); (* 数据落点 = 任务缓冲偏移 + 已做偏移 *) #iWriteOff := UINT_TO_INT(#aTask[#iTaskNo].uBufOff) + #iChunkOff; (* 写任务:把外部缓冲里的内容搬进发送缓冲。 必须赶在下面把 REQ 拉起来之前搬完——MB_CLIENT 一旦被调用就会 从 aChunk 取数往外发。 *) IF #aTask[#iTaskNo].bWrite THEN FOR #i := 0 TO 124 DO IF (#i < #iReqLen) AND ((#iWriteOff + #i) <= 999) AND ((#iWriteOff + #i) >= 0) THEN #aChunk[#i] := #aBuf[#iWriteOff + #i]; ELSE (* 尾部清零:不让上一次读回来的残留跟着发出去 *) #aChunk[#i] := 0; END_IF; END_FOR; END_IF; (* REQ 只给一个扫描周期的脉冲,下一拍(步 3)立刻拉低。 手册原文:"当前请求一完成,若以 REQ=1 执行,就可以处理新请求"。 保持高电平 = 上一请求一完成就自动接着发下一发 —— 连接类故障时 会变成每扫描周期都报一次错,统计当场被刷爆。 脉冲式只发一次,收尾靠 DONE/ERROR 或下面的兜底超时。 *) #abReq[#iSlot] := TRUE; #bJobRun := TRUE; // 门控:从这一刻起,段 6 才允许处理结果 #iStep := 3; 3: (* ---- 等结果 ---- *) #abReq[#iSlot] := FALSE; // 脉冲到此结束:REQ 只高了一拍,避免"完成即重发" (* 这里读到的是**上一周期**MB_CLIENT 的输出,只用来推状态机。 真正的数据处理在段 6(调用之后、同一周期),那边才抓得住 DONE/ERROR 的单周期脉冲。 *) (* 先看 BUSY 落没落:它还在高就说明我们发出去的那一发还在飞, 这会儿既不能判结果、也不能去拆连接。 连接类故障(16#80C5)时 MB_CLIENT 会把 ERROR 一直挂着,只要 REQ 起了沿它就又亮 —— 不看 BUSY 就会把"作业还在跑"当成"作业 已经失败",报出去的错误码是当时的 16#7005(正在发送),真正 到达的 16#80C5 反而没人接;这一发万一侥幸成功,DONE 也会被 一起吞掉(数据不落 aBuf、abTaskOK 不亮,白跑一帧)。 *) IF #tToTmr.Q THEN (* 连 MB_CLIENT 自己都没回话,属于兜底超时。必须按失败计, 否则从站整个断电时统计里一片干净,看着像"根本没故障"。 *) #bOvertime := TRUE; IF #bAutoRecover THEN #iStep := 41; ELSE #iStep := 1; END_IF; ELSIF NOT #abBusy[#iSlot] THEN (* BUSY 已落:这一发收尾了, DONE/ERROR 才是它的结论 *) IF #abErr[#iSlot] THEN IF (#awSt[#iSlot] >= 16#8380) AND (#awSt[#iSlot] <= 16#838F) THEN (* 从站回 Modbus 异常(838x):TCP 连接是好的,不拆线。 逢错必拆会把好端端的连接折进"ID 卡死"。 *) #iStep := 1; ELSIF #bAutoRecover THEN #iStep := 41; ELSE #iStep := 1; END_IF; ELSIF #abDone[#iSlot] THEN #iStep := 1; END_IF; END_IF; 5: (* ---- 一轮做完,等间隔 ---- *) IF #tGapTmr.Q THEN #iStep := 6; END_IF; 6: (* ---- 轮次初始化 ---- *) #uRoundOK := 0; #uRoundErr := 0; #iTaskNo := 0; #iChunkOff := 0; FOR #i := 1 TO 8 DO #abSlotDead[#i] := FALSE; // 每个从站新一轮重新给机会 END_FOR; (* 本轮触发条件:持续电平算数,脉冲靠锁存抓住。 把锁存消费掉——一次触发只对应一轮。 *) #bTrigNow := #bTrigWrite OR #bTrigLatch; (* 锁存无条件消费——一次触发只对应一轮。不能写成"只在 bTrigWrite 为假 时才清":那样单周期脉冲恰好落在轮次初始化这一拍时,电平和锁存会同时 成立,就会连做两轮。持续电平由 bTrigWrite 自己每轮维持。 *) #bTrigLatch := FALSE; FOR #i := 1 TO 32 DO IF (#i > #iTaskCnt) OR (NOT #aTask[#i].bEnable) OR #abTaskBad[#i] THEN (* 超出条数的、有意停用的、体检不通过的,都当"本轮已完成" 直接跳过。比让它去发一帧注定失败的请求要清楚得多。 *) #abTaskDone[#i] := TRUE; ELSIF (#aTask[#i].uTrig = 1) AND (NOT #bTrigNow) THEN (* 触发条件没成立:这条本轮不参与 *) #abTaskDone[#i] := TRUE; ELSIF #aTask[#i].bWrite AND (NOT #bWriteEnable) THEN (* 写任务遇到没打开的总闸:本轮一律跳过。 这是故意的——新导入的块、或者现场误改的表, 都不该让它自己往从站写东西。 *) #abTaskDone[#i] := TRUE; ELSE #abTaskDone[#i] := FALSE; END_IF; END_FOR; #iStep := 1; 41: (* ---- 断开该从站的连接:置 DISCONNECT ---- *) #abReq[#iSlot] := FALSE; #abDisc[#iSlot] := TRUE; #iStep := 42; 42: (* ---- 保持 DISCONNECT,等连接真正拆掉 ---- *) (* ★ v3.4 第六个修复(本版最关键的一处)。 原来是"数两拍就走"(41→42→43),一个扫描周期 10ms 的话只给了 20ms;TCP 连接拆除要上百毫秒。只等两拍的话,下一帧 REQ 必然 落在"连接正在拆除"的窗口里 —— MB_CLIENT 回报 16#80A1 或 16#8200,于是又失败、又拆、又发,成功帧永远是 0。仿真补上连接 层后逐拍复现过:拍 21 开始拆,拍 23 就已经回步 1,拍 25 发出 的那一帧当场报 80A1。 现在按住 DISCONNECT 直到 tDiscWait 走完。 *) #abDisc[#iSlot] := TRUE; IF #tDiscTmr.Q THEN #iStep := 43; END_IF; 43: (* ---- 释放 DISCONNECT ---- *) #abDisc[#iSlot] := FALSE; #iStep := 44; 44: (* ---- 重连冷却:给 TCP 一个干净的重建窗口 ---- *) IF #tReconTmr.Q THEN #iStep := 1; END_IF; END_CASE; (* ============== 5. 调用 MB_CLIENT(8 个槽位) =============== *) (* 8 个槽位每扫描周期都要无条件调用。MB_CLIENT 是异步指令,只发起请求 却不持续调用,作业会永远挂在那儿。 未使用的槽位 REQ 恒为 FALSE,不会真的去建连接,开销可以忽略。 MB_DATA_PTR 统一指向 aChunk——串行轮询保证同一时刻只有一个槽位在跑, 共用一个收发缓冲是安全的:读的时候它是接收区,写的时候它是发送区。 说明:MB_CLIENT 的多重实例调用必须用常量下标,所以这里老老实实展开成 8 段,写法丑但是 TIA 唯一接受的写法。槽位号是变量,不能写成 astMB[i](...)。 MB_UNIT_ID(设备 ID):它不是 MB_CLIENT 的调用引脚——官方参数表里 只有 REQ/DISCONNECT/MB_MODE/MB_DATA_ADDR/MB_DATA_LEN/MB_DATA_PTR/ CONNECT/DONE/BUSY/ERROR/STATUS 共 11 个,设备 ID 存在指令的**静态 变量 MB_Unit_ID** 里(写在实例 DB 中)。v3.3 曾错把它当引脚接, 编译报"形参 MB_UNIT_ID 无效"。现改为每次调用前 #astMB[n].MB_Unit_ID := #aSta[n].uUnitId。v3.2 及之前没写它, 报文里的设备 ID 恒为默认 16#FF(255)——设备只认 1 时,表现就是 "TCP 连得上(空闲 16#7004),一发作业就断"。 *) #astMB[1].MB_Unit_ID := #aSta[1].uUnitId; // 设备 ID:是指令静态变量,不是调用引脚 #astMB[1](REQ := #abReq[1], DISCONNECT := #abDisc[1], CONNECT := #astConn[1], MB_MODE := #auMode[1], MB_DATA_ADDR := #auAddr[1], MB_DATA_LEN := #auLen[1], MB_DATA_PTR := #aChunk, DONE => #abDone[1], BUSY => #abBusy[1], ERROR => #abErr[1], STATUS => #awSt[1]); #astMB[2].MB_Unit_ID := #aSta[2].uUnitId; // 设备 ID:是指令静态变量,不是调用引脚 #astMB[2](REQ := #abReq[2], DISCONNECT := #abDisc[2], CONNECT := #astConn[2], MB_MODE := #auMode[2], MB_DATA_ADDR := #auAddr[2], MB_DATA_LEN := #auLen[2], MB_DATA_PTR := #aChunk, DONE => #abDone[2], BUSY => #abBusy[2], ERROR => #abErr[2], STATUS => #awSt[2]); #astMB[3].MB_Unit_ID := #aSta[3].uUnitId; // 设备 ID:是指令静态变量,不是调用引脚 #astMB[3](REQ := #abReq[3], DISCONNECT := #abDisc[3], CONNECT := #astConn[3], MB_MODE := #auMode[3], MB_DATA_ADDR := #auAddr[3], MB_DATA_LEN := #auLen[3], MB_DATA_PTR := #aChunk, DONE => #abDone[3], BUSY => #abBusy[3], ERROR => #abErr[3], STATUS => #awSt[3]); #astMB[4].MB_Unit_ID := #aSta[4].uUnitId; // 设备 ID:是指令静态变量,不是调用引脚 #astMB[4](REQ := #abReq[4], DISCONNECT := #abDisc[4], CONNECT := #astConn[4], MB_MODE := #auMode[4], MB_DATA_ADDR := #auAddr[4], MB_DATA_LEN := #auLen[4], MB_DATA_PTR := #aChunk, DONE => #abDone[4], BUSY => #abBusy[4], ERROR => #abErr[4], STATUS => #awSt[4]); #astMB[5].MB_Unit_ID := #aSta[5].uUnitId; // 设备 ID:是指令静态变量,不是调用引脚 #astMB[5](REQ := #abReq[5], DISCONNECT := #abDisc[5], CONNECT := #astConn[5], MB_MODE := #auMode[5], MB_DATA_ADDR := #auAddr[5], MB_DATA_LEN := #auLen[5], MB_DATA_PTR := #aChunk, DONE => #abDone[5], BUSY => #abBusy[5], ERROR => #abErr[5], STATUS => #awSt[5]); #astMB[6].MB_Unit_ID := #aSta[6].uUnitId; // 设备 ID:是指令静态变量,不是调用引脚 #astMB[6](REQ := #abReq[6], DISCONNECT := #abDisc[6], CONNECT := #astConn[6], MB_MODE := #auMode[6], MB_DATA_ADDR := #auAddr[6], MB_DATA_LEN := #auLen[6], MB_DATA_PTR := #aChunk, DONE => #abDone[6], BUSY => #abBusy[6], ERROR => #abErr[6], STATUS => #awSt[6]); #astMB[7].MB_Unit_ID := #aSta[7].uUnitId; // 设备 ID:是指令静态变量,不是调用引脚 #astMB[7](REQ := #abReq[7], DISCONNECT := #abDisc[7], CONNECT := #astConn[7], MB_MODE := #auMode[7], MB_DATA_ADDR := #auAddr[7], MB_DATA_LEN := #auLen[7], MB_DATA_PTR := #aChunk, DONE => #abDone[7], BUSY => #abBusy[7], ERROR => #abErr[7], STATUS => #awSt[7]); #astMB[8].MB_Unit_ID := #aSta[8].uUnitId; // 设备 ID:是指令静态变量,不是调用引脚 #astMB[8](REQ := #abReq[8], DISCONNECT := #abDisc[8], CONNECT := #astConn[8], MB_MODE := #auMode[8], MB_DATA_ADDR := #auAddr[8], MB_DATA_LEN := #auLen[8], MB_DATA_PTR := #aChunk, DONE => #abDone[8], BUSY => #abBusy[8], ERROR => #abErr[8], STATUS => #awSt[8]); (* ======================= 6. 结果处理 ======================== *) (* 必须在调用之后、同一扫描周期内做完:DONE/ERROR 只脉冲一个周期, 等状态机下一个周期再来看就已经没了。 *) (* 这一整段必须用 bJobRun 门控:只有"本槽位确实有作业在跑"时才处理结果。 不做门控的后果(v3.0 的真实缺陷):状态机空闲(步 0/5/6)时也会反复进这里, 而连接类错误(16#80C5 远程拒绝/断开)会让 ERROR 持续有效 —— 于是每扫描周期 都给 dwErrCount / uFailRun 加 1。现场看到"错误 46524 次",其实一条 Modbus 帧 都没发出去几次,纯粹是刷出来的假数,还会把 uRoundErr 顶起来让 tPollFail 生效、 把轮次间隔从 200ms 拖成 3s,整个块进入"空转+刷计数"的死循环。 *) (* 再加一道 BUSY 门控:只有"我们发出去的那一发收尾了"才算数(兜底超时除外)。 光有 bJobRun 不够——REQ 起来的同一拍 ERROR 可能还挂着上一次连接故障, 这时候结算出来的错误码是 16#7005(正在发送)这种进程状态,不是真错误; 真错误码 16#80C5 随后才到,却因为门控已经落下而没人接。 BUSY 还在高 = 这一发还在飞,此时 DONE/ERROR 都不是它的结论。 *) (* ★ 这里必须写成嵌套 IF —— 不是风格问题,是**语法要求**(v3.2)。 SCL 与 C 不同:**不短路求值**。复合条件里 AND / OR 的每个操作数 都会被执行,与书写顺序无关。所以下面这样写是错的: IF #bJobRun AND (#iSlot >= 1) AND (NOT #abBusy[#iSlot]) THEN 在 #iSlot = 0 的那几拍照样会去读 #abBusy[0];而 abBusy 是 ARRAY[1..8],下标 0 越界 —— S7-1500 的数组边界检查是运行期内建、 不可关闭的,于是报"FB 2 中的区域长度错误 / 读访问"。 现场指纹很好认:每次上电进 RUN 的**头两拍各一条,之后干干净净**, 因为第 3 拍起 #iSlot 已经 >= 1(步 1 挑到任务后才赋值)。 正确写法:范围检查放外层 IF(只比大小、不碰数组), 数组访问放里层(到这儿 #iSlot 一定合法)。 *) (* 进到这一段里,可以认定 #iTaskNo >= 1 —— 这不是"应该",是能推出来的: #bJobRun 只在步 2 置起,而步 1 在挑到任务时已经先给 #iTaskNo 赋了值; 反过来 #iTaskNo 被清 0 只在步 6(轮次初始化),而走到步 6 时 #bJobRun 必然 已经是 FALSE(每一发都在段 6 里落了门控)。所以段内的 #aTask[#iTaskNo] / #abTaskDone[#iTaskNo] 都是安全的。 ★ 这道门控是**承重墙**,不是"防刷计数"的小补丁,别顺手删: v3.0 没有它的时候,段 6 会在 #iTaskNo = 0 的那几拍去读 #aTask[0]、 写 #abTaskDone[#iTaskNo] —— 两个都越界。给仿真补上运行时边界检查后实测: 6000 拍里 5689 次越界写(`abTaskDone[0]`),正是 9-30 那份诊断缓冲区里 "写访问 16#02:2523"那一类事件的来路。 *) IF #bJobRun AND (#iSlot >= 1) THEN // 外层:只查范围,不碰数组 IF (NOT #abBusy[#iSlot]) OR #bOvertime THEN // 里层:到这儿 iSlot>=1 IF #abDone[#iSlot] THEN (* ------------------------- 本帧成功 ------------------------- *) #bError := FALSE; #bOnline := TRUE; #wStatus := 0; // 成功清零,让 wStatus 反映"当前是否健康" #uFailRun := 0; #dwOKCount := #dwOKCount + 1; IF #uRoundOK < 65535 THEN #uRoundOK := #uRoundOK + 1; END_IF; (* 逐任务反馈:写得到底成没成,看这两个数组就知道 *) IF (#iTaskNo >= 1) AND (#iTaskNo <= 32) THEN #abTaskOK[#iTaskNo] := TRUE; #abTaskErr[#iTaskNo] := FALSE; #awTaskStatus[#iTaskNo] := 0; END_IF; IF #aTask[#iTaskNo].bWrite THEN #dwWrOKCount := #dwWrOKCount + 1; (* 写任务:数据是往外发的,不需要回拷。 aBuf 里保留调用方填进去的内容,方便对照。 *) ELSE (* 读任务:把这一帧搬进数据缓冲。下界保护不能省:任务表填得越界时, 宁可少拷几个字,也不能让 PLC 因为数组越界直接进 STOP。 *) FOR #i := 0 TO 124 DO IF (#i < #iReqLen) AND ((#iWriteOff + #i) <= 999) AND ((#iWriteOff + #i) >= 0) THEN #aBuf[#iWriteOff + #i] := #aChunk[#i]; END_IF; END_FOR; END_IF; (* 分片推进:这一条做完了就标记,否则下一轮调度会接着做下一片 *) #iChunkOff := #iChunkOff + #iReqLen; IF #iChunkOff >= UINT_TO_INT(#aTask[#iTaskNo].uCount) THEN #abTaskDone[#iTaskNo] := TRUE; #iChunkOff := 0; END_IF; #bJobRun := FALSE; // 这一发已经结算完,门控落下来 ELSIF #abErr[#iSlot] OR #bOvertime THEN (* ------------------------- 本帧失败 ------------------------- *) #bError := TRUE; #bOnline := FALSE; #dwErrCount := #dwErrCount + 1; IF #uRoundErr < 65535 THEN #uRoundErr := #uRoundErr + 1; END_IF; IF #uFailRun < 65535 THEN #uFailRun := #uFailRun + 1; END_IF; IF #bOvertime THEN #bTimeout := TRUE; (* 兜底超时:连 MB_CLIENT 都没回话(从站整个断电就是这个表现)。 给它 16#80C8(接收超时)作为统一代号,这样 wStatus 不会是 0, 现场一眼能看出"确实出了事",而不是"没故障"。 *) #wStatus := 16#80C8; ELSE #bTimeout := FALSE; #wStatus := #awSt[#iSlot]; // 锁存错误码,脉冲过后 STATUS 会回 16#7000 END_IF; (* 取证:第一帧失败的原始错误码和任务号只记一次。后面的 80A1 会把 wStatus 覆盖掉,没有这份记录就查不到触发 原因(10-10 现场:54 帧正常后掉线,只剩 80A1)。 *) IF #uFirstErrTask = 0 THEN #wStatusFirst := #wStatus; #uFirstErrTask := #iTaskNo; END_IF; (* 连接 ID 被卡死(非正常掉线后同一 ID 再也建不起来, 换号立刻恢复——官方社区与两次现场实录一致)→ 自动 +1。 下一帧组装 CONNECT 时 ID 变了,走"参数变了才重写"。 *) IF (#awSt[#iSlot] = 16#80A1) OR (#awSt[#iSlot] = 16#80A3) THEN IF #uConnIdRun < 16#0FFF THEN #uConnIdRun := #uConnIdRun + 1; ELSE #uConnIdRun := #uConnIdBase; // 到顶回绕 END_IF; END_IF; (* 逐任务反馈:写失败了必须留下来,不能只是统计里加一。 写失败意味着从站那边**没有**变成你要的值,是要立刻处理的事。 *) IF (#iTaskNo >= 1) AND (#iTaskNo <= 32) THEN #abTaskOK[#iTaskNo] := FALSE; #abTaskErr[#iTaskNo] := TRUE; #awTaskStatus[#iTaskNo] := #wStatus; END_IF; IF #aTask[#iTaskNo].bWrite THEN #dwWrErrCount := #dwWrErrCount + 1; END_IF; (* 这个从站本轮判死:它后面的任务直接跳过,不再陪着一起超时 *) #abSlotDead[#iSlot] := TRUE; (* 当前任务剩下的分片也不做了——从站都不回话了,再做下去只是浪费时间 *) #abTaskDone[#iTaskNo] := TRUE; #iChunkOff := 0; #bJobRun := FALSE; // 失败也结算完,同样落门控 END_IF; END_IF; END_IF; (* ======================= 7. 输出刷新 ======================== *) #bBusy := (#iStep >= 1) AND (#iStep <= 3); #iStepOut := #iStep; #uCurTask := INT_TO_UINT(#iTaskNo); #uCurSlot := INT_TO_USINT(#iSlot); IF (#iTaskNo >= 1) AND (#iTaskNo <= 32) THEN #iCurWrite := #aTask[#iTaskNo].bWrite; END_IF; IF #iSlot >= 1 THEN #wStatusLive := #awSt[#iSlot]; END_IF; END_FUNCTION_BLOCK (* ============================================================ *) (* 使用说明 *) (* ------------------------------------------------------------ *) (* 1. 先建配置(全局 DB 最省事) *) (* 新建一个全局 DB,比如 "MB配置",在里面加三个变量: *) (* aSta : ARRAY[1..8] OF "UDT_MbStation" *) (* aTask : ARRAY[1..32] OF "UDT_MbTask" *) (* aBuf : ARRAY[0..999] OF WORD *) (* 然后把两个 UDT 的字段逐条填上。用在 OB1 的调用示例: *) (* *) (* "MbMulti_主站"(bEnable := "系统运行", *) (* iInterfaceId := 64, *) (* uTaskCount := 2, *) (* aSta := "MB配置".aSta, *) (* aTask := "MB配置".aTask, *) (* aBuf := "MB配置".aBuf); *) (* *) (* 注意:aSta / aTask / aBuf 都是 VAR_IN_OUT,类型必须完全一致,*) (* 不能传部分数组,也不能传结构不同的 DB。aBuf 由输出改成 IN_OUT *) (* 是 v3 的接口变化之一(写任务需要外部可写的数据源)。 *) (* *) (* ------------------------------------------------------------ *) (* 2. 场景 A:读多个 IP 从站 *) (* aSta[1] := [192,168,0,51] uPort := 502 *) (* aSta[2] := [192,168,0,52] uPort := 502 *) (* uTaskCount := 2 *) (* aTask[1]: uStaNo=1 bWrite=FALSE uFunc=3 *) (* uAddr=0 uCount=20 uBufOff=0 uTrig=0 *) (* aTask[2]: uStaNo=2 bWrite=FALSE uFunc=3 *) (* uAddr=0 uCount=20 uBufOff=20 uTrig=0 *) (* 读回:aBuf[0..19] / [20..39] *) (* *) (* ------------------------------------------------------------ *) (* 3. 场景 B:同一个 IP 读 600 个寄存器 *) (* aSta[1] := [192,168,0,51],uTaskCount := 1 *) (* aTask[1]: uStaNo=1 bWrite=FALSE uFunc=3 *) (* uAddr=0 uCount=600 uBufOff=0 uTrig=0 *) (* 块内自动切成 5 帧:125 + 125 + 125 + 125 + 100 *) (* 读回:aBuf[0..599](下标 0 对应寄存器 0) *) (* 一轮耗时 = 5 帧 × (响应时间 + 帧间开销),所以 *) (* tPollGap 别设太小,500ms 以上比较稳。 *) (* *) (* ------------------------------------------------------------ *) (* 4. 场景 C:同一个 IP 同一个端口,又读又写(v3 新增) *) (* 关键是**两条任务填同一个 uStaNo** —— 那就共用同一条 TCP *) (* 连接,块内串行执行:读一帧、写一帧、再读一帧…… *) (* 不需要也不能给写单独开一个实例(那会占两条连接)。 *) (* *) (* aSta[1] := [192,168,0,51] uPort := 502 *) (* uTaskCount := 3 *) (* *) (* // 读 20 个寄存器,落在 aBuf[0..19] *) (* aTask[1].uStaNo =1 bWrite=FALSE uFunc=3 *) (* aTask[1].uAddr =0 uCount =20 uBufOff=0 uTrig=0 *) (* *) (* // 每轮把 2 个字写到从站 100 号起,数据来自 aBuf[500..501] *) (* aTask[2].uStaNo =1 bWrite=TRUE uFunc=16 *) (* aTask[2].uAddr =100 uCount=2 uBufOff=500 uTrig=0 *) (* *) (* // 再读 10 个,落在 aBuf[20..29] *) (* aTask[3].uStaNo =1 bWrite=FALSE uFunc=3 *) (* aTask[3].uAddr =200 uCount=10 uBufOff=20 uTrig=0 *) (* *) (* // 往外写之前,先把要写的内容放进 aBuf: *) (* "MB配置".aBuf[500] := 1500; // 频率给定 *) (* "MB配置".aBuf[501] := 1; // 启停 *) (* *) (* ★ 还要把写总闸打开,否则写任务会被全部跳过: *) (* "MbMulti_主站"(bWriteEnable := TRUE, ...); *) (* 或者更方便的,接到 HMI 上的"允许下发"开关。 *) (* *) (* 想"只在按按钮时写一次"就把 uTrig 填 1,然后用 bTrigWrite *) (* 给一个脉冲(块内有锁存,一个扫描周期的脉冲也抓得住)。 *) (* *) (* ------------------------------------------------------------ *) (* 5. 写的时候必须守住的三条 *) (* (1) 写总闸 bWriteEnable 默认是 FALSE。表里配了写任务但没开 *) (* 闸时,写一律不执行,同时 bWriteLocked 会亮——看到它就先 *) (* 确认是不是你有意关的。 *) (* (2) bWrite 和 uFunc 的方向必须一致:写任务只能填 6 或 16, *) (* 读任务只能填 3 或 4。对不上会被体检拦下来并点亮 bParaFault。 *) (* (3) 写任务不自动分片,一次最多 123 个寄存器(FC06 只能 1 个)。*) (* 要写更多就多填几条任务,别指望它自己拆——写操作拆帧会让 *) (* 从站出现"半新半旧"的中间状态,真实设备上会出事。 *) (* *) (* ------------------------------------------------------------ *) (* 6. 输出怎么用 *) (* bRoundDone : 一轮做完给一个脉冲(做历史记录 / 触发用) *) (* bOnline : TRUE = 最近一帧成功 *) (* bError : 最近一帧失败(下次成功自动复位) *) (* wStatus : 最后一帧结果码,锁存到下次成功。必须锁存—— *) (* MB_CLIENT 的 DONE/ERROR 脉冲一过,STATUS *) (* 立刻回 16#7000,不锁存现场根本查不到错在哪 *) (* 这里拿到的一定是**真错误码**(16#80xx / 81xx / 83xx), *) (* 不会是 16#7005 这类"正在收发"的进程状态:段 3 / 段 6 都等 *) (* 到 BUSY 落下才结算。 *) (* wStatusLive : MB_CLIENT 实时状态(16#7000 空闲 / *) (* 16#7001 建连 / 16#7005 等响应),调试配步号看 *) (* uCurTask : 正在做第几条任务,卡住时看它就知道卡在哪条 *) (* uCurSlot : 正在和哪个从站通信(槽位号) *) (* iCurWrite : TRUE = 当前这一帧是写任务 *) (* uRoundOK : 本轮成功帧数;异常时它就是最好的体检表: *) (* 只有 1 号从站失败 -> 那几条任务里只有它那几帧错 *) (* uRoundErr : 本轮失败帧数 *) (* uFailRun : 连续失败次数,>=3 基本就是从站或线路的问题了 *) (* dwOKCount : 累计成功帧数——盯着它涨,就知道数据在刷新 *) (* dwErrCount : 累计失败帧数 *) (* dwWrOKCount : 累计写成功帧数 *) (* dwWrErrCount: 累计写失败帧数。这个数只要不为 0 就要去看 *) (* abTaskErr 是哪一条,写失败意味着从站没变成你要 *) (* 的值,是要立刻处理的事。 *) (* abTaskOK : 逐任务成败,写任务尤其要看它 *) (* abTaskErr : 同上 *) (* awTaskStatus: 逐任务结果码,锁存 *) (* tRoundTime : 上一轮总耗时,判断轮询周期够不够快 *) (* iStepOut : 状态机步号:0 空闲 / 1 调度 / 2 组装 / *) (* 3 等结果 / 5 轮次间隔 / 6 轮次初始化 / *) (* 41 42 43 断线重连中 *) (* bTimeout : TRUE = 这次失败是兜底超时(从站连 TCP 都没回) *) (* bParaFault : TRUE = 任务表里有非法项被跳过,去查下面第 8 条 *) (* bWriteLocked: TRUE = 表里有写任务但写总闸没开 *) (* *) (* ------------------------------------------------------------ *) (* 7. 参数怎么选 *) (* uMaxRegs 单帧读的上限。协议允许 125,但有些从站/网关只认 *) (* 120 甚至 100,读不到就把这个值往下降,比去改 *) (* 从站固件省事。它对写任务不起作用。 *) (* tPollGap 一轮之间的间隔。任务多、寄存器多的时候一轮本身就 *) (* 要花不少时间,间隔别掐太紧。 *) (* tPollFail 本轮有失败时的间隔。设大一点(3~10s),某个从站 *) (* 掉线时不至于让 PLC 一直空等。 *) (* tTimeout 单帧超时。局域网内 2~5s;跨网段或经无线网关要到 *) (* 10s。 *) (* *) (* ------------------------------------------------------------ *) (* 8. 任务被跳过(bParaFault 亮)的原因 *) (* - uCount = 0 没填要处理几个 *) (* - uStaNo 不在 1..8 从站号填错 *) (* - 读任务 uFunc 不是 3 也不是 4 功能码填错 *) (* - 写任务 uFunc 不是 6 也不是 16 同一个原因 *) (* - 写任务 uCount > 123(FC06 时 > 1)一次写不完,且不自动分片 *) (* - uBufOff + uCount > 1000 数据落点超出 aBuf 范围 *) (* - uTrig 不是 0 也不是 1 触发模式填错 *) (* - bEnable = FALSE 有意停用(这种不点亮报警)*) (* - 任务号 > uTaskCount 表里有内容但没被算进来 *) (* 写任务被写总闸拦下属于另一种情况:不点亮 bParaFault, *) (* 而是点亮 bWriteLocked。 *) (* *) (* ------------------------------------------------------------ *) (* 9. 常见问题 *) (* Q: 配了写任务,但从站那边一直没变化 *) (* A: 按这个顺序查:① bWriteLocked 是不是亮的(写总闸没开, *) (* 默认就是关的);② 这条任务的 uTrig 是不是 1 而你没给 *) (* bTrigWrite;③ abTaskErr[这条] 是不是 TRUE,看 *) (* awTaskStatus 里的错误码;④ uBufOff 那一段 aBuf 里到底 *) (* 有没有填数据。 *) (* *) (* Q: bError 亮,wStatus = 16#80C8 / 16#80A4 *) (* A: 接收超时 / 从站无响应。先 ping 从站 IP,再用 Modbus 调试 *) (* 工具单独连一次,确认 502 端口通不通。从站 IP 必须和 CPU *) (* 在同一个网段。 *) (* *) (* Q: bError 亮,wStatus = 16#80C5 *) (* A: 对端把 TCP 连接拒了/断了,这是 TCP 层的事,不是 Modbus *) (* 协议报错。常见就是这三样:从站 IP 或端口填错、对端没起 *) (* Modbus TCP 服务、防火墙/网段挡着。先在电脑上 ping 通、再用 *) (* Modbus 调试工具连一次 502 端口;通了再回来查块里的配置。 *) (* 这个码跟块本身的逻辑无关。 *) (* *) (* Q: 设备 ID(Unit ID)在哪里设?默认是 1 吗 *) (* A: 不是 1。MB_CLIENT 的 MB_Unit_ID 默认 16#FF(255)(西门子 *) (* 官方 FAQ 102420337)。注意它不是调用引脚,是指令的静态变量—— *) (* v3.3 曾错把它当引脚接,编译报"形参 MB_UNIT_ID 无效",块内已 *) (* 改为调用前写 astMB[n].MB_Unit_ID := aSta[n].uUnitId。v3.3 起 *) (* DB100 每个从站多一个 uUnitId:直连设备一般填 1;经网关挂 RTU *) (* 从站的填 RTU 站号。UDT 改过,DB100 要重建再下载。 *) (* *) (* Q: 通讯正常一阵子后掉成 80A1/A3 循环,重启 PLC 又好, *) (* 过阵子又犯? *) (* A: 连接 ID 被卡死——非正常掉线后同一 ID 建不起来, *) (* 换号立刻恢复(官方社区一致,10-10 现场两度实录)。 *) (* v3.5 起收到 80A1/A3 自动 +1 换号重建。第一次失败的 *) (* 原始错误码在 wStatusFirst 里,触发原因看它。 *) (* Q: TCP 连得上(空闲 wStatusLive=16#7004),一发作业就断, *) (* wStatus 报 16#80A1 / 16#8200 循环 *) (* A: 两个都是连接层的码,不是 Modbus 协议报错。80A1 = 这条连接 *) (* 此刻不能用来发报文(没建立完 / 正在拆 / 连接 ID 被占用); *) (* 8200 = 该连接正在处理另一个 Modbus 请求(西门子官方解释, *) (* 常见于另一实例占着同一连接参数,或服务器还没处理完上一个)。 *) (* 按顺序查:① 工程里有没有第二个 MB_CLIENT 在跟同一台设备 *) (* 通信(旧的单任务版 FB_MBReadLoop、手工拖的 MB_CLIENT_DB), *) (* 有就先断开它的使能;② 设备 ID 对不对——监视表把 *) (* astMB[1].MB_Unit_ID 改成 1 试一把,立竿见影就是它;③ 用电脑 *) (* 上的 Modbus 主站工具直连设备(IP:502,ID=1,FC03,地址 0, *) (* 数量 100):PC 读得回来是 PLC 侧参数问题,读不回来是设备 *) (* 侧问题(服务没起 / 寄存器表不对 / 一次读的个数超限)。 *) (* *) (* Q: 诊断缓冲区里只有两条"FB 2 中的区域长度错误",都在上电 *) (* 进 RUN 的头两拍,之后一条都没有 *) (* A: 复合条件里的数组越界 —— SCL 不短路求值,iSlot 还是 0 的 *) (* 那两拍照样去读 abBusy[0]。判定:这两拍 iStepOut 一定是 0 *) (* 或 6(还没挑到任务);第 3 拍起 iSlot>=1 就不再报。根治 *) (* 写法就是嵌套 IF,见块头 ⑤ 与段 6。 *) (* Q: 出错后 dwErrCount 涨得离谱(几十万) *) (* A: v3.1 之前的毛病:结果处理段没有"作业在跑"门控,连接类 *) (* 错误每扫描周期都给计数器加 1。现在按"一发作业只结算一次", *) (* dwErrCount 就等于真实的失败帧数。监视 byVer 应为 16#08。 *) (* *) (* Q: 同一个从站的读和写会不会打架 *) (* A: 不会。同一个 uStaNo 的任务共用那一条 TCP 连接,块内串行 *) (* 执行,同一时刻只有一个请求在跑。而且调度带"同槽位优先", *) (* 同一从站的读和写会被归到一组连着做,不用来回切连接。 *) (* *) (* Q: 只有一个从站的数据不刷新,其他都正常 *) (* A: 看 uCurSlot 是不是卡在某个槽位号上,再看那个从站的 IP/ *) (* 端口。本块每个从站独立连接,一个不通不影响其他。 *) (* *) (* Q: 600 个寄存器只读回来前 125 个 *) (* A: 检查 uCount 是不是真填了 600。只要 uCount 大于 uMaxRegs, *) (* 块内就会自动分片;如果 uCount 填的是 125,那自然只读 125。 *) (* *) (* Q: 数据整体错位 / 高半截是旧值 *) (* A: 检查各任务的 uBufOff 有没有重叠。本块不会自动排布, *) (* 两段范围叠在一起时后做的会盖掉先做的。读写任务也要分段。 *) (* *) (* Q: wStatus = 16#809B *) (* A: CONNECT 结构(TCON_IP_v4)不对。检查连接 ID 1..8 有没有 *) (* 和项目里别的通信撞车,iInterfaceId 是不是本机 PN 口号。 *) (* *) (* Q: 导入提示 MB_CLIENT 不能作多重实例(S7-1200) *) (* A: 本块是为 S7-1500 写的(8 个实例)。S7-1200 上请改用单任务 *) (* 版 FB_MBReadLoop,每个从站建一个实例。 *) (* *) (* ------------------------------------------------------------ *) (* 10. 和 FB_MBReadLoop / S7-200 SMART MODBUS管理 的关系 *) (* 思路一脉相承:串行轮询、失败也推进、错误码可查、用定时器 *) (* 而不是数扫描周期。 *) (* 升级点:单实例单数据区 -> 8 连接槽位 + 32 任务表 + 自动分片 *) (* + 同一从站读写共用一条连接。 *) (* 选型:一两个从站各读一小片、或者要读写线圈,用 FB_MBReadLoop *) (* 更省事;多个从站、几百个寄存器、或者同一从站要又读又写 *) (* (尤其是写任务需要精细控制时机),用本块。 *) (* ============================================================ *)