从零写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 用一个结构体描述每一种时钟硬件:
// 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):
// 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;
}评分对应表(越往上越优先):
rating >= 350:Invariant TSC(最佳,CPU 本地,无延迟)
rating 300-349:普通 TSC(可用,但降频时会跳)
rating 250-299:HPET(独立芯片,有访问延迟)
rating 100-249:LAPIC Timer(本地,但精度一般)
rating < 100:ACPI PM Timer(最差,精度最低)为什么 TSC 是最强的?三个原因:
- 零开销:读取 TSC 只要一条指令
rdtsc,大约 30 个 CPU 周期。而 HPET 要 MMIO 读寄存器,至少 100+ 周期。 - 本地性:TSC 在 CPU 内部,不受外部总线延迟影响。
- 恒定速率(Invariant TSC):即使 CPU 降频(p-state)、休眠(C-state),Invariant TSC 依然以固定频率运行,不受电源管理影响。
read() → ns:cycles2ns 的数学
mult 和 shift 是 clocksource 的精妙之处。给定一个 cycles 值,转换为纳秒的公式是:
ns = cycles × mult >> shift这不是除法,是位运算,所以极快。
以 TSC 为例,假设 TSC 频率是 3.6GHz(即每周期 ~0.278 纳秒):
// 3.6 GHz TSC(周期 = 1/3.6e9 秒 = 0.277... ns)
// mult/shift 怎么算出来的?
// mult = (10^9 << shift) / freq
// 取 shift = 32,mult = (10^9 << 32) / 3600000000
// = 0x1163X (实际内核值)
cycles = 1000000; // 100 万个 TSC 周期
ns = (cycles * mult) >> shift
≈ 277777778 // ≈ 278ms这个转换在 cycle_to_ns 宏里:
// include/linux/clocksource.h
#define cyc2ns(c,csi) ((csi->mult * (c)) >> csi->shift)为什么用乘法和位移,而不是除法?因为乘加指令在 CPU 里是一个时钟周期,除法则要几十个周期。在 clock_gettime 这种高频路径上,每一次优化都很重要。
时钟切换:mark_clocksource_as_invalid
但 TSC 也有失效的时候。最常见的情况:
- 虚拟机迁移:VM 从一台宿主机迁移到另一台,TSC 频率可能不同
- CPU 热拔:某个核下线,TSC 计数不再连续
- BIOS 配置错误:有些机器的 TSC 频率不准确
这时,Linux 会调用 clocksource_change_rating() 降分,或者直接 disable_clocksource(),然后选下一个最好的。
// 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, ...) 时,内核走的路径是不同的:
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 守护进程微调系统时钟速度,而不是直接设置时间值。
# 查看当前时钟状态
adjtimex -p
# 输出示例:
# mode: 0
# offset: 12345 ← 当前的微秒偏移(正在逐步修正)
# freq: 4321000 ← 频率补偿值(PPM,每百万分之一)
# maxerror: 1000
# status: 16 (TIME_OK)内核用 timekeeper 维护两个关键变量:
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)。
// 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 实践:动手观察你的机器用哪个时钟
# 1. 查看当前使用的 clocksource
cat /sys/devices/system/clocksource/clocksource0/current_clocksource
# 典型输出(物理机):
# tsc
# 典型输出(虚拟机):
# kvm-clock ← KVM 虚拟化提供的时钟源# 2. 查看所有可用的 clocksource(不同平台数量不同)
cat /sys/devices/system/clocksource/clocksource0/available_clocksource
# 输出:
# tsc hpet acpi_pm# 3. 查看各时钟源的精度和评分
cat /sys/devices/system/clocksource/clocksource0/clocksource0/acc
# 或在 /sys/kernel/debug/clocksource 目录查看# 4. 手动切换时钟源(需要 root)
echo hpet > /sys/devices/system/clocksource/clocksource0/current_clocksource
# 验证
cat /sys/devices/system/clocksource/clocksource0/current_clocksource
# 输出:hpet# 5. 用 perf 测量 read() 的开销
perf stat -e cycles:u ./clock_test 1000000
# 观察:TSC 路径只有 1 条指令(rdtsc)
# HPET 路径要读 MMIO,有额外延迟从零实现:代码怎么写(wandos)
wandos 目前没有完整的 clocksource 框架,这是值得补充的方向。以下是一个简化实现的思路:
核心结构:
// 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):
// 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):
// 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 框架。
目标:
- 实现
ClockSource基类,包含read_cycles()、cyc_to_ns()、rating() - 实现
TSCSource(rating=350)和HPETSource(rating=250) - 实现
ClockManager::reselect(),每次注册新时钟源时选出最优 - 让
clock_gettime(CLOCK_MONOTONIC)调用ClockManager(系统调用路径后续再接)
参考实现:
# 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-278msClockManager::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,专门为虚拟机优化)。
# 在 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() 里做了这个保护:
static inline u64 cycle_to_ns(u64 cycles)
{
u64 ns = (cycles * csi->mult) >> csi->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 原版的差距。
-
下载 wandos:
git clone https://github.com/zhangfuwen/wandos.git cd wandos -
找到对应模块:根据本文内容,在
kernel/下新建time/目录实现 clocksource -
实现作业:参照
kernel/process/scheduler.cpp的风格,实现ClockSource基类 +TSCSource+HPETSource+ClockManager -
提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request
提示:wandos 采用 C++ + x86 汇编实现,参考 kernel/ 下的现有代码风格,注释清楚再提交。
评论