| name | C++ Pigweed RPC Services |
| description | Guide to defining, implementing, and testing a new Pigweed RPC service in C++ using Nanopb, pw_protobuf, or raw methods. |
How to Create a New RPC Service in C++ using pw_rpc
Use this guide to create and register a new RPC service in C++ using Pigweed's pw_rpc module.
Overview
To create a new RPC service, follow these four main steps:
- Define your service in a
.proto file.
- Configure your build system to generate C++ code.
- Implement your service class in C++.
- Register your service with an RPC server.
1. Define the Service (.proto)
Create a protocol buffer file to define your service and message types. You should use proto3 syntax.
// my_service.proto
syntax = "proto3";
package my_project;
import "pw_protobuf_protos/common.proto";
service MyService {
// A simple unary RPC
rpc GetStatus(StatusRequest) returns (StatusResponse);
// A server streaming RPC
rpc StreamData(StreamRequest) returns (stream StreamResponse);
}
message StatusRequest {
string query = 1;
}
message StatusResponse {
bool ok = 1;
string message = 2;
}
message StreamRequest {
uint32 count = 1;
}
message StreamResponse {
uint32 index = 1;
float value = 2;
}
2. Configure the Build System
Pigweed supports multiple protobuf backends. You should decide which backend to use based on the following priority:
- Use the library the user asks for.
- Use the default library used by other RPC services in the project (search for it in build files).
- Default to Nanopb if the project has it installed.
- Otherwise, ask the user whether to use Nanopb or pwpb.
Bazel Configuration
Use nanopb_rpc_proto_library or pwpb_rpc_proto_library to generate RPC headers.
load(
"@pigweed//pw_protobuf_compiler:pw_proto_library.bzl",
"nanopb_proto_library",
"nanopb_rpc_proto_library",
)
proto_library(
name = "my_service_proto",
srcs = ["my_service.proto"],
deps = [
"@pigweed//pw_protobuf:common_proto",
],
)
nanopb_proto_library(
name = "my_service_nanopb",
deps = [":my_service_proto"],
)
nanopb_rpc_proto_library(
name = "my_service_nanopb_rpc",
nanopb_proto_library_deps = [":my_service_nanopb"],
deps = [":my_service_proto"],
)
GN
In a BUILD.gn file, use the pw_proto_library template. It automatically generates subtargets for all backends (e.g., .pwpb_rpc, .nanopb_rpc, .raw_rpc).
import("$dir_pw_protobuf_compiler/proto.gni")
pw_proto_library("my_protos") {
sources = [ "my_service.proto" ]
}
pw_source_set("my_service_impl") {
sources = [ "my_service.cc" ]
deps = [
":my_protos.nanopb_rpc", # For message-based types
# or :my_protos.pwpb_rpc
# or :my_protos.raw_rpc
]
}
CMake
In a CMakeLists.txt file, use the pw_proto_library function. It also generates subtargets for all backends.
include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
include($ENV{PW_ROOT}/pw_protobuf_compiler/proto.cmake)
pw_proto_library(my_protos
SOURCES
my_service.proto
)
add_library(my_service_impl ...)
target_link_libraries(my_service_impl PUBLIC
my_protos.nanopb_rpc
)
[!TIP]
Run the build now! You should run the build immediately after configuring it to generate the RPC headers. This allows you to open the generated header and copy the implementation stubs from the bottom of the file, saving you from writing the method signatures manually.
- Bazel:
bazelisk build //my_project:my_service_nanopb_rpc
- GN:
ninja -C out
- CMake:
cmake --build out
Where to Find Generated Headers
The generated headers are placed in your build output directory. Their exact location varies by your build system and toolchain, but your C++ include path will always match your source declaration in the proto_library.
3. Implement the Service Class in C++
Inherit from your generated service base class. Your generated header will be named [proto_file].rpc.pb.h for Nanopb or [proto_file].rpc.pwpb.h for pw_protobuf.
[!TIP]
The generated RPC headers include implementation stubs at the bottom of the file. You can copy-paste these stubs to get started with implementing your service.
Using Nanopb (Recommended)
Include the generated header "my_service.rpc.pb.h".
#include "my_service.rpc.pb.h"
#include "pw_status/status.h"
class MyServiceImpl final : public my_project::pw_rpc::nanopb::MyService::Service<MyServiceImpl> {
public:
pw::Status GetStatus(const my_project_StatusRequest& request,
my_project_StatusResponse& response) {
response.ok = true;
return pw::OkStatus();
}
void GetStatus(const my_project_StatusRequest& request,
pw::rpc::NanopbUnaryResponder<my_project_StatusResponse>& new_responder) {
async_responder_ = std::move(new_responder);
}
void CompleteAsyncGetStatus() {
if (async_responder_.active()) {
my_project_StatusResponse response{.ok = true};
async_responder_.Finish(response, pw::OkStatus());
}
}
void StreamData(const my_project_StreamRequest& request,
pw::rpc::ServerWriter<my_project_StreamResponse>& writer) {
for (uint32_t i = 0; i < request.count; ++i) {
my_project_StreamResponse response;
response.index = i;
response.value = 42.0f;
if (!writer.Write(response).ok()) {
break;
}
}
writer.Finish(pw::OkStatus());
}
void SendData(pw::rpc::ServerReader<my_project_StreamRequest, my_project_StatusResponse>& new_reader) {
client_reader_ = std::move(new_reader);
client_reader_.set_on_next([this](const my_project_StreamRequest& req) {
});
}
void Chat(pw::rpc::ServerReaderWriter<my_project_StatusRequest, my_project_StatusResponse>& new_reader_writer) {
bidi_stream_ = std::move(new_reader_writer);
bidi_stream_.set_on_next([this](const my_project_StatusRequest& req) {
my_project_StatusResponse resp{.ok = true};
bidi_stream_.Write(resp);
});
}
void CloseStreams() {
if (client_reader_.active()) {
client_reader_.Finish({.ok = true}, pw::OkStatus());
}
if (bidi_stream_.active()) {
bidi_stream_.Finish(pw::OkStatus());
}
}
private:
pw::rpc::ServerReader<my_project_StreamRequest, my_project_StatusResponse> client_reader_;
pw::rpc::ServerReaderWriter<my_project_StatusRequest, my_project_StatusResponse> bidi_stream_;
pw::rpc::NanopbUnaryResponder<my_project_StatusResponse> async_responder_;
};
[!IMPORTANT]
Make sure to use std::move if passing the ServerWriter to another thread or context to avoid accidentally closing it when it goes out of scope.
Using pw_protobuf (Alternative)
Include the generated header "my_service.rpc.pwpb.h". Note that you will use ::Message for generated types.
#include "my_service.rpc.pwpb.h"
class MyServicePwpbImpl final : public my_project::pw_rpc::pwpb::MyService::Service<MyServicePwpbImpl> {
public:
pw::Status GetStatus(const my_project::StatusRequest::Message& request,
my_project::StatusResponse::Message& response) {
response.ok = true;
return pw::OkStatus();
}
void GetStatus(const my_project::StatusRequest::Message& request,
pw::rpc::PwpbUnaryResponder<my_project::StatusResponse::Message>& new_responder) {
async_pwpb_responder_ = std::move(new_responder);
}
void CompleteAsyncPwpbGetStatus() {
if (async_pwpb_responder_.active()) {
my_project::StatusResponse::Message response{.ok = true};
async_pwpb_responder_.Finish(response, pw::OkStatus());
}
}
void StreamData(const my_project::StreamRequest::Message& request,
pw::rpc::ServerWriter<my_project::StreamResponse::Message>& writer) {
}
private:
pw::rpc::PwpbUnaryResponder<my_project::StatusResponse::Message> async_pwpb_responder_;
};
Falling Back to Raw Methods (Advanced)
You should use Raw RPC when you hit limitations of higher-level libraries or want to maximize performance. Your common use cases include:
- Callback Limitations (Nanopb &
pw_protobuf::Message): When a message contains fields requiring callbacks (e.g., repeated strings or submessages of unknown size), both Nanopb and pw_protobuf's high-level Message API require you to set the callback function before decoding. Since standard pw_rpc internally decodes the message before it calls your method, you never get a chance to set it. Falling back to Raw RPC gives you the raw bytes so you can set your callbacks and decode manually.
- Zero-Allocation Receiving (Sparse Parsing): Standard generated RPC methods force you to use a static object representation of the message. If you have a complex message but only care about reading a few fields, this object can take up unnecessary RAM. Raw RPC lets you use generic
pw::protobuf::StreamDecoder to scan the raw byte stream field-by-field without instantiating any object tree view in memory.
- Zero-Copy Responses (In-Place Wire Encoding): Raw RPC provides mechanisms like
FinishCallback() which give you direct access to the actual ByteSpan of the pw_rpc wire buffer. You can use pw::protobuf::StreamEncoder to write your response fields directly into the wire buffer in-place, bypassing any intermediate scratch buffers or C structures entirely for maximum performance.
- Low-Overhead Benchmarking (e.g., a simple loop echo): When you want to minimize processing overhead to stress-test or benchmark the transport layer itself. Pigweed's own
BenchmarkService uses Raw RPC for lowest-latency echo.
You can mix Raw methods inside a Nanopb or pw_protobuf service! If a method signature uses standard types like pw::ConstByteSpan and pw::rpc::RawUnaryResponder, RawServerWriter, etc., Pigweed will automatically recognize it as a Raw method and bypass decoding.
class MyMixedService final : public my_project::pw_rpc::nanopb::MyService::Service<MyMixedService> {
public:
pw::Status GetStatus(const my_project_StatusRequest& request,
my_project_StatusResponse& response) {
return pw::OkStatus();
}
void StreamData(pw::ConstByteSpan request, pw::rpc::RawServerWriter& writer) {
}
};
4. Register the Service with the Server
Instantiate your service implementation and register it with your pw::rpc::Server.
#include "pw_rpc/server.h"
constexpr pw::rpc::Channel kChannels[] = { };
pw::rpc::Server server(kChannels);
MyServiceImpl my_service;
void InitApp() {
server.RegisterService(my_service);
}
If you are using pw_system, you can register it in UserAppInit:
#include "pw_system/rpc_server.h"
namespace pw::system {
void UserAppInit() {
pw::system::GetRpcServer().RegisterService(my_service);
}
}
5. Unit Test the Service
You can unit test pw_rpc services using Pigweed's test invocation contexts. These contexts manage the lifecycle of the RPC call, capture response packets, and allow you to simulate client-side streaming events without running a full server.
Depending on your backend, you will use:
PW_NANOPB_TEST_METHOD_CONTEXT
PW_PWPB_TEST_METHOD_CONTEXT
PW_RAW_TEST_METHOD_CONTEXT
Unary test example
#include "pw_rpc/nanopb/test_method_context.h"
#include "my_service.rpc.pb.h"
TEST(MyServiceTest, GetStatus_Success) {
PW_NANOPB_TEST_METHOD_CONTEXT(MyServiceImpl, GetStatus) context;
my_project_StatusRequest request{.query = "test"};
EXPECT_EQ(pw::OkStatus(), context.call(request));
EXPECT_TRUE(context.response().ok);
}
Server Streaming test example
#include "pw_rpc/nanopb/test_method_context.h"
TEST(MyServiceTest, StreamData_SendsMultiplepackets) {
PW_NANOPB_TEST_METHOD_CONTEXT(MyServiceImpl, StreamData) context;
my_project_StreamRequest request{.count = 3};
context.call(request);
EXPECT_TRUE(context.done());
EXPECT_EQ(pw::OkStatus(), context.status());
ASSERT_EQ(3u, context.responses().size());
EXPECT_EQ(0u, context.responses()[0].index);
}
Client Streaming test example
#include "pw_rpc/nanopb/test_method_context.h"
TEST(MyServiceTest, SendData_AggregatesResults) {
PW_NANOPB_TEST_METHOD_CONTEXT(MyServiceImpl, SendData) context;
context.call();
context.SendClientStream({.value = 10});
context.SendClientStream({.value = 20});
context.SendClientStreamEnd();
EXPECT_TRUE(context.done());
EXPECT_EQ(30, context.response().total);
}
Bidirectional Streaming test example
#include "pw_rpc/nanopb/test_method_context.h"
TEST(MyServiceTest, Chat_EchoesResponses) {
PW_NANOPB_TEST_METHOD_CONTEXT(MyServiceImpl, Chat) context;
context.call();
context.SendClientStream({.message = "Ping"});
ASSERT_EQ(1u, context.responses().size());
EXPECT_STREQ("Ping", context.responses()[0].message);
context.SendClientStream({.message = "Pong"});
ASSERT_EQ(2u, context.responses().size());
EXPECT_STREQ("Pong", context.responses()[1].message);
context.SendClientStreamEnd();
}
Complex Callback decoding in tests
When your service response contains callbacks (like repeated fields), standard response decoding will fail. You can pass a custom response object with parsing callbacks preloaded to decode into it in-place.
TEST(MyServiceTest, GetList_HandlesCallbacks) {
PW_NANOPB_TEST_METHOD_CONTEXT(MyServiceImpl, GetList) context;
context.call({});
MyListResponse response{};
response.items.funcs.decode = +[](pb_istream_t* stream, const pb_field_iter_t*, void** arg) -> bool {
return true;
};
context.response(response);
}