code wiki / shims / nx_modbus_shim.nx

nx_modbus_shim.nx source

↩ module page · 425 lines · 18928 B

1// nx_modbus_shim.nx -- Modbus RTU + TCP protocol shim. 2// 3// WHEELER-IMPLEMENTATION-OF-CANONICAL-SPEC. 4// Wire-spec sources (re-implemented clean-room from published specs; 5// no external code imported per sovereignty audit): 6// - Modbus Application Protocol Specification V1.1b3 (Modbus Org, 2012) 7// - Modbus over Serial Line Specification and Implementation Guide V1.02 8// - MODBUS Messaging on TCP/IP Implementation Guide V1.0b 9// 10// Second protocol family per INTEROPERABILITY_CHARTER.md §5.2. 11// Proves the 5-file shim template generalises from automotive 12// (OBD-II in §5.1) to industrial (Modbus in §5.2). 13// 14// Status: SEED v0.1.0. 2026-05-27. 15// 16// WINNER-TIER: BASELINE-C provisional 17// INCUMBENTS: libmodbus v3.1.x (LGPL C library; most-deployed), 18// pyModbus v3.x (Python), Modbus.NET (commercial .NET), 19// ModScan / ModSlave (commercial Windows tools) 20// NUMBERS: V1 ships parsing + framing for the 4 dominant 21// function codes (03/04/06/16); paired throughput + 22// latency bench vs libmodbus pending real serial-port 23// hardware 24// GAP: unmeasured today; comparable parse-cost expected 25// (Modbus framing is trivial vs OBD's ISO-TP); 26// Nishi adds value-add via the same substrate 27// consumer pattern as OBD pred-maint 28// PLAN: M-next: paired bench on a real Modbus RTU serial bus 29// + Modbus TCP server vs libmodbus client; re-rate 30// EXEMPTION REASON: n/a; provisional pending measurement 31// 32// V1 SCOPE: 33// - Modbus RTU framing parse + build (serial) 34// - Modbus TCP framing parse + build (MBAP header) 35// - Function code 03 (read holding registers) 36// - Function code 04 (read input registers) 37// - Function code 06 (write single register) 38// - Function code 16 (write multiple registers) 39// - CRC-16-Modbus (bit-by-bit; no precomputed table for 40// sovereignty; ~30 cycles per byte on x86_64) 41// - Exception response detection (function code + 0x80) 42// - 4 NxProtocolEvent kinds (substrate-facing; raw frames never 43// leak past the shim per INTEROPERABILITY_CHARTER §M2) 44// 45// V2+ SCOPE: 46// - Function codes 01/02 (read coils / discrete inputs) 47// - Function codes 05/15 (write coil / multiple coils) 48// - Function code 23 (read/write multiple registers) 49// - Modbus ASCII variant (rare; legacy industrial) 50// - Multi-master arbitration (rare in deployment) 51// - Per-slave timeout + retry state machine 52// - Modbus security per RFC 6587 (TLS wrap) 53 54import "nx_syscalls.nx" 55 56// ===== Cross-shim verdict codes (mirror INTEROPERABILITY_CHARTER §9) ================================================= 57const NX_SHIM_OK: i64 = 0 58const NX_SHIM_BAD_FRAME: i64 = 1 59const NX_SHIM_PROTOCOL_VIOLATION: i64 = 2 60const NX_SHIM_TIMEOUT: i64 = 3 61const NX_SHIM_BACKPRESSURE: i64 = 4 62const NX_SHIM_UNSUPPORTED_PID: i64 = 5 // here: unsupported function code 63const NX_SHIM_NO_WIRE: i64 = 6 64 65// ===== Modbus-specific verdict codes (extend cross-shim) ================================================= 66const NX_MODBUS_VERDICT_BASE: i64 = 200 67const NX_MODBUS_BAD_CRC: i64 = 200 // RTU CRC mismatch 68const NX_MODBUS_BAD_MBAP_LEN: i64 = 201 // TCP MBAP length field disagrees with payload 69const NX_MODBUS_BAD_PROTO_ID: i64 = 202 // TCP MBAP protocol_id != 0 70const NX_MODBUS_EXCEPTION_RESP: i64 = 203 // function | 0x80 received; exception_code follows 71const NX_MODBUS_BAD_FUNCTION: i64 = 204 // function code unsupported in V1 72const NX_MODBUS_TRUNCATED: i64 = 205 // frame ends mid-field 73const NX_MODBUS_BAD_QUANTITY: i64 = 206 // read/write count outside spec bounds 74 75// ===== Function codes (sealed; Modbus App Protocol V1.1b3 §6) ================================================= 76const NX_MODBUS_FC_READ_COILS: i64 = 0x01 // V2+ TODO 77const NX_MODBUS_FC_READ_DISCRETE_INPUTS: i64 = 0x02 // V2+ 78const NX_MODBUS_FC_READ_HOLDING_REGS: i64 = 0x03 // V1 79const NX_MODBUS_FC_READ_INPUT_REGS: i64 = 0x04 // V1 80const NX_MODBUS_FC_WRITE_SINGLE_COIL: i64 = 0x05 // V2+ 81const NX_MODBUS_FC_WRITE_SINGLE_REG: i64 = 0x06 // V1 82const NX_MODBUS_FC_WRITE_MULTI_COILS: i64 = 0x0F // V2+ 83const NX_MODBUS_FC_WRITE_MULTI_REGS: i64 = 0x10 // V1 84const NX_MODBUS_FC_READ_WRITE_MULTI: i64 = 0x17 // V2+ 85const NX_MODBUS_FC_EXCEPTION_OFFSET: i64 = 0x80 // function | 0x80 = exception response 86 87// ===== Exception codes (per spec §7) ================================================= 88const NX_MODBUS_EXC_ILLEGAL_FUNCTION: i64 = 0x01 89const NX_MODBUS_EXC_ILLEGAL_DATA_ADDR: i64 = 0x02 90const NX_MODBUS_EXC_ILLEGAL_DATA_VALUE: i64 = 0x03 91const NX_MODBUS_EXC_SLAVE_DEVICE_FAILURE: i64 = 0x04 92const NX_MODBUS_EXC_ACKNOWLEDGE: i64 = 0x05 93const NX_MODBUS_EXC_SLAVE_BUSY: i64 = 0x06 94const NX_MODBUS_EXC_GATEWAY_PATH_UNAVAIL: i64 = 0x0A 95const NX_MODBUS_EXC_GATEWAY_TARGET_NO_RESP: i64 = 0x0B 96 97// ===== MBAP header offsets (Modbus TCP; Implementation Guide V1.0b §3.1) ================================================= 98const NX_MBAP_OFF_TXN_ID: i64 = 0 // u16 BE; client-chosen; echoed by server 99const NX_MBAP_OFF_PROTO_ID: i64 = 2 // u16 BE; MUST be 0x0000 100const NX_MBAP_OFF_LENGTH: i64 = 4 // u16 BE; bytes after length field (unit_id + PDU) 101const NX_MBAP_OFF_UNIT_ID: i64 = 6 // u8; slave address 102const NX_MBAP_HEADER_SIZE: i64 = 7 103 104// ===== Frame size bounds ================================================= 105const NX_MODBUS_RTU_MIN_LEN: i64 = 4 // addr + func + CRC(2) 106const NX_MODBUS_RTU_MAX_LEN: i64 = 256 107const NX_MODBUS_TCP_MIN_LEN: i64 = 8 // MBAP(7) + function 108const NX_MODBUS_TCP_MAX_LEN: i64 = 260 109 110// ===== Substrate-facing event kinds ================================================= 111const NX_MODBUS_EVT_HOLDING_REG_READ: i64 = 300 // v0=reg_addr, v1=value, v2=unit_id 112const NX_MODBUS_EVT_INPUT_REG_READ: i64 = 301 113const NX_MODBUS_EVT_WRITE_ACK: i64 = 302 // v0=reg_addr, v1=quantity_or_value, v2=unit_id 114const NX_MODBUS_EVT_EXCEPTION: i64 = 303 // v0=function_code, v1=exception_code, v2=unit_id 115 116// ===== Shim state ================================================= 117 118struct NxModbusShim { 119 name_buf: *u8 120 wire_spec_id: *u8 121 rx_byte_count: i64 122 rx_event_count: i64 123 rx_error_count: i64 124 last_verdict: i64 125 valid: i64 126} 127 128func nx_modbus_shim_init(s: *NxModbusShim) -> i64 { 129 if (s as i64) == 0 { return 0 - NX_SHIM_BAD_FRAME } 130 s.name_buf = "Modbus RTU+TCP" as *u8 131 s.wire_spec_id = "Modbus App Proto V1.1b3 + TCP Impl Guide V1.0b" as *u8 132 s.rx_byte_count = 0 133 s.rx_event_count = 0 134 s.rx_error_count = 0 135 s.last_verdict = NX_SHIM_OK 136 s.valid = 1 137 return NX_SHIM_OK 138} 139 140// ===== CRC-16-Modbus (poly 0xA001 reflected; init 0xFFFF) ================================================= 141// 142// Bit-by-bit implementation; no precomputed table per sovereignty 143// (avoids embedding the 512-byte LUT that every libmodbus carries). 144// Cost ~30 cycles per byte on x86_64; acceptable for substrate use. 145// V2 may add an optional precomputed-table fast path opt-in. 146 147func nx_modbus_crc16(buf: *u8, len: i64) -> i64 { 148 var crc: i64 = 0xffff 149 var i: i64 = 0 150 while i < len { 151 crc = crc ^ (buf[i] as i64) 152 var bit: i64 = 0 153 while bit < 8 { 154 if (crc & 1) == 1 { 155 crc = (crc >> 1) ^ 0xa001 156 } 157 if (crc & 1) == 0 { 158 crc = crc >> 1 159 } 160 bit = bit + 1 161 } 162 i = i + 1 163 } 164 return crc & 0xffff 165} 166 167// ===== Big-endian u16 read ================================================= 168// 169// Modbus is big-endian throughout (per spec; opposite of OBD's 170// CAN-bus convention which is also big-endian for ISO-TP but 171// little-endian inside the payload for some PIDs). 172 173func nx_modbus_read_u16_be(buf: *u8, off: i64) -> i64 { 174 let hi: i64 = buf[off] as i64 175 let lo: i64 = buf[off + 1] as i64 176 return (hi << 8) | lo 177} 178 179func nx_modbus_write_u16_be(buf: *u8, off: i64, value: i64) -> i64 { 180 buf[off] = ((value >> 8) & 0xff) as u8 181 buf[off + 1] = (value & 0xff) as u8 182 return 0 183} 184 185// ===== RTU parse ================================================= 186// 187// Frame shape (Modbus RTU; Modbus over Serial Impl Guide V1.02 §2.5): 188// byte 0: slave address (1..247; 0 = broadcast) 189// byte 1: function code (or function | 0x80 for exception) 190// bytes 2..N-3: PDU data 191// bytes N-2..N-1: CRC-16 (low byte first; little-endian within the CRC field) 192// 193// Returns count of NxProtocolEvents emitted (>=0), or negated 194// verdict on failure. 195 196func nx_modbus_rtu_parse(s: *NxModbusShim, frame: *u8, frame_len: i64, 197 out_evt_kinds: *i64, out_values: *i64, 198 out_unit_ids: *i64, cap: i64) -> i64 { 199 if s.valid != 1 { return 0 - NX_SHIM_NO_WIRE } 200 s.rx_byte_count = s.rx_byte_count + frame_len 201 202 if frame_len < NX_MODBUS_RTU_MIN_LEN { return 0 - NX_MODBUS_TRUNCATED } 203 if frame_len > NX_MODBUS_RTU_MAX_LEN { return 0 - NX_SHIM_BAD_FRAME } 204 205 // CRC check (computed over everything except the last 2 bytes). 206 let computed_crc: i64 = nx_modbus_crc16(frame, frame_len - 2) 207 let frame_crc_lo: i64 = frame[frame_len - 2] as i64 208 let frame_crc_hi: i64 = frame[frame_len - 1] as i64 209 let frame_crc: i64 = (frame_crc_hi << 8) | frame_crc_lo 210 if computed_crc != frame_crc { 211 s.rx_error_count = s.rx_error_count + 1 212 s.last_verdict = NX_MODBUS_BAD_CRC 213 return 0 - NX_MODBUS_BAD_CRC 214 } 215 216 let unit_id: i64 = frame[0] as i64 217 let func_code: i64 = frame[1] as i64 218 219 return nx_modbus_dispatch_pdu(s, func_code, unit_id, frame, 2, frame_len - 4, 220 out_evt_kinds, out_values, out_unit_ids, cap) 221} 222 223// ===== TCP parse ================================================= 224// 225// Frame shape (Modbus TCP; Impl Guide V1.0b §3.1): 226// MBAP header (7 bytes): 227// bytes 0-1: transaction id (u16 BE; echoed) 228// bytes 2-3: protocol id (u16 BE; MUST be 0) 229// bytes 4-5: length (u16 BE; remaining bytes after this field) 230// byte 6: unit id (u8; slave address) 231// bytes 7..: PDU (function code + data) 232// 233// No CRC at the TCP layer (TCP itself provides reliable delivery). 234 235func nx_modbus_tcp_parse(s: *NxModbusShim, frame: *u8, frame_len: i64, 236 out_evt_kinds: *i64, out_values: *i64, 237 out_unit_ids: *i64, cap: i64) -> i64 { 238 if s.valid != 1 { return 0 - NX_SHIM_NO_WIRE } 239 s.rx_byte_count = s.rx_byte_count + frame_len 240 241 if frame_len < NX_MODBUS_TCP_MIN_LEN { return 0 - NX_MODBUS_TRUNCATED } 242 if frame_len > NX_MODBUS_TCP_MAX_LEN { return 0 - NX_SHIM_BAD_FRAME } 243 244 let proto_id: i64 = nx_modbus_read_u16_be(frame, NX_MBAP_OFF_PROTO_ID) 245 if proto_id != 0 { 246 s.rx_error_count = s.rx_error_count + 1 247 s.last_verdict = NX_MODBUS_BAD_PROTO_ID 248 return 0 - NX_MODBUS_BAD_PROTO_ID 249 } 250 251 let mbap_len: i64 = nx_modbus_read_u16_be(frame, NX_MBAP_OFF_LENGTH) 252 let pdu_len: i64 = frame_len - NX_MBAP_HEADER_SIZE 253 if (mbap_len - 1) != pdu_len { 254 // length field counts unit_id + PDU; so PDU = length - 1 255 s.rx_error_count = s.rx_error_count + 1 256 s.last_verdict = NX_MODBUS_BAD_MBAP_LEN 257 return 0 - NX_MODBUS_BAD_MBAP_LEN 258 } 259 260 let unit_id: i64 = frame[NX_MBAP_OFF_UNIT_ID] as i64 261 let func_code: i64 = frame[NX_MBAP_HEADER_SIZE] as i64 262 263 return nx_modbus_dispatch_pdu(s, func_code, unit_id, frame, 264 NX_MBAP_HEADER_SIZE + 1, pdu_len - 1, 265 out_evt_kinds, out_values, out_unit_ids, cap) 266} 267 268// ===== PDU dispatch (shared by RTU + TCP) ================================================= 269// 270// Parses the function-specific body of a Modbus PDU and emits 271// substrate-facing events. V1 supports FC 03/04/06/16 + exception 272// detection. 273 274func nx_modbus_dispatch_pdu(s: *NxModbusShim, func_code: i64, unit_id: i64, 275 frame: *u8, data_off: i64, data_len: i64, 276 out_evt_kinds: *i64, out_values: *i64, 277 out_unit_ids: *i64, cap: i64) -> i64 { 278 // Exception response: function | 0x80; next byte is exception code. 279 if func_code >= NX_MODBUS_FC_EXCEPTION_OFFSET { 280 if data_len < 1 { return 0 - NX_MODBUS_TRUNCATED } 281 if cap < 1 { return 0 - NX_SHIM_BACKPRESSURE } 282 out_evt_kinds[0] = NX_MODBUS_EVT_EXCEPTION 283 out_values[0] = (frame[data_off] as i64) & 0xff // exception code 284 out_unit_ids[0] = unit_id 285 // Also remember the original function for diagnostics. 286 s.rx_event_count = s.rx_event_count + 1 287 return 1 288 } 289 290 if func_code == NX_MODBUS_FC_READ_HOLDING_REGS { 291 return nx_modbus_parse_read_regs(s, NX_MODBUS_EVT_HOLDING_REG_READ, unit_id, 292 frame, data_off, data_len, 293 out_evt_kinds, out_values, out_unit_ids, cap) 294 } 295 if func_code == NX_MODBUS_FC_READ_INPUT_REGS { 296 return nx_modbus_parse_read_regs(s, NX_MODBUS_EVT_INPUT_REG_READ, unit_id, 297 frame, data_off, data_len, 298 out_evt_kinds, out_values, out_unit_ids, cap) 299 } 300 if func_code == NX_MODBUS_FC_WRITE_SINGLE_REG { 301 // Response echoes request: 2 bytes addr + 2 bytes value 302 if data_len < 4 { return 0 - NX_MODBUS_TRUNCATED } 303 if cap < 1 { return 0 - NX_SHIM_BACKPRESSURE } 304 out_evt_kinds[0] = NX_MODBUS_EVT_WRITE_ACK 305 out_values[0] = nx_modbus_read_u16_be(frame, data_off + 2) 306 out_unit_ids[0] = unit_id 307 s.rx_event_count = s.rx_event_count + 1 308 return 1 309 } 310 if func_code == NX_MODBUS_FC_WRITE_MULTI_REGS { 311 // Response echoes: 2 bytes start_addr + 2 bytes quantity 312 if data_len < 4 { return 0 - NX_MODBUS_TRUNCATED } 313 if cap < 1 { return 0 - NX_SHIM_BACKPRESSURE } 314 let start_addr: i64 = nx_modbus_read_u16_be(frame, data_off) 315 let quantity: i64 = nx_modbus_read_u16_be(frame, data_off + 2) 316 out_evt_kinds[0] = NX_MODBUS_EVT_WRITE_ACK 317 out_values[0] = quantity 318 out_unit_ids[0] = unit_id 319 // start_addr lost in V1 NxProtocolEvent 3-slot payload; V2 320 // adds extra_str carrying structured payload per charter §6 321 s.rx_event_count = s.rx_event_count + 1 322 return 1 323 } 324 325 s.rx_error_count = s.rx_error_count + 1 326 s.last_verdict = NX_MODBUS_BAD_FUNCTION 327 return 0 - NX_MODBUS_BAD_FUNCTION 328} 329 330// ===== Read-regs PDU parser ================================================= 331// 332// Response format (FC 03 + FC 04): 333// byte 0: byte count (= 2 * register count) 334// bytes 1..: register values (each 2 bytes BE) 335 336func nx_modbus_parse_read_regs(s: *NxModbusShim, evt_kind: i64, unit_id: i64, 337 frame: *u8, data_off: i64, data_len: i64, 338 out_evt_kinds: *i64, out_values: *i64, 339 out_unit_ids: *i64, cap: i64) -> i64 { 340 if data_len < 1 { return 0 - NX_MODBUS_TRUNCATED } 341 let byte_count: i64 = frame[data_off] as i64 342 if byte_count > (data_len - 1) { return 0 - NX_MODBUS_TRUNCATED } 343 if (byte_count & 1) != 0 { return 0 - NX_MODBUS_BAD_QUANTITY } 344 345 let n_regs: i64 = byte_count / 2 346 if n_regs > cap { return 0 - NX_SHIM_BACKPRESSURE } 347 348 var i: i64 = 0 349 while i < n_regs { 350 let reg_val: i64 = nx_modbus_read_u16_be(frame, data_off + 1 + (i * 2)) 351 out_evt_kinds[i] = evt_kind 352 out_values[i] = reg_val 353 out_unit_ids[i] = unit_id 354 // Register address is implicit (caller knows from the 355 // request it sent). V2 carries it explicitly via shim's 356 // request-tracking state machine. 357 i = i + 1 358 } 359 s.rx_event_count = s.rx_event_count + n_regs 360 return n_regs 361} 362 363// ===== RTU build: read-holding-regs request ================================================= 364// 365// Builds a Modbus RTU read-holding-registers request. 366// PDU: [func=03 | start_addr_be | quantity_be] 367// Frame: [unit_id | PDU | CRC_lo | CRC_hi] 368// 369// Returns bytes written (8 for FC 03 request) or negated verdict. 370 371func nx_modbus_rtu_build_read_holding(s: *NxModbusShim, unit_id: i64, 372 start_addr: i64, quantity: i64, 373 out: *u8, out_cap: i64) -> i64 { 374 if s.valid != 1 { return 0 - NX_SHIM_NO_WIRE } 375 if out_cap < 8 { return 0 - NX_SHIM_BACKPRESSURE } 376 if quantity < 1 { return 0 - NX_MODBUS_BAD_QUANTITY } 377 if quantity > 125 { return 0 - NX_MODBUS_BAD_QUANTITY } // spec §6.3 max 378 379 out[0] = (unit_id & 0xff) as u8 380 out[1] = NX_MODBUS_FC_READ_HOLDING_REGS as u8 381 nx_modbus_write_u16_be(out, 2, start_addr) 382 nx_modbus_write_u16_be(out, 4, quantity) 383 let crc: i64 = nx_modbus_crc16(out, 6) 384 out[6] = (crc & 0xff) as u8 // CRC low byte first 385 out[7] = ((crc >> 8) & 0xff) as u8 386 return 8 387} 388 389// ===== TCP build: read-holding-regs request ================================================= 390// 391// MBAP header + PDU; no CRC. 392// Frame total: 12 bytes (MBAP 7 + function 1 + start_addr 2 + qty 2) 393 394func nx_modbus_tcp_build_read_holding(s: *NxModbusShim, txn_id: i64, unit_id: i64, 395 start_addr: i64, quantity: i64, 396 out: *u8, out_cap: i64) -> i64 { 397 if s.valid != 1 { return 0 - NX_SHIM_NO_WIRE } 398 if out_cap < 12 { return 0 - NX_SHIM_BACKPRESSURE } 399 if quantity < 1 { return 0 - NX_MODBUS_BAD_QUANTITY } 400 if quantity > 125 { return 0 - NX_MODBUS_BAD_QUANTITY } 401 402 nx_modbus_write_u16_be(out, NX_MBAP_OFF_TXN_ID, txn_id) 403 nx_modbus_write_u16_be(out, NX_MBAP_OFF_PROTO_ID, 0) 404 nx_modbus_write_u16_be(out, NX_MBAP_OFF_LENGTH, 6) // unit_id + 5-byte PDU 405 out[NX_MBAP_OFF_UNIT_ID] = (unit_id & 0xff) as u8 406 out[NX_MBAP_HEADER_SIZE] = NX_MODBUS_FC_READ_HOLDING_REGS as u8 407 nx_modbus_write_u16_be(out, NX_MBAP_HEADER_SIZE + 1, start_addr) 408 nx_modbus_write_u16_be(out, NX_MBAP_HEADER_SIZE + 3, quantity) 409 return 12 410} 411 412// ===== diag_fn ================================================= 413// 414// Per INTEROPERABILITY_CHARTER §6: emit shim health metrics. 415// 4-i64 vector layout matches the OBD shim diag_fn for cross-shim 416// uniformity. 417 418func nx_modbus_diag_fn(s: *NxModbusShim, out_vec: *i64) -> i64 { 419 if s.valid != 1 { return 0 - NX_SHIM_NO_WIRE } 420 out_vec[0] = s.rx_byte_count 421 out_vec[1] = s.rx_event_count 422 out_vec[2] = s.rx_error_count 423 out_vec[3] = s.last_verdict 424 return NX_SHIM_OK 425}