☰
Linux+Xenomai下CAN卡中断通信的实时延迟优化实战
2026/10/17 1:23:02 网站建设 项目流程

最近在做一个现场控制单元的项目,代号WT,遇到的核心需求就是“Linux + Xenomai 下 CAN 卡中断通信”。之前我也写过不少基于SocketCAN的接收程序,但真正把CAN卡的中断接到Xenomai实时域以后,整套系统的行为模式完全不一样了。这篇文章就是把我们从选型、搭环境、写RTDM驱动,到排查中断抖动、最终压出稳定延迟的整个过程做一个完整复盘。

如果你正在做运动控制、伺服同步、激光雷达扫描这类和CAN总线打交道的实时应用,又不想被Linux主域那些不可控的调度延迟搞到崩溃,这篇文章应该能省掉你很多试错时间。我不会只讲概念,会把所有踩过的坑、验证过的参数、以及代码骨架都摆出来。

1. 为什么CAN通信最终被我从Linux主域“搬”进了Xenomai实时域

1.1 关键区分:Xenomai到底提供了什么

很多刚接触Xenomai的人容易把两件事混在一起:一类是把Linux内核编译成完全抢占式(比如PREEMPT_RT),另一类是用Xenomai这种双内核方案。PREEMPT_RT的思路是让Linux内核里的所有临界区都可以被打断,配合高优先级线程来降低延迟;而Xenomai走的是另一条路,它在Linux旁边并行运行了一个实时微内核,Linux被降级成这个实时内核里的一个普通低优先级任务。

这意味着什么?在Xenomai实时域里跑的任务,不依赖Linux的调度器,不会被ksoftirqd、kswapd、各种内核线程抢走CPU。CAN控制器一产生中断,I-Pipe中断管道会直接把这个中断分流到实时域去执行。整个过程没有经过Linux的标准中断子系统,也没有经过进程调度器,这才能把“报文从硬件发出到应用任务真正拿到”这件事的延迟压到几十微秒量级。

在WT项目里,我们一开始也是图省事,直接在主Linux域跑SocketCAN,结果在周期性高速CAN报文场景下,偶尔会有几百微秒甚至几个毫秒的尖峰。对普通的数据采集无所谓,但对伺服控制这种1ms周期同步的场合,基本等于灾难。所以我们后来才下定决心,把CAN中断通信整体搬到Xenomai实时域。

1.2 SocketCAN的三个“不确定”来源

不是说SocketCAN不好,而是它的设计目标是“通用”,追求的是Linux网络协议栈的统一性和吞吐量,不是延迟确定性。在WT项目里,我们分析出它在实时场景下的三个主要延迟来源:

第一个是中断下半部。多数CAN驱动把中断处理分成硬中断和软中断两部分,硬中断里只读FIFO的一部分数据,剩下的放到softirq里处理。softirq什么时候运行?取决于当前CPU上中断和线程的竞争情况,如果当前正在跑一个长系统调用,softirq会被推迟。

第二个是协议栈路径太长。SocketCAN走的是netdevice框架,报文要进协议栈,再通过NETLINK或者PF_CAN套接字通知应用层。每一步都有锁、有缓存、有唤醒机制,光是一次recvmsg系统调用就可能几十微秒。

第三个是进程调度。即使中断处理很快,应用线程也必须等待Linux调度器把它唤醒,而Linux调度器在系统负载升高时会做各种负载均衡、唤醒抢占的权衡,最坏情况下的唤醒延迟很难保证。

这三个不确定来源合起来,就导致SocketCAN下测出来的平均延迟可能很好看,但最大最坏延迟总是让人绷紧神经。而工业总线通信,真正要紧的恰恰是最坏情况,不是平均值。

1.3 双域协同的整体设计

既然决定把CAN通信搬进实时域,架构就要重新设计。WT项目的整体分层是这样的:

  • Linux主域:负责显示界面、日志存储、参数配置、和上位机通信这些非实时功能。这一层随便你怎么用线程池、事件循环,崩溃了也不影响实时链路。
  • Xenomai实时域:负责CAN报文的收发、解析、控制指令下发、周期同步任务。这块代码跑出来的行为必须是确定性的。
  • 实时域与主域之间:用共享内存或双向环形缓冲区传递数据。实时域只写共享状态,主域通过轮询或事件上报读取。

这样的好处是,CAN中断进来后,实时域动作非常轻:搬数据、置标志、唤醒RT任务,然后立刻退出中断上下文。剩下的解析、保存、网络交互全部丢给主域的普通Linux进程。

2. 环境搭建与RTDM驱动接入:从内核补丁到中断注册

2.1 内核配置的取舍

Xenomai的部署向来有个让人头疼的阶段,就是内核补丁。我们当时的组合是Xenomai 3 + I-Pipe补丁,编译内核的时候需要打开CONFIG_IPIPE相关的选项。这里有几个关键取舍值得单独讲。

首先是CPU隔离。即使使用了Xenomai,如果Linux主域的网络、显卡、文件系统任务全都挤到一个核上,实时任务也没法做到稳定延迟。我们用的工业小主机是四核,最终方案是把两个核划给实时域,两个核留给Linux主域。启动参数大概长这样:

isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3

然后通过echo把中断亲和性都绑到0号和1号核上。这样实时核上的外部中断源就很少了,只剩下系统时钟和CAN卡中断,能显著减少实时任务被打断的次数。

其次是内核CONFIG_XENO_OPT_TIMER_PERIOD这类定时器精度的配置。如果只是测试,默认1个tick就行;但如果要跑毫秒级周期控制,建议把周期调小,比如100微秒,这样RT任务的时间片轮转会更细,延迟毛刺也更少。

还有一点容易被忽略:不要默认打开所有CONFIG_XENO_DRIVERS的调试选项。调试选项会把每次域切换记录成日志,在生产环境里全是额外开销。

2.2 CAN驱动进入实时域的两种路径

搞清楚要用哪块CAN卡之后,驱动落地有两条常见路径。

一条是用社区维护的Xenomai CAN驱动包。Xenomai生态里曾经有一套rtcan驱动,基于RTDM接口,直接注册rtdm_irq_request。如果你的CAN卡芯片在它的支持列表里,那就非常省事,加载驱动后会在/dev/rtdm下出现节点,你只需要在应用层调用rt_dev_open、rt_dev_read这类接口。

另一条路径是自己为一个特定板卡写RTDM驱动。这不复杂,但需要你把芯片手册上的中断状态寄存器、FIFO操作搞清楚。WT项目里用的板卡是一块通用PCIe转CAN卡,芯片很常见,社区驱动只支持轮询模式,中断模式我们想要的确定性没法被满足,于是干脆在RTDM框架下自己补了中断处理逻辑。

不管哪条路径,核心都在于:驱动必须在实时域注册中断,而不是走Linux irq子系统。

2.3 中断注册与收发API骨架

RTDM驱动的骨架其实很清晰。先实现一个设备打开回调,在回调里为每个CAN通道请求中断:

static int can_dev_open(struct rtdm_dev_context *context, struct rtdm_user_info_t *user_info, int oflags) { struct can_dev *dev = rtdm_context_to_private(context); rtdm_mutex_lock(&dev->open_lock); if (dev->open_count == 0) { rtdm_irq_request(&dev->rx_irq, dev->irq_line, can_rx_irq_handler, RTDM_IRQTYPE_SHARED | RTDM_IRQTYPE_EDGE, dev); rtdm_irq_enable(&dev->rx_irq); } dev->open_count++; rtdm_mutex_unlock(&dev->open_lock); return 0; }

这里有个细节:是否使用RTDM_IRQTYPE_SHARED。PCI总线上的两个设备共享一条IRQ线并不罕见,如果实时中断回调没有快速判断“是不是我的设备产生的中断”,而所有共享设备的中断都进实时回调,轻则多干活,重则该进Linux域的网卡中断被我们长时间屏蔽,引发系统故障。

中断回调里做的事非常简单:读状态寄存器,判断是否接收完成,然后循环读FIFO把报文全搬进环形缓冲区:

static int can_rx_irq_handler(int irq, void *arg) { struct can_dev *dev = arg; uint32_t status = can_read32(dev, REG_STATUS); if (!(status & STATUS_RX_FIFO_NOT_EMPTY)) return RTDM_IRQ_NONE; while (can_read32(dev, REG_STATUS) & STATUS_RX_FIFO_NOT_EMPTY) { struct can_frame frame; read_frame(dev, &frame); ringbuf_push(&dev->rx_ring, &frame); } can_write32(dev, REG_IRQ_CLEAR, IRQ_CLEAR_RX); rtdm_sem_signal(&dev->rx_sem); return RTDM_IRQ_HANDLED; }

写完这一个回调,加上一个rtdm_sem的接收信号量,整个中断通信的骨架就出来了。应用层实时任务只需要rt_dev_read阻塞等待这个信号量,有数据就从环形缓冲区取帧。

3. 一条CAN中断报文的实时旅程:数据通路逐段拆解

3.1 从FIFO置位到中断响应的前半程

如果你用过单片机里的CAN控制器,会发现Xenomai下的处理逻辑和单片机很相似。CAN控制器收到一帧合法报文后,会把报文按过滤规则写入接收FIFO,同时将接收中断标志位置位并拉高中断引脚。

这个引脚接到PCIe桥片后,中断会经过PCI中断控制器,最终踢到全局中断控制器的某条IRQ线上。传统Linux里,这条线会由irq_chip分发到驱动的request_irq处理函数;而在打了I-Pipe补丁的内核中,实时域对这个IRQ抢先了一步。

这里有个极其重要的概念:IRQ流量在进入Linux标准处理之前,先经过I-Pipe管道判断是否属于实时域。如果这个IRQ已经被RTDM驱动注册,I-Pipe会把执行权直接交给实时回调;如果没被注册,就把中断信号“重定向”给Linux主域继续做传统处理。

因此在Xenomai实时回调里,你面对的是一个“没有被Linux预处理的硬件现场”。你的驱动代码必须自己清中断、读状态、操作FIFO。这也是为什么写实时驱动比写普通内核驱动更讲究“一次把该干的活干完”。

3.2 实时中断回调节奏:做搬运工,不做厨师

在中断回调里“做得越少越安全”是通用原则,但在Xenomai下,这句话要执行得更加严格。普通Linux驱动里还可以用tasklet或workqueue把重活延迟到进程上下文,而RTDM中断回调本身就是实时上下文,你要是再引入Linux域的东西,等于把延迟控制在瞬间放弃。

我们在WT项目里有几条硬性纪律:

  • 不调用printk、printf和任何可能产生Linux系统调用的函数。实时回调里要是触发域切换,代价可能是几十上百微秒。
  • 不分配内存。实时上下文里没有普通内核的kmalloc可用,即便有带GFP_ATOMIC的分配器,碎片化和不确定性也违背了初衷。
  • 不加复杂业务逻辑。回调的作用就是“搬数据”,需要解析协议、校验、诊断的活全部丢给接收任务。

那实时任务侧又怎么写呢?接收任务使用rt_task_create创建,优先级设为RT类最高优先级之一,循环里用rt_task_sleep_until做周期节奏,阻塞在信号量上等待中断回调的通知:

static void can_rx_task_proc(void *arg) { struct can_dev *dev = arg; struct can_frame frm; while (1) { if (rt_dev_read(dev->fd, &frm, sizeof(frm)) > 0) { /* 解析、记录、转发给控制任务 */ } } }

这样一条链路下来,从CAN控制器产生中断,到用户态RT任务拿到完整帧,中间只有两次上下文切换的代价:一次是中断上下文结束后回到RT任务,一次是rt_dev_read从等待队列被唤醒。整个路径里没有Linux调度器参与,所以可预期性非常高。

3.3 高速场景下的流控与丢帧计数

当总线速率比较高,或者报文周期很密集时,中断回调必须保证“读得比进得快”。我们在WT项目里遇到过一次比较惊险的情况:1ms周期内要发收几十帧数据,配合部分节点还在做网络管理,CAN总线的负载到了40%左右。如果每次中断只读一帧,CPU上下文切换开销会变得很大,而且如果FIFO深度超过单个中断单次读取量,就会丢帧。

所以中断回调里必须是循环读取直到FIFO空,和我们上面的代码一样。同时要在驱动里维护一个rx_overrun_count计数,当检测到硬件溢出标志时,把这个值递增。这个计数器的意义在于:丢帧可以被观测,而不是被硬件悄悄吃掉,否则半年后查问题根本无从下手。

用户态侧建议加上时间戳。我们会在RT任务收到报文时直接读取Xenomai的高精度时钟,打上一个rt_timestamp,然后和报文内容一起放进环形缓冲区。这样事后分析时能精确知道“这帧报文的到达时刻是否延迟了”,而不是只看到“程序收到的顺序正常”。

4. 一次实际丢帧排查:问题全在实时域之外

4.1 现象与初步数据

WT项目第一版跑起来后,功能正常,但用示波器旁路监测看系统输出脉冲,总感觉偶尔会有那么一拍慢半拍。于是在RT任务里加了时间戳统计,发现某些时刻的接收间隔会从标准1ms突然跳到4ms以上,同时伴有一个通道丢帧计数上涨。

当时第一反应是怀疑驱动中断处理出了问题,结果把实时回调里的代码翻来覆去看了很多遍,都找不到毛病。最后静下心来看,问题其实不在实时域,而是出在“干扰实时域的外部力量”上。这也给了我一个很重要的经验:一旦Xenomai实时任务出现偶发抖动,第一步一定要先问,实时核是否被外部中断频繁打断。

4.2 中断共享与IRQ优先级问题

排查的第一步是看中断分布。用/proc/interrupts观察每个CPU上的中断计数,我发现了搞笑的一幕:CAN卡和板载网卡共享同一条IRQ线,而且网卡流量一大,每秒钟会产生大量共享中断。

如果是传统Linux,共享IRQ的处理方式是逐个调用注册的处理函数,大家都在一个链上,谁忙都会拖慢整条链。而在Xenomai下,这个共享问题更严重,因为RTDM中断回调在这个IRQ线上会优先执行,我们不得不先判断是不是自己的设备触发,如果不是,还要立即让出。但I-Pipe的调度方式导致Linux域在这个IRQ上的处理也被边缘化,结果两边都在等对方,延迟就上来了。

解决方案是改硬件中断路由。我们调整了PCI设备的IRQ分配,通过BIOS或内核参数尽量让CAN卡独占一条IRQ线。设了一个启动参数把PCI设备重新排列,最终CAN卡落到了一条干净的中断线上。这一步之后,抖动尖峰的频率下降了很多,但还有个别的偶发尖峰。

4.3 核隔离与缓存污染问题

第二次排查时,我发现实时核上的cache miss比例高得离谱。原因是主域的Linux线程虽然没有运行在隔离核上,但内核本身有些内核线程和定时器还是会周期性访问各CPU核的数据结构,包括实时核。比如ksoftirqd、rcu_sched这些任务,即使绑核没绑过来,Linux域的周期性housekeeping也会触摸到实时核的cache,导致实时任务每次重新进入循环时都面临冷缓存惩罚。

解决办法除了启动参数里用nohz_full和rcu_nocbs把实时核从Linux的周期事件里摘出去,还要注意板卡的DMA操作。我们在PCIe驱动的初始化里给DMA buffer做了cache一致性配置,然后用dma_alloc_coherent统一管理,避免中断通知时做昂贵的手动同步。

做到这个程度后,尖峰基本只剩硬件偶尔的调度噪声。整条延迟曲线变得非常“平”。

4.4 调试打印打出来的“假延迟”

这个坑特别典型,值得单独写出来。早期调试时,为了看每个周期的报文内容,我直接在RT任务里调用了普通的printf。启动参数里又开着Xenomai控制台扩展,导致每次打印时RT任务会切换回Linux域完成终端IO,再切换回实时域。

切换一次就是几十微秒,而一旦串口或者终端缓冲满了,延迟就是几百微秒。你用打印来判断延时,打印本身却在制造延时,最后测出来的数据完全失真。这个教训让我养成了两个习惯:

  • 实时任务里永远不打印,输出只写到内存环形缓冲;
  • 主域再起一个普通进程,读取这些缓冲定期落盘。

