🤖 roboto_origin_03 Wiki
首页 / 电机子模块 / SocketCAN单例与无锁发送队列

本文档聚焦 MotorsSocketCANMotorsSocketCANFD 的实现机理,剖析其如何在 Linux SocketCAN 之上构建一个线程安全、具备实时特性的通信中间层。核心议题涵盖接口级单例生命周期管理Boost.Lockfree 无锁发送队列的混合同步设计,以及收发双线程的实时调度策略。理解这些机制是阅读后续各品牌电机驱动实现的前提,因为所有电机实例都通过同一抽象接口共享底层 CAN 通道。

Sources: socket_can.hpp, socket_can.cpp, can_iso.hpp

架构定位与设计动机

在机器人控制系统中,同一物理 CAN 总线通常挂载多台电机。如果每台电机对象都独立打开一个 SocketCAN 套接字,会导致套接字资源浪费、总线竞争以及接收端帧重复读取。因此,设计采用了按接口名称隔离的单例模式can0 对应唯一一个 MotorsSocketCAN 实例,所有绑定到 can0 的电机驱动对象共享该实例的套接字、发送队列与接收线程。与此同时,电机控制对延迟极为敏感,发送路径必须避免内核态锁竞争;boost::lockfree::queue 被引入作为多生产者单消费者(MPMC 退化为 MPSC)的无锁缓冲区,使上层驱动的 transmit() 调用在不涉及系统调用的情况下完成入队。

Sources: socket_can.hpp, socket_can.cpp

整体架构概览

下图展示了从抽象工厂到 SocketCAN 单例、再到收发双线程的完整调用拓扑。上层驱动(如 DM、EVO、LRO)不直接依赖 MotorsSocketCAN,而是通过 MotorsCAN 抽象基类获取实例,从而实现后端透明化。

graph TB
    subgraph 上层驱动层
        D1[DmMotorDriver]
        D2[EvoMotorDriver]
        D3[LroMotorDriver]
    end

    subgraph 抽象协议层
        A[MotorsCAN<br/>抽象基类]
        AF[MotorsCAN::get<br/>工厂方法]
        AFD[MotorsCANFD<br/>抽象基类]
        AFFD[MotorsCANFD::get<br/>工厂方法]
    end

    subgraph SocketCAN实现层
        S1[MotorsSocketCAN<br/>单例 instances_['can0']]
        S2[MotorsSocketCAN<br/>单例 instances_['can1']]
        F1[MotorsSocketCANFD<br/>单例 instances_['can0']]
        F2[MotorsSocketCANFD<br/>单例 instances_['can1']]
    end

    subgraph 内核与硬件
        K1[SocketCAN can0]
        K2[SocketCAN can1]
    end

    D1 -->|MotorsCAN::get('can0')| AF
    D2 -->|MotorsCAN::get('can0')| AF
    AF --> S1
    D3 -->|MotorsCANFD::get('can1')| AFFD
    AFFD --> F2
    S1 --> K1
    S2 --> K2
    F1 --> K1
    F2 --> K2

    style S1 fill:#e1f5e1
    style F2 fill:#e1f5e1

Sources: can_iso.hpp, socket_can.hpp

接口级单例:instances_ 映射表

传统单例模式在整个进程生命周期中仅维护一个全局对象,但本实现将其扩展为**"每个网络接口一个单例"**。MotorsSocketCAN 内部维护一个静态的 std::unordered_map<std::string, std::shared_ptr<MotorsSocketCAN>> instances_,键为接口名称(如 "can0")。当上层调用 MotorsSocketCAN::get(interface) 时,工厂方法检查该键是否已存在:若不存在则调用私有构造函数 createInstance 完成套接字打开、线程创建等初始化;若存在则直接返回已缓存的共享指针。这保证了同一接口上的所有电机驱动对象复用同一套接字文件描述符与收发线程,而不同接口之间互不影响。析构时 close() 方法通过 receiving_.store(false) 通知双线程退出,并调用 join() 等待线程结束,随后关闭套接字。

Sources: socket_can.hpp, socket_can.cpp, socket_can.cpp

无锁发送队列的混合同步设计

发送队列采用 boost::lockfree::queue<can_frame, boost::lockfree::fixed_sized<true>> 实现,固定容量为 4096 帧。需要注意的是,这里的"无锁"并非指完全放弃同步原语,而是指生产者端(transmit 调用)完全无锁;消费者端(发送线程)则结合 std::mutexstd::condition_variable 实现阻塞式等待,避免空转。

具体而言,transmit() 方法仅执行 tx_queue_.bounded_push(frame)tx_cv_.notify_one()bounded_push 是 Boost.Lockfree 提供的无锁原子操作,即使在多个电机驱动并发发送时也不会产生内核态的 futex 竞争。发送线程在 while 循环中通过 tx_cv_.wait() 睡眠,直到被唤醒后从队列 pop 帧并执行 ::write(sockfd_, ...) 系统调用。若写入失败(如套接字缓冲区暂时满),线程会以 1 毫秒间隔重试,最多 3 次后记录错误并丢弃该帧。send_sleep_us_ 成员允许调用者注入额外的帧间延迟,以兼容某些对总线负载敏感的电机固件。

