这段时间好好分析了一轮,发现这东西非常非常长,所以这一篇写了很久很久,大概一周多.
手工分析可能存在一些问题,原本是写在Notion的,导出到这里可能有些排版问题,同时软件不断更新的.
重点回答这几件事:
- provide() 的入参到底是什么意思
- provide() 里面的 call(BIND_METHOD, name).result(res) 到底做了什么
- Bridge.call() 为什么返回
RpcCall - provide / provide_safe 的线程差异到底落在哪一层
- method xxx not available 究竟是在哪一层产生的
我这个分析就跟着代码走,需要读的库包含以下.
- ArduinoCore-zephyr
- Arduino_RouterBridge
- Arduino_RPCLite
- arduino-router
上面提到的所有库都在 https://github.com/arduino/ 可以下载到,为了更清晰,我这里每一步都写了编号.
1. 从 Bridge.provide() 开始
文件: Arduino_RouterBridge/src/bridge.h
template<typename F>
bool provide(const MsgPack::str_t& name, F&& func) {
k_mutex_lock(&bridge_mutex, K_FOREVER);
bool res;
bool out = call(BIND_METHOD, name).result(res) && res && server->bind(name, func);
k_mutex_unlock(&bridge_mutex);
return out;
}
1.1 这个函数的两个入参是什么意思
provide() 的模板参数和函数参数分别表示:
F
这是"回调函数类型"的模板参数.
编译器会从func自动推导出它的真实类型.
例如:- 传普通函数时,
F是函数类型或函数引用类型 - 传 lambda 时,
F是 lambda 的闭包类型 - 传函数对象时,
F是该对象类型 const MsgPack::str_t& name
这是这个 RPC 方法对外暴露的"名字".
Linux 侧并不知道 MCU 上有哪个 C++ 函数,它只认识字符串方法名.
所以这个name其实就是"路由键".
这里为什么不用String和char[],主要是为了后续序列化.
- String 更像应用层方便用的类型
- char[] 更像底层缓冲区
- MsgPack::str_t 是这个 RPC/序列化库内部的标准字符串货币
F&& func
这是要绑定到这个方法名上的本地回调.
这里是 forwarding reference,用来保留左值/右值属性,最后转发给底层server->bind(name, func).- F&& 在模板推导下,可以接受任何传入(左值/右值/Lambda)
换句话说,provide("set_led", set_led) 的语义不是"把 C++ 函数名发给 Linux",而是:
- 告诉 router: "我这条连接现在提供一个名叫
set_led的 RPC 方法" - 在 MCU 本地 dispatcher 上,把字符串
"set_led"绑定到set_led这个回调
2. 先看 provide() 这一行里三段表达式分别是什么
还是这一行:
bool out = call(BIND_METHOD, name).result(res) && res && server->bind(name, func);
它其实是三个阶段串在一起:
call(BIND_METHOD, name).result(res)
向 router 发起一次 RPC 调用,请求注册方法名res
这是 router 返回的业务结果,表示 router 是否接受了这次注册server->bind(name, func)
在 MCU 本地把方法名和回调绑起来
也就是:
- 先远端注册
- 再本地注册
- 两边都成功才返回
true
注意这意味着顺序不是"先本地 bind,再远端 register",而是反过来.
3. BIND_METHOD 是什么
文件: Arduino_RouterBridge/src/bridge.h
#define BIND_METHOD "$/register"
所以:
call(BIND_METHOD, name)
等价于:
call("$/register", name)
这不是调用用户方法,而是在调用 router 内建的一个控制 RPC.
4. 继续跟进 call(BIND_METHOD, name)
文件: Arduino_RouterBridge/src/bridge.h
template<typename... Args>
RpcCall<Args...> call(const MsgPack::str_t& method, Args&&... args) {
return RpcCall<Args...>(method, client, &read_mutex, &write_mutex, std::forward<Args>(args)...);
}
4.1 这里的入参分别是什么
const MsgPack::str_t& method
目标 RPC 方法名.这里传进来的是"$/register".Args&&... args
RPC 参数包.这里传进来的是一个参数name.
如果原始调用是:
call(BIND_METHOD, name)
那么这里会推导成:
method = "$/register"args... = { name }
4.2 这个函数本身做了什么
它没有发包.
它只是构造并返回一个 RpcCall<Args...> 对象,把这些信息先存起来:
- method 名
- client 指针
- 读写 mutex
- 参数 tuple
所以 Bridge.call() 本质上是"创建一个待执行的 RPC 调用对象",不是"立即同步执行".
5. 继续跟进 RpcCall(...) 构造函数
文件: Arduino_RouterBridge/src/bridge.h
RpcCall(const MsgPack::str_t& m, RPCClient* c, struct k_mutex* rm, struct k_mutex* wm, Args&&... args)
: method(m), client(c), read_mutex(rm), write_mutex(wm),
callback_params(std::forward_as_tuple(std::forward<Args>(args)...)) {
k_mutex_init(&call_mutex);
setError(GENERIC_ERR, "This call is not yet executed");
}
- 补充知识 Bridge.begin() 时候初始化了RPCClient,后续一直用
5.1 构造时保存了什么
method
RPC 方法名,这里是"$/register"client
一个RPCClient*,后面真正发包要靠它read_mutex/write_mutex
用来串行化对共享串口 transport 的读写callback_params
一个 tuple,把参数包原样保存起来
也就是说,call() 返回以后,这个对象里已经带着"将来要发的那次 RPC 调用"的全部上下文.
5.2 为什么初始 error 是 "This call is not yet executed"
因为这时候请求还没真正发出去.
也就是:
call()返回时,RPC 还没发送- 直到
.result(...)被调用,这个RpcCall才真正执行
6. 继续跟进 .result(res)
文件: Arduino_RouterBridge/src/bridge.h
template<typename RType> bool result(RType& result) {
if (!atomic_cas(&_executed, 0, 1)){
setError(GENERIC_ERR, "This call is no longer available");
return false;
}
while (true) {
if (k_mutex_lock(write_mutex, K_MSEC(10)) == 0) {
std::apply([this](const auto&... elems) {
client->send_rpc(method, msg_id_wait, elems...);
}, callback_params);
k_mutex_unlock(write_mutex);
break;
} else {
k_yield();
}
}
while(true) {
if (k_mutex_lock(read_mutex, K_MSEC(10)) == 0 ) {
RpcError temp_err;
if (client->get_response(msg_id_wait, result, temp_err)) {
k_mutex_unlock(read_mutex);
setError(temp_err.code, temp_err.traceback);
break;
}
k_mutex_unlock(read_mutex);
k_msleep(1);
} else {
k_yield();
}
}
return !isError();
}
这段是整条链路里最关键的一段.
6.1 第一件事: 保证只执行一次
if (!atomic_cas(&_executed, 0, 1))
意思是:
- 只有第一次调用
result()的线程能把_executed从0改成1 - 后续再调用
result(),直接失败
所以 RpcCall 是"一次性消费对象".
这也解释了为什么 README 里说 .result() 只能成功取一次结果.
6.2 第二件事: 真正发请求
client->send_rpc(method, msg_id_wait, elems...);
注意这里才第一次真正调用 RPCClient 发包.
也就是说:
Bridge.call(...)只是构造对象RpcCall.result(...)才真正把 RPC 发到串口上
6.3 第三件事: 等待响应
client->get_response(msg_id_wait, result, temp_err)
这里按消息 id 等待对应 response.
如果响应到了:
- 解析结果到
result - 或解析错误到
temp_err - 最后
return !isError()
所以 call() 不是同步返回,而是 RpcCall,就是为了把:
- "构造一笔待执行 RPC"
- "真正发起 RPC"
- "什么时候阻塞等结果"
分成两步.
7. 继续跟进 client->send_rpc(...)
文件: Arduino_RPCLite/src/client.h
template<typename... Args>
bool send_rpc(const MsgPack::str_t& method, uint32_t& wait_id, Args&&... args) {
uint32_t msg_id;
if (decoder->send_call(CALL_MSG, method, msg_id, std::forward<Args>(args)...)) {
wait_id = msg_id;
return true;
}
return false;
}
7.1 这里做了什么
- 调用
decoder->send_call(...) - 让底层给这次请求分配一个
msg_id - 把这个
msg_id返回给上层,保存到wait_id
这个 msg_id 很重要,因为后面 get_response(wait_id, ...) 要靠它对响应做匹配.
8. 继续跟进 decoder->send_call(...)
文件: Arduino_RPCLite/src/decoder.h
template<typename... Args>
bool send_call(const int call_type, const MsgPack::str_t& method, uint32_t& msg_id, Args&&... args) {
if (call_type!=CALL_MSG && call_type!=NOTIFY_MSG) return false;
MsgPack::Packer packer;
packer.clear();
if (call_type == CALL_MSG){
msg_id = _msg_id;
MsgPack::arr_size_t call_size(REQUEST_SIZE);
packer.serialize(call_size, call_type, msg_id, method);
} else {
MsgPack::arr_size_t call_size(NOTIFY_SIZE);
packer.serialize(call_size, call_type, method);
}
MsgPack::arr_size_t arg_size(sizeof...(args));
packer.serialize(arg_size, std::forward<Args>(args)...);
if (send(reinterpret_cast<const uint8_t*>(packer.data()), packer.size()) == packer.size()){
_msg_id++;
return true;
}
return false;
}
8.1 这里开始进入协议层
这段代码把 RPC 调用编码成 MsgPack RPC 数组.
对 CALL_MSG 而言,包结构是:
[0, msg_id, method, [args...]]
其中:
0表示 requestmsg_id是请求 idmethod是字符串方法名- 最后一个元素是参数数组
如果现在调用的是:
call("$/register", name).result(res)
那么发出去的逻辑包大致就是:
[0, <某个msg_id>, "$/register", ["set_led"]]
这里 set_led 只是例子,真实值取决于 name.
8.2 为什么 RPCClient 和 RPCServer 都用同一个 decoder
文件: [Arduino_RPCLite/src/decoder_manager.h]
static RpcDecoder<>& getDecoder(ITransport& transport)
RPCClient 和 RPCServer 都通过 RpcDecoderManager 按 transport 取同一个 RpcDecoder 实例.
这意味着:
- 同一条串口上的收发缓存是共享状态
- 所以桥接层必须用
read_mutex/write_mutex做访问串行化
这也是 Bridge 里两个 mutex 存在的根本原因之一.
8.3 协议可靠性约束
从 transport.h 和 decoder.h 的实现来看,这套 RPC 协议没有任何应用层可靠性机制.
具体来说:
没有帧定界/字节填充:
SerialTransport 直接 read() / write(),没有 SLIP 编码或任何帧封装.
消息边界完全依赖 MsgPack 自身的长度前缀.decoder 的 parse_packet() 通过解析 MsgPack array header 来确定一条完整消息的起止位置.
没有校验和/CRC:
没有任何消息完整性校验.串口传输如果出现比特错误,只能靠 MsgPack 反序列化失败来间接发现.
没有流控/重传:
没有 ACK/NACK,没有应用层重传,没有背压机制.send() 假设 transport 总能成功写入.
固定 buffer 上限:
文件: Arduino_RPCLite/src/decoder.h
template<size_t BufferSize = DECODER_BUFFER_SIZE>
其中 DECODER_BUFFER_SIZE = 1024.超过这个大小的单条消息会出问题.
另外 RPCRequest<> 的默认 buffer 大小是:
#define DEFAULT_RPC_BUFFER_SIZE (DECODER_BUFFER_SIZE / 4)
即 256 字节.这意味着请求/响应的有效载荷空间其实是比较有限的.
错误恢复机制:
decoder 内部有一个 _discarded_packets 计数器.当解析失败时,包会被丢弃并计数,然后 decoder 会在 buffer 里扫描寻找下一个合法的 MsgPack 包头.
也就是说,如果串口丢字节导致 buffer 状态错位,decoder 只能"丢弃并向前扫描",不能回溯重传.
这些约束不是 bug,而是嵌入式场景下"用复杂度换确定性"的合理取舍.但它们直接影响"什么时候这套系统可能出问题"的判断.
9. 请求发出去以后,Linux 侧谁接收
router 侧入口在 [arduino-router/main.go] 和 [arduino-router/internal/msgpackrouter/router.go]
router 会把串口包装成一个 connection:
wr := &MsgpackDebugStream{Name: cfg.SerialPortAddr, Upstream: serialPort}
routerExit := router.Accept(wr)
也就是说 MCU 串口这一端,在 router 看来也是一个普通 MsgPack RPC client connection.
10. router 收到 $/register 后做什么
文件: [arduino-router/internal/msgpackrouter/router.go]
switch method {
case "$/register":
if len(params) != 1 {
...
} else if methodToRegister, ok := params[0].(string); !ok {
...
} else if err := r.registerMethod(methodToRegister, msgpackconn); err != nil {
...
} else {
fwdRes(true, nil)
return
}
10.1 它检查什么
对于 $/register:
- 必须只有一个参数
- 这个参数必须是字符串
- 然后把这个字符串注册到 router 的路由表里
注册动作在:
文件: arduino-router/internal/msgpackrouter/router.go
func (r *Router) registerMethod(method string, conn *msgpackrpc.Connection) error {
...
r.routes[method] = conn
return nil
}
也就是说 router 并不知道 set_led 对应哪个 C++ 函数.
router 只知道:
- 有个方法名叫
set_led - 它属于这条 MCU connection
这就是 Linux 侧"发现" MCU 方法的真实含义:
- 不是 Linux 去扫描 MCU 的函数表
- 而是 MCU 主动通过
$/register向 router 声明"这个名字归我"
11. router 返回的 res 是什么
回到这一句:
call(BIND_METHOD, name).result(res)
这里的 res 是一个 bool,它接收的是 router 对 $/register 的 RPC 返回值.
当 router 成功注册时,返回:
fwdRes(true, nil)
也就是:
error = nilresult = true
所以:
.result(res)表示"这次 RPC 在协议层有没有完成,并且结果能不能被解析"- 变量
res表示"router 业务上是否接受了这次注册"
这两个都要成功,后面的 && res 才会继续走到 server->bind(name, func).
12. 然后才执行本地 server->bind(name, func)
文件: [Arduino_RPCLite/src/server.h]
template<typename F>
bool bind(const MsgPack::str_t& name, F&& func, MsgPack::str_t tag=""){
return dispatcher.bind(name, func, tag);
}
继续往下:
文件: [Arduino_RPCLite/src/dispatcher.h]
template<typename F>
bool bind(MsgPack::str_t name, F&& f, MsgPack::str_t tag="") {
if (_count >= N) return false;
if (isBound(name)) return false;
_entries[_count++] = {name, tag, wrap(std::forward<F>(f))};
return true;
}
12.1 这里本地到底存了什么
dispatcher 里每个 entry 有三项:
name
方法名字符串tag
用来区分线程通道fn
被wrap(...)之后的统一回调包装器
这一步才是 MCU 本地"方法名 -> C++ 回调"的真实绑定位置.
所以 provide() 成功以后,系统中会同时存在两份状态:
- router 里:
routes["set_led"] = 这条MCU连接 - MCU 本地 dispatcher 里:
entries[i] = { "set_led", "", <wrapped callback> }
13. Linux 侧以后调用 set_led 时,router 怎么处理
文件: arduino-router/internal/msgpackrouter/router.go
client, ok := r.getConnectionForMethod(method)
if !ok {
fwdRes(nil, routerError(ErrCodeMethodNotAvailable, fmt.Sprintf("method %s not available", method)))
return
}
err := client.SendRequestWithAsyncResult(
fwdRes,
method, params...)
13.1 这里说明了什么
对于普通 method:
- router 先查
routes[method] - 找到就把请求转发到对应 connection
- 找不到就直接返回
method xxx not available
所以这条错误的直接含义是:
router 当前查不到这个 method 对应的连接
它首先是 router 路由表层面的错误,不是 MCU 本地 dispatcher 抛出的错误.
14. MCU 收到转发请求后,怎么挑出对应方法
在 bridge 后台线程里,会不断跑:
文件: Arduino_RouterBridge/src/bridge.h
inline void updateEntryPoint(void *, void *, void *){
while (true) {
if (Bridge) {
Bridge.update();
}
k_yield();
}
}
update() 的主体是:
文件: Arduino_RouterBridge/src/bridge.h
RPCRequest<> req;
if (!server->get_rpc(req)) {
...
return;
}
server->process_request(req);
server->send_response(req);
继续看 get_rpc(req):
文件: Arduino_RPCLite/src/server.h
decoder->decode();
const MsgPack::str_t method = decoder->fetch_rpc_method();
if (method == "" || !dispatcher.hasTag(method, tag)) return false;
req.size = decoder->get_request(req.buffer, RpcSize);
return req.size > 0;
14.1 这里做了哪几步
decoder->decode()
从 transport 继续读串口数据,尝试解析一个完整 MsgPack 包decoder->fetch_rpc_method()
从包头里先把 method 字符串取出来dispatcher.hasTag(method, tag)
检查这个 method 是否存在,并且 tag 是否匹配当前这条处理通道decoder->get_request(...)
真正把整包取出来填到req.buffer
所以 MCU 不是"先完整解所有包,再决定谁来处理",而是:
- 先窥探 method 名
- 根据 method + tag 决定当前线程是否有资格处理
- 再把完整 request 取出来
14.5 补齐: wrap() 到底把回调变成了什么
前面 Section 12 看到 server->bind(name, func) 最终存的是:
_entries[_count++] = {name, tag, wrap(std::forward<F>(f))};
这里的 wrap(...) 做了非常精巧的事情.
文件: Arduino_RPCLite/src/wrapper.h
template<typename F, typename Signature = typename arx::function_traits<typename std::decay<F>::type>::function_type>
auto wrap(F&& f) -> RpcFunctionWrapper<Signature>* {
return new RpcFunctionWrapper<Signature>(std::forward<F>(f));
};
14.5.1 编译期签名提取
关键在第二个模板参数:
typename arx::function_traits<typename std::decay<F>::type>::function_type
这行做的事情是:
std::decay<F>::type去掉引用和 cv 限定arx::function_traits<...>提取函数特征(返回类型,参数类型列表)::function_type把它转成std::function<R(Args...)>的形式
所以不管你传进来的是普通函数,lambda,函数对象还是 std::function,wrap() 都能在编译期推导出它的签名.
14.5.2 RpcFunctionWrapper 是什么
文件: Arduino_RPCLite/src/wrapper.h
template<typename R, typename... Args>
class RpcFunctionWrapper<std::function<R(Args...)>>: public IFunctionWrapper {
它继承自 IFunctionWrapper:
class IFunctionWrapper {
public:
virtual ~IFunctionWrapper() = default;
virtual bool operator()(MsgPack::Unpacker& unpacker, MsgPack::Packer& packer) = 0;
};
这是一个类型擦除接口.
所有不同签名的回调,在 dispatcher 表里都以 IFunctionWrapper* 存储.
但每个 RpcFunctionWrapper<R(Args...)> 实例内部,仍然完整保留了原始签名信息.
14.5.3 调用时发生了什么
当 wrapper 的 operator() 被调用时(传入 unpacker 和 packer),它做了这几步:
- 检查参数数组大小是否匹配
sizeof...(Args) - 调用
deserialize_tuple(unpacker, args)从 MsgPack 流里反序列化出参数 tuple - 调用
invoke_with_tuple(_func, args, ...)把参数展开,调用真实回调 - 如果返回类型是
void:packer.serialize(nil, nil)→ 回包里 result 是 nil - 如果返回类型非
void:packer.serialize(nil, out)→ 回包里 result 是返回值
也就是说,wrap() 把"任意签名的 C++ 回调"变成了一个统一接口:
MsgPack 字节流 → 反序列化参数 → 调用真实函数 → 序列化返回值 → MsgPack 字节流
这就是 dispatcher 表里存的东西:不是裸函数指针,而是"知道怎么和 MsgPack 对接的类型擦除包装器".
14.6 补齐: process_request() 和 send_response() 的完整流程
回到 update() 里的调用序列:
server->get_rpc(req); // Section 14 已分析
server->process_request(req); // 这里展开
server->send_response(req); // 这里展开
14.6.1 process_request(req) 做了什么
文件: Arduino_RPCLite/src/server.h
void process_request(RPCRequest<RpcSize>& req) {
if (!req.unpack_request_headers()) {
req.reset();
return;
}
req.pack_response_headers();
dispatcher.call(req.method, req.unpacker, req.packer);
}
分三步:
第一步: req.unpack_request_headers()
文件: Arduino_RPCLite/src/request.h
把 req.buffer 里的原始字节流喂给 unpacker,解出:
req.type- 消息类型(CALL_MSG / NOTIFY_MSG)req.msg_id- 请求 IDreq.method- 方法名
同时 unpacker 的读取位置停在了"参数区域"的开头,等待后续消费.
第二步: req.pack_response_headers()
文件: Arduino_RPCLite/src/request.h
如果 type == CALL_MSG,向 packer 写入 response 包头:
packer.serialize(resp_size, RESP_MSG, msg_id);
即 [RESPONSE_SIZE, 1, msg_id].
此时 packer 里的内容是:
[4, 1, msg_id, ???, ???]
^后续由 dispatcher 填入 result 和 error
如果 type == NOTIFY_MSG,不写 response header(因为 notify 不需要回复).
第三步: dispatcher.call(req.method, req.unpacker, req.packer)
文件: Arduino_RPCLite/src/dispatcher.h
bool call(const MsgPack::str_t& name, MsgPack::Unpacker& unpacker, MsgPack::Packer& packer) {
for (size_t i = 0; i < _count; ++i) {
if (_entries[i].name == name) {
return (*_entries[i].fn)(unpacker, packer);
}
}
// handler not found
MsgPack::object::nil_t nil;
packer.serialize(RpcError(FUNCTION_NOT_FOUND_ERR, name), nil);
return false;
}
遍历表,找到 name 匹配的 entry,调用它的 wrapper operator()(unpacker, packer).
这就是 Section 14.5 分析的 RpcFunctionWrapper 被真正调用的地方.
wrapper 内部会:
- 从 unpacker 反序列化参数
- 调用用户回调
- 把结果(或错误)写入 packer
如果找不到匹配的 method,直接写入 FUNCTION_NOT_FOUND_ERR 错误.
14.6.2 send_response(req) 做了什么
文件: Arduino_RPCLite/src/server.h
bool send_response(const RPCRequest<RpcSize>& req) const {
if (req.type == NO_MSG || req.packer.size() == 0) {
return true; // No response to send
}
if (req.type == NOTIFY_MSG) return true;
return decoder->send_response(req.packer);
}
三个出口:
- 如果
type == NO_MSG或 packer 是空的 → 不发(无效请求或处理失败被 reset 了) - 如果
type == NOTIFY_MSG→ 不发(通知不需要回复) - 否则 → 调
decoder->send_response(req.packer)把 packer 里的字节流发到串口
此时 packer 里装的是完整的 response:
[RESPONSE_SIZE, RESP_MSG, msg_id, result_or_nil, error_or_nil]
这就是 Linux 侧最终收到的 RPC 回包.
14.6.3 把执行链路压成一句话
get_rpc(req) → 从串口读一个完整包,窥探 method 名,匹配 tag
process_request() → 解包头,dispatcher 查表,wrapper 反序列化参数+调用回调+序列化结果
send_response() → 把 packer 里的回包发回串口
前面笔记跟到了 get_rpc() 就停了.现在补齐后,MCU 侧的完整"收请求 → 执行 → 回包"链路就清楚了.
15. provide_safe() 为什么和线程有关
看定义:
文件: Arduino_RouterBridge/src/bridge.h
template<typename F>
bool provide_safe(const MsgPack::str_t& name, F&& func) {
k_mutex_lock(&bridge_mutex, K_FOREVER);
bool res;
bool out = call(BIND_METHOD, name).result(res) && res && server->bind(name, func, "__safe__");
k_mutex_unlock(&bridge_mutex);
return out;
}
和 provide() 的唯一区别,是本地 bind 多传了一个 tag:
"__safe__"
15.1 这个 tag 后面在哪里被用到
后台线程 update() 调:
server->get_rpc(req)
默认 tag 是 "".
而主线程安全路径 update_safe() 调:
文件: Arduino_RouterBridge/src/bridge.h
if (!server->get_rpc(req, "__safe__")) {
...
return;
}
而 update_safe() 又是从主 loop 线程的 hook 里被调:
文件: Arduino_RouterBridge/src/bridge.h
void __attribute__((weak)) __loopHook(void){
k_yield();
safeUpdate();
}
所以:
provide()
本地 tag 是""
由 bridge 后台线程update()处理provide_safe()
本地 tag 是"__safe__"
由主 loop 线程update_safe()处理
router 并不知道 safe 不 safe.
这个差异完全发生在 MCU 本地 dispatcher 选路这一层.
16. method xxx not available 到底表示什么
前面已经看到,这条错误是在 router 里生成的:
文件: arduino-router/internal/msgpackrouter/router.go
client, ok := r.getConnectionForMethod(method)
if !ok {
fwdRes(nil, routerError(ErrCodeMethodNotAvailable, fmt.Sprintf("method %s not available", method)))
return
}
所以这条错误最准确的含义是:
router 当前没有找到这个 method 对应的 connection
它通常对应这些情况:
- 根本没调用过
provide provide里的$/register失败了- 注册还没完成,调用来早了
- 连接断开后 router 把这个 method 从路由表删掉了
- method 名拼错了
它不直接等价于:
- "MCU 本地没 bind"
- "router 已经转发到了 MCU,但 MCU handler 不存在"
如果请求已经成功路由到 MCU,但 MCU 本地 dispatcher 没找到 handler,那么本地会走:
文件: Arduino_RPCLite/src/dispatcher.h
packer.serialize(RpcError(FUNCTION_NOT_FOUND_ERR, name), nil);
这时返回的是 RPCLite 的 FUNCTION_NOT_FOUND_ERR,不是 router 的 method xxx not available.
17. 一个完整时序例子: Bridge.provide("set_led", set_led)
把前面的链路压成顺序:
- 用户代码调用:
Bridge.provide("set_led", set_led);
provide()先进入:
call("$/register", "set_led").result(res)
call()只构造RpcCall.result(res)才真正:
- 拿
write_mutex - 通过
RPCClient::send_rpc()发 request - 包编码成
[CALL_MSG, msg_id, "$/register", ["set_led"]]
- router 收到
$/register
- 检查参数
- 执行
routes["set_led"] = 这条MCU连接 - 回复
true
.result(res)在 MCU 侧收到响应
- 用
msg_id匹配 response - 把
true写入res
provide()继续执行:
server->bind("set_led", set_led)
- MCU 本地 dispatcher 保存:
name = "set_led"
tag = ""
fn = wrapped(set_led)
到这里,这个方法才算真正"注册完成".
18. 一个完整时序例子: Linux 调 MCU 的 set_led
- Linux 某 client 向 router 发:
[REQUEST, x, "set_led", [true]]
- router 查
routes["set_led"]
- 找到 MCU 那条 connection
- router 把请求转发给 MCU
- MCU 的
update()或update_safe()读到包 server->get_rpc(req, tag):
- 先
fetch_rpc_method() - 再看 dispatcher 有没有这个
name + tag
server->process_request(req):
- 解包参数
- 找到 wrapped callback
- 调用
set_led(true) - 把返回值打包进 response
server->send_response(req)发回 router- router 再把 response 回给最初发请求的 Linux client
19. 回到最开始的问题,给一句最短结论
provide(name, func) 的本质不是"把函数给 Linux".
它实际做的是:
- 用
name向 router 注册一个可路由的方法名 - 用
func在 MCU 本地绑定这个方法名对应的实现
所以:
name是对外的 RPC 名字func是 MCU 本地真正执行的 C++ 回调
而 call() 返回 RpcCall,是为了把"构造调用"和"真正阻塞执行"分开;provide_safe() 则是在 MCU 本地多打一个 tag,让 method 只能在主线程那条处理路径里执行.
20. 关键文件索引
- Arduino_RouterBridge/src/bridge.h
- Arduino_RouterBridge/src/singletons.cpp
- Arduino_RPCLite/src/client.h
- Arduino_RPCLite/src/server.h
- Arduino_RPCLite/src/decoder.h
- Arduino_RPCLite/src/decoder_manager.h
- Arduino_RPCLite/src/dispatcher.h*
- arduino-router/internal/msgpackrouter/router.go
- arduino-router/main.go
21. RpcCall 里的 client 是哪来的
前面在 provide() 里看到:
call(BIND_METHOD, name).result(res)
再跟到:
template<typename... Args>
RpcCall<Args...> call(const MsgPack::str_t& method, Args&&... args) {
return RpcCall<Args...>(method, client, &read_mutex, &write_mutex, std::forward<Args>(args)...);
}
这里传给 RpcCall 的 client 不是临时创建的,而是 BridgeClass 的成员:
文件: Arduino_RouterBridge/src/bridge.h
RPCClient* client = nullptr;
RPCServer* server = nullptr;
它们是在 Bridge.begin() 里预先初始化好的:
文件: Arduino_RouterBridge/src/bridge.h
serial_ptr->begin(baud);
...
transport = new SerialTransport(*serial_ptr);
client = new RPCClient(*transport);
server = new RPCServer(*transport);
所以初始化链是:
Bridge.begin()- 打开
Serial1 - 创建
SerialTransport - 用同一个
transport创建RPCClient - 用同一个
transport创建RPCServer - 后续
Bridge.call()只是把这个已经存在的client借给RpcCall
结论:
RpcCall本身不拥有通信端RpcCall只是一个"待执行请求对象"- 真正的通信端是
Bridge.begin()时创建好的client
22. 为什么 client 和 server 能共用一条串口
这点如果不展开,看 Bridge 很容易误以为:
client负责"发请求"server负责"收请求"- 两者互不相关
实际上它们底层共享的是同一个 transport,对应同一个 decoder 管理器.
文件: Arduino_RPCLite/src/client.h
explicit RPCClient(ITransport& t) : decoder(&RpcDecoderManager<>::getDecoder(t)) {}
文件: Arduino_RPCLite/src/server.h
explicit RPCServer(ITransport& t) : decoder(&RpcDecoderManager<>::getDecoder(t)) {}
文件: Arduino_RPCLite/src/decoder_manager.h
static RpcDecoder<>& getDecoder(ITransport& transport)
这意味着:
- 同一个
transport - 会拿到同一个
RpcDecoder - 所以收包缓存,解析状态其实是共享的
这就是为什么 Bridge 里必须有:
read_mutexwrite_mutex
否则:
RpcCall.result()在读 responseupdate()同时也在读 request
就会直接抢同一份 decoder 状态.
从这个角度看,Bridge 更像一个"在单条双向链路上复用 client/server 语义的复合端点".
23. atomic_cas 在这里到底防什么
文件: Arduino_RouterBridge/src/bridge.h
if (!atomic_cas(&_executed, 0, 1)){
setError(GENERIC_ERR, "This call is no longer available");
return false;
}
这里的 atomic_cas 可以理解成:
如果
_executed == 0,就原子地把它改成1,并返回成功;
如果_executed != 0,就不改,返回失败.
在这个场景里它的作用是:
- 保证同一个
RpcCall只能被成功执行一次 - 防止两个线程同时对同一个
RpcCall调.result() - 防止同一个逻辑调用被重复发包
所以它不是协议层逻辑,而是 RpcCall 这个一次性对象的并发保护.
24. router 这层天然是"多连接,多端点"模型
如果只看 Bridge,很容易觉得这套东西是"单 MCU <-> 单 Linux".
但如果看 router 这层的数据结构,就会发现它本来就是多连接模型.
文件: arduino-router/internal/msgpackrouter/router.go
type Router struct {
routesLock sync.Mutex
routes map[string]*msgpackrpc.Connection
routesInternal map[string]RouterRequestHandler
}
再看注册函数:
文件: arduino-router/internal/msgpackrouter/router.go
func (r *Router) registerMethod(method string, conn *msgpackrpc.Connection) error
这里 router 注册的不是"某块固定 MCU 的方法",而是:
- 方法名
method - 属于哪条连接
conn
所以从设计上说,这套路由本来就能扩展到:
- 多个 MCU
- 多个 Linux 进程
- 多个代理外设
- MCU 和 Linux 进程互相提供服务
router 并不关心对端是什么实体,它只关心:
哪条 connection 声明了哪个 method
因此它更像一个:
- 基于连接的 RPC 交换机
而不是:
- 专门服务某个单 MCU 的硬编码桥
25. 当前方案是"按 method 名暴露能力"
现在这套设计的核心是:
- 每个对外能力都注册成一个独立 method
- router 维护
method -> connection - MCU 本地 dispatcher 维护
method -> callback
所以完整查找过程是两级:
- router 全局查找
method -> 哪条connection
- MCU 本地查找
method -> 哪个wrapped callback
这是一个典型的"细粒度方法路由"设计.
它的好处是:
- method 名可读性强
- sketch 层书写很直观
- 调试时容易定位具体调用
- 不需要应用层自己再写二级 dispatch
它的代价是:
- 全局 method 名空间更容易冲突
- 每个能力都要单独注册
- router 只知道字符串方法名,不知道更高层语义
- 当能力很多时,注册表会变大
26. 另一种可能的方案是"总入口 + 参数 dispatch"
从当前实现出发,可以推导出另一种架构方案:
- MCU 不对外暴露很多 method
- 只注册一个总入口,比如:
mcu.callsvc.invokedispatch
然后把真实目标放到参数里,例如:
service_id
command_id
payload
flags
version
这样 router 只需要知道很少几个固定入口.
这类方案的特点是:
- router 路由面更小
- method 名冲突更少
- 更容易做版本管理
- 更容易集中做权限控制或 capability 控制
- 更像系统接口层,而不是直接暴露一组函数名
但代价也很明显:
- sketch 端写法没现在直观
- 应用层自己要再做一次 dispatch
- 类型信息不如独立 method 清晰
- 调试时看到的入口名不够语义化
所以两种设计更适合不同目标:
- 当前实现:
面向开发者友好,适合直接把 MCU 功能按函数名暴露出来 - 总入口方案:
面向系统架构友好,适合能力集中管理,版本演进,权限控制
可以把它们粗略理解成:
- 当前设计 = 面向函数名的 RPC
- 总入口设计 = 面向 service/capability 的 RPC
27. 为什么当前实现更容易写
结合上面两节,可以得出一个比较务实的结论.
对 Arduino 用户来说,当前设计:
Bridge.provide("set_led", set_led);
Bridge.provide("get_temp", get_temp);
明显比下面这种更容易接受:
Bridge.provide("dispatch", dispatch);
然后所有请求都由 dispatch(payload) 再人工分流.
也就是说,现在这套 API 优先优化的是:
- sketch 作者的书写成本
- 示例代码的直观程度
- 学习门槛
而不是优先优化:
- 中央能力模型
- 路由命名空间治理
- 安全域抽象
这也是为什么从当前代码可以看出,它更像一个"开发者可直接使用的 RPC bridge",而不是一个"严格能力封装的系统调用门".
28. 专门跟踪 provide_safe()
前面已经知道:
provide()走默认 tag""provide_safe()走 tag"__safe__"
但如果要真正理解它为什么能保证"在主线程执行",还得把整条链路按代码继续跟下去.
我们从定义开始.
文件: Arduino_RouterBridge/src/bridge.h
template<typename F>
bool provide_safe(const MsgPack::str_t& name, F&& func) {
k_mutex_lock(&bridge_mutex, K_FOREVER);
bool res;
bool out = call(BIND_METHOD, name).result(res) && res && server->bind(name, func, "__safe__");
k_mutex_unlock(&bridge_mutex);
return out;
}
28.1 先看它和 provide() 唯一的代码差异
对比 provide():
server->bind(name, func);
和 provide_safe():
server->bind(name, func, "__safe__");
唯一差别就是多了一个第三个参数:
"__safe__"
所以:
- router 注册流程没有变化
call("$/register", name).result(res)没有变化- 变化只发生在 MCU 本地
server->bind(...)这里
这说明:
safe不是 router 的属性,而是 MCU 本地 dispatcher 的属性.
29. 继续跟踪 server->bind(name, func, "__safe__")
文件: Arduino_RPCLite/src/server.h
template<typename F>
bool bind(const MsgPack::str_t& name, F&& func, MsgPack::str_t tag=""){
return dispatcher.bind(name, func, tag);
}
再往下:
文件: Arduino_RPCLite/src/dispatcher.h
template<typename F>
bool bind(MsgPack::str_t name, F&& f, MsgPack::str_t tag="") {
if (_count >= N) return false;
if (isBound(name)) return false;
_entries[_count++] = {name, tag, wrap(std::forward<F>(f))};
return true;
}
29.1 provide_safe() 最终往表里写了什么
如果用户写的是:
Bridge.provide_safe("greet", greet);
那么 MCU 本地 dispatcher 最终存进去的是一条类似:
name = "greet"
tag = "__safe__"
fn = wrapped(greet)
而不是:
name = "greet"
tag = ""
所以 provide_safe() 的本质可以先记成一句话:
它不是换了一套 router 注册机制,而是在 MCU 本地把这个 method 打上
"__safe__"标签.
30. 接下来问题变成: 谁会消费 "__safe__" 这个 tag
先看 Bridge 里有哪几条收包处理路径.
30.1 后台线程路径: update()
文件: Arduino_RouterBridge/src/bridge.h
void update() {
if (k_mutex_lock(&read_mutex, K_MSEC(10)) != 0 ) return;
RPCRequest<> req;
if (!server->get_rpc(req)) {
k_mutex_unlock(&read_mutex);
k_msleep(1);
return;
}
k_mutex_unlock(&read_mutex);
server->process_request(req);
while (true) {
if (k_mutex_lock(&write_mutex, K_MSEC(10)) == 0){
server->send_response(req);
k_mutex_unlock(&write_mutex);
break;
} else {
k_yield();
}
}
}
这里调用的是:
server->get_rpc(req)
也就是没有显式传 tag,因此用的是默认值 "".
30.2 主线程路径: update_safe()
文件: Arduino_RouterBridge/src/bridge.h
void update_safe() {
if (k_mutex_lock(&read_mutex, K_MSEC(10)) != 0 ) return;
RPCRequest<> req;
if (!server->get_rpc(req, "__safe__")) {
k_mutex_unlock(&read_mutex);
k_msleep(1);
return;
}
k_mutex_unlock(&read_mutex);
server->process_request(req);
while (true) {
if (k_mutex_lock(&write_mutex, K_MSEC(10)) == 0){
server->send_response(req);
k_mutex_unlock(&write_mutex);
break;
} else {
k_yield();
}
}
}
这里调用的是:
server->get_rpc(req, "__safe__")
也就是说:
update()只接默认 tag""update_safe()只接 tag"__safe__"
到这里已经能看出来:
同一条物理链路上的 request,会被哪条执行路径消费,取决于 method 在 dispatcher 里绑定的 tag.
31. update_safe() 又是谁调用的
这是理解"为什么它是主线程安全"的关键.
先看后台线程入口.
文件: Arduino_RouterBridge/src/bridge.h
inline void updateEntryPoint(void *, void *, void *){
while (true) {
if (Bridge) {
Bridge.update();
}
k_yield();
}
}
而这个线程是在 Bridge.begin() 里创建的:
文件: Arduino_RouterBridge/src/bridge.h
upd_tid = k_thread_create(&upd_thread_data, upd_stack_area,
UPDATE_THREAD_STACK_SIZE,
updateEntryPoint,
NULL, NULL, NULL,
UPDATE_THREAD_PRIORITY, 0, K_NO_WAIT);
所以:
update()运行在单独的 Zephyr 线程里
再看 update_safe() 这边.
文件: Arduino_RouterBridge/src/bridge.h
static void safeUpdate(){
BridgeClassUpdater::safeUpdate(&Bridge);
}
而 safeUpdate() 又在这里被调:
文件: Arduino_RouterBridge/src/bridge.h
void __attribute__((weak)) __loopHook(void){
k_yield();
safeUpdate();
}
31.1 为什么说这是主 loop 线程路径
__loopHook() 被设计成 Arduino 主循环上下文里的 hook.
README 也明确写了:
Thread-safe methods execution is granted in the main loop thread where update_safe is called
所以当前设计意图是:
- 后台 bridge 线程持续跑
update() - 主 loop 线程周期性跑
__loopHook() __loopHook()再调用safeUpdate()safeUpdate()再调用Bridge.update_safe()
这条链路可以直接写成:
__loopHook()
-> safeUpdate()
-> BridgeClassUpdater::safeUpdate(&Bridge)
-> Bridge.update_safe()
-> server->get_rpc(req, "__safe__")
于是 "__safe__" tag 的 method,就被强制放到了主 loop 线程处理.
32. 继续跟踪 server->get_rpc(req, "__safe__")
文件: Arduino_RPCLite/src/server.h
template<size_t RpcSize = DEFAULT_RPC_BUFFER_SIZE>
bool get_rpc(RPCRequest<RpcSize>& req, MsgPack::str_t tag="") {
decoder->decode();
const MsgPack::str_t method = decoder->fetch_rpc_method();
if (method == "" || !dispatcher.hasTag(method, tag)) return false;
req.size = decoder->get_request(req.buffer, RpcSize);
return req.size > 0;
}
这里逻辑要拆开看.
32.1 decoder->decode()
继续从共享 transport 里读字节,尝试解析出一条完整包.
32.2 decoder->fetch_rpc_method()
先从包头里把 method 名字读出来,但还没把这个包正式交给 handler.
32.3 dispatcher.hasTag(method, tag)
这是关键选择点.
它问的是:
当前这个 method,在本地 dispatcher 表里,是否存在一条
name == method && tag == 当前tag的记录?
如果答案是否定的,那么:
- 这条路径不会消费这个 request
get_rpc()返回false- 调用者这次循环什么都不做
所以 tag 的作用不是"给 handler 打个说明",而是直接参与"这条请求应不应该由当前线程处理"的判定.
33. 继续跟踪 dispatcher.hasTag(method, tag)
文件: Arduino_RPCLite/src/dispatcher.h
bool hasTag(const MsgPack::str_t& name, MsgPack::str_t& tag) const {
for (size_t i = 0; i < _count; ++i) {
if (_entries[i].name == name && _entries[i].tag == tag) {
return true;
}
}
return false;
}
这就是最终判定规则,没有隐藏逻辑.
要命中,必须同时满足:
- method 名相等
- tag 相等
因此:
provide("foo", fn)注册的{ name="foo", tag="" }
只能被get_rpc(req, "")命中provide_safe("foo", fn)注册的{ name="foo", tag="__safe__" }
只能被get_rpc(req, "__safe__")命中
这也解释了为什么 provide_safe() 本质上是在做"线程选路".
34. provide_safe() 的一次完整时序
现在把整条链收束成一个完整过程.
假设用户写:
Bridge.provide_safe("greet", greet);
34.1 注册阶段
provide_safe("greet", greet)- 先执行:
call("$/register", "greet").result(res)
- router 注册:
routes["greet"] = 这条MCU连接
- router 回复
true - MCU 本地执行:
server->bind("greet", greet, "__safe__")
- dispatcher 存下:
name = "greet"
tag = "__safe__"
fn = wrapped(greet)
34.2 调用阶段
- Linux 某 client 调用:
"greet"
- router 查全局表:
routes["greet"] -> 这条MCU连接
- 请求被转发到 MCU 串口
- MCU 后台线程
update()先看到这个包 - 它调用:
server->get_rpc(req)
也就是 tag 为 ""
dispatcher.hasTag("greet", "")返回false- 所以后台线程不会处理它
- 主 loop 线程跑到
__loopHook() __loopHook()调safeUpdate()safeUpdate()调Bridge.update_safe()update_safe()调:
server->get_rpc(req, "__safe__")
dispatcher.hasTag("greet", "__safe__")返回true- 于是这次 request 被主线程路径取走
server->process_request(req)最终执行greet()server->send_response(req)把结果发回去
最终效果就是:
method 名还是同一个
greet,
router 看不见任何safe信息,
但 MCU 本地用 tag 把它强制导向主 loop 线程处理.
35. provide_safe() 的设计本质
到这里可以把它压缩成一句更准确的话:
provide_safe() 不是"更安全的 router 注册",也不是"另一种 RPC 协议",而是:
在 MCU 本地 dispatcher 上给 method 打上
"__safe__"标签,
再让主 loop 线程的update_safe()专门只消费这个标签的方法.
所以它保护的不是:
- 网络安全
- router 级别权限
- Linux 侧可见性
它保护的是:
- 执行上下文
- 线程归属
- 某些只能在主线程安全调用的 Arduino / 用户代码
36. 一个重要推论: safe 不是权限模型,而是执行模型
这点很容易误解.
当前代码里的 "__safe__":
- 不是 method namespace
- 不是 router 元数据
- 不是 Linux 侧必须显式指定的标记
- 不是鉴权信息
它只是本地 dispatcher 的路由条件.
所以:
- Linux 侧调用
"greet",不会知道它是 safe 还是 non-safe - router 转发
"greet"时,也不会区分 safe / non-safe - 真正的分流只发生在 MCU 收到包之后
因此 provide_safe() 可以理解成:
同名 RPC 的"本地执行线程选择器"
而不是:
对外暴露不同接口
37. 和 provide() 做一个并排对照
37.1 provide("foo", fn)
注册结果:
dispatcher entry = { name="foo", tag="" }
消费路径:
updateEntryPoint()
-> Bridge.update()
-> server->get_rpc(req)
-> dispatcher.hasTag("foo", "")
执行线程:
bridge 后台线程
37.2 provide_safe("foo", fn)
注册结果:
dispatcher entry = { name="foo", tag="__safe__" }
消费路径:
__loopHook()
-> safeUpdate()
-> Bridge.update_safe()
-> server->get_rpc(req, "__safe__")
-> dispatcher.hasTag("foo", "__safe__")
执行线程:
Arduino 主 loop 线程
所以本质差异只有一句:
两者对 router 来说完全一样,
只是在 MCU 本地选择由哪条线程路径来消费 request.
38. 启动时序与故障规避
结合前面的链路,可以把当前实现对启动顺序的依赖明确记下来.
38.1 MCU 重启后的旧注册问题
Bridge.begin() 里有这一句:
文件: Arduino_RouterBridge/src/bridge.h
started = call(RESET_METHOD).result(res) && res;
其中:
#define RESET_METHOD "$/reset"
也就是说,MCU 在 bridge 初始化时会先向 router 发一个:
$/reset
作用是:
- 清掉 router 里这条 connection 之前遗留的 method 注册
- 避免 MCU 重启后,router 还保留旧的
method -> connection映射
所以第一类问题:
MCU 已经复位,但 router 还以为它之前注册的方法仍然有效
当前设计有两条清理路径:
- MCU 主动发
$/reset(如上所述) - 连接断开时 router 自动清理
文件: arduino-router/internal/msgpackrouter/router.go
// Unregister the methods when the connection is terminated
r.removeMethodsFromConnection(msgpackconn)
msgpackconn.Close()
这是在 Accept() 的连接循环尾部执行的.当串口连接物理断开(比如 MCU 复位导致串口消失,或者线被拔掉),msgpackconn.Run() 会因 io.EOF 或其他错误退出循环,然后自动执行:
r.removeMethodsFromConnection(msgpackconn)
这个方法会遍历整个 routes 表,把所有属于这条 connection 的 method 都删掉.
所以旧注册残留的防护是双保险的:
- MCU 重启后主动发
$/reset→ 主动清理 - 旧连接物理断开 → router 感知到断开后自动清理
但如果场景是"MCU 软复位,串口链路没有物理断开",那么 router 不会自动清理,这时候就只能靠 $/reset.
38.2 但对端未就绪问题没有被协议层彻底解决
另一类问题是:
- MCU 先运行
- MCU 已经开始
Bridge.begin()/provide()/call() - 但 Linux 上的 router 或业务服务还没起来
这时就会出现:
- 串口上发出的注册请求没有对端处理
- 或 method 已注册到 router,但实际 Linux 侧业务方还没起来
- 或业务请求被发出后,对面没有按预期回复
从当前代码看,没有一套完整的:
- 注册租约
- 心跳续约
- 自动重注册
- 应用层可靠重试
- 对端 ready 握手
也就是说,当前设计更依赖:
- 启动顺序正确
- 或上层自己做等待 / 重试 / 延迟
而不是由 RPC 协议层自动兜底.
38.3 当前部署顺序下暴露出的设计风险
根据当前项目使用方式,有一个需要明确记录的风险顺序:
- 先下载 MCU 程序
- 释放复位,MCU 开始运行
- 之后才启动容器
- Python 脚本运行在容器内
在这个顺序下,MCU 很可能先于 Linux 侧业务环境开始执行 Bridge.begin(),provide() 或后续 RPC.
这会带来一个现实问题:
- MCU 侧认为自己已经开始 bridge 初始化
- 但 router / 容器内 Python 还没 ready
- 于是早期注册或早期调用存在失败,超时,无响应的窗口
因此可以明确记为一个设计风险:
当前系统如果采用"MCU 先启动,容器后启动"的顺序,那么 bridge 初始化和早期 RPC 依赖时序,存在启动窗口问题.
这个问题不是 $/reset 能解决的,因为:
$/reset解决的是"旧状态清理"- 它不能解决"对端此刻根本还没起来"
38.4 工程上的规避方式
在当前实现不改协议的前提下,比较现实的规避方式是:
- 让 router / 容器 / Python 服务先 ready,再释放 MCU 运行
- 或让 MCU 侧在业务注册前主动等待一段时间
- 或让 MCU 侧对关键注册 / 首次调用做循环探测
- 或增加一条显式的 ready RPC,只有 ready 成功后才继续注册和业务执行
换句话说:
- 最稳的时序是"CPU/router 先,MCU 后"
- "MCU 先,CPU 后"在当前实现下需要额外保护
38.5 当前结论
可以把这一点直接记成一句判断:
当前 bridge / router 设计对启动时序敏感.
$/reset只解决旧注册残留,不解决对端未 ready 的问题.
如果部署顺序是"MCU 先运行,容器后启动",那么这是一个真实的系统设计缺陷,需要靠上层时序控制或额外握手补齐.
39. 一个容易漏掉但很重要的点: RpcCall 不是"纯惰性对象"
前面为了说明主链,重点强调了:
Bridge.call()只构造RpcCallresult()才真正执行 RPC
这个主判断没错,但还需要补两条"隐式执行"语义,否则读代码时容易误判.
文件: Arduino_RouterBridge/src/bridge.h
bool result() {
MsgPack::object::nil_t nil;
return result(nil);
}
~RpcCall(){
result();
}
operator bool() {
return result();
}
39.1 析构函数会触发一次执行
这意味着:
- 如果你写了
Bridge.call("foo", 1, 2); - 即使没有显式
.result(...) - 这个临时对象析构时仍然会调用一次
result()
所以更准确地说:
call()本身不立即发包- 但
RpcCall不是"永远不执行,除非你手动.result()" - 它在析构时会补做一次"默认以 nil 结果接收"的执行
39.2 if (Bridge.call(...)) 也会触发执行
因为定义了:
operator bool() {
return result();
}
所以像 README 里的这种写法:
if (!Bridge.call("send_greeting", "Hello Friend")) {
...
}
本质不是"判断对象是否有效",而是:
- 先执行 RPC
- 再把执行结果转成布尔值
39.3 因此建议把 RpcCall 的语义写成一句更准确的话
Bridge.call()返回的是"延迟到result()/operator bool()/ 析构时才真正发出的单次 RPC 调用对象".
40. notify() 和 call() 的边界也值得单独记一下
文件: Arduino_RouterBridge/src/bridge.h
template<typename... Args>
RpcCall<Args...> call(const MsgPack::str_t& method, Args&&... args) {
return RpcCall<Args...>(method, client, &read_mutex, &write_mutex, std::forward<Args>(args)...);
}
template<typename... Args>
void notify(const MsgPack::str_t method, Args&&... args) {
while (true) {
if (k_mutex_lock(&write_mutex, K_MSEC(10)) == 0) {
client->notify(method, std::forward<Args>(args)...);
k_mutex_unlock(&write_mutex);
break;
}
k_yield();
}
}
这里建议你补一个对照表:
call()
发送CALL_MSG
有msg_id
期待 response
会占用读路径等待匹配响应notify()
发送NOTIFY_MSG
没有msg_id
不等 response
更适合日志,事件,单向信号
这点和 Monitor.write() 的实现正好对应.
文件: Arduino_RouterBridge/src/monitor.h
if (_compatibility_mode) {
bridge->call(MON_WRITE_METHOD, send_buffer).result(written);
} else {
bridge->notify(MON_WRITE_METHOD, send_buffer);
written = size;
}
也就是:
- 老版本 router 兼容路径走
call - 新版本 router 可以直接走
notify
这能帮助读者理解:不是所有 Bridge API 都建立在"请求-响应"之上.
41. router 实际不只有 $/register
如果这份笔记的目标是"UNO Q 通信机制全景",那现在还缺一个"控制面/API 面总览".
41.1 router 内建控制 RPC
文件: arduino-router/internal/msgpackrouter/router.go
当前 router 至少显式处理了这些控制方法:
$/register
注册某个 method 到某条 connection$/reset
清掉该 connection 之前注册的全部 method$/setMaxMsgSize
调整该 connection 的最大 outgoing message 限制
另外 $/version 不是在 router.go 里硬编码,而是在启动阶段注册进去:
if err := router.RegisterMethod("$/version", ...); err != nil {
...
}
所以从系统视角看,BridgeClass 当前实际依赖的 router 控制面至少有:
$/register$/reset$/version
而不是只有一个 $/register.
41.2 串口控制 RPC 也属于系统通信面的一部分
router 还注册了:
$/serial/open$/serial/close
这部分虽然不是 Bridge.provide() 主链的一环,但它说明:
- router 不只是"method name 转发器"
- 它自己也承担了一部分"系统资源控制器"的角色
41.3 $/serial/open 和 $/serial/close 的动态串口管理
这两个方法允许外部通过 RPC 动态打开和关闭串口连接.
$/serial/open 做的是:
err := router.RegisterMethod("$/serial/open", func(_ *msgpackrpc.Connection, params []any, res msgpackrouter.RouterResponseHandler) {
address, ok := params[0].(string)
// 验证地址是否匹配配置
// 通知后台 goroutine 打开串口
serialCloseSignal = make(chan struct{})
serialOpened.Broadcast()
res(true, nil)
})
$/serial/close 做的是:
err = router.RegisterMethod("$/serial/close", func(_ *msgpackrpc.Connection, params []any, res msgpackrouter.RouterResponseHandler) {
address, ok := params[0].(string)
// 验证地址是否匹配配置
close(serialCloseSignal)
serialCloseSignal = nil
serialClosed.Wait()
res(true, nil)
})
关键设计点:
- 串口不是启动时固定打开的,而是可以被远程 RPC 控制开关
- 用
sync.Cond做打开/关闭的同步:serialOpened和serialClosed两个条件变量 - 用
serialCloseSignalchannel 做关闭信号传播 - router 后台有一个 goroutine 在等待串口打开信号,打开后会自动连接并开始路由
另外,当串口连接意外断开时,这个 goroutine 会自动尝试重连:
for {
serialOpened.L.Lock()
for serialCloseSignal == nil {
serialClosed.Broadcast()
serialOpened.Wait() // 等待 open 信号
}
serialOpened.L.Unlock()
// 打开串口,连接到 router...
// 如果连接断开,循环回去等待
}
所以串口管理的设计是:
- 外部通过 RPC 控制开关
- router 内部自动重连
- 开/关状态由条件变量同步
这意味着"串口什么时候可用"不完全取决于物理连接,还取决于有没有人调过 $/serial/open.
42. Arduino_RouterBridge 里其实还有好几个不同的通信子系统
现在把"RPC method 暴露/调用主链"打通了,但 Arduino_RouterBridge 这个库本身不只暴露 Bridge.
42.1 Monitor
文件: Arduino_RouterBridge/src/monitor.h
它实际对应的是一组 monitor RPC:
mon/connectedmon/resetmon/readmon/write
本质上这是把"串口监视器语义"包装成一组 RPC.
也就是说:
- 对 sketch 来说它长得像
Stream - 对底层来说它不是直接读本地 UART
- 而是通过 Bridge 把 monitor 流量转发到 Linux 侧 monitor socket
42.2 BridgeTCPClient / BridgeTCPServer
文件: Arduino_RouterBridge/src/tcp_client.h
文件: Arduino_RouterBridge/src/tcp_server.h
它们对应的 method 包括:
tcp/connecttcp/connectSSLtcp/readtcp/writetcp/closetcp/listentcp/accepttcp/closeListener
这说明一个重要事实:
Arduino_RouterBridge不只是"让 Linux 调 MCU 方法",它还让 MCU 把 Linux 当成网络代理,借 router 去做 TCP/TLS/监听/accept.
42.3 BridgeUDP
文件: Arduino_RouterBridge/src/udp_bridge.h
对应的 method 包括:
udp/connectudp/beginPacketudp/writeudp/endPacketudp/awaitPacketudp/readudp/dropPacketudp/close
这条链路和 TCP 不同,它是"按数据报包边界"组织的,所以如果你后续补文档,建议专门把这几个状态量讲清楚:
connection_id_remainingpacket_metatemp_buffer
因为 UDP 这里不是简单的字节流转发,而是"router 侧 packet + MCU 侧 packet state"配合.
42.4 BridgeHCI
文件: Arduino_RouterBridge/src/hci.h
它对应的是:
hci/openhci/sendhci/recvhci/availhci/close
这条链说明 UNO Q 这套 bridge 不只代理"应用层 RPC",还在尝试把 Linux 主机上的蓝牙 HCI 通道通过 RPC 暴露给 MCU 侧.
如果你想把"通信库"分析做完整,这块很值得单列,因为它已经不是普通业务 RPC,而是"主机设备接口代理".
43. Linux 侧还有三个真正的后端 API 模块
当前启动时会注册三类内部 API:
networkapi.Register(router)hciapi.Register(router)monitorapi.Register(router, cfg.MonitorPortAddr)
43.1 你现在主线实际只详细分析了 msgpackrouter
这当然是对的,因为它是"method 路由内核".
但如果问题改成:
当前通信库里还有哪些没分析到
那么至少还包括:
arduino-router/internal/network-api
真正把tcp/*,udp/*RPC 落到 Gonet.Conn/net.Listener/net.PacketConnarduino-router/internal/monitorapi
真正把mon/*RPC 接到 monitor TCP socketarduino-router/internal/hciapi
真正把hci/*RPC 接到 Linux HCI raw socket
也就是说,msgpackrouter 负责"谁该收到请求",而这些 internal API 模块负责"请求到了以后具体怎么碰 Linux 资源".
44. 如果把"当前仓库里的通信库"拉成清单,还缺这些层
这个仓库里和"通信"有关的内容,严格来说可以分四层:
44.1 主机桥接/RPC 层
Arduino_RouterBridgeArduino_RPCLitearduino-router/msgpackrpcarduino-router/internal/msgpackrouter
这一层你已经分析了核心主链,但还没补完 Monitor/TCP/UDP/HCI 四个子面.
44.2 router 内部资源代理层
arduino-router/internal/network-apiarduino-router/internal/monitorapiarduino-router/internal/hciapi
这层你目前基本还没展开.
44.3 Zephyr 网络抽象层
文件:
- ArduinoCore-zephyr/libraries/WiFi/src/WiFi.h
- ArduinoCore-zephyr/libraries/SocketWrapper/SocketWrapper.h
- ArduinoCore-zephyr/libraries/SocketWrapper/ZephyrClient.h
- ArduinoCore-zephyr/libraries/SocketWrapper/ZephyrServer.h
- ArduinoCore-zephyr/libraries/SocketWrapper/ZephyrUDP.h
- ArduinoCore-zephyr/libraries/Ethernet/README.md
这层的意义是:
WiFi负责接入/状态管理SocketWrapper/ZephyrClient/ZephyrServer/ZephyrUDP把 Zephyr socket API 包装成 Arduino 风格接口Ethernet在当前仓库里更像网络能力入口,而不是一套单独协议栈实现
暂时不考虑补了,除非你要这么做:
- "直接在 Zephyr 上做网络"
- "经由 RouterBridge 把 Linux 当网络代理"
那么这一层就必须补.
44.4 板级总线通信层
文件:
这层不是 host-bridge 网络通信,而是板级外设总线:
Wire= I2CSPI= SPI
如果你说的"通信库"包含板级总线,那它们也还没分析.
如果你说的"通信库"只指 UNO Q 和 Linux 主机之间的通信,那这层可以放到次级优先级.
45. 分析其他Call差异
算了,以后再说,看着很乱.
46. 一句话总结这次补充
你现在这份笔记已经把:
provide()call()RpcCall.result()RPCLiterouter method 注册/转发
这一条主链分析清楚了.
这里暂时不分析的三大片:
RpcCall的隐式执行语义RouterBridge的 Monitor/TCP/UDP/HCI 四个子系统arduino-router里真正落到 Linux 资源的networkapi/monitorapi/hciapi
47. 专门展开 Monitor
Monitor 这条链很值得单独分析,因为它看起来像普通 Serial/Stream,但实际上已经不是"本地 UART",而是:
MCU 上的
Stream包装
-> Bridge RPC
-> router 的monitorapi
-> Linux 侧 monitor TCP socket
47.1 Monitor.begin() 做了三件事
文件: Arduino_RouterBridge/src/monitor.h
bool begin(...) {
...
if (!bridge_started) {
bridge_started = bridge->begin();
}
...
_connected = bridge->call(MON_CONNECTED_METHOD).result(out) && out;
MsgPack::str_t ver;
_compatibility_mode = !bridge->getRouterVersion(ver);
...
}
这里实际做的是:
- 确保
Bridge已经起来 - 调
mon/connected问 Linux 侧当前是否有 monitor 客户端连上 - 调
$/version判断 router 是否支持较新的 monitor 行为
所以 Monitor.begin() 本质不是"初始化一个本地串口",而是"探测并挂接一条经 router 转发的监控流".
47.2 Monitor.write() 的真实路径
文件: Arduino_RouterBridge/src/monitor.h
if (_compatibility_mode) {
bridge->call(MON_WRITE_METHOD, send_buffer).result(written);
} else {
bridge->notify(MON_WRITE_METHOD, send_buffer);
written = size;
}
对应 Linux 侧:
文件: arduino-router/internal/monitorapi/monitor-api.go
monitorapi.write() 会:
- 把参数当作
[]byte或string - 遍历当前所有 monitor TCP client
- 把数据写到这些 socket
所以这条链可以记成:
- MCU
Monitor.write() Bridge.call/notify("mon/write", data)- router 命中 internal method
mon/write monitorapi.write()广播给 Linux monitor clients
47.3 Monitor.read() 的真实路径
MCU 侧不是每次直接向 Linux 要一个字符,而是:
available()
先看本地temp_buffer还能塞多少_read(size)
通过Bridge.call("mon/read", size)向 router 拉一批字节- Linux 侧
monitorapi.read()
从monSendPipe里读数据返回 - MCU 再把这批数据塞进
temp_buffer
文件: Arduino_RouterBridge/src/monitor.h
文件: arduino-router/internal/monitorapi/monitor-api.go
这说明:
Monitor是"批量拉取 + 本地环形缓存"- 不是"每个字节都同步 RPC"
47.4 monitorapi 不是走串口,而是额外开了一个 TCP 监听口
文件: arduino-router/internal/monitorapi/monitor-api.go
listener, err := net.Listen("tcp", addr)
...
_ = router.RegisterMethod("mon/connected", connected)
_ = router.RegisterMethod("mon/read", read)
_ = router.RegisterMethod("mon/write", write)
_ = router.RegisterMethod("mon/reset", reset)
所以 monitor 这条链不是:
- MCU 串口 <-> IDE 串口监视器
而是:
- MCU <-> router 串口 MsgPack RPC
- router <-> monitor TCP socket
这个拓扑如果不单独说出来,读者很容易把 Monitor 误解成"另一个普通串口对象".
48. TCP
BridgeTCPClient / BridgeTCPServer 的本质不是 MCU 自己有 TCP 协议栈去直接连网,而是:
MCU 把 TCP 操作编码成 RPC
router 在 Linux 上真正持有net.Conn/net.Listener
MCU 只拿一个connection_id/listener_id
48.1 BridgeTCPClient.connect()
文件: Arduino_RouterBridge/src/tcp_client.h
const bool ok = _connected || bridge->call(TCP_CONNECT_METHOD, hostname, port).result(connection_id);
对应 Linux 侧:
文件: arduino-router/internal/network-api/network-api.go
tcpConnect() 做的是真正的:
net.Dial("tcp", serverAddr)- 成功后生成新的
id - 把
id -> net.Conn放进liveConnections - 返回
id
所以 MCU 侧拿到的 connection_id 本质是:
- Linux 侧 socket 对象的句柄代理
- 而不是 MCU 本地 socket
48.2 BridgeTCPClient.read()/write()
写路径:
- MCU
write() - 把数据装进
MsgPack::arr_t<uint8_t> - 调
tcp/write - Linux 侧
tcpWrite()对liveConnections[id]执行conn.Write(data)
读路径:
- MCU
available()触发_read(size) _read()调tcp/read- Linux 侧
tcpRead()对conn设置 read deadline 后执行conn.Read(buffer) - 返回一批字节
- MCU 再塞进本地
temp_buffer
文件: Arduino_RouterBridge/src/tcp_client.h
文件: arduino-router/internal/network-api/network-api.go
这里最值得记的一句话是:
MCU 看到的是 Arduino
Client接口;实际持有 TCP 连接状态的是 Linux 侧networkapi.
48.3 connectSSL() 其实是让 Linux 去做 TLS
文件: Arduino_RouterBridge/src/tcp_client.h
文件: arduino-router/internal/network-api/network-api.go
BridgeTCPClient.connectSSL() 最终调用 tcp/connectSSL,Linux 侧用的是:
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
所以 TLS 的真正终止点在 Linux,不在 MCU.
这点非常重要,因为它解释了:
- 为什么 MCU 侧只传 hostname/port/CA cert
- 为什么这套 bridge 能让很轻的 MCU 代码"看起来像会 TLS"
48.4 BridgeTCPServer 其实是"让 Linux 代持 listener"
文件: Arduino_RouterBridge/src/tcp_server.h
_listening = bridge->call(TCP_LISTEN_METHOD, hostname, _port).result(listener_id);
对应 Linux 侧:
tcp/listennet.Listen("tcp", listenAddr),返回listener_idtcp/accept
对liveListeners[listener_id]执行Accept(),再给新连接分配connection_id
文件: arduino-router/internal/network-api/network-api.go
所以这里的 server 语义是:
- MCU 发起"帮我监听这个地址/端口"
- Linux 真正监听
- accept 到的新连接再映射回一个新的
connection_id
这和通常"MCU 本地起 server"是完全不同的架构.
49. UDP
UDP 这条链比 TCP 更值得重点写,因为它不是单纯字节流,而是显式保留了"一个数据报包"的边界.
49.1 BridgeUDP.begin() 并不是连远端,而是在 Linux 上开一个 UDP socket
文件: Arduino_RouterBridge/src/udp_bridge.h
ok = bridge->call(UDP_CONNECT_METHOD, hostname, port).result(connection_id);
对应 Linux 侧:
文件: arduino-router/internal/network-api/network-api.go
udpConn, err := net.ListenUDP("udp", udpAddr)
这里其实更接近:
- 在 Linux 上
ListenUDP(localAddr) - 然后把这个 socket 的代理 ID 交给 MCU
所以 udp/connect 在这里更像"打开/绑定一个 UDP 通道",不是真正意义上的 connected UDP peer.
49.2 发包是三段式
MCU 侧:
beginPacket(host, port)write(payload...)endPacket()
对应 Linux 侧:
udp/beginPacket
记录udpWriteTargets[id],并初始化udpWriteBuffers[id]udp/write
只是往udpWriteBuffers[id]里 append 数据udp/endPacket
才真正执行udpConn.WriteTo(udpBuffer, udpAddr)
文件: Arduino_RouterBridge/src/udp_bridge.h
文件: arduino-router/internal/network-api/network-api.go
所以 UDP 发包不是"每次 write 就发网包",而是:
- MCU 侧先积攒一个 packet
- Linux 侧也维护一份 packet buffer
endPacket()才一次性落网
49.3 收包是两阶段
第一阶段:等一个完整 packet 到达
文件: Arduino_RouterBridge/src/udp_bridge.h
bridge->call(UDP_AWAIT_PACKET_METHOD, connection_id, read_timeout).result(packet_meta);
Linux 侧 udpAwaitPacket() 会:
ReadFrom(buffer)- 拿到
(n, addr) - 把整包数据放进
udpReadBuffers[id] - 返回
[n, host, port]
文件: arduino-router/internal/network-api/network-api.go
第二阶段:MCU 再分批把这一包读完
available()/_read(size)去调udp/readudpRead()从udpReadBuffers[id]切一段出来返回- MCU 用
_remaining跟踪这一包还剩多少没读完
49.4 dropPacket() 的设计意图
文件: Arduino_RouterBridge/src/udp_bridge.h
它的语义是:
- 如果前一个 packet 还没完整读掉
- 主动告诉 Linux 把
udpReadBuffers[id]丢掉 - 然后 MCU 侧
_remaining = 0,temp_buffer.clear()
所以 BridgeUDP 实际上是:
Linux 侧负责"按包收进来并缓存完整 datagram"
MCU 侧负责"把这个 datagram 分批消费完"
这是它和 TCP 的根本差异.
50. 专门展开 HCI
HCI 这条链虽然小,但它其实是这套 bridge 里最"硬核"的一个代理面,因为它碰到的是 Linux 主机蓝牙 HCI raw socket.
50.1 MCU 侧接口长什么样
文件: Arduino_RouterBridge/src/hci.h
BridgeHCI 暴露的核心操作就是:
begin("hci0")send(buffer, size)recv(buffer, max_size)available()end()
这就是一个"二进制设备通道代理".
50.2 为什么这里专门做了 BinaryView
文件: Arduino_RouterBridge/src/hci.h
struct BinaryView {
const uint8_t *data;
size_t size;
void to_msgpack(MsgPack::Packer &packer) const {
packer.pack(data, size);
}
};
这里不是转成字符串,也不是逐字节数组,而是明确告诉 MsgPack:
- 这是 binary payload
- 直接按
bin编码发出去
这说明 HCI 这条链的关注点是:
- 减少动态分配
- 保持二进制透明传输
50.3 Linux 侧到底做了什么
文件: arduino-router/internal/hciapi/hci-api_linux.go
hci/open 会:
- 解析
hci0这类设备名 unix.Socket(AF_BLUETOOTH, SOCK_RAW|SOCK_CLOEXEC, BTPROTO_HCI)IoctlSetInt(... HCIDEVDOWN ...)Bind(... HCI_CHANNEL_USER)
hci/send
unix.Write(fd, data)
hci/recv
- 给 socket 设一个很短的
SO_RCVTIMEO unix.Read(fd, buffer)
hci/avail
unix.Poll(... POLLIN ...)
这说明:
BridgeHCI不是普通业务 RPC;它是把 Linux 内核设备接口通过 RPC 映射到 MCU 侧.
50.4 平台约束
文件: arduino-router/internal/hciapi/hci-api_default.go
BridgeHCI依赖 Linux 侧hciapi,不是跨平台恒成立能力.