Skip to content

一次嵌入式 Linux 时钟频率测试的 Debug 记录

当你的测试用例时而通过时而失败,偏差从几个 ns 突然跳到几百万 ns——罪魁祸首可能不是硬件,而是系统里你根本没注意到的那群"时间管家"。


1. TTC 是什么?

TTC(Triple Timer Counter,三路定时器计数器) 是 Cadence 设计的一种硬件定时器 IP 核。顾名思义,每个 TTC 实例包含 3 个独立的计时器通道,所以一个芯片上如果有 3 个 TTC 模块,总共就能提供 9 路独立的定时/计数通道——资源非常充裕。

1.1 硬件架构

在 EA6530 芯片上,TTC 模块分布在两个子系统:

硬件实例所在域Linux 驱动作用
c_ttc0CPU 子系统cdns,ttc(timer-cadence-ttc.c)clocksource + clockevent,系统"心跳"
c_ttc1CPU 子系统tps,timer(timer-tps.c)64 位级联 clocksource,高精度时间基准
s_ttc0SAP 子系统cdns,ttc传感器处理子系统的计时

c_ttc0 的 Timer 1 作为 clocksource(驱动 CLOCK_MONOTONIC 等系统时钟),Timer 2 作为 clockevent(驱动 hrtimer、调度 tick)。可以说,Linux 内核的几乎所有时间相关功能——进程调度、定时器、睡眠、时间戳——都间接依赖 TTC 硬件的准确性。

1.2 时钟源与预分频

每个计时器通道都可以 自由选择 时钟源:

  • 内部时钟(APB 时钟):CPU 子系统用 C_PAPB_CLK,SAP 子系统用 S_PAPB_CLK
  • 外部时钟(ext_clk):CPU 子系统用 C_TTC_EXTCLK,SAP 子系统用 S_TTC_EXTCLK,由时钟发生器提供

选中的时钟还可以经过 预分频器 降频(分频系数 2 至 65536),以适应不同时间尺度的需求。

⚠️ 重要约束:如果使用外部时钟,它的频率必须 小于 APB 时钟频率的 1/8。因为硬件内部将 ext_clk 不作为时钟信号处理,而是作为使能脉冲,先与 pclk 进行双同步后才读取——如果太快就采不准了。

1.3 工作模式

每个计时器通道可在以下四种模式之一工作,每种都支持递增或递减计数:

  • 间隔模式,递增:计数值等于间隔寄存器值时,计数器复位为零,触发间隔中断,重新开始向上计数
  • 间隔模式,递减:计数值等于零时,触发间隔中断,计数器重置为间隔寄存器值,重新开始向下计数
  • 溢出模式,递增:计数值达到 0xFFFFFFFF 后溢出归零,触发溢出中断,重新开始向上计数
  • 溢出模式,递减:计数值达到零时触发溢出中断,计数器溢出至 0xFFFFFFFF,重新开始向下计数

通俗地说:

  • 间隔模式 = 在一个设定的间隔值和 0 之间循环计数,常用于产生 固定周期 的定时中断(比如每 1ms 中断一次)
  • 溢出模式 = 利用完整的 32 位空间自由运行,常用于产生 长周期 的溢出中断

⚠️ 溢出中断和间隔中断是 互斥 的——由当前工作模式决定启用哪个。

1.4 中断机制——每个通道 6 种中断源

这是 TTC 的一大亮点。每个计时器通道最多可触发 6 种不同来源的中断

中断类型触发条件说明
匹配中断 1计数值 == 匹配寄存器 1可在同一周期内产生多个时间点的触发
匹配中断 2计数值 == 匹配寄存器 2
匹合中断 3计数值 == 匹配寄存器 3
溢出中断计数值经过零点(溢出模式)与间隔中断互斥
间隔中断计数值经过零点(间隔模式)与溢出中断互斥
事件定时器中断ext_clk 脉冲宽度测量溢出用于测量外部信号宽度

三个匹配寄存器使得单个计时器通道就能在同一个计数周期内产生 多个不同时间点 的触发信号——这对 PWM 波形生成、多相位采样等场景非常实用。

1.5 级联扩展

TTC 支持 计数器级联:将多个 32 位定时器串联起来形成 64 位甚至更长的计数器,用于需要极长时间计数的场景。在 Linux 驱动中,c_ttc1 就是利用级联模式实现了 64 位 clocksource,精度比 32 位版本高了一个数量级。

1.6 初始化默认值

硬件初始化时,每个计数器通道被设置为保守的默认配置:

  • 溢出模式
  • 选择内部时钟
  • 计数器禁用
  • 所有中断禁用
  • 事件定时器禁用
  • 输出波形禁用

驱动在 probe 时再根据需求重新配置各通道。

小结

