这一篇在干嘛?
内核做的几乎所有事都离不开时间:进程切换靠时间片、超时检测靠定时器、文件时间戳靠系统时钟。本章从底层硬件时钟(RTC、TSC、PIT、本地 APIC 定时器、HPET)讲到内核的时间管理架构(jiffies、xtime、tick),再到动态定时器的精巧数据结构和各种时间相关系统调用。看完你就知道”系统时间”到底是怎么一秒一秒走出来的。
时钟与定时器电路 | Linux 时间管理架构 | 单处理器架构 | 多处理器架构 | 更新时间与统计 | 动态定时器 | 延迟函数 | 时间相关系统调用
图 1:第 6 章章首插图
时钟与定时器电路
内核要做两类时间测量:
- 维持当前时间与日期——供
time()、gettimeofday()等 API 返回给用户程序,也供内核自己给文件、网络包打时间戳; - 维护定时器——在一段时间流逝后通知内核或用户程序。
这些都靠基于固定频率振荡器和计数器的硬件电路实现。时钟电路(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 芯片内部振荡器频率;LATCH:CLOCK_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_offset 与 get_offset 是灵魂:前者在每次 tick 中断时把精确时刻存起来,后者用存下的值算出距上次中断过去了多少微秒——两者配合让内核达到亚 tick 分辨率(精度远高于 tick 本身),这称为时间插值(time interpolation)。
cur_timer 变量指向当前系统中”最好”的定时器源对应的对象,启动初期指向占位用的 timer_none,内核初始化时由 select_timer() 选定:
| 定时器对象 | 说明 | 时间插值用 | 延迟用 |
|---|---|---|---|
timer_hpet | HPET | HPET | HPET |
timer_pmtmr | ACPI PMT | ACPI PMT | TSC |
timer_tsc | TSC | TSC | TSC |
timer_pit | PIT | PIT | 紧凑循环 |
timer_none | 初始化期占位 | (无) | 紧凑循环 |
优先级从上到下:有 HPET 选 HPET,否则 ACPI PMT,否则 TSC,最后退回一定存在的 PIT。注意本地 APIC 定时器没有对应的定时器对象——它只负责发周期中断,从不用于亚 tick 精度的插值。
jiffies:系统的心跳计数
jiffies 变量记录自系统启动以来流逝的 tick 数,每次定时器中断加 1。80x86 上它是 32 位,约 50 天就回绕(wrap around)。内核靠 time_after、time_after_eq、time_before、time_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:当前日期时间
xtime 是 timespec 类型变量,存当前时间与日期:
tv_sec:自 1970 年 1 月 1 日午夜(UTC)以来的秒数;tv_nsec:当前这秒内已流逝的纳秒数(0~999999999)。
xtime 通常每个 tick 更新一次(约每秒 1000 次)。用户程序取时间、内核更新 inode 时间戳都靠它。xtime_lock 顺序锁(同一把锁也保护 jiffies_64)避免对它的并发访问竞态——它是整个时间管理架构多个临界区的通用保护伞。
单处理器的时间管理架构
UP 系统上,所有时间相关活动都由 PIT 在 IRQ0 上发的中断触发:一部分在中断处理中立即执行,其余交给可延迟函数。
初始化
time_init() 完成:
- 初始化
xtime:用get_cmos_time()从 RTC 读出 1970 年以来的秒数;tv_nsec的设置使得即将到来的jiffies溢出恰好落在秒边界上(与tv_sec加 1 重合)。 - 初始化
wall_to_monotonic:同样是timespec,存”要加到 xtime 上才能得到单调递增时间”的量。因为闰秒和与外部时钟同步都可能让 xtime 突然回跳,有时内核需要真正单调的时间源。 - 若支持 HPET,调
hpet_enable()探测芯片(BIOS 是否已映射寄存器),成功则编程第一个定时器让 IRQ0 每秒中断 1000 次;否则用已被init_IRQ()编程好的 PIT。 - 调
select_timer()选最佳定时器源,设置cur_timer。 - 调
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)执行以下步骤:
write_seqlock()获取xtime_lock(写),保护所有时间相关变量;- 执行
cur_timer->mark_offset方法:timer_hpet:检查自上次 tick 以来是否丢失过定时器中断(万一丢了就补上jiffies_64),记录 HPET 周期计数器当前值;timer_pmtmr:中断源仍是 PIT,但用 ACPI PMT 计数器做精细测量,同样检查丢中断并记录 PMT 计数值;timer_tsc:中断源是 PIT,用 TSC 做精细测量,同样检查丢中断并记录 TSC 值;timer_pit:没有别的电路可用,什么都不做。
- 调
do_timer_interrupt():jiffies_64加 1(仍持有写锁,安全);- 调
update_times()更新系统日期时间、计算系统负载; - 调
update_process_times()做本地 CPU 的时间记账; - 调
profile_tick()做内核代码剖析; - 若系统时钟已与外部时钟同步(之前发过
adjtimex()),每 660 秒(11 分钟)调一次set_rtc_mmss()校准 RTC——帮助联网机器同步时钟。
write_sequnlock()释放xtime_lock;- 返回 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 由本地中断处理程序调用)执行:
- 检查当前进程已运行多久:根据中断发生时进程处于用户态还是内核态,分别调
account_user_time()或account_system_time(),它们会:- 更新当前进程描述符的
utime(用户态 tick 数)或stime(内核态 tick 数);另有cutime/cstime字段累计子进程花费的 CPU tick,出于效率考虑它们不在这里更新,而在父进程查询子进程状态时才结算; - 检查是否达到 CPU 时间限额(
signal->rlim[RLIMIT_CPU].rlim_cur),达到就向 current 发送SIGXCPU和SIGKILL信号; - 调
account_it_virt()和account_it_prof()检查进程的间隔定时器(见后文); - 更新 per-CPU 变量
kstat中的内核统计。
- 更新当前进程描述符的
raise_softirq()在本地 CPU 激活TIMER_SOFTIRQ(动态定时器靠它处理);- 若有 RCU 旧副本待回收,检查本地 CPU 是否经历静息状态并
tasklet_schedule()激活rcu_tasklet(见 /linux内核/lk05); - 调
scheduler_tick()递减当前进程的时间片计数并检查配额是否耗尽(详见 /linux内核/lk07)。
系统负载与内核剖析
calc_load() 每 tick 统计 TASK_RUNNING 和 TASK_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 的定时器视为已到期/失效。
创建并激活一个动态定时器的步骤:
- 创建
timer_list对象 t(静态全局变量 / 函数局部变量(内核栈上)/ 内嵌在动态分配的描述符里); init_timer(&t)初始化(置base为 NULL、lock为开);- 填
function(和data); - 未入链则设好
expires并调add_timer(&t)插入合适的链表; - 已入链则用
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 中动态定时器绑定到激活它的 CPU(add_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() 的逻辑:
- 取本地 CPU 的
tvec_base_t地址; - 拿
base->lock自旋锁、关本地中断; - 外层 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 = &t、t.base = NULL→ 放锁开中断 → 执行t.function(t.data)→ 重新拿锁关中断 → 下一个。执行定时器函数时放开锁,因为函数可能做各种事(甚至睡眠前的准备工作),拿着锁执行会死锁或拖太久。
- 循环结束,所有到期定时器已处理;SMP 上置
running_timer = NULL; - 放锁、开中断。
因为 jiffies 与 timer_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(),其流程充分展示了时间插值的威力:
- 读方式获取
xtime_lock; - 调
cur_timer->getoffset()算距上次定时器中断的微秒数:timer_hpet:当前 HPET 计数值 vs 上次中断时存的值;timer_pmtmr:当前 ACPI PMT 计数值 vs 存的值;timer_tsc:当前 TSC 值 vs 存的值;timer_pit:读 PIT 计数器算距上次 PIT 中断的微秒数;
- 若有丢失的定时器中断,补上延迟:
usec += (jiffies - wall_jiffies) * 1000; - 加上本秒内已流逝的微秒:
usec += (xtime.tv_nsec / 1000); - 拷贝 xtime 到用户缓冲区:
tv->tv_sec = xtime->tv_sec; tv->tv_usec = usec; read_seqretry()校验——若期间有人拿了写锁,跳回第 1 步重做;- 处理微秒溢出:
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_value、it_virt_incr/it_virt_value、it_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_VIRTUAL 和 ITIMER_PROF 不需要动态定时器——进程运行中就能更新:update_process_times() 每 tick 调用 account_it_virt()/account_it_prof(),到期就给当前进程发信号。alarm() 等价于 ITIMER_REAL 的 setitimer()(同样用 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:系统实时时钟,本质就是xtime;clock_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 处理到期动态定时器。并能解释为什么每一步这么安排(中断里少干活、可延迟函数收尾)。
jiffies 为什么故意初始化为 -300000 而不是 0?
为了让没考虑回绕的 buggy 内核代码在开机 5 分钟内就触发溢出、在开发阶段尽早暴露,而不是带着隐患潜伏进稳定版内核。配合 time_after 等宏可以正确比较回绕前后的值;真正的累计值由 64 位的 jiffies_64 承担。
为什么读 jiffies_64 要用顺序锁,而读 32 位 jiffies 不用?
32 位架构上 64 位读不是原子操作:读低半边和高半边之间可能被定时器中断打断、计数被更新,导致高低半边拼起来前后不一致。顺序锁(xtime_lock)保证读到一致版本。32 位 jiffies 的单次读是原子的,天然一致。
动态定时器的 tv1~tv5 分组链表为什么快?
它把到期时间按 tick 区间分层索引:99.6% 的检查只碰 tv1 中一个链表(O(1) 定位),补充操作也只把 tv2 的一个链表摊到 tv1 的 256 个链表,摊销成本极低。对比单一链表每 tick 扫全表、有序链表插入删除 O(n),时间轮做到了查找、插入、删除都近似 O(1)。
在 SMP 系统上,为什么释放资源前用 del_timer_sync() 而不是 del_timer()?
del_timer() 只把定时器从链表摘掉,但如果此时定时器函数已经在另一个 CPU 上开始执行,资源仍会在函数工作时被释放,造成数据损坏。del_timer_sync() 摘除后会等待其他 CPU 上的定时器函数真正结束才返回。若确定函数不会自我重新激活,可用更快的 del_singleshot_timer_sync()。