358|定时器:HPET 和时钟中断

金句:操作系统的时间,不是”看表”看出来的,是被中断”打断”出来的。每一次时钟中断,内核就知道又过了一个 jiffy——然后决定该让谁上场。


1. 三个时钟硬件

x86 平台有三种时钟硬件,按历史顺序:

1.1 RTC(Real Time Clock)

最古老,最慢,用于关机时也保持时间

  • 独立供电(纽扣电池),即使断电也不丢
  • 只能设置较慢的频率(最高 8192Hz)
  • 现在只用来记录墙上时间(年/月/日/时/分/秒)

1.2 PIT(Programmable Interval Timer)

8253/8254 芯片,IBM PC 时代的标准定时器。

  • 只能设置 3 个独立计数器
  • 最高频率约 1.193MHz(时钟晶振 14.31818MHz / 12 分频)
  • Linux 早期用它产生系统时钟中断(IRQ 0)
  • 现在已被 HPET 和 APIC 取代

1.3 HPET(High Precision Event Timer)

现代 PC 的标准时钟,2007 年后成为标配。

  • 至少 10MHz 时钟基准(精度 100ns)
  • 最多 32 个比较器(comparator),每个可独立触发不同中断
  • 支持 64 位计数器,永不溢出(vs PIT 的 16 位计数器)
  • 可以一次性编程多个定时器,不需要轮询

Bash
HPET 结构:

┌─────────────────────────────────────┐
│  Main Counter(64 位,10MHz+)       │  统一时钟源
└─────────────────────────────────────┘
    ├── Comparator 0 → IRQ 2(或映射到 APIC)
    ├── Comparator 1 → IRQ 3
    ├── Comparator 2 → IRQ 4
    └── ...最多 32

2. 时钟中断和 jiffies

Linux 内核有一个全局变量 jiffies

C
volatile unsigned long jiffies;
// 记录系统启动以来经历的中断次数
// 每次时钟中断(timer tick)自动 +1

如果 HZ = 100(常见配置),jiffies 每秒增加 100,即每个 jiffy = 10ms

Bash
jiffies 增长过程:
012 → ... → 99100 → ...
|____|← 1 个 jiffy = 10ms →|

HZ 的选择

  • HZ=100:桌面系统(每 10ms 响应一次)
  • HZ=1000:服务器/实时系统(每 1ms,更精细但 CPU 开销大)
  • HZ=300:某些 Unix 系统传统

C
// 内核里的 HZ 定义(arch/x86/include/asm/param.h)
#ifdef CONFIG_HZ
# define HZ CONFIG_HZ
#else
# define HZ 100
#endif

// jiffies 的读取
#define get_jiffies() (jiffies)

// 从 jiffies 转换到秒/毫秒
int seconds = jiffies / HZ;
int ms = (jiffies * 1000) / HZ;

3. 时钟中断的完整路径

硬件层(HPET → CPU)

Bash
HPET Comparator 0 触发(达到设置的周期值)
  ↓
APIC 接收 IRQ 0(时钟中断)
  ↓
CPU 执行 APIC 中断向量(通常是 0xEC = 236)
  ↓
CPU 跳到 IDT[236](trap gate)
  ↓
进入内核定时器中断处理函数

内核层(IRQ 0 handler)

C
// arch/x86/entry/entry_64.S
IRQ_DOMAIN[236]:
    irq_entries_start:
        pushq    $~0           // vector number
        interrupt_request_0    // → do_timer(arch/x86/kernel/time.c)

C
// kernel/time/timer.c
DEFINE_PER_CPU(struct tvec_base, tvec_bases);

void do_timer(void)
{
    // 1. jiffies +1
    ++jiffies;

    // 2. 更新墙上时间(从 RTC 同步)
    update_wall_time();

    // 3. 更新 CPU 时间统计
    update_process_times(user_mode(get_irq_regs()));

    // 4. 调度器运行时间检查(如果时间片用完)
    scheduler_tick();
}

4. schedule_tick:怎么让进程”时间片耗尽”?

每个进程有一个运行时间片(time slice),由调度器分配。

C
// kernel/sched/core.c
void scheduler_tick(void)
{
    struct rq *rq = this_rq();
    struct task_struct *p = current;

    if (task_on_rq_queued(p)) {
        // 减少当前进程的时间片
        p->se.sum_exec_runtime += delta_exec;
        p->sevruntime -= delta_exec;

        // 时间片是否耗尽?
        if (p->se.time_slice <= 0) {
            // 重新计算时间片
            resched_task(p);    // 设置 TIF_NEED_RESCHED 标志
        }
    }

    // 更新 rq 的负载信息
    update_cpu_load_active();
}

关键:resched_task(p) 设置了进程的 TIF_NEED_RESCHED 标志(thread_info 的 bit 0)。

这意味着:不强制立刻切换,只是打一个标记。等到内核代码的某个安全点(系统调用返回、中断返回),才会真正调用 schedule()


5. timer wheel:O(1) 的定时器管理

Linux 的软件定时器(set_timeout/add_timer)不是用链表实现,而是用哈希定时器轮(timer wheel)。

C
struct timer_list {
    struct list_head entry;
    unsigned long expires;      // 过期 jiffies
    void (*function)(struct timer_list *);
    unsigned long data;
};

timer wheel 的结构:

Bash
timer_wheel 数组(5 层,每层覆盖不同时间范围):

tv1[256]:   0 ~ 255 jiffies        (4KB ~ 2.5s)
tv2[64]:    256 ~ 16383 jiffies    (2.5s ~ 163s)
tv3[64]:    16384 ~ 1048575 jiffies  (163s ~ 17min)
tv4[64]:    1048576 ~ 67108863 jiffies (17min ~ 18h)
tv5[1]:     67108864+ jiffies        (> 18h)

每层的每个桶(bucket)是一个定时器链表,按过期时间排序。

插入定时器 O(1):根据 expires 计算该放到哪个桶,插入链表头。

过期检查:每次 do_timer 触发时,检查 tv1[current_jiffies % 256] 桶,如果有定时器过期,执行并删除。

如果低层桶的第一个定时器还有很久才到期,不会检查高层,减少遍历开销。

这就是为什么 Linux 可以管理上万个定时器而不会变慢。


6. 定时器的使用:setTimeout 怎么实现的?

用户态的 setTimeout(func, 1000ms) 背后:

C
// 用户态
setTimeout(func, 1000);    // glibc 的 pthread 库

// glibc 内部(简化)
struct itimerspec t = {
    .it_value.tv_sec = 1,
    .it_value.tv_nsec = 0
};
timer_create(CLOCK_REALTIME, NULL, &timer_id);
timer_settime(timer_id, 0, &t, NULL);
// 1 秒后 SIGALRM 信号触发

内核层(sys_timer_create + sys_timer_settime):

C
// kernel/time/posix-timers.c
asmlinkage sys_timer_create(clockid_t which_clock,
    struct sigevent *event, timer_t *timer_id)
{
    // 创建 timer(内核对象)
    struct k_itimer *new_timer = alloc_kitimer();

    // 注册到 clock 的 timerqueue
    posix_timer_event(new_timer, 0);
    return 0;
}

asmlinkage sys_timer_settime(timer_t timer_id, int flags,
    const struct itimerspec *new_value, ...)
{
    // 设置到期时间
    new_timer->expires = timespec_to_jiffies(new_value->it_value);

    // 加入 timer wheel(下次时钟中断时检查)
    internal_add_timer(&new_timer->it_timer);
}

到期时,内核发 SIGALRM 给进程,触发用户态的信号处理函数,执行注册的回调。


7. 延迟调度:schedule_timeout 怎么实现的?

Linux 提供一种”睡 N 个 jiffy 后再调度”的机制:

C
// 内核代码
set_current_state(TASK_INTERRUPTIBLE);
schedule_timeout(5 * HZ);   // 睡 5 秒
// 醒来后从这里继续执行

实现原理:

Bash
schedule_timeout(5 * HZ):
  1. 设置一个 5*HZ 后过期的定时器
  2. 调用 schedule()(让出 CPU)
  3. 被唤醒后,删除定时器(如果还没触发)
  4. 继续执行

如果 5 秒内有人调用 wake_up_process(),定时器被删除,进程提前被唤醒。这常用于等待某个事件(有超时上限)。


8. 总结

知识点 关键结论
HPET 现代标准高精度时钟,64 位计数器,32 个比较器
时钟中断 HPET/APIC → IRQ 0 → do_timer
jiffies 时钟中断次数的累计,每次 +1
HZ 每秒 jiffies 增量(100/300/1000)
schedule_tick 减少时间片,时间片耗尽设 TIF_NEED_RESCHED
timer wheel 5 层哈希定时器轮,O(1) 插入
定时器使用 过期后发 SIGALRM 信号
schedule_timeout 睡眠 + 定时器,到期自动唤醒或提前被 wake_up 唤醒

下篇预告(359):[待定 — 键盘输入中断到终端显示,或第一个用户态进程 / 上下文切换,选哪个?]


关注公众号「AI不着急」,回复”资料”获取内核学习路线图。

最后修改: 2024年3月24日

作者

评论

发表评论

您的邮箱地址不会被公开。