SocketCAN 架构解析:Linux 内核中的 CAN 子系统

好,咱们今天来聊聊 SocketCAN 的内核架构。说实话,我刚接触这块的时候,也被它那套“既是网络设备又是字符设备”的玩法搞晕过。但搞懂了之后你会发现,这套设计其实非常巧妙。

SocketCAN 不是简单的驱动堆叠。它是 Linux 内核里一个完整的子系统。你想想看,CAN 总线上的数据流,本质上和网络数据包很像——都有帧、都有ID、都需要路由。所以内核社区干脆把它塞进了网络协议栈。这个决策,我个人觉得非常高明。

双面娇娃:字符设备与网络设备模型

SocketCAN 最让我印象深刻的一点,就是它同时支持两套设备模型。为什么要这么搞?

  • 网络设备模型:每个 CAN 接口对应一个 net_device,比如 can0、can1。你可以用 ifconfig 去配置它,用 tcpdump 去抓包。这套模型负责 CAN 帧的收发、过滤、协议处理。
  • 字符设备模型:通过 /dev/can0 这样的节点,你可以用 read/write 直接操作。这套模型主要用于 CAN 总线诊断、固件升级这类需要原始访问的场景。

我在一个车载项目中遇到过一个问题:客户想同时用 SocketCAN 的 RAW socket 收报文,又想让诊断工具通过字符设备访问同一个接口。当时我查了半天,发现内核里这两套模型其实是共享同一个接收队列的。嗯,这里要注意——它们不是独立的,底层数据流是互通的。

核心要点:网络设备模型负责“协议栈”,字符设备模型负责“裸数据”。两者通过内核内部的 sk_buff 队列桥接。

关键数据结构:从 sk_buff 到 can_frame

搞 SocketCAN 内核编程,有几个数据结构你必须刻在脑子里。我列个表,方便你对照着看。

数据结构 所在文件 作用
struct sk_buff include/linux/skbuff.h 网络子系统通用数据包结构,SocketCAN 用它承载 CAN 帧
struct can_frame include/uapi/linux/can.h CAN 2.0 标准帧结构,包含 ID、DLC、数据
struct canfd_frame include/uapi/linux/can.h CAN FD 帧结构,支持最多 64 字节数据
struct net_device include/linux/netdevice.h 网络设备核心结构,CAN 接口的抽象
struct can_priv include/linux/can/dev.h CAN 设备私有数据,包含波特率、状态、中断等

你可能会问:为什么 CAN 帧要套一层 sk_buff?直接传 can_frame 不行吗?

原因很简单——复用。sk_buff 提供了现成的队列管理、内存分配、协议头操作函数。SocketCAN 只需要在 sk_buff 的 data 区域塞一个 can_frame,就能享受整个网络子系统的红利。我刚开始写 CAN 驱动时,也想过自己搞一套队列,后来发现 sk_buff 的 API 实在太香了。

内核 CAN 子系统的分层架构

咱们用一张图来理清整个架构。这张图我画了很多遍,每次给团队新人讲都拿它当提纲。

SocketCAN 内核架构分层图 用户空间应用程序 candump | cansend | 自定义 SocketCAN 应用 | 诊断工具 系统调用接口 (socket / bind / sendmsg / recvmsg / read / write) PF_CAN 协议族 (AF_CAN) RAW socket | BCM socket | ISOTP socket | J1939 socket 网络设备层 (net_device / can_priv) 设备注册 | 帧收发 | 错误统计 | 状态管理 CAN 核心层 (can-core / can-dev) 过滤规则管理 | 帧路由 | 中断处理 | 定时同步 CAN 控制器驱动层 MCP251x | SJA1000 | FlexCAN | CCAN | 其他 SoC 内置控制器 数据流方向:用户空间 → 系统调用 → 协议族 → 网络设备 → CAN核心 → 驱动 → 硬件

这张图里,我最想强调的就是 CAN 核心层。它是个“隐形英雄”——大部分开发者只跟协议族层打交道,但真正做过滤、路由、错误处理的,是这一层。我调过的一个 bug,就是核心层里过滤规则哈希表冲突导致的丢帧。当时排查了整整两天。

字符设备模型的实现细节

除了网络设备模型,SocketCAN 还通过 can-dev 模块提供了字符设备接口。这个接口的实现思路其实很直接:

  1. 注册一个 miscdevice,主设备号 10,次设备号动态分配。
  2. open 时关联到对应的 net_device。
  3. read/write 时直接操作 sk_buff 队列。
  4. ioctl 用于设置波特率、配置过滤器、查询状态。

我曾经在一个工控项目里,用字符设备接口写了个 CAN 总线监控脚本。当时图省事,直接用 cat /dev/can0 看原始数据。结果发现数据全是乱码——因为字符设备输出的是二进制 can_frame,不是 ASCII。嗯,这个坑我替你们踩过了。

小技巧:如果你只想快速验证 CAN 收发,可以用 cat /dev/can0 | hexdump -C。但正式项目还是老老实实用 SocketCAN 的 RAW socket 吧。

关键数据结构深度解析

咱们再深入看看几个核心结构体。我挑三个最常用的,给你拆开揉碎讲。

struct can_frame

struct can_frame {
    canid_t can_id;    /* 32 bit CAN_ID + EFF/RTR/ERR flags */
    __u8    can_dlc;   /* 数据长度 (0..8) */
    __u8    __pad;     /* 填充字节 */
    __u8    __res0;    /* 保留 */
    __u8    __res1;    /* 保留 */
    __u8    data[8] __attribute__((aligned(8)));
};

注意 can_id 这个字段。它不只是 ID,高 3 位还藏着 EFF、RTR、ERR 标志位。我见过有人直接拿 can_id 做哈希,结果把标志位也带进去了,导致过滤失效。正确的做法是先用 CAN_EFF_MASK 或 CAN_SFF_MASK 做掩码。

struct can_priv

struct can_priv {
    struct can_device_stats can_stats;
    struct can_bittiming bittiming;
    struct can_bittiming_const *bittiming_const;
    struct can_clock clock;
    enum can_state state;
    u32 ctrlmode;
    int restart_ms;
    struct delayed_work restart_work;
    int (*do_set_bittiming)(struct net_device *dev);
    int (*do_set_mode)(struct net_device *dev, enum can_mode mode);
    int (*do_get_state)(struct net_device *dev, enum can_state *state);
};

这个结构体是每个 CAN 设备驱动的“控制面板”。我最常用的是 bittiming 和 state。调试总线问题时,先看 state 是不是 CAN_STATE_ERROR_ACTIVE,如果不是,说明总线已经出问题了。

struct sk_buff 中的 CAN 相关字段

sk_buff 本身很庞大,但 SocketCAN 主要用了这几个:

  • skb->protocol:设置为 htons(ETH_P_CAN) 或 ETH_P_CANFD
  • skb->pkt_type:标记帧类型(PACKET_HOST / PACKET_BROADCAST)
  • skb->dev:指向对应的 net_device
  • skb->data:指向 can_frame 或 canfd_frame

注意:千万不要直接修改 skb->data 指针,除非你清楚自己在做什么。我曾经在驱动里为了省一次 memcpy,直接移动了 data 指针,结果导致上层协议解析全乱套。内核里有些坑,看着小,踩进去就是大半天。

设备注册与初始化流程

最后,咱们捋一遍 CAN 设备从注册到工作的完整流程。这个流程我建议你背下来,写驱动时每一步都可能出问题。

  1. 分配 net_device:调用 alloc_candev(),传入 priv 结构体大小。
  2. 设置硬件参数:填充 can_priv 中的 bittiming、clock、ops 函数指针。
  3. 注册网络设备:register_candev(),内部会调用 register_netdev()。
  4. 启动设备:用户执行 ip link set can0 up type can bitrate 500000。
  5. 内核回调:驱动层的 open() 函数被调用,初始化硬件、申请中断。
  6. 开始收发:中断处理函数调用 netif_rx() 或 napi_gro_receive() 将帧送入协议栈。

我记得第一次写 CAN 驱动时,卡在了第 4 步。用户一执行 up 命令,内核就 panic。查了半天,发现是 bittiming 结构体里的 brp 字段填错了,导致时钟分频除零。从那以后,我每次写驱动都会先打印一遍 bittiming 参数,确认无误再注册。

好了,SocketCAN 的内核架构就讲到这里。你把这些数据结构、分层模型、注册流程搞清楚了,后面讲过滤机制和性能优化时,你就能知道每个优化点到底在内核的哪一层起作用。这是基本功,但也是最值钱的部分。