从零写OS内核 | Clocksource 框架——Linux 怎么在 TSC、HPET、LAPIC 里挑最优时钟源

你有没有遇到过这种奇怪的现象:在多核机器上,连续两次调用 clock_gettime(CLOCK_MONOTONIC),居然返回了倒流的时间?或者在虚拟机里,系统时间突然”跳”了一下,然后又慢慢修正回来?

这背后,是 Linux 时钟子系统在发挥作用。Linux 不是只用一种时钟硬件,而是抽象出一层Clocksource 框架,让不同平台的不同硬件,都能以统一接口向上提供服务。今天,我们就来拆解这个框架——它是怎么工作的,优先级怎么选,以及你的程序是怎么受益的。


什么是 Clocksource:为什么需要它

x86 平台上有至少四种时钟硬件:

硬件 精度 特性 缺点
TSC(Time Stamp Counter) 纳秒级 CPU 内置,零开销,恒定速率 频率会变(降频),VM 里可能不准
HPET(High Precision Event Timer) ~100ns 独立芯片,物理上恒定 有访问延迟,多核间同步难
LAPIC Timer ~100ns 每个 CPU 一个,本地中断 依赖 APIC 时钟,跨核不精确
ACPI PM Timer ~100ns 物理时间,不受电源影响 频率固定(3.579545MHz),精度一般

问题来了:用户进程只调用一个 clock_gettime(),内核该用哪个时钟?

答案是:不要让用户决定,让内核帮你选。Clocksource 框架干的就是这件事——把底层硬件抽象成统一的”计数器”,上层不问”哪个硬件”,只问”现在过了多少纳秒”。


架构:clocksource 结构体

Linux 用一个结构体描述每一种时钟硬件:

C
// include/linux/clocksource.h
struct clocksource {
    u64 (*read)(struct clocksource *cs);   // 读取当前计数值
    u64 mask;                                // 计数器位宽(如 ~0ULL 表示 64 位)
    u32 mult;                                 // 计数值 → 纳秒的乘数
    u32 shift;                                // 右移位数(mult/shift 共同决定精度)
    u32 min_corr_ns;                         // 最小校正量(用于 slewing)
    const char *name;                        // 时钟源名称,如 "tsc""hpet"
    struct list_head list;                   // 链表节点(所有注册过的 clocksource 都挂在一起)
    enum clocksource_ids id;                 // CLOCK_SOURCE_{TSC,HPET,LAPIC,ACPI}
    u32 rating;                              // 优先级评分(越大越好)
    int (*enable)(struct clocksource *cs);   // 可选:启用时的回调
    void (*disable)(struct clocksource *cs); // 可选:禁用时的回调
};

核心逻辑:每次读取 read() 得到一个原始计数值(cycles),乘以 mult、右移 shift 位,得到纳秒数。这个过程完全由内核完成,上层代码不感知你用的是 TSC 还是 HPET。

rating 字段是整个框架的核心——每个 clocksource 注册时给自己打分,分数最高的被选用


评分机制:TSC 为什么总是赢

评分规则(arch/x86/kernel/tsc.c):

C
// TSC 的评分逻辑(简化)
static int __init tsc_init(void)
{
    // 1. 检查 TSC 是否可用
    if (!cpu_has_tsc) return 0;

    // 2. 检查是否是 Invariant TSC(不会因降频而改变速率)
    if (tsc_detect_invariance() == INVARIANT_TSC) {
        // Invariant TSC = 最高分
        clocksource_tsc.rating = 350;
    } else {
        // 普通 TSC,降频时会变,慢一点
        clocksource_tsc.rating = 300;
    }

    // 3. HPET 如果存在,会在 tsc.c 里降分(避免 HPET 优于 TSC)
    // 因为 HPET 有访问延迟,TSC 更轻量
    if (hpet_present)
        clocksource_hpet.rating = 250;  // HPET 降权

    return 0;
}

评分对应表(越往上越优先):

Bash
rating >= 350:Invariant TSC(最佳,CPU 本地,无延迟)
rating 300-349:普通 TSC(可用,但降频时会跳)
rating 250-299:HPET(独立芯片,有访问延迟)
rating 100-249:LAPIC Timer(本地,但精度一般)
rating < 100:ACPI PM Timer(最差,精度最低)

为什么 TSC 是最强的?三个原因:

  1. 零开销:读取 TSC 只要一条指令 rdtsc,大约 30 个 CPU 周期。而 HPET 要 MMIO 读寄存器,至少 100+ 周期。
  2. 本地性:TSC 在 CPU 内部,不受外部总线延迟影响。
  3. 恒定速率(Invariant TSC):即使 CPU 降频(p-state)、休眠(C-state),Invariant TSC 依然以固定频率运行,不受电源管理影响。

read() → ns:cycles2ns 的数学

multshift 是 clocksource 的精妙之处。给定一个 cycles 值,转换为纳秒的公式是:

Bash
ns = cycles × mult >> shift

这不是除法,是位运算,所以极快。

以 TSC 为例,假设 TSC 频率是 3.6GHz(即每周期 ~0.278 纳秒):

C
// 3.6 GHz TSC(周期 = 1/3.6e9 秒 = 0.277... ns)
// mult/shift 怎么算出来的?
// mult = (10^9 << shift) / freq
// 取 shift = 32,mult = (10^9 << 32) / 3600000000
//        = 0x‭1163X‭      (实际内核值)

cycles = 1000000;  // 100 万个 TSC 周期
ns = (cycles * mult) >> shift
   ≈ 277777778  // ≈ 278ms

这个转换在 cycle_to_ns 宏里:

C
// include/linux/clocksource.h
#define cyc2ns(c,csi) ((csi->mult * (c)) >> csi->shift)

为什么用乘法和位移,而不是除法?因为乘加指令在 CPU 里是一个时钟周期,除法则要几十个周期。在 clock_gettime 这种高频路径上,每一次优化都很重要。


时钟切换:mark_clocksource_as_invalid

但 TSC 也有失效的时候。最常见的情况:

  1. 虚拟机迁移:VM 从一台宿主机迁移到另一台,TSC 频率可能不同
  2. CPU 热拔:某个核下线,TSC 计数不再连续
  3. BIOS 配置错误:有些机器的 TSC 频率不准确

这时,Linux 会调用 clocksource_change_rating() 降分,或者直接 disable_clocksource(),然后选下一个最好的。

C
// kernel/time/clocksource.c
static void clocksource_select(void)
{
    struct clocksource *best = NULL;

    // 遍历所有注册过的 clocksource
    list_for_each_entry(cs, &clocksource_list, list) {
        if (!cs->enabled) continue;
        if (cs->rating > best->rating)
            best = cs;
    }

    // 切换到最好的那个
    if (best != curr_clocksource)
        select_clocksource(best);
}

墙上时间 vs 单调时间:两种时间账户

你调用 clock_gettime(CLOCK_REALTIME, ...)clock_gettime(CLOCK_MONOTONIC, ...) 时,内核走的路径是不同的:

Bash
CLOCK_REALTIME  →  时钟源(TSC/HPET)+ NTP slewing + 闰秒处理
                    ↓
               timekeeper 维护的 xtime(墙上时间,可被 adjtimex 修改)

CLOCK_MONOTONIC →  时钟源(TSC/HPET)+ 系统启动后的偏移
                    ↓
               timekeeper 维护的 monotonic时间(不可被用户修改)

CLOCK_MONOTONIC 永远只增不减——即使管理员用 date 命令把系统时间改成 2020 年,CLOCK_MONOTONIC 依然从系统启动那天开始计时,不受影响。这对于测量时间间隔(比如 gettimeofday 计算 RTT)至关重要。

CLOCK_REALTIME 是”墙上时钟”,可以被 NTP 修正、date -s 修改,也可以因为闰秒(leap second)跳一秒。用它来比较时间戳要小心——正常情况下不会,但闰秒那一天可能会”跳”。


NTP 和时钟调整:adjtimex 怎么工作的

adjtimex 系统调用允许管理员或 NTP 守护进程微调系统时钟速度,而不是直接设置时间值。

Bash
# 查看当前时钟状态
adjtimex -p

# 输出示例:
# mode: 0
# offset: 12345    ← 当前的微秒偏移(正在逐步修正)
# freq: 4321000   ← 频率补偿值(PPM,每百万分之一)
# maxerror: 1000
# status: 16 (TIME_OK)

内核用 timekeeper 维护两个关键变量:

C
struct timekeeper {
    struct clocksource *clock;       // 当前选中的 clocksource
    u64             elapsed_real;    // 启动后的总真实时间(ns)
    s64             offset_real;     // CLOCK_REALTIME 的偏移量(正在 slewing)
    s64             offset_monotonic; // CLOCK_MONOTONIC 的偏移量
    u32             mult;            // 当前 mult(可随 NTP 调整)
    int             leap;            // 闰秒状态(+1/-1/0)
};

