ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux实时控制实战:PREEMPT_RT与Xenomai选型调优

Linux实时控制实战:PREEMPT_RT与Xenomai选型调优

我接过一个控制器项目,前期在普通Linux上开发得非常顺手,网络、文件、界面生态全都有;等把伺服周期压到1ms、而且要求连续跑几个小时不出一次大的抖动时,问题就冒出来了。系统的平均负载不高,但偶尔冒出来的几十毫秒延迟,直接把换向时序打乱,现场电机发出异响。那段时间我几乎把Linux与实时控制相关的资料翻了个遍,最后才慢慢明白一个道理:Linux和实时控制不是不能兼得,关键是要弄清楚你要的到底是哪种实时性,以及该在哪一层下功夫。

这颗"定时炸弹"不是个别现象。做运动控制、数据采集、机器视觉同步的工程师,很多都遇到过类似场景:明明CPU占用率不高,但任务就是偶尔迟到。这篇文章我不打算讲教科书,而是把我在几个控制器项目里反复试出来的路线图、编译步骤、调优手段和排错思路整理出来。适合谁看?正在用Linux做设备控制、想引入实时能力但不知道从哪下手的开发者,以及已经改了内核但依然被抖动问题困扰的集成工程师。

1. 先搞清楚:你要的到底是"快"还是"准"

1.1 "实时"的门槛不是平均延迟,而是最大延迟

很多人一听到"实时"两个字,第一反应就是"响应要快"。实际上,实时系统考察的核心指标不是平均速度,而是最坏情况下的可预期性。拿快递打比方:普通快递是越快越好,偶尔慢一两天能接受;实时系统更像班车时刻表——规定几点几分到,晚一分钟就是事故。硬实时场景里,哪怕一个周期内超时,也意味着本次任务失败,可能导致机械碰撞、产品报废甚至安全事故。

所以业内讨论实时控制,看的是最大延迟和抖动范围,不是平均延迟。一个系统平均延迟只有50微秒、但偶尔会跳出2毫秒的尖峰,在运动控制里就可能造成严重的跟踪误差;另一个系统平均延迟200微秒、但最大稳定在250微秒以内,反而是合格的。选型之前,先把"数据怎么看"这个概念立住,后面测试时才不容易自欺欺人。

1.2 用三个问题判断你的项目属于哪种实时等级

判断项目需要什么程度的实时性,我习惯问三个问题:控制周期是多少?允许的最大抖动是多少?一个周期超时会导致什么后果?

如果控制周期在10ms以上,偶尔抖动一两毫秒不影响功能,那普通Linux加轻量优化就够,不要过度设计。如果周期是1ms,并且抖动要控制在几十微秒内,那就必须上实时化方案。再往上,如果控制周期压到250us甚至125us,抖动要求个位数微秒,这种情况下PREEMPT_RT也可能被复杂负载击穿,得考虑双内核或者异构多核方案。

我把常用判断维度列成一个表:

实时级别典型场景可接受的最大延迟推荐起点
软实时音视频流、网络协议处理几毫秒到几十毫秒普通Linux + 调优
硬实时1ms运动控制、同步采集几十微秒到百微秒PREEMPT_RT内核
强实时125us~250us伺服环、电力电子几微秒到十几微秒Xenomai或AMP异构核

这个表的边界不是绝对的,但作为立项阶段的方向判断足够用。我见过不少团队一开始就把目标定在“最强方案”上,结果开发周期翻倍,最后实际控制周期根本用不到那么极限的资源。反过来,也有项目用普通内核硬扛1ms周期,现场天天出问题,才回头考虑正式方案。

2. 普通Linux的"不准时"到底出在哪儿

2.1 调度器、中断与抢占:三个延迟大户

在搞清楚方案之前,得先知道普通Linux到底在哪儿掉链子,否则就算打上补丁,遇到抖动依然不知道往哪个方向查。

第一个来源是调度器。Linux对普通进程用的CFS调度器,按vruntime分配时间片,它追求的是“公平”而不是“准时”。实时调度类SCHED_FIFO/SCHED_RR虽然存在,但在旧内核里,一个进程进入内核态之后可能长时间不被抢占——实时进程优先级再高,也得等内核态工作完成。

第二个来源是中断处理。硬件中断的优先级天然高于一切任务,一旦网卡、USB控制器或者磁盘在高负载下频繁触发中断,实时任务只能被挤在中断处理的缝隙里运行。更麻烦的是,中断处理里那些关中断的临界区,会让所有任务都暂停,这段时间完全不可控。

第三个来源是内核抢占性。标准内核允许用户态进程被抢占,但内核态里很多路径不允许,尤其是持有自旋锁的代码段。一个新硬件驱动的写法不够讲究,就可能在关键路径上加入不可抢占的长临界区,实时任务被活活卡住几十微秒甚至更久。

2.2 自旋锁与优先级反转:为什么实时任务会被低优先级任务堵死

自旋锁本身不是问题,问题出在持锁时间不确定,以及锁竞争时发生的优先级反转。

优先级反转是经典场景:低优先级任务持有一把锁,中优先级任务正在运行,高优先级实时任务试图拿锁,只能等。中优先级任务由于调度策略总能抢到CPU,低优先级任务迟迟无法释放锁,高优先级任务就被一个中优先级任务卡住。普通内核基本不管这件事,PT补丁则把锁换成支持优先级继承的rtmutex——高优先级任务等待锁时,会临时把持锁任务的优先级抬升,让锁尽快释放。

这也是我建议不要在实时任务和普通任务之间共用复杂锁的原因之一。哪怕内核帮你做了优先级继承,每次锁竞争依然会引入延迟,最好的设计是临界区内什么都不做,或者干脆用无锁队列。

2.3 被忽视的电源管理、时钟和内存子系统

除了调度和锁,还有一批隐藏因素对抖动影响巨大,不实测很难发现。

CPU动态调频是其中之一。省电模式下,一个核可能在低频率运行,任务到来了才升频,升频过程需要几十微秒甚至更久,延迟自然不可控。ACPI电源管理里的C-State切换也有类似问题,核进入深度睡眠后再唤醒,来回切换的代价非常明显。

内存子系统方面,动态内存分配可能导致缺页中断,页表构建和TLB刷新也会带来延迟尖峰。还有一个容易忽略的点是内核的周期时钟:每个CPU上都会周期性触发timer tick,即使系统空闲也会唤醒CPU一次,对延迟敏感任务来说这些周期中断都可能成为扰动源。后面调优部分会讲到用nohz_full把时钟中断从一个核上拿掉,就是这个原因。

3. 三条成熟路线:从补丁到双内核再到物理隔离

3.1 PREEMPT_RT:把Linux本身改造成可抢占系统

PREEMPT_RT是目前最温和、也最容易落地的方案,它的思路是把Linux内核改造成几乎全系统可抢占:中断线程化、自旋锁替换为可睡眠的rtmutex、临界区最小化、支持优先级继承。对上层应用来说,系统还是那个Linux,驱动模型、进程模型、文件系统几乎透明,这是它最大的优势。

我个人的体会是,PREEMPT_RT适合绝大多数项目:1ms运动控制周期、视觉同步、多通道数据采集,在正确的调优下都能稳定工作。开发团队不需要重写业务逻辑,只需要把真正关键的任务放到实时线程里,避免在关键路径上做危险操作。如果项目周期紧、人手有限,PREEMPT_RT基本是第一选择。

它的能力边界也很清晰:在极端负载下,比如多个核都被高优先级任务占满、中断风暴爆发、或者设备驱动写得特别差,最大延迟依然可能上升到几百微秒。对125us级别的高端伺服控制来说,这个余量就有点紧了。

3.2 Xenomai与双内核架构:在Linux底下再放一个“准点”小内核

Xenomai代表的是另一条路线:双内核。它在Linux和硬件之间加了一个很小的实时微内核,硬件中断先全部交给这个微内核处理,Linux被降级成它的空闲任务。实时任务运行在微内核上,走RTDM(实时驱动模型),与Linux的API并不完全通用。

这种架构带来了更强的确定性。实时任务不再被Linux内部的各种调度、锁和中断干扰,典型抖动可以做到几微秒级别。代价也很实际:你需要把系统明确分成实时侧和非实时侧,实时侧用RTDM接口访问硬件或与Linux侧通信,Linux侧做界面、网络、文件系统这些重活,两边通过共享内存或者消息队列同步。

学习曲线比PREEMPT_RT陡,调试工具也更少。如果你在现有覆盖完整Linux应用栈的平台上引入Xenomai,前人踩坑记录相对少,团队需要一定的内核和驱动基础。我的判断是:当控制周期进入250us甚至更低,并且抖动的criterion到了个位数微秒,Xenomai比PREEMPT_RT稳妥得多。

3.3 AMP异构核:根本不在同一个核上抢时间

第三条路线是干脆“分家”,也就是AMP(非对称多处理)模式。现在的多核SoC通常允许把一个核划给实时任务专用,这个核上可以跑裸机主循环、也可以跑一个轻量RTOS;另外几个核跑Linux做管理界面、机器视觉、网络通信。核间通过共享内存和核间中断做数据交换。

