A pure-Rust client for Siemens S7 PLCs.
This is a from-scratch reimplementation of the client half of
Snap7 by Davide Nardella
(github.com/davenardella/snap7).
It speaks ISO-TCP (RFC 1006) and the S7 communication protocol directly, so it
talks to S7-200/300/400/1200/1500 CPUs and compatible CPs with no C library,
no unsafe, and no dependencies.
Server and partner roles are intentionally out of scope — this is client only.
[dependencies]
snap7-client = "0.1"All credit for the protocol work belongs to the upstream project. Snap7 is the result of years of reverse-engineering the S7 protocol, and this crate would not exist without it.
- Upstream C++ project: https://github.com/davenardella/snap7
- Homepage and reference manual: https://snap7.sourceforge.net/
This is not a binding, a wrapper, or a bindgen port. Nothing links against
libsnap7. The C++ sources were used as the protocol specification, and the
implementation was written fresh in idiomatic Rust — different architecture,
different memory model, different error handling (see below).
The upstream sources are not vendored in this repository. If you want them side by side for comparison:
git clone https://github.com/davenardella/snap7.gitBecause this is a derived work of an LGPL-3.0 project, this crate is licensed LGPL-3.0-or-later to match. See COPYING.LESSER.
use snap7::{Client, Area, data};
fn main() -> Result<(), snap7::Error> {
// S7-300: rack 0, slot 2. S7-1200/1500: rack 0, slot 0 or 1.
let mut plc = Client::connect_to("192.168.0.1", 0, 2)?;
// Raw bytes
let buf = plc.db_read_to_vec(1, 0, 16)?;
// Typed access
let setpoint = data::get_f32(&buf, 0)?;
let running = data::get_bit(&buf, 4, 0)?;
println!("setpoint {setpoint}, running {running}");
// Write a single bit — no read-modify-write cycle
plc.write_bit(Area::DataBlock, 1, 4, 1, true)?;
plc.disconnect();
Ok(())
}Several variables in one telegram (up to 20 items):
use snap7::{ReadItem, Area};
let items = [
ReadItem::db_bytes(1, 0, 4),
ReadItem::db_bytes(2, 10, 8),
ReadItem::bytes(Area::Merker, 0, 2),
];
// The outer Result covers the exchange; each item reports its own outcome,
// because a CPU can reject items individually.
for (i, res) in plc.read_multi_vars(&items)?.into_iter().enumerate() {
match res.data {
Ok(bytes) => println!("item {i}: {bytes:02X?}"),
Err(e) => eprintln!("item {i} failed: {e}"),
}
}Two runnable examples are included:
cargo run --example browse -- 192.168.0.1 0 2 # identity, blocks, status
cargo run --example db_io -- 192.168.0.1 1 # typed DB read/write| Area | Functions |
|---|---|
| Connection | connect_to, open, connect, disconnect, reconnect, TSAP/port/ref/PDU tuning, timeouts |
| Data I/O | read_area, write_area, read_multi_vars, write_multi_vars, read_bit, write_bit |
| Shorthands | db_read, db_write, mb_*, eb_*, ab_*, tm_*, ct_* |
| Directory | list_blocks, list_blocks_of_type, block_info, block_info_from_image |
| Blocks | upload, full_upload, download, delete_block, db_get, db_fill |
| System | read_szl, read_szl_list, order_code, cpu_info, cp_info, plc_status, protection |
| Clock | plc_date_time, set_plc_date_time, set_plc_date_time_now_utc |
| Control | plc_hot_start, plc_cold_start, plc_stop, copy_ram_to_rom, compress |
| Security | set_session_password, clear_session_password |
| Escape hatch | iso_exchange_buffer for raw telegrams |
The data module converts between PLC bytes and Rust values: BYTE/WORD/
DWORD/INT/DINT/REAL/LREAL/STRING/CHAR arrays, DATE_AND_TIME,
DATE, TIME, TIME_OF_DAY, S5TIME, BCD counters and bits.
The async/job API (Cli_AsReadArea and friends). The C++ version spawns a
thread per client to emulate it; in Rust you can wrap the blocking client in
spawn_blocking, a worker thread, or a Mutex, which composes better than a
bespoke callback API.
The wire protocol is byte-compatible. The surrounding design is not.
Architecture. Three independently testable layers — TCP transport, ISO-TCP
(TPKT/COTP) framing, and the S7 client — instead of a five-deep C++ inheritance
chain (TMsgSocket → TIsoTcpSocket → TSnap7Peer → TSnap7MicroClient →
TSnap7Client) in which each level mutates shared state on its parent.
Memory safety. Snap7 casts raw pointers onto #pragma pack(1) structs and
reads fields without checking the received length, so a truncated or hostile
answer reads past the buffer. Every field access here is range checked and
returns Error::InvalidPlcAnswer instead. The crate is #![forbid(unsafe_code)].
Allocation. Snap7 embeds a 64 KiB opData buffer plus a 4 KiB PDU in every
client object (~70 KiB per connection, used or not). This crate reuses a single
request buffer and allocates results on demand.
Ownership. Functions return Vec<u8>/String rather than writing into
caller-supplied C arrays with int &Size in/out parameters.
Determinism. Block timestamps are decoded as UTC with an embedded civil
calendar algorithm. Snap7 calls localtime, so the same PLC reports different
dates on different machines.
Errors. A typed Error enum. The original numeric codes are preserved via
Error::code() and the original strings via Error::text(), so tooling keyed
on Snap7 error numbers keeps working.
The packed struct offsets were re-derived independently and diffed against this port, which surfaced three defects upstream:
- Multi-PDU timer/counter reads. Snap7 advances the cursor with
Start += NumElements * WordSizefor every word length, but timers and counters are addressed by element index, not by byte. Any timer or counter read large enough to span two PDUs skips half the requested range. ListBlocksOfTypeoverrun. The inner loop runsCThis + 1times and then decrements the pointer, reading one item past the received data.- Unvalidated answer lengths throughout the parsing code.
cargo test68 tests run without any hardware. The integration suite (tests/mock_plc.rs)
starts a mock CPU implementing the real wire protocol — COTP handshake, PDU
negotiation, read/write jobs, user-data functions, multi-telegram SZL answers
and COTP fragmentation — and asserts on the exact bytes the client puts on the
wire, including PDU splitting, bit versus element addressing, odd-length item
padding and CPU error mapping.
Hardware status: the data I/O, directory, SZL, clock, control and security
paths are covered against the mock CPU. upload, full_upload and download
are hand-verified against the C++ struct layouts but have not been exercised
against a real CPU — treat them as unproven.
- Default rack/slot is
0/2(S7-300/400). Use0/0or0/1for S7-1200/1500. - On S7-1200/1500, "optimized block access" must be off for the DB you want to read, and PUT/GET communication must be enabled in the CPU protection settings.
- The default connection type is
PG. A CPU offers only one PG resource, so useConnectionType::Basic(orOp) when TIA Portal is connected at the same time.
LGPL-3.0-or-later, matching the upstream Snap7 licensing. See COPYING.LESSER and COPYING.
Snap7 is Copyright © 2013–2025 Davide Nardella.