slewing 的原理:NTP 不直接”跳时间”,而是每次 tick 把 offset 慢慢加回去。比如检测到系统快了 100ms,NTP 会告诉内核”把 mult 调小一点点”,然后在接下来几小时内慢慢把 100ms 追回来。这叫时钟漂移补偿(frequency compensation)

C
// kernel/time/timekeeping.c
static int __freq_adj(struct timekeeper *tk, s64 delta_ns)
{
    // delta_ns 是测得的误差
    // 把它平摊到每个 tick 里,逐渐修正
    s64 mult_adj = (delta_ns << tk->shift) / (NSEC_PER_SEC / HZ);
    tk->mult += mult_adj;   // 调整 mult,而不是直接改时间
}

Linux 实践:动手观察你的机器用哪个时钟

Bash
# 1. 查看当前使用的 clocksource
cat /sys/devices/system/clocksource/clocksource0/current_clocksource

# 典型输出(物理机):
# tsc

# 典型输出(虚拟机):
# kvm-clock    ← KVM 虚拟化提供的时钟源

Bash
# 2. 查看所有可用的 clocksource(不同平台数量不同)
cat /sys/devices/system/clocksource/clocksource0/available_clocksource

# 输出:
# tsc hpet acpi_pm

Bash
# 3. 查看各时钟源的精度和评分
cat /sys/devices/system/clocksource/clocksource0/clocksource0/acc
# 或在 /sys/kernel/debug/clocksource 目录查看

Bash
# 4. 手动切换时钟源(需要 root)
echo hpet > /sys/devices/system/clocksource/clocksource0/current_clocksource

# 验证
cat /sys/devices/system/clocksource/clocksource0/current_clocksource
# 输出:hpet

Bash
# 5. 用 perf 测量 read() 的开销
perf stat -e cycles:u ./clock_test 1000000

# 观察:TSC 路径只有 1 条指令(rdtsc)
#      HPET 路径要读 MMIO,有额外延迟

从零实现:代码怎么写(wandos)

wandos 目前没有完整的 clocksource 框架,这是值得补充的方向。以下是一个简化实现的思路:

核心结构

Cpp
// kernel/time/clocksource.hpp
class ClockSource {
public:
    virtual ~ClockSource() = default;
    virtual u64 read_cycles() = 0;       // 读取原始周期数
    virtual const char* name() const = 0; // 返回名称
    virtual u32 rating() const = 0;      // 评分
    virtual u64 cyc_to_ns(u64 cycles) const = 0; // cycles → ns 转换

protected:
    u32 mult_;
    u32 shift_;
};

TSC 实现(最简单的 clocksource):

Cpp
// kernel/time/clocksource_tsc.cpp
class TSCSource : public ClockSource {
public:
    u64 read_cycles() override {
        u64 lo, hi;
        __asm__ volatile("rdtsc" : "=a"(lo), "=d"(hi));
        return (hi << 32) | lo;
    }

    const char* name() const override { return "tsc"; }
    u32 rating() const override { return 350; }  // 最高分

    u64 cyc_to_ns(u64 cycles) const override {
        // cycles * mult >> shift
        // mult/shift 由时钟频率决定(启动时测量)
        return (cycles * mult_) >> shift_;
    }
};

时钟选择器(选出最高分的 clocksource):

Cpp
// kernel/time/clock_mgr.cpp
class ClockManager {
    std::vector<ClockSource*> sources_;
    ClockSource* current_ = nullptr;

public:
    void register_source(ClockSource* cs) {
        sources_.push_back(cs);
        // 每次注册后重新选择最优时钟源
        reselect();
    }

    void reselect() {
        ClockSource* best = nullptr;
        for (auto* cs : sources_) {
            if (!best || cs->rating() > best->rating())
                best = cs;
        }
        current_ = best;
    }

    u64 get_cycles() const {
        return current_ ? current_->read_cycles() : 0;
    }

    u64 get_ns() const {
        u64 cyc = get_cycles();
        return current_ ? current_->cyc_to_ns(cyc) : 0;
    }
};

wandos 当前的实际状态/tmp/wandos/kernel/ 下没有 time 子目录,时钟相关代码分散在 arch/x86/ 的启动代码里(如 ap_boot.asm 里的 delay 逻辑)。如果要实现完整的 clocksource 框架,需要新建 kernel/time/ 并参照上述结构。差距说明:wandos 目前只是”用了时钟”,没有”抽象时钟”。


动手环节:实现你的版本

今天的作业:给 wandos 添加一个 clocksource 框架。

