🤖 roboto_origin_03 Wiki
首页 / 电机子模块 / CAN/CANFD协议抽象接口

本页深入解析电机驱动库中 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,完全无需感知套接字文件描述符或线程调度细节。这种分层使得新增一种物理传输方式时,只需新增一个继承自 MotorsCANMotorsCANFD 的后端类,而所有上层电机驱动代码零改动。

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,而是采用了双轨并行的设计策略:分别定义 MotorsCANMotorsCANFD 两个完全独立的抽象基类。这种设计并非冗余,而是出于对 Linux 内核类型系统的尊重。内核头文件 <linux/can.h> 中的 can_framecanfd_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

核心接口契约与工厂方法

MotorsCANMotorsCANFD 的接口契约围绕发送订阅路由三个核心能力展开。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 后端实现要点

MotorsSocketCANMotorsSocketCANFD 是抽象接口的当前唯一生产级实现。它们在内部封装了完整的 Linux SocketCAN 生命周期管理,包括套接字创建、网卡索引查询、bind()、非阻塞标志设置,以及两条长期工作线程的启动与销毁。CANFD 实现与 CAN 实现的核心代码高度同源,差异仅在于:CANFD 需要额外通过 setsockopt(sockfd, SOL_CAN_RAW, CAN_RAW_FD_FRAMES, ...) 启用 FD 帧支持,且读写时使用 CANFD_MTU 而非 CAN_MTU

发送路径采用 Boost.Lockfree 无界队列 串联用户线程与内核。用户调用 transmit() 时,帧被 bounded_pushboost::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),如果接口名以数字结尾(如 can0can1),则按该数字递减核心编号,实现多条总线分散到不同物理核心上运行,减少缓存抖动与上下文切换开销。

常量 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_framecanfd_frame 的数据段与标识符,然后调用 can_->transmit()canfd_->transmit()。由于后端的无锁队列设计,多个 DmMotorDriver 实例可以在不同控制线程中并发调用 transmit(),而不会产生用户态锁竞争。这种“驱动只管协议语义,后端只管传输”的分工边界,是整套 SDK 能够用同一套代码支撑多品牌电机的重要原因。

Sources: dm_motor_driver.cpp, dm_motor_driver.cpp, dm_motor_driver.hpp

继续深入

理解 CAN/CANFD 协议抽象接口后,建议按以下顺序继续阅读: