Linux 到底能不能做实时?TSC 抖动测试10纳秒!
长期被认为"天生不适合做实时"的 Linux,在合理调试配置与核心隔离之后,也能获得接近裸机级的确定性。本文用一套可复现的 TSC 抖动测试,把这场吵了很多年的争论变成一条可以精确测量的频谱:在 AMD Ryzen 9 9950 的完全隔离核心上,1 小时、数千亿次采样中,最大抖动仅约 10 纳秒,超过 100 个时钟周期(约 20 纳秒)的抖动事件一次都没有。
一、这场争论本身,可能就问错了
实时系统圈子里,"Linux 到底能不能做实时操作系统",是一个吵了很多年的问题。
反对者的理由看起来相当充分:Linux 本质上是为吞吐量而生的通用操作系统,它拥有庞大的调度子系统、复杂的锁机制、深度的电源管理栈,以及无处不在的内核线程和延迟工作队列。即便打上 PREEMPT_RT 补丁、把内核中大部分临界区转化为可抢占代码,也常被看作"在一个天生不具备确定性的系统之上,强行施加插队机制"。
支持者同样有理有据:PREEMPT_RT 早已合入主线内核,约八成代码就位;高频交易、工业控制、机器人等领域已有大量成功部署,调度延迟稳定在几十微秒以内。
但这场争论,可能本身就问错了——实时性的本质,从来不是"能不能"的二选一。
二、实时性不是"开关",而是一条"频谱"
任何操作系统,只要它管理并发、仲裁资源、响应中断,就必然会引入时间上的不确定性。Linux 的复杂度高于传统 RTOS,意味着同等条件下它的抖动上界确实更高——但这不等于"不能做实时",而是意味着"需要更多工程手段来逼近确定性"。
反过来看,传统 RTOS 一旦迁移到多核平台,同样要面对核间中断、全局锁、缓存一致性协议、核间负载均衡等复杂机制,其复杂度并不亚于一个精简配置的 Linux。把传统 RTOS 等同于"确定性的保证",其实是一种幸存者偏差。
世界上唯一能做到"绝对确定"的执行环境,只有裸机:没有调度器、没有中断框架、没有锁,代码就是指令序列本身,执行路径完全可预测。
所以,正确的提问方式不是"哪个系统能做实时",而是:经过特定的工程配置后,你的系统抖动能逼近裸机到什么程度?用什么样的方法,才能把这段距离精确地测出来?
三、从 nohz_full 到"完全隔离":把核心"隔离到绝"
Linux 社区其实早就给出过一个答案——nohz_full 核心隔离:当隔离核心上只有一个可运行任务时,内核停止向它发送周期性的时钟中断(tick),从而消除最大的周期性噪声源。
但这并不彻底。根据 Linux 内核官方文档(cpu-isolation.rst)与社区实践,nohz_full 隔离的核心仍会遭受至少五类"残留干扰":
- 1Hz 残余 tick:housekeeping 核心仍会代表隔离核心执行每秒一次的远程调度统计,某些情况下仍会落在隔离核心上;
- 硬件中断未隔离干净:网卡、NVMe 等设备中断默认由 irqbalance 分配,即便设置了 irq affinity,部分 managed IRQ 仍可能落在隔离核心上;
- 核间中断(IPI):TLB 刷新、函数调用 IPI(smp_call_function)、调度迁移等机制会无条件中断目标核心——这是 nohz_full 从未解决的盲区;
- RCU 回调噪声:RCU 状态机仍可能在隔离核心上产生干扰;
- SMI(系统管理中断):来自 BIOS 层的"隐形"中断,对操作系统完全透明,任何内核参数都无法控制它。
一句话概括:这就像给运动员清好了跑道,却仍有陌生人随时闯进来。
望获OS 的做法,是把隔离"做绝":
- 禁用系统中断:不再只是"尽量不分配",而是从根本上禁止隔离核心响应任何系统中断;
- 禁用核间中断 IPI:阻断来自其它核心的 TLB flush、函数调用 IPI 等一切核间干扰;
- 彻底清除残余 tick:消除 1Hz 远程调度 tick 的干扰;
- BIOS 层面规避 SMI:通过配置限制深 C-state,降低 SMI 发生频率。
处理之后,隔离核心上不再存在任何操作系统层面的异步干扰源——它表现得像裸机一样确定。这是一句可以验证的工程声明,而验证它,需要一把比传统工具更硬的"尺子"。
四、为什么 cyclictest 不够?
cyclictest 是 Linux 实时性评测的事实标准:让一个高优先级线程按固定间隔唤醒,再测量实际唤醒时间与预期时间的偏差,从而统计调度延迟。它简单、易用,但也有几个结构性局限:
- 测的是调度行为的确定性,而非系统抖动的确定性。cyclictest 的核心路径是"定时器到期→硬中断→唤醒线程→调度器调度→线程执行测量",得到的是调度层面的延迟。而"调度延迟低"不等于"CPU 执行指令时不会被 SMI、IPI、缓存迁移等异步事件打断"——后者才是指令流的真实抖动,cyclictest 对此完全"无感"。
- 结果偏乐观。cyclictest 使用 clock_nanosleep 唤醒,定时器到期路径直接在硬中断上下文执行;而真实应用通常经过线程化中断上下文,存在额外间接开销,因此测得的是"下界"而非真实延迟。
- 黑盒测量,无法定位根因。它只告诉你延迟是多少,不告诉你为什么。借助 ftrace 等工具排查时,tracing 本身的开销又会掩盖真正的延迟源。
- 周期性唤醒会漏测非周期性干扰。如果 SMI、stop_machine、模块加载恰好发生在两次唤醒之间,其影响可能被完全错过。
五、TSC 抖动测试:用一把硬件级的"尺子"直接量抖动
如果目标是评估"隔离核心上的执行环境是否像裸机一样确定",就要直接在指令执行层面测量时间不确定性。我们选择的基准,是 x86 处理器的 TSC(Time Stamp Counter,时间戳计数器)。
TSC 是处理器内部的 64 位计数器,每个时钟周期自增一次,通过 RDTSC 指令读取。它天然具备四个非常适合当"标尺"的特性:
- 恒定频率(Invariant TSC):不随 CPU 频率缩放和电源状态变化;
- 跨核同步:多核处理器上,所有核心的 TSC 保持一致;
- 极低读取开销:RDTSC 指令本身仅需几十个时钟周期;
- 不可被软件干扰:计数过程是纯硬件的,不存在软件层面的干扰。
方法本身非常朴素:在一个紧凑循环里,背靠背连续执行两次 RDTSC,计算差值。 两次读取之间的指令序列是固定的,如果执行环境绝对确定,这个差值应当纹丝不动;任何异步干扰——中断、IPI、SMI、调度抢占——都会让差值"跳"一下。于是,差值的波动,就成了环境抖动的直接度量。
为了让这把"尺子"足够灵敏,测试代码做了几个刻意设计:
- 循环内全部使用寄存器操作、不访问内存:消除内存访问延迟波动对测量的污染;
- 不插入任何串行化指令(lfence/cpuid/rdtscp):传统实践会在两次读取间插入串行化指令以保证顺序,这里反其道而行——故意让 CPU 的乱序执行引擎把两次 RDTSC 尽量重叠执行,把差值压向硬件极限;
- 只比较低 32 位:减少指令数量,缩短两次读取之间的测量窗口;
- 连续运行 1 小时:覆盖足够大的统计样本量,捕捉罕见的长尾抖动;
- 单独统计超过 100 个时钟周期的事件数:量化"异常抖动"的频率,而不只是看极值。
测试代码还通过 CPUID 0x15 硬件接口读取 TSC 频率(处理器不支持时回退到 clock_gettime 标定法),保证结果能以纳秒为单位、跨机器可比。代价是两次 RDTSC 之间无数据依赖、架构上不保证读取顺序,极端情况下差值可能回绕成一个较大的值——但 1 小时内数千亿次采样中,这种事件的实际概率低到可以忽略。
六、测试代码解读
以下是我们的TSC抖动测试代码,任何x86平台均可编译运行。
- /*
- * tsc.c - x86 TSC (Time Stamp Counter) 读取开销与抖动测量工具
- *
- * 功能:
- * 在指定时长(RUN_SECONDS)内,反复执行"背靠背两次 rdtsc",
- * 统计两次读取之间差值(diff,单位:TSC 周期数)的:
- * - 最小值 min :理论上代表 rdtsc 指令本身的最小执行开销
- * - 最大值 max :代表测量期间出现过的最坏抖动(中断、SMI、
- * 频率切换、缓存/流水线停顿等都可能导致)
- * - 大抖动计数 big:diff > 100 周期的次数,用于衡量系统实时性
- *
- * 用途:评估 x86 平台(尤其是工控/实时场景)的时钟读取开销与
- * 系统抖动水平,常用于实时性(latency)摸底测试。
- */
- #include <stdio.h>
- #include <stdint.h>
- #include <inttypes.h>
- #include <time.h>
- #define VERSION "2.5" /* 程序版本号,打印时输出便于追溯 */
- #define RUN_SECONDS 3600 /* 测量总时长:3600 秒 = 1 小时 */
- /*
- * rdtsc - 读取 CPU 的 TSC 寄存器(64 位时间戳计数器)
- *
- * RDTSC 指令把 TSC 的低 32 位放入 EAX,高 32 位放入 EDX。
- * 这里用 "=a"/"=d" 约束直接绑定到 rax/rdx 寄存器,
- * 再拼成完整的 64 位返回值。
- *
- * 注意:RDTSC 不是串行化指令,乱序引擎可能让它提前/延后执行。
- * 本程序恰恰利用这一点来测"最小开销",详见 main() 中的注释。
- */
- static inline uint64_t rdtsc(void)
- {
- uint32_t lo, hi;
- __asm__ volatile("rdtsc" : "=a"(lo), "=d"(hi));
- return ((uint64_t)hi << 32) | lo;
- }
- /*
- * now_sec - 获取 CLOCK_MONOTONIC 单调时钟,转换为秒(double)
- *
- * 只用于 tsc_freq() 的回退测量路径(用墙上时间标定 TSC 频率),
- * 不参与核心测量循环,因此精度要求不高。
- */
- static double now_sec(void)
- {
- struct timespec ts;
- clock_gettime(CLOCK_MONOTONIC, &ts);
- return ts.tv_sec + ts.tv_nsec / 1e9;
- }
- /*
- * tsc_freq - 获取 TSC 频率(单位 Hz)
- *
- * 优先路径:CPUID.15H(Time Stamp Counter and Nominal Core
- * Crystal Clock Information Leaf)
- * - EAX = 分母 denominator
- * - EBX = 分子 numerator
- * - ECX = 晶体时钟频率(Hz)
- * TSC 频率 = ECX * EBX / EAX
- * 这是 Intel SDM 规定的标准方法,Skylake 及以后的 CPU 支持。
- * 若任一返回值为 0,说明该 CPU 不支持此叶子,走回退路径。
- *
- * 回退路径:软件测量法
- * 记录起始 TSC 值 c0 和起始单调时钟 t0,忙等约 0.1 秒,
- * 用 (TSC 增量) / (时间增量) 估算频率。
- * 0.1 秒的窗口足以把计时误差压到可接受范围(<0.1%)。
- */
- static double tsc_freq(void)
- {
- uint32_t a, b, c, d;
- /* "c"(0) 表示 ECX 输入为 0(选择子叶 0) */
- __asm__ volatile("cpuid" : "=a"(a), "=b"(b), "=c"(c), "=d"(d)
- : "a"(0x15), "c"(0));
- if (a && b && c)
- return (double)c * b / a;
- double t0 = now_sec();
- uint64_t c0 = rdtsc();
- double t1;
- do {
- t1 = now_sec();
- } while (t1 - t0 < 0.1);
- return (rdtsc() - c0) / (t1 - t0);
- }
- int main(void)
- {
- double freq = tsc_freq(); /* TSC 频率 (Hz),用于把周期数换算成时间 */
- /* 统计变量,全部强制驻留寄存器(见下方汇编约束 "+r"):
- * max:观测到的最大 diff(最坏抖动)
- * min:观测到的最小 diff(rdtsc 最小开销),初值取最大便于比较
- * big:diff > 100 周期的事件计数
- * end:结束时刻的 TSC 值 = 当前 TSC + 频率 * 秒数 */
- uint64_t max = 0, big = 0, min = UINT64_MAX;
- uint64_t end = rdtsc() + (uint64_t)(freq * RUN_SECONDS);
- printf("TSC freq: %.3f MHz [v%s]\n", freq / 1e6, VERSION);
- /*
- * ============ 核心测量循环(手写内联汇编) ============
- *
- * 设计目标:测出"背靠背两次 rdtsc"的最小间隔,以此逼近
- * rdtsc 指令的硬件极限开销。为此做出如下权衡:
- *
- * 1) 不插入任何串行化指令(lfence / cpuid / rdtscp)。
- * 串行化会强制排空流水线,两次 rdtsc 之间至少隔几十
- * 个周期,测到的是"串行化开销"而非 rdtsc 本身开销。
- * 不加串行化时,乱序引擎可以把两次读取重叠发射,
- * diff 可压到接近硬件下限(现代 Intel 通常 20~30 周期)。
- *
- * 2) 全部统计量(max/min/big)和循环控制都在寄存器中
- * 完成,循环体内零内存访问,避免缓存 miss / 栈访问
- * 引入额外抖动,保证测到的 diff 纯粹反映 rdtsc 行为。
- *
- * 代价与风险:
- * 两次 rdtsc 之间没有数据依赖,架构上不保证执行先后。
- * 若乱序引擎让第二次 rdtsc 先于第一次执行,减法结果
- * 会回绕成接近 2^32 的大正数,污染 max/big 统计。
- * 实际硬件上 rdtsc 基本按程序序退休,发生概率极低,
- * 且这种污染一眼就能从 max 值(巨大)识别出来。
- *
- * 寄存器分配(由编译器保证,约束见下方):
- * %[max] %[min] %[big] %[end] —— 通用寄存器("+r"/"r")
- * eax/edx —— rdtsc 的固定输出(低/高 32 位)
- * esi/ecx —— 暂存第一次/第二次 rdtsc 的低 32 位
- * r11 —— 临时计算 big 增量(0 或 1)
- *
- * 为什么比较低 32 位就够?
- * 两次背靠背读取的间隔远小于 2^32 个周期(在 GHz 级
- * TSC 下也对应数秒),diff 不可能溢出 32 位,因此只
- * 比较低 32 位即可,省去 64 位拼接,进一步压缩循环体。
- */
- __asm__ volatile(
- "1:\n\t" /* 循环顶标签(局部标签,用 1b 回跳) */
- "rdtsc\n\t" /* 第一次读 TSC:eax=低32位, edx=高32位 */
- "movl %%eax, %%esi\n\t" /* 保存第一次的低 32 位到 esi */
- "rdtsc\n\t" /* 第二次读 TSC(背靠背,无串行化) */
- "movl %%eax, %%ecx\n\t" /* 保存第二次的低 32 位到 ecx */
- "subl %%esi, %%ecx\n\t" /* ecx = 第二次 - 第一次 = diff(32位) */
- /* --- 更新 max:无分支,用 cmov 避免条件跳转引起的流水线气泡 --- */
- "cmpq %[max], %%rcx\n\t" /* 比较 diff 与当前 max */
- "cmovaq %%rcx, %[max]\n\t" /* diff > max 时更新 max(a = above,无符号) */
- /* --- 更新 min:同理,无分支 --- */
- "cmpq %[min], %%rcx\n\t" /* 比较 diff 与当前 min */
- "cmovbq %%rcx, %[min]\n\t" /* diff < min 时更新 min(b = below,无符号) */
- /* --- 统计大抖动(diff > 100 周期):setcc 无分支实现 ---
- * 等价于:big += (diff > 100) ? 1 : 0 */
- "xorl %%r11d, %%r11d\n\t" /* r11 = 0 */
- "cmpl $100, %%ecx\n\t" /* 比较 diff 与阈值 100 */
- "seta %%r11b\n\t" /* diff > 100 则 r11b = 1,否则 0 */
- "addq %%r11, %[big]\n\t" /* 累加到 big */
- /* --- 循环终止判断:用第二次 rdtsc 的完整 64 位值 ---
- * 拼接 rdx:rax(高32:低32)得到完整 TSC,与结束值比较。
- * 用完整 64 位是防止 1 小时测量内低 32 位回绕导致提前退出。 */
- "shlq $32, %%rdx\n\t" /* rdx 高 32 位左移到位 */
- "orq %%rax, %%rdx\n\t" /* 拼入低 32 位,rdx = 完整 TSC */
- "cmpq %[end], %%rdx\n\t" /* 与结束时刻比较 */
- "jb 1b\n\t" /* 未到结束时间则跳回循环顶 */
- /* 输出/输入约束:"+r" 表示读写(统计量),"r" 表示只读(end) */
- : [max] "+r" (max), [min] "+r" (min), [big] "+r" (big)
- : [end] "r" (end)
- /* clobber 列表:告诉编译器这些寄存器被破坏,不要在里面存值 */
- : "rax", "rcx", "rdx", "rsi", "r11", "cc");
- /* --- 结果换算与输出:周期数 / 频率 = 秒,再换算成 ns/us --- */
- double ns = max / freq * 1e9;
- double ns_min = min / freq * 1e9;
- printf("min diff: %" PRIu64 " cycles (%.2f ns)\n", min, ns_min);
- printf("max diff: %" PRIu64 " cycles (%.2f ns, %.3f us)\n",
- max, ns, ns / 1e3);
- printf("big jitter (>100 cycles): %" PRIu64 " events\n", big);
- return 0;
- }
代码解读:
1、TSC频率获取(tsc_freq 函数):
优先使用CPUID.15H叶节点直接读取处理器报告的TSC频率。这是Intel/AMD在较新处理器上提供的硬件接口,能给出精确的TSC频率比值。如果处理器不支持,则回退到标定法:用clock_gettime测量一段已知时间(0.1秒)内的TSC增量来计算频率。
2、核心测量循环(内联汇编):
循环体由以下指令组成:
- "rdtsc\n\t" /* 第一次读 TSC:eax=低32位, edx=高32位 */
- "movl %%eax, %%esi\n\t" /* 保存第一次的低 32 位到 esi */
- "rdtsc\n\t" /* 第二次读 TSC(背靠背,无串行化) */
- "movl %%eax, %%ecx\n\t" /* 保存第二次的低 32 位到 ecx */
- "subl %%esi, %%ecx\n\t" /* ecx = 第二次 - 第一次 = diff(32位) */
随后用cmp / cmov指令组更新max和min统计,并检查diff是否超过100个cycle(若超过则big 计数器+1,用于统计"大抖动事件"次数)。最后用第二次rdtsc的完整64位值判断是否到达测试时长。
3、关键设计点:
- * 设计目标:测出"背靠背两次 rdtsc"的最小间隔,以此逼近
- * rdtsc 指令的硬件极限开销。为此做出如下权衡:
- *
- * 1) 不插入任何串行化指令(lfence / cpuid / rdtscp)。
- * 串行化会强制排空流水线,两次 rdtsc 之间至少隔几十
- * 个周期,测到的是"串行化开销"而非 rdtsc 本身开销。
- * 不加串行化时,乱序引擎可以把两次读取重叠发射,
- * diff 可压到接近硬件下限(现代 Intel 通常 20~30 周期)。
- *
- * 2) 全部统计量(max/min/big)和循环控制都在寄存器中
- * 完成,循环体内零内存访问,避免缓存 miss / 栈访问
- * 引入额外抖动,保证测到的 diff 纯粹反映 rdtsc 行为。
- *
- * 代价与风险:
- * 两次 rdtsc 之间没有数据依赖,架构上不保证执行先后。
- * 若乱序引擎让第二次 rdtsc 先于第一次执行,减法结果
- * 会回绕成接近 2^32 的大正数,污染 max/big 统计。
- * 实际硬件上 rdtsc 基本按程序序退休,发生概率极低,
- * 且这种污染一眼就能从 max 值(巨大)识别出来。
七、实测:1 小时、数千亿次采样,最大抖动约 10 纳秒
测试环境为 AMD Ryzen 9 9950(TSC 频率约 5 GHz),在望获OS 完全隔离的核心上连续运行 1 小时:
- 最大差值约 43 个时钟周期(约 10 纳秒)——1 小时内最大抖动;
- 超过 100 个时钟周期(约 20 纳秒)的"大抖动"事件:0 次。

操作系统的确定性不是"有和无"的问题,而是一条连续的频谱——你的隔离做到什么程度,抖动就降到什么程度。
八、10 纳秒的确定性,意味着什么?
对绝大多数实时控制场景来说,10 纳秒级的确定性几乎是"奢侈"的:微秒级的伺服控制环、高频交易的下单链路、精密测量仪器,它们的控制周期通常在微秒到毫秒级,抖动裕量高出需求两到三个数量级。
更关键的是,这套 TSC 抖动测试方法是操作系统无关的——只要是 x86 平台,无论 Linux、RT-Linux、传统 RTOS 还是裸机,都能运行同一套代码、产出完全可比的指标。这也让它成为横评"软件确定性"的一把客观标尺。
九、结语:确定性,是工程能力
回到开头那个吵了很多年的问题:Linux 能不能做实时操作系统?答案是——能。但前提是:你的隔离做到了什么程度,你的测试测到了什么层面。
望获OS 的实践证明:通过完全隔离一个核心、禁用其系统中断与核间中断,完全可以在操作系统环境下获得接近裸机级的确定性。我们想做的,不只是做出一款"能实时"的操作系统,更是让"国产芯片 + 国产实时操作系统"这条路线,也能把确定性做到极致。
相关镜像和测试代码即将在望获OS v3.0 版本中发布,欢迎关注我们,后续还会分享更多的技术干货。
附:面向大众的"术语小课堂"
- 实时操作系统(RTOS):必须在限定时间内完成响应的操作系统,常见于工业控制、医疗设备、航空航天等对时间敏感的领域。
- PREEMPT_RT:Linux 内核的实时化补丁,已随主线内核发布,通过让内核临界区可抢占来改善调度延迟。
- TSC(时间戳计数器):处理器内部的时间戳计数器,每个时钟周期自增一次,是 x86 平台上最准的"秒表"之一。
- tick:操作系统的心跳节拍,内核靠它来指挥调度;心跳越频繁,对隔离核心的干扰也越多。
- nohz_full:Linux 提供的一种核心隔离机制,让隔离核心停止周期性心跳。
- IPI(核间中断):一颗核心请求另一颗核心"搭把手"的信号,是隔离核心最难防的干扰源之一。
- SMI(系统管理中断):来自 BIOS 层的隐蔽中断,操作系统看不见也管不了,几乎无法用软件消除。
- cyclictest:Linux 实时性评测的"行业标准"工具,但测的是调度延迟,对指令执行层面的抖动并不敏感。
https://www.bilibili.com/video/BV1QwtP6vEZp/?spm_id_from=333.1387.homepage.video_card.click
更多推荐



所有评论(0)