前两篇文章,我们分别讲了时间同步的基本原理,以及 gPTP 如何通过报文交换实现同步。
这一篇我们不再展开协议理论,而是具体看看 Linux 驱动如何实现时间同步。
我们以 Linux 6.18 的 stmmac 以太网驱动为例,沿着硬件时钟的注册、访问和调节,再到报文收发时的取戳与返回,把相关代码串起来。
不过,同步协议并不是全部运行在驱动里。进入源码前,先明确用户态同步程序和网卡驱动各自负责什么,以及它们操作的是哪只时钟。
1. Linux 中的同步软件与时钟
1.1 从 ptp4l 到网卡硬件时钟
在 Linux 上,LinuxPTP 提供了运行 PTP/gPTP 的用户态程序,其中负责网络同步的程序叫作 ptp4l。
前面讲过的报文交换、时间戳配对、时延与 offset 计算,以及决定怎样调钟的伺服算法,都可以由它完成。
用户态程序负责计算,硬件负责在报文经过取戳位置时记录时间,两者之间由内核和网卡驱动连接:驱动配置取戳硬件,把记录结果交给用户态,也把用户态的调钟请求落实到硬件。
这里需要先认识时间戳来自哪只时钟,支持硬件时间戳的 MAC、PHY 或交换芯片,可以使用关联的硬件计数器记录事件时间。
这类供 PTP 使用的硬件时钟称为 PHC,PTP Hardware Clock。
PHC 持续计时,取戳单元在报文发送或接收时锁存它的读数。
因此驱动需要区分读取 PHC 的当前时间,以及取回某次报文的事件时间,后者必须与那份报文保持对应。
1.2 PHC 与系统时间的关系
普通应用调用 clock_gettime(CLOCK_REALTIME, ...) 读取的是 Linux 系统时间,它由内核维护,与网卡的 PHC 是不同对象。
因此,ptp4l 使用硬件时间戳把 PHC 同步好以后,系统时间不会仅因为这件事就自动跟随。
如果应用也需要使用这条共同时间轴,可以由 LinuxPTP 的另一个程序 phc2sys 在 PHC 与系统时钟之间完成同步。
对于跟随网络时间的节点,典型关系是:
网络中的 GM
→ ptp4l 校正本机 PHC
→ phc2sys 校正本机 CLOCK_REALTIME
→ 应用读取系统时间
这里的方向取决于谁提供参考时间。 上面的例子中本机从网络获取参考时间,因此由 phc2sys 让系统时钟跟随已经同步好的 PHC。
然而如果本机承担 GM,并以系统时钟作为参考,方向就反过来了:由 phc2sys 调整 PHC,让它跟随系统时钟,再向网络提供时间。
这样 Linux 中要连接的对象就清楚了: ptp4l 一方面需要取得网络事件时间,另一方面需要控制 PHC。
Linux 分别提供了 socket 时间戳接口 SO_TIMESTAMPING,以及通过 /dev/ptpN 访问 PHC 的时钟接口。

图 1:左列表示报文取戳路径,右列表示 PHC 访问路径。ptp4l 同时使用这两类接口,phc2sys 再按需要连接 PHC 与系统时钟;图中的 CLOCK_REALTIME 表示应用可访问的系统时钟。
在 stmmac 中,stmmac_ptp.c 实现 PHC 的注册和调钟回调,stmmac_hwtstamp.c 负责底层时间寄存器操作,stmmac_main.c 则包含初始化、取戳配置和报文收发处理。
我们先从硬件时钟怎样注册到内核开始。
2. PHC 的注册与调节
2.1 把硬件时钟交给 PTP 框架
在 stmmac 的设备探测阶段,stmmac_dvr_probe() 将网卡的操作表设为 stmmac_netdev_ops,其中的 ndo_open 指向 stmmac_open()。
后续启用网卡接口时,内核通过这个入口启动设备。
Linux 6.18 的 stmmac_open() 进一步调用 __stmmac_open()。
后者完成 stmmac_hw_setup() 后,再调用 stmmac_setup_ptp():使能 PTP 参考时钟,检查硬件取戳能力并初始化时间计数器;初始化成功后,才进入 stmmac_ptp_register() 注册 PHC。

时间计数器初始化完成后,调用进入 stmmac_ptp_register()。
接下来,我们看驱动怎样把这只硬件时钟注册到 Linux,让用户态程序能够读取和调整它。
Linux 通过 PTP 框架向用户态提供统一的时钟接口,但具体怎样读取计数器、修改时间和调整走速,需要由网卡驱动实现。
因此,注册时驱动需要把这些操作对应的函数交给框架,供框架在收到用户态请求时调用。
这些操作保存在 struct ptp_clock_info 中。下面是 stmmac 提供的四个基础时钟操作:
const struct ptp_clock_info stmmac_ptp_clock_ops = {
.adjfine = stmmac_adjust_freq,
.adjtime = stmmac_adjust_time,
.gettime64 = stmmac_get_time,
.settime64 = stmmac_set_time,
};
这些字段把通用操作与 stmmac 的具体函数对应起来:
| 操作 |
回调 |
对硬件时钟的作用 |
| 读取当前时间 |
gettime64 |
读出 MAC 时间计数器的值 |
| 设置当前时间 |
settime64 |
把计数器设置到指定读数 |
| 修正当前偏差 |
adjtime |
按给定增量调整当前读数 |
| 调整运行速率 |
adjfine |
改变计数器此后的走速 |
准备好这些方法后,stmmac 将实际使用的回调对象注册到 PTP class:
priv->ptp_clock = ptp_clock_register(&priv->ptp_clock_ops,
priv->device);
注册成功后,框架为这只 PHC 分配索引并提供 /dev/ptpN 设备。
这里的 N 是 PTP 时钟索引,不能根据网卡名字猜测:eth0 不一定对应 /dev/ptp0。
例如,用下面的查询确认 eth0 关联的 PHC:
ethtool -T eth0
其中的 PTP Hardware Clock 字段给出索引。只有先确认这层关联,后续打开的设备才是当前网卡取戳所使用的时钟,设备名应按实际系统替换。
2.2 从用户态调钟请求到驱动回调
有了设备节点以后,用户态先打开 /dev/ptpN,再把文件描述符转换成动态 POSIX clock ID,也就是一个能够标识这只时钟的 ID。
随后,clock_gettime()、clock_settime() 和 clock_adjtime() 就可以明确地作用于它,这个用法由 Linux PTP 硬件时钟框架提供。
因此同样调用 clock_gettime(),传入 CLOCK_REALTIME 时读取系统时间,传入 PHC 对应的动态 clock ID 时读取网卡硬件时间。函数名相同,操作的时钟对象由参数决定。
当 ptp4l 使用这个 ID 发出校正请求时,内核通过 PTP class 找到注册的 ptp_clock_info,再根据请求类型调用 adjtime 或 adjfine。
stmmac 的函数随后访问 MAC 寄存器,才真正改变硬件时钟。
这层关联保存在 drivers/ptp/ptp_clock.c 中。
注册时,框架创建 struct ptp_clock,用其中的 info 指向驱动传入的回调对象,再通过 posix_clock_register() 接入动态 POSIX 时钟。
用户态请求先进入框架的通用操作,再转给这只 PHC 对应的驱动。
以读时为例,框架的 ptp_clock_gettime() 会优先使用 gettimex64,本文所示的 stmmac 回调表提供的是 gettime64,因此请求会进入 stmmac_get_time()。
调钟也采用同样的分派关系:ptp_clock_adjtime() 根据请求模式调用驱动的偏差修正或调频方法。

图 2:注册建立 PHC 与驱动回调的关联;运行时,用户态的读时、设时和校正请求通过这层关联到达 MAC。
2.3 从驱动回调到 MAC 时间寄存器
进入 stmmac_get_time() 后,函数首先通过 container_of(),从传入的 ptp_clock_info 找回包含它的 struct stmmac_priv。
这个私有对象保存着当前网卡的硬件操作表和寄存器基址,通用回调由此找到了具体设备。
驱动加锁后调用 stmmac_get_systime(priv, priv->ptpaddr, &ns),这里还经过一层硬件分派:hwif.c 按 MAC 版本选择时间戳操作表和 PTP 寄存器偏移,hwif.h 中的调用宏再转入相应的 get_systime()。
这样上层读时接口不必随着 MAC 版本改变。
stmmac_hwtstamp.c 中的 get_systime() 读取秒寄存器 PTP_STSR 和纳秒寄存器 PTP_STNSR。
两个寄存器无法在同一次总线读取中取回,如果恰好跨过整秒,就可能拼出一个不一致的时间,所以驱动会再次读取秒值进行检查:
sec1 = readl_relaxed(ioaddr + PTP_STSR);
do {
sec0 = sec1;
ns = readl_relaxed(ioaddr + PTP_STNSR);
sec1 = readl_relaxed(ioaddr + PTP_STSR);
} while (sec0 != sec1);
上面摘录了读取循环,只有前后两次秒值相同,纳秒字段才属于这一秒;否则重新读取。
得到一致结果后,驱动组合成纳秒数,再转换为 timespec64 返回用户态。
这条路径读取的是 PHC 当前时间,报文事件的时间戳由后面的收发路径取回。
调钟则沿相反方向把参数送进硬件。
stmmac_adjust_time() 将有符号的纳秒偏差拆成秒、纳秒和增减方向,再由底层 adjust_systime() 写入时间更新寄存器,并在 PTP_TCR 中置位 TSUPDT,触发硬件更新。
调频入口仍叫 stmmac_adjust_freq(),但在 Linux 6.18 的回调表中绑定的是 adjfine,参数为带 16 位小数的 ppm 值,即 scaled_ppm。
它先用 adjust_by_scaled_ppm() 从 default_addend 算出新的 addend,再调用底层 config_addend():将新值写入 PTP_TAR,置位 PTP_TCR 中的 TSADDREG,并轮询等待硬件清除该位。
addend 是 MAC 计时速率控制中的增量参数,更新它改变的是 PHC 此后的走速,不是重新设置整块 SoC 的晶振频率。
到这一步,用户态提交的校正量才落实为硬件计数行为的变化。
现在软件已经有办法控制硬件时钟了。
不过,注册成功只建立了访问关系,回调是否正确、硬件是否实际响应,还需要运行验证。
即使 PHC 能正常读写,协议所需的报文时间戳也还要经过另一条配置与返回路径。
3. 硬件时间戳的两层配置
在上一篇中,我们已经区分了需要精确收发时刻的事件报文,以及不依赖自身精确收发时刻的通用报文。
网卡不必默认给所有流量取戳,软件需要告诉它要捕获哪些事件,并说明当前应用希望接收什么时间信息。
Linux 把这两个要求分开处理:设备级配置决定网卡怎样取戳,socket 级配置决定应用怎样请求和接收时间戳。
3.1 设备级配置与报文过滤
设备配置中的 struct hwtstamp_config 包含发送模式和接收过滤条件,分别由 tx_type、rx_filter 表示。
以常见的 Two-Step 硬件取戳为例,发送方向可以启用 HWTSTAMP_TX_ON,接收方向则请求捕获所需的 PTP 事件报文。
在 Linux 6.18 的 stmmac 中,这类设置经过网卡的 ndo_hwtstamp_set 回调,进入 stmmac_hwtstamp_set()。
它把用户态提出的发送模式、接收报文范围等要求转换成 MAC 控制位,并保存实际生效的设置,接收过滤条件不一定与用户请求完全相同。
例如,硬件只能为一组 PTPv2 事件取戳,不能单独筛选其中某一种时,驱动可以返回更宽的过滤范围。
用户态需要检查驱动返回的配置,确认所需报文仍在捕获范围内。
3.2 Socket 级请求与时间戳报告
网卡具备取戳条件以后,应用还需要通过 setsockopt() 配置 SO_TIMESTAMPING。
在硬件取戳场景中,下面三个标志分别表达生成请求和报告要求:
| 标志 |
含义 |
SOF_TIMESTAMPING_TX_HARDWARE |
请求发送硬件时间戳 |
SOF_TIMESTAMPING_RX_HARDWARE |
请求接收硬件时间戳 |
SOF_TIMESTAMPING_RAW_HARDWARE |
要求返回可用的原始硬件时间戳 |
这里的 "请求" 和 "报告" 需要分开理解:硬件记录了一个时间,不代表内核一定会把它交给当前 socket;应用还要选择希望收到的时间戳类型。
RAW_HARDWARE 返回的值属于对应的硬件时钟,不能直接当作 CLOCK_REALTIME 的系统时间。

图 3:设备设置控制 MAC 的发送取戳和接收过滤,socket 设置表达应用的取戳请求及报告要求。两类配置分别作用于不同对象。
ptp4l 会在初始化相关 socket 时完成这些工作。
因此,ethtool -T 显示支持硬件时间戳,只能证明驱动报告了相应能力,还需要设备配置和应用请求匹配,运行中才能取得所需结果。
接下来我们沿一次发送和一次接收,看看这些设置怎样作用于报文,又怎样把硬件记录的时间送回应用。
4. TX 时间戳的异步返回
在上一篇中,GM 先发送 Sync,等拿到精确发送时间后,再用 Follow_Up 把它告诉对端。
这个先后关系放到 Linux 中,就要求发送时间戳能够在报文发出以后返回给 ptp4l。
sendmsg() 成功通常只表示数据已被内核接收,不能据此认为报文已经经过 MAC 的取戳点。
内核会把这次发送请求连同取戳要求交给驱动,等硬件完成事件记录后再返回时间戳。
4.1 从 socket 请求到发送描述符
Linux 使用 struct sk_buff 管理网络报文,通常简称 skb。除了报文数据,它还携带处理这份报文所需的元信息,硬件发送取戳请求就是其中一项。
当应用请求发送硬件时间戳时,内核在 skb 的发送标志中设置 SKBTX_HW_TSTAMP。
stmmac 的发送路径检查这个标志和设备取戳状态;能够处理这次请求时,再设置 SKBTX_IN_PROGRESS,并在发送描述符中启用时间戳捕获。
描述符是驱动交给网卡的工作记录,其中既包含发送数据的信息,也可以包含这次发送的取戳要求。
MAC 执行发送任务,报文经过硬件取戳位置时,便记录关联 PHC 的读数。
ptp4l 请求发送事件报文
→ skb 携带 SKBTX_HW_TSTAMP
→ stmmac 设置 SKBTX_IN_PROGRESS 和描述符取戳要求
→ MAC 发送报文并记录 PHC 时间
前两个标志表达的是请求和处理状态,不能单独证明有效时间戳已经产生。结果还需要从硬件取出,并与原来的发送报文保持对应。
4.2 从完成处理到 socket error queue
发送完成后,stmmac 的清理路径调用 stmmac_get_tx_hwtstamp(),检查时间戳状态,从描述符或 MAC 专用时间戳寄存器中读取结果,再通过 skb_tstamp_tx() 把它交回网络栈。
这里读取的是硬件先前锁存的事件时间。
驱动处理中断或执行清理时,即使已经晚了一段时间,也不应该重新读取"此刻的 PHC"来替代它,否则又会把软件处理延迟带回测量。
发送调用此时已经返回,内核需要另一个通道通知应用,这个通道就是 socket 的 error queue,错误队列。
它也用于传递这类异步控制信息,时间戳出现在其中并不表示发送失败。
stmmac_get_tx_hwtstamp()
→ skb_tstamp_tx()
→ socket error queue
→ recvmsg(..., MSG_ERRQUEUE)
→ ptp4l 得到此前发送事件的时间戳
应用从队列中取出消息后,还要解析随消息返回的附属信息。
其中携带时间戳的控制消息通常称为 SCM_TIMESTAMPING,硬件时间位于时间戳数组的 ts[2]。
应用使用旧、新时间 ABI 时需匹配相应结构;新程序应优先使用 SO_TIMESTAMPING_NEW,避免 32 位旧时间结构的 2038 年问题。
把结果与原来的 Sync 配对后,ptp4l 才能形成对应的 Follow_Up。
驱动负责把本机的事件时间送到用户态,Follow_Up 负责把必要信息送到网络对端,这是两个连续但不同的传递过程。
5. RX 时间戳的随包返回
接收方向同样需要硬件记录事件,但时间顺序有所不同:驱动开始处理收到的报文时,报文已经经过接收取戳位置。
它可以在把数据交给协议栈的同时,把此前捕获的接收时间一起带上。
在 stmmac 中,stmmac_get_rx_hwtstamp() 先判断接收时间戳是否有效,再从相应描述符取值。
不同 MAC 版本可能把时间戳放在当前描述符或附加的上下文描述符中,驱动需要使用与硬件版本匹配的读取方式。
取得时间后,驱动将它写入 skb 的硬件时间戳信息:
shhwtstamp = skb_hwtstamps(skb);
shhwtstamp->hwtstamp = ns_to_ktime(ns);
这两行摘自接收取戳路径,省略了前面的有效性检查等处理。
ns 是读回并按驱动要求修正的硬件时间;将它存入 skb 后,后续处理就能继续保持"这份报文—这次接收时间"的关联。
当应用通过普通 recvmsg() 接收报文时,内核同时通过附属控制消息返回硬件时间戳。应用需要解析这些信息,不能只读取报文数据区。

图 4:TX 时间戳在发送完成后经 error queue 返回,RX 时间戳随正常接收消息返回。两者都携带硬件事件时间,但应用读取的队列不同。
接收 Sync 时,ptp4l 可以由此留下本地接收读数;后来收到匹配的 Follow_Up,再取出其中携带的发送时间。
即使软件处理发生得较晚,参与计算的仍是先前那次 Sync 的事件时间,与上一篇的计算前提一致。
因此,抓包看到报文到达,只证明数据收到了,不能证明这个 skb 携带有效硬件时间戳。
反过来,发送函数返回成功,也不能证明应用已经从 error queue 拿到了发送时间,这两条路径需要分别检查。
6. 从事件时间戳到持续校正
至此,前面两条实现路径可以在 ptp4l 中接起来了:本地接收读数经 RX 路径交给它,发送时间由匹配的 Follow_Up 提供,传播时间来自持续的 Pdelay 测量。
取得这些输入后,ptp4l 计算 offset,再由伺服决定怎样调钟。
如果伺服策略允许直接修正当前偏差,它可以通过 PHC 的时钟接口提交相应调整;如果采用渐进校正,就通过调频让后续读数逐步接近目标。
请求经过 PTP class 和 stmmac 回调,最终作用到产生收发时间戳的 MAC 计数器。
下一轮报文经过同一个取戳单元时,硬件便会记录调整后的 PHC 时间。
软件再次计算 offset,就能判断此次校正有没有达到预期;振荡器走速继续变化时,也能用新的样本继续调整。
测量与调节必须对应同一只时钟,或者有明确、正确的时钟间转换关系。
如果时间戳来自网卡 PHC,软件却只改了系统时钟,网卡下一次取戳仍然使用原来的 PHC,前面观察到的偏差就不会因为这次系统调钟而自动消失。
这也把 ptp4l 与 phc2sys 的分工解释完整了:前者通过网络事件校正关联 PHC,后者按部署需要把同步关系延伸到本机其他时钟。
应用最终读取哪个时钟,需要与系统的同步设计一致。
7. 按闭环路径定位问题
有了上述关系,上一篇提到的 TX timestamp timeout、offset 持续增长等现象,就可以更快地追到内核对象和驱动处理位置。
下面是我对一些常见的时间同步报错简单整理的可能错误原因,便于后期定位:
| 观察到的现象 |
优先检查的位置 |
需要确认的结果 |
| 查不到关联 PHC |
硬件能力识别、参考时钟、PHC 注册路径 |
设备实际具备能力,注册成功且与目标网卡关联 |
| PHC 能读,事件报文取不到戳 |
设备过滤器与 socket 配置 |
所需事件在捕获范围内,应用请求并接收硬件戳 |
| TX timestamp timeout |
skb 标志、描述符取戳、完成处理与 error queue |
硬件生成有效戳,驱动提交结果,用户态及时取回 |
| 收到报文却没有 RX 硬件戳 |
RX 描述符有效位、版本对应的取值路径、skb 附属信息 |
有效时间被写入正确 skb,并随接收消息返回 |
| 测量有效但 offset 持续增长 |
PHC 选择、调钟回调、硬件计时参数及其他调钟进程 |
校正请求作用到目标 PHC,且没有被其他控制覆盖 |
| PHC 已同步,应用时间仍不一致 |
应用时钟来源、phc2sys 方向与时间尺度 |
应用使用的时钟确实接入了预期同步关系 |
例如,TX 超时既可能是硬件没有产生时间戳,也可能是完成处理没有把结果交回,或者用户态没有在等待期限内取到它。
仅凭 timeout 就认定"对端没有回包",会把本机发送取戳问题与网络响应问题混在一起。
如果设备能够报告取戳能力,PHC 也已经注册,但运行时仍拿不到有效戳,我们就应继续检查配置和收发路径。
前一项检查通过,只排除了其中一部分原因,不代表后面的所有条件都已经成立。
我认为 BSP 调试最有价值的做法,是把"硬件有能力""软件已配置""本次事件有有效结果"分开保留证据。只有明确测量输入可靠、调钟作用于正确对象,进一步分析伺服参数才有依据。
8. 总结
前面两篇解释了时间为什么会走散,以及 gPTP 怎样从报文交换中求出可以用于校正的偏差。
这一篇把测量和调节接到了 Linux 的具体实现:用户态运行协议与伺服,PTP 框架开放硬件时钟的控制接口,网卡驱动负责配置取戳并返回事件时间。
PHC 控制路径让软件能够修正当前读数和运行速率,TX/RX 返回路径让软件能够用新的事件判断校正效果。
两者围绕同一时间基准持续运行,才使协议计算真正成为设备上的同步过程。
经过三篇文章到这里,相信大家已经对时间同步形成了从基本概念、协议原理到驱动实现的完整关系,这几篇其实还是相对较为深入和硬核的。
之后有时间的时候,我会再另写一篇 Linux gPTP 使用教程,这个可能更适合使用者,因为可能很多同学只是想使用时间同步,并不需要一开始就理解得这么深入。
所以我会再写一个 Linux gPTP 使用教程结合实验平台介绍环境准备、配置、常用命令和同步结果检查,把这些原理对应到可以观察的运行过程,希望对大家能有帮助。本文由云栈社区整理分享。