什么是 RPC?
RPC(Remote Procedure Call,远程过程调用) 是一种分布式计算协议。它允许一台机器上的程序像调用本地函数一样,去执行另一台远程机器上的函数,开发者不用关心底层网络通信细节。
RPC 框架的大致思路
RPC 框架通过 IDL 定义接口,自动生成客户端 Stub 和服务端 Skeleton;客户端 Stub 将函数调用和参数序列化,经传输层发给服务端;服务端 Skeleton 反序列化消息,路由到对应的业务函数执行,再把结果序列化返回;框架还提供消息编号、超时、错误处理能力,让远程通道上的调用像本地函数一样使用。

RPC 框架重要组成部分:
1. IDL(接口定义语言)
统一描述服务、方法、入参、出参的数据结构,是客户端与服务端的契约。如 protobuf 的 .proto 文件也是一种 IDL 文件。RPC 的 IDL 可能如 .erpc。可通过 IDL 编译器自动生成 Client Stub 和 Server Skeleton 代码。

2. Stub(客户端桩代码)
由 IDL 工具自动生成,放在调用侧。
内部大致实现:
- 组装请求包(方法 ID、序列号、参数)
- 调用底层 transport 发送消息
- 阻塞 / 异步等待响应包
- 反序列化响应,返回结果 / 错误
3. Skeleton(服务端骨架代码)
IDL 工具生成,放在服务端。负责:
- 接收 transport 上来的原始消息
- 反序列化,解析出调用的方法 ID、参数
- 根据方法 ID,路由到用户注册的业务函数
- 执行业务函数后,打包返回值 + 错误码
- 通过 transport 发回响应
Stub/Skeleton 就是 RPC 的胶水层,隔离业务代码和网络 / 总线。
4. 序列化 / 反序列化模块
把内存中的结构体、变量变成连续二进制字节流(序列化);收到字节流还原成内存数据(反序列化)。
可选方案:
- Protobuf / nanopb(嵌入式最常用,紧凑二进制)
- JSON(文本,体积大,MCU 一般不用)
- FlatBuffers(零拷贝,适合高性能嵌入式)
5. Transport 传输层
负责原始字节的收发,RPC 框架把传输抽象成接口,上层 RPC 不用关心是 UART、CAN、RPMSG、TCP、UDP 等。
什么情况下用 RPC?
一些框架 / 工具,总有其适用及不适用的场景。没有最好,只有更好。
嵌入式中,什么场景下用 RPC?
RPC 比较适合需要跨芯片、跨核、跨板双向调用函数,接口多、经常迭代,不想手写大量底层帧解析、命令编码、CRC、应答逻辑的场景。因为 RPC 可以直接定义接口如 read_temp(),调用就像普通函数,序列化自动处理,新增接口只需要更新 IDL,不用手动写报文解析。
不适用微秒级硬实时、极小资源 MCU 场景。因为 RPC 有序列化 / 反序列化开销、内存分配,延迟不确定;完整的 Protobuf 开销太大,序列化会占用栈 / 堆;这种场景一般手写简单帧协议或者极简二进制协议。
下面用一个嵌入式板间控制的例子,直观对比用 RPC、不用 RPC 的方式。
主控 MCU 通过 UART 控制驱动板,如:需要设置电机速度和读取状态等。这个场景是典型的命令-响应、低频控制面交互,很适合 RPC。
1. 方案 1:不用 RPC,手写通信协议
主控和驱动板双方要自己约定帧格式,例如:
帧头 0xAA55 | 命令字 | 长度 | 负载 | CRC16
命令字 0x01 = SetSpeed
命令字 0x02 = GetStatus
主控端要写对应的设置 / 发送函数:

驱动板端还要写对应的接收解析函数:

两端要单独写对应的协议组包解析、CRC 校验、异常处理等;每新增一条指令,两端都要同步改:命令字、解析代码、回复代码等。比较繁琐也比较容易出错。而且接口没有契约,也不方便理解代码。
我们上一篇文章随笔 | 会调 AI 之后,嵌入式工程师还剩什么?中也提到:AI 时代,我们也要注重一些相关知识的沉淀。这里就是一个例子——如果不了解 RPC,可能在上面这个场景中,给的提示词里没有明确提到 RPC 方案,那 AI 实现出来的可能就是方案一。虽然也能用,但可能不是最优解。
除此之外,有个小技巧分享给大家:对一些东西不熟悉的时候,也可以先让 AI 出一些方案对比,让多个 AI 出多个方案,最后综合选择比较合理的那个,顺便还能大致学习各个方案的基础原理。
2. 方案 2:用 RPC,定义一次接口,自动生成两端代码
用 EmbedXrpc 这类工具,需要针对应用先写一份 IDL,例如:

通过配套工具把 IDL 文件生成对应的 .c 与 .h。
主控端业务代码变成:

驱动板端只需注册实现:

RPC 的方式底层仍然走 UART,但打包、解包、序列化、命令分发、超时 / 错误处理由 RPC 运行时和生成代码统一完成。
以上手写通信协议的方式,如果封装得足够好,通信运行时能力可以做到比较接近 RPC 的方式,但是接口契约还是 RPC 的方式更好。
成熟的 RPC 框架——EmbedXrpc
GitHub 仓库:https://github.com/EmbeddedRPC/erpc


eRPC(Embedded RPC) 是 NXP 开源、面向嵌入式紧密耦合系统的轻量级 RPC 框架,专门用于异构多核 SoC、多芯片板间通信,不面向互联网大规模分布式网络场景。
erpcgen IDL 编译器:
IDL 文件后缀 .erpc,用来定义接口、枚举、数据类型;erpcgen 工具读取 IDL,自动生成客户端 stub(shim 桩代码)、服务端 skeleton 骨架代码。例如:
// 简单 idl 示例
program led_demo;
interface LedService{
led_on(in uint8 led_id) -> void;
led_off(in uint8 led_id) -> void;
}
关键特性:
- 轻量,极小代码占用,适配 Cortex‑M 等资源受限 MCU
- 传输层完全抽象,可快速切换 UART / RPMSG / TCP
- 原生 C / C++,可生成 Python 代码,方便上位机和设备联调
- 支持服务端异步通知(server 主动推送消息给 client)
- 支持同步阻塞调用、异步回调调用
- 不需要修改原有 C 函数原型,方便把现有业务函数导出为 RPC 接口
EmbedXrpc 的使用

上一节把 eRPC 的能力过了一遍,这一节讲怎么真正用起来。
1. 构建 erpcgen
eRPC 仓库里其实是两样东西:erpc_c 是最终编译进 MCU 的运行时,拷进工程就能用;erpcgen 是跑在 PC 上的 IDL 编译器,得先构建出来。
- Windows:先跑仓库里的 install_dependencies.ps1,装好本地 MinGW、bison、flex,再用
cmake --preset mingw64 生成工程并构建;也可以直接用 erpcgen/VisualStudio_v14 里的 VS 工程。
- Linux / macOS:跑 install_dependencies.sh 装依赖,之后
make erpcgen 或走 CMake 目标构建;make install 之后 erpcgen 就是全局命令了。
构建完成后执行 erpcgen -h 能看到选项列表,工具就算就绪。
2. 写 IDL
IDL 里只描述接口,不出现任何通信细节:
// demo_service.erpc
interface DemoService
{
// 有返回值:客户端阻塞等回复
add(uint32 a, uint32 b) -> uint32
echo(uint32 value) -> uint32
// oneway:只发不收,服务端不回复
oneway notify(uint32 value)
}
生成 C / C++ 胶水代码:
erpcgen -gc -o ./generated demo_service.erpc
-gc 指定生成 C / C++,-o 指定输出目录。换成 -gpy 就生成 Python 客户端,上位机联调时很省事:同一个 IDL,两侧语言不同也不用手写解析。
生成物有三类:
| 文件 |
作用 |
demo_service.h |
对外 C 接口:服务句柄 create_DemoService_service()、客户端绑定 DemoService_client_init()、每个 IDL 方法一个同名函数 |
demo_service_client.cpp |
客户端桩:参数写入缓冲区,performRequest() 发出并等回复,再读回返回值 |
demo_service_server.cpp |
服务端桩:收到帧后按 method ID 反序列化参数、调用业务函数、序列化返回值 |
demo_service.h:

demo_service_client.cpp:

demo_service_server.cpp:

契约就落在 .erpc 文件里了。要加一个方法,改 IDL 重新生成一遍,两端同时更新,不用再手工对帧格式和命令字。
3. 适配例子
3.1 常用配置
erpc_config.h 常用配置:
#define ERPC_THREADS (ERPC_THREADS_FREERTOS) // 锁/信号量走 FreeRTOS
#define ERPC_ALLOCATION_POLICY (ERPC_ALLOCATION_POLICY_STATIC) // 静态分配,运行期不 malloc
#define ERPC_DEFAULT_BUFFER_SIZE (256U) // 单帧上限
#define ERPC_DEFAULT_BUFFERS_COUNT (4U) // 静态消息池 4 块
eRPC 内部靠 __has_include() 去探测 FreeRTOSConfig.h,不显式指定的话,配置会静默退化成无线程 + 动态分配。
FreeRTOS 侧则要打开 configUSE_RECURSIVE_MUTEXES(FramedTransport 的收发锁)和 configUSE_COUNTING_SEMAPHORES(静态消息池的信号量)。
3.2 传输层:继承 FramedTransport,实现两个函数
eRPC 把传输抽象成纯虚接口。帧头、CRC 校验、重发确认都由基类 FramedTransport 完成:
class UartTransport : public erpc::FramedTransport
{
protected:
// 把 data 里的 size 个字节送到串口
erpc_status_t underlyingSend(const uint8_t *data, uint32_t size) override
{
return (HAL_UART_Transmit(&huart1, (uint8_t *)data, size, 100) == HAL_OK)
? kErpcStatus_Success
: kErpcStatus_SendFailed;
}
// 阻塞收满 size 个字节:中断里塞环形缓冲,这里等信号量
erpc_status_t underlyingReceive(uint8_t *data, uint32_t size) override
{
/* 实现略:从环形缓冲取字节,取空就等信号量 */
}
};
换总线时只动这两个函数。UART、SPI、CAN、RPMSG 都是同一个套路,上层完全无感。
3.3 注册服务,起一个轮询任务
初始化顺序是固定的:
/* 1) 静态消息缓冲池:4 块 × 256 字节 */
erpc_mbf_t mbf = erpc_mbf_static_init();
/* 2) 服务端:transport + codec + 消息池 */
erpc_server_t server = erpc_server_init(transport, mbf);
/* 3) 注册服务,service ID 由生成代码带进去 */
erpc_add_service_to_server(server, create_DemoService_service());
/* 4) 同一块板子上做双向调用时才需要客户端 */
erpc_client_t client = erpc_client_init(clientTransport, mbf); // 另一端的 transport
DemoService_client_init(client);
服务端任务体只有一个循环,收帧、解帧、分发、回包都在里面:
while (1)
{
erpc_server_poll(server);
}
业务代码写在单独的文件里,和 RPC 完全解耦:
uint32_t demo_service_add(uint32_t a, uint32_t b)
{
return a + b; // 只关心业务,不关心帧、CRC、序列号
}
调用侧就一行:
uint32_t sum = DemoService_add(100, 23); // 返回 123
写法和本地函数调用没有区别。
整个流程走完,我们真正自己写的代码只有两个收发函数和一个业务文件。序列化、CRC、命令分发、超时和错误码都不需要我们处理。
新增接口也是同样的动作:改 IDL、重新生成、补业务实现。两端不会因为漏改解析代码而对不上。
总结
RPC 把两端约定帧格式换成两端约定接口。帧头、命令字、CRC、序列号这些细节无需再在业务代码里维护,交给工具统一维护。
要不要用 RPC,关键看接口的规模和变更频率:
- 跨芯片、跨核、跨板,接口多、还要经常加和改,且属于命令-响应式的低频控制交互:适合用
- 微秒级硬实时、资源极小的 MCU:不适合。序列化 / 反序列化存在不可控的开销和延迟
更多嵌入式 RPC、跨核通信以及板间协议的设计经验,欢迎在云栈社区和开发者一起交流讨论。