EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Hardware / TCP Reaches State 4 but SentBytes Stays at 0 on Two PLCSIM Instances
Hardware

TCP Reaches State 4 but SentBytes Stays at 0 on Two PLCSIM Instances

by Ton-Or ·7 Nov 2026·11 min read
TCP Reaches State 4 but SentBytes Stays at 0 on Two PLCSIM Instances

Saying a tool cannot do something requires both a manual page and measurements that isolate one variable at a time. Trying once and failing is not a measurement. It is a guess with symptoms attached.

1-minute summary

Symptom / problem
Two PLCSIM instances reach TCP State 4 but SentBytes stays at 0.
Who should read
Anyone testing S7-1500 TSEND_C and TRCV_C links on PLCSIM.
Fix in short
Add a diagnostic OB that reads STATUS and T_DIAG on both sides. Every failing connection went through the second PROFINET interface, so avoid that path.
Jump to steps

I needed to test a link between two PLCs in a simulator before delivery. I opened two S7-PLCSIM instances and had them communicate through TSEND_C and TRCV_C, but no data went through. I reported to the project owner that this simulator version could not establish TCP connections between instances, which was wrong. After reading the manual, I changed the wording to say that the manual said it was supported but the cause was unknown, which was still wrong, because I had measured nothing either time. This article describes the measurements that produced the actual answer: the handshake succeeds and the status shows connected, but data does not flow through the CPU's second PROFINET interface.

Use a test copy only; these results apply to the simulator

All diagnostic OBs and connection changes in this article were made in a test copy of the project used with the simulator, not in the delivery files or on a PLC controlling machinery. The results describe the behavior of the simulator version I used and establish nothing about physical PLCs. The actual link between PLCs that drive machine outputs must be checked during commissioning by the controls engineer responsible for that machine.

Two Wrong Reports Before I Finally Measured Anything

Here is what actually happened. First, I tested the logic one PLC at a time on the Compact edition of S7-PLCSIM V15.1 bundled with TIA Portal, not PLCSIM Advanced. Everything passed. Then I opened two instances to let the Main PLC and Station PLC actually communicate through the communication blocks, and it did not work. So I wrote in the report that PLCSIM V15.1 could not establish TCP connections between instances. That conclusion came from a single attempt.

On the second round, I went back to pages 143 through 146 of the S7-PLCSIM V15 manual, which explicitly state that TSEND_C and TRCV_C between two S7-1500 instances are supported, provided that the IP addresses are unique, the connections are configured in the program, and each receive operation is limited to 1024 bytes. So I changed the report to say, "The manual says it is supported; the cause of the failure is unknown."

Round 1

The simulator cannot do it

I tried once, it failed, and I blamed the tool. I had not opened the manual or read the blocks' STATUS values. I did not even know whether the failure was in establishing the connection or sending data. This conclusion contradicted the manufacturer's own manual.

Round 2

The manual says it can; the cause is unknown

It sounds more careful, but underneath it was still the same guesswork. I had merely changed from guessing that it was impossible to admitting that I did not know, even though the information needed to answer the question was already in the communication blocks' instance DBs. I just had not read it.

The project owner read this and told me to investigate and measure until I had the facts, which was the right instruction. On the third round, I stopped writing conclusions and built the instrumentation first.

Adding Diagnostic OBs on Both Sides

The TSEND_C and TRCV_C blocks already store everything I wanted to know in their instances: the latest STATUS and the results of T_DIAG, which the blocks call internally, including the connection state, the number of bytes sent, and the number of bytes received. The problem is that these values change quickly, and an error STATUS lasts only one scan, too briefly to catch by watching a watch table. So I added a test OB to the test copy on each side to copy values from the actual instance into a single summary DB and count errors as well.

// OB วินิจฉัย (เฉพาะสำเนาทดสอบ) - คัดลอกค่าจาก instance ที่ network ใช้จริง
TestDB.Send_STATUS   := Inst_Send.STATUS;
TestDB.Send_State    := Inst_Send.s_TDIAG_Status.State;
TestDB.Send_Sent     := Inst_Send.s_TDIAG_Status.SentBytes;
TestDB.Recv_STATUS   := Inst_Recv.STATUS;
TestDB.Recv_State    := Inst_Recv.s_TDIAG_Status.State;
TestDB.Recv_Received := Inst_Recv.s_TDIAG_Status.ReceivedBytes;

IF Inst_Send.ERROR THEN
    TestDB.ErrCount := TestDB.ErrCount + 1;
    TestDB.LastErr  := Inst_Send.STATUS;      // เก็บ error ที่อยู่แค่ scan เดียว
END_IF;

There are three sets of numbers to read together. State tells me which stage the connection has reached; 4 means connected. SentBytes and ReceivedBytes tell me whether any data has actually passed through. If State is 4 but the byte counters do not move, the problem is not in establishing the connection.

Instrumentation alone was not enough; I also needed a control case. My concern was that the simulator might report State 4 for every connection without actually connecting to anything. So I added another control link in the test copy, pointing to the IP address of a partner that did not exist on the network. If that link also reached State 4, State could not be trusted. If it reported an error, State 4 on the other links meant a real handshake had occurred.

State 4Connection established
0 byteSentBytes on the link from X2
16#80C5STATUS for the nonexistent partner link
16#7000STATUS when reading the wrong instance

Extracting the Connection Table from the Project

Before reading the measurements, I needed to know how each link was configured. Connection parameters live in connection DBs, which TIA Portal stores in system block groups rather than alongside the regular blocks. Traversing only BlockGroup.Blocks will not find them; I had to traverse SystemBlockGroups under PlcSoftware.BlockGroup. I exported them all as XML using Openness from Windows PowerShell 5.1.

# รันใน Windows PowerShell 5.1 บนเครื่องที่เปิด TIA Portal V15.1 กับสำเนาทดสอบอยู่
$dll = 'C:\Program Files\Siemens\Automation\Portal V15_1\PublicAPI\V15.1\Siemens.Engineering.dll'
[Reflection.Assembly]::LoadFrom($dll) | Out-Null
$tia = [Siemens.Engineering.TiaPortal]::GetProcesses()[0].Attach()
$prj = $tia.Projects[0]
$out = 'C:\Projects\conn_export'
New-Item -ItemType Directory -Force -Path $out | Out-Null

function Dump($group) {
    foreach ($b in $group.Blocks) {
        $f = Join-Path $out (($b.Name -replace '[\\/:*?"<>|]', '_') + '.xml')
        if (Test-Path $f) { Remove-Item $f -Force }
        $b.Export([IO.FileInfo]$f, [Siemens.Engineering.ExportOptions]::WithDefaults)
    }
    foreach ($g in $group.Groups) { Dump $g }
}

foreach ($dev in $prj.Devices) {
    foreach ($item in $dev.DeviceItems) {
        $type = [Siemens.Engineering.HW.Features.SoftwareContainer]
        $sc = $item.GetType().GetMethod('GetService').MakeGenericMethod($type).Invoke($item, $null)
        if ($sc -and $sc.Software -is [Siemens.Engineering.SW.PlcSoftware]) {
            foreach ($sys in $sc.Software.BlockGroup.SystemBlockGroups) { Dump $sys }
        }
    }
}

PowerShell cannot call a generic method directly, so I had to go through MakeGenericMethod as shown in the code. Once I had the XML, I used Python to extract the start values of the connection-related members and arrange them in a table.

import glob, os
import xml.etree.ElementTree as ET

KEYS = ('InterfaceId', 'ID', 'ConnectionType', 'ActiveEstablished',
        'ADDR', 'RemotePort', 'LocalPort')

def local(tag):
    return tag.split('}')[-1]

def start_values(node, prefix=''):
    for child in node:
        kind = local(child.tag)
        if kind == 'Member':
            name = prefix + child.get('Name', '')
            for sv in child:
                if local(sv.tag) == 'StartValue' and sv.text:
                    yield name, sv.text.strip()
            yield from start_values(child, name + '.')
        elif kind == 'Subelement':                      # สมาชิกของ array
            name = prefix.rstrip('.') + '[' + child.get('Path', '') + ']'
            for sv in child:
                if local(sv.tag) == 'StartValue' and sv.text:
                    yield name, sv.text.strip()
        else:
            yield from start_values(child, prefix)

for path in sorted(glob.glob(r'C:\Projects\conn_export\*.xml')):
    rows = [(n, v) for n, v in start_values(ET.parse(path).getroot())
            if any(k in n for k in KEYS)]
    if rows:
        print(os.path.basename(path))
        for name, value in rows:
            print(f'   {name:<28} {value}')

