3.6 PHC操作与时钟调整:与硬件时钟对话
从用户态到内核态
LinuxPTP运行在用户态,但需要操作内核的PTP硬件时钟(PHC)。
这是如何实现的?
Linux PHC子系统
PHC是什么
PTP Hardware Clock(PHC)是Linux内核提供的子系统:
- 抽象各种硬件时钟设备
- 提供统一的用户态接口
- 支持多种操作:读取、设置、调整
设备文件:
/dev/ptp0 # 第一个PHC设备
/dev/ptp1 # 第二个PHC设备
...
查看系统中的PHC:
$ ls -l /dev/ptp*
crw-rw---- 1 root root 248, 0 Apr 1 10:00 /dev/ptp0
PHC的内核接口
/* Linux内核头文件 <linux/ptp_clock.h> */
struct ptp_clock_caps {
int max_adj; /* 最大频率调整(ppb) */
int n_alarm; /* 告警数量 */
int n_ext_ts; /* 外部时间戳数量 */
int n_per_out; /* 周期性输出数量 */
int pps; /* 是否支持PPS */
int n_pins; /* 引脚数量 */
int cross_timestamping; /* 是否支持交叉时间戳 */
int adjust_phase; /* 是否支持相位调整 */
};
/* IOCTL命令 */
#define PTP_CLOCK_GETCAPS _IOR('P', 1, struct ptp_clock_caps)
#define PTP_PIN_SETFUNC2 _IOW('P', 20, struct ptp_pin_desc)
PHC操作函数
phc_open:打开PHC设备
/* phc.c, 第42-67行 */
clockid_t phc_open(const char *phc)
{
clockid_t clkid;
struct timespec ts;
struct timex tx;
int fd;
memset(&tx, 0, sizeof(tx));
/* 步骤1:打开设备文件 */
fd = open(phc, O_RDWR);
if (fd < 0)
return CLOCK_INVALID;
/* 步骤2:将文件描述符转换为clockid_t */
clkid = FD_TO_CLOCKID(fd);
/* 步骤3:验证clockid是否有效 */
if (clock_gettime(clkid, &ts)) {
close(fd);
return CLOCK_INVALID;
}
/* 步骤4:验证是否可以调整时钟 */
if (clock_adjtime(clkid, &tx)) {
close(fd);
return CLOCK_INVALID;
}
return clkid;
}
FD_TO_CLOCKID宏:
/* Linux内核定义 */
#define CLOCKFD 3
#define FD_TO_CLOCKID(fd) ((~(clockid_t) (fd) << 3) | CLOCKFD)
#define CLOCKID_TO_FD(clk) ((unsigned long) ~((clk) >> 3))
工作原理:
Linux时钟API使用clockid_t标识时钟:
- CLOCK_REALTIME:系统实时时钟
- CLOCK_MONOTONIC:单调递增时钟
- CLOCKFD:动态时钟(通过文件描述符)
PTP设备文件描述符 → clockid_t转换:
1. 打开/dev/ptp0 → 获得fd(如fd=10)
2. FD_TO_CLOCKID(10) → clockid_t
3. 用这个clockid调用clock_gettime等函数
这允许用户态程序像操作普通时钟一样操作PHC!
phc_close:关闭PHC设备
/* phc.c, 第69-75行 */
void phc_close(clockid_t clkid)
{
if (clkid == CLOCK_INVALID)
return;
close(CLOCKID_TO_FD(clkid));
}
phc_max_adj:获取最大频率调整
/* phc.c, 第87-101行 */
int phc_max_adj(clockid_t clkid)
{
int max;
struct ptp_clock_caps caps;
/* 通过ioctl获取能力 */
if (phc_get_caps(clkid, &caps))
return 0;
max = caps.max_adj;
/* 32位平台的特殊处理 */
if (BITS_PER_LONG == 32 && max > MAX_PPB_32)
max = MAX_PPB_32;
return max;
}
static int phc_get_caps(clockid_t clkid, struct ptp_clock_caps *caps)
{
int fd = CLOCKID_TO_FD(clkid), err;
err = ioctl(fd, PTP_CLOCK_GETCAPS, caps);
if (err)
perror("PTP_CLOCK_GETCAPS");
return err;
}
32位平台限制:
/* phc.c, 第37-38行 */
#define BITS_PER_LONG (sizeof(long)*8)
#define MAX_PPB_32 32767999 /* (2^31 - 1) / 65.536 */
为什么需要这个限制?
struct timex {
long freq; /* 频率调整,单位:ppb × 65.536 */
...
};
32位平台:long是32位,范围±2^31
最大值:(2^31 - 1) / 65.536 ≈ 32,767,999 ppb
如果PHC硬件支持更大的调整范围,需要限制到这个值。
phc_has_pps:检查PPS支持
/* phc.c, 第122-129行 */
int phc_has_pps(clockid_t clkid)
{
struct ptp_clock_caps caps;
if (phc_get_caps(clkid, &caps))
return 0;
return caps.pps;
}
PPS(Pulse Per Second):
PPS是一种硬件信号:
- 每秒产生一个脉冲
- 精度可达纳秒级
- 用于精确的时间同步
用途:
- 外部时间源(GPS)输入
- 时间戳触发
- 频率校准
LinuxPTP可以利用PPS:
- 如果PHC支持PPS
- 可以从外部源获取精确时间
- 提高同步精度
phc_has_writephase:检查相位调整支持
/* phc.c, 第131-139行 */
int phc_has_writephase(clockid_t clkid)
{
struct ptp_clock_caps caps;
if (phc_get_caps(clkid, &caps)) {
return 0;
}
return caps.adjust_phase;
}
相位调整能力:
传统频率调整:
- 通过调整振荡器频率
- 相位变化是累积的
- 适合长期稳定
相位调整(硬件写入相位):
- 直接修改时钟相位
- 不改变频率
- 立即生效,无累积误差
支持adjust_phase的硬件:
- 可以更精确地调整相位
- 实现"一步"时钟
- 适合高精度场景
时钟调整函数
clockadj_set_freq:设置频率
/* clockadj.c, 第51-70行 */
int clockadj_set_freq(clockid_t clkid, double freq)
{
struct timex tx;
memset(&tx, 0, sizeof(tx));
/* 系统时钟的特殊处理 */
if (clkid == CLOCK_REALTIME && realtime_nominal_tick) {
tx.modes |= ADJ_TICK;
tx.tick = round(freq / 1e3 / realtime_hz) + realtime_nominal_tick;
freq -= 1e3 * realtime_hz * (tx.tick - realtime_nominal_tick);
}
/* 设置频率 */
tx.modes |= ADJ_FREQUENCY;
tx.freq = (long) (freq * 65.536);
if (clock_adjtime(clkid, &tx) < 0) {
pr_err("failed to adjust the clock: %m");
return -1;
}
return 0;
}
频率调整的单位转换:
输入:freq(ppb,十亿分之一)
内核:tx.freq(单位:ppb × 65.536)
转换公式:
tx.freq = freq × 65.536
为什么是65.536?
历史原因:NTP使用"SHIFT_HZ = 16"
freq字段是2^16缩放的ppm(百万分之一)
1 ppm = 1000 ppb
所以:tx.freq = freq_ppb × 2^16 / 1000
= freq_ppb × 65.536
示例:
freq = 100 ppb
tx.freq = 100 × 65.536 = 6554
系统时钟的特殊处理:
if (clkid == CLOCK_REALTIME && realtime_nominal_tick) {
tx.modes |= ADJ_TICK;
tx.tick = round(freq / 1e3 / realtime_hz) + realtime_nominal_tick;
freq -= 1e3 * realtime_hz * (tx.tick - realtime_nominal_tick);
}
为什么系统时钟需要特殊处理?
系统时钟(CLOCK_REALTIME):
- 基于内核的tick
- tick长度可以调整
- 允许更大的频率调整范围
方法:
1. 调整tick长度(粗调)
2. 调整freq字段(精调)
3. 两者结合,扩大调整范围
示例:
realtime_hz = 1000(USER_HZ)
realtime_nominal_tick = 1000(微秒)
freq = 100000 ppb(100 ppm)
粗调:
tx.tick = 100000 / 1e3 / 1000 + 1000 = 1100微秒
(tick增加100微秒,相当于+100 ppm)
精调:
freq -= 1e3 × 1000 × (1100 - 1000) = 0
(剩余部分由freq字段处理)
效果:
通过tick调整了100 ppm
freq字段只需要调整0 ppb
clockadj_get_freq:获取当前频率
/* clockadj.c, 第72-86行 */
double clockadj_get_freq(clockid_t clkid)
{
double f = 0.0;
struct timex tx;
memset(&tx, 0, sizeof(tx));
if (clock_adjtime(clkid, &tx) < 0) {
pr_err("failed to read out the clock frequency adjustment: %m");
exit(1);
} else {
f = tx.freq / 65.536;
if (clkid == CLOCK_REALTIME && realtime_nominal_tick && tx.tick)
f += 1e3 * realtime_hz * (tx.tick - realtime_nominal_tick);
}
return f;
}
逆转换:
读取时的单位转换:
freq_ppb = tx.freq / 65.536
系统时钟需要加上tick调整:
freq_ppb += 1e3 × hz × (tick - nominal_tick)
clockadj_set_phase:设置相位偏移
/* clockadj.c, 第88-100行 */
int clockadj_set_phase(clockid_t clkid, long offset)
{
struct timex tx;
memset(&tx, 0, sizeof(tx));
tx.modes = ADJ_OFFSET | ADJ_NANO;
tx.offset = offset;
if (clock_adjtime(clkid, &tx) < 0) {
pr_err("failed to set the clock offset: %m");
return -1;
}
return 0;
}
ADJ_OFFSET的作用:
ADJ_OFFSET模式:
- 设置相位偏移
- 时钟逐渐调整,不是立即跳变
- 使用内核的PLL调整
参数:
tx.offset = offset(纳秒)
ADJ_NANO表示offset单位是纳秒
工作原理:
- 时钟偏移offset纳秒
- 内核逐渐调整,消除偏移
- 调整速度由time_constant控制
适用场景:
- 小偏差(微秒级)
- 平滑调整,不影响应用
clockadj_step:时钟跳变
/* clockadj.c, 第102-127行 */
int clockadj_step(clockid_t clkid, int64_t step)
{
struct timex tx;
int sign = 1;
if (step < 0) {
sign = -1;
step *= -1;
}
memset(&tx, 0, sizeof(tx));
tx.modes = ADJ_SETOFFSET | ADJ_NANO;
tx.time.tv_sec = sign * (step / NS_PER_SEC);
tx.time.tv_usec = sign * (step % NS_PER_SEC);
/* 确保tv_usec非负 */
if (tx.time.tv_usec < 0) {
tx.time.tv_sec -= 1;
tx.time.tv_usec += 1000000000;
}
if (clock_adjtime(clkid, &tx) < 0) {
pr_err("failed to step clock: %m");
return -1;
}
return 0;
}
时钟跳变的含义:
ADJ_SETOFFSET模式:
- 立即修改时钟值
- 时间会"跳"到新值
- 可能导致应用问题
参数:
step = +1000000000:时钟向前跳1秒
step = -1000000000:时钟向后跳1秒
处理负数step:
1. step = -1500000000(-1.5秒)
2. sign = -1
3. step = 1500000000
4. tx.time.tv_sec = -1
5. tx.time.tv_usec = -500000000
6. 调整:tv_sec -= 1, tv_usec += 1000000000
→ tv_sec = -2, tv_usec = 500000000
注意:
tx.time是timeval结构:
struct timeval {
time_t tv_sec; /* 秒 */
suseconds_t tv_usec; /* 微秒 */
};
虽然使用了ADJ_NANO标志,但实际传递的是微秒。
内核会根据ADJ_NANO标志将其视为纳秒。
set_phase vs step:
set_phase:
- 渐进调整
- 用PLL逐渐消除偏差
- 无时间跳变
- 适合小偏差
step:
- 立即跳变
- 时间直接修改
- 可能导致应用问题
- 适合大偏差
选择依据:
- |offset| < step_threshold → set_phase
- |offset| > step_threshold → step
clock_adjtime系统调用
函数原型
#include <sys/timex.h>
int clock_adjtime(clockid_t clkid, struct timex *tx);
这是Linux特有的系统调用:
标准POSIX:
clock_gettime:读取时间
clock_settime:设置时间
Linux扩展:
clock_adjtime:高级时钟调整
- 设置频率
- 设置相位
- 时钟跳变
- 读取状态
这允许用户态程序精确控制时钟!
struct timex结构
/* <sys/timex.h> */
struct timex {
int modes; /* 模式选择 */
long offset; /* 相位偏移(纳秒) */
long freq; /* 频率偏移(scaled ppm) */
long maxerror; /* 最大误差 */
long esterror; /* 估计误差 */
int status; /* 时钟状态 */
long constant; /* PLL时间常数 */
long precision; /* 时钟精度 */
long tolerance; /* 时钟容差(最大频率偏差) */
struct timeval time; /* 当前时间 */
long tick; /* 微秒/时钟tick */
long ppsfreq; /* PPS频率 */
long jitter; /* PPS抖动 */
int shift; /* PPS间隔时长 */
long stabil; /* PPS稳定性 */
long jitcnt; /* PPS抖动超限计数 */
long calcnt; /* PPS校准间隔 */
long errcnt; /* PPS校准错误 */
long stbcnt; /* PPS稳定性超限 */
int tai; /* TAI偏移 */
};
modes字段详解
/* modes的位定义 */
#define ADJ_OFFSET 0x0001 /* 设置相位偏移 */
#define ADJ_FREQUENCY 0x0002 /* 设置频率 */
#define ADJ_MAXERROR 0x0004 /* 设置最大误差 */
#define ADJ_ESTERROR 0x0008 /* 设置估计误差 */
#define ADJ_STATUS 0x0010 /* 设置状态 */
#define ADJ_TIMECONST 0x0020 /* 设置时间常数 */
#define ADJ_TAI 0x0080 /* 设置TAI偏移 */
#define ADJ_SETOFFSET 0x0100 /* 时钟跳变 */
#define ADJ_MICRO 0x1000 /* 微秒分辨率 */
#define ADJ_NANO 0x2000 /* 纳秒分辨率 */
#define ADJ_TICK 0x4000 /* 设置tick长度 */
常用模式组合:
/* 设置频率 */
tx.modes = ADJ_FREQUENCY;
/* 设置相位(纳秒) */
tx.modes = ADJ_OFFSET | ADJ_NANO;
/* 时钟跳变(纳秒) */
tx.modes = ADJ_SETOFFSET | ADJ_NANO;
/* 读取当前状态 */
tx.modes = 0; /* 不修改任何参数,只读取 */
PHC与系统时钟同步
phc2sys工具
LinuxPTP提供了phc2sys工具,用于PHC与系统时钟之间的同步。
使用场景:
1. PTP从时钟:PHC同步到网络主时钟
2. 系统时钟:需要从PHC获取时间
3. phc2sys:将PHC时间传递给系统时钟
工作原理:
1. 读取PHC时间
2. 读取系统时钟时间
3. 计算偏差
4. 调整系统时钟
phc2sys配置示例
# 将PHC(/dev/ptp0)同步到系统时钟
phc2sys -s /dev/ptp0 -c CLOCK_REALTIME -w
# 参数说明:
# -s /dev/ptp0:源时钟(PHC)
# -c CLOCK_REALTIME:目标时钟(系统时钟)
# -w:等待ptp4l锁定
实际应用示例
在LinuxPTP中使用PHC
/* clock.c中的时钟创建(简化) */
struct clock *clock_create(enum clock_type type, struct config *config,
const char *phc_device)
{
struct clock *c;
/* 分配内存 */
c = calloc(1, sizeof(*c));
/* 打开PHC设备 */
if (phc_device) {
c->clkid = phc_open(phc_device);
} else {
/* 自动检测PHC */
c->clkid = phc_open("/dev/ptp0");
}
if (c->clkid == CLOCK_INVALID) {
/* 使用系统时钟 */
c->clkid = CLOCK_REALTIME;
}
/* 获取最大频率调整 */
c->max_freq = phc_max_adj(c->clkid);
/* 创建伺服 */
c->servo = servo_create(config, type, fadj, c->max_freq, sw_ts);
return c;
}
时钟调整流程
/* clock.c中的同步函数(简化) */
enum servo_state clock_synchronize(struct clock *c, tmv_t ingress,
tmv_t origin)
{
int64_t offset;
double freq_adj;
enum servo_state state;
/* 计算偏差 */
offset = tmv_to_nanoseconds(tmv_sub(ingress, origin));
/* 调用伺服 */
freq_adj = servo_sample(c->servo, offset, local_ts, 1.0, &state);
/* 应用频率调整 */
switch (state) {
case SERVO_JUMP:
/* 时钟跳变 */
clockadj_step(c->clkid, -offset);
break;
case SERVO_LOCKED:
case SERVO_LOCKED_STABLE:
/* 频率调整 */
clockadj_set_freq(c->clkid, freq_adj);
break;
default:
break;
}
return state;
}
调试和诊断
检查PHC设备
# 查看PHC设备
$ ls -l /dev/ptp*
# 查看PHC能力
$ ethtool -T eth0
Time stamping parameters for eth0:
Capabilities:
hardware-transmit (SOF_TIMESTAMPING_TX_HARDWARE)
hardware-receive (SOF_TIMESTAMPING_RX_HARDWARE)
hardware-raw-clock (SOF_TIMESTAMPING_RAW_HARDWARE)
PTP Hardware Clock: 0
读取PHC时间
# 使用phc_ctl工具
$ phc_ctl /dev/ptp0 -- get
# 输出:
phc_ctl[1234.567]: clock time is 1640995200.123456789 or Thu Dec 31 2021 12:00:00 UTC
设置PHC时间
# 设置PHC时间
$ phc_ctl /dev/ptp0 -- set T 1640995200.000000000
# 调整PHC频率
$ phc_ctl /dev/ptp0 -- freq 100.0 # +100 ppb
小结:PHC操作的核心要点
用户态到内核态的桥梁:
- 文件描述符 → clockid_t
- 标准时钟API操作PHC
关键函数:
- phc_open/close:打开/关闭设备
- clockadj_set_freq:设置频率
- clockadj_set_phase:设置相位
- clockadj_step:时钟跳变
系统调用:
- clock_adjtime:核心系统调用
- struct timex:丰富的控制参数
两种调整方式:
- 渐进调整(set_phase)
- 立即跳变(step)
下集预告
PHC操作解决了“如何调整时钟“,但PTP报文如何在网络中传输?
下一节,我们将分析传输层实现——看看LinuxPTP如何处理UDP和以太网传输。
【悬念留给3.7】
PTP可以在不同传输层运行:
- UDP/IPv4
- UDP/IPv6
- 原始以太网(Layer 2)
LinuxPTP使用统一接口抽象这些传输。
如何实现一个接口支持多种传输?
下一节,我们详细解读传输层实现。