ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux时钟中断全链路:从硬件脉冲到tickless与调试

Linux时钟中断全链路:从硬件脉冲到tickless与调试 凌晨两点半我盯着 QEMU 串口里那行孤零零的tick: 1发愣——时钟中断明明按时来了调度器却死活不切换任务。折腾到天亮才找到原因IDT 里那个向量被我登记成了陷阱门而不是中断门iret弹回来的eflags里 IF 位是开的临界区被自己的下一次 tick 撕开了。时钟中断这东西代码量撑死十几行但它是整个操作系统里最容易看起来跑通了、其实一碰就碎的地方。它既是调度器的心跳也是时间子系统的唯一节拍源还是各种超时、统计、负载计算的统一触发器。这篇文章我想把一次时钟中断从硬件脉冲到内核函数的完整链路拆开讲透包括时钟源怎么选、中断控制器怎么走、内核里tick_periodic到底干了哪些活、tickless 是怎么把节拍关掉的以及怎么用 ftrace、perf、/proc/timer_list这些工具把它抓出来看。适合谁看写过一点裸机代码、知道 IDT 和 GDT 是什么但没细究过 tick 的同学正在做操作系统课程设计、实验里要实现时钟中断的同学还有做嵌入式、虚拟化、实时性调优被时间漂移和中断延迟坑过的工程师。文中所有函数名我以 Linux 5.x 为准老版本2.6/3.x里名字不一样的地方我会标注出来免得你对着旧书对不上号。1. 从硬件脉冲到内核 tick一次时钟中断的完整链路1.1 操作系统为什么非要一个心跳先说清楚一个前提CPU 本身是事件驱动的它只会在取指、访存、收到中断这几种情况下动一下。如果没有外部周期性事件强行打断它一个用户态死循环可以永远跑下去内核的调度器、时间统计、超时检测全都没有机会执行。这就是时钟中断存在的根本理由——它给非抢占式的硬件提供了一个抢占的切入口。具体来说时钟中断承担了至少五件事。第一是抢占scheduler_tick()里检查当前任务的时间片是否耗尽置上TIF_NEED_RESCHED让内核在返回用户态或者中断返回前触发调度。第二是时间推进jiffies自增、墙上时钟xtime/timekeeper更新gettimeofday、clock_gettime这些调用最终都要靠它。第三是超时检测sleep、select、poll的到期判断。第四是统计与负载计算CPU 使用率、loadavg的采样点就在这里。第五是软中断与延后工作的触发定时器软中断、RCU 回调、调度负载均衡都是被 tick 唤醒的。你可能会问现代 CPU 不是有 APIC Timer、有高精度定时器吗还需要周期性心跳吗答案是形式上不需要但语义上需要。Linux 的 nohz 模式确实能在 CPU 空闲时把周期性 tick 关掉改成 oneshot 编程下一个事件什么时候到但那些必须周期性发生的事情比如全局负载采样仍然会通过一个 housekeeping CPU 上的 tick 来完成。所以理解周期性模型是理解 tickless 的前提。1.2 一次 tick 到底走了哪些路把链路拉直从硬件到软件大概是这样一串硬件时钟源PIT / HPET / LAPIC Timer到达计数阈值拉高一根中断线中断控制器老式的 8259A或者现代的 IOAPIC / LAPIC把这条线翻译成一个中断向量号投递给某个 CPU 核心CPU 保存当前上下文CS、RIP、RFLAGS、SS、RSP 压栈根据 IDT 里该向量的描述符跳到内核的中断入口入口汇编entry_64.S保存通用寄存器切换内核栈调用 C 层的do_IRQ()do_IRQ()通过irq_desc找到这个 IRQ 对应的处理链调用handle_irq_event()对于时钟中断最终落到tick_handle_periodic()它再调用tick_periodic()tick_periodic()里更新 jiffies、更新墙上时间、跑本地定时器、调scheduler_tick()、检查 RCU中断返回如果TIF_NEED_RESCHED被置上走preempt_schedule_irq()或者返回用户态时调度。这条链路上有四个接缝最容易出问题IDT 描述符类型中断门 vs 陷阱门、中断控制器的重定向配置、每个 CPU 一个的clock_event_device绑定关系、以及中断下半部的触发时机。后面第 3 节我会逐个展开。注意do_IRQ()这个名字在 x86 上从 2.6 一直用到 4.x5.x 之后主路径改成了common_interrupt→handle_irq()的直接调用但语义没变。如果你看的资料是十年前的函数名对不上很正常别慌。2. 硬件层选型四代时钟源与两代中断控制器2.1 PIT最经典也最憋屈的 8254PC 兼容机上最古老的定时器是 Intel 8253/8254也就是常说的PITProgrammable Interval Timer。它有三个通道通道 0 接在中断控制器的 IRQ0 上通道 1 用于 DMA 刷新通道 2 接扬声器。它有一个 16 位的计数器和几个可编程的计数模式mode 0 到 mode 5操作系统通常用 mode 3方波发生器或者 mode 2分频器。关键数字是它的输入频率1.193182 MHz。这个奇怪的数字来源是当年彩色显示器的 14.31818 MHz 晶振除以 12属于历史包袱。它带来了一个很实际的麻烦——任何想用 PIT 产生整数频率的方案都会有误差。计算公式是计数初值 1193182 / HZ 向下取整 实际频率 1193182 / 计数初值把常见 HZ 值代进去算一遍HZ理论计数初值实际取值实际频率相对误差10011931.821193299.9985 Hz-0.0015%2504772.734773249.985 Hz-0.006%10001193.1811931000.15 Hz0.015%误差看着不大但它是系统性的不会正负抵消。HZ1000 时每天会多走大约 13 秒所以必须靠 NTP 或者clocksource校正来兜底。PIT 的另一个问题是它只有 16 位最大分频 65536对应最低频率约 18.2 Hz所以 HZ 不可能做到很低。PIT 还有个大坑它是全局共享的单例多个 CPU 核心不能各自拥有一个。在 SMP 系统上如果用 PIT 做 tick 源所有核心的中断都会打到同一个 CPU 上负载完全失衡。这也是它后来被淘汰的直接原因。2.2 HPET 与 LAPIC Timer现代平台的两个选择HPETHigh Precision Event Timer是 Intel 和微软在 2005 年前后推的替代品定义在 ACPI 规范里。它有一个 64 位的主计数器频率至少要 10 MHz实际 x86 上通常是 14.31818 MHz也就是 10.000 的倍数关系以及至少 3 个、最多 32 个独立的比较器通道每个通道可以单独产生中断。相比 PIT它的优势是频率高、位宽大、多通道、可以通过 ACPI 的HPET表精确发现。LAPIC Timer是现代多核平台上最常用的 tick 源。每个 CPU 核心都有自己的 Local APIC里面含一个生产级定时器。它有几个特点值得记住一是每核独立天然解决了 SMP 的负载分布问题二是有三种工作模式——one-shot一次性、periodic周期性、TSC-deadline用 TSC 值作为到期时间精度最高但需要 CPU 支持tsc_deadline_timer特性位三是它的计数频率是从 CPU 总线频率或者核心频率分频来的不是固定常数所以内核必须在启动时做一次校准。校准的逻辑很朴素设置一个已知的参考时间比如用 PIT 或者 HPET 量出 10 毫秒在这个时间窗内数 LAPIC Timer 计数器的递减量反推出频率。内核里对应的是lapic_timer_frequency和calibrate_APIC_clock()相关代码。如果你在做裸机开发或者写自己的内核这一步千万别偷懒用硬编码的频率换个机器就废。TSCTime Stamp Counter严格来说不算时钟源而是时间读数源因为它是只读的不能产生中断。但它极其重要rdtsc指令几个周期就能读一次精度到 CPU 周期级。早期的 TSC 在多核和多频率场景下不可靠不同核心不同步、变频会导致计数变慢后来 Intel 引入了constant_tsc频率不受 P-state 影响和nonstop_tsc不受 C-state 影响两个特性位符合这两个条件的 TSC 才能作为clocksource使用。2.3 从 8259A 到 IOAPIC LAPIC中断控制器这边也经历了两代。老式的8259A是两片级联主片 8 个 IRQ从片接在主片 IRQ2 上共 15 个可用PIT 接在主片 IRQ0。它的配置方式是通过0x20、0x21、0xA0、0xA1这四个端口写 ICW1~ICW4 命令字把 IRQ0 映射到向量0x20开始的区域。现代 x86 用的是IOAPIC LAPIC的组合。IOAPIC 负责接收外部设备的中断线通过一张重定向表Redirection Table把每条线翻译成目标 CPU 向量号 触发方式边沿/电平 极性 屏蔽位。LAPIC 则负责接收本核的定时器、性能计数器、IPI处理器间中断等。IPI 里有一套特殊的中断向量比如RESCHEDULE_VECTOR0xFB、CALL_FUNCTION_VECTOR0xFC、CALL_FUNCTION_SINGLE_VECTOR0xFD还有SPURIOUS_APIC_VECTOR0xFF。时钟相关的向量号用grep一眼就能看到grep -n LOCAL_TIMER\|IRQ0_VECTOR\|RESCHEDULE_VECTOR arch/x86/include/asm/irq_vectors.h典型输出里IRQ0_VECTOR是0x20传统 PIT 走的号LOCAL_TIMER_VECTOR是0xEC。所以在 64 核机器上敲cat /proc/interrupts你会看到一堆LOC后面跟的是Local timer interrupts那个就是每核的 LAPIC Timer 计数。3. 内核里发生了什么从 IRQ 入口到 tick_periodic3.1 IDT、中断门与入口汇编时钟中断到达 CPU 之后第一步是查IDT中断描述符表。每个表项 16 字节描述符类型决定了门的行为这是很多人第一次写内核时踩的坑类型type 值是否自动清 IF用途中断门 Interrupt Gate0xE是一般外部中断、时钟陷阱门 Trap Gate0xF否系统调用、断点、异常区别就在 IFInterrupt Flag位。中断门会自动关中断保证处理程序不被同一类型的中断重入陷阱门不关适合允许嵌套的场景。我开头说的那个 bug 就是把时钟向量写成了陷阱门结果临界区里被下一次 tick 打断共享数据结构直接被踩烂。用pack定义门描述符的时候别忘了门类型字段struct idt_entry { uint16_t offset_low; uint16_t selector; uint8_t ist; /* 中断栈表索引 */ uint8_t type_attr; /* 0x8E P1,DPL0,中断门 */ uint16_t offset_mid; uint32_t offset_high; uint32_t reserved; } __attribute__((packed));type_attr填0x8E最高位 P1 表示有效DPL0 表示只有内核能用低 4 位的0xE就是中断门。填0x8F就变成陷阱门了。进入内核之后是入口汇编x86-64 上走的是entry_64.S主路径大致是apic_timer_interrupt→ 一段用push保存寄存器、SWITCH_TO_KERNEL_CR3切页表的公共代码 →call do_IRQ或者直接call handle_irq。这段汇编不好读但有个技巧用objdump -d vmlinux | grep -A 30 apic_timer_interrupt把符号反汇编出来对着看比读源码直观得多。3.2 tick_periodic 里到底干了哪些活这是全文最核心的一段。tick_handle_periodic()是个包装真正干活的是tick_periodic()它按顺序做这几件事5.x 的名字括号里是旧版对应推进 jiffiestick_do_update_jiffies64()旧版是do_timer()。这里是第一个反直觉的点——它不一定只加 1。如果上一次 tick 到现在间隔了N个 tick 周期比如关中断太久、虚拟机被宿主调度出去了它会一次性补上多个 jiffy避免时间追赶时无限丢失。更新墙上时间update_wall_time()用clocksource读到的纳秒数去校正timekeeper结构。执行本地定时器run_local_timers()本质是raise_softirq(TIMER_SOFTIRQ)。注意它只是触发软中断真正的定时器回调是在软中断上下文里跑的不是硬中断里。调度器打点scheduler_tick()更新运行队列时钟rq-clock给当前任务记账CFS 里是update_curr()累加vruntime检查是否该抢占并且周期性触发负载均衡trigger_load_balance()→raise_softirq(SCHED_SOFTIRQ)。RCU 检查rcu_check_callbacks()和rcu_pending()判断是否需要唤醒 RCU 的 softirq。过载检查与看门狗print_other_cpu_stall、软锁检测watchdog_timer_fn最终打的是NMI watchdog的鼓点。jiffies的自增有个细节值得单独讲。它是unsigned long32 位系统上 HZ1000 时 49.7 天就回绕一次2^32 / 1000 / 86400 ≈ 49.7。所以内核里比较两个 jiffies 绝对不能写if (a b)必须用time_after(a, b)、time_before(a, b)这组宏它们把差值转成有符号数比较回绕时依然正确。我见过不止一个驱动写出while (jiffies timeout)这种代码跑几个小时就挂。3.3 jiffies、timekeeper 与 clocksource 的分工这三个东西经常被混为一谈其实分工很清楚clocksource只管现在几点这件事的读操作提供-read()回调。TSC、HPET、ACPI_PM、Jiffies 都可以注册成 clocksource内核按精度、稳定性打分选一个最好的。你可以用cat /sys/devices/system/clocksource/clocksource0/current_clocksource看当前用哪个。clock_event_device管什么时候叫我这件事的编程操作提供-set_next_event()和-set_mode()。LAPIC Timer、HPET 通道都是 clock_event_device。timekeeper把上面两者组合起来维护墙上时间和单调时间处理 NTP 校正、闰秒、时钟源切换。jiffies则是大概的时间刻度它由 tick 驱动精度就是1/HZ。所以它的定位很明确用来做粗粒度的超时判断不用来做精确的时间戳。要精确时间就用ktime_get()、ktime_get_ns()那是走 clocksource 的。这个区分在实际写代码时很关键我在第 6 节的排查案例里会再提。4. tick 的演进从固定节拍到 tickless 与高精度4.1 HZ 取值背后的取舍CONFIG_HZ是编译期定死的常见选项是 100、250、300、1000。它的选择是一场典型的三方博弈HZ 高定时精度高、交互延迟低一个 tick 最长等 10ms 和最短等 1ms手感差异明显、调度更平滑。代价是中断次数成倍上升每次中断的固定开销保存上下文、进出门、jiffies 更新都会被放大功耗也上去了。HZ1000 时每核每秒 1000 次中断64 核机器就是 6.4 万次纯为心跳消耗。HZ 低省电、中断开销小适合服务器和嵌入式。但定时器精度差一个 1ms 的usleep可能实际睡 10ms。折中值 250这是很多通用发行版的选择1ms 到 4ms 的抖动兼顾交互和开销。顺便说一个查表技巧cat /boot/config-$(uname -r) | grep -i ^CONFIG_HZ能看到CONFIG_HZ250、CONFIG_HZ_250y以及CONFIG_NO_HZ_IDLEy这类信息。想知道实际值也可以写个模块或者读到/proc/timer_list里看 tick 周期。4.2 NO_HZ把空转的 tick 关掉周期性 tick 最大的浪费在于CPU 空闲时也在被叫醒。一个休眠的服务器明明什么都没干每秒每核被唤醒几百次功耗白白丢掉。于是有了tickless系列配置配置项行为适用场景CONFIG_HZ_PERIODIC永远周期性 tick老式、实时性要求极端的场景CONFIG_NO_HZ_IDLE仅空闲时停 tick通用发行版默认CONFIG_NO_HZ_FULL非空闲的隔离核也能停高性能计算、低延迟调优NO_HZ_IDLE的工作方式是当某个 CPU 决定进入 idle 时把它的 clock_event_device 从 periodic 模式切到 oneshot 模式然后编程一个下一个定时器到期时间作为唤醒点。如果没有任何定时器就编程一个最长的保守值KTIME_MAX会被夹到设备能表达的最大值。CPU 于是可以睡很久。等到中断来了先把模式切回 periodic再补上 jiffies 的欠账。NO_HZ_FULL更激进它给每个 CPU 一个nohz_full标志被标记的 CPU 在只有一个可运行任务时也能停掉 tick。代价是它需要一个housekeeping CPU——也就是nohz_full之外的某个核——继续跑 tick负责全局的负载采样、定时器迁移等工作。否则loadavg就没人更新了。启动参数写法是nohz_full1-3意味着 1 到 3 号核做隔离0 号核做管家。注意nohz_full和 CPU 隔离isolcpus、rcu_nocbs通常要配合用。只写一个nohz_full你会发现隔离核的定时器还在被反复迁移效果大打折扣。参数怎么生效启动后这样确认cat /sys/devices/system/cpu/nohz_full cat /proc/cmdline grep -i nohz /proc/timer_list | head4.3 高精度定时器 hrtimer 与时间轮即使开了 nohz只要有定时器就还得编程下一次事件。内核里有两套定时器设施低精度定时器timer_list以 jiffies 为单位用时间轮timer wheel组织。经典实现是五级时间轮第一级 256 个桶、后面四级每级 64 个桶总共能表达 8 5×6 38 位的超时范围。它的优势是add和expire都是 O(1)适合海量粗粒度定时器。缺点是精度被 HZ 限制最早也只能在下一个 tick 到期。高精度定时器hrtimer以ktime_t纳秒为单位每个 CPU 维护一个 per-CPU 的红黑树按expires排序。它的精度只受 clocksource 和 clock_event_device 的限制可以做到微秒甚至亚微秒级。epoll_wait、nanosleep、各种驱动的超时用的都是它。两者的关系是hrtimer 到期后会去编程下一次 clock_event_device 的中断这个中断到来时执行 hrtimer 的回调而timer_list到期后跑的是TIMER_SOFTIRQ。在开启CONFIG_HIGH_RES_TIMERS之后周期性 tick 的下半部被 hrtimer 接管——tick 本身不再固定而是被模拟成在需要的时候编程一次 oneshot 中断。这一层如果要做实验最直观的是看/proc/timer_list它会列出每个 CPU 的tick_device、当前clock_event_device、超时事件链表以及已经编程的下一次中断时间。第 5 节我会给具体读法。5. 动手观察把时钟中断和 tick 抓出来看5.1 /proc/interrupts 与 /proc/timer_list 怎么读先是interrupts。在一台普通 x86 机器上敲watch -n 1 grep -E LOC|timer /proc/interrupts你会看到类似这样的行CPU0 CPU1 CPU2 CPU3 0: 18 0 0 0 IR-IO-APIC 2-edge timer LOC: 184021 180233 179887 181002 Local timer interrupts左边0:那条是传统 PIT 走的 IRQ0计数增长很慢甚至不增长因为很多系统根本不用它了LOC那几行才是真正在跑的 LAPIC Timer每秒增长的数值大致就是 HZ 乘以经过的秒数。如果你发现某个核的LOC数值长期不涨说明那个核在跑 nohz或者干脆离线了。再看timer_list重点看这几段sudo cat /proc/timer_list | head -40输出里有cpu: 0、clock 0: .base ... .index 0 .resolution 1 nsecs这是 clocksource 的信息再往下是tick_broadcast和tick_device最后是active timers后面挂的一串 hrtimer每一项带#0: ffffffff81234567, hrtimer_wakeup, S:01, ffff8800..., 12345678 nsecs。这个列表按到期时间排序你自己的驱动注册的 hrtimer 也会出现在这里是排查谁在阻止 CPU 睡眠最直接的工具。5.2 用 ftrace 抓 tick_periodic 的调用链ftrace 是内核里最好用的观测工具不需要重新编译也不用装什么。抓 tick 的完整调用栈cd /sys/kernel/debug/tracing echo 0 tracing_on echo function_graph current_tracer echo tick_periodic set_graph_function echo 1 tracing_on sleep 1 echo 0 tracing_on cat trace | head -80set_graph_function保证只跟这一个函数的下游输出不会爆炸。你会看到tick_periodic下面挂着一层层的tick_do_update_jiffies64、update_wall_time、run_local_timers、scheduler_tick每行前面的微秒数是实测耗时。我实测过一次tick_periodic在普通 x86 上通常 2~10 微秒开了function_graph之后会膨胀到十几倍——这就是探针自身的开销看数据的时候心里要有数。如果只想统计频次而不关心耗时用functiontracer 加set_ftrace_filter性能影响能压到 1% 以内。5.3 用 perf 统计中断和软中断想量化时钟中断究竟花了多少 CPUperf 更合适sudo perf stat -a -e irq:softirq_entry,irq:softirq_exit -I 1000 sleep 5 sudo perf top -e irq:irq_handler_entryirq:softirq_entry这个 tracepoint 会带一个vec字段vec1是TIMER_SOFTIRQvec7是SCHED_SOFTIRQ。跑一分钟统计下来你能直接看到定时器软中断占了多少次、平均每次处理几个定时器。我在一台跑着大量短连接服务的机器上做过这个观测TIMER_SOFTIRQ每秒三千多次绝大多数是网络栈的重传定时器——这种数据比看 CPU 使用率有用得多。还有一个隐藏用法perf record -e timer:hrtimer_expire_entry -a能记录所有 hrtimer 到期事件perf script输出之后按函数名排序就是一张谁的定时器最多的排行榜。5.4 写一个模块把 jiffies 差值打出来最朴素的验证方式自己写个模块统计两次 tick 之间 jiffies 跳了多少#include linux/module.h #include linux/kernel.h #include linux/timer.h #include linux/jiffies.h static struct timer_list my_timer; static unsigned long prev; static void my_timer_fn(struct timer_list *t) { unsigned long now jiffies; pr_info(tick delta %lu jiffies, HZ%d\n, now - prev, HZ); prev now; mod_timer(my_timer, jiffies msecs_to_jiffies(1000)); } static int __init tick_demo_init(void) { prev jiffies; timer_setup(my_timer, my_timer_fn, 0); mod_timer(my_timer, jiffies msecs_to_jiffies(1000)); pr_info(tick_demo loaded, HZ%d\n, HZ); return 0; } static void __exit tick_demo_exit(void) { del_timer_sync(my_timer); } module_init(tick_demo_init); module_exit(tick_demo_exit); MODULE_LICENSE(GPL);编译加载之后dmesg -w看输出正常情况下delta应该是HZ上下浮动几个值。如果你看到它稳定地偏大比如 HZ250 但每次跳 260说明这个 CPU 上的 tick 丢了原因可能是中断被长时间屏蔽也可能是在虚拟机上被宿主调度出去太久。这个模块是我排查时间类问题的第一把螺丝刀简单但极准。5.5 QEMU GDB 单步跟一次中断在虚拟机里做实验最安全。启动参数加上-s -S监听 1234 端口并暂停然后qemu-system-x86_64 -kernel arch/x86/boot/bzImage -append consolettyS0 nokaslr -s -S -nographic gdb vmlinux -ex target remote :1234 -ex b do_IRQ -ex c断下来之后bt看调用栈info registers看RIP和RFLAGS重点确认 IF 位是 0说明中断门生效了。继续跑的话可以用hbreak tick_periodic加硬件断点避免频繁改写内存导致行为失真。用nokaslr关掉地址随机化符号才对得上。想更纯粹一点用 QEMU 的-d int打开中断日志它会把每一次中断的向量号、触发原因、前后寄存器都打出来。看裸机时钟中断到底有没有按期到达这个日志是最权威的证据。6. 踩坑实录时钟中断相关的六类典型问题6.1 jiffies 停走与时间漂移现象很典型系统跑着跑着uptime变慢sleep精度越来越差dmesg里冒出Clocksource tsc unstable。原因通常是 clocksource 选错了。老 CPU 或者某些虚拟机里TSC 不满足constant_tsc一旦 CPU 变频TSC 计数速度跟着变用它算出来的时间就会漂。内核通常能自己检测并回退到acpi_pm或者hpet但回退过程有延迟。手工干预的办法是往/sys/devices/system/clocksource/clocksource0/available_clocksource里看有哪些候选然后echo hpet /sys/devices/system/clocksource/clocksource0/current_clocksource另一个常见原因是中断被长时间屏蔽。驱动里写了个spin_lock_irqsave然后在里面做毫秒级操作这段时间所有中断都进不来tick 自然丢。丢了之后tick_do_update_jiffies64会补账但补的是 jiffies 的数值真正的时间已经过去了表现出来就是调度延迟抖动。抓这个问题用perf record -e irq:irq_disable配合perf script看最长禁用窗口非常有效。注意不要用 jiffies 做高精度测量。1/HZ是它的分辨率HZ250 时分辨率为 4 毫秒。要测微秒级的耗时老老实实用ktime_get_ns()或者local_clock()。6.2 虚拟机里的时间不准虚拟化环境是时钟问题的重灾区。宿主机把 vCPU 调度出去的时候客户机的 tick 是停的。KVM 为此提供了kvm-clock半虚拟化时钟通过pvclock结构体直接从共享内存读时间比rdtsc更可靠Linux 客户机一般会自动启用。检查方式dmesg | grep -i kvm-clock\|clocksource cat /sys/devices/system/clocksource/clocksource0/current_clocksource如果显示的还是tsc可以强制切到kvm-clock。另外虚拟机里最好开CONFIG_NO_HZ_IDLE并且别用HZ1000因为每一次 tick 都是一次 vCPU 退出vmexit成本远高于物理机。我见过一个配置不当的客户机光是 tick 的 vmexit 就吃掉了 15% 的 CPU。还有个容易忽略的点nohz_full在虚拟机上经常效果相反因为停 tick 意味着更多的 vmexit 和更复杂的时钟同步反而变慢。要不要开必须实测。6.3 中断风暴与 CPU 占用异常现象是某个核的%soft或%irq打满/proc/interrupts里某一行每秒涨几万。时钟中断本身不会造成风暴频率是固定的但定时器软中断会。如果某个驱动注册了一个周期极短的 hrtimer或者某个定时器回调里又注册了一个立即到期的定时器就会形成自激循环把TIMER_SOFTIRQ刷爆。排查路径perf top -e irq:softirq_entry看哪个 vec 最高然后用perf record -g -e irq:softirq_entry抓调用栈定位到具体函数。我曾经在一个自研模块里踩过这个坑回调里用hrtimer_start(t, ktime_set(0,0), ...)想立刻再跑一次结果变成死循环单核 100% 软中断ksoftirqd直接跑满。6.4 常见问题速查表把上面这些整理成一张表遇到问题时按行对号入座现象最可能的原因快速验证手段处理方式uptime 走得比真实时间慢clocksource 不稳定TSC 变频dmesg | grep -i clocksource手动切到 kvm-clock / hpetsleep 精度差、抖动大HZ 过低 或 中断延迟cat /proc/timer_list看 resolution提高 HZ 或用 hrtimer / nanosleep单核 %soft 打满定时器自激 / 软中断风暴perf top -e irq:softirq_entry查回调函数去掉重复注册jiffies 不增长中断被长期屏蔽perf record -e irq:irq_disable缩短临界区改用 spin_lock_bh虚拟机时间跳变vCPU 被宿主抢占、未启用 pvclockdmesg | grep kvm-clock启用 kvm-clock避免 nohz_full空载功耗偏高周期性 tick 未关闭grep -i nohz /proc/cmdline开 CONFIG_NO_HZ_IDLE隔离核上定时器不准缺少 housekeeping CPUcat /sys/devices/system/cpu/nohz_full保留 0 号核做管家配合 rcu_nocbs32 位系统 49.7 天后异常jiffies 回绕比较写法错误静态检查jiffies比较点改用 time_after/time_before7. 时钟中断与其他子系统的联动以及从零手搓时的实现顺序7.1 它和调度器、RCU、功耗的耦合关系时钟中断不是一个孤立模块它跟好几个子系统是双向耦合的。跟调度器的关系最紧scheduler_tick()是 CFS 记账的触发点update_curr()在这里把delta_exec累加到vruntime上同时周期性负载均衡trigger_load_balance()也是从这里发起软中断。如果 tick 不稳CFS 的记账就会有系统性偏差表现出来就是任务之间分到的 CPU 时间比例和预期对不上。跟RCU的关系在于RCU 的优雅周期推进依赖 tick 来检测所有 CPU 都经历了一次静止状态quiescent statercu_check_callbacks()就是在 tick 里调用的。nohz 模式下 RCU 需要特殊处理CONFIG_RCU_NOCB_CPU、rcu_nocbs否则隔离核会让优雅周期卡死。跟功耗的关系则是直接的。每一次唤醒都要从 C-state 里爬出来深度 C-state 的出栈延迟可能上百微秒代价远大于那次 tick 的处理耗时。这就是为什么服务器上关掉周期性 tick 能省出可观的电——不是少执行了几条指令而是让 CPU 真正待在了深睡眠里。7.2 如果你想自己实现时钟中断推荐这个顺序写课程设计或者从零手搓操作系统的时候时钟中断通常是第一个真正的外部中断。我建议按这个顺序做能少走很多弯路先把 PIT 当哑巴用只做频率校准。设置通道 0 为 mode 3计数值写1193182 / 100然后挂一个空的处理函数确认能进中断、能iret回去、eflags恢复正常。这一步通了说明 IDT、GDT、中断控制器初始化、汇编入口、栈切换这一整套基础设施都是对的。再接上 jiffies在中断处理里自增一个全局变量主循环里打印它确认增长速度接近预期。这一步会暴露中断门 vs 陷阱门和EOI 忘了发两大类问题——特别是 8259A 的 EOI忘了往0x20端口写0x20你会只收到一次中断然后再也收不到了。这个现象新手特别容易懵因为它看起来像时钟坏了。然后接调度在 tick 里递减时间片、置标志位、在返回用户态前触发切换。这一步需要你先把上下文切换写对。数据显示、共享结构保护这些都可以后面再加。最后换成 LAPIC Timer 或 HPET把 PIT 退成校准用的参考。换的时候注意两点一是必须做频率校准不能硬编码二是 SMP 场景下每个核要各自初始化自己的 LAPIC不能共用一份配置。提示调试阶段把HZ设成 10 或者 20比设成 1000 好得多。中断太密会把你自己的printk淹掉串口输出本身又慢容易形成打印拖慢中断、中断又触发打印的死循环。我个人在这个问题上最大的体会是时钟中断的难点从来不在中断本身而在于它把操作系统里所有共享、并发、时序的问题都放大了一遍。你以为你在调一个定时器实际上你在调临界区、在调时钟源可信度、在调和宿主之间的时间语义。我现在的习惯是任何涉及时间的改动先在nohzoff highresoff的最保守配置下验证正确性再逐步打开优化一次只开一个开关用dmesg和/proc/timer_list对照着看。抖动从这个开关引入的时候你能立刻知道是谁干的。另外一个小技巧把CONFIG_HZ和CONFIG_NO_HZ_IDLE的取值写进你的部署清单机器换一批、内核换一版之后对比一遍很多莫名其妙变慢了的问题答案就在这两个值里。
返回列表