RTOS与Linux实时性对比:调度器、中断、内存与异构双核选型

发布时间:2026/10/29 16:49:25
RTOS与Linux实时性对比:调度器、中断、内存与异构双核选型

三年前接手一个多轴运动控制的现场问题,设备在实验室跑得好好的,一上产线就偶发丢步,复位后又能撑几个小时。翻代码发现,原本跑在 RTOS 上的插补任务被整体搬到了 Linux 用户态,还挂了 SCHED_FIFO 最高优先级,开发者的理由是"优先级拉到顶了,跟裸机没区别"。这个判断几乎踩中了 RTOS 与 Linux 对比里最经典的一个误区:实时性从来不是"优先级高不高"的问题,而是"最坏情况能不能被证明"的问题。RTOS 和 Linux 的差别,绝大部分藏在调度器设计取向、中断处理路径、内存管理机制这三处看不见的地方,而不是宣传页上的主频和内存参数。这篇就把这两个体系掰开揉碎对比一遍,从调度器的数据结构讲到中断下半部,从 KB 级内存账本讲到异构双核的落地架构,尽量让做单片机出身的能看懂 Linux 那一侧的代价,也让做应用出身的明白 RTOS 到底"硬"在哪里。

1. 从一次丢步事故说起:实时性到底在测什么

1.1 快和确定性是两件事,很多人一开始就搞混

大多数人比较 RTOS 和 Linux 的第一反应是比性能:谁的上下文切换快、谁的中断响应快、谁的主频高。这个比较方向从根上就偏了。RTOS 追求的从来不是"平均快",而是最坏情况可控——一个 1kHz 的控制环路,要求每次都能在 800 微秒内跑完,那么系统必须在数学上保证最坏情况不超过 800 微秒,哪怕平均只花 50 微秒。Linux 追求的是吞吐量和公平性,它会让所有任务都分到时间片,让交互响应看起来流畅,但绝不承诺任何一个任务在某个确定时刻一定被执行。

用生活化的说法:RTOS 像急诊室的分诊制度,重伤员进来立刻插队,制度上保证他三分钟内必须上手术台;Linux 像银行的排队叫号系统,虽然也给 VIP 开了专属窗口,但系统整体目标是让所有人都不至于等太久,而不是保证某个人几秒内一定被叫到。你给 Linux 挂上 SCHED_FIFO 优先级 99,相当于在银行里给自己插了个队,看着很靠前,可一旦柜员(CPU)被更紧急的事(中断、内存回收、缓存刷新)叫走,你还是得等,而且等多久没有上界。

衡量这件事的指标有三个,缺一不可:

指标定义RTOS 典型值通用 Linux 典型值PREEMPT_RT 典型值
中断延迟硬件中断发生到 ISR 第一条指令执行亚微秒级(Cortex-M 约 12 周期)数十至数百微秒十几至数十微秒
调度延迟事件就绪到目标任务真正运行微秒级毫秒级,抖动大几十微秒
最坏情况抖动上述延迟的最大值减最小值极小且可分析不可预测,可能到毫秒级有界但仍受硬件影响

真正要命的永远是第三行。一个系统的平均延迟 20 微秒、最坏 20 毫秒,对控制系统来说和延迟 20 毫秒没有区别,因为控制环只要错过一次截止时间,输出的就是错误的力矩。

1.2 那次丢步的真实原因:缺页和 Cache 一起作祟

回到开头那个事故,最后定位到的根因有两条。第一条是运动控制的数据缓冲区用了malloc动态分配,任务运行中偶尔触发缺页异常,一次 page fault 在内核里要走完分配物理页、建立页表映射、可能还要回收内存的完整流程,实测单次耗时从几十微秒跳到过 2 毫秒以上。第二条更隐蔽,插补计算用的大数组跨了 Cache 行边界,在别的进程频繁刷 Cache 的情况下,重新加载的代价不稳定。

这两条在 RTOS 上根本不存在:RTOS 通常没有 MMU,任务用的是物理地址,没有缺页这回事;内存也普遍用静态分配或者固定块内存池,pvPortMalloc从一个预先划好的堆里切块,最坏耗时是可以数出来的。而 Linux 的虚拟内存是它最大的优点之一——进程隔离、内存超售、按需加载全靠它——同时也是实时性最大的敌人之一。你享受了隔离性,就得接受缺页异常这个不可预测的开销。

注意:在 Linux 上做实时任务,启动时用mlockall(MCL_CURRENT | MCL_FUTURE)把内存锁住、预热所有缓冲区、关掉透明大页,这三步能消掉大部分缺页带来的抖动,但消不掉全部,因为内核自己也会在运行时分配内存。

2. 调度器是真正的分水岭:数据结构决定了最坏响应时间

2.1 RTOS 的位图加链表:为什么切换只要几个微秒

拿 FreeRTOS 举例,它的就绪任务管理结构简单到近乎粗暴:一个数组pxReadyTasksLists[configMAX_PRIORITIES],每个优先级对应一条双向链表,同优先级的任务按时间片轮转挂在同一条链上。调度器找下一个要跑的任务时,只需要找到最高的非空优先级,然后取那条链表的头。找最高优先级这一步,在开启configUSE_PORT_OPTIMISED_TASK_SELECTION时用一条前导零计数指令(Cortex-M 上的CLZ)就完成了,时间复杂度 O(1),跟任务数量完全无关。

这个设计带来的直接后果是:任务数从 5 个涨到 50 个,调度开销一分钱不涨。切换本身的代价也很固定——Cortex-M 硬件在异常入口自动压栈 R0-R3、R12、LR、PC、xPSR 八个寄存器,软件再补压 R4-R11,走 PendSV 异常完成,主频 100MHz 下实测在 2 到 4 微秒之间,而且这个数字在不同任务之间几乎不波动。

代价是什么?优先级必须静态规划好。FreeRTOS 里如果configMAX_PRIORITIES设成 7,你就只有 7 个优先级可用,几十个任务要挤在这 7 层里,同优先级的任务之间只能靠时间片轮转,一旦某个任务在临界区里多待了一会儿,同级的其他任务就得等。这是用灵活性换确定性,很划算,但规划的时候要脑子清楚。

/* FreeRTOS 里正确规划优先级的典型做法:按速率单调分配 */ #define PRIO_SAFETY_TRIP (configMAX_PRIORITIES - 1) /* 安全急停,最快 */ #define PRIO_MOTOR_LOOP (configMAX_PRIORITIES - 2) /* 1kHz 电流环 */ #define PRIO_ENCODER_SCAN (configMAX_PRIORITIES - 3) /* 2kHz 编码器采样 */ #define PRIO_COMM_PROTO (configMAX_PRIORITIES - 5) /* 通信协议栈 */ #define PRIO_LOG_FLUSH (1) /* 日志落盘,最慢 */

速率单调调度有个结论值得记一下:任务周期越短,优先级越高,这样在 CPU 利用率不超过某个上界(n 个任务时约为 n×(2^(1/n)-1),n 趋于无穷时收敛到 69.3%)的前提下,所有任务的可调度性是可以静态证明的。这个 69.3% 的上界看着保守,但它换来的是"我能证明它不会错过截止时间",这比"我测了一万次都没超时"强太多。

2.2 Linux 的红黑树与 EEVDF:公平性是怎么吃掉确定性的

Linux 的 CFS 调度器走的是完全不同的路子。它给每个任务维护一个vruntime(虚拟运行时间),所有可运行任务挂在一棵按vruntime排序的红黑树上,每次调度取最左边那个——也就是"被亏欠最多"的任务。这个设计的哲学是公平:谁跑得少谁先跑,长期看每个任务的 CPU 占比正比于它的权重(由 nice 值决定)。

红黑树的查找是 O(log n),插入删除也是 O(log n),比 RTOS 的 O(1) 慢,但这不是关键。关键在于 CFS 引入了一堆让最坏情况无法分析的机制:

  • 调度周期:sysctl_sched_latency默认 6ms,任务多的时候按sched_min_granularity(默认 0.75ms)摊薄。也就是说一个刚被唤醒的普通任务,最坏可能要等到下一个周期边界才被调度,这个等待本身就有毫秒量级。
  • 负载均衡:多核系统上,内核会周期性把任务在 CPU 之间搬来搬去,搬的过程涉及锁、IPI(核间中断)、迁移,耗时不可预测。
  • 组调度:cgroup 一开,调度层级变深,任务被调度的路径变长。
  • 带宽控制:CPU cgroup 的 quota 机制会让任务在配额用完时被强制 throttle,一 throttle 就是几十毫秒起。

内核 6.6 之后 CFS 被 EEVDF(Earliest Eligible Virtual Deadline First)替换,思路是用 lag 和 virtual deadline 来平衡公平与延迟,对交互型任务更友好。但对实时任务来说结论没变:这是一套为公平吞吐设计的调度器,不是为截止时间设计的调度器。

那么 SCHED_FIFO 和 SCHED_RR 呢?它们确实独立于 CFS,优先级 0-99,数值大的先跑,同优先级 FIFO 不带时间片、RR 带时间片。只要高优先级实时任务就绪,CFS 上的普通任务一个都跑不了。听起来很美,但有三个前提经常被忽略:

  1. 实时任务的优先级必须严格高于所有普通任务,且实时任务之间不能互相饿死。
  2. 内核里所有可能阻塞实时任务的路径都被处理过——这正是 PREEMPT_RT 干的事。
  3. 中断不归调度器管,硬件中断该来还是来,该抢占还是抢占。

2.3 PREEMPT_RT 改了什么,又改不了什么

PREEMPT_RT 补丁集干的核心工作可以概括成一句话:把内核里所有不可抢占的区域尽可能缩短或消除。具体手段有这几条:

  • 自旋锁spinlock_t改造成基于rt_mutex的实现,支持优先级继承,等待锁的实时任务不会因为持有者被低优先级任务抢占而无休止地等下去。
  • 硬中断线程化:除了少数标记为IRQF_NO_THREAD的关键中断,其余中断处理程序变成内核线程,可以被调度、可以被抢占,中断里也不再关本地中断。
  • 高精度定时器全面接管,去掉原来的 tick 依赖。
  • RCU 回调改成可抢占,per-CPU 变量的临界区缩短。
  • 关中断临界区被替换成"关中断+自旋锁"组合,或者局部锁。

改完之后,很多实测数据确实漂亮:在 x86 平台上跑cyclictest -p 99 -m -i 1000,空载延迟可以从几百微秒降到 10 微秒以内,加负载后最坏值通常能控制在几十微秒。这个水平已经能满足绝大多数工业控制、机器人、音视频同步的需求。

但改不了的东西同样要命,而且这些恰好是最容易被忽略的:

改不了的项为什么影响实时性应对思路
硬件本身的延迟SMI、内存控制器仲裁、DMA 抢总线带宽选无 SMI 的平台,限制 DMA 突发长度
Cache 与 TLB 争用实时任务被切走再切回,缓存全失效绑核隔离、缩小工作集、避免大数组跨 Cache 行
固件与 BIOS 行为电源管理、频率调节、温度节流锁频、关 C-state、governor 设 performance
内存带宽饱和多核并发访存会拉长单次访问限制非实时核的负载,用 QoS 寄存器
物理内存本身的故障处理ECC 纠错、页迁移通常不可控,只能接受

所以正确的表述是:PREEMPT_RT 把 Linux 从"软实时"提升到了"准硬实时",但它给不出数学上的最坏情况上界。如果你的系统需要出安全认证、需要对最坏响应时间做形式化证明,那还是老老实实上 RTOS 或者异构方案。如果只是需要一个抖动在几十微秒内、大部分时间靠谱的平台,PREEMPT_RT 完全够用。

3. 中断路径与内存模型:两个不在宣传页上的差异

3.1 上半部和下半部:Linux 为什么要把一个中断拆成两半

裸机和 RTOS 处理中断的方式非常直接:中断来了,进 ISR,该读寄存器的读寄存器,该清标志的清标志,该置信号量的置信号量,然后退出。整个路径短得可以用周期数来算。以 Cortex-M 为例,从中断请求到 ISR 第一条指令执行,硬件固定 12 个周期,如果发生尾链优化(tail-chaining)只要 6 个周期,100MHz 主频下是 60 到 120 纳秒。这个数字是芯片手册写死的,你在设计阶段就能把它代入时序计算。

Linux 不行。原因很简单:Linux 要管理的外设太多,中断源太多,而且中断上下文里不能睡眠、不能拿普通 mutex、不能访问用户空间,一旦在 ISR 里干活干太久,整个系统的响应都会被拖垮。所以它把中断处理拆成上半部和下半部:

  • 上半部(hardirq)在关中断上下文里跑,只做最紧急的事:读状态寄存器、清中断标志、把数据搬到缓冲区、触发下半部。要快,通常要求几微秒内结束。
  • 下半部(softirq、tasklet、workqueue、threaded IRQ)在开中断的情况下跑,可以做更重的活。

这个拆分对实时性的影响是双面的。好处是中断被尽快放行,不会因为某个驱动写了烂代码就把整个系统卡死;坏处是一次完整的数据处理要跨两个阶段,中间可能被别的中断插队、可能被调度器推迟,端到端延迟从"纳秒级固定值"变成了"微秒到毫秒级的浮动值"。

线程化中断(threaded IRQ)是 PREEMPT_RT 的标配,用request_threaded_irq()注册,硬中断处理函数只做"确认中断、唤醒线程",真正的处理在内核线程里跑。好处是中断处理可以被优先级调度、可以睡眠,坏处是又增加了一次唤醒和调度的开销。

/* 线程化中断的注册方式:handler 快速返回,thread_fn 里做重活 */ static irqreturn_t my_handler(int irq, void *dev_id) { struct my_dev *d = dev_id; /* 只做最小动作,把状态存起来 */ d->pending |= readl(d->base + REG_STATUS); return IRQ_WAKE_THREAD; /* 唤醒下面的线程 */ } static irqreturn_t my_thread_fn(int irq, void *dev_id) { struct my_dev *d = dev_id; /* 这里可以睡眠、可以拿 mutex、可以 copy_to_user */ process_data(d); return IRQ_HANDLED; } ret = request_threaded_irq(irq, my_handler, my_thread_fn, IRQF_TRIGGER_RISING, "mydev", d);

踩过的坑在这里:线程化之后,中断线程的优先级要显式设置。默认情况下它继承注册时的优先级,如果不设成实时优先级,一个低优先级的后台任务都可能把它挤掉。我见过有人把整个驱动都线程化了,结果中断线程优先级还是 SCHED_OTHER,实测延迟比不线程化还差。用chrt -f 80 <pid>或者在线程创建时设sched_setscheduler都行,关键是别忘了。

3.2 虚拟内存带来的抖动:缺页、OOM 和内存池的对撞

RTOS 的内存分配通常有几种固定套路:完全静态分配(编译期决定,零运行时开销)、固定块内存池(按块大小分若干个池,分配就是链表摘一个节点,O(1))、首次适配加合并的堆(FreeRTOS 的 heap_4,最坏情况跟碎片程度有关)。无论哪种,共同点是分配耗时可以有上界,而且不会出现"分配失败后系统去回收"这种行为。

Linux 的内存管理是一套多层结构:buddy 分配器管物理页,slub 管小对象缓存,虚拟内存管进程地址空间。一个malloc背后可能是:检查 slub 空闲链表(快)→ 没有就走 buddy 分配新页(慢)→ 再没有就触发页面回收(更慢)→ 还是不够就触发 OOM Killer(最慢,直接杀进程)。这个链条的尾部动辄几百毫秒。

对实时任务威胁最大的是缺页异常。用户态进程第一次访问某块内存时,物理页还没分配,CPU 触发 page fault,内核走一遍handle_mm_fault,分配物理页、清零、建页表、刷 TLB,整个过程在 x86 上实测 1 到 10 微秒,但如果赶上了内存紧张需要回收,几百微秒甚至毫秒都有可能。

规避手段有这些,实际项目里基本要全上:

手段作用命令或 API
锁定内存防止换出到交换区mlockall(MCL_CURRENT | MCL_FUTURE)
预热缓冲区提前触发缺页,把开销挪到启动阶段启动时 memset 一遍所有工作缓冲
关闭透明大页避免 khugepaged 后台合并造成抖动echo never > /sys/kernel/mm/transparent_hugepage/enabled
关闭交换彻底消除换页可能swapoff -a
限制内存水位避免实时任务分配时触发直接回收预留内存、调vm.min_free_kbytes
用大页预分配减少 TLB missmmap加MAP_HUGETLB

还有一个 RTOS 上根本不存在的概念:OOM Killer。Linux 在内存耗尽时会挑一个"最该杀"的进程干掉,评分逻辑综合考虑内存占用、运行时长、是否是 root 等等。这个机制对服务端很友好,对实时系统就是灾难——你永远不希望自己的控制进程在某次内存紧张时被内核悄悄杀掉。办法是把关键进程的oom_score_adj设成 -1000,让它免于被杀,但代价是内存真的耗尽时系统会整体挂死而不是杀掉一个进程,两害相权得自己权衡。

3.3 单地址空间与进程隔离:一个崩了是不是全崩

RTOS 上的任务全部共享同一个地址空间,没有用户态和内核态之分,任务直接操作外设寄存器、直接访问全局变量。好处是通信零成本——A 任务写个全局变量,B 任务立刻能读到,不需要任何系统调用;坏处是任何一个任务的野指针都能把整个系统写花,而且调试起来极其痛苦,因为你看到一个任务崩溃,但根因可能在三天前另一个任务越界写了别人的栈。

Linux 用 MMU 把每个进程隔离开,进程只能访问自己的地址空间,越界访问立刻触发 SIGSEGV,进程死掉但系统活着。这个隔离性对复杂业务是刚需——一个跑着几万行应用代码的网关,不能因为某个解析库的 bug 就整机重启。但它带来的开销也是实打实的:

  • 每次系统调用要在用户态和内核态之间切换,x86 上用syscall指令,虽然不是特别贵(几百纳秒),但在高频调用场景下累积起来很可观。
  • 进程间通信必须走内核,管道、消息队列、共享内存、socket,每种都有拷贝或者同步开销。
  • 上下文切换要换页表、刷 TLB,比 RTOS 的任务切换重得多。

这里有个经常被忽略的取舍:如果你的应用本身就是"一堆互相独立的服务",Linux 的进程隔离是净收益,崩一个不影响其他;如果是一个紧耦合的高频控制回路,进程隔离反而是负担,此时用单进程多线程(共享地址空间但仍有内核态切换)或者干脆退回 RTOS 更合适。

4. 资源账本与工具链:从 KB 到 MB 的分界线

4.1 启动时间和镜像体积差了一个数量级还多

这一项对比最直观,也最容易拿来当选型依据。一个裁剪得当的 FreeRTOS 或者 Zephyr 工程,从复位向量到main()里的业务循环,在 Cortex-M4 上通常在 5 到 20 毫秒之间,其中大部分时间花在时钟初始化和外设配置上,内核本身的初始化只有几微秒——它只需要建几个链表、初始化一下 SysTick。整个镜像(含内核+驱动+应用)控制在 64KB Flash 以内是常规操作,RAM 跑 8KB 也很常见。

