Lely CANopen:can_net、io_can_net 与 CAN channel 的分层关系,以及 TX/RX Ring 的设计原理
@[toc]
一句话结论
Lely 中容易混淆的几个对象,实际处于不同层次:
can_net_t是纯内存中的 CAN 逻辑调度核心,负责协议定时器、CAN-ID 接收分发和发送回调;它不知道 SocketCAN、文件描述符、poll、executor 或真实 CAN 控制器。io_can_chan_t是抽象 CAN 通道接口;src/io2/linux/can_chan.c是它在 Linux SocketCAN 上的具体后端,负责文件描述符、收发操作、I/O 就绪事件和写确认。io_can_net_t是桥接层,把can_net_t、io_timer_t、io_tqueue_t和io_can_chan_t连接起来,使同步的协议核心能够运行在异步 I/O 框架上。lely::io::CanNet只是io_can_net_t*的 C++ RAII 包装,不是另一套独立实现;核心机制仍然由 C 文件src/io2/can_net.c和src/can/net.c完成。
tx_ring 和 rxring 放在不同层,并不是不对称设计,而是由数据来源和所有权决定的:
1 | 协议对象产生发送帧 |
最关键的区别是:
io_can_net.tx_ring缓存的是“等待发送的 CAN 帧副本”;Linuxcan_chan内的write_queue缓存的是“等待执行的异步写操作”。两者不是同一种队列,也不能互相替代。
1. 先澄清:这些名称不是同一个 net
1.1 名称与实际对象对照
| 名称 | 实际类型 | 所在层 | 核心职责 |
|---|---|---|---|
can_net_t |
struct __can_net |
can 逻辑核心层 |
定时器调度、CAN-ID 接收分发、发送回调 |
io_can_net_t |
struct io_can_net |
io2 桥接层 |
把逻辑核心接到 Timer、executor 和 CAN channel |
lely::io::CanNet |
C++ 类 | C++ 包装层 | RAII 生命周期、虚函数错误回调、类型转换 |
io_can_chan_t |
抽象接口,实际为 vtable 指针 | io2 抽象设备层 |
定义同步/异步 CAN read、write 操作 |
io_can_chan_impl |
Linux 后端私有结构体 | Linux SocketCAN 后端 | fd、poll、rxring、操作队列、写确认 |
net |
参数名或成员名 | 取决于上下文 | 仅是变量名称,必须看其声明类型 |
例如,src/io2/can_net.c 中有两层 net:
1 | 函数参数 net |
因此看到下面的表达式时:
1 | can_net_recv(net->net, &net->read_msg) |
应当读成:
1 | 把 io_can_net 对象中保存的 read_msg |
而不是“net 调用另一个同名 net”。
1.2 CiA 301 与 Lely 实现边界
CiA 301 定义 CANopen 应用层、通信对象、对象字典和网络管理协议,并规定这些协议通过 CAN 数据链路层交换数据。
但以下名称不是 CiA 301 的标准对象名称:
can_net_t;io_can_net_t;io_can_chan_t;io_tqueue_t;spscring。
它们是 Lely 为了实现协议栈、异步 I/O 和平台适配而设计的软件抽象。
2. 相关文件分别负责什么
用户列出的文件可以分成四组。
2.1 CAN 逻辑核心
1 | include/lely/can/net.h |
这组文件定义并实现:
can_net_t;can_timer_t;can_recv_t;can_net_set_time();can_net_recv();can_net_send();can_net_set_next_func();can_net_set_send_func()。
它只处理:
1 | 时间 |
它不直接执行:
1 | read(fd) |
2.2 抽象 CAN 通道
1 | include/lely/io2/can.h |
该文件定义 io_can_chan_t 的统一接口,包括:
- 同步
read()/write(); - 异步
submit_read()/submit_write(); - 取消和中止操作;
io_can_chan_read与io_can_chan_write操作对象;- 每个操作完成后执行的
ev_task。
其本质是一个 vtable 接口:
1 | io_can_chan_t |
2.3 CAN 网络桥接层
1 | include/lely/io2/can_net.h |
该层把下面四部分连接起来:
1 | can_net_t 协议逻辑核心 |
其公开接口明确说明:发送帧会先进入用户态发送队列,再提交给底层 CAN channel。
2.4 C++ 包装层
1 | include/lely/io2/can_net.hpp |
lely::io::CanNet 的构造函数调用:
1 | io_can_net_create(...) |
析构函数调用:
1 | io_can_net_destroy(...) |
它还把 C 回调转换为可重写的 C++ 虚函数:
on_read_error();on_queue_error();on_write_error();on_can_state();on_can_error()。
所以答案很明确:
有完整的 C 实现。
can_net.hpp只是 C API 的封装层,不负责底层运行机制。
3. 总体对象关系
3.1 静态对象图
1 | flowchart TD |
3.2 io_can_net_t 是整个连接点
struct io_can_net 内部同时保存:
1 | io_timer_t *timer |
并额外保存:
1 | read operation |
因此 io_can_net_t 不是 CANopen 协议状态机本身,也不是 Linux CAN 驱动,而是:
将“同步回调式逻辑核心”适配到“异步 Timer + CAN channel + executor”的运行时对象。
4. can_net_t 到底是什么
4.1 内部只有三类核心资源
struct __can_net 可以概括为:
1 | 1. timer_heap |
再加上:
1 | time |
用于维护逻辑时钟和最早定时器。
4.2 它不是设备驱动
can_net_t 不知道一帧 CAN 消息来自:
- Linux SocketCAN;
- Windows;
- RTOS CAN 驱动;
- 裸机中断;
- 测试桩;
- 仿真总线。
外部只要在收到消息时调用:
1 | can_net_recv(core, msg) |
并为发送注册:
1 | can_net_set_send_func(core, send_func, data) |
逻辑核心就可以工作。
4.3 接收方向:recv_tree
CANopen 协议对象会创建 can_recv_t,并按 CAN-ID 和 flags 注册到 can_net_t。
收到帧后:
1 | can_net_recv() |
因此 can_net_t 在接收方向是一个:
CAN-ID 到协议回调的同步分发器。
4.4 发送方向:只调用回调
can_net_send() 不直接发送到硬件。
它只做:
1 | 检查 send_func 是否存在 |
这意味着 can_net_t 对发送端的唯一要求是:
1 | 外部提供一个“接收发送请求”的函数 |
在 io_can_net_t 中,这个函数就是:
1 | io_can_net_send_func() |
4.5 定时方向:timer_heap
can_timer_t 通过 pnode 插入 can_net.timer_heap。
can_net_set_time() 每次推进逻辑时间时:
1 | 查看 timer_heap 的 root |
处理完成后,can_net_set_next() 取新的最早 timer,并通过 next_func 通知 io_can_net_t。
这正是前一篇 io_tqueue + pheap 学习笔记中“两级最早截止时间压缩”的第一层。
5. io_can_chan_t 与 Linux can_chan.c
5.1 io_can_chan_t 是接口,不是完整对象布局
io_can_chan_t 通过 vtable 调用后端实现。
调用:
1 | io_can_chan_submit_read(chan, read) |
最终会跳转到当前后端的:
1 | io_can_chan_impl_submit_read() |
调用:
1 | io_can_chan_submit_write(chan, write) |
最终会跳转到:
1 | io_can_chan_impl_submit_write() |
Linux、POSIX、Windows 或其他后端可以使用不同实现,但上层 io_can_net_t 不需要知道差异。
5.2 Linux 后端维护的资源
src/io2/linux/can_chan.c 中的 io_can_chan_impl 主要包含:
1 | SocketCAN fd |
其中:
rxbuf_task:从 SocketCAN 读取数据并填充接收环形缓冲区;read_task:用缓冲区中的帧完成上层 read operation;write_task:执行等待中的 write operation;confirm_queue:在启用MSG_CONFIRM时等待发送确认。
6. io_can_net_t 初始化时做了什么
io_can_net_init() 的核心步骤可压缩为:
1 | flowchart TD |
两个关键注册动作是:
1 | can_net_set_next_func(core, io_can_net_next_func, io_net) |
这两个回调分别完成:
1 | 协议 timer_heap → I/O Timer |
6.1 io_can_net_start()
启动后主要做两件事:
1 | 1. 等待 tx_ring 中出现待发送帧 |
read operation 完成后会再次提交,因此形成持续接收循环。
7. 完整接收流程
7.1 从 SocketCAN 到 CANopen 协议对象
1 | sequenceDiagram |
7.2 Linux 后端为什么先放入 rxring
文件描述符“可读”与上层 read operation 的执行时机不是同一件事:
1 | SocketCAN 已经有数据 |
Linux 后端需要把:
1 | fd readiness |
连接起来。
因此 rxring 是 I/O 后端内部的数据缓冲区,而不是协议队列。
7.3 rxring 中保存的不是单纯 can_msg
Linux 后端的接收缓冲元素还包含:
1 | SocketCAN can_frame 或 canfd_frame |
这些信息与 Linux SocketCAN 的读取过程直接相关,所以放在 Linux channel 后端最合理。
7.4 写确认也必须在接收后端处理
启用 txwait 后,SocketCAN 的发送确认以接收路径中的 MSG_CONFIRM 返回。
后端读取帧后必须先判断:
1 | 普通接收帧 |
如果把接收环移到 io_can_net_t,底层后端仍然必须先解析 MSG_CONFIRM,反而会造成职责拆分和重复缓存。
8. 完整发送流程
8.1 从 CANopen 协议对象到 SocketCAN
1 | sequenceDiagram |
8.2 can_net_send() 成功不等于总线发送成功
对 io_can_net_t 而言,can_net_send() 返回成功通常表示:
1 | 该帧已经成功复制到 io_can_net.tx_ring |
它并不表示:
1 | SocketCAN write 已完成 |
真正的异步写错误通过 on_write_error 回调报告。
当 tx_ring 已满时:
1 | io_can_net_send_func() |
因此 tx_ring 同时定义了协议发送端的背压边界。
9. 为什么 tx_ring 在 io_can_net_t 中
9.1 原因一:它服务的是 can_net_send() 的语义
can_net_t 的发送接口是同步回调:
1 | 调用 send_func |
而底层 CAN channel 使用异步 operation:
1 | 提交 write |
两者之间必须有一个适配层:
1 | 同步产生帧 |
这个适配层就是 io_can_net_t,因此发送 ring 属于它。
9.2 原因二:协议层可能在一个回调中产生多帧
例如某个时间推进或接收回调可能连续触发:
- 多个 PDO;
- SDO 应答;
- 心跳;
- EMCY;
- NMT 相关消息;
- 多个同时到期的协议 timer。
这些调用都通过 can_net_send() 快速产生消息。
如果每次都要求底层写操作立即完成,协议状态机会被 I/O 延迟直接阻塞。
tx_ring 允许:
1 | 协议逻辑快速入队 |
9.3 原因三:发送队列长度是 io_can_net 的公开策略
io_can_net_create() 直接接收:
1 | txlen |
用于配置用户态发送队列长度。
这说明该队列是 CAN 网络桥接层对外暴露的行为策略,而不是 Linux 后端的私有实现细节。
不同后端都应共享同样的:
- 入队语义;
- 队列满错误;
- 丢帧计数;
on_queue_error回调;- 顺序发送行为。
所以它应当放在平台无关的 src/io2/can_net.c 中。
9.4 原因四:io_can_net_t 只复用一个 write operation
io_can_net_t 内部只维护:
1 | 一个 write_msg |
运行方式是:
1 | 从 tx_ring 取一帧 |
因此:
1 | tx_ring = 多帧数据队列 |
这使发送状态机非常清晰。
9.5 原因五:写确认超时属于桥接策略
io_can_net_t 还维护:
1 | wait_confirm |
每次提交底层 write 后,会通过 io_tqueue 注册确认超时。
收到完成或写错误后再终止该等待。
这说明 io_can_net_t 管理的是:
1 | 帧排队 |
这些是完整的“逻辑网络发送策略”,不应散落在某个特定平台的 CAN channel 后端中。
10. 为什么 rxring 在 Linux can_chan 底层
10.1 原因一:接收入口由底层设备决定
发送帧的生产者是上层协议,io_can_net_t 可以控制是否接受新帧。
接收帧的生产者则是:
1 | CAN 控制器 / SocketCAN 内核队列 |
当文件描述符可读时,底层必须尽快处理 I/O 就绪事件,避免:
- 内核接收队列堆积;
- 丢帧;
poll事件反复触发;- executor 调度延迟扩大。
因此最接近 fd 的 channel 后端需要拥有接收缓冲。
10.2 原因二:它连接的是 I/O readiness 与 read operation
Linux 后端有两类队列:
1 | rxring |
二者分别代表:
1 | 数据先到,操作后执行 |
read_task 的职责就是在二者之间配对。
10.3 原因三:接收数据包含平台相关信息
Linux 后端需要处理:
struct can_frame;struct canfd_frame;CAN_MTU/CANFD_MTU;MSG_CONFIRM;- SocketCAN 接收时间戳;
EAGAIN/EWOULDBLOCK;IO_EVENT_IN;- 非阻塞
read()。
这些都属于 channel 后端,不属于 io_can_net_t。
10.4 原因四:channel 可以脱离 io_can_net_t 独立使用
io_can_chan_t 是通用 CAN I/O 接口。
用户可以直接:
1 | submit_read() |
而不创建 io_can_net_t 或 CANopen 协议对象。
如果接收缓冲只放在 io_can_net_t 中,那么独立使用 channel 时就失去异步接收所需的缓冲和调度能力。
因此接收 ring 必须属于 channel 实现。
10.5 原因五:避免二级接收缓存
io_can_net_t 始终保持一个异步 read operation:
1 | read 完成 |
底层 channel 已经用 rxring 解决了 I/O 与 task 调度之间的解耦。
如果 io_can_net_t 再增加一个 RX ring,会形成:
1 | SocketCAN |
其结果通常只是:
- 多一次内存复制;
- 多一层容量配置;
- 多一套溢出策略;
- 增加延迟;
- 更难定位丢帧发生在哪一层。
所以 Lely 没有在 io_can_net_t 中重复实现 RX ring。
11. 关键点:write_queue 不是 tx_ring
这是理解当前设计时最容易混淆的地方。
11.1 各队列保存的内容不同
| 队列 | 所在对象 | 保存内容 | 作用 |
|---|---|---|---|
tx_ring + tx_buf |
io_can_net_t |
struct can_msg 的副本 |
缓存协议层待发送帧 |
write_queue |
Linux io_can_chan_impl |
io_can_chan_write.task 节点 |
缓存等待执行的异步写操作 |
confirm_queue |
Linux io_can_chan_impl |
已写出但等待确认的 write operation | 匹配 SocketCAN MSG_CONFIRM |
rxring + rxbuf |
Linux io_can_chan_impl |
收到的原始 CAN/CAN FD 帧与时间戳 | 缓存已到达的数据 |
read_queue |
Linux io_can_chan_impl |
io_can_chan_read.task 节点 |
缓存等待数据的异步读操作 |
11.2 数据队列与操作队列不能替代
tx_ring 解决:
1 | 有很多 CAN 帧要发 |
write_queue 解决:
1 | 有很多异步 write 请求等待 channel 执行 |
在 io_can_net_t 的用法中,虽然 tx_ring 可以有很多帧,但它通常只向 channel 提交一个在途 write operation。
也就是说:
1 | 100 个待发送帧 |
这正是两级队列职责不同的证明。
11.3 为什么不把所有帧都变成独立 write operation
若每个协议发送帧都动态创建一个 io_can_chan_write:
- 需要额外分配或管理 operation 对象;
- 每个对象都要保证
msg指针生命周期; - 取消和确认超时管理更复杂;
- 协议层突发发送会制造大量 task;
- 很难统一实现固定长度的丢帧策略。
Lely 选择:
1 | 固定长度帧 ring |
从而减少对象数量并明确所有权。
12. TX 与 RX 的背压模型本来就不同
12.1 发送方向可以拒绝上层
当 tx_ring 已满时,io_can_net_send_func() 可以:
1 | 返回 ERRNUM_AGAIN |
也就是说,发送方向能够把压力反馈给帧生产者。
12.2 接收方向不能要求总线暂停
当远端节点已经在 CAN 总线上发送帧后,本机不能通过软件调用告诉它:
1 | 请暂停,等我的 executor 空闲后再发 |
接收侧只能:
- 尽快从控制器或内核队列取走;
- 缓存;
- 或在容量不足时丢弃。
因此 RX 缓冲必须靠近 I/O 入口。
12.3 Linux 内核本身已有发送队列
SocketCAN 写入后,内核和 CAN 控制器还有自己的发送队列。
底层 channel 再增加一个通用“帧副本 TX ring”会造成多级不可见排队:
1 | io_can_net.tx_ring |
这样会使:
- 实际排队深度难以计算;
- 发送延迟难以预测;
- 错误归属模糊;
txlen的意义失真。
因此 channel 后端只管理写操作和系统 I/O,不重复承担网络层发送缓存策略。
13. spscring 在两处的生产者和消费者
spscring 是 single-producer / single-consumer ring。
13.1 io_can_net.tx_ring
1 | Producer:io_can_net_send_func() |
当 ring 为空时,consumer 注册等待:
1 | spscring_c_submit_wait() |
producer 提交新帧后,ring 调用:
1 | io_can_net_c_wait_func() |
进而启动发送。
13.2 Linux can_chan.rxring
1 | Producer:rxbuf_task |
当 ring 为空时,consumer 注册等待;producer 放入帧后唤醒 read_task。
13.3 相同数据结构,不同层次语义
| Ring | Producer | Consumer | 业务语义 |
|---|---|---|---|
tx_ring |
协议发送适配回调 | 异步 write 状态机 | 待发送逻辑 CAN 帧 |
rxring |
SocketCAN 读取任务 | 异步 read 状态机 | 已接收底层帧 |
所以不能仅因为两者都使用 spscring,就认为它们应位于同一结构体中。
14. 接收任务之间如何协作
Linux channel 有三个与接收相关的对象:
1 | watch |
14.1 watch
io_poll_watch 只负责通知:
1 | fd 当前可读或可写 |
它不执行完整协议处理。
收到 IO_EVENT_IN 后,watch callback 通常只投递 rxbuf_task。
14.2 rxbuf_task
主要职责:
1 | 从 fd 读取 |
14.3 read_task
主要职责:
1 | 检查 read_queue |
14.4 为什么拆成两个 task
如果把 fd 读取、ring 管理、read operation 完成和协议回调全部塞进 poll callback:
- poll callback 执行时间不可控;
- 容易在 I/O 监视上下文中执行大量协议逻辑;
- 难以处理阻塞与非阻塞模式;
- 难以统一 executor 的任务生命周期;
- 锁边界更复杂。
拆分后,poll callback 只负责唤醒,executor 负责任务执行。
15. 发送任务之间如何协作
io_can_net_t 与 Linux channel 各自维护一段发送状态机。
15.1 io_can_net_t 发送状态机
1 | tx_ring empty |
15.2 Linux channel 发送状态机
1 | submit_write |
15.3 两层分别解决的问题
1 | io_can_net_t |
这就是 TX ring 不应下沉到 Linux channel 的核心原因。
16. 与 io_tqueue + pheap 的完整连接
发送、接收和 Timer 三条链路最终在 io_can_net_t 汇合。
1 | flowchart TD |
因此整个系统可以分成三种适配:
1 | 协议时间 → 系统 Timer |
这三种适配都由 io_can_net_t 组织,但只有平台相关接收缓冲位于 channel 后端。
参考源码
CAN 逻辑核心
lely-core/include/lely/can/net.hlely-core/src/can/net.c
I/O 桥接层
lely-core/include/lely/io2/can_net.hlely-core/include/lely/io2/can_net.hpplely-core/src/io2/can_net.c
CAN channel 抽象与 Linux 后端
lely-core/include/lely/io2/can.hlely-core/src/io2/linux/can_chan.c
数据结构
lely-core/include/lely/util/spscring.hlely-core/include/lely/util/pheap.hlely-core/src/util/pheap.c
前置学习笔记
Lely_CANopen_io_tqueue_pheap_机制原理与运行流程(1).md
协议参考
- CiA 301 V4.2.0:CANopen 应用层和通信协议