这是确定性最强的方案,因为Linux再怎么卡,也不影响实时核上的控制环。代价是开发复杂度高:要维护两套代码、两套工具链,核间通信协议要自己定,调试时跨系统追踪问题也比单系统麻烦。适合产品批量大、硬件可以定制、控制周期要求在微秒级、不会频繁改动的场景。

我整理了一个粗略的对照表:

方案典型延迟量级对现有代码的影响开发成本适用场景
PREEMPT_RT几十微秒量级很小低1ms周期运动控制、采集、同步
Xenomai几微秒量级需要划分实时/非实时中高125us~250us伺服,严格抖动要求
AMP异构核微秒以下,取决于裸机代码非实时侧不变,实时侧独立高大批量产品、微秒级确定性要求

选型时我的建议很直接:能用PREEMPT_RT解决的就别上复杂方案;PREEMPT_RT解决不了,再考虑Xenomai;如果产品跨入微秒级确定性,且硬件允许,再谈AMP。

4. 动手实操:从普通内核切到PREEMPT_RT

4.1 版本匹配:补丁必须和内核小版本一一对应

打RT补丁这件事,第一步就藏着一个坑:补丁版本必须和内核版本严格对应。比如内核是linux-6.x.y,补丁就得是patch-6.x.y-rtN,大版本相同但小版本不同,很容易在patch阶段出现Hunk failed。这时候千万不要硬补,宁可重新下载对应版本的内核源码。

我的操作习惯是先把源码和补丁都下载到同一个目录,加起来也就两百多MB,用sha256sum先校验一遍,再开始解压。以下命令演示的是通用流程:

cd /usr/src tar xf linux-6.x.y.tar.xz cd linux-6.x.y xz -d ../patch-6.x.y-rtN.patch.xz patch -p1 < ../patch-6.x.y-rtN.patch

如果patch输出里出现“Hunk #... FAILED”,马上停手。可以检查一下补丁文件的版本号是不是跟内核目录完全一致,不要用sed去强行改版本字符串,那是饮鸩止渴。

4.2 内核配置里跟实时性最相关的几个开关

补丁打完后,配置阶段很关键。我的建议是不要从零开始做defconfig,那样很容易丢掉现有平台需要的驱动配置。比较稳的做法是先把当前发行版内核的config导出来作为基线,再在上面做增量修改:

zcat /proc/config.gz > .config make olddefconfig

然后进入menuconfig,重点检查这几个选项:

  • Preemption Model选择Fully Preemptible Real-Time(早期版本叫CONFIG_PREEMPT_RT_FULL,新版本直接是CONFIG_PREEMPT_RT)
  • Timer frequency设为1000Hz,也就是CONFIG_HZ_1000
  • 开启High Resolution Timer Support,这个通常默认开着
  • 如果后续要用nohz_full做CPU隔离,需要确认CONFIG_NO_HZ_FULL和CONFIG_RCU_NOCB_CPU可用

直接用scripts/config命令行改更快,但menuconfig更直观,适合第一次操作时逐项看清楚依赖关系。保存配置后开始编译:

make -j$(nproc) make modules_install make install update-grub

编译过程中如果某个模块报错,优先怀疑还是配置选项与驱动不匹配。把报错驱动对应的CONFIG项临时取消,先保证内核能启动,后面再逐个调整。不要贪心一次把全部驱动都选上,RT内核的稳定运行比功能齐全更重要。

重启后在grub菜单里选择RT内核。也可以把GRUB_DEFAULT改成对应内核的submenu项,这里不展开。启动后先用两个简单命令确认状态:

uname -r cat /sys/kernel/realtime

如果/sys/kernel/realtime返回1,说明PREEMPT_RT已经生效。

4.3 用cyclictest验证基线表现

验证实时性最常用的工具是cyclictest,来自rt-tests测试集。装好之后先做一次空载基线测试,这个基线数据以后所有调优都拿它当参照。

cyclictest -t 4 -p 80 -i 1000 -l 100000 -m

参数解释:-t 4开四个测试线程;-p 80是实时优先级;-i 1000是线程间隔1000微秒;-l 100000是循环次数;-m是锁定内存。输出大概长这样:

T: 0 P:80 I:1000 C:100000 Min: 5 Act: 7 Avg: 12 Max: 68 T: 1 P:80 I:1000 C:100000 Min: 4 Act: 6 Avg: 11 Max: 61 T: 2 P:80 I:1000 C:100000 Min: 5 Act: 8 Avg: 12 Max: 59 T: 3 P:80 I:1000 C:100000 Min: 5 Act: 6 Avg: 11 Max: 64

