• 正文
  • 相关推荐
申请入驻 产业图谱

一个专为嵌入式设计的RPC框架

10/20 08:01
130
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

什么是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 接口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:不适合。序列化/反序列化的存在不可控的开销和延迟。

以上就是本次的分享,如果觉得文章有帮助,麻烦帮忙点赞转发,谢谢大家!

相关推荐

本公众号专注于嵌入式技术,包括但不限于C/C++、嵌入式、物联网、Linux等编程学习笔记,同时,公众号内包含大量的学习资源。欢迎关注,一同交流学习,共同进步!