什么是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. 序列化 / 反序列化模块
把内存中的结构体、变量,变成连续二进制字节流(序列化);收到字节流还原成内存数据(反序列化)。
可选:
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 接口MCUXpresso...
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 完成:
classUartTransport :public erpc::FramedTransport
{
protected:
// 把 data 里的 size 个字节送到串口
erpc_status_tunderlyingSend(constuint8_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_tunderlyingReceive(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_tdemo_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:不适合。序列化/反序列化的存在不可控的开销和延迟。
以上就是本次的分享,如果觉得文章有帮助,麻烦帮忙点赞转发,谢谢大家!
130