嵌入式RPC分发器

别再写一坨if-else了------聊聊嵌入式RPC分发器的门道

写单片机写久了,你一定干过这事:串口收到一包数据,解出命令码,然后一长串if-else或者switch-case去调对应的处理函数。功能少的时候还行,等命令多到五六十个,那个switch写得你自己都不想再看第二眼。

今天聊的RPC分发器,就是专治这种"命令越多、代码越烂"的老毛病。

一、先搞清楚:嵌入式里的"RPC"到底是啥

RPC(Remote Procedure Call),远程过程调用。在服务器开发里这个词烂大街了,gRPC、Thrift一大堆框架。但嵌入式里说RPC,其实本质是一回事------

一端发一条消息过来,另一端根据这条消息"自动"调到正确的函数去执行,然后把结果返回去。

你品品,你平时写的串口命令处理、CAN报文分发、蓝牙指令解析,本质上全都是RPC的雏形。区别只在于------你是用最笨的方法手动分发的,还是用一套机制自动分发的。

打个比方:

你去政务大厅办事。最原始的方式是什么?门口站一个人,你说要办啥,他想半天:"哦,这个找3号窗口"、"那个找7号窗口"。每加一个业务,这人脑子里就得多记一条。这人就是你写的那坨switch-case

RPC分发器是什么?是门口那台取号机。你按一下"社保",它吐个号,你直接去对应窗口。新增业务只需要在机器上加个按钮、配个窗口号。那个站在门口人肉调度的人,可以下岗了。

二、一个最小的分发器长什么样

先别急着上框架,咱们从最朴素的C代码看起。

2.1 丑陋但诚实的switch版本

c 复制代码
void dispatch(uint8_t cmd, uint8_t *payload, uint16_t len) {
    switch (cmd) {
        case 0x01: cmd_read_sensor(payload, len);  break;
        case 0x02: cmd_set_led(payload, len);       break;
        case 0x03: cmd_get_version(payload, len);   break;
        case 0x04: cmd_reset(payload, len);         break;
        // ... 写到0x3F你就开始怀疑人生了
        default:   cmd_unknown(cmd); break;
    }
}

问题出在哪?

  • 每加一个命令,必须改这个函数。 你觉得这是小事?等你一个项目有三个人同时加命令,天天merge冲突的时候就知道痛了。
  • 命令码和处理函数的绑定关系散落在流程代码里。 想查"0x17对应啥函数"?全文搜索吧。
  • 没法做任何通用处理。 比如你想给所有命令加个日志,你得在每个case里都加一行?

2.2 函数指针表------分发器的胚胎

老嵌入式工程师的第一个优化,通常是这样的:

c 复制代码
typedef void (*cmd_handler_t)(uint8_t *payload, uint16_t len);

// 一张表,下标就是命令码
static const cmd_handler_t handler_table[256] = {
    [0x01] = cmd_read_sensor,
    [0x02] = cmd_set_led,
    [0x03] = cmd_get_version,
    [0x04] = cmd_reset,
    // 其余自动初始化为NULL
};

void dispatch(uint8_t cmd, uint8_t *payload, uint16_t len) {
    if (handler_table[cmd]) {
        handler_table[cmd](payload, len);
    } else {
        cmd_unknown(cmd);
    }
}

这已经比switch好太多了。dispatch函数从此定型,加命令只改表,不改逻辑。

但------这还不是真正的RPC分发器。它只是个"查表调用",缺了关键的几块。

三、从"查表调用"到"RPC分发器",差在哪

一个能称得上RPC分发器的东西,至少得解决这几个问题:

3.1 参数的序列化/反序列化

串口传过来的是一坨字节流。你的处理函数期望的是结构化的参数。谁来做这个转换?

c 复制代码
// 你不希望每个handler里都手动解析
void cmd_set_led(uint8_t *payload, uint16_t len) {
    // 手动从字节流里抠参数?每个函数都这么写?
    uint8_t led_id = payload[0];
    uint8_t brightness = payload[1];
    uint16_t duration = (payload[2] << 8) | payload[3];
    // ...
}

更好的做法是让分发器帮你完成反序列化,handler拿到的直接是结构体:

c 复制代码
// 定义参数结构
typedef struct {
    uint8_t  led_id;
    uint8_t  brightness;
    uint16_t duration;
} set_led_args_t;

// handler直接拿结构体
void cmd_set_led(const set_led_args_t *args) {
    hal_set_led(args->led_id, args->brightness, args->duration);
}

分发器在查表之后、调handler之前,用参数描述信息把字节流转成结构体。这就是序列化层。

3.2 返回值的打包与回传

RPC的"C"是Call,既然是调用,就得有返回值。分发器需要能把handler的返回值打包成字节流,原路送回去。

3.3 错误处理的统一出口

命令不存在、参数长度不对、执行出错------这些异常不应该每个handler各管各的,而是由分发器统一兜底。

3.4 中间件/钩子

鉴权、日志、限流。在服务器开发里这叫middleware,在嵌入式里同样需要。分发器提供before/after钩子,所有命令自动过一遍,不用每个handler里重复写。

四、手搓一个像模像样的分发器

下面给一个实际能用的骨架,基于C语言,跑在任何单片机上都行。

c 复制代码
#include <stdint.h>
#include <string.h>

/* ---------- 类型定义 ---------- */
typedef enum {
    RPC_OK = 0,
    RPC_ERR_UNKNOWN_CMD,
    RPC_ERR_BAD_PARAM,
    RPC_ERR_EXEC_FAIL,
} rpc_status_t;

// handler 统一签名:入参是payload指针+长度,出参写到resp_buf,返回状态码
typedef rpc_status_t (*rpc_handler_t)(
    const uint8_t *req,  uint16_t req_len,
    uint8_t *resp,       uint16_t *resp_len
);

// 命令描述符------表里的每一行
typedef struct {
    uint16_t      cmd_id;
    rpc_handler_t handler;
    uint16_t      min_req_len;  // 最短合法请求长度,分发器帮你校验
    const char   *name;         // 调试用,release可以去掉
} rpc_entry_t;

// before钩子,返回非0表示拦截,不再调handler
typedef int (*rpc_before_hook_t)(uint16_t cmd_id, const uint8_t *req, uint16_t req_len);

/* ---------- 分发器上下文 ---------- */
typedef struct {
    const rpc_entry_t  *table;
    uint16_t            entry_count;
    rpc_before_hook_t   before_hook;   // 可选
} rpc_dispatcher_t;

/* ---------- 核心分发函数 ---------- */
rpc_status_t rpc_dispatch(
    const rpc_dispatcher_t *disp,
    uint16_t cmd_id,
    const uint8_t *req, uint16_t req_len,
    uint8_t *resp, uint16_t *resp_len)
{
    // 1. 查表
    const rpc_entry_t *entry = NULL;
    for (uint16_t i = 0; i < disp->entry_count; i++) {
        if (disp->table[i].cmd_id == cmd_id) {
            entry = &disp->table[i];
            break;
        }
    }
    if (!entry) return RPC_ERR_UNKNOWN_CMD;

    // 2. 参数校验
    if (req_len < entry->min_req_len) return RPC_ERR_BAD_PARAM;

    // 3. before钩子
    if (disp->before_hook && disp->before_hook(cmd_id, req, req_len) != 0) {
        return RPC_ERR_EXEC_FAIL;  // 被拦截
    }

    // 4. 调用handler
    return entry->handler(req, req_len, resp, resp_len);
}

使用的时候:

c 复制代码
// 各handler实现
rpc_status_t handle_read_sensor(const uint8_t *req, uint16_t req_len,
                                 uint8_t *resp, uint16_t *resp_len) {
    int16_t temp = sensor_read_temperature();
    resp[0] = (temp >> 8) & 0xFF;
    resp[1] = temp & 0xFF;
    *resp_len = 2;
    return RPC_OK;
}

rpc_status_t handle_set_led(const uint8_t *req, uint16_t req_len,
                             uint8_t *resp, uint16_t *resp_len) {
    hal_set_led(req[0], req[1]);
    *resp_len = 0;
    return RPC_OK;
}

// 命令表------整个系统的"路由配置"
static const rpc_entry_t cmd_table[] = {
    { 0x01, handle_read_sensor, 0, "read_sensor" },
    { 0x02, handle_set_led,     2, "set_led"     },
    { 0x03, handle_get_version, 0, "get_version" },
};

// 初始化分发器
static const rpc_dispatcher_t dispatcher = {
    .table       = cmd_table,
    .entry_count = sizeof(cmd_table) / sizeof(cmd_table[0]),
    .before_hook = auth_check,  // 鉴权钩子
};

到这里你回头看,加一条命令,只需要在cmd_table里加一行。分发逻辑、参数校验、鉴权钩子------全不用动。

