Page 1 of 1

ESP-Modbus Slave Register Management: Seeking Elegant Access Syntax While Keeping Segmented Descriptors

Posted: Sat Jun 06, 2026 10:53 am
by PETERESP
ESP-Modbus Slave Register Management: Seeking Elegant Access Syntax While Keeping Segmented Descriptors

Introduction

Working with the ESP-Modbus slave stack on ESP-IDF, I've encountered a familiar tension:

- The stack's descriptor limitation: A single Modbus request cannot span multiple mb_register_area_descriptor_t regions, even if they are contiguous and fully defined. This forces us to either: merge everything into one monolithic descriptor (loses modularity) or force the master to split reads manually (pushes complexity to the wrong side).

- The real-world need: Many of us have large, heterogeneous register maps with different access patterns (e.g., windowed arrays for multi‑channel data, static configuration parameters, rarely‑accessed calibration values). To keep the slave flexible, we split these into separate memory blocks – each registered as its own descriptor.

- The coding pain: When we have dozens of global structure instances like holding_regs_600_t, holding_regs_800_t, etc., accessing their members becomes verbose:

Code: Select all

uint16_t val = baochi_jicunqi_600.chuliaoshichang_td[i];
baochi_jicunqi_800.wuchafanwei_td[j] = new_val;
We want to write concise, direct code like "val = g.chuliaoshichang_td" or even "val = g.window_data" – without repeating the intermediate structure name.

This post is a call for ideas from the community. I'll summarise what I've learned about anonymous structs, compiler extensions, and the constraints we face. Then I'll ask: How do you manage large, segmented register maps while keeping your code clean and maintainable?


Background: The ESP-Modbus Descriptor Limitation

For those new to ESP-Modbus, here's a quick recap:

- The slave stack uses register area descriptors (mb_register_area_descriptor_t) to map Modbus addresses to memory.
- You can register multiple descriptors for the same register type (e.g., MB_PARAM_HOLDING), each with a different start_offset.
- Important: A single Modbus request (e.g., read 20 contiguous registers) will succeed only if all requested registers fall inside exactly one descriptor. If they span two separate descriptors – even if those descriptors are and cover the range without gaps – the request is rejected with an exception.

