Reverse Engineering Siemens S7CommPlus: Symbolic Access to Optimized DB Blocks
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
Reverse Engineering Siemens S7CommPlus: Symbolic Access to Optimized DB Blocks
I spent a while inside Siemens’ S7CommPlus protocol, and this is the full account: the protocol stack, the symbolic addressing algorithm (SymbolCrc + LID), online symbol browsing, and — the part I’m most happy with — how to make offline TIA Portal project browsing and online browsing share a single code path. Every number and example below comes from live tests against real S7-1500 hardware and real TIA Portal projects.
1. Why optimized DB block symbolic access is hard
Classic S7-300/400 communication (the S7.NET ecosystem) relies on absolute addressing: DB1.DBX0.0, M10.2. But S7-1200/1500 TIA projects enable Optimized Block Access for DB blocks by default, and that changes the fundamental contract:
- Variables no longer have a fixed byte offset in PLC memory. The compiler is free to reorder, merge, and bit-pack them.
- Traditional offset-based read/write payloads (the PEAK/POKE style) fail completely on optimized blocks — the commercial AGL library returns a uniform offset of
-1for optimized blocks, which is the standard “there is no offset” semantic (AGL40_SYMBOLIC_DATABLOCK_IS_OPTIMIZED). - The only stable addressing scheme is symbolic addressing: a 32-bit symbol CRC computed from “symbol name + type”, plus an LID (logical index directory) access sequence resolved layer by layer.
That raises two real technical barriers. First, the symbol CRC and LID algorithms have to be reverse engineered. Second, the symbol name is only the entry point — you eventually need the PLC’s internal type tree (every layer of structs, arrays, and UDTs) to translate a string like "DB1".Motor.Speed[2] into a legal access address.
Why an open-source implementation
The S7CommPlus protocol (used by S7-1200/1500, as distinct from classic S7) was never published; the only official implementation is the closed-source commercial AGLink40. The driver I worked on is an open-source .NET implementation:
- The protocol-stack upstream is thomas-v2/S7CommPlusDriver (LGPL — credit to Thomas Wiens for the original protocol reverse engineering).
- The non-TLS authentication algorithm is based on the reverse-engineered bonk-dev/HarpoS7.
- On top of that stack I did the heavy engineering work: full-symbol differential testing against real PLCs, performance work (microsecond-level key rotation), and — the focus of this article — seamless unification of offline project browsing and online browsing.
Note: the driver is still under development and testing. The protocol implementation may contain unknown defects — do not use it in production.
2. Overall architecture
The solution src/S7CommPlusDriver.sln contains 7 projects:
S7CommPlusDriver.sln
├── S7CommPlusDriver/ # Core protocol library (net4.0+, C# 7.3)
│ ├── Core/ # S7CommPlus object model (PObject, DatablockInfo,
│ │ # Softdatatype, PValue, PLCInformation…)
│ ├── ClientApi/ # Client API
│ │ ├── Browser.cs # Symbol tree browsing (online)
│ │ ├── OfflineSymbolSource.cs # Offline symbol source ("virtual PLC" engine for TIA projects)
│ │ ├── PlcTag.cs / PlcTags.cs # Tag abstraction + ReadTags/WriteTags wrappers
│ │ └── ItemAddress.cs # Variable addressing structure (SymbolCrc + LID)
│ ├── Internal/ # S7CommPlusSymbolCrc (symbol CRC algorithm) etc.
│ ├── Net/ # Transport layer (S7Client, MsgSocket)
│ ├── OpenSSL/ # TLS P/Invoke layer (OpenSSL 3.x, x86/x64 dual impl)
│ ├── Legitimation/ # Password legitimation auth
│ ├── Alarming/ # Alarm handling
│ ├── Subscriptions/ # Subscription management
│ ├── MmTimer.cs # High-resolution multimedia timer (drives key rotation)
│ └── S7CommPlusConnection.cs # Main connection entry point (the only public facade, ~2600 lines)
├── HarpoS7Wrapper/ # Non-TLS auth/encryption P/Invoke wrapper (C#)
├── HarpoS7Native/ # Non-TLS auth algorithm native C++ implementation
├── S7CommPlusGUIBrowser/ # WinForms browser demo
├── DriverTest/ # Headless functional tests + differential verification tools
├── TiaProjectParser/ # TIA project offline parser
└── Zlib.net / TestHarpoS7Native # Dependencies and native-library benchmarks
Three design decisions worth calling out:
1. A unified connection facade. S7CommPlusConnection carries the entire public API — Connect/Disconnect, ReadValues/WriteValues, Browse, getTypeInfoByRelId, getPlcTagBySymbol, GetListOfDatablocks, SetPlcOperatingState, GetCommentsXml, and the star of the next section, LoadSymbolsFromFile. Upper layers (GUI, tests, user code) see exactly one class.
2. True AnyCPU. Early versions relied on two build configurations (x86/x64) with _WIN64 conditional compilation hard-coding native DLL names, which meant building two packages. After the rework, a single artifact dispatches at runtime by IntPtr.Size: both HarpoS7Wrapper and OpenSSL use an Impl64/Impl32 nested-static-class pattern (DllImport requires constant DLL names, so the outer public method forwards at runtime), loading the x64 native library on a 64-bit OS and x86 on a 32-bit OS.
3. A protocol version matrix. The implementation covers ProtocolVersion V1/V2/V3; the TLS-encrypted path and the non-TLS path are selected automatically from connection parameters.
3. Connection establishment and the secure channel
Establishing a session is a multi-step handshake (simplified):
TCP connect → session negotiation (Hello/Version) → authentication (TLS or non-TLS path)
→ session key negotiation → login (optional password legitimation) → communication resource negotiation
→ read PLC info (model/MLFB/firmware/PLC name) → reads, writes and browsing can begin
3.1 Non-TLS authentication: HarpoS7
The non-TLS path splits into two authentication algorithms — PlcSim (soft PLC) and RealPlc (S7-1200/1500 hardware). The core primitives include:
- A GF(2^128) LUT transform: a 4096-byte lookup table whose generation involves splitting the constants of the irreducible polynomial
x^128 + x^7 + x^2 + x + 1. - A Monolith boolean circuit transform (auto-generated bit-manipulation code).
- Big-integer compression (BigIntCompressor): a 256-bit multiplication of
overflow × 0x2Fwith low-bit-aligned storage.
I first reverse engineered and hardware-verified these algorithms in C#, then ported them to a native C++ library for performance. To guarantee byte-for-byte equivalence across the port, I built an automated differential test harness: 22 API test cases (auth 8 / legitimation challenge 4 / key derivation 2 / digest 2 / key storage 3 / fingerprinting 3) compared byte-for-byte against the C# reference — 22/22 pass.
The value of differential testing was proven twice during the port:
- Big-integer compression storage position bug:
harpo_bigint_compress()wrote theoverflow * 0x2Fproduct pastbuffer[5], while the C#BigInteger.Multiplyfills from the low dword (buffer[0]) upward — the compressed result diverged, directly corrupting the seed transform. - LUT generator constant-order bug:
make128(0x00008005, 0, 0x00000001, 0)split the 64-bit XOR constant0x01_0000_8005incorrectly into the lo/hi qwords, so the entire 4096-byte LUT was wrong from entry 2 onward, and the PLC simply refused authentication (checksum mismatch).
Both bugs are the “algorithm correct, details misplaced” class — the kind only byte-level differential testing can catch. That is the core methodology of protocol implementation work.
3.2 Key rotation
The session key rotates every 30 minutes over the lifetime of a connection, driven by a high-resolution multimedia timer. Early on, every rotation regenerated key material and ran the full transform (~0.1 ms for PlcSim, ~4.5 ms for RealPlc). After switching to key-material reuse (save-once-reuse) — reusing the seed/IV within a connection and only varying the challenge — rotation dropped to under 20 μs (14 μs on the PlcSim path, a 7.6× speedup). The rotation flow also matches the commercial library’s semantics: the challenge (GetVarSubStreamed) and SetVariable share a single SequenceNumber that does not self-increment.
4. Symbolic addressing core: ItemAddress / LID / SymbolCrc
This is the heart of the article — symbolic access to optimized DB blocks. Every variable access ultimately resolves to an ItemAddress:
4.1 The ItemAddress structure
public class ItemAddress : IS7pSerialize
{
public UInt32 SymbolCrc; // symbol CRC (see 4.3)
public UInt32 AccessArea; // access area: DB block = 0x8A0E0000 + block number
public UInt32 AccessSubArea; // sub-area: DB_ValueActual / ControllerArea_ValueActual
public List<UInt32> LID; // logical index directory (per-level member index)
}
Serialization is SymbolCrc → AccessArea → LID count + 1 → AccessSubArea → LID[], all VLQ (variable-length quantity) encoded into the request payload.
- AccessArea:
SetAccessAreaToDatablock(number)is0x8A0E0000 + block number; the I/Q/M/T/C areas use their own NativeObjects rid (theIArea_Rid, etc.). - Access string:
GetAccessString()emits a hex-dotted form like8A0E0001.78.F—8A0E0001= DB1, and.78,.Fare the LIDs of two member levels. The parsernew ItemAddress("8A0E0001.A")supports the reverse construction.
4.2 The LID access sequence: how a symbol name becomes an address
The translation of getPlcTagBySymbol("\"DB1\".Motor.Speed") is a layer-by-layer recursion:
parseSymbolLevel() takes the first level "DB1" → matches a block
browsePlcTagBySymbol(ti_relid, symbol, varInfo):
1. getTypeInfoByRelId(ti_relid) fetches this level's PObject
2. parseSymbolLevel() extracts the next level name (quote/dot/subscript three-part parsing)
3. locate the member in VarnameList.Names → get PVartypeListElement
4. varInfo.AccessSequence += "." + varType.LID ← append LID layer by layer
5. simultaneously record (name, type) of this level into SymbolCrcPath
6. if the member is an array → expand the subscripts (below)
7. if the member is a struct/array element → recurse into the next level
Array subscript expansion has its own algorithm: boolean-array element counts round up to a multiple of 8 (the PLC bit-packs them); row-major dimSize accumulation folds a multi-dimensional subscript [i,j] into a linear index; if an array element is a struct, the access sequence gets a trailing .1. In the end "DB1".Motor.Speed[2] might translate to 8A0E0001.78.F.2 — every number is a real protocol-level logical index.
4.3 The symbol CRC algorithm
The symbol CRC is the optimized block’s “variable ID card”. The driver implementation (Internal/S7CommPlusSymbolCrc.cs) matches the algorithm in Siemens’ published material on S7-1500 symbolic addressing:
- CRC32, polynomial
0xF4ACFB13, MSB-first, init 0, no final XOR. - Single segment (one member name):
inner = CRC32(UTF8(name) + TypeCode byte), final value= CRC32(inner written as LE32). - Array segment:
inner = CRC32(UTF8(name) + 0x10 (array type code) + element TypeCode + lower bound (LE32)). - Multi-segment path:
CRC32(inner0 | 0x89 | inner1 | … | innerN)— a0x89struct-sub separator byte is inserted between levels. - TypeCode mapping:
BBOOL → BOOL(1);AOMIDENT/EVENTATT/HWINT etc. → DWORD(6);HWANY/HWIO etc. → WORD(4);OB* → INT(5);PORT/RTM/DBANY etc. → UINT(6); all other types use their own enum value.
Concrete examples computed by the driver algorithm (reproducible directly with the driver):
| Symbol | Computation path | SymbolCrc |
|---|---|---|
Speed : REAL |
single segment, TypeCode=8 | 0x888EC040 |
MotorSpeed : REAL |
single segment, TypeCode=8 | 0xA132E807 |
Motor.Speed : REAL.REAL |
two segments, separated by 0x89 |
0x37AC4EA5 |
Setpoints : Array[0..9] of REAL |
array segment (0x10 + 8 + lower bound 0) | 0x521E174E |
One subtle but critical point: the path CRC for any symbol containing an array-element subscript is always 0 (ContainsIndexedArray → SymbolCrc = 0) — the element-locating information is already fully encoded in the LID, and the CRC only covers the “no-subscript” symbol path.
4.4 Batch read/write
The full ReadValues flow:
- Check the license → initialize return values and the error list (error initial value
0xFFFFFFFFFFFFFFFF). - Chunk by
TagsPerReadRequestMax(PDU-size aware; long lists automatically split into multiple requests). - Construct a
GetMultiVariablesRequestper chunk — TLS or secure connections use ProtocolVersion V2, plain connections use V3. - Send and wait for the response →
GetMultiVariablesResponse.DeserializeFromPdu→checkResponseWithIntegrityintegrity check. - Fill values and error codes item by item (one variable’s failure does not affect the rest of the batch; when
ReturnValueis non-zero, the per-item error details live inErrorValues).
WriteValues goes through SetMultiVariablesRequest (AddressListVar + ValueList), also chunked, also integrity-checked. The upper-level PlcTags.ReadTags(conn, tags) wrapper lets callers deal only with the PlcTag abstraction.
5. Online symbol browsing: the PObject contract and on-demand lazy loading
5.1 The browse flow
Browse(out varInfoList)
├── Explore the PLC object tree (OMS/OMM): PLCProgram (ExploreParents=1)
│ ├── Recursively collect Failsafe levels (FailsafeRuntimeGroup 8157 / FailsafeControl 7842)
│ │ └── and the DB blocks underneath (ClassId=4600=MC_DB_Class_Rid, safety DB)
│ └── ObjectOMSTypeInfoContainer fetches all type info at once
│ ├── stored into Browser.m_objs (browse cache)
│ └── simultaneously populates typeInfoList (the getTypeInfoByRelId cache, below)
└── Expand all leaves block-by-block/area-by-area → VarInfo{Name, AccessSequence, Softdatatype,
SymbolCrcPath, ContainsIndexedArray}
5.2 The PObject contract: one level per fetch
The browsing model revolves around one core contract — getTypeInfoByRelId(relid) → PObject: given a relation ID, return the member list of the current single level:
PObject
├── VarnameList.Names[] # this level's member names (an array parent occupies one entry)
└── VartypeList.Elements[] # each member's type shape (OffsetInfoType):
├── POffsetInfoType_Std # leaf (standard variable)
├── POffsetInfoType_Struct # struct member → RelationId points to the next level's type
├── POffsetInfoType_Array1Dim # 1-D array {lower bound, element count, RelationId}
├── POffsetInfoType_ArrayMDim # multi-D array {per-dim bounds/counts, RelationId}
└── POffsetInfoType_FbArray # FB instance array
Each time the GUI tree expands a level, it calls getTypeInfoByRelId once with the node’s relid and generates the next level’s nodes from the element shapes — mirroring the PLC’s real interaction model: fetch one level per expansion. Thanks to the type-info cache (typeInfoList) populated during the Browse phase, subsequent expansions cost zero network requests. There was a sneaky bug here once: Browse stored type info only in the Browser’s internal cache while typeInfoList stayed empty, so every GUI expansion fired a live ExploreRequest at the PLC — and under network jitter you’d get “Could not get type info”. After the fix, all expansion reads hit the local cache.
Browse correctness also went through a batch of fixes: Failsafe safety DBs were entirely missing (the PLC doesn’t return the Failsafe subtree when ExploreParents=0); FB instance arrays didn’t expand (Is1Dim()/IsMDim() were marked TODO); nested UDT/FB/Struct children silently disappeared (added a TypeInfoFetcher for on-demand re-fetch); empty-name entries polluted the node list (SanitizeVarnameList cleans them up in reverse order).
6. Online/offline unification: one function turns a TIA project into a “virtual PLC”
6.1 The problem
In the early versions, browsing a TIA project offline (an .apXX archive or a project directory) and browsing online were two completely different code paths: offline had its own build-tree/expand/select/path-join branches; expanding a block eagerly converted the entire subtree at once (ConvertOfflineNode full recursion); expanding an area walked the whole tag table in one go; startup pre-warmed every leaf in the background; and the tree root was wrapped in an extra project-name layer. Clunky UX, and completely divorced from the online experience.
6.2 Design: a single LoadSymbolsFromFile entry point
The goal in one sentence: S7CommPlusDriver gains exactly one public function, LoadSymbolsFromFile(projectPath); after calling it, browsing the symbol tree and building the variable tree in the GUI behave identically to online mode; users who never call it see zero behavior change.
var conn = new S7CommPlusConnection();
// Load a TIA project (directory or .apXX archive); the connection object becomes a "virtual PLC"
int res = conn.LoadSymbolsFromFile(@"D:\Projects\tiaProject18");
// Pass null to unload the project and fall back to pure online browsing
conn.LoadSymbolsFromFile(null);
Implementation-wise, the connection object holds an OfflineSymbolSource _offlineSymbols; when it is non-null, four browsing APIs route to the offline source before the license check, falling back online on a miss:
| API | Offline routing behavior |
|---|---|
GetListOfDatablocks |
Returns the project’s DB block list (mapped to DatablockInfo), no network requests |
getTypeInfoByRelId |
Queries the offline relid registry; on a hit returns PObject; on a miss falls back online (supporting “offline tree + online supplement” mixed scenarios) |
Browse |
Returns the offline full-leaf list |
getPlcTagBySymbol |
Runs offline symbol resolution first (tree walk + differential leaf-cache CRC); on success returns PlcTag; on failure falls back online |
Four properties follow naturally:
- Zero GUI branching:
S7CommPlusGUIBrowserdeleted all its offline-only code (loader classes, separate expand/select/path-join, full-leaf pre-warm); online and offline share the sameBuildProjectTree()/AfterExpand/AfterSelect/btnRead— the offline tree is just another data source for the online tree. Zero behavior change for existing users is a hard requirement, not a slogan. - Offline-browse + online-read closed loop: open a project offline →
Connectto a real PLC → select a leaf in the offline tree andReadreal values.Connect/Disconnectdoes not clear the offline source: disconnecting only affects online reads, the tree remains. - Tree root always starts from the PLC name: step 8 of the online connect already reads the PLC name, now stored in
PLCInformation.Name(5 fields: Name/PLCFamily/PLCType/Firmware/MLFB); offline mirrors the same field set from the project’s PLC info. The GUI tree root = PLC name, no extra project-name wrapper. - One connection object throughout: btnConnect reuses the existing conn (doesn’t
newone), preserving the loaded project.
6.3 Real on-demand lazy loading
The key constraint on the offline side: you cannot fully parse the project just to build the tree. TiaProjectParser produces 140,000 lines of symbols from a large V19 project; expanding everything is unacceptable in both memory and startup time. The reworked engine:
Parse()drops theParseAllObjectsfull parse: it only does Open (open the storage container) + ExtractAll (extract the symbol table and block interface metadata) + block-list scanning (FindDataBlocksreads only the block storage-object list, without expanding interfaces); the PlcInfoExtractor falls back to table scanning if the tree walk fails.- A relid registry: “minting” on demand from
0x80000000— when a block is first expanded it is assigned a relid and its symbol tree is cached; afterwards that relid swaps in tree nodes and continues downward; area nodes reuse the online fixed relids0x90010000/0x90020000/0x90030000/0x90050000/0x90060000(I/Q/M/T/C), outside the registry. - Per-level BuildPObject:
getTypeInfoByRelIdconverts only the current level of children into a PObject — array parent as a single entry (VarnameList holds only the array parent; element nodes are generated by the GUI, same semantics as online), struct RelationId pointing to the child-level type, MDim dimensions in reverse order, leaf Softdatatype from the tree node’s Type (verified to match the online sd value) — the contract is identical in shape to online, so the GUI’s AfterExpand needs no change. - Hard evidence for lazy-loading correctness: the full export of the lazy path is byte-for-byte identical to the pre-rework baseline (an 83,259-line
cmpgate).
The first expansion of a block costs about one online type-info request; every subsequent level is a zero-cost cache read.
6.4 Why offline reads dare to return a CRC
Online reads of optimized/non-optimized blocks both depend on the 32-bit symbol CRC and the LID access sequence — get either wrong and you read the wrong address. Offline read values therefore always come from the differential leaf cache (the leaves produced by ExportBlockLeaves/ExportTagLeaves and calibrated against real PLC full-symbol differential testing), never from computing the CRC fresh off a tree node — eliminating the “offline CRC wrong → wrong address” risk at the root. The differential leaf cache itself is generated lazily per block/per area, and keyed per PLC in multi-PLC scenarios.
7. The offline parser: TiaProjectParser
TiaProjectParser parses TIA project files directly, with no TIA Portal and no Siemens runtime:
- Input: a project directory or an
.apXXarchive (the same container structure after unpacking; byte-for-byte identical, verified). - Container:
PEData.plf+ layered XML metadata (Database.FileHeader.ProductVersionbinary header always present, V13=1300.109.701.1 → version scale 1300) + storage object streams. - Only 3 disk-format families exist: FileFormat V10 / V11 / V14 — everything from V12 onward uses the V14-family parser, so all version branches in the library are
<= FileFormat.V10/V11/V14, with no V19+-specific logic. This is why a 2017 parser can open a V21 project. - Tested version matrix: V13×2, V14×2, V15×2, V19 (141,634 lines, 25 DBs), V20×3 (S7-1200/1200 G2/1500), V21 (Modbus SCL example, 3,383 lines) — all open/parse/export successfully.
- Semantic details: latest-stream (TIA current view) parsing — newest copy per InstId, latest-wins relation resolution; AT-view child-member output; Variant(63) flat rows; InOut-area DB reference members → Pointer flat rows; array elements expanded item by item.
- The
-1offset on optimized blocks: a V19 project’s all--1offsets are correct behavior (optimized blocks genuinely have no static byte offset), not a parse failure.
8. Multi-PLC project support
A real project can contain multiple PLCs (e.g. one test project held two: CC1 with 253 blocks / 113 input tags, CC2 with 27 blocks / 3 input tags). Design points:
- relid encoding extension: area relid = area base +
plcIndex × 0x100(plcIndex 0 is byte-for-byte identical to the original fixed values, backward compatible; each area window is 0x10000 with a 0x100 step, capping at 256 PLCs); the PLC node sentinel =0x90070000 + plcIndex × 0x100. - Block table grouped by PLC:
GetListOfPlcs()lists PLC names;SelectPlc(name)switches the active block table and mirrors that PLC’s 5-field info;GetListOfDatablocks(plcIndex)fetches blocks by index. - GUI tree: with multiple PLCs, each PLC name becomes a sibling tree root directly (no project-name layer, consistent with single-PLC “root = PLC name”), each carrying a “Loading…” + PLC sentinel that auto-expands one level (block list + 5 areas); selecting a PLC node shows that PLC’s info and symbols, the symbol traversal carries the active PLC out through the PLC subtree, and
Readlands on the correct PLC.
One API-design lesson worth recording: the two-parameter GetListOfDatablocks() overload once wrongly delegated to index 0, so after a SelectPlc switch it still returned PLC[0]’s block table. The semantic contract must be explicit: two parameters = active PLC, three parameters = by index — and it is now enforced by a --conn gate that checks each PLC (selectSwitch consistency).
9. Type boundary: Variant leaves
One project’s MB_SERVER_DB (TCON.CONNECT, TSEND.DATA, TRCV.ADDR, etc. — 24 Variant leaves) once showed blank data type and blank symbol address when selected. The root cause had three layers, each independently valid:
- Tree vs. differential-cache gap: the offline symbol tree (non-differential mode) contains Variant nodes, but the differential leaf cache deliberately excludes Variants (mirroring online Browse, which doesn’t emit such rows) → after the tree walk finds the node, the cache lookup by Access misses and returns null.
- TagFactory missing case:
PlcTags.TagFactory’s Variant case had been commented out by the original author, with the comment “Variant isn’t added inside of the instance-db as a variable!” — this project proves that assumption false, and the default branch returns null. - Protocol layer not implemented:
PValue.DeserializethrowsNotImplementedExceptionfor Variant — the driver does not support Variant value deserialization.
The fix decouples display from value retrieval: on a cache miss, synthesize a display VarInfo from the tree node (Access/Type from the tree node, CRC=0 — non-optimized-block LID reads can still be attempted; optimized-block reads fail cleanly on the PLC side); add a PlcTagVariant display tag class (writes throw NotSupportedException); and ReadTags catches NotImplementedException returning -1 to prevent GUI crashes (the GUI reads on leaf selection). Result: type shows Variant, address shows 8A0E0001.78.F, and ordinary leaf paths have zero regression.
10. Verification: the four-layer differential gate
The first principle of protocol implementation work: without differential testing, you get nowhere. This project has four automated gates.
Layer 1: real-PLC online differential (verify7)
Using a real S7-1500 as ground truth, the offline parse result is compared leaf-by-leaf against online Browse:
SUMMARY online=75042 offline=83259 matched=75042 onlineOnly=0
offlineOnly=8217 segMismatch=0 crcMismatch=0 ok=75042 GATE=PASS
READ tried=50 ok=50 fail=0
onlineOnly=0: offline misses not a single online symbol.segMismatch=0 / crcMismatch=0: all 75,042 symbols have identical CRCs and access sequences — the strongest evidence that offline-parsed optimized-block addresses match the real hardware exactly.- READ 50/50: 50 randomly sampled variables read from the PLC, all successful.
offlineOnly=8217has a clear explanation: 8 old-version blocks left over on the PLC (8A0E0064/012C/0190/03F2/03FC/0406/0410/041A) had already been deleted in the offline project — not a parse gap.- The leaf list is frozen into a byte-baseline file,
cmp’d on every regression.
Layer 2: commercial-library differential (AGL name ground truth)
Using AGLink40 (a native C++ tree dump, via an AglTree tool) as the name ground truth, a full-matrix differential across 25 real projects (covering V13~V21) shows zero real gaps:
- V19 project (141,634 exported lines): leaf names 35,316/35,427 (99.7%) exact hits; the 111 AGL-only lines are all
In_EventStatus(S7_Pointer leaves — I expand them into their structure members per the current view, while AGL lists only the leaf itself). - AGL-only missed leaves across all 25 projects are without exception S7_Pointer — all explainable.
- The dual-PLC project verified separately: CC2 blocks 2,357 common + 142 all hit, tag leaf names 143/143.
- The differential also bidirectionally found problems in the ground-truth library itself: AGL 6.1.0.5 completely misses one project’s F-safety subsystem (F blocks and all tags absent), while my side is complete.
- AGL cannot load V21 (-393099) — for V21 there is no AGL ground truth, so cross-validation uses the PLF-embedded XML metadata instead (12/12 top-level names and 39 lines of Modbus structure all match).
Layer 3: unified API gate (–conn)
Headless verification that behavior after LoadSymbolsFromFile is exactly equivalent to using the offline engine directly:
Browseleaf count, block-table block count, and first-block & Inputs-areagetTypeInfoByRelIdmember counts all match item-by-item (one project: Browse 159,165/159,165 exactly equal).- Multi-PLC per-PLC checks: per-PLC block table + area relid variants + active-block-table consistency after
SelectPlc. --sym <symbol>symbol-resolution probes across the full path, e.g.'"MB_SERVER_DB".TCON.CONNECT' → addr=8A0E0001.78.F type=Variant → OK.- The report is written to
<out>.conn_check.txt; any FAIL fails the gate.
Layer 4: build & byte gate
VS2017 MSBuild 0 errors; TiaSymbolExport output is byte-for-byte identical to the baseline (any parser change must not alter a single exported line); HarpoS7Native 22/22 byte-for-byte differential (see section 3).
11. Quick start
11.1 Online: connect + browse + symbolic read
using S7CommPlusDriver;
using S7CommPlusDriver.ClientApi;
var conn = new S7CommPlusConnection();
int res = conn.Connect("192.168.0.1", password: "your_password");
if (res != 0) throw new Exception($"Connect failed: {res}");
// Full browse: get all leaves (with CRC and access sequence)
conn.Browse(out var leaves);
Console.WriteLine($"leaves: {leaves.Count}");
// Resolve a tag by symbol name ("DB1".Motor.Speed → 8A0E0001.78.F + SymbolCrc)
var tag = conn.getPlcTagBySymbol("\"DB1\".Motor.Speed");
Console.WriteLine($"{tag.Address.GetAccessString()} type={tag.Datatype}");
// Read values (auto-chunked internally, per-item errors returned)
int r = PlcTags.ReadTags(conn, new[] { tag });
Console.WriteLine(r == 0 ? $"value: {tag}" : $"read failed: {r}");
11.2 Offline: use a project file as a PLC
var conn = new S7CommPlusConnection();
// Load a TIA project (directory or .apXX); the connection object becomes a "virtual PLC"
conn.LoadSymbolsFromFile(@"D:\Projects\tiaProject18");
// Multi-PLC: list, switch, browse per PLC
foreach (var plc in conn.GetListOfPlcs())
{
conn.SelectPlc(plc);
conn.GetListOfDatablocks(out var blocks);
Console.WriteLine($"{plc}: {blocks.Count} blocks");
}
// The exact same API as online: resolve by symbol
var tag = conn.getPlcTagBySymbol("\"DB1\".MotorSpeed");
Console.WriteLine($"{tag.Address.GetAccessString()} {tag.Datatype}");
11.3 Offline-browse + online-read closed loop
// Open the project offline → connect a real PLC → read real values from the offline tree's leaves
conn.LoadSymbolsFromFile(@"D:\Projects\tiaProject1");
conn.Connect("192.168.0.250");
var tag = conn.getPlcTagBySymbol("\"DB1\".SetLimitsHW.Value");
PlcTags.ReadTags(conn, new[] { tag });
// Disconnecting does not affect the loaded project or tree
conn.Disconnect();
11.4 Pure address read/write (low level)
var addr = new ItemAddress("8A0E0001.A"); // or SetAccessAreaToDatablock(1) + LID
conn.ReadValues(new List<ItemAddress> { addr }, out var values, out var errors);
12. Closing
From protocol stack to project browsing, the driver walked one complete technical route: reverse engineering → C# reference implementation → native C++ port with byte-for-byte differential testing → full hardware validation → online/offline unification. The “one function turns a TIA project into a virtual PLC” design illustrates a point I keep relearning: a good abstraction is not an extra layer of glue — it’s making two data sources share one contract. The PObject contract + relid semantics + differential leaf-cache CRC are that contract: online is the network implementation, offline is the project-file implementation.
Symbolic access to optimized DB blocks is really three hurdles: get the symbol CRC algorithm right, walk the LID access sequence level by level, and fetch the type tree on demand. The implementation here passed differential verification against real hardware and 25 real projects.
The driver is still evolving (subscriptions, alarms, the TLS path, and more). Feedback and testing are welcome. Again: not for production use.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch