sim: Modbus RTU slave model (ModbusSlave) on the RS-485 seam

The device counterpart to warden-modbus's master/scanner: request-frame in,
response-frame out, in host memory. CRC16 is byte-identical to the master
(poly 0xA001, low-first; known vector 01 03 00 00 00 01 -> 84 0A verified).
Implements the data plane — read/write holding & input registers, coils,
discrete inputs (FC 0x01-0x06, 0x0F, 0x10) + Report Slave ID (0x11) — with
exception replies (illegal function/address/value) and the two real-world
faults the master must survive: a device that silently ignores a request
(drop_next) and one that NAKs everything (force_exception). This is what the
modbus-master MC/DC harness drives against; MEI (0x2B/0x0E) is a follow-up.

11 tests, sim crate 25/25 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf
This commit is contained in:
BFE Engineering
2026-08-24 18:04:38 -06:00
co-authored by Claude Opus 4.8
parent 4a1ec1d84e
commit 435953799c
3 changed files with 447 additions and 6 deletions
+6 -2
View File
@@ -2,17 +2,21 @@
//!
//! Lets driver and supervisor logic run and be tested on the host, with no panel,
//! by modelling the RV1106 hardware the vendor SDK cannot: the register/SRAM bus
//! ([`membus`]), the RISC-V HPMCU watchdog coprocessor ([`hpmcu`]), and — as they
//! land — the RGA blitter and the NPU.
//! ([`membus`]), the RISC-V HPMCU watchdog coprocessor ([`hpmcu`]), the CRU reset
//! ladder ([`cru`]), and the RS-485 device end ([`modbus`]) — and, as they land,
//! the RGA blitter and the NPU.
//!
//! Design: one [`membus::MemBus`] seam, two backends. On the host, [`membus::SimBus`]
//! is an in-memory word map; on the device, flared's `devmem.rs` implements the same
//! trait over `/dev/mem`, so the same code runs against either. See the repo README.
//! (The [`modbus`] slave rides a byte-stream seam, not the register bus.)
pub mod cru;
pub mod hpmcu;
pub mod membus;
pub mod modbus;
pub use cru::{BootMode, CruSim, ResetCause};
pub use hpmcu::HpmcuSim;
pub use membus::{MemBus, SimBus};
pub use modbus::ModbusSlave;
+429
View File
@@ -0,0 +1,429 @@
//! Modbus RTU **slave** simulator — the device end of the RS-485 seam.
//!
//! flare-edge's `warden-modbus` is the *master/scanner*: it probes RS-485 for
//! VFDs and PDUs, identifies them, and reads their register maps. To harden that
//! master to MC/DC we need something for it to talk to that behaves like a real
//! slave — correct CRC framing, the data-plane function codes, exception replies,
//! and the annoying real-world faults (a cheap sensor that ignores a function, a
//! device that NAKs an unsupported code). This is that slave, in host memory:
//! feed it a request frame, get the response frame (or `None` when a real slave
//! would stay silent). No serial port, no hardware, fully deterministic.
//!
//! Scope is the data plane — read/write of holding & input registers, coils, and
//! discrete inputs (FC 0x010x06, 0x0F, 0x10) plus Report Slave ID (0x11) — which
//! is what a VFD/PDU register poll actually exercises. Identification via MEI
//! (0x2B/0x0E) is a documented follow-up.
/// Modbus exception codes (returned as `fc | 0x80`, then the code).
pub mod exc {
pub const ILLEGAL_FUNCTION: u8 = 0x01;
pub const ILLEGAL_DATA_ADDRESS: u8 = 0x02;
pub const ILLEGAL_DATA_VALUE: u8 = 0x03;
}
/// Modbus RTU CRC16 (poly 0xA001, low byte first on the wire). Identical to the
/// master's `crc16` — the two must agree or nothing frames.
pub fn crc16(bytes: &[u8]) -> u16 {
let mut crc: u16 = 0xFFFF;
for &b in bytes {
crc ^= b as u16;
for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xA001 } else { crc >> 1 };
}
}
crc
}
/// Append the RTU CRC (low byte then high) to a frame body in place.
pub fn append_crc(frame: &mut Vec<u8>) {
let c = crc16(frame);
frame.push((c & 0xFF) as u8);
frame.push((c >> 8) as u8);
}
/// True if `frame` (including its trailing 2 CRC bytes) has a valid CRC.
pub fn crc_ok(frame: &[u8]) -> bool {
if frame.len() < 3 {
return false;
}
let (body, crc) = frame.split_at(frame.len() - 2);
crc == [(crc16(body) & 0xFF) as u8, (crc16(body) >> 8) as u8]
}
/// A Modbus RTU slave device model.
pub struct ModbusSlave {
pub address: u8,
holding: Vec<u16>,
input: Vec<u16>,
coils: Vec<bool>,
discrete: Vec<bool>,
slave_id: Vec<u8>,
/// Silently drop this many upcoming requests (models a device that ignores a
/// function, or a flaky bus) — the master must time out and move on.
drop_next: usize,
/// Force every function to answer with this exception (models a device that
/// NAKs everything but a narrow set) until cleared.
force_exception: Option<u8>,
}
impl ModbusSlave {
/// A slave at `address` with `regs` holding & input registers and `bits`
/// coils & discrete inputs, all zero.
pub fn new(address: u8, regs: usize, bits: usize) -> Self {
Self {
address,
holding: vec![0; regs],
input: vec![0; regs],
coils: vec![false; bits],
discrete: vec![false; bits],
slave_id: vec![0xFF, 0xFF], // run-indicator on; arbitrary id byte
drop_next: 0,
force_exception: None,
}
}
pub fn set_holding(&mut self, addr: usize, v: u16) {
self.holding[addr] = v;
}
pub fn set_input(&mut self, addr: usize, v: u16) {
self.input[addr] = v;
}
pub fn set_coil(&mut self, addr: usize, v: bool) {
self.coils[addr] = v;
}
pub fn set_discrete(&mut self, addr: usize, v: bool) {
self.discrete[addr] = v;
}
pub fn holding(&self, addr: usize) -> u16 {
self.holding[addr]
}
pub fn coil(&self, addr: usize) -> bool {
self.coils[addr]
}
/// Set the Report-Slave-ID payload (everything after the byte-count).
pub fn set_slave_id(&mut self, id: Vec<u8>) {
self.slave_id = id;
}
/// Ignore the next `n` requests entirely (no response).
pub fn drop_next(&mut self, n: usize) {
self.drop_next = n;
}
/// Answer every request with `code` until [`clear_faults`](Self::clear_faults).
pub fn force_exception(&mut self, code: u8) {
self.force_exception = Some(code);
}
pub fn clear_faults(&mut self) {
self.drop_next = 0;
self.force_exception = None;
}
/// Handle one RTU request frame; return the RTU response frame, or `None`
/// when a real slave would stay silent (bad CRC, wrong address, broadcast,
/// or an injected drop).
pub fn handle_frame(&mut self, req: &[u8]) -> Option<Vec<u8>> {
if self.drop_next > 0 {
self.drop_next -= 1;
return None;
}
if req.len() < 4 || !crc_ok(req) {
return None; // RTU slaves silently discard malformed / bad-CRC frames
}
let addr = req[0];
if addr != self.address {
return None; // not for us (address 0 = broadcast: no reply either)
}
let fc = req[1];
let pdu = &req[2..req.len() - 2]; // between address and CRC
let body = if let Some(code) = self.force_exception {
Err(code)
} else {
self.dispatch(fc, pdu)
};
let mut frame = vec![self.address];
match body {
Ok(mut data) => {
frame.push(fc);
frame.append(&mut data);
}
Err(code) => {
frame.push(fc | 0x80);
frame.push(code);
}
}
append_crc(&mut frame);
Some(frame)
}
/// Build the response PDU (everything between fc and CRC) or an exception.
fn dispatch(&mut self, fc: u8, pdu: &[u8]) -> Result<Vec<u8>, u8> {
match fc {
0x01 => self.read_bits(pdu, false),
0x02 => self.read_bits(pdu, true),
0x03 => self.read_regs(pdu, false),
0x04 => self.read_regs(pdu, true),
0x05 => self.write_single_coil(pdu),
0x06 => self.write_single_reg(pdu),
0x0F => self.write_multi_coils(pdu),
0x10 => self.write_multi_regs(pdu),
0x11 => Ok(self.report_slave_id()),
_ => Err(exc::ILLEGAL_FUNCTION),
}
}
fn read_regs(&self, pdu: &[u8], input: bool) -> Result<Vec<u8>, u8> {
if pdu.len() != 4 {
return Err(exc::ILLEGAL_DATA_VALUE);
}
let start = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
let count = u16::from_be_bytes([pdu[2], pdu[3]]) as usize;
if count == 0 || count > 125 {
return Err(exc::ILLEGAL_DATA_VALUE);
}
let bank = if input { &self.input } else { &self.holding };
if start + count > bank.len() {
return Err(exc::ILLEGAL_DATA_ADDRESS);
}
let mut out = vec![(count * 2) as u8];
for r in &bank[start..start + count] {
out.extend_from_slice(&r.to_be_bytes());
}
Ok(out)
}
fn read_bits(&self, pdu: &[u8], discrete: bool) -> Result<Vec<u8>, u8> {
if pdu.len() != 4 {
return Err(exc::ILLEGAL_DATA_VALUE);
}
let start = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
let count = u16::from_be_bytes([pdu[2], pdu[3]]) as usize;
if count == 0 || count > 2000 {
return Err(exc::ILLEGAL_DATA_VALUE);
}
let bank = if discrete { &self.discrete } else { &self.coils };
if start + count > bank.len() {
return Err(exc::ILLEGAL_DATA_ADDRESS);
}
let nbytes = count.div_ceil(8);
let mut out = vec![nbytes as u8];
out.extend(std::iter::repeat_n(0u8, nbytes));
for (i, &bit) in bank[start..start + count].iter().enumerate() {
if bit {
out[1 + i / 8] |= 1 << (i % 8);
}
}
Ok(out)
}
fn write_single_reg(&mut self, pdu: &[u8]) -> Result<Vec<u8>, u8> {
if pdu.len() != 4 {
return Err(exc::ILLEGAL_DATA_VALUE);
}
let addr = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
let val = u16::from_be_bytes([pdu[2], pdu[3]]);
if addr >= self.holding.len() {
return Err(exc::ILLEGAL_DATA_ADDRESS);
}
self.holding[addr] = val;
Ok(pdu.to_vec()) // echo request PDU
}
fn write_single_coil(&mut self, pdu: &[u8]) -> Result<Vec<u8>, u8> {
if pdu.len() != 4 {
return Err(exc::ILLEGAL_DATA_VALUE);
}
let addr = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
let val = u16::from_be_bytes([pdu[2], pdu[3]]);
if val != 0x0000 && val != 0xFF00 {
return Err(exc::ILLEGAL_DATA_VALUE);
}
if addr >= self.coils.len() {
return Err(exc::ILLEGAL_DATA_ADDRESS);
}
self.coils[addr] = val == 0xFF00;
Ok(pdu.to_vec())
}
fn write_multi_regs(&mut self, pdu: &[u8]) -> Result<Vec<u8>, u8> {
if pdu.len() < 5 {
return Err(exc::ILLEGAL_DATA_VALUE);
}
let start = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
let count = u16::from_be_bytes([pdu[2], pdu[3]]) as usize;
let bytecount = pdu[4] as usize;
if count == 0 || count > 123 || bytecount != count * 2 || pdu.len() != 5 + bytecount {
return Err(exc::ILLEGAL_DATA_VALUE);
}
if start + count > self.holding.len() {
return Err(exc::ILLEGAL_DATA_ADDRESS);
}
for i in 0..count {
self.holding[start + i] = u16::from_be_bytes([pdu[5 + i * 2], pdu[6 + i * 2]]);
}
Ok(pdu[0..4].to_vec()) // echo address + count
}
fn write_multi_coils(&mut self, pdu: &[u8]) -> Result<Vec<u8>, u8> {
if pdu.len() < 5 {
return Err(exc::ILLEGAL_DATA_VALUE);
}
let start = u16::from_be_bytes([pdu[0], pdu[1]]) as usize;
let count = u16::from_be_bytes([pdu[2], pdu[3]]) as usize;
let bytecount = pdu[4] as usize;
if count == 0 || count > 1968 || bytecount != count.div_ceil(8) || pdu.len() != 5 + bytecount
{
return Err(exc::ILLEGAL_DATA_VALUE);
}
if start + count > self.coils.len() {
return Err(exc::ILLEGAL_DATA_ADDRESS);
}
for i in 0..count {
self.coils[start + i] = pdu[5 + i / 8] & (1 << (i % 8)) != 0;
}
Ok(pdu[0..4].to_vec())
}
fn report_slave_id(&self) -> Vec<u8> {
let mut out = vec![self.slave_id.len() as u8];
out.extend_from_slice(&self.slave_id);
out
}
}
/// Build an RTU request frame (with CRC) — convenience for tests and for driving
/// the master's parser. `pdu` is everything between the address and the CRC
/// (i.e. `fc` followed by its data).
pub fn request(address: u8, pdu: &[u8]) -> Vec<u8> {
let mut f = vec![address];
f.extend_from_slice(pdu);
append_crc(&mut f);
f
}
/// Build a read-holding-registers (FC 0x03) request frame.
pub fn read_holding(address: u8, start: u16, count: u16) -> Vec<u8> {
let mut pdu = vec![0x03];
pdu.extend_from_slice(&start.to_be_bytes());
pdu.extend_from_slice(&count.to_be_bytes());
request(address, &pdu)
}
#[cfg(test)]
mod tests {
use super::*;
/// Known Modbus RTU CRC vector: `01 03 00 00 00 01` -> low `84`, high `0A`.
#[test]
fn crc_known_vector() {
assert_eq!(crc16(&[0x01, 0x03, 0x00, 0x00, 0x00, 0x01]), 0x0A84);
let f = read_holding(1, 0, 1);
assert_eq!(&f[f.len() - 2..], &[0x84, 0x0A]);
assert!(crc_ok(&f));
}
#[test]
fn read_holding_returns_values() {
let mut s = ModbusSlave::new(0x11, 16, 0);
s.set_holding(2, 0xABCD);
s.set_holding(3, 0x1234);
let resp = s.handle_frame(&read_holding(0x11, 2, 2)).unwrap();
// addr fc bytecount d d d d crc crc
assert_eq!(resp[0], 0x11);
assert_eq!(resp[1], 0x03);
assert_eq!(resp[2], 4); // 2 regs * 2 bytes
assert_eq!(&resp[3..7], &[0xAB, 0xCD, 0x12, 0x34]);
assert!(crc_ok(&resp));
}
#[test]
fn write_single_register_updates_and_echoes() {
let mut s = ModbusSlave::new(1, 8, 0);
let req = request(1, &[0x06, 0x00, 0x05, 0x07, 0xD0]); // write 2000 -> reg 5
let resp = s.handle_frame(&req).unwrap();
assert_eq!(s.holding(5), 2000);
assert_eq!(&resp[1..6], &[0x06, 0x00, 0x05, 0x07, 0xD0]); // echo
}
#[test]
fn write_multiple_registers() {
let mut s = ModbusSlave::new(1, 8, 0);
// FC10 start=0 count=2 bytecount=4 vals=0x1111,0x2222
let req = request(1, &[0x10, 0, 0, 0, 2, 4, 0x11, 0x11, 0x22, 0x22]);
let resp = s.handle_frame(&req).unwrap();
assert_eq!(s.holding(0), 0x1111);
assert_eq!(s.holding(1), 0x2222);
assert_eq!(&resp[1..6], &[0x10, 0, 0, 0, 2]); // echo addr+count
}
#[test]
fn coils_write_then_read() {
let mut s = ModbusSlave::new(1, 0, 16);
// FC05 write single coil 3 = ON
s.handle_frame(&request(1, &[0x05, 0, 3, 0xFF, 0x00])).unwrap();
assert!(s.coil(3));
// FC01 read coils 0..8 -> bit 3 set => byte 0x08
let resp = s.handle_frame(&request(1, &[0x01, 0, 0, 0, 8])).unwrap();
assert_eq!(resp[2], 1); // 1 byte
assert_eq!(resp[3], 0x08);
}
#[test]
fn illegal_function_returns_exception() {
let mut s = ModbusSlave::new(1, 8, 0);
let resp = s.handle_frame(&request(1, &[0x63, 0, 0])).unwrap();
assert_eq!(resp[1], 0x63 | 0x80);
assert_eq!(resp[2], exc::ILLEGAL_FUNCTION);
assert!(crc_ok(&resp));
}
#[test]
fn out_of_range_read_is_illegal_data_address() {
let mut s = ModbusSlave::new(1, 4, 0);
let resp = s.handle_frame(&read_holding(1, 2, 10)).unwrap();
assert_eq!(resp[1], 0x83);
assert_eq!(resp[2], exc::ILLEGAL_DATA_ADDRESS);
}
#[test]
fn wrong_address_and_bad_crc_are_silent() {
let mut s = ModbusSlave::new(0x11, 8, 0);
assert!(s.handle_frame(&read_holding(0x12, 0, 1)).is_none()); // other slave
let mut bad = read_holding(0x11, 0, 1);
*bad.last_mut().unwrap() ^= 0xFF; // corrupt CRC
assert!(s.handle_frame(&bad).is_none());
}
/// A device that ignores the next request (the "cheap sensor" the master
/// comment warns about) — the master must fall through to the next function.
#[test]
fn drop_next_models_a_silent_device() {
let mut s = ModbusSlave::new(1, 8, 0);
s.drop_next(1);
assert!(s.handle_frame(&read_holding(1, 0, 1)).is_none()); // ignored
assert!(s.handle_frame(&read_holding(1, 0, 1)).is_some()); // recovers
}
/// A device that NAKs everything (forced exception) until cleared.
#[test]
fn force_exception_naks_until_cleared() {
let mut s = ModbusSlave::new(1, 8, 0);
s.force_exception(exc::ILLEGAL_FUNCTION);
let resp = s.handle_frame(&read_holding(1, 0, 1)).unwrap();
assert_eq!(resp[1], 0x83);
s.clear_faults();
let resp = s.handle_frame(&read_holding(1, 0, 1)).unwrap();
assert_eq!(resp[1], 0x03); // normal again
}
#[test]
fn report_slave_id() {
let mut s = ModbusSlave::new(7, 4, 0);
s.set_slave_id(vec![0x42, 0xFF]);
let resp = s.handle_frame(&request(7, &[0x11])).unwrap();
assert_eq!(resp[1], 0x11);
assert_eq!(resp[2], 2); // byte count
assert_eq!(&resp[3..5], &[0x42, 0xFF]);
}
}