Linux 完全是另一个量级。即使经过深度裁剪,内核镜像(zImage 或者 uImage)也在 1.5MB 到 3MB 之间,跑起来之后的内存占用(内核数据结构、slab 缓存、页表)通常在 8MB 到 16MB,加上根文件系统,整个系统落地的存储需求在 32MB 以上。启动时间上,从 bootloader 到用户态第一个进程,一般 1 到 5 秒。

但这个差距是可以大幅压缩的,压缩手段值得记一下:

优化手段效果代价
裁剪内核配置(make localmodconfig)镜像从 8MB 降到 2MB 级别反复试错,容易漏功能
用 initramfs 内嵌根文件系统省掉挂载存储的时间镜像变大,更新麻烦
关掉控制台输出(quiet加loglevel=0)省 200-500ms,串口打印非常慢出问题看不到日志
预链接与 XIP减少重定位和加载时间需要工具链支持
跳过不必要的驱动探测省几十到几百毫秒要精确知道硬件配置
并行化 init 脚本省几百毫秒启动脚本复杂度上升

我实测过一个工业网关,原版启动 4.2 秒,经过裁剪内核、内嵌 initramfs、静默启动、并行化服务这几步之后,从复位到主业务进程就绪压到了 780 毫秒。再往下压就很困难了,因为内核初始化的固定开销摆在那里。如果业务要求"上电 100 毫秒内必须输出",那 Linux 就别想了,必须 RTOS 或者异构双核。

4.2 出问题之后怎么查:两套完全不同的调试哲学

RTOS 侧的调试体验,说实话是"简单粗暴但有效"。硬件断点、单步、看内存,用 SWD 或者 JTAG 接上调试器就行,程序跑飞了直接暂停看 PC 和栈,栈回溯不一定完整但通常够用。更狠的手段是 SWO 或者 ETM 跟踪,能把指令流实时吐出来,虽然带宽有限、配置麻烦,但真的能定位那种偶发的时序问题。打印调试靠 UART,注意波特率和缓冲区,别在主循环里同步等串口发完,那是新手最常见的性能杀手。

Linux 侧的调试是另一套体系,工具极其丰富但学习曲线陡:

# 查调度延迟分布,重点看 Max 那一列而不是 Avg cyclictest -p 99 -m -i 1000 -d 0 -n -h 400 -q # 查硬件本身引入的延迟(SMI、固件等),这个不受内核控制 hwlatdetect --duration=60 # 追踪某进程的所有系统调用和耗时 strace -T -p $(pidof mycontrol) # 用 ftrace 看中断关闭了多久 echo 1 > /sys/kernel/debug/tracing/events/irq/enable cat /sys/kernel/debug/tracing/trace_pipe # 看哪个进程在抢 CPU perf top -e sched:sched_switch # 查最坏情况的中断延迟来源 perf record -e irq:softirq_entry -a -- sleep 10

这几个工具的组合拳基本能覆盖八成以上的实时性问题。cyclictest看整体延迟分布,hwlatdetect排除硬件干扰,strace看用户态的系统调用开销,ftrace进内核看具体路径,perf做采样分析。相比之下 RTOS 侧基本只有"断点加打印"和"逻辑分析仪"两板斧。

提示:hwlatdetect如果测出很大延迟,先别急着改代码,去 BIOS 里关 SMI、关 C-state、关 C1E、关超线程,很多时候问题出在固件层而不是操作系统层。

5. 别做成非此即彼:四种实际可落地的组合架构

5.1 异构双核:A 核跑 Linux 做业务,M 核跑 RTOS 做控制

这是目前工业、汽车、机器人领域最主流的方案。SoC 内部集成一个应用处理器核(跑 Linux)和一个实时微控制器核(跑 FreeRTOS 或裸机),两者通过共享内存、mailbox 中断、或者片上总线通信。Linux 那侧负责网络、协议、界面、日志、OTA,实时那侧负责电机环、编码器采样、安全联锁。

通信机制常见的有三种,选择要看数据量和实时性要求:

机制数据量延迟适用场景
Mailbox 中断极小(几个寄存器字)微秒级事件通知、命令触发
共享内存 + 中断通知大(KB 到 MB)微秒到几十微秒采样数据批量传输、参数下发
RPMsg / OpenAMP 框架中几十微秒需要标准化协议栈时

关键设计点在于共享内存的同步。裸写共享内存会踩缓存一致性的坑:M 核写的数据可能还在自己的写缓冲里,A 核读到的就是旧值。标准做法是在共享内存区前后各放一个序号计数,写入方写完数据再更新序号,读取方先读序号、读数据、再读一次序号,两次一致才认为数据有效(这就是所谓的 seqlock 思想)。缓存层面,如果是非一致性的 SoC,需要显式调用 cache flush 和 invalidate,这些 API 各厂商不一样,但语义是一致的。

举个典型的双核通信数据结构:

/* 共享内存区,两侧约定同一个地址和同一份结构体定义 */ struct shared_ctrl { volatile uint32_t seq_w; /* 写入方序号 */ volatile uint32_t seq_r; /* 读取方序号 */ volatile uint32_t cmd; /* 命令字 */ volatile int32_t setpoint[8];/* 8 轴目标位置 */ volatile int32_t feedback[8];/* 8 轴实际位置 */ volatile uint32_t status; /* 状态位 */ uint8_t _pad[64]; /* 对齐到 cache line,避免伪共享 */ }; /* 写入方:先写数据,再递增序号,最后发 mailbox 中断 */ static void shm_write(struct shared_ctrl *s, const int32_t *sp) { for (int i = 0; i < 8; i++) s->setpoint[i] = sp[i]; __sync_synchronize(); /* 内存屏障,保证顺序 */ s->seq_w++; mb_send(MAILBOX_CH0, 1); /* 通知对侧 */ }

_pad[64]那行不是随手写的,是为了让结构体对齐到 64 字节缓存行,防止两侧同时写不同字段时发生伪共享,把对方那条缓存行踢掉。这个细节很多资料里不讲,但在高频通信场景下能差出几倍的延迟。

5.2 单核实时扩展与虚拟化:两条更省硬件成本的路

不是所有项目都愿意上双核 SoC,有时硬件成本压得死,只能在单核上想办法。这条路有几种走法:

实时扩展内核(双内核方案):在主内核旁边挂一个实时内核,接管中断控制器,实时任务跑在实时核上,普通任务跑在 Linux 上。这类方案的优点是实时性接近硬件极限(延迟可以做到个位数微秒),缺点是维护成本高——实时核和主核之间的补丁适配经常跟不上内核版本,而且两套驱动模型并存,开发难度大。近年新项目里用得少了,但存量系统里还不少。

PREEMPT_RT 单内核:前面讲过,改造成本最低,生态最好,延迟几十微秒,适合大部分"准实时"场景。它的优势是能用完整的 Linux 驱动生态,不用为实时任务单独写一套驱动。

虚拟化方案:在硬件上跑一个 Hypervisor,把 CPU 核静态分给两个虚拟机,一个跑 Linux,一个跑 RTOS。Jailhouse 这类静态分区方案延迟很低,因为虚拟机之间没有调度竞争,核是独占的。代价是要处理虚拟化层的中断注入和外设直通,配置复杂度不低。

这几种路线的对比,我个人的经验是:新项目如果延迟要求在 50 微秒以内、且能接受双核硬件,直接上异构方案,省心;延迟要求在 100 微秒到 1 毫秒之间,PREEMPT_RT 足够,别折腾;延迟要求极高且必须单核,才考虑双内核或者虚拟化,同时做好长期维护的心理准备。

5.3 一张倒推表:先确定截止时间,再选操作系统

选型的时候最忌讳的流程是"先定操作系统,再想办法满足需求"。正确的顺序是从需求倒推:先明确最紧的截止时间是多少、允许的超时概率是多少、系统失效的后果是什么。

典型场景最紧截止时间允许抖动推荐方案
电机电流环 / 逆变器 PWM10-50 μs< 2 μsRTOS 或裸机,单核独占
工业伺服位置环100-500 μs< 10 μsRTOS + 硬件加速
汽车安全气囊点火必须确定性数学可证专用安全芯片 + RTOS
机器人关节控制1-2 ms< 100 μs异构双核,M 核跑 RTOS
视觉引导抓取10-30 ms< 5 msLinux 常规内核即可
工业网关 / 边缘盒子100 ms 级不敏感Linux 随便用
车载信息娱乐交互响应 100 ms 级不敏感Linux + 硬件合成器
音视频同步播放5-20 ms< 1 msPREEMPT_RT 或专用 DSP

这张表不是绝对标准,但逻辑是对的:截止时间在微秒级、且要求数学可证明,选 RTOS;截止时间在毫秒级以上、业务逻辑复杂、需要网络和文件系统,选 Linux;两者都要,选异构。

6. 六个真实踩过的坑:从优先级反转到测试假阳性

6.1 优先级反转:用错同步原语,高优先级任务被低优先级堵死

这个坑在 RTOS 上比在 Linux 上更容易踩,因为 RTOS 的同步原语种类多,名字还像。FreeRTOS 里xSemaphoreCreateBinary()创建的二值信号量不支持优先级继承,xSemaphoreCreateMutex()创建的互斥量才支持。用二值信号量当锁用,一旦发生"低优先级任务持锁被中优先级任务抢占,高优先级任务等锁"的经典三段式,高优先级任务会被无限期阻塞,而且系统看起来一切正常,只是偶尔超时。

Linux 侧对应的问题是pthread_mutex的默认属性是PTHREAD_MUTEX_NORMAL,同样不做优先级继承。要用得显式设置:

pthread_mutexattr_t attr; pthread_mutexattr_init(&attr); pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT); pthread_mutex_init(&mtx, &attr);

而且用户态的优先级继承要真正生效,还得保证内核侧的 futex 实现支持 PI(CONFIG_FUTEX_PI),PREEMPT_RT 里是默认开的,普通内核里要确认。

排查这类问题的手段:在 RTOS 上开configUSE_MUTEXES和configUSE_TRACE_FACILITY,用运行时统计看每个任务的最大阻塞时间;在 Linux 上用perf sched latency看每个任务的最大调度延迟,一旦某个实时任务的最大延迟突然跳到几十毫秒,八成是在等一把没有 PI 的锁。

6.2 cyclictest 的空载数据毫无意义

新手最常见的操作是:装完 PREEMPT_RT,跑一下cyclictest,看到 Max 是 8 微秒,欢呼,上线。结果在现场跑两天就出问题。原因是cyclictest空载时测的是"系统什么都不干时定时器能多准",这个数字当然漂亮,但它完全不反映真实负载下的表现。

有效的测法必须叠加真实负载:

# 后台制造内存压力 stress-ng --vm 2 --vm-bytes 512M --timeout 300s & # 制造 IO 压力(模拟日志写入) stress-ng --hdd 2 --hdd-bytes 256M --timeout 300s & # 制造网络压力 stress-ng --sock 4 --timeout 300s & # 同时测延迟,注意 -h 参数记录直方图 cyclictest -p 99 -m -i 200 -d 0 -n -h 400 -q -D 300s

重点看的是Max 和 99.999 分位,而不是 Avg。一个系统的 Avg 是 5 微秒、Max 是 3 毫秒,和 Avg 是 50 微秒、Max 是 60 微秒相比,后者才是能用的。控制系统的失效率只跟最坏值有关。

还有一个隐蔽的假阳性来源:cyclictest自己也可能被干扰。如果它没绑核,可能被调度到负载最重的那个核上,测出来的数字偏高;如果绑了核但那个核上有中断,数字又会偏。标准做法是用-a指定 CPU,配合isolcpus把核隔离开,同时把中断亲和性都挪到别的核上。

6.3 中断亲和性与 CPU 隔离没配对,白折腾一场

说到隔核,这里有个完整的配置链路,缺一环就白干。目标是让 CPU3 专门跑实时任务,其他核跑普通业务,所有中断都不许打到 CPU3 上。

# 1. 内核启动参数里隔离 CPU3,同时把 RCU 回调也挪走 # 在 bootloader 的 cmdline 里加: isolcpus=3 nohz_full=3 rcu_nocbs=3 irqaffinity=0-2 # 2. 启动后确认隔离生效 cat /sys/devices/system/cpu/isolated # 应输出 3 # 3. 把实时任务绑到 CPU3 taskset -c 3 ./my_control_app # 4. 逐个检查中断亲和性,确保没有中断落在 CPU3 for i in /proc/irq/*/smp_affinity_list; do echo "0-2" > $i 2>/dev/null done # 5. 检查是否还有中断落在 CPU3 上 watch -n1 'cat /proc/interrupts | awk "{print \$1, \$2, \$3, \$4}"'

第 4 步那个循环要注意,有些中断是 per-CPU 的(比如本地 APIC 定时器、IPI),不允许修改亲和性,会报错,忽略即可。真正的验证在第 5 步:跑一段时间后看/proc/interrupts里 CPU3 那一列是不是基本全零,如果还有数字在涨,说明有中断没被迁走,得单独查。

nohz_full的作用是让隔离核上只有一个任务时停止调度时钟 tick,减少干扰。但这个特性有前提:核上必须确实只有一个可运行任务,多一个都不行,否则 tick 会重新打开,配置白费。

6.4 电源管理:一个 C-state 让延迟翻十倍

这个坑极其隐蔽,因为它是固件层的行为,操作系统层面看不到任何异常。现象是:系统空闲一段时间后,第一次中断的响应延迟比平时高一个数量级,跑起来之后又恢复正常。

根因是 CPU 进入了深度 C-state(C3 或更深),退出这个状态需要几十到几百微秒。规避办法是在 BIOS 里关掉深度 C-state,或者在内核里限制:

# 查看当前 C-state 使用情况 cat /sys/devices/system/cpu/cpu0/cpuidle/state*/name cat /sys/devices/system/cpu/cpu0/cpuidle/state*/usage # 禁止进入 C1 以上的状态(只允许 C0) # 注意:不同平台的 state 编号不一样,先看清楚哪个是哪个 echo 1 > /sys/devices/system/cpu/cpu0/cpuidle/state1/disable # 对隔离核更彻底:用 idle=poll 让它根本不睡 # 内核启动参数里加 idle=poll(代价是功耗飙升)

同样的道理,CPU 频率调节(cpufreq)也要锁死。ondemand或者schedutilgovernor 会在负载低时降频,负载上来之后再升频,升频的过程有几十微秒的延迟,而且升频期间执行的任务速度是慢的。实时系统一律设成performance:

# 所有核锁到最高频率 for i in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do echo performance > $i done # 确认没有频率波动 grep "cpu MHz" /proc/cpuinfo