EA6530 的 TTC 模块是一个功能丰富的多功能定时器组。它既支持简单的周期中断,也支持复杂的 PWM 波形生成(通过匹配寄存器),还能精确测量外部脉冲宽度。开发者可以根据实际需求(如电机控制、数据采样、通信超时等)灵活配置时钟、模式和中断源。而这个灵活性的代价,就是 我们必须确保它真的按照我们设定的频率在运行——这正是本文要讲的 bug 所涉及的核心问题。


2. 为什么要测 TTC?

在嵌入式系统里,定时器的准确性直接决定了:

  • 实时性:hrtimer 能否在承诺的时间内唤醒进程?
  • 时间精度clock_gettime() 返回的纳秒值是否可信?
  • 时钟源切换:系统能否在不同 clocksource 之间正确切换(比如从 TTC 切到其他定时器)?
  • 频率调节:当通过 adjtimex() 微调时钟频率时,硬件能否正确响应?

如果 TTC 驱动有 bug,后果可能是:

  • 进程调度抖动过大,实时任务错过截止时间
  • 系统时间漂移,网络协议(PTP/NTP)同步失败
  • 定时器提前触发或严重延迟

所以我们的 tps-test 框架里专门有一个 TTC 测试模块,包含三项测试:

  1. Clocksource Switch Test——验证 /sys/devices/system/clocksource/ 下各时钟源能正确切换
  2. Frequency Step Test——验证时钟频率步进响应是否准确
  3. Timer Accuracy and Latency Test——验证 POSIX 定时器的精度和延迟

其中最复杂、也最容易出问题的,就是 Frequency Step Test


3. Frequency Step Test 是怎么测的?

3.1 核心原理

这个测试要回答的问题是:当我通过 adjtimex() 改变了时钟频率,CLOCK_MONOTONIC 是否按照我设置的频率正确运行?

Linux 内核有两种单调时钟:

  • CLOCK_MONOTONIC:受 NTP/adjtimex 频率调整影响的单调时钟
  • CLOCK_MONOTONIC_RAW:纯硬件频率、不受任何软件调整的"原始"单调时钟

测试的思路很巧妙——用 RAW 当参考,测 MONOTONIC 的响应

CLOCK_MONOTONIC_RAW  →  纯硬件计时,不会被动改变  →  参考基准
CLOCK_MONOTONIC      →  受 adjtimex() 控制          →  待测目标

如果 CLOCK_MONOTONIC 的频率被正确设置了,那么它相对于 CLOCK_MONOTONIC_RAW 的偏差(offset)应该是一条稳定的直线(或者一个稳定的斜率)。如果频率响应有问题,偏差曲线会出异常。

3.2 采样方法

c
static double get_sample(struct sample *sample) {
    clock_gettime(CLOCK_MONOTONIC_RAW, &ts1);
    clock_gettime(CLOCK_MONOTONIC,     &ts2);   // ← 待测
    clock_gettime(CLOCK_MONOTONIC_RAW, &ts3);
}

三次调用形成一个"夹心"结构:两次 RAW 包裹一次 MONOTONIC。两次 RAW 的时间差就是调用延迟,offset = MONOTONIC - RAW_mid,消除读取延迟的影响。

3.3 频率步进流程

测试分三步走:

c
// Step 1: 设置基准频率
set_frequency(freq_base);
reset_ntp_error();      // 清除 NTP 残留偏移

// Step 2: 加一个已知步进
set_frequency(freq_base + freq_step);
// ... 等待一段时间 ...

// Step 3: 回到基准频率,采样
set_frequency(freq_base);
// ... 收集 100 个样本 ...

regress(samples, SAMPLES/2, &intercept, &slope, &stddev1, &max1);
regress(samples + SAMPLES/2, SAMPLES/2, &intercept, &slope, &stddev2, &max2);

对前 50 个样本和后 50 个样本分别做线性回归,得到频率误差(slope)和标准差(stddev)。如果频率误差超过 MAX_FREQ_ERROR(20 ppm)或标准差超过 MAX_STDDEV(50 ns),判定 FAIL

3.4 adjtimex() 设置频率的细节

c
static int set_frequency(double freq) {
    struct timex txc;
    int tick_offset;

    tick_offset = 1e6 * freq / user_hz;

    txc.modes = ADJ_TICK | ADJ_FREQUENCY;
    txc.tick = 1000000 / user_hz + tick_offset;      // tick 微调
    txc.freq = (1e6 * freq - user_hz * tick_offset) * (1 << 16);  // freq 微调

    adjtimex(&txc);
}

adjtimex() 同时调整两个参数:

  • tick:每次时钟中断的微秒数(粗调,单位 1/user_hz μs)
  • freq:65536 倍的 ppm 值(细调,单位 ppm × 2^16)

两者配合才能覆盖大范围的频率调整。这个接口直接影响 CLOCK_MONOTONIC 的运行频率。


4. Bug 是什么?

4.1 现象

Frequency Step Test 时而通过时而失败。失败时日志里的偏差值异常巨大:

正常行:  Dev = 3 ns,    Max = 12 ns     ← 这才是正常抖动
失败行:  Dev = 4297100 ns, Max = 972651 ns ← 4 ms 级别,完全不合理

这不是正常的时钟读取抖动(那个在 ns 级),更像 测试过程中时钟频率被外部力量篡改了

4.2 根因分析

回顾一下测试的前提条件:

测试期间,只有 ttc-test 一个程序在调 adjtimex() 改频率。

但现实是,Linux 系统上可能运行着这些时间同步服务:

服务作用干扰方式
chronydNTP 客户端/服务器周期性调用 adjtimex() 调频率和偏移
systemd-timesyncdsystemd 自带的 SNTP 客户端周期性调用 adjtimex() 校准
ntpd传统 NTP 守护进程周期性调用 adjtimex()
ptp4lIEEE 1588 PTP 精确时间协议主/从通过 PHC 调整系统时钟频率
phc2sysPTP PHC 到系统时钟同步将 PHC 频率偏移同步到系统时钟

这些服务的职责就是"管时间"——和 ttc-test 做的事完全重叠。它们在后台周期性地调用 adjtimex()覆盖或扰动 ttc-test 刚设置的频率

于是测试看到的曲线变成了:

ttc-test 设置频率 → 时间同步服务插手调整 → offset 曲线突然跳变/弯折 → 回归失败

而不是预期的:

ttc-test 设置频率 → 系统按这个频率稳定运行 → 回归得到正确斜率

4.3 为什么之前没发现?

在开发/调试环境中,这些时间同步服务可能没有配置、没有启动,或者网络不通所以它们没有活跃调整。但一旦部署到实际生产环境——网络通了、PTP 配了、chrony 启了——测试就变得不稳定。


5. 怎么修的?

5.1 第一版修复:killall 杀进程

最初的思路是:测试开始前用 killall 杀掉这些进程,测试结束后重新启动对应的服务。

cpp
void stop_process(const char *process) {
    std::string kill_cmd = "killall " + process;
    executeCommand(kill_cmd);
}

但这有两个问题:

  1. killall 默认发 SIGTERM——进程可以捕获或忽略它,不保证真的死了
  2. killall 立即返回——进程退出需要时间,测试可能和残留进程竞争
  3. 杀掉的进程无法追踪——stopped_processes 只是个 bool,析构时不知道该恢复什么

5.2 最终修复:SIGSTOP 挂起进程 + 状态验证

经过 review 反馈后,改为更优雅的方案:不杀进程,而是用 SIGSTOP 挂起它们

cpp
void suspend_process(const char *process) {
    std::string pids = utils_cmd("pidof " + process);
    // ...
    std::string stop_cmd = "kill -STOP " + pid;   // SIGSTOP,不可被捕获
    executeCommand(stop_cmd);

    // 验证进程确实进入了 stopped 状态
    for (int i = 0; i < 10 && !process_is_stopped(pid); i++)
        usleep(100000);   // 最多等 1 秒
}

SIGSTOP 的关键优势:

特性SIGSTOPSIGTERMSIGKILL
可被捕获/忽略❌ 不可✅ 可以❌ 不可
进程状态挂起(T 状态)终止终止
可恢复✅ SIGCONT 恢复❌ 需重启❌ 需重启
保留进程上下文✅ 内存/状态完整

用 SIGSTOP 挂起意味着:

  • 进程保证停下来——SIGSTOP 不能被捕获或忽略
  • 进程不会丢失——SIGCONT 就能原样恢复,不需要重新启动服务
  • 可以验证——读取 /proc/<pid>/statusState: 字段,确认是 T(stopped)

5.3 RAII 自动恢复

用一个 time_sync_guard 结构体实现 RAII(Resource Acquisition Is Initialization)模式:

cpp
struct time_sync_guard {
    std::vector<std::string> stopped_services;   // systemctl stop 的服务
    std::vector<std::string> suspended_pids;     // SIGSTOP 挂起的进程 PID

    time_sync_guard() {
        // 构造时:停止服务 + 挂起进程
        for (const char *service : services)
            stop_active_service(service);
        for (const char *process : processes)
            suspend_process(process);
        sleep(1);  // 等待时间同步状态稳定
    }

    ~time_sync_guard() {
        // 析构时:SIGCONT 恢复进程 + systemctl start 恢复服务
        for (const std::string& pid : suspended_pids)
            executeCommand("kill -CONT " + pid);
        for (auto it = stopped_services.rbegin(); ...)
            executeCommand("systemctl start " + *it);
    }
};

