3、SocketCAN核心API详解:socket()、bind()、connect()、sendmsg()、recvmsg()、close()

说实话,很多刚接触SocketCAN的朋友,上来就照着网上的demo抄,能跑就行。但真到了项目里——尤其是要处理高负载CAN总线或者做网关转发的时候——你会发现,这几个API的用法细节直接决定了你的程序是稳如老狗还是动不动就丢帧。

我个人习惯,在讲具体代码之前,先理清楚SocketCAN在整个Linux网络栈里的位置。你想想看,它本质上就是一个特殊的协议族(AF_CAN),走的还是socket那一套接口。所以,理解这六个核心API,就等于拿到了操作CAN总线的钥匙。

核心观点: SocketCAN把CAN总线抽象成了一个网络设备。你操作CAN报文,就像操作UDP数据报一样——只不过地址从IP+端口变成了CAN ID+接口索引。

3.1 socket() —— 创建CAN套接字

这是第一步。我见过有人在这里写错协议类型,导致bind的时候一直报错。其实很简单:

int s;
s = socket(PF_CAN, SOCK_RAW, CAN_RAW);

三个参数的含义:

  • PF_CAN:协议族,别写成AF_CAN,虽然值一样,但语义上PF用于创建,AF用于绑定。
  • SOCK_RAW:原始套接字,直接收发完整的CAN帧。如果你做网关或者诊断,基本都用这个。
  • CAN_RAW:协议类型。注意,这里不是IPPROTO_TCP那种,而是CAN协议特有的。
我的经验: 如果你需要同时收多个CAN接口的数据,可以创建多个socket,每个绑定不同的接口。别想着一个socket搞定所有——CAN_RAW不支持多接口绑定。

3.2 bind() —— 绑定CAN接口

socket创建完,它还是个空壳。你得告诉它:你要从哪个CAN接口收数据,或者往哪个接口发。bind就是干这个的。

struct sockaddr_can addr;
struct ifreq ifr;

strcpy(ifr.ifr_name, "can0");
ioctl(s, SIOCGIFINDEX, &ifr);

addr.can_family = AF_CAN;
addr.can_ifindex = ifr.ifr_ifindex;

bind(s, (struct sockaddr *)&addr, sizeof(addr));

这里有个坑,我踩过:必须先通过ioctl拿到接口索引。你不能直接写死ifindex,因为系统重启后接口索引可能会变。尤其是多CAN卡设备,顺序一乱,程序就绑错了。

注意: bind()之后,这个socket就只能收发绑定接口的报文了。如果你想监听所有CAN接口,需要创建多个socket分别bind。

3.3 connect() —— 过滤CAN ID

很多人不理解:CAN总线是广播式的,为什么还要connect?其实,connect在SocketCAN里不是建立连接,而是设置接收过滤规则。

struct sockaddr_can addr;
addr.can_family = AF_CAN;
addr.can_ifindex = ifr.ifr_ifindex;
// 关键:设置要接收的CAN ID
addr.can_addr.tp.tx_id = 0x123;
addr.can_addr.tp.rx_id = 0x123;

connect(s, (struct sockaddr *)&addr, sizeof(addr));

connect之后,这个socket就只收CAN ID为0x123的报文了。其他报文内核直接帮你过滤掉,应用层根本收不到。这在处理大量无关报文时特别有用。

但我得提醒你:connect的过滤粒度是单个ID。如果你需要过滤一组ID(比如0x100-0x1FF),那就得用setsockopt设置CAN_RAW_FILTER。这个我们下一章会细讲。

3.4 sendmsg() —— 发送CAN报文

发送报文,我推荐用sendmsg而不是write。为什么?因为sendmsg可以携带辅助数据(cmsg),比如设置发送时间戳、优先级等。write只能发裸数据。

struct msghdr msg = {0};
struct iovec iov;
struct can_frame frame;