排查问题的时候,先把实时任务的“行为轨迹”记录下来,然后再由非实时程序负责展示,方向就对了。

4.5 排查后的数据对比

修复完上述三个问题后,我们用同样的压力场景重测,数据变化非常明显。下面是我们当时测的一组典型值,具体平台不同会有差异,但变化趋势是普遍的:

指标SocketCAN主域Xenomai实时域(修复前)Xenomai实时域(修复后)
端到端平均延迟90µs28µs15µs
端到端P99.9延迟1.6ms320µs45µs
最大抖动4.3ms1.1ms68µs
一小时丢帧数偶尔十几帧3~5帧0帧

注意,这里的“端到端”是从CAN控制器中断置位到RT任务进入业务解析。修复后最大抖动依然没有降到零,但已经进入了一个可以接受的稳定区间。对WT项目来说,1ms周期里最坏情况68µs的抖动完全够用。

5. 延迟实测与还能榨出的余量

5.1 如何建立一套可信的基准测试

很多人在Xenomai项目里测延迟都犯同一个错误:用普通Linux的时间API去测实时逻辑。比如直接在RT任务里调用clock_gettime(CLOCK_MONOTONIC),这个调用本身就可能触发域切换,测出来的数据会包含测试代码自己的开销。在Xenomai里应该用实时库提供的时钟接口,比如rt_timer_read(),确保整个测试路径都留在实时域。

另外最好让测试卡和被测系统之间产生真实CAN报文。我们在项目里放了一个独立的CAN发送节点,按固定周期发送带递增序号的数据帧,接收侧统计序号断点来判断是否丢帧,同时记录每个序号到达的本地时钟差。这种方式比单纯在程序内部自测要可信得多,因为数据一旦走物理链路,硬件收发、FIFO深度、以及驱动中断的所有环节都被覆盖了。

5.2 实测结果与主要瓶颈

修复完的系统在1ms周期同步任务下,平均延迟15µs左右,最大延迟68µs左右。这个数据看起来不错,但要提醒一句:延迟的数值高度依赖当时的总线波特率、FIFO深度、CPU主频和实时核是否干净。换一块低功耗的主板,最大延迟可能直接飙到两三百微秒。

如果你测出的数据和我们的差别很大,请优先检查两件事:一是实时核上是否还有Linux域的周期事件在跑,二是中断回调里是否不小心调用了一个让你切到主域的第三方库函数。和锁竞争相比,这两件事才是Xenomai项目里最大的隐形杀手。

5.3 进一步优化的路线

如果要把延迟再往下压,有几个方向可以试。

第一个是中断合并和批量读取。中断回调节省下来的上下文切换非常可观,如果每次进入回调都等FIFO攒到一定数量再读,代价是单帧延迟会变大,但对吞吐型应用很友好。可以做成动态策略,低负载时逐帧中断,高负载时合并读。

第二个是共享内存交付。如果CAN报文到了实时任务后还要交给Linux主域的界面程序展示,尽量用固定的共享内存区域,不要走socket或字符设备的读写,这样可以避免每次报文传递都让Linux主域经历一次完整的调度唤醒。

第三个是考虑DMA运行模式。部分PCIe CAN卡支持直接把数据DMA到主机内存,并在DMA完成时触发中断。这样中断回调里只需要做地址校验和缓冲队列指针更新,连FIFO读取都省了。但DMA缓冲的cache一致性维护要做得非常小心,不适合新手第一次就尝试。

还能不能继续压?可以,但收益会越来越低。工程上不能只看平均延迟,还得看总线负载变化时的行为。我现在的思路是:先保住最坏情况可预期,再考虑优化吞吐。

最后分享一个我们项目里长期保留的习惯:每次启动实时系统后,先用一个低优先级主域任务做30分钟的空跑统计,收集“最大抖动”“上下文切换次数”“丢帧计数”这三个数。如果哪天系统重新部署或者升级内核,只要这三个数出现明显变化,立刻就能知道实时环境退化了,不用等伺服飞车那一天才发现问题。做实时通信,最贵的不是微秒级的调优,而是让整个系统的时延可观测、可复现、可回归。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询