Skip to content

Release v0.6.8 - #239

Merged
thiagoralves merged 5 commits into
mainfrom
development
Sep 18, 2026
Merged

thiagoralves merged 5 commits into
mainfrom
development

Conversation

@thiagoralves

Copy link
Copy Markdown
Contributor

Release v0.6.8 — patch bump over v0.6.7.

Merges development into main to cut the release. New since v0.6.7:

  • feat(debug): address a leaf's value in place instead of copying it out — the live-pointer accessor (type_ops[].ptr) that OpenPLC's baremetal OPC-UA zero-copy read depends on (feat(debug): address a leaf's value in place instead of copying it out #238).
  • fix(debug): review fixes on the live pointer accessor.
  • perf(debug): split the pointer op into its own table so AVR firmware isn't charged for it when unused.

Tagging v0.6.8 on the merge commit kicks the build-and-release workflow (publishes strucpp-0.6.8.tgz + platform binaries). The OpenPLC editor/web strucpp pin is bumped to v0.6.8 in a follow-up on their RTOP-285 branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NegApcNt2SzRk3FDAs5T9p

autonomylogic and others added 5 commits September 16, 2026 14:58
`handle_read` copies a value into a caller's buffer. That is the right shape
for a Modbus frame, but wrong for a protocol stack that can serve memory it
does not own: OPC-UA hands open62541 a `UA_Variant` and, marked
`UA_VARIANT_DATA_NODELETE`, it will neither copy nor free it. Today the
baremetal server allocates from a ~19 KB arena for every value of every read,
and multiplies that by `maxNodesPerRead`.

So `TypeOps` gains a sixth op and `handle_ptr(arr, elem, &len)` exposes it: the
same value `handle_read` would produce, addressed where it already lives, with
its length in bytes. STRING hands back the payload rather than the
`[len][payload]` wire form, and WSTRING the code-unit buffer, so `len` is bytes
there, not characters.

The op reads through a new `IECVar::read_ptr()` rather than `raw_ptr()`. They
agree the instant a force is applied, because `force()` writes through to
`value_` — but a located variable is driven by the program straight into
`value_` every scan, and only `read_ptr()` still resolves to the forced value
afterwards. `raw_ptr()` would have leaked the program's value past an active
force, silently and only for located variables. It mirrors
`IECStringVar::c_str()`, which already resolves the force this way.

`read_wstring` splits each code unit explicitly so the WIRE format is
little-endian on any host. A pointer cannot do that — the caller gets host
order — so `ptr_wstring` carries a `static_assert` on `__BYTE_ORDER__` instead
of silently serving byte-swapped text on a big-endian target.

Not exported through the `strucpp_debug_*` C ABI. The pointer is into live PLC
storage, safe only for a caller inside the scan's own thread of control — true
of the baremetal super-loop, false of the Runtime v4 `.so`, where the scan has
its own thread. Linux callers keep `strucpp_debug_read()`.

The test compiles real generated code and checks the properties that matter:
the pointer agrees with `handle_read`, follows a later write with no second
call, resolves a force the same way, keeps resolving it after the program
writes the located storage directly, and returns null rather than crashing for
an out-of-range leaf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NegApcNt2SzRk3FDAs5T9p
Eight items from the review on #238.

**The endianness assert took the header down with it (blocker).** It sat at
namespace scope and unconditional, so it fired when `debug_dispatch.hpp` was
INCLUDED, not when `ptr_wstring` was used: a big-endian build of firmware that
only ever calls handle_read/handle_write stopped compiling, and MSVC (which
defines no `__BYTE_ORDER__`) failed regardless of endianness. Reproduced both.
It is now a compile-time selection — on a target that cannot be served, the op
returns nullptr with *len = 0, which is already handle_ptr's contract for "no
pointer available", and routes the caller to the copying path the old assertion
text told them to use anyway.

**A parameterised string was registered wrong rather than not at all.** Every
string op casts the leaf to `IECStringVar<254>`, but codegen emits the DECLARED
size, and `length_` sits at a different offset for every `n`. Measured on
`STRING(20) := 'hello'`: sizeof 50 vs 518, length read from 468 bytes past the
end. It read 0 there — but with non-zero neighbouring memory it reads as the
cap, and the read path then serves that many bytes of adjacent memory. `Entry`
has nowhere to carry a per-leaf width, so the generator now SKIPS such a leaf
with a reason instead of registering it. Nothing authored in the editor can
reach this (its type picker is a closed list from the IEC registry, which has
no length parameter) — it is hand-written ST through the CLI.

**Skipped leaves were silent.** `skipped` was collected and dropped, so a
variable was simply absent from the debugger and from OPC-UA with nothing in the
build to say why. It is now a warning per leaf, like `incomplete` already was.

**The pointer ops no longer truncate at DEBUG_STRING_CAP.** That cap is the
Modbus debug FRAME budget — one length byte and a fixed window. A pointer has
neither, and OPC-UA carries its own length, so clamping there silently shortened
a 200-character string to 126 on a transport that never asked for it. Verified:
200 chars now arrive whole, and an empty string still returns a non-null pointer
with length 0.

**ptr_wstring had no test.** Added the byte-vs-code-unit arithmetic checked
against what read_wstring puts on the wire, empty STRING and WSTRING (a valid
value, not a miss), and a forced STRING through handle_ptr. Confirmed they fail
when the `* 2` is removed.

Also: the lifetime contract now says a pointer dies when the force state changes
(read_ptr returns a different object after unforce), `docs/RUNTIME.md` documents
`read_ptr()` beside `raw_ptr()`, the type_ops comment no longer claims to be
flash-resident on AVR and carries the measured cost of the new column, and the
table's column alignment is restored.

2399 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NegApcNt2SzRk3FDAs5T9p
S8 from the review, now measured on target rather than argued.

The reviewer asked whether the new `ptr` column moved the smallest supported
target's footprint, and could not measure it — nor could I at first, because the
Arduino AVR toolchain ships no C++ standard headers. It does build: the editor
carries the shim at `resources/sources/avr-libstdcpp/include`, and its simulator
compiles for `arduino:avr:mega` (an ATmega2560) through arduino-cli. Same sketch,
same runtime tree, only calling handle_read/handle_write:

    with the ptr column      425 B SRAM   5128 B flash
    without it (baseline)    383 B SRAM   4654 B flash

So yes: +42 bytes of SRAM and ~474 of flash, on every AVR firmware, for an op
only the baremetal OPC-UA server calls. `type_ops` carries no
STRUCPP_DEBUG_FLASH, so on a Harvard target it is .rodata copied into SRAM at
startup — "inline constexpr" does not make it flash-resident.

The fix is not to move `type_ops` to PROGMEM (that would route every row read
through pgm_read_ptr in the hot path of handle_read/handle_write/handle_set,
which is a change that deserves its own evidence). It is to stop the pointer op
being a column at all: `ptr_ops[]` is a table of its own, read only by
handle_ptr. Nothing references it unless handle_ptr is called, so
-ffunction-sections/-fdata-sections + --gc-sections drop both the table and the
ptr_impl<T> instantiations.

Measured after the split, same sketch:

    never calls handle_ptr   383 B SRAM   4728 B flash
    calls handle_ptr         425 B SRAM   5196 B flash

The feature now costs what it costs, and only to firmware that uses it. The
consumers are unaffected — the baremetal glue calls handle_ptr and never touches
the table.

2399 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NegApcNt2SzRk3FDAs5T9p
…nter-accessor

feat(debug): address a leaf's value in place instead of copying it out
Keep the committed version in step with the release tag. The release
workflow injects the version from the tag at build time, so this is for
consistency rather than function.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NegApcNt2SzRk3FDAs5T9p
@thiagoralves
thiagoralves merged commit d12dfa0 into main Sep 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants