这一篇在干嘛?

内核做的几乎所有事都离不开时间:进程切换靠时间片、超时检测靠定时器、文件时间戳靠系统时钟。本章从底层硬件时钟(RTC、TSC、PIT、本地 APIC 定时器、HPET)讲到内核的时间管理架构(jiffies、xtime、tick),再到动态定时器的精巧数据结构和各种时间相关系统调用。看完你就知道”系统时间”到底是怎么一秒一秒走出来的。

时钟与定时器电路 | Linux 时间管理架构 | 单处理器架构 | 多处理器架构 | 更新时间与统计 | 动态定时器 | 延迟函数 | 时间相关系统调用

图 1:第 6 章章首插图

时钟与定时器电路

内核要做两类时间测量:

  1. 维持当前时间与日期——供 time()gettimeofday() 等 API 返回给用户程序,也供内核自己给文件、网络包打时间戳;
  2. 维护定时器——在一段时间流逝后通知内核或用户程序。

这些都靠基于固定频率振荡器和计数器的硬件电路实现。时钟电路(clock)用于记录当前时刻和做精确测量;定时器电路(timer)由内核编程,让它按预定义频率周期性发出中断——这些周期中断是软件定时器的命脉。

实时时钟 RTC

所有 PC 都有一个独立于 CPU 和其他芯片的实时时钟(RTC),由小电池供电,关机后照样走。它与 CMOS RAM 集成在同一块芯片(Motorola 146818 或等价物)。RTC 能在 IRQ8 上以 2 Hz~8192 Hz 发周期中断,也能编程为到达指定时刻时触发 IRQ8(闹钟功能)。

Linux 只用 RTC 来获取开机时的日期与时间(通过 0x70、0x71 两个 I/O 端口访问),但允许用户进程通过 /dev/rtc 设备文件来编程 RTC。

时间戳计数器 TSC

从 Pentium 开始,80x86 处理器内置一个 64 位的时间戳计数器(TSC),每收到一个外部振荡器时钟信号就加 1,可用 rdtsc 指令读取。时钟 1 GHz 时 TSC 每纳秒加 1——这是最精细的计时来源。麻烦在于时钟频率编译时不可知(同一个内核镜像要跑在各种 CPU 上),所以系统启动时由 calibrate_tsc() 函数数出约 5 毫秒时间窗内的时钟信号数来标定实际频率(这个时间窗由 PIT 的一个通道产生)。

可编程间隔定时器 PIT

可编程间隔定时器(PIT)的角色像微波炉的闹铃——时间一到就通知,只是它”响”的方式是发出定时器中断(timer interrupt),而且是永久地按内核设定的固定频率一直响。IBM 兼容 PC 至少有一个 PIT,通常由 8254 CMOS 芯片实现,使用 0x40~0x43 I/O 端口。

Linux 把 PIT 编程为在 IRQ0 上以约 1000 Hz 发中断——每 1 毫秒一次,这个时间间隔叫 tick(滴答),其纳秒长度存在 tick_nsec 变量中(PC 上初始为 999848 纳秒,约合 1000.15 Hz;与外部时钟同步时内核可能微调它)。tick 是整个系统所有节拍活动的基准,像音乐排练时的节拍器。

tick 长短是个权衡:更短的 tick 意味着更高分辨率,多媒体播放更流畅、poll()/select() 响应更快;但 CPU 花在内核态的时间比例变大,用户程序整体变慢。慢机器 tick 约 10 毫秒(100 次/秒),快机器约 1 毫秒(1000 或 1024 次/秒)。

三个相关常量宏:

  • HZ:每秒定时器中断的大约次数(IBM PC 上为 1000);
  • CLOCK_TICK_RATE:1193182,8254 芯片内部振荡器频率;
  • LATCHCLOCK_TICK_RATE/HZ 四舍五入的整数,用于编程 PIT。

setup_pit_timer() 对 PIT 的初始化:

spin_lock_irqsave(&i8253_lock, flags);
outb_p(0x34, 0x43);        /* 命令:按新频率发中断 */
udelay(10);
outb_p(LATCH & 0xff, 0x40); /* 16 位 LATCH 分两个字节写入 8 位端口 */
udelay(10);
outb(LATCH >> 8, 0x40);
spin_unlock_irqrestore(&i8253_lock, flags);

outb_p()outb() 类似,只是多插一个 no-op 停顿防止硬件懵掉。

CPU 本地定时器

新型 80x86 处理器的本地 APIC 自带一个本地定时器,与 PIT 类似但有三点不同:

  • 计数器 32 位(PIT 只有 16 位),可编程出非常低的中断频率;
  • 本地定时器中断只发给自己的 CPU,而 PIT 的中断是全局的,可被任一 CPU 处理;
  • 它基于总线时钟信号,只能每 1/2/4/…/128 个总线时钟信号减一次计数器,灵活性不如用自己的时钟信号的 PIT。

高精度事件定时器 HPET 与 ACPI PMT

HPET(Intel 与 Microsoft 联合开发):芯片内含最多 8 个 32 位或 64 位独立计数器,各自时钟频率至少 10 MHz(即至少每 100 纳秒加 1);每个计数器最多可挂 32 个定时器,每个定时器由比较器和匹配寄存器组成——计数器值与匹配寄存器相等就触发硬件中断,部分定时器还能发周期中断。寄存器映射在内存空间(类似 I/O APIC),BIOS 引导时建立映射并报告地址。HPET 架构最丰富,有它就该优先用它,预计最终会彻底取代 PIT。

ACPI 电源管理定时器(ACPI PMT):几乎所有 ACPI 主板都有,固定频率约 3.58 MHz 的简单计数器。它的价值在于:当操作系统或 BIOS 为了省电动态降低 CPU 频率/电压时,TSC 的频率会跟着变(造成时间扭曲),而 ACPI PMT 频率不变。反过来,TSC 的高频率又很适合测量极短的时间间隔。

常见坑:用 TSC 计时却碰上变频 CPU

在支持动态调频调压(省电策略)的机器上,TSC 走的”秒”会随 CPU 频率伸缩,直接拿 TSC 差值算时间会得出离谱结果。这种场景要么用 ACPI PMT、HPET,要么依赖内核已封装好的 timer object 抽象。

Linux 时间管理架构

内核每个 tick 都要做这些事:更新自启动以来流逝的时间、更新日期时间、检查当前进程是否超时(必要时抢占)、更新资源使用统计、检查软件定时器是否到期。

时间管理架构(timekeeping architecture)就是围绕时间流的一套数据结构与函数,单处理器和多处理器系统略有差异:

  • 单处理器:所有时间相关活动都由全局定时器(PIT 或 HPET)的中断触发;
  • 多处理器:全局活动(如软件定时器处理、系统时间维护)由全局定时器中断触发,CPU 特定活动(如监视当前进程运行时长)由本地 APIC 定时器中断触发。

现实中存在混合情况(没有本地 APIC 的早期 SMP 主板、本地定时器中断不可用的有缺陷主板、自带本地 APIC 的新单处理器系统),这里只讨论两种”纯粹”架构。架构还依赖 TSC、ACPI PMT、HPET 的有无——内核需要两种基本能力:维持当前时间、计算当前秒内已流逝的纳秒数,后者的实现精度取决于可用硬件。

定时器对象:统一抽象所有时钟源

为了统一处理各种定时器源,内核使用 timer_opts 类型的”定时器对象”——一个名字加四个标准方法:

字段说明
name标识定时器源的字符串
mark_offset记录最后一次 tick 的精确时刻,由定时器中断处理程序调用
get_offset返回距上次 tick 已流逝的时间
monotonic_clock返回内核初始化以来的纳秒数
delay等待给定数量的”loops”(用于延迟函数)

mark_offsetget_offset 是灵魂:前者在每次 tick 中断时把精确时刻存起来,后者用存下的值算出距上次中断过去了多少微秒——两者配合让内核达到亚 tick 分辨率(精度远高于 tick 本身),这称为时间插值(time interpolation)。

cur_timer 变量指向当前系统中”最好”的定时器源对应的对象,启动初期指向占位用的 timer_none,内核初始化时由 select_timer() 选定:

定时器对象说明时间插值用延迟用
timer_hpetHPETHPETHPET
timer_pmtmrACPI PMTACPI PMTTSC
timer_tscTSCTSCTSC
timer_pitPITPIT紧凑循环
timer_none初始化期占位(无)紧凑循环

优先级从上到下:有 HPET 选 HPET,否则 ACPI PMT,否则 TSC,最后退回一定存在的 PIT。注意本地 APIC 定时器没有对应的定时器对象——它只负责发周期中断,从不用于亚 tick 精度的插值。

jiffies:系统的心跳计数

jiffies 变量记录自系统启动以来流逝的 tick 数,每次定时器中断加 1。80x86 上它是 32 位,约 50 天就回绕(wrap around)。内核靠 time_aftertime_after_eqtime_beforetime_before_eq 宏正确处理回绕——即使溢出也能给出正确的大小比较结果。

一个有趣的细节:jiffies 启动时不是初始化为 0,而是 0xfffb6c20——相当于有符号数 -300000,开机仅 5 分钟后就会溢出一次。这是故意的:没检查溢出需求的 buggy 代码会在开发阶段立刻暴露,而不是潜伏到稳定版内核里。

有时内核需要真正的”自启动以来的总 tick 数”(不管回绕),所以 80x86 上 jiffies 被链接器等价为 64 位计数器 jiffies_64低 32 位。1 毫秒 tick 下 jiffies_64 要几亿年才回绕,可视为永不溢出。为什么不直接把 jiffies 声明成 64 位?因为 32 位架构上 64 位访问不原子——读完整 64 位需要同步手段防止两个 32 位半边被并发更新,比 32 位读慢得多。

读 64 位版本用 get_jiffies_64()

unsigned long long get_jiffies_64(void)
{
    unsigned long seq;
    unsigned long long ret;
    do {
        seq = read_seqbegin(&xtime_lock);
        ret = jiffies_64;
    } while (read_seqretry(&xtime_lock, seq));
    return ret;
}

用第 5 章讲过的 xtime_lock 顺序锁保护:反复读,直到确认读期间没有被并发更新。写侧(增加 jiffies_64 的临界区)则用 write_seqlock(&xtime_lock) / write_sequnlock(&xtime_lock)。注意 ++jiffies_64 会连带增加 32 位的 jiffies——它就是前者的低半边。

xtime:当前日期时间

xtimetimespec 类型变量,存当前时间与日期:

  • tv_sec:自 1970 年 1 月 1 日午夜(UTC)以来的秒数;
  • tv_nsec:当前这秒内已流逝的纳秒数(0~999999999)。

xtime 通常每个 tick 更新一次(约每秒 1000 次)。用户程序取时间、内核更新 inode 时间戳都靠它。xtime_lock 顺序锁(同一把锁也保护 jiffies_64)避免对它的并发访问竞态——它是整个时间管理架构多个临界区的通用保护伞。

单处理器的时间管理架构

UP 系统上,所有时间相关活动都由 PIT 在 IRQ0 上发的中断触发:一部分在中断处理中立即执行,其余交给可延迟函数。

初始化

time_init() 完成:

  1. 初始化 xtime:用 get_cmos_time() 从 RTC 读出 1970 年以来的秒数;tv_nsec 的设置使得即将到来的 jiffies 溢出恰好落在秒边界上(与 tv_sec 加 1 重合)。
  2. 初始化 wall_to_monotonic:同样是 timespec,存”要加到 xtime 上才能得到单调递增时间”的量。因为闰秒和与外部时钟同步都可能让 xtime 突然回跳,有时内核需要真正单调的时间源。
  3. 若支持 HPET,调 hpet_enable() 探测芯片(BIOS 是否已映射寄存器),成功则编程第一个定时器让 IRQ0 每秒中断 1000 次;否则用已被 init_IRQ() 编程好的 PIT。
  4. select_timer() 选最佳定时器源,设置 cur_timer
  5. setup_irq(0,&irq0) 注册 IRQ0 的中断门:
struct irqaction irq0 = { timer_interrupt, SA_INTERRUPT, 0, "timer", NULL, NULL };

从此时起,每个 tick 调用一次 timer_interrupt(),且因 SA_INTERRUPT 标志,执行期间中断是关闭的。

定时器中断处理程序

timer_interrupt()(PIT 或 HPET 的 ISR)执行以下步骤:

  1. write_seqlock() 获取 xtime_lock(写),保护所有时间相关变量;
  2. 执行 cur_timer->mark_offset 方法:
    • timer_hpet:检查自上次 tick 以来是否丢失过定时器中断(万一丢了就补上 jiffies_64),记录 HPET 周期计数器当前值;
    • timer_pmtmr:中断源仍是 PIT,但用 ACPI PMT 计数器做精细测量,同样检查丢中断并记录 PMT 计数值;
    • timer_tsc:中断源是 PIT,用 TSC 做精细测量,同样检查丢中断并记录 TSC 值;
    • timer_pit:没有别的电路可用,什么都不做。
  3. do_timer_interrupt()
    • jiffies_64 加 1(仍持有写锁,安全);
    • update_times() 更新系统日期时间、计算系统负载;
    • update_process_times() 做本地 CPU 的时间记账;
    • profile_tick() 做内核代码剖析;
    • 若系统时钟已与外部时钟同步(之前发过 adjtimex()),每 660 秒(11 分钟)调一次 set_rtc_mmss() 校准 RTC——帮助联网机器同步时钟。
  4. write_sequnlock() 释放 xtime_lock
  5. 返回 1 表示中断已被有效处理。

多处理器的时间管理架构

SMP 系统有两路定时器中断:PIT/HPET 的全局中断负责与特定 CPU 无关的活动(软件定时器、系统时间维护);本地 APIC 定时器中断负责本 CPU 相关的活动(监视当前进程运行时长、更新资源使用统计)。

初始化

全局中断处理程序仍由 time_init() 初始化。本地定时器中断保留中断向量 239(0xef):apic_intr_init() 在 IDT 中把该向量对应的中断门设为低级处理程序 apic_timer_interrupt() 的地址。随后 calibrate_APIC_clock() 计算引导 CPU 的本地 APIC 在一个 tick(1 毫秒)内收到多少个总线时钟信号,setup_APIC_timer() 用这个值编程每个本地 APIC——每个 CPU 执行一次。因为所有本地 APIC 定时器基于同一总线时钟信号,它们天然同步,引导 CPU 算出的值对其余 CPU 同样有效。

全局与本地中断处理程序

SMP 版 timer_interrupt() 与 UP 版的差异:

  • do_timer_interrupt() 要写 I/O APIC 的一个端口来应答定时器 IRQ;
  • 不调用 update_process_times()profile_tick()——它们都是特定于 CPU 的活动。

本地定时器中断的低级处理程序与其他中断如出一辙:

apic_timer_interrupt:
    pushl $(239-256)
    SAVE_ALL
    movl %esp, %eax
    call smp_apic_timer_interrupt
    jmp ret_from_intr

高级处理程序 smp_apic_timer_interrupt():取 CPU 逻辑号 n → irq_stat 数组第 n 项的 apic_timer_irqs 计数加 1 → 在本地 APIC 上应答中断 → irq_enter() → 调 smp_local_timer_interrupt()irq_exit()

smp_local_timer_interrupt() 做 per-CPU 的时间活动:调 profile_tick()(内核剖析),再调 update_process_times()(检查当前进程运行时长、更新本地统计)。管理员可通过写 /proc/profile 改变剖析采样频率(内核通过改变本地定时器中断频率实现),但 update_process_times() 仍严格每 tick 执行一次。

更新时间与系统统计

更新时间与日期

全局定时器中断处理程序调用的 update_times() 负责刷新 xtime

void update_times(void)
{
    unsigned long ticks;
    ticks = jiffies - wall_jiffies;
    if (ticks) {
        wall_jiffies += ticks;
        update_wall_time(ticks);
    }
    calc_load(ticks);
}

wall_jiffies 记录 xtime 上次被更新时刻对应的 jiffies 值。它可能小于 jiffies - 1——中断长时间被禁时定时器中断会丢失,内核未必每个 tick 都更新 xtime;但没有 tick 会永久丢失(mark_offset 方法负责检查),长远看 xtime 仍是正确的。update_wall_time() 连续调 update_wall_time_one_tick() ticks 次,通常每次给 xtime.tv_nsec 加 1000000;超过 999999999 时进位到 tv_sec。若发过 adjtimex(),每次加的值会被微调,让时钟渐变地加快或减慢。

更新本地 CPU 统计

update_process_times()(UP 由全局中断处理程序调用,SMP 由本地中断处理程序调用)执行:

  1. 检查当前进程已运行多久:根据中断发生时进程处于用户态还是内核态,分别调 account_user_time()account_system_time(),它们会:
    • 更新当前进程描述符的 utime(用户态 tick 数)或 stime(内核态 tick 数);另有 cutime/cstime 字段累计子进程花费的 CPU tick,出于效率考虑它们不在这里更新,而在父进程查询子进程状态时才结算;
    • 检查是否达到 CPU 时间限额(signal->rlim[RLIMIT_CPU].rlim_cur),达到就向 current 发送 SIGXCPUSIGKILL 信号;
    • account_it_virt()account_it_prof() 检查进程的间隔定时器(见后文);
    • 更新 per-CPU 变量 kstat 中的内核统计。
  2. raise_softirq() 在本地 CPU 激活 TIMER_SOFTIRQ(动态定时器靠它处理);
  3. 若有 RCU 旧副本待回收,检查本地 CPU 是否经历静息状态并 tasklet_schedule() 激活 rcu_tasklet(见 /linux内核/lk05);
  4. scheduler_tick() 递减当前进程的时间片计数并检查配额是否耗尽(详见 /linux内核/lk07)。

系统负载与内核剖析

calc_load() 每 tick 统计 TASK_RUNNINGTASK_UNINTERRUPTIBLE 状态的进程数,更新平均负载uptime 命令显示最近 1、5、15 分钟的 load average:单处理器上 0 表示没有活跃进程(swapper 除外),1 表示 CPU 被 100% 占满,大于 1 表示多个活跃进程分抢 CPU。