The resulting table looked like this. I replaced the IP addresses and ports with example values, but the structure and InterfaceId values are as actually read.

Link Side InterfaceId Role Destination local port
Station -> Main Station sends 64 active 192.168.10.1:2000 -
Station -> Main Main receives 72 passive - 2000
Main -> Station Main sends 72 active 192.168.10.11:2001 -
Main -> Station Station receives 64 passive - 2001
Control link Sending side Depends on the side where added active IP with no device -

The most important column is InterfaceId. The Main project used 72 for every link, while the Station project used 64. On the CPU with two PROFINET ports, 64 is the first interface, or X1, and 72 is the second, or X2. At the time, I did not yet know this mattered; I simply noted that the two sides differed.

Measurements: Every Failing Link Passed Through X2

I loaded the test copies into both instances, let them run, and read the summary DBs. These were the results.

Measured link Reading Meaning
Station TSEND_C (active from X1) Connected; SentBytes keeps increasing The X1 side can send
Main TRCV_C (receiving on X2) ReceivedBytes = 0 Data does not reach the receive block
Main TSEND_C (active from X2) State = 4 but SentBytes stays at 0 Connected but unable to send
Control link to a nonexistent partner STATUS 16#80C5 The other links' handshakes actually occurred
Two PLCSIM instances: where data flows and stallsStation PLCX1InterfaceId 64X2No linkMain PLCX2InterfaceId 72X1No linkX1 SentBytes rises; X2 receives 0State 4 but SentBytes = 0Control linkIP with no deviceSTATUS 16#80C5Real handshake, but no data through X280C5 confirms State 4 is not a false reading
Every problematic link starts or ends at the Main PLC's X2. On the link from the Station's X1, the SentBytes counter moves. The control link pointing to an IP with no device reports 16#80C5, showing that State 4 is the result of a real handshake.

The last row is what makes the other rows interpretable. The link pointing to an IP with no device reported 16#80C5 instead of reaching State 4. That means the simulator was not arbitrarily returning connected for every link. State 4 on the Main link from X2 was therefore real. A handshake had actually occurred, but not a single byte of data passed afterward.

Once I overlaid the measurements on the connection table, the pattern was clear. Every problematic link started or ended at the Main CPU's second interface. Links leaving X2 could not send; links entering X2 could not receive. The side on X1 worked normally, with its byte counter moving. The conclusion I wrote in the third report was that this version of PLCSIM V15.1 exposes the X2 IP address and accepts TCP handshakes, but does not transfer T block data through the second interface. Links on X1 work.

This conclusion differed from the first two because it defined the limits. It was not "TCP does not work" or "the cause is unknown," but "links through X2 do not work; links on X1 do," which could immediately support a decision. I have written about signals that appear to indicate success without proving that something works in web development in my article on false signals from a sandbox. This time, it was the same issue in the world of PLC simulators.

A Measurement Trap: Reading the Wrong Instance Returns 16#7000

While building the instrumentation, I made one mistake that would have become a third wrong conclusion if I had not caught it. This project mixed two types of communication block instances. The first was a single-instance DB, a separate DB for each block. The second was a multi-instance, where the instance is a member in another block's DB. Some names existed in both forms in the project at the same time, while the network actually used only one of them.

My first diagnostic OB read the instance that the network did not use. The result was STATUS 16#7000, meaning the block had no job to do. Had I trusted that value, I would have diagnosed the program as never requesting a send, which would have been entirely wrong. To prevent this, I opened the XML of the block calling TSEND_C or TRCV_C and checked which instance name that Part referenced.

import sys
import xml.etree.ElementTree as ET

def local(tag):
    return tag.split('}')[-1]

root = ET.parse(sys.argv[1]).getroot()          # XML ของ FC/FB ที่เรียกบล็อกสื่อสาร
for part in root.iter():
    if local(part.tag) != 'Part' or part.get('Name') not in ('TSEND_C', 'TRCV_C'):
        continue
    for inst in part:
        if local(inst.tag) != 'Instance':
            continue
        names = [c.get('Name') for c in inst if local(c.tag) == 'Component']
        print(part.get('Name'), inst.get('Scope'), '.'.join(names))

If the output is a single name, it is a single-instance DB. If it is a DB name followed by a member name, it is a multi-instance. The diagnostic OB must read only from the name printed by this script.

Another thing I had to rule out before drawing a conclusion was whether the program had actually requested a send. The send blocks in this project were triggered by bits in the clock memory byte. If clock memory was not enabled in the test copy's hardware config, the blocks would never be triggered, and the visible symptoms would look exactly like SentBytes stuck at 0. So I added a few more lines to the same OB to count edges on the 1 Hz and 5 Hz bits and count scans.

// ยืนยันว่า clock memory เดินจริง และ OB นี้ถูกเรียกจริง
TestDB.ScanCount := TestDB.ScanCount + 1;

IF "Clock_1Hz" AND NOT TestDB.Last1Hz THEN
    TestDB.Edge1Hz := TestDB.Edge1Hz + 1;
END_IF;
TestDB.Last1Hz := "Clock_1Hz";

IF "Clock_5Hz" AND NOT TestDB.Last5Hz THEN
    TestDB.Edge5Hz := TestDB.Edge5Hz + 1;
END_IF;
TestDB.Last5Hz := "Clock_5Hz";

All three counters must advance. If the edge counters do not move, the problem is with the trigger, not the network. That must be fixed first before returning to State and the byte counters.

  1. Find the instance name actually used by the network in the block's XML; do not guess from DB names.
  2. Confirm that the send trigger is running by counting clock memory edges.
  3. Read STATUS, State, SentBytes, and ReceivedBytes on both sides at the same time.
  4. Add a control link to a nonexistent partner to see what a real error looks like.
  5. Extract the connection table and compare it with the measurements to find the variable shared by the failing links.
  6. Write the conclusion in terms of limits: what works, what does not, and under which conditions.

Practical Options and Other PLCSIM Symptoms

Once the limits are known, there are two options. First, if the project's links really use X2 and must stay that way, accept that they cannot be tested with two simulator instances and test them on physical PLCs. Second, in the test copy only, move the connection DBs on both sides to X1 on the same subnet to test the data exchange logic, knowing that this does not test the actual network path. Whichever option is chosen, the delivery documentation must include a section listing untested items, and it must state there that the actual link between the PLCs needs to be checked during commissioning.

Keep interface changes in the test copy out of the delivery files

Moving a connection from X2 to X1 changes the machine's network path. If that change slips into the files delivered to the site, the PLCs will try to communicate through ports that have no cabling. Test and delivery copies must be kept in separate folders, and the delivery copy must be created fresh from the original every time, not made by removing test additions from the test copy.

While opening and closing instances dozens of times, I encountered some PLCSIM symptoms worth knowing about so they do not get confused with link problems.

Symptom Cause Fix
Any project can be loaded The empty instance still holds the default IP 192.168.0.1 Normal; no action needed
Connect to module failed The instance holds another project's IP Close that instance and open a new one
State 4 but byte counters are 0 The link passes through the second interface Move to X1 in the test copy, or test on physical PLCs
STATUS stays at 16#7000 Reading an instance the network does not use Find the instance name in the block's XML
Order for ruling out causes, from local to remoteCorrect instanceCheck XML->Trigger runningCount clock edges->State4 = connected->Byte countersMoving or not->InterfaceId64 or 72

The Lesson: Claims About Tool Limits Need Two Layers of Evidence

I was wrong the first two times for the same reason: I wrote a conclusion before I had measurements. The first time, I blamed the tool. The second time, I cited the manual and stopped there. Neither gave the project owner enough information to make a decision, because neither established what could and could not be tested.

The rule I have set for myself since this job is that before writing that a tool cannot do something, I need two things. The first is a manual page stating what the tool does or does not support. The second is measurements that isolate one variable at a time, with a control case whose expected outcome is known for comparison. If I have only one of those, I should write that I do not yet know and state what I will measure next.

I also need to state the limits of these results. I measured only the Compact edition of PLCSIM V15.1, with a CPU model that has two PROFINET ports. I did not test PLCSIM Advanced or other TIA Portal versions. Anyone using another version should take their own measurements using the same method before trusting this result. The work this link serves—the watchdog for the link between PLCs—is covered separately in my article on the gap in the first 3 seconds after startup. If measurements on other versions differ from what I have written, I will come back and update it.

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first