// SPDX-License-Identifier: GPL-3.0-or-later WITH STruCpp-runtime-exception // Copyright (C) 2025 Autonomy / OpenPLC Project // This file is part of the STruC++ Runtime Library and is covered by the // STruC++ Runtime Library Exception. See COPYING.RUNTIME for details. /** * STruC++ Runtime - Debugger Dispatch * * Per-entry force/unforce/read operations for the OpenPLC debugger protocol. * * Each leaf variable in a compiled project (including array elements, struct * fields, and FB internals) is registered in a compile-time Entry table with * {void* ptr, uint8_t tag}. The pointer is to the leaf's own IECVar; the * tag indexes this file's type_ops table, which holds templated function * pointers that know how to force/unforce/read that concrete T. * * The table itself is emitted per-project by STruC++ into generated_debug.cpp. * This header provides the shared, project-agnostic dispatch logic. */ #pragma once // `debug_table.hpp` is the AVR-clean header generated_debug.cpp also // includes — it carries the Entry / TypeTag / STRUCPP_DEBUG_FLASH bits // shared between the table emitter and the dispatch helpers. Importing // it here (rather than redefining) keeps the ABI definitions in exactly // one place. See debug_table.hpp's preamble for why // `` no longer lives in the same TU as user variable // references. #include "debug_table.hpp" #include "iec_types.hpp" #include "iec_traits.hpp" #include "iec_var.hpp" #include "iec_string.hpp" #include "iec_wstring.hpp" #include #include #include #include #ifdef __AVR__ // `read_entry` and friends use `pgm_read_word_far` / `pgm_read_byte` / // `pgm_get_far_address`, which live here. Only the runtime translation // unit (arduino_runtime_glue.cpp / runtime_v4_entry.cpp) ever needs // this dispatch header; `generated_debug.cpp` consumes only // `debug_table.hpp` so it never sees the AVR register-macro contamination // `` brings in transitively. #include #endif namespace strucpp { namespace debug { // --------------------------------------------------------------------------- // Status codes used by the protocol helpers below. // Match the values the MatIEC-era ModbusSlave expected (0x7E / 0x81 / 0x82) // so wire-format parsers on the editor don't need to change. // --------------------------------------------------------------------------- constexpr uint8_t STATUS_OK = 0x7E; constexpr uint8_t STATUS_OUT_OF_BOUNDS = 0x81; constexpr uint8_t STATUS_DATA_TOO_LARGE = 0x82; // --------------------------------------------------------------------------- // Templated per-type helpers. One instantiation per IEC elementary type; // type_ops[] below wires them into a runtime-indexable table. // --------------------------------------------------------------------------- template inline void force_impl(void* p, const uint8_t* bytes) noexcept { T v; std::memcpy(&v, bytes, sizeof(T)); static_cast*>(p)->force(v); } // Specialization: memcpy-into-bool is technically UB for non-{0,1} byte // values, and some AVR GCC versions have optimizer behavior around bool // that can surprise. Normalize explicitly. template <> inline void force_impl(void* p, const uint8_t* bytes) noexcept { const bool v = bytes[0] != 0; static_cast*>(p)->force(v); } template inline void unforce_impl(void* p) noexcept { static_cast*>(p)->unforce(); } // Soft write — updates the underlying value_ via IECVar::set(). Respects // existing forces (set() is a no-op while forced_ is true), so a force in // place stays authoritative until the user explicitly unforces. // // Distinct from force_impl: that one pins the variable indefinitely; this // one writes a value the program can overwrite on the next scan cycle. // Used by external clients (OPC-UA, future BACnet, etc.) that want // regular write semantics rather than debugger-style forcing. template inline void write_impl(void* p, const uint8_t* bytes) noexcept { T v; std::memcpy(&v, bytes, sizeof(T)); static_cast*>(p)->set(v); } // memcpy-into-bool is technically UB for non-{0,1} byte values; normalize // explicitly. Same reasoning as force_impl. template <> inline void write_impl(void* p, const uint8_t* bytes) noexcept { const bool v = bytes[0] != 0; static_cast*>(p)->set(v); } template inline void read_impl(const void* p, uint8_t* dest) noexcept { T v = static_cast*>(p)->get(); std::memcpy(dest, &v, sizeof(T)); } // STRING / WSTRING live in `IECStringVar<254>` / `IECWStringVar<254>`, // the force-aware wrappers around `IECString<254>` / `IECWString<254>`. // Both wrappers carry their own length, capped at 254 bytes / 254 wide // code units of storage. // // Wire format (matches the editor decoder in // `src/frontend/utils/variable-sizes.ts` — `len8-utf8` / `len8-utf16le`): // // STRING: [ uint8 length ][ DEBUG_STRING_CAP bytes UTF-8 payload ] // ^ 1 byte ^ 126 bytes (always — content past the // declared length is unused but the // wire width is fixed) // // WSTRING: [ uint8 length ][ DEBUG_STRING_CAP * 2 bytes UTF-16LE ] // ^ 1 byte ^ 252 bytes (126 little-endian code units) // // The length prefix is a uint8 because that's what the wire reserves // (`DEBUG_STRING_CAP = 126` in the editor); a string longer than 126 // is truncated at the boundary on the way out. The editor reads // exactly the prefix and uses it to decode `min(length, CAP)` content // units; the remaining bytes in the fixed window are ignored. // // We zero-fill the unused tail of the window on every read so stale // bus contents from a previous read can't leak into the editor — which // would otherwise show garbage after the legitimate content if a // reader misuses the cap. // // All four ops are force-aware (read sees the forced value when active; // write/set is a no-op on a forced variable per `IECStringVar::set`'s // own guard; force/unforce manipulate the force state directly). constexpr uint8_t DEBUG_STRING_CAP = 126; // chars / code units constexpr uint8_t DEBUG_STRING_WIDTH = 1 + DEBUG_STRING_CAP; // 127 bytes on the wire constexpr uint8_t DEBUG_WSTRING_WIDTH = 1 + DEBUG_STRING_CAP * 2; // 253 bytes on the wire // --- STRING (IECStringVar<254>) --------------------------------------- inline void read_string(const void* p, uint8_t* dest) noexcept { const auto* var = static_cast*>(p); const std::size_t actual_len = var->length(); const uint8_t wire_len = static_cast( actual_len < DEBUG_STRING_CAP ? actual_len : DEBUG_STRING_CAP); dest[0] = wire_len; if (wire_len > 0) { std::memcpy(dest + 1, var->c_str(), wire_len); } if (wire_len < DEBUG_STRING_CAP) { std::memset(dest + 1 + wire_len, 0, DEBUG_STRING_CAP - wire_len); } } inline void write_string(void* p, const uint8_t* bytes) noexcept { auto* var = static_cast*>(p); const uint8_t wire_len = bytes[0] < DEBUG_STRING_CAP ? bytes[0] : DEBUG_STRING_CAP; var->set(IECString<254>(reinterpret_cast(bytes + 1), wire_len)); } inline void force_string(void* p, const uint8_t* bytes) noexcept { auto* var = static_cast*>(p); const uint8_t wire_len = bytes[0] < DEBUG_STRING_CAP ? bytes[0] : DEBUG_STRING_CAP; var->force(IECString<254>(reinterpret_cast(bytes + 1), wire_len)); } inline void unforce_string(void* p) noexcept { static_cast*>(p)->unforce(); } // --- WSTRING (IECWStringVar<254>) ------------------------------------- inline void read_wstring(const void* p, uint8_t* dest) noexcept { const auto* var = static_cast*>(p); const std::size_t actual_len = var->length(); const uint8_t wire_len = static_cast( actual_len < DEBUG_STRING_CAP ? actual_len : DEBUG_STRING_CAP); dest[0] = wire_len; const char16_t* src = var->c_str(); for (uint8_t i = 0; i < wire_len; ++i) { // Little-endian 16-bit code unit — explicit byte split so the // wire format is host-endianness-independent (AVR is LE in // practice but ARM-BE targets, however rare, would otherwise // serialise the wrong way around). dest[1 + i * 2] = static_cast(src[i] & 0xFF); dest[1 + i * 2 + 1] = static_cast((src[i] >> 8) & 0xFF); } const std::size_t used = 1 + static_cast(wire_len) * 2; if (used < DEBUG_WSTRING_WIDTH) { std::memset(dest + used, 0, DEBUG_WSTRING_WIDTH - used); } } inline void write_wstring(void* p, const uint8_t* bytes) noexcept { auto* var = static_cast*>(p); const uint8_t wire_len = bytes[0] < DEBUG_STRING_CAP ? bytes[0] : DEBUG_STRING_CAP; char16_t buf[DEBUG_STRING_CAP]; for (uint8_t i = 0; i < wire_len; ++i) { buf[i] = static_cast(bytes[1 + i * 2]) | static_cast(static_cast(bytes[1 + i * 2 + 1]) << 8); } var->set(IECWString<254>(buf, wire_len)); } inline void force_wstring(void* p, const uint8_t* bytes) noexcept { auto* var = static_cast*>(p); const uint8_t wire_len = bytes[0] < DEBUG_STRING_CAP ? bytes[0] : DEBUG_STRING_CAP; char16_t buf[DEBUG_STRING_CAP]; for (uint8_t i = 0; i < wire_len; ++i) { buf[i] = static_cast(bytes[1 + i * 2]) | static_cast(static_cast(bytes[1 + i * 2 + 1]) << 8); } var->force(IECWString<254>(buf, wire_len)); } inline void unforce_wstring(void* p) noexcept { static_cast*>(p)->unforce(); } // --------------------------------------------------------------------------- // Dispatch table entry. The `size` field is the byte width consumed/produced // by force/read (for strings: reserved, handled specially). // --------------------------------------------------------------------------- struct TypeOps { void (*force) (void*, const uint8_t*); void (*unforce)(void*); void (*read) (const void*, uint8_t*); void (*write) (void*, const uint8_t*); uint8_t size; }; // --------------------------------------------------------------------------- // type_ops[]: one row per TypeTag, in tag order. // Kept inline so it's flash-resident with no separate .cpp required. // --------------------------------------------------------------------------- inline constexpr TypeOps type_ops[TAG__COUNT] = { /*BOOL */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(BOOL_t) }, /*SINT */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(SINT_t) }, /*USINT */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(USINT_t) }, /*INT */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(INT_t) }, /*UINT */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(UINT_t) }, /*DINT */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(DINT_t) }, /*UDINT */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(UDINT_t) }, /*LINT */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(LINT_t) }, /*ULINT */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(ULINT_t) }, /*REAL */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(REAL_t) }, /*LREAL */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(LREAL_t) }, /*BYTE */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(BYTE_t) }, /*WORD */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(WORD_t) }, /*DWORD */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(DWORD_t) }, /*LWORD */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(LWORD_t) }, /*TIME */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(TIME_t) }, /*DATE */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(DATE_t) }, /*TOD */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(TOD_t) }, /*DT */ { &force_impl, &unforce_impl, &read_impl, &write_impl, sizeof(DT_t) }, /*STRING */ { &force_string, &unforce_string, &read_string, &write_string, DEBUG_STRING_WIDTH }, /*WSTRING */ { &force_wstring, &unforce_wstring, &read_wstring, &write_wstring, DEBUG_WSTRING_WIDTH }, }; // --------------------------------------------------------------------------- // Per-project tables are declared in `debug_table.hpp` (which we // include above). They live there — not here — because the table-emit // translation unit (`generated_debug.cpp`) needs the `extern` // declarations to force external linkage on its `const` definitions, // and pulling `debug_dispatch.hpp` into generated_debug.cpp drags // `` → `` into a TU that names user // variables. See debug_table.hpp's preamble for the rationale. // // On AVR these tables are in PROGMEM; the accessors below use // pgm_read_*_far() when the chip exposes RAMPZ (Mega2560, ATmega32U4, // ATmega1280, etc.) and fall back to near pgm_read_word() on the // atmega328p / atmega168 family (Uno, Nano, Pro Mini), whose entire // flash always fits in 16 bits. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // read_entry(): fetches Entry for (array_idx, elem_idx). // On AVR uses PROGMEM reads (far when the chip has RAMPZ, near otherwise); // elsewhere a plain array access. Returns {nullptr, 0} on out-of-bounds so // callers can cheaply check. // // `defined(RAMPZ)` is the same predicate avr-libc's uses to // gate declarations of `pgm_read_*_far` and `pgm_get_far_address`. Chips // without RAMPZ (atmega328p / atmega168 family — Uno, Nano, Pro Mini) lack // the ELPM instruction and the avr-libc headers don't expose the _far // variants, so referencing them is a hard compile error. Chips with RAMPZ // (atmega2560 — Mega, atmega1280, atmega32u4 — Micro / Leonardo, etc.) // keep the far-addressing path since their tables may live above 64 KB // (Mega) or because the same code is benign-but-correct when flash is // ≤64 KB (32u4: ELPM with RAMPZ=0 behaves as LPM). // --------------------------------------------------------------------------- inline Entry read_entry(uint8_t arr, uint16_t elem) noexcept { Entry out{nullptr, 0, 0}; if (arr >= debug_array_count) return out; #if defined(__AVR__) && defined(RAMPZ) // Fetch elem count (uint16_t in PROGMEM) first uint32_t counts_base = pgm_get_far_address(debug_array_counts); uint16_t count = pgm_read_word_far(counts_base + arr * sizeof(uint16_t)); if (elem >= count) return out; // Fetch Entry* (pointer-to-PROGMEM, 16-bit on AVR but stored in far flash) uint32_t arrays_base = pgm_get_far_address(debug_arrays); // pointers in PROGMEM are 16-bit near pointers on AVR (entry arrays live // in their own PROGMEM regions which near pointers can still reach, since // each array < 32 KB. But debug_arrays itself can be far.) uintptr_t table_ptr = pgm_read_word_far(arrays_base + arr * sizeof(void*)); // Read the 4-byte Entry. We assume the array is in the lower 64 KB; if // it's past, we would need pgm_read_word_far on the element too. For // Phase 4a we accept the <64 KB constraint per entry array. const uint8_t* entry_addr = reinterpret_cast(table_ptr) + elem * sizeof(Entry); uintptr_t ptr_val = pgm_read_word(entry_addr); uint8_t tag_val = pgm_read_byte(entry_addr + sizeof(void*)); out.ptr = reinterpret_cast(ptr_val); out.tag = tag_val; #elif defined(__AVR__) // AVR without RAMPZ — flash is ≤64 KB on these chips, so every PROGMEM // address fits in a 16-bit pointer and near accessors are sufficient. uint16_t count = pgm_read_word(&debug_array_counts[arr]); if (elem >= count) return out; const Entry* table = reinterpret_cast(pgm_read_word(&debug_arrays[arr])); const uint8_t* entry_addr = reinterpret_cast(table) + elem * sizeof(Entry); uintptr_t ptr_val = pgm_read_word(entry_addr); uint8_t tag_val = pgm_read_byte(entry_addr + sizeof(void*)); out.ptr = reinterpret_cast(ptr_val); out.tag = tag_val; #else uint16_t count = debug_array_counts[arr]; if (elem >= count) return out; out = debug_arrays[arr][elem]; #endif return out; } // --------------------------------------------------------------------------- // Per-entry operations. These are what ModbusSlave / Runtime v4 call. // --------------------------------------------------------------------------- /** Set (force or unforce) a variable. Returns STATUS_* code. */ inline uint8_t handle_set(uint8_t arr, uint16_t elem, bool forcing, const uint8_t* bytes, uint16_t len) noexcept { Entry e = read_entry(arr, elem); if (!e.ptr || e.tag >= TAG__COUNT) return STATUS_OUT_OF_BOUNDS; if (forcing) { uint8_t expected = type_ops[e.tag].size; // size == 0 is the string stub — Phase 4a rejects for now if (expected == 0) return STATUS_DATA_TOO_LARGE; if (len < expected) return STATUS_DATA_TOO_LARGE; type_ops[e.tag].force(e.ptr, bytes); } else { type_ops[e.tag].unforce(e.ptr); } return STATUS_OK; } /** Read one variable into `dest`. Writes type_ops[tag].size bytes. * Returns bytes written, or 0 on out-of-bounds. */ inline uint16_t handle_read(uint8_t arr, uint16_t elem, uint8_t* dest) noexcept { Entry e = read_entry(arr, elem); if (!e.ptr || e.tag >= TAG__COUNT) return 0; uint8_t n = type_ops[e.tag].size; if (n == 0) return 0; // string stub type_ops[e.tag].read(e.ptr, dest); return n; } /** Soft write (non-forcing). Updates the underlying value via * IECVar::set(). If the variable is currently forced, the write is * silently ignored — forcing remains authoritative until unforced. * This matches OPC-UA / BACnet write semantics: the next scan cycle * may overwrite the written value, unlike force which pins it. * Returns STATUS_* code. */ inline uint8_t handle_write(uint8_t arr, uint16_t elem, const uint8_t* bytes, uint16_t len) noexcept { Entry e = read_entry(arr, elem); if (!e.ptr || e.tag >= TAG__COUNT) return STATUS_OUT_OF_BOUNDS; uint8_t expected = type_ops[e.tag].size; if (expected == 0) return STATUS_DATA_TOO_LARGE; // string stub if (len < expected) return STATUS_DATA_TOO_LARGE; type_ops[e.tag].write(e.ptr, bytes); return STATUS_OK; } /** Variable size for (arr, elem) — 0 if unknown/out-of-bounds. */ inline uint16_t handle_size(uint8_t arr, uint16_t elem) noexcept { Entry e = read_entry(arr, elem); if (!e.ptr || e.tag >= TAG__COUNT) return 0; return type_ops[e.tag].size; } /** Total number of arrays. */ inline uint8_t handle_array_count() noexcept { return debug_array_count; } /** Element count for a given array — 0 if `arr` out-of-bounds. * AVR branch mirrors `read_entry` above: RAMPZ-equipped chips use far * accessors, others fall back to near reads. */ inline uint16_t handle_elem_count(uint8_t arr) noexcept { if (arr >= debug_array_count) return 0; #if defined(__AVR__) && defined(RAMPZ) uint32_t counts_base = pgm_get_far_address(debug_array_counts); return pgm_read_word_far(counts_base + arr * sizeof(uint16_t)); #elif defined(__AVR__) return pgm_read_word(&debug_array_counts[arr]); #else return debug_array_counts[arr]; #endif } } } // namespace strucpp::debug // --------------------------------------------------------------------------- // C-linkage shims for the OpenPLC Runtime v4 .so interface. // // The runtime dlopen()s a libplc_.so and dlsym()s these symbols to // speak the debug protocol without needing the C++ strucpp::debug namespace. // // Usage: in the .so's packaging step (Phase 5), compile ONE .cpp with // // #define STRUCPP_V4_DEBUG_EXPORTS_DEFINE // #include "debug_dispatch.hpp" // // The symbols use `attribute((used, visibility("default")))` so they're // retained even under LTO and appear in the dynamic symbol table. // // Embedded targets (Arduino) should NOT define the macro — the Flash cost // of these extra symbols is unnecessary there (the ModbusSlave calls // handle_* directly via C++ linkage). // --------------------------------------------------------------------------- #ifdef STRUCPP_V4_DEBUG_EXPORTS_DEFINE #define STRUCPP_V4_EXPORT __attribute__((used, visibility("default"))) extern "C" { STRUCPP_V4_EXPORT uint8_t strucpp_debug_array_count(void) { return strucpp::debug::handle_array_count(); } STRUCPP_V4_EXPORT uint16_t strucpp_debug_elem_count(uint8_t arr) { return strucpp::debug::handle_elem_count(arr); } STRUCPP_V4_EXPORT uint16_t strucpp_debug_size(uint8_t arr, uint16_t elem) { return strucpp::debug::handle_size(arr, elem); } STRUCPP_V4_EXPORT uint8_t strucpp_debug_set(uint8_t arr, uint16_t elem, bool forcing, const uint8_t *bytes, uint16_t len) { return strucpp::debug::handle_set(arr, elem, forcing, bytes, len); } STRUCPP_V4_EXPORT uint16_t strucpp_debug_read(uint8_t arr, uint16_t elem, uint8_t *dest) { return strucpp::debug::handle_read(arr, elem, dest); } STRUCPP_V4_EXPORT uint8_t strucpp_debug_write(uint8_t arr, uint16_t elem, const uint8_t *bytes, uint16_t len) { return strucpp::debug::handle_write(arr, elem, bytes, len); } } // extern "C" #undef STRUCPP_V4_EXPORT #endif // STRUCPP_V4_DEBUG_EXPORTS_DEFINE