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}