Modbus-RTU is a request and a reply, each a handful of bytes with a CRC on the end. Building a read request means putting a slave address, a function code, a register address and a count into eight bytes.
Look closely at the endianness, because there are two of them in the same frame. The address and the count are big-endian. The CRC is little-endian. This is not a mistake in the code — the protocol is from 1979 and that is genuinely what goes on the wire.
/// slave | fc | addr_hi | addr_lo | cnt_hi | cnt_lo | crc_lo | crc_hi
pub fn build_read(slave: u8, fc: u8, addr: u16, count: u16) -> [u8; 8] {
let mut f = [0u8; 8];
f[0] = slave;
f[1] = fc;
f[2..4].copy_from_slice(&addr.to_be_bytes()); // big-endian
f[4..6].copy_from_slice(&count.to_be_bytes()); // big-endian
let crc = crc16(&f[..6]);
f[6..8].copy_from_slice(&crc.to_le_bytes()); // little-endian
f
}Two endiannesses, one eight-byte frame
Registers are big-endian; the CRC is little-endian. Writing it as to_be_bytes and to_le_bytes puts that fact in the code rather than in a comment nobody reads.
The same product has a C# service in it that also speaks Modbus, and it carries three separate Modbus packages from NuGet. On the device there is no NuGet, no allocator, and no room to be relaxed about it — so the whole thing is about ninety lines, and it is genuinely not hard.
The difference that matters is not line count. It is what happens when a frame is wrong.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RtuError {
TooShort, // frame shorter than the minimum for its shape
Crc, // checksum mismatch
Mismatch, // someone else's slave or function code
Exception(u8), // the device said no, and why
Malformed, // byte count contradicts the request
}parse_read_response writes into a buffer the caller already owns and returns how many registers it wrote. Nothing is allocated, which is what lets it run on a device with no allocator at all — and a caller can decode into a stack array reused every second for the lifetime of the device.
The failures are an enum, so the compiler will not let you quietly ignore one. In C# the equivalent is an exception carrying a code, and nothing obliges anyone to catch it.
Reading one register
C# - a library, an array, an exception
ushort[] regs = master.ReadHoldingRegisters(
slaveAddress: 1, startAddress: 4, numberOfPoints: 1);
level = regs[0];
// ...and somewhere up the stack, if anyone remembered:
catch (ModbusException ex) when (ex.FunctionCode == 0x83)
{
// illegal data address - or was it illegal function?
}Rust - the caller's buffer, a closed set
let mut regs = [0u16; 1];
match rtu::parse_read_response(1, 3, 1, frame, &mut regs) {
Ok(_) => level = regs[0] as i32,
Err(RtuError::Crc) => diag.frame_err += 1,
Err(RtuError::Exception(code)) => diag.exception += 1,
Err(RtuError::Mismatch) => {} // someone else's answer
Err(RtuError::TooShort)
| Err(RtuError::Malformed) => diag.frame_err += 1,
}The C# allocates a fresh array per read and signals failure by unwinding. The Rust decodes into the caller's buffer and returns a closed set of outcomes. Add a variant to that enum later and every match that does not handle it stops compiling — which is the entire point.
One test worth stealing. Rather than checking a handful of known-bad frames, it builds a valid response for every register count from one to eight, then flips a single bit in every byte position and asserts each corruption is caught:
That is a property, not an example. It would survive a rewrite of the parser, and it catches the classic mistake of validating the CRC but forgetting to cross-check the byte count against what was actually asked for.
Check yourself
Not graded — just to see whether it landed.
1.A response arrives with a valid CRC, the right slave and the right function code, but it carries one register when two were requested. What should the parser do?
2.Why does parse_read_response take an output buffer instead of returning a Vec?