Overview: C# to Siemens S7 Communication over Ethernet
Siemens S7 CPUs (S7-200 SMART, S7-300, S7-400, S7-1200, S7-1500, ET 200S/SP) expose a vendor-proprietary application layer known as the S7 protocol on top of ISO-on-TCP (RFC 1006), COTP (ISO 8073), and TCP/IP. Once a PC is reachable on the same subnet, a .NET application can open up to 8 or 16 concurrent S7 connections per CPU (model dependent) and read/write data blocks, merkers, inputs, outputs, timers, and counters without changing the PLC program. This reference covers the practical mechanics of building such a client in C#: protocol architecture, library selection, TIA Portal configuration, full read/write code for the dominant libraries, error-code interpretation, and field-proven verification steps.
Protocol Stack and Wire Format
The S7 communication path a C# client traverses is:
- Ethernet (IEEE 802.3) – physical and MAC layer.
- IPv4/IPv6 – addressing and routing.
- TCP – reliable transport, destination port 102 on the PLC side (the client side uses an ephemeral port).
- ISO-on-TCP / RFC 1006 – encapsulation of ISO 8073 TPDUs inside TCP. Siemens' implementation is called the S7 protocol transport.
- COTP (ISO 8073) – connection management with Connection Request (CR, TPDU 0x0E) and Connection Confirm (CC, TPDU 0x0D), and Data Transfer (DT, TPDU 0x0F) PDUs.
- S7 Communication (S7Comm) – application layer with UserData and the actual read/write services: 0x04 Read, 0x05 Write, 0x1A Request Download, 0x1D Download Block, 0x1E Upload, 0x1F Pi_Start, 0x00 CPU Stop, 0x28/0x29 Pi-Service (set clock), 0xF0 Group programming/security, 0x00 SZL.
The full S7Comm packet structure used by every C# library looks like:
- TPKT header: 4 bytes (version=0x03, reserved, length 2 bytes big-endian)
- COTP header: 3 bytes minimum (length, PDU type, TPDU number)
- S7 header: 10 bytes (protocol id 0x32, ROSCTR=1/3/7 for Job/Ack/UserData, data length, PDU ref, param length, data length, function code)
- Item address specification: 12 bytes per item specifying memory area, byte offset, bit, transport size, count
Because the S7 header uses a function group/subfunction/sequence model, C# libraries wrap the wire bytes and expose ReadBytes, WriteBytes, Read, Write methods that build the proper TPKT/COTP/S7 frames.
Library Selection Matrix
Four open-source .NET libraries dominate this use case. Selection depends on the .NET target, project lifecycle, and required CPU support.
| Library | License | .NET Targets | CPU Support | Strengths | Caveats |
|---|---|---|---|---|---|
| S7NetPlus (s7netplus) | MIT | .NET Framework 4.6.1, .NET 6/7/8, .NET Standard 2.0/2.1 | S7-200 SMART, S7-300/400, S7-1200/1500, LOGO! 8 | Active fork of original S7.Net, async/await API, multi-PLC manager, Get/Set for typed variables, hot community adoption | No built-in subscription / change-notification polling beyond manual timer |
| Sally7 | MIT | .NET 5+ | S7-300/400, S7-1200/1500 (banner: 1500 partial) | Pure managed code, no native DLL, async-first, IAsyncEnumerable, modern C# | Library is younger; less production hardening than S7NetPlus |
| libnodave (port to .NET via P/Invoke or wrapper) | GPL | All – calls C native libnodave.dll/libnodave.so | S7-200, S7-300/400, S7-1200/1500 | Long history, well documented, raw byte access | GPL obligation, no async, manual socket handling, no LOGO 8 |
| Snap7 (.NET wrapper, e.g. Snap7Sharp) | LGPL | .NET Framework / .NET Core via wrapper | S7-200, S7-300/400, S7-1200/1500, S7-1500 software controller | Very fast, multi-threaded, also supports partner/server mode, fully RFC compliant | Native dependency (snap7.dll) – must match 32/64-bit process; wrapper required for pure C# |
For new projects targeting S7-1200/1500 with TIA Portal V15+ firmware, S7NetPlus is the most-used C# option. Snap7 is preferred for high-throughput S7-300/400 systems (e.g. 100+ tag poll loops). Sally7 fits greenfield .NET 8 microservice deployments.
Prerequisites
Before any C# code runs, four prerequisites must be in place.
-
Network reachability. The PC must be able to open a TCP socket to the CPU's IP address on port 102. Verify with:
Test-NetConnection 192.168.1.111 -Port 102(PowerShell) ornc -vz 192.168.1.111 102(Linux/macOS). A successful reply indicates the CPU's CP is listening. - CPU firmware and project settings. The CPU must have an Ethernet interface (PN/IE) and a configured IP address. S7-1200/1500 require the "Permit access with PUT/GET communication from remote partner" checkbox in TIA Portal under Properties > Protection > Connection mechanisms. Without this, every read/write returns ISO error 0x8104 or S7 error 0x8500.
- PLC-side data blocks exist and are non-optimized for bit-level access. On S7-1200/1500, an "Optimized block access" DB is still readable with PUT/GET as long as the S7 client uses absolute byte offsets. Symbolic access via name is library-specific. To allow classic byte access from any library, leave the DB "non-optimized" (right-click DB → Properties → Attributes → uncheck "Optimized block access").
- CPU connection budget. Per Siemens manual "S7-1500 Communication" entry ID 59192925, S7-1500 CPUs allow up to 16 S7 connections for Put/Get by default (extendable with MMC licenses up to 64 on 1518). S7-1200 CPUs allow 8. S7-300/400 with integrated PN or CP343-1 allow 16. Exceeding this returns ISO error 0x04 (no resources).
Implementing the Client with S7NetPlus
Add the NuGet package to a .NET 6+ console application:
dotnet add package S7netplus
Establish a connection, read 16 bytes from DB1 starting at byte 301, and write them back incremented:
using S7.Net;
using S7.Net.Types;
var plc = new Plc(CpuType.S71200, "192.168.1.111", 0, 1);
plc.Open();
// Read 16 bytes from DB1 starting at byte 301
byte[] buffer = plc.ReadBytes(DataType.DataBlock, 1, 301, 16);
if (buffer != null)
{
Console.WriteLine($"Read {buffer.Length} bytes from DB1.DBB301");
for (int i = 0; i < buffer.Length; i++)
buffer[i] = (byte)(buffer[i] + 1);
plc.WriteBytes(DataType.DataBlock, 1, 301, buffer);
Console.WriteLine("Incremented values written back to DB1.DBB301");
}
// Strongly-typed single-variable access (works for non-optimized DBs)
int counter = (int)plc.Read("DB1.DBD300");
plc.Write("DB1.DBD300", counter + 1);
plc.Close();
Constructor parameters are (CpuType, ip, rack, slot). Rack/slot defaults: S7-300/400 = 0/2, S7-1200/1500 = 0/1, ET 200S PN = 0/1. For S7-200 SMART use CpuType.S7200Smart and rack=0/slot=1.
Implementing the Client with libnodave
libnodave is used through the C# wrapper. Add libnodave.net.dll as a reference and the native libnodave.dll for the correct bitness next to the executable. The C# code mirrors the original question:
using libnodave;
daveOSserialType fds = new daveOSserialType();
fds.rfd = libnodave.openSocket(2000, "192.168.1.111");
fds.wfd = libnodave.openSocket(2001, "192.168.1.111");
if (fds.rfd > 0)
{
Console.WriteLine("here 1");
daveInterface di = new daveInterface(fds, "IF1", 0, 2, daveSpeed187k);
daveConnection dc = new daveConnection(di, 0, 0, 2); // MPI=0, rack=0, slot=2 (S7-300)
int conn = dc.connectPLC();
Console.WriteLine("connectPLC returned " + conn);
int res = dc.readBytes(daveDB, 1, 301, 16, null);
if (res == 0)
{
Console.WriteLine("HERE 3");
// First 4 bytes of dc.akku1 are the payload
for (int i = 0; i < 16; i++)
Console.Write($"DB1.DBB{301 + i} = {dc.akku1[i]} ");
}
else
{
Console.WriteLine("error " + res + " " + daveStrerror(res));
}
dc.disconnectPLC();
di.disconnectAdapter();
}
daveSpeed187k refers to the MPI/PROFIBUS baud. For S7-1200/1500 on Ethernet the speed parameter is irrelevant (TCP is the transport), but libnodave still requires the value. Use daveSpeed187k or daveSpeed1500k; either is accepted by the S7 CPU.Implementing the Client with Snap7
Snap7 in C# uses the Snap7.net wrapper (or direct P/Invoke to snap7.dll):
using Snap7;
var client = new S7Client();
int ok = client.ConnectTo("192.168.1.111", 0, 1, 2); // IP, rack, slot, conn type
if (ok == 0)
{
byte[] buffer = new byte[16];
client.DBRead(1, 301, ref buffer);
for (int i = 0; i < buffer.Length; i++) buffer[i]++;
client.DBWrite(1, 301, buffer);
client.Disconnect();
}
else
{
Console.WriteLine($"Snap7 error {client.LastErrorString()}");
}
Error Code Reference
The original Libnodave call returned -10. In libnodave res values are negative for local communication errors and positive ISO-on-TCP / S7 error codes. The mapping relevant to the question is:
| libnodave return | Meaning | Common cause | Fix |
|---|---|---|---|
-10 (resInvalidArgument) |
Function called with invalid argument |
readBytes was called with null target buffer on a build that needs a pre-allocated array, OR rfd/wfd were both opened to the same socket (you opened the same port twice) |
Use one socket and pass the array, or use openSocket with a single port and let libnodave multiplex |
-1 (resCannotOpenSocket) |
TCP connect failed | Wrong IP, firewall, PLC not listening on 102 | Test port 102 with Test-NetConnection; disable Windows Firewall for private profile |
| -2 | ISO connect failed | PLC is reachable but refuses COTP CR | Check CPU protection level, TIA "Permit access with Put/Get" |
| 0x8104 | CPU returns "No resources" | All 8/16 S7 connections in use | Reduce concurrent clients or upgrade to S7-1500 with more S7 connection resources |
| 0x8500 | Wrong PDU size or unsupported function | Library sent 960 byte PDU to a CPU that negotiated 240 | Force PDU size to 240 with SetConnectionType(2) (S7-200) or use S7NetPlus auto-negotiation |
| 0x8501 | Object does not exist | DB number out of range, or DB not downloaded | Verify DB number exists in the offline/online project |
| 0x8502 | Out of memory / invalid address | Byte offset + length past DB size, or wrong transport size | Re-check DB size and offset; ensure count * transportSize ≤ 480 bytes per S7 request |
| 0x8503 / 0x8504 | Write/read area not allowed | Attempted to write to a non-optimized DB whose "Accessible from HMI/OPC UA" is off | In DB properties enable HMI access |
| 0x8101 | Hardware fault | CPU in STOP or DB inconsistent | Check CPU diagnostic buffer with TIA online |
Full libnodave error string table is in daveStrerror() of the source. For ISO transport codes see the Wireshark dissector "S7 Communication" → SZL list. For S7NetPlus the equivalent is plc.LastErrorCode and plc.LastErrorString members of the Plc object.
TIA Portal Configuration on S7-1200/1500
On modern S7-1500 CPUs the default project is fully protected. Open the project offline, navigate to Device configuration > Properties > Protection > Connection mechanisms, and check the box Permit access with PUT/GET communication from remote partner (PLC, HMI, OPC, …). Compile and download to the CPU. Without this, the response is the S7 error 0x8500 even though the IP route is fine.
For an S7-1200 (firmware V4.0+), the same property is under Properties > General > Protection > Connection mechanisms. For an S7-300/400 the Put/Get service is always allowed; password protection is enforced only for write operations through the S7 firewall in NetPro.
If the project uses an "optimized" DB (default for new S7-1500 DBs), the S7 client can still read/write bytes by absolute offset. However, the offset visible to the client is the offset in the optimized data record, which is not necessarily the same as the symbol compile order. Use the TIA "monitor all" view with the data block open to confirm the byte offset of each variable before issuing a ReadBytes(DB, 1, 301, 16) call.
Port and Firewall Rules
Put/Get listens on TCP 102 of the CPU's PROFINET interface. If the PC sits in a Windows domain with centralized firewall, add an inbound rule on the Domain and Private profiles for TCP 102 source Any, destination PLC IP. On Linux use iptables -A OUTPUT -p tcp --dport 102 -d 192.168.1.111 -j ACCEPT. Routed networks require the gateway to be configured with PG routing or a VPN bridge; standard NAT hides the S7 connection.
Reading the Same Tag from Multiple Threads
The Plc object in S7NetPlus is not thread-safe. Wrap each Read/Write call in lock (plc) { ... }, or create one Plc instance per worker thread (each instance uses its own S7 connection and counts against the CPU's connection budget).
Asynchronous Polling Pattern with S7NetPlus
using S7.Net;
var plc = new Plc(CpuType.S71500, "192.168.1.111", 0, 1);
await plc.OpenAsync();
var cts = new CancellationTokenSource();
_ = Task.Run(async () =>
{
while (!cts.Token.IsCancellationRequested)
{
try
{
int wordValue = (short)await plc.ReadAsync("DB10.DBW0");
float realValue = (float)await plc.ReadAsync("DB10.DBD2");
Console.WriteLine($"tick {DateTime.Now:HH:mm:ss.fff} " +
$"WORD={wordValue} REAL={realValue:F2}");
}
catch (Exception ex)
{
Console.WriteLine($"poll error: {ex.Message}");
}
await Task.Delay(250, cts.Token);
}
});
Console.ReadKey();
cts.Cancel();
plc.Close();
Reading and Writing Bit-Level Tags
Bit access uses a bool in S7NetPlus:
bool motorRun = (bool)plc.Read("DB20.DBX4.0"); // bit 0 of byte 4
plc.Write("DB20.DBX4.0", !motorRun); // toggle
For libnodave, read the full byte then mask: (dc.akku1[4] & 0x01) == 0x01. Writing requires dc.writeBits(daveDB, 1, 4*8+0, 1, new byte[]{(byte)(motorRun ? 1 : 0)});.
Connection Count and PDU Size Negotiation
After the S7 client opens the ISO connection, the CPU replies with the maximum PDU size it supports (S7-1500: 960 bytes, S7-1200: 240 bytes, S7-300/400: 240/480 bytes). All requests must split large reads into chunks of PDU_length - 18 bytes or smaller. S7NetPlus handles this transparently. libnodave returns -5 (resTooManyPDUAsked) if the application asks for more than negotiated. Snap7 has client.SetMaxPduLength() to enforce the negotiated value.
Security: Access Protection and Passwords
S7-1500 firmware V2.6+ supports a four-level access protection: No access (full protection), HMI access, Read access, Full access. The C# client must use the Full access password when writing to a protected CPU. With S7NetPlus, pass the password as a fourth constructor argument: new Plc(CpuType.S71500, ip, 0, 1, "password123"). libnodave uses daveSetPassword(dc, password) after connectPLC(). Note: with V2.0+ firmware, an S7-1500 also blocks "Read access" on Put/Get unless the partner is in the connection list — add the PC's IP to the Accessible nodes of the CPU.
Performance Notes
- Single S7 request latency on a 100 Mbit local network: 5–15 ms.
- Optimal item grouping: pack up to 20 items per request; group contiguous DB areas.
- For 1 ms cycle on 10,000 tags, use S7NetPlus async with batching; libnodave is sync and bottlenecks easily above 500 tags per second.
- Snap7 client with
AsReadRequestand 4 parallel connections on an S7-1516 reaches 12,000 tags/second.
Verifying the Client
-
Wireshark sanity check. Capture on the PC's NIC, filter
ip.addr==192.168.1.111 && tcp.port==102. You should see TPKT/COTP handshake (CR → CC), then S7 Job/Ack PDUs. If only the TCP SYN/SYN-ACK/FIN appears and no TPKT, the application is sending the wrong port. - Online test in TIA. Open the online view of the same DB; change a byte. The C# poll loop should reflect the new value within one cycle.
- Diagnostic buffer. With the project still online, open the CPU's diagnostic buffer. Each successful read does not write an entry; rejected reads produce an entry with S7Comm error code matching the table above.
-
Round-trip count. When polling 16 bytes at 100 ms, expect a steady ~10 PDUs/s. A sudden drop to 0 with
LastErrorCode=0x8104indicates the connection budget is exhausted.
Troubleshooting Matrix
| Symptom | Likely Cause | Resolution |
|---|---|---|
connectPLC() returns -1 |
TCP 102 not reachable | Ping PLC, check firewall, verify PROFINET port LEDs |
connectPLC() returns -2 |
CPU rejects COTP | Enable Put/Get in TIA, add PC to accessible nodes |
| libnodave res=-10 | Invalid argument or duplicate socket | Open single socket, allocate a real byte[], pass it to readBytes
|
| S7NetPlus returns 0x8500 | Protection level mismatch | Reduce protection to "Full access" or supply correct password |
| Reads return 0xFF on every byte | Wrong DB number or DB not present in target CPU | Cross-check DB number with TIA project online view |
| Some tags read, others return 0x8502 | Optimized DB with shifted offsets | Use TIA "monitor all" to capture the real byte address of the symbol |
| Connection drops every ~30 s | TCP keepalive off, router drops idle sessions | Send a no-op Read every 5 s or enable keepalive on the socket |
| Time-out after first request on S7-200 SMART | Default 960 byte PDU request on 240 byte CPU | Force PDU size 240 in the library |
Frequently Asked Questions
What TCP port does Siemens S7 use for Put/Get communication?
TCP port 102 on the S7 CPU's PROFINET/Industrial Ethernet interface. The C# client uses an ephemeral local port, but the destination must always be 102. Verify with Test-NetConnection <PLC_IP> -Port 102.
Why does my S7NetPlus read return S7 error 0x8500 even though port 102 is reachable?
CPU access protection is blocking Put/Get. In TIA Portal, open the CPU's device configuration, go to Properties > Protection > Connection mechanisms and enable Permit access with PUT/GET communication from remote partner. Recompile and download the project.
Can I read optimized data blocks on an S7-1500 from C#?
Yes. Optimized DBs are still readable by absolute byte offset using Put/Get. The library cannot use the symbolic name across the wire; you must know the byte offset of each variable. To inspect the offset, open the DB in TIA with Monitor all active and read the address shown next to each tag.
How many simultaneous S7 connections does an S7-1500 accept?
Standard S7-1500 CPUs allow 16 S7 connections for Put/Get. Some CPUs (e.g. CPU 1518) accept up to 64 with a separate connection-resource MMC license. S7-1200 allows 8; S7-300/400 with integrated PN or CP 343-1 allow 16. Exceeding the limit returns ISO error 0x8104.
What does the libnodave return value -10 mean?
It is resInvalidArgument — the function was called with a parameter the C wrapper rejected. In the typical case shown, the C# code opened the socket twice (ports 2000 and 2001) and called readBytes(... , null). Pass a pre-allocated byte[16] to readBytes and let libnodave use a single socket, or use the .NET library S7NetPlus instead, which manages the socket internally.