这里真正要盯的是每行最后的Max值。Min和Avg都低说明整体不错,但Max一旦经常冲到几百微秒以上,就得继续排查。注意这台机器是某台x86工控机的输出样例,不代表所有平台,不同CPU平台数据差异很大。最关键的提醒是:空载测试只代表系统最低水平,不能拿它说“系统合格了”。

4.4 负载测试才能暴露真实问题

空载跑完再上负载,这是我在某次项目中得到的深刻教训。当时空载Max只有50微秒,以为稳了,结果把业务线程一起打开后,Max直接跳到2毫秒。原因很快就找到了——某个驱动程序在中断上下文里做了耗时操作。

所以我的流程是:先空载一轮,再在另一个核上跑stress-ng的CPU、内存、磁盘混合压力,同时自己写的业务线程也全部开启,再跑cyclictest。两轮数据对比,如果Max成倍增长,就需要对照/proc/interrupts和ftrace去定位到底是谁抢占了CPU。

stress-ng --cpu 4 --vm 2 --hdd 1 -t 300 & cyclictest -t 4 -p 80 -i 1000 -l 300000 -m

负载测试通过后,还要做长时间浸泡:跑至少24小时甚至48小时,很多偶发尖峰是几小时才出现一次的。这一步省不得,尤其是准备用来做正式产线设备的内核。

5. 按顺序做系统调优:把不确定性一个个请出去

5.1 CPU隔离和中断亲和性:给实时任务留一个“干净的核”

即使内核已经是PREEMPT_RT,其他核上的调度活动、后台线程、定时器中断依然可能通过共享资源干扰实时任务。最有效的办法是物理隔离:在grub内核参数里指定某些核不参与通用调度,专门跑实时任务。

我常用的内核参数组合是这样:

isolcpus=4,5 nohz_full=4,5 rcu_nocbs=4,5
  • isoLcpus把4、5号核从普通调度器中摘除,普通进程默认不会跑上去
  • nohz_full让4、5号核不再接收周期性timer tick,减少定时器中断打扰
  • rcu_nocbs把RCU回调迁移到非隔离核,避免内核后台在隔离核上突然执行回调

启动后用taskset把实时线程绑到隔离核上。cyclictest测试时也可以这样:

taskset -c 4,5 cyclictest -t 2 -p 80 -i 1000 -l 1000000 -m

同时要处理中断亲和性。我的做法是关闭irqbalance服务,避免它不断迁移中断;然后把网卡、磁盘、USB这些高频中断的smp_affinity统一指到非隔离核上,让实时核彻底清净。命令不复杂,就是把对应中断写入/proc/irq/编号/smp_affinity,具体编号要看/proc/interrupts。

5.2 内存锁定、实时优先级与关键路径设计

隔离了CPU之后,第二个容易被忽略的问题是缺页和动态分配。实时线程第一次访问某段内存时,如果它还没有被映射到物理页,会触发缺页中断,这个过程可以长达几百微秒。所以实时任务里所有用到的内存,都要在初始化阶段一次性触碰一遍,然后调用mlockall把进程地址空间锁进内存。

mlockall(MCL_CURRENT | MCL_FUTURE);

这段代码写在启动阶段,之后再不做动态内存分配。业务逻辑里如果必须用队列,就预先分配一块固定大小的环形缓冲;尽量不要在关键路径里用malloc/new、打印日志、打开文件这些操作,每一项都可能引入不可控延迟。

实时优先级也要有节制。优先级确实越高越好,但如果把十个线程全设成优先级99,它们内部互相抢占产生的抖动反而会让系统不稳定。我的习惯是优先保证最核心的一个控制线程最高,其余辅助实时线程依次降几级。优先级反转问题交给内核的rtmutex处理,但自己设计数据共享时仍然优先选择无锁方案。

5.3 电源管理、BIOS与后台服务

CPU频率和电源管理是另一个常被忽略的抖动来源,尤其是笔记本平台和低功耗工控板。现代CPU为了省电会有大量C-State和动态调频机制,实时任务需要立刻执行时,CPU可能刚从休眠状态醒来,频率还没拉高,这一步就是几十微秒的延迟。

我的调优习惯是把CPU governor固定到performance模式:

cpupower frequency-set -g performance

再在BIOS层面尽量关闭SpeedStep、C-State、以及平台相关的SMI机制。SMI问题在x86平台上尤其明显:BIOS层的系统性管理中断不可屏蔽,一旦触发,所有实时任务都得靠边站。很多“莫名其妙几百微秒尖峰”的案例,最后都指向平台固件。这个层面操作系统管不了,只能改BIOS设置。

另外,跑实时控制的机器尽量不要装桌面环境、不启用不必要的系统服务。光一个GNOME的定时任务、更新服务、磁索引,就可能让系统整体活动水平上升,间接影响实时任务。我一般用最小化的服务器发行版,或者启动到runlevel 3级别,能省掉很多干扰项。

调优项我很常用的一张表:

调优对象常用手段主要收益
CPU核isolcpus + nohz_full + rcu_nocbs消除同核调度中断干扰
中断关闭irqbalance,设置smp_affinity让实时核远离高频中断
内存mlockall + 预分配 + 锁定栈大小避免关键路径缺页停顿
电源performance governor + BIOS关闭C-State/SMI消除频率切换和深度休眠唤醒延迟
系统最小化安装、无桌面、精简服务降低后台活动对全局的影响

这些操作按顺序做完,再重新跑第一节提到的那轮负载测试。我见过不少平台的峰值延迟从原本的一两百微秒降到二三十微秒,隔离核配合performance governor之后,10到20微秒左右的峰值也是有可能达到的。不过每个平台差异很大,不要拿别人的数据当自己的验收标准。

6. 我踩过的坑和最终判断

6.1 版本问题、SMI尖峰、动态分配:三个最典型的卡壳点

先说版本问题。有一次我图省事,直接用了内核源码目录下的“接近版本”的补丁,大版本对、小版本差一位,patch阶段出现一堆Hunk FAILED。当时试图用sed改补丁头文件版本号硬过,结果编译出来的内核启动到一半就panic。后来老老实实重新下源码和补丁,十五分钟搞定。教训是:实时Linux的版本敏感性就是这么强,不要和补丁较劲。

SMI尖峰是另一个迷惑性极强的问题。有块x86工控板,cyclictest的Max值每过一段时间就会跳到1到3毫秒,而且用ftrace怎么追都追不到具体是哪个内核函数在跑。后来反复查资料、对比论坛经验,才发现是BIOS的系统管理模式在定期执行,操作系统完全屏蔽不了。最后只能进BIOS把相关选项关掉,Max才恢复到几十微秒。这种问题最耗时间,因为从软件视角看完全是“黑天鹅”。

动态内存分配导致的劣化也很隐秘。有个模块在正常流程里瞬时跑得好好的,压力测试时偶尔出现一次百微秒级尖峰,定位半天发现是一个日志库在关键线程里调用了malloc来格式化字符串。把日志改成线程外的异步队列后,问题立刻消失。这件事之后我定了一条规矩:任何进入实时关键路径的代码,不允许出现动态内存分配、文件读写和非必要的系统调用。

6.2 实时性是一个“可测量”的系统工程

经历这几个项目后,我的最大体会是:实时性不是一个配置开关,而是一套需要持续测量的系统行为。内核补丁只是给你一个平台,真正的确定性要靠CPU隔离、内存锁定、中断控制、电源管理这些措施逐层收敛。

所以现在我接任何实时项目,都会先搭好一套基线测试流程:BIOS设置固定、内核参数固定、空载cyclictest一轮、负载cyclictest一轮、长时间浸泡一轮。所有数据记录在案。后续每次修改驱动、更新内核、调整业务代码,都拿同一套流程跑一遍,再和基线对比。很多“改着改着变慢了”的回溯问题,靠这套方法能在半小时内锁定方向,而不是靠感觉排查。

6.3 所谓“兼得”,其实是指正确取舍

回答标题里那个问题:Linux与实时控制如何兼得?我的最终答案分两层。

第一层,对大多数项目而言,PREEMPT_RT加上认真的系统调优,已经能把1ms周期的运动控制、同步采集做得相当稳定。你不需要牺牲Linux生态,不需要重写业务,就能获得可用的实时能力。第二层,如果项目真的要求125us级、微秒级抖动,那就不要试图让Linux单独完成所有事情,老老实实划分边界,用Xenomai或者AMP做一个专门的控制核,让Linux负责它擅长的部分。

所谓“兼得”,不是在同一个核上同时要生态和极限确定性,而是把系统分层,让每个部分干它最擅长的事。最后再分享一个操作习惯:现在凡是接实时项目,我都会先把BIOS里所有电源优化项关掉,装好内核后先跑一轮空载cyclictest,再跑负载测试,并保存基线数据。后面每次改应用代码、加驱动,都拿同一套测试对比。你会发现找回归点快得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进