一次嵌入式 Linux 时钟频率测试的 Debug 记录
当你的测试用例时而通过时而失败,偏差从几个 ns 突然跳到几百万 ns——罪魁祸首可能不是硬件,而是系统里你根本没注意到的那群"时间管家"。
1. TTC 是什么?
TTC(Triple Timer Counter,三路定时器计数器) 是 Cadence 设计的一种硬件定时器 IP 核。顾名思义,每个 TTC 实例包含 3 个独立的计时器通道,所以一个芯片上如果有 3 个 TTC 模块,总共就能提供 9 路独立的定时/计数通道——资源非常充裕。
1.1 硬件架构
在 EA6530 芯片上,TTC 模块分布在两个子系统:
| 硬件实例 | 所在域 | Linux 驱动 | 作用 |
|---|---|---|---|
| c_ttc0 | CPU 子系统 | cdns,ttc(timer-cadence-ttc.c) | clocksource + clockevent,系统"心跳" |
| c_ttc1 | CPU 子系统 | tps,timer(timer-tps.c) | 64 位级联 clocksource,高精度时间基准 |
| s_ttc0 | SAP 子系统 | 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 测试模块,包含三项测试:
- Clocksource Switch Test——验证
/sys/devices/system/clocksource/下各时钟源能正确切换 - Frequency Step Test——验证时钟频率步进响应是否准确
- 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 采样方法
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 频率步进流程
测试分三步走:
// 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() 设置频率的细节
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 系统上可能运行着这些时间同步服务:
| 服务 | 作用 | 干扰方式 |
|---|---|---|
| chronyd | NTP 客户端/服务器 | 周期性调用 adjtimex() 调频率和偏移 |
| systemd-timesyncd | systemd 自带的 SNTP 客户端 | 周期性调用 adjtimex() 校准 |
| ntpd | 传统 NTP 守护进程 | 周期性调用 adjtimex() |
| ptp4l | IEEE 1588 PTP 精确时间协议主/从 | 通过 PHC 调整系统时钟频率 |
| phc2sys | PTP PHC 到系统时钟同步 | 将 PHC 频率偏移同步到系统时钟 |
这些服务的职责就是"管时间"——和 ttc-test 做的事完全重叠。它们在后台周期性地调用 adjtimex(),覆盖或扰动 ttc-test 刚设置的频率。
于是测试看到的曲线变成了:
ttc-test 设置频率 → 时间同步服务插手调整 → offset 曲线突然跳变/弯折 → 回归失败而不是预期的:
ttc-test 设置频率 → 系统按这个频率稳定运行 → 回归得到正确斜率4.3 为什么之前没发现?
在开发/调试环境中,这些时间同步服务可能没有配置、没有启动,或者网络不通所以它们没有活跃调整。但一旦部署到实际生产环境——网络通了、PTP 配了、chrony 启了——测试就变得不稳定。
5. 怎么修的?
5.1 第一版修复:killall 杀进程
最初的思路是:测试开始前用 killall 杀掉这些进程,测试结束后重新启动对应的服务。
void stop_process(const char *process) {
std::string kill_cmd = "killall " + process;
executeCommand(kill_cmd);
}但这有两个问题:
killall默认发 SIGTERM——进程可以捕获或忽略它,不保证真的死了killall立即返回——进程退出需要时间,测试可能和残留进程竞争- 杀掉的进程无法追踪——
stopped_processes只是个bool,析构时不知道该恢复什么
5.2 最终修复:SIGSTOP 挂起进程 + 状态验证
经过 review 反馈后,改为更优雅的方案:不杀进程,而是用 SIGSTOP 挂起它们。
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 的关键优势:
| 特性 | SIGSTOP | SIGTERM | SIGKILL |
|---|---|---|---|
| 可被捕获/忽略 | ❌ 不可 | ✅ 可以 | ❌ 不可 |
| 进程状态 | 挂起(T 状态) | 终止 | 终止 |
| 可恢复 | ✅ SIGCONT 恢复 | ❌ 需重启 | ❌ 需重启 |
| 保留进程上下文 | ✅ 内存/状态完整 | ❌ | ❌ |
用 SIGSTOP 挂起意味着:
- 进程保证停下来——SIGSTOP 不能被捕获或忽略
- 进程不会丢失——SIGCONT 就能原样恢复,不需要重新启动服务
- 可以验证——读取
/proc/<pid>/status的State:字段,确认是T(stopped)
5.3 RAII 自动恢复
用一个 time_sync_guard 结构体实现 RAII(Resource Acquisition Is Initialization)模式:
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 状态:
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 模式
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,只有测试完全通过才改为0sync_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 权限 |
写测试时值得思考的问题
- 测试的前置条件是什么?——不只是"硬件存在",还包括"系统状态可控"
- 测试会修改全局状态吗?——
adjtimex()是全局的,测试前后必须保存/恢复 - 有没有其他东西也在修改同一状态?——时间同步服务、cron 任务、其他测试用例
- 清理逻辑能否自动化?——RAII > 手动清理 > 没有清理
这篇记录的修改对应 commit a5a4c80,最终代码位于 apps/bsp/ttc/ttc_test.cpp。