Lely CANopen coapp::Device:本地 SDO/PDO 读写、远程对象映射与运行机制

在这里插入图片描述

@[toc]

1. 先给出最关键的结论

理解 Device 时,必须先把“本地读写”“远程读写”“SDO”“PDO”分开:

  1. Device 本质上是 co_dev_t 的 C++ 所有权与访问封装。
  2. Device::Read()Device::Write() 是对本地对象字典执行一次“本地 SDO 请求”,不会发送 CAN 帧。
  3. Device::Get()Device::Set() 是直接访问本地对象字典值,绕过 SDO 访问权限、范围检查和下载/上传回调。
  4. Device::RpdoRead() 并不会通过 CAN 去读取远程节点,而是把“远程 TPDO 对象地址”转换为本地 RPDO 映射对象,再读取本地缓存值。
  5. Device::TpdoWrite() 也不会执行远程 SDO 下载,而是把“远程 RPDO 对象地址”转换为本地 TPDO 映射对象,再写入本地待发送值。
  6. 真正通过 CAN 总线访问远程对象字典的是 co_csdo_t / lely::canopen::Sdo,不是 Device
  7. RPDO 接收和 TPDO 发送虽然是 PDO 通信,但它们访问对象字典时复用了本地 SDO indication 机制:
    • RPDO:co_pdo_dn()co_sub_dn_ind()
    • TPDO:co_pdo_up()co_sub_up_ind()
  8. Device 位于“对象字典模型”和“CANopen 服务”之间,但它本身不是 CAN 网络服务,不持有 CAN 通道,也不直接收发 CAN 帧。

最容易混淆的点是:

RpdoRead()TpdoWrite() 的参数看起来是远程节点的 node-ID/index/sub-index,但它们操作的仍然是本地 co_dev_t 中的代理对象。


2. Device 在 Lely CANopen 中处于什么位置

2.1 总体结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
flowchart LR
DCF[EDS / DCF / static device] --> DEV[co_dev_t\n本地对象字典模型]

DEVICE[lely::canopen::Device\nC++ 封装] -->|拥有并访问| DEV

NODE[lely::canopen::Node] -->|继承| DEVICE
NODE --> NET[CAN network / timer / executor]

CSDO[co_csdo_t / coapp::Sdo\n远程 SDO 客户端] --> NET
CSDO -->|可读取 SDO 参数| DEV

RPDO[co_rpdo_t\nRPDO 服务] --> NET
RPDO -->|co_pdo_dn| DEV

TPDO[co_tpdo_t\nTPDO 服务] --> NET
TPDO -->|co_pdo_up| DEV

2.2 各模块的职责

模块 核心职责 是否直接收发 CAN 帧
co_dev_t 保存对象字典、节点 ID、对象、子对象、当前值及访问回调
co_obj_t 表示一个对象字典条目,例如 0x6040
co_sub_t 表示一个子对象,例如 0x6040:00,保存类型、权限、PDO 属性和值
Device 管理 co_dev_t 生命周期,提供类型安全访问、锁、回调和远程 PDO 地址映射
co_sdo_req 描述一次本地上传/下载请求及其数据缓冲
co_csdo_t Client-SDO 状态机,向远程 Server-SDO 发起网络请求
co_rpdo_t 接收 PDO 帧并写入本地对象字典
co_tpdo_t 从本地对象字典取值并发送 PDO 帧
co_pdo_dn() / co_pdo_up() 在 PDO 数据与对象字典之间搬运数据 否,供 PDO 服务调用

因此,Device 可以理解为:

1
2
3
4
5
6
7
Device = co_dev_t 的 C++ 所有权封装
+ 本地对象字典访问接口
+ 类型转换
+ SDO 错误转换
+ 线程锁
+ 写入通知
+ 远程 PDO 对象地址到本地代理对象的映射

它不等于:

1
2
3
4
5
Device != Client-SDO
Device != Server-SDO
Device != RPDO service
Device != TPDO service
Device != CAN channel

3. Deviceco_dev_t 的关系

3.1 co_dev_t 是真正的数据载体

co_dev_t 保存完整的本地设备描述,包括:

  • network-ID;
  • node-ID;
  • 对象字典中的 co_obj_t
  • 每个对象下的 co_sub_t
  • 子对象类型;
  • 访问权限;
  • PDO mapping 属性;
  • 当前值;
  • SDO upload/download indication 回调;
  • TPDO event indication 回调。

Device 本身没有重新实现一套对象字典,而是始终通过 C API 操作内部 co_dev_t

3.2 所有权关系

Device::Impl_ 内部保存:

1
::std::unique_ptr<co_dev_t, DeviceDeleter> dev;

自定义删除器最终调用:

1
co_dev_destroy(dev);

这表示:

  • Device 拥有 co_dev_t
  • Device 析构时自动销毁对象字典;
  • 不需要用户手工调用 co_dev_destroy()
  • Device 禁止复制,避免两个实例同时拥有同一个 co_dev_t

3.3 创建来源

Device 支持三种输入:

  1. 已存在的 co_dev_t*
  2. 文本 EDS/DCF,以及可选的 concise DCF;
  3. 静态设备描述 co_sdev*

文本 DCF 路径最终进入:

1
co_dev_create_from_dcf_file(dcf_txt.c_str());

若存在二进制 concise DCF,再调用:

1
co_dev_read_dcf_file(dev, nullptr, nullptr, dcf_bin.c_str());

这一步只负责创建和初始化本地对象字典。真正的 NMT、SDO、PDO 服务是在 Node 等更高层对象启动后,根据这个对象字典创建的。


4. 四类对象字典访问必须分清

接口 地址含义 实际访问对象 权限/范围检查 回调 是否发送 CAN 帧
Read<T>() 本地 index/sub-index 本地 co_dev_t
Write<T>() 本地 index/sub-index 本地 co_dev_t
Get<T>() 本地 index/sub-index 本地 co_dev_t
Set<T>() 本地 index/sub-index 本地 co_dev_t
RpdoRead<T>() 远程 TPDO 对象地址 本地 RPDO 代理对象
RpdoGet<T>() 远程 TPDO 对象地址 本地 RPDO 代理对象
TpdoRead<T>() 远程 RPDO 对象地址 本地 TPDO 代理对象
TpdoWrite<T>() 远程 RPDO 对象地址 本地 TPDO 代理对象
TpdoGet<T>() 远程 RPDO 对象地址 本地 TPDO 代理对象
TpdoSet<T>() 远程 RPDO 对象地址 本地 TPDO 代理对象
Sdo::SubmitUpload() 远程 index/sub-index 远程节点对象字典 由远程 SDO server 执行 远程回调/完成通知
Sdo::SubmitDownload() 远程 index/sub-index 远程节点对象字典 由远程 SDO server 执行 远程回调/完成通知

其中 Get/Set/RpdoGet/TpdoGet/TpdoSet 位于 protected 区域,主要供 NodeMaster 或派生类内部使用。


5. 本地 SDO 读取:Device::Read()

5.1 为什么叫本地 SDO

co_dev_up_req() 的注释明确指出:它向本地设备提交 upload request,等价于从对象字典读取值。

这里复用了 SDO 的访问语义,但没有:

  • Client-SDO COB-ID;
  • Server-SDO COB-ID;
  • CAN 帧;
  • 超时;
  • expedited/segmented/block 网络状态机。

5.2 调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sequenceDiagram
participant App as Application
participant Device as Device::Read<T>
participant DevReq as co_dev_up_req
participant Sub as co_sub_t upload indication
participant Conv as OnUpCon / canopen_traits

App->>Device: Read<T>(idx, subidx)
Device->>Device: 创建 error_code、结果变量和 tuple
Device->>DevReq: co_dev_up_req(dev, idx, subidx, OnUpCon<T>, data)
DevReq->>Sub: 查找对象和子对象并执行 upload indication
Sub->>Sub: 检查可读权限并取得当前值
Sub-->>DevReq: 返回字节和 SDO abort code
DevReq->>Conv: OnUpCon<T>(ac, ptr, n, data)
Conv->>Conv: CANopen C 数据转换为 C++ 类型 T
Conv-->>Device: 写入结果和 abort code
Device-->>App: 返回 T 或抛出 SdoError

5.3 代码主线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <class T>
Device::Read(uint16_t idx, uint8_t subidx, std::error_code& ec) const {
uint32_t ac = 0;
T value = T();
auto t = std::tie(ac, value);

std::lock_guard<Impl_> lock(*impl_);

if (!co_dev_up_req(dev(), idx, subidx, &OnUpCon<T>, &t)) {
if (ac)
ec = static_cast<SdoErrc>(ac);
else
ec.clear();
} else {
ec = util::make_error_code();
}

return value;
}

关键点:

  1. co_dev_up_req() 返回值表示函数调用本身是否失败;
  2. ac 表示 CANopen SDO abort code;
  3. OnUpCon<T> 将原始字节转换为目标 C++ 类型;
  4. canopen_traits<T> 决定 T 对应的 CANopen 类型编号和 C 表示;
  5. error_code 参数的重载会把错误转换为 SdoError 异常。

5.4 Read()Get() 的区别

Get() 的主线是:

1
2
3
4
5
co_dev_find_obj()
-> co_obj_find_sub()
-> 检查 CANopen 类型是否匹配
-> co_sub_get_val()
-> 直接复制当前值

它不会经过 co_dev_up_req()co_sub_up_ind(),因此:

  • 不执行 SDO read access 检查;
  • 不执行用户注册的 upload indication;
  • 不适合模拟一次真实的 SDO 读取;
  • 适合协议栈内部直接观察本地状态。

6. 本地 SDO 写入:Device::Write()

6.1 调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sequenceDiagram
participant App as Application
participant Device as Device::Write<T>
participant Traits as canopen_traits<T>
participant DevReq as co_dev_dn_val_req
participant Sub as co_sub_t download indication
participant Notify as Device::Impl_::OnWrite

App->>Device: Write<T>(idx, subidx, value)
Device->>Traits: to_c_type(value)
Traits-->>Device: C 类型值和 CANopen 类型编号
Device->>DevReq: co_dev_dn_val_req(...)
DevReq->>Sub: 执行本地 download indication
Sub->>Sub: 权限、类型、长度、范围和应用回调检查
Sub->>Sub: 更新当前值
Sub->>Notify: 写入成功后通知
Notify-->>Device: OnWrite / OnRpdoWrite
Device-->>App: 成功或 SdoError

6.2 主体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <class T>
Device::Write(uint16_t idx, uint8_t subidx, const T& value,
std::error_code& ec) {
using traits = canopen_traits<T>;

auto val = traits::to_c_type(value, ec);
if (ec)
return;

uint32_t ac = 0;

{
std::lock_guard<Impl_> lock(*impl_);
co_dev_dn_val_req(dev(), idx, subidx, traits::index, &val,
&OnDnCon, &ac);
}

traits::destroy(val);
}

6.3 写入成功后的通知机制

Device::Impl_ 构造时遍历对象字典中的对象,并对 0x2000..0xBFFF 区域安装 download indication:

1
2
3
4
5
6
7
8
9
10
11
12
13
co_obj_set_dn_ind(
obj,
[](co_sub_t* sub, co_sdo_req* req, void* data) -> uint32_t {
uint32_t ac = 0;
if (co_sub_on_dn(sub, req, &ac) == -1 || ac)
return ac;

auto impl = static_cast<Impl_*>(data);
impl->OnWrite(co_obj_get_idx(co_sub_get_obj(sub)),
co_sub_get_subidx(sub));
return 0;
},
this);

这里的逻辑不是替换默认写入,而是:

1
2
3
4
5
先执行 co_sub_on_dn()
-> 完成默认下载处理和实际写入

只有写入完整且成功
-> 再调用 Impl_::OnWrite()

因此 OnWrite() 看到的是已经成功写入后的值。

6.4 Write()Set() 的区别

Set() 直接执行:

1
2
3
查找 obj/sub
-> 检查模板类型与对象字典类型是否匹配
-> co_sub_set_val()

它绕过:

  • SDO 写权限;
  • 数值范围检查;
  • 对象自定义 download indication;
  • Device::OnWrite()
  • OnRpdoWrite()

因此:

  • 应用层需要按 CANopen 规则访问对象时使用 Write()
  • 协议栈内部维护影子值或初始化值时才使用 Set()
  • 不应因为 Set() 更直接就用它替代正常 SDO 写入。

7. PDO 为什么也走本地 SDO indication

PDO 与 SDO 在总线上是不同协议,但写入或读取对象字典时,它们需要遵守同一套对象访问钩子。

Lely 的实现选择复用:

1
2
co_sub_dn_ind()  本地下载入口
co_sub_up_ind() 本地上传入口

这样做的结果是:

  • 通过 SDO 写对象;
  • 通过 RPDO 写对象;
  • 协议栈内部执行本地 SDO 写请求;

都能进入相同的子对象下载处理链。

这正是 Device::OnWrite() 能同时观察 SDO download 和 RPDO 写入的原因。


8. RPDO 接收后如何写入本地对象字典

8.1 服务创建关系

co_rpdo_create() 同时需要:

1
2
3
can_net_t *net
co_dev_t *dev
PDO number
  • net 用于接收 CAN 帧;
  • dev 提供 0x1400..0x15FF 通信参数、0x1600..0x17FF 映射参数以及目标应用对象。

8.2 数据路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sequenceDiagram
participant CAN as CAN network
participant RPDO as co_rpdo_t
participant PdoDn as co_pdo_dn
participant Sub as co_sub_dn_ind
participant DevCb as Device::Impl_::OnWrite
participant AppCb as OnRpdoWrite

CAN->>RPDO: 收到 RPDO CAN 帧
RPDO->>RPDO: 根据 0x1400 通信参数确认 PDO
RPDO->>PdoDn: 映射参数 0x1600 + payload

loop 每个映射项
PdoDn->>PdoDn: 解析 index/sub-index/bit length
PdoDn->>Sub: 构造本地 co_sdo_req 并执行 download indication
Sub->>Sub: 检查 RPDO 可写性并更新值
Sub->>DevCb: 写入成功通知
end

DevCb->>DevCb: 查询本地对象的反向远程映射
DevCb->>AppCb: OnRpdoWrite(remote id, remote idx, remote subidx)

8.3 co_pdo_dn() 的核心动作

每个 mapping entry 是一个 32 位值:

1
2
3
bits 31..16 : object index
bits 15..8 : sub-index
bits 7..0 : mapping length in bits

co_pdo_dn() 对每个映射项执行:

  1. 从 PDO payload 按位提取数据;
  2. 可选执行 co_dev_chk_rpdo()
  3. 查找本地 co_sub_t
  4. 构造临时 co_sdo_req
  5. 调用 co_sub_dn_ind()
  6. 由子对象完成权限、类型和应用处理;
  7. 写入成功后进入 Device::OnWrite() 通知链。

所以 RPDO 不是直接 memcpy 到变量,而是通过对象字典访问入口写入。

8.4 RPDO 检查内容

co_dev_chk_rpdo() 主要检查:

  • 对象是否存在;
  • 子索引是否存在;
  • 是否具有写权限;
  • 是否允许 PDO mapping;
  • access 属性是否包含 RPDO。

错误使用标准 SDO abort code 表示,例如:

  • NO_OBJ
  • NO_SUB
  • NO_WRITE
  • NO_PDO
  • PDO_LEN

9. TPDO 发送前如何读取本地对象字典

9.1 服务创建关系

co_tpdo_create() 同样需要:

1
2
3
can_net_t *net
co_dev_t *dev
PDO number

co_dev_t 中需要存在:

  • 0x1800..0x19FF TPDO communication parameters;
  • 0x1A00..0x1BFF TPDO mapping parameters;
  • 被映射的本地应用对象。

9.2 数据路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sequenceDiagram
participant Trigger as Event / SYNC / timer
participant TPDO as co_tpdo_t
participant PdoUp as co_pdo_up
participant Sub as co_sub_up_ind
participant CAN as CAN network

Trigger->>TPDO: 触发 TPDO
TPDO->>TPDO: 检查 transmission type、inhibit time 等
TPDO->>PdoUp: 传入 0x1A00 mapping

loop 每个映射项
PdoUp->>PdoUp: 解析 index/sub-index/bit length
PdoUp->>Sub: 本地 upload indication
Sub-->>PdoUp: 当前对象值
PdoUp->>PdoUp: 按位拼接到 PDO payload
end

PdoUp-->>TPDO: 完整 payload
TPDO->>CAN: 发送 TPDO CAN 帧

9.3 co_pdo_up() 的核心动作

对每个映射项:

  1. 可选执行 co_dev_chk_tpdo()
  2. 查找本地子对象;
  3. 清理并复用 co_sdo_req
  4. 调用 co_sub_up_ind()
  5. 确认值可在一次 PDO 采样中完整取得;
  6. 按 mapping length 写入 payload。

co_dev_chk_tpdo() 检查:

  • 对象和子对象是否存在;
  • 是否可读;
  • 是否允许 PDO mapping;
  • access 属性是否包含 TPDO。

10. WriteEvent()SetEvent() 到底做什么

WriteEvent()SetEvent() 不写入对象值,只表示:

某个对象发生了需要触发事件型 TPDO 的变化。

底层调用:

1
co_dev_tpdo_event(dev(), idx, subidx);

co_dev_tpdo_event() 会:

  1. 检查该子对象是否被映射到 PDO;
  2. 找到所有包含该子对象的有效异步或事件驱动 TPDO;
  3. 调用注册在 co_dev_t 上的 TPDO event indication;
  4. 对应 co_tpdo_t 服务再尝试发送 PDO。

因此,典型事件型 TPDO 操作分为两步:

1
2
TpdoWrite<uint16_t>(remote_id, remote_idx, remote_subidx, value);
TpdoWriteEvent(remote_id, remote_idx, remote_subidx);

第一步只更新本地 TPDO 代理对象;第二步才通知 TPDO 服务“这个对象发生了发送事件”。

若 TPDO 使用 SYNC、事件定时器或其他 transmission type,发送时机仍由 TPDO 服务规则决定。


11. Device 所谓的“远程 PDO 读写”是什么

11.1 核心思想:远程地址,实际访问本地代理对象

假设远程节点 5 的 TPDO 映射了:

1
0x6064:00  Position actual value

主站本地对象字典中会有一个 RPDO 映射目标,例如:

1
0x4000:01

收到远程 TPDO 后,真正更新的是主站本地:

1
0x4000:01

应用层不希望记忆每个本地代理地址,因此 Device 建立映射:

1
2
remote (node 5, 0x6064:00)
-> local (0x4000:01)

应用调用:

1
RpdoRead<int32_t>(5, 0x6064, 0);

内部实际执行:

1
2
3
4
5
RpdoMapping(5, 0x6064, 0)
-> 得到本地 0x4000:01

Read<int32_t>(0x4000, 1)
-> 本地 SDO upload

这里没有向节点 5 发送任何请求。

11.2 为什么仍然叫 remote object

因为 API 向应用层暴露的是远程设备语义:

1
节点 ID + 远程 index + 远程 sub-index

但是协议实现采用本地代理对象保存 PDO 数据,因此 Device 需要完成地址翻译。


12. UpdateRpdoMapping() 的机制

12.1 映射方向

1
2
远程 TPDO 对象
-> 主站本地 RPDO 对象

它用于:

  • RpdoRead()
  • RpdoGet()
  • 从本地写入通知反查远程对象,然后触发 OnRpdoWrite()

12.2 使用的对象字典区域

对象范围 含义
0x1400..0x15FF 本地 RPDO communication parameter
0x1600..0x17FF 本地 RPDO mapping parameter
0x5800..0x59FF Lely 扩展:远程 TPDO number 和 node-ID
0x5A00..0x5BFF Lely 扩展:远程 TPDO mapping

12.3 构建步骤

对每个 RPDO:

  1. 检查 0x1400+i:01 的 COB-ID 是否有效;
  2. 0x5800+i 获取远程 node-ID;
  3. 若不存在扩展对象,则尝试从预定义连接 COB-ID 推导 node-ID;
  4. 0x1600+i 读取本地 RPDO mapping;
  5. 0x5A00+i 读取远程 TPDO mapping;
  6. 检查本地和远程 mapping 项数一致;
  7. 检查每个映射项的位长度一致;
  8. 建立远程到本地映射;
  9. 同时建立本地到远程的反向映射。

12.4 映射键编码

远程对象键被编码到 32 位整数:

1
2
3
bits 31..24 : remote node-ID
bits 23..8 : remote index
bits 7..0 : remote sub-index

等价于:

1
(remote_id << 24) | (remote_idx << 8) | remote_subidx

本地对象键使用:

1
(local_idx << 8) | local_subidx

rpdo_mapping 同时保存:

1
2
remote key -> local key
local key -> remote key

反向映射使 Impl_::OnWrite(local_idx, local_subidx) 能恢复远程来源地址。


13. UpdateTpdoMapping() 的机制

13.1 映射方向

1
2
远程 RPDO 对象
-> 主站本地 TPDO 对象

它用于:

  • TpdoRead()
  • TpdoWrite()
  • TpdoGet()
  • TpdoSet()
  • TpdoWriteEvent()
  • TpdoSetEvent()

13.2 使用的对象字典区域

对象范围 含义
0x1800..0x19FF 本地 TPDO communication parameter
0x1A00..0x1BFF 本地 TPDO mapping parameter
0x5C00..0x5DFF Lely 扩展:远程 RPDO number 和 node-ID
0x5E00..0x5FFF Lely 扩展:远程 RPDO mapping

13.3 构建步骤

对每个 TPDO:

  1. 检查 TPDO COB-ID 是否有效;
  2. 获取远程 node-ID;
  3. 读取本地 TPDO mapping;
  4. 读取远程 RPDO mapping;
  5. 检查映射项数和位长度;
  6. 建立:
1
remote RPDO address -> local TPDO proxy address

13.4 写远程 RPDO 的实际过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sequenceDiagram
participant App as Application
participant Device as Device
participant Map as tpdo_mapping
participant LocalOD as Local co_dev_t
participant TPDO as TPDO service
participant CAN as CAN network

App->>Device: TpdoWrite(id, idx, subidx, value)
Device->>Map: 查 remote RPDO ->> local TPDO proxy
Map-->>Device: local index/sub-index
Device->>LocalOD: 本地 Write(local index/sub-index)
Note over Device,LocalOD: 此时没有 CAN 帧

App->>Device: TpdoWriteEvent(id, idx, subidx)
Device->>Map: 再次取得 local proxy
Device->>LocalOD: co_dev_tpdo_event(local index/sub-index)
LocalOD->>TPDO: TPDO event indication
TPDO->>LocalOD: co_pdo_up() 采集映射值
TPDO->>CAN: 发送 TPDO

从远程节点视角看,这个 TPDO 是它的 RPDO,因此应用 API 使用远程 RPDO 地址描述目标。


14. RpdoRead()TpdoRead()TpdoWrite() 的准确含义

14.1 RpdoRead()

1
2
3
输入:远程节点 TPDO 中的对象地址
过程:远程地址 -> 本地 RPDO proxy -> Device::Read()
结果:读取最后一次被 RPDO 更新到本地的缓存值

它不保证:

  • 远程节点当前仍在线;
  • 数据刚刚更新;
  • 主站会主动请求新的 PDO;
  • 读取动作会产生 CAN 帧。

14.2 TpdoRead()

1
2
3
输入:远程节点 RPDO 中的对象地址
过程:远程地址 -> 本地 TPDO proxy -> Device::Read()
结果:读取主站当前准备通过 TPDO 发出的本地值

它读取的不是远程节点已接收后的值。

14.3 TpdoWrite()

1
2
3
输入:远程节点 RPDO 中的对象地址
过程:远程地址 -> 本地 TPDO proxy -> Device::Write()
结果:更新主站本地待发送值

