Linux Raspberry Pi

Microsecond accurate NTP with a Raspberry Pi and PPS GPS


Lots of acronyms in that title. If I expand them out, it says – “microsecond accurate network time protocol with a Raspberry Pi and pulse per second global positioning system [receiver]”. What it means is you can get super accurate timekeeping (1 microsecond = 0.000001 seconds) with a Raspberry Pi and a GPS receiver (the GT-U7 is less than $12) that spits out pulses every second. By following this guide, you will your very own Stratum 1 NTP server at home!

Why would you need time this accurate at home?

You don’t. There aren’t many applications for this level of timekeeping in general, and even fewer at home. But this blog is called Austin’s Nerdy Things so here we are. Using standard, default internet NTP these days will get your computers to within a 10-20 milliseconds of actual time (1 millisecond = 0.001 seconds). By default, Windows computers get time from MacOS computers get time from Linux devices get time from [entity], like PPS gets you to the next SI prefix in terms of accuracy (milli -> micro), which means 1000x more accurate timekeeping.

YouTube video link

If you prefer a video version –

Materials needed


0 – Update your Pi and install packages

This NTP guide assumes you have a Raspberry Pi ready to go. If you don’t, I have a 16 minute video on YouTube that goes through flashing the SD card and initial setup –

You should update your Pi to latest before basically any project. We will install some other packages as well. pps-tools help us check that the Pi is receiving PPS signals from the GPS module. We also need GPSd for the GPS decoding of both time and position. I use chrony instead of NTPd because it seems to sync faster than NTPd in most instances and also handles PPS without compiling from source (the default Raspbian NTP doesn’t do PPS) Installing chrony will remove ntpd.

sudo apt update
sudo apt upgrade
sudo rpi-update
sudo apt install pps-tools gpsd gpsd-clients python-gps chrony

1 – add GPIO and module info where needed

In /boot/config.txt, add ‘dtoverlay=pps-gpio,gpiopin=18’ to a new line. This is necessary for PPS. If you want to get the NMEA data from the serial line, you must also enable UART and set the initial baud rate.

sudo bash -c "echo '# the next 3 lines are for GPS PPS signals' >> /boot/config.txt"
sudo bash -c "echo 'dtoverlay=pps-gpio,gpiopin=18' >> /boot/config.txt"
sudo bash -c "echo 'enable_uart=1' >> /boot/config.txt"
sudo bash -c "echo 'init_uart_baud=9600' >> /boot/config.txt"

In /etc/modules, add ‘pps-gpio’ to a new line.

sudo bash -c "echo 'pps-gpio' >> /etc/modules"


sudo reboot

2 – wire up the GPS module to the Pi

I used the Adafruit Ultimate GPS breakout. It has 9 pins but we are only interested in 5. There is also the Adafruit GPS hat which fits right on the Pi but that seems expensive for what it does (but it is significantly neater in terms of wiring).

Pin connections:

  1. GPS PPS to RPi pin 12 (GPIO 18)
  2. GPS VIN to RPi pin 2 or 4
  3. GPS GND to RPi pin 6
  4. GPS RX to RPi pin 8
  5. GPS TX to RPi pin 10
  6. see 2nd picture for a visual
Adafruit Ultimate GPS Breakout V3
We use 5 wires total. GPS PPS to pin 12 (GPIO 18), GPS VIN to pin 2 or 4, GPS GND to pin 6, GPS RX to pin 8, GPS TX to pin 10.
GPS with wires attached to the board (but not to the Pi) and the antenna. The antenna has a SMA connector, and there is another adapter that is SMA to u.fl to plug into the GPS board.

Now place your GPS antenna (if you have one) in a spot with a good view of the sky. If you don’t have an antenna, you might have to get a bit creative with how you locate your Raspberry Pi with attached GPS.

I honestly have my antenna in the basement (with only the kitchen and attic above) and I generally have 8-10 satellites locked all the time (11 as of writing). Guess that means the antenna works better than expected! Either way, better exposure to the sky will in theory work better. Pic:

My super awesome placement of the GPS antenna on top of the wood “cage” (?) I built to hold my 3d printer on top of my server rack. I guess this is a testimony for how well the GPS antenna works? It has 11 satellites locked on as of writing, with a HDOP of 0.88. The components in this rack (Brocade ICX6450-48P, 1U white box with Xeon 2678v3/96GB memory/2x480GB enterprise SSDs/4x4TB HDDs, Dell R710 with 4x4TB and other stuff) will be detailed in an upcoming post.

3 – check that PPS is working

First, check that the PPS module is loaded:

lsmod | grep pps

The output should be like:

[email protected]:~ $ lsmod | grep pps
pps_gpio               16384  0
pps_core               16384  1 pps_gpio

Second, check for the PPS pulses:

sudo ppstest /dev/pps0

The output should spit out a new line every second that looks something like this (your output will be a bit farther from x.000000 since it isn’t yet using the GPS PPS):

[email protected]:~ $ sudo ppstest /dev/pps0
trying PPS source "/dev/pps0"
found PPS source "/dev/pps0"
ok, found 1 source(s), now start fetching data...
source 0 - assert 1618799061.999999504, sequence: 882184 - clear  0.000000000, sequence: 0
source 0 - assert 1618799062.999999305, sequence: 882185 - clear  0.000000000, sequence: 0
source 0 - assert 1618799063.999997231, sequence: 882186 - clear  0.000000000, sequence: 0
source 0 - assert 1618799064.999996827, sequence: 882187 - clear  0.000000000, sequence: 0
source 0 - assert 1618799065.999995852, sequence: 882188 - clear  0.000000000, sequence: 0

4 – change GPSd to start immediately upon boot

Edit /etc/default/gpsd and change GPSD_OPTIONS=”” to GPSD_OPTIONS=”-n” and change DEVICES=”” to DEVICES=”/dev/ttyS0 /dev/pps0″, then reboot. My full /etc/default/gpsd is below:

[email protected]:~ $ sudo cat /etc/default/gpsd
# Default settings for the gpsd init script and the hotplug wrapper.

# Start the gpsd daemon automatically at boot time

# Use USB hotplugging to add new USB devices automatically to the daemon

# Devices gpsd should collect to at boot time.
# They need to be read/writeable, either by user gpsd or the group dialout.
DEVICES="/dev/ttyS0 /dev/pps0"

# Other options you want to pass to gpsd
sudo reboot

4.5 – check GPS for good measure

To ensure your GPS has a valid position, you can run gpsmon or cgps to check satellites and such. This check also ensures GPSd is functioning as expected. If your GPS doesn’t have a position solution, you won’t get a good time signal. If GPSd isn’t working, you won’t get any updates on the screen. The top portion will show the analyzed GPS data and the bottom portion will scroll by with the raw GPS sentences from the GPS module.

gpsmon showing 10 satellites used for the position with HDOP of 0.88. This indicates a good position solution which means the time signals should be good as well. The PPS of 0.000000684 indicates the Raspberry Pi is only 684 nanoseconds off of GPS satellite time.

5 – edit config files

For chrony, add these two lines to the /etc/chrony/chrony.conf file somewhere near the rest of the server lines:

refclock SHM 0 refid NMEA offset 0.200
refclock PPS /dev/pps0 refid PPS lock NMEA

My entire /etc/chrony/chrony.conf file looks like this:

###### below this line are custom config changes #######
server iburst minpoll 3 maxpoll 5
server iburst

# delay determined experimentally by setting noselect then monitoring for a few hours
# 0.325 means the NMEA time sentence arrives 325 milliseconds after the PPS pulse
# the delay adjusts it forward
refclock SHM 0 delay 0.325 refid NMEA
refclock PPS /dev/pps0 refid PPS

allow # my home network
###### above this line are custom config changes #######

###### below this line is standard chrony stuff #######
keyfile /etc/chrony/chrony.keys
driftfile /var/lib/chrony/chrony.drift
#log tracking measurements statistics
logdir /var/log/chrony
maxupdateskew 100.0
hwclockfile /etc/adjtime
makestep 1 3

Restart chrony, wait a few minutes, and verify.

sudo systemctl restart chrony

5 – verify the NTP server is using the GPS PPS

Right after a chrony restart, the sources will look like this (shown by running ‘chronyc sources’)

[email protected]:~ $ chronyc sources
210 Number of sources = 9
MS Name/IP address         Stratum Poll Reach LastRx Last sample
#? NMEA                          0   4     0     -     +0ns[   +0ns] +/-    0ns
#? PPS                           0   4     0     -     +0ns[   +0ns] +/-    0ns
^?     0   3     3     -     +0ns[   +0ns] +/-    0ns
^?             1   6     3     1  -2615us[-2615us] +/- 8218us
^?             1   6     1     3  -2495us[-2495us] +/- 7943us
^?            0   6     0     -     +0ns[   +0ns] +/-    0ns
^?                 3   6     1     4  -4866us[-4866us] +/-   43ms
^? usdal4-ntp-002.aaplimg.c>     1   6     1     4  -2143us[-2143us] +/-   13ms
^?           3   6     1     3  -3747us[-3747us] +/- 9088us

The # means locally connected source of time. The question marks mean it is still determine the status of each source.

After a couple minutes, you can check again:

[email protected]:~ $ chronyc -n sources
210 Number of sources = 9
MS Name/IP address         Stratum Poll Reach LastRx Last sample
#x NMEA                          0   4   377    23    -37ms[  -37ms] +/- 1638us
#* PPS                           0   4   377    25   -175ns[ -289ns] +/-  126ns
^?                     0   5   377     -     +0ns[   +0ns] +/-    0ns
^-                  1   6   177    22  -3046us[-3046us] +/- 8233us
^- 2610:20:6f96:96::4            1   6    17    28  -2524us[-2524us] +/- 7677us
^?                1   6     3    30  -3107us[-3107us] +/- 8460us
^-                 3   6    17    28  -8233us[-8233us] +/-   47ms
^-                  1   6    17    29  -3048us[-3048us] +/-   14ms
^- 2606:4700:f1::123             3   6    17    29  -3325us[-3325us] +/- 8488us

For the S column, * means the source that is active. + means it is considered a good source and would be used if the current one is determined to be bad or is unavailable. The x shown for the NMEA source means it is a “false ticker”, which means it isn’t being used. In our case that is fine because the PPS source is active and valid. Anything else generally means it won’t be used.

In this case, chrony is using the PPS signal. The value inside the brackets is how far off chrony is from the source. It is showing that we are 289 nanoseconds off of GPS PPS time. This is very, very close to atomic clock level accuracy. The last column (after the +/-) includes latency to the NTP source as well as how far out of sync chrony thinks it is (for example, the server is 12.5 milliseconds away one-way via ping):

[email protected]:~ $ ping -c 5
PING ( 56(84) bytes of data.
64 bytes from icmp_seq=1 ttl=54 time=25.2 ms
64 bytes from icmp_seq=2 ttl=54 time=27.7 ms
64 bytes from icmp_seq=3 ttl=54 time=23.8 ms
64 bytes from icmp_seq=4 ttl=54 time=24.4 ms
64 bytes from icmp_seq=5 ttl=54 time=23.4 ms

--- ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4007ms
rtt min/avg/max/mdev = 23.403/24.954/27.780/1.547 ms

For a full list of information about how to interpret these results, check here –

6 – results

A day after the bulk of writing this post, I turned on logging via the .conf file and restarted chrony. It settled to microsecond level accuracy in 57 seconds. For the offset column, the scientific notation e-7 means the results are in nanoseconds. This means that for the 19:54:35 line, the clock is -450.9 nanoseconds off of the PPS signal. There is that e-10 line in there which says that it is 831 picoseconds off PPS (I had to look up SI prefixes to make sure pico came after nano! also I doubt the Pi can actually keep track of time that closely.). After the initial sync, there is only 1 line in the below log that is actually at the microsecond level (the others are all better than microsecond) – the 20:00:59 entry, which shows the clock is -1.183 microseconds off.

Things that affect timekeeping on the Pi

Thought I’d toss in this section for completeness (i.e. thanks for all the good info Austin but how can I make this even better?). There are a few things that affect how well the time is kept on the Pi:

  • Ambient temperature around the Pi – if you plot the freq PPM against ambient temperature, there will be a clear trend. The more stable the ambient temp, the less variation in timekeeping.
  • Load on the Pi – similar to above. Highly variable loads will make the processor work harder and easier. Harder working processor means more heat. More heat means more variability. These crystals are physical devices after all.
  • GPS reception – they actually making timing GPS chips that prefer satellites directly overhead. They have better ability to filter out multipathing and such. In general, the better the GPS reception, the better the PPS signal.


After running through the steps in this guide, you should now have a functional Stratum 1 NTP server running on your Raspberry Pi with microsecond level accuracy provided by a PPS GPS. This system can obtain time in the absence of any external sources other than the GPS (for example, internet time servers), and then sync up with the extremely precise GPS PPS signal. Our NTP GPS PPS clock is now within a few microseconds of actual GPS time.


I read a ton on and other pages on that domain over the years which has been extremely helpful in getting GPS PPS NTP going for my setup. There is a lot of background info for how/why this stuff works and many useful graphics. Another source is for a short and sweet (maybe too short) set of commands.

19 replies on “Microsecond accurate NTP with a Raspberry Pi and PPS GPS”

You can make your Rasperry Pi considerable more accurate by replacing the current unregulated 19.2MHz crystal oscillator by a 19.2MHz TCXO. Doing that will reduce your clock jitter from hundred of nanoseconds to single digit nanoseconds over longer periods.

I would be interested in learning more about this. Can you tell me if its replacing the oscillator on the board or by connecting the new one through GPIO?

If you decide to by the Adafruit Ultimate GPS Pi Hat instead (which I did) you will need to change line 2 in step 1’s code from:

sudo bash -c “echo ‘dtoverlay=pps-gpio,gpiopin=18’ >> /boot/config.txt”


sudo bash -c “echo ‘dtoverlay=pps-gpio,gpiopin=4’ >> /boot/config.txt”

as the hat uses GPIO 4 for the pps.

Thanks for the nice tutorial.
I was able to make it kind of work with a rather cheap M6N receiver, that sends NMEA messages (could also easily read the messages using the “cat /dev/ttyS0” command, cpgs showed believable values). Kind of work, because chronyc sources picked the NMEA as the source, not the pps.
Trying to use a Sparkfun M9N chip, I was not able to make it work. cgps only dispalys Lat and Lon if I am lucky and chronyc sources marked both, NMEA and pps, with and “x”.
I have the suspicion, that it has to do with the protocol (“cat /dev/ttyS0” only shows gibberish, but in a nice pace). Can you give me a hint how to make it work using this GPS-receiver?

Given there are some issues with the supply of RPi4 at the moment. Can this NTP server be created on an RPi2 as an alternative?

Yes it can! I believe it will work on any generation of a Pi. The loads of NTP are quite low. But if you want to try it on a 1/2 I wouldn’t put much else on it.

I have an NTP server based on NTPd using just de NMEA dirver included in NTPd on on old Linux mini-pc after enabling PPS. Works fine. Is there an obligation to use the GPSd daemon with SHM on the Raspberry PI? I feel this daemon adds more complexity and ways to fail.

The GPSd daemon is extremely stable. Are you sure you aren’t already using GPSd? As far as I know, NTP does not speak directly with the GPS unit. That task is handled by GPSd. I was under the impression that it isn’t possible to use the NMEA driver without GPSd.

Thanks for this information; it was very helpful.

I struggled to get an output from gpsmon for a while. Other tutorials suggest turning off the serial console so as not to interfere with the GPS UART. I am new enough to this that I rely heavily on “cookbook” solutions; there are more details here that I am unfamiliar with, but I am learning!

Here is the output of chronyc sources:

210 Number of sources = 9
MS Name/IP address Stratum Poll Reach LastRx Last sample
#? NMEA 0 4 0 – +0ns[ +0ns] +/- 0ns
#* PPS 0 4 377 18 +119ns[ +164ns] +/- 156ns
^- 2600:3c01::f03c:91ff:feb> 2 10 377 1027 -4016us[-4016us] +/- 49ms
^- 2601:603:b7f:fec0:feed:f> 2 10 377 644 -1890us[-1890us] +/- 45ms
^- 2 10 377 120 -9951us[-9950us] +/- 154ms
^- 2 10 377 967 -3694us[-3694us] +/- 82ms
^- 1 10 377 654 +1552us[+1552us] +/- 41ms
^- ussjc2-ntp-002.aaplimg.c> 1 10 377 786 -3693us[-3694us] +/- 11ms
^- 1 10 377 0 -2764us[-2764us] +/- 19ms

I was finally able to get an output from gpsmon, but only after running this command;

$ sudo gpsmon /dev/ttyAMA0

Is this correct, or do I need to change something (and if so, how)?
I see the NMEA line in the output above is blank, which is not the case in your example. Is this related to my previous question?

Finally, here is a tracking log output. I note the Freq ppm is quite a bit higher than your example.

Date (UTC) Time IP Address St Freq ppm Skew ppm Offset L Co Offset sd Rem. corr. Root delay Root disp. Max. error
2022-01-22 21:55:37 PPS 1 4.181 0.008 -1.853e-07 N 1 1.308e-07 -3.267e-08 1.000e-09 9.378e-06 2.815e-05
2022-01-22 21:55:53 PPS 1 4.180 0.006 -8.448e-08 N 1 1.238e-07 3.384e-08 1.000e-09 1.009e-05 2.568e-05
2022-01-22 21:56:09 PPS 1 4.180 0.006 -9.915e-08 N 1 1.216e-07 4.291e-08 1.000e-09 9.307e-06 2.633e-05
2022-01-22 21:56:25 PPS 1 4.179 0.004 -9.254e-08 N 1 9.092e-08 3.139e-08 1.000e-09 8.389e-06 2.556e-05
2022-01-22 21:56:41 PPS 1 4.179 0.003 4.639e-08 N 1 8.636e-08 1.112e-08 1.000e-09 8.461e-06 2.460e-05
2022-01-22 21:56:57 PPS 1 4.178 0.003 -6.817e-08 N 1 8.631e-08 -2.583e-08 1.000e-09 8.606e-06 2.457e-05
2022-01-22 21:57:13 PPS 1 4.178 0.003 2.173e-08 N 1 8.049e-08 2.847e-08 1.000e-09 7.331e-06 2.472e-05
2022-01-22 21:57:29 PPS 1 4.178 0.003 -9.590e-08 N 1 8.872e-08 6.239e-09 1.000e-09 8.280e-06 2.340e-05
2022-01-22 21:57:45 PPS 1 4.178 0.003 -7.691e-08 N 1 9.102e-08 2.351e-08 1.000e-09 9.196e-06 2.444e-05
2022-01-22 21:58:01 PPS 1 4.177 0.002 -3.345e-08 N 1 8.725e-08 2.643e-08 1.000e-09 9.915e-06 2.536e-05
2022-01-22 21:58:17 PPS 1 4.177 0.002 4.439e-08 N 1 8.511e-08 3.246e-08 1.000e-09 8.155e-06 2.603e-05
2022-01-22 21:58:33 PPS 1 4.177 0.002 -2.607e-08 N 1 8.186e-08 -1.104e-08 1.000e-09 9.723e-06 2.422e-05
2022-01-22 21:58:50 PPS 1 4.177 0.002 7.627e-09 N 1 7.765e-08 1.317e-08 1.000e-09 8.009e-06 2.579e-05
2022-01-22 21:59:06 PPS 1 4.177 0.001 1.010e-08 N 1 7.416e-08 5.172e-09 1.000e-09 9.017e-06 2.406e-05
2022-01-22 21:59:22 PPS 1 4.177 0.002 -8.711e-08 N 1 8.304e-08 -4.910e-09 1.000e-09 9.745e-06 2.506e-05
2022-01-22 21:59:38 PPS 1 4.177 0.002 -6.299e-08 N 1 8.496e-08 2.788e-08 1.000e-09 8.784e-06 2.587e-05
2022-01-22 21:59:54 PPS 1 4.176 0.002 -1.062e-07 N 1 8.794e-08 2.601e-08 1.000e-09 8.676e-06 2.492e-05
2022-01-22 22:00:10 PPS 1 4.176 0.003 -1.904e-07 N 1 1.383e-07 -9.262e-12 1.000e-09 8.736e-06 2.486e-05
2022-01-22 22:00:26 PPS 1 4.175 0.006 -1.316e-07 N 1 1.361e-07 1.551e-08 1.000e-09 8.321e-06 2.502e-05

Is this the result of the inevitable inaccuracies of the timebase on the Raspberry Pi?

Thanks again.


I should have also mentioned that I am using a NEO-6M module with it’s UART and PPS outputs to the Pi, just as you describe here. I am using a windows machine to run U-Center, from which I can change settings on the ‘6M.

Hi Rob, yes, I do need to add the part about turning off the serial console. I do appreciate that you worked through it yourself though! Freq PPM will vary across hardware, nothing to worry about there. For your sudo gpsd command – did you set /etc/defaults/gpsd to use the correct port? Might be worth trying ttyAMA0 instead of ttyS0.

Hi, after some times running chrony and gpsd I found that:

MS Name/IP address Stratum Poll Reach LastRx Last sample
#x NMEA 0 4 377 17 +177ms[ +177ms] +/- 165ms
#* PPS 0 4 377 17 -2166ns[-3443ns] +/- 1843ns
^- 2 7 377 48 +1490us[+1489us] +/- 35ms
^- 2 6 377 80 +2848us[+2845us] +/- 75ms
^- 2 7 377 151 -12ms[ -12ms] +/- 32ms
^- 3 6 377 179 -1096us[-1104us] +/- 17ms
^- 1 6 377 173 +2551us[+2543us] +/- 20ms
^- 2 6 377 42 +2277us[+2276us] +/- 49ms
^- 1 6 377 46 -865us[ -866us] +/- 19ms
^- 2 7 377 110 -235us[ -245us] +/- 64ms
^- 2 7 377 43 -434us[ -435us] +/- 75ms
^- 1 6 377 172 +2957us[+2948us] +/- 20ms
^- 2 7 377 42 +3418us[+3417us] +/- 67ms
^- 1 7 377 42 -11ms[ -11ms] +/- 27ms

Do you think that PPS are correct?
I’m using a orange pi zero, with u-blox M8N GPS and PPS connected to OPi GPIO.
GPS and serial port are configured to 115200 baud rate.

Thanks for this. I found I had to disable bluetooth to allow GPSD to access the serial port (added ‘dtoverlay=disable-bt’ to /boot/config.txt) otherwise there was a conflict for GPSD accessing the serial port /dev/ttyAMA0. (I’m on a RPi 3B running Raspios Bullseye and the same Adafruit GPS v3)
Looking at your photos, it seems your connections to the RPi header are displaced one position down (e.g. green +5v Vin on the GPS seems to be connected to RPi pin 6 not pin4)

Success! I used an Adafruit Ultimate GPS HAT, PPS GPIO pin #4. It’s nice having a disciplined nanosecond GPS-fed NTP server, but when I mention it at parties people just walk away!

Leave a Reply

Your email address will not be published.