Lely CANopen coapp Driver:BasicMaster 与 BasicDriver 的职责边界、注册表和事件流程

摘要:从 BasicMaster 的远程节点操作能力出发,解析 Driver 注册表、非拥有关系、事件分发,以及各类 Driver 的协作机制。

在这里插入图片描述
@[toc]
Lely CANopen 的 coapp 层中,Driver 很容易被误解成 CAN 硬件驱动、协议状态机,或者由 Master 自动创建的“远程节点对象”。源码表达的关系并不是这样:BasicMaster 本身可以直接操作远程节点;BasicDriver 是绑定固定 Node-ID 和 Executor 的节点级应用适配器,同时也是 Master 分发节点事件的接收对象。

1. 先回答两个核心问题

1.1 BasicMaster 能否直接操作远程节点

可以。BasicMaster 的公开接口已经包含 Node-ID 参数,可以直接对指定远程节点执行操作,例如:

  • Command(cs, id):发送 NMT 命令;
  • Boot(id)IsReady(id):启动并查询 NMT boot-slave 流程;
  • SubmitRead()SubmitWrite():提交 SDO 上传或下载;
  • AsyncRead()AsyncWrite():以 Future 形式执行 SDO;
  • RpdoMapped(id)TpdoMapped(id):取得指定节点的 PDO 映射访问器;
  • ConfigHeartbeat(id, ...):配置 Master 本地对象 0x1016 中对该节点的心跳消费参数。

因此,下面两条路径都成立:

1
2
直接调用:Application -> BasicMaster(node_id, ...) -> CANopen services
Driver 调用:Application -> BasicDriver(...) -> BasicMaster(node_id, ...) -> CANopen services

BasicDriver 不是访问远程节点的必经层,也不存在“先通过 Driver 取出远程节点对象,再进行通信”的步骤。

1.2 BasicMaster 是否会构造一组 DriverBase

不会。下面的继承声明只表示 BasicMaster 内部带有一个 Driver 注册表:

1
2
3
4
5
class BasicMaster
: public Node,
protected std::map<uint8_t, DriverBase*> {
// ...
};

这个 std::mapBasicMaster 构造时会被默认构造成空容器,但其中的 DriverBase 对象由应用创建。Master 只保存裸指针并按 Node-ID 查找,不负责 newdelete 或决定 Driver 的实际类型。

最准确的概括是:

1
2
3
BasicMaster:拥有 CANopen 主站协议能力,并维护 Driver 注册表
BasicDriver:由应用创建,绑定一个远程 Node-ID,并自动注册到 Master
DriverBase*:Master 用于事件分发的非拥有指针

2. BasicMaster 同时承担协议入口和事件路由

BasicMaster 的继承关系包含两个不同维度:

1
2
3
class BasicMaster
: public Node,
protected std::map<uint8_t, DriverBase*>;

public Node 提供主站节点本身的 CANopen 能力;protected std::map 则提供远程节点 Driver 的内部索引。二者不能混为同一个“远程节点集合”。

2.1 Node 一侧:实际执行 CANopen 操作

BasicMaster 通过 Node、NMT 服务、Client-SDO、PDO 映射和内部实现对象执行协议操作。直接 SDO 调用的关键参数是:

1
Executor + Node-ID + Index + Sub-index + completion/future

概念上可写为:

1
2
3
4
5
6
const uint8_t node_id = 1;

auto future = master.AsyncRead<uint32_t>(
exec, node_id, 0x1000, 0x00);

master.Command(lely::canopen::NmtCommand::START, node_id);

SubmitRead()AsyncRead() 的实现会根据 Node-ID 获取 Client-SDO 服务。这个过程不通过 Driver 注册表;如果没有可用 Client-SDO,则返回或报告 SdoErrc::NO_SDO。因此:

Driver 不是 SDO 资源本身。是否有可用 SDO 连接,由 Master 的 SDO 服务配置和运行状态决定,而不是由 std::map<uint8_t, DriverBase*> 决定。

2.2 map 一侧:把事件交给节点级应用对象

Driver 注册表使用:

1
std::map<uint8_t, DriverBase*>

其键和值分别是:

1
2
key   = 远程节点 Node-ID
value = 该节点对应的 DriverBase 非拥有指针

它主要用于两类工作:

  1. 节点级事件分发,例如 OnState(id, st)OnHeartbeat(id, occurred)OnEmcy(id, ...)
  2. Driver 参与的配置和反配置流程,例如调用 DriverBase::OnConfig()OnDeconfig()

网络级事件,如 CAN 状态、CAN 错误、SYNC 和 TIME,会遍历全部已注册 Driver;节点级事件则使用 find(id) 只通知对应 Driver。

2.3 没有注册 Driver 时,哪些能力仍然存在