它不等价于:

1
远程 SDO download

也不一定立即发送 PDO。事件型 PDO 通常还需要 TpdoWriteEvent()


15. OnWrite()OnRpdoWrite() 的关系

15.1 OnWrite()

对象 0x2000..0xBFFF 经本地 SDO download 或 RPDO 成功写入后,Impl_::OnWrite() 会:

  1. 调用虚函数 Device::OnWrite(idx, subidx)
  2. 调用通过 Device::OnWrite(std::function<...>) 注册的函数对象;
  3. 查询该本地对象是否属于 RPDO 远程映射;
  4. 若能反查到远程地址,则调用 RpdoWrite()

15.2 RpdoWrite()

RpdoWrite() 进一步调用:

  1. 虚函数 OnRpdoWrite(remote_id, remote_idx, remote_subidx)
  2. 通过 OnRpdoWrite(std::function<...>) 注册的函数对象。

因此完整关系为:

1
2
3
4
5
6
本地对象成功写入
-> OnWrite(local idx, local subidx)

若该本地对象是 RPDO proxy
-> 反向映射
-> OnRpdoWrite(remote id, remote idx, remote subidx)

15.3 锁和回调

Device 使用 std::lock_guard<Impl_> 自动加锁。

调用用户 std::function 前会:

  1. 复制函数对象;
  2. 使用 UnlockGuard<Impl_> 临时解锁;
  3. 执行用户回调;
  4. 离开作用域后重新加锁。

目的在于避免用户回调再次调用 Device 公共接口时死锁。

虚函数 OnWrite()OnRpdoWrite() 与注册的 std::function 锁环境并不完全相同,派生类实现时必须遵守 Node 对锁状态的约定。


16. 真正的远程 SDO 与 Device 有什么关系

16.1 真正的远程 SDO

真正通过 CAN 读写远程对象字典的底层接口是:

1
2
3
co_csdo_up_req();
co_csdo_dn_req();
co_csdo_dn_val_req();

C++ 应用层封装是:

1
lely::canopen::Sdo

其典型接口是:

1
2
Sdo::SubmitUpload<T>(...);
Sdo::SubmitDownload<T>(...);

Sdo 内部管理 Client-SDO 请求队列和 co_csdo_t 状态机。

16.2 网络调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sequenceDiagram
participant App as Application
participant Queue as coapp::Sdo
participant CSDO as co_csdo_t
participant CAN as CAN network
participant SSDO as Remote Server-SDO
participant RemoteOD as Remote object dictionary

App->>Queue: SubmitUpload / SubmitDownload
Queue->>CSDO: 排队并启动请求
CSDO->>CAN: 发送 SDO request CAN frame
CAN->>SSDO: 远程节点接收
SSDO->>RemoteOD: upload/download indication
RemoteOD-->>SSDO: 值或 abort code
SSDO->>CAN: SDO response CAN frame
CAN->>CSDO: 响应
CSDO-->>Queue: completion
Queue-->>App: callback / future

16.3 Device 在远程 SDO 中的作用

Sdo 构造时可以接收:

1
Sdo(can_net, co_dev, sdo_number)

此时本地 co_dev_t 主要提供:

  • 0x1280..0x12FF Client-SDO 参数;
  • COB-ID;
  • 对端 node-ID 等配置。

但实际网络传输和状态机仍属于 co_csdo_t,不是 Device::Read/Write()

16.4 最重要的区分表

需求 正确接口
读取本节点对象,并执行 SDO 权限与回调 Device::Read()
写本节点对象,并执行 SDO 权限与回调 Device::Write()
协议栈内部直接读取本地值 Device::Get()
协议栈内部直接修改本地值 Device::Set()
读取最近一次远程 TPDO 映射到本地的值 Device::RpdoRead()
修改本地主站将通过 TPDO 发往远程 RPDO 的值 Device::TpdoWrite()
触发事件型 TPDO Device::TpdoWriteEvent()
主动读取远程节点对象字典 Sdo::SubmitUpload() / co_csdo_up_req()
主动写远程节点对象字典 Sdo::SubmitDownload() / co_csdo_dn_req()