五、性能优化:当命令多到一定程度

上面的线性查表,命令不多时完全够用。但如果你有200+个命令,每次都遍历一遍不太合适。几个常用的优化方向:

方向一:命令码连续时直接当下标

如果你的命令码是从0x00到0xNN连续的,那最快------直接数组下标寻址,O(1)。

c 复制代码
// 命令码范围: 0x00 ~ 0x3F
#define CMD_MAX 0x40
static const rpc_entry_t *fast_table[CMD_MAX];

// 初始化时建立索引
void build_index(void) {
    for (int i = 0; i < entry_count; i++) {
        fast_table[cmd_table[i].cmd_id] = &cmd_table[i];
    }
}

方向二:命令码稀疏时用哈希

命令码不连续、分布很散?搞个简单的哈希。在嵌入式上不用搞多复杂,一个取模+开放寻址就够了。

方向三:编译期排序+二分查找

表按cmd_id排好序,运行时二分查找,O(log N)。200个命令也只需要比较8次。

六、进阶:自动生成命令表

真正做产品的时候,命令表不应该手写。你应该有一份协议描述文件(可以是JSON、YAML、甚至Excel),然后用脚本自动生成C代码的命令表和对应的handler声明。

yaml 复制代码
# protocol.yaml
commands:
  - id: 0x01
    name: read_sensor
    request: []
    response:
      - {name: temperature, type: int16}

  - id: 0x02
    name: set_led
    request:
      - {name: led_id, type: uint8}
      - {name: brightness, type: uint8}
    response: []

一个Python脚本读这个文件,吐出cmd_table.ccmd_handlers.h协议文档、命令表、handler声明三者永远一致,不会出现"代码里有这个命令但文档里没写"的情况。

这其实就是gRPC/Protobuf在嵌入式场景下的简化版思路。

七、回到那个比喻

还记得政务大厅的取号机吗?

  • 命令表 = 取号机上的按钮列表("社保"、"医保"、"公积金"...)
  • handler = 各个窗口的工作人员
  • 分发器 = 取号机 + 叫号屏,自动把你导到对应窗口
  • before钩子 = 门口的安检,所有人进来先过一遍
  • 序列化/反序列化 = 你填的表格。你写的是人话,系统内部转成标准格式交给窗口处理

新增业务?加个按钮、配个窗口。不用重新培训门口那个调度员,因为他已经不存在了。

八、什么时候该上分发器

有人可能觉得"我就三五个命令,有必要吗?"

实话说------如果你确定命令永远不超过10个,switch写得清清楚楚,那确实没必要。工程是解决问题的,不是炫技的。

但如果你面对的是这些场景,那就该认真考虑了:

  • 命令会持续增长(产品迭代、OTA升级后加功能)
  • 多人协作开发同一套协议
  • 需要对接自动化测试(测试框架按命令表批量发命令)
  • 协议需要版本管理(v1和v2共存)
  • 你受够了每次加命令都要改dispatch函数还经常漏掉break

代码的臭味,往往不是一开始就有的。是你第15次往switch里加case的时候,突然闻到的。


如果觉得有帮助,点个赞不迷路。有问题评论区见。

相关推荐
QYQ_11272 小时前
嵌入式学习——51单片机(下)
嵌入式硬件·学习·51单片机
进击的横打2 小时前
【车载开发系列】RH850中的看门狗WDTA
单片机·嵌入式硬件
17(无规则自律)2 小时前
【Linux驱动实战】:最简单的内核模块
linux·c语言·驱动开发·嵌入式硬件
单片机设计星球3 小时前
51单片机的【智能家居系统】仿真设计
嵌入式硬件·51单片机·智能家居
逐步前行3 小时前
STM32_SysTick_系统定时器
stm32·单片机·嵌入式硬件
逐步前行3 小时前
STM32_外部中断_寄存器操作
stm32·单片机·嵌入式硬件
Saniffer_SH4 小时前
【高清视频】AI服务器调试利器:PCIe功耗分析设备 Quarch PAM 深度解析
网络·人工智能·驱动开发·嵌入式硬件·测试工具·计算机外设·压力测试
安庆平.Я4 小时前
STM32——FreeRTOS - 任务创建和删除*
stm32·单片机·嵌入式硬件
BT-BOX12 小时前
第三章|新建STM32CubeMX工程生成keil工程和proteus联调仿真
stm32·嵌入式硬件·proteus