idle=poll这个参数我要多说一句:它让 CPU 在空闲时不进任何低功耗状态,而是空转轮询。延迟确实最优(接近零),但功耗可能从 5W 涨到 40W,散热和电源都要重新算。只在确实需要极致延迟、且散热设计有余量的场合用。

6.5 RTOS 上的栈溢出检测只覆盖了半边天

回到 RTOS 侧,有个问题跟 Linux 的缺页一样讨厌:栈溢出。FreeRTOS 提供configCHECK_FOR_STACK_OVERFLOW,设成 1 时检查栈指针是否越界,设成 2 时在栈底预填魔数再检查魔数是否被改。听起来挺全,但实际的保护范围有限:

  • 方法 1 只能发现"栈指针已经跑到栈外",发现不了"写越界但指针又回来了"的情况。
  • 方法 2 只能发现溢出了至少若干字节的情况,如果溢出量很小,恰好没写到魔数区域,就查不出来。
  • 两种方法都是在任务切换时检查,如果某个任务一直不切走,溢出多久都不会被发现。

更靠谱的做法是:先用uxTaskGetStackHighWaterMark()在开发阶段把每个任务的实际栈用量测出来,留 30% 到 50% 余量,然后在链接脚本里给栈区前后各放一段保护区,用 MPU(内存保护单元)把它们设成不可访问,一旦踩到立刻触发硬件异常。这才是真正兜底的手段,只不过配置麻烦,很多项目图省事就跳过了。

提示:中断服务程序用的栈和任务栈是分开的(Cortex-M 上中断默认用主栈 MSP),主栈的大小在启动文件里配,很多人只关注任务栈把主栈忘了,中断一嵌套深就踩爆。

6.6 移植代码时把"能跑"当成"能实时"

最后一个坑最普遍:把 RTOS 上的代码往 Linux 上搬,编译通过、功能正常、跑一天不崩,就认为迁移完成。实际上代码里到处是破坏实时性的写法,只是负载低的时候暴露不出来。

典型的几类:

  • 在实时线程里调用printf。标准输出可能被重定向到串口、文件、或者 systemd journal,每一步都可能阻塞,一次printf耗时几百微秒到几毫秒不等。
  • 在实时线程里做new/malloc。glibc 的分配器有锁,多线程竞争时会阻塞。
  • 在实时线程里读文件或者访问/sys、/proc。这些操作背后是内核的文件系统路径,耗时完全不可控。
  • 用std::mutex保护实时数据。标准库的 mutex 在 Linux 上通常走 futex,无竞争时很快,一有竞争就进内核睡眠,唤醒延迟不可控。
  • 日志用同步写。正确做法是写环形缓冲区,由低优先级的日志线程异步落盘。

改造思路是把所有"可能阻塞"的操作从实时路径上摘出去,实时线程只做计算和标志位读写,剩下的活交给普通线程。这个改造的工作量往往比想象中大,因为阻塞点藏得很深,需要逐个函数审一遍。

7. 我个人的几条实操判断

做了这些年,选型上慢慢形成了几条自己的判断标准,不一定适用于所有人,但至少能少走点弯路。

第一条:先看失效后果,再看延迟数字。一个 LED 闪烁延迟 100 毫秒没人管,一个伺服环路延迟 100 微秒可能就是撞机。很多项目一开始纠结"我们到底算不算实时",其实只要问一句"超时了会怎样",答案立刻清晰。

第二条:能不上异构就不上异构。双核方案看着很美,实际落地时共享内存的同步、双份代码的维护、两侧固件版本的对齐、联合调试的复杂度,都是隐性成本。能在单核上解决就先单核,实在压不住了再考虑拆核。

第三条:PREEMPT_RT 值得先试。它的改造成本最低——换个内核、重新编译驱动、调几个参数——如果实测下来满足要求,能省掉大量架构决策。很多团队一上来就设计异构方案,最后发现单核加实时补丁完全够用。

第四条:测试环境一定要复现现场负载。实验室里空载测出来的延迟数据,参考价值接近于零。测试时至少要把内存压力、IO 压力、网络流量、中断风暴都模拟上,而且要看最坏值而不是平均值。

第五条:留够裕量,别压着极限设计。算出来最坏延迟 900 微秒、截止时间 1 毫秒,这不叫满足要求,这叫定时炸弹。温度变化、器件老化、固件升级都可能让延迟涨个 20%,留一倍裕量才踏实。

最后再分享一个测量上的小技巧。判断一个系统的实时性,与其在软件里打时间戳,不如直接看硬件:在中断入口和出口各翻转一个 GPIO,用逻辑分析仪或者示波器抓波形,这样测出来的延迟不包含任何软件测量本身的开销,而且能看到抖动分布的形态。我习惯在关键路径上留两个测试点,调试完也不用删,量产版本的 PCB 上空焊就行,万一现场出问题还能飞线上去抓。这个习惯帮我定位过好几次"软件指标看着正常但实际就是卡了"的诡异问题。