RPC Server Implementation & Code Generation#
This is the third devlog in the development story of WindRPC, a lightweight Remote Procedure Call (RPC) framework designed for communication between microcontrollers and host applications.
https://github.com/micro-artwork/windrpc
Introduction#
Before creating a full server automation tool, it was crucial to build and optimize the C server engine for MCU environments. Only by writing the server implementation by hand could I understand how user-defined services and procedures should be generated and laid out in C.
Manual RPC Implementation & Module Architecture#
I began by defining the essential components required for an MCU RPC server and organizing them into distinct modules.
The core architecture of the C server engine was structured as follows:
windrpc.c(Main Dispatcher): The framework entry point that parses incoming binary packet headers and manages the event processing loop.- Handler Module: The packet processor that decodes incoming requests, triggers business logic, and encodes return values into response packets.
- User Callback Module: The interface where firmware developers write application-specific logic (e.g., LED color controls, power status queries).
- Asynchronous Notification Module (Notify): A dedicated module allowing the server to push events (e.g., sensor changes, hardware alerts) to clients without waiting for explicit client polling.
After implementing these core modules, I wrote test cases to verify the basic packet transport, routing, and dispatching pipeline.
Extracting Patterns & Refactoring with C Macros#
Once the manual C server implementation was working, I refactored the codebase to eliminate repetitive code.
Analyzing the server code revealed a massive amount of boilerplate code repeated for every procedure call when inspecting tags, decoding payloads, and building responses.
I extracted these repeated patterns into refined C Preprocessor Macros:
- Tag & Type Alias Wrapping: Abbreviating NanoPB’s excessively long C type and tag names (
hlt_windrpc_service_led_Request_display_pixels_tag) using macro concatenation (##). - Boilerplate Generation: Automating repetitive decode switch-case statements and struct initialization routines via concise macro declarations.
// Example C preprocessor macro pipeline (windrpc_defs.h)
#define WINDRPC_COMMAND_ID(SERVICE, COMMAND) \
WINDRPC_CAT(WINDRPC_PACKAGE_NAME, _windrpc_core_RpcId_RPC_ID_, SERVICE, _, COMMAND)
#define WINDRPC_DECODE_SERVICE_ENTRY(service) \
case WINDRPC_SERVICE_REQUEST_TAG(service): { \
WINDRPC_SERVICE_REQUEST_TYPE(service) *msg = (WINDRPC_SERVICE_REQUEST_TYPE(service) *)field->pData; \
msg->cb_command.funcs.decode = decode_service_##service; \
msg->cb_command.arg = ctx; \
return pb_decode(stream, WINDRPC_SERVICE_REQUEST_FIELDS(service), msg); \
}
Template & Pattern Code Injection#
After isolating the repetitive parsing and decoding patterns, the fixed framework skeleton was preserved as a template, while variable code fragments were injected based on the service/procedure definitions in user_spec.yml:
- Framework Core Template: The event loop, transaction management, and primary dispatch logic remained fixed in static template files.
- Dynamic Pattern Code Injection: Generating only the necessary macros, decoding routines, and handler dispatchers specified in
user_spec.ymland injecting them into template placeholders.
Combining a fixed template framework with dynamic pattern code injection meant that as new services or procedure calls were added, the generator simply injected missing pattern blocks without rewriting core framework plumbing.
Structuring the Procedure Pipeline (Decode -> Execute -> Encode)#
Alongside macro abstractions, the procedure execution pipeline was structured into three clear steps to ensure a uniform lifecycle for all RPC calls:
- Decode: Parse incoming binary packets, deserialize into C structs, and validate arguments.
- Execute: Invoke the registered application callback function to perform actual business logic.
- Encode: Serialize return values and status codes into response packets and transmit over the wire.
The runtime environment was designed to execute this 3-step pipeline sequentially for every RPC call.
This allowed firmware developers to focus exclusively on writing the application logic for the Execute stage, while the framework automatically managed incoming decoding and outgoing response encoding.
// 1. 3-step procedure pipeline structure (windrpc.h)
struct windrpc_procedure {
bool (*decode_cmd)(pb_istream_t *stream, const pb_field_t *field, void **arg); // 1) Decode
int32_t (*execute)(struct windrpc_operation *operation, void *context); // 2) Execute (Callback)
void (*encode_res)(windrpc_response_msg_t *message, void *context); // 3) Encode
};
// 2. Service procedure structs and user bindings
struct windrpc_service_power {
struct windrpc_procedure read_power_info;
struct windrpc_procedure subscribe_power_info;
};
struct windrpc_user_service {
struct windrpc_service_led *led;
struct windrpc_service_power *power;
};
// 3. Application callback implementation executed during the Execute stage
int32_t windrpc_on_power_read_power_info(struct windrpc_operation *op, void *context) {
// Application business logic written by firmware developer
op->server_msg.response.power.voltage_mv = 3300;
op->server_msg.response.power.is_charging = true;
return 0; // Success return automatically triggers the Encode stage and sends response
}
Generator Adoption & Initial Limitations#
Adopting a template-based code generator made generating server skeleton code significantly easier.
However, during early development, frequent changes to RPC message fields required corresponding updates to server logic, making initial maintenance somewhat tedious. Over time, as RPC message structures stabilized, the server generator proved to be a highly reliable tool.
One remaining limitation was handling variable-length dynamic data (strings, repeated arrays), which still required NanoPB decoding callbacks or unfamiliar data structures. Despite these early constraints, this initial version of WindRPC was integrated into the Bitnari project and put to real-world use.
Conclusion#
After validating the PoC, personal commitments forced a hiatus for both the Bitnari project and WindRPC development.
When work finally resumed long afterward, major architectural evolutions took place for the WindRPC framework.
In the next post, I will detail how the project resumed and achieved full multi-client code generation and a streamlined Flat architecture.