内核自带极简剖析器 readprofile,帮开发者找出内核热点(最频繁执行的代码片段)。它基于蒙特卡洛方法:每次定时器中断时若中断发生在内核态,就从栈上取出被中断前的 eip 值记一笔——日积月累,样本堆积在热点上。启用需内核启动参数 profile=N(2^N 是剖析的代码片段粒度),数据从 /proc/profile 读取,写该文件则清零(SMP 上还能改采样频率);一般用 readprofile 命令而非直接操作文件。2.6 还有更强大的 oprofile,能同时发现内核、用户程序和系统库的热点——启用时 profile_tick() 改调 timer_notify() 采集数据。

NMI 看门狗

SMP 内核可为开发者提供看门狗,检测导致系统冻结的内核 bug——用 nmi_watchdog 参数启动。它利用本地/ I/O APIC 的硬件特性:能对每个 CPU 发周期性 NMI 中断。NMI 的妙处是不会被 cli 指令屏蔽,所以即使中断被禁、系统半死不活,看门狗照样工作。

每个 tick,所有 CPU 都会执行一次 NMI 处理程序 → do_nmi():取 CPU 号 n,检查 irq_stat 第 n 项的 apic_timer_irqs 计数——CPU 正常时,本地定时器中断处理程序每个 tick 都会把它加 1,所以这个值应与上次 NMI 时不同;若没变,说明整个 tick 里本地定时器中断都没执行——CPU 冻结了。此时看门狗大动干戈:向系统日志写入吓人的消息、转储 CPU 寄存器和内核栈(kernel oops)、杀死当前进程——给开发者留下破案线索。

动态定时器

定时器是一种软件设施:在未来某个时刻(时间间隔流逝后)调用某个函数;这个时刻称为超时(time-out)。内核和用户进程都大量使用:软盘驱动用定时器在软盘长时间未被访问后关掉电机,打印机驱动用它检测打印机错误状态。

实现思路很朴素:每个定时器存一个”到期时刻”字段(创建时由当时 jiffies 加上间隔数算出,之后不变);内核检查时拿它与当前 jiffies 比较,jiffies >= 存的值 即到期。

Linux 分两类定时器:动态定时器(内核使用,可动态创建销毁、数量不限)和间隔定时器(用户态进程创建)。一条重要警告:定时器检查总是由可延迟函数完成,而可延迟函数可能在激活后很久才执行,所以内核只能保证定时器函数在到期时刻或最多晚几百毫秒内执行——不能保证准时。因此定时器不适合到期时刻必须严格保证的实时应用。

timer_list 与竞态条件

struct timer_list {
    struct list_head entry;      /* 挂入链表的连接件 */
    unsigned long expires;       /* 到期时刻(tick 数) */
    spinlock_t lock;             /* 保护本对象的锁 */
    unsigned long magic;         /* 魔数,检查是否已初始化 */
    void (*function)(unsigned long); /* 到期执行的函数 */
    unsigned long data;          /* 传给 function 的参数 */
    tvec_base_t *base;           /* 所属 CPU 的 tvec_base_t */
};

function 是到期执行的函数,data 是传给它的参数——多亏 data 字段,一个通用函数就能服务多个设备驱动(data 存设备 ID 之类以区分)。expires 小于等于当前 jiffies 的定时器视为已到期/失效。

创建并激活一个动态定时器的步骤:

  1. 创建 timer_list 对象 t(静态全局变量 / 函数局部变量(内核栈上)/ 内嵌在动态分配的描述符里);
  2. init_timer(&t) 初始化(置 base 为 NULL、lock 为开);
  3. function(和 data);
  4. 未入链则设好 expires 并调 add_timer(&t) 插入合适的链表;
  5. 已入链则用 mod_timer(&t) 更新 expires(它会负责把对象挪到正确的链表)。

到期后内核自动把 t 从链表移除。但进程有时需要显式移除:del_timer()del_timer_sync()del_singleshot_timer_sync()——比如睡眠的进程提前被唤醒、决定销毁定时器。对已移除的定时器调 del_timer() 无害,所以在定时器函数内部删定时器是好习惯。

动态定时器是异步激活的,竞态很多。经典例子:定时器函数操作一个可丢弃资源(内核模块、文件结构),如果释放资源时不先停定时器,定时器函数可能在资源消失后被激活——数据损坏。单处理器的正确写法:

del_timer(&t);
X_Release_Resources();

但多处理器上这仍不安全:del_timer() 执行时定时器函数可能正在另一个 CPU 上跑,资源还是会在函数工作时被释放。对策是 del_timer_sync():移除定时器后检查函数是否在其他 CPU 上执行,是则等待其结束。它复杂而慢,因为必须考虑定时器函数自我重新激活的情况;若开发者确认函数不会自我激活,可用更简单快速的 del_singleshot_timer_sync()

另外,修改已激活定时器的 expires 字段必须用 mod_timer(),而不是”删了重建”——后者在两条控制路径并发修改同一定时器时会乱套。所有定时器函数的 SMP 安全性靠每个 timer_list 自带的 lock 自旋锁保证:内核每次访问动态定时器都先关中断再拿这把锁。

Linux 2.6 中动态定时器绑定到激活它的 CPUadd_timer()/mod_timer() 在哪个 CPU 执行,定时器函数就在哪个 CPU 跑);del_timer() 家族则可以停任何 CPU 上的定时器。

分组链表:聪明的时间轮

数据结构的选择是道难题:所有定时器串成一条链表的话每个 tick 扫全表太贵;维护有序链表的话插入删除又太贵。最终方案:把 expires 值按 tick 区间分组,让定时器能高效地从大区间链表”渗透”到小区间链表;SMP 上再把活跃定时器按 CPU 分摊。

核心是 per-CPU 变量 tvec_bases,每个元素是 tvec_base_t 结构:

typedef struct tvec_t_base_s {
    spinlock_t lock;
    unsigned long timer_jiffies;      /* 尚未检查的最早到期时间 */
    struct timer_list *running_timer; /* 本 CPU 正在执行的定时器 */
    tvec_root_t tv1;   /* 256 个链表:未来 255 个 tick 内到期的 */
    tvec_t tv2;        /* 64 个链表:未来 2^14-1 ticks */
    tvec_t tv3;        /* 64 个链表:未来 2^20-1 ticks */
    tvec_t tv4;        /* 64 个链表:未来 2^26-1 ticks */
    tvec_t tv5;        /* 同 tv4,但最后一个链表放超远期定时器 */
} tvec_base_t;
  • tv1 是 256 个 list_head 的数组,按 expires & 255 索引,装下个 255 tick 内到期的所有定时器;
  • tv2/tv3/tv4 各是 64 个链表,覆盖再往后 2^14-1、2^20-1、2^26-1 个 tick;
  • tv5 与前者相同,但最后一个链表容纳 expires 巨大无比的定时器,永不需要从别处补充。

timer_jiffies 表示”尚未检查的定时器中最早的到期时间”:与 jiffies 相等说明没有积压;更小说明之前的 tick 的定时器还没处理(可延迟函数被长期禁用或大量中断处理程序执行时会积压)。它启动时设为 jiffies,只被 run_timer_softirq() 递增。

图 2:动态定时器的五组链表(tv1 覆盖最近 255 个 tick,tv2~tv5 逐级覆盖更远区间)

动态定时器的处理:TIMER_SOFTIRQ

处理软件定时器是耗时活动,不该由定时器中断处理程序亲自干——Linux 2.6 交给可延迟函数 TIMER_SOFTIRQ,其处理函数 run_timer_softirq() 的逻辑:

  1. 取本地 CPU 的 tvec_base_t 地址;
  2. base->lock 自旋锁、关本地中断;
  3. 外层 while 循环(base->timer_jiffies <= jiffies 时继续),每轮:
    • 算出 tv1 中下一个要处理的链表索引:index = base->timer_jiffies & 255
    • 若 index 为 0,tv1 的 256 个链表已全部轮空,需要渗透(cascade):
if (!index &&
    (!cascade(base, &base->tv2, (base->timer_jiffies >>  8) & 63)) &&
    (!cascade(base, &base->tv3, (base->timer_jiffies >> 14) & 63)) &&
    (!cascade(base, &base->tv4, (base->timer_jiffies >> 20) & 63)))
        cascade(base, &base->tv5, (base->timer_jiffies >> 26) & 63);

cascade(base, &base->tv2, i)tv2.vec[i] 链表里的定时器全部挪进 tv1 的合适链表;除非 tv2 全空才返回 0,此时再从 tv3 补 tv2,依此类推。

  • base->timer_jiffies 加 1;
  • tv1.vec[index] 链表里的每个定时器 t:摘出 → (SMP 上)置 running_timer = &tt.base = NULL放锁开中断 → 执行 t.function(t.data)重新拿锁关中断 → 下一个。执行定时器函数时放开锁,因为函数可能做各种事(甚至睡眠前的准备工作),拿着锁执行会死锁或拖太久。
  1. 循环结束,所有到期定时器已处理;SMP 上置 running_timer = NULL
  2. 放锁、开中断。

因为 jiffiestimer_jiffies 通常相等,外层循环通常只执行一轮;一般执行 jiffies - timer_jiffies + 1 轮。若执行期间又来了定时器中断(jiffies 异步增长),本 tick 到期的定时器也会被考虑到。

这套复杂算法换来极好的性能:假设 TIMER_SOFTIRQ 紧跟中断执行,那么 99.6% 的情况下(255/256)run_timer_softirq() 只是直接跑到期函数;tv1 需要 63/64 概率从 tv2 的一个链表补充(把 1 个链表摊到 tv1 的 256 个链表);tv2 每 16.4 秒才需要从 tv3 补一次,tv3 每 17 分 28 秒、tv4 每 18 小时 38 分补一次,tv5 永不需要。

应用示例:nanosleep()

sys_nanosleep() 接收 timespec 指针,挂起调用进程到指定时间流逝为止。先 copy_from_user() 拷入局部变量 t,然后:

current->state = TASK_INTERRUPTIBLE;
remaining = schedule_timeout(timespec_to_jiffies(&t)+1);

timespec_to_jiffies() 把时间间隔换算成 tick 数(为保险多加一个 tick)。进程超时靠动态定时器实现——schedule_timeout() 本质上:

struct timer_list timer;
unsigned long expire = timeout + jiffies;
init_timer(&timer);
timer.expires = expire;
timer.data = (unsigned long) current;
timer.function = process_timeout;
add_timer(&timer);
schedule();                    /* 进程挂起,直到定时器到期 */
del_singleshot_timer_sync(&timer);
timeout = expire - jiffies;
return (timeout < 0 ? 0 : timeout);

schedule() 让出 CPU;到期时定时器函数唤醒进程:

void process_timeout(unsigned long __data)
{
    wake_up_process((task_t *)__data);
}

data 字段存的就是进程描述符指针。进程醒来后继续执行 sys_nanosleep():若 schedule_timeout() 返回 0(超时已到)系统调用结束;否则(因信号等原因提前醒来)系统调用被自动重新执行(见 /linux内核/lk11)。

延迟函数

定时器等待”几毫秒以下”的短时间毫无用武之地——动态定时器建立开销大、最小等待 1 毫秒。设备驱动等硬件完成某个操作的典型等待只有若干微秒,这时用 udelay() / ndelay()(参数分别是微秒/纳秒),本质是执行紧凑指令循环直到时间流逝:

void udelay(unsigned long usecs)
{
    unsigned long loops;
    loops = (usecs*HZ*current_cpu_data.loops_per_jiffy)/1000000;
    cur_timer->delay(loops);
}
 
void ndelay(unsigned long nsecs)
{
    unsigned long loops;
    loops = (nsecs*HZ*current_cpu_data.loops_per_jiffy)/1000000000;
    cur_timer->delay(loops);
}

两者都委托 cur_timer->delay 方法,参数单位是”loops”。一个 loop 的时长取决于定时器对象:

  • timer_hpet/timer_pmtmr/timer_tsc:一个 loop 对应一个 CPU 时钟周期(用 HPET/TSC 硬件精确计量);
  • timer_none/timer_pit:一个 loop 对应紧凑指令循环一次迭代的时间。

初始化阶段 select_timer() 设置好 cur_timer 后,内核执行 calibrate_delay() 算出一个 tick 里能塞下多少 loops,存入 current_cpu_data.loops_per_jiffy——udelay()/ndelay() 靠它做微秒/纳秒到 loops 的换算。

时间相关系统调用

time() 与 gettimeofday()

  • time():返回 1970 年 1 月 1 日午夜(UTC)以来的秒数。已被取代,仅为向后兼容保留;
  • gettimeofday():在 timeval 结构中返回秒数加最后一秒内的微秒数(第二个参数 timezone 目前未用);
  • ftime():已不是系统调用的老函数,返回秒数加毫秒数。

sys_gettimeofday()do_gettimeofday(),其流程充分展示了时间插值的威力:

  1. 读方式获取 xtime_lock
  2. cur_timer->getoffset() 算距上次定时器中断的微秒数:
    • timer_hpet:当前 HPET 计数值 vs 上次中断时存的值;
    • timer_pmtmr:当前 ACPI PMT 计数值 vs 存的值;
    • timer_tsc:当前 TSC 值 vs 存的值;
    • timer_pit:读 PIT 计数器算距上次 PIT 中断的微秒数;
  3. 若有丢失的定时器中断,补上延迟:usec += (jiffies - wall_jiffies) * 1000;
  4. 加上本秒内已流逝的微秒:usec += (xtime.tv_nsec / 1000);
  5. 拷贝 xtime 到用户缓冲区:tv->tv_sec = xtime->tv_sec; tv->tv_usec = usec;
  6. read_seqretry() 校验——若期间有人拿了写锁,跳回第 1 步重做;
  7. 处理微秒溢出:
while (tv->tv_usec >= 1000000) {
    tv->tv_usec -= 1000000;
    tv->tv_sec++;
}

root 权限进程可用 stime()(过时)或 settimeofday() 修改日期时间,do_settimeofday() 做的事与 do_gettimeofday() 互补。注意这两个调用只改 xtime,不动 RTC 寄存器——新时间在关机后丢失,除非用户执行 clock 程序把 RTC 改掉。

adjtimex()

时钟漂移让所有系统迟早偏离正确时间,但猛改时间是既烦人又危险的行为:编译大程序时依赖文件时间戳的 make 可能因时间跳变而错误地判定依赖关系;分布式文件系统更需要各机器时钟协调一致。因此系统通常定期跑 NTP 这类时间同步协议,让时间在每个 tick 渐变——这依赖 adjtimex()。它接收 timex 结构指针,用其中的值更新内核参数并返回填充了当前内核值的结构;update_wall_time_one_tick() 据此微调每个 tick 加到 xtime 上的微秒数,实现快慢调节。

setitimer() 与 alarm()

间隔定时器(interval timer)是用户态进程可激活的特殊定时器,周期性地(或单次延迟后)向进程发送 Unix 信号。每个间隔定时器由两个量刻画:信号发送频率(单次为 0)和距下次信号的剩余时间。同样要记住:只保证”到期后”执行,不保证准时。

POSIX 的 setitimer() 用第一个参数选择计时策略:

策略计时内容信号
ITIMER_REAL真实流逝时间SIGALRM
ITIMER_VIRTUAL进程花在用户态的时间SIGVTALRM
ITIMER_PROF用户态 + 内核态时间SIGPROF

第二参数指向 itimerval 结构(指定初始时长和自动重启时长,0 表示单次),第三参数可选地接收旧值。进程描述符里对应三对字段:it_real_incr/it_real_valueit_virt_incr/it_virt_valueit_prof_incr/it_prof_value——每对的 incr 存两次信号间的 tick 间隔,value 存定时器当前值。

ITIMER_REAL 用动态定时器实现——因为即使进程不在 CPU 上跑,内核也要给它发信号。每个进程描述符有 real_timer 动态定时器对象:setitimer() 初始化它并 add_timer();到期时执行 it_real_fn(),发 SIGALRM,若 it_real_incr 非零则重新设置 expires 让定时器自我复活。

ITIMER_VIRTUALITIMER_PROF 不需要动态定时器——进程运行中就能更新:update_process_times() 每 tick 调用 account_it_virt()/account_it_prof(),到期就给当前进程发信号。alarm() 等价于 ITIMER_REALsetitimer()(同样用 real_timer),所以两者不能同时使用。

POSIX 定时器

POSIX 1003.1b 为多线程和实时应用引入了新定时器。任何实现必须提供若干 POSIX 时钟——具有预定义分辨率和属性的虚拟时间源;应用创建定时器时指定一个 POSIX 时钟作为计时基准。相关系统调用:

系统调用说明
clock_gettime()取 POSIX 时钟当前值
clock_settime()设置 POSIX 时钟当前值
clock_getres()取 POSIX 时钟分辨率
timer_create()基于指定 POSIX 时钟创建新定时器
timer_gettime()取定时器当前值与增量
timer_settime()设定时器当前值与增量
timer_getoverrun()取已到期定时器的”超发”次数
timer_delete()销毁定时器
clock_nanosleep()以 POSIX 时钟为基准睡眠

Linux 2.6 提供两种 POSIX 时钟:

  • CLOCK_REALTIME:系统实时时钟,本质就是 xtimeclock_getres() 返回分辨率 999848 纳秒(约每秒更新 1000 次);
  • CLOCK_MONOTONIC:剔除一切因外部时钟同步造成的时间扭曲的时钟,本质是 xtime + wall_to_monotonic;分辨率同为 999848 纳秒。

POSIX 定时器由动态定时器实现,类似 ITIMER_REAL 但灵活可靠得多,两个关键差异:

  • 传统间隔定时器到期只能发 SIGALRM 给激活它的进程;POSIX 定时器到期可发任意信号,可发给整个多线程应用或单个指定线程,还可以强制执行应用某线程中的通知函数,甚至什么都不做(交由用户态库处理);
  • 传统定时器多次到期而进程收不到信号(信号被阻塞或进程没在跑)时,只有第一个信号送达、其余全丢;POSIX 定时器同样丢信号,但进程可调 timer_getoverrun() 查出第一个信号以来定时器共到期了几次。

通关标准

能完整讲出”一秒是怎么走出来的”:PIT 每 tick 发 IRQ0 → 定时器中断处理程序拿 xtime_lock → mark_offset 记录插值基准 → jiffies_64 加 1 → update_times 刷新 xtime、calc_load 算负载 → update_process_times 记账并激活 TIMER_SOFTIRQ → run_timer_softirq 处理到期动态定时器。并能解释为什么每一步这么安排(中断里少干活、可延迟函数收尾)。