17. 从应用视角理解一组典型操作

17.1 本地对象字典 SDO 语义读取

1
uint32_t value = Read<uint32_t>(0x2000, 0x00);

含义:

1
对当前 Device 的本地对象字典执行一次本地 SDO upload。

17.2 本地对象字典 SDO 语义写入

1
Write<uint16_t>(0x2001, 0x00, 100);

含义:

1
2
对当前 Device 的本地对象字典执行一次本地 SDO download,
执行权限检查、范围检查和写入回调。

17.3 读取远程 TPDO 的本地缓存

Device 派生类中:

1
int32_t position = RpdoRead<int32_t>(5, 0x6064, 0x00);

含义:

1
2
读取节点 5 的 0x6064:00 所对应的本地 RPDO proxy 值。
不产生 SDO 请求,也不主动请求远程 TPDO。

17.4 通过 TPDO 为远程 RPDO 准备数据

1
2
TpdoWrite<uint16_t>(5, 0x6040, 0x00, control_word);
TpdoWriteEvent(5, 0x6040, 0x00);

含义:

1
2
先把 control_word 写入本地 TPDO proxy,
再触发映射了该对象的事件型 TPDO。

17.5 通过远程 SDO 写节点 5

这类操作不走 Device::TpdoWrite(),而应提交到节点 5 对应的 Client-SDO 队列:

1
Sdo::SubmitDownload(..., 0x6040, 0x00, control_word, ...)

该操作会产生 SDO CAN 帧,并等待远程 Server-SDO 响应。


18. 完整运行关系图

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
flowchart TB
APP[Application]

subgraph CPP[C++ coapp layer]
DEVICE[Device]
SDOQ[Sdo queue]
NODE[Node / Master]
end

subgraph CCORE[C CANopen core]
DEV[co_dev_t]
OBJ[co_obj_t / co_sub_t]
LOCALSDO[local SDO request\nco_dev_up_req / co_dev_dn_req]
CSDO[co_csdo_t]
RPDO[co_rpdo_t]
TPDO[co_tpdo_t]
PDODN[co_pdo_dn]
PDOUP[co_pdo_up]
end

CAN[CAN network]
REMOTE[Remote CANopen node]

APP --> DEVICE
APP --> SDOQ
NODE --> DEVICE
NODE --> CAN

DEVICE --> DEV
DEV --> OBJ

DEVICE --> LOCALSDO
LOCALSDO --> OBJ

DEVICE -->|Rpdo/Tpdo address mapping| DEV

SDOQ --> CSDO
CSDO --> CAN
CAN --> REMOTE

CAN --> RPDO
RPDO --> PDODN
PDODN --> OBJ

TPDO --> PDOUP
PDOUP --> OBJ
TPDO --> CAN

20. 只保留与机制直接相关的 C++ 语法

20.1 PIMPL

1
2
struct Impl_;
std::unique_ptr<Impl_> impl_;

用途:隐藏实现细节,避免在头文件暴露 map、回调、锁和 C 结构。

20.2 RAII 锁

1
std::lock_guard<Impl_> lock(*impl_);

进入作用域加锁,离开作用域自动解锁。

20.3 traits

1
using traits = canopen_traits<T>;

将 C++ 类型映射为:

  • CANopen data type index;
  • C 类型;
  • 地址和长度;
  • 构造、转换和销毁方法。

20.4 SFINAE

1
typename std::enable_if<is_canopen<T>::value, T>::type

表示只有 T 是 Lely 支持的 CANopen 类型时,该模板函数才参与编译。

20.5 std::tie()

1
std::tie(idx, subidx) = impl_->TpdoMapping(...);

把函数返回的 tuple 分别写回多个变量。

20.6 std::function

1
std::function<void(uint16_t, uint8_t)> on_write;

保存普通函数、lambda 或成员函数适配器,作为统一的写入通知接口。

这些语法都是服务于对象生命周期、类型转换、锁和回调,并没有改变底层 CANopen 数据流。