DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
modbus.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file modbus.cpp
6 * @brief Modbus TCP slave: data model, MBAP/PDU codec, and the TCP transport.
7 */
8
10
11#if DETWS_ENABLE_MODBUS
12
13#include <string.h>
14
15// ---------------------------------------------------------------------------
16// Data model (all BSS - no heap)
17// ---------------------------------------------------------------------------
18
19// All Modbus data-model state, owned by one instance (internal linkage): the coil / discrete
20// bitfields, the holding / input registers, and the write callback, grouped so it is one
21// named owner, unreachable from any other translation unit.
22struct ModbusCtx
23{
24 uint8_t coils[(DETWS_MODBUS_COILS + 7) / 8];
25 uint8_t discrete[(DETWS_MODBUS_DISCRETE_INPUTS + 7) / 8];
26 uint16_t holding[DETWS_MODBUS_HOLDING_REGS];
27 uint16_t input[DETWS_MODBUS_INPUT_REGS];
28 ModbusWriteCb write_cb = nullptr;
29};
30static ModbusCtx s_modbus;
31
32static bool bit_get(const uint8_t *a, uint16_t i)
33{
34 return (a[i >> 3] >> (i & 7)) & 1u;
35}
36static void bit_set(uint8_t *a, uint16_t i, bool v)
37{
38 if (v)
39 a[i >> 3] |= (uint8_t)(1u << (i & 7));
40 else
41 a[i >> 3] &= (uint8_t)~(1u << (i & 7));
42}
43
44void modbus_server_init()
45{
46 memset(s_modbus.coils, 0, sizeof(s_modbus.coils));
47 memset(s_modbus.discrete, 0, sizeof(s_modbus.discrete));
48 memset(s_modbus.holding, 0, sizeof(s_modbus.holding));
49 memset(s_modbus.input, 0, sizeof(s_modbus.input));
50 s_modbus.write_cb = nullptr;
51}
52
53void modbus_on_write(ModbusWriteCb cb)
54{
55 s_modbus.write_cb = cb;
56}
57
58bool modbus_get_coil(uint16_t addr)
59{
60 return (addr < DETWS_MODBUS_COILS) ? bit_get(s_modbus.coils, addr) : false;
61}
62void modbus_set_coil(uint16_t addr, bool on)
63{
64 if (addr < DETWS_MODBUS_COILS)
65 bit_set(s_modbus.coils, addr, on);
66}
67bool modbus_get_discrete_input(uint16_t addr)
68{
69 return (addr < DETWS_MODBUS_DISCRETE_INPUTS) ? bit_get(s_modbus.discrete, addr) : false;
70}
71void modbus_set_discrete_input(uint16_t addr, bool on)
72{
74 bit_set(s_modbus.discrete, addr, on);
75}
76uint16_t modbus_get_holding_reg(uint16_t addr)
77{
78 return (addr < DETWS_MODBUS_HOLDING_REGS) ? s_modbus.holding[addr] : 0;
79}
80void modbus_set_holding_reg(uint16_t addr, uint16_t value)
81{
83 s_modbus.holding[addr] = value;
84}
85uint16_t modbus_get_input_reg(uint16_t addr)
86{
87 return (addr < DETWS_MODBUS_INPUT_REGS) ? s_modbus.input[addr] : 0;
88}
89void modbus_set_input_reg(uint16_t addr, uint16_t value)
90{
91 if (addr < DETWS_MODBUS_INPUT_REGS)
92 s_modbus.input[addr] = value;
93}
94
95// ---------------------------------------------------------------------------
96// PDU codec (host-testable)
97// ---------------------------------------------------------------------------
98
99static uint16_t rd16(const uint8_t *p)
100{
101 return (uint16_t)((p[0] << 8) | p[1]);
102}
103static void wr16(uint8_t *p, uint16_t v)
104{
105 p[0] = (uint8_t)(v >> 8);
106 p[1] = (uint8_t)(v & 0xFF);
107}
108
109// Build an exception PDU (fc|0x80, code). Always 2 bytes.
110static size_t pdu_exception(ModbusFunction fc, ModbusException code, uint8_t *out)
111{
112 out[0] = (uint8_t)((uint8_t)fc | 0x80);
113 out[1] = (uint8_t)code;
114 return 2;
115}
116
117// Process one PDU (function code + data) against the data model. Returns the
118// response PDU length, or 0 if it cannot fit (caller treats 0 as "send nothing").
119static size_t modbus_process_pdu(const uint8_t *pdu, size_t pdu_len, uint8_t *out, size_t out_cap)
120{
121 if (pdu_len < 1)
122 return 0;
123 ModbusFunction fc = static_cast<ModbusFunction>(pdu[0]);
124
125 // Dispatch on the function code; each case validates its own request length and
126 // address/quantity range, replying with the data or a Modbus exception PDU.
127 switch (fc)
128 {
129 // FC1/FC2: read up to 2000 single-bit coils / discrete inputs, packed 8 per byte.
130 case ModbusFunction::MODBUS_FC_READ_COILS:
131 case ModbusFunction::MODBUS_FC_READ_DISCRETE_INPUTS: {
132 if (pdu_len < 5)
133 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
134 uint16_t start = rd16(pdu + 1), qty = rd16(pdu + 3);
135 uint16_t limit =
136 (fc == ModbusFunction::MODBUS_FC_READ_COILS) ? DETWS_MODBUS_COILS : DETWS_MODBUS_DISCRETE_INPUTS;
137 const uint8_t *src = (fc == ModbusFunction::MODBUS_FC_READ_COILS) ? s_modbus.coils : s_modbus.discrete;
138 if (qty < 1 || qty > 2000)
139 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
140 if ((uint32_t)start + qty > limit)
141 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_ADDRESS, out);
142 uint8_t bytes = (uint8_t)((qty + 7) / 8);
143 if ((size_t)2 + bytes > out_cap)
144 return 0;
145 out[0] = (uint8_t)fc;
146 out[1] = bytes;
147 memset(out + 2, 0, bytes);
148 for (uint16_t i = 0; i < qty; i++)
149 if (bit_get(src, (uint16_t)(start + i)))
150 out[2 + (i >> 3)] |= (uint8_t)(1u << (i & 7));
151 return (size_t)2 + bytes;
152 }
153
154 // FC3/FC4: read up to 125 16-bit holding / input registers, big-endian.
155 case ModbusFunction::MODBUS_FC_READ_HOLDING_REGS:
156 case ModbusFunction::MODBUS_FC_READ_INPUT_REGS: {
157 if (pdu_len < 5)
158 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
159 uint16_t start = rd16(pdu + 1), qty = rd16(pdu + 3);
160 uint16_t limit =
161 (fc == ModbusFunction::MODBUS_FC_READ_HOLDING_REGS) ? DETWS_MODBUS_HOLDING_REGS : DETWS_MODBUS_INPUT_REGS;
162 const uint16_t *src = (fc == ModbusFunction::MODBUS_FC_READ_HOLDING_REGS) ? s_modbus.holding : s_modbus.input;
163 if (qty < 1 || qty > 125)
164 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
165 if ((uint32_t)start + qty > limit)
166 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_ADDRESS, out);
167 uint8_t bytes = (uint8_t)(qty * 2);
168 if ((size_t)2 + bytes > out_cap)
169 return 0;
170 out[0] = (uint8_t)fc;
171 out[1] = bytes;
172 for (uint16_t i = 0; i < qty; i++)
173 wr16(out + 2 + i * 2, src[start + i]);
174 return (size_t)2 + bytes;
175 }
176
177 // FC5: write one coil (value 0xFF00 = on, 0x0000 = off); echo the request back.
178 case ModbusFunction::MODBUS_FC_WRITE_SINGLE_COIL: {
179 if (pdu_len < 5)
180 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
181 uint16_t addr = rd16(pdu + 1), value = rd16(pdu + 3);
182 if (value != 0x0000 && value != 0xFF00)
183 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
184 if (addr >= DETWS_MODBUS_COILS)
185 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_ADDRESS, out);
186 bit_set(s_modbus.coils, addr, value == 0xFF00);
187 if (s_modbus.write_cb)
188 s_modbus.write_cb((uint8_t)fc, addr, 1);
189 if (out_cap < 5)
190 return 0;
191 memcpy(out, pdu, 5); // echo request
192 return 5;
193 }
194
195 // FC6: write one holding register; echo the request back.
196 case ModbusFunction::MODBUS_FC_WRITE_SINGLE_REG: {
197 if (pdu_len < 5)
198 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
199 uint16_t addr = rd16(pdu + 1), value = rd16(pdu + 3);
200 if (addr >= DETWS_MODBUS_HOLDING_REGS)
201 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_ADDRESS, out);
202 s_modbus.holding[addr] = value;
203 if (s_modbus.write_cb)
204 s_modbus.write_cb((uint8_t)fc, addr, 1);
205 if (out_cap < 5)
206 return 0;
207 memcpy(out, pdu, 5);
208 return 5;
209 }
210
211 // FC15: write up to 1968 coils from a packed bitfield; reply start + quantity.
212 case ModbusFunction::MODBUS_FC_WRITE_MULTIPLE_COILS: {
213 if (pdu_len < 6)
214 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
215 uint16_t start = rd16(pdu + 1), qty = rd16(pdu + 3);
216 uint8_t bc = pdu[5];
217 if (qty < 1 || qty > 1968 || bc != (uint8_t)((qty + 7) / 8) || pdu_len < (size_t)6 + bc)
218 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
219 if ((uint32_t)start + qty > DETWS_MODBUS_COILS)
220 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_ADDRESS, out);
221 for (uint16_t i = 0; i < qty; i++)
222 {
223 bool v = (pdu[6 + (i >> 3)] >> (i & 7)) & 1u;
224 bit_set(s_modbus.coils, (uint16_t)(start + i), v);
225 }
226 if (s_modbus.write_cb)
227 s_modbus.write_cb((uint8_t)fc, start, qty);
228 if (out_cap < 5)
229 return 0;
230 out[0] = (uint8_t)fc;
231 wr16(out + 1, start);
232 wr16(out + 3, qty);
233 return 5;
234 }
235
236 // FC16: write up to 123 holding registers; reply start + quantity.
237 case ModbusFunction::MODBUS_FC_WRITE_MULTIPLE_REGS: {
238 if (pdu_len < 6)
239 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
240 uint16_t start = rd16(pdu + 1), qty = rd16(pdu + 3);
241 uint8_t bc = pdu[5];
242 if (qty < 1 || qty > 123 || bc != (uint8_t)(qty * 2) || pdu_len < (size_t)6 + bc)
243 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_VALUE, out);
244 if ((uint32_t)start + qty > DETWS_MODBUS_HOLDING_REGS)
245 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_DATA_ADDRESS, out);
246 for (uint16_t i = 0; i < qty; i++)
247 s_modbus.holding[start + i] = rd16(pdu + 6 + i * 2);
248 if (s_modbus.write_cb)
249 s_modbus.write_cb((uint8_t)fc, start, qty);
250 if (out_cap < 5)
251 return 0;
252 out[0] = (uint8_t)fc;
253 wr16(out + 1, start);
254 wr16(out + 3, qty);
255 return 5;
256 }
257
258 // Any unsupported function code: reply with the ILLEGAL FUNCTION exception.
259 default:
260 return pdu_exception(fc, ModbusException::MODBUS_EX_ILLEGAL_FUNCTION, out);
261 }
262}
263
264size_t modbus_process_adu(const uint8_t *req, size_t req_len, uint8_t *resp, size_t resp_cap)
265{
266 if (req_len < 8 || resp_cap < 8)
267 return 0; // need MBAP (7) + at least a function code
268
269 uint16_t tid = rd16(req);
270 uint16_t pid = rd16(req + 2);
271 uint16_t len = rd16(req + 4);
272 uint8_t uid = req[6];
273
274 if (pid != 0)
275 return 0; // not Modbus
276 if (len < 2 || (size_t)6 + len > req_len)
277 return 0; // length field disagrees with the frame
278
279 const uint8_t *pdu = req + 7;
280 size_t pdu_len = (size_t)len - 1; // len counts the unit id + the PDU
281
282 size_t rlen = modbus_process_pdu(pdu, pdu_len, resp + 7, resp_cap - 7);
283 if (rlen == 0)
284 return 0;
285
286 wr16(resp, tid); // echo transaction id
287 wr16(resp + 2, 0); // protocol id = 0
288 wr16(resp + 4, (uint16_t)(1 + rlen)); // length = unit id + response PDU
289 resp[6] = uid; // echo unit id
290 return 7 + rlen;
291}
292
293#if DETWS_ENABLE_MODBUS_RTU
294// CRC16-Modbus (init 0xFFFF, reflected poly 0xA001); transmitted low byte first.
295static uint16_t modbus_crc16(const uint8_t *data, size_t len)
296{
297 uint16_t crc = 0xFFFFu;
298 for (size_t i = 0; i < len; i++)
299 {
300 crc ^= data[i];
301 for (int b = 0; b < 8; b++)
302 crc = (crc & 1u) ? (uint16_t)((crc >> 1) ^ 0xA001u) : (uint16_t)(crc >> 1);
303 }
304 return crc;
305}
306
307size_t modbus_rtu_process_adu(const uint8_t *req, size_t req_len, uint8_t *resp, size_t resp_cap, uint8_t my_addr)
308{
309 if (req_len < 4 || resp_cap < 4) // addr(1) + min PDU(1) + CRC(2)
310 return 0;
311
312 // Validate the trailing CRC over [addr .. last PDU byte] (low byte first).
313 uint16_t want = modbus_crc16(req, req_len - 2);
314 uint16_t got = (uint16_t)(req[req_len - 2] | (req[req_len - 1] << 8));
315 if (want != got)
316 return 0; // corrupt frame - drop silently (no response), per Modbus RTU
317
318 uint8_t addr = req[0];
319 bool broadcast = (addr == 0);
320 if (!broadcast && addr != my_addr)
321 return 0; // not addressed to this slave
322
323 const uint8_t *pdu = req + 1;
324 size_t pdu_len = req_len - 3; // strip addr + 2 CRC bytes
325
326 size_t rlen = modbus_process_pdu(pdu, pdu_len, resp + 1, resp_cap - 3); // leave addr + CRC room
327 if (rlen == 0)
328 return 0;
329 if (broadcast)
330 return 0; // executed, but a broadcast gets no reply
331
332 resp[0] = my_addr;
333 uint16_t crc = modbus_crc16(resp, 1 + rlen);
334 resp[1 + rlen] = (uint8_t)(crc & 0xFFu);
335 resp[2 + rlen] = (uint8_t)(crc >> 8);
336 return 1 + rlen + 2;
337}
338#endif // DETWS_ENABLE_MODBUS_RTU
339
340// ---------------------------------------------------------------------------
341// TCP transport (ESP32-only; the core above is host-tested standalone)
342// ---------------------------------------------------------------------------
343
344#if defined(ARDUINO)
345
348
349// Bytes available in the slot's rx ring.
350// Thin adapters over the transport RX read API - the ring is owned by transport;
351// this service never indexes rx_buffer or advances rx_tail itself.
352static size_t ring_avail(const TcpConn *c)
353{
354 return det_conn_available(c->id);
355}
356static void ring_peek(const TcpConn *c, size_t off, uint8_t *dst, size_t n)
357{
358 det_conn_peek(c->id, off, dst, n);
359}
360static void ring_consume(TcpConn *c, size_t n)
361{
362 det_conn_consume(c->id, n);
363}
364
365static void raw_send(uint8_t slot, const void *data, size_t n)
366{
367 if (!det_conn_active(slot) || n == 0)
368 return;
369 det_conn_send(slot, data, (u16_t)n);
370 det_conn_flush(slot);
371}
372
373static void close_conn(uint8_t slot)
374{
375 det_conn_close(slot); // transport owns detach + slot reset + close
376}
377
378void modbus_rx(uint8_t slot)
379{
380 TcpConn *conn = &conn_pool[slot];
381
382 // Frame complete ADUs out of the rx ring; a partial frame stays buffered.
383 for (;;)
384 {
385 size_t avail = ring_avail(conn);
386 if (avail < 8) // MBAP (7) + at least a function code
387 break;
388
389 uint8_t hdr[6];
390 ring_peek(conn, 0, hdr, 6);
391 uint16_t pid = (uint16_t)((hdr[2] << 8) | hdr[3]);
392 uint16_t len = (uint16_t)((hdr[4] << 8) | hdr[5]);
393 if (pid != 0 || len < 2 || len > (MODBUS_ADU_MAX - 6))
394 {
395 close_conn(slot); // not a Modbus TCP frame - drop the connection
396 return;
397 }
398 size_t frame_total = (size_t)6 + len;
399 if (avail < frame_total)
400 break; // wait for the rest of the frame
401
402 uint8_t adu[MODBUS_ADU_MAX];
403 ring_peek(conn, 0, adu, frame_total);
404 ring_consume(conn, frame_total);
405
406 uint8_t resp[MODBUS_ADU_MAX];
407 size_t rl = modbus_process_adu(adu, frame_total, resp, sizeof(resp));
408 if (rl)
409 raw_send(slot, resp, rl);
410 }
411}
412
413// The Modbus ProtoHandler (Layer 5 dispatch seam) - only a data handler; a partial ADU waits in the
414// rx ring, so there is no per-connection accept/close/poll state. Returned by accessor (no session
415// dependency); proto_register_builtins() installs it.
416static const ProtoHandler s_modbus_handler = {nullptr, modbus_rx, nullptr, nullptr};
417const ProtoHandler *modbus_proto_handler(void)
418{
419 return &s_modbus_handler;
420}
421
422#else // !ARDUINO
423
424// Host builds test the pure ADU codec; there is no TCP transport handler.
425const ProtoHandler *modbus_proto_handler(void)
426{
427 return nullptr;
428}
429
430#endif // ARDUINO
431
432#endif // DETWS_ENABLE_MODBUS
#define DETWS_MODBUS_HOLDING_REGS
Number of Modbus holding registers (FC 3/6/16), 16-bit R/W (BSS).
#define DETWS_MODBUS_INPUT_REGS
Number of Modbus input registers (FC 4), 16-bit read-only (BSS).
#define DETWS_MODBUS_COILS
Number of Modbus coils (FC 1/5/15), single-bit R/W (BSS, bit-packed).
#define DETWS_MODBUS_DISCRETE_INPUTS
Number of Modbus discrete inputs (FC 2), single-bit read-only (BSS, bit-packed).
Zero-heap Modbus TCP slave/server (Modbus Application Protocol v1.1b3).
Layer 5 (Session) - per-protocol connection handler dispatch table.
Per-protocol connection event/poll callbacks (Layer 5 dispatch vtable).
A single TCP connection context.
Definition tcp.h:72
uint8_t id
Fixed slot index (0 … MAX_CONNS-1).
Definition tcp.h:73
bool det_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:362
void det_conn_flush(uint8_t slot)
Flush queued bytes / finish the send on slot (TLS-aware).
Definition tcp.cpp:413
void det_conn_close(uint8_t slot)
Close connection slot gracefully (tcp_close), aborting if the FIN cannot be queued....
Definition tcp.cpp:467
TcpConn conn_pool[CONN_POOL_SLOTS]
Static pool of connection contexts. Defined in tcp.cpp. Sized CONN_POOL_SLOTS: MAX_CONNS TCP slots pl...
Definition tcp.cpp:347
Layer 4 (Transport) - TCP connection pool, ring buffers, and lwIP integration.