This is a known behaviour (I've posted about it separately), but it’s not documented explicitly. It forces awkward workarounds.


Why We Can’t Just Use One Monolithic Descriptor

Many of us have dynamic data that must be windowed. For example:

- A 2‑D array channel_data[8][500] – we expose only the current channel’s slice via a window register.
- The windowed area changes when the master writes a “page” register.
- The rest of the register map contains static parameters, status flags, etc.

If we put everything into one big array, we lose the ability to map the windowed slice independently – because the window would occupy the same physical memory as other variables. Therefore we must keep separate memory blocks:

- One block for the windowed region (e.g., holding registers 2000‑2099)
- Another block for static parameters (e.g., 3000‑3999)
- Possibly more blocks for calibration data, diagnostic logs, etc.

Each block is registered with its own descriptor. The master can access each block individually – but cannot read across block boundaries in one request.


The Coding Annoyance: Verbose Access to Many Structure Instances

With separate blocks, we inevitably have separate C structure instances:

Code: Select all

holding_regs_600_t   baochi_jicunqi_600;   // registers 600‑639
holding_regs_800_t   baochi_jicunqi_800;   // registers 800‑839
holding_regs_900_t   baochi_jicunqi_900;   // registers 900‑939
// ... many more
In our application code, we frequently need to read or write members of these structures. The code becomes littered with long names:

Code: Select all

baochi_jicunqi_600.chuliaoshichang_td[i] = some_value;
value = baochi_jicunqi_800.wuchafanwei_td[j];
We would much prefer something like:

Code: Select all

g.chuliaoshichang_td[i] = some_value;    // g is a single global "container"
value = g.wuchafanwei_td[j];
Where "g" somehow “contains” all the members of all the individual structures, without merging their underlying memory blocks.


Research: What the C Language (and Compilers) Offer

I've been looking into ways to “flatten” multiple independent structures into a single, syntactically unified view, while keeping each block's memory separate. The obvious tool is anonymous structs/unions (a GNU/MS extension).

1. Anonymous structs / unnamed fields

GCC and Clang (with -fms-extensions) allow you to place an unnamed structure inside another structure. The inner members become directly accessible as if they were members of the outer structure.

Example:

Code: Select all

struct Outer {
    int a;
    struct {        // unnamed struct
        int x;
        int y;
    };
    int b;
};

struct Outer o;
o.x = 10;   // directly accessible, no intermediate name
This is exactly the syntax we want! However, there is a catch: you cannot take the address of an unnamed field, because it has no name. And for Modbus descriptor registration, we do need the address and size of each sub‑block (e.g., &baochi_jicunqi_600 and sizeof(holding_regs_600_t)).

If we embed an unnamed struct inside a big container, we cannot later say "&container.some_subblock" – there is no "some_subblock" identifier.

2. Using a named member + convenience macro

We could keep a named member (e.g., s600) but then write macros or inline functions to shorten access:

Code: Select all

#define chuliaoshichang_td (baochi_jicunqi_600.chuliaoshichang_td)
This works but pollutes the global namespace and must be repeated for every member – not a clean solution.

3. Pointer aliases

We could create pointers to each member array and then use those pointers. Again, this adds boilerplate and doesn’t give a single container.

4. -fms-extensions and “tagged” unnamed fields

GCC's manual "Unnamed Fields" states that with -fms-extensions, you can also use a tagged struct definition (e.g., "struct nested { int a; };") as an unnamed field. The field still has no name, but the tag is known. However, you still cannot obtain its address via a member name.

Clang’s behaviour is similar, as seen in the ms-anonymous-struct.c test – the nested struct is flattened.

5. Could we register the outer container and then use offsets?

If we put all our memory blocks into one giant structure (contiguous in memory), we could register only one big descriptor. But that would defeat the purpose of having separate blocks – because the windowed region would be fixed in memory and couldn’t be remapped to different backend arrays. So this is not acceptable for dynamic windowing.

6. Recent developments: Linux kernel may enable -fms-extensions by default

An interesting news article reports that Linux 6.19 may enable -fms-extensions in the kernel build. This reflects a growing acceptance of these non‑standard extensions for cleaner code organisation. However, it doesn’t solve our addressing problem.


The Core Dilemma Summarised

1. Concise access to scattered register blocks
- Desired syntax: "g.member" instead of "blockX.member"
- Requirement: Members from different blocks appear as direct members of a single container "g".

2. Keep each block's memory independent (needed for windowing)
- Requirement: Blocks must be separate variables/arrays, not merged into one contiguous region.

3. Register each block as a separate Modbus descriptor
- Need: Address and size of each block
- Requirement: Must have a named identifier for each block (to pass to mbc_slave_set_descriptor).

These three points appear contradictory:

- If we flatten members using anonymous structs, we lose the per‑block identifiers.
- If we keep named blocks, we cannot flatten them without losing independence.


What I’m Asking the Community

I’m not looking for a ready‑made library – I’m looking for ideas, patterns, or clever uses of the C preprocessor/compiler extensions that can bring us closer to both worlds.

Specifically:

1. Has anyone successfully used anonymous structs together with a scheme to obtain the address of each sub‑block (e.g., via offsetof and the container address) without naming the sub‑block?
- For example, could we register "&container + offset_of_subblock" and "sizeof(subblock_type)"?
- Is it safe to compute such pointers, given alignment and possible padding?

2. Are there other non‑standard extensions (e.g., __attribute__((packed)), __typeof__, or compound literals) that could help?

3. Would you simply accept the verbose access and focus on other parts of the code? Or do you have a different high‑level design that avoids the problem altogether (e.g., using the slave’s event API to buffer windowed data)?

4. Any experience with patching esp-modbus to allow cross‑descriptor requests? I’m aware this would be a major change, but I’m curious if someone has attempted it and what the complexity was.

5. Do you know of any other embedded Modbus stacks (for other MCUs) that handle multiple registration areas more gracefully? Perhaps we could learn from their API design.


Resources and Further Reading

I’ve collected several links that helped me understand the landscape of anonymous structs, compiler extensions, and compatibility issues. They might be useful for others diving into this:

- GCC documentation on Unnamed Fields: https://gcc.gnu.org/onlinedocs/gcc-12.5 ... ields.html
- Clang test for MS anonymous structs: https://sources.debian.org/src/llvm-too ... -struct.c/
- LLVM mailing list discussion about unnamed structs and member lookup: https://lists.llvm.org/pipermail/cfe-de ... 58655.html
- Microsoft docs on anonymous class types: https://learn.microsoft.com/en-us/cpp/c ... w=msvc-170
- CSDN Q&A (mixed quality, but highlights cross‑platform pitfalls): https://ask.csdn.net/questions/8890011
- Linux kernel considering enabling -fms-extensions: https://www.toutiao.com/article/7571846464852083263/
- My earlier post on the descriptor limitation (for context) – (already referenced)


Closing Thoughts

The ESP-Modbus slave stack is otherwise solid and well‑designed. The descriptor limitation is a known wart, but we’ve learned to live with it by splitting register maps. Now the remaining challenge is ergonomic: how to write clean, maintainable code that accesses dozens of segmented register variables without drowning in nested structure names.

I believe there must be a clever combination of preprocessor macros, anonymous structs, and careful pointer arithmetic that gives us the best of both worlds. But I haven’t found it yet.

I’m hoping the collective wisdom of this forum can provide new angles, share real‑world examples, or simply tell me “it’s not worth it, just write verbose code”. Either way, I’ll be grateful.

Thank you for reading this long post. I look forward to your insights!

(This post is intended to be a collaborative exploration – please share your experiences, code snippets, or links to other discussions.)

Re: ESP-Modbus Slave Register Management: Seeking Elegant Access Syntax While Keeping Segmented Descriptors

Posted: Sun Jun 07, 2026 11:43 pm
by Sprite
You could use unnamed members and then offsetoff(first_member) to get the byte offset of a block (and the same trick for the length).

Re: ESP-Modbus Slave Register Management: Seeking Elegant Access Syntax While Keeping Segmented Descriptors

Posted: Mon Jun 08, 2026 11:41 am
by MicroController
Can you use C++?

Code: Select all

struct all_holding_regs_t : public holding_regs_600_t, holding_regs_800_t, holding_regs_900_t {
};