目标

  1. 实现 ClockSource 基类,包含 read_cycles()cyc_to_ns()rating()
  2. 实现 TSCSource(rating=350)和 HPETSource(rating=250)
  3. 实现 ClockManager::reselect(),每次注册新时钟源时选出最优
  4. clock_gettime(CLOCK_MONOTONIC) 调用 ClockManager(系统调用路径后续再接)

参考实现

Bash
# 1. clone wandos
git clone https://github.com/zhangfuwen/wandos.git
cd wandos

# 2. 新建目录
mkdir -p kernel/time

# 3. 实现基类(clocksource.hpp)和 TSC 实现(clocksource_tsc.cpp)
# 参考 kernel/process/scheduler.cpp 的代码风格(注释清晰)

# 4. 写测试程序验证 mult/shift 的精度

验收标准

  • TSCSource::cyc_to_ns(1000000) 在 3.6GHz CPU 上返回约 277-278ms
  • ClockManager::reselect() 在注册 TSC 和 HPET 后正确选出 TSC

踩坑与注意事项

坑 1:TSC 频率不是恒定的(降频问题)

现象:在笔记本上(CPU 降频后),用 TSC 计时 10 秒,实际过了 12 秒。

原因:老式 TSC 会随 CPU 频率变化而变化。Linux 会检测并降权普通 TSC(rating 300),只给 Invariant TSC 打 350 分。

解决:启动时用 tsc_init() 检测。如果 cpu_has_invariant_tsc 为 false,优先使用 HPET。

坑 2:虚拟机里 TSC 不可靠

现象:在 KVM/VMware 虚拟机里,clock_gettime 有时返回历史时间(倒流)。

原因:虚拟机里 TSC 是虚拟的,如果宿主机调度了 vCPU,TSC 可能暂停。Linux 的解决方法是切到 kvm-clock(rating 310,专门为虚拟机优化)。

Bash
# 在 VM 里查看
cat /sys/devices/system/clocksource/clocksource0/current_clocksource
# 输出可能是:kvm-clock

坑 3:mult/shift 计算错误导致时间溢出

现象:cyc2ns() 的结果偶尔会变成负数。

原因:mult * cycles 可能超出 64 位范围(当 cycles 很大时)。实际解法是在乘法前检查 cycles > (ULLONG_MAX / mult),如果超出则截断或分步计算。Linux 内核在 cycle_to_ns() 里做了这个保护:

C
static inline u64 cycle_to_ns(u64 cycles)
{
    u64 ns = (cycles * csi-&gt;mult) &gt;&gt; csi-&gt;shift;
    return ns;
}

但在极端情况下(比如系统运行了很久,cycles 值很大),这种乘法仍可能溢出——所以生产代码通常还会做边界检查。


写在最后

Clocksource 框架的核心,是一个简单的评分机制——每种时钟硬件给自己打分,Linux 始终选用分数最高的那个。TSC 因为零开销、本地性、Invariant 特性,是物理机的首选;kvm-clock 因为虚拟化安全,是 VM 的首选。

下篇预告(360):下一个编号是 360。候选方向:TTY/文本终端(键盘输入到屏幕输出,完整的字符设备驱动框架),或者第一个用户态进程(init/ systemd 的前身)。你选哪个?

仓库https://github.com/golang12306/os-kernel-from-scratch


相关阅读

  • Linux Kernel Source: include/linux/clocksource.h(clocksource 结构体定义)
  • Linux Kernel Source: kernel/time/clocksource.c(时钟选择逻辑)
  • Linux Kernel Source: arch/x86/kernel/tsc.c(TSC 检测和评分)
  • Linux Kernel Source: kernel/time/timekeeping.c(timekeeper 和 NTP slewing)
  • https://github.com/zhangfuwen/wandos — Linux 内核教程(wandos 当前无 clocksource 实现)

动手环节

想深入理解本文内容?动手实践是最好的方式:

今天的目标:下载 wandos 代码仓库,实现文中的 clocksource 框架,对比和 Linux 原版的差距。

  1. 下载 wandos

    Bash
    git clone https://github.com/zhangfuwen/wandos.git
    cd wandos
  2. 找到对应模块:根据本文内容,在 kernel/ 下新建 time/ 目录实现 clocksource

  3. 实现作业:参照 kernel/process/scheduler.cpp 的风格,实现 ClockSource 基类 + TSCSource + HPETSource + ClockManager

  4. 提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request

提示:wandos 采用 C++ + x86 汇编实现,参考 kernel/ 下的现有代码风格,注释清楚再提交。


仓库:https://github.com/golang12306/os-kernel-from-scratch

最后修改: 2024年8月16日

作者

评论

发表评论

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