本页深入解析电机驱动库中 CAN 2.0 与 CAN-FD 的协议抽象层设计。该层的核心使命是让电机驱动实现与底层传输机制解耦——无论后端走的是 Linux SocketCAN、EtherCAT-to-CAN 桥接,还是共享内存,电机驱动代码都通过同一套接口收发帧。对于需要在同一机器人系统中混用达妙 DM、EVO、LeadRobot 等多品牌电机的开发者而言,理解这一抽象层是掌握整个通信架构的关键。
Sources: can_iso.hpp, canfd_iso.hpp
分层架构总览
整个通信栈可划分为三层:物理后端层、协议抽象层、电机驱动层。物理后端层负责对接真实的 Linux SocketCAN 网卡;协议抽象层暴露统一的 transmit() 与回调注册接口;电机驱动层(如 DmMotorDriver)只持有抽象接口的 std::shared_ptr,完全无需感知套接字文件描述符或线程调度细节。这种分层使得新增一种物理传输方式时,只需新增一个继承自 MotorsCAN 或 MotorsCANFD 的后端类,而所有上层电机驱动代码零改动。
classDiagram
direction TB
class MotorsCAN {
+transmit(can_frame&) void
+add_can_callback(CanCbkFunc, CanCbkId) void
+remove_can_callback(CanCbkId) void
+clear_can_callbacks() void
+set_can_key_extractor(CanCbkKeyExtractor) void
+get(string, string) shared_ptr~MotorsCAN~$
}
class MotorsCANFD {
+transmit(canfd_frame&) void
+add_canfd_callback(CanFdCbkFunc, CanFdCbkId) void
+remove_canfd_callback(CanFdCbkId) void
+clear_canfd_callbacks() void
+set_canfd_key_extractor(CanFdCbkKeyExtractor) void
+get(string, string) shared_ptr~MotorsCANFD~$
}
class MotorsSocketCAN {
-sockfd_ int
-tx_queue_ LFQueue
-receiver_thread_ thread
-sender_thread_ thread
+get(string) shared_ptr~MotorsSocketCAN~$
}
class MotorsSocketCANFD {
-sockfd_ int
-tx_queue_ LFQueueFD
-receiver_thread_ thread
-sender_thread_ thread
+get(string) shared_ptr~MotorsSocketCANFD~$
}
class DmMotorDriver {
-can_ shared_ptr~MotorsCAN~
-canfd_ shared_ptr~MotorsCANFD~
-can_rx_cbk(can_frame) void
-canfd_rx_cbk(canfd_frame) void
}
MotorsCAN <|-- MotorsSocketCAN
MotorsCANFD <|-- MotorsSocketCANFD
MotorsCAN <-- DmMotorDriver : can_
MotorsCANFD <-- DmMotorDriver : canfd_
物理后端当前已实现 SocketCAN 直连;接口注释中预留了 EtherCAT 桥接扩展位,未来可通过工厂方法的 backend 参数无缝切换。MotorDriver 基类中通过 CommType 枚举区分 CAN、CANFD 与 EtherCAT 三种通信形态,驱动实例在构造时根据传入的 interface_type 字符串选择对应的抽象接口。
Sources: can_iso.hpp, canfd_iso.hpp, motor_driver.hpp
双轨并行抽象:为何 CAN 与 CANFD 各自独立
项目中并未使用单一的“通用帧”来统摄 CAN 与 CANFD,而是采用了双轨并行的设计策略:分别定义 MotorsCAN 与 MotorsCANFD 两个完全独立的抽象基类。这种设计并非冗余,而是出于对 Linux 内核类型系统的尊重。内核头文件 <linux/can.h> 中的 can_frame 与 canfd_frame 在内存布局、字段命名与最大传输单元(MTU)上均不相同;强行把它们塞进同一个 C++ 接口会导致类型不安全,且每次收发都需要额外的转换与拷贝开销。因此,两个基类在 API 语义上保持镜像对称——都有 transmit()、add_*_callback()、remove_*_callback()、clear_*_callbacks()、set_*_key_extractor() 以及静态工厂 get()——但操作的帧类型截然不同。
| 维度 | MotorsCAN (CAN 2.0) | MotorsCANFD (CAN-FD) |
|---|---|---|
| 帧类型 | struct can_frame |
struct canfd_frame |
| 标准 MTU | CAN_MTU (16 字节) |
CANFD_MTU (72 字节) |
| 最大数据段 | 8 字节 | 64 字节 |
| 回调类型 | CanCbkFunc |
CanFdCbkFunc |
| 回调键类型 | CanCbkId (uint16_t) |
CanFdCbkId (uint16_t) |
| 键提取器 | CanCbkKeyExtractor |
CanFdCbkKeyExtractor |
| 默认后端 | MotorsSocketCAN |
MotorsSocketCANFD |
| SocketCAN 特性 | 标准原始套接字 | 需开启 CAN_RAW_FD_FRAMES |
镜像对称的接口设计带来了显著的可维护性优势:熟悉 CAN 抽象的开发者可以零学习成本切换到 CANFD,反之亦然。同时也为多协议电机驱动(如 DM 系列同时支持 CAN 与 CANFD)的代码复用奠定了基础。
Sources: can_iso.hpp, canfd_iso.hpp, socket_can.cpp, socket_canfd.cpp
核心接口契约与工厂方法
MotorsCAN 与 MotorsCANFD 的接口契约围绕发送、订阅、路由三个核心能力展开。transmit() 是纯虚方法,负责将帧投递到后端的发送队列;add_*_callback() 与 remove_*_callback() 实现基于 CAN ID 的发布-订阅式接收;set_*_key_extractor() 则允许调用方自定义“从帧到回调键”的映射规则。默认提取器直接取 frame.can_id,但在复杂拓扑(例如需要按接口+ID 联合寻址)中可以注入自定义 lambda。
工厂方法 MotorsCAN::get(interface, backend) 与 MotorsCANFD::get(interface, backend) 是获取后端实例的唯一入口。当前实现中,backend 参数仅支持 "socketcan",若传入未识别字符串会抛出 std::runtime_error。工厂内部委托给 MotorsSocketCAN::get(interface) 或 MotorsSocketCANFD::get(interface),后者维护了一个以接口名为键的静态 unordered_map,确保同一网卡只会被打开一次,所有共享该网卡的电机驱动实例持有同一个后端对象的 shared_ptr。这种单例-per-接口模式至关重要:若每个电机都独立创建套接字,会导致内核中的 CAN 帧被任意一个套接字随机消费,破坏多电机通信的正确性。
Sources: can_iso.hpp, canfd_iso.hpp, socket_can.cpp, socket_canfd.cpp
回调路由与 Key Extractor 模式
回调路由机制是抽象层的“神经系统”。当 SocketCAN 接收线程从内核读取到一帧数据后,需要迅速判断该帧应该递送给哪个电机驱动实例。系统采用键提取器 + 无序映射表的两级路由结构:首先调用 key_extractor_ 从帧中计算出一个 uint16_t 类型的键,然后在 unordered_map 中查找该键对应的 std::function 回调并执行。默认提取器直接返回 frame.can_id,这意味着每个电机驱动实例通常以其主站回读 ID(master_id)作为注册键。
sequenceDiagram
participant Kernel as Linux CAN 子系统
participant RX as 接收线程<br/>(receiver_thread_)
participant KE as Key Extractor
participant Map as callback_list_<br/>(unordered_map)
participant Driver as DmMotorDriver
Kernel->>RX: can_frame / canfd_frame
RX->>KE: frame
KE-->>RX: CanCbkId / CanFdCbkId
RX->>Map: find(id)
Map-->>RX: CanCbkFunc
RX->>Driver: callback_to_run(frame)
Key Extractor 的可定制性为复杂总线拓扑提供了扩展空间。例如,如果未来需要在同一进程中管理多条 CAN 总线,而不同总线上存在相同的 CAN ID,就可以自定义提取器,将接口名哈希与 can_id 组合成全局唯一键。回调表的读写受 std::mutex 保护,虽然接收线程在持锁期间执行查找,但映射表通常很小(等于单条总线上的电机数量),因此锁竞争可以忽略不计。需要注意的是,回调函数本身是在接收线程上下文中同步执行的,因此驱动实现者应当保证回调体足够轻量,避免阻塞实时接收循环。
Sources: socket_can.hpp, socket_can.cpp, socket_canfd.hpp, socket_canfd.cpp
SocketCAN 后端实现要点
MotorsSocketCAN 与 MotorsSocketCANFD 是抽象接口的当前唯一生产级实现。它们在内部封装了完整的 Linux SocketCAN 生命周期管理,包括套接字创建、网卡索引查询、bind()、非阻塞标志设置,以及两条长期工作线程的启动与销毁。CANFD 实现与 CAN 实现的核心代码高度同源,差异仅在于:CANFD 需要额外通过 setsockopt(sockfd, SOL_CAN_RAW, CAN_RAW_FD_FRAMES, ...) 启用 FD 帧支持,且读写时使用 CANFD_MTU 而非 CAN_MTU。
发送路径采用 Boost.Lockfree 无界队列 串联用户线程与内核。用户调用 transmit() 时,帧被 bounded_push 到 boost::lockfree::queue(容量 4096),随后通过条件变量唤醒发送线程。发送线程在循环中弹出帧并执行 write(),若遇到短暂错误(如套接字发送缓冲区满)会进行最多 3 次重试,每次间隔 1 毫秒。无锁队列的引入使得多电机并发发送时不需要在 transmit() 调用路径上争夺互斥锁,显著降低了用户控制循环的抖动。
接收路径则基于 select() + 非阻塞 read() 的组合。接收线程每次超时等待 1 毫秒(TIMEOUT_USEC = 1000),当套接字可读时,通过循环 read() 将当前内核缓冲区中所有帧一次性排空,避免在下次 select() 唤醒前遗留数据。这种“批量排空”策略在高频总线场景下能有效减少系统调用次数。
flowchart LR
subgraph 用户线程
A[调用 transmit]
B[bounded_push]
end
subgraph TX线程
C[条件变量等待]
D[pop 队列]
E[write 套接字]
end
subgraph 内核
F[SocketCAN 缓冲区]
end
A --> B --> C --> D --> E --> F
Sources: socket_can.hpp, socket_can.cpp, socket_canfd.hpp, socket_canfd.cpp
实时调度与 CPU 亲和性
为了降低控制延迟,SocketCAN 后端在创建收发线程时施加了两项实时优化。首先,两个线程均通过 pthread_setschedparam 设置为 SCHED_FIFO 调度策略,优先级 80,使其在普通用户线程之前获得 CPU 时间。其次,线程通过 pthread_setaffinity_np 绑定到特定 CPU 核心:默认绑定到最后一个核心(total_cores - 1),如果接口名以数字结尾(如 can0、can1),则按该数字递减核心编号,实现多条总线分散到不同物理核心上运行,减少缓存抖动与上下文切换开销。
| 常量 | CAN 值 | CANFD 值 | 说明 |
|---|---|---|---|
| 初始 fd | INIT_FD = -1 |
FD_INIT_FD = -1 |
套接字未打开标识 |
| 发送队列大小 | TX_QUEUE_SIZE = 4096 |
FD_TX_QUEUE_SIZE = 4096 |
Boost.Lockfree 队列容量 |
| select 超时秒 | TIMEOUT_SEC = 0 |
FD_TIMEOUT_SEC = 0 |
|
| select 超时微秒 | TIMEOUT_USEC = 1000 |
FD_TIMEOUT_USEC = 1000 |
1 毫秒轮询周期 |
| 最大重试次数 | MAX_RETRY_COUNT = 3 |
FD_MAX_RETRY_COUNT = 3 |
write() 失败重试 |
| 发送缓冲 | 1 MB (SO_SNDBUF) | 1 MB (SO_SNDBUF) | 内核发送缓冲区 |
这些优化对 1kHz 以上的高频控制循环尤为重要;如果接收线程被普通线程抢占,可能导致 CAN 帧在内核缓冲区中堆积,进而增加反馈延迟。
Sources: socket_can.cpp, socket_canfd.cpp, socket_can.hpp, socket_canfd.hpp
驱动层集成范式
电机驱动层与协议抽象层的交互遵循一种高度一致的范式:构造时获取后端实例并注册回调,析构时注销回调,控制指令时构造帧并调用 transmit()。以 DmMotorDriver 为例,其构造函数根据 interface_type 决定走 CAN 还是 CANFD 分支,随后调用 MotorsCAN::get() 或 MotorsCANFD::get() 获取共享后端,并将本实例的接收回调以 master_id_ 为键注册到后端。析构时则调用对应的 remove_*_callback(),确保电机对象销毁后不会继续收到指向已释放对象的回调。
在发送侧,DmMotorDriver::lock_motor() 展示了典型的帧封装逻辑:根据当前 comm_type_ 分别填充 can_frame 或 canfd_frame 的数据段与标识符,然后调用 can_->transmit() 或 canfd_->transmit()。由于后端的无锁队列设计,多个 DmMotorDriver 实例可以在不同控制线程中并发调用 transmit(),而不会产生用户态锁竞争。这种“驱动只管协议语义,后端只管传输”的分工边界,是整套 SDK 能够用同一套代码支撑多品牌电机的重要原因。
Sources: dm_motor_driver.cpp, dm_motor_driver.cpp, dm_motor_driver.hpp
继续深入
理解 CAN/CANFD 协议抽象接口后,建议按以下顺序继续阅读:
- 若希望了解 SocketCAN 后端的无锁发送队列细节与单例管理策略,请阅读 SocketCAN单例与无锁发送队列。
- 若需要掌握 CAN-FD 扩展帧在电机控制中的具体应用场景(如 64 字节大数据包),请阅读 CAN-FD扩展帧支持。
- 若要从电机驱动视角观察抽象接口的实际使用,可继续阅读 达妙DM电机驱动详解、EVO电机驱动详解 或 LeadRobot电机驱动详解。