能力 无 Driver 时是否可用 条件或差异
发送 NMT 命令 可以 直接调用 BasicMaster::Command()
发起 boot-slave 可以 直接调用 Boot(id);仍受 NMT、DCF 和 SDO 配置约束
SDO 读写 可以 必须存在可用 Client-SDO;调用时显式传 Executor 和 Node-ID
查询 ready 状态 可以 直接调用 IsReady(id)
访问 PDO 映射视图 可以 直接调用 RpdoMapped(id)TpdoMapped(id)
DriverBase::OnState() 等节点回调 不会触发 注册表中没有接收对象
自定义 Driver OnConfig() 不会执行 Master 找不到 Driver 时,将该自定义配置步骤视为完成
AsyncDeconfig(id) 调用 Driver 反配置 无实际 Driver 可调用 返回空 Future 或不执行节点应用反配置

这里需要特别注意 boot 流程:BasicMaster::OnConfig(id) 查不到对应 Driver 时,会直接以成功结果结束“update configuration”步骤。这不表示设备已经执行了应用自定义配置,只表示 Master 没有注册可调用的远程节点接口,因此没有额外配置工作需要等待

3. protected map 是注册表,不是所有权容器

3.1 为什么使用 protected 继承

从效果上,可以把源码理解为下面这种组合关系:

1
2
3
4
class BasicMaster : public Node {
protected:
std::map<uint8_t, DriverBase*> drivers;
};

源码选择继承 std::map,使 BasicMaster 内部可以直接调用 find()begin()end()erase() 和遍历 *this。由于继承方式是 protected

  • BasicMaster 自己及其派生类可以访问 map 接口;
  • 普通应用代码不能把 BasicMaster 当成公开的 std::map 使用;
  • 对外暴露的是 Insert()Erase() 和 Master 自身的 CANopen API,而不是任意修改注册表。

这是一种实现方式,不代表“Master 是一个远程节点容器”的领域建模结论。

3.2 Driver 的创建、注册和注销

典型应用代码由外部构造 Master 和 Driver:

1
2
3
lely::canopen::AsyncMaster master(...);
MotorDriver motor_1(exec, master, 1);
MotorDriver motor_2(exec, master, 2);

BasicDriver 构造时执行:

1
master.Insert(*this);

析构时执行:

1
master.Erase(*this);

BasicMaster::Insert() 会检查:

  • Node-ID 是否位于 1..127
  • 是否错误地使用了 Master 自身的 Node-ID;
  • 同一个 Node-ID 是否已经注册。

通过检查后,才执行等价于下面的登记:

1
drivers[driver.id()] = &driver;

Erase() 只有在 Node-ID 和指针都匹配时才删除登记,并取消该节点未完成或待处理的 SDO 请求。

3.3 裸指针意味着什么

注册表保存的是:

1
DriverBase*

而不是:

1
2
3
std::unique_ptr<DriverBase>
std::shared_ptr<DriverBase>
DriverBase

因此它表达的是“可查找关系”,不是对象所有权:

  • Driver 内存由应用管理;
  • Master 不负责销毁 Driver;
  • Driver 析构时主动注销;
  • Master 必须比注册在其中的 Driver 活得更久;
  • 不能在仍有可能投递到 Driver 的任务存在时,提前破坏 Driver 生命周期。

推荐的局部对象顺序是先构造 Master,再构造 Driver。C++ 逆序析构时,Driver 会先注销,随后 Master 才销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sequenceDiagram
participant App as Application
participant Mst as BasicMaster
participant Drv as BasicDriver

App->>Mst: construct master
App->>Drv: construct driver with exec, master and node_id
Drv->>Mst: Insert this Driver
Mst->>Mst: store node_id to Driver pointer

Note over Mst,Drv: Master only stores a non-owning pointer

App->>Drv: destroy driver
Drv->>Mst: Erase this Driver
Mst->>Mst: cancel SDO requests and erase entry
App->>Mst: destroy master

4. 类关系:协议主站、物理节点 Driver 与逻辑设备

观察下图时,应区分三条关系:

  • BasicMaster 是协议操作入口,并按 Node-ID 非拥有地注册物理 Driver;
  • BasicDriverFiberDriverLoopDriver 表示一个物理远程节点采用何种执行模型;
  • BasicLogicalDriver 用于一个物理 Node-ID 内包含多个逻辑设备的情况。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
classDiagram
class Node

class BasicMaster {
+Command(command, node_id)
+Boot(node_id)
+SubmitRead(exec, node_id, ...)
+AsyncRead(exec, node_id, ...)
+RpdoMapped(node_id)
+TpdoMapped(node_id)
+Insert(DriverBase)
+Erase(DriverBase)
}

class DriverBase {
<<abstract>>
+GetExecutor()
+netid()
+id()
+OnState()
+OnBoot()
+OnConfig()
+OnDeconfig()
}

