The machinery I use (with GCC or Clang, since these require the toolchain to use ELF format object files), means that if I want to expose say
static int beef = 1;
to my query-response interface, all I need to do is include a header file defining some structures and macros, and add
EXPOSE_INT(&beef, 0xBEEF);
where 0xBEEF is the token identifier (perfect hash of the name) I use in the query-response interface for it.
That, and an input parser/tokeniser and dispatcher, is all that is needed, really.
The key parts of that included header file are
#include <stdint.h>
struct exposed {
uint32_t ident;
union {
void (*func)(void);
int *iptr;
/* Other pointer types omitted for brevity */
};
} __attribute__((packed));
#define MERGE2_(a, b) a ## b
#define MERGE2(a, b) MERGE2_(a, b)
#define EXPOSE_(varname, vtype, vname, member, ptr) \
__attribute__((used, section ("exposed"))) \
static const struct exposed varname = { \
.ident = (((uint32_t)(vtype) & 0x1F) << 27) \
| ((uint32_t)(vname) & 0x07FFFFFF), \
.member = (ptr) \
}
#define EXPOSE_FUNC(fn, hash) \
EXPOSE_(MERGE2(expose_, __LINE__), 0, hash, func, fn)
#define EXPOSE_INT(ptr, hash) \
EXPOSE_(MERGE2(expose_, __LINE__), 1, hash, iptr, ptr)
/* Up to 30 other types omitted for brevity */
The union is only used for type checking, so that if you use e.g char step; and then EXPOSE_INT(&step, 0xDEAD), you get a warning about incompatible pointer types.
Above, the high 5 bits of ident specify the type, and low 27 bits the perfect hash or encoding of the name. (For example, five base-40 characters.)
Each EXPOSE_INT() or EXPOSE_FUNC() declaration adds a struct exposed entry (in pseudo-random order) to the exposed section in the final binary, where they all can be accessed. This means that I can expose even static variables, without global linkage, and it only needs a single declaration in that same compilation unit. It also works very well if one uses preprocessor conditionals or Makefile magic to select what code gets included in the final binary. Wherever the variable or function is defined, you just add an expose macro next to it: very low cognitive overhead.
In the linked binary, we can use the following to look up any exposed item:
extern const struct exposed __start_exposed[];
extern const struct exposed __stop_exposed[];
const struct exposed *find_exposed(uint32_t hash)
{
const uint32_t mask = 0x07FFFFFF;
const struct exposed *curr = __start_exposed;
/* Ensure hash has no type bits set. */
hash &= mask;
while (curr < __stop_exposed)
if ((curr->ident & mask) == hash)
return curr;
else
curr++;
return NULL;
}
The two extern symbols are defined by the linker. The section contains all expose declarations from all compilation units linked to the final object.
If find_exposed(hash) returns a non-NULL pointer p, then (p->ident>>27) identifies the type (the number in the EXPOSE_INT(), EXPOSE_FUNC() macros).
All of the above gets placed in ROM/Flash; no additional RAM is used. (If you use your own linker script, this is the exposed section. Do also define the __start_exposed and __stop_exposed symbols, as is done by default linker scripts.)
The find_exposed() operation can be made faster by adding a post-processing step in your build, where the order of the entries in the exposed section is sorted (by the 27 low bits of ident members, here). That would allow you to use a binary search, as I mentioned before. You could also store the strings, and in the post-processing step replace the strings with their corresponding hashes, even if it changes the size of the exposed section. (The annoying thing is the relocation records, and editing them. Raw binary data is trivial, but relocation records –– the pointers to the variables or functions –– are harder to manipulate using objcopy only.) (Also note that this latter struct exposed does not need to have the same format as earlier; it only needs to match the possibly post-processed exposed section contents.)
In addition to above, you need the input parser, of course. But all it needs, really, is to parse (hash) the first name, look it up, and decide what to do with the rest of the input request/command –– but knowing the type and address of the thing being referred to. In particular, an implementation that supports
name '?' (query)
name '=' VALUE (assignment)
name (function call without parameters)
name VALUE (function call with an integer parameter)
can parse the input character by character using a straightforward state machine, without any kind of string buffer at all. VALUE can be an integer, fixed-point, or even a name, depending on what your exact needs are; but integer-only is the simplest. Adding multiple-parameter function calls requires a slightly more complicated state machine, and RAM storage for the parameters, a stack of VALUEs.
This does come in useful, when you have an external device –– a sensor, or actuator of some kind, for example –– and you are not sure of the optimal parameters to use with that device. Or you are playing with e.g. PWM capabilities of the MCU.
Instead of making everything externally programmable, you just declare the variables you want to get and set, and the functions you want to call, and your input parser stuff should handle the details.
(As I often use Teensies for this kind of experimentation, and they all have native USB interfaces, I can also use an "extra" USB Serial endpoint for this kind of communication between my computer and the microcontrooler. The actual firmware can be a bulk USB device, a HID device, or whatever I happen to need.)
In a design sense, this is very close to attaching a debugger to your microcontroller, and using it to modify the RAM state or execute specific functions.
For playing with controlling external devices, I like this approach better than a debugger, because this way the microcontroller firmware runs normally and is always in full control, instead of being under the debugger control. This minimizes the differences between "normal operation" and "exploring settings", and thus ensures the results found via such exploration are consistent even when used with this machinery later on.