Lely CANopen SYNC、TIME 与 EMCY学习
@[toc]
SYNC、TIME 和 EMCY 都属于 CiA 301 通信对象,但在 Lely CANopen 中,它们并不是三个“收到 CAN 帧后直接调用用户回调”的简单模块。三者都被实现成依附于 can_net_t 和 co_dev_t 的被动服务对象:对象字典决定角色与 CAN-ID,can_recv_t 接收匹配报文,can_timer_t 驱动周期或抑制时间,最终通过回调或 can_net_send() 把事件交给上层。
三者的触发模型并不相同:
co_sync_t的 producer 周期直接由对象0x1006驱动,修改0x1005/0x1006/0x1019会立即重配 receiver、timer 和 counter;co_time_t的0x1012只决定 producer/consumer 角色和 COB-ID,实际发送起点与周期仍要由co_time_start_prod()显式提交;co_emcy_t不做周期发送,它把错误变化压入活动错误栈和 CAN 帧缓冲,再按0x1015inhibit time 串行发送;sync.hpp、time.hpp、emcy.hpp没有重新实现协议,只使用incomplete_c_type管理 C 对象并适配 C++ callable。
这篇文章以用户提供的本地 lely-core 源码包为主要证据,结合 CiA 301 V4.2.0 和 Lely 官方文档校对协议边界。源码包未携带 .git 元数据,因此本文不把它静默等同于某个 Git commit;文中涉及行号时,均指本次本地归档文件。
1. 九个文件在整条链路中的位置
本文重点分析以下文件:
| 文件 | 主要内容 | 是否保存运行状态 |
|---|---|---|
include/lely/co/sync.h |
SYNC C API、COB-ID 位定义、indication/error callback | 否 |
include/lely/co/sync.hpp |
COSync C++ 不完整类型包装与 callable 适配 |
否 |
src/co/sync.c |
SYNC receiver、periodic timer、计数器、OD 动态重配置 | 是 |
include/lely/co/time.h |
TIME C API、TIME_OF_DAY 转换、producer 调度接口 | 否 |
include/lely/co/time.hpp |
COTime C++ 包装 |
否 |
src/co/time.c |
TIME receiver、timer、时间编码与 0x1013 更新 |
是 |
include/lely/co/emcy.h |
EMCY producer/consumer API、错误栈接口和 indication | 否 |
include/lely/co/emcy.hpp |
COEmcy C++ 包装 |
否 |
src/co/emcy.c |
活动错误栈、0x1003、发送缓冲、inhibit timer、远端 EMCY consumer |
是 |
Lely 官方概览把 CANopen 核心描述为完全被动的实现,并在 standards support 页面列出 CiA 301 的 SYNC、TIME、EMCY 及相关 OD 对象支持范围:[^lely-standards]
协议库不创建线程,不自行执行 CAN I/O,也不直接读取系统时钟;应用需要把 CAN 帧和当前时间送入 can_net_t。SYNC、TIME 和 EMCY 都是这种架构中的 service object。[^lely-overview]
1 | flowchart TB |
这里要特别区分两层“时间”:
1 | can_timer_t |
sync.c、time.c、emcy.c 只操作 can_timer_t。它们不知道 Linux timerfd、SocketCAN、epoll 或线程,实际系统 I/O 仍由 io_can_net 等桥接层完成。
2. 三个服务共享的生命周期模型
三组源码都采用相同的对象生命周期:
1 | __co_xxx_alloc() |
2.1 创建时已经启动
co_sync_create()、co_time_create() 和 co_emcy_create() 都通过内部 __co_xxx_init() 完成初始化,并在成功路径中启动服务。对应头文件也明确说明“service is started as if by co_xxx_start()”。因此:
create()不是单纯分配内存;- 创建后对象字典回调和 CAN receiver/timer 可能已经生效;
stop()必须解除这些外部关联,否则仅释放外层指针会留下悬空回调。
2.2 对象字典写入直接驱动运行期重配置
三个服务启动时都会为关键 OD 对象安装 download indication:
| 服务 | 被拦截对象 | 重配置效果 |
|---|---|---|
| SYNC | 0x1005、0x1006、0x1019 |
重注册 receiver、重设周期 timer、复位 counter |
| TIME | 0x1012 |
重注册 consumer receiver;必要时停止 producer timer |
| EMCY | 0x1003、0x1014、0x1028 |
清错误历史、改变 producer COB-ID、重配指定远端 consumer |
这类写入不是“先修改 OD,等下次复位再生效”。实际链路是:
1 | SDO download / 本地 SDO 写入 |
因此运行期写通信参数时,访问规则本身也是状态机的一部分。例如 SYNC producer 活动时不允许直接把旧 CAN-ID 改成另一个仍然有效的 producer CAN-ID;正确过程通常是先禁用,再修改标识符,最后重新启用。
2.3 条件编译直接改变接口和 ABI
源码分别受以下宏控制:
1 |
Autotools 对应选项为:
1 | --disable-sync -> LELY_NO_CO_SYNC |
Lely 官方构建文档列出了这些裁剪选项,并说明部分结构体相关特性宏必须在库与应用侧保持一致。对于嵌入式静态库,不能只在应用代码中包含头文件后假定服务一定存在;库、头文件和应用的条件编译配置必须匹配。[^lely-config]
3. SYNC:对象字典直接驱动 producer 周期
3.1 struct __co_sync 保存了什么
src/co/sync.c:40-67 的对象非常紧凑:
1 | co_sync_t |
它没有独立线程,没有报文队列,也不直接维护 PDO。SYNC 服务只负责:
- 接收或产生 SYNC 帧;
- 维护可选计数器;
- 把同步事件交给上层;
- 在帧长度不符合配置时报告通信错误。
3.2 三个对象决定 SYNC 本体,0x1007 不在这里处理
| OD | 作用 | sync.c 中的行为 |
|---|---|---|
0x1005 |
SYNC COB-ID、producer 位、标准/扩展帧 | 决定 receiver 或 producer 角色及 CAN-ID |
0x1006 |
communication cycle period,单位 µs | producer 周期;为 0 时不启动周期发送 |
0x1019 |
synchronous counter overflow | 0 表示无 payload;2..240 表示一字节计数器上限 |
0x1007 |
synchronous window length | 不由 sync.c 处理,由 RPDO/TPDO 服务用于同步窗口判定 |
这一区分很重要。co_sync_t 只产生“同步边沿”;同步窗口内是否允许发送 TPDO、是否接收并执行 RPDO,是 PDO 服务的职责。把 0x1007 写入 sync.c 的心智模型,会导致调用关系完全错位。
3.3 co_sync_update() 的角色矩阵
src/co/sync.c:380-420 是配置落地的中心:
| producer 位 | 0x1006 |
receiver | timer |
|---|---|---|---|
| 0 | 任意 | 按 0x1005 注册,作为 consumer |
停止 |
| 1 | 0 | 停止,避免接收自己的 SYNC | 停止 |
| 1 | 非 0 | 停止 | 周期启动 |
producer 周期不是从“当前时刻 + 周期”任意起算。源码先取得 can_net_t 当前时间,再对周期取模,把首次发送时间对齐到下一个周期整数倍:
1 | now |
这使多个使用同一时间基准、同一周期的节点更容易落在一致的时间栅格上。
3.4 consumer 接收流程
1 | sequenceDiagram |
co_sync_recv() 的细节有两点:
- 若
0x1019 == 0,期望 DLC 为 0;否则期望 DLC 为 1; - 即使 DLC 错误,源码仍会调用 indication,只是传入
cnt = 0,同时通过 error callback 报告0x8240 / 0x10。
在 NMT 集成路径中,SYNC indication 会进入 co_nmt_on_sync()。源码明确先处理 TPDO,再处理 RPDO,以确保同一对象同时映射到同步 TPDO 和 RPDO 时,发送的是“上一个同步窗口”的值,然后才用本次收到的 RPDO 更新对象字典(src/co/nmt.c:1621-1643)。这不是普通的调用顺序偏好,而是同步数据一致性的语义要求。
3.5 producer 发送流程
周期到期后 co_sync_timer():
1 | can_timer_t 到期 |
因此本节点作为 producer 时,也会通过 indication 驱动本地同步 PDO,不必等待 CAN 控制器把自己发送的帧再回环接收。co_sync_update() 同时会停掉 receiver,正是为了避免“本地 timer indication + 自收回环”造成重复同步事件。
3.6 0x1019 的运行期限制
CiA 301 规定 0x1019:[^cia301]
0:SYNC 无数据;1:保留;2..240:一字节 counter;241..255:保留。
源码进一步禁止在 0x1006 != 0 时修改 0x1019,返回 CO_SDO_AC_DATA_DEV。也就是说,要改变 counter 形式,应先把通信周期清零,再修改 overflow,最后恢复周期。
4. TIME:角色由 0x1012 决定,调度由 API 决定
4.1 TIME 与 SYNC 的根本差异
SYNC 解决“同步动作发生在什么时候”,TIME 解决“网络当前时间是什么”。两者虽然都使用 producer/consumer 模型,但 Lely 的调度方式不同:
1 | SYNC producer |
这一点由 co_time_update() 直接体现:
- consumer 位有效时注册 receiver;
- consumer 位无效时停止 receiver;
- producer 位无效时停止 timer;
- producer 位有效时,函数本身并不启动 timer。
4.2 相关对象
| OD | 作用 | 本源码中的使用方式 |
|---|---|---|
0x1012 |
TIME COB-ID、consumer 位、producer 位、帧类型 | 决定角色和 CAN-ID |
0x1013 |
High resolution time stamp,单位 µs | producer 每次发送前写入“服务创建后经过的微秒数” |
0x1013 不是 TIME 帧的第七或第八个字节。它是本地对象字典中的高分辨率时间戳;标准 TIME 帧仍固定为 6 字节。
4.3 TIME_OF_DAY 编码与 epoch 转换
TIME payload:
1 | Byte 0..3:当天经过的毫秒数,低 28 bit 有效 |
Lely 对外使用 struct timespec。转换函数完成 Unix epoch 与 CANopen epoch 的偏移:
1 | 1970-01-01 -> 1984-01-01 |
co_time_of_day_get() 把 CANopen TIME_OF_DAY 转为 Unix timespec;co_time_of_day_set() 执行反向转换。Lely v2.3.4 的发布说明曾专门修复 co_time_of_day_set() “只计算但没有真正写回结果”的问题,说明这部分转换应纳入版本核对范围。[^lely-v234]
4.4 consumer 接收流程
1 | sequenceDiagram |
源码只要求 msg->len >= 6,不会因经典 CAN 帧带有额外字节而拒绝;实际只解析前 6 字节。它会忽略 RTR 和 CAN FD 格式帧。
4.5 producer 调度与发送
co_time_start_prod(time, start, interval) 直接把参数交给 can_timer_start():
start |
interval |
语义 |
|---|---|---|
| 非空 | 非空 | 从绝对时间 start 开始周期发送 |
| 非空 | 空 | 在绝对时间发送一次 |
| 空 | 非空 | 相对当前时间等待一个 interval 后周期发送 |
| 空 | 空 | 停止 producer timer;实际工程直接调用 co_time_stop_prod() 表意更清楚 |
发送回调 co_time_timer(tp) 使用的 tp 是本次 timer deadline:
1 | 更新 0x1013 = tp - service creation time(µs,截断为 u32) |
这里存在两个值得注意的时基边界:
- TIME payload 表达的是绝对日期时间,通常需要与真实 UTC/日历时钟一致;
can_net_t的超时调度在工程上往往接入单调时钟。若直接用单调时钟值编码 TIME payload,得到的不会是有意义的日历时间。
因此 TIME producer 的 can_net_t 时间源、应用提供的 start 和系统实时时钟之间必须有明确的映射策略。源码不会替应用自动完成时钟同步、时区处理或 UTC 校准。
5. EMCY:活动错误栈、历史对象和发送抑制是三套不同状态
5.1 co_emcy_t 内部不是一个错误码变量
src/co/emcy.c:85-123 的核心状态包括:
1 | co_emcy_t |
必须区分:
- 活动错误栈:当前仍未解除的错误条件,用于计算组合 error register;
0x1003:向对象字典暴露的预定义错误域;- CAN 帧缓冲:已经形成、但受 inhibit time 限制尚未发送的 EMCY 帧;
- consumer receiver:接收别的节点的 EMCY,与本节点 producer 错误栈无直接所有权关系。
5.2 相关对象
| OD | 作用 | Lely 实现 |
|---|---|---|
0x1001 |
Error register | 每次 co_emcy_send() 前更新为当前组合值 |
0x1003 |
Pre-defined error field | 把活动错误栈中的 EEC 写入子项,最新错误位于最前 |
0x1014 |
EMCY producer COB-ID | 决定 producer 是否有效及 CAN-ID |
0x1015 |
EMCY inhibit time,单位 100 µs | 决定相邻发送帧最小间隔 |
0x1028 |
Emergency consumer object | 每个子项把远端 Node-ID 关联到一个 EMCY COB-ID |
CiA 301 对 0x1003 的每个错误项定义为 32 位:[^cia301]
低 16 位是 error code,高 16 位是制造商附加信息。本源码 co_emcy_set_1003() 只写 emcy->msgs[i].eec,因此高 16 位保持 0。这是合法的“未提供附加信息”实现,但要注意:co_emcy_push() 传入的 5 字节 MSEF 不会进入 0x1003。
5.3 push() 如何建立新的错误状态
co_emcy_push() 的执行顺序:
1 | 检查 eec != 0 |
组合 error register 不是只取最新错误:
1 | for (size_t i = 0; i < emcy->nmsg; i++) |
因此多个错误属于不同类别时,0x1001 可以同时反映多个类别。Lely v2.3.4 发布说明明确提到“error register 改为按需计算”,以支持按任意顺序解除错误。[^lely-v234]
5.4 remove()、pop() 与 clear()
| API | 行为 |
|---|---|
co_emcy_pop() |
读取并移除 index 0,即最近错误 |
co_emcy_remove(n) |
移除任意栈位置,用于错误恢复顺序与发生顺序不同的情况 |
co_emcy_find(eec) |
从新到旧查找指定 EEC |
co_emcy_clear() |
清空所有活动错误并发送 no-error EMCY |
移除一个错误后,源码发送 EEC 0x0000 的 reset 报文:
- 若仍有活动错误,error register 是剩余错误的 OR;MSEF 前两字节放当前最新错误 EEC;
- 若已无活动错误,发送 EEC
0x0000、ER0x00。
这解释了为什么“解除其中一个错误”不等于 0x1001 必然清零。
5.5 inhibit time 与发送缓冲
1 | flowchart TD |
0x1015 == 0 时,deadline 每次仍等于 now,循环可以在一次 co_emcy_flush() 中把全部缓存帧提交给 can_net_send()。当 inhibit 非零时,函数每次最多发送当前允许的一帧,然后为下一帧建立 one-shot deadline。
注意 can_buf 只是 Lely CAN 协议层的帧缓冲。can_net_send() 之后,io2 CAN channel 仍可能有自己的异步写队列;两者解决的是不同层的背压问题。
5.6 远端 EMCY consumer
0x1028 的每个有效子项对应一个远端节点。初始化阶段,源码为 1..127 的节点槽位准备 receiver;启动或写入 0x1028 时,再按配置的 COB-ID 注册指定 receiver。
收到帧后:
1 | 忽略 RTR |
本地归档的 MSEF 复制代码为:
1 | memcpy(msef, msg->data + 3, |
从协议语义看,这里更合理的长度应是 MIN(msg->len - 3, 5)。当前 MAX 会在 DLC 为 4..7 时复制 5 字节,把有效 payload 之外的数据数组内容也交给用户回调。对经典 CAN 的固定 data[8] 布局而言,这通常不会越过结构体数组边界,但会把未由该帧声明的尾部字节当成 MSEF,属于已确认的有效长度处理异常。同样,本文未声称已完成运行时复现或确认上游当前分支状态。
5.7 无动态内存时的资源边界
定义 LELY_NO_MALLOC 后,EMCY 改用静态存储:
| 宏 | 默认值 | 影响 |
|---|---|---|
CO_EMCY_MAX_NMSG |
8 | 同时保留的活动错误数量上限 |
CO_EMCY_CAN_BUF_SIZE |
16 帧 | 等待 inhibit time 发送的帧缓冲容量 |
co_emcy_push() 在活动错误达到上限后返回 ERRNUM_NOMEM;CAN 帧缓冲不能扩容时,co_emcy_send() 也会失败。嵌入式目标应根据最坏情况下的并发错误数和错误突发速率配置这两个宏,而不是只看单帧 EMCY 的 8 字节 payload。
6. SYNC 错误如何进入 EMCY
SYNC 与 EMCY 并非完全独立。Lely 的 NMT service manager 在创建 SYNC 时安装两个回调:
1 | SYNC indication -> co_nmt_on_sync() |
当 SYNC DLC 与 0x1019 配置不匹配时:
1 | co_sync_recv() |
如果 EEC 属于 0x81xx communication error,co_nmt_on_err() 还会调用由 0x1029:01 指定的 communication error behavior。0x8240 不属于 0x81xx,因此它进入 EMCY,但不会走这条 communication error behavior 分支。
TIME 在当前 nmt_srv.c 中只被创建和销毁,没有像 SYNC 那样自动安装 indication/error bridge。应用若需要在收到 TIME 后校准本地时钟,必须自行取得 TIME service 并设置 indication,或在更高层完成对应接入。
7. 三个模块的运行模型对照
| 维度 | SYNC | TIME | EMCY |
|---|---|---|---|
| 通信模型 | producer/consumer | producer/consumer | producer/consumer |
| 典型默认 CAN-ID | 0x080 |
0x100 |
0x080 + Node-ID |
| payload | 0 或 1 字节 | 固定 6 字节 | 固定 8 字节 |
| producer 触发 | 0x1006 周期 |
co_time_start_prod() |
错误状态变化 |
| consumer 注册 | 非 producer 时自动注册 | 0x1012 consumer 位 |
0x1028 每个远端节点 |
| 使用 timer | 周期 timer | 应用提交的 timer | inhibit deadline timer |
| 内部队列 | 无 | 无 | 活动错误栈 + CAN 帧缓冲 |
| 与 NMT 集成 | 自动驱动 TPDO/RPDO,错误可进入 EMCY | 当前只创建/销毁 | 作为 NMT 错误出口 |
| 动态配置对象 | 1005/1006/1019 |
1012 |
1003/1014/1028,发送时读取 1015 |
三者共同的设计思想可以概括为:
1 | 对象字典定义通信参数 |
8. C++ 头文件:所有权和回调适配,不是第二套实现
三个 .hpp 的结构基本一致:
1 | c_type_traits<__co_xxx> |
例如:
1 | class COSync : public incomplete_c_type<__co_sync> { |
关键点:
alloc/init/fini/free最终仍调用 C 层__co_xxx_*();start()、stop()、push()、startProd()等均是薄转发;- 模板版
setInd()/setErr()使用c_obj_call或c_mem_call把函数对象、成员函数适配为 C callback; - 三个类的析构函数都是
protected,说明它们不是按普通值类型随意栈上创建和销毁的接口; - 协议行为、条件编译和 ABI 最终仍由 C 实现决定。
Lely 官方 Doxygen 当前标识为 2.4.0,并将这些头文件描述为对应 C service object 的 C++ interface。[^lely-doxygen] 在新应用中,若已经使用 liblely-coapp,通常应优先通过 Node/BasicMaster/BasicSlave 等上层对象取得服务能力,而不是绕过 NMT service manager 再创建一套重复的 SYNC/TIME/EMCY 对象。
参考资料
[^lely-overview]: Lely CANopen - Library overview,说明协议栈的被动、异步架构以及 can_net_t、SYNC、TIME、EMCY service object。
[^lely-config]: Lely CANopen - Build configuration,说明 --disable-sync、--disable-time、--disable-emcy、LELY_NO_MALLOC 以及静态 EMCY 容量宏。
[^lely-v234]: Lely CANopen - Release v2.3.4,记录 co_time_of_day_set()、EMCY 任意错误移除、组合 error register 和 SYNC stop 行为修复。
[^lely-doxygen]: Lely core libraries 2.4.0 Doxygen,用于核对公开 API、C/C++ 包装和版本标识。
[^lely-standards]: Lely CANopen - Standards support,说明 CiA 301 中 SYNC、TIME、EMCY 及相关对象的支持范围。
[^cia301]: 《CiA 301 CANopen 应用层和通信协议 V4.2.0(中文注释版)》,重点参考 7.2.5 SYNC、7.2.6 TIME、7.2.7 EMCY,以及 7.5.2 中 0x1003/0x1005/0x1006/0x1007/0x1012/0x1013/0x1014/0x1015/0x1019/0x1028 对象定义。










