别再写一坨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.c和cmd_handlers.h。协议文档、命令表、handler声明三者永远一致,不会出现"代码里有这个命令但文档里没写"的情况。
这其实就是gRPC/Protobuf在嵌入式场景下的简化版思路。
七、回到那个比喻
还记得政务大厅的取号机吗?
- 命令表 = 取号机上的按钮列表("社保"、"医保"、"公积金"...)
- handler = 各个窗口的工作人员
- 分发器 = 取号机 + 叫号屏,自动把你导到对应窗口
- before钩子 = 门口的安检,所有人进来先过一遍
- 序列化/反序列化 = 你填的表格。你写的是人话,系统内部转成标准格式交给窗口处理
新增业务?加个按钮、配个窗口。不用重新培训门口那个调度员,因为他已经不存在了。
八、什么时候该上分发器
有人可能觉得"我就三五个命令,有必要吗?"
实话说------如果你确定命令永远不超过10个,switch写得清清楚楚,那确实没必要。工程是解决问题的,不是炫技的。
但如果你面对的是这些场景,那就该认真考虑了:
- 命令会持续增长(产品迭代、OTA升级后加功能)
- 多人协作开发同一套协议
- 需要对接自动化测试(测试框架按命令表批量发命令)
- 协议需要版本管理(v1和v2共存)
- 你受够了每次加命令都要改dispatch函数还经常漏掉break
代码的臭味,往往不是一开始就有的。是你第15次往switch里加case的时候,突然闻到的。
如果觉得有帮助,点个赞不迷路。有问题评论区见。