Problem Overview
Calling s7_get_multiple_read_cnf from the Siemens SAPI-S7 programming interface (s7std.dll / s732std.dll) inside a managed .NET application produces two distinct failure modes depending on how the caller provisions the length and value buffers:
-
Single variable reads succeed. The
S7_MULTIPLE_READ_CNFmessage returned bys7_receivedecodes correctly ands7_get_multiple_read_cnfreturnsS7_OK. -
Reads of two or more variables throw
System.NullReferenceExceptionwhenval_length_ptris provided. -
Reads of two or more variables return API error 80 ("user data buffer too small") when
val_length_ptris omitted.
The dual symptom pattern is a classic P/Invoke marshalling defect: the managed wrapper builds three pinned buffers of uniform size and pre-fills them with array metadata instead of the actual variable-length read payload. The SAPI-S7 C interface expects a heterogeneous payload buffer whose total byte count is the sum of the per-variable lengths returned in the read confirm event. The mismatch manifests as either an unmarshalled pointer dereference (CLR NullReferenceException on the unmanaged caller) or as native error 80 once the C function probes the destination buffer size.
Environment and Prerequisites
| Component | Specification |
|---|---|
| Siemens SAPI-S7 DLL |
s7std.dll or s732std.dll (32-bit Windows) |
| Header definitions |
SAPI_S7.H, SAPI_S7.HPP (shipped with the SAPI-S7 installation) |
| Operating system | Windows 7 / 10 / 11 (32-bit or WOW64), Windows XP legacy supported per original release notes |
| CP / Network | CP5611, CP5613, CP5614, CP5621 PROFIBUS / MPI (per the S7 Programming Interface manual) |
| Target PLC | SIMATIC S7-300 / S7-400 (example case: S7-315-2DP), valid for S7-200 via DP where supported |
| .NET runtime | .NET Framework 4.x or .NET 6/8 with UseWindowsForms or EnableDynamicLoading for P/Invoke |
| Siemens support entry | SAPI-S7 Programming and Reference Guide |
Root Cause Analysis
The native signature of s7_get_multiple_read_cnf per the S7 Programming Interface manual is:
/* Excerpt — see SAPI_S7.H for authoritative prototype */
INT32 s7_get_multiple_read_cnf(
void *od_ptr, /* application descriptor, NULL allowed */
INT16 *msg_array_pointer, /* result code per requested variable */
UINT16 *val_length_ptr, /* actual length of each variable (bytes/bits per type) */
void *val_value_ptr /* contiguous buffer holding all variable values */
);
Three issues in the original C# wrapper are responsible for the observed failure modes:
-
Uniform sizing of the value buffer. The wrapper allocates
UInt16[32](64 bytes) for the value buffer regardless of how many variables were requested and what their combined payload size is. The native call walks the variable list and offsets into the destination buffer by the running length; when the sum of requested lengths exceeds the allocated 64 bytes, the native code returns error 80. -
Length array pre-initialised to zero. Pre-writing zeros into
val_length_ptrviaMarshal.WriteInt16corrupts the input/output contract.val_length_ptris an output array: the API fills it with the actual length of each returned variable. Pre-seeding it forces the wrapper to over-allocate and produces an internal null-deref path when the caller later attempts to read variablenbut the offset calculation collapses against the pre-zeroed metadata. -
Missing length-to-type translation. Each S7 datatype has a fixed byte size (BIT=1, BYTE=1, WORD=2, DWORD=4, INT=2, DINT=4, REAL=4). The wrapper treats every variable as 2 bytes.
val_value_ptrmust therefore be sized assum(sizeof(type_i) for i in 1..N), notN * 2.
Buffer and Length Calculation
Before invoking s7_get_multiple_read_cnf, the caller must compute three quantities from the original s7_multiple_read_req request:
| Quantity | Symbol | Formula |
|---|---|---|
| Number of variables | N |
Count of entries in var_addr_array passed to s7_multiple_read_req
|
| Per-variable byte size | s_i |
Byte width of the requested S7 datatype (BIT=1 bit packed in WORD, BYTE=1, WORD=2, INT=2, DWORD=4, DINT=4, REAL=4, CHAR=1, STRING=n) |
| Total value buffer bytes | B |
B = SUM(s_i for i = 1..N) |
| Result array entries | R |
R = N (UINT16 per variable) |
| Length array entries | L |
L = N (UINT16 per variable) |
The pinned buffers must be allocated as:
// Pseudocode for allocation
int bytesResult = N * sizeof(UInt16);
int bytesLength = N * sizeof(UInt16);
int bytesValue = B; // sum of per-variable byte widths
IntPtr pResult = Marshal.AllocHGlobal(bytesResult);
IntPtr pLength = Marshal.AllocHGlobal(bytesLength);
IntPtr pValue = Marshal.AllocHGlobal(bytesValue);
// pResult and pLength are OUTPUT only - do NOT pre-fill.
// pValue is OUTPUT only - the API writes the variable values into it.
Corrected P/Invoke Marshalling
Use [Out] attributes on output-only pointer parameters and never seed them before the call. The corrected declaration set:
using System;
using System.Runtime.InteropServices;
internal static class SapiS7
{
private const string SAPI_DLL = "s732std.dll"; // or "s7std.dll" depending on installation
[DllImport(SAPI_DLL, CallingConvention = CallingConvention.StdCall,
CharSet = CharSet.Ansi, ExactSpelling = true)]
public static extern Int32 s7_init(
byte[] cp_name, byte[] vfd_name, ref UInt32 cp_descr);
[DllImport(SAPI_DLL, CallingConvention = CallingConvention.StdCall,
CharSet = CharSet.Ansi, ExactSpelling = true)]
public static extern Int32 s7_multiple_read_req(
UInt32 cp_descr, UInt16 cref,
UInt32 var_count,
[MarshalAs(UnmanagedType.LPArray)] UInt16[] var_addr_array,
IntPtr od_ptr);
[DllImport(SAPI_DLL, CallingConvention = CallingConvention.StdCall,
CharSet = CharSet.Ansi, ExactSpelling = true)]
public static extern Int32 s7_receive(
UInt32 cp_descr,
[Out] [MarshalAs(UnmanagedType.LPArray, SizeConst = 256)] byte[] msg_buffer,
UInt32 msg_len,
ref UInt32 msg_read,
ref UInt16 cref);
[DllImport(SAPI_DLL, CallingConvention = CallingConvention.StdCall,
CharSet = CharSet.Ansi, ExactSpelling = true)]
public static extern Int32 s7_get_multiple_read_cnf(
IntPtr od_ptr,
IntPtr msg_array_pointer,
IntPtr val_length_ptr,
IntPtr val_value_ptr);
public const Int32 S7_OK = 0;
public const UInt16 S7_MULTIPLE_READ_CNF = 0x0002; // verify against SAPI_S7.H
}
The [Out] attribute on output arrays (or simply leaving unmanaged memory uninitialised) prevents the CLR from pre-filling pinned arrays and removes the NullReferenceException path triggered by stale zero values.
Working Reference Implementation
public static int MyMultiReadCnf(uint cpDescr, int varCount, int totalPayloadBytes)
{
IntPtr pResult = Marshal.AllocHGlobal(varCount * sizeof(ushort));
IntPtr pLength = Marshal.AllocHGlobal(varCount * sizeof(ushort));
IntPtr pValue = Marshal.AllocHGlobal(totalPayloadBytes);
try
{
// Do NOT pre-fill pResult, pLength, or pValue. They are OUTPUT only.
Int32 ret = s7_get_multiple_read_cnf(
new IntPtr(0), pResult, pLength, pValue);
if (ret != S7_OK)
{
string detMsg = s7_last_detailed_err_msg();
ushort detNo = s7_last_detailed_err_no();
ushort iecNo = s7_last_iec_err_no();
string iecMsg = s7_last_iec_err_msg();
LogError("s7_get_multiple_read_cnf", detNo, detMsg, iecNo, iecMsg);
return -1;
}
// Iterate results
for (int i = 0; i < varCount; i++)
{
ushort resultCode = (ushort)Marshal.ReadInt16(pResult, i * 2);
ushort length = (ushort)Marshal.ReadInt16(pLength, i * 2);
int offset = CumulativeOffset(i); // see table above
byte[] slice = new byte[length];
Marshal.Copy(pValue, slice, offset, length);
ProcessVariable(i, resultCode, length, slice);
}
return 0;
}
finally
{
Marshal.FreeHGlobal(pResult);
Marshal.FreeHGlobal(pLength);
Marshal.FreeHGlobal(pValue);
}
}
The CumulativeOffset(i) helper must return the byte offset of variable i within the contiguous value buffer. It is not simply i * 2; it is the running sum of per-type widths determined at request-build time.
Error 80 Reference Table
| API error | Likely meaning | Corrective action |
|---|---|---|
| 80 | User data buffer too small for combined variable payload | Recompute B = SUM(s_i) and resize val_value_ptr
|
| 82 | Invalid connection reference (cref) |
Re-acquire cref via s7_get_cref
|
| 87 | CP not initialised or wrong CP name | Verify cp_name string matches installed CP (CP5611, CP5621, etc.) |
| 93 | Timeout on PLC response | Increase receive timeout; verify PROFIBUS bus parameters |
| 0xFF (255) | Generic CP fault | Check s7_last_detailed_err_no for sub-code |
s7_last_detailed_err_no and s7_last_iec_err_no. The IEC error code maps to standard IEC 61131-3 diagnostic primitives and is more stable across firmware revisions than the SAPI internal code.Alternative: C++ Wrapper DLL
If P/Invoke proves brittle or performance-critical, build a thin C++ DLL that exposes a stable C-style ABI to .NET and forwards calls to s7std.dll. This pattern is documented as a workaround in the SAPI-S7 support thread and is endorsed by Siemens application engineers when managed-language interop is required.
// MySapiBridge.h
#pragma once
#define SAPI_DLL_IMPORT extern "C" __declspec(dllexport)
SAPI_DLL_IMPORT int BridgeMultiRead(
unsigned long cp_descr,
unsigned short cref,
unsigned short var_count,
unsigned short* var_addrs,
unsigned char* result_code_out,
unsigned short* length_out,
unsigned char* value_out,
unsigned int value_buf_size);
The C++ wrapper computes buffer offsets from var_addrs (which carry datatype and DB/M/I/Q/E/A/PE/PA origin) and translates them into per-type byte widths. The C# side then calls a single P/Invoke entry point with pre-sized byte[] arrays managed entirely on the .NET side.
Alternative: OPC DA / OPC UA from C#
Siemens explicitly recommends OPC for managed .NET integrations. Supported paths:
- OPC DA via SIMATIC NET OPC server and a .NET OPCDA wrapper such as Advosol OPCDA.NET.
- OPC UA via the .NET Standard OPC UA SDK from the OPC Foundation or commercial stacks (Siemens OPC UA Scout for configuration).
- S7Comm Plus via third-party libraries (Snap7 plus is an open-source C# wrapper around libsnap7).
The OPC path eliminates P/Invoke fragility but introduces event-driven semantics, client/server overhead, and indeterminate scheduling. For deterministic cyclic reads at sub-10 ms intervals, the SAPI-S7 path remains attractive if the wrapper interop is implemented correctly.
Verification Procedure
- Configure STEP 7 with the matching CP type and PROFIBUS address (default 2 for MPI/DP master).
- Build a test request of 3 variables spanning different datatypes:
MW10(WORD, 2 bytes),DB1.DBD20(DWORD, 4 bytes),DB1.DBX30.0(BIT packed, contributes 2 bytes per 16 bits requested). - Invoke the corrected wrapper and confirm
ret == S7_OKwith all threeresultCodeentries = 0. - Validate per-variable length:
length[0] == 2,length[1] == 4,length[2] == 2(bit container width). - Cross-check the slice bytes against PLC values via TIA Portal / STEP 7 watch table.
- Stress test with 32 variables (matches the original 32-element array sizing) to confirm zero allocations beyond the request envelope.
Troubleshooting Matrix
| Symptom | Most likely cause | Fix |
|---|---|---|
| NullReferenceException on multi-variable read |
val_length_ptr pre-seeded with zero; offset walks past buffer |
Remove pre-fill; allocate B = SUM(s_i)
|
| Error 80 |
val_value_ptr too small |
Recompute payload buffer from per-type byte widths |
| Error 82 | Stale cref after CP re-init |
Re-fetch cref via s7_get_cref
|
| Error 87 | CP name string mismatch | Verify cp_name against s7_get_device enumeration |
| Garbage variable values | Wrong datatype width in offset calculation | Use CumulativeOffset from request build, not hard-coded i*2
|
| Single-variable works, multi-variable fails | Implicit uniform-size assumption in wrapper | Switch to per-type byte-width buffer model |
| AccessViolationException on read |
IntPtr computed with .ToInt32() on 64-bit |
Use .ToInt64() or arithmetic directly on IntPtr
|
FAQ
Why does single-variable read succeed but multi-variable read fail in SAPI-S7 C#?
A single variable fits inside the wrapper's hard-coded 64-byte buffer with no offset arithmetic, so the defect is hidden. Two or more variables force the native API to walk per-type offsets, which then either dereference a zero-filled length entry (NullReferenceException) or overrun the 64-byte destination (error 80).
What does SAPI-S7 error 80 mean?
Error 80 is returned when the user-supplied value buffer (val_value_ptr) is smaller than the combined byte width of all requested variables. Compute the buffer size as the sum of per-datatype widths (BYTE=1, WORD=2, INT=2, DWORD=4, DINT=4, REAL=4, plus STRING header) and reallocate.
Is SAPI-S7 officially supported from C#?
No. Siemens documents the SAPI-S7 API as a C interface intended for C, C++, and VB native development. P/Invoke from .NET is an unofficial interop path; Siemens recommends OPC DA or OPC UA for managed integrations.
Can I call s7_get_multiple_read_cnf without a length array?
No. val_length_ptr is required because the API uses it to advance the write offset through the value buffer for each variable in turn. Omitting it leads to undefined native behaviour or error 80.
What is the fastest alternative to SAPI-S7 for high-speed C# PLC reads?
For sub-10 ms cyclic reads, build a C++ wrapper DLL that exports a flat C ABI and forwards to s7std.dll. For less demanding rates, OPC UA with subscriptions or libsnap7 via a managed wrapper provides a stable managed-language path without P/Invoke fragility.