frame.can_id = 0x123;
frame.can_dlc = 8;
memcpy(frame.data, "HelloCAN", 8);

iov.iov_base = &frame;
iov.iov_len = sizeof(frame);
msg.msg_iov = &iov;
msg.msg_iovlen = 1;

// 可以在这里添加cmsg,比如设置发送时间戳
// msg.msg_control = ...

sendmsg(s, &msg, 0);

这里有个细节:can_frame结构体的大小是16字节(4字节ID + 1字节DLC + 1字节填充 + 8字节数据 + 2字节填充)。别算错了,否则sendmsg会返回EINVAL。

避坑指南: 我曾经在一个项目中,因为can_frame结构体对齐问题,在ARM和x86之间交叉编译时出了bug。建议始终用sizeof(struct can_frame)来获取大小,别手算。

3.5 recvmsg() —— 接收CAN报文

接收和发送是对称的。同样,我推荐用recvmsg而不是read。原因一样:recvmsg可以获取辅助数据,比如接收时间戳、帧类型(标准帧/扩展帧/远程帧)等。

struct msghdr msg = {0};
struct iovec iov;
struct can_frame frame;
char ctrl[256]; // 辅助数据缓冲区

iov.iov_base = &frame;
iov.iov_len = sizeof(frame);
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = ctrl;
msg.msg_controllen = sizeof(ctrl);

recvmsg(s, &msg, 0);

// 检查接收到的帧类型
if (frame.can_id & CAN_EFF_FLAG) {
    printf("扩展帧,ID = 0x%x\n", frame.can_id & CAN_EFF_MASK);
} else {
    printf("标准帧,ID = 0x%x\n", frame.can_id & CAN_SFF_MASK);
}

// 从cmsg中提取时间戳
struct cmsghdr *cmsg;
for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
    if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SO_TIMESTAMPNS) {
        struct timespec *ts = (struct timespec *)CMSG_DATA(cmsg);
        printf("接收时间戳: %ld.%09ld\n", ts->tv_sec, ts->tv_nsec);
    }
}

嗯,这里要注意:recvmsg默认是阻塞的。如果你不想阻塞,要么用O_NONBLOCK,要么用select/poll/epoll。我个人习惯用epoll,因为CAN总线报文密集时,select的效率会下降。

3.6 close() —— 关闭套接字

这个最简单,但也最容易忽略。

close(s);

close会做几件事:

  • 清空socket的接收缓冲区(没读的报文就丢了)
  • 释放绑定的过滤规则
  • 如果socket有未发送的报文,也会丢弃
警告: 在多线程程序中,确保close之前没有其他线程正在使用这个socket。否则,你可能会收到莫名其妙的EBADF错误。我建议用引用计数或者RCU机制来管理socket生命周期。

3.7 核心API关系图

下面这张图,把六个API的调用流程和关系画清楚了。你可以把它当作SocketCAN编程的路线图:

SocketCAN 核心API调用流程 socket() 创建套接字 bind() 绑定CAN接口 connect() 设置过滤规则 sendmsg() 发送报文 recvmsg() 接收报文 close() 关闭套接字 注意:connect() 是可选的。如果不调用,则接收所有CAN ID的报文(需配合setsockopt过滤) sendmsg()/recvmsg() 可以多次循环调用,直到close()

3.8 六个API的对比总结

API 核心作用 必须调用? 常见错误
socket() 创建CAN套接字 是 协议类型写错(如写成SOCK_DGRAM)
bind() 绑定到具体CAN接口 是 未获取接口索引就绑定
connect() 设置单ID接收过滤 否 误以为建立TCP连接
sendmsg() 发送CAN帧(支持cmsg) 否(可用write替代) can_frame大小计算错误
recvmsg() 接收CAN帧(支持cmsg) 否(可用read替代) 未处理阻塞/非阻塞模式
close() 关闭套接字释放资源 是 多线程中未同步导致EBADF