ESP-Modbus Slave Register Management: Seeking Elegant Access Syntax While Keeping Segmented Descriptors
Posted: Sat Jun 06, 2026 10:53 am
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:
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:
In our application code, we frequently need to read or write members of these structures. The code becomes littered with long names:
We would much prefer something like:
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:
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:
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.)
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;
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
Code: Select all
baochi_jicunqi_600.chuliaoshichang_td[i] = some_value;
value = baochi_jicunqi_800.wuchafanwei_td[j];
Code: Select all
g.chuliaoshichang_td[i] = some_value; // g is a single global "container"
value = g.wuchafanwei_td[j];
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
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)
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.)