Lely CANopen coapp::Device:本地 SDO/PDO 读写、远程对象映射与运行机制
@[toc]
1. 先给出最关键的结论
理解 Device 时,必须先把“本地读写”“远程读写”“SDO”“PDO”分开:
Device本质上是co_dev_t的 C++ 所有权与访问封装。Device::Read()、Device::Write()是对本地对象字典执行一次“本地 SDO 请求”,不会发送 CAN 帧。Device::Get()、Device::Set()是直接访问本地对象字典值,绕过 SDO 访问权限、范围检查和下载/上传回调。Device::RpdoRead()并不会通过 CAN 去读取远程节点,而是把“远程 TPDO 对象地址”转换为本地 RPDO 映射对象,再读取本地缓存值。Device::TpdoWrite()也不会执行远程 SDO 下载,而是把“远程 RPDO 对象地址”转换为本地 TPDO 映射对象,再写入本地待发送值。- 真正通过 CAN 总线访问远程对象字典的是
co_csdo_t/lely::canopen::Sdo,不是Device。 - RPDO 接收和 TPDO 发送虽然是 PDO 通信,但它们访问对象字典时复用了本地 SDO indication 机制:
- RPDO:
co_pdo_dn()→co_sub_dn_ind(); - TPDO:
co_pdo_up()→co_sub_up_ind()。
- RPDO:
Device位于“对象字典模型”和“CANopen 服务”之间,但它本身不是 CAN 网络服务,不持有 CAN 通道,也不直接收发 CAN 帧。
最容易混淆的点是:
RpdoRead()和TpdoWrite()的参数看起来是远程节点的node-ID/index/sub-index,但它们操作的仍然是本地co_dev_t中的代理对象。
2. Device 在 Lely CANopen 中处于什么位置
2.1 总体结构
1 | flowchart LR |
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 | Device = co_dev_t 的 C++ 所有权封装 |
它不等于:
1 | Device != Client-SDO |
3. Device 与 co_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 支持三种输入:
- 已存在的
co_dev_t*; - 文本 EDS/DCF,以及可选的 concise DCF;
- 静态设备描述
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 区域,主要供 Node、Master 或派生类内部使用。
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 | sequenceDiagram |
5.3 代码主线
1 | template <class T> |
关键点:
co_dev_up_req()返回值表示函数调用本身是否失败;ac表示 CANopen SDO abort code;OnUpCon<T>将原始字节转换为目标 C++ 类型;canopen_traits<T>决定T对应的 CANopen 类型编号和 C 表示;- 无
error_code参数的重载会把错误转换为SdoError异常。
5.4 Read() 与 Get() 的区别
Get() 的主线是:
1 | co_dev_find_obj() |
它不会经过 co_dev_up_req() 和 co_sub_up_ind(),因此:
- 不执行 SDO read access 检查;
- 不执行用户注册的 upload indication;
- 不适合模拟一次真实的 SDO 读取;
- 适合协议栈内部直接观察本地状态。
6. 本地 SDO 写入:Device::Write()
6.1 调用链
1 | sequenceDiagram |
6.2 主体实现
1 | template <class T> |
6.3 写入成功后的通知机制
Device::Impl_ 构造时遍历对象字典中的对象,并对 0x2000..0xBFFF 区域安装 download indication:
1 | co_obj_set_dn_ind( |
这里的逻辑不是替换默认写入,而是:
1 | 先执行 co_sub_on_dn() |
因此 OnWrite() 看到的是已经成功写入后的值。
6.4 Write() 与 Set() 的区别
Set() 直接执行:
1 | 查找 obj/sub |
它绕过:
- SDO 写权限;
- 数值范围检查;
- 对象自定义 download indication;
Device::OnWrite();OnRpdoWrite()。
因此:
- 应用层需要按 CANopen 规则访问对象时使用
Write(); - 协议栈内部维护影子值或初始化值时才使用
Set(); - 不应因为
Set()更直接就用它替代正常 SDO 写入。
7. PDO 为什么也走本地 SDO indication
PDO 与 SDO 在总线上是不同协议,但写入或读取对象字典时,它们需要遵守同一套对象访问钩子。
Lely 的实现选择复用:
1 | co_sub_dn_ind() 本地下载入口 |
这样做的结果是:
- 通过 SDO 写对象;
- 通过 RPDO 写对象;
- 协议栈内部执行本地 SDO 写请求;
都能进入相同的子对象下载处理链。
这正是 Device::OnWrite() 能同时观察 SDO download 和 RPDO 写入的原因。
8. RPDO 接收后如何写入本地对象字典
8.1 服务创建关系
co_rpdo_create() 同时需要:
1 | can_net_t *net |
net用于接收 CAN 帧;dev提供0x1400..0x15FF通信参数、0x1600..0x17FF映射参数以及目标应用对象。
8.2 数据路径
1 | sequenceDiagram |
8.3 co_pdo_dn() 的核心动作
每个 mapping entry 是一个 32 位值:
1 | bits 31..16 : object index |
co_pdo_dn() 对每个映射项执行:
- 从 PDO payload 按位提取数据;
- 可选执行
co_dev_chk_rpdo(); - 查找本地
co_sub_t; - 构造临时
co_sdo_req; - 调用
co_sub_dn_ind(); - 由子对象完成权限、类型和应用处理;
- 写入成功后进入
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 | can_net_t *net |
co_dev_t 中需要存在:
0x1800..0x19FFTPDO communication parameters;0x1A00..0x1BFFTPDO mapping parameters;- 被映射的本地应用对象。
9.2 数据路径
1 | sequenceDiagram |
9.3 co_pdo_up() 的核心动作
对每个映射项:
- 可选执行
co_dev_chk_tpdo(); - 查找本地子对象;
- 清理并复用
co_sdo_req; - 调用
co_sub_up_ind(); - 确认值可在一次 PDO 采样中完整取得;
- 按 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() 会:
- 检查该子对象是否被映射到 PDO;
- 找到所有包含该子对象的有效异步或事件驱动 TPDO;
- 调用注册在
co_dev_t上的 TPDO event indication; - 对应
co_tpdo_t服务再尝试发送 PDO。
因此,典型事件型 TPDO 操作分为两步:
1 | TpdoWrite<uint16_t>(remote_id, remote_idx, remote_subidx, value); |
第一步只更新本地 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 | remote (node 5, 0x6064:00) |
应用调用:
1 | RpdoRead<int32_t>(5, 0x6064, 0); |
内部实际执行:
1 | RpdoMapping(5, 0x6064, 0) |
这里没有向节点 5 发送任何请求。
11.2 为什么仍然叫 remote object
因为 API 向应用层暴露的是远程设备语义:
1 | 节点 ID + 远程 index + 远程 sub-index |
但是协议实现采用本地代理对象保存 PDO 数据,因此 Device 需要完成地址翻译。
12. UpdateRpdoMapping() 的机制
12.1 映射方向
1 | 远程 TPDO 对象 |
它用于:
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:
- 检查
0x1400+i:01的 COB-ID 是否有效; - 从
0x5800+i获取远程 node-ID; - 若不存在扩展对象,则尝试从预定义连接 COB-ID 推导 node-ID;
- 从
0x1600+i读取本地 RPDO mapping; - 从
0x5A00+i读取远程 TPDO mapping; - 检查本地和远程 mapping 项数一致;
- 检查每个映射项的位长度一致;
- 建立远程到本地映射;
- 同时建立本地到远程的反向映射。
12.4 映射键编码
远程对象键被编码到 32 位整数:
1 | bits 31..24 : remote node-ID |
等价于:
1 | (remote_id << 24) | (remote_idx << 8) | remote_subidx |
本地对象键使用:
1 | (local_idx << 8) | local_subidx |
rpdo_mapping 同时保存:
1 | remote key -> local key |
反向映射使 Impl_::OnWrite(local_idx, local_subidx) 能恢复远程来源地址。
13. UpdateTpdoMapping() 的机制
13.1 映射方向
1 | 远程 RPDO 对象 |
它用于:
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:
- 检查 TPDO COB-ID 是否有效;
- 获取远程 node-ID;
- 读取本地 TPDO mapping;
- 读取远程 RPDO mapping;
- 检查映射项数和位长度;
- 建立:
1 | remote RPDO address -> local TPDO proxy address |
13.4 写远程 RPDO 的实际过程
1 | sequenceDiagram |
从远程节点视角看,这个 TPDO 是它的 RPDO,因此应用 API 使用远程 RPDO 地址描述目标。
14. RpdoRead()、TpdoRead()、TpdoWrite() 的准确含义
14.1 RpdoRead()
1 | 输入:远程节点 TPDO 中的对象地址 |
它不保证:
- 远程节点当前仍在线;
- 数据刚刚更新;
- 主站会主动请求新的 PDO;
- 读取动作会产生 CAN 帧。
14.2 TpdoRead()
1 | 输入:远程节点 RPDO 中的对象地址 |
它读取的不是远程节点已接收后的值。
14.3 TpdoWrite()
1 | 输入:远程节点 RPDO 中的对象地址 |
它不等价于:
1 | 远程 SDO download |
也不一定立即发送 PDO。事件型 PDO 通常还需要 TpdoWriteEvent()。
15. OnWrite() 与 OnRpdoWrite() 的关系
15.1 OnWrite()
对象 0x2000..0xBFFF 经本地 SDO download 或 RPDO 成功写入后,Impl_::OnWrite() 会:
- 调用虚函数
Device::OnWrite(idx, subidx); - 调用通过
Device::OnWrite(std::function<...>)注册的函数对象; - 查询该本地对象是否属于 RPDO 远程映射;
- 若能反查到远程地址,则调用
RpdoWrite()。
15.2 RpdoWrite()
RpdoWrite() 进一步调用:
- 虚函数
OnRpdoWrite(remote_id, remote_idx, remote_subidx); - 通过
OnRpdoWrite(std::function<...>)注册的函数对象。
因此完整关系为:
1 | 本地对象成功写入 |
15.3 锁和回调
Device 使用 std::lock_guard<Impl_> 自动加锁。
调用用户 std::function 前会:
- 复制函数对象;
- 使用
UnlockGuard<Impl_>临时解锁; - 执行用户回调;
- 离开作用域后重新加锁。
目的在于避免用户回调再次调用 Device 公共接口时死锁。
虚函数 OnWrite()、OnRpdoWrite() 与注册的 std::function 锁环境并不完全相同,派生类实现时必须遵守 Node 对锁状态的约定。
16. 真正的远程 SDO 与 Device 有什么关系
16.1 真正的远程 SDO
真正通过 CAN 读写远程对象字典的底层接口是:
1 | co_csdo_up_req(); |
C++ 应用层封装是:
1 | lely::canopen::Sdo |
其典型接口是:
1 | Sdo::SubmitUpload<T>(...); |
Sdo 内部管理 Client-SDO 请求队列和 co_csdo_t 状态机。
16.2 网络调用链
1 | sequenceDiagram |
16.3 Device 在远程 SDO 中的作用
Sdo 构造时可以接收:
1 | Sdo(can_net, co_dev, sdo_number) |
此时本地 co_dev_t 主要提供:
0x1280..0x12FFClient-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 | 对当前 Device 的本地对象字典执行一次本地 SDO download, |
17.3 读取远程 TPDO 的本地缓存
在 Device 派生类中:
1 | int32_t position = RpdoRead<int32_t>(5, 0x6064, 0x00); |
含义:
1 | 读取节点 5 的 0x6064:00 所对应的本地 RPDO proxy 值。 |
17.4 通过 TPDO 为远程 RPDO 准备数据
1 | TpdoWrite<uint16_t>(5, 0x6040, 0x00, control_word); |
含义:
1 | 先把 control_word 写入本地 TPDO proxy, |
17.5 通过远程 SDO 写节点 5
这类操作不走 Device::TpdoWrite(),而应提交到节点 5 对应的 Client-SDO 队列:
1 | Sdo::SubmitDownload(..., 0x6040, 0x00, control_word, ...) |
该操作会产生 SDO CAN 帧,并等待远程 Server-SDO 响应。
18. 完整运行关系图
1 | flowchart TB |
20. 只保留与机制直接相关的 C++ 语法
20.1 PIMPL
1 | struct 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 数据流。