Sources: socket_can.hpp, socket_can.cpp, socket_can.cpp

设计权衡对比

维度 本实现(无锁队列 + 条件变量) 纯互斥队列(std::queue + mutex)
生产者竞争 仅原子操作,无内核态锁 可能触发 futex 睡眠/唤醒
消费者行为 阻塞等待,零 CPU 空转 阻塞等待,零 CPU 空转
队列容量 编译期固定(4096),不可动态扩展 可动态扩展
内存分配 预分配,无运行时 malloc 取决于实现,可能动态分配
适用场景 高频并发入队、低延迟控制 低频或容忍微秒级锁竞争

Sources: socket_can.hpp, socket_can.cpp

收发双线程与实时性优化

每个 MotorsSocketCAN 实例在构造阶段启动两个 std::thread:接收线程(can_rx)与发送线程(can_tx)。二者均经过严格的实时性调校。

实时调度与优先级:线程创建后,通过 pthread_setschedparam 将自身设置为 SCHED_FIFO 调度策略,优先级固定为 80。这意味着在 Linux CFS 普通任务之外,收发线程能够以确定性延迟抢占 CPU 时间,降低因调度抖动导致的帧丢失或发送延迟。

CPU 亲和性绑定:线程根据接口名称的末位数字自动计算目标 CPU 核心。例如 can0 绑定到最后一个可用核心,can1 绑定到倒数第二个核心,依此类推。该策略将 SocketCAN 中断处理、用户态收发线程与上位机控制算法隔离到不同物理核心,减少缓存失效与上下文切换开销。若 hardware_concurrency() 无法获取核心数,则回退到 4 核假设。

Sources: socket_can.cpp, socket_can.cpp

接收线程的工作流程

接收线程以非阻塞模式运行。套接字通过 fcntl 设置 O_NONBLOCK 标志,随后进入 select 多路复用等待,超时设为 1 毫秒。当 select 返回可读事件后,线程通过循环 ::read() 批量消费内核环形缓冲区中的所有可用帧,直到遇到 EAGAINEWOULDBLOCK 为止。每读取一帧,线程在持有 can_callback_mutex_ 的情况下查询 can_callback_list_ 哈希表,根据 can_id 获取对应的 std::function 回调并在锁外执行。这种"查找加锁、执行解锁"的模式避免了回调内部逻辑阻塞接收流水线。

Sources: socket_can.cpp, socket_can.cpp

CAN-FD 的同构扩展

MotorsSocketCANFDMotorsSocketCAN 在架构上完全同构,差异仅体现在协议帧类型与套接字选项。CAN-FD 实现额外通过 setsockopt(sockfd_, SOL_CAN_RAW, CAN_RAW_FD_FRAMES, ...) 启用 FD 帧支持,缓冲区、超时、队列大小等常量均使用独立的 FD_* 前缀命名。上层驱动(如 EVO、LRO)通过 MotorsCANFD::get(interface) 获取实例,回调映射与发送队列机制与经典 CAN 完全一致。这种镜像设计使得维护者可以在两套实现之间应用相同的优化或修复,而无需重构架构。

Sources: socket_canfd.hpp, socket_canfd.cpp

CAN 与 CAN-FD 实现对比

特性 MotorsSocketCAN MotorsSocketCANFD
基类 MotorsCAN MotorsCANFD
帧类型 can_frame canfd_frame
队列类型 LFQueue (4096) LFQueueFD (4096)
套接字选项 标准 CAN_RAW 启用 CAN_RAW_FD_FRAMES
读取 MTU CAN_MTU CANFD_MTU
线程名 can_rx / can_tx canfd_rx / canfd_tx
单例映射 instances_ instances_

Sources: socket_can.hpp, socket_canfd.hpp

上层驱动的使用方式

电机驱动对象(如 DmMotorDriver)在构造函数中通过 MotorsCAN::get(can_interface) 获取共享实例,并将自身的接收回调注册到该实例的回调映射表中,键值为电机的主 ID(master_id_)。这意味着同一 CAN 总线上的所有电机共用一对收发线程,但通过不同的 ID 键值实现帧路由。当电机对象析构时,它会调用 remove_can_callback 注销自身回调,避免野指针或已销毁对象被调用。由于实例以 std::shared_ptr 管理,只有当最后一个引用该接口的电机驱动销毁后,单例实例才会真正析构并关闭套接字。

Sources: dm_motor_driver.cpp, dm_motor_driver.hpp

阅读延伸

本文档阐释了 SocketCAN 通信后端的核心实现。若需了解这些后端如何被不同品牌的电机驱动封装与调用,可继续阅读后续章节:

若关注 CAN-FD 在协议层面的差异与配置要点,请参考:

若希望从更高层面理解抽象接口与后端解耦的设计哲学,可回顾: