Skip to content

netutils/ptpd: hardware TX timestamps via MSG_ERRQUEUE and egress latency compensation - #3791

Draft
daniel-p-carvalho wants to merge 9 commits into
apache:masterfrom
daniel-p-carvalho:feat/ptpd-hw-tx-timestamp-v2
Draft

daniel-p-carvalho wants to merge 9 commits into
apache:masterfrom
daniel-p-carvalho:feat/ptpd-hw-tx-timestamp-v2

Conversation

@daniel-p-carvalho

@daniel-p-carvalho daniel-p-carvalho commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Depends on #3782 and on apache/nuttx#20148, which in turn depends on apache/nuttx#20180. Please do not merge this PR before them.

The first seven commits below are the commits of #3782, and are reviewed there. Until #3782 is merged they show up in this diff; only the last two commits are new here. This PR stays a draft until then, and I will rebase it onto master once #3782 is in, so that it is reduced to those two commits. The transmit timestamps it reads only exist with the driver support of apache/nuttx#20148; without it the daemon falls back to software timestamps, as described below.

Summary

With hardware timestamping, ptpd took the transmit time of its event messages (Sync, Delay_Req, Pdelay_Req) from the clock after sendmsg() returned. That is later than the frame really left, so a fast peer's reply could appear to arrive before the request had gone, and the peer delay measured with the peer-to-peer mechanism came out negative (about -18 µs on the board I tested with) and was rejected. #3782 says so: on this hardware P2P needs accurate transmit timestamps.

This PR reads the transmit timestamp from the hardware the way Linux does. When hardware timestamping is selected on an AF_PACKET socket, ptpd requests SOF_TIMESTAMPING_TX_HARDWARE for each event message, waits for the looped-back packet on the error queue with MSG_ERRQUEUE and takes the timestamp from its SO_TIMESTAMPING control message. The driver side of that is apache/nuttx#20148.

Commits

The first seven are the commits of #3782, unchanged.

  1. Retrieve hardware TX timestamps via MSG_ERRQUEUE. Timestamps transmitted event messages with the hardware clock instead of reading the clock after sendmsg() returns.
    • A transmit socket of its own, separate from the event socket, so the error queue is not shared with received packets.
    • POLLERR is handled separately from POLLIN in the main loop, and all pending packets are drained on each wakeup.
    • If the timestamp does not arrive, a software timestamp taken before the frame is sent is used. After three consecutive failures the driver is assumed not to provide hardware transmit timestamps, a warning is printed once and only software timestamps are used, so a driver without support does not stall the daemon.
    • The software timestamp is taken before sending in every mode.
    • A measured path delay down to -100 µs is accepted and clamped to zero, since hardware timestamps on both ends can make a short link measure slightly negative.
  2. Add egress latency compensation for TX timestamps. The frame leaves the MAC later than the moment its hardware transmit timestamp is latched, because of the clock domain crossing and the PHY. That fixed delay is the egressLatency port parameter of IEEE 1588. Adds CONFIG_NETUTILS_PTPD_EGRESS_LATENCY_NS (default 0, no compensation), the -O option to override it at run time and egress_latency_ns in struct ptpd_config_s. It is the counterpart of the ingress compensation of netutils/ptpd: IEEE 1588 P2P delay mechanism, hardware clock phase-lock and outlier rejection #3782, and software timestamps are not affected.

Impact

  • New option -O and CONFIG_NETUTILS_PTPD_EGRESS_LATENCY_NS, default 0. struct ptpd_config_s gets egress_latency_ns; the only user in the tree is system/ptpd, which is updated here.
  • Only relevant with hardware timestamping (-H). A driver that does not return transmit timestamps makes the daemon switch to software timestamps after three failures.
  • The egress compensation value has not been calibrated, so the default 0 applies no correction.

Testing

Built for stm32f4discovery:ethraw with CONFIG_STM32_ETH_TIMESTAMP_TX=y, without errors or warnings from ptpd. ./tools/checkpatch.sh -g <base>..HEAD passes.

On hardware: a custom STM32F407 board with the STM32 Ethernet MAC and a DP83848 PHY, over AF_PACKET and through a Fast Ethernet switch (ptpd -2 -s -p /dev/ptp0 -B -H -P -i eth0), against a GNSS-referenced ptp4l Grandmaster using the IEC/IEEE 61850-9-3 profile, with the driver support of apache/nuttx#20148:

  • path_delay 9265.3 ns with σ = 2.5 ns over a 20 minute run (159 samples, no failed queries), phase offset about -2.2 µs, drift about -79.95 ppm.
  • Several 5 minute runs: path_delay between 9.26 and 9.33 µs.
  • The PPS output of the MAC, measured on an oscilloscope against the Grandmaster's PPS, stayed within about 20 µs.
  • The same series without this PR, with software transmit timestamps, gave a negative peer delay (about -18 µs) that was rejected, so path_delay_ns stayed at 0 and the phase offset was worse (about -6.7 µs against -2.4 µs).

The end-to-end mechanism (-E), master mode, the best master clock algorithm, a 24 hour run and a calibrated -O value were not tested.

ptp_sendmsg() called a blocking recvmsg(state->tx_socket, ...) right
after sending a Delay_Req whenever hardware_ts was set, assuming a
Linux-style MSG_ERRQUEUE/loopback semantics NuttX does not have.
Since tx_socket and event_socket share the same underlying
connection, this call instead blocked on and consumed whatever PTP
packet arrived next on the wire — almost always the Delay_Resp,
which typically arrives within milliseconds of the request. Its
payload was read into a local buffer that went out of scope on
return, so the packet never reached ptp_process_rx_packet() and
path_delay_ns stayed at 0 in -H mode. t3 is now captured locally
via ptp_gettime(), the same way -S mode already did, until
hardware TX timestamping is supported.

Also replaces the path delay heuristic in ptp_process_delay_resp()
(which derived an approximation of (t2-t1) from path_delay_ns and
last_delta_ns, only valid once the clock had already converged) with
the canonical IEEE 1588-2008 §11.3 formula: store (t2-t1) directly
from Sync/Follow_Up as sync_diff_ns, then average it with (t4-t3)
from the Delay_Req/Delay_Resp exchange. Relaxes the path delay
ceiling to 10ms unconditionally, since Delay_Req's t3 is software-
timestamped in both modes until hardware TX timestamping is supported.

Assisted-by: Claude:claude-sonnet-5
Assisted-by: Gemini:gemini-3.8-flash-medium
Signed-off-by: Daniel P. Carvalho <danieloak@gmail.com>
Implements the Peer-to-Peer (P2P) transparent clock delay measurement
mechanism (IEEE 1588-2008 §11.4 / IEEE 802.1AS / IEC/IEEE 61850-9-3)
in apps/netutils/ptpd:

- Add PTP_MSGTYPE_PDELAY_REQ, PTP_MSGTYPE_PDELAY_RESP, and
  PTP_MSGTYPE_PDELAY_RESP_FOLLOW_UP definitions and structs in ptpv2.h.
- Define IEEE 1588-2008 Annex F peer delay multicast MAC address
  01:80:c2:00:00:0e and Annex D peer delay IP address 224.0.0.107.
- Replace bool delay_e2e with enum ptp_delay_mechanism_e (PTP_DELAY_NONE,
  PTP_DELAY_E2E, PTP_DELAY_P2P) in include/netutils/ptpd.h.
- Add -P CLI option in system/ptpd/ptpd_main.c with mutual exclusion
  check against -E, and display last_transmitted_pdelayreq in status.
- Implement responder logic in ptp_process_pdelay_req() sending
  Pdelay_Resp (t2) and Pdelay_Resp_Follow_Up (t3) regardless of master
  or slave state.
- Implement requester logic in ptp_send_pdelay_req() gated on the
  physical link without requiring prior BMCA master selection.
- Implement ptp_process_pdelay_resp() and
  ptp_process_pdelay_resp_followup() using canonical mean path delay
  formula ((t4 - t1) - (t3 - t2)) / 2.
- Refactor path delay bounds checking and moving average filter into
  ptp_record_path_delay() shared across E2E and P2P mechanisms.
- Set PTP version 2.0 and controlField 0x05 in Pdelay_Req, Pdelay_Resp
  and Pdelay_Resp_Follow_Up, and in the own-identity header, so that
  peers such as linuxptp accept the messages.
- Clear pdelay_waiting_followup when a new Pdelay_Req is sent, so an
  orphaned Pdelay_Resp_Follow_Up from an abandoned cycle is not paired
  with stale timestamps.
- Warn at startup when P2P is selected without CONFIG_SCHED_TICKLESS,
  since a tick-driven clock cannot resolve the peer delay.
- Skip IP multicast join/leave handling for AF_PACKET.

Assisted-by: Claude:claude-sonnet-5
Assisted-by: Gemini:gemini-3.8-flash-medium
Signed-off-by: Daniel P. Carvalho <danieloak@gmail.com>
A single drift-rate sample computed between two consecutive sync
updates was clamped against CLOCK_ADJTIME_SLEWLIMIT_PPM - the
hardware's slew-rate safety limit, not a bound on how large a real
crystal-oscillator drift measurement can plausibly be. An abnormally
short or long measurement interval (e.g. right after a clock
source outage/reconnect, or a burst of closely spaced sync packets
following packet loss) could therefore produce a wildly implausible
sample that still passed the check and corrupted the long-term
drift_ppb average.

Add CONFIG_NETUTILS_PTPD_MAX_DRIFT_PPB (default 500000, well above
any real crystal's few-hundred-ppm drift) as a dedicated plausibility
bound, intentionally much tighter than CLOCK_ADJTIME_SLEWLIMIT_PPM.
A sample outside this bound is discarded and the previous averaged
drift_ppb is kept unchanged instead of being corrupted.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Daniel P. Carvalho <danieloak@gmail.com>
When state->clockid is configured to a hardware PTP clock device
(e.g., /dev/ptp0) instead of CLOCK_REALTIME, ptp_adjtime() previously
passed only the measured frequency drift (-ppb) to clock_adjtime(),
ignoring the residual phase offset (delta_ns / adjustment_ns).
As a result, while the hardware counter tracked frequency, its phase
was never pulled into alignment with the master clock.

Convert delta_ns (which combines frequency drift and current phase error
clamped to max_adjust_ns) to ppb over CONFIG_CLOCK_ADJTIME_PERIOD_MS,
acting as a proportional-integral (PI) phase servo. This drives the
hardware clock to phase lock with the master via POSIX clock_adjtime()
using ADJ_FREQUENCY without requiring proprietary ioctl calls.

Assisted-by: Claude:claude-sonnet-5
Assisted-by: Gemini:gemini-3.8-flash-medium
Signed-off-by: Daniel P. Carvalho <danieloak@gmail.com>
The MAC latches a hardware receive timestamp later than the frame
reaches the wire, because of the PHY and the clock domain crossing.
This fixed delay is the ingressLatency port parameter of IEEE 1588 and
shows up as a constant phase error between the local and the master
clock.

Subtract the configured latency from every hardware receive timestamp
in ptp_getrxtime(), the single place where they enter the daemon, so
Sync, Delay_Resp and the peer delay messages are all corrected.

- Add CONFIG_NETUTILS_PTPD_INGRESS_LATENCY_NS (default 0, which applies
  no compensation).
- Add the -I option to override it at run time.
- Add ingress_latency_ns to struct ptpd_config_s.

Software timestamps are not affected.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Daniel P. Carvalho <danieloak@gmail.com>
…tus()

PR apache#3789 replaced the in-memory sigqueue + shared memory IPC in ptpd_status()
with file-based IPC to support Protected and Kernel modes across address
spaces. However, on microcontrollers running CONFIG_BUILD_FLAT, a filesystem
or /tmp (TMPFS) is rarely mounted or available, causing ptpd_status() to fail
with -ETIMEDOUT (errno 110) because the status file cannot be created.

Retain the file-based IPC for !CONFIG_BUILD_FLAT (Protected and Kernel modes)
while restoring the zero-overhead in-memory sigqueue + semaphore IPC for
CONFIG_BUILD_FLAT. Both modes share the status serialization logic via
ptp_populate_status() and support all fields including P2P.

Assisted-by: Gemini:gemini-3.8-flash-medium
Signed-off-by: Daniel P. Carvalho <danieloak@gmail.com>
A single Sync sample whose receive timestamp was taken late, for
example because the task was scheduled late with software
timestamping, was fed straight into the phase correction and the
drift estimate, and could pull the clock away from the master.

- Add CONFIG_NETUTILS_PTPD_OUTLIER_THRESHOLD_NS (default 0, which
  disables the check). A phase error that differs by more than this
  many nanoseconds from the median of the last five accepted samples
  is discarded, with a warning.
- Accept the sample after eight consecutive rejections and restart
  the history from it, so that a real step of the master is still
  followed while a short burst of disturbed samples is ridden out.
- Restart the history whenever the clock is stepped, since the old
  samples no longer describe the new time base.
- With the default of 0 the behaviour is unchanged.

Signed-off-by: Daniel P. Carvalho <danieloak@gmail.com>
Assisted-by: Claude:claude-sonnet-5
Timestamp transmitted event messages with the hardware clock instead of
reading the clock after sendmsg() returns.

When hardware timestamping is selected on an AF_PACKET socket, request
SOF_TIMESTAMPING_TX_HARDWARE for each event message, wait for the
looped-back packet on the error queue with MSG_ERRQUEUE, and take the
timestamp from its SO_TIMESTAMPING control message, as on Linux. Sync,
Delay_Req and Pdelay_Req get their real departure time.

- Use a transmit socket of its own, separate from the event socket, so
  the error queue is not shared with received packets.
- Handle POLLERR separately from POLLIN in the main loop and drain all
  pending packets on each wakeup.
- If the timestamp does not arrive, fall back to a software timestamp
  taken before the frame is sent. After three consecutive failures the
  driver is assumed not to provide hardware transmit timestamps, a
  warning is printed once and only software timestamps are used, so a
  driver without support does not stall the daemon.
- Take the software timestamp before sending in every mode. It used to
  be taken after sendmsg() returned, so a fast peer's reply could appear
  to arrive before the request had left and give a negative delay.
- Accept a measured path delay down to -100 microseconds and clamp it to
  zero, since hardware timestamps on both ends can make a short link
  measure slightly negative.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Daniel P. Carvalho <danieloak@gmail.com>
The frame leaves the MAC later than the moment its hardware transmit
timestamp is latched, because of the clock domain crossing and the PHY.
This fixed delay is the egressLatency port parameter of IEEE 1588.

Add the configured latency to every hardware transmit timestamp
obtained through MSG_ERRQUEUE, the counterpart of the ingress
compensation.

- Add CONFIG_NETUTILS_PTPD_EGRESS_LATENCY_NS (default 0, which applies
  no compensation).
- Add the -O option to override it at run time.
- Add egress_latency_ns to struct ptpd_config_s.

Software timestamps are not affected.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Daniel P. Carvalho <danieloak@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant