4、RDTSC 校准:如何通过 CPU 频率(TSC 频率)将 TSC 计数值转换为纳秒/微秒时间

好,咱们接着聊 RDTSC。上一节我们讲了怎么拿到那个 64 位的 TSC 计数值,但光拿到数字没用啊。你想想看,一个计数器从 0 跑到 10 亿,这到底过了多久?是 1 秒还是 0.5 秒?

说白了,TSC 计数值本身没有物理意义。它只是一个不断累加的整数。要把它变成我们熟悉的纳秒、微秒,就必须知道 CPU 的 TSC 频率——也就是每秒钟 TSC 跳了多少次。

我个人习惯把这一步叫做「校准」。嗯,这里要注意,校准不是一次性的。不同 CPU、不同电源策略下,TSC 频率可能会变。我曾在项目中遇到过一台服务器,BIOS 里开了节能模式,结果 TSC 频率在 2.6GHz 和 2.2GHz 之间来回跳,搞得我抓狂了好几天。

4.1 为什么需要校准?

先看一个简单的公式:

时间(秒) = TSC差值 / TSC频率(Hz)

比如你两次 RDTSC 读到的值相差 2,600,000,000,而 CPU 主频是 2.6GHz,那时间就是 1 秒。但如果频率不准呢?

举个例子:

  • 你以为是 2.6GHz,算出来 1 秒
  • 实际是 2.4GHz,真实时间其实是 1.083 秒
  • 误差 8.3%!

在微秒级交易系统里,8% 的误差意味着什么?意味着你的订单可能比别人晚 80 微秒到达交易所。80 微秒,足够让一笔套利从盈利变成亏损。

⚠️ 警告: 不要直接从 /proc/cpuinfo 里读 "cpu MHz" 字段。那个值通常是动态变化的,而且精度不够。我曾经吃过这个亏,后来改用硬件校准才稳定下来。

4.2 校准方法一:利用操作系统时钟

这是最直接的方法。思路很简单:

  1. 记录开始时的 TSC 值
  2. 调用 sleep(1) 或类似函数等待 1 秒
  3. 记录结束时的 TSC 值
  4. 差值就是 TSC 频率

代码示例:

#include <stdio.h>
#include <unistd.h>
#include <stdint.h>

static inline uint64_t rdtsc() {
    uint32_t lo, hi;
    __asm__ volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return ((uint64_t)hi << 32) | lo;
}

double calibrate_tsc_via_sleep() {
    uint64_t start = rdtsc();
    sleep(1);  // 等待 1 秒
    uint64_t end = rdtsc();
    
    double freq_hz = (double)(end - start);
    printf("TSC Frequency: %.2f Hz (%.2f GHz)\n", freq_hz, freq_hz / 1e9);
    return freq_hz;
}

这个方法简单,但有个大问题:sleep(1) 的精度不够。操作系统调度、中断、上下文切换都会引入误差。我实测过,误差通常在 0.1% ~ 0.5% 之间。对于微秒级计时来说,这误差太大了。

💡 技巧: 如果你非要用这个方法,可以多测几次取平均。比如测 10 次,去掉最大最小值,再取平均。这样能把误差压到 0.05% 左右。

4.3 校准方法二:利用 HPET 或 ACPI 定时器

HPET(高精度事件定时器)是主板上的硬件定时器,精度通常在 10 微秒以内。用它来校准 TSC 频率,比 sleep 靠谱多了。

思路是这样的:

  • HPET 有一个固定的频率(通常是 14.31818 MHz 或 24 MHz)
  • 我们同时读取 HPET 计数器和 TSC 计数器
  • 等待 HPET 走过一个已知的时间间隔(比如 1 毫秒)
  • 计算 TSC 在这段时间内的增量

代码示例(伪代码,实际需要访问 HPET 内存映射寄存器):

// 假设 HPET 基地址为 0xFED00000
// HPET 主计数器偏移 0xF0
// HPET 频率可通过寄存器 0x00 读取

uint64_t read_hpet_counter() {
    return *(volatile uint64_t*)(0xFED00000 + 0xF0);
}

double calibrate_tsc_via_hpet() {
    uint64_t hpet_start = read_hpet_counter();
    uint64_t tsc_start = rdtsc();
    
    // 等待 HPET 走过 1 毫秒
    // HPET 频率假设为 14.31818 MHz
    uint64_t hpet_target = hpet_start + 14318;  // 1ms
    while (read_hpet_counter() < hpet_target);
    
    uint64_t tsc_end = rdtsc();
    uint64_t tsc_delta = tsc_end - tsc_start;
    
    // 1ms = 0.001s
    double freq_hz = tsc_delta / 0.001;
    return freq_hz;
}

这个方法精度高很多,误差通常在 0.01% 以内。但缺点是需要访问硬件寄存器,需要 root 权限,而且不同主板的 HPET 基地址可能不同。

🔑 核心要点: HPET 校准是目前生产环境中用得最多的方法。我个人在搭建交易系统时,就是用 HPET 做基准,再配合多次采样取中位数,最终把误差压到了 0.001% 以下。

4.4 校准方法三:利用 CPU 自身的恒定 TSC 特性

现代 Intel 和 AMD CPU 都有一个叫 Invariant TSC 的特性。简单说,就是 TSC 频率不再随 CPU 主频变化,而是固定在一个恒定值上。

你可以通过 CPUID 指令来检测是否支持:

// 检查 Invariant TSC 支持
// CPUID.0x80000007.EDX[8] = 1 表示支持

int check_invariant_tsc() {
    uint32_t eax, ebx, ecx, edx;
    __asm__ volatile("cpuid" 
        : "=a"(eax), "=b"(ebx), "=c"(ecx), "=d"(edx)
        : "a"(0x80000007));
    return (edx >> 8) & 1;
}

如果支持 Invariant TSC,那 TSC 频率就等于 CPU 的「标称频率」。这个值可以从 MSR(模型特定寄存器)中读取:

// MSR 0xCE 包含 TSC 频率信息(部分 CPU)
// 需要内核模块或 wrmsr 指令

uint64_t read_msr(uint32_t msr) {
    uint32_t lo, hi;
    __asm__ volatile("rdmsr" : "=a"(lo), "=d"(hi) : "c"(msr));
    return ((uint64_t)hi << 32) | lo;
}

double get_tsc_freq_from_msr() {
    uint64_t msr_val = read_msr(0xCE);
    // 解析 MSR 得到频率,不同 CPU 格式不同
    // 这里以 Intel Skylake 为例
    uint32_t ratio = (msr_val >> 8) & 0xFF;
    double bus_freq = 100.0e6;  // 100 MHz
    return ratio * bus_freq;
}
⚠️ 注意: MSR 读取需要内核权限。在用户态直接调用 rdmsr 会触发段错误。我一般是在内核模块里做校准,然后把频率值暴露给用户态程序。

4.5 校准后的转换公式

不管用哪种方法,最终我们得到了 TSC 频率 freq_hz。然后转换就很简单了:

// TSC 差值转纳秒
inline uint64_t tsc_diff_to_ns(uint64_t diff, double freq_hz) {
    return (uint64_t)(diff * 1e9 / freq_hz);
}

// TSC 差值转微秒
inline uint64_t tsc_diff_to_us(uint64_t diff, double freq_hz) {
    return (uint64_t)(diff * 1e6 / freq_hz);
}

// 使用示例
uint64_t tsc1 = rdtsc();
// ... 做一些操作 ...
uint64_t tsc2 = rdtsc();
uint64_t elapsed_ns = tsc_diff_to_ns(tsc2 - tsc1, tsc_freq);
printf("Elapsed: %lu ns\n", elapsed_ns);

这里有个性能陷阱:除法运算很慢。在热路径上,我建议用乘法加移位来替代除法。比如:

// 预计算一个乘数因子
// 假设 freq_hz = 2.6e9
// 1ns = 2.6 个 TSC 周期
// 所以 diff / 2.6 ≈ diff * 0.3846

// 用定点数表示
// 2^32 / 2.6e9 ≈ 1.652
// 但更精确的做法是:

uint64_t tsc_to_ns_factor;  // 定点数,Q32 格式

void init_tsc_to_ns(double freq_hz) {
    // 将 1e9 / freq_hz 转换为定点数
    double factor = 1e9 / freq_hz;
    tsc_to_ns_factor = (uint64_t)(factor * (1ULL << 32));
}

inline uint64_t tsc_diff_to_ns_fast(uint64_t diff) {
    // 使用乘法替代除法
    return (diff * tsc_to_ns_factor) >> 32;
}

这样就把除法变成了一个乘法和一个移位。在 Intel CPU 上,乘法延迟只有 3 个周期,而除法要 20-40 个周期。对于微秒级交易系统来说,每一纳秒都很宝贵。

4.6 校准流程总结

我把整个校准流程画了一张图,方便你理解:

TSC 校准流程 1. 选择校准方法 方法A: sleep 校准 方法B: HPET 校准 方法C: MSR 读取 多次采样取中位数 验证 Invariant TSC 解析 MSR 得到频率 得到 TSC 频率 (Hz) 转换公式: 时间 = TSC差值 / 频率

这张图展示了三种校准路径。我个人推荐优先使用 HPET 校准(方法B),因为它兼顾了精度和可靠性。如果硬件不支持 HPET,再退而求其次用 MSR 读取。sleep 校准只适合做快速验证,别用在生产环境。

💡 实战建议: 在交易系统启动时做一次校准,然后把频率值保存到全局变量。运行时每隔几分钟做一次「校验」,检查频率是否发生变化。如果变化超过 0.1%,就重新校准。这样既能保证精度,又不会频繁校准影响性能。

好了,关于 TSC 校准就讲到这里。记住一句话:校准不准,后面全白费。花 10 分钟把校准做好,能省下后面 10 小时的调试时间。


专注资料整理