只要 time_sync_guard 对象是局部变量,不管函数是正常返回、提前 return 还是 goto 跳出,析构函数都会被调用,服务都会被恢复。这正是 RAII 的威力——你不需要在每个退出点手写恢复逻辑。

5.4 系统时间状态保存与恢复

除了隔离时间同步服务,还需要保存和恢复内核的 adjtimex 状态:

cpp
static int save_time_state(struct timex *saved) {
    memset(saved, 0, sizeof(*saved));
    return adjtimex(saved);   // modes=0 → 只读取,不修改
}

static int restore_time_state(const struct timex *saved) {
    struct timex txc;
    memset(&txc, 0, sizeof(txc));
    txc.modes = ADJ_TICK | ADJ_FREQUENCY;
    txc.tick = saved->tick;   // 恢复原始 tick
    txc.freq = saved->freq;  // 恢复原始 freq
    return adjtimex(&txc);
}

原来的代码在测试结束时硬编码 set_frequency(0.0)——把频率归零。但如果系统原本就有频率偏移(比如 chrony 为了补偿晶振漂移设置了 5 ppm 的偏移),归零反而破坏了系统状态。改为保存/恢复后,测试结束后系统回到测试前的精确状态

5.5 清理顺序

恢复操作的顺序也很关键:

1. restore_time_state()   ← 先恢复内核 tick/freq
2. sync_guard 析构        ← 再恢复时间同步服务

如果反过来——先恢复服务再恢复内核参数——chrony/ptp4l 一启动就可能立刻调用 adjtimex() 覆盖我们正在恢复的状态。先恢复内核参数,服务重启后看到的就是正确的基准。

5.6 错误处理的 goto-out 模式

cpp
int frequency_step_test(void) {
    int ret = -1;
    bool time_state_saved = false;

    time_sync_guard sync_guard;           // RAII:自动恢复服务
    save_time_state(&saved_time_state);   // 保存内核状态
    time_state_saved = true;

    if (set_frequency(0.0) < 0) goto out;
    if (reset_ntp_error() < 0) goto out;

    // ... 测试循环 ...
    for (...) {
        if (set_frequency(freq_base) < 0) goto out;
        if (reset_ntp_error() < 0) goto out;
        if (set_frequency(freq_base + freq_step) < 0) goto out;
        if (set_frequency(freq_base) < 0) goto out;
    }

    ret = fails ? -1 : 0;

out:
    if (time_state_saved && restore_time_state(&saved_time_state) < 0)
        ret = -1;   // 恢复失败也算测试失败

    return ret;
    // ← sync_guard 析构自动发生在这里
}

goto out 在 C/C++ 中是经典的错误处理模式,比嵌套 if-else 更清晰。关键是:

  • time_state_saved 标志确保只有成功保存了状态才尝试恢复
  • ret 初始化为 -1,只有测试完全通过才改为 0
  • sync_guard 不需要手动管理——RAII 自动保证

6. 修复后验证

修复后,Frequency Step Test 连续 3 次稳定通过,偏差值回归到正常 ns 级别:

  Step           1st interval              2nd interval
             Freq    Dev     Max       Freq    Dev     Max
  ------------------------------------------------------------
    64000000  +0.003     3    12  +0.003     3    12  [OK]
    ...

原因很简单:测试期间只有 ttc-test 一个程序在调 adjtimex(),时间同步服务全被 SIGSTOP 挂起了,无法干扰。


7. 总结与反思

这次 Debug 的核心教训

在测试"接管系统时间频率"的功能时,必须先确认没有其他进程也在做同一件事。

这是一个典型的 隐式共享资源冲突——adjtimex() 是内核提供的全局接口,任何进程都能调,但没有互斥机制。当两个"时间管理者"同时操作同一个 adjtimex 状态时,谁后调用谁生效,测试就成了随机的。

修复方案对比

方案优点缺点
killall 杀进程简单进程可能不死;丢失进程信息;需手动重启
❌ 检测后跳过测试不破坏系统测试无法运行,不是解决问题
SIGSTOP 挂起 + RAII保证停;可恢复;自动清理需要 root 权限

写测试时值得思考的问题

  1. 测试的前置条件是什么?——不只是"硬件存在",还包括"系统状态可控"
  2. 测试会修改全局状态吗?——adjtimex() 是全局的,测试前后必须保存/恢复
  3. 有没有其他东西也在修改同一状态?——时间同步服务、cron 任务、其他测试用例
  4. 清理逻辑能否自动化?——RAII > 手动清理 > 没有清理

这篇记录的修改对应 commit a5a4c80,最终代码位于 apps/bsp/ttc/ttc_test.cpp