Integration & setup#
pw_rpc: Efficient, low-code-size RPC system for embedded devices
This guide is designed for project owners, platform engineers, and system architects
who are setting up the pw_rpc infrastructure for their project—especially if you
are integrating pw_rpc into an existing codebase, a custom RTOS, or a bare-metal
environment.
While application engineers define .proto services and implement business
logic, the platform owner is responsible for building the underlying communication
plumbing:
Integrating core Pigweed prerequisites (asserts, logging, synchronization).
Choosing physical transports and mapping them to Channels.
Implementing
pw::rpc::ChannelOutputto handle packet transmission (TX).Creating the ingress (RX) task or polling loop to unframe packets and pass them to
pw::rpc::Server::ProcessPacket().Registering services with the RPC Server.
(Optional) Configuring MCU-to-MCU C++ Clients or dual-role
pw::rpc::ClientServerendpoints.(Optional) Setting up Host tooling (Python, TypeScript, Java/Android).
System architecture#
The system architecture separates the concerns between Pigweed-provided core libraries (protocol handling, packet encoding/decoding, service dispatch) and the platform components you implement (transport drivers, framing, task contexts, and business logic).
1. Overall system architecture#
This high-level overview shows the boundary between customer transport plumbing,
the pw_rpc engine, and application services:
flowchart TB
classDef pw fill:#e8f0fe,stroke:#1a73e8,stroke-width:2px,color:#174ea6;
classDef user fill:#fef7e0,stroke:#f9ab00,stroke-width:2px,color:#b06000;
classDef medium fill:#f1f3f4,stroke:#5f6368,stroke-width:1px,stroke-dasharray: 4 4,color:#3c4043;
subgraph Host["Host / Client Peer"]
PeerClient["Client / Host Tooling (PIGWEED + YOUR SCRIPTS)"]:::pw
end
subgraph Transport["Physical Transport"]
Medium["Hardware Bus / Medium (UART, SPI, USB, BLE, Sockets)"]:::medium
end
subgraph Target["Target Device (Embedded MCU)"]
direction TB
subgraph IngressPlumbing["1. Transport Ingress Pipeline (YOU IMPLEMENT)"]
Ingress["• Hardware Driver (DMA / ISR / Serial)<br/>• Dispatch Context (RTOS Task or Main Loop)<br/>• Framing / Packetizer (e.g. pw_hdlc)"]:::user
end
subgraph RpcCore["2. pw_rpc Core Engine (PIGWEED)"]
RpcServer["• Packet Validation & Channel Routing<br/>• Protobuf Request Deserialization & Response Encoding<br/>• Service Dispatch"]:::pw
end
subgraph ServicePlumbing["3. Application Services (YOU IMPLEMENT)"]
AppServices["• Method Handlers (Unary & Streaming)<br/>• Device Business Logic"]:::user
end
subgraph EgressPlumbing["4. Transport Egress Pipeline (YOU IMPLEMENT)"]
Egress["• ChannelOutput::Send()<br/>• Transport Framing & Transmit Driver"]:::user
end
end
PeerClient <-->|Transmits / Receives| Medium
Medium -->|Raw Inbound Bytes| Ingress
Ingress -->|Complete Packet Buffer| RpcServer
RpcServer -->|Dispatches Request| AppServices
AppServices -->|Response / Stream Data| RpcServer
RpcServer -->|Encoded Packet Buffer| Egress
Egress -->|Framed Outbound Bytes| Medium
In an end-to-end pw_rpc system, an external peer (such as a host script or
companion MCU) transmits requests across a physical medium like UART or SPI. On
the target MCU, your transport ingress pipeline collects raw bytes, un-frames
them into discrete RPC packet buffers, and delivers them to the pw_rpc core
server. The core server verifies packet headers, decodes the protobuf payload,
and invokes the matching method handler in your registered application services.
When the service produces responses or streaming data, it sends them through the
core encoder to your pw::rpc::ChannelOutput implementation, which
frames and transmits the bytes back across the physical medium to the peer.
2. Ingress (RX) packet flow & service dispatch#
This diagram details how incoming transport data is packaged into a complete packet
buffer, passed to pw::rpc::Server::ProcessPacket(), and dispatched to the
targeted service method:
flowchart TB
classDef pw fill:#e8f0fe,stroke:#1a73e8,stroke-width:2px,color:#174ea6;
classDef user fill:#fef7e0,stroke:#f9ab00,stroke-width:2px,color:#b06000;
classDef medium fill:#f1f3f4,stroke:#5f6368,stroke-width:1px,stroke-dasharray: 4 4,color:#3c4043;
subgraph HW_Layer["1. Transport Ingress (YOU IMPLEMENT)"]
PhysRX["Physical Inbound Data"]:::medium
Driver["Transport Driver (DMA / Serial / Bus)"]:::user
Dispatch["Dispatch Context (RTOS Task or Main Loop)"]:::user
Framing["Packetizer / Framing (e.g. pw_hdlc or native packets)"]:::user
PhysRX --> Driver --> Dispatch --> Framing
end
subgraph Core_Dispatch["2. pw_rpc Ingress Engine (PIGWEED CORE)"]
ProcessPacket["Server::ProcessPacket(packet_buffer)"]:::pw
Lookup["Channel & Service / Method Lookup"]:::pw
ProtobufDecode["Protobuf Request Deserialization"]:::pw
ProcessPacket --> Lookup --> ProtobufDecode
end
subgraph Service_Targets["3. Registered Services (YOU IMPLEMENT)"]
Svc1["EchoService::Echo(request, response)"]:::user
Svc2["SensorService::GetReading(request, writer)"]:::user
SvcN["DeviceService::Reboot(request, response)"]:::user
end
Framing -->|Complete Packet Buffer<br/><i>span<const std::byte></i>| ProcessPacket
ProtobufDecode -->|Invokes Method| Svc1
ProtobufDecode -->|Invokes Method| Svc2
ProtobufDecode -->|Invokes Method| SvcN
During ingress, physical hardware transfers raw serial or packet bytes into your
device driver. A dispatch context (such as an RTOS worker task or main event loop)
feeds these bytes to a framer (e.g., HDLC) that reconstructs the boundary of each
discrete RPC packet. Once a complete buffer is formed, your dispatch loop passes
it as a span<const std::byte> to pw::rpc::Server::ProcessPacket().
The pw_rpc engine parses the packet header, verifies the channel ID, locates
the targeted service and method, deserializes the protobuf request fields, and
invokes your service method handler synchronously.
3. Egress (TX) packet flow & channel routing#
This diagram shows how responses and streaming packets from multiple independent
services re-combine into the pw_rpc core encoder, resolve the target channel,
and transmit via your pw::rpc::ChannelOutput:
flowchart TB
classDef pw fill:#e8f0fe,stroke:#1a73e8,stroke-width:2px,color:#174ea6;
classDef user fill:#fef7e0,stroke:#f9ab00,stroke-width:2px,color:#b06000;
classDef medium fill:#f1f3f4,stroke:#5f6368,stroke-width:1px,stroke-dasharray: 4 4,color:#3c4043;
subgraph Service_Origins["1. Service Execution & Client Calls (YOU IMPLEMENT)"]
Svc1["EchoService Handler<br/>(Unary Response)"]:::user
Svc2["SensorService Handler<br/>(ServerWriter Stream)"]:::user
ClientCall["MCU Client Call<br/>(ClientWriter / Request)"]:::user
end
subgraph Core_Encoding["2. pw_rpc Core Encoding & Routing (PIGWEED CORE)"]
ProtobufEncode["Protobuf Response / Payload Serialization"]:::pw
PacketAssemble["RPC Packet Assembly<br/>(Envelope Header + Payload)"]:::pw
ChannelResolve["Channel Lookup by Channel ID"]:::pw
ProtobufEncode --> PacketAssemble --> ChannelResolve
end
subgraph Transport_Egress["3. Channel & Transport Egress (YOU IMPLEMENT)"]
ChannelOut["pw::rpc::ChannelOutput::Send(packet_buffer)"]:::user
TxFraming["Framing / Driver / DMA TX"]:::user
PhysTX["Physical Outbound Medium"]:::medium
ChannelOut --> TxFraming --> PhysTX
end
Svc1 -->|Returns response| ProtobufEncode
Svc2 -->|writer.Write| ProtobufEncode
ClientCall -->|client.Invoke| ProtobufEncode
ChannelResolve -->|Calls Output| ChannelOut
During egress, service method handlers and client callers initiate transmissions
by returning unary responses, calling writer.Write() on streaming writers, or
invoking new client requests. The pw_rpc core library serializes the message
into protobuf wire format, wraps it with RPC envelope metadata (including channel
ID, service ID, method ID, and sequence numbers), and looks up the active channel.
The core then passes the assembled packet buffer directly to your
pw::rpc::ChannelOutput subclass via
pw::rpc::ChannelOutput::Send(), where your driver optionally frames the
packet and transmits it across the physical hardware.
Setup at a glance#
Setting up pw_rpc involves 7 core steps:
Step |
Topic |
What You Provide / Configure |
|---|---|---|
Pigweed Prerequisites |
Build integration, pw_assert, pw_log, pw_sync (or null backend for bare metal), Protobuf generator. |
|
Transport & Channels |
Channel ID mapping, packet MTU sizing, transport framing selection. |
|
Egress (TX) Path |
Subclass |
|
Ingress (RX) Path |
Instantiation of |
|
Service Registration |
Implement |
|
C++ Clients (Optional) |
|
|
Host Tooling (Optional) |
Python (Python client), TypeScript (TypeScript client), or Java/Android integrations. |
Tip
A detailed Deployment Checklist is provided at the end of this guide to track implementation tasks.
Step 1: Integrate Pigweed basics (Prerequisites)#
pw_rpc is designed to be lightweight and modular, but relies on a few fundamental
Pigweed building blocks.
Required modules#
Module |
Purpose in |
Notes |
|---|---|---|
Standardized error handling |
Core status codes ( |
|
Invariant checking |
Configure a backend for your platform (e.g. |
|
Diagnostic logging |
Used by |
|
Thread synchronization |
Mutex, BinarySemaphore, TimedThreadNotification backends. |
|
Memory views |
Zero-copy |
|
Intrusive lists |
Internal tracking of registered services and channels. |
Note
Bare-Metal & Single-Threaded Deployments:
pw_rpc works seamlessly on bare-metal (superloop / single-threaded) targets.
Because pw_rpc internally uses synchronization primitives for thread safety,
you only need to configure a null mutex backend (e.g. pw_sync_baremetal
or a platform null backend). With a null backend, locking operations compile
down to zero-overhead no-ops while keeping the API compatible.
Protobuf backend selection#
Choose the protobuf code generator suited for your project:
Nanopb (
.rpc.pb.h) [Recommended for most projects] – Generates lightweight C structs. Fully supports unary and streaming RPCs with minimal RAM overhead.pw_protobuf (
.rpc.pwpb.h) – Pure C++ type-safe generator. Useful when avoiding C struct code generators.Raw RPC (
.raw_rpc.pb.h) – Provides direct access to raw bytes (pw::ConstByteSpan). Best for zero-copy streaming, custom deserializers, or performance benchmarking.
Step 2: Transport layer & channel architecture#
Note
This section focuses on transport selection and system-level channel design for project integrators. For the complete C++ API reference, dynamic channel allocation, and channel ID remapping, see C++ client & server.
What is a Channel?#
In pw_rpc, a Channel (pw::rpc::Channel) represents a logical
communication pathway between a client and server. Each channel binds:
A unique integer Channel ID (
uint32_t).A
pw::rpc::ChannelOutputinterface responsible for transmitting encoded RPC packets.
Key design principles of channels#
1. Stateless and “Implied Open”#
pw_rpc channels do not perform handshake negotiations, connection setup
packets, SYN/ACK handshakes, or keep-alive pings.
A channel is considered “open” simply by existing in the channel list with a valid Channel ID and
pw::rpc::ChannelOutput.Memory Benefit: Because
pw_rpcmaintains no per-channel connection state machines, its RAM footprint is exceptionally small (only a few bytes per channel).
2. Channel limitations & transport responsibilities#
Because pw_rpc channels are intentionally minimal, your underlying transport
layer must handle certain responsibilities:
No Built-in Framing:
pw_rpcpackets are discrete byte buffers. If your physical transport is stream-oriented (like UART, SPI, or TCP), your transport layer must provide framing (e.g., HDLC via pw_hdlc, SLIP, or length-prefixed headers) to delimit packet boundaries before passing them topw::rpc::Server::ProcessPacket().No Built-in Backpressure / Flow Control:
pw_rpcdoes not throttle the sender if the receiver is busy. If an endpoint generates stream packets faster than the physical medium or receiver can consume them, packets may be dropped. Use hardware flow control (e.g. UART RTS/CTS) or transport-level flow control if necessary.No Built-in Retries / Reliability:
pw_rpcdoes not retransmit lost packets. In lossy environments (e.g. noisy serial or wireless), implement reliability at the transport layer (e.g. ARQ / ACK-NACK protocols) or handle timeouts and retries at the application layer.
Sizing and MTU considerations#
The maximum size of an RPC message is governed by two constraints:
PW_RPC_ENCODING_BUFFER_SIZE_BYTES: Compile-time configuration (default: 512 bytes) that defines the maximum encoded RPC packet sizepw_rpccan construct in memory.pw::rpc::ChannelOutput::MaximumTransmissionUnit(): The maximum packet size the physical transport can transmit in one frame.
Use the helper pw::rpc::MaxSafePayloadSize() to determine the maximum
payload size your service can safely write without exceeding encode buffers.
Step 3: Implement ChannelOutput and create channels#
To send packets out of pw_rpc, create a class derived from pw::rpc::ChannelOutput.
Subclassing ChannelOutput#
In the pw_rpc architecture, packet transmission via pw::rpc::ChannelOutput::Send()
cannot fail from the perspective of the RPC engine. If the underlying transport driver cannot
transmit the packet (for example, if a hardware queue is full or a peer is disconnected), the
packet should simply be dropped.
class MyUartChannelOutput : public pw::rpc::ChannelOutput {
public:
constexpr MyUartChannelOutput(const char* name)
: pw::rpc::ChannelOutput(name) {}
// Returns the maximum size packet this output can transmit.
size_t MaximumTransmissionUnit() override { return kMaxMtu; }
// Sends an encoded pw_rpc packet over the transport.
//
// CRITICAL RULES:
// 1. The buffer is ONLY valid for the duration of this call. Transmit
// synchronously or copy to a DMA/queue buffer before returning.
// 2. NEVER call any pw_rpc Server/Client APIs inside Send() (causes
// deadlocks).
// 3. Packet transmission cannot fail from the perspective of pw_rpc. If the
// underlying transport cannot send the packet, drop it.
pw::Status Send(pw::span<const std::byte> buffer) override {
MyDriver_Transmit(buffer.data(), buffer.size()).IgnoreError();
return pw::OkStatus();
}
private:
static constexpr size_t kMaxMtu = 512;
};
Creating channel instances#
Instantiate your channels with explicit, non-zero IDs:
enum class RpcChannelId : uint32_t {
kHostUartChannel = 1,
kPeerMcuSpiChannel = 2,
};
MyUartChannelOutput uart_output("UART_Output");
MyUartChannelOutput spi_output("SPI_Output");
// Define static channels array
pw::rpc::Channel channels[] = {
pw::rpc::Channel::Create<RpcChannelId::kHostUartChannel>(&uart_output),
pw::rpc::Channel::Create<RpcChannelId::kPeerMcuSpiChannel>(&spi_output),
};
Step 4: Server setup & ingress (RX) pipeline#
The RPC server processes incoming requests, dispatches them to registered services, and encodes responses.
1. Instantiating the Server#
pw::rpc::Server server(channels);
[[maybe_unused]] pw::rpc::Server& GetServer() { return server; }
2. Building the RX Ingress Pipeline#
Incoming raw bytes from the transport must be collected, unframed into discrete
RPC packet buffers, and passed to pw::rpc::Server::ProcessPacket().
Choosing an Ingress Execution Context#
pw::rpc::Server::ProcessPacket() decodes the packet and executes the
corresponding service method handler synchronously on the calling thread/context.
You have two architectural choices for where to run ProcessPacket():
Option A: Dedicated RTOS Thread / Task (Recommended for multi-threaded systems): A dedicated thread (e.g. FreeRTOS task, Zephyr thread, or
pw::thread::Thread) blocks waiting on incoming bytes, unframes packets, and processes them. This isolates RPC processing from other application tasks. Ensure the task has adequate stack space (typically 2–4 KB, depending on message sizes).Option B: Existing Main Loop / Superloop / Event Loop (Ideal for bare-metal & cooperative systems): You do not need a separate thread. In bare-metal or event-driven systems, you can simply poll the transport non-blockingly and invoke
server.ProcessPacket()directly from your mainwhile (true)loop or event handler whenever a complete frame is available.
Warning
Never Invoke ProcessPacket() from an Interrupt Service Routine (ISR)!
Service method handlers execute synchronously within ProcessPacket() and
may perform complex computations, lock mutexes, or write responses to
ChannelOutput. If your hardware uses interrupt-driven RX (e.g. UART RX
interrupt or DMA transfer-complete interrupt), the ISR should only buffer raw
bytes into a ring buffer or queue and wake up a task or notify the main loop.
Never pass packets to ProcessPacket() inside an interrupt context.
Option A Example: Dedicated Ingress Thread (RTOS)#
class RpcIngressThread : public pw::thread::ThreadCore {
public:
void Run() override {
PW_LOG_INFO("Starting RPC Ingress Thread...");
std::array<std::byte, 512> decoder_buffer;
pw::hdlc::Decoder decoder(decoder_buffer);
std::array<std::byte, 64> rx_chunk;
while (true) {
// 1. Read raw bytes from physical transport (blocking read)
size_t bytes_read =
MyUartDriver_ReadBlocking(rx_chunk.data(), rx_chunk.size());
// 2. Feed bytes into framing decoder
for (size_t i = 0; i < bytes_read; ++i) {
auto result = decoder.Process(rx_chunk[i]);
if (result.ok()) {
pw::hdlc::Frame& frame = result.value();
// 3. Filter by address if multiple protocols share the link
if (frame.address() == pw::hdlc::kDefaultRpcAddress) {
// 4. Pass the unframed RPC packet to the server
pw::Status status = GetServer().ProcessPacket(frame.data());
if (!status.ok()) {
PW_LOG_WARN("RPC ProcessPacket failed: %s", status.str());
}
}
}
}
break; // Prevent infinite loop in non-threaded tests
}
}
};
Option B Example: Main Loop / Bare-Metal Polling#
std::array<std::byte, 512> poll_decoder_buffer;
pw::hdlc::Decoder poll_decoder(poll_decoder_buffer);
// Non-blocking poll function called periodically from main superloop
[[maybe_unused]] void PollRpcIngress() {
std::array<std::byte, 32> rx_chunk;
// Non-blocking read: returns 0 immediately if no bytes available
size_t bytes_read =
MyUartDriver_ReadNonBlocking(rx_chunk.data(), rx_chunk.size());
for (size_t i = 0; i < bytes_read; ++i) {
auto result = poll_decoder.Process(rx_chunk[i]);
if (result.ok()) {
pw::hdlc::Frame& frame = result.value();
if (frame.address() == pw::hdlc::kDefaultRpcAddress) {
pw::Status status = GetServer().ProcessPacket(frame.data());
if (!status.ok()) {
PW_LOG_WARN("RPC ProcessPacket error: %s", status.str());
}
}
}
}
}
Step 5: Registering services on the server#
Once the server and ingress path are running, instantiate your service classes and register them with the server.
SensorServiceImpl sensor_service;
void RegisterAppServices(pw::rpc::Server& server) {
server.RegisterService(sensor_service);
}
Tip
For a detailed guide on creating .proto files, build rules, and implementing
unary/streaming methods, refer to Creating services.
Step 6: Embedded C++ client setup (MCU-to-MCU)#
When a microcontroller needs to make RPC calls to another MCU or to a host, it acts as an RPC Client.
1. Creating the Client#
The pw::rpc::Client is instantiated with its own list of channels
(or shares channels with a server):
pw::rpc::Channel client_channels[] = {
pw::rpc::Channel::Create<1>(&mcu2_output),
};
pw::rpc::Client rpc_client(client_channels);
// Route incoming packets from MCU2 to the client:
[[maybe_unused]] void OnMcu2PacketReceived(pw::ConstByteSpan packet) {
static_cast<void>(rpc_client.ProcessPacket(packet));
}
2. Dual-Role Nodes with pw::rpc::ClientServer#
If a single device acts as both a Server and a Client over the same channel/transport,
use pw::rpc::ClientServer. It combines both endpoints and routes packets
automatically:
pw::rpc::Channel shared_channels[] = {
pw::rpc::Channel::Create<1>(&uart_output),
};
// Instantiates both client and server sharing the channels
pw::rpc::ClientServer client_server(shared_channels);
[[maybe_unused]] void OnPacketReceived(pw::ConstByteSpan packet) {
// Automatically routes request packets to the server, and response packets to
// the client:
static_cast<void>(client_server.ProcessPacket(packet));
}
3. Invoking RPCs from C++#
Asynchronous Client Call (Non-blocking):#
using SensorClient = pw::rpc::examples::pw_rpc::nanopb::SensorService::Client;
constexpr uint32_t kPeerChannelId = 1;
// Retain call object to keep call active
pw::rpc::NanopbUnaryReceiver<pw_rpc_examples_SensorResponse> active_call;
void OnSensorResponse(const pw_rpc_examples_SensorResponse& resp,
pw::Status status) {
if (status.ok()) {
PW_LOG_INFO("Temperature: %f", resp.temperature);
}
}
[[maybe_unused]] void RequestSensorReading() {
SensorClient client(rpc_client, kPeerChannelId);
pw_rpc_examples_SensorRequest req{.sensor_id = 1};
active_call = client.GetReading(req, OnSensorResponse);
}
Synchronous Client Call (Blocking wrapper):#
[[maybe_unused]] pw::Status FetchSensorSync() {
pw_rpc_examples_SensorRequest req{.sensor_id = 1};
// Blocks calling thread until response arrives or timeout occurs
pw::rpc::SynchronousCallResult<pw_rpc_examples_SensorResponse> result =
pw::rpc::SynchronousCall<
pw::rpc::examples::pw_rpc::nanopb::SensorService::GetReading>(
rpc_client, kPeerChannelId, req);
if (!result.ok()) {
PW_LOG_ERROR("RPC failed: %s", result.status().str());
return result.status();
}
PW_LOG_INFO("Temperature: %f", result.response().temperature);
return pw::OkStatus();
}
Step 7: Host tooling & Python client setup#
Python clients are commonly used for CLI debug tools, automated factory testing, and integration test harnesses.
How Python pw_rpc Works#
In Python:
pw_rpc.descriptors.Channel(channel_id, output_callable)binds a channel ID to a Python send function (Callable[[bytes], Any]).When the transport receives bytes, pass them to
client.process_packet(raw_packet).
Example: Custom Transport in Python#
def send_to_device(data: bytes) -> None:
"""Encapsulates data in an HDLC frame and sends it over the transport."""
frame = encode.ui_frame(ord('R'), data)
# Write the frame to your transport (e.g. serial port, socket, or BLE):
# ser.write(frame)
del frame
CHANNEL_ID = 1
channel = pw_rpc.Channel(CHANNEL_ID, send_to_device)
# Create the RPC Client:
client = pw_rpc.Client.from_modules(
callback_client.Impl(),
[channel],
[sensor_service_pb2],
)
def handle_incoming_bytes(raw_bytes: bytes) -> None:
"""Processes incoming bytes from the transport and forwards RPC packets."""
decoder = decode.FrameDecoder()
for frame in decoder.process_valid_frames(raw_bytes):
if frame.address == ord('R'):
client.process_packet(frame.data)
def invoke_rpc_example() -> None:
"""Invokes an RPC method on the connected device."""
sensor_service = client.channel(
CHANNEL_ID
).rpcs.pw.rpc.examples.SensorService
# 1. Unary call
status, response = sensor_service.GetReading(sensor_id=1)
if status.ok():
print(f"Temperature: {response.temperature} C")
else:
print(f"RPC failed with status: {status}")
Other language clients#
TypeScript (TypeScript client): WebSerial, WebUSB, and WebSocket interfaces via
pigweedjs/pw_rpc.Java / Kotlin / Android (Java client): Mobile and JVM tools via
dev.pigweed.pw_rpc.
Configuration & memory tuning#
The following compile-time options allow you to tune memory consumption and performance:
Macro / Option |
Default |
Description & Tuning Advice |
|---|---|---|
|
|
Max size of an encoded RPC packet buffer. Increase if services send large payloads; decrease to save RAM. |
|
|
Set to |
|
|
When |
|
Platform default |
Watchdog timeout ticks to detect deadlocks in RPC user callbacks. |
Complete end-to-end C++ integration reference#
Below is a complete, standalone example assembling the entire pipeline in an embedded system:
#include <array>
#include <cstddef>
#include <cstdint>
#include <mutex>
#include "pw_assert/check.h"
#include "pw_bytes/span.h"
#include "pw_hdlc/decoder.h"
#include "pw_hdlc/default_addresses.h"
#include "pw_hdlc/encoder.h"
#include "pw_log/log.h"
#include "pw_rpc/channel.h"
#include "pw_rpc/examples/echo_service.rpc.pb.h"
#include "pw_rpc/server.h"
#include "pw_sync/mutex.h"
#include "pw_thread/thread_core.h"
namespace {
// Hardware stubs for illustration:
void UartWriteByte(uint8_t /*byte*/) {}
size_t UartReadBytes(uint8_t* /*dest*/, size_t /*max_len*/) { return 0; }
// 1. Implement ChannelOutput for Egress (TX)
class HdlcUartChannelOutput : public pw::rpc::ChannelOutput {
public:
HdlcUartChannelOutput() : pw::rpc::ChannelOutput("HDLC_UART") {}
size_t MaximumTransmissionUnit() override { return 512; }
pw::Status Send(pw::span<const std::byte> buffer) override {
std::lock_guard guard(tx_mutex_);
for (std::byte b : buffer) {
UartWriteByte(static_cast<uint8_t>(b));
}
return pw::OkStatus();
}
private:
pw::sync::Mutex tx_mutex_;
};
HdlcUartChannelOutput uart_channel_output;
// 2. Declare Channels and Server
constexpr uint32_t kDefaultChannelId = 1;
pw::rpc::Channel channels[] = {
pw::rpc::Channel::Create<kDefaultChannelId>(&uart_channel_output),
};
pw::rpc::Server server(channels);
// 3. Implement the Service
class EchoServiceImpl final
: public pw::rpc::examples::pw_rpc::nanopb::EchoService::Service<
EchoServiceImpl> {
public:
pw::Status Echo(const pw_rpc_examples_EchoMessage& request,
pw_rpc_examples_EchoMessage& response) {
PW_LOG_INFO("Received Echo request: %d", static_cast<int>(request.msg_id));
response = request;
return pw::OkStatus();
}
};
EchoServiceImpl echo_service;
// 4. Ingress (RX) Dispatch Thread
class RpcDispatchThread : public pw::thread::ThreadCore {
public:
void Run() override {
PW_LOG_INFO("RPC Dispatch Thread active");
std::array<std::byte, 512> decoder_buffer;
pw::hdlc::Decoder decoder(decoder_buffer);
std::array<uint8_t, 32> rx_raw;
while (true) {
size_t count = UartReadBytes(rx_raw.data(), rx_raw.size());
for (size_t i = 0; i < count; ++i) {
auto result = decoder.Process(static_cast<std::byte>(rx_raw[i]));
if (result.ok()) {
pw::hdlc::Frame& frame = result.value();
if (frame.address() == pw::hdlc::kDefaultRpcAddress) {
pw::Status status = server.ProcessPacket(frame.data());
if (!status.ok()) {
PW_LOG_WARN("Failed to process packet: %s", status.str());
}
}
}
}
break; // Prevent infinite loop in test harness
}
}
};
[[maybe_unused]] RpcDispatchThread rpc_dispatch_thread;
} // namespace
// 5. System Initialization Entrypoint
[[maybe_unused]] void InitializeRpcSystem() {
// Register services
server.RegisterService(echo_service);
}
Comprehensive deployment checklist#
Use this checklist to ensure all architectural and implementation components are in place:
1. Prerequisites & Environment#
Build system integration:
pw_rpctargets and dependencies wired into your build system (Bazel, GN, CMake, or native build).Asserts and logs configured: pw_assert and pw_log backends configured for the target platform.
Synchronization backend: pw_sync backend configured (e.g. FreeRTOS or Zephyr backend for RTOS, or null mutex backend for bare metal).
Protobuf generator selected: Configured Nanopb (
nanopb_rpc_proto_library),pw_protobuf(pwpb_rpc_proto_library), or Raw RPC, and verified proto generation.
2. Transport & Framing#
Physical transports identified: Mapped physical links (UART, SPI, USB, BLE, Sockets) to logical routes.
Framing protocol integrated: Integrated a framing layer for stream-oriented transports (e.g., pw_hdlc).
Channel IDs assigned: Assigned unique static integer IDs (
1..127) for each endpoint.Buffer sizing (MTU): Configured
PW_RPC_ENCODING_BUFFER_SIZE_BYTESand verified framing decoder buffers match transport MTU.
3. Egress (TX) Pipeline#
ChannelOutput subclass: Implemented derived class providing
pw::rpc::ChannelOutput::Send()andpw::rpc::ChannelOutput::MaximumTransmissionUnit().Buffer lifecycle verified: Ensured
Send()transmits synchronously or copies data before returning (buffer is not accessed asynchronously).Deadlock prevention: Verified
Send()never callspw_rpcAPIs or invokes RPC methods directly.Transport mutex: Added mutex synchronization if physical transmitter is shared across channels/threads.
Channel instances created: Instantiated
pw::rpc::Channel::Create<kChannelId>(&my_output).
4. Ingress (RX) Pipeline#
Execution model chosen: Configured either a dedicated RTOS dispatch thread or a main loop / superloop polling function.
Stack sizing: If using an RTOS thread, allocated sufficient stack (typically 2–4 KB) for service method execution.
Framing & packet ingress: Incoming bytes fed to decoder -> valid frames passed to
pw::rpc::Server::ProcessPacket().No ISR invocation: Verified
ProcessPacket()is never called directly from an ISR context.
5. Services & Application#
Server instantiation: Instantiated
pw::rpc::Server server(channels);.Service implementations: Implemented service handlers inheriting from generated
Service<Impl>base classes.Service registration: Registered all services during startup via
server.RegisterService(...).
6. Embedded Clients (If MCU-to-MCU)#
Client / ClientServer: Instantiated
pw::rpc::Client(orpw::rpc::ClientServerfor dual-role nodes).Response ingress: Ingress pipeline routes response packets to
client.ProcessPacket(packet).Call object lifecycle: Call objects retained in class members / state variables for active RPCs.
7. Host Tooling & Language Integration (If applicable)#
Python / CLI: Configured Python
pw_rpc.descriptors.Channelwith serial/socket TX, RX background listener, andclient.process_packet().TypeScript / Java: Configured web or Android client stubs if tooling requires them.
8. Verification & Stress Testing#
Basic round-trip: Verified unary RPC request and response.
Streaming throughput: Tested sustained server/client streaming under expected data rates.
Error recovery: Verified system handles malformed packets, framing errors, and sudden disconnection gracefully.