class BasicDriver {
+master
+rpdo_mapped
+tpdo_mapped
+SubmitRead()
+AsyncRead()
+Post()
}

class FiberDriver {
+Wait()
+USleep()
+Defer()
}

class LoopDriver {
+GetLoop()
+Join()
+Wait()
+RunRead()
+RunWrite()
}

class LogicalDriverBase {
<<abstract>>
+Number()
+AsyncConfig()
+AsyncDeconfig()
}

class BasicLogicalDriver {
+DeviceType()
+Profile()
+ObjectIndex()
}

Node <|-- BasicMaster
DriverBase <|-- BasicDriver
BasicDriver <|-- FiberDriver
BasicDriver <|-- LoopDriver
DriverBase <|-- LogicalDriverBase
LogicalDriverBase <|-- BasicLogicalDriver
BasicMaster "1" --> "0..*" DriverBase : non-owning registry by Node-ID
BasicDriver "1" --> "0..8" LogicalDriverBase : non-owning logical registry

源码中 BasicDriverDriverBase 使用非公开继承,而 FiberDriverLoopDriver 再公开继承 BasicDriver。应用通常直接派生 BasicDriver 或执行模型变体,并覆写受保护回调;DriverBase 主要是 Master 内部统一事件分发所需的抽象契约。

分析说明:源码没有用注释解释非公开继承的设计动机。将其理解为“隐藏底层 DriverBase 接口,同时向具体 Driver 派生类提供节点级 API 和受保护回调”,属于基于访问控制和使用方式的实现分析。

5. driver.hpp:DriverBase 定义节点应用事件契约

DriverBase 是远程节点 Driver 的抽象接口。它不持有 SDO 状态机,也不代表远程节点对象字典副本,主要定义身份、执行域和回调契约。

5.1 节点身份与执行域

1
2
3
virtual ev::Executor GetExecutor() const noexcept = 0;
virtual uint8_t netid() const noexcept = 0;
virtual uint8_t id() const noexcept = 0;

GetExecutor() 不只是普通工具参数。它至少承担两类任务:

  • BasicDriver::SubmitRead()SubmitWrite()AsyncRead()AsyncWrite() 等接口把它传给 Master,作为 SDO completion 或 Future 完成任务的执行器;
  • AsyncMaster 模型中,远程节点事件会先投递到这个 Executor,再调用对应的 Driver 回调。

因此,同一 Driver 内部状态应按该 Executor 的并发语义设计;使用 Strand 时,可以把同一节点的回调串行化;FiberDriverLoopDriver 的核心差异,也在于如何建立和运行这个执行域。

5.2 BasicMaster 与 AsyncMaster 的回调执行差异

这里必须区分基类默认实现和异步派生实现:

Master 类型 Driver 事件调用方式 需要注意的语义
BasicMaster 默认事件处理函数查找 Driver 后直接调用相应回调 不应仅凭 GetExecutor() 就假定事件一定经过异步队列
AsyncMaster 重写相关事件处理函数,并把 Driver 回调投递到 driver->GetExecutor() 不同 Driver 可以使用不同 Executor、Strand、fiber 或专用 Loop

SDO completion 则由调用 SubmitRead()AsyncRead() 等接口时显式传入的 Executor 决定。BasicDriver 只是自动传入自身的 GetExecutor()

这一区分解释了为什么 FiberDriverLoopDriver 的构造函数要求 AsyncMaster:它们不仅要为 SDO completion 提供执行域,也要求节点事件真正被投递到各自的 fiber 或专用事件循环。

5.3 CANopen 事件回调

回调 触发含义 作用域与关键语义
OnCanState() CAN 总线状态变化 网络级,广播给已注册 Driver
OnCanError() CAN 总线错误 网络级
OnCommand() Master 自身发生 NMT 状态切换 网络级,不等同于远程节点状态
OnSync() Master 发送或接收 SYNC PDO 处理完成后调用
OnSyncError() SYNC 长度不匹配 网络级协议异常
OnTime() Master 收到 TIME 网络级时间事件
OnState() 心跳协议检测到远程节点状态或 boot-up 节点级
OnHeartbeat() 心跳超时发生或恢复 节点级,不是每帧心跳通知
OnNodeGuarding() node guarding 超时发生或恢复 节点级
OnEmcy() 收到该远程节点的 EMCY 节点级
OnBoot() NMT boot-slave 流程结束 节点级,携带状态、阶段错误码和说明
OnRpdoWrite() 本地主站 OD 中远程 RPDO 映射值被写入 参数采用远程对象索引语义

这里最容易混淆的是:

  • OnCommand() 描述 Master 自身的 NMT 命令和状态迁移;
  • OnState() 描述通过心跳协议观察到的远程节点状态;
  • OnHeartbeat(bool occurred) 描述超时事件发生或解除,而不是心跳报文接收通知。

