CAN报文收发实战:阻塞与非阻塞I/O、多路复用与时间戳
报文收发,说白了就是SocketCAN编程里最核心的日常操作。很多初学者觉得不就是read/write嘛,有什么好讲的?嗯,我当年也这么想,直到在一个车载项目中,因为I/O模式选错,导致整个ECU的响应延迟飙到十几毫秒……从那以后,我对收发模式的选择就再也不敢马虎了。
这一节,我们就把CAN报文的收发玩透。从最基础的阻塞模式,到高性能的多路复用,再到时间戳和优先级仲裁,一条线串下来。
4.1 阻塞与非阻塞I/O模式
先看最直观的两种模式。阻塞模式,就是调用read的时候,如果没有数据,线程就挂在那等。非阻塞模式呢,不管有没有数据,立即返回。
核心区别一句话:阻塞模式省CPU但占线程,非阻塞模式灵活但需要轮询。
代码上怎么切换?很简单,打开socket之后,用fcntl设置O_NONBLOCK标志就行。
int s = socket(PF_CAN, SOCK_RAW, CAN_RAW);
// 设置为非阻塞模式
int flags = fcntl(s, F_GETFL, 0);
fcntl(s, F_SETFL, flags | O_NONBLOCK);
// 非阻塞读
struct can_frame frame;
int nbytes = read(s, &frame, sizeof(frame));
if (nbytes < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 没有数据,做别的事
}
}
我的习惯:在原型验证阶段,我通常先用阻塞模式,逻辑简单好调试。等性能调优阶段,再切到非阻塞+多路复用。别一上来就搞复杂了。
4.2 select/poll/epoll多路复用
非阻塞模式虽然灵活,但你总不能一直while循环轮询吧?那CPU就全耗在空转上了。这时候就需要多路复用——让内核帮你盯着多个socket,有数据了再通知你。
Linux下有三兄弟:select、poll、epoll。我按使用场景给你排个序:
| 机制 | 最大fd数 | 效率 | 适用场景 |
|---|---|---|---|
| select | 1024(FD_SETSIZE) | 低,O(n)遍历 | 少量socket,兼容老代码 |
| poll | 无上限 | 中等,O(n)遍历 | 中等数量连接 |
| epoll | 无上限 | 高,O(1)事件驱动 | 大量socket,高性能场景 |
对于CAN总线来说,通常一个应用只开1~2个CAN socket。所以select其实够用。但我个人更推荐epoll,原因很简单——万一以后要同时监控CAN和网络socket呢?
来看一个epoll + CAN的实战片段:
int epfd = epoll_create1(0);
struct epoll_event ev, events[10];
ev.events = EPOLLIN;
ev.data.fd = can_socket;
epoll_ctl(epfd, EPOLL_CTL_ADD, can_socket, &ev);
while (1) {
int nfds = epoll_wait(epfd, events, 10, 100); // 100ms超时
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == can_socket) {
struct can_frame frame;
int nbytes = read(can_socket, &frame, sizeof(frame));
// 处理报文
}
}
}
我曾经踩过的坑:epoll_wait的超时时间设成了0,结果CPU直接飙到100%。后来才意识到,超时0等于忙轮询。建议设10~100ms,既保证响应速度,又不浪费CPU。
4.3 CAN报文时间戳获取
时间戳这东西,做车载诊断和性能分析的时候特别重要。比如你要算两条报文之间的延迟,或者判断某个事件发生的精确时刻。
SocketCAN提供了两种时间戳:
- 软件时间戳:默认就有,精度在微秒级,但受系统调度影响
- 硬件时间戳:需要硬件支持,精度纳秒级,适合高精度场景
获取时间戳的代码也很直接:
struct can_frame frame;
struct timeval tv;
struct timespec ts;
// 方法一:通过ioctl获取软件时间戳
ioctl(can_socket, SIOCGSTAMP, &tv);
printf("软件时间戳: %ld.%06ld\n", tv.tv_sec, tv.tv_usec);
// 方法二:通过recvmsg获取硬件时间戳(需要setsockopt开启)
struct iovec iov;
struct msghdr msg;
struct cmsghdr *cmsg;
char ctrl[CMSG_SPACE(sizeof(struct timespec))];
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(can_socket, &msg, 0);
for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SO_TIMESTAMPNS) {
ts = *(struct timespec *)CMSG_DATA(cmsg);
printf("硬件时间戳: %ld.%09ld\n", ts.tv_sec, ts.tv_nsec);
}
}
我的建议:大多数场景用软件时间戳就够了。只有当你做CANoe对标测试或者总线延迟分析时,才需要上硬件时间戳。而且硬件时间戳需要驱动和硬件双重支持,别指望随便一块USB转CAN就能用。
4.4 发送优先级与仲裁机制
CAN总线的仲裁机制,说白了就是靠报文ID比大小。ID越小,优先级越高。这个在物理层就决定了,应用层改不了。
但应用层能做什么?能控制发送时机和发送队列。
SocketCAN的发送队列默认是FIFO。如果你有多个线程同时发报文,后调用的write不一定后发出去——因为内核的队列调度和总线仲裁是两回事。
来看一个实际案例:
// 高优先级报文(ID=0x100)
struct can_frame high_prio = { .can_id = 0x100, .can_dlc = 8 };
// 低优先级报文(ID=0x500)
struct can_frame low_prio = { .can_id = 0x500, .can_dlc = 8 };
// 同时发送时,高优先级报文会先被总线仲裁出去
write(can_socket, &high_prio, sizeof(high_prio));
write(can_socket, &low_prio, sizeof(low_prio));
这里有个细节:write只是把报文放进内核的发送缓冲区,真正的发送由内核的CAN协议栈调度。如果你要确保高优先级报文绝对优先发送,可以用sendmsg配合SO_PRIORITY:
int priority = 6; // 0~6,6最高
setsockopt(can_socket, SOL_SOCKET, SO_PRIORITY, &priority, sizeof(priority));
// 此后该socket的所有发送报文都会优先进入内核发送队列
注意:SO_PRIORITY影响的是内核网络栈的队列调度,不是CAN总线仲裁。总线仲裁永远看报文ID。这个优先级只是让你的报文在内核里少排队,早点送到CAN控制器。
4.5 本章知识结构图
下面这张图把本章的核心逻辑串起来了。从I/O模式选择,到多路复用,再到时间戳和优先级,一条线走到底。
4.6 实战要点总结
最后,我把这一节的核心要点整理成清单,方便你对照检查:
- I/O模式:原型用阻塞,调优用非阻塞+epoll
- 多路复用:epoll是首选,select只适合老平台或极简场景
- 时间戳:软件时间戳用ioctl(SIOCGSTAMP),硬件时间戳用recvmsg+SO_TIMESTAMPNS
- 发送优先级:总线仲裁看ID,内核调度看SO_PRIORITY,两者不冲突
- 避坑:epoll超时别设0,硬件时间戳先确认驱动支持