(* ============================================================ *) (* UDT_MbStation / UDT_MbTask / FB_MbMultiRead *) (* Modbus TCP client: multi-slave + multi-job + large blocks, *) (* cyclic READ / WRITE *) (* ------------------------------------------------------------ *) (* Platform : Siemens S7-1500 (TIA Portal SCL) *) (* Wraps the system instruction MB_CLIENT; one FB *) (* drives up to 8 connections *) (* ------------------------------------------------------------ *) (* What it does (compared to the single-job FB_MBReadLoop): *) (* Case A Several IP slaves. With the single-job version you *) (* need one instance per slave and each instance owns *) (* its own connection. This block keeps 8 internal *) (* connection slots: every slave owns one slot, stays *) (* connected, and polling just switches slot - no *) (* reconnecting. *) (* Case B One IP, 600 registers. A Modbus frame carries 125 *) (* words at most. Fill uCount = 600 here and the block *) (* splits it into 5 frames by itself. *) (* Case C One IP and one port, reading AND writing. This is *) (* new in this version. Give the read job and the write *) (* job the same uStaNo and they share that single TCP *) (* connection, executing one after the other. No extra *) (* connection has to be opened for writing. *) (* The caller only fills two tables and gives one bEnable bit. *) (* ------------------------------------------------------------ *) (* [Changes in v3 vs v2.0 - READ THIS BEFORE UPGRADING] *) (* 1. Write support. New bWrite field in the job table declares *) (* the direction; uFunc gains 6 / 16 as write function *) (* codes. Reading is still 3 / 4. *) (* 2. aBuf changed from VAR_OUTPUT to VAR_IN_OUT. This is *) (* required: a write job needs an externally writable data *) (* source, which an output pin cannot provide. The fix is *) (* easy - add one more array to the configuration DB and *) (* pass it in: *) (* old: "MbMulti_Master".aBuf[0] *) (* new: "MBConfig".aBuf[0] *) (* 3. New write master switch bWriteEnable, DEFAULT FALSE. *) (* With write jobs configured but the switch off, every *) (* write job is skipped and bWriteLocked comes on. This is *) (* a deliberate safe default: a freshly imported block must *) (* never write into a slave on its own. *) (* 4. New per-job feedback abTaskOK / abTaskErr / awTaskStatus. *) (* Whether a write really succeeded no longer has to be *) (* guessed. *) (* 5. uFunc = 1 / 2 (read coils, read discrete inputs) are no *) (* longer supported. This block is about register data; for *) (* coils use the single-job FB_MBReadLoop (101/102/105/115). *) (* ------------------------------------------------------------ *) (* How it works (internal state machine): *) (* 0 idle --> 6 round init --> 1 schedule *) (* --> 2 build request, raise REQ --> 3 wait BUSY low/DONE/ERR *) (* --> back to 1 for the next chunk / job *) (* --> all done --> 5 wait round gap --> 6 next round *) (* On failure it runs 41/42/43 to drop and reconnect that *) (* slave, then carries on with the other jobs. *) (* Only one request is in flight at a time (serial polling); *) (* all 8 connections stay open in the meantime. *) (* ------------------------------------------------------------ *) (* How to fill the two tables: *) (* *) (* [UDT_MbStation slave table] index 1..8 IS the connection slot*) (* sIP slave IP, e.g. [192,168,0,51] *) (* uPort slave port, usually 502 *) (* *) (* [UDT_MbTask job table] up to 32 rows, one row = one *) (* contiguous register block *) (* uStaNo which slave (index into the slave table, 1..8) *) (* bWrite FALSE = read this row; TRUE = write this row *) (* uFunc read : 3 = FC03 read holding registers *) (* 4 = FC04 read input registers *) (* write: 6 = FC06 write single register *) (* 16 = FC16 write multiple registers *) (* (the direction must match bWrite or it is *) (* rejected by the sanity check) *) (* uAddr start register address, 0-based on-the-wire *) (* uCount read : how many, can exceed 125 - auto split *) (* write: how many, max 123 - NOT auto split *) (* uBufOff where the data sits in aBuf *) (* bEnable FALSE = this row takes no part *) (* uTrig 0 = every round; 1 = only while bTrigWrite *) (* permits *) (* *) (* Example 1: one slave, read 600 registers into aBuf[0..599] *) (* aTask[1].uStaNo := 1; aTask[1].bWrite := FALSE; *) (* aTask[1].uFunc := 3; *) (* aTask[1].uAddr := 0; aTask[1].uCount := 600; *) (* aTask[1].uBufOff:= 0; aTask[1].bEnable:= TRUE; *) (* aTask[1].uTrig := 0; *) (* uTaskCount := 1; *) (* Internally this becomes 5 frames: 125+125+125+125+100 *) (* *) (* Example 2: the same slave (same IP and port), reading and *) (* writing *) (* aSta[1] = [192,168,0,51] uPort 502 *) (* aTask[1] read : uStaNo=1 bWrite=FALSE uFunc=3 *) (* uAddr=0 uCount=20 uBufOff=0 uTrig=0 *) (* aTask[2] write: uStaNo=1 bWrite=TRUE uFunc=16 *) (* uAddr=100 uCount=2 uBufOff=500 uTrig=1 *) (* (fill aBuf[500..501] from outside first) *) (* Both jobs share slot 1's TCP connection and run one *) (* after the other. The write job has uTrig=1, so it only *) (* writes in the round where bTrigWrite permits. *) (* *) (* Example 3: three slaves, 100 registers each *) (* aSta[1] = [192,168,0,51] aSta[2] = [192,168,0,52] *) (* aSta[3] = [192,168,0,53]; uTaskCount := 3 *) (* aTask[1]: uStaNo=1 bWrite=FALSE uFunc=3 *) (* uAddr=0 uCount=100 uBufOff=0 *) (* aTask[2]: uStaNo=2 ... uBufOff=100 *) (* aTask[3]: uStaNo=3 ... uBufOff=200 *) (* Data lands in aBuf[0..99] [100..199] [200..299] *) (* ------------------------------------------------------------ *) (* Five rules that must be followed: *) (* 1. iInterfaceId is the hardware identifier of the CPU's own *) (* PROFINET interface ("Device config -> interface -> *) (* properties -> hardware identifier", commonly 64). Using *) (* the slave's IP here is wrong - it is this CPU's port. *) (* 2. The block must be called unconditionally in OB1 or a *) (* cyclic interrupt OB on every scan. MB_CLIENT is an *) (* asynchronous instruction - skipping a call will stall it. *) (* 3. Connection IDs used: uConnIdBase..+7. On 80A1/A3 the *) (* running base bumps by one. If they collide, just *) (* change the uConnIdBase input - no code edit needed. *) (* 4. aBuf is 1000 words by default. To hold more, enlarge both *) (* the ARRAY[0..999] in VAR_IN_OUT and the 1000 boundary *) (* constants in section 6 below. *) (* 5. The write master switch bWriteEnable defaults to FALSE. *) (* To actually write into a slave you must set it TRUE - *) (* on site, wire it to an HMI "write permitted" switch. *) (* ------------------------------------------------------------ *) (* Version: v3.5 2026-10-10 *) (* v3.5 fixes bug #8: a stuck connection ID (80A1/80A3 *) (* loop). Field proof: after a power cycle 54 frames ran *) (* fine; once the link dropped, teardown plus reconnect *) (* never got the SAME ID up again (80A1). A different ID *) (* worked instantly - same finding as the Siemens forum. *) (* Now on 80A1/80A3 it bumps its running base ID by one *) (* and rebuilds (uConnIdRun, wraps at 4095). A Modbus *) (* exception from the server (8380..838F) no longer tears *) (* the link down - the link itself is fine. *) (* First-failure forensics: wStatusFirst / uFirstErrTask *) (* keep the ORIGINAL code of the very first failure - the *) (* 80A1 loop would overwrite wStatus and lose the trigger. *) (* byVer = 16#08. *) (* Version: v3.4 2026-10-10 *) (* v3.4 fixes the 6th and 7th real bug (symptom on site: *) (* 80A1 and 8200 cycling, reproduced beat by beat in the *) (* simulation once the model grew a connection layer): *) (* 6) The disconnect only waited two beats. 41->42->43 is *) (* three scans, 20 ms at a 10 ms cycle, while a real *) (* TCP teardown takes well over 100 ms. The next REQ *) (* therefore always lands inside the "connection is *) (* being torn down" window, and MB_CLIENT answers *) (* 16#80A1 (port / connection resource unavailable) or *) (* 16#8200 (this connection is still busy with another *) (* request) - fail, disconnect, send, fail, forever, *) (* with the OK count stuck at 0. *) (* Now: hold DISCONNECT until tDiscWait elapses, then *) (* cool down for tReconn before the next frame. *) (* 7) The connection parameters were rewritten every frame *) (* (LocalPort included), so MB_CLIENT took it as a *) (* parameter change and rebuilt the link every time. *) (* Now they are rewritten only when they really changed. *) (* Also new: uConnIdBase - the connection ID is now *) (* configurable. If IDs 1..8 are already taken by other *) (* comms on site, move this base to a free range *) (* (TCON_IP_v4.ID is 1..4095). *) (* Version: v3.3 2026-10-10 *) (* v3.3 adds uUnitId (device ID) to UDT_MbStation. NOTE: *) (* MB_UNIT_ID is NOT a call parameter of MB_CLIENT, it is the *) (* STATIC variable of the instruction (set in the instance DB). *) (* v3.3 wrongly wired it as a pin; the compiler answered *) (* "invalid formal parameter MB_UNIT_ID". Fixed: assign *) (* astMB[n].MB_Unit_ID before every call. Nothing wrote it *) (* before, so every frame carried the default 16#FF (255). *) (* Many devices only accept 1: TCP connects (idle 16#7004) but *) (* every job dies, with 16#80A1 / 16#8200 alternating on site. *) (* Each station in DB100 has its own uUnitId: usually 1 direct, *) (* or the RTU station number behind a gateway. UDT changed: *) (* rebuild DB100. *) (* Version: v3.2 2026-10-09 *) (* v3.2 fixes the fifth real defect (found in the on-site *) (* diagnostic buffer): *) (* 5) SCL does NOT short-circuit. The section-6 gate was one *) (* compound condition "bJobRun AND iSlot>=1 AND NOT *) (* abBusy[iSlot]". SCL evaluates every operand, so on the *) (* scans where iSlot = 0 it still read abBusy[0] - out of *) (* range for ARRAY[1..8] (a read access). Signature: two *) (* "area length error in FB 2" entries on the first two scans *) (* after RUN, then silence (iSlot>=1 from the third scan on). *) (* Fixed with nested IFs: range check outside, array access *) (* inside. Never put an array access and its range test in *) (* the same compound condition. *) (* v3.1 fixed four (kept): 1) section 6 had no job-in-flight *) (* gate, so a connection-level error bumped dwErrCount every *) (* scan - the huge error count on site was fabricated; 2) REQ *) (* held high means re-send on completion, now one pulse; *) (* 3) the round timer skipped the 3 reconnect scans (41-43); *) (* 4) settling in the scan REQ was raised reported the *) (* momentary 16#7005 instead of the real 16#80C5 (now settled *) (* only once BUSY has dropped, overrun excepted). *) (* Import : Project tree -> External source files -> Add new *) (* external file -> Generate blocks from source. This file *) (* contains 2 PLC data types + 1 FB; generate the two UDTs *) (* first. *) (* After an interface change you MUST rebuild the call block *) (* and its instance DB: delete both from OB1, drag the FB *) (* in again and wire aSta / aTask / aBuf. Replacing the FB *) (* alone keeps the area length error alive. *) (* Depends: FB_MBReadLoop is the single-job version; both may *) (* coexist without interfering. *) (* Import : Project tree -> External source files -> Add new *) (* external file -> Generate blocks from source. This *) (* file contains 2 PLC data types + 1 FB; generate the *) (* two UDTs first. *) (* Depends: FB_MBReadLoop is the single-job version; both may *) (* coexist without interfering. *) (* ============================================================ *) TYPE "UDT_MbStation" VERSION : 0.1 STRUCT sIP : ARRAY[1..4] OF USINT; // Slave IP address, e.g. [192,168,0,51] uPort : UINT; // Slave port, Modbus TCP standard is 502 uUnitId : USINT := 16#FF; // Device ID (Modbus Unit ID). Left at // the MB_CLIENT default 16#FF (255), // many direct devices only accept 1 - // check this first when nothing comes // back. Via a gateway: the RTU address. END_STRUCT; END_TYPE TYPE "UDT_MbTask" VERSION : 0.2 STRUCT uStaNo : UINT; // Which slave: index 1..8 into the slave table (= connection slot) bWrite : BOOL; // FALSE = read this row; TRUE = write this row uFunc : UINT; // read : 3 = FC03 holding registers, 4 = FC04 input registers // write: 6 = FC06 single register, 16 = FC16 multiple registers uAddr : UINT; // Start register address, 0-based on-the-wire offset uCount : UINT; // read : how many, may exceed 125 (auto split) // write: how many, max 123 (never split) uBufOff : UINT; // Where the data starts inside aBuf bEnable : BOOL; // TRUE = this row takes part in polling uTrig : UINT; // 0 = every round; 1 = only while bTrigWrite permits END_STRUCT; END_TYPE FUNCTION_BLOCK "FB_MbMultiRead" { S7_Optimized_Access := 'TRUE' } VAR_INPUT bEnable : BOOL := TRUE; // TRUE = auto cycle; FALSE = pause (connections stay open) iInterfaceId : UINT := 64; // This CPU's PROFINET interface HW ID (not the slave's) uTaskCount : UINT := 1; // How many rows of the job table are in use, 1..32 uMaxRegs : UINT := 125; // Max registers per frame, 1..125 (read jobs only) bWriteEnable : BOOL := FALSE; // Write master switch: FALSE skips every write job bTrigWrite : BOOL := FALSE; // Trigger: jobs with uTrig=1 run in a round it permits tPollGap : TIME := T#200ms; // Gap between two rounds tPollFail : TIME := T#3s; // Gap after a round that contained a failure (back-off) tTimeout : TIME := T#3s; // Per-frame timeout (safety net against stalls) bAutoRecover : BOOL := TRUE; // TRUE = drop and reconnect that slave after a failure bSkipDead : BOOL := TRUE; // TRUE = skip remaining jobs of a slave that failed this round bReset : BOOL := FALSE; // Reset statistics and fault flags uConnIdBase : UINT := 1; // First connection ID: slot n = base+n-1; auto-bumps on 80A1/A3 tDiscWait : TIME := T#300ms; // How long DISCONNECT is held when dropping a link tReconn : TIME := T#200ms; // Cool-down after DISCONNECT is released END_VAR VAR_IN_OUT aSta : ARRAY[1..8] OF "UDT_MbStation"; // Slave table, index = connection slot 1..8 aTask : ARRAY[1..32] OF "UDT_MbTask"; // Job table, up to 32 rows aBuf : ARRAY[0..999] OF WORD; // Data buffer: read destination and write source END_VAR VAR_OUTPUT bRoundDone : BOOL; // One-scan pulse once a full round finished bBusy : BOOL; // Polling in progress bError : BOOL; // Last frame failed (auto-cleared by the next success) bOnline : BOOL; // TRUE = last frame succeeded uCurTask : UINT; // Job number currently / last executed uCurSlot : USINT; // Slave slot currently / last used uRoundOK : UINT; // Successful frames in this round uRoundErr : UINT; // Failed frames in this round uFailRun : UINT; // Consecutive failure count dwOKCount : DWORD; // Total successful frames (reads and writes) dwErrCount : DWORD; // Total failed frames wStatus : WORD; // Latched result code of the last frame (0 = OK) wStatusLive : WORD; // Raw live MB_CLIENT STATUS tRoundTime : TIME; // Duration of the previous round iStepOut : INT; // State machine step (online diagnostics) bTimeout : BOOL; // TRUE = last failure was the safety net timeout wStatusFirst : WORD; // Original code of the very first failure (forensics) uFirstErrTask: UINT; // Task number of the very first failure (0 = none yet) bParaFault : BOOL; // TRUE = illegal job table / parameter was skipped bWriteLocked : BOOL; // TRUE = write jobs exist but the master switch is off abTaskOK : ARRAY[1..32] OF BOOL; // Per job: did the last execution succeed abTaskErr : ARRAY[1..32] OF BOOL; // Per job: did the last execution fail awTaskStatus : ARRAY[1..32] OF WORD; // Per job: latched result code iCurWrite : BOOL; // TRUE = the current / last frame is a write job dwWrOKCount : DWORD; // Total successful write frames dwWrErrCount : DWORD; // Total failed write frames byVer : BYTE; // Version fingerprint: expect 16#08 END_VAR VAR astMB : ARRAY[1..8] OF MB_CLIENT; // 8 independent TCP connections, kept open astConn : ARRAY[1..8] OF TCON_IP_v4; // Connection description per slot uConnIdRun : UINT := 0; // Running connection ID base; 0 = not synced (takes uConnIdBase on first scan) auMode : ARRAY[1..8] OF UINT; // Current MB_MODE per slot auAddr : ARRAY[1..8] OF UINT; // Current MB_DATA_ADDR per slot auLen : ARRAY[1..8] OF UINT; // Current MB_DATA_LEN per slot abReq : ARRAY[1..8] OF BOOL; // REQ per slot abDisc : ARRAY[1..8] OF BOOL; // DISCONNECT per slot abDone : ARRAY[1..8] OF BOOL; // DONE per slot abBusy : ARRAY[1..8] OF BOOL; // BUSY per slot abErr : ARRAY[1..8] OF BOOL; // ERROR per slot awSt : ARRAY[1..8] OF WORD; // STATUS per slot aChunk : ARRAY[0..124] OF WORD; // Single frame send / receive buffer abTaskDone : ARRAY[1..32] OF BOOL; // Job already finished in this round abTaskBad : ARRAY[1..32] OF BOOL; // Job configuration illegal (recomputed every scan) abSlotDead : ARRAY[1..8] OF BOOL; // Slave already failed in this round bJobRun : BOOL; // A job is in flight on this slot (gate for section 6) iStep : INT := 0; // State machine step iSlot : INT := 0; // Current slot, 0 = not used yet iTaskNo : INT := 0; // Current job number, 0 = none iChunkOff : INT := 0; // How many registers of the current job are done iReqLen : INT := 0; // Registers requested in this frame iWriteOff : INT := 0; // Start index of this frame's data inside aBuf bFirst : BOOL := TRUE; // First run flag (fire one round right after power-up) bPrevEn : BOOL := FALSE; // bEnable state of the previous scan bPrevTrig : BOOL := FALSE; // bTrigWrite state of the previous scan bTrigLatch : BOOL := FALSE; // Trigger latch so a one-scan pulse survives tGapTmr : TON_TIME; // Round gap timer tToTmr : TON_TIME; // Frame timeout timer tRoundTmr : TON_TIME; // Round duration timer abConnInit : ARRAY[1..8] OF BOOL; // Connection params of this slot already written? tDiscTmr : TON_TIME; // Holds DISCONNECT down tReconTmr : TON_TIME; // Cool-down before the next frame byVer : BYTE := 16#08; // Version fingerprint (v3.5) END_VAR VAR_TEMP i : INT; // Loop index iNext : INT; // Job number picked by the scheduler iSlotTmp : INT; // Temporary slot number (range-checked before use) iTaskCnt : INT; // Clamped job count iMaxReq : INT; // Clamped max registers per frame (read) iChunkRemain : INT; // Registers still to do in the current job iModeTmp : INT; // Intermediate value when building MB_MODE tPT : TIME; // Effective timer preset bEnRise : BOOL; // Rising edge of bEnable bTrigRise : BOOL; // Rising edge of bTrigWrite bTrigNow : BOOL; // Is the trigger condition satisfied this round bOvertime : BOOL; // Did a safety net timeout happen this scan bThisBad : BOOL; // Intermediate value while checking one job row bHasWrite : BOOL; // Is there a usable write job in the table bThisWr : BOOL; // While checking: is this row a legal write job diFuncMax : DINT; // While checking: max quantity for this function code bRoundTmrOn : BOOL; // Round timer enable (includes the 3 reconnect steps) END_VAR BEGIN (* ======== 1. Version fingerprint and parameter check ======== *) IF #uConnIdRun = 0 THEN #uConnIdRun := #uConnIdBase; // First scan after power-up: align with the input base END_IF; #byVer := 16#08; #bOvertime := FALSE; // VAR_TEMP must be cleared explicitly every scan #bParaFault := FALSE; #bHasWrite := FALSE; (* Compare uTaskCount as UINT first - converting UINT to INT turns values above 32767 into negatives, so a direct UINT_TO_INT followed by an upper bound check would accept 40000 as a small legal number. *) IF #uTaskCount > 32 THEN #iTaskCnt := 32; #bParaFault := TRUE; ELSE #iTaskCnt := UINT_TO_INT(#uTaskCount); END_IF; IF (#uMaxRegs < 1) OR (#uMaxRegs > 125) THEN (* 125 is the Modbus limit for one frame; a larger value would be rejected by the slave anyway *) #bParaFault := TRUE; IF #uMaxRegs > 125 THEN #iMaxReq := 125; ELSE #iMaxReq := 1; END_IF; ELSE #iMaxReq := UINT_TO_INT(#uMaxRegs); END_IF; (* Sanity check of the whole job table, every scan. bParaFault must be a STATIC output: if it were only set during round initialisation, the line at the top of this section would clear it on the very next scan and the watch table would never show the alarm - useless. (This was found by simulation, not by reasoning.) *) FOR #i := 1 TO 32 DO #bThisBad := FALSE; #bThisWr := FALSE; IF (#i <= #iTaskCnt) AND #aTask[#i].bEnable THEN (* a. A bad slave number is fatal: a wrong uStaNo is an array index out of range, which sends the PLC straight to STOP. *) IF (#aTask[#i].uStaNo < 1) OR (#aTask[#i].uStaNo > 8) THEN #bThisBad := TRUE; END_IF; (* b. Direction and function code must agree - whitelist, not exclusion. Write jobs accept only FC06 (single) and FC16 (multiple); read jobs accept only FC03 and FC04. A write function code with bWrite left FALSE is rejected here: otherwise MB_CLIENT really would send a write frame, and in write mode MB_DATA_PTR is the data source - the slave would silently receive whatever is in the external buffer. *) #diFuncMax := 0; IF #aTask[#i].bWrite THEN #bThisWr := TRUE; IF #aTask[#i].uFunc = 6 THEN #diFuncMax := 1; // FC06 writes exactly one register ELSIF #aTask[#i].uFunc = 16 THEN #diFuncMax := 123; // FC16 up to 123, and never split ELSE #bThisBad := TRUE; END_IF; ELSE IF (#aTask[#i].uFunc = 3) OR (#aTask[#i].uFunc = 4) THEN #diFuncMax := 125; // Per frame limit; splitting is allowed, see c ELSE #bThisBad := TRUE; END_IF; END_IF; (* c. Quantity check *) IF #aTask[#i].uCount = 0 THEN #bThisBad := TRUE; ELSIF #aTask[#i].bWrite AND (UINT_TO_DINT(#aTask[#i].uCount) > #diFuncMax) THEN (* Write jobs are NEVER split: if it does not fit in one frame it is an error, not something to break up quietly. A split write leaves the slave momentarily holding half the new values and half the old ones, which is not acceptable on real equipment. To write more, add more rows. *) #bThisBad := TRUE; END_IF; (* d. The data must fit inside aBuf *) IF (UINT_TO_DINT(#aTask[#i].uBufOff) + UINT_TO_DINT(#aTask[#i].uCount)) > 1000 THEN #bThisBad := TRUE; END_IF; (* e. Trigger mode accepts only 0 and 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; // static: a skipped row must stay visible END_IF; END_FOR; (* Usable write jobs exist but the master switch is off. The operator has to be able to see this state, otherwise it just looks like "I configured the write job, why does nothing happen". *) #bWriteLocked := #bHasWrite AND (NOT #bWriteEnable); (* ========================= 2. Reset ========================= *) IF #bReset THEN #dwOKCount := 0; #dwErrCount := 0; #dwWrOKCount := 0; #dwWrErrCount := 0; #uFailRun := 0; #bError := FALSE; #bOnline := FALSE; #wStatus := 0; #iStep := 0; #bJobRun := FALSE; // Clear the in-flight flag too, else a ghost job stays behind #bFirst := TRUE; // Restart as "first" so one round runs immediately #uConnIdRun := #uConnIdBase; // Pull the running ID base back to the input #wStatusFirst := 0; // Forensics records cleared too #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. The three timers ==================== *) (* Frame timeout: runs only in "wait for result", resets itself elsewhere. Step 3 now means "wait until BUSY drops", so this timeout also covers the case where MB_CLIENT itself never answers - which is exactly what a slave that lost power looks like. *) #tToTmr(IN := (#iStep = 3), PT := #tTimeout); (* Round gap: a round with failures uses tPollFail as a back-off so a dead slave does not keep burning communication resources *) #tPT := #tPollGap; IF #uRoundErr > 0 THEN #tPT := #tPollFail; END_IF; #tGapTmr(IN := (#iStep = 5), PT := #tPT); (* Round duration: from scheduling to end of round, read ET. Excludes the gap. The three reconnect steps 41..43 MUST be part of the round: gating only 1..4 would reset ET during those steps, so any round that had an error would always report T#0MS - the diagnostic would be worthless. *) #bRoundTmrOn := ((#iStep >= 1) AND (#iStep <= 4)) OR ((#iStep >= 41) AND (#iStep <= 44)); #tRoundTmr(IN := #bRoundTmrOn, PT := T#1h); (* v3.4: hold DISCONNECT this long so the TCP teardown completes. A real teardown takes well over 100 ms; the old two beats (20 ms) were nowhere near enough. *) #tDiscTmr(IN := (#iStep = 42), PT := #tDiscWait); (* v3.4: cool down after DISCONNECT is released, giving MB_CLIENT a clean window to rebuild the connection. *) #tReconTmr(IN := (#iStep = 44), PT := #tReconn); (* ===================== 4. State machine ===================== *) #bEnRise := #bEnable AND NOT #bPrevEn; #bPrevEn := #bEnable; #bRoundDone := FALSE; // Pulse output, cleared by default (* Trigger latch: even a one-scan pulse on bTrigWrite survives until the next round initialisation. A sustained level counts in every round (handled with OR in the round initialisation below). *) #bTrigRise := #bTrigWrite AND NOT #bPrevTrig; #bPrevTrig := #bTrigWrite; IF #bTrigRise THEN #bTrigLatch := TRUE; END_IF; IF NOT #bEnable THEN #bTrigLatch := FALSE; // Triggers accumulated while disabled are dropped END_IF; (* Disable freezes polling immediately. Connections are not dropped on purpose: a kept connection can be reused directly, while dropping it would force a fresh handshake. *) IF (NOT #bEnable) AND (#iStep >= 1) AND (#iStep <= 6) THEN #iStep := 0; #bJobRun := FALSE; // Disabling voids the running job so its late result is not counted END_IF; CASE #iStep OF 0: (* ---- Idle ---- *) FOR #i := 1 TO 8 DO #abReq[#i] := FALSE; // Drop every request so MB_CLIENT can settle END_FOR; IF #bEnable AND (#bFirst OR #bEnRise) THEN #bFirst := FALSE; #iStep := 6; END_IF; 1: (* ---- Scheduling: pick the next job to run ---- *) #iNext := 0; (* First look for an unfinished job on the current slot. Finishing a slave's jobs back to back avoids switching slots, and it also makes grouping the table by slave optional - even with read and write rows interleaved, they are gathered per slave here, so reads and writes naturally share that one connection. *) 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; (* Nothing left on this slave, move to a slave that still has work *) 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); (* Range check first: a wrong uStaNo is an out-of-range array index and sends the PLC to 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 (* Whole round finished *) #tRoundTime := #tRoundTmr.ET; #bRoundDone := TRUE; #iStep := 5; ELSIF #iNext <> #iTaskNo THEN (* Job changed: reset the chunk offset and start from its head *) #iTaskNo := #iNext; #iSlot := UINT_TO_INT(#aTask[#iNext].uStaNo); #iChunkOff := 0; #iStep := 2; ELSE (* Same job: the previous chunk is done, continue with the next *) #iStep := 2; END_IF; 2: (* ---- Build the request for this frame ---- *) IF #aTask[#iTaskNo].bWrite THEN (* Write job: send the whole row in one frame, never split. Section 1 already verified the count is at most 123, so there is no need to compare with uMaxRegs here - that limit is for reads. *) #iReqLen := UINT_TO_INT(#aTask[#iTaskNo].uCount); ELSE (* Read job: this frame carries what is left, capped by the limit *) #iChunkRemain := UINT_TO_INT(#aTask[#iTaskNo].uCount) - #iChunkOff; IF #iChunkRemain > #iMaxReq THEN #iReqLen := #iMaxReq; ELSE #iReqLen := #iChunkRemain; END_IF; END_IF; (* Build the connection parameters of this slot. Serial polling guarantees no other job is running right now, so changing them is safe. Changing a slave IP or port at runtime takes effect next frame. *) (* v3.4 fix 7: rewrite the connection parameters only when they really changed. Copying them in every frame (LocalPort back to 0 included) made MB_CLIENT believe the parameters had been touched, so it re-established the link - and the request of this frame landed in the "link not up yet" window, answering 80A1 (port / connection resource unavailable) or 8200 (this connection is still busy with another request). That was the second root cause behind "80A1 and 8200 cycling, OK count always 0". These comparisons live in one compound condition, but none of them can index out of range (#iSlot was range-checked in step 1), so a compound condition is safe here - splitting into nested IFs is only mandatory when a range test and an array access sit in the same condition. *) 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 opens it (client role) #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 = assign the local port automatically #abConnInit[#iSlot] := TRUE; END_IF; (* Direct function code method: MB_MODE = 100 + function code. 3 -> 103 read holding registers, 4 -> 104 read input registers, 6 -> 106 write single register, 16 -> 116 write multiple registers. With this method uAddr must be the 0-based on-the-wire address, never a 40001-style number. *) #iModeTmp := UINT_TO_INT(#aTask[#iTaskNo].uFunc); #auMode[#iSlot] := INT_TO_UINT(100 + #iModeTmp); (* Chunk address = job start address + offset done so far. For a write job iChunkOff is always 0. *) #auAddr[#iSlot] := #aTask[#iTaskNo].uAddr + INT_TO_UINT(#iChunkOff); #auLen[#iSlot] := INT_TO_UINT(#iReqLen); (* Data position = job buffer offset + offset done so far *) #iWriteOff := UINT_TO_INT(#aTask[#iTaskNo].uBufOff) + #iChunkOff; (* Write job: move the payload from the external buffer into the send buffer. It must be done before REQ is raised below - as soon as MB_CLIENT is called it takes the data from 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 (* Clear the tail so leftovers from the last read are not sent out together with the new values *) #aChunk[#i] := 0; END_IF; END_FOR; END_IF; (* REQ is a one-scan pulse; step 3 drops it again right away. Manual wording: "once the current request is finished, a new request can be processed if the call is made with REQ = 1". Holding REQ high therefore means "start the next job as soon as this one ends" - with a connection-level fault that turns into one error report per scan and blows the statistics apart. A pulse sends exactly one request; completion is signalled by DONE/ERROR or the safety-net timeout. *) #abReq[#iSlot] := TRUE; #bJobRun := TRUE; // Gate: from here on section 6 may handle results #iStep := 3; 3: (* ---- Waiting for the result ---- *) #abReq[#iSlot] := FALSE; // Pulse ends here: REQ was high for exactly one scan (* What is read here are the MB_CLIENT outputs of the PREVIOUS scan; they only drive the state machine. The real data handling happens in section 6 (after the call, same scan), which is where the one-scan DONE/ERROR pulse can still be caught. *) (* First check whether BUSY has dropped: as long as it is still high the frame we sent is still in flight, so this is neither the place to judge the result nor the place to drop the connection. With a connection-level fault (16#80C5) MB_CLIENT keeps ERROR asserted and it lights up again the moment REQ sees a rising edge - ignoring BUSY means "job still running" is read as "job failed", the code reported is the momentary 16#7005 (sending), and the real 16#80C5 that arrives later is claimed by nobody. And if that frame did succeed, its DONE is swallowed too: no data into aBuf, no abTaskOK, one frame wasted. *) IF #tToTmr.Q THEN (* Even MB_CLIENT did not answer - safety net timeout. It must count as a failure, otherwise a slave that is completely powered off leaves the statistics perfectly clean, looking like "no fault". *) #bOvertime := TRUE; IF #bAutoRecover THEN #iStep := 41; ELSE #iStep := 1; END_IF; ELSIF NOT #abBusy[#iSlot] THEN (* BUSY has dropped: this frame is settled, so DONE/ERROR is its verdict *) IF #abErr[#iSlot] THEN IF (#awSt[#iSlot] >= 16#8380) AND (#awSt[#iSlot] <= 16#838F) THEN (* Server returned a Modbus exception (838x): the TCP link is fine - do NOT tear it down. Tearing down on every error is what got the ID stuck. *) #iStep := 1; ELSIF #bAutoRecover THEN #iStep := 41; ELSE #iStep := 1; END_IF; ELSIF #abDone[#iSlot] THEN #iStep := 1; END_IF; END_IF; 5: (* ---- Round finished, waiting for the gap ---- *) IF #tGapTmr.Q THEN #iStep := 6; END_IF; 6: (* ---- Round initialisation ---- *) #uRoundOK := 0; #uRoundErr := 0; #iTaskNo := 0; #iChunkOff := 0; FOR #i := 1 TO 8 DO #abSlotDead[#i] := FALSE; // Every slave gets a fresh chance each round END_FOR; (* Trigger condition for this round: a sustained level counts, a pulse is caught by the latch. Consume the latch - one trigger maps to one round. *) #bTrigNow := #bTrigWrite OR #bTrigLatch; (* Consume the latch unconditionally: one trigger maps to exactly one round. Guarding it with "only while bTrigWrite is FALSE" makes a single-scan pulse that lands on the round-init scan fire twice, because level and latch are both true at that instant. A sustained level is carried by bTrigWrite itself, every round. *) #bTrigLatch := FALSE; FOR #i := 1 TO 32 DO IF (#i > #iTaskCnt) OR (NOT #aTask[#i].bEnable) OR #abTaskBad[#i] THEN (* Rows beyond the count, deliberately disabled rows and rows that failed the sanity check are all marked as "done" and skipped. That is much clearer than letting them send a frame that is bound to fail. *) #abTaskDone[#i] := TRUE; ELSIF (#aTask[#i].uTrig = 1) AND (NOT #bTrigNow) THEN (* Trigger condition not satisfied: this row takes no part *) #abTaskDone[#i] := TRUE; ELSIF #aTask[#i].bWrite AND (NOT #bWriteEnable) THEN (* A write job meeting a closed master switch is skipped. That is deliberate: neither a freshly imported block nor a table that was edited by mistake should write into a slave by itself. *) #abTaskDone[#i] := TRUE; ELSE #abTaskDone[#i] := FALSE; END_IF; END_FOR; #iStep := 1; 41: (* ---- Drop that slave's connection: assert DISCONNECT ---- *) #abReq[#iSlot] := FALSE; #abDisc[#iSlot] := TRUE; #iStep := 42; 42: (* ---- Hold DISCONNECT until the link is really gone ---- *) (* v3.4 fix 6, the most important change of this release. This used to be "wait two beats" (41 -> 42 -> 43), which at a 10 ms cycle is 20 ms, while a real TCP teardown takes well over 100 ms. Two beats meant the next REQ always landed in the "connection is being torn down" window, and MB_CLIENT answered 16#80A1 or 16#8200 - so it failed, disconnected and sent again, with the OK count stuck at 0 forever. The simulation reproduced this beat by beat once it had a connection layer: teardown started at beat 21, the FB was already back in step 1 at beat 23, and the frame sent at beat 25 answered 80A1. Now DISCONNECT is held until tDiscWait has elapsed. *) #abDisc[#iSlot] := TRUE; IF #tDiscTmr.Q THEN #iStep := 43; END_IF; 43: (* ---- Release DISCONNECT ---- *) #abDisc[#iSlot] := FALSE; #iStep := 44; 44: (* ---- Reconnect cool-down: a clean window for TCP ---- *) IF #tReconTmr.Q THEN #iStep := 1; END_IF; END_CASE; (* =============== 5. Call MB_CLIENT (8 slots) ================ *) (* All 8 slots must be called unconditionally every scan. MB_CLIENT is an asynchronous instruction: raising a request without calling it further leaves the job hanging forever. Unused slots keep REQ FALSE and never actually open a connection, so the overhead is negligible. MB_DATA_PTR points at aChunk for every slot - serial polling guarantees only one slot runs at a time, so sharing one buffer is safe: it is the receive area while reading and the send area while writing. Note: a multi-instance call of MB_CLIENT requires a CONSTANT index, so the 8 calls are spelled out. Ugly, but it is the only form TIA accepts - the slot number is a variable and astMB[i](...) would not compile. MB_UNIT_ID (device ID): NOT a call parameter of MB_CLIENT. The official parameter list has 11 pins only (REQ / DISCONNECT / MB_MODE / MB_DATA_ADDR / MB_DATA_LEN / MB_DATA_PTR / CONNECT / DONE / BUSY / ERROR / STATUS); the device ID lives in the STATIC variable MB_Unit_ID of the instruction (set it in the instance DB). v3.3 wrongly wired it as a pin and the compiler answered "invalid formal parameter MB_UNIT_ID". Now it is assigned before every call: #astMB[n].MB_Unit_ID := #aSta[n].uUnitId. Before v3.3 nothing wrote it, so every frame carried the default 16#FF (255) - with a device that only accepts 1 the symptom is "TCP connects (idle 16#7004), every job dies". *) #astMB[1].MB_Unit_ID := #aSta[1].uUnitId; // device ID: a static #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; // device ID: a static #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; // device ID: a static #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; // device ID: a static #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; // device ID: a static #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; // device ID: a static #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; // device ID: a static #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; // device ID: a static #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. Handle the result =================== *) (* Must be done after the call, within the same scan: DONE/ERROR pulse for one scan only, so by the time the state machine looks at it next scan it is gone. *) (* This whole section must be gated by bJobRun: results are handled only while a job is actually in flight on this slot. Without the gate (the real v3.0 defect) the section also runs while the state machine is idle (steps 0/5/6), and a connection-level error (16#80C5 remote refused / closed) keeps ERROR asserted - so dwErrCount / uFailRun were incremented every single scan. The "46524 errors" seen on site were fabricated, not real frames, and the same runaway pushed uRoundErr up, which switched tPollFail in and stretched the round gap from 200ms to 3s: the block ended up spinning and counting instead of polling. *) (* A second gate on BUSY: only a frame that has actually been settled counts (the safety net timeout excepted). bJobRun alone is not enough - in the very scan REQ is raised, ERROR may still be asserted from the previous connection fault, and settling then reports 16#7005 (sending), a process state rather than a real error; the real 16#80C5 arrives afterwards, when the gate has already dropped, so nobody claims it. BUSY still high means this frame is still in flight, and in that state DONE/ERROR is not its verdict. *) (* The nested IFs here are mandatory, not a style choice (v3.2). Unlike C, SCL does NOT short-circuit: every operand of AND / OR is evaluated, in source order. So this is wrong: IF #bJobRun AND (#iSlot >= 1) AND (NOT #abBusy[#iSlot]) THEN On the scans where #iSlot = 0 it still reads #abBusy[0], which is out of range for abBusy : ARRAY[1..8]. The S7-1500 array bounds check is built into the runtime and cannot be disabled, so it reports "area length error in FB 2" (a read access). Site signature: exactly two entries on the first two scans after RUN, then nothing - from the third scan #iSlot is >= 1 because step 1 has picked a task. Correct form: range check in the outer IF (comparison only, no array), array access in the inner IF (slot provably valid). *) (* Reaching this section guarantees #iTaskNo >= 1 - not by assumption, by proof: #bJobRun is only raised in step 2, and step 1 has already assigned #iTaskNo when it picked a job; conversely #iTaskNo is only cleared to 0 in step 6 (round init), and by the time the state machine gets there #bJobRun is necessarily FALSE (every job drops the gate in section 6). So the #aTask[#iTaskNo] / #abTaskDone[#iTaskNo] accesses here are safe. * This gate is a LOAD-BEARING wall, not a cosmetic counter fix - do not remove it. Without it (the v3.0 form) section 6 reads #aTask[0] and writes #abTaskDone[#iTaskNo] on the scans where #iTaskNo = 0; both are out of range. With the runtime bounds check added to the simulation this measured 5689 out-of-range writes in 6000 scans - exactly the class of "write access 16#02:2523" entries seen in the 09-30 diagnostic buffer. *) IF #bJobRun AND (#iSlot >= 1) THEN // outer: range check only IF (NOT #abBusy[#iSlot]) OR #bOvertime THEN // inner: iSlot >= 1 here IF #abDone[#iSlot] THEN (* --------------------- Frame succeeded ---------------------- *) #bError := FALSE; #bOnline := TRUE; #wStatus := 0; // Cleared on success so wStatus shows "healthy right now" #uFailRun := 0; #dwOKCount := #dwOKCount + 1; IF #uRoundOK < 65535 THEN #uRoundOK := #uRoundOK + 1; END_IF; (* Per job feedback: these two arrays tell whether a write went through *) 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; (* Write job: the data went out, there is nothing to copy back. aBuf keeps whatever the caller put in, which helps comparing. *) ELSE (* Read job: copy this frame into the data buffer. The lower bound guard must not be dropped: if the job table is out of range, it is better to copy fewer words than to send the PLC to STOP through an array index violation. *) 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; (* Chunk advance: mark the job done once finished, otherwise the next scheduling pass continues with the following chunk *) #iChunkOff := #iChunkOff + #iReqLen; IF #iChunkOff >= UINT_TO_INT(#aTask[#iTaskNo].uCount) THEN #abTaskDone[#iTaskNo] := TRUE; #iChunkOff := 0; END_IF; #bJobRun := FALSE; // This frame is settled, drop the gate ELSIF #abErr[#iSlot] OR #bOvertime THEN (* ----------------------- Frame failed ----------------------- *) #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; (* Safety net timeout: even MB_CLIENT did not answer (that is what a completely powered-off slave looks like). Use 16#80C8 (receive timeout) as a common code so wStatus is never 0 here - the operator must be able to tell "something did happen" from "no fault". *) #wStatus := 16#80C8; ELSE #bTimeout := FALSE; #wStatus := #awSt[#iSlot]; // Latch the code; STATUS returns to 16#7000 right after END_IF; (* Forensics: record the original code and task number of the very first failure, once. Later 80A1s overwrite wStatus and the trigger would be lost (field 10-10: 54 good frames, then wStatus showed only 80A1). *) IF #uFirstErrTask = 0 THEN #wStatusFirst := #wStatus; #uFirstErrTask := #iTaskNo; END_IF; (* Stuck connection ID (after an abnormal drop the same ID never establishes again; a different ID works - Siemens forum and two field episodes) -> bump the running base. Next frame the ID differs, so the CONNECT rewrite fires. *) IF (#awSt[#iSlot] = 16#80A1) OR (#awSt[#iSlot] = 16#80A3) THEN IF #uConnIdRun < 16#0FFF THEN #uConnIdRun := #uConnIdRun + 1; ELSE #uConnIdRun := #uConnIdBase; // wrapped, start over END_IF; END_IF; (* Per job feedback: a failed write must be remembered, not just counted. A failed write means the slave did NOT take the value you wanted - that needs acting on. *) 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; (* Mark this slave dead for the rest of the round: its remaining jobs are skipped instead of waiting for their own timeouts *) #abSlotDead[#iSlot] := TRUE; (* The remaining chunks of the current job are dropped too - the slave is not answering, carrying on would just waste time *) #abTaskDone[#iTaskNo] := TRUE; #iChunkOff := 0; #bJobRun := FALSE; // Failure is settled as well, drop the gate END_IF; END_IF; END_IF; (* ==================== 7. Refresh outputs ==================== *) #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 (* ============================================================ *) (* Usage *) (* ------------------------------------------------------------ *) (* 1. Build the configuration first (a global DB is simplest) *) (* Create a global DB, say "MBConfig", with three variables: *) (* aSta : ARRAY[1..8] OF "UDT_MbStation" *) (* aTask : ARRAY[1..32] OF "UDT_MbTask" *) (* aBuf : ARRAY[0..999] OF WORD *) (* Then fill in the two UDTs row by row. Call it in OB1: *) (* *) (* "MbMulti_Master"(bEnable := "SystemRun", *) (* iInterfaceId := 64, *) (* uTaskCount := 2, *) (* aSta := "MBConfig".aSta, *) (* aTask := "MBConfig".aTask, *) (* aBuf := "MBConfig".aBuf); *) (* *) (* Note: aSta / aTask / aBuf are all VAR_IN_OUT and the types must match *) (* exactly - no partial arrays, no differently structured DB. aBuf moving *) (* from output to IN_OUT is one of the v3 interface changes (a write job *) (* needs an externally writable data source). *) (* *) (* ------------------------------------------------------------ *) (* 2. Case A: read several IP slaves *) (* 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 *) (* Result: aBuf[0..19] / [20..39] *) (* *) (* ------------------------------------------------------------ *) (* 3. Case B: one IP, 600 registers *) (* aSta[1] := [192,168,0,51], uTaskCount := 1 *) (* aTask[1]: uStaNo=1 bWrite=FALSE uFunc=3 *) (* uAddr=0 uCount=600 uBufOff=0 uTrig=0 *) (* Split into 5 frames: 125 + 125 + 125 + 125 + 100 *) (* Result: aBuf[0..599] (index 0 = register 0) *) (* A round takes 5 x (response time + overhead), so do not set tPollGap *) (* too small - 500 ms or more is comfortable. *) (* *) (* ------------------------------------------------------------ *) (* 4. Case C: one IP and one port, reading and writing (new in v3) *) (* The key is that BOTH rows use the SAME uStaNo - then they share that *) (* one TCP connection and the block runs them one after the other: read a *) (* frame, write a frame, read a frame ... *) (* Do NOT open a separate instance for the write; that would cost a second *) (* connection. *) (* *) (* aSta[1] := [192,168,0,51] uPort := 502 *) (* uTaskCount := 3 *) (* *) (* // read 20 registers into aBuf[0..19] *) (* aTask[1].uStaNo =1 bWrite=FALSE uFunc=3 *) (* aTask[1].uAddr =0 uCount =20 uBufOff=0 uTrig=0 *) (* *) (* // every round write 2 words to slave register 100, from aBuf[500..501] *) (* aTask[2].uStaNo =1 bWrite=TRUE uFunc=16 *) (* aTask[2].uAddr =100 uCount=2 uBufOff=500 uTrig=0 *) (* *) (* // then read 10 more into aBuf[20..29] *) (* aTask[3].uStaNo =1 bWrite=FALSE uFunc=3 *) (* aTask[3].uAddr =200 uCount=10 uBufOff=20 uTrig=0 *) (* *) (* // fill the payload into aBuf before it is sent out *) (* "MBConfig".aBuf[500] := 1500; // speed setpoint *) (* "MBConfig".aBuf[501] := 1; // run command *) (* *) (* ALSO open the write master switch, otherwise every write job is skipped: *) (* "MbMulti_Master"(bWriteEnable := TRUE, ...); *) (* Better still, wire it to an HMI "write permitted" switch. *) (* *) (* For "write once when a button is pressed", set uTrig = 1 and pulse *) (* bTrigWrite (the block latches it, so a one-scan pulse is caught). *) (* *) (* ------------------------------------------------------------ *) (* 5. Three rules for writing *) (* (1) The master switch bWriteEnable defaults to FALSE. With write jobs *) (* in the table but the switch off, nothing is written and *) (* bWriteLocked comes on - when you see it, confirm that you closed *) (* the switch on purpose. *) (* (2) bWrite and the direction of uFunc must agree: write jobs take only *) (* 6 or 16, read jobs only 3 or 4. A mismatch is caught by the sanity *) (* check and raises bParaFault. *) (* (3) Write jobs are never split, so at most 123 registers per frame *) (* (FC06 only 1). To write more, add more rows - do not expect the *) (* block to split. A split write leaves the slave with half the new *) (* and half the old values, which is not acceptable on real equipment. *) (* *) (* ------------------------------------------------------------ *) (* 6. What to watch *) (* bRoundDone : pulse once a full round finished (history / trigger) *) (* bOnline : TRUE = last frame succeeded *) (* bError : last frame failed (auto-cleared by the next success) *) (* wStatus : latched result code of the last frame. It must be latched *) (* because DONE/ERROR pulse for one scan only, after which *) (* STATUS returns to 16#7000. *) (* What you get here is always a REAL error code (16#80xx / 81xx / 83xx), *) (* never a 16#7005-style "sending" process state: sections 3 *) (* and 6 both settle only after BUSY has dropped. *) (* wStatusLive : live MB_CLIENT status (16#7000 idle / 16#7001 connecting *) (* / 16#7005 waiting for response) *) (* uCurTask : which row is running - when it hangs, this tells you where *) (* uCurSlot : which slave is being talked to (slot number) *) (* iCurWrite : TRUE = the current frame is a write job *) (* uRoundOK : successful frames this round; on trouble it is the best *) (* health report there is *) (* uRoundErr : failed frames this round *) (* uFailRun : consecutive failures; three or more points at the slave or *) (* the wiring *) (* dwOKCount : total successes - watch it climb to confirm data refreshes *) (* dwErrCount : total failures *) (* dwWrOKCount : total successful write frames *) (* dwWrErrCount: total failed write frames. Any non-zero value means you *) (* must look at abTaskErr to find which row failed - a failed *) (* write means the slave did not take the value you wanted, *) (* which needs acting on immediately. *) (* abTaskOK : per job success - watch this for write jobs especially *) (* abTaskErr : per job failure *) (* awTaskStatus: per job latched result code *) (* tRoundTime : duration of the last round, tells you if the cycle is fast *) (* enough *) (* iStepOut : state machine step: 0 idle / 1 scheduling / 2 building / *) (* 3 waiting / 5 round gap / 6 round init / 41 42 43 reconnect *) (* bTimeout : TRUE = the failure was the safety net timeout (the slave *) (* did not even answer at TCP level) *) (* bParaFault : TRUE = an illegal row was skipped, see section 8 *) (* bWriteLocked: TRUE = write jobs exist but the master switch is off *) (* *) (* ------------------------------------------------------------ *) (* 7. Choosing the parameters *) (* uMaxRegs Per frame read limit. The protocol allows 125, but some *) (* slaves / gateways only accept 120 or even 100. Lower this *) (* value rather than touching the slave firmware. It does not *) (* apply to write jobs. *) (* tPollGap Gap between rounds. With many jobs or many registers a round *) (* itself takes a while, so do not set the gap too tight. *) (* tPollFail Gap after a round with failures. Use 3..10 s so a dead slave *) (* does not keep the PLC waiting for nothing. *) (* tTimeout Per frame timeout. 2..5 s in a LAN; up to 10 s across subnets *) (* or wireless gateways. *) (* *) (* ------------------------------------------------------------ *) (* 8. Why a row is skipped (bParaFault on) *) (* - uCount = 0 nothing to process *) (* - uStaNo not in 1..8 wrong slave number *) (* - read job, uFunc is neither 3 nor 4 wrong function code *) (* - write job, uFunc is neither 6 nor 16 same reason *) (* - write job, uCount > 123 (or > 1 for FC06): does not fit one frame and *) (* is never split *) (* - uBufOff + uCount > 1000 data position outside aBuf *) (* - uTrig is neither 0 nor 1 wrong trigger mode *) (* - bEnable = FALSE deliberately disabled (no alarm) *) (* - job number > uTaskCount table has content that is not counted *) (* A write job stopped by the master switch is a different case: it does not *) (* raise bParaFault, it raises bWriteLocked. *) (* *) (* ------------------------------------------------------------ *) (* 9. Frequently asked questions *) (* Q: A write job is configured but the slave never changes *) (* A: Check in this order: (1) is bWriteLocked on (master switch is off by *) (* default); (2) does the row have uTrig = 1 while you are not pulsing *) (* bTrigWrite; (3) is abTaskErr[that row] TRUE - look at the code in *) (* awTaskStatus; (4) does the aBuf range at uBufOff actually contain the *) (* data you want to send. *) (* *) (* Q: Where do I set the device ID (Unit ID)? Is the default 1? *) (* A: No. MB_CLIENT's MB_Unit_ID defaults to 16#FF (255, Siemens *) (* FAQ 102420337). NOTE: it is not a call parameter, it is the *) (* static variable of the instruction. v3.3 wrongly wired it as *) (* a pin (compiler: "invalid formal parameter MB_UNIT_ID"); *) (* the block now assigns astMB[n].MB_Unit_ID before each call. *) (* As of v3.3 each station in DB100 has a uUnitId: usually 1 *) (* for a direct device, or the RTU station number behind a *) (* gateway. The UDT changed, so DB100 must be rebuilt and *) (* downloaded. *) (* *) (* Q: Comms run fine, then die into an 80A1/80A3 loop; *) (* a PLC restart fixes it, but it comes back later? *) (* A: The connection ID is stuck: after an abnormal drop *) (* the CPU keeps a stale entry for that ID, and the *) (* same ID never establishes again - a different one *) (* works at once. Since v3.5 the block bumps the base *) (* ID on 80A1/80A3. Original code of the very first *) (* failure stays in wStatusFirst - your trigger clue. *) (* Q: TCP connects (idle wStatusLive = 16#7004) but every job *) (* dies; wStatus alternates between 16#80A1 and 16#8200 *) (* A: Both are connection-layer codes, not Modbus protocol *) (* errors. 80A1 = this connection cannot carry a frame right now *) (* (still establishing / being torn down / its ID is in use); *) (* 8200 = this connection is already processing another Modbus *) (* request (Siemens wording - another instance holds the same *) (* connection parameters, or the server has not finished the *) (* previous one). Check in this order: 1) is there a SECOND *) (* MB_CLIENT talking to the same device in this project (the old *) (* single-job FB_MBReadLoop, a hand-placed MB_CLIENT_DB)? Cut *) (* its enable first; 2) the device ID - force *) (* astMB[1].MB_Unit_ID to 1 in a watch table, if it starts *) (* working this was it; 3) connect a PC Modbus master tool *) (* directly (IP:502, ID=1, FC03, addr 0, count 100): if the PC *) (* reads, the problem is on the PLC side; if not, on the device *) (* side (service down / wrong register map / quantity too large *) (* for one read). *) (* *) (* Q: bError is on, wStatus = 16#80C5 *) (* A: The peer refused or closed the TCP connection. That is a *) (* TCP-layer matter, not a Modbus protocol error. Three usual *) (* causes: wrong slave IP or port, no Modbus TCP server running *) (* on the peer, or a firewall / subnet in the way. Ping the IP *) (* from a PC and connect to port 502 with a Modbus test tool; *) (* once that works, check the block configuration. This code has *) (* nothing to do with the block logic itself. *) (* *) (* Q: bError is on, wStatus = 16#80C8 / 16#80A4 *) (* A: Receive timeout / no response. Ping the slave IP first, then connect *) (* once with a Modbus test tool and check port 502. The slave IP must be *) (* in the same subnet as the CPU. *) (* *) (* Q: Do reads and writes to the same slave interfere with each other *) (* A: No. Jobs with the same uStaNo share that one TCP connection and the *) (* block runs them serially - only one request is in flight at a time. *) (* Scheduling prefers the current slot, so a slave's reads and writes are *) (* grouped and run back to back without switching connections. *) (* *) (* Q: Only one slave's data stopped refreshing, the others are fine *) (* A: See whether uCurSlot sits on a particular slot, then check that *) (* slave's IP and port. Every slave owns its own connection, so one *) (* unreachable slave does not affect the others. *) (* *) (* Q: Only the first 125 of 600 registers come back *) (* A: Check that uCount really is 600. Whenever uCount exceeds uMaxRegs the *) (* block splits automatically; if uCount says 125 then 125 is all you get. *) (* *) (* Q: Data is shifted / the upper half holds old values *) (* A: Check the uBufOff ranges for overlap. The block does not lay the data *) (* out for you, and when two ranges overlap the later one overwrites the *) (* earlier one. Read and write jobs need separate ranges too. *) (* *) (* Q: wStatus = 16#809B *) (* A: The CONNECT structure (TCON_IP_v4) is wrong. Check that connection *) (* IDs 1..8 do not clash with other communication in the project and *) (* that iInterfaceId really is this CPU's PN interface. *) (* *) (* Q: exactly two "area length error in FB 2" entries, on the *) (* first two scans after RUN, then nothing at all *) (* A: an array access inside a compound condition - SCL does *) (* not short-circuit, so with iSlot still 0 those two scans *) (* read abBusy[0]. How to tell: iStepOut is 0 or 6 on those *) (* two scans (no task yet); from scan 3 on iSlot >= 1 and it *) (* stops. Fix is nested IFs - item 5 in the header, item 6. *) (* Q: dwErrCount climbs absurdly (hundreds of thousands) *) (* A: That was the v3.1-and-earlier defect: the result handler *) (* had no "job in flight" gate, so a connection-level error *) (* incremented the counter every scan. Now one job is settled *) (* exactly once, so dwErrCount equals the real number of failed *) (* frames. Monitor byVer - it must read 16#08. *) (* *) (* Q: Importing complains MB_CLIENT cannot be multi-instance (S7-1200) *) (* A: This block is written for the S7-1500 (8 instances). On an S7-1200 *) (* use the single-job FB_MBReadLoop instead, one instance per slave. *) (* *) (* ------------------------------------------------------------ *) (* 10. Relation to FB_MBReadLoop and the S7-200 SMART MODBUS library *) (* Same lineage: serial polling, failures still advance the cycle, error *) (* codes can be read back, and timers - never scan counts - drive the *) (* interval. *) (* Upgrades: single instance / single data area -> 8 connection slots + *) (* 32 row job table + automatic splitting + reads and writes to one slave *) (* sharing a connection. *) (* Selection: for one or two slaves reading a small block, or for coils, *) (* use FB_MBReadLoop. For many slaves, hundreds of registers, or one slave *) (* needing both reads and writes (especially with precise control over when *) (* a write happens), use this block. *) (* ============================================================ *)