5.4 配置完成契约

1
2
3
4
5
virtual void OnConfig(
std::function<void(std::error_code)> res) noexcept = 0;

virtual void OnDeconfig(
std::function<void(std::error_code)> res) noexcept = 0;

这两个接口有四个关键约束:

  1. 回调自身必须非阻塞;
  2. 配置工作应异步执行,或转移到其他线程或 fiber;
  3. Master 会等待 res() 返回后继续流程;
  4. 实现必须最终调用一次 res(),否则 boot 或 deconfig 流程无法闭环。

函数声明为 noexcept,派生类不能让异常越过边界;应在内部转换为 std::error_code

6. BasicDriver:固定 Node-ID、Executor 和 Master 引用

BasicDriverDriverBase 之上增加面向单个物理节点的应用 API。核心成员可分为两组。

固定节点上下文:

1
2
3
master -> 当前 CANopen Master
exec_ -> 本 Driver 的事件和 completion 执行器
id_ -> 远程节点 Node-ID

PDO 映射访问能力:

1
2
3
rpdo_mapped      -> 远程 RPDO 映射对象的只读访问器
tpdo_mapped -> 远程 TPDO 映射对象的读写访问器
tpdo_event_mutex -> 与 Master 共享的 TPDO 事件同步对象

构造函数的核心逻辑是:

1
2
3
4
5
6
7
8
9
BasicDriver::BasicDriver(exec, master, id)
: master(master),
rpdo_mapped(master.RpdoMapped(id)),
tpdo_mapped(master.TpdoMapped(id)),
tpdo_event_mutex(master.tpdo_event_mutex),
exec_(exec ? exec : master.GetExecutor()),
id_(id) {
master.Insert(*this);
}

析构函数调用 master.Erase(*this)。因此,Driver 的职责不是创建通信服务,而是:

  • 固定远程 Node-ID;
  • 固定回调执行域;
  • 为该节点提供简化 API;
  • 注册为节点事件接收对象;
  • 为应用封装节点业务状态和行为。

6.1 为什么构造阶段就确定 Executor

Master 完成 Insert() 后,后续节点事件就可能被分发到该 Driver。因此注册前必须完成:

  • Executor 选择;
  • PDO 映射访问器建立;
  • Node-ID 保存。

这也解释了 FiberDriverBaseLoopDriverBase 为什么出现在继承列表前部:C++ 先构造基类,使 Fiber Executor、Loop 或 Strand 在 BasicDriver 注册到 Master 之前准备完成。

7. 直接使用 BasicMaster,还是通过 BasicDriver

二者不是互斥方案。生产应用通常同时使用:

  • Master 处理网络级操作和跨节点协调;
  • Driver 封装每个节点的业务行为和回调状态。

7.1 同一个 SDO 操作的两种入口

直接调用 Master 时,应用显式传入 Executor 和 Node-ID:

1
2
auto future = master.AsyncRead<uint32_t>(
exec, node_id, 0x1000, 0x00);

通过 Driver 时,Node-ID 和 Executor 已经固定:

1
auto future = driver.AsyncRead<uint32_t>(0x1000, 0x00);

BasicDriver 的内联实现本质上执行:

1
2
return master.AsyncRead<uint32_t>(
GetExecutor(), id(), 0x1000, 0x00);

因此 Driver 是节点级门面,不是另一套 SDO 实现。

7.2 两条操作路径和一条事件路径

路径 是否经过 Driver 注册表 实际作用
应用直接调用 BasicMaster 显式给出 Executor 和 Node-ID,直接提交 NMT、SDO 或 PDO 相关操作
应用调用 BasicDriver 门面 Driver 补入已绑定的 Executor 和 Node-ID,随后仍调用 BasicMaster
Master 分发协议事件 网络级事件遍历注册表;节点级事件按 Node-ID 查找 Driver

因此,操作路径是否经过 BasicDriver 由应用选择;事件路径能否进入节点业务对象,则由 Driver 注册表决定。 注册一个 Driver 不会创建额外 SDO 服务;不注册 Driver 也不会阻断 Master 的直接通信接口。

7.3 选用原则

场景 推荐入口 原因
临时测试工具、一次性 SDO 读写 直接 BasicMaster 代码短,不需要建立节点业务类
网络级启动、停止和跨节点协调 BasicMaster Node-ID 是运行时参数,便于统一调度
长期运行的单节点业务封装 BasicDriver 固定 Node-ID/Executor,并承接节点回调
多种设备类型、多节点并行运行 每节点一个 Driver,共享一个 Master 隔离业务状态,避免重复传 Node-ID
一个物理节点包含多个逻辑设备 BasicDriver + LogicalDriver 需要 profile 索引换算和逻辑设备事件分发

8. PDO 映射访问:它不是一次 SDO 读写

