To reliably interface a GPS and Raspberry Pi, bypass USB-to-serial adapters and wire the module’s UART TX/RX pins directly to the Pi’s GPIO header. USB adapters introduce latency, drop packets under heavy bus load, and require messy udev rules to maintain persistent mount points. A direct UART connection to the Pi 5’s RP1 southbridge yields a rock-solid, low-latency NMEA data stream.
This guide targets the Raspberry Pi 5 (8GB variant) running Raspberry Pi OS (Bookworm or later). We will use the u-blox NEO-M9N, a multi-band GNSS receiver that achieves lock faster and holds it longer in urban canyons compared to the older NEO-6M or MTK3339 chips.
Project Overview & Difficulty Rating
The Pi 5 represents a major architectural shift from the Pi 4. The Broadcom BCM2712 application processor delegates I/O to the RP1 chip. Consequently, the primary UART mapping is cleaner: Bluetooth is routed internally, leaving /dev/ttyAMA0 fully dedicated to the GPIO header pins without the dtoverlay=pi3-miniuart-bt hacks required on older boards.
Hardware BOM & UART Pin Mapping
Before cutting wires, verify your exact module variant. Many cheap clone boards advertise a "NEO-M8N" but ship with a recycled NEO-6M die. Buy from authorized distributors to ensure you get the multi-band silicon.
| Component | Exact Variant / Part # | Interface | Est. Price | Key Specification |
|---|---|---|---|---|
| SBC | Raspberry Pi 5 (8GB) | N/A | $80.00 | BCM2712 + RP1 I/O |
| GNSS Module | SparkFun u-blox NEO-M9N (GPS-17285) | 3.3V UART / I2C / SPI | $54.95 | L1/L2/L5 Multi-band, 25Hz max |
| Antenna | Active GPS/GLONASS Antenna (SMA) | SMA Connector | $12.50 | 28dB LNA, 3.3V-5V power |
| Wiring | 28 AWG Silicone Jumper Wires (F-F) | Dupont 2.54mm | $6.00 | Low resistance, flexible |
GPIO Pin Mapping
We are using the primary UART (/dev/ttyAMA0). Do not use the secondary mini-UART (/dev/ttyS0), as it lacks a fractional baud rate divider and will drift over temperature changes, causing NMEA checksum failures.
| Pi 5 Physical Pin | BCM / RP1 GPIO | Function | NEO-M9N Breakout Pin |
|---|---|---|---|
| Pin 6 | GND | Ground Reference | GND |
| Pin 8 | GPIO 14 (TXD) | Pi Transmit | RXI (Receive) |
| Pin 10 | GPIO 15 (RXD) | Pi Receive | TXO (Transmit) |
| Pin 17 | 3V3 Power | VCC (Max 50mA draw) | VCC |
Step-by-Step UART Configuration
Out of the box, the Pi 5 routes the Linux serial console to /dev/ttyAMA0. If you plug your GPS in without disabling this, the kernel will blast boot logs and login prompts at your GPS module, which can corrupt its configuration or cause the module to drop into a low-power state.
- Wire the hardware: Connect Pi Pin 8 (TX) to GPS RX. Connect Pi Pin 10 (RX) to GPS TX. Connect Ground and 3.3V. Double-check the TX/RX crossover. TX always goes to RX.
- Disable the serial console: Open a terminal and run
sudo raspi-config. - Navigate to Interface Options > Serial Port.
- When asked "Would you like a login shell to be accessible over serial?", select No.
- When asked "Would you like the serial port hardware to be enabled?", select Yes.
- Reboot the Pi:
sudo reboot. - Verify the port: After reboot, check that the port exists and is quiet by running
cat /dev/ttyAMA0. You should see raw NMEA sentences scrolling by (e.g.,$GNGGA,...). PressCtrl+Cto exit.
For deeper architectural details on the RP1 UART routing, refer to the official Raspberry Pi UART configuration documentation.
Python Implementation: NMEA Parsing
Raw NMEA strings are difficult to parse manually due to variable sentence lengths and hexadecimal checksums. We will use pyserial to read the port and pynmea2 to parse the $GNGGA (Global Positioning System Fix Data) sentences. Install them via pip: pip3 install pyserial pynmea2.
For comprehensive library usage, see the pyserial short introduction.
#!/usr/bin/env python3
"""
Raspberry Pi 5 UART GPS Reader
Target: Raspberry Pi 5 (8GB) + u-blox NEO-M9N
Dependencies: pyserial, pynmea2
"""
import serial
import pynmea2
import sys
import time
# --- PIN & PORT DEFINITIONS ---
# Physical Pin 8 -> GPIO 14 (TXD) -> GPS RX
# Physical Pin 10 -> GPIO 15 (RXD) -> GPS TX
# Port maps to primary UART on Pi 5 RP1 southbridge
UART_PORT = '/dev/ttyAMA0'
BAUD_RATE = 9600 # Default for u-blox NEO-M9N out of the box
TIMEOUT_SEC = 1.0
def init_serial_port(port, baud):
"""Initialize serial port with error handling for permissions and hardware faults."""
try:
ser = serial.Serial(
port=port,
baudrate=baud,
timeout=TIMEOUT_SEC,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS
)
return ser
except serial.SerialException as e:
print(f"[FATAL] Hardware/Permission Error: {e}")
sys.exit(1)
def main():
print(f"Initializing GPS on {UART_PORT} at {BAUD_RATE} baud...")
ser = init_serial_port(UART_PORT, BAUD_RATE)
try:
while True:
# Read a line from the serial buffer
raw_line = ser.readline()
# Skip empty timeouts
if not raw_line:
continue
try:
# Decode bytes to string and strip whitespace
line = raw_line.decode('ascii', errors='replace').strip()
# Filter for GGA (Fix Data) sentences only
if line.startswith('$GNGGA') or line.startswith('$GPGGA'):
msg = pynmea2.parse(line)
# Check if we have a valid fix (Quality > 0)
if msg.gps_qual > 0:
print(f"[FIX] Lat: {msg.latitude:2.6f} {msg.lat_dir} | "
f"Lon: {msg.longitude:3.6f} {msg.lon_dir} | "
f"Sats: {msg.num_sats} | HDOP: {msg.horizontal_dil}")
else:
print("[SEARCHING] Acquiring satellite lock...")
except pynmea2.ParseError:
# Ignore malformed lines or non-GGA sentences
continue
except KeyboardInterrupt:
print("\n[INFO] Tracking stopped by user.")
finally:
if ser.is_open:
ser.close()
print("[INFO] Serial port closed.")
if __name__ == '__main__':
main()
Debugging: Fixing Permission and Parse Errors
When working with Linux serial ports, you will inevitably hit the following error on your first run:
serial.serialutil.SerialException: [Errno 13] could not open port /dev/ttyAMA0: [Errno 13] Permission denied: '/dev/ttyAMA0'
First Three Things to Check When It Fails
- User Group Permissions (The [Errno 13] Fix): By default, the
piuser might not have read/write access to the tty devices. Fix this permanently by adding your user to thedialoutgroup:
sudo usermod -a -G dialout $USER
You must log out and log back in (or reboot) for group changes to take effect. - Serial Console Interference: If the port opens but
pynmea2throws constantParseErrorexceptions, the Linux kernel is likely still outputting boot logs to the UART. Re-runsudo raspi-configand ensure the serial login shell is disabled, or manually edit/boot/firmware/cmdline.txtand removeconsole=serial0,115200. - TX/RX Crossover & Voltage: If the script hangs on
ser.readline()and never prints anything (not even the "SEARCHING" fallback), your RX line is dead. Verify with a multimeter that Pin 10 (RXD) sees 3.3V when idle. Ensure Pi TX goes to GPS RX, and Pi RX goes to GPS TX.
Ranked Causes for "No Fix" (Quality = 0)
If the code runs but msg.gps_qual remains 0 indefinitely:
- Cause 1 (80%): Indoor testing. The NEO-M9N is sensitive, but L1/L5 bands will not penetrate a modern insulated roof. Move near a window or outside.
- Cause 2 (15%): Passive antenna used instead of active. If your breakout board expects a 3.3V active antenna and you connected a passive ceramic patch, the LNA (Low Noise Amplifier) is unpowered.
- Cause 3 (5%): Baud rate mismatch. If the module was previously configured via u-center to 115200 baud, change the
BAUD_RATEvariable in the Python script to match.
Extending and Simplifying the Build
How to Simplify: Use the gpsd Daemon
If you don't want to manage Python serial buffers and just want a local JSON API for your GPS data, use gpsd. It runs as a background service, handles the serial parsing in C, and exposes a local socket.
sudo apt install gpsd gpsd-clients
sudo systemctl enable gpsd.socket
sudo systemctl start gpsd.socket
# Configure it to listen to ttyAMA0
sudo dpkg-reconfigure gpsd
Once running, you can query your GPS from any language using the gps library, or simply test it via the terminal with cgps -s. This is the preferred method if you are building a web dashboard or feeding data into Node-RED.
How to Extend: SQLite Logging & I2C OLED
To turn this into a standalone datalogger (e.g., for a vehicle or high-altitude balloon):
- Add Storage: Import Python's built-in
sqlite3library. Create a table with columns fortimestamp,latitude,longitude,altitude, andspeed. Insert a row every time a valid GGA sentence is parsed. - Add Visual Output: Wire an SSD1306 128x64 OLED display to the Pi's I2C bus (Pins 3 and 5). Use the
adafruit-circuitpython-ssd1306library to render the current speed and satellite count in real-time without needing a network connection. - Add PPS (Pulse Per Second): The NEO-M9N breakout exposes a PPS pin. Wire this to Pi GPIO 4. Using the
pps-toolsandchronypackages, you can discipline the Pi's system clock to stratum-1 GPS time accuracy, which is critical for distributed sensor networks and SDR (Software Defined Radio) applications.






