最近把一套 CAN 卡中断通信程序从纯 Linux 环境挪到了 Xenomai 实时域下,断断续续调试了两周才跑稳。项目代号就叫 WT,核心目标很明确:在保证 CAN 报文处理延迟可控的前提下,把中断驱动的收发链路完整跑通,并验证最坏情况下的响应时间。这篇文章主要记录整体思路、驱动适配、实时线程实现,以及实际踩过的坑,适合正在评估 Xenomai + CAN 集成方案,或者已经在用 RTDM 接口但总被中断延迟问题困扰的开发者参考。
1. 项目背景与整体方案选型
1.1 为什么必须要 Xenomai
WT 项目原本跑在一套普通 Linux 系统上,用 SocketCAN 接收外部控制器发来的 CAN 报文。刚开始业务逻辑简单,报文频率也不高,几千帧每秒下没暴露什么问题。后来对方升级了协议,要求设备在收到某一帧命令后的 100 微秒内完成解析、计算并回发对应报文。普通 Linux 的调度延迟在负载波动时很容易超过这个上限,尤其是系统里有网卡中断、磁盘 IO 和别的用户进程时,一次调度延迟可能从几十微秒跳到几毫秒。这个抖动对控制链路是致命的。
Xenomai 的典型做法是在硬件和 Linux 内核之间加一层实时内核域,让实时任务优先抢占资源,把普通 Linux 的大部分中断和调度延隔离开。CAN 通信恰好属于强实时外设场景,所以方案从一开始就锁定在 Xenomai 3 上。
1.2 中断方式 vs 轮询方式
系统设计时最纠结的就是中断还是轮询。很多人觉得实时系统里轮询更简单,CPU 占用高但逻辑直接,但实际上 CAN 控制器收到的数据并不是均匀到达的,尤其是外部主站发送的命令帧带有明显突发性。如果固定 1ms 轮询一次,突发报文来了之后最坏要等多一个周期,这会直接破坏 100 微秒的延迟目标。把轮询周期缩短到 50 微秒,又会让 CPU 在空闲时段也持续空转,造成不必要的电源和资源浪费。
所以最终选择了中断驱动方式:CAN 卡收到帧后,硬件产生中断,实时内核域中的中断处理函数立刻执行,把数据拷贝到预先分配好的缓冲区,再唤醒一个实时收发线程去处理业务逻辑。这样空闲时几乎没有调用开销,突发时能第一时间响应。两种方式的对比如下:
| 对比项 | 中断驱动 | 轮询驱动 |
|---|---|---|
| 响应延迟 | 硬件触发,微秒级唤醒 | 依赖轮询周期,有平均半周期延迟 |
| CPU 占用 | 空闲时几乎为 0 | 固定占用,周期越短占用越高 |
| 实现复杂度 | 需要驱动和实时任务配合,略复杂 | 逻辑最简单 |
| 抖动控制 | 受中断延迟影响,但可优化 | 相对固定,但最坏延迟较大 |
| 适用场景 | 突发性强、延迟要求高的控制链路 | 周期性采样、对延迟要求低的场合 |
1.3 WT 项目的软硬件环境
硬件平台是一台 x86 架构的工控机,CPU 是四核低功耗型号,内存 4G,自带一个 PCIe 接口的 CAN 卡,控制器芯片比较常见,属于带收发中断信号那种。操作系统内核使用 4.19 版本,打了 Xenomai 3 的 cobalt 补丁,用户空间使用 Alchemy API。CAN 卡驱动不是内核原生支持的,而是厂商提供了一份符合 RTDM 标准的驱动补丁,这是后续所有工作的前提。
如果之前没有接触过 RTDM,可以把它理解为实时版的设备驱动框架。普通 Linux 驱动里用 request_irq 注册中断,在 Xenomai 下则要使用 RTDM 接口来注册中断处理函数,这样中断才能直接进入实时内核域,而不是先经过 Linux 内核的中断子系统。后面所有代码都基于这个接口。
2. Xenomai 中断处理机制与驱动适配
2.1 中断到底是怎么进的实时域
很多第一次接触 Xenomai 的人会误以为在驱动里随便写一个中断处理函数,程序就会自动实时了。实际上,Xenomai 3 采用双内核架构,实时中断会优先被实时内核域的头部处理,之后才可能传递给 Linux 域。这意味着中断处理函数的注册方式、上下文环境、能调用什么函数,都有严格限制。
我一开始犯过一个低级错误,直接按普通内核模块的思路在驱动里用 request_irq 注册中断,结果中断来了之后唤醒的是 Linux 内核线程,实时任务还是被普通调度器拖着跑,延迟跟之前没什么区别。后面换成厂商提供的 RTDM 驱动补丁,使用 rtdm_irq_request 接口注册中断处理函数,中断信号才真正在实时域被处理。
在实际项目中要特别注意一点:在 Xenomai 的实时中断处理函数里,不能调用 Linux 内核提供的 spin_lock_bh、printk 这类可能触发调度或睡眠的函数,也不能直接调用用户态的标准库。中断处理函数只适合做最轻量的操作,比如读寄存器、清中断标志、把数据放入无锁队列、唤醒实时任务。剩下的解析和业务逻辑全部放到用户态实时线程里完成。
2.2 选择适合 Xenomai 的 CAN 卡
并非所有 CAN 卡都能在 Xenomai 下跑出稳定延迟。市面上的 PCIe CAN 卡很多,但多数自带驱动是纯 Linux 内核驱动,走 SocketCAN 协议栈。普通 SocketCAN 在非实时 Linux 下足够用,但中断一旦进到 Linux 内核,再转发到用户态 socket,中间经过的协议栈、netif 调度等环节会带来不可控的延迟。Xenomai 项目里如果要严格保证延迟,要么选用提供 RTDM 驱动的设备,要么使用实时 SocketCAN 补丁。
我这次用的是某款国产 PCIe CAN 卡,厂商驱动里已经包含 RTDM 接口,也提供实时字符设备节点。调试前一定要跟厂商确认驱动版本是否匹配当前 Xenomai 版本,不然经常会出现接口签名不一致的问题。还有一点要留意,RTDM 驱动通常不会自动加载,需要手动 insmod 或配置开机加载,加载完成后检查 /dev/rtdm 下是否出现对应的设备节点。
2.3 中断映射与 CPU 核绑定
Xenomai 实时任务跑在哪个 CPU 核上,直接决定中断延迟上限。如果中断可以在任意核上触发,而实时任务固定跑在另一个核,那么中断处理函数和任务之间的唤醒就要经过核间中断,延迟抖动会大得多。更稳妥的做法是先把中断亲和性绑定到一个固定 CPU 核,再把实时收发线程也绑定到同一个核。
操作方式也比较简单,驱动初始化时通过 irq_set_affinity_hint 或者写 /proc/irq/N/smp_affinity 指定中断到的 CPU 集合,用户态任务则用 pthread_attr_setaffinity_np 或 Xenomai 的 rt_task_set_mode 固定到对应核上。这里有一个很容易忽略的细节:CPU0 经常会承担大量系统内核管理中断,尽量避免把实时关键线程放在 CPU0,除非单独做隔离处理。实测下来,把 CAN 中断和收法线程都放在 CPU3 后,抖动明显下降。
3. 核心实现:基于中断驱动的 CAN 收发链路
3.1 驱动层的中断处理与数据交接
驱动层是整个链路的第一环,代码结构虽然短,但要求极高。中断处理函数收到硬件中断后,先读取 CAN 控制器的中断状态寄存器,判断是接收中断、发送完成中断还是错误中断。如果是接收中断,就把数据从 CAN 控制器的 FIFO 中搬到一个无锁环形队列中,然后唤醒应用层的实时任务。这里必须使用无锁或实时自旋锁,因为普通锁可能会让中断上下文进入睡眠状态。
中断处理函数切不可做协议解析、字节序转换、log 打印这些事。曾经有一版驱动为了调试方便,在中断里直接调用了 printk 输出每一帧的 ID,结果中断响应时间直接从 20 微秒增加到 200 微秒,CAN 总线稍微一忙就丢帧。所以调试代码归调试代码,正式运行一定要保持中断处理函数足够简洁。环形队列的大小也要根据总线上的峰值流量设置,我一般会按最大突发帧数的两倍来分配。
3.2 应用层实时线程的任务结构
应用层使用 Xenomai 的 Alchemy API 创建实时收发线程。线程的主要逻辑是阻塞等待中断处理函数发出的信号,被唤醒后从环形队列中把数据取出来,完成业务解析和回发。这种“中断 + 信号量 + 线程”的结构非常经典,相当于把实时中断的上半部和下半部分离开,既保证了响应速度,也避免在中断上下文里处理复杂逻辑。
线程优先级要特别谨慎。Xenomai 的优先级数字越大优先级越高。我把收发线程优先级设成了比普通控制任务稍高一点,但低于实时内核内部的一些关键任务。如果你使用默认优先级,很可能会被别的周期任务抢占,导致延迟超标。此外线程创建后建议立刻调用 mlockall(MCL_CURRENT | MCL_FUTURE) 锁住内存,防止运行中发生缺页中断。缺页带来的延迟是不可接受的,这个问题在高负载下尤其明显。
3.3 关键配置与接口说明
RTDM 设备打开方式与传统文件 IO 类似,直接使用 rt_dev_open。大部分厂商驱动会提供 ioctl 来配置波特率、滤波模式、接收超时等参数。CAN 总线波特率、采样点、帧格式这些参数如果不一致,通信会直接失败,而且不容易排查。我习惯把配置定义在一个结构体里,启动时统一设置,并且在实际发送第一帧之前回读确认。
典型的初始化流程大致如下:打开设备、通过 ioctl 设置波特率和过滤规则、启动实时任务、任务进入阻塞等待状态。中断使能通常在内核驱动加载时就完成了,不需要用户态再额外开启。应用层如果要做总线错误监测,可以额外创建一个低优先级的看门狗线程,定期读错误状态寄存器,但不建议把错误处理逻辑放在主收法线程里。
3.4 一个精简的应用层代码示例
下面是一段精简后的伪代码,展示核心逻辑,实际项目里需要补充错误处理和参数校验。这段代码跑在 Xenomai 用户态,使用 Alchemy API:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <rtdm/rtdm.h> #include <alchemy/task.h> #include <alchemy/sem.h> #define CAN_DEVICE "/dev/rtdm/can0" #define RX_TASK_PRIORITY 99 RT_TASK rx_task; RT_SEM rx_sem; void can_rx_thread(void *arg) { char frame[32]; ssize_t n; while (1) { /* 等待中断处理函数唤醒 */ rt_sem_p(&rx_sem, TM_INFINITE); n = rt_dev_read(CAN_DEVICE, frame, sizeof(frame)); if (n > 0) { /* 这里解析 CAN 帧并计算回发 */ process_can_frame(frame, n); } } } int main(int argc, char *argv[]) { int fd; /* 内存锁页,防止缺页 */ mlockall(MCL_CURRENT | MCL_FUTURE); /* 打开 RTDM 设备 */ fd = rt_dev_open(CAN_DEVICE, O_RDONLY); if (fd < 0) { perror("open"); return -1; } /* 配置波特率,过滤规则,具体取决于厂商 ioctl */ rt_dev_ioctl(fd, RTCAN_SET_BAUDRATE, 500000); rt_dev_ioctl(fd, RTCAN_SET_FILTER, 0); /* 创建信号量和实时任务 */ rt_sem_create(&rx_sem, "rx_sem", 0, S_PRIO); rt_task_create(&rx_task, "rx_task", 0, RX_TASK_PRIORITY, 0, 0); rt_task_start(&rx_task, can_rx_thread, NULL); /* 主线程进入休眠,实际工程中可能继续做其他控制逻辑 */ pause(); return 0; }这段代码里 rt_sem_p 会阻塞当前实时任务,直到中断处理函数调用 rt_sem_v 释放信号量。rt_dev_read 在 RTDM 驱动下通常会实现为从环形队列拷贝数据,不会造成二次上下文切换。对于 500kbps 波特率这种中等负载,这样的结构非常够用。
4. 实测中的问题与排查记录
4.1 中断频繁导致的 CPU 占用过高
第一个遇到的问题不是功能不工作,而是功能正常但实时线程 CPU 占用率非常高。原因是 CAN 总线上的报文比较密集,每个中断都唤醒一次线程,而线程被唤醒后只需要很少的处理时间,大部分时间其实是在调度切换中浪费掉了。中断到达速率一旦超过每秒几万次,调度开销就会积累到肉眼可见的程度。
解决方法是把“每中断唤醒一次”改成“批量唤醒”。具体做法是在中断处理函数里只把数据放入队列,触发一个计数器,只有计数器达到阈值比如 20 帧,才唤醒一次实时线程。这样既保证突发时不会错过报文,也极大减少无意义的上下文切换。与此同时,任务被唤醒后循环读取队列直到取空,避免唤醒多次。调整之后 CPU 占用从 30% 降到 5% 左右。
4.2 启动初期偶发丢帧
批量唤醒上线之后,系统出现了新的问题:总线空闲时偶尔丢一帧,特别是系统刚启动、任务还没完全跑起来的时候。定位到最后发现是环形队列在初始化阶段没有清空,而实时线程和中断处理函数之间的同步逻辑里有一个先清空队列再使能中断的顺序问题。启动时中断先来了几帧,却没有线程消费,队列尾部自然被后续帧覆盖。
修复措施是在驱动加载完成、中断使能之前,由应用层主动读取并丢弃队列已有数据,同时增加一个初始化完成标志。另一个更隐蔽的原因是环形队列的生产者和消费者使用了不同平台的内存分配方式,导致 CPU 缓存不一致。排查时用原子操作和内存屏障解决了这个问题。如果有条件,最好在驱动初始化时把队列锁进 CPU 缓存能访问的固定物理内存,Dev 自动化会减少很多隐藏问题。
4.3 最坏延迟仍然超标
功能稳定之后,最让人头疼的是延迟抖动。用示波器抓 CAN 回发信号的物理时刻,大部分情况下都能控制得很好,但每隔一段时间会出现一个明显毛刺,延迟直接翻倍甚至到毫秒级。查了许久才发现,问题不在 CAN 链路本身,而是系统里另一个网卡中断和定时器中断与 CAN 中断共享了同一个 CPU 核。实时线程被不相关的中断打断,cache 被污染后再恢复执行,延迟自然变大。
解决方式分三步:先把 CAN 卡中断绑定到独立的 CPU3,再把系统的 tickless 内核调度尽量降低干扰,最后把网卡和磁盘等所有非实时中断全部迁移到 CPU0 和 CPU2 上。这些操作写在一个启动脚本里,每次开机自动执行。经过这些调整后,最坏延迟从 300 微秒左右降到了 60 微秒以内,才真正满足项目要求。
4.4 中断处理函数中的锁问题
开发过程中还有一个容易踩的坑:在中断处理函数里使用普通 Linux 自旋锁。某次代码 review 时发现一个驱动子模块用了 spin_lock 保护共享状态,而同一时刻实时线程也在访问状态变量。表面看起来没问题,但实际上在 Xenomai 的实时域里,这个锁的行为并不一定符合预期。一旦锁持有者和请求者的优先级/域不一致,就可能发生优先级翻转甚至死锁。
最终改成 Xenomai 提供的实时自旋锁接口,并保证锁的持有时间极短。如果确实有复杂结构需要跨上下文保护,建议把数据全部搬到无锁队列中,用单生产者单消费者模型避免加锁。这条经验后来在其他项目里也反复验证过,凡是实时中断和普通上下文共享数据,都优先用无锁队列而不是锁。
5. 常见问题排查速查表与经验补充
5.1 问题速查表
这里把我遇到过的典型问题整理成一个表格,方便后续排查时快速对照:
| 现象 | 可能原因 | 检查重点 | 处理建议 |
|---|---|---|---|
| 收不到任何 CAN 帧 | 驱动未加载或设备节点错误 | 检查 /dev/rtdm 下节点 | 重新加载驱动并确认权限 |
| 中断注册失败 | RTDM 驱动与 Xenomai 版本不匹配 | 查看内核日志 | 换用匹配的驱动补丁 |
| 延迟偶尔超标 | 中断亲和性未配置 | 检查 /proc/irq/N/smp_affinity | 绑定到固定 CPU 核 |
| 帧丢失 | 环形队列过小或未清空 | 检查队列生产消费速度 | 扩大队列并增加溢出计数 |
| 实时线程 CPU 占用高 | 每帧都唤醒线程 | 统计唤醒频率 | 改成批量唤醒模式 |
| 调用标准库导致崩溃 | 中断上下文调用了非实时函数 | 审查中断处理代码 | 只保留寄存器操作和唤醒 |
| 收发线程被抢占 | 优先级设置过低 | 检查任务优先级 | 提高实时线程优先级 |
| 总线错误频繁 | 波特率采样点不匹配 | 确认总线参数 | 重新配置采样点 |
5.2 几条最值得记住的经验
整个项目做下来,我最有价值的一条体会是:Xenomai 实时中断通信能不能跑稳,关键不是应用层代码写得多花哨,而是驱动层和数据交接结构是否足够简单。中断处理函数越短越好,应用层实时线程越专注越好,任何逻辑都不该放在高优先级上下文里。
还有一个小技巧,调试阶段不要一上来就上高负载压测,先用单个周期帧验证基础延迟,确认无误后再逐渐加压。很多人上来就用官方测试工具把总线打满,结果到手一堆数据也不知道瓶颈在哪。慢慢地由简入繁,排查问题会高效很多。
最后再说一个容易忽略的细节:Xenomai 下的 CAN 通信最好把所有功能都集中到一个专用线程里,不要为了代码整洁把收发拆成两个线程。因为接收和回发往往有严格的时序关系,拆开后就需要额外同步,反而增加延迟和出错概率。像 WT 项目这样,一个实时线程连续处理接收、计算、回发,逻辑最简单也最稳定。