rpdo_mappedtpdo_mapped 是 Master 对远程节点 PDO 映射数据建立的对象访问门面:

成员 权限 典型用途
rpdo_mapped 只读 读取由远程节点 RPDO 语义对应的映射值
tpdo_mapped 读写 更新将通过 Master TPDO 路径发送给远程节点的映射值

这里的命名应从 Master 的角度理解。它们访问的是 Master 管理的映射对象视图,不应等同于“立即向远程 OD 发起一次 SDO”。实际网络发送时机仍受 PDO 通信参数、传输类型、SYNC、事件触发和 Master 的 PDO 实现控制。

tpdo_event_mutex 暴露了与 Master 共享的 TPDO 事件互斥量。它说明 TPDO 映射值更新与事件触发之间存在共享同步要求。应用在组合多个映射值并触发 PDO 时,应遵循上层 API 的锁定语义,避免其他执行上下文观察到不完整的一组数据。

9. driver.cpp:默认事件分发不是业务处理

BasicDriver 为所有 DriverBase 回调提供默认实现,但这些默认实现基本不处理设备业务。它们的主要职责是把事件转发给已注册的 LogicalDriver

例如,CAN 状态、CAN 错误、NMT 命令、心跳超时、远程节点状态、SYNC、TIME、EMCY、node guarding 和 boot 结果,默认都会遍历内部逻辑设备表并广播。

1
2
3
4
5
6
7
flowchart LR
Mst["BasicMaster detects event"] --> Phy["BasicDriver callback"]
Phy --> HasLogical{"Logical drivers registered?"}
HasLogical -- "No" --> Done["Default callback returns"]
HasLogical -- "Yes" --> Route{"Profile-index-specific event?"}
Route -- "No" --> Broadcast["Broadcast to all logical drivers"]
Route -- "Yes" --> Select["Select logical device and rebase index"]

这带来一个直接结论:

  • 如果应用直接派生 BasicDriverFiberDriverLoopDriver,应覆写自己关心的回调;
  • 如果没有覆写,且没有注册任何 LogicalDriver,大多数默认事件处理函数只是返回,不会自动完成设备业务。

9.1 OnRpdoWrite 的特殊路由

OnRpdoWrite() 是默认分发中唯一需要按对象索引选择逻辑设备的事件。

当索引位于 0x6000..0x9FFF 时,源码按每个逻辑设备 0x800 个索引计算逻辑设备号:

1
logical_number = (index - 0x6000) / 0x800 + 1

然后把索引还原到第一个逻辑设备的标准 profile 区域:

1
logical_index = index - (logical_number - 1) * 0x800

例如:

1
2
3
物理索引 0x6810
逻辑设备号 = (0x6810 - 0x6000) / 0x800 + 1 = 2
传给逻辑 Driver 的索引 = 0x6810 - 0x800 = 0x6010

如果索引不在 profile 区域,则事件广播给全部逻辑设备,因为源码没有足够信息把通信参数区或制造商区对象唯一归属到某个逻辑设备。

10. OnConfig 与 OnDeconfig:配置流程如何闭环

BasicDriver::OnConfig() 的默认行为并不是“替当前物理节点下载一组 DCF”。它只负责聚合已注册 LogicalDriver 的配置流程。

其执行逻辑如下:

  1. 没有逻辑设备:立即 res(success)
  2. 指定某个逻辑设备号:只运行对应 Driver 的 AsyncConfig()
  3. 只有一个逻辑设备:直接返回该 Future;
  4. 有多个逻辑设备:分别启动 AsyncConfig(),使用 ev::when_all() 等待全部完成;
  5. Future 完成后提取可识别的 std::system_error,转换为 std::error_code,再调用 res()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sequenceDiagram
participant Mst as BasicMaster boot process
participant Drv as BasicDriver
participant Log as Logical drivers
participant Exec as Driver Executor

Mst->>Drv: OnConfig(result_callback)
alt no logical driver
Drv-->>Mst: result_callback(success)
else one or more logical drivers
Drv->>Log: AsyncConfig() for each target
Log-->>Exec: SdoFuture results
Exec->>Drv: when_all completion
Drv-->>Mst: result_callback(error_code)
end

OnDeconfig() 使用同样的聚合模式。

10.1 直接派生 BasicDriver 时应如何理解

如果某设备只有一个普通物理功能,且应用直接派生 FiberDriver,通常会覆写该物理 Driver 的 OnConfig()。此时,派生类自己的实现取代 BasicDriver 默认实现,配置内容由应用决定。

如果物理节点包含多个逻辑设备,则可以不覆写物理 Driver 的 OnConfig(),让 BasicDriver 默认聚合各个 LogicalDriver::OnConfig()

10.2 错误传播边界

源码在配置聚合完成后只把 std::system_error 中的 error_code 提取出来;其他异常类型会被忽略,然后以默认构造的成功码调用 res()。这意味着逻辑设备配置失败应通过 SdoFuture 中可转换为 std::system_error 的错误语义传播,而不应依赖任意异常类型。

这是实现层面的能力边界,不应把它理解为“所有异常都会自动转成配置失败”。应用自己的 noexcept OnConfig() 也应捕获全部异常,并为未知异常选择明确的本地错误码。

11. LogicalDriver:一个 Node-ID 内拆分最多八个设备

BasicLogicalDriver 不是新的 CANopen 节点,也没有独立 Node-ID。它共享物理 BasicDriver 的:

  • Master;
  • Node-ID;
  • Executor;
  • PDO 映射视图;
  • TPDO 事件互斥量。

它增加的是逻辑设备号、设备类型和对象索引换算。

11.1 注册与生命周期

构造时调用 driver.Insert(*this),析构时调用 driver.Erase(*this)。物理 Driver 内部使用 std::map<uint8_t, LogicalDriverBase*> 保存逻辑设备,接口把逻辑设备号限制在 1..8

这里需要保留一个源码级未决点:当前 masterBasicDriver::Insert() 使用 find(driver.id()) 检查已有项,但实际插入键是 driver.Number()。由于 id() 返回物理节点 Node-ID,而 Number() 返回逻辑设备号,这两个键的语义并不一致。仅根据当前实现,不能断言重复逻辑设备号一定会在插入前被正确拒绝;这更像是需要向上游确认的实现缺口。本文其余流程按公开接口“逻辑设备号唯一”的设计意图解释,但不把该意图写成已经验证的运行保证。

逻辑 Driver 持有的是裸指针注册关系,因此生命周期顺序由应用保证:逻辑 Driver 销毁前,不能再有可能访问它的待执行任务。LoopDriver 的文档因此特别要求,在销毁逻辑 Driver 前调用 Join(),让专用事件循环停止并清理剩余任务。

11.2 对象索引换算

逻辑 Driver 让业务代码始终使用第一个逻辑设备的标准 profile 区域 0x6000..0x67FF。对于逻辑设备 n,实际远程对象索引为:

1
actual_index = logical_index + (n - 1) * 0x800

例如逻辑设备 3 访问 0x6040

1
actual_index = 0x6040 + 2 * 0x800 = 0x7040

该换算会应用到 SDO 读写以及 rpdo_mappedtpdo_mapped 访问器;非 profile 区域索引保持不变。

11.3 配置前识别设备类型

逻辑 Driver 的 AsyncConfig() 会先更新 DeviceType()

  • 对第一个逻辑设备,优先读取 Master 本地对象 0x1F84:Node-ID 中的期望设备类型;
  • 如果本地没有该值,则通过 SDO 读取远程 0x1000:00
  • 如果设备类型高 16 位为 0xFFFF,表示需要从逻辑设备类型对象读取具体类型;
  • 对第二至第八个逻辑设备,读取经过索引换算的 0x67FF:00
  • 类型解析成功后,才调用该逻辑 Driver 的 OnConfig()

因此,LogicalDriver 不只是事件分组器,也承担复合设备 profile 区域换算和逻辑设备类型装配。

12. FiberDriver:共享线程上的可读顺序流程

FiberDriverBasicDriver 前增加:

1
2
3
FiberThread
FiberExecutor
Strand

其任务和回调运行在 fiber 中。Wait(SdoFuture<T>) 调用 fiber_await() 挂起当前 fiber,待 Future 完成后继续执行。底层承载线程仍可运行其他任务,因此它提供的是“同步写法”,不是阻塞整个事件循环线程。

1
2
3
4
5
6
OnConfig fiber
-> AsyncWrite()
-> Wait(): suspend current fiber
-> event loop continues processing CAN/SDO
-> future ready
-> resume current fiber

FiberDriver 不创建每节点一个专用 OS 线程。它使用传入的 inner executor;未提供时使用 Master Executor。Strand 用于保证该 Driver 投递的任务按串行语义执行。

使用限制也很明确:

  • Driver 必须在其任务实际运行的线程上实例化;
  • Wait() 只能从提交到该 Driver Executor 的任务中调用;
  • 不能从任意外部线程直接调用 Wait()

12.1 一个配置示例

下面示例体现 FiberDriver 的典型用途:在 OnConfig() 中保持顺序式代码,同时通过 fiber 挂起等待异步 SDO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <lely/coapp/fiber_driver.hpp>

#include <cstdint>
#include <functional>
#include <system_error>

class DeviceDriver final : public lely::canopen::FiberDriver {
public:
DeviceDriver(ev_exec_t* exec,
lely::canopen::AsyncMaster& master,
uint8_t id)
: FiberDriver(exec, master, id) {}

protected:
void OnConfig(
std::function<void(std::error_code)> res) noexcept override {
try {
// Wait() suspends this fiber; it does not block the underlying event loop.
Wait(AsyncWrite(0x1017, 0, uint16_t{1000}));

const auto device_type = Wait(AsyncRead<uint32_t>(0x1000, 0));
(void)device_type;

res({});
} catch (const std::system_error& e) {
res(e.code());
} catch (...) {
res(std::make_error_code(std::errc::io_error));
}
}
};

这个例子只说明 Driver 执行模型,不代表所有设备都应在启动阶段修改 0x1017。具体对象、数据类型和配置顺序必须以从站 EDS/DCF 和产品要求为准。

13. LoopDriver:每个 Driver 独立事件循环和线程

LoopDriver 也继承 BasicDriver,但它的执行资源完全不同:

  • 自有 ev::Loop
  • 与 Loop 绑定的 Strand
  • 独立线程运行 loop.run()
  • Wait() 通过运行该 Loop 的待处理任务,直到 Future 就绪;
  • RunRead()RunWrite()AsyncRead()AsyncWrite() 外提供同步式包装。

其构造顺序是:

  1. LoopDriverBase 先创建 Loop 和 Strand;
  2. BasicDriver 使用 Strand 的 inner executor 注册到 Master;
  3. Impl_ 创建线程并运行专用 Loop;
  4. Impl_ 同时注册到 Master 的 I/O Context,以便 Context shutdown 时触发 Driver shutdown。

停止流程比普通 BasicDriver 更复杂:

  1. 从 Master 注销 Driver,阻止新事件投递,并取消未完成的 SDO;
  2. 停止阻塞运行的事件循环;
  3. 重启 Loop 并以非阻塞方式处理剩余任务;
  4. 设置 stopped promise;
  5. Join() 等待线程退出。

Join() 通过原子标志保证多次调用时只进行一次实际等待。

13.1 适用代价

LoopDriver 提供最直接的线程隔离,但每个 Driver 都拥有线程和事件循环。节点数量较多时,需要评估:

  • 线程栈内存;
  • 调度和上下文切换;
  • Driver 销毁顺序;
  • 逻辑 Driver 待处理任务;
  • 应用共享资源的跨线程同步。

分析说明:LoopDriver::Wait() 会通过专用 Loop 继续处理待执行任务,因此同步外观不等于“期间没有其他回调运行”。若业务状态跨多个等待点,仍需考虑回调重入和状态一致性。这是根据 loop.wait() 的运行方式得出的并发分析。

14. 三种执行模型如何选择

方案 OS 线程模型 SDO 编程方式 隔离性 主要代价 适合场景
BasicDriver 使用指定 Executor,不自行创建线程 Submit 回调或 Async Future 取决于外部 Executor 需要显式组织异步状态 已有成熟事件驱动框架,追求最小封装
FiberDriver 通常共享事件循环线程,不按节点新增线程 Wait(Async...) 顺序式写法 Strand 可提供节点内串行化 受 fiber 调用上下文约束 多节点共享主循环,希望配置代码保持可读顺序
LoopDriver 每节点一个专用线程和 Loop Wait()RunRead()RunWrite() 节点间线程隔离较强 线程、栈和销毁同步开销 节点较少,希望设备逻辑与主循环隔离

LogicalDriver 不属于第四种执行模型。它可以建立在 BasicDriverFiberDriverLoopDriver 之上,用于一个物理 Node-ID 内存在多个逻辑设备的情况。

对普通单逻辑设备从站,通常不需要 LogicalDriver。对多轴驱动器、复合 I/O 或一个 CANopen 节点暴露多个 profile 区域的设备,才需要使用逻辑设备拆分。

15. 三条运行路径如何汇合

理解这套设计,关键不是只记住一条调用链,而是区分“直接操作”“Driver 门面操作”和“事件回调”三条路径。

15.1 直接通过 BasicMaster 操作远程节点

1
2
3
4
5
6
7
8
9
10
11
12
13
sequenceDiagram
participant App as Application
participant Mst as BasicMaster
participant Sdo as Client SDO service
participant Bus as CAN bus
participant Exec as Completion executor

App->>Mst: AsyncRead(exec, node_id, index, subindex)
Mst->>Sdo: GetSdo(node_id) and submit upload
Sdo->>Bus: SDO request
Bus-->>Sdo: SDO response or timeout
Sdo->>Exec: complete future
Exec-->>App: value or error

这条路径不查询 Driver 注册表。它只要求 Master 能根据 Node-ID 获得所需协议资源,例如可用的 Client-SDO。

15.2 通过 BasicDriver 使用节点级门面

1
2
3
4
5
6
7
8
9
10
11
sequenceDiagram
participant App as Application
participant Drv as BasicDriver
participant Mst as BasicMaster
participant Core as CANopen services

App->>Drv: AsyncRead(index, subindex)
Drv->>Mst: AsyncRead(GetExecutor(), id(), index, subindex)
Mst->>Core: submit operation
Core-->>Drv: complete on Driver executor
Drv-->>App: future continuation

Driver 只补齐已绑定的 Executor 和 Node-ID。真正的 SDO 请求管理、超时、传输和取消仍由 Master 及底层服务执行。

15.3 协议事件回到节点业务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
flowchart TD
Bus["CAN frame or timer event"] --> Core["CANopen protocol services"]
Core --> Mst["Master identifies event scope and Node-ID"]
Mst --> Scope{"Network-wide or node-specific?"}
Scope -->|"Network-wide"| All["Iterate all registered DriverBase pointers"]
Scope -->|"Node-specific"| Find{"find node_id"}
Find -->|"Not found"| Skip["No DriverBase callback"]
Find -->|"Found"| Mode{"Master implementation"}
All --> Mode
Mode -->|"BasicMaster"| Direct["Call Driver callback directly"]
Mode -->|"AsyncMaster"| Post["Post callback to Driver Executor"]
Direct --> Drv["BasicDriver or derived callback"]
Post --> Drv
Drv --> Override{"Physical Driver overrides callback?"}
Override -->|"Yes"| App["Run node-specific application logic"]
Override -->|"No"| Logical{"Logical drivers registered?"}
Logical -->|"No"| Return["Default implementation returns"]
Logical -->|"Yes"| Dispatch["Broadcast or route by profile index"]

BasicMasterAsyncMaster 共享同一 Driver 注册关系,但回调调度方式不同:前者的默认实现直接通知 Driver,后者把回调投递到 Driver 的 Executor。无论哪种实现,找不到对应 Node-ID 时都不会产生节点级 Driver 回调。

三条路径合起来,才是完整心智模型:

1
2
3
BasicMaster 负责协议操作和网络调度
BasicDriver 负责节点上下文封装和节点事件接入
Driver 注册表只服务于查找和回调,不是协议资源或远程对象所有权

16. 关键设计结论

  1. BasicMaster 可以直接操作远程节点。 NMT、boot-slave、SDO 和 PDO 映射接口都显式接收 Node-ID,Driver 不是通信必经层。
  2. protected std::map<uint8_t, DriverBase*> 是非拥有注册表。 Master 构造的是空 map,不会构造、持有或删除一组 Driver 对象。
  3. Driver 对象由应用创建。 BasicDriver 构造时 Insert(),析构时 Erase();Master 必须比 Driver 生命周期更长。
  4. 协议资源与 Driver 注册表相互独立。 SDO 调用通过 Node-ID 获取 Client-SDO;没有 Driver 不等于没有 SDO,没有 SDO 也不能通过创建 Driver 自动补足。
  5. BasicDriver 是固定节点上下文的门面。 它把 Executor 和 Node-ID 注入 BasicMaster API,并为应用集中保存节点业务状态。
  6. Driver 注册表决定节点级应用回调能否到达。 没有注册 Driver 时,Master 仍可通信,但不会调用该节点的 DriverBase::OnState()OnEmcy() 等回调。
  7. 事件回调是否异步投递取决于 Master 实现。 BasicMaster 默认直接通知 Driver;AsyncMaster 才把相关回调投递到 driver->GetExecutor()。SDO completion 则由提交操作时传入的 Executor 决定。
  8. 自定义配置流程依赖 Driver。 Master 找不到 Driver 时会把额外的 update-configuration 步骤视为完成,不会替应用执行自定义 OnConfig()
  9. driver.cpp 的默认回调主要服务于 LogicalDriver 聚合。 普通物理 Driver 未覆写回调且未注册逻辑 Driver 时,默认实现通常不会产生设备业务行为。
  10. FiberDriver 和 LoopDriver 只改变执行与等待模型。 Fiber 在共享执行线程上挂起 fiber;Loop 为每个 Driver 建立独立线程和事件循环,并且二者依赖 AsyncMaster 完成 Driver 事件投递。
  11. LogicalDriver 是同一物理 Node-ID 内的 profile 分区。 它共享 Master、Node-ID 和通信资源,只增加逻辑编号、类型识别、索引换算和二次事件分发。

参考源码

  1. include/lely/coapp/master.hpp
  2. src/coapp/master.cpp
  3. include/lely/coapp/driver.hpp
  4. src/coapp/driver.cpp
  5. include/lely/coapp/fiber_driver.hpp
  6. src/coapp/fiber_driver.cpp
  7. include/lely/coapp/loop_driver.hpp
  8. src/coapp/loop_driver.cpp
  9. include/lely/coapp/logical_driver.hpp
  10. src/coapp/logical_driver.cpp
  11. BasicMaster 2.4.0 API 与源码页